UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

160 lines (138 loc) 4.14 kB
import { ALL_FORMATS, CanvasSink, CanvasSinkOptions, Input, InputVideoTrack, WrappedCanvas, } from "mediabunny" import {DecoderSource} from '../../driver/fns/schematic.js' import {loadDecoderSource} from '../../driver/utils/load-decoder-source.js' export class Filmstrip { #sink: CanvasSink #cache: Map<number, WrappedCanvas> = new Map() #activeRange: TimeRange = [0, 0] private constructor( private videoTrack: InputVideoTrack, private options: Required<FilmstripOptions> ) { this.#sink = new CanvasSink(videoTrack, options.canvasSinkOptions) } static async init(source: DecoderSource, options: FilmstripOptions) { const input = new Input({ formats: ALL_FORMATS, source: await loadDecoderSource(source) }) const videoTrack = await input.getPrimaryVideoTrack() if(videoTrack) return new Filmstrip( videoTrack, { frequency: options.frequency ?? 1, canvasSinkOptions: options.canvasSinkOptions ?? {width: 80, height: 50, fit: "fill"}, onChange: options.onChange }) else throw new Error("Source has no video track") } /** * Sets the frequency (granularity) of filmstrip thumbnails. * Changing this triggers a filmstrip refresh after any ongoing update finishes. * @param value - The new frequency in seconds. */ set frequency(value: number) { if(value !== this.options.frequency) { this.options.frequency = value this.#update() } } get frequency() { return this.options.frequency } #computeActiveRange([start, end]: TimeRange): TimeRange { const tileSize = end - start return [start - tileSize, end + tileSize] } async #generateTiles() { const [rangeStart, rangeEnd] = this.#activeRange const neededTimestamps = new Set<number>() // duration should be computed but with trim etc also const duration = await this.videoTrack.computeDuration() for ( let timestamp = rangeStart; timestamp <= rangeEnd; timestamp += this.options.frequency ) { // Clamp to valid time range if (timestamp >= 0 && timestamp <= duration) neededTimestamps.add(+timestamp.toFixed(3)) } const missingTimestamps = [...neededTimestamps] .filter(t => !this.#cache.has(t)) let i = 0 for await (const canvas of this.#sink.canvasesAtTimestamps(missingTimestamps)) { if(canvas) { const requestedTime = missingTimestamps[i++] this.#cache.set(requestedTime, canvas) } } // 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 the visible time range for the filmstrip. * * Triggers a thumbnails update, with extended margins to preload * thumbnails slightly outside the visible range. * @param visibleRange - The current timeline viewport as a [start, end] tuple in seconds. */ set range(visibleRange: TimeRange) { const newRange = this.#computeActiveRange(visibleRange) // Avoid redundant updates if ( this.#activeRange[0] === newRange[0] && this.#activeRange[1] === newRange[1] ) return this.#activeRange = newRange this.#update() } #updating: Promise<void> | null = null #shouldRunAgain = false async #update() { // Perform update immediately. If multiple updates are requested while updating, // only the latest one will run after the current finishes (skips intermediate ones). if(this.#updating) { this.#shouldRunAgain = true return } this.#updating = this.#generateTiles() 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: number) { return this.#cache.get(time) } } type TimeRange = [number, number] interface FilmstripOptions { frequency?: number canvasSinkOptions?: CanvasSinkOptions onChange: (tiles: { time: number canvas: WrappedCanvas }[]) => void }