Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions crates/opentake-core/src/core.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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).
Expand Down
203 changes: 203 additions & 0 deletions src-tauri/src/export.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<opentake_project::MissingMedia> 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<String>,
/// 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<MissingMediaDto>,
/// 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<BundleReportDto, String> {
// 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<BundleReportDto, String> {
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
Expand Down Expand Up @@ -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()
}]
);
}
}
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
127 changes: 127 additions & 0 deletions src-tauri/tests/bundle_export_integration.rs
Original file line number Diff line number Diff line change
@@ -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");
}
Loading
Loading