@omnimedia/omnitool
Version:
open source video processing tools
236 lines • 9.14 kB
JavaScript
import { ALL_FORMATS, Input, VideoSampleSink } from "mediabunny";
import { ms } from "../../../../units/ms.js";
import { loadDecoderSource } from "../../../../driver/utils/load-decoder-source.js";
import { createVisualSampler } from "../../parts/samplers/visual/sampler.js";
class BaseVisualSampler {
driver;
resolveMedia;
timeline;
#videoCursors = new Map();
#sampler;
constructor(driver, resolveMedia, timeline) {
this.driver = driver;
this.resolveMedia = resolveMedia;
this.timeline = timeline;
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);
});
}
sample(timecode) {
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) {
if (timecode < this.#lastTimecode)
throw new Error(`Forward-only cursor regression: ${timecode}ms < ${this.#lastTimecode}ms`);
this.#lastTimecode = timecode;
return this.sample(timecode);
}
createCursor(source, startUs, _endUs) {
const video = this.driver.decodeVideo({ source, start: startUs / 1_000_000 });
const reader = video.readable.getReader();
let current = null;
let nextPromise = 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) {
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) {
if (timecode > this.#lastTimecode)
throw new Error(`Reverse-only cursor regression: ${timecode}ms > ${this.#lastTimecode}ms`);
this.#lastTimecode = timecode;
return this.sample(timecode);
}
createCursor(source, startUs, endUs) {
const windowUs = 1_000_000;
const prefetchThreshold = windowUs * 0.5;
let frames = [];
let windowStart = Infinity;
let windowEnd = -Infinity;
let input = null;
let sink = null;
let prefetchPromise = null;
let activeFetches = 0;
let idle = Promise.resolve();
let resolveIdle = null;
let canceled = false;
const clear = () => {
for (const frame of frames)
frame.close();
frames = [];
};
const startFetch = () => {
if (activeFetches++ === 0)
idle = new Promise(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) => {
startFetch();
const wEnd = Math.min(endUs, targetUs + 1);
const wStart = Math.max(startUs, wEnd - windowUs);
const newFrames = [];
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) => {
clear();
const result = await fetchFrames(targetUs);
frames = result.frames;
windowStart = result.windowStart;
windowEnd = result.windowEnd;
};
return {
async next(targetUs) {
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;
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) => Math.round(ms * 1_000);
//# sourceMappingURL=cursor.js.map