diff --git a/crates/opentake-ops/src/command.rs b/crates/opentake-ops/src/command.rs index 315ce44..aaea685 100644 --- a/crates/opentake-ops/src/command.rs +++ b/crates/opentake-ops/src/command.rs @@ -173,6 +173,18 @@ pub enum KeyframePayload { Crop(opentake_domain::KeyframeTrack), } +/// An explicit single-keyframe value for [`EditCommand::UpsertKeyframe`]. Unlike +/// [`KeyframePayload`] (a whole replacement track), this carries just the value +/// to upsert at the command's `frame`. Exactly one variant is used per command, +/// matching `property` — Scalar for Opacity/Volume/Rotation, Pair for +/// Position/Scale, Crop for Crop. +#[derive(Clone, Copy, Debug)] +pub enum KeyframeValue { + Scalar(f64), + Pair(opentake_domain::AnimPair), + Crop(opentake_domain::Crop), +} + /// The unified editing command. Every editing surface routes through this. #[derive(Clone, Debug)] pub enum EditCommand { @@ -227,6 +239,19 @@ pub enum EditCommand { property: KeyframeProperty, frame: i32, }, + /// Upsert a keyframe at `frame` (absolute timeline frame) with an EXPLICIT + /// `value`, instead of the clip's current sampled value (that's + /// `StampKeyframe`). Creates the track if absent. This is the missing half of + /// upstream's animation authoring: `write` does + /// `if .isActive { clip.upsertKeyframe(in: \., frame:, value:) } + /// else { set static }` — the static half already exists via + /// `SetClipProperties`; this command is the upsert half. + UpsertKeyframe { + clip_id: String, + property: KeyframeProperty, + frame: i32, + value: KeyframeValue, + }, /// Remove the keyframe at `frame` (absolute timeline frame). Clears the track /// to `None` when it becomes empty. RemoveKeyframe { @@ -343,6 +368,14 @@ pub enum EditCommand { /// kept verbatim. The render layer is responsible for any overshoot /// sampling when the new media is shorter. SwapMedia { clip_id: String, media_ref: String }, + /// Reset the transform section back to defaults. 1:1 port of upstream's + /// Inspector "Reset transform" button (`InspectorView.transformHeader`): + /// sets `transform` to identity (`Transform::default()`, full-canvas + /// centered, no rotation/flip), `opacity` to `1.0`, clears the opacity / + /// position / scale / rotation keyframe tracks, and zeroes both fades + /// (frames + interpolation back to `Linear`). Crop and its keyframe track + /// are untouched (a separate Inspector section upstream). + ResetTransform { clip_ids: Vec }, /// Undo the last committed command. Undo, /// Redo the last undone command. @@ -416,6 +449,12 @@ pub fn apply( property, frame, } => stamp_keyframe(state, clip_id, property, frame), + EditCommand::UpsertKeyframe { + clip_id, + property, + frame, + value, + } => upsert_keyframe(state, clip_id, property, frame, value), EditCommand::RemoveKeyframe { clip_id, property, @@ -471,6 +510,7 @@ pub fn apply( 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), + EditCommand::ResetTransform { clip_ids } => reset_transform(state, clip_ids), } } @@ -1227,6 +1267,93 @@ fn stamp_keyframe( ) } +/// Write an explicit `value` into `property`'s keyframe track at `frame` +/// (absolute timeline frame), upserting in place (creating the track if +/// absent). Unlike [`stamp_keyframe`], the value is supplied by the caller +/// rather than sampled from the clip's current state — this is the missing +/// half of upstream's animation authoring (`write`'s +/// `clip.upsertKeyframe(...)` branch; the static-value branch already exists +/// via `SetClipProperties`). Does NOT touch the clip's static field. +fn upsert_keyframe( + state: &mut EditorState, + clip_id: String, + property: KeyframeProperty, + frame: i32, + value: KeyframeValue, +) -> 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() + ))); + } + // Type/property agreement check (mirrors `set_keyframes`). + let ok = matches!( + (property, value), + (KeyframeProperty::Opacity, KeyframeValue::Scalar(_)) + | (KeyframeProperty::Volume, KeyframeValue::Scalar(_)) + | (KeyframeProperty::Rotation, KeyframeValue::Scalar(_)) + | (KeyframeProperty::Position, KeyframeValue::Pair(_)) + | (KeyframeProperty::Scale, KeyframeValue::Pair(_)) + | (KeyframeProperty::Crop, KeyframeValue::Crop(_)) + ); + if !ok { + return Err(EditError::Invalid( + "keyframe value type does not match property".into(), + )); + } + let summary = format!("Set keyframe on {clip_id}"); + transact( + state, + "Set 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, value) { + (KeyframeProperty::Opacity, KeyframeValue::Scalar(v)) => { + 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, KeyframeValue::Scalar(v)) => { + 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, KeyframeValue::Scalar(v)) => { + 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, KeyframeValue::Pair(v)) => { + 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, KeyframeValue::Pair(v)) => { + 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, KeyframeValue::Crop(v)) => { + 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); + } + _ => unreachable!("validated above"), + } + Ok(vec![clip_id]) + }, + ) +} + fn remove_keyframe( state: &mut EditorState, clip_id: String, @@ -2114,6 +2241,29 @@ fn swap_media( ) } +/// 1:1 port of upstream's Inspector "Reset transform" button +/// (`InspectorView.transformHeader`'s `onReset` closure): resets `transform` +/// to identity, `opacity` to `1.0`, clears the opacity / position / scale / +/// rotation keyframe tracks, and zeroes both fades back to `Linear` +/// interpolation. Crop is a separate section upstream and is left untouched. +fn reset_transform( + state: &mut EditorState, + clip_ids: Vec, +) -> Result { + set_clip_effect_field(state, clip_ids, "Reset Transform", |clip| { + clip.transform = Transform::default(); + clip.opacity = 1.0; + clip.opacity_track = None; + clip.position_track = None; + clip.scale_track = None; + clip.rotation_track = None; + clip.fade_in_frames = 0; + clip.fade_out_frames = 0; + clip.fade_in_interpolation = Interpolation::Linear; + clip.fade_out_interpolation = Interpolation::Linear; + }) +} + // MARK: - Small local helpers fn validate_entry(state: &EditorState, e: &ClipEntry, i: usize) -> Result<(), EditError> { @@ -2711,6 +2861,248 @@ mod keyframe_edit_tests { } } +#[cfg(test)] +mod upsert_keyframe_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{ClipType, Crop, Keyframe, KeyframeTrack}; + + /// Build a state with one video track and one clip at [100, 130) (start + /// frame != 0, so `rel = frame - start` is exercised, not just identity). + 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, + at: None, + }, + &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 opacity_track_kfs(state: &EditorState, clip_id: &str) -> Vec<(i32, f64)> { + 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)).collect()) + .unwrap_or_default() + } + + #[test] + fn creates_track_on_clean_clip() { + let (mut state, ids, clip_id) = make_state_with_clip(); + 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::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 110, // rel 10 (start_frame = 100) + value: KeyframeValue::Scalar(0.25), + }, + &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, vec![(10, 0.25)]); + } + + #[test] + fn upsert_at_existing_rel_overwrites_value() { + let (mut state, ids, clip_id) = make_state_with_clip(); + let loc = state.find_clip(&clip_id).unwrap(); + state.timeline.tracks[loc.track_index].clips[loc.clip_index].opacity_track = + Some(KeyframeTrack::from_keyframes(vec![Keyframe::new(10, 0.5)])); + + apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 110, // rel 10 — same as existing kf + value: KeyframeValue::Scalar(0.9), + }, + &ids, + ) + .unwrap(); + + // Upsert overwrites in place — no duplicate keyframe. + let kfs = opacity_track_kfs(&state, &clip_id); + assert_eq!(kfs, vec![(10, 0.9)]); + } + + #[test] + fn wrong_value_variant_for_property_is_invalid() { + let (mut state, ids, clip_id) = make_state_with_clip(); + + // Opacity requires Scalar, not Pair. + let err = apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Opacity, + frame: 110, + value: KeyframeValue::Pair(opentake_domain::AnimPair::new(0.0, 0.0)), + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + + // Position requires Pair, not Scalar. + let err = apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Position, + frame: 110, + value: KeyframeValue::Scalar(1.0), + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + + // Crop requires Crop, not Scalar. + let err = apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id, + property: KeyframeProperty::Crop, + frame: 110, + value: KeyframeValue::Scalar(1.0), + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn frame_outside_clip_range_is_invalid() { + let (mut state, ids, clip_id) = make_state_with_clip(); + // Clip spans [100, 130). Frame 200 is outside. + let err = apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id, + property: KeyframeProperty::Opacity, + frame: 200, + value: KeyframeValue::Scalar(0.5), + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + #[test] + fn clip_not_found_is_invalid() { + let (mut state, ids, _clip_id) = make_state_with_clip(); + let err = apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: "nonexistent".into(), + property: KeyframeProperty::Opacity, + frame: 110, + value: KeyframeValue::Scalar(0.5), + }, + &ids, + ); + assert!(matches!(err, Err(EditError::Invalid(_)))); + } + + /// `rel` is computed as `frame - start_frame`; verify with a clip whose + /// `start_frame != 0` (the fixture uses 100) and confirm the round-trip + /// value samples back correctly for each payload shape. + #[test] + fn rel_offset_computed_from_nonzero_start_frame_and_value_round_trips() { + let (mut state, ids, clip_id) = make_state_with_clip(); + + // Pair (Position): value round-trips via `sample`. + apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Position, + frame: 115, // rel 15 + value: KeyframeValue::Pair(opentake_domain::AnimPair::new(0.3, 0.7)), + }, + &ids, + ) + .unwrap(); + let loc = state.find_clip(&clip_id).unwrap(); + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let position_track = clip.position_track.as_ref().expect("position track"); + assert_eq!(position_track.keyframes.len(), 1); + assert_eq!(position_track.keyframes[0].frame, 15); + let sampled = position_track.sample(15, opentake_domain::AnimPair::new(0.0, 0.0)); + approx(sampled.a, 0.3); + approx(sampled.b, 0.7); + + // Crop: value round-trips via `sample`. + let crop = Crop { + left: 0.1, + top: 0.2, + right: 0.3, + bottom: 0.4, + }; + apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Crop, + frame: 120, // rel 20 + value: KeyframeValue::Crop(crop), + }, + &ids, + ) + .unwrap(); + let loc = state.find_clip(&clip_id).unwrap(); + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let crop_track = clip.crop_track.as_ref().expect("crop track"); + assert_eq!(crop_track.keyframes[0].frame, 20); + let sampled_crop = crop_track.sample(20, Crop::default()); + assert_eq!(sampled_crop, crop); + + // Volume (dB): value round-trips via `sample` — the track stores raw + // dB, unconverted (see `clip.volume_at` which does + // `VolumeScale::linear_from_db(t.sample(...))` on top of this stored + // value). + apply( + &mut state, + EditCommand::UpsertKeyframe { + clip_id: clip_id.clone(), + property: KeyframeProperty::Volume, + frame: 125, // rel 25 + value: KeyframeValue::Scalar(-6.0), + }, + &ids, + ) + .unwrap(); + let loc = state.find_clip(&clip_id).unwrap(); + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + let volume_track = clip.volume_track.as_ref().expect("volume track"); + assert_eq!(volume_track.keyframes[0].frame, 25); + approx(volume_track.sample(25, 0.0), -6.0); + } + + fn approx(a: f64, b: f64) { + assert!((a - b).abs() < 1e-9, "{a} != {b}"); + } +} + #[cfg(test)] mod duplicate_clips_tests { use super::*; @@ -2970,3 +3362,232 @@ mod text_style_property_tests { assert_eq!(clip.text_style.as_ref().unwrap().font_size, 120.0); } } + +#[cfg(test)] +mod reset_transform_tests { + use super::*; + use crate::id::SeqIdGen; + use opentake_domain::{AnimPair, ClipType, Interpolation, Keyframe, KeyframeTrack}; + + /// Build a state with one video track and two clips: `clip_id` is fully + /// animated (transform / opacity / fades / all four tracks), `other_id` is + /// left untouched to prove the reset is scoped to the requested clip. + fn make_state_with_animated_clip() -> (EditorState, SeqIdGen, String, String) { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + apply( + &mut state, + EditCommand::InsertTrack { + kind: ClipType::Video, + at: None, + }, + &ids, + ) + .unwrap(); + let clip_id = ids.next_id(); + let mut clip = opentake_domain::Clip::new(clip_id.clone(), "asset1", 0, 30); + clip.transform = Transform { + center_x: 0.25, + center_y: 0.75, + width: 2.0, + height: 3.0, + rotation: 45.0, + flip_horizontal: true, + flip_vertical: true, + }; + clip.opacity = 0.5; + clip.opacity_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new(0, 0.2)])); + clip.position_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + AnimPair::new(0.1, 0.1), + )])); + clip.scale_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + AnimPair::new(1.5, 1.5), + )])); + clip.rotation_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new(0, 90.0)])); + clip.crop = Crop { + left: 0.1, + top: 0.1, + right: 0.1, + bottom: 0.1, + }; + clip.crop_track = Some(KeyframeTrack::from_keyframes(vec![Keyframe::new( + 0, + Crop { + left: 0.2, + top: 0.2, + right: 0.2, + bottom: 0.2, + }, + )])); + clip.fade_in_frames = 10; + clip.fade_out_frames = 10; + clip.fade_in_interpolation = Interpolation::Smooth; + clip.fade_out_interpolation = Interpolation::Smooth; + state.timeline.tracks[0].clips.push(clip); + + let other_id = ids.next_id(); + let mut other = opentake_domain::Clip::new(other_id.clone(), "asset2", 40, 30); + other.transform.rotation = 30.0; + other.opacity = 0.7; + state.timeline.tracks[0].clips.push(other); + + (state, ids, clip_id, other_id) + } + + #[test] + fn reset_transform_restores_defaults_and_clears_exactly_the_upstream_tracks() { + let (mut state, ids, clip_id, _other_id) = make_state_with_animated_clip(); + let res = apply( + &mut state, + EditCommand::ResetTransform { + clip_ids: vec![clip_id.clone()], + }, + &ids, + ) + .unwrap(); + assert!(res.changed); + assert_eq!(res.affected_clip_ids, vec![clip_id.clone()]); + + let loc = state.find_clip(&clip_id).unwrap(); + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + + // Transform back to identity (full-canvas centered, no rotate/flip). + assert_eq!(clip.transform, Transform::default()); + assert_eq!(clip.transform.center_x, 0.5); + assert_eq!(clip.transform.center_y, 0.5); + assert_eq!(clip.transform.width, 1.0); + assert_eq!(clip.transform.height, 1.0); + assert_eq!(clip.transform.rotation, 0.0); + assert!(!clip.transform.flip_horizontal); + assert!(!clip.transform.flip_vertical); + + // Opacity back to fully opaque. + assert_eq!(clip.opacity, 1.0); + + // Exactly the four animation tracks upstream clears. + assert!(clip.opacity_track.is_none()); + assert!(clip.position_track.is_none()); + assert!(clip.scale_track.is_none()); + assert!(clip.rotation_track.is_none()); + + // Fades zeroed and interpolation reset to Linear. + assert_eq!(clip.fade_in_frames, 0); + assert_eq!(clip.fade_out_frames, 0); + assert_eq!(clip.fade_in_interpolation, Interpolation::Linear); + assert_eq!(clip.fade_out_interpolation, Interpolation::Linear); + + // Crop and its keyframe track are a separate Inspector section + // upstream and must survive the reset untouched. + assert_eq!( + clip.crop, + Crop { + left: 0.1, + top: 0.1, + right: 0.1, + bottom: 0.1, + } + ); + assert!(clip.crop_track.is_some()); + } + + #[test] + fn reset_transform_leaves_other_clips_untouched() { + let (mut state, ids, clip_id, other_id) = make_state_with_animated_clip(); + apply( + &mut state, + EditCommand::ResetTransform { + clip_ids: vec![clip_id], + }, + &ids, + ) + .unwrap(); + + let loc = state.find_clip(&other_id).unwrap(); + let other = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + assert_eq!(other.transform.rotation, 30.0); + assert_eq!(other.opacity, 0.7); + } + + #[test] + fn reset_transform_is_undoable() { + let (mut state, ids, clip_id, _other_id) = make_state_with_animated_clip(); + let version_before = state.version(); + apply( + &mut state, + EditCommand::ResetTransform { + clip_ids: vec![clip_id.clone()], + }, + &ids, + ) + .unwrap(); + + apply(&mut state, EditCommand::Undo, &ids).unwrap(); + let loc = state.find_clip(&clip_id).unwrap(); + let clip = &state.timeline.tracks[loc.track_index].clips[loc.clip_index]; + assert_eq!(clip.transform.rotation, 45.0); + assert_eq!(clip.opacity, 0.5); + assert!(clip.opacity_track.is_some()); + assert_eq!(state.version(), version_before + 2); // reset commit + undo + } + + #[test] + fn reset_transform_missing_clip_errors_with_no_change() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + let version_before = state.version(); + + let err = apply( + &mut state, + EditCommand::ResetTransform { + clip_ids: vec!["does-not-exist".into()], + }, + &ids, + ); + assert!(err.is_err()); + assert_eq!(state.version(), version_before); + } + + #[test] + fn reset_transform_empty_clip_ids_errors() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + let err = apply( + &mut state, + EditCommand::ResetTransform { clip_ids: vec![] }, + &ids, + ); + assert!(err.is_err()); + } + + #[test] + fn reset_transform_noop_when_already_default_reports_unchanged() { + let mut state = EditorState::default(); + let ids = SeqIdGen::default(); + apply( + &mut state, + EditCommand::InsertTrack { + kind: ClipType::Video, + at: None, + }, + &ids, + ) + .unwrap(); + let clip_id = ids.next_id(); + let clip = opentake_domain::Clip::new(clip_id.clone(), "asset1", 0, 30); + state.timeline.tracks[0].clips.push(clip); + let version_before = state.version(); + + let res = apply( + &mut state, + EditCommand::ResetTransform { + clip_ids: vec![clip_id], + }, + &ids, + ) + .unwrap(); + assert!(!res.changed); + assert_eq!(state.version(), version_before); + } +} diff --git a/crates/opentake-ops/src/lib.rs b/crates/opentake-ops/src/lib.rs index cbb1f21..e4d9b06 100644 --- a/crates/opentake-ops/src/lib.rs +++ b/crates/opentake-ops/src/lib.rs @@ -32,7 +32,7 @@ pub use engines::{ // --- Command layer --- pub use command::{ apply, ClipEntry, ClipProperties, EditCommand, EditError, EditResult, KeyframePayload, - KeyframeProperty, RenameEntry, TextEntry, + KeyframeProperty, KeyframeValue, RenameEntry, TextEntry, }; pub use editor_state::{DocSnapshot, EditorState}; pub use id::{IdGen, SeqIdGen}; diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 73170e5..74d976f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -20,7 +20,7 @@ use opentake_core::{AppCore, CmdError, EditCommand}; use opentake_ops::{ ClipEntry, ClipMove, ClipProperties, FrameRange, KeyframePayload, KeyframeProperty, - RenameEntry, TextEntry, + KeyframeValue, RenameEntry, TextEntry, }; use opentake_domain::{ @@ -291,6 +291,13 @@ pub enum EditRequest { frame: i32, }, #[serde(rename_all = "camelCase")] + UpsertKeyframe { + clip_id: String, + property: KeyframePropertyDto, + frame: i32, + value: KeyframeValueDto, + }, + #[serde(rename_all = "camelCase")] RemoveKeyframe { clip_id: String, property: KeyframePropertyDto, @@ -378,6 +385,8 @@ pub enum EditRequest { DeleteFolder { folder_ids: Vec }, #[serde(rename_all = "camelCase")] SwapMedia { clip_id: String, media_ref: String }, + #[serde(rename_all = "camelCase")] + ResetTransform { clip_ids: Vec }, } impl EditRequest { @@ -439,6 +448,17 @@ impl EditRequest { property: property.into(), frame, }, + EditRequest::UpsertKeyframe { + clip_id, + property, + frame, + value, + } => EditCommand::UpsertKeyframe { + clip_id, + property: property.into(), + frame, + value: value.into_value(), + }, EditRequest::RemoveKeyframe { clip_id, property, @@ -550,6 +570,7 @@ impl EditRequest { EditRequest::SwapMedia { clip_id, media_ref } => { EditCommand::SwapMedia { clip_id, media_ref } } + EditRequest::ResetTransform { clip_ids } => EditCommand::ResetTransform { clip_ids }, }) } } @@ -837,6 +858,27 @@ impl KeyframePayloadDto { } } +/// An explicit single-value payload for [`EditRequest::UpsertKeyframe`]. Mirrors +/// [`KeyframePayloadDto`]'s `kind`-tagging, but carries one value (not a whole +/// replacement track) to upsert at the command's `frame`. +#[derive(Debug, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum KeyframeValueDto { + Scalar { value: f64 }, + Pair { value: AnimPair }, + Crop { value: Crop }, +} + +impl KeyframeValueDto { + fn into_value(self) -> KeyframeValue { + match self { + KeyframeValueDto::Scalar { value } => KeyframeValue::Scalar(value), + KeyframeValueDto::Pair { value } => KeyframeValue::Pair(value), + KeyframeValueDto::Crop { value } => KeyframeValue::Crop(value), + } + } +} + #[cfg(test)] mod edit_request_serde_tests { use super::EditRequest; @@ -933,6 +975,74 @@ mod edit_request_serde_tests { } } + #[test] + fn deserializes_upsert_keyframe_scalar_and_maps_to_command() { + // camelCase clipId/frame must deserialize (the recurring DTO camelCase + // trap), and the "scalar" kind must map onto KeyframeValue::Scalar. + let request = serde_json::from_str::( + r#"{"type":"upsertKeyframe","clipId":"clip-1","property":"opacity","frame":110,"value":{"kind":"scalar","value":0.25}}"#, + ) + .expect("upsertKeyframe scalar camelCase"); + + match request.into_command().expect("upsertKeyframe command") { + EditCommand::UpsertKeyframe { + clip_id, + property, + frame, + value, + } => { + assert_eq!(clip_id, "clip-1"); + assert_eq!(property, opentake_ops::KeyframeProperty::Opacity); + assert_eq!(frame, 110); + assert!(matches!(value, opentake_ops::KeyframeValue::Scalar(v) if v == 0.25)); + } + other => panic!("expected UpsertKeyframe, got {other:?}"), + } + } + + #[test] + fn deserializes_upsert_keyframe_pair_and_crop_and_maps_to_command() { + let pair_request = serde_json::from_str::( + r#"{"type":"upsertKeyframe","clipId":"clip-1","property":"position","frame":10,"value":{"kind":"pair","value":{"a":0.3,"b":0.7}}}"#, + ) + .expect("upsertKeyframe pair camelCase"); + match pair_request + .into_command() + .expect("upsertKeyframe pair command") + { + EditCommand::UpsertKeyframe { + property, value, .. + } => { + assert_eq!(property, opentake_ops::KeyframeProperty::Position); + match value { + opentake_ops::KeyframeValue::Pair(p) => { + assert_eq!(p.a, 0.3); + assert_eq!(p.b, 0.7); + } + other => panic!("expected Pair value, got {other:?}"), + } + } + other => panic!("expected UpsertKeyframe, got {other:?}"), + } + + let crop_request = serde_json::from_str::( + r#"{"type":"upsertKeyframe","clipId":"clip-1","property":"crop","frame":10,"value":{"kind":"crop","value":{"left":0.1,"top":0.2,"right":0.3,"bottom":0.4}}}"#, + ) + .expect("upsertKeyframe crop camelCase"); + match crop_request + .into_command() + .expect("upsertKeyframe crop command") + { + EditCommand::UpsertKeyframe { + property, value, .. + } => { + assert_eq!(property, opentake_ops::KeyframeProperty::Crop); + assert!(matches!(value, opentake_ops::KeyframeValue::Crop(_))); + } + other => panic!("expected UpsertKeyframe, got {other:?}"), + } + } + #[test] fn deserializes_effect_commands_and_maps_to_ops_variants() { let grade = serde_json::from_str::( @@ -978,6 +1088,23 @@ mod edit_request_serde_tests { } } + /// Guards the IPC boundary (`AGENTS.md` camelCase discipline): the + /// multiword `clipIds` field must deserialize on the wire exactly like the + /// other multi-clip commands (`setColorGrade` et al.). + #[test] + fn deserializes_reset_transform_camelcase_and_maps_to_ops_variant() { + let req = serde_json::from_str::( + r#"{"type":"resetTransform","clipIds":["clip-1","clip-2"]}"#, + ) + .expect("resetTransform camelCase"); + match req.into_command().expect("resetTransform command") { + EditCommand::ResetTransform { clip_ids } => { + assert_eq!(clip_ids, vec!["clip-1", "clip-2"]); + } + other => panic!("expected ResetTransform, got {other:?}"), + } + } + #[test] fn deserializes_media_library_commands_and_maps_to_ops_variants() { let rename_media = serde_json::from_str::( diff --git a/web/src/components/inspector/Inspector.tsx b/web/src/components/inspector/Inspector.tsx index 4882461..7d1408b 100644 --- a/web/src/components/inspector/Inspector.tsx +++ b/web/src/components/inspector/Inspector.tsx @@ -1,8 +1,11 @@ /** * Inspector (SPEC §6). Title bar + one of four content states: marquee summary, * clip inspector (with Video/Audio tabs), media-asset source, or project - * metadata. Editable fields commit via SetClipProperties. The keyframe lane and - * Text/AI-Edit tabs are scaffolded (TODO: full parity in a later pass). + * metadata. Editable fields commit via SetClipProperties; a field whose + * property already has an active keyframe track stays editable but commits + * via UpsertKeyframe at the playhead instead (see `../../lib/keyframeValue`). + * The keyframe lane and Text/AI-Edit tabs are scaffolded (TODO: full parity + * in a later pass). */ import { useEffect, useState } from "react"; @@ -12,11 +15,13 @@ import { Info, Palette, Pipette, + RotateCcw, SlidersHorizontal, type LucideIcon, } from "lucide-react"; import { PanelHeaderBar } from "../ui/PanelShell"; import { Icon } from "../ui/Icon"; +import { HoverButton } from "../ui/HoverButton"; import { ScrubbableNumberField } from "./ScrubbableNumberField"; import { TextTab } from "./TextTab"; import { KeyframesPanel } from "./KeyframesPanel"; @@ -28,14 +33,23 @@ import * as edit from "../../store/editActions"; import { formatTimecode } from "../../lib/geometry"; import { cropAt, + liveVolumeKfLinearAt, mediaCanvasAspect, - opacityAt, + rawOpacityAt, resizeTransformKeepingSourceAspect, rotationAt, sizeAt, topLeftAt, - volumeAt, } from "../../lib/clip"; +import { + cropEdgeKeyframeValue, + opacityKeyframeValue, + positionXKeyframeValue, + positionYKeyframeValue, + rotationKeyframeValue, + scaleKeyframeValue, + volumeKeyframeValue, +} from "../../lib/keyframeValue"; import { FS, RADIUS, SPACE } from "../../lib/theme"; import { useT, type TFunction } from "../../i18n"; import type { @@ -183,27 +197,9 @@ 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. */ +/** Inline hint shown beside an animated field's editable control, signaling + * that committing a value here upserts a keyframe at the playhead rather + * than setting the clip's static property. */ function AnimatedHint({ t }: { t: TFunction }) { return ( 0; // Sampled values at the playhead. - const sampledOpacity = opacityAt(clip, activeFrame); - const sampledVolume = volumeAt(clip, activeFrame); + const sampledOpacity = rawOpacityAt(clip, activeFrame); + const sampledVolume = liveVolumeKfLinearAt(clip, activeFrame) ?? clip.volume; const sampledRotation = rotationAt(clip, activeFrame); const sampledScale = sizeAt(clip, activeFrame)[0]; const sampledTopLeft = topLeftAt(clip, activeFrame); @@ -356,26 +352,22 @@ function ClipInspector({
- {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 })} - /> - )} + (20 * Math.log10(Math.max(1e-6, v))).toFixed(1)} + suffix=" dB" + width={56} + displayTextOverride={(v) => (v <= 0 ? "-∞ dB" : null)} + onCommit={(v) => + volumeAnimated + ? edit.upsertKeyframe(clip.id, "volume", activeFrame, volumeKeyframeValue(v)) + : commit({ volume: v }) + } + /> + {volumeAnimated && }
@@ -384,67 +376,76 @@ function ClipInspector({
+ edit.resetTransform([clip.id])} + size={18} + > + +
- {scaleAnimated ? ( - <> - - - - ) : ( - Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => - commit({ - transform: resizeTransformKeepingSourceAspect(clip.transform, v, aspect), - }) - } - /> - )} + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => + scaleAnimated + ? edit.upsertKeyframe( + clip.id, + "scale", + activeFrame, + scaleKeyframeValue(clip.transform, v, aspect), + ) + : commit({ + transform: resizeTransformKeepingSourceAspect(clip.transform, v, aspect), + }) + } + /> + {scaleAnimated && } - {rotationAnimated ? ( - <> - - - - ) : ( - v.toFixed(0)} - suffix="°" - width={56} - onCommit={(v) => commit({ transform: { ...clip.transform, rotation: v } })} - /> - )} + v.toFixed(0)} + suffix="°" + width={56} + onCommit={(v) => + rotationAnimated + ? edit.upsertKeyframe(clip.id, "rotation", activeFrame, rotationKeyframeValue(v)) + : commit({ transform: { ...clip.transform, rotation: v } }) + } + /> + {rotationAnimated && } - {opacityAnimated ? ( - <> - - - - ) : ( - Math.round(v * 100).toString()} - suffix="%" - width={56} - onCommit={(v) => commit({ opacity: v })} - /> - )} + Math.round(v * 100).toString()} + suffix="%" + width={56} + onCommit={(v) => + opacityAnimated + ? edit.upsertKeyframe( + clip.id, + "opacity", + activeFrame, + opacityKeyframeValue(v * 100), + ) + : commit({ opacity: v }) + } + /> + {opacityAnimated && }
@@ -452,6 +453,7 @@ function ClipInspector({ clip={clip} sampledTopLeft={sampledTopLeft} animated={positionAnimated} + activeFrame={activeFrame} commit={commit} t={t} /> @@ -460,6 +462,7 @@ function ClipInspector({ clip={clip} sampledCrop={sampledCrop} animated={cropAnimated} + activeFrame={activeFrame} commit={commit} t={t} /> @@ -524,12 +527,14 @@ function PositionSection({ clip, sampledTopLeft, animated, + activeFrame, commit, t, }: { clip: Clip; sampledTopLeft: { x: number; y: number }; animated: boolean; + activeFrame: number; commit: (props: Parameters[1]) => void; t: TFunction; }) { @@ -540,44 +545,46 @@ function PositionSection({
- {animated ? ( - <> - - - - ) : ( - v.toFixed(3)} - width={56} - onCommit={(v) => - commit({ transform: { ...clip.transform, centerX: v + w / 2 } }) - } - /> - )} + v.toFixed(3)} + width={56} + onCommit={(v) => + animated + ? edit.upsertKeyframe( + clip.id, + "position", + activeFrame, + positionXKeyframeValue(v, sampledTopLeft.y), + ) + : commit({ transform: { ...clip.transform, centerX: v + w / 2 } }) + } + /> + {animated && } - {animated ? ( - <> - - - - ) : ( - v.toFixed(3)} - width={56} - onCommit={(v) => - commit({ transform: { ...clip.transform, centerY: v + h / 2 } }) - } - /> - )} + v.toFixed(3)} + width={56} + onCommit={(v) => + animated + ? edit.upsertKeyframe( + clip.id, + "position", + activeFrame, + positionYKeyframeValue(sampledTopLeft.x, v), + ) + : commit({ transform: { ...clip.transform, centerY: v + h / 2 } }) + } + /> + {animated && }
); @@ -589,12 +596,14 @@ function CropSection({ clip, sampledCrop, animated, + activeFrame, commit, t, }: { clip: Clip; sampledCrop: Crop; animated: boolean; + activeFrame: number; commit: (props: Parameters[1]) => void; t: TFunction; }) { @@ -604,22 +613,25 @@ function CropSection({ }; const renderEdge = (label: string, edge: keyof Crop, value: number) => ( - {animated ? ( - <> - - - - ) : ( - v.toFixed(3)} - width={56} - onCommit={(v) => commitEdge(edge, v)} - /> - )} + v.toFixed(3)} + width={56} + onCommit={(v) => + animated + ? edit.upsertKeyframe( + clip.id, + "crop", + activeFrame, + cropEdgeKeyframeValue(sampledCrop, edge, v), + ) + : commitEdge(edge, v) + } + /> + {animated && } ); return ( diff --git a/web/src/i18n/dict.ts b/web/src/i18n/dict.ts index 0728980..fdae399 100644 --- a/web/src/i18n/dict.ts +++ b/web/src/i18n/dict.ts @@ -151,6 +151,7 @@ const zh: Dict = { "inspector.tab.aiEdit": "AI 编辑", "inspector.section.levels": "电平", "inspector.section.transform": "变换", + "inspector.action.resetTransform": "重置变换", "inspector.section.playback": "播放", "inspector.section.format": "格式", "inspector.section.text": "文本内容", @@ -559,6 +560,7 @@ const en: Dict = { "inspector.tab.aiEdit": "AI Edit", "inspector.section.levels": "Levels", "inspector.section.transform": "Transform", + "inspector.action.resetTransform": "Reset transform", "inspector.section.playback": "Playback", "inspector.section.format": "Format", "inspector.section.text": "Text Content", diff --git a/web/src/lib/clip.test.ts b/web/src/lib/clip.test.ts index 0e6b435..e2d8d28 100644 --- a/web/src/lib/clip.test.ts +++ b/web/src/lib/clip.test.ts @@ -1,11 +1,16 @@ import { describe, expect, it } from "vitest"; import { clampTrimDeltaFrames, + dbFromLinear, fitTransformForMedia, + liveVolumeKfLinearAt, mediaCanvasAspect, + opacityAt, + rawOpacityAt, resizeTransformKeepingSourceAspect, trimSourceValues, trimToPlayheadEdits, + volumeAt, } from "./clip"; import type { Clip, ClipType } from "./types"; @@ -165,3 +170,73 @@ describe("media aspect transform helpers", () => { }); }); }); + +function fullClip(overrides: Partial = {}): Clip { + return { + id: "c1", + mediaRef: "c1-media", + mediaType: "video", + sourceClipType: "video", + startFrame: 10, + durationFrames: 40, + trimStartFrame: 0, + trimEndFrame: 0, + speed: 1, + volume: 1, + fadeInFrames: 0, + fadeOutFrames: 0, + fadeInInterpolation: "linear", + fadeOutInterpolation: "linear", + opacity: 1, + transform: { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + }, + crop: { left: 0, top: 0, right: 0, bottom: 0 }, + ...overrides, + }; +} + +// Regression guard for the Inspector "edit an animated value" seed. Upstream +// (InspectorView.swift writeVolume/writeOpacity) seeds the editable field from +// the RAW keyframe-track sample — NOT the composited output — so editing an +// animated value upserts the authored value instead of baking in the static +// outer volume / fade envelope. See liveVolumeKfLinearAt + rawOpacityAt. +describe("animated-value inspector seed (raw track sample, no fade/gain)", () => { + it("liveVolumeKfLinearAt is null when the clip has no volume keyframes", () => { + expect(liveVolumeKfLinearAt(fullClip(), 15)).toBeNull(); + }); + + it("liveVolumeKfLinearAt returns the raw track gain, excluding static volume and fade", () => { + // -6 dB authored keyframe, 2x static outer volume, sampled inside the fade-in. + const clip = fullClip({ + mediaType: "audio", + sourceClipType: "audio", + volume: 2, + fadeInFrames: 20, + volumeTrack: { keyframes: [{ frame: 0, value: -6, interpolationOut: "linear" }] }, + }); + const raw = liveVolumeKfLinearAt(clip, 15); + expect(raw).not.toBeNull(); + // Round-trips back to the authored -6 dB → editing without change is idempotent. + expect(dbFromLinear(raw as number)).toBeCloseTo(-6); + // The composited output differs (× static volume 2 × the 0.25 fade ramp at + // rel-frame 5), proving the seed is NOT taken from it. + expect(volumeAt(clip, 15)).toBeCloseTo((raw as number) * 2 * 0.25); + }); + + it("rawOpacityAt returns the authored track value, excluding the fade envelope", () => { + const clip = fullClip({ + fadeInFrames: 20, + opacityTrack: { keyframes: [{ frame: 0, value: 0.8, interpolationOut: "linear" }] }, + }); + // Raw = authored 0.8; effective = 0.8 × 0.25 fade at rel-frame 5. + expect(rawOpacityAt(clip, 15)).toBeCloseTo(0.8); + expect(opacityAt(clip, 15)).toBeCloseTo(0.2); + }); +}); diff --git a/web/src/lib/clip.ts b/web/src/lib/clip.ts index dbac19c..2c50356 100644 --- a/web/src/lib/clip.ts +++ b/web/src/lib/clip.ts @@ -525,6 +525,19 @@ export function volumeAt(clip: Clip, frame: number): number { return clip.volume * kfGain * fadeMultiplier(clip, frame); } +/** + * Raw volume-track sample at `frame` as LINEAR amplitude — the authored keyframe + * gain WITHOUT the static outer `volume` or the fade envelope. Mirrors upstream + * `Clip.liveVolumeKfDb` (kept linear here to match the linear-valued volume + * field). Returns `null` when the track has no keyframes. The Inspector seeds + * its editable value from this when volume is animated, so editing upserts the + * authored keyframe value rather than the composited output. + */ +export function liveVolumeKfLinearAt(clip: Clip, frame: number): number | null { + if (!trackIsActive(clip.volumeTrack)) return null; + return linearFromDb(sampleScalarTrack(clip.volumeTrack, keyframeOffset(clip, frame), 0.0)); +} + /** 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); diff --git a/web/src/lib/keyframeValue.test.ts b/web/src/lib/keyframeValue.test.ts new file mode 100644 index 0000000..a9ee235 --- /dev/null +++ b/web/src/lib/keyframeValue.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it } from "vitest"; +import { linearFromDb } from "./clip"; +import { + cropEdgeKeyframeValue, + opacityKeyframeValue, + positionXKeyframeValue, + positionYKeyframeValue, + rotationKeyframeValue, + scaleKeyframeValue, + volumeKeyframeValue, +} from "./keyframeValue"; +import type { Crop, Transform } from "./types"; + +function tf(over: Partial = {}): Transform { + return { + centerX: 0.5, + centerY: 0.5, + width: 1, + height: 1, + rotation: 0, + flipHorizontal: false, + flipVertical: false, + ...over, + }; +} + +function crop(over: Partial = {}): Crop { + return { left: 0, top: 0, right: 0, bottom: 0, ...over }; +} + +describe("opacityKeyframeValue", () => { + it("converts a 0-100 percent field value into a 0-1 scalar", () => { + expect(opacityKeyframeValue(100)).toEqual({ kind: "scalar", value: 1 }); + expect(opacityKeyframeValue(50)).toEqual({ kind: "scalar", value: 0.5 }); + expect(opacityKeyframeValue(0)).toEqual({ kind: "scalar", value: 0 }); + }); +}); + +describe("rotationKeyframeValue", () => { + it("passes degrees through unchanged", () => { + expect(rotationKeyframeValue(45)).toEqual({ kind: "scalar", value: 45 }); + expect(rotationKeyframeValue(-90)).toEqual({ kind: "scalar", value: -90 }); + }); +}); + +describe("volumeKeyframeValue", () => { + it("converts linear amplitude (the field's control value) to dB (the track's unit)", () => { + const result = volumeKeyframeValue(1); + expect(result.kind).toBe("scalar"); + expect((result as { kind: "scalar"; value: number }).value).toBeCloseTo(0, 5); // 0 dB at unity gain + }); + + it("round-trips through linearFromDb -> volumeKeyframeValue -> ~same dB", () => { + const dbIn = -6; + const linear = linearFromDb(dbIn); + const result = volumeKeyframeValue(linear); + expect(result.kind).toBe("scalar"); + expect((result as { kind: "scalar"; value: number }).value).toBeCloseTo(dbIn, 5); + }); + + it("clamps silence to the volume floor (-60 dB), matching dbFromLinear", () => { + const result = volumeKeyframeValue(0); + expect(result).toEqual({ kind: "scalar", value: -60 }); + }); +}); + +describe("scaleKeyframeValue", () => { + it("builds an AnimPair {a: width, b: height} using the known media aspect", () => { + // aspect 2 (source is twice as wide as tall relative to canvas): width 0.5 -> height 0.25 + const result = scaleKeyframeValue(tf({ width: 1, height: 0.5 }), 0.5, 2); + expect(result).toEqual({ kind: "pair", value: { a: 0.5, b: 0.25 } }); + }); + + it("falls back to the clip transform's own aspect when aspect is null (matches resizeTransformKeepingSourceAspect)", () => { + // transform aspect = 1/0.5 = 2 -> width 0.4 -> height 0.2 + const result = scaleKeyframeValue(tf({ width: 1, height: 0.5 }), 0.4, null); + expect(result).toEqual({ kind: "pair", value: { a: 0.4, b: 0.2 } }); + }); +}); + +describe("positionXKeyframeValue", () => { + it("writes the new X into `a` and preserves the sampled Y in `b`", () => { + expect(positionXKeyframeValue(0.3, 0.75)).toEqual({ kind: "pair", value: { a: 0.3, b: 0.75 } }); + }); +}); + +describe("positionYKeyframeValue", () => { + it("preserves the sampled X in `a` and writes the new Y into `b`", () => { + expect(positionYKeyframeValue(0.1, 0.9)).toEqual({ kind: "pair", value: { a: 0.1, b: 0.9 } }); + }); +}); + +describe("cropEdgeKeyframeValue", () => { + it("changes only the given edge, preserving the other three from the sampled crop", () => { + const sampled = crop({ left: 0.1, top: 0.2, right: 0.3, bottom: 0.4 }); + expect(cropEdgeKeyframeValue(sampled, "left", 0.15)).toEqual({ + kind: "crop", + value: { left: 0.15, top: 0.2, right: 0.3, bottom: 0.4 }, + }); + expect(cropEdgeKeyframeValue(sampled, "bottom", 0.5)).toEqual({ + kind: "crop", + value: { left: 0.1, top: 0.2, right: 0.3, bottom: 0.5 }, + }); + }); + + it("does not mutate the sampled crop object passed in", () => { + const sampled = crop({ left: 0.1, top: 0.2, right: 0.3, bottom: 0.4 }); + const snapshot = { ...sampled }; + cropEdgeKeyframeValue(sampled, "right", 0.99); + expect(sampled).toEqual(snapshot); + }); +}); diff --git a/web/src/lib/keyframeValue.ts b/web/src/lib/keyframeValue.ts new file mode 100644 index 0000000..c6851f0 --- /dev/null +++ b/web/src/lib/keyframeValue.ts @@ -0,0 +1,65 @@ +/** + * Pure value-mapping helpers for editing an animated (keyframe-track-active) + * Inspector property. Each function takes the sampled-at-playhead value plus + * the user's raw field input and returns the `KeyframeValueReq` to pass to + * `upsertKeyframe`. These mirror the SAME semantic value the static + * (non-keyframed) field commits via `setClipProperties` today — see + * `Inspector.tsx`'s static `onCommit` handlers for each property, and + * `crates/opentake-ops/src/command.rs::upsert_keyframe` for the Rust-side + * property/value-kind pairing (Opacity/Volume/Rotation=Scalar, + * Position/Scale=Pair, Crop=Crop; Volume is stored in dB). + */ + +import { dbFromLinear, resizeTransformKeepingSourceAspect } from "./clip"; +import type { Crop, KeyframeValueReq, Transform } from "./types"; + +/** Opacity field shows a 0–100 percentage; the track stores 0–1. */ +export function opacityKeyframeValue(percent: number): KeyframeValueReq { + return { kind: "scalar", value: percent / 100 }; +} + +/** Rotation field and track both use degrees — no conversion. */ +export function rotationKeyframeValue(degrees: number): KeyframeValueReq { + return { kind: "scalar", value: degrees }; +} + +/** Volume field displays dB (via `ScrubbableNumberField`'s `format`), but its + * underlying control value is LINEAR amplitude (matching the static field's + * `value={clip.volume}` / `onCommit={(v) => commit({ volume: v })}`). The + * track stores dB, so convert linear -> dB with the same `dbFromLinear` the + * static ReadOnly display already uses for `sampledVolume`. */ +export function volumeKeyframeValue(linear: number): KeyframeValueReq { + return { kind: "scalar", value: dbFromLinear(linear) }; +} + +/** Scale field commits the normalized canvas WIDTH; height is re-derived by + * the SAME `resizeTransformKeepingSourceAspect` the static commit uses (it + * is reused here rather than re-derived, so the aspect-fallback behavior — + * falling back to `clip.transform`'s own aspect when `aspect` is null — + * stays byte-for-byte identical to the static path). The scale track stores + * an `AnimPair` where `a` = width, `b` = height (see `sizeAt` in clip.ts). */ +export function scaleKeyframeValue( + clipTransform: Transform, + width: number, + aspect: number | null, +): KeyframeValueReq { + const next = resizeTransformKeepingSourceAspect(clipTransform, width, aspect); + return { kind: "pair", value: { a: next.width, b: next.height } }; +} + +/** Position X: preserve the sampled Y (other axis) from `topLeftAt`. The + * position track stores an `AnimPair` where `a` = x, `b` = y. */ +export function positionXKeyframeValue(newX: number, sampledY: number): KeyframeValueReq { + return { kind: "pair", value: { a: newX, b: sampledY } }; +} + +/** Position Y: preserve the sampled X (other axis). */ +export function positionYKeyframeValue(sampledX: number, newY: number): KeyframeValueReq { + return { kind: "pair", value: { a: sampledX, b: newY } }; +} + +/** Crop edge: one edge changes, the other three are carried over from the + * sampled crop (immutable spread — never mutate `sampledCrop`). */ +export function cropEdgeKeyframeValue(sampledCrop: Crop, edge: keyof Crop, value: number): KeyframeValueReq { + return { kind: "crop", value: { ...sampledCrop, [edge]: value } }; +} diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index 4f90102..e38bd98 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -283,6 +283,14 @@ export type KeyframePayloadReq = | { kind: "pair"; keyframes: Keyframe[] } | { kind: "crop"; keyframes: Keyframe[] }; +/** An explicit single-value keyframe payload, tagged by `kind` (mirror of + * `KeyframeValueDto`). Unlike `KeyframePayloadReq` (a whole replacement + * track), this carries just the value to upsert at a given frame. */ +export type KeyframeValueReq = + | { kind: "scalar"; value: number } + | { kind: "pair"; value: AnimPair } + | { kind: "crop"; value: Crop }; + /** A project-frame range `[start, end)` for ripple delete. */ export interface FrameRangeReq { start: number; @@ -306,6 +314,13 @@ export type EditRequest = | { type: "setClipProperties"; clipIds: string[]; properties: ClipPropertiesReq } | { type: "setKeyframes"; clipId: string; property: KeyframeProperty; payload: KeyframePayloadReq } | { type: "stampKeyframe"; clipId: string; property: KeyframeProperty; frame: number } + | { + type: "upsertKeyframe"; + clipId: string; + property: KeyframeProperty; + frame: number; + value: KeyframeValueReq; + } | { 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 } @@ -335,7 +350,8 @@ export type EditRequest = | { type: "renameFolder"; entries: RenameEntryReq[] } | { type: "deleteMedia"; assetIds: string[] } | { type: "deleteFolder"; folderIds: string[] } - | { type: "swapMedia"; clipId: string; mediaRef: string }; + | { type: "swapMedia"; clipId: string; mediaRef: string } + | { type: "resetTransform"; clipIds: string[] }; export interface TextEntryReq { trackIndex: number; diff --git a/web/src/store/editActions.ts b/web/src/store/editActions.ts index eca941e..e4e20b7 100644 --- a/web/src/store/editActions.ts +++ b/web/src/store/editActions.ts @@ -27,6 +27,7 @@ import type { Interpolation, KeyframePayloadReq, KeyframeProperty, + KeyframeValueReq, MaskInput, MediaItem, RenameEntryReq, @@ -122,6 +123,15 @@ export async function setEffects(clipIds: string[], effects: EffectInput[]) { await applyAndRefresh({ type: "setEffects", clipIds, effects }); } +/** Inspector Transform section "Reset" button (upstream `transformHeader` + * onReset). Resets transform to identity, opacity to full, clears the + * opacity/position/scale/rotation keyframe tracks, and zeroes both fades. + * Crop is untouched (a separate Inspector section upstream). */ +export async function resetTransform(clipIds: string[]) { + if (clipIds.length === 0) return; + await applyAndRefresh({ type: "resetTransform", clipIds }); +} + export async function linkClips(clipIds: string[]) { await applyAndRefresh({ type: "link", clipIds }); } @@ -169,6 +179,18 @@ export async function stampKeyframe( await applyAndRefresh({ type: "stampKeyframe", clipId, property, frame }); } +/** Upsert a keyframe at `frame` with an EXPLICIT value (not the sampled value — + * that's `stampKeyframe`). The write path for editing an animated property's + * value at the playhead, mirroring upstream `write`'s upsert branch. */ +export async function upsertKeyframe( + clipId: string, + property: KeyframeProperty, + frame: number, + value: KeyframeValueReq, +) { + await applyAndRefresh({ type: "upsertKeyframe", clipId, property, frame, value }); +} + /** Remove the keyframe at `frame`. */ export async function removeKeyframe( clipId: string,