Merge pull request #888 from vangel76/fix-882-viewer-pixel-exact

#882: pixel-exact Composition viewer at 100 % and above

# Conflicts:
#	crates/ui-egui/src/menus.rs
This commit is contained in:
Brandon Thomas committed 2026-10-12 04:22:01 -04:00
commit 617412b555
5 files changed
+107 -19

No files matched your search

+32 -11
View File
@@ -776,15 +776,6 @@ fn toggle(slot: &mut bool, p: &Value) -> Value {
json!(*slot)
}
/// The next View ▸ Zoom In / Zoom Out magnification (points per pixel) after `cur`.
fn zoom_step(cur: f32, zoom_in: bool) -> f32 {
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
if zoom_in {
STEPS.iter().copied().find(|s| *s > cur + 1e-3).unwrap_or(32.0)
} else {
STEPS.iter().rev().copied().find(|s| *s < cur - 1e-3).unwrap_or(0.015)
}
}
/// Perform a frontend command (from `Event::Frontend`).
pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Value) -> Result<Value, String> {
@@ -914,13 +905,15 @@ pub fn frontend(app: &mut EffectcraftApp, ctx: &egui::Context, id: &str, p: Valu
// the Composition viewer.
"view.zoomIn" | "view.zoomOut" if app.ui.focused == PanelKind::Layer => {
let cur = app.ui.layer_zoom.unwrap_or_else(|| crate::panels::layer_panel::last_fit(ctx));
let next = zoom_step(cur, id == "view.zoomIn");
// The Layer panel zooms in points (pixels-per-point 1).
let next = zoom_step(cur, 1.0, id == "view.zoomIn");
app.ui.layer_zoom = Some(next);
json!({"zoom": next, "panel": "layer"})
}
"view.zoomIn" | "view.zoomOut" => {
let ppp = ctx.pixels_per_point();
let cur = v.zoom.unwrap_or_else(|| crate::panels::viewer::last_fit(ctx));
let next = zoom_step(cur, id == "view.zoomIn");
let next = zoom_step(cur, ppp, id == "view.zoomIn");
v.zoom = Some(next);
json!({"zoom": next})
}
@@ -2003,10 +1996,38 @@ fn menu_entry(app: &EffectcraftApp, ui: &mut egui::Ui, e: &MenuEntry) -> egui::R
ui.add_enabled(enabled, b)
}
/// View ▸ Zoom In / Zoom Out: the next standard magnification (as a share of screen pixels, so
/// 100 % is one comp pixel per screen pixel at any display scaling) above or below `cur`, which is
/// in points per comp pixel like the result (#882).
fn zoom_step(cur: f32, ppp: f32, zoom_in: bool) -> f32 {
const STEPS: [f32; 16] = [0.015, 0.03, 0.0625, 0.125, 0.25, 0.333, 0.5, 0.66, 1.0, 1.5, 2.0, 3.0, 4.0, 8.0, 16.0, 32.0];
let ppp = if ppp.is_finite() && ppp > 0.0 { ppp } else { 1.0 };
let shown = cur * ppp;
let next = if zoom_in {
STEPS.iter().copied().find(|s| *s > shown + 1e-3).unwrap_or(32.0)
} else {
STEPS.iter().rev().copied().find(|s| *s < shown - 1e-3).unwrap_or(0.015)
};
next / ppp
}
#[cfg(test)]
mod tests {
use super::*;
/// #882: at 125 % / 150 % display scaling Zoom In reaches 100 % (one comp pixel per screen
/// pixel), not 100 % in points (125 % / 150 % on screen).
#[test]
fn zoom_steps_land_on_whole_screen_magnifications() {
for ppp in [1.0f32, 1.25, 1.5, 2.0] {
let at_100 = zoom_step(0.7 / ppp, ppp, true);
assert!((at_100 * ppp - 1.0).abs() < 1e-6, "ppp {ppp}: {}", at_100 * ppp);
assert!((zoom_step(at_100, ppp, true) * ppp - 1.5).abs() < 1e-6);
assert!((zoom_step(at_100, ppp, false) * ppp - 0.66).abs() < 1e-6);
}
assert!(zoom_step(1.0, 0.0, true).is_finite());
}
#[test]
fn shortcut_passes_share_frame_start_modifiers() {
let ctx = egui::Context::default();
+48 -6
View File
@@ -798,7 +798,10 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
ctx.data_mut(|d| d.insert_temp(egui::Id::new("viewer-fit"), fit));
let zoom = app.ui.viewer.zoom.unwrap_or(fit);
let center = area.center() + vec2(app.ui.viewer.pan[0], app.ui.viewer.pan[1]);
let origin = center - vec2(cw * zoom / 2.0, ch * zoom / 2.0);
// On the physical pixel grid, so at 100 % every comp pixel covers exactly one screen pixel
// instead of being blended with its neighbour (#882).
let ppp = ctx.pixels_per_point();
let origin = snap_to_pixels(center - vec2(cw * zoom / 2.0, ch * zoom / 2.0), ppp);
let map = ViewerMap { origin, zoom, comp: [cw, ch], area };
ctx.data_mut(|d| d.insert_temp(map_id(), map));
let comp_rect = Rect::from_min_size(origin, vec2(cw * zoom, ch * zoom));
@@ -808,7 +811,6 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
app.ui.viewer.extended = extended_region(app, &comp, cid, map);
// Frame.
let ppp = ctx.pixels_per_point();
let scale = app.viewer_scale(zoom, ppp);
if !app.playback.playing {
// Queue the frame on screen before any prefetch.
@@ -2484,21 +2486,32 @@ pub(crate) fn styled_line(painter: &egui::Painter, pts: [Pos2; 2], stroke: Strok
}
}
/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest.
/// `p` (points) moved to the nearest physical pixel corner at `ppp` pixels per point.
pub(crate) fn snap_to_pixels(p: Pos2, ppp: f32) -> Pos2 {
if !(ppp.is_finite() && ppp > 0.0 && p.x.is_finite() && p.y.is_finite()) {
return p;
}
pos2((p.x * ppp).round() / ppp, (p.y * ppp).round() / ppp)
}
/// Texture filtering for Viewer Zoom Quality: More Accurate = bilinear, Faster = nearest. As in
/// After Effects, a magnified frame (above 100 %, or a lower Resolution) always shows its pixels
/// as blocks, never smoothed, in either mode (#882).
pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions {
if smooth { egui::TextureOptions::LINEAR } else { egui::TextureOptions::NEAREST }
if smooth { egui::TextureOptions { magnification: egui::TextureFilter::Nearest, ..egui::TextureOptions::LINEAR } } else { egui::TextureOptions::NEAREST }
}
/// The sampler GPU frames (mipmapped display textures) are drawn with: More Accurate filters
/// bilinearly within and between mip levels, so a frame shown below 100 % is averaged instead of
/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame.
/// skipping pixels (#417); Faster takes the nearest pixel of the full-size frame. Magnified, both
/// show the nearest pixel (#882).
pub(crate) fn zoom_sampler(smooth: bool) -> eframe::wgpu::SamplerDescriptor<'static> {
use eframe::wgpu::{FilterMode, MipmapFilterMode, SamplerDescriptor};
let label = Some("viewer frame");
if smooth {
SamplerDescriptor {
label,
mag_filter: FilterMode::Linear,
mag_filter: FilterMode::Nearest,
min_filter: FilterMode::Linear,
mipmap_filter: MipmapFilterMode::Linear,
..Default::default()
@@ -2596,6 +2609,35 @@ mod tests {
use super::*;
use effectcraft_engine::geom::{Mat4, vec3};
/// #882: the frame's corner sits on a physical pixel at any display scaling, so 100 % maps
/// comp pixels 1:1 onto screen pixels.
#[test]
fn frame_origin_snaps_to_physical_pixels() {
for ppp in [1.0f32, 1.25, 1.5, 1.75, 2.0] {
for p in [pos2(-201.12, 72.4), pos2(689.8, 239.75), pos2(0.3, -0.37)] {
let s = snap_to_pixels(p, ppp);
for v in [s.x * ppp, s.y * ppp] {
assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}: {p:?} → {s:?}");
}
assert!((s - p).length() <= 0.71 / ppp, "moved by at most half a pixel: {p:?} → {s:?}");
}
}
assert_eq!(snap_to_pixels(pos2(1.3, 2.0), 0.0), pos2(1.3, 2.0));
}
/// #882: a magnified frame shows its pixels as blocks in both Zoom Quality modes; More
/// Accurate only smooths when the frame is shown smaller.
#[test]
fn magnified_frames_are_never_smoothed() {
use eframe::wgpu::FilterMode;
for smooth in [true, false] {
assert_eq!(zoom_texture_options(smooth).magnification, egui::TextureFilter::Nearest);
assert_eq!(zoom_sampler(smooth).mag_filter, FilterMode::Nearest);
}
assert_eq!(zoom_texture_options(true).minification, egui::TextureFilter::Linear);
assert_eq!(zoom_sampler(true).min_filter, FilterMode::Linear);
}
/// A perspective-like homography: w = 100 + y (the plane crosses the camera at y = −100).
fn tilted() -> Mat3 {
Mat3([[100.0, 0.0, 0.0], [0.0, 100.0, 0.0], [0.0, 1.0, 100.0]])
+2 -1
View File
@@ -403,7 +403,8 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter:
_ => {
let img = crate::frames::to_color_image(&s.image);
let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) };
let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, egui::TextureOptions::LINEAR);
let opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth());
let tex = crate::frames::load_fitted(ctx, "viewer-snapshot", img, opts);
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
tex
}
+22
View File
@@ -2024,3 +2024,25 @@ fn vertex_tools_edit_motion_path_keyframes() {
click(&mut h, quarter);
assert_eq!(keys(&h).len(), 3);
}
/// #882: at 100 % (and 200 %) on a scaled display the frame's corner sits on a physical pixel, so
/// every comp pixel covers whole screen pixels instead of being blended with its neighbour.
#[test]
fn frame_sits_on_the_physical_pixel_grid() {
for ppp in [1.0f32, 1.25, 1.5] {
let mut s = Session::default();
s.execute("comp.new", json!({"name": "Grid", "width": 301, "height": 201, "duration": 1})).unwrap();
let mut h = Harness::builder().with_size(egui::vec2(1600.0, 1000.0)).with_pixels_per_point(ppp).build_eframe(move |_| EffectcraftApp::new(s));
h.run_steps(3);
for (mag, pan) in [(1.0f32, [0.3f32, 0.37]), (2.0, [-0.45, 0.2])] {
h.state_mut().ui.viewer.zoom = Some(mag / ppp);
h.state_mut().ui.viewer.pan = pan;
h.run_steps(2);
let c = rect(&h, "viewer.comp");
for v in [c.min.x * ppp, c.min.y * ppp] {
assert!((v - v.round()).abs() < 1e-3, "ppp {ppp}, {mag}×: corner at {v} physical px");
}
assert!((c.width() * ppp - 301.0 * mag).abs() < 1e-2, "ppp {ppp}, {mag}×: {} physical px wide", c.width() * ppp);
}
}
}
+3 -1
View File
@@ -96,7 +96,9 @@ Commands (CLI, MCP, control channel):
how much of a position motion path the viewer draws (all, none, N seconds or N keyframes
around the current time)
- `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp
- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear
- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = averaged
(bilinear, mipmapped) when the frame is shown below 100 %; magnified frames show their pixels as
blocks in both modes, and the frame sits on the physical pixel grid, so 100 % is exact
- `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management
- `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour,
Effect Controls / Properties the layer's