Spaces:
Sleeping
Sleeping
Commit ·
0e7c368
1
Parent(s): 915f7b8
Auto-extract: generate the LLM step outline first, anchor frames to step times
Browse filesAuto-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>
- app.py +71 -28
- scripts/smoke_test.py +10 -7
- 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(
|
| 175 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 176 |
if not video_path:
|
| 177 |
-
return _gallery_value(frames), frames, gr.update(),
|
|
|
|
|
|
|
|
|
|
|
|
|
| 178 |
|
| 179 |
-
#
|
| 180 |
-
|
| 181 |
-
auto_transcribed = False
|
| 182 |
if not (transcript_obj and getattr(transcript_obj, "segments", None)):
|
| 183 |
-
progress(0.0, "
|
| 184 |
transcript_obj = _run_transcription(session, video_path, progress)
|
| 185 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
|
|
|
|
| 187 |
spoken = (
|
| 188 |
[(s.start, s.end) for s in transcript_obj.segments]
|
| 189 |
-
if transcript_obj and transcript_obj.segments
|
| 190 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 198 |
-
|
| 199 |
-
|
| 200 |
-
note = "
|
| 201 |
return (
|
| 202 |
_gallery_value(merged),
|
| 203 |
merged,
|
| 204 |
box_out,
|
| 205 |
transcript_obj,
|
| 206 |
-
|
|
|
|
|
|
|
| 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 |
-
|
| 269 |
-
|
| 270 |
-
|
| 271 |
-
|
| 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**
|
| 367 |
-
"the
|
| 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]
|
| 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
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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(
|
| 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)
|