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:
Ben (Zo AI) 2026-04-18 22:57:05 +00:00
parent 8bdc894690
commit 284c8a7e2d
3 changed files with 435 additions and 338 deletions

View File

@ -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>
);
}

View File

@ -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();

View File

@ -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({