feat(export): add frame count, ETA, elapsed time, and cancel confirmation dialog
Implements #739 - Export progress now shows: - Frame count: 'Frame 120 / 1200' - Elapsed time: 'Elapsed 0:45' - Estimated time remaining: '~2:30 remaining' - Cancel confirmation dialog before cancelling mid-export Changes: - Extend ExportState interface with totalFrames, currentFrame, elapsedMs - ProjectManager.export() computes totalFrames from duration and fps at start - onProgress updates currentFrame and elapsedMs via Date.now() delta - ExportCancelDialog component wraps cancel action with Radix AlertDialog - formatDuration() utility for MM:SS time formatting - UI renders frame count, elapsed, and ETA when exporting
This commit is contained in:
parent
8bdc894690
commit
284c8a7e2d
|
|
@ -1,336 +1,403 @@
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
import { TransitionTopIcon } from "@hugeicons/core-free-icons";
|
||||||
import { HugeiconsIcon } from "@hugeicons/react";
|
import { HugeiconsIcon } from "@hugeicons/react";
|
||||||
import {
|
import {
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
} from "@/components/ui/popover";
|
} from "@/components/ui/popover";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Label } from "@/components/ui/label";
|
import { Label } from "@/components/ui/label";
|
||||||
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Checkbox } from "@/components/ui/checkbox";
|
import { Checkbox } from "@/components/ui/checkbox";
|
||||||
import { cn } from "@/utils/ui";
|
import { cn } from "@/utils/ui";
|
||||||
import {
|
import {
|
||||||
getExportMimeType,
|
getExportMimeType,
|
||||||
getExportFileExtension,
|
getExportFileExtension,
|
||||||
downloadBuffer,
|
downloadBuffer,
|
||||||
} from "@/lib/export";
|
} from "@/lib/export";
|
||||||
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
import { Check, Copy, Download, RotateCcw } from "lucide-react";
|
||||||
import {
|
import {
|
||||||
EXPORT_FORMAT_VALUES,
|
EXPORT_FORMAT_VALUES,
|
||||||
EXPORT_QUALITY_VALUES,
|
EXPORT_QUALITY_VALUES,
|
||||||
type ExportFormat,
|
type ExportFormat,
|
||||||
type ExportQuality,
|
type ExportQuality,
|
||||||
} from "@/lib/export";
|
} from "@/lib/export";
|
||||||
import {
|
import {
|
||||||
Section,
|
Section,
|
||||||
SectionContent,
|
SectionContent,
|
||||||
SectionHeader,
|
SectionHeader,
|
||||||
SectionTitle,
|
SectionTitle,
|
||||||
} from "@/components/section";
|
} from "@/components/section";
|
||||||
import { useEditor } from "@/hooks/use-editor";
|
import { useEditor } from "@/hooks/use-editor";
|
||||||
import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults";
|
import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults";
|
||||||
|
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog";
|
||||||
function isExportFormat(value: string): value is ExportFormat {
|
import type React from "react";
|
||||||
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
|
|
||||||
}
|
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 isExportQuality(value: string): value is ExportQuality {
|
||||||
|
return EXPORT_QUALITY_VALUES.some((qualityValue) => qualityValue === value);
|
||||||
export function ExportButton() {
|
}
|
||||||
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
|
||||||
const editor = useEditor();
|
export function ExportButton() {
|
||||||
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false);
|
||||||
const hasProject = !!activeProject;
|
const editor = useEditor();
|
||||||
|
const activeProject = useEditor((e) => e.project.getActiveOrNull());
|
||||||
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
|
const hasProject = !!activeProject;
|
||||||
if (!open) {
|
|
||||||
editor.project.cancelExport();
|
const handlePopoverOpenChange = ({ open }: { open: boolean }) => {
|
||||||
editor.project.clearExportState();
|
if (!open) {
|
||||||
}
|
editor.project.cancelExport();
|
||||||
setIsExportPopoverOpen(open);
|
editor.project.clearExportState();
|
||||||
};
|
}
|
||||||
|
setIsExportPopoverOpen(open);
|
||||||
return (
|
};
|
||||||
<Popover
|
|
||||||
open={isExportPopoverOpen}
|
return (
|
||||||
onOpenChange={(open) => handlePopoverOpenChange({ open })}
|
<Popover
|
||||||
>
|
open={isExportPopoverOpen}
|
||||||
<PopoverTrigger asChild>
|
onOpenChange={(open) => handlePopoverOpenChange({ open })}
|
||||||
<button
|
>
|
||||||
type="button"
|
<PopoverTrigger asChild>
|
||||||
className={cn(
|
<button
|
||||||
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
type="button"
|
||||||
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
className={cn(
|
||||||
)}
|
"flex items-center gap-1.5 rounded-md bg-[#38BDF8] px-[0.12rem] py-[0.12rem] text-white",
|
||||||
onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
|
hasProject ? "cursor-pointer" : "cursor-not-allowed opacity-50",
|
||||||
disabled={!hasProject}
|
)}
|
||||||
onKeyDown={(event) => {
|
onClick={hasProject ? () => setIsExportPopoverOpen(true) : undefined}
|
||||||
if (hasProject && (event.key === "Enter" || event.key === " ")) {
|
disabled={!hasProject}
|
||||||
event.preventDefault();
|
onKeyDown={(event) => {
|
||||||
setIsExportPopoverOpen(true);
|
if (hasProject && (event.key === "Enter" || event.key === " ")) {
|
||||||
}
|
event.preventDefault();
|
||||||
}}
|
setIsExportPopoverOpen(true);
|
||||||
>
|
}
|
||||||
<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-3.5" />
|
>
|
||||||
<span className="z-50 text-[0.875rem]">Export</span>
|
<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)]">
|
||||||
<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">
|
<HugeiconsIcon icon={TransitionTopIcon} className="z-50 size-3.5" />
|
||||||
<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>
|
<span className="z-50 text-[0.875rem]">Export</span>
|
||||||
</div>
|
<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>
|
<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>
|
||||||
</button>
|
</div>
|
||||||
</PopoverTrigger>
|
</div>
|
||||||
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
|
</button>
|
||||||
</Popover>
|
</PopoverTrigger>
|
||||||
);
|
{hasProject && <ExportPopover onOpenChange={setIsExportPopoverOpen} />}
|
||||||
}
|
</Popover>
|
||||||
|
);
|
||||||
function ExportPopover({
|
}
|
||||||
onOpenChange,
|
|
||||||
}: {
|
function formatDuration(ms: number): string {
|
||||||
onOpenChange: (open: boolean) => void;
|
const totalSeconds = Math.floor(ms / 1000);
|
||||||
}) {
|
const minutes = Math.floor(totalSeconds / 60);
|
||||||
const editor = useEditor();
|
const seconds = totalSeconds % 60;
|
||||||
const activeProject = useEditor((e) => e.project.getActive());
|
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||||
const exportState = useEditor((e) => e.project.getExportState());
|
}
|
||||||
const { isExporting, progress, result: exportResult } = exportState;
|
|
||||||
const [format, setFormat] = useState<ExportFormat>(
|
function ExportCancelDialog({
|
||||||
DEFAULT_EXPORT_OPTIONS.format,
|
onConfirm,
|
||||||
);
|
trigger,
|
||||||
const [quality, setQuality] = useState<ExportQuality>(
|
}: {
|
||||||
DEFAULT_EXPORT_OPTIONS.quality,
|
onConfirm: () => void;
|
||||||
);
|
trigger: React.ReactNode;
|
||||||
const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
|
}) {
|
||||||
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
|
const [open, setOpen] = useState(false);
|
||||||
);
|
|
||||||
|
const handleConfirm = () => {
|
||||||
const handleExport = async () => {
|
onConfirm();
|
||||||
if (!activeProject) return;
|
setOpen(false);
|
||||||
|
};
|
||||||
const result = await editor.project.export({
|
|
||||||
options: {
|
return (
|
||||||
format,
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
quality,
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
fps: activeProject.settings.fps,
|
<DialogContent>
|
||||||
includeAudio: shouldIncludeAudio,
|
<DialogHeader>
|
||||||
},
|
<DialogTitle>Cancel export?</DialogTitle>
|
||||||
});
|
<DialogDescription>
|
||||||
|
The video file is not yet complete. If you cancel now, you will not
|
||||||
if (result.cancelled) {
|
get an output file. This cannot be undone.
|
||||||
editor.project.clearExportState();
|
</DialogDescription>
|
||||||
return;
|
</DialogHeader>
|
||||||
}
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
if (result.success && result.buffer) {
|
Continue exporting
|
||||||
downloadBuffer({
|
</Button>
|
||||||
buffer: result.buffer,
|
<Button variant="destructive" onClick={handleConfirm}>
|
||||||
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
|
Cancel export
|
||||||
mimeType: getExportMimeType({ format }),
|
</Button>
|
||||||
});
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
editor.project.clearExportState();
|
</Dialog>
|
||||||
onOpenChange(false);
|
);
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
function ExportPopover({
|
||||||
const handleCancel = () => {
|
onOpenChange,
|
||||||
editor.project.cancelExport();
|
}: {
|
||||||
};
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}) {
|
||||||
return (
|
const editor = useEditor();
|
||||||
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
|
const activeProject = useEditor((e) => e.project.getActive());
|
||||||
{exportResult && !exportResult.success ? (
|
const exportState = useEditor((e) => e.project.getExportState());
|
||||||
<ExportError
|
const { isExporting, progress, result: exportResult } = exportState;
|
||||||
error={exportResult.error || "Unknown error occurred"}
|
const [format, setFormat] = useState<ExportFormat>(
|
||||||
onRetry={handleExport}
|
DEFAULT_EXPORT_OPTIONS.format,
|
||||||
/>
|
);
|
||||||
) : (
|
const [quality, setQuality] = useState<ExportQuality>(
|
||||||
<>
|
DEFAULT_EXPORT_OPTIONS.quality,
|
||||||
<div className="flex items-center justify-between p-3 border-b">
|
);
|
||||||
<h3 className="font-medium text-sm">
|
const [shouldIncludeAudio, setShouldIncludeAudio] = useState<boolean>(
|
||||||
{isExporting ? "Exporting project" : "Export project"}
|
DEFAULT_EXPORT_OPTIONS.includeAudio ?? true,
|
||||||
</h3>
|
);
|
||||||
</div>
|
|
||||||
|
const handleExport = async () => {
|
||||||
<div className="flex flex-col gap-4">
|
if (!activeProject) return;
|
||||||
{!isExporting && (
|
|
||||||
<>
|
const result = await editor.project.export({
|
||||||
<div className="flex flex-col">
|
options: {
|
||||||
<Section
|
format,
|
||||||
collapsible
|
quality,
|
||||||
defaultOpen={false}
|
fps: activeProject.settings.fps,
|
||||||
showTopBorder={false}
|
includeAudio: shouldIncludeAudio,
|
||||||
>
|
},
|
||||||
<SectionHeader>
|
});
|
||||||
<SectionTitle>Format</SectionTitle>
|
|
||||||
</SectionHeader>
|
if (result.cancelled) {
|
||||||
<SectionContent>
|
editor.project.clearExportState();
|
||||||
<RadioGroup
|
return;
|
||||||
value={format}
|
}
|
||||||
onValueChange={(value) => {
|
|
||||||
if (isExportFormat(value)) {
|
if (result.success && result.buffer) {
|
||||||
setFormat(value);
|
downloadBuffer({
|
||||||
}
|
buffer: result.buffer,
|
||||||
}}
|
filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`,
|
||||||
>
|
mimeType: getExportMimeType({ format }),
|
||||||
<div className="flex items-center space-x-2">
|
});
|
||||||
<RadioGroupItem value="mp4" id="mp4" />
|
|
||||||
<Label htmlFor="mp4">
|
editor.project.clearExportState();
|
||||||
MP4 (H.264) - Better compatibility
|
onOpenChange(false);
|
||||||
</Label>
|
}
|
||||||
</div>
|
};
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<RadioGroupItem value="webm" id="webm" />
|
const handleCancel = () => {
|
||||||
<Label htmlFor="webm">
|
editor.project.cancelExport();
|
||||||
WebM (VP9) - Smaller file size
|
};
|
||||||
</Label>
|
|
||||||
</div>
|
return (
|
||||||
</RadioGroup>
|
<PopoverContent className="bg-background mr-4 flex w-80 flex-col p-0">
|
||||||
</SectionContent>
|
{exportResult && !exportResult.success ? (
|
||||||
</Section>
|
<ExportError
|
||||||
|
error={exportResult.error || "Unknown error occurred"}
|
||||||
<Section collapsible defaultOpen={false}>
|
onRetry={handleExport}
|
||||||
<SectionHeader>
|
/>
|
||||||
<SectionTitle>Quality</SectionTitle>
|
) : (
|
||||||
</SectionHeader>
|
<>
|
||||||
<SectionContent>
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
<RadioGroup
|
<h3 className="font-medium text-sm">
|
||||||
value={quality}
|
{isExporting ? "Exporting project" : "Export project"}
|
||||||
onValueChange={(value) => {
|
</h3>
|
||||||
if (isExportQuality(value)) {
|
</div>
|
||||||
setQuality(value);
|
|
||||||
}
|
<div className="flex flex-col gap-4">
|
||||||
}}
|
{!isExporting && (
|
||||||
>
|
<>
|
||||||
<div className="flex items-center space-x-2">
|
<div className="flex flex-col">
|
||||||
<RadioGroupItem value="low" id="low" />
|
<Section
|
||||||
<Label htmlFor="low">Low - Smallest file size</Label>
|
collapsible
|
||||||
</div>
|
defaultOpen={false}
|
||||||
<div className="flex items-center space-x-2">
|
showTopBorder={false}
|
||||||
<RadioGroupItem value="medium" id="medium" />
|
>
|
||||||
<Label htmlFor="medium">Medium - Balanced</Label>
|
<SectionHeader>
|
||||||
</div>
|
<SectionTitle>Format</SectionTitle>
|
||||||
<div className="flex items-center space-x-2">
|
</SectionHeader>
|
||||||
<RadioGroupItem value="high" id="high" />
|
<SectionContent>
|
||||||
<Label htmlFor="high">High - Recommended</Label>
|
<RadioGroup
|
||||||
</div>
|
value={format}
|
||||||
<div className="flex items-center space-x-2">
|
onValueChange={(value) => {
|
||||||
<RadioGroupItem value="very_high" id="very_high" />
|
if (isExportFormat(value)) {
|
||||||
<Label htmlFor="very_high">
|
setFormat(value);
|
||||||
Very high - Largest file size
|
}
|
||||||
</Label>
|
}}
|
||||||
</div>
|
>
|
||||||
</RadioGroup>
|
<div className="flex items-center space-x-2">
|
||||||
</SectionContent>
|
<RadioGroupItem value="mp4" id="mp4" />
|
||||||
</Section>
|
<Label htmlFor="mp4">
|
||||||
|
MP4 (H.264) - Better compatibility
|
||||||
<Section collapsible defaultOpen={false}>
|
</Label>
|
||||||
<SectionHeader>
|
</div>
|
||||||
<SectionTitle>Audio</SectionTitle>
|
<div className="flex items-center space-x-2">
|
||||||
</SectionHeader>
|
<RadioGroupItem value="webm" id="webm" />
|
||||||
<SectionContent>
|
<Label htmlFor="webm">
|
||||||
<div className="flex items-center space-x-2">
|
WebM (VP9) - Smaller file size
|
||||||
<Checkbox
|
</Label>
|
||||||
id="include-audio"
|
</div>
|
||||||
checked={shouldIncludeAudio}
|
</RadioGroup>
|
||||||
onCheckedChange={(checked) =>
|
</SectionContent>
|
||||||
setShouldIncludeAudio(!!checked)
|
</Section>
|
||||||
}
|
|
||||||
/>
|
<Section collapsible defaultOpen={false}>
|
||||||
<Label htmlFor="include-audio">
|
<SectionHeader>
|
||||||
Include audio in export
|
<SectionTitle>Quality</SectionTitle>
|
||||||
</Label>
|
</SectionHeader>
|
||||||
</div>
|
<SectionContent>
|
||||||
</SectionContent>
|
<RadioGroup
|
||||||
</Section>
|
value={quality}
|
||||||
</div>
|
onValueChange={(value) => {
|
||||||
|
if (isExportQuality(value)) {
|
||||||
<div className="p-3 pt-0">
|
setQuality(value);
|
||||||
<Button onClick={handleExport} className="w-full gap-2">
|
}
|
||||||
<Download className="size-4" />
|
}}
|
||||||
Export
|
>
|
||||||
</Button>
|
<div className="flex items-center space-x-2">
|
||||||
</div>
|
<RadioGroupItem value="low" id="low" />
|
||||||
</>
|
<Label htmlFor="low">Low - Smallest file size</Label>
|
||||||
)}
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
{isExporting && (
|
<RadioGroupItem value="medium" id="medium" />
|
||||||
<div className="space-y-4 p-3">
|
<Label htmlFor="medium">Medium - Balanced</Label>
|
||||||
<div className="flex flex-col gap-2">
|
</div>
|
||||||
<div className="flex items-center justify-between text-center">
|
<div className="flex items-center space-x-2">
|
||||||
<p className="text-muted-foreground text-sm">
|
<RadioGroupItem value="high" id="high" />
|
||||||
{Math.round(progress * 100)}%
|
<Label htmlFor="high">High - Recommended</Label>
|
||||||
</p>
|
</div>
|
||||||
<p className="text-muted-foreground text-sm">100%</p>
|
<div className="flex items-center space-x-2">
|
||||||
</div>
|
<RadioGroupItem value="very_high" id="very_high" />
|
||||||
<Progress value={progress * 100} className="w-full" />
|
<Label htmlFor="very_high">
|
||||||
</div>
|
Very high - Largest file size
|
||||||
|
</Label>
|
||||||
<Button
|
</div>
|
||||||
variant="outline"
|
</RadioGroup>
|
||||||
className="w-full rounded-md"
|
</SectionContent>
|
||||||
onClick={handleCancel}
|
</Section>
|
||||||
>
|
|
||||||
Cancel
|
<Section collapsible defaultOpen={false}>
|
||||||
</Button>
|
<SectionHeader>
|
||||||
</div>
|
<SectionTitle>Audio</SectionTitle>
|
||||||
)}
|
</SectionHeader>
|
||||||
</div>
|
<SectionContent>
|
||||||
</>
|
<div className="flex items-center space-x-2">
|
||||||
)}
|
<Checkbox
|
||||||
</PopoverContent>
|
id="include-audio"
|
||||||
);
|
checked={shouldIncludeAudio}
|
||||||
}
|
onCheckedChange={(checked) =>
|
||||||
|
setShouldIncludeAudio(!!checked)
|
||||||
function ExportError({
|
}
|
||||||
error,
|
/>
|
||||||
onRetry,
|
<Label htmlFor="include-audio">
|
||||||
}: {
|
Include audio in export
|
||||||
error: string;
|
</Label>
|
||||||
onRetry: () => void;
|
</div>
|
||||||
}) {
|
</SectionContent>
|
||||||
const [copied, setCopied] = useState(false);
|
</Section>
|
||||||
|
</div>
|
||||||
const handleCopy = async () => {
|
|
||||||
await navigator.clipboard.writeText(error);
|
<div className="p-3 pt-0">
|
||||||
setCopied(true);
|
<Button onClick={handleExport} className="w-full gap-2">
|
||||||
setTimeout(() => setCopied(false), 1000);
|
<Download className="size-4" />
|
||||||
};
|
Export
|
||||||
|
</Button>
|
||||||
return (
|
</div>
|
||||||
<div className="space-y-4 p-3">
|
</>
|
||||||
<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>
|
{isExporting && (() => {
|
||||||
</div>
|
const { totalFrames = 0, currentFrame = 0, elapsedMs = 0 } = exportState;
|
||||||
|
const etaMs = progress > 0 && progress < 1
|
||||||
<div className="flex gap-2">
|
? Math.round((elapsedMs / progress) - elapsedMs)
|
||||||
<Button
|
: 0;
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
return (
|
||||||
className="h-8 flex-1 text-xs"
|
<div className="space-y-4 p-3">
|
||||||
onClick={handleCopy}
|
<div className="flex flex-col gap-2">
|
||||||
>
|
<div className="flex items-center justify-between text-center">
|
||||||
{copied ? <Check className="text-constructive" /> : <Copy />}
|
<p className="text-muted-foreground text-sm">
|
||||||
Copy
|
{Math.round(progress * 100)}%
|
||||||
</Button>
|
</p>
|
||||||
<Button
|
<p className="text-muted-foreground text-sm">100%</p>
|
||||||
variant="outline"
|
</div>
|
||||||
size="sm"
|
<Progress value={progress * 100} className="w-full" />
|
||||||
className="h-8 flex-1 text-xs"
|
</div>
|
||||||
onClick={onRetry}
|
|
||||||
>
|
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
|
||||||
<RotateCcw />
|
{totalFrames > 0 && (
|
||||||
Retry
|
<span>
|
||||||
</Button>
|
Frame {currentFrame.toLocaleString()} / {totalFrames.toLocaleString()}
|
||||||
</div>
|
</span>
|
||||||
</div>
|
)}
|
||||||
);
|
<div className="flex justify-between">
|
||||||
}
|
<span>Elapsed {formatDuration(elapsedMs)}</span>
|
||||||
|
{etaMs > 0 && <span>~{formatDuration(etaMs)} remaining</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ExportCancelDialog
|
||||||
|
onConfirm={handleCancel}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" className="w-full rounded-md">
|
||||||
|
Cancel
|
||||||
|
</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 p-3">
|
||||||
|
<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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -55,8 +55,12 @@ export class ProjectManager {
|
||||||
isExporting: false,
|
isExporting: false,
|
||||||
progress: 0,
|
progress: 0,
|
||||||
result: null,
|
result: null,
|
||||||
|
totalFrames: 0,
|
||||||
|
currentFrame: 0,
|
||||||
|
elapsedMs: 0,
|
||||||
};
|
};
|
||||||
private exportCancelRequested = false;
|
private exportCancelRequested = false;
|
||||||
|
private exportStartTime = 0;
|
||||||
|
|
||||||
constructor(private editor: EditorCore) {}
|
constructor(private editor: EditorCore) {}
|
||||||
|
|
||||||
|
|
@ -207,13 +211,33 @@ export class ProjectManager {
|
||||||
|
|
||||||
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
|
async export({ options }: { options: ExportOptions }): Promise<ExportResult> {
|
||||||
this.exportCancelRequested = false;
|
this.exportCancelRequested = false;
|
||||||
this.exportState = { isExporting: true, progress: 0, result: null };
|
const activeProject = this.editor.project.getActive();
|
||||||
|
const duration = this.editor.timeline.getTotalDuration();
|
||||||
|
const exportFps = options.fps ?? activeProject.settings.fps;
|
||||||
|
const totalFrames = Math.round((duration / 1000) * exportFps);
|
||||||
|
|
||||||
|
this.exportStartTime = Date.now();
|
||||||
|
this.exportState = {
|
||||||
|
isExporting: true,
|
||||||
|
progress: 0,
|
||||||
|
result: null,
|
||||||
|
totalFrames,
|
||||||
|
currentFrame: 0,
|
||||||
|
elapsedMs: 0,
|
||||||
|
};
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
const result = await this.editor.renderer.exportProject({
|
const result = await this.editor.renderer.exportProject({
|
||||||
options,
|
options,
|
||||||
onProgress: ({ progress }) => {
|
onProgress: ({ progress }) => {
|
||||||
this.exportState = { ...this.exportState, progress };
|
const elapsedMs = Date.now() - this.exportStartTime;
|
||||||
|
const currentFrame = Math.floor(progress * totalFrames);
|
||||||
|
this.exportState = {
|
||||||
|
...this.exportState,
|
||||||
|
progress,
|
||||||
|
currentFrame,
|
||||||
|
elapsedMs,
|
||||||
|
};
|
||||||
this.notify();
|
this.notify();
|
||||||
},
|
},
|
||||||
onCancel: () => this.exportCancelRequested,
|
onCancel: () => this.exportCancelRequested,
|
||||||
|
|
@ -223,6 +247,9 @@ export class ProjectManager {
|
||||||
isExporting: false,
|
isExporting: false,
|
||||||
progress: this.exportState.progress,
|
progress: this.exportState.progress,
|
||||||
result,
|
result,
|
||||||
|
totalFrames: this.exportState.totalFrames,
|
||||||
|
currentFrame: this.exportState.totalFrames,
|
||||||
|
elapsedMs: this.exportState.elapsedMs,
|
||||||
};
|
};
|
||||||
this.notify();
|
this.notify();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,9 @@ export interface ExportState {
|
||||||
isExporting: boolean;
|
isExporting: boolean;
|
||||||
progress: number;
|
progress: number;
|
||||||
result: ExportResult | null;
|
result: ExportResult | null;
|
||||||
|
totalFrames?: number;
|
||||||
|
currentFrame?: number;
|
||||||
|
elapsedMs?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getExportMimeType({
|
export function getExportMimeType({
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue