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-ops/src/command.rs b/crates/opentake-ops/src/command.rs index f9c75a9..c5ef5c5 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -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; @@ -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. @@ -281,6 +297,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. @@ -400,6 +433,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), } } @@ -832,6 +866,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()); } @@ -1742,6 +1800,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> { diff --git a/crates/opentake-ops/tests/command_apply.rs b/crates/opentake-ops/tests/command_apply.rs index 91cf426..a5b8ba0 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, @@ -519,6 +521,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 +1203,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..6a38cc8 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,22 @@ pub enum EditRequest { entries: Vec, }, #[serde(rename_all = "camelCase")] - MoveClips { moves: Vec }, + MoveClips { + moves: Vec, + }, #[serde(rename_all = "camelCase")] - RemoveClips { clip_ids: Vec }, + RemoveClips { + clip_ids: Vec, + }, #[serde(rename_all = "camelCase")] - SplitClip { clip_id: String, at_frame: i32 }, + SplitClip { + clip_id: String, + at_frame: i32, + }, #[serde(rename_all = "camelCase")] - TrimClips { edits: Vec }, + TrimClips { + edits: Vec, + }, #[serde(rename_all = "camelCase")] SetClipProperties { clip_ids: Vec, @@ -189,17 +200,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 +240,10 @@ pub enum EditRequest { asset_ids: Vec, folder_id: Option, }, + SwapMedia { + clip_id: String, + media_ref: String, + }, } impl EditRequest { @@ -344,6 +371,9 @@ impl EditRequest { asset_ids, folder_id, }, + EditRequest::SwapMedia { clip_id, media_ref } => { + EditCommand::SwapMedia { clip_id, media_ref } + } }) } } @@ -449,6 +479,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 +506,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..e7973e5 100644 --- a/web/src/components/timeline/TimelineContainer.tsx +++ b/web/src/components/timeline/TimelineContainer.tsx @@ -25,6 +25,8 @@ import { TrackHeaderColumn } from "./TrackHeaderColumn"; import { Playhead } from "./Playhead"; import { SnapIndicator } from "./SnapIndicator"; import { hitTestClip, expandLinkGroup, clipsInRect, 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"; @@ -83,6 +85,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( @@ -576,6 +581,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/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..9aee63b 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,214 @@ 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) + ); +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4c64386..a35a19f 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`). */ @@ -176,7 +188,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..38e0f5e 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[]) { @@ -151,6 +152,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 +402,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 }), }));