bunch of small improvements
This commit is contained in:
parent
46a74b30ba
commit
2c5d5dc234
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
];
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue