Technical note
Both streams start at 0.000. The content is still 120 ms apart.
A small, reproducible test of the gap between what a file reports and what it contains.
What ffprobe reported
I made two 10-second MP4 files and asked ffprobe about them. It reported the same codecs, the same durations and the same stream start times for both: video 0.000, audio 0.000.
| File | Stream | start_time | duration |
|---|---|---|---|
| good.mp4 | video | 0.000000 | 10.000000 |
| good.mp4 | audio | 0.000000 | 10.000000 |
| bad.mp4 | video | 0.000000 | 10.000000 |
| bad.mp4 | audio | 0.000000 | 10.000000 |
The files are not identical, though. Their hashes differ, and so does their audio. In one file, each tone starts on the same frame as its visual flash. In the other, each tone starts 120 ms after its flash.
ffprobe is not wrong here. It answered the question it was asked. It just was not the question that mattered.
The test
Each file has a white video frame and a 50 ms, 1 kHz tone at every whole second, both generated with FFmpeg's built-in sources. For the second file, I delayed the audio content by 120 ms before muxing:
V="color=c=black:s=640x360:r=30:d=10,drawbox=x=0:y=0:w=iw:h=ih:c=white:t=fill:enable='lt(mod(t+0.001\,1)\,0.02)'"
A="aevalsrc='0.5*sin(2*PI*1000*t)*lt(mod(t\,1)\,0.05)':s=48000:d=10"
ffmpeg -f lavfi -i "$V" -f lavfi -i "$A" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 128k -t 10 good.mp4
ffmpeg -f lavfi -i "$V" -f lavfi -i "$A,adelay=120:all=1" \
-c:v libx264 -pix_fmt yuv420p -c:a aac -b:a 128k -t 10 bad.mp4Then I measured two things. First, the container view: each stream's start_time. Second, the content: when each flash appears (a frame's mean brightness jumps, via FFmpeg's signalstats) and when each tone starts (the end of a silence, via silencedetect). Each tone onset is paired with the flash before it. The full script is in the evidence repository.
The result
measured_output.txt.| File | Stream start (video / audio) | Content offset, every event |
|---|---|---|
| good.mp4 | 0.000 / 0.000 s | 0.0 ms (9 events) |
| bad.mp4 | 0.000 / 0.000 s | 120.0 ms (10 events) |
The container view cannot tell the files apart. The content measurement recovers the injected delay exactly, at every event.
good.mp4 has one event fewer because its first tone starts the file, so there is no silence before it for silencedetect to end.
Why the metadata cannot see it
"Sync" is being used for two different things:
- Stream timestamps: when each stream's first frame or sample is scheduled on the container's timeline. That is what
start_timereports. - Content-event alignment: whether a sound and the picture event it belongs to land at the same moment.
If the audio is already late inside the stream, the container will still schedule that stream at 0.000. It is reporting accurately on the wrong layer.
The 0.000 is also partly constructed. In both files, FFmpeg's MP4 muxer wrote an edit list for each stream so the timeline starts cleanly at zero despite the encoders' startup delay. That is normal and correct, but it is another reminder that start_time describes the container's timeline, not the content.
A correction I made along the way
The first version of this test gated the tone with FFmpeg's volume filter. That filter evaluates its expression once per 1,024-sample audio frame, so tone onsets in both files landed 0 to 19 ms late, varying from second to second. The difference between the files was still exactly 120 ms, but the "good" file did not measure as 0 ms. Explaining that away would have been easy.
Instead, I replaced the generator with a sample-accurate one (aevalsrc) and re-ran everything. The numbers here come from that version, and the evidence repository records the correction.
This is the part I would most like readers to take away. A clean result from a test you have not examined is weak evidence. The first job is to make sure the test measures what you think it measures.
What this shows, and what it does not
It shows a mechanism: identical stream metadata can sit on top of misaligned content, and measuring events exposes the difference.
It does not show:
- A lip-sync method for real footage. Synthetic markers are far easier to detect than faces and voices. On real material, the practical anchors are a slate or handclap, or a sharp sound with a visible cause, checked at the start, middle and end so you can tell a constant offset from drift.
- How common offsets are, or how large an offset viewers notice. That depends on content and viewing conditions, and this test makes no perceptual claim.
Where this applies
Metadata checks are the easiest layer to automate, so they are often the only one that gets automated. They are still worth running: codec, frame rate, sample rate and duration errors are real. But they sit beside other layers, not above them:
- content-event sync
- loudness measured against the destination's current spec
- one full playback at normal speed
- a listen on the device the audience actually uses
A file can pass the first layer and fail any of the others.
The same pattern shows up well outside video. A plug-in that reports zero latency still has to line up in a null test. A build labeled "universal" still has to contain both architectures. A green check is only as good as the question it asks.
This is the principle I try to apply in software and audio work: measure the behavior you actually care about, not the proxy that happens to be easy to read.
Claim discipline
- Observed: both files report identical stream start times (0.000) and durations. Their content events differ by 120 ms.
- Measured: 0.0 ms (good, 9 events) and 120.0 ms (bad, 10 events). The files regenerate byte-identically on FFmpeg 9.0.1.
- Inferred: real-world offsets have many causes. This test demonstrates the mechanism, not how often offsets happen or how visible they are.
- Recommended: when timing matters, measure it on content events, not on stream metadata alone.