#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:
parent
98ba9acbce
commit
3feadebbc4
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue