vivekchakraverty Claude Opus 4.8 commited on
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>

Files changed (4) hide show
  1. app.py +28 -6
  2. scripts/smoke_test.py +9 -3
  3. src/frames.py +27 -4
  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
- recs = extract_auto_frames(video_path, config.session_dir(session))
 
 
 
 
 
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("### Captured frames")
 
 
 
 
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(on_auto, [session_state, frames_state, video_state], [gallery, frames_state, status])
 
 
 
 
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
- recs = frames_lib.extract_auto_frames(sample, sdir)
 
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, token=token
 
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(video_path: str | Path, max_frames: int) -> list[float]:
 
 
 
 
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, session_dir: str | Path, max_frames: int = 40
 
 
 
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 is the most reliable signal for
39
- # tutorials; the BLIP-caption match and sharpness refine the choice, break ties,
40
- # and carry the decision when a step has no timestamp.
41
- _W_PROX, _W_SEM, _W_SHARP, _MANUAL_BONUS = 0.55, 0.30, 0.15, 0.25
 
 
 
 
 
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
- window: float = 30.0,
86
  ) -> FrameRecord | None:
87
- """Pick the most relevant frame for a step by combining accurate signals:
88
- timestamp proximity + sharpness + BLIP-caption semantic match + manual bonus.
 
 
 
89
  """
90
- pool = [f for f in candidates if f.path not in used] or candidates
91
- if not pool:
92
  return None
93
 
 
94
  if timestamp is None:
95
- within = pool
96
- else:
97
- within = [f for f in pool if abs(f.timestamp - timestamp) <= window] or pool
98
-
99
- sharps = [_sharpness(f.path) for f in within]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- if timestamp is None:
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
- sharp = norm_sharp(_sharpness(frame.path))
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(within, key=score)
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 (