Merge upstream/main into gap-object-matte

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-12 19:03:42 +02:00
co-authored by Claude Sonnet 5.5
275 changed files with 24783 additions and 1912 deletions
+2 -1
View File
@@ -74,7 +74,8 @@ pub fn audible(project: &Project, l: &Layer) -> bool {
})
}
fn db_to_gain(db: f64) -> f32 {
/// Linear gain of an Audio Levels value in dB; -96 dB and below is silence.
pub fn db_to_gain(db: f64) -> f32 {
if db <= -96.0 { 0.0 } else { 10f64.powf(db / 20.0) as f32 }
}
+17 -1
View File
@@ -15,7 +15,9 @@
//! are linearised just before they are transformed and blended, the comp accumulates in linear
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
//! pixels to their parent). Without a working space the Working Gamma's curve is used (sRGB
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage.
//! at 2.2, a pure 2.4 power curve at 2.4); so is it for Interpret As Linear Light footage
//! and for float footage (OpenEXR, float TIFF), whose linear values are encoded once
//! ([`Pipe::float_curve`]).
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::{Footage, HdrMode, ProjectSettings};
@@ -68,6 +70,20 @@ impl Pipe {
f.color_profile.unwrap_or(if self.space.is_some() { ColorSpace::Srgb } else { self.curve() })
}
/// How float footage `f` (OpenEXR, float TIFF; linear light) is decoded: as stored when
/// Interpret As Linear Light encodes it afterwards (once, with
/// [`Pipe::linear_footage_space`]), else with the Working Gamma's curve when unmanaged and
/// the sRGB curve otherwise (the working space converts from there).
pub fn float_curve(&self, f: &Footage) -> crate::FloatCurve {
if f.linear_light_applies() { crate::FloatCurve::Linear } else { self.float_encoding() }
}
/// The curve float footage is encoded with when nothing interprets it afterwards: the
/// Working Gamma's when unmanaged (sRGB at 2.2, a pure 2.4 power curve at 2.4), else sRGB.
pub fn float_encoding(&self) -> crate::FloatCurve {
if self.space.is_none() && self.gamma24 { crate::FloatCurve::Gamma24 } else { crate::FloatCurve::Srgb }
}
/// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for
/// Preserve RGB, whose values enter the working space as the file stores them.
pub fn media_in(&self, f: &Footage) -> Option<Conversion> {
+157 -22
View File
@@ -23,6 +23,7 @@ pub mod text;
pub mod three_d;
use std::sync::Arc;
use std::sync::atomic::{AtomicBool, Ordering};
pub use auto::{AutoKey, AutoPick};
pub use cache::{CacheStats, LayerCache, LayerStore, PrefetchStore};
@@ -36,6 +37,21 @@ use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
pub use eval::{EvalCtx, ExprHost, effect_bounds, source_size};
use rayon::prelude::*;
/// How a [`FootageSource`] hands over float footage (OpenEXR, float TIFF), whose values are
/// linear light: encoded with a curve, as the compositor takes footage, or as stored. Other
/// footage is decoded the same way whatever it says. Interpret Footage ▸ Preserve RGB always
/// reads the values as stored.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
pub enum FloatCurve {
/// The sRGB curve (managed projects, and Working Gamma 2.2).
#[default]
Srgb,
/// A pure 2.4 power curve (no working space, Working Gamma 2.4).
Gamma24,
/// The linear values as stored (Interpret As Linear Light encodes them afterwards).
Linear,
}
/// Supplies decoded footage frames (implemented by the media layer).
pub trait FootageSource: Send + Sync {
/// The frame of `item` at source time `t`, straight from the file (any size).
@@ -47,6 +63,16 @@ pub trait FootageSource: Send + Sync {
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, _width: u32, _height: u32) -> Option<Arc<Image>> {
self.frame(item, footage, t)
}
/// [`FootageSource::frame`] (`size: None`) or [`FootageSource::frame_at_size`] with float
/// footage handed over as `float` says ([`FootageSource::frame`] uses [`FloatCurve::Srgb`]).
/// Sources without float footage can leave the default, which ignores `float`.
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
let _ = float;
match size {
Some((w, h)) => self.frame_at_size(item, footage, t, w, h),
None => self.frame(item, footage, t),
}
}
/// `frames` interleaved stereo sample frames of `item`'s audio from source time `t` at
/// `rate` Hz (`None` when unavailable). The first sample is `t.to_units_floor(rate)`;
/// a sample boundary uses the earliest representable tick that selects that sample.
@@ -85,6 +111,93 @@ pub trait FootageSource: Send + Sync {
fn vector_frame(&self, _item: ItemId, _footage: &Footage, _scale: f64) -> Option<Arc<Image>> {
None
}
/// Run `f`, which reads frames from this source, as work that can be called off: once
/// `cancel` is set, long decodes it starts (a movie seeking back to a keyframe and decoding
/// forward) stop early and their frames come back `None`. Sources that can't stop a decode
/// just run `f`. See [`CancellableFootage`].
fn cancellable(&self, _cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
f()
}
/// Reads through this source have been called off: frames may have come back `None` that
/// exist, so what was made from them must not be kept.
fn called_off(&self) -> bool {
false
}
/// Why `footage` shows no picture although its file is there: a read of it failed because
/// no decoder handles its codec, profile, chroma format or bit depth, or the stream is damaged
/// (e.g. "H.264 (High 4:2:2) isn't supported by the built-in decoder: ..."). `None` while it
/// decodes, and for sources that don't track failures. Stays set until the file is forgotten.
fn problem(&self, _footage: &Footage) -> Option<String> {
None
}
}
/// A render's view of a [`FootageSource`] whose frame reads can be called off with `cancel`
/// (a viewer frame nobody waits for any more, #329). Every read goes through
/// [`FootageSource::cancellable`] on its own, so reads made for this render on another thread
/// (a parallel loop) answer to this render's flag, and reads for other renders never do.
pub struct CancellableFootage<'a> {
pub inner: &'a dyn FootageSource,
pub cancel: Arc<AtomicBool>,
}
impl CancellableFootage<'_> {
fn read<R>(&self, f: impl FnOnce() -> Option<R>) -> Option<R> {
if self.cancel.load(Ordering::Relaxed) {
return None;
}
let (mut f, mut out) = (Some(f), None);
self.inner.cancellable(&self.cancel, &mut || out = f.take().and_then(|f| f()));
out
}
}
impl FootageSource for CancellableFootage<'_> {
fn frame(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<Image>> {
self.read(|| self.inner.frame(item, footage, t))
}
fn frame_at_size(&self, item: ItemId, footage: &Footage, t: Tick, width: u32, height: u32) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_at_size(item, footage, t, width, height))
}
fn audio(&self, item: ItemId, footage: &Footage, t: Tick, frames: usize, rate: u32) -> Option<Vec<f32>> {
self.inner.audio(item, footage, t, frames, rate)
}
fn aux(&self, item: ItemId, footage: &Footage, t: Tick) -> Option<Arc<effectcraft_raster::AuxChannels>> {
self.inner.aux(item, footage, t)
}
fn model(&self, item: ItemId, footage: &Footage) -> Option<Arc<effectcraft_model::Model>> {
self.inner.model(item, footage)
}
fn set_cache_budget(&self, bytes: usize) {
self.inner.set_cache_budget(bytes)
}
fn purge(&self) {
self.inner.purge()
}
fn forget(&self, path: &str) {
self.inner.forget(path)
}
fn set_conform_folder(&self, folder: Option<std::path::PathBuf>) {
self.inner.set_conform_folder(folder)
}
fn cache_budget(&self) -> Option<usize> {
self.inner.cache_budget()
}
fn vector_frame(&self, item: ItemId, footage: &Footage, scale: f64) -> Option<Arc<Image>> {
self.inner.vector_frame(item, footage, scale)
}
fn cancellable(&self, cancel: &Arc<AtomicBool>, f: &mut dyn FnMut()) {
self.inner.cancellable(cancel, f)
}
fn called_off(&self) -> bool {
self.cancel.load(Ordering::Relaxed) || self.inner.called_off()
}
fn problem(&self, footage: &Footage) -> Option<String> {
self.inner.problem(footage)
}
fn frame_with(&self, item: ItemId, footage: &Footage, t: Tick, size: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
self.read(|| self.inner.frame_with(item, footage, t, size, float))
}
}
/// Footage that can be rasterised at any scale (SVG).
@@ -377,6 +490,16 @@ pub trait FxTarget {
fn gpu(&mut self, steps: &[FxStep]) -> bool;
/// Run one CPU effect: `f` maps the buffer.
fn cpu(&mut self, f: &mut dyn FnMut(Buf) -> Buf);
/// Grow the buffer to cover its whole layer rectangle (`size` layer pixels from its own
/// corner, the `layer_size` handed to effects), as [`effectcraft_effects::Buf::cover`].
/// Authored buffers (text, shapes) only span their painted content; this gives the stack
/// the room a sized source's buffer already has (#659).
fn cover(&mut self, size: [f64; 2]) {
self.cpu(&mut |mut buf| {
buf.cover(size);
buf
});
}
}
/// The CPU effect target (GPU chains go through [`Accelerator::effects`]: upload, readback).
@@ -579,6 +702,12 @@ impl<'a> Renderer<'a> {
}
}
/// The layer cache to keep this render's buffers in: none once its footage reads were
/// called off, as they may have come back empty ([`FootageSource::called_off`], #329).
fn cache_to_keep(&self) -> Option<&'a LayerCache> {
self.cache.filter(|_| !self.footage.called_off())
}
/// A layer's Quality switch, limited by the precomp layers above it when
/// [`RenderOpts::nested_switches`] is on.
pub fn quality(&self, layer: &Layer) -> Quality {
@@ -732,15 +861,14 @@ impl<'a> Renderer<'a> {
Region::Full
}
/// 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).
/// Rasterisation scale of a layer's source: the output scale, or — for text and shape layers,
/// which are always continuously rasterised (#390, #751), and Continuously Rasterize 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 continuous = match layer.source {
LayerSource::Text => true,
LayerSource::Shape => layer.switches.collapse,
LayerSource::Text | LayerSource::Shape => true,
LayerSource::Footage { item } => {
layer.switches.collapse && matches!(self.project.item(item).map(|i| &i.kind), Some(ItemKind::Footage(f)) if is_vector_footage(f))
}
@@ -991,6 +1119,15 @@ impl<'a> Renderer<'a> {
let Some(fx) = layer.effects() else { return };
let lt = layer.layer_time(ctx.time);
let layer_size = ctx.effect_bounds(layer).0;
// #659: an authored layer's buffer spans only its painted content while the stack works
// on the layer's whole region (`layer_size`): give the buffer the room a sized source
// already has, so tiling or displacement can place pixels outside the content instead
// of losing them at its edge. Only when something will actually run, and not for an
// adjustment layer, whose buffer is the comp below and already spans it.
if !adjustment
&& fx.groups().any(|g| g.enabled && matches!(&g.kind, GroupKind::Effect { effect } if effectcraft_effects::find(effect).is_some_and(|s| !effectcraft_effects::audio_fx::is_audio_effect(s.id)))) {
target.cover(layer_size);
}
let mut mask_shapes = masks::shapes(ctx, layer);
if origin != [0.0; 2] {
for q in mask_shapes.iter_mut().flat_map(|m| m.points.iter_mut()) {
@@ -1109,7 +1246,7 @@ impl<'a> Renderer<'a> {
if let Some(pf) = self.proxy_for(*item)
&& let Some(nc) = self.project.comp(*item)
{
let img = self.footage.frame(*item, pf, lt)?;
let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;
return Some(self.footage_buf(&img, pf, nc.width, nc.height));
}
// Essential Properties overrides render the nested comp with this instance's
@@ -1220,7 +1357,7 @@ impl<'a> Renderer<'a> {
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
let i = field_rate.frame_at(t).max(0);
let img = self.footage.frame(item, f, f.frame_rate.tick_of(i / 2))?;
let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?;
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
@@ -1234,6 +1371,7 @@ impl<'a> Renderer<'a> {
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
let mode = frame_blend_mode(ctx, layer);
let float = self.pipe.float_curve(f);
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let (i, w) = frame_position(t, f.frame_rate);
if w > 1e-6 {
@@ -1241,7 +1379,7 @@ impl<'a> Renderer<'a> {
let key = self.cache.map(|_| {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h);
(0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).hash(&mut h);
h.finish()
});
if let (Some(c), Some(k)) = (self.cache, key)
@@ -1249,21 +1387,18 @@ impl<'a> Renderer<'a> {
{
return Some(Arc::new(b.img.clone()));
}
let a = self.footage.frame(item, f, f.frame_rate.tick_of(i));
let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1));
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float);
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
if let (Some(a), Some(b)) = (a, b) {
let img = blend_frames(mode, &a, &b, w as f32);
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }));
}
return Some(Arc::new(img));
}
}
}
match size {
Some((w, h)) => self.footage.frame_at_size(item, f, t, w, h),
None => self.footage.frame(item, f, t),
}
self.footage.frame_with(item, f, t, size, float)
}
/// Source → masks, clamped/quantised to the project depth.
@@ -1304,7 +1439,7 @@ impl<'a> Renderer<'a> {
let mut nb = (*b).clone();
color::convert(&mut nb.img, &c);
let nb = Arc::new(nb);
if let (Some(cache), Some(k)) = (self.cache, key) {
if let (Some(cache), Some(k)) = (self.cache_to_keep(), key) {
cache.insert(k, nb.clone());
}
nb
@@ -1322,7 +1457,7 @@ impl<'a> Renderer<'a> {
return Some((b, key));
}
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, flat.clone());
}
Some((flat, key))
@@ -1353,7 +1488,7 @@ impl<'a> Renderer<'a> {
}
let buf = self.masked_source(ctx, layer)?;
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, buf.clone());
}
Some((buf, key))
@@ -1378,7 +1513,7 @@ impl<'a> Renderer<'a> {
buf = self.effect_space_stack(ctx, layer, buf, false, effects, None, origin);
}
let buf = Arc::new(buf);
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, buf.clone());
}
Some(buf)
@@ -1410,7 +1545,7 @@ impl<'a> Renderer<'a> {
}
let body = Arc::new(st.body);
let passes: Vec<_> = st.passes.into_iter().map(|(b, m)| (Arc::new(b), m)).collect();
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, body.clone());
for (i, (b, _)) in passes.iter().enumerate() {
c.insert(cache::derive(k, i as u64 + 1), b.clone());
@@ -2034,7 +2169,7 @@ impl<'a> Renderer<'a> {
let mut foot = self.source(ctx, layer)?;
let _ = masks::apply(ctx, layer, &mut foot, self.mask_blur(ctx, layer));
let foot = Arc::new(foot);
if let (Some(c), Some(k)) = (self.cache, key) {
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
c.insert(k, foot.clone());
}
Some(foot)
+35 -5
View File
@@ -6,7 +6,9 @@
//! the paths of nested groups;
//! - paint items (fills, strokes, gradient fills/strokes) paint the paths above them, *as modified
//! by every operator in the stack* — a Trim Paths below a Stroke still trims it;
//! - items higher in the list draw on top.
//! - items higher in the list draw on top;
//! - surface items (Front, Bevel, Side, Back) colour those surfaces of the Advanced 3D
//! extrusion of every paint in their group and its nested groups (the innermost wins).
use effectcraft_color::{BlendMode, blend_pixel};
use effectcraft_effects::Buf;
@@ -53,8 +55,14 @@ struct Draw {
xf: Mat3,
opacity: f32,
blend: BlendMode,
/// Front / Bevel / Side / Back colours of the paint's extrusion (Advanced 3D).
surfaces: SurfaceColors,
}
/// Straight RGB colours of an extrusion's surfaces, by [`effectcraft_model::extrude::Surface`]
/// index (Front, Bevel, Side, Back); `None` keeps the paint's colour.
pub type SurfaceColors = [Option<[f32; 3]>; 4];
fn mat_path(path: &BezPath, m: &Mat3) -> BezPath {
effectcraft_path::transform(std::slice::from_ref(path), m).pop().unwrap_or_default()
}
@@ -179,6 +187,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
let mut draws: Vec<Draw> = Vec::new();
let mut live: Vec<usize> = Vec::new();
let opacity = |g: &PropGroup| (ctx.f(layer, g, "opacity", 100.0) / 100.0) as f32;
let mut surfaces: SurfaceColors = [None; 4];
for node in &contents.children {
let Node::Group(g) = node else { continue };
if !g.enabled {
@@ -224,6 +233,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
});
}
"stroke" => {
@@ -236,6 +246,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
});
}
"gfill" | "gstroke" => {
@@ -258,6 +269,7 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
xf: Mat3::IDENTITY,
opacity: opacity(g),
blend: blend_mode(ctx, layer, g),
surfaces: [None; 4],
});
}
"trim" => {
@@ -386,7 +398,24 @@ fn collect(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup, arena: &mut Vec<B
}
}
}
_ => {}
m => {
if let Some(i) = effectcraft_project::build::SHAPE_SURFACES.iter().position(|(s, _)| *s == m)
&& g.get("color").is_some()
&& let Some(slot) = surfaces.get_mut(i)
{
let c = ctx.color(layer, g, "color");
*slot = Some([c[0], c[1], c[2]]);
}
}
}
}
// Surface items colour every paint of the group, wherever they sit in it; nested groups'
// own surface items were applied first and win.
if surfaces.iter().any(Option::is_some) {
for d in &mut draws {
for (s, g) in d.surfaces.iter_mut().zip(surfaces) {
*s = s.or(g);
}
}
}
(draws, live)
@@ -622,8 +651,9 @@ pub fn fill_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<
/// The painted regions of a shape layer for Advanced 3D extrusion, bottom paint first: fills
/// (gradient fills: the colour half way along) and strokes, each stroke turned into the region
/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved).
pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4])> {
/// it covers (outline of the stroke with its width, joins, caps and dashes, overlaps resolved),
/// with the colours of their extrusion's surfaces.
pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) -> Vec<(Vec<BezPath>, [f32; 4], SurfaceColors)> {
let mut arena = Vec::new();
let (draws, _) = collect(ctx, layer, contents, &mut arena);
draws
@@ -641,7 +671,7 @@ pub fn extrusion_outlines(ctx: &EvalCtx, layer: &Layer, contents: &PropGroup) ->
Paint::Stroke { color, style } => (*color, vec![stroke_region(&paths, style)?]),
Paint::Gradient { g, stroke: Some(style), .. } => (g.sample(0.5), vec![stroke_region(&paths, style)?]),
};
Some((region, [c[0], c[1], c[2], c[3] * d.opacity]))
Some((region, [c[0], c[1], c[2], c[3] * d.opacity], d.surfaces))
})
.collect()
}
+96
View File
@@ -135,6 +135,32 @@ fn track_matte_alpha() {
assert!(img.get(150, 50)[3] < 0.01);
}
/// Every matte kind with an opaque white 50×50 matte at the left of a 200×100 comp: the plain
/// kinds keep the fill only inside the matte, the inverted kinds only outside it (#864).
#[test]
fn track_matte_kinds_keep_and_invert() {
for (kind, inside) in [(MatteKind::Alpha, true), (MatteKind::AlphaInverted, false), (MatteKind::Luma, true), (MatteKind::LumaInverted, false)] {
let (mut p, cid, comp) = setup();
let mut matte = solid(&mut p, &comp, [1.0, 1.0, 1.0], 50, 50);
matte.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 50.0, 0.0]);
matte.switches.video = false;
let mut fill = solid(&mut p, &comp, [0.0, 0.0, 1.0], 200, 100);
fill.track_matte = Some(TrackMatte { layer: matte.id, kind });
let c = p.comp_mut(cid).unwrap();
c.layers.push(matte);
c.layers.push(fill);
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
let (a_in, a_out) = (img.get(50, 50)[3], img.get(150, 50)[3]);
if inside {
assert!(a_in > 0.99 && a_out < 0.01, "{kind:?}: {a_in} inside, {a_out} outside");
assert!(img.get(50, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
} else {
assert!(a_in < 0.01 && a_out > 0.99, "{kind:?}: {a_in} inside, {a_out} outside");
assert!(img.get(150, 50)[2] > 0.99, "{kind:?} keeps the fill's colour");
}
}
}
#[test]
fn shape_layer_draws_fill_and_stroke() {
let (mut p, cid, comp) = setup();
@@ -268,6 +294,23 @@ fn render_cached(p: &Project, cid: ItemId, t: Tick, cache: Option<&crate::LayerC
r.comp_frame(cid, t)
}
/// #329: a render whose footage reads were called off keeps nothing in the layer cache: a layer
/// made from a frame that came back empty (an effect's layer parameter) would be reused by the
/// next render of that time.
#[test]
fn a_called_off_render_keeps_nothing_in_the_layer_cache() {
let (p, cid) = cache_scene();
let t = Tick::from_seconds_f64(0.5);
let cache = crate::LayerCache::default();
let off = crate::CancellableFootage { inner: &crate::NoFootage, cancel: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(true)) };
let mut r = crate::Renderer::new(&p, &off, crate::RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, t);
assert_eq!(cache.stats().entries, 0, "a called-off render kept layers");
render_cached(&p, cid, t, Some(&cache));
assert!(cache.stats().entries > 0, "a render that runs to the end keeps its layers");
}
/// [`add_effect`] for a 200×100 layer.
fn add_effect_200(p: &mut Project, l: &mut effectcraft_project::Layer, id: &str, vals: &[(&str, Value)]) {
add_effect(p, l, id, [200.0, 100.0], vals);
@@ -597,6 +640,59 @@ fn default_effect_points_sit_at_the_centre_of_a_shape_layer() {
}
}
/// #659: a tiling effect on a text layer tiles across the layer's whole region. The glyphs
/// were cut off at their own bounding box: the effect computed over the comp-sized layer rect
/// but wrote into the painted (glyph-bounds) buffer alone. Tiling shrinks each copy, so the
/// probe is the x-span of the painted pixels, not their count.
#[test]
fn effects_on_text_reach_past_the_text_bounds() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([50.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let plain = render_frame(&p, cid, Tick::ZERO, 1.0);
let painted = plain.data.iter().filter(|px| px[3] > 0.5).count();
add_effect_200(&mut p, &mut l, "ec.distort.cctiler", &[("scale", Value::Scalar(25.0))]);
p.comp_mut(cid).unwrap().layers = vec![l];
let tiled = render_frame(&p, cid, Tick::ZERO, 1.0);
let (mut lo, mut hi) = (u32::MAX, 0u32);
for y in 0..100 {
for x in 0..200 {
if tiled.get(x as i64, y as i64)[3] > 0.5 {
lo = lo.min(x);
hi = hi.max(x);
}
}
}
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}"
);
}
/// #658: CC 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() {
let (mut p, cid, comp) = setup();
let mut l = build::layer(&mut p, &comp, "Text", LayerSource::Text, (200, 100), None);
l.props.prop_mut("text/sourceText").unwrap().value = Value::Text(Box::new(TextDoc { text: "HI".into(), size: 24.0, ..Default::default() }));
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([60.0, 60.0, 0.0]);
p.comp_mut(cid).unwrap().layers.push(l.clone());
let right = |img: &effectcraft_raster::Image| (0..100).map(|y| (0..200).filter(|x| img.get(*x, y)[3] > 0.5).max().unwrap_or(0)).max().unwrap_or(0);
let plain = right(&render_frame(&p, cid, Tick::ZERO, 1.0));
add_effect_200(
&mut p,
&mut l,
"ec.distort.ccsmear",
&[("from", Value::Vec2([110.0, 45.0])), ("to", Value::Vec2([185.0, 45.0])), ("reach", Value::Scalar(100.0)), ("radius", Value::Scalar(40.0))],
);
p.comp_mut(cid).unwrap().layers = vec![l];
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}");
}
/// A layer parameter pointing at a shape layer sees all of it (in its effect space), and the
/// effect's own masks are in effect space too: Set Matte with a centred shape layer as the
/// matte keeps the centre of a full-comp solid (#227).
+78 -26
View File
@@ -145,36 +145,53 @@ fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize {
(x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count()
}
#[test]
fn continuously_rasterized_shape_stays_sharp_when_scaled_up() {
let run = |cr: bool| {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
/// A 10 px white square in a 200×200 comp, scaled 800% either by its own Scale or by a parent
/// null's, with the Continuously Rasterize switch `cr`.
fn scaled_square(cr: bool, via_parent: bool) -> Image {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
p.next_id = next;
l.props.sub_mut("contents").unwrap().children.push(g.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
l.switches.collapse = cr;
let mut c = comp.clone();
if via_parent {
let mut null = build::layer(&mut p, &comp, "Null", LayerSource::Null, (100, 100), None);
set(&mut null, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut null, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut null, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
set(&mut l, "transform/position", Value::Vec3([0.0, 0.0, 0.0]));
l.parent = Some(null.id);
c.layers = vec![l, null];
} else {
set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0]));
l.switches.collapse = cr;
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 cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
render_frame(&p, cid, Tick::ZERO, 1.0)
}
/// Shape layers are always continuously rasterised, as in After Effects (#751): a shape scaled up
/// by its own transform or a parent's is drawn at its on-screen size, with a 1 px anti-aliased
/// edge, whether or not the Continuously Rasterize switch is on.
#[test]
fn shape_scaled_up_stays_sharp_without_the_switch() {
// The 10 px square scaled 800% spans 60.3..140.3.
let soft = run(false);
let sharp = run(true);
let ws = edge_width(&soft, 100, 50, 75);
let wc = edge_width(&sharp, 100, 50, 75);
assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}");
assert!(wc <= 1, "continuously rasterized edge: {wc}");
assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01);
for cr in [false, true] {
for via_parent in [false, true] {
let img = scaled_square(cr, via_parent);
let w = edge_width(&img, 100, 50, 75);
assert!(w <= 1, "switch {cr}, scaled by parent {via_parent}: edge {w} px wide");
assert!(img.get(100, 100)[3] > 0.99 && img.get(55, 100)[3] < 0.01);
}
}
}
#[test]
@@ -285,3 +302,38 @@ fn unscaled_text_rasterises_at_the_output_scale() {
assert_eq!(k, 1.0, "text at {pos:?}");
}
}
/// Effects on a shape layer run on its continuously rasterised pixels, still in layer space as
/// with the switch off in After Effects: a 10 px square scaled 800% with a 2 px blur looks like
/// an 80 px square with a 16 px blur, not a blurred bitmap enlarged afterwards (#751).
#[test]
fn effects_on_a_scaled_shape_follow_its_scale() {
let run = |size: f64, scale: f64, blur: f64| {
let mut p = project();
let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None);
let mut next = p.next_id;
let mut ids = Ids(&mut next);
let rect = build::shape_rect(&mut ids, [size, size], [0.0, 0.0], 0.0);
let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]);
let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]);
let spec = effectcraft_effects::find("ec.blur.gaussian").unwrap();
let mut fx = effectcraft_effects::instantiate(spec, &mut ids, spec.name, [200.0, 200.0]);
p.next_id = next;
fx.prop_mut("blurriness").unwrap().value = Value::Scalar(blur);
l.props.sub_mut("contents").unwrap().children.push(g.into());
l.props.sub_mut("effects").unwrap().children.push(fx.into());
set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0]));
set(&mut l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
set(&mut l, "transform/scale", Value::Vec3([scale, scale, 100.0]));
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 scaled = run(10.0, 800.0, 2.0);
let native = run(80.0, 100.0, 16.0);
let d = max_diff(&scaled, &native);
assert!(scaled.get(100, 100)[3] > 0.99 && scaled.get(70, 100)[3] > 0.05, "the blur spreads the edge");
assert!(d < 0.05, "scaled blurred shape differs from the native one by {d}");
}
+46 -1
View File
@@ -7,7 +7,7 @@ use effectcraft_project::build;
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
use crate::{FloatCurve, FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
let mut p = Project::default();
@@ -294,3 +294,48 @@ fn working_gamma_24_encodes_linear_footage_with_a_power_curve() {
crate::color::Pipe::of(&s).key()
});
}
/// Float footage (an OpenEXR) the way `effectcraft-media` decodes it: linear values handed over
/// as the renderer asks ([`FloatCurve`]).
struct FloatFile([f32; 3]);
impl FootageSource for FloatFile {
fn frame(&self, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.frame_with(item, f, t, None, FloatCurve::Srgb)
}
fn frame_with(&self, _: ItemId, f: &Footage, _: Tick, _: Option<(u32, u32)>, float: FloatCurve) -> Option<Arc<Image>> {
let c = self.0.map(|v| match float {
FloatCurve::Srgb => ColorSpace::Srgb.encode(v),
FloatCurve::Gamma24 => v.powf(1.0 / 2.4),
FloatCurve::Linear => v,
});
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
}
}
/// #750: with no working space, float footage is encoded with the Working Gamma's curve (as
/// After Effects converts a linear EXR), and Interpret As Linear Light encodes it once, not on
/// top of the decode's own encoding. A working space ignores Working Gamma.
#[test]
fn float_footage_is_encoded_once_with_the_working_gamma() {
let render = |gamma: f64, ws: Option<ColorSpace>, linear_light: bool| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_gamma = gamma;
p.settings.working_space = ws;
let f = Footage { path: "x.exr".into(), linear_light, ..footage(None) };
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = FloatFile([0.051, 0.18, 0.5]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
let near = |o: [f32; 4], want: [f32; 3]| (0..3).all(|i| (o[i] - want[i]).abs() < 2e-3);
for linear_light in [false, true] {
let o = render(2.4, None, linear_light);
assert!(near(o, [0.290, 0.490, 0.749]), "2.4, linear light {linear_light}: {o:?}");
let o = render(2.2, None, linear_light);
assert!(near(o, [0.250, 0.461, 0.735]), "2.2, linear light {linear_light}: {o:?}");
let srgb = render(2.2, Some(ColorSpace::Srgb), linear_light);
assert!(near(srgb, [0.250, 0.461, 0.735]), "sRGB working space, linear light {linear_light}: {srgb:?}");
assert_eq!(render(2.4, Some(ColorSpace::Srgb), linear_light), srgb);
}
}
+29 -3
View File
@@ -58,9 +58,21 @@ pub struct CharXf {
/// Variable Font Axes offsets (axis tag, user units), the first `n_axes` used.
pub axes: [([u8; 4], f64); 4],
pub n_axes: u8,
/// Front / Bevel / Side / Back Color of the extruded character (Advanced 3D), by
/// `effectcraft_model::extrude::Surface` index, and the selection each was set with.
pub surface: [Option<[f32; 4]>; 4],
pub surface_k: [f32; 4],
}
impl CharXf {
/// The surface colours of an extrusion painted `base` (the character's fill or stroke):
/// each animated surface colour blended over `base` by its selection.
pub fn surface_colors(&self, base: [f32; 4]) -> crate::shapes::SurfaceColors {
std::array::from_fn(|i| {
let (c, k) = (self.surface.get(i).copied().flatten()?, self.surface_k.get(i).copied().unwrap_or(0.0));
Some(std::array::from_fn(|d| base[d] + (c[d] - base[d]) * k))
})
}
/// The variable-font axis offsets that move the outline.
pub fn axis_deltas(&self) -> Vec<(String, f32)> {
self.axes[..self.n_axes as usize]
@@ -79,6 +91,10 @@ impl CharXf {
}
}
/// Animator property match ids of Front / Bevel / Side / Back Color, in
/// `effectcraft_model::extrude::Surface::ALL` order.
pub const SURFACE_ANIM_PROPS: [&str; 4] = ["frontColor", "bevelColor", "sideColor", "backColor"];
/// Animator property match id of a Variable Font Axes property (`axis_wght`).
pub const AXIS_PREFIX: &str = "axis_";
@@ -115,6 +131,8 @@ impl Default for CharXf {
blur: [0.0; 2],
axes: [([0; 4], 0.0); 4],
n_axes: 0,
surface: [None; 4],
surface_k: [0.0; 4],
}
}
}
@@ -149,9 +167,10 @@ fn range_params(ctx: &EvalCtx, layer: &Layer, sel: &PropGroup, n: usize) -> Rang
let units_index = adv.map(|a| ctx.e(layer, a, "units") == 1).unwrap_or(false);
let div = if units_index { n.max(1) as f64 } else { 100.0 };
Range {
start: ctx.f(layer, sel, "start", 0.0) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }) / div,
offset: ctx.f(layer, sel, "offset", 0.0) / div,
// Start and End stay within 0..100% (0..n in Index units); Offset within -100..100%.
start: ctx.f(layer, sel, "start", 0.0).clamp(0.0, div) / div,
end: ctx.f(layer, sel, "end", if units_index { n as f64 } else { 100.0 }).clamp(0.0, div) / div,
offset: ctx.f(layer, sel, "offset", 0.0).clamp(-div, div) / div,
amount: af("amount", 100.0) / 100.0,
shape: Shape::from_index(adv.map(|a| ctx.e(layer, a, "shape")).unwrap_or(0)),
smoothness: af("smoothness", 100.0) / 100.0,
@@ -274,6 +293,7 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
let char_range = props.get("characterRange").map(|p| ctx.value(layer, p).as_enum());
let char_align = props.get("characterAlignment").map(|p| ctx.value(layer, p).as_enum());
let blur = props.get("blur").map(|p| ctx.value(layer, p).as_vec2());
let surface = SURFACE_ANIM_PROPS.map(|m| props.get(m).map(|p| ctx.value(layer, p).as_color()));
let axes: Vec<([u8; 4], f64)> = props
.props()
.filter_map(|p| {
@@ -385,6 +405,12 @@ pub fn char_transforms(ctx: &EvalCtx, layer: &Layer, text: &PropGroup, lay: &Tex
for (tag, v) in &axes {
c.add_axis(*tag, v * k);
}
for ((s, sk), v) in c.surface.iter_mut().zip(&mut c.surface_k).zip(surface) {
if let Some(v) = v {
*s = Some(v);
*sk = (*sk + k as f32).clamp(0.0, 1.0);
}
}
}
}
out
+24
View File
@@ -99,6 +99,30 @@ fn range_based_on_words_and_lines() {
assert_eq!(s, vec![0.0, 1.0, 1.0, 0.0, 0.0, 0.0, 0.0], "{s:?}");
}
#[test]
fn range_start_end_and_offset_are_capped() {
let (mut p, cid, comp) = setup(400, 200);
let mut l = text_layer(&mut p, &comp, doc("ABCD", 30.0));
let a = animator(&mut p, &mut l, &["opacity"], &["range"]);
set(&mut l, "text/animators/#1/selectors/#1/advanced/smoothness", Value::Scalar(0.0));
// End 300% is capped at 100% (Offset -150% at -100%): the range is -100%..0% and selects nothing.
// Uncapped it would be -150%..150% and select everything.
set(&mut l, "text/animators/#1/selectors/#1/end", Value::Scalar(300.0));
set(&mut l, "text/animators/#1/selectors/#1/offset", Value::Scalar(-150.0));
p.comp_mut(cid).unwrap().layers.push(l);
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
// Start -200% is capped at 0%: with Offset 100% the range is 100%..150% and selects nothing.
// Uncapped it would be -100%..150% and select everything.
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/start", Value::Scalar(-200.0));
set(l, "text/animators/#1/selectors/#1/end", Value::Scalar(50.0));
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(100.0));
assert_eq!(selection(&p, cid, 0.0, a), vec![0.0; 4]);
let l = &mut p.comp_mut(cid).unwrap().layers[0];
set(l, "text/animators/#1/selectors/#1/offset", Value::Scalar(0.0));
assert_eq!(selection(&p, cid, 0.0, a), vec![1.0, 1.0, 0.0, 0.0]);
}
#[test]
fn selector_modes_and_randomize() {
let (mut p, cid, comp) = setup(400, 200);
+27 -16
View File
@@ -543,10 +543,11 @@ pub fn extrusion_params(ctx: &EvalCtx, layer: &Layer) -> Option<model::extrude::
/// space). Fills and strokes are both extruded (a stroke as the region it covers, bevelled like
/// the fill), stacked in the layer's paint order (see [`model::extrude::extrude_stacked`]).
/// Text extrudes each character in its own space, so per-character 3D animation (position,
/// rotation, scale in Z) carries over.
/// rotation, scale in Z) carries over. A paint whose Front / Bevel / Side / Back are coloured
/// (shape surface items, text animators) comes as one mesh per surface, in that order.
pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams) -> Vec<(Primitive, [f32; 4], Mat4)> {
// (paths, colour, mesh → layer, stacking level)
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4, usize)> = vec![];
// (paths, colour, mesh → layer, stacking level, surface colours)
let mut groups: Vec<(Vec<kurbo::BezPath>, [f32; 4], Mat4, usize, crate::shapes::SurfaceColors)> = vec![];
match layer.source {
LayerSource::Text => {
if let Some(tg) = crate::text::text_geom(ctx, layer) {
@@ -561,13 +562,13 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
let op = (g.xf.opacity / 100.0).clamp(0.0, 1.0) as f32;
if g.apply_fill && g.fill[3] * op > 0.0 {
let c = [g.fill[0], g.fill[1], g.fill[2], g.fill[3] * op];
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top)));
groups.push((vec![g.local.clone()], c, g.m, usize::from(!stroke_on_top), g.xf.surface_colors(c)));
}
if g.stroke_width > 0.0 && g.stroke[3] * op > 0.0 {
let st = effectcraft_path::StrokeStyle { width: g.stroke_width, join: effectcraft_path::Join::Round, ..Default::default() };
if let Some(region) = crate::shapes::stroke_region(std::slice::from_ref(&g.local), &st) {
let c = [g.stroke[0], g.stroke[1], g.stroke[2], g.stroke[3] * op];
groups.push((vec![region], c, g.m, usize::from(stroke_on_top)));
groups.push((vec![region], c, g.m, usize::from(stroke_on_top), g.xf.surface_colors(c)));
}
}
}
@@ -575,22 +576,32 @@ pub fn extruded_meshes(ctx: &EvalCtx, layer: &Layer, p: &model::extrude::Extrude
}
LayerSource::Shape => {
if let Some(c) = layer.props.sub("contents") {
groups = crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c))| (ps, c, Mat4::IDENTITY, k)).collect();
groups =
crate::shapes::extrusion_outlines(ctx, layer, c).into_iter().enumerate().map(|(k, (ps, c, s))| (ps, c, Mat4::IDENTITY, k, s)).collect();
}
}
_ => {}
}
groups
.into_iter()
.filter_map(|(paths, color, m, level)| {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
return None;
let mut out = vec![];
for (paths, color, m, level, surfaces) in groups {
let cs: Vec<Vec<[f64; 2]>> = paths.iter().flat_map(|bp| contours(bp, 0.25)).collect();
let outlines = model::extrude::group_contours(cs);
if outlines.is_empty() {
continue;
}
if surfaces.iter().all(Option::is_none) {
out.push((model::extrude::extrude_stacked(&outlines, p, level), color, m));
continue;
}
for (mesh, s) in model::extrude::extrude_surfaces_stacked(&outlines, p, level).into_iter().zip(surfaces) {
if mesh.indices.is_empty() {
continue;
}
Some((model::extrude::extrude_stacked(&outlines, p, level), color, m))
})
.collect()
let c = s.map_or(color, |s| [s[0], s[1], s[2], color[3]]);
out.push((mesh, c, m));
}
}
out
}
fn extruded_layer(r: &Renderer, ctx: &EvalCtx, layer: &Layer, p: &model::extrude::ExtrudeParams, b: &mut Builder) {
+79
View File
@@ -313,6 +313,85 @@ fn extruded_text_renders_as_a_mesh() {
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
}
#[test]
fn shape_surface_items_colour_the_extruded_surfaces() {
let (mut p, cid) = project(200, 200);
add(&mut p, cid, LayerSource::Shape, |l| {
l.switches.three_d = true;
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let surface = |ids: &mut build::Ids, m: &str, c: [f64; 4]| {
let mut g = build::shape_surface(ids, m).unwrap();
g.children.push(build::shape_surface_prop(ids, "color").unwrap().into());
if let Some(effectcraft_project::Node::Prop(pr)) = g.children.last_mut() {
pr.value = Value::Color(c);
}
g
};
// A white square whose group colours its sides green; the layer's Contents colour every
// front blue and every side red, but the group's own Side wins.
let rect = build::shape_rect(ids, [80.0, 80.0], [0.0, 0.0], 0.0);
let fill = build::shape_fill(ids, [1.0, 1.0, 1.0, 1.0]);
let side = surface(ids, "side", [0.0, 1.0, 0.0, 1.0]);
let group = build::shape_group(ids, "Group 1", vec![rect, fill, side]);
let front = surface(ids, "front", [0.0, 0.0, 1.0, 1.0]);
let outer_side = surface(ids, "side", [1.0, 0.0, 0.0, 1.0]);
let contents = l.props.sub_mut("contents").unwrap();
contents.children.extend([group.into(), front.into(), outer_side.into()]);
l.props.children.push(build::extrusion_geometry_options(ids).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(40.0));
set(l, "transform/position", Value::Vec3([100.0, 100.0, 0.0]));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
// No bevel: Front, Side and Back (the Back keeps the fill's white).
let colours: Vec<[f32; 4]> = meshes.iter().map(|m| m.1).collect();
assert_eq!(colours, [[0.0, 0.0, 1.0, 1.0], [0.0, 1.0, 0.0, 1.0], [1.0, 1.0, 1.0, 1.0]]);
assert!(meshes[0].0.normals.iter().all(|n| n[2] < -0.99), "front cap");
assert!(meshes[1].0.normals.iter().all(|n| n[2].abs() < 1e-6), "walls");
// Rendered (unlit: base colours) at an angle: the blue face and the green side both show.
p.comp_mut(cid).unwrap().layers[0].props.prop_mut("transform/rotationY").unwrap().value = Value::Scalar(50.0);
let img = render(&p, cid);
let blue = img.data.iter().filter(|q| q[3] > 0.99 && q[2] > 0.9 && q[0] < 0.1 && q[1] < 0.1).count();
let green = img.data.iter().filter(|q| q[3] > 0.99 && q[1] > 0.9 && q[0] < 0.1 && q[2] < 0.1).count();
assert!(blue > 200 && green > 50, "blue {blue}, green {green}");
}
#[test]
fn text_animators_colour_the_extruded_surfaces() {
let (mut p, cid) = project(200, 100);
add(&mut p, cid, LayerSource::Text, |l| {
l.switches.three_d = true;
let doc = effectcraft_keyframe::TextDoc { text: "IO".into(), size: 60.0, fill: [1.0, 1.0, 1.0, 1.0], apply_fill: true, ..Default::default() };
set(l, "text/sourceText", Value::Text(Box::new(doc)));
let mut next = 10_000u64;
let ids = &mut build::Ids(&mut next);
let props = ["sideColor", "bevelColor"].iter().flat_map(|k| build::text_anim_props(ids, k, false)).collect();
let anim = build::text_animator(ids, "Animator 1", props);
l.props.group_mut("text/animators").unwrap().children.push(anim.into());
set(l, "text/animators/#1/properties/sideColor", Value::Color([0.0, 1.0, 0.0, 1.0]));
// Half the selection: the bevel colour blends half way over the fill.
set(l, "text/animators/#1/selectors/#1/advanced/amount", Value::Scalar(50.0));
l.props.children.push(build::extrusion_geometry_options(ids).into());
set(l, "geometryOptions/extrusionDepth", Value::Scalar(30.0));
set(l, "geometryOptions/bevelStyle", Value::Enum(1));
});
let ctx = crate::EvalCtx { project: &p, comp_id: cid, comp: p.comp(cid).unwrap(), time: Tick::ZERO, expr: None, footage: None };
let l = &ctx.comp.layers[0];
let params = scene::extrusion_params(&ctx, l).unwrap();
let meshes = scene::extruded_meshes(&ctx, l, &params);
// Two characters × (Front, Bevel, Side, Back).
assert_eq!(meshes.len(), 8);
for m in meshes.chunks(4) {
assert_eq!(m[0].1, [1.0, 1.0, 1.0, 1.0], "front keeps the fill");
assert_eq!(m[1].1, [1.0, 0.5, 0.5, 1.0], "bevel: red half way over white");
assert_eq!(m[2].1, [0.5, 1.0, 0.5, 1.0], "side: green half way over white");
assert_eq!(m[3].1, [1.0, 1.0, 1.0, 1.0], "back keeps the fill");
}
}
// ---------------------------------------------------------------- motion blur, blend modes, mattes
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
+20
View File
@@ -457,9 +457,29 @@ pub struct Views3D {
/// Edited view cameras (views not present use their defaults).
#[serde(default)]
pub cams: BTreeMap<View3D, ViewCam>,
/// The views of the extra panes of a 2- or 4-view layout (pane 1 to 3, i.e. index 0 to 2);
/// panes not present show their default ([`Views3D::DEFAULT_PANES`]).
#[serde(default)]
pub panes: Vec<View3D>,
}
impl Views3D {
/// Views the extra panes of a multi-view layout start with: Top, Front and Right.
pub const DEFAULT_PANES: [View3D; 3] = [View3D::Top, View3D::Front, View3D::Right];
/// The view of extra pane `i` (0 = the first beside the main view).
pub fn pane(&self, i: usize) -> View3D {
self.panes.get(i).or_else(|| Self::DEFAULT_PANES.get(i)).copied().unwrap_or_default()
}
/// Choose the view of extra pane `i` (ignored past the third).
pub fn set_pane(&mut self, i: usize, v: View3D) {
if i >= Self::DEFAULT_PANES.len() {
return;
}
while self.panes.len() <= i {
self.panes.push(Self::DEFAULT_PANES[self.panes.len()]);
}
self.panes[i] = v;
}
pub fn cam(&self, v: View3D, w: f64, h: f64) -> ViewCam {
self.cams.get(&v).copied().unwrap_or_else(|| default_view_cam(v, w, h))
}