Skip to content

Preserve color and HDR

current

Color mistakes rarely make an encode fail. They make it look slightly wrong on some players, and nobody notices until later. The rule is simple: find out what the source really is, keep it that way through the pipeline, and tag the output to match the pixels you actually wrote.

Terminal window
ffprobe -v error -select_streams v:0 -show_entries stream=pix_fmt,color_range,color_space,color_transfer,color_primaries,chroma_location -of default=nw=1 input.mkv

If everything is filled in and plausible, preserve it. If fields are unknown, work out the truth from where the video came from: DVDs are BT.601 (smpte170m for NTSC, bt470bg for PAL), HD Blu-ray and HD broadcast are BT.709, UHD Blu-ray HDR is BT.2020 + PQ. A 1080p file that was upscaled from DVD can still carry BT.601 colors. See the colorimetry reference for what each field means.

Current FFmpeg passes the decoded frames’ primaries, transfer, matrix, range and chroma location to the encoder, and (since 7.0) passes HDR10 mastering-display and content-light metadata through to libx264, libx265 and libsvtav1; libaom-av1 also passes it. A plain transcode keeps them:

Terminal window
ffmpeg -i input.mkv -c:v libsvtav1 -crf 30 -preset 6 -c:a copy output.mkv

What breaks preservation:

  • Dropping bit depth. -pix_fmt yuv420p on a 10-bit HDR source keeps the HDR tags but quantizes PQ to 8 bits, which bands badly. Keep HDR at 10-bit.
  • Filters that change color without retagging, or third-party tools in the middle of the pipeline (see standalone encoders below).
  • Dynamic metadata. HDR10+ and Dolby Vision need extra steps; see HDR10+ and Dolby Vision.

Use setparams to change the tags on the frames without touching the samples. For an untagged NTSC DVD encode:

Terminal window
ffmpeg -i input.mkv -vf setparams=color_primaries=smpte170m:color_trc=smpte170m:colorspace=smpte170m:range=tv -c:v libx264 -crf 18 output.mkv

To retag without re-encoding, rewrite the bitstream’s VUI or sequence header with a bitstream filter, and pass the same values as output options so the container agrees:

Terminal window
ffmpeg -i input.mkv -c copy -bsf:v h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1 -color_primaries bt709 -color_trc bt709 -colorspace bt709 output.mkv

hevc_metadata takes the same options and av1_metadata uses color_primaries, transfer_characteristics, matrix_coefficients and color_range; values are the H.273 numbers. The bitstream filter doesn’t touch the container: from a Matroska or MP4 input FFmpeg copies the old color fields (the Matroska Colour elements, the MP4 colr box), and ffprobe and other tools that read them keep reporting the old values. With -c copy the output options only set those fields.

To fix a Matroska file that already has stale values, edit it in place with mkvpropedit; remuxing with mkvmerge doesn’t help, because it keeps the container values of a Matroska input:

Terminal window
mkvpropedit output.mkv --edit track:v1 --set color-transfer-characteristics=1 --set color-primaries=1 --set color-matrix-coefficients=1

Upscaling a DVD to 1080p and tagging it BT.709 without converting shifts the colors (greens and skin tones most visibly). Convert while you scale. zscale takes the input interpretation explicitly, which is what you want for an untagged source:

Terminal window
ffmpeg -i dvd.mkv -vf zscale=w=1440:h=1080:f=spline36:min=170m:pin=170m:tin=601:m=709:p=709:t=709 -c:v libx264 -crf 18 output.mkv

For PAL use min=470bg:pin=bt470bg. The matrix is the part that visibly matters; the transfer function is the same and the primaries differ only slightly. The built-in scale filter converts only the matrix, so keep the source’s primaries and transfer tags when you use it:

Terminal window
ffmpeg -i dvd.mkv -vf scale=1440:1080:in_color_matrix=bt601:out_color_matrix=bt709,setparams=color_primaries=smpte170m:color_trc=smpte170m -c:v libx264 -crf 18 output.mkv

In VapourSynth, declare the source properties if the source filter didn’t set them, then let resize convert:

clip = core.std.SetFrameProps(clip, _Matrix=6, _Primaries=6, _Transfer=6, _Range=0)
clip = core.resize.Spline36(clip, 1440, 1080, matrix_s="709", primaries_s="709", transfer_s="709")

_Range uses 0 for limited and 1 for full, like H.273. The older _ColorRange property, now deprecated, used the opposite numbering.

Terminal window
ffmpeg -i full.mkv -vf scale=out_range=limited,format=yuv420p -c:v libx264 -crf 18 output.mkv

The format=yuv420p matters: FFmpeg decodes full-range 8-bit H.264/HEVC as the legacy yuvj420p format, which cannot hold limited range. Without it scale silently does nothing, and zscale converts the samples but leaves the output tagged full range. See color range for diagnosing range problems.

That is tone mapping, not a tag change. See tone mapping to SDR.

Standalone encoders: tag everything yourself

Section titled “Standalone encoders: tag everything yourself”

Y4M carries no primaries, transfer or matrix. When you pipe from FFmpeg or VapourSynth into x264, x265, SvtAv1EncApp or aomenc, the encoder knows nothing about the source’s color unless you pass it. FFmpeg also needs -strict -1 to write 10-bit or 12-bit Y4M.

Propertyx264 / x265SvtAv1EncAppaomenc
Primaries--colorprim bt709--color-primaries bt709--color-primaries=bt709
Transfer--transfer bt709--transfer-characteristics bt709--transfer-characteristics=bt709
Matrix--colormatrix bt709--matrix-coefficients bt709--matrix-coefficients=bt709
Range--range tv (x264), --range limited (x265)--color-range studioread from Y4M only
Chroma location--chromaloc 0--chroma-sample-position left--chroma-sample-position=vertical
HDR10 valuesbt2020, smpte2084, bt2020ncbt2020, smpte2084, bt2020-nclbt2020, smpte2084, bt2020ncl

Range is the trap. FFmpeg writes XCOLORRANGE=FULL into the Y4M header for full-range video; x264 and aomenc read it, but x265 4.3 and SvtAv1EncApp 4.2 ignore it and tag the output as limited. Pass the range explicitly to those two.

A 10-bit HDR10 pipe into x265:

Terminal window
ffmpeg -i input.mkv -strict -1 -f yuv4mpegpipe - | x265 --y4m --input - --output-depth 10 --crf 18 --preset slow \
--colorprim bt2020 --transfer smpte2084 --colormatrix bt2020nc --range limited --chromaloc 2 \
--master-display "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)" \
--max-cll "1000,400" -o output.hevc

--output-depth 10 is needed because multi-bit-depth x265 builds encode at 8 bits unless told otherwise, even when the input is 10-bit. Copy the mastering-display and MaxCLL values from the source — see HDR10 metadata. With aomenc, use --passes=1 when reading from a pipe; its default two-pass mode tries to read the input twice.

Read the source values from the first frame:

Terminal window
ffprobe -v error -select_streams v:0 -read_intervals "%+#1" -show_frames input.mkv

ffprobe prints chromaticities and luminances as fractions (red_x=34000/50000, max_luminance=10000000/10000, max_content=1000). Each encoder wants them in a different form:

EncoderMastering display (P3-D65, 1000 / 0.0001 nits)Content light (MaxCLL, MaxFALL)
x265, -x265-params--master-display "G(13250,34500)B(7500,3000)R(34000,16000)WP(15635,16450)L(10000000,1)" — x,y × 50000; luminance × 10000--max-cll "1000,400"
x264--mastering-display with the same integer format as x265--cll "1000,400"
SVT-AV1, -svtav1-params--mastering-display "G(0.265,0.690)B(0.150,0.060)R(0.680,0.320)WP(0.3127,0.3290)L(1000,0.0001)" — plain x,y and nits--content-light 1000,400
aomencno option; use FFmpeg’s libaom-av1, which passes the source’s metadata—

In -x265-params and -svtav1-params, drop the leading dashes and separate options with colons (master-display=G(…)…:max-cll=1000,400). The order is G, B, R, then white point. For a BT.2020 mastering display the x265 primaries are G(8500,39850)B(6550,2300)R(35400,14600). Use the numbers from your source, not from an example: the mastering display’s peak is a property of the grade, and MaxCLL/MaxFALL are measured from the content. When a source has none, leave them out rather than inventing them.

In FFmpeg you usually don’t need these options at all, because the metadata is passed through. Set them via -x265-params or -svtav1-params only when the source lacks them or you are fixing them.

Dynamic metadata is per frame, so it only survives if the encoder writes it for each frame and the frames still line up with the source.

Re-encode with FFmpegStandalone workflow
HDR10+ to HEVCDropped by libx265. Extract with hdr10plus_tool, then pass -x265-params dhdr10-info=metadata.jsonx265 --dhdr10-info metadata.json, or inject into the encoded stream with hdr10plus_tool inject
HDR10+ to AV1Passed through by libaom-av1 (FFmpeg 8.0 and later); dropped by libsvtav1—
Dolby Vision to HEVC-dolbyvision 1 with libx265 writes profile 8.x; x265 also needs VBV (vbv-maxrate, vbv-bufsize)Extract the RPU with dovi_tool, then x265 --dolby-vision-profile 8.1 --dolby-vision-rpu RPU.bin or dovi_tool inject-rpu
Dolby Vision to AV1-dolbyvision 1 with libsvtav1 or libaom-av1 writes profile 10— (dovi_tool handles HEVC only)

Profile 7 (dual-layer) sources cannot be passed through by FFmpeg; convert the RPU to 8.1 with dovi_tool first. Any trim, crop, frame-rate change or frame drop must be applied to the metadata as well. See HDR formats for what the profiles mean.

When muxing Dolby Vision into MP4 with FFmpeg, add -strict unofficial; without it the Dolby Vision configuration box is not written and players treat the file as plain HDR10. mkvmerge writes it to Matroska by default.

Run the same two ffprobe commands on the finished file and compare them with the source. Then watch it in a player that honors the tags (mpv does) next to the source, paying attention to skin tones, saturated reds and greens, and shadow detail. Tags that match the source plus pixels that look the same are the goal: tags can match the source even when the samples were converted on the way.