UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

168 lines (143 loc) 4.59 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 #activeRange: TimeRange = [0, 0] #cache: Map<number, WrappedCanvas> = new Map() private constructor( videoTrack: InputVideoTrack, private duration: number, private options: 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) { 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]: TimeRange, margin = 1): TimeRange { 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<number>() 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: Set<number>) { this.options.onPlaceholders?.([...neededTimestamps]) } async #generateTiles(neededTimestamps: Set<number>) { 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<void>(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}: FilmstripUpdate) { 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: Promise<void> | null = 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: number) { return this.#cache.get(time) } } export type TimeRange = [start: number, end: number] export type FilmstripUpdate = {range: TimeRange, frequency: number} interface FilmstripOptions { frequency: number canvasSinkOptions?: CanvasSinkOptions onPlaceholders?: (timestamps: number[]) => void onChange: (tiles: { time: number canvas: WrappedCanvas }[]) => void }