| name | color-correct |
|---|---|
| description | Grade a video file with your AI agent as the colorist β it looks at frames, diagnoses casts/exposure/saturation, authors a readable ffmpeg grade, previews side-by-side, iterates, renders. Non-destructive. Nine named looks (Golden Hour, Honey, Linen, Super 8, Oat Milk, Espresso, Velvet, Popsicle, Terracotta) reverse-engineered from creators with great color, each shipped as a .cube LUT too. Use when the user says "color correct this", "/color-correct", "make this look good", "apply Oat Milk to this clip", "show me all the looks on this video", or "make my footage look like [creator]" (Steal mode via --like). ALWAYS starts by showing the menu β a contact sheet of all nine looks on the user's own footage, each look rendered on one or two frames of the footage (two β e.g. talking head + differently-lit b-roll β whenever scenes vary, so a look that flatters one scene but wrecks another is caught) β before applying anything; a requested look or AI recommendation is marked on the sheet for confirmation. Steal mode fits new looks from a reference. |
Color Correct β AI colorist skill
Grade a video file with your AI agent as the colorist: it looks at frames, diagnoses what's wrong, authors a grade as a readable ffmpeg filter chain, previews side-by-side, iterates, renders. Non-destructive (writes a new file), audio stream-copied.
The core idea: the agent's superpower isn't running ffmpeg filters β it's the closed loop. Extract frames β look β grade β extract again β compare β adjust. Never render blind; never trust a preset without eyeballing it on the actual footage.
Ways to use it
β οΈ THE MENU COMES FIRST β ALWAYS. Whatever the user asked for, the first thing they see is the contact sheet of all nine looks rendered on their footage β each look shown on two different frames (a talking head + a b-roll / differently-lit scene), because a look that flatters one scene can wreck another. Color is chosen with eyes, not words β a look name means nothing until you've seen it on your own frames. No look is applied and nothing full-renders before the user has picked from the menu.
1. "Apply Oat Milk to this clip" β render the menu with Oat Milk marked as the requested look, show it, ask "this one, or did another catch your eye?" β then apply their confirmation.
2. "Show me all the looks" β the menu, which was happening anyway.
3. "Color correct this" / "make this look good" β diagnose (content type, casts, exposure, saturation), do the Fix pass if needed, then render the menu on the corrected frame with the AI's recommendation marked and a one-line reason ("cozy desk light β Honey"). User picks; AI's pick is a default, not a decision.
The only menu-free path: a pure neutral Fix with no look at all ("just fix the white balance") β nothing to choose from there.
Inputs
- Path to a video file (ask if not given).
- Optional: a look name,
menu(contact sheet), or nothing (AI decides). - Optional: a reference (YouTube URL / screenshot) for Steal mode β see below.
Tooling
ffmpegfilters:colortemperature,eq,colorbalance,curves,vibrance,unsharp,hue,lut3dscripts/measure.pyβ grade fingerprint of a frame (luma percentiles, per-band casts, saturation)scripts/consensus.pyβ multi-frame median fingerprint (for Steal mode)yt-dlpβ for pulling reference sections in Steal mode- The agent's eyes β the actual instrument
Working directory
/tmp/color-correct/<basename>/ β keep frames + the grade chain for debugging.
The grade fingerprint (what to measure)
python3 scripts/measure.py frame.jpg prints, per frame:
- luma p1/p10/p50/p90/p99 β exposure (p50), contrast (p90βp10 spread), matte-ness (p1 > 8 = lifted blacks), highlight ceiling (p99 < 245 = pulled whites)
- per-band RGB casts (shadows 0β64 / mids 64β160 / highs 160β255) β RβB = warm/cool tilt, G offset = green/magenta tilt, per tonal band (split-toning lives here)
- mean saturation + p90 saturation
Numbers back the eyes; eyes make the call. Always read the frame images too β casts hide in skin and neutrals, and stats can't see "this looks cheap".
Workflow
Step 0 β Probe + sample
ffprobe for codec, bit depth, color space/transfer (flag log footage β D-Log/HLG needs a conversion LUT first). Extract 4β6 frames across the runtime (lighting changes mid-video more often than you think):
for t in 2 15 40 ...; do ffmpeg -y -ss $t -i IN.mp4 -frames:v 1 -q:v 2 /tmp/color-correct/$NAME/src_${t}s.jpg; done
Step 1 β Diagnose (look + measure)
Read the frames. Run measure.py. Name what's wrong in plain words before touching filters: "green webcam cast in mids, blacks crushed, skin under-saturated". Classify the content: talking head, b-roll, screen recording, mixed edit. Screen recordings and UI captures should NOT be graded hard β a look that flatters skin will yellow a white webpage. Mixed edits get a conservative global grade.
Step 2 β Author the grade
Write the chain as an ordered, commented list the user can approve.
Correction order (always this order):
colortemperature=temperature=Nβ kill the WB castcolorbalanceβ residual green/magenta tinteq=brightness/contrast/gammaβ exposure + contrast (brightness Β±0.06 max; prefer gamma for mids)vibrance=intensity=Nβ saturation that protects already-saturated px + skin (prefer overeq=saturation)curvesβ matte lift / highlight rolloff / per-channel split-tones (last)
Step 2b β The looks
Nine looks, each reverse-engineered from a creator whose color work we admire β via consensus fingerprinting (median grade fingerprint over ~18 frames across 3 different videos per creator; only the invariants survive, scene-driven casts are discarded). Credits in the README.
Strength: the chains below are the SUBTLE (~50%) versions β the shipping default. Full-strength consensus references are in the table's last column as multipliers. Go full only when the user asks for "more" or the footage is very flat. If a side-by-side screams, halve everything.
| Look | Character | Chain (subtle, default) |
|---|---|---|
| Golden Hour | filmic warmth, rolled-off whites, green-gold highlights | colortemperature=temperature=6150,curves=master='0/0.018 0.6/0.59 1/0.94',colorbalance=rs=0.015:gh=0.015,vibrance=intensity=0.08 |
| Honey | soft warm matte, gentle contrast | colortemperature=temperature=6050,curves=master='0/0.023 0.5/0.5 1/0.95',vibrance=intensity=0.04,colorbalance=gm=0.01:gh=0.01 |
| Linen | soft rolled whites, quiet warmth, magenta-leaning highlights | curves=master='0/0.018 0.5/0.5 1/0.915',colortemperature=temperature=6100,colorbalance=gh=-0.015,eq=saturation=0.965 |
| Super 8 | film-stock compression, warm, heavy highlight rolloff | curves=master='0/0.02 0.5/0.51 1/0.9',colortemperature=temperature=5900,colorbalance=gs=-0.01:gm=-0.01,vibrance=intensity=0.05 |
| Oat Milk | creamy editorial: warm shadows β cool milky highlights (inverted split-tone) | curves=master='0/0.035 0.5/0.51 1/0.915',colorbalance=rs=0.02:rm=0.015:rh=-0.03:bh=0.025,eq=saturation=0.985 |
| Espresso | moody: darker mids, warm skin against neutral shadows | eq=contrast=1.03:saturation=0.95,colorbalance=rm=0.025:bm=-0.015,curves=master='0/0.02 0.5/0.485 1/0.94' |
| Velvet | lifted matte blacks, pulled whites, warm mids | curves=master='0/0.035 0.5/0.5 1/0.935',colortemperature=temperature=6000,colorbalance=gs=-0.01:gh=0.015,vibrance=intensity=0.04 |
| Popsicle | bright pop: full whites, punchy spread, sunny highlights | eq=brightness=0.01:contrast=1.03,curves=master='0/0.025 0.5/0.515 1/1',vibrance=intensity=0.06,colorbalance=gh=0.015 |
| Terracotta | warm + contrasty, magenta-leaning shadows, rich | eq=contrast=1.035,colortemperature=temperature=5850,colorbalance=gs=-0.02:gm=-0.01,curves=master='0/0.01 0.5/0.5 1/0.99' |
Full strength = double every parameter's distance from neutral (temp toward the value 2Γ further from 6500, curve offsets Γ2, colorbalance Γ2, vibrance Γ2, contrast 1.03 β 1.06, saturation 0.95 β 0.9).
Faster path: every look also exists as a .cube LUT in luts/{subtle,full}/ β lut3d=luts/subtle/golden-hour.cube applies identically to the chain (verified ~1/255). Use the chains when you need to adapt a look to the footage; use the LUTs for straight application or for handing to an NLE. Regenerate after editing chains: scripts/gen_luts.sh (uses scripts/chain2cube.py, HALD-CLUT method β per-pixel filters only, no spatial filters like unsharp in a LUT-able chain).
Step 2c β The menu (MANDATORY GATE β no look is applied before this)
Every run that could end in a look goes through the menu, built on one or two frames: two whenever the footage has more than one scene or lighting situation (the default for real videos) β a single frame lies: a look that flatters a warm talking-head face can yellow a white-walled b-roll or a screen/UI shot. One frame is acceptable only for visually uniform footage (a single static talking-head setup). Pick two frames that differ in content or lighting (e.g. a talking head with skin + a b-roll / differently-lit scene; for a mixed edit, deliberately pick the two that stress the grade most β the warm A-roll and the white-walled/UI b-roll). If a Fix pass was needed, render the menu on the corrected frames.
For each look, render it on both frames, stack the two into one vertical tile, label the tile once, then tile the look-tiles into a grid (include an Original tile and, when a Fix is in play, a Neutral fix tile):
# per look, per frame: ffmpeg -y -i frameN.jpg -vf "$CHAIN,scale=900:-2" -q:v 2 lookN.jpg
# vstack the two graded frames into one tile, label once (PIL), then grid the look-tiles
Show the sheet and STOP β the user picks with their eyes before anything is applied. If the user named a look, mark it on the sheet ("you asked for Oat Milk β confirm or switch"); in auto mode, mark the AI recommendation with its one-line reason. (Labeling via PIL is the portable path β many ffmpeg builds ship without drawtext.)
Step 2d β AI recommends (auto mode β recommendation, not decision)
The agent's decision tree, whose output is the marked default on the menu, never a silent application:
- Footage has a cast / exposure problem β Fix first, always.
- Content is a screen recording or mixed edit β conservative fix only, or nothing. Say why.
- Talking head, clean β recommend the look whose character fits the content's energy (bright tutorial β Popsicle; cozy desk β Honey/Velvet; cinematic vlog β Golden Hour/Super 8; editorial β Oat Milk/Linen; moody tech β Espresso). Name the reasoning in one line on the menu.
- After the user picks from the menu: side-by-side preview, then the full render.
Step 2e β Steal mode (fit a new look from any creator)
A single reference frame confounds the grade with the scene (a warm cabin frame reads as a warm grade). The grade is what stays constant while scenes change:
- Sample wide: 2β3 videos from the channel, different environments. 3 sections each, 2 frames per section β ~18 frames. Use
yt-dlp -f "bv*[height<=1080][vcodec^=avc1]"(AV1 section downloads can produce undecodable files). Contact-sheet the frames β drop motion graphics / screen recordings / title cards before measuring. python3 scripts/consensus.py <dir>β per-frame stats + MEDIAN + IQR. Read the IQR like a signal: tight IQR = grade signature; wide IQR = scene-driven, IGNORE it.- Author the chain from the invariants only. Typical real signatures: highlight rolloff point, saturation ceiling, black floor, small consistent band tints (Β±3β10). Typical fakes: big warmth (weather), casts that flip sign between scenes.
- Verify side-by-side on the target footage, 2β3 iterations. Save the fitted look at subtle strength.
Step 3 β Preview (never full-render first)
Confirm the chosen grade on two different frames (the same two the menu used, or another content/lighting pair) β a grade that holds on one scene can break on another:
ffmpeg -y -ss T -t 5 -i IN.mp4 -vf "$CHAIN" -c:v libx264 -crf 18 preview.mp4
# per frame: ffmpeg -y -i src_frameN.jpg -i graded_frameN.jpg -filter_complex hstack sbs_N.jpg
# then stack sbs_1 over sbs_2 into one before/after image spanning both frames
Read the side-by-side. Check in order: skin first, then neutrals (walls/whites), then blacks, then overall vibe. Re-measure the graded frames β did the numbers move where predicted? Show the user (both frames) before the full render.
Step 3.5 β Skin gate (MANDATORY when a person is in frame)
Eyes are the first check; this makes it mechanical:
python3 scripts/skincheck.py src_frame.jpg graded_frame.jpg
Detects skin pixels in the original (tightened YCbCr box + saturation bounds β the classic box false-positives on beige walls) and measures how the grade moved them:
- PASS (exit 0) β hue shift < 6Β°, sat change < 25% β proceed
- SOFTEN (exit 1) β halve the look strength, re-render the preview, re-check
- FAIL (exit 2) β the grade breaks skin. Don't just soften: diagnose. Usually means WB wasn't corrected first, or the look is wrong for this footage.
- SKIP β <2% skin in frame (b-roll): judge by neutrals instead.
Run the gate on 2β3 frames across the runtime (lighting changes move skin too). White balance is protected by the same mechanism upstream: the Fix pass neutralizes casts before any look, and the gate catches it if that step was skipped β a look on top of a cast fails the hue check immediately.
Step 4 β Render
ffmpeg -y -hwaccel videotoolbox -i IN.mp4 -vf "$CHAIN,format=yuv420p" \
-c:v libx264 -preset fast -crf 18 \
-c:a copy -movflags +faststart OUT_graded.mp4
Audio stream-copied; bt709 tags preserved. Verify frame pairs at 3 timestamps across the runtime.
Taste rules
- Correct, then grade. A look on top of a cast = the cast, warmer.
- Skin is the referee. Any move that breaks skin loses.
- Subtle wins. If the side-by-side screams, halve everything.
- Matte = lifted blacks + pulled whites, not low contrast.
- Split-tone via
colorbalancebands, warmth viacolortemperature, mids viagammaβ one job per filter, in order. - Never grade log footage directly β convert (lut3d) first.
- Don't grade UI/screen-recordings hard; white interfaces show every cast.
Gotchas (earned the hard way)
curvesneeds anchors at both ends (0/x ... 1/y) β a single-point curve extrapolates into a blown-out frame.- macOS bash 3.2 silently breaks
declare -A(all keys collapse to index 0). Usecasestatements in download scripts. - Renderer color-shift paranoia check: encoders (x264 vs hardware) don't shift color (<0.3/255 measured) β but color metadata does. Preserve bt709/range tags; judge grades in the player your audience uses (browsers β QuickTime gamma).
