Spaces:
Running
Running
Commit ·
6e3fc02
1
Parent(s): e9abad3
Make auto-frame extraction narration-accurate
Browse files- Gate scene-detected frames to the spoken time range (drops screen-recorder
intro/idle screens) when a transcript is available.
- Weight transcript/LLM step timestamps far more in per-step frame selection:
tighter window, exclude out-of-speech frames, and extract a fresh frame at the
exact step time when no close frame exists.
- Thread the transcript into auto-extract + build; hint users to transcribe first.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- app.py +28 -6
- scripts/smoke_test.py +9 -3
- src/frames.py +27 -4
- src/guide.py +45 -26
app.py
CHANGED
|
@@ -171,17 +171,23 @@ def on_capture(session: str, frames: list[dict], data_url: str, current_time: fl
|
|
| 171 |
)
|
| 172 |
|
| 173 |
|
| 174 |
-
def on_auto(session: str, frames: list[dict], video_path: str, progress=gr.Progress()):
|
| 175 |
if not video_path:
|
| 176 |
return _gallery_value(frames), frames, "Upload a video first."
|
| 177 |
progress(0.1, "Detecting scenes…")
|
| 178 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
merged = frames + [asdict(r) for r in recs]
|
| 180 |
progress(1.0, "Done.")
|
|
|
|
| 181 |
return (
|
| 182 |
_gallery_value(merged),
|
| 183 |
merged,
|
| 184 |
-
f"Auto-extracted {len(recs)} frames ({len(merged)} total).",
|
| 185 |
)
|
| 186 |
|
| 187 |
|
|
@@ -254,12 +260,19 @@ def on_build(
|
|
| 254 |
video_path: str,
|
| 255 |
do_caption: bool,
|
| 256 |
hf_token: str,
|
|
|
|
| 257 |
progress=gr.Progress(),
|
| 258 |
):
|
| 259 |
if draft is None or not getattr(draft, "steps", None):
|
| 260 |
return None, "Generate the step-by-step guide first."
|
| 261 |
token = config.apply_token(hf_token)
|
| 262 |
recs = [FrameRecord(**d) for d in frames]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
progress(0.1, "Matching images to steps…")
|
| 264 |
g = guide_lib.assemble_guide(
|
| 265 |
draft,
|
|
@@ -268,6 +281,7 @@ def on_build(
|
|
| 268 |
session_dir=config.session_dir(session),
|
| 269 |
do_caption=do_caption,
|
| 270 |
token=token,
|
|
|
|
| 271 |
progress=progress,
|
| 272 |
)
|
| 273 |
out = config.session_dir(session) / "guide.docx"
|
|
@@ -326,7 +340,11 @@ def build_ui() -> gr.Blocks:
|
|
| 326 |
capture_btn = gr.Button("📸 Capture current frame", variant="primary")
|
| 327 |
auto_btn = gr.Button("✨ Auto-extract frames")
|
| 328 |
with gr.Column(scale=2):
|
| 329 |
-
gr.Markdown(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
gallery = gr.Gallery(
|
| 331 |
label="Frames pool — click an image to enlarge / select it",
|
| 332 |
elem_id="dm-gallery",
|
|
@@ -373,7 +391,11 @@ def build_ui() -> gr.Blocks:
|
|
| 373 |
outputs=[gallery, frames_state, status],
|
| 374 |
js=CAPTURE_JS,
|
| 375 |
)
|
| 376 |
-
auto_btn.click(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
gallery.select(on_select_frame, None, [selected_state, status])
|
| 378 |
delete_btn.click(
|
| 379 |
on_delete_frame,
|
|
@@ -392,7 +414,7 @@ def build_ui() -> gr.Blocks:
|
|
| 392 |
)
|
| 393 |
build_btn.click(
|
| 394 |
on_build,
|
| 395 |
-
[session_state, draft_state, frames_state, video_state, caption_chk, hf_token],
|
| 396 |
[download, status],
|
| 397 |
)
|
| 398 |
|
|
|
|
| 171 |
)
|
| 172 |
|
| 173 |
|
| 174 |
+
def on_auto(session: str, frames: list[dict], video_path: str, transcript_obj, progress=gr.Progress()):
|
| 175 |
if not video_path:
|
| 176 |
return _gallery_value(frames), frames, "Upload a video first."
|
| 177 |
progress(0.1, "Detecting scenes…")
|
| 178 |
+
spoken = (
|
| 179 |
+
[(s.start, s.end) for s in transcript_obj.segments]
|
| 180 |
+
if transcript_obj and transcript_obj.segments
|
| 181 |
+
else None
|
| 182 |
+
)
|
| 183 |
+
recs = extract_auto_frames(video_path, config.session_dir(session), spoken_intervals=spoken)
|
| 184 |
merged = frames + [asdict(r) for r in recs]
|
| 185 |
progress(1.0, "Done.")
|
| 186 |
+
tip = "" if spoken else " · tip: transcribe first for narration-aligned frames"
|
| 187 |
return (
|
| 188 |
_gallery_value(merged),
|
| 189 |
merged,
|
| 190 |
+
f"Auto-extracted {len(recs)} frames ({len(merged)} total).{tip}",
|
| 191 |
)
|
| 192 |
|
| 193 |
|
|
|
|
| 260 |
video_path: str,
|
| 261 |
do_caption: bool,
|
| 262 |
hf_token: str,
|
| 263 |
+
transcript_obj,
|
| 264 |
progress=gr.Progress(),
|
| 265 |
):
|
| 266 |
if draft is None or not getattr(draft, "steps", None):
|
| 267 |
return None, "Generate the step-by-step guide first."
|
| 268 |
token = config.apply_token(hf_token)
|
| 269 |
recs = [FrameRecord(**d) for d in frames]
|
| 270 |
+
spoken_range = None
|
| 271 |
+
if transcript_obj and transcript_obj.segments:
|
| 272 |
+
spoken_range = (
|
| 273 |
+
min(s.start for s in transcript_obj.segments),
|
| 274 |
+
max(s.end for s in transcript_obj.segments),
|
| 275 |
+
)
|
| 276 |
progress(0.1, "Matching images to steps…")
|
| 277 |
g = guide_lib.assemble_guide(
|
| 278 |
draft,
|
|
|
|
| 281 |
session_dir=config.session_dir(session),
|
| 282 |
do_caption=do_caption,
|
| 283 |
token=token,
|
| 284 |
+
spoken_range=spoken_range,
|
| 285 |
progress=progress,
|
| 286 |
)
|
| 287 |
out = config.session_dir(session) / "guide.docx"
|
|
|
|
| 340 |
capture_btn = gr.Button("📸 Capture current frame", variant="primary")
|
| 341 |
auto_btn = gr.Button("✨ Auto-extract frames")
|
| 342 |
with gr.Column(scale=2):
|
| 343 |
+
gr.Markdown(
|
| 344 |
+
"### Captured frames\n"
|
| 345 |
+
"_Tip: **Transcribe** (step 2) before **Auto-extract** — frames then "
|
| 346 |
+
"snap to the narration and skip recorder intro/idle screens._"
|
| 347 |
+
)
|
| 348 |
gallery = gr.Gallery(
|
| 349 |
label="Frames pool — click an image to enlarge / select it",
|
| 350 |
elem_id="dm-gallery",
|
|
|
|
| 391 |
outputs=[gallery, frames_state, status],
|
| 392 |
js=CAPTURE_JS,
|
| 393 |
)
|
| 394 |
+
auto_btn.click(
|
| 395 |
+
on_auto,
|
| 396 |
+
[session_state, frames_state, video_state, transcript_state],
|
| 397 |
+
[gallery, frames_state, status],
|
| 398 |
+
)
|
| 399 |
gallery.select(on_select_frame, None, [selected_state, status])
|
| 400 |
delete_btn.click(
|
| 401 |
on_delete_frame,
|
|
|
|
| 414 |
)
|
| 415 |
build_btn.click(
|
| 416 |
on_build,
|
| 417 |
+
[session_state, draft_state, frames_state, video_state, caption_chk, hf_token, transcript_state],
|
| 418 |
[download, status],
|
| 419 |
)
|
| 420 |
|
scripts/smoke_test.py
CHANGED
|
@@ -54,8 +54,9 @@ def main() -> None:
|
|
| 54 |
print(f" device={tr.device} segments={len(tr.segments)} text={tr.text[:120]!r}")
|
| 55 |
assert tr.text.strip(), "Transcript is empty"
|
| 56 |
|
| 57 |
-
print("[2/5] Auto-extract frames…")
|
| 58 |
-
|
|
|
|
| 59 |
print(f" frames={len(recs)}")
|
| 60 |
assert recs, "No frames were extracted"
|
| 61 |
|
|
@@ -71,8 +72,13 @@ def main() -> None:
|
|
| 71 |
assert draft.steps, "No steps in draft"
|
| 72 |
|
| 73 |
print("[4/5] Assemble (align + caption)…")
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
g = guide_lib.assemble_guide(
|
| 75 |
-
draft, recs, video_path=str(sample), session_dir=sdir, do_caption=True,
|
|
|
|
| 76 |
)
|
| 77 |
|
| 78 |
print("[5/5] Export DOCX…")
|
|
|
|
| 54 |
print(f" device={tr.device} segments={len(tr.segments)} text={tr.text[:120]!r}")
|
| 55 |
assert tr.text.strip(), "Transcript is empty"
|
| 56 |
|
| 57 |
+
print("[2/5] Auto-extract frames (narration-gated)…")
|
| 58 |
+
spoken = [(s.start, s.end) for s in tr.segments] if tr.segments else None
|
| 59 |
+
recs = frames_lib.extract_auto_frames(sample, sdir, spoken_intervals=spoken)
|
| 60 |
print(f" frames={len(recs)}")
|
| 61 |
assert recs, "No frames were extracted"
|
| 62 |
|
|
|
|
| 72 |
assert draft.steps, "No steps in draft"
|
| 73 |
|
| 74 |
print("[4/5] Assemble (align + caption)…")
|
| 75 |
+
spoken_range = (
|
| 76 |
+
(min(s.start for s in tr.segments), max(s.end for s in tr.segments))
|
| 77 |
+
if tr.segments else None
|
| 78 |
+
)
|
| 79 |
g = guide_lib.assemble_guide(
|
| 80 |
+
draft, recs, video_path=str(sample), session_dir=sdir, do_caption=True,
|
| 81 |
+
token=token, spoken_range=spoken_range,
|
| 82 |
)
|
| 83 |
|
| 84 |
print("[5/5] Export DOCX…")
|
src/frames.py
CHANGED
|
@@ -45,7 +45,11 @@ def detect_scenes(video_path: str | Path) -> list[tuple[float, float]]:
|
|
| 45 |
return [(start.get_seconds(), end.get_seconds()) for start, end in scenes]
|
| 46 |
|
| 47 |
|
| 48 |
-
def _scene_timestamps(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
scenes = detect_scenes(video_path)
|
| 50 |
if scenes:
|
| 51 |
timestamps = [(start + end) / 2.0 for start, end in scenes]
|
|
@@ -56,6 +60,18 @@ def _scene_timestamps(video_path: str | Path, max_frames: int) -> list[float]:
|
|
| 56 |
step = duration / (count + 1)
|
| 57 |
timestamps = [step * (i + 1) for i in range(count)]
|
| 58 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# Keep an evenly spaced subset if we overshoot the cap.
|
| 60 |
if len(timestamps) > max_frames:
|
| 61 |
last = len(timestamps) - 1
|
|
@@ -65,14 +81,21 @@ def _scene_timestamps(video_path: str | Path, max_frames: int) -> list[float]:
|
|
| 65 |
|
| 66 |
|
| 67 |
def extract_auto_frames(
|
| 68 |
-
video_path: str | Path,
|
|
|
|
|
|
|
|
|
|
| 69 |
) -> list[FrameRecord]:
|
| 70 |
-
"""Extract one representative frame per detected scene, then dedup.
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
frames_dir = Path(session_dir) / "frames"
|
| 72 |
frames_dir.mkdir(parents=True, exist_ok=True)
|
| 73 |
|
| 74 |
records: list[FrameRecord] = []
|
| 75 |
-
for i, ts in enumerate(_scene_timestamps(video_path, max_frames)):
|
| 76 |
out = frames_dir / f"auto_{i:03d}_{int(ts * 1000):08d}.png"
|
| 77 |
try:
|
| 78 |
video.extract_frame(video_path, ts, out)
|
|
|
|
| 45 |
return [(start.get_seconds(), end.get_seconds()) for start, end in scenes]
|
| 46 |
|
| 47 |
|
| 48 |
+
def _scene_timestamps(
|
| 49 |
+
video_path: str | Path,
|
| 50 |
+
max_frames: int,
|
| 51 |
+
spoken_intervals: list[tuple[float, float]] | None = None,
|
| 52 |
+
) -> list[float]:
|
| 53 |
scenes = detect_scenes(video_path)
|
| 54 |
if scenes:
|
| 55 |
timestamps = [(start + end) / 2.0 for start, end in scenes]
|
|
|
|
| 60 |
step = duration / (count + 1)
|
| 61 |
timestamps = [step * (i + 1) for i in range(count)]
|
| 62 |
|
| 63 |
+
# Anchor to the narration: keep only frames within the spoken time range.
|
| 64 |
+
# This drops screen-recorder intro/outro and idle screens (no speech there),
|
| 65 |
+
# which is the main source of irrelevant auto-frames.
|
| 66 |
+
if spoken_intervals:
|
| 67 |
+
lo = min(s for s, _ in spoken_intervals)
|
| 68 |
+
hi = max(e for _, e in spoken_intervals)
|
| 69 |
+
pad = 1.5
|
| 70 |
+
gated = [t for t in timestamps if lo - pad <= t <= hi + pad]
|
| 71 |
+
# If gating removed everything, fall back to the segment start times.
|
| 72 |
+
timestamps = gated or [s for s, _ in spoken_intervals]
|
| 73 |
+
|
| 74 |
+
timestamps = sorted(timestamps)
|
| 75 |
# Keep an evenly spaced subset if we overshoot the cap.
|
| 76 |
if len(timestamps) > max_frames:
|
| 77 |
last = len(timestamps) - 1
|
|
|
|
| 81 |
|
| 82 |
|
| 83 |
def extract_auto_frames(
|
| 84 |
+
video_path: str | Path,
|
| 85 |
+
session_dir: str | Path,
|
| 86 |
+
max_frames: int = 40,
|
| 87 |
+
spoken_intervals: list[tuple[float, float]] | None = None,
|
| 88 |
) -> list[FrameRecord]:
|
| 89 |
+
"""Extract one representative frame per detected scene, then dedup.
|
| 90 |
+
|
| 91 |
+
When ``spoken_intervals`` (from the transcript) are given, frames are gated to
|
| 92 |
+
the narrated time range so recorder intro/idle screens are not captured.
|
| 93 |
+
"""
|
| 94 |
frames_dir = Path(session_dir) / "frames"
|
| 95 |
frames_dir.mkdir(parents=True, exist_ok=True)
|
| 96 |
|
| 97 |
records: list[FrameRecord] = []
|
| 98 |
+
for i, ts in enumerate(_scene_timestamps(video_path, max_frames, spoken_intervals)):
|
| 99 |
out = frames_dir / f"auto_{i:03d}_{int(ts * 1000):08d}.png"
|
| 100 |
try:
|
| 101 |
video.extract_frame(video_path, ts, out)
|
src/guide.py
CHANGED
|
@@ -35,10 +35,15 @@ class Guide:
|
|
| 35 |
steps: list[GuideStep] = field(default_factory=list)
|
| 36 |
|
| 37 |
|
| 38 |
-
# Relevance weights. Timestamp proximity
|
| 39 |
-
#
|
| 40 |
-
#
|
| 41 |
-
_W_PROX, _W_SEM, _W_SHARP
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
_STOPWORDS = {
|
| 44 |
"the", "a", "an", "to", "of", "and", "or", "in", "on", "at", "for", "with",
|
|
@@ -82,39 +87,51 @@ def _pick_frame(
|
|
| 82 |
timestamp: float | None,
|
| 83 |
step_text: str,
|
| 84 |
used: set[str],
|
| 85 |
-
|
| 86 |
) -> FrameRecord | None:
|
| 87 |
-
"""Pick the
|
| 88 |
-
|
|
|
|
|
|
|
|
|
|
| 89 |
"""
|
| 90 |
-
|
| 91 |
-
if not
|
| 92 |
return None
|
| 93 |
|
|
|
|
| 94 |
if timestamp is None:
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
|
| 98 |
-
|
| 99 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
smin, smax = min(sharps), max(sharps)
|
| 101 |
|
| 102 |
def norm_sharp(value: float) -> float:
|
| 103 |
return (value - smin) / (smax - smin) if smax > smin else 1.0
|
| 104 |
|
| 105 |
def score(frame: FrameRecord) -> float:
|
| 106 |
-
|
| 107 |
-
prox = 0.0
|
| 108 |
-
else:
|
| 109 |
-
prox = 1.0 - min(abs(frame.timestamp - timestamp) / window, 1.0)
|
| 110 |
sem = _text_relevance(frame.caption, step_text)
|
| 111 |
-
|
| 112 |
-
total = _W_PROX * prox + _W_SEM * sem + _W_SHARP * sharp
|
| 113 |
-
if frame.source == "manual":
|
| 114 |
-
total += _MANUAL_BONUS
|
| 115 |
-
return total
|
| 116 |
|
| 117 |
-
return max(
|
| 118 |
|
| 119 |
|
| 120 |
def assemble_guide(
|
|
@@ -125,13 +142,15 @@ def assemble_guide(
|
|
| 125 |
session_dir: str | Path | None = None,
|
| 126 |
do_caption: bool = True,
|
| 127 |
token: str | None = None,
|
|
|
|
| 128 |
progress: Callable[[float, str], None] | None = None,
|
| 129 |
) -> Guide:
|
| 130 |
"""Combine a guide draft with frames into a fully illustrated :class:`Guide`.
|
| 131 |
|
| 132 |
When captioning is on, the whole (deduped) frame pool is captioned once so
|
| 133 |
BLIP can both *suggest* the most relevant frame per step and supply the
|
| 134 |
-
figure captions.
|
|
|
|
| 135 |
"""
|
| 136 |
frames_sorted = sorted(frames, key=lambda f: f.timestamp)
|
| 137 |
|
|
@@ -156,7 +175,7 @@ def assemble_guide(
|
|
| 156 |
progress(0.5 + 0.5 * (i / total), f"Matching image to step {i + 1}/{total}…")
|
| 157 |
|
| 158 |
step_text = f"{sd.heading} {sd.text}".strip()
|
| 159 |
-
chosen = _pick_frame(frames_sorted, sd.approx_timestamp, step_text, used)
|
| 160 |
|
| 161 |
# No suitable frame nearby — extract one at the step timestamp.
|
| 162 |
if (
|
|
|
|
| 35 |
steps: list[GuideStep] = field(default_factory=list)
|
| 36 |
|
| 37 |
|
| 38 |
+
# Relevance weights. Timestamp proximity (transcript/LLM time) is by far the most
|
| 39 |
+
# reliable signal for tutorials and is weighted heavily; the BLIP-caption match and
|
| 40 |
+
# sharpness only break ties.
|
| 41 |
+
_W_PROX, _W_SEM, _W_SHARP = 0.70, 0.18, 0.12
|
| 42 |
+
# How far (seconds) a frame may sit from a step's LLM timestamp to be reused.
|
| 43 |
+
# Manual frames get a wider window (the user captured them on purpose); scene/auto
|
| 44 |
+
# frames must be close, otherwise we extract a fresh frame at the exact step time.
|
| 45 |
+
_MANUAL_WINDOW = 20.0
|
| 46 |
+
_AUTO_WINDOW = 12.0
|
| 47 |
|
| 48 |
_STOPWORDS = {
|
| 49 |
"the", "a", "an", "to", "of", "and", "or", "in", "on", "at", "for", "with",
|
|
|
|
| 87 |
timestamp: float | None,
|
| 88 |
step_text: str,
|
| 89 |
used: set[str],
|
| 90 |
+
spoken_range: tuple[float, float] | None = None,
|
| 91 |
) -> FrameRecord | None:
|
| 92 |
+
"""Pick the best existing frame for a step, anchored to its LLM timestamp.
|
| 93 |
+
|
| 94 |
+
Returns ``None`` when no pool frame sits close enough in time — the caller then
|
| 95 |
+
extracts a fresh frame at the exact step timestamp, which keeps every step's
|
| 96 |
+
image aligned to the narration rather than to an unrelated visual scene change.
|
| 97 |
"""
|
| 98 |
+
avail = [f for f in candidates if f.path not in used] or candidates
|
| 99 |
+
if not avail:
|
| 100 |
return None
|
| 101 |
|
| 102 |
+
# No LLM timestamp for this step: fall back to caption relevance, then sharpness.
|
| 103 |
if timestamp is None:
|
| 104 |
+
return max(avail, key=lambda f: (_text_relevance(f.caption, step_text), _sharpness(f.path)))
|
| 105 |
+
|
| 106 |
+
# 1) A manual frame captured near this step wins — it's deliberate user intent.
|
| 107 |
+
manual_near = [
|
| 108 |
+
f for f in avail
|
| 109 |
+
if f.source == "manual" and abs(f.timestamp - timestamp) <= _MANUAL_WINDOW
|
| 110 |
+
]
|
| 111 |
+
if manual_near:
|
| 112 |
+
return min(manual_near, key=lambda f: abs(f.timestamp - timestamp))
|
| 113 |
+
|
| 114 |
+
# 2) Scene/auto frames tightly around the step's LLM time, inside the spoken range.
|
| 115 |
+
near = [f for f in avail if abs(f.timestamp - timestamp) <= _AUTO_WINDOW]
|
| 116 |
+
if spoken_range:
|
| 117 |
+
lo, hi = spoken_range
|
| 118 |
+
in_speech = [f for f in near if lo - 2.0 <= f.timestamp <= hi + 2.0]
|
| 119 |
+
near = in_speech or near
|
| 120 |
+
if not near:
|
| 121 |
+
return None # -> caller extracts a fresh frame at the exact step time
|
| 122 |
+
|
| 123 |
+
sharps = [_sharpness(f.path) for f in near]
|
| 124 |
smin, smax = min(sharps), max(sharps)
|
| 125 |
|
| 126 |
def norm_sharp(value: float) -> float:
|
| 127 |
return (value - smin) / (smax - smin) if smax > smin else 1.0
|
| 128 |
|
| 129 |
def score(frame: FrameRecord) -> float:
|
| 130 |
+
prox = 1.0 - min(abs(frame.timestamp - timestamp) / _AUTO_WINDOW, 1.0)
|
|
|
|
|
|
|
|
|
|
| 131 |
sem = _text_relevance(frame.caption, step_text)
|
| 132 |
+
return _W_PROX * prox + _W_SEM * sem + _W_SHARP * norm_sharp(_sharpness(frame.path))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 133 |
|
| 134 |
+
return max(near, key=score)
|
| 135 |
|
| 136 |
|
| 137 |
def assemble_guide(
|
|
|
|
| 142 |
session_dir: str | Path | None = None,
|
| 143 |
do_caption: bool = True,
|
| 144 |
token: str | None = None,
|
| 145 |
+
spoken_range: tuple[float, float] | None = None,
|
| 146 |
progress: Callable[[float, str], None] | None = None,
|
| 147 |
) -> Guide:
|
| 148 |
"""Combine a guide draft with frames into a fully illustrated :class:`Guide`.
|
| 149 |
|
| 150 |
When captioning is on, the whole (deduped) frame pool is captioned once so
|
| 151 |
BLIP can both *suggest* the most relevant frame per step and supply the
|
| 152 |
+
figure captions. ``spoken_range`` (first/last narration time) keeps selection
|
| 153 |
+
inside the narrated portion of the video.
|
| 154 |
"""
|
| 155 |
frames_sorted = sorted(frames, key=lambda f: f.timestamp)
|
| 156 |
|
|
|
|
| 175 |
progress(0.5 + 0.5 * (i / total), f"Matching image to step {i + 1}/{total}…")
|
| 176 |
|
| 177 |
step_text = f"{sd.heading} {sd.text}".strip()
|
| 178 |
+
chosen = _pick_frame(frames_sorted, sd.approx_timestamp, step_text, used, spoken_range)
|
| 179 |
|
| 180 |
# No suitable frame nearby — extract one at the step timestamp.
|
| 181 |
if (
|