diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a157359f..866bf021 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -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; diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index e63d9c7f..8998236e 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -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); diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 57a3511f..cb314fed 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -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()); diff --git a/crates/ui-egui/tests/ui_live_preview.rs b/crates/ui-egui/tests/ui_live_preview.rs new file mode 100644 index 00000000..e72856bf --- /dev/null +++ b/crates/ui-egui/tests/ui_live_preview.rs @@ -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>); + +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() + ); +} diff --git a/docs/architecture.md b/docs/architecture.md index c8c8a4e5..053ed82e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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