Improvement to the trim

This commit is contained in:
2026-07-15 12:12:24 +02:00
parent bb1b17d531
commit 02a6131d15
+162 -58
View File
@@ -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