From 4b39a0e36398672e43f2e9ed9f14ed884a637e34 Mon Sep 17 00:00:00 2001 From: baiqing Date: Mon, 29 Jun 2026 00:25:44 +0800 Subject: [PATCH] feat(export): add video export dialog and title-bar entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire the existing `export_video` backend (#112) to a front-end surface so users can render the timeline to a real .mp4 from the UI. - ExportDialog: format select (H.264/.mp4 enabled; H.265/ProRes shown disabled + "not wired" note, matching the backend's resolve_preset rejection) and resolution select (720p/1080p/4K, default seeded from the timeline's own short edge). Export → native .mp4 save dialog → exportVideo. Backend has no progress callback, so this is a status+toast surface (button reads "Exporting…", controls disabled in flight); success/failure both pushToast. - TitleBar: "Export Video" entry beside the XML export, disabled when no track holds a clip. - api.ts: exportVideo() wrapper + ExportRequest/ExportSummary types, camelCase-aligned with the Rust DTO; rejects outside Tauri. - Dropdown: optional per-option `disabled` (greyed, unselectable). - uiStore: exportDialogOpen state; mount dialog in App. - i18n: zh-CN + en keys for the dialog and title-bar entry. - Tests: pure helpers (withMp4Ext / defaultMp4Name / defaultQuality). Front-end only; no export backend logic changed. --- web/src/App.tsx | 2 + web/src/components/shell/ExportDialog.test.ts | 56 +++ web/src/components/shell/ExportDialog.tsx | 364 ++++++++++++++++++ web/src/components/shell/TitleBar.tsx | 30 +- web/src/components/ui/Dropdown.tsx | 15 +- web/src/i18n/dict.ts | 47 +++ web/src/lib/api.ts | 38 ++ web/src/store/uiStore.ts | 5 + 8 files changed, 554 insertions(+), 3 deletions(-) create mode 100644 web/src/components/shell/ExportDialog.test.ts create mode 100644 web/src/components/shell/ExportDialog.tsx diff --git a/web/src/App.tsx b/web/src/App.tsx index 1b01102..705d039 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,5 +1,6 @@ import { useEffect } from "react"; import { TitleBar } from "./components/shell/TitleBar"; +import { ExportDialog } from "./components/shell/ExportDialog"; import { EditorSplit } from "./components/shell/EditorSplit"; import { HomeView } from "./components/home/HomeView"; import { SettingsView } from "./components/settings/SettingsView"; @@ -104,6 +105,7 @@ export default function App() { )} {settingsOpen && } + ); diff --git a/web/src/components/shell/ExportDialog.test.ts b/web/src/components/shell/ExportDialog.test.ts new file mode 100644 index 0000000..d4a8a49 --- /dev/null +++ b/web/src/components/shell/ExportDialog.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { + defaultMp4Name, + defaultQuality, + withMp4Ext, +} from "./ExportDialog"; + +describe("withMp4Ext", () => { + it("appends .mp4 when missing", () => { + expect(withMp4Ext("/out/clip")).toBe("/out/clip.mp4"); + }); + + it("keeps an existing .mp4 extension (case-insensitive)", () => { + expect(withMp4Ext("/out/clip.mp4")).toBe("/out/clip.mp4"); + expect(withMp4Ext("/out/clip.MP4")).toBe("/out/clip.MP4"); + }); + + it("appends .mp4 to a path with a different extension (does not strip it)", () => { + // The save dialog filters to .mp4, but guard the H.264 container regardless. + expect(withMp4Ext("/out/clip.mov")).toBe("/out/clip.mov.mp4"); + }); +}); + +describe("defaultMp4Name", () => { + it("falls back to Timeline.mp4 for an unsaved project", () => { + expect(defaultMp4Name(null)).toBe("Timeline.mp4"); + }); + + it("derives the name from the project bundle, stripping dir + .opentake", () => { + expect(defaultMp4Name("/Users/me/Documents/OpenTake/My Film.opentake")).toBe( + "My Film.mp4", + ); + }); + + it("handles a bare bundle name with no directory", () => { + expect(defaultMp4Name("Demo.opentake")).toBe("Demo.mp4"); + }); +}); + +describe("defaultQuality", () => { + it("maps standard 1080p timelines to the 1080p bucket", () => { + expect(defaultQuality(1920, 1080)).toBe("1080p"); + }); + + it("maps a vertical 1080-wide timeline to 1080p (short edge drives it)", () => { + expect(defaultQuality(1080, 1920)).toBe("1080p"); + }); + + it("maps small (≤840 short edge) timelines to 720p", () => { + expect(defaultQuality(1280, 720)).toBe("720p"); + }); + + it("maps large (≥1620 short edge) timelines to 4k", () => { + expect(defaultQuality(3840, 2160)).toBe("4k"); + }); +}); diff --git a/web/src/components/shell/ExportDialog.tsx b/web/src/components/shell/ExportDialog.tsx new file mode 100644 index 0000000..56cfa0e --- /dev/null +++ b/web/src/components/shell/ExportDialog.tsx @@ -0,0 +1,364 @@ +/** + * ExportDialog (SPEC §2.4 / #112). Modal shown from the title bar to render the + * whole timeline to a real video file via the `export_video` backend command + * (per-frame GPU composite → ffmpeg H.264 / .mp4 + AAC mux). + * + * Scope mirrors the backend's first cut: + * - Format: H.264 / .mp4 is the only wired path; H.265 / ProRes are shown + * disabled with a "not wired yet" note (the backend `resolve_preset` rejects + * them, so we never let the user pick one and hit a server error). + * - Resolution: 720p / 1080p / 4K short-edge presets. The default pre-selects + * the preset matching the timeline's own shorter edge so a standard project + * round-trips its native size; it falls back to 1080p (the backend default). + * + * The backend runs to completion with no progress callback, so this is a + * deliberate "status + toast" surface — never a faked progress bar. While the + * export runs the controls are disabled and the button reads "Exporting…"; + * success / failure both `pushToast` and close on success. + */ + +import { useEffect, useMemo, useState } from "react"; +import { X } from "lucide-react"; +import { Icon } from "../ui/Icon"; +import { Dropdown } from "../ui/Dropdown"; +import { useEditorUiStore } from "../../store/uiStore"; +import { useProjectStore } from "../../store/projectStore"; +import { useT } from "../../i18n"; +import * as api from "../../lib/api"; +import type { ExportCodec, ExportQuality } from "../../lib/api"; +import { saveDialog } from "../../lib/dialog"; + +const MP4_EXT = "mp4"; + +/** Ensure a chosen path carries the `.mp4` extension (the H.264 container). */ +export function withMp4Ext(path: string): string { + return path.toLowerCase().endsWith(`.${MP4_EXT}`) ? path : `${path}.${MP4_EXT}`; +} + +/** + * Default export filename: the open project's base name with `.mp4`, falling + * back to "Timeline.mp4" for an unsaved project. The bundle path ends in + * `…/Name.opentake`, so strip the directory and the `.opentake` suffix. + */ +export function defaultMp4Name(projectPath: string | null): string { + if (!projectPath) return `Timeline.${MP4_EXT}`; + const base = projectPath.split(/[\\/]/).pop() ?? projectPath; + const stem = base.replace(/\.opentake$/i, ""); + return `${stem || "Timeline"}.${MP4_EXT}`; +} + +/** Pick the preset whose short edge best matches the timeline's shorter side. */ +export function defaultQuality(width: number, height: number): ExportQuality { + const shortEdge = Math.min(width, height); + if (shortEdge >= 1620) return "4k"; // ≥ 1620 rounds to the 2160 bucket + if (shortEdge <= 840) return "720p"; // ≤ 840 rounds to the 720 bucket + return "1080p"; +} + +export function ExportDialog() { + const t = useT(); + const open = useEditorUiStore((s) => s.exportDialogOpen); + const setOpen = useEditorUiStore((s) => s.setExportDialogOpen); + const pushToast = useEditorUiStore((s) => s.pushToast); + const timeline = useProjectStore((s) => s.timeline); + + const [codec, setCodec] = useState("h264"); + const [quality, setQuality] = useState("1080p"); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + // Re-seed the resolution default from the timeline each time the dialog opens. + useEffect(() => { + if (open) { + setQuality(defaultQuality(timeline.width, timeline.height)); + setError(null); + } + }, [open, timeline.width, timeline.height]); + + // Close on Escape (ignored while an export is in flight so the run isn't + // abandoned mid-encode from the user's point of view). + useEffect(() => { + if (!open) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape" && !busy) setOpen(false); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [open, busy, setOpen]); + + const codecOptions = useMemo( + () => [ + { id: "h264" as const, label: t("export.codec.h264") }, + { id: "h265" as const, label: t("export.codec.h265"), disabled: true }, + { id: "prores" as const, label: t("export.codec.prores"), disabled: true }, + ], + [t], + ); + + const qualityOptions = useMemo( + () => [ + { id: "720p" as const, label: t("export.quality.720p") }, + { id: "1080p" as const, label: t("export.quality.1080p") }, + { id: "4k" as const, label: t("export.quality.4k") }, + ], + [t], + ); + + if (!open) return null; + + async function onExport(): Promise { + if (busy) return; + setError(null); + + const save = await saveDialog(); + if (!save) { + // No native save panel (outside Tauri) — the export can't run here. + pushToast(t("export.unavailable")); + return; + } + const projectPath = useProjectStore.getState().projectPath; + const dir = projectPath + ? projectPath.replace(/[\\/][^\\/]*$/, "") + : await api.getDefaultProjectDir().catch(() => ""); + const sep = dir && !dir.endsWith("/") ? "/" : ""; + const defaultPath = dir + ? `${dir}${sep}${defaultMp4Name(projectPath)}` + : undefined; + + const chosen = await save({ + title: t("export.saveDialog"), + defaultPath, + filters: [{ name: t("export.saveFilter"), extensions: [MP4_EXT] }], + }); + if (typeof chosen !== "string") return; // cancelled + + setBusy(true); + try { + const summary = await api.exportVideo({ + outPath: withMp4Ext(chosen), + codec, + quality, + }); + pushToast( + t("export.done", { + width: summary.width, + height: summary.height, + frames: summary.frameCount, + }), + ); + setOpen(false); + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setError(message); + pushToast(t("export.failed")); + } finally { + setBusy(false); + } + } + + return ( +
{ + if (!busy) setOpen(false); + }} + > +
e.stopPropagation()} + > + {/* Header */} +
+ + {t("export.title")} + + +
+ + {/* Body: format + resolution rows. */} +
+ + setCodec(id)} + ariaLabel={t("export.format")} + minWidth={160} + /> + + + + setQuality(id)} + ariaLabel={t("export.resolution")} + minWidth={160} + /> + + + {error && ( +
+ {error} +
+ )} +
+ + {/* Footer: cancel + export. */} +
+ + +
+
+
+ ); +} + +/** One labelled control row (label left, control right, optional hint below). */ +function Row({ + label, + hint, + children, +}: { + label: string; + hint?: string; + children: React.ReactNode; +}) { + return ( +
+
+ + {label} + + {children} +
+ {hint && ( + + {hint} + + )} +
+ ); +} diff --git a/web/src/components/shell/TitleBar.tsx b/web/src/components/shell/TitleBar.tsx index 94ccda1..dc42b52 100644 --- a/web/src/components/shell/TitleBar.tsx +++ b/web/src/components/shell/TitleBar.tsx @@ -8,7 +8,7 @@ * menu, the in-app menu entry point for an environment without a native menu bar. */ -import { Upload, Home, Settings as SettingsIcon, Library } from "lucide-react"; +import { Upload, Home, Settings as SettingsIcon, Library, Film } from "lucide-react"; import { Icon } from "../ui/Icon"; import { ViewMenu } from "./ViewMenu"; import { useEditorUiStore } from "../../store/uiStore"; @@ -39,9 +39,15 @@ function defaultXmlName(projectPath: string | null): string { export function TitleBar() { const setView = useEditorUiStore((s) => s.setView); const setSettingsOpen = useEditorUiStore((s) => s.setSettingsOpen); + const setExportDialogOpen = useEditorUiStore((s) => s.setExportDialogOpen); const projectPath = useProjectStore((s) => s.projectPath); + const tracks = useProjectStore((s) => s.timeline.tracks); const t = useT(); + // Video export needs something to render: disable the entry when no track + // holds a clip (an empty timeline would only encode black frames). + const hasClips = tracks.some((track) => track.clips.length > 0); + /** * Export the timeline as Final Cut Pro 7 XML (`.xml`). Mirrors the new-project * save flow (`projectActions.newProjectAndEnter`): open the native save panel, @@ -138,6 +144,28 @@ export function TitleBar() { > +