From 029c0da5e5ee051e529ad9226ea5892da341b67d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:03:36 -0400 Subject: [PATCH 1/3] M2.4: bit depth + linear blending + working space 8/16 bpc projects clamp and quantise layer pixels after source/masks and every effect, and the comp after every layer (16 bpc = 0..32768); 32 bpc keeps over-range values. Blend modes without HDR support clamp their inputs in 32 bpc. Working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices derived from the published primaries), footage colour profiles from stream metadata or Interpret Footage, Linearize Working Space and Blend Colors Using 1.0 Gamma. Layer cache keys include the colour settings. Project panel depth button: click cycles, Alt-click opens Project Settings. Co-Authored-By: Claude Opus 5.5 --- crates/color/src/blend.rs | 49 ++++- crates/color/src/lib.rs | 2 + crates/color/src/space.rs | 273 ++++++++++++++++++++++++ crates/engine/src/commands/file.rs | 14 +- crates/engine/src/commands/file_more.rs | 15 +- crates/engine/src/lib.rs | 2 + crates/engine/src/tests_fidelity.rs | 28 +++ crates/engine/src/tests_stubs.rs | 1 + crates/engine/src/tests_track.rs | 2 + crates/lottie/src/import.rs | 1 + crates/lottie/src/tests.rs | 1 + crates/media/src/probe.rs | 15 ++ crates/project/src/lib.rs | 40 +++- crates/render/src/audio.rs | 1 + crates/render/src/cache.rs | 2 + crates/render/src/color.rs | 114 ++++++++++ crates/render/src/lib.rs | 168 ++++++++++++--- crates/render/src/tests.rs | 2 + crates/render/src/tests_audio_fx.rs | 1 + crates/render/src/tests_color.rs | 227 ++++++++++++++++++++ crates/render/src/three_d/compose.rs | 4 +- crates/render/src/three_d/tests.rs | 2 + crates/ui-egui/src/panels/forms.rs | 27 ++- crates/ui-egui/src/panels/project.rs | 8 +- 24 files changed, 957 insertions(+), 42 deletions(-) create mode 100644 crates/color/src/space.rs create mode 100644 crates/engine/src/tests_fidelity.rs create mode 100644 crates/render/src/color.rs create mode 100644 crates/render/src/tests_color.rs diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index ee239c3ce..2305f12af 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -157,6 +157,37 @@ impl BlendMode { matches!(self, BlendMode::StencilAlpha | BlendMode::StencilLuma | BlendMode::SilhouetteAlpha | BlendMode::SilhouetteLuma) } + /// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp + /// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects + /// (in 8/16 bpc everything is already in range). + pub fn supports_hdr(self) -> bool { + matches!( + self, + BlendMode::Normal + | BlendMode::Dissolve + | BlendMode::DancingDissolve + | BlendMode::Darken + | BlendMode::Multiply + | BlendMode::LinearBurn + | BlendMode::DarkerColor + | BlendMode::Add + | BlendMode::Lighten + | BlendMode::Screen + | BlendMode::LinearDodge + | BlendMode::LighterColor + | BlendMode::Difference + | BlendMode::ClassicDifference + | BlendMode::Subtract + | BlendMode::Divide + | BlendMode::StencilAlpha + | BlendMode::StencilLuma + | BlendMode::SilhouetteAlpha + | BlendMode::SilhouetteLuma + | BlendMode::AlphaAdd + | BlendMode::LuminescentPremul + ) + } + pub fn next(self) -> BlendMode { let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0); BlendMode::ALL[(i + 1) % BlendMode::ALL.len()] @@ -364,8 +395,12 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> if da <= 0.0 { return src; } - let cs = [src[0] / sa, src[1] / sa, src[2] / sa]; - let cb = [dst[0] / da, dst[1] / da, dst[2] / da]; + let mut cs = [src[0] / sa, src[1] / sa, src[2] / sa]; + let mut cb = [dst[0] / da, dst[1] / da, dst[2] / da]; + if !mode.supports_hdr() { + cs = cs.map(|v| v.clamp(0.0, 1.0)); + cb = cb.map(|v| v.clamp(0.0, 1.0)); + } let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; let mut o = [0.0f32; 4]; @@ -432,6 +467,16 @@ mod tests { assert_eq!(blend_pixel(BlendMode::SilhouetteAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), d); } + #[test] + fn hdr_modes() { + // Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs. + let d = [2.0, 2.0, 2.0, 1.0]; + let s = [0.5, 0.5, 0.5, 1.0]; + assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6); + assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6); + assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0); + } + #[test] fn names_roundtrip() { for m in BlendMode::ALL { diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index e292d9177..9212b020e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -6,9 +6,11 @@ //! colour and composited with Porter-Duff source-over on premultiplied pixels. pub mod blend; +pub mod space; pub use blend::{BlendMode, blend_pixel}; use serde::{Deserialize, Serialize}; +pub use space::{ColorSpace, Conversion}; /// Straight (un-premultiplied) RGBA, 0..1 nominal (32 bpc may exceed). #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs new file mode 100644 index 000000000..2bc935135 --- /dev/null +++ b/crates/color/src/space.rs @@ -0,0 +1,273 @@ +//! RGB colour spaces for colour management: the project working space, footage interpretation +//! and the display. +//! +//! Each space is defined by its published primaries and white point and its transfer function: +//! +//! * **sRGB IEC 61966-2-1** — Rec. 709 primaries, D65, the piecewise sRGB curve. +//! * **Rec. 709** — ITU-R BT.709-6 primaries, D65, the BT.1886 display EOTF (gamma 2.4). +//! * **Rec. 2020** — ITU-R BT.2020-2 primaries, D65, BT.1886 (gamma 2.4). +//! * **Display P3** — SMPTE EG 432-1 (DCI-P3) primaries with D65 and the sRGB curve. +//! +//! RGB → XYZ matrices are derived from the primaries (the standard construction from chromaticity +//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), so +//! conversions between any two spaces are exact up to `f64` rounding. Transfer functions extend +//! to over-range and negative values (32 bpc) by mirroring around zero. + +use serde::{Deserialize, Serialize}; + +/// An RGB colour space (all D65). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ColorSpace { + /// sRGB IEC 61966-2-1. + Srgb, + /// HDTV Rec. 709 (gamma 2.4). + Rec709, + /// UHDTV Rec. 2020 (gamma 2.4). + Rec2020, + /// Display P3. + DisplayP3, +} + +/// CIE xy chromaticity of D65. +const D65: [f64; 2] = [0.3127, 0.3290]; + +impl ColorSpace { + pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3]; + + /// Display name (as in Project Settings ▸ Color ▸ Working Space). + pub fn label(self) -> &'static str { + match self { + ColorSpace::Srgb => "sRGB IEC61966-2.1", + ColorSpace::Rec709 => "HDTV (Rec. 709)", + ColorSpace::Rec2020 => "Rec. 2020", + ColorSpace::DisplayP3 => "Display P3", + } + } + + /// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`). + pub fn id(self) -> &'static str { + match self { + ColorSpace::Srgb => "srgb", + ColorSpace::Rec709 => "rec709", + ColorSpace::Rec2020 => "rec2020", + ColorSpace::DisplayP3 => "p3", + } + } + + /// Parse an id or label (case-insensitive, loose). + pub fn parse(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + match k.as_str() { + "srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb), + "rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709), + "rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020), + "p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3), + _ => None, + } + } + + /// Red, green and blue primaries (CIE xy). + pub fn primaries(self) -> [[f64; 2]; 3] { + match self { + ColorSpace::Srgb | ColorSpace::Rec709 => [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]], + ColorSpace::Rec2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], + } + } + + /// Linear RGB → CIE XYZ (Y of white = 1). + pub fn to_xyz(self) -> [[f64; 3]; 3] { + rgb_to_xyz(self.primaries(), D65) + } + + /// Encoded value → linear light. + pub fn decode(self, v: f32) -> f32 { + let a = v.abs(); + let l = match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::srgb_to_linear(a), + ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(2.4), + }; + l.copysign(v) + } + + /// Linear light → encoded value. + pub fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::linear_to_srgb(a), + ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(1.0 / 2.4), + }; + e.copysign(v) + } +} + +/// The standard RGB → XYZ matrix from primaries and white point. +pub fn rgb_to_xyz(p: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] { + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let (r, g, b) = (xyz(p[0]), xyz(p[1]), xyz(p[2])); + let m = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]]; + let w = xyz(white); + let s = mul_vec(&invert(&m), w); + let mut o = m; + for row in &mut o { + for j in 0..3 { + row[j] *= s[j]; + } + } + o +} + +/// 3×3 matrix inverse (the matrices here are always well conditioned). +pub fn invert(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; + let det = m[0][0] * c(1, 1, 2, 2) - m[0][1] * c(1, 0, 2, 2) + m[0][2] * c(1, 0, 2, 1); + let k = 1.0 / det; + [ + [c(1, 1, 2, 2) * k, -c(0, 1, 2, 2) * k, c(0, 1, 1, 2) * k], + [-c(1, 0, 2, 2) * k, c(0, 0, 2, 2) * k, -c(0, 0, 1, 2) * k], + [c(1, 0, 2, 1) * k, -c(0, 0, 2, 1) * k, c(0, 0, 1, 1) * k], + ] +} + +pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o +} + +pub fn mul_vec(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) +} + +/// A colour conversion between two spaces, each either encoded (with its transfer curve) or +/// linear. Applied to straight (un-premultiplied) RGB. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Conversion { + /// Decode the input with this space's curve first. + pub decode: Option, + /// Linear RGB matrix (None = identity primaries). + pub matrix: Option<[[f32; 3]; 3]>, + /// Encode the output with this space's curve last. + pub encode: Option, +} + +impl Conversion { + /// From `from` (linear when `from_linear`) to `to` (linear when `to_linear`). `None` when + /// the conversion is the identity. + pub fn new(from: ColorSpace, from_linear: bool, to: ColorSpace, to_linear: bool) -> Option { + let same_primaries = from.primaries() == to.primaries(); + let same_curve = from_linear == to_linear && (from_linear || curve_eq(from, to)); + if same_primaries && same_curve { + return None; + } + let matrix = (!same_primaries).then(|| { + let m = mul(&invert(&to.to_xyz()), &from.to_xyz()); + m.map(|r| r.map(|v| v as f32)) + }); + // Same primaries, both encoded with different curves (sRGB ↔ Rec. 709), or a matrix in + // between: go through linear. + let decode = (!from_linear).then_some(from); + let encode = (!to_linear).then_some(to); + Some(Conversion { decode, matrix, encode }) + } + + /// Decode only (encoded → linear in the same space). + pub fn linearize(space: ColorSpace) -> Conversion { + Conversion { decode: Some(space), matrix: None, encode: None } + } + + /// Encode only (linear → encoded in the same space). + pub fn delinearize(space: ColorSpace) -> Conversion { + Conversion { decode: None, matrix: None, encode: Some(space) } + } + + #[inline] + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let mut c = c; + if let Some(s) = self.decode { + c = c.map(|v| s.decode(v)); + } + if let Some(m) = &self.matrix { + c = [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]); + } + if let Some(s) = self.encode { + c = c.map(|v| s.encode(v)); + } + c + } +} + +fn curve_eq(a: ColorSpace, b: ColorSpace) -> bool { + let srgb = |s: ColorSpace| matches!(s, ColorSpace::Srgb | ColorSpace::DisplayP3); + srgb(a) == srgb(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srgb_matrix_matches_iec() { + // IEC 61966-2-1 (rounded to 4 places). + let m = ColorSpace::Srgb.to_xyz(); + let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]; + for i in 0..3 { + for j in 0..3 { + assert!((m[i][j] - want[i][j]).abs() < 2e-4, "{i}{j}: {} vs {}", m[i][j], want[i][j]); + } + } + // Rec. 2020 luminance row (BT.2020: 0.2627, 0.6780, 0.0593). + let y = ColorSpace::Rec2020.to_xyz()[1]; + assert!((y[0] - 0.2627).abs() < 1e-4 && (y[1] - 0.6780).abs() < 1e-4 && (y[2] - 0.0593).abs() < 1e-4, "{y:?}"); + } + + #[test] + fn round_trips() { + let samples = [[0.2f32, 0.5, 0.8], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.03, 0.9, 0.4], [1.5, 0.2, 2.0]]; + for a in ColorSpace::ALL { + for b in ColorSpace::ALL { + for (al, bl) in [(false, false), (true, false), (false, true), (true, true)] { + let fwd = Conversion::new(a, al, b, bl); + let back = Conversion::new(b, bl, a, al); + assert_eq!(fwd.is_none(), back.is_none()); + for c in samples { + let mid = fwd.map(|f| f.apply(c)).unwrap_or(c); + let o = back.map(|f| f.apply(mid)).unwrap_or(mid); + for i in 0..3 { + // Pure power curves amplify f32 rounding near zero. + assert!((o[i] - c[i]).abs() < 2e-3, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}"); + } + } + } + } + } + } + + #[test] + fn white_is_preserved_and_gamut_grows() { + let c = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, false).unwrap(); + let w = c.apply([1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); + // Pure sRGB red sits inside Rec. 2020: less saturated there. + let r = c.apply([1.0, 0.0, 0.0]); + assert!(r[0] < 1.0 && r[1] > 0.0 && r[2] > 0.0, "{r:?}"); + // sRGB ↔ Rec. 709 share primaries: only the curve changes. + let k = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec709, false).unwrap(); + assert!(k.matrix.is_none()); + assert!(Conversion::new(ColorSpace::Srgb, true, ColorSpace::Rec709, true).is_none()); + } + + #[test] + fn transfer_extends_over_range() { + let s = ColorSpace::Srgb; + assert!((s.decode(s.encode(4.0)) - 4.0).abs() < 1e-4); + assert!((s.decode(s.encode(-0.25)) + 0.25).abs() < 1e-5); + assert!((ColorSpace::Rec709.decode(0.5) - 0.5f32.powf(2.4)).abs() < 1e-6); + assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb)); + assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3)); + } +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 92e8836e0..ee61f9007 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { if let Some(l) = p.get("linearize").and_then(Value::as_bool) { proj.settings.linearize = l; } + if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) { + proj.settings.blend_linear = l; + } + if let Some(w) = p.get("workingSpace") { + proj.settings.working_space = match w.as_str() { + None | Some("none" | "None" | "") => None, + Some(n) => Some( + effectcraft_project::ColorSpace::parse(n) + .ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?, + ), + }; + } if let Some(t) = str_p(p, "timeDisplay") { proj.settings.time_display = if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode }; @@ -172,7 +184,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}", + "{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}", always, project_settings ), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 0180d2798..87c1e2dff 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage { codec: "Placeholder".into(), missing: true, sequence: vec![], + color_profile: None, } } @@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result { }; let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32); let par = f_p(p, "pixelAspect"); + // Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB). + let profile = match str_p(p, "colorProfile") { + Some("auto" | "none" | "") => Some(None), + Some(c) => match effectcraft_project::ColorSpace::parse(c) { + Some(cs) => Some(Some(cs)), + None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))), + }, + None => None, + }; s.edit("Interpret Footage", None, |proj, _| { for i in &items { if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) { @@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result { if let Some(x) = par { f.pixel_aspect = x; } + if let Some(c) = profile { + f.color_profile = c; + } } } Ok(()) @@ -634,7 +647,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}", + "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}", has_footage_selection, interpret ), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b5c5f43b..5bc517165 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -518,6 +518,8 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] +mod tests_fidelity; +#[cfg(test)] mod tests_effects; #[cfg(test)] mod tests_lottie; diff --git a/crates/engine/src/tests_fidelity.rs b/crates/engine/src/tests_fidelity.rs new file mode 100644 index 000000000..d1b63e2d8 --- /dev/null +++ b/crates/engine/src/tests_fidelity.rs @@ -0,0 +1,28 @@ +//! Render-fidelity commands: project colour settings, footage colour profile, slip edit. + +use effectcraft_project::{BitDepth, ColorSpace}; +use serde_json::json; + +use crate::Session; + +#[test] +fn project_colour_settings_are_undoable() { + let mut s = Session::default(); + s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "rec2020", "linearize": true, "blendLinear": true})).unwrap(); + let st = &s.project.settings; + assert_eq!((st.bit_depth, st.working_space, st.linearize, st.blend_linear), (BitDepth::Bpc32, Some(ColorSpace::Rec2020), true, true)); + s.execute("file.projectSettings", json!({"workingSpace": "Display P3"})).unwrap(); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3)); + s.execute("file.projectSettings", json!({"workingSpace": "none"})).unwrap(); + assert_eq!(s.project.settings.working_space, None); + assert!(s.execute("file.projectSettings", json!({"workingSpace": "acescg-ish"})).is_err()); + assert!(s.undo()); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3)); + assert!(s.undo() && s.undo()); + assert_eq!(s.project.settings.bit_depth, BitDepth::Bpc8); + assert!(!s.project.settings.blend_linear); + // The Project panel's depth button cycles 8 → 16 → 32 → 8. + for want in ["16 bpc", "32 bpc", "8 bpc"] { + assert_eq!(s.execute("file.cycleBitDepth", json!({})).unwrap(), json!(want)); + } +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index c1356ff2e..141858caa 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -256,6 +256,7 @@ impl Importer for MockImporter { codec: "mock".into(), missing: false, sequence: vec![], + color_profile: None, }) } } diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests_track.rs index b3c2a1747..7ea69cfa6 100644 --- a/crates/engine/src/tests_track.rs +++ b/crates/engine/src/tests_track.rs @@ -100,6 +100,7 @@ fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, Layer codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); @@ -408,6 +409,7 @@ fn analyze_1080p_single_point_speed() { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 9ad38f394..c87dc1879 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -170,6 +170,7 @@ pub fn import( codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot)); cx.items.push(item); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 0cd38a096..5140133c8 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -591,6 +591,7 @@ fn images_embed_as_base64_and_come_back() { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }), ); let c = comp(&p, cid); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index a248cfc67..4e1c56110 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -71,6 +71,20 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - codec, missing: false, sequence: Vec::new(), + color_profile: v.and_then(|v| profile_of(&v.color)), + } +} + +/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or +/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. +fn profile_of(c: &filmcraft_color::ColorInfo) -> Option { + use effectcraft_project::ColorSpace; + use filmcraft_color::{Primaries, Transfer}; + match (c.primaries, c.transfer) { + (Primaries::Bt2020, _) => Some(ColorSpace::Rec2020), + (Primaries::P3D65, _) => Some(ColorSpace::DisplayP3), + (_, Transfer::Bt709) => Some(ColorSpace::Rec709), + _ => None, } } @@ -99,6 +113,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: codec, missing: false, sequence: Vec::new(), + color_profile: None, } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 722f3fe19..88fc6d02a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -13,6 +13,7 @@ pub mod tracking; use std::collections::BTreeMap; use std::sync::Arc; +pub use effectcraft_color::ColorSpace; use effectcraft_color::{BlendMode, Label}; use effectcraft_time::{FrameRate, Tick}; pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path}; @@ -70,6 +71,22 @@ impl BitDepth { pub fn is_float(self) -> bool { self == BitDepth::Bpc32 } + /// Quantisation levels per channel of the integer depths (8 bpc: 255; 16 bpc: 32768, After + /// Effects' "15 + 1" bit range), `None` for 32 bpc float. + pub fn levels(self) -> Option { + match self { + BitDepth::Bpc8 => Some(255.0), + BitDepth::Bpc16 => Some(32768.0), + BitDepth::Bpc32 => None, + } + } + pub fn bits(self) -> u32 { + match self { + BitDepth::Bpc8 => 8, + BitDepth::Bpc16 => 16, + BitDepth::Bpc32 => 32, + } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -82,8 +99,16 @@ pub enum TimeDisplayStyle { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ProjectSettings { pub bit_depth: BitDepth, - /// Blend in linear light instead of the sRGB-encoded working space. + /// Linearize Working Space: the working space uses linear light (1.0 gamma) for everything + /// (sources, effects, blending). Needs a working space. pub linearize: bool, + /// Working Space (colour management). `None` = no colour management. + #[serde(default)] + pub working_space: Option, + /// Blend Colors Using 1.0 Gamma: layers blend in linear light (sources and effects stay in + /// the working space's encoding). + #[serde(default)] + pub blend_linear: bool, pub time_display: TimeDisplayStyle, /// Frame numbering starts at 0 (or 1). pub frame_start: i64, @@ -92,7 +117,15 @@ pub struct ProjectSettings { impl Default for ProjectSettings { fn default() -> Self { - ProjectSettings { bit_depth: BitDepth::Bpc8, linearize: false, time_display: TimeDisplayStyle::Timecode, frame_start: 0, audio_sample_rate: 48_000 } + ProjectSettings { + bit_depth: BitDepth::Bpc8, + linearize: false, + working_space: None, + blend_linear: false, + time_display: TimeDisplayStyle::Timecode, + frame_start: 0, + audio_sample_rate: 48_000, + } } } @@ -520,6 +553,9 @@ pub struct Footage { /// Image sequence files (when kind = Sequence). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub sequence: Vec, + /// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_profile: Option, } fn one() -> u32 { diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 246ff683e..4884411f6 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -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)); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index d1dc692c2..a26e652cb 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -291,6 +291,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool } _ => 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); diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs new file mode 100644 index 000000000..7b190b7d9 --- /dev/null +++ b/crates/render/src/color.rs @@ -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, + /// Working space (`None` = unmanaged). + pub space: Option, + /// 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) -> Option { + 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 { + self.linear.then(|| Conversion::linearize(self.curve())) + } + + /// Working space → blending space (linear when blending with 1.0 gamma). + pub fn to_blend(&self) -> Option { + self.linear_blend.then(|| Conversion::linearize(self.curve())) + } + + /// Blending space → working space. + pub fn from_blend(&self) -> Option { + self.linear_blend.then(|| Conversion::delinearize(self.curve())) + } + + /// Working space → sRGB display (top-level comp output). + pub fn output(&self) -> Option { + 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]; + }); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc7db2d4e..d60a1ee82 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ pub mod audio; pub mod cache; +pub mod color; pub mod eval; pub mod masks; pub mod shapes; @@ -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] }; @@ -157,6 +158,8 @@ pub struct Renderer<'a> { pub profile: Option<&'a std::sync::Mutex>>, /// Current nesting depth (precomp recursion guard). depth: usize, + /// The project's colour pipeline (bit depth, working space, linear blending). + pub(crate) pipe: color::Pipe, } /// Time spent on one layer of one frame (milliseconds). @@ -176,7 +179,12 @@ 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) } + } + + /// A renderer for nested work (precomps, layers read by effects): one level deeper. + fn nested(&self) -> Renderer<'a> { + Renderer { depth: self.depth + 1, ..*self } } fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { @@ -194,6 +202,24 @@ impl<'a> Renderer<'a> { 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 +235,15 @@ 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); + 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 +295,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,14 +315,17 @@ 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); Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } @@ -307,40 +336,94 @@ 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, s))), + LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, s))), _ => 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`. + fn footage_frame(&self, _ctx: &EvalCtx, _layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + self.footage.frame(item, f, t) + } + + /// Source → masks, clamped/quantised to the project depth. + fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + 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> { + 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> { + 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, key: Option) -> Arc { + 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, Option)> { 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 @@ -365,8 +448,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()); @@ -384,8 +466,7 @@ impl<'a> Renderer<'a> { { 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 +481,7 @@ impl<'a> Renderer<'a> { fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { 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 +490,7 @@ impl<'a> Renderer<'a> { { let passes: Option> = 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,7 +509,18 @@ 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 { @@ -488,6 +580,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 +688,15 @@ 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 mut below = Buf { img: canvas.clone(), offset: [0.0; 2], 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,8 +715,10 @@ struct StyledLayer { body: Arc, /// Pre-style pixels (knockout shape). content: Arc, - /// Styled cache key. + /// Styled cache key (the content key when plain). key: Option, + /// Content cache key. + ckey: Option, /// No layer styles: composite `body` the usual way. plain: bool, } @@ -659,6 +761,8 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath { #[cfg(test)] mod tests_audio_fx; #[cfg(test)] +mod tests_color; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index dc1f3cdaf..a5a7d8b6d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -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) diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index 173ffc88f..dfd49ccc3 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -50,6 +50,7 @@ fn audio_footage() -> Footage { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, } } diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs new file mode 100644 index 000000000..87f9dd1ae --- /dev/null +++ b/crates/render/src/tests_color.rs @@ -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> { + 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) -> 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, profile: Option| { + 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); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 53023c507..421587bc7 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -167,7 +167,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> { - 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 +176,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() diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 2a068e3ac..01e0399df 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -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) diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 77e432b94..e0f1a5f21 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -218,7 +218,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), ], ), - "file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect"]) => { + "file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind { effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()), _ => None, @@ -236,6 +236,18 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), Field::num("loop", "Loop (times)", f.loop_count as f64), Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect), + Field::choice( + "colorProfile", + "Color profile", + &[ + ("Embedded / sRGB", json!("auto")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ], + f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), ], ) } @@ -261,7 +273,20 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { &[("Timecode", json!("timecode")), ("Frames", json!("frames"))], usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)), ), + Field::choice( + "workingSpace", + "Working space", + &[ + ("None", json!("none")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ], + st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), Field::bool("linearize", "Linearize working space", st.linearize), + Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), ], ) } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 4d0e0bb12..31d7d4de1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -311,9 +311,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click()); p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim }); app.auto.add("project.bitDepth", dr, depth); + // Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects). if dresp.clicked() { - actions.push(("file.cycleBitDepth".into(), json!({}))); + if ui.input(|i| i.modifiers.alt) { + actions.push(("file.projectSettings".into(), json!({}))); + } else { + actions.push(("file.cycleBitDepth".into(), json!({}))); + } } + dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"); let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0)); if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() { let sel = app.session.state.project_selection.clone(); From bfa21e32224029e095761be2471749c0af0f80df Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:05:52 -0400 Subject: [PATCH 2/3] M4.3: frame blending Frame Mix and Pixel Motion for footage and precomp layers when the layer's Frame Blending switch and the comp's Enable Frame Blending are on and the source time falls between source frames (rate conform, time stretch, time remap). Exact frame positions from tick arithmetic. Pixel Motion is a hierarchical block-matching optical flow (SAD search, sub-pixel parabola fit, vector median) with a bidirectional warp-blend (raster::flow). Blended footage frames are cached. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/lib.rs | 4 +- crates/raster/src/flow.rs | 290 +++++++++++++++++++++++++ crates/raster/src/lib.rs | 1 + crates/render/src/cache.rs | 2 + crates/render/src/lib.rs | 74 ++++++- crates/render/src/tests_frame_blend.rs | 168 ++++++++++++++ 6 files changed, 533 insertions(+), 6 deletions(-) create mode 100644 crates/raster/src/flow.rs create mode 100644 crates/render/src/tests_frame_blend.rs diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 5bc517165..564dbc1bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -518,10 +518,10 @@ mod tests; #[cfg(test)] mod tests_3d; #[cfg(test)] -mod tests_fidelity; -#[cfg(test)] mod tests_effects; #[cfg(test)] +mod tests_fidelity; +#[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_menu_cmds; diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs new file mode 100644 index 000000000..c398eadd2 --- /dev/null +++ b/crates/raster/src/flow.rs @@ -0,0 +1,290 @@ +//! Optical flow by block matching, and motion-compensated frame interpolation (the Pixel Motion +//! mode of frame blending). +//! +//! The method is the textbook hierarchical block matcher used in video coding and frame-rate +//! conversion: build a luma pyramid of both frames; at the coarsest level, find for each block of +//! frame A the integer displacement into frame B with the smallest sum of absolute differences +//! (SAD) in a search window; at each finer level, start from the doubled coarse vectors (and the +//! neighbours' vectors) and refine in a small window; finish with a sub-pixel parabola fit of the +//! SAD around the best vector and a 3×3 vector median to remove outliers. +//! +//! Interpolation at a fraction `w` between the frames warps both towards the in-between time — +//! A forward by `w·F`, B back by `(1 − w)·F` — and cross-fades them, so moving detail stays sharp +//! instead of double-exposing as with a plain frame mix. + +use rayon::prelude::*; + +use crate::Image; + +/// A block motion field: one vector per `block × block` tile of frame A (in pixels, A → B). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Flow { + pub width: u32, + pub height: u32, + pub block: u32, + /// Tiles across / down. + pub cols: usize, + pub rows: usize, + pub v: Vec<[f32; 2]>, +} + +impl Flow { + /// The motion at pixel position (x, y), bilinearly interpolated between tile centres. + pub fn at(&self, x: f64, y: f64) -> [f32; 2] { + if self.v.is_empty() { + return [0.0; 2]; + } + let b = self.block as f64; + let fx = (x / b - 0.5).clamp(0.0, (self.cols - 1) as f64); + let fy = (y / b - 0.5).clamp(0.0, (self.rows - 1) as f64); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.cols - 1), (y0 + 1).min(self.rows - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let g = |c: usize, r: usize| self.v[r * self.cols + c]; + let mut o = [0.0; 2]; + for i in 0..2 { + let top = g(x0, y0)[i] + (g(x1, y0)[i] - g(x0, y0)[i]) * tx; + let bot = g(x0, y1)[i] + (g(x1, y1)[i] - g(x0, y1)[i]) * tx; + o[i] = top + (bot - top) * ty; + } + o + } +} + +/// A single-channel plane. +struct Plane { + w: usize, + h: usize, + d: Vec, +} + +impl Plane { + fn luma(img: &Image) -> Plane { + let d = img.data.par_iter().map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + 0.25 * p[3]).collect(); + Plane { w: img.width as usize, h: img.height as usize, d } + } + fn half(&self) -> Plane { + let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1)); + let d = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = (i % w * 2, i / w * 2); + let g = |dx: usize, dy: usize| self.d[(y + dy).min(self.h - 1) * self.w + (x + dx).min(self.w - 1)]; + (g(0, 0) + g(1, 0) + g(0, 1) + g(1, 1)) * 0.25 + }) + .collect(); + Plane { w, h, d } + } + #[inline] + fn get(&self, x: i64, y: i64) -> f32 { + self.d[y.clamp(0, self.h as i64 - 1) as usize * self.w + x.clamp(0, self.w as i64 - 1) as usize] + } +} + +/// SAD of the `bs` block at (bx, by) in `a` against `b` displaced by (dx, dy). +fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32 { + let mut s = 0.0; + for y in by..by + bs { + for x in bx..bx + bs { + s += (a.get(x, y) - b.get(x + dx, y + dy)).abs(); + } + } + s +} + +/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution; +/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`). +pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { + let bs = block.max(4) as usize; + let (w, h) = (a.width as usize, a.height as usize); + let cols = w.div_ceil(bs).max(1); + let rows = h.div_ceil(bs).max(1); + let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; + if a.width != b.width || a.height != b.height || w < bs || h < bs { + return flow; + } + // Pyramids (level 0 = full resolution); stop while blocks still fit. + let mut pa = vec![Plane::luma(a)]; + let mut pb = vec![Plane::luma(b)]; + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) { + let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); + pa.extend(na); + pb.extend(nb); + } + let top = pa.len() - 1; + // Integer vectors at the current level, per full-resolution tile. + let mut cur: Vec<[i64; 2]> = vec![[0, 0]; cols * rows]; + for level in (0..=top).rev() { + let (la, lb) = (&pa[level], &pb[level]); + let k = 1i64 << level; + // The block covers the tile's footprint at this level (at least 4 pixels). + let lbs = ((bs as i64) / k).max(4); + let r = if level == top { radius as i64 } else { 1 }; + let prev = cur.clone(); + cur = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, rr) = ((i % cols) as i64, (i / cols) as i64); + let bx = c * bs as i64 / k; + let by = rr * bs as i64 / k; + // Candidates: this tile's and its neighbours' vectors from the coarser level. + let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; + if level != top { + for (dc, dr) in [(0, 0), (-1, 0), (1, 0), (0, -1), (0, 1)] { + let (nc, nr) = (c + dc, rr + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = prev[nr as usize * cols + nc as usize]; + seeds.push([v[0] * 2, v[1] * 2]); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + let mut best = ([0i64, 0i64], f32::INFINITY); + for s in seeds { + for dy in -r..=r { + for dx in -r..=r { + let d = [s[0] + dx, s[1] + dy]; + // A tiny bias towards short vectors breaks ties in flat areas. + let e = sad(la, lb, bx, by, lbs, d[0], d[1]) + 1e-4 * (d[0].abs() + d[1].abs()) as f32; + if e < best.1 { + best = (d, e); + } + } + } + } + best.0 + }) + .collect(); + } + // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. + let (la, lb) = (&pa[0], &pb[0]); + let bsi = bs as i64; + let fine: Vec<[f32; 2]> = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi); + let d = cur[i]; + let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]); + let fit = |em: f32, ep: f32| { + let den = em - 2.0 * e0 + ep; + if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 } + }; + let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1])); + let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1)); + [d[0] as f32 + sx, d[1] as f32 + sy] + }) + .collect(); + // 3×3 vector median (component-wise) against outliers. + flow.v = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, r) = ((i % cols) as i64, (i / cols) as i64); + let mut xs = Vec::with_capacity(9); + let mut ys = Vec::with_capacity(9); + for dr in -1..=1 { + for dc in -1..=1 { + let (nc, nr) = (c + dc, r + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = fine[nr as usize * cols + nc as usize]; + xs.push(v[0]); + ys.push(v[1]); + } + } + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + [xs[xs.len() / 2], ys[ys.len() / 2]] + }) + .collect(); + flow +} + +/// Cross-fade of two frames: `a·(1 − w) + b·w` (Frame Mix). +pub fn mix(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return if w < 0.5 { a.clone() } else { b.clone() }; + } + let mut out = a.clone(); + out.data.par_iter_mut().zip(b.data.par_iter()).for_each(|(p, q)| { + for c in 0..4 { + p[c] += (q[c] - p[c]) * w; + } + }); + out +} + +/// Motion-compensated frame at fraction `w` (0 = `a`, 1 = `b`) between two frames (Pixel Motion). +pub fn interpolate(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return mix(a, b, w); + } + let flow = block_flow(a, b, 8, 4); + let mut out = Image::new(a.width, a.height); + let wd = w as f64; + out.rows_mut().for_each(|(y, row)| { + let cy = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let cx = x as f64 + 0.5; + let f = flow.at(cx, cy); + let (fx, fy) = (f[0] as f64, f[1] as f64); + let pa = a.sample_bilinear_clamped(cx - wd * fx, cy - wd * fy); + let pb = b.sample_bilinear_clamped(cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy); + for c in 0..4 { + px[c] = pa[c] + (pb[c] - pa[c]) * w; + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured image shifted right by `dx` pixels. + fn texture(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() + 0.1 * (u * 0.013 * v * 0.011).sin(); + let s = s as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img + } + + 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::(); + n += 3; + } + } + s / n as f32 + } + + #[test] + fn flow_finds_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let f = block_flow(&a, &b, 8, 4); + let m = f.at(64.0, 48.0); + assert!((m[0] - 6.0).abs() < 0.3 && m[1].abs() < 0.3, "{m:?}"); + } + + #[test] + fn pixel_motion_beats_frame_mix_on_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let truth = texture(128, 96, 3.0); + let pm = mae(&interpolate(&a, &b, 0.5), &truth, 12); + let fm = mae(&mix(&a, &b, 0.5), &truth, 12); + assert!(pm < fm * 0.25, "pixel motion {pm} vs frame mix {fm}"); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 643de9e7f..dfe538486 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,6 +4,7 @@ //! heavy operation is row-parallel with rayon. pub mod blur; +pub mod flow; pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index a26e652cb..8cd27d598 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -288,6 +288,8 @@ 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, } diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index d60a1ee82..b200386e5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -22,10 +22,10 @@ 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_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::*; @@ -327,6 +327,18 @@ impl<'a> Renderer<'a> { } 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 } => { @@ -365,8 +377,37 @@ impl<'a> Renderer<'a> { buf } - /// A footage frame at source time `t`. - fn footage_frame(&self, _ctx: &EvalCtx, _layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + /// 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> { + 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) } @@ -723,6 +764,29 @@ struct StyledLayer { 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) @@ -763,6 +827,8 @@ mod tests_audio_fx; #[cfg(test)] mod tests_color; #[cfg(test)] +mod tests_frame_blend; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; diff --git a/crates/render/src/tests_frame_blend.rs b/crates/render/src/tests_frame_blend.rs new file mode 100644 index 000000000..c172b81c8 --- /dev/null +++ b/crates/render/src/tests_frame_blend.rs @@ -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> { + 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::(); + 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); +} From adf5db63976163c97af96f0a21f8b93e0241ee62 Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:14:53 -0400 Subject: [PATCH 3/3] M4.4: collapse transformations + slip Collapsed precomps draw their layers straight into the parent with concatenated transforms and multiplied opacity (nested blend modes and adjustment layers act on the parent); a non-Normal mode, track matte or Preserve Transparency composites the collapsed result as a whole; masks, effects or styles force a flattened render. Nested 3D layers use the parent's camera and lights and join the parent's 3D run when the precomp layer is 3D. Continuously Rasterize re-rasterises text and shape layers at their on-screen scale. Draft quality samples nearest-neighbour, Wireframe draws the layer bounds. layer.slip / slipBack (Alt+PageUp) / slipForward (Alt+PageDown) and dragging the timeline source bar slip the source under fixed in/out points, clamped to the source duration; undoable. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/commands/layer.rs | 62 ++++++++ crates/engine/src/tests_fidelity.rs | 55 +++++++ crates/render/src/lib.rs | 178 +++++++++++++++++++++-- crates/render/src/tests_collapse.rs | 202 ++++++++++++++++++++++++++ crates/render/src/three_d/compose.rs | 64 +++++++- crates/ui-egui/src/panels/timeline.rs | 34 ++++- docs/architecture.md | 24 +++ 7 files changed, 603 insertions(+), 16 deletions(-) create mode 100644 crates/render/src/tests_collapse.rs diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09a50660c..38a09b07f 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -629,6 +629,57 @@ fn timing(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like +/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive +/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and +/// layer markers are in layer time, so they travel with the source. +fn slip(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) { + (Some(f), _) => fr.tick_of(f), + (None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)), + _ => return Err(bad("layer.slip", "delta (seconds) or frames required")), + }; + // Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out. + let mut shifts: Vec<(LayerId, Tick)> = Vec::new(); + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + if l.props.get("timeRemap").is_some() { + continue; + } + let dur = match &l.source { + LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)), + _ => None, + }, + LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), + _ => None, + }; + let Some(dur) = dur else { continue }; + let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur)); + let (lo, hi) = (a.min(b), a.max(b)); + let (min_d, max_d) = (l.out_point - hi, l.in_point - lo); + if min_d > max_d { + continue; + } + shifts.push((l.id, delta.max(min_d).min(max_d))); + } + if shifts.is_empty() { + return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)")); + } + s.edit("Slip Edit", merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &shifts { + if let Some(l) = comp.layer_mut(*id) { + l.start_time += *d; + } + } + Ok(()) + })?; + Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::>()})) +} + fn arrange(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let how = str_p(p, "to").unwrap_or("front").to_string(); @@ -970,6 +1021,17 @@ pub fn specs() -> Vec { has_layers, timing ), + cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip), + cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(-1); + slip(s, &p) + }), + cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(1); + slip(s, &p) + }), cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange), cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose), cmd!( diff --git a/crates/engine/src/tests_fidelity.rs b/crates/engine/src/tests_fidelity.rs index d1b63e2d8..e5e84c03d 100644 --- a/crates/engine/src/tests_fidelity.rs +++ b/crates/engine/src/tests_fidelity.rs @@ -26,3 +26,58 @@ fn project_colour_settings_are_undoable() { assert_eq!(s.execute("file.cycleBitDepth", json!({})).unwrap(), json!(want)); } } + +#[test] +fn footage_colour_profile_is_interpreted() { + let mut s = Session::default(); + let id = s.execute("file.importPlaceholder", json!({"name": "clip", "width": 64, "height": 36, "frameRate": 24, "duration": 2})).unwrap(); + let item = id["item"].as_u64().or_else(|| id.as_u64()).map(effectcraft_project::ItemId).unwrap_or_else(|| *s.project.items.keys().last().unwrap()); + let profile = |s: &Session| match &s.project.item(item).unwrap().kind { + effectcraft_project::ItemKind::Footage(f) => f.color_profile, + _ => panic!("not footage"), + }; + s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "rec2020"})).unwrap(); + assert_eq!(profile(&s), Some(ColorSpace::Rec2020)); + s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "auto"})).unwrap(); + assert_eq!(profile(&s), None); + assert!(s.undo()); + assert_eq!(profile(&s), Some(ColorSpace::Rec2020)); +} + +#[test] +fn slip_moves_the_source_under_fixed_in_and_out() { + use effectcraft_time::Tick; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "frameRate": 30, "duration": 10})).unwrap(); + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 4, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + let id = s.active_comp().unwrap().layers[0].id.0; + // Trim to 1 s .. 3 s of a 4 s source. + s.execute("layer.timing", json!({"layers": [id], "in": 1.0, "out": 3.0})).unwrap(); + let get = |s: &Session| s.active_comp().unwrap().layers[0].clone(); + let before = get(&s); + assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0)); + // Slip the source 0.5 s later: in/out stay, the source (start) moves. + s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap(); + let l = get(&s); + assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point)); + // The source time at comp 2 s is now 1.5 s. + assert_eq!(l.layer_time(Tick::from_seconds_f64(2.0)).seconds(), 1.5); + // Clamped so the source still covers in..out: at most start = in (1 s). + let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap(); + assert_eq!(get(&s).start_time.seconds(), 1.0); + assert_eq!(r["slipped"][0]["delta"], json!(0.5)); + // Alt+PageUp / Alt+PageDown nudge by a frame. + s.execute("layer.slipBack", json!({"layers": [id]})).unwrap(); + assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9); + s.execute("layer.slipForward", json!({"layers": [id]})).unwrap(); + assert_eq!(get(&s).start_time.seconds(), 1.0); + // Undo walks back through the slips. + assert!(s.undo() && s.undo() && s.undo() && s.undo()); + assert_eq!(get(&s).start_time, Tick::ZERO); + assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp")); + assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown")); + // Layers without a source duration can't slip. + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err()); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index b200386e5..05c05efa5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -21,7 +21,7 @@ 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_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}; @@ -160,6 +160,24 @@ pub struct Renderer<'a> { 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, + /// 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>, +} + +/// 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). @@ -179,12 +197,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, pipe: color::Pipe::of(&project.settings) } + 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, ..*self } + 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 { + 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> { @@ -238,7 +383,10 @@ impl<'a> Renderer<'a> { three_d::compose::draw_run(self, ctx, &visible[i..j], canvas); i = j; } else { - self.draw_layer(ctx, visible[i], 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. @@ -363,8 +511,8 @@ impl<'a> Renderer<'a> { } Some(buf) } - LayerSource::Text => Some(self.authored(text::render(ctx, layer, s))), - LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(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, } } @@ -480,7 +628,7 @@ impl<'a> Renderer<'a> { } fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { - 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) { @@ -501,7 +649,7 @@ 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> { - 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) { @@ -565,7 +713,10 @@ impl<'a> Renderer<'a> { } 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 @@ -578,6 +729,7 @@ impl<'a> Renderer<'a> { fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 { let s = self.opts.scale; let (l2c, _) = ctx.layer_to_comp(layer); + let l2c = self.outer.map_or(l2c, |o| o * l2c); 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])) } @@ -610,7 +762,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; } @@ -825,6 +981,8 @@ 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; diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs new file mode 100644 index 000000000..d3cf954cf --- /dev/null +++ b/crates/render/src/tests_collapse.rs @@ -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)); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 421587bc7..53ba8cdbc 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -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), @@ -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 = 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> { + 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. diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ab85e8eb1..be891090f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -953,8 +953,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { let xs = tm.x(layer.start_time.seconds()); let xe = tm.x(layer.comp_time(d).seconds()); - let ghost = Rect::from_min_max(pos2(xs, r.min.y + 9.0), pos2(xe, r.max.y - 9.0)); + let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0)); gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18)); + // Dragging the source bar outside the in/out span slips the source. + for (side, gr) in [ + ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), + ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max)), + ] { + if gr.width() < 2.0 { + continue; + } + let gresp = ui.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::drag()).on_hover_text("Drag to slip the source"); + app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip"); + if gresp.hovered() || gresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let acc_id = egui::Id::new(("slip-acc", layer.id.0)); + if gresp.dragged() { + let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); + acc += gresp.drag_delta().x; + let frames = ((acc as f64 / pps) / fd).trunc() as i64; + if frames != 0 { + acc -= (frames as f64 * fd * pps) as f32; + actions.push(( + "layer.slip".into(), + json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)}), + )); + } + ctx.data_mut(|d| d.insert_temp(acc_id, acc)); + } + if gresp.drag_stopped() { + ctx.data_mut(|d| d.remove::(acc_id)); + ui_actions.push(UiAct::EndMerge); + } + } } app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name); // Interactions: move body, trim edges. diff --git a/docs/architecture.md b/docs/architecture.md index b78865817..dc95f35b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,30 @@ groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `pup Half, Third and Quarter resolution render proportionally fewer pixels end to end. +**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are +`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and +after every effect, and the comp after every layer (16 bpc uses 0..32768), so over-range values +(Add, Screen, Exposure…) only survive in 32 bpc; blend modes without HDR support clamp their +inputs in 32 bpc. With a working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices derived +from the published primaries), footage is converted from its colour profile (stream metadata or +Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB display. Linearize +Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for +blending. Layer cache keys include these settings. + +**Frame blending**: footage and precomp layers whose source time falls between source frames +(rate conform, stretch, remap) blend the two neighbouring frames, by cross-fade (Frame Mix) or by +hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, +`raster::flow`). + +**Collapse Transformations**: a collapsed precomp layer without masks, effects or styles draws +its nested layers straight into the parent with concatenated transforms (one resample), so +nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the +parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's +3D run. Masks, effects or styles force a flattened render. On text and shape layers the switch is +Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples +nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, +dragging the source bar in the timeline) moves the source under fixed in/out points. + **Motion tracking** (Animation ▸ Track Motion / Stabilize Motion, Window ▸ Tracker): a tracker is a `Tracker` group under the layer's Motion Trackers group, with Track Point groups (Feature Center, Feature Size, Search Offset, Search Size, Confidence, Attach Point, Attach Point Offset)