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:
commit
0fac647928
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
|
|
@ -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()) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue