From e222f15d1dd50aa50be8c7424d86aa7fedf0defc Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Sun, 31 Aug 2025 13:26:53 +0200 Subject: [PATCH] feat: implement timeline caching with cache indicator --- .../src/components/editor/preview-panel.tsx | 110 +++++- .../src/components/editor/timeline/index.tsx | 102 +++--- .../timeline/timeline-cache-indicator.tsx | 116 +++++++ .../editor/timeline/timeline-marker.tsx | 67 ++++ apps/web/src/hooks/use-frame-cache.ts | 320 ++++++++++++++++++ apps/web/src/lib/export.ts | 23 +- 6 files changed, 672 insertions(+), 66 deletions(-) create mode 100644 apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx create mode 100644 apps/web/src/components/editor/timeline/timeline-marker.tsx create mode 100644 apps/web/src/hooks/use-frame-cache.ts diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx index 1654624c..c53cdafb 100644 --- a/apps/web/src/components/editor/preview-panel.tsx +++ b/apps/web/src/components/editor/preview-panel.tsx @@ -13,6 +13,7 @@ import { renderTimelineFrame } from "@/lib/timeline-renderer"; import { cn } from "@/lib/utils"; import { formatTimeCode } from "@/lib/time"; import { EditableTimecode } from "@/components/ui/editable-timecode"; +import { useFrameCache } from "@/hooks/use-frame-cache"; import { DEFAULT_CANVAS_SIZE, DEFAULT_FPS, @@ -44,6 +45,8 @@ export function PreviewPanel() { const { activeProject } = useProjectStore(); const previewRef = useRef(null); const canvasRef = useRef(null); + const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } = + useFrameCache(); const lastFrameTimeRef = useRef(0); const renderSeqRef = useRef(0); const offscreenCanvasRef = useRef( @@ -216,6 +219,17 @@ export function PreviewPanel() { }; }, [dragState, previewDimensions, canvasSize, updateTextElement]); + // Invalidate cache when timeline changes + useEffect(() => { + invalidateCache(); + }, [ + tracks, + mediaFiles, + activeProject?.backgroundColor, + activeProject?.backgroundType, + invalidateCache, + ]); + const handleTextMouseDown = ( e: React.MouseEvent, element: any, @@ -449,7 +463,7 @@ export function PreviewPanel() { }; }, [isPlaying, volume, muted, mediaFiles]); - // Canvas: draw current frame for visible elements using offscreen compositing + // Canvas: draw current frame with caching useEffect(() => { const draw = async () => { const canvas = canvasRef.current; @@ -475,10 +489,86 @@ export function PreviewPanel() { lastFrameTimeRef.current = currentTime; } - // Invalidate older async renders when user scrubs rapidly - const mySeq = (renderSeqRef.current += 1); + const cachedFrame = getCachedFrame( + currentTime, + tracks, + mediaFiles, + activeProject + ); + if (cachedFrame) { + mainCtx.putImageData(cachedFrame, 0, 0); - // Offscreen buffer to avoid flicker (reuse canvas) + // Pre-render nearby frames in background + if (!isPlaying) { + // Only during scrubbing to avoid interfering with playback + preRenderNearbyFrames( + currentTime, + tracks, + mediaFiles, + activeProject, + async (time: number) => { + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = displayWidth; + tempCanvas.height = displayHeight; + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) + throw new Error("Failed to create temp canvas context"); + + await renderTimelineFrame({ + ctx: tempCtx, + time, + canvasWidth: displayWidth, + canvasHeight: displayHeight, + tracks, + mediaFiles, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + return tempCtx.getImageData(0, 0, displayWidth, displayHeight); + } + ); + } else { + // Small lookahead while playing + preRenderNearbyFrames( + currentTime, + tracks, + mediaFiles, + activeProject, + async (time: number) => { + const tempCanvas = document.createElement("canvas"); + tempCanvas.width = displayWidth; + tempCanvas.height = displayHeight; + const tempCtx = tempCanvas.getContext("2d"); + if (!tempCtx) + throw new Error("Failed to create temp canvas context"); + + await renderTimelineFrame({ + ctx: tempCtx, + time, + canvasWidth: displayWidth, + canvasHeight: displayHeight, + tracks, + mediaFiles, + backgroundColor: + activeProject?.backgroundType === "blur" + ? "transparent" + : activeProject?.backgroundColor || "#000000", + projectCanvasSize: canvasSize, + }); + + return tempCtx.getImageData(0, 0, displayWidth, displayHeight); + }, + 1 + ); + } + return; + } + + // Cache miss - render from scratch if (!offscreenCanvasRef.current) { const hasOffscreen = typeof (globalThis as unknown as { OffscreenCanvas?: unknown }) @@ -541,6 +631,14 @@ export function PreviewPanel() { projectCanvasSize: canvasSize, }); + const imageData = (offCtx as CanvasRenderingContext2D).getImageData( + 0, + 0, + displayWidth, + displayHeight + ); + cacheFrame(currentTime, imageData, tracks, mediaFiles, activeProject); + // Blit offscreen to visible canvas mainCtx.clearRect(0, 0, displayWidth, displayHeight); if ((offscreenCanvas as HTMLCanvasElement).getContext) { @@ -564,6 +662,10 @@ export function PreviewPanel() { canvasSize.height, activeProject?.backgroundType, activeProject?.backgroundColor, + getCachedFrame, + cacheFrame, + preRenderNearbyFrames, + isPlaying, ]); // Get media elements for blur background (video/image only) diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx index c994d707..90a19d61 100644 --- a/apps/web/src/components/editor/timeline/index.tsx +++ b/apps/web/src/components/editor/timeline/index.tsx @@ -57,6 +57,9 @@ import { useSelectionBox } from "@/hooks/use-selection-box"; import { SnapIndicator } from "../snap-indicator"; import { SnapPoint } from "@/hooks/use-timeline-snapping"; import type { DragData, TimelineTrack, TrackType } from "@/types/timeline"; +import { TimelineCacheIndicator } from "./timeline-cache-indicator"; +import { TimelineMarker } from "./timeline-marker"; +import { useFrameCache } from "@/hooks/use-frame-cache"; import { getTrackHeight, getCumulativeHeightBefore, @@ -85,6 +88,7 @@ export function Timeline() { const { mediaFiles, addMediaFile } = useMediaStore(); const { activeProject } = useProjectStore(); const { currentTime, duration, seek, setDuration } = usePlaybackStore(); + const { getRenderStatus } = useFrameCache(); const [isDragOver, setIsDragOver] = useState(false); const { addElementToNewTrack } = useTimelineStore(); const dragCounterRef = useRef(0); @@ -637,7 +641,7 @@ export function Timeline() { {/* Timeline Header with Ruler */}
{/* Track Labels Header */} -
+
{/* Empty space */} . @@ -658,7 +662,21 @@ export function Timeline() { onClick={handleTimelineContentClick} data-ruler-area > - + { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + const tracksViewport = tracksScrollRef.current; + if (tracksViewport) { + tracksViewport.scrollLeft = ( + e.currentTarget as HTMLDivElement + ).scrollLeft; + } + isUpdatingRef.current = false; + }} + >
+ {/* Time markers */} {(() => { // Calculate appropriate time interval based on zoom level @@ -693,55 +719,13 @@ export function Timeline() { time % (interval >= 1 ? Math.max(1, interval) : 1) === 0; return ( -
- - {(() => { - const formatTime = (seconds: number) => { - const hours = Math.floor(seconds / 3600); - const minutes = Math.floor((seconds % 3600) / 60); - const secs = seconds % 60; - - if (hours > 0) { - return `${hours}:${minutes - .toString() - .padStart(2, "0")}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (minutes > 0) { - return `${minutes}:${Math.floor(secs) - .toString() - .padStart(2, "0")}`; - } - if (interval >= 1) { - return `${Math.floor(secs)}s`; - } - return `${secs.toFixed(1)}s`; - }; - return formatTime(time); - })()} - -
+ time={time} + zoomLevel={zoomLevel} + interval={interval} + isMainMarker={isMainMarker} + /> ); }).filter(Boolean); })()} @@ -836,7 +820,21 @@ export function Timeline() { containerRef={tracksContainerRef} isActive={selectionBox?.isActive || false} /> - + { + if (isUpdatingRef.current) return; + isUpdatingRef.current = true; + const rulerViewport = rulerScrollRef.current; + if (rulerViewport) { + rulerViewport.scrollLeft = ( + e.currentTarget as HTMLDivElement + ).scrollLeft; + } + isUpdatingRef.current = false; + }} + >
+
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx new file mode 100644 index 00000000..f6494738 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx @@ -0,0 +1,116 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; +import { TimelineTrack } from "@/types/timeline"; +import { MediaFile } from "@/types/media"; +import { TProject } from "@/types/project"; + +interface CacheSegment { + startTime: number; + endTime: number; + cached: boolean; +} + +interface TimelineCacheIndicatorProps { + duration: number; + zoomLevel: number; + tracks: TimelineTrack[]; + mediaFiles: MediaFile[]; + activeProject: TProject | null; + getRenderStatus: ( + time: number, + tracks: TimelineTrack[], + mediaFiles: MediaFile[], + activeProject: TProject | null + ) => "cached" | "not-cached"; +} + +export function TimelineCacheIndicator({ + duration, + zoomLevel, + tracks, + mediaFiles, + activeProject, + getRenderStatus, +}: TimelineCacheIndicatorProps) { + // Calculate cache segments by sampling the timeline + const calculateCacheSegments = (): CacheSegment[] => { + const segments: CacheSegment[] = []; + const sampleRate = 10; // Sample every 0.1 seconds + const totalSamples = Math.ceil(duration * sampleRate); + + if (totalSamples === 0) { + return [{ startTime: 0, endTime: duration, cached: false }]; + } + + let currentSegment: CacheSegment | null = null; + + for (let i = 0; i <= totalSamples; i++) { + const time = i / sampleRate; + const cached = + getRenderStatus(time, tracks, mediaFiles, activeProject) === "cached"; + + if (!currentSegment) { + // Start first segment + currentSegment = { + startTime: time, + endTime: time, + cached, + }; + } else if (currentSegment.cached === cached) { + // Extend current segment + currentSegment.endTime = time; + } else { + // Finish current segment and start new one + segments.push(currentSegment); + currentSegment = { + startTime: time, + endTime: time, + cached, + }; + } + } + + // Add the last segment + if (currentSegment) { + currentSegment.endTime = duration; + segments.push(currentSegment); + } + + return segments; + }; + + const cacheSegments = calculateCacheSegments(); + + return ( +
+ {cacheSegments.map((segment, index) => { + const startX = + segment.startTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const endX = + segment.endTime * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel; + const width = endX - startX; + + return ( +
+ ); + })} +
+ ); +} diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx new file mode 100644 index 00000000..1351a457 --- /dev/null +++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx @@ -0,0 +1,67 @@ +"use client"; + +import { cn } from "@/lib/utils"; +import { TIMELINE_CONSTANTS } from "@/constants/timeline-constants"; + +interface TimelineMarkerProps { + time: number; + zoomLevel: number; + interval: number; + isMainMarker: boolean; +} + +export function TimelineMarker({ + time, + zoomLevel, + interval, + isMainMarker, +}: TimelineMarkerProps) { + return ( +
+ + {(() => { + const formatTime = (seconds: number) => { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + if (hours > 0) { + return `${hours}:${minutes + .toString() + .padStart(2, "0")}:${Math.floor(secs) + .toString() + .padStart(2, "0")}`; + } + if (minutes > 0) { + return `${minutes}:${Math.floor(secs) + .toString() + .padStart(2, "0")}`; + } + if (interval >= 1) { + return `${Math.floor(secs)}s`; + } + return `${secs.toFixed(1)}s`; + }; + return formatTime(time); + })()} + +
+ ); +} 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 = + __frameCacheGlobal.__sharedFrameCache ?? new Map(); +__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, + 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(); + 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, + }; +} diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts index f25f5f6b..2dd41855 100644 --- a/apps/web/src/lib/export.ts +++ b/apps/web/src/lib/export.ts @@ -16,6 +16,8 @@ import { useMediaStore } from "@/stores/media-store"; import { useProjectStore } from "@/stores/project-store"; import { DEFAULT_FPS } from "@/stores/project-store"; import { ExportOptions, ExportResult } from "@/types/export"; +import { TimelineTrack } from "@/types/timeline"; +import { MediaFile } from "@/types/media"; export const DEFAULT_EXPORT_OPTIONS: ExportOptions = { format: "mp4", @@ -40,8 +42,8 @@ interface AudioElement { } async function createTimelineAudioBuffer( - tracks: any[], - mediaFiles: any[], + tracks: TimelineTrack[], + mediaFiles: MediaFile[], duration: number, sampleRate: number = 44100 ): Promise { @@ -51,7 +53,7 @@ async function createTimelineAudioBuffer( // Collect all audio elements from timeline const audioElements: AudioElement[] = []; - const mediaMap = new Map(mediaFiles.map((m) => [m.id, m])); + const mediaMap = new Map(mediaFiles.map((m) => [m.id, m])); for (const track of tracks) { if (track.muted) continue; @@ -59,11 +61,12 @@ async function createTimelineAudioBuffer( for (const element of track.elements) { if (element.type !== "media") continue; - const mediaItem = mediaMap.get(element.mediaId); + const mediaElement = element; + const mediaItem = mediaMap.get(mediaElement.mediaId); if (!mediaItem || mediaItem.type !== "audio") continue; const visibleDuration = - element.duration - element.trimStart - element.trimEnd; + mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd; if (visibleDuration <= 0) continue; try { @@ -75,11 +78,11 @@ async function createTimelineAudioBuffer( audioElements.push({ buffer: audioBuffer, - startTime: element.startTime, - duration: element.duration, - trimStart: element.trimStart, - trimEnd: element.trimEnd, - muted: element.muted || track.muted || false, + startTime: mediaElement.startTime, + duration: mediaElement.duration, + trimStart: mediaElement.trimStart, + trimEnd: mediaElement.trimEnd, + muted: mediaElement.muted || track.muted || false, }); } catch (error) { console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);