From 076f681388956bfdb85165e3f8ffd6a4eda59943 Mon Sep 17 00:00:00 2001 From: baiqing Date: Thu, 2 Jul 2026 14:08:46 +0800 Subject: [PATCH] feat(export): self-contained .opentake bundle export (wire archive.rs end to end) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream's third export mode ("Palmier Project", ExportMode.palmierProject) was fully built as opentake_project::archive but reachable by nothing. Wire it: - export.rs: export_bundle command snapshots the LIVE timeline/manifest/ generation-log (upstream archives live editor state and never saves first — ExportService.swift:168-197; unsaved project allowed, projectURL nil -> only external media resolves) and delegates to a testable run_bundle_export seam (mirrors run_export). DTOs BundleReportDto/MissingMediaDto (camelCase, serde round-trip tested). Registered in lib.rs. - core.rs: AppCore::generation_log() snapshot accessor (carried into the bundle like upstream editor.generationLog). - ExportDialog: "bundle" mode with the project-name default filename (".opentake", ExportView.swift:354-356); clean success closes + toast; MISSING media keeps the dialog open with a danger-styled per-file report (upstream keep-open comment, ExportView.swift:369-375). api.ts exportBundle() typed 1:1 against the DTOs. zh/en i18n. 7 new dialog tests. - tests/bundle_export_integration.rs: temp-dir end-to-end (present media collected into media/, missing reported, project.json/media.json written). Prior agent died on a 503 mid-work; its partial Rust (DTOs + command body + generation_log accessor) was verified and completed (registration, seam, tests, frontend were missing). Gates: fmt/clippy -D warnings clean; cargo test --workspace 1372; pnpm build clean; pnpm test 330. --- crates/opentake-core/src/core.rs | 10 + src-tauri/src/export.rs | 203 ++++++++++++++++ src-tauri/src/lib.rs | 1 + src-tauri/tests/bundle_export_integration.rs | 127 ++++++++++ web/src/components/shell/ExportDialog.test.ts | 37 +++ web/src/components/shell/ExportDialog.tsx | 225 ++++++++++++++++-- web/src/i18n/dict.ts | 29 +++ web/src/lib/api.ts | 39 +++ 8 files changed, 646 insertions(+), 25 deletions(-) create mode 100644 src-tauri/tests/bundle_export_integration.rs diff --git a/crates/opentake-core/src/core.rs b/crates/opentake-core/src/core.rs index 432e58d..dfdb535 100644 --- a/crates/opentake-core/src/core.rs +++ b/crates/opentake-core/src/core.rs @@ -33,6 +33,7 @@ use std::sync::{Arc, Mutex}; use opentake_domain::{MediaManifest, MediaManifestEntry, Timeline}; use opentake_ops::command::{EditCommand, EditResult}; use opentake_ops::IdGen; +use opentake_project::GenerationLog; use crate::deps::CoreDeps; use crate::error::Result; @@ -278,6 +279,15 @@ impl AppCore { self.lock().media() } + /// A snapshot of the current AI generation log. Cloned out from under the + /// session lock so a caller (the `.opentake` bundle exporter) can write it + /// into a self-contained bundle alongside the timeline + manifest, exactly as + /// upstream carries `editor.generationLog` into `PalmierProjectExporter` + /// (`Export/ExportService.swift:186-197`). Reads are infallible. + pub fn generation_log(&self) -> GenerationLog { + self.lock().generation_log().clone() + } + /// The open project's `.opentake` bundle directory, or `None` for an unsaved /// project. Needed to resolve [`MediaSource::Project`](opentake_domain::MediaSource) /// relative paths to on-disk files (preview/composite read the original media). diff --git a/src-tauri/src/export.rs b/src-tauri/src/export.rs index d710e0e..5100faf 100644 --- a/src-tauri/src/export.rs +++ b/src-tauri/src/export.rs @@ -670,6 +670,134 @@ fn run_export_with_control( }) } +// MARK: - Self-contained `.opentake` bundle export (#29 / upstream `.palmier`) + +/// One media entry that could not be bundled because its source file was not +/// found on disk. Mirror of `opentake_project::MissingMedia`, serialized as +/// camelCase for the front end (`id` / `name`). Kept as a dangling reference in +/// the exported bundle exactly as upstream does. +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct MissingMediaDto { + /// The manifest entry id. + pub id: String, + /// The manifest entry display name. + pub name: String, +} + +impl From for MissingMediaDto { + fn from(m: opentake_project::MissingMedia) -> Self { + MissingMediaDto { + id: m.id, + name: m.name, + } + } +} + +/// Summary of a completed `.opentake` bundle export, returned to the front end. +/// camelCase mirror of `opentake_project::ArchiveReport` plus the written +/// `outPath`, so the dialog can surface the missing-media list (upstream keeps +/// the export dialog open and lists what couldn't be included — +/// `Export/ExportView.swift:369-375`). +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct BundleReportDto { + /// Absolute path the bundle was written to. + pub out_path: String, + /// Ids of entries that were external and are now bundled internally. + pub collected: Vec, + /// Count of already-internal media files copied across. + pub copied_internal: usize, + /// Entries whose source file could not be found (kept as dangling refs). + pub missing: Vec, + /// Total bytes copied into the new bundle's `media/` directory. + pub total_bytes: u64, +} + +impl BundleReportDto { + /// Project an [`opentake_project::ArchiveReport`] plus the destination path + /// into the camelCase DTO the front end consumes. + fn from_report(out_path: String, report: opentake_project::ArchiveReport) -> Self { + BundleReportDto { + out_path, + collected: report.collected, + copied_internal: report.copied_internal, + missing: report + .missing + .into_iter() + .map(MissingMediaDto::from) + .collect(), + total_bytes: report.total_bytes, + } + } +} + +/// `export_bundle`: write a self-contained `.opentake` bundle to `out_path`, with +/// every resolvable media reference copied inside and the manifest rewritten to +/// bundle-relative paths (port of upstream `exportPalmierProject` / +/// `PalmierProjectExporter.export`). +/// +/// Mirrors upstream `Export/ExportService.swift:166-214` + +/// `Export/ExportView.swift:351-378`: +/// - **No save-first.** Upstream archives the *live* in-memory `editor.timeline` +/// / `editor.mediaManifest` / `editor.generationLog` directly; it does not +/// flush to disk first. Here the authoritative timeline/manifest/log live in +/// the Rust [`AppCore`], so snapshotting them is the exact equivalent — the +/// bundle reflects the live document with no separate save. +/// - **Unsaved projects are allowed.** Upstream passes `editor.projectURL?` +/// (optional) as the source bundle; when nil, only `.external` media resolves. +/// [`AppCore::project_dir`] is `None` for an unsaved project, and +/// [`opentake_project::archive`] documents that `None` resolves only external +/// media — so a never-saved project (all media external) still bundles fully, +/// matching upstream. +/// +/// Returns the missing-media report so the front end can list entries that +/// couldn't be included. GPU is not involved (this is pure file collection); IO +/// failures surface as `Err(String)` at the Tauri boundary. +#[tauri::command] +pub fn export_bundle( + core: State<'_, AppCore>, + out_path: String, +) -> Result { + // Snapshot the live document (upstream reads the in-memory editor objects). + let timeline = core.get_timeline().timeline; + let manifest = core.media(); + let generation_log = core.generation_log(); + let source_bundle = core.project_dir(); + + run_bundle_export( + &timeline, + &manifest, + &generation_log, + source_bundle.as_deref(), + out_path, + ) +} + +/// The bundle-export orchestration, decoupled from Tauri/`AppCore` so it can be +/// driven directly by a temp-dir integration test with a hand-built timeline / +/// manifest / generation log. The command wrapper only snapshots the live +/// session and delegates here (the same split [`run_export`] uses for the video +/// path). `pub` for the integration test in `tests/bundle_export_integration.rs`. +/// +/// `source_bundle` is the open project's `.opentake` directory (used to resolve +/// `.project` relative media and carry across thumbnail / chat sessions), or +/// `None` for a never-saved project (only `.external` media then resolves) — +/// matching upstream's optional `sourceProjectURL`. +pub fn run_bundle_export( + timeline: &opentake_domain::Timeline, + manifest: &opentake_domain::MediaManifest, + generation_log: &opentake_project::GenerationLog, + source_bundle: Option<&Path>, + out_path: String, +) -> Result { + let dest = PathBuf::from(&out_path); + let report = + opentake_project::archive(timeline, manifest, generation_log, source_bundle, &dest) + .map_err(|e| e.to_string())?; + Ok(BundleReportDto::from_report(out_path, report)) +} + fn project_frame_time_secs(source_frame: i64, timeline_fps: i32) -> f64 { let fps = if timeline_fps > 0 { timeline_fps as f64 @@ -950,4 +1078,79 @@ mod tests { ); assert!(mix_timeline_audio(&tl, &media).expect("ok").is_none()); } + + // MARK: - `.opentake` bundle export DTOs + + #[test] + fn missing_media_dto_serializes_camelcase() { + // The front end reads `{ id, name }` — both already single words, so this + // pins the field names (and the `serde(rename_all = "camelCase")` on the + // struct) against an accidental rename that would silently break the + // dialog's missing-media list. camelCase IPC drift is this repo's #1 bug. + let dto = MissingMediaDto { + id: "asset-7".into(), + name: "b-roll.mov".into(), + }; + let json = serde_json::to_value(&dto).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ "id": "asset-7", "name": "b-roll.mov" }) + ); + } + + #[test] + fn bundle_report_dto_serializes_camelcase_multiword_fields() { + // `outPath`, `copiedInternal`, and `totalBytes` are the multi-word fields + // the TS `BundleReport` interface must match verbatim; assert the exact + // JSON keys so a Rust-side rename can't diverge from the front end. + let dto = BundleReportDto { + out_path: "/tmp/My Film.opentake".into(), + collected: vec!["asset-1".into(), "asset-2".into()], + copied_internal: 3, + missing: vec![MissingMediaDto { + id: "asset-9".into(), + name: "gone.mp4".into(), + }], + total_bytes: 123_456, + }; + let json = serde_json::to_value(&dto).expect("serialize"); + assert_eq!( + json, + serde_json::json!({ + "outPath": "/tmp/My Film.opentake", + "collected": ["asset-1", "asset-2"], + "copiedInternal": 3, + "missing": [{ "id": "asset-9", "name": "gone.mp4" }], + "totalBytes": 123_456, + }) + ); + } + + #[test] + fn bundle_report_dto_from_report_maps_every_field() { + // The projection from the engine's `ArchiveReport` (+ dest path) into the + // camelCase DTO must carry each field 1:1, including converting the + // engine's `MissingMedia` into the front-end `MissingMediaDto`. + let report = opentake_project::ArchiveReport { + collected: vec!["ext-1".into()], + copied_internal: 2, + missing: vec![opentake_project::MissingMedia { + id: "m-1".into(), + name: "lost.png".into(), + }], + total_bytes: 4096, + }; + let dto = BundleReportDto::from_report("/out/x.opentake".into(), report); + assert_eq!(dto.out_path, "/out/x.opentake"); + assert_eq!(dto.collected, vec!["ext-1".to_string()]); + assert_eq!(dto.copied_internal, 2); + assert_eq!(dto.total_bytes, 4096); + assert_eq!( + dto.missing, + vec![MissingMediaDto { + id: "m-1".into(), + name: "lost.png".into() + }] + ); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 6b1a426..2753ddd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -173,6 +173,7 @@ pub fn run() { render::composite_frame, export::export_video, export::cancel_export, + export::export_bundle, secret::secret_save, secret::secret_load, secret::secret_delete, diff --git a/src-tauri/tests/bundle_export_integration.rs b/src-tauri/tests/bundle_export_integration.rs new file mode 100644 index 0000000..b9dda62 --- /dev/null +++ b/src-tauri/tests/bundle_export_integration.rs @@ -0,0 +1,127 @@ +//! Temp-dir integration test for the self-contained `.opentake` bundle export +//! spine (`export::run_bundle_export`). +//! +//! Unlike the video export, bundling is pure file collection — no GPU, no +//! ffmpeg — so this test always runs. It builds a two-clip timeline whose +//! manifest references one on-disk external file and one missing external file, +//! runs the orchestrator, and asserts: +//! - the destination bundle is created with `project.json` / `media.json`, +//! - the resolvable media is copied into the bundle's `media/` dir, +//! - the report lists the resolvable asset as `collected` and the missing one +//! under `missing` (the exact data the export dialog surfaces). +//! +//! Drives the Tauri-decoupled `run_bundle_export` directly (the same seam the +//! video export's `run_export` uses), so no live `AppCore`/Tauri handle is +//! needed. + +use std::fs; + +use opentake_domain::{ + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, +}; +use opentake_project::GenerationLog; +use opentake_tauri_lib::export::run_bundle_export; + +/// One external manifest entry pointing at `absolute_path` (which may or may not +/// exist on disk — a non-existent path exercises the missing-media path). +fn external_entry(id: &str, name: &str, absolute_path: &str) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: name.into(), + kind: ClipType::Video, + source: MediaSource::External { + absolute_path: absolute_path.into(), + }, + duration: 1.0, + generation_input: None, + source_width: Some(320), + source_height: Some(240), + source_fps: Some(30.0), + has_audio: Some(false), + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } +} + +#[test] +fn run_bundle_export_collects_present_media_and_reports_missing() { + let dir = tempfile::tempdir().unwrap(); + + // One real external source on disk... + let present = dir.path().join("present.mp4"); + fs::write(&present, b"fake-mp4-bytes").unwrap(); + // ...and one that does not exist (→ reported missing, kept as dangling ref). + let absent = dir.path().join("gone.mp4"); + + let dest = dir.path().join("Bundle.opentake"); + + // Timeline: one video track with a clip per asset. + let mut tl = Timeline::new(); + let mut track = Track::new("t1", ClipType::Video); + track + .clips + .push(Clip::new("clip-present", "asset-present", 0, 30)); + track + .clips + .push(Clip::new("clip-absent", "asset-absent", 30, 30)); + tl.tracks.push(track); + + let mut manifest = MediaManifest::new(); + manifest.entries.push(external_entry( + "asset-present", + "present.mp4", + &present.to_string_lossy(), + )); + manifest.entries.push(external_entry( + "asset-absent", + "gone.mp4", + &absent.to_string_lossy(), + )); + + let log = GenerationLog::new(); + + // `None` source bundle → unsaved project; only `.external` media resolves, + // which is exactly this fixture (matches upstream's optional sourceProjectURL). + let report = run_bundle_export( + &tl, + &manifest, + &log, + None, + dest.to_string_lossy().into_owned(), + ) + .expect("bundle export should succeed"); + + // The bundle exists with its core JSON documents. + assert!(dest.is_dir(), "destination bundle dir should exist"); + assert!( + dest.join("project.json").is_file(), + "project.json should be written" + ); + assert!( + dest.join("media.json").is_file(), + "media.json should be written" + ); + + // The resolvable external asset is collected and physically copied in. + assert_eq!(report.out_path, dest.to_string_lossy()); + assert_eq!( + report.collected, + vec!["asset-present".to_string()], + "the on-disk external asset should be collected" + ); + assert!(report.total_bytes > 0, "some bytes should have been copied"); + let media_dir = dest.join("media"); + assert!(media_dir.is_dir(), "media/ dir should exist in the bundle"); + let copied: Vec<_> = fs::read_dir(&media_dir) + .unwrap() + .filter_map(|e| e.ok()) + .collect(); + assert_eq!(copied.len(), 1, "exactly one media file should be bundled"); + + // The missing external asset is reported (not silently dropped), with the id + // + display name the dialog lists. + assert_eq!(report.missing.len(), 1, "one asset should be missing"); + assert_eq!(report.missing[0].id, "asset-absent"); + assert_eq!(report.missing[0].name, "gone.mp4"); +} diff --git a/web/src/components/shell/ExportDialog.test.ts b/web/src/components/shell/ExportDialog.test.ts index 06a70d6..850475c 100644 --- a/web/src/components/shell/ExportDialog.test.ts +++ b/web/src/components/shell/ExportDialog.test.ts @@ -1,7 +1,9 @@ import { describe, expect, it } from "vitest"; import { + defaultBundleName, defaultMp4Name, defaultQuality, + formatBytes, progressPercent, withMp4Ext, } from "./ExportDialog"; @@ -38,6 +40,41 @@ describe("defaultMp4Name", () => { }); }); +describe("defaultBundleName", () => { + it("falls back to Untitled.opentake for an unsaved project", () => { + expect(defaultBundleName(null)).toBe("Untitled.opentake"); + }); + + it("round-trips a saved project bundle name (dir stripped, extension kept)", () => { + expect( + defaultBundleName("/Users/me/Documents/OpenTake/My Film.opentake"), + ).toBe("My Film.opentake"); + }); + + it("handles a bare bundle name with no directory", () => { + expect(defaultBundleName("Demo.opentake")).toBe("Demo.opentake"); + }); +}); + +describe("formatBytes", () => { + it("reports 0 B for zero, negative, or non-finite sizes", () => { + expect(formatBytes(0)).toBe("0 B"); + expect(formatBytes(-10)).toBe("0 B"); + expect(formatBytes(NaN)).toBe("0 B"); + }); + + it("keeps raw bytes below 1 KB with no decimal", () => { + expect(formatBytes(512)).toBe("512 B"); + }); + + it("scales into KB / MB / GB with one decimal", () => { + expect(formatBytes(1024)).toBe("1 KB"); + expect(formatBytes(1536)).toBe("1.5 KB"); + expect(formatBytes(5 * 1024 * 1024)).toBe("5 MB"); + expect(formatBytes(1024 * 1024 * 1024)).toBe("1 GB"); + }); +}); + describe("defaultQuality", () => { it("maps standard 1080p timelines to the 1080p bucket", () => { expect(defaultQuality(1920, 1080)).toBe("1080p"); diff --git a/web/src/components/shell/ExportDialog.tsx b/web/src/components/shell/ExportDialog.tsx index 91d6d3b..9a8bc1b 100644 --- a/web/src/components/shell/ExportDialog.tsx +++ b/web/src/components/shell/ExportDialog.tsx @@ -32,6 +32,13 @@ import { saveDialog } from "../../lib/dialog"; const MP4_EXT = "mp4"; const MOV_EXT = "mov"; +/** Extension of a self-contained project bundle (matches the Rust + * `BUNDLE_EXTENSION` and how projects are named/opened today). */ +const BUNDLE_EXT = "opentake"; + +/** Top-level export target: a rendered video, or a self-contained project + * bundle (upstream `ExportMode.video` / `.palmierProject`). */ +export type ExportMode = "video" | "bundle"; /** The container extension the backend's `resolve_preset` requires for a codec. */ export function extForCodec(codec: ExportCodec): typeof MP4_EXT | typeof MOV_EXT { @@ -66,6 +73,30 @@ export function defaultMp4Name(projectPath: string | null): string { return defaultExportName(projectPath, MP4_EXT); } +/** + * Default bundle filename: the open project's base name with the `.opentake` + * extension, falling back to "Untitled.opentake" for an unsaved project + * (upstream `startPalmierExport`: `editor.projectURL?…lastPathComponent ?? + * Project.defaultProjectName`). Reuses {@link defaultExportName}, which already + * strips the directory and a trailing `.opentake` from the source path — so a + * saved "My Film.opentake" round-trips to "My Film.opentake". + */ +export function defaultBundleName(projectPath: string | null): string { + if (!projectPath) return `Untitled.${BUNDLE_EXT}`; + return defaultExportName(projectPath, BUNDLE_EXT); +} + +/** Human-readable byte size for the bundle "collected N media · " toast. + * Base-1024 with one decimal past KB; pure so it's unit-testable. */ +export function formatBytes(bytes: number): string { + if (!Number.isFinite(bytes) || bytes <= 0) return "0 B"; + const units = ["B", "KB", "MB", "GB", "TB"]; + const i = Math.min(units.length - 1, Math.floor(Math.log(bytes) / Math.log(1024))); + const value = bytes / 1024 ** i; + const rounded = i === 0 ? value : Math.round(value * 10) / 10; + return `${rounded} ${units[i]}`; +} + /** 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); @@ -90,11 +121,16 @@ export function ExportDialog() { const pushToast = useEditorUiStore((s) => s.pushToast); const timeline = useProjectStore((s) => s.timeline); + const [mode, setMode] = useState("video"); const [codec, setCodec] = useState("h264"); const [quality, setQuality] = useState("1080p"); const [busy, setBusy] = useState(false); const [error, setError] = useState(null); const [progress, setProgress] = useState<{ done: number; total: number } | null>(null); + // Missing-media report from a bundle export that otherwise succeeded. Non-null + // keeps the dialog open with a distinct notice (upstream keeps the export + // sheet open "so the user sees what couldn't be included"). + const [bundleMissing, setBundleMissing] = useState(null); // Guards against unsubscribing a listener from a stale/overlapping export run // (belt-and-suspenders; only one export runs at a time in practice). const progressUnlisten = useRef<(() => void) | null>(null); @@ -105,6 +141,7 @@ export function ExportDialog() { setQuality(defaultQuality(timeline.width, timeline.height)); setError(null); setProgress(null); + setBundleMissing(null); } }, [open, timeline.width, timeline.height]); @@ -146,6 +183,23 @@ export function ExportDialog() { [t], ); + const modeOptions = useMemo( + () => [ + { id: "video" as const, label: t("export.mode.video") }, + { id: "bundle" as const, label: t("export.mode.bundle") }, + ], + [t], + ); + + /** Switch export target, clearing any prior run's error / missing report so a + * stale notice from the other mode doesn't linger. Blocked while busy. */ + function onModeChange(next: ExportMode): void { + if (busy) return; + setMode(next); + setError(null); + setBundleMissing(null); + } + if (!open) return null; async function onExport(): Promise { @@ -217,12 +271,78 @@ export function ExportDialog() { } } + /** + * Self-contained `.opentake` bundle export (upstream `startPalmierExport`). + * Snapshots the live project (no save-first, matching upstream) and copies all + * resolvable media inside. On a clean result the dialog closes with a success + * toast; when some media was missing the dialog stays open with a distinct + * report so the user sees what couldn't be included. There is no progress + * event for bundling (pure file copy), so no listener is wired. + */ + async function onExportBundle(): Promise { + if (busy) return; + setError(null); + setBundleMissing(null); + + const save = await saveDialog(); + if (!save) { + // No native save panel (outside Tauri) — the export can't run here. + pushToast(t("export.bundle.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}${defaultBundleName(projectPath)}` + : undefined; + + const chosen = await save({ + title: t("export.bundle.saveDialog"), + defaultPath, + filters: [{ name: t("export.bundle.saveFilter"), extensions: [BUNDLE_EXT] }], + }); + if (typeof chosen !== "string") return; // cancelled + + setBusy(true); + try { + const report = await api.exportBundle(withExt(chosen, BUNDLE_EXT)); + if (report.missing.length === 0) { + // Clean success: close with a summary toast (upstream reveals the file + // in Finder; no reveal capability is wired here, so surface via toast). + pushToast( + report.collected.length > 0 + ? t("export.bundle.done", { + collected: report.collected.length, + size: formatBytes(report.totalBytes), + }) + : t("export.bundle.doneNoMedia"), + ); + setOpen(false); + } else { + // Exported, but some media couldn't be found — keep the dialog open and + // list them (distinct from the failure path). + setBundleMissing(report.missing); + pushToast(t("export.bundle.missing", { count: report.missing.length })); + } + } catch (e) { + const message = e instanceof Error ? e.message : String(e); + setError(message); + pushToast(t("export.bundle.failed")); + } finally { + setBusy(false); + } + } + async function onCancel(): Promise { if (!busy) { setOpen(false); return; } - await api.cancelExport(); + // Bundling has no cooperative cancel; only the video path can stop mid-run. + if (mode === "video") await api.cancelExport(); } return ( @@ -291,7 +411,7 @@ export function ExportDialog() { - {/* Body: format + resolution rows. */} + {/* Body: mode picker, then mode-specific rows. */}
- + setCodec(id)} - ariaLabel={t("export.format")} + value={mode} + options={modeOptions} + onChange={(id) => onModeChange(id)} + ariaLabel={t("export.mode")} minWidth={160} /> - - setQuality(id)} - ariaLabel={t("export.resolution")} - minWidth={160} - /> - + {mode === "video" ? ( + <> + + setCodec(id)} + ariaLabel={t("export.format")} + minWidth={160} + /> + + + + setQuality(id)} + ariaLabel={t("export.resolution")} + minWidth={160} + /> + + + ) : ( +

+ {t("export.bundle.description")} +

+ )} + + {mode === "bundle" && bundleMissing && ( +
+ + {t("export.bundle.missing", { count: bundleMissing.length })} + + {bundleMissing.map((m) => ( + + {m.name} + + ))} +
+ )} - {busy && ( + {mode === "video" && busy && (
- {busy ? t("export.exporting") : t("export.run")} + {busy + ? t("export.exporting") + : mode === "bundle" + ? t("export.bundle.run") + : t("export.run")}
diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 108eaca..bd8274b 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -83,6 +83,20 @@ const zh: Dict = { "export.failed": "导出失败", "export.unavailable": "当前环境无法导出视频(需桌面版)", + // Export mode (#29): video vs self-contained .opentake bundle + "export.mode": "导出类型", + "export.mode.video": "视频 (.mp4)", + "export.mode.bundle": "项目包 (.opentake)", + "export.bundle.description": "打包本项目及其全部素材,便于在任意电脑上打开。", + "export.bundle.run": "导出项目包", + "export.bundle.saveDialog": "导出项目包", + "export.bundle.saveFilter": "OpenTake 项目包", + "export.bundle.done": "已导出项目包 · 收集 {collected} 项素材 · {size}", + "export.bundle.doneNoMedia": "已导出项目包", + "export.bundle.missing": "已导出,但有 {count} 项素材缺失、未能包含。", + "export.bundle.failed": "导出项目包失败", + "export.bundle.unavailable": "当前环境无法导出项目包(需桌面版)", + // View menu "view.menu": "视图", "view.layout": "布局", @@ -509,6 +523,21 @@ const en: Dict = { "export.failed": "Export failed", "export.unavailable": "Video export needs the desktop app", + // Export mode (#29): video vs self-contained .opentake bundle + "export.mode": "Export type", + "export.mode.video": "Video (.mp4)", + "export.mode.bundle": "Project bundle (.opentake)", + "export.bundle.description": + "Bundles this project with all its media so it opens on any machine.", + "export.bundle.run": "Export Bundle", + "export.bundle.saveDialog": "Export Project Bundle", + "export.bundle.saveFilter": "OpenTake Bundle", + "export.bundle.done": "Bundle exported · {collected} media collected · {size}", + "export.bundle.doneNoMedia": "Bundle exported", + "export.bundle.missing": "Exported, but {count} media file(s) were missing and couldn't be included.", + "export.bundle.failed": "Bundle export failed", + "export.bundle.unavailable": "Bundle export needs the desktop app", + "view.menu": "View", "view.layout": "Layout", "view.panels": "Panels", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index 29d572b..15a04cb 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -287,6 +287,45 @@ export async function onExportProgress( }); } +// MARK: - Self-contained `.opentake` bundle export (#29 / upstream `.palmier`) +// +// `export_bundle` writes a self-contained `.opentake` bundle to disk: every +// resolvable media reference is copied inside and the manifest is rewritten to +// bundle-relative paths, so the project opens on any machine (port of upstream +// `ExportService.exportPalmierProject` / `PalmierProjectExporter`). It carries +// the live in-memory timeline / manifest / generation log with no save-first, +// matching upstream. Both interfaces mirror the Rust DTOs verbatim — camelCase +// `outPath` / `copiedInternal` / `totalBytes` (IPC camelCase drift is this +// repo's #1 historical bug). Outside Tauri there is no Rust core / file system, +// so the wrapper rejects with a friendly error rather than silently no-op'ing. + +/** One media entry that could not be bundled because its source file was not + * found on disk (mirror of Rust `MissingMediaDto`). Kept as a dangling + * reference in the exported bundle, exactly as upstream does. */ +export interface MissingMedia { + id: string; + name: string; +} + +/** Summary of a completed `.opentake` bundle export (mirror of Rust + * `BundleReportDto`). `missing` lists entries whose source file couldn't be + * found so the dialog can surface them while still reporting success. */ +export interface BundleReport { + outPath: string; + collected: string[]; + copiedInternal: number; + missing: MissingMedia[]; + totalBytes: number; +} + +/** Write a self-contained `.opentake` bundle to `outPath`, returning the + * missing-media report. Rejects outside Tauri (no core / no FS). */ +export async function exportBundle(outPath: string): Promise { + await ensureTauri(); + if (invokeImpl) return invokeImpl("export_bundle", { outPath }); + throw new Error("bundle export requires the desktop app"); +} + // MARK: - Media commands // // `import_folder` scans a directory for white-listed media and imports each;