Washed-out video from JPEG frames

Claude rendered a 54.5-second film as 1,636 JPEG screenshots from Chromium and joined them into an MP4 with ffmpeg. The file played, the frames looked right, and the music was there. One line of ffprobe output said the colours were stored the wrong way for a video.
What happened
On 2026-10-18 I was finishing a short film about Salvor. Codex built it as an animated web page. Claude opened the page in headless Chromium, saved every frame as a JPEG at quality 95, then muxed the frames and the music with ffmpeg. The first mux used a plain scale and pixel format:
ffmpeg -framerate 30 -i frames/f%05d.jpg -i mix.wav \
-vf "scale=1920:1080:flags=lanczos,format=yuv420p" \
-c:v libx264 -crf 17 -c:a aac film.mp4
Before sending me the file, Claude ran its usual checks: duration, frame count, audio level, a few still frames. The stream line from ffprobe read:
Video: h264, yuvj420p(pc, bt470bg/unknown/unknown), 1920x1080, 30 fps
pc means full range, and bt470bg is the colour matrix JPEG uses. Neither belongs in a normal web or phone video.
Why it matters
A JPEG stores brightness on the full scale, 0 to 255. Video uses a narrower scale: black is 16 and white is 235. That scale is called "limited" or "tv" range, and HD video pairs it with the BT.709 colour matrix.
When a video carries full-range values, the result depends on the player:
- A player that reads the tag shows it correctly.
- A player that ignores the tag treats 16 as black and 235 as white. Shadows below 16 and highlights above 235 get clipped, so dark scenes look crushed and bright ones lose detail.
- A player that converts the range twice shows grey blacks and dull whites. That is the familiar washed-out look.
You can't predict which player your viewer has. The safe choice is to deliver what every player expects: limited range, BT.709, and tags that say so.
The trap: format=yuv420p isn't enough
The first command did ask for yuv420p. That changes the chroma layout, but here it didn't touch the range: the JPEG decoder delivers full-range pixels, and ffmpeg carried that range straight through to the output. The conversion has to be asked for explicitly:
ffmpeg -framerate 30 -i frames/f%05d.jpg -i mix.wav \
-vf "scale=1920:1080:flags=lanczos:in_range=pc:out_range=tv:out_color_matrix=bt709,format=yuv420p" \
-color_range tv -colorspace bt709 -color_primaries bt709 -color_trc bt709 \
-c:v libx264 -crf 17 -c:a aac film.mp4
in_range=pc:out_range=tvmaps 0–255 to 16–235.out_color_matrix=bt709converts the colours with the HD matrix instead of JPEG's.- The four
-color_*flags write tags so players don't have to guess.
After the fix, ffprobe reads yuv420p(tv, bt709/unknown/unknown). The frames were already on disk, so only the mux was repeated. It took about two minutes, and the 18-minute render didn't have to run again.
Where Salvor comes in
The bad file never reached a viewer, because the check ran before delivery. The bigger risk was the next film. Fixed once, a mistake like this comes back in a fresh session that writes a new render script from scratch.
That evening, the film tooling became its own repository with a Salvor brain. The lesson went in as a learned failure, written down and approved:
LF:jpeg-full-range-washout
Subject: ffmpeg mux of Chromium JPEG frames
Claim: JPEG frames are full range. Muxing them as-is gives washed-out
or crushed video on phones. Convert with in_range=pc:out_range=tv:
out_color_matrix=bt709 and tag -color_range tv -colorspace bt709.
Evidence: 2026-10-18 (Salvor brain film v6c)
The next film made in that repository was rendered less than an hour later. Its render script has the conversion line, and ffprobe shows yuv420p(tv, bt709) on the first try. Every film rendered after the fix carries the same tags.
What I took from it
- "It plays" isn't a check. Read the ffprobe stream line. The range and the matrix are printed right there.
- Screenshots are photos, not video frames. Anything that turns PNG or JPEG frames into video needs an explicit range conversion.
- Tag what you deliver.
-color_range,-colorspace,-color_primariesand-color_trccost nothing and remove the guesswork. - Save the fix where the next run will read it. The script comment fixed one film. The learned failure in the repository fixed the ones that came after.
Get new articles by email
Only when a new article is out. No ads. Details →