mirror of
https://github.com/OpenCut-app/OpenCut.git
synced 2026-07-13 21:52:53 +02:00
bunch of small improvements
This commit is contained in:
@@ -16,7 +16,7 @@ import {
|
|||||||
DEFAULT_EXPORT_OPTIONS,
|
DEFAULT_EXPORT_OPTIONS,
|
||||||
} from "@/lib/export";
|
} from "@/lib/export";
|
||||||
import { useProjectStore } from "@/stores/project-store";
|
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 { ExportFormat, ExportQuality, ExportResult } from "@/types/export";
|
||||||
import { PropertyGroup } from "./properties-panel/property-item";
|
import { PropertyGroup } from "./properties-panel/property-item";
|
||||||
|
|
||||||
@@ -134,135 +134,184 @@ function ExportPopover({
|
|||||||
return (
|
return (
|
||||||
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
|
<PopoverContent className="w-80 mr-4 flex flex-col gap-3 bg-background">
|
||||||
<>
|
<>
|
||||||
<div className="flex items-center justify-between">
|
{exportResult && !exportResult.success ? (
|
||||||
<h3 className=" font-medium">
|
<ExportError
|
||||||
{isExporting ? "Exporting project" : "Export project"}
|
error={exportResult.error || "Unknown error occurred"}
|
||||||
</h3>
|
onRetry={handleExport}
|
||||||
<Button variant="text" size="icon" onClick={handleClose}>
|
/>
|
||||||
<X className="!size-5 text-foreground/85" />
|
) : (
|
||||||
</Button>
|
<>
|
||||||
</div>
|
<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">
|
<div className="flex flex-col gap-4">
|
||||||
{!isExporting && (
|
{!isExporting && (
|
||||||
<>
|
<>
|
||||||
<div className="flex flex-col gap-3">
|
<div className="flex flex-col gap-3">
|
||||||
<PropertyGroup
|
<PropertyGroup
|
||||||
title="Format"
|
title="Format"
|
||||||
titleClassName="text-sm"
|
titleClassName="text-sm"
|
||||||
defaultExpanded={false}
|
defaultExpanded={false}
|
||||||
>
|
>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={format}
|
value={format}
|
||||||
onValueChange={(value) => setFormat(value as ExportFormat)}
|
onValueChange={(value) =>
|
||||||
>
|
setFormat(value as ExportFormat)
|
||||||
<div className="flex items-center space-x-2">
|
}
|
||||||
<RadioGroupItem value="mp4" id="mp4" />
|
>
|
||||||
<Label htmlFor="mp4">
|
<div className="flex items-center space-x-2">
|
||||||
MP4 (H.264) - Better compatibility
|
<RadioGroupItem value="mp4" id="mp4" />
|
||||||
</Label>
|
<Label htmlFor="mp4">
|
||||||
</div>
|
MP4 (H.264) - Better compatibility
|
||||||
<div className="flex items-center space-x-2">
|
</Label>
|
||||||
<RadioGroupItem value="webm" id="webm" />
|
</div>
|
||||||
<Label htmlFor="webm">
|
<div className="flex items-center space-x-2">
|
||||||
WebM (VP9) - Smaller file size
|
<RadioGroupItem value="webm" id="webm" />
|
||||||
</Label>
|
<Label htmlFor="webm">
|
||||||
</div>
|
WebM (VP9) - Smaller file size
|
||||||
</RadioGroup>
|
</Label>
|
||||||
</PropertyGroup>
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup
|
<PropertyGroup
|
||||||
title="Quality"
|
title="Quality"
|
||||||
titleClassName="text-sm"
|
titleClassName="text-sm"
|
||||||
defaultExpanded={false}
|
defaultExpanded={false}
|
||||||
>
|
>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={quality}
|
value={quality}
|
||||||
onValueChange={(value) =>
|
onValueChange={(value) =>
|
||||||
setQuality(value as ExportQuality)
|
setQuality(value as ExportQuality)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="low" id="low" />
|
<RadioGroupItem value="low" id="low" />
|
||||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="medium" id="medium" />
|
<RadioGroupItem value="medium" id="medium" />
|
||||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="high" id="high" />
|
<RadioGroupItem value="high" id="high" />
|
||||||
<Label htmlFor="high">High - Recommended</Label>
|
<Label htmlFor="high">High - Recommended</Label>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<RadioGroupItem value="very_high" id="very_high" />
|
<RadioGroupItem value="very_high" id="very_high" />
|
||||||
<Label htmlFor="very_high">
|
<Label htmlFor="very_high">
|
||||||
Very High - Largest file size
|
Very High - Largest file size
|
||||||
</Label>
|
</Label>
|
||||||
</div>
|
</div>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<PropertyGroup
|
<PropertyGroup
|
||||||
title="Audio"
|
title="Audio"
|
||||||
titleClassName="text-sm"
|
titleClassName="text-sm"
|
||||||
defaultExpanded={false}
|
defaultExpanded={false}
|
||||||
>
|
>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex items-center space-x-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="include-audio"
|
id="include-audio"
|
||||||
checked={includeAudio}
|
checked={includeAudio}
|
||||||
onCheckedChange={(checked) => setIncludeAudio(!!checked)}
|
onCheckedChange={(checked) =>
|
||||||
/>
|
setIncludeAudio(!!checked)
|
||||||
<Label htmlFor="include-audio">
|
}
|
||||||
Include audio in export
|
/>
|
||||||
</Label>
|
<Label htmlFor="include-audio">
|
||||||
|
Include audio in export
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</PropertyGroup>
|
||||||
</div>
|
</div>
|
||||||
</PropertyGroup>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button onClick={handleExport} className="w-full gap-2">
|
<Button onClick={handleExport} className="w-full gap-2">
|
||||||
<Download className="w-4 h-4" />
|
<Download className="w-4 h-4" />
|
||||||
Export
|
Export
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isExporting && (
|
{isExporting && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
<div className="text-center flex items-center justify-between">
|
<div className="text-center flex items-center justify-between">
|
||||||
<p className="text-sm text-muted-foreground mb-2">
|
<p className="text-sm text-muted-foreground mb-2">
|
||||||
{Math.round(progress * 100)}%
|
{Math.round(progress * 100)}%
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-muted-foreground mb-2">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>
|
</div>
|
||||||
<Progress value={progress * 100} className="w-full" />
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
className="rounded-md w-full"
|
|
||||||
onClick={() => {}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
</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>
|
</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 (
|
return (
|
||||||
<BaseView ref={containerRef} className="flex flex-col justify-between">
|
<BaseView ref={containerRef} className="flex flex-col justify-between h-full">
|
||||||
<PropertyGroup title="Language">
|
<PropertyGroup title="Language">
|
||||||
<LanguageSelect
|
<LanguageSelect
|
||||||
selectedCountry={selectedCountry}
|
selectedCountry={selectedCountry}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ScrollArea } from "@/components/ui/scroll-area";
|
import { ScrollArea } from "@/components/ui/scroll-area";
|
||||||
import { Separator } from "@/components/ui/separator";
|
import { Separator } from "@/components/ui/separator";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
interface PanelBaseViewProps {
|
interface PanelBaseViewProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
@@ -25,7 +26,7 @@ function ViewContent({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<ScrollArea className="flex-1">
|
<ScrollArea className="flex-1">
|
||||||
<div className={`p-5 h-full ${className}`}>{children}</div>
|
<div className={cn("p-5", className)}>{children}</div>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -40,7 +41,7 @@ export function PanelBaseView({
|
|||||||
ref,
|
ref,
|
||||||
}: PanelBaseViewProps) {
|
}: PanelBaseViewProps) {
|
||||||
return (
|
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 ? (
|
{!tabs || tabs.length === 0 ? (
|
||||||
<ViewContent className={className}>{children}</ViewContent>
|
<ViewContent className={className}>{children}</ViewContent>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -260,6 +260,7 @@ export function PreviewPanel() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
|
const hasAnyElements = tracks.some((track) => track.elements.length > 0);
|
||||||
|
const shouldRenderPreview = hasAnyElements || activeProject?.backgroundType;
|
||||||
const getActiveElements = (): ActiveElement[] => {
|
const getActiveElements = (): ActiveElement[] => {
|
||||||
const activeElements: 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"
|
className="flex-1 flex flex-col items-center justify-center min-h-0 min-w-0"
|
||||||
>
|
>
|
||||||
<div className="flex-1" />
|
<div className="flex-1" />
|
||||||
{hasAnyElements ? (
|
{shouldRenderPreview ? (
|
||||||
<div
|
<div
|
||||||
ref={previewRef}
|
ref={previewRef}
|
||||||
className="relative overflow-hidden border"
|
className="relative overflow-hidden border"
|
||||||
@@ -727,9 +728,7 @@ export function PreviewPanel() {
|
|||||||
aria-label="Video preview canvas"
|
aria-label="Video preview canvas"
|
||||||
/>
|
/>
|
||||||
{activeElements.length === 0 ? (
|
{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))
|
activeElements.map((elementData) => renderElement(elementData))
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -15,10 +15,15 @@ function splitBackgroundLayers(input: string): string[] {
|
|||||||
return layers;
|
return layers;
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractColorFromStop(stop: string): string {
|
function parseColorStop(stop: string): { color: string; position?: number } {
|
||||||
const s = stop.trim();
|
const s = stop.trim();
|
||||||
const funcs = ["rgba(", "rgb(", "hsla(", "hsl("] as const;
|
|
||||||
for (const fn of funcs) {
|
// 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)) {
|
if (s.startsWith(fn)) {
|
||||||
let depth = 0;
|
let depth = 0;
|
||||||
for (let i = 0; i < s.length; i += 1) {
|
for (let i = 0; i < s.length; i += 1) {
|
||||||
@@ -27,33 +32,52 @@ function extractColorFromStop(stop: string): string {
|
|||||||
else if (ch === ")") {
|
else if (ch === ")") {
|
||||||
depth -= 1;
|
depth -= 1;
|
||||||
if (depth === 0) {
|
if (depth === 0) {
|
||||||
return s.slice(0, i + 1);
|
color = s.slice(0, i + 1);
|
||||||
|
remaining = s.slice(i + 1).trim();
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return s;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const firstToken = s.split(" ")[0] as string;
|
|
||||||
return firstToken;
|
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 drawLinearGradient(
|
function parseLinearGradient(layer: string, width: number, height: number) {
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
layer: string
|
|
||||||
): void {
|
|
||||||
const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
|
const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
|
||||||
const parts = splitBackgroundLayers(inside);
|
const parts = splitBackgroundLayers(inside);
|
||||||
const dir = (parts.shift() || "").trim();
|
const dir = (parts.shift() || "").trim();
|
||||||
|
|
||||||
let x0 = 0,
|
let x0 = 0,
|
||||||
y0 = 0,
|
y0 = 0,
|
||||||
x1 = width,
|
x1 = width,
|
||||||
y1 = 0;
|
y1 = 0; // default: to right
|
||||||
|
|
||||||
if (dir.endsWith("deg")) {
|
if (dir.endsWith("deg")) {
|
||||||
const deg = parseFloat(dir);
|
const deg = parseFloat(dir);
|
||||||
const rad = ((90 - deg) * Math.PI) / 180;
|
const rad = (deg * Math.PI) / 180;
|
||||||
const cx = width / 2;
|
const cx = width / 2;
|
||||||
const cy = height / 2;
|
const cy = height / 2;
|
||||||
const r = Math.hypot(width, height) / 2;
|
const r = Math.hypot(width, height) / 2;
|
||||||
@@ -85,63 +109,53 @@ function drawLinearGradient(
|
|||||||
y1 = 0;
|
y1 = 0;
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
// No direction specified, treat as first color
|
||||||
parts.unshift(dir);
|
parts.unshift(dir);
|
||||||
}
|
}
|
||||||
const grad = ctx.createLinearGradient(x0, y0, x1, y1);
|
|
||||||
const colorStops = parts;
|
return { x0, y0, x1, y1, colors: 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(
|
function parseRadialGradient(layer: string, width: number, height: number) {
|
||||||
ctx: CanvasRenderingContext2D,
|
|
||||||
width: number,
|
|
||||||
height: number,
|
|
||||||
layer: string
|
|
||||||
): void {
|
|
||||||
const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
|
const inside = layer.slice(layer.indexOf("(") + 1, layer.lastIndexOf(")"));
|
||||||
const parts = splitBackgroundLayers(inside);
|
const parts = splitBackgroundLayers(inside);
|
||||||
const first = (parts.shift() || "").trim();
|
const first = (parts.shift() || "").trim();
|
||||||
|
|
||||||
let cx = width / 2;
|
let cx = width / 2;
|
||||||
let cy = height / 2;
|
let cy = height / 2;
|
||||||
|
|
||||||
if (first.startsWith("circle at")) {
|
if (first.startsWith("circle at")) {
|
||||||
const pos = first.replace("circle at", "").trim();
|
const pos = first.replace("circle at", "").trim();
|
||||||
const [px, py] = pos.split(" ");
|
const coords = pos.split(/\s+/);
|
||||||
const parsePos = (p?: string, full?: number): number => {
|
|
||||||
if (!p) return (full || 0) / 2;
|
for (let i = 0; i < coords.length; i += 1) {
|
||||||
if (p.endsWith("%")) return (parseFloat(p) / 100) * (full || 0);
|
const coord = coords[i] as string;
|
||||||
if (p === "left") return 0;
|
if (coord.endsWith("%")) {
|
||||||
if (p === "right") return full || 0;
|
const val = parseFloat(coord) / 100;
|
||||||
if (p === "top") return 0;
|
if (i === 0) cx = val * width;
|
||||||
if (p === "bottom") return full || 0;
|
else if (i === 1) cy = val * height;
|
||||||
if (p === "center") return (full || 0) / 2;
|
} else if (coord === "left") cx = 0;
|
||||||
return (full || 0) / 2;
|
else if (coord === "right") cx = width;
|
||||||
};
|
else if (coord === "top") cy = 0;
|
||||||
if (px && py) {
|
else if (coord === "bottom") cy = height;
|
||||||
cx = parsePos(px, width);
|
else if (coord === "center") {
|
||||||
cy = parsePos(py, height);
|
if (i === 0) cx = width / 2;
|
||||||
} else if (px) {
|
else if (i === 1) cy = height / 2;
|
||||||
cx = parsePos(px, width);
|
}
|
||||||
cy = parsePos(px, height);
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
parts.unshift(first);
|
parts.unshift(first);
|
||||||
}
|
}
|
||||||
const r = Math.hypot(width, height);
|
|
||||||
const grad = ctx.createRadialGradient(cx, cy, 0, cx, cy, r);
|
// Use farthest-corner for radius
|
||||||
const colorStops = parts;
|
const r = Math.max(
|
||||||
const n = Math.max(1, colorStops.length - 1);
|
Math.hypot(cx, cy),
|
||||||
for (let i = 0; i < colorStops.length; i += 1) {
|
Math.hypot(width - cx, cy),
|
||||||
const color = extractColorFromStop(colorStops[i] as string);
|
Math.hypot(cx, height - cy),
|
||||||
grad.addColorStop(Math.min(1, Math.max(0, i / n)), color);
|
Math.hypot(width - cx, height - cy)
|
||||||
}
|
);
|
||||||
ctx.fillStyle = grad;
|
|
||||||
ctx.fillRect(0, 0, width, height);
|
return { cx, cy, r, colors: parts };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function drawCssBackground(
|
export function drawCssBackground(
|
||||||
@@ -151,19 +165,55 @@ export function drawCssBackground(
|
|||||||
css: string
|
css: string
|
||||||
): void {
|
): void {
|
||||||
const layers = splitBackgroundLayers(css).filter(Boolean);
|
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) {
|
for (let i = layers.length - 1; i >= 0; i -= 1) {
|
||||||
const layer = layers[i] as string;
|
const layer = layers[i] as string;
|
||||||
|
|
||||||
if (layer.startsWith("linear-gradient(")) {
|
if (layer.startsWith("linear-gradient(")) {
|
||||||
drawLinearGradient(ctx, width, height, layer);
|
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(")) {
|
} else if (layer.startsWith("radial-gradient(")) {
|
||||||
drawRadialGradient(ctx, width, height, layer);
|
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 (
|
} else if (
|
||||||
layer.startsWith("#") ||
|
layer.startsWith("#") ||
|
||||||
layer.startsWith("rgb") ||
|
layer.startsWith("rgb") ||
|
||||||
layer.startsWith("hsl")
|
layer.startsWith("hsl") ||
|
||||||
|
layer === "transparent" ||
|
||||||
|
layer === "white" ||
|
||||||
|
layer === "black"
|
||||||
) {
|
) {
|
||||||
ctx.fillStyle = layer;
|
if (layer !== "transparent") {
|
||||||
ctx.fillRect(0, 0, width, height);
|
ctx.fillStyle = layer;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -58,13 +58,9 @@ export async function renderTimelineFrame({
|
|||||||
ctx.fillRect(0, 0, canvasWidth, canvasHeight);
|
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")) {
|
if (backgroundColor && backgroundColor.includes("gradient")) {
|
||||||
try {
|
drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
|
||||||
drawCssBackground(ctx, canvasWidth, canvasHeight, backgroundColor);
|
|
||||||
} catch {
|
|
||||||
// best-effort; ignore failures
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
|
const scaleX = projectCanvasSize ? canvasWidth / projectCanvasSize.width : 1;
|
||||||
|
|||||||
Reference in New Issue
Block a user