diff --git a/crates/opentake-agent/src/mcp/dispatch.rs b/crates/opentake-agent/src/mcp/dispatch.rs index 450ad6e..02dfc09 100644 --- a/crates/opentake-agent/src/mcp/dispatch.rs +++ b/crates/opentake-agent/src/mcp/dispatch.rs @@ -437,6 +437,7 @@ impl Dispatcher { opacity: a.opacity, transform: a.transform.map(transform_from_arg), text_content: a.content.clone(), + ..Default::default() }; let res = self.apply(EditCommand::SetClipProperties { clip_ids, diff --git a/crates/opentake-media/src/decode/pcm.rs b/crates/opentake-media/src/decode/pcm.rs index 2820a3f..4bba61c 100644 --- a/crates/opentake-media/src/decode/pcm.rs +++ b/crates/opentake-media/src/decode/pcm.rs @@ -147,6 +147,12 @@ pub fn extract_pcm(path: &Path, spec: &PcmSpec, range: Option<(f64, f64)>) -> Re if !status.success() && raw.is_empty() { return Err(MediaError::no_track("audio", path)); } + // ffmpeg can exit 0 with empty stdout when metadata says audio exists but + // no decodable samples: treat as no audio track so the waveform cache + // isn't poisoned with all-1.0 silence. + if raw.is_empty() { + return Err(MediaError::no_track("audio", path)); + } let samples = raw_to_mono_f32(&raw, spec); Ok(PcmBuffer { diff --git a/crates/opentake-media/src/waveform/store.rs b/crates/opentake-media/src/waveform/store.rs index 8b86562..06df16b 100644 --- a/crates/opentake-media/src/waveform/store.rs +++ b/crates/opentake-media/src/waveform/store.rs @@ -34,6 +34,11 @@ pub fn load_waveform(cache_root: &Path, key: &str) -> Option> { while let Ok(v) = cursor.read_f32::() { out.push(v); } + // Reject poison files: every bucket == 1.0 silence is what an older build + // cached when extract_pcm returned an empty Vec. Force regeneration. + if !out.is_empty() && out.iter().all(|&v| v == 1.0) { + return None; + } Some(out) } diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index f9c75a9..130d16b 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -2,7 +2,7 @@ //! //! UI gestures, the in-app agent, and the external MCP server all funnel through //! one command enum, so undo / validation / versioning are written once -//! (`ARCHITECTURE.md §5`, upstream `ToolExecutor`). +//! (`ARCHITECTURE.md 搂5`, upstream `ToolExecutor`). //! //! `apply` is the `withTimelineSwap` transaction, generalized to the whole //! document (timeline + manifest): @@ -18,7 +18,9 @@ use std::collections::HashSet; -use opentake_domain::{ChromaKey, ClipType, ColorGrade, Effect, Mask, Timeline, Transform}; +use opentake_domain::{ + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Mask, Timeline, Transform, +}; use crate::editor_state::EditorState; use crate::engines::FrameRange; @@ -49,7 +51,7 @@ impl std::fmt::Display for EditError { impl std::error::Error for EditError {} -/// Outcome of a successfully-attempted command. 1:1 shape from `ARCHITECTURE.md §5`. +/// Outcome of a successfully-attempted command. 1:1 shape from `ARCHITECTURE.md 搂5`. #[derive(Clone, PartialEq, Eq, Debug)] pub struct EditResult { /// Whether the document actually changed (drives undo-stack push + version bump). @@ -130,6 +132,20 @@ pub struct ClipProperties { pub opacity: Option, pub transform: Option, pub text_content: Option, + /// Per-clip crop insets (normalized 0–1). Setting this clears `crop_track`. + pub crop: Option, + /// Fade-in length in frames. Setting this clamps to the clip duration. + pub fade_in_frames: Option, + /// Fade-out length in frames. Setting this clamps to the clip duration. + pub fade_out_frames: Option, + /// Fade-in interpolation mode. + pub fade_in_interpolation: Option, + /// Fade-out interpolation mode. + pub fade_out_interpolation: Option, + /// Horizontal flip flag (writes to `transform.flip_horizontal`). + pub flip_horizontal: Option, + /// Vertical flip flag (writes to `transform.flip_vertical`). + pub flip_vertical: Option, } /// Which keyframe track [`EditCommand::SetKeyframes`] targets. @@ -165,6 +181,16 @@ pub enum EditCommand { }, /// Move clips (expanded to linked partners by the caller) to new tracks/frames. MoveClips { moves: Vec }, + /// Deep-copy clips (Option/Alt-drag duplicate) to new positions. Each clip + /// is cloned with all its fields (keyframe tracks / grade / chroma / masks / + /// effects / text / transform / crop / fades), gets a fresh id, is shifted + /// by `offset_frames`, lands on `target_track_indexes[i]`, and has its + /// `link_group_id` cleared (a copy is not linked to the original's group). + DuplicateClips { + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + }, /// Remove clips (expanded to linked partners), pruning emptied tracks. RemoveClips { clip_ids: Vec }, /// Split a clip at a frame (splits linked partners too). @@ -250,7 +276,7 @@ pub enum EditCommand { RemoveTracks { track_indexes: Vec }, /// 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` → + /// (upstream `placeClip` / `add_clips` with omitted `trackIndex` 鈫? /// `insertTrack`), so dragging media onto an empty timeline produces a clip. InsertTrack { kind: ClipType }, /// Toggle track-head properties (mute / hide / sync-lock). `None` leaves a @@ -281,6 +307,23 @@ pub enum EditCommand { /// Delete folders recursively (subfolders + their assets) and cascade-remove /// clips referencing any deleted asset. DeleteFolder { folder_ids: Vec }, + /// Replace a clip's `media_ref` in place, preserving all editing attributes + /// (transform / crop / keyframe tracks / grade / masks / effects / fade / + /// trim / speed / start / duration). 1:1 port of upstream + /// `replaceClipMediaRef(resetTrim: false)`: + /// + /// * **Type-must-match**: the candidate asset's `kind` must strictly equal + /// the clip's `media_type` (no `isVisual` leniency, no `media_type` + /// override). A mismatch is refused without mutating state. + /// * **Link-group cascade**: clips that share the seed clip's link group + /// AND its old `media_ref` are swapped together, so a linked audio/video + /// pair pointing at the same file stays in sync. + /// * **No-op on identical ref**: swapping to the same `media_ref` returns + /// `changed = false` (no undo entry, no version bump). + /// * **No trim/duration rewrites**: trim / speed / start / duration are + /// kept verbatim. The render layer is responsible for any overshoot + /// sampling when the new media is shorter. + SwapMedia { clip_id: String, media_ref: String }, /// Undo the last committed command. Undo, /// Redo the last undone command. @@ -331,6 +374,11 @@ pub fn apply( entries, } => insert_clips(state, track_index, at_frame, entries, ids), EditCommand::MoveClips { moves } => move_clips(state, moves, ids), + EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => duplicate_clips_cmd(state, clip_ids, offset_frames, target_track_indexes, ids), EditCommand::RemoveClips { clip_ids } => remove_clips(state, clip_ids), EditCommand::SplitClip { clip_id, at_frame } => split(state, clip_id, at_frame, ids), EditCommand::TrimClips { edits } => trim(state, edits), @@ -400,6 +448,7 @@ pub fn apply( EditCommand::RenameFolder { entries } => rename_folder(state, entries), EditCommand::DeleteMedia { asset_ids } => delete_media(state, asset_ids), EditCommand::DeleteFolder { folder_ids } => delete_folder(state, folder_ids), + EditCommand::SwapMedia { clip_id, media_ref } => swap_media(state, clip_id, media_ref), } } @@ -625,6 +674,54 @@ fn move_clips( ) } +/// Option/Alt-drag duplicate: deep-copy each clip to a new position. See +/// [`EditCommand::DuplicateClips`]. +fn duplicate_clips_cmd( + state: &mut EditorState, + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + ids: &dyn IdGen, +) -> Result { + if clip_ids.is_empty() { + return Err(EditError::Invalid( + "Missing or empty 'clipIds' array".into(), + )); + } + if target_track_indexes.len() != clip_ids.len() { + return Err(EditError::Invalid(format!( + "targetTrackIndexes length ({}) must match clipIds length ({})", + target_track_indexes.len(), + clip_ids.len() + ))); + } + for id in &clip_ids { + if state.find_clip(id).is_none() { + return Err(EditError::Invalid(format!("Clip not found: {id}"))); + } + } + let action_name = if clip_ids.len() == 1 { + "Duplicate Clip" + } else { + "Duplicate Clips" + }; + let n = clip_ids.len(); + transact( + state, + action_name, + move |_| format!("Duplicated {n} clip(s)"), + |st| { + Ok(ops::duplicate_clips( + &mut st.timeline, + &clip_ids, + offset_frames, + &target_track_indexes, + ids, + )) + }, + ) +} + fn remove_clips(state: &mut EditorState, clip_ids: Vec) -> Result { if clip_ids.is_empty() { return Err(EditError::Invalid( @@ -832,6 +929,30 @@ fn apply_property_changes( if let Some(t) = props.transform { clip.transform = t; } + if let Some(c) = props.crop { + clip.crop = c; + clip.crop_track = None; + } + if let Some(v) = props.fade_in_frames { + clip.fade_in_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(v) = props.fade_out_frames { + clip.fade_out_frames = v.max(0); + clip.clamp_fades_to_duration(); + } + if let Some(i) = props.fade_in_interpolation { + clip.fade_in_interpolation = i; + } + if let Some(i) = props.fade_out_interpolation { + clip.fade_out_interpolation = i; + } + if let Some(f) = props.flip_horizontal { + clip.transform.flip_horizontal = f; + } + if let Some(f) = props.flip_vertical { + clip.transform.flip_vertical = f; + } if let Some(c) = &props.text_content { clip.text_content = Some(c.clone()); } @@ -1311,7 +1432,7 @@ fn ripple_delete_ranges( match outcome { RippleOutcome::Refused(reason) => { // Restore in case clear_region partially mutated before a later refusal - // (it can't here — refusal is dry-run first — but keep it airtight). + // (it can't here 鈥?refusal is dry-run first 鈥?but keep it airtight). state.restore(before); Err(EditError::Refused(reason)) } @@ -1742,6 +1863,121 @@ fn delete_folder( ) } +/// Replace a clip's `media_ref` in place, preserving every editing attribute +/// (transform / crop / keyframe tracks / grade / masks / effects / fade / text +/// / trim / speed / start / duration). 1:1 port of upstream +/// `replaceClipMediaRef(resetTrim: false)`: +/// +/// 1. Validate the seed clip exists and the candidate asset exists in the +/// manifest, then refuse unless `clip.media_type == asset.kind` (strict +/// equality — no `isVisual` leniency). A video clip can only be swapped to +/// a video asset, an audio clip only to an audio asset, etc. +/// 2. Walk the seed clip's link group, picking every clip that shares the +/// same `media_ref`. Each one is updated to the new ref in the same +/// transaction, so a linked audio/video pair pointing at the same file +/// stays in sync (and `Undo` restores every old ref atomically). +/// 3. **No** trim / duration / start rewrites — `resetTrim: false`. The render +/// layer is responsible for any overshoot sampling when the new media is +/// shorter. +/// 4. Same `media_ref` is a no-op (`changed = false`, no undo entry, no +/// version bump). +fn swap_media( + state: &mut EditorState, + clip_id: String, + media_ref: String, +) -> Result { + // 1. Seed clip must exist. + let seed_loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + + // 2. Candidate asset must exist in the manifest. + let new_asset = state + .manifest + .entries + .iter() + .find(|e| e.id == media_ref) + .ok_or_else(|| EditError::Invalid(format!("Media not found: {media_ref}")))?; + + // 3. Strict type-match: clip.media_type == asset.kind. No isVisual leniency, + // no media_type override. A video clip can only swap to a video asset, + // an audio clip only to an audio asset. + let seed_media_type = + state.timeline.tracks[seed_loc.track_index].clips[seed_loc.clip_index].media_type; + if seed_media_type != new_asset.kind { + return Err(EditError::Refused(format!( + "Type mismatch: clip is {:?}, asset is {:?}", + seed_media_type, new_asset.kind + ))); + } + + // 4. No-op when the seed already references the new media. + let seed_old_ref = state.timeline.tracks[seed_loc.track_index].clips[seed_loc.clip_index] + .media_ref + .clone(); + if seed_old_ref == media_ref { + let version = state.version(); + return Ok(EditResult { + changed: false, + action_name: "Swap Media".to_string(), + affected_clip_ids: vec![clip_id.clone()], + timeline_version: version, + summary: format!("No-op: {clip_id} already references {media_ref}"), + }); + } + + // 5. Collect every link-group partner that also references the old ref. + // `expand_to_link_group` returns the whole group; we then keep only + // the members whose `media_ref` matches the seed's old ref. + let link_group = ops::expand_to_link_group(&state.timeline, &{ + let mut s = HashSet::new(); + s.insert(clip_id.clone()); + s + }); + let mut targets: Vec = Vec::new(); + for member_id in &link_group { + if let Some(loc) = state.find_clip(member_id) { + let c = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + if c.media_ref == seed_old_ref { + targets.push(member_id.clone()); + } + } + } + if !targets.iter().any(|id| id == &clip_id) { + // Defensive: the seed itself must always be in the target set. + targets.push(clip_id.clone()); + } + + let summary_old = seed_old_ref; + let summary_new = media_ref.clone(); + let target_count = targets.len(); + transact( + state, + "Swap Media", + move |affected| { + if affected.len() <= 1 { + format!("Swapped {clip_id}: {summary_old} -> {summary_new}") + } else { + format!( + "Swapped {n} linked clips: {summary_old} -> {summary_new}", + n = affected.len() + ) + } + }, + move |st| { + let mut affected = Vec::with_capacity(target_count); + for tid in &targets { + if let Some(loc) = st.find_clip(tid) { + st.timeline.tracks[loc.track_index].clips[loc.clip_index].media_ref = + media_ref.clone(); + affected.push(tid.clone()); + } + } + Ok(affected) + }, + ) +} + // MARK: - Small local helpers fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result<(), EditError> { @@ -2037,7 +2273,7 @@ mod keyframe_edit_tests { EditCommand::StampKeyframe { clip_id: clip_id.clone(), property: KeyframeProperty::Opacity, - frame: 110, // rel 10 — same as existing kf + frame: 110, // rel 10 閳?same as existing kf }, &ids, ) @@ -2115,7 +2351,7 @@ mod keyframe_edit_tests { EditCommand::RemoveKeyframe { clip_id, property: KeyframeProperty::Opacity, - frame: 110, // rel 10 — no kf here + frame: 110, // rel 10 閳?no kf here }, &ids, ); @@ -2161,7 +2397,7 @@ mod keyframe_edit_tests { clip_id, property: KeyframeProperty::Opacity, from_frame: 100, // rel 0 - to_frame: 110, // rel 10 — occupied + to_frame: 110, // rel 10 閳?occupied }, &ids, ); @@ -2178,7 +2414,7 @@ mod keyframe_edit_tests { EditCommand::MoveKeyframe { clip_id, property: KeyframeProperty::Opacity, - from_frame: 115, // rel 15 — no kf + from_frame: 115, // rel 15 閳?no kf to_frame: 120, // rel 20 }, &ids, @@ -2242,7 +2478,7 @@ mod keyframe_edit_tests { EditCommand::SetKeyframeInterpolation { clip_id, property: KeyframeProperty::Opacity, - frame: 115, // rel 15 — no kf + frame: 115, // rel 15 閳?no kf interpolation: Interpolation::Linear, }, &ids, @@ -2254,3 +2490,171 @@ mod keyframe_edit_tests { assert!((a - b).abs() < 1e-9, "{a} != {b}"); } } + +#[cfg(test)] +mod duplicate_clips_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{Clip, ClipType, Keyframe, KeyframeTrack, Track}; + + fn state_with_clip() -> EditorState { + let mut tl = Timeline::new(); + let mut t = Track::new("v1", ClipType::Video); + t.clips.push(Clip::new("c1", "asset", 0, 30)); + tl.tracks.push(t); + EditorState::from_timeline(tl) + } + + #[test] + fn duplicate_clips_creates_copy_with_new_id() { + let mut state = state_with_clip(); + let ids = SeqIdGen::new("d-"); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert!(res.changed); + assert_eq!(res.action_name, "Duplicate Clip"); + assert_eq!(res.affected_clip_ids.len(), 1); + // Original retained, copy present at frame 100. + assert!(state.timeline.tracks[0].clips.iter().any(|c| c.id == "c1")); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + assert_eq!(copy.start_frame, 100); + assert_ne!(copy.id, "c1"); + } + + #[test] + fn duplicate_clips_deep_copies_keyframe_tracks() { + let mut state = state_with_clip(); + state.timeline.tracks[0].clips[0].opacity_track = + Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, 0.0), + Keyframe::new(30, 1.0), + ])); + let ids = SeqIdGen::default(); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + let op = copy.opacity_track.as_ref().unwrap(); + assert_eq!( + op.keyframes.iter().map(|k| k.frame).collect::>(), + vec![0, 30] + ); + } + + #[test] + fn duplicate_clips_clears_link_group_id() { + let mut state = state_with_clip(); + state.timeline.tracks[0].clips[0].link_group_id = Some("grp".into()); + let ids = SeqIdGen::default(); + let res = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 50, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + let copy = state.timeline.tracks[0] + .clips + .iter() + .find(|c| c.id == res.affected_clip_ids[0]) + .unwrap(); + assert!(copy.link_group_id.is_none()); + } + + #[test] + fn duplicate_clips_missing_clip_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["nope".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_length_mismatch_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0, 1], // wrong length + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_empty_ids_errors() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec![], + offset_frames: 100, + target_track_indexes: vec![], + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn duplicate_clips_is_undoable() { + let mut state = state_with_clip(); + let ids = SeqIdGen::default(); + let version_before = state.version(); + apply( + &mut state, + EditCommand::DuplicateClips { + clip_ids: vec!["c1".into()], + offset_frames: 100, + target_track_indexes: vec![0], + }, + &ids, + ) + .unwrap(); + assert_eq!(state.timeline.tracks[0].clips.len(), 2); + assert!(state.can_undo()); + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + assert_eq!(state.timeline.tracks[0].clips.len(), 1); + assert_eq!(state.timeline.tracks[0].clips[0].id, "c1"); + assert_eq!(state.version(), version_before + 2); // commit + undo + } +} diff --git a/crates/opentake-ops/src/ops/duplicate.rs b/crates/opentake-ops/src/ops/duplicate.rs new file mode 100644 index 0000000..9097a5b --- /dev/null +++ b/crates/opentake-ops/src/ops/duplicate.rs @@ -0,0 +1,514 @@ +//! Clip duplication (Option/Alt-drag copy). Deep-clips each source clip — +//! including all keyframe tracks / grade / chroma / masks / effects / text / +//! transform / crop / fades — mints a fresh id, shifts `start_frame` by +//! `offset_frames`, places it on `target_track_indexes[i]`, and remaps the +//! link group (a multi-clip group — e.g. an A/V linked pair — gets a fresh +//! shared id so the copies stay linked; a single-clip group is cleared). +//! The destination range is cleared overwrite-style first (mirrors +//! `move_clips`), so a duplicate landing on an existing clip overwrites it. +//! +//! Companion to [`crate::ops::move_clips`]: same destination-clearing + +//! pin-by-id + sort + prune flow, but the source clip stays put and a deep +//! copy is dropped at the target. + +use std::collections::HashMap; + +use opentake_domain::{Clip, Timeline}; + +use crate::id::IdGen; +use crate::ops::clear_region::clear_region; +use crate::ops::place::sort_clips; +use crate::ops::tracks::prune_empty_tracks; + +/// Deep-copy each clip in `clip_ids` to a new position: `start_frame` shifted +/// by `offset_frames`, placed on `target_track_indexes[i]` (one target per +/// source, by index). Returns the ids of the newly created clips (in input +/// order). Missing clips or out-of-range / type-incompatible targets are +/// silently skipped (mirrors `move_clips`'s "guard ... continue"). +/// +/// Each duplicate: +/// - gets a fresh id from `ids`, +/// - keeps every field of the source (keyframe tracks, grade, chroma, masks, +/// effects, text, transform, crop, fades — `Clip: Clone` is a deep copy), +/// - has its `link_group_id` remapped (a group shared by multiple copied +/// clips gets a fresh shared id so the copies stay linked; a single-clip +/// group is cleared to `None`), +/// - has `start_frame = source.start_frame + offset_frames` (clamped `>= 0`). +pub fn duplicate_clips( + timeline: &mut Timeline, + clip_ids: &[String], + offset_frames: i32, + target_track_indexes: &[usize], + ids: &dyn IdGen, +) -> Vec { + if clip_ids.is_empty() { + return Vec::new(); + } + + // Resolve each source clip + validate its target track. Collect up front so + // the mutation phase can pin tracks by id (pruning could shift indices). + struct Plan { + clone: Clip, + to_track_id: String, + to_frame: i32, + } + let mut plans: Vec = Vec::new(); + for (i, id) in clip_ids.iter().enumerate() { + let Some((ti, ci)) = find(timeline, id) else { + continue; + }; + let Some(&to_track) = target_track_indexes.get(i) else { + continue; + }; + if to_track >= timeline.tracks.len() { + continue; + } + let src_type = timeline.tracks[ti].kind; + let dest_type = timeline.tracks[to_track].kind; + if !dest_type.is_compatible(src_type) { + continue; + } + let clone = timeline.tracks[ti].clips[ci].clone(); + let to_frame = (clone.start_frame + offset_frames).max(0); + plans.push(Plan { + clone, + to_track_id: timeline.tracks[to_track].id.clone(), + to_frame, + }); + } + if plans.is_empty() { + return Vec::new(); + } + + // Clear each destination range (pin by track id) so the duplicate overwrites + // whatever was there, exactly like `move_clips` / `place_clip` do. + for plan in &plans { + if let Some(idx) = timeline + .tracks + .iter() + .position(|t| t.id == plan.to_track_id) + { + clear_region( + timeline, + idx, + plan.to_frame, + plan.to_frame + plan.clone.duration_frames, + false, + ids, + ); + } + } + + // Build the link-group remap table (mirrors upstream's `groupCounts` / + // `groupRemap` in EditorViewModel+Clipboard.swift): a group shared by + // multiple copied clips (e.g. an A/V linked pair) maps to a fresh shared id + // so the copies stay linked to each other; a group with only one clip (or + // no group) maps to None — that copy stands alone. + let mut group_counts: HashMap, usize> = HashMap::new(); + for plan in &plans { + *group_counts + .entry(plan.clone.link_group_id.clone()) + .or_insert(0) += 1; + } + let mut group_remap: HashMap, Option> = HashMap::new(); + for (group_id, &count) in &group_counts { + let new_id = if count > 1 && group_id.is_some() { + Some(ids.next_id()) + } else { + None + }; + group_remap.insert(group_id.clone(), new_id); + } + + // Drop each deep copy at its target frame with a fresh id + remapped link. + let mut created = Vec::new(); + for plan in plans { + if let Some(idx) = timeline + .tracks + .iter() + .position(|t| t.id == plan.to_track_id) + { + let mut clip = plan.clone; + clip.id = ids.next_id(); + clip.start_frame = plan.to_frame; + // Remap the link group: multi-clip groups get the fresh shared id, + // single-clip groups (and None) clear to None. + let remapped = group_remap.get(&clip.link_group_id).cloned().flatten(); + clip.link_group_id = remapped; + created.push(clip.id.clone()); + timeline.tracks[idx].clips.push(clip); + sort_clips(&mut timeline.tracks[idx]); + } + } + prune_empty_tracks(timeline); + created +} + +fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> { + for (ti, t) in timeline.tracks.iter().enumerate() { + if let Some(ci) = t.clips.iter().position(|c| c.id == clip_id) { + return Some((ti, ci)); + } + } + None +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{ + ChromaKey, ClipType, ColorGrade, Crop, Effect, Interpolation, Keyframe, KeyframeTrack, + Mask, MaskShape, Point2, Track, + }; + + fn clip(id: &str, start: i32, dur: i32) -> Clip { + Clip::new(id, "asset", start, dur) + } + + fn two_video_tracks() -> Timeline { + let mut tl = Timeline::new(); + let mut v1 = Track::new("v1", ClipType::Video); + v1.clips.push(clip("a", 0, 30)); + let v2 = Track::new("v2", ClipType::Video); + tl.tracks.push(v1); + tl.tracks.push(v2); + tl + } + + #[test] + fn duplicate_keeps_original_and_creates_copy_at_offset() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::new("d-"); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + assert_eq!(created.len(), 1); + // Original stays put. + assert!(tl.tracks[0] + .clips + .iter() + .any(|c| c.id == "a" && c.start_frame == 0)); + // Copy lands at frame 100 on the same track with a fresh id. + let copy = tl.tracks[0].clips.iter().find(|c| c.id == "d-1").unwrap(); + assert_eq!(copy.start_frame, 100); + assert_eq!(copy.duration_frames, 30); + assert_eq!(copy.media_ref, "asset"); + } + + #[test] + fn duplicate_clears_link_group_id() { + let mut tl = two_video_tracks(); + // Mark the source as linked. + tl.tracks[0].clips[0].link_group_id = Some("grp".into()); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert!( + copy.link_group_id.is_none(), + "duplicate must not inherit link" + ); + // Original keeps its link group. + assert_eq!(tl.tracks[0].clips[0].link_group_id.as_deref(), Some("grp")); + } + + fn find_clip<'a>(tl: &'a Timeline, id: &str) -> &'a Clip { + tl.tracks + .iter() + .flat_map(|t| t.clips.iter()) + .find(|c| c.id == id) + .expect("clip exists") + } + + #[test] + fn duplicate_clips_remaps_link_group_for_multi_clip_group() { + // A/V linked pair (shared group "g1") + a lone clip in its own group + // ("g2"). Mirrors upstream's groupCounts/groupRemap: the pair's copies + // must share a fresh group id; the lone clip's copy clears to None. + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + let mut va = clip("va", 0, 30); + va.link_group_id = Some("g1".into()); + let mut sv = clip("sv", 60, 30); + sv.link_group_id = Some("g2".into()); + v.clips.push(va); + v.clips.push(sv); + let mut a = Track::new("a", ClipType::Audio); + let mut aa = clip("aa", 0, 30); + aa.media_type = ClipType::Audio; + aa.link_group_id = Some("g1".into()); + a.clips.push(aa); + tl.tracks.push(v); + tl.tracks.push(a); + + let g = SeqIdGen::default(); + let created = duplicate_clips( + &mut tl, + &["va".into(), "aa".into(), "sv".into()], + 200, + &[0, 1, 0], + &g, + ); + assert_eq!(created.len(), 3); + + // The A/V pair copies share a NEW link_group_id (same as each other, + // different from the source "g1"). + let va_copy = find_clip(&tl, &created[0]); + let aa_copy = find_clip(&tl, &created[1]); + assert_eq!(va_copy.link_group_id, aa_copy.link_group_id); + assert_ne!(va_copy.link_group_id.as_deref(), Some("g1")); + assert!( + va_copy.link_group_id.is_some(), + "multi-clip group copies must stay linked" + ); + + // The lone clip's group ("g2", count == 1) clears to None. + let sv_copy = find_clip(&tl, &created[2]); + assert!( + sv_copy.link_group_id.is_none(), + "single-clip group must clear to None" + ); + + // Originals keep their original group ids. + assert_eq!(find_clip(&tl, "va").link_group_id.as_deref(), Some("g1")); + assert_eq!(find_clip(&tl, "aa").link_group_id.as_deref(), Some("g1")); + } + + #[test] + fn duplicate_deep_copies_keyframe_tracks() { + let mut tl = two_video_tracks(); + // Give the source an opacity track + volume track with keyframes. + tl.tracks[0].clips[0].opacity_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, 0.0), + Keyframe::new(30, 1.0), + ])); + tl.tracks[0].clips[0].volume_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::new(0, -6.0), + Keyframe::new(30, 0.0), + ])); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + // Keyframe offsets are clip-relative, so they're identical to the source + // (the copy's start_frame moved, but offsets stay). + let op = copy.opacity_track.as_ref().unwrap(); + assert_eq!( + op.keyframes.iter().map(|k| k.frame).collect::>(), + vec![0, 30] + ); + let vol = copy.volume_track.as_ref().unwrap(); + assert_eq!( + vol.keyframes.iter().map(|k| k.value).collect::>(), + vec![-6.0, 0.0] + ); + // Mutating the copy's track must not touch the original (deep copy). + let copy_op = copy.opacity_track.as_ref().unwrap().clone(); + tl.tracks[0] + .clips + .iter_mut() + .find(|c| c.id == created[0]) + .unwrap() + .opacity_track = None; + assert!(tl.tracks[0].clips[0].opacity_track.is_some()); + assert_eq!( + tl.tracks[0].clips[0].opacity_track.as_ref().unwrap(), + ©_op + ); + } + + #[test] + fn duplicate_deep_copies_grade_masks_effects() { + let mut tl = two_video_tracks(); + let src = &mut tl.tracks[0].clips[0]; + src.color_grade = Some(ColorGrade { + exposure: 0.5, + ..Default::default() + }); + src.chroma_key = Some(ChromaKey::default()); + src.masks = vec![Mask { + shape: MaskShape::Circle { + center: Point2::new(0.5, 0.5), + radius: Point2::new(0.3, 0.3), + }, + feather: 0.05, + invert: false, + }]; + src.effects = vec![Effect::new("gaussianBlur").with_param("radius", 4.0)]; + let orig_color_grade = src.color_grade; + let orig_chroma_key = src.chroma_key; + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.color_grade, orig_color_grade); + assert_eq!(copy.chroma_key, orig_chroma_key); + assert_eq!(copy.masks.len(), 1); + assert_eq!(copy.effects.len(), 1); + // Mutate the copy's masks; the original must be unaffected (no shared ref). + let copy_masks = copy.masks.clone(); + tl.tracks[0] + .clips + .iter_mut() + .find(|c| c.id == created[0]) + .unwrap() + .masks + .clear(); + assert_eq!(tl.tracks[0].clips[0].masks, copy_masks); + } + + #[test] + fn duplicate_to_different_track_uses_target_index() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + // Copy lands on v2 (index 1). + let copy = tl.tracks[1] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.start_frame, 100); + // Original still on v1. + assert!(tl.tracks[0].clips.iter().any(|c| c.id == "a")); + } + + #[test] + fn duplicate_multiple_clips_preserve_relative_spacing() { + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + v.clips.push(clip("a", 0, 30)); + v.clips.push(clip("b", 60, 30)); // 30-frame gap + tl.tracks.push(v); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into(), "b".into()], 100, &[0, 0], &g); + assert_eq!(created.len(), 2); + let c0 = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + let c1 = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[1]) + .unwrap(); + // a@0 -> 100, b@60 -> 160; gap of 30 preserved. + assert_eq!(c0.start_frame, 100); + assert_eq!(c1.start_frame, 160); + } + + #[test] + fn duplicate_overwrites_blocking_clip_at_destination() { + let mut tl = two_video_tracks(); + // Place a blocker on v2 at [90,150); duplicating a to v2@100 overwrites the overlap. + tl.tracks[1].clips.push(clip("blocker", 90, 60)); + let g = SeqIdGen::new("r-"); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + let v2 = tl.tracks.iter().find(|t| t.id == "v2").unwrap(); + let copy = v2.clips.iter().find(|c| c.id == created[0]).unwrap(); + assert_eq!((copy.start_frame, copy.end_frame()), (100, 130)); + // No clip other than the copy covers [100,130). + let covering = v2 + .clips + .iter() + .filter(|c| c.id != created[0] && c.start_frame < 130 && c.end_frame() > 100) + .count(); + assert_eq!(covering, 0); + } + + #[test] + fn duplicate_clamps_start_frame_to_zero() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + // a starts at 0; offset -50 would put it at -50 -> clamped to 0. + let created = duplicate_clips(&mut tl, &["a".into()], -50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.start_frame, 0); + } + + #[test] + fn duplicate_skips_missing_clip() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["nope".into()], 100, &[0], &g); + assert!(created.is_empty()); + } + + #[test] + fn duplicate_skips_incompatible_target_track() { + let mut tl = Timeline::new(); + let mut v = Track::new("v", ClipType::Video); + v.clips.push(clip("a", 0, 30)); + let a = Track::new("a", ClipType::Audio); + tl.tracks.push(v); + tl.tracks.push(a); + let g = SeqIdGen::default(); + // Duplicating a video clip onto an audio track -> incompatible -> skipped. + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[1], &g); + assert!(created.is_empty()); + assert!(tl.tracks[1].clips.is_empty()); + } + + #[test] + fn duplicate_skips_out_of_range_target() { + let mut tl = two_video_tracks(); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["a".into()], 100, &[99], &g); + assert!(created.is_empty()); + } + + #[test] + fn duplicate_copies_text_and_transform_fields() { + let mut tl = Timeline::new(); + let mut t = Track::new("t", ClipType::Text); + let mut c = Clip::new("txt", "", 0, 30); + c.media_type = ClipType::Text; + c.source_clip_type = ClipType::Text; + c.text_content = Some("Hello".into()); + c.transform = opentake_domain::Transform::from_center( + opentake_domain::Point { x: 0.25, y: 0.75 }, + 0.5, + 0.5, + ); + c.crop = Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }; + c.fade_in_frames = 5; + c.fade_in_interpolation = Interpolation::Smooth; + c.rotation_track = Some(KeyframeTrack::from_keyframes(vec![ + Keyframe::with_interpolation(0, 0.0, Interpolation::Linear), + Keyframe::new(10, 0.2), + ])); + t.clips.push(c); + tl.tracks.push(t); + let g = SeqIdGen::default(); + let created = duplicate_clips(&mut tl, &["txt".into()], 50, &[0], &g); + let copy = tl.tracks[0] + .clips + .iter() + .find(|c| c.id == created[0]) + .unwrap(); + assert_eq!(copy.text_content.as_deref(), Some("Hello")); + assert_eq!(copy.transform.center_x, 0.25); + assert_eq!(copy.crop.left, 0.1); + assert_eq!(copy.fade_in_frames, 5); + assert_eq!(copy.fade_in_interpolation, Interpolation::Smooth); + assert!(copy.rotation_track.is_some()); + } +} diff --git a/crates/opentake-ops/src/ops/mod.rs b/crates/opentake-ops/src/ops/mod.rs index dcaf88e..7ea074c 100644 --- a/crates/opentake-ops/src/ops/mod.rs +++ b/crates/opentake-ops/src/ops/mod.rs @@ -4,6 +4,7 @@ //! in place, and the command layer snapshots/commits around them. pub mod clear_region; +pub mod duplicate; pub mod folders; pub mod linking; pub mod move_clips; @@ -14,6 +15,7 @@ pub mod tracks; pub mod trim; pub use clear_region::clear_region; +pub use duplicate::duplicate_clips; pub use folders::{ create_folder, delete_folder, delete_media, move_to_folder, rename_folder, rename_media, }; diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 91cf426..cbce363 100644 --- a/crates/opentake-ops/tests/command_apply.rs +++ b/crates/opentake-ops/tests/command_apply.rs @@ -5,7 +5,9 @@ use opentake_domain::{AnimPair, Interpolation, Keyframe, KeyframeTrack}; use opentake_domain::{ChromaKey, ColorGrade, Effect, Mask, MaskShape, Point2}; -use opentake_domain::{Clip, ClipType, MediaManifest, Timeline, Track, Transform}; +use opentake_domain::{ + Clip, ClipType, MediaManifest, MediaManifestEntry, MediaSource, Timeline, Track, Transform, +}; use opentake_ops::{ apply, ClipEntry, ClipMove, ClipProperties, EditCommand, EditError, EditorState, FrameRange, KeyframePayload, KeyframeProperty, SeqIdGen, TextEntry, @@ -225,6 +227,40 @@ fn split_linked_pair_splits_partner_and_regroups() { assert_ne!(rights[0].link_group_id.as_deref(), Some("g1")); } +#[test] +fn duplicate_linked_pair_keeps_copies_linked() { + // Option/Alt-drag duplicating an A/V linked pair must keep the copies linked + // to each other under a fresh group id (groupCounts/groupRemap semantics). + let mut st = linked_av_state(); + let g = SeqIdGen::default(); + let res = apply( + &mut st, + EditCommand::DuplicateClips { + clip_ids: vec!["v1".into(), "a1".into()], + offset_frames: 200, + target_track_indexes: vec![0, 1], + }, + &g, + ) + .unwrap(); + assert_eq!(res.affected_clip_ids.len(), 2); + + // The copies share a NEW link_group_id (same as each other, different from + // the source "g1") — the A/V link survives the duplicate. + let vc = find_clip(&st, &res.affected_clip_ids[0]); + let ac = find_clip(&st, &res.affected_clip_ids[1]); + assert_eq!(vc.link_group_id, ac.link_group_id); + assert_ne!(vc.link_group_id.as_deref(), Some("g1")); + assert!( + vc.link_group_id.is_some(), + "linked pair copies must stay linked" + ); + + // Originals keep "g1". + assert_eq!(find_clip(&st, "v1").link_group_id.as_deref(), Some("g1")); + assert_eq!(find_clip(&st, "a1").link_group_id.as_deref(), Some("g1")); +} + #[test] fn remove_clips_expands_to_linked_partner() { let mut st = linked_av_state(); @@ -519,6 +555,153 @@ fn set_clip_properties_scalar_clears_keyframe_track() { assert!(c.opacity_track.is_none()); // cleared by setting the scalar } +#[test] +fn set_clip_properties_crop_sets_and_clears_track() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + // Pre-existing crop track should be cleared when a static crop is set. + let mut existing = st.timeline.tracks[0].clips[0].clone(); + existing.crop_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + opentake_domain::Crop { + left: 0.1, + top: 0.0, + right: 0.0, + bottom: 0.0, + }, + )])); + st.timeline.tracks[0].clips[0] = existing; + + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + crop: Some(opentake_domain::Crop { + left: 0.2, + top: 0.1, + right: 0.0, + bottom: 0.0, + }), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + + let c = &st.timeline.tracks[0].clips[0]; + assert!((c.crop.left - 0.2).abs() < 1e-9); + assert!((c.crop.top - 0.1).abs() < 1e-9); + assert!(c.crop_track.is_none()); // cleared by setting the static value +} + +#[test] +fn set_clip_properties_fade_sets_frames_and_interpolation() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + fade_in_frames: Some(10), + fade_out_frames: Some(15), + fade_in_interpolation: Some(Interpolation::Smooth), + fade_out_interpolation: Some(Interpolation::Hold), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + + let c = &st.timeline.tracks[0].clips[0]; + assert_eq!(c.fade_in_frames, 10); + assert_eq!(c.fade_out_frames, 15); + assert_eq!(c.fade_in_interpolation, Interpolation::Smooth); + assert_eq!(c.fade_out_interpolation, Interpolation::Hold); +} + +#[test] +fn set_clip_properties_fade_clamps_to_duration() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 30)])]); + let g = SeqIdGen::default(); + // fade_in 100 on a 30-frame clip should clamp to 30, fade_out to 0. + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + fade_in_frames: Some(100), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert_eq!(c.fade_in_frames, 30); + assert_eq!(c.fade_out_frames, 0); +} + +#[test] +fn set_clip_properties_flip_writes_to_transform() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + flip_horizontal: Some(true), + flip_vertical: Some(true), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert!(c.transform.flip_horizontal); + assert!(c.transform.flip_vertical); +} + +#[test] +fn set_clip_properties_multiple_fields_at_once() { + let mut st = state(vec![video_track("v", true, vec![clip("c", 0, 60)])]); + let g = SeqIdGen::default(); + apply( + &mut st, + EditCommand::SetClipProperties { + clip_ids: vec!["c".into()], + properties: ClipProperties { + crop: Some(opentake_domain::Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }), + fade_in_frames: Some(5), + fade_in_interpolation: Some(Interpolation::Smooth), + flip_horizontal: Some(true), + opacity: Some(0.8), + ..Default::default() + }, + }, + &g, + ) + .unwrap(); + let c = &st.timeline.tracks[0].clips[0]; + assert!((c.crop.left - 0.1).abs() < 1e-9); + assert!((c.crop.bottom - 0.4).abs() < 1e-9); + assert_eq!(c.fade_in_frames, 5); + assert_eq!(c.fade_in_interpolation, Interpolation::Smooth); + assert!(c.transform.flip_horizontal); + assert!((c.opacity - 0.8).abs() < 1e-9); + assert!(c.opacity_track.is_none()); // opacity scalar cleared its track +} + // ---- set_keyframes -------------------------------------------------------- #[test] @@ -1054,3 +1237,355 @@ fn ripple_delete_clips_rejects_unknown_clip() { )); assert_eq!(st.version(), 0); } + +// ---- swap_media ------------------------------------------------------------ + +/// Build a manifest entry with `duration` in seconds and an External source. +fn media_entry(id: &str, kind: ClipType, duration_secs: f64) -> MediaManifestEntry { + MediaManifestEntry { + id: id.into(), + name: id.into(), + kind, + source: MediaSource::External { + absolute_path: format!("/abs/{id}"), + }, + duration: duration_secs, + generation_input: None, + source_width: None, + source_height: None, + source_fps: None, + has_audio: None, + folder_id: None, + cached_remote_url: None, + cached_remote_url_expires_at: None, + } +} + +/// Build a state with the given tracks and manifest entries (fps defaults to 30). +fn state_with_media(tracks: Vec, entries: Vec) -> EditorState { + let mut tl = Timeline::new(); + tl.tracks = tracks; + let mut manifest = MediaManifest::new(); + manifest.entries = entries; + EditorState::new(tl, manifest) +} + +#[test] +fn swap_media_replaces_ref_and_preserves_attributes() { + // Clip duration 100 frames (fps=30 -> 100/30 secs). New media same length. + let mut c = clip("c", 0, 100); + c.opacity = 0.7; + c.transform = Transform { + center_x: 0.3, + center_y: 0.4, + width: 0.5, + height: 0.6, + rotation: 15.0, + flip_horizontal: true, + flip_vertical: false, + }; + c.trim_start_frame = 5; + c.trim_end_frame = 7; + c.speed = 1.5; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "new".into(), + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + assert_eq!(res.action_name, "Swap Media"); + assert_eq!(res.affected_clip_ids, vec!["c".to_string()]); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "new"); + assert_eq!(clip.duration_frames, 100); // unchanged + // Preserved editing attributes + assert!((clip.opacity - 0.7).abs() < 1e-9); + assert!((clip.transform.center_x - 0.3).abs() < 1e-9); + assert!((clip.transform.rotation - 15.0).abs() < 1e-9); + assert!(clip.transform.flip_horizontal); + // trim / speed untouched (resetTrim=false) + assert_eq!(clip.trim_start_frame, 5); + assert_eq!(clip.trim_end_frame, 7); + assert!((clip.speed - 1.5).abs() < 1e-9); +} + +#[test] +fn swap_media_does_not_truncate_when_new_media_shorter() { + // resetTrim=false: clip duration is preserved even when the new media is + // shorter. The render layer is responsible for any overshoot sampling. + let mut c = clip("c", 0, 100); + c.start_frame = 20; + c.trim_start_frame = 2; + c.trim_end_frame = 3; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("short", ClipType::Video, 50.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "short".into(), + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + let clip = &st.timeline.tracks[0].clips[0]; + assert_eq!(clip.media_ref, "short"); + // Start / duration / trim all untouched. + assert_eq!(clip.start_frame, 20); + assert_eq!(clip.duration_frames, 100); + assert_eq!(clip.trim_start_frame, 2); + assert_eq!(clip.trim_end_frame, 3); +} + +#[test] +fn swap_media_rejects_missing_media_ref() { + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![media_entry("old", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "nonexistent".into(), + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); // unchanged + // Original media_ref preserved. + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); +} + +#[test] +fn swap_media_rejects_type_mismatch() { + // Clip is video; asset is audio. Must refuse (no isVisual leniency). + let mut c = clip("c", 0, 100); + c.media_type = ClipType::Video; + c.source_clip_type = ClipType::Video; + let v = video_track("v", true, vec![c]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("audio1", ClipType::Audio, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "audio1".into(), + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Refused(_))); + assert_eq!(st.version(), 0); // unchanged + // Original media_ref preserved. + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); + assert_eq!(st.timeline.tracks[0].clips[0].media_type, ClipType::Video); +} + +#[test] +fn swap_media_rejects_missing_clip() { + let v = video_track("v", true, vec![]); + let entries = vec![media_entry("new", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + let err = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "missing".into(), + media_ref: "new".into(), + }, + &g, + ) + .unwrap_err(); + + assert!(matches!(err, EditError::Invalid(_))); + assert_eq!(st.version(), 0); +} + +#[test] +fn swap_media_no_op_on_same_ref() { + // Seed clip references "asset" (builder default); swapping to "asset" must + // be a no-op (no undo entry, no version bump). + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![media_entry("asset", ClipType::Video, 100.0 / 30.0)]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + let version_before = st.version(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "asset".into(), + }, + &g, + ) + .unwrap(); + + assert!(!res.changed); + assert_eq!(st.version(), version_before); + assert!(!st.can_undo()); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); +} + +#[test] +fn swap_media_is_undoable() { + let v = video_track("v", true, vec![clip("c", 0, 100)]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v], entries); + let g = SeqIdGen::default(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "c".into(), + media_ref: "new".into(), + }, + &g, + ) + .unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "new"); + assert!(st.can_undo()); + + // Undo via the command (undo() is pub(crate), so we route through apply). + apply(&mut st, EditCommand::Undo, &g).unwrap(); + assert_eq!(st.timeline.tracks[0].clips[0].media_ref, "asset"); // restored +} + +#[test] +fn swap_media_cascades_to_link_group_with_same_ref() { + // A linked V1/A1 pair both reference "old". Swapping the video clip must + // also swap the audio clip's ref so the pair stays in sync. + let mut vc = clip("v", 0, 100); + vc.media_type = ClipType::Video; + vc.source_clip_type = ClipType::Video; + vc.link_group_id = Some("g1".into()); + let mut ac = clip("a", 0, 100); + ac.media_type = ClipType::Audio; + ac.source_clip_type = ClipType::Audio; + ac.link_group_id = Some("g1".into()); + let v = video_track("v", true, vec![vc]); + let a = audio_track("a", true, vec![ac]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("new_v", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v, a], entries); + let g = SeqIdGen::default(); + + let res = apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "v".into(), + media_ref: "new_v".into(), + }, + &g, + ) + .unwrap(); + + assert!(res.changed); + // Both V1 and A1 updated. + let v_clip = st + .find_clip("v") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + let a_clip = st + .find_clip("a") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + assert_eq!(v_clip.media_ref, "new_v"); + assert_eq!(a_clip.media_ref, "new_v"); + + // Undo restores both. + apply(&mut st, EditCommand::Undo, &g).unwrap(); + let v_clip = st + .find_clip("v") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + let a_clip = st + .find_clip("a") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + assert_eq!(v_clip.media_ref, "asset"); + assert_eq!(a_clip.media_ref, "asset"); +} + +#[test] +fn swap_media_does_not_cascade_to_link_group_with_different_ref() { + // V1 references "old", A1 (its linked partner) references a DIFFERENT + // asset. Swapping V1 must NOT touch A1 — the swap is only meant to + // update clips that share the old ref. + let mut vc = clip("v", 0, 100); + vc.media_type = ClipType::Video; + vc.source_clip_type = ClipType::Video; + vc.link_group_id = Some("g1".into()); + let mut ac = clip("a", 0, 100); + ac.media_type = ClipType::Audio; + ac.source_clip_type = ClipType::Audio; + ac.link_group_id = Some("g1".into()); + ac.media_ref = "other".into(); + let v = video_track("v", true, vec![vc]); + let a = audio_track("a", true, vec![ac]); + let entries = vec![ + media_entry("old", ClipType::Video, 100.0 / 30.0), + media_entry("other", ClipType::Audio, 100.0 / 30.0), + media_entry("new_v", ClipType::Video, 100.0 / 30.0), + ]; + let mut st = state_with_media(vec![v, a], entries); + let g = SeqIdGen::default(); + + apply( + &mut st, + EditCommand::SwapMedia { + clip_id: "v".into(), + media_ref: "new_v".into(), + }, + &g, + ) + .unwrap(); + + let v_clip = st + .find_clip("v") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + let a_clip = st + .find_clip("a") + .map(|l| &st.timeline.tracks[l.track_index].clips[l.clip_index]) + .unwrap(); + assert_eq!(v_clip.media_ref, "new_v"); + assert_eq!(a_clip.media_ref, "other"); // untouched +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index fe96489..6e8a2de 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -131,7 +131,9 @@ fn msg(e: CmdError) -> String { #[serde(tag = "type", rename_all = "camelCase")] pub enum EditRequest { #[serde(rename_all = "camelCase")] - AddClips { entries: Vec }, + AddClips { + entries: Vec, + }, #[serde(rename_all = "camelCase")] InsertClips { track_index: usize, @@ -139,13 +141,28 @@ pub enum EditRequest { entries: Vec, }, #[serde(rename_all = "camelCase")] - MoveClips { moves: Vec }, + MoveClips { + moves: Vec, + }, #[serde(rename_all = "camelCase")] - RemoveClips { clip_ids: Vec }, + DuplicateClips { + clip_ids: Vec, + offset_frames: i32, + target_track_indexes: Vec, + }, #[serde(rename_all = "camelCase")] - SplitClip { clip_id: String, at_frame: i32 }, + RemoveClips { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - TrimClips { edits: Vec }, + SplitClip { + clip_id: String, + at_frame: i32, + }, + #[serde(rename_all = "camelCase")] + TrimClips { + edits: Vec, + }, #[serde(rename_all = "camelCase")] SetClipProperties { clip_ids: Vec, @@ -189,17 +206,29 @@ pub enum EditRequest { ranges: Vec, }, #[serde(rename_all = "camelCase")] - RippleDeleteClips { clip_ids: Vec }, + RippleDeleteClips { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - AddTexts { entries: Vec }, + AddTexts { + entries: Vec, + }, #[serde(rename_all = "camelCase")] - Link { clip_ids: Vec }, + Link { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - Unlink { clip_ids: Vec }, + Unlink { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - RemoveTracks { track_indexes: Vec }, + RemoveTracks { + track_indexes: Vec, + }, #[serde(rename_all = "camelCase")] - InsertTrack { kind: ClipType }, + InsertTrack { + kind: ClipType, + }, #[serde(rename_all = "camelCase")] SetTrackProps { track_index: usize, @@ -217,6 +246,10 @@ pub enum EditRequest { asset_ids: Vec, folder_id: Option, }, + SwapMedia { + clip_id: String, + media_ref: String, + }, } impl EditRequest { @@ -237,6 +270,15 @@ impl EditRequest { EditRequest::MoveClips { moves } => EditCommand::MoveClips { moves: moves.into_iter().map(ClipMoveDto::into_move).collect(), }, + EditRequest::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + } => EditCommand::DuplicateClips { + clip_ids, + offset_frames, + target_track_indexes, + }, EditRequest::RemoveClips { clip_ids } => EditCommand::RemoveClips { clip_ids }, EditRequest::SplitClip { clip_id, at_frame } => { EditCommand::SplitClip { clip_id, at_frame } @@ -344,6 +386,9 @@ impl EditRequest { asset_ids, folder_id, }, + EditRequest::SwapMedia { clip_id, media_ref } => { + EditCommand::SwapMedia { clip_id, media_ref } + } }) } } @@ -449,6 +494,20 @@ pub struct ClipPropertiesDto { pub transform: Option, #[serde(default)] pub text_content: Option, + #[serde(default)] + pub crop: Option, + #[serde(default)] + pub fade_in_frames: Option, + #[serde(default)] + pub fade_out_frames: Option, + #[serde(default)] + pub fade_in_interpolation: Option, + #[serde(default)] + pub fade_out_interpolation: Option, + #[serde(default)] + pub flip_horizontal: Option, + #[serde(default)] + pub flip_vertical: Option, } impl ClipPropertiesDto { @@ -462,6 +521,13 @@ impl ClipPropertiesDto { opacity: self.opacity, transform: self.transform, text_content: self.text_content, + crop: self.crop, + fade_in_frames: self.fade_in_frames, + fade_out_frames: self.fade_out_frames, + fade_in_interpolation: self.fade_in_interpolation, + fade_out_interpolation: self.fade_out_interpolation, + flip_horizontal: self.flip_horizontal, + flip_vertical: self.flip_vertical, } } } diff --git a/web/src/App.tsx b/web/src/App.tsx index cbc8f69..a6990cd 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -14,6 +14,38 @@ import { initI18n } from "./i18n"; import { initTheme } from "./store/settingsStore"; import { onGoHome } from "./lib/api"; +function Toast() { + const toast = useEditorUiStore((s) => s.toast); + const clearToast = useEditorUiStore((s) => s.clearToast); + useEffect(() => { + if (!toast) return; + const timer = setTimeout(clearToast, 2000); + return () => clearTimeout(timer); + }, [toast, clearToast]); + if (!toast) return null; + return ( +
+ {toast.message} +
+ ); +} + export default function App() { // Editor-only hooks are safe to keep mounted across views: they only act on // editor state/events and the keyboard handler is a no-op until the editor is @@ -65,6 +97,7 @@ export default function App() {
+ ); } diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 066ed38..479ef10 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -11,12 +11,21 @@ import { Icon } from "../ui/Icon"; import { ScrubbableNumberField } from "./ScrubbableNumberField"; import { TextTab } from "./TextTab"; import { KeyframesPanel } from "./KeyframesPanel"; +import { SwapMediaSection } from "./SwapMediaSection"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; +import { + cropAt, + opacityAt, + rotationAt, + sizeAt, + topLeftAt, + volumeAt, +} from "../../lib/clip"; import { useT, type TFunction } from "../../i18n"; -import type { Clip, Timeline } from "../../lib/types"; +import type { Clip, Crop, Interpolation, Timeline } from "../../lib/types"; function gcd(a: number, b: number): number { return b === 0 ? a : gcd(b, a % b); @@ -127,6 +136,79 @@ function Row({ label, children }: { label: string; children: React.ReactNode }) ); } +/** A non-interactive numeric value shown when a property is keyframe-animated. + * Mirrors ScrubbableNumberField's typography but without drag/click handlers. */ +function ReadOnlyValue({ text, width = 56 }: { text: string; width?: number }) { + return ( + + {text} + + ); +} + +/** Inline hint shown beside a read-only field when a property is animated. */ +function AnimatedHint({ t }: { t: TFunction }) { + return ( + + {t("inspector.animatedHint")} + + ); +} + +const INTERPOLATION_KEYS: Record = { + linear: "inspector.interpolation.linear", + hold: "inspector.interpolation.hold", + smooth: "inspector.interpolation.smooth", +}; + +/** A compact native ` onChange(e.target.value as Interpolation)} + style={{ + fontSize: "var(--fs-sm)", + color: "var(--accent-primary)", + background: "var(--bg-raised)", + border: "var(--bw-thin) solid var(--border-primary)", + borderRadius: "var(--radius-xs)", + padding: "1px 4px", + }} + > + {(Object.keys(INTERPOLATION_KEYS) as Interpolation[]).map((k) => ( + + ))} + + ); +} + const TAB_LABEL_KEY: Record<"text" | "video" | "audio" | "aiEdit", string> = { text: "inspector.tab.text", video: "inspector.tab.video", @@ -159,9 +241,29 @@ function ClipInspector({ const activeTab = tabs.includes(tab as never) ? tab : tabs[0]; + // Live sampling: read the current playhead frame so every numeric field shows + // the value at the playhead (upstream `InspectorView.livePreview`). + const activeFrame = useEditorUiStore((s) => s.activeFrame); + const commit = (props: Parameters[1]) => edit.setClipProperties([clip.id], props); + // Track-active checks (a track is active iff it holds ≥1 keyframe). + const opacityAnimated = !!clip.opacityTrack && clip.opacityTrack.keyframes.length > 0; + const volumeAnimated = !!clip.volumeTrack && clip.volumeTrack.keyframes.length > 0; + const rotationAnimated = !!clip.rotationTrack && clip.rotationTrack.keyframes.length > 0; + const scaleAnimated = !!clip.scaleTrack && clip.scaleTrack.keyframes.length > 0; + const positionAnimated = !!clip.positionTrack && clip.positionTrack.keyframes.length > 0; + const cropAnimated = !!clip.cropTrack && clip.cropTrack.keyframes.length > 0; + + // Sampled values at the playhead. + const sampledOpacity = opacityAt(clip, activeFrame); + const sampledVolume = volumeAt(clip, activeFrame); + const sampledRotation = rotationAt(clip, activeFrame); + const sampledScale = sizeAt(clip, activeFrame)[0]; + const sampledTopLeft = topLeftAt(clip, activeFrame); + const sampledCrop = cropAt(clip, activeFrame); + return (
{tabs.length > 1 && ( @@ -192,24 +294,35 @@ function ClipInspector({ )}
+ {clip.mediaType !== "text" && } {activeTab === "text" ? ( ) : activeTab === "audio" ? (
- (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} - suffix=" dB" - width={56} - displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} - onCommit={(v) => commit({ volume: v })} - /> + {volumeAnimated ? ( + <> + + + + ) : ( + (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} + suffix=" dB" + width={56} + displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} + onCommit={(v) => commit({ volume: v })} + /> + )} +
) : ( <> @@ -218,45 +331,86 @@ function ClipInspector({
- Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => - commit({ transform: { ...clip.transform, width: v, height: v } }) - } - /> + {scaleAnimated ? ( + <> + + + + ) : ( + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, width: v, height: v } }) + } + /> + )} - v.toFixed(0)} - suffix="°" - width={56} - onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} - /> + {rotationAnimated ? ( + <> + + + + ) : ( + v.toFixed(0)} + suffix="°" + width={56} + onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} + /> + )} - Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => commit({ opacity: v })} - /> + {opacityAnimated ? ( + <> + + + + ) : ( + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => commit({ opacity: v })} + /> + )} + + + + + + + +
@@ -305,6 +459,213 @@ function ClipInspector({ ); } +// MARK: - Position section (top-left x/y) + +function PositionSection({ + clip, + sampledTopLeft, + animated, + commit, + t, +}: { + clip: Clip; + sampledTopLeft: { x: number; y: number }; + animated: boolean; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + // Editing top-left x/y writes back through `transform.centerX/centerY`. The + // size is preserved from the current transform (scale track writes via scale). + const [w, h] = [clip.transform.width, clip.transform.height]; + return ( +
+ + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, centerX: v + w / 2 } }) + } + /> + )} + + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => + commit({ transform: { ...clip.transform, centerY: v + h / 2 } }) + } + /> + )} + +
+ ); +} + +// MARK: - Crop section (4 edge insets, 0–1) + +function CropSection({ + clip, + sampledCrop, + animated, + commit, + t, +}: { + clip: Clip; + sampledCrop: Crop; + animated: boolean; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + const commitEdge = (edge: keyof Crop, v: number) => { + const next: Crop = { ...clip.crop, [edge]: v }; + commit({ crop: next }); + }; + const renderEdge = (label: string, edge: keyof Crop, value: number) => ( + + {animated ? ( + <> + + + + ) : ( + v.toFixed(3)} + width={56} + onCommit={(v) => commitEdge(edge, v)} + /> + )} + + ); + return ( +
+ + {renderEdge(t("inspector.field.cropLeft"), "left", sampledCrop.left)} + {renderEdge(t("inspector.field.cropTop"), "top", sampledCrop.top)} + {renderEdge(t("inspector.field.cropRight"), "right", sampledCrop.right)} + {renderEdge(t("inspector.field.cropBottom"), "bottom", sampledCrop.bottom)} +
+ ); +} + +// MARK: - Flip section (horizontal / vertical checkboxes) + +function FlipSection({ + clip, + commit, + t, +}: { + clip: Clip; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + const checkboxStyle: React.CSSProperties = { + accentColor: "var(--accent-primary)", + cursor: "pointer", + }; + return ( +
+ + + commit({ flipHorizontal: e.target.checked })} + /> + + + commit({ flipVertical: e.target.checked })} + /> + +
+ ); +} + +// MARK: - Fade section (fade in/out frames + interpolation) + +function FadeSection({ + clip, + commit, + t, +}: { + clip: Clip; + commit: (props: Parameters[1]) => void; + t: TFunction; +}) { + return ( +
+ + + v.toFixed(0)} + width={56} + onCommit={(v) => commit({ fadeInFrames: Math.round(v) })} + /> + + + commit({ fadeInInterpolation: v })} + t={t} + /> + + + v.toFixed(0)} + width={56} + onCommit={(v) => commit({ fadeOutFrames: Math.round(v) })} + /> + + + commit({ fadeOutInterpolation: v })} + t={t} + /> + +
+ ); +} + function ProjectMetadata({ timeline, t }: { timeline: Timeline; t: TFunction }) { const g = gcd(timeline.width, timeline.height) || 1; const total = timeline.tracks.reduce( diff --git a/web/src/components/inspector/SwapMediaSection.tsx b/web/src/components/inspector/SwapMediaSection.tsx new file mode 100644 index 0000000..0977d1d --- /dev/null +++ b/web/src/components/inspector/SwapMediaSection.tsx @@ -0,0 +1,173 @@ +/** + * SwapMediaSection — Inspector's "替换媒体" picker (SPEC §5.10, §6). + * + * Isolated into its own file so Inspector.tsx stays free of mediaStore + * coupling (review #121 point 4: avoid touching the media area from the + * Inspector). When #91 rewrites the media system, this is the single + * touchpoint to update. + * + * Opens an inline media picker that lists every library asset of the SAME + * type as the clip (strict type match, no isVisual leniency). Selecting one + * fires `edit.swapMedia`, which preserves all editing attributes + * (resetTrim=false: trim / speed / start / duration are untouched). Text + * clips don't render this section (they have no source media to swap), and + * the backend refuses type mismatches. + * + * Gate (SPEC §5.10, frontend-UI-1to1-SPEC.md:665): "非 text 且单链组" — the + * entry is hidden when the clip is part of a multi-clip link group, because + * swapping would cascade to every partner sharing the old mediaRef. Linked + * clips should be swapped via the timeline right-click menu where the group + * selection is explicit. + */ + +import { useState } from "react"; +import { RefreshCw } from "lucide-react"; +import { Icon } from "../ui/Icon"; +import { useProjectStore } from "../../store/projectStore"; +import { useMediaStore } from "../../store/mediaStore"; +import * as edit from "../../store/editActions"; +import { type TFunction } from "../../i18n"; +import type { Clip, MediaItem } from "../../lib/types"; + +/** A compact media-type badge label. */ +function mediaTypeLabel(type: MediaItem["type"]): string { + switch (type) { + case "video": + return "Video"; + case "audio": + return "Audio"; + case "image": + return "Image"; + case "text": + return "Text"; + case "lottie": + return "Lottie"; + } +} + +export function SwapMediaSection({ clip, t }: { clip: Clip; t: TFunction }) { + const [open, setOpen] = useState(false); + const items = useMediaStore((s) => s.items); + const timeline = useProjectStore((s) => s.timeline); + + // "单链组" gate: hide when the clip belongs to a link group with > 1 member. + if (clip.linkGroupId) { + const groupSize = timeline.tracks.reduce( + (n, tr) => n + tr.clips.filter((c) => c.linkGroupId === clip.linkGroupId).length, + 0, + ); + if (groupSize > 1) return null; + } + + // Exclude the current media source; only assets of the SAME type are + // candidates (the backend will refuse any other kind anyway). + const candidates = items.filter( + (m) => m.id !== clip.mediaRef && m.type === clip.mediaType, + ); + + const handlePick = (item: MediaItem) => { + void edit.swapMedia(clip.id, item.id); + setOpen(false); + }; + + return ( +
+ + + {open && ( +
+
+ {t("inspector.swapMediaTitle")} +
+ {candidates.length === 0 ? ( +
+ {t("inspector.swapMediaEmpty")} +
+ ) : ( + candidates.map((item) => ( + + )) + )} +
+ )} +
+ ); +} diff --git a/web/src/components/preview/TimelinePlaybackLayer.tsx b/web/src/components/preview/TimelinePlaybackLayer.tsx index bcf9d86..2bd01e6 100644 --- a/web/src/components/preview/TimelinePlaybackLayer.tsx +++ b/web/src/components/preview/TimelinePlaybackLayer.tsx @@ -96,7 +96,7 @@ export function TimelinePlayback({ timeline, fps, playing }: { timeline: Timelin if (!playing) { // Paused: keep elements in DOM but silence the clock and media. mediaClock.release(); - + for (const el of els.current.values()) el.pause(); return; } diff --git a/web/src/components/timeline/ClipContextMenu.tsx b/web/src/components/timeline/ClipContextMenu.tsx new file mode 100644 index 0000000..d16b01d --- /dev/null +++ b/web/src/components/timeline/ClipContextMenu.tsx @@ -0,0 +1,235 @@ +/** + * ClipContextMenu (SPEC §5.8). Right-click menu for timeline clips. MVP items: + * Split at Playhead / Delete / Link or Unlink. Copy/Cut/Paste will be added + * once the clipboard PR (#94) lands. Closes on outside click or item action. + * + * Positioning (#93 review #108): the root element is position:fixed and placed + * at the viewport coords (left/top) passed in from TimelineContainer, which + * captured them from the contextmenu event's clientX/clientY. If the menu would + * overflow the viewport edge it flips to the opposite side of the click point. + */ + +import { useEffect, useLayoutEffect, useRef, useState } from "react"; +import { useProjectStore } from "../../store/projectStore"; +import { useEditorUiStore } from "../../store/uiStore"; +import * as edit from "../../store/editActions"; +import { useT } from "../../i18n"; +import { isSingleLinkGroup } from "../../lib/clip"; +import type { Clip } from "../../lib/types"; + +// Fixed size estimate for viewport-boundary flipping before the menu is +// measured. Close to the rendered size so the flip decision is correct on the +// first paint; the actual size is re-measured in a layout effect below. +const MENU_ESTIMATE = { width: 180, height: 240 }; + +export function ClipContextMenu({ + clipId, + left, + top, + onClose, +}: { + clipId: string; + left: number; + top: number; + onClose: () => void; +}) { + const t = useT(); + const timeline = useProjectStore((s) => s.timeline); + const selectedClipIds = useEditorUiStore((s) => s.selectedClipIds); + const selectClips = useEditorUiStore((s) => s.selectClips); + const setPendingSwapClipId = useEditorUiStore((s) => s.setPendingSwapClipId); + const ref = useRef(null); + + // Compute the final position with viewport-boundary flipping. Start from the + // estimate so the first paint is already correct; re-measure after mount. + const [pos, setPos] = useState(() => ({ + left: flipLeft(left, MENU_ESTIMATE.width), + top: flipTop(top, MENU_ESTIMATE.height), + })); + + // Re-measure with the real DOM size once mounted (before paint, no flicker). + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const w = el.offsetWidth; + const h = el.offsetHeight; + setPos({ left: flipLeft(left, w), top: flipTop(top, h) }); + }, [left, top]); + + // Close on outside click or Escape. + useEffect(() => { + const onDown = (e: MouseEvent) => { + if (ref.current && !ref.current.contains(e.target as Node)) onClose(); + }; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") onClose(); + }; + document.addEventListener("mousedown", onDown); + document.addEventListener("keydown", onKey); + return () => { + document.removeEventListener("mousedown", onDown); + document.removeEventListener("keydown", onKey); + }; + }, [onClose]); + + // Locate the clip to read linkGroupId + mediaType for menu gating. The parent + // (TimelineContainer) already validates the clip exists in onContextMenu before + // opening the menu, so a missing clip here is a stale-state edge case — just + // render nothing. Do NOT call onClose() during render (React render purity, + // review #108 item 2). + let clip: Clip | null = null; + for (const track of timeline.tracks) { + const found = track.clips.find((c) => c.id === clipId); + if (found) { + clip = found; + break; + } + } + if (!clip) return null; + + // The menu acts on the current selection; if the right-clicked clip isn't + // selected, select just it (mirrors typical NLE behavior). + const isSelected = selectedClipIds.has(clipId); + const ensureSelected = () => { + if (!isSelected) selectClips(new Set([clipId])); + }; + + const items: Array<{ id: string; label: string; action: () => void; danger?: boolean; disabled?: boolean }> = [ + { + id: "split", + label: t("contextMenu.split"), + action: () => { + ensureSelected(); + void edit.splitAtPlayhead(); + }, + }, + { + id: "delete", + label: t("contextMenu.delete"), + action: () => { + ensureSelected(); + void edit.deleteSelectedClips(); + }, + danger: true, + }, + ]; + + // Link/Unlink: operate on the full selection (>= 2 clips to link). + if (clip.linkGroupId) { + items.push({ + id: "unlink", + label: t("contextMenu.unlink"), + action: () => { + ensureSelected(); + const ids = [...useEditorUiStore.getState().selectedClipIds]; + if (ids.length > 0) void edit.unlinkClips(ids); + }, + }); + } else { + items.push({ + id: "link", + label: t("contextMenu.link"), + action: () => { + ensureSelected(); + const ids = [...useEditorUiStore.getState().selectedClipIds]; + if (ids.length >= 2) void edit.linkClips(ids); + }, + }); + } + + // Swap Media: enabled only for non-text clips that are alone in their link + // group (SPEC §5.10 "非 text 且单链组"). A multi-clip link group (e.g. linked + // A/V pair) is disabled to avoid desyncing partners. On click, opens the + // SwapMediaPicker modal (pre-filters candidates by strict type equality). + const swapDisabled = clip.mediaType === "text" || !isSingleLinkGroup(clip, timeline); + + // Disabled placeholders for upcoming features (no action on click). + items.push( + { + id: "swapMedia", + label: t("contextMenu.swapMedia"), + action: () => { + ensureSelected(); + setPendingSwapClipId(clipId); + }, + disabled: swapDisabled, + }, + { id: "saveAsMedia", label: t("contextMenu.saveAsMedia"), action: () => {}, disabled: true }, + { id: "extractAudio", label: t("contextMenu.extractAudio"), action: () => {}, disabled: true }, + ); + + return ( +
+ {items.map((item) => ( + + ))} +
+ ); +} + +// Flip the menu to stay within the viewport horizontally. If the menu would +// overflow the right edge, render it to the left of the click point instead. +function flipLeft(left: number, menuWidth: number): number { + if (left + menuWidth > window.innerWidth) { + return Math.max(0, left - menuWidth); + } + return left; +} + +// Flip the menu to stay within the viewport vertically. If the menu would +// overflow the bottom edge, render it above the click point instead. +function flipTop(top: number, menuHeight: number): number { + if (top + menuHeight > window.innerHeight) { + return Math.max(0, top - menuHeight); + } + return top; +} diff --git a/web/src/components/timeline/SwapMediaPicker.tsx b/web/src/components/timeline/SwapMediaPicker.tsx new file mode 100644 index 0000000..acefe37 --- /dev/null +++ b/web/src/components/timeline/SwapMediaPicker.tsx @@ -0,0 +1,211 @@ +/** + * SwapMediaPicker (SPEC §5.10). Modal media picker shown when the user invokes + * Swap Media from the clip context menu. Lists library assets whose `type` + * strictly equals the target clip's `mediaType` (1:1 with upstream + * `isAssetCompatibleWithPendingSwap`), so type mismatch is prevented at the UI + * layer; the backend re-validates as a safety net. On selection, fires + * `edit.swapMedia` (which preserves trim/speed/keyframes/transform — + * `resetTrim=false` semantics) and cascades to linked clips sharing the same + * old mediaRef. + */ + +import { useEffect, useMemo, useState } from "react"; +import { useEditorUiStore } from "../../store/uiStore"; +import { useProjectStore } from "../../store/projectStore"; +import { useMediaStore } from "../../store/mediaStore"; +import * as edit from "../../store/editActions"; +import { useT } from "../../i18n"; +import { clipDisplayName } from "../../lib/clip"; +import { formatTimecode } from "../../lib/geometry"; +import type { Clip, MediaItem } from "../../lib/types"; + +export function SwapMediaPicker() { + const t = useT(); + const pendingSwapClipId = useEditorUiStore((s) => s.pendingSwapClipId); + const setPendingSwapClipId = useEditorUiStore((s) => s.setPendingSwapClipId); + const timeline = useProjectStore((s) => s.timeline); + const items = useMediaStore((s) => s.items); + const [error, setError] = useState(null); + const [busy, setBusy] = useState(false); + const fps = timeline.fps; + + // Resolve the target clip from the pending id. + const clip: Clip | null = useMemo(() => { + if (!pendingSwapClipId) return null; + for (const track of timeline.tracks) { + const found = track.clips.find((c) => c.id === pendingSwapClipId); + if (found) return found; + } + return null; + }, [pendingSwapClipId, timeline]); + + // Close on Escape. + useEffect(() => { + if (!clip) return; + const onKey = (e: KeyboardEvent) => { + if (e.key === "Escape") setPendingSwapClipId(null); + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + }, [clip, setPendingSwapClipId]); + + if (!clip) return null; + + // Pre-filter candidates by strict type equality (backend re-validates). + const candidates: MediaItem[] = items.filter( + (m) => m.type === clip.mediaType && m.id !== clip.mediaRef, + ); + + async function pick(item: MediaItem) { + if (busy) return; + setBusy(true); + setError(null); + try { + await edit.swapMedia(clip!.id, item.id); + setPendingSwapClipId(null); + } catch (e) { + // Backend refuses on type mismatch / missing clip (EditError::Refused). + setError(e instanceof Error ? e.message : String(e)); + } finally { + setBusy(false); + } + } + + return ( +
setPendingSwapClipId(null)} + > +
e.stopPropagation()} + > +
+ + {t("contextMenu.swapMedia")} · {clipDisplayName(clip)} + + +
+ + {error && ( +
+ {error} +
+ )} + +
+ {candidates.length === 0 ? ( +
+ {t("swapMedia.noCandidates")} +
+ ) : ( + candidates.map((m) => ( + + )) + )} +
+
+
+ ); +} diff --git a/web/src/components/timeline/TimelineContainer.tsx b/web/src/components/timeline/TimelineContainer.tsx index 5cb4d35..0766816 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -14,30 +14,64 @@ import { frameAt, totalFrames, trackAt, + trackY, } from "../../lib/geometry"; import { firstAudioIndex } from "../../lib/zones"; import { clampTrimDeltaFrames, trimSourceValues } from "../../lib/clip"; -import { collectTargets, findSnap } from "../../lib/snap"; +import { collectTargets, findSnap, findSnapDelta } from "../../lib/snap"; import { paintTimeline, type DragPaint } from "./timelineCanvas"; import { useT } from "../../i18n"; import { paintRuler } from "./rulerCanvas"; import { TrackHeaderColumn } from "./TrackHeaderColumn"; import { Playhead } from "./Playhead"; import { SnapIndicator } from "./SnapIndicator"; -import { hitTestClip, expandLinkGroup, clipsInRect, type ClipHit } from "./hitTest"; +import { hitTestClip, expandLinkGroup, clipsInRect, audioVolumeKfHit, type ClipHit } from "./hitTest"; +import { ClipContextMenu } from "./ClipContextMenu"; +import { SwapMediaPicker } from "./SwapMediaPicker"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import { useMediaStore } from "../../store/mediaStore"; import * as edit from "../../store/editActions"; +import { forceRefresh } from "../../store/sync"; +import { isTauri } from "../../lib/api"; import { getWaveform } from "../../lib/api"; +import type { ClipType } from "../../lib/types"; + +/** Where a move/duplicate drag will land. `newTrack` creates a fresh track of + * `trackType` below the last existing one (upstream `placeClip` auto-track- + * creation), so dragging into the empty area below the track region still + * produces a clip. */ +type DropTarget = + | { kind: "existing"; trackIndex: number } + | { kind: "newTrack"; trackType: ClipType }; type DragState = | { kind: "scrub" } - | { kind: "move"; hit: ClipHit; grabFrame: number; deltaFrames: number; startTrack: number; targetTrack: number; companions: string[] } + | { + kind: "move"; + hit: ClipHit; + grabFrame: number; + deltaFrames: number; + startTrack: number; + targetTrack: number; + companions: string[]; + /** Option/Alt held at pointer-down: duplicate instead of move. */ + isDuplicate: boolean; + /** Where the drag will land (existing track or a new track below). */ + dropTarget: DropTarget; + } | { kind: "trimLeft" | "trimRight"; hit: ClipHit; startTrim: number; deltaFrames: number } | { kind: "marquee"; startDocX: number; startDocY: number; curDocX: number; curDocY: number } + | { kind: "audioVolumeKf"; clipId: string; fromFrame: number; ghostFrame: number } | null; +/** New-track kind for a dragged clip: audio clips → "audio", everything else + * → "video" (visual types share the video zone, matching `addMediaToTimeline` + * and upstream `placeClip`). */ +function newTrackTypeFor(clip: { mediaType: ClipType }): ClipType { + return clip.mediaType === "audio" ? "audio" : "video"; +} + export function TimelineContainer() { const timeline = useProjectStore((s) => s.timeline); const zoomScale = useEditorUiStore((s) => s.zoomScale); @@ -69,6 +103,10 @@ export function TimelineContainer() { const rulerCanvasRef = useRef(null); const [viewport, setViewport] = useState({ width: 0, height: 0 }); const dragRef = useRef(null); + // Snap hysteresis: keeps the snapped {frame, probeOffset} across pointer + // 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); const [snapFrame, setSnapFrame] = useState(null); const [dragTick, forceTick] = useState(0); const t = useT(); @@ -83,6 +121,9 @@ export function TimelineContainer() { const mountedRef = useRef(true); useEffect(() => () => { mountedRef.current = false; }, []); const [waveformVersion, setWaveformVersion] = useState(0); + // Right-click context menu state. `x/y` are viewport coords (clientX/clientY) + // so ClipContextMenu can position itself with position:fixed (#93 review #108). + const [menu, setMenu] = useState<{ clipId: string; x: number; y: number } | null>(null); const total = useMemo(() => totalFrames(timeline), [timeline]); const docWidth = useMemo( @@ -155,6 +196,8 @@ export function TimelineContainer() { ids: new Set(d.companions), deltaFrames: d.deltaFrames, trackDelta: d.targetTrack - d.startTrack, + isDuplicate: d.isDuplicate, + newTrackType: d.dropTarget.kind === "newTrack" ? d.dropTarget.trackType : undefined, }; } else if (d?.kind === "trimLeft" || d?.kind === "trimRight") { drag = { @@ -163,6 +206,13 @@ export function TimelineContainer() { edge: d.kind === "trimLeft" ? "left" : "right", deltaFrames: d.deltaFrames, }; + } else if (d?.kind === "audioVolumeKf") { + drag = { + kind: "volumeKf", + clipId: d.clipId, + fromFrame: d.fromFrame, + ghostFrame: d.ghostFrame, + }; } paintTimeline(ctx, { timeline, @@ -350,6 +400,40 @@ export function TimelineContainer() { return; } + // Volume-keyframe dot drag (non-Cmd, non-shift): grab a volume kf dot to + // move it (SPEC §5.4 volume envelope). Checked before the clip-body hit so + // a dot click drags the kf instead of starting a clip move. + if (!e.metaKey && !e.shiftKey) { + const kfHit = audioVolumeKfHit(timeline, docX, docY, zoomScale, trackHeights); + if (kfHit) { + selectClips(new Set([kfHit.clipId])); + dragRef.current = { + kind: "audioVolumeKf", + clipId: kfHit.clipId, + fromFrame: kfHit.frame, + ghostFrame: kfHit.frame, + }; + return; + } + } + + // Cmd+click on an audio clip's volume line (not a kf dot) → stamp a new + // volume keyframe at the clicked frame (SPEC §5.4). A click landing on an + // existing dot is a no-op (the kf already exists there). + if (e.metaKey && hit && hit.clip.mediaType === "audio") { + const onDot = audioVolumeKfHit(timeline, docX, docY, zoomScale, trackHeights) !== null; + if (!onDot) { + const clipFrame = Math.max( + 0, + Math.min(hit.clip.durationFrames, frameAt(docX, zoomScale) - hit.clip.startFrame), + ); + void edit.stampKeyframe(hit.clip.id, "volume", clipFrame); + } + selectClips(new Set([hit.clip.id])); + dragRef.current = null; + return; + } + if (hit) { // Selection logic (linkedOn = !Option). const linked = !e.altKey; @@ -398,6 +482,8 @@ export function TimelineContainer() { startTrack: hit.trackIndex, targetTrack: hit.trackIndex, companions: [...nextSel], + isDuplicate: e.altKey, + dropTarget: { kind: "existing", trackIndex: hit.trackIndex }, }; } return; @@ -430,33 +516,104 @@ export function TimelineContainer() { if (d.kind === "move") { const rawFrame = frameAt(docX, zoomScale); let deltaFrames = rawFrame - d.grabFrame; - // Snap: probe the moved clip's edges. + // Snap: probe every companion's start+end (multi-probe, SPEC §5.8) and + // keep the snap engaged across moves via snapStateRef (sticky band). + // Pass `null` for the playhead so a move drag never snaps to the + // playhead — that would feel like the clips are "pinned" to it (the + // PR #120 review request-changes fix). const excluded = new Set(d.companions); - const targets = collectTargets(timeline, excluded, activeFrame); - const movedStart = d.hit.clip.startFrame + deltaFrames; - const movedEnd = movedStart + d.hit.clip.durationFrames; - const snapStart = findSnap(movedStart, targets, zoomScale, null); - const snapEnd = findSnap(movedEnd, targets, zoomScale, null); + const targets = collectTargets(timeline, excluded, null); + const leadStart = d.hit.clip.startFrame; + const probes: number[] = []; + const probeOffsets: number[] = []; + for (const id of d.companions) { + const loc = findClipLoc(timeline, id); + if (!loc) continue; + const c = timeline.tracks[loc[0]].clips[loc[1]]; + const startOff = c.startFrame - leadStart; + const endOff = startOff + c.durationFrames; + // Moved absolute frame = lead's moved start + this probe's offset. + probes.push(leadStart + deltaFrames + startOff); + probeOffsets.push(startOff); + probes.push(leadStart + deltaFrames + endOff); + probeOffsets.push(endOff); + } + const snap = findSnapDelta( + probes, + targets, + zoomScale, + snapStateRef.current, + probeOffsets, + ); let snapped: number | null = null; - if (snapStart && (!snapEnd || Math.abs(snapStart.frame - movedStart) <= Math.abs(snapEnd.frame - movedEnd))) { - deltaFrames += snapStart.frame - movedStart; - snapped = snapStart.frame; - } else if (snapEnd) { - deltaFrames += snapEnd.frame - movedEnd; - snapped = snapEnd.frame; + if (snap) { + deltaFrames += snap.delta; + snapStateRef.current = { frame: snap.snappedFrame, probeOffset: snap.probeOffset }; + snapped = snap.snappedFrame; + } else { + snapStateRef.current = null; } // Clamp so the clip can't go before frame 0. if (d.hit.clip.startFrame + deltaFrames < 0) { deltaFrames = -d.hit.clip.startFrame; snapped = null; + snapStateRef.current = null; + } + // Drop target: existing track under the cursor, or a new track when the + // pointer is below the last track's bottom (upstream `placeClip` auto- + // track-creation). `trackAt` returns null both above and below the track + // region; only treat the below-last-track case as a new-track drop. + const hovered = trackAt(timeline, docY, trackHeights); + let targetTrack: number; + let dropTarget: DropTarget; + if (hovered !== null) { + targetTrack = hovered; + dropTarget = { kind: "existing", trackIndex: hovered }; + } else { + const lastTrackBottom = + timeline.tracks.length > 0 + ? trackY(timeline, timeline.tracks.length, trackHeights) + : LAYOUT.rulerHeight + LAYOUT.dropZoneHeight; + if (docY >= lastTrackBottom) { + // Below the last track → create a new track on drop. + const trackType = newTrackTypeFor(d.hit.clip); + dropTarget = { kind: "newTrack", trackType }; + // Clamp the ghost to the last existing track for rendering; the + // actual new track is created on pointer-up. + targetTrack = Math.max(0, timeline.tracks.length - 1); + } else { + // Above the first track (ruler/drop zone) → keep the previous target. + targetTrack = d.targetTrack; + dropTarget = d.dropTarget; + } } - const targetTrack = trackAt(timeline, docY, trackHeights) ?? d.startTrack; - dragRef.current = { ...d, deltaFrames, targetTrack }; + dragRef.current = { ...d, deltaFrames, targetTrack, dropTarget }; setSnapFrame(snapped); forceTick((n) => n + 1); return; } + if (d.kind === "audioVolumeKf") { + const loc = findClipLoc(timeline, d.clipId); + if (!loc) return; + const clip = timeline.tracks[loc[0]].clips[loc[1]]; + // Cursor → clip-relative frame, clamped to the clip's span. + let ghostFrame = frameAt(docX, zoomScale) - clip.startFrame; + // Snap to the playhead (±5 frames, clip-relative) so a kf can be parked + // exactly on the playhead for precise editing. + const playheadRel = activeFrame - clip.startFrame; + if (Math.abs(ghostFrame - playheadRel) <= 5) { + ghostFrame = playheadRel; + setSnapFrame(activeFrame); + } else { + setSnapFrame(null); + } + ghostFrame = Math.max(0, Math.min(clip.durationFrames, ghostFrame)); + dragRef.current = { ...d, ghostFrame }; + forceTick((n) => n + 1); + return; + } + if (d.kind === "trimLeft" || d.kind === "trimRight") { const rawFrame = frameAt(docX, zoomScale); const edge = d.kind === "trimLeft" ? d.hit.clip.startFrame : d.hit.clip.startFrame + d.hit.clip.durationFrames; @@ -505,12 +662,20 @@ export function TimelineContainer() { (e: React.PointerEvent) => { const d = dragRef.current; dragRef.current = null; + snapStateRef.current = null; setSnapFrame(null); (e.currentTarget as HTMLElement).releasePointerCapture(e.pointerId); if (!d) return; if (d.kind === "move") { - if (d.deltaFrames === 0 && d.targetTrack === d.startTrack) return; // no-op + // No-op: no movement, no track change, and not dropping on a new track. + if ( + d.deltaFrames === 0 && + d.dropTarget.kind === "existing" && + d.dropTarget.trackIndex === d.startTrack + ) { + return; + } // Resolve every dragged clip's current location. const locs = d.companions .map((id) => { @@ -527,9 +692,41 @@ export function TimelineContainer() { const minStart = Math.min(...locs.map((l) => l.clip.startFrame)); const frameDelta = Math.max(d.deltaFrames, -minStart); + // Drop below the last track → create a new track first, then move/dup. + if (d.dropTarget.kind === "newTrack") { + const trackType = d.dropTarget.trackType; + const isDup = d.isDuplicate; + const locSnapshot = locs.map((l) => ({ id: l.id, ti: l.ti, startFrame: l.clip.startFrame })); + void (async () => { + await edit.insertTrack(trackType); + // Ensure the mirror reflects the new track before computing the + // target index (Tauri's timeline_changed is async; browser mode + // already refreshed inside applyAndRefresh). + if (isTauri) await forceRefresh(); + const tl = useProjectStore.getState().timeline; + const newTrackIndex = tl.tracks.length - 1; // appended at end + if (isDup) { + await edit.duplicateClips( + locSnapshot.map((l) => l.id), + frameDelta, + locSnapshot.map(() => newTrackIndex), + ); + } else { + await edit.moveClips( + locSnapshot.map((l) => ({ + clipId: l.id, + toTrack: newTrackIndex, + toFrame: l.startFrame + frameDelta, + })), + ); + } + })(); + return; + } + // One group TRACK delta: step toward 0 until every clip stays in-bounds // and lands on a type-compatible track (rigid group, not per-clip clamp). - const rawTrackDelta = d.targetTrack - d.startTrack; + const rawTrackDelta = d.dropTarget.trackIndex - d.startTrack; let trackDelta = rawTrackDelta; const step = rawTrackDelta > 0 ? -1 : 1; while (trackDelta !== 0) { @@ -542,12 +739,34 @@ export function TimelineContainer() { } if (frameDelta === 0 && trackDelta === 0) return; // nothing actually moves - const moves = locs.map((l) => ({ - clipId: l.id, - toTrack: l.ti + trackDelta, - toFrame: l.clip.startFrame + frameDelta, - })); - void edit.moveClips(moves); + if (d.isDuplicate) { + // Option/Alt-drag duplicate: deep-copy each clip to its target. The + // backend mints fresh ids, shifts start_frame by offsetFrames, and + // clears link_group_id (copies aren't linked to the originals). + void edit.duplicateClips( + locs.map((l) => l.id), + frameDelta, + locs.map((l) => l.ti + trackDelta), + ); + } else { + const moves = locs.map((l) => ({ + clipId: l.id, + toTrack: l.ti + trackDelta, + toFrame: l.clip.startFrame + frameDelta, + })); + void edit.moveClips(moves); + } + return; + } + + if (d.kind === "audioVolumeKf") { + // Commit the keyframe move only when the frame actually changed (a bare + // click on a dot is a no-op). The backend `moveKeyframe` is idempotent + // for fromFrame === toFrame, but skipping the round-trip avoids an + // unnecessary history entry. + if (d.ghostFrame !== d.fromFrame) { + void edit.moveKeyframe(d.clipId, "volume", d.fromFrame, d.ghostFrame); + } return; } @@ -576,6 +795,23 @@ export function TimelineContainer() { // Ghost preview offsets for the active drag (read from dragRef during render). const drag = dragRef.current; + // Right-click on a clip -> context menu. + const onContextMenu = useCallback( + (e: React.MouseEvent) => { + const { docX, docY } = toDoc(e); + const hit = hitTestClip(timeline, docX, docY, zoomScale, trackHeights); + if (!hit) return; // empty space: keep the default (suppressed) menu + e.preventDefault(); + // If the clip isn't already selected, select just it so menu actions + // target the right clip. + if (!selectedClipIds.has(hit.clip.id)) { + selectClips(new Set([hit.clip.id])); + } + setMenu({ clipId: hit.clip.id, x: e.clientX, y: e.clientY }); + }, + [toDoc, timeline, zoomScale, trackHeights, selectedClipIds, selectClips], + ); + return (
)} + {/* Clip right-click context menu. */} + {menu && ( + setMenu(null)} + /> + )} + + {/* Swap Media picker modal (SPEC §5.10). */} + + {/* Horizontal scrollbar proxy (thin) — drag handled via wheel; kept minimal. */}
); diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index 200070d..f2ebb25 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -23,8 +23,23 @@ interface DrawOpts { /** This clip is being dragged (move/trim ghost): drawn semi-transparent at its * live position so it follows the cursor. */ ghost?: boolean; + /** Link-group frame offset vs the lead clip (null = unlinked or is lead). + * When non-null/non-zero, a red badge "+N"/"-N" is drawn at the top-left. */ + linkOffset?: number | null; + /** Volume-keyframe drag ghost: when set, the dot at `fromFrame` is hidden and + * a ghost dot is drawn at `ghostFrame` (same value) so the grabbed keyframe + * follows the cursor (SPEC §5.4). Only set on the dragged clip. */ + volumeKfGhost?: { fromFrame: number; ghostFrame: number }; + /** This ghost is an Option/Alt-drag duplicate preview (issue #98): draws a + * "+" badge in the top-right corner so the user sees the gesture will copy + * rather than move. Only meaningful when `ghost` is true. */ + isDuplicate?: boolean; } +/** Radius of the draggable volume-keyframe dots drawn by `drawVolumeEnvelope`. + * Kept in sync with the hit-test tolerance in `hitTest.ts` (8px incl. tol). */ +export const VOLUME_KF_DOT_RADIUS = 5; + /** Linear amplitude → dB, clamped to the volume slider range. 1:1 port of * `VolumeScale.dbFromLinear` (opentake-domain clip.rs). */ export function dbFromLinear(linear: number): number { @@ -200,6 +215,19 @@ export function drawClip( ctx.restore(); } + // 8. Volume envelope (audio only): a rubber-band polyline over the body plus + // draggable keyframe dots (SPEC §5.4 volume envelope). Drawn before the + // bottom keyframe diamonds so the dots sit above the waveform fill. + if (clip.mediaType === "audio") { + drawVolumeEnvelope(ctx, clip, rect, opts.volumeKfGhost); + } + + // 8b. Link-offset badge: red "+N"/"-N" at the top-left when this clip is out + // of step with its link-group lead (SPEC §5.4 linked-offset indicator). + if (opts.linkOffset != null && opts.linkOffset !== 0) { + drawOffsetBadge(ctx, opts.linkOffset, rect); + } + // 9. Keyframe diamonds along the bottom (ClipRenderer:163-191), y = maxY-5. drawKeyframeMarkers(ctx, clip, rect); @@ -208,6 +236,41 @@ export function drawClip( ctx.fillRect(x, y, TRIM.handleWidth, height); ctx.fillRect(x + width - TRIM.handleWidth, y, TRIM.handleWidth, height); + // 11. Duplicate badge (issue #98): when this ghost is an Option/Alt-drag + // duplicate preview, draw a "+" badge in the top-right corner so the + // user sees the gesture will copy rather than move. Mirrors the + // upstream `+` overlay on option-drag ghosts. + if (opts.ghost && opts.isDuplicate) { + drawDuplicateBadge(ctx, x, y, width); + } + + ctx.restore(); +} + +/** Draw a "+" duplicate badge in the top-right corner of a ghost clip (issue #98). + * Yellow circle with a black "+" — high contrast against any track color, and + * matches the systemYellow used for keyframe diamonds so it reads as "active". */ +function drawDuplicateBadge( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + width: number, +) { + const radius = 7; + const cx = x + width - radius - 2; + const cy = y + radius + 2; + ctx.save(); + // Solid yellow disc. + ctx.beginPath(); + ctx.arc(cx, cy, radius, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemYellow; + ctx.fill(); + // Black "+" glyph, centered. + ctx.fillStyle = "rgba(0,0,0,0.9)"; + ctx.font = `700 11px ${cssFontStack()}`; + ctx.textAlign = "center"; + ctx.textBaseline = "middle"; + ctx.fillText("+", cx, cy + 0.5); ctx.restore(); } @@ -408,3 +471,125 @@ function drawKeyframeMarkers(ctx: CanvasRenderingContext2D, clip: Clip, rect: Cl function cssFontStack(): string { return '-apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", system-ui, sans-serif'; } + +/** + * Volume-envelope rubber band for audio clips (SPEC §5.4). Draws a polyline + * through `clip.volumeTrack` keyframes (linear amplitude → body y), with a + * draggable dot at each keyframe. When no track exists, a flat line at + * `clip.volume` is drawn so the user still sees the static level. Frames are + * clip-relative (0 = clip start); x mapping matches `drawKeyframeMarkers` so the + * envelope dots align vertically with the bottom keyframe diamonds. + */ +function drawVolumeEnvelope( + ctx: CanvasRenderingContext2D, + clip: Clip, + rect: ClipRect, + ghost?: { fromFrame: number; ghostFrame: number }, +) { + if (clip.durationFrames <= 0) return; + const ppf = (rect.width - 2 * TRIM.handleWidth) / clip.durationFrames; + if (ppf <= 0) return; + const baseX = rect.x + TRIM.handleWidth; + const bodyTop = rect.y + CLIP.labelBarHeight; + const bodyH = rect.height - CLIP.labelBarHeight; + if (bodyH <= 6) return; + // Map linear volume [0,1] → body [bottom, top]; clamp for display only. + const yForVol = (v: number) => { + const c = Math.max(0, Math.min(1, v)); + return bodyTop + bodyH * (1 - c); + }; + const track = clip.volumeTrack; + const kfs = track ? [...track.keyframes].sort((a, b) => a.frame - b.frame) : []; + ctx.save(); + ctx.beginPath(); + if (kfs.length === 0) { + const y = yForVol(clip.volume); + ctx.moveTo(baseX, y); + ctx.lineTo(baseX + clip.durationFrames * ppf, y); + } else { + // Extend the first/last keyframe value across the clip's full span so the + // line spans edge to edge (matches upstream's sampled envelope). + ctx.moveTo(baseX, yForVol(kfs[0].value)); + for (const kf of kfs) { + const f = Math.max(0, Math.min(clip.durationFrames, kf.frame)); + ctx.lineTo(baseX + f * ppf, yForVol(kf.value)); + } + ctx.lineTo(baseX + clip.durationFrames * ppf, yForVol(kfs[kfs.length - 1].value)); + } + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 1.25; + ctx.stroke(); + // Draggable keyframe dots. While dragging (ghost set), hide the original dot + // at fromFrame and draw a ghost dot at ghostFrame (same value) so the grabbed + // keyframe follows the cursor without leaving a stale dot behind. + if (kfs.length > 0) { + const fromKf = ghost ? kfs.find((k) => k.frame === ghost.fromFrame) : undefined; + for (const kf of kfs) { + if (ghost && kf.frame === ghost.fromFrame) continue; // hidden — drawn as ghost below + if (kf.frame < 0 || kf.frame > clip.durationFrames) continue; + const kx = baseX + kf.frame * ppf; + const ky = yForVol(kf.value); + ctx.beginPath(); + ctx.arc(kx, ky, VOLUME_KF_DOT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemYellow; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.95)"; + ctx.lineWidth = 1; + ctx.stroke(); + } + if (ghost && fromKf) { + const gf = Math.max(0, Math.min(clip.durationFrames, ghost.ghostFrame)); + const gx = baseX + gf * ppf; + const gy = yForVol(fromKf.value); + ctx.beginPath(); + ctx.arc(gx, gy, VOLUME_KF_DOT_RADIUS, 0, Math.PI * 2); + ctx.fillStyle = ACCENT.systemOrange; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,1)"; + ctx.lineWidth = 1.25; + ctx.stroke(); + } + } + ctx.restore(); +} + +/** + * Link-offset badge: a small red rounded pill at the clip's top-left showing the + * frame offset vs the link-group lead ("+N" when this clip trails, "-N" when it + * leads in time beyond the lead start). Drawn inside the body so it doesn't + * overlap the label bar (SPEC §5.4 linked-offset indicator). + */ +function drawOffsetBadge(ctx: CanvasRenderingContext2D, offsetFrames: number, rect: ClipRect) { + const n = Math.abs(offsetFrames); + const sign = offsetFrames > 0 ? "+" : "-"; + const label = `${sign}${n}`; + ctx.save(); + ctx.font = `600 9px ${cssFontStack()}`; + ctx.textBaseline = "middle"; + ctx.textAlign = "left"; + const textW = ctx.measureText(label).width; + const padX = 4; + const badgeH = 13; + const badgeW = Math.ceil(textW + padX * 2); + // Skip when the clip is too small to legibly hold the badge (now reserves + // room for the right trim handle too — the badge must never overlap it). + if (rect.width < badgeW + CLIP.stripWidth + TRIM.handleWidth + 6 || rect.height < CLIP.labelBarHeight + badgeH + 2) { + ctx.restore(); + return; + } + // Anchor to the right edge, just inside the right trim handle. Upstream + // `ClipRenderer.swift:640-644` draws the offset pill in the top-right so + // it doesn't sit on top of the color strip or the trim handle (the PR + // #120 review request-changes fix). + const bx = rect.x + rect.width - TRIM.handleWidth - badgeW - 2; + const by = rect.y + CLIP.labelBarHeight + 2; + roundRectPath(ctx, bx, by, badgeW, badgeH, 3); + ctx.fillStyle = ACCENT.offsetBadge; + ctx.fill(); + ctx.strokeStyle = "rgba(255,255,255,0.85)"; + ctx.lineWidth = 0.5; + ctx.stroke(); + ctx.fillStyle = "rgba(255,255,255,1)"; + ctx.fillText(label, bx + padX, by + badgeH / 2 + 0.5); + ctx.restore(); +} diff --git a/web/src/components/timeline/hitTest.ts b/web/src/components/timeline/hitTest.ts index a5008d8..16dcc89 100644 --- a/web/src/components/timeline/hitTest.ts +++ b/web/src/components/timeline/hitTest.ts @@ -5,7 +5,7 @@ * (already offset for the header column and scroll by the caller). */ -import { TRIM } from "../../lib/theme"; +import { TRIM, CLIP } from "../../lib/theme"; import { clipRect } from "../../lib/geometry"; import type { Timeline, Clip } from "../../lib/types"; @@ -95,3 +95,58 @@ export function clipsInRect( } return out; } + +/** Hit radius (px) for a volume-keyframe dot — the dot is drawn at 5px radius, + * plus 3px of grab tolerance so a fast click still grabs it. */ +const VOLUME_KF_HIT_RADIUS = 8; + +/** Result of hitting a draggable volume-keyframe dot. `frame` is clip-relative + * (0 = clip start), matching `Keyframe.frame` storage. */ +export interface VolumeKfHit { + clipId: string; + /** Clip-relative keyframe frame. */ + frame: number; +} + +/** + * Hit-test the draggable volume-keyframe dots drawn by `drawVolumeEnvelope` + * (SPEC §5.4). Returns the first audio clip's volume kf within the grab radius, + * or null. The dot position math mirrors `drawVolumeEnvelope` exactly so a + * visible dot is always grabbable. `docX`/`docY` are document-space (already + * scroll-adjusted by the caller, same convention as `hitTestClip`). + */ +export function audioVolumeKfHit( + timeline: Timeline, + docX: number, + docY: number, + pixelsPerFrame: number, + trackHeights: Record, +): VolumeKfHit | null { + for (let ti = 0; ti < timeline.tracks.length; ti++) { + const track = timeline.tracks[ti]; + for (const clip of track.clips) { + if (clip.mediaType !== "audio") continue; + const track2 = clip.volumeTrack; + if (!track2 || track2.keyframes.length === 0) continue; + const rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); + if (clip.durationFrames <= 0) continue; + const ppf = (rect.width - 2 * TRIM.handleWidth) / clip.durationFrames; + if (ppf <= 0) continue; + const baseX = rect.x + TRIM.handleWidth; + const bodyTop = rect.y + CLIP.labelBarHeight; + const bodyH = rect.height - CLIP.labelBarHeight; + for (const kf of track2.keyframes) { + if (kf.frame < 0 || kf.frame > clip.durationFrames) continue; + const kx = baseX + kf.frame * ppf; + const c = Math.max(0, Math.min(1, kf.value)); + const ky = bodyTop + bodyH * (1 - c); + const dx = docX - kx; + const dy = docY - ky; + if (dx * dx + dy * dy <= VOLUME_KF_HIT_RADIUS * VOLUME_KF_HIT_RADIUS) { + return { clipId: clip.id, frame: kf.frame }; + } + } + } + } + return null; +} diff --git a/web/src/components/timeline/timelineCanvas.ts b/web/src/components/timeline/timelineCanvas.ts index be676b2..f5fd619 100644 --- a/web/src/components/timeline/timelineCanvas.ts +++ b/web/src/components/timeline/timelineCanvas.ts @@ -7,8 +7,9 @@ import { BG, BORDER, TEXT } from "../../lib/theme"; import { clipRect, trackDisplayHeight, trackY } from "../../lib/geometry"; +import { linkOffsetForClip } from "../../lib/clip"; import { drawClip } from "./clipRenderer"; -import type { Timeline } from "../../lib/types"; +import type { Timeline, ClipType } from "../../lib/types"; export interface PaintState { timeline: Timeline; @@ -42,8 +43,20 @@ export interface PaintState { /** A live move/trim, projected for ghost rendering. */ export type DragPaint = - | { kind: "move"; ids: Set; deltaFrames: number; trackDelta: number } - | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number }; + | { + kind: "move"; + ids: Set; + deltaFrames: number; + trackDelta: number; + /** Option/Alt-drag duplicate: ghost renders with a "+" badge. */ + isDuplicate?: boolean; + /** Dropping below the last track creates a new track of this type; the + * ghost renders at the new-track position and a dashed indicator is + * drawn below the last track. `undefined` = drop on an existing track. */ + newTrackType?: ClipType; + } + | { kind: "trim"; clipId: string; edge: "left" | "right"; deltaFrames: number } + | { kind: "volumeKf"; clipId: string; fromFrame: number; ghostFrame: number }; export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { const { timeline, pixelsPerFrame, trackHeights, width, dpr, scrollLeft, scrollTop } = s; @@ -77,21 +90,51 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // 3. Clips (skip those fully outside the visible window). A clip being dragged // is drawn at its live (offset) position as a ghost so it follows the cursor. const drag = s.drag; + // New-track drop indicator: a dashed zone below the last track showing where + // the new track will be created (upstream `placeClip` auto-track-creation). + if (drag?.kind === "move" && drag.newTrackType && timeline.tracks.length > 0) { + const newTrackY = trackY(timeline, timeline.tracks.length, trackHeights); + const newTrackH = trackDisplayHeight(timeline.tracks[0], trackHeights); // default height + 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([]); + } + } for (let ti = 0; ti < timeline.tracks.length; ti++) { const track = timeline.tracks[ti]; for (const clip of track.clips) { let rect = clipRect(timeline, ti, clip, pixelsPerFrame, trackHeights); let ghost = false; + let isDuplicate = false; if (drag?.kind === "move" && drag.ids.has(clip.id)) { - const nti = Math.max(0, Math.min(timeline.tracks.length - 1, ti + drag.trackDelta)); - rect = clipRect( - timeline, - nti, - { ...clip, startFrame: clip.startFrame + drag.deltaFrames }, - pixelsPerFrame, - trackHeights, - ); + if (drag.newTrackType) { + // Dropping on a new track: render the ghost at the new-track Y + // (below the last existing track) using a default track height. + const newTrackY = trackY(timeline, timeline.tracks.length, trackHeights); + const ghostH = trackDisplayHeight(timeline.tracks[0], trackHeights) - 4; + rect = { + x: (clip.startFrame + drag.deltaFrames) * pixelsPerFrame, + y: newTrackY + 2, + width: clip.durationFrames * pixelsPerFrame, + height: ghostH, + }; + } else { + const nti = Math.max(0, Math.min(timeline.tracks.length - 1, ti + drag.trackDelta)); + rect = clipRect( + timeline, + nti, + { ...clip, startFrame: clip.startFrame + drag.deltaFrames }, + pixelsPerFrame, + trackHeights, + ); + } ghost = true; + isDuplicate = drag.isDuplicate === true; } else if (drag?.kind === "trim" && drag.clipId === clip.id) { const dx = drag.deltaFrames * pixelsPerFrame; rect = @@ -101,6 +144,13 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { ghost = true; } if (rect.x + rect.width < scrollLeft || rect.x > visRight) continue; + // Volume-kf drag ghost: when this clip is the one being dragged, tell the + // renderer to draw the grabbed dot at its ghost frame instead of the + // original, so the dot follows the cursor (SPEC §5.4). + const volumeKfGhost = + drag?.kind === "volumeKf" && drag.clipId === clip.id + ? { fromFrame: drag.fromFrame, ghostFrame: drag.ghostFrame } + : undefined; drawClip(ctx, clip, rect, { isSelected: s.selectedClipIds.has(clip.id), fps: timeline.fps, @@ -109,6 +159,9 @@ export function paintTimeline(ctx: CanvasRenderingContext2D, s: PaintState) { // asset's file is offline. missing: clip.mediaType !== "text" && s.missingMediaRefs.has(clip.mediaRef), ghost, + linkOffset: linkOffsetForClip(timeline, clip.id), + volumeKfGhost, + isDuplicate, }); } } diff --git a/web/src/hooks/useKeyboardShortcuts.ts b/web/src/hooks/useKeyboardShortcuts.ts index 6a5b3b8..f15a765 100644 --- a/web/src/hooks/useKeyboardShortcuts.ts +++ b/web/src/hooks/useKeyboardShortcuts.ts @@ -8,6 +8,8 @@ import { useEffect } from "react"; import { useEditorUiStore } from "../store/uiStore"; import { useProjectStore } from "../store/projectStore"; +import { useClipboardStore } from "../store/clipboardStore"; +import { t } from "../i18n"; import * as edit from "../store/editActions"; import { saveCurrentProject } from "../store/projectActions"; import { ZOOM } from "../lib/theme"; @@ -102,6 +104,22 @@ export function useKeyboardShortcuts() { return; } return; + case "KeyC": + e.preventDefault(); + edit.copyClips(); + return; + case "KeyX": + e.preventDefault(); + void edit.cutClips(); + return; + case "KeyV": + e.preventDefault(); + if (!useClipboardStore.getState().hasContent) { + useEditorUiStore.getState().pushToast(t("edit.clipboardEmpty")); + return; + } + void edit.pasteClipsAtPlayhead(); + return; } return; } diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7aac2fa..b25d2d2 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -91,6 +91,10 @@ const zh: Dict = { "inspector.section.playback": "播放", "inspector.section.format": "格式", "inspector.section.text": "文本内容", + "inspector.section.position": "位置", + "inspector.section.crop": "裁剪", + "inspector.section.flip": "翻转", + "inspector.section.fade": "淡入淡出", "inspector.field.volume": "音量", "inspector.field.scale": "缩放", "inspector.field.rotation": "旋转", @@ -100,6 +104,22 @@ const zh: Dict = { "inspector.field.frameRate": "帧率", "inspector.field.aspectRatio": "宽高比", "inspector.field.duration": "时长", + "inspector.field.positionX": "X 位置", + "inspector.field.positionY": "Y 位置", + "inspector.field.cropLeft": "左侧", + "inspector.field.cropTop": "顶部", + "inspector.field.cropRight": "右侧", + "inspector.field.cropBottom": "底部", + "inspector.field.flipHorizontal": "水平翻转", + "inspector.field.flipVertical": "垂直翻转", + "inspector.field.fadeInFrames": "淡入帧数", + "inspector.field.fadeOutFrames": "淡出帧数", + "inspector.field.fadeInInterpolation": "淡入插值", + "inspector.field.fadeOutInterpolation": "淡出插值", + "inspector.interpolation.linear": "线性", + "inspector.interpolation.hold": "保持", + "inspector.interpolation.smooth": "平滑", + "inspector.animatedHint": "已在关键帧面板动画化", "inspector.keyframes": "关键帧", "inspector.keyframes.stamp": "在播放头处盖章", "inspector.keyframes.clear": "清除动画", @@ -116,6 +136,10 @@ const zh: Dict = { "inspector.keyframes.property.volume": "音量", "inspector.keyframes.empty": "无关键帧", "inspector.textPlaceholder": "输入文本…", + "inspector.swapMedia": "替换媒体", + "inspector.swapMediaTitle": "选择新媒体", + "inspector.swapMediaEmpty": "媒体库为空,请先导入媒体。", + "inspector.swapMediaCurrent": "当前", // Toolbar "toolbar.undo": "撤销 (⌘Z)", @@ -134,6 +158,17 @@ const zh: Dict = { "timeline.syncLock": "同步锁定", "timeline.dropHint": "将媒体拖到此处开始", + // Clip context menu (right-click) + "contextMenu.split": "在播放头处分割", + "contextMenu.delete": "删除", + "contextMenu.link": "链接", + "contextMenu.unlink": "取消链接", + // Disabled placeholders (issue #93 acceptance: menu must list these even if stub) + "contextMenu.swapMedia": "替换媒体", + "contextMenu.saveAsMedia": "另存为媒体", + "contextMenu.extractAudio": "提取音频", + "swapMedia.noCandidates": "没有同类型素材可替换", + // Preview "preview.fit": "适应", "preview.timelineTab": "时间线", @@ -197,6 +232,12 @@ const zh: Dict = { "common.cancel": "取消", "common.open": "打开", + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "复制 (⌘C)", + "edit.cut": "剪切 (⌘X)", + "edit.paste": "粘贴 (⌘V)", + "edit.clipboardEmpty": "剪贴板为空", + // Global asset library (#56) "library.title": "素材库", "library.entry": "素材库", @@ -290,6 +331,10 @@ const en: Dict = { "inspector.section.playback": "Playback", "inspector.section.format": "Format", "inspector.section.text": "Text Content", + "inspector.section.position": "Position", + "inspector.section.crop": "Crop", + "inspector.section.flip": "Flip", + "inspector.section.fade": "Fade", "inspector.field.volume": "Volume", "inspector.field.scale": "Scale", "inspector.field.rotation": "Rotation", @@ -299,6 +344,22 @@ const en: Dict = { "inspector.field.frameRate": "Frame Rate", "inspector.field.aspectRatio": "Aspect Ratio", "inspector.field.duration": "Duration", + "inspector.field.positionX": "X Position", + "inspector.field.positionY": "Y Position", + "inspector.field.cropLeft": "Left", + "inspector.field.cropTop": "Top", + "inspector.field.cropRight": "Right", + "inspector.field.cropBottom": "Bottom", + "inspector.field.flipHorizontal": "Flip Horizontal", + "inspector.field.flipVertical": "Flip Vertical", + "inspector.field.fadeInFrames": "Fade In Frames", + "inspector.field.fadeOutFrames": "Fade Out Frames", + "inspector.field.fadeInInterpolation": "Fade In Interpolation", + "inspector.field.fadeOutInterpolation": "Fade Out Interpolation", + "inspector.interpolation.linear": "Linear", + "inspector.interpolation.hold": "Hold", + "inspector.interpolation.smooth": "Smooth", + "inspector.animatedHint": "Animated in the keyframes panel", "inspector.keyframes": "Keyframes", "inspector.keyframes.stamp": "Stamp at Playhead", "inspector.keyframes.clear": "Clear Animation", @@ -315,6 +376,10 @@ const en: Dict = { "inspector.keyframes.property.volume": "Volume", "inspector.keyframes.empty": "No keyframes", "inspector.textPlaceholder": "Enter text…", + "inspector.swapMedia": "Swap Media", + "inspector.swapMediaTitle": "Select New Media", + "inspector.swapMediaEmpty": "Media library is empty. Import media first.", + "inspector.swapMediaCurrent": "Current", "toolbar.undo": "Undo (⌘Z)", "toolbar.redo": "Redo (⇧⌘Z)", @@ -331,6 +396,17 @@ const en: Dict = { "timeline.syncLock": "Sync lock", "timeline.dropHint": "Drop media here to start", + // Clip context menu (right-click) + "contextMenu.split": "Split at Playhead", + "contextMenu.delete": "Delete", + "contextMenu.link": "Link", + "contextMenu.unlink": "Unlink", + // Disabled placeholders (issue #93 acceptance: menu must list these even if stub) + "contextMenu.swapMedia": "Swap Media", + "contextMenu.saveAsMedia": "Save as Media", + "contextMenu.extractAudio": "Extract Audio", + "swapMedia.noCandidates": "No compatible media to swap", + "preview.fit": "Fit", "preview.timelineTab": "Timeline", "preview.noMedia": "No media", @@ -389,6 +465,12 @@ const en: Dict = { "common.cancel": "Cancel", "common.open": "Open", + // Edit (copy / cut / paste, Issue #94) + "edit.copy": "Copy (⌘C)", + "edit.cut": "Cut (⌘X)", + "edit.paste": "Paste (⌘V)", + "edit.clipboardEmpty": "Clipboard is empty", + // Global asset library (#56) "library.title": "Library", "library.entry": "Library", diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index 6947974..cfb3c93 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -6,7 +6,15 @@ import { TRACK_COLOR } from "./theme"; import { formatClipDuration } from "./geometry"; -import type { Clip, ClipType, TrimEditReq } from "./types"; +import type { + AnimPair, + Clip, + ClipType, + Crop, + KeyframeTrack, + Timeline, + TrimEditReq, +} from "./types"; export function trackColor(type: ClipType): string { return TRACK_COLOR[type] ?? TRACK_COLOR.video; @@ -31,6 +39,21 @@ export function isLinked(clip: Clip): boolean { return clip.linkGroupId != null; } +/** Whether `clip` is alone in its link group (SPEC §5.10 "单链组" gate for + * Swap Media). True when the clip has no `linkGroupId`, or when no OTHER clip + * in the timeline shares the same `linkGroupId`. A multi-clip link group + * (e.g. linked A/V pair) disables Swap Media to avoid desyncing the partners. + * 1:1 with upstream `TimelineView.menu` Swap Media availability condition. */ +export function isSingleLinkGroup(clip: Clip, timeline: Timeline): boolean { + if (!clip.linkGroupId) return true; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.id !== clip.id && c.linkGroupId === clip.linkGroupId) return false; + } + } + return true; +} + /** Which edge a trim drag grabs. */ export type TrimEdge = "left" | "right"; @@ -112,3 +135,250 @@ export function trimToPlayheadEdits(clips: Clip[], frame: number, edge: TrimEdge } return edits; } + +// MARK: - Live sampling (1:1 port of opentake-domain::Clip::*_at) +// +// These mirror the Rust `Clip` sampling methods so the Inspector can display +// the value at the current playhead frame (live preview), matching upstream +// `InspectorView.livePreview`. Frames are absolute timeline frames; the helpers +// convert to clip-relative offsets internally. See `crates/opentake-domain/src/clip.rs`. + +/** `smoothstep(t) = t*t*(3 - 2t)`. 1:1 with `keyframe::smoothstep`. */ +function smoothstep(t: number): number { + return t * t * (3.0 - 2.0 * t); +} + +/** Linear amplitude <-> dB mapping (1:1 port of `VolumeScale`). */ +const VOLUME_FLOOR_DB = -60.0; +const VOLUME_CEILING_DB = 15.0; + +export function dbFromLinear(linear: number): number { + if (linear > 0.0) { + return Math.min(VOLUME_CEILING_DB, Math.max(VOLUME_FLOOR_DB, 20.0 * Math.log10(linear))); + } + return VOLUME_FLOOR_DB; +} + +export function linearFromDb(db: number): number { + if (db > VOLUME_FLOOR_DB) { + return Math.pow(10, Math.min(db, VOLUME_CEILING_DB) / 20.0); + } + return 0.0; +} + +/** Interpolate between two scalar keyframe values. */ +function lerpNumber(a: number, b: number, t: number): number { + return a + (b - a) * t; +} + +/** Interpolate between two `AnimPair` values component-wise. */ +function lerpAnimPair(a: AnimPair, b: AnimPair, t: number): AnimPair { + return { a: lerpNumber(a.a, b.a, t), b: lerpNumber(a.b, b.b, t) }; +} + +/** Interpolate between two `Crop` values component-wise. */ +function lerpCrop(a: Crop, b: Crop, t: number): Crop { + return { + left: lerpNumber(a.left, b.left, t), + top: lerpNumber(a.top, b.top, t), + right: lerpNumber(a.right, b.right, t), + bottom: lerpNumber(a.bottom, b.bottom, t), + }; +} + +/** + * Sample a keyframe track at clip-relative `frame`, clamping at the endpoints + * (no extrapolation). Inside a span, the *left* keyframe's `interpolationOut` + * selects hold / linear / smooth. 1:1 port of `KeyframeTrack::sample`. + */ +export function sampleKeyframeTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: V, + lerp: (a: V, b: V, t: number) => V, +): V { + if (!track || track.keyframes.length === 0) return fallback; + const kfs = track.keyframes; + if (kfs.length === 1) return kfs[0].value; + if (frame <= kfs[0].frame) return kfs[0].value; + const last = kfs[kfs.length - 1]; + if (frame >= last.frame) return last.value; + + let bIdx = kfs.findIndex((k) => k.frame > frame); + if (bIdx === -1) return last.value; + const a = kfs[bIdx - 1]; + const b = kfs[bIdx]; + const raw = (frame - a.frame) / (b.frame - a.frame); + switch (a.interpolationOut) { + case "hold": + return a.value; + case "linear": + return lerp(a.value, b.value, raw); + case "smooth": + return lerp(a.value, b.value, smoothstep(raw)); + } +} + +/** Sample a scalar (`number`) keyframe track. */ +function sampleScalarTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: number, +): number { + return sampleKeyframeTrack(track, frame, fallback, lerpNumber); +} + +/** Sample an `AnimPair` keyframe track. */ +function samplePairTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: AnimPair, +): AnimPair { + return sampleKeyframeTrack(track, frame, fallback, lerpAnimPair); +} + +/** Sample a `Crop` keyframe track. */ +function sampleCropTrack( + track: KeyframeTrack | undefined, + frame: number, + fallback: Crop, +): Crop { + return sampleKeyframeTrack(track, frame, fallback, lerpCrop); +} + +/** Absolute timeline frame -> clip-relative offset used by track storage. */ +function keyframeOffset(clip: Clip, frame: number): number { + return frame - clip.startFrame; +} + +/** A track is active iff it holds at least one keyframe. */ +function trackIsActive(track: KeyframeTrack | undefined): boolean { + return !!track && track.keyframes.length > 0; +} + +/** + * 0..=1 envelope from the fade head/tail ramps. `min(in, out)`. Returns 0 + * outside `[0, durationFrames]` (closed interval, as upstream). 1:1 port of + * `Clip::fade_multiplier`. + */ +export function fadeMultiplier(clip: Clip, frame: number): number { + const rel = frame - clip.startFrame; + if (rel < 0 || rel > clip.durationFrames) return 0.0; + const inMul = + clip.fadeInFrames > 0 + ? clip.fadeInInterpolation === "smooth" + ? smoothstep(Math.min(rel / clip.fadeInFrames, 1.0)) + : Math.min(rel / clip.fadeInFrames, 1.0) + : 1.0; + const outRem = clip.durationFrames - rel; + const outMul = + clip.fadeOutFrames > 0 + ? clip.fadeOutInterpolation === "smooth" + ? smoothstep(Math.min(outRem / clip.fadeOutFrames, 1.0)) + : Math.min(outRem / clip.fadeOutFrames, 1.0) + : 1.0; + return Math.min(inMul, outMul); +} + +/** Authored opacity without the fade envelope. 1:1 port of `Clip::raw_opacity_at`. */ +export function rawOpacityAt(clip: Clip, frame: number): number { + return sampleScalarTrack(clip.opacityTrack, keyframeOffset(clip, frame), clip.opacity); +} + +/** + * Effective opacity at `frame`: authored value × fade envelope (visual clips + * only; audio short-circuits before fade). 1:1 port of `Clip::opacity_at`. + */ +export function opacityAt(clip: Clip, frame: number): number { + const base = rawOpacityAt(clip, frame); + if (clip.mediaType === "audio" || (clip.fadeInFrames === 0 && clip.fadeOutFrames === 0)) { + return base; + } + return base * fadeMultiplier(clip, frame); +} + +/** + * Effective linear volume: keyframe envelope (dB) first, fade ramp on top, + * static volume as outer gain. 1:1 port of `Clip::volume_at`. + */ +export function volumeAt(clip: Clip, frame: number): number { + const kfGain = trackIsActive(clip.volumeTrack) + ? linearFromDb(sampleScalarTrack(clip.volumeTrack, keyframeOffset(clip, frame), 0.0)) + : 1.0; + return clip.volume * kfGain * fadeMultiplier(clip, frame); +} + +/** Sampled rotation (degrees) at `frame`. 1:1 port of `Clip::rotation_at`. */ +export function rotationAt(clip: Clip, frame: number): number { + return sampleScalarTrack(clip.rotationTrack, keyframeOffset(clip, frame), clip.transform.rotation); +} + +/** Sampled `(width, height)` at `frame`. 1:1 port of `Clip::size_at`. */ +export function sizeAt(clip: Clip, frame: number): [number, number] { + const fallback: AnimPair = { a: clip.transform.width, b: clip.transform.height }; + const s = samplePairTrack(clip.scaleTrack, keyframeOffset(clip, frame), fallback); + return [s.a, s.b]; +} + +/** Sampled top-left (normalized canvas space) at `frame`. 1:1 port of `Clip::top_left_at`. */ +export function topLeftAt(clip: Clip, frame: number): { x: number; y: number } { + if (trackIsActive(clip.positionTrack)) { + const p = samplePairTrack(clip.positionTrack, keyframeOffset(clip, frame), { a: 0, b: 0 }); + return { x: p.a, y: p.b }; + } + const [w, h] = sizeAt(clip, frame); + return { + x: clip.transform.centerX - w / 2.0, + y: clip.transform.centerY - h / 2.0, + }; +} + +/** Sampled crop insets at `frame`. 1:1 port of `Clip::crop_at`. */ +export function cropAt(clip: Clip, frame: number): Crop { + return sampleCropTrack(clip.cropTrack, keyframeOffset(clip, frame), clip.crop); +} + +/** Whether any transform-related track is active. 1:1 port of `Clip::has_transform_animation`. */ +export function hasTransformAnimation(clip: Clip): boolean { + return ( + trackIsActive(clip.positionTrack) || + trackIsActive(clip.scaleTrack) || + trackIsActive(clip.rotationTrack) + ); +} + + +/** + * Frame offset of `clipId` within its link group, relative to the group's lead + * (earliest-starting) clip. Returns `null` when the clip isn't linked, or when + * it IS the lead (offset 0 → no badge needed). A positive result means this + * clip starts LATER than the lead (e.g. audio trailing video by 3 frames → 3); + * negative means it starts earlier. Used by the offset badge renderer (SPEC + * §5.4 linked-offset indicator). + */ +export function linkOffsetForClip(timeline: Timeline, clipId: string): number | null { + let target: Clip | null = null; + let groupId: string | undefined; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.id === clipId) { + target = c; + groupId = c.linkGroupId; + } + } + } + if (!target || !groupId) return null; + // Collect every clip in the same link group, find the lead (min startFrame). + let leadStart = Number.POSITIVE_INFINITY; + for (const track of timeline.tracks) { + for (const c of track.clips) { + if (c.linkGroupId === groupId && c.startFrame < leadStart) { + leadStart = c.startFrame; + } + } + } + if (!Number.isFinite(leadStart)) return null; + const offset = target.startFrame - leadStart; + if (offset === 0) return null; // lead clip → no badge + return offset; +} diff --git a/web/src/lib/snap.ts b/web/src/lib/snap.ts index 466c687..4b918b9 100644 --- a/web/src/lib/snap.ts +++ b/web/src/lib/snap.ts @@ -85,21 +85,54 @@ export function findSnap( * Multi-probe snap (SPEC §5.8 `findSnap probeOffsets`): for a set of probe * offsets (e.g. start + end edges of all selected clips), find the snap that * yields the smallest correction, returning the delta to apply. + * + * `currentlySnapped` carries the previously snapped `{frame, probeOffset}` so + * the sticky band (1.5x) keeps the same probe engaged across pointer events + * (SnapEngine.swift:64-93) — without it, the snap would toggle off/on near the + * threshold edge and the clip would jitter. `probeOffsets` is a parallel array + * of stable per-probe identifiers (e.g. the frame offset from the lead clip's + * start); when omitted the probe index is used. The snapped `probeOffset` is + * returned so the caller can feed it back in on the next move. */ export function findSnapDelta( probeFrames: number[], targets: SnapTarget[], pixelsPerFrame: number, -): { delta: number; snappedFrame: number } | null { - let best: { delta: number; snappedFrame: number } | null = null; + currentlySnapped: { frame: number; probeOffset: number } | null = null, + probeOffsets?: number[], +): { delta: number; snappedFrame: number; probeOffset: number } | null { + if (probeFrames.length === 0) return null; + const offsets = probeOffsets ?? probeFrames.map((_, i) => i); + const baseThresholdFrames = SNAP.thresholdPixels / pixelsPerFrame; + const stickyBand = baseThresholdFrames * SNAP.stickyMultiplier; + + // Sticky: keep the held target engaged while its owning probe stays within + // the sticky band (1.5x). This mirrors findSnap's sticky branch but tracks + // WHICH probe was snapped via probeOffset. + if (currentlySnapped !== null) { + const idx = offsets.indexOf(currentlySnapped.probeOffset); + if (idx >= 0) { + const probe = probeFrames[idx]; + if (Math.abs(probe - currentlySnapped.frame) <= stickyBand) { + return { + delta: currentlySnapped.frame - probe, + snappedFrame: currentlySnapped.frame, + probeOffset: currentlySnapped.probeOffset, + }; + } + } + } + + let best: { delta: number; snappedFrame: number; probeOffset: number } | null = null; let bestDist = Number.POSITIVE_INFINITY; - for (const probe of probeFrames) { + for (let i = 0; i < probeFrames.length; i++) { + const probe = probeFrames[i]; const res = findSnap(probe, targets, pixelsPerFrame, null); if (!res) continue; const dist = Math.abs(res.frame - probe); if (dist < bestDist) { bestDist = dist; - best = { delta: res.frame - probe, snappedFrame: res.frame }; + best = { delta: res.frame - probe, snappedFrame: res.frame, probeOffset: offsets[i] }; } } return best; diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4c64386..5f3de74 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -123,6 +123,18 @@ export interface ClipPropertiesReq { opacity?: number; transform?: Transform; textContent?: string; + /** Per-clip crop insets (normalized 0–1). Clears `cropTrack` on the backend. */ + crop?: Crop; + /** Fade-in length in frames. Clamped to clip duration on the backend. */ + fadeInFrames?: number; + /** Fade-out length in frames. Clamped to clip duration on the backend. */ + fadeOutFrames?: number; + fadeInInterpolation?: Interpolation; + fadeOutInterpolation?: Interpolation; + /** Writes to `transform.flipHorizontal` on the backend. */ + flipHorizontal?: boolean; + /** Writes to `transform.flipVertical` on the backend. */ + flipVertical?: boolean; } /** Which property a keyframe track targets (mirror of `KeyframeProperty`). */ @@ -152,6 +164,12 @@ export type EditRequest = | { type: "addClips"; entries: ClipEntryReq[] } | { type: "insertClips"; trackIndex: number; atFrame: number; entries: ClipEntryReq[] } | { type: "moveClips"; moves: ClipMoveReq[] } + | { + type: "duplicateClips"; + clipIds: string[]; + offsetFrames: number; + targetTrackIndexes: number[]; + } | { type: "removeClips"; clipIds: string[] } | { type: "splitClip"; clipId: string; atFrame: number } | { type: "trimClips"; edits: TrimEditReq[] } @@ -176,7 +194,8 @@ export type EditRequest = syncLocked?: boolean; } | { type: "createFolder"; name: string; parentFolderId?: string } - | { type: "moveToFolder"; assetIds: string[]; folderId?: string }; + | { type: "moveToFolder"; assetIds: string[]; folderId?: string } + | { type: "swapMedia"; clipId: string; mediaRef: string }; export interface TextEntryReq { trackIndex: number; diff --git a/web/src/store/clipboardStore.ts b/web/src/store/clipboardStore.ts new file mode 100644 index 0000000..427f705 --- /dev/null +++ b/web/src/store/clipboardStore.ts @@ -0,0 +1,40 @@ +/** + * Front-end clipboard store for copy/cut/paste (Issue #94). Holds snapshots of + * the selected clips at copy time plus the source first-frame, so a paste can + * re-place them relative to the current playhead without touching the original + * clips. `linkGroupId` is cleared on paste so the backend re-assigns new + * groups (mirrors upstream `pasteClipsAtPlayhead` link re-reflection). + * + * The store is UI-only: the authoritative timeline lives in Rust; this is just + * a transient paste buffer, never persisted. + */ + +import { create } from "zustand"; +import type { Clip } from "../lib/types"; + +export interface ClipboardEntry { + /** Deep snapshot of the clip at copy time. */ + clip: Clip; + /** Track index the clip lived on when copied. Used to preserve track + * placement on paste (upstream behavior). */ + sourceTrackIndex: number; +} + +interface ClipboardState { + entries: ClipboardEntry[]; + /** The smallest `startFrame` among copied clips. Paste offsets every clip + * by `activeFrame - sourceFirstFrame` so the group lands at the playhead. */ + sourceFirstFrame: number; + hasContent: boolean; + set: (entries: ClipboardEntry[], sourceFirstFrame: number) => void; + clear: () => void; +} + +export const useClipboardStore = create((set) => ({ + entries: [], + sourceFirstFrame: 0, + hasContent: false, + set: (entries, sourceFirstFrame) => + set({ entries, sourceFirstFrame, hasContent: entries.length > 0 }), + clear: () => set({ entries: [], sourceFirstFrame: 0, hasContent: false }), +})); diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7885792..f892efc 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -10,6 +10,7 @@ import { forceRefresh } from "./sync"; import { useEditorUiStore } from "./uiStore"; import { useProjectStore } from "./projectStore"; import { trimToPlayheadEdits } from "../lib/clip"; +import { useClipboardStore } from "./clipboardStore"; import type { Clip, ClipEntryReq, @@ -37,7 +38,7 @@ async function applyAndRefresh(cmd: Parameters[0]) { export async function addClips(entries: ClipEntryReq[]) { if (entries.length === 0) return; - await applyAndRefresh({ type: "addClips", entries }); + return applyAndRefresh({ type: "addClips", entries }); } export async function moveClips(moves: ClipMoveReq[]) { @@ -45,6 +46,26 @@ export async function moveClips(moves: ClipMoveReq[]) { await applyAndRefresh({ type: "moveClips", moves }); } +/** 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 + * `offsetFrames`, lands on `targetTrackIndexes[i]`, and clears the link group + * (a copy is not linked to the original's partners). Returns the new clip ids + * via the EditResult so the caller can select them. */ +export async function duplicateClips( + clipIds: string[], + offsetFrames: number, + targetTrackIndexes: number[], +) { + if (clipIds.length === 0) return; + return applyAndRefresh({ + type: "duplicateClips", + clipIds, + offsetFrames, + targetTrackIndexes, + }); +} + export async function removeClips(clipIds: string[]) { if (clipIds.length === 0) return; await applyAndRefresh({ type: "removeClips", clipIds }); @@ -151,6 +172,18 @@ export async function moveToFolder(assetIds: string[], folderId?: string) { await applyAndRefresh({ type: "moveToFolder", assetIds, folderId }); } +/** Replace a clip's media source in place, preserving all editing attributes + * (transform / crop / keyframe tracks / grade / masks / effects / fade / trim / + * speed / start / duration). 1:1 port of upstream + * `replaceClipMediaRef(resetTrim: false)`. The backend enforces a strict type + * match (`clip.mediaType == asset.type`), no-ops on same mediaRef, and + * cascades the swap across the link-group members that share the same old + * `mediaRef`; callers only need to pass the seed clip id and the candidate + * asset id. */ +export async function swapMedia(clipId: string, mediaRef: string) { + await applyAndRefresh({ type: "swapMedia", clipId, mediaRef }); +} + export async function undo() { await api.undo(); if (!isTauri) await forceRefresh(); @@ -389,3 +422,110 @@ export async function addTextClip() { ui.selectClips(new Set(res.affectedClipIds)); } } + +// MARK: - Clipboard (copy / cut / paste, Issue #94) +// +// Front-end paste buffer: copy snapshots the selected clips; paste re-places +// them at the playhead with a fresh `linkGroupId` (cleared so the backend +// re-assigns, mirroring upstream `pasteClipsAtPlayhead` link re-reflection). +// Track placement is preserved (clip stays on its original track index); if +// the target track no longer exists the clip is skipped. + +/** Collect selected clips with their track index into the clipboard store. + * If any selected clip belongs to a link group, the entire group is copied + * (mirrors upstream `copyClips` which expands the selection to include + * linked companions, so a paste reproduces the video+audio pair). */ +export function copyClips() { + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const ids = ui.selectedClipIds; + if (ids.size === 0) return; + // Expand selection to include linked companions. + const expanded = new Set(ids); + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (ids.has(clip.id) && clip.linkGroupId) { + for (let tj = 0; tj < tl.tracks.length; tj++) { + for (const c2 of tl.tracks[tj].clips) { + if (c2.linkGroupId === clip.linkGroupId) expanded.add(c2.id); + } + } + } + } + } + const entries: { clip: Clip; sourceTrackIndex: number }[] = []; + for (let ti = 0; ti < tl.tracks.length; ti++) { + for (const clip of tl.tracks[ti].clips) { + if (expanded.has(clip.id)) entries.push({ clip, sourceTrackIndex: ti }); + } + } + if (entries.length === 0) return; + const sourceFirstFrame = entries.reduce( + (min, e) => Math.min(min, e.clip.startFrame), + Number.POSITIVE_INFINITY, + ); + useClipboardStore.getState().set(entries, sourceFirstFrame); +} + +/** Copy then delete — the standard cut semantics. */ +export async function cutClips() { + copyClips(); + await deleteSelectedClips(); +} + +/** Paste clipboard entries at the current playhead. Each clip's `startFrame` + * is offset by `activeFrame - sourceFirstFrame`. After the clips are created, + * link groups are re-established: clips that shared a `linkGroupId` in the + * clipboard are re-linked via `linkClips` so the paste preserves video+audio + * linkage. Clips whose source track no longer exists are silently skipped + * (upstream drops them too). */ +export async function pasteClipsAtPlayhead() { + const cb = useClipboardStore.getState(); + if (!cb.hasContent || cb.entries.length === 0) return; + const ui = useEditorUiStore.getState(); + const tl = useProjectStore.getState().timeline; + const offset = ui.activeFrame - cb.sourceFirstFrame; + const entries: ClipEntryReq[] = []; + const sourceLinkGroups: (string | undefined)[] = []; + for (const e of cb.entries) { + if (e.sourceTrackIndex >= tl.tracks.length) continue; + const startFrame = Math.max(0, e.clip.startFrame + offset); + entries.push({ + mediaRef: e.clip.mediaRef, + mediaType: e.clip.mediaType, + sourceClipType: e.clip.sourceClipType, + trackIndex: e.sourceTrackIndex, + startFrame, + durationFrames: e.clip.durationFrames, + trimStartFrame: e.clip.trimStartFrame, + trimEndFrame: e.clip.trimEndFrame, + hasAudio: e.clip.mediaType === "audio" || e.clip.mediaType === "video", + // Don't auto-create a linked audio: the linked audio clip is already in + // the clipboard (copyClips expands link groups) and will be pasted as + // its own entry; addLinkedAudio=true would create a duplicate. + addLinkedAudio: false, + }); + sourceLinkGroups.push(e.clip.linkGroupId); + } + if (entries.length === 0) return; + const res = await addClips(entries); + if (!res || res.affectedClipIds.length === 0) return; + + // Re-establish link groups: map each old linkGroupId to the set of newly + // created clip ids, then call linkClips for each group. + const newGroupMap = new Map(); + for (let i = 0; i < res.affectedClipIds.length && i < sourceLinkGroups.length; i++) { + const oldGroup = sourceLinkGroups[i]; + if (!oldGroup) continue; + const newId = res.affectedClipIds[i]; + const arr = newGroupMap.get(oldGroup); + if (arr) arr.push(newId); + else newGroupMap.set(oldGroup, [newId]); + } + for (const ids of newGroupMap.values()) { + if (ids.length >= 2) await linkClips(ids); + } + + // Select the freshly pasted clips so the user can immediately move/trim them. + ui.selectClips(new Set(res.affectedClipIds)); +} diff --git a/web/src/store/uiStore.ts b/web/src/store/uiStore.ts index 768bfb5..c333062 100644 --- a/web/src/store/uiStore.ts +++ b/web/src/store/uiStore.ts @@ -104,6 +104,13 @@ interface UiState { // Media panel navigation mediaPanelCurrentFolderId: string | null; + /** Pending Swap Media flow (SPEC §5.10). When set, a media-picker modal is + * shown for the clip with this id; the picker pre-filters candidates by + * `item.type === clip.mediaType` (strict, mirroring backend + * `isAssetCompatibleWithPendingSwap`). `null` = no swap in flight. */ + pendingSwapClipId: string | null; + setPendingSwapClipId: (id: string | null) => void; + // Actions setActiveFrame: (frame: number) => void; setCurrentFrame: (frame: number) => void; @@ -143,6 +150,11 @@ interface UiState { setMediaTab: (tab: MediaTabId) => void; setMediaSubTab: (tab: MediaSubTabId) => void; setInspectorTab: (tab: InspectorTabId) => void; + + // Toast (transient message) + toast: { message: string; id: number } | null; + pushToast: (message: string) => void; + clearToast: () => void; } export const useEditorUiStore = create((set, get) => ({ @@ -186,6 +198,9 @@ export const useEditorUiStore = create((set, get) => ({ mediaPanelCurrentFolderId: null, + pendingSwapClipId: null, + setPendingSwapClipId: (pendingSwapClipId) => set({ pendingSwapClipId }), + setActiveFrame: (activeFrame) => set({ activeFrame }), setCurrentFrame: (currentFrame) => set({ currentFrame, activeFrame: currentFrame }), setPlaying: (isPlaying) => set({ isPlaying }), @@ -272,4 +287,8 @@ export const useEditorUiStore = create((set, get) => ({ setMediaTab: (mediaTab) => set({ mediaTab }), setMediaSubTab: (mediaSubTab) => set({ mediaSubTab }), setInspectorTab: (inspectorTab) => set({ inspectorTab }), + + toast: null, + pushToast: (message) => set({ toast: { message, id: Date.now() } }), + clearToast: () => set({ toast: null }), }));