From b1136a9f7d70e1608a831e03f05419846a73582c Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Thu, 23 Jul 2026 15:08:59 +0200 Subject: [PATCH] Adding sweep for keying --- gnommo/cli.py | 106 +++++++++++++++++++++++++++-------------- gnommo/parser.py | 6 ++- gnommo/preprocessor.py | 17 +++++-- 3 files changed, 90 insertions(+), 39 deletions(-) diff --git a/gnommo/cli.py b/gnommo/cli.py index 4c0e8e5..2a32b69 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -1459,6 +1459,35 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No normalised[lower_id] = seg_data existing_narration = normalised + # Migrate legacy entries to the raw/processed split. Older runs stored the + # processed output in source_file (e.g. "processed/S1-end_processed.mov"). + # New model: source_file = raw recording, processed_file = processed output, + # so the project can be rendered from raw before the preprocess stage runs. + migrated_count = 0 + for seg_id, entry in existing_narration.items(): + src = entry.get("source_file", "") + if not (src.startswith("processed/") or "_processed." in src): + continue + # Preserve the processed path under processed_file (don't clobber an + # explicit one the user already set). + entry.setdefault("processed_file", src) + # Repoint source_file at the raw recording when we can find it. + raw_match = next( + ( + f + for f in (raw_dir.iterdir() if raw_dir.exists() else []) + if f.is_file() + and f.suffix.lower() in _raw_video_exts_set + and f.stem.lower() == seg_id.lower() + ), + None, + ) + if raw_match: + entry["source_file"] = f"raw_mov/{raw_match.name}" + migrated_count += 1 + # else: no raw available — leave source_file as the processed file so the + # segment still renders; it simply can't be re-preprocessed from raw. + default_filters = config.default_filters if config else {} added_count = 0 @@ -1526,7 +1555,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No narration_entry = { "source_file": f"raw_mov/{video_file.name}", - "output_file": f"processed/{video_file.stem}_processed.mov", + "processed_file": f"processed/{video_file.stem}_processed.mov", } if "talkinghead" in default_filters: @@ -1544,11 +1573,17 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No print(f" Added narration segment: {segment_id} (from raw_mov)") removed_count = len(stale_keys) - if added_count > 0 or removed_count > 0 or merged_count > 0 or not narration_json_path.exists(): + if ( + added_count > 0 + or removed_count > 0 + or merged_count > 0 + or migrated_count > 0 + or not narration_json_path.exists() + ): with open(narration_json_path, "w", encoding="utf-8") as f: json.dump(existing_narration, f, indent=2) - if added_count > 0 or removed_count > 0 or merged_count > 0: + if added_count > 0 or removed_count > 0 or merged_count > 0 or migrated_count > 0: parts = [] if added_count: parts.append(f"+{added_count}") @@ -1556,6 +1591,8 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No parts.append(f"-{removed_count}") if merged_count: parts.append(f"merged {merged_count} duplicate(s)") + if migrated_count: + parts.append(f"migrated {migrated_count} to raw/processed split") print(f" Updated narration.json ({', '.join(parts)} segments)") else: if not existing_narration: @@ -2714,27 +2751,15 @@ def cmd_preprocess( successfully_processed.append((segment_id, segment_source)) # --- Update narration.json --- - # Write processed segments; preserve any existing per-segment settings (skip/take/etc.) - _PRESERVE_KEYS = ( - "skip", - "take", - "begin", - "end", - "cutout", - "use_audio_channels", - "defer_loudnorm", - "volume", - "zoom", - ) + # Record where the processed output landed WITHOUT touching source_file. The + # raw recording (raw_mov/…) stays as source_file so the project remains + # renderable before preprocessing; render prefers processed_file once it + # exists on disk and otherwise falls back to source_file. The rest of the + # entry (filter, cutout, trim points, …) is preserved as-is. for segment_id, segment_source in successfully_processed: - existing_entry = existing_narration.get(segment_id, {}) - entry: dict = {} - # Preserve settings the user may have set (trim points, cutout, etc.) - for key in _PRESERVE_KEYS: - if key in existing_entry: - entry[key] = existing_entry[key] + entry = dict(existing_narration.get(segment_id, {})) # 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["processed_file"] = f"processed/{segment_id}_processed.mov" entry.setdefault("use_audio_channels", "auto") entry.setdefault("defer_loudnorm", False) existing_narration[segment_id] = entry @@ -4857,14 +4882,28 @@ def _stage_key(project_path, filters, ref, out_dir, source_name, ss) -> int: (background gone but subject eroding), and lets the eye judge. The opaque column falls as the key eats the subject; pick the balance before that.""" base = _grade_step(filters, "gnommokey") + + # Pre-scan (gain only, no eroders) for the gentlest gain that clears the + # background — the "knee" where transparency plateaus. The sweep then centres + # on it so the variants aren't all bunched to one side of the useful range. + scan_gains = [60, 90, 120, 150, 180, 210, 240, 270] + scan = [(g, _eval_alpha(ref, _mask_and(filters, dict(base, screen_gain=g, shadow_boost=0, clip_black=0)))[0]) + for g in scan_gains] + max_t = max(t for _, t in scan) + g_knee = next((g for g, t in scan if t >= max_t - 0.005), scan_gains[-1]) + steps = [round(i / 9, 2) for i in range(10)] # aggressiveness 0.0 .. 1.0 - print(" Sweeping key aggressiveness 0.0 → 1.0 (gain 120→280, shadow_boost 0→3, clip_black 0→12)") + print(f" Background clears near gain {g_knee}; sweeping aggressiveness 0.0 → 1.0 centred there") + print(" (gain 0.6×→1.5× knee; shadow_boost/clip_black kick in only past the knee)") candidates = [] for i, a in enumerate(steps, 1): params = { - "screen_gain": int(round(120 + 160 * a)), - "shadow_boost": round(3.0 * a, 2), - "clip_black": int(round(12 * a)), + # gain spans under-keyed → over the knee; eroders (shadow_boost, + # clip_black) stay at 0 until past the knee, then ramp — so the gentle + # half is clean and only the aggressive half erodes. + "screen_gain": int(round(g_knee * (0.6 + 0.9 * a))), + "shadow_boost": round(max(0.0, 3.0 * (a - 0.5) / 0.5), 2), + "clip_black": int(round(max(0.0, 12 * (a - 0.6) / 0.4))), } cfg = dict(base, **params) t, o, p = _eval_alpha(ref, _mask_and(filters, cfg)) @@ -4877,15 +4916,12 @@ def _stage_key(project_path, filters, ref, out_dir, source_name, ss) -> int: "opaque_pct": round(o * 100, 1), "partial_pct": round(p * 100, 1)}, }) - # Recommend the *gentlest* setting that has essentially cleared the background - # — the knee where transparency stops climbing. Past it, more aggression just - # erodes the subject, which is exactly what we're trying to avoid. - rec = candidates[len(candidates) // 3]["id"] - for i in range(1, len(candidates)): - dt = candidates[i]["hint"]["transparent_pct"] - candidates[i - 1]["hint"]["transparent_pct"] - if dt < 0.3: - rec = candidates[i - 1]["id"] - break + # Recommend the *gentlest* variant that has essentially cleared the background + # (transparency within 0.3% of the max) — the knee. Below it is under-keyed; + # above it only erodes the subject. + peak_t = max(c["hint"]["transparent_pct"] for c in candidates) + rec = next((c["id"] for c in candidates + if c["hint"]["transparent_pct"] >= peak_t - 0.5), candidates[0]["id"]) manifest = { "stage": "key", "target_step": "gnommokey", "source": source_name, "ss": round(ss, 2), diff --git a/gnommo/parser.py b/gnommo/parser.py index 82f91b1..333409b 100644 --- a/gnommo/parser.py +++ b/gnommo/parser.py @@ -633,7 +633,11 @@ def parse_narration( narration[segment_id] = VideoSource( source_file=segment_data["source_file"], filter=filter_list, - output_file=segment_data.get("output_file"), + # New model stores the preprocessed output under "processed_file"; + # "output_file" is the legacy key. Either maps to output_file, which + # render uses (preferring it when it exists, else source_file). + output_file=segment_data.get("processed_file") + or segment_data.get("output_file"), take=take, skip=skip, zoom=segment_data.get("zoom", 1.0), diff --git a/gnommo/preprocessor.py b/gnommo/preprocessor.py index e93d400..6611437 100644 --- a/gnommo/preprocessor.py +++ b/gnommo/preprocessor.py @@ -2355,12 +2355,23 @@ def parse_chroma_key_config(config: dict[str, Any]) -> ChromaKeyConfig: def get_preprocessed_path(videos_dir: Path, video_source: VideoSource) -> Path: """ - Get the path to the preprocessed video file. + Get the file to feed into render for this segment. - Returns output_file if specified, otherwise returns source_file. + Prefers the preprocessed output (output_file / processed_file) once it has + actually been produced on disk, so a project can be rendered straight from + the raw source_file before the (heavy) preprocess stage has run. Falls back + to source_file whenever no processed output exists yet. """ if video_source.output_file: - return videos_dir / video_source.output_file + processed = videos_dir / video_source.output_file + if processed.exists(): + return processed + # preprocess may emit the compressed variant alongside the recorded name + variant = processed.with_suffix( + ".webm" if processed.suffix.lower() == ".mov" else ".mov" + ) + if variant.exists(): + return variant return videos_dir / video_source.source_file