From 38e6c7940c85275ab79c5012835903c9ae97ce82 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Wed, 15 Jul 2026 20:00:04 +0200 Subject: [PATCH] Stitch cleanup and grading! --- GlitchTrailer/project.json | 6 ++--- README.md | 48 ++++++++++++++++++++++++++++++++++++++ gnommo/cli.py | 30 ++++++++++-------------- gnommo/models.py | 13 ++++++----- gnommo/narration.py | 6 ++--- gnommo/preprocessor.py | 8 ++++--- gnommo/renderer.py | 5 ++-- gnommo/state.py | 2 +- 8 files changed, 81 insertions(+), 37 deletions(-) diff --git a/GlitchTrailer/project.json b/GlitchTrailer/project.json index 01fbda6..cb23a82 100644 --- a/GlitchTrailer/project.json +++ b/GlitchTrailer/project.json @@ -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, diff --git a/README.md b/README.md index 0513920..c4a9b6d 100644 --- a/README.md +++ b/README.md @@ -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 (0–5). | + +**`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 ``` diff --git a/gnommo/cli.py b/gnommo/cli.py index 7c26aea..8ecb572 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -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/ 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 diff --git a/gnommo/models.py b/gnommo/models.py index 5f11281..d4f4c99 100644 --- a/gnommo/models.py +++ b/gnommo/models.py @@ -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( diff --git a/gnommo/narration.py b/gnommo/narration.py index f877716..8e238e7 100644 --- a/gnommo/narration.py +++ b/gnommo/narration.py @@ -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 diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index 53888e3..62a270f 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -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})" diff --git a/gnommo/renderer.py b/gnommo/renderer.py index e98e5ed..c039aea 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -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: diff --git a/gnommo/state.py b/gnommo/state.py index b2d99cb..0d854eb 100644 --- a/gnommo/state.py +++ b/gnommo/state.py @@ -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