-
Notifications
You must be signed in to change notification settings - Fork 5
Expand file tree
/
Copy pathlive-video-softproof.html
More file actions
644 lines (573 loc) · 25.7 KB
/
Copy pathlive-video-softproof.html
File metadata and controls
644 lines (573 loc) · 25.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
<!doctype html>
<!--
Copyright (c) 2026 Glenn Wilton, O2 Creative Limited
Released under the MIT License (see ./LICENSE).
-->
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>jsColorEngine · Real-Time Video Soft Proof</title>
<link rel="stylesheet" href="styles.css">
<style>
.video-grid {
display: grid; gap: 18px;
grid-template-columns: 1fr 1fr;
}
@media (max-width: 820px) { .video-grid { grid-template-columns: 1fr; } }
.video-grid video, .video-grid canvas {
display: block; width: 100%; height: auto;
border-radius: 6px; background: #111;
border: 1px solid var(--border-soft);
}
.panel-head {
display: flex; align-items: baseline; gap: 10px;
margin-bottom: 6px; font-size: 13px;
}
.panel-head .engine-name { font-weight: 600; }
.panel-timing-block {
font-family: var(--mono); font-size: 11px; color: var(--accent-2);
line-height: 1.5; margin-top: 6px;
}
.panel-timing-block .t-lbl {
color: var(--text-muted); font-size: 10px; text-transform: uppercase;
letter-spacing: 0.04em;
}
.fps-badge {
display: inline-block;
font-family: var(--mono); font-size: 13px; font-weight: 700;
color: var(--accent); background: rgba(0,0,0,0.4);
padding: 2px 8px; border-radius: 4px; margin-left: 8px;
}
.fps-badge.is-good { color: var(--accent-2); }
.fps-badge.is-ok { color: var(--warn); }
.fps-badge.is-bad { color: var(--error); }
.cheeky {
font-style: italic; color: var(--text-dim); font-size: 12px;
margin-top: 6px;
}
</style>
<script src="devtools-warn.js"></script>
<script src="browser/jsColorEngineWeb.js"></script>
</head>
<body>
<header class="topbar">
<div class="topbar-head">
<div class="brand">
<span class="brand-mark">jsColorEngine</span>
<span class="brand-tag">samples</span>
</div>
<nav class="nav" aria-label="Site">
<a href="index.html">Home</a>
<a href="samples.html" class="is-active">Samples</a>
<a href="bench/">Bench</a>
</nav>
</div>
<nav class="nav nav--secondary" aria-label="Sample demos">
<a href="samples.html">Samples</a>
<a href="live-video-softproof.html" class="is-active">Live Video</a>
<a href="softproof.html">Soft Proof</a>
<a href="softproof-vs-lcms.html">jsCE vs lcms</a>
<a href="lut-cmyk-to-rgb.html">CMYK → RGB LUT</a>
<a href="lut-tiff-builder.html">LUT TIFF Builder</a>
<a href="colour-calculator.html">Colour Calculator</a>
</nav>
</header>
<main>
<section class="card">
<h1 style="font-size:22px; line-height:1.35; margin:0 0 8px;">
The world’s first — and, frankly, most unnecessary
— real-time video soft-proofing engine.
</h1>
<p class="lead" style="font-size:13px;">
Because somewhere, someone needs to know whether their car video is
CMYK-safe for GRACoL 2006 Coated. That person is probably not you. But the
proof is right there that jsColorEngine is fast enough to do real time video.
</p>
<p class="lead" style="margin-top:10px;">
Left: original sRGB video. Right: every frame decoded, run through
a full ICC soft-proof pipeline (sRGB → CMYK → sRGB) via a pre-built 3D CLUT,
and blitted to canvas — <em>in real time</em> in Javascript wasm.
no WebGL, no workers. Just Single thread JavaScript and a lookup table.
</p>
<details style="margin-top:10px;">
<summary class="muted" style="cursor:pointer;">Why?</summary>
<p class="lead" style="margin-top:8px;">
The headline is <strong>zero hot-path cost.</strong> The
tetrahedral interpolator doesn’t know or care whether a
LUT cell carries a soft-proof RGB image or video frame
— it just blends 8 corners. All the profile maths
amortises into a one-time ~50–100 ms LUT bake.
</p>
<p class="lead">
Budget: 720×1280 @ 30 fps =
27.6 MPx/s. The 3D int8 path already sits at
60–200 MPx/s — on a mid range x64 processor,
plenty of slack even after
<code>drawImage</code> round-trip overhead.
</p>
<p class="lead">
Demo value: <em>“here’s what you video would look like printed
frame by frame on a press, right now.”</em>
The Profile-swap dropdown makes the
difference between GRACoL and ISO Coated visible in
real-time — which is a thing colour-managed workflows
normally can only show on stills, and only after a render-wait.
</p>
<p class="lead">
This is what happens when you realize you can and don't
stop to think if you should. This quietly demonstrates that the
engine is fast enough to do a genuinely silly thing at 40+ fps
just because it can.
</p>
<p class="lead">
<strong>Why not WebGL?</strong> Real-time video LUT work in
the browser usually reaches for a fragment shader — fast
and proven. But a lot of jsColorEngine’s actual users
don’t have a GPU at all: headless Node.js on a print-queue
server, a containerised RIP, a CI step rendering proof previews,
an AWS Lambda batch job. Shaders need a GPU, driver, and often
a window context; WASM SIMD doesn’t. This demo runs the
same kernel that would run on the server — the browser
tab is just a convenient place to <em>see</em> it move.
</p>
</details>
<p class="muted" style="margin-top:8px;">
Pick a sample clip in the controls; attribution is shown there.
</p>
</section>
<section class="card">
<div class="controls">
<label>
Sample video
<select id="videoSelect">
<option value="video/sample1.mp4" selected>Colourful</option>
<option value="video/sample2.mp4">Car</option>
<option value="video/sample3.mp4">Autumn</option>
</select>
</label>
<label>
CMYK Profile
<select id="profileSelect">
<option value="profiles/CoatedGRACoL2006.icc" selected>GRACoL 2006 Coated (US)</option>
<option value="profiles/ISOcoated_v2_eci.icc">ISO Coated v2 (ECI)</option>
<option value="profiles/eciCMYK_v2.icc">eciCMYK v2 (Generic Exchange)</option>
</select>
</label>
<label>
Intent
<select id="intentSelect">
<option value="perceptual" selected>Perceptual</option>
<option value="relative">Relative</option>
<option value="saturation">Saturation</option>
<option value="absolute">Absolute</option>
</select>
</label>
<button class="btn-primary" id="playBtn">▶ Play</button>
<!-- Lock 30fps via requestVideoFrameCallback — disabled for now,
causes stutter due to rVFC timing vs frame budget overlap.
Left in code for future revisit with double-buffering. -->
<input type="checkbox" id="lockFps" style="display:none">
<span id="statusText" class="timing-inline"></span>
</div>
<p class="muted" style="margin-top:12px;" id="videoAttribution"></p>
</section>
<section class="card">
<div class="video-grid">
<div>
<div class="panel-head">
<span class="engine-name">Original sRGB</span>
</div>
<video id="srcVideo" muted loop playsinline preload="auto"></video>
</div>
<div>
<div class="panel-head">
<span class="engine-name">Soft proof</span>
<span class="fps-badge" id="fpsBadge">—</span>
</div>
<canvas id="proofCanvas"></canvas>
<div class="panel-timing-block" id="proofTiming"></div>
</div>
</div>
</section>
<section class="card">
<h2>System</h2>
<div class="info-grid" style="grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));">
<div class="info-cell">
<span class="lbl">WASM</span>
<span class="val" id="sysWasm">—</span>
</div>
<div class="info-cell">
<span class="lbl">WASM SIMD</span>
<span class="val" id="sysSimd">—</span>
</div>
<div class="info-cell">
<span class="lbl">LUT kernel</span>
<span class="val" id="sysKernel">—</span>
</div>
<div class="info-cell">
<span class="lbl">Video frame API</span>
<span class="val" id="sysVfc">—</span>
</div>
</div>
<p class="muted" style="margin-top:10px;">
Requires a modern browser with WebAssembly SIMD support
(Chrome 91+, Firefox 89+, Safari 16.4+,
Edge 91+) for full performance. Without SIMD the engine
falls back to scalar WASM or pure JS — still works, but
frame rate will drop.
</p>
</section>
<section class="card">
<h2>How it works</h2>
<div class="info-grid" style="grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));">
<div class="info-cell">
<span class="lbl">Pipeline</span>
<span class="val">sRGB → CMYK → sRGB</span>
</div>
<div class="info-cell">
<span class="lbl">LUT</span>
<span class="val">3D CLUT, pre-built once</span>
</div>
<div class="info-cell">
<span class="lbl">Interpolation</span>
<span class="val">Tetrahedral, int8</span>
</div>
<div class="info-cell">
<span class="lbl">Hot path</span>
<span class="val">transformArrayViaLUT</span>
</div>
<div class="info-cell">
<span class="lbl">Workers / WASM</span>
<span class="val">None — main thread JS</span>
</div>
<div class="info-cell">
<span class="lbl">Allocation</span>
<span class="val">Zero per frame</span>
</div>
</div>
</section>
</main>
<footer class="footer">
<span>© 2026 Glenn Wilton, O2 Creative Limited · <a href="LICENSE">MIT License</a></span>
<span>
<a href="https://github.com/glennwilton/jsColorEngine">GitHub</a>
·
<a href="bench/">Bench</a>
</span>
</footer>
<script>
(function () {
'use strict';
// ── jsColorEngine imports ─────────────────────────────────────────────
//
// Profile – loads or creates ICC colour profiles (sRGB, CMYK, etc.)
// Transform – builds a colour pipeline and transforms pixel data
// eIntent – rendering-intent enum: perceptual, relative, saturation, absolute
const { Profile, Transform, eIntent } = window.jsColorEngine;
// ── DOM references ────────────────────────────────────────────────────
const video = document.getElementById('srcVideo');
const canvas = document.getElementById('proofCanvas');
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const playBtn = document.getElementById('playBtn');
const statusText = document.getElementById('statusText');
const fpsBadge = document.getElementById('fpsBadge');
const proofTiming = document.getElementById('proofTiming');
const profileSel = document.getElementById('profileSelect');
const intentSel = document.getElementById('intentSelect');
const videoSel = document.getElementById('videoSelect');
const videoAttrEl = document.getElementById('videoAttribution');
const lockFpsCb = document.getElementById('lockFps');
const VIDEO_CREDIT = {
'video/sample1.mp4': 'Colourful — Video by Norberto Navarro Valiente from Pixabay',
'video/sample2.mp4': 'Car — Video by MD RONI ISLAM from Pixabay',
'video/sample3.mp4': 'Autumn — Video by Robert from Pixabay',
};
function setVideoSource() {
const url = videoSel.value;
if (running) stop();
video.src = url;
video.load();
videoAttrEl.innerHTML = VIDEO_CREDIT[url] || '';
}
const hasVFC = typeof HTMLVideoElement !== 'undefined' &&
'requestVideoFrameCallback' in HTMLVideoElement.prototype;
let transform = null;
let running = false;
let rafId = 0;
let vfcId = 0;
// Off-screen canvas used to grab raw RGBA pixel data from the video.
let srcCanvas = null;
let srcCtx = null;
let w = 0, h = 0, pixels = 0;
// Pre-allocated output buffer — reused every frame to avoid per-frame
// Uint8ClampedArray allocation and garbage-collection pressure.
// See "Output buffer reuse" below in processFrame().
let reusableOut = null;
// FPS / throughput tracking (updated once per second)
let frameCount = 0;
let lastFpsUpdate = 0;
let transformMsAccum = 0;
// ── System detection ──────────────────────────────────────────────────
//
// jsColorEngine auto-detects WASM / SIMD and picks the fastest kernel:
// SIMD available → 'int-wasm-simd' (~160 MPx/s)
// WASM only → 'int-wasm-scalar' (~85 MPx/s)
// neither → 'int' pure JS (~75 MPx/s)
//
// We detect here just to show the user what their browser supports.
function detectWasm() {
try {
if (typeof WebAssembly === 'object' &&
typeof WebAssembly.instantiate === 'function') return true;
} catch (_) {}
return false;
}
function detectSimd() {
try {
return WebAssembly.validate(new Uint8Array([
0,97,115,109,1,0,0,0,1,5,1,96,0,1,123,3,2,1,0,10,10,1,
8,0,65,0,253,15,253,98,11
]));
} catch (_) {}
return false;
}
const wasmOk = detectWasm();
const simdOk = detectSimd();
document.getElementById('sysWasm').textContent = wasmOk ? 'OK' : 'NOT AVAILABLE';
document.getElementById('sysWasm').className = 'val' + (wasmOk ? ' is-ok' : ' is-warn');
document.getElementById('sysSimd').textContent = simdOk ? 'OK' : 'NOT AVAILABLE';
document.getElementById('sysSimd').className = 'val' + (simdOk ? ' is-ok' : ' is-warn');
document.getElementById('sysVfc').textContent = hasVFC ? 'requestVideoFrameCallback' : 'requestAnimationFrame (fallback)';
document.getElementById('sysVfc').className = 'val' + (hasVFC ? ' is-ok' : '');
if (!simdOk) {
statusText.textContent = '\u26a0 No WASM SIMD \u2014 performance will be reduced';
statusText.className = 'timing-inline';
}
// ── Engine setup ──────────────────────────────────────────────────────
//
// The colour pipeline for soft-proofing is:
//
// sRGB → CMYK (print profile) → sRGB
//
// This simulates what the image would look like once printed on a
// specific press/paper stock and then viewed on screen.
//
// The pipeline is built once via createMultiStage(). Internally this
// bakes a 3D CLUT (colour look-up table) — all the heavy profile
// maths (chromatic adaptation, gamut mapping, PCS conversion, TRC
// curves) is folded into the LUT at build time (~50–100 ms).
//
// After that, every frame just calls transformArrayViaLUT() which
// does fast tetrahedral interpolation through the pre-built LUT —
// no profile maths at all on the hot path.
function parseIntent() {
return eIntent[intentSel.value] ?? eIntent.perceptual;
}
async function buildTransform() {
statusText.textContent = 'Building LUT\u2026';
statusText.className = 'timing-inline is-busy';
const intent = parseIntent();
// Step 1: Load profiles.
// '*sRGB' is a built-in virtual profile — no file needed.
// The CMYK profile is loaded from a .icc file via HTTP.
const srgb = new Profile();
srgb.loadVirtualProfile('*sRGB');
const cmyk = new Profile();
await cmyk.loadPromise(profileSel.value);
// Step 2: Create a Transform with LUT mode.
// buildLut: true — pre-build a 3D CLUT for fast pixel transforms
// dataFormat: 'int8' — input/output are Uint8ClampedArray (0–255)
const t = new Transform({ buildLut: true, dataFormat: 'int8' });
// Step 3: Build the multi-stage pipeline.
// The chain alternates [profile, intent, profile, intent, profile].
// Here: sRGB → (perceptual) → CMYK → (relative) → sRGB
// This is the expensive step (~50–100 ms). It only runs once per
// profile/intent change — not per frame.
const t0 = performance.now();
t.createMultiStage([srgb, intent, cmyk, eIntent.relative, srgb]);
const lutMs = performance.now() - t0;
transform = t;
// Show which kernel the engine auto-selected
const kernel = t.lutMode || 'unknown';
document.getElementById('sysKernel').textContent = kernel;
document.getElementById('sysKernel').className = 'val' +
(kernel.includes('simd') ? ' is-ok' : kernel.includes('wasm') ? '' : ' is-warn');
statusText.textContent = `LUT built in ${lutMs.toFixed(0)} ms (${kernel}) \u2014 ready`;
statusText.className = 'timing-inline';
return lutMs;
}
// ── Per-frame processing ──────────────────────────────────────────────
function ensureBuffers() {
const vw = video.videoWidth;
const vh = video.videoHeight;
if (vw === w && vh === h) return;
w = vw; h = vh;
pixels = w * h;
canvas.width = w;
canvas.height = h;
// Off-screen canvas to extract RGBA pixels from the video element
srcCanvas = document.createElement('canvas');
srcCanvas.width = w;
srcCanvas.height = h;
srcCtx = srcCanvas.getContext('2d', { willReadFrequently: true });
// OUTPUT BUFFER REUSE
// Allocate one Uint8ClampedArray for the entire session at this
// resolution. We pass this into transformArrayViaLUT() on every
// frame so the engine writes directly into it instead of creating
// a new array each time. Benefits:
// - Zero per-frame JS allocation (no new Uint8ClampedArray)
// - Reduced GC pressure (critical for smooth 60fps)
// - ~3–5% throughput gain (measured in bench/)
// The buffer is only re-created if the video resolution changes.
reusableOut = new Uint8ClampedArray(pixels * 4);
}
function processFrame() {
ensureBuffers();
// Grab the current video frame as raw RGBA pixel data
srcCtx.drawImage(video, 0, 0, w, h);
const src = srcCtx.getImageData(0, 0, w, h).data;
// THE HOT PATH — this is the only jsColorEngine call per frame.
//
// transformArrayViaLUT(inputArray, inputHasAlpha, outputHasAlpha,
// preserveAlpha, pixelCount, outputArray)
//
// inputArray : Uint8ClampedArray of RGBA pixels from the video
// inputHasAlpha : true — the canvas gives us RGBA (4 bytes/pixel)
// outputHasAlpha: true — we need RGBA output for putImageData
// preserveAlpha : true — copy alpha through unchanged
// pixelCount : number of pixels (width × height)
// outputArray : reusableOut — our pre-allocated buffer
//
// Internally this runs tetrahedral interpolation through the
// pre-built 3D CLUT using the best available kernel (WASM SIMD
// → WASM scalar → pure JS, auto-selected at create() time).
//
// WASM memory is managed automatically: wasmMaxMemory (default
// 128 MB) compacts after oversized images; wasmShrinkRatio keeps
// memory proportional to the workload. For fixed-size video frames
// neither guard ever fires — zero overhead.
const t0 = performance.now();
transform.transformArrayViaLUT(src, true, true, true, pixels, reusableOut);
const ms = performance.now() - t0;
// Write the transformed pixels to the visible canvas.
// reusableOut already contains the RGBA result — we wrap it in
// an ImageData (zero-copy view) and blit it.
ctx.putImageData(new ImageData(reusableOut, w, h), 0, 0);
return ms;
}
// ── Stats display ─────────────────────────────────────────────────────
function updateStats(ms) {
frameCount++;
transformMsAccum += ms;
const now = performance.now();
if (now - lastFpsUpdate >= 1000) {
const elapsed = (now - lastFpsUpdate) / 1000;
const fps = frameCount / elapsed;
const avg = transformMsAccum / frameCount;
const mpx = (pixels / (avg / 1000)) / 1e6;
const target = lockFpsCb.checked ? 30 : 60;
fpsBadge.textContent = `${fps.toFixed(0)} fps`;
fpsBadge.className = 'fps-badge' +
(fps >= target * 0.85 ? ' is-good' : fps >= target * 0.5 ? ' is-ok' : ' is-bad');
proofTiming.innerHTML =
`<span class="t-lbl">Transform</span> ${avg.toFixed(1)} ms/frame \u00b7 ${mpx.toFixed(1)} MPx/s<br>` +
`<span class="t-lbl">Resolution</span> ${w}\u00d7${h} (${(pixels / 1e6).toFixed(2)} MP)<br>` +
`<span class="t-lbl">Target</span> ${target} fps \u00b7 budget ${(1000 / target).toFixed(0)} ms/frame`;
frameCount = 0;
transformMsAccum = 0;
lastFpsUpdate = now;
}
}
// ── Frame loops ───────────────────────────────────────────────────────
//
// Two loop strategies depending on browser support:
//
// requestVideoFrameCallback (rVFC):
// Fires once per decoded video frame (~30 fps for a 30fps video).
// More efficient — only processes when a new frame is actually
// available. But can cause timing issues if the transform takes
// too long relative to the callback budget.
//
// requestAnimationFrame (rAF):
// Fires at the display refresh rate (typically 60 Hz). May re-process
// the same video frame if the video is 30fps, but gives smoother
// UI updates and is universally supported.
function vfcLoop() {
if (!running) return;
const ms = processFrame();
updateStats(ms);
vfcId = video.requestVideoFrameCallback(vfcLoop);
}
function rafLoop() {
if (!running) return;
const ms = processFrame();
updateStats(ms);
rafId = requestAnimationFrame(rafLoop);
}
function startLoop() {
lastFpsUpdate = performance.now();
frameCount = 0;
transformMsAccum = 0;
if (lockFpsCb.checked && hasVFC) {
vfcId = video.requestVideoFrameCallback(vfcLoop);
} else {
rafId = requestAnimationFrame(rafLoop);
}
}
function stopLoop() {
cancelAnimationFrame(rafId);
rafId = 0;
}
// ── Start / stop ──────────────────────────────────────────────────────
//
// On first play:
// 1. buildTransform() loads profiles and bakes the LUT (once)
// 2. video.play() starts the media element
// 3. startLoop() kicks off the per-frame processing loop
//
// Changing the CMYK profile or intent stops playback, rebuilds the
// LUT with the new settings, and restarts.
async function start() {
if (running) {
stop();
return;
}
try {
await buildTransform();
await video.play();
running = true;
playBtn.innerHTML = '◼ Stop';
startLoop();
} catch (e) {
console.error(e);
statusText.textContent = 'Error: ' + e.message;
}
}
function stop() {
running = false;
stopLoop();
video.pause();
playBtn.innerHTML = '▶ Play';
statusText.textContent = 'Stopped';
}
// ── Event wiring ──────────────────────────────────────────────────────
playBtn.addEventListener('click', start);
lockFpsCb.addEventListener('change', () => {
if (!running) return;
stopLoop();
startLoop();
});
async function rebuildIfRunning() {
if (!running) return;
stop();
await start();
}
profileSel.addEventListener('change', rebuildIfRunning);
intentSel.addEventListener('change', rebuildIfRunning);
videoSel.addEventListener('change', setVideoSource);
setVideoSource();
})();
</script>
</body>
</html>