mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
UI: keep live previews updating during rapid edits
This commit is contained in:
1 parent
f08d090cd8
commit
e445877494
5 files changed
+262
-11
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user