mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
Merge pull request #32 from storytold/m4-preview
Previews (M4): RAM preview keys and eviction, Auto resolution while playing, Cache Before Playback that fits, Cache Frames When Idle, purges
This commit is contained in:
15 files changed
+388
-50
No files matched your search
@@ -569,6 +569,7 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let disk = matches!(what.as_str(), "all" | "memoryAndDisk" | "disk");
|
||||
if memory {
|
||||
s.layer_cache.clear();
|
||||
s.footage.purge();
|
||||
}
|
||||
let mut disk_entries = 0;
|
||||
if disk && let Some(dc) = &s.disk_cache {
|
||||
@@ -581,7 +582,11 @@ fn purge_caches(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if matches!(what.as_str(), "all" | "image" | "memory" | "memoryAndDisk") {
|
||||
effectcraft_effects::roto::purge();
|
||||
}
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
// The viewers' RAM preview goes with the memory purges (and the 3D one, whose renders it
|
||||
// holds); purging the disk cache or the snapshot leaves it.
|
||||
if memory || what == "3d" {
|
||||
s.events.push(crate::Event::PurgeCaches);
|
||||
}
|
||||
s.toast(format!("Purged {what} cache"));
|
||||
Ok(json!({"purged": what, "diskEntries": disk_entries}))
|
||||
}
|
||||
|
||||
@@ -263,9 +263,35 @@ fn label_group_purge_and_edit_original() {
|
||||
s.execute("layer.select", json!({"layers": [a]})).unwrap();
|
||||
s.execute("edit.selectLabelGroup", json!({})).unwrap();
|
||||
assert_eq!(s.state.selected_layers.len(), 2);
|
||||
// A footage source that counts purges (the media pool drops its decoded frames).
|
||||
struct Counting(std::sync::atomic::AtomicUsize);
|
||||
impl crate::render::FootageSource for Counting {
|
||||
fn frame(
|
||||
&self,
|
||||
_: effectcraft_project::ItemId,
|
||||
_: &effectcraft_project::Footage,
|
||||
_: effectcraft_time::Tick,
|
||||
) -> Option<std::sync::Arc<crate::render::Image>> {
|
||||
None
|
||||
}
|
||||
fn purge(&self) {
|
||||
self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
let src = std::sync::Arc::new(Counting(Default::default()));
|
||||
s.footage = src.clone();
|
||||
s.drain_events();
|
||||
s.execute("edit.purge", json!({"what": "memory"})).unwrap();
|
||||
assert!(s.drain_events().contains(&Event::PurgeCaches));
|
||||
assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1, "decoded footage frames are dropped");
|
||||
// The disk cache and the snapshot purge alone: the RAM preview stays.
|
||||
for what in ["disk", "snapshot"] {
|
||||
s.execute("edit.purge", json!({"what": what})).unwrap();
|
||||
assert!(!s.drain_events().contains(&Event::PurgeCaches), "{what}");
|
||||
}
|
||||
assert_eq!(src.0.load(std::sync::atomic::Ordering::SeqCst), 1);
|
||||
s.execute("edit.purge", json!({"what": "3d"})).unwrap();
|
||||
assert!(s.drain_events().contains(&Event::PurgeCaches));
|
||||
assert!(!s.is_enabled("edit.editOriginal"));
|
||||
}
|
||||
|
||||
|
||||
@@ -233,12 +233,16 @@ impl MediaPool {
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop every cached frame and decoder.
|
||||
pub fn clear(&self) {
|
||||
/// Drop every cached frame (decoders stay open).
|
||||
pub fn clear_frames(&self) {
|
||||
let mut c = lock(&self.inner.cache);
|
||||
c.lru = Lru::default();
|
||||
c.last.clear();
|
||||
drop(c);
|
||||
}
|
||||
|
||||
/// Drop every cached frame and decoder.
|
||||
pub fn clear(&self) {
|
||||
self.clear_frames();
|
||||
lock(&self.inner.sources).clear();
|
||||
}
|
||||
|
||||
@@ -602,6 +606,9 @@ impl FootageSource for MediaPool {
|
||||
fn set_cache_budget(&self, bytes: usize) {
|
||||
self.set_budget(bytes);
|
||||
}
|
||||
fn purge(&self) {
|
||||
self.clear_frames();
|
||||
}
|
||||
fn cache_budget(&self) -> Option<usize> {
|
||||
Some(self.budget())
|
||||
}
|
||||
|
||||
@@ -56,6 +56,9 @@ pub trait FootageSource: Send + Sync {
|
||||
/// Set the decoded-frame cache budget in bytes (Settings ▸ Memory & CPU); sources without
|
||||
/// a cache ignore it.
|
||||
fn set_cache_budget(&self, _bytes: usize) {}
|
||||
/// Drop the decoded frames it keeps (Edit ▸ Purge ▸ All Memory / Image Cache Memory): the
|
||||
/// next reads decode again. Sources without a cache ignore it.
|
||||
fn purge(&self) {}
|
||||
/// Settings ▸ Disk ▸ Conformed Audio Folder: where decoded audio is kept between reads
|
||||
/// (`None` = off); sources that don't decode audio ignore it.
|
||||
fn set_conform_folder(&self, _folder: Option<std::path::PathBuf>) {}
|
||||
|
||||
@@ -2,7 +2,10 @@
|
||||
//!
|
||||
//! Frames render on a dedicated thread pool from immutable `Arc<Project>` snapshots, so the UI
|
||||
//! never waits for the compositor. Results land in a memory-budgeted cache keyed by (project
|
||||
//! revision, comp, frame, scale); the timeline draws the cached range as the green cache bar.
|
||||
//! revision, comp, frame, scale, view, render options); the timeline draws the cached range as
|
||||
//! the green cache bar. When the budget is full the least recently shown frames go first, and
|
||||
//! frames of an older revision are dropped as soon as the project changes (they can never be
|
||||
//! shown again).
|
||||
//!
|
||||
//! 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
|
||||
@@ -63,6 +66,15 @@ pub struct FrameKey {
|
||||
pub scale: u32,
|
||||
/// Hash of the 3D view camera (0 = the comp's active camera).
|
||||
pub view: u64,
|
||||
/// Hash of the other render options (Draft / Fast Previews, shadows, nested switches…).
|
||||
pub opts: u64,
|
||||
}
|
||||
|
||||
impl FrameKey {
|
||||
/// Whether `self` is a frame of the same comp, revision, scale, view and options as `base`.
|
||||
pub fn same_series(&self, base: &FrameKey) -> bool {
|
||||
FrameKey { frame: base.frame, ..*self } == *base
|
||||
}
|
||||
}
|
||||
|
||||
struct Cache {
|
||||
@@ -109,6 +121,14 @@ impl Cache {
|
||||
self.forget(&i);
|
||||
}
|
||||
}
|
||||
/// Shown again: last in line for eviction.
|
||||
fn touch(&mut self, k: &FrameKey) {
|
||||
if let Some(i) = self.order.iter().rposition(|o| o == k)
|
||||
&& let Some(k) = self.order.remove(i)
|
||||
{
|
||||
self.order.push_back(k);
|
||||
}
|
||||
}
|
||||
fn evict_to_budget(&mut self) {
|
||||
while self.bytes > self.budget {
|
||||
let Some(old) = self.order.pop_front() else { break };
|
||||
@@ -153,7 +173,8 @@ fn content_key(keys: &ContentKeys, project: &Project, revision: u64, comp: u64)
|
||||
k
|
||||
}
|
||||
|
||||
fn opts_hash(o: &RenderOpts) -> u64 {
|
||||
/// Hash of render options (part of [`FrameKey`] and of the disk key).
|
||||
pub fn opts_hash(o: &RenderOpts) -> u64 {
|
||||
let mut h = disk_cache::Hash128::default();
|
||||
h.write(format!("{o:?}").as_bytes());
|
||||
h.finish() as u64
|
||||
@@ -288,8 +309,24 @@ impl Frames {
|
||||
self.ctx.clone()
|
||||
}
|
||||
|
||||
/// The cached frame, which becomes the most recently used.
|
||||
pub fn get(&self, k: &FrameKey) -> Option<FrameImage> {
|
||||
self.cache.lock().ok()?.map.get(k).cloned()
|
||||
let mut c = self.cache.lock().ok()?;
|
||||
let img = c.map.get(k).cloned()?;
|
||||
c.touch(k);
|
||||
Some(img)
|
||||
}
|
||||
|
||||
/// Drop frames of project revisions before `revision` (and their queued jobs): an edit
|
||||
/// makes them unreachable, so they only take memory from frames that can still be shown.
|
||||
pub fn drop_stale(&self, revision: u64) {
|
||||
if let Ok(mut c) = self.cache.lock() {
|
||||
let stale: Vec<FrameKey> = c.map.keys().filter(|k| k.revision < revision).copied().collect();
|
||||
for k in &stale {
|
||||
c.remove(k);
|
||||
}
|
||||
c.order.retain(|k| k.revision >= revision);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn is_cached(&self, k: &FrameKey) -> bool {
|
||||
@@ -300,17 +337,18 @@ impl Frames {
|
||||
self.inflight.lock().map(|s| s.len()).unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Cached frame numbers for (revision, comp, scale) — for the cache bar.
|
||||
pub fn cached_frames(&self, revision: u64, comp: u64, scale: u32) -> Vec<i64> {
|
||||
/// Cached frame numbers of the series of `base` (its comp, revision, scale, view and render
|
||||
/// options) — for the cache bar.
|
||||
pub fn cached_frames(&self, base: &FrameKey) -> Vec<i64> {
|
||||
let Ok(c) = self.cache.lock() else { return vec![] };
|
||||
let mut v: Vec<i64> = c.map.keys().filter(|k| k.revision == revision && k.comp == comp && k.scale == scale).map(|k| k.frame).collect();
|
||||
let mut v: Vec<i64> = c.map.keys().filter(|k| k.same_series(base)).map(|k| k.frame).collect();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
|
||||
/// Frames of (revision, comp, scale) in the disk cache but not in RAM — the blue cache bar.
|
||||
/// Frames of the series of `base` in the disk cache but not in RAM — the blue cache bar.
|
||||
/// `opts` are the viewer's render options (part of the disk key).
|
||||
pub fn disk_frames(&self, src: &RenderSource, revision: u64, comp: u64, scale: u32, view: u64, frames: i64, opts: &RenderOpts) -> Vec<i64> {
|
||||
pub fn disk_frames(&self, src: &RenderSource, base: &FrameKey, frames: i64, opts: &RenderOpts) -> Vec<i64> {
|
||||
let remote = self.remote.as_ref().filter(|r| r.disk());
|
||||
let contains = |k: u128| match (&src.disk, remote) {
|
||||
(Some(dc), _) => dc.contains(disk_cache::Kind::Frame, k),
|
||||
@@ -320,14 +358,11 @@ impl Frames {
|
||||
if src.disk.is_none() && remote.is_none() {
|
||||
return vec![];
|
||||
}
|
||||
let ram: HashSet<i64> = self.cached_frames(revision, comp, scale).into_iter().collect();
|
||||
let ram: HashSet<i64> = self.cached_frames(base).into_iter().collect();
|
||||
let oh = opts_hash(opts);
|
||||
(0..frames.clamp(0, 100_000))
|
||||
.filter(|f| !ram.contains(f))
|
||||
.filter(|f| {
|
||||
let key = FrameKey { revision, comp, frame: *f, scale, view };
|
||||
contains(frame_disk_key(&self.content_keys, &src.project, &key, oh))
|
||||
})
|
||||
.filter(|f| contains(frame_disk_key(&self.content_keys, &src.project, &FrameKey { frame: *f, ..*base }, oh)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
@@ -692,3 +727,49 @@ impl Queue {
|
||||
jobs.iter().enumerate().max_by_key(|(_, j)| (j.urgent, if j.urgent { j.seq as i64 } else { -(j.seq as i64) })).map(|(i, _)| i)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn key(revision: u64, frame: i64, opts: u64) -> FrameKey {
|
||||
FrameKey { revision, comp: 1, frame, scale: 1000, view: 0, opts }
|
||||
}
|
||||
|
||||
fn img() -> FrameImage {
|
||||
FrameImage::Cpu(Arc::new(egui::ColorImage::new([2, 2], vec![egui::Color32::BLACK; 4])))
|
||||
}
|
||||
|
||||
fn insert(f: &Frames, k: FrameKey) {
|
||||
f.cache.lock().unwrap().insert(k, img());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn least_recently_shown_frames_go_first() {
|
||||
let f = Frames::default();
|
||||
f.set_budget(3 * 16);
|
||||
for i in 0..3 {
|
||||
insert(&f, key(1, i, 0));
|
||||
}
|
||||
// Frame 0 is shown again, so frame 1 is now the oldest.
|
||||
assert!(f.get(&key(1, 0, 0)).is_some());
|
||||
insert(&f, key(1, 3, 0));
|
||||
assert!(f.is_cached(&key(1, 0, 0)) && !f.is_cached(&key(1, 1, 0)));
|
||||
assert!(f.is_cached(&key(1, 2, 0)) && f.is_cached(&key(1, 3, 0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stale_revisions_are_dropped_and_series_are_kept_apart() {
|
||||
let f = Frames::default();
|
||||
insert(&f, key(1, 0, 0));
|
||||
insert(&f, key(2, 0, 0));
|
||||
insert(&f, key(2, 1, 7));
|
||||
// Other render options (Draft, shadows…) are another series.
|
||||
assert_eq!(f.cached_frames(&key(2, 0, 0)), vec![0]);
|
||||
assert_eq!(f.cached_frames(&key(2, 0, 7)), vec![1]);
|
||||
f.drop_stale(2);
|
||||
assert!(!f.is_cached(&key(1, 0, 0)) && f.is_cached(&key(2, 0, 0)));
|
||||
let c = f.cache.lock().unwrap();
|
||||
assert_eq!((c.order.len(), c.bytes), (2, 2 * 16), "the memory is given back");
|
||||
}
|
||||
}
|
||||
+132
-20
@@ -127,6 +127,33 @@ 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>,
|
||||
/// When the last frames were shown (seconds, the last second's worth): the achieved rate.
|
||||
pub shown_at: std::collections::VecDeque<f64>,
|
||||
}
|
||||
|
||||
impl Playback {
|
||||
/// A new frame went on screen at `now`.
|
||||
pub fn frame_shown(&mut self, now: f64) {
|
||||
self.shown += 1;
|
||||
self.shown_at.push_back(now);
|
||||
while self.shown_at.front().is_some_and(|t| now - t > 1.0) {
|
||||
self.shown_at.pop_front();
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames per second shown over the last second while the preview plays (`None` before two
|
||||
/// frames were shown, while caching, or stopped).
|
||||
pub fn achieved_fps(&self) -> Option<f64> {
|
||||
let (a, b) = (self.shown_at.front()?, self.shown_at.back()?);
|
||||
(self.playing && !self.caching && self.shown_at.len() > 1 && b > a).then(|| (self.shown_at.len() - 1) as f64 / (b - a))
|
||||
}
|
||||
|
||||
/// The preview keeps up with its frame rate (within 5 %; previews that skip frames count
|
||||
/// the frames they mean to show).
|
||||
pub fn real_time(&self) -> Option<bool> {
|
||||
let step = self.plan.map_or(1, |p| p.step).max(1) as f64;
|
||||
self.achieved_fps().map(|f| f * step >= self.fps * 0.95)
|
||||
}
|
||||
}
|
||||
|
||||
pub struct EffectcraftApp {
|
||||
@@ -152,6 +179,9 @@ pub struct EffectcraftApp {
|
||||
pending_screenshots: Vec<(u64, Option<String>, Option<[f32; 4]>, Sender<serde_json::Value>, f64)>,
|
||||
next_token: u64,
|
||||
pub(crate) last_ui_time: f64,
|
||||
/// When the user last did something (input or an edit): Cache Frames When Idle waits for
|
||||
/// a second of quiet. (time, project revision then)
|
||||
last_activity: (f64, u64),
|
||||
pub(crate) toast: Option<(String, f64)>,
|
||||
/// Texture of the last CPU frame shown in the viewer and the key it came from.
|
||||
pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>,
|
||||
@@ -226,6 +256,7 @@ impl EffectcraftApp {
|
||||
pending_screenshots: vec![],
|
||||
next_token: 1,
|
||||
last_ui_time: 0.0,
|
||||
last_activity: (0.0, 0),
|
||||
toast: None,
|
||||
viewer_tex: None,
|
||||
viewer_native: None,
|
||||
@@ -462,12 +493,8 @@ impl EffectcraftApp {
|
||||
};
|
||||
return r.scale(zoom, ppp);
|
||||
}
|
||||
if self.playback.playing && self.ui.viewer.res == state::Resolution::Auto {
|
||||
// Keep previews snappy: half the displayed size, but no lower than Settings ▸
|
||||
// Previews ▸ Adaptive Resolution Limit.
|
||||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||||
return full.min((full * 0.5).max(self.session.prefs.adaptive_limit()));
|
||||
}
|
||||
// Auto follows the zoom alike while playing and paused, so frames cached while scrubbing
|
||||
// play back, and frames cached by a preview stay on screen when it stops.
|
||||
let full = self.ui.viewer.res.scale(zoom, ppp);
|
||||
// Fast Previews ▸ Adaptive Resolution / Fast Draft: lower resolution while dragging.
|
||||
let (_, k) = self.session.state.viewer.fast_previews.render(self.ui.viewer.interacting);
|
||||
@@ -477,8 +504,32 @@ impl EffectcraftApp {
|
||||
full
|
||||
}
|
||||
|
||||
/// The cache key of viewer frame `frame` of `comp` at `scale` (see [`Self::frame_series`]).
|
||||
pub fn frame_key(&self, comp: ItemId, frame: i64, scale: f64) -> FrameKey {
|
||||
FrameKey { revision: self.session.revision, comp: comp.0, frame, scale: (scale * 1000.0).round() as u32, view: self.view_hash(comp) }
|
||||
FrameKey { frame, ..self.frame_series(comp, scale) }
|
||||
}
|
||||
|
||||
/// The key of frame 0 of `comp`'s viewer frames at `scale` (the revision, view and render
|
||||
/// options are the same for every frame: loops compute this once).
|
||||
pub fn frame_series(&self, comp: ItemId, scale: f64) -> FrameKey {
|
||||
FrameKey {
|
||||
revision: self.session.revision,
|
||||
comp: comp.0,
|
||||
frame: 0,
|
||||
scale: (scale * 1000.0).round() as u32,
|
||||
view: self.view_hash(comp),
|
||||
opts: frames::opts_hash(&self.frame_opts(comp, scale)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame series the viewer of `comp` shows now (its scale, view and options, at the
|
||||
/// current revision): what the cache bars count.
|
||||
pub fn shown_series(&self, comp: ItemId) -> FrameKey {
|
||||
match self.viewer_shown.as_ref().map(|(_, k)| *k) {
|
||||
Some(k) if k.comp == comp.0 && k.revision == self.session.revision => FrameKey { frame: 0, ..k },
|
||||
Some(k) => self.frame_series(comp, k.scale as f64 / 1000.0),
|
||||
None => self.frame_series(comp, 1.0),
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash of the comp viewer's 3D view camera (0 for the active camera view).
|
||||
@@ -587,6 +638,7 @@ impl EffectcraftApp {
|
||||
layer_controls: preset.include_layer_controls,
|
||||
restore_max,
|
||||
audio_frame: (!plan.video).then_some(plan.first),
|
||||
shown_at: Default::default(),
|
||||
};
|
||||
if !self.playback.caching {
|
||||
self.start_audio(fr.tick_of(plan.first));
|
||||
@@ -666,9 +718,10 @@ impl EffectcraftApp {
|
||||
fn play_cached(&mut self, now: f64) -> bool {
|
||||
let (Some(mut pl), Some(cid)) = (self.playback.plan, self.session.active_comp_id()) else { return false };
|
||||
let scale = self.viewer_shown.as_ref().map(|(_, k)| k.scale as f64 / 1000.0).unwrap_or(1.0);
|
||||
let series = self.frame_series(cid, scale);
|
||||
let mut last = None;
|
||||
let mut f = pl.first;
|
||||
while f <= pl.end && self.frames.is_cached(&self.frame_key(cid, f, scale)) {
|
||||
while f <= pl.end && self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||||
last = Some(f);
|
||||
f += pl.step;
|
||||
}
|
||||
@@ -722,16 +775,27 @@ impl EffectcraftApp {
|
||||
(self.frames_parallelism() / 2).max(2) as i64
|
||||
};
|
||||
let mut queued = self.frames.inflight();
|
||||
let series = self.frame_series(cid, scale);
|
||||
if self.playback.caching
|
||||
&& let Some(p) = plan
|
||||
{
|
||||
// Cache Before Playback: the whole range in play order, then start the clock.
|
||||
// Cache Before Playback: the range in play order, then start the clock. A range
|
||||
// larger than the RAM preview budget is cut to the frames that fit (caching more
|
||||
// would evict the first ones and never finish).
|
||||
let mut all = true;
|
||||
let n = (p.end - p.start) / p.step + 1;
|
||||
let fit = self.frames_that_fit(&c, scale);
|
||||
if n > fit {
|
||||
let mut cut = p;
|
||||
(cut.start, cut.end) = (p.first, (p.first + (fit - 1) * p.step).min(p.end));
|
||||
self.playback.plan = Some(cut);
|
||||
self.toast = Some((format!("Preview cut to {fit} of {n} frames: no more fit in the RAM preview (Settings ▸ Memory & CPU)"), now));
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
let order: Vec<i64> = (0..n).filter_map(|k| p.frame_after(p.first, k)).collect();
|
||||
for f in order {
|
||||
let key = self.frame_key(cid, f, scale);
|
||||
if self.frames.is_cached(&key) {
|
||||
if self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||||
continue;
|
||||
}
|
||||
all = false;
|
||||
@@ -769,8 +833,7 @@ impl EffectcraftApp {
|
||||
f
|
||||
}
|
||||
};
|
||||
let key = self.frame_key(cid, f, scale);
|
||||
if !self.frames.is_cached(&key) {
|
||||
if !self.frames.is_cached(&FrameKey { frame: f, ..series }) {
|
||||
self.request_frame(cid, f, scale);
|
||||
queued += 1;
|
||||
}
|
||||
@@ -783,6 +846,7 @@ impl EffectcraftApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
if !self.playback.playing {
|
||||
self.cache_when_idle(ctx, &c, &series, now, (wa, wb));
|
||||
return;
|
||||
}
|
||||
let snap = |f: i64| wa + (f - wa).div_euclid(step) * step;
|
||||
@@ -802,10 +866,10 @@ impl EffectcraftApp {
|
||||
if !video {
|
||||
self.playback.audio_frame = Some(target);
|
||||
} else {
|
||||
self.playback.waiting = !self.frames.is_cached(&self.frame_key(cid, target, scale));
|
||||
self.playback.waiting = !self.frames.is_cached(&FrameKey { frame: target, ..series });
|
||||
if target != cur {
|
||||
self.session.set_time(fr.tick_of(target));
|
||||
self.playback.shown += 1;
|
||||
self.playback.frame_shown(now);
|
||||
}
|
||||
}
|
||||
ctx.request_repaint();
|
||||
@@ -841,18 +905,17 @@ impl EffectcraftApp {
|
||||
ctx.request_repaint();
|
||||
return;
|
||||
}
|
||||
let key = self.frame_key(cid, target, scale);
|
||||
if self.frames.is_cached(&key) {
|
||||
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.shown += 1;
|
||||
self.playback.frame_shown(now);
|
||||
}
|
||||
} else {
|
||||
// Not cached yet: hold the clock at the current frame (cache first, then play).
|
||||
self.playback.waiting = true;
|
||||
let next = plan.and_then(|p| p.frame_after(cur, 1)).unwrap_or(if cur + 1 > wb { wa } else { cur + 1 });
|
||||
if self.frames.is_cached(&self.frame_key(cid, next, scale)) {
|
||||
if self.frames.is_cached(&FrameKey { frame: next, ..series }) {
|
||||
self.session.set_time(fr.tick_of(next));
|
||||
}
|
||||
self.playback.start_wall = now;
|
||||
@@ -861,6 +924,54 @@ impl EffectcraftApp {
|
||||
ctx.request_repaint();
|
||||
}
|
||||
|
||||
/// Composition ▸ Preview ▸ Cache Frames When Idle: after a second without input or edits,
|
||||
/// render the work area's frames (from the current time on, then from its start) in the
|
||||
/// background, a few at a time, until it is cached or the RAM preview budget is full.
|
||||
fn cache_when_idle(&mut self, ctx: &egui::Context, comp: &effectcraft_engine::project::Comp, series: &FrameKey, now: f64, (wa, wb): (i64, i64)) {
|
||||
let active = ctx.input(|i| !i.events.is_empty() || i.pointer.any_down() || i.pointer.is_moving());
|
||||
if active || self.last_activity.1 != self.session.revision {
|
||||
self.last_activity = (now, self.session.revision);
|
||||
}
|
||||
if !self.ui.cache_when_idle || self.frames.urgent_pending() {
|
||||
return;
|
||||
}
|
||||
let quiet = now - self.last_activity.0;
|
||||
if quiet < 1.0 {
|
||||
ctx.request_repaint_after(std::time::Duration::from_secs_f64(1.0 - quiet));
|
||||
return;
|
||||
}
|
||||
let cur = comp.frame_rate.frame_at(self.session.time()).clamp(wa, wb);
|
||||
let n = wb - wa + 1;
|
||||
let fit = self.frames_that_fit(comp, series.scale as f64 / 1000.0).min(n);
|
||||
let slots = (self.frames_parallelism() / 2).max(1);
|
||||
let mut queued = self.frames.inflight();
|
||||
let scale = series.scale as f64 / 1000.0;
|
||||
let mut pending = false;
|
||||
for k in 0..fit {
|
||||
let f = wa + (cur - wa + k).rem_euclid(n);
|
||||
if self.frames.is_cached(&FrameKey { frame: f, ..*series }) {
|
||||
continue;
|
||||
}
|
||||
pending = true;
|
||||
if queued >= slots {
|
||||
break;
|
||||
}
|
||||
self.request_frame(ItemId(series.comp), f, scale);
|
||||
queued += 1;
|
||||
}
|
||||
if pending {
|
||||
ctx.request_repaint_after(std::time::Duration::from_millis(100));
|
||||
}
|
||||
}
|
||||
|
||||
/// How many viewer frames of `comp` at `scale` the RAM preview budget holds (90 % of it,
|
||||
/// at least one).
|
||||
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;
|
||||
((self.frames.budget() as f64 * 0.9 / frame) as i64).max(1)
|
||||
}
|
||||
|
||||
fn frames_parallelism(&self) -> usize {
|
||||
if cfg!(target_arch = "wasm32") {
|
||||
// frame workers render one frame each; without them frames render one at a time
|
||||
@@ -1011,7 +1122,8 @@ impl EffectcraftApp {
|
||||
}
|
||||
effectcraft_engine::Event::Toast { message, .. } => self.toast = Some((message, ctx.input(|i| i.time))),
|
||||
effectcraft_engine::Event::OpenUrl(url) => ctx.open_url(egui::OpenUrl::new_tab(url)),
|
||||
effectcraft_engine::Event::ProjectChanged { .. } => {}
|
||||
// Frames of earlier revisions can't be shown again.
|
||||
effectcraft_engine::Event::ProjectChanged { revision } => self.frames.drop_stale(revision),
|
||||
effectcraft_engine::Event::Frontend { command, params } => {
|
||||
if let Err(e) = crate::menus::frontend(self, ctx, &command, params) {
|
||||
self.ui.status = e;
|
||||
|
||||
@@ -87,6 +87,13 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
ly += 18.0;
|
||||
}
|
||||
}
|
||||
// While a preview plays: the frame rate it achieves and whether that is real time.
|
||||
if let (Some(fps), Some(rt)) = (app.playback.achieved_fps(), app.playback.real_time()) {
|
||||
let note = if rt { "real-time" } else { "not real-time" };
|
||||
p.text(pos2(x0, ly), Align2::LEFT_CENTER, format!("{fps:.2} fps ({note})"), Tokens::ui(11.5), if rt { t.text_dim } else { t.warning });
|
||||
app.auto.add("info.previewFps", Rect::from_min_size(pos2(x0, ly - 8.0), vec2(200.0, 16.0)), &format!("{fps:.2} fps ({note})"));
|
||||
ly += 18.0;
|
||||
}
|
||||
// Settings ▸ Composition ▸ Show Rendering Progress in Info Panel.
|
||||
if app.session.prefs.composition.show_rendering_progress {
|
||||
let ms = app.frames.last_ms.lock().map(|v| *v).unwrap_or(0.0);
|
||||
|
||||
@@ -215,14 +215,12 @@ pub fn preview(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
}
|
||||
|
||||
// Cache bar over the preview range.
|
||||
if let Some(c) = app.session.active_comp_arc() {
|
||||
let cid = app.session.active_comp_id().map(|i| i.0).unwrap_or(0);
|
||||
let scale = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000);
|
||||
if let (Some(c), Some(cid)) = (app.session.active_comp_arc(), app.session.active_comp_id()) {
|
||||
let pl = match app.playback.plan.filter(|_| app.playback.playing) {
|
||||
Some(pl) => pl,
|
||||
None => effectcraft_engine::preview::plan(&o, &c, app.session.time()),
|
||||
};
|
||||
let cached_set = app.frames.cached_frames(app.session.revision, cid, scale);
|
||||
let cached_set = app.frames.cached_frames(&app.shown_series(cid));
|
||||
let total = pl.frames().count();
|
||||
let cached = pl.frames().filter(|f| cached_set.binary_search(f).is_ok()).count();
|
||||
let bar = Rect::from_min_size(pos2(rect.min.x + 12.0, yy), vec2(rect.width() - 24.0, 6.0));
|
||||
|
||||
@@ -932,9 +932,9 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
let _ = app.session.execute("comp.workArea", json!({key: secs, "merge": "wa-drag"}));
|
||||
}
|
||||
}
|
||||
// Cache bar (green: cached frames at the viewer's resolution).
|
||||
let scale_key = app.viewer_shown.as_ref().map(|(_, k)| k.scale).unwrap_or(1000);
|
||||
let cached = app.frames.cached_frames(app.session.revision, cid.0, scale_key);
|
||||
// Cache bar (green: cached frames of what the viewer shows: its resolution, view and options).
|
||||
let series = app.shown_series(cid);
|
||||
let cached = app.frames.cached_frames(&series);
|
||||
let fd = comp.frame_duration().seconds();
|
||||
let cy0 = wa_row.max.y - 4.0;
|
||||
let mut run: Option<(i64, i64)> = None;
|
||||
@@ -959,8 +959,8 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
||||
// Disk-cached frames not in RAM (blue), like After Effects.
|
||||
if app.session.disk_cache.is_some() || app.frames.remote_disk() {
|
||||
let frames = comp.frame_rate.frame_at(comp.duration);
|
||||
let opts = app.frame_opts(cid, scale_key as f64 / 1000.0);
|
||||
let disk = app.frames.disk_frames(&app.render_source(), app.session.revision, cid.0, scale_key, app.view_hash(cid), frames, &opts);
|
||||
let opts = app.frame_opts(cid, series.scale as f64 / 1000.0);
|
||||
let disk = app.frames.disk_frames(&app.render_source(), &series, frames, &opts);
|
||||
let draw_blue = |a: i64, b: i64| {
|
||||
let x0 = tm.x(a as f64 * fd);
|
||||
let x1 = tm.x((b + 1) as f64 * fd);
|
||||
|
||||
@@ -184,3 +184,76 @@ fn align_panel_runs_align_and_distribute() {
|
||||
click(&mut h, "distribute.hcenter");
|
||||
assert_eq!(pos(&h), p);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_before_playback_plays_what_fits_in_ram() {
|
||||
let mut h = harness();
|
||||
set(&mut h, json!({"shortcut": "spacebar", "cacheBeforePlayback": true, "range": "entireDuration", "playFrom": "rangeStart"}));
|
||||
h.run_steps(2);
|
||||
// Room for about 12 full-size frames: the 240-frame range can't all be cached.
|
||||
h.state().frames.set_budget(320 * 180 * 4 * 12);
|
||||
invoke(&mut h, "playback.toggle", json!({}));
|
||||
assert!(h.state().playback.caching);
|
||||
for _ in 0..600 {
|
||||
h.step();
|
||||
if !h.state().playback.caching {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(5));
|
||||
}
|
||||
let st = &h.state().playback;
|
||||
assert!(st.playing && !st.caching, "caching finished and the preview plays");
|
||||
let pl = st.plan.unwrap();
|
||||
assert!(pl.start == 0 && pl.end < 239 && pl.end >= 9, "cut to what fits: {}..{}", pl.start, pl.end);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn auto_resolution_is_the_same_playing_and_paused() {
|
||||
let mut h = harness();
|
||||
let paused = h.state().viewer_scale(0.5, 2.0);
|
||||
h.state_mut().playback.playing = true;
|
||||
assert_eq!(h.state().viewer_scale(0.5, 2.0), paused);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn achieved_frame_rate_and_real_time() {
|
||||
let mut p = effectcraft_ui_egui::Playback { playing: true, fps: 24.0, ..Default::default() };
|
||||
assert_eq!(p.achieved_fps(), None);
|
||||
for i in 0..25 {
|
||||
p.frame_shown(i as f64 / 24.0);
|
||||
}
|
||||
assert!((p.achieved_fps().unwrap() - 24.0).abs() < 1e-9);
|
||||
assert_eq!(p.real_time(), Some(true));
|
||||
let mut slow = effectcraft_ui_egui::Playback { playing: true, fps: 24.0, ..Default::default() };
|
||||
for i in 0..13 {
|
||||
slow.frame_shown(i as f64 / 12.0);
|
||||
}
|
||||
assert_eq!(slow.real_time(), Some(false));
|
||||
assert_eq!(slow.shown_at.len(), 13, "only the last second counts");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cache_frames_when_idle_fills_the_work_area() {
|
||||
let mut h = harness();
|
||||
let cid = h.state().session.active_comp_id().unwrap();
|
||||
let cached = |h: &Harness<'_, EffectcraftApp>| h.state().frames.cached_frames(&h.state().shown_series(cid)).len();
|
||||
// Off: only a few frames ahead of the current time are prefetched (the work area is the
|
||||
// whole 240 frames).
|
||||
for _ in 0..120 {
|
||||
h.step();
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
let before = cached(&h);
|
||||
assert!(before < 240, "{before}");
|
||||
invoke(&mut h, "playback.cacheWhenIdle", json!({"value": true}));
|
||||
let mut n = 0;
|
||||
for _ in 0..4000 {
|
||||
h.step();
|
||||
n = cached(&h);
|
||||
if n >= 240 {
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_millis(2));
|
||||
}
|
||||
assert_eq!(n, 240, "the work area is cached while idle");
|
||||
}
|
||||
@@ -137,7 +137,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
|
||||
s.execute("prop.set", json!({"layer": "#1", "path": "effects/#1/blurriness", "value": 12})).unwrap();
|
||||
let opts = RenderOpts { backend: effectcraft_engine::render::Backend::Gpu, ..Default::default() };
|
||||
let t = effectcraft_engine::time::Tick::ZERO;
|
||||
let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0 };
|
||||
let key = |comp: effectcraft_engine::project::ItemId| FrameKey { revision: s.revision, comp: comp.0, frame: 0, scale: 1000, view: 0, opts: 0 };
|
||||
let disk = Arc::new(Mutex::new(HashMap::new()));
|
||||
let worker = Arc::new(GpuWorker::new(&s, worker_gpu.clone(), disk.clone()));
|
||||
let mut frames = Frames::default();
|
||||
@@ -162,7 +162,7 @@ fn gpu_worker_renders_effect_frames_and_the_disk_cache_survives_a_reload() {
|
||||
let mut frames2 = Frames::default();
|
||||
frames2.set_remote(Some(worker2.clone()));
|
||||
assert!(frames2.remote_disk());
|
||||
let bar = frames2.disk_frames(&source(&s, None), s.revision, fx.0, 1000, 0, 30, &opts);
|
||||
let bar = frames2.disk_frames(&source(&s, None), &key(fx), 30, &opts);
|
||||
assert_eq!(bar, vec![0], "the blue cache bar shows the stored frame");
|
||||
frames2.request_urgent(&source(&s, Some(page_gpu)), key(fx), fx, t, opts);
|
||||
frames2.dispatch_remote();
|
||||
|
||||
@@ -105,7 +105,7 @@ fn viewer_frames_render_remotely_from_project_diffs() {
|
||||
let comp = s.active_comp_id().unwrap();
|
||||
let c = s.project.comp(comp).unwrap().clone();
|
||||
let opts = RenderOpts { scale: 0.25, guides: true, ..Default::default() };
|
||||
let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0 };
|
||||
let key = |rev: u64, f: i64| FrameKey { revision: rev, comp: comp.0, frame: f, scale: 250, view: 0, opts: 0 };
|
||||
let worker = Arc::new(FakeWorker::new(&s, 2));
|
||||
let mut frames = Frames::default();
|
||||
frames.set_remote(Some(worker.clone()));
|
||||
|
||||
@@ -129,6 +129,13 @@ its evaluated inputs, excluding the transform. Static and transform-only layers
|
||||
editing one layer re-renders only that layer. Effects that read the clock directly are declared in
|
||||
`effects::TIME_DEPENDENT`, and a test checks every registered effect against that list.
|
||||
|
||||
The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by project
|
||||
revision, comp, frame, scale, 3D view / region of interest and a hash of the other render
|
||||
options (Draft / Fast Previews, shadows, nested switches), so frames rendered under other
|
||||
options are never shown. When the budget (Settings ▸ Memory & CPU) is full the least recently
|
||||
shown frames go first, and frames of an older revision are dropped as soon as the project
|
||||
changes. The timeline's green bar counts the frames of exactly what the viewer shows.
|
||||
|
||||
A persistent **disk cache** (`render::disk_cache`, Settings ▸ Media & Disk Cache) backs both
|
||||
the layer cache and the viewer's RAM preview: layer buffers that were slow to render and every
|
||||
preview frame are written (LZ4, atomic rename, checksummed) under 128-bit content keys — the
|
||||
|
||||
@@ -383,6 +383,23 @@ Browser in the web app (landed in M13.10: browser storage and File System Access
|
||||
wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script
|
||||
`Socket` validates ports, and ScriptUI resource strings accept trailing array commas.
|
||||
|
||||
## Update: M4.5–M4.8 cached playback
|
||||
|
||||
- **RAM preview keys** carry the render options (Fast Previews Draft / Fast Draft, Realtime
|
||||
Shadows), so a mode switch never shows frames rendered the other way; eviction drops the least
|
||||
recently shown frame; frames of an earlier revision are freed as soon as the project changes;
|
||||
the green bars count exactly what the viewer shows (scale, 3D view, region of interest).
|
||||
- **Playback**: viewer Resolution Auto keeps its scale while playing (it halved it, so cached
|
||||
frames weren't reused); Cache Before Playback cuts a range larger than the RAM preview budget
|
||||
to the frames that fit and plays them (it waited forever); the Info panel shows the achieved
|
||||
frame rate and whether it is real time.
|
||||
- **Cache Frames When Idle** renders the work area in the background after a second of quiet.
|
||||
- **Edit ▸ Purge**: disk and snapshot purges keep the RAM preview; memory purges also drop
|
||||
decoded footage frames.
|
||||
|
||||
Not yet: content-keyed RAM frames (an edit drops every comp's RAM frames; the disk cache keeps
|
||||
content keys), audio scrubbing, four snapshot slots.
|
||||
|
||||
## Update: M3.14 project files
|
||||
|
||||
Project files name the version that wrote them (`savedBy`); opening one that a newer EffectCraft
|
||||
|
||||
+3
-1
@@ -47,7 +47,9 @@ Commands (CLI, MCP, control channel):
|
||||
- `autoSave.saveOnRenderStart`: Save When Starting Render Queue
|
||||
- `composition.showRenderingProgress`: Show Rendering Progress in Info Panel and Flowchart
|
||||
- `previews.adaptiveResolutionLimit`: Adaptive Resolution Limit
|
||||
- `previews.cacheFramesWhenIdle`: Cache Frames When Idle
|
||||
- `previews.cacheFramesWhenIdle`: Cache Frames When Idle (Composition ▸ Preview): after a second
|
||||
without input or edits, the viewer renders the work area into the RAM preview in the
|
||||
background, from the current time on, until it is cached or the budget is full
|
||||
- `previews.fastPreviews`: Fast Previews (Draft 3D, Faster Effects)
|
||||
- `appearance.theme`: Theme
|
||||
- `appearance.brightness`: Brightness
|
||||
|
||||
Reference in new issue
Block a user