# Preserve color and HDR

Canonical URL: https://encode.wiki/guides/preserve-color-and-hdr/
Content version: ca42e61ede0a24f789d7287a0001244ad22f4abf
Status: current
License: CC BY-SA 4.0
Checksum (SHA-256): f85643670578eef8a3959c1d78f2f67019f90d2729225308158793744aaba11f

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.

## Find out what the source is

```sh
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](/reference/colorimetry/intro/) for what each field means.

## Preserve: let FFmpeg carry the tags

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:

```sh
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](#hdr10-and-dolby-vision).

## Retag: fix missing or wrong tags

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

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

> **Caution — Don't use the output options to tag when encoding.**
>
> In current FFmpeg (7.1 and later, including 9.0) the encoder's color properties are taken from the
> filtered frames. When you re-encode, the output options behave differently from what most guides
> assume:
>
> - `-color_primaries` and `-color_trc` are overwritten by the frame values, so they do nothing.
> - `-colorspace` and `-color_range` are passed to filter negotiation, so FFmpeg auto-inserts a
>   scaler and **converts** the samples (an untagged frame is treated as BT.601 when converting to
>   `-colorspace bt709`). Depending on the pixel format they can also do nothing at all.
>
> Use `setparams` to tag and an explicit filter to convert. Encoder-private options such as
> `-x265-params colorprim=…` do set the bitstream tags, but FFmpeg's muxer then writes no Matroska
> color elements.
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:

```sh
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:

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

## Convert: change the samples, then tag

### SD BT.601 to HD BT.709

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:

```sh
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:

```sh
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:

```py
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.

### Full range to limited range

```sh
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](/reference/colorimetry/range/) for diagnosing range problems.

### HDR to SDR

That is tone mapping, not a tag change. See [tone mapping to SDR](/hdr/#tone-map-hdr-to-sdr).

## 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.

| Property        | x264 / x265                                   | SvtAv1EncApp                        | aomenc                              |
| --------------- | --------------------------------------------- | ----------------------------------- | ----------------------------------- |
| 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 studio`              | read from Y4M only                  |
| Chroma location | `--chromaloc 0`                               | `--chroma-sample-position left`     | `--chroma-sample-position=vertical` |
| HDR10 values    | `bt2020`, `smpte2084`, `bt2020nc`             | `bt2020`, `smpte2084`, `bt2020-ncl` | `bt2020`, `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:

```sh
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](#hdr10-static-metadata). With `aomenc`, use `--passes=1` when reading
from a pipe; its default two-pass mode tries to read the input twice.

## HDR10 static metadata

Read the source values from the first frame:

```sh
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:

| Encoder                   | Mastering 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`      |
| aomenc                    | no 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.

## HDR10+ and Dolby Vision

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 FFmpeg                                                                                                                   | Standalone workflow                                                                                                                                        |
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- |
| HDR10+ to HEVC       | Dropped by libx265. Extract with [hdr10plus_tool](/tools/utilities/hdr10plus_tool/), then pass `-x265-params dhdr10-info=metadata.json` | `x265 --dhdr10-info metadata.json`, or inject into the encoded stream with `hdr10plus_tool inject`                                                         |
| HDR10+ to AV1        | Passed 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](/tools/utilities/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](/hdr/) 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.

## Check the output

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.
