Spaces:
Sleeping
Sleeping
Commit ·
85b485a
0
Parent(s):
DocuMaker: video to step-by-step DOCX guide (Whisper + HF LLM + BLIP)
Browse files- .env.example +35 -0
- .gitignore +20 -0
- README.md +201 -0
- app.py +360 -0
- packages.txt +1 -0
- requirements.txt +31 -0
- scripts/make_sample.py +117 -0
- scripts/smoke_test.py +95 -0
- src/__init__.py +3 -0
- src/config.py +142 -0
- src/docx_export.py +55 -0
- src/frames.py +124 -0
- src/guide.py +205 -0
- src/llm.py +273 -0
- src/transcribe.py +137 -0
- src/video.py +82 -0
- src/vision.py +166 -0
- 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>
|