summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--apps/web/src/components/editor/export-button.tsx291
-rw-r--r--apps/web/src/components/editor/media-panel/views/captions.tsx2
-rw-r--r--apps/web/src/components/editor/panel-base-view.tsx5
-rw-r--r--apps/web/src/components/editor/preview-panel.tsx7
-rw-r--r--apps/web/src/data/colors/syntax-ui.tsx64
-rw-r--r--apps/web/src/hooks/use-frame-cache.ts640
-rw-r--r--apps/web/src/lib/canvas-gradients.ts388
-rw-r--r--apps/web/src/lib/timeline-renderer.ts8
8 files changed, 750 insertions, 655 deletions
diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx
index 41a5f65c..246e8b6f 100644
--- a/apps/web/src/components/editor/export-button.tsx
+++ b/apps/web/src/components/editor/export-button.tsx
@@ -16,7 +16,7 @@ import {
DEFAULT_EXPORT_OPTIONS,
} from "@/lib/export";
import { useProjectStore } from "@/stores/project-store";
-import { Download, X } from "lucide-react";
+import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
@@ -134,135 +134,184 @@ function ExportPopover({
return (
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
<>
- <div className="flex items-center justify-between">
- <h3 className=" font-medium">
- {isExporting ? "Exporting project" : "Export project"}
- </h3>
- <Button variant="text" size="icon" onClick={handleClose}>
- <X className="!size-5 text-foreground/85" />
- </Button>
- </div>
+ {exportResult && !exportResult.success ? (
+ <ExportError
+ error={exportResult.error || "Unknown error occurred"}
+ onRetry={handleExport}
+ />
+ ) : (
+ <>
+ <div className="flex items-center justify-between">
+ <h3 className=" font-medium">
+ {isExporting ? "Exporting project" : "Export project"}
+ </h3>
+ <Button variant="text" size="icon" onClick={handleClose}>
+ <X className="!size-5 text-foreground/85" />
+ </Button>
+ </div>
- <div className="flex flex-col gap-4">
- {!isExporting && (
- <>
- <div className="flex flex-col gap-3">
- <PropertyGroup
- title="Format"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={format}
- onValueChange={(value) => setFormat(value as ExportFormat)}
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="mp4" id="mp4" />
- <Label htmlFor="mp4">
- MP4 (H.264) - Better compatibility
- </Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="webm" id="webm" />
- <Label htmlFor="webm">
- WebM (VP9) - Smaller file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <div className="flex flex-col gap-4">
+ {!isExporting && (
+ <>
+ <div className="flex flex-col gap-3">
+ <PropertyGroup
+ title="Format"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={format}
+ onValueChange={(value) =>
+ setFormat(value as ExportFormat)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="mp4" id="mp4" />
+ <Label htmlFor="mp4">
+ MP4 (H.264) - Better compatibility
+ </Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="webm" id="webm" />
+ <Label htmlFor="webm">
+ WebM (VP9) - Smaller file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
- <PropertyGroup
- title="Quality"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <RadioGroup
- value={quality}
- onValueChange={(value) =>
- setQuality(value as ExportQuality)
- }
- >
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="low" id="low" />
- <Label htmlFor="low">Low - Smallest file size</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="medium" id="medium" />
- <Label htmlFor="medium">Medium - Balanced</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="high" id="high" />
- <Label htmlFor="high">High - Recommended</Label>
- </div>
- <div className="flex items-center space-x-2">
- <RadioGroupItem value="very_high" id="very_high" />
- <Label htmlFor="very_high">
- Very High - Largest file size
- </Label>
- </div>
- </RadioGroup>
- </PropertyGroup>
+ <PropertyGroup
+ title="Quality"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <RadioGroup
+ value={quality}
+ onValueChange={(value) =>
+ setQuality(value as ExportQuality)
+ }
+ >
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="low" id="low" />
+ <Label htmlFor="low">Low - Smallest file size</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="medium" id="medium" />
+ <Label htmlFor="medium">Medium - Balanced</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="high" id="high" />
+ <Label htmlFor="high">High - Recommended</Label>
+ </div>
+ <div className="flex items-center space-x-2">
+ <RadioGroupItem value="very_high" id="very_high" />
+ <Label htmlFor="very_high">
+ Very High - Largest file size
+ </Label>
+ </div>
+ </RadioGroup>
+ </PropertyGroup>
- <PropertyGroup
- title="Audio"
- titleClassName="text-sm"
- defaultExpanded={false}
- >
- <div className="flex items-center space-x-2">
- <Checkbox
- id="include-audio"
- checked={includeAudio}
- onCheckedChange={(checked) => setIncludeAudio(!!checked)}
- />
- <Label htmlFor="include-audio">
- Include audio in export
- </Label>
+ <PropertyGroup
+ title="Audio"
+ titleClassName="text-sm"
+ defaultExpanded={false}
+ >
+ <div className="flex items-center space-x-2">
+ <Checkbox
+ id="include-audio"
+ checked={includeAudio}
+ onCheckedChange={(checked) =>
+ setIncludeAudio(!!checked)
+ }
+ />
+ <Label htmlFor="include-audio">
+ Include audio in export
+ </Label>
+ </div>
+ </PropertyGroup>
</div>
- </PropertyGroup>
- </div>
- <Button onClick={handleExport} className="w-full gap-2">
- <Download className="w-4 h-4" />
- Export
- </Button>
- </>
- )}
-
- {isExporting && (
- <div className="space-y-4">
- <div className="flex flex-col">
- <div className="text-center flex items-center justify-between">
- <p className="text-sm text-muted-foreground mb-2">
- {Math.round(progress * 100)}%
- </p>
- <p className="text-sm text-muted-foreground mb-2">100%</p>
- </div>
- <Progress value={progress * 100} className="w-full" />
- </div>
+ <Button onClick={handleExport} className="w-full gap-2">
+ <Download className="w-4 h-4" />
+ Export
+ </Button>
+ </>
+ )}
- <Button
- variant="outline"
- className="rounded-md w-full"
- onClick={() => {}}
- >
- Cancel
- </Button>
- </div>
- )}
+ {isExporting && (
+ <div className="space-y-4">
+ <div className="flex flex-col">
+ <div className="text-center flex items-center justify-between">
+ <p className="text-sm text-muted-foreground mb-2">
+ {Math.round(progress * 100)}%
+ </p>
+ <p className="text-sm text-muted-foreground mb-2">100%</p>
+ </div>
+ <Progress value={progress * 100} className="w-full" />
+ </div>
- {exportResult && !exportResult.success && (
- <div className="text-center space-y-3">
- <div className="text-red-600 font-medium">Export failed</div>
- <p className="text-sm text-muted-foreground">
- {exportResult.error || "Unknown error occurred"}
- </p>
- <Button variant="outline" onClick={() => setExportResult(null)}>
- Try Again
- </Button>
+ <Button
+ variant="outline"
+ className="rounded-md w-full"
+ onClick={() => {}}
+ >
+ Cancel
+ </Button>
+ </div>
+ )}
</div>
- )}
- </div>
+ </>
+ )}
</>
</PopoverContent>
);
}
+
+function ExportError({
+ error,
+ onRetry,
+}: {
+ error: string;
+ onRetry: () => void;
+}) {
+ const [copied, setCopied] = useState(false);
+
+ const handleCopy = async () => {
+ await navigator.clipboard.writeText(error);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 1000);
+ };
+
+ return (
+ <div className="space-y-4">
+ <div className="flex flex-col gap-1.5">
+ <p className="text-sm font-medium text-red-400">Export failed</p>
+ <p className="text-xs text-muted-foreground">
+ {error}
+ </p>
+ </div>
+
+ <div className="flex gap-2">
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1 text-xs h-8"
+ onClick={handleCopy}
+ >
+ {copied ? <Check className="text-green-500" /> : <Copy />}
+ Copy
+ </Button>
+ <Button
+ variant="outline"
+ size="sm"
+ className="flex-1 text-xs h-8"
+ onClick={onRetry}
+ >
+ <RotateCcw />
+ Retry
+ </Button>
+ </div>
+ </div>
+ );
+}
diff --git a/apps/web/src/components/editor/media-panel/views/captions.tsx b/apps/web/src/components/editor/media-panel/views/captions.tsx
index 59403a8d..fbe5b418 100644
--- a/apps/web/src/components/editor/media-panel/views/captions.tsx
+++ b/apps/web/src/components/editor/media-panel/views/captions.tsx
@@ -188,7 +188,7 @@ export function Captions() {
};
return (
- <BaseView ref={containerRef} className="flex flex-col justify-between">
+ <BaseView ref={containerRef} className="flex flex-col justify-between h-full">
<PropertyGroup title="Language">
<LanguageSelect
selectedCountry={selectedCountry}
diff --git a/apps/web/src/components/editor/panel-base-view.tsx b/apps/web/src/components/editor/panel-base-view.tsx
index ec01028c..bef4e62c 100644
--- a/apps/web/src/components/editor/panel-base-view.tsx
+++ b/apps/web/src/components/editor/panel-base-view.tsx
@@ -1,6 +1,7 @@
import { ScrollArea } from "@/components/ui/scroll-area";
import { Separator } from "@/components/ui/separator";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
+import { cn } from "@/lib/utils";
interface PanelBaseViewProps {
children?: React.ReactNode;
@@ -25,7 +26,7 @@ function ViewContent({
}) {
return (
<ScrollArea className="flex-1">
- <div className={`p-5 h-full ${className}`}>{children}</div>
+ <div className={cn("p-5", className)}>{children}</div>
</ScrollArea>
);
}
@@ -40,7 +41,7 @@ export function PanelBaseView({
ref,
}: PanelBaseViewProps) {
return (
- <div className={`h-full flex flex-col ${className}`} ref={ref}>
+ <div className={cn("h-full flex flex-col", className)} ref={ref}>
{!tabs || tabs.length === 0 ? (
<ViewContent className={className}>{children}</ViewContent>
) : (
diff --git a/apps/web/src/components/editor/preview-panel.tsx b/apps/web/src/components/editor/preview-panel.tsx
index b7cc9578..8b1abd81 100644
--- a/apps/web/src/components/editor/preview-panel.tsx
+++ b/apps/web/src/components/editor/preview-panel.tsx
@@ -260,6 +260,7 @@ export function PreviewPanel() {
}, []);
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
+ const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
const getActiveElements = (): ActiveElement[] => {
const activeElements: ActiveElement[] = [];
@@ -701,7 +702,7 @@ export function PreviewPanel() {
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
>
<div className="flex-1" />
- {hasAnyElements ? (
+ {shouldRenderPreview ? (
<div
ref={previewRef}
className="relative overflow-hidden border"
@@ -727,9 +728,7 @@ export function PreviewPanel() {
aria-label="Video preview canvas"
/>
{activeElements.length === 0 ? (
- <div className="absolute inset-0 flex items-center justify-center text-muted-foreground">
- No elements at current time
- </div>
+ <></>
) : (
activeElements.map((elementData) => renderElement(elementData))
)}
diff --git a/apps/web/src/data/colors/syntax-ui.tsx b/apps/web/src/data/colors/syntax-ui.tsx
index 6a7677b4..50c7e7cc 100644
--- a/apps/web/src/data/colors/syntax-ui.tsx
+++ b/apps/web/src/data/colors/syntax-ui.tsx
@@ -1,32 +1,32 @@
-// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
-
-export const syntaxUIGradients = [
- // Cyan to Blue gradients
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
- "linear-gradient(to right, #bfdbfe, #a5f3fc)",
- "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
-
- // Purple gradients
- "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
- "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
-
- // Blue gradients
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
- "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
-
- // Green gradients
- "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
- "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
-
- // Red gradient
- "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
-
- // Yellow/Orange gradient
- "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
-
- // Pink gradient
- "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
-
- // Neon radial gradient
- "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
-];
+// These are the gradients from Syntax UI (https://syntaxui.com/effects/gradients)
+
+export const syntaxUIGradients = [
+ // Cyan to Blue gradients
+ "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
+ "linear-gradient(to right, #bfdbfe, #a5f3fc)",
+ "linear-gradient(to right, #22d3ee, #0ea5e9, #0284c7)",
+
+ // Purple gradients
+ "linear-gradient(to right, #e9d5ff, #d8b4fe, #c084fc)",
+ "linear-gradient(to right, #c4b5fd, #a78bfa, #8b5cf6)",
+
+ // Blue gradients
+ "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
+ "linear-gradient(to right, #93c5fd, #60a5fa, #3b82f6)",
+
+ // Green gradients
+ "linear-gradient(to right, #6ee7b7, #34d399, #10b981)",
+ "linear-gradient(to right, #d1fae5, #a7f3d0, #6ee7b7)",
+
+ // Red gradient
+ "linear-gradient(to right, #fca5a5, #f87171, #ef4444)",
+
+ // Yellow/Orange gradient
+ "linear-gradient(to right, #fde68a, #fbbf24, #f59e0b)",
+
+ // Pink gradient
+ "linear-gradient(to right, #fbcfe8, #f9a8d4, #f472b6)",
+
+ // Neon radial gradient
+ "radial-gradient(circle at bottom left, #ff00ff, #00ffff)",
+];
diff --git a/apps/web/src/hooks/use-frame-cache.ts b/apps/web/src/hooks/use-frame-cache.ts
index 8dc99966..2abfb912 100644
--- a/apps/web/src/hooks/use-frame-cache.ts
+++ b/apps/web/src/hooks/use-frame-cache.ts
@@ -1,320 +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,
- };
-}
+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/canvas-gradients.ts b/apps/web/src/lib/canvas-gradients.ts
index a3b93c02..7d1cdc05 100644
--- a/apps/web/src/lib/canvas-gradients.ts
+++ b/apps/web/src/lib/canvas-gradients.ts
@@ -1,169 +1,219 @@
-function splitBackgroundLayers(input: string): string[] {
- const layers: string[] = [];
- let depth = 0;
- let start = 0;
- for (let i = 0; i < input.length; i += 1) {
- const ch = input[i] as string;
- if (ch === "(") depth += 1;
- else if (ch === ")") depth -= 1;
- else if (ch === "," && depth === 0) {
- layers.push(input.slice(start, i).trim());
- start = i + 1;
- }
- }
- layers.push(input.slice(start).trim());
- return layers;
-}
-
-function extractColorFromStop(stop: string): string {
- const s = stop.trim();
- const funcs = ["rgba(", "rgb(", "hsla(", "hsl("] as const;
- for (const fn of funcs) {
- if (s.startsWith(fn)) {
- let depth = 0;
- for (let i = 0; i < s.length; i += 1) {
- const ch = s[i] as string;
- if (ch === "(") depth += 1;
- else if (ch === ")") {
- depth -= 1;
- if (depth === 0) {
- return s.slice(0, i + 1);
- }
- }
- }
- return s;
- }
- }
- const firstToken = s.split(" ")[0] as string;
- return firstToken;
-}
-
-function drawLinearGradient(
- ctx: CanvasRenderingContext2D,
- width: number,
- height: number,
- layer: string
-): void {
- const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
- const parts = splitBackgroundLayers(inside);
- const dir = (parts.shift() || "").trim();
- let x0 = 0,
- y0 = 0,
- x1 = width,
- y1 = 0;
- if (dir.endsWith("deg")) {
- const deg = parseFloat(dir);
- const rad = ((90 - deg) * Math.PI) / 180;
- const cx = width / 2;
- const cy = height / 2;
- const r = Math.hypot(width, height) / 2;
- x0 = cx - Math.cos(rad) * r;
- y0 = cy - Math.sin(rad) * r;
- x1 = cx + Math.cos(rad) * r;
- y1 = cy + Math.sin(rad) * r;
- } else if (dir.startsWith("to ")) {
- const d = dir.slice(3).trim();
- if (d === "right") {
- x0 = 0;
- y0 = 0;
- x1 = width;
- y1 = 0;
- } else if (d === "left") {
- x0 = width;
- y0 = 0;
- x1 = 0;
- y1 = 0;
- } else if (d === "bottom") {
- x0 = 0;
- y0 = 0;
- x1 = 0;
- y1 = height;
- } else if (d === "top") {
- x0 = 0;
- y0 = height;
- x1 = 0;
- y1 = 0;
- }
- } else {
- parts.unshift(dir);
- }
- const grad = ctx.createLinearGradient(x0, y0, x1, y1);
- const colorStops = parts;
- const n = Math.max(1, colorStops.length - 1);
- for (let i = 0; i < colorStops.length; i += 1) {
- const color = extractColorFromStop(colorStops[i] as string);
- grad.addColorStop(Math.min(1, Math.max(0, i / n)), color);
- }
- ctx.fillStyle = grad;
- ctx.fillRect(0, 0, width, height);
-}
-
-function drawRadialGradient(
- ctx: CanvasRenderingContext2D,
- width: number,
- height: number,
- layer: string
-): void {
- const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
- const parts = splitBackgroundLayers(inside);
- const first = (parts.shift() || "").trim();
- let cx = width / 2;
- let cy = height / 2;
- if (first.startsWith("circle at")) {
- const pos = first.replace("circle at", "").trim();
- const [px, py] = pos.split(" ");
- const parsePos = (p?: string, full?: number): number => {
- if (!p) return (full || 0) / 2;
- if (p.endsWith("%")) return (parseFloat(p) / 100) * (full || 0);
- if (p === "left") return 0;
- if (p === "right") return full || 0;
- if (p === "top") return 0;
- if (p === "bottom") return full || 0;
- if (p === "center") return (full || 0) / 2;
- return (full || 0) / 2;
- };
- if (px && py) {
- cx = parsePos(px, width);
- cy = parsePos(py, height);
- } else if (px) {
- cx = parsePos(px, width);
- cy = parsePos(px, height);
- }
- } else {
- parts.unshift(first);
- }
- const r = Math.hypot(width, height);
- const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
- const colorStops = parts;
- const n = Math.max(1, colorStops.length - 1);
- for (let i = 0; i < colorStops.length; i += 1) {
- const color = extractColorFromStop(colorStops[i] as string);
- grad.addColorStop(Math.min(1, Math.max(0, i / n)), color);
- }
- ctx.fillStyle = grad;
- ctx.fillRect(0, 0, width, height);
-}
-
-export function drawCssBackground(
- ctx: CanvasRenderingContext2D,
- width: number,
- height: number,
- css: string
-): void {
- const layers = splitBackgroundLayers(css).filter(Boolean);
- for (let i = layers.length - 1; i >= 0; i -= 1) {
- const layer = layers[i] as string;
- if (layer.startsWith("linear-gradient(")) {
- drawLinearGradient(ctx, width, height, layer);
- } else if (layer.startsWith("radial-gradient(")) {
- drawRadialGradient(ctx, width, height, layer);
- } else if (
- layer.startsWith("#") ||
- layer.startsWith("rgb") ||
- layer.startsWith("hsl")
- ) {
- ctx.fillStyle = layer;
- ctx.fillRect(0, 0, width, height);
- }
- }
-}
+function splitBackgroundLayers(input: string): string[] {
+ const layers: string[] = [];
+ let depth = 0;
+ let start = 0;
+ for (let i = 0; i < input.length; i += 1) {
+ const ch = input[i] as string;
+ if (ch === "(") depth += 1;
+ else if (ch === ")") depth -= 1;
+ else if (ch === "," && depth === 0) {
+ layers.push(input.slice(start, i).trim());
+ start = i + 1;
+ }
+ }
+ layers.push(input.slice(start).trim());
+ return layers;
+}
+
+function parseColorStop(stop: string): { color: string; position?: number } {
+ const s = stop.trim();
+
+ // Handle functional colors like rgba(), rgb(), hsla(), hsl()
+ const colorFunctions = ["rgba(", "rgb(", "hsla(", "hsl("];
+ let color = "";
+ let remaining = "";
+
+ for (const fn of colorFunctions) {
+ if (s.startsWith(fn)) {
+ let depth = 0;
+ for (let i = 0; i < s.length; i += 1) {
+ const ch = s[i] as string;
+ if (ch === "(") depth += 1;
+ else if (ch === ")") {
+ depth -= 1;
+ if (depth === 0) {
+ color = s.slice(0, i + 1);
+ remaining = s.slice(i + 1).trim();
+ break;
+ }
+ }
+ }
+ break;
+ }
+ }
+
+ if (!color) {
+ const parts = s.split(/\s+/);
+ color = parts[0] as string;
+ remaining = parts.slice(1).join(" ");
+ }
+
+ // Convert transparent to transparent white for better blending
+ if (color === "transparent") {
+ color = "rgba(255, 255, 255, 0)";
+ }
+
+ // Parse position if present
+ let position: number | undefined;
+ if (remaining) {
+ const posMatch = remaining.match(/(\d+(?:\.\d+)?)%/);
+ if (posMatch) {
+ position = parseFloat(posMatch[1] as string) / 100;
+ }
+ }
+
+ return { color, position };
+}
+
+function parseLinearGradient(layer: string, width: number, height: number) {
+ const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
+ const parts = splitBackgroundLayers(inside);
+ const dir = (parts.shift() || "").trim();
+
+ let x0 = 0,
+ y0 = 0,
+ x1 = width,
+ y1 = 0; // default: to right
+
+ if (dir.endsWith("deg")) {
+ const deg = parseFloat(dir);
+ const rad = (deg * Math.PI) / 180;
+ const cx = width / 2;
+ const cy = height / 2;
+ const r = Math.hypot(width, height) / 2;
+ x0 = cx - Math.cos(rad) * r;
+ y0 = cy - Math.sin(rad) * r;
+ x1 = cx + Math.cos(rad) * r;
+ y1 = cy + Math.sin(rad) * r;
+ } else if (dir.startsWith("to ")) {
+ const d = dir.slice(3).trim();
+ if (d === "right") {
+ x0 = 0;
+ y0 = 0;
+ x1 = width;
+ y1 = 0;
+ } else if (d === "left") {
+ x0 = width;
+ y0 = 0;
+ x1 = 0;
+ y1 = 0;
+ } else if (d === "bottom") {
+ x0 = 0;
+ y0 = 0;
+ x1 = 0;
+ y1 = height;
+ } else if (d === "top") {
+ x0 = 0;
+ y0 = height;
+ x1 = 0;
+ y1 = 0;
+ }
+ } else {
+ // No direction specified, treat as first color
+ parts.unshift(dir);
+ }
+
+ return { x0, y0, x1, y1, colors: parts };
+}
+
+function parseRadialGradient(layer: string, width: number, height: number) {
+ const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
+ const parts = splitBackgroundLayers(inside);
+ const first = (parts.shift() || "").trim();
+
+ let cx = width / 2;
+ let cy = height / 2;
+
+ if (first.startsWith("circle at")) {
+ const pos = first.replace("circle at", "").trim();
+ const coords = pos.split(/\s+/);
+
+ for (let i = 0; i < coords.length; i += 1) {
+ const coord = coords[i] as string;
+ if (coord.endsWith("%")) {
+ const val = parseFloat(coord) / 100;
+ if (i === 0) cx = val * width;
+ else if (i === 1) cy = val * height;
+ } else if (coord === "left") cx = 0;
+ else if (coord === "right") cx = width;
+ else if (coord === "top") cy = 0;
+ else if (coord === "bottom") cy = height;
+ else if (coord === "center") {
+ if (i === 0) cx = width / 2;
+ else if (i === 1) cy = height / 2;
+ }
+ }
+ } else {
+ parts.unshift(first);
+ }
+
+ // Use farthest-corner for radius
+ const r = Math.max(
+ Math.hypot(cx, cy),
+ Math.hypot(width - cx, cy),
+ Math.hypot(cx, height - cy),
+ Math.hypot(width - cx, height - cy)
+ );
+
+ return { cx, cy, r, colors: parts };
+}
+
+export function drawCssBackground(
+ ctx: CanvasRenderingContext2D,
+ width: number,
+ height: number,
+ css: string
+): void {
+ const layers = splitBackgroundLayers(css).filter(Boolean);
+
+ // Draw layers from last to first (CSS background order)
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
+ const layer = layers[i] as string;
+
+ if (layer.startsWith("linear-gradient(")) {
+ const { x0, y0, x1, y1, colors } = parseLinearGradient(
+ layer,
+ width,
+ height
+ );
+ const grad = ctx.createLinearGradient(x0, y0, x1, y1);
+ const colorStops = colors.map((c) => parseColorStop(c as string));
+
+ // Handle positions properly
+ for (let j = 0; j < colorStops.length; j += 1) {
+ const stop = colorStops[j] as { color: string; position?: number };
+ const pos = stop.position ?? j / Math.max(1, colorStops.length - 1);
+ grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color);
+ }
+
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, width, height);
+ } else if (layer.startsWith("radial-gradient(")) {
+ const { cx, cy, r, colors } = parseRadialGradient(layer, width, height);
+ const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
+ const colorStops = colors.map((c) => parseColorStop(c as string));
+
+ // Handle positions properly
+ for (let j = 0; j < colorStops.length; j += 1) {
+ const stop = colorStops[j] as { color: string; position?: number };
+ const pos = stop.position ?? j / Math.max(1, colorStops.length - 1);
+ grad.addColorStop(Math.max(0, Math.min(1, pos)), stop.color);
+ }
+
+ ctx.fillStyle = grad;
+ ctx.fillRect(0, 0, width, height);
+ } else if (
+ layer.startsWith("#") ||
+ layer.startsWith("rgb") ||
+ layer.startsWith("hsl") ||
+ layer === "transparent" ||
+ layer === "white" ||
+ layer === "black"
+ ) {
+ if (layer !== "transparent") {
+ ctx.fillStyle = layer;
+ ctx.fillRect(0, 0, width, height);
+ }
+ }
+ }
+}
diff --git a/apps/web/src/lib/timeline-renderer.ts b/apps/web/src/lib/timeline-renderer.ts
index 4d00129c..b2650b7c 100644
--- a/apps/web/src/lib/timeline-renderer.ts
+++ b/apps/web/src/lib/timeline-renderer.ts
@@ -58,13 +58,9 @@ export async function renderTimelineFrame({
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
}
- // If backgroundColor is a CSS gradient string, draw it using JS (no foreignObject) to avoid CORS tainting
+ // If backgroundColor is a CSS gradient string, draw it
if (backgroundColor && backgroundColor.includes("gradient")) {
- try {
- drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
- } catch {
- // best-effort; ignore failures
- }
+ drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
}
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;