Preview: the start with sound never waits for ever (#947)

The startup hold waited for the current frame to be cached before
anything else, so a frame that can never be cached held the first frame
for ever: a zero RAM preview budget, or a single frame larger than the
budget (which the comp-size estimate misses for Extended Viewer frames).

It now gives up at once when the budget can't hold even one comp-sized
frame, and otherwise after STARTUP_WAIT (2 s from pressing play): the
sound starts from the first frame, and the preview goes on as a stalled
one does. frame_bytes() is factored out of frames_that_fit() for it.

Tests (ui_audio_scrub): with a budget of 0 or half a frame the sound
starts on the first step; with an Extended Viewer frame larger than the
whole budget (the estimate says one fits) the first frame holds for about
two seconds, then the sound starts. Both wait for ever on the previous
commit. The Extended Viewer setup is now shared by its two tests.
This commit is contained in:
tylerjenningsw committed 2026-10-13 07:56:29 -04:00
1 parent e519f21f45
commit c75ea46c86
2 files changed
+79 -9

No files matched your search

+19 -3
View File
@@ -52,6 +52,12 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0;
/// [`EffectcraftApp::editing`]).
const EDIT_QUIET: f64 = 0.3;
/// Longest a preview with sound holds its first frame waiting for frames (seconds, #947). A frame
/// that never gets cached (larger than the RAM preview, which the size estimate can't always
/// tell) mustn't hold it for ever: the sound starts from the first frame anyway, and the preview
/// goes on as a stalled one does.
const STARTUP_WAIT: f64 = 2.0;
/// How many frames from the first a preview with sound waits for before it starts (#947): the
/// prefetch's `ahead`, but no more than its `window` requests from the current frame (offsets
/// `0..window`), and no more than fit in the RAM preview `budget` (90 %, as for the prefetch) at
@@ -234,6 +240,8 @@ pub struct Playback {
/// while they rendered and the sound started at the later frame, so the opening was lost.
/// A stall later on still falls back to [`Playback::audio_held`].
pub starting: bool,
/// When the preview started (seconds): [`Playback::starting`] gives up after [`STARTUP_WAIT`].
pub starting_since: f64,
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
pub shown_at: std::collections::VecDeque<f64>,
}
@@ -1022,6 +1030,7 @@ impl EffectcraftApp {
audio_frame: (!plan.video).then_some(plan.first),
audio_held: false,
starting: false,
starting_since: now,
shown_at: Default::default(),
};
if !self.playback.caching {
@@ -1298,7 +1307,10 @@ impl EffectcraftApp {
let [w, h] = img.size();
startup_frames(ahead, window, self.frames.budget(), w.saturating_mul(h).saturating_mul(4))
});
if !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) {
// Don't wait for frames the RAM preview can't hold (not even one frame fits), nor for
// longer than STARTUP_WAIT (a frame that never gets cached).
let give_up = (self.frames.budget() as f64) < self.frame_bytes(&c, scale) || now - self.playback.starting_since >= STARTUP_WAIT;
if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) {
self.playback.waiting = true;
ctx.request_repaint();
return;
@@ -1467,9 +1479,13 @@ impl EffectcraftApp {
}
fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 {
((self.frames.budget() as f64 * 0.9 / self.frame_bytes(comp, scale)) as i64).max(1)
}
/// Bytes of one comp-sized viewer frame at `scale` (Extended Viewer frames are larger).
fn frame_bytes(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> f64 {
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
let frame = side(comp.width) * side(comp.height) * 4.0;
((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1)
side(comp.width) * side(comp.height) * 4.0
}
fn frames_parallelism(&self) -> usize {
+60 -6
View File
@@ -327,11 +327,10 @@ fn startup_finishes_at_any_ram_preview_budget() {
}
}
/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized
/// frames holds fewer of them, and the start waits only for as many as really fit (#947).
#[test]
fn startup_finishes_when_extended_viewer_frames_are_larger() {
let fake = Fake::default();
/// An audible 160×120 comp with a 3D layer and Draft 3D, Extended Viewer on: its viewer frames
/// render the pasteboard too, larger than the comp. Returns the harness once the first frame has
/// rendered, the comp, and that frame's size.
fn extended_viewer(fake: &Fake) -> (Harness<'static, EffectcraftApp>, ItemId, [usize; 2]) {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "E", "width": 160, "height": 120, "frameRate": 30, "duration": 8})).unwrap();
let cid = s.active_comp_id().unwrap();
@@ -344,7 +343,6 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() {
let mut app = EffectcraftApp::new(s);
let f = fake.clone();
app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(f.clone()) as Box<dyn AudioDevice>)));
let ahead = (app.frames.parallelism() * 2).max(4);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
// The viewer shows the pasteboard: frames render the extended region.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
@@ -358,6 +356,16 @@ fn startup_finishes_when_extended_viewer_frames_are_larger() {
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on");
(h, cid, size)
}
/// Extended Viewer frames render larger than the comp: a budget that would hold `ahead` comp-sized
/// frames holds fewer of them, and the start waits only for as many as really fit (#947).
#[test]
fn startup_finishes_when_extended_viewer_frames_are_larger() {
let fake = Fake::default();
let (mut h, cid, size) = extended_viewer(&fake);
let ahead = (h.state().frames.parallelism() * 2).max(4);
let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4);
// Room for `ahead` comp-sized frames (and a half), which is fewer real ones.
let budget = ((ahead as f64 + 0.5) * comp_bytes as f64 / 0.9) as usize;
@@ -450,3 +458,49 @@ fn a_started_preview_follows_its_sound() {
assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing, "a stall stops the sound");
*device.0.lock().unwrap() = true;
}
/// A RAM preview that can't hold even one frame (none, or half of one): the start doesn't wait
/// for frames that can never be cached; the sound starts at once from the first frame.
#[test]
fn startup_does_not_wait_when_the_ram_preview_holds_no_frame() {
for budget in [0, 9216 / 2] {
let fake = Fake::default();
let (app, _) = setup_long(true, &fake, 8.0);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
h.run_steps(2);
h.state().frames.set_budget(budget);
h.state().frames.clear();
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
h.step();
assert!(!h.state().playback.starting, "budget {budget}: no wait");
assert!(fake.0.lock().unwrap().0.is_some(), "budget {budget}: the sound started");
assert!(h.state().playback.playing);
}
}
/// A frame that never gets cached although the estimate says one fits (an Extended Viewer frame
/// larger than the whole budget): the start gives up after a short wait instead of holding the
/// first frame for ever, and the sound starts from it.
#[test]
fn startup_gives_up_after_a_short_wait() {
let fake = Fake::default();
let (mut h, cid, size) = extended_viewer(&fake);
let (comp_bytes, real_bytes) = (160 * 120 * 4, size[0] * size[1] * 4);
assert!(real_bytes > comp_bytes * 2, "Extended Viewer frames ({size:?}) are larger than the comp");
// Room for a comp-sized frame but not a real one: the current frame can never be cached.
let budget = (comp_bytes + real_bytes) / 2;
h.state().frames.set_budget(budget);
h.state().frames.clear();
let start = h.ctx.input(|i| i.time);
h.state_mut().play(start);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while h.state().playback.starting && std::time::Instant::now() < deadline {
assert_eq!(frame(&h, cid), 0, "it holds the first frame while it waits");
h.step();
}
let waited = h.ctx.input(|i| i.time) - start;
assert!(!h.state().playback.starting, "it gave up waiting");
assert!((1.9..4.0).contains(&waited), "after about two seconds ({waited:.2} s)");
assert!(fake.0.lock().unwrap().0.is_some(), "and the sound started");
}