diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index dffe91c..8d469e0 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -147,7 +147,10 @@ export class AutoTrackerNode extends Node { if ( region.w > 4 && region.h > 4 ) { // initFromVideo is async (loads WASM on first call); tracking begins // automatically once `ready` becomes true. - this.tracker.initFromVideo( videoElement, region ); + this.tracker.initFromVideo( videoElement, region ).catch( err => { + console.error( '[AutoTracker] Tracking initialisation failed:', err ); + this.hintText.visible = true; + } ); } else { this.hintText.visible = true; diff --git a/src/tracking/OpenCVTracker.ts b/src/tracking/OpenCVTracker.ts index 6bfd8dc..7510197 100644 --- a/src/tracking/OpenCVTracker.ts +++ b/src/tracking/OpenCVTracker.ts @@ -1,16 +1,39 @@ // cv type is 'any' — the OpenCV.js WASM API is dynamic and not fully typed. let cvPromise: Promise | null = null; +const CV_LOAD_TIMEOUT_MS = 30_000; + function loadCV(): Promise { if ( !cvPromise ) { - cvPromise = import( '@techstark/opencv-js' ).then( mod => { - const cv = ( mod as any ).default ?? mod; - // WASM may already be ready (e.g. in test environments) - if ( typeof cv.Mat === 'function' ) return cv; - return new Promise( resolve => { - cv.onRuntimeInitialized = () => resolve( cv ); + cvPromise = import( '@techstark/opencv-js' ).then( async mod => { + let cv = ( mod as any ).default ?? mod; + + // The default export may itself be a Promise (v4.12.0+). + if ( cv instanceof Promise ) { + cv = await cv; + } + + // WASM may already be ready (e.g. in test environments). + if ( typeof cv.Mat === 'function' ) { + return cv; + } + + // Wait for the Emscripten runtime to initialise, with a timeout so we + // never hang indefinitely. + return new Promise( ( resolve, reject ) => { + const timer = setTimeout( () => { + reject( new Error( 'OpenCV WASM initialisation timed out' ) ); + }, CV_LOAD_TIMEOUT_MS ); + + cv.onRuntimeInitialized = () => { + clearTimeout( timer ); + resolve( cv ); + }; } ); } ); + + // If loading fails, clear the cached promise so the next attempt can retry. + cvPromise.catch( () => { cvPromise = null; } ); } return cvPromise; } @@ -50,20 +73,23 @@ export class OpenCVTracker { const imageData = this.ctx.getImageData( 0, 0, this.offscreen.width, this.offscreen.height ); const frame = this.cv.matFromImageData( imageData ); const gray = new this.cv.Mat(); - this.cv.cvtColor( frame, gray, this.cv.COLOR_RGBA2GRAY ); - - if ( this.templateMat ) this.templateMat.delete(); - - const roi = new this.cv.Rect( - Math.round( Math.max( 0, region.x ) ), - Math.round( Math.max( 0, region.y ) ), - Math.round( Math.min( region.w, this.offscreen.width - region.x ) ), - Math.round( Math.min( region.h, this.offscreen.height - region.y ) ) - ); - this.templateMat = gray.roi( roi ).clone(); - - frame.delete(); - gray.delete(); + try { + this.cv.cvtColor( frame, gray, this.cv.COLOR_RGBA2GRAY ); + + if ( this.templateMat ) this.templateMat.delete(); + + const roi = new this.cv.Rect( + Math.round( Math.max( 0, region.x ) ), + Math.round( Math.max( 0, region.y ) ), + Math.round( Math.min( region.w, this.offscreen.width - region.x ) ), + Math.round( Math.min( region.h, this.offscreen.height - region.y ) ) + ); + this.templateMat = gray.roi( roi ).clone(); + } + finally { + frame.delete(); + gray.delete(); + } } /** @@ -77,20 +103,22 @@ export class OpenCVTracker { const imageData = this.ctx.getImageData( 0, 0, this.offscreen.width, this.offscreen.height ); const frame = this.cv.matFromImageData( imageData ); const gray = new this.cv.Mat(); - this.cv.cvtColor( frame, gray, this.cv.COLOR_RGBA2GRAY ); - const result = new this.cv.Mat(); - this.cv.matchTemplate( gray, this.templateMat, result, this.cv.TM_CCOEFF_NORMED ); - const { maxLoc } = this.cv.minMaxLoc( result ); - - frame.delete(); - gray.delete(); - result.delete(); - - return { - x: maxLoc.x + this.templateMat.cols / 2, - y: maxLoc.y + this.templateMat.rows / 2, - }; + try { + this.cv.cvtColor( frame, gray, this.cv.COLOR_RGBA2GRAY ); + this.cv.matchTemplate( gray, this.templateMat, result, this.cv.TM_CCOEFF_NORMED ); + const { maxLoc } = this.cv.minMaxLoc( result ); + + return { + x: maxLoc.x + this.templateMat.cols / 2, + y: maxLoc.y + this.templateMat.rows / 2, + }; + } + finally { + frame.delete(); + gray.delete(); + result.delete(); + } } public dispose(): void {