""" Scene window construction from a video duration and its VTT subtitles. A *scene window* is a fixed-length slice of the timeline (default 30s with a 15s stride) plus the subtitle text that overlaps it. Windows are the unit of work for the VLM: each one yields exactly one scene description and one embedding. We use fixed sliding windows rather than shot boundaries (TransNetV2) because narrative arcs — *disagreement then forgiveness* in the same conversation — usually span multiple shots. Shot-aligned windows would slice the arc into pieces and lose the cross-shot context that makes the description useful. """ from __future__ import annotations from dataclasses import dataclass, field from typing import Sequence DEFAULT_WINDOW_SECONDS = 30.0 DEFAULT_STRIDE_SECONDS = 15.0 @dataclass class SceneWindow: """A single window of the timeline with its subtitle text.""" index: int start_seconds: float end_seconds: float subtitle_text: str subtitle_entries: list[dict] = field(default_factory=list) @property def duration_seconds(self) -> float: return self.end_seconds - self.start_seconds @property def label(self) -> str: """Stable identifier safe to use as a filename component.""" return f"window-{self.index:04d}-start-{self.start_seconds:07.1f}s" def to_dict(self) -> dict: return { "index": self.index, "start_seconds": round(self.start_seconds, 3), "end_seconds": round(self.end_seconds, 3), "duration_seconds": round(self.duration_seconds, 3), "subtitle_text": self.subtitle_text, "subtitle_entry_count": len(self.subtitle_entries), } def build_scene_windows( duration_seconds: float, subtitle_entries: Sequence[dict], *, window_seconds: float = DEFAULT_WINDOW_SECONDS, stride_seconds: float = DEFAULT_STRIDE_SECONDS, ) -> list[SceneWindow]: """Slice a video into overlapping fixed-length scene windows. Args: duration_seconds: Total duration of the video in seconds. subtitle_entries: Parsed VTT entries (see ``subtitles_process_vtt.parse_vtt``). Each entry must have ``start``, ``end``, and ``text`` keys in seconds. window_seconds: Length of each window. 30s is a good default for narrative arcs; shorter values fragment them, longer values blur emotional shifts together. stride_seconds: Distance between window starts. With the default (15s stride, 30s window) every moment of the video is covered by two windows, giving the VLM a chance to catch arcs that straddle a window boundary. Returns: Ordered list of ``SceneWindow``. The final window is clipped to ``duration_seconds``. A video shorter than one window still produces exactly one window covering the whole video. """ if window_seconds <= 0: raise ValueError(f"window_seconds must be > 0, got {window_seconds}") if stride_seconds <= 0: raise ValueError(f"stride_seconds must be > 0, got {stride_seconds}") if duration_seconds <= 0: raise ValueError(f"duration_seconds must be > 0, got {duration_seconds}") windows: list[SceneWindow] = [] start = 0.0 index = 0 while start < duration_seconds: end = min(start + window_seconds, duration_seconds) overlapping = _entries_overlapping(subtitle_entries, start, end) text = _join_entries(overlapping) windows.append( SceneWindow( index=index, start_seconds=start, end_seconds=end, subtitle_text=text, subtitle_entries=list(overlapping), ) ) index += 1 next_start = start + stride_seconds if next_start >= duration_seconds: break start = next_start return windows def _entries_overlapping( entries: Sequence[dict], start: float, end: float ) -> list[dict]: """Return entries whose [start, end) interval intersects [start, end).""" matched: list[dict] = [] for entry in entries: entry_start = float(entry.get("start", 0.0)) entry_end = float(entry.get("end", entry_start)) if entry_end <= start: continue if entry_start >= end: continue matched.append(entry) return matched def _join_entries(entries: Sequence[dict]) -> str: """Join the entries' text into a single readable paragraph.""" pieces = [] for entry in entries: text = (entry.get("text") or "").strip() if text: pieces.append(text) return " ".join(pieces)