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
+170 -121
View File
@@ -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))
)} )}
+112 -62
View File
@@ -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);
}
} }
} }
} }
+2 -6
View File
@@ -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;