mediacodec.dev

Colour spaces, and why your video looks washed out

Rec.709, limited vs full range, and the tagging mistakes that drain the contrast out of an export without touching a pixel.

Updated

You export a clip, open it next to the source, and the export looks flat. Blacks are grey, whites are dull, the whole thing has less bite. Nothing in your command mentioned colour. This is the most common colour bug in video, and it is almost never a compression problem: it is a labelling problem.

Pixels versus their instructions

A video file stores numbers. What those numbers mean comes from metadata attached to the stream, and there are three separate tags that matter:

  • Primaries. Which physical red, green and blue the numbers refer to. Rec.709 for HD, Rec.601 for standard definition, Rec.2020 for wide-gamut.
  • Transfer characteristics. The curve mapping stored values to light, usually called gamma. Rec.709’s curve for SDR, PQ or HLG for HDR.
  • Matrix coefficients. How the Y, Cb and Cr planes convert back to RGB. This is the tag that pairs with chroma subsampling.

Change a tag and no pixel value changes. Only the instructions for interpreting them change, and the picture on screen moves accordingly. That is why colour bugs feel spooky: the file is “the same” and looks different.

Limited versus full range

This is the one that causes the washed-out export, so it deserves the space.

Video traditionally does not use the full 0 to 255 range of an 8-bit value. Limited range (also called TV or studio range) puts black at 16 and white at 235, reserving the ends as headroom for signals that overshoot. Full range (PC range) uses 0 to 255 with nothing held back.

Both are legitimate. The damage happens when the file is tagged one way and was actually encoded the other:

  • Full-range pixels tagged as limited: the player stretches 16–235 out to 0–255, and since your blacks were already at 0 they clip while everything else gains contrast. Crushed shadows, blown highlights.
  • Limited-range pixels tagged as full: the player takes 16 to mean “not quite black” and 235 to mean “not quite white.” Blacks turn grey, whites turn beige. This is the washed-out export.

Screen recordings and anything from a graphics pipeline tend to be full range. Camera footage tends to be limited. A tool that assumes wrongly on the way in or out produces exactly this.

Inspecting before guessing

ffprobe -v error -select_streams v:0 \
  -show_entries stream=color_range,color_space,color_primaries,color_transfer \
  -of default=noprint_wrappers=1 input.mp4

Empty values are the real danger. An untagged file is not neutral, it is ambiguous, and every player fills the blank with its own assumption. Two players disagreeing about the same file almost always means missing tags.

Fixing it

If the pixels are right and only the labels are wrong, correct the labels and copy the stream. No re-encode, no generation loss:

ffmpeg -i in.mp4 -c copy \
  -color_range tv -colorspace bt709 \
  -color_primaries bt709 -color_trc bt709 out.mp4

If the pixels genuinely are in the wrong range, that is a conversion and needs an encode:

ffmpeg -i in.mp4 -vf scale=in_range=full:out_range=tv \
  -c:v libx264 -crf 18 -c:a copy out.mp4

Diagnose which case you have before running either. Applying the second when you needed the first shifts the picture twice and makes it worse.

The SD-to-HD trap

Rec.601 and Rec.709 use different matrix coefficients. Run standard-definition footage through a pipeline that assumes HD and the conversion back to RGB uses the wrong numbers, producing a subtle but persistent colour shift, usually visible first in skin tones. It is not a large error, which is precisely why it survives review and ships.

HDR is a different problem

Everything above is metadata. HDR is not. Rec.2020 primaries with a PQ transfer curve describe a genuinely wider range of colour and brightness than an SDR display can show, so getting HDR content onto an SDR screen is a real conversion called tone mapping, with actual decisions about how to compress the highlights.

Tagging HDR content as SDR does not convert it. It produces the grey, desatured result people mistake for a washed-out export, and no amount of relabelling fixes it. If the source is HDR and the target is not, you need a tone map, and you should look at the result rather than trusting the command exited zero.

What to remember

When an export looks flat, check color_range first; it explains most of these cases in one line. Remember that fixing a tag is free and fixing pixels costs an encode, so identify which one is wrong before you convert anything. And treat an untagged file as a bug waiting to happen, because every tool downstream will guess, and they will not all guess the same way. The transcode lab is a quick way to see how your own browser renders a file before you go further.