UNPKG

@omnimedia/omnitool

Version:

open source video processing tools

292 lines (232 loc) 7.62 kB
import {ALL_FORMATS, Input, VideoSampleSink} from "mediabunny" import {ms, Ms} from "../../../../units/ms.js" import {Driver} from "../../../../driver/driver.js" import {TimelineFile} from "../../../parts/basics.js" import {DecoderSource} from "../../../../driver/fns/schematic.js" import {loadDecoderSource} from "../../../../driver/utils/load-decoder-source.js" import {createVisualSampler} from "../../parts/samplers/visual/sampler.js" type StreamCursor<T> = { next(target: number): Promise<T | undefined> cancel(): Promise<void> } type VideoFrameCursor = StreamCursor<VideoFrame> abstract class BaseVisualSampler { readonly #videoCursors = new Map<number, VideoFrameCursor>() readonly #sampler constructor( protected driver: Driver, protected resolveMedia: (hash: string) => DecoderSource, protected timeline: TimelineFile ) { this.#sampler = createVisualSampler(this.resolveMedia, (item, time) => { const targetUs = toUs(ms(item.start + time)) let cursor = this.#videoCursors.get(item.id) if (!cursor) { const source = this.resolveMedia(item.mediaHash) const endUs = toUs(ms(item.start + item.duration)) cursor = this.createCursor(source, targetUs, endUs) this.#videoCursors.set(item.id, cursor) } return cursor.next(targetUs) }) } protected abstract createCursor(source: DecoderSource, startUs: number, endUs: number): VideoFrameCursor protected sample(timecode: Ms) { return this.#sampler.sample(this.timeline, timecode) } async cancel() { await Promise.all([...this.#videoCursors.values()].map(c => c.cancel())) this.#videoCursors.clear() } } /** * forward-only frame cursor optimized for export purposes. * it uses mediabunny internally so the support for non-clients * should be done from mediabunny custom decoder/encoder */ export class CursorVisualSampler extends BaseVisualSampler { #lastTimecode = -Infinity next(timecode: Ms) { if (timecode < this.#lastTimecode) throw new Error(`Forward-only cursor regression: ${timecode}ms < ${this.#lastTimecode}ms`) this.#lastTimecode = timecode return this.sample(timecode) } protected createCursor(source: DecoderSource, startUs: number, _endUs: number): VideoFrameCursor { const video = this.driver.decodeVideo({source, start: startUs / 1_000_000}) const reader = video.readable.getReader() let current: VideoFrame | null = null let nextPromise: Promise<VideoFrame | null> | null = null let ended = false const readNext = async () => { if (ended) return null const {done, value} = await reader.read() if (done) return (ended = true, null) const frame = new VideoFrame(value) value.close() return frame } return { async next(targetUs: number): Promise<VideoFrame | undefined> { current ??= await readNext() if (!current) return undefined while (true) { nextPromise ??= readNext() const nextFrame = await nextPromise if (!nextFrame) return new VideoFrame(current) const currentUs = current.timestamp ?? -Infinity const nextUs = nextFrame.timestamp ?? currentUs if (nextUs < targetUs) { current.close() current = nextFrame nextPromise = null continue } const useNext = Math.abs(nextUs - targetUs) < Math.abs(currentUs - targetUs) if (useNext) { current.close() current = nextFrame nextPromise = null continue } return new VideoFrame(current) } }, async cancel() { const pending = nextPromise nextPromise = null ended = true const buffered = await pending?.catch(() => null) buffered?.close() current?.close() current = null video.cancel() } } } } export class ReverseCursorVisualSampler extends BaseVisualSampler { #lastTimecode = Infinity next(timecode: Ms) { if (timecode > this.#lastTimecode) throw new Error(`Reverse-only cursor regression: ${timecode}ms > ${this.#lastTimecode}ms`) this.#lastTimecode = timecode return this.sample(timecode) } protected createCursor(source: DecoderSource, startUs: number, endUs: number): VideoFrameCursor { const windowUs = 1_000_000 const prefetchThreshold = windowUs * 0.5 let frames: VideoFrame[] = [] let windowStart = Infinity let windowEnd = -Infinity let input: Input | null = null let sink: VideoSampleSink | null = null let prefetchPromise: Promise<{frames: VideoFrame[], windowStart: number, windowEnd: number}> | null = null let activeFetches = 0 let idle: Promise<void> = Promise.resolve() let resolveIdle: (() => void) | null = null let canceled = false const clear = () => { for (const frame of frames) frame.close() frames = [] } const startFetch = () => { if (activeFetches++ === 0) idle = new Promise<void>(resolve => resolveIdle = resolve) } const endFetch = () => { if (--activeFetches === 0) { resolveIdle?.() resolveIdle = null } } const getSink = async () => { if (sink) return sink input = new Input({ source: await loadDecoderSource(source), formats: ALL_FORMATS, }) const track = await input.getPrimaryVideoTrack() sink = track && await track.canDecode() ? new VideoSampleSink(track) : null return sink } const fetchFrames = async (targetUs: number) => { startFetch() const wEnd = Math.min(endUs, targetUs + 1) const wStart = Math.max(startUs, wEnd - windowUs) const newFrames: VideoFrame[] = [] const videoSink = await getSink() if (videoSink) { for await (const sample of videoSink.samples(wStart / 1_000_000, wEnd / 1_000_000)) { newFrames.push(sample.toVideoFrame()) sample.close() } } endFetch() return {frames: newFrames, windowStart: wStart, windowEnd: wEnd} } const loadWindow = async (targetUs: number) => { clear() const result = await fetchFrames(targetUs) frames = result.frames windowStart = result.windowStart windowEnd = result.windowEnd } return { async next(targetUs: number): Promise<VideoFrame | undefined> { if (canceled) return undefined if (targetUs < windowStart || targetUs > windowEnd) { if (prefetchPromise) { const prefetched = await prefetchPromise prefetchPromise = null if (canceled) { for (const f of prefetched.frames) f.close() return undefined } if (targetUs >= prefetched.windowStart && targetUs <= prefetched.windowEnd) { clear() frames = prefetched.frames windowStart = prefetched.windowStart windowEnd = prefetched.windowEnd } else { for (const f of prefetched.frames) f.close() await loadWindow(targetUs) } } else { await loadWindow(targetUs) } } if (!prefetchPromise && targetUs < windowStart + prefetchThreshold && windowStart > startUs) prefetchPromise = fetchFrames(windowStart - 1) let best: VideoFrame | undefined let bestDistance = Infinity for (const frame of frames) { const distance = Math.abs((frame.timestamp ?? targetUs) - targetUs) if (distance < bestDistance) { best = frame bestDistance = distance } } return best ? new VideoFrame(best) : undefined }, async cancel() { canceled = true const pending = prefetchPromise prefetchPromise = null const prefetched = await pending?.catch(() => null) if (prefetched) for (const f of prefetched.frames) f.close() await idle clear() input?.dispose() input = null sink = null } } } } const toUs = (ms: Ms) => Math.round(ms * 1_000)