Stitch cleanup and grading!
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
```
|
||||
|
||||
+12
-18
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user