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 { cn } from "@/lib/utils";
|
||||||
import { formatTimeCode } from "@/lib/time";
|
import { formatTimeCode } from "@/lib/time";
|
||||||
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
import { EditableTimecode } from "@/components/ui/editable-timecode";
|
||||||
|
import { useFrameCache } from "@/hooks/use-frame-cache";
|
||||||
import {
|
import {
|
||||||
DEFAULT_CANVAS_SIZE,
|
DEFAULT_CANVAS_SIZE,
|
||||||
DEFAULT_FPS,
|
DEFAULT_FPS,
|
||||||
@@ -44,6 +45,8 @@ export function PreviewPanel() {
|
|||||||
const { activeProject } = useProjectStore();
|
const { activeProject } = useProjectStore();
|
||||||
const previewRef = useRef<HTMLDivElement>(null);
|
const previewRef = useRef<HTMLDivElement>(null);
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
|
const { getCachedFrame, cacheFrame, invalidateCache, preRenderNearbyFrames } =
|
||||||
|
useFrameCache();
|
||||||
const lastFrameTimeRef = useRef(0);
|
const lastFrameTimeRef = useRef(0);
|
||||||
const renderSeqRef = useRef(0);
|
const renderSeqRef = useRef(0);
|
||||||
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
|
const offscreenCanvasRef = useRef<OffscreenCanvas | HTMLCanvasElement | null>(
|
||||||
@@ -216,6 +219,17 @@ export function PreviewPanel() {
|
|||||||
};
|
};
|
||||||
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
|
}, [dragState, previewDimensions, canvasSize, updateTextElement]);
|
||||||
|
|
||||||
|
// Invalidate cache when timeline changes
|
||||||
|
useEffect(() => {
|
||||||
|
invalidateCache();
|
||||||
|
}, [
|
||||||
|
tracks,
|
||||||
|
mediaFiles,
|
||||||
|
activeProject?.backgroundColor,
|
||||||
|
activeProject?.backgroundType,
|
||||||
|
invalidateCache,
|
||||||
|
]);
|
||||||
|
|
||||||
const handleTextMouseDown = (
|
const handleTextMouseDown = (
|
||||||
e: React.MouseEvent<HTMLDivElement>,
|
e: React.MouseEvent<HTMLDivElement>,
|
||||||
element: any,
|
element: any,
|
||||||
@@ -449,7 +463,7 @@ export function PreviewPanel() {
|
|||||||
};
|
};
|
||||||
}, [isPlaying, volume, muted, mediaFiles]);
|
}, [isPlaying, volume, muted, mediaFiles]);
|
||||||
|
|
||||||
// Canvas: draw current frame for visible elements using offscreen compositing
|
// Canvas: draw current frame with caching
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const draw = async () => {
|
const draw = async () => {
|
||||||
const canvas = canvasRef.current;
|
const canvas = canvasRef.current;
|
||||||
@@ -475,10 +489,86 @@ export function PreviewPanel() {
|
|||||||
lastFrameTimeRef.current = currentTime;
|
lastFrameTimeRef.current = currentTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invalidate older async renders when user scrubs rapidly
|
const cachedFrame = getCachedFrame(
|
||||||
const mySeq = (renderSeqRef.current += 1);
|
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) {
|
if (!offscreenCanvasRef.current) {
|
||||||
const hasOffscreen =
|
const hasOffscreen =
|
||||||
typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
|
typeof (globalThis as unknown as { OffscreenCanvas?: unknown })
|
||||||
@@ -541,6 +631,14 @@ export function PreviewPanel() {
|
|||||||
projectCanvasSize: canvasSize,
|
projectCanvasSize: canvasSize,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const imageData = (offCtx as CanvasRenderingContext2D).getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
displayWidth,
|
||||||
|
displayHeight
|
||||||
|
);
|
||||||
|
cacheFrame(currentTime, imageData, tracks, mediaFiles, activeProject);
|
||||||
|
|
||||||
// Blit offscreen to visible canvas
|
// Blit offscreen to visible canvas
|
||||||
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
|
mainCtx.clearRect(0, 0, displayWidth, displayHeight);
|
||||||
if ((offscreenCanvas as HTMLCanvasElement).getContext) {
|
if ((offscreenCanvas as HTMLCanvasElement).getContext) {
|
||||||
@@ -564,6 +662,10 @@ export function PreviewPanel() {
|
|||||||
canvasSize.height,
|
canvasSize.height,
|
||||||
activeProject?.backgroundType,
|
activeProject?.backgroundType,
|
||||||
activeProject?.backgroundColor,
|
activeProject?.backgroundColor,
|
||||||
|
getCachedFrame,
|
||||||
|
cacheFrame,
|
||||||
|
preRenderNearbyFrames,
|
||||||
|
isPlaying,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// Get media elements for blur background (video/image only)
|
// 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 { SnapIndicator } from "../snap-indicator";
|
||||||
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
import { SnapPoint } from "@/hooks/use-timeline-snapping";
|
||||||
import type { DragData, TimelineTrack, TrackType } from "@/types/timeline";
|
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 {
|
import {
|
||||||
getTrackHeight,
|
getTrackHeight,
|
||||||
getCumulativeHeightBefore,
|
getCumulativeHeightBefore,
|
||||||
@@ -85,6 +88,7 @@ export function Timeline() {
|
|||||||
const { mediaFiles, addMediaFile } = useMediaStore();
|
const { mediaFiles, addMediaFile } = useMediaStore();
|
||||||
const { activeProject } = useProjectStore();
|
const { activeProject } = useProjectStore();
|
||||||
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
|
const { currentTime, duration, seek, setDuration } = usePlaybackStore();
|
||||||
|
const { getRenderStatus } = useFrameCache();
|
||||||
const [isDragOver, setIsDragOver] = useState(false);
|
const [isDragOver, setIsDragOver] = useState(false);
|
||||||
const { addElementToNewTrack } = useTimelineStore();
|
const { addElementToNewTrack } = useTimelineStore();
|
||||||
const dragCounterRef = useRef(0);
|
const dragCounterRef = useRef(0);
|
||||||
@@ -637,7 +641,7 @@ export function Timeline() {
|
|||||||
{/* Timeline Header with Ruler */}
|
{/* Timeline Header with Ruler */}
|
||||||
<div className="flex bg-panel sticky top-0 z-10">
|
<div className="flex bg-panel sticky top-0 z-10">
|
||||||
{/* Track Labels Header */}
|
{/* 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 */}
|
{/* Empty space */}
|
||||||
<span className="text-sm font-medium text-muted-foreground opacity-0">
|
<span className="text-sm font-medium text-muted-foreground opacity-0">
|
||||||
.
|
.
|
||||||
@@ -658,7 +662,21 @@ export function Timeline() {
|
|||||||
onClick={handleTimelineContentClick}
|
onClick={handleTimelineContentClick}
|
||||||
data-ruler-area
|
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
|
<div
|
||||||
ref={rulerRef}
|
ref={rulerRef}
|
||||||
className="relative h-10 select-none cursor-default"
|
className="relative h-10 select-none cursor-default"
|
||||||
@@ -667,6 +685,14 @@ export function Timeline() {
|
|||||||
}}
|
}}
|
||||||
onMouseDown={handleRulerMouseDown}
|
onMouseDown={handleRulerMouseDown}
|
||||||
>
|
>
|
||||||
|
<TimelineCacheIndicator
|
||||||
|
duration={duration}
|
||||||
|
zoomLevel={zoomLevel}
|
||||||
|
tracks={tracks}
|
||||||
|
mediaFiles={mediaFiles}
|
||||||
|
activeProject={activeProject}
|
||||||
|
getRenderStatus={getRenderStatus}
|
||||||
|
/>
|
||||||
{/* Time markers */}
|
{/* Time markers */}
|
||||||
{(() => {
|
{(() => {
|
||||||
// Calculate appropriate time interval based on zoom level
|
// Calculate appropriate time interval based on zoom level
|
||||||
@@ -693,55 +719,13 @@ export function Timeline() {
|
|||||||
time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
|
time % (interval >= 1 ? Math.max(1, interval) : 1) === 0;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<TimelineMarker
|
||||||
key={i}
|
key={i}
|
||||||
className={`absolute top-0 h-4 ${
|
time={time}
|
||||||
isMainMarker
|
zoomLevel={zoomLevel}
|
||||||
? "border-l border-muted-foreground/40"
|
interval={interval}
|
||||||
: "border-l border-muted-foreground/20"
|
isMainMarker={isMainMarker}
|
||||||
}`}
|
/>
|
||||||
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>
|
|
||||||
);
|
);
|
||||||
}).filter(Boolean);
|
}).filter(Boolean);
|
||||||
})()}
|
})()}
|
||||||
@@ -836,7 +820,21 @@ export function Timeline() {
|
|||||||
containerRef={tracksContainerRef}
|
containerRef={tracksContainerRef}
|
||||||
isActive={selectionBox?.isActive || false}
|
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
|
<div
|
||||||
className="relative flex-1"
|
className="relative flex-1"
|
||||||
style={{
|
style={{
|
||||||
@@ -1085,7 +1083,7 @@ function TimelineToolbar({
|
|||||||
|
|
||||||
const currentBookmarked = isBookmarked(currentTime);
|
const currentBookmarked = isBookmarked(currentTime);
|
||||||
return (
|
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">
|
<div className="flex items-center gap-1 w-full">
|
||||||
<TooltipProvider delayDuration={500}>
|
<TooltipProvider delayDuration={500}>
|
||||||
<Tooltip>
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
+13
-10
@@ -16,6 +16,8 @@ import { useMediaStore } from "@/stores/media-store";
|
|||||||
import { useProjectStore } from "@/stores/project-store";
|
import { useProjectStore } from "@/stores/project-store";
|
||||||
import { DEFAULT_FPS } from "@/stores/project-store";
|
import { DEFAULT_FPS } from "@/stores/project-store";
|
||||||
import { ExportOptions, ExportResult } from "@/types/export";
|
import { ExportOptions, ExportResult } from "@/types/export";
|
||||||
|
import { TimelineTrack } from "@/types/timeline";
|
||||||
|
import { MediaFile } from "@/types/media";
|
||||||
|
|
||||||
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
export const DEFAULT_EXPORT_OPTIONS: ExportOptions = {
|
||||||
format: "mp4",
|
format: "mp4",
|
||||||
@@ -40,8 +42,8 @@ interface AudioElement {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function createTimelineAudioBuffer(
|
async function createTimelineAudioBuffer(
|
||||||
tracks: any[],
|
tracks: TimelineTrack[],
|
||||||
mediaFiles: any[],
|
mediaFiles: MediaFile[],
|
||||||
duration: number,
|
duration: number,
|
||||||
sampleRate: number = 44100
|
sampleRate: number = 44100
|
||||||
): Promise<AudioBuffer | null> {
|
): Promise<AudioBuffer | null> {
|
||||||
@@ -51,7 +53,7 @@ async function createTimelineAudioBuffer(
|
|||||||
|
|
||||||
// Collect all audio elements from timeline
|
// Collect all audio elements from timeline
|
||||||
const audioElements: AudioElement[] = [];
|
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) {
|
for (const track of tracks) {
|
||||||
if (track.muted) continue;
|
if (track.muted) continue;
|
||||||
@@ -59,11 +61,12 @@ async function createTimelineAudioBuffer(
|
|||||||
for (const element of track.elements) {
|
for (const element of track.elements) {
|
||||||
if (element.type !== "media") continue;
|
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;
|
if (!mediaItem || mediaItem.type !== "audio") continue;
|
||||||
|
|
||||||
const visibleDuration =
|
const visibleDuration =
|
||||||
element.duration - element.trimStart - element.trimEnd;
|
mediaElement.duration - mediaElement.trimStart - mediaElement.trimEnd;
|
||||||
if (visibleDuration <= 0) continue;
|
if (visibleDuration <= 0) continue;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -75,11 +78,11 @@ async function createTimelineAudioBuffer(
|
|||||||
|
|
||||||
audioElements.push({
|
audioElements.push({
|
||||||
buffer: audioBuffer,
|
buffer: audioBuffer,
|
||||||
startTime: element.startTime,
|
startTime: mediaElement.startTime,
|
||||||
duration: element.duration,
|
duration: mediaElement.duration,
|
||||||
trimStart: element.trimStart,
|
trimStart: mediaElement.trimStart,
|
||||||
trimEnd: element.trimEnd,
|
trimEnd: mediaElement.trimEnd,
|
||||||
muted: element.muted || track.muted || false,
|
muted: mediaElement.muted || track.muted || false,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
console.warn(`Failed to decode audio file ${mediaItem.name}:`, error);
|
||||||
|
|||||||
Reference in New Issue
Block a user