diff --git a/apps/web/src/components/editor/export-button.tsx b/apps/web/src/components/editor/export-button.tsx index e66067daf..19470b34d 100644 --- a/apps/web/src/components/editor/export-button.tsx +++ b/apps/web/src/components/editor/export-button.tsx @@ -1,336 +1,403 @@ -"use client"; - -import { useState } from "react"; -import { TransitionTopIcon } from "@hugeicons/core-free-icons"; -import { HugeiconsIcon } from "@hugeicons/react"; -import { - 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, - downloadBuffer, -} from "@/lib/export"; -import { Check, Copy, Download, RotateCcw } from "lucide-react"; -import { - EXPORT_FORMAT_VALUES, - EXPORT_QUALITY_VALUES, - type ExportFormat, - type ExportQuality, -} from "@/lib/export"; -import { - Section, - SectionContent, - SectionHeader, - SectionTitle, -} from "@/components/section"; -import { useEditor } from "@/hooks/use-editor"; -import { DEFAULT_EXPORT_OPTIONS } from "@/lib/export/defaults"; - -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); -} - -export function ExportButton() { - const [isExportPopoverOpen, setIsExportPopoverOpen] = useState(false); - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActiveOrNull()); - const hasProject = !!activeProject; - - const handlePopoverOpenChange = ({ open }: { open: boolean }) => { - if (!open) { - editor.project.cancelExport(); - editor.project.clearExportState(); - } - setIsExportPopoverOpen(open); - }; - - return ( - handlePopoverOpenChange({ open })} - > - - - - {hasProject && } - - ); -} - -function ExportPopover({ - onOpenChange, -}: { - onOpenChange: (open: boolean) => void; -}) { - const editor = useEditor(); - const activeProject = useEditor((e) => e.project.getActive()); - const exportState = useEditor((e) => e.project.getExportState()); - const { isExporting, progress, result: exportResult } = exportState; - const [format, setFormat] = useState( - DEFAULT_EXPORT_OPTIONS.format, - ); - const [quality, setQuality] = useState( - DEFAULT_EXPORT_OPTIONS.quality, - ); - const [shouldIncludeAudio, setShouldIncludeAudio] = useState( - DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, - ); - - const handleExport = async () => { - if (!activeProject) return; - - const result = await editor.project.export({ - options: { - format, - quality, - fps: activeProject.settings.fps, - includeAudio: shouldIncludeAudio, - }, - }); - - if (result.cancelled) { - editor.project.clearExportState(); - return; - } - - if (result.success && result.buffer) { - downloadBuffer({ - buffer: result.buffer, - filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, - mimeType: getExportMimeType({ format }), - }); - - editor.project.clearExportState(); - onOpenChange(false); - } - }; - - const handleCancel = () => { - editor.project.cancelExport(); - }; - - return ( - - {exportResult && !exportResult.success ? ( - - ) : ( - <> -
-

- {isExporting ? "Exporting project" : "Export project"} -

-
- -
- {!isExporting && ( - <> -
-
- - Format - - - { - if (isExportFormat(value)) { - setFormat(value); - } - }} - > -
- - -
-
- - -
-
-
-
- -
- - Quality - - - { - if (isExportQuality(value)) { - setQuality(value); - } - }} - > -
- - -
-
- - -
-
- - -
-
- - -
-
-
-
- -
- - Audio - - -
- - setShouldIncludeAudio(!!checked) - } - /> - -
-
-
-
- -
- -
- - )} - - {isExporting && ( -
-
-
-

- {Math.round(progress * 100)}% -

-

100%

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

Export failed

-

{error}

-
- -
- - -
-
- ); -} +"use client"; + +import { useState } from "react"; +import { TransitionTopIcon } from "@hugeicons/core-free-icons"; +import { HugeiconsIcon } from "@hugeicons/react"; +import { + 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, + downloadBuffer, +} from "@/lib/export"; +import { Check, Copy, Download, RotateCcw } from "lucide-react"; +import { + EXPORT_FORMAT_VALUES, + EXPORT_QUALITY_VALUES, + type ExportFormat, + type ExportQuality, +} from "@/lib/export"; +import { + Section, + SectionContent, + SectionHeader, + SectionTitle, +} from "@/components/section"; +import { useEditor } from "@/hooks/use-editor"; +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 { + return EXPORT_FORMAT_VALUES.some((formatValue) => formatValue === 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(); + const activeProject = useEditor((e) => e.project.getActiveOrNull()); + const hasProject = !!activeProject; + + const handlePopoverOpenChange = ({ open }: { open: boolean }) => { + if (!open) { + editor.project.cancelExport(); + editor.project.clearExportState(); + } + setIsExportPopoverOpen(open); + }; + + return ( + handlePopoverOpenChange({ open })} + > + + + + {hasProject && } + + ); +} + +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 ( + + {trigger} + + + Cancel export? + + The video file is not yet complete. If you cancel now, you will not + get an output file. This cannot be undone. + + + + + + + + + ); +} + +function ExportPopover({ + onOpenChange, +}: { + onOpenChange: (open: boolean) => void; +}) { + const editor = useEditor(); + const activeProject = useEditor((e) => e.project.getActive()); + const exportState = useEditor((e) => e.project.getExportState()); + const { isExporting, progress, result: exportResult } = exportState; + const [format, setFormat] = useState( + DEFAULT_EXPORT_OPTIONS.format, + ); + const [quality, setQuality] = useState( + DEFAULT_EXPORT_OPTIONS.quality, + ); + const [shouldIncludeAudio, setShouldIncludeAudio] = useState( + DEFAULT_EXPORT_OPTIONS.includeAudio ?? true, + ); + + const handleExport = async () => { + if (!activeProject) return; + + const result = await editor.project.export({ + options: { + format, + quality, + fps: activeProject.settings.fps, + includeAudio: shouldIncludeAudio, + }, + }); + + if (result.cancelled) { + editor.project.clearExportState(); + return; + } + + if (result.success && result.buffer) { + downloadBuffer({ + buffer: result.buffer, + filename: `${activeProject.metadata.name}${getExportFileExtension({ format })}`, + mimeType: getExportMimeType({ format }), + }); + + editor.project.clearExportState(); + onOpenChange(false); + } + }; + + const handleCancel = () => { + editor.project.cancelExport(); + }; + + return ( + + {exportResult && !exportResult.success ? ( + + ) : ( + <> +
+

+ {isExporting ? "Exporting project" : "Export project"} +

+
+ +
+ {!isExporting && ( + <> +
+
+ + Format + + + { + if (isExportFormat(value)) { + setFormat(value); + } + }} + > +
+ + +
+
+ + +
+
+
+
+ +
+ + Quality + + + { + if (isExportQuality(value)) { + setQuality(value); + } + }} + > +
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ +
+ + Audio + + +
+ + setShouldIncludeAudio(!!checked) + } + /> + +
+
+
+
+ +
+ +
+ + )} + + {isExporting && (() => { + const { totalFrames = 0, currentFrame = 0, elapsedMs = 0 } = exportState; + const etaMs = progress > 0 && progress < 1 + ? Math.round((elapsedMs / progress) - elapsedMs) + : 0; + + return ( +
+
+
+

+ {Math.round(progress * 100)}% +

+

100%

+
+ +
+ +
+ {totalFrames > 0 && ( + + Frame {currentFrame.toLocaleString()} / {totalFrames.toLocaleString()} + + )} +
+ Elapsed {formatDuration(elapsedMs)} + {etaMs > 0 && ~{formatDuration(etaMs)} remaining} +
+
+ + + Cancel + + } + /> +
+ ); + })()} +
+ + )} +
+ ); +} + +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 ( +
+
+

Export failed

+

{error}

+
+ +
+ + +
+
+ ); +} diff --git a/apps/web/src/core/managers/project-manager.ts b/apps/web/src/core/managers/project-manager.ts index 81b237dae..dd7454cb3 100644 --- a/apps/web/src/core/managers/project-manager.ts +++ b/apps/web/src/core/managers/project-manager.ts @@ -55,8 +55,12 @@ export class ProjectManager { isExporting: false, progress: 0, result: null, + totalFrames: 0, + currentFrame: 0, + elapsedMs: 0, }; private exportCancelRequested = false; + private exportStartTime = 0; constructor(private editor: EditorCore) {} @@ -207,13 +211,33 @@ export class ProjectManager { async export({ options }: { options: ExportOptions }): Promise { 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(); const result = await this.editor.renderer.exportProject({ options, 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(); }, onCancel: () => this.exportCancelRequested, @@ -223,6 +247,9 @@ export class ProjectManager { isExporting: false, progress: this.exportState.progress, result, + totalFrames: this.exportState.totalFrames, + currentFrame: this.exportState.totalFrames, + elapsedMs: this.exportState.elapsedMs, }; this.notify(); diff --git a/apps/web/src/lib/export/index.ts b/apps/web/src/lib/export/index.ts index c5c522b36..6a6325aab 100644 --- a/apps/web/src/lib/export/index.ts +++ b/apps/web/src/lib/export/index.ts @@ -31,6 +31,9 @@ export interface ExportState { isExporting: boolean; progress: number; result: ExportResult | null; + totalFrames?: number; + currentFrame?: number; + elapsedMs?: number; } export function getExportMimeType({