Improvement to the trim
This commit is contained in:
+162
-58
@@ -2746,6 +2746,21 @@ _TRIM_STOP_WORDS = frozenset({
|
|||||||
"when", "how", "who", "all", "very", "also", "more", "get", "have",
|
"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":
|
def _parse_segment_slide_range(seg_id: str) -> "tuple[int, int | None] | None":
|
||||||
"""Parse 'S11-24' → (11, 24), 'S1-end' → (1, None), else 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)
|
n = len(target)
|
||||||
threshold = max(1, int(n * 0.55))
|
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):
|
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_idxs = content_idxs[max(0, end_ci - n + 1): end_ci + 1]
|
||||||
window = [norm[i] for i in window_idxs]
|
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
|
# Sequential match: iterate target left-to-right, consume window matches
|
||||||
score = 0
|
score = 0
|
||||||
wi = 0
|
wi = 0
|
||||||
|
last_match_wi = None
|
||||||
for t_word in target:
|
for t_word in target:
|
||||||
while wi < len(window):
|
while wi < len(window):
|
||||||
w = window[wi]
|
w = window[wi]
|
||||||
|
matched = w == t_word or (len(w) >= 4 and len(t_word) >= 4 and w[:4] == t_word[:4])
|
||||||
wi += 1
|
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
|
score += 1
|
||||||
break
|
break
|
||||||
|
|
||||||
if score >= threshold:
|
if score >= threshold and last_match_wi is not None:
|
||||||
last_tw = transcript_words[content_idxs[end_ci]]
|
last_tw = transcript_words[window_idxs[last_match_wi]]
|
||||||
if verbose:
|
if verbose:
|
||||||
print(f"\n → matched slide end: '{last_tw.word}' at {last_tw.end:.2f}s")
|
print(f"\n → matched slide end: '{last_tw.word}' at {last_tw.end:.2f}s")
|
||||||
return last_tw.end
|
return last_tw.end
|
||||||
@@ -2832,6 +2852,66 @@ def _find_slide_end_in_transcript(
|
|||||||
return None
|
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(
|
||||||
project_path: Path,
|
project_path: Path,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
@@ -2841,18 +2921,20 @@ def cmd_trim(
|
|||||||
whisper_model: str = "base",
|
whisper_model: str = "base",
|
||||||
) -> int:
|
) -> 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:
|
Trim only fills in the side(s) the user hasn't pinned. A user-set `begin`/
|
||||||
- Transcribes audio with Whisper to get word-level timestamps
|
`start` pins the beginning; a user-set `end` pins the end — those are always
|
||||||
- skip = max(0, first_word.start - 0.5)
|
respected (even under --force). Only the un-pinned side is auto-detected:
|
||||||
- take = (end of last word on slide M) + 0.15 - skip
|
|
||||||
- S{N}-end.mov: only trims the start, no end trim
|
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
|
- 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
|
from .preprocessor import detect_silence_bounds, get_video_duration
|
||||||
|
|
||||||
print(f"Trimming narration: {project_path.name}")
|
print(f"Trimming narration: {project_path.name}")
|
||||||
@@ -2920,13 +3002,19 @@ def cmd_trim(
|
|||||||
seg_force = force or source_changed
|
seg_force = force or source_changed
|
||||||
|
|
||||||
existing = raw_data.get(seg_id, {})
|
existing = raw_data.get(seg_id, {})
|
||||||
# Any manual trim point counts as "already trimmed" — including the
|
# Respect the user's manual trim points independently per side, and only
|
||||||
# user-friendly begin/end/start aliases. Without this, a segment with
|
# auto-detect the side they haven't pinned:
|
||||||
# only a manual `begin` would be re-trimmed and have auto-detected
|
# - begin/start pin the beginning; end pins the end (user vocabulary).
|
||||||
# skip/take written over it, silently clobbering the intended trim.
|
# - skip/take are what trim itself writes (or raw manual overrides).
|
||||||
has_explicit = any(k in existing for k in ("skip", "take", "begin", "end", "start"))
|
# A side counts as "resolved" if pinned by the user OR already written by
|
||||||
if has_explicit and not seg_force:
|
# trim; --force re-detects auto (skip/take) sides but never overwrites a
|
||||||
print(f" {seg_id}: already trimmed, skipping (use --force to redo)")
|
# 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
|
_trim_fps[seg_id] = current_fp # adopt/keep current fingerprint
|
||||||
continue
|
continue
|
||||||
if source_changed:
|
if source_changed:
|
||||||
@@ -2945,47 +3033,57 @@ def cmd_trim(
|
|||||||
try:
|
try:
|
||||||
if transcript_path.exists() and not seg_force:
|
if transcript_path.exists() and not seg_force:
|
||||||
words = load_transcript(transcript_path)
|
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:
|
else:
|
||||||
print(f" {seg_id}: transcribing {source_path.parent.name}/{source_path.name} (model={whisper_model})...", end="", flush=True)
|
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)
|
words = transcribe_video(source_path, model=whisper_model)
|
||||||
save_transcript(words, transcript_path)
|
save_transcript(words, transcript_path)
|
||||||
print(f" {len(words)} words", end="", flush=True)
|
print(f" {len(words)} words")
|
||||||
|
|
||||||
if not 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
|
slide_range = None
|
||||||
else:
|
else:
|
||||||
total_dur = get_video_duration(source_path)
|
total_dur = get_video_duration(source_path)
|
||||||
new_skip = max(0.0, round(words[0].start - 0.5, 3))
|
|
||||||
|
|
||||||
if end_slide is None:
|
# ---- Beginning: keep user's begin, else start of start-slide ----
|
||||||
# S{N}-end: trim start and 2s after last spoken word
|
if begin_user:
|
||||||
last_word_end = words[-1].end
|
skip = _user_begin_skip(existing) or 0.0
|
||||||
new_take = round(min(last_word_end + 2.0 - new_skip, total_dur - new_skip), 3)
|
begin_note = f"begin kept (user-set → {skip:.2f}s)"
|
||||||
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"
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
last_slide_text = slide_texts.get(end_slide, "")
|
start_text = slide_texts.get(start_slide, "")
|
||||||
end_ts = None
|
start_ts = _find_slide_start_in_transcript(words, start_text, verbose) if start_text else None
|
||||||
if last_slide_text:
|
src = f"S{start_slide} first word" if start_ts is not None else "first word"
|
||||||
end_ts = _find_slide_end_in_transcript(words, last_slide_text, verbose)
|
if start_ts is None:
|
||||||
if end_ts is None:
|
start_ts = words[0].start
|
||||||
print(f"\n ⚠ could not locate S{end_slide} end in transcript — trimming start only")
|
skip = max(0.0, round(start_ts - _TRIM_LEAD_IN, 3))
|
||||||
new_take = round(total_dur - new_skip, 3)
|
begin_note = f"begin auto: {src} {start_ts:.2f}s −{_TRIM_LEAD_IN:g}s → {skip:.2f}s"
|
||||||
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"
|
|
||||||
)
|
|
||||||
|
|
||||||
raw_data[seg_id]["skip"] = new_skip
|
# ---- End: keep user's end, else end of end-slide + tail ----
|
||||||
raw_data[seg_id]["take"] = new_take
|
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
|
_trim_fps[seg_id] = current_fp
|
||||||
updated += 1
|
updated += 1
|
||||||
continue
|
continue
|
||||||
@@ -2996,7 +3094,7 @@ def cmd_trim(
|
|||||||
print(f"\n ⚠ transcription failed ({label}), falling back to silence detection")
|
print(f"\n ⚠ transcription failed ({label}), falling back to silence detection")
|
||||||
slide_range = None # fall through
|
slide_range = None # fall through
|
||||||
|
|
||||||
# --- Silence-based fallback ---
|
# --- Silence-based fallback (respects user-pinned begin/end too) ---
|
||||||
print(
|
print(
|
||||||
f" {seg_id}: analysing {source_path.parent.name}/{source_path.name}...",
|
f" {seg_id}: analysing {source_path.parent.name}/{source_path.name}...",
|
||||||
end="",
|
end="",
|
||||||
@@ -3007,17 +3105,23 @@ def cmd_trim(
|
|||||||
)
|
)
|
||||||
total_dur = get_video_duration(source_path)
|
total_dur = get_video_duration(source_path)
|
||||||
|
|
||||||
new_skip = max(0.0, round(first_sound - 0.5, 3))
|
if begin_user:
|
||||||
new_take = round(min(total_dur - new_skip, last_sound + 3.0 - new_skip), 3)
|
skip = _user_begin_skip(existing) or 0.0
|
||||||
new_take = max(0.0, new_take)
|
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(
|
if end_user:
|
||||||
f" first={first_sound:.2f}s last={last_sound:.2f}s"
|
end_abs = parse_timestamp(existing["end"])
|
||||||
f" → skip={new_skip:.3f}s take={new_take:.3f}s"
|
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
|
print(f" {begin_note} · {end_note}")
|
||||||
raw_data[seg_id]["take"] = new_take
|
|
||||||
_trim_fps[seg_id] = current_fp
|
_trim_fps[seg_id] = current_fp
|
||||||
updated += 1
|
updated += 1
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user