mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-14 19:27:05 -04:00
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 <[email protected]>
This commit is contained in:
1 parent
36ef45077a
commit
94e4afe8de
5 files changed
+80
-15
No files matched your search
@@ -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");
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in new issue
Block a user