diff --git a/crates/edit/src/lib.rs b/crates/edit/src/lib.rs
--- a/crates/edit/src/lib.rs
+++ b/crates/edit/src/lib.rs
@@ -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)
diff --git a/crates/edit/src/ripple_rate_tests.rs b/crates/edit/src/ripple_rate_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/edit/src/ripple_rate_tests.rs
@@ -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);
+            }
+        }
+    }
+}
diff --git a/crates/edit/src/tests.rs b/crates/edit/src/tests.rs
--- a/crates/edit/src/tests.rs
+++ b/crates/edit/src/tests.rs
@@ -1079,3 +1079,6 @@ fn moving_an_edge_keeps_the_media_under_the_timeline() {
         }
     }
 }
+
+#[path = "ripple_rate_tests.rs"]
+mod ripple_rate_tests;
diff --git a/crates/engine/src/commands.rs b/crates/engine/src/commands.rs
--- a/crates/engine/src/commands.rs
+++ b/crates/engine/src/commands.rs
@@ -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")?;
diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs
--- a/crates/engine/src/lib.rs
+++ b/crates/engine/src/lib.rs
@@ -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;
diff --git a/crates/engine/src/ripple_rate_stretch_tests.rs b/crates/engine/src/ripple_rate_stretch_tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/engine/src/ripple_rate_stretch_tests.rs
@@ -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);
+    }
+}
diff --git a/crates/matroska/README.md b/crates/matroska/README.md
--- a/crates/matroska/README.md
+++ b/crates/matroska/README.md
@@ -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.
diff --git a/crates/matroska/tests/oracle.rs b/crates/matroska/tests/oracle.rs
--- a/crates/matroska/tests/oracle.rs
+++ b/crates/matroska/tests/oracle.rs
@@ -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]
diff --git a/crates/ui-egui/README.md b/crates/ui-egui/README.md
--- a/crates/ui-egui/README.md
+++ b/crates/ui-egui/README.md
@@ -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).
diff --git a/crates/ui-egui/src/app/frame.rs b/crates/ui-egui/src/app/frame.rs
--- a/crates/ui-egui/src/app/frame.rs
+++ b/crates/ui-egui/src/app/frame.rs
@@ -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."),
diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs
--- a/crates/ui-egui/src/panels/timeline.rs
+++ b/crates/ui-egui/src/panels/timeline.rs
@@ -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));
diff --git a/crates/ui-egui/src/panels/timeline/interact.rs b/crates/ui-egui/src/panels/timeline/interact.rs
--- a/crates/ui-egui/src/panels/timeline/interact.rs
+++ b/crates/ui-egui/src/panels/timeline/interact.rs
@@ -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})))
diff --git a/crates/ui-egui/src/panels/timeline/stretch_preview.rs b/crates/ui-egui/src/panels/timeline/stretch_preview.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/panels/timeline/stretch_preview.rs
@@ -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;
diff --git a/crates/ui-egui/src/panels/timeline/stretch_preview/tests.rs b/crates/ui-egui/src/panels/timeline/stretch_preview/tests.rs
new file mode 100644
--- /dev/null
+++ b/crates/ui-egui/src/panels/timeline/stretch_preview/tests.rs
@@ -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));
+}
diff --git a/crates/ui-egui/src/panels/timeline_hit.rs b/crates/ui-egui/src/panels/timeline_hit.rs
--- a/crates/ui-egui/src/panels/timeline_hit.rs
+++ b/crates/ui-egui/src/panels/timeline_hit.rs
@@ -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!(
diff --git a/crates/ui-egui/src/panels/trim_monitor.rs b/crates/ui-egui/src/panels/trim_monitor.rs
--- a/crates/ui-egui/src/panels/trim_monitor.rs
+++ b/crates/ui-egui/src/panels/trim_monitor.rs
@@ -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)],
diff --git a/crates/ui-egui/tests/it/timeline_trim_ui.rs b/crates/ui-egui/tests/it/timeline_trim_ui.rs
--- a/crates/ui-egui/tests/it/timeline_trim_ui.rs
+++ b/crates/ui-egui/tests/it/timeline_trim_ui.rs
@@ -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]
diff --git a/docs/control-protocol.md b/docs/control-protocol.md
--- a/docs/control-protocol.md
+++ b/docs/control-protocol.md
@@ -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 |
