vivekchakraverty Claude Opus 5 commited on
Commit
2d82711
·
1 Parent(s): 8b53847

Add Beam serverless-GPU captioner (Qwen2.5-VL) with batching

Browse files

BLIP-base is a 0.25B COCO captioner with no OCR, so on tutorial screenshots
it produces generic text ("a computer screen with a website on it") that adds
nothing to a step-by-step guide. Serve Qwen2.5-VL-7B-Instruct on a Beam
serverless GPU instead; it reads on-screen text and understands UI elements.

- beam_app.py: batched endpoint, scale-to-zero, weights cached on a Volume.
torch is pinned to 2.7.1 deliberately: Beam hosts run a CUDA 12.9 driver and
an unpinned torch resolves to a cu13 wheel, which fails as a silent
torch.cuda.is_available() == False and a bare HTTP 500 at request time.
- vision.py: Beam backend plus caption_batch(), chunked to keep request
bodies bounded. Falls back to the HF Inference API and then local BLIP, so
an unreachable or scaled-to-zero endpoint degrades instead of breaking.
- guide.py: caption the frame pool in one batched call rather than N calls.
- .beamignore: exclude work/ (32MB of frames) and .env from image uploads.

Captioning is opt-in: without DOCUMAKER_BEAM_CAPTION_URL, behaviour is
unchanged.

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

Files changed (8) hide show
  1. .beamignore +38 -0
  2. .env.example +10 -0
  3. README.md +71 -1
  4. beam_app.py +204 -0
  5. scripts/test_beam_captions.py +64 -0
  6. src/config.py +14 -0
  7. src/guide.py +11 -8
  8. src/vision.py +90 -4
.beamignore ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Generated by Beam SDK
2
+ .beamignore
3
+ .git
4
+ .idea
5
+ .python-version
6
+ .vscode
7
+ .venv
8
+ venv
9
+ __pycache__
10
+ .DS_Store
11
+ .config
12
+ drive/MyDrive
13
+ .coverage
14
+ .pytest_cache
15
+ .ipynb
16
+ .ruff_cache
17
+ .dockerignore
18
+ .ipynb_checkpoints
19
+ .env.local
20
+ .envrc
21
+ **/__pycache__/
22
+ **/.pytest_cache/
23
+ **/node_modules/
24
+ **/.venv/
25
+ *.pyc
26
+ .next/
27
+ .circleci
28
+
29
+ # --- DocuMaker additions ---
30
+ # Runtime artifacts: uploaded videos, extracted frames, audio, generated DOCX.
31
+ # Without this the whole work/ tree (tens of MB) is uploaded on every deploy.
32
+ work/
33
+ # Secrets must never be baked into the container image. The Beam SDK ignores
34
+ # .env.local but not .env, which is where DocuMaker keeps its tokens.
35
+ .env
36
+ .gradio/
37
+ .cache/
38
+ dep5.log
.env.example CHANGED
@@ -22,6 +22,16 @@ 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
 
22
  # DOCUMAKER_VLM_PROVIDER=
23
  # DOCUMAKER_LOCAL_CAPTION_MODEL=Salesforce/blip-image-captioning-base
24
 
25
+ # --- Beam serverless-GPU captioner (see beam_app.py) ---
26
+ # When set, this is the preferred captioner: a real VLM (Qwen2.5-VL) on a GPU,
27
+ # instead of the 0.25B BLIP fallback that cannot read on-screen text. The HF
28
+ # Inference API and local BLIP stay as fallbacks, so an unreachable endpoint
29
+ # degrades rather than breaks the run.
30
+ # Get both values from: beam deploy beam_app.py:caption
31
+ # DOCUMAKER_BEAM_CAPTION_URL=
32
+ # DOCUMAKER_BEAM_TOKEN=
33
+ # DOCUMAKER_BEAM_TIMEOUT=300 # a cold container pages in weights first
34
+
35
  # --- Whisper (local faster-whisper) ---
36
  DOCUMAKER_WHISPER_MODEL=small # tiny | base | small | medium | large-v3
37
  DOCUMAKER_WHISPER_DEVICE=auto # auto | cuda | cpu
README.md CHANGED
@@ -148,6 +148,72 @@ demo); set `DOCUMAKER_WHISPER_MODEL=base` in the Space *Settings → Variables*
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:
@@ -156,7 +222,11 @@ All settings are environment variables (see [.env.example](.env.example)). Highl
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` |
 
148
  snappier transcription. You can push to **both** GitHub and the Space (add both as
149
  git remotes).
150
 
151
+ ## Serverless GPU captions (Beam)
152
+
153
+ The default local captioner, BLIP-base, is a 0.25B model trained on COCO photos.
154
+ It has no OCR, so on tutorial screenshots it produces generic text like *"a
155
+ computer screen with a website on it"* — near-useless for a step-by-step guide.
156
+
157
+ [`beam_app.py`](beam_app.py) serves **Qwen2.5-VL-7B-Instruct** on a serverless
158
+ GPU instead. It reads on-screen text and understands UI affordances, and it
159
+ scales to zero when idle.
160
+
161
+ ```bash
162
+ uv tool install beam-client && beam configure default --token <YOUR_TOKEN>
163
+ beam deploy beam_app.py:caption
164
+ ```
165
+
166
+ Put the printed URL and your Beam token in `.env`:
167
+
168
+ ```
169
+ DOCUMAKER_BEAM_CAPTION_URL=https://<your-endpoint>.app.beam.cloud
170
+ DOCUMAKER_BEAM_TOKEN=<your-beam-token>
171
+ ```
172
+
173
+ Captioning then prefers Beam, falling back to the HF Inference API and then
174
+ local BLIP — an unreachable or scaled-to-zero endpoint degrades the output
175
+ rather than breaking the run. The whole frame pool is sent in **one** batched
176
+ request (see `vision.caption_batch`), so a cold start is paid once per document,
177
+ not once per frame.
178
+
179
+ ### Choosing a GPU
180
+
181
+ `DOCUMAKER_BEAM_GPU` selects the card. Qwen2.5-VL-7B in bf16 is ~16.5GB of
182
+ weights, so 24GB is the practical floor.
183
+
184
+ **No 16GB card is usable on Beam today**, which is why the default is 24GB:
185
+ `A4000` deploys but reports *"No compute capacity currently supports A4000"* and
186
+ never gets hardware, and `T4`/`V100` are rejected outright with *"This GPU type
187
+ is not supported. Please use an A10G or RTX 4090 instead."*
188
+
189
+ | GPU | VRAM | Notes |
190
+ |---|---|---|
191
+ | `A10G` | 24GB | **Default.** Ampere, native bf16, explicitly supported by Beam |
192
+ | `RTX4090` | 24GB | The other explicitly supported option; Ada, faster |
193
+ | `L4` | 24GB | Ada, efficient — check capacity before relying on it |
194
+ | `A100_40` | 40GB | Overkill for a 7B VLM |
195
+ | `A4000` | 16GB | ⚠️ Deploys, but currently has no capacity |
196
+ | `T4` / `V100` | 16GB | ⚠️ Rejected by Beam. `V100` would also fail on CUDA support |
197
+
198
+ To fit a smaller card, set `DOCUMAKER_BEAM_MODEL=Qwen/Qwen2.5-VL-3B-Instruct`
199
+ (~7GB in bf16). `DOCUMAKER_BEAM_MAX_PIXELS` bounds VRAM either way — Qwen2.5-VL
200
+ scales visual tokens with input resolution, so an uncapped screenshot can
201
+ balloon usage.
202
+
203
+ ### Why torch is pinned
204
+
205
+ `beam_app.py` pins `torch==2.7.1` deliberately. Beam's hosts run a **CUDA 12.9
206
+ driver**; an unpinned `torch` resolves to a cu13 wheel that the driver is too old
207
+ to run. The failure is silent and misleading — `torch.cuda.is_available()` just
208
+ returns `False`, the model load fails on `device_map="cuda:0"`, and the endpoint
209
+ returns a bare `HTTP 500`. If you bump torch, verify the wheel's CUDA version
210
+ still matches the host driver.
211
+
212
+ If the AWQ dependency chain ever breaks, the no-quantization fallback is
213
+ `DOCUMAKER_BEAM_MODEL=Qwen/Qwen2.5-VL-3B-Instruct` (~7GB in bf16, still far
214
+ better than BLIP). `DOCUMAKER_BEAM_MAX_PIXELS` bounds VRAM — Qwen2.5-VL scales
215
+ visual tokens with input resolution, so an uncapped screenshot can balloon usage.
216
+
217
  ## Configuration
218
 
219
  All settings are environment variables (see [.env.example](.env.example)). Highlights:
 
222
  |---|---|---|
223
  | `DOCUMAKER_LLM_MODEL` | `Qwen/Qwen2.5-7B-Instruct` | Text LLM (any HF instruct model) |
224
  | `DOCUMAKER_VLM_MODEL` | `Qwen/Qwen2-VL-7B-Instruct` | API vision model tried before local BLIP |
225
+ | `DOCUMAKER_LOCAL_CAPTION_MODEL` | `Salesforce/blip-image-captioning-base` | Local captioner (last-resort fallback) |
226
+ | `DOCUMAKER_BEAM_CAPTION_URL` | *(unset)* | Beam GPU captioner; preferred when set |
227
+ | `DOCUMAKER_BEAM_TOKEN` | *(unset)* | Beam auth token |
228
+ | `DOCUMAKER_BEAM_GPU` | `A10G` | GPU for the Beam endpoint (24GB) |
229
+ | `DOCUMAKER_BEAM_MODEL` | `Qwen/Qwen2.5-VL-7B-Instruct` | VLM served on Beam |
230
  | `DOCUMAKER_ENABLE_VISION` | `1` | Set `0` to skip captioning |
231
  | `DOCUMAKER_WHISPER_MODEL` | `small` | `tiny`…`large-v3` |
232
  | `DOCUMAKER_WHISPER_DEVICE` | `auto` | `auto` / `cuda` / `cpu` |
beam_app.py ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Beam serverless-GPU captioner for DocuMaker.
2
+
3
+ Replaces the local BLIP fallback in ``src/vision.py`` with a real vision-language
4
+ model. BLIP is a 0.25B COCO captioner with no OCR — on tutorial screenshots it
5
+ emits generic text like "a computer screen with a website on it". Qwen2.5-VL
6
+ reads on-screen text and understands UI affordances, which is what a step-by-step
7
+ guide actually needs.
8
+
9
+ The endpoint is *batched*: DocuMaker captions one frame per step (see
10
+ ``src/guide.py``), so sending the whole set in one request turns N cold-start
11
+ round-trips into one.
12
+
13
+ Deploy:
14
+ beam deploy beam_app.py:caption
15
+
16
+ Model weights are cached on a Beam Volume, so only the first container pays the
17
+ download cost.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import io
23
+ import os
24
+
25
+ from beam import Image, QueueDepthAutoscaler, Volume, endpoint
26
+
27
+ # --- Tunables ---------------------------------------------------------------
28
+ # Full bf16 weights (~16.5GB) — comfortable on the 24GB A10G. The AWQ build was
29
+ # only needed to fit 16GB, and AutoAWQ is deprecated (last tested on torch 2.6 /
30
+ # transformers 4.51), so dropping it removes a fragile dependency. For a smaller
31
+ # card, Qwen/Qwen2.5-VL-3B-Instruct is ~7GB and still far better than BLIP.
32
+ MODEL_ID = os.getenv("DOCUMAKER_BEAM_MODEL", "Qwen/Qwen2.5-VL-7B-Instruct")
33
+ # A10G (24GB). No 16GB card is usable here: A4000 reports no capacity, and Beam
34
+ # rejects T4/V100 outright ("use an A10G or RTX 4090 instead"). The 7B bf16
35
+ # weights need ~16.5GB, leaving headroom for the vision encoder and KV cache.
36
+ GPU = os.getenv("DOCUMAKER_BEAM_GPU", "A10G")
37
+ CACHE_DIR = "./hf-cache"
38
+
39
+ # Qwen2.5-VL scales its visual token count with input resolution, so an
40
+ # unbounded screenshot can balloon VRAM. Cap it: 1280 * 28 * 28 keeps a typical
41
+ # 1080p screenshot well inside budget while preserving legible UI text.
42
+ MAX_PIXELS = int(os.getenv("DOCUMAKER_BEAM_MAX_PIXELS", str(1280 * 28 * 28)))
43
+ MIN_PIXELS = int(os.getenv("DOCUMAKER_BEAM_MIN_PIXELS", str(256 * 28 * 28)))
44
+
45
+ DEFAULT_PROMPT = (
46
+ "In one concise sentence, describe what this screenshot from a tutorial shows, "
47
+ "focusing on the on-screen UI element or the action being performed. "
48
+ "Do not begin with phrases like 'The image shows'."
49
+ )
50
+
51
+ image = Image(
52
+ python_version="python3.11",
53
+ python_packages=[
54
+ # PIN torch, do not float it. Beam's hosts run a CUDA 12.9 driver, and an
55
+ # unpinned `torch` resolves to a cu13 wheel whose CUDA runtime the driver
56
+ # is too old for — torch.cuda.is_available() silently returns False and
57
+ # the container dies on device_map="cuda:0" with a bare 500.
58
+ # torch 2.7.1 ships cu126 on PyPI, which the 12.9 driver runs fine.
59
+ "torch==2.7.1",
60
+ "torchvision==0.22.1",
61
+ "transformers==4.53.2",
62
+ "accelerate",
63
+ "qwen-vl-utils",
64
+ "pillow",
65
+ ],
66
+ ).with_envs([f"HF_HOME={CACHE_DIR}"])
67
+
68
+
69
+ def load_model():
70
+ """Runs once per container (``on_start``), not once per request."""
71
+ import torch
72
+ from transformers import AutoProcessor, Qwen2_5_VLForConditionalGeneration
73
+
74
+ # Fail loudly here rather than with an opaque 500 from the request handler:
75
+ # a driver/wheel CUDA mismatch shows up exactly as "no CUDA available".
76
+ if not torch.cuda.is_available():
77
+ raise RuntimeError(
78
+ f"CUDA unavailable (torch {torch.__version__}). The pinned torch build "
79
+ "must match Beam's host driver — see the pin note on `image` above."
80
+ )
81
+ print(f"[documaker-captioner] torch {torch.__version__} on "
82
+ f"{torch.cuda.get_device_name(0)}")
83
+
84
+ processor = AutoProcessor.from_pretrained(
85
+ MODEL_ID, min_pixels=MIN_PIXELS, max_pixels=MAX_PIXELS, cache_dir=CACHE_DIR
86
+ )
87
+
88
+ # transformers v5 renamed ``torch_dtype`` to ``dtype``; v4 only knows the old
89
+ # spelling. Try the new one first so this works on either.
90
+ # bfloat16: A10G is Ampere, so bf16 is native and avoids the fp16 overflow
91
+ # Qwen2.5-VL is prone to. Falls back to fp16 on pre-Ampere cards.
92
+ dtype = torch.bfloat16 if torch.cuda.is_bf16_supported() else torch.float16
93
+ common = {"device_map": "cuda:0", "cache_dir": CACHE_DIR}
94
+ try:
95
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
96
+ MODEL_ID, dtype=dtype, **common
97
+ )
98
+ except TypeError:
99
+ model = Qwen2_5_VLForConditionalGeneration.from_pretrained(
100
+ MODEL_ID, torch_dtype=dtype, **common
101
+ )
102
+
103
+ model.eval()
104
+ print(f"[documaker-captioner] loaded {MODEL_ID} on {model.device}")
105
+ return processor, model
106
+
107
+
108
+ def _decode_image(raw: str):
109
+ """Accept a bare base64 string or a full ``data:image/...;base64,`` URI."""
110
+ from PIL import Image as PILImage
111
+
112
+ if not raw:
113
+ raise ValueError("empty image payload")
114
+ if raw.startswith("data:"):
115
+ raw = raw.split(",", 1)[1]
116
+ return PILImage.open(io.BytesIO(base64.b64decode(raw))).convert("RGB")
117
+
118
+
119
+ @endpoint(
120
+ name="documaker-captioner",
121
+ image=image,
122
+ gpu=GPU,
123
+ cpu=2,
124
+ memory="16Gi",
125
+ on_start=load_model,
126
+ volumes=[Volume(name="documaker-hf-cache", mount_path=CACHE_DIR)],
127
+ # Weights take ~40s to page in on a cold container. Staying warm for 5
128
+ # minutes means a user processing several videos in a sitting pays that
129
+ # once, while an idle endpoint still scales to zero.
130
+ keep_warm_seconds=300,
131
+ timeout=600,
132
+ autoscaler=QueueDepthAutoscaler(max_containers=2, tasks_per_container=1),
133
+ )
134
+ def caption(context, **inputs):
135
+ """Caption a batch of frames.
136
+
137
+ Input::
138
+
139
+ {"items": [{"image": "<b64|data-uri>", "context": "optional step text"}],
140
+ "prompt": "optional override",
141
+ "max_new_tokens": 96}
142
+
143
+ Output::
144
+
145
+ {"captions": ["...", ...], "model": "...", "count": N}
146
+
147
+ Captions are returned positionally, so ``captions[i]`` belongs to
148
+ ``items[i]``. A frame that fails to decode or generate yields ``""`` rather
149
+ than failing the whole batch — DocuMaker treats an empty caption as "no
150
+ caption" and the guide still builds.
151
+ """
152
+ import torch
153
+
154
+ processor, model = context.on_start_value
155
+
156
+ items = inputs.get("items") or []
157
+ if not items:
158
+ return {"captions": [], "model": MODEL_ID, "count": 0}
159
+
160
+ base_prompt = inputs.get("prompt") or DEFAULT_PROMPT
161
+ max_new_tokens = int(inputs.get("max_new_tokens") or 96)
162
+
163
+ captions: list[str] = []
164
+ for item in items:
165
+ try:
166
+ img = _decode_image(item.get("image", ""))
167
+ prompt = base_prompt
168
+ step_context = (item.get("context") or "").strip()
169
+ if step_context:
170
+ prompt += f" For context, this step is about: {step_context[:200]}"
171
+
172
+ messages = [
173
+ {
174
+ "role": "user",
175
+ "content": [
176
+ {"type": "image", "image": img},
177
+ {"type": "text", "text": prompt},
178
+ ],
179
+ }
180
+ ]
181
+ text = processor.apply_chat_template(
182
+ messages, tokenize=False, add_generation_prompt=True
183
+ )
184
+ model_inputs = processor(
185
+ text=[text], images=[img], padding=True, return_tensors="pt"
186
+ ).to(model.device)
187
+
188
+ with torch.no_grad():
189
+ generated = model.generate(
190
+ **model_inputs,
191
+ max_new_tokens=max_new_tokens,
192
+ do_sample=False,
193
+ )
194
+ # Strip the prompt tokens before decoding.
195
+ trimmed = generated[0][model_inputs.input_ids.shape[1]:]
196
+ caption_text = processor.decode(
197
+ trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=True
198
+ ).strip()
199
+ captions.append(caption_text)
200
+ except Exception as exc: # one bad frame must not sink the batch
201
+ print(f"[documaker-captioner] frame failed: {exc}")
202
+ captions.append("")
203
+
204
+ return {"captions": captions, "model": MODEL_ID, "count": len(captions)}
scripts/test_beam_captions.py ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Smoke-test the Beam GPU captioner against real frames.
2
+
3
+ Usage (from the repo root, with DOCUMAKER_BEAM_CAPTION_URL / DOCUMAKER_BEAM_TOKEN
4
+ set in .env or the environment):
5
+
6
+ python scripts/test_beam_captions.py [frame.png ...]
7
+
8
+ With no arguments it picks a few frames out of ``work/``. Prints the caption each
9
+ backend produces so you can see the Qwen2.5-VL vs BLIP difference directly.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import sys
14
+ import time
15
+ from pathlib import Path
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+
19
+ from src import config, vision # noqa: E402
20
+
21
+
22
+ def find_frames(limit: int = 3) -> list[Path]:
23
+ work = Path(__file__).resolve().parent.parent / "work"
24
+ frames = sorted(work.glob("*/frames/*.png"))
25
+ return frames[:limit]
26
+
27
+
28
+ def main() -> int:
29
+ paths = [Path(a) for a in sys.argv[1:]] or find_frames()
30
+ paths = [p for p in paths if p.exists()]
31
+ if not paths:
32
+ print("No frames found. Pass image paths explicitly.")
33
+ return 1
34
+
35
+ print(f"Beam URL : {config.BEAM_CAPTION_URL or '(unset)'}")
36
+ print(f"Beam token : {'set' if config.BEAM_CAPTION_TOKEN else '(unset)'}")
37
+ print(f"Frames : {len(paths)}\n")
38
+
39
+ if not config.BEAM_CAPTION_URL:
40
+ print("DOCUMAKER_BEAM_CAPTION_URL is unset — caption_batch would fall back "
41
+ "to the HF API / local BLIP. Set it to test the GPU path.")
42
+ return 1
43
+
44
+ start = time.time()
45
+ captions = vision.caption_batch([(p, "") for p in paths])
46
+ elapsed = time.time() - start
47
+
48
+ for path, caption in zip(paths, captions):
49
+ print(f"--- {path.name}")
50
+ print(f" {caption or '(empty)'}\n")
51
+
52
+ ok = sum(1 for c in captions if c)
53
+ print(f"{ok}/{len(paths)} captioned in {elapsed:.1f}s "
54
+ f"({elapsed / max(len(paths), 1):.1f}s per frame)")
55
+ # _BEAM_DISABLED flips on the first hard failure (bad URL/token/endpoint).
56
+ if vision._BEAM_DISABLED:
57
+ print("\nNOTE: the Beam backend errored and was disabled for this run — "
58
+ "captions above (if any) came from the HF API or local BLIP.")
59
+ return 1
60
+ return 0 if ok else 1
61
+
62
+
63
+ if __name__ == "__main__":
64
+ raise SystemExit(main())
src/config.py CHANGED
@@ -116,6 +116,20 @@ 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()
 
116
  "DOCUMAKER_LOCAL_CAPTION_MODEL", "Salesforce/blip-image-captioning-base"
117
  )
118
 
119
+ # --- Beam serverless-GPU captioner (see beam_app.py) -------------------------
120
+ # When BEAM_CAPTION_URL is set it becomes the preferred captioner, ahead of the
121
+ # HF Inference API and local BLIP. Both remain as fallbacks, so an unreachable
122
+ # or scaled-to-zero endpoint degrades instead of breaking the run.
123
+ BEAM_CAPTION_URL = os.getenv("DOCUMAKER_BEAM_CAPTION_URL", "").strip()
124
+ BEAM_CAPTION_TOKEN = os.getenv("DOCUMAKER_BEAM_TOKEN", "").strip()
125
+ # A cold container must download/page in the weights before it answers; the
126
+ # first call of a session can legitimately take a couple of minutes.
127
+ BEAM_CAPTION_TIMEOUT = float(os.getenv("DOCUMAKER_BEAM_TIMEOUT", "300"))
128
+ # Frames per request. A long video can yield dozens of frames; sending them all
129
+ # at once makes a multi-MB body and a generation run long enough to hit the
130
+ # endpoint timeout. Chunking keeps each request bounded.
131
+ BEAM_CAPTION_BATCH_SIZE = int(os.getenv("DOCUMAKER_BEAM_BATCH_SIZE", "12"))
132
+
133
  # --- Whisper (local faster-whisper) -----------------------------------------
134
  WHISPER_MODEL = os.getenv("DOCUMAKER_WHISPER_MODEL", "small")
135
  WHISPER_DEVICE = os.getenv("DOCUMAKER_WHISPER_DEVICE", "auto").strip().lower()
src/guide.py CHANGED
@@ -157,14 +157,17 @@ def assemble_guide(
157
  # Caption the pool up front (once per frame, context-free to keep the
158
  # relevance signal unbiased) so captions feed both selection and figures.
159
  if do_caption and frames_sorted:
160
- for i, rec in enumerate(frames_sorted):
161
- if rec.caption is None:
162
- if progress:
163
- progress(
164
- 0.05 + 0.45 * (i / len(frames_sorted)),
165
- f"Captioning frame {i + 1}/{len(frames_sorted)}…",
166
- )
167
- rec.caption = vision.caption_image(rec.path, token=token) or ""
 
 
 
168
 
169
  used: set[str] = set()
170
  steps: list[GuideStep] = []
 
157
  # Caption the pool up front (once per frame, context-free to keep the
158
  # relevance signal unbiased) so captions feed both selection and figures.
159
  if do_caption and frames_sorted:
160
+ pending = [rec for rec in frames_sorted if rec.caption is None]
161
+ if pending:
162
+ if progress:
163
+ progress(0.05, f"Captioning {len(pending)} frames…")
164
+ # One batched request when the Beam GPU endpoint is configured;
165
+ # caption_batch falls back to per-frame captioning otherwise.
166
+ captions = vision.caption_batch(
167
+ [(rec.path, "") for rec in pending], token=token
168
+ )
169
+ for rec, cap in zip(pending, captions):
170
+ rec.caption = cap or ""
171
 
172
  used: set[str] = set()
173
  steps: list[GuideStep] = []
src/vision.py CHANGED
@@ -20,6 +20,10 @@ _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, "
@@ -69,6 +73,84 @@ def _caption_via_api(image_path: str | Path, prompt: str, token: str | None) ->
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.
@@ -129,10 +211,10 @@ def caption_image(
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:
@@ -141,6 +223,10 @@ def caption_image(
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)
 
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
+ # Same idea for the Beam GPU endpoint: one hard failure (bad URL, bad token,
24
+ # endpoint deleted) disables it for the session rather than paying the timeout
25
+ # on every frame.
26
+ _BEAM_DISABLED = False
27
 
28
  _CAPTION_PROMPT = (
29
  "In one concise sentence, describe what this screenshot from a tutorial shows, "
 
73
  return (resp.choices[0].message.content or "").strip()
74
 
75
 
76
+ def _beam_post(items: list[tuple[str | Path, str]], prompt: str) -> list[str] | None:
77
+ """One request to the Beam endpoint. ``None`` on any failure."""
78
+ global _BEAM_DISABLED
79
+
80
+ import requests
81
+
82
+ payload = {
83
+ "items": [
84
+ {"image": _data_uri(path), "context": ctx or ""} for path, ctx in items
85
+ ],
86
+ "prompt": prompt,
87
+ }
88
+ headers = {"Content-Type": "application/json"}
89
+ if config.BEAM_CAPTION_TOKEN:
90
+ headers["Authorization"] = f"Bearer {config.BEAM_CAPTION_TOKEN}"
91
+
92
+ try:
93
+ resp = requests.post(
94
+ config.BEAM_CAPTION_URL,
95
+ json=payload,
96
+ headers=headers,
97
+ timeout=config.BEAM_CAPTION_TIMEOUT,
98
+ )
99
+ resp.raise_for_status()
100
+ captions = resp.json().get("captions")
101
+ if not isinstance(captions, list) or len(captions) != len(items):
102
+ return None
103
+ return [str(c or "").strip() for c in captions]
104
+ except Exception:
105
+ # Auth/URL problems repeat on every call, so stop trying this session.
106
+ _BEAM_DISABLED = True
107
+ return None
108
+
109
+
110
+ def _beam_caption_batch(
111
+ items: list[tuple[str | Path, str]], prompt: str
112
+ ) -> list[str] | None:
113
+ """Caption frames via the Beam GPU endpoint, chunked.
114
+
115
+ ``items`` is a list of ``(image_path, context)``. Returns captions aligned to
116
+ ``items``, or ``None`` if the endpoint is unconfigured or any chunk fails —
117
+ the caller then falls back to the HF API and local BLIP for the whole set,
118
+ which keeps the outcome predictable rather than half-Beam/half-BLIP.
119
+ """
120
+ if _BEAM_DISABLED or not config.BEAM_CAPTION_URL or not items:
121
+ return None
122
+
123
+ size = max(1, config.BEAM_CAPTION_BATCH_SIZE)
124
+ captions: list[str] = []
125
+ for start in range(0, len(items), size):
126
+ chunk = _beam_post(items[start:start + size], prompt)
127
+ if chunk is None:
128
+ return None
129
+ captions.extend(chunk)
130
+ return captions
131
+
132
+
133
+ def caption_batch(
134
+ items: list[tuple[str | Path, str]], *, token: str | None = None
135
+ ) -> list[str]:
136
+ """Caption a list of ``(image_path, context)`` pairs.
137
+
138
+ Prefers one batched call to the Beam GPU endpoint. Without it, falls back to
139
+ per-frame captioning via :func:`caption_image` so behaviour is unchanged when
140
+ Beam is not configured.
141
+ """
142
+ if not config.ENABLE_VISION or not items:
143
+ return ["" for _ in items]
144
+
145
+ captions = _beam_caption_batch(items, _CAPTION_PROMPT)
146
+ if captions is not None:
147
+ return captions
148
+
149
+ return [
150
+ caption_image(path, token=token, context=ctx) or "" for path, ctx in items
151
+ ]
152
+
153
+
154
  def _load_local_captioner() -> None:
155
  """Load the BLIP captioner directly (the image-to-text pipeline task was
156
  removed in transformers 5). Uses the GPU if a CUDA build of torch is present.
 
211
  ) -> str | None:
212
  """Return a one-line caption for a frame, or None if captioning is off/failed.
213
 
214
+ Order of preference: the Beam GPU endpoint (if ``DOCUMAKER_BEAM_CAPTION_URL``
215
+ is set), then an API vision-chat model (if any provider serves one), then
216
+ local BLIP. After a backend fails once it is skipped for the rest of the
217
+ session to avoid repeated dead calls. Local BLIP needs no token.
218
  """
219
  global _API_VLM_DISABLED
220
  if not config.ENABLE_VISION:
 
223
  if context:
224
  prompt += f" For context, this step is about: {context[:200]}"
225
 
226
+ beam = _beam_caption_batch([(image_path, context)], _CAPTION_PROMPT)
227
+ if beam and beam[0]:
228
+ return beam[0]
229
+
230
  if token and not _API_VLM_DISABLED:
231
  try:
232
  caption = _caption_via_api(image_path, prompt, token)