2025-08-27 00:52:39 +02:00
|
|
|
"use client";
|
|
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
import { useState, useRef } from "react";
|
|
|
|
|
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
|
|
|
|
import { HugeiconsIcon } from "@hugeicons/react";
|
2025-08-27 00:52:39 +02:00
|
|
|
import {
|
2026-01-31 00:20:04 +01:00
|
|
|
Popover,
|
|
|
|
|
PopoverContent,
|
|
|
|
|
PopoverTrigger,
|
|
|
|
|
} from "@/components/ui/popover";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
|
|
|
|
import { Label } from "@/components/ui/label";
|
|
|
|
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
|
|
|
|
import { Progress } from "@/components/ui/progress";
|
|
|
|
|
import { Checkbox } from "@/components/ui/checkbox";
|
|
|
|
|
import { cn } from "@/utils/ui";
|
|
|
|
|
import { getExportMimeType, getExportFileExtension } from "@/lib/export";
|
2026-02-10 16:28:21 +01:00
|
|
|
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
2026-01-31 00:20:04 +01:00
|
|
|
import {
|
|
|
|
|
EXPORT_FORMAT_VALUES,
|
|
|
|
|
EXPORT_QUALITY_VALUES,
|
|
|
|
|
type ExportFormat,
|
|
|
|
|
type ExportQuality,
|
|
|
|
|
type ExportResult,
|
|
|
|
|
} from "@/types/export";
|
|
|
|
|
import { PropertyGroup } from "@/components/editor/panels/properties/property-item";
|
|
|
|
|
import { useEditor } from "@/hooks/use-editor";
|
|
|
|
|
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
|
2025-08-27 00:52:39 +02:00
|
|
|
|
|
|
|
|
export function ExportButton() {
|
2026-01-31 00:20:04 +01:00
|
|
|
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
|
|
|
|
const editor = useEditor();
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const handleExport = () => {
|
|
|
|
|
setIsExportPopoverOpen(true);
|
|
|
|
|
};
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const hasProject = !!editor.project.getActive();
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
return (
|
|
|
|
|
<Popover open={isExportPopoverOpen} onOpenChange={setIsExportPopoverOpen}>
|
|
|
|
|
<PopoverTrigger asChild>
|
|
|
|
|
<button
|
|
|
|
|
type="button"
|
|
|
|
|
className={cn(
|
|
|
|
|
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
2026-02-10 16:28:21 +01:00
|
|
|
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
2026-01-31 00:20:04 +01:00
|
|
|
)}
|
|
|
|
|
onClick={hasProject ? handleExport : undefined}
|
|
|
|
|
disabled={!hasProject}
|
|
|
|
|
onKeyDown={(event) => {
|
|
|
|
|
if (hasProject && (event.key === "Enter" || event.key === " ")) {
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
handleExport();
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<div className="relative flex items-center gap-1.5 rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
|
|
|
|
|
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-4" />
|
|
|
|
|
<span className="z-50 text-[0.875rem]">Export</span>
|
|
|
|
|
<div className="absolute top-0 left-0 z-10 flex size-full items-center justify-center rounded-[0.6rem] bg-linear-to-t from-white/0 to-white/50">
|
|
|
|
|
<div className="absolute top-[0.08rem] z-50 h-[calc(100%-2px)] w-[calc(100%-2px)] rounded-[0.6rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</button>
|
|
|
|
|
</PopoverTrigger>
|
|
|
|
|
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
|
|
|
|
|
</Popover>
|
|
|
|
|
);
|
2025-08-27 00:52:39 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function ExportPopover({
|
2026-01-31 00:20:04 +01:00
|
|
|
onOpenChange,
|
2025-08-27 00:52:39 +02:00
|
|
|
}: {
|
2026-01-31 00:20:04 +01:00
|
|
|
onOpenChange: (open: boolean) => void;
|
2025-08-27 00:52:39 +02:00
|
|
|
}) {
|
2026-01-31 00:20:04 +01:00
|
|
|
const editor = useEditor();
|
|
|
|
|
const activeProject = editor.project.getActive();
|
|
|
|
|
const [format, setFormat] = useState<ExportFormat>(
|
|
|
|
|
DEFAULT_EXPORT_OPTIONS.format,
|
|
|
|
|
);
|
|
|
|
|
const [quality, setQuality] = useState<ExportQuality>(
|
|
|
|
|
DEFAULT_EXPORT_OPTIONS.quality,
|
|
|
|
|
);
|
|
|
|
|
const [includeAudio, setIncludeAudio] = useState<boolean>(
|
|
|
|
|
DEFAULT_EXPORT_OPTIONS.includeAudio || true,
|
|
|
|
|
);
|
|
|
|
|
const [isExporting, setIsExporting] = useState(false);
|
|
|
|
|
const [progress, setProgress] = useState(0);
|
|
|
|
|
const [exportResult, setExportResult] = useState<ExportResult | null>(null);
|
|
|
|
|
const cancelRequestedRef = useRef(false);
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const handleExport = async () => {
|
|
|
|
|
if (!activeProject) return;
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
cancelRequestedRef.current = false;
|
|
|
|
|
setIsExporting(true);
|
|
|
|
|
setProgress(0);
|
|
|
|
|
setExportResult(null);
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const result = await editor.project.export({
|
|
|
|
|
options: {
|
|
|
|
|
format,
|
|
|
|
|
quality,
|
|
|
|
|
fps: activeProject.settings.fps,
|
|
|
|
|
includeAudio,
|
|
|
|
|
onProgress: ({ progress }) => setProgress(progress),
|
|
|
|
|
onCancel: () => cancelRequestedRef.current,
|
|
|
|
|
},
|
|
|
|
|
});
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
setIsExporting(false);
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
if (result.cancelled) {
|
|
|
|
|
setExportResult(null);
|
|
|
|
|
setProgress(0);
|
|
|
|
|
return;
|
|
|
|
|
}
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
setExportResult(result);
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
if (result.success && result.buffer) {
|
|
|
|
|
const mimeType = getExportMimeType({ format });
|
|
|
|
|
const extension = getExportFileExtension({ format });
|
|
|
|
|
const blob = new Blob([result.buffer], { type: mimeType });
|
|
|
|
|
const url = URL.createObjectURL(blob);
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const a = document.createElement("a");
|
|
|
|
|
a.href = url;
|
|
|
|
|
a.download = `${activeProject.metadata.name}${extension}`;
|
|
|
|
|
document.body.appendChild(a);
|
|
|
|
|
a.click();
|
|
|
|
|
document.body.removeChild(a);
|
|
|
|
|
URL.revokeObjectURL(url);
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
onOpenChange(false);
|
|
|
|
|
setExportResult(null);
|
|
|
|
|
setProgress(0);
|
|
|
|
|
}
|
|
|
|
|
};
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const handleCancel = () => {
|
|
|
|
|
cancelRequestedRef.current = true;
|
|
|
|
|
};
|
2025-08-28 21:52:03 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
return (
|
2026-02-10 16:28:21 +01:00
|
|
|
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
|
2026-01-31 00:20:04 +01:00
|
|
|
{exportResult && !exportResult.success ? (
|
|
|
|
|
<ExportError
|
|
|
|
|
error={exportResult.error || "Unknown error occurred"}
|
|
|
|
|
onRetry={handleExport}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
2026-02-10 16:28:21 +01:00
|
|
|
<div className="flex items-center justify-between p-3 border-b">
|
|
|
|
|
<h3 className="font-medium text-sm">
|
2026-01-31 00:20:04 +01:00
|
|
|
{isExporting ? "Exporting project" : "Export project"}
|
|
|
|
|
</h3>
|
|
|
|
|
</div>
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
<div className="flex flex-col gap-4">
|
|
|
|
|
{!isExporting && (
|
|
|
|
|
<>
|
2026-02-10 16:28:21 +01:00
|
|
|
<div className="flex flex-col">
|
2026-01-31 00:20:04 +01:00
|
|
|
<PropertyGroup
|
|
|
|
|
title="Format"
|
|
|
|
|
defaultExpanded={false}
|
2026-02-10 16:28:21 +01:00
|
|
|
hasBorderTop={false}
|
2026-01-31 00:20:04 +01:00
|
|
|
>
|
|
|
|
|
<RadioGroup
|
|
|
|
|
value={format}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
if (isExportFormat(value)) {
|
|
|
|
|
setFormat(value);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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>
|
2025-08-27 00:52:39 +02:00
|
|
|
|
2026-02-10 16:28:21 +01:00
|
|
|
<PropertyGroup title="Quality" defaultExpanded={false}>
|
2026-01-31 00:20:04 +01:00
|
|
|
<RadioGroup
|
|
|
|
|
value={quality}
|
|
|
|
|
onValueChange={(value) => {
|
|
|
|
|
if (isExportQuality(value)) {
|
|
|
|
|
setQuality(value);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
<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>
|
2025-08-31 19:57:50 +02:00
|
|
|
|
2026-02-10 16:28:21 +01:00
|
|
|
<PropertyGroup title="Audio" defaultExpanded={false}>
|
2026-01-31 00:20:04 +01:00
|
|
|
<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>
|
|
|
|
|
|
2026-02-10 16:28:21 +01:00
|
|
|
<div className="p-3 pt-0">
|
|
|
|
|
<Button onClick={handleExport} className="w-full gap-2">
|
|
|
|
|
<Download className="size-4" />
|
|
|
|
|
Export
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-01-31 00:20:04 +01:00
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{isExporting && (
|
2026-02-10 16:28:21 +01:00
|
|
|
<div className="space-y-4 p-3">
|
2026-01-31 00:20:04 +01:00
|
|
|
<div className="flex flex-col">
|
|
|
|
|
<div className="flex items-center justify-between text-center">
|
|
|
|
|
<p className="text-muted-foreground mb-2 text-sm">
|
|
|
|
|
{Math.round(progress * 100)}%
|
|
|
|
|
</p>
|
|
|
|
|
<p className="text-muted-foreground mb-2 text-sm">100%</p>
|
|
|
|
|
</div>
|
|
|
|
|
<Progress value={progress * 100} className="w-full" />
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="w-full rounded-md"
|
|
|
|
|
onClick={handleCancel}
|
|
|
|
|
>
|
|
|
|
|
Cancel
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</PopoverContent>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isExportFormat(value: string): value is ExportFormat {
|
|
|
|
|
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isExportQuality(value: string): value is ExportQuality {
|
|
|
|
|
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
|
2025-08-27 00:52:39 +02:00
|
|
|
}
|
2025-08-31 19:57:50 +02:00
|
|
|
|
|
|
|
|
function ExportError({
|
2026-01-31 00:20:04 +01:00
|
|
|
error,
|
|
|
|
|
onRetry,
|
2025-08-31 19:57:50 +02:00
|
|
|
}: {
|
2026-01-31 00:20:04 +01:00
|
|
|
error: string;
|
|
|
|
|
onRetry: () => void;
|
2025-08-31 19:57:50 +02:00
|
|
|
}) {
|
2026-01-31 00:20:04 +01:00
|
|
|
const [copied, setCopied] = useState(false);
|
2025-08-31 19:57:50 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
const handleCopy = async () => {
|
|
|
|
|
await navigator.clipboard.writeText(error);
|
|
|
|
|
setCopied(true);
|
|
|
|
|
setTimeout(() => setCopied(false), 1000);
|
|
|
|
|
};
|
2025-08-31 19:57:50 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
return (
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="flex flex-col gap-1.5">
|
|
|
|
|
<p className="text-destructive text-sm font-medium">Export failed</p>
|
|
|
|
|
<p className="text-muted-foreground text-xs">{error}</p>
|
|
|
|
|
</div>
|
2025-08-31 19:57:50 +02:00
|
|
|
|
2026-01-31 00:20:04 +01:00
|
|
|
<div className="flex gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 flex-1 text-xs"
|
|
|
|
|
onClick={handleCopy}
|
|
|
|
|
>
|
|
|
|
|
{copied ? <Check className="text-constructive" /> : <Copy />}
|
|
|
|
|
Copy
|
|
|
|
|
</Button>
|
|
|
|
|
<Button
|
|
|
|
|
variant="outline"
|
|
|
|
|
size="sm"
|
|
|
|
|
className="h-8 flex-1 text-xs"
|
|
|
|
|
onClick={onRetry}
|
|
|
|
|
>
|
|
|
|
|
<RotateCcw />
|
|
|
|
|
Retry
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-08-31 19:57:50 +02:00
|
|
|
}
|