From 02a6131d15ada1d26491195fe479393c9d4fd79e Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Wed, 15 Jul 2026 12:12:24 +0200 Subject: [PATCH] Improvement to the trim --- gnommo/cli.py | 220 +++++++++++++++++++++++++++++++++++++------------- 1 file changed, 162 insertions(+), 58 deletions(-) diff --git a/gnommo/cli.py b/gnommo/cli.py index 817dd37..51a8a65 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -2746,6 +2746,21 @@ _TRIM_STOP_WORDS = frozenset({ "when", "how", "who", "all", "very", "also", "more", "get", "have", }) +# Transcript-based auto-trim padding: keep this much lead-in before the start +# slide's first word, and this much tail after the end slide's last word. +_TRIM_LEAD_IN = 0.5 +_TRIM_TAIL_OUT = 2.0 + + +def _user_begin_skip(existing: dict) -> "float | None": + """Return the skip (seconds) implied by a user-pinned begin/start, or None.""" + from .parser import parse_timestamp + if existing.get("begin"): + return parse_timestamp(existing["begin"]) + if existing.get("start"): + return parse_timestamp(existing["start"]) + return None + def _parse_segment_slide_range(seg_id: str) -> "tuple[int, int | None] | None": """Parse 'S11-24' → (11, 24), 'S1-end' → (1, None), else None.""" @@ -2805,7 +2820,9 @@ def _find_slide_end_in_transcript( n = len(target) threshold = max(1, int(n * 0.55)) - # Scan backwards: find the latest window of n content words that matches + # Scan backwards: find the latest window of n content words that matches, + # and return the end timestamp of the LAST matched word — not the window's + # last word, which may be trailing filler after the slide's actual last word. for end_ci in range(len(content_idxs) - 1, n - 2, -1): window_idxs = content_idxs[max(0, end_ci - n + 1): end_ci + 1] window = [norm[i] for i in window_idxs] @@ -2813,16 +2830,19 @@ def _find_slide_end_in_transcript( # Sequential match: iterate target left-to-right, consume window matches score = 0 wi = 0 + last_match_wi = None for t_word in target: while wi < len(window): w = window[wi] + matched = w == t_word or (len(w) >= 4 and len(t_word) >= 4 and w[:4] == t_word[:4]) wi += 1 - if w == t_word or (len(w) >= 4 and len(t_word) >= 4 and w[:4] == t_word[:4]): + if matched: + last_match_wi = wi - 1 score += 1 break - if score >= threshold: - last_tw = transcript_words[content_idxs[end_ci]] + if score >= threshold and last_match_wi is not None: + last_tw = transcript_words[window_idxs[last_match_wi]] if verbose: print(f"\n → matched slide end: '{last_tw.word}' at {last_tw.end:.2f}s") return last_tw.end @@ -2832,6 +2852,66 @@ def _find_slide_end_in_transcript( return None +def _find_slide_start_in_transcript( + transcript_words: list, + slide_text: str, + verbose: bool = False, +) -> "float | None": + """ + Locate where slide_text begins in the transcript and return that word's + start timestamp. Searches the head of the transcript for the first few + content words from slide_text using a sequential fuzzy match (the mirror of + _find_slide_end_in_transcript). Returns None if no confident match is found. + """ + target = _trim_content_words(slide_text)[:12] + if not target: + return None + + norm = [re.sub(r"[^a-z0-9']", "", w.word.lower()) for w in transcript_words] + content_idxs = [ + i for i, w in enumerate(norm) + if w and w not in _TRIM_STOP_WORDS and len(w) > 2 + ] + if not content_idxs: + return None + + n = len(target) + threshold = max(1, int(n * 0.55)) + + # Scan forwards: find the earliest window of n content words that matches, + # and return the timestamp of the FIRST matched word (not the window start — + # the target sequence may begin partway into the window, after filler). + for start_ci in range(len(content_idxs)): + window_idxs = content_idxs[start_ci: start_ci + n] + if len(window_idxs) < threshold: + break + window = [norm[i] for i in window_idxs] + + score = 0 + wi = 0 + first_match_wi = None + for t_word in target: + while wi < len(window): + w = window[wi] + matched = w == t_word or (len(w) >= 4 and len(t_word) >= 4 and w[:4] == t_word[:4]) + wi += 1 + if matched: + if first_match_wi is None: + first_match_wi = wi - 1 + score += 1 + break + + if score >= threshold and first_match_wi is not None: + first_tw = transcript_words[window_idxs[first_match_wi]] + if verbose: + print(f"\n → matched slide start: '{first_tw.word}' at {first_tw.start:.2f}s") + return first_tw.start + + if verbose: + print(f"\n → could not match slide start (target head: {target[:5]})") + return None + + def cmd_trim( project_path: Path, verbose: bool, @@ -2841,18 +2921,20 @@ def cmd_trim( whisper_model: str = "base", ) -> int: """ - Trim narration segments and write skip/take values into narration.json. + Auto-detect skip/take for narration segments and write them into narration.json. - For segments named S{N}-{M}.mov or S{N}-end.mov: - - Transcribes audio with Whisper to get word-level timestamps - - skip = max(0, first_word.start - 0.5) - - take = (end of last word on slide M) + 0.15 - skip - - S{N}-end.mov: only trims the start, no end trim + Trim only fills in the side(s) the user hasn't pinned. A user-set `begin`/ + `start` pins the beginning; a user-set `end` pins the end — those are always + respected (even under --force). Only the un-pinned side is auto-detected: + + For segments named S{N}-{M}.mov or S{N}-end.mov (transcript-based): + - begin = start of slide N's first word − 0.5s (falls back to first word) + - end = end of slide M's last word + 2.0s (S{N}-end: last spoken word + 2.0s) - Transcripts are cached in narration/transcripts/{seg_id}.json - For other segments: falls back to silence detection. + For other segments: falls back to silence detection (also honouring pins). """ - from .parser import parse_project_config, parse_narration + from .parser import parse_project_config, parse_narration, parse_timestamp from .preprocessor import detect_silence_bounds, get_video_duration print(f"Trimming narration: {project_path.name}") @@ -2920,13 +3002,19 @@ def cmd_trim( seg_force = force or source_changed existing = raw_data.get(seg_id, {}) - # Any manual trim point counts as "already trimmed" — including the - # user-friendly begin/end/start aliases. Without this, a segment with - # only a manual `begin` would be re-trimmed and have auto-detected - # skip/take written over it, silently clobbering the intended trim. - has_explicit = any(k in existing for k in ("skip", "take", "begin", "end", "start")) - if has_explicit and not seg_force: - print(f" {seg_id}: already trimmed, skipping (use --force to redo)") + # Respect the user's manual trim points independently per side, and only + # auto-detect the side they haven't pinned: + # - begin/start pin the beginning; end pins the end (user vocabulary). + # - skip/take are what trim itself writes (or raw manual overrides). + # A side counts as "resolved" if pinned by the user OR already written by + # trim; --force re-detects auto (skip/take) sides but never overwrites a + # user-pinned begin/end. + begin_user = bool(existing.get("begin") or existing.get("start")) + end_user = bool(existing.get("end")) + begin_resolved = begin_user or "skip" in existing + end_resolved = end_user or "take" in existing + if begin_resolved and end_resolved and not seg_force: + print(f" {seg_id}: begin & end already set, skipping (use --force to redo)") _trim_fps[seg_id] = current_fp # adopt/keep current fingerprint continue if source_changed: @@ -2945,47 +3033,57 @@ def cmd_trim( try: if transcript_path.exists() and not seg_force: words = load_transcript(transcript_path) - print(f" {seg_id}: loaded cached transcript ({len(words)} words)", end="", flush=True) + print(f" {seg_id}: loaded cached transcript ({len(words)} words)") else: print(f" {seg_id}: transcribing {source_path.parent.name}/{source_path.name} (model={whisper_model})...", end="", flush=True) words = transcribe_video(source_path, model=whisper_model) save_transcript(words, transcript_path) - print(f" {len(words)} words", end="", flush=True) + print(f" {len(words)} words") if not words: - print(f" — no words found, falling back to silence detection") + print(f" no words found — falling back to silence detection") slide_range = None else: total_dur = get_video_duration(source_path) - new_skip = max(0.0, round(words[0].start - 0.5, 3)) - if end_slide is None: - # S{N}-end: trim start and 2s after last spoken word - last_word_end = words[-1].end - new_take = round(min(last_word_end + 2.0 - new_skip, total_dur - new_skip), 3) - new_take = max(0.0, new_take) - print( - f" first={words[0].start:.2f}s last={last_word_end:.2f}s" - f" → skip={new_skip:.3f}s take={new_take:.3f}s" - ) + # ---- Beginning: keep user's begin, else start of start-slide ---- + if begin_user: + skip = _user_begin_skip(existing) or 0.0 + begin_note = f"begin kept (user-set → {skip:.2f}s)" else: - last_slide_text = slide_texts.get(end_slide, "") - end_ts = None - if last_slide_text: - end_ts = _find_slide_end_in_transcript(words, last_slide_text, verbose) - if end_ts is None: - print(f"\n ⚠ could not locate S{end_slide} end in transcript — trimming start only") - new_take = round(total_dur - new_skip, 3) - else: - new_take = round(min(end_ts + 0.15 - new_skip, total_dur - new_skip), 3) - new_take = max(0.0, new_take) - print( - f" first={words[0].start:.2f}s S{end_slide}_end={end_ts:.2f}s" - f" → skip={new_skip:.3f}s take={new_take:.3f}s" - ) + start_text = slide_texts.get(start_slide, "") + start_ts = _find_slide_start_in_transcript(words, start_text, verbose) if start_text else None + src = f"S{start_slide} first word" if start_ts is not None else "first word" + if start_ts is None: + start_ts = words[0].start + skip = max(0.0, round(start_ts - _TRIM_LEAD_IN, 3)) + begin_note = f"begin auto: {src} {start_ts:.2f}s −{_TRIM_LEAD_IN:g}s → {skip:.2f}s" - raw_data[seg_id]["skip"] = new_skip - raw_data[seg_id]["take"] = new_take + # ---- End: keep user's end, else end of end-slide + tail ---- + if end_user: + end_abs = parse_timestamp(existing["end"]) + end_note = f"end kept (user-set → {end_abs:.2f}s)" + elif end_slide is None: + # S{N}-end: end a couple of seconds after the last spoken word. + end_abs = min(words[-1].end + _TRIM_TAIL_OUT, total_dur) + end_note = f"end auto: last word {words[-1].end:.2f}s +{_TRIM_TAIL_OUT:g}s → {end_abs:.2f}s" + else: + end_text = slide_texts.get(end_slide, "") + end_ts = _find_slide_end_in_transcript(words, end_text, verbose) if end_text else None + if end_ts is None: + end_abs = total_dur + end_note = f"end auto: S{end_slide} not found → to end {total_dur:.2f}s" + else: + end_abs = min(end_ts + _TRIM_TAIL_OUT, total_dur) + end_note = f"end auto: S{end_slide} last word {end_ts:.2f}s +{_TRIM_TAIL_OUT:g}s → {end_abs:.2f}s" + + # Write only the side(s) not pinned by the user, in skip/take terms. + if not begin_user: + raw_data[seg_id]["skip"] = skip + if not end_user: + raw_data[seg_id]["take"] = round(max(0.0, end_abs - skip), 3) + + print(f" {begin_note} · {end_note}") _trim_fps[seg_id] = current_fp updated += 1 continue @@ -2996,7 +3094,7 @@ def cmd_trim( print(f"\n ⚠ transcription failed ({label}), falling back to silence detection") slide_range = None # fall through - # --- Silence-based fallback --- + # --- Silence-based fallback (respects user-pinned begin/end too) --- print( f" {seg_id}: analysing {source_path.parent.name}/{source_path.name}...", end="", @@ -3007,17 +3105,23 @@ def cmd_trim( ) total_dur = get_video_duration(source_path) - new_skip = max(0.0, round(first_sound - 0.5, 3)) - new_take = round(min(total_dur - new_skip, last_sound + 3.0 - new_skip), 3) - new_take = max(0.0, new_take) + if begin_user: + skip = _user_begin_skip(existing) or 0.0 + begin_note = f"begin kept (user-set → {skip:.2f}s)" + else: + skip = max(0.0, round(first_sound - _TRIM_LEAD_IN, 3)) + raw_data[seg_id]["skip"] = skip + begin_note = f"begin auto: first sound {first_sound:.2f}s −{_TRIM_LEAD_IN:g}s → {skip:.2f}s" - print( - f" first={first_sound:.2f}s last={last_sound:.2f}s" - f" → skip={new_skip:.3f}s take={new_take:.3f}s" - ) + if end_user: + end_abs = parse_timestamp(existing["end"]) + end_note = f"end kept (user-set → {end_abs:.2f}s)" + else: + end_abs = min(total_dur, last_sound + 3.0) + raw_data[seg_id]["take"] = round(max(0.0, end_abs - skip), 3) + end_note = f"end auto: last sound {last_sound:.2f}s +3.0s → {end_abs:.2f}s" - raw_data[seg_id]["skip"] = new_skip - raw_data[seg_id]["take"] = new_take + print(f" {begin_note} · {end_note}") _trim_fps[seg_id] = current_fp updated += 1