import { lerp } from '../../lib/util/math.js';
import { extend } from '../../lib/util/object.js';
import Looper from '../../lib/util/Looper.js';
import DomSmith from '../../lib/dom/DomSmith.js';
/**
* The AnalyserVideo component analyzes video frames to extract pixel-based data for visual processing and visualizations.
* It captures thumbnails of the current video frame, reduces them to a defined grid, and smooths color values over time.
* The component supports real-time analysis, debug output to canvas elements, and modular configuration.
* It is typically used to drive reactive UI elements or visualizers based on video content.
* @exports module:src/visualizer/AnalyserVideo
* @requires lib/util/math
* @requires lib/util/object
* @requires lib/util/Looper
* @requires lib/dom/DomSmith
* @author Frank Kudermann - alphanull
* @version 1.0.0
* @license MIT
*/
export default class AnalyserVideo {
/**
* Holds the instance configuration for this component.
* @type {Object}
* @property {number} [gridSize=3] The number of grid cells per row/column.
* @property {number} [gridScale=3] The scaling factor applied to the grid cells.
* @property {number} [lerp=0.6] The interpolation factor used for smoothing pixel values.
* @property {number} [dim=1] The dimming multiplier applied to pixel values.
* @property {boolean} [debug=false] Enables debug mode with visual outputs.
* @property {number} [analyseTimer=250] Delay (in ms) between iterations of the analysis loop.
*/
#config;
/**
* Reference to the main player instance.
* @type {module:src/core/Player}
*/
#player;
/**
* Holds tokens of subscriptions (for this subclass only).
* @type {number[]}
*/
#subscriptions;
/**
* Secret key only known to the player instance and initialized components.
* Used to be able to restrict access to API methods in conjunction with secure mode.
* @type {symbol}
*/
#apiKey;
/**
* Flag indicating whether the analyser is enabled.
* @type {boolean}
*/
#enabled = true;
/**
* Canvas element for processing video frames.
* @type {HTMLCanvasElement|OffscreenCanvas}
*/
#canvas;
/**
* 2D Rendering Context of the canvas.
* @type {CanvasRenderingContext2D}
*/
#ctx;
/**
* Debugging DOM elements created by DomSmith.
* @type {module:lib/dom/DomSmith}
*/
#debug;
/**
* Canvas element for input debugging.
* @type {HTMLCanvasElement}
*/
#debugInput;
/**
* 2D Rendering Context for the input debugging canvas.
* @type {CanvasRenderingContext2D}
*/
#debugInputCtx;
/**
* Canvas element for output debugging.
* @type {HTMLCanvasElement}
*/
#debugOutput;
/**
* 2D Rendering Context for the output debugging canvas.
* @type {CanvasRenderingContext2D}
*/
#debugOutputCtx;
/**
* Current pixel data after processing.
* @type {ImageData|null}
*/
#pixelData;
/**
* Previous pixel data for comparison and smoothing.
* @type {Uint8ClampedArray}
*/
#oldPixelData;
/**
* Analyse Loop Instance, used for updating the viedeo data.
* @type {module:lib/util/Looper}
*/
#analyseLoop;
/**
* Id for the refresh delay.
* @type {number|undefined}
*/
#refreshDelayId;
/**
* Creates an instance of the AnalyserVideo component.
* @param {module:src/core/Player} player Reference to the VisionPlayer instance.
* @param {Object} config Configuration object for the AnalyserAudio component. This is passed from the subclass.
* @param {symbol} apiKey Token for extended access to the player API.
*/
constructor(player, config = {}, apiKey) {
const configExt = extend({
gridSize: 3,
gridScale: 3,
lerp: 0.6,
dim: 1,
debug: false,
analyseTimer: 250
}, config);
this.#player = player;
this.#player.subscribe('data/ready', this.#onDataReady);
this.#config = configExt;
this.#apiKey = apiKey;
this.#analyseLoop = new Looper(() => this.analyseLoop(), this.#config.analyseTimer);
if (this.#config.debug) {
this.#debug = new DomSmith({
_ref: 'wrapper',
style: 'position: fixed; top: 10%; left: 5%; z-index: 15; pointer-events: none;',
_nodes: [{
_ref: 'input',
_tag: 'canvas',
style: 'position: absolute; top: 0; left: 0; width: 300px; height: 300px;'
},
{
_ref: 'output',
_tag: 'canvas',
style: 'position: absolute; top: 0; left: 350px; width: 300px; height: 300px;'
}
]
}, this.#player.dom.getElement(this.#apiKey));
this.#debugInput = this.#debug.input;
this.#debugInputCtx = this.#debugInput.getContext('2d', { alpha: false });
this.#debugInputCtx.imageSmoothingEnabled = false;
this.#debugOutput = this.#debug.output;
this.#debugOutputCtx = this.#debugOutput.getContext('2d', { alpha: false });
this.#debugOutputCtx.imageSmoothingEnabled = false;
}
}
/**
* Handles "data/ready" events to activate video analysis based on media type. Deactivated when media type is audio.
* @param {module:src/core/Data~mediaItem} mediaItem Object containing media type info.
* @param {string} mediaItem.mediaType Type of the media ('video' or 'audio').
* @listens module:src/core/Data#data/ready
*/
#onDataReady = ({ mediaType }) => {
this.stopLoop();
this.#player.unsubscribe(this.#subscriptions);
if (mediaType !== 'video' || !this.#enabled) return;
// NOTE: This methods are explicitly bound with .bind(this) to guarantee the correct this context in subclasses,
// allowing overrides and super.method() calls to work reliably.
this.#subscriptions = [
this.#player.subscribe('media/play', this.startLoop.bind(this)),
this.#player.subscribe('media/pause', this.stopLoop.bind(this)),
this.#player.subscribe('media/canplay', this.#refreshBounce.bind(this))
];
const scale = this.#config.gridSize * this.#config.gridScale;
this.#canvas = typeof window.OffscreenCanvas === 'undefined' ? document.createElement('canvas') : new OffscreenCanvas(scale, scale);
this.#ctx = this.#canvas.getContext('2d', { alpha: false }); // intentionally *not* using `willReadFrequently` – performance is better without it
this.#ctx.imageSmoothingEnabled = false;
this.#oldPixelData = new Uint8ClampedArray(this.#config.gridSize * this.#config.gridSize * 4);
this.#oldPixelData.fill(0);
this.#pixelData = new Uint8ClampedArray(this.#config.gridSize * this.#config.gridSize * 4);
this.#pixelData.fill(0);
};
/**
* Starts the analysis loop by initiating the first iteration.
* @listens module:src/core/Media#media/play
*/
startLoop() {
if (this.#enabled) this.#analyseLoop.start();
}
/**
* Stops the ongoing analysis loop.
* @listens module:src/core/Media#media/pause
*/
stopLoop() {
this.#analyseLoop.stop();
}
/**
* Performs one iteration of the analysis loop.
* @returns {ImageData} Calculated Image Data.
*/
analyseLoop() {
const result = this.#getPixelData();
if (result === false) return false;
if (this.#config.debug) {
this.#debugInputCtx.drawImage(this.#canvas, 0, 0, this.#debugInput.width, this.#debugInput.height);
createImageBitmap(this.#pixelData).then(img => { this.#debugOutputCtx.drawImage(img, 0, 0, this.#debugInput.width, this.#debugInput.height); });
}
return this.#pixelData;
}
/**
* Retrieves and processes pixel data from the video stream.
* Draws a thumbnail for pixel analysis, divides it into grid cells, averages color values, and applies linear interpolation for smoothing.
* @param {number} [lerpVal] The interpolation value for smoothing.
* @returns {boolean} True if pixel data was successfully retrieved and processed, false otherwise.
*/
#getPixelData(lerpVal = 1 - this.#config.lerp) {
const videoStream = this.#player.media.getElement(this.#apiKey),
{ gridSize, gridScale } = this.#config,
gridScaleItems = gridScale * gridScale,
gridScaleSize = gridSize * gridScale;
let pixels;
try {
// generate thumbnail for pixel analysis
this.#ctx.drawImage(videoStream, 0, 0, videoStream.videoWidth, videoStream.videoHeight, 0, 0, gridScaleSize, gridScaleSize);
pixels = this.#ctx.getImageData(0, 0, gridScaleSize, gridScaleSize).data;
} catch (e) {
console.error('[VideoAnalyser] Failed to get Image Data', { cause: e }); // eslint-disable-line no-console
this.stopLoop();
return false;
}
// loop through image data and average color values
const avgData = new ImageData(gridSize, gridSize);
// outer loop based on target grid size
for (let row = 0; row < gridSize; row += 1) {
// inner loop
for (let col = 0; col < gridSize; col += 1) {
let r = 0,
b = 0,
g = 0;
// now average pixels
for (let i = 0; i < gridScale; i += 1) {
const rowOffset = (row * gridScale + i) * gridScaleSize;
for (let j = 0; j < gridScale; j += 1) {
const pos = (rowOffset + col * gridScale + j) * 4;
r += pixels[pos + 0];
g += pixels[pos + 1];
b += pixels[pos + 2];
}
}
// copy averaged and lerped pixel values to image data
const targetIdx = (col + row * gridSize) * 4,
dimmed = gridScaleItems * this.#config.dim;
avgData.data[targetIdx + 0] = Math.floor(lerp(this.#oldPixelData[targetIdx + 0], r / dimmed, lerpVal));
avgData.data[targetIdx + 1] = Math.floor(lerp(this.#oldPixelData[targetIdx + 1], g / dimmed, lerpVal));
avgData.data[targetIdx + 2] = Math.floor(lerp(this.#oldPixelData[targetIdx + 2], b / dimmed, lerpVal));
avgData.data[targetIdx + 3] = 255; // always opaque
}
}
this.#oldPixelData = new Uint8ClampedArray(avgData.data);
this.#pixelData = avgData;
return true;
}
/**
* Refreshes the analysis loop after a short delay.
*/
#refreshBounce() {
clearTimeout(this.#refreshDelayId);
this.#refreshDelayId = setTimeout(this.refresh.bind(this), 50);
}
/**
* Refreshes the pixel data immediately, resetting the interpolation.
* @returns {ImageData} Calculated Image Data.
* @listens module:src/core/Media#media/seeked
* @listens module:src/core/Media#media/canplay
*/
refresh() {
this.#getPixelData(1);
if (this.#config.debug) {
this.#debugInputCtx.drawImage(this.#canvas, 0, 0, this.#debugInput.width, this.#debugInput.height);
createImageBitmap(this.#pixelData).then(img => { this.#debugOutputCtx.drawImage(img, 0, 0, this.#debugInput.width, this.#debugInput.height); });
}
return this.#pixelData;
}
/**
* Enables the analyser and restarts the analysis loop if playback is ongoing.
*/
enable() {
this.#enabled = true;
if (this.#player.getState('media.paused')) return;
this.startLoop();
}
/**
* Disables the analyser.
*/
disable() {
this.#enabled = false;
this.stopLoop();
}
/**
* Removes all events, subscriptions and DOM nodes created by this component.
*/
destroy() {
if (this.#config.debug) this.#debug.destroy();
clearTimeout(this.#refreshDelayId);
this.#analyseLoop.destroy();
this.#player.unsubscribe(this.#subscriptions);
this.#player.unsubscribe('data/ready', this.#onDataReady);
this.#player = this.#canvas = this.#apiKey = this.#analyseLoop = null;
}
}