diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 8d22aa0..f9c75a9 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -182,6 +182,36 @@ pub enum EditCommand { property: KeyframeProperty, payload: KeyframePayload, }, + /// Stamp a keyframe at `frame` (absolute timeline frame) using the clip's + /// current sampled value for `property`. Creates the track if absent. + StampKeyframe { + clip_id: String, + property: KeyframeProperty, + frame: i32, + }, + /// Remove the keyframe at `frame` (absolute timeline frame). Clears the track + /// to `None` when it becomes empty. + RemoveKeyframe { + clip_id: String, + property: KeyframeProperty, + frame: i32, + }, + /// Move a keyframe from `from_frame` to `to_frame` (both absolute timeline + /// frames). Refuses if `to_frame` is already occupied. + MoveKeyframe { + clip_id: String, + property: KeyframeProperty, + from_frame: i32, + to_frame: i32, + }, + /// Change the interpolation mode of the keyframe at `frame` (absolute timeline + /// frame). + SetKeyframeInterpolation { + clip_id: String, + property: KeyframeProperty, + frame: i32, + interpolation: opentake_domain::Interpolation, + }, /// Set (or clear with `None`) the color grade on one or more clips. SetColorGrade { clip_ids: Vec, @@ -313,6 +343,28 @@ pub fn apply( property, payload, } => set_keyframes(state, clip_id, property, payload), + EditCommand::StampKeyframe { + clip_id, + property, + frame, + } => stamp_keyframe(state, clip_id, property, frame), + EditCommand::RemoveKeyframe { + clip_id, + property, + frame, + } => remove_keyframe(state, clip_id, property, frame), + EditCommand::MoveKeyframe { + clip_id, + property, + from_frame, + to_frame, + } => move_keyframe(state, clip_id, property, from_frame, to_frame), + EditCommand::SetKeyframeInterpolation { + clip_id, + property, + frame, + interpolation, + } => set_keyframe_interpolation(state, clip_id, property, frame, interpolation), EditCommand::SetColorGrade { clip_ids, grade } => set_color_grade(state, clip_ids, grade), EditCommand::SetChromaKey { clip_ids, @@ -843,6 +895,314 @@ fn set_keyframes( ) } +fn stamp_keyframe( + state: &mut EditorState, + clip_id: String, + property: KeyframeProperty, + frame: i32, +) -> Result { + let loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + if !clip.contains(frame) { + return Err(EditError::Invalid(format!( + "Frame {frame} is outside clip range ({}..{})", + clip.start_frame, + clip.end_frame() + ))); + } + let summary = format!("Stamp keyframe on {clip_id}"); + transact( + state, + "Stamp Keyframe", + move |_| summary, + move |st| { + let loc = st.find_clip(&clip_id).expect("validated above"); + let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let rel = frame - clip.start_frame; + match property { + KeyframeProperty::Opacity => { + let v = clip.raw_opacity_at(frame); + let mut track = clip.opacity_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new(rel, v)); + clip.opacity_track = empty_to_none(track); + } + KeyframeProperty::Volume => { + let v = clip + .volume_track + .as_ref() + .map(|t| t.sample(rel, 0.0)) + .unwrap_or(0.0); + let mut track = clip.volume_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new(rel, v)); + clip.volume_track = empty_to_none(track); + } + KeyframeProperty::Rotation => { + let v = clip.rotation_at(frame); + let mut track = clip.rotation_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new(rel, v)); + clip.rotation_track = empty_to_none(track); + } + KeyframeProperty::Position => { + let tl = clip.top_left_at(frame); + let v = opentake_domain::AnimPair::new(tl.x, tl.y); + let mut track = clip.position_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new(rel, v)); + clip.position_track = empty_to_none(track); + } + KeyframeProperty::Scale => { + let sz = clip.size_at(frame); + let v = opentake_domain::AnimPair::new(sz.0, sz.1); + let mut track = clip.scale_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new(rel, v)); + clip.scale_track = empty_to_none(track); + } + KeyframeProperty::Crop => { + let v = clip.crop_at(frame); + let mut track = clip.crop_track.take().unwrap_or_default(); + track.upsert(opentake_domain::Keyframe::new(rel, v)); + clip.crop_track = empty_to_none(track); + } + } + Ok(vec![clip_id]) + }, + ) +} + +fn remove_keyframe( + state: &mut EditorState, + clip_id: String, + property: KeyframeProperty, + frame: i32, +) -> Result { + let loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let rel = frame - clip.start_frame; + let has_kf = match property { + KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, rel), + KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, rel), + KeyframeProperty::Rotation => has_keyframe_at(&clip.rotation_track, rel), + KeyframeProperty::Position => has_keyframe_at(&clip.position_track, rel), + KeyframeProperty::Scale => has_keyframe_at(&clip.scale_track, rel), + KeyframeProperty::Crop => has_keyframe_at(&clip.crop_track, rel), + }; + if !has_kf { + return Err(EditError::Invalid(format!( + "Keyframe not found at frame {frame}" + ))); + } + let summary = format!("Remove keyframe on {clip_id}"); + transact( + state, + "Remove Keyframe", + move |_| summary, + move |st| { + let loc = st.find_clip(&clip_id).expect("validated above"); + let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let rel = frame - clip.start_frame; + match property { + KeyframeProperty::Opacity => { + if let Some(mut t) = clip.opacity_track.take() { + t.remove(rel); + clip.opacity_track = empty_to_none(t); + } + } + KeyframeProperty::Volume => { + if let Some(mut t) = clip.volume_track.take() { + t.remove(rel); + clip.volume_track = empty_to_none(t); + } + } + KeyframeProperty::Rotation => { + if let Some(mut t) = clip.rotation_track.take() { + t.remove(rel); + clip.rotation_track = empty_to_none(t); + } + } + KeyframeProperty::Position => { + if let Some(mut t) = clip.position_track.take() { + t.remove(rel); + clip.position_track = empty_to_none(t); + } + } + KeyframeProperty::Scale => { + if let Some(mut t) = clip.scale_track.take() { + t.remove(rel); + clip.scale_track = empty_to_none(t); + } + } + KeyframeProperty::Crop => { + if let Some(mut t) = clip.crop_track.take() { + t.remove(rel); + clip.crop_track = empty_to_none(t); + } + } + } + Ok(vec![clip_id]) + }, + ) +} + +fn move_keyframe( + state: &mut EditorState, + clip_id: String, + property: KeyframeProperty, + from_frame: i32, + to_frame: i32, +) -> Result { + let loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let from_rel = from_frame - clip.start_frame; + let to_rel = to_frame - clip.start_frame; + let has_source = match property { + KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, from_rel), + KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, from_rel), + KeyframeProperty::Rotation => has_keyframe_at(&clip.rotation_track, from_rel), + KeyframeProperty::Position => has_keyframe_at(&clip.position_track, from_rel), + KeyframeProperty::Scale => has_keyframe_at(&clip.scale_track, from_rel), + KeyframeProperty::Crop => has_keyframe_at(&clip.crop_track, from_rel), + }; + if !has_source { + return Err(EditError::Invalid(format!( + "Keyframe not found at frame {from_frame}" + ))); + } + // Validate target frame is within clip range (half-open [start, end)). + if !clip.contains(to_frame) { + return Err(EditError::Invalid(format!( + "Target frame {to_frame} is outside clip range ({}..{})", + clip.start_frame, + clip.end_frame() + ))); + } + if from_rel != to_rel { + let target_occupied = match property { + KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, to_rel), + KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, to_rel), + KeyframeProperty::Rotation => has_keyframe_at(&clip.rotation_track, to_rel), + KeyframeProperty::Position => has_keyframe_at(&clip.position_track, to_rel), + KeyframeProperty::Scale => has_keyframe_at(&clip.scale_track, to_rel), + KeyframeProperty::Crop => has_keyframe_at(&clip.crop_track, to_rel), + }; + if target_occupied { + return Err(EditError::Invalid(format!( + "Target frame {to_frame} already occupied" + ))); + } + } + let summary = format!("Move keyframe on {clip_id}"); + transact( + state, + "Move Keyframe", + move |_| summary, + move |st| { + let loc = st.find_clip(&clip_id).expect("validated above"); + let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let from_rel = from_frame - clip.start_frame; + let to_rel = to_frame - clip.start_frame; + match property { + KeyframeProperty::Opacity => { + if let Some(mut t) = clip.opacity_track.take() { + t.move_keyframe(from_rel, to_rel); + clip.opacity_track = empty_to_none(t); + } + } + KeyframeProperty::Volume => { + if let Some(mut t) = clip.volume_track.take() { + t.move_keyframe(from_rel, to_rel); + clip.volume_track = empty_to_none(t); + } + } + KeyframeProperty::Rotation => { + if let Some(mut t) = clip.rotation_track.take() { + t.move_keyframe(from_rel, to_rel); + clip.rotation_track = empty_to_none(t); + } + } + KeyframeProperty::Position => { + if let Some(mut t) = clip.position_track.take() { + t.move_keyframe(from_rel, to_rel); + clip.position_track = empty_to_none(t); + } + } + KeyframeProperty::Scale => { + if let Some(mut t) = clip.scale_track.take() { + t.move_keyframe(from_rel, to_rel); + clip.scale_track = empty_to_none(t); + } + } + KeyframeProperty::Crop => { + if let Some(mut t) = clip.crop_track.take() { + t.move_keyframe(from_rel, to_rel); + clip.crop_track = empty_to_none(t); + } + } + } + Ok(vec![clip_id]) + }, + ) +} + +fn set_keyframe_interpolation( + state: &mut EditorState, + clip_id: String, + property: KeyframeProperty, + frame: i32, + interpolation: opentake_domain::Interpolation, +) -> Result { + let loc = state + .find_clip(&clip_id) + .ok_or_else(|| EditError::Invalid(format!("Clip not found: {clip_id}")))?; + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let rel = frame - clip.start_frame; + let has_kf = match property { + KeyframeProperty::Opacity => has_keyframe_at(&clip.opacity_track, rel), + KeyframeProperty::Volume => has_keyframe_at(&clip.volume_track, rel), + KeyframeProperty::Rotation => has_keyframe_at(&clip.rotation_track, rel), + KeyframeProperty::Position => has_keyframe_at(&clip.position_track, rel), + KeyframeProperty::Scale => has_keyframe_at(&clip.scale_track, rel), + KeyframeProperty::Crop => has_keyframe_at(&clip.crop_track, rel), + }; + if !has_kf { + return Err(EditError::Invalid(format!( + "Keyframe not found at frame {frame}" + ))); + } + let summary = format!("Set keyframe interpolation on {clip_id}"); + transact( + state, + "Set Keyframe Interpolation", + move |_| summary, + move |st| { + let loc = st.find_clip(&clip_id).expect("validated above"); + let clip = &mut st.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let rel = frame - clip.start_frame; + match property { + KeyframeProperty::Opacity => { + set_kf_interp(&mut clip.opacity_track, rel, interpolation) + } + KeyframeProperty::Volume => { + set_kf_interp(&mut clip.volume_track, rel, interpolation) + } + KeyframeProperty::Rotation => { + set_kf_interp(&mut clip.rotation_track, rel, interpolation) + } + KeyframeProperty::Position => { + set_kf_interp(&mut clip.position_track, rel, interpolation) + } + KeyframeProperty::Scale => set_kf_interp(&mut clip.scale_track, rel, interpolation), + KeyframeProperty::Crop => set_kf_interp(&mut clip.crop_track, rel, interpolation), + } + Ok(vec![clip_id]) + }, + ) +} + // MARK: - Advanced pixel-effect commands (A-tier) // // These set per-clip visual fields (color grade / chroma key / masks / effects). @@ -1436,6 +1796,27 @@ fn empty_to_none( } } +fn has_keyframe_at(t_opt: &Option>, rel: i32) -> bool { + t_opt + .as_ref() + .map(|t| t.keyframes.iter().any(|k| k.frame == rel)) + .unwrap_or(false) +} + +fn set_kf_interp( + t_opt: &mut Option>, + rel: i32, + interpolation: opentake_domain::Interpolation, +) { + if let Some(t) = t_opt { + for kf in &mut t.keyframes { + if kf.frame == rel { + kf.interpolation_out = interpolation; + } + } + } +} + fn loc_clip_id(state: &EditorState, loc: opentake_domain::ClipLocation) -> String { state.timeline.tracks[loc.track_index].clips[loc.clip_index] .id @@ -1569,3 +1950,307 @@ mod insert_track_tests { assert!(err.is_err()); } } + +#[cfg(test)] +mod keyframe_edit_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{ClipType, Interpolation, Keyframe, KeyframeTrack}; + + /// Build a state with one video track and one clip at [100, 130). + fn make_state_with_clip() -> (EditorState, SeqIdGen, String) { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + apply( + &mut state, + EditCommand::InsertTrack { + kind: ClipType::Video, + }, + &ids, + ) + .unwrap(); + let clip_id = ids.next_id(); + let clip = opentake_domain::Clip::new(clip_id.clone(), "asset1", 100, 30); + state.timeline.tracks[0].clips.push(clip); + (state, ids, clip_id) + } + + fn set_opacity_track(state: &mut EditorState, clip_id: &str, kfs: Vec>) { + let loc = state.find_clip(clip_id).unwrap(); + state.timeline.tracks[loc.track_index].clips[loc.clip_index].opacity_track = + Some(KeyframeTrack::from_keyframes(kfs)); + } + + fn opacity_track_kfs(state: &EditorState, clip_id: &str) -> Vec<(i32, f64, Interpolation)> { + let loc = state.find_clip(clip_id).unwrap(); + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + clip.opacity_track + .as_ref() + .map(|t| { + t.keyframes + .iter() + .map(|k| (k.frame, k.value, k.interpolation_out)) + .collect() + }) + .unwrap_or_default() + } + + // --- StampKeyframe --- + + #[test] + fn stamp_keyframe_creates_track_when_absent() { + let (mut state, ids, clip_id) = make_state_with_clip(); + // No opacity track initially. + let loc = state.find_clip(&clip_id).unwrap(); + assert!(state.timeline.tracks[loc.track_index].clips[loc.clip_index] + .opacity_track + .is_none()); + + let res = apply( + &mut state, + EditCommand::StampKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 110, // rel 10 + }, + &ids, + ) + .unwrap(); + assert!(res.changed); + assert_eq!(res.affected_clip_ids, vec![clip_id.clone()]); + + let kfs = opacity_track_kfs(&state, &clip_id); + assert_eq!(kfs.len(), 1); + assert_eq!(kfs[0].0, 10); // rel frame + // Default opacity is 1.0, so stamped value is 1.0. + approx(kfs[0].1, 1.0); + } + + #[test] + fn stamp_keyframe_upserts_existing() { + let (mut state, ids, clip_id) = make_state_with_clip(); + // Pre-existing track with a kf at rel 10. + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(10, 0.5)]); + + apply( + &mut state, + EditCommand::StampKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 110, // rel 10 — same as existing kf + }, + &ids, + ) + .unwrap(); + + // Upsert should not duplicate. + let kfs = opacity_track_kfs(&state, &clip_id); + assert_eq!(kfs.len(), 1); + assert_eq!(kfs[0].0, 10); + } + + #[test] + fn stamp_keyframe_clip_not_found() { + let (mut state, ids, _clip_id) = make_state_with_clip(); + let err = apply( + &mut state, + EditCommand::StampKeyframe { + clip_id: "nonexistent".into(), + property: KeyframeProperty::Opacity, + frame: 110, + }, + &ids, + ); + assert!(err.is_err()); + } + + #[test] + fn stamp_keyframe_frame_outside_clip() { + let (mut state, ids, clip_id) = make_state_with_clip(); + // Clip spans [100, 130). Frame 200 is outside. + let err = apply( + &mut state, + EditCommand::StampKeyframe { + clip_id, + property: KeyframeProperty::Opacity, + frame: 200, + }, + &ids, + ); + assert!(err.is_err()); + } + + // --- RemoveKeyframe --- + + #[test] + fn remove_keyframe_deletes_and_clears_empty_track() { + let (mut state, ids, clip_id) = make_state_with_clip(); + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(10, 0.5)]); + + apply( + &mut state, + EditCommand::RemoveKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 110, // rel 10 + }, + &ids, + ) + .unwrap(); + + // Track should be cleared to None when empty. + let loc = state.find_clip(&clip_id).unwrap(); + assert!(state.timeline.tracks[loc.track_index].clips[loc.clip_index] + .opacity_track + .is_none()); + } + + #[test] + fn remove_keyframe_not_found() { + let (mut state, ids, clip_id) = make_state_with_clip(); + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(0, 0.5)]); + + let err = apply( + &mut state, + EditCommand::RemoveKeyframe { + clip_id, + property: KeyframeProperty::Opacity, + frame: 110, // rel 10 — no kf here + }, + &ids, + ); + assert!(err.is_err()); + } + + // --- MoveKeyframe --- + + #[test] + fn move_keyframe_to_empty() { + let (mut state, ids, clip_id) = make_state_with_clip(); + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(0, 0.5)]); + + apply( + &mut state, + EditCommand::MoveKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + from_frame: 100, // rel 0 + to_frame: 110, // rel 10 + }, + &ids, + ) + .unwrap(); + + let kfs = opacity_track_kfs(&state, &clip_id); + assert_eq!(kfs.len(), 1); + assert_eq!(kfs[0].0, 10); // moved to rel 10 + } + + #[test] + fn move_keyframe_target_occupied() { + let (mut state, ids, clip_id) = make_state_with_clip(); + set_opacity_track( + &mut state, + &clip_id, + vec![Keyframe::new(0, 0.0), Keyframe::new(10, 1.0)], + ); + + let err = apply( + &mut state, + EditCommand::MoveKeyframe { + clip_id, + property: KeyframeProperty::Opacity, + from_frame: 100, // rel 0 + to_frame: 110, // rel 10 — occupied + }, + &ids, + ); + assert!(err.is_err()); + } + + #[test] + fn move_keyframe_source_missing() { + let (mut state, ids, clip_id) = make_state_with_clip(); + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(0, 0.5)]); + + let err = apply( + &mut state, + EditCommand::MoveKeyframe { + clip_id, + property: KeyframeProperty::Opacity, + from_frame: 115, // rel 15 — no kf + to_frame: 120, // rel 20 + }, + &ids, + ); + assert!(err.is_err()); + } + + #[test] + fn move_keyframe_target_outside_clip() { + let (mut state, ids, clip_id) = make_state_with_clip(); + // Clip spans [100, 130). Frame 200 is outside. + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(0, 0.5)]); + let err = apply( + &mut state, + EditCommand::MoveKeyframe { + clip_id, + property: KeyframeProperty::Opacity, + from_frame: 100, // rel 0 + to_frame: 200, // outside clip range + }, + &ids, + ); + assert!(err.is_err()); + } + + // --- SetKeyframeInterpolation --- + + #[test] + fn set_keyframe_interpolation_changes_mode() { + let (mut state, ids, clip_id) = make_state_with_clip(); + // Keyframe::new defaults to Smooth. + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(0, 0.5)]); + assert_eq!( + opacity_track_kfs(&state, &clip_id)[0].2, + Interpolation::Smooth + ); + + apply( + &mut state, + EditCommand::SetKeyframeInterpolation { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 100, // rel 0 + interpolation: Interpolation::Linear, + }, + &ids, + ) + .unwrap(); + + let kfs = opacity_track_kfs(&state, &clip_id); + assert_eq!(kfs[0].2, Interpolation::Linear); + } + + #[test] + fn set_keyframe_interpolation_kf_not_found() { + let (mut state, ids, clip_id) = make_state_with_clip(); + set_opacity_track(&mut state, &clip_id, vec![Keyframe::new(0, 0.5)]); + + let err = apply( + &mut state, + EditCommand::SetKeyframeInterpolation { + clip_id, + property: KeyframeProperty::Opacity, + frame: 115, // rel 15 — no kf + interpolation: Interpolation::Linear, + }, + &ids, + ); + assert!(err.is_err()); + } + + fn approx(a: f64, b: f64) { + assert!((a - b).abs() < 1e-9, "{a} != {b}"); + } +} diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index c26a99f..787bb4c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -160,6 +160,28 @@ pub enum EditRequest { property: KeyframePropertyDto, payload: KeyframePayloadDto, }, + StampKeyframe { + clip_id: String, + property: KeyframePropertyDto, + frame: i32, + }, + RemoveKeyframe { + clip_id: String, + property: KeyframePropertyDto, + frame: i32, + }, + MoveKeyframe { + clip_id: String, + property: KeyframePropertyDto, + from_frame: i32, + to_frame: i32, + }, + SetKeyframeInterpolation { + clip_id: String, + property: KeyframePropertyDto, + frame: i32, + interpolation: Interpolation, + }, RippleDeleteRanges { track_index: usize, ranges: Vec, @@ -239,6 +261,46 @@ impl EditRequest { property: property.into(), payload: payload.into_payload()?, }, + EditRequest::StampKeyframe { + clip_id, + property, + frame, + } => EditCommand::StampKeyframe { + clip_id, + property: property.into(), + frame, + }, + EditRequest::RemoveKeyframe { + clip_id, + property, + frame, + } => EditCommand::RemoveKeyframe { + clip_id, + property: property.into(), + frame, + }, + EditRequest::MoveKeyframe { + clip_id, + property, + from_frame, + to_frame, + } => EditCommand::MoveKeyframe { + clip_id, + property: property.into(), + from_frame, + to_frame, + }, + EditRequest::SetKeyframeInterpolation { + clip_id, + property, + frame, + interpolation, + } => EditCommand::SetKeyframeInterpolation { + clip_id, + property: property.into(), + frame, + interpolation, + }, EditRequest::RippleDeleteRanges { track_index, ranges, diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 192ba1c..066ed38 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -10,6 +10,7 @@ import { PanelHeaderBar } from "../ui/PanelShell"; import { Icon } from "../ui/Icon"; import { ScrubbableNumberField } from "./ScrubbableNumberField"; import { TextTab } from "./TextTab"; +import { KeyframesPanel } from "./KeyframesPanel"; import { useProjectStore } from "../../store/projectStore"; import { useEditorUiStore } from "../../store/uiStore"; import * as edit from "../../store/editActions"; @@ -298,6 +299,8 @@ function ClipInspector({ {t("inspector.keyframes")} + + {keyframesOpen && } ); } diff --git a/web/src/components/inspector/KeyframesLaneRow.tsx b/web/src/components/inspector/KeyframesLaneRow.tsx new file mode 100644 index 0000000..758dfdf --- /dev/null +++ b/web/src/components/inspector/KeyframesLaneRow.tsx @@ -0,0 +1,398 @@ +/** + * KeyframesLaneRow (SPEC §6.4). One animatable property row inside the + * KeyframesPanel. Renders a label + stamp/clear buttons, and a track strip + * with draggable diamond markers (one per keyframe). + * + * Interaction model: + * - Click empty track area → seek the playhead to that frame. + * - Click a diamond → starts a drag (window mousemove/mouseup); the diamond + * follows the cursor in real time, snaps to the playhead within ±5 frames, + * and commits via `edit.moveKeyframe` on mouseup. + * - Right-click a diamond → context menu (delete / set interpolation). + * - Stamp button → `edit.stampKeyframe` at the current playhead. + * - Clear button → `edit.setKeyframes` with an empty keyframe array. + * + * Diamond rendering: HTML divs positioned with `left: %` and rotated 45°, NOT + * SVG polygons. SVG `polygon points` cannot take percentage strings, and the + * `viewBox + preserveAspectRatio="none"` alternative distorts the diamonds + * (non-uniform x/y scaling). HTML divs with `transform: rotate(45deg)` produce + * correctly-proportioned diamonds at any track width and scale naturally. + */ + +import { useState, useRef, useCallback, useEffect } from "react"; +import { useEditorUiStore } from "../../store/uiStore"; +import * as edit from "../../store/editActions"; +import type { + AnimPair, + Clip, + Crop, + Interpolation, + KeyframeProperty, + KeyframeTrack, +} from "../../lib/types"; +import type { TFunction } from "../../i18n"; + +const DIAMOND_SIZE = 8; +const SNAP_FRAMES = 5; +const LANE_HEIGHT = 24; + +/** Union of all concrete keyframe-track value types (mirror of Clip's *Track + * fields). Used so `getTrack` can return a single typed union. */ +type AnyKeyframeTrack = + | KeyframeTrack + | KeyframeTrack + | KeyframeTrack; + +export function KeyframesLaneRow({ + clip, + property, + t, +}: { + clip: Clip; + property: KeyframeProperty; + t: TFunction; +}) { + const activeFrame = useEditorUiStore((s) => s.activeFrame); + const setActiveFrame = useEditorUiStore((s) => s.setActiveFrame); + const track = getTrack(clip, property); + const trackRef = useRef(null); + const [dragging, setDragging] = useState<{ fromFrame: number; currentFrame: number } | null>(null); + const [menu, setMenu] = useState<{ x: number; y: number; frame: number } | null>(null); + /** Holds the cleanup function for the active drag's window listeners. + * Cleared on unmount via the useEffect below to prevent leaks. */ + const dragCleanupRef = useRef<(() => void) | null>(null); + + // Unmount safety: remove any active drag listeners. + useEffect(() => { + return () => { + dragCleanupRef.current?.(); + dragCleanupRef.current = null; + }; + }, []); + + const startFrame = clip.startFrame; + const duration = Math.max(1, clip.durationFrames); + + // Frame → ratio (0..1) for diamond positioning. + const frameToRatio = useCallback((frame: number) => frame / duration, [duration]); + // Client X → clip-relative frame (rounded), clamped to [0, duration]. + const xToFrame = useCallback( + (clientX: number) => { + const el = trackRef.current; + if (!el) return 0; + const rect = el.getBoundingClientRect(); + const ratio = Math.max(0, Math.min(1, (clientX - rect.left) / rect.width)); + return Math.round(ratio * duration); + }, + [duration], + ); + + const handleStamp = () => { + void edit.stampKeyframe(clip.id, property, activeFrame); + }; + + // Clear the whole track — kind depends on the property's value type. + const handleClear = () => { + if (property === "position" || property === "scale") { + void edit.setKeyframes(clip.id, property, { kind: "pair", keyframes: [] }); + } else if (property === "crop") { + void edit.setKeyframes(clip.id, property, { kind: "crop", keyframes: [] }); + } else { + void edit.setKeyframes(clip.id, property, { kind: "scalar", keyframes: [] }); + } + }; + + // Click empty track → seek playhead. Skipped when clicking a diamond (child). + const handleTrackClick = (e: React.MouseEvent) => { + if (e.target !== e.currentTarget) return; + const rel = xToFrame(e.clientX); + setActiveFrame(startFrame + rel); + }; + + // Start a keyframe drag. Uses window listeners so the drag continues even + // when the cursor leaves the track (matches the app's existing drag pattern). + // The cleanup ref ensures listeners are removed if the component unmounts + // mid-drag (e.g. user deselects the clip). + const handleDiamondMouseDown = (e: React.MouseEvent, absFrame: number) => { + e.stopPropagation(); + e.preventDefault(); + setDragging({ fromFrame: absFrame, currentFrame: absFrame }); + // Clamp to [startFrame, startFrame + duration - 1] (half-open clip range). + const lastFrame = startFrame + duration - 1; + const onMove = (ev: globalThis.MouseEvent) => { + const rel = xToFrame(ev.clientX); + let newFrame = startFrame + rel; + // Clamp to valid clip range. + newFrame = Math.max(startFrame, Math.min(lastFrame, newFrame)); + // Snap to playhead when within threshold. + if (Math.abs(newFrame - activeFrame) <= SNAP_FRAMES) newFrame = activeFrame; + setDragging((d) => (d ? { ...d, currentFrame: newFrame } : d)); + }; + const onUp = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + dragCleanupRef.current = null; + setDragging((d) => { + if (d && d.fromFrame !== d.currentFrame) { + void edit.moveKeyframe(clip.id, property, d.fromFrame, d.currentFrame); + } + return null; + }); + }; + window.addEventListener("mousemove", onMove); + window.addEventListener("mouseup", onUp); + dragCleanupRef.current = () => { + window.removeEventListener("mousemove", onMove); + window.removeEventListener("mouseup", onUp); + }; + }; + + const handleDiamondContextMenu = (e: React.MouseEvent, absFrame: number) => { + e.preventDefault(); + e.stopPropagation(); + setMenu({ x: e.clientX, y: e.clientY, frame: absFrame }); + }; + + const closeMenu = () => setMenu(null); + + const handleDelete = (frame: number) => { + void edit.removeKeyframe(clip.id, property, frame); + closeMenu(); + }; + + const handleSetInterpolation = (frame: number, interp: Interpolation) => { + void edit.setKeyframeInterpolation(clip.id, property, frame, interp); + closeMenu(); + }; + + const keyframes = track?.keyframes ?? []; + const propertyLabel = t(`inspector.keyframes.property.${property}`); + + // Build display list: apply the live drag offset to the dragged keyframe so + // it follows the cursor before the commit fires. + const displayKeyframes = keyframes.map((kf) => { + const absFrame = kf.frame + startFrame; + if (dragging && absFrame === dragging.fromFrame) { + return { key: absFrame, frame: dragging.currentFrame - startFrame, isDragging: true }; + } + return { key: absFrame, frame: kf.frame, isDragging: false }; + }); + + return ( +
+ {/* Label + action buttons */} +
+ + {propertyLabel} + +
+ + {keyframes.length > 0 && ( + + )} +
+
+ + {/* Keyframe track strip */} +
e.preventDefault()} + style={{ + position: "relative", + height: LANE_HEIGHT, + background: "var(--bg-raised)", + borderRadius: 3, + cursor: "pointer", + overflow: "visible", + }} + > + {displayKeyframes.map((kf) => ( +
handleDiamondMouseDown(e, kf.key)} + onContextMenu={(e) => handleDiamondContextMenu(e, kf.key)} + style={{ + position: "absolute", + left: `${frameToRatio(kf.frame) * 100}%`, + top: "50%", + width: DIAMOND_SIZE, + height: DIAMOND_SIZE, + background: kf.isDragging ? "var(--accent-primary)" : "var(--track-lottie)", + border: "0.5px solid rgba(0,0,0,0.4)", + transform: "translate(-50%, -50%) rotate(45deg)", + cursor: "grab", + pointerEvents: "auto", + }} + /> + ))} + + {keyframes.length === 0 && !dragging && ( +
+ + {t("inspector.keyframes.empty")} + +
+ )} +
+ + {/* Right-click context menu (fixed overlay + menu, like ClipContextMenu) */} + {menu && ( + handleDelete(menu.frame)} + onSetInterpolation={(interp) => handleSetInterpolation(menu.frame, interp)} + onClose={closeMenu} + /> + )} +
+ ); +} + +/** Floating context menu for a single keyframe. A full-screen invisible + * overlay captures outside clicks/right-clicks to close. */ +function KeyframeContextMenu({ + x, + y, + t, + onDelete, + onSetInterpolation, + onClose, +}: { + x: number; + y: number; + t: TFunction; + onDelete: () => void; + onSetInterpolation: (interp: Interpolation) => void; + onClose: () => void; +}) { + return ( + <> +
{ + e.preventDefault(); + onClose(); + }} + /> +
+ +
+
+ {t("inspector.keyframes.interpolation")} +
+ onSetInterpolation("linear")} + label={t("inspector.keyframes.interpolation.linear")} + /> + onSetInterpolation("hold")} + label={t("inspector.keyframes.interpolation.hold")} + /> + onSetInterpolation("smooth")} + label={t("inspector.keyframes.interpolation.smooth")} + /> +
+ + ); +} + +function MenuItem({ onClick, label }: { onClick: () => void; label: string }) { + return ( +
(e.currentTarget.style.background = "var(--bg-prominent)")} + onMouseLeave={(e) => (e.currentTarget.style.background = "transparent")} + > + {label} +
+ ); +} + +/** Resolve the clip's keyframe track for a given property. Returns undefined + * when the clip has no keyframes on that property. */ +function getTrack( + clip: Clip, + property: KeyframeProperty, +): AnyKeyframeTrack | undefined { + switch (property) { + case "opacity": + return clip.opacityTrack; + case "volume": + return clip.volumeTrack; + case "rotation": + return clip.rotationTrack; + case "position": + return clip.positionTrack; + case "scale": + return clip.scaleTrack; + case "crop": + return clip.cropTrack; + } +} diff --git a/web/src/components/inspector/KeyframesPanel.tsx b/web/src/components/inspector/KeyframesPanel.tsx new file mode 100644 index 0000000..bb3257a --- /dev/null +++ b/web/src/components/inspector/KeyframesPanel.tsx @@ -0,0 +1,95 @@ +/** + * KeyframesPanel (SPEC §6.4). Inspector sub-panel that renders one row per + * animatable property for the selected clip, each row a `KeyframesLaneRow` with + * draggable diamond markers. A panel-wide red playhead line overlays every row + * so the user can see the current frame against every track at once. + * + * Layout: the outer div carries the top border + padding (visual spacing from + * the Inspector edges). An inner `position: relative` wrapper (no padding) + * holds the ruler, the rows, and the playhead overlay. Both the playhead + * (`left: X%`) and the keyframe diamonds inside each row (`left: X%`) resolve + * against the same width — the inner wrapper's content box — so they align + * exactly at every frame. (If the overlay were positioned against the padded + * outer box, the playhead would drift from the diamonds by up to the padding + * width at the clip's start/end.) + */ + +import { useEditorUiStore } from "../../store/uiStore"; +import type { TFunction } from "../../i18n"; +import type { Clip, KeyframeProperty } from "../../lib/types"; +import { KeyframesLaneRow } from "./KeyframesLaneRow"; + +const VIDEO_PROPERTIES: KeyframeProperty[] = ["position", "scale", "rotation", "opacity", "crop"]; +const AUDIO_PROPERTIES: KeyframeProperty[] = ["volume"]; + +export function KeyframesPanel({ clip, t }: { clip: Clip; t: TFunction }) { + const activeFrame = useEditorUiStore((s) => s.activeFrame); + const properties = clip.mediaType === "audio" ? AUDIO_PROPERTIES : VIDEO_PROPERTIES; + const startFrame = clip.startFrame; + const endFrame = clip.startFrame + clip.durationFrames; + const duration = Math.max(1, endFrame - startFrame); + + // Playhead position within the clip (0..1), clamped to the clip's span. + const playheadRatio = Math.max(0, Math.min(1, (activeFrame - startFrame) / duration)); + + return ( +
+
+ {/* Ruler bar — a thin tinted strip showing the clip's span. */} +
+
+
+ + {/* Property rows. */} + {properties.map((prop) => ( + + ))} + + {/* Panel-wide playhead overlay — spans the full height of the inner + wrapper (ruler + every row) so the user sees the playhead cross all + tracks. pointerEvents:none so it never blocks row clicks/drags. + zIndex above the rows but below the context menu (z-index 1000+). */} +
+
+
+ ); +} diff --git a/web/src/components/timeline/clipRenderer.ts b/web/src/components/timeline/clipRenderer.ts index f980ee5..aeb8916 100644 --- a/web/src/components/timeline/clipRenderer.ts +++ b/web/src/components/timeline/clipRenderer.ts @@ -370,6 +370,7 @@ function drawKeyframeMarkers(ctx: CanvasRenderingContext2D, clip: Clip, rect: Cl clip.scaleTrack, clip.rotationTrack, clip.cropTrack, + clip.volumeTrack, ]; const frames = new Set(); for (const t of tracks) { diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 7d20385..9acfdbd 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -101,6 +101,20 @@ const zh: Dict = { "inspector.field.aspectRatio": "宽高比", "inspector.field.duration": "时长", "inspector.keyframes": "关键帧", + "inspector.keyframes.stamp": "在播放头处盖章", + "inspector.keyframes.clear": "清除动画", + "inspector.keyframes.delete": "删除关键帧", + "inspector.keyframes.interpolation": "插值", + "inspector.keyframes.interpolation.linear": "线性", + "inspector.keyframes.interpolation.hold": "保持", + "inspector.keyframes.interpolation.smooth": "平滑", + "inspector.keyframes.property.position": "位置", + "inspector.keyframes.property.scale": "缩放", + "inspector.keyframes.property.rotation": "旋转", + "inspector.keyframes.property.opacity": "不透明度", + "inspector.keyframes.property.crop": "裁剪", + "inspector.keyframes.property.volume": "音量", + "inspector.keyframes.empty": "无关键帧", "inspector.textPlaceholder": "输入文本…", // Toolbar @@ -262,6 +276,20 @@ const en: Dict = { "inspector.field.aspectRatio": "Aspect Ratio", "inspector.field.duration": "Duration", "inspector.keyframes": "Keyframes", + "inspector.keyframes.stamp": "Stamp at Playhead", + "inspector.keyframes.clear": "Clear Animation", + "inspector.keyframes.delete": "Delete Keyframe", + "inspector.keyframes.interpolation": "Interpolation", + "inspector.keyframes.interpolation.linear": "Linear", + "inspector.keyframes.interpolation.hold": "Hold", + "inspector.keyframes.interpolation.smooth": "Smooth", + "inspector.keyframes.property.position": "Position", + "inspector.keyframes.property.scale": "Scale", + "inspector.keyframes.property.rotation": "Rotation", + "inspector.keyframes.property.opacity": "Opacity", + "inspector.keyframes.property.crop": "Crop", + "inspector.keyframes.property.volume": "Volume", + "inspector.keyframes.empty": "No keyframes", "inspector.textPlaceholder": "Enter text…", "toolbar.undo": "Undo (⌘Z)", diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 855a7e9..4c64386 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -157,6 +157,10 @@ export type EditRequest = | { type: "trimClips"; edits: TrimEditReq[] } | { type: "setClipProperties"; clipIds: string[]; properties: ClipPropertiesReq } | { type: "setKeyframes"; clipId: string; property: KeyframeProperty; payload: KeyframePayloadReq } + | { type: "stampKeyframe"; clipId: string; property: KeyframeProperty; frame: number } + | { type: "removeKeyframe"; clipId: string; property: KeyframeProperty; frame: number } + | { type: "moveKeyframe"; clipId: string; property: KeyframeProperty; fromFrame: number; toFrame: number } + | { type: "setKeyframeInterpolation"; clipId: string; property: KeyframeProperty; frame: number; interpolation: Interpolation } | { type: "rippleDeleteRanges"; trackIndex: number; ranges: FrameRangeReq[] } | { type: "rippleDeleteClips"; clipIds: string[] } | { type: "addTexts"; entries: TextEntryReq[] } diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index 7d5fa88..7885792 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -17,6 +17,7 @@ import type { ClipPropertiesReq, ClipType, FrameRangeReq, + Interpolation, KeyframePayloadReq, KeyframeProperty, MediaItem, @@ -95,6 +96,44 @@ export async function setKeyframes( await applyAndRefresh({ type: "setKeyframes", clipId, property, payload }); } +/** Stamp a keyframe at `frame` using the clip's current sampled value. */ +export async function stampKeyframe( + clipId: string, + property: KeyframeProperty, + frame: number, +) { + await applyAndRefresh({ type: "stampKeyframe", clipId, property, frame }); +} + +/** Remove the keyframe at `frame`. */ +export async function removeKeyframe( + clipId: string, + property: KeyframeProperty, + frame: number, +) { + await applyAndRefresh({ type: "removeKeyframe", clipId, property, frame }); +} + +/** Move a keyframe from `fromFrame` to `toFrame`. */ +export async function moveKeyframe( + clipId: string, + property: KeyframeProperty, + fromFrame: number, + toFrame: number, +) { + await applyAndRefresh({ type: "moveKeyframe", clipId, property, fromFrame, toFrame }); +} + +/** Change the interpolation mode of the keyframe at `frame`. */ +export async function setKeyframeInterpolation( + clipId: string, + property: KeyframeProperty, + frame: number, + interpolation: Interpolation, +) { + await applyAndRefresh({ type: "setKeyframeInterpolation", clipId, property, frame, interpolation }); +} + /** Ripple-delete project-frame ranges on a track, closing the gaps. */ export async function rippleDeleteRanges(trackIndex: number, ranges: FrameRangeReq[]) { if (ranges.length === 0) return;