All articles

Washed-out video from JPEG frames

3 min readNotes

nitrateX Building with agents

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.

JPEG levels: as muxed and convertedTwo left-to-right lanes compare JPEG full-range video with range-converted BT.709 video. Packets cross ffmpeg and a player. The upper result dims and clips its ends; the converted result keeps its full bar and earns a check. Repeats every ten seconds.As muxedJPEG · 0–255ffmpegexpects 16–235ConvertedJPEG · 0–255ffmpegexpects 16–2350–255 → 16–235 · BT.709
The same JPEG frames, muxed as-is and converted to video range.

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=tv maps 0–255 to 16–235.
  • out_color_matrix=bt709 converts 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_primaries and -color_trc cost 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.

Share on X

nitrateX Building with agents

Get new articles by email

Only when a new article is out. No ads. Details →