ffmpegvideo-encodinghdr

FFmpeg HDR to SDR: Stop iPhone Uploads Coming Out Gray

·Javid Jamae·10 min read
FFmpeg HDR to SDR: Stop iPhone Uploads Coming Out Gray

An iPhone clip goes through your pipeline, comes out looking milky and desaturated, and nobody notices until it's live on TikTok. The source plays fine in Photos. The output plays fine in VLC on your MacBook. It only looks broken on the phones your audience is actually holding.

Quick answer: FFmpeg HDR to SDR conversion needs an explicit tone map, because -vf scale alone re-encodes PQ or HLG code values as if they were Rec. 709, and the clip ships gray and washed out. The working chain is zscale=t=linear:npl=100,format=gbrpf32le,zscale=p=bt709,tonemap=tonemap=hable:desat=0,zscale=t=bt709:m=bt709:r=tv,format=yuv420p on an FFmpeg build compiled with libzimg, plus -color_primaries bt709 -color_trc bt709 -colorspace bt709 on the output so players don't guess. If you'd rather not maintain a zimg-enabled FFmpeg build on every worker, FFmpeg Micro runs that same tone map as one API call with no servers to run.

The washed-out look is a transfer curve mismatch, not a bad encode

Everyone's first theory is that the encoder crushed the file. That's a reasonable guess, and it's wrong. Your bitrate is fine, your CRF is fine, and re-encoding at CRF 16 changes nothing.

What actually happened is that iPhone 12 and later record 10-bit HEVC in Dolby Vision Profile 8.4, which carries an HLG base layer. Modern Samsung and Pixel phones record HLG or PQ too. Those formats store brightness on a completely different curve than Rec. 709. HLG mid-tones sit lower and its highlights are compressed into the top of the range, on the assumption that a display will expand them back out. When an SDR player reads those code values with a standard BT.1886 curve instead, the picture arrives exactly as you'd expect: low contrast, lifted blacks, drained color.

FFmpeg's scale filter does not convert transfer characteristics. It resizes pixels. So ffmpeg -i IMG_4471.mov -vf scale=1080:1920 -c:v libx264 out.mp4 faithfully carries HDR code values into an SDR container, and the Dolby Vision RPU metadata gets dropped along the way because libx264 has nowhere to put it. Nothing errors. Exit code 0. The batch of 40 clips ships gray.

This shows up inside other tools too, not just hand-rolled pipelines. The video2x project has an open report (issue #1305) about HDR input coming out washed out after upscaling, which is the same missing tone map one layer up.

Detect HDR at ingest with ffprobe before you spend an encode on it

The check costs one ffprobe call against the file header and takes a few hundred milliseconds. Run it as the first step of ingest, the same place you'd check for a missing moov atom or a bad container.

ffprobe -v error -select_streams v:0 \
  -show_entries stream=color_transfer,color_primaries,color_space,pix_fmt \
  -of default=noprint_wrappers=1 IMG_4471.mov

An iPhone HDR clip answers like this:

color_space=bt2020nc
color_transfer=arib-std-b67
color_primaries=bt2020
pix_fmt=yuv420p10le

The rule to encode in your validator: a color_transfer of smpte2084 means PQ (HDR10 or Dolby Vision), arib-std-b67 means HLG, and anything else (bt709, unknown, empty) is SDR and needs no tone map. bt2020nc in color_space and yuv420p10le in pix_fmt are corroborating signals, not the decision. Some cameras write wide-gamut primaries on otherwise SDR footage, so branch on the transfer function alone.

For Dolby Vision specifically, ffprobe -show_streams prints a side data block: DOVI configuration record: version: 1.0, profile: 8, level: 4, rpu flag: 1. Profile 8.4 is the HLG-compatible one Apple uses, which is why the same file reports arib-std-b67. Handle it as HLG and you're right.

This is the same normalize-at-ingest posture as validating input before you trust it. Reject or branch at the door, never mid-render.

The zscale and tonemap chain, filter by filter

Tone mapping in FFmpeg is a five-stage pipeline because you have to leave the HDR transfer curve, do the math in linear light at float precision, and then land back on Rec. 709.

ffmpeg -i IMG_4471.mov -vf "\
zscale=t=linear:npl=100,\
format=gbrpf32le,\
zscale=p=bt709,\
tonemap=tonemap=hable:desat=0,\
zscale=t=bt709:m=bt709:r=tv,\
format=yuv420p" \
  -c:v libx264 -crf 20 -preset medium \
  -c:a aac -b:a 128k \
  -color_primaries bt709 -color_trc bt709 -colorspace bt709 \
  -movflags +faststart out.mp4

Each stage earns its place. zscale=t=linear:npl=100 undoes the HLG or PQ curve into linear light, with nominal peak luminance set to 100 nits. format=gbrpf32le moves to 32-bit float RGB so the tone curve doesn't band. zscale=p=bt709 maps the BT.2020 gamut down to Rec. 709 primaries. tonemap compresses the dynamic range. The final zscale re-applies the Rec. 709 transfer and matrix at limited range, and format=yuv420p gets you back to 8-bit 4:2:0 that browsers will decode.

Two knobs matter and the rest are noise. desat=0 is the important one: the default of 2 desaturates bright pixels to protect highlights, and on phone footage of skin and product shots it makes faces look bleached. Set it to 0 first, and only raise it toward 1 if specular highlights clip to flat white. For npl, 100 is the safe default for HLG; PQ sources graded at 1000 nits often look better with npl=1000, which lifts mid-tones instead of pushing everything down.

On tone curves, hable holds shadow detail and is the sane default for UGC. mobius is gentler on mid-tones and slightly flatter. reinhard clips highlights hard. clip is not a tone map, it's a truncation, and it produces the blown-sky look people mistake for "HDR conversion just looks bad."

libplacebo does it on the GPU, if your build has Vulkan

FFmpeg 8.0, released in August 2025, made libplacebo the more appealing path for anyone doing this at volume. It runs the tone map on a Vulkan device and implements BT.2390, which handles bright highlights more gracefully than the tonemap filter's fixed curves.

ffmpeg -init_hw_device vulkan -i IMG_4471.mov \
  -vf "format=yuv420p10,hwupload,\
libplacebo=tonemapping=bt.2390:colorspace=bt709:color_primaries=bt709:color_trc=bt709:range=tv,\
hwdownload,format=yuv420p" \
  -c:v libx264 -crf 20 -preset medium -c:a aac -b:a 128k \
  -color_primaries bt709 -color_trc bt709 -colorspace bt709 out.mp4

The catch is the dependency chain. You need --enable-libplacebo, a Vulkan loader, and a GPU the container can see. On a Docker worker without device passthrough, -init_hw_device vulkan fails before a single frame decodes. The zscale chain runs anywhere and is the one to ship if your workers are plain CPU containers.

"No such filter: 'zscale'" means your FFmpeg was built without libzimg

Most distro packages, and a surprising number of Docker images, ship FFmpeg without --enable-libzimg. The filter simply doesn't exist in the binary, and the error arrives at filter-graph init rather than at decode:

[AVFilterGraph @ 0x55d1f2a1c3c0] No such filter: 'zscale'
Error reinitializing filters!
Failed to inject frame into filter network: Function not implemented

Confirm with ffmpeg -filters | grep zscale, and check the build with ffmpeg -buildconf | grep zimg. If it's absent, you need a different build, not a different command. This is the exact same class of problem as an FFmpeg build missing libx264: the flags you copied from a tutorial assume a compile-time option your binary doesn't have. Static builds from John Van Sickle and the BtbN GitHub releases include zimg. The Ubuntu and Debian archive packages historically have not.

This is where the ingest step gets annoying to own. You're now pinning an FFmpeg build with libzimg on every worker, branching on ffprobe output, and re-testing the tone curve whenever Apple changes what the camera writes. Sending the clip to FFmpeg Micro instead makes normalize-on-ingest one POST with a webhook back, which is the whole reason the UGC video pipeline pages exist. The job schema is in the docs, and there's a free tier to test a real clip before you wire anything up.

Burn captions after the tone map, never before

Order matters here in a way that isn't obvious until you see it. The subtitles and drawtext filters write literal pixel values into the frame, so caption white lands at RGB 255,255,255 in whatever color space the frame currently occupies.

Burn first and tone map second, and the tone mapper treats your caption white as a specular highlight and pulls it down toward diffuse white. The text comes out dull gray on a correctly-mapped image. Skip the tone map entirely and you get the opposite tell: blazing white captions sitting on washed-out gray footage, which is how a reviewer spots a broken batch from across the room. That contrast mismatch is usually the first symptom anyone reports, and it sends people hunting through their caption burn-in settings when the color pipeline is the actual defect.

Tone map, then burn. In a single command, that means subtitles=subs.srt goes after format=yuv420p at the end of the chain.

Pitfalls that cost a re-render

Five HDR-to-SDR mistakes survive a first-pass fix and come back a week later.

  • Stripping HDR tags without converting. Setting -color_trc bt709 on untouched PQ data relabels the file without changing a pixel. The picture is identical and still gray, and now nothing downstream can detect the problem.
  • Skipping the output color flags. Tone map correctly but forget -color_primaries bt709 -color_trc bt709 -colorspace bt709 and some players fall back to guessing from resolution, which puts 4K clips back on BT.2020.
  • Trusting your own monitor. On an HDR-capable MacBook display, QuickTime often shows both the broken and the fixed file acceptably. Check on an SDR display or in Chrome on a Windows machine.
  • Running the whole chain on 8-bit intermediate formats. If format=gbrpf32le is missing, the linear-light stage quantizes and you get visible banding in skies and gradients.
  • Mixing tone-mapped and untouched clips in one concat. Half the timeline shifts brightness at every cut, which is a different symptom of the same missing ingest branch covered in normalizing before you concat.

FAQ

How do I know if a video is HDR before converting it?

Run ffprobe -select_streams v:0 -show_entries stream=color_transfer against the file and read the value. smpte2084 means PQ, arib-std-b67 means HLG, and both need a tone map before SDR delivery. Anything else is already SDR.

Why does my HDR video look washed out after converting to SDR?

An HDR video looks washed out after conversion because the PQ or HLG transfer curve was never converted, only the container and codec changed. The pixel values still encode HDR brightness, so an SDR player applies the wrong curve and renders lifted blacks and flat, desaturated color. Adding a tonemap stage to the filter chain fixes it.

Do I need zscale, or can I tone map without it?

You need either zscale (which requires an FFmpeg build with libzimg) or libplacebo (which requires Vulkan) to convert between transfer curves in FFmpeg. The tonemap filter alone can't do it, because it expects linear-light float input and only zscale or libplacebo can produce that.

Does converting HDR to SDR lose quality?

Converting HDR to SDR permanently discards the extra dynamic range, since you're compressing roughly 1000 nits of highlight information into about 100. Keep the original file as your master and treat the SDR version as a delivery render, the same way you'd keep a ProRes master alongside an H.264 export.

Should I tone map iPhone footage even if it looks fine on my Mac?

Yes, tone map iPhone footage regardless of how it looks locally, because an HDR-capable Mac display renders the untouched file correctly while most viewers' browsers and SDR screens will not. The check that matters is playback on an SDR display, not in QuickTime on the machine that did the encode.

If you'd rather branch on ffprobe once and hand the tone map off, FFmpeg Micro takes the clip, normalizes HLG and PQ sources to Rec. 709, and returns an SDR MP4 in a single call from code, n8n, Make, Zapier, or an AI agent. Sign up free and run one of your own gray clips through it.

About Javid Jamae

Founder & CEO at FFmpeg Micro

Javid is a software engineer, author, and entrepreneur with over 25 years of professional software development experience across enterprise, startup, and consulting environments. He founded FFmpeg Micro to make video processing accessible to developers through a simple, automation-first REST API.

Software EngineeringVideo ProcessingFFmpegCloud ArchitectureAPI DesignAutomation

Ready to process videos at scale?

Start using FFmpeg Micro's simple API today. No infrastructure required.

Get Started Free