From 88e2e6470a8b8784eff7206786194194af62e1ef Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:51:50 +0700 Subject: [PATCH 1/4] Text: touching glyphs join without a seam (#415) Cause: each glyph's fill was rasterised and composited on its own, so where two glyphs share an edge (the crossbars of "ff") two half-covered pixels composited to 0.75 coverage instead of 1: a thin light seam, where After Effects draws the line as one outline. Fix: consecutive fill passes of the same opaque colour, without Blur, under Normal Inter-Character Blending are rasterised as one coverage mask (the glyphs' outlines in layer space, wound the same way so overlaps add up under NonZero). Characters of other colours, translucent or blurred ones, strokes and the other blending modes are drawn one by one as before. Tests: text::tests::touching_glyphs_join_without_a_seam (two glyphs sharing an edge mid-pixel: 0.75 before, 1.0 now; opposite windings and overlaps stay filled; other colours and Multiply still composite one by one). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/text.rs | 70 ++++++++++++++++++++++++++------- crates/render/src/text/tests.rs | 41 +++++++++++++++++++ docs/parity.md | 8 ++++ 3 files changed, 104 insertions(+), 15 deletions(-) diff --git a/crates/render/src/text.rs b/crates/render/src/text.rs index 9fda69805..7d7f7615b 100644 --- a/crates/render/src/text.rs +++ b/crates/render/src/text.rs @@ -4,6 +4,8 @@ //! inter-character blending). Per-character 3D characters are handed to the 3D compositor as //! separate planes (see [`per_char_planes`]). +use std::borrow::Cow; + use effectcraft_color::BlendMode; use effectcraft_effects::Buf; use effectcraft_geom::{Mat3, Mat4, Vec3, vec2, vec3}; @@ -790,16 +792,17 @@ fn passes(geom: &TextGeom) -> Vec { v } -/// Rasterize one pass of a glyph (path already in target pixels via `m`) into a premultiplied -/// patch over the target rect, blurred when the character has Blur. -fn raster_pass(g: &PlacedGlyph, stroke: bool, path: &BezPath, m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image { +/// Rasterize one pass of a glyph — or of a run of glyphs painted alike, as one outline — (paths +/// mapped into target pixels by `m`) into a premultiplied patch over the target rect, blurred +/// when the character has Blur. +fn raster_pass(g: &PlacedGlyph, stroke: bool, paths: &[BezPath], m: &Mat3, rect: [i64; 4], blur_px: [f64; 2]) -> Image { let (w, h) = ((rect[2] - rect[0]) as u32, (rect[3] - rect[1]) as u32); let mm = Mat3::translate(vec2(-rect[0] as f64, -rect[1] as f64)) * *m; let cov = if stroke { let st = StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() }; - effectcraft_path::stroke_coverage(std::slice::from_ref(path), &st, &mm, w, h) + effectcraft_path::stroke_coverage(paths, &st, &mm, w, h) } else { - effectcraft_path::fill_coverage(std::slice::from_ref(path), &mm, w, h, FillRule::NonZero) + effectcraft_path::fill_coverage(paths, &mm, w, h, FillRule::NonZero) }; let col = if stroke { g.stroke } else { g.fill }; let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32; @@ -838,20 +841,57 @@ fn blit(dst: &mut Image, src: &Image, at: [i64; 2], mode: BlendMode) { } } -/// Draw glyphs (paths mapped by `to_px` from layer space into `img`). -fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option) { - let (w, h) = (img.width as i64, img.height as i64); +/// An opaque, unblurred fill pass: where such fills of one colour touch or overlap, compositing +/// them one by one would let their anti-aliased edges show the background through. +fn solid_fill(g: &PlacedGlyph, stroke: bool) -> bool { + !stroke && g.xf.blur == [0.0; 2] && g.fill[3] >= 1.0 && g.xf.opacity >= 100.0 +} + +/// The passes in drawing order, consecutive opaque fills of one colour gathered into a run that +/// is rasterised as one outline, as After Effects draws a line of text: glyphs that touch (the +/// crossbars of "ff") or overlap join without a seam (#415). Under an Inter-Character Blending +/// mode other than Normal every character is still composited onto the others on its own. +fn runs(geom: &TextGeom) -> Vec<(Vec, bool)> { + let mut out: Vec<(Vec, bool)> = vec![]; for (gi, stroke) in passes(geom) { - if only.is_some_and(|o| o != gi) { - continue; - } let g = &geom.glyphs[gi]; if g.xf.opacity <= 0.0 { continue; } - let m = *to_px * if only.is_some() { Mat3::IDENTITY } else { g.m2() }; - let path = &g.local; - let Some(b) = effectcraft_path::bounds(std::slice::from_ref(path)) else { continue }; + if geom.blend == BlendMode::Normal + && solid_fill(g, stroke) + && let Some((run, false)) = out.last_mut() + && run.last().is_some_and(|&p| solid_fill(&geom.glyphs[p], false) && geom.glyphs[p].fill == g.fill) + { + run.push(gi); + continue; + } + out.push((vec![gi], stroke)); + } + out +} + +/// Draw glyphs (paths mapped by `to_px` from layer space into `img`). +fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Option) { + let (w, h) = (img.width as i64, img.height as i64); + let runs = match only { + Some(o) => passes(geom).into_iter().filter(|(gi, _)| *gi == o && geom.glyphs[*gi].xf.opacity > 0.0).map(|(gi, st)| (vec![gi], st)).collect(), + None => runs(geom), + }; + for (run, stroke) in runs { + let Some(g) = run.first().map(|&gi| &geom.glyphs[gi]) else { continue }; + // One glyph is drawn from its own outline; a run from its glyphs' outlines in layer + // space, all wound the same way so overlaps add up instead of cancelling (NonZero). + let local = Cow::Borrowed(std::slice::from_ref(&g.local)); + let (paths, m): (Cow<[BezPath]>, Mat3) = match (only, run.as_slice()) { + (Some(_), _) => (local, *to_px), + (None, [_]) => (local, *to_px * g.m2()), + (None, _) => { + let wound = |p: BezPath| if kurbo::Shape::area(&p) < 0.0 { p.reverse_subpaths() } else { p }; + (Cow::Owned(run.iter().map(|&gi| wound(geom.glyphs[gi].path())).collect()), *to_px) + } + }; + let Some(b) = effectcraft_path::bounds(&paths) else { continue }; let blur = [g.xf.blur[0] * s, g.xf.blur[1] * s]; let pad = g.stroke_width * m.mean_scale() + 2.0 + blur[0].max(blur[1]) * 1.5; let r = m.map_rect(&effectcraft_geom::Rect::new(b.x0, b.y0, b.x1, b.y1)); @@ -864,7 +904,7 @@ fn draw_glyphs(geom: &TextGeom, img: &mut Image, to_px: &Mat3, s: f64, only: Opt if rect[2] <= rect[0] || rect[3] <= rect[1] { continue; } - let patch = raster_pass(g, stroke, path, &m, rect, blur); + let patch = raster_pass(g, stroke, &paths, &m, rect, blur); blit(img, &patch, [rect[0], rect[1]], if only.is_some() { BlendMode::Normal } else { geom.blend }); } } diff --git a/crates/render/src/text/tests.rs b/crates/render/src/text/tests.rs index c50999b05..835f9d338 100644 --- a/crates/render/src/text/tests.rs +++ b/crates/render/src/text/tests.rs @@ -485,3 +485,44 @@ fn caret_follows_animators_and_path_text() { let glyph_c = glyph_paths(&c, ly).iter().map(|g| effectcraft_path::bounds(std::slice::from_ref(&g.0)).unwrap().center().y).collect::>(); assert!(qt.y > glyph_c[1] && qt.y < glyph_c[2], "between the 2nd and 3rd characters: {} in {glyph_c:?}", qt.y); } + +/// Glyphs that touch or overlap are drawn as one outline, as After Effects draws a line of text: +/// no light seam where their anti-aliased edges meet (the crossbars of "ff", #415). +#[test] +fn touching_glyphs_join_without_a_seam() { + use kurbo::Shape; + let glyph = |x0: f64, x1: f64, fill: [f32; 4], reversed: bool| { + let path = kurbo::Rect::new(x0, 4.0, x1, 16.0).to_path(0.1); + PlacedGlyph { + local: if reversed { path.reverse_subpaths() } else { path }, + m: Mat4::IDENTITY, + xf: CharXf::default(), + fill, + stroke: [0.0; 4], + stroke_width: 0.0, + apply_fill: true, + char_index: 0, + rest: [0.0; 2], + } + }; + let draw = |glyphs: Vec, blend: BlendMode| { + let geom = TextGeom { doc: TextDoc::default(), glyphs, fill_stroke: 0, blend }; + let mut img = crate::Image::new(24, 20); + draw_glyphs(&geom, &mut img, &Mat3::IDENTITY, 1.0, None); + img + }; + let (white, red) = ([1.0; 4], [1.0, 0.0, 0.0, 1.0]); + // The shared edge runs through the middle of pixel column 10. + let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Normal).get(10, 10); + assert!(seam[3] > 0.999 && seam[0] > 0.999, "touching glyphs leave no seam: {seam:?}"); + // Outlines wound the other way (another font, a mirrored character) still add up. + let seam = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, true)], BlendMode::Normal).get(10, 10); + assert!(seam[3] > 0.999, "opposite windings join too: {seam:?}"); + let overlap = draw(vec![glyph(4.0, 12.0, white, false), glyph(8.0, 17.0, white, true)], BlendMode::Normal).get(10, 10); + assert!(overlap[3] > 0.999, "and their overlap stays filled: {overlap:?}"); + // Characters of other colours, or blended with each other, are still drawn one by one. + let two = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, red, false)], BlendMode::Normal).get(10, 10); + assert!((two[3] - 0.75).abs() < 0.02, "{two:?}"); + let multiply = draw(vec![glyph(4.0, 10.5, white, false), glyph(10.5, 17.0, white, false)], BlendMode::Multiply).get(10, 10); + assert!(multiply[3] < 0.9, "{multiply:?}"); +} diff --git a/docs/parity.md b/docs/parity.md index 2adb4490e..8d1619789 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -401,6 +401,14 @@ Browser in the web app (landed in M13.10: browser storage and File System Access wrong signatures and versions, fuel exhaustion, traps and bit-identical output; script `Socket` validates ports, and ScriptUI resource strings accept trailing array commas. +## Update: text and viewer fidelity (G1) + +Fixes from measured reports: + +- Glyphs that touch or overlap ("ff") are drawn as one outline, so no light seam shows where + their anti-aliased edges meet (#415). Characters of other colours, with Blur, or under an + Inter-Character Blending mode are still composited one by one. + ## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel EffectCraft's core keeps After Effects parity; what After Effects users get from third parties is From 36ef45077a912cdf5169db6ca44df146c882a023 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:52:03 +0700 Subject: [PATCH 2/4] Styles: overlays keep anti-aliased edges clean (#416) Cause: the interior styles were blended over the layer body with the content's coverage folded into their opacity. On an edge pixel at 50 % coverage a Gradient or Color Overlay went over the half-transparent body as a half-transparent layer, so the result kept a quarter of the layer's own fill and its alpha rose to 0.75: a light rim (the white fill showing through) on styled text. Fix: blend_clipped composites the interior styles (Gradient Overlay, Color Overlay, Satin, Inner Glow, Inner Shadow and the inside of Stroke) onto the layer within its shape: the body is divided by the content coverage, blended with the style at its own opacity, and multiplied back, so the alpha is kept and an edge pixel mixes the style with the layer's colour inside the shape only, as a clipped layer does. Fill Opacity and Blend Interior Styles as Group behave as before. Tests: tests_styles::overlays_leave_antialiased_edges_clean (a masked square with half-covered edge columns; Color Overlay, Gradient Overlay and an inside Stroke now match a plain layer of the overlay colour; Color Overlay's edge was 0.41 red against 0.23 before). Co-Authored-By: Claude Opus 5.5 --- crates/render/src/styles.rs | 60 +++++++++++++++++++--------- crates/render/src/tests_styles.rs | 65 ++++++++++++++++++++++++++++++- docs/architecture.md | 5 ++- docs/parity.md | 4 ++ 4 files changed, 112 insertions(+), 22 deletions(-) diff --git a/crates/render/src/styles.rs b/crates/render/src/styles.rs index 8bc244985..614a80959 100644 --- a/crates/render/src/styles.rs +++ b/crates/render/src/styles.rs @@ -460,18 +460,41 @@ fn noisy(a: f32, noise: f32, x: usize, y: usize, seed: u32) -> f32 { if noise <= 0.0 { a } else { a * (1.0 - noise * hash_noise(x as u32, y as u32, seed)) } } +/// `dst` with the straight colour `c` at coverage `a` blended onto it at pixel (`x`, `y`). +#[inline] +fn blend_style(mode: BlendMode, seed: u32, x: usize, y: usize, dst: [f32; 4], c: [f32; 3], a: f32) -> [f32; 4] { + let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 }; + blend_pixel(mode, dst, [c[0] * a, c[1] * a, c[2] * a, a], n) +} + /// Blend a straight colour with coverage `a` onto every pixel of `img` (`f(x, y)` → (colour, a)). fn blend_into(img: &mut Image, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) { let w = img.width as usize; img.data.par_chunks_mut(w.max(1)).enumerate().for_each(|(y, row)| { for (x, d) in row.iter_mut().enumerate() { let (c, a) = f(x, y); - if a <= 0.0 { - continue; + if a > 0.0 { + *d = blend_style(mode, seed, x, y, *d, c, a); + } + } + }); +} + +/// Blend an interior style clipped to the content: `f(x, y)` → (straight colour, opacity) is +/// blended onto the layer *inside* its shape, whose coverage is `clip`, so the body keeps its +/// alpha and an anti-aliased edge pixel mixes the style with the layer's colour in the +/// proportion the style covers it, instead of letting the layer's own colour show through +/// around a blended edge (#416). +fn blend_clipped(img: &mut Image, clip: &Plane, mode: BlendMode, seed: u32, f: impl Fn(usize, usize) -> ([f32; 3], f32) + Sync) { + let w = img.width as usize; + img.data.par_chunks_mut(w.max(1)).zip(clip.data.par_chunks(w.max(1))).enumerate().for_each(|(y, (row, cov))| { + for (x, (d, &k)) in row.iter_mut().zip(cov).enumerate() { + if k > 0.0 + && let (c, a) = f(x, y) + && a > 0.0 + { + *d = blend_style(mode, seed, x, y, d.map(|v| v / k), c, a).map(|v| v * k); } - let src = [c[0] * a, c[1] * a, c[2] * a, a]; - let n = if mode == BlendMode::Dissolve { hash_noise(x as u32, y as u32, seed) } else { 0.5 }; - *d = blend_pixel(mode, *d, src, n); } }); } @@ -568,13 +591,12 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2]) if !bl.interior_group && bl.fill < 1.0 { body.scale_alpha(bl.fill); } - let cov = |i: usize| alpha.data[i]; if let Some(go) = &styles.gradient { let (cx, cy, hw, hh) = gradient_frame(go, &buf, &alpha, layer_size); let (ux, uy) = (go.angle.to_radians().cos(), -go.angle.to_radians().sin()); let ext = ((hw * ux).abs() + (hh * uy).abs()).max(1e-3) * go.scale; let radius = hw.max(hh).max(1e-3) * go.scale; - blend_into(&mut body, go.mode, seed, |x, y| { + blend_clipped(&mut body, &alpha, go.mode, seed, |x, y| { let (px, py) = (x as f64 + 0.5 - cx, y as f64 + 0.5 - cy); let along = px * ux + py * uy; let across = -px * uy + py * ux; @@ -592,35 +614,35 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2]) t = 1.0 - t; } let c = go.gradient.sample(t.clamp(0.0, 1.0)); - ([c[0], c[1], c[2]], c[3] * go.opacity * cov(y * w + x)) + ([c[0], c[1], c[2]], c[3] * go.opacity) }); } if let Some(co) = &styles.color { - blend_into(&mut body, co.mode, seed, |x, y| (co.color, co.opacity * cov(y * w + x))); + blend_clipped(&mut body, &alpha, co.mode, seed, |_, _| (co.color, co.opacity)); } if let Some(sa) = &styles.satin { let b = gauss_plane(&alpha, sa.size * s / 2.0, sa.size * s / 2.0); let off = [sa.offset[0] * s, sa.offset[1] * s]; - blend_into(&mut body, sa.mode, seed, |x, y| { + blend_clipped(&mut body, &alpha, sa.mode, seed, |x, y| { let v = (shifted(&b, x, y, off) - shifted(&b, x, y, [-off[0], -off[1]])).abs().min(1.0); let v = if sa.invert { 1.0 - v } else { v }; - (sa.color, v * sa.opacity * cov(y * w + x)) + (sa.color, v * sa.opacity) }); } if let Some(gl) = &styles.inner_glow { let v = glow_intensity(&alpha, &d, gl, s, true); - blend_into(&mut body, gl.mode, seed, |x, y| { + blend_clipped(&mut body, &alpha, gl.mode, seed, |x, y| { let i = y * w + x; let e = if gl.center { 1.0 - v.data[i] } else { v.data[i] }; let (c, a) = glow_color(gl, e, x, y); - (c, noisy(a * gl.opacity * cov(i), gl.noise, x, y, seed ^ 0x3c3c)) + (c, noisy(a * gl.opacity, gl.noise, x, y, seed ^ 0x3c3c)) }); } if let Some(is) = &styles.inner_shadow { let shape = soft_shape(&alpha, &d, is.size * s, is.spread, true); let off = [is.offset[0] * s, is.offset[1] * s]; - blend_into(&mut body, is.mode, seed, |x, y| { - let a = shifted(&shape, x, y, off).min(1.0) * is.opacity * cov(y * w + x); + blend_clipped(&mut body, &alpha, is.mode, seed, |x, y| { + let a = shifted(&shape, x, y, off).min(1.0) * is.opacity; (is.color, noisy(a, is.noise, x, y, seed ^ 0x1234)) }); } @@ -635,11 +657,13 @@ pub fn render(ctx: &EvalCtx, layer: &Layer, content: &Buf, layer_size: [f64; 2]) 2 => (size / 2.0, size / 2.0), _ => (size, 0.0), }; - // Inside part: blended over the body within the content. + // Inside part: blended over the body within the content, over the share of each + // pixel's coverage the ring takes. if r_in > 0.0 { - blend_into(&mut body, sk.mode, seed, |x, y| { + blend_clipped(&mut body, &alpha, sk.mode, seed, |x, y| { let i = y * w + x; - let ring = (alpha.data[i] - grown(sd.data[i], -r_in)).max(0.0); + let a = alpha.data[i]; + let ring = ((a - grown(sd.data[i], -r_in)) / a.max(1e-6)).clamp(0.0, 1.0); (sk.color, ring * sk.opacity) }); } diff --git a/crates/render/src/tests_styles.rs b/crates/render/src/tests_styles.rs index 34bfe30c1..147ff1590 100644 --- a/crates/render/src/tests_styles.rs +++ b/crates/render/src/tests_styles.rs @@ -1,10 +1,10 @@ //! Layer Styles pixel tests. use effectcraft_color::Label; -use effectcraft_keyframe::{Gradient, Keyframe, Value}; +use effectcraft_keyframe::{Gradient, Keyframe, ShapePath, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::styles::{self as st, GROUP}; -use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, Project, Solid}; +use effectcraft_project::{Comp, ItemId, ItemKind, Layer, LayerSource, MaskMode, Project, Solid}; use effectcraft_time::{FrameRate, Tick}; use crate::{Image, NoFootage, RenderOpts, Renderer, render_frame}; @@ -182,6 +182,67 @@ fn gradient_overlay_follows_angle() { assert!(img.get(81, 50)[0] > 0.9 && img.get(118, 50)[0] < 0.1); } +/// Overlays replace the layer's colour inside its alpha: an anti-aliased edge pixel mixes the +/// background with the overlay colour only, exactly as a layer of that colour would, with no +/// fringe of the layer's own colour (#416). +#[test] +fn overlays_leave_antialiased_edges_clean() { + let (blue, bg) = ([0.2, 0.533, 1.0], [0.25, 0.22, 0.45]); + // A mask half a pixel inside the square's left and right sides: in layer space, where the + // styles run, columns 80 and 119 are half covered. + let edge = |color: [f32; 3], f: &dyn Fn(&mut Project, &Comp, &mut Layer)| { + let (p, cid) = scene(color, Some(bg), |p, c, l| { + let mut next = p.next_id; + let pts = vec![[0.5, 0.0], [39.5, 0.0], [39.5, 40.0], [0.5, 40.0]]; + let sp = ShapePath { in_tangents: vec![[0.0; 2]; 4], out_tangents: vec![[0.0; 2]; 4], vertices: pts, closed: true, feather: Vec::new() }; + let m = build::mask(&mut Ids(&mut next), "Mask 1", sp, MaskMode::Add, [255, 255, 0]); + p.next_id = next; + l.props.group_mut("masks").unwrap().children.push(m.into()); + f(p, c, l); + }); + let img = render(&p, cid); + [img.get(80, 50), img.get(119, 50), img.get(100, 50)] + }; + let plain = edge(blue, &|_, _, _| {}); + assert!(near(plain[0][0], 0.5 * (blue[0] + bg[0]), 0.01), "half covered: {:?}", plain[0]); + let color = Value::Color([blue[0] as f64, blue[1] as f64, blue[2] as f64, 1.0]); + let flat = + Gradient { colors: vec![(0.0, [blue[0], blue[1], blue[2], 1.0]), (1.0, [blue[0], blue[1], blue[2], 1.0])], opacities: vec![(0.0, 1.0), (1.0, 1.0)] }; + let styled: [(&str, Box); 3] = [ + ( + "Color Overlay", + Box::new(|p, c, l| { + add(p, c, l, "colorOverlay"); + set(l, "colorOverlay/color", color.clone()); + }), + ), + ( + "Gradient Overlay", + Box::new(|p, c, l| { + add(p, c, l, "gradientOverlay"); + set(l, "gradientOverlay/colors", Value::Gradient(flat.clone())); + }), + ), + ( + "Stroke (inside)", + Box::new(|p, c, l| { + add(p, c, l, "stroke"); + set(l, "stroke/position", Value::Enum(1)); + set(l, "stroke/size", Value::Scalar(25.0)); + set(l, "stroke/color", color.clone()); + }), + ), + ]; + for (name, f) in &styled { + let px = edge([1.0; 3], f.as_ref()); + for (a, b) in px.iter().zip(&plain) { + for ch in 0..4 { + assert!(near(a[ch], b[ch], 0.01), "{name}: {a:?} against the plain layer {b:?}"); + } + } + } +} + fn bevel_scene(angle: f64) -> Image { let (p, cid) = scene([0.5; 3], None, |p, c, l| { add(p, c, l, "bevelEmboss"); diff --git a/docs/architecture.md b/docs/architecture.md index 7fd5456d3..79822d32b 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -207,8 +207,9 @@ switches that don't change pixels (Audio, Lock, Shy, Hide Shy Layers: `Switches: **Layer styles** (Layer ▸ Layer Styles; `crates/render/src/styles.rs`) render in layer space and may grow the layer's bounds. Drop Shadow and Outer Glow become separate passes composited below the layer with their own blend modes; the interior styles, Stroke and Bevel and Emboss are baked into -the layer body. Layer opacity fades the whole stack; Knockout and the R/G/B channel switches apply -at composite time. Styled pixels are cached separately from the layer content, so editing a style +the layer body. Interior styles are clipped to the layer: they blend onto its colour inside its +shape and keep its alpha, so an anti-aliased edge mixes the style and the background only. Layer +opacity fades the whole stack; Knockout and the R/G/B channel switches apply at composite time. Styled pixels are cached separately from the layer content, so editing a style reuses the cached source/masks/effects. Global Light is one setting per comp, mirrored into every layer's Blending Options and kept in step after each edit. diff --git a/docs/parity.md b/docs/parity.md index 8d1619789..31b20a356 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -408,6 +408,10 @@ Fixes from measured reports: - Glyphs that touch or overlap ("ff") are drawn as one outline, so no light seam shows where their anti-aliased edges meet (#415). Characters of other colours, with Blur, or under an Inter-Character Blending mode are still composited one by one. +- Interior layer styles (Gradient / Color Overlay, Satin, Inner Glow, Inner Shadow, the inside of + Stroke) blend onto the layer's colour inside its alpha, so an anti-aliased edge no longer shows + a rim of the layer's own fill (#416). Not yet: Inner Bevel still shades an anti-aliased edge + over the layer's own colour. ## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel From 94e4afe8deb9ec7d5fc24d7e922a58a43e6d36b3 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:52:20 +0700 Subject: [PATCH 3/4] Text: text layers are always rasterised at their on-screen scale (#390) Text is vector in After Effects and stays sharp at any Scale. Here a text layer was drawn at its font size and the bitmap resampled by Scale unless Continuously Rasterize was on, so text grown with Scale went soft. This reworks #200 (reverted in 29c4d3a for kinetic_typography). Why kinetic_typography failed: its time remap key at 1.9 s sits on frame 23 (1.917 s), so main-comp 0.3 s shows the precomp at 1.6026 s, but the test compared it with the precomp rendered at 1.6 s. The moving, motion-blurred title already differed by 0.89 of the 1.0 mean-difference budget on main (0.987 in all); with continuous rasterisation the "Sub" line, whose expression scale is 84.00 % at 1.6 s and 84.105 % at 1.6026 s, falls on either side of the 2^-1/4 quarter-octave step and was drawn at 0.84 at one time and at 1.0 at the other, pushing the total to 1.075. Fix: raster_scale treats text layers as always continuously rasterised (shape layers and vector footage still follow the switch), and rounds the quarter-octave step up past rounding noise only: an unscaled layer at a fractional position measured its scale as 1 + 1e-15 and was rasterised at 2^1/4, softening unscaled text. The QA test now compares the remapped precomp with the precomp at the time the remap really shows (read back from the property): difference 0.63, all of it the title's motion blur, the Sub line identical. Tests: tests_collapse::text_scaled_up_stays_sharp_without_the_switch (from #200: 10 px text at 800 % matches 80 px text), tests_collapse::unscaled_text_rasterises_at_the_output_scale (fails without the noise tolerance), automation qa kinetic_typography. Co-Authored-By: Claude Opus 5.5 --- crates/automation/tests/qa/kinetic.rs | 8 +++- crates/render/src/lib.rs | 24 +++++++----- crates/render/src/tests_collapse.rs | 55 ++++++++++++++++++++++++++- docs/architecture.md | 5 ++- docs/parity.md | 3 ++ 5 files changed, 80 insertions(+), 15 deletions(-) diff --git a/crates/automation/tests/qa/kinetic.rs b/crates/automation/tests/qa/kinetic.rs index 4cc81ef38..59a1e9bb7 100644 --- a/crates/automation/tests/qa/kinetic.rs +++ b/crates/automation/tests/qa/kinetic.rs @@ -75,9 +75,13 @@ fn kinetic_typography() { qa.exec("layer.setSwitch", json!({"layers": [pre["layer"].clone()], "switch": "motionBlur", "value": true})); qa.exec("layer.enableTimeRemap", json!({"layers": [pre["layer"].clone()], "value": true})); qa.tool("add_keyframe", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "keys": [{"time": 0, "value": 1.9}, {"time": 1.9, "value": 0}]})); - let fwd = qa.frame(Some(pre["comp"].clone()), 1.6); + // The 1.9 s key sits on frame 23 (1.917 s), so 0.3 s maps to 1.6026 s: compared at that time, + // not 1.6 s, where the moving title (and text drawn at its on-screen scale) differs. + let at = qa.tool("get_property", json!({"layer": pre["layer"].clone(), "path": "timeRemap", "time": 0.3}))["value"].as_f64().unwrap(); + assert!((at - 1.6).abs() < 0.01, "time remap 0.3 s → 1.6 s of the precomp: {at}"); + let fwd = qa.frame(Some(pre["comp"].clone()), at); let rev = qa.frame(Some(comp.clone()), 0.3); - assert!(mean_diff(&fwd, &rev) < 1.0, "time remap 0.3 s → 1.6 s of the precomp"); + assert!(mean_diff(&fwd, &rev) < 1.0, "the remapped precomp shows its frame at {at} s: {}", mean_diff(&fwd, &rev)); // Render: H.264 and a PNG sequence through the render queue. let mp4 = qa.path("kinetic.mp4"); diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0062898c4..a35566b45 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -681,18 +681,21 @@ impl<'a> Renderer<'a> { Region::Full } - /// Rasterisation scale of a layer's source: the output scale, or — for Continuously - /// Rasterize text and shape layers — the output scale times the layer's on-screen scale - /// (quarter-octave steps, rounded up, so vector content is drawn at its final size and - /// stays sharp when scaled up). + /// Rasterisation scale of a layer's source: the output scale, or — for text layers, which are + /// always continuously rasterised, and Continuously Rasterize shape layers and vector footage — + /// the output scale times the layer's on-screen scale (quarter-octave steps, rounded up, so + /// vector content is drawn at its final size and stays sharp when scaled up). pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { let s = self.opts.scale; - let vector = match layer.source { - LayerSource::Text | LayerSource::Shape => true, - LayerSource::Footage { item } => matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)), + let continuous = match layer.source { + LayerSource::Text => true, + LayerSource::Shape => layer.switches.collapse, + LayerSource::Footage { item } => { + layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f)) + } _ => false, }; - if !layer.switches.collapse || !vector { + if !continuous { return s; } let (l2c, _) = ctx.layer_to_comp(layer); @@ -705,7 +708,10 @@ impl<'a> Renderer<'a> { if !k.is_finite() || k <= 1e-6 { return s; } - let mut k = 2f64.powf((k.log2() * 4.0).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); + // The step is rounded up past rounding noise only: an unscaled layer whose position isn't + // a whole number maps the unit vectors to lengths a hair over 1, which must stay 1 (#390: + // rounded up to 2^¼, unscaled text was drawn larger and resampled, softer). + let mut k = 2f64.powf((k.log2() * 4.0 - 1e-6).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); // Keep the buffer within about 16 million pixels. if let Some(b) = content_bounds(ctx, layer) { let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s; diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs index 7d7e28ae7..f13dedd11 100644 --- a/crates/render/src/tests_collapse.rs +++ b/crates/render/src/tests_collapse.rs @@ -1,12 +1,12 @@ //! Collapse Transformations, Continuously Rasterize and the Quality switch. use effectcraft_color::{BlendMode, Label}; -use effectcraft_keyframe::Value; +use effectcraft_keyframe::{TextDoc, Value}; use effectcraft_project::build::{self, Ids}; use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; use effectcraft_time::{FrameRate, Tick}; -use crate::{Image, render_frame}; +use crate::{EvalCtx, Image, NoFootage, RenderOpts, Renderer, render_frame}; fn project() -> Project { let mut p = Project::default(); @@ -234,3 +234,54 @@ fn collapsed_precomp_of_another_size_renders_through_the_parent_camera() { assert!(img.get(100, 100)[3] < 0.01, "only the 40 px solid is drawn"); } } + +/// Text layers are always continuously rasterised, as text is vector in After Effects (#390, #194): +/// text scaled up with the layer's Scale is drawn at its on-screen size, as sharp as text set at +/// that font size, without the switch. +#[test] +fn text_scaled_up_stays_sharp_without_the_switch() { + let run = |size: f64, scale: f64| { + let mut p = project(); + let comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size, ..Default::default() })); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([40.0, 120.0, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0])); + assert!(!l.switches.collapse); + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + let partial = |img: &Image| img.data.iter().filter(|px| (0.02..0.98).contains(&px[3])).count(); + let scaled = run(10.0, 800.0); + let native = run(80.0, 100.0); + let (ps, pn) = (partial(&scaled), partial(&native)); + let d = max_diff(&scaled, &native); + eprintln!("partially covered pixels: scaled {ps}, native {pn}; max difference {d}"); + assert!(native.data.iter().filter(|px| px[3] > 0.98).count() > 1000, "the text renders"); + assert!(ps <= pn + pn / 2, "scaled text is blurred: {ps} soft pixels against {pn}"); + assert!(d < 0.05, "scaled text differs from text set at that size by {d}"); +} + +/// Unscaled text is drawn at the output scale wherever it sits: the lengths a position that isn't a +/// whole number maps the unit vectors to, a rounding error over 1, don't raise its raster scale a +/// quarter octave (#390: that softened it, and drew it differently at nearby times). +#[test] +fn unscaled_text_rasterises_at_the_output_scale() { + let mut p = project(); + let mut comp = Comp::new(240, 160, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (240, 160), None); + l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HE".into(), size: 30.0, ..Default::default() })); + comp.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.into())); + for i in 0..500 { + let pos = [3.1 + i as f64 * 0.37, 7.3 + i as f64 * 0.29, 0.0]; + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/position", Value::Vec3(pos)); + let r = Renderer::new(&p, &NoFootage, RenderOpts::default()); + let c = p.comp(cid).unwrap(); + let k = r.raster_scale(&EvalCtx::new(&p, cid, c, Tick::ZERO), &c.layers[0]); + assert_eq!(k, 1.0, "text at {pos:?}"); + } +} diff --git a/docs/architecture.md b/docs/architecture.md index 79822d32b..7835e395e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -355,8 +355,9 @@ hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, its nested layers straight into the parent with concatenated transforms (one resample), so nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's -3D run. Masks, effects or styles force a flattened render. On text and shape layers the switch is -Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples +3D run. Masks, effects or styles force a flattened render. Text layers are always rasterised at +their on-screen scale, as text is vector in After Effects; on shape layers and vector footage the +switch is Continuously Rasterize and does the same. Quality: Draft samples nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, dragging the source bar in the timeline) moves the source under fixed in/out points. diff --git a/docs/parity.md b/docs/parity.md index 31b20a356..5c4ae138a 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -412,6 +412,9 @@ Fixes from measured reports: Stroke) blend onto the layer's colour inside its alpha, so an anti-aliased edge no longer shows a rim of the layer's own fill (#416). Not yet: Inner Bevel still shades an anti-aliased edge over the layer's own colour. +- Text layers are always rasterised at their on-screen scale, sharp at any Scale as in After + Effects, and an unscaled layer at a fractional position is no longer rasterised a quarter octave + too large (#390). ## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel From 9d85d4f11667224266eee9f95d1d1b501f9bbabb Mon Sep 17 00:00:00 2001 From: bflatastic Date: Fri, 9 Oct 2026 22:52:48 +0700 Subject: [PATCH 4/4] Viewer: Auto resolution follows the magnification; minified frames are averaged (#417) Cause: Auto dropped to Half below 75 % magnification, so text between 50 % and 75 % was rendered with fewer pixels than the screen shows and looked soft. Below 100 % the frame texture was minified with a plain bilinear sampler and no mip levels (Full at 46 % skipped most frame pixels), so edges stair-stepped. Fix: - Auto renders the pixels the magnification needs, as in After Effects: the coarsest whole factor that still gives every screen pixel a rendered one (Full above 50 %, Half down to 33.3 %, Third down to 25 %, Quarter below). - GPU frames: the display texture carries a full mip chain, each level the 2 x 2 mean of the one above (display.wgsl `mip`, same bind group layout as `display`), sampled trilinearly with Viewer Zoom Quality More Accurate (Faster keeps the nearest pixel of the full-size frame). - CPU frames (egui-wgpu textures have no mips): uploaded averaged down by the whole factor that leaves about one texel per screen pixel, the same box average as the texture-limit fit (#201), re-uploaded when the magnification calls for another factor; drawn with texture coordinates that keep each texel over its pixels when the size isn't a multiple. Show Channel / exposure textures follow. Tests: gpu tests::display_frames_are_mipmapped (level count and level 1 = 2 x 2 means of level 0), viewer_tools auto_resolution_follows_the_magnification (60 % was Half) and minified_frames_keep_their_texels_in_place. Co-Authored-By: Claude Opus 5.5 --- crates/gpu/src/context.rs | 66 ++++++++++++++--------- crates/gpu/src/lib.rs | 5 +- crates/gpu/src/shaders/display.wgsl | 17 ++++++ crates/gpu/src/tests.rs | 46 ++++++++++++++++ crates/ui-egui/src/frames.rs | 36 ++++++++++--- crates/ui-egui/src/lib.rs | 9 ++-- crates/ui-egui/src/panels/viewer.rs | 59 +++++++++++++++----- crates/ui-egui/src/panels/viewer_tools.rs | 65 ++++++++++++++++++++-- crates/ui-egui/src/panels/viewers.rs | 6 +-- crates/ui-egui/src/state.rs | 15 ++---- docs/architecture.md | 5 ++ docs/parity.md | 4 ++ 12 files changed, 263 insertions(+), 70 deletions(-) diff --git a/crates/gpu/src/context.rs b/crates/gpu/src/context.rs index 93283e785..e994dd1bd 100644 --- a/crates/gpu/src/context.rs +++ b/crates/gpu/src/context.rs @@ -246,6 +246,8 @@ pub struct GpuContext { pipelines: HashMap<&'static str, wgpu::ComputePipeline>, display_bgl: wgpu::BindGroupLayout, display: wgpu::ComputePipeline, + /// The display texture's next mip level from the one above (`display.wgsl` `mip`). + display_mip: wgpu::ComputePipeline, dummy: wgpu::Texture, dummy_tex: wgpu::TextureView, dummy_buf: wgpu::Buffer, @@ -506,16 +508,20 @@ impl GpuContext { immediate_size: 0, }) })?; - let display = init_resource(&device, "display compute pipeline", || { - device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { - label: Some("display"), - layout: Some(&dlayout), - module: &dmodule, - entry_point: Some("display"), - compilation_options: Default::default(), - cache: None, + let display_pipeline = |entry: &'static str| { + init_resource(&device, "display compute pipeline", || { + device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: Some(entry), + layout: Some(&dlayout), + module: &dmodule, + entry_point: Some(entry), + compilation_options: Default::default(), + cache: None, + }) }) - })?; + }; + let display = display_pipeline("display")?; + let display_mip = display_pipeline("mip")?; let dummy = init_resource(&device, "dummy texture", || { device.create_texture(&wgpu::TextureDescriptor { label: Some("dummy"), @@ -543,6 +549,7 @@ impl GpuContext { pipelines, display_bgl, display, + display_mip, dummy_tex, dummy, dummy_buf, @@ -1186,36 +1193,47 @@ impl<'g> Enc<'g> { self.g.device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("data"), contents: &bytes, usage: wgpu::BufferUsages::STORAGE }) } - /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format). + /// Convert to the viewer's premultiplied RGBA8 texture (egui-wgpu's native texture format), + /// with a full mip chain so the viewer minifies it without aliasing (#417). pub fn display(&mut self, img: &GpuImage) -> wgpu::Texture { let g = self.g; + let levels = mip_levels(img.width, img.height); let tex = g.device.create_texture(&wgpu::TextureDescriptor { label: Some("viewer frame"), size: wgpu::Extent3d { width: img.width, height: img.height, depth_or_array_layers: 1 }, - mip_level_count: 1, + mip_level_count: levels, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::STORAGE_BINDING | wgpu::TextureUsages::COPY_SRC, view_formats: &[], }); - let sv = img.texture.create_view(&Default::default()); - let ov = tex.create_view(&Default::default()); - let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { - label: None, - layout: &g.display_bgl, - entries: &[ - wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(&sv) }, - wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(&ov) }, - ], - }); + let level = |l: u32| tex.create_view(&wgpu::TextureViewDescriptor { base_mip_level: l, mip_level_count: Some(1), ..Default::default() }); + // Level 0 from the frame, then each level the 2 × 2 mean of the one above. + let mut steps = vec![(&g.display, img.texture.create_view(&Default::default()), level(0), img.width, img.height)]; + for l in 1..levels { + steps.push((&g.display_mip, level(l - 1), level(l), (img.width >> l).max(1), (img.height >> l).max(1))); + } let enc = self.encoder(); - { + for (pipeline, src, dst, w, h) in &steps { + let bg = g.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: None, + layout: &g.display_bgl, + entries: &[ + wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(src) }, + wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(dst) }, + ], + }); let mut pass = enc.begin_compute_pass(&wgpu::ComputePassDescriptor { label: Some("display"), timestamp_writes: None }); - pass.set_pipeline(&g.display); + pass.set_pipeline(pipeline); pass.set_bind_group(0, &bg, &[]); - pass.dispatch_workgroups(img.width.div_ceil(16), img.height.div_ceil(16), 1); + pass.dispatch_workgroups(w.div_ceil(16), h.div_ceil(16), 1); } tex } } + +/// Mip levels of a full chain for a `w × h` texture (down to 1 × 1). +pub(crate) fn mip_levels(w: u32, h: u32) -> u32 { + u32::BITS - w.max(h).max(1).leading_zeros() +} diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 7d1243034..8879ab862 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -104,8 +104,9 @@ pub struct Gpu { auto: Arc, } -/// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`), -/// ready for `egui_wgpu::Renderer::register_native_texture`. +/// A viewer frame left on the GPU: premultiplied RGBA8 (`wgpu::TextureFormat::Rgba8Unorm`) with +/// a full mip chain (for minified views), ready for +/// `egui_wgpu::Renderer::register_native_texture_with_sampler_options`. #[derive(Clone, Debug)] pub struct DisplayFrame { pub texture: wgpu::Texture, diff --git a/crates/gpu/src/shaders/display.wgsl b/crates/gpu/src/shaders/display.wgsl index 682618759..1b7da8a18 100644 --- a/crates/gpu/src/shaders/display.wgsl +++ b/crates/gpu/src/shaders/display.wgsl @@ -17,3 +17,20 @@ fn display(@builtin(global_invocation_id) gid: vec3) { let q = floor(vec4(clamp(s.xyz, vec3(0.0), vec3(a)), a) * 255.0 + 0.5) / 255.0; textureStore(dout, p, q); } + +// The display texture's next mip level (`dsrc` = the level above, `dout` = this one): each texel +// the mean of the 2 × 2 premultiplied texels it covers, as ui-egui's fitted CPU textures average +// (#417). A level one texel wide or high repeats its edge. +@compute @workgroup_size(16, 16) +fn mip(@builtin(global_invocation_id) gid: vec3) { + let dims = vec2(textureDimensions(dout)); + let p = vec2(gid.xy); + if (p.x >= dims.x || p.y >= dims.y) { + return; + } + let last = vec2(textureDimensions(dsrc)) - vec2(1); + let q = p * 2; + let s = textureLoad(dsrc, min(q, last), 0) + textureLoad(dsrc, min(q + vec2(1, 0), last), 0) + + textureLoad(dsrc, min(q + vec2(0, 1), last), 0) + textureLoad(dsrc, min(q + vec2(1, 1), last), 0); + textureStore(dout, p, s * 0.25); +} diff --git a/crates/gpu/src/tests.rs b/crates/gpu/src/tests.rs index 93ea89b0d..39be29a97 100644 --- a/crates/gpu/src/tests.rs +++ b/crates/gpu/src/tests.rs @@ -635,6 +635,52 @@ fn backend_selection_and_display_frames() { } } +/// The display texture carries a full mip chain, each level the 2 × 2 mean of the one above, so the +/// viewer minifies a frame without aliasing (#417). +#[test] +fn display_frames_are_mipmapped() { + let Some(g) = gpu() else { return }; + let s = blend_scene(BitDepth::Bpc8, BlendMode::Screen); + let mut r = Renderer::new(&s.p, &Pattern, opts()); + r.accel = Some(g); + let f = g.render_display(&r, s.cid, Tick::ZERO).expect("display frame"); + assert_eq!(f.texture.mip_level_count(), crate::context::mip_levels(f.width, f.height)); + assert_eq!(f.texture.mip_level_count(), u32::BITS - f.width.max(f.height).leading_zeros()); + let base = g.read_display(&f).expect("readback"); + // Level 1, copied out to read it back. + let (w1, h1) = (f.width / 2, f.height / 2); + let tex = g.device().create_texture(&wgpu::TextureDescriptor { + label: None, + size: wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8Unorm, + usage: wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let mut e = crate::context::Enc::new(&g.ctx); + e.encoder().copy_texture_to_texture( + wgpu::TexelCopyTextureInfo { texture: &f.texture, mip_level: 1, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All }, + wgpu::Extent3d { width: w1, height: h1, depth_or_array_layers: 1 }, + ); + let level1 = e.read_texture(&tex, w1, h1, 4).expect("level 1"); + let at = |x: u32, y: u32, c: usize| base[((y * f.width + x) * 4) as usize + c] as f32; + let mut varied = false; + for y in 0..h1 { + for x in 0..w1 { + for c in 0..4 { + let want = (at(2 * x, 2 * y, c) + at(2 * x + 1, 2 * y, c) + at(2 * x, 2 * y + 1, c) + at(2 * x + 1, 2 * y + 1, c)) / 4.0; + let got = level1[((y * w1 + x) * 4) as usize + c] as f32; + assert!((want - got).abs() <= 1.0, "({x}, {y}) channel {c}: {got} against the mean {want}"); + varied |= want != at(2 * x, 2 * y, c); + } + } + } + assert!(varied, "the scene has edges to average"); +} + /// Browsers' WGSL compilers reject an f32 literal whose exact decimal value lies outside the f32 /// range (`3.40282347e38` rounds to f32::MAX in Rust but exceeds it), which invalidates the whole /// module (every kernel); naga accepts it, so native runs can't catch it. Every float literal diff --git a/crates/ui-egui/src/frames.rs b/crates/ui-egui/src/frames.rs index a157359fd..e17840902 100644 --- a/crates/ui-egui/src/frames.rs +++ b/crates/ui-egui/src/frames.rs @@ -425,33 +425,53 @@ pub fn to_color_image(img: &effectcraft_engine::render::Image) -> egui::ColorIma egui::ColorImage::new([img.width as usize, img.height as usize], px) } -/// The size `size` is shown at in a texture whose sides can't exceed `max_side`: divided by the -/// smallest whole factor that fits (frames of comps wider than the GPU's texture limit, #201). +/// The whole factor an image of `size` is averaged down by for a texture: at least `min_k`, and +/// the smallest that fits its sides in `max_side` (frames of comps wider than the GPU's texture +/// limit, #201). +pub fn fit_factor(size: [usize; 2], max_side: usize, min_k: usize) -> usize { + size[0].max(size[1]).div_ceil(max_side.max(1)).max(min_k).max(1) +} + +/// The size `size` is shown at in a texture whose sides can't exceed `max_side`. pub fn fitted_size(size: [usize; 2], max_side: usize) -> [usize; 2] { - let k = size[0].max(size[1]).div_ceil(max_side.max(1)).max(1); + let k = fit_factor(size, max_side, 1); [size[0].div_ceil(k), size[1].div_ceil(k)] } +/// The texture coordinates of the image within a texture averaged down by `k`: its last texels +/// cover fewer pixels than the factor, so drawn over the image's area with these the texels sit +/// exactly where their pixels are. +pub fn fitted_uv(size: [usize; 2], k: usize) -> egui::Rect { + let k = k.max(1); + let f = |n: usize| (n as f64 / (n.div_ceil(k).max(1) * k) as f64) as f32; + egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(f(size[0]), f(size[1]))) +} + /// `img` as a texture the renderer accepts: unchanged when it fits in `max_side`, else averaged /// down by a whole factor (premultiplied pixels average correctly). The viewer draws it over the /// same area, so only detail beyond the GPU's texture limit is lost, instead of the upload /// failing (#201). pub fn fit_texture(img: egui::ColorImage, max_side: usize) -> egui::ColorImage { + fit_texture_by(img, max_side, 1) +} + +/// [`fit_texture`] averaging down by at least `min_k` (see [`fit_factor`]). +pub fn fit_texture_by(img: egui::ColorImage, max_side: usize, min_k: usize) -> egui::ColorImage { let [w, h] = img.size; - let [fw, fh] = fitted_size(img.size, max_side); - if [fw, fh] == [w, h] { + let k = fit_factor(img.size, max_side, min_k); + if k == 1 { return img; } - let k = w.max(h).div_ceil(max_side.max(1)).max(1); + let (fw, fh) = (w.div_ceil(k), h.div_ceil(k)); let px: Vec = (0..fw * fh) .into_par_iter() .map(|i| { let (x0, y0) = ((i % fw) * k, (i / fw) * k); - let (mut sum, mut n) = ([0u32; 4], 0u32); + let (mut sum, mut n) = ([0u64; 4], 0u64); for y in y0..(y0 + k).min(h) { for c in img.pixels.get(y * w + x0..y * w + (x0 + k).min(w)).unwrap_or_default() { for (s, v) in sum.iter_mut().zip(c.to_array()) { - *s += v as u32; + *s += v as u64; } n += 1; } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 01a028816..82be8a323 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -231,8 +231,9 @@ pub struct EffectcraftApp { /// When the project last changed, and its revision then (see [`Self::editing`]). last_edit: (f64, u64), pub(crate) toast: Option<(String, f64)>, - /// Texture of the last CPU frame shown in the viewer and the key it came from. - pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey)>, + /// Texture of the last CPU frame shown in the viewer, the key it came from and the factor it + /// was averaged down by for the magnification (`panels::viewer::minify_factor`). + pub(crate) viewer_tex: Option<(egui::TextureHandle, FrameKey, usize)>, /// The frames the other (passive) Composition viewers show, by viewer id. pub(crate) passive_tex: std::collections::HashMap, /// The last GPU frame shown: its egui texture id (registered with egui-wgpu), key and texture. @@ -550,7 +551,7 @@ impl EffectcraftApp { } self.viewer_native = None; self.passive_tex.retain(|_, (_, texture)| matches!(texture, panels::viewers::PassiveTexture::Cpu(_))); - self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key)| (texture.id(), *key)); + self.viewer_shown = self.viewer_tex.as_ref().map(|(texture, key, _)| (texture.id(), *key)); self.viewer_image = None; // Old completion closures retain only their detached slot, not the replacement. self.viewer_readback = Default::default(); @@ -615,7 +616,7 @@ impl EffectcraftApp { /// The size of the viewer frame's texture (CPU frames; fitted to the GPU's texture limit). pub fn viewer_texture_size(&self) -> Option<[usize; 2]> { - self.viewer_tex.as_ref().map(|(t, _)| t.size()) + self.viewer_tex.as_ref().map(|(t, _, _)| t.size()) } /// The viewer's current pixels as 8-bit premultiplied RGBA, reading a GPU frame back the diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index 66045ce28..9bc70cb19 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -669,8 +669,11 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { 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); - if stale { - show_frame(app, &ctx, key, img); + // 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() { // The same frame at a later revision (an edit elsewhere, an undo): it shows that one. k.revision = key.revision; @@ -1989,6 +1992,34 @@ pub(crate) fn zoom_texture_options(smooth: bool) -> egui::TextureOptions { if smooth { 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. +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, + min_filter: FilterMode::Linear, + mipmap_filter: MipmapFilterMode::Linear, + ..Default::default() + } + } else { + SamplerDescriptor { label, lod_max_clamp: 0.0, ..Default::default() } + } +} + +/// The whole factor a CPU frame is averaged down by for its texture when `pixels` of it are drawn +/// over `screen` screen pixels: about one texel per screen pixel, so bilinear filtering never +/// skips frame pixels — the CPU frames' counterpart of the GPU frames' mip levels (#417). Faster +/// keeps every pixel. +pub(crate) fn minify_factor(smooth: bool, pixels: f64, screen: f64) -> usize { + let k = (pixels / screen + 1e-3).floor(); + if smooth && k >= 1.0 { k.min(256.0) as usize } else { 1 } +} + pub(crate) fn hex_rgb(s: &str) -> Option<[u8; 3]> { let h = s.trim().trim_start_matches('#'); if h.len() != 6 || !h.is_ascii() { @@ -2026,26 +2057,27 @@ fn rect_of(map: &ViewerMap, r: Option<[f64; 4]>) -> Rect { Rect::from_min_max(map.to_screen([x, y]), map.to_screen([x + w, y + h])) } -/// Put a rendered frame on screen: CPU pixels go into an egui texture; GPU frames are drawn -/// straight from their wgpu texture (registered with egui-wgpu, no readback). -fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage) { +/// Put a rendered frame on screen: CPU pixels go into an egui texture, averaged down by `minify` +/// (see [`minify_factor`]); GPU frames are drawn straight from their wgpu texture (registered with +/// egui-wgpu, no readback). +fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames::FrameKey, img: crate::frames::FrameImage, minify: usize) { use crate::frames::FrameImage; // Settings ▸ Previews ▸ Viewer Zoom Quality. let smooth = app.session.prefs.viewer_zoom_smooth(); let opts = zoom_texture_options(smooth); match img { FrameImage::Cpu(img) => { - // Frames wider or taller than the GPU's texture limit go up averaged down (#201). + // Frames wider or taller than the GPU's texture limit go up averaged down too (#201). let max = crate::frames::max_texture_side(ctx); - let fitted = crate::frames::fit_texture((*img).clone(), max); + let fitted = crate::frames::fit_texture_by((*img).clone(), max, minify); match &mut app.viewer_tex { - Some((tex, k)) if tex.size() == fitted.size => { + Some((tex, k, m)) if tex.size() == fitted.size => { tex.set(fitted, opts); - *k = key; + (*k, *m) = (key, minify); } - _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key)), + _ => app.viewer_tex = Some((ctx.load_texture("viewer-frame", fitted, opts), key, minify)), } - app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k)| (t.id(), *k)); + app.viewer_shown = app.viewer_tex.as_ref().map(|(t, k, _)| (t.id(), *k)); app.viewer_image = Some(img); vt::set_texture_roi(ctx, app.session.state.region_of_interest.or(app.ui.viewer.extended)); } @@ -2053,13 +2085,12 @@ fn show_frame(app: &mut EffectcraftApp, ctx: &egui::Context, key: crate::frames: let Some(rs) = &app.wgpu else { return }; let view = f.texture.create_view(&Default::default()); let mut ren = rs.renderer.write(); - let filter = if smooth { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest }; let id = match &app.viewer_native { Some((id, _, _)) => { - ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, *id); + ren.update_egui_texture_from_wgpu_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth), *id); *id } - None => ren.register_native_texture(&rs.device, &view, filter), + None => ren.register_native_texture_with_sampler_options(&rs.device, &view, zoom_sampler(smooth)), }; drop(ren); app.viewer_native = Some((id, key, f)); diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 36b64b481..8d41eb738 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -421,7 +421,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: } comp_rect } else { - let Some((tex, k)) = app.viewer_tex.clone() else { return }; + let Some((tex, k, minify)) = app.viewer_tex.clone() else { return }; if k.comp != cid.0 { return; } @@ -431,29 +431,40 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: None => comp_rect, }; if plain { + let uv = app.viewer_image.as_ref().map_or(uv, |src| fitted_uv(ctx, src.size, minify)); painter.image(tex.id(), rect, uv, Color32::WHITE); return; } rect }; let Some(src) = app.viewer_image.clone() else { return }; - let zoom_opts = super::viewer::zoom_texture_options(app.session.prefs.viewer_zoom_smooth()); + let smooth = app.session.prefs.viewer_zoom_smooth(); + // Averaged down for the magnification, as the frame's own texture is (#417). + let minify = super::viewer::minify_factor(smooth, src.size[0] as f64, (rect.width() * ctx.pixels_per_point()) as f64); let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h); + (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key, minify).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-display-tex"); let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { Some((k, tex)) if k == key => tex, _ => { - let tex = crate::frames::load_fitted(ctx, "viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts); + let img = + crate::frames::fit_texture_by(transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), crate::frames::max_texture_side(ctx), minify); + let tex = ctx.load_texture("viewer-display", img, super::viewer::zoom_texture_options(smooth)); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } }; - painter.image(tex.id(), rect, uv, Color32::WHITE); + painter.image(tex.id(), rect, fitted_uv(ctx, src.size, minify), Color32::WHITE); +} + +/// The texture coordinates of a frame of `size` in its texture, fitted with at least `minify` +/// (see [`crate::frames::fitted_uv`]). +fn fitted_uv(ctx: &egui::Context, size: [usize; 2], minify: usize) -> Rect { + crate::frames::fitted_uv(size, crate::frames::fit_factor(size, crate::frames::max_texture_side(ctx), minify)) } // ---------------------------------------------------------------- bottom bar @@ -757,4 +768,48 @@ mod tests { assert_eq!(resolution_label(Resolution::Quarter, 0.25), "Quarter"); assert_eq!(resolution_label(Resolution::Custom(5), 0.2), "Custom (5)"); } + + /// Auto renders the pixels the magnification needs, as in After Effects: Full above 50 %, + /// Half down to 33.3 %, Third down to 25 %, Quarter below — never fewer pixels than the + /// screen shows (#417: 60 % rendered at Half). + #[test] + fn auto_resolution_follows_the_magnification() { + let auto = |zoom: f32, ppp: f32| resolution_label(Resolution::Auto, Resolution::Auto.scale(zoom, ppp)); + for (zoom, want) in [(2.0, "(Full)"), (1.0, "(Full)"), (0.6, "(Full)"), (0.51, "(Full)"), (0.5, "(Half)"), (0.46, "(Half)"), (0.34, "(Half)")] { + assert_eq!(auto(zoom, 1.0), want, "{zoom}"); + } + for (zoom, want) in [(1.0 / 3.0, "(Third)"), (0.26, "(Third)"), (0.25, "(Quarter)"), (0.1, "(Quarter)"), (0.0, "(Quarter)")] { + assert_eq!(auto(zoom, 1.0), want, "{zoom}"); + } + // Magnification in points on a 150 % display: 40 % shows 60 % of the comp's pixels. + assert_eq!(auto(0.4, 1.5), "(Full)"); + assert_eq!(auto(f32::NAN, 1.0), "(Full)"); + assert_eq!(Resolution::Full.scale(0.1, 1.0), 1.0); + } + + /// CPU frames go up averaged down to about one texel per screen pixel (More Accurate only), + /// drawn with texture coordinates that keep each texel over its pixels (#417). + #[test] + fn minified_frames_keep_their_texels_in_place() { + use crate::panels::viewer::minify_factor; + assert_eq!(minify_factor(true, 1.0, 0.46), 2); + assert_eq!(minify_factor(true, 1.0, 0.5), 2); + assert_eq!(minify_factor(true, 1.0, 0.51), 1); + assert_eq!(minify_factor(true, 0.5, 0.46), 1, "a Half frame at 46 %"); + assert_eq!(minify_factor(true, 1.0, 1.0 / 3.0), 3); + assert_eq!(minify_factor(true, 1.0, 2.0), 1); + assert_eq!(minify_factor(false, 1.0, 0.1), 1, "Faster keeps every pixel"); + assert_eq!(minify_factor(true, 1.0, 0.0), 256); + assert_eq!(minify_factor(true, f64::NAN, 1.0), 1); + // 2560 px by 3: 854 texels hold 2562 px, the image is 2560 / 2562 of them. + let uv = crate::frames::fitted_uv([2560, 1440], 3); + assert!((uv.max.x - 2560.0 / 2562.0).abs() < 1e-6 && uv.max.y == 1.0, "{uv:?}"); + assert_eq!(crate::frames::fitted_uv([7, 5], 1).max, egui::pos2(1.0, 1.0)); + let img = + egui::ColorImage::new([5, 1], vec![egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::WHITE, egui::Color32::BLACK, egui::Color32::WHITE]); + let out = crate::frames::fit_texture_by(img, 4096, 2); + assert_eq!(out.size, [3, 1]); + assert_eq!(out.pixels[1], egui::Color32::from_gray(128), "texels average their pixels"); + assert_eq!(out.pixels[2], egui::Color32::WHITE, "the last one only the pixels it holds"); + } } diff --git a/crates/ui-egui/src/panels/viewers.rs b/crates/ui-egui/src/panels/viewers.rs index 57a3511f5..c0c4a4542 100644 --- a/crates/ui-egui/src/panels/viewers.rs +++ b/crates/ui-egui/src/panels/viewers.rs @@ -145,14 +145,14 @@ fn set_texture(app: &mut EffectcraftApp, ctx: &egui::Context, id: u32, key: Fram (FrameImage::Gpu(f), old) => { let Some(rs) = &app.wgpu else { return }; let view = f.texture.create_view(&Default::default()); - let filter = if app.session.prefs.viewer_zoom_smooth() { eframe::wgpu::FilterMode::Linear } else { eframe::wgpu::FilterMode::Nearest }; + let sampler = || super::viewer::zoom_sampler(app.session.prefs.viewer_zoom_smooth()); let mut ren = rs.renderer.write(); let tid = match old { Some((_, PassiveTexture::Gpu(tid, _))) => { - ren.update_egui_texture_from_wgpu_texture(&rs.device, &view, filter, tid); + ren.update_egui_texture_from_wgpu_texture_with_sampler_options(&rs.device, &view, sampler(), tid); tid } - _ => ren.register_native_texture(&rs.device, &view, filter), + _ => ren.register_native_texture_with_sampler_options(&rs.device, &view, sampler()), }; PassiveTexture::Gpu(tid, f) } diff --git a/crates/ui-egui/src/state.rs b/crates/ui-egui/src/state.rs index a7069c2bb..0cce2c113 100644 --- a/crates/ui-egui/src/state.rs +++ b/crates/ui-egui/src/state.rs @@ -252,17 +252,12 @@ impl Resolution { /// Render scale for a viewer magnification (and display pixel density). pub fn scale(self, zoom: f32, ppp: f32) -> f64 { match self { + // As in After Effects, Auto renders the pixels the magnification needs: the coarsest + // whole factor that still gives every screen pixel a rendered one (Full above 50 %, + // Half down to 33.3 %, Third down to 25 %, Quarter below), never fewer (#417). Resolution::Auto => { - let z = (zoom * ppp) as f64; - if z >= 0.75 { - 1.0 - } else if z >= 0.45 { - 0.5 - } else if z >= 0.3 { - 1.0 / 3.0 - } else { - 0.25 - } + let n = (1.0 / (zoom * ppp) as f64 + 1e-3).floor(); + if n >= 1.0 { 1.0 / n.min(4.0) } else { 1.0 } } Resolution::Full => 1.0, Resolution::Half => 0.5, diff --git a/docs/architecture.md b/docs/architecture.md index 7835e395e..33e38215e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -171,6 +171,11 @@ options (Draft / Fast Previews, shadows, nested switches), so frames rendered un 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. +Resolution Auto renders the pixels the magnification needs, as in After Effects (Full above +50 %, Half down to 33.3 %, Third down to 25 %, then Quarter). Below 100 % the viewer averages +instead of skipping pixels (Viewer Zoom Quality More Accurate): GPU frames carry a mip chain +sampled trilinearly, CPU frames go up averaged down by the whole factor that leaves about one +texel per screen pixel. 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 diff --git a/docs/parity.md b/docs/parity.md index 5c4ae138a..c89bee974 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -415,6 +415,10 @@ Fixes from measured reports: - Text layers are always rasterised at their on-screen scale, sharp at any Scale as in After Effects, and an unscaled layer at a fractional position is no longer rasterised a quarter octave too large (#390). +- The viewer's Auto resolution renders the pixels the magnification needs (Full above 50 %), and + frames shown below 100 % are averaged (GPU frames through mip levels, CPU frames by a whole + factor) instead of minified bilinearly (#417). Not yet: the viewer resolution and magnification + are not remembered between launches. ## Update: plug-ins and extensions as in After Effects; ease presets become a ScriptUI panel