mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-27 11:37:02 -06:00
Merge main into text editing (M9.10)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -329,6 +329,7 @@ mod tests {
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
};
|
||||
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
|
||||
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));
|
||||
|
||||
@@ -288,9 +288,13 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
|
||||
hash_debug(&mut h, f);
|
||||
item.hash(&mut h);
|
||||
ctx.source_time(layer).0.hash(&mut h);
|
||||
// Frame blending mixes neighbouring source frames.
|
||||
ctx.comp.enable_frame_blending.hash(&mut h);
|
||||
}
|
||||
_ => return None,
|
||||
}
|
||||
// Bit depth and colour management change the pixels.
|
||||
crate::color::Pipe::of(&ctx.project.settings).key().hash(&mut h);
|
||||
ctx.comp_id.hash(&mut h);
|
||||
ctx.comp.width.hash(&mut h);
|
||||
ctx.comp.height.hash(&mut h);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Project colour settings in the compositor: bit depth, working space, linear blending.
|
||||
//!
|
||||
//! * **Bit depth.** Pixels are `f32` throughout, but 8 and 16 bpc projects behave like integer
|
||||
//! pipelines: each layer's pixels are clamped to 0..1 and quantised to the depth after its
|
||||
//! source and masks and after every effect, and the comp is clamped and quantised after every
|
||||
//! layer is blended. Over-range values (Add/Screen of bright layers, Exposure…) therefore only
|
||||
//! survive in 32 bpc. 16 bpc uses After Effects' 0..32768 range.
|
||||
//! * **Working space** (colour management). Footage is converted from its colour profile (its
|
||||
//! metadata, else sRGB) into the working space; the top-level comp output is converted from
|
||||
//! the working space to the sRGB display. Without a working space nothing is converted.
|
||||
//! * **Linearize Working Space**: the working space is linear light, so sources (footage and
|
||||
//! the colours of solids, text and shapes, which are authored in the working space's encoding)
|
||||
//! are linearised before masks and effects, and everything runs in linear.
|
||||
//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels
|
||||
//! 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 sRGB curve is used.
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::ProjectSettings;
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// The colour pipeline of a project (see the module docs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Pipe {
|
||||
/// Quantisation levels (255, 32768) or `None` for 32 bpc float.
|
||||
pub levels: Option<f32>,
|
||||
/// Working space (`None` = unmanaged).
|
||||
pub space: Option<ColorSpace>,
|
||||
/// The working space is linear.
|
||||
pub linear: bool,
|
||||
/// Blend in linear light (and the working space is not already linear).
|
||||
pub linear_blend: bool,
|
||||
}
|
||||
|
||||
impl Pipe {
|
||||
pub fn of(s: &ProjectSettings) -> Pipe {
|
||||
let linear = s.linearize && s.working_space.is_some();
|
||||
Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear }
|
||||
}
|
||||
|
||||
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
|
||||
fn curve(&self) -> ColorSpace {
|
||||
self.space.unwrap_or(ColorSpace::Srgb)
|
||||
}
|
||||
|
||||
/// Footage with colour profile `profile` (`None` = sRGB) → working space.
|
||||
pub fn media_in(&self, profile: Option<ColorSpace>) -> Option<Conversion> {
|
||||
let ws = self.space?;
|
||||
Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear)
|
||||
}
|
||||
|
||||
/// Authored colours (solids, text, shapes) → working space: linearised in a linear working
|
||||
/// space.
|
||||
pub fn authored_in(&self) -> Option<Conversion> {
|
||||
self.linear.then(|| Conversion::linearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Working space → blending space (linear when blending with 1.0 gamma).
|
||||
pub fn to_blend(&self) -> Option<Conversion> {
|
||||
self.linear_blend.then(|| Conversion::linearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Blending space → working space.
|
||||
pub fn from_blend(&self) -> Option<Conversion> {
|
||||
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Working space → sRGB display (top-level comp output).
|
||||
pub fn output(&self) -> Option<Conversion> {
|
||||
let ws = self.space?;
|
||||
Conversion::new(ws, self.linear, ColorSpace::Srgb, false)
|
||||
}
|
||||
|
||||
/// Clamp and quantise to the bit depth (no-op in 32 bpc).
|
||||
pub fn quantize(&self, img: &mut Image) {
|
||||
if let Some(l) = self.levels {
|
||||
quantize(img, l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash of everything that changes pixels (for cache keys).
|
||||
pub fn key(&self) -> u64 {
|
||||
let mut k = self.levels.map_or(0, |l| l as u64);
|
||||
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
|
||||
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
|
||||
k
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps.
|
||||
pub fn quantize(img: &mut Image, levels: f32) {
|
||||
let inv = 1.0 / levels;
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
let a = (p[3].clamp(0.0, 1.0) * levels).round() * inv;
|
||||
p[3] = a;
|
||||
for c in 0..3 {
|
||||
p[c] = (p[c].clamp(0.0, a) * levels).round() * inv;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Convert premultiplied pixels (on straight colour).
|
||||
pub fn convert(img: &mut Image, c: &Conversion) {
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
let a = p[3];
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let o = c.apply([p[0] / a, p[1] / a, p[2] / a]);
|
||||
*p = [o[0] * a, o[1] * a, o[2] * a, a];
|
||||
});
|
||||
}
|
||||
+418
-41
@@ -8,6 +8,7 @@
|
||||
|
||||
pub mod audio;
|
||||
pub mod cache;
|
||||
pub mod color;
|
||||
pub mod eval;
|
||||
pub mod masks;
|
||||
pub mod shapes;
|
||||
@@ -20,11 +21,11 @@ use std::sync::Arc;
|
||||
pub use cache::{CacheStats, LayerCache};
|
||||
use effectcraft_color::BlendMode;
|
||||
use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params};
|
||||
use effectcraft_geom::{Mat3, vec2};
|
||||
use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
|
||||
use effectcraft_geom::{Mat3, Mat4, vec2};
|
||||
use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling};
|
||||
pub use effectcraft_raster::Image;
|
||||
use effectcraft_raster::{WarpOpts, composite_warp};
|
||||
use effectcraft_time::Tick;
|
||||
use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick};
|
||||
pub use eval::{EvalCtx, ExprHost, source_size};
|
||||
use rayon::prelude::*;
|
||||
|
||||
@@ -64,7 +65,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
let sub = self.r.nested();
|
||||
// The cached layer buffer is shared; effects get their own copy.
|
||||
let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
|
||||
let size = source_size(self.r.project, other);
|
||||
@@ -77,7 +78,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
|
||||
return None;
|
||||
}
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
let sub = self.r.nested();
|
||||
let buf = (*sub.layer_input(self.ctx, other, 0)?).clone();
|
||||
let size = source_size(self.r.project, other);
|
||||
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
|
||||
@@ -101,7 +102,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
}
|
||||
let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed));
|
||||
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
let sub = self.r.nested();
|
||||
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
|
||||
}
|
||||
|
||||
@@ -111,7 +112,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
|
||||
return None;
|
||||
}
|
||||
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
|
||||
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
|
||||
let sub = self.r.nested();
|
||||
let buf = if masks_and_effects { (*sub.content_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() };
|
||||
let size = source_size(self.r.project, other);
|
||||
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
|
||||
@@ -138,11 +139,14 @@ pub struct RenderOpts {
|
||||
pub draft: bool,
|
||||
/// 3D view camera override (viewer Front/Top/Custom views); None = the comp's active camera.
|
||||
pub view: Option<three_d::CameraState>,
|
||||
/// Region of interest `[x, y, w, h]` in comp pixels (viewer only): the top-level frame covers
|
||||
/// just this rectangle, and only its pixels are composited.
|
||||
pub roi: Option<[f64; 4]>,
|
||||
}
|
||||
|
||||
impl Default for RenderOpts {
|
||||
fn default() -> Self {
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None }
|
||||
RenderOpts { scale: 1.0, motion_blur: true, guides: false, draft: false, view: None, roi: None }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,6 +161,26 @@ pub struct Renderer<'a> {
|
||||
pub profile: Option<&'a std::sync::Mutex<Vec<LayerTiming>>>,
|
||||
/// Current nesting depth (precomp recursion guard).
|
||||
depth: usize,
|
||||
/// The project's colour pipeline (bit depth, working space, linear blending).
|
||||
pub(crate) pipe: color::Pipe,
|
||||
/// Collapsed precomp being drawn into its parent: nested comp pixels → parent comp pixels
|
||||
/// (before the output scale).
|
||||
outer: Option<Mat3>,
|
||||
/// Opacity of the collapsed precomp layers this comp is drawn through.
|
||||
opacity_mul: f32,
|
||||
/// Collapsed precomp: nested 3D layers join the parent's 3D space.
|
||||
pub(crate) collapse3d: Option<Collapse3d<'a>>,
|
||||
}
|
||||
|
||||
/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`).
|
||||
#[derive(Clone, Copy)]
|
||||
pub(crate) struct Collapse3d<'a> {
|
||||
/// Nested comp world → parent world (the precomp layer's world transform).
|
||||
pub world: Mat4,
|
||||
/// The parent's camera.
|
||||
pub cam: three_d::CameraState,
|
||||
/// The outermost comp's context (its lights light the nested layers).
|
||||
pub parent: EvalCtx<'a>,
|
||||
}
|
||||
|
||||
/// Time spent on one layer of one frame (milliseconds).
|
||||
@@ -176,7 +200,139 @@ pub struct LayerTiming {
|
||||
|
||||
impl<'a> Renderer<'a> {
|
||||
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
|
||||
Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 }
|
||||
Renderer {
|
||||
project,
|
||||
footage,
|
||||
expr: None,
|
||||
opts,
|
||||
cache: None,
|
||||
profile: None,
|
||||
depth: 0,
|
||||
pipe: color::Pipe::of(&project.settings),
|
||||
outer: None,
|
||||
opacity_mul: 1.0,
|
||||
collapse3d: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
|
||||
fn nested(&self) -> Renderer<'a> {
|
||||
Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self }
|
||||
}
|
||||
|
||||
/// The nested comp of a precomp layer whose transformations collapse into this comp: the
|
||||
/// Collapse Transformations switch is on and the layer has no masks, effects or layer
|
||||
/// styles (those force a flattened render of the precomp, as in After Effects).
|
||||
pub(crate) fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<ItemId> {
|
||||
if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 {
|
||||
return None;
|
||||
}
|
||||
let LayerSource::Comp { item } = &layer.source else { return None };
|
||||
if self.project.comp_contains(*item, ctx.comp_id) || self.project.comp(*item).is_none() {
|
||||
return None;
|
||||
}
|
||||
if layer.masks().is_some_and(|m| !m.children.is_empty()) {
|
||||
return None;
|
||||
}
|
||||
if layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) {
|
||||
return None;
|
||||
}
|
||||
if styles::active(ctx, layer) {
|
||||
return None;
|
||||
}
|
||||
Some(*item)
|
||||
}
|
||||
|
||||
/// A renderer and context that draw a collapsed precomp's layers straight into this comp:
|
||||
/// their transforms are concatenated with the precomp layer's (one resample, no
|
||||
/// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their
|
||||
/// 3D layers use this comp's camera and lights.
|
||||
pub(crate) fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> {
|
||||
let nc = self.project.comp(item)?;
|
||||
let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.source_time(layer), ..*ctx };
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let outer = Some(self.outer.map_or(l2c, |o| o * l2c));
|
||||
let world = ctx.world_matrix(layer);
|
||||
let c3 = Collapse3d {
|
||||
world: self.collapse3d.map_or(world, |c| c.world * world),
|
||||
cam: three_d::compose::camera_for(self, ctx),
|
||||
parent: self.collapse3d.map_or(*ctx, |c| c.parent),
|
||||
};
|
||||
Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx))
|
||||
}
|
||||
|
||||
pub(crate) fn opacity_mul(&self) -> f32 {
|
||||
self.opacity_mul
|
||||
}
|
||||
|
||||
/// Draw a collapsed 2D precomp layer: nested layers blend straight into `canvas` with their
|
||||
/// own modes (Normal precomp, no matte); otherwise they are drawn into an isolated buffer
|
||||
/// that then takes the precomp layer's track matte, Preserve Transparency and blend mode.
|
||||
fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) {
|
||||
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
|
||||
if opacity <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some());
|
||||
if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency {
|
||||
if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) {
|
||||
sub.draw_comp(&nctx, canvas);
|
||||
}
|
||||
return;
|
||||
}
|
||||
let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return };
|
||||
let mut iso = Image::new(canvas.width, canvas.height);
|
||||
sub.draw_comp(&nctx, &mut iso);
|
||||
self.composite_iso(ctx, layer, iso, canvas, opacity);
|
||||
}
|
||||
|
||||
/// Rasterisation scale of a layer's source: the output scale, or — for Continuously
|
||||
/// Rasterize text and shape layers — the output scale times the layer's on-screen scale
|
||||
/// (quarter-octave steps, rounded up, so vector content is drawn at its final size and
|
||||
/// stays sharp when scaled up).
|
||||
pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 {
|
||||
let s = self.opts.scale;
|
||||
if !layer.switches.collapse || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) {
|
||||
return s;
|
||||
}
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let m = self.outer.map_or(l2c, |o| o * l2c);
|
||||
let a = layer.transform().map(|tr| ctx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]);
|
||||
let p0 = m.apply(vec2(a[0], a[1]));
|
||||
let px = m.apply(vec2(a[0] + 1.0, a[1]));
|
||||
let py = m.apply(vec2(a[0], a[1] + 1.0));
|
||||
let k = ((px.x - p0.x).hypot(px.y - p0.y)).max((py.x - p0.x).hypot(py.y - p0.y));
|
||||
if !k.is_finite() || k <= 1e-6 {
|
||||
return s;
|
||||
}
|
||||
let mut k = 2f64.powf((k.log2() * 4.0).ceil() / 4.0).clamp(1.0 / 16.0, 64.0);
|
||||
// Keep the buffer within about 16 million pixels.
|
||||
if let Some(b) = content_bounds(ctx, layer) {
|
||||
let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s;
|
||||
k = k.min((16.0e6 / area).sqrt().max(1.0));
|
||||
}
|
||||
s * k
|
||||
}
|
||||
|
||||
/// Draw a Wireframe-quality layer: its bounds as a one-pixel outline.
|
||||
fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let Some(b) = content_bounds(ctx, layer) else { return };
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c);
|
||||
let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y)));
|
||||
let (w, h) = (canvas.width as i64, canvas.height as i64);
|
||||
for i in 0..4 {
|
||||
let (p, q) = (c[i], c[(i + 1) % 4]);
|
||||
let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize;
|
||||
for j in 0..=n {
|
||||
let t = j as f64 / n as f64;
|
||||
let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64);
|
||||
if x >= 0 && y >= 0 && x < w && y < h {
|
||||
canvas.set(x as u32, y as u32, [1.0, 1.0, 1.0, 1.0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
|
||||
@@ -189,11 +345,49 @@ impl<'a> Renderer<'a> {
|
||||
let s = self.opts.scale;
|
||||
let w = ((comp.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((comp.height as f64 * s).round() as u32).max(1);
|
||||
// Region of interest on a comp with 3D layers: render the frame and crop it.
|
||||
if self.depth == 0
|
||||
&& let Some(r) = self.opts.roi
|
||||
&& comp.has_3d()
|
||||
{
|
||||
let full = Renderer { opts: RenderOpts { roi: None, ..self.opts }, ..*self }.comp_frame(comp_id, t);
|
||||
let (x0, y0) = ((r[0] * s).round().max(0.0) as u32, (r[1] * s).round().max(0.0) as u32);
|
||||
let (rw, rh) = (((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1));
|
||||
let mut out = Image::new(rw, rh);
|
||||
for y in 0..rh.min(full.height.saturating_sub(y0)) {
|
||||
for x in 0..rw.min(full.width.saturating_sub(x0)) {
|
||||
out.data[(y * rw + x) as usize] = full.data[((y + y0) * full.width + x + x0) as usize];
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
let (w, h) = match self.roi_offset() {
|
||||
Some((_, _, rw, rh)) => (rw, rh),
|
||||
None => (w, h),
|
||||
};
|
||||
let mut canvas = Image::new(w, h);
|
||||
if self.depth > 16 {
|
||||
return canvas;
|
||||
}
|
||||
let ctx = self.ctx(comp_id, comp, t);
|
||||
self.draw_comp(&ctx, &mut canvas);
|
||||
if let Some(c) = self.pipe.from_blend() {
|
||||
color::convert(&mut canvas, &c);
|
||||
}
|
||||
self.pipe.quantize(&mut canvas);
|
||||
// The top-level comp leaves the working space for the (sRGB) display / output.
|
||||
if self.depth == 0
|
||||
&& let Some(c) = self.pipe.output()
|
||||
{
|
||||
color::convert(&mut canvas, &c);
|
||||
self.pipe.quantize(&mut canvas);
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// Draw a comp's layers (bottom to top) into `canvas` (blending space).
|
||||
fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image) {
|
||||
let (comp, t) = (ctx.comp, ctx.time);
|
||||
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
|
||||
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
|
||||
let visible: Vec<&Layer> = comp
|
||||
@@ -209,17 +403,18 @@ impl<'a> Renderer<'a> {
|
||||
while j < visible.len() && visible[j].is_3d() {
|
||||
j += 1;
|
||||
}
|
||||
three_d::compose::draw_run(self, &ctx, &visible[i..j], &mut canvas);
|
||||
three_d::compose::draw_run(self, ctx, &visible[i..j], canvas);
|
||||
i = j;
|
||||
} else {
|
||||
self.draw_layer(&ctx, visible[i], &mut canvas);
|
||||
match self.collapsed(ctx, visible[i]) {
|
||||
Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas),
|
||||
None => self.draw_layer(ctx, visible[i], canvas),
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
// 8/16 bpc: the comp is an integer buffer after every layer.
|
||||
self.pipe.quantize(canvas);
|
||||
}
|
||||
if !self.project.settings.bit_depth.is_float() {
|
||||
canvas.clamp01();
|
||||
}
|
||||
canvas
|
||||
}
|
||||
|
||||
/// Evaluate an effect instance's parameters.
|
||||
@@ -271,6 +466,8 @@ impl<'a> Renderer<'a> {
|
||||
let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
|
||||
let t0 = web_time::Instant::now();
|
||||
buf = effectcraft_effects::apply(spec, &ectx, buf);
|
||||
// 8/16 bpc effects write integer pixels.
|
||||
self.pipe.quantize(&mut buf.img);
|
||||
if let Some(v) = timing.as_deref_mut() {
|
||||
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
|
||||
}
|
||||
@@ -289,15 +486,30 @@ impl<'a> Renderer<'a> {
|
||||
if layer.switches.adjustment {
|
||||
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
|
||||
}
|
||||
let c = sol.color;
|
||||
let mut c = sol.color;
|
||||
if let Some(conv) = self.pipe.authored_in() {
|
||||
c = conv.apply(c);
|
||||
}
|
||||
Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s })
|
||||
}
|
||||
LayerSource::Comp { item } => {
|
||||
if self.project.comp_contains(*item, ctx.comp_id) {
|
||||
return None;
|
||||
}
|
||||
let sub = Renderer { depth: self.depth + 1, ..*self };
|
||||
let sub = self.nested();
|
||||
let lt = ctx.source_time(layer);
|
||||
// Frame blending between the nested comp's frames (time-stretched/remapped).
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
if mode != FrameBlend::Off
|
||||
&& let Some(nc) = self.project.comp(*item)
|
||||
{
|
||||
let (i, w) = frame_position(lt, nc.frame_rate);
|
||||
if w > 1e-6 {
|
||||
let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i));
|
||||
let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1));
|
||||
return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: s });
|
||||
}
|
||||
}
|
||||
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
|
||||
}
|
||||
LayerSource::Footage { item } => {
|
||||
@@ -307,40 +519,123 @@ impl<'a> Renderer<'a> {
|
||||
return None;
|
||||
}
|
||||
let lt = ctx.source_time(layer);
|
||||
let img = self.footage.frame(*item, f, lt)?;
|
||||
let img = self.footage_frame(ctx, layer, *item, f, lt)?;
|
||||
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
|
||||
// Keep native pixels when downsampling is small; resample otherwise.
|
||||
if s < 0.75 {
|
||||
let mut buf = if s < 0.75 {
|
||||
let w = ((f.width as f64 * s).round() as u32).max(1);
|
||||
let h = ((f.height as f64 * s).round() as u32).max(1);
|
||||
return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s });
|
||||
Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }
|
||||
} else {
|
||||
Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }
|
||||
};
|
||||
if let Some(c) = self.pipe.media_in(f.color_profile) {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k })
|
||||
Some(buf)
|
||||
}
|
||||
LayerSource::Text => Some(text::render(ctx, layer, s)),
|
||||
LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)),
|
||||
LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))),
|
||||
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Authored colours into the working space (linear working spaces).
|
||||
fn authored(&self, mut buf: Buf) -> Buf {
|
||||
if let Some(c) = self.pipe.authored_in() {
|
||||
color::convert(&mut buf.img, &c);
|
||||
}
|
||||
buf
|
||||
}
|
||||
|
||||
/// A footage frame at source time `t`, frame-blended between the two nearest source frames
|
||||
/// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and
|
||||
/// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping).
|
||||
fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let mode = frame_blend_mode(ctx, layer);
|
||||
if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
|
||||
let (i, w) = frame_position(t, f.frame_rate);
|
||||
if w > 1e-6 {
|
||||
// Blended frames are cached (Pixel Motion is expensive).
|
||||
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);
|
||||
h.finish()
|
||||
});
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
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));
|
||||
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) {
|
||||
c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 }));
|
||||
}
|
||||
return Some(Arc::new(img));
|
||||
}
|
||||
}
|
||||
}
|
||||
self.footage.frame(item, f, t)
|
||||
}
|
||||
|
||||
/// Source → masks, clamped/quantised to the project depth.
|
||||
fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
self.pipe.quantize(&mut buf.img);
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// Fully processed layer buffer (source → masks → effects → layer styles, flattened).
|
||||
/// Served from the layer cache when the layer's content key matches.
|
||||
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
self.layer_buf_keyed(ctx, layer).map(|(b, _)| b)
|
||||
}
|
||||
|
||||
/// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma).
|
||||
pub(crate) fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
let (b, k) = self.layer_buf_keyed(ctx, layer)?;
|
||||
Some(self.to_blend(b, k))
|
||||
}
|
||||
|
||||
/// A buffer converted to the blending space (cached under a key derived from `key`).
|
||||
pub(crate) fn to_blend(&self, b: Arc<Buf>, key: Option<u64>) -> Arc<Buf> {
|
||||
let Some(c) = self.pipe.to_blend() else { return b };
|
||||
let key = key.map(|k| cache::derive(k, 0x1ea7));
|
||||
if let (Some(cache), Some(k)) = (self.cache, key)
|
||||
&& let Some(x) = cache.get(k)
|
||||
{
|
||||
return x;
|
||||
}
|
||||
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) {
|
||||
cache.insert(k, nb.clone());
|
||||
}
|
||||
nb
|
||||
}
|
||||
|
||||
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
let st = self.styled(ctx, layer, None)?;
|
||||
if st.passes.is_empty() {
|
||||
return Some(st.body);
|
||||
return Some((st.body, st.key));
|
||||
}
|
||||
let key = st.key.map(|k| cache::derive(k, 0xf1a7));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
return Some(b);
|
||||
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) {
|
||||
c.insert(k, flat.clone());
|
||||
}
|
||||
Some(flat)
|
||||
Some((flat, key))
|
||||
}
|
||||
|
||||
/// A layer's source pixels at the context time, before masks, effects and the transform
|
||||
@@ -356,7 +651,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft));
|
||||
let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
@@ -365,8 +660,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
return Some((b, key));
|
||||
}
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
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) {
|
||||
c.insert(k, buf.clone());
|
||||
@@ -378,14 +672,13 @@ impl<'a> Renderer<'a> {
|
||||
/// (what Time effects read at neighbouring times). Served from / stored in the layer cache
|
||||
/// under its own key, so scrubbing reuses frames rendered for earlier output frames.
|
||||
pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option<Arc<Buf>> {
|
||||
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.opts.scale, self.opts.draft, effects));
|
||||
let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
return Some(b);
|
||||
}
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
let mut buf = self.masked_source(ctx, layer)?;
|
||||
if effects > 0 {
|
||||
buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None);
|
||||
}
|
||||
@@ -400,7 +693,7 @@ impl<'a> Renderer<'a> {
|
||||
fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<StyledLayer> {
|
||||
let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?;
|
||||
if !styles::active(ctx, layer) {
|
||||
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true });
|
||||
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true });
|
||||
}
|
||||
let modes = styles::pass_modes(ctx, layer);
|
||||
let key = ckey.map(|k| cache::styles_key(ctx, layer, k));
|
||||
@@ -409,7 +702,7 @@ impl<'a> Renderer<'a> {
|
||||
{
|
||||
let passes: Option<Vec<_>> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect();
|
||||
if let Some(passes) = passes {
|
||||
return Some(StyledLayer { passes, body, content, key, plain: false });
|
||||
return Some(StyledLayer { passes, body, content, key, ckey, plain: false });
|
||||
}
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
@@ -428,11 +721,25 @@ impl<'a> Renderer<'a> {
|
||||
c.insert(cache::derive(k, i as u64 + 1), b.clone());
|
||||
}
|
||||
}
|
||||
Some(StyledLayer { passes, body, content, key, plain: false })
|
||||
Some(StyledLayer { passes, body, content, key, ckey, plain: false })
|
||||
}
|
||||
|
||||
/// The styled layer's buffers in the blending space.
|
||||
fn styled_to_blend(&self, st: StyledLayer) -> StyledLayer {
|
||||
if self.pipe.to_blend().is_none() {
|
||||
return st;
|
||||
}
|
||||
let body = self.to_blend(st.body, st.key);
|
||||
let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) };
|
||||
let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect();
|
||||
StyledLayer { passes, body, content, ..st }
|
||||
}
|
||||
|
||||
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
|
||||
if self.opts.draft || layer.switches.quality == Quality::Draft {
|
||||
if layer.switches.quality == Quality::Draft {
|
||||
// Draft quality: no interpolation (nearest neighbour), as After Effects' Draft.
|
||||
effectcraft_raster::Sampling::Nearest
|
||||
} else if self.opts.draft {
|
||||
effectcraft_raster::Sampling::Bilinear
|
||||
} else if layer.switches.sampling == Sampling::Bicubic {
|
||||
effectcraft_raster::Sampling::Bicubic
|
||||
@@ -441,11 +748,35 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
}
|
||||
|
||||
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
|
||||
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
|
||||
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
let r = self.opts.roi.filter(|_| self.depth == 0)?;
|
||||
let s = self.opts.scale;
|
||||
if r[2] <= 0.0 || r[3] <= 0.0 {
|
||||
return None;
|
||||
}
|
||||
Some(((r[0] * s).round(), (r[1] * s).round(), ((r[2] * s).round() as u32).max(1), ((r[3] * s).round() as u32).max(1)))
|
||||
}
|
||||
|
||||
/// Scaled comp pixel → output pixel: the region of interest's offset.
|
||||
fn out_matrix(&self) -> Mat3 {
|
||||
match self.roi_offset() {
|
||||
Some((x, y, _, _)) => Mat3::translate(vec2(-x, -y)),
|
||||
None => Mat3::IDENTITY,
|
||||
}
|
||||
}
|
||||
|
||||
/// Buffer pixel → output pixel matrix for the layer at the context time.
|
||||
fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 {
|
||||
let s = self.opts.scale;
|
||||
let (l2c, _) = ctx.layer_to_comp(layer);
|
||||
Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
let l2c = self.outer.map_or(l2c, |o| o * l2c);
|
||||
self.out_matrix()
|
||||
* Mat3::scale(vec2(s, s))
|
||||
* l2c
|
||||
* Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale))
|
||||
* Mat3::translate(vec2(-buf.offset[0], -buf.offset[1]))
|
||||
}
|
||||
|
||||
/// Draw a processed layer into `target` (transform, motion blur) with `mode`/`opacity`.
|
||||
@@ -477,7 +808,11 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let opacity = ctx.opacity(layer) as f32;
|
||||
let opacity = ctx.opacity(layer) as f32 * self.opacity_mul;
|
||||
if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment {
|
||||
self.draw_wireframe(ctx, layer, canvas);
|
||||
return;
|
||||
}
|
||||
if opacity <= 0.0 && !layer.switches.adjustment {
|
||||
return;
|
||||
}
|
||||
@@ -488,6 +823,7 @@ impl<'a> Renderer<'a> {
|
||||
let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() });
|
||||
let t0 = web_time::Instant::now();
|
||||
let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return };
|
||||
let st = self.styled_to_blend(st);
|
||||
let t1 = web_time::Instant::now();
|
||||
if st.plain {
|
||||
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
|
||||
@@ -595,8 +931,16 @@ impl<'a> Renderer<'a> {
|
||||
masks::apply(ctx, layer, &mut foot);
|
||||
let mut matte = Image::new(canvas.width, canvas.height);
|
||||
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
|
||||
let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
|
||||
let adjusted = self.apply_effects(ctx, layer, below, true);
|
||||
let o = self.roi_offset().map(|(x, y, _, _)| [-x, -y]).unwrap_or([0.0; 2]);
|
||||
let mut below = Buf { img: canvas.clone(), offset: o, scale: self.opts.scale };
|
||||
// Effects run in the working space, not the linear blending space.
|
||||
if let Some(c) = self.pipe.from_blend() {
|
||||
color::convert(&mut below.img, &c);
|
||||
}
|
||||
let mut adjusted = self.apply_effects(ctx, layer, below, true);
|
||||
if let Some(c) = self.pipe.to_blend() {
|
||||
color::convert(&mut adjusted.img, &c);
|
||||
}
|
||||
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
|
||||
return;
|
||||
}
|
||||
@@ -615,12 +959,37 @@ struct StyledLayer {
|
||||
body: Arc<Buf>,
|
||||
/// Pre-style pixels (knockout shape).
|
||||
content: Arc<Buf>,
|
||||
/// Styled cache key.
|
||||
/// Styled cache key (the content key when plain).
|
||||
key: Option<u64>,
|
||||
/// Content cache key.
|
||||
ckey: Option<u64>,
|
||||
/// No layer styles: composite `body` the usual way.
|
||||
plain: bool,
|
||||
}
|
||||
|
||||
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
|
||||
fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend {
|
||||
if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off }
|
||||
}
|
||||
|
||||
/// Position of source time `t` among frames at `rate`: the frame at or before `t` and the
|
||||
/// fraction (0..1) of the way to the next frame. Exact (integer tick arithmetic), so frames that
|
||||
/// line up exactly report a fraction of 0.
|
||||
pub fn frame_position(t: Tick, rate: FrameRate) -> (i64, f64) {
|
||||
let num = t.0 as i128 * rate.num as i128;
|
||||
let den = TICKS_PER_SECOND as i128 * rate.den as i128;
|
||||
(num.div_euclid(den) as i64, num.rem_euclid(den) as f64 / den as f64)
|
||||
}
|
||||
|
||||
/// Blend two neighbouring frames `w` of the way from `a` to `b`: a cross-fade (Frame Mix) or a
|
||||
/// motion-compensated interpolation (Pixel Motion).
|
||||
pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image {
|
||||
match mode {
|
||||
FrameBlend::PixelMotion => effectcraft_raster::flow::interpolate(a, b, w),
|
||||
_ => effectcraft_raster::flow::mix(a, b, w),
|
||||
}
|
||||
}
|
||||
|
||||
/// Convenience: render one frame of a comp with no footage source.
|
||||
pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image {
|
||||
Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t)
|
||||
@@ -631,6 +1000,8 @@ mod tests;
|
||||
#[cfg(test)]
|
||||
mod tests_remap;
|
||||
#[cfg(test)]
|
||||
mod tests_roi;
|
||||
#[cfg(test)]
|
||||
mod tests_styles;
|
||||
|
||||
/// Layer-space bounds `[x0, y0, x1, y1]` of a layer's content at the context time (source size
|
||||
@@ -659,6 +1030,12 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
|
||||
#[cfg(test)]
|
||||
mod tests_audio_fx;
|
||||
#[cfg(test)]
|
||||
mod tests_collapse;
|
||||
#[cfg(test)]
|
||||
mod tests_color;
|
||||
#[cfg(test)]
|
||||
mod tests_frame_blend;
|
||||
#[cfg(test)]
|
||||
mod tests_paint;
|
||||
#[cfg(test)]
|
||||
mod tests_time;
|
||||
|
||||
@@ -72,6 +72,28 @@ fn group_matrix(ctx: &EvalCtx, layer: &Layer, tr: &PropGroup) -> (Mat3, f32) {
|
||||
(m, (ctx.f(layer, tr, "opacity", 100.0) / 100.0) as f32)
|
||||
}
|
||||
|
||||
/// Shape item (a Path, Rectangle… inside nested Shape groups) → layer space: the product of the
|
||||
/// enclosing groups' transforms. `None` when `uid` is not in the layer's contents.
|
||||
pub fn item_matrix(ctx: &EvalCtx, layer: &Layer, uid: u64) -> Option<Mat3> {
|
||||
fn walk(ctx: &EvalCtx, layer: &Layer, g: &PropGroup, uid: u64, m: Mat3) -> Option<Mat3> {
|
||||
for sub in g.groups() {
|
||||
if sub.uid == uid {
|
||||
return Some(m);
|
||||
}
|
||||
if sub.match_id == "group"
|
||||
&& let Some(inner) = sub.sub("contents")
|
||||
{
|
||||
let gm = sub.sub("transform").map(|t| group_matrix(ctx, layer, t).0).unwrap_or(Mat3::IDENTITY);
|
||||
if let Some(r) = walk(ctx, layer, inner, uid, m * gm) {
|
||||
return Some(r);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
walk(ctx, layer, layer.props.sub("contents")?, uid, Mat3::IDENTITY)
|
||||
}
|
||||
|
||||
fn rule(ctx: &EvalCtx, layer: &Layer, g: &PropGroup) -> FillRule {
|
||||
if ctx.e(layer, g, "rule") == 1 { FillRule::EvenOdd } else { FillRule::NonZero }
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ use crate::render_frame;
|
||||
|
||||
fn setup() -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
(p, cid, comp)
|
||||
|
||||
@@ -50,6 +50,7 @@ fn audio_footage() -> Footage {
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
//! Collapse Transformations, Continuously Rasterize and the Quality switch.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{Image, render_frame};
|
||||
|
||||
fn project() -> Project {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
p
|
||||
}
|
||||
|
||||
fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), pos: [f64; 3]) -> Layer {
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos);
|
||||
l
|
||||
}
|
||||
|
||||
fn set(l: &mut Layer, path: &str, v: Value) {
|
||||
l.props.prop_mut(path).unwrap().value = v;
|
||||
}
|
||||
|
||||
fn max_diff(a: &Image, b: &Image) -> f32 {
|
||||
a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max)
|
||||
}
|
||||
|
||||
/// Outer 200×100 comp: a grey background, and either a precomp (scaled 150%, rotated) of two
|
||||
/// solids — the top one in Add mode — or the same two solids parented to a null that carries the
|
||||
/// precomp layer's transform (the manual flattening).
|
||||
fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) {
|
||||
let mut p = project();
|
||||
let outer = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut a = solid(&mut p, &inner, [0.6, 0.1, 0.1], (40, 40), [40.0, 50.0, 0.0]);
|
||||
let mut b = solid(&mut p, &inner, [0.1, 0.3, 0.6], (40, 40), [60.0, 50.0, 0.0]);
|
||||
b.blend_mode = BlendMode::Add;
|
||||
let bg = solid(&mut p, &outer, [0.3, 0.3, 0.3], (200, 100), [100.0, 50.0, 0.0]);
|
||||
let xf = |l: &mut Layer| {
|
||||
set(l, "transform/anchor", Value::Vec3([50.0, 50.0, 0.0]));
|
||||
set(l, "transform/position", Value::Vec3([100.0, 50.0, 0.0]));
|
||||
set(l, "transform/scale", Value::Vec3([150.0, 150.0, 100.0]));
|
||||
set(l, "transform/rotation", Value::Scalar(20.0));
|
||||
};
|
||||
let cid;
|
||||
if flatten {
|
||||
let mut null = build::layer(&mut p, &outer, "Null", LayerSource::Null, (100, 100), None);
|
||||
xf(&mut null);
|
||||
a.parent = Some(null.id);
|
||||
b.parent = Some(null.id);
|
||||
let mut c = outer.clone();
|
||||
c.layers = vec![null, b, a, bg];
|
||||
cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
} else {
|
||||
let mut inner = inner;
|
||||
inner.layers = vec![b, a];
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None);
|
||||
xf(&mut pre);
|
||||
pre.switches.collapse = collapse;
|
||||
let mut c = outer.clone();
|
||||
c.layers = vec![pre, bg];
|
||||
cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
}
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_precomp_equals_manual_flattening() {
|
||||
let (p, cid) = scene(false, true);
|
||||
let flat = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let (p, cid) = scene(true, false);
|
||||
let collapsed = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert!(max_diff(&flat, &collapsed) < 1e-4, "collapsed differs from flattening by {}", max_diff(&flat, &collapsed));
|
||||
// Without collapsing, Add only sees the precomp's own layers (not the grey below), and the
|
||||
// precomp is resampled twice.
|
||||
let (p, cid) = scene(false, false);
|
||||
let nested = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert!(max_diff(&flat, &nested) > 0.2);
|
||||
// Opacity of the collapsed layer fades every nested layer.
|
||||
let (mut p, cid) = scene(true, false);
|
||||
set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/opacity", Value::Scalar(50.0));
|
||||
let half = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let (x, y) = (100, 50);
|
||||
let (bg, full) = (0.3, collapsed.get(x, y)[0]);
|
||||
assert!((half.get(x, y)[0] - (bg + (full - bg) * 0.5)).abs() < 0.03, "{:?} vs {full}", half.get(x, y));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn masks_on_a_collapsed_layer_force_a_flattened_render() {
|
||||
let (mut p, cid) = scene(true, false);
|
||||
let (p2, cid2) = scene(false, false);
|
||||
// Add a mask covering everything: the layer renders like an uncollapsed precomp.
|
||||
let mut next = p.next_id;
|
||||
let mask = build::mask(
|
||||
&mut Ids(&mut next),
|
||||
"Mask 1",
|
||||
effectcraft_keyframe::ShapePath::rect([50.0, 50.0], 4000.0, 4000.0),
|
||||
effectcraft_project::MaskMode::Add,
|
||||
[255, 255, 0],
|
||||
);
|
||||
p.next_id = next;
|
||||
p.comp_mut(cid).unwrap().layers[0].props.sub_mut("masks").unwrap().children.push(mask.into());
|
||||
let a = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
let b = render_frame(&p2, cid2, Tick::ZERO, 1.0);
|
||||
assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_3d_layers_join_the_parent_scene() {
|
||||
// Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a
|
||||
// blue 3D solid at z = −200 (nearer the camera). Collapsed, depth sorting puts blue in front;
|
||||
// uncollapsed, the precomp is one plane at z = 0 drawn below the red.
|
||||
let run = |collapse: bool| {
|
||||
let mut p = project();
|
||||
let outer = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let inner = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]);
|
||||
blue.switches.three_d = true;
|
||||
let mut inner = inner;
|
||||
inner.layers = vec![blue];
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None);
|
||||
pre.switches.three_d = true;
|
||||
pre.switches.collapse = collapse;
|
||||
let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]);
|
||||
red.switches.three_d = true;
|
||||
let mut c = outer;
|
||||
c.layers = vec![red, pre];
|
||||
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100)
|
||||
};
|
||||
let c = run(true);
|
||||
assert!(c[2] > 0.99 && c[0] < 0.01, "collapsed: blue in front {c:?}");
|
||||
let n = run(false);
|
||||
assert!(n[0] > 0.99 && n[2] < 0.01, "uncollapsed: red on top {n:?}");
|
||||
}
|
||||
|
||||
/// Pixels along row `y` from `x0` to `x1` with partial coverage (an edge's width).
|
||||
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]));
|
||||
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)
|
||||
};
|
||||
// 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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wireframe_quality_draws_the_layer_bounds() {
|
||||
let mut p = project();
|
||||
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]);
|
||||
l.switches.quality = Quality::Wireframe;
|
||||
let mut c = comp;
|
||||
c.layers = vec![l];
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert_eq!(img.get(50, 50)[3], 0.0, "no fill");
|
||||
assert_eq!(img.get(30, 50), [1.0, 1.0, 1.0, 1.0], "outline");
|
||||
// Draft quality samples nearest-neighbour: no partial pixels at a half-pixel offset.
|
||||
let mut p = project();
|
||||
let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]);
|
||||
l.switches.quality = Quality::Draft;
|
||||
let mut c = comp;
|
||||
c.layers = vec![l];
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into()));
|
||||
let img = render_frame(&p, cid, Tick::ZERO, 1.0);
|
||||
assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0));
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Bit depth, working space and linear blending.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::{BlendMode, ColorSpace, Label};
|
||||
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};
|
||||
|
||||
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = depth;
|
||||
let comp = Comp::new(40, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
(p, cid, comp)
|
||||
}
|
||||
|
||||
fn add_solid(p: &mut Project, cid: ItemId, color: [f32; 3], mode: BlendMode, opacity: f64) {
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: 40, height: 20, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, &comp, "S", LayerSource::Solid { item: sid }, (40, 20), None);
|
||||
l.blend_mode = mode;
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(opacity);
|
||||
// New layers go on top.
|
||||
p.comp_mut(cid).unwrap().layers.insert(0, l);
|
||||
}
|
||||
|
||||
fn px(p: &Project, cid: ItemId) -> [f32; 4] {
|
||||
render_frame(p, cid, Tick::ZERO, 1.0).get(10, 10)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_clamps_in_8_and_16_but_not_32_bpc() {
|
||||
for (depth, want) in [(BitDepth::Bpc8, 1.0), (BitDepth::Bpc16, 1.0), (BitDepth::Bpc32, 1.6)] {
|
||||
let (mut p, cid, _) = setup(depth);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
|
||||
let v = px(&p, cid)[0];
|
||||
assert!((v - want).abs() < 1e-5, "{depth:?}: {v}");
|
||||
}
|
||||
// Screen of over-range values survives in 32 bpc only: a 1.6 base screened with 0.5.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
|
||||
add_solid(&mut p, cid, [0.5, 0.5, 0.5], BlendMode::Screen, 100.0);
|
||||
let v = px(&p, cid)[0];
|
||||
assert!((v - (1.6 + 0.5 - 0.8)).abs() < 1e-5, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantization_steps_per_depth() {
|
||||
let want = |depth| match depth {
|
||||
BitDepth::Bpc8 => (0.3f32 * 255.0).round() / 255.0,
|
||||
BitDepth::Bpc16 => (0.3f32 * 32768.0).round() / 32768.0,
|
||||
BitDepth::Bpc32 => 0.3,
|
||||
};
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
|
||||
let (mut p, cid, _) = setup(depth);
|
||||
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
|
||||
let v = px(&p, cid)[0];
|
||||
assert!((v - want(depth)).abs() < 1e-7, "{depth:?}: {v} vs {}", want(depth));
|
||||
}
|
||||
// 8 bpc: 77/255 = 0.30196 (one step is 1/255); 16 bpc is within 1/65536.
|
||||
let (mut p8, c8, _) = setup(BitDepth::Bpc8);
|
||||
add_solid(&mut p8, c8, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
|
||||
assert!((px(&p8, c8)[0] - 77.0 / 255.0).abs() < 1e-7);
|
||||
// A 1% opacity layer of white over black lands on a whole 8-bit step.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc8);
|
||||
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 1.0);
|
||||
let v = px(&p, cid)[0] * 255.0;
|
||||
assert!((v - v.round()).abs() < 1e-4 && (v - 2.55).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_blending_of_red_over_green() {
|
||||
// 50% red over green: gamma blending gives (0.5, 0.5, 0); 1.0-gamma blending mixes linear
|
||||
// light, so each channel is the sRGB encoding of 0.5 linear ≈ 0.735.
|
||||
let run = |linear: bool| {
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.blend_linear = linear;
|
||||
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
|
||||
px(&p, cid)
|
||||
};
|
||||
let g = run(false);
|
||||
assert!((g[0] - 0.5).abs() < 1e-5 && (g[1] - 0.5).abs() < 1e-5 && g[2].abs() < 1e-6, "{g:?}");
|
||||
let l = run(true);
|
||||
let e = effectcraft_color::linear_to_srgb(0.5);
|
||||
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4 && l[2].abs() < 1e-6, "{l:?}");
|
||||
assert!((e - 0.7354).abs() < 1e-3);
|
||||
// Unblended (opaque) layers are unchanged by linear blending.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.blend_linear = true;
|
||||
add_solid(&mut p, cid, [0.25, 0.5, 0.75], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 0.25).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.75).abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linearized_working_space_blends_linear_and_round_trips_colours() {
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
|
||||
let l = px(&p, cid);
|
||||
let e = effectcraft_color::linear_to_srgb(0.5);
|
||||
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4, "{l:?}");
|
||||
// An opaque solid shows its own colour (linearised in, encoded out).
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [0.2, 0.5, 0.9], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 0.2).abs() < 1e-4 && (o[1] - 0.5).abs() < 1e-4 && (o[2] - 0.9).abs() < 1e-4, "{o:?}");
|
||||
// Linearize needs a working space (like AE, where it is disabled without one).
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
|
||||
assert!((px(&p, cid)[0] - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_working_space_colours_leave_the_display_gamut() {
|
||||
// Pure Rec. 2020 red is outside sRGB: in 32 bpc the display conversion keeps the
|
||||
// over-range/negative values; 8 bpc clamps them.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Rec2020);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!(o[0] > 1.0 && o[1] < 0.0 && o[2] < 0.0, "{o:?}");
|
||||
p.settings.bit_depth = BitDepth::Bpc8;
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 1.0).abs() < 1e-6 && o[1] == 0.0 && o[2] == 0.0, "{o:?}");
|
||||
// sRGB working space: no conversion at all.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
add_solid(&mut p, cid, [0.3, 0.6, 0.9], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 0.3).abs() < 1e-6 && (o[2] - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// One flat-colour footage frame.
|
||||
struct Flat([f32; 3]);
|
||||
impl FootageSource for Flat {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
let c = self.0;
|
||||
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
|
||||
}
|
||||
}
|
||||
|
||||
fn footage(profile: Option<ColorSpace>) -> Footage {
|
||||
Footage {
|
||||
path: "x.png".into(),
|
||||
kind: FootageKind::Still,
|
||||
width: 40,
|
||||
height: 20,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
native_rate: None,
|
||||
duration: Tick::ZERO,
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Ignore,
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_profile_is_converted_into_the_working_space() {
|
||||
let render = |ws: Option<ColorSpace>, profile: Option<ColorSpace>| {
|
||||
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = ws;
|
||||
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(footage(profile)));
|
||||
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 = Flat([1.0, 0.0, 0.0]);
|
||||
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
|
||||
};
|
||||
// Unmanaged: the file's values pass straight through.
|
||||
let o = render(None, Some(ColorSpace::Rec2020));
|
||||
assert_eq!(o, [1.0, 0.0, 0.0, 1.0]);
|
||||
// Managed: Rec. 2020 red shown on an sRGB display is beyond sRGB red.
|
||||
let o = render(Some(ColorSpace::Srgb), Some(ColorSpace::Rec2020));
|
||||
assert!(o[0] > 1.0 && o[1] < 0.0, "{o:?}");
|
||||
// Untagged footage is sRGB: a round trip through a Rec. 2020 working space.
|
||||
let o = render(Some(ColorSpace::Rec2020), None);
|
||||
assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_cache_is_keyed_by_colour_settings() {
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc8);
|
||||
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
|
||||
let cache = LayerCache::default();
|
||||
let frame = |p: &Project| {
|
||||
let mut r = Renderer::new(p, &crate::NoFootage, RenderOpts::default());
|
||||
r.cache = Some(&cache);
|
||||
r.comp_frame(cid, Tick::ZERO).get(10, 10)[0]
|
||||
};
|
||||
let a = frame(&p);
|
||||
assert!((a - 77.0 / 255.0).abs() < 1e-7);
|
||||
assert_eq!(cache.stats().entries, 1);
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let b = frame(&p);
|
||||
assert!((b - 0.3).abs() < 1e-7, "stale 8 bpc pixels: {b}");
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
let _ = frame(&p);
|
||||
assert_eq!(cache.stats().entries, 3, "each setting renders its own entry");
|
||||
// Back to 8 bpc reuses the first entry.
|
||||
p.settings.bit_depth = BitDepth::Bpc8;
|
||||
p.settings.working_space = None;
|
||||
p.settings.linearize = false;
|
||||
let hits = cache.stats().hits;
|
||||
assert!((frame(&p) - a).abs() < 1e-9);
|
||||
assert_eq!(cache.stats().hits, hits + 1);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
//! Frame blending (Frame Mix and Pixel Motion) for footage and precomp layers.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rayon::prelude::*;
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_keyframe::{Keyframe, Value};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{FootageSource, Image, RenderOpts, Renderer, frame_position};
|
||||
|
||||
#[test]
|
||||
fn frame_mix_weights_for_24_to_30_conform() {
|
||||
// Comp frame k at 30 fps sits at source frame 0.8·k of 24 fps footage.
|
||||
let want = [(0, 0.0), (0, 0.8), (1, 0.6), (2, 0.4), (3, 0.2), (4, 0.0), (4, 0.8)];
|
||||
for (k, (i, w)) in want.into_iter().enumerate() {
|
||||
let t = FrameRate::FPS_30.tick_of(k as i64);
|
||||
let (fi, fw) = frame_position(t, FrameRate::FPS_24);
|
||||
assert_eq!(fi, i, "comp frame {k}");
|
||||
assert!((fw - w).abs() < 1e-12, "comp frame {k}: {fw} vs {w}");
|
||||
}
|
||||
// NTSC 29.97 footage in a 29.97 comp lines up exactly.
|
||||
for k in [0, 1, 7, 1001, 30_000] {
|
||||
assert_eq!(frame_position(FrameRate::FPS_29_97.tick_of(k), FrameRate::FPS_29_97), (k, 0.0));
|
||||
}
|
||||
}
|
||||
|
||||
/// Footage whose frame `i` is a flat grey of `i / 10` (24 fps), or a textured pattern moving
|
||||
/// 6 px right per frame.
|
||||
struct Frames {
|
||||
moving: bool,
|
||||
}
|
||||
|
||||
fn pattern(w: u32, h: u32, dx: f64) -> Image {
|
||||
let mut img = Image::new(w, h);
|
||||
img.rows_mut().for_each(|(y, row)| {
|
||||
for (x, p) in row.iter_mut().enumerate() {
|
||||
let (u, v) = (x as f64 - dx, y as f64);
|
||||
let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos()) as f32;
|
||||
*p = [s, s * 0.8, 1.0 - s, 1.0];
|
||||
}
|
||||
});
|
||||
img
|
||||
}
|
||||
|
||||
impl FootageSource for Frames {
|
||||
fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
|
||||
let i = f.frame_rate.frame_at(t);
|
||||
if self.moving {
|
||||
return Some(Arc::new(pattern(f.width, f.height, 6.0 * i as f64)));
|
||||
}
|
||||
let g = i as f32 / 10.0;
|
||||
Some(Arc::new(Image::filled(f.width, f.height, [g, g, g, 1.0])))
|
||||
}
|
||||
}
|
||||
|
||||
fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let comp = Comp::new(size.0, size.1, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let f = Footage {
|
||||
path: "clip.mov".into(),
|
||||
kind: FootageKind::Video,
|
||||
width: size.0,
|
||||
height: size.1,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_24,
|
||||
native_rate: None,
|
||||
duration: Tick::from_seconds_f64(2.0),
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Ignore,
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
};
|
||||
let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f));
|
||||
let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None);
|
||||
l.switches.frame_blend = mode;
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn frame(p: &Project, cid: ItemId, k: i64, moving: bool) -> Image {
|
||||
let src = Frames { moving };
|
||||
Renderer::new(p, &src, RenderOpts::default()).comp_frame(cid, FrameRate::FPS_30.tick_of(k))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frame_mix_blends_footage_frames() {
|
||||
let (p, cid) = setup(FrameBlend::FrameMix, (16, 8));
|
||||
// Comp frame 1 → source 0.8: 0.2·frame 0 + 0.8·frame 1 = 0.08.
|
||||
let v = frame(&p, cid, 1, false).get(4, 4)[0];
|
||||
assert!((v - 0.08).abs() < 1e-6, "{v}");
|
||||
let v = frame(&p, cid, 2, false).get(4, 4)[0];
|
||||
assert!((v - (0.4 * 0.1 + 0.6 * 0.2)).abs() < 1e-6, "{v}");
|
||||
// Off (layer switch or comp switch): the frame at or before.
|
||||
let (p, cid) = setup(FrameBlend::Off, (16, 8));
|
||||
assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6);
|
||||
let (mut p, cid) = setup(FrameBlend::FrameMix, (16, 8));
|
||||
p.comp_mut(cid).unwrap().enable_frame_blending = false;
|
||||
assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6);
|
||||
}
|
||||
|
||||
fn mae(a: &Image, b: &Image, margin: u32) -> f32 {
|
||||
let mut s = 0.0;
|
||||
let mut n = 0;
|
||||
for y in margin..a.height - margin {
|
||||
for x in margin..a.width - margin {
|
||||
let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64));
|
||||
s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::<f32>();
|
||||
n += 3;
|
||||
}
|
||||
}
|
||||
s / n as f32
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn pixel_motion_beats_frame_mix_on_moving_footage() {
|
||||
let size = (128, 96);
|
||||
// Comp frame 2 → source frame 1.6: the pattern 9.6 px along.
|
||||
let truth = pattern(size.0, size.1, 6.0 * 1.6);
|
||||
let (p, cid) = setup(FrameBlend::FrameMix, size);
|
||||
let mix = mae(&frame(&p, cid, 2, true), &truth, 14);
|
||||
let (p, cid) = setup(FrameBlend::PixelMotion, size);
|
||||
let pm = mae(&frame(&p, cid, 2, true), &truth, 14);
|
||||
assert!(pm < mix * 0.3, "pixel motion {pm} vs frame mix {mix}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn time_stretched_precomp_frame_mixes() {
|
||||
// Inner comp: a white solid moving 10 px per frame from x = 10. The precomp is stretched to
|
||||
// 200%, so odd outer frames fall halfway between inner frames.
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let mut inner = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 20, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 20), None);
|
||||
// Anchor at the left edge: x = 10 + 10 per frame.
|
||||
l.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 10.0, 0.0]);
|
||||
l.props.prop_mut("transform/position").unwrap().keys =
|
||||
vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 10.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([310.0, 10.0, 0.0]))];
|
||||
inner.layers.push(l);
|
||||
let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into()));
|
||||
let outer = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(4.0));
|
||||
let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into()));
|
||||
let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (400, 20), None);
|
||||
pre.stretch = 200.0;
|
||||
pre.out_point = Tick::from_seconds_f64(4.0);
|
||||
pre.switches.frame_blend = FrameBlend::FrameMix;
|
||||
p.comp_mut(cid).unwrap().layers.push(pre);
|
||||
// Outer frame 1 → inner frame 0.5: solids at 10..20 (frame 0) and 20..30 (frame 1), half each.
|
||||
let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0);
|
||||
for x in [12, 17, 22, 27] {
|
||||
assert!((img.get(x, 10)[3] - 0.5).abs() < 5e-3, "x {x}: {:?}", img.get(x, 10));
|
||||
}
|
||||
// Without frame blending the nested comp renders at the in-between time: one solid, 15..25.
|
||||
p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off;
|
||||
let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0);
|
||||
assert!(img.get(12, 10)[3] < 1e-3 && (img.get(17, 10)[3] - 1.0).abs() < 1e-3 && (img.get(22, 10)[3] - 1.0).abs() < 1e-3 && img.get(27, 10)[3] < 1e-3);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
//! Region of interest: a ROI render equals the matching crop of the full render.
|
||||
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_keyframe::Value;
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{Comp, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{Image, NoFootage, RenderOpts, Renderer};
|
||||
|
||||
fn project(three_d: bool) -> (Project, effectcraft_project::ItemId) {
|
||||
let mut p = Project::default();
|
||||
let comp = Comp::new(160, 120, FrameRate::FPS_30, Tick::from_seconds_f64(1.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
let mut layers = vec![];
|
||||
for (i, (c, w, h, pos, rot)) in
|
||||
[([1.0, 0.2, 0.1], 90, 50, [60.0, 50.0], 17.0), ([0.1, 0.4, 1.0], 70, 70, [100.0, 75.0], -30.0), ([0.9, 0.9, 0.2], 40, 30, [30.0, 95.0], 0.0)]
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
{
|
||||
let sid = p.add_item("Solid", Label::Red, None, ItemKind::Solid(Solid { color: c, width: w, height: h, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(&mut p, &comp, "Solid", LayerSource::Solid { item: sid }, (w, h), None);
|
||||
l.props.prop_mut("transform/position").unwrap().value = Value::Vec3([pos[0], pos[1], 0.0]);
|
||||
l.props.prop_mut("transform/rotation").unwrap().value = Value::Scalar(rot);
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = Value::Scalar(80.0);
|
||||
if i == 1 {
|
||||
l.blend_mode = BlendMode::Screen;
|
||||
l.switches.three_d = three_d;
|
||||
}
|
||||
layers.push(l);
|
||||
}
|
||||
p.comp_mut(cid).unwrap().layers = layers;
|
||||
(p, cid)
|
||||
}
|
||||
|
||||
fn crop(img: &Image, x: u32, y: u32, w: u32, h: u32) -> Image {
|
||||
let mut out = Image::new(w, h);
|
||||
for yy in 0..h {
|
||||
for xx in 0..w {
|
||||
out.data[(yy * w + xx) as usize] = img.data[((yy + y) * img.width + xx + x) as usize];
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn assert_same(a: &Image, b: &Image) {
|
||||
assert_eq!((a.width, a.height), (b.width, b.height));
|
||||
for (i, (p, q)) in a.data.iter().zip(&b.data).enumerate() {
|
||||
for c in 0..4 {
|
||||
assert!((p[c] - q[c]).abs() < 1e-4, "pixel {i} channel {c}: {} vs {}", p[c], q[c]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roi_render_equals_cropped_full_render() {
|
||||
for three_d in [false, true] {
|
||||
let (p, cid) = project(three_d);
|
||||
let full = Renderer::new(&p, &NoFootage, RenderOpts::default()).comp_frame(cid, Tick::ZERO);
|
||||
let roi = Renderer::new(&p, &NoFootage, RenderOpts { roi: Some([30.0, 20.0, 80.0, 60.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!((roi.width, roi.height), (80, 60));
|
||||
assert_same(&roi, &crop(&full, 30, 20, 80, 60));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn roi_at_half_resolution() {
|
||||
let (p, cid) = project(false);
|
||||
let full = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
let roi = Renderer::new(&p, &NoFootage, RenderOpts { scale: 0.5, roi: Some([40.0, 20.0, 60.0, 40.0]), ..Default::default() }).comp_frame(cid, Tick::ZERO);
|
||||
assert_eq!((roi.width, roi.height), (30, 20));
|
||||
assert_same(&roi, &crop(&full, 20, 10, 30, 20));
|
||||
}
|
||||
@@ -150,6 +150,10 @@ fn unproject(g: &Geo, sx: f64, sy: f64) -> Option<(f64, f64, f64)> {
|
||||
|
||||
/// The camera a renderer uses at a context time (view override only for the top-level comp).
|
||||
pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState {
|
||||
// Collapsed precomps see through the parent's camera.
|
||||
if let Some(c) = r.collapse3d {
|
||||
return c.cam;
|
||||
}
|
||||
match r.opts.view {
|
||||
Some(v) if r.depth == 0 => v,
|
||||
_ => active_camera(ctx),
|
||||
@@ -167,7 +171,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
|
||||
|
||||
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
|
||||
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
|
||||
let buf = r.layer_buf(ctx, layer)?;
|
||||
let buf = r.blend_layer_buf(ctx, layer)?;
|
||||
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
|
||||
}
|
||||
|
||||
@@ -176,7 +180,7 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer,
|
||||
if crate::text::per_char_3d(ctx, layer) {
|
||||
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
|
||||
.into_iter()
|
||||
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, Arc::new(buf), m))
|
||||
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m))
|
||||
.collect();
|
||||
}
|
||||
prepare(r, ctx, layer, order, out, in_run).into_iter().collect()
|
||||
@@ -200,7 +204,8 @@ fn prepare_with<'a>(
|
||||
let comp = (ctx.comp.width as f64, ctx.comp.height as f64);
|
||||
let s = r.opts.scale;
|
||||
let cam = camera_for(r, ctx);
|
||||
let world = ctx.world_matrix(layer) * local;
|
||||
let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world);
|
||||
let world = outer * ctx.world_matrix(layer) * local;
|
||||
// Depth of field: blur the layer by its circle of confusion at its centre.
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
let b = buf_bounds(&buf);
|
||||
@@ -234,7 +239,7 @@ fn prepare_with<'a>(
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd));
|
||||
let c = camera_for(r, &sub);
|
||||
geo(&c, sub.world_matrix(layer) * local, &buf, comp, s, out)
|
||||
geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out)
|
||||
})
|
||||
.collect()
|
||||
};
|
||||
@@ -253,7 +258,7 @@ fn prepare_with<'a>(
|
||||
bicubic: matches!(r.sampling(layer), effectcraft_raster::Sampling::Bicubic),
|
||||
buf,
|
||||
geos,
|
||||
opacity: ctx.opacity(layer) as f32,
|
||||
opacity: ctx.opacity(layer) as f32 * r.opacity_mul(),
|
||||
draw: in_run && mat.casts_shadows != 2,
|
||||
mat,
|
||||
matte: None,
|
||||
@@ -319,12 +324,18 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
|
||||
return;
|
||||
}
|
||||
let out = (canvas.width, canvas.height);
|
||||
let lights = scene_lights(ctx);
|
||||
// A collapsed precomp's 3D layers are lit by the outer comp's lights.
|
||||
let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent));
|
||||
let mut items: Vec<Item> = run
|
||||
.par_iter()
|
||||
.enumerate()
|
||||
.filter(|(_, l)| ctx.opacity(l) > 0.0)
|
||||
.filter(|(_, l)| ctx.opacity(l) > 0.0 && l.switches.quality != effectcraft_project::Quality::Wireframe)
|
||||
.flat_map_iter(|(i, l)| {
|
||||
// Stack order with room for a collapsed precomp's layers in between.
|
||||
let i = i * 1024;
|
||||
if let Some(item) = r.collapsed(ctx, l) {
|
||||
return collapsed_items(r, ctx, l, item, i, out);
|
||||
}
|
||||
let mut its = prepare_all(r, ctx, l, i, out, true);
|
||||
if !its.is_empty() {
|
||||
let m = matte_for(r, ctx, l, out);
|
||||
@@ -351,6 +362,49 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut
|
||||
items.sort_by_key(|i| i.order);
|
||||
let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect();
|
||||
composite(canvas, &items, &lights, &casters, run[0].id.0 as u32);
|
||||
for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) {
|
||||
r.draw_layer(ctx, l, canvas);
|
||||
}
|
||||
}
|
||||
|
||||
/// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's
|
||||
/// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they
|
||||
/// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack
|
||||
/// order. (Nested adjustment layers are skipped here.)
|
||||
fn collapsed_items<'a>(
|
||||
r: &Renderer<'a>,
|
||||
ctx: &EvalCtx<'a>,
|
||||
layer: &'a Layer,
|
||||
item: effectcraft_project::ItemId,
|
||||
order: usize,
|
||||
out: (u32, u32),
|
||||
) -> Vec<Item<'a>> {
|
||||
let op = ctx.opacity(layer) as f32 * r.opacity_mul();
|
||||
let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] };
|
||||
let t = nctx.time;
|
||||
let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
|
||||
let nested: Vec<&'a Layer> = nctx
|
||||
.comp
|
||||
.layers
|
||||
.iter()
|
||||
.rev()
|
||||
.filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide))
|
||||
.collect();
|
||||
let mut items = Vec::new();
|
||||
for (k, l) in nested.into_iter().enumerate() {
|
||||
if nctx.opacity(l) <= 0.0 {
|
||||
continue;
|
||||
}
|
||||
let mut its = prepare_all(&sub, &nctx, l, order + 1 + k.min(1022), out, true);
|
||||
if !its.is_empty() {
|
||||
let m = matte_for(&sub, &nctx, l, out);
|
||||
for it in &mut its {
|
||||
it.matte = m.clone();
|
||||
}
|
||||
}
|
||||
items.extend(its);
|
||||
}
|
||||
items
|
||||
}
|
||||
|
||||
/// Per-pixel depth-sorted compositing of prepared planes.
|
||||
|
||||
@@ -14,6 +14,8 @@ const H: u32 = 100;
|
||||
|
||||
fn setup() -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
|
||||
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
|
||||
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
(p, cid, comp)
|
||||
|
||||
Reference in New Issue
Block a user