From fa8512ff3d00b95987f2e58da4e93195e496e6b8 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Thu, 16 Jul 2026 16:00:19 +0200 Subject: [PATCH] Ading cleanup and small improvements --- gnommo/cli.py | 44 ++++++++++++++++++++++++++++++++++++++------ gnommo/renderer.py | 29 ++++++++++++++++++++++++----- 2 files changed, 62 insertions(+), 11 deletions(-) diff --git a/gnommo/cli.py b/gnommo/cli.py index 8ecb572..fa7301c 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -2185,22 +2185,54 @@ def _prune_manifest( if (key.lower() if lowercase_keys else key) not in keep_ids ] - if not to_remove: + # Case-collision duplicates: when keys are case-insensitive (videos), several + # keys can share the same lowercase form (e.g. "KnightRotating" and + # "knightrotating"). Markers resolve by lowercased id, so only the exact + # lowercase key is ever reachable — the other case-variants are dead weight. + # Collapse each surviving collision group to its lowercase canonical. + dup_remove: list[tuple[str, str]] = [] # (removed_key, kept_canonical_key) + if lowercase_keys: + remove_set = set(to_remove) + groups: dict[str, list[str]] = {} + for key in data: + if key in remove_set: + continue + groups.setdefault(key.lower(), []).append(key) + for low, keys in groups.items(): + if len(keys) < 2: + continue + # Keep the already-lowercase key (what the renderer resolves); else + # the first-seen, so at least one entry survives. + canonical = next((k for k in keys if k == low), keys[0]) + for key in keys: + if key != canonical: + dup_remove.append((key, canonical)) + + if not to_remove and not dup_remove: if verbose: print(f" {path.name}: all {len(data)} {label} entries in use.") return 0 - print(f" {path.name}: removing {len(to_remove)} unused {label} entr" - f"{'y' if len(to_remove) == 1 else 'ies'}:") - for key in to_remove: - print(f" - {key}") + if to_remove: + print(f" {path.name}: removing {len(to_remove)} unused {label} entr" + f"{'y' if len(to_remove) == 1 else 'ies'}:") + for key in to_remove: + print(f" - {key}") + + if dup_remove: + print(f" {path.name}: removing {len(dup_remove)} case-duplicate {label} entr" + f"{'y' if len(dup_remove) == 1 else 'ies'}:") + for key, canonical in dup_remove: + print(f" - {key} (duplicate of '{canonical}')") if not dry_run: for key in to_remove: del data[key] + for key, _ in dup_remove: + del data[key] _write_json_preserve(path, data) - return len(to_remove) + return len(to_remove) + len(dup_remove) def _prune_narration(project_path: Path, verbose: bool, dry_run: bool) -> int: diff --git a/gnommo/renderer.py b/gnommo/renderer.py index c039aea..8451b32 100644 --- a/gnommo/renderer.py +++ b/gnommo/renderer.py @@ -1019,7 +1019,7 @@ def build_filter_complex( zoomed_width = int(cut_width * zoom) zoomed_height = int(cut_height * zoom) - if not plan.narration_pauses or narration_concat: + if not plan.narration_pauses: video_label = f"av{i}" filters.append( f"{narr_src}fps={plan.config.fps},setpts=PTS-STARTPTS," @@ -1035,9 +1035,19 @@ def build_filter_complex( ) current_label = next_label else: + # Freeze-splice: cut the narration video at each pause and re-time the + # remainder so it resumes exactly where it stopped (the cutscene owns + # the frame during the gap). narr_src may be the concat filter output + # [narrsrc_v], which is single-consumer, so split it into one branch + # per segment before trimming. segments = _build_narration_segments( plan.narration_pauses, plan.total_duration ) + split_labels = [f"nvsplit{i}_{k}" for k in range(len(segments))] + filters.append( + f"{narr_src}split={len(segments)}" + + "".join(f"[{lbl}]" for lbl in split_labels) + ) for seg_idx, (src_start, src_end, out_start, out_end) in enumerate( segments @@ -1045,7 +1055,7 @@ def build_filter_complex( seg_label = f"av{i}_seg{seg_idx}" pts_offset = out_start filters.append( - f"{narr_src}trim={src_start:.3f}:{src_end:.3f}," + f"[{split_labels[seg_idx]}]trim={src_start:.3f}:{src_end:.3f}," f"setpts=PTS-STARTPTS+{pts_offset:.3f}/TB," f"format=yuva444p10le," f"scale={zoomed_width}:{zoomed_height}:force_original_aspect_ratio=increase," @@ -1275,7 +1285,7 @@ def build_filter_complex( plan.narration_end_time if plan.outro_events else plan.total_duration ) - if not plan.narration_pauses or narration_concat: + if not plan.narration_pauses: # Simple case: trim main audio to end before outro (with optional channel and volume filters) filter_parts = [] if channel_filter: @@ -1299,8 +1309,17 @@ def build_filter_complex( else: audio_labels_to_mix.append(f"{_main_aud}") else: - # Complex case: segment the narration audio for pauses + # Freeze-splice: cut the narration audio at each pause and delay the + # remainder so it resumes on the exact sample it stopped on (the + # cutscene's own audio fills the gap). _main_aud may be the concat + # output [narrsrc_a], which is single-consumer, so asplit it into one + # branch per segment first. segments = _build_narration_segments(plan.narration_pauses, audio_end_time) + asplit_labels = [f"narr_aud_split{k}" for k in range(len(segments))] + filters.append( + f"{_main_aud}asplit={len(segments)}" + + "".join(f"[{lbl}]" for lbl in asplit_labels) + ) for seg_idx, (src_start, src_end, out_start, out_end) in enumerate( segments ): @@ -1317,7 +1336,7 @@ def build_filter_complex( if volume_filter: filter_parts.append(volume_filter) filters.append( - f"{_main_aud}{','.join(filter_parts)}[{seg_label}]" + f"[{asplit_labels[seg_idx]}]{','.join(filter_parts)}[{seg_label}]" ) audio_labels_to_mix.append(f"[{seg_label}]")