feat: implement timeline caching with cache indicator

This commit is contained in:
Maze Winther 2025-08-31 13:26:53 +02:00
parent 21dac9f09e
commit e222f15d1d
6 changed files with 672 additions and 66 deletions

View File

@ -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<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
useFrameCache();
const lastFrameTimeRef = useRef(0);
const renderSeqRef = useRef(0);
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
@ -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<HTMLDivElement>,
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)

View File

@ -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 */}
<div className="flex bg-panel sticky top-0 z-10">
{/* Track Labels Header */}
<div className="w-28 shrink-0 bg-panel border-r flex items-center justify-between px-3 py-2">
<div className="w-28 shrink-0 bg-panel border-r border-t flex items-center justify-between px-3 py-2">
{/* Empty space */}
<span className="text-sm font-medium text-muted-foreground opacity-0">
.
@ -658,7 +662,21 @@ export function Timeline() {
onClick={handleTimelineContentClick}
data-ruler-area
>
<ScrollArea className="w-full" ref={rulerScrollRef}>
<ScrollArea
className="w-full"
ref={rulerScrollRef}
onScroll={(e) => {
if (isUpdatingRef.current) return;
isUpdatingRef.current = true;
const tracksViewport = tracksScrollRef.current;
if (tracksViewport) {
tracksViewport.scrollLeft = (
e.currentTarget as HTMLDivElement
).scrollLeft;
}
isUpdatingRef.current = false;
}}
>
<div
ref={rulerRef}
className="relative h-10 select-none cursor-default"
@ -667,6 +685,14 @@ export function Timeline() {
}}
onMouseDown={handleRulerMouseDown}
>
<TimelineCacheIndicator
duration={duration}
zoomLevel={zoomLevel}
tracks={tracks}
mediaFiles={mediaFiles}
activeProject={activeProject}
getRenderStatus={getRenderStatus}
/>
{/* 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 (
<div
<TimelineMarker
key={i}
className={`absolute top-0 h-4 ${
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
}`}
style={{
left: `${
time *
TIMELINE_CONSTANTS.PIXELS_PER_SECOND *
zoomLevel
}px`,
}}
>
<span
className={`absolute top-1 left-1 text-[0.6rem] ${
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
}`}
>
{(() => {
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);
})()}
</span>
</div>
time={time}
zoomLevel={zoomLevel}
interval={interval}
isMainMarker={isMainMarker}
/>
);
}).filter(Boolean);
})()}
@ -836,7 +820,21 @@ export function Timeline() {
containerRef={tracksContainerRef}
isActive={selectionBox?.isActive || false}
/>
<ScrollArea className="w-full h-full" ref={tracksScrollRef}>
<ScrollArea
className="w-full h-full"
ref={tracksScrollRef}
onScroll={(e) => {
if (isUpdatingRef.current) return;
isUpdatingRef.current = true;
const rulerViewport = rulerScrollRef.current;
if (rulerViewport) {
rulerViewport.scrollLeft = (
e.currentTarget as HTMLDivElement
).scrollLeft;
}
isUpdatingRef.current = false;
}}
>
<div
className="relative flex-1"
style={{
@ -1085,7 +1083,7 @@ function TimelineToolbar({
const currentBookmarked = isBookmarked(currentTime);
return (
<div className="border-b flex items-center justify-between px-2 py-1">
<div className=" flex items-center justify-between px-2 py-1">
<div className="flex items-center gap-1 w-full">
<TooltipProvider delayDuration={500}>
<Tooltip>

View File

@ -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 (
<div className="absolute top-0 left-0 right-0 h-px z-10 pointer-events-none">
{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 (
<div
key={index}
className={cn(
"absolute top-0 h-px",
segment.cached ? "bg-primary" : "bg-border"
)}
style={{
left: `${startX}px`,
width: `${width}px`,
}}
title={
segment.cached
? "Cached (fast playback)"
: "Not cached (will render)"
}
/>
);
})}
</div>
);
}

View File

@ -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 (
<div
className={cn(
"absolute top-0 h-4",
isMainMarker
? "border-l border-muted-foreground/40"
: "border-l border-muted-foreground/20"
)}
style={{
left: `${time * TIMELINE_CONSTANTS.PIXELS_PER_SECOND * zoomLevel}px`,
}}
>
<span
className={cn(
"absolute top-1 left-1 text-[0.6rem]",
isMainMarker
? "text-muted-foreground font-medium"
: "text-muted-foreground/70"
)}
>
{(() => {
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);
})()}
</span>
</div>
);
}

View File

@ -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,
};
}

View File

@ -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<AudioBuffer | null> {
@ -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<string, MediaFile>(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);