apssouza22 commited on
Commit
4bdf735
·
verified ·
1 Parent(s): 5e79132

Deploy GPU detection cluster (Docker Space)

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ node_modules
2
+ .git
3
+ .idea
4
+ .claude
5
+ dist
6
+ .DS_Store
7
+ .npmrc
8
+ *.md
9
+ !README.md
.gitignore ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ node_modules
2
+ dist
3
+ .DS_Store
4
+ /.idea/
5
+ /.claude/
ARTICLE.md ADDED
@@ -0,0 +1,530 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # WebGPU + AI in the Browser: Real-Time Object Detection on Video
2
+
3
+ *Part 3 of the WebGPU Video Processing series — builds on [Part 2: Hardware-Accelerated Video Processing](https://github.com/apssouza22/webgpu-video-ai/blob/main/ARTICLE.md)*
4
+
5
+ > **Live demo:** [apssouza22.github.io/webgpu-video-ai](https://apssouza22.github.io/webgpu-video-ai) — open it in Chrome or Edge with a GPU, no install required.
6
+ > **Source code:** [github.com/apssouza22/webgpu-video-ai](https://github.com/apssouza22/webgpu-video-ai)
7
+
8
+ ---
9
+
10
+ In Part 2 we built a complete encode/decode pipeline: MediaBunny decoded video frames, WebGPU composited layers, and WebCodecs exported a final MP4 — all without a server. That pipeline processed video offline, one frame at a time, as fast as the CPU allowed.
11
+
12
+ Part 3 adds a harder constraint: **real-time**. We want to run an AI object detection model on every video frame as it plays and draw bounding boxes on the GPU — without ever stalling the video. That means three things must happen concurrently: decoding, inference, and rendering. This article explains the architecture that makes that possible and why each piece of the stack was chosen.
13
+
14
+ ---
15
+
16
+ ## Why AI in the browser?
17
+
18
+ Running models in the browser used to mean a Python server, a REST API, and a round-trip that made real-time interaction impractical. Three recent additions change that:
19
+
20
+ - **WebGPU** exposes the GPU's thousands of cores directly to web code, making matrix math fast enough for neural networks.
21
+ - **ONNX Runtime Web** compiles a standard model format to run on that GPU from JavaScript.
22
+ - **Transformers.js** wraps ONNX Runtime with a Hugging Face–compatible API, so loading a model is one `pipeline()` call.
23
+
24
+ Together they let you download a model once to the browser cache and run inference locally, frame after frame, with no server. You can see this running right now at the [live demo](https://apssouza22.github.io/webgpu-video-ai) — the model downloads on first load, then detects objects in the video entirely on your local GPU.
25
+
26
+ ---
27
+
28
+ ## System architecture overview
29
+
30
+ Before diving into each component, here is how all the pieces fit together. Three execution contexts run concurrently and communicate through a small set of typed messages and GPU command queues:
31
+
32
+ ```mermaid
33
+ flowchart TD
34
+ subgraph MT["MAIN THREAD"]
35
+ CP["CompositionPlayer - RAF loop 60fps"]
36
+ VP["VideoPlayer"]
37
+ MB["MediaBunny decoder"]
38
+ GC["GpuCompositor"]
39
+ GVR["GpuVideoRenderer - pass 1"]
40
+ GIR["GpuImageRenderer - pass 2"]
41
+ GDR["GpuDetectionRenderer - pass 3"]
42
+ VOD["VideoObjectDetection"]
43
+ AP["AudioPlayer - Web Audio API"]
44
+ CV["Canvas output"]
45
+ end
46
+
47
+ subgraph DW["DETECTION WORKER"]
48
+ DWT["detection.worker.ts"]
49
+ CIB["createImageBitmap resize"]
50
+ OC["OffscreenCanvas 576x576"]
51
+ TJ["Transformers.js pipeline()"]
52
+ ONNX["ONNX Runtime Web"]
53
+ end
54
+
55
+ subgraph GPU["GPU HARDWARE"]
56
+ RQ["Render command queue - video and detection WGSL"]
57
+ CQ["Compute command queue - ONNX inference WGSL"]
58
+ end
59
+
60
+ CP --> VP
61
+ CP --> AP
62
+ VP --> MB
63
+ MB -->|VideoFrame| GC
64
+ GC --> GVR
65
+ GC --> GIR
66
+ GC --> GDR
67
+ GVR --> CV
68
+ GIR --> CV
69
+ GDR --> CV
70
+ GDR --> RQ
71
+ VP --> VOD
72
+ VOD -->|"postMessage(frame, [frame]) zero-copy"| DWT
73
+ DWT --> CIB --> OC --> TJ --> ONNX --> CQ
74
+ DWT -->|"detect-result bounding boxes"| VOD
75
+ VOD -->|detections| GDR
76
+ ```
77
+
78
+ The main thread never blocks on inference. The GPU hardware executes both rendering and inference command buffers, potentially in parallel on separate compute units.
79
+
80
+ ---
81
+
82
+ ## WebGPU: why it matters for AI
83
+
84
+ Graphics and AI share a fundamental operation: multiply a large matrix by a vector, millions of times per second. A CPU executes these sequentially, one element at a time. A GPU executes them in parallel across thousands of shader invocations that all run simultaneously.
85
+
86
+ ```mermaid
87
+ flowchart LR
88
+ subgraph CPU["CPU - Sequential O(N^3)"]
89
+ c1["row 0"] --> c2["row 1"] --> c3["row 2"] --> c4["row N"]
90
+ end
91
+ subgraph GPU["GPU - Parallel O(N) amortized"]
92
+ g1["core 0 - row 0"]
93
+ g2["core 1 - row 1"]
94
+ g3["core 2 - row 2"]
95
+ g4["core N - row N"]
96
+ end
97
+ ```
98
+
99
+ The difference is not incremental. A modern GPU can sustain hundreds of TFLOPS on matrix operations while a CPU manages a few TFLOPS at best. For a neural network, every layer is a matrix multiply. More parallelism means more layers per second, which means more frames per second.
100
+
101
+ WebGPU exposes this hardware through a low-overhead command model. Instead of issuing GPU calls one by one, you record a `GPUCommandEncoder`, accumulate all operations into it, and submit the whole batch in a single call:
102
+
103
+ ```typescript
104
+ const encoder = this.device.createCommandEncoder();
105
+ const pass = encoder.beginRenderPass({ ... });
106
+ pass.setPipeline(this.pipeline);
107
+ pass.setBindGroup(0, bindGroup);
108
+ pass.draw(6);
109
+ pass.end();
110
+ this.device.queue.submit([encoder.finish()]);
111
+ ```
112
+
113
+ The GPU processes the command buffer asynchronously. The CPU continues without waiting. This is the design that makes it possible to both render video and run AI inference at the same time.
114
+
115
+ ### Comparison with WebGL
116
+
117
+ WebGL was designed for rendering, not compute. Doing matrix math in WebGL requires abusing fragment shaders — packing data into textures and writing shaders that treat each pixel as a matrix element. It works, but it is awkward, limited in precision, and cannot express modern network architectures cleanly.
118
+
119
+ WebGPU adds a dedicated **compute shader** stage (`@compute`). ONNX Runtime Web uses compute shaders for every operation in a neural network: matrix multiplication, convolution, attention, normalization. The shader code is written in WGSL, the same language used for rendering, and runs on the same hardware. The AI pipeline and the rendering pipeline live in the same GPU process, sharing the same device and queue.
120
+
121
+ ---
122
+
123
+ ## Transformers.js and ONNX: how they work
124
+
125
+ ### ONNX: a universal model format
126
+
127
+ ONNX (Open Neural Network Exchange) is a file format that describes a model as a computation graph. Nodes are operations (matrix multiply, ReLU, softmax), and edges are tensors that flow between them.
128
+
129
+ ```mermaid
130
+ flowchart TD
131
+ INPUT["Input image - 576x576x3"]
132
+ BACKBONE["Backbone ResNet-D\nConv2D and BatchNorm\nconvolutional feature extraction"]
133
+ ENCODER["Encoder DETR head\nMulti-head self-attention\nMatMul and Softmax and Add"]
134
+ DECODER["Decoder\nCross-attention over object queries"]
135
+ BOX["Box head\nxmin, ymin, xmax, ymax"]
136
+ CLASS["Class head\n60 object labels"]
137
+
138
+ INPUT --> BACKBONE
139
+ BACKBONE -->|"feature maps"| ENCODER
140
+ ENCODER -->|"encoded tokens"| DECODER
141
+ DECODER --> BOX
142
+ DECODER --> CLASS
143
+ ```
144
+
145
+ Training frameworks — PyTorch, TensorFlow, JAX — can all export to ONNX. ONNX Runtime then runs that graph on any supported backend: CPU, CUDA, CoreML, or **WebGPU**. When loaded in the browser, ONNX Runtime Web reads the graph, allocates GPU buffers for each tensor, and compiles the operations into WGSL shaders at load time — which is why the first inference ("warmup") always takes longer than subsequent ones.
146
+
147
+ The model used here, `onnx-community/rfdetr_medium-ONNX`, is a real-time detection transformer. It takes a `576×576` image as input and returns bounding boxes with class labels and confidence scores. The ONNX file is hosted on Hugging Face and downloaded into the browser's cache the first time you load the [demo](https://apssouza22.github.io/webgpu-video-ai).
148
+
149
+ ### Transformers.js: pipeline abstraction
150
+
151
+ Transformers.js wraps ONNX Runtime Web with a high-level API modeled after the Hugging Face `transformers` Python library. In [`detection.worker.ts`](https://github.com/apssouza22/webgpu-video-ai/blob/main/src/detection/detection.worker.ts), loading the model is a single call:
152
+
153
+ ```typescript
154
+ const MODEL_ID = 'onnx-community/rfdetr_medium-ONNX';
155
+
156
+ detector = await pipeline('object-detection', MODEL_ID, {
157
+ device: 'webgpu', // compile ONNX graph to WGSL compute shaders
158
+ dtype: 'fp16', // faster WebGPU inference (fp32 is more accurate)
159
+ progress_callback: (progress) => {
160
+ // streams download progress: file name + percentage
161
+ post({ type: 'status', message: `Downloading ${progress.file} (${percent}%)…` });
162
+ },
163
+ });
164
+ ```
165
+
166
+ `device: 'webgpu'` tells Transformers.js to compile the ONNX graph onto the GPU via WebGPU. The `progress_callback` is useful because the model is tens of megabytes: you can display a real download bar rather than a silent spinner — which is exactly what the [demo](https://apssouza22.github.io/webgpu-video-ai) shows in the status area during first load.
167
+
168
+ After the `await` resolves, `detector` is a callable function. You pass it an image (here an `OffscreenCanvas`) and get back structured results:
169
+
170
+ ```typescript
171
+ const results = await detect(canvas, { threshold: 0.5, percentage: true });
172
+ // [{ label: 'person', score: 0.91, box: { xmin: 0.12, ymin: 0.08, xmax: 0.44, ymax: 0.97 } }, ...]
173
+ ```
174
+
175
+ `percentage: true` returns box coordinates normalized to 0–1, which matches the coordinate system the WebGPU detection shader uses directly — no conversion needed.
176
+
177
+ ---
178
+
179
+ ## The parallel processing problem
180
+
181
+ Here is the core tension. Video at 30 fps gives you 33 ms per frame. Inference alone exceeds that budget on most hardware:
182
+
183
+ ```mermaid
184
+ gantt
185
+ title Per-frame time budget at 30fps (33ms window)
186
+ dateFormat x
187
+ axisFormat %Lms
188
+
189
+ section Main thread
190
+ Decode frame (MediaBunny) : 0, 5
191
+ Cache image overlays : 5, 6
192
+ GPU render 3 passes : 6, 8
193
+
194
+ section Worker thread
195
+ AI inference RF-DETR : crit, 0, 80
196
+ ```
197
+
198
+ RF-DETR on a mid-range GPU takes 30–80 ms. If inference ran synchronously on the main thread, the video would stutter every single frame.
199
+
200
+ The key insight is that inference and rendering **do not need to happen in the same frame**. The detections from frame N are still valid for frames N+1 and N+2 — objects do not teleport. You can display slightly stale boxes and refresh them as new inference results arrive asynchronously. That observation unlocks the architecture.
201
+
202
+ ---
203
+
204
+ ## Web Workers: the isolation layer
205
+
206
+ A Web Worker is a JavaScript execution context that runs on a separate OS thread. It shares no memory with the main thread, has its own event loop, and cannot access the DOM. What it *can* access: WebGPU, `OffscreenCanvas`, `VideoFrame`, and `fetch`.
207
+
208
+ Isolating the model inside a Worker gives you three critical properties:
209
+
210
+ **1. The main thread is never blocked.** Model loading, WGSL shader compilation, and inference all happen on the worker thread. The `requestAnimationFrame` loop that drives video playback continues at 60 fps regardless of what the model is doing.
211
+
212
+ **2. The GPU is shared.** Both the worker's WebGPU inference pipeline and the main thread's rendering pipeline target the same physical GPU. They share no JavaScript objects, but the hardware executes their command buffers concurrently when possible.
213
+
214
+ **3. `VideoFrame` transfer is zero-copy.** `VideoFrame` is a transferable object. When you pass it in the `postMessage` transfer list, the underlying GPU texture buffer moves to the worker without copying pixel data:
215
+
216
+ ```mermaid
217
+ sequenceDiagram
218
+ participant MT as Main Thread
219
+ participant W as Detection Worker
220
+
221
+ MT->>MT: decode VideoFrame (owns GPU buffer)
222
+ MT->>W: postMessage(frame, [frame]) - zero copy, no memcpy
223
+ Note over MT: frame is neutered, cannot be used
224
+ Note over W: frame received, owns the GPU buffer
225
+ W->>W: createImageBitmap resize to 576x576
226
+ W->>W: ONNX inference on GPU
227
+ W-->>MT: detect-result with bounding boxes
228
+ Note over W: worker calls frame.close()
229
+ ```
230
+
231
+ A 1280×720 frame at 4 bytes per pixel is 3.5 MB. Copying that on every frame would add ~100 MB/s of memory bandwidth overhead — the zero-copy transfer makes the overhead negligible:
232
+
233
+ ```typescript
234
+ // ObjectDetector.ts — main thread
235
+ this.worker.postMessage(
236
+ { type: 'detect', id, threshold, frame },
237
+ [frame], // ← transfer list: frame is moved, not copied
238
+ );
239
+ // frame is now null/neutered on the main thread
240
+ ```
241
+
242
+ ### The typed message protocol
243
+
244
+ The worker communicates through a narrow, explicitly typed protocol defined in [`workerMessages.ts`](https://github.com/apssouza22/webgpu-video-ai/blob/main/src/detection/workerMessages.ts):
245
+
246
+ ```mermaid
247
+ sequenceDiagram
248
+ participant MT as Main Thread
249
+ participant W as Detection Worker
250
+
251
+ MT->>W: type init
252
+ W-->>MT: type status - Downloading model
253
+ W-->>MT: type status - Compiling RF-DETR shaders
254
+ W-->>MT: type ready
255
+
256
+ MT->>W: type detect, id 42, threshold 0.5, frame VideoFrame
257
+ Note over W: resize to 576x576, run ONNX graph
258
+ W-->>MT: type detect-result, id 42, results array
259
+
260
+ MT->>W: type detect, id 43, frame VideoFrame
261
+ W-->>MT: type error, id 43, error message
262
+ ```
263
+
264
+ The numeric `id` field is essential: it matches each response to its originating request. The `ObjectDetector` class on the main thread stores pending promises in a `Map<id, {resolve, reject}>`:
265
+
266
+ ```typescript
267
+ // ObjectDetector.ts
268
+ async detect(frame: VideoFrame, options: { threshold: number }): Promise<DetectionResult[]> {
269
+ const id = this.nextId++;
270
+ return new Promise((resolve, reject) => {
271
+ this.pending.set(id, { resolve, reject });
272
+ this.worker.postMessage({ type: 'detect', id, threshold: options.threshold, frame }, [frame]);
273
+ });
274
+ }
275
+
276
+ // on 'detect-result' message:
277
+ const pending = this.pending.get(message.id);
278
+ pending?.resolve(message.results);
279
+ this.pending.delete(message.id);
280
+ ```
281
+
282
+ This is a clean bridge from a message-passing interface to async/await without a library.
283
+
284
+ ---
285
+
286
+ ## The detection pipeline end to end
287
+
288
+ ### Step 1 — Frame preprocessing inside the worker
289
+
290
+ RF-DETR expects a `576×576` image. Video frames are typically `1280×720` or larger. The worker resizes the transferred `VideoFrame` using `createImageBitmap` with explicit resize parameters, then draws it onto a persistent `OffscreenCanvas`:
291
+
292
+ ```typescript
293
+ // detection.worker.ts
294
+ const MODEL_INPUT_SIZE = 576;
295
+
296
+ async function runDetection(frame: VideoFrame, threshold: number) {
297
+ const { canvas, ctx } = getPreprocessSurface(); // reused 576×576 OffscreenCanvas
298
+
299
+ const bitmap = await createImageBitmap(frame, {
300
+ resizeWidth: MODEL_INPUT_SIZE,
301
+ resizeHeight: MODEL_INPUT_SIZE, // GPU-accelerated resize
302
+ });
303
+ ctx.drawImage(bitmap, 0, 0);
304
+ bitmap.close();
305
+ frame.close(); // release the transferred VideoFrame
306
+
307
+ return detect(canvas, { threshold, percentage: true });
308
+ }
309
+ ```
310
+
311
+ `createImageBitmap` with resize parameters is GPU-accelerated in browsers that support it. The `OffscreenCanvas` is created once with `{ willReadFrequently: true }` so the browser optimizes its backing store for the one CPU readback that Transformers.js needs to build the input tensor.
312
+
313
+ ```mermaid
314
+ flowchart TD
315
+ VF["VideoFrame 1280x720\ntransferred from main thread"]
316
+ CIB["createImageBitmap\nresizeWidth 576, resizeHeight 576\nGPU-accelerated downscale"]
317
+ OC["OffscreenCanvas 576x576\nreused each frame, willReadFrequently true"]
318
+ T1["1. Read pixels from canvas"]
319
+ T2["2. Build Float32 input tensor"]
320
+ T3["3. Upload tensor to GPU buffer"]
321
+ T4["4. Run ONNX graph via WGSL compute shaders"]
322
+ T5["5. Readback output tensor to CPU"]
323
+ RES["DetectionResult array\nlabel, score, box coords normalized 0 to 1"]
324
+
325
+ VF -->|"frame.close() after bitmap"| CIB
326
+ CIB --> OC
327
+ OC --> T1 --> T2 --> T3 --> T4 --> T5 --> RES
328
+ ```
329
+
330
+ ### Step 2 — Throttling: one frame in flight
331
+
332
+ Running inference takes longer than a single video frame. The `VideoObjectDetection` class in [`VideoObjectDetection.ts`](https://github.com/apssouza22/webgpu-video-ai/blob/main/src/player/VideoObjectDetection.ts) implements a simple but effective throttle: if a detection is already running, the incoming frame is held as a "pending" replacement. When inference completes, it immediately starts on the pending frame, dropping any intermediate frames:
333
+
334
+ ```typescript
335
+ schedule(videoFrame: VideoFrame): void {
336
+ if (this.detectionBusy) {
337
+ this.pendingDetectionFrame?.close(); // discard previous pending
338
+ this.pendingDetectionFrame = new VideoFrame(videoFrame);
339
+ return;
340
+ }
341
+ void this.runDetection(new VideoFrame(videoFrame));
342
+ }
343
+ ```
344
+
345
+ The state machine looks like this:
346
+
347
+ ```mermaid
348
+ flowchart TD
349
+ A["schedule videoFrame"] --> B{detectionBusy?}
350
+ B -- NO --> C["runDetection frame\ndetectionBusy = true"]
351
+ B -- YES --> D["pendingFrame = frame\nclose previous pending if any"]
352
+ C --> E["await ONNX inference"]
353
+ E --> F["store detections\ndetectionBusy = false"]
354
+ F --> G{pendingFrame?}
355
+ G -- YES --> H["runDetection pendingFrame\npendingFrame = null"]
356
+ G -- NO --> I["idle - await next schedule"]
357
+ H --> E
358
+ D -.->|waits implicitly| F
359
+ ```
360
+
361
+ At any given moment there is at most one detection in flight and one pending. The detection rate tracks hardware capability automatically — a fast GPU detects every frame, a slow GPU skips frames without any explicit rate-limiting code.
362
+
363
+ ### Step 3 — Version-gating stale results
364
+
365
+ There is a subtle race: between submitting a detection and receiving the result, the user might seek to a different position in the video. Applying old bounding boxes to new content looks wrong. An incrementing version counter handles this:
366
+
367
+ ```typescript
368
+ private async runDetection(detectionFrame: VideoFrame): Promise<void> {
369
+ const detectionVersion = ++this.detectionVersion;
370
+ // ...inference (may take 50ms)...
371
+ if (detectionVersion !== this.detectionVersion) {
372
+ return; // a newer detection was started — discard this result
373
+ }
374
+ this.detections = toGpuDetections(results);
375
+ }
376
+ ```
377
+
378
+ Every call to `runDetection` increments the counter. On completion, it checks that the counter has not moved. If it has, the stale result is silently dropped.
379
+
380
+ ---
381
+
382
+ ## Rendering detections on the GPU
383
+
384
+ The bounding boxes returned by the model are JavaScript objects. To draw them without looping over pixels in JavaScript, they are packed into a WebGPU uniform buffer and sent to a WGSL shader that draws the boxes entirely on the GPU.
385
+
386
+ ### The uniform buffer layout
387
+
388
+ ```
389
+ Offset 0 ┌─────────────────────────────────┐
390
+ │ count (u32, 4 bytes) │
391
+ Offset 4 │ lineWidth (f32, 4 bytes) │
392
+ Offset 8 │ _pad (vec2f, 8 bytes) │
393
+ Offset 16 ├─────────────────────────────────┤ ← boxes start
394
+ │ box[0].bounds (vec4f, 16 bytes) │ xmin, ymin, xmax, ymax
395
+ │ box[0].color (vec4f, 16 bytes) │ r, g, b, alpha
396
+ Offset 48 ├─────────────────────────────────┤
397
+ │ box[1].bounds (16 bytes) │
398
+ │ box[1].color (16 bytes) │
399
+ ├─────────────────────────────────┤
400
+ │ ... │
401
+ │ box[31] │
402
+ Offset 1040└─────────────────────────────────┘
403
+ Total: 16 + 32 × 32 = 1,040 bytes (fixed, allocated once at startup)
404
+ ```
405
+
406
+ Fixed at 32 boxes maximum — enough for any real scene, and the constant size means the buffer is allocated once at startup and reused every frame with a single `writeBuffer` call.
407
+
408
+ ### The fragment shader draws borders analytically
409
+
410
+ Instead of uploading one quad per bounding box (which would require a dynamic vertex buffer), the shader uses a **full-screen triangle pass** and tests each pixel analytically against all boxes in the uniform buffer:
411
+
412
+ ```wgsl
413
+ fn onBorder(uv: vec2f, bounds: vec4f, lineWidth: f32) -> bool {
414
+ let insideX = step(bounds.x, uv.x) * step(uv.x, bounds.z);
415
+ let insideY = step(bounds.y, uv.y) * step(uv.y, bounds.w);
416
+
417
+ let nearLeft = abs(uv.x - bounds.x) < lineWidth;
418
+ let nearRight = abs(uv.x - bounds.z) < lineWidth;
419
+ let nearTop = abs(uv.y - bounds.y) < lineWidth;
420
+ let nearBottom = abs(uv.y - bounds.w) < lineWidth;
421
+
422
+ return (nearLeft && insideY > 0.5) ||
423
+ (nearRight && insideY > 0.5) ||
424
+ (nearTop && insideX > 0.5) ||
425
+ (nearBottom && insideX > 0.5);
426
+ }
427
+
428
+ @fragment
429
+ fn fragmentMain(input: VertexOutput) -> @location(0) vec4f {
430
+ var color = vec4f(0.0); // transparent by default
431
+ for (var i = 0u; i < detection.count; i++) {
432
+ let box = detection.boxes[i];
433
+ if (onBorder(input.uv, box.bounds, detection.lineWidth)) {
434
+ color = vec4f(box.color.rgb, 1.0);
435
+ }
436
+ }
437
+ return color;
438
+ }
439
+ ```
440
+
441
+ Every fragment (pixel) independently tests whether it falls on the border of any detection box. The GPU runs these tests in parallel across all pixels simultaneously — ~921,600 pixels × 5 box tests = ~4.6M comparisons completed in a single render pass in under 1ms. No geometry uploads, no dynamic buffers, no JavaScript loops over pixels.
442
+
443
+ ### The compositing order
444
+
445
+ The [`GpuCompositor`](https://github.com/apssouza22/webgpu-video-ai/blob/main/src/gpu/GpuCompositor.ts) issues three render passes per frame in a fixed order, all targeting the same canvas texture:
446
+
447
+ ```mermaid
448
+ flowchart TD
449
+ CT["Canvas texture 1280x720"]
450
+ P1["Pass 1 - GpuVideoRenderer\nloadOp clear\nVideoFrame via importExternalTexture\nalpha blend"]
451
+ S1["video frame rendered"]
452
+ P2["Pass 2 - GpuImageRenderer\nloadOp load\nHTMLImageElement to rgba8unorm cached texture\nalpha blend"]
453
+ S2["video plus image overlays"]
454
+ P3["Pass 3 - GpuDetectionRenderer\nloadOp load\nuniform buffer with box list\nalpha blend"]
455
+ OUT["Final composited frame\nvideo, overlays, and detection boxes"]
456
+
457
+ CT --> P1 --> S1 --> P2 --> S2 --> P3 --> OUT
458
+ ```
459
+
460
+ Each pass's `loadOp: 'load'` means "start from what the previous pass wrote." WebGPU serializes passes within a queue submission, so each pass sees the accumulated result of all prior passes. The detection layer always renders last, so boxes are always visible on top of any overlay.
461
+
462
+ ---
463
+
464
+ ## The warmup problem
465
+
466
+ ONNX Runtime Web compiles WGSL shaders for each operation in the model graph on first use. On a complex model like RF-DETR, this takes one to three seconds and produces a visible freeze if it happens during playback. The fix is one inference before the user presses play:
467
+
468
+ ```typescript
469
+ // main.ts
470
+ setStatus('Warming up RF-DETR shaders (first inference)…');
471
+ await player.getVideoPlayer().warmupDetection(0);
472
+ ```
473
+
474
+ This decodes frame 0, runs it through the full detection pipeline, and discards the result. The side effect is that all WGSL shaders are compiled and cached by the WebGPU driver. Every subsequent inference hits already-compiled pipelines and runs at steady-state speed. You can observe the warmup message in the status bar when you first open the [demo](https://apssouza22.github.io/webgpu-video-ai).
475
+
476
+ ---
477
+
478
+ ## Required HTTP headers
479
+
480
+ Transformers.js uses `SharedArrayBuffer` internally for efficient WASM memory management. `SharedArrayBuffer` is gated behind cross-origin isolation — the page must be served with:
481
+
482
+ ```
483
+ Cross-Origin-Opener-Policy: same-origin
484
+ Cross-Origin-Embedder-Policy: require-corp
485
+ ```
486
+
487
+ Without them, `SharedArrayBuffer` is unavailable and Transformers.js either falls back to a slower code path or fails outright. In Vite, these are set in the dev server configuration:
488
+
489
+ ```typescript
490
+ // vite.config.ts
491
+ server: {
492
+ headers: {
493
+ 'Cross-Origin-Opener-Policy': 'same-origin',
494
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
495
+ },
496
+ }
497
+ ```
498
+
499
+ You must also set them in production. A missing COEP header is one of the most common reasons a Transformers.js app works in development but silently fails after deployment. Check them first when debugging.
500
+
501
+ ---
502
+
503
+ ## Key lessons
504
+
505
+ - **Transfer, do not copy.** `VideoFrame` is a transferable. Pass it with `[frame]` in the `postMessage` transfer list to move the GPU buffer to the worker with zero memory copy. Copying 3.5 MB of pixel data per frame destroys throughput.
506
+
507
+ - **Warm up before you need it.** ONNX shader compilation happens on first inference. Run one dummy call at startup — the [demo](https://apssouza22.github.io/webgpu-video-ai) does this before revealing the play button — so playback never triggers a compile stall mid-video.
508
+
509
+ - **One detection in flight.** Queuing detections faster than the model can process them causes memory pressure and bursty latency. A single pending-frame slot gives you the freshest possible result at any hardware speed, without any explicit rate-limiting.
510
+
511
+ - **Version-gate results.** Any async operation that feeds the render loop needs a staleness check. An incrementing counter is enough. Without it, seeking causes stale boxes to flash on screen.
512
+
513
+ - **Analyze pixels in the shader, not JavaScript.** Drawing bounding boxes as a full-screen analytical test is faster than uploading per-box geometry because the GPU runs the test across all pixels simultaneously with zero JavaScript overhead per pixel.
514
+
515
+ - **COEP/COOP are not optional.** Set them in both development and production. Check them first when debugging a Transformers.js deployment.
516
+
517
+ ---
518
+
519
+ ## What this architecture enables
520
+
521
+ The pipeline described here runs a 60-class object detector on live video at whatever rate the hardware supports — from a few detections per second on integrated graphics to near-frame-rate on a discrete GPU — without ever stalling video playback. You can see the detection FPS counter in the [live demo](https://apssouza22.github.io/webgpu-video-ai) updating in real time as the model runs.
522
+
523
+ More broadly, the same structure applies to any model you want to run on video: pose estimation, segmentation, depth prediction, face recognition. The worker isolation, the zero-copy transfer, the warmup, and the GPU rendering layer are all model-agnostic. Swap the Transformers.js `pipeline()` call and update the WGSL uniform struct, and the architecture carries the new task without structural changes.
524
+
525
+ The browser is no longer a thin client that sends video to a server for processing. It is a capable ML runtime with direct access to the GPU, a mature async concurrency model, and zero-copy primitives that make real-time inference practical. The gap between what you can build in the browser and what requires a backend is narrowing fast.
526
+
527
+ ---
528
+
529
+ *Full source code: [github.com/apssouza22/webgpu-video-ai](https://github.com/apssouza22/webgpu-video-ai)*
530
+ *Live demo: [apssouza22.github.io/webgpu-video-ai](https://apssouza22.github.io/webgpu-video-ai)*
CLAUDE.md ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # CLAUDE.md
2
+
3
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
4
+
5
+ ## Commands
6
+
7
+ ```bash
8
+ npm run dev # Vite (5180) + detection broker API (8787)
9
+ npm run build # Type-check (tsc) + Vite bundle → docs/
10
+ npm run preview # Serve the production build locally
11
+ npm run dev:web # Vite only
12
+ npm run dev:api # API broker only (8787)
13
+ ```
14
+
15
+ No test suite exists — tested manually in the browser and via `curl`.
16
+
17
+ ## Architecture
18
+
19
+ This is a **browser GPU detection cluster**: RF-DETR inference runs in a Web Worker on WebGPU, while a Node broker exposes HTTP so backends can submit detection jobs.
20
+
21
+ ### Data flow
22
+
23
+ ```
24
+ curl / Python / app
25
+ → Node broker (server/) — task queue, image fetch
26
+ → SSE → browser host (src/host/detectionNodeHost.ts)
27
+ → ObjectDetector → detection.worker.ts (Transformers.js, WebGPU)
28
+ → results POST back to broker → HTTP response
29
+ ```
30
+
31
+ ### Key modules
32
+
33
+ | Path | Role |
34
+ |------|------|
35
+ | `src/host/detectionNodeHost.ts` | Registers host, listens for SSE tasks, runs inference |
36
+ | `src/detection/ObjectDetector.ts` | Main-thread API to the detection worker |
37
+ | `src/detection/detection.worker.ts` | RF-DETR on `OffscreenCanvas`; model: `onnx-community/rfdetr_medium-ONNX` |
38
+ | `server/index.ts` | Express broker: `/v1/detect`, host registration, SSE stream |
39
+
40
+ ### Web Worker and CORS requirements
41
+
42
+ The detection worker uses Transformers.js with `device: 'webgpu'`. Vite sends `Cross-Origin-Opener-Policy: same-origin` and `Cross-Origin-Embedder-Policy: require-corp` (see `vite.config.ts`) for SharedArrayBuffer / WASM.
43
+
44
+ ### Build output
45
+
46
+ Production build targets `docs/` with base path `/webgpu-video-ai/` for GitHub Pages. Transformers.js is excluded from `optimizeDeps` because models download at runtime.
Dockerfile ADDED
@@ -0,0 +1,24 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces (Docker SDK) — serves UI + detection broker on port 7860
2
+ FROM node:22-bookworm-slim AS build
3
+
4
+ WORKDIR /app
5
+ COPY package.json package-lock.json ./
6
+ RUN npm ci
7
+ COPY . .
8
+ RUN npm run build:space
9
+
10
+ FROM node:22-bookworm-slim
11
+
12
+ WORKDIR /app
13
+ ENV NODE_ENV=production
14
+ ENV PORT=7860
15
+ ENV SERVE_STATIC=1
16
+
17
+ COPY package.json package-lock.json ./
18
+ RUN npm ci
19
+ COPY --from=build /app/docs ./docs
20
+ COPY --from=build /app/server ./server
21
+ COPY --from=build /app/shared ./shared
22
+
23
+ EXPOSE 7860
24
+ CMD ["npm", "start"]
README.md CHANGED
@@ -1,12 +1,115 @@
1
  ---
2
- title: Webgpu Cluster
3
- emoji: 🏃
4
- colorFrom: red
5
- colorTo: pink
6
  sdk: docker
 
7
  pinned: false
8
- license: mit
9
- short_description: 'A demo of how to create a webgpu cluster '
 
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: GPU Detection Cluster
3
+ emoji: 🎮
4
+ colorFrom: blue
5
+ colorTo: purple
6
  sdk: docker
7
+ app_port: 7860
8
  pinned: false
9
+ models:
10
+ - onnx-community/rfdetr_medium-ONNX
11
+ - HuggingFaceTB/SmolVLM-500M-Instruct
12
  ---
13
 
14
+ # GPU detection cluster
15
+
16
+ Browser-hosted **RF-DETR** object detection exposed over HTTP. Inference runs on **WebGPU** in a dedicated worker; a **Node broker** queues tasks and lets you call detection from `curl`, Python, or any backend.
17
+
18
+ ```text
19
+ curl / Python → Node broker (task queue) → SSE → browser host (WebGPU)
20
+ ```
21
+
22
+ ## Quick start
23
+
24
+ ```bash
25
+ npm install
26
+ npm run dev
27
+ ```
28
+
29
+ 1. Open **http://localhost:5180**
30
+ 2. Choose a **model to share**, pick a host id (e.g. `my-gpu-node`), and click **Start hosting** (loads the model on WebGPU; keep the tab open)
31
+ 3. Open **http://localhost:5180/monitor.html** to see all registered hosts and copy curl examples
32
+ 4. From another terminal:
33
+
34
+ ```bash
35
+ curl -X POST 'http://localhost:5180/v1/detect' \
36
+ -H 'Content-Type: application/json' \
37
+ -d '{
38
+ "host": "my-gpu-node",
39
+ "image_url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png",
40
+ "threshold": 0.5
41
+ }'
42
+ ```
43
+
44
+ Response:
45
+
46
+ ```json
47
+ {
48
+ "task_id": "...",
49
+ "host": "my-gpu-node",
50
+ "threshold": 0.5,
51
+ "detections": [
52
+ { "label": "cat", "score": 0.92, "box": { "xmin": 10, "ymin": 20, "xmax": 200, "ymax": 180 } }
53
+ ]
54
+ }
55
+ ```
56
+
57
+ `npm run dev` runs **Vite** (port 5180) and the **API** (port 8787). Vite proxies `/v1` and `/api` to the broker.
58
+
59
+ ## Endpoints
60
+
61
+ | Method | Path | Description |
62
+ |--------|------|-------------|
63
+ | `POST` | `/v1/detect` | Run object detection (waits for host; body below) |
64
+ | `POST` | `/v1/describe` | Describe an image with SmolVLM (waits for host; body below) |
65
+ | `GET` | `/v1/hosts` | List registered hosts and online status |
66
+ | `GET` | `/v1/models` | Cluster models (id, label, whether the host app implements them) |
67
+ | — | `/monitor.html` | Dashboard — live host list and curl examples |
68
+ | `GET` | `/v1/tasks/:id` | Task status / results |
69
+ | `POST` | `/api/hosts/register` | Called by the browser host |
70
+ | `GET` | `/api/hosts/stream?host_id=` | SSE — browser host receives tasks |
71
+
72
+ **`POST /v1/detect` body:**
73
+
74
+ - `host` (required) — id from the browser host page
75
+ - `image_url` or `image_base64` (required) — broker fetches URLs server-side
76
+ - `threshold` (optional, default `0.5`)
77
+
78
+ ## Scripts
79
+
80
+ - `npm run dev` — Vite + API together
81
+ - `npm run dev:api` — API only (port 8787)
82
+ - `npm run dev:web` — Vite only (proxies API when both run)
83
+ - `npm run build` — typecheck + production bundle to `docs/`
84
+ - `npm run start:api` — run broker without watch
85
+
86
+ ## Cluster models
87
+
88
+ Available models are defined in `shared/clusterModels.ts`. The host page dropdown and `GET /v1/models` both read from that list. Add a new entry there, then wire loading in `src/host/detectionNodeHost.ts` (`ensureModelLoaded`) and any broker routes.
89
+
90
+ | Model id | Endpoint | Worker |
91
+ |----------|----------|--------|
92
+ | `rfdetr-medium` | `POST /v1/detect` | `src/detection/detection.worker.ts` |
93
+ | `smolvlm-500m` | `POST /v1/describe` | `src/videodescription/videodescription.worker.ts` |
94
+
95
+ Object detection uses `onnx-community/rfdetr_medium-ONNX` via `@huggingface/transformers` with `device: 'webgpu'`. Video description uses `HuggingFaceTB/SmolVLM-500M-Instruct` with quantized vision/decoder weights (same setup as the [SmolVLM realtime WebGPU demo](https://huggingface.co/spaces/webml-community/smolvlm-realtime-webgpu)). Models download from Hugging Face on first load. Inference runs in a **Web Worker**; images arrive as base64 from the broker and are converted to `VideoFrame` on the host page.
96
+
97
+ **`POST /v1/describe` body:**
98
+
99
+ - `host` (required)
100
+ - `image_url` or `image_base64` (required)
101
+ - `instruction` (optional, default `"What do you see?"`)
102
+ - `max_new_tokens` (optional, default `100`)
103
+
104
+ ## Requirements
105
+
106
+ - Browser with **WebGPU** (Chrome or Edge desktop recommended)
107
+ - Dev server COOP/COEP headers in `vite.config.ts` (required for Transformers.js)
108
+
109
+ ## Hugging Face Spaces
110
+
111
+ Deploy as a **Docker Space** (broker + UI on port 7860). Step-by-step instructions: [SPACES.md](./SPACES.md).
112
+
113
+ ## License
114
+
115
+ MIT
SPACES.md ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Deploy to Hugging Face Spaces
2
+
3
+ This app needs a **Docker Space** (Node broker + static WebGPU UI on one origin with COOP/COEP headers).
4
+
5
+ ## 1. Log in
6
+
7
+ ```bash
8
+ hf auth login
9
+ ```
10
+
11
+ Create a token at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) (write access).
12
+
13
+ ## 2. Create the Space
14
+
15
+ Replace `YOUR_USERNAME` with your Hugging Face username:
16
+
17
+ ```bash
18
+ hf repos create YOUR_USERNAME/gpu-video-cluster \
19
+ --type space \
20
+ --space-sdk docker \
21
+ --exist-ok
22
+ ```
23
+
24
+ Optional: pick hardware (default is free CPU):
25
+
26
+ ```bash
27
+ hf repos create YOUR_USERNAME/gpu-video-cluster \
28
+ --type space \
29
+ --space-sdk docker \
30
+ --flavor cpu-basic \
31
+ --exist-ok
32
+ ```
33
+
34
+ ## 3. Push the repo
35
+
36
+ From the project root:
37
+
38
+ ```bash
39
+ hf upload . YOUR_USERNAME/gpu-video-cluster \
40
+ --repo-type space \
41
+ --exclude ".git/*" \
42
+ --exclude "node_modules/*" \
43
+ --exclude ".idea/*" \
44
+ --exclude ".claude/*" \
45
+ --commit-message "Deploy GPU detection cluster"
46
+ ```
47
+
48
+ The Space builds from `Dockerfile`, runs `npm run build:space`, and starts `npm start` on port **7860**.
49
+
50
+ ## 4. Use the Space
51
+
52
+ 1. Open `https://YOUR_USERNAME-gpu-video-cluster.hf.space/`
53
+ 2. Pick a host id and click **Start hosting** (WebGPU runs in the browser tab)
54
+ 3. Open `/monitor.html` for curl examples
55
+ 4. Call the API on the same URL, e.g.:
56
+
57
+ ```bash
58
+ curl -X POST 'https://YOUR_USERNAME-gpu-video-cluster.hf.space/v1/detect' \
59
+ -H 'Content-Type: application/json' \
60
+ -d '{
61
+ "host": "my-gpu-node",
62
+ "image_url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png",
63
+ "threshold": 0.5
64
+ }'
65
+ ```
66
+
67
+ ## Local Docker test
68
+
69
+ ```bash
70
+ docker build -t gpu-video-cluster .
71
+ docker run --rm -p 7860:7860 gpu-video-cluster
72
+ ```
73
+
74
+ Then open http://localhost:7860/
75
+
76
+ ## Notes
77
+
78
+ - **WebGPU** runs in the visitor’s browser (Chrome/Edge desktop). The Space container only hosts the broker and static files.
79
+ - First model load downloads weights from the Hub into the browser cache.
80
+ - GitHub Pages builds use `npm run build` (base `/webgpu-video-ai/`). Spaces use `npm run build:space` (`vite build --mode space`, base `/`) via the Dockerfile.
index.html ADDED
@@ -0,0 +1,128 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>GPU detection cluster host</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ font-family: system-ui, sans-serif;
11
+ }
12
+ body {
13
+ margin: 0;
14
+ padding: 24px;
15
+ background: #111;
16
+ color: #eee;
17
+ }
18
+ main {
19
+ max-width: 720px;
20
+ margin: 0 auto;
21
+ }
22
+ h1 {
23
+ margin: 0 0 8px;
24
+ font-size: 1.4rem;
25
+ }
26
+ p {
27
+ color: #bdbdbd;
28
+ line-height: 1.5;
29
+ }
30
+ a {
31
+ color: #9eb6ff;
32
+ }
33
+ .row {
34
+ display: flex;
35
+ flex-wrap: wrap;
36
+ gap: 12px;
37
+ margin: 16px 0;
38
+ }
39
+ input,
40
+ select,
41
+ button {
42
+ font: inherit;
43
+ }
44
+ input,
45
+ select {
46
+ flex: 1;
47
+ min-width: 200px;
48
+ padding: 10px 12px;
49
+ border: 1px solid #333;
50
+ border-radius: 8px;
51
+ background: #0d1117;
52
+ color: #eee;
53
+ }
54
+ select {
55
+ cursor: pointer;
56
+ }
57
+ .field {
58
+ flex: 1;
59
+ min-width: 200px;
60
+ }
61
+ .field label {
62
+ display: block;
63
+ margin-bottom: 6px;
64
+ font-size: 12px;
65
+ color: #8b949e;
66
+ text-transform: uppercase;
67
+ letter-spacing: 0.04em;
68
+ }
69
+ .field select {
70
+ width: 100%;
71
+ box-sizing: border-box;
72
+ }
73
+ button {
74
+ padding: 10px 16px;
75
+ border: 0;
76
+ border-radius: 8px;
77
+ background: #4f46e5;
78
+ color: #fff;
79
+ cursor: pointer;
80
+ }
81
+ button:disabled {
82
+ opacity: 0.5;
83
+ cursor: not-allowed;
84
+ }
85
+ pre {
86
+ margin: 0;
87
+ padding: 16px;
88
+ overflow-x: auto;
89
+ border: 1px solid #2b2b2b;
90
+ border-radius: 12px;
91
+ background: #0d1117;
92
+ font: 12px/1.5 ui-monospace, monospace;
93
+ color: #c9d1d9;
94
+ white-space: pre-wrap;
95
+ }
96
+ #status {
97
+ margin-top: 16px;
98
+ font: 13px/1.5 ui-monospace, monospace;
99
+ color: #7eb8ff;
100
+ white-space: pre-wrap;
101
+ }
102
+ </style>
103
+ </head>
104
+ <body>
105
+ <main>
106
+ <p style="margin: 0 0 16px; font-size: 14px">
107
+ <a href="/monitor.html">Cluster monitor</a> — view all connected hosts
108
+ </p>
109
+ <h1>GPU detection cluster host</h1>
110
+ <p>
111
+ Keep this tab open. Inference runs here on WebGPU; the Node broker forwards jobs from
112
+ curl, Python, or other apps (<code>/v1/detect</code> or <code>/v1/describe</code>).
113
+ </p>
114
+ <div class="row">
115
+ <input id="host-id" type="text" placeholder="Host id" autocomplete="off" />
116
+ <div class="field">
117
+ <label for="model-select">Model to share</label>
118
+ <select id="model-select" aria-label="Model to share with the cluster"></select>
119
+ </div>
120
+ <button id="register-btn" type="button">Start hosting</button>
121
+ </div>
122
+ <p>Example once hosting is active:</p>
123
+ <pre id="curl-example"></pre>
124
+ <p id="status">Initializing…</p>
125
+ </main>
126
+ <script type="module" src="/src/pages/detectionNodeHost.ts"></script>
127
+ </body>
128
+ </html>
monitor.html ADDED
@@ -0,0 +1,244 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>GPU cluster monitor</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: dark;
10
+ font-family: system-ui, sans-serif;
11
+ }
12
+ body {
13
+ margin: 0;
14
+ padding: 24px;
15
+ background: #111;
16
+ color: #eee;
17
+ }
18
+ main {
19
+ max-width: 960px;
20
+ margin: 0 auto;
21
+ }
22
+ header {
23
+ display: flex;
24
+ flex-wrap: wrap;
25
+ align-items: flex-start;
26
+ justify-content: space-between;
27
+ gap: 16px;
28
+ margin-bottom: 24px;
29
+ }
30
+ h1 {
31
+ margin: 0 0 8px;
32
+ font-size: 1.4rem;
33
+ }
34
+ p {
35
+ color: #bdbdbd;
36
+ line-height: 1.5;
37
+ margin: 0 0 8px;
38
+ }
39
+ a {
40
+ color: #9eb6ff;
41
+ }
42
+ .nav {
43
+ display: flex;
44
+ gap: 16px;
45
+ font-size: 14px;
46
+ }
47
+ .stats {
48
+ display: flex;
49
+ flex-wrap: wrap;
50
+ gap: 12px;
51
+ margin-bottom: 24px;
52
+ }
53
+ .stat {
54
+ flex: 1;
55
+ min-width: 140px;
56
+ padding: 14px 16px;
57
+ border: 1px solid #2b2b2b;
58
+ border-radius: 12px;
59
+ background: #0d1117;
60
+ }
61
+ .stat-label {
62
+ font-size: 12px;
63
+ color: #8b949e;
64
+ text-transform: uppercase;
65
+ letter-spacing: 0.04em;
66
+ }
67
+ .stat-value {
68
+ margin-top: 6px;
69
+ font-size: 1.25rem;
70
+ font-weight: 600;
71
+ }
72
+ .stat-value.ok {
73
+ color: #3fb950;
74
+ }
75
+ .stat-value.warn {
76
+ color: #d29922;
77
+ }
78
+ .stat-value.err {
79
+ color: #f85149;
80
+ }
81
+ #hosts {
82
+ display: flex;
83
+ flex-direction: column;
84
+ gap: 16px;
85
+ }
86
+ .host-card {
87
+ border: 1px solid #2b2b2b;
88
+ border-radius: 12px;
89
+ background: #0d1117;
90
+ overflow: hidden;
91
+ }
92
+ .host-header {
93
+ display: flex;
94
+ flex-wrap: wrap;
95
+ align-items: center;
96
+ justify-content: space-between;
97
+ gap: 12px;
98
+ padding: 16px;
99
+ border-bottom: 1px solid #21262d;
100
+ }
101
+ .host-id {
102
+ font: 600 1rem/1.2 ui-monospace, monospace;
103
+ }
104
+ .badge {
105
+ display: inline-block;
106
+ padding: 4px 10px;
107
+ border-radius: 999px;
108
+ font-size: 12px;
109
+ font-weight: 600;
110
+ }
111
+ .badge.online {
112
+ background: #23863633;
113
+ color: #3fb950;
114
+ }
115
+ .badge.offline {
116
+ background: #6e768133;
117
+ color: #8b949e;
118
+ }
119
+ .host-meta {
120
+ padding: 0 16px 16px;
121
+ font-size: 13px;
122
+ color: #8b949e;
123
+ }
124
+ .host-meta span + span::before {
125
+ content: ' · ';
126
+ }
127
+ .curl-details {
128
+ border-top: 1px solid #21262d;
129
+ }
130
+ .curl-details summary {
131
+ padding: 12px 16px;
132
+ font-size: 13px;
133
+ color: #9eb6ff;
134
+ cursor: pointer;
135
+ list-style: none;
136
+ user-select: none;
137
+ }
138
+ .curl-details summary::-webkit-details-marker {
139
+ display: none;
140
+ }
141
+ .curl-details summary::before {
142
+ content: '▸ ';
143
+ display: inline-block;
144
+ transition: transform 0.15s ease;
145
+ }
146
+ .curl-details[open] summary::before {
147
+ transform: rotate(90deg);
148
+ }
149
+ .curl-details .summary-hide {
150
+ display: none;
151
+ }
152
+ .curl-details[open] .summary-show {
153
+ display: none;
154
+ }
155
+ .curl-details[open] .summary-hide {
156
+ display: inline;
157
+ }
158
+ .curl-body {
159
+ border-top: 1px solid #21262d;
160
+ }
161
+ pre {
162
+ margin: 0;
163
+ padding: 16px;
164
+ overflow-x: auto;
165
+ background: #010409;
166
+ font: 12px/1.5 ui-monospace, monospace;
167
+ color: #c9d1d9;
168
+ white-space: pre-wrap;
169
+ }
170
+ .empty {
171
+ padding: 32px;
172
+ text-align: center;
173
+ border: 1px dashed #30363d;
174
+ border-radius: 12px;
175
+ color: #8b949e;
176
+ }
177
+ #refresh-note {
178
+ margin-top: 16px;
179
+ font-size: 12px;
180
+ color: #6e7681;
181
+ }
182
+ #error {
183
+ margin-bottom: 16px;
184
+ padding: 12px 16px;
185
+ border-radius: 8px;
186
+ background: #490202;
187
+ color: #ffa198;
188
+ font-size: 14px;
189
+ display: none;
190
+ }
191
+ button.copy {
192
+ margin: 12px 16px 0;
193
+ padding: 6px 12px;
194
+ border: 1px solid #30363d;
195
+ border-radius: 6px;
196
+ background: #21262d;
197
+ color: #c9d1d9;
198
+ font: inherit;
199
+ font-size: 12px;
200
+ cursor: pointer;
201
+ }
202
+ button.copy:hover {
203
+ background: #30363d;
204
+ }
205
+ </style>
206
+ </head>
207
+ <body>
208
+ <main>
209
+ <header>
210
+ <div>
211
+ <h1>GPU cluster monitor</h1>
212
+ <p>
213
+ Registered browser hosts and live connection status from
214
+ <code>GET /v1/hosts</code>.
215
+ </p>
216
+ </div>
217
+ <nav class="nav">
218
+ <a href="/">Register a host</a>
219
+ </nav>
220
+ </header>
221
+
222
+ <div id="error"></div>
223
+
224
+ <section class="stats" aria-label="Cluster summary">
225
+ <div class="stat">
226
+ <div class="stat-label">Broker</div>
227
+ <div id="broker-status" class="stat-value">…</div>
228
+ </div>
229
+ <div class="stat">
230
+ <div class="stat-label">Hosts online</div>
231
+ <div id="online-count" class="stat-value">—</div>
232
+ </div>
233
+ <div class="stat">
234
+ <div class="stat-label">Hosts registered</div>
235
+ <div id="total-count" class="stat-value">—</div>
236
+ </div>
237
+ </section>
238
+
239
+ <div id="hosts"></div>
240
+ <p id="refresh-note">Refreshing every 3s</p>
241
+ </main>
242
+ <script type="module" src="/src/pages/clusterMonitor.ts"></script>
243
+ </body>
244
+ </html>
package-lock.json ADDED
The diff for this file is too large to render. See raw diff
 
package.json ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "name": "gpu-video-cluster",
3
+ "private": true,
4
+ "version": "1.0.0",
5
+ "type": "module",
6
+ "description": "Browser GPU cluster for RF-DETR object detection via HTTP",
7
+ "scripts": {
8
+ "dev": "concurrently -k -n web,api -c blue,green \"vite\" \"tsx watch server/index.ts\"",
9
+ "dev:web": "vite",
10
+ "dev:api": "tsx watch server/index.ts",
11
+ "build": "tsc && tsc -p tsconfig.server.json && vite build",
12
+ "build:space": "tsc && tsc -p tsconfig.server.json && vite build --mode space",
13
+ "preview": "vite preview",
14
+ "start": "SERVE_STATIC=1 tsx server/index.ts",
15
+ "start:api": "tsx server/index.ts"
16
+ },
17
+ "dependencies": {
18
+ "@huggingface/transformers": "^4.2.0",
19
+ "cors": "^2.8.5",
20
+ "express": "^5.1.0"
21
+ },
22
+ "devDependencies": {
23
+ "@types/cors": "^2.8.17",
24
+ "@types/express": "^5.0.3",
25
+ "@webgpu/types": "^0.1.66",
26
+ "concurrently": "^9.2.0",
27
+ "tsx": "^4.20.3",
28
+ "typescript": "~5.9.3",
29
+ "vite": "^7.3.2"
30
+ }
31
+ }
server/imageLoader.ts ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {DetectRequestBody} from './types.js';
2
+
3
+ export async function resolveImagePayload(
4
+ body: DetectRequestBody,
5
+ ): Promise<{imageBase64: string; mimeType: string}> {
6
+ if (body.image_base64) {
7
+ const base64 = body.image_base64.replace(/^data:[^;]+;base64,/, '');
8
+ return {
9
+ imageBase64: base64,
10
+ mimeType: body.image_type ?? 'image/jpeg',
11
+ };
12
+ }
13
+
14
+ if (body.image_url) {
15
+ const response = await fetch(body.image_url);
16
+ if (!response.ok) {
17
+ throw new Error(`Failed to fetch image_url (${response.status})`);
18
+ }
19
+
20
+ const buffer = Buffer.from(await response.arrayBuffer());
21
+ const mimeType =
22
+ response.headers.get('content-type')?.split(';')[0]?.trim() ??
23
+ body.image_type ??
24
+ 'image/jpeg';
25
+
26
+ return {
27
+ imageBase64: buffer.toString('base64'),
28
+ mimeType,
29
+ };
30
+ }
31
+
32
+ throw new Error('Provide image_url or image_base64');
33
+ }
server/index.ts ADDED
@@ -0,0 +1,354 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import {fileURLToPath} from 'node:url';
3
+ import cors from 'cors';
4
+ import express from 'express';
5
+ import type {Request, Response} from 'express';
6
+ import {
7
+ CLUSTER_MODELS,
8
+ DEFAULT_CLUSTER_MODEL_ID,
9
+ isValidClusterModelId,
10
+ } from '../shared/clusterModels.js';
11
+ import {resolveImagePayload} from './imageLoader.js';
12
+ import {initSse, writeSse, writeSseComment} from './sse.js';
13
+ import {store} from './store.js';
14
+ import type {
15
+ CompleteTaskBody,
16
+ DescribeRequestBody,
17
+ DetectRequestBody,
18
+ RegisterHostBody,
19
+ } from './types.js';
20
+
21
+ const PORT = Number(process.env.PORT ?? 8787);
22
+ const TASK_TIMEOUT_MS = Number(process.env.TASK_TIMEOUT_MS ?? 120_000);
23
+ const SERVE_STATIC = process.env.SERVE_STATIC === '1';
24
+ const staticDir = path.resolve(
25
+ path.dirname(fileURLToPath(import.meta.url)),
26
+ '../docs',
27
+ );
28
+
29
+ const app = express();
30
+
31
+ app.use((_req, res, next) => {
32
+ res.setHeader('Cross-Origin-Opener-Policy', 'same-origin');
33
+ res.setHeader('Cross-Origin-Embedder-Policy', 'require-corp');
34
+ next();
35
+ });
36
+
37
+ app.use(cors());
38
+ app.use(express.json({limit: '32mb'}));
39
+
40
+ app.get('/health', (_req, res) => {
41
+ res.json({status: 'ok'});
42
+ });
43
+
44
+ app.get('/v1/hosts', (_req, res) => {
45
+ res.json({hosts: store.listHosts()});
46
+ });
47
+
48
+ app.get('/v1/models', (_req, res) => {
49
+ res.json({
50
+ models: CLUSTER_MODELS.map((model) => ({
51
+ id: model.id,
52
+ label: model.label,
53
+ description: model.description,
54
+ implemented: model.implemented,
55
+ })),
56
+ });
57
+ });
58
+
59
+ app.post('/api/hosts/register', (req, res) => {
60
+ const body = req.body as RegisterHostBody;
61
+ if (!body?.id?.trim()) {
62
+ res.status(400).json({error: 'id is required'});
63
+ return;
64
+ }
65
+
66
+ const modelId = body.model?.trim() || DEFAULT_CLUSTER_MODEL_ID;
67
+ if (!isValidClusterModelId(modelId)) {
68
+ res.status(400).json({
69
+ error: `Unknown model '${modelId}'. Supported: ${CLUSTER_MODELS.map((m) => m.id).join(', ')}`,
70
+ });
71
+ return;
72
+ }
73
+
74
+ const host = store.registerHost(body.id.trim(), modelId);
75
+ res.json({
76
+ id: host.id,
77
+ model: host.model,
78
+ online: store.isHostOnline(host.id),
79
+ });
80
+ });
81
+
82
+ app.get('/api/hosts/stream', (req, res) => {
83
+ const hostId = String(req.query.host_id ?? '').trim();
84
+ if (!hostId) {
85
+ res.status(400).json({error: 'host_id query parameter is required'});
86
+ return;
87
+ }
88
+
89
+ if (!store.getHost(hostId)) {
90
+ res.status(404).json({error: `Host '${hostId}' not found`});
91
+ return;
92
+ }
93
+
94
+ initSse(res);
95
+
96
+ const heartbeat = setInterval(() => {
97
+ store.heartbeat(hostId);
98
+ writeSseComment(res, 'ping');
99
+ }, 15_000);
100
+
101
+ store.attachHostStream(hostId, {
102
+ write: (event, data) => writeSse(res, event, data),
103
+ close: () => {
104
+ clearInterval(heartbeat);
105
+ if (!res.writableEnded) {
106
+ res.end();
107
+ }
108
+ },
109
+ });
110
+
111
+ writeSse(res, 'ready', {host_id: hostId});
112
+
113
+ req.on('close', () => {
114
+ clearInterval(heartbeat);
115
+ store.detachHostStream(hostId);
116
+ });
117
+ });
118
+
119
+ app.post('/api/tasks/:taskId/processing', (req, res) => {
120
+ const taskId = req.params.taskId;
121
+ store.markProcessing(taskId);
122
+ res.json({status: 'ok'});
123
+ });
124
+
125
+ app.post('/api/tasks/:taskId/complete', (req, res) => {
126
+ const taskId = req.params.taskId;
127
+ const body = req.body as CompleteTaskBody;
128
+
129
+ try {
130
+ if (body.status === 'done') {
131
+ const task = store.completeTask(taskId, {
132
+ status: 'done',
133
+ results: body.results ?? [],
134
+ });
135
+ res.json({status: 'ok', task_id: task.id});
136
+ return;
137
+ }
138
+
139
+ const task = store.completeTask(taskId, {
140
+ status: 'error',
141
+ error: body.error ?? 'Detection failed',
142
+ });
143
+ res.json({status: 'ok', task_id: task.id, error: task.error});
144
+ } catch (error) {
145
+ const message = error instanceof Error ? error.message : String(error);
146
+ res.status(404).json({error: message});
147
+ }
148
+ });
149
+
150
+ app.get('/v1/tasks/:taskId', (req, res) => {
151
+ const task = store.getTask(req.params.taskId);
152
+ if (!task) {
153
+ res.status(404).json({error: 'Task not found'});
154
+ return;
155
+ }
156
+
157
+ res.json({
158
+ id: task.id,
159
+ kind: task.kind,
160
+ host: task.hostId,
161
+ status: task.status,
162
+ results: task.results,
163
+ description: task.description,
164
+ error: task.error,
165
+ created_at: task.createdAt,
166
+ started_at: task.startedAt,
167
+ completed_at: task.completedAt,
168
+ });
169
+ });
170
+
171
+ app.post('/v1/detect', async (req, res) => {
172
+ await handleDetect(req, res);
173
+ });
174
+
175
+ app.post('/v1/describe', async (req, res) => {
176
+ await handleDescribe(req, res);
177
+ });
178
+
179
+ async function handleDetect(req: Request, res: Response): Promise<void> {
180
+ const body = req.body as DetectRequestBody;
181
+
182
+ if (!body?.host?.trim()) {
183
+ res.status(400).json({
184
+ error: {
185
+ message: "Missing required field 'host'. Open the app in a browser and register a host id.",
186
+ type: 'invalid_request_error',
187
+ },
188
+ });
189
+ return;
190
+ }
191
+
192
+ const hostId = body.host.trim();
193
+
194
+ if (!store.getHost(hostId)) {
195
+ res.status(404).json({
196
+ error: {
197
+ message: `Host '${hostId}' not found. Register it in the browser host page first.`,
198
+ type: 'invalid_request_error',
199
+ },
200
+ });
201
+ return;
202
+ }
203
+
204
+ if (!store.isHostOnline(hostId)) {
205
+ res.status(503).json({
206
+ error: {
207
+ message: `Host '${hostId}' is offline. Keep the browser host tab open with the model loaded.`,
208
+ type: 'host_unavailable',
209
+ },
210
+ });
211
+ return;
212
+ }
213
+
214
+ try {
215
+ const image = await resolveImagePayload(body);
216
+ const threshold = body.threshold ?? 0.5;
217
+
218
+ const task = store.createDetectTask(hostId, {
219
+ imageBase64: image.imageBase64,
220
+ mimeType: image.mimeType,
221
+ threshold,
222
+ });
223
+
224
+ const completed = await store.waitForTask(task.id, TASK_TIMEOUT_MS);
225
+
226
+ if (completed.status === 'error') {
227
+ res.status(500).json({
228
+ error: {
229
+ message: completed.error ?? 'Detection failed',
230
+ type: 'detection_error',
231
+ },
232
+ task_id: completed.id,
233
+ });
234
+ return;
235
+ }
236
+
237
+ res.json({
238
+ task_id: completed.id,
239
+ host: completed.hostId,
240
+ threshold: completed.threshold,
241
+ detections: completed.results ?? [],
242
+ });
243
+ } catch (error) {
244
+ const message = error instanceof Error ? error.message : String(error);
245
+ const status = message.includes('timed out') ? 504 : 400;
246
+ res.status(status).json({
247
+ error: {
248
+ message,
249
+ type: status === 504 ? 'timeout' : 'invalid_request_error',
250
+ },
251
+ });
252
+ }
253
+ }
254
+
255
+ async function handleDescribe(req: Request, res: Response): Promise<void> {
256
+ const body = req.body as DescribeRequestBody;
257
+
258
+ if (!body?.host?.trim()) {
259
+ res.status(400).json({
260
+ error: {
261
+ message: "Missing required field 'host'. Open the app in a browser and register a host id.",
262
+ type: 'invalid_request_error',
263
+ },
264
+ });
265
+ return;
266
+ }
267
+
268
+ const hostId = body.host.trim();
269
+
270
+ if (!store.getHost(hostId)) {
271
+ res.status(404).json({
272
+ error: {
273
+ message: `Host '${hostId}' not found. Register it in the browser host page first.`,
274
+ type: 'invalid_request_error',
275
+ },
276
+ });
277
+ return;
278
+ }
279
+
280
+ if (!store.isHostOnline(hostId)) {
281
+ res.status(503).json({
282
+ error: {
283
+ message: `Host '${hostId}' is offline. Keep the browser host tab open with the model loaded.`,
284
+ type: 'host_unavailable',
285
+ },
286
+ });
287
+ return;
288
+ }
289
+
290
+ try {
291
+ const image = await resolveImagePayload(body);
292
+ const instruction = body.instruction?.trim() || 'What do you see?';
293
+ const maxNewTokens = body.max_new_tokens ?? 100;
294
+
295
+ const task = store.createDescribeTask(hostId, {
296
+ imageBase64: image.imageBase64,
297
+ mimeType: image.mimeType,
298
+ instruction,
299
+ maxNewTokens,
300
+ });
301
+
302
+ const completed = await store.waitForTask(task.id, TASK_TIMEOUT_MS);
303
+
304
+ if (completed.status === 'error') {
305
+ res.status(500).json({
306
+ error: {
307
+ message: completed.error ?? 'Description failed',
308
+ type: 'description_error',
309
+ },
310
+ task_id: completed.id,
311
+ });
312
+ return;
313
+ }
314
+
315
+ res.json({
316
+ task_id: completed.id,
317
+ host: completed.hostId,
318
+ instruction: completed.instruction,
319
+ max_new_tokens: completed.maxNewTokens,
320
+ description: completed.description ?? '',
321
+ });
322
+ } catch (error) {
323
+ const message = error instanceof Error ? error.message : String(error);
324
+ const status = message.includes('timed out') ? 504 : 400;
325
+ res.status(status).json({
326
+ error: {
327
+ message,
328
+ type: status === 504 ? 'timeout' : 'invalid_request_error',
329
+ },
330
+ });
331
+ }
332
+ }
333
+
334
+ if (SERVE_STATIC) {
335
+ app.use(express.static(staticDir));
336
+ app.get('/monitor.html', (_req, res) => {
337
+ res.sendFile(path.join(staticDir, 'monitor.html'));
338
+ });
339
+ app.get('/', (_req, res) => {
340
+ res.sendFile(path.join(staticDir, 'index.html'));
341
+ });
342
+ }
343
+
344
+ app.listen(PORT, '0.0.0.0', () => {
345
+ console.log(`Detection broker listening on http://0.0.0.0:${PORT}`);
346
+ if (SERVE_STATIC) {
347
+ console.log(` Static UI: / and /monitor.html`);
348
+ }
349
+ console.log(` POST /v1/detect`);
350
+ console.log(` POST /v1/describe`);
351
+ console.log(` GET /v1/hosts`);
352
+ console.log(` GET /v1/models`);
353
+ console.log(` GET /api/hosts/stream?host_id=...`);
354
+ });
server/sse.ts ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {Response} from 'express';
2
+
3
+ export function initSse(res: Response): void {
4
+ res.setHeader('Content-Type', 'text/event-stream');
5
+ res.setHeader('Cache-Control', 'no-cache');
6
+ res.setHeader('Connection', 'keep-alive');
7
+ res.flushHeaders?.();
8
+ }
9
+
10
+ export function writeSse(res: Response, event: string, data: unknown): void {
11
+ res.write(`event: ${event}\n`);
12
+ res.write(`data: ${JSON.stringify(data)}\n\n`);
13
+ }
14
+
15
+ export function writeSseComment(res: Response, comment: string): void {
16
+ res.write(`: ${comment}\n\n`);
17
+ }
server/store.ts ADDED
@@ -0,0 +1,248 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {randomUUID} from 'node:crypto';
2
+ import type {
3
+ DetectionResultJson,
4
+ DetectionTask,
5
+ HostRecord,
6
+ HostStream,
7
+ } from './types.js';
8
+
9
+ type TaskWaiter = {
10
+ resolve: (task: DetectionTask) => void;
11
+ reject: (error: Error) => void;
12
+ timeout: ReturnType<typeof setTimeout>;
13
+ };
14
+
15
+ export class BrokerStore {
16
+ private readonly hosts = new Map<string, HostRecord>();
17
+ private readonly tasks = new Map<string, DetectionTask>();
18
+ private readonly waiters = new Map<string, TaskWaiter>();
19
+
20
+ registerHost(id: string, model: string): HostRecord {
21
+ const existing = this.hosts.get(id);
22
+ const host: HostRecord = {
23
+ id,
24
+ model,
25
+ registeredAt: existing?.registeredAt ?? Date.now(),
26
+ lastHeartbeat: Date.now(),
27
+ stream: existing?.stream,
28
+ };
29
+ this.hosts.set(id, host);
30
+ return host;
31
+ }
32
+
33
+ attachHostStream(id: string, stream: HostStream): void {
34
+ const host = this.hosts.get(id);
35
+ if (!host) {
36
+ throw new Error(`Host '${id}' is not registered`);
37
+ }
38
+
39
+ host.stream?.close();
40
+ host.stream = stream;
41
+ host.lastHeartbeat = Date.now();
42
+ this.flushPendingTasks(id);
43
+ }
44
+
45
+ detachHostStream(id: string): void {
46
+ const host = this.hosts.get(id);
47
+ if (host) {
48
+ host.stream = undefined;
49
+ }
50
+ }
51
+
52
+ heartbeat(id: string): void {
53
+ const host = this.hosts.get(id);
54
+ if (host) {
55
+ host.lastHeartbeat = Date.now();
56
+ }
57
+ }
58
+
59
+ listHosts(): Array<{
60
+ id: string;
61
+ model: string;
62
+ online: boolean;
63
+ registeredAt: number;
64
+ lastHeartbeat: number;
65
+ }> {
66
+ return [...this.hosts.values()].map((host) => ({
67
+ id: host.id,
68
+ model: host.model,
69
+ online: Boolean(host.stream),
70
+ registeredAt: host.registeredAt,
71
+ lastHeartbeat: host.lastHeartbeat,
72
+ }));
73
+ }
74
+
75
+ getHost(id: string): HostRecord | undefined {
76
+ return this.hosts.get(id);
77
+ }
78
+
79
+ isHostOnline(id: string): boolean {
80
+ const host = this.hosts.get(id);
81
+ return Boolean(host?.stream);
82
+ }
83
+
84
+ createDetectTask(
85
+ hostId: string,
86
+ payload: {imageBase64: string; mimeType: string; threshold: number},
87
+ ): DetectionTask {
88
+ return this.createTask(hostId, {
89
+ kind: 'detect',
90
+ imageBase64: payload.imageBase64,
91
+ mimeType: payload.mimeType,
92
+ threshold: payload.threshold,
93
+ });
94
+ }
95
+
96
+ createDescribeTask(
97
+ hostId: string,
98
+ payload: {
99
+ imageBase64: string;
100
+ mimeType: string;
101
+ instruction: string;
102
+ maxNewTokens: number;
103
+ },
104
+ ): DetectionTask {
105
+ return this.createTask(hostId, {
106
+ kind: 'describe',
107
+ imageBase64: payload.imageBase64,
108
+ mimeType: payload.mimeType,
109
+ instruction: payload.instruction,
110
+ maxNewTokens: payload.maxNewTokens,
111
+ });
112
+ }
113
+
114
+ private createTask(
115
+ hostId: string,
116
+ payload: {
117
+ kind: DetectionTask['kind'];
118
+ imageBase64: string;
119
+ mimeType: string;
120
+ threshold?: number;
121
+ instruction?: string;
122
+ maxNewTokens?: number;
123
+ },
124
+ ): DetectionTask {
125
+ const host = this.hosts.get(hostId);
126
+ if (!host) {
127
+ throw new Error(`Host '${hostId}' not found. Register the browser host first.`);
128
+ }
129
+
130
+ const task: DetectionTask = {
131
+ id: randomUUID(),
132
+ hostId,
133
+ kind: payload.kind,
134
+ status: 'pending',
135
+ imageBase64: payload.imageBase64,
136
+ mimeType: payload.mimeType,
137
+ threshold: payload.threshold,
138
+ instruction: payload.instruction,
139
+ maxNewTokens: payload.maxNewTokens,
140
+ createdAt: Date.now(),
141
+ };
142
+
143
+ this.tasks.set(task.id, task);
144
+ this.dispatchTask(task);
145
+ return task;
146
+ }
147
+
148
+ waitForTask(taskId: string, timeoutMs: number): Promise<DetectionTask> {
149
+ const task = this.tasks.get(taskId);
150
+ if (!task) {
151
+ return Promise.reject(new Error(`Task '${taskId}' not found`));
152
+ }
153
+
154
+ if (task.status === 'done' || task.status === 'error') {
155
+ return Promise.resolve(task);
156
+ }
157
+
158
+ return new Promise<DetectionTask>((resolve, reject) => {
159
+ const timeout = setTimeout(() => {
160
+ this.waiters.delete(taskId);
161
+ reject(new Error(`Task '${taskId}' timed out after ${timeoutMs}ms`));
162
+ }, timeoutMs);
163
+
164
+ this.waiters.set(taskId, {
165
+ resolve,
166
+ reject,
167
+ timeout,
168
+ });
169
+ });
170
+ }
171
+
172
+ markProcessing(taskId: string): void {
173
+ const task = this.tasks.get(taskId);
174
+ if (!task) {
175
+ return;
176
+ }
177
+
178
+ task.status = 'processing';
179
+ task.startedAt = Date.now();
180
+ }
181
+
182
+ completeTask(
183
+ taskId: string,
184
+ outcome:
185
+ | {status: 'done'; results?: DetectionResultJson[]; description?: string}
186
+ | {status: 'error'; error: string},
187
+ ): DetectionTask {
188
+ const task = this.tasks.get(taskId);
189
+ if (!task) {
190
+ throw new Error(`Task '${taskId}' not found`);
191
+ }
192
+
193
+ task.completedAt = Date.now();
194
+
195
+ if (outcome.status === 'done') {
196
+ task.status = 'done';
197
+ task.results = outcome.results;
198
+ task.description = outcome.description;
199
+ } else {
200
+ task.status = 'error';
201
+ task.error = outcome.error;
202
+ }
203
+
204
+ const waiter = this.waiters.get(taskId);
205
+ if (waiter) {
206
+ clearTimeout(waiter.timeout);
207
+ this.waiters.delete(taskId);
208
+ if (task.status === 'error') {
209
+ waiter.reject(new Error(task.error ?? 'Detection failed'));
210
+ } else {
211
+ waiter.resolve(task);
212
+ }
213
+ }
214
+
215
+ return task;
216
+ }
217
+
218
+ getTask(taskId: string): DetectionTask | undefined {
219
+ return this.tasks.get(taskId);
220
+ }
221
+
222
+ private dispatchTask(task: DetectionTask): void {
223
+ const host = this.hosts.get(task.hostId);
224
+ if (!host?.stream) {
225
+ return;
226
+ }
227
+
228
+ host.stream.write('task', {
229
+ id: task.id,
230
+ kind: task.kind,
231
+ threshold: task.threshold,
232
+ instruction: task.instruction,
233
+ max_new_tokens: task.maxNewTokens,
234
+ image_base64: task.imageBase64,
235
+ mime_type: task.mimeType,
236
+ });
237
+ }
238
+
239
+ private flushPendingTasks(hostId: string): void {
240
+ for (const task of this.tasks.values()) {
241
+ if (task.hostId === hostId && task.status === 'pending') {
242
+ this.dispatchTask(task);
243
+ }
244
+ }
245
+ }
246
+ }
247
+
248
+ export const store = new BrokerStore();
server/types.ts ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export interface DetectionBox {
2
+ xmin: number;
3
+ ymin: number;
4
+ xmax: number;
5
+ ymax: number;
6
+ }
7
+
8
+ export interface DetectionResultJson {
9
+ label: string;
10
+ score: number;
11
+ box: DetectionBox;
12
+ }
13
+
14
+ export interface HostRecord {
15
+ id: string;
16
+ model: string;
17
+ registeredAt: number;
18
+ lastHeartbeat: number;
19
+ stream?: HostStream;
20
+ }
21
+
22
+ export interface HostStream {
23
+ write: (event: string, data: unknown) => void;
24
+ close: () => void;
25
+ }
26
+
27
+ export type TaskStatus = 'pending' | 'processing' | 'done' | 'error';
28
+
29
+ export type ClusterTaskKind = 'detect' | 'describe';
30
+
31
+ export interface DetectionTask {
32
+ id: string;
33
+ hostId: string;
34
+ kind: ClusterTaskKind;
35
+ status: TaskStatus;
36
+ imageBase64: string;
37
+ mimeType: string;
38
+ threshold?: number;
39
+ instruction?: string;
40
+ maxNewTokens?: number;
41
+ results?: DetectionResultJson[];
42
+ description?: string;
43
+ error?: string;
44
+ createdAt: number;
45
+ startedAt?: number;
46
+ completedAt?: number;
47
+ }
48
+
49
+ export interface DetectRequestBody {
50
+ host: string;
51
+ threshold?: number;
52
+ image_url?: string;
53
+ image_base64?: string;
54
+ image_type?: string;
55
+ }
56
+
57
+ export interface DescribeRequestBody {
58
+ host: string;
59
+ instruction?: string;
60
+ max_new_tokens?: number;
61
+ image_url?: string;
62
+ image_base64?: string;
63
+ image_type?: string;
64
+ }
65
+
66
+ export interface RegisterHostBody {
67
+ id: string;
68
+ model?: string;
69
+ }
70
+
71
+ export interface CompleteTaskBody {
72
+ status: 'done' | 'error';
73
+ results?: DetectionResultJson[];
74
+ description?: string;
75
+ error?: string;
76
+ }
shared/clusterModels.ts ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /** Models a browser host can expose to the cluster. Add entries as new workers are wired up. */
2
+ export interface ClusterModelDefinition {
3
+ id: string;
4
+ label: string;
5
+ description: string;
6
+ /** When false, the host UI shows the option but cannot start hosting yet. */
7
+ implemented: boolean;
8
+ }
9
+
10
+ export const CLUSTER_MODELS: readonly ClusterModelDefinition[] = [
11
+ {
12
+ id: 'rfdetr-medium',
13
+ label: 'Object detection',
14
+ description: 'RF-DETR Medium (COCO) on WebGPU',
15
+ implemented: true,
16
+ },
17
+ {
18
+ id: 'smolvlm-500m',
19
+ label: 'Video description',
20
+ description: 'SmolVLM-500M-Instruct vision-language model on WebGPU',
21
+ implemented: true,
22
+ },
23
+ ] as const;
24
+
25
+ export const DEFAULT_CLUSTER_MODEL_ID = CLUSTER_MODELS[0].id;
26
+
27
+ export function getClusterModel(id: string): ClusterModelDefinition | undefined {
28
+ return CLUSTER_MODELS.find((model) => model.id === id);
29
+ }
30
+
31
+ export function isValidClusterModelId(id: string): boolean {
32
+ return CLUSTER_MODELS.some((model) => model.id === id);
33
+ }
34
+
35
+ export function isImplementedClusterModel(id: string): boolean {
36
+ const model = getClusterModel(id);
37
+ return Boolean(model?.implemented);
38
+ }
src/detection/ObjectDetector.ts ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {DetectionResult, WorkerRequest, WorkerResponse} from './workerMessages';
2
+
3
+ export type {DetectionResult} from './workerMessages';
4
+
5
+ type PendingRequest = {
6
+ resolve: (results: DetectionResult[]) => void;
7
+ reject: (error: Error) => void;
8
+ };
9
+
10
+ export class ObjectDetector {
11
+ private readonly worker: Worker;
12
+ private readonly pending = new Map<number, PendingRequest>();
13
+ private nextId = 0;
14
+ private readonly ready: Promise<void>;
15
+ private readySettled = false;
16
+ private resolveReady!: () => void;
17
+ private rejectReady!: (error: Error) => void;
18
+ private onStatus?: (message: string) => void;
19
+
20
+ private constructor(worker: Worker, onStatus?: (message: string) => void) {
21
+ this.worker = worker;
22
+ this.onStatus = onStatus;
23
+
24
+ this.ready = new Promise<void>((resolve, reject) => {
25
+ this.resolveReady = resolve;
26
+ this.rejectReady = reject;
27
+ });
28
+
29
+ this.worker.addEventListener('message', (event: MessageEvent<WorkerResponse>) => {
30
+ this.handleMessage(event.data);
31
+ });
32
+ this.worker.addEventListener('error', (event) => {
33
+ this.failReady(new Error(event.message || 'Detection worker failed'));
34
+ });
35
+
36
+ this.worker.postMessage({type: 'init'} satisfies WorkerRequest);
37
+ }
38
+
39
+ static async create(onStatus?: (message: string) => void): Promise<ObjectDetector> {
40
+ const worker = new Worker(new URL('./detection.worker.ts', import.meta.url), {
41
+ type: 'module',
42
+ });
43
+ const detector = new ObjectDetector(worker, onStatus);
44
+ await detector.ready;
45
+ return detector;
46
+ }
47
+
48
+ async warmup(frame: VideoFrame, threshold = 0.5): Promise<void> {
49
+ await this.detect(frame, {threshold});
50
+ }
51
+
52
+ async detect(
53
+ frame: VideoFrame,
54
+ options: {threshold: number},
55
+ ): Promise<DetectionResult[]> {
56
+ await this.ready;
57
+
58
+ const id = this.nextId++;
59
+
60
+ return new Promise<DetectionResult[]>((resolve, reject) => {
61
+ this.pending.set(id, {resolve, reject});
62
+ this.worker.postMessage(
63
+ {type: 'detect', id, threshold: options.threshold, frame} satisfies WorkerRequest,
64
+ [frame],
65
+ );
66
+ });
67
+ }
68
+
69
+ dispose(): void {
70
+ for (const {reject} of this.pending.values()) {
71
+ reject(new Error('Detection worker terminated'));
72
+ }
73
+ this.pending.clear();
74
+ this.worker.terminate();
75
+ }
76
+
77
+ private handleMessage(message: WorkerResponse): void {
78
+ if (message.type === 'status') {
79
+ this.onStatus?.(message.message);
80
+ return;
81
+ }
82
+
83
+ if (message.type === 'ready') {
84
+ if (!this.readySettled) {
85
+ this.readySettled = true;
86
+ this.resolveReady();
87
+ }
88
+ return;
89
+ }
90
+
91
+ if (message.type === 'detect-result') {
92
+ const pending = this.pending.get(message.id);
93
+ if (pending) {
94
+ this.pending.delete(message.id);
95
+ pending.resolve(message.results);
96
+ }
97
+ return;
98
+ }
99
+
100
+ if (message.type === 'error') {
101
+ const error = new Error(message.error);
102
+
103
+ if (message.id !== undefined) {
104
+ const pending = this.pending.get(message.id);
105
+ if (pending) {
106
+ this.pending.delete(message.id);
107
+ pending.reject(error);
108
+ }
109
+ return;
110
+ }
111
+
112
+ this.failReady(error);
113
+ }
114
+ }
115
+
116
+ private failReady(error: Error): void {
117
+ if (!this.readySettled) {
118
+ this.readySettled = true;
119
+ this.rejectReady(error);
120
+ }
121
+ }
122
+ }
src/detection/detection.worker.ts ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="webworker" />
2
+
3
+ import {pipeline, type ObjectDetectionOutput} from '@huggingface/transformers';
4
+ import type {WorkerRequest, WorkerResponse} from './workerMessages';
5
+
6
+ const MODEL_ID = 'onnx-community/rfdetr_medium-ONNX';
7
+ /** RF-DETR preprocessor input size (see model preprocessor_config.json). */
8
+ const MODEL_INPUT_SIZE = 576;
9
+
10
+ type DetectFn = (
11
+ input: OffscreenCanvas,
12
+ options?: {threshold?: number; percentage?: boolean},
13
+ ) => Promise<ObjectDetectionOutput>;
14
+
15
+ let detector: DetectFn | null = null;
16
+ let loadPromise: Promise<void> | null = null;
17
+ let preprocessCanvas: OffscreenCanvas | null = null;
18
+ let preprocessCtx: OffscreenCanvasRenderingContext2D | null = null;
19
+
20
+ function getPreprocessSurface(): {
21
+ canvas: OffscreenCanvas;
22
+ ctx: OffscreenCanvasRenderingContext2D;
23
+ } {
24
+ if (!preprocessCanvas || !preprocessCtx) {
25
+ preprocessCanvas = new OffscreenCanvas(MODEL_INPUT_SIZE, MODEL_INPUT_SIZE);
26
+ const ctx = preprocessCanvas.getContext('2d', {willReadFrequently: true});
27
+ if (!ctx) {
28
+ throw new Error('OffscreenCanvas 2D context not available in worker');
29
+ }
30
+ preprocessCtx = ctx;
31
+ }
32
+ return {canvas: preprocessCanvas, ctx: preprocessCtx};
33
+ }
34
+
35
+ function post(message: WorkerResponse): void {
36
+ self.postMessage(message);
37
+ }
38
+
39
+ async function loadDetector(): Promise<DetectFn> {
40
+ if (detector) {
41
+ return detector;
42
+ }
43
+
44
+ if (!loadPromise) {
45
+ loadPromise = (async () => {
46
+ post({type: 'status', message: 'Loading RF-DETR model (WebGPU)…'});
47
+ detector = (await pipeline('object-detection', MODEL_ID, {
48
+ device: 'webgpu',
49
+ dtype: 'fp32',
50
+ progress_callback: (progress) => {
51
+ if (progress.status === 'progress' && progress.file) {
52
+ const percent =
53
+ progress.total && progress.total > 0
54
+ ? Math.round((progress.loaded / progress.total) * 100)
55
+ : null;
56
+ post({
57
+ type: 'status',
58
+ message: percent
59
+ ? `Downloading ${progress.file} (${percent}%)…`
60
+ : `Downloading ${progress.file}…`,
61
+ });
62
+ }
63
+ },
64
+ })) as DetectFn;
65
+ post({type: 'status', message: 'Compiling RF-DETR shaders…'});
66
+ })();
67
+ }
68
+
69
+ await loadPromise;
70
+ return detector!;
71
+ }
72
+
73
+ async function runDetection(
74
+ frame: VideoFrame,
75
+ threshold: number,
76
+ ): Promise<ObjectDetectionOutput> {
77
+ const detect = await loadDetector();
78
+ const {canvas, ctx} = getPreprocessSurface();
79
+
80
+ try {
81
+ const bitmap = await createImageBitmap(frame, {
82
+ resizeWidth: MODEL_INPUT_SIZE,
83
+ resizeHeight: MODEL_INPUT_SIZE,
84
+ });
85
+ try {
86
+ ctx.drawImage(bitmap, 0, 0);
87
+ } finally {
88
+ bitmap.close();
89
+ }
90
+ } finally {
91
+ frame.close();
92
+ }
93
+
94
+ return detect(canvas, {threshold, percentage: true});
95
+ }
96
+
97
+ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
98
+ const message = event.data;
99
+
100
+ try {
101
+ if (message.type === 'init') {
102
+ await loadDetector();
103
+ post({type: 'ready'});
104
+ return;
105
+ }
106
+
107
+ if (message.type === 'detect') {
108
+ const results = await runDetection(message.frame, message.threshold);
109
+ post({type: 'detect-result', id: message.id, results});
110
+ return;
111
+ }
112
+ } catch (error) {
113
+ if (message.type === 'detect') {
114
+ message.frame.close();
115
+ }
116
+
117
+ post({
118
+ type: 'error',
119
+ error: error instanceof Error ? error.message : String(error),
120
+ id: message.type === 'detect' ? message.id : undefined,
121
+ });
122
+ }
123
+ };
src/detection/workerMessages.ts ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {ObjectDetectionOutput} from '@huggingface/transformers';
2
+
3
+ export type DetectionResult = ObjectDetectionOutput[number];
4
+
5
+ export type WorkerRequest =
6
+ | {type: 'init'}
7
+ | {type: 'detect'; id: number; threshold: number; frame: VideoFrame};
8
+
9
+ export type WorkerResponse =
10
+ | {type: 'status'; message: string}
11
+ | {type: 'ready'}
12
+ | {type: 'detect-result'; id: number; results: DetectionResult[]}
13
+ | {type: 'error'; error: string; id?: number};
src/pages/clusterMonitor.ts ADDED
@@ -0,0 +1,232 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_BASE = import.meta.env.VITE_API_BASE ?? '';
2
+ const REFRESH_MS = 3000;
3
+
4
+ const SAMPLE_IMAGE_URL =
5
+ 'https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png';
6
+
7
+ interface HostInfo {
8
+ id: string;
9
+ model: string;
10
+ online: boolean;
11
+ registeredAt: number;
12
+ lastHeartbeat: number;
13
+ }
14
+
15
+ const brokerStatusEl = document.getElementById('broker-status')!;
16
+ const onlineCountEl = document.getElementById('online-count')!;
17
+ const totalCountEl = document.getElementById('total-count')!;
18
+ const hostsEl = document.getElementById('hosts')!;
19
+ const errorEl = document.getElementById('error')!;
20
+ const refreshNoteEl = document.getElementById('refresh-note')!;
21
+
22
+ const expandedCurlHosts = new Set<string>();
23
+
24
+ function syncExpandedCurlFromDom(): void {
25
+ hostsEl.querySelectorAll<HTMLDetailsElement>('details.curl-details').forEach((details) => {
26
+ const id = details.closest('[data-host-id]')?.getAttribute('data-host-id');
27
+ if (!id) {
28
+ return;
29
+ }
30
+ if (details.open) {
31
+ expandedCurlHosts.add(id);
32
+ } else {
33
+ expandedCurlHosts.delete(id);
34
+ }
35
+ });
36
+ }
37
+
38
+ function detectOrigin(): string {
39
+ return window.location.origin;
40
+ }
41
+
42
+ function buildCurlExample(hostId: string, modelId: string): string {
43
+ const origin = detectOrigin();
44
+
45
+ if (modelId === 'smolvlm-500m') {
46
+ return `curl -X POST '${origin}/v1/describe' \\
47
+ -H 'Content-Type: application/json' \\
48
+ -d '{
49
+ "host": "${hostId}",
50
+ "image_url": "${SAMPLE_IMAGE_URL}",
51
+ "instruction": "What do you see?",
52
+ "max_new_tokens": 100
53
+ }'`;
54
+ }
55
+
56
+ return `curl -X POST '${origin}/v1/detect' \\
57
+ -H 'Content-Type: application/json' \\
58
+ -d '{
59
+ "host": "${hostId}",
60
+ "image_url": "${SAMPLE_IMAGE_URL}",
61
+ "threshold": 0.5
62
+ }'`;
63
+ }
64
+
65
+ function formatTime(ms: number): string {
66
+ return new Date(ms).toLocaleString();
67
+ }
68
+
69
+ function relativeTime(ms: number): string {
70
+ const seconds = Math.floor((Date.now() - ms) / 1000);
71
+ if (seconds < 60) {
72
+ return `${seconds}s ago`;
73
+ }
74
+ const minutes = Math.floor(seconds / 60);
75
+ if (minutes < 60) {
76
+ return `${minutes}m ago`;
77
+ }
78
+ const hours = Math.floor(minutes / 60);
79
+ return `${hours}h ago`;
80
+ }
81
+
82
+ function showError(message: string): void {
83
+ errorEl.textContent = message;
84
+ errorEl.style.display = 'block';
85
+ }
86
+
87
+ function clearError(): void {
88
+ errorEl.style.display = 'none';
89
+ }
90
+
91
+ async function fetchBrokerHealth(): Promise<boolean> {
92
+ const res = await fetch(`${API_BASE}/health`);
93
+ if (!res.ok) {
94
+ return false;
95
+ }
96
+ const json = (await res.json()) as {status?: string};
97
+ return json.status === 'ok';
98
+ }
99
+
100
+ async function fetchHosts(): Promise<HostInfo[]> {
101
+ const res = await fetch(`${API_BASE}/v1/hosts`);
102
+ if (!res.ok) {
103
+ throw new Error(`GET /v1/hosts failed (${res.status})`);
104
+ }
105
+ const json = (await res.json()) as {hosts: HostInfo[]};
106
+ return json.hosts ?? [];
107
+ }
108
+
109
+ function renderHosts(hosts: HostInfo[]): void {
110
+ const online = hosts.filter((h) => h.online).length;
111
+ onlineCountEl.textContent = String(online);
112
+ onlineCountEl.className = `stat-value ${online > 0 ? 'ok' : 'warn'}`;
113
+ totalCountEl.textContent = String(hosts.length);
114
+
115
+ if (hosts.length === 0) {
116
+ hostsEl.innerHTML = `
117
+ <div class="empty">
118
+ <p>No hosts registered yet.</p>
119
+ <p><a href="/public">Open the host page</a>, pick an id, and click “Start hosting”.</p>
120
+ </div>`;
121
+ return;
122
+ }
123
+
124
+ const sorted = [...hosts].sort((a, b) => {
125
+ if (a.online !== b.online) {
126
+ return a.online ? -1 : 1;
127
+ }
128
+ return b.lastHeartbeat - a.lastHeartbeat;
129
+ });
130
+
131
+ syncExpandedCurlFromDom();
132
+
133
+ hostsEl.innerHTML = sorted
134
+ .map((host) => {
135
+ const curl = buildCurlExample(host.id, host.model);
136
+ const escapedCurl = curl.replace(/'/g, '&#39;');
137
+ return `
138
+ <article class="host-card" data-host-id="${escapeHtml(host.id)}">
139
+ <div class="host-header">
140
+ <span class="host-id">${escapeHtml(host.id)}</span>
141
+ <span class="badge ${host.online ? 'online' : 'offline'}">
142
+ ${host.online ? 'Online' : 'Offline'}
143
+ </span>
144
+ </div>
145
+ <div class="host-meta">
146
+ <span>Model: ${escapeHtml(host.model)}</span>
147
+ <span>Registered: ${formatTime(host.registeredAt)}</span>
148
+ <span>Last heartbeat: ${relativeTime(host.lastHeartbeat)}</span>
149
+ </div>
150
+ <details class="curl-details"${expandedCurlHosts.has(host.id) ? ' open' : ''}>
151
+ <summary>
152
+ <span class="summary-show">Show curl example</span>
153
+ <span class="summary-hide">Hide curl example</span>
154
+ </summary>
155
+ <div class="curl-body">
156
+ <button type="button" class="copy" data-curl="${escapedCurl}">Copy curl</button>
157
+ <pre>${escapeHtml(curl)}</pre>
158
+ </div>
159
+ </details>
160
+ </article>`;
161
+ })
162
+ .join('');
163
+
164
+ hostsEl.querySelectorAll('button.copy').forEach((btn) => {
165
+ btn.addEventListener('click', async () => {
166
+ const curl = btn.getAttribute('data-curl')?.replace(/&#39;/g, "'") ?? '';
167
+ await navigator.clipboard.writeText(curl);
168
+ const label = btn.textContent;
169
+ btn.textContent = 'Copied!';
170
+ setTimeout(() => {
171
+ btn.textContent = label;
172
+ }, 1500);
173
+ });
174
+ });
175
+ }
176
+
177
+ function escapeHtml(text: string): string {
178
+ return text
179
+ .replace(/&/g, '&amp;')
180
+ .replace(/</g, '&lt;')
181
+ .replace(/>/g, '&gt;')
182
+ .replace(/"/g, '&quot;');
183
+ }
184
+
185
+ async function refresh(): Promise<void> {
186
+ try {
187
+ const healthy = await fetchBrokerHealth();
188
+ brokerStatusEl.textContent = healthy ? 'Healthy' : 'Unreachable';
189
+ brokerStatusEl.className = `stat-value ${healthy ? 'ok' : 'err'}`;
190
+
191
+ if (!healthy) {
192
+ showError(
193
+ `Broker at ${detectOrigin()} is not responding. Run \`npm run dev\` or \`npm run dev:api\`.`,
194
+ );
195
+ hostsEl.innerHTML = '';
196
+ onlineCountEl.textContent = '—';
197
+ totalCountEl.textContent = '—';
198
+ return;
199
+ }
200
+
201
+ clearError();
202
+ const hosts = await fetchHosts();
203
+ renderHosts(hosts);
204
+ refreshNoteEl.textContent = `Last updated ${new Date().toLocaleTimeString()} · refreshing every ${REFRESH_MS / 1000}s`;
205
+ } catch (error) {
206
+ const message = error instanceof Error ? error.message : String(error);
207
+ showError(message);
208
+ brokerStatusEl.textContent = 'Error';
209
+ brokerStatusEl.className = 'stat-value err';
210
+ }
211
+ }
212
+
213
+ hostsEl.addEventListener('toggle', (event) => {
214
+ const details = event.target;
215
+ if (!(details instanceof HTMLDetailsElement) || !details.classList.contains('curl-details')) {
216
+ return;
217
+ }
218
+ const id = details.closest('[data-host-id]')?.getAttribute('data-host-id');
219
+ if (!id) {
220
+ return;
221
+ }
222
+ if (details.open) {
223
+ expandedCurlHosts.add(id);
224
+ } else {
225
+ expandedCurlHosts.delete(id);
226
+ }
227
+ });
228
+
229
+ void refresh();
230
+ setInterval(() => {
231
+ void refresh();
232
+ }, REFRESH_MS);
src/pages/detectionNodeHost.ts ADDED
@@ -0,0 +1,338 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import {
2
+ CLUSTER_MODELS,
3
+ DEFAULT_CLUSTER_MODEL_ID,
4
+ getClusterModel,
5
+ isImplementedClusterModel,
6
+ } from '../../shared/clusterModels';
7
+ import type {ObjectDetector} from '../detection/ObjectDetector';
8
+ import type {DetectionResult} from '../detection/workerMessages';
9
+ import type {VideoDescriber} from '../videodescription/VideoDescriber';
10
+
11
+ const API_BASE = import.meta.env.VITE_API_BASE ?? '';
12
+
13
+ const OBJECT_DETECTION_MODEL_ID = 'rfdetr-medium';
14
+ const VIDEO_DESCRIPTION_MODEL_ID = 'smolvlm-500m';
15
+
16
+ type ClusterTaskKind = 'detect' | 'describe';
17
+
18
+ interface HostTaskMessage {
19
+ id: string;
20
+ kind: ClusterTaskKind;
21
+ image_base64: string;
22
+ mime_type: string;
23
+ threshold?: number;
24
+ instruction?: string;
25
+ max_new_tokens?: number;
26
+ }
27
+
28
+ const statusEl = document.getElementById('status')!;
29
+ const hostIdInput = document.getElementById('host-id') as HTMLInputElement;
30
+ const modelSelect = document.getElementById('model-select') as HTMLSelectElement;
31
+ const registerBtn = document.getElementById('register-btn') as HTMLButtonElement;
32
+ const curlExample = document.getElementById('curl-example')!;
33
+
34
+ let detector: ObjectDetector | null = null;
35
+ let describer: VideoDescriber | null = null;
36
+ let hostId = '';
37
+ let selectedModelId = DEFAULT_CLUSTER_MODEL_ID;
38
+ let processing = false;
39
+ let eventSource: EventSource | null = null;
40
+ const taskQueue: HostTaskMessage[] = [];
41
+
42
+ function setStatus(message: string): void {
43
+ statusEl.textContent = message;
44
+ console.log('[detection-host]', message);
45
+ }
46
+
47
+ function populateModelSelect(): void {
48
+ modelSelect.replaceChildren(
49
+ ...CLUSTER_MODELS.map((model) => {
50
+ const option = document.createElement('option');
51
+ option.value = model.id;
52
+ option.textContent = model.implemented
53
+ ? model.label
54
+ : `${model.label} (coming soon)`;
55
+ option.disabled = !model.implemented;
56
+ option.title = model.description;
57
+ return option;
58
+ }),
59
+ );
60
+ modelSelect.value = DEFAULT_CLUSTER_MODEL_ID;
61
+ }
62
+
63
+ function updateCurlExample(): void {
64
+ const origin = window.location.origin;
65
+
66
+ if (selectedModelId === OBJECT_DETECTION_MODEL_ID) {
67
+ curlExample.textContent = `curl -X POST '${origin}/v1/detect' \\
68
+ -H 'Content-Type: application/json' \\
69
+ -d '{
70
+ "host": "${hostId}",
71
+ "image_url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png",
72
+ "threshold": 0.5
73
+ }'`;
74
+ return;
75
+ }
76
+
77
+ if (selectedModelId === VIDEO_DESCRIPTION_MODEL_ID) {
78
+ curlExample.textContent = `curl -X POST '${origin}/v1/describe' \\
79
+ -H 'Content-Type: application/json' \\
80
+ -d '{
81
+ "host": "${hostId}",
82
+ "image_url": "https://huggingface.co/datasets/huggingface/documentation-images/resolve/main/coco_sample.png",
83
+ "instruction": "What do you see?",
84
+ "max_new_tokens": 100
85
+ }'`;
86
+ return;
87
+ }
88
+
89
+ const model = getClusterModel(selectedModelId);
90
+ curlExample.textContent = model
91
+ ? `# ${model.label}\n# API endpoint for this model is not wired yet.`
92
+ : '';
93
+ }
94
+
95
+ async function ensureModelLoaded(modelId: string): Promise<void> {
96
+ if (modelId === OBJECT_DETECTION_MODEL_ID) {
97
+ if (!detector) {
98
+ setStatus('Loading RF-DETR (WebGPU)…');
99
+ const {ObjectDetector: Detector} = await import('../detection/ObjectDetector');
100
+ detector = await Detector.create(setStatus);
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (modelId === VIDEO_DESCRIPTION_MODEL_ID) {
106
+ if (!describer) {
107
+ setStatus('Loading SmolVLM-500M (WebGPU)…');
108
+ const {VideoDescriber: Describer} = await import('../videodescription/VideoDescriber');
109
+ describer = await Describer.create(setStatus);
110
+ }
111
+ return;
112
+ }
113
+
114
+ const model = getClusterModel(modelId);
115
+ throw new Error(
116
+ model
117
+ ? `${model.label} is not available on this host yet.`
118
+ : `Unknown model "${modelId}".`,
119
+ );
120
+ }
121
+
122
+ async function base64ToVideoFrame(base64: string, mimeType: string): Promise<VideoFrame> {
123
+ const binary = atob(base64);
124
+ const bytes = new Uint8Array(binary.length);
125
+ for (let i = 0; i < binary.length; i++) {
126
+ bytes[i] = binary.charCodeAt(i);
127
+ }
128
+
129
+ const blob = new Blob([bytes], {type: mimeType});
130
+ const bitmap = await createImageBitmap(blob);
131
+ const frame = new VideoFrame(bitmap, {timestamp: 0});
132
+ bitmap.close();
133
+ return frame;
134
+ }
135
+
136
+ async function processDetectTask(task: HostTaskMessage): Promise<void> {
137
+ if (!detector) {
138
+ return;
139
+ }
140
+
141
+ let frame: VideoFrame | null = null;
142
+
143
+ try {
144
+ frame = await base64ToVideoFrame(task.image_base64, task.mime_type);
145
+ const results = await detector.detect(frame, {threshold: task.threshold ?? 0.5});
146
+
147
+ await fetch(`${API_BASE}/api/tasks/${task.id}/complete`, {
148
+ method: 'POST',
149
+ headers: {'Content-Type': 'application/json'},
150
+ body: JSON.stringify({
151
+ status: 'done',
152
+ results: serializeDetectionResults(results),
153
+ }),
154
+ });
155
+ } catch (error) {
156
+ const message = error instanceof Error ? error.message : String(error);
157
+ await fetch(`${API_BASE}/api/tasks/${task.id}/complete`, {
158
+ method: 'POST',
159
+ headers: {'Content-Type': 'application/json'},
160
+ body: JSON.stringify({status: 'error', error: message}),
161
+ });
162
+ throw error;
163
+ } finally {
164
+ frame?.close();
165
+ }
166
+ }
167
+
168
+ async function processDescribeTask(task: HostTaskMessage): Promise<void> {
169
+ if (!describer) {
170
+ return;
171
+ }
172
+
173
+ let frame: VideoFrame | null = null;
174
+
175
+ try {
176
+ frame = await base64ToVideoFrame(task.image_base64, task.mime_type);
177
+ const description = await describer.describe(frame, {
178
+ instruction: task.instruction ?? 'What do you see?',
179
+ maxNewTokens: task.max_new_tokens ?? 100,
180
+ });
181
+
182
+ await fetch(`${API_BASE}/api/tasks/${task.id}/complete`, {
183
+ method: 'POST',
184
+ headers: {'Content-Type': 'application/json'},
185
+ body: JSON.stringify({
186
+ status: 'done',
187
+ description,
188
+ }),
189
+ });
190
+ } catch (error) {
191
+ const message = error instanceof Error ? error.message : String(error);
192
+ await fetch(`${API_BASE}/api/tasks/${task.id}/complete`, {
193
+ method: 'POST',
194
+ headers: {'Content-Type': 'application/json'},
195
+ body: JSON.stringify({status: 'error', error: message}),
196
+ });
197
+ throw error;
198
+ } finally {
199
+ frame?.close();
200
+ }
201
+ }
202
+
203
+ async function processTask(task: HostTaskMessage): Promise<void> {
204
+ processing = true;
205
+ setStatus(`Processing task ${task.id.slice(0, 8)}…`);
206
+
207
+ try {
208
+ if (task.kind === 'describe') {
209
+ await processDescribeTask(task);
210
+ } else {
211
+ await processDetectTask(task);
212
+ }
213
+ setStatus(`Task ${task.id.slice(0, 8)} complete. Waiting for requests…`);
214
+ } catch (error) {
215
+ const message = error instanceof Error ? error.message : String(error);
216
+ setStatus(`Task failed: ${message}`);
217
+ } finally {
218
+ processing = false;
219
+ void drainTaskQueue();
220
+ }
221
+ }
222
+
223
+ async function drainTaskQueue(): Promise<void> {
224
+ if (processing || taskQueue.length === 0) {
225
+ return;
226
+ }
227
+
228
+ const task = taskQueue.shift();
229
+ if (task) {
230
+ await processTask(task);
231
+ }
232
+ }
233
+
234
+ function enqueueTask(task: HostTaskMessage): void {
235
+ taskQueue.push(task);
236
+ void drainTaskQueue();
237
+ }
238
+
239
+ function serializeDetectionResults(results: DetectionResult[]): unknown[] {
240
+ return results.map((result) => ({
241
+ label: result.label,
242
+ score: result.score,
243
+ box: {
244
+ xmin: result.box.xmin,
245
+ ymin: result.box.ymin,
246
+ xmax: result.box.xmax,
247
+ ymax: result.box.ymax,
248
+ },
249
+ }));
250
+ }
251
+
252
+ function connectStream(): void {
253
+ eventSource?.close();
254
+
255
+ const url = `${API_BASE}/api/hosts/stream?host_id=${encodeURIComponent(hostId)}`;
256
+ eventSource = new EventSource(url);
257
+
258
+ eventSource.addEventListener('ready', () => {
259
+ const model = getClusterModel(selectedModelId);
260
+ const modelLabel = model?.label ?? selectedModelId;
261
+ setStatus(`Hosting "${hostId}" (${modelLabel}). Waiting for requests…`);
262
+ });
263
+
264
+ eventSource.addEventListener('task', (event) => {
265
+ const raw = JSON.parse(event.data) as HostTaskMessage & {kind?: ClusterTaskKind};
266
+ const task: HostTaskMessage = {
267
+ ...raw,
268
+ kind: raw.kind ?? 'detect',
269
+ };
270
+ enqueueTask(task);
271
+ });
272
+
273
+ eventSource.onerror = () => {
274
+ if (eventSource?.readyState === EventSource.CLOSED) {
275
+ setStatus('Broker connection lost. Reconnecting in 2s…');
276
+ setTimeout(connectStream, 2000);
277
+ }
278
+ };
279
+ }
280
+
281
+ async function registerHost(): Promise<void> {
282
+ hostId = hostIdInput.value.trim();
283
+ selectedModelId = modelSelect.value;
284
+
285
+ if (!hostId) {
286
+ setStatus('Enter a host id (e.g. my-gpu-node).');
287
+ return;
288
+ }
289
+
290
+ if (!isImplementedClusterModel(selectedModelId)) {
291
+ setStatus('Choose an available model from the list.');
292
+ return;
293
+ }
294
+
295
+ registerBtn.disabled = true;
296
+ modelSelect.disabled = true;
297
+ hostIdInput.disabled = true;
298
+
299
+ try {
300
+ await ensureModelLoaded(selectedModelId);
301
+
302
+ const res = await fetch(`${API_BASE}/api/hosts/register`, {
303
+ method: 'POST',
304
+ headers: {'Content-Type': 'application/json'},
305
+ body: JSON.stringify({id: hostId, model: selectedModelId}),
306
+ });
307
+
308
+ if (!res.ok) {
309
+ const json = await res.json().catch(() => ({}));
310
+ throw new Error(json.error ?? `Register failed (${res.status})`);
311
+ }
312
+
313
+ updateCurlExample();
314
+ connectStream();
315
+ } catch (error) {
316
+ const message = error instanceof Error ? error.message : String(error);
317
+ setStatus(`Failed to start host:\n${message}`);
318
+ registerBtn.disabled = false;
319
+ modelSelect.disabled = false;
320
+ hostIdInput.disabled = false;
321
+ }
322
+ }
323
+
324
+ registerBtn.addEventListener('click', () => {
325
+ void registerHost();
326
+ });
327
+
328
+ modelSelect.addEventListener('change', () => {
329
+ selectedModelId = modelSelect.value;
330
+ updateCurlExample();
331
+ });
332
+
333
+ populateModelSelect();
334
+ hostIdInput.value = `node-${Math.random().toString(36).slice(2, 8)}`;
335
+ selectedModelId = modelSelect.value;
336
+ updateCurlExample();
337
+
338
+ setStatus('Pick a model, enter a host id, and click “Start hosting”.');
src/videodescription/VideoDescriber.ts ADDED
@@ -0,0 +1,127 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import type {WorkerRequest, WorkerResponse} from './workerMessages';
2
+
3
+ type PendingRequest = {
4
+ resolve: (description: string) => void;
5
+ reject: (error: Error) => void;
6
+ };
7
+
8
+ export class VideoDescriber {
9
+ private readonly worker: Worker;
10
+ private readonly pending = new Map<number, PendingRequest>();
11
+ private nextId = 0;
12
+ private readonly ready: Promise<void>;
13
+ private readySettled = false;
14
+ private resolveReady!: () => void;
15
+ private rejectReady!: (error: Error) => void;
16
+ private onStatus?: (message: string) => void;
17
+
18
+ private constructor(worker: Worker, onStatus?: (message: string) => void) {
19
+ this.worker = worker;
20
+ this.onStatus = onStatus;
21
+
22
+ this.ready = new Promise<void>((resolve, reject) => {
23
+ this.resolveReady = resolve;
24
+ this.rejectReady = reject;
25
+ });
26
+
27
+ this.worker.addEventListener('message', (event: MessageEvent<WorkerResponse>) => {
28
+ this.handleMessage(event.data);
29
+ });
30
+ this.worker.addEventListener('error', (event) => {
31
+ this.failReady(new Error(event.message || 'Video description worker failed'));
32
+ });
33
+
34
+ this.worker.postMessage({type: 'init'} satisfies WorkerRequest);
35
+ }
36
+
37
+ static async create(onStatus?: (message: string) => void): Promise<VideoDescriber> {
38
+ const worker = new Worker(
39
+ new URL('./videodescription.worker.ts', import.meta.url),
40
+ {type: 'module'},
41
+ );
42
+ const describer = new VideoDescriber(worker, onStatus);
43
+ await describer.ready;
44
+ return describer;
45
+ }
46
+
47
+ async warmup(frame: VideoFrame, instruction = 'What do you see?'): Promise<void> {
48
+ await this.describe(frame, {instruction});
49
+ }
50
+
51
+ async describe(
52
+ frame: VideoFrame,
53
+ options: {instruction: string; maxNewTokens?: number},
54
+ ): Promise<string> {
55
+ await this.ready;
56
+
57
+ const id = this.nextId++;
58
+
59
+ return new Promise<string>((resolve, reject) => {
60
+ this.pending.set(id, {resolve, reject});
61
+ this.worker.postMessage(
62
+ {
63
+ type: 'describe',
64
+ id,
65
+ instruction: options.instruction,
66
+ maxNewTokens: options.maxNewTokens ?? 100,
67
+ frame,
68
+ } satisfies WorkerRequest,
69
+ [frame],
70
+ );
71
+ });
72
+ }
73
+
74
+ dispose(): void {
75
+ for (const {reject} of this.pending.values()) {
76
+ reject(new Error('Video description worker terminated'));
77
+ }
78
+ this.pending.clear();
79
+ this.worker.terminate();
80
+ }
81
+
82
+ private handleMessage(message: WorkerResponse): void {
83
+ if (message.type === 'status') {
84
+ this.onStatus?.(message.message);
85
+ return;
86
+ }
87
+
88
+ if (message.type === 'ready') {
89
+ if (!this.readySettled) {
90
+ this.readySettled = true;
91
+ this.resolveReady();
92
+ }
93
+ return;
94
+ }
95
+
96
+ if (message.type === 'describe-result') {
97
+ const pending = this.pending.get(message.id);
98
+ if (pending) {
99
+ this.pending.delete(message.id);
100
+ pending.resolve(message.description);
101
+ }
102
+ return;
103
+ }
104
+
105
+ if (message.type === 'error') {
106
+ const error = new Error(message.error);
107
+
108
+ if (message.id !== undefined) {
109
+ const pending = this.pending.get(message.id);
110
+ if (pending) {
111
+ this.pending.delete(message.id);
112
+ pending.reject(error);
113
+ }
114
+ return;
115
+ }
116
+
117
+ this.failReady(error);
118
+ }
119
+ }
120
+
121
+ private failReady(error: Error): void {
122
+ if (!this.readySettled) {
123
+ this.readySettled = true;
124
+ this.rejectReady(error);
125
+ }
126
+ }
127
+ }
src/videodescription/videodescription.worker.ts ADDED
@@ -0,0 +1,158 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /// <reference lib="webworker" />
2
+
3
+ import {
4
+ AutoModelForVision2Seq,
5
+ AutoProcessor,
6
+ RawImage,
7
+ Tensor,
8
+ type PreTrainedModel,
9
+ type Processor,
10
+ } from '@huggingface/transformers';
11
+ import type {WorkerRequest, WorkerResponse} from './workerMessages';
12
+
13
+ const MODEL_ID = 'HuggingFaceTB/SmolVLM-500M-Instruct';
14
+
15
+ const DTYPE = {
16
+ embed_tokens: 'fp16',
17
+ vision_encoder: 'q4',
18
+ decoder_model_merged: 'q4',
19
+ } as const;
20
+
21
+ let processor: Processor | null = null;
22
+ let model: PreTrainedModel | null = null;
23
+ let loadPromise: Promise<void> | null = null;
24
+
25
+ function post(message: WorkerResponse): void {
26
+ self.postMessage(message);
27
+ }
28
+
29
+ function reportDownloadProgress(progress: {
30
+ status: string;
31
+ file?: string;
32
+ loaded?: number;
33
+ total?: number;
34
+ }): void {
35
+ if (progress.status === 'progress' && progress.file) {
36
+ const percent =
37
+ progress.total && progress.total > 0
38
+ ? Math.round(((progress.loaded ?? 0) / progress.total) * 100)
39
+ : null;
40
+ post({
41
+ type: 'status',
42
+ message: percent
43
+ ? `Downloading ${progress.file} (${percent}%)…`
44
+ : `Downloading ${progress.file}…`,
45
+ });
46
+ }
47
+ }
48
+
49
+ async function loadPipeline(): Promise<{processor: Processor; model: PreTrainedModel}> {
50
+ if (processor && model) {
51
+ return {processor, model};
52
+ }
53
+
54
+ if (!loadPromise) {
55
+ loadPromise = (async () => {
56
+ post({type: 'status', message: 'Loading SmolVLM processor…'});
57
+ processor = await AutoProcessor.from_pretrained(MODEL_ID, {
58
+ progress_callback: reportDownloadProgress,
59
+ });
60
+
61
+ post({type: 'status', message: 'Loading SmolVLM model (WebGPU)…'});
62
+ model = await AutoModelForVision2Seq.from_pretrained(MODEL_ID, {
63
+ dtype: DTYPE,
64
+ device: 'webgpu',
65
+ progress_callback: reportDownloadProgress,
66
+ });
67
+
68
+ post({type: 'status', message: 'Compiling SmolVLM shaders…'});
69
+ })();
70
+ }
71
+
72
+ await loadPromise;
73
+ return {processor: processor!, model: model!};
74
+ }
75
+
76
+ async function frameToRawImage(frame: VideoFrame): Promise<RawImage> {
77
+ const bitmap = await createImageBitmap(frame);
78
+ try {
79
+ const canvas = new OffscreenCanvas(bitmap.width, bitmap.height);
80
+ const ctx = canvas.getContext('2d', {willReadFrequently: true});
81
+ if (!ctx) {
82
+ throw new Error('OffscreenCanvas 2D context not available in worker');
83
+ }
84
+ ctx.drawImage(bitmap, 0, 0);
85
+ return RawImage.fromCanvas(canvas);
86
+ } finally {
87
+ bitmap.close();
88
+ frame.close();
89
+ }
90
+ }
91
+
92
+ async function runDescription(
93
+ frame: VideoFrame,
94
+ instruction: string,
95
+ maxNewTokens: number,
96
+ ): Promise<string> {
97
+ const {processor: proc, model: vlm} = await loadPipeline();
98
+ const image = await frameToRawImage(frame);
99
+
100
+ const messages = [
101
+ {
102
+ role: 'user',
103
+ content: [{type: 'image'}, {type: 'text', text: instruction}],
104
+ },
105
+ ];
106
+
107
+ const text = proc.apply_chat_template(messages, {
108
+ add_generation_prompt: true,
109
+ });
110
+
111
+ const inputs = await proc(text, [image], {
112
+ do_image_splitting: false,
113
+ });
114
+
115
+ const generatedIds = (await vlm.generate({
116
+ ...inputs,
117
+ max_new_tokens: maxNewTokens,
118
+ })) as Tensor;
119
+
120
+ const promptLength = inputs.input_ids.dims.at(-1) ?? 0;
121
+ const output = proc.batch_decode(generatedIds.slice(null, [promptLength, null]), {
122
+ skip_special_tokens: true,
123
+ });
124
+
125
+ return output[0]?.trim() ?? '';
126
+ }
127
+
128
+ self.onmessage = async (event: MessageEvent<WorkerRequest>) => {
129
+ const message = event.data;
130
+
131
+ try {
132
+ if (message.type === 'init') {
133
+ await loadPipeline();
134
+ post({type: 'ready'});
135
+ return;
136
+ }
137
+
138
+ if (message.type === 'describe') {
139
+ const description = await runDescription(
140
+ message.frame,
141
+ message.instruction,
142
+ message.maxNewTokens,
143
+ );
144
+ post({type: 'describe-result', id: message.id, description});
145
+ return;
146
+ }
147
+ } catch (error) {
148
+ if (message.type === 'describe') {
149
+ message.frame.close();
150
+ }
151
+
152
+ post({
153
+ type: 'error',
154
+ error: error instanceof Error ? error.message : String(error),
155
+ id: message.type === 'describe' ? message.id : undefined,
156
+ });
157
+ }
158
+ };
src/videodescription/workerMessages.ts ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export type WorkerRequest =
2
+ | {type: 'init'}
3
+ | {
4
+ type: 'describe';
5
+ id: number;
6
+ frame: VideoFrame;
7
+ instruction: string;
8
+ maxNewTokens: number;
9
+ };
10
+
11
+ export type WorkerResponse =
12
+ | {type: 'status'; message: string}
13
+ | {type: 'ready'}
14
+ | {type: 'describe-result'; id: number; description: string}
15
+ | {type: 'error'; error: string; id?: number};
src/vite-env.d.ts ADDED
@@ -0,0 +1 @@
 
 
1
+ /// <reference types="vite/client" />
tsconfig.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
5
+ "module": "ESNext",
6
+ "moduleResolution": "bundler",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "types": ["@webgpu/types"]
11
+ },
12
+ "include": ["src", "shared"],
13
+ "exclude": ["server"]
14
+ }
tsconfig.server.json ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "lib": ["ES2022"],
5
+ "module": "NodeNext",
6
+ "moduleResolution": "NodeNext",
7
+ "strict": true,
8
+ "skipLibCheck": true,
9
+ "noEmit": true,
10
+ "esModuleInterop": true,
11
+ "resolveJsonModule": true
12
+ },
13
+ "include": ["server", "shared"]
14
+ }
vite.config.ts ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import path from 'node:path';
2
+ import { fileURLToPath } from 'node:url';
3
+ import { defineConfig } from 'vite';
4
+
5
+ const API_PORT = 8787;
6
+ const rootDir = path.dirname(fileURLToPath(import.meta.url));
7
+
8
+ export default defineConfig(({ command, mode }) => ({
9
+ base: mode === 'space' ? '/' : command === 'build' ? '/webgpu-video-ai/' : '/',
10
+ optimizeDeps: {
11
+ exclude: ['@huggingface/transformers'],
12
+ },
13
+ worker: {
14
+ format: 'es',
15
+ },
16
+ build: {
17
+ outDir: 'docs',
18
+ rollupOptions: {
19
+ input: {
20
+ main: path.resolve(rootDir, 'index.html'),
21
+ monitor: path.resolve(rootDir, 'monitor.html'),
22
+ },
23
+ },
24
+ },
25
+ server: {
26
+ port: 5180,
27
+ headers: {
28
+ 'Cross-Origin-Opener-Policy': 'same-origin',
29
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
30
+ },
31
+ proxy: {
32
+ '/api': `http://localhost:${API_PORT}`,
33
+ '/v1': `http://localhost:${API_PORT}`,
34
+ '/health': `http://localhost:${API_PORT}`,
35
+ },
36
+ },
37
+ preview: {
38
+ headers: {
39
+ 'Cross-Origin-Opener-Policy': 'same-origin',
40
+ 'Cross-Origin-Embedder-Policy': 'require-corp',
41
+ },
42
+ proxy: {
43
+ '/api': `http://localhost:${API_PORT}`,
44
+ '/v1': `http://localhost:${API_PORT}`,
45
+ '/health': `http://localhost:${API_PORT}`,
46
+ },
47
+ },
48
+ }));