@omnimedia/omnitool
Version:
open source video processing tools
168 lines (143 loc) • 4.59 kB
text/typescript
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
}