Merge pull request #127 from storytold/fix/103-preview-audio-cache

Preview: Audio, Lock and Shy keep the cache; playback with audio waits for frames (#103)
This commit is contained in:
BFlat 2026-10-07 01:47:01 +07:00 committed by GitHub
commit 0fac647928
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 348 additions and 36 deletions

View File

@ -47,6 +47,27 @@ fn demo_layer_cache_is_transparent() {
}
}
/// Audio, Lock and Shy don't change pixels: toggling them keeps the cached layers (#103).
#[test]
fn audio_lock_and_shy_keep_cached_layers() {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "C", "width": 160, "height": 90, "frameRate": 30, "duration": 1})).unwrap();
s.execute("layer.newSolid", json!({"color": "#3080ff", "width": 80, "height": 40})).unwrap();
s.execute("effect.apply", json!({"layer": "#1", "effect": "Gaussian Blur"})).unwrap();
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
let cid = s.active_comp_id().unwrap();
let t = s.time();
s.render(cid, t, Default::default());
let before = s.layer_cache.stats();
for sw in ["audio", "lock", "shy"] {
s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": sw, "value": sw != "audio"})).unwrap();
}
s.render(cid, t, Default::default());
let after = s.layer_cache.stats();
assert_eq!(after.misses, before.misses, "no layer rendered again");
assert!(after.hits > before.hits);
}
#[test]
fn every_command_has_unique_id_and_runs_or_reports() {
let mut ids = std::collections::HashSet::new();

View File

@ -117,6 +117,12 @@ fn footage_changes_change_the_salt() {
s.execute("prop.set", json!({"layer": "#1", "path": "transform/opacity", "value": 50})).unwrap();
let k2 = effectcraft_render::disk_cache::comp_content_key(&s.project, cid);
assert_ne!(k1, k2);
// Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't change it (#103).
for sw in ["audio", "lock", "shy"] {
s.execute("layer.setSwitch", json!({"layers": ["#1"], "switch": sw, "value": sw != "audio"})).unwrap();
}
s.execute("comp.setSwitch", json!({"switch": "hideShy", "value": true})).unwrap();
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);
// Unrelated comps do not change it.
s.execute("comp.new", json!({"name": "Other", "width": 10, "height": 10, "frameRate": 30, "duration": 1})).unwrap();
assert_eq!(effectcraft_render::disk_cache::comp_content_key(&s.project, cid), k2);

View File

@ -613,6 +613,139 @@ impl Default for Switches {
}
}
// What a frame's pixels depend on. Caches key on it: the Audio, Lock and Shy switches and Hide
// Shy Layers don't change pixels, so toggling them keeps cached frames (#103).
impl Switches {
/// These switches with Audio, Lock and Shy at their defaults.
pub fn pixels(&self) -> Switches {
Switches { audio: true, locked: false, shy: false, ..self.clone() }
}
}
impl Layer {
/// Draws the same pixels as `o`: equal but for the Audio, Lock and Shy switches.
pub fn same_pixels(&self, o: &Layer) -> bool {
// Every field (no `..`: a new field must be considered here); the property tree last.
let Layer {
id,
name,
source,
label,
comment,
start_time,
in_point,
out_point,
stretch,
switches,
blend_mode,
preserve_transparency,
track_matte,
parent,
markers,
markers_locked,
auto_orient,
environment,
environment_background,
props,
} = self;
*id == o.id
&& *name == o.name
&& *source == o.source
&& *label == o.label
&& *comment == o.comment
&& *start_time == o.start_time
&& *in_point == o.in_point
&& *out_point == o.out_point
&& *stretch == o.stretch
&& switches.pixels() == o.switches.pixels()
&& *blend_mode == o.blend_mode
&& *preserve_transparency == o.preserve_transparency
&& *track_matte == o.track_matte
&& *parent == o.parent
&& *markers == o.markers
&& *markers_locked == o.markers_locked
&& *auto_orient == o.auto_orient
&& *environment == o.environment
&& *environment_background == o.environment_background
&& *props == o.props
}
}
impl Comp {
/// Draws the same pixels as `o`: equal but for its layers' Audio, Lock and Shy switches and
/// Hide Shy Layers.
pub fn same_pixels(&self, o: &Comp) -> bool {
// Every field (no `..`: a new field must be considered here); the layers last.
let Comp {
width,
height,
pixel_aspect,
frame_rate,
duration,
display_start,
background,
work_area,
layers,
markers,
shutter_angle,
shutter_phase,
motion_blur_samples,
motion_blur_adaptive_limit,
renderer,
hide_shy: _,
enable_motion_blur,
enable_frame_blending,
draft_3d,
preserve_frame_rate,
preserve_resolution,
poster_time,
global_light,
guides,
essential,
} = self;
*width == o.width
&& *height == o.height
&& *pixel_aspect == o.pixel_aspect
&& *frame_rate == o.frame_rate
&& *duration == o.duration
&& *display_start == o.display_start
&& *background == o.background
&& *work_area == o.work_area
&& *markers == o.markers
&& *shutter_angle == o.shutter_angle
&& *shutter_phase == o.shutter_phase
&& *motion_blur_samples == o.motion_blur_samples
&& *motion_blur_adaptive_limit == o.motion_blur_adaptive_limit
&& *renderer == o.renderer
&& *enable_motion_blur == o.enable_motion_blur
&& *enable_frame_blending == o.enable_frame_blending
&& *draft_3d == o.draft_3d
&& *preserve_frame_rate == o.preserve_frame_rate
&& *preserve_resolution == o.preserve_resolution
&& *poster_time == o.poster_time
&& *global_light == o.global_light
&& *guides == o.guides
&& *essential == o.essential
&& layers.len() == o.layers.len()
&& layers.iter().zip(&o.layers).all(|(a, b)| a.same_pixels(b))
}
/// The comp with those switches at their defaults (borrowed when they already are), for
/// keys that hash the whole comp.
pub fn pixel_form(&self) -> std::borrow::Cow<'_, Comp> {
if !self.hide_shy && self.layers.iter().all(|l| l.switches == l.switches.pixels()) {
return std::borrow::Cow::Borrowed(self);
}
let mut c = self.clone();
c.hide_shy = false;
for l in &mut c.layers {
l.switches = l.switches.pixels();
}
std::borrow::Cow::Owned(c)
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum MatteKind {
#[default]

View File

@ -558,7 +558,8 @@ fn key_any(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, blur: bool, fo
draft.hash(&mut h);
layer.id.hash(&mut h);
hash_debug(&mut h, &layer.source);
hash_debug(&mut h, &layer.switches);
// (Audio, Lock and Shy don't change pixels.)
hash_debug(&mut h, &layer.switches.pixels());
// Mask motion blur and effects that read the shutter depend on whether the layer is motion
// blurred in this render and on the comp's shutter.
if layer.masks().is_some_and(|m| !m.children.is_empty()) || layer.effects().is_some_and(|fx| !fx.children.is_empty()) {

View File

@ -780,7 +780,7 @@ pub fn footage_salt(project: &effectcraft_project::Project) -> u64 {
/// Content key of a composition's pixels: project render settings, the comp and everything it
/// uses (nested comps, footage, solids, their proxies and Use Proxy switches, footage and proxy
/// files' size and time). With expressions in play (which can read any comp) the whole project
/// counts.
/// counts. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) don't count.
pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraft_project::ItemId) -> u128 {
use effectcraft_project::{ItemKind, LayerSource};
let mut h = Hash128::default();
@ -810,7 +810,10 @@ pub fn comp_content_key(project: &effectcraft_project::Project, comp: effectcraf
let Some(it) = project.item(id) else { continue };
h.write_u64(id.0);
h.write(it.name.as_bytes());
hash_debug(&mut h, &it.kind);
match &it.kind {
ItemKind::Comp(c) => hash_debug(&mut h, &c.pixel_form()),
k => hash_debug(&mut h, k),
}
if let ItemKind::Footage(f) = &it.kind {
footage_stamp(&mut h, f);
}

View File

@ -5,7 +5,8 @@
//! *content* ([`comp_content`]: the comp and everything it uses), frame, scale, view and render
//! options; the timeline draws the cached range as the green cache bar. An edit keeps the frames
//! of every comp it doesn't touch, and undo finds the frames of the state it returns to, as in
//! After Effects. When the budget is full the least recently shown frames go first.
//! After Effects. Switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers) keep
//! the frames too. When the budget is full the least recently shown frames go first.
//!
//! wasm32 has no threads: jobs wait in the same priority queue and [`Frames::pump`] hands them
//! to a [`RemoteFrames`] (the browser's frame worker, a second engine instance fed with project
@ -25,7 +26,7 @@
use std::collections::{BTreeSet, HashMap, HashSet, VecDeque};
use std::sync::{Arc, Mutex};
use effectcraft_engine::project::{ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
use effectcraft_engine::project::{Comp, ItemId, ItemKind, LayerSource, Node, Project, PropGroup};
use effectcraft_engine::render::disk_cache::{self, DiskCache};
use effectcraft_engine::render::{ExprHost, FootageSource, LayerCache, RenderOpts, Renderer};
use effectcraft_engine::time::Tick;
@ -191,6 +192,10 @@ pub struct RenderSource {
pub disk: Option<Arc<DiskCache>>,
}
/// A [`comp_content`] identity and, when it was found in another project, that project
/// ([`Frames::identity`]).
type Identity = (u64, Option<Arc<Project>>);
/// Content keys of (project revision, comp), computed once per revision.
type ContentKeys = Arc<Mutex<HashMap<(u64, u64), u128>>>;
@ -214,6 +219,11 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64)
/// hands out), and by value the project settings and the footage, solids and proxies it uses.
/// With expressions in its comps (which can read anything) every item counts.
pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
comp_content_with(project, comp, |_, c| Arc::as_ptr(c))
}
/// [`comp_content`] with each comp's address given by `addr`.
fn comp_content_with(project: &Project, comp: ItemId, addr: impl Fn(ItemId, &Arc<Comp>) -> *const Comp) -> u64 {
use std::hash::{Hash, Hasher};
fn has_expression(g: &PropGroup) -> bool {
g.children.iter().any(|c| match c {
@ -247,7 +257,7 @@ pub fn comp_content(project: &Project, comp: ItemId) -> u64 {
it.name.hash(&mut h);
}
match &it.kind {
ItemKind::Comp(c) => (Arc::as_ptr(c) as usize).hash(&mut h),
ItemKind::Comp(c) => (addr(id, c) as usize).hash(&mut h),
k => format!("{k:?}").hash(&mut h),
}
format!("{:?}", it.proxy).hash(&mut h);
@ -342,8 +352,11 @@ pub struct Frames {
/// Render time of the last viewer (urgent) frame in ms, for the Info panel / perf readout.
pub last_ms: Arc<Mutex<f64>>,
content_keys: ContentKeys,
/// [`comp_content`] identities by (revision, comp).
identities: Mutex<HashMap<(u64, u64), u64>>,
/// [`comp_content`] identities by (revision, comp), with the project that keeps an identity
/// found by [`Frames::identity`] in another project.
identities: Mutex<HashMap<(u64, u64), Identity>>,
/// The last new identity of each comp and its project (see [`Frames::identity`]).
bases: Mutex<HashMap<u64, (u64, Arc<Project>)>>,
/// A project snapshot for each identity handed out, with when it was last asked for (see
/// [`Frames::content_of`]); the counter.
keepers: Mutex<(u64, HashMap<u64, (u64, Arc<Project>)>)>,
@ -373,6 +386,7 @@ impl Default for Frames {
last_ms: Arc::new(Mutex::new(0.0)),
content_keys: Arc::default(),
identities: Mutex::default(),
bases: Mutex::default(),
keepers: Mutex::default(),
remote: None,
remote_busy: Arc::default(),
@ -428,22 +442,22 @@ impl Frames {
/// revision). The project is kept while frames of that identity are cached or rendering, so
/// the comp addresses it hashes can't be reused by other comps meanwhile.
pub fn content_of(&self, project: &Arc<Project>, revision: u64, comp: ItemId) -> u64 {
let known = self.identities.lock().ok().and_then(|m| m.get(&(revision, comp.0)).copied());
let id = known.unwrap_or_else(|| {
let id = comp_content(project, comp);
let known = self.identities.lock().ok().and_then(|m| m.get(&(revision, comp.0)).cloned());
let (id, base) = known.unwrap_or_else(|| {
let found = self.identity(project, comp);
if let Ok(mut m) = self.identities.lock() {
if m.len() > 256 {
m.clear();
}
m.insert((revision, comp.0), id);
m.insert((revision, comp.0), found.clone());
}
id
found
});
if let Ok(mut guard) = self.keepers.lock() {
let (clock, k) = &mut *guard;
*clock += 1;
let now = *clock;
k.entry(id).or_insert_with(|| (now, project.clone())).0 = now;
k.entry(id).or_insert_with(|| (now, base.unwrap_or_else(|| project.clone()))).0 = now;
if k.len() > MAX_KEEPERS {
let inflight: HashSet<u64> = self.inflight.lock().map(|s| s.iter().map(|f| f.content).collect()).unwrap_or_default();
if let Ok(mut c) = self.cache.lock() {
@ -464,6 +478,30 @@ impl Frames {
id
}
/// The [`comp_content`] identity of `comp` in `project`, or the comp's last new identity when
/// every comp it draws is the same or draws the same pixels there ([`Comp::same_pixels`]:
/// only Audio, Lock or Shy switches changed), so toggling those keeps the cached frames
/// (#103). That identity hashes the addresses of the comps in its project, which is
/// returned to keep them.
fn identity(&self, project: &Arc<Project>, comp: ItemId) -> Identity {
let plain = comp_content(project, comp);
let Ok(mut bases) = self.bases.lock() else { return (plain, None) };
if let Some((base_id, base)) = bases.get(&comp.0)
&& *base_id != plain
&& comp_content_with(project, comp, |id, c| match base.item(id).map(|i| &i.kind) {
Some(ItemKind::Comp(b)) if Arc::ptr_eq(b, c) || b.same_pixels(c) => Arc::as_ptr(b),
_ => Arc::as_ptr(c),
}) == *base_id
{
return (*base_id, Some(base.clone()));
}
if bases.len() > MAX_KEEPERS {
bases.clear();
}
bases.insert(comp.0, (plain, project.clone()));
(plain, None)
}
pub fn is_cached(&self, k: &FrameKey) -> bool {
self.cache.lock().map(|c| c.map.contains_key(k)).unwrap_or(false)
}
@ -970,4 +1008,42 @@ mod tests {
comp.width = 16;
assert_ne!(f.content_of(&p, 2, a), before);
}
/// Audio, Lock and Shy (and Hide Shy Layers) don't change pixels: toggling them, in the comp
/// or a comp it nests, keeps its frames, also when toggled back (#103).
#[test]
fn audio_lock_and_shy_switches_keep_the_frames() {
use effectcraft_engine::Session;
use serde_json::json;
let mut s = Session::default();
let id = |v: serde_json::Value, k: &str| v[k].as_u64().unwrap();
let inner = ItemId(id(s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let solid = id(s.execute("layer.newSolid", json!({"comp": inner.0, "color": "#ff0000"})).unwrap(), "layer");
let outer = ItemId(id(s.execute("comp.new", json!({"name": "Outer", "width": 8, "height": 8, "duration": 1})).unwrap(), "comp"));
let nested = id(s.execute("layer.addItem", json!({"comp": outer.0, "item": inner.0})).unwrap(), "layer");
let f = Frames::default();
let at = |s: &Session| f.content_of(&s.project, s.revision, outer);
let o0 = at(&s);
let switch = |s: &mut Session, comp: ItemId, layer: u64, sw: &str, v: bool| {
s.execute("layer.setSwitch", json!({"comp": comp.0, "layers": [layer], "switch": sw, "value": v})).unwrap();
};
for (comp, layer, sw) in [(outer, nested, "audio"), (outer, nested, "lock"), (outer, nested, "shy"), (inner, solid, "audio"), (inner, solid, "shy")] {
// Audio off; Lock and Shy on.
switch(&mut s, comp, layer, sw, sw != "audio");
assert_eq!(at(&s), o0, "{sw} on in {comp:?}");
}
s.execute("comp.setSwitch", json!({"comp": outer.0, "switch": "hideShy", "value": true})).unwrap();
assert_eq!(at(&s), o0, "Hide Shy Layers");
switch(&mut s, outer, nested, "audio", true);
switch(&mut s, outer, nested, "lock", false);
assert_eq!(at(&s), o0, "toggled back");
// Switches that change pixels still do.
switch(&mut s, inner, solid, "video", false);
let o1 = at(&s);
assert_ne!(o1, o0, "Video");
switch(&mut s, outer, nested, "audio", false);
assert_eq!(at(&s), o1, "a mute after a real edit keeps that edit's frames");
switch(&mut s, inner, solid, "video", true);
assert_ne!(at(&s), o1);
}
}

View File

@ -135,6 +135,10 @@ pub struct Playback {
pub restore_max: Option<Option<PanelKind>>,
/// Audio-only previews: the frame under the audible sample (the current time stays put).
pub audio_frame: Option<i64>,
/// The preview's sound waits for frames: while the frames ahead aren't cached, every frame
/// shows as it renders, silently; the sound starts once the rest of the preview (or what
/// fits in the RAM preview) is cached, so it never plays over skipped frames (#103).
pub audio_held: bool,
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
pub shown_at: std::collections::VecDeque<f64>,
}
@ -684,31 +688,33 @@ impl EffectcraftApp {
layer_controls: preset.include_layer_controls,
restore_max,
audio_frame: (!plan.video).then_some(plan.first),
audio_held: false,
shown_at: Default::default(),
};
if !self.playback.caching {
self.start_audio(fr.tick_of(plan.first));
if plan.video {
self.playback.audio_held = self.audio_plays();
} else {
self.start_audio(fr.tick_of(plan.first));
}
}
}
/// Start audio preview from comp time `t` when the preview includes audio at the comp's
/// frame rate, the comp has something audible and an output device opens.
/// The preview includes audio at the comp's frame rate and the comp has something audible.
fn audio_plays(&self) -> bool {
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
let Some(c) = self.session.project.comp(cid) else { return false };
// A preview at another frame rate plays slower or faster than real time: silent.
pl.audio && (pl.fps - c.frame_rate.as_f64()).abs() <= 0.01 && effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid)
}
/// Start audio preview from comp time `t` when [`Self::audio_plays`] and an output device
/// opens.
fn start_audio(&mut self, t: Tick) {
self.audio = None;
self.meter.clipped = [false; 2];
let Some(pl) = self.playback.plan else { return };
if !pl.audio {
return;
}
let Some(cid) = self.session.active_comp_id() else { return };
let Some(c) = self.session.project.comp_arc(cid) else { return };
// A preview at another frame rate plays slower or faster than real time: silent.
if (pl.fps - c.frame_rate.as_f64()).abs() > 0.01 {
return;
}
if !effectcraft_engine::render::audio::comp_has_audio(&self.session.project, cid) {
return;
}
let (Some(pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return };
let Some(c) = self.session.project.comp_arc(cid).filter(|_| self.audio_plays()) else { return };
let out = audio::AudioOutput::from_prefs(&self.session.prefs);
let Some(dev) = self.hooks.audio_device.as_ref().and_then(|f| f(&out)) else { return };
let mix = self.session.prefs.audio.preview_sample_rate;
@ -935,8 +941,17 @@ impl EffectcraftApp {
return;
}
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
// Audio clock drives playback: show the frame under the audible sample (frames that
// are not rendered yet are dropped, never delayed).
// Held sound starts once the frames ahead are cached (see `Playback::audio_held`).
if self.playback.audio_held
&& let Some(p) = plan
&& self.cached_ahead(&p, &series, cur, self.frames_that_fit(&c, scale))
{
self.playback.audio_held = false;
self.playback.start_wall = now;
self.playback.start_frame = cur;
self.start_audio(fr.tick_of(cur));
}
// Audio clock drives playback: show the frame under the audible sample.
if let Some(a) = &self.audio {
if a.finished() {
if video {
@ -950,12 +965,20 @@ impl EffectcraftApp {
let target = snap(audio::frame_of_sample(a.clock(), a.rate, fr).clamp(wa, wb));
if !video {
self.playback.audio_frame = Some(target);
} else {
self.playback.waiting = !self.frames.is_cached(&FrameKey { frame: target, ..series });
} else if self.frames.is_cached(&FrameKey { frame: target, ..series }) {
self.playback.waiting = false;
if target != cur {
self.session.set_time(fr.tick_of(target));
self.playback.frame_shown(now);
}
} else {
// Rendering can't keep up: rather than skip frames, the sound stops and every
// frame shows as it renders until the frames ahead are cached again.
self.audio = None;
self.playback.audio_held = true;
self.playback.waiting = true;
self.playback.start_wall = now;
self.playback.start_frame = cur;
}
ctx.request_repaint();
return;
@ -1065,6 +1088,13 @@ impl EffectcraftApp {
/// How many viewer frames of `comp` at `scale` the RAM preview budget holds (90 % of it,
/// at least one).
/// The frames of `plan` from `cur` to its end, or as many as fit in the RAM preview, are
/// cached: sound can play from `cur` in real time.
fn cached_ahead(&self, plan: &effectcraft_engine::preview::PreviewPlan, series: &FrameKey, cur: i64, fit: i64) -> bool {
let cached = self.frames.cached_frames(series);
(0..fit).map_while(|k| plan.frame_after(cur, k)).take_while(|f| *f >= cur).all(|f| cached.binary_search(&f).is_ok())
}
fn frames_that_fit(&self, comp: &effectcraft_engine::project::Comp, scale: f64) -> i64 {
let side = |n: u32| (n as f64 * scale).ceil().max(1.0);
let frame = side(comp.width) * side(comp.height) * 4.0;

View File

@ -1,6 +1,6 @@
//! Audio scrubbing: Ctrl/Cmd-dragging the current time plays one frame of the comp's mix at each
//! new frame (each snippet replacing what is still queued); holding still repeats nothing; silent
//! comps open no output; an idle scrub closes it.
//! comps open no output; an idle scrub closes it. Previews with audio wait for frames.
use std::sync::{Arc, Mutex};
@ -107,3 +107,41 @@ 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.
#[test]
fn preview_with_audio_shows_every_frame_and_sounds_once_cached() {
let fake = Fake::default();
let (app, cid) = setup(true, &fake);
// A little under one frame of input time per step.
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 31.0).build_eframe(|_| app);
h.run_steps(2);
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.ctx.input(|i| i.time);
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;
for _ in 0..2000 {
h.step();
if h.state().audio.is_some() {
break;
}
let f = frame(&h);
assert!(f == last || f == last + 1 || f < last, "{last} → {f}: a frame was skipped");
shown += usize::from(f != last);
last = f;
std::thread::sleep(std::time::Duration::from_millis(1));
}
assert!(h.state().audio.is_some() && fake.0.lock().unwrap().0.is_some(), "the sound started once the frames ahead were cached");
assert!(shown > 0, "frames played silently first");
// A frame that isn't cached stops the sound rather than being skipped.
h.state().frames.clear();
h.step();
assert!(h.state().audio.is_none() && h.state().playback.audio_held && h.state().playback.playing);
}

View File

@ -163,7 +163,11 @@ keys, with a nesting-depth guard.
**Audio** is mixed by `render::audio::mix_comp` in blocks: Audio switch, solo, Audio Levels and
the Effect > Audio effects (`effects::audio_fx`, applied per layer with a pre-roll so blocks are
independent). Export and preview playback share it; the desktop app plays it through cpal and the
audio clock drives preview playback (`ui-egui::audio`).
audio clock drives preview playback (`ui-egui::audio`). The sound starts once the frames ahead
are cached; until then, and whenever playback reaches a frame that isn't cached, every frame
shows as it renders, silently, rather than being skipped. Layer and frame caches ignore the
switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers: `Switches::pixels`,
`Comp::same_pixels`), so toggling them keeps the cached frames.
**Layer styles** (Layer ▸ Layer Styles; `crates/render/src/styles.rs`) render in layer space and
may grow the layer's bounds. Drop Shadow and Outer Glow become separate passes composited below the