summaryrefslogtreecommitdiff
path: root/apps/web/src
diff options
context:
space:
mode:
Diffstat (limited to 'apps/web/src')
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx110
-rw-r--r--apps/web/src/components/editor/timeline/index.tsx102
-rw-r--r--apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx116
-rw-r--r--apps/web/src/components/editor/timeline/timeline-marker.tsx67
-rw-r--r--apps/web/src/hooks/use-frame-cache.ts320
-rw-r--r--apps/web/src/lib/export.ts23
6 files changed, 672 insertions, 66 deletions
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index 1654624c..c53cdafb 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -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);
+
+ // 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");
- // Offscreen buffer to avoid flicker (reuse canvas)
+ 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)
diff --git a/apps/web/src/components/editor/timeline/index.tsx b/apps/web/src/components/editor/timeline/index.tsx
index c994d707..90a19d61 100644
--- a/apps/web/src/components/editor/timeline/index.tsx
+++ b/apps/web/src/components/editor/timeline/index.tsx
@@ -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>
diff --git a/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
new file mode 100644
index 00000000..f6494738
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-cache-indicator.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/components/editor/timeline/timeline-marker.tsx b/apps/web/src/components/editor/timeline/timeline-marker.tsx
new file mode 100644
index 00000000..1351a457
--- /dev/null
+++ b/apps/web/src/components/editor/timeline/timeline-marker.tsx
@@ -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>
+ );
+}
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts
new file mode 100644
index 00000000..8dc99966
--- /dev/null
+++ b/apps/web/src/hooks/use-frame-cache.ts
@@ -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,
+ };
+}
diff --git a/apps/web/src/lib/export.ts b/apps/web/src/lib/export.ts
index f25f5f6b..2dd41855 100644
--- a/apps/web/src/lib/export.ts
+++ b/apps/web/src/lib/export.ts
@@ -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);