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