bunch of small improvements

This commit is contained in:
Maze Winther 2025-08-31 19:57:50 +02:00
parent 46a74b30ba
commit 2c5d5dc234
8 changed files with 750 additions and 655 deletions

View File

@ -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>
</>
)}
<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>
{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
variant="outline"
className="rounded-md w-full"
onClick={() => {}}
>
Cancel
</Button>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button
variant="outline"
className="rounded-md w-full"
onClick={() => {}}
>
Cancel
</Button>
)}
</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>
</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>
);
}

View File

@ -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}

View File

@ -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>
) : (

View File

@ -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))
)}

View File

@ -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)",
];

View File

@ -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,
};
}

View File

@ -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);
}
}
}
}

View File

@ -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;