mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-03 00:59:00 +00:00
Merge pull request #1177 from tylerjenningsw/fix/947-preview-opening-audio
Preview: preserve opening audio while preparing video playback (#947)
This commit is contained in:
5 files changed
+577
-99
No files matched your search
@@ -40,11 +40,16 @@ impl EffectcraftApp {
|
||||
restore_max,
|
||||
audio_frame: (!plan.video).then_some(plan.first),
|
||||
audio_held: false,
|
||||
starting: false,
|
||||
starting_since: now,
|
||||
first_cached: None,
|
||||
shown_at: Default::default(),
|
||||
};
|
||||
if !self.playback.caching {
|
||||
if plan.video {
|
||||
self.playback.audio_held = self.audio_plays();
|
||||
let audible = self.audio_plays();
|
||||
self.playback.audio_held = audible;
|
||||
self.playback.starting = audible;
|
||||
} else {
|
||||
self.start_audio(fr.tick_of(plan.first));
|
||||
}
|
||||
@@ -104,6 +109,7 @@ impl EffectcraftApp {
|
||||
let was = std::mem::replace(&mut self.playback.playing, false);
|
||||
self.audio = None;
|
||||
self.playback.caching = false;
|
||||
self.playback.starting = false;
|
||||
if !was {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,29 @@ use super::{EffectcraftApp, FrameKey, FrameWindow, ItemId, PlaybackPass, audio,
|
||||
/// [`EffectcraftApp::editing`]).
|
||||
const EDIT_QUIET: f64 = 0.3;
|
||||
|
||||
/// Longest a preview with sound holds its first frame, once that frame is cached, waiting for the
|
||||
/// frames ahead (seconds, #947). A frame ahead that never gets cached 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;
|
||||
|
||||
/// Longest a preview with sound holds its first frame in all (seconds, #947). The first frame
|
||||
/// itself can take a while (a heavy comp, a busy machine), and [`STARTUP_WAIT`] counts only from
|
||||
/// when it is cached; but one that never gets cached (larger than the RAM preview, which the size
|
||||
/// estimate can't always tell) mustn't hold it for ever. The preview then goes on as a stalled
|
||||
/// one does: its sound starts once the frames are cached.
|
||||
const STARTUP_LIMIT: f64 = 10.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
|
||||
/// `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)
|
||||
}
|
||||
|
||||
impl EffectcraftApp {
|
||||
/// RAM preview: advance at the preview's frame rate when frames are cached; otherwise hold
|
||||
/// and keep rendering ahead (the green cache bar fills, then playback runs in real time).
|
||||
@@ -53,6 +76,9 @@ impl EffectcraftApp {
|
||||
return;
|
||||
}
|
||||
let fit = self.frames_that_fit(&c, scale);
|
||||
if self.hold_first_frame(&pass, fit) {
|
||||
return;
|
||||
}
|
||||
self.start_held_audio(&pass, fit);
|
||||
if self.advance_audio_clock(&pass, step) {
|
||||
return;
|
||||
@@ -179,8 +205,54 @@ impl EffectcraftApp {
|
||||
}
|
||||
}
|
||||
|
||||
/// Held sound starts once the frames ahead are cached (see `Playback::audio_held`): the
|
||||
/// prefetch window when the silent preview keeps real time, else the rest of the preview.
|
||||
/// Starting a preview 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. How many frames ahead: only ones
|
||||
/// `prefetch_playback` 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. Returns true
|
||||
/// while it holds the first frame.
|
||||
fn hold_first_frame(&mut self, pass: &PlaybackPass<'_>, fit: i64) -> bool {
|
||||
let PlaybackPass { ctx, comp, scale, now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
|
||||
if !self.playback.starting {
|
||||
return false;
|
||||
}
|
||||
let Some(p) = plan else { return false };
|
||||
self.playback.start_wall = now;
|
||||
self.playback.start_frame = cur;
|
||||
// `prefetch_playback` requests every uncached frame of offsets 0..window from here.
|
||||
let window = (ahead * 3).min(fit - 1);
|
||||
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() && self.playback.first_cached.is_none() {
|
||||
self.playback.first_cached = Some(now);
|
||||
}
|
||||
// Don't wait for frames the RAM preview can't hold (not even one frame fits), for the
|
||||
// frames ahead longer than STARTUP_WAIT once the first is in, nor longer than
|
||||
// STARTUP_LIMIT in all (a frame that never gets cached).
|
||||
let give_up = (self.frames.budget() as f64) < self.frame_bytes(comp, scale)
|
||||
|| self.playback.first_cached.is_some_and(|t| now - t >= STARTUP_WAIT)
|
||||
|| now - self.playback.starting_since >= STARTUP_LIMIT;
|
||||
if !give_up && !need.is_some_and(|n| self.cached_ahead(&p, &series, cur, n)) {
|
||||
self.playback.waiting = true;
|
||||
ctx.request_repaint();
|
||||
return true;
|
||||
}
|
||||
self.playback.starting = false;
|
||||
self.playback.waiting = false;
|
||||
// Not on a frame that isn't cached: the stall below would stop the sound at once. The
|
||||
// preview goes on as a stalled one does, and the sound starts once frames are cached.
|
||||
if need.is_some() {
|
||||
self.playback.audio_held = false;
|
||||
self.start_audio(fr.tick_of(cur));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// Held sound starts once the frames ahead are cached again after a stall (see
|
||||
/// `Playback::audio_held`): the prefetch window when the silent preview keeps real time, else
|
||||
/// the rest of the preview.
|
||||
fn start_held_audio(&mut self, pass: &PlaybackPass<'_>, fit: i64) {
|
||||
let PlaybackPass { now, plan, series, cur, ahead, frame_rate: fr, .. } = *pass;
|
||||
if !self.playback.audio_held {
|
||||
@@ -365,9 +437,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 {
|
||||
@@ -379,3 +455,28 @@ impl EffectcraftApp {
|
||||
self.frames.parallelism()
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
@@ -184,6 +184,17 @@ pub struct Playback {
|
||||
/// fits in the RAM preview) is cached, so it never plays over skipped frames (#103), or
|
||||
/// sooner once the silent preview keeps real time with the frames ahead cached (#208).
|
||||
pub audio_held: bool,
|
||||
/// Starting a preview with sound (#947): the playhead stays on the first frame while the
|
||||
/// frames ahead render (the prefetch window, at most what fits in the RAM preview), then
|
||||
/// sound and picture start together from it. Without this the picture played on silently
|
||||
/// 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_LIMIT`.
|
||||
pub starting_since: f64,
|
||||
/// When the first frame was cached while [`Playback::starting`] (seconds): the wait for the
|
||||
/// frames ahead gives up `STARTUP_WAIT` after it.
|
||||
pub first_cached: Option<f64>,
|
||||
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
|
||||
pub shown_at: std::collections::VecDeque<f64>,
|
||||
}
|
||||
|
||||
@@ -166,96 +166,90 @@ fn cmd_dragging_the_current_time_scrubs_and_an_idle_scrub_closes() {
|
||||
assert!(h.state().scrub.is_none() && fake.0.lock().unwrap().1, "closed and stopped");
|
||||
}
|
||||
|
||||
/// A preview with audio waits for frames like a silent one (#103): while the frames ahead aren't
|
||||
/// cached, every frame shows as it renders, silently; the sound starts once the rest of the
|
||||
/// preview is cached, and a frame that isn't cached stops it instead of being skipped.
|
||||
/// The comp's frame at the current time.
|
||||
fn frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> i64 {
|
||||
let app = h.state();
|
||||
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
|
||||
}
|
||||
|
||||
/// The frame under the preview audio's first sample (its clock before the device pulls any).
|
||||
fn audio_frame(h: &Harness<'_, EffectcraftApp>, cid: ItemId) -> Option<i64> {
|
||||
let app = h.state();
|
||||
let a = app.audio.as_ref()?;
|
||||
Some(effectcraft_ui_egui::audio::frame_of_sample(a.clock(), a.rate, app.session.project.comp(cid).unwrap().frame_rate))
|
||||
}
|
||||
|
||||
/// Step until the preview's sound starts (or 60 s pass, for a loaded machine), checking the
|
||||
/// playhead holds `first` meanwhile. Returns whether the sound started.
|
||||
fn until_sound(h: &mut Harness<'_, EffectcraftApp>, cid: ItemId, first: i64) -> bool {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while std::time::Instant::now() < deadline {
|
||||
step(h);
|
||||
if h.state().audio.is_some() {
|
||||
return true;
|
||||
}
|
||||
assert_eq!(frame(h, cid), first, "the playhead holds the first frame until the sound can start");
|
||||
assert!(h.state().playback.starting && h.state().playback.playing);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// One step with the app clock, which playback runs on, a step (1/30 s) further on: these tests
|
||||
/// count time in steps, however long a step takes on a loaded machine.
|
||||
fn step(h: &mut Harness<'_, EffectcraftApp>) {
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
}
|
||||
|
||||
/// Starting a preview with sound before its frames are cached (#947): the playhead holds the
|
||||
/// first frame while the frames ahead render, then sound and picture start together from it (the
|
||||
/// sound used to start at a later frame, losing the opening). A frame that isn't cached later on
|
||||
/// still stops the sound rather than being skipped (#103).
|
||||
#[test]
|
||||
fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
|
||||
fn preview_with_audio_holds_the_first_frame_and_sounds_from_it() {
|
||||
let fake = Fake::default();
|
||||
let (mut app, cid) = setup(true, &fake);
|
||||
// Hold rendering in the test: the cache cannot fill ahead of what the test releases, so the
|
||||
// silently shown frames and the moment the sound starts do not depend on the host's threads.
|
||||
// Release only the frames this test chooses, and drive the app clock: rendering cannot race
|
||||
// the clock on a slow or loaded test host.
|
||||
let gate = GatedFrames::default();
|
||||
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
|
||||
app.frames.set_remote(Some(renderer));
|
||||
let ahead = (app.frames.parallelism() * 2).max(4);
|
||||
let ahead = (app.frames.parallelism() * 2).max(4) as i64;
|
||||
app.session.execute("comp.settings", json!({"comp": cid.0, "duration": (4 * ahead) as f64 / 30.0})).unwrap();
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
|
||||
// Playback measures time on the app clock, never `ctx.input(|i| i.time)`; drive that clock
|
||||
// explicitly at one frame per step.
|
||||
h.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
gate.discard(&mut h);
|
||||
let frame = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let app = h.state();
|
||||
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
|
||||
};
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
|
||||
let mut last = frame(&h);
|
||||
let mut shown = 0;
|
||||
// Show frames while only those frames are cached: they play silently first.
|
||||
for _ in 0..4 {
|
||||
let target = last + 1;
|
||||
gate.render(&mut h, cid, &[target]);
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
let f = frame(&h);
|
||||
// Accumulating 1/30 in f64 can leave the same frame up for one more step; it must never
|
||||
// jump over a frame.
|
||||
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
|
||||
shown += usize::from(f != last);
|
||||
last = f;
|
||||
}
|
||||
// A frame that has not rendered holds the clock at the frame on screen; once it renders it is
|
||||
// shown, never jumped over. This exercises the production hold path, not a test-only shortcut.
|
||||
let waiting = last;
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
assert_eq!(frame(&h), waiting, "an uncached frame holds the preview instead of being skipped");
|
||||
gate.render(&mut h, cid, &[waiting + 1]);
|
||||
// f64 clock accumulation need not put the next step exactly on the next frame boundary;
|
||||
// keep stepping until the held frame appears, and never allow it to jump past that frame.
|
||||
let mut showed_waiting = false;
|
||||
for _ in 0..4 {
|
||||
h.state_mut().clock_mut().advance(1.0 / 30.0);
|
||||
h.step();
|
||||
let f = frame(&h);
|
||||
assert!(f == waiting || f == waiting + 1, "{waiting} → {f}: the held frame was skipped");
|
||||
if f == waiting + 1 {
|
||||
showed_waiting = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(showed_waiting, "the waiting frame is shown once it renders");
|
||||
shown += 1;
|
||||
last = waiting + 1;
|
||||
assert!(shown > 0, "frames played silently first");
|
||||
assert!(h.state().audio.is_none() && h.state().playback.audio_held, "the rest of the range is not cached: the sound waits");
|
||||
// Cache everything ahead; only then may the sound start.
|
||||
let end = h.state().playback.plan.expect("a preview plan").end;
|
||||
let rest: Vec<i64> = ((last + 1)..=end).collect();
|
||||
gate.render(&mut h, cid, &rest);
|
||||
h.step();
|
||||
assert!(h.state().playback.starting && h.state().audio.is_none(), "nothing is cached: no sound yet");
|
||||
// Half the frames ahead cached: the playhead holds the first frame, silently.
|
||||
let half: Vec<i64> = (0..ahead / 2).collect();
|
||||
gate.render(&mut h, cid, &half);
|
||||
step(&mut h);
|
||||
assert!(h.state().playback.starting && h.state().audio.is_none(), "{} of {ahead} frames ahead cached: no sound yet", half.len());
|
||||
assert_eq!(frame(&h, cid), 0, "the playhead holds the first frame");
|
||||
// All of them: the sound starts, from that frame.
|
||||
let all: Vec<i64> = (0..ahead).collect();
|
||||
gate.render(&mut h, cid, &all);
|
||||
step(&mut h);
|
||||
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
|
||||
assert_eq!(audio_frame(&h, cid), Some(0), "from the first frame, not a later one");
|
||||
assert!(!h.state().playback.starting);
|
||||
// A frame that isn't cached stops the sound rather than being skipped.
|
||||
gate.discard(&mut h);
|
||||
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 preview whose frames render in real time sounds without waiting for the whole work area to
|
||||
/// be cached (#208): once the silent preview has kept up for a moment with the frames ahead
|
||||
/// cached, the sound starts, still without skipping a frame before it.
|
||||
/// A long (30 s) preview sounds once the frames ahead of its first frame are cached, not the
|
||||
/// whole work area (#208), and from that first frame (#947).
|
||||
#[test]
|
||||
fn preview_with_audio_sounds_once_rendering_keeps_up() {
|
||||
fn preview_with_audio_sounds_without_caching_the_work_area() {
|
||||
let fake = Fake::default();
|
||||
let (mut app, cid) = setup_long(true, &fake, 30.0);
|
||||
// Release only the frames this test chooses, and drive the app clock: rendering cannot race
|
||||
// the clock, so a frame is never skipped by a slow or loaded test host.
|
||||
let gate = GatedFrames::default();
|
||||
let renderer: Arc<dyn RemoteFrames> = Arc::new(gate.clone());
|
||||
app.frames.set_remote(Some(renderer));
|
||||
@@ -264,36 +258,396 @@ fn preview_with_audio_sounds_once_rendering_keeps_up() {
|
||||
h.run_steps(2);
|
||||
gate.discard(&mut h);
|
||||
let ahead = (h.state().frames.parallelism() * 2).max(4) as i64;
|
||||
let frame = |h: &Harness<'_, EffectcraftApp>| {
|
||||
let app = h.state();
|
||||
app.session.project.comp(cid).unwrap().frame_rate.frame_at(app.session.time())
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
let frames: Vec<i64> = (0..ahead).collect();
|
||||
gate.render(&mut h, cid, &frames);
|
||||
step(&mut h);
|
||||
assert!(h.state().audio.is_some(), "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.
|
||||
fn pull(feed: &AudioFeed, frames: usize) -> Vec<f32> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20);
|
||||
while feed.queued_frames() < frames && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let mut out = vec![0.0f32; frames * 2];
|
||||
feed.pull(&mut out);
|
||||
out
|
||||
}
|
||||
|
||||
/// The opening of the preview is heard (#947): a sound that lasts only the first 100 ms after the
|
||||
/// start, from the beginning of the comp or from the middle, reaches the output device.
|
||||
#[test]
|
||||
fn a_sound_in_the_first_100_ms_is_heard() {
|
||||
for start in [0.0, 1.0] {
|
||||
let fake = Fake::default();
|
||||
let (mut app, cid) = setup_long(false, &fake, 4.0);
|
||||
// A tone only from `start` to `start` + 0.1 s.
|
||||
let l = app.session.execute("layer.newSolid", json!({"color": "#808080"})).unwrap()["layer"].as_u64().unwrap();
|
||||
app.session.execute("effect.apply", json!({"layers": [l], "effect": "ec.audio.tone"})).unwrap();
|
||||
app.session.execute("layer.setSwitch", json!({"layers": [l], "switch": "audio", "value": true})).unwrap();
|
||||
app.session.execute("layer.timing", json!({"layers": [l], "in": start, "out": start + 0.1})).unwrap();
|
||||
app.session.execute("time.set", json!({"time": start})).unwrap();
|
||||
let first = (start * 30.0) as i64;
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
|
||||
h.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
assert!(until_sound(&mut h, cid, first), "{start}: the sound started");
|
||||
assert_eq!(audio_frame(&h, cid), Some(first), "{start}: from the first frame");
|
||||
let feed = fake.0.lock().unwrap().0.clone().expect("the output opened");
|
||||
// The first 100 ms at 48 kHz.
|
||||
let out = pull(&feed, 4800);
|
||||
assert!(out.iter().any(|v| v.abs() > 0.01), "{start}: the opening tone reached the output");
|
||||
}
|
||||
}
|
||||
|
||||
/// A preview without video starts its sound at once, from the first frame (unchanged), and so
|
||||
/// does one whose frames are already cached.
|
||||
#[test]
|
||||
fn audio_only_previews_start_at_once() {
|
||||
let fake = Fake::default();
|
||||
let (app, cid) = setup(true, &fake);
|
||||
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(|_| app);
|
||||
h.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
let now = h.state().now();
|
||||
h.state_mut().play_with(now, effectcraft_engine::preview::PreviewShortcut::AltNumpad0);
|
||||
assert!(h.state().audio.is_some() && !h.state().playback.starting, "Video off: the sound starts at once");
|
||||
assert_eq!(audio_frame(&h, cid), Some(0));
|
||||
}
|
||||
|
||||
/// Without an output device the preview still plays, from the first frame, once the frames ahead
|
||||
/// are cached; Stop while it prepares leaves nothing playing.
|
||||
#[test]
|
||||
fn no_device_plays_the_picture_and_stop_ends_the_wait() {
|
||||
let fake = Fake::default();
|
||||
let (mut app, cid) = setup_long(true, &fake, 10.0);
|
||||
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.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
assert!(h.state().playback.starting);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while h.state().playback.starting && std::time::Instant::now() < deadline {
|
||||
step(&mut h);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(!h.state().playback.starting && h.state().audio.is_none() && h.state().playback.playing, "no device: the picture plays alone");
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
||||
while frame(&h, cid) == 0 && std::time::Instant::now() < deadline {
|
||||
step(&mut h);
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
assert!(frame(&h, cid) > 0, "from the first frame on");
|
||||
// Stop while preparing.
|
||||
h.state_mut().stop();
|
||||
let now = h.state().now();
|
||||
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);
|
||||
// One step into preparing: the frames ahead are requested and it waits for them.
|
||||
step(&mut h);
|
||||
assert!(h.state().playback.starting && h.state().playback.waiting && h.state().audio.is_none(), "preparing");
|
||||
h.state_mut().stop();
|
||||
// The renders it queued finish after Stop: they must not start the sound.
|
||||
for _ in 0..30 {
|
||||
step(&mut h);
|
||||
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.state().now();
|
||||
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 {
|
||||
step(h);
|
||||
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.state_mut().use_manual_clock();
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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();
|
||||
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 mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 30.0).build_eframe(|_| app);
|
||||
h.state_mut().use_manual_clock();
|
||||
// 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 {
|
||||
step(&mut h);
|
||||
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");
|
||||
(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;
|
||||
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();
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
step(&mut h);
|
||||
// The first frame renders before the harness clock moves on: each step is 1/30 s however long
|
||||
// it takes, so on a busy machine the first frame could take longer than STARTUP_LIMIT of that
|
||||
// clock, and this test is about the wait for the frames ahead.
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while !h.state().frames.is_cached(&h.state().shown_series(cid)) && std::time::Instant::now() < deadline {
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
while h.state().playback.starting && std::time::Instant::now() < deadline {
|
||||
step(&mut h);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(!h.state().playback.starting, "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");
|
||||
let (started, stopped) = {
|
||||
let d = fake.0.lock().unwrap();
|
||||
(d.0.is_some(), d.1)
|
||||
};
|
||||
assert!(started && !stopped, "the device started and kept playing");
|
||||
}
|
||||
|
||||
/// A first frame that takes longer than STARTUP_WAIT to be cached (a heavy comp, a busy machine;
|
||||
/// here it can't be cached until the RAM preview grows): the start keeps holding it, then the
|
||||
/// sound starts from it and keeps playing. It used to give up two seconds after Play, start the
|
||||
/// sound on the uncached frame and stop it at once, so the opening played silently.
|
||||
#[test]
|
||||
fn a_slow_first_frame_keeps_its_opening_sound() {
|
||||
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");
|
||||
h.state().frames.set_budget((comp_bytes + real_bytes) / 2);
|
||||
h.state().frames.clear();
|
||||
let start = h.state().now();
|
||||
h.state_mut().play(start);
|
||||
assert!(h.state().playback.audio_held && h.state().audio.is_none(), "nothing is cached: no sound yet");
|
||||
let mut last = frame(&h);
|
||||
let mut shown = 0;
|
||||
// Two seconds of playback at one frame per clock step: keep the prefetch window cached, but
|
||||
// not the rest of the 30 s comp, so the sound has to start by the real-time rule.
|
||||
for step in 1..=60 {
|
||||
let target = last + 1;
|
||||
let window: Vec<i64> = (target..target + ahead).collect();
|
||||
gate.render(&mut h, cid, &window);
|
||||
// Set the clock to this step's exact time rather than adding 1/30 repeatedly: accumulated
|
||||
// f64 error can put one step just below a frame boundary and the next just above it, so
|
||||
// `floor(elapsed × fps)` would otherwise jump from frame N to N + 2 for a one-frame step.
|
||||
let now = h.state().now();
|
||||
let tick_time = start + step as f64 / 30.0;
|
||||
h.state_mut().clock_mut().advance(tick_time - now);
|
||||
h.step();
|
||||
if h.state().audio.is_some() {
|
||||
break;
|
||||
}
|
||||
let f = frame(&h);
|
||||
assert!(f == last || f == last + 1, "{last} → {f}: a frame was skipped");
|
||||
shown += usize::from(f != last);
|
||||
last = f;
|
||||
while h.state().now() - start < 3.0 {
|
||||
step(&mut h);
|
||||
assert!(h.state().playback.starting, "it still holds the first frame");
|
||||
assert_eq!(frame(&h, cid), 0);
|
||||
}
|
||||
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started within 2 s, at frame {last} of 900");
|
||||
assert!(shown >= 3, "the sound starts only after the silent preview kept up for a moment");
|
||||
assert!(fake.0.lock().unwrap().0.is_none(), "no sound yet");
|
||||
h.state().frames.set_budget(1 << 30);
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(60);
|
||||
while h.state().playback.starting && std::time::Instant::now() < deadline {
|
||||
step(&mut h);
|
||||
std::thread::sleep(std::time::Duration::from_millis(1));
|
||||
}
|
||||
assert!(!h.state().playback.starting, "the preview left its startup");
|
||||
assert_eq!(audio_frame(&h, cid), Some(0), "with its sound from the first frame");
|
||||
let (started, stopped) = {
|
||||
let d = fake.0.lock().unwrap();
|
||||
(d.0.is_some(), d.1)
|
||||
};
|
||||
assert!(started && !stopped, "the device started and kept playing");
|
||||
}
|
||||
|
||||
/// 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.state_mut().use_manual_clock();
|
||||
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 {
|
||||
step(&mut h);
|
||||
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.state_mut().use_manual_clock();
|
||||
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() {
|
||||
step(&mut h);
|
||||
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();
|
||||
step(&mut h);
|
||||
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 isn't started on a frame that can't be shown
|
||||
/// (a stall would stop it at once): the preview goes on as a stalled one does.
|
||||
#[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.state_mut().use_manual_clock();
|
||||
h.run_steps(2);
|
||||
h.state().frames.set_budget(budget);
|
||||
h.state().frames.clear();
|
||||
let now = h.state().now();
|
||||
h.state_mut().play(now);
|
||||
step(&mut h);
|
||||
assert!(!h.state().playback.starting, "budget {budget}: no wait");
|
||||
assert!(fake.0.lock().unwrap().0.is_none(), "budget {budget}: no sound started and dropped");
|
||||
assert!(h.state().playback.playing && h.state().playback.audio_held, "budget {budget}: it goes on as a stalled preview");
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 STARTUP_LIMIT instead of holding the
|
||||
/// first frame for ever, without starting the sound on a frame it can't show.
|
||||
#[test]
|
||||
fn startup_gives_up_on_a_first_frame_that_never_caches() {
|
||||
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.state().now();
|
||||
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");
|
||||
step(&mut h);
|
||||
}
|
||||
let waited = h.state().now() - start;
|
||||
assert!(!h.state().playback.starting, "it gave up waiting");
|
||||
assert!((9.9..12.0).contains(&waited), "after about ten seconds ({waited:.2} s)");
|
||||
assert!(fake.0.lock().unwrap().0.is_none(), "no sound started and dropped");
|
||||
assert!(h.state().playback.playing && h.state().playback.audio_held, "it goes on as a stalled preview");
|
||||
}
|
||||
@@ -133,6 +133,12 @@ The most important missing piece: it turns every other estimate here into a meas
|
||||
the prompt, with their layers, in one undo step, for single items, multiple selections and
|
||||
folder contents. Nothing happens with an empty selection, in other panels, while typing or with
|
||||
a dialog open, and the next Delete still asks.
|
||||
- Regression evidence for [#947](https://github.com/storytold/effectcraft/issues/947): the
|
||||
preview startup tests in `ui_audio_scrub`, checked headlessly on Windows with a fake output
|
||||
device, start an audible preview on an uncached comp. The playhead holds the first frame until
|
||||
the frames ahead are cached, the sound starts at that frame (from 0 s and from 1 s, also for a
|
||||
30 s work area), and a tone lasting only the first 100 ms reaches the output. Video off still
|
||||
starts at once, no device plays the picture, and Stop while preparing leaves nothing playing.
|
||||
- Regression evidence for [#612](https://github.com/storytold/effectcraft/issues/612): the
|
||||
`ui_gradient_value` and `ui_gradient_editor` input tests, checked headlessly on Windows, open
|
||||
the Gradient Editor from the Timeline's "Edit Gradient…" on a shape layer's Gradient Fill. They
|
||||
|
||||
Reference in new issue
Block a user