@omnimedia/omnitool
Version:
open source video processing tools
121 lines • 4.7 kB
JavaScript
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