vivekchakraverty Claude Opus 4.8 commited on
Commit
0e7c368
·
1 Parent(s): 915f7b8

Auto-extract: generate the LLM step outline first, anchor frames to step times

Browse files

Auto-extract now runs guide generation (after transcription) when no draft exists,
then extracts frames AT the LLM step timestamps — the same suggestions the per-step
weighted selection relies on. Falls back to narration-gated scenes when there is no
token/draft. The generated transcript + outline are surfaced in the UI (reused, not
recomputed). Smoke test reordered to generate before extracting.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (3) hide show
  1. app.py +71 -28
  2. scripts/smoke_test.py +10 -7
  3. src/frames.py +28 -5
app.py CHANGED
@@ -171,39 +171,73 @@ 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, transcript_obj, progress=gr.Progress()):
175
- # Outputs: gallery, frames_state, transcript_box, transcript_state, status
 
 
 
 
 
 
 
 
176
  if not video_path:
177
- return _gallery_value(frames), frames, gr.update(), gr.update(), "Upload a video first."
 
 
 
 
178
 
179
- # Transcribe first if we don't have a transcript yet, so frames can be gated
180
- # to the narration (otherwise recorder intro/idle screens get captured).
181
- auto_transcribed = False
182
  if not (transcript_obj and getattr(transcript_obj, "segments", None)):
183
- progress(0.0, "No transcript yet — transcribing first…")
184
  transcript_obj = _run_transcription(session, video_path, progress)
185
- auto_transcribed = True
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
 
187
  spoken = (
188
  [(s.start, s.end) for s in transcript_obj.segments]
189
- if transcript_obj and transcript_obj.segments
190
- else None
 
 
 
 
 
 
 
 
191
  )
192
- progress(0.85, "Detecting scenes…")
193
- recs = extract_auto_frames(video_path, config.session_dir(session), spoken_intervals=spoken)
194
  merged = frames + [asdict(r) for r in recs]
195
  progress(1.0, "Done.")
196
 
197
- # If we just transcribed, surface it in the box + state so it's reused (and
198
- # don't clobber a transcript the user may have already edited).
199
- box_out = transcript_obj.to_timestamped_text() if auto_transcribed else gr.update()
200
- note = " (auto-transcribed first)" if auto_transcribed else ""
201
  return (
202
  _gallery_value(merged),
203
  merged,
204
  box_out,
205
  transcript_obj,
206
- f"Auto-extracted {len(recs)} frames{note} ({len(merged)} total).",
 
 
207
  )
208
 
209
 
@@ -258,6 +292,18 @@ def on_token_set(hf_token: str):
258
  return "Enter your HuggingFace token to generate the guide."
259
 
260
 
 
 
 
 
 
 
 
 
 
 
 
 
261
  def on_generate(transcript_text: str, transcript_obj, hf_token: str, progress=gr.Progress()):
262
  token = config.apply_token(hf_token)
263
  if not token:
@@ -265,13 +311,10 @@ def on_generate(transcript_text: str, transcript_obj, hf_token: str, progress=gr
265
  tr = _parse_timestamped_text(transcript_text) if transcript_text.strip() else transcript_obj
266
  if tr is None or not tr.segments:
267
  return "", None, "Transcribe the audio first (or paste a transcript)."
268
- try:
269
- draft = llm.build_guide_draft(tr, token=token, progress=progress)
270
- except RuntimeError as exc:
271
- return "", None, f"⚠️ {exc}"
272
- if not draft.steps:
273
- return "", None, "The LLM returned no steps — try a different DOCUMAKER_LLM_MODEL."
274
- return _draft_to_md(draft), draft, f"Drafted {len(draft.steps)} steps. Review, then build the DOCX."
275
 
276
 
277
  def on_build(
@@ -363,8 +406,8 @@ def build_ui() -> gr.Blocks:
363
  with gr.Column(scale=2):
364
  gr.Markdown(
365
  "### Captured frames\n"
366
- "_**Auto-extract** transcribes first automatically, so frames snap to "
367
- "the narration and skip recorder intro/idle screens._"
368
  )
369
  gallery = gr.Gallery(
370
  label="Frames pool — click an image to enlarge / select it",
@@ -414,8 +457,8 @@ def build_ui() -> gr.Blocks:
414
  )
415
  auto_btn.click(
416
  on_auto,
417
- [session_state, frames_state, video_state, transcript_state],
418
- [gallery, frames_state, transcript_box, transcript_state, status],
419
  )
420
  gallery.select(on_select_frame, None, [selected_state, status])
421
  delete_btn.click(
 
171
  )
172
 
173
 
174
+ def on_auto(
175
+ session: str,
176
+ frames: list[dict],
177
+ video_path: str,
178
+ transcript_obj,
179
+ draft_obj,
180
+ hf_token: str,
181
+ progress=gr.Progress(),
182
+ ):
183
+ # Outputs: gallery, frames_state, transcript_box, transcript_state, guide_md, draft_state, status
184
  if not video_path:
185
+ return (_gallery_value(frames), frames, gr.update(), transcript_obj,
186
+ gr.update(), draft_obj, "Upload a video first.")
187
+
188
+ token = config.apply_token(hf_token)
189
+ notes: list[str] = []
190
 
191
+ # 1) Transcript needed to anchor/gate frames to the narration.
192
+ auto_tr = False
 
193
  if not (transcript_obj and getattr(transcript_obj, "segments", None)):
194
+ progress(0.0, "Transcribing first…")
195
  transcript_obj = _run_transcription(session, video_path, progress)
196
+ auto_tr = True
197
+
198
+ # 2) LLM step outline — so frames anchor to the actual guide steps (the same
199
+ # LLM timestamps the per-step selection weights heavily).
200
+ auto_draft = False
201
+ if not (draft_obj and getattr(draft_obj, "steps", None)):
202
+ if token:
203
+ progress(0.5, "Generating step outline (LLM)…")
204
+ new_draft, msg = _generate_draft(transcript_obj, token, progress)
205
+ if new_draft:
206
+ draft_obj, auto_draft = new_draft, True
207
+ else:
208
+ notes.append(msg)
209
+ else:
210
+ notes.append("add your HF token for step-aligned frames")
211
 
212
+ # 3) Extract — at step timestamps when available, else narration-gated scenes.
213
  spoken = (
214
  [(s.start, s.end) for s in transcript_obj.segments]
215
+ if transcript_obj and transcript_obj.segments else None
216
+ )
217
+ steps_ts = (
218
+ [s.approx_timestamp for s in draft_obj.steps if s.approx_timestamp is not None]
219
+ if draft_obj and getattr(draft_obj, "steps", None) else None
220
+ )
221
+ progress(0.9, "Extracting frames…")
222
+ recs = extract_auto_frames(
223
+ video_path, config.session_dir(session),
224
+ spoken_intervals=spoken, step_timestamps=steps_ts,
225
  )
 
 
226
  merged = frames + [asdict(r) for r in recs]
227
  progress(1.0, "Done.")
228
 
229
+ kind = "step-aligned" if steps_ts else ("narration-gated" if spoken else "scene")
230
+ box_out = transcript_obj.to_timestamped_text() if auto_tr else gr.update()
231
+ md_out = _draft_to_md(draft_obj) if auto_draft else gr.update()
232
+ note = (" · " + "; ".join(notes)) if notes else ""
233
  return (
234
  _gallery_value(merged),
235
  merged,
236
  box_out,
237
  transcript_obj,
238
+ md_out,
239
+ draft_obj,
240
+ f"Auto-extracted {len(recs)} {kind} frames ({len(merged)} total).{note}",
241
  )
242
 
243
 
 
292
  return "Enter your HuggingFace token to generate the guide."
293
 
294
 
295
+ def _generate_draft(tr, token: str, progress):
296
+ """Build the LLM step draft. Returns (draft|None, message). Shared by the
297
+ Generate button and Auto-extract."""
298
+ try:
299
+ draft = llm.build_guide_draft(tr, token=token, progress=progress)
300
+ except RuntimeError as exc:
301
+ return None, f"⚠️ {exc}"
302
+ if not draft.steps:
303
+ return None, "The LLM returned no steps — try a different DOCUMAKER_LLM_MODEL."
304
+ return draft, f"Drafted {len(draft.steps)} steps."
305
+
306
+
307
  def on_generate(transcript_text: str, transcript_obj, hf_token: str, progress=gr.Progress()):
308
  token = config.apply_token(hf_token)
309
  if not token:
 
311
  tr = _parse_timestamped_text(transcript_text) if transcript_text.strip() else transcript_obj
312
  if tr is None or not tr.segments:
313
  return "", None, "Transcribe the audio first (or paste a transcript)."
314
+ draft, msg = _generate_draft(tr, token, progress)
315
+ if draft is None:
316
+ return "", None, msg
317
+ return _draft_to_md(draft), draft, msg + " Review, then build the DOCX."
 
 
 
318
 
319
 
320
  def on_build(
 
406
  with gr.Column(scale=2):
407
  gr.Markdown(
408
  "### Captured frames\n"
409
+ "_**Auto-extract** runs transcription + the step outline first, so frames "
410
+ "anchor to the guide steps (and skip recorder intro/idle screens)._"
411
  )
412
  gallery = gr.Gallery(
413
  label="Frames pool — click an image to enlarge / select it",
 
457
  )
458
  auto_btn.click(
459
  on_auto,
460
+ [session_state, frames_state, video_state, transcript_state, draft_state, hf_token],
461
+ [gallery, frames_state, transcript_box, transcript_state, guide_md, draft_state, status],
462
  )
463
  gallery.select(on_select_frame, None, [selected_state, status])
464
  delete_btn.click(
scripts/smoke_test.py CHANGED
@@ -54,13 +54,7 @@ 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 (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
-
63
- print("[3/5] Build guide draft (LLM)…")
64
  try:
65
  draft = llm.build_guide_draft(tr, token=token)
66
  if not draft.steps:
@@ -71,6 +65,15 @@ def main() -> None:
71
  draft = naive_draft(tr)
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))
 
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] Build guide draft (LLM)…")
 
 
 
 
 
 
58
  try:
59
  draft = llm.build_guide_draft(tr, token=token)
60
  if not draft.steps:
 
65
  draft = naive_draft(tr)
66
  assert draft.steps, "No steps in draft"
67
 
68
+ print("[3/5] Auto-extract frames (step-aligned)…")
69
+ spoken = [(s.start, s.end) for s in tr.segments] if tr.segments else None
70
+ step_ts = [s.approx_timestamp for s in draft.steps if s.approx_timestamp is not None]
71
+ recs = frames_lib.extract_auto_frames(
72
+ sample, sdir, spoken_intervals=spoken, step_timestamps=step_ts or None
73
+ )
74
+ print(f" frames={len(recs)} (from {len(step_ts)} step timestamps)")
75
+ assert recs, "No frames were extracted"
76
+
77
  print("[4/5] Assemble (align + caption)…")
78
  spoken_range = (
79
  (min(s.start for s in tr.segments), max(s.end for s in tr.segments))
src/frames.py CHANGED
@@ -80,22 +80,45 @@ def _scene_timestamps(
80
  return timestamps
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)
 
80
  return timestamps
81
 
82
 
83
+ def _dedup_close(timestamps: list[float], min_gap: float = 1.5) -> list[float]:
84
+ """Collapse timestamps that are closer than ``min_gap`` seconds."""
85
+ out: list[float] = []
86
+ for t in sorted(timestamps):
87
+ if not out or t - out[-1] >= min_gap:
88
+ out.append(t)
89
+ return out
90
+
91
+
92
  def extract_auto_frames(
93
  video_path: str | Path,
94
  session_dir: str | Path,
95
  max_frames: int = 40,
96
  spoken_intervals: list[tuple[float, float]] | None = None,
97
+ step_timestamps: list[float] | None = None,
98
  ) -> list[FrameRecord]:
99
+ """Extract representative frames, then dedup.
100
+
101
+ Priority of anchors:
102
+ 1. ``step_timestamps`` (from the LLM step draft) extract at the exact moments
103
+ the guide refers to, so the pool matches the steps.
104
+ 2. ``spoken_intervals`` (from the transcript) — scene frames gated to the
105
+ narrated time range, dropping recorder intro/idle screens.
106
+ 3. Otherwise — scene midpoints (or uniform sampling).
107
  """
108
  frames_dir = Path(session_dir) / "frames"
109
  frames_dir.mkdir(parents=True, exist_ok=True)
110
 
111
+ if step_timestamps:
112
+ timestamps = _dedup_close([t for t in step_timestamps if t is not None and t >= 0])
113
+ if len(timestamps) > max_frames:
114
+ last = len(timestamps) - 1
115
+ picks = sorted({round(i * last / (max_frames - 1)) for i in range(max_frames)})
116
+ timestamps = [timestamps[i] for i in picks]
117
+ else:
118
+ timestamps = _scene_timestamps(video_path, max_frames, spoken_intervals)
119
+
120
  records: list[FrameRecord] = []
121
+ for i, ts in enumerate(timestamps):
122
  out = frames_dir / f"auto_{i:03d}_{int(ts * 1000):08d}.png"
123
  try:
124
  video.extract_frame(video_path, ts, out)