/ app / ui / frame-analyzer.js
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;