frame-analyzer.js
1 import AppConstants from '../config/constants.js'; 2 import * as animFrameModule from '../utils/animation/animation-frame-manager.js'; 3 import { LRUCache } from '../utils/caching/lru-cache.js'; 4 import * as canvasOps from '../utils/canvas/canvas-operations.js'; 5 import * as colorConversion from '../utils/canvas/color-conversion.js'; 6 import { setRootCssVariables } from '../utils/dom/index.js'; 7 import { throttleWithVideoConditions } from '../utils/timing/throttle-debounce.js'; 8 import { createEventEmitter, createLocalEventSubscriptionManager } from '../utils/event/index.js'; 9 10 export class FrameAnalyzer { 11 constructor(videoElement, uiManager) { 12 if (!videoElement) { 13 throw new Error('FrameAnalyzer requires a video element'); 14 } 15 16 this.videoElement = videoElement; 17 this.uiManager = uiManager; 18 19 this.events = createEventEmitter(); 20 21 this.subscriptions = createLocalEventSubscriptionManager(); 22 this.lastAppliedHue = AppConstants.UI.DEFAULT_HUE; 23 24 this.lastAppliedRgb = { 25 r: 255, 26 g: 51, 27 b: 153, 28 }; 29 30 this.lastVideoTime = 0; 31 this.canvas = null; 32 this.ctx = null; 33 this.imageData = null; 34 this.pixels = null; 35 this.frameCache = null; 36 this.animationManager = null; 37 this.throttledAnalyzer = null; 38 this.onHueApplied = null; 39 this.onColorApplied = null; 40 this._playbackStartedHandler = null; 41 this._eventSubscriptions = []; 42 43 this._applyDefaultColors(); 44 45 this._initializeAsync(); 46 } 47 48 getEventEmitter() { 49 return this.events; 50 } 51 52 _applyDefaultColors() { 53 const { rgbToHsl, hslToRgb } = colorConversion; 54 const hsl = rgbToHsl(this.lastAppliedRgb.r, this.lastAppliedRgb.g, this.lastAppliedRgb.b); 55 56 const brightRgb = hslToRgb(hsl.h, hsl.s, 60); 57 58 this.lastAppliedRgb = { 59 r: brightRgb.r, 60 g: brightRgb.g, 61 b: brightRgb.b, 62 }; 63 64 const rgbString = `${this.lastAppliedRgb.r}, ${this.lastAppliedRgb.g}, ${this.lastAppliedRgb.b}`; 65 66 setRootCssVariables({ 67 hue: `${this.lastAppliedHue}`, 68 'primary-rgb': rgbString, 69 }); 70 } 71 72 async _initializeAsync() { 73 const { createOffscreenCanvas } = canvasOps; 74 try { 75 const { canvas, ctx } = createOffscreenCanvas( 76 AppConstants.UI.FRAME_ANALYSIS_WIDTH, 77 AppConstants.UI.FRAME_ANALYSIS_HEIGHT, 78 true, 79 false, 80 ); 81 82 this.canvas = canvas; 83 this.ctx = ctx; 84 85 this.imageData = this.ctx.createImageData(this.canvas.width, this.canvas.height); 86 this.pixels = this.imageData.data; 87 88 this.frameCache = new LRUCache(10); 89 90 const { AnimationFrameManager } = animFrameModule; 91 this.animationManager = new AnimationFrameManager(this.runAnalysisLoop.bind(this)); 92 93 this.throttledAnalyzer = throttleWithVideoConditions( 94 this.analyzeDominantColor.bind(this), 95 AppConstants.UI.FRAME_ANALYSIS_INTERVAL_MS, 96 { 97 minTimeDelta: AppConstants.UI.FRAME_ANALYSIS_MIN_TIME_DELTA, 98 videoElement: this.videoElement, 99 }, 100 ); 101 } catch (error) { 102 console.error('Failed to initialize FrameAnalyzer:', error); 103 } 104 } 105 106 async startAnalysis() { 107 if (this.animationManager?.isActive) { 108 console.info('Frame analysis already running, skipping start request'); 109 return; 110 } 111 112 if (!this.animationManager) { 113 console.info('Waiting for animation manager to initialize...'); 114 await new Promise(resolve => { 115 const checkInit = () => { 116 if (this.animationManager) { 117 resolve(); 118 } else { 119 setTimeout(checkInit, 50); 120 } 121 }; 122 checkInit(); 123 }); 124 } 125 126 console.info('Starting frame analysis loop'); 127 this.animationManager.start(); 128 129 try { 130 const colorData = await this.analyzeDominantColor(); 131 if (colorData) { 132 this.applyColorToUI(colorData); 133 } 134 } catch (error) { 135 console.warn('Initial color analysis failed:', error); 136 } 137 } 138 139 stopAnalysis() { 140 if (this.animationManager) { 141 this.animationManager.stop(); 142 } 143 } 144 145 async runAnalysisLoop(timestamp) { 146 try { 147 const colorDataPromise = this.throttledAnalyzer(); 148 149 const colorData = await colorDataPromise; 150 if (colorData !== null) { 151 this.applyColorToUI(colorData); 152 } 153 } catch (error) { 154 console.warn('Error in analysis loop:', error); 155 } 156 this.lastVideoTime = this.videoElement.currentTime; 157 } 158 159 analyzeDominantColor() { 160 if (!this.frameCache || !this.canvas || !this.ctx) { 161 return Promise.resolve(null); 162 } 163 164 try { 165 const currentTime = Math.floor(this.videoElement.currentTime * 10) / 10; 166 167 const cachedColor = this.frameCache.get(currentTime); 168 if (cachedColor !== undefined) { 169 return Promise.resolve(cachedColor); 170 } 171 172 return (async () => { 173 try { 174 const { calculateAverageColor, captureVideoFrame, getPixelData, samplePixels } = 175 canvasOps; 176 const { isColorUsable, rgbToHue, smoothHueTransition } = colorConversion; 177 178 const captured = captureVideoFrame( 179 this.videoElement, 180 this.ctx, 181 this.canvas.width, 182 this.canvas.height, 183 ); 184 185 if (!captured) { 186 this.frameCache.set(currentTime, null); 187 return null; 188 } 189 190 const imageData = getPixelData(this.ctx, this.canvas.width, this.canvas.height); 191 if (!imageData) { 192 this.frameCache.set(currentTime, null); 193 return null; 194 } 195 196 this.imageData = imageData; 197 this.pixels = imageData.data; 198 199 const sampleData = samplePixels( 200 this.pixels, 201 this.pixels.length / 4, 202 MAX_SAMPLES, 203 (r, g, b) => isColorUsable(r, g, b, MIN_BRIGHTNESS, MAX_BRIGHTNESS), 204 ); 205 206 if (sampleData.sampleCount < MIN_SAMPLE_COUNT) { 207 this.frameCache.set(currentTime, null); 208 return null; 209 } 210 211 const avgColor = calculateAverageColor(sampleData); 212 if (!avgColor) { 213 this.frameCache.set(currentTime, null); 214 return null; 215 } 216 217 const rawHue = rgbToHue( 218 avgColor.avgR, 219 avgColor.avgG, 220 avgColor.avgB, 221 AppConstants.UI.DEFAULT_HUE, 222 ); 223 224 const smoothedHue = smoothHueTransition( 225 rawHue, 226 this.lastAppliedHue, 227 AppConstants.UI.FRAME_ANALYSIS_SMOOTHING, 228 ); 229 230 const colorData = { 231 hue: smoothedHue, 232 rgb: { 233 r: Math.round(avgColor.avgR), 234 g: Math.round(avgColor.avgG), 235 b: Math.round(avgColor.avgB), 236 }, 237 }; 238 239 this.frameCache.set(currentTime, colorData); 240 241 return colorData; 242 } catch (error) { 243 console.warn('Frame analysis error:', error); 244 return null; 245 } 246 })(); 247 } catch (error) { 248 console.warn('Frame analysis error:', { 249 canvasSize: this.canvas 250 ? { h: this.canvas.height, w: this.canvas.width } 251 : 'canvas not initialized', 252 error, 253 videoTime: this.videoElement.currentTime, 254 }); 255 return Promise.resolve(null); 256 } 257 } 258 259 applyColorToUI(colorData) { 260 if (!colorData || !colorData.hue) { 261 return; 262 } 263 264 const { hue, rgb } = colorData; 265 266 let changed = false; 267 268 if (Math.abs(hue - this.lastAppliedHue) >= AppConstants.UI.FRAME_ANALYSIS_MIN_HUE_CHANGE) { 269 this.lastAppliedHue = hue; 270 changed = true; 271 272 if (typeof this.onHueApplied === 'function') { 273 this.onHueApplied(hue); 274 } 275 } 276 277 if (rgb && rgb.r !== undefined && rgb.g !== undefined && rgb.b !== undefined) { 278 const { rgbToHsl, hslToRgb } = colorConversion; 279 const hsl = rgbToHsl(rgb.r, rgb.g, rgb.b); 280 281 const brightRgb = hslToRgb(hsl.h, hsl.s, 60); 282 283 this.lastAppliedRgb = { 284 r: brightRgb.r, 285 g: brightRgb.g, 286 b: brightRgb.b, 287 }; 288 289 changed = true; 290 291 if (typeof this.onColorApplied === 'function') { 292 this.onColorApplied(this.lastAppliedRgb); 293 } 294 } else { 295 return; 296 } 297 298 if (changed) { 299 const rgbString = `${this.lastAppliedRgb.r}, ${this.lastAppliedRgb.g}, ${this.lastAppliedRgb.b}`; 300 301 setRootCssVariables({ 302 hue: `${this.lastAppliedHue}`, 303 'primary-rgb': rgbString, 304 }); 305 306 try { 307 const progressLine = document.querySelector('.progress-line'); 308 if (progressLine) { 309 progressLine.style.background = `rgb(${rgbString})`; 310 progressLine.style.boxShadow = `0 0 6px rgba(${rgbString}, 0.6), 0 0 10px rgba(${rgbString}, 0.4)`; 311 } 312 } catch (error) { 313 console.warn('Failed to directly update progress line style:', error); 314 } 315 } 316 } 317 318 subscribeToVideoEvents(videoControllerEvents) { 319 if (!videoControllerEvents) { 320 console.warn( 321 'Video controller events not available, frame analyzer will not respond to playback events', 322 ); 323 return; 324 } 325 326 import('../services/video/video-events.js').then(({ VideoEvents }) => { 327 this._playbackStartedHandler = () => { 328 console.info('Frame analyzer detected playback started, starting analysis'); 329 this.startAnalysis(); 330 331 this.analyzeDominantColor() 332 .then(colorData => { 333 if (colorData) { 334 console.info('Applying initial color to UI from playback started event'); 335 this.applyColorToUI(colorData); 336 337 if (this.uiManager && typeof this.uiManager.updateProgressBar === 'function') { 338 this.uiManager.updateProgressBar(); 339 } 340 } 341 }) 342 .catch(error => { 343 console.warn('Failed to perform initial color analysis on playback start:', error); 344 }); 345 }; 346 this.subscriptions.subscribe( 347 videoControllerEvents, 348 VideoEvents.PLAYBACK_STARTED, 349 this._playbackStartedHandler, 350 this, 351 ); 352 }); 353 } 354 355 cleanup() { 356 if (this.animationManager) { 357 this.animationManager.cleanup(); 358 } 359 360 if (this.throttledAnalyzer && typeof this.throttledAnalyzer.cancel === 'function') { 361 this.throttledAnalyzer.cancel(); 362 } 363 364 this.subscriptions.unsubscribeAll(); 365 366 this.events.clearAllEvents(); 367 368 if (this.canvas?.parentNode) { 369 this.canvas.remove(); 370 } 371 372 this.ctx = null; 373 this.canvas = null; 374 this.imageData = null; 375 this.pixels = null; 376 377 if (this.frameCache) { 378 this.frameCache.clear(); 379 } 380 381 this.frameCache = null; 382 this.throttledAnalyzer = null; 383 this._playbackStartedHandler = null; 384 385 this.onHueApplied = null; 386 this.onColorApplied = null; 387 this.lastAppliedRgb = null; 388 } 389 } 390 391 export const MIN_BRIGHTNESS = 20; 392 export const MAX_BRIGHTNESS = 235; 393 export const MIN_SAMPLE_COUNT = 10; 394 export const MAX_SAMPLES = AppConstants.UI.FRAME_ANALYSIS_MAX_SAMPLES || 1000;