diff --git a/crates/opentake-core/src/core.rs b/crates/opentake-core/src/core.rs index a21241b..432e58d 100644 --- a/crates/opentake-core/src/core.rs +++ b/crates/opentake-core/src/core.rs @@ -246,9 +246,23 @@ impl AppCore { /// (autosave); `Some(path)` is a save-as. Emits [`CoreEvent::ProjectSaved`] /// with the written path on success. pub fn save_project(&self, path: Option) -> Result { + self.save_project_with_thumbnail(path, None) + } + + /// Like [`Self::save_project`] but also writes a cover `thumbnail.jpg` from + /// the supplied JPEG bytes (`None` leaves any existing cover in place). The + /// caller — which owns the media engine / GPU — captures the representative + /// frame (upstream `captureThumbnail`, via + /// [`opentake_media::capture_project_thumbnail`]) so this assembly layer stays + /// free of the ffmpeg/GPU stack. Emits [`CoreEvent::ProjectSaved`] on success. + pub fn save_project_with_thumbnail( + &self, + path: Option, + thumbnail: Option>, + ) -> Result { let written = { let mut session = self.lock(); - session.save_project(path)? + session.save_project_with_thumbnail(path, thumbnail)? }; self.events.emit(&CoreEvent::ProjectSaved { path: written.to_string_lossy().into_owned(), diff --git a/crates/opentake-core/src/session.rs b/crates/opentake-core/src/session.rs index 52132d7..ebf2858 100644 --- a/crates/opentake-core/src/session.rs +++ b/crates/opentake-core/src/session.rs @@ -151,10 +151,41 @@ impl EditorSession { /// (so saving never mutates the document) plus the generation log, and lets /// `opentake-project` write the bundle atomically. /// + /// **Save-as also copies the source bundle's `media/` directory** into the + /// new bundle (upstream `mediaDirWrapper`, `Project/VideoProject.swift:112-117`): + /// a project holding internal media + /// ([`MediaSource::Project`](opentake_domain::MediaSource) relative paths — + /// AI-generated, pasted, captured stills) would otherwise have every one of + /// those references silently dangle after Save-As, since `bundle.rs::save` + /// "never creates or deletes `media/`". A plain save (target equals the + /// current dir) copies nothing; a missing source `media/` is a no-op; a + /// partial-copy failure propagates as a real error (never a half-copied + /// bundle) — see [`opentake_project::copy_media_dir`]. + /// /// Errors with [`CoreError::NoProjectOpen`] when neither a path nor a /// remembered project dir is available. pub fn save_project(&mut self, path: Option) -> Result { - let target = match path.or_else(|| self.project_dir.clone()) { + self.save_project_with_thumbnail(path, None) + } + + /// Like [`Self::save_project`] but also writes a cover `thumbnail.jpg` when + /// `thumbnail` carries JPEG bytes. The caller (which owns the media engine / + /// GPU) captures the representative frame — see + /// [`opentake_media::capture_project_thumbnail`], the port of upstream + /// `captureThumbnail` — and hands the bytes in, so `opentake-core` stays free + /// of the ffmpeg/GPU stack (`crate::deps`). `None` leaves any existing + /// `thumbnail.jpg` untouched (`bundle.rs::save` only writes the thumbnail when + /// [`Project::thumbnail`] is set), matching upstream's best-effort capture + /// that simply omits the cover on failure. + pub fn save_project_with_thumbnail( + &mut self, + path: Option, + thumbnail: Option>, + ) -> Result { + // Remember the currently-open bundle before we adopt any new target, so + // a save-as knows the source `media/` to carry across. + let previous_dir = self.project_dir.clone(); + let target = match path.or_else(|| previous_dir.clone()) { Some(p) => p, None => return Err(CoreError::NoProjectOpen), }; @@ -162,6 +193,9 @@ impl EditorSession { let mut project = Project::new(target.clone()); project.timeline = self.state.timeline.clone(); project.manifest = self.state.manifest.clone(); + // Cover image (upstream `snapshotThumbnail` → `thumbnail.jpg`): only set + // when the caller produced bytes; otherwise leave the on-disk cover as-is. + project.thumbnail = thumbnail; // Only persist a generation log once it has rows (mirrors the upstream // "write the log component when present" tolerance). if !self.generation_log.entries.is_empty() { @@ -169,6 +203,18 @@ impl EditorSession { } project.save()?; + // Save-as (target differs from the previously-open bundle): fold the + // source bundle's `media/` into the new one before adopting it, so + // internal media survives the move. `copy_media_dir` is itself a no-op + // when source == dest, but only copy when we truly had a prior bundle at + // a different path (a first save of a never-saved project has no source + // media/ to carry). + if let Some(source_dir) = &previous_dir { + if source_dir != &target { + opentake_project::copy_media_dir(source_dir, &target)?; + } + } + self.project_dir = Some(target.clone()); Ok(target) } @@ -534,4 +580,183 @@ mod tests { assert!(matches!(err, Err(CoreError::Unsupported("media")))); assert!(s.media().entries.is_empty()); } + + // --- Save-as copies the project-internal media/ directory (Item 1) --- + + /// A per-call-unique scratch dir under the system temp dir, removed on drop. + struct TmpDir(PathBuf); + impl TmpDir { + fn new(tag: &str) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let p = std::env::temp_dir() + .join(format!("opentake-saveas-{tag}-{}-{n}", std::process::id())); + let _ = std::fs::remove_dir_all(&p); + std::fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } + } + + /// A project-internal (`.project`) manifest entry pointing at + /// `media/`, plus the actual file written under the source bundle's + /// `media/` dir — the setup a project with internal media has on disk. + fn seed_bundle_with_internal_media( + bundle: &Path, + file_name: &str, + bytes: &[u8], + ) -> EditorSession { + use opentake_domain::{MediaManifestEntry, MediaSource}; + let media_dir = bundle.join("media"); + std::fs::create_dir_all(&media_dir).unwrap(); + std::fs::write(media_dir.join(file_name), bytes).unwrap(); + + let mut project = Project::new(bundle.to_path_buf()); + project.manifest.entries.push(MediaManifestEntry { + id: "asset-1".into(), + name: file_name.into(), + kind: ClipType::Image, + source: MediaSource::Project { + relative_path: format!("media/{file_name}"), + }, + duration: 0.0, + generation_input: None, + source_width: Some(2), + source_height: Some(2), + source_fps: None, + has_audio: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + }); + project.save().unwrap(); + + EditorSession::open_project(bundle).unwrap() + } + + #[test] + fn save_as_copies_internal_media_to_new_bundle_and_manifest_resolves() { + use opentake_domain::{MediaResolver, MediaSource}; + let tmp = TmpDir::new("copy"); + let src = tmp.path().join("Source.opentake"); + let dst = tmp.path().join("Dest.opentake"); + + let payload = b"PNGDATA"; + let mut s = seed_bundle_with_internal_media(&src, "still.png", payload); + // Sanity: the session opened against the source bundle. + assert_eq!(s.project_dir(), Some(src.as_path())); + + // Save-as to a brand-new directory. + let written = s.save_project(Some(dst.clone())).unwrap(); + assert_eq!(written, dst); + assert_eq!(s.project_dir(), Some(dst.as_path())); + + // The media file now exists at the SAME relative path inside the new + // bundle (media/still.png), with identical bytes. + let copied = dst.join("media").join("still.png"); + assert!(copied.is_file(), "media file missing at {copied:?}"); + assert_eq!(std::fs::read(&copied).unwrap(), payload); + + // The reopened manifest still resolves the entry to the on-disk file in + // the new bundle (the reference did not dangle). + let reopened = EditorSession::open_project(&dst).unwrap(); + let manifest = reopened.media(); + let entry = &manifest.entries[0]; + assert!(matches!( + &entry.source, + MediaSource::Project { relative_path } if relative_path == "media/still.png" + )); + let resolver = MediaResolver::new(&manifest, Some(dst.as_path())); + let resolved = resolver.expected_path("asset-1").unwrap(); + assert!( + resolved.is_file(), + "resolved path not on disk: {resolved:?}" + ); + assert_eq!(std::fs::read(&resolved).unwrap(), payload); + } + + #[test] + fn plain_save_same_path_does_not_touch_media_dir() { + let tmp = TmpDir::new("samepath"); + let src = tmp.path().join("Same.opentake"); + let mut s = seed_bundle_with_internal_media(&src, "clip.png", b"x"); + + // A no-arg save writes back to the same bundle. It must not recurse into + // or rewrite media/ (bundle.rs::save "never creates or deletes media/", + // and copy_media_dir short-circuits on source == dest). We assert the + // existing media file is left exactly as-is. + let media_file = src.join("media").join("clip.png"); + let before = std::fs::metadata(&media_file).unwrap(); + let written = s.save_project(None).unwrap(); + assert_eq!(written, src); + // File still present, same length; the dir was not replaced/emptied. + let after = std::fs::metadata(&media_file).unwrap(); + assert_eq!(before.len(), after.len()); + assert!(media_file.is_file()); + } + + #[test] + fn save_with_thumbnail_bytes_writes_thumbnail_jpg() { + let tmp = TmpDir::new("thumb"); + let dir = tmp.path().join("Cover.opentake"); + let mut s = EditorSession::new_project(); + s.state = EditorState::from_timeline(one_video_track()); + + let jpeg = vec![0xFF, 0xD8, 1, 2, 3, 0xFF, 0xD9]; // stand-in JPEG bytes + let written = s + .save_project_with_thumbnail(Some(dir.clone()), Some(jpeg.clone())) + .unwrap(); + assert_eq!(written, dir); + let thumb = dir.join("thumbnail.jpg"); + assert!(thumb.is_file(), "thumbnail.jpg not written"); + assert_eq!(std::fs::read(&thumb).unwrap(), jpeg); + } + + #[test] + fn save_without_thumbnail_leaves_existing_cover_untouched() { + let tmp = TmpDir::new("thumb-keep"); + let dir = tmp.path().join("Keep.opentake"); + let mut s = EditorSession::new_project(); + s.state = EditorState::from_timeline(one_video_track()); + + // First save writes a cover. + let jpeg = vec![0xFF, 0xD8, 9, 9, 0xFF, 0xD9]; + s.save_project_with_thumbnail(Some(dir.clone()), Some(jpeg.clone())) + .unwrap(); + + // A subsequent save with no thumbnail bytes must not delete/overwrite the + // existing thumbnail.jpg (bundle.save only writes it when Some). + s.save_project_with_thumbnail(None, None).unwrap(); + assert_eq!(std::fs::read(dir.join("thumbnail.jpg")).unwrap(), jpeg); + } + + #[test] + fn save_as_with_no_source_media_dir_is_ok() { + let tmp = TmpDir::new("nomedia"); + let src = tmp.path().join("NoMedia.opentake"); + let dst = tmp.path().join("Out.opentake"); + + // Source bundle saved WITHOUT any media/ dir (external-only / empty + // project). Save-as must succeed and simply not create a media/ dir. + let mut project = Project::new(src.clone()); + project.timeline = one_video_track(); + project.save().unwrap(); + let mut s = EditorSession::open_project(&src).unwrap(); + + let written = s.save_project(Some(dst.clone())).unwrap(); + assert_eq!(written, dst); + assert!(dst.join("project.json").is_file()); + assert!( + !dst.join("media").exists(), + "no source media/ -> none should be created" + ); + } } diff --git a/crates/opentake-media/src/lib.rs b/crates/opentake-media/src/lib.rs index 4841578..c30af36 100644 --- a/crates/opentake-media/src/lib.rs +++ b/crates/opentake-media/src/lib.rs @@ -38,6 +38,7 @@ pub mod ort_worker; pub mod probe; pub mod search; pub mod thumbnail; +pub mod timecode; pub mod transcribe; pub mod waveform; @@ -59,10 +60,13 @@ pub use decode::{ pub use encode::{ExportPreset, ExportResolution, VideoCodec, VideoEncoder}; pub use thumbnail::{ - image_thumbnail, video_thumbnail_times, video_thumbnails, PartialThumbCallback, - ThumbnailCacheMeta, VideoThumb, + capture_project_thumbnail, image_thumbnail, pick_thumbnail_source, video_thumbnail_times, + video_thumbnails, PartialThumbCallback, ThumbnailCacheMeta, ThumbnailKind, ThumbnailSource, + VideoThumb, }; +pub use timecode::{parse_smpte_timecode, read_start_timecode_frame}; + pub use waveform::{waveform, waveform_cached, waveform_sample_count}; pub use transcribe::{ diff --git a/crates/opentake-media/src/thumbnail/mod.rs b/crates/opentake-media/src/thumbnail/mod.rs index 634aa32..c96d6b9 100644 --- a/crates/opentake-media/src/thumbnail/mod.rs +++ b/crates/opentake-media/src/thumbnail/mod.rs @@ -5,8 +5,12 @@ //! frame decode uses the system ffmpeg CLI; image thumbnails use the `image` //! crate (with EXIF orientation handled by the decoder). +pub mod project; pub mod sprite; +pub use project::{ + capture_project_thumbnail, pick_thumbnail_source, ThumbnailKind, ThumbnailSource, +}; pub use sprite::{load_sprite, save_sprite, ThumbnailCacheMeta, VideoThumb}; use std::path::Path; diff --git a/crates/opentake-media/src/thumbnail/project.rs b/crates/opentake-media/src/thumbnail/project.rs new file mode 100644 index 0000000..d3ac14c --- /dev/null +++ b/crates/opentake-media/src/thumbnail/project.rs @@ -0,0 +1,374 @@ +//! Project cover thumbnail — the representative-frame capture written into a +//! bundle's `thumbnail.jpg` on save. 1:1 port of upstream +//! `VideoProject.captureThumbnail` (`Project/VideoProject.swift:261-300`). +//! +//! Upstream walks `timeline.tracks where track.type == .video`, then each clip +//! in order, and returns the first frame it can grab: +//! - an **image** clip → `ImageEncoder.thumbnail(url, maxPixelSize: 640)` → +//! `encodeJPEG(quality: 0.7)`; +//! - a **video** clip → `AVAssetImageGenerator` (`maximumSize = 320×180`, +//! `appliesPreferredTrackTransform`) seeked to +//! `CMTime(value: clip.trimStartFrame, timescale: fps)` → JPEG `quality 0.7`. +//! +//! The **pick** ([`pick_thumbnail_source`]) is a pure function over the timeline +//! and manifest (resolvable-file filter lives here because the media layer, +//! unlike `opentake-domain`, may touch the filesystem), so the track/clip +//! selection rule is unit-testable without ffmpeg. The **capture** +//! ([`capture_project_thumbnail`]) decodes and JPEG-encodes and therefore needs +//! ffmpeg / a real image file. + +use std::path::{Path, PathBuf}; + +use opentake_domain::{ClipType, MediaManifest, MediaResolver, Timeline}; + +use crate::decode::frame::{decode_frame_at, FrameRequest}; +use crate::error::Result; +use crate::thumbnail::image_thumbnail; + +/// Long-edge cap for an **image** clip's cover, matching upstream +/// `ImageEncoder.thumbnail(url:, maxPixelSize: 640)`. +pub const IMAGE_COVER_MAX_PIXEL: u32 = 640; + +/// Box a **video** clip's cover is fit within, matching upstream +/// `generator.maximumSize = CGSize(width: 320, height: 180)`. +pub const VIDEO_COVER_MAX_SIZE: (u32, u32) = (320, 180); + +/// Seek tolerance (seconds) for the video cover grab. Upstream's +/// `AVAssetImageGenerator` uses its default tolerances (not zero); a modest +/// window keeps the grab cheap and reliably lands a decodable frame near the +/// clip's in-point. +pub const VIDEO_COVER_TOLERANCE_SECS: f64 = 1.0; + +/// JPEG quality for the cover. Upstream encodes at `compressionFactor: 0.7`; +/// `image`'s `JpegEncoder` takes a 1–100 quality, so 72 ≈ 0.7. Named (not +/// hardcoded) per the media-layer "no magic thresholds" rule. +pub const PROJECT_THUMB_JPEG_QUALITY: u8 = 72; + +/// Which decode path a picked clip needs. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ThumbnailKind { + /// Still image: decode the whole file, fit to [`IMAGE_COVER_MAX_PIXEL`]. + Image, + /// Video: seek to the in-point and grab one frame in [`VIDEO_COVER_MAX_SIZE`]. + Video, +} + +/// The representative clip chosen for the cover: the on-disk source, whether it +/// is an image or video, and (for video) the source frame to seek to (the clip's +/// `trim_start_frame`, i.e. its in-point — exactly upstream's +/// `CMTime(value: clip.trimStartFrame, …)`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ThumbnailSource { + /// Resolved, existing path to the source media file. + pub path: PathBuf, + /// Image vs video decode path. + pub kind: ThumbnailKind, + /// Source frame to seek to for video (0 for images / no trim). Absolute + /// source-frame offset like upstream's `clip.trimStartFrame`. + pub seek_frame: i32, +} + +/// Pick the representative clip for the project cover, or `None` when no video +/// track carries a resolvable image/video clip. Pure over `(timeline, manifest, +/// project_base)`; only reads the filesystem to confirm the picked file exists +/// (upstream `resolveURL` yields nothing for an unresolved ref, so an unresolved +/// clip is skipped just the same). +/// +/// Order mirrors upstream exactly: iterate tracks whose kind is `Video` +/// (**not** every visual track — audio/text/lottie tracks are skipped), then the +/// clips in stored order; the first clip that is an image or a video **and** +/// whose media resolves to an existing file wins. +pub fn pick_thumbnail_source( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, +) -> Option { + let resolver = MediaResolver::new(manifest, project_base); + for track in timeline.tracks.iter().filter(|t| t.kind == ClipType::Video) { + for clip in &track.clips { + let kind = match clip.media_type { + ClipType::Image => ThumbnailKind::Image, + ClipType::Video => ThumbnailKind::Video, + // Non-visual / text / lottie clips on a video track are not + // frame-grabbable cover sources (upstream only handles .image + // and .video), so skip them. + _ => continue, + }; + let Some(path) = resolver.expected_path(&clip.media_ref) else { + continue; // unresolved ref (no manifest entry / no project base) + }; + if !path.is_file() { + continue; // offline media — upstream's generator would fail too + } + return Some(ThumbnailSource { + path, + kind, + // Images ignore the seek; keep the clip's own value for video. + seek_frame: clip.trim_start_frame.max(0), + }); + } + } + None +} + +/// Capture the project cover as JPEG bytes, or `None` when there is no +/// representative clip (empty project / all-offline media) or the single grab +/// fails. Mirrors upstream `captureThumbnail`: pick → decode one frame → encode +/// JPEG at [`PROJECT_THUMB_JPEG_QUALITY`]. `fps` is the timeline frame rate, used +/// to convert a video clip's `seek_frame` to a seek time. +/// +/// A decode/encode failure returns `None` (not `Err`): upstream's capture is +/// best-effort and simply omits `thumbnail.jpg` on failure — the save itself must +/// never fail because a cover could not be produced. +pub fn capture_project_thumbnail( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, +) -> Option> { + let source = pick_thumbnail_source(timeline, manifest, project_base)?; + let fps = if timeline.fps > 0 { timeline.fps } else { 30 }; + encode_source(&source, fps).ok() +} + +/// Decode the picked clip's cover frame and JPEG-encode it. Split out so the +/// (ffmpeg-dependent) capture is a single fallible step the caller degrades to +/// `None`. +fn encode_source(source: &ThumbnailSource, fps: i32) -> Result> { + let frame = match source.kind { + ThumbnailKind::Image => image_thumbnail(&source.path, IMAGE_COVER_MAX_PIXEL)?, + ThumbnailKind::Video => { + let time_secs = (source.seek_frame.max(0) as f64) / fps as f64; + let req = FrameRequest { + time_secs, + max_size: VIDEO_COVER_MAX_SIZE, + tolerance_secs: VIDEO_COVER_TOLERANCE_SECS, + apply_rotation: true, // upstream appliesPreferredTrackTransform + }; + let (_actual, frame) = decode_frame_at(&source.path, &req)?; + frame + } + }; + encode_jpeg(&frame) +} + +/// Encode an [`RgbaFrame`](crate::frame::RgbaFrame) as JPEG (alpha dropped → RGB) +/// at [`PROJECT_THUMB_JPEG_QUALITY`]. Mirrors the sprite cache's JPEG path. +fn encode_jpeg(frame: &crate::frame::RgbaFrame) -> Result> { + let rgba = image::RgbaImage::from_raw(frame.width, frame.height, frame.rgba.clone()) + .ok_or_else(|| crate::error::MediaError::Encode("thumbnail: bad rgba buffer".into()))?; + let rgb = image::DynamicImage::ImageRgba8(rgba).to_rgb8(); + let mut jpg_bytes = Vec::new(); + { + let mut encoder = image::codecs::jpeg::JpegEncoder::new_with_quality( + &mut jpg_bytes, + PROJECT_THUMB_JPEG_QUALITY, + ); + encoder + .encode( + rgb.as_raw(), + rgb.width(), + rgb.height(), + image::ExtendedColorType::Rgb8, + ) + .map_err(|e| crate::error::MediaError::Encode(format!("thumbnail jpeg: {e}")))?; + } + Ok(jpg_bytes) +} + +#[cfg(test)] +mod tests { + use super::*; + use opentake_domain::{ + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, + }; + use std::fs; + use std::path::PathBuf; + + /// A per-call-unique scratch dir under the system temp dir, removed on drop. + struct TmpDir(PathBuf); + impl TmpDir { + fn new(tag: &str) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let p = std::env::temp_dir().join(format!( + "opentake-projthumb-{tag}-{}-{n}", + std::process::id() + )); + let _ = fs::remove_dir_all(&p); + fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + fn entry(id: &str, kind: ClipType, abs_path: &Path) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: id.into(), + kind, + source: MediaSource::External { + absolute_path: abs_path.to_string_lossy().into_owned(), + }, + duration: 1.0, + generation_input: None, + source_width: Some(4), + source_height: Some(4), + source_fps: None, + has_audio: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } + } + + fn clip(id: &str, media_ref: &str, media_type: ClipType, trim_start: i32) -> Clip { + let mut c = Clip::new(id, media_ref, 0, 30); + c.media_type = media_type; + c.trim_start_frame = trim_start; + c + } + + /// Write a real PNG so `expected_path().is_file()` passes (the pick filters + /// on existence, matching upstream `resolveURL` returning nothing offline). + fn touch_png(dir: &Path, name: &str) -> PathBuf { + let p = dir.join(name); + image::RgbaImage::from_pixel(4, 4, image::Rgba([10, 20, 30, 255])) + .save(&p) + .unwrap(); + p + } + + #[test] + fn pick_returns_none_for_empty_timeline() { + let tl = Timeline::new(); + let manifest = MediaManifest::new(); + assert_eq!(pick_thumbnail_source(&tl, &manifest, None), None); + } + + #[test] + fn pick_takes_first_resolvable_image_clip_on_video_track() { + let dir = TmpDir::new("pick-image"); + let img = touch_png(dir.path(), "pic.png"); + let mut manifest = MediaManifest::new(); + manifest.entries.push(entry("a1", ClipType::Image, &img)); + + let mut tl = Timeline::new(); + let mut vt = Track::new("vt", ClipType::Video); + vt.clips.push(clip("c1", "a1", ClipType::Image, 0)); + tl.tracks.push(vt); + + let picked = pick_thumbnail_source(&tl, &manifest, None).expect("picked"); + assert_eq!(picked.kind, ThumbnailKind::Image); + assert_eq!(picked.path, img); + assert_eq!(picked.seek_frame, 0); + } + + #[test] + fn pick_carries_video_trim_start_as_seek_frame() { + let dir = TmpDir::new("pick-video"); + // A video clip whose source "file" merely needs to exist for the pick + // (the pick never decodes; it only checks `is_file()`). Plain bytes are + // enough and avoid the image crate rejecting a `.mp4` extension. + let vid = dir.path().join("shot.mp4"); + fs::write(&vid, b"not-real-video").unwrap(); + let mut manifest = MediaManifest::new(); + manifest.entries.push(entry("v1", ClipType::Video, &vid)); + + let mut tl = Timeline::new(); + let mut vt = Track::new("vt", ClipType::Video); + vt.clips.push(clip("c1", "v1", ClipType::Video, 45)); + tl.tracks.push(vt); + + let picked = pick_thumbnail_source(&tl, &manifest, None).expect("picked"); + assert_eq!(picked.kind, ThumbnailKind::Video); + assert_eq!(picked.seek_frame, 45); + } + + #[test] + fn pick_skips_audio_tracks_and_offline_clips() { + let dir = TmpDir::new("pick-skip"); + // Present on disk, but on an AUDIO track → must be skipped (upstream only + // scans `.video` tracks). + let on_audio = touch_png(dir.path(), "onaudio.png"); + // On a video track but its file does NOT exist → offline, skipped. + let missing_path = dir.path().join("gone.png"); + // The one that should win: second clip on the video track, present. + let good = touch_png(dir.path(), "good.png"); + + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(entry("aud", ClipType::Image, &on_audio)); + manifest + .entries + .push(entry("missing", ClipType::Image, &missing_path)); + manifest.entries.push(entry("good", ClipType::Image, &good)); + + let mut tl = Timeline::new(); + let mut at = Track::new("at", ClipType::Audio); + at.clips.push(clip("ca", "aud", ClipType::Image, 0)); // ignored: audio track + let mut vt = Track::new("vt", ClipType::Video); + vt.clips + .push(clip("c-missing", "missing", ClipType::Image, 0)); // offline + vt.clips.push(clip("c-good", "good", ClipType::Image, 0)); // winner + tl.tracks.push(at); + tl.tracks.push(vt); + + let picked = pick_thumbnail_source(&tl, &manifest, None).expect("picked"); + assert_eq!(picked.path, good); + } + + #[test] + fn pick_skips_text_clips_on_video_track() { + let dir = TmpDir::new("pick-text"); + let img = touch_png(dir.path(), "real.png"); + let mut manifest = MediaManifest::new(); + // Text clips carry no manifest media entry; only the image does. + manifest.entries.push(entry("img", ClipType::Image, &img)); + + let mut tl = Timeline::new(); + let mut vt = Track::new("vt", ClipType::Video); + vt.clips.push(clip("t1", "text-ref", ClipType::Text, 0)); // skipped + vt.clips.push(clip("i1", "img", ClipType::Image, 0)); // winner + tl.tracks.push(vt); + + let picked = pick_thumbnail_source(&tl, &manifest, None).expect("picked"); + assert_eq!(picked.kind, ThumbnailKind::Image); + assert_eq!(picked.path, img); + } + + #[test] + fn capture_encodes_jpeg_for_image_clip() { + let dir = TmpDir::new("cap-image"); + let img = touch_png(dir.path(), "cover.png"); + let mut manifest = MediaManifest::new(); + manifest.entries.push(entry("a1", ClipType::Image, &img)); + let mut tl = Timeline::new(); + let mut vt = Track::new("vt", ClipType::Video); + vt.clips.push(clip("c1", "a1", ClipType::Image, 0)); + tl.tracks.push(vt); + + let bytes = capture_project_thumbnail(&tl, &manifest, None).expect("jpeg bytes"); + assert!(!bytes.is_empty()); + // JPEG SOI marker. + assert_eq!(&bytes[..2], &[0xFF, 0xD8]); + // Decodable back to an image. + let decoded = image::load_from_memory(&bytes).expect("decode jpeg"); + assert!(decoded.width() > 0 && decoded.height() > 0); + } + + #[test] + fn capture_returns_none_without_representative_clip() { + let tl = Timeline::new(); + let manifest = MediaManifest::new(); + assert!(capture_project_thumbnail(&tl, &manifest, None).is_none()); + } +} diff --git a/crates/opentake-media/src/timecode.rs b/crates/opentake-media/src/timecode.rs new file mode 100644 index 0000000..138c99b --- /dev/null +++ b/crates/opentake-media/src/timecode.rs @@ -0,0 +1,361 @@ +//! Source start timecode — the ffprobe equivalent of upstream +//! `XMLExporter.Builder.readStartTimecodeFrame` (`Export/XMLExporter.swift:245-262`), +//! which reads a media file's start timecode so the XMEML `` node +//! carries the real source offset instead of a `00:00:00:00` dummy. +//! +//! ## Why a string parser (upstream reads a raw tmcd frame count) +//! Upstream pulls the QuickTime `tmcd` timecode track via `AVAssetReader` and +//! reads the leading **big-endian UInt32 frame count** straight out of the sample +//! buffer — it never parses a `"HH:MM:SS:FF"` string. That AVFoundation path has +//! no cross-platform equivalent. ffprobe instead surfaces the same information as +//! a formatted string tag (`tags.timecode`), so this module reads that string and +//! converts it back to a start **frame** at the file's frame rate. The two routes +//! yield the same integer for a file whose tmcd track holds `HH:MM:SS:FF`. +//! +//! The string→frame conversion ([`parse_smpte_timecode`]): +//! - **NDF** (`HH:MM:SS:FF`): `frame = ((hh*60+mm)*60+ss)*fps + ff` — the exact +//! inverse of upstream `formatTimecode`'s non-drop path (`:265-275`). +//! - **DF** (`HH:MM:SS;FF`, 29.97/59.94): *subtracts* the standard SMPTE drop +//! count (`drop * (total_minutes − total_minutes/10)`, +//! `drop = round(fps*0.066666)`) from the naive wall-clock frame count. This is +//! the canonical inverse of the **valid** drop-frame strings ffprobe emits. +//! (Upstream reads a raw tmcd frame count and never parses a DF *string*, and +//! its own `formatTimecode` linear-offsets rather than skipping the `;00`/`;01` +//! boundary frames — so there is no upstream string→frame DF reference to +//! mirror; canonical SMPTE is the correct target for real ffprobe input.) +//! +//! The parser is pure and unit-tested; the ffprobe read ([`read_start_timecode_frame`]) +//! follows the invocation pattern in [`crate::probe`] / [`crate::ff`] and needs a +//! real file, so it is exercised only when a media fixture is available. + +use std::path::Path; + +use crate::ff; + +/// Parse an SMPTE timecode string (`"HH:MM:SS:FF"` non-drop, or `"HH:MM:SS;FF"` +/// drop-frame) to a start **frame** at `fps`, or `None` for malformed input. +/// +/// The separator before the frames field selects the mode: `;` (or `.`, the +/// alternate drop-frame separator some tools emit) means drop-frame; `:` means +/// non-drop. Drop-frame math is only applied when `fps` actually rounds to a +/// drop-frame rate (30 or 60) — a `;` on a 25 fps file is treated as non-drop, so +/// a mis-tagged separator never corrupts the frame count. +/// +/// `fps` is the integer timebase (upstream `rateTags` timebase: 30 for 29.97, +/// 60 for 59.94, …); `<= 0` yields `None`. +pub fn parse_smpte_timecode(input: &str, fps: i32) -> Option { + if fps <= 0 { + return None; + } + let s = input.trim(); + if s.is_empty() { + return None; + } + + // The frames field is separated from seconds by ':' (NDF) or ';' / '.' (DF). + // Split off the final field first, remembering which separator was used, then + // split the remaining HH:MM:SS on ':'. + let drop_sep_pos = s.rfind([';', '.']); + let (head, frames_str, drop_frame) = match drop_sep_pos { + Some(pos) => (&s[..pos], &s[pos + 1..], true), + None => { + let pos = s.rfind(':')?; + (&s[..pos], &s[pos + 1..], false) + } + }; + + let mut parts = head.split(':'); + let hh = parse_field(parts.next()?)?; + let mm = parse_field(parts.next()?)?; + let ss = parse_field(parts.next()?)?; + if parts.next().is_some() { + return None; // too many ':' groups + } + let ff = parse_field(frames_str)?; + + // Range sanity: minutes/seconds must be clock-valid; frames below the + // timebase. (Upstream never validates because the tmcd frame count is already + // canonical; here the string is external input, so reject nonsense.) + if mm >= 60 || ss >= 60 || ff >= fps { + return None; + } + + let total_seconds = (hh * 60 + mm) * 60 + ss; + let naive = total_seconds * fps + ff; + + // Only compensate when the timebase is genuinely a drop-frame rate; a stray + // ';' on 24/25 fps is honored as plain non-drop. + if drop_frame && is_drop_frame_rate(fps) { + let drop = drop_frames_per_minute(fps); + let total_minutes = hh * 60 + mm; + let dropped = drop * (total_minutes - total_minutes / 10); + Some(naive - dropped) + } else { + Some(naive) + } +} + +/// Parse one non-negative integer field, rejecting signs / non-digits so that a +/// stray `-` or letter fails the whole timecode rather than silently truncating. +fn parse_field(s: &str) -> Option { + let s = s.trim(); + if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse::().ok() +} + +/// A drop-frame rate is one whose integer timebase is a multiple of 30 (30 → +/// 29.97, 60 → 59.94). Mirrors upstream's `dropFrame = ntsc && timebase % 30 == 0` +/// gate, minus the ntsc flag (which the string tag doesn't carry). +fn is_drop_frame_rate(fps: i32) -> bool { + fps % 30 == 0 +} + +/// Frames dropped per minute (except every 10th): `round(fps * 0.066666)` — 2 at +/// 30, 4 at 60. Verbatim from upstream `formatTimecode`'s `drop` computation. +fn drop_frames_per_minute(fps: i32) -> i32 { + (fps as f64 * 0.066666).round() as i32 +} + +/// Read a media file's start timecode as a frame count at `fps`, or `None` when +/// the file carries no timecode tag / the tag is unparseable / ffprobe is +/// unavailable. ffprobe exposes the QuickTime `tmcd` timecode (and container +/// timecode metadata) as a `tags.timecode` string on a stream or on the format; +/// this reads whichever is present and converts it via [`parse_smpte_timecode`]. +/// +/// `fps` is the export timebase the caller already computed for the file (upstream +/// `rateTags` timebase), so the frames-per-second used for parsing matches the +/// `` written alongside the `` node. +pub fn read_start_timecode_frame(path: &Path, fps: i32) -> Option { + let json = ff::ffprobe_json(path).ok()?; + let tag = timecode_tag(&json)?; + parse_smpte_timecode(&tag, fps) +} + +/// Extract the first `tags.timecode` string from ffprobe JSON, preferring a +/// stream tag (the `tmcd` track / video stream) and falling back to the format +/// (container) tag. Pure over the JSON so the lookup precedence is unit-testable +/// without invoking ffprobe. +pub fn timecode_tag(json: &serde_json::Value) -> Option { + // Stream tags first: a dedicated timecode stream or a video stream that + // carries the tmcd metadata. + if let Some(streams) = json.get("streams").and_then(|v| v.as_array()) { + for s in streams { + if let Some(tc) = tags_timecode(s) { + return Some(tc); + } + } + } + // Container-level fallback (`format.tags.timecode`). + tags_timecode(json.get("format")?) +} + +/// `.tags.timecode` as a non-empty trimmed string. +fn tags_timecode(obj: &serde_json::Value) -> Option { + let tc = obj + .get("tags")? + .get("timecode")? + .as_str()? + .trim() + .to_string(); + if tc.is_empty() { + None + } else { + Some(tc) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + // --- NDF: plain inverse of upstream formatTimecode's non-drop path --- + + #[test] + fn ndf_zero_is_frame_zero() { + assert_eq!(parse_smpte_timecode("00:00:00:00", 30), Some(0)); + } + + #[test] + fn ndf_counts_frames_seconds_minutes_hours() { + // 1h 2m 3s 4f @ 30 = ((1*60+2)*60+3)*30 + 4 = (3723)*30 + 4 = 111694. + assert_eq!(parse_smpte_timecode("01:02:03:04", 30), Some(111_694)); + } + + #[test] + fn ndf_one_second_is_fps_frames() { + assert_eq!(parse_smpte_timecode("00:00:01:00", 24), Some(24)); + assert_eq!(parse_smpte_timecode("00:00:01:00", 25), Some(25)); + } + + #[test] + fn ndf_frames_field_adds_directly() { + assert_eq!(parse_smpte_timecode("00:00:00:29", 30), Some(29)); + } + + #[test] + fn ndf_is_exact_inverse_of_upstream_format_timecode_non_drop() { + // Reproduce upstream formatTimecode(non-drop) for several frames and + // confirm parse() round-trips back to the original frame index. + let fps = 25; + for &frame in &[0, 1, 24, 25, 26, 1000, 90_061] { + let ff = frame % fps; + let ss = (frame / fps) % 60; + let mm = (frame / (fps * 60)) % 60; + let hh = frame / (fps * 3600); + let s = format!("{hh:02}:{mm:02}:{ss:02}:{ff:02}"); + assert_eq!(parse_smpte_timecode(&s, fps), Some(frame), "frame {frame}"); + } + } + + // --- DF: inverse of upstream's drop-frame path (subtract SMPTE drop count) --- + + #[test] + fn df_first_minute_matches_naive_count() { + // Within the first minute nothing is dropped: 00:00:10;15 @ 30 = 10*30+15. + assert_eq!(parse_smpte_timecode("00:00:10;15", 30), Some(315)); + } + + #[test] + fn df_drops_two_frames_at_first_whole_minute() { + // Drop-frame 29.97: at 00:01:00;02 the two frames 00:01:00;00 and ;01 do + // not exist, so it is the frame immediately after 00:00:59;29. + // naive(00:01:00;02) = 60*30 + 2 = 1802; dropped = 2*(1 - 0) = 2 → 1800. + // naive(00:00:59;29) = 59*30 + 29 = 1799 → +1 = 1800. They meet at 1800. + assert_eq!(parse_smpte_timecode("00:00:59;29", 30), Some(1799)); + assert_eq!(parse_smpte_timecode("00:01:00;02", 30), Some(1800)); + } + + #[test] + fn df_no_drop_on_tenth_minute() { + // Every 10th minute keeps its frames: 00:10:00;00 drops 2*(10 - 1) = 18. + // naive = 10*60*30 = 18000 → 18000 - 18 = 17982. + assert_eq!(parse_smpte_timecode("00:10:00;00", 30), Some(17_982)); + } + + #[test] + fn df_is_inverse_of_standard_drop_frame_encoding() { + // ffprobe's `tags.timecode` for a 29.97 file is a *valid* SMPTE + // drop-frame string (the two frames `;00`/`;01` are skipped at each + // minute except every tenth). Encode a frame index to that canonical + // string, then confirm parse() reads back the same integer. + // + // NOTE: upstream `formatTimecode` does NOT skip those boundary frames — + // it linearly offsets and can emit the invalid `00:01:00;00`. Upstream + // never *parses* timecode (it reads a raw tmcd frame count), so it + // provides no string→frame DF reference; the canonical SMPTE encoding + // that real ffprobe output uses is the correct inverse target here. + let fps = 30; + let drop = (fps as f64 * 0.066666).round() as i32; // 2 + // Canonical SMPTE drop-frame: frame index -> valid HH:MM:SS;FF. + let forward = |frame: i32| -> String { + let frames_per_10min = fps * 600 - drop * 9; + let frames_per_min_df = fps * 60 - drop; + let d = frame / frames_per_10min; + let m = frame % frames_per_10min; + let f = if m > drop { + frame + drop * 9 * d + drop * ((m - drop) / frames_per_min_df) + } else { + frame + drop * 9 * d + }; + let ff = f % fps; + let ss = (f / fps) % 60; + let mm = (f / (fps * 60)) % 60; + let hh = f / (fps * 3600); + format!("{hh:02}:{mm:02}:{ss:02};{ff:02}") + }; + for &frame in &[0, 1, 1799, 1800, 1801, 17_982, 20_000, 107_892] { + let s = forward(frame); + assert_eq!( + parse_smpte_timecode(&s, fps), + Some(frame), + "frame {frame} via {s}" + ); + } + } + + #[test] + fn df_separator_on_non_drop_rate_is_treated_as_non_drop() { + // A ';' on 25 fps must NOT apply drop math (25 is not a drop-frame rate). + assert_eq!(parse_smpte_timecode("00:10:00;00", 25), Some(15_000)); + } + + #[test] + fn dot_separator_is_drop_frame() { + // Some tools emit '.' as the drop-frame separator; treat it like ';'. + assert_eq!( + parse_smpte_timecode("00:10:00.00", 30), + parse_smpte_timecode("00:10:00;00", 30) + ); + } + + // --- Malformed input → None --- + + #[test] + fn rejects_bad_shapes() { + assert_eq!(parse_smpte_timecode("", 30), None); + assert_eq!(parse_smpte_timecode("garbage", 30), None); + assert_eq!(parse_smpte_timecode("00:00:00", 30), None); // only 3 fields + assert_eq!(parse_smpte_timecode("00:00:00:00:00", 30), None); // 5 fields + assert_eq!(parse_smpte_timecode("aa:bb:cc:dd", 30), None); + assert_eq!(parse_smpte_timecode("00:00:00:-1", 30), None); // signed field + assert_eq!(parse_smpte_timecode("01:02:03:04", 0), None); // bad fps + } + + #[test] + fn rejects_out_of_range_fields() { + assert_eq!(parse_smpte_timecode("00:60:00:00", 30), None); // minutes + assert_eq!(parse_smpte_timecode("00:00:60:00", 30), None); // seconds + assert_eq!(parse_smpte_timecode("00:00:00:30", 30), None); // frame >= fps + assert_eq!(parse_smpte_timecode("00:00:00:24", 24), None); // frame == fps + } + + // --- timecode_tag JSON lookup precedence --- + + #[test] + fn timecode_tag_prefers_stream_over_format() { + let j = json!({ + "streams": [ + {"codec_type": "video", "tags": {"timecode": "01:00:00:00"}} + ], + "format": {"tags": {"timecode": "02:00:00:00"}} + }); + assert_eq!(timecode_tag(&j).as_deref(), Some("01:00:00:00")); + } + + #[test] + fn timecode_tag_falls_back_to_format() { + let j = json!({ + "streams": [{"codec_type": "video"}], + "format": {"tags": {"timecode": "00:00:10:00"}} + }); + assert_eq!(timecode_tag(&j).as_deref(), Some("00:00:10:00")); + } + + #[test] + fn timecode_tag_absent_is_none() { + let j = json!({"streams": [{"codec_type": "video"}], "format": {}}); + assert_eq!(timecode_tag(&j), None); + } + + #[test] + fn timecode_tag_empty_string_is_none() { + let j = json!({"streams": [], "format": {"tags": {"timecode": " "}}}); + assert_eq!(timecode_tag(&j), None); + } + + #[test] + fn timecode_tag_scans_multiple_streams() { + // Audio stream first with no tc; the tmcd/video stream carries it. + let j = json!({ + "streams": [ + {"codec_type": "audio"}, + {"codec_type": "data", "tags": {"timecode": "00:05:00:00"}} + ], + "format": {} + }); + assert_eq!(timecode_tag(&j).as_deref(), Some("00:05:00:00")); + } +} diff --git a/crates/opentake-project/src/bundle.rs b/crates/opentake-project/src/bundle.rs index 314b281..d9e1eeb 100644 --- a/crates/opentake-project/src/bundle.rs +++ b/crates/opentake-project/src/bundle.rs @@ -148,6 +148,95 @@ impl Project { } } +/// Copy a source bundle's `media/` directory into `dest_bundle`, recursively, +/// preserving the relative layout — the port of upstream `mediaDirWrapper` +/// (`Project/VideoProject.swift:112-117`), which folds the whole `media/` +/// directory into the saved package on every save/save-as. Save-as builds the +/// new bundle at a fresh path; without this, project-internal media +/// ([`MediaSource::Project`](opentake_domain::MediaSource) relative paths — AI +/// output, pasted, captured stills) is left behind and every reference silently +/// dangles. +/// +/// Contract: +/// - **Missing source `media/`** → no-op `Ok(())` (upstream returns `nil` from +/// `mediaDirWrapper` when the dir doesn't exist; nothing to carry). +/// - **Same-path save** (source and dest bundle are the same directory) → no-op, +/// so autosave never copies `media/` onto itself. +/// - **Partial-copy failure** → the destination `media/` is never left +/// half-populated: the tree is staged into a sibling temp directory and +/// atomically renamed into place only after a fully successful copy; any error +/// removes the temp staging and propagates, matching the atomic-replace +/// philosophy [`archive`](crate::archive) uses. +pub fn copy_media_dir(source_bundle: &Path, dest_bundle: &Path) -> Result<()> { + // Same bundle (autosave / plain save): nothing to copy. Compare with + // `standardize`-free canonical-ish equality via the same-path check the + // caller already knows; here we guard the source==dest media dir case so a + // direct call is self-protecting too. + if source_bundle == dest_bundle { + return Ok(()); + } + + let src_media = layout::media_dir(source_bundle); + if !src_media.is_dir() { + return Ok(()); // upstream: no media/ dir -> no wrapper -> nothing written + } + + let dest_media = layout::media_dir(dest_bundle); + create_dir_all(dest_bundle)?; + + // Stage into a sibling temp dir, then atomically swap into `media/` so a + // failure mid-copy never leaves a partially populated `media/`. + let staging = temp_sibling(&dest_media); + // A stale staging dir from a crashed prior run would break create_dir_all's + // freshness; clear it first (best-effort). + let _ = fs::remove_dir_all(&staging); + if let Err(e) = copy_dir_recursive(&src_media, &staging) { + let _ = fs::remove_dir_all(&staging); + return Err(e); + } + + // Replace any existing dest `media/` with the freshly staged tree. `rename` + // onto an existing directory fails on most platforms, so remove first; the + // window between remove and rename is the same one `write_bytes_atomic` + // accepts for JSON components. + if dest_media.exists() { + if let Err(e) = fs::remove_dir_all(&dest_media) { + let _ = fs::remove_dir_all(&staging); + return Err(ProjectError::io(&dest_media, e)); + } + } + match fs::rename(&staging, &dest_media) { + Ok(()) => Ok(()), + Err(e) => { + let _ = fs::remove_dir_all(&staging); + Err(ProjectError::io(&dest_media, e)) + } + } +} + +/// Recursively copy directory `src` into `dest`, creating `dest` and mirroring +/// the subtree. Shared by [`copy_media_dir`]; kept here (rather than reused from +/// [`crate::archive`], whose copy helper is private and coupled to its report +/// bookkeeping) so bundle save stays self-contained. +fn copy_dir_recursive(src: &Path, dest: &Path) -> Result<()> { + create_dir_all(dest)?; + let entries = fs::read_dir(src).map_err(|e| ProjectError::io(src, e))?; + for entry in entries { + let entry = entry.map_err(|e| ProjectError::io(src, e))?; + let from = entry.path(); + let to = dest.join(entry.file_name()); + let file_type = entry.file_type().map_err(|e| ProjectError::io(&from, e))?; + if file_type.is_dir() { + copy_dir_recursive(&from, &to)?; + } else { + fs::copy(&from, &to) + .map(|_| ()) + .map_err(|e| ProjectError::io(&to, e))?; + } + } + Ok(()) +} + // --- IO helpers (each tags the failing path) --- fn read_file(path: &Path) -> Result> { @@ -197,3 +286,95 @@ fn temp_sibling(dest: &Path) -> PathBuf { None => PathBuf::from(tmp_name), } } + +#[cfg(test)] +mod tests { + use super::*; + + /// A per-call-unique scratch dir under the system temp dir, removed on drop. + struct TmpDir(PathBuf); + impl TmpDir { + fn new(tag: &str) -> Self { + use std::sync::atomic::{AtomicU64, Ordering}; + static N: AtomicU64 = AtomicU64::new(0); + let n = N.fetch_add(1, Ordering::Relaxed); + let p = std::env::temp_dir() + .join(format!("opentake-bundle-{tag}-{}-{n}", std::process::id())); + let _ = fs::remove_dir_all(&p); + fs::create_dir_all(&p).unwrap(); + TmpDir(p) + } + fn path(&self) -> &Path { + &self.0 + } + } + impl Drop for TmpDir { + fn drop(&mut self) { + let _ = fs::remove_dir_all(&self.0); + } + } + + #[test] + fn copy_media_dir_mirrors_nested_layout() { + let tmp = TmpDir::new("nested"); + let src = tmp.path().join("Src.opentake"); + let dst = tmp.path().join("Dst.opentake"); + let src_media = layout::media_dir(&src); + fs::create_dir_all(src_media.join("sub")).unwrap(); + fs::write(src_media.join("a.png"), b"AAA").unwrap(); + fs::write(src_media.join("sub").join("b.mov"), b"BBBB").unwrap(); + + copy_media_dir(&src, &dst).unwrap(); + + assert_eq!(fs::read(dst.join("media").join("a.png")).unwrap(), b"AAA"); + assert_eq!( + fs::read(dst.join("media").join("sub").join("b.mov")).unwrap(), + b"BBBB" + ); + } + + #[test] + fn copy_media_dir_missing_source_is_noop() { + let tmp = TmpDir::new("missing"); + let src = tmp.path().join("Src.opentake"); // no media/ under it + let dst = tmp.path().join("Dst.opentake"); + fs::create_dir_all(&src).unwrap(); + + copy_media_dir(&src, &dst).unwrap(); + assert!(!dst.join("media").exists()); + } + + #[test] + fn copy_media_dir_same_path_is_noop() { + let tmp = TmpDir::new("same"); + let bundle = tmp.path().join("Same.opentake"); + let media = layout::media_dir(&bundle); + fs::create_dir_all(&media).unwrap(); + fs::write(media.join("keep.png"), b"KEEP").unwrap(); + + // Source == dest: must not touch (delete/replace) the existing media/. + copy_media_dir(&bundle, &bundle).unwrap(); + assert_eq!(fs::read(media.join("keep.png")).unwrap(), b"KEEP"); + } + + #[test] + fn copy_media_dir_replaces_existing_dest_media() { + let tmp = TmpDir::new("replace"); + let src = tmp.path().join("Src.opentake"); + let dst = tmp.path().join("Dst.opentake"); + fs::create_dir_all(layout::media_dir(&src)).unwrap(); + fs::write(layout::media_dir(&src).join("new.png"), b"NEW").unwrap(); + // Pre-existing stale file in the destination media/ that is NOT in the + // source; a full swap must not leave it behind. + fs::create_dir_all(layout::media_dir(&dst)).unwrap(); + fs::write(layout::media_dir(&dst).join("stale.png"), b"OLD").unwrap(); + + copy_media_dir(&src, &dst).unwrap(); + + assert_eq!(fs::read(dst.join("media").join("new.png")).unwrap(), b"NEW"); + assert!( + !dst.join("media").join("stale.png").exists(), + "stale dest media should be replaced, not merged" + ); + } +} diff --git a/crates/opentake-project/src/fcpxml.rs b/crates/opentake-project/src/fcpxml.rs index e37eba3..17985e5 100644 --- a/crates/opentake-project/src/fcpxml.rs +++ b/crates/opentake-project/src/fcpxml.rs @@ -28,9 +28,12 @@ //! - 关键帧插值曲线(linear/hold/smooth):导入时用默认缓动 //! //! 与上游的两点差异(都是跨平台降级,语义对齐): -//! - **源起始时间码**:上游用 AVFoundation 读 QuickTime `tmcd` 轨;Rust/Tauri 无等价 -//! 实现,这里 1:1 降级为 startFrame=0 + `00:00:00:00`(正是上游读不到 tmcd 时的回退 -//! 分支)。源时间码读取为后续(可用 ffprobe 补)。 +//! - **源起始时间码**:上游用 AVFoundation 读 QuickTime `tmcd` 轨(读到的原始 UInt32 +//! 帧数)。Rust/Tauri 无等价实现,改由调用方(src-tauri)经 ffprobe 读 +//! `tags.timecode` 字符串、用 [`opentake_media::read_start_timecode_frame`] 转成起始帧, +//! 通过 [`export_xmeml_with_timecodes`] 注入本模块;读不到时退回 startFrame=0 + +//! `00:00:00:00`(正是上游 `sourceStartFrame(for:) ?? 0` 的回退分支)。本模块自身保持 +//! 零 IO,`export_xmeml` 不注入任何时间码即等价旧行为(全 0)。 //! - **文件存在性检查**:domain 的 [`MediaResolver`] 是零 IO 的(只算 expected_path), //! 上游 `resolveURL` 会过滤解析不到的 clip。这里在本模块内用 `expected_path() + //! is_file()` 复刻过滤语义,不污染 domain 的零 IO 约束;过滤后 link 的 @@ -51,14 +54,31 @@ fn seconds_to_frame(seconds: f64, fps: i32) -> i32 { } /// 把 [`Timeline`] 导出为 XMEML 4(FCP7 XML)字符串。纯函数:输入时间线、媒体清单、 -/// 以及解析 `Project` 相对路径所需的工程目录,输出完整 XML 文本。 +/// 以及解析 `Project` 相对路径所需的工程目录,输出完整 XML 文本。每个源文件的 +/// `` 起始帧一律为 0(降级为 `00:00:00:00`)—— 要注入真实源起始时间码, +/// 用 [`export_xmeml_with_timecodes`]。 pub fn export_xmeml( timeline: &Timeline, manifest: &MediaManifest, project_base: Option<&Path>, +) -> String { + export_xmeml_with_timecodes(timeline, manifest, project_base, &HashMap::new()) +} + +/// 同 [`export_xmeml`],但注入每个源文件的**起始时间码帧**(`media_ref → start +/// frame`)。命中的 `media_ref` 会把该帧写入其 ``(1:1 对应上游 +/// `sourceStartFrame(for:)` 的返回值,`Export/XMLExporter.swift:220,238-243`);未命中 +/// 或值缺失时退回 0(即上游读不到 tmcd 轨时的 `?? 0` 分支)。保持纯函数:时间码由 +/// 调用方(src-tauri)用 [`opentake_media::read_start_timecode_frame`] 经 ffprobe 解析后 +/// 传入,本模块不做任何 IO。 +pub fn export_xmeml_with_timecodes( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, + start_timecodes: &HashMap, ) -> String { let resolver = MediaResolver::new(manifest, project_base); - Builder::new(timeline, &resolver).build() + Builder::new(timeline, &resolver, start_timecodes).build() } // MARK: - Builder @@ -93,10 +113,17 @@ struct Builder<'a> { clip_addresses: HashMap, /// link group id → 该组的片段(按出现顺序)。 clips_by_link_group: HashMap>, + /// media_ref → 源起始时间码帧(注入,缺失即 0)。对应上游 `startFrameCache` + /// 已解析的结果,只是这里由调用方经 ffprobe 预先算好传入。 + start_timecodes: &'a HashMap, } impl<'a> Builder<'a> { - fn new(timeline: &'a Timeline, resolver: &'a MediaResolver<'a>) -> Self { + fn new( + timeline: &'a Timeline, + resolver: &'a MediaResolver<'a>, + start_timecodes: &'a HashMap, + ) -> Self { Builder { timeline, resolver, @@ -106,6 +133,7 @@ impl<'a> Builder<'a> { emitted_files: HashSet::new(), clip_addresses: HashMap::new(), clips_by_link_group: HashMap::new(), + start_timecodes, } } @@ -385,9 +413,11 @@ impl<'a> Builder<'a> { el("media", vec![el("video", video_children)]) }; - // timecode 是 DaVinci Resolve 必需的。源时间码无跨平台读取实现,降级为 0。 + // timecode 是 DaVinci Resolve 必需的。源起始时间码由调用方经 ffprobe 注入 + // (`start_timecodes`);未命中退回 0——正是上游 `sourceStartFrame(for:) ?? 0` + // 读不到 tmcd 轨时的分支。 let drop_frame = ntsc && timebase % 30 == 0; - let start_frame = 0; // 源起始时间码读取为后续(上游无 tmcd 时也是 0)。 + let start_frame = self.start_timecodes.get(media_ref).copied().unwrap_or(0); let timecode = el( "timecode", vec![ @@ -1459,6 +1489,63 @@ mod tests { fs::remove_dir_all(&dir).ok(); } + #[test] + fn export_injected_start_timecode_lands_in_file_timecode() { + let dir = std::env::temp_dir().join(format!("opentake-xmeml-tc-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let vpath = touch(&dir, "tc.mp4"); + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(ext_entry("v1", "tc.mp4", ClipType::Video, &vpath, 4.0)); + let mut tl = Timeline::new(); // fps 30 + let mut vtrack = Track::new("vt", ClipType::Video); + vtrack.clips.push(Clip::new("c1", "v1", 0, 30)); + tl.tracks.push(vtrack); + + // 注入 1s 起始时间码:30 fps → 30 帧 → 00:00:01:00。 + let mut tcs = HashMap::new(); + tcs.insert("v1".to_string(), 30); + let xml = export_xmeml_with_timecodes(&tl, &manifest, None, &tcs); + + // 里应出现注入的帧与其 SMPTE 串,而非 00:00:00:00。 + assert!(xml.contains("30")); + assert!(xml.contains("00:00:01:00")); + // sequence 顶层 timecode 仍是固定的 00:00:00:00(未被 per-file 注入影响)。 + assert!(xml.contains("00:00:00:00")); + fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn export_absent_timecode_falls_back_to_zero() { + let dir = std::env::temp_dir().join(format!("opentake-xmeml-tc0-{}", std::process::id())); + fs::create_dir_all(&dir).unwrap(); + let vpath = touch(&dir, "notc.mp4"); + let mut manifest = MediaManifest::new(); + manifest + .entries + .push(ext_entry("v1", "notc.mp4", ClipType::Video, &vpath, 4.0)); + let mut tl = Timeline::new(); + let mut vtrack = Track::new("vt", ClipType::Video); + vtrack.clips.push(Clip::new("c1", "v1", 0, 30)); + tl.tracks.push(vtrack); + + // 一个不含本文件的 map(命中另一个 ref),该文件必须退回 0。 + let mut other = HashMap::new(); + other.insert("someone-else".to_string(), 999); + let injected = export_xmeml_with_timecodes(&tl, &manifest, None, &other); + // 便捷入口(无注入)也应等价。 + let plain = export_xmeml(&tl, &manifest, None); + + for xml in [&injected, &plain] { + assert!(xml.contains("0")); + // 该文件 的 string 为 00:00:00:00;整份文档不含 999。 + assert!(xml.contains("00:00:00:00")); + assert!(!xml.contains("999")); + } + fs::remove_dir_all(&dir).ok(); + } + #[test] fn export_position_scale_keyframes_motion() { let dir = std::env::temp_dir().join(format!("opentake-xmeml-mkf-{}", std::process::id())); diff --git a/crates/opentake-project/src/lib.rs b/crates/opentake-project/src/lib.rs index e98ecee..71d1437 100644 --- a/crates/opentake-project/src/lib.rs +++ b/crates/opentake-project/src/lib.rs @@ -54,10 +54,10 @@ pub mod otio; pub mod xmlnode; pub use archive::{archive, ArchiveReport, MissingMedia}; -pub use bundle::Project; +pub use bundle::{copy_media_dir, Project}; pub use edl::export_edl; pub use error::{ProjectError, Result}; -pub use fcpxml::export_xmeml; +pub use fcpxml::{export_xmeml, export_xmeml_with_timecodes}; pub use fcpxml_modern::export_fcpxml; pub use gen_log::{GenerationLog, GenerationLogEntry}; pub use otio::export_otio; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 74d976f..f3001c0 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -13,8 +13,8 @@ use serde::{Deserialize, Serialize}; use tauri::{AppHandle, Manager, State}; use opentake_core::dto::{ - handle_edit_apply, handle_get_timeline, handle_project_new, handle_project_open, - handle_project_save, handle_redo, handle_undo, EditResultDto, TimelineSnapshotDto, + handle_edit_apply, handle_get_timeline, handle_project_new, handle_project_open, handle_redo, + handle_undo, EditResultDto, TimelineSnapshotDto, }; use opentake_core::{AppCore, CmdError, EditCommand}; @@ -60,9 +60,29 @@ pub fn project_open(core: State<'_, AppCore>, path: String) -> Result, path: Option) -> Result { - handle_project_save(&core, path).map_err(msg) + let timeline = core.get_timeline().timeline; + let manifest = core.media(); + let project_dir = core.project_dir(); + let thumbnail = + opentake_media::capture_project_thumbnail(&timeline, &manifest, project_dir.as_deref()); + + let target = path.map(std::path::PathBuf::from); + core.save_project_with_thumbnail(target, thumbnail) + .map(|p| p.to_string_lossy().into_owned()) + .map_err(|e| e.to_string()) } /// `get_default_project_dir`: the default folder new projects save into @@ -92,10 +112,51 @@ pub fn export_xmeml(core: State<'_, AppCore>, path: String) -> Result<(), String let timeline = core.get_timeline().timeline; let manifest = core.media(); let project_dir = core.project_dir(); - let xml = opentake_project::export_xmeml(&timeline, &manifest, project_dir.as_deref()); + // Resolve each source file's start timecode via ffprobe (upstream reads the + // QuickTime `tmcd` track; here `opentake_media::read_start_timecode_frame` + // reads `tags.timecode`). Per-file failures are silently dropped -> 0. + let start_timecodes = resolve_start_timecodes(&timeline, &manifest, project_dir.as_deref()); + let xml = opentake_project::export_xmeml_with_timecodes( + &timeline, + &manifest, + project_dir.as_deref(), + &start_timecodes, + ); std::fs::write(&path, xml).map_err(|e| e.to_string()) } +/// Build the `media_ref -> start-frame` map for [`export_xmeml`]. Iterates the +/// manifest, resolves each entry to an on-disk file, and reads its start timecode +/// via ffprobe at the **same integer timebase** the XMEML `` node uses for +/// that source (`max(1, round(source_fps ?? timeline.fps))`, the upstream +/// `rateTags` timebase — so the parsed frame count matches the `` written +/// beside it). A missing manifest entry path, an unreadable file, or an absent +/// timecode tag simply yields no map entry, and the exporter falls back to 0 +/// exactly as upstream's `sourceStartFrame(for:) ?? 0` does. Only entries with a +/// nonzero timecode are inserted (zero is already the exporter default). +fn resolve_start_timecodes( + timeline: &opentake_domain::Timeline, + manifest: &opentake_domain::MediaManifest, + project_base: Option<&std::path::Path>, +) -> std::collections::HashMap { + let resolver = opentake_domain::MediaResolver::new(manifest, project_base); + let mut map = std::collections::HashMap::new(); + for entry in &manifest.entries { + // Same per-file timebase the exporter computes (integer FCP7 timebase). + let raw_fps = entry.source_fps.unwrap_or(timeline.fps as f64); + let timebase = (raw_fps.round() as i32).max(1); + let Some(path) = resolver.expected_path(&entry.id) else { + continue; + }; + if let Some(frame) = opentake_media::read_start_timecode_frame(&path, timebase) { + if frame > 0 { + map.insert(entry.id.clone(), frame); + } + } + } + map +} + /// `export_fcpxml`: deprecated alias for [`export_xmeml`], kept so any existing /// front-end caller keeps working. The command name historically said "fcpxml" /// but always produced XMEML 4 (FCP7 XML); the honest name is `export_xmeml`.