From 3feadebbc445cd98375aabe679eec926dbeda846 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sat, 10 Oct 2026 19:16:40 +0200 Subject: [PATCH] #313: J/K/L shuttle the Source monitor both ways at up to 8x Problem: with the Source monitor focused, L only ever played at normal speed, and J and the slow shuttle commands returned "Source playback currently supports normal forward speed": the Source clock always ran at 1x. Fix: the Source clock runs at a signed speed. L/J play forward/backward and pressing the same key again doubles the speed up to 8x; Shuttle Slow Right/Left play at 1/4 speed. As in the Program monitor, speeds other than normal forward play without sound, and backward playback stops on the first frame. docs/monitors.md updated. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/menus.rs | 15 +++---- crates/ui-egui/src/source_playback.rs | 47 ++++++++++++++++++---- crates/ui-egui/tests/source_playback_ui.rs | 34 ++++++++++++++++ docs/monitors.md | 15 ++++--- 4 files changed, 91 insertions(+), 20 deletions(-) diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 9dff12d3..2a0c8eb5 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -287,7 +287,8 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: } match id { "playback.slowForward" | "playback.slowReverse" if targets_source(app, ¶ms) => { - return Err("Source playback currently supports normal forward speed".into()); + app.shuttle_source(if id == "playback.slowForward" { 0.25 } else { -0.25 })?; + return Ok(json!({"playing": app.source_playback.clock.playing, "speed": app.source_playback.clock.speed})); } "playback.slowForward" | "playback.slowReverse" => { app.play(if id == "playback.slowForward" { 0.25 } else { -0.25 }); @@ -315,12 +316,12 @@ pub fn invoke(app: &mut FilmcraftApp, ctx: &egui::Context, id: &str, mut params: app.toggle_play(1.0); return Ok(json!({"playing": app.playback.playing})); } - "playback.forward" if targets_source(app, ¶ms) => { - app.play_source()?; - return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0})); - } - "playback.reverse" if targets_source(app, ¶ms) => { - return Err("Source playback currently supports normal forward speed; use Play/Space or frame stepping".into()); + // L / J: pressing again in the same direction doubles the speed, up to 8× + "playback.forward" | "playback.reverse" if targets_source(app, ¶ms) => { + let (clock, dir) = (&app.source_playback.clock, if id == "playback.forward" { 1.0 } else { -1.0 }); + let speed = if clock.playing && clock.speed * dir > 0.0 { (clock.speed * 2.0).clamp(-8.0, 8.0) } else { dir }; + app.shuttle_source(speed)?; + return Ok(json!({"playing": app.source_playback.clock.playing, "speed": app.source_playback.clock.speed})); } "playhead.stepBack" | "playhead.stepForward" | "playhead.stepBack5" | "playhead.stepForward5" if targets_source(app, ¶ms) => { app.stop_source(); diff --git a/crates/ui-egui/src/source_playback.rs b/crates/ui-egui/src/source_playback.rs index 494f9d08..7cdb7c21 100644 --- a/crates/ui-egui/src/source_playback.rs +++ b/crates/ui-egui/src/source_playback.rs @@ -30,7 +30,16 @@ impl FilmcraftApp { } pub fn play_source(&mut self) -> Result<(), String> { - self.start_source_playback(None, None) + self.start_source_playback(None, None, 1.0) + } + + /// J / K / L in the Source monitor: play at `speed` × real time (negative = backward) from the + /// Source playhead. Sound plays at normal forward speed only, as in the Program monitor. + pub fn shuttle_source(&mut self, speed: f64) -> Result<(), String> { + if !speed.is_finite() || speed == 0.0 { + return Err("Source shuttle speed must be a non-zero number".into()); + } + self.start_source_playback(None, None, speed.clamp(-8.0, 8.0)) } pub fn play_source_range(&mut self, from_playhead: bool, preroll: bool) -> Result<(), String> { @@ -53,10 +62,10 @@ impl FilmcraftApp { if start >= stop { return Err("Source playhead is at or beyond the Out point".into()); } - self.start_source_playback(Some(start), Some(stop)) + self.start_source_playback(Some(start), Some(stop), 1.0) } - fn start_source_playback(&mut self, start: Option, stop: Option) -> Result<(), String> { + fn start_source_playback(&mut self, start: Option, stop: Option, speed: f64) -> Result<(), String> { let item = self.session.state.source_item.ok_or("Open a media file in the Source monitor first")?; let view = source_view(&self.session, item).ok_or("Source item is unavailable")?; let media = self.session.project.item(view.media).and_then(|i| i.as_media()).ok_or("Source playback currently supports media files and subclips")?; @@ -76,7 +85,8 @@ impl FilmcraftApp { self.stop(); self.stop_source(); let mut time = start.unwrap_or(self.session.state.source_playhead); - if start.is_none() && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) { + // forward from the end (or before a looped range) starts over; backward runs from where it is + if start.is_none() && speed > 0.0 && (time < first || time >= view.rate.snap(Tick(end.0.saturating_sub(view.rate.frame_duration().0))).max(first)) { time = first; } self.session.execute("source.setPlayhead", json!({"time": time.0})).map_err(|e| e.to_string())?; @@ -84,8 +94,8 @@ impl FilmcraftApp { self.source_playback.range_start = start; self.source_playback.item = Some(item); self.source_playback.shown = time; - self.source_playback.clock = Playback { playing: true, speed: 1.0, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() }; - self.source_playback.clock.meter.start(1.0); + self.source_playback.clock = Playback { playing: true, speed, anchor_time: -1.0, anchor_tick: time, looping, stop_at: stop, ..Default::default() }; + self.source_playback.clock.meter.start(speed); self.start_source_audio(); Ok(()) } @@ -105,6 +115,12 @@ impl FilmcraftApp { fn start_source_audio(&mut self) { self.source_playback.clock.audio_clock = false; + if (self.source_playback.clock.speed - 1.0).abs() > 1e-9 { + if let Some(audio) = self.audio.as_mut() { + audio.stop(); + } + return; + } let Some(item) = self.source_playback.item else { return }; let Some(view) = source_view(&self.session, item) else { return }; let Some(media) = self.session.project.item(view.media).and_then(|i| i.as_media()) else { return }; @@ -221,7 +237,24 @@ impl FilmcraftApp { Some((frames, sr)) if clock.audio_clock && sr > 0 => frames as f64 / f64::from(sr), _ => (now - clock.anchor_time).max(0.0), }; - let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed).0)); + let next = Tick(clock.anchor_tick.0.saturating_add(Tick::from_seconds_f64(elapsed * clock.speed).0)); + if clock.speed < 0.0 { + // backward: stops on the first frame (as the Program monitor stops at the start) + let ended = next <= view.start; + match self.session.execute("source.setPlayhead", json!({"time": next.max(view.start).0})) { + Ok(_) => self.source_playback.shown = self.session.state.source_playhead, + Err(e) => { + self.stop_source(); + self.ui.status = e.to_string(); + return; + } + } + if ended { + self.stop_source(); + } + ctx.request_repaint(); + return; + } let range = view.selected_range(); let end = clock.stop_at.unwrap_or_else(|| if clock.looping { range.end() } else { view.end }).min(view.end); let ended = next >= end; diff --git a/crates/ui-egui/tests/source_playback_ui.rs b/crates/ui-egui/tests/source_playback_ui.rs index 7ae95546..c2028303 100644 --- a/crates/ui-egui/tests/source_playback_ui.rs +++ b/crates/ui-egui/tests/source_playback_ui.rs @@ -196,3 +196,37 @@ fn a_single_frame_source_range_starts_at_the_marked_last_frame() { assert!(!h.state().source_playback.clock.playing); assert_eq!(h.state().session.state.source_playhead, last); } + +#[test] +fn j_and_l_shuttle_the_source_both_ways_and_double_the_speed() { + let mut h = Harness::builder().with_step_dt(0.25).with_size(egui::vec2(1280.0, 800.0)).build_eframe(move |_cc| app(5)); + h.step(); + h.state_mut().ui.focused = PanelKind::Source; + let before = h.state().session.project.to_json(); + let program = h.state().session.playhead(); + let ctx = h.ctx.clone(); + for speed in [1.0, 2.0, 4.0, 8.0, 8.0] { + let r = menus::invoke(h.state_mut(), &ctx, "playback.forward", json!({})).unwrap(); + assert_eq!(r["speed"], speed); + } + // J while running forward turns around at normal speed, then runs backward to the first frame + h.state_mut().session.execute("source.setPlayhead", json!({"seconds":2})).unwrap(); + assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.reverse", json!({})).unwrap()["speed"], -1.0); + let at = h.state().session.state.source_playhead; + h.step(); + h.step(); + assert!(h.state().session.state.source_playhead < at, "J plays the Source backward"); + assert!(!h.state().source_playback.clock.audio_clock, "shuttle speeds play without sound"); + assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.reverse", json!({})).unwrap()["speed"], -2.0); + for _ in 0..8 { + h.step(); + } + assert!(!h.state().source_playback.clock.playing); + assert_eq!(h.state().session.state.source_playhead, Tick::ZERO); + assert_eq!(menus::invoke(h.state_mut(), &ctx, "playback.slowForward", json!({})).unwrap()["speed"], 0.25); + menus::invoke(h.state_mut(), &ctx, "playback.stop", json!({})).unwrap(); + assert!(!h.state().source_playback.clock.playing); + assert_eq!(h.state().session.playhead(), program); + assert!(!h.state().playback.playing); + assert_eq!(h.state().session.project.to_json(), before); +} diff --git a/docs/monitors.md b/docs/monitors.md index d60ff85b..3610c9ea 100644 --- a/docs/monitors.md +++ b/docs/monitors.md @@ -43,14 +43,17 @@ Play auditions the full source span by default. Play In to Out and Play to Out address the marked Source range when Source has focus. The Source wrench menu can loop the marked range; Program keeps its separate loop setting. -Normal forward playback has sound. L starts normal forward Source playback, K stops, and the -left/right frame-step keys address Source when it has focus. Reverse and shuttle-speed Source -playback and source sequences are not yet supported and report an explanatory error. A failed -audio device leaves the picture advancing with a status message; a decoding error stops Source. +Normal forward playback has sound. With Source focused, L plays forward and J backward; pressing +the same key again doubles the speed up to 8×, K stops, Shuttle Slow Right/Left play at ¼ speed, +and the left/right frame-step keys step the Source playhead. As in Program, shuttle speeds other +than normal forward play without sound, and backward playback stops on the first frame. Source +sequences are not yet supported and report an explanatory error. A failed audio device leaves +the picture advancing with a status message; a decoding error stops Source. Commands: `source.playback.play`, `source.playback.stop`, `source.playback.toggle` (no parameters), -available through the UI command registry, CLI bridge and MCP. `playback.toggle` also accepts -`monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing, +available through the UI command registry, CLI bridge and MCP. `playback.toggle`, +`playback.forward`, `playback.reverse`, `playback.slowForward` and `playback.slowReverse` also +accept `monitor: "source"|"program"` to override focus. `ui.inspect.sourcePlayback` reports playing, audioClock and playhead. Test: `cargo test --release -p filmcraft-ui-egui --test source_playback_ui`. ## Drag a marked Source range to the timeline