#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 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-10 19:16:40 +02:00
parent 98ba9acbce
commit 3feadebbc4
4 changed files with 91 additions and 20 deletions

View File

@ -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, &params) => {
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, &params) => {
app.play_source()?;
return Ok(json!({"playing": app.source_playback.clock.playing, "speed": 1.0}));
}
"playback.reverse" if targets_source(app, &params) => {
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, &params) => {
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, &params) => {
app.stop_source();

View File

@ -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<Tick>, stop: Option<Tick>) -> Result<(), String> {
fn start_source_playback(&mut self, start: Option<Tick>, stop: Option<Tick>, 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;

View File

@ -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);
}

View File

@ -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