Merge remote-tracking branch 'upstream/main' into rq-readback-3335

# Conflicts:
#	crates/render/src/lib.rs
This commit is contained in:
vangel76
2026-11-09 16:06:16 +01:00
105 changed files with 3208 additions and 987 deletions
+5 -2
View File
@@ -21,10 +21,13 @@ pub enum Backend {
}
impl Backend {
/// The backend `s` names, whatever case it is written in: the project's own values (`cpu`,
/// `gpu`, `auto`), the labels Project Settings ▸ Video Rendering and Effects draws
/// (`GPU Acceleration`, `Software Rendering`) and the older After Effects wording.
pub fn parse(s: &str) -> Option<Backend> {
match s.to_ascii_lowercase().as_str() {
"cpu" | "software" | "mercury software only" => Some(Backend::Cpu),
"gpu" | "mercury gpu acceleration" => Some(Backend::Gpu),
"cpu" | "software" | "mercury software only" | "software rendering" => Some(Backend::Cpu),
"gpu" | "mercury gpu acceleration" | "gpu acceleration" => Some(Backend::Gpu),
"auto" => Some(Backend::Auto),
_ => None,
}
+3 -1
View File
@@ -694,7 +694,9 @@ fn reads_other_times(layer: &Layer, effects: Option<usize>) -> bool {
fx.groups().take(effects.unwrap_or(usize::MAX)).any(|g| {
g.enabled
&& matches!(&g.kind, effectcraft_project::GroupKind::Effect { effect }
if effect.starts_with("ec.time.") || effectcraft_effects::reads_param_history(effect))
if effect.starts_with("ec.time.")
|| effectcraft_effects::reads_param_history(effect)
|| (!effect.starts_with("ec.") && effectcraft_effects::plugin::reads_other_times(effect)))
})
})
}
+27 -4
View File
@@ -76,8 +76,9 @@ pub trait FootageSource: Send + Sync {
fn cache_budget(&self) -> Option<usize> {
None
}
/// Vector footage (SVG) rasterised at `scale` × its pixel size, for Continuously Rasterize.
/// `None` when the footage is not vector or cannot be read.
/// Vector footage (SVG, PDF, Illustrator, EPS: [`is_vector_footage`]) rasterised at `scale` ×
/// its size as [`vector_fit`] draws it, for Continuously Rasterize. `None` when the footage
/// is not vector or cannot be read.
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
None
}
@@ -216,9 +217,31 @@ impl FootageSource for CancellableFootage<'_> {
}
}
/// Footage that can be rasterised at any scale (SVG).
/// Footage that can be rasterised at any scale: SVG, PDF, Illustrator and EPS files, and a
/// placed page of an After Effects project (its codec is not stored there).
pub fn is_vector_footage(f: &Footage) -> bool {
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS")
matches!(f.codec.as_str(), "SVG" | "PDF" | "AI" | "EPS") || f.placed_page
}
/// How vector footage `f` is drawn at `scale` × its size: the image size, and the x and y scale
/// from document pixels (`doc` = the document's width and height, `own` = its pixel size, the
/// size rounded up) to image pixels.
///
/// Footage from an After Effects project ([`Footage::placed_page`]) fills its stored size, which
/// can round or measure the page differently: the layer's anchor and masks are measured against
/// that size, and the renderer resamples a reduced-resolution frame to it, so drawn at its own
/// size the art was scaled by the width ratio alone at full resolution (off against the masks)
/// and stretched to the stored aspect below 3/4 resolution (#3099). Other footage is drawn 1:1 at
/// the document's own size, as it always was.
pub fn vector_fit(f: &Footage, doc: [f64; 2], own: (u32, u32), scale: f64) -> ((u32, u32), [f64; 2]) {
let scale = if scale.is_finite() && scale > 0.0 { scale } else { 1.0 };
let side = |px: u32| (f64::from(px) * scale).ceil().clamp(1.0, f64::from(u32::MAX)) as u32;
let size = (f.width, f.height);
let fills = f.placed_page && size != own && size.0 > 0 && size.1 > 0 && doc.iter().all(|d| d.is_finite() && *d > 0.0);
if !fills {
return ((side(own.0), side(own.1)), [scale, scale]);
}
((side(size.0), side(size.1)), [scale * f64::from(size.0) / doc[0], scale * f64::from(size.1) / doc[1]])
}
/// No footage available (renders footage layers as transparent).
+1 -1
View File
@@ -47,7 +47,7 @@ mod opts;
mod pipeline;
pub use accel::{Accelerator, Backend, FrameMemory, FxStep, FxTarget};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem};
pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage, missing_item_problem, vector_fit};
pub use frames::{
adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path,
render_frame, roi_inside,
+32 -11
View File
@@ -329,7 +329,7 @@ fn card_dance_comp_camera_matches_the_default_view() {
assert!(img.get(10, 50)[3] < 0.01);
}
/// A black 2D solid with CC Particle World, under a camera layer panned right by `pan` px.
/// A black 2D solid with Particle Emitter 3D, under a camera layer panned right by `pan` px.
fn particle_world_scene(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut l = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
@@ -359,7 +359,7 @@ fn particles_x(img: &crate::Image) -> f32 {
sx / m
}
/// #397: CC Particle World on a 2D layer is seen through the comp's camera layer, and the layer
/// #397: Particle Emitter 3D on a 2D layer is seen through the comp's camera layer, and the layer
/// cache notices the camera moving.
#[test]
fn particle_world_follows_the_comp_camera() {
@@ -374,7 +374,7 @@ fn particle_world_follows_the_comp_camera() {
assert!(b < a - 20.0, "panning the camera right moves the particles left: {a} -> {b}");
}
/// A black solid with CC Particle World whose Birth Rate is 3 until layer time `cut`, then 0 a
/// A black solid with Particle Emitter 3D whose Birth Rate is 3 until layer time `cut`, then 0 a
/// frame later (`None`: 3 throughout); the layer starts at comp time `start`, stretched `stretch` %.
fn burst_scene(cut: Option<f64>, start: f64, stretch: f64) -> (Project, ItemId) {
burst_scene_keys(cut, start, stretch, false)
@@ -624,7 +624,7 @@ fn a_text_expression_selector_is_not_served_from_the_disk_cache() {
/// A Birth Rate expression reading another layer: changing that layer changes the history but
/// not the current rate (0) nor this layer's properties, which is all the layer cache key sees.
/// CC Particle World layers with expressions therefore aren't layer cached.
/// Particle Emitter 3D layers with expressions therefore aren't layer cached.
#[test]
fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cache() {
let scene = |opacity: f64| {
@@ -657,8 +657,8 @@ fn particle_world_with_a_birth_rate_expression_is_not_served_from_the_layer_cach
assert!(light(&b) > 1.0 && light(&b) < light(&a), "fewer births: {} vs {}", light(&b), light(&a));
}
/// A grey solid (CC Mr. Mercury shades its blobs from it) with effect `id` (CC Particle Systems
/// II, CC Mr. Mercury): Birth Rate `rate`,
/// A grey solid (Liquid Particles shades its blobs from it) with effect `id` (CC Particle Systems
/// II, Liquid Particles): Birth Rate `rate`,
/// or keyed 3 until 0.5 s and 0 a frame later (`None`); Longevity 1 s.
fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
@@ -677,7 +677,7 @@ fn system_scene(id: &str, rate: Option<f64>) -> (Project, ItemId) {
(p, cid)
}
/// #1373 for CC Particle Systems II and CC Mr. Mercury through the renderer: a keyed burst
/// #1373 for Particle Emitter 2D and Liquid Particles through the renderer: a keyed burst
/// draws as the constant rate during it, still draws after the rate drops to 0 (unlike a rate
/// of 0 throughout), and draws as nothing born once Longevity has run out.
#[test]
@@ -1057,6 +1057,27 @@ fn comp_frame_ends_the_frame_when_the_render_panics() {
assert_eq!(a.ends.load(Ordering::SeqCst), 1);
}
/// The renderer labels Project Settings ▸ Video Rendering and Effects draws parse back as the
/// backend they name (in any case), and the older spellings still work.
#[test]
fn renderer_labels_parse_back_and_keep_the_older_spellings() {
use crate::Backend;
for (label, backend) in [
("GPU Acceleration", Backend::Gpu),
("gpu acceleration", Backend::Gpu),
("Mercury GPU Acceleration", Backend::Gpu),
("gpu", Backend::Gpu),
("Software Rendering", Backend::Cpu),
("software rendering", Backend::Cpu),
("Mercury Software Only", Backend::Cpu),
("cpu", Backend::Cpu),
("Auto", Backend::Auto),
] {
assert_eq!(Backend::parse(label), Some(backend), "{label}");
}
assert_eq!(Backend::parse("Vulkan"), None);
}
#[test]
fn auto_backend_sends_2d_and_3d_comps_to_the_gpu() {
let (mut p, cid, comp) = setup();
@@ -1223,11 +1244,11 @@ fn effects_on_text_reach_past_the_text_bounds() {
}
assert!(
painted > 50 && hi.saturating_sub(lo) > 100,
"CC Tiler tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
"Scaled Tiles tiles the glyphs across the layer region, not just its text bounds: {painted} painted, x-span {lo}..{hi}"
);
}
/// #658: CC Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// #658: Directional Smear drags text glyphs beyond the text layer's own bounds. Pixels smeared past
/// the painted rectangle used to fall off its edge, so the liquid trail never appeared.
#[test]
fn cc_smear_trails_text_past_its_bounds() {
@@ -1246,7 +1267,7 @@ fn cc_smear_trails_text_past_its_bounds() {
);
p.comp_mut(cid).unwrap().layers = vec![l].into();
let smeared = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
assert!(plain > 40 && smeared > plain + 25, "CC Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
assert!(plain > 40 && smeared > plain + 25, "Directional Smear trails the glyphs past the text bounds: right edge {plain} → {smeared}");
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
@@ -1502,7 +1523,7 @@ fn solo_excludes_other_layers_outside_the_soloed_layers_range() {
assert_eq!(second.get(100, 50), [0.0, 0.0, 1.0, 1.0], "only B inside its range");
}
/// #1252: a layer blended with a CC Particle World layer, under a camera panned by `pan` px.
/// #1252: a layer blended with a Particle Emitter 3D layer, under a camera panned by `pan` px.
fn scene_reader_dependency(pan: f64) -> (Project, ItemId) {
let (mut p, cid, comp) = setup();
let mut source = solid(&mut p, &comp, [0.0, 0.0, 0.0], 200, 100);
+89
View File
@@ -1,7 +1,10 @@
//! Time effects through the renderer: neighbouring frames via `EffectHost::self_at`, and the
//! layer cache staying correct while scrubbing.
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_effects::plugin::{EffectPlugin, PluginFrame, PluginManifest, PluginParams, register_plugin};
use effectcraft_keyframe::{Keyframe, ShapePath, Value};
use effectcraft_project::build::{self, Ids};
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, MaskMode, Project, Solid};
@@ -157,3 +160,89 @@ fn input_keys_ignore_later_effects() {
set(&mut p, "effects/#1/blurriness", 8.0);
assert_ne!(key(&p).0, one);
}
/// A plug-in that reads the layer's neighbouring frames (WebAssembly ABI 2) makes its layer's
/// cache key depend on the layer time, even when the source and every property are static. A
/// plug-in that does not keeps the old key (#1977: a static layer stays cached across a scrub).
#[test]
fn abi2_plugin_layers_key_on_layer_time() {
struct NeighbourReader {
m: PluginManifest,
}
impl EffectPlugin for NeighbourReader {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> {
Ok(())
}
fn reads_other_times(&self) -> bool {
true
}
}
struct Plain {
m: PluginManifest,
}
impl EffectPlugin for Plain {
fn manifest(&self) -> &PluginManifest {
&self.m
}
fn render(&self, _frame: &mut PluginFrame, _params: &PluginParams, _time: f64) -> Result<(), String> {
Ok(())
}
}
/// A static white solid with one plug-in effect, registered by `plugin`.
fn plugin_scene(plugin: Arc<dyn EffectPlugin>) -> (Project, ItemId) {
let mut p = Project::default();
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: [1.0, 1.0, 1.0], width: 200, height: 100, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (200, 100), None);
let spec = register_plugin(plugin).unwrap();
let mut next = p.next_id;
let g = effectcraft_effects::instantiate(spec, &mut Ids(&mut next), spec.name, [200.0, 100.0]);
p.next_id = next;
l.props.sub_mut("effects").unwrap().children.push(g.into());
p.comp_mut(cid).unwrap().layers.push(l);
(p, cid)
}
fn key(p: &Project, cid: ItemId, t: f64) -> Option<u64> {
let comp = p.comp(cid).unwrap();
let ctx = crate::EvalCtx {
project: p,
comp_id: cid,
comp,
time: Tick::from_seconds_f64(t),
expr: None,
footage: None,
property_overrides: &[],
media_overrides: &[],
reads: None,
};
crate::cache::layer_key(&ctx, &comp.layers[0], 1.0, false, false)
}
let manifest = |id: &str| PluginManifest {
api: 2,
id: id.into(),
name: "Key".into(),
category: "Test Plug-ins".into(),
version: "1".into(),
author: String::new(),
description: String::new(),
params: vec![],
};
let (plain, c1) = plugin_scene(Arc::new(Plain { m: manifest("org.test.render.time-key.plain") }));
assert_eq!(key(&plain, c1, 0.25), key(&plain, c1, 0.5), "a plain plug-in keeps the old key");
let (reader, c2) = plugin_scene(Arc::new(NeighbourReader { m: manifest("org.test.render.time-key.reader") }));
assert_ne!(key(&reader, c2, 0.25), key(&reader, c2, 0.5), "an ABI 2 plug-in keys on the layer time");
}