From ec08e945e592702e3ed6a2a1f3fff01cf06a2b75 Mon Sep 17 00:00:00 2001 From: jenstandstad Date: Wed, 29 Jul 2026 17:27:48 +0200 Subject: [PATCH] Adding a few fixes to the gnommo pipeline --- build_all.sh | 11 + docs/atomic_events.md | 18 +- gnommo/cli.py | 512 ++++++++++++++++++++---------------------- import_all.sh | 9 + render_all.sh | 13 +- trim_all.sh | 9 + 6 files changed, 291 insertions(+), 281 deletions(-) create mode 100755 build_all.sh create mode 100755 import_all.sh create mode 100755 trim_all.sh diff --git a/build_all.sh b/build_all.sh new file mode 100755 index 0000000..8688581 --- /dev/null +++ b/build_all.sh @@ -0,0 +1,11 @@ +#!/bin/sh + + +./gnommo.sh -p video1 build --realign --force +./gnommo.sh -p video2 build --realign --force +./gnommo.sh -p video3 build --realign --force +./gnommo.sh -p video4 build --realign --force +./gnommo.sh -p video5 build --realign --force +./gnommo.sh -p video6 build --realign --force + + diff --git a/docs/atomic_events.md b/docs/atomic_events.md index 2d13a96..b58dbe8 100644 --- a/docs/atomic_events.md +++ b/docs/atomic_events.md @@ -11,14 +11,20 @@ Implemented: take`; `events_to_marker_timings` round-trips them as overrides. - Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on, - take** (the fully-wired per-event fields). Unknown keys are ignored. + take, volume** (numeric keys `take`/`volume` coerced to float). Unknown keys are ignored. - The key-reuse collision validator hard-error was removed (reuse is legal now). -Deferred (follow-ups): inline override of the *global* params (skip/zoom/volume/ -use_audio_channels/pause_narration) — the renderer reads these from `video_source` in ~13 -places, so wiring them per-event is a separate change; stripping the moved fields from -videos.json (kept as fallback defaults for now); a validator warning for unknown/unwired -inline keys. +`volume` is **sparse/overridable**: the renderer reads `VideoEvent.volume` (line ~1571), +which is the events.json override if present else the videos.json default — so a videos.json +change keeps propagating, and events.json only stores `volume` when it's actually overridden +(inline/GUI/manual). `derive_events` materializes it only when overridden; `_EVENT_OVERRIDE_KEYS` +round-trips it. This is the template for the remaining globals. + +Deferred (follow-ups): inline/per-event override of the *other* globals (skip/zoom/ +use_audio_channels/pause_narration) — same renderer-plumbing pattern as volume, per site; +per-segment narration voiceover volume (render currently uses `narration_videos[0].volume` +only); stripping the moved fields from videos.json (kept as fallback defaults); a validator +warning for unknown inline keys. ## Problem diff --git a/gnommo/cli.py b/gnommo/cli.py index a82eac5..44e8587 100644 --- a/gnommo/cli.py +++ b/gnommo/cli.py @@ -2906,16 +2906,6 @@ def cmd_preprocess( # Trim Command — transcript-based trimming for slide-range segments # ============================================================================= -# Words so common they're useless for matching slide boundaries. -_TRIM_STOP_WORDS = frozenset({ - "a", "an", "the", "and", "or", "but", "in", "on", "at", "to", "of", - "for", "is", "it", "its", "we", "our", "this", "that", "with", "be", - "are", "was", "has", "not", "so", "as", "by", "do", "if", "up", - "you", "i", "he", "she", "they", "them", "just", "now", "can", - "than", "then", "from", "about", "into", "out", "what", "there", - "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 @@ -2932,14 +2922,19 @@ def _user_begin_skip(existing: dict) -> "float | None": 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.""" - m = re.match(r"^s(\d+)-(?:s?(\d+)|(end))$", seg_id.lower()) - if not m: - return None - start = int(m.group(1)) - end = int(m.group(2)) if m.group(2) else None - return (start, end) +def _locked_begin_skip(existing: dict, begin_user: bool) -> float: + """Skip (seconds) for a segment whose begin is locked — user pin or prior value.""" + if begin_user: + return _user_begin_skip(existing) or 0.0 + return float(existing.get("skip", 0.0)) + + +def _locked_end_abs(existing: dict, end_user: bool, skip: float) -> float: + """Absolute end (seconds) for a segment whose end is locked.""" + from .parser import parse_timestamp + if end_user: + return parse_timestamp(existing["end"]) + return skip + float(existing.get("take", 0.0)) def _extract_slide_texts(manuscript_path: Path) -> "dict[int, str]": @@ -2956,130 +2951,60 @@ def _extract_slide_texts(manuscript_path: Path) -> "dict[int, str]": return result -def _trim_content_words(text: str) -> "list[str]": - """Lowercase words stripped of punctuation, excluding stop words.""" - words = re.findall(r"[a-zA-Z0-9']+", text.lower()) - return [w for w in words if w not in _TRIM_STOP_WORDS and len(w) > 2] - - -def _find_slide_end_in_transcript( +def _map_slides_in_transcript( + slide_texts: "dict[int, str]", transcript_words: list, - slide_text: str, - verbose: bool = False, -) -> "float | None": + fuzzy_threshold: float = 0.6, +) -> "dict[int, tuple[float, float]]": + """Locate each manuscript slide inside one segment's transcript. + + Reuses the same fuzzy phrase matcher the build stage aligns with + (transformer._find_phrase_timestamp), so a slide's start time and match + quality are derived exactly as they will be at render alignment. + + Returns {slide_num: (start_sec, quality)} for every slide that belongs to + this recording. Coverage is discovered from content, not trusted from the + filename's slide numbers — that absence of a match (and a low quality on a + botched retake) is the signal the reconciliation pass uses to place segment + boundaries. + + Each slide is matched independently over the whole transcript, then only the + longest run whose start times rise with slide number is kept. A recording + holds one contiguous span of slides, so a slide from outside that span can + only throw a stray, out-of-order match — the monotonic filter drops it + instead of letting it poison a forward cursor. """ - Locate where slide_text ends in the transcript and return that word's - end timestamp. Searches the tail of the transcript for the last few - content words from slide_text using a sequential fuzzy match. - Returns None if no confident match is found. - """ - target = _trim_content_words(slide_text)[-12:] - if not target: - return None + from .transformer import _find_phrase_timestamp - # Normalise transcript words (strip punctuation, lowercase) - norm = [re.sub(r"[^a-z0-9']", "", w.word.lower()) for w in transcript_words] - # Index into transcript of content words only - 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 + hits: "list[tuple[int, float, float]]" = [] # (slide_num, start_sec, quality) + for slide_num in sorted(slide_texts): + anchor = " ".join(slide_texts[slide_num].split()[:10]) + if not anchor.strip(): + continue + idx, timestamp, confidence, _match_end = _find_phrase_timestamp( + anchor, transcript_words, start_from=0, fuzzy_threshold=fuzzy_threshold + ) + if idx >= 0: + hits.append((slide_num, max(0.0, round(timestamp, 3)), round(confidence, 3))) + if not hits: + return {} - n = len(target) - threshold = max(1, int(n * 0.55)) - - # 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] - - # 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 matched: - last_match_wi = wi - 1 - score += 1 - break - - 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 - - if verbose: - print(f"\n → could not match slide end (target tail: {target[-5:]})") - 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 + # Longest strictly-increasing-by-time subsequence over slides in number order. + n = len(hits) + run_len = [1] * n + prev = [-1] * n + for i in range(n): + for j in range(i): + if hits[j][1] < hits[i][1] and run_len[j] + 1 > run_len[i]: + run_len[i] = run_len[j] + 1 + prev[i] = j + end = max(range(n), key=lambda i: run_len[i]) + keep = [] + while end != -1: + keep.append(end) + end = prev[end] + keep.reverse() + return {hits[i][0]: (hits[i][1], hits[i][2]) for i in keep} def cmd_trim( @@ -3093,18 +3018,23 @@ def cmd_trim( """ Auto-detect skip/take for narration segments and write them into narration.json. - 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: + Each segment is transcribed (cached in narration/transcripts/{seg_id}.json) + and its slides are located by content — _map_slides_in_transcript discovers + which manuscript slides the recording actually covers, so trim never relies on + the segment's filename numbers (which go stale when slides are inserted). - 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 + Two passes: + 1. Per segment, build a {slide: (start, quality)} map. + 2. Reconcile overlaps: when consecutive segments share a slide (a + re-recorded retake), the earlier take is cut before that slide and the + later take begins at it, so the botched tail is dropped automatically. - For other segments: falls back to silence detection (also honouring pins). + Otherwise begin = first slide's first word − 0.5s and end = last spoken + word + 2.0s. Trim only fills the side(s) the user hasn't pinned: a user-set + `begin`/`start` pins the beginning, `end` pins the end (respected under + --force). Segments with no usable transcript fall back to silence detection. """ - from .parser import parse_project_config, parse_narration, parse_timestamp + from .parser import parse_project_config, parse_narration from .preprocessor import detect_silence_bounds, get_video_duration print(f"Trimming narration: {project_path.name}") @@ -3153,11 +3083,22 @@ def cmd_trim( _prev_trim_fps = _state.get_items(project_path, "trim") _trim_fps: dict[str, str] = {} # segments to (re)record after the loop - updated = 0 - for seg_id in sorted(narration.keys()): + # ===================================================================== + # Phase A — gather each segment's transcript + slide map (or leave it for + # silence-based trimming when no usable transcript is available). The slide + # map is what lets reconciliation discover which slides each recording + # actually covers, independent of the (possibly stale) filename numbers. + # ===================================================================== + from .transcriber import transcribe_video, save_transcript, load_transcript + from .narration import segment_order + + transcripts_dir.mkdir(parents=True, exist_ok=True) + infos: "dict[str, dict]" = {} + + for seg_id in segment_order(narration): seg = narration[seg_id] - # Prefer raw file; fall back to source_file from narration.json + # Prefer raw file; fall back to source_file from narration.json. source_path = raw_lookup.get(seg_id) if source_path is None: source_path = narration_dir / seg.source_file @@ -3165,145 +3106,180 @@ def cmd_trim( print(f" {seg_id}: source file not found, skipping") continue - # Has the raw source changed since we last trimmed this segment? current_fp = _state.fingerprint_path(source_path, _state.META) recorded_fp = _prev_trim_fps.get(seg_id) source_changed = recorded_fp is not None and recorded_fp != current_fp seg_force = force or source_changed existing = raw_data.get(seg_id, {}) - # 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/start pin the beginning; end pins the end (user vocabulary). + # A side is "locked" if the user pinned it, or trim already wrote it and + # we're not re-detecting (--force / changed source). Locked sides are + # preserved verbatim; only unlocked sides are (re)computed. 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 + begin_locked = begin_user or ("skip" in existing and not seg_force) + end_locked = end_user or ("take" in existing and not seg_force) + fully_locked = begin_locked and end_locked + if source_changed: print(f" {seg_id}: raw source changed since last trim — re-trimming") - slide_range = _parse_segment_slide_range(seg_id) if slide_texts else None + info = { + "seg": seg, + "source_path": source_path, + "current_fp": current_fp, + "existing": existing, + "begin_user": begin_user, + "end_user": end_user, + "begin_locked": begin_locked, + "end_locked": end_locked, + "words": None, + "slide_map": {}, + "total_dur": 0.0, + } + infos[seg_id] = info - if slide_range is not None: - # --- Transcript-based trimming --- - from .transcriber import transcribe_video, save_transcript, load_transcript, TranscriptionError + # Transcription is expensive and deterministic, so reuse a cached + # transcript whenever the source is unchanged — even under --force, which + # only means "re-detect skip/take", not "re-transcribe". A fully-locked + # segment still gets its map for free from a cached transcript (it may + # anchor a neighbour's overlap) but is never transcribed just for that. + transcript_path = transcripts_dir / f"{seg_id}.json" + words = None + try: + if transcript_path.exists() and not source_changed: + words = load_transcript(transcript_path) + print(f" {seg_id}: loaded cached transcript ({len(words)} words)") + elif fully_locked and not seg_force: + pass # nothing to detect and no reusable transcript — skip + 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") + except Exception as exc: + label = "Whisper not installed" if "openai-whisper" in str(exc) else str(exc) + print(f"\n \u26a0 transcription failed ({label}) — will use silence detection for {seg_id}") + words = None - start_slide, end_slide = slide_range - transcripts_dir.mkdir(parents=True, exist_ok=True) - transcript_path = transcripts_dir / f"{seg_id}.json" + if words: + info["words"] = words + info["total_dur"] = get_video_duration(source_path) + info["slide_map"] = _map_slides_in_transcript(slide_texts, words) if slide_texts else {} - try: - if transcript_path.exists() and not seg_force: - words = load_transcript(transcript_path) - 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") + # ===================================================================== + # Phase B — reconcile overlaps between consecutive transcript segments. + # When two neighbours both contain a slide (a re-recorded retake), the whole + # shared region belongs to the later take: the earlier take is cut before + # the first shared slide, and the later take begins at it. One split point, + # so the join has no gap and no duplicated audio. + # ===================================================================== + overlap_end: "dict[str, tuple[int, float]]" = {} # seg_id -> (split_slide, cut_sec) + overlap_begin: "dict[str, tuple[int, float]]" = {} # seg_id -> (split_slide, begin_sec) - if not words: - print(f" no words found — falling back to silence detection") - slide_range = None - else: - total_dur = get_video_duration(source_path) - - # ---- 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: - 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" - - # ---- 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: - # Slide-end text didn't match the transcript (e.g. the - # narrator drifted from the manuscript). Fall back to the - # last spoken word + tail — NOT total_dur, which includes - # trailing silence and leaves the clip playing long. - end_abs = min(words[-1].end + _TRIM_TAIL_OUT, total_dur) - end_note = f"end auto: S{end_slide} not found → last word {words[-1].end:.2f}s +{_TRIM_TAIL_OUT:g}s → {end_abs:.2f}s" - print( - f"\n ⚠️ {seg_id}: could NOT match the end of S{end_slide} in the transcript.\n" - f" The manuscript text for S{end_slide} likely drifted from what was\n" - f" spoken. Falling back to the last spoken word ({words[-1].end:.2f}s)\n" - f" + {_TRIM_TAIL_OUT:g}s instead of the raw file length ({total_dur:.2f}s).\n" - f" Check S{end_slide}'s text or set an explicit `end` for {seg_id}." - ) - 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 - - except Exception as exc: - from .transcriber import TranscriptionError - label = "Whisper not installed" if "openai-whisper" in str(exc) else str(exc) - print(f"\n ⚠ transcription failed ({label}), falling back to silence detection") - slide_range = None # fall through - - # --- Silence-based fallback (respects user-pinned begin/end too) --- + mapped = [s for s in segment_order(narration) if infos.get(s, {}).get("slide_map")] + for a_id, b_id in zip(mapped, mapped[1:]): + a_map = infos[a_id]["slide_map"] + b_map = infos[b_id]["slide_map"] + shared = sorted(set(a_map) & set(b_map)) + # A real retake: the later take matches the shared slide at least as well + # as the earlier one. Split at the first such slide — the later take owns + # the whole overlap from there. If the earlier take is better everywhere + # (a stray coincidental match, not a retake), there's nothing to reconcile. + split = next((s for s in shared if b_map[s][1] >= a_map[s][1]), None) + if split is None: + continue + overlap_end[a_id] = (split, a_map[split][0]) + overlap_begin[b_id] = (split, b_map[split][0]) + a_q, b_q = a_map[split][1], b_map[split][1] print( - f" {seg_id}: analysing {source_path.parent.name}/{source_path.name}...", - end="", - flush=True, + f" \u2194 overlap {a_id}\u2194{b_id} on S{split}: cut {a_id} at " + f"{a_map[split][0]:.2f}s, {b_id} begins {b_map[split][0]:.2f}s " + f"(quality {a_q:.2f}\u2192{b_q:.2f})" ) - first_sound, last_sound = detect_silence_bounds( - source_path, noise_threshold_db=threshold_db, verbose=verbose - ) - total_dur = get_video_duration(source_path) + if max(a_q, b_q) < 0.5: + print(f" \u26a0\ufe0f both takes match S{split} weakly — verify the {a_id}/{b_id} seam.") - if begin_user: - skip = _user_begin_skip(existing) or 0.0 - begin_note = f"begin kept (user-set → {skip:.2f}s)" + # ===================================================================== + # Compute skip/take per segment and write the unlocked sides. + # ===================================================================== + updated = 0 + for seg_id in segment_order(narration): + info = infos.get(seg_id) + if info is None: + continue + existing = info["existing"] + begin_user, end_user = info["begin_user"], info["end_user"] + begin_locked, end_locked = info["begin_locked"], info["end_locked"] + words = info["words"] + + if begin_locked and end_locked: + print(f" {seg_id}: begin & end already set, skipping (use --force to redo)") + _trim_fps[seg_id] = info["current_fp"] + continue + + if not words: + # --- Silence-based fallback (no usable transcript) --- + print(f" {seg_id}: analysing {info['source_path'].parent.name}/{info['source_path'].name}...", end="", flush=True) + first_sound, last_sound = detect_silence_bounds( + info["source_path"], noise_threshold_db=threshold_db, verbose=verbose + ) + total_dur = get_video_duration(info["source_path"]) + if begin_locked: + skip = _locked_begin_skip(existing, begin_user) + begin_note = f"begin kept ({skip:.2f}s)" + else: + skip = max(0.0, round(first_sound - _TRIM_LEAD_IN, 3)) + begin_note = f"begin auto: first sound {first_sound:.2f}s \u2212{_TRIM_LEAD_IN:g}s \u2192 {skip:.2f}s" + if end_locked: + end_abs = _locked_end_abs(existing, end_user, skip) + end_note = f"end kept ({end_abs:.2f}s)" + else: + end_abs = min(total_dur, last_sound + 3.0) + end_note = f"end auto: last sound {last_sound:.2f}s +3.0s \u2192 {end_abs:.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" + total_dur = info["total_dur"] + slide_map = info["slide_map"] - 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" + # ---- Beginning ---- + if begin_locked: + skip = _locked_begin_skip(existing, begin_user) + begin_note = f"begin kept ({skip:.2f}s)" + elif seg_id in overlap_begin: + split, begin_sec = overlap_begin[seg_id] + skip = round(begin_sec, 3) # retake begins exactly at the shared slide + begin_note = f"begin: overlap retake \u2192 S{split} at {skip:.2f}s" + else: + if slide_map: + first_slide = min(slide_map) + start_ts = slide_map[first_slide][0] + src = f"S{first_slide}" + else: + start_ts = words[0].start + src = "first word" + skip = max(0.0, round(start_ts - _TRIM_LEAD_IN, 3)) + begin_note = f"begin auto: {src} {start_ts:.2f}s \u2212{_TRIM_LEAD_IN:g}s \u2192 {skip:.2f}s" - print(f" {begin_note} · {end_note}") - _trim_fps[seg_id] = current_fp + # ---- End ---- + if end_locked: + end_abs = _locked_end_abs(existing, end_user, skip) + end_note = f"end kept ({end_abs:.2f}s)" + elif seg_id in overlap_end: + split, cut_sec = overlap_end[seg_id] + end_abs = round(cut_sec, 3) # cut before the re-recorded slide + end_note = f"end: cut before S{split} (retaken next) at {end_abs:.2f}s" + else: + 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 \u2192 {end_abs:.2f}s" + + if not begin_locked: + raw_data.setdefault(seg_id, {})["skip"] = skip + if not end_locked: + raw_data.setdefault(seg_id, {})["take"] = round(max(0.0, end_abs - skip), 3) + + print(f" {begin_note} \u00b7 {end_note}") + _trim_fps[seg_id] = info["current_fp"] updated += 1 if updated > 0: diff --git a/import_all.sh b/import_all.sh new file mode 100755 index 0000000..72fecc5 --- /dev/null +++ b/import_all.sh @@ -0,0 +1,9 @@ +#!/bin/sh + + +./gnommo.sh -p video1 import +./gnommo.sh -p video2 import +./gnommo.sh -p video3 import +./gnommo.sh -p video4 import +./gnommo.sh -p video5 import +./gnommo.sh -p video6 import diff --git a/render_all.sh b/render_all.sh index f9092e5..69d456c 100755 --- a/render_all.sh +++ b/render_all.sh @@ -1,10 +1,9 @@ #!/bin/sh -./gnommo.sh -p video1 all -./gnommo.sh -p video2 all -./gnommo.sh -p video3 all -./gnommo.sh -p video4 all -./gnommo.sh -p video5 all -./gnommo.sh -p video6 all - +./gnommo.sh -p video1 render --force +./gnommo.sh -p video2 render --force +./gnommo.sh -p video3 render --force +./gnommo.sh -p video4 render --force +./gnommo.sh -p video5 render --force +./gnommo.sh -p video6 render --force diff --git a/trim_all.sh b/trim_all.sh new file mode 100755 index 0000000..6a00475 --- /dev/null +++ b/trim_all.sh @@ -0,0 +1,9 @@ +#!/bin/sh + + +./gnommo.sh -p video1 trim --force +./gnommo.sh -p video2 trim --force +./gnommo.sh -p video3 trim --force +./gnommo.sh -p video4 trim --force +./gnommo.sh -p video5 trim --force +./gnommo.sh -p video6 trim --force