Dev.to WebDev πŸ›  Dev πŸ‘ 0 πŸ“– 8 min read

Why Does My Hit the Road Dude Clip Look Washed Out After Editing? Checking Color Range Tags With ffprobe and Python

Checked November 4, 2026 (09:00 CST, UTC+8) against the live pages linked below. I build hittheroaddude.app, an independent studio that remakes the Hit the Road Dude (State Property) pickup-window clip from one photo. Th

Checked November 4, 2026 (09:00 CST, UTC+8) against the live pages linked below. I build hittheroaddude.app, an independent studio that remakes the Hit the Road Dude (State Property) pickup-window clip from one photo. The script in this post works on any H.264 MP4. It doesn't depend on my studio.

A question I keep getting, in different words: "My Hit the Road Dude clip looked fine on my phone, then I ran it through an editor and now it's grey and flat." Or the reverse: "the black truck interior turned into a black blob and the sky is blown out."

Neither one is usually the AI's fault. The pixels didn't change. What changed is how a player was told to read them. Every H.264 file carries (or fails to carry) a few small colour tags, and when the tags and the pixels disagree, the same file looks different in every app.

This post shows how to check that in 15 seconds with ffprobe, a raw read of the luma plane, and a 90-line Python script. It also shows the two fixes, and which one re-encodes.

The short version

  • Limited range ("tv") means luma lives between 16 and 235. That's what almost all phone and web video uses.
  • Full range ("pc") means luma uses 0 to 255. Some cameras, screen recorders and image-to-video exports write this.
  • If a limited file is tagged full, black is drawn at 16 instead of 0 and it looks washed out.
  • If a full file is tagged limited, everything under 16 and over 235 clips: crushed blacks, blown whites.
  • If the file is untagged, each app guesses. That's why it can look right in one place and wrong in the next.

You can't tell which of these you have by looking. You can measure it.

Step 1: read the tags

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

A healthy phone-ready file prints something like color_range=tv, color_space=bt709, color_primaries=bt709, color_transfer=bt709. unknown on any of these lines means "nobody said, the app will guess."

Step 2: look at the pixels, not just the label

Tags are claims. To check them, read the actual Y (luma) plane and count how many samples fall outside 16..235. Two things matter here:

  1. Use the raw plane. -vf extractplanes=y -pix_fmt gray hands you the stored luma bytes as they are. If you convert to RGB first, ffmpeg applies a range conversion and you end up measuring your own conversion.
  2. Use a share, not min/max. My first version used signalstats YMIN/YMAX. x264 ringing at sharp edges (text, the window frame) pushes a few samples to 0 or 255 even in a perfectly normal limited-range file, so min/max said "full range" for everything. Counting the share of samples outside 16..235 and using a 1% threshold fixed that.

The script

#!/usr/bin/env python3
"""color_check.py - why does my vertical clip look washed out or too contrasty?

Compares the colour tags an MP4 declares (ffprobe) with the luma levels its
pixels actually use (raw Y plane sampled at 2 fps), then prints a fix.

usage: python3 color_check.py clip.mp4
exit 0 = tags present and consistent, 1 = something to fix
"""
import json, subprocess, sys
import numpy as np

UNSET = ("unknown", "unspecified", "reserved", None)


def probe(path):
    out = subprocess.run(
        ["ffprobe", "-v", "error", "-select_streams", "v:0", "-show_entries",
         "stream=codec_name,width,height,pix_fmt,color_range,color_space,"
         "color_primaries,color_transfer", "-of", "json", path],
        check=True, capture_output=True, text=True).stdout
    return json.loads(out)["streams"][0]


def luma_outside_limited(path, w, h):
    """Share of luma samples below 16 or above 235, read straight from the Y plane."""
    raw = subprocess.run(
        ["ffmpeg", "-v", "error", "-i", path, "-an", "-vf", "fps=2,extractplanes=y",
         "-f", "rawvideo", "-pix_fmt", "gray", "-"],
        check=True, capture_output=True).stdout
    y = np.frombuffer(raw, np.uint8)
    frames = y.size // (w * h)
    return float(np.mean((y < 16) | (y > 235))), frames


def main(path):
    s = probe(path)
    share, frames = luma_outside_limited(path, s["width"], s["height"])
    tags = {k: s.get(k) for k in
            ("color_range", "color_space", "color_primaries", "color_transfer")}
    print(f"{path}: {s['codec_name']} {s['pix_fmt']} {s['width']}x{s['height']}, "
          f"{frames} frames sampled")
    for k, v in tags.items():
        print(f"  {k:<16} {v or 'unknown'}")
    print(f"  luma outside 16..235: {share:.1%}")

    uses_full = share > 0.01          # >1% of samples outside limited range
    problems = []
    missing = [k for k, v in tags.items() if v in UNSET]
    if missing:
        problems.append("untagged " + ", ".join(missing) + ": each app guesses")
    if tags["color_range"] == "tv" and uses_full:
        problems.append("tagged limited (tv) but pixels use full range: "
                        "blacks crush and whites clip")
    if tags["color_range"] == "pc" and not uses_full:
        problems.append("tagged full (pc) but pixels sit in 16..235: looks washed out")
    if not problems:
        print("OK: tags present and consistent with the pixels")
        return 0
    for p in problems:
        print("FIX:", p)
    flag = 1 if uses_full else 0
    print("\nRe-tag only (no re-encode, H.264, BT.709):")
    print(f"  ffmpeg -i {path} -c copy -bsf:v h264_metadata=colour_primaries=1:"
          f"transfer_characteristics=1:matrix_coefficients=1:video_full_range_flag={flag} "
          f"fixed.mp4")
    if uses_full:
        print("Or convert to limited range, the safe default for social apps "
              "(re-encodes video, copies audio):")
        print(f"  ffmpeg -i {path} -vf \"scale=in_range=full:out_range=limited,"
              f"format=yuv420p,setparams=range=tv:color_primaries=bt709:"
              f"color_trc=bt709:colorspace=bt709\" -c:v libx264 -crf 18 -c:a copy "
              f"limited.mp4")
    return 1


if __name__ == "__main__":
    if len(sys.argv) != 2:
        sys.exit(__doc__)
    sys.exit(main(sys.argv[1]))

It needs Python 3, numpy and ffmpeg/ffprobe on your PATH (pip install numpy). Sampling at 2 fps means a 15-second clip is 30 frames, which took under half a second on my box.

What it printed on four test files

I made four 15-second 720Γ—1280 test clips with testsrc2, a black bar and a white bar, and a synthetic voice track. One is untagged, one is tagged correctly, one is limited range tagged as full, and one is full range tagged as limited. Here's the output, trimmed:

untagged.mp4: h264 yuv420p 720x1280, 30 frames sampled
  color_range      unknown
  ...
  luma outside 16..235: 0.0%
FIX: untagged color_range, color_space, color_primaries, color_transfer: each app guesses
  ffmpeg -i untagged.mp4 -c copy -bsf:v h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1:video_full_range_flag=0 fixed.mp4

good.mp4: ... color_range tv ... luma outside 16..235: 0.0%
OK: tags present and consistent with the pixels

washed.mp4: h264 yuvj420p ... color_range pc ... luma outside 16..235: 0.0%
FIX: tagged full (pc) but pixels sit in 16..235: looks washed out

crushed.mp4: ... color_range tv ... luma outside 16..235: 25.0%
FIX: tagged limited (tv) but pixels use full range: blacks crush and whites clip

The 25% on crushed.mp4 is the black and white bars: a quarter of the frame was stored at full-range values under a limited-range label.

Fix A: re-tag only (no re-encode)

If the pixels are fine and only the label is wrong or missing, rewrite the label. The h264_metadata bitstream filter edits the stream's VUI fields in place, with -c copy, so nothing is re-encoded and the picture is bit-identical:

ffmpeg -i washed.mp4 -c copy \
  -bsf:v h264_metadata=colour_primaries=1:transfer_characteristics=1:matrix_coefficients=1:video_full_range_flag=0 \
  fixed.mp4

The 1s are the H.264 code points for BT.709, and video_full_range_flag is 0 for limited and 1 for full. After this, washed.mp4 and crushed.mp4 (re-tagged with flag 1) both printed OK.

Fix B: convert to limited range (re-encodes)

Re-tagging a full-range file as full is correct, but some apps and editors still handle full-range H.264 badly. If you're going to post it anyway, the safer move is to convert the pixels to limited range once:

ffmpeg -i crushed.mp4 \
  -vf "scale=in_range=full:out_range=limited,format=yuv420p,setparams=range=tv:color_primaries=bt709:color_trc=bt709:colorspace=bt709" \
  -c:v libx264 -crf 18 -c:a copy limited.mp4

Two details that cost me time:

  • in_range=full is needed here because the file claims to be limited. Without it, scale trusts the wrong tag and converts nothing.
  • On ffmpeg 7.1, passing -color_primaries bt709 -color_trc bt709 as output options did not end up in the H.264 VUI for me (ffprobe still said unknown). Setting them on the frames with setparams did. Check with ffprobe after any fix rather than trusting the command line.

The result was 15.000 s with video and audio both present, and the checker printed OK with 0.0% outside 16..235.

Where this fits with a Hit the Road Dude clip

My studio's output is a fixed format: every clip is "15 seconds, vertical 9:16, at 480p or 720p, with sound" (FAQ), with a newly generated voice (original audio or a new voice). I'm not going to claim what tags any particular downloaded file carries, because players, browsers and "save to Photos" paths can rewrite containers. Run the checker on the file you actually have, and run it again on the file your editor exports. The second run is where problems usually show up.

A few practical notes:

  • Check before and after editing. If the original says OK and the export doesn't, your editor or a re-save step changed the tags.
  • Re-tagging is free, so try it first. It doesn't touch the picture, so you can't make things worse. Keep the original.
  • Resolution doesn't change any of this. 480p ($9.99) and 720p ($12.99) differ in sharpness, not colour handling (480p or 720p, pricing). Paying for 720p won't fix a range mismatch, and re-tagging works the same on both.
  • A washed-out look is not a failed render. If the colours are fine in one player and wrong in another, it's a labelling problem. The help page covers the things that really are generation issues (faces, mouths, hands).

Then do the normal pre-post pass from check before posting: the "State?" pause, the send-off, the face, and a caption that says it's an AI remake.

Recap

  1. ffprobe the four colour tags.
  2. Measure the share of luma outside 16..235 from the raw Y plane.
  3. Tags missing or wrong, pixels fine: re-tag with h264_metadata and -c copy.
  4. Full-range pixels you want to post safely: convert with scale=in_range=full:out_range=limited, then set tags with setparams.
  5. ffprobe again. Trust the output, not the command line.
πŸ“° Read the original article on Dev.to WebDev

Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes β€” full credit and traffic to the original publisher.