gen-luts.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317
  1. #!/usr/bin/env python3
  2. """Generate .cube 3D LUT grade variants (+ optional preview stills with HTML chooser).
  3. A .cube LUT is plain ASCII (an N^3 lattice of RGB triples), so grade candidates
  4. can be computed rather than hand-tuned in an NLE. This emits a family of looks —
  5. optionally on top of an S-Log3 -> Rec.709 conversion for log footage — and, with
  6. --previews, renders one still per look plus an index.html so a HUMAN can choose.
  7. THE AGENT NEVER PICKS THE GRADE. Generate, render previews, present the chooser,
  8. wait. Grading is a taste call (see SKILL.md / references/color-grading.md).
  9. Usage: gen-luts.py [--variants LIST|all] [--size N] [--input-space slog3|rec709]
  10. [--out-dir DIR] [--previews MEDIA [--frame-at S]] [--json]
  11. Input: no positional; --previews takes a video/image to grade stills from
  12. Output: stdout = one line per written file (or --json manifest envelope,
  13. schema claude-mods.ffmpeg-ops.luts/v1)
  14. Stderr: progress, the human-picks-the-grade reminder, errors
  15. Exit: 0 ok, 2 usage, 3 preview source missing, 5 ffmpeg missing (--previews only)
  16. Examples:
  17. gen-luts.py --variants all --out-dir work/luts
  18. gen-luts.py --variants warm_filmic,punchy,teal_orange --input-space slog3
  19. gen-luts.py --variants all --out-dir work/luts --previews footage.mp4 --frame-at 12.5
  20. gen-luts.py --variants all --json | jq -r '.data.files[]'
  21. """
  22. import argparse
  23. import json
  24. import shutil
  25. import subprocess
  26. import sys
  27. from pathlib import Path
  28. from typing import NoReturn
  29. SCHEMA = "claude-mods.ffmpeg-ops.luts/v1"
  30. EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_MISSING_DEP = 0, 2, 3, 5
  31. # Each look: white-balance temp (+warm/-cool), lift/gamma/gain (master),
  32. # per-channel gain tweaks, contrast (pivot 0.5), saturation, fade (black lift).
  33. # Optional "mix": a 3x3 channel-mix matrix applied first (rows = output R,G,B
  34. # as weights of input r,g,b) — what makes sepia/Technicolor expressible.
  35. LOOKS = {
  36. "neutral709": dict(temp=0.00, lift=0.000, gamma=1.00, gain=1.00,
  37. rgb_gain=(1.00, 1.00, 1.00), contrast=1.00, sat=1.00, fade=0.00),
  38. "warm_filmic": dict(temp=0.06, lift=0.005, gamma=0.98, gain=1.00,
  39. rgb_gain=(1.02, 1.00, 0.97), contrast=1.08, sat=1.05, fade=0.03),
  40. "punchy": dict(temp=0.01, lift=-0.010, gamma=1.00, gain=1.02,
  41. rgb_gain=(1.00, 1.00, 1.00), contrast=1.22, sat=1.25, fade=0.00),
  42. "teal_orange": dict(temp=0.02, lift=0.000, gamma=1.00, gain=1.00,
  43. rgb_gain=(1.05, 1.00, 0.94), contrast=1.10, sat=1.10, fade=0.01,
  44. shadow_teal=0.04),
  45. "cool_desat": dict(temp=-0.05, lift=0.005, gamma=1.00, gain=0.99,
  46. rgb_gain=(0.97, 1.00, 1.03), contrast=1.04, sat=0.80, fade=0.02),
  47. "bleach_bypass": dict(temp=0.00, lift=-0.005, gamma=1.00, gain=0.98,
  48. rgb_gain=(1.00, 1.00, 1.00), contrast=1.30, sat=0.45, fade=0.00),
  49. "film_fade": dict(temp=0.02, lift=0.010, gamma=1.02, gain=0.99,
  50. rgb_gain=(1.01, 1.00, 0.99), contrast=0.96, sat=0.90, fade=0.06),
  51. "golden_hour": dict(temp=0.10, lift=0.005, gamma=1.01, gain=1.00,
  52. rgb_gain=(1.04, 1.01, 0.95), contrast=1.05, sat=1.08, fade=0.02),
  53. "pastel": dict(temp=0.01, lift=0.015, gamma=1.05, gain=0.99,
  54. rgb_gain=(1.00, 1.00, 1.00), contrast=0.88, sat=0.72, fade=0.08),
  55. "noir_bw": dict(temp=0.00, lift=-0.005, gamma=1.00, gain=1.00,
  56. rgb_gain=(1.00, 1.00, 1.00), contrast=1.25, sat=0.00, fade=0.00),
  57. "sepia": dict(temp=0.00, lift=0.005, gamma=1.00, gain=1.00,
  58. rgb_gain=(1.00, 1.00, 1.00), contrast=1.02, sat=1.00, fade=0.02,
  59. mix=((.393, .769, .189), (.349, .686, .168), (.272, .534, .131))),
  60. "technicolor2": dict(temp=0.00, lift=0.000, gamma=1.00, gain=1.00,
  61. rgb_gain=(1.00, 1.00, 1.00), contrast=1.10, sat=1.20, fade=0.00,
  62. mix=((1.0, 0.0, 0.0), (0.0, 0.6, 0.4), (0.0, 0.4, 0.6))),
  63. "matrix_green": dict(temp=0.00, lift=0.005, gamma=1.00, gain=1.00,
  64. rgb_gain=(0.97, 1.06, 0.98), contrast=1.10, sat=0.85, fade=0.02),
  65. # Scope-extracted from reference footage (see look-recipes.md grimdark):
  66. # warm-ash desat, pulled mids, true-ish blacks, controlled ceiling.
  67. "grimdark": dict(temp=0.015, lift=0.000, gamma=0.93, gain=0.97,
  68. rgb_gain=(1.02, 1.01, 0.98), contrast=1.04, sat=0.33, fade=0.03),
  69. }
  70. # Tone-map variants: gradient-map luma onto 2 stops (duotone) or 3 stops
  71. # (tritone/monotone: shadow, mid, highlight), all 0..1 RGB. Chroma of the look
  72. # = how far the stops sit from the neutral grey axis - monotones barely leave
  73. # it, poster duotones live far out. "contrast" applies pre-map (widens spread).
  74. _TONE_BASE = dict(temp=0.0, lift=0.0, gamma=1.0, gain=1.0,
  75. rgb_gain=(1.0, 1.0, 1.0), contrast=1.05, sat=1.0, fade=0.0)
  76. LOOKS.update({
  77. # poster-strength duotones
  78. "duo_navy": {**_TONE_BASE, "tones": ((.05, .08, .25), (.98, .93, .80))},
  79. "duo_cyanotype": {**_TONE_BASE, "tones": ((.04, .16, .29), (.92, .96, 1.0))},
  80. "duo_sunset": {**_TONE_BASE, "tones": ((.23, .06, .36), (1.0, .78, .34))},
  81. "duo_forest": {**_TONE_BASE, "tones": ((.06, .24, .18), (.91, .85, .63))},
  82. "duo_crimson": {**_TONE_BASE, "tones": ((.10, .02, .03), (1.0, .88, .86))},
  83. "duo_synthwave": {**_TONE_BASE, "tones": ((.35, .06, .42), (.42, .91, 1.0))},
  84. # muted / tertiary duotones
  85. "duo_ash_rose": {**_TONE_BASE, "tones": ((.23, .20, .22), (.85, .78, .76))},
  86. "duo_olive_bone": {**_TONE_BASE, "tones": ((.18, .20, .14), (.90, .88, .81))},
  87. "duo_petrol_paper": {**_TONE_BASE, "tones": ((.12, .23, .24), (.93, .91, .86))},
  88. "duo_indigo_parchment": {**_TONE_BASE, "tones": ((.16, .23, .33), (.91, .89, .82))},
  89. "duo_slate_ice": {**_TONE_BASE, "tones": ((.11, .15, .20), (.95, .97, .98))},
  90. # monotones (darkroom chemical tones - chroma barely off the grey axis)
  91. "mono_selenium": {**_TONE_BASE, "tones": ((.05, .04, .07), (.48, .46, .52), (.96, .95, .97))},
  92. "mono_platinum": {**_TONE_BASE, "tones": ((.07, .07, .06), (.52, .51, .49), (.97, .96, .94))},
  93. "mono_coffee": {**_TONE_BASE, "tones": ((.08, .05, .03), (.55, .47, .40), (.96, .92, .87))},
  94. "mono_steel": {**_TONE_BASE, "tones": ((.04, .06, .09), (.46, .50, .55), (.94, .96, .98))},
  95. # tritones (distinct shadow / mid / highlight hues)
  96. "tri_split_classic": {**_TONE_BASE, "tones": ((.06, .07, .12), (.50, .49, .48), (.98, .94, .86))},
  97. "tri_tobacco": {**_TONE_BASE, "tones": ((.05, .04, .02), (.45, .40, .28), (.95, .88, .70))},
  98. "tri_arctic": {**_TONE_BASE, "tones": ((.03, .05, .09), (.42, .50, .58), (.93, .97, 1.0))},
  99. })
  100. def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
  101. if json_mode:
  102. print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
  103. print(f"ERROR: {message}", file=sys.stderr)
  104. sys.exit(exit_code)
  105. def clamp(x: float) -> float:
  106. return 0.0 if x < 0.0 else 1.0 if x > 1.0 else x
  107. def slog3_to_linear(x: float) -> float:
  108. """Sony S-Log3 EOTF (input 0..1 code value -> scene linear)."""
  109. if x >= 171.2102946929 / 1023.0:
  110. return (10.0 ** ((x * 1023.0 - 420.0) / 261.5)) * 0.19 - 0.01
  111. return (x * 1023.0 - 95.0) * 0.01125 / (171.2102946929 - 95.0)
  112. def linear_to_rec709(x: float) -> float:
  113. """BT.709 OETF with a Reinhard-style shoulder for >1.0 scene values."""
  114. x = max(0.0, x)
  115. x = x / (1.0 + 0.35 * x) # soft highlight roll-off
  116. if x < 0.018:
  117. return 4.5 * x
  118. return 1.099 * (x ** 0.45) - 0.099
  119. def apply_look(r: float, g: float, b: float, p: dict) -> tuple:
  120. # Channel mix first (sepia/Technicolor-class looks), then white balance.
  121. mix = p.get("mix")
  122. if mix:
  123. r, g, b = (mix[0][0] * r + mix[0][1] * g + mix[0][2] * b,
  124. mix[1][0] * r + mix[1][1] * g + mix[1][2] * b,
  125. mix[2][0] * r + mix[2][1] * g + mix[2][2] * b)
  126. t = p["temp"]
  127. r, b = r * (1.0 + t), b * (1.0 - t)
  128. # Lift / gamma / gain (master), then per-channel gain.
  129. out = []
  130. for c, cg in zip((r, g, b), p["rgb_gain"]):
  131. c = c * p["gain"] * cg + p["lift"] * (1.0 - c)
  132. c = clamp(c) ** (1.0 / p["gamma"])
  133. out.append(c)
  134. r, g, b = out
  135. # Teal/orange split-tone: push shadows toward teal (complement of the warm gain).
  136. st = p.get("shadow_teal", 0.0)
  137. if st:
  138. luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
  139. w = (1.0 - luma) ** 2 # weight shadows only
  140. r, b = r - st * w, b + st * w
  141. # Contrast around mid pivot.
  142. k = p["contrast"]
  143. r, g, b = (0.5 + (c - 0.5) * k for c in (r, g, b))
  144. # Tone gradient map (replaces saturation): 2 stops = duotone lerp,
  145. # 3 stops = piecewise shadow->mid (luma 0..0.5) -> highlight (0.5..1).
  146. tones = p.get("tones")
  147. luma = 0.2126 * r + 0.7152 * g + 0.0722 * b
  148. if tones:
  149. luma = clamp(luma)
  150. if len(tones) == 3:
  151. lo, hi = (tones[0], tones[1]) if luma < 0.5 else (tones[1], tones[2])
  152. f2 = luma * 2 if luma < 0.5 else (luma - 0.5) * 2
  153. else:
  154. lo, hi, f2 = tones[0], tones[1], luma
  155. r, g, b = (lo[i] + f2 * (hi[i] - lo[i]) for i in range(3))
  156. else:
  157. s = p["sat"]
  158. r, g, b = (luma + s * (c - luma) for c in (r, g, b))
  159. # Fade (lifted blacks).
  160. f = p["fade"]
  161. r, g, b = (f + c * (1.0 - f) for c in (r, g, b))
  162. return clamp(r), clamp(g), clamp(b)
  163. def write_cube(path: Path, name: str, size: int, input_space: str, params: dict) -> None:
  164. lines = [f'# generated by claude-mods ffmpeg-ops gen-luts.py',
  165. f'# look={name} input_space={input_space}',
  166. f'TITLE "{name}"',
  167. f'LUT_3D_SIZE {size}',
  168. 'DOMAIN_MIN 0.0 0.0 0.0',
  169. 'DOMAIN_MAX 1.0 1.0 1.0']
  170. n = size - 1
  171. for bi in range(size): # .cube order: red varies fastest
  172. for gi in range(size):
  173. for ri in range(size):
  174. r, g, b = ri / n, gi / n, bi / n
  175. if input_space == "slog3":
  176. r, g, b = (linear_to_rec709(slog3_to_linear(c)) for c in (r, g, b))
  177. r, g, b = apply_look(r, g, b, params)
  178. lines.append(f"{r:.6f} {g:.6f} {b:.6f}")
  179. tmp = path.with_suffix(".cube.tmp")
  180. tmp.write_text("\n".join(lines) + "\n", encoding="ascii")
  181. tmp.replace(path)
  182. def render_previews(ffmpeg: str, media: Path, luts: list, out_dir: Path,
  183. frame_at: float) -> list:
  184. stills = []
  185. base_png = out_dir / "preview_original.png"
  186. runs = [(None, base_png)] + [(p, out_dir / f"preview_{p.stem}.png") for p in luts]
  187. media_abs = str(media.resolve())
  188. for lut, png in runs:
  189. cmd = [ffmpeg, "-y", "-v", "error", "-ss", str(frame_at), "-i", media_abs]
  190. if lut:
  191. # Run from out_dir and reference the LUT by bare filename — a full
  192. # path inside the filter arg hits the drive-colon escaping trap
  193. # ("lut3d=file=C:/..." parses ':' as an option separator).
  194. cmd += ["-vf", f"lut3d=file={lut.name}:interp=tetrahedral"]
  195. cmd += ["-frames:v", "1", png.name]
  196. proc = subprocess.run(cmd, capture_output=True, text=True, cwd=str(out_dir))
  197. if proc.returncode == 0:
  198. stills.append(png)
  199. else:
  200. print(f"warning: preview failed for {lut.name if lut else 'original'}: "
  201. f"{(proc.stderr.strip().splitlines() or ['?'])[-1]}", file=sys.stderr)
  202. cells = "\n".join(
  203. f'<figure><img src="{p.name}" loading="lazy">'
  204. f"<figcaption>{p.stem.replace('preview_', '')}</figcaption></figure>"
  205. for p in stills)
  206. (out_dir / "index.html").write_text(
  207. "<!doctype html><meta charset='utf-8'><title>Pick a grade</title>"
  208. "<style>body{background:#111;color:#eee;font:14px system-ui;margin:24px}"
  209. "main{display:grid;grid-template-columns:repeat(auto-fill,minmax(420px,1fr));gap:16px}"
  210. "img{width:100%;border-radius:6px}figcaption{margin-top:4px;text-align:center}"
  211. "</style><h1>Pick a grade</h1><main>" + cells + "</main>\n",
  212. encoding="utf-8")
  213. return stills
  214. def main() -> int:
  215. ap = argparse.ArgumentParser(
  216. description="Generate .cube grade variants; optionally render a preview chooser.",
  217. epilog="Examples:\n"
  218. " gen-luts.py --variants all --out-dir work/luts\n"
  219. " gen-luts.py --variants all --previews footage.mp4 --frame-at 12.5\n",
  220. formatter_class=argparse.RawDescriptionHelpFormatter)
  221. ap.add_argument("--variants", default="all",
  222. help=f"comma list or 'all' of: {', '.join(LOOKS)} (default all)")
  223. ap.add_argument("--size", type=int, default=33, choices=(17, 33, 65),
  224. help="lattice points per axis (default 33)")
  225. ap.add_argument("--input-space", default="rec709", choices=("rec709", "slog3"),
  226. help="source space; slog3 bakes an S-Log3->Rec.709 conversion in")
  227. ap.add_argument("--out-dir", default="luts", help="output directory (default ./luts)")
  228. ap.add_argument("--previews", default=None, metavar="MEDIA",
  229. help="render a graded still per LUT from this video/image + index.html")
  230. ap.add_argument("--frame-at", type=float, default=5.0,
  231. help="timestamp for the preview frame (default 5.0s)")
  232. ap.add_argument("--json", action="store_true", help="emit JSON manifest on stdout")
  233. args = ap.parse_args()
  234. if args.variants.strip().lower() == "all":
  235. names = list(LOOKS)
  236. else:
  237. names = [v.strip() for v in args.variants.split(",") if v.strip()]
  238. unknown = [n for n in names if n not in LOOKS]
  239. if unknown or not names:
  240. err(args.json, "USAGE",
  241. f"unknown look(s): {', '.join(unknown) or '(none given)'} "
  242. f"(available: {', '.join(LOOKS)})", EXIT_USAGE)
  243. ffmpeg = None
  244. media = None
  245. if args.previews:
  246. ffmpeg = shutil.which("ffmpeg")
  247. if not ffmpeg:
  248. err(args.json, "MISSING_DEPENDENCY",
  249. "ffmpeg not found on PATH (required for --previews)", EXIT_MISSING_DEP)
  250. media = Path(args.previews)
  251. if not media.is_file():
  252. err(args.json, "NOT_FOUND", f"preview source not found: {media}",
  253. EXIT_NOT_FOUND)
  254. out_dir = Path(args.out_dir)
  255. out_dir.mkdir(parents=True, exist_ok=True)
  256. written = []
  257. for name in names:
  258. path = out_dir / f"{name}.cube"
  259. print(f"writing {path.name} ({args.size}^3, {args.input_space})...",
  260. file=sys.stderr)
  261. write_cube(path, name, args.size, args.input_space, LOOKS[name])
  262. written.append(path)
  263. stills = []
  264. if args.previews and ffmpeg and media:
  265. print("rendering preview stills...", file=sys.stderr)
  266. stills = render_previews(ffmpeg, media, written, out_dir, args.frame_at)
  267. data = {"out_dir": str(out_dir), "size": args.size,
  268. "input_space": args.input_space,
  269. "files": [str(p) for p in written],
  270. "previews": [str(p) for p in stills],
  271. "chooser": str(out_dir / "index.html") if stills else None}
  272. if args.json:
  273. print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
  274. else:
  275. for p in written + stills:
  276. print(p)
  277. if stills:
  278. print(out_dir / "index.html")
  279. print("REMINDER: present the chooser to the human — never auto-pick a grade.",
  280. file=sys.stderr)
  281. return EXIT_OK
  282. if __name__ == "__main__":
  283. sys.exit(main())