summaryrefslogtreecommitdiff
path: root/apps/web/src/services/media/video-cache.ts
diff options
context:
space:
mode:
authorMaze Winther <mazewinther@gmail.com>2026-01-31 22:19:11 +0100
committerMaze Winther <mazewinther@gmail.com>2026-01-31 22:19:11 +0100
commitf820d9a8948b97460ebdfdaa6b42b6d510e2b3cf (patch)
tree460b4cd9ac403fe5591bb0b5b4064e1662d08077 /apps/web/src/services/media/video-cache.ts
parent7878c8bb7b83bce568ced990903b8195a9dd4605 (diff)
downloadopencut-f820d9a8948b97460ebdfdaa6b42b6d510e2b3cf.tar.gz
refactor services structure
Diffstat (limited to 'apps/web/src/services/media/video-cache.ts')
-rw-r--r--apps/web/src/services/media/video-cache.ts314
1 files changed, 0 insertions, 314 deletions
diff --git a/apps/web/src/services/media/video-cache.ts b/apps/web/src/services/media/video-cache.ts
deleted file mode 100644
index f3ddfcb2..00000000
--- a/apps/web/src/services/media/video-cache.ts
+++ /dev/null
@@ -1,314 +0,0 @@
-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();