Adding sweep for keying
This commit is contained in:
+71
-35
@@ -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
@@ -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
@@ -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
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user