1451 lines
52 KiB
Python
1451 lines
52 KiB
Python
"""Transform stage: resolve timings and build render plan."""
|
||
|
||
import re
|
||
import string
|
||
from dataclasses import dataclass
|
||
from pathlib import Path
|
||
from typing import Optional
|
||
|
||
from .models import (
|
||
AudioDefinition,
|
||
AudioEvent,
|
||
CameraEvent,
|
||
CameraState,
|
||
CutoutDefinition,
|
||
CAMERA_PRESETS,
|
||
NarrationPause,
|
||
OutroEvent,
|
||
ProjectConfig,
|
||
RenderPlan,
|
||
SlideDefinition,
|
||
SlideEvent,
|
||
VideoEvent,
|
||
VideoSource,
|
||
)
|
||
from .parser import get_video_duration, resolve_missing_videos
|
||
from .transcriber import TranscribedWord
|
||
|
||
# Audio trigger offset: play sound this many seconds before the marker
|
||
AUDIO_OFFSET_SECONDS = 1.0
|
||
|
||
# Shorthand marker prefix → (cutout_name, layer).
|
||
# These are the ETL source-of-truth: when a manuscript contains [vft:X],
|
||
# that projects cutout="fullscreen" and layer="above" into videos.json for X.
|
||
# The pause-variant entries (vftp: etc.) carry a third element "pause_narration"
|
||
# which is a per-event property, not stored in videos.json.
|
||
_SHORTHAND_PREFIXES: dict[str, tuple] = {
|
||
"vft:": ("fullscreen", "above"),
|
||
"vfb:": ("fullscreen", "below"),
|
||
"vfm:": ("fullscreen", "mid"),
|
||
"vf2t:": ("fullscreen2", "above"),
|
||
"vf2b:": ("fullscreen2", "below"),
|
||
"vf2m:": ("fullscreen2", "mid"),
|
||
"vst:": ("square", "above"),
|
||
"vsb:": ("square", "below"),
|
||
"vsm:": ("square", "mid"),
|
||
"vftp:": ("fullscreen", "above"),
|
||
"vfbp:": ("fullscreen", "below"),
|
||
"vfmp:": ("fullscreen", "mid"),
|
||
"vf2tp:": ("fullscreen2", "above"),
|
||
"vf2bp:": ("fullscreen2", "below"),
|
||
"vf2mp:": ("fullscreen2", "mid"),
|
||
"vstp:": ("square", "above"),
|
||
"vsbp:": ("square", "below"),
|
||
"vsmp:": ("square", "mid"),
|
||
}
|
||
|
||
|
||
@dataclass
|
||
class MarkerTiming:
|
||
"""A marker with its aligned timestamp and confidence."""
|
||
|
||
marker_id: str
|
||
timestamp: float # -1 if not found
|
||
context: str # the text following the marker
|
||
confidence: float # 0-1, how confident the match is
|
||
|
||
|
||
def _normalize_text(text: str) -> str:
|
||
"""Normalize text for matching (lowercase, expand contractions, remove punctuation)."""
|
||
text = text.lower()
|
||
# Expand common contractions before removing punctuation
|
||
# This ensures "I'm" matches "I am" in transcripts
|
||
contractions = {
|
||
"i'm": "i am",
|
||
"you're": "you are",
|
||
"we're": "we are",
|
||
"they're": "they are",
|
||
"he's": "he is",
|
||
"she's": "she is",
|
||
"it's": "it is",
|
||
"that's": "that is",
|
||
"what's": "what is",
|
||
"there's": "there is",
|
||
"here's": "here is",
|
||
"who's": "who is",
|
||
"how's": "how is",
|
||
"let's": "let us",
|
||
"i've": "i have",
|
||
"you've": "you have",
|
||
"we've": "we have",
|
||
"they've": "they have",
|
||
"i'd": "i would",
|
||
"you'd": "you would",
|
||
"he'd": "he would",
|
||
"she'd": "she would",
|
||
"we'd": "we would",
|
||
"they'd": "they would",
|
||
"i'll": "i will",
|
||
"you'll": "you will",
|
||
"he'll": "he will",
|
||
"she'll": "she will",
|
||
"we'll": "we will",
|
||
"they'll": "they will",
|
||
"isn't": "is not",
|
||
"aren't": "are not",
|
||
"wasn't": "was not",
|
||
"weren't": "were not",
|
||
"haven't": "have not",
|
||
"hasn't": "has not",
|
||
"hadn't": "had not",
|
||
"won't": "will not",
|
||
"wouldn't": "would not",
|
||
"don't": "do not",
|
||
"doesn't": "does not",
|
||
"didn't": "did not",
|
||
"can't": "cannot",
|
||
"couldn't": "could not",
|
||
"shouldn't": "should not",
|
||
"mightn't": "might not",
|
||
"mustn't": "must not",
|
||
}
|
||
for contraction, expansion in contractions.items():
|
||
text = re.sub(r"\b" + re.escape(contraction) + r"\b", expansion, text)
|
||
text = re.sub(r"[^\w\s]", "", text)
|
||
text = re.sub(r"\s+", " ", text)
|
||
return text.strip()
|
||
|
||
|
||
def _normalize_token(word: str) -> str:
|
||
"""Normalize a single word token for comparison.
|
||
|
||
Strips leading/trailing punctuation and lowercases. Interior characters
|
||
(e.g. apostrophes in contractions) are preserved so "don't" stays "don't".
|
||
Applied to both transcript tokens and phrase words at comparison time.
|
||
"""
|
||
return word.lower().strip(string.punctuation)
|
||
|
||
|
||
def _is_known_marker(
|
||
marker_id: str, slides: dict = None, videos: dict = None, audio: dict = None
|
||
) -> bool:
|
||
"""
|
||
Check if a marker is a known type that should be processed.
|
||
|
||
Known markers:
|
||
- Slide markers (S1, S2, etc.) - must be in slides dict
|
||
- video:xxx - video triggers
|
||
- narration:xxx - narration triggers
|
||
- Camera presets (Zoom1, TiltLeft, etc.)
|
||
- Audio markers (A1, A2, etc.)
|
||
|
||
Unknown markers are ignored (not part of the render plan).
|
||
"""
|
||
slides = slides or {}
|
||
videos = videos or {}
|
||
audio = audio or {}
|
||
|
||
# Slide markers
|
||
if marker_id in slides:
|
||
return True
|
||
|
||
# Video/narration triggers (all supported prefixes)
|
||
_VIDEO_PREFIXES = (
|
||
"video:",
|
||
"narration:",
|
||
"vft:", "vfb:", "vfm:",
|
||
"vf2t:", "vf2b:", "vf2m:",
|
||
"vst:", "vsb:", "vsm:",
|
||
"vftp:", "vfbp:", "vfmp:",
|
||
"vf2tp:", "vf2bp:", "vf2mp:",
|
||
"vstp:", "vsbp:", "vsmp:",
|
||
)
|
||
if any(marker_id.startswith(p) for p in _VIDEO_PREFIXES):
|
||
return True
|
||
|
||
# Camera presets
|
||
if marker_id in CAMERA_PRESETS:
|
||
return True
|
||
|
||
# Audio markers (A followed by id, e.g., Awoosh) or audio: prefix (e.g., audio:woosh)
|
||
if marker_id.startswith("A") and len(marker_id) > 1:
|
||
audio_id = marker_id[1:]
|
||
if audio_id in audio or audio_id.isdigit():
|
||
return True
|
||
if marker_id.startswith("audio:") and audio is not None:
|
||
audio_id = marker_id[6:]
|
||
if audio_id in audio:
|
||
return True
|
||
|
||
return False
|
||
|
||
|
||
def _strip_unknown_markers(
|
||
text: str, slides: dict = None, videos: dict = None, audio: dict = None
|
||
) -> str:
|
||
"""
|
||
Remove all [...] markers from context text — none are pronounced aloud.
|
||
|
||
Note: [cite:...] markers are already stripped at parse time by parse_manuscript().
|
||
"""
|
||
return re.sub(r"\[([^\]]+)\]", "", text)
|
||
|
||
|
||
def _extract_marker_contexts(
|
||
manuscript_text: str,
|
||
slides: dict = None,
|
||
videos: dict = None,
|
||
audio: dict = None,
|
||
) -> list[tuple[str, str, bool, str]]:
|
||
"""
|
||
Extract known markers and the text immediately following them from manuscript.
|
||
|
||
Unknown markers are filtered out and stripped from following text.
|
||
Note: [cite:...] markers are already stripped at parse time.
|
||
|
||
Returns list of (marker_id, anchor_text, is_borrowed, anchor_type) tuples.
|
||
anchor_type is "before" (default — place before the matched phrase) or
|
||
"after" (place at the end of the matched phrase — used for markers that
|
||
trail a narration block and have no following text of their own).
|
||
"""
|
||
slides = slides or {}
|
||
videos = videos or {}
|
||
audio = audio or {}
|
||
|
||
parts = re.split(r"\[([^\]]+)\]", manuscript_text)
|
||
|
||
raw_contexts = []
|
||
for i in range(1, len(parts), 2):
|
||
marker_id = parts[i]
|
||
|
||
if not _is_known_marker(marker_id, slides, videos, audio):
|
||
continue
|
||
|
||
text_pieces = []
|
||
j = i + 1
|
||
while j < len(parts):
|
||
chunk = parts[j].strip()
|
||
if chunk:
|
||
text_pieces.append(chunk)
|
||
j += 1
|
||
if j >= len(parts):
|
||
break
|
||
if _is_known_marker(parts[j], slides, videos, audio):
|
||
break
|
||
j += 1
|
||
|
||
following_text = " ".join(text_pieces)
|
||
following_text = " ".join(following_text.split())
|
||
following_text = _strip_unknown_markers(following_text, slides, videos, audio)
|
||
following_text = " ".join(following_text.split())
|
||
raw_contexts.append((marker_id, following_text))
|
||
|
||
contexts = []
|
||
for i, (marker_id, following_text) in enumerate(raw_contexts):
|
||
if following_text:
|
||
words = following_text.split()[:10]
|
||
contexts.append((marker_id, " ".join(words), False, "before"))
|
||
else:
|
||
borrowed = False
|
||
for j in range(i + 1, len(raw_contexts)):
|
||
next_marker_id, next_text = raw_contexts[j]
|
||
if next_text:
|
||
if next_marker_id in (slides or {}):
|
||
break
|
||
words = next_text.split()[:10]
|
||
contexts.append((marker_id, " ".join(words), True, "before"))
|
||
borrowed = True
|
||
break
|
||
if not borrowed:
|
||
# No following text and blocked by a slide boundary — look
|
||
# backward for the tail of the preceding narration block and
|
||
# anchor to the END of those words instead of extrapolating.
|
||
preceding_text = ""
|
||
for k in range(i - 1, -1, -1):
|
||
if raw_contexts[k][1]:
|
||
preceding_text = raw_contexts[k][1]
|
||
break
|
||
if preceding_text:
|
||
words = preceding_text.split()
|
||
tail = " ".join(words[-6:])
|
||
contexts.append((marker_id, tail, False, "after"))
|
||
else:
|
||
contexts.append((marker_id, "", False, "before"))
|
||
|
||
return contexts
|
||
|
||
|
||
def _fuzzy_match_ratio(
|
||
phrase_words: list[str],
|
||
transcription: list[TranscribedWord],
|
||
start_idx: int,
|
||
window_size: int = 10,
|
||
pre_filler: int = 30,
|
||
inter_filler: int = 3,
|
||
) -> tuple[float, int, int]:
|
||
"""
|
||
Calculate how many words from phrase match the transcription at start_idx.
|
||
|
||
Words are matched sequentially. Two separate filler tolerances:
|
||
- pre_filler: max words before the FIRST phrase word (absorbs ad-libs)
|
||
- inter_filler: max words between consecutive phrase words (keeps the
|
||
match tight so common words don't stretch the window far
|
||
into later text, which would push last_idx past subsequent
|
||
markers' positions)
|
||
|
||
Returns (ratio, first_match_offset, last_match_end_offset) where offsets
|
||
are relative to start_idx. last_match_end_offset points past the last
|
||
matched word.
|
||
"""
|
||
if not phrase_words:
|
||
return 0.0, 0, 0
|
||
|
||
if start_idx >= len(transcription):
|
||
return 0.0, 0, 0
|
||
|
||
words_to_check = min(len(phrase_words), window_size)
|
||
# Window only needs to cover pre_filler + phrase words + inter_filler slack
|
||
transcript_end = min(
|
||
start_idx + pre_filler + words_to_check + inter_filler, len(transcription)
|
||
)
|
||
|
||
transcript_words = [
|
||
_normalize_token(transcription[j].word)
|
||
for j in range(start_idx, transcript_end)
|
||
]
|
||
|
||
matches = 0
|
||
words_checked = 0
|
||
t_pos = 0
|
||
first_match_offset = 0
|
||
last_match_end_offset = 0
|
||
|
||
for phrase_word in phrase_words[:words_to_check]:
|
||
normalized = _normalize_token(phrase_word)
|
||
if len(normalized) < 2:
|
||
continue
|
||
words_checked += 1
|
||
|
||
# First phrase word may be preceded by a long ad-lib; subsequent words
|
||
# should appear within a few positions of each other.
|
||
if matches == 0:
|
||
search_end = min(t_pos + pre_filler + 1, len(transcript_words))
|
||
else:
|
||
search_end = min(t_pos + inter_filler + 1, len(transcript_words))
|
||
|
||
for j in range(t_pos, search_end):
|
||
t_word = transcript_words[j]
|
||
matched = False
|
||
if normalized == t_word:
|
||
matched = True
|
||
elif len(normalized) >= 4 and len(t_word) >= 4:
|
||
if normalized in t_word or t_word in normalized:
|
||
matched = True
|
||
|
||
if matched:
|
||
if matches == 0:
|
||
first_match_offset = j
|
||
matches += 1
|
||
last_match_end_offset = j + 1
|
||
t_pos = j + 1
|
||
break
|
||
|
||
ratio = matches / words_checked if words_checked > 0 else 0.0
|
||
return ratio, first_match_offset, last_match_end_offset
|
||
|
||
|
||
def _find_phrase_timestamp(
|
||
phrase: str,
|
||
transcription: list[TranscribedWord],
|
||
start_from: int = 0,
|
||
fuzzy_threshold: float = 0.5,
|
||
) -> tuple[int, float, float, int]:
|
||
"""
|
||
Find a phrase in the transcription using fuzzy matching.
|
||
|
||
Returns (word_index, timestamp, confidence, match_end_idx) or
|
||
(-1, -1.0, 0.0, -1) if not found. word_index points to the first
|
||
matched word. match_end_idx points past the last matched word.
|
||
"""
|
||
phrase_words = [tok for tok in (_normalize_token(w) for w in phrase.split()) if tok]
|
||
|
||
if not phrase_words:
|
||
return -1, -1.0, 0.0, -1
|
||
|
||
best_idx = -1
|
||
best_ratio = 0.0
|
||
best_first_offset = 0
|
||
best_end_offset = 0
|
||
|
||
for i in range(start_from, len(transcription)):
|
||
ratio, first_offset, end_offset = _fuzzy_match_ratio(
|
||
phrase_words, transcription, i
|
||
)
|
||
if ratio > best_ratio:
|
||
best_ratio = ratio
|
||
best_idx = i
|
||
best_first_offset = first_offset
|
||
best_end_offset = end_offset
|
||
|
||
# Sequential alignment: stop at the first position that clears the
|
||
# threshold. Continuing to scan the full transcript risks jumping
|
||
# to a higher-ratio match much later and skipping over subsequent
|
||
# markers' positions entirely.
|
||
if best_ratio >= fuzzy_threshold:
|
||
break
|
||
|
||
if best_ratio >= fuzzy_threshold and best_idx >= 0:
|
||
actual_idx = best_idx + best_first_offset
|
||
match_end_idx = best_idx + best_end_offset
|
||
return actual_idx, transcription[actual_idx].start, best_ratio, match_end_idx
|
||
|
||
return -1, -1.0, 0.0, -1
|
||
|
||
|
||
def align_markers_to_transcription(
|
||
manuscript_text: str,
|
||
transcription: list[TranscribedWord],
|
||
slides: dict = None,
|
||
videos: dict = None,
|
||
audio: dict = None,
|
||
fuzzy_threshold: float = 0.6,
|
||
) -> list[MarkerTiming]:
|
||
"""
|
||
Align manuscript markers to transcription timestamps using fuzzy phrase matching.
|
||
|
||
For each known marker, extracts the text immediately following it in the
|
||
manuscript and searches for that phrase in the Whisper transcript. Markers are
|
||
matched in manuscript order, each starting its search after the previous match.
|
||
|
||
The filler-word window is intentionally large (+30 words) so that ad-libbed
|
||
words spoken before or between the manuscript cue words do not prevent a match.
|
||
|
||
Unknown markers are filtered out — they aren't pronounced and shouldn't be in
|
||
the render plan. Note: [cite:...] markers are stripped at parse time.
|
||
|
||
Args:
|
||
manuscript_text: Full manuscript with [S1], [video:xxx], etc.
|
||
transcription: Word-level timestamps from Whisper
|
||
slides: Slide definitions (to identify valid slide markers)
|
||
videos: Video definitions (to identify valid video markers)
|
||
audio: Audio definitions (to identify valid audio markers)
|
||
fuzzy_threshold: Minimum match ratio (default 0.6 = 60% of words must match)
|
||
|
||
Returns:
|
||
List of MarkerTiming with timestamps and confidence (known markers only)
|
||
"""
|
||
contexts = _extract_marker_contexts(manuscript_text, slides, videos, audio)
|
||
timings: list[MarkerTiming] = []
|
||
|
||
last_idx = 0
|
||
last_end_time = 0.0
|
||
|
||
for marker_id, anchor_text, is_borrowed, anchor_type in contexts:
|
||
if not anchor_text.strip():
|
||
marker_time = last_end_time + 1.0
|
||
timings.append(
|
||
MarkerTiming(
|
||
marker_id=marker_id,
|
||
timestamp=marker_time,
|
||
context="(after previous)",
|
||
confidence=1.0,
|
||
)
|
||
)
|
||
last_end_time = marker_time
|
||
continue
|
||
|
||
idx, timestamp, confidence, match_end_idx = _find_phrase_timestamp(
|
||
anchor_text,
|
||
transcription,
|
||
start_from=last_idx,
|
||
fuzzy_threshold=fuzzy_threshold,
|
||
)
|
||
|
||
if idx >= 0:
|
||
if anchor_type == "after":
|
||
# Marker trails a narration block — place it at the END of the
|
||
# matched phrase (when those words finish being spoken).
|
||
end_idx = min(match_end_idx - 1, len(transcription) - 1)
|
||
marker_time = transcription[end_idx].end if transcription else 0.0
|
||
timings.append(
|
||
MarkerTiming(
|
||
marker_id=marker_id,
|
||
timestamp=marker_time,
|
||
context=f"(end of: {anchor_text[:40]})",
|
||
confidence=confidence,
|
||
)
|
||
)
|
||
last_idx = match_end_idx
|
||
last_end_time = marker_time
|
||
else:
|
||
adjusted_time = max(0.0, timestamp - 0.5)
|
||
timings.append(
|
||
MarkerTiming(
|
||
marker_id=marker_id,
|
||
timestamp=adjusted_time,
|
||
context=anchor_text[:50],
|
||
confidence=confidence,
|
||
)
|
||
)
|
||
if not is_borrowed:
|
||
last_idx = match_end_idx
|
||
if last_idx > 0 and last_idx <= len(transcription):
|
||
last_end_time = transcription[last_idx - 1].end
|
||
else:
|
||
last_end_time = transcription[-1].end if transcription else 0.0
|
||
else:
|
||
timings.append(
|
||
MarkerTiming(
|
||
marker_id=marker_id,
|
||
timestamp=-1.0,
|
||
context=anchor_text[:50],
|
||
confidence=0.0,
|
||
)
|
||
)
|
||
|
||
# Repair pass: retry INTERPOLATED markers that the forward scan missed.
|
||
# Root cause of cascade failures: one bad match advances last_idx past
|
||
# the true positions of several subsequent markers. Fix: search in a
|
||
# bounded window [prev_marker_time - 1s, next_marker_time + 2s] so we
|
||
# avoid false early matches while still recovering from cascade failures.
|
||
if any(t.timestamp < 0 for t in timings):
|
||
for i, timing in enumerate(timings):
|
||
if timing.timestamp >= 0:
|
||
continue
|
||
|
||
marker_id, anchor_text, is_borrowed, anchor_type = contexts[i]
|
||
if not anchor_text.strip():
|
||
continue
|
||
|
||
# Lower bound: previous matched marker's timestamp → word index.
|
||
# Repairs processed in order, so already-repaired markers count too.
|
||
prev_time = 0.0
|
||
for j in range(i - 1, -1, -1):
|
||
if timings[j].timestamp >= 0:
|
||
prev_time = max(0.0, timings[j].timestamp - 1.0)
|
||
break
|
||
win_start = next(
|
||
(j for j, w in enumerate(transcription) if w.start >= prev_time),
|
||
0,
|
||
)
|
||
|
||
# Upper bound: next matched marker in the timings list (+2s padding)
|
||
next_time = float("inf")
|
||
for j in range(i + 1, len(timings)):
|
||
if timings[j].timestamp >= 0:
|
||
next_time = timings[j].timestamp + 2.0
|
||
break
|
||
|
||
win_end = (
|
||
next(
|
||
(j for j, w in enumerate(transcription) if w.start > next_time),
|
||
len(transcription),
|
||
)
|
||
if next_time < float("inf")
|
||
else len(transcription)
|
||
)
|
||
|
||
if win_end <= win_start:
|
||
continue
|
||
|
||
# Search in the bounded window with a relaxed threshold
|
||
sub = transcription[win_start:win_end]
|
||
idx, timestamp, confidence, match_end_idx = _find_phrase_timestamp(
|
||
anchor_text,
|
||
sub,
|
||
start_from=0,
|
||
fuzzy_threshold=max(0.4, fuzzy_threshold - 0.1),
|
||
)
|
||
|
||
if idx >= 0:
|
||
if anchor_type == "after" and match_end_idx > 0:
|
||
end_word = sub[min(match_end_idx - 1, len(sub) - 1)]
|
||
marker_time = end_word.end
|
||
else:
|
||
marker_time = max(0.0, timestamp - 0.5)
|
||
timings[i] = MarkerTiming(
|
||
marker_id=marker_id,
|
||
timestamp=marker_time,
|
||
context=f"(repaired: {anchor_text[:40]})",
|
||
confidence=confidence,
|
||
)
|
||
|
||
# Deduplicate slide markers. The manuscript pattern [SN]\n\n[SN] text... is
|
||
# common: the first blank occurrence is a visual-transition cue and the second
|
||
# carries the narration text used for alignment. We keep the first entry in
|
||
# order (preserving manuscript position) but upgrade its timestamp to the
|
||
# best-matched value found for that ID, then drop subsequent duplicates.
|
||
slides_set = set(slides or {})
|
||
seen: dict[str, int] = {} # marker_id → index in deduped list
|
||
deduped: list[MarkerTiming] = []
|
||
for timing in timings:
|
||
if timing.marker_id not in slides_set:
|
||
deduped.append(timing)
|
||
continue
|
||
if timing.marker_id not in seen:
|
||
seen[timing.marker_id] = len(deduped)
|
||
deduped.append(timing)
|
||
else:
|
||
prev_idx = seen[timing.marker_id]
|
||
prev = deduped[prev_idx]
|
||
# Upgrade if: previous was a placeholder/interpolated and the new one is better.
|
||
# Also upgrade if previous used the backward-looking "after" anchor —
|
||
# that heuristic gives end-of-preceding-section timing, but a direct
|
||
# "before" match on the second occurrence (start-of-new-section − 0.5s)
|
||
# is more accurate for when the slide should appear.
|
||
should_upgrade = (
|
||
prev.context == "(after previous)"
|
||
and timing.context != "(after previous)"
|
||
) or (
|
||
prev.timestamp < 0
|
||
and timing.timestamp >= 0
|
||
) or (
|
||
prev.context.startswith("(end of:")
|
||
and timing.timestamp >= 0
|
||
and timing.context != "(after previous)"
|
||
and not timing.context.startswith("(end of:")
|
||
)
|
||
if should_upgrade:
|
||
deduped[prev_idx] = MarkerTiming(
|
||
marker_id=prev.marker_id,
|
||
timestamp=timing.timestamp,
|
||
context=timing.context,
|
||
confidence=timing.confidence,
|
||
)
|
||
|
||
return deduped
|
||
|
||
|
||
def build_render_plan(
|
||
project_path: Path,
|
||
config: ProjectConfig,
|
||
slides: dict[str, SlideDefinition],
|
||
videos: dict[str, VideoSource],
|
||
videos_dir: Path,
|
||
manuscript_text: str,
|
||
transcription: list[TranscribedWord],
|
||
audio: Optional[dict[str, AudioDefinition]] = None,
|
||
audio_dir: Optional[Path] = None,
|
||
slide_range: Optional[tuple[str, Optional[str]]] = None,
|
||
narration_schedule: Optional[list] = None,
|
||
narration_source: Optional[VideoSource] = None,
|
||
marker_timings_override: Optional[list["MarkerTiming"]] = None,
|
||
) -> tuple[RenderPlan, list[MarkerTiming]]:
|
||
"""
|
||
Build a complete render plan from manuscript and transcription.
|
||
|
||
This performs on-the-fly alignment of manuscript markers to transcription
|
||
timestamps, then builds the render plan.
|
||
|
||
Args:
|
||
manuscript_text: The manuscript.txt content (source of truth for markers)
|
||
transcription: Word-level timestamps from whisper transcription
|
||
slide_range: Optional tuple of (start_slide, end_slide) for partial rendering.
|
||
marker_timings_override: When provided (e.g. loaded from events.json /
|
||
scaffold.json), these timings are used verbatim instead of aligning
|
||
against the transcript. Their timestamps are already final-timeline
|
||
values, so the narration-skip adjustment below is skipped for them.
|
||
This is the seam that lets `render` consume a hand-edited scaffold
|
||
without re-running (and re-breaking on) fuzzy alignment.
|
||
|
||
Returns:
|
||
Tuple of (RenderPlan, list of MarkerTiming for display)
|
||
"""
|
||
audio = audio or {}
|
||
audio_dir = audio_dir or project_path
|
||
|
||
# Align markers to transcription timestamps — unless caller supplied timings
|
||
# (from the scaffold/events layer), in which case those win verbatim.
|
||
if marker_timings_override is not None:
|
||
marker_timings = marker_timings_override
|
||
else:
|
||
marker_timings = align_markers_to_transcription(
|
||
manuscript_text, transcription, slides=slides, videos=videos, audio=audio
|
||
)
|
||
|
||
# Find shared_assets directory
|
||
shared_assets_dir = None
|
||
if (project_path / "shared_assets").exists():
|
||
shared_assets_dir = project_path / "shared_assets"
|
||
elif (project_path.parent / "shared_assets").exists():
|
||
shared_assets_dir = project_path.parent / "shared_assets"
|
||
|
||
# Track which files are loaded from external cache
|
||
cached_files: set[str] = set()
|
||
|
||
# --- Narration source ---
|
||
# Render-time concat: narration is the concatenation of the scheduled
|
||
# segments, so there is no single file to probe — the total duration is the
|
||
# sum of the segment durations and skip is already baked into each segment.
|
||
if narration_schedule:
|
||
narration_video_id = "narration"
|
||
narration_video = narration_source or VideoSource(
|
||
source_file="", cutout=config.default_slide_type, always_visible=True
|
||
)
|
||
narration_skip = 0.0
|
||
full_duration = sum(seg.duration for seg in narration_schedule)
|
||
else:
|
||
narration_video_id = config.main_video
|
||
if isinstance(narration_video_id, list):
|
||
narration_video_id = narration_video_id[0] if narration_video_id else None
|
||
if not (narration_video_id and narration_video_id in videos):
|
||
raise ValueError(
|
||
f"Main video '{narration_video_id}' not specified or not found in videos. "
|
||
f"Available: {list(videos.keys())}"
|
||
)
|
||
narration_video = videos[narration_video_id]
|
||
narration_skip = narration_video.skip
|
||
video_path, is_cached = _resolve_video_path(
|
||
videos_dir, narration_video, shared_assets_dir, project_path
|
||
)
|
||
if is_cached:
|
||
cached_files.add(narration_video_id)
|
||
full_duration = get_video_duration(video_path)
|
||
|
||
# Apply skip offset: if narration starts at `skip` seconds, subtract it from
|
||
# all marker timestamps so they line up with the trimmed timeline. Skipped for
|
||
# override timings, which are already expressed in the final timeline.
|
||
if narration_skip > 0 and marker_timings_override is None:
|
||
for timing in marker_timings:
|
||
if timing.timestamp >= 0:
|
||
timing.timestamp = max(0.0, timing.timestamp - narration_skip)
|
||
|
||
# Build marker -> timestamp lookup
|
||
marker_times: dict[str, float] = {}
|
||
for timing in marker_timings:
|
||
if timing.timestamp >= 0:
|
||
marker_times[timing.marker_id] = timing.timestamp
|
||
|
||
cutout = config.cutouts[narration_video.cutout]
|
||
# Adjust duration for skip (content starts at skip, so effective duration is less)
|
||
effective_duration = full_duration - narration_skip
|
||
narration_videos: list[tuple[str, VideoSource, CutoutDefinition]] = [
|
||
(narration_video_id, narration_video, cutout)
|
||
]
|
||
# Resolve slide range to time range
|
||
time_offset = 0.0
|
||
render_end_time = effective_duration
|
||
if slide_range:
|
||
start_slide, end_slide = slide_range
|
||
if start_slide not in marker_times:
|
||
raise ValueError(
|
||
f"Start slide '{start_slide}' not found in aligned markers"
|
||
)
|
||
time_offset = marker_times[start_slide]
|
||
if end_slide:
|
||
if end_slide not in marker_times:
|
||
raise ValueError(
|
||
f"End slide '{end_slide}' not found in aligned markers"
|
||
)
|
||
render_end_time = marker_times[end_slide]
|
||
|
||
# Build events from aligned markers
|
||
slide_events = _extract_slide_events(
|
||
marker_timings,
|
||
slides,
|
||
effective_duration,
|
||
time_range=(time_offset, render_end_time) if slide_range else None,
|
||
)
|
||
|
||
# Before extracting video events, resolve any referenced videos that are missing
|
||
# from the project's videos.json by looking them up in shared_assets/videos.json.
|
||
_VIDEO_MARKER_PREFIXES = (
|
||
"video:",
|
||
"narration:",
|
||
"vft:", "vfb:", "vfm:",
|
||
"vf2t:", "vf2b:", "vf2m:",
|
||
"vst:", "vsb:", "vsm:",
|
||
"vftp:", "vfbp:", "vfmp:",
|
||
"vf2tp:", "vf2bp:", "vf2mp:",
|
||
"vstp:", "vsbp:", "vsmp:",
|
||
)
|
||
missing_video_ids = [
|
||
timing.marker_id[len(prefix) :]
|
||
for timing in marker_timings
|
||
if timing.timestamp >= 0
|
||
for prefix in _VIDEO_MARKER_PREFIXES
|
||
if timing.marker_id.startswith(prefix)
|
||
and timing.marker_id[len(prefix) :] not in videos
|
||
]
|
||
if missing_video_ids:
|
||
found = resolve_missing_videos(missing_video_ids, project_path, config)
|
||
videos.update(found)
|
||
|
||
video_events, video_warnings = _extract_video_events(
|
||
marker_timings,
|
||
videos,
|
||
config.cutouts,
|
||
slides,
|
||
effective_duration,
|
||
time_range=(time_offset, render_end_time) if slide_range else None,
|
||
)
|
||
if video_warnings:
|
||
import sys
|
||
|
||
print("\nWarnings:", file=sys.stderr)
|
||
for w in video_warnings:
|
||
print(f" ⚠ {w}", file=sys.stderr)
|
||
print("", file=sys.stderr)
|
||
|
||
# Track cached files for triggered videos
|
||
for event in video_events:
|
||
_, is_cached = _resolve_video_path(
|
||
videos_dir, event.video_source, shared_assets_dir, project_path
|
||
)
|
||
if is_cached:
|
||
cached_files.add(event.video_id)
|
||
|
||
audio_events = _extract_audio_events(
|
||
marker_timings,
|
||
audio,
|
||
time_range=(time_offset, render_end_time) if slide_range else None,
|
||
)
|
||
|
||
camera_events, initial_camera_state = _extract_camera_events(
|
||
marker_timings,
|
||
time_range=(time_offset, render_end_time) if slide_range else None,
|
||
)
|
||
|
||
# Apply time offset to all events (for partial rendering)
|
||
if time_offset > 0:
|
||
for event in slide_events:
|
||
event.start_time -= time_offset
|
||
event.end_time -= time_offset
|
||
for event in video_events:
|
||
event.start_time -= time_offset
|
||
event.end_time -= time_offset
|
||
for event in audio_events:
|
||
event.start_time = max(0, event.start_time - time_offset)
|
||
for event in camera_events:
|
||
event.time -= time_offset
|
||
|
||
total_duration = render_end_time - time_offset
|
||
|
||
# Handle narration pauses (videos that pause the narration track)
|
||
narration_pauses: list[NarrationPause] = []
|
||
pause_video_events = [e for e in video_events if e.video_source.pause_narration]
|
||
|
||
if pause_video_events:
|
||
# Sort pause events by their narration time
|
||
pause_video_events.sort(key=lambda e: e.start_time)
|
||
|
||
cumulative_offset = 0.0
|
||
for event in pause_video_events:
|
||
pause_duration = event.video_source.pause_narration
|
||
narration_time = event.start_time # Time in narration source
|
||
|
||
# Create pause record (before applying offset to this event)
|
||
narration_pauses.append(
|
||
NarrationPause(
|
||
output_time=narration_time + cumulative_offset,
|
||
narration_time=narration_time,
|
||
duration=pause_duration,
|
||
video_id=event.video_id,
|
||
)
|
||
)
|
||
|
||
# Offset all events that come AFTER this pause.
|
||
# Use >= so a slide that transitions at exactly narration_time is
|
||
# pushed past the pause (matching the >= already used for video events).
|
||
# Also extend the end_time of the current slide so it stays visible
|
||
# as the background behind the pause-video overlay, avoiding a gap.
|
||
for slide_event in slide_events:
|
||
if slide_event.start_time >= narration_time:
|
||
slide_event.start_time += pause_duration
|
||
if slide_event.end_time >= narration_time:
|
||
slide_event.end_time += pause_duration
|
||
|
||
for vid_event in video_events:
|
||
if vid_event is event:
|
||
# Don't shift the pause event by its own pause
|
||
continue
|
||
if vid_event.start_time >= narration_time:
|
||
vid_event.start_time += pause_duration
|
||
if vid_event.end_time > narration_time:
|
||
vid_event.end_time += pause_duration
|
||
|
||
for aud_event in audio_events:
|
||
if aud_event.start_time > narration_time:
|
||
aud_event.start_time += pause_duration
|
||
|
||
for cam_event in camera_events:
|
||
if cam_event.time > narration_time:
|
||
cam_event.time += pause_duration
|
||
|
||
cumulative_offset += pause_duration
|
||
|
||
# Update total duration
|
||
total_duration += cumulative_offset
|
||
|
||
# Save narration end time (before outro)
|
||
narration_end_time = total_duration
|
||
|
||
# Include outro only when rendering to the end of the video.
|
||
# A slide_range with an explicit end slide (e.g. S1:S10) is a middle chunk —
|
||
# skip the outro so it doesn't appear on every chunk, only the last one.
|
||
is_last_chunk = not slide_range or slide_range[1] is None
|
||
|
||
# Resolve any outro videos missing from videos.json via shared_assets.
|
||
if config.outro and is_last_chunk:
|
||
missing_outro_ids = [vid_id for vid_id in config.outro if vid_id not in videos]
|
||
if missing_outro_ids:
|
||
found = resolve_missing_videos(missing_outro_ids, project_path, config)
|
||
videos.update(found)
|
||
still_missing = [vid_id for vid_id in config.outro if vid_id not in videos]
|
||
for vid_id in still_missing:
|
||
print(
|
||
f" WARNING: outro video '{vid_id}' not found in videos.json or shared_assets — skipped",
|
||
flush=True,
|
||
)
|
||
|
||
# Build outro events (plays after narration ends)
|
||
outro_events = _extract_outro_events(
|
||
config.outro if is_last_chunk else [],
|
||
videos,
|
||
config.cutouts,
|
||
total_duration,
|
||
videos_dir,
|
||
shared_assets_dir,
|
||
project_path,
|
||
cached_files,
|
||
)
|
||
|
||
# Update total duration to include outro
|
||
if outro_events:
|
||
total_duration = outro_events[-1].end_time
|
||
|
||
# Derive slides directory — lowercase path for case-sensitive filesystems (WSL/Linux).
|
||
slides_json_path = project_path / config.slides_path.lower()
|
||
slides_dir = slides_json_path.parent
|
||
|
||
plan = RenderPlan(
|
||
project_path=project_path,
|
||
config=config,
|
||
slide_events=slide_events,
|
||
total_duration=total_duration,
|
||
slides=slides,
|
||
videos=videos,
|
||
video_events=video_events,
|
||
narration_videos=narration_videos,
|
||
slides_dir=slides_dir,
|
||
videos_dir=videos_dir,
|
||
audio_events=audio_events,
|
||
audio=audio,
|
||
audio_dir=audio_dir,
|
||
camera_events=camera_events,
|
||
time_offset=time_offset,
|
||
initial_camera_state=initial_camera_state,
|
||
input_seek_time=time_offset,
|
||
shared_assets_dir=shared_assets_dir,
|
||
narration_pauses=narration_pauses,
|
||
narration_segments=narration_schedule or [],
|
||
outro_events=outro_events,
|
||
narration_end_time=narration_end_time,
|
||
cached_files=cached_files,
|
||
)
|
||
|
||
return plan, marker_timings
|
||
|
||
|
||
def _resolve_video_path(
|
||
videos_dir: Path,
|
||
video_source: VideoSource,
|
||
shared_assets_dir: Path = None,
|
||
project_path: Path = None,
|
||
) -> tuple[Path, bool]:
|
||
"""Resolve the actual video file path with cache fallback.
|
||
|
||
Returns:
|
||
Tuple of (resolved_path, is_cached) where is_cached=True if
|
||
the file was found in the external cache.
|
||
"""
|
||
from .cache import resolve_with_cache
|
||
|
||
if video_source.is_shared and shared_assets_dir:
|
||
base_dir = shared_assets_dir
|
||
else:
|
||
base_dir = videos_dir
|
||
|
||
if video_source.output_file:
|
||
video_path = base_dir / video_source.output_file
|
||
if project_path:
|
||
resolved, is_cached = resolve_with_cache(video_path, project_path)
|
||
if resolved.exists():
|
||
return resolved, is_cached
|
||
elif video_path.exists():
|
||
return video_path, False
|
||
webm_path = video_path.with_suffix(".mov")
|
||
if project_path:
|
||
resolved, is_cached = resolve_with_cache(webm_path, project_path)
|
||
if resolved.exists():
|
||
return resolved, is_cached
|
||
elif webm_path.exists():
|
||
return webm_path, False
|
||
|
||
source_path = base_dir / video_source.source_file
|
||
if project_path:
|
||
return resolve_with_cache(source_path, project_path)
|
||
return source_path, False
|
||
|
||
|
||
def _interpolate_slide_times(
|
||
marker_timings: list[MarkerTiming],
|
||
slides: dict,
|
||
total_duration: float,
|
||
) -> list[float]:
|
||
"""
|
||
Return sorted slide timestamps with unaligned slides (timestamp < 0)
|
||
interpolated evenly between their aligned neighbours. Used by both
|
||
_extract_slide_events and _extract_video_events so video end-times
|
||
never skip over a slide that Whisper failed to align.
|
||
"""
|
||
all_markers = [
|
||
(t.timestamp, t.marker_id)
|
||
for t in marker_timings
|
||
if t.marker_id in slides
|
||
]
|
||
if not all_markers:
|
||
return []
|
||
|
||
n = len(all_markers)
|
||
resolved = list(all_markers)
|
||
i = 0
|
||
while i < n:
|
||
if resolved[i][0] < 0:
|
||
run_start = i
|
||
while i < n and resolved[i][0] < 0:
|
||
i += 1
|
||
run_end = i
|
||
prev_time = resolved[run_start - 1][0] if run_start > 0 else 0.0
|
||
next_time = resolved[run_end][0] if run_end < n else total_duration
|
||
count = run_end - run_start
|
||
for j, idx in enumerate(range(run_start, run_end)):
|
||
frac = (j + 1) / (count + 1)
|
||
resolved[idx] = (
|
||
prev_time + (next_time - prev_time) * frac,
|
||
resolved[idx][1],
|
||
)
|
||
else:
|
||
i += 1
|
||
|
||
return sorted(t for t, _ in resolved)
|
||
|
||
|
||
def _extract_slide_events(
|
||
marker_timings: list[MarkerTiming],
|
||
slides: dict[str, SlideDefinition],
|
||
total_duration: float,
|
||
time_range: Optional[tuple[float, float]] = None,
|
||
) -> list[SlideEvent]:
|
||
"""Extract slide events from aligned marker timings.
|
||
|
||
Each slide starts at its own marker timestamp and ends when the next
|
||
slide's marker appears. Before the first slide, no slide is shown.
|
||
|
||
Slides that could not be aligned (timestamp < 0) have their position
|
||
interpolated evenly between the surrounding aligned slides rather than
|
||
being excluded.
|
||
"""
|
||
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
||
|
||
# Get ALL slide markers in manuscript order (aligned and unaligned),
|
||
# with unaligned ones interpolated via the shared helper.
|
||
all_slide_markers: list[tuple[float, str]] = []
|
||
for timing in marker_timings:
|
||
if timing.marker_id in slides:
|
||
all_slide_markers.append((timing.timestamp, timing.marker_id))
|
||
|
||
if not all_slide_markers:
|
||
return []
|
||
|
||
# Re-derive interpolated times (same logic as _interpolate_slide_times but
|
||
# we need the (time, id) pairs here for event building).
|
||
n = len(all_slide_markers)
|
||
resolved: list[tuple[float, str]] = list(all_slide_markers)
|
||
|
||
i = 0
|
||
while i < n:
|
||
if resolved[i][0] < 0:
|
||
run_start = i
|
||
while i < n and resolved[i][0] < 0:
|
||
i += 1
|
||
run_end = i # exclusive
|
||
|
||
prev_time = resolved[run_start - 1][0] if run_start > 0 else 0.0
|
||
next_time = resolved[run_end][0] if run_end < n else total_duration
|
||
|
||
count = run_end - run_start
|
||
for j, idx in enumerate(range(run_start, run_end)):
|
||
frac = (j + 1) / (count + 1)
|
||
resolved[idx] = (
|
||
prev_time + (next_time - prev_time) * frac,
|
||
resolved[idx][1],
|
||
)
|
||
else:
|
||
i += 1
|
||
|
||
events: list[SlideEvent] = []
|
||
for i, (marker_time, marker_id) in enumerate(resolved):
|
||
# First slide always starts at 0 — it's the opening state of the presentation.
|
||
start_time = 0.0 if i == 0 else marker_time
|
||
|
||
# End time is when the NEXT slide's marker appears, or end of video
|
||
if i + 1 < len(resolved):
|
||
end_time = resolved[i + 1][0]
|
||
else:
|
||
end_time = total_duration
|
||
|
||
# Filter by time range
|
||
if end_time <= range_start or start_time >= range_end:
|
||
continue
|
||
start_time = max(start_time, range_start)
|
||
end_time = min(end_time, range_end)
|
||
|
||
events.append(
|
||
SlideEvent(
|
||
slide_id=marker_id,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
slide_def=slides[marker_id],
|
||
)
|
||
)
|
||
|
||
return events
|
||
|
||
|
||
def _extract_video_events(
|
||
marker_timings: list[MarkerTiming],
|
||
videos: dict[str, VideoSource],
|
||
cutouts: dict[str, CutoutDefinition],
|
||
slides: dict[str, SlideDefinition],
|
||
total_duration: float,
|
||
time_range: Optional[tuple[float, float]] = None,
|
||
) -> tuple[list[VideoEvent], list[str]]:
|
||
"""
|
||
Extract video events from aligned marker timings.
|
||
|
||
- [video:xxx] events end at the next SLIDE marker
|
||
- [narration:xxx] events run until end
|
||
|
||
Returns (events, warnings). Invalid markers are skipped and reported in warnings.
|
||
"""
|
||
warnings: list[str] = []
|
||
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
||
|
||
# Collect slide times for video end-time calculation.
|
||
# Use the interpolated times (same as _extract_slide_events) so that a slide
|
||
# Whisper failed to align doesn't get skipped, causing the preceding video to
|
||
# bleed through into the following slide.
|
||
slide_times: list[float] = _interpolate_slide_times(
|
||
marker_timings, slides, total_duration
|
||
)
|
||
|
||
# Pause-variant prefixes — the only thing the render pass still needs from
|
||
# shorthand markers at event-build time (pause_narration is per-event, not stored in videos.json).
|
||
_PAUSE_PREFIXES = {"vftp:", "vfbp:", "vfmp:", "vf2tp:", "vf2bp:", "vf2mp:", "vstp:", "vsbp:", "vsmp:"}
|
||
|
||
# Collect video markers: (time, video_id, event_type, pause_narration)
|
||
# video_markers: (timestamp, video_id, marker_type, pause_narration)
|
||
# cutout and layer are read from videos.json (projected there by _project_markers_to_videos)
|
||
video_markers: list[tuple[float, str, str, bool]] = []
|
||
|
||
for timing in marker_timings:
|
||
if timing.timestamp < 0:
|
||
continue
|
||
|
||
mid = timing.marker_id
|
||
|
||
# --- shorthand markers (vft:/vfb:/vst:/vsb: and pause variants) ---
|
||
shorthand_match = next(
|
||
(p for p in _SHORTHAND_PREFIXES if mid.startswith(p)), None
|
||
)
|
||
if shorthand_match:
|
||
video_id = mid[len(shorthand_match) :].lower()
|
||
if video_id not in videos:
|
||
warnings.append(
|
||
f"[{mid}] references unknown video '{video_id}' — skipped. "
|
||
f"Add it to videos.json or remove the marker."
|
||
)
|
||
continue
|
||
# Validate that videos.json has the correct cutout (written by ETL)
|
||
video_source = videos[video_id]
|
||
if not video_source.cutout or video_source.cutout not in cutouts:
|
||
warnings.append(
|
||
f"[{mid}] video '{video_id}' has no valid cutout in videos.json — "
|
||
f"run render once to project values, or set cutout manually."
|
||
)
|
||
continue
|
||
pause_narration = shorthand_match in _PAUSE_PREFIXES
|
||
video_markers.append((timing.timestamp, video_id, "video", pause_narration))
|
||
continue
|
||
|
||
# --- legacy [video:xxx] ---
|
||
if mid.startswith("video:"):
|
||
video_id = mid[6:].lower()
|
||
if video_id not in videos:
|
||
warnings.append(
|
||
f"[video:{video_id}] references unknown video '{video_id}' — skipped."
|
||
)
|
||
continue
|
||
video_source = videos[video_id]
|
||
if not video_source.cutout or video_source.cutout not in cutouts:
|
||
warnings.append(
|
||
f"[video:{video_id}] has no valid cutout in videos.json — skipped."
|
||
)
|
||
continue
|
||
video_markers.append((timing.timestamp, video_id, "video", False))
|
||
continue
|
||
|
||
# --- [narration:xxx] ---
|
||
if mid.startswith("narration:"):
|
||
video_id = mid[10:].lower()
|
||
if video_id not in videos:
|
||
warnings.append(
|
||
f"[narration:{video_id}] references unknown video '{video_id}' — skipped."
|
||
)
|
||
continue
|
||
video_source = videos[video_id]
|
||
if not video_source.cutout or video_source.cutout not in cutouts:
|
||
warnings.append(
|
||
f"[narration:{video_id}] has no valid cutout in videos.json — skipped."
|
||
)
|
||
continue
|
||
video_markers.append((timing.timestamp, video_id, "narration", False))
|
||
|
||
events: list[VideoEvent] = []
|
||
for start_time, video_id, marker_type, pause_narration in video_markers:
|
||
video_source = videos[video_id]
|
||
|
||
# Read cutout and layer directly from videos.json (projected by ETL)
|
||
cutout_name = video_source.cutout
|
||
cutout = cutouts[cutout_name]
|
||
layer = video_source.layer
|
||
|
||
end_on = video_source.end_on
|
||
if end_on == "take" and video_source.take is not None:
|
||
end_time = start_time + video_source.take
|
||
elif end_on == "end":
|
||
end_time = total_duration
|
||
elif end_on in ("next_slide", "slide") or (end_on is None and marker_type == "video"):
|
||
# End at next slide marker ("slide" is a recognised alias for "next_slide")
|
||
end_time = total_duration
|
||
for slide_time in slide_times:
|
||
if slide_time > start_time:
|
||
end_time = slide_time
|
||
break
|
||
# pause_narration videos must stay visible for the full pause duration —
|
||
# the narration is held for that long, so the overlay should match.
|
||
if video_source.pause_narration:
|
||
end_time = max(end_time, start_time + video_source.pause_narration)
|
||
else:
|
||
# end_on is None and marker_type == "narration": runs to end
|
||
end_time = total_duration
|
||
|
||
# Filter by time range
|
||
if start_time < range_start or start_time >= range_end:
|
||
continue
|
||
end_time = min(end_time, range_end)
|
||
|
||
events.append(
|
||
VideoEvent(
|
||
video_id=video_id,
|
||
start_time=start_time,
|
||
end_time=end_time,
|
||
video_source=video_source,
|
||
cutout=cutout,
|
||
cutout_name=cutout_name,
|
||
layer=layer,
|
||
)
|
||
)
|
||
|
||
return events, warnings
|
||
|
||
|
||
def _extract_audio_events(
|
||
marker_timings: list[MarkerTiming],
|
||
audio: dict[str, AudioDefinition],
|
||
time_range: Optional[tuple[float, float]] = None,
|
||
) -> list[AudioEvent]:
|
||
"""Extract audio events from aligned marker timings."""
|
||
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
||
events: list[AudioEvent] = []
|
||
|
||
for timing in marker_timings:
|
||
if timing.timestamp < 0:
|
||
continue
|
||
|
||
marker_id = timing.marker_id
|
||
audio_id = None
|
||
if marker_id.startswith("A") and len(marker_id) > 1:
|
||
audio_id = marker_id[1:]
|
||
elif marker_id.startswith("audio:"):
|
||
audio_id = marker_id[6:]
|
||
if audio_id is not None and audio_id in audio:
|
||
if timing.timestamp < range_start or timing.timestamp >= range_end:
|
||
continue
|
||
start_time = max(0, timing.timestamp - AUDIO_OFFSET_SECONDS)
|
||
events.append(
|
||
AudioEvent(
|
||
audio_id=audio_id,
|
||
start_time=start_time,
|
||
audio_def=audio[audio_id],
|
||
)
|
||
)
|
||
|
||
return events
|
||
|
||
|
||
def _extract_camera_events(
|
||
marker_timings: list[MarkerTiming],
|
||
time_range: Optional[tuple[float, float]] = None,
|
||
) -> tuple[list[CameraEvent], CameraState]:
|
||
"""
|
||
Extract camera events from aligned marker timings.
|
||
|
||
Camera state is cumulative. Returns (events, initial_state).
|
||
"""
|
||
range_start, range_end = time_range if time_range else (0.0, float("inf"))
|
||
|
||
events: list[CameraEvent] = []
|
||
current_state = CameraState()
|
||
initial_state = CameraState()
|
||
found_range_start = False
|
||
|
||
for timing in marker_timings:
|
||
if timing.timestamp < 0:
|
||
continue
|
||
|
||
marker_id = timing.marker_id
|
||
if marker_id not in CAMERA_PRESETS:
|
||
continue
|
||
|
||
preset = CAMERA_PRESETS[marker_id]
|
||
|
||
# Determine new state based on marker type
|
||
if marker_id in ("Reset", "NoTilt"):
|
||
new_state = CameraState()
|
||
elif marker_id.startswith("Zoom"):
|
||
new_state = CameraState(
|
||
zoom=preset.zoom,
|
||
rotation=current_state.rotation,
|
||
pan_x=current_state.pan_x,
|
||
pan_y=current_state.pan_y,
|
||
focal_x=current_state.focal_x,
|
||
focal_y=current_state.focal_y,
|
||
)
|
||
elif marker_id.startswith("Tilt"):
|
||
new_state = CameraState(
|
||
zoom=current_state.zoom,
|
||
rotation=preset.rotation,
|
||
pan_x=current_state.pan_x,
|
||
pan_y=current_state.pan_y,
|
||
focal_x=current_state.focal_x,
|
||
focal_y=current_state.focal_y,
|
||
)
|
||
elif marker_id.startswith("Pan"):
|
||
new_state = CameraState(
|
||
zoom=current_state.zoom,
|
||
rotation=current_state.rotation,
|
||
pan_x=preset.pan_x,
|
||
pan_y=preset.pan_y,
|
||
focal_x=current_state.focal_x,
|
||
focal_y=current_state.focal_y,
|
||
)
|
||
else:
|
||
new_state = preset
|
||
|
||
# Capture state at range start
|
||
if not found_range_start and timing.timestamp >= range_start:
|
||
initial_state = current_state
|
||
found_range_start = True
|
||
|
||
# Only emit events within range
|
||
if range_start <= timing.timestamp < range_end:
|
||
events.append(
|
||
CameraEvent(
|
||
time=timing.timestamp,
|
||
target_state=new_state,
|
||
duration=0.2,
|
||
easing="ease-out",
|
||
)
|
||
)
|
||
|
||
current_state = new_state
|
||
|
||
if not found_range_start:
|
||
initial_state = CameraState()
|
||
|
||
return events, initial_state
|
||
|
||
|
||
def _extract_outro_events(
|
||
outro_video_ids: list[str],
|
||
videos: dict[str, VideoSource],
|
||
cutouts: dict[str, CutoutDefinition],
|
||
narration_end_time: float,
|
||
videos_dir: Path,
|
||
shared_assets_dir: Path = None,
|
||
project_path: Path = None,
|
||
cached_files: set = None,
|
||
) -> list[OutroEvent]:
|
||
"""
|
||
Extract outro events that play after the narration ends.
|
||
|
||
Outro videos play in sequence, starting from narration_end_time.
|
||
Each video plays for its `take` duration (or full source duration if no take).
|
||
"""
|
||
events: list[OutroEvent] = []
|
||
current_time = narration_end_time
|
||
|
||
for video_id in outro_video_ids:
|
||
if video_id not in videos:
|
||
continue
|
||
|
||
video_source = videos[video_id]
|
||
|
||
# Get the video duration
|
||
video_path, is_cached = _resolve_video_path(
|
||
videos_dir, video_source, shared_assets_dir, project_path
|
||
)
|
||
if is_cached and cached_files is not None:
|
||
cached_files.add(video_id)
|
||
if video_path.exists():
|
||
full_duration = get_video_duration(video_path)
|
||
else:
|
||
full_duration = 10.0 # Fallback
|
||
|
||
# Use take if specified, otherwise use full duration
|
||
duration = video_source.take if video_source.take is not None else full_duration
|
||
|
||
# Account for skip
|
||
duration = max(0, duration)
|
||
|
||
# Resolve cutout (None = fullscreen)
|
||
cutout = None
|
||
if video_source.cutout and video_source.cutout in cutouts:
|
||
cutout = cutouts[video_source.cutout]
|
||
|
||
events.append(
|
||
OutroEvent(
|
||
video_id=video_id,
|
||
start_time=current_time,
|
||
end_time=current_time + duration,
|
||
video_source=video_source,
|
||
cutout=cutout,
|
||
)
|
||
)
|
||
|
||
current_time += duration
|
||
|
||
return events
|