mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
feat: implement timeline caching with cache indicator
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user