Adding a few fixes to the gnommo pipeline
This commit is contained in:
Executable
+11
@@ -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
@@ -11,14 +11,20 @@ Implemented:
|
|||||||
take`; `events_to_marker_timings` round-trips them as overrides.
|
take`; `events_to_marker_timings` round-trips them as overrides.
|
||||||
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
|
- Inline grammar `[prefix:handle, key=value, …]` (`parser.parse_marker`), threaded through
|
||||||
alignment into `MarkerTiming.overrides`. Supported inline keys: **cutout, layer, end_on,
|
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).
|
- 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/
|
`volume` is **sparse/overridable**: the renderer reads `VideoEvent.volume` (line ~1571),
|
||||||
use_audio_channels/pause_narration) — the renderer reads these from `video_source` in ~13
|
which is the events.json override if present else the videos.json default — so a videos.json
|
||||||
places, so wiring them per-event is a separate change; stripping the moved fields from
|
change keeps propagating, and events.json only stores `volume` when it's actually overridden
|
||||||
videos.json (kept as fallback defaults for now); a validator warning for unknown/unwired
|
(inline/GUI/manual). `derive_events` materializes it only when overridden; `_EVENT_OVERRIDE_KEYS`
|
||||||
inline 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
|
## Problem
|
||||||
|
|
||||||
|
|||||||
+244
-268
@@ -2906,16 +2906,6 @@ def cmd_preprocess(
|
|||||||
# Trim Command — transcript-based trimming for slide-range segments
|
# 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
|
# 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.
|
# slide's first word, and this much tail after the end slide's last word.
|
||||||
_TRIM_LEAD_IN = 0.5
|
_TRIM_LEAD_IN = 0.5
|
||||||
@@ -2932,14 +2922,19 @@ def _user_begin_skip(existing: dict) -> "float | None":
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _parse_segment_slide_range(seg_id: str) -> "tuple[int, int | None] | None":
|
def _locked_begin_skip(existing: dict, begin_user: bool) -> float:
|
||||||
"""Parse 'S11-24' → (11, 24), 'S1-end' → (1, None), else None."""
|
"""Skip (seconds) for a segment whose begin is locked — user pin or prior value."""
|
||||||
m = re.match(r"^s(\d+)-(?:s?(\d+)|(end))$", seg_id.lower())
|
if begin_user:
|
||||||
if not m:
|
return _user_begin_skip(existing) or 0.0
|
||||||
return None
|
return float(existing.get("skip", 0.0))
|
||||||
start = int(m.group(1))
|
|
||||||
end = int(m.group(2)) if m.group(2) else None
|
|
||||||
return (start, end)
|
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]":
|
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
|
return result
|
||||||
|
|
||||||
|
|
||||||
def _trim_content_words(text: str) -> "list[str]":
|
def _map_slides_in_transcript(
|
||||||
"""Lowercase words stripped of punctuation, excluding stop words."""
|
slide_texts: "dict[int, str]",
|
||||||
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(
|
|
||||||
transcript_words: list,
|
transcript_words: list,
|
||||||
slide_text: str,
|
fuzzy_threshold: float = 0.6,
|
||||||
verbose: bool = False,
|
) -> "dict[int, tuple[float, float]]":
|
||||||
) -> "float | None":
|
"""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
|
from .transformer import _find_phrase_timestamp
|
||||||
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
|
|
||||||
|
|
||||||
# Normalise transcript words (strip punctuation, lowercase)
|
hits: "list[tuple[int, float, float]]" = [] # (slide_num, start_sec, quality)
|
||||||
norm = [re.sub(r"[^a-z0-9']", "", w.word.lower()) for w in transcript_words]
|
for slide_num in sorted(slide_texts):
|
||||||
# Index into transcript of content words only
|
anchor = " ".join(slide_texts[slide_num].split()[:10])
|
||||||
content_idxs = [
|
if not anchor.strip():
|
||||||
i for i, w in enumerate(norm)
|
continue
|
||||||
if w and w not in _TRIM_STOP_WORDS and len(w) > 2
|
idx, timestamp, confidence, _match_end = _find_phrase_timestamp(
|
||||||
]
|
anchor, transcript_words, start_from=0, fuzzy_threshold=fuzzy_threshold
|
||||||
if not content_idxs:
|
)
|
||||||
return None
|
if idx >= 0:
|
||||||
|
hits.append((slide_num, max(0.0, round(timestamp, 3)), round(confidence, 3)))
|
||||||
|
if not hits:
|
||||||
|
return {}
|
||||||
|
|
||||||
n = len(target)
|
# Longest strictly-increasing-by-time subsequence over slides in number order.
|
||||||
threshold = max(1, int(n * 0.55))
|
n = len(hits)
|
||||||
|
run_len = [1] * n
|
||||||
# Scan backwards: find the latest window of n content words that matches,
|
prev = [-1] * n
|
||||||
# and return the end timestamp of the LAST matched word — not the window's
|
for i in range(n):
|
||||||
# last word, which may be trailing filler after the slide's actual last word.
|
for j in range(i):
|
||||||
for end_ci in range(len(content_idxs) - 1, n - 2, -1):
|
if hits[j][1] < hits[i][1] and run_len[j] + 1 > run_len[i]:
|
||||||
window_idxs = content_idxs[max(0, end_ci - n + 1): end_ci + 1]
|
run_len[i] = run_len[j] + 1
|
||||||
window = [norm[i] for i in window_idxs]
|
prev[i] = j
|
||||||
|
end = max(range(n), key=lambda i: run_len[i])
|
||||||
# Sequential match: iterate target left-to-right, consume window matches
|
keep = []
|
||||||
score = 0
|
while end != -1:
|
||||||
wi = 0
|
keep.append(end)
|
||||||
last_match_wi = None
|
end = prev[end]
|
||||||
for t_word in target:
|
keep.reverse()
|
||||||
while wi < len(window):
|
return {hits[i][0]: (hits[i][1], hits[i][2]) for i in keep}
|
||||||
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
|
|
||||||
|
|
||||||
|
|
||||||
def cmd_trim(
|
def cmd_trim(
|
||||||
@@ -3093,18 +3018,23 @@ def cmd_trim(
|
|||||||
"""
|
"""
|
||||||
Auto-detect skip/take for narration segments and write them into narration.json.
|
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`/
|
Each segment is transcribed (cached in narration/transcripts/{seg_id}.json)
|
||||||
`start` pins the beginning; a user-set `end` pins the end — those are always
|
and its slides are located by content — _map_slides_in_transcript discovers
|
||||||
respected (even under --force). Only the un-pinned side is auto-detected:
|
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):
|
Two passes:
|
||||||
- begin = start of slide N's first word − 0.5s (falls back to first word)
|
1. Per segment, build a {slide: (start, quality)} map.
|
||||||
- end = end of slide M's last word + 2.0s (S{N}-end: last spoken word + 2.0s)
|
2. Reconcile overlaps: when consecutive segments share a slide (a
|
||||||
- Transcripts are cached in narration/transcripts/{seg_id}.json
|
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
|
from .preprocessor import detect_silence_bounds, get_video_duration
|
||||||
|
|
||||||
print(f"Trimming narration: {project_path.name}")
|
print(f"Trimming narration: {project_path.name}")
|
||||||
@@ -3153,11 +3083,22 @@ def cmd_trim(
|
|||||||
_prev_trim_fps = _state.get_items(project_path, "trim")
|
_prev_trim_fps = _state.get_items(project_path, "trim")
|
||||||
_trim_fps: dict[str, str] = {} # segments to (re)record after the loop
|
_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]
|
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)
|
source_path = raw_lookup.get(seg_id)
|
||||||
if source_path is None:
|
if source_path is None:
|
||||||
source_path = narration_dir / seg.source_file
|
source_path = narration_dir / seg.source_file
|
||||||
@@ -3165,145 +3106,180 @@ def cmd_trim(
|
|||||||
print(f" {seg_id}: source file not found, skipping")
|
print(f" {seg_id}: source file not found, skipping")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Has the raw source changed since we last trimmed this segment?
|
|
||||||
current_fp = _state.fingerprint_path(source_path, _state.META)
|
current_fp = _state.fingerprint_path(source_path, _state.META)
|
||||||
recorded_fp = _prev_trim_fps.get(seg_id)
|
recorded_fp = _prev_trim_fps.get(seg_id)
|
||||||
source_changed = recorded_fp is not None and recorded_fp != current_fp
|
source_changed = recorded_fp is not None and recorded_fp != current_fp
|
||||||
seg_force = force or source_changed
|
seg_force = force or source_changed
|
||||||
|
|
||||||
existing = raw_data.get(seg_id, {})
|
existing = raw_data.get(seg_id, {})
|
||||||
# Respect the user's manual trim points independently per side, and only
|
# begin/start pin the beginning; end pins the end (user vocabulary).
|
||||||
# auto-detect the side they haven't pinned:
|
# A side is "locked" if the user pinned it, or trim already wrote it and
|
||||||
# - begin/start pin the beginning; end pins the end (user vocabulary).
|
# we're not re-detecting (--force / changed source). Locked sides are
|
||||||
# - skip/take are what trim itself writes (or raw manual overrides).
|
# preserved verbatim; only unlocked sides are (re)computed.
|
||||||
# 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"))
|
begin_user = bool(existing.get("begin") or existing.get("start"))
|
||||||
end_user = bool(existing.get("end"))
|
end_user = bool(existing.get("end"))
|
||||||
begin_resolved = begin_user or "skip" in existing
|
begin_locked = begin_user or ("skip" in existing and not seg_force)
|
||||||
end_resolved = end_user or "take" in existing
|
end_locked = end_user or ("take" in existing and not seg_force)
|
||||||
if begin_resolved and end_resolved and not seg_force:
|
fully_locked = begin_locked and end_locked
|
||||||
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:
|
if source_changed:
|
||||||
print(f" {seg_id}: raw source changed since last trim — re-trimming")
|
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:
|
# Transcription is expensive and deterministic, so reuse a cached
|
||||||
# --- Transcript-based trimming ---
|
# transcript whenever the source is unchanged — even under --force, which
|
||||||
from .transcriber import transcribe_video, save_transcript, load_transcript, TranscriptionError
|
# 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
|
if words:
|
||||||
transcripts_dir.mkdir(parents=True, exist_ok=True)
|
info["words"] = words
|
||||||
transcript_path = transcripts_dir / f"{seg_id}.json"
|
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:
|
# Phase B — reconcile overlaps between consecutive transcript segments.
|
||||||
words = load_transcript(transcript_path)
|
# When two neighbours both contain a slide (a re-recorded retake), the whole
|
||||||
print(f" {seg_id}: loaded cached transcript ({len(words)} words)")
|
# shared region belongs to the later take: the earlier take is cut before
|
||||||
else:
|
# the first shared slide, and the later take begins at it. One split point,
|
||||||
print(f" {seg_id}: transcribing {source_path.parent.name}/{source_path.name} (model={whisper_model})...", end="", flush=True)
|
# so the join has no gap and no duplicated audio.
|
||||||
words = transcribe_video(source_path, model=whisper_model)
|
# =====================================================================
|
||||||
save_transcript(words, transcript_path)
|
overlap_end: "dict[str, tuple[int, float]]" = {} # seg_id -> (split_slide, cut_sec)
|
||||||
print(f" {len(words)} words")
|
overlap_begin: "dict[str, tuple[int, float]]" = {} # seg_id -> (split_slide, begin_sec)
|
||||||
|
|
||||||
if not words:
|
mapped = [s for s in segment_order(narration) if infos.get(s, {}).get("slide_map")]
|
||||||
print(f" no words found — falling back to silence detection")
|
for a_id, b_id in zip(mapped, mapped[1:]):
|
||||||
slide_range = None
|
a_map = infos[a_id]["slide_map"]
|
||||||
else:
|
b_map = infos[b_id]["slide_map"]
|
||||||
total_dur = get_video_duration(source_path)
|
shared = sorted(set(a_map) & set(b_map))
|
||||||
|
# A real retake: the later take matches the shared slide at least as well
|
||||||
# ---- Beginning: keep user's begin, else start of start-slide ----
|
# as the earlier one. Split at the first such slide — the later take owns
|
||||||
if begin_user:
|
# the whole overlap from there. If the earlier take is better everywhere
|
||||||
skip = _user_begin_skip(existing) or 0.0
|
# (a stray coincidental match, not a retake), there's nothing to reconcile.
|
||||||
begin_note = f"begin kept (user-set → {skip:.2f}s)"
|
split = next((s for s in shared if b_map[s][1] >= a_map[s][1]), None)
|
||||||
else:
|
if split is None:
|
||||||
start_text = slide_texts.get(start_slide, "")
|
continue
|
||||||
start_ts = _find_slide_start_in_transcript(words, start_text, verbose) if start_text else None
|
overlap_end[a_id] = (split, a_map[split][0])
|
||||||
src = f"S{start_slide} first word" if start_ts is not None else "first word"
|
overlap_begin[b_id] = (split, b_map[split][0])
|
||||||
if start_ts is None:
|
a_q, b_q = a_map[split][1], b_map[split][1]
|
||||||
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) ---
|
|
||||||
print(
|
print(
|
||||||
f" {seg_id}: analysing {source_path.parent.name}/{source_path.name}...",
|
f" \u2194 overlap {a_id}\u2194{b_id} on S{split}: cut {a_id} at "
|
||||||
end="",
|
f"{a_map[split][0]:.2f}s, {b_id} begins {b_map[split][0]:.2f}s "
|
||||||
flush=True,
|
f"(quality {a_q:.2f}\u2192{b_q:.2f})"
|
||||||
)
|
)
|
||||||
first_sound, last_sound = detect_silence_bounds(
|
if max(a_q, b_q) < 0.5:
|
||||||
source_path, noise_threshold_db=threshold_db, verbose=verbose
|
print(f" \u26a0\ufe0f both takes match S{split} weakly — verify the {a_id}/{b_id} seam.")
|
||||||
)
|
|
||||||
total_dur = get_video_duration(source_path)
|
|
||||||
|
|
||||||
if begin_user:
|
# =====================================================================
|
||||||
skip = _user_begin_skip(existing) or 0.0
|
# Compute skip/take per segment and write the unlocked sides.
|
||||||
begin_note = f"begin kept (user-set → {skip:.2f}s)"
|
# =====================================================================
|
||||||
|
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:
|
else:
|
||||||
skip = max(0.0, round(first_sound - _TRIM_LEAD_IN, 3))
|
total_dur = info["total_dur"]
|
||||||
raw_data[seg_id]["skip"] = skip
|
slide_map = info["slide_map"]
|
||||||
begin_note = f"begin auto: first sound {first_sound:.2f}s −{_TRIM_LEAD_IN:g}s → {skip:.2f}s"
|
|
||||||
|
|
||||||
if end_user:
|
# ---- Beginning ----
|
||||||
end_abs = parse_timestamp(existing["end"])
|
if begin_locked:
|
||||||
end_note = f"end kept (user-set → {end_abs:.2f}s)"
|
skip = _locked_begin_skip(existing, begin_user)
|
||||||
else:
|
begin_note = f"begin kept ({skip:.2f}s)"
|
||||||
end_abs = min(total_dur, last_sound + 3.0)
|
elif seg_id in overlap_begin:
|
||||||
raw_data[seg_id]["take"] = round(max(0.0, end_abs - skip), 3)
|
split, begin_sec = overlap_begin[seg_id]
|
||||||
end_note = f"end auto: last sound {last_sound:.2f}s +3.0s → {end_abs:.2f}s"
|
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}")
|
# ---- End ----
|
||||||
_trim_fps[seg_id] = current_fp
|
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
|
updated += 1
|
||||||
|
|
||||||
if updated > 0:
|
if updated > 0:
|
||||||
|
|||||||
Executable
+9
@@ -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
@@ -1,10 +1,9 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
|
|
||||||
|
|
||||||
./gnommo.sh -p video1 all
|
./gnommo.sh -p video1 render --force
|
||||||
./gnommo.sh -p video2 all
|
./gnommo.sh -p video2 render --force
|
||||||
./gnommo.sh -p video3 all
|
./gnommo.sh -p video3 render --force
|
||||||
./gnommo.sh -p video4 all
|
./gnommo.sh -p video4 render --force
|
||||||
./gnommo.sh -p video5 all
|
./gnommo.sh -p video5 render --force
|
||||||
./gnommo.sh -p video6 all
|
./gnommo.sh -p video6 render --force
|
||||||
|
|
||||||
|
|||||||
Executable
+9
@@ -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
|
||||||
Reference in New Issue
Block a user