@omnimedia/omnitool
Version:
open source video processing tools
133 lines • 4.88 kB
JavaScript
import { renderTile } from "./parts/render.js";
import { collectPeakLevels } from "./parts/collect.js";
const MAX_TILE_WIDTH = 4096;
export class Waveform {
#tiles = new Map();
#activeRange = [0, 0];
#visibleRange = [0, 0];
#zoom;
#levels;
#onChange;
#updateQueued = false;
color;
duration;
tileSize;
tileHeight;
preloadMargin;
constructor(levels, duration, options) {
this.#levels = levels;
this.duration = duration;
this.tileSize = options.tileSize ?? 1;
this.#zoom = options.zoom ?? ((options.tileWidth ?? 256) / this.tileSize);
this.tileHeight = options.tileHeight ?? 96;
this.preloadMargin = options.preloadMargin ?? 2;
this.color = options.color ?? "rgb(3, 148, 129)";
this.#onChange = options.onChange;
}
static async init(driver, source, options = {}) {
const { duration, levels } = await collectPeakLevels(driver, source);
return new Waveform(levels, duration, options);
}
/** Waveform render density in pixels per second. */
set zoom(pixelsPerSecond) {
const next = Math.max(1, pixelsPerSecond);
if (next === this.#zoom)
return;
this.#zoom = next;
this.#tiles.clear();
this.#queueUpdate();
}
get zoom() {
return this.#zoom;
}
get range() {
return this.#visibleRange;
}
#computeActiveRange([start, end], margin = 1) {
const visibleSize = end - start;
return [
Math.max(0, start - visibleSize * margin),
Math.min(this.duration, end + visibleSize * margin),
];
}
set range(visibleRange) {
this.#visibleRange = visibleRange;
const [visibleStart, visibleEnd] = visibleRange;
const visibleSize = visibleEnd - visibleStart;
const [activeStart, activeEnd] = this.#activeRange;
const leftBuffered = activeStart > 0;
const rightBuffered = activeEnd < this.duration;
const leftSettled = !leftBuffered || visibleStart >= activeStart + visibleSize;
const rightSettled = !rightBuffered || visibleEnd <= activeEnd - visibleSize;
if (leftSettled && rightSettled)
return;
this.#activeRange = this.#computeActiveRange(visibleRange, this.preloadMargin);
this.#queueUpdate();
}
#queueUpdate() {
if (this.#updateQueued)
return;
this.#updateQueued = true;
queueMicrotask(() => {
this.#updateQueued = false;
this.#generateTiles();
});
}
#generateTiles() {
const [rangeStart, rangeEnd] = this.#activeRange;
const neededStarts = new Set();
const level = this.#levelForZoom();
const firstStart = Math.max(0, Math.floor(rangeStart / this.tileSize) * this.tileSize);
const lastStart = Math.min(this.duration, rangeEnd);
for (let startTime = firstStart; startTime <= lastStart; startTime += this.tileSize) {
neededStarts.add(startTime);
}
for (const startTime of neededStarts) {
if (!this.#tiles.has(startTime)) {
const endTime = Math.min(startTime + this.tileSize, this.duration);
this.#tiles.set(startTime, this.#buildTileData(startTime, endTime, level));
}
}
for (const startTime of this.#tiles.keys()) {
if (!neededStarts.has(startTime))
this.#tiles.delete(startTime);
}
this.#emit();
}
#buildTileData(startTime, endTime, level) {
const peaks = this.#slicePeaks(level, startTime, endTime);
return {
startTime,
endTime,
peaks,
canvas: renderTile(peaks, {
width: this.#tilePixelWidth(startTime, endTime),
height: this.tileHeight,
color: this.color,
}),
};
}
#levelForZoom() {
return this.#levels.find(level => level.peaksPerSecond >= this.#zoom)
?? this.#levels[this.#levels.length - 1];
}
#slicePeaks(level, startTime, endTime) {
if (!level.peaksPerSecond)
return new Float32Array();
const from = Math.max(0, Math.floor(startTime * level.peaksPerSecond));
const to = Math.max(from + 1, Math.min(level.peaks.length, Math.ceil(endTime * level.peaksPerSecond)));
return level.peaks.slice(from, to);
}
#tilePixelWidth(startTime, endTime) {
return Math.min(MAX_TILE_WIDTH, Math.max(1, Math.ceil((endTime - startTime) * this.#zoom)));
}
#emit() {
if (!this.#onChange)
return;
this.#onChange([...this.#tiles.values()].sort((a, b) => a.startTime - b.startTime));
}
getTiles() {
return this.#tiles;
}
}
//# sourceMappingURL=waveform.js.map