OpenCut/apps/web/src/components/editor/export-button.tsx

333 lines
11 KiB
TypeScript

"use client";
import { useState } from "react";
import { TransitionUpIcon } from "@opencut/ui/icons";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { Button } from "../ui/button";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Progress } from "../ui/progress";
import { Checkbox } from "../ui/checkbox";
import { cn } from "@/utils/ui";
import { getExportMimeType, getExportFileExtension } from "@/utils/export";
import { Check, Copy, Download, RotateCcw, X } from "lucide-react";
import {
EXPORT_FORMAT_VALUES,
EXPORT_QUALITY_VALUES,
ExportFormat,
ExportQuality,
ExportResult,
} from "@/types/export";
import { PropertyGroup } from "./properties-panel/property-item";
import { useEditor } from "@/hooks/use-editor";
import { DEFAULT_EXPORT_OPTIONS } from "@/constants/export-constants";
export function ExportButton() {
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
const editor = useEditor();
const handleExport = () => {
setIsExportPopoverOpen(true);
};
const hasProject = !!editor.project.getActive();
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 transition-all duration-200",
hasProject
? "cursor-pointer hover:brightness-95"
: "cursor-not-allowed opacity-50",
)}
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.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7] px-4 py-1 shadow-[0_1px_3px_0px_rgba(0,0,0,0.65)]">
<TransitionUpIcon className="z-50" />
<span className="z-50 text-[0.875rem]">Export</span>
<div className="absolute top-0 left-0 z-10 flex h-full w-full items-center justify-center rounded-[0.8rem] 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.8rem] bg-linear-270 from-[#2567EC] to-[#37B6F7]"></div>
</div>
</div>
</button>
</PopoverTrigger>
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
</Popover>
);
}
function ExportPopover({
onOpenChange,
}: {
onOpenChange: (open: boolean) => void;
}) {
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 handleExport = async () => {
if (!activeProject) return;
setIsExporting(true);
setProgress(0);
setExportResult(null);
const result = await editor.project.export({
options: {
format,
quality,
fps: activeProject.settings.fps,
includeAudio,
onProgress: ({ progress }) => setProgress(progress),
onCancel: () => false, // TODO: add cancel functionality
},
});
setIsExporting(false);
setExportResult(result);
if (result.success && result.buffer) {
// Download the file
const mimeType = getExportMimeType({ format });
const extension = getExportFileExtension({ format });
const blob = new Blob([result.buffer], { type: mimeType });
const url = URL.createObjectURL(blob);
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);
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
const handleClose = () => {
if (!isExporting) {
onOpenChange(false);
setExportResult(null);
setProgress(0);
}
};
return (
<PopoverContent className="bg-background mr-4 flex w-80 flex-col gap-3">
<>
{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="text-foreground/85 !size-5" />
</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) => {
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>
<PropertyGroup
title="Quality"
titleClassName="text-sm"
defaultExpanded={false}
>
<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>
<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>
<Button onClick={handleExport} className="w-full gap-2">
<Download className="h-4 w-4" />
Export
</Button>
</>
)}
{isExporting && (
<div className="space-y-4">
<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={() => {}}
>
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);
}
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-destructive text-sm font-medium">Export failed</p>
<p className="text-muted-foreground text-xs">{error}</p>
</div>
<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>
);
}