From 029c0da5e5ee051e529ad9226ea5892da341b67d Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Fri, 2 Oct 2026 10:03:36 -0400 Subject: [PATCH] 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 ee239c3c..2305f12a 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 e292d917..9212b020 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 00000000..2bc93513 --- /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 92e8836e..ee61f900 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 0180d279..87c1e2df 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 8b5c5f43..5bc51716 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 00000000..d1b63e2d --- /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 c1356ff2..141858ca 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 b3c2a174..7ea69cfa 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 9ad38f39..c87dc187 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 0cd38a09..5140133c 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 a248cfc6..4e1c5611 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 722f3fe1..88fc6d02 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 246ff683..4884411f 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 d1dc692c..a26e652c 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 00000000..7b190b7d --- /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 dc7db2d4..d60a1ee8 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 dc1f3cda..a5a7d8b6 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 173ffc88..dfd49ccc 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 00000000..87f9dd1a --- /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 53023c50..421587bc 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 2a068e3a..01e0399d 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 77e432b9..e0f1a5f2 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 4d0e0bb1..31d7d4de 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();