feat: implement timeline caching with cache indicator
This commit is contained in:
parent
21dac9f09e
commit
e222f15d1d
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in New Issue