Skip to content
Merged
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
20 changes: 19 additions & 1 deletion crates/opentake-media/src/thumbnail/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,9 @@ pub const THUMB_TOLERANCE_SECS: f64 = 1.0;
pub const IMAGE_THUMB_MAX_PIXEL: u32 = 120;
/// Progressive publish stride (upstream publishes every 50 frames).
pub const PARTIAL_STRIDE: usize = 50;
/// Hard cap for one sprite generation. Long sources still get representative
/// filmstrips without keeping thousands of decoded frames in memory.
pub const MAX_VIDEO_THUMBNAILS: usize = 240;

/// Callback invoked with the partially-decoded thumbnail list for progressive UI
/// updates (upstream's every-50-frames publish).
Expand All @@ -47,6 +50,13 @@ pub fn video_thumbnail_times(duration: f64) -> Vec<f64> {
times
}

fn sprite_thumbnail_times(duration: f64) -> Vec<f64> {
video_thumbnail_times(duration)
.into_iter()
.take(MAX_VIDEO_THUMBNAILS)
.collect()
}

/// Generate a video thumbnail sequence. Returns the disk-cached sequence on a
/// hit; otherwise decodes, saves the sprite cache, and returns. `on_partial` is
/// invoked every [`PARTIAL_STRIDE`] frames for progressive UI updates.
Expand All @@ -63,7 +73,7 @@ pub fn video_thumbnails(
}
}

let times = video_thumbnail_times(duration_secs);
let times = sprite_thumbnail_times(duration_secs);
if times.is_empty() {
return Ok(Vec::new());
}
Expand Down Expand Up @@ -160,6 +170,14 @@ mod tests {
assert_eq!(t, vec![0.0, 1.0, 2.0, 3.0]);
}

#[test]
fn max_video_thumbnails_caps_long_sprite_generation() {
let times = sprite_thumbnail_times(60.0 * 60.0);
assert_eq!(times.len(), MAX_VIDEO_THUMBNAILS);
assert_eq!(times[0], 0.0);
assert_eq!(times[1], 2.0);
}

#[test]
fn image_thumbnail_scales_down_long_edge() {
// Build a 400x100 PNG in a temp file and thumbnail it.
Expand Down
8 changes: 7 additions & 1 deletion crates/opentake-media/src/thumbnail/sprite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ use std::path::{Path, PathBuf};
use image::{ImageBuffer, Rgba, RgbaImage};
use serde::{Deserialize, Serialize};

use super::MAX_VIDEO_THUMBNAILS;
use crate::error::Result;
use crate::frame::RgbaFrame;
use crate::waveform::store::CACHE_SUBDIR;
Expand Down Expand Up @@ -150,7 +151,12 @@ pub fn save_sprite(cache_root: &Path, key: &str, thumbs: &[VideoThumb]) -> Resul
pub fn load_sprite(cache_root: &Path, key: &str) -> Option<Vec<VideoThumb>> {
let meta_bytes = std::fs::read(json_path(cache_root, key)).ok()?;
let meta: ThumbnailCacheMeta = serde_json::from_slice(&meta_bytes).ok()?;
if meta.tile_width == 0 || meta.tile_height == 0 || meta.columns == 0 || meta.times.is_empty() {
if meta.tile_width == 0
|| meta.tile_height == 0
|| meta.columns == 0
|| meta.times.is_empty()
|| meta.times.len() > MAX_VIDEO_THUMBNAILS
{
return None;
}
let sprite = image::open(jpg_path(cache_root, key)).ok()?.to_rgba8();
Expand Down
21 changes: 21 additions & 0 deletions crates/opentake-ops/src/command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,8 @@ pub enum EditCommand {
Unlink { clip_ids: Vec<String> },
/// Remove tracks by index.
RemoveTracks { track_indexes: Vec<usize> },
/// Swap two same-kind tracks as whole rows. OpenTake-only extension.
SwapTracks { a: usize, b: usize },
/// 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 @@ -436,6 +438,7 @@ pub fn apply(
EditCommand::Link { clip_ids } => link(state, clip_ids, ids),
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::InsertTrack { kind, at } => insert_track_cmd(state, kind, at, ids),
EditCommand::SetTrackProps {
track_index,
Expand Down Expand Up @@ -670,6 +673,24 @@ fn set_track_props(
)
}

fn swap_tracks(state: &mut EditorState, a: usize, b: usize) -> Result<EditResult, EditError> {
let track_count = state.timeline.tracks.len();
if a >= track_count || b >= track_count {
return Err(EditError::Invalid(format!(
"track index out of range: a={a}, b={b}, timeline has {track_count} track(s)"
)));
}
transact(
state,
"Swap Tracks",
move |_| format!("Swapped tracks {a} and {b}"),
|st| {
ops::swap_tracks(&mut st.timeline, a, b);
Ok(Vec::new())
},
)
}

fn insert_clips(
state: &mut EditorState,
track_index: usize,
Expand Down
2 changes: 2 additions & 0 deletions crates/opentake-ops/src/ops/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ pub mod move_clips;
pub mod place;
pub mod ripple;
pub mod split;
pub mod swap;
pub mod tracks;
pub mod trim;

Expand All @@ -30,6 +31,7 @@ pub use ripple::{
RippleOutcome, RippleRangesReport,
};
pub use split::{split_clip, split_single_clip};
pub use swap::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
132 changes: 132 additions & 0 deletions crates/opentake-ops/src/ops/swap.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
//! Track swap op. OpenTake-only extension: exchange two whole tracks without
//! applying clip overwrite semantics.

use opentake_domain::Timeline;

/// Swap two whole tracks. Returns `true` when the timeline changed.
///
/// The video/audio partition invariant is preserved by allowing only same-kind
/// swaps. Invalid indexes, identical indexes, and cross-kind requests are no-ops.
pub fn swap_tracks(timeline: &mut Timeline, a: usize, b: usize) -> bool {
if a == b || a >= timeline.tracks.len() || b >= timeline.tracks.len() {
return false;
}
if timeline.tracks[a].kind != timeline.tracks[b].kind {
return false;
}
timeline.tracks.swap(a, b);
true
}

#[cfg(test)]
mod tests {
use super::*;
use opentake_domain::{Clip, ClipType, Track};

fn track(id: &str, kind: ClipType, clips: Vec<Clip>) -> Track {
let mut track = Track::new(id, kind);
track.clips = clips;
track
}

fn clip(id: &str, media_ref: &str, start: i32, duration: i32) -> Clip {
Clip::new(id, media_ref, start, duration)
}

#[test]
fn swaps_adjacent_video_tracks_without_touching_clips() {
let mut tl = Timeline::new();
tl.tracks.push(track(
"v-top",
ClipType::Video,
vec![clip("overlay", "m-overlay", 0, 30)],
));
tl.tracks.push(track(
"v-bottom",
ClipType::Video,
vec![clip("base", "m-base", 10, 40)],
));
tl.tracks.push(track("a1", ClipType::Audio, vec![]));

assert!(swap_tracks(&mut tl, 0, 1));

assert_eq!(
tl.tracks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
["v-bottom", "v-top", "a1"]
);
assert_eq!(tl.tracks[0].clips[0].id, "base");
assert_eq!(tl.tracks[0].clips[0].start_frame, 10);
assert_eq!(tl.tracks[0].clips[0].duration_frames, 40);
assert_eq!(tl.tracks[1].clips[0].id, "overlay");
}

#[test]
fn swaps_non_adjacent_same_kind_tracks() {
let mut tl = Timeline::new();
tl.tracks.push(track("v1", ClipType::Video, vec![]));
tl.tracks.push(track(
"a-top",
ClipType::Audio,
vec![clip("voice", "m-voice", 20, 50)],
));
tl.tracks.push(track("a-mid", ClipType::Audio, vec![]));
tl.tracks.push(track(
"a-bottom",
ClipType::Audio,
vec![clip("music", "m-music", 0, 120)],
));

assert!(swap_tracks(&mut tl, 1, 3));

assert_eq!(
tl.tracks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
["v1", "a-bottom", "a-mid", "a-top"]
);
assert_eq!(tl.tracks[1].clips[0].id, "music");
assert_eq!(tl.tracks[3].clips[0].id, "voice");
}

#[test]
fn rejects_cross_kind_swap_without_mutating() {
let mut tl = Timeline::new();
tl.tracks.push(track(
"v1",
ClipType::Video,
vec![clip("video", "m-video", 0, 30)],
));
tl.tracks.push(track(
"a1",
ClipType::Audio,
vec![clip("audio", "m-audio", 0, 30)],
));
let before = tl.clone();

assert!(!swap_tracks(&mut tl, 0, 1));
assert_eq!(tl, before);
}

#[test]
fn video_swap_changes_track_order_used_for_z_order() {
let mut tl = Timeline::new();
tl.tracks.push(track(
"overlay",
ClipType::Video,
vec![clip("overlay-clip", "m-overlay", 0, 30)],
));
tl.tracks.push(track(
"base",
ClipType::Video,
vec![clip("base-clip", "m-base", 0, 30)],
));

assert_eq!(
tl.tracks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
["overlay", "base"]
);
assert!(swap_tracks(&mut tl, 0, 1));
assert_eq!(
tl.tracks.iter().map(|t| t.id.as_str()).collect::<Vec<_>>(),
["base", "overlay"]
);
}
}
62 changes: 62 additions & 0 deletions crates/opentake-ops/tests/command_apply.rs
Original file line number Diff line number Diff line change
Expand Up @@ -910,6 +910,68 @@ fn remove_tracks_resolves_indexes_before_removal() {
assert_eq!(st.timeline.tracks[0].id, "v1");
}

#[test]
fn swap_tracks_command_undo_restores_order() {
let mut st = state(vec![
video_track("top", true, vec![clip("overlay", 0, 30)]),
video_track("bottom", true, vec![clip("base", 0, 30)]),
audio_track("audio", true, vec![clip("voice", 0, 30)]),
]);
let g = SeqIdGen::default();

let res = apply(&mut st, EditCommand::SwapTracks { a: 0, b: 1 }, &g).unwrap();

assert!(res.changed);
assert_eq!(res.action_name, "Swap Tracks");
assert_eq!(
st.timeline
.tracks
.iter()
.map(|track| track.id.as_str())
.collect::<Vec<_>>(),
["bottom", "top", "audio"]
);
assert_eq!(st.timeline.tracks[0].clips[0].id, "base");
assert_eq!(st.timeline.tracks[1].clips[0].id, "overlay");
assert!(st.can_undo());

let undo = apply(&mut st, EditCommand::Undo, &g).unwrap();
assert!(undo.changed);
assert_eq!(
st.timeline
.tracks
.iter()
.map(|track| track.id.as_str())
.collect::<Vec<_>>(),
["top", "bottom", "audio"]
);
assert_eq!(st.timeline.tracks[0].clips[0].id, "overlay");
assert_eq!(st.timeline.tracks[1].clips[0].id, "base");
}

#[test]
fn swap_tracks_cross_type_is_noop_without_undo_entry() {
let mut st = state(vec![
video_track("video", true, vec![clip("v", 0, 30)]),
audio_track("audio", true, vec![clip("a", 0, 30)]),
]);
let g = SeqIdGen::default();

let res = apply(&mut st, EditCommand::SwapTracks { a: 0, b: 1 }, &g).unwrap();

assert!(!res.changed);
assert_eq!(st.version(), 0);
assert!(!st.can_undo());
assert_eq!(
st.timeline
.tracks
.iter()
.map(|track| track.id.as_str())
.collect::<Vec<_>>(),
["video", "audio"]
);
}

// ---- no-change command ----------------------------------------------------

#[test]
Expand Down
17 changes: 17 additions & 0 deletions src-tauri/src/commands.rs
Original file line number Diff line number Diff line change
Expand Up @@ -233,6 +233,8 @@ pub enum EditRequest {
#[serde(rename_all = "camelCase")]
RemoveTracks { track_indexes: Vec<usize> },
#[serde(rename_all = "camelCase")]
SwapTracks { a: usize, b: usize },
#[serde(rename_all = "camelCase")]
InsertTrack { kind: ClipType, at: Option<usize> },
#[serde(rename_all = "camelCase")]
SetTrackProps {
Expand Down Expand Up @@ -385,6 +387,7 @@ impl EditRequest {
EditRequest::RemoveTracks { track_indexes } => {
EditCommand::RemoveTracks { track_indexes }
}
EditRequest::SwapTracks { a, b } => EditCommand::SwapTracks { a, b },
EditRequest::InsertTrack { kind, at } => EditCommand::InsertTrack { kind, at },
EditRequest::SetTrackProps {
track_index,
Expand Down Expand Up @@ -752,6 +755,20 @@ mod edit_request_serde_tests {
}
}

#[test]
fn deserializes_swap_tracks_and_maps_to_command() {
let request = serde_json::from_str::<EditRequest>(r#"{"type":"swapTracks","a":0,"b":2}"#)
.expect("swapTracks camelCase");

match request.into_command().expect("swapTracks command") {
EditCommand::SwapTracks { a, b } => {
assert_eq!(a, 0);
assert_eq!(b, 2);
}
other => panic!("expected SwapTracks, got {other:?}"),
}
}

#[test]
fn deserializes_effect_commands_and_maps_to_ops_variants() {
let grade = serde_json::from_str::<EditRequest>(
Expand Down
Loading
Loading