Stitch cleanup and grading!

This commit is contained in:
2026-07-15 20:00:04 +02:00
parent 83715e85e8
commit 38e6c7940c
8 changed files with 81 additions and 37 deletions
+3 -3
View File
@@ -48,9 +48,9 @@
"screen_gain": 175,
"screen_balance": 58,
"despill_bias": [
217,
240,
255
235,
222,
210
],
"despill_strength": 5.0,
"edge_erode": 1.0,
+48
View File
@@ -126,6 +126,53 @@ gnommo -p myproject pre
**When to use:** After recording narration that needs background removal, sound normalization or other processing.
#### Green screen keying (`gnommokey`)
The `talkinghead` preset in `project.json` chains a `gnommokey` keyer (a
Keylight-style color-difference keyer) with a `color_grade` step. Key fields:
| Field | What it does |
|-------|--------------|
| `screen_color` | RGB of your green screen (sampled from the footage). |
| `screen_gain` | Key extraction strength. Higher = more aggressive matte. |
| `screen_balance` | Mixes luminance into the key (helps darker/lighter greens). |
| `clip_black` / `clip_white` | Compress the matte range (crush transparent / opaque). |
| `despill_bias` | RGB the keyed *edges* shift toward. A light neutral/skin tone reads better than cool blue. |
| `despill_strength` | How hard the edge/dominant-green despill pulls toward `despill_bias`. |
| `spill_suppress` | **Interior green-limiter — see below.** |
| `edge_erode` | Shrinks the alpha edge by N passes to kill green fringe (05). |
**`spill_suppress` — the bald-head knob.** The regular despill only acts where
green is the *dominant* channel (`g > max(r,b)`). On skin — especially a bald
head catching green bounce — red stays dominant, so that despill never touches
the interior and a green cast survives. `spill_suppress` caps green across the
**whole frame** at a reference extrapolated through the other two channels:
```
0.0 → cap at max(r, b) (only strong spill; effectively off)
1.0 → cap at avg(r, b) (removes visible cast)
2.0 → cap at min(r, b) (green can never exceed the smallest channel — nuclear)
```
Green is only ever *reduced*, never boosted, so clean pixels are untouched.
Start around `0.6`; go past `1.0` for heavy close-up spill. If skin tips
magenta/pink, ease back down. Range is `0.0``2.0`.
#### 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
gnommo -p myproject grade # first raw_mov clip, 3s from 5s in
gnommo -p myproject grade --ss 12 --dur 4 # seek 12s in, make a 4s preview
gnommo -p myproject grade --file media/narration/raw_mov/clipA.mov
```
Tweak the `gnommokey` / `color_grade` values in `project.json`, re-run `grade`,
re-open the `.mov`, repeat until it looks right.
---
### Stage 4: stitch
@@ -267,6 +314,7 @@ gnommo -p myproject render
```bash
gnommo -p myproject validate # Check for errors without rendering
gnommo -p myproject grade # Preview the keyer/grade on a few seconds of raw footage
gnommo -p myproject description # Generate YouTube description with chapters
gnommo -p myproject transcribe --final # Transcribe final.mp4 for subtitles
```
+12 -18
View File
@@ -1313,7 +1313,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
media/narration/processed/ ← chroma-keyed output (preprocess)
media/narration/narration.json
Scans processed/ for ready-to-stitch files and raw/ for any new raw
Scans processed/ for ready-to-render files and raw/ for any new raw
recordings not yet represented in narration.json.
Priority: processed/ files define the segment catalogue.
@@ -1410,7 +1410,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
}
narration_entry["use_audio_channels"] = "auto"
# Loudnorm is applied per-segment during preprocess (not deferred to
# stitch), so the processed files are already normalized and ready to be
# preprocess), so the processed files are already normalized and ready to be
# concatenated directly at render time.
narration_entry["defer_loudnorm"] = False
@@ -1438,7 +1438,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
narration_entry["use_audio_channels"] = "auto"
# Loudnorm is applied per-segment during preprocess (not deferred to
# stitch), so the processed files are already normalized and ready to be
# preprocess), so the processed files are already normalized and ready to be
# concatenated directly at render time.
narration_entry["defer_loudnorm"] = False
@@ -2367,7 +2367,7 @@ def cmd_preprocess(
cache_root = _resolve_process_cache(project_path, config)
if cache_root:
# Mirror the project's media/ structure so GnommoCache (resolve_with_cache)
# finds these files transparently during render/stitch.
# finds these files transparently during render.
cache_narration_dir = cache_root / "media" / "narration"
cache_narration_dir.mkdir(parents=True, exist_ok=True)
(cache_narration_dir / "processed").mkdir(parents=True, exist_ok=True)
@@ -2459,9 +2459,8 @@ def cmd_preprocess(
if using_compressed and segment_id.endswith("_compressed"):
segment_id = segment_id[: -len("_compressed")]
# For non-full res, write into the res subdir so stitch --res low finds the
# files at narration/low/processed/ (narration.json still records the plain
# "processed/..." path; stitch shifts the base dir itself).
# For non-full res, write processed outputs into the res subdir.
# (narration.json still records the plain "processed/..." path.)
_res_cfg = RES_CONFIGS.get(res) if res != "full" else None
if _res_cfg:
_, _, _subdir = _res_cfg
@@ -2469,7 +2468,7 @@ def cmd_preprocess(
else:
output_file = f"processed/{segment_id}_processed.mov"
# When process_cache is set, output goes to the cache dir; narration.json
# still records the relative path so stitch (also using cache) can find it.
# still records the relative path so render (also using cache) can find it.
output_base = cache_narration_dir or narration_dir
output_path = output_base / output_file
@@ -2606,7 +2605,7 @@ def cmd_preprocess(
for key in _PRESERVE_KEYS:
if key in existing_entry:
entry[key] = existing_entry[key]
# Always record the plain path; stitch shifts the base dir for low/tiny.
# Always record the plain path; the res subdir shift happens at render for low/tiny.
entry["source_file"] = f"processed/{segment_id}_processed.mov"
entry.setdefault("use_audio_channels", "auto")
entry.setdefault("defer_loudnorm", False)
@@ -3120,7 +3119,7 @@ def _transcode_processed_files(
1. Transcode to a temp file using hevc_videotoolbox with alpha.
2. Move the ProRes original into a prores/ subdirectory (never deleted).
3. Rename the compressed file to the original _processed.mov name
so stitch/render find it unchanged.
so render finds it unchanged.
The prores/ subdirectory is never scanned — only top-level files are candidates.
If prores/<filename> already exists the file has already been compressed —
@@ -3297,7 +3296,7 @@ def cmd_transcode(
Compress _processed.mov files (ProRes 4444 + alpha) to HEVC+alpha.
Archives the ProRes original as _prores.mov (never deleted).
The compressed file takes the original _processed.mov name so the
rest of the pipeline (stitch, render) finds it unchanged.
rest of the pipeline (render) finds it unchanged.
Uses Apple VideoToolbox (hevc_videotoolbox) with --alpha-quality.
"""
if processed:
@@ -3440,11 +3439,6 @@ def cmd_transcode(
return 0
# =============================================================================
# Stitch Command (fast iteration on narration segments)
# =============================================================================
# =============================================================================
# Render Command
# =============================================================================
@@ -4292,7 +4286,7 @@ def cmd_render(
)
# Timestamp guard (make-style): even if the fingerprint matches, re-render
# when a key upstream artifact — the stitched narration or its transcript —
# when a key upstream artifact — the concatenated narration or its transcript —
# is newer than the rendered output. This catches cases the fingerprint
# can't: the state file isn't transferred to the render rig (it's a dotfile),
# and the combined may live on an external cache disk. If the previous stage
@@ -4674,7 +4668,7 @@ def cmd_all(
result = cmd_trim(project_path, verbose, force=cascade_force, threshold_db=-40.0)
if result != 0:
return result
# Trim modifies narration.json skip/take values; any change invalidates stitch
# Trim modifies narration.json skip/take values; any change invalidates the render
if _files_modified_since(project_path, t0, "narration.json"):
cascade_force = True
+7 -6
View File
@@ -132,11 +132,12 @@ class GnommoKeyConfig:
# How aggressively to apply despill (0-1)
despill_strength: float = 0.5
# Interior green-limiter (0.0-1.0, 0 = off). Suppresses green cast/spill
# Interior green-limiter (0.0-2.0, 0 = off). Suppresses green cast/spill
# across the whole frame even where green is NOT the dominant channel — the
# case the bias/edge despill misses (e.g. green bounce on skin/a bald head).
# Caps green at a reference blended between max(r,b) [0.0] and the r/b
# average [1.0]. 0.5-0.7 removes cast without pushing skin magenta.
# Caps green at a reference through the other two channels: max(r,b) [0.0] ->
# average [1.0] -> min(r,b) [2.0]. 0.5-0.7 for light cast; >1.0 for heavy
# close-up spill (2.0 = green can never exceed the smallest channel).
spill_suppress: float = 0.0
# Alpha bias: influences edge treatment (RGB)
@@ -534,9 +535,9 @@ class RenderPlan:
default_factory=list
) # Gaps in narration for interstitial videos
# Render-time narration concat: ordered segments (skip/take + offset) to
# concatenate directly at render time instead of using a single pre-stitched
# single pre-stitched narration input. Typed loosely (list of narration.NarrationSegment)
# to avoid a circular import between models and narration.
# concatenate directly at render time. Typed loosely (list of
# narration.NarrationSegment) to avoid a circular import between models and
# narration.
narration_segments: list = field(default_factory=list)
# Outro sequence (plays after narration ends)
outro_events: list["OutroEvent"] = field(
+3 -3
View File
@@ -1,6 +1,6 @@
"""Deterministic narration scheduling for render-time segment stitching.
"""Deterministic narration scheduling for render-time segment concatenation.
Rather than pre-stitching segments into one file, the render stage
Rather than pre-concatenating segments into one file, the render stage
concatenates the processed segments directly. From narration.json + the cached
per-segment transcripts this module computes two things:
@@ -8,7 +8,7 @@ per-segment transcripts this module computes two things:
combined timeline) — this drives the ffmpeg concat at render time; and
2. the merged word-level transcript, with every word re-timed into the
combined timeline — this drives slide alignment, exactly what
re-transcribing a pre-stitched narration file used to produce, but derived
re-transcribing a pre-concatenated narration file used to produce, but derived
deterministically (no re-transcription, no separate combined file).
The processed files share framerate and format and are uncompressed, so the
+5 -3
View File
@@ -1209,10 +1209,12 @@ def build_gnommokey_filter(config: dict) -> str:
# Interior spill suppression: cap the spill channel across the WHOLE frame,
# including fully-opaque interior pixels the bias/edge despill can't reach
# (green bounce on skin, a bald head, etc.). Caps the channel at a reference
# blended between max(other two) [t=0] and their average [t=1]; green is only
# ever reduced, never boosted, so non-spilled pixels are untouched.
# extrapolated through the other two channels: max [t=0] -> average [t=1] ->
# min [t=2] (since r+b-max == min). Values >1 push green below the r/b
# average for heavy close-up spill. Green is only ever reduced, never
# boosted, so non-spilled pixels are untouched.
if cfg.spill_suppress > 0:
t = min(max(cfg.spill_suppress, 0.0), 1.0)
t = min(max(cfg.spill_suppress, 0.0), 2.0)
if is_green_screen:
ref = f"((1-{t:.3f})*max(r(X,Y),b(X,Y))+{t:.3f}*(r(X,Y)+b(X,Y))/2)"
new_g = f"min(g(X,Y),{ref})"
+2 -3
View File
@@ -384,9 +384,8 @@ def build_ffmpeg_command(plan: RenderPlan, output_path: Path) -> list[str]:
# Input: narration (talking head).
# Concat mode: when plan.narration_segments is set, add each processed segment
# as its own input (trimmed by skip/take) and concatenate them in-graph into a
# single normalized narration stream — replacing the pre-stitched
# single pre-stitched narration input. Otherwise, the single always-visible input path.
# Add -ss seek BEFORE -i for skip parameter and/or partial rendering.
# single normalized narration stream. Otherwise, the single always-visible
# input path. Add -ss seek BEFORE -i for skip parameter and/or partial rendering.
always_visible_inputs: list[int] = []
narration_concat = None # (video_label, audio_label) when concat mode is active
if plan.narration_segments:
+1 -1
View File
@@ -1,6 +1,6 @@
"""Persistent per-stage completion tracking.
Each pipeline stage (preprocess, trim, stitch, render) records a fingerprint of
Each pipeline stage (preprocess, trim, render) records a fingerprint of
its inputs in ``.gnommo_state.json`` when it completes successfully. On the next
run a stage can ask whether its inputs are unchanged (and its output still
present) and skip the work the same staleness intelligence that ``all``'s