diff options
Diffstat (limited to 'apps/web/src/hooks')
| -rw-r--r-- | apps/web/src/hooks/use-frame-cache.ts | 320 |
1 files changed, 320 insertions, 0 deletions
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts new file mode 100644 index 00000000..8dc99966 --- /dev/null +++ b/apps/web/src/hooks/use-frame-cache.ts @@ -0,0 +1,320 @@ +import { useRef, useCallback } from "react";
+import {
+ TimelineTrack,
+ TimelineElement,
+ MediaElement,
+ TextElement,
+} from "@/types/timeline";
+import { MediaFile } from "@/types/media";
+import { TProject } from "@/types/project";
+
+interface CachedFrame {
+ imageData: ImageData;
+ timelineHash: string;
+ timestamp: number;
+}
+
+interface FrameCacheOptions {
+ maxCacheSize?: number; // Maximum number of cached frames
+ cacheResolution?: number; // Frames per second to cache at
+}
+
+// Shared singleton cache across hook instances (HMR-safe)
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const __frameCacheGlobal: any = globalThis as any;
+const __sharedFrameCache: Map<number, CachedFrame> =
+ __frameCacheGlobal.__sharedFrameCache ?? new Map<number, CachedFrame>();
+__frameCacheGlobal.__sharedFrameCache = __sharedFrameCache;
+
+export function useFrameCache(options: FrameCacheOptions = {}) {
+ const { maxCacheSize = 300, cacheResolution = 30 } = options; // 10 seconds at 30fps
+
+ const frameCacheRef = useRef(__sharedFrameCache);
+
+ // Generate a hash of the timeline state that affects rendering
+ const getTimelineHash = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null
+ ): string => {
+ // Get elements that are active at this time
+ const activeElements: Array<{
+ id: string;
+ type: string;
+ startTime: number;
+ duration: number;
+ trimStart: number;
+ trimEnd: number;
+ mediaId?: string;
+ // Text-specific properties
+ content?: string;
+ fontSize?: number;
+ fontFamily?: string;
+ color?: string;
+ backgroundColor?: string;
+ x?: number;
+ y?: number;
+ rotation?: number;
+ opacity?: number;
+ }> = [];
+
+ for (const track of tracks) {
+ if (track.muted) continue;
+
+ for (const element of track.elements) {
+ // Check if element has hidden property (some elements might not have it)
+ const isHidden = "hidden" in element ? element.hidden : false;
+ if (isHidden) continue;
+
+ const elementStart = element.startTime;
+ const elementEnd =
+ element.startTime +
+ (element.duration - element.trimStart - element.trimEnd);
+
+ if (time >= elementStart && time < elementEnd) {
+ if (element.type === "media") {
+ const mediaElement = element as MediaElement;
+ activeElements.push({
+ id: element.id,
+ type: element.type,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ mediaId: mediaElement.mediaId,
+ });
+ } else if (element.type === "text") {
+ const textElement = element as TextElement;
+ activeElements.push({
+ id: element.id,
+ type: element.type,
+ startTime: element.startTime,
+ duration: element.duration,
+ trimStart: element.trimStart,
+ trimEnd: element.trimEnd,
+ content: textElement.content,
+ fontSize: textElement.fontSize,
+ fontFamily: textElement.fontFamily,
+ color: textElement.color,
+ backgroundColor: textElement.backgroundColor,
+ x: textElement.x,
+ y: textElement.y,
+ rotation: textElement.rotation,
+ opacity: textElement.opacity,
+ });
+ }
+ }
+ }
+ }
+
+ // Include project settings that affect rendering
+ const projectState = {
+ backgroundColor: activeProject?.backgroundColor,
+ backgroundType: activeProject?.backgroundType,
+ blurIntensity: activeProject?.blurIntensity,
+ canvasSize: activeProject?.canvasSize,
+ };
+
+ return JSON.stringify({
+ activeElements,
+ projectState,
+ time: Math.floor(time * cacheResolution) / cacheResolution, // Quantize time
+ });
+ },
+ [cacheResolution]
+ );
+
+ // Check if a frame is cached and valid
+ const isFrameCached = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null
+ ): boolean => {
+ const frameKey = Math.floor(time * cacheResolution);
+ const cached = frameCacheRef.current.get(frameKey);
+
+ if (!cached) return false;
+
+ const currentHash = getTimelineHash(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject
+ );
+ return cached.timelineHash === currentHash;
+ },
+ [getTimelineHash, cacheResolution]
+ );
+
+ // Get cached frame if available and valid
+ const getCachedFrame = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null
+ ): ImageData | null => {
+ const frameKey = Math.floor(time * cacheResolution);
+ const cached = frameCacheRef.current.get(frameKey);
+
+ if (!cached) return null;
+
+ const currentHash = getTimelineHash(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject
+ );
+ if (cached.timelineHash !== currentHash) {
+ // Cache is stale, remove it
+ frameCacheRef.current.delete(frameKey);
+ return null;
+ }
+
+ return cached.imageData;
+ },
+ [getTimelineHash, cacheResolution]
+ );
+
+ // Cache a rendered frame
+ const cacheFrame = useCallback(
+ (
+ time: number,
+ imageData: ImageData,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null
+ ): void => {
+ const frameKey = Math.floor(time * cacheResolution);
+ const timelineHash = getTimelineHash(
+ time,
+ tracks,
+ mediaFiles,
+ activeProject
+ );
+
+ // Enforce cache size limit (LRU eviction)
+ if (frameCacheRef.current.size >= maxCacheSize) {
+ // Remove oldest entries
+ const entries = Array.from(frameCacheRef.current.entries());
+ entries.sort((a, b) => a[1].timestamp - b[1].timestamp);
+
+ // Remove oldest 20% of entries
+ const toRemove = Math.floor(entries.length * 0.2);
+ for (let i = 0; i < toRemove; i++) {
+ frameCacheRef.current.delete(entries[i][0]);
+ }
+ }
+
+ frameCacheRef.current.set(frameKey, {
+ imageData,
+ timelineHash,
+ timestamp: Date.now(),
+ });
+ },
+ [getTimelineHash, cacheResolution, maxCacheSize]
+ );
+
+ // Clear cache when timeline changes significantly
+ const invalidateCache = useCallback(() => {
+ frameCacheRef.current.clear();
+ }, []);
+
+ // Get render status for timeline indicator
+ const getRenderStatus = useCallback(
+ (
+ time: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null
+ ): "cached" | "not-cached" => {
+ return isFrameCached(time, tracks, mediaFiles, activeProject)
+ ? "cached"
+ : "not-cached";
+ },
+ [isFrameCached]
+ );
+
+ // Pre-render frames around current time
+ const preRenderNearbyFrames = useCallback(
+ async (
+ currentTime: number,
+ tracks: TimelineTrack[],
+ mediaFiles: MediaFile[],
+ activeProject: TProject | null,
+ renderFunction: (time: number) => Promise<ImageData>,
+ range: number = 3 // seconds
+ ) => {
+ const framesToPreRender: number[] = [];
+
+ // Calculate frames to pre-render (around current time)
+ for (
+ let offset = -range;
+ offset <= range;
+ offset += 1 / cacheResolution
+ ) {
+ const time = currentTime + offset;
+ if (time < 0) continue;
+
+ if (!isFrameCached(time, tracks, mediaFiles, activeProject)) {
+ framesToPreRender.push(time);
+ }
+ }
+
+ // Expand to full 1-second buckets to avoid fragmented tiny cache regions
+ const secondsToPreRender = new Set<number>();
+ for (const t of framesToPreRender) {
+ secondsToPreRender.add(Math.floor(t));
+ }
+
+ const expandedTimes: number[] = [];
+ for (const s of secondsToPreRender) {
+ for (let k = 0; k < cacheResolution; k++) {
+ const t = s + k / cacheResolution;
+ if (t < 0) continue;
+ if (!isFrameCached(t, tracks, mediaFiles, activeProject)) {
+ expandedTimes.push(t);
+ }
+ }
+ }
+
+ // Sort forward-first near currentTime to improve perceived responsiveness
+ expandedTimes.sort((a, b) => {
+ const da = a >= currentTime ? a - currentTime : currentTime - a + 1e6;
+ const db = b >= currentTime ? b - currentTime : currentTime - b + 1e6;
+ return da - db;
+ });
+
+ // Cap total scheduled renders to avoid jank (e.g., up to 90 frames)
+ const CAP = Math.max(30, Math.min(90, cacheResolution * 3));
+ const toSchedule = expandedTimes.slice(0, CAP);
+
+ // Pre-render during idle time
+ for (const time of toSchedule) {
+ requestIdleCallback(async () => {
+ try {
+ const imageData = await renderFunction(time);
+ cacheFrame(time, imageData, tracks, mediaFiles, activeProject);
+ } catch (error) {
+ console.warn(`Pre-render failed for time ${time}:`, error);
+ }
+ });
+ }
+ },
+ [isFrameCached, cacheFrame, cacheResolution]
+ );
+
+ return {
+ isFrameCached,
+ getCachedFrame,
+ cacheFrame,
+ invalidateCache,
+ getRenderStatus,
+ preRenderNearbyFrames,
+ cacheSize: frameCacheRef.current.size,
+ };
+}
|
