Every Player Stuttered on the Same Video. The File Was Blameless.

· 6 min read · devops ffmpegnvdechevctranscodingvideo

The question I could not answer

A converted video stuttered. Not badly — a hitch every few seconds, then it carried on. The kind of thing you blame on Wi-Fi.

Except it happened on an iPad Pro M1 playing the file offline, in VLC, and in Chrome on a desktop. Different decoders, different platforms, same periodic hitch. The original download played perfectly everywhere. So the network was out, the device was out, and the file was in.

I spent a long time measuring the file, and every measurement said the file was fine. It was not fine. It was a faithful re-encode of a decoder bug.

Step 1: measure the container, not the picture

The converted file is HEVC Main 8-bit in MP4 (hvc1), 3840×2160, 25 fps, yuv420p, bt709, average 15.49 Mbps, 232.8 seconds, 5819 frames, moov before mdat.

Everything about its timeline is textbook:

frame intervals       5818 of 5818 = exactly 0.040000 s
duplicate PTS         0
negative PTS          0
gaps > 1.5x median    0
edit list (ctts)      5796 entries  (not a flat list)
has_b_frames          2
A/V start offset      0.000 s
B-frame pattern       I B B B P B B B ...  (744 groups, all 3 B-frames)

A flat ctts — every presentation offset set to zero — is the classic way a re-encode ends up with PTS sitting in decode order, and it makes players drop roughly a quarter of the frames. That is what I expected to find. It was not there.

Full decode, both verbosity levels:

ffmpeg -v warning -i converted.mp4 -f null -
ffmpeg -v error   -i converted.mp4 -f null -

Both exit 0, both print nothing, 174 seconds of wall time for 232 seconds of video. No corrupt frames, no missing references, no non-monotonic DTS.

The GOP looked different from the source (24 keyframes, a constant 10.0 s, because hevc_nvenc defaults to -g 250; the source has 59 keyframes at an average 3.88 s) and the bitrate was 17% higher than the source — but the source’s own peak was higher than the output’s, so neither explains a hitch every few seconds.

Everything measurable in the container was correct. Which is the point where you have to stop measuring the container.

Step 2: compare pictures, from two different decoders

If the timing is right and the file still looks wrong, the question becomes whether the pictures are right. I compared the source file decoded two ways, frame by frame, with SSIM:

# software decode of the source
ffmpeg -i source.mkv -vf "scale=640:360" -f rawvideo -pix_fmt yuv420p sw.yuv

# the same frames through NVDEC's AV1 path
ffmpeg -c:v av1_cuvid -i source.mkv -vf "scale=640:360" -f rawvideo -pix_fmt yuv420p hw.yuv

106 frames out of 5819 disagree — 1.8% — with SSIM as low as 0.330. Not uniform noise: the interval histogram peaks at about every 30 frames.

Two control tests made it worse and better at the same time:

Then the decisive comparison — which side does the converted file match?

ComparisonMean SSIMMinimumFrames below 0.98
Converted HEVC vs software decode of the source0.990910.330106
Converted HEVC vs NVDEC decode of the source0.997420.9910

The converted file agrees with the wrong decode, frame for frame. It is not a bad encode. It is an accurate encode of the wrong pictures.

A single extracted frame shows it plainly: frame 546 through software decode is a wide shot of a choir; through av1_cuvid it is a close-up from a different moment in the video — and the converted file has the close-up.

Step 3: the fix is one line

The bug is not the hardware. It is the legacy decoder wrapper. av1_cuvid is an old-style wrapped decoder; the modern hardware path goes through hwaccel:

# before
-c:v av1_cuvid

# after
-hwaccel cuda -hwaccel_output_format cuda -c:v av1

Same stream, same source, 1500 frames compared against the software reference:

-hwaccel cuda -c:v av1        SSIM 1.000000   (bit-identical, every frame)
vp9_cuvid vs libvpx-vp9       SSIM 0.99991    (0 frames below 0.98 - unaffected)

Encoding speed did not change: a 30-second 4K slice in 24.1 s, 1.24× realtime either way.

To be precise about the scope: this is a bug in the *_cuvid wrapper for AV1 on this driver/library combination, reproducible on demand. It says nothing about your GPU’s AV1 decoder — the hardware path is clean.

Step 4: make it impossible to ship again

The uncomfortable part is that my pipeline had a verification step. It checked the duration, the resolution, the codec, the frame rate and the frame rhythm — all container properties. Every one of them passed on a file with 106 wrong frames.

So the pipeline is now five steps, and the fourth one is the picture check:

decode -> encode -> verify structure -> verify content -> replace the file

The content gate compares the output’s frames against a software decode of the source (both sides decoded sequentially, cropped by timestamp), and uses the defect’s shape rather than a single-frame threshold:

Those numbers are calibrated against measured data, not intuition. Good encodes in this library sit at 0 frames of 1500 below 0.95. The defective files sit at 2.9% of frames below 0.85 with a depressed mean. A perfectly good 4K60 encode of a dance video once measured min 0.949 — a single frame below 0.95, at a hard cut — which a single-frame threshold would have rejected forever.

Scoped to the library: 75 files had AV1 originals in the snapshots, 65 of them (87%) failed the content gate. All 65 were re-encoded from the pristine snapshot originals — never from the already-corrupted file — and every one landed with min SSIM 0.997–0.999.

What I would do differently

  1. “It decodes without errors” is not a quality check. Decoding a frame successfully says nothing about whether it is the right frame. The picture comparison is the check that found it. 2. Compare against a second decoder, not against your expectations. A software decode is free and it is the reference. If your hardware path disagrees with it, you have found something worth understanding. 3. Verify the output against the source, not the source’s metadata. 4. Do not assume the modern and legacy hardware paths are the same code. av1_cuvid and -hwaccel cuda -c:v av1 reach the same silicon through different plumbing. A bug in one is not a verdict on the hardware. 5. A gate that fails safe is worth more than a gate that never fails. My first gate rejected that 4K60 encode. The file was fine and the gate was wrong — but the failure cost one wasted encode, not a bad file in the library.

The stutter is gone. Not because the encoder got better, but because the decoder stopped lying.

Lee Teong Hoe

Lee Teong Hoe · Mr Hoelee

Full-stack developer & DevOps engineer based in Malaysia. I run my own production infrastructure and build web systems for small businesses — this blog is the living record.

Hiring? Profile & stack · Malaysian SME? Website design & development ↗ · All posts