Adding sweep for keying

This commit is contained in:
2026-07-23 15:08:59 +02:00
parent a3919f595a
commit b1136a9f7d
3 changed files with 90 additions and 39 deletions
+71 -35
View File
@@ -1459,6 +1459,35 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
normalised[lower_id] = seg_data normalised[lower_id] = seg_data
existing_narration = normalised 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 {} default_filters = config.default_filters if config else {}
added_count = 0 added_count = 0
@@ -1526,7 +1555,7 @@ def _import_narration_segments(narration_dir: Path, config, verbose: bool) -> No
narration_entry = { narration_entry = {
"source_file": f"raw_mov/{video_file.name}", "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: 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)") print(f" Added narration segment: {segment_id} (from raw_mov)")
removed_count = len(stale_keys) 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: with open(narration_json_path, "w", encoding="utf-8") as f:
json.dump(existing_narration, f, indent=2) 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 = [] parts = []
if added_count: if added_count:
parts.append(f"+{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}") parts.append(f"-{removed_count}")
if merged_count: if merged_count:
parts.append(f"merged {merged_count} duplicate(s)") 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)") print(f" Updated narration.json ({', '.join(parts)} segments)")
else: else:
if not existing_narration: if not existing_narration:
@@ -2714,27 +2751,15 @@ def cmd_preprocess(
successfully_processed.append((segment_id, segment_source)) successfully_processed.append((segment_id, segment_source))
# --- Update narration.json --- # --- Update narration.json ---
# Write processed segments; preserve any existing per-segment settings (skip/take/etc.) # Record where the processed output landed WITHOUT touching source_file. The
_PRESERVE_KEYS = ( # raw recording (raw_mov/…) stays as source_file so the project remains
"skip", # renderable before preprocessing; render prefers processed_file once it
"take", # exists on disk and otherwise falls back to source_file. The rest of the
"begin", # entry (filter, cutout, trim points, …) is preserved as-is.
"end",
"cutout",
"use_audio_channels",
"defer_loudnorm",
"volume",
"zoom",
)
for segment_id, segment_source in successfully_processed: for segment_id, segment_source in successfully_processed:
existing_entry = existing_narration.get(segment_id, {}) entry = dict(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]
# Always record the plain path; the res subdir shift happens at render 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["processed_file"] = f"processed/{segment_id}_processed.mov"
entry.setdefault("use_audio_channels", "auto") entry.setdefault("use_audio_channels", "auto")
entry.setdefault("defer_loudnorm", False) entry.setdefault("defer_loudnorm", False)
existing_narration[segment_id] = entry 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 (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.""" column falls as the key eats the subject; pick the balance before that."""
base = _grade_step(filters, "gnommokey") 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 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 = [] candidates = []
for i, a in enumerate(steps, 1): for i, a in enumerate(steps, 1):
params = { params = {
"screen_gain": int(round(120 + 160 * a)), # gain spans under-keyed → over the knee; eroders (shadow_boost,
"shadow_boost": round(3.0 * a, 2), # clip_black) stay at 0 until past the knee, then ramp — so the gentle
"clip_black": int(round(12 * a)), # 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) cfg = dict(base, **params)
t, o, p = _eval_alpha(ref, _mask_and(filters, cfg)) 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), "opaque_pct": round(o * 100, 1),
"partial_pct": round(p * 100, 1)}, "partial_pct": round(p * 100, 1)},
}) })
# Recommend the *gentlest* setting that has essentially cleared the background # Recommend the *gentlest* variant that has essentially cleared the background
# — the knee where transparency stops climbing. Past it, more aggression just # (transparency within 0.3% of the max) — the knee. Below it is under-keyed;
# erodes the subject, which is exactly what we're trying to avoid. # above it only erodes the subject.
rec = candidates[len(candidates) // 3]["id"] peak_t = max(c["hint"]["transparent_pct"] for c in candidates)
for i in range(1, len(candidates)): rec = next((c["id"] for c in candidates
dt = candidates[i]["hint"]["transparent_pct"] - candidates[i - 1]["hint"]["transparent_pct"] if c["hint"]["transparent_pct"] >= peak_t - 0.5), candidates[0]["id"])
if dt < 0.3:
rec = candidates[i - 1]["id"]
break
manifest = { manifest = {
"stage": "key", "target_step": "gnommokey", "stage": "key", "target_step": "gnommokey",
"source": source_name, "ss": round(ss, 2), "source": source_name, "ss": round(ss, 2),
+5 -1
View File
@@ -633,7 +633,11 @@ def parse_narration(
narration[segment_id] = VideoSource( narration[segment_id] = VideoSource(
source_file=segment_data["source_file"], source_file=segment_data["source_file"],
filter=filter_list, 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, take=take,
skip=skip, skip=skip,
zoom=segment_data.get("zoom", 1.0), zoom=segment_data.get("zoom", 1.0),
+14 -3
View File
@@ -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: 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: 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 return videos_dir / video_source.source_file