How the Color Grading Logic in `helpers/grade.py` Handles Different Color Spaces

The color grading logic in helpers/grade.py operates on normalized pixel values after color space conversion, with HDR sources being tone-mapped to SDR in helpers/render.py before any grading filters are applied.

The browser-use/video-use repository provides video processing utilities that apply color corrections through a decoupled architecture. The color grading logic itself remains color-space-agnostic by design, processing only normalized luma and saturation values while delegating all color space transformations to upstream components. This separation ensures that the same grading algorithms work identically across SDR (Rec.709) and HDR (Rec.2020/PQ/HLG) sources without requiring color-space-specific branching inside the grading module.

Two Grading Modes: Preset and Auto

The helpers/grade.py module implements two distinct approaches for applying color grades to video content.

Preset mode applies a static ffmpeg filter chain drawn from predefined constants. These filters—including eq, curves, and colorbalance—are applied unchanged to every frame regardless of content analysis.

Auto mode performs data-driven per-clip correction. The _sample_frame_stats function analyzes a sample of frames to compute mean brightness, contrast range, and saturation levels. It then constructs a modest eq= filter that constrains adjustments to never exceed ±8% on any axis, producing a "clean, not graded" aesthetic that preserves the original character of the footage.

Bit-Depth Normalization Enables Color-Space Agnosticism

When sampling frames for auto-grading, the code extracts raw Y (luma) and saturation values from ffmpeg’s signalstats filter. These values arrive in the native bit depth of the decoded frame—ranging from 0-255 for 8-bit sources to 0-1023 for 10-bit sources.

Rather than assuming a specific color space, the _sample_frame_stats function normalizes these values by the maximum possible value for the detected bit depth:


# Lines 115-118 of grade.py

# Normalization ensures downstream math operates in 0..1 

# regardless of source bit depth or color space.

max_val = (2 ** bit_depth) - 1
y_mean = (sum(y_avgs) / len(y_avgs)) / max_val

Because this normalization depends solely on bit depth and not on color primaries or transfer functions, the same analysis logic functions correctly for both Rec.709 (SDR) and Rec.2020 (HDR) sources. The grading code never assumes specific color primaries, allowing it to remain agnostic to the underlying color space of the input material.

HDR Handling Through Tone-Mapping in render.py

Color space conversion—specifically HDR-to-SDR transformation—occurs entirely outside grade.py in the helpers/render.py module. This upstream processing ensures that by the time grading filters are applied, the video signal has already been normalized to a standard SDR Rec.709 color space.

The is_hdr_source function detects HDR content by checking for PQ (HDR10) or HLG transfer functions via ffprobe:


# Lines 20-30 of render.py

HDR_TRANSFERS = {"smpte2084", "arib-std-b67"}

def is_hdr_source(video: Path) -> bool:
    # ffprobe command queries color_transfer metadata

    return out.stdout.strip() in HDR_TRANSFERS

When HDR content is detected, render.py prepends a comprehensive tone-mapping chain to the ffmpeg filter graph before invoking any grading logic:


# Lines 10-17 of render.py

TONEMAP_CHAIN = (
    "zscale=t=linear:npl=100,"
    "format=gbrpf32le,"
    "zscale=p=bt709,"
    "tonemap=tonemap=hable:desat=0,"
    "zscale=t=bt709:m=bt709:r=tv,"
    "format=yuv420p"
)

During segment extraction, the filter assembly follows a strict order that places tone-mapping before grading:


# Lines 79-86 of render.py (extract_segment)

vf_parts: list[str] = []
if is_hdr_source(source):
    vf_parts.append(TONEMAP_CHAIN)   # HDR → SDR conversion first

vf_parts.append(scale)
if grade_filter:
    vf_parts.append(grade_filter)    # Color grade applied to SDR signal

vf = ",".join(vf_parts)

This architecture guarantees that the grading logic in grade.py always receives an SDR signal, eliminating the need for HDR-specific handling within the grading algorithms themselves.

What the Grade Filters Actually Modify

The filters generated by grade.py operate on the Y′CbCr domain after any tone-mapping has occurred:

  • eq= – Adjusts contrast, brightness (gamma), and saturation using ffmpeg's equalizer filter
  • curves= – Applies a subtle S-curve to the master (luma) channel for refined contrast control
  • colorbalance= – Available only in the optional warm_cinematic preset; tweaks shadows and highlights using RGB-style color balance (not used in auto mode)

All adjustments are constrained to modest ranges in auto mode to avoid dramatic color shifts. Since these filters process the signal only after HDR-to-SDR conversion in render.py, they interact with a standardized color space rather than handling the wide gamut and high dynamic range of the original HDR source.

Practical Usage Examples

Apply auto-grading to an SDR source directly:

python helpers/grade.py input.mp4 -o out.mp4

Process HDR content through the render pipeline (includes automatic tone-mapping):

python helpers/render.py edl.json -o final.mp4

Use an explicit preset instead of auto-analysis:

python helpers/grade.py input.mp4 -o out.mp4 --preset warm_cinematic

Bypass both presets and auto-grade with a custom filter string:

python helpers/grade.py input.mp4 -o out.mp4 --filter "eq=contrast=1.1:saturation=1.02"

Summary

  • Color-space agnostic design: helpers/grade.py normalizes pixel values by bit depth rather than color space, enabling operation across Rec.709 and Rec.2020 sources.
  • Separation of concerns: HDR detection and tone-mapping reside in helpers/render.py, which converts all HDR signals to SDR before grading.
  • Normalized processing: The _sample_frame_stats function converts luma values to a 0..1 range based on bit depth, making analysis independent of color primaries.
  • Filter constraints: Auto mode generates eq= filters with adjustments capped at ±8%, operating on Y′CbCr data after tone-mapping.
  • Pipeline order: The render pipeline assembles filters as tone-map → scale → grade, ensuring consistent SDR processing for all color grades.

Frequently Asked Questions

Does grade.py support HDR color grading natively?

No, grade.py does not process HDR signals directly. According to the source code in helpers/render.py, HDR sources (PQ or HLG) are first converted to SDR using the TONEMAP_CHAIN filter sequence before any grading filters from grade.py are applied. The grading logic always operates on tone-mapped SDR content.

How does the auto-grading algorithm handle different bit depths?

The _sample_frame_stats function in grade.py detects the bit depth of the source material and normalizes luma values by dividing by (2 ** bit_depth) - 1. This produces values in the 0..1 range regardless of whether the source is 8-bit, 10-bit, or higher, allowing the same brightness and contrast calculations to work across different video formats.

What is the difference between preset mode and auto mode in grade.py?

Preset mode applies static ffmpeg filter strings defined in the PRESETS dictionary (such as warm_cinematic) unchanged to every frame. Auto mode analyzes sample frames using signalstats, calculates mean brightness and saturation, then generates a custom eq= filter with corrections limited to ±8% on any axis.

Why are color space conversions handled in render.py instead of grade.py?

This architectural decision keeps the grading logic simple and color-space-agnostic. By handling HDR detection and tone-mapping in helpers/render.py, the system ensures that grade.py receives a standardized SDR signal in all cases. This eliminates the need for complex color space branching logic within the grading algorithms and allows the same grading code to work reliably across diverse source materials.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →