Streamline MP4 streaming in Java with Qtfaststart
Use qtfaststart-java to move a completed MP4’s playback index ahead of its media data without
re-encoding. This walkthrough builds a Java command-line wrapper, keeps the library’s writes in a
private temporary directory, and compares the result with the source before you serve it.
Understand what fast start changes
MP4 files store track and timing information in the moov atom and media payload in mdat atoms.
When moov follows the media, a player may need an additional HTTP range request to retrieve the
index, or wait for the download when range requests are unavailable. Moving the index forward makes
it available near the start of a sequential download.
This is file-layout optimization for progressive MP4 streaming. It changes neither the encoded bitrate nor the codecs, and it does not create adaptive streaming renditions. Playback startup still depends on the player, buffering, and the HTTP server; this example makes no startup-time benchmark. FFmpeg’s MOV/MP4 muxer documentation distinguishes fast start from fragmented output.
Set up the Java project
The upstream project implements the relocation in pure
Java. Its 0.1.0 tag dates from 2014, and its installation instructions still name JCenter. Use
the pinned JitPack artifact below; do not substitute
a similarly named library. This is a legacy dependency, not a general MP4 validator.
Use Linux with Bash and a filesystem that supports hard links. Install a maintained
JDK 21 build, Maven,
and a package containing both FFmpeg and FFprobe. The verification
script also needs cmp, mktemp, and rm. The tested versions are OpenJDK 21.0.12.1, Maven 3.10.0
and 3.9.11, and FFmpeg/FFprobe 9.0.1; these are tested versions, not intrinsic minimums.
Start in a writable directory. Check every executable before creating the project:
(
for tool in java javac mvn ffmpeg ffprobe cmp mktemp rm mkdir; do
command -v "$tool" >/dev/null 2>&1 || {
printf 'Missing prerequisite: %s\n' "$tool" >&2
exit 1
}
done
java -version && javac -version &&
MAVEN_SKIP_RC=true MAVEN_BASEDIR="$PWD/qtfaststart-demo" MAVEN_ARGS= mvn -version &&
ffmpeg -version && ffprobe -version &&
mkdir qtfaststart-demo &&
mkdir -p qtfaststart-demo/.mvn qtfaststart-demo/src/main/java
)
An existing qtfaststart-demo makes setup stop. Keep its files and use a different parent directory
for a fresh start. If the second mkdir fails, fix the directory permissions and create the two
missing subdirectories in that same project before continuing. The subshell keeps your current
directory and shell options unchanged.
Save this complete pom.xml in qtfaststart-demo/:
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>local.example</groupId>
<artifactId>qtfaststart-demo</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<repositories>
<repository>
<id>jitpack.io</id>
<url>https://jitpack.io</url>
</repository>
</repositories>
<dependencies>
<dependency>
<groupId>com.github.ypresto</groupId>
<artifactId>qtfaststart-java</artifactId>
<version>0.1.0</version>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.14.1</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-dependency-plugin</artifactId>
<version>3.8.1</version>
</plugin>
</plugins>
</build>
</project>
Save settings.xml beside it. The build uses these settings instead of your personal Maven
settings and downloads dependencies into the project’s .m2-repository. Its own .mvn directory
and explicit base directory isolate it from an enclosing project’s Maven configuration.
<settings xmlns="http://maven.apache.org/SETTINGS/1.2.0"/>
Save the wrapper and run it
Use a known-good, completed, non-fragmented MP4 with uncompressed moov metadata. H.264 video and
AAC audio are the tested combination, including multiple tracks. Keep the input and output parent
directories stable during processing, and allow disk space for a second copy. The library loads
moov into memory; this example is for trusted local files, not untrusted uploads or repairing
damaged recordings.
Save OptimizeMp4.java in qtfaststart-demo/src/main/java/:
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.LinkOption;
import java.nio.file.Path;
import net.ypresto.qtfaststart.QtFastStart;
import net.ypresto.qtfaststart.QtFastStart.QtFastStartException;
public class OptimizeMp4 {
public static boolean optimize(Path input, Path output)
throws IOException, QtFastStartException {
input = input.toRealPath();
if (!Files.isRegularFile(input)) {
throw new IOException("Input must be a regular file: " + input);
}
output = output.toAbsolutePath();
if (Files.exists(output, LinkOption.NOFOLLOW_LINKS)) {
throw new IOException("Output already exists: " + output);
}
Path directory = Files.createTempDirectory(output.getParent(), ".qtfaststart-");
Path temporary = directory.resolve("optimized.mp4");
try {
boolean moved = QtFastStart.fastStart(input.toFile(), temporary.toFile());
if (!moved) return false;
if (Files.size(temporary) != Files.size(input)) {
throw new IOException("Relocation produced an incomplete copy");
}
Files.createLink(output, temporary);
return true;
} finally {
Files.deleteIfExists(temporary);
Files.delete(directory);
}
}
public static void main(String[] args) {
if (args.length != 2) {
System.err.println("Usage: OptimizeMp4 INPUT.mp4 OUTPUT.mp4");
System.exit(64);
}
try {
if (!optimize(Path.of(args[0]), Path.of(args[1]))) {
System.err.println("No output created: already front-indexed or unsupported layout."
+ " This does not validate the input.");
System.exit(2);
}
System.out.println("Relocated MP4 saved to " + args[1] + "; verify it before serving.");
} catch (IOException | QtFastStartException | RuntimeException e) {
System.err.println("Failed for " + args[0] + ": " + e.getMessage());
System.exit(1);
}
}
}
Copy your source to qtfaststart-demo/input.mp4, then paste this from the parent directory:
(
cd qtfaststart-demo &&
MAVEN_SKIP_RC=true MAVEN_BASEDIR="$PWD" MAVEN_ARGS= \
mvn -B -s settings.xml -gs settings.xml -Dmaven.repo.local=.m2-repository \
clean package dependency:copy-dependencies &&
java -cp 'target/classes:target/dependency/*' OptimizeMp4 \
./input.mp4 ./output_optimized.mp4
)
The dependency-copy goal supplies the JAR consumed by the printed classpath. If dependency resolution
or compilation fails, the && prevents an older build from running. Fix that error and repeat the
block. On relocation, the program creates output_optimized.mp4 and exits zero. Exit two leaves no
output; exit one reports a processing or filesystem failure. A rerun refuses the existing output.
The 0.1.0 implementation opens its output for writing and deletes it when it does not finish a relocation. Passing an existing file directly to the library can destroy it. The wrapper gives it only a private path, then uses Files.createLink to publish the completed file. On the tested Linux filesystem, an existing destination, including a dangling symbolic link or a destination created during processing, is refused without replacement.
Verify the layout and media
A zero return from the wrapper establishes relocation, not intact media. Save this as
qtfaststart-demo/verify-mp4.sh. It checks the top-level atom order reported by FFprobe, compares every
stream and packet’s timing and encoded-data checksum, and compares all decoded video frames and
audio samples. Use explicit local paths beginning with /, ./, or ../ for both arguments.
#!/usr/bin/env bash
set -euo pipefail
if [ "$#" -ne 2 ]; then
printf 'Usage: bash verify-mp4.sh INPUT.mp4 OUTPUT.mp4\n' >&2
exit 64
fi
input=$1
output=$2
for path in "$input" "$output"; do
case "$path" in
/*|./*|../*) ;;
*) printf 'Use an explicit local path: %s\n' "$path" >&2; exit 64 ;;
esac
done
work=$(mktemp -d)
trap 'rm -rf -- "$work"' EXIT
ffprobe -v trace -i "$output" -show_entries format=format_name \
-of default > /dev/null 2> "$work/atoms.log"
seen_moov=false
seen_mdat=false
while IFS= read -r line; do
case "$line" in
*"type:'moov' parent:'root'"*)
[ "$seen_moov" = false ] || exit 1
seen_moov=true ;;
*"type:'mdat' parent:'root'"*)
[ "$seen_moov" = true ] || { printf 'moov follows media\n' >&2; exit 1; }
seen_mdat=true ;;
*"type:'moof' parent:'root'"*) printf 'Fragmented MP4 is outside this example\n' >&2; exit 1 ;;
esac
done < "$work/atoms.log"
[ "$seen_moov" = true ] && [ "$seen_mdat" = true ]
entries='stream=index,codec_type,codec_name,profile,width,height,pix_fmt,sample_rate,channels,channel_layout,time_base,start_pts,start_time,duration_ts,duration,nb_frames,extradata_hash:stream_tags:stream_side_data:packet=stream_index,pts,dts,duration,size,flags,data_hash'
ffprobe -v error -show_entries "$entries" -show_data_hash sha256 \
-of json -i "$input" > "$work/input.json"
ffprobe -v error -show_entries "$entries" -show_data_hash sha256 \
-of json -i "$output" > "$work/output.json"
cmp "$work/input.json" "$work/output.json"
ffmpeg -nostdin -v error -xerror -threads 1 -noautorotate -i "$input" \
-map 0:v? -map 0:a? -c:v rawvideo -c:a pcm_s16le -threads 1 \
-f framemd5 "$work/input.framemd5"
ffmpeg -nostdin -v error -xerror -threads 1 -noautorotate -i "$output" \
-map 0:v? -map 0:a? -c:v rawvideo -c:a pcm_s16le -threads 1 \
-f framemd5 "$work/output.framemd5"
cmp "$work/input.framemd5" "$work/output.framemd5"
printf 'Verified: moov precedes media; stream data, timing, and decoded audio/video match.\n'
Run the saved script as a separate Bash process; do not paste its set and exit statements into
your current shell:
(
cd qtfaststart-demo &&
bash verify-mp4.sh ./input.mp4 ./output_optimized.mp4
)
Success prints Verified: moov precedes media; stream data, timing, and decoded audio/video match. The input and output file hashes generally differ because the index moves and chunk offsets change. The checks compare media and timing instead. Disabling automatic rotation makes the decoded-frame comparison consistent; stream side data checks that rotation metadata is retained. A matching result cannot establish that the original recording was undamaged, and it does not measure browser playback.
Handle no output and failures
The library’s last-atom check
returns false when scanning does not end at moov. That includes already front-indexed files, but
also unsupported layouts and some malformed inputs. Do not translate false into “already optimized”
or serve a nonexistent output. Inspect the source separately or choose another processing path.
Compressed moov metadata and certain chunk-offset overflows are unsupported. Malformed files may
throw a library exception, an I/O exception, or an unchecked exception. If verification reports
decoding errors or differences, keep the source and withhold the output from serving. The wrapper’s
size check catches incomplete copies; equal size alone does not establish correct media.
An unwritable or missing output parent, lack of hard-link support, or an existing output causes a
nonzero exit. The finally block removes the private temporary file and directory on ordinary
failure. A killed JVM or filesystem failure during cleanup can leave a .qtfaststart-* directory;
remove only that invocation’s temporary directory after confirming its process has stopped.
Alternative approaches
If FFmpeg is already part of your processing pipeline, its fast-start muxer option avoids adding a legacy Java dependency. For the same local input, remux all streams without re-encoding:
(
cd qtfaststart-demo &&
ffmpeg -nostdin -n -i ./input.mp4 -map 0 -c copy -movflags +faststart ./output_ffmpeg.mp4
)
-n leaves an existing output untouched, and -map 0 selects every stream. On FFmpeg 9.0.1,
the existing-file refusal returned zero with an error diagnostic, so use a new destination and
inspect the result rather than relying on status alone. The codecs must be supported by the MP4
muxer. This is a remux: FFmpeg can rewrite container metadata and timing, so the strict comparison
above is for the Java relocation, not a general FFmpeg acceptance test.
Continue with MP4 delivery
Use the qtfaststart-java GitHub repository to inspect the dependency, the FFmpeg format reference for muxer options, and MDN’s MP4 container guide for container and codec background. For a broader encoding workflow, see Transloadit’s 🤖 /video/encode Robot.
