Browse / github.com/storytold/filmcraft / patch
timeline: add Shift-drag ripple rate stretch
timeline: add Shift-drag ripple rate stretch Depends on #3225: this branch retains the separate Matroska oracle repair commit `3299fc25`. The feature originated in `214b159b`; current head `d730a02c` merges `main` at `3a5f217b` (FilmCraft 0.13.0). This resolves conflicts with upstream's Alt edge edits while preserving their link-following choice in both preview and release. Alt is captured on the press frame, before the drag threshold. Rate Stretch changes speed by dragging an edge, but it has no visual ripple mode: shortening leaves a gap and lengthening stops at the next clip. The Speed/Duration dialog's “Ripple Edit, Shifting Trailin
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- KeroZelvin (imported from pull request #3228, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +1090 −95 in 18 files
- Review
- pending
- Shared
- 2026-10-28
- Id
917062f19e68bd2494aacc11
How it went
No other operator has reported using it yet.
Diff
raw · crates/edit/src/lib.rs crates/edit/src/ripple_rate_tests.rs crates/edit/src/tests.rs crates/engine/src/commands.rs crates/engine/src/lib.rs crates/engine/src/ripple_rate_stretch_tests.rs crates/matroska/README.md crates/matroska/tests/oracle.rs crates/ui-egui/README.md crates/ui-egui/src/app/frame.rs crates/ui-egui/src/panels/timeline.rs crates/ui-egui/src/panels/timeline/interact.rs crates/ui-egui/src/panels/timeline/stretch_preview.rs crates/ui-egui/src/panels/timeline/stretch_preview/tests.rs crates/ui-egui/src/panels/timeline_hit.rs crates/ui-egui/src/panels/trim_monitor.rs crates/ui-egui/tests/it/timeline_trim_ui.rs docs/control-protocol.md
@@ -836,7 +836,9 @@ pub fn trim(seq: &mut Sequence, clip: ClipId, edge: Edge, mode: TrimMode, delta: /// past it and is linked to a clip that shifts (a split edit). The edit is refused when it would /// overwrite material on a sync-locked track, or when such a linked clip has no room to follow. pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta: Tick, ctx: &mut EditCtx) -> Result<Tick> {- let Some(&first) = clips.first() else { return Ok(Tick::ZERO) };+ if clips.is_empty() {+ return Ok(Tick::ZERO);+ } for c in clips { unlocked_track(seq, *c)?; }@@ -864,12 +866,23 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta origins.push((tid, from)); } }+ shift_ripple_followers(seq, &mut work, clips, edge, &origins, d)?;+ transitions_follow_cuts(seq, &mut work);+ work.check().map_err(EditError::Other)?;+ *seq = work;+ Ok(d)+}++/// Shared follower rules for a ripple trim and an Out-edge ripple rate stretch. The caller edits+/// only a working copy, so a blocked sync-locked track leaves the original sequence untouched.+fn shift_ripple_followers(before: &Sequence, work: &mut Sequence, clips: &[ClipId], edge: Edge, origins: &[(TrackId, Tick)], d: Tick) -> Result<()> {+ let Some(&first) = clips.first() else { return Ok(()) }; // a shorter or longer head: the cut is the latest member's start (a sound that leads its picture // starts earlier), whichever member was grabbed let main_from = if edge == Edge::In { origins.iter().map(|o| o.1).max().unwrap_or_default() } else {- let (tid, _) = seq.find_item(first).ok_or(EditError::NoItem(first))?;+ let (tid, _) = before.find_item(first).ok_or(EditError::NoItem(first))?; origins.iter().find(|(t, _)| *t == tid).map(|o| o.1).unwrap_or_default() }; let shift = if edge == Edge::In { -d } else { d };@@ -912,13 +925,14 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta let closing = if edge == Edge::In { TimeRange::new(from - Tick(1), -shift) } else { TimeRange::new(from + shift, -shift) }; let own = |i: &TrackItem| i.link.is_some_and(|l| own_links.contains(&l)); if tr.items.iter().any(|i| i.range().overlaps(&closing) && !follows(i) && !own(i) && !stays(i)) {- return Err(EditError::SyncLockConflict(track_label(seq, tid)));+ return Err(EditError::SyncLockConflict(track_label(before, tid))); } } let followers: Vec<ClipId> = tr.items.iter().filter(|i| !clips.contains(&i.id) && follows(i)).map(|i| i.id).collect(); for i in &mut tr.items { if !clips.contains(&i.id) && (i.start >= from || followers.contains(&i.id)) {- i.start += shift;+ let start = i.start.0.checked_add(shift.0).filter(|s| *s >= 0 && s.checked_add(i.duration.0).is_some_and(|end| end <= Tick::MAX.0));+ i.start = Tick(start.ok_or_else(|| EditError::SyncLockConflict(track_label(before, tid)))?); } } tr.sort();@@ -931,13 +945,10 @@ pub fn ripple_trim_group(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delta }); // a follower with no room (a clip in its way, or the sequence start) blocks the edit if tr.items.first().is_some_and(|i| i.start < Tick::ZERO) || tr.items.windows(2).any(|w| w[0].end() > w[1].start) {- return Err(EditError::SyncLockConflict(track_label(seq, tr.id)));+ return Err(EditError::SyncLockConflict(track_label(before, tr.id))); } }- transitions_follow_cuts(seq, &mut work);- work.check().map_err(EditError::Other)?;- *seq = work;- Ok(d)+ Ok(()) } /// Rolling edit between two adjacent items on one track: moves the cut by `delta`.@@ -1150,6 +1161,86 @@ pub fn rate_stretch_items(seq: &mut Sequence, clips: &[ClipId], edge: Edge, delt Ok(speed) } +/// Ripple Rate Stretch of an Out edge. Keep the source range, anchor the In edge and move later+/// material by the duration change. Unlike an ordinary stretch, adjacent followers do not bound+/// the new duration. Locked partners stay fixed, as for ordinary Rate Stretch. Split edits with+/// unequal durations/rates are refused: one visual edge cannot give them a common new rate.+pub fn ripple_rate_stretch_items(seq: &mut Sequence, clips: &[ClipId], delta: Tick, ctx: &mut EditCtx) -> Result<f64> {+ let grabbed = *clips.first().ok_or(EditError::Nothing)?;+ unlocked_track(seq, grabbed)?;+ let mut open = Vec::new();+ for &c in clips {+ match unlocked_track(seq, c) {+ Ok(_) => {+ if !open.contains(&c) {+ open.push(c);+ }+ }+ Err(EditError::Locked) => {}+ Err(e) => return Err(e),+ }+ }+ let first = *open.first().ok_or(EditError::Locked)?;+ let (_, main) = seq.find_item(first).ok_or(EditError::NoItem(first))?;+ let duration = main.duration;+ let speed = main.speed;+ let start = main.start;+ let reverse = main.reverse;+ if duration < ctx.min_duration || ctx.min_duration <= Tick::ZERO || !speed.is_finite() || speed <= 0.0 {+ return Err(EditError::Other("rate stretch needs a positive duration and speed".into()));+ }+ let mut origins = Vec::new();+ let mut room = Tick::MAX;+ for c in &open {+ let (tid, it) = seq.find_item(*c).ok_or(EditError::NoItem(*c))?;+ if it.start != start || it.duration != duration || it.speed != speed || it.reverse != reverse || origins.iter().any(|o: &(TrackId, Tick)| o.0 == tid) {+ return Err(EditError::Other("ripple rate stretch needs aligned linked clips with equal durations, speeds and direction".into()));+ }+ if it.frame_hold.is_some() {+ return Err(EditError::Other("a held frame cannot be rate stretched".into()));+ }+ room = room.min(Tick::MAX - it.start);+ origins.push((tid, it.end()));+ }+ if room < ctx.min_duration {+ return Err(EditError::TooShort);+ }+ let new_duration = (duration + delta).clamp(ctx.min_duration, room);+ let d = new_duration - duration;+ if d == Tick::ZERO {+ return Ok(speed);+ }+ // Captions inside the stretched take need retiming, and later captions need a shift. Until+ // that mapping is supported, refuse rather than silently break an unlocked track's sync.+ if let Some(tr) = seq.caption_tracks.iter().find(|tr| !tr.locked && tr.sync_lock && tr.captions.iter().any(|c| c.end() > start)) {+ return Err(EditError::Other(format!(+ "ripple rate stretch cannot retime sync-locked caption track `{}`; lock it or turn off its sync lock to keep it fixed",+ tr.name+ )));+ }+ let mut work = seq.clone();+ let mut new_speed = speed;+ for c in &open {+ let (_, it) = work.find_item_mut(*c).ok_or(EditError::NoItem(*c))?;+ let source_length = it.source_out() - it.source_in;+ new_speed = source_length.0 as f64 / new_duration.0 as f64;+ if !new_speed.is_finite() || new_speed <= 0.0 {+ return Err(EditError::Other("rate stretch needs a positive source range".into()));+ }+ it.duration = new_duration;+ it.speed = new_speed;+ }+ shift_ripple_followers(seq, &mut work, &open, Edge::Out, &origins, d)?;+ transitions_follow_cuts(seq, &mut work);+ for c in &open {+ let (tid, _) = work.find_item(*c).ok_or(EditError::NoItem(*c))?;+ fades_follow_edges(work.track_mut(tid).ok_or(EditError::NoTrack(tid))?, *c);+ }+ work.check().map_err(EditError::Other)?;+ *seq = work;+ Ok(new_speed)+}+ /// Set speed/duration (Clip ▸ Speed/Duration…). `ripple` shifts following material. pub fn set_speed(seq: &mut Sequence, clip: ClipId, speed: f64, reverse: bool, ripple: bool, ctx: &mut EditCtx) -> Result<()> { set_speed_group(seq, &[clip], speed, reverse, ripple, ctx)new file mode 100644@@ -0,0 +1,205 @@+use super::*;++/// Apply an Out-edge ripple stretch with the fixture’s edit context.+fn stretch(fx: &mut Fx, clips: &[ClipId], delta: Tick) -> Result<f64> {+ let mut next = fx.next;+ ripple_rate_stretch_items(&mut fx.seq, clips, delta, &mut Fx::ctx(&mut next))+}++/// Opening and closing the cut preserve source frames, existing gaps and excluded tracks.+#[test]+fn both_directions_keep_adjacency_gaps_source_and_locked_music() {+ for delta in [-17, 23] {+ for reverse in [false, true] {+ let mut fx = Fx::new();+ let (v1, v2, v3, a1, a2) = (fx.v(0), fx.v(1), fx.v(2), fx.a(0), fx.a(1));+ let pic = fx.put(v1, 10, 60, 30);+ let sound = fx.put(a1, 10, 60, 30);+ fx.seq.find_item_mut(pic).unwrap().1.reverse = reverse;+ fx.seq.find_item_mut(sound).unwrap().1.reverse = reverse;+ fx.put(v1, 70, 20, 100);+ fx.put(v1, 95, 20, 120);+ fx.put(a1, 70, 20, 100);+ fx.put(v2, 80, 20, 100);+ fx.put(v3, 80, 20, 100);+ fx.seq.track_mut(v3).unwrap().sync_lock = false;+ fx.put(a2, 0, 300, 0);+ fx.seq.track_mut(a2).unwrap().locked = true;+ let before = fx.seq.clone();+ let speed = stretch(&mut fx, &[pic, sound], f(delta)).unwrap();+ assert!((speed - 60.0 / (60 + delta) as f64).abs() < 1e-12);+ assert_eq!(fx.spans(v1), vec![(10, 60 + delta), (70 + delta, 20), (95 + delta, 20)]);+ assert_eq!(fx.spans(a1), vec![(10, 60 + delta), (70 + delta, 20)]);+ assert_eq!(fx.spans(v2), vec![(80 + delta, 20)]);+ assert_eq!(fx.seq.track(a2), before.track(a2), "locked music unchanged");+ assert_eq!(fx.seq.track(v3), before.track(v3), "sync lock off unchanged");+ for c in [pic, sound] {+ let old = before.find_item(c).unwrap().1;+ let new = fx.seq.find_item(c).unwrap().1;+ assert_eq!((new.source_in, new.source_out(), new.reverse), (old.source_in, old.source_out(), old.reverse));+ }+ fx.seq.check().unwrap();+ }+ }+}++/// A linked split-edit follower moves once, or a collision rejects the whole edit.+#[test]+fn split_edit_follower_moves_once_or_refuses_atomically() {+ for blocked in [false, true] {+ let mut fx = Fx::new();+ let (v1, a1) = (fx.v(0), fx.a(0));+ let main = fx.put(v1, 0, 60, 20);+ let later = fx.put(v1, 60, 30, 80);+ let lead = fx.put(a1, 50, 40, 70);+ for c in [later, lead] {+ fx.seq.find_item_mut(c).unwrap().1.link = Some(999);+ }+ if blocked {+ fx.put(a1, 0, 49, 0);+ }+ let before = fx.seq.clone();+ let r = stretch(&mut fx, &[main], -f(10));+ if blocked {+ assert!(r.is_err());+ assert_eq!(fx.seq, before);+ } else {+ r.unwrap();+ assert_eq!(fx.spans(v1), vec![(0, 50), (50, 30)]);+ assert_eq!(fx.spans(a1), vec![(40, 40)]);+ }+ }+}++/// Closing across an independent sync-locked clip refuses without changing the sequence.+#[test]+fn sync_locked_material_across_closing_region_blocks_without_mutation() {+ let mut fx = Fx::new();+ let (v1, a1) = (fx.v(0), fx.a(0));+ let clip = fx.put(v1, 0, 60, 20);+ fx.put(v1, 60, 30, 80);+ fx.put(a1, 0, 100, 0);+ let before = fx.seq.clone();+ assert!(matches!(stretch(&mut fx, &[clip], -f(10)), Err(EditError::SyncLockConflict(_))));+ assert_eq!(fx.seq, before);+}++/// Locked members stay fixed; a selection containing only locked members cannot stretch.+#[test]+fn locked_partners_stay_fixed_and_all_locked_refuses() {+ let mut fx = Fx::new();+ let (v1, a1) = (fx.v(0), fx.a(0));+ let pic = fx.put(v1, 0, 60, 20);+ let sound = fx.put(a1, 0, 60, 20);+ fx.seq.track_mut(a1).unwrap().locked = true;+ let locked = fx.seq.track(a1).unwrap().clone();+ stretch(&mut fx, &[pic, sound], f(10)).unwrap();+ assert_eq!(fx.seq.track(a1).unwrap(), &locked);+ fx.seq.track_mut(v1).unwrap().locked = true;+ let before = fx.seq.clone();+ assert_eq!(stretch(&mut fx, &[pic, sound], f(10)), Err(EditError::Locked));+ assert_eq!(fx.seq, before);+}++/// Differently edited linked members cannot be retimed as one aligned group.+#[test]+fn unequal_or_offset_linked_members_are_refused() {+ for change in [0, 1, 2, 3] {+ let mut fx = Fx::new();+ let (v1, a1) = (fx.v(0), fx.a(0));+ let pic = fx.put(v1, 20, 60, 20);+ let snd = fx.put(a1, 20, 60, 20);+ let i = fx.seq.find_item_mut(snd).unwrap().1;+ match change {+ 0 => i.start += f(1),+ 1 => i.duration -= f(1),+ 2 => i.speed = 2.0,+ _ => i.reverse = true,+ }+ let before = fx.seq.clone();+ assert!(stretch(&mut fx, &[pic, snd], f(10)).is_err());+ assert_eq!(fx.seq, before);+ }+}++/// Invalid ids and overflow refuse, while repeated bounded edits retain precise source ranges.+#[test]+fn bounds_and_missing_ids_are_safe_and_rate_keeps_precision() {+ let mut fx = Fx::new();+ let v1 = fx.v(0);+ let clip = fx.put(v1, 0, 60, 10);+ fx.put(v1, 60, 10, 0);+ let before = fx.seq.clone();+ assert!(stretch(&mut fx, &[clip, ClipId(999)], f(1)).is_err());+ assert!(stretch(&mut fx, &[clip], Tick(i64::MAX)).is_err());+ assert_eq!(fx.seq, before);+ stretch(&mut fx, &[clip], Tick(i64::MIN)).unwrap();+ assert_eq!(fx.seq.find_item(clip).unwrap().1.duration, f(1));+ assert_eq!(fx.spans(v1), vec![(0, 1), (1, 10)]);+ for _ in 0..50 {+ stretch(&mut fx, &[clip], f(6)).unwrap();+ stretch(&mut fx, &[clip], -f(6)).unwrap();+ }+ let i = fx.seq.find_item(clip).unwrap().1;+ assert_eq!((i.source_in, i.source_out(), i.duration), (f(10), f(70), f(1)));+}++/// Transitions follow the moved cut and fades fit within a shortened clip.+#[test]+fn transitions_follow_changed_cut_and_fades_fit_new_duration() {+ let mut fx = Fx::new();+ let v1 = fx.v(0);+ let main = fx.put(v1, 0, 60, 30);+ let next = fx.put(v1, 60, 40, 100);+ let transition = |id, start, from, to| Transition {+ id: TransitionId(id),+ effect: filmcraft_project::find_effect("cross_dissolve").unwrap().instance(),+ start: f(start),+ duration: f(6),+ from,+ to,+ align: Default::default(),+ reverse: false,+ };+ fx.seq.track_mut(v1).unwrap().transitions = vec![transition(1, 57, Some(main), Some(next)), transition(2, 54, Some(main), None)];+ stretch(&mut fx, &[main], -f(58)).unwrap();+ let tr = fx.seq.track(v1).unwrap();+ let cut = tr.transitions.iter().find(|t| t.id == TransitionId(1)).unwrap();+ let fade = tr.transitions.iter().find(|t| t.id == TransitionId(2)).unwrap();+ assert_eq!((cut.start, cut.duration), (f(0), f(6)), "transition stays within its clips when the head gets shorter");+ assert_eq!((fade.start, fade.duration), (f(0), f(2)), "fade capped to new clip");+}++/// Affected caption tracks block the edit unless locked or excluded from sync lock.+#[test]+fn affected_sync_locked_captions_block_until_explicitly_excluded() {+ for delta in [-10, 10] {+ for (caption_start, locked, sync_lock, allowed) in+ [(0, false, true, true), (25, false, true, false), (85, false, true, false), (85, true, true, true), (85, false, false, true)]+ {+ let mut fx = Fx::new();+ let v1 = fx.v(0);+ let main = fx.put(v1, 20, 60, 20);+ let mut tr = filmcraft_project::CaptionTrack::new(TrackId(20000), "Subtitles".into(), Default::default());+ tr.locked = locked;+ tr.sync_lock = sync_lock;+ tr.captions.push(filmcraft_project::Caption {+ id: ClipId(20001),+ start: f(caption_start),+ duration: f(10),+ text: "Synthetic".into(),+ speaker: None,+ cue_id: None,+ settings: String::new(),+ });+ fx.seq.caption_tracks.push(tr);+ let before = fx.seq.clone();+ let result = stretch(&mut fx, &[main], f(delta));+ assert_eq!(result.is_ok(), allowed);+ assert_eq!(fx.seq.caption_tracks, before.caption_tracks);+ if !allowed {+ assert_eq!(fx.seq, before);+ }+ }+ }+}@@ -1079,3 +1079,6 @@ fn moving_an_edge_keeps_the_media_under_the_timeline() { } } }++#[path = "ripple_rate_tests.rs"]+mod ripple_rate_tests;@@ -687,6 +687,33 @@ pub fn roll_delta(s: &Session, left: ClipId, right: ClipId, delta: Tick) -> Resu clamp_roll_linked(q, &linked_roll_pairs(q, left, right, s.state.linked_selection), delta, &mut ctx) } +/// The sequence a Rate Stretch would produce, for a transient Timeline preview. No project,+/// history, media or preferences are changed. Preview and release share the edit algebra.+pub fn rate_stretch_preview(s: &Session, clip: ClipId, edge: Edge, delta: Tick, ripple: bool) -> Result<filmcraft_project::Sequence> {+ rate_stretch_preview_linked(s, clip, edge, delta, ripple, true)+}++/// [`rate_stretch_preview`] with the link-following choice captured at the pointer press.+/// With `linked` false (Alt held), only the grabbed member is rate stretched.+pub fn rate_stretch_preview_linked(s: &Session, clip: ClipId, edge: Edge, delta: Tick, ripple: bool, linked: bool) -> Result<filmcraft_project::Sequence> {+ let q = s.active_sequence().ok_or(EngineError::NoSequence)?;+ let mut work = q.clone();+ let durations = |id: ItemId| crate::media_duration(&s.project, &s.media, id);+ let starts = |id: ItemId| crate::media_start(&s.project, id);+ let mut next = s.project.next_id;+ let mut ctx = edit::EditCtx { next_id: &mut next, media_duration: &durations, media_start: &starts, min_duration: q.settings.frame_rate.frame_duration() };+ let clips = if linked { with_links(s, &[clip]) } else { vec![clip] };+ if ripple {+ if edge != Edge::Out {+ return Err(bad("timeline.rateStretch", "ripple rate stretch supports the Out edge only"));+ }+ edit::ripple_rate_stretch_items(&mut work, &clips, delta, &mut ctx)?;+ } else {+ edit::rate_stretch_items(&mut work, &clips, edge, delta, &mut ctx)?;+ }+ Ok(work)+}+ /// Sequence settings matching a media clip (New Sequence From Clip, New Sequence from an item): /// its frame size, rate and pixel aspect ratio (Interpret Footage's, else the file's), so a /// 1440 x 1080 clip with 4:3 pixels makes a 1440 x 1080 sequence that displays 16:9.@@ -2979,19 +3006,33 @@ fn build() -> Vec<CommandSpec> { })?; Ok(Value::Null) }),- cmd!("timeline.rateStretch", "Rate Stretch", [], None, r#"{"clip":id,"edge":"in|out","delta":ticks,"linked":bool?}"#, has_seq, |s, p| {- let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.rateStretch", "need `clip`"))?;- let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out };- let d = p- .get("delta")- .and_then(Value::as_i64)- .map(Tick)- .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))- .unwrap_or_default();- let clips = with_links_p(s, p, c);- let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| Ok(edit::rate_stretch_items(q, &clips, edge, d, ctx)?))?;- Ok(json!({"speed": sp}))- }),+ cmd!(+ "timeline.rateStretch",+ "Rate Stretch",+ [],+ None,+ r#"{"clip":id,"edge":"in|out","delta":ticks,"ripple":bool?,"linked":bool?}"#,+ has_seq,+ |s, p| {+ let c = clip_p(p, "clip").ok_or_else(|| bad("timeline.rateStretch", "need `clip`"))?;+ let edge = if str_p(p, "edge") == Some("in") { Edge::In } else { Edge::Out };+ let d = p+ .get("delta")+ .and_then(Value::as_i64)+ .map(Tick)+ .or_else(|| p.get("deltaFrames").and_then(Value::as_i64).map(|f| s.sequence_rate().tick_of(f)))+ .unwrap_or_default();+ let clips = with_links_p(s, p, c);+ let ripple = bool_p(p, "ripple").unwrap_or(false);+ if ripple && edge != Edge::Out {+ return Err(bad("timeline.rateStretch", "ripple rate stretch supports the Out edge only"));+ }+ let sp = s.edit_sequence("Rate Stretch", |q, ctx, _| {+ Ok(if ripple { edit::ripple_rate_stretch_items(q, &clips, d, ctx)? } else { edit::rate_stretch_items(q, &clips, edge, d, ctx)? })+ })?;+ Ok(json!({"speed": sp}))+ }+ ), cmd!("timeline.razor", "Razor", [], None, r#"{"time":ticks,"track":"V1"|id?,"clip":id?}"#, has_seq, |s, p| { let t = time_p(s, p, "").unwrap_or(s.playhead()); let tr = track_p(s, p, "track", "timeline.razor")?;@@ -1393,3 +1393,6 @@ mod source_placement_tests; mod frame_export_tests; #[cfg(test)] mod wasm_clock_tests;++#[cfg(test)]+mod ripple_rate_stretch_tests;new file mode 100644@@ -0,0 +1,172 @@+use crate::{+ Session,+ commands::{rate_stretch_preview, rate_stretch_preview_linked},+};+use filmcraft_project::ClipId;+use filmcraft_time::Tick;+use serde_json::json;++/// Build three aligned linked pairs and a locked synthetic music bed.+fn fixture() -> (Session, Vec<ClipId>) {+ let mut s = Session::default();+ s.execute("file.newProject", json!({"name":"Ripple stretch"})).unwrap();+ s.execute("file.newSequence", json!({"name":"Sequence", "fps":25, "video":2, "audio":2})).unwrap();+ let r = s.execute("file.newOfflineFile", json!({"name":"Synthetic", "seconds":10, "fps":25, "video":true, "audio":true})).unwrap();+ let item = r["item"].as_u64().unwrap();+ let f = s.sequence_rate().tick_of(1).0;+ let mut ids = Vec::new();+ for n in 0..3 {+ let r = s.execute("timeline.place", json!({"item":item, "track":"V1", "audioTrack":"A1", "time":f*60*n, "sourceIn":f*20, "duration":f*60})).unwrap();+ ids.extend(r["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())));+ }+ s.execute("timeline.place", json!({"item":item, "track":"A2", "time":0, "duration":f*240})).unwrap();+ s.execute("timeline.setTrack", json!({"track":"A2", "locked":true})).unwrap();+ (s, ids)+}++/// Preview, command, persistence and undo agree for either drag direction and trailing selection.+#[test]+fn ripple_rate_stretch_keeps_linked_media_locked_music_and_one_undo_step() {+ for delta in [-20, 20] {+ for selected in [false, true] {+ let (mut s, ids) = fixture();+ if selected {+ s.execute("timeline.select", json!({"clips":[ids[2].0, ids[4].0]})).unwrap();+ }+ let before = s.project.clone();+ let history = s.history.undo.len();+ let d = s.sequence_rate().tick_of(delta);+ let preview = rate_stretch_preview(&s, ids[0], filmcraft_edit::Edge::Out, d, true).unwrap();+ assert_eq!(*s.project, *before, "preview is read only");+ assert_eq!(s.history.undo.len(), history);+ let r = s.execute("timeline.rateStretch", json!({"clip":ids[0].0, "edge":"out", "deltaFrames":delta, "ripple":true})).unwrap();+ let q = s.active_sequence().unwrap();+ assert_eq!(q, &preview, "release matches preview exactly");+ let old = before.sequence(s.state.active_sequence.unwrap()).unwrap();+ assert_eq!(q.audio_tracks[1], old.audio_tracks[1]);+ let f = s.sequence_rate().tick_of(1);+ for (n, c) in ids.iter().enumerate() {+ let now = q.find_item(*c).unwrap().1;+ let was = old.find_item(*c).unwrap().1;+ assert_eq!((now.source_in, now.source_out()), (was.source_in, was.source_out()));+ if n < 2 {+ assert_eq!(now.duration, Tick(f.0 * (60 + delta)));+ assert!((now.speed - 60.0 / (60 + delta) as f64).abs() < 1e-12);+ } else {+ assert_eq!(now.start, was.start + d);+ assert_eq!(now.source_time_at(now.start), was.source_time_at(was.start));+ }+ }+ assert!((r["speed"].as_f64().unwrap() - 60.0 / (60 + delta) as f64).abs() < 1e-12);+ let after = s.project.clone();+ let reopened = filmcraft_format::decode(&filmcraft_format::encode(&after, true)).unwrap().project;+ assert_eq!(reopened.sequence(s.state.active_sequence.unwrap()), Some(q), "saved state retains source ranges and rates");+ assert_eq!(s.history.undo.len(), history + 1);+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(*s.project, *before);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(*s.project, *after);+ }+ }+}++/// The Speed/Duration ripple option does not persist into a later ordinary stretch.+#[test]+fn ordinary_stretch_and_dialog_ripple_do_not_enable_later_visual_ripple() {+ let (mut s, ids) = fixture();+ s.execute("clip.speedDuration", json!({"clips":[ids[0].0], "speed":200, "ripple":true})).unwrap();+ let before = s.project.clone();+ s.execute("timeline.rateStretch", json!({"clip":ids[0].0, "edge":"out", "deltaFrames":-10})).unwrap();+ let q = s.active_sequence().unwrap();+ for c in &ids[2..] {+ assert_eq!(q.find_item(*c), before.sequence(s.state.active_sequence.unwrap()).unwrap().find_item(*c));+ }+}++/// Unsupported edges, hostile deltas and locked edits leave project and history unchanged.+#[test]+fn unsupported_in_edge_and_hostile_commands_are_atomic() {+ let (mut s, ids) = fixture();+ for p in [+ json!({"clip":ids[0].0,"edge":"in","deltaFrames":5,"ripple":true}),+ json!({"clip":999999,"deltaFrames":5,"ripple":true}),+ json!({"clip":ids[0].0,"delta":i64::MAX,"ripple":true}),+ ] {+ let before = s.project.clone();+ let history = s.history.undo.len();+ assert!(s.execute("timeline.rateStretch", p).is_err());+ assert_eq!(*s.project, *before);+ assert_eq!(s.history.undo.len(), history);+ }+ s.execute("timeline.setTrack", json!({"track":"V1","locked":true})).unwrap();+ let before = s.project.clone();+ assert!(s.execute("timeline.rateStretch", json!({"clip":ids[0].0,"deltaFrames":5,"ripple":true})).is_err());+ assert_eq!(*s.project, *before);+}++/// A separate synthetic CPU-render check for the reported temporary black clip. This checks+/// source coverage, not the desktop's asynchronous decoder/cache path or the reporter's media.+#[test]+fn synthetic_speed_duration_ripple_still_renders_after_undo_redo() {+ let mut s = Session::default();+ s.execute("file.openDemoProject", json!({})).unwrap();+ let item = s.active_sequence().unwrap().video_tracks[0].items[0].item;+ s.execute("file.newSequence", json!({"name":"Render check","fps":25,"width":64,"height":64,"video":1,"audio":0})).unwrap();+ let r = s.execute("timeline.place", json!({"item":item.0,"track":"V1","audio":false,"time":0,"duration":s.sequence_rate().tick_of(60).0})).unwrap();+ let c = ClipId(r["clips"][0].as_u64().unwrap());+ let original = s.render_program_at(1.0, Tick::ZERO).unwrap();+ for speed in [50, 200] {+ s.execute("clip.speedDuration", json!({"clips":[c.0],"speed":speed,"ripple":true})).unwrap();+ for _ in 0..2 {+ let clip = s.active_sequence().unwrap().find_item(c).unwrap().1;+ let duration = clip.duration;+ for t in [Tick::ZERO, Tick(duration.0 / 2), duration - s.sequence_rate().frame_duration()] {+ let (image, offline) = s.try_render_program_with_offline_at(1.0, t).unwrap();+ assert!(offline.is_empty());+ assert!(image.px.as_chunks::<4>().0.iter().any(|p| p[0] > 1e-4 || p[1] > 1e-4 || p[2] > 1e-4));+ }+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(s.render_program_at(1.0, Tick::ZERO).unwrap().px, original.px);+ s.execute("edit.redo", json!({})).unwrap();+ }+ s.execute("edit.undo", json!({})).unwrap();+ }+}++/// The preview and command both refuse affected sync-locked captions without adding history.+#[test]+fn caption_refusal_matches_preview_and_preserves_history() {+ let (mut s, ids) = fixture();+ s.execute("captions.add", json!({"text":"Synthetic caption","frame":80,"durationSeconds":1})).unwrap();+ let before = s.project.clone();+ let history = s.history.undo.len();+ let delta = s.sequence_rate().tick_of(-10);+ assert!(rate_stretch_preview(&s, ids[0], filmcraft_edit::Edge::Out, delta, true).is_err());+ assert!(s.execute("timeline.rateStretch", json!({"clip":ids[0].0,"edge":"out","delta":delta.0,"ripple":true})).is_err());+ assert_eq!(*s.project, *before);+ assert_eq!(s.history.undo.len(), history);+}++/// Alt’s captured link-following choice agrees between preview, commit and undo.+#[test]+fn alt_rate_stretch_preview_and_release_leave_the_linked_member_unchanged() {+ for (ripple, frames) in [(false, -10), (true, 10)] {+ let (mut s, ids) = fixture();+ let before = s.project.clone();+ let history = s.history.undo.len();+ let delta = s.sequence_rate().tick_of(frames);+ let preview = rate_stretch_preview_linked(&s, ids[0], filmcraft_edit::Edge::Out, delta, ripple, false).unwrap();+ assert_eq!(*s.project, *before, "preview is read only");+ s.execute("timeline.rateStretch", json!({"clip":ids[0].0,"edge":"out","delta":delta.0,"ripple":ripple,"linked":false})).unwrap();+ let q = s.active_sequence().unwrap();+ let old = before.sequence(s.state.active_sequence.unwrap()).unwrap();+ assert_eq!(q, &preview, "Alt preview and release agree");+ assert_eq!(q.find_item(ids[1]), old.find_item(ids[1]), "the edited clip's linked audio stays unchanged");+ assert_eq!(q.find_item(ids[0]).unwrap().1.duration, old.find_item(ids[0]).unwrap().1.duration + delta);+ assert_eq!(s.history.undo.len(), history + 1);+ s.execute("edit.undo", json!({})).unwrap();+ assert_eq!(*s.project, *before);+ s.execute("edit.redo", json!({})).unwrap();+ assert_eq!(s.active_sequence().unwrap(), &preview);+ }+}@@ -1,3 +1,17 @@ # filmcraft-matroska A re-export of `craft_media::matroska`. Documentation: https://github.com/storytold/craft-libs/blob/main/crates/media/README.md++## Oracle coverage++`tests/oracle.rs` requires actual Xiph/EBML lacing on AAC and FLAC fixtures, and+FixedOrEbml lacing on PCM. The Vorbis remux regression supplies a deliberately+incompatible declared duration and requires the writer to keep every packet's+original timestamp, payload, keyframe flag and codec headers without lacing.+This avoids assuming that every encoder's `DefaultDuration` permits lacing.+All remux cases are checked independently with ffprobe; assertions are not skipped+when requested lacing cannot preserve timestamps.++Laced frames share a block timestamp and must be contiguous; see+[RFC 9559 section 10.3.5](https://www.rfc-editor.org/rfc/rfc9559.html#section-10.3.5).+The muxer implementation and media dependencies are unchanged by this oracle repair.@@ -272,16 +272,66 @@ fn av1() { assert!(matches!(&f.tracks[0].codec, Codec::Av1 { av1c } if av1c.first() == Some(&0x81))); } -/// Re-mux a fixture with our writer using each lacing mode; ffprobe must read the result identically-/// to our demuxer (and to the source packets).+/// Remux without changing packet timestamps or payloads, then check the output independently+/// with ffprobe. Requesting a lacing mode does not guarantee a lace: the supplied duration must+/// also reproduce the packet timestamps (RFC 9559 section 10.3.5).+fn remux_and_compare(ffprobe: &Path, file: &MkvFile, packets: &[Packet], mode: LacingMode, label: &str, expect_laced: bool) {+ let specs: Vec<TrackSpec> = file+ .tracks+ .iter()+ .enumerate()+ .map(|(ti, t)| {+ let mut s = TrackSpec::new(t.kind, t.codec_id.clone());+ s.codec_private = t.codec_private.clone();+ // Preserve a declared duration; only supply one when the input has none.+ s.default_duration_ns = t.default_duration_ns.or_else(|| {+ let pts: Vec<i64> = packets.iter().filter(|p| p.track == ti).map(|p| p.pts_ns).collect();+ (t.kind == TrackKind::Audio && pts.len() > 1).then(|| ((pts[pts.len() - 1] - pts[0]) / (pts.len() as i64 - 1)) as u64)+ });+ s.video_size = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height));+ s.audio = t.audio.as_ref().map(|a| (a.sampling_frequency, a.channels, a.bit_depth));+ s.codec_delay_ns = t.codec_delay_ns;+ s+ })+ .collect();+ let opts = MuxOptions { lacing: mode, ..Default::default() };+ let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), specs, opts).unwrap();+ for p in packets {+ w.write_frame(p.track, p.pts_ns, p.keyframe, &p.data, None).unwrap();+ }+ let out = w.finish().unwrap().into_inner();+ let laced = {+ let f = open(&out[..]).unwrap();+ f.tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0))+ };+ assert_eq!(laced, expect_laced, "{label} {mode:?}: unexpected lacing");+ let outp = fixture_dir().join(format!("remux_{mode:?}_{label}"));+ std::fs::write(&outp, &out).unwrap();+ let (got_file, got) = compare(ffprobe, &outp);+ assert_eq!(got_file.tracks.len(), file.tracks.len());+ for (a, b) in got_file.tracks.iter().zip(&file.tracks) {+ assert_eq!((&a.codec_id, &a.codec_private, a.codec_delay_ns), (&b.codec_id, &b.codec_private, b.codec_delay_ns), "{label} {mode:?}: codec headers");+ }+ assert_eq!(got.len(), packets.len());+ for (a, b) in got.iter().zip(packets) {+ assert_eq!(a.data, b.data, "{label} {mode:?}: payload");+ assert_eq!((a.track, a.pts, a.keyframe), (b.track, b.pts, b.keyframe), "{label} {mode:?}: timestamp/keyframe");+ if !expect_laced {+ assert_eq!(a.pts_ns, b.pts_ns, "{label} {mode:?}: explicit timestamp precision");+ }+ }+ std::fs::remove_file(&outp).unwrap();+}++/// Exercise each lacing mode on packets that can actually share a lace. Vorbis's encoder-+/// supplied DefaultDuration need not match its packet spacing; that fallback is checked below. #[test] fn laced_remux() { let Some((ffmpeg, ffprobe)) = tools() else { return }; for (src, modes) in [- ("vorbis.mkv", &[LacingMode::Xiph, LacingMode::Ebml][..]), ("h264_aac.mkv", &[LacingMode::Ebml, LacingMode::Xiph][..]), ("prores_pcm.mkv", &[LacingMode::FixedOrEbml][..]),- ("flac.mkv", &[LacingMode::Ebml][..]),+ ("flac.mkv", &[LacingMode::Ebml, LacingMode::Xiph][..]), ] { let Some(path) = fixture(&ffmpeg, src) else { continue }; let bytes = std::fs::read(&path).unwrap();@@ -289,48 +339,33 @@ fn laced_remux() { let file = d.file().clone(); let packets: Vec<Packet> = d.collect::<Result<_>>().unwrap(); for &mode in modes {- let specs: Vec<TrackSpec> = file- .tracks- .iter()- .enumerate()- .map(|(ti, t)| {- let mut s = TrackSpec::new(t.kind, t.codec_id.clone());- s.codec_private = t.codec_private.clone();- // audio without a DefaultDuration gets its mean packet spacing, so it can lace- s.default_duration_ns = t.default_duration_ns.or_else(|| {- let pts: Vec<i64> = packets.iter().filter(|p| p.track == ti).map(|p| p.pts_ns).collect();- (t.kind == TrackKind::Audio && pts.len() > 1).then(|| ((pts[pts.len() - 1] - pts[0]) / (pts.len() as i64 - 1)) as u64)- });- s.video_size = t.video.as_ref().map(|v| (v.pixel_width, v.pixel_height));- s.audio = t.audio.as_ref().map(|a| (a.sampling_frequency, a.channels, a.bit_depth));- s.codec_delay_ns = t.codec_delay_ns;- s- })- .collect();- let opts = MuxOptions { lacing: mode, ..Default::default() };- let mut w = MkvWriter::new(std::io::Cursor::new(Vec::new()), specs, opts).unwrap();- for p in &packets {- w.write_frame(p.track, p.pts_ns, p.keyframe, &p.data, None).unwrap();- }- let out = w.finish().unwrap().into_inner();- let laced = {- let f = open(&out[..]).unwrap();- f.tracks.iter().any(|t| t.samples.iter().any(|s| s.lace > 0))- };- assert!(laced, "{src} {mode:?}: nothing was laced");- let outp = fixture_dir().join(format!("remux_{mode:?}_{src}"));- std::fs::write(&outp, &out).unwrap();- let (_, got) = compare(&ffprobe, &outp);- assert_eq!(got.len(), packets.len());- for (a, b) in got.iter().zip(&packets) {- assert_eq!(a.data, b.data, "{src} {mode:?}: payload");- assert_eq!((a.track, a.pts), (b.track, b.pts), "{src} {mode:?}: timestamp");- }- std::fs::remove_file(&outp).unwrap();+ remux_and_compare(&ffprobe, &file, &packets, mode, src, true); } } } +/// A mode request must not force lacing when DefaultDuration cannot describe the existing PTS.+/// In particular, some FFmpeg Vorbis fixtures declare a much shorter duration than their packet+/// spacing. Make that mismatch deterministic without changing any source packet's timestamp.+#[test]+fn vorbis_lacing_preserves_pts_with_mismatched_duration() {+ let Some((ffmpeg, ffprobe)) = tools() else { return };+ let Some(path) = fixture(&ffmpeg, "vorbis.mkv") else { return };+ let bytes = std::fs::read(path).unwrap();+ let d = Demuxer::from_slice(&bytes).unwrap();+ let mut file = d.file().clone();+ let packets: Vec<Packet> = d.collect::<Result<_>>().unwrap();+ assert_eq!(file.tracks.len(), 1);+ assert_eq!(file.tracks[0].kind, TrackKind::Audio);+ assert!(packets.len() > 1 && packets.windows(2).all(|w| w[1].pts > w[0].pts), "fixture must have distinct increasing timestamps");+ // One nanosecond cannot advance a frame by even one of this file's timestamp ticks.+ // This remains a mismatch regardless of the encoder's own DefaultDuration choice.+ file.tracks[0].default_duration_ns = Some(1);+ for mode in [LacingMode::Xiph, LacingMode::Ebml] {+ remux_and_compare(&ffprobe, &file, &packets, mode, "mismatched_vorbis.mkv", false);+ }+}+ /// Damaged file: overwrite bytes in the middle of the clusters; the demuxer must not fail and must /// resync on a later cluster. #[test]@@ -3,6 +3,47 @@ The egui frontend draws panels and dialogs, and dispatches project changes through the engine. Interactive controls keep stable automation ids regardless of the interface language. +## Visual ripple rate stretch++With the Rate Stretch tool (`R`), hold **Shift when pressing an Out edge**, then drag to shorten+or lengthen the clip while trailing clips follow. The hover bracket turns red. The gesture is+captured at the press, so releasing Shift before the mouse does not change the edit. The+preview uses the same edit algebra as release; a blocked edit reports its reason and stays put.+One drag is one undo step.++Pointer updates and drawing share a bounded cache of successful or blocked edit previews. The+key includes project identity/revision, active sequence, link-selection state and stretch inputs;+clamped requests share their accepted-delta preview. Ending or cancelling the drag releases the+snapshots. `stretch_preview::tests` covers reuse and invalidation without mutating the project.++Plain Rate Stretch, including Shift on an In edge, retains its usual neighbour-limited behavior.+The **Ripple Edit, Shifting Trailing Clips** checkbox applies only to the Speed/Duration dialog;+it does not enable a persistent mode for later drags. There is no new global preference.++The edited clip's In edge and source span stay fixed. Followers on its track and unlocked,+sync-locked video/audio tracks move once by the duration change, preserving existing gaps and+following split-edit partners according to ripple-trim rules. Selection does not restrict the+followers. Locked tracks (including a music bed) stay fixed. An unlocked grabbed clip may stretch+while its locked linked partner stays fixed, matching ordinary Rate Stretch's locked-partner rule.+Linked members being stretched must have matching starts, durations, speeds and direction;+other split edits are refused rather than silently losing their source correspondence. In-edge+ripple rate stretch and retiming captions are outside this gesture's scope. Affected unlocked,+sync-locked caption tracks therefore block the edit with an explanation; locked captions or+tracks with sync lock off stay fixed. Rate stretch does+not consume extra source handles; transitions follow their cut and one-sided fades fit the clip.++Automation: `timeline.rateStretch` accepts `ripple: true` for the Out edge, and+`ui.timeline.hit` reports `kind: "rippleRateStretch"` for Shift + Out edge. The command's default+is non-ripple. Edit-algebra, engine undo/redo and headless pointer tests use synthetic fixtures;+the ignored `ripple_rate_stretch_screenshot` test renders the hover and preview without a desktop+window. Live playback, hardware decode and the separate report of a temporarily black clip are+not established by these geometry/state tests.++Alt at the press leaves the edited clip's linked partner unstretched, matching ordinary edge+edits. The preview and release capture this choice together with Shift. `timeline.rateStretch`+accepts `linked: false` for the same operation; ripple followers still obey track sync-lock and+collision rules, so an unchanged partner that blocks a shortening refuses the edit atomically.+ ## Control input limits The control channel bounds `ui.click` to 10 clicks and `ui.drag` to 1024 movement steps (#2784).@@ -171,7 +171,7 @@ impl FilmcraftApp { state::Tool::TrackSelectBackward => tl!("Click to select all clips to the left in all tracks. Shift-click for a single track."), state::Tool::Ripple => tl!("Drag an edit point to ripple trim; later clips move to keep the gap closed."), state::Tool::Rolling => tl!("Drag an edit point to roll it: the out of one clip and the in of the next move together."),- state::Tool::RateStretch => tl!("Drag an edge to change the clip's speed so it fills the new duration."),+ state::Tool::RateStretch => tl!("Drag an edge to change speed. Shift-drag an Out edge to shift trailing clips."), state::Tool::Remix => tl!("Drag the edge of a music clip to remix it to the new duration at musically matching beats."), state::Tool::Razor => tl!("Click to split a clip. Shift-click to split all tracks."), state::Tool::Slip => tl!("Drag a clip to slip its source in/out without moving it."),@@ -34,6 +34,7 @@ mod menus; mod ruler; mod scrollbars; mod snap;+mod stretch_preview; mod waveforms; #[cfg(test)]@@ -91,6 +92,8 @@ pub struct TlState { pub drag: Option<Drag>, /// What the primary button went down on in the Timeline (#259); see [`Pressed`]. pub pressed: Option<Pressed>,+ /// The last two stretch calculations, shared by pointer updates and drawing.+ stretch_preview: Mutex<stretch_preview::Cache>, /// A drag or press aborted with Escape; ignores release until the button comes up (#580). pub cancelled: bool, /// Last layout (for hit-testing from the control channel).@@ -131,6 +134,7 @@ impl TlState { self.duplicates = None; self.duplicates_pending = None; self.duplicates_done = Default::default();+ self.stretch_preview = Default::default(); } } @@ -144,6 +148,8 @@ pub struct Pressed { /// The time under the pointer. pub tick: Tick, pub grab: Grab,+ /// Whether an edge edit follows linked partners, captured before the drag threshold.+ pub linked: bool, } #[derive(Clone, Debug)]@@ -198,6 +204,8 @@ pub enum Drag { delta: Tick, /// As in [`Drag::Trim`]. from: Tick,+ /// Captured at press by the shared hit test; never inherited from Speed/Duration.+ ripple: bool, /// As in [`Drag::Trim`]. linked: bool, },@@ -444,7 +452,7 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // clips whose media changes in the preview (a trimmed or rolled edge): drawn from the edited // item, so the waveform and thumbnail stay where the media is instead of squeezing (#374) let mut edited: HashMap<ClipId, TrackItem> = HashMap::new();- preview_drag(app, seq, &mut previews, &mut edited);+ let stretch_preview = preview_drag(app, seq, &mut previews, &mut edited); // Option-drag copies: the clips stay where they are and their copies follow the pointer let copying = matches!(app.tl.drag, Some(Drag::Move { copy: true, .. })); let copies: Vec<(ClipId, (Tick, Tick, Option<TrackId>))> = if copying { previews.drain().collect() } else { Vec::new() };@@ -547,7 +555,8 @@ pub fn show(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) { // clip drag also shows where the drop will carry its transitions (those whose clips all // move to one track by one offset): a plain move draws them on their destination track // right away, a copy adds a ghost beside the copies while the originals stay at home.- for trn in &tr.transitions {+ let transitions = stretch_preview.as_ref().and_then(|q| q.track(r.track)).map(|tr| &tr.transitions).unwrap_or(&tr.transitions);+ for trn in transitions { let carried = move_transition_preview(app, seq, trn); // the transition's home span (a transition dragged by an end still takes over) let (start, end) = match &app.tl.drag {@@ -850,7 +859,7 @@ mod trim_preview_tests { assert_same_media(&right, &edited[&right.id]); assert_eq!(edited[&right.id].start, right.start + delta); // Rate Stretch keeps the same media in a new length: drawn from the original item- app.tl.drag = Some(Drag::Stretch { clip: left.id, edge: filmcraft_edit::Edge::Out, delta, from: Tick::ZERO, linked: true });+ app.tl.drag = Some(Drag::Stretch { clip: left.id, edge: filmcraft_edit::Edge::Out, delta: -delta, from: Tick::ZERO, ripple: false, linked: true }); let (mut out, mut edited) = (HashMap::new(), HashMap::new()); preview_drag(&app, &seq, &mut out, &mut edited); assert!(edited.is_empty() && out.contains_key(&left.id));@@ -4,12 +4,24 @@ use super::menus::{CLIP_MENU, EditPointTarget, edit_point_menu, gap_menu, marker use super::snap::{snap, snap_playhead}; use super::*; +/// Preview an edit's clip positions and source ranges without changing the project. pub(super) fn preview_drag( app: &FilmcraftApp, seq: &Sequence, out: &mut HashMap<ClipId, (Tick, Tick, Option<TrackId>)>, edited: &mut HashMap<ClipId, TrackItem>,-) {+) -> Option<Arc<Sequence>> {+ if let Some(Drag::Stretch { clip, edge, delta, ripple, linked, .. }) = &app.tl.drag {+ let work = super::stretch_preview::cached(app, *clip, *edge, *delta, *ripple, *linked).ok()?;+ let old_positions: HashMap<ClipId, (Tick, Tick)> = seq.all_tracks().flat_map(|tr| tr.items.iter().map(|i| (i.id, (i.start, i.duration)))).collect();+ for it in work.all_tracks().flat_map(|tr| &tr.items) {+ if old_positions.get(&it.id).is_some_and(|old| *old != (it.start, it.duration)) {+ out.insert(it.id, (it.start, it.duration, None));+ }+ }+ return Some(work);+ }+ app.tl.stretch_preview.lock().unwrap_or_else(std::sync::PoisonError::into_inner).clear(); // the item as the edge move leaves it, and its place in the preview let mut moved = |it: &TrackItem, edge: filmcraft_edit::Edge, delta: Tick| { let mut e = it.clone();@@ -17,7 +29,7 @@ pub(super) fn preview_drag( out.insert(e.id, (e.start, e.duration, None)); edited.insert(e.id, e); };- let Some(d) = &app.tl.drag else { return };+ let Some(d) = &app.tl.drag else { return None }; match d { Drag::Move { new_track: Some(_), .. } => {} Drag::Move { clips, offset, track_delta, .. } => {@@ -41,19 +53,6 @@ pub(super) fn preview_drag( } } }- // a rate stretch keeps the same media in a new length: that one does squeeze- Drag::Stretch { clip, edge, delta, linked, .. } => {- let ids = if *linked { filmcraft_engine::commands::with_links(&app.session, &[*clip]) } else { vec![*clip] };- for c in ids {- if let Some((_, it)) = seq.find_item(c) {- let v = match edge {- filmcraft_edit::Edge::In => (it.start + *delta, it.duration - *delta, None),- filmcraft_edit::Edge::Out => (it.start, it.duration + *delta, None),- };- out.insert(c, v);- }- }- } Drag::Roll { left, right, delta, .. } => { let pairs = filmcraft_engine::commands::linked_roll_pairs(seq, *left, *right, app.session.state.linked_selection); for (pl, pr) in pairs {@@ -72,6 +71,7 @@ pub(super) fn preview_drag( } _ => {} }+ None } /// The transition a clip drag will leave behind, by the same rule `edit::move_items` applies:@@ -122,6 +122,7 @@ fn marquee_clips(seq: &Sequence, layout: &Layout, r: Rect) -> Vec<ClipId> { fn drag_is_locked(session: &filmcraft_engine::Session, seq: &Sequence, drag: &Drag) -> bool { let clips = match drag { Drag::Move { clips, track_delta, .. } => return move_is_locked(seq, clips, *track_delta),+ Drag::Stretch { clip, ripple: true, .. } | Drag::Stretch { clip, linked: false, .. } => return move_is_locked(seq, &[*clip], 0), Drag::Trim { clip, .. } | Drag::Stretch { clip, .. } | Drag::Remix { clip, .. } | Drag::Slip { clip, .. } | Drag::Slide { clip, .. } => vec![*clip], Drag::Roll { left, right, .. } => vec![*left, *right], Drag::Transition { id, .. } => return seq.all_tracks().any(|t| t.locked && t.transitions.iter().any(|tr| tr.id == *id)),@@ -232,7 +233,7 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence if matches!(grab, Grab::Other(Hit::Ruler)) { scrub_playhead(app, seq, layout, p, mods.shift); }- app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab });+ app.tl.pressed = Some(Pressed { pos: p, tick: layout.tick_at(p.x), grab, linked: !mods.alt }); } // ---- wheel, as in Premiere Pro on macOS (checked in 26.5.2):@@ -336,7 +337,7 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence // a press and release in the same frame is never stored: it happened where egui says None => { let Some(p) = resp.interact_pointer_pos() else { return };- Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple) }+ Pressed { pos: p, tick: layout.tick_at(p.x), grab: grab_at(seq, layout, p, tool, mods, roll_ripple), linked: !mods.alt } } }; let (p, t) = (pressed.pos, pressed.tick);@@ -429,10 +430,11 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence None } (_, Grab::Edge { clip, edge, kind, .. }) => Some(match kind {- EdgeKind::Trim => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Regular, delta: Tick::ZERO, from: t, linked: !mods.alt },- EdgeKind::Ripple => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Ripple, delta: Tick::ZERO, from: t, linked: !mods.alt },+ EdgeKind::Trim => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Regular, delta: Tick::ZERO, from: t, linked: pressed.linked },+ EdgeKind::Ripple => Drag::Trim { clip, edge, mode: filmcraft_edit::TrimMode::Ripple, delta: Tick::ZERO, from: t, linked: pressed.linked }, EdgeKind::Roll { left, right } => Drag::Roll { left, right, delta: Tick::ZERO, from: t },- EdgeKind::Stretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t, linked: !mods.alt },+ EdgeKind::Stretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t, ripple: false, linked: pressed.linked },+ EdgeKind::RippleStretch => Drag::Stretch { clip, edge, delta: Tick::ZERO, from: t, ripple: true, linked: pressed.linked }, EdgeKind::Remix => Drag::Remix { clip, delta: Tick::ZERO, from: t }, }), (_, Grab::Volume { clip, .. }) if resp.drag_started() => crate::panels::timeline_volume::start(seq, layout, clip, p).map(Drag::Volume),@@ -607,10 +609,21 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence let target = rate.snap_nearest(it.end() + (t_here - from)).max(it.start + rate.frame_duration()); Drag::Remix { clip, delta: target - it.end(), from } }),- Drag::Stretch { clip, edge, from, linked, .. } => seq.find_item(clip).map(|(_, it)| {+ Drag::Stretch { clip, edge, from, ripple, linked, .. } => seq.find_item(clip).map(|(_, it)| { let base = if edge == filmcraft_edit::Edge::In { it.start } else { it.end() }; let target = snap(app, seq, layout, rate.snap_nearest(base + (t_here - from)), &[clip]);- Drag::Stretch { clip, edge, delta: target - base, from, linked }+ let delta = match super::stretch_preview::cached(app, clip, edge, target - base, ripple, linked) {+ Ok(q) => q+ .find_item(clip)+ .map(|(_, i)| if edge == filmcraft_edit::Edge::In { i.start - it.start } else { i.duration - it.duration })+ .unwrap_or_default(),+ Err(e) => {+ ctx.set_cursor_icon(CursorIcon::NotAllowed);+ app.ui.status = e.to_string();+ Tick::ZERO+ }+ };+ Drag::Stretch { clip, edge, delta, from, ripple, linked } }), Drag::Roll { left, right, from, .. } => seq.find_item(left).map(|(_, l)| { let target = snap(app, seq, layout, rate.snap_nearest(l.end() + (t_here - from)), &[left, right]);@@ -713,7 +726,7 @@ pub(super) fn interact(app: &mut FilmcraftApp, ui: &mut egui::Ui, seq: &Sequence "timeline.trim", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "mode": if mode == filmcraft_edit::TrimMode::Ripple {"ripple"} else {"regular"}, "delta": delta.0, "linked": linked}), )),- Drag::Stretch { clip, edge, delta, linked, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0, "linked": linked}))),+ Drag::Stretch { clip, edge, delta, ripple, linked, .. } if delta != Tick::ZERO => Some(app.session.execute("timeline.rateStretch", json!({"clip": clip.0, "edge": if edge == filmcraft_edit::Edge::In {"in"} else {"out"}, "delta": delta.0, "ripple": ripple, "linked": linked}))), Drag::Remix { clip, delta, .. } if delta != Tick::ZERO => { let d = seq.find_item(clip).map(|(_, it)| it.duration + delta).unwrap_or(Tick::ZERO); Some(app.session.execute("clip.remix", json!({"clip": clip.0, "duration": d.0})))new file mode 100644@@ -0,0 +1,97 @@+//! Bounded Rate Stretch previews shared by pointer updates and the next draw.+//! Keep failures as well as successful edits: a blocked pointer request can alternate with the+//! unchanged geometry drawn at delta zero. Project edits, sequence changes and link selection+//! invalidate the inputs; ending a stretch or resetting media caches releases the snapshots.++use std::collections::VecDeque;+use std::sync::{Arc, Weak};++use filmcraft_edit::Edge;+use filmcraft_project::{ClipId, ItemId, Project, Sequence};+use filmcraft_time::Tick;++use crate::FilmcraftApp;++type Preview = Result<Arc<Sequence>, Arc<str>>;++#[derive(Clone, Copy, Debug, PartialEq, Eq)]+struct Key {+ revision: u64,+ sequence: Option<ItemId>,+ linked_selection: bool,+ clip: ClipId,+ edge: Edge,+ delta: Tick,+ ripple: bool,+ linked: bool,+}++#[derive(Default)]+pub(super) struct Cache {+ project: Weak<Project>,+ entries: VecDeque<(Key, Preview)>,+}++impl Cache {+ /// Release snapshots when their drag ends or is cancelled.+ pub(super) fn clear(&mut self) {+ self.entries.clear();+ self.project = Weak::new();+ }++ /// Retain the two most recently used requests, including a clamped edit's accepted delta.+ fn remember(&mut self, key: Key, preview: Preview) {+ self.entries.retain(|(old, _)| *old != key);+ if self.entries.len() >= 2 {+ self.entries.pop_front();+ }+ self.entries.push_back((key, preview));+ }+}++/// Return the same snapshot for unchanged stretch inputs without cloning/editing the sequence again.+pub(super) fn cached(app: &FilmcraftApp, clip: ClipId, edge: Edge, delta: Tick, ripple: bool, linked: bool) -> Preview {+ let project = Arc::downgrade(&app.session.project);+ let key = Key {+ revision: app.session.revision,+ sequence: app.session.state.active_sequence,+ linked_selection: app.session.state.linked_selection,+ clip,+ edge,+ delta,+ ripple,+ linked,+ };+ {+ let mut cache = app.tl.stretch_preview.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+ if let Some((_, preview)) = cache.entries.iter().find(|(old, _)| cache.project.ptr_eq(&project) && *old == key) {+ let preview = preview.clone();+ cache.remember(key, preview.clone());+ return preview;+ }+ }+ let preview = filmcraft_engine::commands::rate_stretch_preview_linked(&app.session, clip, edge, delta, ripple, linked)+ .map(Arc::new)+ .map_err(|error| Arc::<str>::from(error.to_string()));+ // The pointer can request beyond a duration bound. Drawing uses the accepted delta, which+ // describes this same edit and should not cause a second calculation at that bound.+ let accepted = preview.as_ref().ok().and_then(|work| {+ let old = app.session.active_sequence()?.find_item(clip)?.1;+ let new = work.find_item(clip)?.1;+ let delta = if edge == Edge::In { new.start - old.start } else { new.duration - old.duration };+ Some(Key { delta, ..key })+ });+ let mut cache = app.tl.stretch_preview.lock().unwrap_or_else(std::sync::PoisonError::into_inner);+ if !cache.project.ptr_eq(&project) {+ cache.clear();+ cache.project = project;+ }+ cache.remember(key, preview.clone());+ if let Some(accepted) = accepted.filter(|accepted| *accepted != key) {+ cache.remember(accepted, preview.clone());+ }+ preview+}++#[cfg(test)]+mod tests;new file mode 100644@@ -0,0 +1,124 @@+use super::*;+use crate::panels::timeline::{Drag, interact::preview_drag};+use filmcraft_engine::Session;+use serde_json::json;++/// Build aligned offline media followed by another linked pair, with a locked music bed.+fn fixture(duration: i64) -> (FilmcraftApp, Vec<ClipId>) {+ let mut session = Session::default();+ session.execute("file.newProject", json!({"name":"Preview cache"})).unwrap();+ session.execute("file.newSequence", json!({"fps":25,"video":1,"audio":2})).unwrap();+ let item =+ session.execute("file.newOfflineFile", json!({"name":"Synthetic","seconds":10,"fps":25,"video":true,"audio":true})).unwrap()["item"].as_u64().unwrap();+ let frame = session.sequence_rate().frame_duration().0;+ let mut clips = Vec::new();+ for n in 0..2 {+ let placed = session+ .execute(+ "timeline.place",+ json!({"item":item,"track":"V1","audioTrack":"A1","time":n*duration*frame,"sourceIn":20*frame,"duration":duration*frame}),+ )+ .unwrap();+ clips.extend(placed["clips"].as_array().unwrap().iter().map(|c| ClipId(c.as_u64().unwrap())));+ }+ session.execute("timeline.place", json!({"item":item,"track":"A2","time":0,"duration":200*frame})).unwrap();+ session.execute("timeline.setTrack", json!({"track":"A2","locked":true})).unwrap();+ (FilmcraftApp::new(session), clips)+}++/// Pointer updates and drawing reuse one edit snapshot while preserving project and history.+#[test]+fn drawing_reuses_the_pointer_preview_and_releases_it_after_the_drag() {+ let (mut app, clips) = fixture(60);+ let delta = app.session.sequence_rate().tick_of(10);+ let before = app.session.project.clone();+ let history = app.session.history.undo.len();+ let pointer = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap();+ app.tl.drag = Some(Drag::Stretch { clip: clips[0], edge: Edge::Out, delta, from: Tick::ZERO, ripple: true, linked: true });+ let seq = app.session.active_sequence().unwrap().clone();+ let (mut positions, mut edited) = (std::collections::HashMap::new(), std::collections::HashMap::new());+ let drawn = preview_drag(&app, &seq, &mut positions, &mut edited).unwrap();+ assert!(Arc::ptr_eq(&pointer, &drawn));+ assert_eq!(positions[&clips[2]].0, seq.find_item(clips[2]).unwrap().1.start + delta);+ assert_eq!(drawn.audio_tracks[1], seq.audio_tracks[1], "locked music remains fixed");+ assert!(Arc::ptr_eq(&app.session.project, &before));+ assert_eq!(app.session.history.undo.len(), history);+ app.tl.drag = None;+ assert!(preview_drag(&app, &seq, &mut positions, &mut edited).is_none());+ assert!(!Arc::ptr_eq(&pointer, &cached(&app, clips[0], Edge::Out, delta, true, true).unwrap()));+}++/// A pointer request clamped by its neighbour shares the accepted-delta draw's snapshot.+#[test]+fn clamped_requests_reuse_their_accepted_delta() {+ let (app, clips) = fixture(60);+ let delta = app.session.sequence_rate().tick_of(20);+ let requested = cached(&app, clips[0], Edge::Out, delta, false, true).unwrap();+ assert_eq!(requested.find_item(clips[0]).unwrap().1.duration, app.session.sequence_rate().tick_of(60));+ let accepted = cached(&app, clips[0], Edge::Out, Tick::ZERO, false, true).unwrap();+ assert!(Arc::ptr_eq(&requested, &accepted));+}++/// Changing a stretch parameter or link-selection state computes the corresponding edit.+#[test]+fn changed_inputs_do_not_reuse_an_incompatible_preview() {+ let (mut app, clips) = fixture(60);+ let frame = app.session.sequence_rate().frame_duration();+ let first = cached(&app, clips[0], Edge::Out, Tick(frame.0 * 10), true, true).unwrap();+ let longer = cached(&app, clips[0], Edge::Out, Tick(frame.0 * 20), true, true).unwrap();+ assert!(!Arc::ptr_eq(&first, &longer));+ assert_eq!(longer.find_item(clips[0]).unwrap().1.duration, Tick(frame.0 * 80));+ let regular = cached(&app, clips[0], Edge::Out, Tick(frame.0 * 10), false, true).unwrap();+ assert_eq!(regular.find_item(clips[0]).unwrap().1.duration, Tick(frame.0 * 60));+ let unlinked = cached(&app, clips[0], Edge::Out, Tick(frame.0 * 10), true, false).unwrap();+ assert_eq!(unlinked.find_item(clips[1]).unwrap().1.duration, Tick(frame.0 * 60));+ app.session.state.linked_selection = false;+ let selection_off = cached(&app, clips[0], Edge::Out, Tick(frame.0 * 10), true, true).unwrap();+ assert_eq!(selection_off.find_item(clips[1]).unwrap().1.duration, Tick(frame.0 * 60));+ let other = cached(&app, clips[2], Edge::Out, Tick(frame.0 * 10), true, true).unwrap();+ assert_eq!(other.find_item(clips[2]).unwrap().1.duration, Tick(frame.0 * 70));+ let inside = cached(&app, clips[0], Edge::In, Tick(frame.0 * 10), false, true).unwrap();+ assert_eq!(inside.find_item(clips[0]).unwrap().1.start, Tick(frame.0 * 10));+ app.session.state.active_sequence = None;+ assert!(cached(&app, clips[0], Edge::Out, Tick(frame.0 * 10), true, true).is_err());+}++/// Cache blocked requests alongside unchanged geometry; project edits and undo invalidate both.+#[test]+fn failures_are_reused_and_revision_changes_recompute() {+ let (mut app, clips) = fixture(60);+ let delta = app.session.sequence_rate().tick_of(10);+ // Adding the first caption otherwise creates its track as a separate undoable operation.+ app.session.execute("captions.newTrack", json!({})).unwrap();+ let before = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap();+ app.session.execute("captions.add", json!({"text":"Synthetic caption","time":app.session.sequence_rate().tick_of(80).0,"durationSeconds":1})).unwrap();+ let error = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap_err();+ let unchanged = cached(&app, clips[0], Edge::Out, Tick::ZERO, true, true).unwrap();+ let repeated = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap_err();+ assert!(Arc::ptr_eq(&error, &repeated));+ assert!(Arc::ptr_eq(&unchanged, &cached(&app, clips[0], Edge::Out, Tick::ZERO, true, true).unwrap()));+ // As a blocked pointer keeps moving, each draw still reuses the unchanged geometry.+ for frames in [20, 30] {+ assert!(cached(&app, clips[0], Edge::Out, app.session.sequence_rate().tick_of(frames), true, true).is_err());+ assert!(Arc::ptr_eq(&unchanged, &cached(&app, clips[0], Edge::Out, Tick::ZERO, true, true).unwrap()));+ }+ app.session.execute("edit.undo", json!({})).unwrap();+ let after = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap();+ assert!(!Arc::ptr_eq(&before, &after));+ assert_eq!(*before, *after);+}++/// Project replacement can repeat ids/revision, so cache identity follows the actual project too.+#[test]+fn project_replacement_with_repeated_ids_and_revision_is_not_a_cache_hit() {+ let (mut app, clips) = fixture(60);+ let delta = app.session.sequence_rate().tick_of(10);+ let before = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap();+ let (other, other_clips) = fixture(40);+ assert_eq!(clips, other_clips);+ assert_eq!(app.session.revision, other.session.revision);+ app.session = other.session;+ let after = cached(&app, clips[0], Edge::Out, delta, true, true).unwrap();+ assert!(!Arc::ptr_eq(&before, &after));+ assert_eq!(after.find_item(clips[0]).unwrap().1.duration, app.session.sequence_rate().tick_of(50));+}@@ -227,6 +227,8 @@ pub enum EdgeKind { Roll { left: ClipId, right: ClipId }, /// Rate Stretch: the clip's speed changes to fill its new length. Stretch,+ /// Shift + Out-edge Rate Stretch: later clips follow the changed duration.+ RippleStretch, /// Remix: a music clip is re-planned to its new length (Out edges only). Remix, }@@ -239,6 +241,7 @@ impl EdgeKind { EdgeKind::Ripple => "ripple", EdgeKind::Roll { .. } => "roll", EdgeKind::Stretch => "rateStretch",+ EdgeKind::RippleStretch => "rippleRateStretch", EdgeKind::Remix => "remix", } }@@ -300,6 +303,7 @@ pub fn grab_at(seq: &Sequence, layout: &Layout, pos: Pos2, tool: Tool, mods: Mod } Tool::Ripple => EdgeKind::Ripple, Tool::Rolling => roll(seq, track, clip, edge),+ Tool::RateStretch if mods.shift && edge == Edge::Out => EdgeKind::RippleStretch, Tool::RateStretch => EdgeKind::Stretch, Tool::Remix if edge == Edge::Out => EdgeKind::Remix, _ => return Grab::Other(h),@@ -583,6 +587,15 @@ mod tests { } } + /// Shift exposes ripple stretching only for the Rate Stretch tool’s Out edge.+ #[test]+ fn shift_rate_stretch_is_explicit_and_out_only() {+ let (seq, l, tr) = fixture(clips());+ assert_eq!(grab(&seq, &l, Tool::RateStretch, 297.0, Modifiers::SHIFT, false), edge_grab(tr, 1, Edge::Out, EdgeKind::RippleStretch));+ assert_eq!(grab(&seq, &l, Tool::RateStretch, 303.0, Modifiers::SHIFT, false), edge_grab(tr, 2, Edge::In, EdgeKind::Stretch));+ assert_eq!(EdgeKind::RippleStretch.name(), "rippleRateStretch");+ }+ #[test] fn edge_kind_names_for_the_control_channel() { assert_eq!(@@ -103,7 +103,7 @@ pub fn paint_bracket(p: &egui::Painter, x: f32, row: &Row, out: bool, col: Color pub fn paint_hover_bracket(p: &egui::Painter, seq: &Sequence, layout: &Layout, track: TrackId, clip: ClipId, edge: Edge, kind: EdgeKind) { let Some(row) = layout.rows.iter().find(|r| r.track == track) else { return }; let Some(tr) = seq.track(track) else { return };- let full = if kind == EdgeKind::Ripple { RIPPLE_RED } else { ROLL_YELLOW };+ let full = if matches!(kind, EdgeKind::Ripple | EdgeKind::RippleStretch) { RIPPLE_RED } else { ROLL_YELLOW }; let col = full.gamma_multiply(0.5); let marks = match kind { EdgeKind::Roll { left, right } => vec![(left, true), (right, false)],@@ -30,6 +30,11 @@ impl Driver { let mut session = Session::default(); session.execute("file.openDemoProject", json!({})).expect("demo project"); session.execute("sequence.snap", json!({"on": false})).expect("snapping off");+ Self::with_session(session)+ }++ /// Create the offscreen timeline driver around a supplied disposable session.+ fn with_session(session: Session) -> Self { let (tx, rx) = channel(); let app = FilmcraftApp::new(session).with_control(rx); let harness = Harness::builder().with_size(egui::vec2(1600.0, 980.0)).with_step_dt(1.0 / 60.0).with_max_steps(10_000).build_eframe(move |_cc| app);@@ -78,17 +83,35 @@ impl Driver { /// Press at `from`, move to `to` over `steps` frames (as a mouse does), release. fn drag(&mut self, from: Pos2, to: Pos2, steps: usize) {+ self.drag_with_mods(from, to, steps, egui::Modifiers::NONE, false);+ }++ /// Drag with press-time modifiers, optionally releasing them before crossing the drag threshold.+ fn drag_with_mods(&mut self, from: Pos2, to: Pos2, steps: usize, mods: egui::Modifiers, release_shift_early: bool) {+ self.harness.input_mut().events.push(egui::Event::ModifiersChanged(mods)); let push = |d: &mut Self, e: egui::Event| d.harness.input_mut().events.push(e); push(self, egui::Event::PointerMoved(from)); self.frames(1);- push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });+ push(self, egui::Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: mods }); self.frames(1);+ if release_shift_early {+ self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE));+ } for i in 1..=steps { push(self, egui::Event::PointerMoved(from + (to - from) * (i as f32 / steps as f32))); self.frames(1); }- push(self, egui::Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });+ push(+ self,+ egui::Event::PointerButton {+ pos: to,+ button: egui::PointerButton::Primary,+ pressed: false,+ modifiers: if release_shift_early { egui::Modifiers::NONE } else { mods },+ },+ ); self.frames(2);+ self.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::NONE)); } /// Move the pointer to `at` with no button down, and let the Timeline react.@@ -701,6 +724,117 @@ fn linked_audio_drag_adds_audio_and_video_tracks() { move_existing_clip_to_a_new_track(true, true); } +/// Build an offscreen Rate Stretch timeline with linked clips and locked music.+fn ripple_fixture() -> (Driver, u64, u64) {+ let mut s = Session::default();+ s.execute("file.newProject", json!({"name":"Visual ripple"})).unwrap();+ s.execute("file.newSequence", json!({"name":"Sequence","fps":25,"video":1,"audio":2})).unwrap();+ let r = s.execute("file.newOfflineFile", json!({"name":"Synthetic","seconds":10,"fps":25,"video":true,"audio":true})).unwrap();+ let item = r["item"].as_u64().unwrap();+ let f = s.sequence_rate().tick_of(1).0;+ let mut clips = Vec::new();+ for n in 0..3 {+ let r = s.execute("timeline.place", json!({"item":item,"track":"V1","audioTrack":"A1","time":f*60*n,"sourceIn":f*20,"duration":f*60})).unwrap();+ clips.push(r["clips"][0].as_u64().unwrap());+ }+ s.execute("timeline.place", json!({"item":item,"track":"A2","time":0,"duration":f*240})).unwrap();+ s.execute("timeline.setTrack", json!({"track":"A2","locked":true})).unwrap();+ s.execute("sequence.snap", json!({"on":false})).unwrap();+ let mut d = Driver::with_session(s);+ d.ok("ui.set", json!({"tool":"RateStretch"}));+ (d, clips[0], clips[1])+}++/// Shift captured on press ripples either direction, even when released during the drag.+#[test]+fn shift_out_drag_ripples_both_directions_and_captures_shift_at_press() {+ for direction in [-1.0, 1.0] {+ let (mut d, first, next) = ripple_fixture();+ let before = d.app().session.project.clone();+ let undo = d.undo_steps();+ let (x0, x1, y) = d.edges(first);+ let by = ((x1 - x0) / 4.0).round() * direction;+ d.drag_with_mods(pos2(x1 - 3.0, y), pos2(x1 - 3.0 + by, y), by.abs() as usize, egui::Modifiers::SHIFT, true);+ let q = d.app().session.active_sequence().unwrap();+ let a = q.find_item(ClipId(first)).unwrap().1;+ let b = q.find_item(ClipId(next)).unwrap().1;+ assert_eq!(a.end(), b.start);+ let old = before.sequence(d.app().session.state.active_sequence.unwrap()).unwrap();+ let old_a = old.find_item(ClipId(first)).unwrap().1;+ assert_eq!((a.source_in, a.source_out()), (old_a.source_in, old_a.source_out()));+ assert_eq!(q.audio_tracks[1], old.audio_tracks[1]);+ assert_eq!(a.duration.cmp(&old_a.duration), if direction < 0.0 { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater });+ assert_eq!(d.undo_steps(), undo + 1);+ d.exec("edit.undo", json!({}));+ assert_eq!(*d.app().session.project, *before);+ }+}++/// Plain Out-edge and Shift In-edge drags keep their ordinary non-ripple behavior.+#[test]+fn plain_rate_stretch_leaves_a_gap_and_shift_in_is_ordinary() {+ let (mut d, first, next) = ripple_fixture();+ let (x0, x1, y) = d.edges(first);+ let next_start = d.span(next).0;+ let by = ((x1 - x0) / 4.0).round();+ d.drag(pos2(x1 - 3.0, y), pos2(x1 - 3.0 - by, y), by as usize);+ assert!(d.span(first).1 < next_start);+ assert_eq!(d.span(next).0, next_start);+ d.exec("edit.undo", json!({}));+ let (x0, _, y) = d.edges(first);+ d.drag_with_mods(pos2(x0 + 3.0, y), pos2(x0 + 3.0 + by, y), by as usize, egui::Modifiers::SHIFT, false);+ assert!(d.span(first).0 > 0);+ assert_eq!(d.span(first).1, next_start);+ assert_eq!(d.span(next).0, next_start);+}++/// Alt and Shift+Alt captured on press preserve the edited clip’s linked audio.+#[test]+fn alt_rate_stretch_and_shift_alt_ripple_leave_the_edited_linked_audio_fixed() {+ for (mods, direction) in [(egui::Modifiers::ALT, -1.0), (egui::Modifiers { alt: true, shift: true, ..Default::default() }, 1.0)] {+ let (mut d, first, _) = ripple_fixture();+ let before = d.app().session.project.clone();+ let q = d.app().session.active_sequence().unwrap();+ let audio = q.audio_tracks[0].items[0].id.0;+ let old_audio = q.find_item(ClipId(audio)).unwrap().1.clone();+ let undo = d.undo_steps();+ let (x0, x1, y) = d.edges(first);+ let by = ((x1 - x0) / 4.0).round() * direction;+ d.drag_with_mods(pos2(x1 - 3.0, y), pos2(x1 - 3.0 + by, y), by.abs() as usize, mods, true);+ let q = d.app().session.active_sequence().unwrap();+ assert_eq!(q.find_item(ClipId(audio)).unwrap().1, &old_audio);+ let old = before.sequence(d.app().session.state.active_sequence.unwrap()).unwrap();+ assert_eq!(+ q.find_item(ClipId(first)).unwrap().1.duration.cmp(&old.find_item(ClipId(first)).unwrap().1.duration),+ if direction < 0.0 { std::cmp::Ordering::Less } else { std::cmp::Ordering::Greater }+ );+ assert_eq!(d.undo_steps(), undo + 1);+ d.exec("edit.undo", json!({}));+ assert_eq!(*d.app().session.project, *before);+ }+}++/// Render the Shift Out-edge hover and live ripple preview without opening a window.+#[test]+#[ignore = "headless wgpu visual review"]+fn ripple_rate_stretch_screenshot() {+ let (mut d, first, _) = ripple_fixture();+ let (x0, x1, y) = d.edges(first);+ d.harness.input_mut().events.push(egui::Event::ModifiersChanged(egui::Modifiers::SHIFT));+ d.hover(pos2(x1 - 3.0, y));+ d.screenshot("ripple-rate-stretch-hover");+ d.harness.input_mut().events.push(egui::Event::PointerButton {+ pos: pos2(x1 - 3.0, y),+ button: egui::PointerButton::Primary,+ pressed: true,+ modifiers: egui::Modifiers::SHIFT,+ });+ d.frames(1);+ d.harness.input_mut().events.push(egui::Event::PointerMoved(pos2(x1 + (x1 - x0) / 4.0, y)));+ d.frames(3);+ d.screenshot("ripple-rate-stretch-preview");+}+ /// #3300: with Alt held, dragging the edge of one side of a linked clip trims only that side; the /// linked audio keeps its range. Without Alt the partner still follows. #[test]@@ -57,7 +57,7 @@ Methods (handlers in `crates/ui-egui/src/control.rs`): | `ui.drag` | `{from, to, steps, modifiers}` | press–move–release; `steps` is an integer 0–1024 (default 12; 0 or 1 uses 2 steps) | | `ui.scroll` | `{id|x,y, dx, dy, modifiers}` | wheel / trackpad | | `ui.key` / `ui.type` | `{key}` (`Cmd+K`, `Space`…; off macOS `Ctrl+K` is the same key) / `{text}` | keyboard |-| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null. On the ruler it reports `ruler`, or `workArea` with the bar's `edge` on the work area bar |+| `ui.timeline.hit` / `ui.timeline.locate` | `{x,y,modifiers?}` / `{clip, edge?}` | timeline hit-testing. `hit` reports a clip's `edge` (`In`/`Out`) within 7 px of it (a third of a narrow clip); just left of a cut that is the left clip's Out, from the cut rightwards the right clip's In. `kind` is the trim a press of the current tool with `modifiers` starts there (`trim`, `ripple`, `roll`, `rateStretch`, `rippleRateStretch` (Rate Stretch with Shift on an Out edge), `remix`), `volume` on an audio clip's Volume line (Selection and Pen tools), or null. On the ruler it reports `ruler`, or `workArea` with the bar's `edge` on the work area bar | | `ui.playback` | `{action: play|stop|toggle, speed?}` | | | `ui.screenshot` | `{path?, panel?}` | PNG of the window or one panel; fails after 10 s when no frame is presented (window hidden, display asleep) | | `ui.resize`, `app.quit` | `{width,height}` / `{}` | resize or close the native window; `app.quit` quits without the save prompt (unsaved changes stay in the recovery journal), while the command `app.quit` asks first |