Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

27 changes: 27 additions & 0 deletions crates/opentake-ops/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ pub enum EditCommand {
RemoveTracks { track_indexes: Vec<usize> },
/// Swap two same-kind tracks as whole rows. OpenTake-only extension.
SwapTracks { a: usize, b: usize },
/// Swap the positions — track + start frame — of two clips, so a cross-track
/// drag exchanges them instead of overwriting (swallowing) the destination.
/// Lossless: refused with no change if a clip would overlap a third clip at
/// its new slot. OpenTake-only extension.
SwapClips { a: String, b: String },
/// Insert a new empty track of `kind` (clamped into its zone). Lets the drop
/// flow create a track on demand when the timeline has no compatible one
/// (upstream `placeClip` / `add_clips` with omitted `trackIndex` 鈫?
Expand Down Expand Up @@ -445,6 +450,7 @@ pub fn apply(
EditCommand::Unlink { clip_ids } => unlink(state, clip_ids),
EditCommand::RemoveTracks { track_indexes } => remove_tracks(state, track_indexes),
EditCommand::SwapTracks { a, b } => swap_tracks(state, a, b),
EditCommand::SwapClips { a, b } => swap_clips(state, a, b),
EditCommand::InsertTrack { kind, at } => insert_track_cmd(state, kind, at, ids),
EditCommand::SetTrackProps {
track_index,
Expand Down Expand Up @@ -697,6 +703,27 @@ fn swap_tracks(state: &mut EditorState, a: usize, b: usize) -> Result<EditResult
)
}

/// Swap the positions of two clips. The op refuses (leaves the timeline
/// untouched) when the swap would overlap a third clip; `transact` then reports
/// `changed = false`, so a refused swap is a clean no-op with no undo entry.
fn swap_clips(state: &mut EditorState, a: String, b: String) -> Result<EditResult, EditError> {
if state.find_clip(&a).is_none() {
return Err(EditError::Invalid(format!("Clip not found: {a}")));
}
if state.find_clip(&b).is_none() {
return Err(EditError::Invalid(format!("Clip not found: {b}")));
}
transact(
state,
"Swap Clips",
|_| "Swapped clip positions".to_string(),
move |st| {
ops::swap_clip_positions(&mut st.timeline, &a, &b);
Ok(vec![a, b])
},
)
}

fn insert_clips(
state: &mut EditorState,
track_index: usize,
Expand Down
2 changes: 1 addition & 1 deletion crates/opentake-ops/src/ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ pub use ripple::{
RippleOutcome, RippleRangesReport,
};
pub use split::{split_clip, split_single_clip};
pub use swap::swap_tracks;
pub use swap::{swap_clip_positions, swap_tracks};
pub use tracks::{
available_audio_track_index, insert_track, prune_empty_tracks, remove_tracks,
resolve_or_create_audio_track, zones, ZoneLayout,
Expand Down
178 changes: 176 additions & 2 deletions crates/opentake-ops/src/ops/swap.rs
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
//! Track swap op. OpenTake-only extension: exchange two whole tracks without
//! applying clip overwrite semantics.
//! Swap ops. OpenTake-only extensions: exchange two whole tracks, or exchange
//! the positions of two individual clips — both without the overwrite (clip
//! "swallowing") semantics of a plain move.

use opentake_domain::Timeline;

use crate::ops::clear_region::remove_clip;
use crate::ops::place::sort_clips;

/// Swap two whole tracks. Returns `true` when the timeline changed.
///
/// The video/audio partition invariant is preserved by allowing only same-kind
Expand All @@ -18,6 +22,85 @@ pub fn swap_tracks(timeline: &mut Timeline, a: usize, b: usize) -> bool {
true
}

/// Swap the `(track, start_frame)` of two clips — the clip-level "exchange
/// positions" gesture: drag a clip onto another track's clip so the two trade
/// places instead of one overwriting (swallowing) the other. Returns `true`
/// when the timeline changed.
///
/// Lossless by construction: if either clip, placed at the other's slot, would
/// overlap a *different* clip on its destination track (e.g. the two clips have
/// different durations and a neighbour sits in the way), the swap is refused and
/// the timeline is left untouched. Cross-kind requests (a video clip onto an
/// audio track, or vice versa), a missing clip, or `id_a == id_b` are all no-ops.
pub fn swap_clip_positions(timeline: &mut Timeline, id_a: &str, id_b: &str) -> bool {
if id_a == id_b {
return false;
}
let Some((ta, ca)) = find(timeline, id_a) else {
return false;
};
let Some((tb, cb)) = find(timeline, id_b) else {
return false;
};
let clip_a = timeline.tracks[ta].clips[ca].clone();
let clip_b = timeline.tracks[tb].clips[cb].clone();
// Each clip must be allowed on the other's track (same kind, or both visual).
if !timeline.tracks[tb].kind.is_compatible(clip_a.media_type)
|| !timeline.tracks[ta].kind.is_compatible(clip_b.media_type)
{
return false;
}
let a_start = clip_a.start_frame;
let b_start = clip_b.start_frame;
// Both clips vacate their slots, so they never block each other; only OTHER
// clips on each destination track can refuse the swap.
let exclude = [id_a, id_b];
if !range_free(
&timeline.tracks[tb],
b_start,
b_start + clip_a.duration_frames,
&exclude,
) || !range_free(
&timeline.tracks[ta],
a_start,
a_start + clip_b.duration_frames,
&exclude,
) {
return false;
}
remove_clip(timeline, id_a);
remove_clip(timeline, id_b);
let mut moved_a = clip_a;
moved_a.start_frame = b_start;
let mut moved_b = clip_b;
moved_b.start_frame = a_start;
timeline.tracks[tb].clips.push(moved_a);
timeline.tracks[ta].clips.push(moved_b);
sort_clips(&mut timeline.tracks[ta]);
if ta != tb {
sort_clips(&mut timeline.tracks[tb]);
}
true
}

fn find(timeline: &Timeline, clip_id: &str) -> Option<(usize, usize)> {
timeline.tracks.iter().enumerate().find_map(|(ti, t)| {
t.clips
.iter()
.position(|c| c.id == clip_id)
.map(|ci| (ti, ci))
})
}

/// True when `[start, end)` is free of any clip on `track` whose id isn't in
/// `exclude` (half-open overlap test, matching the timeline's no-overlap rule).
fn range_free(track: &opentake_domain::Track, start: i32, end: i32, exclude: &[&str]) -> bool {
!track
.clips
.iter()
.any(|c| !exclude.contains(&c.id.as_str()) && c.start_frame < end && c.end_frame() > start)
}

#[cfg(test)]
mod tests {
use super::*;
Expand Down Expand Up @@ -129,4 +212,95 @@ mod tests {
["base", "overlay"]
);
}

#[test]
fn swaps_two_single_clips_across_tracks() {
let mut tl = Timeline::new();
tl.tracks.push(track(
"v-top",
ClipType::Video,
vec![clip("x", "m-x", 0, 30)],
));
tl.tracks.push(track(
"v-bot",
ClipType::Video,
vec![clip("y", "m-y", 0, 30)],
));

assert!(swap_clip_positions(&mut tl, "x", "y"));

// x now sits on the bottom track, y on the top — neither swallowed.
let vtop = tl.tracks.iter().find(|t| t.id == "v-top").unwrap();
let vbot = tl.tracks.iter().find(|t| t.id == "v-bot").unwrap();
assert_eq!(vtop.clips.len(), 1);
assert_eq!(vbot.clips.len(), 1);
assert!(vtop.clips.iter().any(|c| c.id == "y" && c.start_frame == 0));
assert!(vbot.clips.iter().any(|c| c.id == "x" && c.start_frame == 0));
}

#[test]
fn swap_exchanges_start_frames_too() {
let mut tl = Timeline::new();
tl.tracks
.push(track("v1", ClipType::Video, vec![clip("x", "m-x", 10, 40)]));
tl.tracks.push(track(
"v2",
ClipType::Video,
vec![clip("y", "m-y", 100, 20)],
));

assert!(swap_clip_positions(&mut tl, "x", "y"));

let v1 = tl.tracks.iter().find(|t| t.id == "v1").unwrap();
let v2 = tl.tracks.iter().find(|t| t.id == "v2").unwrap();
assert!(v2
.clips
.iter()
.any(|c| c.id == "x" && c.start_frame == 100 && c.duration_frames == 40));
assert!(v1
.clips
.iter()
.any(|c| c.id == "y" && c.start_frame == 10 && c.duration_frames == 20));
}

#[test]
fn swap_refused_when_it_would_overlap_a_neighbour() {
let mut tl = Timeline::new();
tl.tracks
.push(track("v1", ClipType::Video, vec![clip("x", "m-x", 0, 100)]));
tl.tracks.push(track(
"v2",
ClipType::Video,
vec![clip("y", "m-y", 0, 20), clip("z", "m-z", 30, 50)],
));
let before = tl.clone();

// x (dur 100) at v2@0 would cover [0,100), overlapping z [30,80) -> refuse.
assert!(!swap_clip_positions(&mut tl, "x", "y"));
assert_eq!(tl, before);
}

#[test]
fn swap_refused_across_kinds() {
let mut tl = Timeline::new();
tl.tracks
.push(track("v", ClipType::Video, vec![clip("x", "m-x", 0, 30)]));
tl.tracks
.push(track("a", ClipType::Audio, vec![clip("y", "m-y", 0, 30)]));
let before = tl.clone();

assert!(!swap_clip_positions(&mut tl, "x", "y"));
assert_eq!(tl, before);
}

#[test]
fn swap_missing_clip_is_noop() {
let mut tl = Timeline::new();
tl.tracks
.push(track("v", ClipType::Video, vec![clip("x", "m-x", 0, 30)]));
let before = tl.clone();

assert!(!swap_clip_positions(&mut tl, "x", "nope"));
assert_eq!(tl, before);
}
}
Loading
Loading