fix(app): never block the UI thread on a sync render during playback

The main-process sample showed the UI thread spending 100% of its time
in TicketArena::wait from the painted frame's synchronous render: every
cache-missed playhead frame sync-rendered inline, and the seek-priority
ticket then stole worker capacity from the pre-render window while the
blocked tick loop could not feed it — a self-reinforcing loop that made
playback unusably choppy.

On a playback miss the viewer now shows the last displayed frame while
the pre-render window warms up/catches up (paused monitors and the very
first frame keep the synchronous path). Adds a gpui test driving real
playback that requires the window to supply playhead frames, and the
real-footage bench_playback example used for the measurements
(152 fps aggregate on 1080p H.264 at 480p preview, decode-bound).
This commit is contained in:
2026-08-19 05:12:30 +08:00
parent a17d5be56a
commit 9e9c6d9863
2 changed files with 298 additions and 0 deletions
+90
View File
@@ -3069,6 +3069,22 @@ impl AppEngine for RealEngine {
});
return image;
}
// During playback a cache miss must NOT block the UI thread on a
// synchronous render: the wait starves the tick loop that feeds
// the pre-render window, and the seek-priority ticket steals
// worker capacity from it, so the window never catches up (every
// painted frame blocked in `TicketArena::wait` — the choppy
// playback regression). Show the last displayed frame while the
// window warms up; the workers catch up within a few frames and
// the window then serves every playhead frame.
if self.clock(monitor).read(cx).transport.is_playing() {
if let Some(image) = cache
.get(&monitor)
.and_then(|e| e.proxy.as_ref().map(|p| p.image.clone()))
{
return image;
}
}
// Both monitors render through the oakrender ticket arena (falling
// back to the synthetic pattern when rendering is unavailable): the
// program monitor renders the current sequence, the source monitor
@@ -6008,6 +6024,80 @@ mod tests {
}
}
/// Playback pre-render window (M15 S2): during playback the playhead
/// frame must come from the worker-rendered shm slot cache, NOT the
/// synchronous render path (the main thread blocking in
/// `TicketArena::wait` on every painted frame is the "playback is
/// unusably choppy" regression — the UI must never sync-wait during
/// playback once the window has warmed up).
#[gpui::test]
async fn playback_window_supplies_playhead_frames(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::env::temp_dir().join(format!(
"oakapp_playback_window_{}.mp4",
std::process::id()
));
oakcodec::testmedia::write_test_clip(&media, 64, 64, 250, 25)
.expect("generate playback test media");
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)
})
});
// Start playback and drive the tick loop: the window must fill.
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 filled = 0usize;
let mut hit = false;
loop {
cx.update(|app| engine.update(app, |engine, cx| engine.tick(cx)));
let (slots, submitted) = cx.read(|app| {
let engine = engine.read(app);
let windows = engine.preview_windows.lock().unwrap();
let window = windows.get(&Monitor::Program);
(
window.map(|w| w.slots.len()).unwrap_or(0),
window.map(|w| w.submitted.len()).unwrap_or(0),
)
});
filled = filled.max(slots);
let playhead = cx.read(|app| engine.read(app).clock_frame(Monitor::Program, app));
hit = hit
|| cx
.update(|app| engine.update(app, |engine, _cx| engine.preview_slot_frame(Monitor::Program, playhead)))
.is_some();
if hit {
break;
}
assert!(
std::time::Instant::now() < deadline,
"the playback window must supply playhead frames (peak cached {filled}, submitted {submitted})"
);
std::thread::sleep(Duration::from_millis(10));
}
assert!(filled > 0, "the window cached worker frames");
let _ = std::fs::remove_file(&media);
}
// ---- M15 S3 audio prefetch ------------------------------------------
/// A lightweight `RenderedAudio` stand-in (the prefetch logic only