UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

162 lines (130 loc) 4.66 kB
import {renderTile} from "./parts/render.js" import {Driver} from "../../../driver/driver.js" import {collectPeakLevels} from "./parts/collect.js" import {DecoderSource} from "../../../driver/fns/schematic.js" import {WaveformOptions, WaveformPeakLevel, WaveformTileData, WaveformTimeRange} from "./parts/types.js" const MAX_TILE_WIDTH = 4096 export class Waveform { #tiles = new Map<number, WaveformTileData>() #activeRange: WaveformTimeRange = [0, 0] #visibleRange: WaveformTimeRange = [0, 0] #zoom #levels #onChange #updateQueued = false readonly color readonly duration readonly tileSize readonly tileHeight readonly preloadMargin private constructor(levels: WaveformPeakLevel[], duration: number, options: WaveformOptions) { 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: Driver, source: DecoderSource, options: WaveformOptions = {}) { const {duration, levels} = await collectPeakLevels(driver, source) return new Waveform(levels, duration, options) } /** Waveform render density in pixels per second. */ set zoom(pixelsPerSecond: number) { 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]: WaveformTimeRange, margin = 1): WaveformTimeRange { const visibleSize = end - start return [ Math.max(0, start - visibleSize * margin), Math.min(this.duration, end + visibleSize * margin), ] } set range(visibleRange: WaveformTimeRange) { 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<number>() 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: number, endTime: number, level: WaveformPeakLevel): WaveformTileData { 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: WaveformPeakLevel, startTime: number, endTime: number) { 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: number, endTime: number) { 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 } }