summaryrefslogtreecommitdiff
path: root/apps/web/src/services/video-cache
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src/services/video-cache')
-rw-r--r--apps/web/src/services/video-cache/service.ts316
1 files changed, 316 insertions, 0 deletions
diff --git a/apps/web/src/services/video-cache/service.ts b/apps/web/src/services/video-cache/service.ts
new file mode 100644
index 00000000..36441981
--- /dev/null
+++ b/apps/web/src/services/video-cache/service.ts
@@ -0,0 +1,316 @@
+import {
+ Input,
+ ALL_FORMATS,
+ BlobSource,
+ CanvasSink,
+ type WrappedCanvas,
+} from "mediabunny";
+
+interface VideoSinkData {
+ 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> {
+ try {
+ const input = new Input({
+ source: new BlobSource(file),
+ formats: ALL_FORMATS,
+ });
+
+ const videoTrack = await input.getPrimaryVideoTrack();
+ if (!videoTrack) {
+ throw new Error("No video track found");
+ }
+
+ const canDecode = await videoTrack.canDecode();
+ if (!canDecode) {
+ throw new Error("Video codec not supported for decoding");
+ }
+
+ const sink = new CanvasSink(videoTrack, {
+ poolSize: 3,
+ fit: "contain",
+ });
+
+ this.sinks.set(mediaId, {
+ sink,
+ iterator: null,
+ currentFrame: null,
+ nextFrame: null,
+ lastTime: -1,
+ prefetching: false,
+ prefetchPromise: null,
+ });
+ } catch (error) {
+ 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();
+ }
+
+ 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();