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,
} 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))
)}
+112 -62
View File
@@ -15,10 +15,15 @@ function splitBackgroundLayers(input: string): string[] {
return layers;
}
function extractColorFromStop(stop: string): string {
function parseColorStop(stop: string): { color: string; position?: number } {
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)) {
let depth = 0;
for (let i = 0; i < s.length; i += 1) {
@@ -27,33 +32,52 @@ function extractColorFromStop(stop: string): string {
else if (ch === ")") {
depth -= 1;
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(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
layer: string
): void {
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;
y1 = 0; // default: to right
if (dir.endsWith("deg")) {
const deg = parseFloat(dir);
const rad = ((90 - deg) * Math.PI) / 180;
const rad = (deg * Math.PI) / 180;
const cx = width / 2;
const cy = height / 2;
const r = Math.hypot(width, height) / 2;
@@ -85,63 +109,53 @@ function drawLinearGradient(
y1 = 0;
}
} else {
// No direction specified, treat as first color
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);
return { x0, y0, x1, y1, colors: parts };
}
function drawRadialGradient(
ctx: CanvasRenderingContext2D,
width: number,
height: number,
layer: string
): void {
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 [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);
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);
}
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);
// 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(
@@ -151,19 +165,55 @@ export function drawCssBackground(
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(")) {
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(")) {
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 (
layer.startsWith("#") ||
layer.startsWith("rgb") ||
layer.startsWith("hsl")
layer.startsWith("hsl") ||
layer === "transparent" ||
layer === "white" ||
layer === "black"
) {
ctx.fillStyle = layer;
ctx.fillRect(0, 0, width, height);
if (layer !== "transparent") {
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);
}
// 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;