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:
BFlat authored and GitHub committed 2026-10-05 01:13:37 +07:00
commit 4e98b8c4bd
15 files changed
+388 -50

No files matched your search

+6 -1
View File
@@ -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}))
}
+26
View File
@@ -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"));
}
+10 -3
View File
@@ -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())
}
+3
View File
@@ -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>) {}
+94 -13
View File
@@ -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
View File
@@ -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;
+7
View File
@@ -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);
+2 -4
View File
@@ -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));
+5 -5
View File
@@ -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);
+73
View File
@@ -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");
}
+2 -2
View File
@@ -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();
+1 -1
View File
@@ -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()));
+7
View File
@@ -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
+17
View File
@@ -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
View File
@@ -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