OpenCut/apps/web/src/services/video-cache/service.ts

329 lines
7.2 KiB
TypeScript

import {
Input,
ALL_FORMATS,
BlobSource,
CanvasSink,
type WrappedCanvas,
} from "mediabunny";
interface VideoSinkData {
input: Input;
sink: CanvasSink;
iterator: AsyncGenerator<WrappedCanvas, void, unknown> | null;
currentFrame: WrappedCanvas | null;
nextFrame: WrappedCanvas | null;
lastTime: number;
prefetching: boolean;
prefetchPromise: Promise<void> | null;
}
export class VideoCache {
private sinks = new Map<string, VideoSinkData>();
private initPromises = new Map<string, Promise<void>>();
async getFrameAt({
mediaId,
file,
time,
}: {
mediaId: string;
file: File;
time: number;
}): Promise<WrappedCanvas | null> {
await this.ensureSink({ mediaId, file });
const sinkData = this.sinks.get(mediaId);
if (!sinkData) return null;
if (sinkData.nextFrame && sinkData.nextFrame.timestamp <= time) {
sinkData.currentFrame = sinkData.nextFrame;
sinkData.nextFrame = null;
this.startPrefetch({ sinkData });
}
if (
sinkData.currentFrame &&
this.isFrameValid({ frame: sinkData.currentFrame, time })
) {
if (!sinkData.nextFrame && !sinkData.prefetching) {
this.startPrefetch({ sinkData });
}
return sinkData.currentFrame;
}
if (
sinkData.iterator &&
sinkData.currentFrame &&
time >= sinkData.lastTime &&
time < sinkData.lastTime + 2.0
) {
const frame = await this.iterateToTime({ sinkData, targetTime: time });
if (frame) {
if (!sinkData.nextFrame && !sinkData.prefetching) {
this.startPrefetch({ sinkData });
}
return frame;
}
}
const frame = await this.seekToTime({ sinkData, time });
if (frame && !sinkData.nextFrame && !sinkData.prefetching) {
this.startPrefetch({ sinkData });
}
return frame;
}
private isFrameValid({
frame,
time,
}: {
frame: WrappedCanvas;
time: number;
}): boolean {
return time >= frame.timestamp && time < frame.timestamp + frame.duration;
}
private async iterateToTime({
sinkData,
targetTime,
}: {
sinkData: VideoSinkData;
targetTime: number;
}): Promise<WrappedCanvas | null> {
if (!sinkData.iterator) return null;
try {
while (true) {
// Wait for any pending prefetch to finish before touching iterator
if (sinkData.prefetching && sinkData.prefetchPromise) {
await sinkData.prefetchPromise;
}
// Check if the nextFrame (which might have just arrived) is what we need
if (
sinkData.nextFrame &&
sinkData.nextFrame.timestamp <= targetTime + 0.05 // Tolerance
) {
sinkData.currentFrame = sinkData.nextFrame;
sinkData.nextFrame = null;
} else {
const { value: frame, done } = await sinkData.iterator.next();
if (done || !frame) break;
sinkData.currentFrame = frame;
}
const frame = sinkData.currentFrame;
if (!frame) break;
sinkData.lastTime = frame.timestamp;
if (this.isFrameValid({ frame, time: targetTime })) {
return frame;
}
if (frame.timestamp > targetTime + 1.0) break;
}
} catch (error) {
console.warn("Iterator failed, will restart:", error);
sinkData.iterator = null;
}
return null;
}
private async seekToTime({
sinkData,
time,
}: {
sinkData: VideoSinkData;
time: number;
}): Promise<WrappedCanvas | null> {
try {
if (sinkData.prefetching && sinkData.prefetchPromise) {
await sinkData.prefetchPromise;
}
if (sinkData.iterator) {
await sinkData.iterator.return();
sinkData.iterator = null;
}
sinkData.nextFrame = null;
sinkData.iterator = sinkData.sink.canvases(time);
sinkData.lastTime = time;
// Fetch current frame
const { value: frame } = await sinkData.iterator.next();
if (frame) {
sinkData.currentFrame = frame;
// Aggressively fetch next frame immediately to fill buffer
// This matches the mediaplayer example which fetches 2 frames on start
try {
const { value: next } = await sinkData.iterator.next();
if (next) {
sinkData.nextFrame = next;
}
} catch (e) {
console.warn("Failed to pre-fetch next frame on seek:", e);
}
return frame;
}
} catch (error) {
console.warn("Failed to seek video:", error);
}
return null;
}
private startPrefetch({ sinkData }: { sinkData: VideoSinkData }): void {
if (sinkData.prefetching || !sinkData.iterator || sinkData.nextFrame) {
return;
}
sinkData.prefetching = true;
sinkData.prefetchPromise = this.prefetchNextFrame({ sinkData });
}
private async prefetchNextFrame({
sinkData,
}: {
sinkData: VideoSinkData;
}): Promise<void> {
if (!sinkData.iterator) {
sinkData.prefetching = false;
sinkData.prefetchPromise = null;
return;
}
try {
const { value: frame, done } = await sinkData.iterator.next();
if (done || !frame) {
sinkData.prefetching = false;
sinkData.prefetchPromise = null;
return;
}
sinkData.nextFrame = frame;
sinkData.prefetching = false;
sinkData.prefetchPromise = null;
} catch (error) {
console.warn("Prefetch failed:", error);
sinkData.prefetching = false;
sinkData.prefetchPromise = null;
sinkData.iterator = null;
}
}
private async ensureSink({
mediaId,
file,
}: {
mediaId: string;
file: File;
}): Promise<void> {
if (this.sinks.has(mediaId)) return;
if (this.initPromises.has(mediaId)) {
await this.initPromises.get(mediaId);
return;
}
const initPromise = this.initializeSink({ mediaId, file });
this.initPromises.set(mediaId, initPromise);
try {
await initPromise;
} finally {
this.initPromises.delete(mediaId);
}
}
private async initializeSink({
mediaId,
file,
}: {
mediaId: string;
file: File;
}): Promise<void> {
const input = new Input({
source: new BlobSource(file),
formats: ALL_FORMATS,
});
try {
const videoTrack = await input.getPrimaryVideoTrack();
if (!videoTrack) {
input.dispose();
throw new Error("No video track found");
}
const canDecode = await videoTrack.canDecode();
if (!canDecode) {
input.dispose();
throw new Error("Video codec not supported for decoding");
}
const sink = new CanvasSink(videoTrack, {
poolSize: 3,
fit: "contain",
});
this.sinks.set(mediaId, {
input,
sink,
iterator: null,
currentFrame: null,
nextFrame: null,
lastTime: -1,
prefetching: false,
prefetchPromise: null,
});
} catch (error) {
input.dispose();
console.error(`Failed to initialize video sink for ${mediaId}:`, error);
throw error;
}
}
clearVideo({ mediaId }: { mediaId: string }): void {
const sinkData = this.sinks.get(mediaId);
if (sinkData) {
if (sinkData.iterator) {
void sinkData.iterator.return();
}
// Clear frame references
sinkData.currentFrame = null;
sinkData.nextFrame = null;
// Dispose the input to release WebCodecs resources
sinkData.input.dispose();
this.sinks.delete(mediaId);
}
this.initPromises.delete(mediaId);
}
clearAll(): void {
for (const [mediaId] of this.sinks) {
this.clearVideo({ mediaId });
}
}
getStats() {
return {
totalSinks: this.sinks.size,
activeSinks: Array.from(this.sinks.values()).filter((s) => s.iterator)
.length,
cachedFrames: Array.from(this.sinks.values()).filter(
(s) => s.currentFrame,
).length,
};
}
}
export const videoCache = new VideoCache();