mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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:
commit
617412b555
5 files changed
+107
-19
No files matched your search
+32
-11
@@ -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();
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user