Preview: the start with sound waits only for frames it can get (#947)

The start held the first frame until `ahead.min(fit)` frames were cached,
which could never happen in two cases, holding it for ever:
- the prefetch requests offsets 0..min(ahead * 3, fit - 1) from the
  playhead, one frame fewer than that when the RAM preview limits it, and
  with the playhead held nothing else requested the last one;
- `fit` counts comp-sized frames, but Extended Viewer frames render
  larger, so fewer of them fit in the budget than it needs at once.

It now waits for startup_frames(ahead, window, budget, frame bytes):
ahead, no more than the prefetch window requests, and no more than fit
in the budget at the size the first frame really rendered at, at least
the current frame.

Tests: startup_frames unit cases (a 4-frame budget needs 3; a 160×120
comp's 320×248 Extended Viewer frames need 2); ui_audio_scrub starts from
a cold cache with budgets of 2, 3, 4, ahead - 1, ahead and ahead + 1
frames, with and without a device, and with Extended Viewer frames larger
than the comp: each leaves its startup, from frame 0 (both hold the first
frame for ever on the previous commit). Also: a device whose start fails
plays the picture; a device that consumes the sound moves the picture on
and a stall still stops it; the 30 s preview starts without caching the
whole work area; Stop after a step of preparation never opens the output.
This commit is contained in:
tylerjenningsw committed 2026-10-12 22:39:25 -04:00
1 parent 682f38733d
commit e519f21f45
2 files changed
+224 -5

No files matched your search

+44 -2
View File
@@ -52,6 +52,17 @@ const SCREENSHOT_TIMEOUT_S: f64 = 4.0;
/// [`EffectcraftApp::editing`]).
const EDIT_QUIET: f64 = 0.3;
/// 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
/// `frame_bytes`, the size frames really render at; at least the current frame, which the viewer
/// always requests. Waiting for a frame that is never requested, or that can't stay cached with
/// the others, would hold the first frame for ever.
fn startup_frames(ahead: i64, window: i64, budget: usize, frame_bytes: usize) -> i64 {
let fit = (budget as f64 * 0.9 / frame_bytes.max(1) as f64) as i64;
ahead.min(window).min(fit).max(1)
}
/// Modal dialogs.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Dialog {
@@ -1275,13 +1286,19 @@ impl EffectcraftApp {
let fit = self.frames_that_fit(&c, scale);
// Starting with sound: hold the first frame until the frames ahead are cached, then start
// the sound from that same frame (see `Playback::starting`). Without an output device the
// picture plays on its own clock from it.
// picture plays on its own clock from it. How many frames ahead: only ones the prefetch
// above requests and that fit in the RAM preview at the size they really render (an
// Extended Viewer frame is larger than the comp), so the wait always ends.
if self.playback.starting
&& let Some(p) = plan
{
self.playback.start_wall = now;
self.playback.start_frame = cur;
if !self.cached_ahead(&p, &series, cur, ahead.min(fit)) {
let need = self.frames.get(&FrameKey { frame: cur, ..series }).map(|img| {
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)) {
self.playback.waiting = true;
ctx.request_repaint();
return;
@@ -2175,3 +2192,28 @@ mod appearance_tests {
assert_eq!(app.ui.theme, ThemeKind::Darker);
}
}
#[cfg(test)]
mod startup_tests {
use super::startup_frames;
/// The frames a preview with sound waits for are always requested and fit together (#947).
#[test]
fn startup_waits_only_for_frames_it_requests_and_can_keep() {
// A 64×36 frame: 9216 bytes. A 46 080-byte budget fits 4 of them (90 %); the prefetch
// window then requests offsets 0..3, so the start needs those 3, not 4 it never asks for.
let frame = 64 * 36 * 4;
let fit = 4;
let window = (32i64 * 3).min(fit - 1);
assert_eq!(startup_frames(32, window, 46_080, frame), 3);
// A roomy budget: the prefetch's `ahead` frames.
assert_eq!(startup_frames(8, 24, 100_000_000, frame), 8);
// Extended Viewer: a 160×120 comp renders 320×248 frames. 952 320 bytes fit 11 comp-sized
// frames but only 2 real ones (90 %): wait for 2, not 4.
assert_eq!(startup_frames(4, 11, 952_320, 320 * 248 * 4), 2);
// Nothing fits, or no window: still the current frame, never 0.
assert_eq!(startup_frames(8, 0, 0, frame), 1);
// A zero frame size counts as 1 byte: no division by zero.
assert_eq!(startup_frames(8, 24, 10, 0), 8);
}
}
+180 -3
View File
@@ -182,6 +182,8 @@ fn preview_with_audio_sounds_without_caching_the_work_area() {
h.state_mut().play(now);
assert!(until_sound(&mut h, cid, 0), "the sound started");
assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame");
let cached = h.state().frames.cached_frames(&h.state().shown_series(cid)).len();
assert!((1..900).contains(&cached), "it started with {cached} of the 900 frames cached, not the whole work area");
}
/// Pull `frames` stereo frames from the output, waiting for the feeder thread to mix them.
@@ -263,13 +265,188 @@ fn no_device_plays_the_picture_and_stop_ends_the_wait() {
// Stop while preparing.
h.state_mut().stop();
let now = h.ctx.input(|i| i.time);
h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(fake.clone()) as Box<dyn AudioDevice>)));
let device = fake.clone();
h.state_mut().hooks.audio_device = Some(Box::new(move |_| Some(Box::new(device.clone()) as Box<dyn AudioDevice>)));
h.state().frames.clear();
h.state_mut().play(now);
assert!(h.state().playback.starting);
// One step into preparing: the frames ahead are requested and it waits for them.
h.step();
assert!(h.state().playback.starting && h.state().playback.waiting && h.state().audio.is_none(), "preparing");
h.state_mut().stop();
for _ in 0..10 {
// The renders it queued finish after Stop: they must not start the sound.
for _ in 0..30 {
h.step();
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(!h.state().playback.playing && !h.state().playback.starting && h.state().audio.is_none(), "Stop while preparing: nothing plays");
assert!(fake.0.lock().unwrap().0.is_none(), "the output never opened");
}
/// Start a preview and step until it leaves its startup (or 60 s pass). Returns whether it did.
fn until_started(h: &mut Harness<'_, EffectcraftApp>) -> bool {
let now = h.ctx.input(|i| i.time);
h.state_mut().play(now);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
while h.state().playback.starting && std::time::Instant::now() < deadline {
h.step();
std::thread::sleep(std::time::Duration::from_millis(1));
}
!h.state().playback.starting
}
/// RAM preview budgets just below, at and above the prefetch's frames ahead, and small ones, from
/// a cold cache, with and without an output device: the start never waits for a frame the
/// prefetch doesn't request (with a budget of 4 frames it asks for 3 ahead of the playhead).
#[test]
fn startup_finishes_at_any_ram_preview_budget() {
let probe = Fake::default();
let (app, _) = setup(true, &probe);
let ahead = (app.frames.parallelism() * 2).max(4) as i64;
// A 64×36 frame is 9216 bytes; `fit` frames fit in (fit + ½) frames' worth / 0.9.
let mut fits = vec![2, 3, 4, ahead - 1, ahead, ahead + 1];
fits.sort();
fits.dedup();
for fit in fits {
for device in [true, false] {
let fake = Fake::default();
let (mut app, cid) = setup_long(true, &fake, 8.0);
if !device {
app.hooks.audio_device = Some(Box::new(|_| None));
}
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);
let budget = ((fit as f64 + 0.5) * 9216.0 / 0.9) as usize;
h.state().frames.set_budget(budget);
h.state().frames.clear();
assert!(until_started(&mut h), "fit {fit} (ahead {ahead}), device {device}: the preview left its startup");
assert_eq!(h.state().audio.is_some(), device, "fit {fit}, device {device}");
if device {
assert_eq!(audio_frame(&h, cid), Some(0), "fit {fit}: from the first frame");
}
}
}
}
/// 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 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();
let l = s.execute("layer.newSolid", json!({"color": "#4060a0"})).unwrap()["layer"].as_u64().unwrap();
s.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap();
s.execute("layer.setSwitch", json!({"layers": [l], "switch": "threeD", "value": true})).unwrap();
s.execute("comp.setSwitch", json!({"switch": "draft3d", "value": true})).unwrap();
s.prefs.three_d.extended_viewer = true;
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);
let size = loop {
h.step();
let series = h.state().shown_series(cid);
if let Some(img) = h.state().frames.get(&series) {
break img.size();
}
assert!(std::time::Instant::now() < deadline, "the first frame rendered");
std::thread::sleep(std::time::Duration::from_millis(5));
};
assert!(h.state().ui.viewer.extended.is_some(), "Extended Viewer is on");
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;
assert!((budget as f64 * 0.9 / real_bytes as f64) < ahead as f64, "real frames ({size:?}) fit fewer than {ahead}");
h.state().frames.set_budget(budget);
h.state().frames.clear();
assert!(until_started(&mut h), "the preview left its startup");
assert!(h.state().audio.is_some(), "with its sound");
assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame");
}
/// An output device whose start fails.
#[derive(Clone, Default)]
struct Broken;
impl AudioDevice for Broken {
fn sample_rate(&self) -> u32 {
48000
}
fn start(&mut self, _feed: Arc<AudioFeed>) -> Result<(), String> {
Err("device unavailable".into())
}
fn stop(&mut self) {}
}
/// A device that fails to start: the picture plays on its own from the first frame.
#[test]
fn a_device_that_fails_to_start_plays_the_picture() {
let fake = Fake::default();
let (mut app, cid) = setup_long(true, &fake, 8.0);
app.hooks.audio_device = Some(Box::new(|_| Some(Box::new(Broken) as Box<dyn AudioDevice>)));
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);
assert!(until_started(&mut h), "it left its startup");
assert!(h.state().audio.is_none() && h.state().playback.playing);
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while frame(&h, cid) == 0 && std::time::Instant::now() < deadline {
h.step();
std::thread::sleep(std::time::Duration::from_millis(5));
}
assert!(frame(&h, cid) > 0, "the picture plays");
}
/// An output that plays: a thread takes the feed's samples in real time, as a sound card would.
#[derive(Clone, Default)]
struct Playing(Arc<Mutex<bool>>);
impl AudioDevice for Playing {
fn sample_rate(&self) -> u32 {
48000
}
fn start(&mut self, feed: Arc<AudioFeed>) -> Result<(), String> {
let stop = self.0.clone();
std::thread::spawn(move || {
let mut buf = vec![0.0f32; 480 * 2];
while !*stop.lock().unwrap() {
feed.pull(&mut buf);
std::thread::sleep(std::time::Duration::from_millis(10));
}
});
Ok(())
}
fn stop(&mut self) {
*self.0.lock().unwrap() = true;
}
}
/// Once started, the sound's clock moves the picture on from the first frame, and a frame that
/// isn't cached still stops the sound rather than being skipped.
#[test]
fn a_started_preview_follows_its_sound() {
let fake = Fake::default();
let (mut app, cid) = setup_long(true, &fake, 8.0);
let device = Playing::default();
let d = device.clone();
app.hooks.audio_device = Some(Box::new(move |_| Some(Box::new(d.clone()) as Box<dyn AudioDevice>)));
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);
assert!(until_started(&mut h) && h.state().audio.is_some(), "the sound started");
assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame");
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
while frame(&h, cid) < 5 && std::time::Instant::now() < deadline && h.state().audio.is_some() {
h.step();
std::thread::sleep(std::time::Duration::from_millis(10));
}
assert!(frame(&h, cid) >= 5, "the picture follows the sound's clock (at frame {})", frame(&h, cid));
h.state().frames.set_budget(0);
h.state().frames.clear();
h.step();
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;
}