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

@ -34,6 +34,8 @@ import {
} 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";
import type React from "react";
function isExportFormat(value: string): value is ExportFormat { function isExportFormat(value: string): value is ExportFormat {
return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value); return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === value);
@ -92,6 +94,51 @@ export function ExportButton() {
); );
} }
function formatDuration(ms: number): string {
const totalSeconds = Math.floor(ms / 1000);
const minutes = Math.floor(totalSeconds / 60);
const seconds = totalSeconds % 60;
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
}
function ExportCancelDialog({
onConfirm,
trigger,
}: {
onConfirm: () => void;
trigger: React.ReactNode;
}) {
const [open, setOpen] = useState(false);
const handleConfirm = () => {
onConfirm();
setOpen(false);
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Cancel export?</DialogTitle>
<DialogDescription>
The video file is not yet complete. If you cancel now, you will not
get an output file. This cannot be undone.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setOpen(false)}>
Continue exporting
</Button>
<Button variant="destructive" onClick={handleConfirm}>
Cancel export
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
function ExportPopover({ function ExportPopover({
onOpenChange, onOpenChange,
}: { }: {
@ -261,27 +308,47 @@ function ExportPopover({
</> </>
)} )}
{isExporting && ( {isExporting && (() => {
<div className="space-y-4 p-3"> const { totalFrames = 0, currentFrame = 0, elapsedMs = 0 } = exportState;
<div className="flex flex-col gap-2"> const etaMs = progress > 0 && progress < 1
<div className="flex items-center justify-between text-center"> ? Math.round((elapsedMs / progress) - elapsedMs)
<p className="text-muted-foreground text-sm"> : 0;
{Math.round(progress * 100)}%
</p>
<p className="text-muted-foreground text-sm">100%</p>
</div>
<Progress value={progress * 100} className="w-full" />
</div>
<Button return (
variant="outline" <div className="space-y-4 p-3">
className="w-full rounded-md" <div className="flex flex-col gap-2">
onClick={handleCancel} <div className="flex items-center justify-between text-center">
> <p className="text-muted-foreground text-sm">
Cancel {Math.round(progress * 100)}%
</Button> </p>
</div> <p className="text-muted-foreground text-sm">100%</p>
)} </div>
<Progress value={progress * 100} className="w-full" />
</div>
<div className="flex flex-col gap-1 text-xs text-muted-foreground">
{totalFrames > 0 && (
<span>
Frame {currentFrame.toLocaleString()} / {totalFrames.toLocaleString()}
</span>
)}
<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> </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({