Adding a few fixes to the gnommo pipeline

This commit is contained in:
2026-07-29 17:27:48 +02:00
parent 4c6c9b8569
commit ec08e945e5
6 changed files with 291 additions and 281 deletions
Executable
+11
View File
@@ -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
+12 -6
View File
@@ -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
+244 -268
View File
@@ -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:
Executable
+9
View File
@@ -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
+6 -7
View File
@@ -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
Executable
+9
View File
@@ -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