mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-31 21:48:20 -06:00
Merge upstream/main into gap-object-matte
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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, ¶ms);
|
||||
// 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, ¶ms);
|
||||
// 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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user