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