#!/usr/bin/env python3
"""Generate .cube 3D LUT grade variants (+ optional preview stills with HTML chooser).
A .cube LUT is plain ASCII (an N^3 lattice of RGB triples), so grade candidates
can be computed rather than hand-tuned in an NLE. This emits a family of looks —
optionally on top of an S-Log3 -> Rec.709 conversion for log footage — and, with
--previews, renders one still per look plus an index.html so a HUMAN can choose.
THE AGENT NEVER PICKS THE GRADE. Generate, render previews, present the chooser,
wait. Grading is a taste call (see SKILL.md / references/color-grading.md).
Usage: gen-luts.py [--variants LIST|all] [--size N] [--input-space slog3|rec709]
[--out-dir DIR] [--previews MEDIA [--frame-at S]] [--json]
Input: no positional; --previews takes a video/image to grade stills from
Output: stdout = one line per written file (or --json manifest envelope,
schema claude-mods.ffmpeg-ops.luts/v1)
Stderr: progress, the human-picks-the-grade reminder, errors
Exit: 0 ok, 2 usage, 3 preview source missing, 5 ffmpeg missing (--previews only)
Examples:
gen-luts.py --variants all --out-dir work/luts
gen-luts.py --variants warm_filmic,punchy,teal_orange --input-space slog3
gen-luts.py --variants all --out-dir work/luts --previews footage.mp4 --frame-at 12.5
gen-luts.py --variants all --json | jq -r '.data.files[]'
"""
import argparse
import json
import shutil
import subprocess
import sys
from pathlib import Path
from typing import NoReturn
SCHEMA = "claude-mods.ffmpeg-ops.luts/v1"
EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_MISSING_DEP = 0, 2, 3, 5
# Each look: white-balance temp (+warm/-cool), lift/gamma/gain (master),
# per-channel gain tweaks, contrast (pivot 0.5), saturation, fade (black lift).
# Optional "mix": a 3x3 channel-mix matrix applied first (rows = output R,G,B
# as weights of input r,g,b) — what makes sepia/Technicolor expressible.
LOOKS = {
"neutral709": dict(temp=0.00, lift=0.000, gamma=1.00, gain=1.00,
rgb_gain=(1.00, 1.00, 1.00), contrast=1.00, sat=1.00, fade=0.00),
"warm_filmic": dict(temp=0.06, lift=0.005, gamma=0.98, gain=1.00,
rgb_gain=(1.02, 1.00, 0.97), contrast=1.08, sat=1.05, fade=0.03),
"punchy": dict(temp=0.01, lift=-0.010, gamma=1.00, gain=1.02,
rgb_gain=(1.00, 1.00, 1.00), contrast=1.22, sat=1.25, fade=0.00),
"teal_orange": dict(temp=0.02, lift=0.000, gamma=1.00, gain=1.00,
rgb_gain=(1.05, 1.00, 0.94), contrast=1.10, sat=1.10, fade=0.01,
shadow_teal=0.04),
"cool_desat": dict(temp=-0.05, lift=0.005, gamma=1.00, gain=0.99,
rgb_gain=(0.97, 1.00, 1.03), contrast=1.04, sat=0.80, fade=0.02),
"bleach_bypass": dict(temp=0.00, lift=-0.005, gamma=1.00, gain=0.98,
rgb_gain=(1.00, 1.00, 1.00), contrast=1.30, sat=0.45, fade=0.00),
"film_fade": dict(temp=0.02, lift=0.010, gamma=1.02, gain=0.99,
rgb_gain=(1.01, 1.00, 0.99), contrast=0.96, sat=0.90, fade=0.06),
"golden_hour": dict(temp=0.10, lift=0.005, gamma=1.01, gain=1.00,
rgb_gain=(1.04, 1.01, 0.95), contrast=1.05, sat=1.08, fade=0.02),
"pastel": dict(temp=0.01, lift=0.015, gamma=1.05, gain=0.99,
rgb_gain=(1.00, 1.00, 1.00), contrast=0.88, sat=0.72, fade=0.08),
"noir_bw": dict(temp=0.00, lift=-0.005, gamma=1.00, gain=1.00,
rgb_gain=(1.00, 1.00, 1.00), contrast=1.25, sat=0.00, fade=0.00),
"sepia": dict(temp=0.00, lift=0.005, gamma=1.00, gain=1.00,
rgb_gain=(1.00, 1.00, 1.00), contrast=1.02, sat=1.00, fade=0.02,
mix=((.393, .769, .189), (.349, .686, .168), (.272, .534, .131))),
"technicolor2": dict(temp=0.00, lift=0.000, gamma=1.00, gain=1.00,
rgb_gain=(1.00, 1.00, 1.00), contrast=1.10, sat=1.20, fade=0.00,
mix=((1.0, 0.0, 0.0), (0.0, 0.6, 0.4), (0.0, 0.4, 0.6))),
"matrix_green": dict(temp=0.00, lift=0.005, gamma=1.00, gain=1.00,
rgb_gain=(0.97, 1.06, 0.98), contrast=1.10, sat=0.85, fade=0.02),
# Scope-extracted from reference footage (see look-recipes.md grimdark):
# warm-ash desat, pulled mids, true-ish blacks, controlled ceiling.
"grimdark": dict(temp=0.015, lift=0.000, gamma=0.93, gain=0.97,
rgb_gain=(1.02, 1.01, 0.98), contrast=1.04, sat=0.33, fade=0.03),
}
# Tone-map variants: gradient-map luma onto 2 stops (duotone) or 3 stops
# (tritone/monotone: shadow, mid, highlight), all 0..1 RGB. Chroma of the look
# = how far the stops sit from the neutral grey axis - monotones barely leave
# it, poster duotones live far out. "contrast" applies pre-map (widens spread).
_TONE_BASE = dict(temp=0.0, lift=0.0, gamma=1.0, gain=1.0,
rgb_gain=(1.0, 1.0, 1.0), contrast=1.05, sat=1.0, fade=0.0)
LOOKS.update({
# poster-strength duotones
"duo_navy": {**_TONE_BASE, "tones": ((.05, .08, .25), (.98, .93, .80))},
"duo_cyanotype": {**_TONE_BASE, "tones": ((.04, .16, .29), (.92, .96, 1.0))},
"duo_sunset": {**_TONE_BASE, "tones": ((.23, .06, .36), (1.0, .78, .34))},
"duo_forest": {**_TONE_BASE, "tones": ((.06, .24, .18), (.91, .85, .63))},
"duo_crimson": {**_TONE_BASE, "tones": ((.10, .02, .03), (1.0, .88, .86))},
"duo_synthwave": {**_TONE_BASE, "tones": ((.35, .06, .42), (.42, .91, 1.0))},
# muted / tertiary duotones
"duo_ash_rose": {**_TONE_BASE, "tones": ((.23, .20, .22), (.85, .78, .76))},
"duo_olive_bone": {**_TONE_BASE, "tones": ((.18, .20, .14), (.90, .88, .81))},
"duo_petrol_paper": {**_TONE_BASE, "tones": ((.12, .23, .24), (.93, .91, .86))},
"duo_indigo_parchment": {**_TONE_BASE, "tones": ((.16, .23, .33), (.91, .89, .82))},
"duo_slate_ice": {**_TONE_BASE, "tones": ((.11, .15, .20), (.95, .97, .98))},
# monotones (darkroom chemical tones - chroma barely off the grey axis)
"mono_selenium": {**_TONE_BASE, "tones": ((.05, .04, .07), (.48, .46, .52), (.96, .95, .97))},
"mono_platinum": {**_TONE_BASE, "tones": ((.07, .07, .06), (.52, .51, .49), (.97, .96, .94))},
"mono_coffee": {**_TONE_BASE, "tones": ((.08, .05, .03), (.55, .47, .40), (.96, .92, .87))},
"mono_steel": {**_TONE_BASE, "tones": ((.04, .06, .09), (.46, .50, .55), (.94, .96, .98))},
# tritones (distinct shadow / mid / highlight hues)
"tri_split_classic": {**_TONE_BASE, "tones": ((.06, .07, .12), (.50, .49, .48), (.98, .94, .86))},
"tri_tobacco": {**_TONE_BASE, "tones": ((.05, .04, .02), (.45, .40, .28), (.95, .88, .70))},
"tri_arctic": {**_TONE_BASE, "tones": ((.03, .05, .09), (.42, .50, .58), (.93, .97, 1.0))},
})
def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
if json_mode:
print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
print(f"ERROR: {message}", file=sys.stderr)
sys.exit(exit_code)
def clamp(x: float) -> float:
return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
def slog3_to_linear(x: float) -> float:
"""Sony S-Log3 EOTF (input 0..1 code value -> scene linear)."""
if x >= 171.2102946929 / 1023.0:
return (10.0 ** ((x * 1023.0 - 420.0) / 261.5)) * 0.19 - 0.01
return (x * 1023.0 - 95.0) * 0.01125 / (171.2102946929 - 95.0)
def linear_to_rec709(x: float) -> float:
"""BT.709 OETF with a Reinhard-style shoulder for >1.0 scene values."""
x = max(0.0, x)
x = x / (1.0 + 0.35 * x) # soft highlight roll-off
if x < 0.018:
return 4.5 * x
return 1.099 * (x ** 0.45) - 0.099
def apply_look(r: float, g: float, b: float, p: dict) -> tuple:
# Channel mix first (sepia/Technicolor-class looks), then white balance.
mix = p.get("mix")
if mix:
r, g, b = (mix[0][0] * r + mix[0][1] * g + mix[0][2] * b,
mix[1][0] * r + mix[1][1] * g + mix[1][2] * b,
mix[2][0] * r + mix[2][1] * g + mix[2][2] * b)
t = p["temp"]
r, b = r * (1.0 + t), b * (1.0 - t)
# Lift / gamma / gain (master), then per-channel gain.
out = []
for c, cg in zip((r, g, b), p["rgb_gain"]):
c = c * p["gain"] * cg + p["lift"] * (1.0 - c)
c = clamp(c) ** (1.0 / p["gamma"])
out.append(c)
r, g, b = out
# Teal/orange split-tone: push shadows toward teal (complement of the warm gain).
st = p.get("shadow_teal", 0.0)
if st:
luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
w = (1.0 - luma) ** 2 # weight shadows only
r, b = r - st * w, b + st * w
# Contrast around mid pivot.
k = p["contrast"]
r, g, b = (0.5 + (c - 0.5) * k for c in (r, g, b))
# Tone gradient map (replaces saturation): 2 stops = duotone lerp,
# 3 stops = piecewise shadow->mid (luma 0..0.5) -> highlight (0.5..1).
tones = p.get("tones")
luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
if tones:
luma = clamp(luma)
if len(tones) == 3:
lo, hi = (tones[0], tones[1]) if luma < 0.5 else (tones[1], tones[2])
f2 = luma * 2 if luma < 0.5 else (luma - 0.5) * 2
else:
lo, hi, f2 = tones[0], tones[1], luma
r, g, b = (lo[i] + f2 * (hi[i] - lo[i]) for i in range(3))
else:
s = p["sat"]
r, g, b = (luma + s * (c - luma) for c in (r, g, b))
# Fade (lifted blacks).
f = p["fade"]
r, g, b = (f + c * (1.0 - f) for c in (r, g, b))
return clamp(r), clamp(g), clamp(b)
def write_cube(path: Path, name: str, size: int, input_space: str, params: dict) -> None:
lines = [f'# generated by claude-mods ffmpeg-ops gen-luts.py',
f'# look={name} input_space={input_space}',
f'TITLE "{name}"',
f'LUT_3D_SIZE {size}',
'DOMAIN_MIN 0.0 0.0 0.0',
'DOMAIN_MAX 1.0 1.0 1.0']
n = size - 1
for bi in range(size): # .cube order: red varies fastest
for gi in range(size):
for ri in range(size):
r, g, b = ri / n, gi / n, bi / n
if input_space == "slog3":
r, g, b = (linear_to_rec709(slog3_to_linear(c)) for c in (r, g, b))
r, g, b = apply_look(r, g, b, params)
lines.append(f"{r:.6f} {g:.6f} {b:.6f}")
tmp = path.with_suffix(".cube.tmp")
tmp.write_text("\n".join(lines) + "\n", encoding="ascii")
tmp.replace(path)
def render_previews(ffmpeg: str, media: Path, luts: list, out_dir: Path,
frame_at: float) -> list:
stills = []
base_png = out_dir / "preview_original.png"
runs = [(None, base_png)] + [(p, out_dir / f"preview_{p.stem}.png") for p in luts]
media_abs = str(media.resolve())
for lut, png in runs:
cmd = [ffmpeg, "-y", "-v", "error", "-ss", str(frame_at), "-i", media_abs]
if lut:
# Run from out_dir and reference the LUT by bare filename — a full
# path inside the filter arg hits the drive-colon escaping trap
# ("lut3d=file=C:/..." parses ':' as an option separator).
cmd += ["-vf", f"lut3d=file={lut.name}:interp=tetrahedral"]
cmd += ["-frames:v", "1", png.name]
proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(out_dir))
if proc.returncode == 0:
stills.append(png)
else:
print(f"warning: preview failed for {lut.name if lut else 'original'}: "
f"{(proc.stderr.strip().splitlines() or ['?'])[-1]}", file=sys.stderr)
cells = "\n".join(
f''
f"