vivekchakraverty Claude Opus 4.8 commited on
Commit
85b485a
·
0 Parent(s):

DocuMaker: video to step-by-step DOCX guide (Whisper + HF LLM + BLIP)

Browse files
Files changed (18) hide show
  1. .env.example +35 -0
  2. .gitignore +20 -0
  3. README.md +201 -0
  4. app.py +360 -0
  5. packages.txt +1 -0
  6. requirements.txt +31 -0
  7. scripts/make_sample.py +117 -0
  8. scripts/smoke_test.py +95 -0
  9. src/__init__.py +3 -0
  10. src/config.py +142 -0
  11. src/docx_export.py +55 -0
  12. src/frames.py +124 -0
  13. src/guide.py +205 -0
  14. src/llm.py +273 -0
  15. src/transcribe.py +137 -0
  16. src/video.py +82 -0
  17. src/vision.py +166 -0
  18. src/web/player.html +10 -0
.env.example ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Copy this file to ".env" and adjust as needed. All values are optional —
2
+ # DocuMaker falls back to sensible defaults. HF_TOKEN is also read from your
3
+ # environment (HF_TOKEN / HUGGINGFACEHUB_API_TOKEN) if not set here.
4
+
5
+ # HuggingFace token — https://huggingface.co/settings/tokens
6
+ # NOTE: the Gradio app takes your token from its UI field, NOT from here.
7
+ # This is only used by the headless smoke test (scripts/smoke_test.py) / CLI.
8
+ HF_TOKEN=
9
+
10
+ # --- Text LLM (HuggingFace Inference API) ---
11
+ # Any instruct model available via HF Inference Providers. Swap if a model is
12
+ # unavailable on the free tier. Good options: Qwen/Qwen2.5-7B-Instruct,
13
+ # meta-llama/Llama-3.1-8B-Instruct, mistralai/Mistral-7B-Instruct-v0.3
14
+ DOCUMAKER_LLM_MODEL=Qwen/Qwen2.5-7B-Instruct
15
+ # DOCUMAKER_LLM_PROVIDER= # e.g. hf-inference, together, novita (blank = auto)
16
+ DOCUMAKER_LLM_MAX_TOKENS=2048
17
+ DOCUMAKER_LLM_TEMPERATURE=0.3
18
+
19
+ # --- Vision LLM (HuggingFace Inference API) ---
20
+ DOCUMAKER_VLM_MODEL=Qwen/Qwen2-VL-7B-Instruct
21
+ DOCUMAKER_ENABLE_VISION=1 # set 0 to skip captioning entirely
22
+ # DOCUMAKER_VLM_PROVIDER=
23
+ # DOCUMAKER_LOCAL_CAPTION_MODEL=Salesforce/blip-image-captioning-base
24
+
25
+ # --- Whisper (local faster-whisper) ---
26
+ DOCUMAKER_WHISPER_MODEL=small # tiny | base | small | medium | large-v3
27
+ DOCUMAKER_WHISPER_DEVICE=auto # auto | cuda | cpu
28
+ # DOCUMAKER_WHISPER_COMPUTE_TYPE= # blank = auto; e.g. int8_float16, int8, float16
29
+
30
+ # --- Frame extraction ---
31
+ DOCUMAKER_SCENE_THRESHOLD=27.0 # lower = more scenes detected
32
+ DOCUMAKER_DEDUP_HASH_DISTANCE=6 # higher = more aggressive dedup
33
+
34
+ # --- Output ---
35
+ DOCUMAKER_DOCX_IMAGE_WIDTH_INCHES=5.5
.gitignore ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Runtime artifacts (uploads, frames, audio, generated docs)
2
+ work/
3
+
4
+ # Secrets
5
+ .env
6
+
7
+ # Virtual environments
8
+ .venv/
9
+ venv/
10
+
11
+ # Python
12
+ __pycache__/
13
+ *.py[cod]
14
+ *.egg-info/
15
+
16
+ # Gradio cache
17
+ .gradio/
18
+
19
+ # HuggingFace / model caches living in-project (if any)
20
+ .cache/
README.md ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: DocuMaker
3
+ emoji: 🎬
4
+ colorFrom: indigo
5
+ colorTo: purple
6
+ sdk: gradio
7
+ sdk_version: 6.18.0
8
+ app_file: app.py
9
+ python_version: "3.11"
10
+ pinned: false
11
+ license: mit
12
+ short_description: Turn a tutorial video into a step-by-step DOCX guide
13
+ ---
14
+
15
+ # DocuMaker
16
+
17
+ Turn a tutorial/screencast **video** into a polished **step-by-step `.docx` guide**
18
+ with screenshots — using open-source tools and **free** HuggingFace models.
19
+
20
+ > The block above is HuggingFace Spaces config. On GitHub it renders as a small
21
+ > table; on Spaces it tells the platform how to run the app.
22
+
23
+ Pipeline: `video → preview → frames (manual + automatic) → audio transcription
24
+ (Whisper) → LLM cleanup & step-structuring → image/step alignment + captions →
25
+ DOCX export`.
26
+
27
+ - **UI:** a local Gradio app.
28
+ - **Transcription:** runs **locally** with [faster-whisper](https://github.com/SYSTRAN/faster-whisper)
29
+ (GPU with automatic CPU fallback).
30
+ - **Guide writing:** the **HuggingFace Inference API** (uses your HF token).
31
+ - **Image captions:** a vision model. It tries an API vision-chat model first, then
32
+ falls back to a **local BLIP** captioner — which is the path most free HF accounts
33
+ end up using, since few enabled providers serve a vision model on the free tier.
34
+ - **Frames:** one-click manual snapshots **and** automatic scene-detection
35
+ (PySceneDetect) de-duplicated with perceptual hashing.
36
+
37
+ ### How a frame is chosen for each step
38
+
39
+ Relevancy is decided by combining accurate signals (no single model "judges" it):
40
+
41
+ 1. **Timestamp alignment** — the frame on screen while that step was narrated
42
+ (Whisper timestamps ↔ step time). The strongest signal for tutorials.
43
+ 2. **Sharpness** — variance-of-Laplacian, to avoid blurry scene-transition frames.
44
+ 3. **BLIP caption match** — the BLIP caption is compared to the step's text; a frame
45
+ whose description overlaps the step gets a nudge. This *suggests*, it doesn't decide.
46
+ 4. **Manual preference** — frames you snapshot yourself win ties.
47
+
48
+ See `_pick_frame` in [src/guide.py](src/guide.py).
49
+
50
+ ## Requirements
51
+
52
+ - Python 3.11
53
+ - **ffmpeg** on your `PATH` (`ffmpeg -version` should work)
54
+ - A HuggingFace token — get one at <https://huggingface.co/settings/tokens>
55
+ (a free **Read** token works). You **paste it into the app's UI**; it is not read
56
+ from the environment. (The headless smoke test reads it from `HF_TOKEN` /
57
+ `HUGGINGFACEHUB_API_TOKEN` instead.)
58
+ - A CUDA GPU is optional (Whisper falls back to CPU automatically)
59
+
60
+ ## Setup
61
+
62
+ ```bash
63
+ python -m venv .venv
64
+ # Windows (PowerShell): .venv\Scripts\Activate.ps1
65
+ # Git Bash: source .venv/Scripts/activate
66
+ pip install -r requirements.txt
67
+
68
+ cp .env.example .env # optional — tweak model ids / Whisper size
69
+ ```
70
+
71
+ `requirements.txt` includes the local **BLIP** captioner (`torch` + `transformers`,
72
+ CPU build). The BLIP model (~1 GB) downloads on first use and is cached. Captions
73
+ are nice-to-have: without a captioner you still get images + step text, and frame
74
+ selection uses timestamp + sharpness only.
75
+
76
+ To run BLIP on a local **NVIDIA GPU**, reinstall a CUDA build of torch — it's used
77
+ automatically when available:
78
+
79
+ ```bash
80
+ pip install torch --index-url https://download.pytorch.org/whl/cu124
81
+ ```
82
+
83
+ ### HuggingFace token
84
+
85
+ The **app takes your token from the UI** — paste it into the "🔑 HuggingFace token"
86
+ box at the top of the page. When you enter it (local single-user mode), the app
87
+ sets it as the process `HF_TOKEN` (in memory only, never written to disk) so every
88
+ HuggingFace operation — the guide LLM and model downloads — authenticates with it,
89
+ overriding any stale token already in your environment.
90
+
91
+ **Shared / multi-user deployments:** if you launch with `DOCUMAKER_SHARE=1` or a
92
+ non-localhost `DOCUMAKER_SERVER_NAME`, the app switches to per-session tokens and
93
+ **does not** touch the global `HF_TOKEN` — so one user's token can never leak to
94
+ another. The token is still threaded directly to that user's LLM/caption calls (the
95
+ guide LLM clients are created per request, never cached across sessions).
96
+
97
+ The **headless smoke test** (`scripts/smoke_test.py`) instead reads the token from
98
+ `HF_TOKEN` / `HUGGINGFACEHUB_API_TOKEN` and validates which one actually
99
+ authenticates (handy if one is stale).
100
+
101
+ ## Run
102
+
103
+ ```bash
104
+ python app.py
105
+ ```
106
+
107
+ Open the printed local URL, then:
108
+
109
+ 1. **Paste your HuggingFace token** into the 🔑 box at the top.
110
+ 2. **Upload & preview** a video.
111
+ 3. **Capture frames** — scrub the seek bar and click *📸 Capture current frame*,
112
+ and/or click *✨ Auto-extract frames* for scene-based snapshots.
113
+ 4. **Transcribe audio** (Whisper, local). Edit the transcript if you like.
114
+ 5. **Generate step-by-step guide** (HF LLM) and review the steps.
115
+ 6. **Build DOCX** — images are matched to steps and captioned, then download `guide.docx`.
116
+
117
+ ## Quick backend check (no UI)
118
+
119
+ ```bash
120
+ python scripts/make_sample.py # synthesizes work/sample/sample.mp4 (4 scenes + narration)
121
+ python scripts/smoke_test.py # runs the full pipeline and asserts a valid DOCX
122
+ ```
123
+
124
+ The smoke test falls back to a naive draft if the HF API is unreachable, so DOCX
125
+ assembly is always exercised.
126
+
127
+ ## Deploy to HuggingFace Spaces
128
+
129
+ This repo is Spaces-ready: the YAML header in this README, `packages.txt` (installs
130
+ `ffmpeg`), and a single `requirements.txt` are all the platform needs. The app
131
+ auto-detects Spaces (via `SPACE_ID`), binds to `0.0.0.0`, and switches to
132
+ **multi-user-safe tokens** — each visitor pastes their own HF token in the UI and it
133
+ never touches the shared environment.
134
+
135
+ 1. Create a new **Gradio** Space at <https://huggingface.co/new-space>.
136
+ 2. Push this repo to it:
137
+ ```bash
138
+ git init && git add -A && git commit -m "DocuMaker"
139
+ git remote add space https://huggingface.co/spaces/<your-username>/<space-name>
140
+ git push space main
141
+ ```
142
+ (Or drag the files into the Space's *Files* tab.)
143
+ 3. The Space builds and starts automatically. Visitors paste their **own** HF token —
144
+ you don't expose yours or share its quota.
145
+
146
+ Notes for the free CPU tier: transcription and BLIP run on CPU (slower but fine for a
147
+ demo); set `DOCUMAKER_WHISPER_MODEL=base` in the Space *Settings → Variables* for
148
+ snappier transcription. You can push to **both** GitHub and the Space (add both as
149
+ git remotes).
150
+
151
+ ## Configuration
152
+
153
+ All settings are environment variables (see [.env.example](.env.example)). Highlights:
154
+
155
+ | Variable | Default | Purpose |
156
+ |---|---|---|
157
+ | `DOCUMAKER_LLM_MODEL` | `Qwen/Qwen2.5-7B-Instruct` | Text LLM (any HF instruct model) |
158
+ | `DOCUMAKER_VLM_MODEL` | `Qwen/Qwen2-VL-7B-Instruct` | API vision model tried before local BLIP |
159
+ | `DOCUMAKER_LOCAL_CAPTION_MODEL` | `Salesforce/blip-image-captioning-base` | Local captioner |
160
+ | `DOCUMAKER_ENABLE_VISION` | `1` | Set `0` to skip captioning |
161
+ | `DOCUMAKER_WHISPER_MODEL` | `small` | `tiny`…`large-v3` |
162
+ | `DOCUMAKER_WHISPER_DEVICE` | `auto` | `auto` / `cuda` / `cpu` |
163
+ | `DOCUMAKER_SCENE_THRESHOLD` | `27.0` | Lower = more auto-frames |
164
+ | `DOCUMAKER_SHARE` | `0` | `1` = Gradio public share link (enables multi-user-safe tokens) |
165
+ | `DOCUMAKER_SERVER_NAME` | `127.0.0.1` | Bind address; non-localhost enables multi-user-safe tokens |
166
+
167
+ ## Troubleshooting
168
+
169
+ - **Whisper CUDA errors / cuDNN not found:** faster-whisper (CTranslate2) needs the
170
+ NVIDIA CUDA libraries. Either install them —
171
+ `pip install nvidia-cublas-cu12 nvidia-cudnn-cu12` — or force CPU with
172
+ `DOCUMAKER_WHISPER_DEVICE=cpu` (slower but always works).
173
+ - **LLM call failed / model not available:** free-tier model availability changes.
174
+ Set `DOCUMAKER_LLM_MODEL` to another available instruct model, or pin a provider
175
+ with `DOCUMAKER_LLM_PROVIDER`.
176
+ - **No captions in the DOCX:** the API vision model usually isn't served on free HF
177
+ accounts ("not supported by any provider you have enabled"), so DocuMaker uses
178
+ local BLIP (installed via `requirements.txt`). Frame *selection* and images still
179
+ work without it.
180
+ - **Video doesn't preview:** the app serves files from `work/` via Gradio's
181
+ `allowed_paths` (URL prefix `/gradio_api/file=`). Make sure the upload completed;
182
+ very large files take a moment.
183
+ - **No images in the DOCX:** capture or auto-extract frames before *Build DOCX*.
184
+ Steps with a timestamp but no nearby frame pull a fresh frame from the video.
185
+
186
+ ## Project layout
187
+
188
+ ```
189
+ app.py Gradio UI + event wiring
190
+ src/config.py env-driven settings
191
+ src/video.py ffmpeg audio extract / duration / frame@timestamp
192
+ src/frames.py scene detection, dedup, manual-capture decode
193
+ src/transcribe.py faster-whisper (CUDA→CPU fallback)
194
+ src/llm.py HF Inference: transcript → structured step JSON
195
+ src/vision.py VLM captioning (HF API) + local BLIP fallback
196
+ src/guide.py align frames↔steps, caption
197
+ src/docx_export.py python-docx assembly
198
+ src/web/player.html custom HTML5 player for seek + snapshot
199
+ scripts/make_sample.py synthesize a test clip
200
+ scripts/smoke_test.py headless end-to-end check
201
+ ```
app.py ADDED
@@ -0,0 +1,360 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """DocuMaker — Gradio app: video -> frames + transcript -> LLM guide -> DOCX.
2
+
3
+ Run with: python app.py
4
+ Then open the printed local URL in your browser.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import os
9
+ import re
10
+ import shutil
11
+ import uuid
12
+ from dataclasses import asdict
13
+ from pathlib import Path
14
+
15
+ import gradio as gr
16
+
17
+ from src import config
18
+ from src import docx_export
19
+ from src import guide as guide_lib
20
+ from src import llm
21
+ from src import transcribe as transcribe_lib
22
+ from src import video
23
+ from src.frames import FrameRecord, extract_auto_frames, save_manual_frame
24
+ from src.transcribe import Transcript, TranscriptSegment
25
+
26
+ # --- Static assets -----------------------------------------------------------
27
+ PLAYER_TEMPLATE = (Path(__file__).parent / "src" / "web" / "player.html").read_text(
28
+ encoding="utf-8"
29
+ )
30
+
31
+ # JS run in the browser when the user clicks "Capture current frame". It draws
32
+ # the *currently displayed* video frame onto a canvas and returns the PNG data
33
+ # URL + the playback time. The 4 returned values replace the 4 wired inputs
34
+ # (session and frames pass through unchanged; the last two carry the capture).
35
+ CAPTURE_JS = """
36
+ (session, frames, _url, _time) => {
37
+ const v = document.getElementById('dm-video');
38
+ if (!v || !v.videoWidth) { return [session, frames, '', 0]; }
39
+ const c = document.createElement('canvas');
40
+ c.width = v.videoWidth;
41
+ c.height = v.videoHeight;
42
+ c.getContext('2d').drawImage(v, 0, 0, c.width, c.height);
43
+ let url = '';
44
+ try { url = c.toDataURL('image/png'); } catch (e) { url = ''; }
45
+ return [session, frames, url, v.currentTime];
46
+ }
47
+ """
48
+
49
+
50
+ # --- Small helpers -----------------------------------------------------------
51
+ def _fmt_ts(seconds: float | int | None) -> str:
52
+ s = int(seconds or 0)
53
+ return f"{s // 60:02d}:{s % 60:02d}"
54
+
55
+
56
+ def _player_html(file_path_posix: str) -> str:
57
+ # The template builds the Gradio file URL (with a legacy-prefix fallback).
58
+ return PLAYER_TEMPLATE.replace("__VIDEO_PATH__", file_path_posix)
59
+
60
+
61
+ def _gallery_value(frames: list[dict]) -> list[tuple[str, str]]:
62
+ return [
63
+ (f["path"], f"{f.get('source', '')} @ {_fmt_ts(f.get('timestamp', 0))}")
64
+ for f in frames
65
+ ]
66
+
67
+
68
+ def _parse_timestamped_text(text: str) -> Transcript:
69
+ """Re-parse the (possibly user-edited) '[mm:ss] text' transcript box."""
70
+ segments: list[TranscriptSegment] = []
71
+ for line in text.splitlines():
72
+ m = re.match(r"\s*\[(\d{1,2}):(\d{2})\]\s*(.*)", line)
73
+ if m:
74
+ mm, ss, body = m.groups()
75
+ start = float(int(mm) * 60 + int(ss))
76
+ segments.append(TranscriptSegment(start=start, end=start, text=body))
77
+ elif line.strip():
78
+ if segments:
79
+ segments[-1].text += " " + line.strip()
80
+ else:
81
+ segments.append(TranscriptSegment(0.0, 0.0, line.strip()))
82
+ return Transcript(segments=segments)
83
+
84
+
85
+ def _draft_to_md(draft: llm.GuideDraft) -> str:
86
+ lines = [f"## {draft.title}", "", draft.intro or "", ""]
87
+ if draft.prerequisites:
88
+ lines.append("**Prerequisites**")
89
+ lines += [f"- {p}" for p in draft.prerequisites]
90
+ lines.append("")
91
+ for i, step in enumerate(draft.steps, start=1):
92
+ ts = ""
93
+ if step.approx_timestamp is not None:
94
+ ts = f" _(~{_fmt_ts(step.approx_timestamp)})_"
95
+ lines.append(f"**Step {i}: {step.heading}**{ts}")
96
+ lines.append(step.text)
97
+ lines.append("")
98
+ return "\n".join(lines)
99
+
100
+
101
+ # --- Event handlers ----------------------------------------------------------
102
+ def init_session():
103
+ sid = uuid.uuid4().hex[:12]
104
+ config.session_dir(sid)
105
+ return sid, [], "", None, None, "Session ready — upload a video to begin."
106
+
107
+
108
+ def on_upload(file_path: str | None, session: str):
109
+ if not file_path:
110
+ return gr.update(), "", "No file received."
111
+ sdir = config.session_dir(session)
112
+ dest = sdir / f"source{Path(file_path).suffix or '.mp4'}"
113
+ shutil.copyfile(file_path, dest)
114
+ duration = video.get_duration(dest)
115
+ html = _player_html(dest.as_posix())
116
+ return (
117
+ html,
118
+ str(dest),
119
+ f"Loaded video ({duration:.1f}s). Seek + capture frames, or auto-extract.",
120
+ )
121
+
122
+
123
+ def on_capture(session: str, frames: list[dict], data_url: str, current_time: float):
124
+ rec = save_manual_frame(data_url, current_time or 0.0, config.session_dir(session))
125
+ if rec is None:
126
+ return _gallery_value(frames), frames, "Capture failed — let the video load, then retry."
127
+ frames = frames + [asdict(rec)]
128
+ return (
129
+ _gallery_value(frames),
130
+ frames,
131
+ f"Captured frame at {_fmt_ts(rec.timestamp)} ({len(frames)} total).",
132
+ )
133
+
134
+
135
+ def on_auto(session: str, frames: list[dict], video_path: str, progress=gr.Progress()):
136
+ if not video_path:
137
+ return _gallery_value(frames), frames, "Upload a video first."
138
+ progress(0.1, "Detecting scenes…")
139
+ recs = extract_auto_frames(video_path, config.session_dir(session))
140
+ merged = frames + [asdict(r) for r in recs]
141
+ progress(1.0, "Done.")
142
+ return (
143
+ _gallery_value(merged),
144
+ merged,
145
+ f"Auto-extracted {len(recs)} frames ({len(merged)} total).",
146
+ )
147
+
148
+
149
+ def on_clear():
150
+ return [], [], "Cleared all frames."
151
+
152
+
153
+ def on_transcribe(session: str, video_path: str, progress=gr.Progress()):
154
+ if not video_path:
155
+ return "", None, "Upload a video first."
156
+ sdir = config.session_dir(session)
157
+ progress(0.05, "Extracting audio…")
158
+ wav = video.extract_audio(video_path, sdir / "audio.wav")
159
+ progress(0.1, "Loading Whisper…")
160
+ tr = transcribe_lib.transcribe(wav, progress=progress)
161
+ return (
162
+ tr.to_timestamped_text(),
163
+ tr,
164
+ f"Transcribed {len(tr.segments)} segments "
165
+ f"(lang={tr.language or '?'}, device={tr.device or 'cpu'}).",
166
+ )
167
+
168
+
169
+ def on_token_set(hf_token: str):
170
+ """Mirror the UI token into the HF_TOKEN environment variable."""
171
+ token = config.apply_token(hf_token)
172
+ if token:
173
+ return "🔑 HuggingFace token set for this session."
174
+ return "Enter your HuggingFace token to generate the guide."
175
+
176
+
177
+ def on_generate(transcript_text: str, transcript_obj, hf_token: str, progress=gr.Progress()):
178
+ token = config.apply_token(hf_token)
179
+ if not token:
180
+ return "", None, "⚠️ Enter your HuggingFace token above to generate the guide."
181
+ tr = _parse_timestamped_text(transcript_text) if transcript_text.strip() else transcript_obj
182
+ if tr is None or not tr.segments:
183
+ return "", None, "Transcribe the audio first (or paste a transcript)."
184
+ try:
185
+ draft = llm.build_guide_draft(tr, token=token, progress=progress)
186
+ except RuntimeError as exc:
187
+ return "", None, f"⚠️ {exc}"
188
+ if not draft.steps:
189
+ return "", None, "The LLM returned no steps — try a different DOCUMAKER_LLM_MODEL."
190
+ return _draft_to_md(draft), draft, f"Drafted {len(draft.steps)} steps. Review, then build the DOCX."
191
+
192
+
193
+ def on_build(
194
+ session: str,
195
+ draft,
196
+ frames: list[dict],
197
+ video_path: str,
198
+ do_caption: bool,
199
+ hf_token: str,
200
+ progress=gr.Progress(),
201
+ ):
202
+ if draft is None or not getattr(draft, "steps", None):
203
+ return None, "Generate the step-by-step guide first."
204
+ token = config.apply_token(hf_token)
205
+ recs = [FrameRecord(**d) for d in frames]
206
+ progress(0.1, "Matching images to steps…")
207
+ g = guide_lib.assemble_guide(
208
+ draft,
209
+ recs,
210
+ video_path=video_path or None,
211
+ session_dir=config.session_dir(session),
212
+ do_caption=do_caption,
213
+ token=token,
214
+ progress=progress,
215
+ )
216
+ out = config.session_dir(session) / "guide.docx"
217
+ docx_export.export_docx(g, out)
218
+ n_imgs = sum(1 for s in g.steps if s.image_path)
219
+ progress(1.0, "Done.")
220
+ return str(out), f"Built {out.name}: {len(g.steps)} steps, {n_imgs} images."
221
+
222
+
223
+ # --- UI ----------------------------------------------------------------------
224
+ def build_ui() -> gr.Blocks:
225
+ with gr.Blocks(title="DocuMaker") as demo:
226
+ gr.Markdown(
227
+ "# 🎬➜📄 DocuMaker\n"
228
+ "Turn a tutorial video into a step-by-step **DOCX** guide with screenshots. "
229
+ "Transcription runs locally (Whisper); the guide text uses a free HuggingFace "
230
+ "model via your token; image captions use local BLIP."
231
+ )
232
+
233
+ with gr.Accordion("🔑 HuggingFace token (required to generate the guide)", open=True):
234
+ hf_token = gr.Textbox(
235
+ label="HuggingFace token",
236
+ placeholder="hf_… (paste your token — used only for this session, never stored)",
237
+ type="password",
238
+ autofocus=True,
239
+ )
240
+ gr.Markdown(
241
+ "Create a token at "
242
+ "[huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) "
243
+ "(a free **Read** token works). It's kept in memory for this session only."
244
+ )
245
+
246
+ session_state = gr.State("")
247
+ frames_state = gr.State([])
248
+ video_state = gr.State("")
249
+ transcript_state = gr.State(None)
250
+ draft_state = gr.State(None)
251
+ # Hidden carriers that the capture JS fills in.
252
+ cap_url = gr.Textbox(visible=False)
253
+ cap_time = gr.Number(visible=False)
254
+
255
+ with gr.Row():
256
+ with gr.Column(scale=3):
257
+ gr.Markdown("### 1 · Upload & preview")
258
+ upload = gr.File(
259
+ label="Upload a video",
260
+ type="filepath",
261
+ file_types=[".mp4", ".mov", ".mkv", ".webm", ".avi", ".m4v", ".mpg", ".mpeg"],
262
+ )
263
+ player = gr.HTML()
264
+ with gr.Row():
265
+ capture_btn = gr.Button("📸 Capture current frame", variant="primary")
266
+ auto_btn = gr.Button("✨ Auto-extract frames")
267
+ with gr.Column(scale=2):
268
+ gr.Markdown("### Captured frames")
269
+ gallery = gr.Gallery(
270
+ label="Frames pool", columns=3, height=320, object_fit="contain", allow_preview=True
271
+ )
272
+ clear_btn = gr.Button("🗑️ Clear frames")
273
+
274
+ gr.Markdown("### 2 · Transcribe → 3 · Generate guide")
275
+ with gr.Row():
276
+ with gr.Column():
277
+ transcribe_btn = gr.Button("🎙️ Transcribe audio (Whisper)")
278
+ transcript_box = gr.Textbox(
279
+ label="Transcript (editable — '[mm:ss] text' per line)", lines=12
280
+ )
281
+ with gr.Column():
282
+ generate_btn = gr.Button("📝 Generate step-by-step guide (LLM)")
283
+ guide_md = gr.Markdown()
284
+
285
+ gr.Markdown("### 4 · Build the document")
286
+ with gr.Row():
287
+ caption_chk = gr.Checkbox(value=config.ENABLE_VISION, label="Caption images with vision model")
288
+ build_btn = gr.Button("📄 Build DOCX", variant="primary")
289
+ download = gr.File(label="Download guide.docx")
290
+
291
+ status = gr.Markdown("")
292
+
293
+ # --- wiring ---
294
+ demo.load(
295
+ init_session,
296
+ outputs=[session_state, frames_state, transcript_box, transcript_state, draft_state, status],
297
+ )
298
+ # Mirror the token into HF_TOKEN as soon as it's entered (so even model
299
+ # downloads during transcription authenticate with it).
300
+ hf_token.blur(on_token_set, [hf_token], [status])
301
+ hf_token.submit(on_token_set, [hf_token], [status])
302
+ upload.change(on_upload, [upload, session_state], [player, video_state, status])
303
+
304
+ capture_btn.click(
305
+ on_capture,
306
+ inputs=[session_state, frames_state, cap_url, cap_time],
307
+ outputs=[gallery, frames_state, status],
308
+ js=CAPTURE_JS,
309
+ )
310
+ auto_btn.click(on_auto, [session_state, frames_state, video_state], [gallery, frames_state, status])
311
+ clear_btn.click(on_clear, None, [gallery, frames_state, status])
312
+
313
+ transcribe_btn.click(
314
+ on_transcribe, [session_state, video_state], [transcript_box, transcript_state, status]
315
+ )
316
+ generate_btn.click(
317
+ on_generate,
318
+ [transcript_box, transcript_state, hf_token],
319
+ [guide_md, draft_state, status],
320
+ )
321
+ build_btn.click(
322
+ on_build,
323
+ [session_state, draft_state, frames_state, video_state, caption_chk, hf_token],
324
+ [download, status],
325
+ )
326
+
327
+ return demo
328
+
329
+
330
+ if __name__ == "__main__":
331
+ # HuggingFace Spaces sets SPACE_ID and serves the app publicly, so treat it as
332
+ # multi-user and bind to all interfaces (Spaces expects 0.0.0.0:7860).
333
+ on_spaces = bool(os.getenv("SPACE_ID"))
334
+ share = os.getenv("DOCUMAKER_SHARE", "0").lower() in ("1", "true", "yes")
335
+
336
+ if on_spaces:
337
+ server_name = "0.0.0.0"
338
+ multiuser = True
339
+ else:
340
+ server_name = os.getenv("DOCUMAKER_SERVER_NAME", "127.0.0.1")
341
+ multiuser = share or server_name not in ("127.0.0.1", "localhost", "::1")
342
+
343
+ # In shared/multi-user mode keep each user's token in their own session: do
344
+ # NOT mirror it into the process-global environment.
345
+ config.set_allow_env_token(not multiuser)
346
+ if multiuser:
347
+ print(
348
+ "DocuMaker: shared/multi-user mode — HF tokens are kept per session "
349
+ "(HF_TOKEN env is not set)."
350
+ )
351
+
352
+ app = build_ui().queue()
353
+ app.launch(
354
+ theme=gr.themes.Soft(),
355
+ allowed_paths=[str(config.WORK_DIR)],
356
+ share=share and not on_spaces, # Spaces provides its own URL — no tunnel
357
+ server_name=server_name,
358
+ show_error=True,
359
+ inbrowser=not multiuser,
360
+ )
packages.txt ADDED
@@ -0,0 +1 @@
 
 
1
+ ffmpeg
requirements.txt ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DocuMaker — single requirements file (HuggingFace Spaces installs only this one).
2
+
3
+ # ---- PyTorch CPU wheels: keeps the Spaces / CPU image small. ----
4
+ # For a local NVIDIA GPU instead, reinstall torch from the CUDA index:
5
+ # pip install torch --index-url https://download.pytorch.org/whl/cu124
6
+ --extra-index-url https://download.pytorch.org/whl/cpu
7
+
8
+ # Core app / UI
9
+ gradio>=6.0
10
+ python-dotenv>=1.0
11
+
12
+ # Transcription (local, CTranslate2 — no torch needed)
13
+ faster-whisper>=1.0.3
14
+
15
+ # Frame extraction / scene detection / dedup
16
+ # headless OpenCV: no system GUI libs (libGL) required — important on Spaces.
17
+ scenedetect>=0.6.4
18
+ opencv-python-headless>=4.9
19
+ Pillow>=10.2
20
+ imagehash>=4.3
21
+ numpy>=1.26
22
+
23
+ # HuggingFace Inference API (text LLM) + token validation
24
+ huggingface_hub>=0.27
25
+
26
+ # Local BLIP image captioner (vision)
27
+ transformers>=4.44
28
+ torch>=2.2
29
+
30
+ # DOCX output
31
+ python-docx>=1.1
scripts/make_sample.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Synthesize a tiny tutorial-style test clip with no external assets.
2
+
3
+ Produces ``work/sample/sample.mp4``: four solid-color scenes (so scene detection
4
+ has clear cuts) plus spoken narration generated with the built-in Windows SAPI
5
+ voice (so Whisper has real speech to transcribe).
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import os
10
+ import shutil
11
+ import subprocess
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ try: # Windows consoles default to cp1252 and choke on non-ASCII output.
16
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
17
+ except Exception:
18
+ pass
19
+
20
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
21
+
22
+ from src import config # noqa: E402
23
+
24
+
25
+ def _powershell() -> str:
26
+ """Locate powershell.exe (it lives in a v1.0\\ subdir not always on PATH)."""
27
+ found = shutil.which("powershell") or shutil.which("pwsh")
28
+ if found:
29
+ return found
30
+ candidate = Path(os.environ.get("SystemRoot", r"C:\Windows")) / (
31
+ "System32/WindowsPowerShell/v1.0/powershell.exe"
32
+ )
33
+ if candidate.exists():
34
+ return str(candidate)
35
+ raise RuntimeError("Could not find powershell.exe to synthesize narration audio.")
36
+
37
+ NARRATION = (
38
+ "Welcome to this quick tutorial. "
39
+ "First, open the application from your desktop. "
40
+ "Next, click the File menu in the top left corner. "
41
+ "Then choose the Export option from the list. "
42
+ "Finally, pick a folder and save your document."
43
+ )
44
+ # Textured, visually distinct patterns so scene detection finds clear cuts and
45
+ # perceptual-hash dedup keeps them (solid colors collapse to one pHash).
46
+ PATTERNS = [
47
+ "smptebars=size=1280x720",
48
+ "testsrc2=size=1280x720",
49
+ "rgbtestsrc=size=1280x720",
50
+ "mandelbrot=size=1280x720",
51
+ ]
52
+
53
+
54
+ def _run(cmd: list[str]) -> None:
55
+ print("+", " ".join(cmd))
56
+ proc = subprocess.run(cmd, capture_output=True, text=True)
57
+ if proc.returncode != 0:
58
+ raise RuntimeError(f"Command failed: {' '.join(cmd)}\n{proc.stderr}")
59
+
60
+
61
+ def make_narration(out_wav: Path) -> None:
62
+ ps = (
63
+ "Add-Type -AssemblyName System.Speech; "
64
+ "$s = New-Object System.Speech.Synthesis.SpeechSynthesizer; "
65
+ f"$s.SetOutputToWaveFile('{out_wav.as_posix()}'); "
66
+ f"$s.Speak('{NARRATION}'); $s.Dispose();"
67
+ )
68
+ _run([_powershell(), "-NoProfile", "-Command", ps])
69
+
70
+
71
+ def make_slides(dirpath: Path) -> list[Path]:
72
+ paths = []
73
+ for i, pattern in enumerate(PATTERNS):
74
+ p = dirpath / f"slide_{i}.png"
75
+ _run(
76
+ [
77
+ config.FFMPEG_BIN, "-y",
78
+ "-f", "lavfi", "-i", pattern,
79
+ "-frames:v", "1", str(p),
80
+ ]
81
+ )
82
+ paths.append(p)
83
+ return paths
84
+
85
+
86
+ def main() -> Path:
87
+ out_dir = config.WORK_DIR / "sample"
88
+ out_dir.mkdir(parents=True, exist_ok=True)
89
+
90
+ narration = out_dir / "narration.wav"
91
+ make_narration(narration)
92
+ slides = make_slides(out_dir)
93
+
94
+ listfile = out_dir / "slides.txt"
95
+ lines: list[str] = []
96
+ for p in slides:
97
+ lines.append(f"file '{p.as_posix()}'")
98
+ lines.append("duration 3")
99
+ lines.append(f"file '{slides[-1].as_posix()}'") # concat needs the last file twice
100
+ listfile.write_text("\n".join(lines), encoding="utf-8")
101
+
102
+ out_mp4 = out_dir / "sample.mp4"
103
+ _run(
104
+ [
105
+ config.FFMPEG_BIN, "-y",
106
+ "-f", "concat", "-safe", "0", "-i", str(listfile),
107
+ "-i", str(narration),
108
+ "-c:v", "libx264", "-pix_fmt", "yuv420p",
109
+ "-c:a", "aac", "-shortest", str(out_mp4),
110
+ ]
111
+ )
112
+ print("Sample video:", out_mp4)
113
+ return out_mp4
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
scripts/smoke_test.py ADDED
@@ -0,0 +1,95 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Headless end-to-end backend test (no Gradio UI).
2
+
3
+ Runs: sample video -> audio -> Whisper -> scene frames -> LLM step draft ->
4
+ assemble -> DOCX, asserting each stage produced output. The LLM step falls back
5
+ to a naive sentence-per-step draft if the HuggingFace API is unreachable, so the
6
+ DOCX assembly is always validated.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import sys
11
+ from pathlib import Path
12
+
13
+ try: # Windows consoles default to cp1252 and choke on emoji/non-ASCII output.
14
+ sys.stdout.reconfigure(encoding="utf-8", errors="replace")
15
+ except Exception:
16
+ pass
17
+
18
+ ROOT = Path(__file__).resolve().parent.parent
19
+ sys.path.insert(0, str(ROOT))
20
+ sys.path.insert(0, str(ROOT / "scripts"))
21
+
22
+ import make_sample # noqa: E402
23
+ from src import config, docx_export, video # noqa: E402
24
+ from src import frames as frames_lib # noqa: E402
25
+ from src import guide as guide_lib # noqa: E402
26
+ from src import llm, transcribe # noqa: E402
27
+ from src.llm import GuideDraft, StepDraft # noqa: E402
28
+
29
+
30
+ def naive_draft(tr) -> GuideDraft:
31
+ steps = [
32
+ StepDraft(heading=f"Step {i}", text=seg.text.strip(), approx_timestamp=seg.start)
33
+ for i, seg in enumerate(tr.segments, start=1)
34
+ if seg.text.strip()
35
+ ]
36
+ return GuideDraft(title="Sample Guide", intro="Generated offline.", steps=steps)
37
+
38
+
39
+ def main() -> None:
40
+ sample = config.WORK_DIR / "sample" / "sample.mp4"
41
+ if not sample.exists():
42
+ sample = make_sample.main()
43
+
44
+ sdir = config.session_dir("smoke")
45
+
46
+ # The app takes the token from the UI; this headless test reads it from the
47
+ # environment (validating which of HF_TOKEN / HUGGINGFACEHUB_API_TOKEN works).
48
+ token = config.resolve_hf_token()
49
+ print(f"HF token: {'present' if token else 'MISSING (LLM step will use naive fallback)'}")
50
+
51
+ print("\n[1/5] Extract audio + transcribe…")
52
+ wav = video.extract_audio(sample, sdir / "audio.wav")
53
+ tr = transcribe.transcribe(wav)
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
+
62
+ print("[3/5] Build guide draft (LLM)…")
63
+ try:
64
+ draft = llm.build_guide_draft(tr, token=token)
65
+ if not draft.steps:
66
+ raise RuntimeError("LLM returned no steps")
67
+ print(f" LLM ok: '{draft.title}' ({len(draft.steps)} steps)")
68
+ except Exception as exc:
69
+ print(f" LLM unavailable ({exc}); using naive fallback draft.")
70
+ draft = naive_draft(tr)
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…")
79
+ out = sdir / "guide.docx"
80
+ docx_export.export_docx(g, out)
81
+ assert out.exists() and out.stat().st_size > 0, "DOCX not written"
82
+
83
+ for s in g.steps:
84
+ ts = f"{int((s.timestamp or 0))//60:02d}:{int((s.timestamp or 0))%60:02d}"
85
+ print(f" - [{ts}] {s.heading!r}: img={'yes' if s.image_path else 'no'} cap={s.caption!r}")
86
+ n_imgs = sum(1 for s in g.steps if s.image_path)
87
+ n_caps = sum(1 for s in g.steps if s.caption)
88
+ print(
89
+ f"\nOK ✅ {out} ({out.stat().st_size} bytes, "
90
+ f"{len(g.steps)} steps, {n_imgs} images, {n_caps} captions)"
91
+ )
92
+
93
+
94
+ if __name__ == "__main__":
95
+ main()
src/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ """DocuMaker — turn a tutorial video into a step-by-step DOCX guide."""
2
+
3
+ __version__ = "0.1.0"
src/config.py ADDED
@@ -0,0 +1,142 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Central configuration for DocuMaker.
2
+
3
+ Every tunable is read from environment variables (optionally a local ``.env``
4
+ file), so model ids / devices can be swapped without touching code.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import functools
9
+ import os
10
+ from pathlib import Path
11
+
12
+ from dotenv import load_dotenv
13
+
14
+ # Project root = parent of the ``src`` package directory.
15
+ PROJECT_ROOT = Path(__file__).resolve().parent.parent
16
+
17
+ # Load .env from the project root if present (silently ignored if missing).
18
+ load_dotenv(PROJECT_ROOT / ".env")
19
+
20
+
21
+ def _flag(name: str, default: str = "0") -> bool:
22
+ return os.getenv(name, default).strip().lower() not in ("0", "false", "no", "")
23
+
24
+
25
+ # --- Paths -------------------------------------------------------------------
26
+ WORK_DIR = Path(os.getenv("DOCUMAKER_WORK_DIR", str(PROJECT_ROOT / "work"))).resolve()
27
+ WORK_DIR.mkdir(parents=True, exist_ok=True)
28
+
29
+ # --- HuggingFace credentials -------------------------------------------------
30
+ def _token_candidates() -> list[str]:
31
+ seen: set[str] = set()
32
+ out: list[str] = []
33
+ for value in (
34
+ os.getenv("DOCUMAKER_HF_TOKEN"),
35
+ os.getenv("HF_TOKEN"),
36
+ os.getenv("HUGGINGFACEHUB_API_TOKEN"),
37
+ ):
38
+ value = (value or "").strip()
39
+ if value and value not in seen:
40
+ seen.add(value)
41
+ out.append(value)
42
+ return out
43
+
44
+
45
+ # Whether a UI token may be mirrored into the process environment. True for the
46
+ # default local single-user app; turned off automatically for shared/multi-user
47
+ # launches so one user's token can't leak to another via the global environment.
48
+ _ALLOW_ENV_TOKEN = True
49
+
50
+
51
+ def set_allow_env_token(allowed: bool) -> None:
52
+ global _ALLOW_ENV_TOKEN
53
+ _ALLOW_ENV_TOKEN = bool(allowed)
54
+
55
+
56
+ def apply_token(token: str | None) -> str | None:
57
+ """Return the cleaned UI token and, **in single-user mode only**, mirror it
58
+ into the process ``HF_TOKEN``/``HUGGINGFACEHUB_API_TOKEN`` so huggingface_hub
59
+ (InferenceClient auto-discovery, model downloads) also uses it.
60
+
61
+ In multi-user/shared mode the environment is left untouched — the token is
62
+ still threaded explicitly to the LLM and captioner, so it stays scoped to the
63
+ caller's session and nothing leaks across users. Returns None if empty.
64
+ """
65
+ token = (token or "").strip()
66
+ if token and _ALLOW_ENV_TOKEN:
67
+ os.environ["HF_TOKEN"] = token
68
+ os.environ["HUGGINGFACEHUB_API_TOKEN"] = token
69
+ return token or None
70
+
71
+
72
+ @functools.lru_cache(maxsize=1)
73
+ def resolve_hf_token() -> str | None:
74
+ """Return the first *valid* HF token among the configured candidates.
75
+
76
+ Environments often have a stale ``HF_TOKEN`` alongside a working
77
+ ``HUGGINGFACEHUB_API_TOKEN`` (or vice versa). We validate via ``whoami`` and
78
+ pick the one that authenticates, then point huggingface_hub's own
79
+ auto-discovery (model downloads, etc.) at the same working token.
80
+ """
81
+ candidates = _token_candidates()
82
+ if not candidates:
83
+ return None
84
+
85
+ chosen = candidates[0]
86
+ try:
87
+ from huggingface_hub import whoami
88
+
89
+ for token in candidates:
90
+ try:
91
+ whoami(token=token)
92
+ chosen = token
93
+ break
94
+ except Exception:
95
+ continue
96
+ except Exception:
97
+ pass # offline / hub import issue — fall back to the first candidate
98
+
99
+ os.environ["HF_TOKEN"] = chosen
100
+ os.environ["HUGGINGFACEHUB_API_TOKEN"] = chosen
101
+ return chosen
102
+
103
+ # --- Text LLM (HF Inference API) --------------------------------------------
104
+ LLM_MODEL = os.getenv("DOCUMAKER_LLM_MODEL", "Qwen/Qwen2.5-7B-Instruct")
105
+ LLM_PROVIDER = os.getenv("DOCUMAKER_LLM_PROVIDER", "").strip() or None
106
+ LLM_MAX_TOKENS = int(os.getenv("DOCUMAKER_LLM_MAX_TOKENS", "2048"))
107
+ LLM_TEMPERATURE = float(os.getenv("DOCUMAKER_LLM_TEMPERATURE", "0.3"))
108
+ # Approx. characters of transcript per LLM chunk (keeps prompts within context).
109
+ LLM_CHUNK_CHARS = int(os.getenv("DOCUMAKER_LLM_CHUNK_CHARS", "6000"))
110
+
111
+ # --- Vision LLM (HF Inference API) + local fallback -------------------------
112
+ ENABLE_VISION = _flag("DOCUMAKER_ENABLE_VISION", "1")
113
+ VLM_MODEL = os.getenv("DOCUMAKER_VLM_MODEL", "Qwen/Qwen2-VL-7B-Instruct")
114
+ VLM_PROVIDER = os.getenv("DOCUMAKER_VLM_PROVIDER", "").strip() or None
115
+ LOCAL_CAPTION_MODEL = os.getenv(
116
+ "DOCUMAKER_LOCAL_CAPTION_MODEL", "Salesforce/blip-image-captioning-base"
117
+ )
118
+
119
+ # --- Whisper (local faster-whisper) -----------------------------------------
120
+ WHISPER_MODEL = os.getenv("DOCUMAKER_WHISPER_MODEL", "small")
121
+ WHISPER_DEVICE = os.getenv("DOCUMAKER_WHISPER_DEVICE", "auto").strip().lower()
122
+ # Blank => choose automatically per device (int8_float16 on CUDA, int8 on CPU).
123
+ WHISPER_COMPUTE_TYPE = os.getenv("DOCUMAKER_WHISPER_COMPUTE_TYPE", "").strip()
124
+
125
+ # --- Frame extraction --------------------------------------------------------
126
+ SCENE_THRESHOLD = float(os.getenv("DOCUMAKER_SCENE_THRESHOLD", "27.0"))
127
+ SCENE_MIN_LEN_SEC = float(os.getenv("DOCUMAKER_SCENE_MIN_LEN_SEC", "1.0"))
128
+ DEDUP_HASH_DISTANCE = int(os.getenv("DOCUMAKER_DEDUP_HASH_DISTANCE", "6"))
129
+
130
+ # --- DOCX --------------------------------------------------------------------
131
+ DOCX_IMAGE_WIDTH_INCHES = float(os.getenv("DOCUMAKER_DOCX_IMAGE_WIDTH_INCHES", "5.5"))
132
+
133
+ # --- External binaries -------------------------------------------------------
134
+ FFMPEG_BIN = os.getenv("DOCUMAKER_FFMPEG_BIN", "ffmpeg")
135
+ FFPROBE_BIN = os.getenv("DOCUMAKER_FFPROBE_BIN", "ffprobe")
136
+
137
+
138
+ def session_dir(session_id: str) -> Path:
139
+ """Return (creating if needed) the working directory for one session."""
140
+ d = WORK_DIR / session_id
141
+ (d / "frames").mkdir(parents=True, exist_ok=True)
142
+ return d
src/docx_export.py ADDED
@@ -0,0 +1,55 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Render a :class:`~src.guide.Guide` into a Word .docx with images + captions."""
2
+ from __future__ import annotations
3
+
4
+ from pathlib import Path
5
+
6
+ from . import config
7
+ from .guide import Guide
8
+
9
+
10
+ def export_docx(guide: Guide, out_path: str | Path) -> Path:
11
+ from docx import Document
12
+ from docx.enum.text import WD_ALIGN_PARAGRAPH
13
+ from docx.shared import Inches, Pt
14
+
15
+ out_path = Path(out_path)
16
+ out_path.parent.mkdir(parents=True, exist_ok=True)
17
+
18
+ doc = Document()
19
+ doc.add_heading(guide.title or "Step-by-Step Guide", level=0)
20
+
21
+ if guide.intro:
22
+ doc.add_paragraph(guide.intro)
23
+
24
+ if guide.prerequisites:
25
+ doc.add_heading("Prerequisites", level=1)
26
+ for item in guide.prerequisites:
27
+ doc.add_paragraph(item, style="List Bullet")
28
+
29
+ doc.add_heading("Steps", level=1)
30
+
31
+ figure_no = 0
32
+ for i, step in enumerate(guide.steps, start=1):
33
+ heading = step.heading.strip() if step.heading else ""
34
+ doc.add_heading(f"Step {i}: {heading}" if heading else f"Step {i}", level=2)
35
+
36
+ if step.text:
37
+ doc.add_paragraph(step.text)
38
+
39
+ if step.image_path and Path(step.image_path).exists():
40
+ try:
41
+ doc.add_picture(step.image_path, width=Inches(config.DOCX_IMAGE_WIDTH_INCHES))
42
+ doc.paragraphs[-1].alignment = WD_ALIGN_PARAGRAPH.CENTER
43
+ except Exception:
44
+ pass # skip an unreadable image rather than fail the export
45
+ else:
46
+ if step.caption:
47
+ figure_no += 1
48
+ caption_par = doc.add_paragraph()
49
+ caption_par.alignment = WD_ALIGN_PARAGRAPH.CENTER
50
+ run = caption_par.add_run(f"Figure {figure_no}: {step.caption}")
51
+ run.italic = True
52
+ run.font.size = Pt(9)
53
+
54
+ doc.save(str(out_path))
55
+ return out_path
src/frames.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Frame extraction: automatic (scene detection) and manual (browser capture).
2
+
3
+ A :class:`FrameRecord` is the common unit passed around the app and into the
4
+ guide builder. Automatic frames come from PySceneDetect scene midpoints (with a
5
+ uniform-sampling fallback for single-scene videos) and are de-duplicated with a
6
+ perceptual hash. Manual frames arrive as base64 data URLs from the browser.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import binascii
12
+ from dataclasses import dataclass
13
+ from pathlib import Path
14
+
15
+ import imagehash
16
+ from PIL import Image
17
+
18
+ from . import config, video
19
+
20
+
21
+ @dataclass
22
+ class FrameRecord:
23
+ """One extracted frame plus where it came from in the video."""
24
+
25
+ path: str
26
+ timestamp: float
27
+ source: str = "auto" # "auto" | "manual"
28
+ caption: str | None = None
29
+
30
+ @property
31
+ def label(self) -> str:
32
+ mm, ss = divmod(int(self.timestamp), 60)
33
+ return f"{self.source} @ {mm:02d}:{ss:02d}"
34
+
35
+
36
+ def detect_scenes(video_path: str | Path) -> list[tuple[float, float]]:
37
+ """Return a list of (start_sec, end_sec) scene spans via PySceneDetect."""
38
+ try:
39
+ from scenedetect import ContentDetector, detect
40
+
41
+ scenes = detect(str(video_path), ContentDetector(threshold=config.SCENE_THRESHOLD))
42
+ except Exception:
43
+ # Detection is best-effort; callers fall back to uniform sampling.
44
+ return []
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]
52
+ else:
53
+ # Single static scene (or detection failed): sample uniformly.
54
+ duration = video.get_duration(video_path) or 60.0
55
+ count = min(max_frames, max(3, int(duration // 5)))
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
62
+ picks = sorted({round(i * last / (max_frames - 1)) for i in range(max_frames)})
63
+ timestamps = [timestamps[i] for i in picks]
64
+ return timestamps
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)
79
+ except Exception:
80
+ continue
81
+ records.append(FrameRecord(path=str(out), timestamp=ts, source="auto"))
82
+ return dedup_records(records)
83
+
84
+
85
+ def dedup_records(
86
+ records: list[FrameRecord], distance: int | None = None
87
+ ) -> list[FrameRecord]:
88
+ """Drop near-identical frames using perceptual hashing (pHash)."""
89
+ distance = config.DEDUP_HASH_DISTANCE if distance is None else distance
90
+ kept: list[FrameRecord] = []
91
+ hashes: list[imagehash.ImageHash] = []
92
+ for rec in records:
93
+ try:
94
+ with Image.open(rec.path) as im:
95
+ phash = imagehash.phash(im)
96
+ except Exception:
97
+ continue
98
+ if any((phash - kept_hash) <= distance for kept_hash in hashes):
99
+ Path(rec.path).unlink(missing_ok=True) # remove the redundant file
100
+ continue
101
+ hashes.append(phash)
102
+ kept.append(rec)
103
+ return kept
104
+
105
+
106
+ def save_manual_frame(
107
+ data_url: str, timestamp: float, session_dir: str | Path
108
+ ) -> FrameRecord | None:
109
+ """Decode a base64 image data URL captured in the browser and save it."""
110
+ if not data_url:
111
+ return None
112
+ frames_dir = Path(session_dir) / "frames"
113
+ frames_dir.mkdir(parents=True, exist_ok=True)
114
+
115
+ b64 = data_url.split(",", 1)[1] if "," in data_url else data_url
116
+ try:
117
+ raw = base64.b64decode(b64)
118
+ except (binascii.Error, ValueError):
119
+ return None
120
+
121
+ ts = float(timestamp or 0.0)
122
+ out = frames_dir / f"manual_{int(ts * 1000):08d}.png"
123
+ out.write_bytes(raw)
124
+ return FrameRecord(path=str(out), timestamp=ts, source="manual")
src/guide.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Assemble a final guide: match a frame to each step, then caption it.
2
+
3
+ Takes the LLM's :class:`~src.llm.GuideDraft` (pure text) plus the pool of
4
+ extracted :class:`~src.frames.FrameRecord` s and produces a :class:`Guide` where
5
+ each step carries the best-matching image and a caption. If a step has a
6
+ timestamp but no nearby frame, a fresh frame is pulled from the video so every
7
+ step can be illustrated.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from dataclasses import dataclass, field
13
+ from pathlib import Path
14
+ from typing import Callable
15
+
16
+ from . import config, video, vision
17
+ from .frames import FrameRecord
18
+ from .llm import GuideDraft
19
+
20
+
21
+ @dataclass
22
+ class GuideStep:
23
+ heading: str
24
+ text: str
25
+ timestamp: float | None = None
26
+ image_path: str | None = None
27
+ caption: str | None = None
28
+
29
+
30
+ @dataclass
31
+ class Guide:
32
+ title: str = "Step-by-Step Guide"
33
+ intro: str = ""
34
+ prerequisites: list[str] = field(default_factory=list)
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",
45
+ "your", "you", "is", "are", "be", "this", "that", "it", "from", "by", "as",
46
+ "into", "then", "will", "can", "should", "have", "has", "its", "their", "our",
47
+ "up", "out", "off", "over", "we", "i",
48
+ }
49
+
50
+ _SHARPNESS_CACHE: dict[str, float] = {}
51
+
52
+
53
+ def _sharpness(path: str) -> float:
54
+ if path not in _SHARPNESS_CACHE:
55
+ _SHARPNESS_CACHE[path] = vision.frame_score(path)
56
+ return _SHARPNESS_CACHE[path]
57
+
58
+
59
+ def _keywords(text: str | None) -> set[str]:
60
+ return {
61
+ w
62
+ for w in re.findall(r"[a-z0-9]+", (text or "").lower())
63
+ if len(w) > 2 and w not in _STOPWORDS
64
+ }
65
+
66
+
67
+ def _text_relevance(caption: str | None, step_text: str) -> float:
68
+ """Fraction of the step's keywords that BLIP's caption mentions (0..1).
69
+
70
+ This is the BLIP *suggestion* signal: it nudges selection toward a frame
71
+ whose description overlaps the step, without letting BLIP decide alone.
72
+ """
73
+ step_kw = _keywords(step_text)
74
+ cap_kw = _keywords(caption)
75
+ if not step_kw or not cap_kw:
76
+ return 0.0
77
+ return min(len(step_kw & cap_kw) / len(step_kw), 1.0)
78
+
79
+
80
+ def _pick_frame(
81
+ candidates: list[FrameRecord],
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(
121
+ draft: GuideDraft,
122
+ frames: list[FrameRecord],
123
+ *,
124
+ video_path: str | Path | None = None,
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
+
138
+ # Caption the pool up front (once per frame, context-free to keep the
139
+ # relevance signal unbiased) so captions feed both selection and figures.
140
+ if do_caption and frames_sorted:
141
+ for i, rec in enumerate(frames_sorted):
142
+ if rec.caption is None:
143
+ if progress:
144
+ progress(
145
+ 0.05 + 0.45 * (i / len(frames_sorted)),
146
+ f"Captioning frame {i + 1}/{len(frames_sorted)}…",
147
+ )
148
+ rec.caption = vision.caption_image(rec.path, token=token) or ""
149
+
150
+ used: set[str] = set()
151
+ steps: list[GuideStep] = []
152
+ total = max(len(draft.steps), 1)
153
+
154
+ for i, sd in enumerate(draft.steps):
155
+ if progress:
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 (
163
+ chosen is None
164
+ and video_path
165
+ and session_dir
166
+ and sd.approx_timestamp is not None
167
+ ):
168
+ out = Path(session_dir) / "frames" / f"step_{i:03d}_{int(sd.approx_timestamp * 1000):08d}.png"
169
+ try:
170
+ video.extract_frame(video_path, sd.approx_timestamp, out)
171
+ chosen = FrameRecord(path=str(out), timestamp=sd.approx_timestamp, source="auto")
172
+ except Exception:
173
+ chosen = None
174
+
175
+ image_path = None
176
+ caption = None
177
+ if chosen is not None:
178
+ used.add(chosen.path)
179
+ image_path = chosen.path
180
+ if chosen.caption is None and do_caption: # freshly extracted frame
181
+ chosen.caption = (
182
+ vision.caption_image(chosen.path, token=token, context=step_text) or ""
183
+ )
184
+ caption = chosen.caption or None
185
+
186
+ steps.append(
187
+ GuideStep(
188
+ heading=sd.heading,
189
+ text=sd.text,
190
+ timestamp=sd.approx_timestamp if sd.approx_timestamp is not None
191
+ else (chosen.timestamp if chosen else None),
192
+ image_path=image_path,
193
+ caption=caption,
194
+ )
195
+ )
196
+
197
+ if progress:
198
+ progress(1.0, "Guide assembled.")
199
+
200
+ return Guide(
201
+ title=draft.title,
202
+ intro=draft.intro,
203
+ prerequisites=draft.prerequisites,
204
+ steps=steps,
205
+ )
src/llm.py ADDED
@@ -0,0 +1,273 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Transcript cleanup + step-structuring via the HuggingFace Inference API.
2
+
3
+ The LLM turns a rough, timestamped transcript into a structured guide draft
4
+ (title, intro, prerequisites, ordered steps). Long transcripts are processed
5
+ map-reduce style so prompts stay within the model's context window. All model
6
+ ids are config-driven and responses are parsed defensively, because free-tier
7
+ model availability and exact output formatting both vary.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import re
13
+ from dataclasses import dataclass, field
14
+ from typing import Any, Callable
15
+
16
+ from . import config
17
+ from .transcribe import Transcript
18
+
19
+ _SYSTEM = (
20
+ "You are a meticulous technical writer. You convert rough spoken transcripts "
21
+ "from how-to/tutorial videos into clear, accurate, step-by-step instructions. "
22
+ "You never invent actions that are not in the transcript."
23
+ )
24
+
25
+
26
+ @dataclass
27
+ class StepDraft:
28
+ heading: str
29
+ text: str
30
+ approx_timestamp: float | None = None
31
+
32
+
33
+ @dataclass
34
+ class GuideDraft:
35
+ title: str = "Step-by-Step Guide"
36
+ intro: str = ""
37
+ prerequisites: list[str] = field(default_factory=list)
38
+ steps: list[StepDraft] = field(default_factory=list)
39
+
40
+
41
+ # --- Inference client --------------------------------------------------------
42
+ def get_client(token: str | None):
43
+ """Build an InferenceClient for the given user-supplied token.
44
+
45
+ Creating a client is cheap (no network until a call), so we don't cache —
46
+ this lets the token change at runtime (it comes from the UI field).
47
+ """
48
+ from huggingface_hub import InferenceClient
49
+
50
+ kwargs: dict[str, Any] = {"model": config.LLM_MODEL}
51
+ if token:
52
+ kwargs["token"] = token
53
+ if config.LLM_PROVIDER:
54
+ kwargs["provider"] = config.LLM_PROVIDER
55
+ return InferenceClient(**kwargs)
56
+
57
+
58
+ def _chat(client, user_prompt: str, *, max_tokens: int | None = None) -> str:
59
+ try:
60
+ resp = client.chat_completion(
61
+ messages=[
62
+ {"role": "system", "content": _SYSTEM},
63
+ {"role": "user", "content": user_prompt},
64
+ ],
65
+ max_tokens=max_tokens or config.LLM_MAX_TOKENS,
66
+ temperature=config.LLM_TEMPERATURE,
67
+ )
68
+ except Exception as exc:
69
+ raise RuntimeError(
70
+ f"HuggingFace LLM call failed for model '{config.LLM_MODEL}'. "
71
+ f"Check the model is available on your plan or set DOCUMAKER_LLM_MODEL "
72
+ f"to another instruct model.\nDetails: {exc}"
73
+ ) from exc
74
+ return resp.choices[0].message.content or ""
75
+
76
+
77
+ # --- JSON / time parsing -----------------------------------------------------
78
+ def _extract_json(text: str) -> Any:
79
+ """Best-effort extraction of a JSON object/array from an LLM response."""
80
+ text = text.strip()
81
+ try:
82
+ return json.loads(text)
83
+ except Exception:
84
+ pass
85
+
86
+ fenced = re.search(r"```(?:json)?\s*(.*?)```", text, re.S)
87
+ if fenced:
88
+ try:
89
+ return json.loads(fenced.group(1))
90
+ except Exception:
91
+ pass
92
+
93
+ for open_ch, close_ch in (("{", "}"), ("[", "]")):
94
+ i, j = text.find(open_ch), text.rfind(close_ch)
95
+ if i != -1 and j > i:
96
+ try:
97
+ return json.loads(text[i : j + 1])
98
+ except Exception:
99
+ continue
100
+ return None
101
+
102
+
103
+ def _parse_time(value: Any) -> float | None:
104
+ """Parse 'ss', 'mm:ss', or 'hh:mm:ss' (or a number) into seconds."""
105
+ if value is None:
106
+ return None
107
+ if isinstance(value, (int, float)):
108
+ return float(value)
109
+ s = str(value).strip()
110
+ if not s:
111
+ return None
112
+ try:
113
+ parts = [float(p) for p in s.split(":")]
114
+ except ValueError:
115
+ return None
116
+ seconds = 0.0
117
+ for part in parts:
118
+ seconds = seconds * 60 + part
119
+ return seconds
120
+
121
+
122
+ # --- Prompt builders ---------------------------------------------------------
123
+ _JSON_FULL = (
124
+ '{"title": "...", "intro": "...", "prerequisites": ["..."], '
125
+ '"steps": [{"heading": "short title", "text": "what to do", "approx_time": "mm:ss"}]}'
126
+ )
127
+ _JSON_STEPS = '{"steps": [{"heading": "short title", "text": "what to do", "approx_time": "mm:ss"}]}'
128
+
129
+
130
+ def _full_prompt(timestamped_text: str) -> str:
131
+ return (
132
+ "Convert this timestamped tutorial transcript into a clean step-by-step guide.\n"
133
+ "- Fix obvious speech-to-text errors; remove filler words and repetition.\n"
134
+ "- Write each step as a clear, imperative instruction.\n"
135
+ "- Add a short descriptive title, a 1-2 sentence introduction, and any "
136
+ "prerequisites that are implied (empty list if none).\n"
137
+ "- For each step set \"approx_time\" as \"mm:ss\" from the nearest [mm:ss] marker.\n"
138
+ f"Respond with ONLY JSON in this exact shape:\n{_JSON_FULL}\n\n"
139
+ f"Transcript:\n{timestamped_text}"
140
+ )
141
+
142
+
143
+ def _chunk_prompt(timestamped_text: str) -> str:
144
+ return (
145
+ "From this timestamped transcript excerpt of a tutorial video, extract the "
146
+ "concrete actions as an ordered list of steps.\n"
147
+ "- Fix speech-to-text errors; remove filler and repetition.\n"
148
+ "- Write each step as a clear, imperative instruction.\n"
149
+ "- Set \"approx_time\" as \"mm:ss\" from the nearest [mm:ss] marker.\n"
150
+ f"Respond with ONLY JSON in this exact shape:\n{_JSON_STEPS}\n\n"
151
+ f"Transcript:\n{timestamped_text}"
152
+ )
153
+
154
+
155
+ def _reduce_prompt(steps_json: str) -> str:
156
+ return (
157
+ "You are assembling the final step-by-step guide from steps extracted across "
158
+ "several transcript chunks.\n"
159
+ "- Merge near-duplicates and keep a logical order.\n"
160
+ "- Keep every distinct action; do not invent new steps.\n"
161
+ "- Add a short descriptive title, a 1-2 sentence introduction, and prerequisites "
162
+ "if implied (empty list if none). Preserve each step's \"approx_time\".\n"
163
+ f"Respond with ONLY JSON in this exact shape:\n{_JSON_FULL}\n\n"
164
+ f"Extracted steps:\n{steps_json}"
165
+ )
166
+
167
+
168
+ # --- Chunking ----------------------------------------------------------------
169
+ def _timestamped_lines(transcript: Transcript) -> list[str]:
170
+ lines = []
171
+ for seg in transcript.segments:
172
+ mm, ss = divmod(int(seg.start), 60)
173
+ lines.append(f"[{mm:02d}:{ss:02d}] {seg.text.strip()}")
174
+ return lines
175
+
176
+
177
+ def _chunk_transcript(transcript: Transcript, max_chars: int) -> list[str]:
178
+ lines = _timestamped_lines(transcript)
179
+ if not lines:
180
+ return [transcript.text] if transcript.text else []
181
+
182
+ chunks: list[str] = []
183
+ current: list[str] = []
184
+ length = 0
185
+ for line in lines:
186
+ if current and length + len(line) > max_chars:
187
+ chunks.append("\n".join(current))
188
+ current, length = [], 0
189
+ current.append(line)
190
+ length += len(line) + 1
191
+ if current:
192
+ chunks.append("\n".join(current))
193
+ return chunks
194
+
195
+
196
+ # --- Result parsing ----------------------------------------------------------
197
+ def _parse_steps(raw_steps: Any) -> list[StepDraft]:
198
+ steps: list[StepDraft] = []
199
+ if not isinstance(raw_steps, list):
200
+ return steps
201
+ for item in raw_steps:
202
+ if not isinstance(item, dict):
203
+ continue
204
+ heading = str(item.get("heading") or item.get("title") or "").strip()
205
+ text = str(item.get("text") or item.get("instruction") or "").strip()
206
+ if not (heading or text):
207
+ continue
208
+ ts = _parse_time(item.get("approx_time", item.get("approx_timestamp")))
209
+ steps.append(StepDraft(heading=heading or text[:60], text=text, approx_timestamp=ts))
210
+ return steps
211
+
212
+
213
+ def _parse_guide(data: Any) -> GuideDraft:
214
+ if not isinstance(data, dict):
215
+ return GuideDraft()
216
+ prereqs = data.get("prerequisites") or []
217
+ if not isinstance(prereqs, list):
218
+ prereqs = [str(prereqs)]
219
+ return GuideDraft(
220
+ title=str(data.get("title") or "Step-by-Step Guide").strip(),
221
+ intro=str(data.get("intro") or "").strip(),
222
+ prerequisites=[str(p).strip() for p in prereqs if str(p).strip()],
223
+ steps=_parse_steps(data.get("steps")),
224
+ )
225
+
226
+
227
+ # --- Public entry point ------------------------------------------------------
228
+ def build_guide_draft(
229
+ transcript: Transcript,
230
+ *,
231
+ token: str | None = None,
232
+ progress: Callable[[float, str], None] | None = None,
233
+ ) -> GuideDraft:
234
+ """Turn a transcript into a structured :class:`GuideDraft` via the LLM.
235
+
236
+ ``token`` is the user's HuggingFace token (supplied in the UI).
237
+ """
238
+ chunks = _chunk_transcript(transcript, config.LLM_CHUNK_CHARS)
239
+ if not chunks:
240
+ return GuideDraft()
241
+
242
+ client = get_client(token)
243
+
244
+ if len(chunks) == 1:
245
+ if progress:
246
+ progress(0.1, "Writing the guide…")
247
+ draft = _parse_guide(_extract_json(_chat(client, _full_prompt(chunks[0]))))
248
+ if progress:
249
+ progress(1.0, "Guide drafted.")
250
+ return draft
251
+
252
+ # Map: extract steps per chunk.
253
+ all_steps: list[dict] = []
254
+ for i, chunk in enumerate(chunks):
255
+ if progress:
256
+ progress(i / (len(chunks) + 1), f"Structuring part {i + 1}/{len(chunks)}…")
257
+ data = _extract_json(_chat(client, _chunk_prompt(chunk)))
258
+ for step in _parse_steps((data or {}).get("steps") if isinstance(data, dict) else data):
259
+ mm, ss = divmod(int(step.approx_timestamp or 0), 60)
260
+ all_steps.append(
261
+ {"heading": step.heading, "text": step.text, "approx_time": f"{mm:02d}:{ss:02d}"}
262
+ )
263
+
264
+ # Reduce: merge into a titled guide.
265
+ if progress:
266
+ progress(len(chunks) / (len(chunks) + 1), "Assembling the final guide…")
267
+ reduced = _extract_json(_chat(client, _reduce_prompt(json.dumps({"steps": all_steps}))))
268
+ draft = _parse_guide(reduced)
269
+ if not draft.steps: # reduce failed — fall back to the mapped steps
270
+ draft = _parse_guide({"steps": all_steps})
271
+ if progress:
272
+ progress(1.0, "Guide drafted.")
273
+ return draft
src/transcribe.py ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Local speech-to-text via faster-whisper (CTranslate2).
2
+
3
+ The model is loaded lazily and cached. On a CUDA box we prefer
4
+ ``int8_float16``; if CUDA is unavailable or its libraries are missing we fall
5
+ back to CPU ``int8`` automatically, so transcription always works.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from collections.abc import Iterator
10
+ from dataclasses import dataclass, field
11
+ from pathlib import Path
12
+ from typing import Callable
13
+
14
+ from . import config
15
+
16
+ # Cached (model, (device, compute_type)).
17
+ _MODEL = None
18
+ _MODEL_INFO: tuple[str, str] | None = None
19
+ # Set once a CUDA runtime failure is seen, to skip the GPU on later calls.
20
+ _FORCE_CPU = False
21
+
22
+
23
+ @dataclass
24
+ class TranscriptSegment:
25
+ start: float
26
+ end: float
27
+ text: str
28
+
29
+
30
+ @dataclass
31
+ class Transcript:
32
+ segments: list[TranscriptSegment] = field(default_factory=list)
33
+ language: str = ""
34
+ device: str = ""
35
+
36
+ @property
37
+ def text(self) -> str:
38
+ return " ".join(seg.text.strip() for seg in self.segments).strip()
39
+
40
+ def to_timestamped_text(self) -> str:
41
+ lines = []
42
+ for seg in self.segments:
43
+ mm, ss = divmod(int(seg.start), 60)
44
+ lines.append(f"[{mm:02d}:{ss:02d}] {seg.text.strip()}")
45
+ return "\n".join(lines)
46
+
47
+
48
+ def _candidate_configs() -> Iterator[tuple[str, str]]:
49
+ """Yield (device, compute_type) attempts in priority order."""
50
+ override = config.WHISPER_COMPUTE_TYPE
51
+ device = config.WHISPER_DEVICE
52
+ if device == "cpu" or _FORCE_CPU:
53
+ yield ("cpu", override or "int8")
54
+ return
55
+ # "cuda" or "auto": try GPU first, then always fall back to CPU int8.
56
+ yield ("cuda", override or "int8_float16")
57
+ yield ("cpu", "int8")
58
+
59
+
60
+ def _is_cuda_error(exc: Exception) -> bool:
61
+ msg = str(exc).lower()
62
+ return any(tok in msg for tok in ("cublas", "cudnn", "cuda", ".dll", "gpu"))
63
+
64
+
65
+ def get_model():
66
+ """Load (once) and return the faster-whisper model with its resolved config."""
67
+ global _MODEL, _MODEL_INFO
68
+ if _MODEL is not None:
69
+ return _MODEL, _MODEL_INFO
70
+
71
+ from faster_whisper import WhisperModel
72
+
73
+ errors: list[str] = []
74
+ for device, compute_type in _candidate_configs():
75
+ try:
76
+ _MODEL = WhisperModel(
77
+ config.WHISPER_MODEL, device=device, compute_type=compute_type
78
+ )
79
+ _MODEL_INFO = (device, compute_type)
80
+ return _MODEL, _MODEL_INFO
81
+ except Exception as exc: # CUDA libs missing, OOM, etc.
82
+ errors.append(f" {device}/{compute_type}: {exc}")
83
+
84
+ raise RuntimeError(
85
+ "Could not load the Whisper model. Attempts:\n" + "\n".join(errors)
86
+ )
87
+
88
+
89
+ def _run_transcribe(
90
+ media_path: str | Path, progress: Callable[[float, str], None] | None
91
+ ) -> Transcript:
92
+ model, info = get_model()
93
+ device = info[0] if info else ""
94
+
95
+ segment_iter, meta = model.transcribe(str(media_path), vad_filter=True, beam_size=5)
96
+ total = float(getattr(meta, "duration", 0.0)) or 0.0
97
+
98
+ # The CUDA libraries are loaded lazily on first encode, so a missing-cuBLAS
99
+ # error surfaces while consuming this generator — not at model construction.
100
+ segments: list[TranscriptSegment] = []
101
+ for seg in segment_iter:
102
+ segments.append(
103
+ TranscriptSegment(start=float(seg.start), end=float(seg.end), text=seg.text)
104
+ )
105
+ if progress and total:
106
+ frac = min(seg.end / total, 1.0)
107
+ progress(frac, f"Transcribing… {int(frac * 100)}%")
108
+
109
+ return Transcript(
110
+ segments=segments,
111
+ language=getattr(meta, "language", "") or "",
112
+ device=device,
113
+ )
114
+
115
+
116
+ def transcribe(
117
+ media_path: str | Path,
118
+ *,
119
+ progress: Callable[[float, str], None] | None = None,
120
+ ) -> Transcript:
121
+ """Transcribe an audio/video file into timestamped segments.
122
+
123
+ Falls back from GPU to CPU automatically if a CUDA runtime error (e.g.
124
+ missing cuBLAS/cuDNN) occurs during inference.
125
+ """
126
+ global _MODEL, _MODEL_INFO, _FORCE_CPU
127
+ try:
128
+ return _run_transcribe(media_path, progress)
129
+ except RuntimeError as exc:
130
+ on_gpu = bool(_MODEL_INFO and _MODEL_INFO[0] == "cuda")
131
+ if not (on_gpu and _is_cuda_error(exc)):
132
+ raise
133
+ # Drop the GPU model and retry once on CPU.
134
+ _MODEL, _MODEL_INFO, _FORCE_CPU = None, None, True
135
+ if progress:
136
+ progress(0.0, "GPU unavailable — falling back to CPU…")
137
+ return _run_transcribe(media_path, progress)
src/video.py ADDED
@@ -0,0 +1,82 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Video helpers: audio extraction, duration probing, single-frame grabbing.
2
+
3
+ All heavy lifting is delegated to ffmpeg/ffprobe (already on PATH). ffmpeg's
4
+ ``-ss`` before ``-i`` is both fast and frame-accurate in modern builds, which we
5
+ rely on for precise frame extraction at a given timestamp.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ import subprocess
10
+ from pathlib import Path
11
+
12
+ from . import config
13
+
14
+
15
+ class FFmpegError(RuntimeError):
16
+ """Raised when an ffmpeg/ffprobe subprocess fails."""
17
+
18
+
19
+ def _run(cmd: list[str]) -> subprocess.CompletedProcess:
20
+ proc = subprocess.run(cmd, capture_output=True, text=True)
21
+ if proc.returncode != 0:
22
+ raise FFmpegError(
23
+ f"Command failed ({proc.returncode}): {' '.join(cmd)}\n{proc.stderr.strip()}"
24
+ )
25
+ return proc
26
+
27
+
28
+ def get_duration(video_path: str | Path) -> float:
29
+ """Return the media duration in seconds (0.0 if it cannot be determined)."""
30
+ try:
31
+ proc = _run(
32
+ [
33
+ config.FFPROBE_BIN,
34
+ "-v", "error",
35
+ "-show_entries", "format=duration",
36
+ "-of", "default=noprint_wrappers=1:nokey=1",
37
+ str(video_path),
38
+ ]
39
+ )
40
+ return float(proc.stdout.strip())
41
+ except (FFmpegError, ValueError):
42
+ return 0.0
43
+
44
+
45
+ def extract_audio(video_path: str | Path, out_wav: str | Path) -> Path:
46
+ """Extract a 16 kHz mono WAV (the format faster-whisper expects)."""
47
+ out_wav = Path(out_wav)
48
+ out_wav.parent.mkdir(parents=True, exist_ok=True)
49
+ _run(
50
+ [
51
+ config.FFMPEG_BIN,
52
+ "-y",
53
+ "-i", str(video_path),
54
+ "-vn", # drop video
55
+ "-ac", "1", # mono
56
+ "-ar", "16000", # 16 kHz
57
+ "-f", "wav",
58
+ str(out_wav),
59
+ ]
60
+ )
61
+ return out_wav
62
+
63
+
64
+ def extract_frame(video_path: str | Path, timestamp: float, out_png: str | Path) -> Path:
65
+ """Save a single frame at ``timestamp`` seconds as a PNG.
66
+
67
+ ``-ss`` is placed before ``-i`` for fast, frame-accurate seeking.
68
+ """
69
+ out_png = Path(out_png)
70
+ out_png.parent.mkdir(parents=True, exist_ok=True)
71
+ _run(
72
+ [
73
+ config.FFMPEG_BIN,
74
+ "-y",
75
+ "-ss", f"{max(timestamp, 0.0):.3f}",
76
+ "-i", str(video_path),
77
+ "-frames:v", "1",
78
+ "-q:v", "2",
79
+ str(out_png),
80
+ ]
81
+ )
82
+ return out_png
src/vision.py ADDED
@@ -0,0 +1,166 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Multimodal pass: caption frames and score them for "informativeness".
2
+
3
+ Captioning prefers a vision LLM on the HuggingFace Inference API and falls back
4
+ to a local BLIP model (only if torch/transformers are installed). Frame scoring
5
+ uses a cheap sharpness heuristic (variance of the Laplacian) so the guide builder
6
+ can prefer crisp, content-rich frames over blurry scene-transition frames.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import base64
11
+ import io
12
+ from pathlib import Path
13
+
14
+ from . import config
15
+
16
+ _LOCAL_PROC = None
17
+ _LOCAL_MODEL = None
18
+ _LOCAL_DEVICE = "cpu"
19
+ _LOCAL_FAILED = False
20
+ # Many free HF accounts have no provider that serves a vision-chat model. Once
21
+ # the API VLM fails, stop retrying it for the session and use local BLIP.
22
+ _API_VLM_DISABLED = False
23
+
24
+ _CAPTION_PROMPT = (
25
+ "In one concise sentence, describe what this screenshot from a tutorial shows, "
26
+ "focusing on the on-screen UI element or the action being performed. "
27
+ "Do not begin with phrases like 'The image shows'."
28
+ )
29
+
30
+
31
+ def _data_uri(image_path: str | Path, max_side: int = 1024) -> str:
32
+ """Downscale + JPEG-encode an image into a data URI (saves API bandwidth)."""
33
+ from PIL import Image
34
+
35
+ with Image.open(image_path) as im:
36
+ im = im.convert("RGB")
37
+ im.thumbnail((max_side, max_side))
38
+ buf = io.BytesIO()
39
+ im.save(buf, format="JPEG", quality=85)
40
+ return "data:image/jpeg;base64," + base64.b64encode(buf.getvalue()).decode()
41
+
42
+
43
+ def _get_vlm_client(token: str | None):
44
+ from huggingface_hub import InferenceClient
45
+
46
+ kwargs = {"model": config.VLM_MODEL}
47
+ if token:
48
+ kwargs["token"] = token
49
+ if config.VLM_PROVIDER:
50
+ kwargs["provider"] = config.VLM_PROVIDER
51
+ return InferenceClient(**kwargs)
52
+
53
+
54
+ def _caption_via_api(image_path: str | Path, prompt: str, token: str | None) -> str:
55
+ client = _get_vlm_client(token)
56
+ resp = client.chat_completion(
57
+ messages=[
58
+ {
59
+ "role": "user",
60
+ "content": [
61
+ {"type": "text", "text": prompt},
62
+ {"type": "image_url", "image_url": {"url": _data_uri(image_path)}},
63
+ ],
64
+ }
65
+ ],
66
+ max_tokens=120,
67
+ temperature=0.2,
68
+ )
69
+ return (resp.choices[0].message.content or "").strip()
70
+
71
+
72
+ def _load_local_captioner() -> None:
73
+ """Load the BLIP captioner directly (the image-to-text pipeline task was
74
+ removed in transformers 5). Uses the GPU if a CUDA build of torch is present.
75
+ """
76
+ global _LOCAL_PROC, _LOCAL_MODEL, _LOCAL_DEVICE
77
+ from transformers import AutoProcessor
78
+
79
+ try:
80
+ from transformers import AutoModelForImageTextToText as _AutoCaptionModel
81
+ except Exception: # older transformers
82
+ from transformers import AutoModelForVision2Seq as _AutoCaptionModel
83
+
84
+ proc = AutoProcessor.from_pretrained(config.LOCAL_CAPTION_MODEL)
85
+ model = _AutoCaptionModel.from_pretrained(config.LOCAL_CAPTION_MODEL)
86
+
87
+ device = "cpu"
88
+ try:
89
+ import torch
90
+
91
+ if torch.cuda.is_available():
92
+ device = "cuda"
93
+ model = model.to(device)
94
+ except Exception:
95
+ pass
96
+
97
+ _LOCAL_PROC, _LOCAL_MODEL, _LOCAL_DEVICE = proc, model, device
98
+
99
+
100
+ def _caption_via_local(image_path: str | Path) -> str:
101
+ """Local BLIP captioner. Returns '' if torch/transformers are unavailable."""
102
+ global _LOCAL_FAILED
103
+ if _LOCAL_FAILED:
104
+ return ""
105
+ if _LOCAL_MODEL is None:
106
+ try:
107
+ _load_local_captioner()
108
+ except Exception:
109
+ _LOCAL_FAILED = True
110
+ return ""
111
+ try:
112
+ import torch
113
+ from PIL import Image
114
+
115
+ with Image.open(image_path) as im:
116
+ img = im.convert("RGB")
117
+ inputs = _LOCAL_PROC(images=img, return_tensors="pt")
118
+ if _LOCAL_DEVICE != "cpu":
119
+ inputs = {k: v.to(_LOCAL_DEVICE) for k, v in inputs.items()}
120
+ with torch.no_grad():
121
+ out = _LOCAL_MODEL.generate(**inputs, max_new_tokens=40)
122
+ return _LOCAL_PROC.decode(out[0], skip_special_tokens=True).strip()
123
+ except Exception:
124
+ return ""
125
+
126
+
127
+ def caption_image(
128
+ image_path: str | Path, *, token: str | None = None, context: str = ""
129
+ ) -> str | None:
130
+ """Return a one-line caption for a frame, or None if captioning is off/failed.
131
+
132
+ With a ``token`` it tries an API vision-chat model first (if any provider
133
+ serves one), then falls back to local BLIP. After the API VLM fails once it
134
+ is skipped for the rest of the session to avoid repeated dead calls. Local
135
+ BLIP needs no token.
136
+ """
137
+ global _API_VLM_DISABLED
138
+ if not config.ENABLE_VISION:
139
+ return None
140
+ prompt = _CAPTION_PROMPT
141
+ if context:
142
+ prompt += f" For context, this step is about: {context[:200]}"
143
+
144
+ if token and not _API_VLM_DISABLED:
145
+ try:
146
+ caption = _caption_via_api(image_path, prompt, token)
147
+ if caption:
148
+ return caption
149
+ except Exception:
150
+ _API_VLM_DISABLED = True # no usable provider — switch to local BLIP
151
+
152
+ caption = _caption_via_local(image_path)
153
+ return caption or None
154
+
155
+
156
+ def frame_score(image_path: str | Path) -> float:
157
+ """Sharpness score (variance of Laplacian). Higher = crisper/more detailed."""
158
+ try:
159
+ import cv2
160
+
161
+ img = cv2.imread(str(image_path), cv2.IMREAD_GRAYSCALE)
162
+ if img is None:
163
+ return 0.0
164
+ return float(cv2.Laplacian(img, cv2.CV_64F).var())
165
+ except Exception:
166
+ return 0.0
src/web/player.html ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ <div class="dm-player" style="display:flex;flex-direction:column;gap:6px;">
2
+ <video id="dm-video" controls preload="metadata"
3
+ src="/gradio_api/file=__VIDEO_PATH__"
4
+ onerror="if(!this.dataset.fb){this.dataset.fb='1';this.src='/file=__VIDEO_PATH__';}"
5
+ style="width:100%;max-height:430px;background:#000;border-radius:8px;"></video>
6
+ <p style="margin:0;font-size:12px;opacity:0.75;">
7
+ Use the seek bar to find the exact moment, then click
8
+ <b>"📸 Capture current frame"</b> below to snapshot it.
9
+ </p>
10
+ </div>