Adding grading sweep and autokeying

This commit is contained in:
2026-07-19 01:39:02 +02:00
parent 4a05c1c78d
commit 17e35920b6
4 changed files with 69 additions and 23 deletions
+23 -4
View File
@@ -111,6 +111,29 @@ gnommo -p myproject import
--- ---
### Stage 3: Grading
Iterate on keying/grading without running a full preprocess. It seeks a few
seconds into a raw clip, runs it through the `talkinghead` filter chain, and
writes `grade_preview.mov` (ProRes 4444 with alpha) to the project root.
# 1. KEY — auto, objective. Generates a candidate + preview, applies on pick.
gnommo -p video3 grade --stage key
open video3/grade_sweep/key_1.png # sanity-check the matte
gnommo -p video3 grade --pick key_1 # writes it to project.json
# 2. DESPILL — sweep, YOU pick. 7 stills, spill_suppress 0.71.5.
gnommo -p video3 grade --stage despill
open video3/grade_sweep/ # eyeball despill_1..7.png
gnommo -p video3 grade --pick despill_5 # apply whichever looks clean
# 3. GRADE — sweep, YOU pick. 6 stills, paleness 0.01.0.
gnommo -p video3 grade --stage grade
open video3/grade_sweep/ # eyeball grade_1..6.png
gnommo -p video3 grade --pick grade_3 # apply the paleness you like
### Stage 3: Preprocess ### Stage 3: Preprocess
Applies video filters (chroma key, scaling, etc.) to narration segments. Applies video filters (chroma key, scaling, etc.) to narration segments.
@@ -167,11 +190,7 @@ some blue, saturated yellow fabric reflects almost none. `yellow_protect`
(yellow) while leaving skin/scalp spill fully suppressed. Bump it toward `1.0` (yellow) while leaving skin/scalp spill fully suppressed. Bump it toward `1.0`
if warm colours go orange; leave at `0` if you have no strong yellows. if warm colours go orange; leave at `0` if you have no strong yellows.
#### Grade preview (`grade`)
Iterate on keying/grading without running a full preprocess. It seeks a few
seconds into a raw clip, runs it through the `talkinghead` filter chain, and
writes `grade_preview.mov` (ProRes 4444 with alpha) to the project root.
```bash ```bash
gnommo -p myproject grade # first raw_mov clip, 3s from 5s in gnommo -p myproject grade # first raw_mov clip, 3s from 5s in
+26 -19
View File
@@ -4885,43 +4885,50 @@ def _stage_despill(project_path, filters, ref, out_dir, source_name, ss) -> int:
def _stage_grade(project_path, filters, ref, out_dir, source_name, ss) -> int: def _stage_grade(project_path, filters, ref, out_dir, source_name, ss) -> int:
"""Deterministic paleness sweep 0.01.0 → color_grade candidates to pick.""" """Deterministic 'look' sweep 0.01.0 → punchy color_grade candidates.
base_cg = _grade_step(filters, "color_grade") or {}
The dial ramps a Photoshop-auto-levels-style contrast/levels stretch plus
vividness (saturation) and a slight pale lean, from flat (0) to strong (1),
so the candidates differ boldly instead of by a hair."""
key = _grade_step(filters, "gnommokey") key = _grade_step(filters, "gnommokey")
contrast = float(base_cg.get("contrast", 1.05)) values = [round(i / 5, 1) for i in range(6)] # look 0.0 .. 1.0
values = [round(i / 5, 1) for i in range(6)] # paleness 0.0 .. 1.0 print(" Sweeping look (auto-levels + vividness) 0.0 1.0")
print(" Sweeping paleness 0.0 → 1.0")
candidates = [] candidates = []
for i, p in enumerate(values, 1): for i, p in enumerate(values, 1):
cg = {"type": "color_grade", "contrast": contrast, cg = {
"rm": round(-0.10 * p, 3), "gm": round(0.02 * p, 3), "type": "color_grade",
"saturation": round(1.0 - 0.15 * p, 3), "brightness": round(0.05 * p, 3)} "auto_levels": round(p, 3), # levels/contrast punch
"saturation": round(1.0 + 0.5 * p, 3), # 1.0 → 1.5 vivid
"contrast": round(1.0 + 0.15 * p, 3), # gentle, colorlevels adds the rest
"brightness": round(0.02 * p, 3),
"rm": round(-0.04 * p, 3), # a touch paler/cooler
}
chain = _mask_and(filters, key, cg) chain = _mask_and(filters, key, cg)
skin = _measure_skin(ref, chain) skin = _measure_skin(ref, chain)
png = out_dir / f"grade_{i}.png" png = out_dir / f"grade_{i}.png"
_render_preview(ref, chain, png) _render_preview(ref, chain, png)
params = {k: cg[k] for k in ("rm", "gm", "saturation", "brightness", "contrast")} params = {k: cg[k] for k in ("auto_levels", "saturation", "contrast", "brightness", "rm")}
params["paleness"] = p params["look"] = p
candidates.append({ candidates.append({
"id": f"grade_{i}", "file": png.name, "params": params, "id": f"grade_{i}", "file": png.name, "params": params,
"hint": {"skin_rgb": list(skin), "warmth": skin[0] - skin[2]}, "hint": {"skin_rgb": list(skin), "warmth": skin[0] - skin[2]},
}) })
manifest = { manifest = {
"stage": "grade", "target_step": "color_grade", "stage": "grade", "target_step": "color_grade",
"source": source_name, "ss": round(ss, 2), "swept": ["paleness"], "source": source_name, "ss": round(ss, 2), "swept": ["look"],
"recommended": "grade_3", "candidates": candidates, "recommended": "grade_4", "candidates": candidates,
} }
mpath = _grade_write_manifest(out_dir, manifest) mpath = _grade_write_manifest(out_dir, manifest)
rec = manifest["recommended"] rec = manifest["recommended"]
print(f" {'id':>10} {'paleness':>8} {'skin RGB':>17} {'warmth':>6} png") print(f" {'id':>10} {'look':>5} {'sat':>5} {'skin RGB':>17} {'warmth':>6} png")
print(f" {'-'*10} {'-'*8} {'-'*17} {'-'*6} {'-'*3}") print(f" {'-'*10} {'-'*5} {'-'*5} {'-'*17} {'-'*6} {'-'*3}")
for c in candidates: for c in candidates:
star = " ◀ suggested" if c["id"] == rec else "" star = " ◀ suggested" if c["id"] == rec else ""
print(f" {c['id']:>10} {c['params']['paleness']:>8} " print(f" {c['id']:>10} {c['params']['look']:>5} {c['params']['saturation']:>5} "
f"{str(tuple(c['hint']['skin_rgb'])):>17} {c['hint']['warmth']:>6} {c['file']}{star}") f"{str(tuple(c['hint']['skin_rgb'])):>17} {c['hint']['warmth']:>6} {c['file']}{star}")
print(f"\n Manifest: {mpath}") print(f"\n Manifest: {mpath}")
print(" Pick the paleness you like:") print(" Pick the look you like (flat → punchy):")
print(f" gnommo -p {project_path.name} grade --pick grade_3") print(f" gnommo -p {project_path.name} grade --pick grade_4")
return 0 return 0
@@ -4948,8 +4955,8 @@ def _grade_pick(project_path, stage_hint, pick) -> int:
if cand is None: if cand is None:
print(f" ERROR: candidate '{cand_id}' not in {mpath.name}.") print(f" ERROR: candidate '{cand_id}' not in {mpath.name}.")
return 1 return 1
# 'paleness' is a UI-only dial, not a real color_grade field. # 'look'/'paleness' are UI-only dials, not real color_grade fields.
params = {k: v for k, v in cand["params"].items() if k != "paleness"} params = {k: v for k, v in cand["params"].items() if k not in ("look", "paleness")}
_apply_candidate_to_project(project_path, manifest["target_step"], params) _apply_candidate_to_project(project_path, manifest["target_step"], params)
print(f" Applied {cand_id} → project.json ({manifest['target_step']}): " print(f" Applied {cand_id} → project.json ({manifest['target_step']}): "
+ ", ".join(f"{k}={v}" for k, v in params.items())) + ", ".join(f"{k}={v}" for k, v in params.items()))
+6
View File
@@ -196,6 +196,12 @@ class ColorGradeConfig:
brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change) brightness: float = 0.0 # Brightness adjustment (-1.0 to 1.0, 0 = no change)
saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change) saturation: float = 1.0 # Saturation multiplier (0.0-3.0, 1.0 = no change)
# Auto-levels: a fixed histogram stretch (crush blacks, lift whites) for
# punch, like Photoshop auto-levels. 0 = off, 1 = strong. Fixed (not
# per-frame adaptive) so it can't flicker; it remaps a constant [lo,hi]
# window to full range, so the keyed-out background never skews it.
auto_levels: float = 0.0
# Custom curves for lift/gamma/gain control # Custom curves for lift/gamma/gain control
# Format: "0/0 0.5/0.56 1/1" means (input/output) control points # Format: "0/0 0.5/0.56 1/1" means (input/output) control points
curves_r: str = "" # Red channel curve curves_r: str = "" # Red channel curve
+14
View File
@@ -1004,6 +1004,19 @@ def build_color_grade_filter(config: dict) -> str:
# Start with format conversion to RGBA for color operations # Start with format conversion to RGBA for color operations
parts.append("format=rgba") parts.append("format=rgba")
# Auto-levels: a fixed contrast/levels stretch for punch. Crushes blacks and
# lifts whites by remapping [lo, hi] → [0, 1] equally per channel (luminance
# levels, no colour shift). Fixed points → no per-frame flicker, and no
# histogram is measured so the transparent green background can't skew it.
if grade_config.auto_levels > 0:
al = min(max(grade_config.auto_levels, 0.0), 1.0)
lo = round(0.10 * al, 4)
hi = round(1.0 - 0.10 * al, 4)
parts.append(
f"colorlevels=rimin={lo}:gimin={lo}:bimin={lo}:"
f"rimax={hi}:gimax={hi}:bimax={hi}"
)
# Color balance (only add if any value is non-zero) # Color balance (only add if any value is non-zero)
colorbalance_parts = [] colorbalance_parts = []
if grade_config.rs != 0: if grade_config.rs != 0:
@@ -1085,6 +1098,7 @@ def parse_color_grade_config(config: dict) -> ColorGradeConfig:
contrast=float(config.get("contrast", 1.0)), contrast=float(config.get("contrast", 1.0)),
brightness=float(config.get("brightness", 0.0)), brightness=float(config.get("brightness", 0.0)),
saturation=float(config.get("saturation", 1.0)), saturation=float(config.get("saturation", 1.0)),
auto_levels=float(config.get("auto_levels", 0.0)),
# Custom curves # Custom curves
curves_r=config.get("curves_r", ""), curves_r=config.get("curves_r", ""),
curves_g=config.get("curves_g", ""), curves_g=config.get("curves_g", ""),