Viewer: a layer drag moves the picture while the button is down

Every pointer move replaces the comp's content identity, so the frame on
screen takes a new cache key at every step while its render is still in
flight; the viewer only ever showed an exact key, so the picture froze on
the last completed frame until the drag ended. While a drag or a property
scrub is running, show the newest frame of the same comp, frame, scale,
view and render options that is ready — a render behind, as After Effects
does — and go back to exact frames when it stops.
This commit is contained in:
Aavirash
2026-10-10 12:13:55 +02:00
parent a2554c404c
commit 603337a061
3 changed files with 67 additions and 5 deletions
+19
View File
@@ -541,6 +541,25 @@ impl Frames {
Some(img)
}
/// The newest cached frame of `base`'s viewer series — its comp, frame, scale, view and render
/// options, whatever the comp content or revision: the last completed render. A drag (or a
/// property scrub) gives the comp a new content identity with every pointer move, so the
/// viewer falls back to this while the exact key's render is still in flight, and the picture
/// follows the pointer instead of freezing until it stops. `None` if the series has nothing
/// cached.
pub fn get_newest(&self, base: &FrameKey) -> Option<(FrameKey, FrameImage)> {
let mut c = self.cache.lock().ok()?;
let newest = c
.map
.keys()
.filter(|k| k.comp == base.comp && k.frame == base.frame && k.scale == base.scale && k.view == base.view && k.opts == base.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.
+11 -5
View File
@@ -668,16 +668,22 @@ 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) {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, k)| *k != key);
// The frame on screen. While a drag or a property scrub is rendering, the exact key changes
// with every edit and its render is still in flight; showing the newest frame that is ready
// keeps the picture following the pointer (a render behind), where waiting for the exact key
// would freeze it until the pointer stops.
let interacting = app.ui.viewer.interacting || app.ui.viewer.property_interacting;
let shown = app.frames.get(&key).map(|img| (key, img)).or_else(|| interacting.then(|| app.frames.get_newest(&key)).flatten());
if let Some((k, img)) = shown {
let stale = app.viewer_shown.as_ref().is_none_or(|(_, s)| *s != k);
// A CPU frame goes up again when the magnification calls for another factor.
let minify = minify_factor(app.session.prefs.viewer_zoom_smooth(), scale, (zoom * ppp) as f64);
let refit = !app.viewer_on_gpu() && app.viewer_tex.as_ref().is_some_and(|(_, _, m)| *m != minify);
if stale || refit {
show_frame(app, &ctx, key, img, minify);
} else if let Some((_, k)) = app.viewer_shown.as_mut() {
show_frame(app, &ctx, k, img, minify);
} else if let Some((_, s)) = app.viewer_shown.as_mut() {
// The same frame at a later revision (an edit elsewhere, an undo): it shows that one.
k.revision = key.revision;
s.revision = k.revision;
}
}
if app.ui.viewer.transparency_grid {
+37
View File
@@ -526,6 +526,43 @@ fn shape_drag_ghost_shows_where_the_shape_will_land() {
assert_eq!(comp.layer(plate).unwrap().props.sub("masks").unwrap().groups().count(), 1, "the drag drew a mask");
}
/// Dragging a layer moves its picture while the button is still down. Every pointer move replaces
/// the comp's content identity, so the exact frame on screen is re-rendered at every step: waiting
/// for it would freeze the viewer on the last completed frame for the whole drag.
#[test]
fn a_layer_drag_moves_the_picture_while_the_button_is_down() {
let mut h = harness();
let box_ = h.state().session.active_comp().unwrap().layers.iter().find(|l| l.name == "Box").unwrap().id;
h.state_mut().session.execute("layer.select", json!({"layers": [box_.0]})).unwrap();
settle_viewer(&mut h);
let from = screen(&h, [320.0, 180.0]);
let to = screen(&h, [520.0, 180.0]);
h.input_mut().events.push(Event::PointerMoved(from));
h.input_mut().events.push(Event::PointerButton { pos: from, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() });
h.step();
// Where the layer is not (yet): the plate behind it.
let plate = px_at(&h.render().unwrap(), &h, [440.0, 200.0]);
// Drag in small steps, still holding the button, and give the renders a chance to land.
let mut moved = false;
for i in 1..=40 {
let p = from + (to - from) * (i as f32 / 40.0);
h.input_mut().events.push(Event::PointerMoved(p));
h.step();
std::thread::sleep(std::time::Duration::from_millis(15));
// 30 comp px behind the pointer: inside the layer, well away from its outline, its anchor
// icon and where it started.
let at = px_at(&h.render().expect("mid-drag frame"), &h, [320.0 + 200.0 * (i as f32 / 40.0) - 30.0, 200.0]);
if i >= 30 && delta(at, plate) > 30 {
moved = true;
break;
}
}
assert!(moved, "the layer's pixels did not follow the pointer while the button was down");
h.input_mut().events.push(Event::PointerButton { pos: to, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() });
h.run_steps(2);
}
/// Clicking the word "Fill" opens Fill Options: a radial gradient in Multiply at 40% paints the
/// next shape drawn (#227).
#[test]