UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

121 lines 4.7 kB
import { ALL_FORMATS, CanvasSink, Input, } from "mediabunny"; import { loadDecoderSource } from '../../driver/utils/load-decoder-source.js'; export class Filmstrip { duration; options; #sink; #activeRange = [0, 0]; #cache = new Map(); constructor(videoTrack, duration, options) { this.duration = duration; this.options = options; this.#sink = new CanvasSink(videoTrack, options.canvasSinkOptions); } static async init(source, options) { const input = new Input({ formats: ALL_FORMATS, source: await loadDecoderSource(source) }); const videoTrack = await input.getPrimaryVideoTrack(); if (videoTrack) { const duration = await videoTrack.computeDuration(); return new Filmstrip(videoTrack, duration, { frequency: options.frequency ?? 1, canvasSinkOptions: options.canvasSinkOptions ?? { width: 80, height: 50, fit: "fill" }, onChange: options.onChange, onPlaceholders: options.onPlaceholders }); } else throw new Error("Source has no video track"); } #computeActiveRange([start, end], margin = 1) { const tileSize = end - start; return [start - tileSize * margin, end + tileSize * margin]; } get frequency() { return this.options.frequency; } #timestamps() { const [rangeStart, rangeEnd] = this.#activeRange; const neededTimestamps = new Set(); for (let timestamp = Math.max(0, rangeStart); timestamp <= rangeEnd; timestamp += this.options.frequency) { // Clamp to valid time range if (timestamp >= 0 && timestamp <= this.duration) neededTimestamps.add(timestamp); } return neededTimestamps; } #generatePlaceholders(neededTimestamps) { this.options.onPlaceholders?.([...neededTimestamps]); } async #generateTiles(neededTimestamps) { const missingTimestamps = [...neededTimestamps] .filter(t => !this.#cache.has(t)); let i = 0; for await (const canvas of this.#sink.canvasesAtTimestamps(missingTimestamps)) { const requestedTime = missingTimestamps[i++]; if (canvas) { this.#cache.set(requestedTime, canvas); } await new Promise(resolve => setTimeout(resolve)); } // Dispose canvases outside the new range for (const key of this.#cache.keys()) { if (!neededTimestamps.has(key)) { this.#cache.delete(key); } } const tiles = [...this.#cache.entries()] .map(([time, canvas]) => ({ time, canvas })); this.options.onChange(tiles); } /** * Updates filmstrip thumbnails when its range or frequency changes. * * @param update.range The current timeline viewport as a [start, end] tuple in seconds. * Thumbnails preload slightly outside the visible range. * @param update.frequency The granularity of filmstrip thumbnails in seconds. */ update({ range: visibleRange, frequency }) { const [visStart, visEnd] = visibleRange; const visibleSize = visEnd - visStart; const [actStart, actEnd] = this.#activeRange; const frequencyChanged = frequency !== this.options.frequency; this.options.frequency = frequency; // trigger when we're 1x visible width away from margin edges const leftTrigger = actStart + visibleSize; const rightTrigger = actEnd - visibleSize; const nearLeftEdge = visStart < leftTrigger; const nearRightEdge = visEnd > rightTrigger; if (!nearLeftEdge && !nearRightEdge && !frequencyChanged) return; this.#activeRange = this.#computeActiveRange(visibleRange, 2); this.#update(); } #updating = null; #shouldRunAgain = false; async #update() { const timestamps = this.#timestamps(); this.#generatePlaceholders(timestamps); if (this.#updating) { this.#shouldRunAgain = true; return; } this.#updating = this.#generateTiles(timestamps); await this.#updating; this.#updating = null; if (this.#shouldRunAgain) { this.#shouldRunAgain = false; await this.#update(); } } /** * Returns the cached thumbnail (if any) for a given timestamp. * @param time - The timestamp to retrieve the canvas for. */ getThumbnail(time) { return this.#cache.get(time); } } //# sourceMappingURL=filmstrip.js.map