@@ -300,7 +300,39 @@ <h2>Decode an image</h2>
300300const video = document . getElementById ( 'cam-video' ) ;
301301const overlay = document . getElementById ( 'cam-overlay' ) ;
302302const scratch = document . createElement ( 'canvas' ) ;
303- let stream , running = false , lastDecode = 0 , lastDecoded = [ ] ;
303+ let stream , running = false , lastSent = 0 , lastDecoded = [ ] , workerBusy = false ;
304+
305+ // Decoding runs in a background worker (its own wasm instance) so the heavy sampler
306+ // can never block the UI thread — the live overlay stays smooth even when a scan is
307+ // slow. The main thread only sends a frame when the worker is free.
308+ const WASM_URL = new URL ( 'anyd.wasm' , location . href ) . href ;
309+ const workerSrc = `
310+ let w; const mem = () => new Uint8Array(w.memory.buffer);
311+ const ready = fetch(${ JSON . stringify ( WASM_URL ) } ).then(r => WebAssembly.instantiateStreaming(r, {})).then(x => { w = x.instance.exports; });
312+ function wr(b){ const p = w.alloc(b.length); mem().set(b, p); return p; }
313+ function take(pk){ const ptr = Number(pk >> 32n), len = Number(pk & 0xffffffffn); const o = mem().slice(ptr, ptr + len); w.dealloc(ptr, len); return o; }
314+ function decLuma(cw, ch, luma){ const p = wr(luma); const pk = w.decode(cw, ch, p); w.dealloc(p, luma.length); return JSON.parse(new TextDecoder().decode(take(pk)) || '[]'); }
315+ self.onmessage = async (e) => {
316+ await ready;
317+ const { width, height, luma, crops } = e.data;
318+ const seen = new Set(), found = [];
319+ const add = (r, box) => { const k = r.symbology + '|' + r.text; if (!seen.has(k)) { seen.add(k); found.push({ symbology: r.symbology, text: r.text, box }); } };
320+ for (const b of crops.slice(0, 6)) {
321+ const x0 = Math.max(0, b.x0 | 0), y0 = Math.max(0, b.y0 | 0), x1 = Math.min(width, Math.ceil(b.x1)), y1 = Math.min(height, Math.ceil(b.y1));
322+ const cw = x1 - x0, ch = y1 - y0; if (cw < 12 || ch < 12) continue;
323+ const crop = new Uint8Array(cw * ch);
324+ for (let y = 0; y < ch; y++) { const s = (y0 + y) * width + x0; crop.set(luma.subarray(s, s + cw), y * cw); }
325+ for (const r of decLuma(cw, ch, crop)) add(r, b);
326+ }
327+ self.postMessage(found);
328+ };
329+ ` ;
330+ const decodeWorker = new Worker ( URL . createObjectURL ( new Blob ( [ workerSrc ] , { type : 'text/javascript' } ) ) ) ;
331+ decodeWorker . onmessage = ( e ) => {
332+ lastDecoded = e . data ; workerBusy = false ;
333+ document . getElementById ( 'cam-result' ) . textContent =
334+ lastDecoded . length ? lastDecoded . map ( r => `${ r . symbology } : ${ r . text } ` ) . join ( '\n' ) : '' ;
335+ } ;
304336
305337function bbox ( corners ) {
306338 const xs = corners . map ( p => p [ 0 ] ) , ys = corners . map ( p => p [ 1 ] ) ;
@@ -441,7 +473,7 @@ <h2>Decode an image</h2>
441473 document . getElementById ( 'cam-btn' ) . disabled = true ;
442474 document . getElementById ( 'cam-stop' ) . disabled = false ;
443475 document . getElementById ( 'cam-capture' ) . disabled = false ;
444- running = true ; requestAnimationFrame ( loop ) ;
476+ running = true ; workerBusy = false ; lastSent = 0 ; requestAnimationFrame ( loop ) ;
445477} ;
446478document . getElementById ( 'cam-stop' ) . onclick = ( ) => {
447479 running = false ; stream && stream . getTracks ( ) . forEach ( t => t . stop ( ) ) ;
@@ -481,55 +513,48 @@ <h2>Decode an image</h2>
481513 `captured frame ${ pw } ×${ ph } — ${ cands . length } located, ${ decoded . length } decoded (saved PNG + JSON)` ;
482514} ;
483515
516+ let liveCands = [ ] , liveScale = [ 1 , 1 ] , lastLocate = 0 , lastMs = 0 ;
484517function loop ( ts ) {
485- if ( ! running ) return ;
518+ if ( ! running ) { return ; }
519+ requestAnimationFrame ( loop ) ;
486520 const vw = video . videoWidth , vh = video . videoHeight ;
487- if ( vw ) {
488- // Process at (capped) native resolution — small codes need the pixels: a QR that
489- // is ~30px in a 480px frame is invisible to both the locator and the sampler.
490- // 1080p passes through at full res (locate is ~1.8 ms there).
491- const pw = Math . min ( vw , 1920 ) , ph = Math . round ( pw * vh / vw ) ;
521+ if ( ! vw ) return ;
522+ const pw = Math . min ( vw , 1920 ) , ph = Math . round ( pw * vh / vw ) ;
523+
524+ // Grab + locate at ~30 fps (getImageData at 1080p is the heavy main-thread step);
525+ // the HUD still renders every frame below, so the overlay stays smooth.
526+ if ( ts - lastLocate > 33 ) {
527+ lastLocate = ts ;
492528 scratch . width = pw ; scratch . height = ph ;
493529 const sctx = scratch . getContext ( '2d' , { willReadFrequently : true } ) ;
494530 sctx . drawImage ( video , 0 , 0 , pw , ph ) ;
495531 const img = sctx . getImageData ( 0 , 0 , pw , ph ) ;
496532 const frameArea = pw * ph ;
497-
498533 const t0 = performance . now ( ) ;
499534 // Drop background-sized boxes: a code is a bounded object, not half the scene.
500- const cands = locate ( img ) . filter ( c => {
535+ liveCands = locate ( img ) . filter ( c => {
501536 const b = bbox ( c . corners ) ;
502537 return ( b . x1 - b . x0 ) * ( b . y1 - b . y0 ) < 0.5 * frameArea ;
503538 } ) ;
504- const ms = performance . now ( ) - t0 ;
505-
506- // Periodic decode: the two-stage pipeline — crop each located region and decode it
507- // at full resolution (a small code fills its own crop), plus a whole-frame attempt.
508- if ( ts - lastDecode > 300 ) {
509- lastDecode = ts ;
510- const seen = new Set ( ) , found = [ ] ;
511- const add = ( r , box ) => { const k = r . symbology + '|' + r . text ; if ( ! seen . has ( k ) ) { seen . add ( k ) ; found . push ( { ...r , box } ) ; } } ;
512- decode ( img ) . forEach ( r => add ( r , null ) ) ;
513- for ( const c of cands ) {
514- if ( found . length >= 8 ) break ;
515- const b = bbox ( c . corners ) ;
516- const crop = cropRGBA ( img , b . x0 - 16 , b . y0 - 16 , b . x1 + 16 , b . y1 + 16 ) ;
517- if ( crop ) decode ( crop ) . forEach ( r => add ( r , b ) ) ;
518- }
519- lastDecoded = found ;
520- document . getElementById ( 'cam-result' ) . textContent =
521- found . length ? found . map ( r => `${ r . symbology } : ${ r . text } ` ) . join ( '\n' ) : '' ;
539+ lastMs = performance . now ( ) - t0 ;
540+ liveScale = [ pw , ph ] ;
541+ // Decode off-thread: send a frame only when the worker is free, so a slow scan
542+ // throttles itself and can never stall the UI.
543+ if ( ! workerBusy && ts - lastSent > 120 ) {
544+ lastSent = ts ; workerBusy = true ;
545+ const { luma } = lumaFrom ( img ) ;
546+ const crops = liveCands . map ( c => { const b = bbox ( c . corners ) ; return { x0 : b . x0 - 16 , y0 : b . y0 - 16 , x1 : b . x1 + 16 , y1 : b . y1 + 16 } ; } ) ;
547+ decodeWorker . postMessage ( { width : pw , height : ph , luma, crops } , [ luma . buffer ] ) ;
522548 }
523-
524- overlay . width = video . clientWidth ; overlay . height = video . clientHeight ;
525- const octx = overlay . getContext ( '2d' ) ;
526- octx . clearRect ( 0 , 0 , overlay . width , overlay . height ) ;
527- renderHUD ( octx , overlay . width , overlay . height , cands , lastDecoded , overlay . width / pw , overlay . height / ph , ts ) ;
528-
529- document . getElementById ( 'cam-stat' ) . innerHTML =
530- `◈ tracking <b>${ cands . length } </b> · locked <b>${ lastDecoded . length } </b> · scan <b>${ ms . toFixed ( 1 ) } ms</b>` ;
531549 }
532- requestAnimationFrame ( loop ) ;
550+
551+ // HUD every frame (cheap: canvas only) for a smooth reticle/scan animation.
552+ overlay . width = video . clientWidth ; overlay . height = video . clientHeight ;
553+ const octx = overlay . getContext ( '2d' ) ;
554+ octx . clearRect ( 0 , 0 , overlay . width , overlay . height ) ;
555+ renderHUD ( octx , overlay . width , overlay . height , liveCands , lastDecoded , overlay . width / liveScale [ 0 ] , overlay . height / liveScale [ 1 ] , ts ) ;
556+ document . getElementById ( 'cam-stat' ) . innerHTML =
557+ `◈ tracking <b>${ liveCands . length } </b> · locked <b>${ lastDecoded . length } </b> · scan <b>${ lastMs . toFixed ( 1 ) } ms</b>` ;
533558}
534559
535560// ---- file decode ----
0 commit comments