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
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),
+5 -1
View File
@@ -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),
+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:
"""
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