fix(app): playback tracks the playhead after stalls - clamp, prune, never teleport

Two compounding causes behind 'playhead advances but the picture stays
frozen' and 'pause freezes the app':

- The wall-anchored clock teleported the playhead past the pre-render
  window during any long stall (the first render after pressing play
  costs seconds while the worker pool spins up: measured +104 frames in
  one 4.1s block). The window then started behind and, with stale
  in-flight frames occupying the workers, never converged.
  RealClock::tick now clamps the advance to 2 frames/tick and
  re-anchors the dropped time (NLE drop-frames semantics).

- Window frames the playhead had already passed stayed pending/in
  flight, burning worker time on frames that could never be displayed.
  update_preview_window now cancels them per tick via the new
  JobDispatch::cancel_preview_frame, keeping the workers on frames
  around the playhead.

Includes a production-shaped regression test (real 1080p media on the
timeline, actual cpu_frame display path) that failed with the exact
production signature (playhead 240 / displayed 0 / 36 stale slots)
before the fix and passes after.
This commit is contained in:
2026-08-19 12:17:10 +08:00
parent 9e9c6d9863
commit 46e43b51d9
3 changed files with 119 additions and 1 deletions
+10
View File
@@ -1609,6 +1609,16 @@ impl JobDispatch for ProcessDispatcher {
Some(self.preview_window_capacity())
}
/// Cancel one pre-render window frame (delegates to the inherent
/// [`ProcessDispatcher::cancel_frame`]).
fn cancel_preview_frame(&self, sequence: u64, frame: i64, version: u64) {
self.cancel_frame(&FrameKey {
sequence,
frame,
version,
});
}
/// Release a consumed frame's slot (delegates to the inherent
/// release — see [`ProcessDispatcher::release_frame`]).
fn release_frame(&self, frame: &ShmFrameRef) {
+5
View File
@@ -155,6 +155,11 @@ pub trait JobDispatch: Send + Sync {
fn preview_window_capacity(&self) -> Option<usize> {
None
}
/// Cancel one pre-render window frame by scheduler key (pending or in
/// flight; the completion fires `Error::State`). Default no-op: only
/// the process backend schedules.
fn cancel_preview_frame(&self, _sequence: u64, _frame: i64, _version: u64) {}
}
/// Thread-free job dispatcher (M15 S2). Executes jobs on the calling
+104 -1
View File
@@ -668,7 +668,11 @@ impl RealClock {
}
/// Advances the playhead from the wall clock while playing, looping at
/// `length`. No-op when stopped.
/// `length`. No-op when stopped. The advance is clamped per tick: a
/// long stall (the first render after pressing play, a disk stall)
/// must not teleport the playhead past the pre-render window — the
/// dropped time is re-anchored away instead (NLEs drop frames during
/// stalls; they never jump the playhead over rendered content).
pub fn tick(&mut self, length: Frame) {
let Some((started, anchored)) = self.started else {
return;
@@ -679,6 +683,14 @@ impl RealClock {
(elapsed.as_secs_f64() * self.rate.num as f64 / self.rate.den as f64).round()
as i64,
);
// At 60 Hz ticks this allows up to 120 fps of advance — normal
// playback rates are unaffected; only stall teleporting is clamped.
const MAX_ADVANCE_PER_TICK: i64 = 2;
let current = self.transport.frame();
if frame.0 > current.0 + MAX_ADVANCE_PER_TICK {
frame = Frame(current.0 + MAX_ADVANCE_PER_TICK);
self.started = Some((Instant::now(), frame));
}
if length.0 > 0 && frame.0 >= length.0 {
frame = Frame(frame.0 % length.0);
}
@@ -1652,6 +1664,23 @@ impl RealEngine {
window
.submitted
.retain(|f| *f >= keep_from || (*f >= playhead && *f < end));
// Cancel in-flight/pending frames the playhead has already passed:
// when they complete they can never be displayed, but they still
// occupy worker time. Dropping them keeps the workers on frames
// around the playhead, so a stall-deferred window converges back
// instead of grinding through ancient history forever (the
// "picture frozen while the playhead moves" regression).
let stale_pending: Vec<i64> = window
.submitted
.iter()
.copied()
.filter(|f| *f < keep_from && !window.slots.contains_key(f))
.collect();
for f in stale_pending {
m.dispatch
.cancel_preview_frame(window.sequence, f, window.generation);
window.submitted.remove(&f);
}
let new_frames: Vec<i64> = (playhead.max(0)..end)
.filter(|f| !window.submitted.contains(f))
.collect();
@@ -6098,6 +6127,80 @@ mod tests {
let _ = std::fs::remove_file(&media);
}
/// The production scenario: real 1080p media on the timeline, driving
/// the actual `cpu_frame` display path the viewer paints with (not
/// just the window internals). The displayed frame must track the
/// playhead during playback — a permanently frozen picture means the
/// window never serves the display path.
#[gpui::test]
async fn playback_display_tracks_the_playhead(cx: &mut gpui::TestAppContext) {
let _media = media_lock();
let _worker = WorkerBinGuard::set();
let engine = cx.update(|cx| cx.new(|cx| RealEngine::create(cx)));
cx.update(|app| engine.update(app, |engine, cx| engine.new_project(cx)));
let media = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/demo.mp4");
cx.update(|app| {
engine.update(app, |engine, cx| {
engine.import_footage(media.clone(), cx).expect("import")
})
});
let name = media.file_name().unwrap().to_string_lossy().into_owned();
let entry = cx.read(|app| {
engine
.read(app)
.roots()
.into_iter()
.find(|e| e.name.as_ref() == name)
})
.expect("imported footage is listed");
cx.update(|app| {
engine.update(app, |engine, cx| {
engine.drop_footage(entry.id, TrackKind::Video, 0, Frame(0), cx)
})
});
cx.update(|app| engine.update(app, |engine, cx| engine.play(Monitor::Program, cx)));
let deadline = std::time::Instant::now() + Duration::from_secs(30);
let mut last_displayed = -1i64;
loop {
cx.update(|app| engine.update(app, |engine, cx| engine.tick(cx)));
let (playhead, displayed, slots) = cx.read(|app| {
let engine = engine.read(app);
let playhead = engine.clock_frame(Monitor::Program, app).0;
let displayed = engine
.cpu_frame_cache
.lock()
.unwrap()
.get(&Monitor::Program)
.and_then(|e| e.proxy.as_ref().map(|p| p.frame))
.unwrap_or(-1);
let slots = engine
.preview_windows
.lock()
.unwrap()
.get(&Monitor::Program)
.map(|w| w.slots.len())
.unwrap_or(0);
(playhead, displayed, slots)
});
last_displayed = last_displayed.max(displayed);
if displayed >= 3 && playhead - displayed < 4 {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the displayed frame must track the playhead (playhead {playhead}, displayed {displayed}, peak displayed {last_displayed}, window slots {slots})"
);
// The viewer paints at ~60 Hz.
std::thread::sleep(Duration::from_millis(16));
cx.update(|app| {
engine.read(app).cpu_frame(Monitor::Program, app);
});
}
assert!(last_displayed >= 3);
}
// ---- M15 S3 audio prefetch ------------------------------------------
/// A lightweight `RenderedAudio` stand-in (the prefetch logic only