UI: keep live previews updating during rapid edits

This commit is contained in:
cdinhoo committed 2026-10-09 19:06:17 +03:00
1 parent f08d090cd8
commit e445877494
5 files changed
+262 -11

No files matched your search

+86 -2
View File
@@ -521,6 +521,42 @@ impl Frames {
Some(img)
}
/// Prefer the exact requested frame. While an edit renders, advance to the newest completed
/// revision of the same comp, time, scale, view and render options instead of freezing on the
/// image from before the drag. Input may arrive before the viewer draws on every UI frame,
/// so even a fast worker cannot always finish the *latest* revision in time.
///
/// Never move backwards when workers finish out of order, and never substitute another time
/// or view while scrubbing or switching comps. The exact content lookup above also means an
/// undo can immediately reuse an old cache entry, regardless of its original revision.
pub fn get_for_viewer(&self, wanted: &FrameKey, shown: Option<&FrameKey>) -> Option<(FrameKey, FrameImage)> {
if let Some(img) = self.get(wanted) {
return Some((*wanted, img));
}
let shown = shown?;
if shown.comp != wanted.comp || shown.frame != wanted.frame || shown.view != wanted.view || shown.revision >= wanted.revision {
return None;
}
let mut c = self.cache.lock().ok()?;
let newest = c
.map
.keys()
.filter(|k| {
k.revision > shown.revision
&& k.revision < wanted.revision
&& k.comp == wanted.comp
&& k.frame == wanted.frame
&& k.scale == wanted.scale
&& k.view == wanted.view
&& k.opts == wanted.opts
})
.max_by_key(|k| k.revision)
.copied()?;
let img = c.map.get(&newest).cloned()?;
c.touch(&newest);
Some((newest, img))
}
/// The [`comp_content`] identity of `comp` in `project` at `revision` (taken once per
/// 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.
@@ -678,8 +714,9 @@ impl Frames {
// Only the newest viewer frame is urgent. The comp's earlier viewer frames that
// haven't started are dropped (scrubbing: rendering every frame the pointer passed
// would hold the workers the frame under it needs); frames already rendering finish
// into the cache, and the viewer shows its last frame until the new one is in
// (#284). Other comps' viewer frames become prefetch.
// into the cache. During edits the viewer advances through compatible completed
// revisions; scrubbing holds its last frame until the requested time is in (#284).
// Other comps' viewer frames become prefetch.
q.jobs.retain(|j| {
let superseded = j.urgent && j.key != key && j.key.comp == key.comp;
if superseded {
@@ -1247,6 +1284,53 @@ mod tests {
assert!(!f.is_cached(&key(3, 0, 0)));
}
#[test]
fn live_edits_present_the_newest_completed_revision_without_going_backwards() {
let f = Frames::default();
let shown = key(1, 0, 0);
let newest = key(3, 0, 0);
let wanted = key(5, 0, 0);
insert(&f, shown);
insert(&f, newest);
insert(&f, key(2, 0, 0)); // Older worker finishes after the newer one.
let displayed = f.get_for_viewer(&wanted, Some(&shown)).unwrap().0;
assert_eq!(displayed, newest);
assert!(f.get_for_viewer(&wanted, Some(&displayed)).is_none(), "never replay an older completion");
insert(&f, wanted);
assert_eq!(f.get_for_viewer(&wanted, Some(&displayed)).unwrap().0, wanted, "settle on the exact final edit");
// Undo reuses the content of an earlier revision immediately.
let undo = FrameKey { revision: 6, ..shown };
let reused = f.get_for_viewer(&undo, Some(&wanted)).unwrap().0;
assert_eq!(reused.content, shown.content);
assert_eq!(reused.revision, undo.revision);
}
#[test]
fn live_edits_do_not_substitute_other_times_views_or_render_settings() {
let f = Frames::default();
let shown = key(1, 0, 0);
let wanted = key(3, 0, 0);
let done = key(2, 0, 0);
for mismatch in [
FrameKey { comp: 9, ..done },
FrameKey { frame: 1, ..done },
FrameKey { scale: 500, ..done },
FrameKey { view: 1, ..done },
FrameKey { opts: 1, ..done },
FrameKey { revision: 4, ..done },
] {
f.clear();
insert(&f, mismatch);
assert!(f.get_for_viewer(&wanted, Some(&shown)).is_none(), "wrong candidate: {mismatch:?}");
}
f.clear();
insert(&f, done);
assert!(f.get_for_viewer(&wanted, None).is_none(), "no stale content on opening a viewer");
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { comp: 9, ..shown })).is_none());
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { frame: 1, ..shown })).is_none());
assert!(f.get_for_viewer(&wanted, Some(&FrameKey { view: 1, ..shown })).is_none());
}
#[test]
fn an_edit_keeps_the_frames_of_comps_it_does_not_touch() {
use effectcraft_engine::Session;
+1 -1
View File
@@ -641,7 +641,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let frame = comp.frame_rate.frame_at(time);
let key = app.frame_key(cid, frame, scale);
app.request_frame_urgent(cid, frame, scale);
if let Some(img) = app.frames.get(&key) {
if let Some((key, img)) = app.frames.get_for_viewer(&key, app.viewer_shown.as_ref().map(|(_, k)| k)) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key);
if stale {
show_frame(app, &ctx, key, img);
+7 -2
View File
@@ -101,7 +101,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
let frame = c.frame_rate.frame_at(app.session.time_of(cid));
app.request_frame(cid, frame, scale);
let key = app.frame_key(cid, frame, scale);
if let Some(img) = app.frames.get(&key) {
if let Some((key, img)) = app.frames.get_for_viewer(&key, app.passive_tex.get(&id).map(|(k, _)| k)) {
set_texture(app, &ctx, id, key, img);
}
match app.passive_tex.get(&id).filter(|(k, _)| k.comp == cid.0) {
@@ -127,7 +127,12 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, id: u32, rect: Rect) {
/// Keep the frame viewer `id` shows in its texture.
fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: FrameKey, img: FrameImage) {
if app.passive_tex.get(&id).is_some_and(|(k, _)| *k == key) {
if let Some((k, _)) = app.passive_tex.get_mut(&id)
&& *k == key
{
// An undo or an edit elsewhere can reuse this content at a newer revision. Keep that
// revision as the lower bound for subsequent intermediate edit frames.
k.revision = key.revision;
return;
}
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
+158
View File
@@ -0,0 +1,158 @@
//! Completed edits must reach the viewer even when a newer edit is still rendering.
use std::sync::{Arc, Mutex};
use effectcraft_engine::{
Session,
project::LayerId,
render::{NoFootage, Renderer},
};
use effectcraft_ui_egui::{
EffectcraftApp,
frames::{RemoteDone, RemoteFrame, RemoteFrames, RemoteJob, to_color_image},
};
use egui_kittest::Harness;
use serde_json::json;
/// Hold completions explicitly: input/render ordering is deterministic, without timing sleeps.
#[derive(Default)]
struct HeldRenderer(Mutex<Vec<(RemoteJob, RemoteDone)>>);
impl RemoteFrames for HeldRenderer {
fn slots(&self) -> usize {
1
}
fn start(&self, job: RemoteJob, done: RemoteDone) {
self.0.lock().unwrap().push((job, done));
}
}
impl HeldRenderer {
fn finish(&self) -> egui::ColorImage {
let (job, done) = self.0.lock().unwrap().remove(0);
let image = Renderer::new(&job.project, &NoFootage, job.opts).comp_frame(job.comp, job.t);
let pixels = to_color_image(&image);
done(Ok(RemoteFrame { width: image.width, height: image.height, rgba: pixels.pixels.iter().flat_map(|c| c.to_array()).collect(), ms: 20.0 }));
pixels
}
}
#[test]
fn opacity_edits_show_completed_intermediate_frames_before_the_drag_stops() {
let mut session = Session::default();
session.execute("comp.new", json!({"name":"Live opacity", "width":64, "height":36, "duration":1})).unwrap();
let layer = session.execute("layer.newSolid", json!({"color":"#ffffff"})).unwrap()["layer"].as_u64().unwrap();
let renderer = Arc::new(HeldRenderer::default());
let mut app = EffectcraftApp::new(session);
app.frames.set_remote(Some(renderer.clone()));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.016).build_eframe(|_| app);
h.run_steps(3);
assert_eq!(h.state().frames.dispatch_remote(), 1);
renderer.finish();
h.step();
let alpha = |h: &mut Harness<'_, EffectcraftApp>| h.state_mut().viewer_pixels().unwrap().pixels[32 + 18 * 64].a();
assert_eq!(alpha(&mut h), 255);
for opacity in [80, 60, 40, 20] {
h.state_mut().session.execute("prop.set", json!({"layer":layer, "path":"transform/opacity", "value":opacity, "merge":"scrub"})).unwrap();
h.step();
assert_eq!(h.state().frames.dispatch_remote(), 1);
// Another input arrives before that frame completes, as in a fast slider drag.
h.state_mut().session.execute("prop.set", json!({"layer":layer, "path":"transform/opacity", "value":opacity - 1, "merge":"scrub"})).unwrap();
renderer.finish();
h.step();
assert_eq!(alpha(&mut h), (opacity as f32 * 2.55).round() as u8, "completed {opacity}% opacity must display while the next edit renders");
}
// Releasing the slider still converges to the exact final state.
assert_eq!(h.state().frames.dispatch_remote(), 1);
renderer.finish();
h.step();
assert_eq!(alpha(&mut h), (19.0_f32 * 2.55).round() as u8);
let series = h.state().shown_series(h.state().session.active_comp_id().unwrap());
assert!(h.state().frames.is_cached(&series));
}
#[test]
fn position_anchor_and_effect_edits_present_intermediate_pixels() {
for (effect, path, first, second) in [
(None, "transform/position", json!([22, 18, 0]), json!([18, 18, 0])),
(None, "transform/anchor", json!([9, 8, 0]), json!([6, 8, 0])),
(Some("Gaussian Blur"), "blurriness", json!(3), json!(6)),
(Some("CC Radial Fast Blur"), "center", json!([4, 4]), json!([18, 12])),
] {
let mut session = Session::default();
session.execute("comp.new", json!({"width":64,"height":36,"duration":1})).unwrap();
let layer = session.execute("layer.newSolid", json!({"color":"#ffffff","width":24,"height":16})).unwrap()["layer"].as_u64().unwrap();
if effect == Some("CC Radial Fast Blur") {
// A uniform solid is invariant under a radial blur; give the point edit visible detail.
session.execute("effect.apply", json!({"layers":[layer],"effect":"Gradient Ramp"})).unwrap();
}
let property = if let Some(effect) = effect {
session.execute("effect.apply", json!({"layers":[layer],"effect":effect})).unwrap();
session.active_comp().unwrap().layer(LayerId(layer)).unwrap().effects().unwrap().groups().last().unwrap().get(path).unwrap().uid
} else {
session.active_comp().unwrap().layer(LayerId(layer)).unwrap().props.prop(path).unwrap().uid
};
let renderer = Arc::new(HeldRenderer::default());
let mut app = EffectcraftApp::new(session);
app.frames.set_remote(Some(renderer.clone()));
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(0.016).build_eframe(|_| app);
h.run_steps(3);
h.state().frames.dispatch_remote();
let initial = renderer.finish();
h.step();
h.state_mut().session.execute("prop.set", json!({"layer":layer,"prop":property,"value":first,"merge":"drag"})).unwrap();
h.step();
assert_eq!(h.state().frames.dispatch_remote(), 1);
h.state_mut().session.execute("prop.set", json!({"layer":layer,"prop":property,"value":second,"merge":"drag"})).unwrap();
let intermediate = renderer.finish();
assert!(initial.pixels != intermediate.pixels, "{effect:?} {path}: fixture must change rendered pixels");
h.step();
assert_eq!(h.state_mut().viewer_pixels().unwrap().pixels, intermediate.pixels, "{effect:?} {path}: display finished edit while next edit renders");
assert_eq!(h.state().frames.dispatch_remote(), 1);
let final_image = renderer.finish();
h.step();
assert_eq!(h.state_mut().viewer_pixels().unwrap().pixels, final_image.pixels, "{effect:?} {path}: converge to exact final edit");
}
}
/// Manual throughput probe: no timing threshold in CI. Includes egui layout and native workers,
/// but no window compositor or GPU upload; it measures presentation progress during edits.
#[test]
#[ignore = "manual native-worker preview throughput probe"]
fn continuous_opacity_preview_probe() {
use std::time::{Duration, Instant};
let mut session = Session::default();
session.execute("comp.new", json!({"width":1920,"height":1080,"duration":1})).unwrap();
let layer = session.execute("layer.newSolid", json!({"color":"#ffffff"})).unwrap()["layer"].as_u64().unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).with_step_dt(1.0 / 60.0).build_eframe(|_| EffectcraftApp::new(session));
let ready = Instant::now();
while h.state_mut().viewer_pixels().is_none() && ready.elapsed() < Duration::from_secs(5) {
h.step();
std::thread::sleep(Duration::from_millis(5));
}
let mut last = h.state_mut().viewer_pixels().expect("initial viewer frame");
let start = Instant::now();
let mut updates = 0;
let mut longest_hold = Duration::ZERO;
let mut last_update = start;
for i in 0..120 {
h.state_mut().session.execute("prop.set", json!({"layer":layer,"path":"transform/opacity","value":99-i%90,"merge":"scrub"})).unwrap();
h.step();
let now = h.state_mut().viewer_pixels().unwrap();
if !Arc::ptr_eq(&now, &last) {
updates += 1;
longest_hold = longest_hold.max(last_update.elapsed());
last_update = Instant::now();
last = now;
}
let deadline = start + Duration::from_secs_f64((i + 1) as f64 / 60.0);
std::thread::sleep(deadline.saturating_duration_since(Instant::now()));
}
longest_hold = longest_hold.max(last_update.elapsed());
println!(
"LIVE_PREVIEW_PROBE updates={updates}/120 elapsed_ms={:.1} longest_hold_ms={:.1} last_render_ms={:.3}",
start.elapsed().as_secs_f64() * 1000.0,
longest_hold.as_secs_f64() * 1000.0,
*h.state().frames.last_ms.lock().unwrap()
);
}
+10 -6
View File
@@ -164,12 +164,16 @@ 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.
The viewer's **RAM preview** (`ui-egui/src/frames.rs`) keeps finished frames keyed by comp
content, frame, scale, 3D view / region of interest and a hash of the other render options
(Draft / Fast Previews, shadows, nested switches). Edits preserve unaffected comps' frames,
and undo can reuse earlier content. During rapid edits at the same time and view, the viewer
shows the newest completed revision while the exact requested revision renders; out-of-order
completions cannot move it backwards. This avoids freezing until a slider or layer drag stops.
Frames at other times or with other render options are never substituted. Obsolete queued jobs
are dropped; running jobs finish into the cache. When the budget (Settings ▸ Memory & CPU) is
full the least recently shown frames go first. The timeline's green bar counts cached frames
of the current content and viewer settings.
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