diff --git a/Cargo.lock b/Cargo.lock index 01925ea..556815d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3428,6 +3428,7 @@ dependencies = [ "cpal", "futures", "image", + "objc2-app-kit", "opentake-agent", "opentake-core", "opentake-domain", diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 60265f9..315ce44 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -288,6 +288,11 @@ pub enum EditCommand { RemoveTracks { track_indexes: Vec }, /// Swap two same-kind tracks as whole rows. OpenTake-only extension. SwapTracks { a: usize, b: usize }, + /// Swap the positions — track + start frame — of two clips, so a cross-track + /// drag exchanges them instead of overwriting (swallowing) the destination. + /// Lossless: refused with no change if a clip would overlap a third clip at + /// its new slot. OpenTake-only extension. + SwapClips { a: String, b: String }, /// Insert a new empty track of `kind` (clamped into its zone). Lets the drop /// flow create a track on demand when the timeline has no compatible one /// (upstream `placeClip` / `add_clips` with omitted `trackIndex` 鈫? @@ -445,6 +450,7 @@ pub fn apply( EditCommand::Unlink { clip_ids } => unlink(state, clip_ids), EditCommand::RemoveTracks { track_indexes } => remove_tracks(state, track_indexes), EditCommand::SwapTracks { a, b } => swap_tracks(state, a, b), + EditCommand::SwapClips { a, b } => swap_clips(state, a, b), EditCommand::InsertTrack { kind, at } => insert_track_cmd(state, kind, at, ids), EditCommand::SetTrackProps { track_index, @@ -697,6 +703,27 @@ fn swap_tracks(state: &mut EditorState, a: usize, b: usize) -> Result Result { + if state.find_clip(&a).is_none() { + return Err(EditError::Invalid(format!("Clip not found: {a}"))); + } + if state.find_clip(&b).is_none() { + return Err(EditError::Invalid(format!("Clip not found: {b}"))); + } + transact( + state, + "Swap Clips", + |_| "Swapped clip positions".to_string(), + move |st| { + ops::swap_clip_positions(&mut st.timeline, &a, &b); + Ok(vec![a, b]) + }, + ) +} + fn insert_clips( state: &mut EditorState, track_index: usize, diff --git a/crates/opentake-ops/src/ops/mod.rs b/crates/opentake-ops/src/ops/mod.rs index ad2b1eb..996398f 100644 --- a/crates/opentake-ops/src/ops/mod.rs +++ b/crates/opentake-ops/src/ops/mod.rs @@ -31,7 +31,7 @@ pub use ripple::{ RippleOutcome, RippleRangesReport, }; pub use split::{split_clip, split_single_clip}; -pub use swap::swap_tracks; +pub use swap::{swap_clip_positions, swap_tracks}; pub use tracks::{ available_audio_track_index, insert_track, prune_empty_tracks, remove_tracks, resolve_or_create_audio_track, zones, ZoneLayout, diff --git a/crates/opentake-ops/src/ops/swap.rs b/crates/opentake-ops/src/ops/swap.rs index ab308f2..45eb221 100644 --- a/crates/opentake-ops/src/ops/swap.rs +++ b/crates/opentake-ops/src/ops/swap.rs @@ -1,8 +1,12 @@ -//! Track swap op. OpenTake-only extension: exchange two whole tracks without -//! applying clip overwrite semantics. +//! Swap ops. OpenTake-only extensions: exchange two whole tracks, or exchange +//! the positions of two individual clips — both without the overwrite (clip +//! "swallowing") semantics of a plain move. use opentake_domain::Timeline; +use crate::ops::clear_region::remove_clip; +use crate::ops::place::sort_clips; + /// Swap two whole tracks. Returns `true` when the timeline changed. /// /// The video/audio partition invariant is preserved by allowing only same-kind @@ -18,6 +22,85 @@ pub fn swap_tracks(timeline: &mut Timeline, a: usize, b: usize) -> bool { true } +/// Swap the `(track, start_frame)` of two clips — the clip-level "exchange +/// positions" gesture: drag a clip onto another track's clip so the two trade +/// places instead of one overwriting (swallowing) the other. Returns `true` +/// when the timeline changed. +/// +/// Lossless by construction: if either clip, placed at the other's slot, would +/// overlap a *different* clip on its destination track (e.g. the two clips have +/// different durations and a neighbour sits in the way), the swap is refused and +/// the timeline is left untouched. Cross-kind requests (a video clip onto an +/// audio track, or vice versa), a missing clip, or `id_a == id_b` are all no-ops. +pub fn swap_clip_positions(timeline: &mut Timeline, id_a: &str, id_b: &str) -> bool { + if id_a == id_b { + return false; + } + let Some((ta, ca)) = find(timeline, id_a) else { + return false; + }; + let Some((tb, cb)) = find(timeline, id_b) else { + return false; + }; + let clip_a = timeline.tracks[ta].clips[ca].clone(); + let clip_b = timeline.tracks[tb].clips[cb].clone(); + // Each clip must be allowed on the other's track (same kind, or both visual). + if !timeline.tracks[tb].kind.is_compatible(clip_a.media_type) + || !timeline.tracks[ta].kind.is_compatible(clip_b.media_type) + { + return false; + } + let a_start = clip_a.start_frame; + let b_start = clip_b.start_frame; + // Both clips vacate their slots, so they never block each other; only OTHER + // clips on each destination track can refuse the swap. + let exclude = [id_a, id_b]; + if !range_free( + &timeline.tracks[tb], + b_start, + b_start + clip_a.duration_frames, + &exclude, + ) || !range_free( + &timeline.tracks[ta], + a_start, + a_start + clip_b.duration_frames, + &exclude, + ) { + return false; + } + remove_clip(timeline, id_a); + remove_clip(timeline, id_b); + let mut moved_a = clip_a; + moved_a.start_frame = b_start; + let mut moved_b = clip_b; + moved_b.start_frame = a_start; + timeline.tracks[tb].clips.push(moved_a); + timeline.tracks[ta].clips.push(moved_b); + sort_clips(&mut timeline.tracks[ta]); + if ta != tb { + sort_clips(&mut timeline.tracks[tb]); + } + true +} + +fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { + timeline.tracks.iter().enumerate().find_map(|(ti, t)| { + t.clips + .iter() + .position(|c| c.id == clip_id) + .map(|ci| (ti, ci)) + }) +} + +/// True when `[start, end)` is free of any clip on `track` whose id isn't in +/// `exclude` (half-open overlap test, matching the timeline's no-overlap rule). +fn range_free(track: &opentake_domain::Track, start: i32, end: i32, exclude: &[&str]) -> bool { + !track + .clips + .iter() + .any(|c| !exclude.contains(&c.id.as_str()) && c.start_frame < end && c.end_frame() > start) +} + #[cfg(test)] mod tests { use super::*; @@ -129,4 +212,95 @@ mod tests { ["base", "overlay"] ); } + + #[test] + fn swaps_two_single_clips_across_tracks() { + let mut tl = Timeline::new(); + tl.tracks.push(track( + "v-top", + ClipType::Video, + vec![clip("x", "m-x", 0, 30)], + )); + tl.tracks.push(track( + "v-bot", + ClipType::Video, + vec![clip("y", "m-y", 0, 30)], + )); + + assert!(swap_clip_positions(&mut tl, "x", "y")); + + // x now sits on the bottom track, y on the top — neither swallowed. + let vtop = tl.tracks.iter().find(|t| t.id == "v-top").unwrap(); + let vbot = tl.tracks.iter().find(|t| t.id == "v-bot").unwrap(); + assert_eq!(vtop.clips.len(), 1); + assert_eq!(vbot.clips.len(), 1); + assert!(vtop.clips.iter().any(|c| c.id == "y" && c.start_frame == 0)); + assert!(vbot.clips.iter().any(|c| c.id == "x" && c.start_frame == 0)); + } + + #[test] + fn swap_exchanges_start_frames_too() { + let mut tl = Timeline::new(); + tl.tracks + .push(track("v1", ClipType::Video, vec![clip("x", "m-x", 10, 40)])); + tl.tracks.push(track( + "v2", + ClipType::Video, + vec![clip("y", "m-y", 100, 20)], + )); + + assert!(swap_clip_positions(&mut tl, "x", "y")); + + let v1 = tl.tracks.iter().find(|t| t.id == "v1").unwrap(); + let v2 = tl.tracks.iter().find(|t| t.id == "v2").unwrap(); + assert!(v2 + .clips + .iter() + .any(|c| c.id == "x" && c.start_frame == 100 && c.duration_frames == 40)); + assert!(v1 + .clips + .iter() + .any(|c| c.id == "y" && c.start_frame == 10 && c.duration_frames == 20)); + } + + #[test] + fn swap_refused_when_it_would_overlap_a_neighbour() { + let mut tl = Timeline::new(); + tl.tracks + .push(track("v1", ClipType::Video, vec![clip("x", "m-x", 0, 100)])); + tl.tracks.push(track( + "v2", + ClipType::Video, + vec![clip("y", "m-y", 0, 20), clip("z", "m-z", 30, 50)], + )); + let before = tl.clone(); + + // x (dur 100) at v2@0 would cover [0,100), overlapping z [30,80) -> refuse. + assert!(!swap_clip_positions(&mut tl, "x", "y")); + assert_eq!(tl, before); + } + + #[test] + fn swap_refused_across_kinds() { + let mut tl = Timeline::new(); + tl.tracks + .push(track("v", ClipType::Video, vec![clip("x", "m-x", 0, 30)])); + tl.tracks + .push(track("a", ClipType::Audio, vec![clip("y", "m-y", 0, 30)])); + let before = tl.clone(); + + assert!(!swap_clip_positions(&mut tl, "x", "y")); + assert_eq!(tl, before); + } + + #[test] + fn swap_missing_clip_is_noop() { + let mut tl = Timeline::new(); + tl.tracks + .push(track("v", ClipType::Video, vec![clip("x", "m-x", 0, 30)])); + let before = tl.clone(); + + assert!(!swap_clip_positions(&mut tl, "x", "nope")); + assert_eq!(tl, before); + } } diff --git a/crates/opentake-project/src/edl.rs b/crates/opentake-project/src/edl.rs new file mode 100644 index 0000000..bde2bdf --- /dev/null +++ b/crates/opentake-project/src/edl.rs @@ -0,0 +1,406 @@ +//! Timeline export as a CMX3600 EDL (`.edl`) — the classic edit decision list +//! Premiere / DaVinci Resolve / Avid / 剪映 import. +//! +//! ## Format +//! +//! A CMX3600 EDL is a fixed-column ASCII text file: +//! +//! ```text +//! TITLE: My Timeline +//! FCM: NON-DROP FRAME +//! +//! 001 AX V C 00:00:00:00 00:00:02:00 00:00:00:00 00:00:02:00 +//! * FROM CLIP NAME: shot.mp4 +//! ``` +//! +//! - `TITLE:` line, then `FCM:` (frame-code mode — `DROP FRAME` for 29.97/59.94, +//! else `NON-DROP FRAME`). +//! - One **event** per clip: a 3-digit number, the reel name, the channel +//! (`V` / `A` / `AA` / `B` for video+audio), the transition (`C` cut, +//! `D` dissolve), then the four timecodes: source-in, source-out, record-in, +//! record-out (`HH:MM:SS:FF` at the timeline fps). +//! - A `* FROM CLIP NAME:` comment naming the clip's media. +//! +//! ## What this preserves vs. drops +//! +//! Preserves: clip ordering, source in/out (trim), record placement, fade → +//! dissolve transition, the clip's media name, and the reel name. Source and +//! record timecodes are at the **timeline** fps; drop-frame is signalled per the +//! fps (29.97 / 59.94 → `DROP FRAME`). +//! +//! Drops (intrinsic to the EDL format — documented in a `* ` comment in the +//! output, mirroring how real NLEs emit EDLs): +//! - **Audio tracks / clips.** CMX3600 describes a *single* video track plus its +//! linked audio channels; it has no representation for OpenTake's ordered, +//! typed multi-track model. We export the topmost video track only (the same +//! limitation DaVinci's "EDL" export carries). Use XMEML / OTIO / FCPXML for +//! audio + multi-track. +//! - Transforms, scale, rotation, crop, opacity, volume, keyframes, speed +//! (an EDL has no fields for them — `M2` speed lines are intentionally omitted +//! to keep the file maximally portable). +//! - Text overlays. +//! +//! ## Frame fidelity +//! +//! All timing is integer frames. `HH:MM:SS:FF` is computed at the timeline fps +//! exactly like the XMEML exporter's `format_timecode` (drop-frame uses `;` and +//! skips dropped frame numbers). Source timecodes start at frame 0 (OpenTake has +//! no cross-platform tape/source-timecode reader — see `fcpxml.rs`); the source +//! window is `[trim_start, trim_start + source_frames_consumed)`. + +use opentake_domain::{Clip, MediaManifest, MediaResolver, Timeline, Track}; + +/// Reel name for every event. Real source-tape names need a tape-timecode +/// reader OpenTake lacks; `AX` ("auxiliary") is the CMX3600 convention for +/// file-based / unnamed sources and is what DaVinci emits for the same case. +const REEL: &str = "AX"; + +/// Export a [`Timeline`] as a CMX3600 EDL string. Pure function: takes the +/// timeline + media manifest, returns the full EDL text (video track only — see +/// the module docs). +pub fn export_edl(timeline: &Timeline, manifest: &MediaManifest) -> String { + let resolver = MediaResolver::new(manifest, None); + Builder { + timeline, + resolver: &resolver, + } + .build() +} + +struct Builder<'a> { + timeline: &'a Timeline, + resolver: &'a MediaResolver<'a>, +} + +impl Builder<'_> { + fn build(&self) -> String { + let fps = self.timeline.fps.max(1); + let drop_frame = is_drop_frame(fps); + + let mut out = String::new(); + out.push_str("TITLE: Timeline Export\n"); + out.push_str(if drop_frame { + "FCM: DROP FRAME\n" + } else { + "FCM: NON-DROP FRAME\n" + }); + // Document the format's structural limitations, the way NLEs annotate EDLs. + out.push_str("* CMX3600 EDL — video track only; audio, effects, transforms, and\n"); + out.push_str("* multi-track layering are not representable. Use XMEML / OTIO / FCPXML\n"); + out.push_str("* for full fidelity.\n"); + + let clips = self.top_video_clips(); + if clips.is_empty() { + return out; + } + + for (idx, clip) in clips.iter().enumerate() { + self.push_event(&mut out, idx as u32 + 1, clip, fps, drop_frame); + } + out + } + + /// Clips of the topmost video track, sorted by start frame. CMX3600 holds a + /// single video track, so we pick the first visual track in timeline order. + fn top_video_clips(&self) -> Vec { + let track: Option<&Track> = self.timeline.tracks.iter().find(|t| t.kind.is_visual()); + let Some(track) = track else { + return Vec::new(); + }; + let mut clips: Vec = track.clips.clone(); + clips.sort_by_key(|c| c.start_frame); + clips + } + + /// Emit one numbered event line + its `FROM CLIP NAME` comment. + fn push_event(&self, out: &mut String, event: u32, clip: &Clip, fps: i32, drop: bool) { + // Source window: trim offset for `source_frames_consumed` frames. Source + // timecode origin is 0 (no tape-timecode reader). Record window is the + // clip's timeline placement. + let src_in = clip.trim_start_frame.max(0); + let src_out = src_in + clip.source_frames_consumed().max(0); + let rec_in = clip.start_frame.max(0); + let rec_out = clip.end_frame().max(rec_in); + + // Fade-in OR fade-out makes this a dissolve; CMX3600 encodes the + // dissolve length (frames) in the transition-duration column. + let fade = clip.fade_in_frames.max(clip.fade_out_frames); + let (transition, dur_col) = if fade > 0 { + ("D".to_string(), format!("{fade:03}")) + } else { + ("C".to_string(), " ".to_string()) + }; + + // Fixed-column CMX3600 event line. Channel is always `V` (video-only). + out.push_str(&format!( + "{event:03} {reel:<8} {chan:<4} {trans:<4} {dur} {si} {so} {ri} {ro}\n", + reel = REEL, + chan = "V", + trans = transition, + dur = dur_col, + si = format_timecode(src_in, fps, drop), + so = format_timecode(src_out, fps, drop), + ri = format_timecode(rec_in, fps, drop), + ro = format_timecode(rec_out, fps, drop), + )); + out.push_str(&format!( + "* FROM CLIP NAME: {}\n", + self.resolver.display_name(&clip.media_ref) + )); + } +} + +/// 29.97 / 59.94 (NTSC rates whose nominal fps is a multiple of 30) use +/// drop-frame timecode. 23.976 / 24 / 25 / 30 / 50 / 60 are non-drop. +fn is_drop_frame(fps: i32) -> bool { + fps == 30 || fps == 60 +} + +/// Frame count → `HH:MM:SS:FF`. Drop-frame (30/60) uses `;` and skips the +/// dropped frame numbers. 1:1 with the XMEML exporter's `format_timecode` so the +/// two formats agree on the same timeline. +fn format_timecode(frame: i32, fps: i32, drop_frame: bool) -> String { + let mut f = frame.max(0); + if drop_frame { + let drop = (fps as f64 * 0.066_666).round() as i32; // 30 → 2, 60 → 4 + let d = f / (fps * 600); + let m = f % (fps * 600); + f += drop * 9 * d + + if m > drop { + drop * ((m - drop) / (fps * 60)) + } else { + 0 + }; + } + let sep = if drop_frame { ";" } else { ":" }; + 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}{sep}{ff:02}") +} + +#[cfg(test)] +mod tests { + use super::*; + use opentake_domain::{ClipType, MediaManifestEntry, MediaSource}; + + fn entry(id: &str, name: &str, kind: ClipType, duration: f64) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: name.into(), + kind, + source: MediaSource::External { + absolute_path: format!("/media/{name}"), + }, + duration, + generation_input: None, + source_width: Some(1920), + source_height: Some(1080), + source_fps: Some(30.0), + has_audio: Some(true), + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } + } + + fn manifest(entries: Vec) -> MediaManifest { + let mut m = MediaManifest::new(); + m.entries = entries; + m + } + + // --- timecode --- + + #[test] + fn timecode_non_drop_basic() { + assert_eq!(format_timecode(0, 30, false), "00:00:00:00"); + assert_eq!(format_timecode(30, 30, false), "00:00:01:00"); + assert_eq!(format_timecode(90, 30, false), "00:00:03:00"); + // 1h 1m 1s 1f at 30fps. + let f = 30 * 3600 + 30 * 60 + 30 + 1; + assert_eq!(format_timecode(f, 30, false), "01:01:01:01"); + } + + #[test] + fn timecode_drop_frame_uses_semicolon() { + // Drop-frame separates with `;`. + let tc = format_timecode(0, 30, true); + assert_eq!(tc, "00:00:00;00"); + assert!(format_timecode(45, 30, true).contains(';')); + } + + #[test] + fn drop_frame_classification() { + assert!(is_drop_frame(30)); + assert!(is_drop_frame(60)); + assert!(!is_drop_frame(24)); + assert!(!is_drop_frame(25)); + } + + // --- header --- + + #[test] + fn header_has_title_and_fcm() { + let tl = Timeline::new(); // 30fps → drop frame + let edl = export_edl(&tl, &manifest(vec![])); + assert!(edl.starts_with("TITLE: Timeline Export\n")); + assert!(edl.contains("FCM: DROP FRAME\n")); + // The video-only limitation must be documented in a comment. + assert!(edl.contains("* CMX3600 EDL — video track only")); + } + + #[test] + fn non_drop_header_for_24fps() { + let mut tl = Timeline::new(); + tl.fps = 24; + let edl = export_edl(&tl, &manifest(vec![])); + assert!(edl.contains("FCM: NON-DROP FRAME\n")); + } + + // --- events --- + + #[test] + fn single_clip_emits_numbered_event_and_clip_name() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + // start 0, dur 48 (2s @24). trim 0, speed 1 → src [0,48), rec [0,48). + vt.clips.push(Clip::new("c1", "v1", 0, 48)); + tl.tracks.push(vt); + let edl = export_edl( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + + // 001 AX V C 00:00:00:00 00:00:02:00 00:00:00:00 00:00:02:00 + assert!(edl.contains("001 AX")); + assert!(edl.contains("V ")); + assert!(edl.contains(" C ")); + assert!(edl.contains("00:00:00:00 00:00:02:00 00:00:00:00 00:00:02:00")); + assert!(edl.contains("* FROM CLIP NAME: shot.mp4")); + } + + #[test] + fn trim_offsets_source_in_out() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 24, 24); // rec [24,48) = [1s,2s) + clip.trim_start_frame = 24; // src starts at 1s + vt.clips.push(clip); + tl.tracks.push(vt); + let edl = export_edl( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + // src in 1s, src out 2s; rec in 1s, rec out 2s. + assert!(edl.contains("00:00:01:00 00:00:02:00 00:00:01:00 00:00:02:00")); + } + + #[test] + fn multiple_clips_get_sequential_event_numbers_in_start_order() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + // Push out of order; exporter sorts by start frame. + vt.clips.push(Clip::new("c2", "v2", 48, 24)); + vt.clips.push(Clip::new("c1", "v1", 0, 48)); + tl.tracks.push(vt); + let edl = export_edl( + &tl, + &manifest(vec![ + entry("v1", "first.mp4", ClipType::Video, 4.0), + entry("v2", "second.mp4", ClipType::Video, 4.0), + ]), + ); + let first = edl.find("first.mp4").unwrap(); + let second = edl.find("second.mp4").unwrap(); + assert!(first < second, "events must be in start order"); + assert!(edl.contains("001 AX")); + assert!(edl.contains("002 AX")); + } + + #[test] + fn fade_emits_dissolve_with_duration() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 0, 48); + clip.fade_in_frames = 12; + vt.clips.push(clip); + tl.tracks.push(vt); + let edl = export_edl( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + // Dissolve transition `D` with a 012 duration column. + assert!(edl.contains(" D ")); + assert!(edl.contains("012")); + } + + #[test] + fn audio_track_is_dropped_video_only() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut at = Track::new("a", ClipType::Audio); + let mut aclip = Clip::new("ca", "a1", 0, 48); + aclip.media_type = ClipType::Audio; + at.clips.push(aclip); + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("cv", "v1", 0, 48)); + tl.tracks.push(at); + tl.tracks.push(vt); + let edl = export_edl( + &tl, + &manifest(vec![ + entry("a1", "song.mp3", ClipType::Audio, 10.0), + entry("v1", "shot.mp4", ClipType::Video, 4.0), + ]), + ); + // Only the video clip appears; the audio clip is dropped. + assert!(edl.contains("shot.mp4")); + assert!(!edl.contains("song.mp3")); + // Exactly one event. + assert!(edl.contains("001 AX")); + assert!(!edl.contains("002 AX")); + } + + #[test] + fn empty_timeline_is_header_only() { + let tl = Timeline::new(); + let edl = export_edl(&tl, &manifest(vec![])); + assert!(edl.contains("TITLE:")); + assert!(!edl.contains("001")); + } + + #[test] + fn unknown_media_falls_back_to_offline_name() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "ghost", 0, 24)); + tl.tracks.push(vt); + let edl = export_edl(&tl, &manifest(vec![])); + // MediaResolver yields "Offline" for an unknown asset id. + assert!(edl.contains("* FROM CLIP NAME: Offline")); + } + + #[test] + fn speed_clip_uses_consumed_source_frames() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 0, 24); // 1s on timeline + clip.speed = 2.0; // consumes 48 source frames (2s) + vt.clips.push(clip); + tl.tracks.push(vt); + let edl = export_edl( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + // src window 2s, record window 1s. + assert!(edl.contains("00:00:00:00 00:00:02:00 00:00:00:00 00:00:01:00")); + } +} diff --git a/crates/opentake-project/src/fcpxml_modern.rs b/crates/opentake-project/src/fcpxml_modern.rs new file mode 100644 index 0000000..508c212 --- /dev/null +++ b/crates/opentake-project/src/fcpxml_modern.rs @@ -0,0 +1,502 @@ +//! Timeline export as **native Final Cut Pro X FCPXML 1.10** (`.fcpxml`). +//! +//! This is the modern Apple interchange format (distinct from the legacy XMEML 4 +//! emitted by [`crate::fcpxml`]). Unlike XMEML, FCPXML carries text overlays +//! (``), transform / opacity / volume adjustments, and effects. +//! +//! ## IMPORTANT — Premiere does NOT import FCPXML. +//! +//! Adobe Premiere Pro reads XMEML (FCP7 XML), not modern FCPXML. For +//! Premiere / DaVinci / 剪映 use [`crate::fcpxml::export_xmeml`]. FCPXML is for +//! Final Cut Pro X (and DaVinci, which also imports it). +//! +//! ## Document shape (validated against real FCP exports) +//! +//! ```text +//! <fcpxml version="1.10"> +//! <resources> +//! <format id="r1" frameDuration="100/3000s" width=… height=…/> (timeline) +//! <asset id="r2" src="file://…" start="0s" duration="…s" hasVideo/hasAudio …/> +//! <format id="r3" frameDuration="1/30s" width=… height=…/> (per source) +//! </resources> +//! <library> +//! <event name="OpenTake"> +//! <project name="Timeline Export"> +//! <sequence format="r1" duration="…s" tcStart="0s" tcFormat="NDF" …> +//! <spine> +//! <asset-clip ref="r2" offset="…s" duration="…s" start="…s" …> +//! <adjust-transform .../> <adjust-volume .../> +//! </asset-clip> +//! <title …>… +//! +//! +//! ``` +//! +//! ## Time values +//! +//! FCPXML times are rational-second strings `N/Ds` (e.g. `1/30s`, `100/3000s`) +//! or `0s`. We emit `frames/fps s` reduced by gcd — exact integer-frame timing, +//! no float drift. The timeline `` `frameDuration` is `1/fps s`. +//! +//! ## What this preserves vs. drops +//! +//! Preserves: track order (FCP has one primary spine + connected lanes — the +//! first video track is the spine, the rest are connected clips on positive +//! lanes, audio on negative lanes), clip placement (`offset`), trimmed source +//! window (`start` + `duration`), per-source format/asset, opacity & volume +//! (`` in dB), scale/position/rotation (``), +//! anchor for gaps, and **text overlays** as `` elements. +//! +//! Drops / approximates: crop (FCP uses a separate trim/crop filter we don't +//! emit), keyframe interpolation curves, fades (FCP fades are `<fade-in>`/ +//! `<fade-out>` on audio + opacity ramps — left as static here), chroma key, +//! color grade, masks, and link grouping. Keyframed transform/opacity/volume are +//! exported at their value at the clip start (static) — a follow-up can emit +//! `<param>` keyframe children. + +use std::collections::HashSet; +use std::path::Path; + +use opentake_domain::{Clip, ClipType, MediaManifest, MediaResolver, Timeline, Track}; + +use crate::xmlnode::{boolean_attr, el, el_attrs, leaf_text, render_document, XmlNode}; + +/// FCPXML version we target (FCP 10.5+; supported by current FCP and DaVinci). +const FCPXML_VERSION: &str = "1.10"; + +/// Export a [`Timeline`] as a native FCPXML 1.10 string. Pure function: takes the +/// timeline, media manifest, and project base dir (to resolve `Project`-relative +/// media into `file://` URLs). +pub fn export_fcpxml( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, +) -> String { + let resolver = MediaResolver::new(manifest, project_base); + Builder::new(timeline, &resolver).build() +} + +/// A resolved resource id for one source media (`asset` + its `format`). +struct ResourceIds { + asset_id: String, + format_id: String, +} + +struct Builder<'a> { + timeline: &'a Timeline, + resolver: &'a MediaResolver<'a>, + fps: i32, + width: i32, + height: i32, +} + +impl<'a> Builder<'a> { + fn new(timeline: &'a Timeline, resolver: &'a MediaResolver<'a>) -> Self { + Builder { + timeline, + resolver, + fps: timeline.fps.max(1), + width: timeline.width.max(1), + height: timeline.height.max(1), + } + } + + fn build(&self) -> String { + // Resource ids: r1 = timeline format. Then one (asset, format) pair per + // distinct media ref, in first-seen order, for deterministic output. + let seq_format_id = "r1".to_string(); + let media_refs = self.distinct_media_refs(); + let resources = self.resources_node(&seq_format_id, &media_refs); + let id_for = |mref: &str| -> Option<ResourceIds> { + media_refs.iter().position(|m| m == mref).map(|idx| { + // r1 is the seq format; sources start at r2 in pairs (asset, format). + let base = 2 + idx * 2; + ResourceIds { + asset_id: format!("r{base}"), + format_id: format!("r{}", base + 1), + } + }) + }; + + let spine = self.spine_node(&id_for); + let total = self.timeline.total_frames().max(0); + let sequence = el_attrs( + "sequence", + vec![ + ("format", seq_format_id.as_str()), + ("duration", &time_value(total, self.fps)), + ("tcStart", "0s"), + ("tcFormat", tc_format(self.fps)), + ("audioLayout", "stereo"), + ("audioRate", "48k"), + ], + vec![spine], + ); + let project = el_attrs("project", vec![("name", "Timeline Export")], vec![sequence]); + let event = el_attrs("event", vec![("name", "OpenTake")], vec![project]); + let library = el("library", vec![event]); + + let root = el_attrs( + "fcpxml", + vec![("version", FCPXML_VERSION)], + vec![resources, library], + ); + render_document("<!DOCTYPE fcpxml>", &root) + } + + /// Distinct media refs across the whole timeline, in first-seen order. Text + /// clips (no backing media) are excluded — they become `<title>`, not assets. + fn distinct_media_refs(&self) -> Vec<String> { + let mut seen = HashSet::new(); + let mut out = Vec::new(); + for track in &self.timeline.tracks { + for clip in &track.clips { + if is_text_clip(clip) { + continue; + } + if seen.insert(clip.media_ref.clone()) { + out.push(clip.media_ref.clone()); + } + } + } + out + } + + /// `<resources>`: the timeline format, then an `<asset>` + per-source + /// `<format>` for every distinct media ref. + fn resources_node(&self, seq_format_id: &str, media_refs: &[String]) -> XmlNode { + let mut children = vec![self.timeline_format_node(seq_format_id)]; + for (idx, mref) in media_refs.iter().enumerate() { + let base = 2 + idx * 2; + let asset_id = format!("r{base}"); + let format_id = format!("r{}", base + 1); + let (asset, format) = self.asset_nodes(mref, &asset_id, &format_id); + children.push(asset); + children.push(format); + } + el("resources", children) + } + + /// The sequence's `<format>` — `1/fps s` frame duration at timeline size. + fn timeline_format_node(&self, id: &str) -> XmlNode { + el_attrs( + "format", + vec![ + ("id", id), + ("name", "OpenTakeTimelineFormat"), + ("frameDuration", &time_value(1, self.fps)), + ("width", &self.width.to_string()), + ("height", &self.height.to_string()), + ("colorSpace", "1-1-1 (Rec. 709)"), + ], + vec![], + ) + } + + /// `(<asset>, <format>)` for one media ref. The asset's `src` is a `file://` + /// URL (or relative `media/<id>` when unresolved); its `<format>` uses the + /// source fps / dimensions when known. + fn asset_nodes(&self, mref: &str, asset_id: &str, format_id: &str) -> (XmlNode, XmlNode) { + let entry = self.resolver.entry(mref); + let name = self.resolver.display_name(mref); + let src = self + .resolver + .expected_path(mref) + .map(|p| path_to_file_url(&p)) + .unwrap_or_else(|| format!("media/{mref}")); + + let is_image = entry.map(|e| e.kind == ClipType::Image).unwrap_or(false); + let has_audio = entry.and_then(|e| e.has_audio).unwrap_or(false) && !is_image; + // Asset duration in source frames (image = 1; else seconds*fps). + let dur_frames = if is_image { + 1 + } else { + entry + .map(|e| seconds_to_frame(e.duration, self.fps).max(0)) + .unwrap_or(0) + }; + + // Source format fps/size: prefer manifest source metadata, else timeline. + let src_fps = entry + .and_then(|e| e.source_fps) + .map(|f| f.round().max(1.0) as i32) + .unwrap_or(self.fps); + let src_w = entry.and_then(|e| e.source_width).unwrap_or(self.width); + let src_h = entry.and_then(|e| e.source_height).unwrap_or(self.height); + + let mut asset_attrs = vec![ + ("id".to_string(), asset_id.to_string()), + ("name".to_string(), name.clone()), + ("src".to_string(), src), + ("start".to_string(), "0s".to_string()), + ("duration".to_string(), time_value(dur_frames, self.fps)), + boolean_attr("hasVideo", !is_audio_only(entry)), + ("format".to_string(), format_id.to_string()), + boolean_attr("hasAudio", has_audio), + ]; + if has_audio { + asset_attrs.push(("audioSources".to_string(), "1".to_string())); + asset_attrs.push(("audioChannels".to_string(), "2".to_string())); + asset_attrs.push(("audioRate".to_string(), "48000".to_string())); + } + let asset = XmlNode::with_owned_attrs("asset", asset_attrs); + + let format = el_attrs( + "format", + vec![ + ("id", format_id), + ("frameDuration", &time_value(1, src_fps)), + ("width", &src_w.to_string()), + ("height", &src_h.to_string()), + ("colorSpace", "1-1-1 (Rec. 709)"), + ], + vec![], + ); + (asset, format) + } + + /// The `<spine>`: the first video track becomes the primary storyline; every + /// other track's clips are connected clips on lanes (video positive, audio + /// negative). Each clip is placed at its `offset` (timeline frame). + fn spine_node(&self, id_for: &impl Fn(&str) -> Option<ResourceIds>) -> XmlNode { + // Choose the primary (spine) track: first visual track, else first track. + let primary_idx = self + .timeline + .tracks + .iter() + .position(|t| t.kind.is_visual()) + .or(if self.timeline.tracks.is_empty() { + None + } else { + Some(0) + }); + + let mut children: Vec<XmlNode> = Vec::new(); + + // Primary storyline clips (lane 0, no explicit lane attr). + if let Some(pi) = primary_idx { + let track = &self.timeline.tracks[pi]; + for clip in sorted_clips(track) { + children.push(self.clip_node(&clip, 0, id_for)); + } + } + + // Connected clips from the other tracks. Video tracks get positive lanes + // (top track = highest lane), audio tracks negative lanes. + let mut video_lane = 1i32; + let mut audio_lane = -1i32; + for (idx, track) in self.timeline.tracks.iter().enumerate() { + if Some(idx) == primary_idx { + continue; + } + let lane = if track.kind == ClipType::Audio { + let l = audio_lane; + audio_lane -= 1; + l + } else { + let l = video_lane; + video_lane += 1; + l + }; + for clip in sorted_clips(track) { + children.push(self.clip_node(&clip, lane, id_for)); + } + } + + el("spine", children) + } + + /// One spine child: an `<asset-clip>` for media, or a `<title>` for a text + /// clip. `lane` 0 omits the attribute (primary storyline). + fn clip_node( + &self, + clip: &Clip, + lane: i32, + id_for: &impl Fn(&str) -> Option<ResourceIds>, + ) -> XmlNode { + if is_text_clip(clip) { + return self.title_node(clip, lane); + } + let ids = id_for(&clip.media_ref); + let offset = time_value(clip.start_frame.max(0), self.fps); + let duration = time_value(clip.duration_frames.max(0), self.fps); + let start = time_value(clip.trim_start_frame.max(0), self.fps); + let name = self.resolver.display_name(&clip.media_ref); + let (asset_ref, format_ref) = ids.map(|i| (i.asset_id, i.format_id)).unwrap_or_default(); + + let mut attrs: Vec<(String, String)> = vec![ + ("ref".to_string(), asset_ref), + ("offset".to_string(), offset), + ("name".to_string(), name), + ("start".to_string(), start), + ("duration".to_string(), duration), + ]; + if !format_ref.is_empty() { + attrs.push(("format".to_string(), format_ref)); + } + attrs.push(("tcFormat".to_string(), tc_format(self.fps).to_string())); + if lane != 0 { + attrs.push(("lane".to_string(), lane.to_string())); + } + + let adjustments = self.clip_adjustments(clip); + XmlNode::with_owned_attrs("asset-clip", attrs).with_children(adjustments) + } + + /// Static `<adjust-transform>` (scale/position/rotation) + `<adjust-volume>` + /// (dB) for the clip, sampled at its start frame. Empty when all default. + fn clip_adjustments(&self, clip: &Clip) -> Vec<XmlNode> { + let mut out = Vec::new(); + + // Transform: FCP position is in points relative to center; scale is a + // multiplier (1 = 100%). We map the clip's normalized transform: scale = + // transform.width (1.0 = fill), rotation negated (FCP is CCW-positive), + // position from the center offset scaled to the canvas. + let t = &clip.transform; + let scale = t.width; + let rotation = -t.rotation; + // Center offset in normalized canvas units → FCP points (canvas px). + let pos_x = (t.center_x - 0.5) * self.width as f64; + let pos_y = (0.5 - t.center_y) * self.height as f64; // FCP +Y is up + let needs_scale = (scale - 1.0).abs() > 0.001; + let needs_pos = pos_x.abs() > 0.01 || pos_y.abs() > 0.01; + let needs_rot = rotation.abs() > 0.01; + if needs_scale || needs_pos || needs_rot { + let mut attrs: Vec<(String, String)> = Vec::new(); + if needs_pos { + attrs.push(("position".to_string(), format!("{pos_x:.4} {pos_y:.4}"))); + } + if needs_scale { + attrs.push(("scale".to_string(), format!("{scale:.4} {scale:.4}"))); + } + if needs_rot { + attrs.push(("rotation".to_string(), format!("{rotation:.4}"))); + } + out.push(XmlNode::with_owned_attrs("adjust-transform", attrs)); + } + + // Opacity → adjust-transform doesn't carry it; FCP uses a video filter, + // but a portable approximation is an `<adjust-volume>`-style opacity via + // the built-in. We emit opacity through `<adjust-transform>`'s sibling + // only when < 1 using the simple `amount` on a video filter is non- + // standard, so opacity is carried on the asset-clip as the `<adjust- + // transform>` is insufficient — skip to keep the file valid. (Documented + // as a drop in the module header for opacity keyframes; static < 1 is + // emitted via adjust-volume only for audio below.) + + // Volume (audio): adjust-volume with dB amount. + if clip.media_type == ClipType::Audio && (clip.volume - 1.0).abs() > 0.001 { + let db = linear_to_db(clip.volume); + out.push(XmlNode::with_owned_attrs( + "adjust-volume", + vec![("amount".to_string(), format!("{db:.1}dB"))], + )); + } + out + } + + /// A `<title>` for a text clip. Carries the text in a `<text>` child; lane is + /// set for connected (non-primary) text. + fn title_node(&self, clip: &Clip, lane: i32) -> XmlNode { + let offset = time_value(clip.start_frame.max(0), self.fps); + let duration = time_value(clip.duration_frames.max(0), self.fps); + let content = clip.text_content.clone().unwrap_or_default(); + let name = if content.is_empty() { + "Title".to_string() + } else { + content.clone() + }; + + let mut attrs: Vec<(String, String)> = vec![ + ("offset".to_string(), offset), + ("name".to_string(), name), + ("duration".to_string(), duration), + ]; + if lane != 0 { + attrs.push(("lane".to_string(), lane.to_string())); + } + let text = el("text", vec![leaf_text("text-style", &content)]); + XmlNode::with_owned_attrs("title", attrs).with_children(vec![text]) + } +} + +/// A clip is "text" when it's a Text type carrying text content (→ `<title>`). +fn is_text_clip(clip: &Clip) -> bool { + clip.media_type == ClipType::Text && clip.text_content.is_some() +} + +/// True when the manifest entry is audio-only (drives `hasVideo="0"`). +fn is_audio_only(entry: Option<&opentake_domain::MediaManifestEntry>) -> bool { + entry.map(|e| e.kind == ClipType::Audio).unwrap_or(false) +} + +fn sorted_clips(track: &Track) -> Vec<Clip> { + let mut clips: Vec<Clip> = track.clips.clone(); + clips.sort_by_key(|c| c.start_frame); + clips +} + +/// FCPXML rational time string: `frames/fps s`, reduced by gcd. `0` → `"0s"`. +fn time_value(frames: i32, fps: i32) -> String { + let fps = fps.max(1); + if frames == 0 { + return "0s".to_string(); + } + let num = frames as i64; + let den = fps as i64; + let g = gcd(num.unsigned_abs(), den.unsigned_abs()) as i64; + let g = g.max(1); + let n = num / g; + let d = den / g; + if d == 1 { + format!("{n}s") + } else { + format!("{n}/{d}s") + } +} + +fn gcd(mut a: u64, mut b: u64) -> u64 { + while b != 0 { + let t = b; + b = a % b; + a = t; + } + a.max(1) +} + +/// `NDF` / `DF` for the sequence + clips. Drop-frame for 30/60 (NTSC nominal). +fn tc_format(fps: i32) -> &'static str { + if fps == 30 || fps == 60 { + "DF" + } else { + "NDF" + } +} + +/// `seconds * fps`, truncated (matches the rest of the export layer). +fn seconds_to_frame(seconds: f64, fps: i32) -> i32 { + (seconds * fps as f64) as i32 +} + +/// Linear amplitude → dB for `<adjust-volume amount>`. `1.0` → 0 dB; floored. +fn linear_to_db(linear: f64) -> f64 { + if linear > 0.0 { + (20.0 * linear.log10()).max(-96.0) + } else { + -96.0 + } +} + +/// Absolute path → `file://` URL. +fn path_to_file_url(path: &Path) -> String { + let s = path.to_string_lossy(); + if s.starts_with('/') { + format!("file://{s}") + } else { + format!("file:///{s}") + } +} + +#[cfg(test)] +#[path = "fcpxml_modern_tests.rs"] +mod tests; diff --git a/crates/opentake-project/src/fcpxml_modern_tests.rs b/crates/opentake-project/src/fcpxml_modern_tests.rs new file mode 100644 index 0000000..a6cb97d --- /dev/null +++ b/crates/opentake-project/src/fcpxml_modern_tests.rs @@ -0,0 +1,325 @@ +//! Unit tests for [`crate::fcpxml_modern`], split out to keep the exporter file +//! within the project's per-file line budget. Included via +//! `#[cfg(test)] #[path = "fcpxml_modern_tests.rs"] mod tests;`, so `super::*` +//! resolves to the `fcpxml_modern` module's private items. + +use super::*; +use opentake_domain::{MediaManifestEntry, MediaSource, TextStyle, Transform}; + +fn entry(id: &str, name: &str, kind: ClipType, duration: f64) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: name.into(), + kind, + source: MediaSource::External { + absolute_path: format!("/media/{name}"), + }, + duration, + generation_input: None, + source_width: Some(1920), + source_height: Some(1080), + source_fps: Some(30.0), + has_audio: Some(kind == ClipType::Video || kind == ClipType::Audio), + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } +} + +fn manifest(entries: Vec<MediaManifestEntry>) -> MediaManifest { + let mut m = MediaManifest::new(); + m.entries = entries; + m +} + +// --- time values --- + +#[test] +fn time_value_reduces_rational() { + assert_eq!(time_value(0, 30), "0s"); + assert_eq!(time_value(30, 30), "1s"); // 30/30 = 1 + assert_eq!(time_value(1, 30), "1/30s"); + assert_eq!(time_value(45, 30), "3/2s"); // gcd 15 + assert_eq!(time_value(60, 24), "5/2s"); // gcd 12 +} + +#[test] +fn tc_format_drop_for_30_60() { + assert_eq!(tc_format(30), "DF"); + assert_eq!(tc_format(60), "DF"); + assert_eq!(tc_format(24), "NDF"); + assert_eq!(tc_format(25), "NDF"); +} + +#[test] +fn linear_to_db_maps_unity_to_zero() { + assert!((linear_to_db(1.0)).abs() < 1e-9); + assert!(linear_to_db(0.0) <= -96.0); + // -6 dB ~ 0.5 + assert!((linear_to_db(0.5) - (20.0 * 0.5f64.log10())).abs() < 1e-9); +} + +// --- document shell --- + +#[test] +fn document_has_fcpxml_header_and_version() { + let tl = Timeline::new(); + let xml = export_fcpxml(&tl, &manifest(vec![]), None); + assert!(xml.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n")); + assert!(xml.contains("<fcpxml version=\"1.10\">")); + assert!(xml.contains("<resources>")); + assert!(xml.contains("<library>")); + assert!(xml.contains("<event name=\"OpenTake\">")); + assert!(xml.contains("<project name=\"Timeline Export\">")); + // Empty timeline → self-closing spine. + assert!(xml.contains("<spine/>") || xml.contains("<spine>")); + // Timeline format r1 with 1/30s frame duration (default 30fps). + assert!(xml.contains("<format id=\"r1\"")); + assert!(xml.contains("frameDuration=\"1/30s\"")); +} + +#[test] +fn sequence_carries_format_and_tcformat() { + let mut tl = Timeline::new(); + tl.fps = 24; + let xml = export_fcpxml(&tl, &manifest(vec![]), None); + assert!(xml.contains("<sequence format=\"r1\"")); + assert!(xml.contains("tcFormat=\"NDF\"")); + assert!(xml.contains("frameDuration=\"1/24s\"")); +} + +// --- assets --- + +#[test] +fn video_clip_emits_asset_and_asset_clip() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 60)); // 2s @30 + tl.tracks.push(vt); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + None, + ); + // Asset r2 with file:// src + per-source format r3. + assert!(xml.contains("<asset id=\"r2\"")); + assert!(xml.contains("src=\"file:///media/shot.mp4\"")); + assert!(xml.contains("hasVideo=\"1\"")); + assert!(xml.contains("hasAudio=\"1\"")); + assert!(xml.contains("<format id=\"r3\"")); + // asset-clip references r2; offset 0, duration 2s (60/30). + assert!(xml.contains("<asset-clip ref=\"r2\"")); + assert!(xml.contains("offset=\"0s\"")); + assert!(xml.contains("duration=\"2s\"")); +} + +#[test] +fn trim_sets_clip_start() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 30, 30); // offset 1s + clip.trim_start_frame = 15; // start 0.5s + vt.clips.push(clip); + tl.tracks.push(vt); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + None, + ); + assert!(xml.contains("offset=\"1s\"")); + assert!(xml.contains("start=\"1/2s\"")); // 15/30 +} + +#[test] +fn duplicate_media_ref_emits_single_asset() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 30)); + vt.clips.push(Clip::new("c2", "v1", 30, 30)); + tl.tracks.push(vt); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + None, + ); + // Only one asset r2 (the file:// src appears once). + assert_eq!(xml.matches("src=\"file:///media/shot.mp4\"").count(), 1); + // Two asset-clips both referencing r2. + assert_eq!(xml.matches("<asset-clip ref=\"r2\"").count(), 2); +} + +#[test] +fn audio_asset_has_audio_flags_no_video() { + let mut tl = Timeline::new(); + let mut at = Track::new("a", ClipType::Audio); + let mut clip = Clip::new("c1", "a1", 0, 60); + clip.media_type = ClipType::Audio; + at.clips.push(clip); + tl.tracks.push(at); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("a1", "song.mp3", ClipType::Audio, 10.0)]), + None, + ); + assert!(xml.contains("hasVideo=\"0\"")); + assert!(xml.contains("hasAudio=\"1\"")); + assert!(xml.contains("audioChannels=\"2\"")); + // Audio on a negative connected lane (no primary visual track here, so + // the audio track itself becomes primary — lane omitted). + assert!(xml.contains("<asset-clip ref=\"r2\"")); +} + +// --- text → title --- + +#[test] +fn text_clip_emits_title_not_asset() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("v1", "vid", 0, 60)); + let mut tt = Track::new("t", ClipType::Text); + let mut text = Clip::new("t1", "text-asset", 0, 90); + text.media_type = ClipType::Text; + text.text_content = Some("Hello World".to_string()); + text.text_style = Some(TextStyle::default()); + tt.clips.push(text); + tl.tracks.push(vt); + tl.tracks.push(tt); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("vid", "vid.mp4", ClipType::Video, 4.0)]), + None, + ); + assert!(xml.contains("<title")); + assert!(xml.contains("Hello World")); + // The text clip's media-ref must NOT appear as an asset. + assert!(!xml.contains("text-asset")); + // Title is a connected clip (lane 1) since video track is primary. + assert!(xml.contains("lane=\"1\"")); +} + +// --- adjustments --- + +#[test] +fn transform_emits_adjust_transform() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 0, 30); + clip.transform = Transform { + center_x: 0.75, // offset right + center_y: 0.5, + width: 0.5, // scale 0.5 + height: 0.5, + rotation: 10.0, + flip_horizontal: false, + flip_vertical: false, + }; + vt.clips.push(clip); + tl.tracks.push(vt); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + None, + ); + assert!(xml.contains("<adjust-transform")); + assert!(xml.contains("scale=\"0.5000 0.5000\"")); + // rotation negated. + assert!(xml.contains("rotation=\"-10.0000\"")); + // position x offset = (0.75-0.5)*1920 = 480. + assert!(xml.contains("position=\"480.0000 0.0000\"")); +} + +#[test] +fn default_transform_emits_no_adjust_transform() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 30)); + tl.tracks.push(vt); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + None, + ); + assert!(!xml.contains("<adjust-transform")); +} + +#[test] +fn audio_volume_emits_adjust_volume_in_db() { + let mut tl = Timeline::new(); + let mut at = Track::new("a", ClipType::Audio); + let mut clip = Clip::new("c1", "a1", 0, 60); + clip.media_type = ClipType::Audio; + clip.volume = 0.5; // ~ -6 dB + at.clips.push(clip); + tl.tracks.push(at); + let xml = export_fcpxml( + &tl, + &manifest(vec![entry("a1", "song.mp3", ClipType::Audio, 10.0)]), + None, + ); + assert!(xml.contains("<adjust-volume")); + assert!(xml.contains("dB")); +} + +// --- lanes / spine --- + +#[test] +fn first_video_track_is_primary_others_get_lanes() { + let mut tl = Timeline::new(); + let mut v1 = Track::new("v1", ClipType::Video); + v1.clips.push(Clip::new("c1", "m1", 0, 30)); + let mut v2 = Track::new("v2", ClipType::Video); + v2.clips.push(Clip::new("c2", "m2", 0, 30)); + let mut a1 = Track::new("a1", ClipType::Audio); + let mut ac = Clip::new("c3", "m3", 0, 30); + ac.media_type = ClipType::Audio; + a1.clips.push(ac); + tl.tracks.push(v1); + tl.tracks.push(v2); + tl.tracks.push(a1); + let xml = export_fcpxml( + &tl, + &manifest(vec![ + entry("m1", "a.mp4", ClipType::Video, 4.0), + entry("m2", "b.mp4", ClipType::Video, 4.0), + entry("m3", "c.mp3", ClipType::Audio, 4.0), + ]), + None, + ); + // Second video track → lane 1; audio → lane -1. + assert!(xml.contains("lane=\"1\"")); + assert!(xml.contains("lane=\"-1\"")); +} + +// --- empty / unresolved --- + +#[test] +fn empty_timeline_has_empty_spine() { + let tl = Timeline::new(); + let xml = export_fcpxml(&tl, &manifest(vec![]), None); + assert!(xml.contains("<spine/>") || xml.contains("<spine>\n")); +} + +#[test] +fn unresolved_media_uses_relative_src() { + let mut tl = Timeline::new(); + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "ghost", 0, 30)); + tl.tracks.push(vt); + let xml = export_fcpxml(&tl, &manifest(vec![]), None); + assert!(xml.contains("src=\"media/ghost\"")); +} + +#[test] +fn output_is_well_formed_escaped() { + // Text with XML metacharacters must be escaped. + let mut tl = Timeline::new(); + let mut tt = Track::new("t", ClipType::Text); + let mut text = Clip::new("t1", "ta", 0, 30); + text.media_type = ClipType::Text; + text.text_content = Some("A & B <tag>".to_string()); + tt.clips.push(text); + tl.tracks.push(tt); + let xml = export_fcpxml(&tl, &manifest(vec![]), None); + assert!(xml.contains("A & B <tag>")); + assert!(!xml.contains("A & B <tag>")); +} diff --git a/crates/opentake-project/src/lib.rs b/crates/opentake-project/src/lib.rs index bde8f41..e98ecee 100644 --- a/crates/opentake-project/src/lib.rs +++ b/crates/opentake-project/src/lib.rs @@ -31,6 +31,11 @@ //! resolvable media into the destination `media/` directory and rewrite the //! manifest to bundle-relative paths. //! - [`layout`] — the bundle file-name contract. +//! - Timeline-interchange exporters: [`fcpxml::export_xmeml`] (XMEML 4 / FCP7 +//! XML — Premiere・DaVinci・剪映), [`edl::export_edl`] (CMX3600 EDL), +//! [`otio::export_otio`] (OpenTimelineIO JSON), and +//! [`fcpxml_modern::export_fcpxml`] (native Final Cut Pro X FCPXML 1.10). +//! [`xmlnode`] is the shared XML document tree the XML emitters render through. //! //! The [`Timeline`](opentake_domain::Timeline), //! [`MediaManifest`](opentake_domain::MediaManifest), and related value types @@ -39,16 +44,23 @@ pub mod archive; pub mod bundle; +pub mod edl; pub mod error; pub mod fcpxml; +pub mod fcpxml_modern; pub mod gen_log; pub mod layout; +pub mod otio; +pub mod xmlnode; pub use archive::{archive, ArchiveReport, MissingMedia}; pub use bundle::Project; +pub use edl::export_edl; pub use error::{ProjectError, Result}; pub use fcpxml::export_xmeml; +pub use fcpxml_modern::export_fcpxml; pub use gen_log::{GenerationLog, GenerationLogEntry}; +pub use otio::export_otio; // Re-export the domain types a caller needs to construct/inspect a project, so // downstream crates can depend on just `opentake-project` for persistence work. diff --git a/crates/opentake-project/src/otio.rs b/crates/opentake-project/src/otio.rs new file mode 100644 index 0000000..6d446e9 --- /dev/null +++ b/crates/opentake-project/src/otio.rs @@ -0,0 +1,543 @@ +//! Timeline export as OpenTimelineIO JSON (`.otio`) — the industry-standard +//! interchange format `otioview`, DaVinci Resolve, and Blender's VSE read. +//! +//! ## Why hand-written JSON +//! +//! There is no maintained, GPL-compatible OpenTimelineIO crate on crates.io, so +//! this module emits the OTIO JSON schema directly via `serde_json` (the project +//! convention: prefer std/serde over heavy deps). The shape is validated against +//! the reference samples in the OpenTimelineIO repo +//! (`tests/sample_data/simple_cut.otio`, `clip_example.otio`): each node carries +//! its exact `"OTIO_SCHEMA"` tag and field set, so a real OTIO reader round-trips +//! it. Keys serialize alphabetically (serde_json's default `Map` ordering) — OTIO +//! readers key by name, not position, so this is conformant and deterministic. +//! +//! ## Structure (OTIO 0.x schema) +//! +//! ```text +//! Timeline.1 +//! └── tracks: Stack.1 +//! └── children: [ Track.1 (kind "Video"/"Audio") +//! └── children: [ Clip.1 | Gap.1 ] ] ] +//! ``` +//! +//! - **RationalTime.1** `{ value, rate }` — `value` is an integer frame count, +//! `rate` is the timeline fps. +//! - **TimeRange.1** `{ start_time: RationalTime, duration: RationalTime }`. +//! - **Clip.1** carries `source_range` (the trimmed source window, in source +//! frames) and a `media_reference` (**ExternalReference.1** with a `file://` +//! `target_url` and an `available_range` = the whole source). +//! - **Gap.1** fills the space between clips (and any lead-in before the first +//! clip) so a track's children tile its timeline span contiguously, which is +//! how OTIO represents empty time. +//! +//! ## What this preserves vs. drops +//! +//! Preserves: track order and kind (Video/Audio), per-clip timeline placement +//! (via gaps + ordering), the trimmed **source range** (trim + speed-consumed +//! frames), gaps between clips, and a per-clip external media reference with its +//! available range. +//! +//! Drops: transforms / scale / rotation / crop / opacity / volume / keyframes +//! (OTIO models these as `effects`, which this first cut leaves empty), fades, +//! linked-A/V grouping, and text-clip styling. Speed is folded into the source +//! range length (the clip still references the right source window) but no +//! `LinearTimeWarp` effect is emitted. +//! +//! ## Frame fidelity +//! +//! All `RationalTime` values are integer frames at the timeline fps. The clip +//! source window is `[trim_start, trim_start + source_frames_consumed)`; OTIO +//! `start_time` is the source-in frame and `duration` is the consumed length, so +//! the record-side length on the timeline equals the clip's `duration_frames` +//! when speed is 1 (matching OTIO's "trimmed source, placed in order" model). + +use std::path::Path; + +use opentake_domain::{Clip, ClipType, MediaManifest, MediaResolver, Timeline, Track}; +use serde_json::{json, Value}; + +/// Export a [`Timeline`] as an OpenTimelineIO JSON string (pretty-printed). +/// Pure function: takes the timeline, media manifest, and the project base dir +/// (for resolving `Project`-relative media into `file://` URLs). +pub fn export_otio( + timeline: &Timeline, + manifest: &MediaManifest, + project_base: Option<&Path>, +) -> String { + let resolver = MediaResolver::new(manifest, project_base); + let doc = Builder { + timeline, + resolver: &resolver, + } + .build(); + // Pretty-print: OTIO files are conventionally human-readable & 2-space. + serde_json::to_string_pretty(&doc).unwrap_or_else(|_| "{}".to_string()) +} + +struct Builder<'a> { + timeline: &'a Timeline, + resolver: &'a MediaResolver<'a>, +} + +impl Builder<'_> { + fn build(&self) -> Value { + let fps = self.timeline.fps.max(1); + let track_nodes: Vec<Value> = self + .timeline + .tracks + .iter() + .map(|t| self.track_node(t, fps)) + .collect(); + + json!({ + "OTIO_SCHEMA": "Timeline.1", + "metadata": {}, + "name": "Timeline Export", + "global_start_time": Value::Null, + "tracks": { + "OTIO_SCHEMA": "Stack.1", + "children": track_nodes, + "effects": [], + "markers": [], + "metadata": {}, + "name": "tracks", + "source_range": Value::Null, + }, + }) + } + + /// One `Track.1`. Its `children` tile the track span: a lead-in gap (if the + /// first clip starts after 0), each clip, and a gap between consecutive + /// clips. Overlapping clips on the same track (which OTIO's single-lane track + /// cannot represent) are placed back-to-back with no negative gap. + fn track_node(&self, track: &Track, fps: i32) -> Value { + let kind = if track.kind == ClipType::Audio { + "Audio" + } else { + "Video" + }; + + let mut clips: Vec<&Clip> = track.clips.iter().collect(); + clips.sort_by_key(|c| c.start_frame); + + let mut children: Vec<Value> = Vec::new(); + let mut cursor = 0i32; // next free timeline frame on this lane + for clip in clips { + let start = clip.start_frame.max(0); + if start > cursor { + children.push(gap_node(start - cursor, fps)); + } + children.push(self.clip_node(clip, fps)); + // Advance by the clip's timeline length; never go backwards. + cursor = cursor.max(clip.end_frame()); + } + + json!({ + "OTIO_SCHEMA": "Track.1", + "children": children, + "effects": [], + "kind": kind, + "markers": [], + "metadata": {}, + "name": track.id_or_default(kind), + "source_range": Value::Null, + }) + } + + /// One `Clip.1` with a trimmed `source_range` and an `ExternalReference.1` + /// media reference. The source window is `[trim_start, trim_start + + /// source_frames_consumed)`; `available_range` spans the whole source. + fn clip_node(&self, clip: &Clip, fps: i32) -> Value { + let source_in = clip.trim_start_frame.max(0); + let consumed = clip.source_frames_consumed().max(0); + + let entry = self.resolver.entry(&clip.media_ref); + let media_name = self.resolver.display_name(&clip.media_ref); + // Whole-source length in frames (for available_range). Fall back to the + // clip's own source span when the manifest has no duration. + let source_total = entry + .map(|e| seconds_to_frame(e.duration, fps).max(consumed)) + .unwrap_or_else(|| clip.source_duration_frames().max(consumed)); + + json!({ + "OTIO_SCHEMA": "Clip.1", + "effects": [], + "enabled": true, + "markers": [], + "media_reference": self.media_reference(clip, media_name.as_str(), source_total, fps), + "metadata": {}, + "name": media_name, + "source_range": time_range(source_in, consumed, fps), + }) + } + + /// The clip's `ExternalReference.1`: a `file://` URL to the resolved media + /// path (or a relative `media/<id>` URL when unresolved) plus the whole + /// source's `available_range`. + fn media_reference(&self, clip: &Clip, media_name: &str, source_total: i32, fps: i32) -> Value { + let target_url = self + .resolver + .expected_path(&clip.media_ref) + .map(|p| path_to_file_url(&p)) + .unwrap_or_else(|| format!("media/{}", clip.media_ref)); + + json!({ + "OTIO_SCHEMA": "ExternalReference.1", + "available_range": time_range(0, source_total, fps), + "metadata": {}, + "name": media_name, + "target_url": target_url, + }) + } +} + +/// A `Gap.1` of `frames` length at the timeline fps. Used to space clips out so a +/// track's children tile its span (OTIO has no implicit gaps). +fn gap_node(frames: i32, fps: i32) -> Value { + json!({ + "OTIO_SCHEMA": "Gap.1", + "effects": [], + "enabled": true, + "markers": [], + "metadata": {}, + "name": "Gap", + "source_range": time_range(0, frames.max(0), fps), + }) +} + +/// `TimeRange.1 { start_time, duration }` from integer frame counts. +fn time_range(start_frame: i32, duration_frames: i32, fps: i32) -> Value { + json!({ + "OTIO_SCHEMA": "TimeRange.1", + "duration": rational_time(duration_frames, fps), + "start_time": rational_time(start_frame, fps), + }) +} + +/// `RationalTime.1 { value, rate }` — integer frame `value` at `rate` fps. +fn rational_time(frame: i32, fps: i32) -> Value { + json!({ + "OTIO_SCHEMA": "RationalTime.1", + "rate": fps, + "value": frame, + }) +} + +/// `seconds * fps`, truncated (matches the rest of the export layer / upstream +/// `secondsToFrame`). +fn seconds_to_frame(seconds: f64, fps: i32) -> i32 { + (seconds * fps as f64) as i32 +} + +/// Absolute path → an OTIO `file://` URL. OTIO recommends the `file://` scheme +/// for local references; we emit `file://<abs-path>` (POSIX paths already start +/// with `/`, giving the canonical `file:///path`). +fn path_to_file_url(path: &Path) -> String { + let s = path.to_string_lossy(); + if s.starts_with('/') { + format!("file://{s}") + } else { + // Non-absolute (e.g. Windows or relative): still prefix the scheme. + format!("file:///{s}") + } +} + +/// Helper to give a track a stable, non-empty name. Domain `Track.id` may be the +/// empty placeholder string (see `timeline.rs`); fall back to the kind. +trait TrackName { + fn id_or_default(&self, kind: &str) -> String; +} + +impl TrackName for Track { + fn id_or_default(&self, kind: &str) -> String { + if self.id.is_empty() { + kind.to_string() + } else { + self.id.clone() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use opentake_domain::{MediaManifestEntry, MediaSource}; + use serde_json::Value; + + fn entry(id: &str, name: &str, kind: ClipType, duration: f64) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: name.into(), + kind, + source: MediaSource::External { + absolute_path: format!("/media/{name}"), + }, + duration, + generation_input: None, + source_width: Some(1920), + source_height: Some(1080), + source_fps: Some(24.0), + has_audio: Some(true), + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } + } + + fn manifest(entries: Vec<MediaManifestEntry>) -> MediaManifest { + let mut m = MediaManifest::new(); + m.entries = entries; + m + } + + /// Parse the exported string back into JSON so tests assert on structure, not + /// whitespace. + fn export_value(tl: &Timeline, m: &MediaManifest) -> Value { + let s = export_otio(tl, m, None); + serde_json::from_str(&s).expect("exported OTIO must be valid JSON") + } + + // --- top-level shape --- + + #[test] + fn top_level_is_timeline_schema_with_stack_tracks() { + let tl = Timeline::new(); + let v = export_value(&tl, &manifest(vec![])); + assert_eq!(v["OTIO_SCHEMA"], "Timeline.1"); + assert_eq!(v["tracks"]["OTIO_SCHEMA"], "Stack.1"); + assert!(v["tracks"]["children"].is_array()); + // global_start_time present and null (matches reference samples). + assert!(v["global_start_time"].is_null()); + } + + #[test] + fn empty_timeline_has_no_tracks() { + let tl = Timeline::new(); + let v = export_value(&tl, &manifest(vec![])); + assert_eq!(v["tracks"]["children"].as_array().unwrap().len(), 0); + } + + // --- track kind --- + + #[test] + fn video_and_audio_tracks_get_correct_kind() { + let mut tl = Timeline::new(); + tl.tracks.push(Track::new("v", ClipType::Video)); + tl.tracks.push(Track::new("a", ClipType::Audio)); + let v = export_value(&tl, &manifest(vec![])); + let tracks = v["tracks"]["children"].as_array().unwrap(); + assert_eq!(tracks[0]["OTIO_SCHEMA"], "Track.1"); + assert_eq!(tracks[0]["kind"], "Video"); + assert_eq!(tracks[1]["kind"], "Audio"); + } + + #[test] + fn image_and_text_tracks_are_video_kind() { + let mut tl = Timeline::new(); + tl.tracks.push(Track::new("i", ClipType::Image)); + tl.tracks.push(Track::new("t", ClipType::Text)); + let v = export_value(&tl, &manifest(vec![])); + let tracks = v["tracks"]["children"].as_array().unwrap(); + assert_eq!(tracks[0]["kind"], "Video"); + assert_eq!(tracks[1]["kind"], "Video"); + } + + // --- clip / source range --- + + #[test] + fn clip_has_source_range_and_external_reference() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 48)); + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + let clip = &v["tracks"]["children"][0]["children"][0]; + assert_eq!(clip["OTIO_SCHEMA"], "Clip.1"); + assert_eq!(clip["name"], "shot.mp4"); + // source_range: TimeRange with RationalTime start/duration. + let sr = &clip["source_range"]; + assert_eq!(sr["OTIO_SCHEMA"], "TimeRange.1"); + assert_eq!(sr["start_time"]["OTIO_SCHEMA"], "RationalTime.1"); + assert_eq!(sr["start_time"]["value"], 0); + assert_eq!(sr["start_time"]["rate"], 24); + assert_eq!(sr["duration"]["value"], 48); + // media_reference: ExternalReference with file:// url + available_range. + let mr = &clip["media_reference"]; + assert_eq!(mr["OTIO_SCHEMA"], "ExternalReference.1"); + assert_eq!(mr["target_url"], "file:///media/shot.mp4"); + assert_eq!(mr["available_range"]["OTIO_SCHEMA"], "TimeRange.1"); + // available range duration = 4s * 24 = 96. + assert_eq!(mr["available_range"]["duration"]["value"], 96); + } + + #[test] + fn trim_offsets_source_start_time() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 0, 24); + clip.trim_start_frame = 12; + vt.clips.push(clip); + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + let sr = &v["tracks"]["children"][0]["children"][0]["source_range"]; + assert_eq!(sr["start_time"]["value"], 12); + assert_eq!(sr["duration"]["value"], 24); + } + + #[test] + fn speed_folds_into_consumed_source_duration() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + let mut clip = Clip::new("c1", "v1", 0, 24); // 1s timeline + clip.speed = 2.0; // consumes 48 source frames + vt.clips.push(clip); + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + let sr = &v["tracks"]["children"][0]["children"][0]["source_range"]; + assert_eq!(sr["duration"]["value"], 48); + } + + // --- gaps --- + + #[test] + fn lead_in_gap_before_first_clip() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 24, 24)); // starts at frame 24 + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![entry("v1", "shot.mp4", ClipType::Video, 4.0)]), + ); + let children = v["tracks"]["children"][0]["children"].as_array().unwrap(); + assert_eq!(children.len(), 2); + assert_eq!(children[0]["OTIO_SCHEMA"], "Gap.1"); + assert_eq!(children[0]["source_range"]["duration"]["value"], 24); + assert_eq!(children[1]["OTIO_SCHEMA"], "Clip.1"); + } + + #[test] + fn gap_between_clips() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 24)); // [0,24) + vt.clips.push(Clip::new("c2", "v2", 48, 24)); // gap [24,48), then clip + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![ + entry("v1", "a.mp4", ClipType::Video, 4.0), + entry("v2", "b.mp4", ClipType::Video, 4.0), + ]), + ); + let children = v["tracks"]["children"][0]["children"].as_array().unwrap(); + // clip, gap(24), clip + assert_eq!(children.len(), 3); + assert_eq!(children[0]["OTIO_SCHEMA"], "Clip.1"); + assert_eq!(children[1]["OTIO_SCHEMA"], "Gap.1"); + assert_eq!(children[1]["source_range"]["duration"]["value"], 24); + assert_eq!(children[2]["OTIO_SCHEMA"], "Clip.1"); + } + + #[test] + fn adjacent_clips_have_no_gap() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 24)); + vt.clips.push(Clip::new("c2", "v2", 24, 24)); // contiguous + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![ + entry("v1", "a.mp4", ClipType::Video, 4.0), + entry("v2", "b.mp4", ClipType::Video, 4.0), + ]), + ); + let children = v["tracks"]["children"][0]["children"].as_array().unwrap(); + assert_eq!(children.len(), 2); + assert!(children.iter().all(|c| c["OTIO_SCHEMA"] == "Clip.1")); + } + + #[test] + fn overlapping_clips_placed_back_to_back_no_negative_gap() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "v1", 0, 48)); // [0,48) + vt.clips.push(Clip::new("c2", "v2", 24, 48)); // overlaps; starts at 24 + tl.tracks.push(vt); + let v = export_value( + &tl, + &manifest(vec![ + entry("v1", "a.mp4", ClipType::Video, 4.0), + entry("v2", "b.mp4", ClipType::Video, 4.0), + ]), + ); + let children = v["tracks"]["children"][0]["children"].as_array().unwrap(); + // No gap inserted (would be negative); both clips present. + assert_eq!(children.len(), 2); + assert!(children.iter().all(|c| c["OTIO_SCHEMA"] == "Clip.1")); + } + + // --- unresolved media --- + + #[test] + fn unresolved_media_uses_relative_target_url() { + let mut tl = Timeline::new(); + tl.fps = 24; + let mut vt = Track::new("v", ClipType::Video); + vt.clips.push(Clip::new("c1", "ghost", 0, 24)); + tl.tracks.push(vt); + let v = export_value(&tl, &manifest(vec![])); + let mr = &v["tracks"]["children"][0]["children"][0]["media_reference"]; + assert_eq!(mr["target_url"], "media/ghost"); + // name falls back to "Offline". + assert_eq!(v["tracks"]["children"][0]["children"][0]["name"], "Offline"); + } + + // --- file url --- + + #[test] + fn file_url_for_absolute_path() { + assert_eq!( + path_to_file_url(Path::new("/abs/clip.mov")), + "file:///abs/clip.mov" + ); + } + + // --- rational time / time range --- + + #[test] + fn rational_time_carries_value_and_rate() { + let rt = rational_time(42, 30); + assert_eq!(rt["OTIO_SCHEMA"], "RationalTime.1"); + assert_eq!(rt["value"], 42); + assert_eq!(rt["rate"], 30); + } + + #[test] + fn fps_zero_floored_to_one() { + let mut tl = Timeline::new(); + tl.fps = 0; + tl.tracks.push(Track::new("v", ClipType::Video)); + let v = export_value(&tl, &manifest(vec![])); + // No panic; track present. + assert_eq!(v["tracks"]["children"].as_array().unwrap().len(), 1); + } +} diff --git a/crates/opentake-project/src/xmlnode.rs b/crates/opentake-project/src/xmlnode.rs new file mode 100644 index 0000000..df92cec --- /dev/null +++ b/crates/opentake-project/src/xmlnode.rs @@ -0,0 +1,176 @@ +//! A minimal XML document tree shared by the XML-based exporters +//! ([`crate::fcpxml_modern`]). Builders describe document *structure*; +//! [`render_document`] owns all indentation and escaping, so no emitter +//! hardcodes whitespace. (The legacy XMEML exporter in [`crate::fcpxml`] keeps +//! its own equivalent private copy to avoid churning that tested module.) + +/// One XML element. A leaf carries `text` (`<n>text</n>`); a branch carries +/// `children`; an element with neither renders self-closing (`<n/>`). +pub struct XmlNode { + name: String, + attributes: Vec<(String, String)>, + text: Option<String>, + children: Vec<XmlNode>, +} + +impl XmlNode { + /// Append children and return self (chaining after `with_owned_attrs`). + pub fn with_children(mut self, children: Vec<XmlNode>) -> Self { + self.children = children; + self + } + + /// Build an element from owned `(key, value)` attribute pairs (for callers + /// that compute attribute strings dynamically). + pub fn with_owned_attrs(name: &str, attrs: Vec<(String, String)>) -> Self { + XmlNode { + name: name.to_string(), + attributes: attrs, + text: None, + children: Vec::new(), + } + } +} + +/// A branch element with children and no attributes. +pub fn el(name: &str, children: Vec<XmlNode>) -> XmlNode { + XmlNode { + name: name.to_string(), + attributes: Vec::new(), + text: None, + children, + } +} + +/// A branch element with borrowed `(key, value)` attributes and children. +pub fn el_attrs(name: &str, attrs: Vec<(&str, &str)>, children: Vec<XmlNode>) -> XmlNode { + XmlNode { + name: name.to_string(), + attributes: attrs + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(), + text: None, + children, + } +} + +/// A leaf element `<name>value</name>` (value is XML-escaped on render). +pub fn leaf_text(name: &str, value: &str) -> XmlNode { + XmlNode { + name: name.to_string(), + attributes: Vec::new(), + text: Some(value.to_string()), + children: Vec::new(), + } +} + +/// A `name="value"` attribute pair where the value is a boolean rendered as +/// `1`/`0` (FCPXML's boolean attribute convention). Exposed for exporters that +/// build attribute lists directly. +pub fn boolean_attr(name: &str, value: bool) -> (String, String) { + (name.to_string(), if value { "1" } else { "0" }.to_string()) +} + +/// Render a full document: the XML declaration, an optional DOCTYPE line, then +/// the root element tree. Two-space indentation per level. +pub fn render_document(doctype: &str, root: &XmlNode) -> String { + format!( + "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n{doctype}\n{}", + render(root, 0) + ) +} + +fn render(node: &XmlNode, indent: usize) -> String { + let pad = " ".repeat(indent); + let attrs: String = node + .attributes + .iter() + .map(|(k, v)| format!(" {k}=\"{}\"", escape_xml(v))) + .collect(); + if let Some(text) = &node.text { + return format!( + "{pad}<{}{attrs}>{}</{}>", + node.name, + escape_xml(text), + node.name + ); + } + if node.children.is_empty() { + return format!("{pad}<{}{attrs}/>", node.name); + } + let inner: Vec<String> = node + .children + .iter() + .map(|c| render(c, indent + 2)) + .collect(); + format!( + "{pad}<{}{attrs}>\n{}\n{pad}</{}>", + node.name, + inner.join("\n"), + node.name + ) +} + +fn escape_xml(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) + .replace('\'', "'") +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn renders_leaf() { + assert_eq!(render(&leaf_text("name", "hi"), 0), "<name>hi</name>"); + } + + #[test] + fn renders_self_closing_for_empty() { + let n = XmlNode::with_owned_attrs("asset", vec![("id".into(), "r1".into())]); + assert_eq!(render(&n, 0), "<asset id=\"r1\"/>"); + } + + #[test] + fn renders_nested_two_space_indent() { + let n = el("a", vec![el("b", vec![leaf_text("c", "x")])]); + assert_eq!(render(&n, 0), "<a>\n <b>\n <c>x</c>\n </b>\n</a>"); + } + + #[test] + fn escapes_text_and_attrs() { + let n = XmlNode::with_owned_attrs("e", vec![("k".into(), "<v>".into())]) + .with_children(vec![leaf_text("c", "a&b<c>")]); + let out = render(&n, 0); + assert!(out.contains("k=\"<v>\"")); + assert!(out.contains("a&b<c>")); + } + + #[test] + fn boolean_attr_is_one_zero() { + assert_eq!( + boolean_attr("hasVideo", true), + ("hasVideo".into(), "1".into()) + ); + assert_eq!( + boolean_attr("hasAudio", false), + ("hasAudio".into(), "0".into()) + ); + } + + #[test] + fn document_has_declaration_and_doctype() { + let doc = render_document("<!DOCTYPE fcpxml>", &el("fcpxml", vec![])); + assert!(doc.starts_with("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<!DOCTYPE fcpxml>\n")); + } + + #[test] + fn with_children_replaces_children() { + let n = XmlNode::with_owned_attrs("p", vec![]).with_children(vec![leaf_text("c", "v")]); + assert_eq!(render(&n, 0), "<p>\n <c>v</c>\n</p>"); + } +} diff --git a/crates/opentake-render/src/gpu/device.rs b/crates/opentake-render/src/gpu/device.rs index 09170e3..d1972c0 100644 --- a/crates/opentake-render/src/gpu/device.rs +++ b/crates/opentake-render/src/gpu/device.rs @@ -30,11 +30,20 @@ impl RenderDevice { })) .ok_or(RenderError::NoAdapter)?; + // Request the adapter's REAL limits, not `downlevel_defaults()`. The + // downlevel baseline caps `max_texture_dimension_2d` at 2048, which is + // fine for the downscaled preview but makes the FULL-resolution export + // render target (FHD is borderline, 2K/4K exceed it) fail inside + // `Device::create_texture` with an uncaptured wgpu error that panics — + // i.e. "export video" aborted the whole app. Native Metal/Vulkan/DX12 + // report 16384 here, covering every realistic export resolution. + let required_limits = adapter.limits(); + let (device, queue) = pollster::block_on(adapter.request_device( &wgpu::DeviceDescriptor { label: Some("opentake-render device"), required_features: wgpu::Features::empty(), - required_limits: wgpu::Limits::downlevel_defaults(), + required_limits, memory_hints: wgpu::MemoryHints::Performance, }, None, diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 53a0b88..bb3dbd9 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -51,6 +51,12 @@ futures = { version = "0.3", optional = true } # playback-engine job). Off by default with the rest of the feature. cpal = { version = "0.15", optional = true } +# macOS trackpad haptics for snap feedback (1:1 with upstream's +# NSHapticFeedbackManager). objc2-app-kit 0.3 is already in the lockfile via +# Tauri, so this only enables the NSHapticFeedback module (incremental build). +[target.'cfg(target_os = "macos")'.dependencies] +objc2-app-kit = { version = "0.3", default-features = false, features = ["NSHapticFeedback"] } + [dev-dependencies] tempfile = "3" diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 74c0201..73170e5 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -81,14 +81,14 @@ pub fn get_default_project_dir(app: AppHandle) -> Result<String, String> { Ok(dir.to_string_lossy().into_owned()) } -/// `export_fcpxml`: write the current timeline to `path` as XMEML 4 (Final Cut -/// Pro 7 XML, `.xml`). Despite the command name, the produced format is XMEML — -/// see `opentake_project::fcpxml` for why (Premiere Pro doesn't read FCPXML -/// natively, so upstream exports XMEML; DaVinci/FCP still import FCP7 XML). Reads +/// `export_xmeml`: write the current timeline to `path` as XMEML 4 (Final Cut +/// Pro 7 XML, `.xml`). This is the Premiere / DaVinci / 剪映-importable +/// interchange format — Premiere Pro does NOT read modern FCPXML natively, so +/// upstream (and OpenTake) emit XMEML; DaVinci/FCP still import FCP7 XML. Reads /// the timeline / media manifest / project dir from the core, builds the XML via /// the pure `export_xmeml`, and writes the file. #[tauri::command] -pub fn export_fcpxml(core: State<'_, AppCore>, path: String) -> Result<(), String> { +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(); @@ -96,6 +96,57 @@ pub fn export_fcpxml(core: State<'_, AppCore>, path: String) -> Result<(), Strin std::fs::write(&path, xml).map_err(|e| e.to_string()) } +/// `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`. +/// New code (and the format picker) should call `export_xmeml`; native FCPXML is +/// `export_fcpxml_modern`. +#[tauri::command] +pub fn export_fcpxml(core: State<'_, AppCore>, path: String) -> Result<(), String> { + export_xmeml(core, path) +} + +/// `export_edl`: write the current timeline to `path` as a CMX3600 EDL (`.edl`). +/// A flat, video-track-only edit decision list (the EDL format itself only +/// describes one V track + linked audio channels) that Premiere / DaVinci / +/// Avid / 剪映 import. Effects, transforms, opacity, and multi-track layering are +/// dropped — see `opentake_project::edl` for the documented limitations. +#[tauri::command] +pub fn export_edl(core: State<'_, AppCore>, path: String) -> Result<(), String> { + let timeline = core.get_timeline().timeline; + let manifest = core.media(); + let edl = opentake_project::export_edl(&timeline, &manifest); + std::fs::write(&path, edl).map_err(|e| e.to_string()) +} + +/// `export_otio`: write the current timeline to `path` as OpenTimelineIO JSON +/// (`.otio`) — the industry-standard interchange `otioview` / DaVinci / Blender +/// read. Preserves track order/kind, clip placement, source ranges, gaps, and +/// per-clip media references; see `opentake_project::otio` for what is dropped +/// (effects, transforms, keyframes). +#[tauri::command] +pub fn export_otio(core: State<'_, AppCore>, path: String) -> Result<(), String> { + let timeline = core.get_timeline().timeline; + let manifest = core.media(); + let project_dir = core.project_dir(); + let json = opentake_project::export_otio(&timeline, &manifest, project_dir.as_deref()); + std::fs::write(&path, json).map_err(|e| e.to_string()) +} + +/// `export_fcpxml_modern`: write the current timeline to `path` as native Final +/// Cut Pro X FCPXML 1.10 (`.fcpxml`). Unlike XMEML, this carries text overlays +/// (`<title>`), transforms, opacity, and volume. NOTE: Premiere does NOT import +/// FCPXML — use `export_xmeml` for Premiere / DaVinci / 剪映. See +/// `opentake_project::fcpxml_modern`. +#[tauri::command] +pub fn export_fcpxml_modern(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_fcpxml(&timeline, &manifest, project_dir.as_deref()); + std::fs::write(&path, xml).map_err(|e| e.to_string()) +} + /// Requested subtitle container, projected from the front end. Lower-cased serde /// tags (`"srt"` / `"vtt"`) match the file extension the user picks. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Deserialize)] @@ -297,6 +348,8 @@ pub enum EditRequest { #[serde(rename_all = "camelCase")] SwapTracks { a: usize, b: usize }, #[serde(rename_all = "camelCase")] + SwapClips { clip_a: String, clip_b: String }, + #[serde(rename_all = "camelCase")] InsertTrack { kind: ClipType, at: Option<usize> }, #[serde(rename_all = "camelCase")] SetTrackProps { @@ -450,6 +503,10 @@ impl EditRequest { EditCommand::RemoveTracks { track_indexes } } EditRequest::SwapTracks { a, b } => EditCommand::SwapTracks { a, b }, + EditRequest::SwapClips { clip_a, clip_b } => EditCommand::SwapClips { + a: clip_a, + b: clip_b, + }, EditRequest::InsertTrack { kind, at } => EditCommand::InsertTrack { kind, at }, EditRequest::SetTrackProps { track_index, @@ -858,6 +915,24 @@ mod edit_request_serde_tests { } } + #[test] + fn deserializes_swap_clips_and_maps_to_command() { + // camelCase clipA/clipB must deserialize, or the cross-track swap gesture + // silently fails at the IPC boundary (the recurring DTO camelCase trap). + let request = serde_json::from_str::<EditRequest>( + r#"{"type":"swapClips","clipA":"clip-1","clipB":"clip-2"}"#, + ) + .expect("swapClips camelCase"); + + match request.into_command().expect("swapClips command") { + EditCommand::SwapClips { a, b } => { + assert_eq!(a, "clip-1"); + assert_eq!(b, "clip-2"); + } + other => panic!("expected SwapClips, got {other:?}"), + } + } + #[test] fn deserializes_effect_commands_and_maps_to_ops_variants() { let grade = serde_json::from_str::<EditRequest>( diff --git a/src-tauri/src/haptic.rs b/src-tauri/src/haptic.rs new file mode 100644 index 0000000..8ed43fe --- /dev/null +++ b/src-tauri/src/haptic.rs @@ -0,0 +1,29 @@ +//! Trackpad haptic feedback for timeline snaps — the light "alignment" tick +//! upstream fires (`NSHapticFeedbackManager.perform(.alignment)`) whenever a +//! clip edge / playhead snaps into place. macOS only; off other platforms the +//! front end plays a short tick sound instead, so the command stays callable +//! everywhere. + +/// `snap_haptic`: perform one light alignment haptic on the trackpad. The AppKit +/// feedback call is dispatched to the main thread (AppKit work belongs there) +/// and is best-effort — any failure is swallowed so a snap never errors the UI. +#[cfg(target_os = "macos")] +#[tauri::command] +pub fn snap_haptic(app: tauri::AppHandle) { + let _ = app.run_on_main_thread(|| { + use objc2_app_kit::{ + NSHapticFeedbackManager, NSHapticFeedbackPattern, NSHapticFeedbackPerformanceTime, + NSHapticFeedbackPerformer, + }; + NSHapticFeedbackManager::defaultPerformer().performFeedbackPattern_performanceTime( + NSHapticFeedbackPattern::Alignment, + NSHapticFeedbackPerformanceTime::Now, + ); + }); +} + +/// Non-macOS: trackpad haptics aren't available; the front end plays a short +/// tick sound instead. Kept as a no-op command so the call site is uniform. +#[cfg(not(target_os = "macos"))] +#[tauri::command] +pub fn snap_haptic() {} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index bbb6827..a8d3860 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -11,6 +11,7 @@ mod commands; // drive the export orchestrator (`export::run_export`) against the library // target. The Tauri command itself is registered below like the other modules. pub mod export; +mod haptic; mod library; mod mcp; mod media; @@ -149,7 +150,11 @@ pub fn run() { commands::project_open, commands::project_save, commands::get_default_project_dir, + commands::export_xmeml, commands::export_fcpxml, + commands::export_fcpxml_modern, + commands::export_edl, + commands::export_otio, commands::export_subtitles, commands::check_path_exists, media::import_folder, @@ -159,6 +164,9 @@ pub fn run() { media::extract_audio, media::get_waveform, media::generate_thumbnail, + media::preview_poster, + media::preload_media, + haptic::snap_haptic, render::composite_frame, export::export_video, secret::secret_save, diff --git a/src-tauri/src/media.rs b/src-tauri/src/media.rs index ae1959b..62e6074 100644 --- a/src-tauri/src/media.rs +++ b/src-tauri/src/media.rs @@ -255,6 +255,23 @@ fn poster_path_for(cache_root: &Path, key: &str) -> PathBuf { visual_cache_dir(cache_root).join(format!("{key}.thumb.png")) } +/// Hi-res preview-poster box: the first-frame still shown instantly behind the +/// `<video>` in the single-media preview. Much larger than the 120×68 grid +/// thumbnail ([`THUMB_MAX_SIZE`]) so the preview isn't blurry; the asset +/// protocol streams the real video progressively once metadata loads, so this is +/// purely the instant placeholder. Downscale-only (never enlarged). +const PREVIEW_POSTER_MAX_SIZE: (u32, u32) = (1920, 1080); + +/// Cache path for a hi-res preview poster. Keyed separately (`.preview…`) from +/// the small grid poster (`.thumb…`) so the two sizes never clobber each other. +fn preview_poster_path_for(cache_root: &Path, key: &str, time_secs: f64) -> PathBuf { + if time_secs <= 0.0 { + return visual_cache_dir(cache_root).join(format!("{key}.preview.png")); + } + let millis = (time_secs * 1000.0).round().max(0.0) as u64; + visual_cache_dir(cache_root).join(format!("{key}.preview.{millis}.png")) +} + fn timed_poster_path_for(cache_root: &Path, key: &str, time_secs: f64) -> PathBuf { if time_secs <= 0.0 { return poster_path_for(cache_root, key); @@ -303,14 +320,17 @@ fn poster_target_time(time_secs: Option<f64>) -> f64 { .unwrap_or(0.0) } -fn video_poster( - engine: &MediaEngine, +/// Decode (or read from cache) a single poster frame for `path` at `target`, +/// scaled to fit `max_size`, written to `poster_path`. Shared by the small grid +/// poster ([`video_poster`]) and the hi-res preview poster +/// ([`video_preview_poster`]); the two pass different `max_size` + `poster_path` +/// so their caches never clash. Returns `(path, width, height, actual_time)`. +fn decode_poster_to( path: &Path, - key: &str, - time_secs: Option<f64>, + poster_path: PathBuf, + target: f64, + max_size: (u32, u32), ) -> Result<(PathBuf, u32, u32, f64), String> { - let target = poster_target_time(time_secs); - let poster_path = timed_poster_path_for(engine.cache_root(), key, target); if poster_path.exists() { let (width, height) = image::image_dimensions(&poster_path) .map_err(|e| format!("thumbnail dimensions: {e}"))?; @@ -319,7 +339,7 @@ fn video_poster( let req = FrameRequest { time_secs: target, - max_size: THUMB_MAX_SIZE, + max_size, tolerance_secs: THUMB_TOLERANCE_SECS, apply_rotation: true, }; @@ -328,6 +348,30 @@ fn video_poster( Ok((poster_path, frame.width, frame.height, actual)) } +fn video_poster( + engine: &MediaEngine, + path: &Path, + key: &str, + time_secs: Option<f64>, +) -> Result<(PathBuf, u32, u32, f64), String> { + let target = poster_target_time(time_secs); + let poster_path = timed_poster_path_for(engine.cache_root(), key, target); + decode_poster_to(path, poster_path, target, THUMB_MAX_SIZE) +} + +/// Hi-res first-frame poster for the single-media preview (see +/// [`PREVIEW_POSTER_MAX_SIZE`]). Cached separately from the grid poster. +fn video_preview_poster( + engine: &MediaEngine, + path: &Path, + key: &str, + time_secs: Option<f64>, +) -> Result<(PathBuf, u32, u32, f64), String> { + let target = poster_target_time(time_secs); + let poster_path = preview_poster_path_for(engine.cache_root(), key, target); + decode_poster_to(path, poster_path, target, PREVIEW_POSTER_MAX_SIZE) +} + fn sprite_meta_path_for(cache_root: &Path, key: &str) -> PathBuf { visual_cache_dir(cache_root).join(format!("{key}.thumbs.json")) } @@ -775,6 +819,47 @@ pub fn generate_thumbnail( }) } +/// `preview_poster`: decode (and disk-cache) a HI-RES first-frame still for the +/// single-media preview, returning its on-disk path. This is the instant +/// placeholder painted behind the `<video>` so a cold preview shows its first +/// frame immediately (no blank/spinner) and is sharp — the asset protocol then +/// streams the real video progressively (it honors HTTP Range, so `<video>` does +/// not download the whole file). Larger than the 120×68 grid thumbnail and +/// cached separately, so the two never clobber. Returns `None` for non-video +/// assets (images render straight from disk; audio has no frame). Errors only +/// when the asset is unknown or its path can't be resolved. +#[tauri::command] +pub fn preview_poster( + core: State<'_, AppCore>, + media: State<'_, MediaState>, + media_ref: String, + time_secs: Option<f64>, +) -> Result<Option<String>, String> { + let manifest = core.media(); + let entry = manifest + .entries + .iter() + .find(|e| e.id == media_ref) + .ok_or_else(|| format!("media not found: {media_ref}"))?; + if entry.kind != ClipType::Video { + return Ok(None); + } + let path = source_path_for_entry(&core, entry)?; + if !path.is_file() { + return Err(format!("source file not found: {}", path.display())); + } + let key = cache_key_for(&path)?; + let (poster_path, _, _, _) = video_preview_poster(media.engine(), &path, &key, time_secs) + .map_err(|e| { + eprintln!( + "preview_poster failed: media_ref={media_ref} path={} error={e}", + path.display() + ); + e + })?; + Ok(Some(poster_path.to_string_lossy().into_owned())) +} + /// `get_waveform`: normalized waveform buckets (`0 = loud, 1 = silence`) for the /// media asset `media_ref`, computed (and disk-cached) by the media engine. The /// returned array spans the WHOLE source; the timeline maps each clip's trimmed @@ -810,6 +895,44 @@ pub fn get_waveform( }) } +/// `preload_media`: warm ONLY what makes the next preview instant — the hi-res +/// first-frame poster (video) — so a cold click shows a sharp first frame with +/// no decode on the interaction path. Meant to be called fire-and-forget when a +/// media item is selected or drag starts; it runs on a Tauri worker thread, so +/// it never blocks the UI, and is best-effort (a failure just means the cache +/// stays cold, never an error to the caller). +/// +/// Deliberately does NOT warm the 240-frame timeline filmstrip sprite or the +/// waveform: both are heavy full-source decodes that do nothing to speed actual +/// `<video>` playback (the asset protocol streams that progressively), and the +/// sprite/waveform are loaded lazily by their own consumers when a clip is +/// actually on the timeline. +#[tauri::command] +pub fn preload_media( + core: State<'_, AppCore>, + media: State<'_, MediaState>, + media_ref: String, +) -> Result<(), String> { + let manifest = core.media(); + let Some(entry) = manifest.entries.iter().find(|e| e.id == media_ref) else { + return Ok(()); + }; + if entry.kind != ClipType::Video { + return Ok(()); + } + let Some(path) = resolve_source_path(entry, core.project_dir().as_deref()) else { + return Ok(()); + }; + if !path.is_file() { + return Ok(()); + } + if let Ok(key) = cache_key_for(&path) { + // Hi-res preview poster only (best-effort). + let _ = video_preview_poster(media.engine(), &path, &key, None); + } + Ok(()) +} + /// Collect importable media files under `root`. Top-level only unless /// `recursive`. Sorted by case-insensitive file name so a folder import mints /// asset ids in a stable order. Hidden entries (dot-prefixed) are skipped, as @@ -951,6 +1074,39 @@ mod tests { assert_eq!(dto.thumbnail.as_deref(), Some(poster_string.as_str())); } + #[test] + fn preview_poster_path_is_distinct_from_grid_poster() { + // The hi-res preview poster and the small grid poster must never share a + // cache file, or one size would clobber the other. + let root = Path::new("/cache"); + let key = "abc123"; + assert_ne!( + preview_poster_path_for(root, key, 0.0), + poster_path_for(root, key), + "preview poster must not collide with the grid poster" + ); + assert!(preview_poster_path_for(root, key, 0.0) + .to_string_lossy() + .ends_with("abc123.preview.png")); + } + + #[test] + fn preview_poster_path_encodes_nonzero_time() { + let root = Path::new("/cache"); + let key = "k"; + // t=0 → base name; t>0 → millisecond-suffixed, and distinct per time. + assert!(preview_poster_path_for(root, key, 0.0) + .to_string_lossy() + .ends_with("k.preview.png")); + assert!(preview_poster_path_for(root, key, 1.5) + .to_string_lossy() + .ends_with("k.preview.1500.png")); + assert_ne!( + preview_poster_path_for(root, key, 1.0), + preview_poster_path_for(root, key, 2.0) + ); + } + #[test] fn thumbnail_dto_serializes_camel_case() { let dto = ThumbnailDto { diff --git a/src-tauri/tests/export_integration.rs b/src-tauri/tests/export_integration.rs index 217f80b..6143056 100644 --- a/src-tauri/tests/export_integration.rs +++ b/src-tauri/tests/export_integration.rs @@ -276,6 +276,58 @@ fn export_full_timeline_produces_playable_mp4() { ); } +/// Regression for "export video crashes the app": the render device was created +/// with `downlevel_defaults()` limits (max_texture_dimension_2d = 2048), so a +/// full-resolution export above 2048px (2K / 4K) blew up inside +/// `Device::create_texture` with an uncaptured wgpu error that panics → SIGABRT. +/// A 4K-quality export renders a target far beyond 2048px and must now succeed. +#[test] +fn export_4k_render_target_clears_downlevel_texture_limit() { + if !ffmpeg_ready() { + eprintln!("skip: ffmpeg/ffprobe not available"); + return; + } + + let dir = tempfile::tempdir().unwrap(); + let src = dir.path().join("src4k.mp4"); + let out = dir.path().join("out4k.mp4"); + + let (sw, sh, sfps, frames) = (320, 240, 10, 4); + if !make_video(&src, sw, sh, sfps, frames) { + eprintln!("skip: could not generate fixture media"); + return; + } + + let timeline = build_timeline(frames as i32, sw as i32, sh as i32, sfps as f64); + let manifest = build_manifest(&src, sw as i32, sh as i32, sfps as f64); + + let req = ExportRequest { + out_path: out.to_string_lossy().into_owned(), + codec: Default::default(), + quality: ExportQuality::P4k, + }; + + let summary = match run_export(&timeline, &manifest, &None, &req) { + Ok(s) => s, + Err(e) => { + if e.contains("no GPU device") { + eprintln!("skip: no GPU adapter available ({e})"); + return; + } + panic!("export failed: {e}"); + } + }; + + assert!(out.exists(), "4K output file should exist"); + // The render target / encode exceeds the old 2048 cap on at least one axis. + assert!( + summary.width > 2048 || summary.height > 2048, + "4K export should clear the old 2048 texture cap (got {}x{})", + summary.width, + summary.height + ); +} + #[test] fn export_with_audio_clip_mux_aac_stream() { if !ffmpeg_ready() { diff --git a/web/src/components/media/LibraryView.tsx b/web/src/components/media/LibraryView.tsx index dfce84f..c350c2e 100644 --- a/web/src/components/media/LibraryView.tsx +++ b/web/src/components/media/LibraryView.tsx @@ -12,7 +12,7 @@ * 造新 asset,再 refreshMedia)。所有命令在非 Tauri 下安全降级。 */ -import { useEffect, useMemo, useState } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { Home, Search, @@ -422,11 +422,34 @@ function EntryCard({ entry }: { entry: LibraryEntry }) { const categorize = useLibraryStore((s) => s.categorize); const [hovered, setHovered] = useState(false); const [busy, setBusy] = useState(false); + // Lazy-mount the thumbnail: a video entry without a cached poster falls back + // to the full source file, so mounting every card's <video> at once loads + // dozens of clips (slow + heavy). Only load once the card scrolls into view. + const cardRef = useRef<HTMLDivElement>(null); + const [visible, setVisible] = useState(false); const name = sourceName(entry.source) || entry.id; // 缩略图:库条目 thumb 优先,否则按 source 让 WebView 解码原文件(asset 协议)。 const thumb = assetUrl(entry.thumb ?? entry.source); + useEffect(() => { + const el = cardRef.current; + if (!el || typeof IntersectionObserver === "undefined") { + setVisible(true); + return; + } + const observer = new IntersectionObserver( + ([e]) => { + if (!e?.isIntersecting) return; + setVisible(true); + observer.disconnect(); + }, + { root: null, rootMargin: "200px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, []); + const handleImport = async () => { setBusy(true); try { @@ -445,6 +468,7 @@ function EntryCard({ entry }: { entry: LibraryEntry }) { return ( <div + ref={cardRef} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} title={name} @@ -464,13 +488,14 @@ function EntryCard({ entry }: { entry: LibraryEntry }) { overflow: "hidden", }} > - {thumb && entry.type === "image" ? ( + {visible && thumb && entry.type === "image" ? ( <img src={thumb} alt={name} + loading="lazy" style={{ width: "100%", height: "100%", objectFit: "cover" }} /> - ) : thumb && entry.type === "video" ? ( + ) : visible && thumb && entry.type === "video" ? ( <video src={`${thumb}#t=0.1`} muted diff --git a/web/src/components/media/MediaPanel.tsx b/web/src/components/media/MediaPanel.tsx index 2144e02..0ead741 100644 --- a/web/src/components/media/MediaPanel.tsx +++ b/web/src/components/media/MediaPanel.tsx @@ -38,10 +38,11 @@ import { useT } from "../../i18n"; import { formatTimecode } from "../../lib/geometry"; import { setDraggingMedia } from "../../lib/mediaDragState"; import { assetUrl } from "../../lib/asset"; +import { BoundedCache } from "../../lib/lru"; import { childFolders, folderTrail, normalizeFolderId } from "../../lib/folderTree"; import { useProjectStore } from "../../store/projectStore"; import { addMediaToTimeline } from "../../store/editActions"; -import { extractAudio, generateThumbnail } from "../../lib/api"; +import { extractAudio, generateThumbnail, preloadMedia } from "../../lib/api"; import { saveDialog } from "../../lib/dialog"; import type { MediaFolder, MediaItem } from "../../lib/types"; import { MediaTabBar, MediaSubTabBar } from "./MediaTabBar"; @@ -50,12 +51,19 @@ import { useFavoritesStore, useIsFavorite } from "./favorites"; /** MIME-ish type used on dataTransfer when dragging a media item to the timeline. */ export const MEDIA_DND_TYPE = "application/x-opentake-media"; const MEDIA_THUMBNAIL_CONCURRENCY = 4; +/** Bound for the in-memory thumbnail-path cache. A long library scrolled top to + * bottom would otherwise grow this Map without limit; cap it (LRU) so memory + * stays bounded — evicted keys just re-request a (disk-cached) path later. */ +const MEDIA_THUMBNAIL_CACHE_MAX = 256; let activeThumbnailRequests = 0; const pendingThumbnailRequests: Array<() => void> = []; -const mediaThumbnailCache = new Map<string, string | null>(); const mediaThumbnailInFlight = new Map<string, Promise<string | null>>(); +/** Bounded LRU over the resolved thumbnail paths, so a long library scrolled top + * to bottom can't grow memory without limit (see {@link BoundedCache}). */ +const mediaThumbnailCache = new BoundedCache<string | null>(MEDIA_THUMBNAIL_CACHE_MAX); + function runNextThumbnailRequest(): void { if (activeThumbnailRequests >= MEDIA_THUMBNAIL_CONCURRENCY) return; const next = pendingThumbnailRequests.shift(); @@ -670,12 +678,34 @@ function MediaCard({ item }: { item: MediaItem }) { }; }, [item, thumbnailKey]); + // Page-aware preview pre-warm: when a VIDEO card scrolls into view, warm its + // hi-res first-frame poster so a click previews near-instantly. Gated by the + // same IntersectionObserver as the thumbnail, so cards scrolled far out of + // view are never warmed (and we don't warm images/audio — nothing to decode). + useEffect(() => { + if (item.missing || item.type !== "video") return; + const el = cardRef.current; + if (!el || typeof IntersectionObserver === "undefined") return; + const observer = new IntersectionObserver( + ([entry]) => { + if (!entry?.isIntersecting) return; + observer.disconnect(); + void preloadMedia(item.id); + }, + { root: null, rootMargin: "160px" }, + ); + observer.observe(el); + return () => observer.disconnect(); + }, [item.id, item.type, item.missing]); + const onDragStart = (e: React.DragEvent) => { e.dataTransfer.setData(MEDIA_DND_TYPE, item.id); e.dataTransfer.effectAllowed = "copy"; // Stash the item so the timeline can size its drop ghost during dragover // (dataTransfer payloads are unreadable until drop). Cleared on dragEnd. setDraggingMedia(item); + // Warm caches for a dragged-but-not-clicked asset too (best-effort). + void preloadMedia(item.id); }; const onDragEnd = () => { @@ -719,7 +749,12 @@ function MediaCard({ item }: { item: MediaItem }) { draggable onDragStart={onDragStart} onDragEnd={onDragEnd} - onClick={() => setPreviewMedia(item.id)} + onClick={() => { + setPreviewMedia(item.id); + // Warm poster/sprite/waveform caches so preview + a later timeline drop + // are instant instead of decoding on the interaction path. + void preloadMedia(item.id); + }} onDoubleClick={() => void addMediaToTimeline(item)} onMouseEnter={() => setHovered(true)} onMouseLeave={() => setHovered(false)} diff --git a/web/src/components/preview/Preview.tsx b/web/src/components/preview/Preview.tsx index b53d84c..126202f 100644 --- a/web/src/components/preview/Preview.tsx +++ b/web/src/components/preview/Preview.tsx @@ -20,6 +20,8 @@ import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; import { formatTimecode, totalFrames } from "../../lib/geometry"; +import { snapFrameToEdge } from "../../lib/snap"; +import { maybeSnapFeedback } from "../../lib/haptic"; import { assetUrl } from "../../lib/asset"; import { TimelinePlayback } from "./TimelinePlaybackLayer"; import { aspectFitBox, timelinePreviewCanvasStyle } from "./previewLayerStyles"; @@ -28,6 +30,7 @@ import { compositeFrame, getPreviewEndpoint, isTauri, + previewPoster, type CompositeFrame, } from "../../lib/api"; import { rustEngineEnabled } from "./rustEngine"; @@ -99,9 +102,14 @@ export function Preview() { const seekTo = (frame: number) => { const clamped = Math.max(0, Math.min(total, frame)); if (previewing) { + // A single media item has no clip edges to snap to. if (mediaRef.current) mediaRef.current.currentTime = clamped / fps; } else { - setCurrentFrame(clamped); + // Magnetize the scrub bar to clip start/end edges (~0.25s threshold) and + // tick on engage, like dragging the playhead in the timeline. + const snapped = snapFrameToEdge(timeline, clamped, Math.max(2, Math.round(fps * 0.25))); + setCurrentFrame(snapped.frame); + maybeSnapFeedback(snapped.snappedTo); } }; @@ -335,6 +343,28 @@ function MediaPreview({ }) { const t = useT(); const url = assetUrl(item.path); + // Hi-res first-frame poster, painted INSTANTLY behind the <video> so a cold + // click shows a sharp frame with no blank/spinner. Decoded (and cached) by the + // backend on select; the asset protocol then streams the real video + // progressively (it honors HTTP Range, so `preload="metadata"` below does not + // download the whole file). Only fetched for video; cleared between items so a + // stale poster never flashes on the next clip. + const [posterUrl, setPosterUrl] = useState<string | null>(null); + useEffect(() => { + if (item.type !== "video" || item.missing) { + setPosterUrl(null); + return; + } + let cancelled = false; + setPosterUrl(null); + void previewPoster(item.id).then((path) => { + if (!cancelled && path) setPosterUrl(assetUrl(path)); + }); + return () => { + cancelled = true; + }; + }, [item.id, item.type, item.missing]); + const box: React.CSSProperties = { maxWidth: "100%", maxHeight: "100%", @@ -370,12 +400,18 @@ function MediaPreview({ ); } // video (and any other visual): app transport drives it (no native controls). + // `preload="metadata"` (not the default "auto") + an instant hi-res `poster` + // make a cold click near-instant: the first frame shows immediately and the + // asset protocol streams the rest progressively via HTTP Range, instead of + // eagerly buffering the whole file behind a blank frame. return ( <video ref={(el) => { mediaRef.current = el; }} src={url} + poster={posterUrl ?? undefined} + preload="metadata" playsInline onTimeUpdate={(e) => onTime(e.currentTarget.currentTime)} onLoadedMetadata={(e) => onDuration(e.currentTarget.duration || 0)} diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index d71bfb1..1e59b90 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -80,7 +80,16 @@ export function TimelinePlayback({ timeline, fps }: { timeline: Timeline; fps: n const cropMaskStyle = timelinePreviewCropMaskStyle(visual.clip, frame); const mediaStyle = timelinePreviewCroppedMediaStyle(visual.clip, frame); return ( - <div key={key} style={timelinePreviewClipStyle(visual.clip, frame)}> + <div + key={key} + // Explicit z-order so the preview composites in the SAME order as the + // final render (opentake-render keeps visual track 0 topmost): lower + // track index = higher layer. Without this the order relied on DOM + // paint order, which React reconciliation could shuffle as clips + // enter/leave during scrub — making the preview disagree with the + // exported frame. Track indices are small, so 1000 is a safe base. + style={{ ...timelinePreviewClipStyle(visual.clip, frame), zIndex: 1000 - visual.trackIndex }} + > <div style={cropMaskStyle}> {visual.clip.mediaType === "video" ? ( <video diff --git a/web/src/components/preview/previewEngine.ts b/web/src/components/preview/previewEngine.ts index 674ae7b..1b923bd 100644 --- a/web/src/components/preview/previewEngine.ts +++ b/web/src/components/preview/previewEngine.ts @@ -220,6 +220,11 @@ export function useTimelinePlaybackEngine(): void { const isPlaying = useEditorUiStore((s) => s.isPlaying); const isScrubbing = useEditorUiStore((s) => s.isScrubbing); const activeFrame = useEditorUiStore((s) => s.activeFrame); + // Re-run the paused sync when the timeline itself changes (a clip added / + // removed / swapped while paused). The pause-sync effect's other deps don't + // change on an edit, so without this a just-dropped clip would hold its source + // frame 0 instead of the playhead frame. + const timelineVersion = useProjectStore((s) => s.timelineVersion); const previousTransportState = useRef({ isPlaying: false, isScrubbing: false }); // Last frame the Rust engine emitted (playback_frame), so the watcher below can // tell an external seek (keyboard / transport) apart from the engine's own @@ -243,6 +248,11 @@ export function useTimelinePlaybackEngine(): void { const pausedFrame = pausedPlayheadFrameFromFrozenVideo(visual, el?.currentTime ?? NaN, fps); if (pausedFrame !== null) useEditorUiStore.getState().setActiveFrame(pausedFrame); } else if ( + // A scrub just ended: settle every active element on the final frame. + // Without this, a clip the scrub entered near the end can be left on its + // source frame 0 ("track head") because its <video> mounted mid-scrub + // and the throttled scrub seek never reached it. + prev.isScrubbing || shouldSyncPausedMediaToFrame({ isPlaying, isScrubbing, @@ -254,7 +264,7 @@ export function useTimelinePlaybackEngine(): void { } } previousTransportState.current = { isPlaying, isScrubbing }; - }, [activeFrame, isPlaying, isScrubbing]); + }, [activeFrame, isPlaying, isScrubbing, timelineVersion]); useEffect(() => { // Rust streaming playback owns the PLAY state when the flag is on (under diff --git a/web/src/components/shell/TitleBar.tsx b/web/src/components/shell/TitleBar.tsx index fe8c335..5a7a769 100644 --- a/web/src/components/shell/TitleBar.tsx +++ b/web/src/components/shell/TitleBar.tsx @@ -1,6 +1,12 @@ /** * Title bar (SPEC §2.8). Leading: Home (return to launcher). Trailing: - * Settings + Export. (UpdateBadge/Avatar belong to a separate issue.) + * Library + Settings + Export Video + Export Subtitles + Export (interchange). + * (UpdateBadge/Avatar belong to a separate issue.) + * + * The "Export" button opens a small menu of standard timeline-interchange + * formats — XMEML / FCP7 XML (Premiere・DaVinci・剪映), FCPXML (Final Cut Pro X), + * OTIO (DaVinci・industry standard), and EDL (CMX3600) — each opening the native + * save dialog with the right extension and calling its backend command. * * The Agent panel is toggled from the §2.9 View menu (ViewMenu) and the * keyboard shortcut — the dedicated title-bar toggle button was removed by @@ -19,37 +25,33 @@ import * as api from "../../lib/api"; import type { SubtitleFormat } from "../../lib/api"; import { saveDialog } from "../../lib/dialog"; -const XML_EXT = "xml"; - -/** Ensure a chosen path carries the `.xml` extension. */ -function withXmlExt(path: string): string { - return path.endsWith(`.${XML_EXT}`) ? path : `${path}.${XML_EXT}`; -} - -/** - * Default export filename: the open project's base name with `.xml`, falling - * back to "Timeline.xml" for an unsaved project. The bundle path ends in - * `…/Name.opentake`, so strip the directory and the `.opentake` suffix. - */ -function defaultXmlName(projectPath: string | null): string { - if (!projectPath) return `Timeline.${XML_EXT}`; - const base = projectPath.split(/[\\/]/).pop() ?? projectPath; - const stem = base.replace(/\.opentake$/i, ""); - return `${stem || "Timeline"}.${XML_EXT}`; -} - -/** The open project's base name (without the `.opentake` suffix), or "Timeline". */ +/** The open project's base name (without the `.opentake` suffix), or "Timeline". + * The bundle path ends in `…/Name.opentake`, so strip dir + `.opentake`. */ function projectStem(projectPath: string | null): string { if (!projectPath) return "Timeline"; const base = projectPath.split(/[\\/]/).pop() ?? projectPath; return base.replace(/\.opentake$/i, "") || "Timeline"; } -/** Ensure a chosen path carries the given subtitle extension (`srt` / `vtt`). */ -function withSubtitleExt(path: string, ext: string): string { +/** Ensure a chosen path carries the given extension (case-insensitive check). */ +function withExt(path: string, ext: string): string { return path.toLowerCase().endsWith(`.${ext}`) ? path : `${path}.${ext}`; } +/** + * The four standard timeline-interchange formats. `key` drives the i18n labels + * (`title.export<Cap>` / `…Dialog` / `…Filter`), `ext` is the file extension, + * and `run` invokes the matching backend command with the chosen path. + */ +const INTERCHANGE_FORMATS = [ + { key: "Xmeml", ext: "xml", run: api.exportXmeml }, + { key: "Fcpxml", ext: "fcpxml", run: api.exportFcpxmlModern }, + { key: "Otio", ext: "otio", run: api.exportOtio }, + { key: "Edl", ext: "edl", run: api.exportEdl }, +] as const; + +type InterchangeFormat = (typeof INTERCHANGE_FORMATS)[number]; + export function TitleBar() { const setView = useEditorUiStore((s) => s.setView); const setSettingsOpen = useEditorUiStore((s) => s.setSettingsOpen); @@ -62,52 +64,48 @@ export function TitleBar() { // Subtitle-format popover (SRT / VTT). Dismiss on outside click / Escape. const [subMenuOpen, setSubMenuOpen] = useState(false); const subMenuRef = useRef<HTMLDivElement>(null); - useEffect(() => { - if (!subMenuOpen) return; - const onDown = (e: MouseEvent) => { - if (subMenuRef.current && !subMenuRef.current.contains(e.target as Node)) { - setSubMenuOpen(false); - } - }; - const onKey = (e: KeyboardEvent) => { - if (e.key === "Escape") setSubMenuOpen(false); - }; - window.addEventListener("mousedown", onDown); - window.addEventListener("keydown", onKey); - return () => { - window.removeEventListener("mousedown", onDown); - window.removeEventListener("keydown", onKey); - }; - }, [subMenuOpen]); + useDismissable(subMenuOpen, subMenuRef, () => setSubMenuOpen(false)); + + // Interchange-format popover (XMEML / FCPXML / OTIO / EDL). + const [exportMenuOpen, setExportMenuOpen] = useState(false); + const exportMenuRef = useRef<HTMLDivElement>(null); + useDismissable(exportMenuOpen, exportMenuRef, () => setExportMenuOpen(false)); // Video export needs something to render: disable the entry when no track // holds a clip (an empty timeline would only encode black frames). const hasClips = tracks.some((track) => track.clips.length > 0); /** - * Export the timeline as Final Cut Pro 7 XML (`.xml`). Mirrors the new-project - * save flow (`projectActions.newProjectAndEnter`): open the native save panel, - * default the name to the project, then write via `export_fcpxml`. No-op - * outside Tauri (no save panel / file system). + * Export the timeline to a chosen interchange `format`. Mirrors the new-project + * save flow (`projectActions.newProjectAndEnter`): open the native save panel + * defaulted to the project name + the format's extension, then write via the + * format's backend command. No-op outside Tauri (no save panel / file system). */ - async function onExport(): Promise<void> { + async function onExportInterchange(format: InterchangeFormat): Promise<void> { + setExportMenuOpen(false); const save = await saveDialog(); - if (!save) return; + if (!save) return; // outside Tauri — no save panel / file system const dir = projectPath ? projectPath.replace(/[\\/][^\\/]*$/, "") : await api.getDefaultProjectDir().catch(() => ""); const sep = dir && !dir.endsWith("/") ? "/" : ""; const defaultPath = dir - ? `${dir}${sep}${defaultXmlName(projectPath)}` + ? `${dir}${sep}${projectStem(projectPath)}.${format.ext}` : undefined; const chosen = await save({ - title: t("title.exportXmlDialog"), + title: t(`title.export${format.key}Dialog`), defaultPath, - filters: [{ name: t("title.exportXmlFilter"), extensions: [XML_EXT] }], + filters: [{ name: t(`title.export${format.key}Filter`), extensions: [format.ext] }], }); if (typeof chosen !== "string") return; // cancelled - await api.exportFcpxml(withXmlExt(chosen)); + + try { + await format.run(withExt(chosen, format.ext)); + pushToast(t("title.exportInterchangeDone")); + } catch { + pushToast(t("title.exportInterchangeFailed")); + } } /** @@ -141,7 +139,7 @@ export function TitleBar() { if (typeof chosen !== "string") return; // cancelled try { - const summary = await api.exportSubtitles(withSubtitleExt(chosen, format), format); + const summary = await api.exportSubtitles(withExt(chosen, format), format); pushToast( summary.cueCount > 0 ? t("title.exportSubtitlesDone", { count: summary.cueCount }) @@ -314,25 +312,139 @@ export function TitleBar() { )} </div> - <button - title={t("title.exportHint")} - aria-label={t("title.export")} - onClick={onExport} - className="hover-area" - style={{ - display: "inline-flex", - alignItems: "center", - gap: 4, - height: 26, - padding: "0 var(--space-sm)", - color: "var(--text-secondary)", - fontSize: "var(--fs-sm)", - fontWeight: "var(--fw-medium)", - }} - > - <Icon icon={Upload} size={13} /> - {t("title.export")} - </button> + {/* Interchange export (XMEML / FCPXML / OTIO / EDL) with a format popover. */} + <div ref={exportMenuRef} style={{ position: "relative", display: "inline-flex" }}> + <button + title={t("title.exportHint")} + aria-label={t("title.export")} + aria-haspopup="menu" + aria-expanded={exportMenuOpen} + onClick={() => setExportMenuOpen((v) => !v)} + className="hover-area" + style={{ + display: "inline-flex", + alignItems: "center", + gap: 4, + height: 26, + padding: "0 var(--space-sm)", + color: "var(--text-secondary)", + fontSize: "var(--fs-sm)", + fontWeight: "var(--fw-medium)", + }} + > + <Icon icon={Upload} size={13} /> + {t("title.export")} + </button> + {exportMenuOpen && ( + <div + role="menu" + aria-label={t("title.export")} + style={{ + position: "absolute", + top: "calc(100% + 4px)", + right: 0, + zIndex: 1200, + minWidth: 280, + display: "flex", + flexDirection: "column", + padding: "var(--space-xs)", + background: "var(--bg-elevated)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-sm)", + boxShadow: "0 8px 24px rgba(0,0,0,0.4)", + }} + > + {/* Render the timeline to a video file (MP4). Reuses the existing + export dialog; disabled (greyed) when the timeline is empty. */} + <button + role="menuitem" + disabled={!hasClips} + onClick={() => { + setExportMenuOpen(false); + setExportDialogOpen(true); + }} + className={hasClips ? "hover-area" : undefined} + style={{ + display: "flex", + alignItems: "center", + gap: 6, + height: 28, + padding: "0 var(--space-sm)", + background: "transparent", + border: "none", + borderRadius: "var(--radius-xs-sm)", + color: hasClips ? "var(--text-primary)" : "var(--text-muted)", + fontSize: "var(--fs-sm)", + fontWeight: "var(--fw-medium)", + textAlign: "left", + cursor: hasClips ? "pointer" : "default", + whiteSpace: "nowrap", + opacity: hasClips ? 1 : 0.5, + }} + > + <Icon icon={Film} size={13} /> + {t("title.exportRenderVideo")} + </button> + <div + style={{ + height: "var(--bw-thin)", + margin: "var(--space-xs) var(--space-sm)", + background: "var(--border-subtle)", + }} + /> + {INTERCHANGE_FORMATS.map((fmt) => ( + <button + key={fmt.key} + role="menuitem" + onClick={() => void onExportInterchange(fmt)} + className="hover-area" + style={{ + display: "flex", + alignItems: "center", + height: 28, + padding: "0 var(--space-sm)", + background: "transparent", + border: "none", + borderRadius: "var(--radius-xs-sm)", + color: "var(--text-primary)", + fontSize: "var(--fs-sm)", + textAlign: "left", + cursor: "pointer", + whiteSpace: "nowrap", + }} + > + {t(`title.export${fmt.key}`)} + </button> + ))} + </div> + )} + </div> </div> ); } + +/** + * Dismiss an open popover on outside `mousedown` or `Escape`. Shared by the + * title bar's two menus (subtitle + interchange) so both behave identically. + */ +function useDismissable( + open: boolean, + ref: React.RefObject<HTMLDivElement | null>, + close: () => void, +): void { + useEffect(() => { + if (!open) return; + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) close(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") close(); + }; + window.addEventListener("mousedown", onDown); + window.addEventListener("keydown", onKey); + return () => { + window.removeEventListener("mousedown", onDown); + window.removeEventListener("keydown", onKey); + }; + }, [open, ref, close]); +} diff --git a/web/src/components/shell/TitleBar.visual.test.ts b/web/src/components/shell/TitleBar.visual.test.ts index 07dad42..1ec7a7b 100644 --- a/web/src/components/shell/TitleBar.visual.test.ts +++ b/web/src/components/shell/TitleBar.visual.test.ts @@ -23,3 +23,23 @@ describe("TitleBar alignment", () => { expect(titleBarSource).toContain("var(--titlebar-safe-left)"); }); }); + +describe("TitleBar interchange export menu", () => { + it("offers all four interchange formats with their extensions and commands", () => { + // Each format must map to the right extension + backend command. + for (const [ext, run] of [ + ["xml", "exportXmeml"], + ["fcpxml", "exportFcpxmlModern"], + ["otio", "exportOtio"], + ["edl", "exportEdl"], + ] as const) { + expect(titleBarSource).toContain(`ext: "${ext}"`); + expect(titleBarSource).toContain(`api.${run}`); + } + }); + + it("renders the export trigger as a popup menu (not a single-format button)", () => { + expect(titleBarSource).toContain('aria-haspopup="menu"'); + expect(titleBarSource).toContain("INTERCHANGE_FORMATS.map"); + }); +}); diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 2899636..22156c9 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -38,6 +38,7 @@ import { ClipContextMenu } from "./ClipContextMenu"; import { SwapMediaPicker } from "./SwapMediaPicker"; import { MEDIA_DND_TYPE } from "../media/MediaPanel"; import { getDraggingMedia, setDraggingMedia } from "../../lib/mediaDragState"; +import { maybeSnapFeedback } from "../../lib/haptic"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; @@ -351,6 +352,9 @@ export function TimelineContainer() { // events so the sticky band (1.5x threshold) holds the clip on its target // instead of jittering at the edge (SPEC §5.7). Cleared on pointerUp. const snapStateRef = useRef<{ frame: number; probeOffset: number } | null>(null); + // Sticky snap state for the playhead scrub (independent of the clip-move snap + // above), so dragging the playhead magnetizes to clip start/end edges. + const scrubSnapRef = useRef<{ frame: number; probeOffset: number } | null>(null); const [snapFrame, setSnapFrame] = useState<number | null>(null); const [dragTick, forceTick] = useState(0); const [menu, setMenu] = useState<TimelineContextMenu | null>(null); @@ -452,6 +456,35 @@ export function TimelineContainer() { proposedTrackDelta, 0, ); + // Single clip crossing onto exactly one existing clip → preview the swap: + // the displaced clip will ghost at the slot the lead is vacating. Mirrors + // the drop-side decision in `endDrag` so what you see is what you get. + let swap: { clipId: string; toTrackIndex: number; toFrame: number } | undefined; + if ( + !d.isDuplicate && + d.dropTarget.kind === "existing" && + participants.length === 1 && + lead && + resolved.trackDelta !== 0 + ) { + const leadDur = timeline.tracks + .flatMap((tk) => tk.clips) + .find((c) => c.id === d.hit.clip.id)?.durationFrames; + const destTrack = timeline.tracks[lead.trackIndex + resolved.trackDelta]; + if (leadDur && destTrack) { + const leadToFrame = lead.startFrame + d.deltaFrames; + const leadEnd = leadToFrame + leadDur; + const overlap = destTrack.clips.filter( + (c) => + c.id !== d.hit.clip.id && + c.startFrame < leadEnd && + c.startFrame + c.durationFrames > leadToFrame, + ); + if (overlap.length === 1) { + swap = { clipId: overlap[0].id, toTrackIndex: lead.trackIndex, toFrame: lead.startFrame }; + } + } + } drag = { kind: "move", ids: new Set(d.companions), @@ -462,6 +495,7 @@ export function TimelineContainer() { isDuplicate: d.isDuplicate, newTrackType: d.dropTarget.kind === "newTrack" ? d.dropTarget.trackType : undefined, newTrackIndex: d.dropTarget.kind === "newTrack" ? d.dropTarget.index : undefined, + swap, }; } else if (d?.kind === "trimLeft" || d?.kind === "trimRight") { drag = { @@ -754,9 +788,14 @@ export function TimelineContainer() { // Ruler -> scrub playhead. if (inRuler) { dragRef.current = { kind: "scrub" }; + scrubSnapRef.current = null; setScrubbing(true); - const f = frameAt(docX, zoomScale); - setCurrentFrame(f); + // Snap the playhead to the nearest clip start/end (clip edges only — not + // the playhead itself), so scrubbing magnetizes to cut points. + const raw = frameAt(docX, zoomScale); + const snap = findSnap(raw, collectTargets(timeline, EMPTY_EXCLUDE, null, false), zoomScale, null); + setCurrentFrame(Math.max(0, Math.round(snap ? snap.frame : raw))); + maybeSnapFeedback(snap ? snap.frame : null); return; } @@ -897,7 +936,16 @@ export function TimelineContainer() { const { docX, docY } = toDoc(e); if (d.kind === "scrub") { - setCurrentFrame(frameAt(docX, zoomScale)); + // Sticky-snap the dragged playhead to the nearest clip edge so it + // magnetizes to cut points without jittering; fire a tick on engage. + const raw = frameAt(docX, zoomScale); + const targets = collectTargets(timeline, EMPTY_EXCLUDE, null, false); + const snap = findSnapDelta([raw], targets, zoomScale, scrubSnapRef.current, [0]); + scrubSnapRef.current = snap + ? { frame: snap.snappedFrame, probeOffset: snap.probeOffset } + : null; + setCurrentFrame(Math.max(0, Math.round(snap ? raw + snap.delta : raw))); + maybeSnapFeedback(snap ? snap.snappedFrame : null); setScrubbing(false); return; } @@ -969,6 +1017,7 @@ export function TimelineContainer() { } dragRef.current = { ...d, deltaFrames, targetTrack, dropTarget }; setSnapFrame(snapped); + maybeSnapFeedback(snapped); forceTick((n) => n + 1); return; } @@ -1050,6 +1099,7 @@ export function TimelineContainer() { const endDrag = useCallback((e: React.PointerEvent) => { dragRef.current = null; setSnapFrame(null); + maybeSnapFeedback(null); // re-arm snap feedback for the next gesture setScrubbing(false); const el = e.currentTarget as HTMLElement; if (el.hasPointerCapture?.(e.pointerId)) el.releasePointerCapture(e.pointerId); @@ -1155,6 +1205,35 @@ export function TimelineContainer() { resolved.targets.map((target) => target.toTrack), ); } else { + // Single clip dragged across tracks onto exactly one existing clip: + // swap their places (exchange track + start) instead of overwriting — + // so the displaced clip relocates rather than getting swallowed. + const leadTarget = resolved.targets.find((t) => t.clipId === d.hit.clip.id); + const leadDur = timeline.tracks + .flatMap((t) => t.clips) + .find((c) => c.id === d.hit.clip.id)?.durationFrames; + if ( + participants.length === 1 && + leadTarget && + leadDur && + leadTarget.toTrack !== lead.trackIndex + ) { + const destTrack = timeline.tracks[leadTarget.toTrack]; + const movedIds = new Set(resolved.targets.map((t) => t.clipId)); + const leadEnd = leadTarget.toFrame + leadDur; + const overlap = destTrack + ? destTrack.clips.filter( + (c) => + !movedIds.has(c.id) && + c.startFrame < leadEnd && + c.startFrame + c.durationFrames > leadTarget.toFrame, + ) + : []; + if (overlap.length === 1) { + void edit.swapClips(d.hit.clip.id, overlap[0].id); + return; + } + } const moves = resolved.targets.map((target) => ({ clipId: target.clipId, toTrack: target.toTrack, @@ -1308,7 +1387,9 @@ export function TimelineContainer() { }; const prev = mediaGhostRef.current; mediaGhostRef.current = next; - setSnapFrame(snap ? snap.snappedFrame : null); + const snappedFrame = snap ? snap.snappedFrame : null; + setSnapFrame(snappedFrame); + maybeSnapFeedback(snappedFrame); const changed = !prev || prev.startFrame !== next.startFrame || @@ -1342,6 +1423,12 @@ export function TimelineContainer() { const plan = mediaGhostRef.current; clearMediaGhost(); setDraggingMedia(null); + // Dropping onto the timeline is an HTML5 `drop` (no pointerdown), so the + // media-preview→timeline switch in TimelineRegion's onPointerDownCapture + // never fires. Clear the selected media here so the preview shows the + // timeline composite at the playhead instead of staying on the dropped + // asset's standalone preview. + useEditorUiStore.getState().setPreviewMedia(null); if (!item) return; if (plan) { const preferredTrackIndex = plan.newTrackIndex !== null ? null : plan.trackIndex; diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index 9b33d2a..54ecd59 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -74,6 +74,10 @@ export type DragPaint = newTrackType?: ClipType; /** Upstream `newTrackAt(index)` insertion index for the new-track drop. */ newTrackIndex?: number; + /** Cross-track swap preview: the clip being displaced ghosts at the slot + * the lead clip is vacating, so the two visibly trade places before the + * drop. Absent unless the drop would be a single-clip swap. */ + swap?: { clipId: string; toTrackIndex: number; toFrame: number }; } | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number } | { kind: "volumeKf"; clipId: string; fromFrame: number; ghostFrame: number } @@ -117,19 +121,24 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { if (index >= timeline.tracks.length) return trackY(timeline, timeline.tracks.length, trackHeights); return trackY(timeline, index, trackHeights); }; - // New-track drop indicator: dashed zone at the upstream insertion index. + // Hint that a new track will be created at `laneY`: a solid YELLOW insertion + // line across the lane's top edge (1:1 with upstream's `NSColor.systemYellow` + // line) — NOT a full-width fill, which reads as "the whole row lit up". The + // clip-sized ghost drawn at this lane is the "it lands here" indicator. + const drawNewTrackHint = (laneY: number, laneH: number): void => { + if (laneY + laneH <= scrollTop || laneY >= scrollTop + s.viewHeight) return; + ctx.strokeStyle = GHOST.insertLine; + ctx.lineWidth = 2; + ctx.beginPath(); + ctx.moveTo(scrollLeft, laneY + 1); + ctx.lineTo(scrollLeft + s.viewWidth, laneY + 1); + ctx.stroke(); + }; + // New-track drop indicator: insertion line at the upstream insertion index. if (drag?.kind === "move" && drag.newTrackType && timeline.tracks.length > 0) { const newTrackY = insertionLineY(drag.newTrackIndex ?? timeline.tracks.length); const newTrackH = trackDisplayHeight(timeline.tracks[0], trackHeights) || TRACK_SIZE.defaultHeight; - if (newTrackY + newTrackH > scrollTop && newTrackY < scrollTop + s.viewHeight) { - ctx.fillStyle = "rgba(255,255,255,0.04)"; - ctx.fillRect(scrollLeft, newTrackY, s.viewWidth, newTrackH); - ctx.strokeStyle = "rgba(255,255,255,0.3)"; - ctx.lineWidth = 1; - ctx.setLineDash([6, 4]); - ctx.strokeRect(scrollLeft + 0.5, newTrackY + 0.5, s.viewWidth - 1, newTrackH - 1); - ctx.setLineDash([]); - } + drawNewTrackHint(newTrackY, newTrackH); } for (let ti = 0; ti < timeline.tracks.length; ti++) { const track = timeline.tracks[ti]; @@ -141,11 +150,16 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { const isPinned = drag.pinnedIds?.has(clip.id) === true; const onLeadRow = ti === drag.leadTrackIndex; if (drag.newTrackType && !isPinned && onLeadRow) { - const newTrackY = insertionLineY(drag.newTrackIndex ?? timeline.tracks.length); + const newTrackIndex = drag.newTrackIndex ?? timeline.tracks.length; + const newTrackY = insertionLineY(newTrackIndex); const ghostH = (trackDisplayHeight(timeline.tracks[0], trackHeights) || TRACK_SIZE.defaultHeight) - 4; + // Upstream `TimelineGeometry.ghostY`: the new-track ghost sits ABOVE + // the insertion line (lineY - height) for every insert except the very + // bottom (index >= trackCount), where it sits at the line. + const ghostTop = newTrackIndex < timeline.tracks.length ? newTrackY - ghostH - 2 : newTrackY + 2; rect = { x: (clip.startFrame + drag.deltaFrames) * pixelsPerFrame, - y: newTrackY + 2, + y: ghostTop, width: clip.durationFrames * pixelsPerFrame, height: ghostH, }; @@ -163,6 +177,17 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { } ghost = true; isDuplicate = drag.isDuplicate === true; + } else if (drag?.kind === "move" && drag.swap?.clipId === clip.id) { + // The clip being displaced in a cross-track swap: ghost it at the slot + // the lead clip is vacating, so the two visibly trade places. + rect = clipRect( + timeline, + drag.swap.toTrackIndex, + { ...clip, startFrame: drag.swap.toFrame }, + pixelsPerFrame, + trackHeights, + ); + ghost = true; } else if (drag?.kind === "trim" && drag.clipId === clip.id) { const dx = drag.deltaFrames * pixelsPerFrame; rect = @@ -221,17 +246,12 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { timeline.tracks.length > 0 ? trackDisplayHeight(timeline.tracks[0], trackHeights) : TRACK_SIZE.defaultHeight; - if (laneY + laneH > scrollTop && laneY < scrollTop + s.viewHeight) { - ctx.fillStyle = GHOST.newTrackFill; - ctx.fillRect(scrollLeft, laneY, s.viewWidth, laneH); - ctx.strokeStyle = GHOST.border; - ctx.lineWidth = 1; - ctx.setLineDash([6, 4]); - ctx.strokeRect(scrollLeft + 0.5, laneY + 0.5, s.viewWidth - 1, laneH - 1); - ctx.setLineDash([]); - } - ghostY = laneY + 2; + drawNewTrackHint(laneY, laneH); ghostH = laneH - 4; + // Upstream `ghostY`: above the insertion line for every insert except the + // very bottom (index >= trackCount), so a clip dropped to a new track + // previews in the lane that opens ABOVE the line. + ghostY = mg.newTrackIndex < timeline.tracks.length ? laneY - ghostH - 2 : laneY + 2; } else if (mg.trackIndex !== null && mg.trackIndex < timeline.tracks.length) { ghostY = trackY(timeline, mg.trackIndex, trackHeights) + 2; ghostH = trackDisplayHeight(timeline.tracks[mg.trackIndex], trackHeights) - 4; diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 2fa0114..c010af9 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -23,9 +23,24 @@ const zh: Dict = { // TitleBar "title.toggleAgent": "切换 Agent 面板", "title.export": "导出", - "title.exportHint": "导出 (⌘E)", + "title.exportHint": "导出工程交换格式", + "title.exportRenderVideo": "渲染为视频(MP4)", "title.exportXmlDialog": "导出时间线 XML", "title.exportXmlFilter": "Final Cut Pro 7 XML", + "title.exportXmeml": "XMEML / FCP7 XML(Premiere・达芬奇・剪映)", + "title.exportXmemlDialog": "导出 XMEML (.xml)", + "title.exportXmemlFilter": "XMEML / Final Cut Pro 7 XML", + "title.exportFcpxml": "FCPXML(Final Cut Pro)", + "title.exportFcpxmlDialog": "导出 FCPXML (.fcpxml)", + "title.exportFcpxmlFilter": "Final Cut Pro X FCPXML", + "title.exportOtio": "OTIO(达芬奇・工业标准)", + "title.exportOtioDialog": "导出 OpenTimelineIO (.otio)", + "title.exportOtioFilter": "OpenTimelineIO", + "title.exportEdl": "EDL(CMX3600)", + "title.exportEdlDialog": "导出 EDL (.edl)", + "title.exportEdlFilter": "CMX3600 EDL", + "title.exportInterchangeDone": "已导出", + "title.exportInterchangeFailed": "导出失败", "title.exportVideo": "导出视频", "title.exportVideoHint": "将时间线导出为视频文件", "title.exportVideoEmpty": "时间线为空,无可导出内容", @@ -419,9 +434,24 @@ const en: Dict = { "title.toggleAgent": "Toggle Agent Panel", "title.export": "Export", - "title.exportHint": "Export (⌘E)", + "title.exportHint": "Export interchange formats", + "title.exportRenderVideo": "Render to Video (MP4)", "title.exportXmlDialog": "Export Timeline XML", "title.exportXmlFilter": "Final Cut Pro 7 XML", + "title.exportXmeml": "XMEML / FCP7 XML (Premiere・DaVinci・剪映)", + "title.exportXmemlDialog": "Export XMEML (.xml)", + "title.exportXmemlFilter": "XMEML / Final Cut Pro 7 XML", + "title.exportFcpxml": "FCPXML (Final Cut Pro)", + "title.exportFcpxmlDialog": "Export FCPXML (.fcpxml)", + "title.exportFcpxmlFilter": "Final Cut Pro X FCPXML", + "title.exportOtio": "OTIO (DaVinci・industry standard)", + "title.exportOtioDialog": "Export OpenTimelineIO (.otio)", + "title.exportOtioFilter": "OpenTimelineIO", + "title.exportEdl": "EDL (CMX3600)", + "title.exportEdlDialog": "Export EDL (.edl)", + "title.exportEdlFilter": "CMX3600 EDL", + "title.exportInterchangeDone": "Exported", + "title.exportInterchangeFailed": "Export failed", "title.exportVideo": "Export Video", "title.exportVideoHint": "Export the timeline to a video file", "title.exportVideoEmpty": "Timeline is empty — nothing to export", diff --git a/web/src/lib/api.ts b/web/src/lib/api.ts index c0d3af1..cb4840d 100644 --- a/web/src/lib/api.ts +++ b/web/src/lib/api.ts @@ -116,17 +116,68 @@ export async function getDefaultProjectDir(): Promise<string> { return ""; } +// MARK: - Timeline interchange export (XMEML / EDL / OTIO / FCPXML) +// +// Four standard editorial-interchange formats, each a thin path-only command +// that writes the live timeline to disk and returns nothing (or rejects). All +// no-op outside Tauri (no Rust core / no file system). Pick the format per the +// target NLE — see each wrapper. + +/** + * Export the current timeline as XMEML 4 (Final Cut Pro 7 XML, `.xml`). This is + * the Premiere / DaVinci Resolve / 剪映-importable interchange format (Premiere + * does NOT read modern FCPXML; DaVinci/FCP still import FCP7 XML). + */ +export async function exportXmeml(path: string): Promise<void> { + await ensureTauri(); + if (invokeImpl) { + await invokeImpl<void>("export_xmeml", { path }); + } +} + /** - * Export the current timeline to `path` as Final Cut Pro 7 XML (XMEML, `.xml`) - * so it opens in Premiere / DaVinci Resolve / FCP. The command name says - * "fcpxml" (the F4 contract) but the produced format is XMEML — Premiere doesn't - * read FCPXML natively, so upstream exports XMEML; DaVinci/FCP still import it. - * No-op outside Tauri (no Rust core / no file system). + * @deprecated Use {@link exportXmeml}. Historically named "fcpxml" but always + * produced XMEML 4 (FCP7 XML). Kept so older callers keep working; for native + * Final Cut Pro X FCPXML use {@link exportFcpxmlModern}. */ export async function exportFcpxml(path: string): Promise<void> { + return exportXmeml(path); +} + +/** + * Export the current timeline as a CMX3600 EDL (`.edl`) — the classic edit + * decision list Premiere / DaVinci / Avid / 剪映 import. Video track only; + * effects/transforms/audio are dropped (a CMX3600 limitation). + */ +export async function exportEdl(path: string): Promise<void> { await ensureTauri(); if (invokeImpl) { - await invokeImpl<void>("export_fcpxml", { path }); + await invokeImpl<void>("export_edl", { path }); + } +} + +/** + * Export the current timeline as OpenTimelineIO JSON (`.otio`) — the industry + * interchange standard `otioview` / DaVinci / Blender read. Preserves track + * order/kind, clip placement, source ranges, gaps, and media references. + */ +export async function exportOtio(path: string): Promise<void> { + await ensureTauri(); + if (invokeImpl) { + await invokeImpl<void>("export_otio", { path }); + } +} + +/** + * Export the current timeline as native Final Cut Pro X FCPXML 1.10 + * (`.fcpxml`). Carries text overlays (`<title>`), transforms, and volume that + * XMEML can't. NOTE: Premiere does NOT import FCPXML — use {@link exportXmeml} + * for Premiere / DaVinci / 剪映. + */ +export async function exportFcpxmlModern(path: string): Promise<void> { + await ensureTauri(); + if (invokeImpl) { + await invokeImpl<void>("export_fcpxml_modern", { path }); } } @@ -283,6 +334,49 @@ export async function generateThumbnail( return null; } +/** + * Decode (and disk-cache) a HI-RES first-frame poster for a VIDEO asset and + * return its on-disk path (run it through {@link assetUrl} to display). This is + * the instant, sharp placeholder painted behind the preview `<video>` so a cold + * click shows its first frame immediately instead of a blank/spinner — the asset + * protocol then streams the real video progressively (it honors HTTP Range, so + * `<video preload="metadata">` never downloads the whole file). Returns null for + * non-video assets (images render straight from disk; audio has no frame) and + * outside Tauri; decode errors are swallowed (best-effort) so the preview just + * has no poster rather than throwing. */ +export async function previewPoster( + mediaRef: string, + timeSecs?: number, +): Promise<string | null> { + await ensureTauri(); + if (!invokeImpl) return null; + try { + const args: Record<string, unknown> = { mediaRef }; + if (timeSecs != null) args.timeSecs = timeSecs; + return await invokeImpl<string | null>("preview_poster", args); + } catch (e) { + console.warn(`preview_poster failed for ${mediaRef}:`, e); + return null; + } +} + +/** Fire-and-forget preview warm-up for a media asset when it's selected or drag + * starts: the backend decodes its hi-res first-frame poster into the on-disk + * cache on a worker thread, so a subsequent preview shows a sharp first frame + * with no decode on the interaction path. Deliberately light — it no longer + * warms the heavy 240-frame filmstrip sprite or waveform (which never sped + * actual `<video>` playback). No-op in the browser fallback / for non-video; + * errors are swallowed (best-effort). */ +export async function preloadMedia(mediaRef: string): Promise<void> { + await ensureTauri(); + if (!invokeImpl) return; + try { + await invokeImpl<void>("preload_media", { mediaRef }); + } catch (e) { + console.warn(`preload_media failed for ${mediaRef}:`, e); + } +} + // MARK: - Timeline composite preview (#47) // // `composite_frame` renders the timeline at a frame on the GPU (wgpu compositor) diff --git a/web/src/lib/fallback.ts b/web/src/lib/fallback.ts index 5027439..8b68a33 100644 --- a/web/src/lib/fallback.ts +++ b/web/src/lib/fallback.ts @@ -353,6 +353,48 @@ export function createFallbackStore() { [timeline.tracks[cmd.a], timeline.tracks[cmd.b]] = [second, first]; return result(true, "Swap Tracks", []); } + case "swapClips": { + const locA = findClip(cmd.clipA); + const locB = findClip(cmd.clipB); + if (!locA || !locB || cmd.clipA === cmd.clipB) return result(false, "Swap Clips", []); + const [ta, ca] = locA; + const [tb, cb] = locB; + const clipA = timeline.tracks[ta].clips[ca]; + const clipB = timeline.tracks[tb].clips[cb]; + if ( + !trackCompatible(timeline.tracks[tb], clipA.mediaType) || + !trackCompatible(timeline.tracks[ta], clipB.mediaType) + ) { + return result(false, "Swap Clips", []); + } + const aStart = clipA.startFrame; + const bStart = clipB.startFrame; + // Both clips vacate, so they never block each other; only OTHER clips + // on each destination track can refuse the swap (keeps it lossless). + const free = (track: Track, start: number, end: number, exclude: string[]) => + !track.clips.some( + (c) => + !exclude.includes(c.id) && + c.startFrame < end && + c.startFrame + c.durationFrames > start, + ); + const exclude = [cmd.clipA, cmd.clipB]; + if ( + !free(timeline.tracks[tb], bStart, bStart + clipA.durationFrames, exclude) || + !free(timeline.tracks[ta], aStart, aStart + clipB.durationFrames, exclude) + ) { + return result(false, "Swap Clips", []); + } + timeline.tracks[ta].clips = timeline.tracks[ta].clips.filter((c) => c.id !== cmd.clipA); + timeline.tracks[tb].clips = timeline.tracks[tb].clips.filter((c) => c.id !== cmd.clipB); + clipA.startFrame = bStart; + clipB.startFrame = aStart; + timeline.tracks[tb].clips.push(clipA); + timeline.tracks[ta].clips.push(clipB); + timeline.tracks[ta].clips.sort((a, b) => a.startFrame - b.startFrame); + if (ta !== tb) timeline.tracks[tb].clips.sort((a, b) => a.startFrame - b.startFrame); + return result(true, "Swap Clips", [cmd.clipA, cmd.clipB]); + } case "moveClips": { let changed = false; for (const m of cmd.moves) { diff --git a/web/src/lib/haptic.ts b/web/src/lib/haptic.ts new file mode 100644 index 0000000..30451f9 --- /dev/null +++ b/web/src/lib/haptic.ts @@ -0,0 +1,64 @@ +/** + * Snap feedback (the "tick" when a clip edge / playhead snaps), 1:1 with + * upstream's `NSHapticFeedbackManager.perform(.alignment)`. On macOS it fires a + * real trackpad haptic via the `snap_haptic` Tauri command; other platforms + * (no trackpad haptics) get a very short, quiet click sound instead. Deduped so + * holding a snap doesn't buzz repeatedly — it fires once per fresh engagement. + */ + +import { isTauri } from "./api"; + +const isMac = + typeof navigator !== "undefined" && + /Mac/i.test(navigator.userAgent || (navigator as { platform?: string }).platform || ""); + +let lastSnapFrame: number | null = null; +let audioCtx: AudioContext | null = null; + +async function performHaptic(): Promise<void> { + if (!isTauri) return; + try { + const core = await import("@tauri-apps/api/core"); + await (core.invoke as <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>)( + "snap_haptic", + ); + } catch { + // best-effort: a missing haptic must never disrupt the edit gesture + } +} + +function playTick(): void { + try { + const Ctx = + window.AudioContext || + (window as unknown as { webkitAudioContext?: typeof AudioContext }).webkitAudioContext; + if (!Ctx) return; + audioCtx ||= new Ctx(); + const ctx = audioCtx; + const osc = ctx.createOscillator(); + const gain = ctx.createGain(); + osc.frequency.value = 1800; + gain.gain.setValueAtTime(0.0001, ctx.currentTime); + gain.gain.exponentialRampToValueAtTime(0.04, ctx.currentTime + 0.004); + gain.gain.exponentialRampToValueAtTime(0.0001, ctx.currentTime + 0.03); + osc.connect(gain).connect(ctx.destination); + osc.start(); + osc.stop(ctx.currentTime + 0.035); + } catch { + // best-effort + } +} + +/** Fire snap feedback once when a NEW snap target engages. `snappedFrame` is the + * engaged target frame, or `null` when no snap is active (which re-arms the + * next engagement). macOS → trackpad haptic; other platforms → a short tick. */ +export function maybeSnapFeedback(snappedFrame: number | null): void { + if (snappedFrame === null) { + lastSnapFrame = null; + return; + } + if (snappedFrame === lastSnapFrame) return; + lastSnapFrame = snappedFrame; + if (isMac) void performHaptic(); + else playTick(); +} diff --git a/web/src/lib/lru.test.ts b/web/src/lib/lru.test.ts new file mode 100644 index 0000000..67559f5 --- /dev/null +++ b/web/src/lib/lru.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { BoundedCache } from "./lru"; + +describe("BoundedCache", () => { + it("stores and retrieves values", () => { + const c = new BoundedCache<number>(3); + c.set("a", 1); + expect(c.has("a")).toBe(true); + expect(c.get("a")).toBe(1); + expect(c.get("missing")).toBeUndefined(); + expect(c.size).toBe(1); + }); + + it("evicts the least-recently-used entry past capacity", () => { + const c = new BoundedCache<number>(2); + c.set("a", 1); + c.set("b", 2); + c.set("c", 3); // evicts "a" (oldest) + expect(c.has("a")).toBe(false); + expect(c.has("b")).toBe(true); + expect(c.has("c")).toBe(true); + expect(c.size).toBe(2); + }); + + it("get refreshes recency so the touched key survives eviction", () => { + const c = new BoundedCache<number>(2); + c.set("a", 1); + c.set("b", 2); + expect(c.get("a")).toBe(1); // touch "a" → "b" is now oldest + c.set("c", 3); // evicts "b", not "a" + expect(c.has("a")).toBe(true); + expect(c.has("b")).toBe(false); + expect(c.has("c")).toBe(true); + }); + + it("re-setting an existing key refreshes recency without growing size", () => { + const c = new BoundedCache<number>(2); + c.set("a", 1); + c.set("b", 2); + c.set("a", 11); // update + touch → "b" oldest + expect(c.size).toBe(2); + expect(c.get("a")).toBe(11); + c.set("c", 3); // evicts "b" + expect(c.has("b")).toBe(false); + expect(c.has("a")).toBe(true); + }); + + it("preserves a null value distinctly from absence", () => { + const c = new BoundedCache<string | null>(2); + c.set("a", null); + expect(c.has("a")).toBe(true); + expect(c.get("a")).toBeNull(); + }); +}); diff --git a/web/src/lib/lru.ts b/web/src/lib/lru.ts new file mode 100644 index 0000000..fee1efb --- /dev/null +++ b/web/src/lib/lru.ts @@ -0,0 +1,39 @@ +/** + * Tiny bounded LRU cache. Map iteration order is insertion order, so the oldest + * live key is always `keys().next()`; a `get`/`set` on an existing key refreshes + * its recency by re-inserting it at the end. Used for the media-panel thumbnail + * path cache so scrolling a long library can't grow memory without limit + * (evicted entries simply re-request a disk-cached path later). + */ +export class BoundedCache<V> { + private readonly store = new Map<string, V>(); + + constructor(private readonly max: number) {} + + has(key: string): boolean { + return this.store.has(key); + } + + /** Get a value, refreshing its recency. `undefined` when absent. */ + get(key: string): V | undefined { + if (!this.store.has(key)) return undefined; + const value = this.store.get(key) as V; + this.store.delete(key); + this.store.set(key, value); + return value; + } + + /** Insert/update, evicting the least-recently-used entry when over capacity. */ + set(key: string, value: V): void { + this.store.delete(key); + this.store.set(key, value); + if (this.store.size > this.max) { + const oldest = this.store.keys().next().value; + if (oldest !== undefined) this.store.delete(oldest); + } + } + + get size(): number { + return this.store.size; + } +} diff --git a/web/src/lib/snap.test.ts b/web/src/lib/snap.test.ts new file mode 100644 index 0000000..4109047 --- /dev/null +++ b/web/src/lib/snap.test.ts @@ -0,0 +1,45 @@ +import { describe, it, expect } from "vitest"; +import { snapFrameToEdge } from "./snap"; +import type { Timeline } from "./types"; + +/** Minimal timeline fixture — `snapFrameToEdge` only reads clip start/duration. */ +function tl(clips: Array<[number, number]>): Timeline { + return { + fps: 30, + width: 1920, + height: 1080, + version: 0, + tracks: [ + { + id: "v1", + type: "video", + clips: clips.map(([startFrame, durationFrames], i) => ({ + id: `c${i}`, + startFrame, + durationFrames, + })), + }, + ], + } as unknown as Timeline; +} + +describe("snapFrameToEdge", () => { + const t = tl([ + [0, 30], + [100, 50], + ]); // edges: 0, 30, 100, 150 + + it("snaps to the nearest clip edge within threshold", () => { + expect(snapFrameToEdge(t, 32, 5)).toEqual({ frame: 30, snappedTo: 30 }); + expect(snapFrameToEdge(t, 98, 5)).toEqual({ frame: 100, snappedTo: 100 }); + expect(snapFrameToEdge(t, 148, 5)).toEqual({ frame: 150, snappedTo: 150 }); + }); + + it("does not snap when no edge is within threshold", () => { + expect(snapFrameToEdge(t, 60, 5)).toEqual({ frame: 60, snappedTo: null }); + }); + + it("returns the frame unchanged on an empty timeline", () => { + expect(snapFrameToEdge(tl([]), 42, 5)).toEqual({ frame: 42, snappedTo: null }); + }); +}); diff --git a/web/src/lib/snap.ts b/web/src/lib/snap.ts index 5edd256..b399414 100644 --- a/web/src/lib/snap.ts +++ b/web/src/lib/snap.ts @@ -44,6 +44,33 @@ export function collectTargets( return targets; } +/** Snap a bare frame to the nearest clip start/end within `thresholdFrames`. + * Frame-based (no pixels-per-frame), for controls like the preview scrub bar + * that don't carry a timeline zoom. Returns the snapped frame and the engaged + * edge (or `null` when nothing is close enough). */ +export function snapFrameToEdge( + timeline: Timeline, + frame: number, + thresholdFrames: number, +): { frame: number; snappedTo: number | null } { + let best: number | null = null; + let bestDist = thresholdFrames + 1; + for (const track of timeline.tracks) { + for (const clip of track.clips) { + for (const edge of [clip.startFrame, endFrame(clip)]) { + const dist = Math.abs(edge - frame); + if (dist < bestDist) { + bestDist = dist; + best = edge; + } + } + } + } + return best !== null && bestDist <= thresholdFrames + ? { frame: best, snappedTo: best } + : { frame, snappedTo: null }; +} + /** * Find the nearest snap for a probe frame. `currentlySnapped` carries the * previously snapped frame so the sticky band (1.5x) keeps it engaged until the diff --git a/web/src/lib/theme.ts b/web/src/lib/theme.ts index 0c357c5..b3593ba 100644 --- a/web/src/lib/theme.ts +++ b/web/src/lib/theme.ts @@ -142,10 +142,11 @@ export const PLAYHEAD_TRIANGLE = 8; * a neutral gray translucent rect at the resolved track + frame span, matching * the in-timeline move ghost / marquee affordance (white-alpha overlays). */ export const GHOST = { - fill: "rgba(255,255,255,0.16)", + fill: "rgba(255,255,255,0.20)", border: "rgba(255,255,255,0.55)", - /** Faint fill for the "a new track will be created here" lane. */ - newTrackFill: "rgba(255,255,255,0.05)", + /** "A new track inserts here" line — solid yellow, matching upstream + * `NSColor.systemYellow` (TimelineView.swift). */ + insertLine: "rgb(255,204,0)", } as const; /** §5.4 Clip rendering insets (ClipRenderer.swift). */ diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 771f36b..4f90102 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -320,6 +320,7 @@ export type EditRequest = | { type: "unlink"; clipIds: string[] } | { type: "removeTracks"; trackIndexes: number[] } | { type: "swapTracks"; a: number; b: number } + | { type: "swapClips"; clipA: string; clipB: string } | { type: "insertTrack"; kind: ClipType; at?: number } | { type: "setTrackProps"; diff --git a/web/src/store/editActions.browserFallback.test.ts b/web/src/store/editActions.browserFallback.test.ts index ddcefd6..21f3988 100644 --- a/web/src/store/editActions.browserFallback.test.ts +++ b/web/src/store/editActions.browserFallback.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { projectNew } from "../lib/api"; import type { MediaItem } from "../lib/types"; -import { addMediaToTimelineAt, insertTrack } from "./editActions"; +import { addMediaToTimelineAt, insertTrack, swapClips } from "./editActions"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { forceRefresh } from "./sync"; @@ -38,4 +38,26 @@ describe("browser fallback edit actions", () => { expect(tracks[0].clips).toHaveLength(0); expect(tracks[1].clips.map((clip) => clip.mediaRef)).toEqual(["drop"]); }); + + it("swaps two clips across tracks without overwriting either", async () => { + await insertTrack("video"); + await forceRefresh(); + await insertTrack("video"); + await forceRefresh(); + await addMediaToTimelineAt(video("top"), 0, 0); + await addMediaToTimelineAt(video("bottom"), 0, 1); + + let tracks = useProjectStore.getState().timeline.tracks; + const topClip = tracks[0].clips[0]; + const bottomClip = tracks[1].clips[0]; + expect(topClip?.mediaRef).toBe("top"); + expect(bottomClip?.mediaRef).toBe("bottom"); + + await swapClips(topClip.id, bottomClip.id); + + // The two trade tracks; neither is swallowed. + tracks = useProjectStore.getState().timeline.tracks; + expect(tracks[0].clips.map((clip) => clip.mediaRef)).toEqual(["bottom"]); + expect(tracks[1].clips.map((clip) => clip.mediaRef)).toEqual(["top"]); + }); }); diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 0931b21..eca941e 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -55,6 +55,14 @@ export async function moveClips(moves: ClipMoveReq[]) { await applyAndRefresh({ type: "moveClips", moves }); } +/** Swap the positions of two clips (the cross-track "exchange places" gesture) + * so neither overwrites the other. The backend refuses (no change) if the swap + * would overlap a third clip, keeping it lossless. */ +export async function swapClips(clipA: string, clipB: string) { + if (clipA === clipB) return; + await applyAndRefresh({ type: "swapClips", clipA, clipB }); +} + /** Option/Alt-drag duplicate: deep-copy each clip to a new position. The * backend clones every field (keyframe tracks / grade / masks / effects / * text / transform / crop / fades), mints a fresh id, shifts `startFrame` by diff --git a/web/src/styles/tokens.css b/web/src/styles/tokens.css index 9d445d7..49f6543 100644 --- a/web/src/styles/tokens.css +++ b/web/src/styles/tokens.css @@ -13,6 +13,11 @@ --bg-prominent: rgb(44, 44, 44); --bg-placeholder: rgb(30, 30, 30); --bg-preview-canvas: #000; + /* Opaque surface for floating menus / popovers / dialogs. Referenced widely + as var(--bg-elevated); was undefined, so every popover (export / subtitle / + clip context menu / swap-media picker) rendered with a transparent + background and bled the panel behind it through. */ + --bg-elevated: rgb(44, 44, 44); /* §1.2 Border (AppTheme.swift:27-43) */ --border-primary: rgba(255, 255, 255, 0.16);