diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs index 2bc93513..ee7590ec 100644 --- a/crates/color/src/space.rs +++ b/crates/color/src/space.rs @@ -1,5 +1,5 @@ -//! RGB colour spaces for colour management: the project working space, footage interpretation -//! and the display. +//! RGB colour spaces for colour management: the project working space, footage interpretation, +//! output and the display. //! //! Each space is defined by its published primaries and white point and its transfer function: //! @@ -7,15 +7,23 @@ //! * **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. +//! * **ACEScg** — AP1 primaries (Academy S-2014-004), the ACES white (x 0.32168, y 0.33767), +//! linear. +//! * **ACES2065-1** — AP0 primaries (SMPTE ST 2065-1), the ACES white, linear. +//! * **Rec. 2100 PQ** — BT.2020 primaries with the SMPTE ST 2084 / ITU-R BT.2100 perceptual +//! quantizer; linear 1.0 is the HDR reference white of ITU-R BT.2408 (203 cd/m²). +//! * **Rec. 2100 HLG** — BT.2020 primaries with the ITU-R BT.2100 hybrid log-gamma OETF; +//! linear 1.0 is the BT.2408 reference white (75% HLG signal). //! //! 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 +//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), and +//! spaces whose white is not D65 (ACES) are adapted to D65 with the Bradford transform, 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). +/// An RGB colour space. #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum ColorSpace { /// sRGB IEC 61966-2-1. @@ -26,13 +34,117 @@ pub enum ColorSpace { Rec2020, /// Display P3. DisplayP3, + /// ACEScg (AP1, linear). + AcesCg, + /// ACES2065-1 (AP0, linear). + Aces2065, + /// Rec. 2100 PQ (BT.2020 primaries, SMPTE ST 2084). + Rec2100Pq, + /// Rec. 2100 HLG (BT.2020 primaries, hybrid log-gamma). + Rec2100Hlg, } /// CIE xy chromaticity of D65. const D65: [f64; 2] = [0.3127, 0.3290]; +/// CIE xy chromaticity of the ACES white point. +pub const ACES_WHITE: [f64; 2] = [0.32168, 0.33767]; +const AP0: [[f64; 2]; 3] = [[0.7347, 0.2653], [0.0, 1.0], [0.0001, -0.0770]]; +const AP1: [[f64; 2]; 3] = [[0.713, 0.293], [0.165, 0.830], [0.128, 0.044]]; +const BT2020: [[f64; 2]; 3] = [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]]; + +/// SMPTE ST 2084 constants. +const PQ_M1: f64 = 2610.0 / 16384.0; +const PQ_M2: f64 = 2523.0 / 4096.0 * 128.0; +const PQ_C1: f64 = 3424.0 / 4096.0; +const PQ_C2: f64 = 2413.0 / 4096.0 * 32.0; +const PQ_C3: f64 = 2392.0 / 4096.0 * 32.0; +/// Linear 1.0 in cd/m² (ITU-R BT.2408 HDR reference white). +pub const PQ_REFERENCE_WHITE: f64 = 203.0; +/// ITU-R BT.2100 HLG constants. +const HLG_A: f64 = 0.178_832_77; +const HLG_B: f64 = 0.284_668_92; +const HLG_C: f64 = 0.559_910_73; +/// Scene light (E, 0..1) of linear 1.0: the BT.2408 reference white at a 75% HLG signal. +pub const HLG_REFERENCE: f64 = 0.264_962_56; + +/// SMPTE ST 2084 inverse EOTF: absolute luminance / 10 000 cd/m² → signal. +pub fn pq_encode(y: f64) -> f64 { + let y = y.max(0.0).powf(PQ_M1); + ((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y)).powf(PQ_M2) +} + +/// SMPTE ST 2084 EOTF: signal → absolute luminance / 10 000 cd/m². +pub fn pq_decode(e: f64) -> f64 { + let p = e.clamp(0.0, 1.0).powf(1.0 / PQ_M2); + ((p - PQ_C1).max(0.0) / (PQ_C2 - PQ_C3 * p)).powf(1.0 / PQ_M1) +} + +/// ITU-R BT.2100 HLG OETF: scene light E (0..1) → signal. +pub fn hlg_encode(e: f64) -> f64 { + let e = e.max(0.0); + if e <= 1.0 / 12.0 { (3.0 * e).sqrt() } else { HLG_A * (12.0 * e - HLG_B).ln() + HLG_C } +} + +/// ITU-R BT.2100 HLG inverse OETF: signal → scene light E. +pub fn hlg_decode(v: f64) -> f64 { + let v = v.max(0.0); + if v <= 0.5 { v * v / 3.0 } else { (((v - HLG_C) / HLG_A).exp() + HLG_B) / 12.0 } +} + +/// Transfer curves (see [`ColorSpace::curve`]). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Curve { + Linear, + Srgb, + /// BT.1886 gamma 2.4. + Gamma24, + Pq, + Hlg, +} + +impl Curve { + /// Encoded value → linear light. + pub fn decode(self, v: f32) -> f32 { + let a = v.abs(); + let l = match self { + Curve::Linear => a, + Curve::Srgb => crate::srgb_to_linear(a), + Curve::Gamma24 => a.powf(2.4), + Curve::Pq => (pq_decode(a as f64) * 10_000.0 / PQ_REFERENCE_WHITE) as f32, + Curve::Hlg => (hlg_decode(a as f64) / HLG_REFERENCE) as f32, + }; + l.copysign(v) + } + + /// Linear light → encoded value. + pub fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + Curve::Linear => a, + Curve::Srgb => crate::linear_to_srgb(a), + Curve::Gamma24 => a.powf(1.0 / 2.4), + Curve::Pq => pq_encode(a as f64 * PQ_REFERENCE_WHITE / 10_000.0) as f32, + Curve::Hlg => hlg_encode(a as f64 * HLG_REFERENCE) as f32, + }; + e.copysign(v) + } +} impl ColorSpace { - pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3]; + /// Every space (footage interpretation, output). + pub const ALL: [ColorSpace; 8] = [ + ColorSpace::Srgb, + ColorSpace::Rec709, + ColorSpace::Rec2020, + ColorSpace::DisplayP3, + ColorSpace::AcesCg, + ColorSpace::Aces2065, + ColorSpace::Rec2100Pq, + ColorSpace::Rec2100Hlg, + ]; + /// Spaces a project can work in (Project Settings ▸ Color ▸ Working Space). + pub const WORKING: [ColorSpace; 6] = + [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3, ColorSpace::AcesCg, ColorSpace::Aces2065]; /// Display name (as in Project Settings ▸ Color ▸ Working Space). pub fn label(self) -> &'static str { @@ -41,16 +153,25 @@ impl ColorSpace { ColorSpace::Rec709 => "HDTV (Rec. 709)", ColorSpace::Rec2020 => "Rec. 2020", ColorSpace::DisplayP3 => "Display P3", + ColorSpace::AcesCg => "ACEScg", + ColorSpace::Aces2065 => "ACES2065-1", + ColorSpace::Rec2100Pq => "Rec. 2100 PQ", + ColorSpace::Rec2100Hlg => "Rec. 2100 HLG", } } - /// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`). + /// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`, `acescg`, + /// `aces2065`, `rec2100pq`, `rec2100hlg`). pub fn id(self) -> &'static str { match self { ColorSpace::Srgb => "srgb", ColorSpace::Rec709 => "rec709", ColorSpace::Rec2020 => "rec2020", ColorSpace::DisplayP3 => "p3", + ColorSpace::AcesCg => "acescg", + ColorSpace::Aces2065 => "aces2065", + ColorSpace::Rec2100Pq => "rec2100pq", + ColorSpace::Rec2100Hlg => "rec2100hlg", } } @@ -62,6 +183,10 @@ impl ColorSpace { "rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709), "rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020), "p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3), + "acescg" | "ap1" => Some(ColorSpace::AcesCg), + "aces2065" | "aces20651" | "aces" | "ap0" => Some(ColorSpace::Aces2065), + "rec2100pq" | "bt2100pq" | "pq" | "st2084" | "hdr10" => Some(ColorSpace::Rec2100Pq), + "rec2100hlg" | "bt2100hlg" | "hlg" => Some(ColorSpace::Rec2100Hlg), _ => None, } } @@ -70,37 +195,70 @@ impl ColorSpace { 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::Rec2020 | ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => BT2020, ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], + ColorSpace::AcesCg => AP1, + ColorSpace::Aces2065 => AP0, } } - /// Linear RGB → CIE XYZ (Y of white = 1). + /// White point (CIE xy). + pub fn white(self) -> [f64; 2] { + match self { + ColorSpace::AcesCg | ColorSpace::Aces2065 => ACES_WHITE, + _ => D65, + } + } + + /// The transfer curve. + pub fn curve(self) -> Curve { + match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => Curve::Srgb, + ColorSpace::Rec709 | ColorSpace::Rec2020 => Curve::Gamma24, + ColorSpace::AcesCg | ColorSpace::Aces2065 => Curve::Linear, + ColorSpace::Rec2100Pq => Curve::Pq, + ColorSpace::Rec2100Hlg => Curve::Hlg, + } + } + + /// The space is linear light by definition (ACES). + pub fn is_linear(self) -> bool { + self.curve() == Curve::Linear + } + + /// A high-dynamic-range encoding (Rec. 2100 PQ / HLG). + pub fn is_hdr(self) -> bool { + matches!(self.curve(), Curve::Pq | Curve::Hlg) + } + + /// Linear RGB → CIE XYZ D65 (Y of white = 1; non-D65 spaces Bradford-adapted). pub fn to_xyz(self) -> [[f64; 3]; 3] { - rgb_to_xyz(self.primaries(), D65) + let w = self.white(); + let m = rgb_to_xyz(self.primaries(), w); + if w == D65 { m } else { mul(&bradford(w, D65), &m) } } /// 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) + self.curve().decode(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) + self.curve().encode(v) } } +/// Bradford chromatic adaptation from white `src` to white `dst` (CIE xy). +pub fn bradford(src: [f64; 2], dst: [f64; 2]) -> [[f64; 3]; 3] { + const MB: [[f64; 3]; 3] = [[0.8951, 0.2664, -0.1614], [-0.7502, 1.7135, 0.0367], [0.0389, -0.0685, 1.0296]]; + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let s = mul_vec(&MB, xyz(src)); + let d = mul_vec(&MB, xyz(dst)); + let diag = [[d[0] / s[0], 0.0, 0.0], [0.0, d[1] / s[1], 0.0], [0.0, 0.0, d[2] / s[2]]]; + mul(&invert(&MB), &mul(&diag, &MB)) +} + /// 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]]; @@ -160,7 +318,7 @@ impl Conversion { /// 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)); + let same_curve = from_linear == to_linear && (from_linear || from.curve() == to.curve()); if same_primaries && same_curve { return None; } @@ -201,11 +359,6 @@ impl Conversion { } } -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::*; @@ -235,11 +388,17 @@ mod tests { let back = Conversion::new(b, bl, a, al); assert_eq!(fwd.is_none(), back.is_none()); for c in samples { + // HDR signals are 0..1. + if a.is_hdr() && !al && c.iter().any(|v| *v > 1.0) { + continue; + } 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:?}"); + // Pure power curves amplify f32 rounding near zero; PQ signals near 1.0 + // stand for ~50× reference white. + let tol = if a.is_hdr() || b.is_hdr() { 1e-2 } else { 2e-3 }; + assert!((o[i] - c[i]).abs() < tol, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}"); } } } @@ -270,4 +429,42 @@ mod tests { assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb)); assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3)); } + #[test] + fn aces_and_rec2100_spaces() { + // SMPTE ST 2065-1 AP0 → XYZ (ACES white, unadapted). + let m = rgb_to_xyz(ColorSpace::Aces2065.primaries(), ACES_WHITE); + let want = [[0.952_552_4, 0.0, 0.000_093_68], [0.343_966_4, 0.728_166_1, -0.072_132_5], [0.0, 0.0, 1.008_825_2]]; + for i in 0..3 { + for j in 0..3 { + assert!((m[i][j] - want[i][j]).abs() < 1e-5, "{i}{j}: {}", m[i][j]); + } + } + // ACEScg white → sRGB white; ACEScg red sits outside sRGB (negative green/blue). + let c = Conversion::new(ColorSpace::AcesCg, true, ColorSpace::Srgb, true).unwrap(); + let w = c.apply([1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-3), "{w:?}"); + let r = c.apply([1.0, 0.0, 0.0]); + assert!((r[0] - 1.705).abs() < 3e-3 && r[1] < 0.0 && r[2] < 0.0, "{r:?}"); + assert!(ColorSpace::AcesCg.is_linear() && !ColorSpace::Srgb.is_linear()); + // PQ: the BT.2408 reference white (1.0 = 203 cd/m²) encodes to ≈ 0.58; 10 000 cd/m² to 1. + let pq = ColorSpace::Rec2100Pq; + assert!((pq.encode(1.0) - 0.5807).abs() < 1e-3, "{}", pq.encode(1.0)); + assert!((pq.encode(10_000.0 / 203.0) - 1.0).abs() < 1e-4); + assert!((pq_encode(100.0 / 10_000.0) - 0.508).abs() < 1e-3); + // HLG: reference white at 75%; the OETF is continuous at 1/12. + let hlg = ColorSpace::Rec2100Hlg; + assert!((hlg.encode(1.0) - 0.75).abs() < 1e-5, "{}", hlg.encode(1.0)); + assert!((hlg_encode(1.0 / 12.0) - 0.5).abs() < 1e-9 && (hlg_encode(1.0) - 1.0).abs() < 1e-6); + for v in [0.0f32, 0.01, 0.18, 1.0, 3.0] { + assert!((pq.decode(pq.encode(v)) - v).abs() < 1e-3 * v.max(1.0), "pq {v}"); + assert!((hlg.decode(hlg.encode(v)) - v).abs() < 1e-3 * v.max(1.0), "hlg {v}"); + } + assert!(pq.is_hdr() && hlg.is_hdr() && !ColorSpace::Rec2020.is_hdr()); + // Same primaries as Rec. 2020: only the curve changes. + assert!(Conversion::new(ColorSpace::Rec2020, false, ColorSpace::Rec2100Pq, false).unwrap().matrix.is_none()); + for cs in ColorSpace::ALL { + assert_eq!(ColorSpace::parse(cs.id()), Some(cs)); + assert_eq!(ColorSpace::parse(cs.label()), Some(cs), "{}", cs.label()); + } + } } diff --git a/crates/effects/src/controls.rs b/crates/effects/src/controls.rs index 98a5b140..9f220bfc 100644 --- a/crates/effects/src/controls.rs +++ b/crates/effects/src/controls.rs @@ -6,6 +6,13 @@ use effectcraft_project::ParamUi; use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider}; +/// Id of the Stereo 3D Controls effect (Layer ▸ Camera ▸ Create Stereo 3D Rig). +pub const STEREO_CONTROLS: &str = "ec.control.stereo3d"; +/// Stereo 3D Controls ▸ Configuration: which eyes are offset from the master camera. +pub const STEREO_CONFIGURATIONS: [&str; 3] = ["Stereo Pair (Left & Right)", "Center & Right", "Center & Left"]; +/// Stereo 3D Controls ▸ Convergence Of: the zero-parallax distance. +pub const STEREO_CONVERGENCE: [&str; 2] = ["Camera Point of Interest", "Camera Zoom Plane"]; + fn passthrough(_: &EffectCtx, b: Buf) -> Buf { b } @@ -24,5 +31,17 @@ pub fn specs() -> Vec { spec("ec.control.point3d", "3D Point Control", vec![p("point", "3D Point", Value::Vec3([0.0, 0.0, 0.0]), ParamUi::Point3)]), spec("ec.control.layer", "Layer Control", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)]), spec("ec.control.dropdown", "Dropdown Menu Control", vec![p("menu", "Menu", Value::Enum(0), popup(&["Item 1", "Item 2", "Item 3"]))]), + // Layer ▸ Camera ▸ Create Stereo 3D Rig: the rig's eye cameras read these (expressions). + spec( + STEREO_CONTROLS, + "Stereo 3D Controls", + vec![ + p("configuration", "Configuration", Value::Enum(0), popup(&STEREO_CONFIGURATIONS)), + p("sceneDepth", "Stereo Scene Depth", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)), + p("convergence", "Enable Convergence", Value::Bool(false), ParamUi::Checkbox), + p("convergenceOf", "Convergence Of", Value::Enum(0), popup(&STEREO_CONVERGENCE)), + p("zOffset", "Convergence Z Offset", num(0.0), slider(-100_000.0, 100_000.0, -2000.0, 2000.0, 1)), + ], + ), ] } diff --git a/crates/effects/src/lib.rs b/crates/effects/src/lib.rs index a5b4f346..3457e576 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -17,7 +17,7 @@ mod channel3d; mod color2; mod color3; mod color_fx; -mod controls; +pub mod controls; mod distort; mod distort2; mod distort3; @@ -37,6 +37,7 @@ mod noise2; mod noise3; mod obsolete; mod ocio; +pub mod ocio_config; pub mod paint; mod perspective; mod perspective2; diff --git a/crates/effects/src/ocio.rs b/crates/effects/src/ocio.rs index bcde06f9..6dd17c4f 100644 --- a/crates/effects/src/ocio.rs +++ b/crates/effects/src/ocio.rs @@ -559,6 +559,10 @@ pub const DISPLAYS: &[(&str, [[f64; 2]; 3], Tf)] = pub const VIEWS: &[&str] = &["Standard", "Tone Mapped", "Raw"]; +/// Configuration popup: the built-in config, or a custom `.ocio` file (Config File: a path or +/// the config's text; spaces chosen by name, see [`crate::ocio_config`]). +pub const CONFIGS: &[&str] = &["Built-in (EffectCraft minimal)", "Custom (.ocio file)"]; + /// Built-in looks: CDLs applied in ACEScct. pub fn looks() -> Vec<(&'static str, Cdl)> { let grey = 0.413_588_402_492_442_1; // ACEScct of 0.18 @@ -647,7 +651,35 @@ fn space_at(i: u32) -> &'static Space { &SPACES[(i as usize).min(SPACES.len() - 1)] } +/// The custom config chosen by an effect (Configuration ▸ Custom with a readable file). +fn custom_config(ctx: &EffectCtx) -> Option> { + if ctx.params.e("config") != 1 { + return None; + } + crate::ocio_config::load_config(ctx.params.s("configFile")) +} + +/// A space of a custom config by its typed name, else by the built-in popup's name. +fn custom_space<'c>(cfg: &'c crate::ocio_config::Config, name: &str, popup: u32) -> Option<&'c crate::ocio_config::ConfigSpace> { + cfg.space(name).or_else(|| cfg.space(space_at(popup).name)) +} + fn ocio_cst(ctx: &EffectCtx, mut b: Buf) -> Buf { + // Custom configuration that can't be read: pixels pass through. + if ctx.params.e("config") == 1 && custom_config(ctx).is_none() { + return b; + } + if let Some(cfg) = custom_config(ctx) { + let (Some(a), Some(z)) = ( + custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), + custom_space(&cfg, ctx.params.s("destinationName"), ctx.params.e("destination")), + ) else { + return b; + }; + let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) }; + map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32)); + return b; + } let (a, z) = (space_at(ctx.params.e("source")), space_at(ctx.params.e("destination"))); let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) }; if a.name == z.name { @@ -683,6 +715,21 @@ fn ocio_cdl(ctx: &EffectCtx, mut b: Buf) -> Buf { } fn ocio_display(ctx: &EffectCtx, mut b: Buf) -> Buf { + // Custom configuration that can't be read: pixels pass through. + if ctx.params.e("config") == 1 && custom_config(ctx).is_none() { + return b; + } + if let Some(cfg) = custom_config(ctx) { + // The display's view names a colour space of the config. + let display = DISPLAYS.get(ctx.params.e("display") as usize).map_or("", |d| d.0); + let view = VIEWS.get(ctx.params.e("view") as usize).copied().unwrap_or(""); + let dn = if ctx.params.s("displayName").is_empty() { display } else { ctx.params.s("displayName") }; + let vn = if ctx.params.s("viewName").is_empty() { view } else { ctx.params.s("viewName") }; + let (Some(a), Some(z)) = (custom_space(&cfg, ctx.params.s("sourceName"), ctx.params.e("source")), cfg.view_space(dn, vn)) else { return b }; + let (a, z) = if ctx.params.e("direction") == 1 { (z, a) } else { (a, z) }; + map_px(&mut b, |c| cfg.convert(a, z, c.map(|v| v as f64)).map(|v| v as f32)); + return b; + } let input = space_at(ctx.params.e("source")); let f = display_xform(input, ctx.params.e("display") as usize, ctx.params.e("view") as usize, ctx.params.e("direction") == 1); map_px(&mut b, f); @@ -758,6 +805,10 @@ fn profile(ctx: &EffectCtx, i: u32) -> (Space, bool) { ColorSpace::Rec709 => sp("Rec.709", REC709, D65, Tf::Gamma(2.4)), ColorSpace::Rec2020 => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), ColorSpace::DisplayP3 => sp("Display P3", P3, D65, Tf::Srgb), + ColorSpace::AcesCg => *space_by_name("ACEScg").expect("built-in"), + ColorSpace::Aces2065 => *space_by_name("ACES2065-1").expect("built-in"), + // HDR encodings are output spaces, never a working space. + ColorSpace::Rec2100Pq | ColorSpace::Rec2100Hlg => sp("Rec.2020", REC2020, D65, Tf::Gamma(2.4)), }; match i { 0 => (ws(ctx.env.working_space.unwrap_or(ColorSpace::Srgb)), ctx.env.working_linear), @@ -919,9 +970,12 @@ pub fn specs() -> Vec { "OCIO Color Space Transform", cc, vec![ - p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), + p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), space_param("source", "Source", lin709), + p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), space_param("destination", "Destination", srgb), + p("destinationName", "Destination (config name)", Value::Str(String::new()), ParamUi::Text), direction(), ], ocio_cst, @@ -931,10 +985,14 @@ pub fn specs() -> Vec { "OCIO Display Transform", cc, vec![ - p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])), + p("config", "Configuration", Value::Enum(0), popup(CONFIGS)), + p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text), space_param("source", "Input Color Space", lin709), + p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::>())), + p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text), p("view", "View Transform", Value::Enum(0), popup(VIEWS)), + p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text), direction(), ], ocio_display, diff --git a/crates/effects/src/ocio_config.rs b/crates/effects/src/ocio_config.rs new file mode 100644 index 00000000..2ed7cee8 --- /dev/null +++ b/crates/effects/src/ocio_config.rs @@ -0,0 +1,928 @@ +//! Custom OpenColorIO configuration files (`.ocio`) for the OCIO effects, read from the +//! published configuration format (a YAML document), not from OpenColorIO's source. +//! +//! Supported subset: +//! +//! * `search_path` (a string with `:`-separated entries or a list), resolved against the +//! config's folder, for `FileTransform` sources; +//! * `roles` (any role name can stand for its colour space); +//! * `colorspaces`: `name`, `aliases`, `isdata`, and the transforms to / from the reference +//! space — `to_reference` / `from_reference` (OCIO v1) or `to_scene_reference` / +//! `from_scene_reference` (v2); a space with only one direction is inverted for the other; +//! * `displays`: display → views (`! {name, colorspace}`); +//! * transforms: `MatrixTransform` (`matrix` 4×4 row-major, `offset`), `FileTransform` (the LUT +//! and CDL files [`super::ocio::parse_file`] reads; `interpolation`, `ccc_id`), +//! `ExponentTransform` (`value`), `LogTransform` (`base`), `LogAffineTransform` (`base`, +//! `logSideSlope`, `logSideOffset`, `linSideSlope`, `linSideOffset`), `CDLTransform` (`slope`, +//! `offset`, `power`, `sat`), `RangeTransform` (`min_in_value`, `max_in_value`, +//! `min_out_value`, `max_out_value`) and `GroupTransform` (`children`), each with +//! `direction: inverse`. Other transforms are reported by [`Config::unsupported`] and pass +//! colours through. +//! +//! The YAML reader covers what configs use: block mappings and sequences, flow `{}` / `[]` +//! collections (also spanning lines), `!` tags, quoted scalars, `|` / `>` block scalars +//! and comments. + +use std::collections::HashMap; +use std::path::{Path, PathBuf}; +use std::sync::{Arc, Mutex, OnceLock}; + +use super::ocio::{Cdl, FileXform, load_file}; + +// ---------------------------------------------------------------- YAML subset + +/// A parsed YAML node. +#[derive(Clone, Debug, PartialEq)] +pub enum Yaml { + Str(String), + Seq(Vec), + Map(Vec<(String, Yaml)>), + /// A `!` node. + Tagged(String, Box), + Null, +} + +impl Yaml { + pub fn get(&self, k: &str) -> Option<&Yaml> { + match self { + Yaml::Map(m) => m.iter().find(|(key, _)| key == k).map(|(_, v)| v), + Yaml::Tagged(_, v) => v.get(k), + _ => None, + } + } + pub fn str(&self) -> Option<&str> { + match self { + Yaml::Str(s) => Some(s), + Yaml::Tagged(_, v) => v.str(), + _ => None, + } + } + pub fn seq(&self) -> &[Yaml] { + match self { + Yaml::Seq(v) => v, + Yaml::Tagged(_, v) => v.seq(), + _ => &[], + } + } + pub fn tag(&self) -> Option<&str> { + match self { + Yaml::Tagged(t, _) => Some(t), + _ => None, + } + } + fn f(&self) -> Option { + self.str()?.trim().parse().ok() + } + fn nums(&self) -> Vec { + match self { + Yaml::Seq(v) => v.iter().filter_map(Yaml::f).collect(), + Yaml::Str(_) => self.f().into_iter().collect(), + Yaml::Tagged(_, v) => v.nums(), + _ => vec![], + } + } +} + +/// Strip a trailing comment (a `#` outside quotes, at the start or after whitespace). +fn strip_comment(line: &str) -> &str { + let (mut sq, mut dq) = (false, false); + let b = line.as_bytes(); + for (i, &c) in b.iter().enumerate() { + match c { + b'\'' if !dq => sq = !sq, + b'"' if !sq => dq = !dq, + b'#' if !sq && !dq && (i == 0 || b[i - 1].is_ascii_whitespace()) => return &line[..i], + _ => {} + } + } + line +} + +fn unquote(s: &str) -> String { + let t = s.trim(); + if t.len() >= 2 && ((t.starts_with('"') && t.ends_with('"')) || (t.starts_with('\'') && t.ends_with('\''))) { + return t[1..t.len() - 1].replace("\\\"", "\"").replace("''", "'"); + } + t.to_string() +} + +/// Split `key: value` at the first `:` followed by a space or the end (outside quotes/brackets). +fn split_key(s: &str) -> Option<(String, &str)> { + let b = s.as_bytes(); + let (mut sq, mut dq, mut depth) = (false, false, 0i32); + for (i, &c) in b.iter().enumerate() { + match c { + b'\'' if !dq => sq = !sq, + b'"' if !sq => dq = !dq, + b'{' | b'[' if !sq && !dq => depth += 1, + b'}' | b']' if !sq && !dq => depth -= 1, + b':' if !sq && !dq && depth == 0 && (i + 1 == b.len() || b[i + 1] == b' ' || b[i + 1] == b'\t') => { + let k = s[..i].trim(); + if k.is_empty() || k.starts_with('!') || k.starts_with('{') || k.starts_with('[') { + return None; + } + return Some((unquote(k), s[i + 1..].trim())); + } + _ => {} + } + } + None +} + +/// Bracket balance of a flow fragment (outside quotes). +fn balance(s: &str) -> i32 { + let (mut sq, mut dq, mut d) = (false, false, 0); + for c in s.chars() { + match c { + '\'' if !dq => sq = !sq, + '"' if !sq => dq = !dq, + '{' | '[' if !sq && !dq => d += 1, + '}' | ']' if !sq && !dq => d -= 1, + _ => {} + } + } + d +} + +struct Flow<'a> { + s: &'a [u8], + i: usize, +} + +impl Flow<'_> { + fn ws(&mut self) { + while self.i < self.s.len() && (self.s[self.i] as char).is_whitespace() { + self.i += 1; + } + } + fn peek(&self) -> Option { + self.s.get(self.i).copied() + } + fn value(&mut self) -> Yaml { + self.ws(); + match self.peek() { + Some(b'{') => { + self.i += 1; + let mut m = vec![]; + loop { + self.ws(); + match self.peek() { + None => break, + Some(b'}') => { + self.i += 1; + break; + } + Some(b',') => { + self.i += 1; + continue; + } + _ => {} + } + let k = self.scalar(true); + self.ws(); + if self.peek() == Some(b':') { + self.i += 1; + } + let v = self.value(); + m.push((unquote(&k), v)); + } + Yaml::Map(m) + } + Some(b'[') => { + self.i += 1; + let mut v = vec![]; + loop { + self.ws(); + match self.peek() { + None => break, + Some(b']') => { + self.i += 1; + break; + } + Some(b',') => { + self.i += 1; + continue; + } + _ => v.push(self.value()), + } + } + Yaml::Seq(v) + } + Some(b'!') => { + let start = self.i; + while self.i < self.s.len() && !(self.s[self.i] as char).is_whitespace() && self.s[self.i] != b'{' && self.s[self.i] != b'[' { + self.i += 1; + } + let tag = tag_name(std::str::from_utf8(&self.s[start..self.i]).unwrap_or("")); + self.ws(); + let inner = match self.peek() { + Some(b'{') | Some(b'[') => self.value(), + _ => Yaml::Null, + }; + Yaml::Tagged(tag, Box::new(inner)) + } + _ => { + let s = self.scalar(false); + if s.is_empty() { Yaml::Null } else { Yaml::Str(unquote(&s)) } + } + } + } + /// A plain or quoted scalar, ending at `,` `}` `]` (and `:` + space for keys). + fn scalar(&mut self, key: bool) -> String { + self.ws(); + let start = self.i; + if let Some(q @ (b'"' | b'\'')) = self.peek() { + self.i += 1; + while self.i < self.s.len() && self.s[self.i] != q { + self.i += 1; + } + self.i = (self.i + 1).min(self.s.len()); + return String::from_utf8_lossy(&self.s[start..self.i]).into_owned(); + } + while self.i < self.s.len() { + let c = self.s[self.i]; + if c == b',' || c == b'}' || c == b']' { + break; + } + if key && c == b':' { + break; + } + self.i += 1; + } + String::from_utf8_lossy(&self.s[start..self.i]).trim().to_string() + } +} + +fn tag_name(t: &str) -> String { + t.trim().trim_start_matches('!').trim_start_matches('<').trim_end_matches('>').to_string() +} + +fn flow(s: &str) -> Yaml { + Flow { s: s.as_bytes(), i: 0 }.value() +} + +struct Lines { + /// (indent, content without comment), blank lines removed. + v: Vec<(usize, String)>, +} + +impl Lines { + fn new(text: &str) -> Lines { + let v = text + .lines() + .filter(|l| !l.trim_start().starts_with('%') && l.trim() != "---" && l.trim() != "...") + .map(|l| { + let c = strip_comment(l).trim_end(); + (c.len() - c.trim_start().len(), c.trim_start().to_string()) + }) + .filter(|(_, c)| !c.is_empty()) + .collect(); + Lines { v } + } + + /// The value of a `key:` / `- ` remainder `rest` whose children are indented more than + /// `indent` (from line `*i`). + fn value(&self, rest: &str, indent: usize, i: &mut usize) -> Yaml { + let rest = rest.trim(); + if rest.is_empty() { + return match self.v.get(*i) { + // A sequence may sit at the key's own indent. + Some((ind, c)) if *ind > indent || (*ind == indent && (c == "-" || c.starts_with("- "))) => self.block(*ind, i), + _ => Yaml::Null, + }; + } + if rest == "|" || rest == ">" || rest.starts_with("|-") || rest.starts_with(">-") || rest.starts_with("|+") || rest.starts_with(">+") { + let mut out = vec![]; + while let Some((ind, c)) = self.v.get(*i) { + if *ind <= indent { + break; + } + out.push(c.clone()); + *i += 1; + } + return Yaml::Str(out.join(if rest.starts_with('>') { " " } else { "\n" })); + } + if rest.starts_with('!') && !rest.contains('{') && !rest.contains('[') { + let tag = tag_name(rest); + let inner = match self.v.get(*i) { + Some((ind, _)) if *ind > indent => self.block(*ind, i), + _ => Yaml::Null, + }; + return Yaml::Tagged(tag, Box::new(inner)); + } + if rest.starts_with('{') || rest.starts_with('[') || (rest.starts_with('!') && (rest.contains('{') || rest.contains('['))) { + // Flow collections may continue on the next lines. + let mut text = rest.to_string(); + while balance(&text) > 0 && *i < self.v.len() { + text.push(' '); + text.push_str(&self.v[*i].1); + *i += 1; + } + return flow(&text); + } + Yaml::Str(unquote(rest)) + } + + /// A block node whose lines start at `indent`. + fn block(&self, indent: usize, i: &mut usize) -> Yaml { + let Some((_, first)) = self.v.get(*i) else { return Yaml::Null }; + if first == "-" || first.starts_with("- ") { + let mut items = vec![]; + while let Some((ind, c)) = self.v.get(*i) { + if *ind != indent || !(c == "-" || c.starts_with("- ")) { + break; + } + *i += 1; + let rest = c[1..].trim_start().to_string(); + // `- key: value` opens a mapping whose keys are indented to the key. + if split_key(&rest).is_some() && !rest.starts_with('{') && !rest.starts_with('!') { + let inner = indent + 1 + (c.len() - 1 - c[1..].trim_start().len()); + items.push(self.map_from(Some(rest), inner.max(indent + 2), i)); + } else { + items.push(self.value(&rest, indent, i)); + } + } + return Yaml::Seq(items); + } + if split_key(first).is_some() { + return self.map_from(None, indent, i); + } + *i += 1; + Yaml::Str(unquote(first)) + } + + /// A block mapping at `indent`; `first` is an entry already taken from a `- ` line. + fn map_from(&self, first: Option, indent: usize, i: &mut usize) -> Yaml { + let mut m = vec![]; + if let Some(f) = first + && let Some((k, rest)) = split_key(&f) + { + let v = self.value(rest, indent, i); + m.push((k, v)); + } + while let Some((ind, c)) = self.v.get(*i) { + if *ind != indent { + if *ind < indent { + break; + } + // Over-indented stray line: skip. + *i += 1; + continue; + } + let Some((k, rest)) = split_key(c) else { break }; + *i += 1; + let v = self.value(rest, indent, i); + m.push((k, v)); + } + Yaml::Map(m) + } +} + +/// Parse a YAML document (the subset described in the module docs). +pub fn parse_yaml(text: &str) -> Yaml { + let l = Lines::new(text); + let mut i = 0; + let indent = l.v.first().map_or(0, |(ind, _)| *ind); + l.block(indent, &mut i) +} + +// ---------------------------------------------------------------- transforms + +/// A colour transform of a config. +#[derive(Clone, Debug)] +pub enum Xf { + /// 4×4 row-major matrix (RGB of the first three rows/columns) plus offset. + Matrix { + m: [f64; 16], + offset: [f64; 4], + }, + File { + src: String, + xf: Option>, + interp: u32, + ccc: String, + }, + Exponent([f64; 3]), + Log { + base: f64, + }, + LogAffine { + base: f64, + log_slope: [f64; 3], + log_offset: [f64; 3], + lin_slope: [f64; 3], + lin_offset: [f64; 3], + }, + Cdl(Cdl), + Range { + min_in: f64, + max_in: f64, + min_out: f64, + max_out: f64, + }, + Group(Vec), + Inverse(Box), + /// An unsupported transform (by tag): identity. + Unsupported(String), +} + +fn vec3_of(y: Option<&Yaml>, d: f64) -> [f64; 3] { + let v = y.map(Yaml::nums).unwrap_or_default(); + match v.len() { + 0 => [d; 3], + 1 | 2 => [v[0]; 3], + _ => [v[0], v[1], v[2]], + } +} + +fn mat_apply(m: &[f64; 16], off: &[f64; 4], c: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|r| m[r * 4] * c[0] + m[r * 4 + 1] * c[1] + m[r * 4 + 2] * c[2] + off[r]) +} + +fn mat3_inverse(m: &[f64; 16]) -> Option<[[f64; 3]; 3]> { + let a = [[m[0], m[1], m[2]], [m[4], m[5], m[6]], [m[8], m[9], m[10]]]; + let det = a[0][0] * (a[1][1] * a[2][2] - a[1][2] * a[2][1]) - a[0][1] * (a[1][0] * a[2][2] - a[1][2] * a[2][0]) + + a[0][2] * (a[1][0] * a[2][1] - a[1][1] * a[2][0]); + (det.abs() > 1e-12).then(|| effectcraft_color::space::invert(&a)) +} + +fn log_base(v: f64, base: f64) -> f64 { + v.max(f64::MIN_POSITIVE).ln() / base.ln() +} + +impl Xf { + fn from_yaml(y: &Yaml, dir: &Path, search: &[String]) -> Xf { + let tag = y.tag().unwrap_or(""); + let body = match y { + Yaml::Tagged(_, b) => b.as_ref(), + other => other, + }; + let inverse = body.get("direction").and_then(Yaml::str).is_some_and(|d| d.eq_ignore_ascii_case("inverse")); + let xf = match tag { + "MatrixTransform" => { + let v = body.get("matrix").map(Yaml::nums).unwrap_or_default(); + let mut m = [1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0]; + if v.len() == 16 { + m.copy_from_slice(&v); + } else if v.len() == 9 { + for r in 0..3 { + for c in 0..3 { + m[r * 4 + c] = v[r * 3 + c]; + } + } + } + let o = body.get("offset").map(Yaml::nums).unwrap_or_default(); + let mut offset = [0.0; 4]; + for (k, v) in o.iter().take(4).enumerate() { + offset[k] = *v; + } + Xf::Matrix { m, offset } + } + "FileTransform" => { + let src = body.get("src").and_then(Yaml::str).unwrap_or("").to_string(); + let interp = match body.get("interpolation").and_then(Yaml::str).unwrap_or("linear").to_ascii_lowercase().as_str() { + "nearest" => 0, + "tetrahedral" | "best" => 2, + _ => 1, + }; + let ccc = body.get("cccid").or_else(|| body.get("ccc_id")).and_then(Yaml::str).unwrap_or("").to_string(); + let xf = resolve(&src, dir, search).and_then(|p| load_file(&p.to_string_lossy())); + Xf::File { src, xf, interp, ccc } + } + "ExponentTransform" => Xf::Exponent(vec3_of(body.get("value"), 1.0)), + "LogTransform" => Xf::Log { base: body.get("base").and_then(Yaml::f).unwrap_or(2.0) }, + "LogAffineTransform" => Xf::LogAffine { + base: body.get("base").and_then(Yaml::f).unwrap_or(2.0), + log_slope: vec3_of(body.get("logSideSlope"), 1.0), + log_offset: vec3_of(body.get("logSideOffset"), 0.0), + lin_slope: vec3_of(body.get("linSideSlope"), 1.0), + lin_offset: vec3_of(body.get("linSideOffset"), 0.0), + }, + "CDLTransform" => Xf::Cdl(Cdl { + slope: vec3_of(body.get("slope"), 1.0), + offset: vec3_of(body.get("offset"), 0.0), + power: vec3_of(body.get("power"), 1.0), + sat: body.get("sat").and_then(Yaml::f).unwrap_or(1.0), + }), + "RangeTransform" => Xf::Range { + min_in: body.get("min_in_value").and_then(Yaml::f).unwrap_or(0.0), + max_in: body.get("max_in_value").and_then(Yaml::f).unwrap_or(1.0), + min_out: body.get("min_out_value").and_then(Yaml::f).unwrap_or(0.0), + max_out: body.get("max_out_value").and_then(Yaml::f).unwrap_or(1.0), + }, + "GroupTransform" => Xf::Group(body.get("children").map(|c| c.seq().iter().map(|t| Xf::from_yaml(t, dir, search)).collect()).unwrap_or_default()), + other => Xf::Unsupported(other.to_string()), + }; + if inverse { Xf::Inverse(Box::new(xf)) } else { xf } + } + + /// Apply forward (or inverse) to linear-or-encoded RGB. + pub fn apply(&self, c: [f64; 3], inverse: bool) -> [f64; 3] { + match self { + Xf::Inverse(x) => x.apply(c, !inverse), + Xf::Matrix { m, offset } => { + if !inverse { + return mat_apply(m, offset, c); + } + match mat3_inverse(m) { + Some(inv) => { + let d = [c[0] - offset[0], c[1] - offset[1], c[2] - offset[2]]; + effectcraft_color::space::mul_vec(&inv, d) + } + None => c, + } + } + Xf::File { xf, interp, ccc, .. } => match xf { + Some(x) => x.apply(c.map(|v| v as f32), *interp, inverse, ccc).map(|v| v as f64), + None => c, + }, + Xf::Exponent(e) => [0, 1, 2].map(|k| { + let p = if inverse { 1.0 / e[k].max(1e-9) } else { e[k] }; + c[k].max(0.0).powf(p) + }), + Xf::Log { base } => { + if inverse { + c.map(|v| base.powf(v)) + } else { + c.map(|v| log_base(v, *base)) + } + } + Xf::LogAffine { base, log_slope, log_offset, lin_slope, lin_offset } => [0, 1, 2].map(|k| { + if inverse { + (base.powf((c[k] - log_offset[k]) / log_slope[k]) - lin_offset[k]) / lin_slope[k] + } else { + log_slope[k] * log_base(lin_slope[k] * c[k] + lin_offset[k], *base) + log_offset[k] + } + }), + Xf::Cdl(cdl) => { + if inverse { + cdl.invert(c, true) + } else { + cdl.apply(c, true) + } + } + Xf::Range { min_in, max_in, min_out, max_out } => { + let (a0, a1, b0, b1) = if inverse { (*min_out, *max_out, *min_in, *max_in) } else { (*min_in, *max_in, *min_out, *max_out) }; + let k = if (a1 - a0).abs() > 1e-12 { (b1 - b0) / (a1 - a0) } else { 0.0 }; + c.map(|v| b0 + (v - a0) * k) + } + Xf::Group(list) => { + let mut v = c; + if inverse { + for x in list.iter().rev() { + v = x.apply(v, true); + } + } else { + for x in list { + v = x.apply(v, false); + } + } + v + } + Xf::Unsupported(_) => c, + } + } + + fn unsupported(&self, out: &mut Vec) { + match self { + Xf::Unsupported(t) => out.push(t.clone()), + Xf::Inverse(x) => x.unsupported(out), + Xf::Group(v) => v.iter().for_each(|x| x.unsupported(out)), + Xf::File { src, xf: None, .. } => out.push(format!("FileTransform (missing `{src}`)")), + _ => {} + } + } +} + +/// Find a FileTransform source on the search path (relative to the config's folder). +fn resolve(src: &str, dir: &Path, search: &[String]) -> Option { + let p = Path::new(src); + if p.is_absolute() { + return p.exists().then(|| p.to_path_buf()); + } + let mut dirs: Vec = search.iter().map(|s| dir.join(s)).collect(); + dirs.push(dir.to_path_buf()); + dirs.into_iter().map(|d| d.join(src)).find(|c| c.exists()) +} + +// ---------------------------------------------------------------- the config + +/// A colour space of a config. +#[derive(Clone, Debug)] +pub struct ConfigSpace { + pub name: String, + pub aliases: Vec, + pub family: String, + pub is_data: bool, + pub to_ref: Option, + pub from_ref: Option, +} + +impl ConfigSpace { + fn to_reference(&self, c: [f64; 3]) -> [f64; 3] { + match (&self.to_ref, &self.from_ref) { + (Some(x), _) => x.apply(c, false), + (None, Some(x)) => x.apply(c, true), + _ => c, + } + } + fn reference_to(&self, c: [f64; 3]) -> [f64; 3] { + match (&self.from_ref, &self.to_ref) { + (Some(x), _) => x.apply(c, false), + (None, Some(x)) => x.apply(c, true), + _ => c, + } + } +} + +/// A parsed `.ocio` configuration. +#[derive(Clone, Debug, Default)] +pub struct Config { + pub name: String, + pub search_path: Vec, + pub roles: Vec<(String, String)>, + pub spaces: Vec, + /// Display → [(view, colour space)]. + pub displays: Vec<(String, Vec<(String, String)>)>, +} + +impl Config { + /// Parse config text; FileTransform sources resolve against `dir`. + pub fn parse(text: &str, dir: &Path) -> Result { + let y = parse_yaml(text); + if !matches!(y, Yaml::Map(_)) { + return Err("not an OCIO config (expected a YAML mapping)".into()); + } + let search_path: Vec = match y.get("search_path") { + Some(Yaml::Seq(v)) => v.iter().filter_map(|s| s.str().map(str::to_string)).collect(), + Some(s) => s.str().map(|s| s.split(':').map(|p| p.trim().to_string()).filter(|p| !p.is_empty()).collect()).unwrap_or_default(), + None => vec![], + }; + let roles = match y.get("roles") { + Some(Yaml::Map(m)) => m.iter().filter_map(|(k, v)| v.str().map(|s| (k.clone(), s.to_string()))).collect(), + _ => vec![], + }; + let mut spaces = vec![]; + for list in ["colorspaces", "display_colorspaces"] { + for cs in y.get(list).map(Yaml::seq).unwrap_or(&[]) { + let Some(name) = cs.get("name").and_then(Yaml::str) else { continue }; + let xf = |keys: &[&str]| keys.iter().find_map(|k| cs.get(k)).filter(|v| !matches!(v, Yaml::Null)).map(|v| Xf::from_yaml(v, dir, &search_path)); + spaces.push(ConfigSpace { + name: name.to_string(), + aliases: cs.get("aliases").map(|a| a.seq().iter().filter_map(|s| s.str().map(str::to_string)).collect()).unwrap_or_default(), + family: cs.get("family").and_then(Yaml::str).unwrap_or("").to_string(), + is_data: cs.get("isdata").and_then(Yaml::str).is_some_and(|v| v == "true"), + to_ref: xf(&["to_reference", "to_scene_reference", "to_display_reference"]), + from_ref: xf(&["from_reference", "from_scene_reference", "from_display_reference"]), + }); + } + } + if spaces.is_empty() { + return Err("the config has no colorspaces".into()); + } + let displays = match y.get("displays") { + Some(Yaml::Map(m)) => m + .iter() + .map(|(d, views)| { + let v = views.seq().iter().filter_map(|v| Some((v.get("name")?.str()?.to_string(), v.get("colorspace")?.str()?.to_string()))).collect(); + (d.clone(), v) + }) + .collect(), + _ => vec![], + }; + Ok(Config { name: y.get("name").and_then(Yaml::str).unwrap_or("").to_string(), search_path, roles, spaces, displays }) + } + + pub fn space_names(&self) -> Vec<&str> { + self.spaces.iter().map(|s| s.name.as_str()).collect() + } + + /// A colour space by name, alias or role (case-insensitive). + pub fn space(&self, name: &str) -> Option<&ConfigSpace> { + let n = name.trim(); + let by = |n: &str| self.spaces.iter().find(|s| s.name.eq_ignore_ascii_case(n) || s.aliases.iter().any(|a| a.eq_ignore_ascii_case(n))); + by(n).or_else(|| self.roles.iter().find(|(r, _)| r.eq_ignore_ascii_case(n)).and_then(|(_, s)| by(s))) + } + + /// The colour space of a display's view. + pub fn view_space(&self, display: &str, view: &str) -> Option<&ConfigSpace> { + let (_, views) = self.displays.iter().find(|(d, _)| d.eq_ignore_ascii_case(display.trim())).or(self.displays.first())?; + let (_, cs) = views.iter().find(|(v, _)| v.eq_ignore_ascii_case(view.trim())).or(views.first())?; + self.space(cs) + } + + /// Convert one colour from space `a` to space `b` (through the reference space; data + /// spaces pass through). + pub fn convert(&self, a: &ConfigSpace, b: &ConfigSpace, c: [f64; 3]) -> [f64; 3] { + if a.is_data || b.is_data || a.name == b.name { + return c; + } + b.reference_to(a.to_reference(c)) + } + + /// Transforms this module can't apply (by tag), for warnings. + pub fn unsupported(&self) -> Vec { + let mut out = vec![]; + for s in &self.spaces { + for x in [&s.to_ref, &s.from_ref].into_iter().flatten() { + x.unsupported(&mut out); + } + } + out.sort(); + out.dedup(); + out + } +} + +type ConfigCache = Mutex>>>; + +/// Load (and cache) a config from a `.ocio` path or the config text itself. +pub fn load_config(src: &str) -> Option> { + let t = src.trim(); + if t.is_empty() { + return None; + } + static CACHE: OnceLock = OnceLock::new(); + let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + if let Ok(m) = cache.lock() + && let Some(v) = m.get(src) + { + return v.clone(); + } + let parsed = if src.contains('\n') { + Config::parse(src, Path::new(".")) + } else { + let p = Path::new(t); + std::fs::read_to_string(p).map_err(|e| e.to_string()).and_then(|text| Config::parse(&text, p.parent().unwrap_or(Path::new(".")))) + } + .ok() + .map(Arc::new); + if let Ok(mut m) = cache.lock() { + m.insert(src.to_string(), parsed.clone()); + } + parsed +} + +#[cfg(test)] +mod tests { + use super::*; + + const CONFIG: &str = r#" +ocio_profile_version: 2 + +name: Test Config # a comment +search_path: "luts:other" +roles: + scene_linear: lin + color_timing: "log2" + +displays: + sRGB: + - ! {name: Standard, colorspace: gamma22} + - ! {name: Raw, colorspace: raw} + +colorspaces: + - ! + name: lin + family: "" + description: | + The reference. + Linear. + isdata: false + + - ! + name: gamma22 + aliases: [g22, "gamma 2.2"] + to_scene_reference: ! {value: [2.2, 2.2, 2.2, 1]} + + - ! + name: half + from_reference: ! {matrix: [0.5, 0, 0, 0, + 0, 0.5, 0, 0, + 0, 0, 0.5, 0, + 0, 0, 0, 1], offset: [0.1, 0.1, 0.1, 0]} + + - ! + name: log2 + from_reference: ! + children: + - ! {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]} + - ! {base: 2} + + - ! + name: affine + to_reference: ! {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse} + + - ! + name: lut + to_reference: ! {src: double.cube, interpolation: linear} + + - ! + name: raw + isdata: true + + - ! + name: weird + to_reference: ! {exposure: 1} +"#; + + fn close(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() < tol) + } + + #[test] + fn yaml_subset() { + let y = parse_yaml("a: 1\nb:\n - x\n - {k: [1, 2], s: 'q: r'}\nc: ! {v: 3}\nd:\n- 4\n- 5\n"); + assert_eq!(y.get("a").and_then(Yaml::str), Some("1")); + assert_eq!(y.get("b").unwrap().seq().len(), 2); + assert_eq!(y.get("b").unwrap().seq()[1].get("k").unwrap().nums(), vec![1.0, 2.0]); + assert_eq!(y.get("b").unwrap().seq()[1].get("s").and_then(Yaml::str), Some("q: r")); + assert_eq!(y.get("c").unwrap().tag(), Some("T")); + assert_eq!(y.get("c").unwrap().get("v").and_then(Yaml::str), Some("3")); + assert_eq!(y.get("d").unwrap().seq().len(), 2, "a sequence at the key's indent"); + } + + #[test] + fn parses_spaces_roles_displays_and_converts() { + let dir = std::env::temp_dir().join(format!("ec-ocio-{}", std::process::id())); + std::fs::create_dir_all(dir.join("luts")).unwrap(); + // A 1D LUT that doubles (0..1 → 0..2). + std::fs::write(dir.join("luts/double.cube"), "LUT_1D_SIZE 2\nDOMAIN_MIN 0 0 0\nDOMAIN_MAX 1 1 1\n0 0 0\n2 2 2\n").unwrap(); + let c = Config::parse(CONFIG, &dir).unwrap(); + assert_eq!(c.name, "Test Config"); + assert_eq!(c.search_path, vec!["luts", "other"]); + assert_eq!(c.space_names(), vec!["lin", "gamma22", "half", "log2", "affine", "lut", "raw", "weird"]); + // Roles and aliases resolve. + assert_eq!(c.space("scene_linear").unwrap().name, "lin"); + assert_eq!(c.space("G22").unwrap().name, "gamma22"); + assert_eq!(c.space("gamma 2.2").unwrap().name, "gamma22"); + assert_eq!(c.view_space("sRGB", "Standard").unwrap().name, "gamma22"); + let sp = |n: &str| c.space(n).unwrap(); + // gamma22 → lin: decode with the exponent. + let g = c.convert(sp("gamma22"), sp("lin"), [0.5, 0.25, 1.0]); + assert!(close(g, [0.5f64.powf(2.2), 0.25f64.powf(2.2), 1.0], 1e-9), "{g:?}"); + // lin → gamma22: the inverse (only to_reference given). + let back = c.convert(sp("lin"), sp("gamma22"), g); + assert!(close(back, [0.5, 0.25, 1.0], 1e-9), "{back:?}"); + // lin → half: matrix and offset; and back through the inverse matrix. + let h = c.convert(sp("lin"), sp("half"), [0.4, 0.6, 1.0]); + assert!(close(h, [0.3, 0.4, 0.6], 1e-9), "{h:?}"); + assert!(close(c.convert(sp("half"), sp("lin"), h), [0.4, 0.6, 1.0], 1e-9)); + // lin → log2: a group (×2 then log2): 0.5 → 0; 2 → 2. + let l = c.convert(sp("lin"), sp("log2"), [0.5, 2.0, 4.0]); + assert!(close(l, [0.0, 2.0, 3.0], 1e-9), "{l:?}"); + assert!(close(c.convert(sp("log2"), sp("lin"), l), [0.5, 2.0, 4.0], 1e-9)); + // LogAffine (inverse direction as to_reference) round-trips. + let a = c.convert(sp("lin"), sp("affine"), [0.18, 0.5, 1.0]); + let expect = [0.18f64, 0.5, 1.0].map(|v| 0.5 * (2.0 * v + 0.01).log10() + 0.2); + assert!(close(a, expect, 1e-9), "{a:?} vs {expect:?}"); + assert!(close(c.convert(sp("affine"), sp("lin"), a), [0.18, 0.5, 1.0], 1e-9)); + // FileTransform found on the search path. + let f = c.convert(sp("lut"), sp("lin"), [0.25, 0.5, 0.0]); + assert!(close(f, [0.5, 1.0, 0.0], 1e-6), "{f:?}"); + // Data spaces pass through; unsupported transforms are reported. + assert_eq!(c.convert(sp("raw"), sp("gamma22"), [0.3, 0.3, 0.3]), [0.3, 0.3, 0.3]); + assert_eq!(c.unsupported(), vec!["ExposureContrastTransform".to_string()]); + // Loading from a file path (cached), and errors. + std::fs::write(dir.join("config.ocio"), CONFIG).unwrap(); + let loaded = load_config(&dir.join("config.ocio").to_string_lossy()).unwrap(); + assert_eq!(loaded.spaces.len(), 8); + assert!(load_config("/no/such/config.ocio").is_none()); + assert!(Config::parse("just: text\n", &dir).is_err()); + } + /// The OCIO Color Space and Display Transform effects with Configuration ▸ Custom. + #[test] + fn effects_use_a_custom_config() { + use effectcraft_keyframe::Value; + let cfg = "ocio_profile_version: 1\nroles:\n default: lin\ndisplays:\n Monitor:\n - ! {name: Video, colorspace: g2}\ncolorspaces:\n - !\n name: lin\n - !\n name: g2\n to_reference: ! {value: [2, 2, 2, 1]}\n"; + let img = crate::Image::filled(2, 2, [0.5, 0.25, 1.0, 1.0]); + let fx = |id: &str, extra: &[(&str, Value)]| { + let mut v = vec![("config", Value::Enum(1)), ("configFile", Value::Str(cfg.into()))]; + v.extend(extra.iter().cloned()); + crate::run_fx(id, &v, img.clone(), 0.0, crate::EffectEnv::default()).img.data[0] + }; + let o = fx("ec.color.ociocolorspace", &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("default".into()))]); + assert!((o[0] - 0.25).abs() < 1e-6 && (o[1] - 0.0625).abs() < 1e-6 && (o[2] - 1.0).abs() < 1e-6, "{o:?}"); + // Inverse direction. + let o = fx( + "ec.color.ociocolorspace", + &[("sourceName", Value::Str("g2".into())), ("destinationName", Value::Str("lin".into())), ("direction", Value::Enum(1))], + ); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // Display ▸ view → its colour space. + let o = fx( + "ec.color.ociodisplay", + &[("sourceName", Value::Str("lin".into())), ("displayName", Value::Str("Monitor".into())), ("viewName", Value::Str("Video".into()))], + ); + assert!((o[0] - 0.5f32.sqrt()).abs() < 1e-6, "{o:?}"); + // An unreadable config leaves pixels alone. + let o = crate::run_fx( + "ec.color.ociocolorspace", + &[("config", Value::Enum(1)), ("configFile", Value::Str("/missing.ocio".into()))], + img.clone(), + 0.0, + crate::EffectEnv::default(), + ); + assert_eq!(o.img.data, img.data); + } +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 580f5fd7..5a69a213 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -258,15 +258,52 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) { proj.settings.blend_linear = l; } + use effectcraft_project::{ColorEngine, ColorSpace, HdrMode}; + let cmd = "file.projectSettings"; + if let Some(e) = str_p(p, "colorEngine") { + let engine = match e.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "adobe" | "adobemanaged" | "builtin" => ColorEngine::Adobe, + "ocio" | "ociomanaged" => ColorEngine::Ocio, + _ => return Err(bad(cmd, format!("colorEngine: adobe|ocio, not `{e}`"))), + }; + if engine == ColorEngine::Ocio && proj.settings.color_engine != ColorEngine::Ocio { + // The OCIO built-in config works in ACES and renders the display through its + // tone-mapped view. + if !proj.settings.working_space.is_some_and(|w| w.is_linear()) { + proj.settings.working_space = Some(ColorSpace::AcesCg); + } + if proj.settings.hdr == HdrMode::Clip { + proj.settings.hdr = HdrMode::ToneMap; + } + } + proj.settings.color_engine = engine; + } if let Some(w) = p.get("workingSpace") { + let ids = ColorSpace::WORKING.iter().map(|c| c.id()).collect::>().join("|"); 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}`")))?, + ColorSpace::parse(n).filter(|c| ColorSpace::WORKING.contains(c)).ok_or_else(|| bad(cmd, format!("workingSpace: none|{ids}, not `{n}`")))?, ), }; } + if proj.settings.color_engine == ColorEngine::Ocio && !proj.settings.working_space.is_some_and(|w| w.is_linear()) { + return Err(bad(cmd, "the OCIO built-in config's working spaces are acescg and aces2065")); + } + if let Some(h) = str_p(p, "hdr") { + proj.settings.hdr = match h.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "clip" | "off" | "none" => HdrMode::Clip, + "compand" => HdrMode::Compand, + "tonemap" | "tonemapped" => HdrMode::ToneMap, + _ => return Err(bad(cmd, format!("hdr: clip|compand|toneMap, not `{h}`"))), + }; + } + if let Some(o) = p.get("outputSpace") { + proj.settings.output_space = match o.as_str() { + None | Some("none" | "None" | "" | "default") => None, + Some(n) => Some(ColorSpace::parse(n).ok_or_else(|| bad(cmd, format!("outputSpace: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, not `{n}`")))?), + }; + } if let Some(r) = p.get("renderer").or_else(|| p.get("gpuAcceleration")) { proj.settings.gpu_acceleration = match r { Value::Bool(b) => *b, @@ -279,8 +316,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { }; } 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 }; + proj.settings.time_display = effectcraft_project::TimeDisplayStyle::parse(t) + .ok_or_else(|| bad("file.projectSettings", format!("timeDisplay: timecode|frames|feet35|feet16, not `{t}`")))?; } Ok(()) })?; @@ -353,7 +390,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, renderer?: gpu|software, timeDisplay?: timecode|frames}", + "{bitDepth?: 8|16|32, colorEngine?: adobe|ocio, workingSpace?: none|srgb|rec709|rec2020|p3|acescg|aces2065, linearize?, blendLinear?, hdr?: clip|compand|toneMap, outputSpace?: srgb|rec709|rec2020|p3|rec2100pq|rec2100hlg, renderer?: gpu|software, timeDisplay?: timecode|frames|feet35|feet16}", always, project_settings ), diff --git a/crates/engine/src/commands/mod.rs b/crates/engine/src/commands/mod.rs index 39fa4234..4112cf6c 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -38,6 +38,7 @@ mod proxy; pub mod puppet; mod query; mod render_queue; +pub(crate) mod rig3d; pub mod roto_cmds; mod settings; mod shape_stroke; @@ -46,7 +47,7 @@ mod styles; mod text_anim; pub mod text_edit; mod three_d; -mod time; +pub mod time; mod track; mod view; pub mod viewer_cmds; @@ -114,6 +115,7 @@ pub fn command_specs() -> &'static [CommandSpec] { v.extend(styles::specs()); v.extend(three_d::specs()); v.extend(model3d::specs()); + v.extend(rig3d::specs()); v.extend(text_anim::specs()); v.extend(text_edit::specs()); v.extend(layer_time::specs()); diff --git a/crates/engine/src/commands/rig3d.rs b/crates/engine/src/commands/rig3d.rs new file mode 100644 index 00000000..f034fa64 --- /dev/null +++ b/crates/engine/src/commands/rig3d.rs @@ -0,0 +1,614 @@ +//! Camera and light rigs: Layer ▸ Camera ▸ Create Stereo 3D Rig / Create Orbit Null / Create +//! Cameras from 3D Model, Layer ▸ Light ▸ Create Lights from 3D Model / Control Light with +//! Camera / Create Environment Light Background Layer. + +use effectcraft_color::Label; +use effectcraft_effects::controls::{STEREO_CONFIGURATIONS, STEREO_CONTROLS, STEREO_CONVERGENCE}; +use effectcraft_geom::{Mat4, Vec3, vec3}; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{AutoOrient, Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project}; +use effectcraft_render::EvalCtx; +use effectcraft_render::three_d::adv::scene::model_to_layer; +use effectcraft_render::three_d::camera::{self, layer_frame}; +use serde_json::{Value, json}; + +use super::layer::insert_layer; +use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, resolve_layer}; +use crate::{EngineError, Result, Session, cmd}; + +fn arr(v: Vec3) -> [f64; 3] { + [v.x, v.y, v.z] +} + +/// Set a property's static value (clears its keyframes and expression). +fn set(l: &mut Layer, path: &str, v: KV) { + if let Some(pr) = l.props.prop_mut(path) { + pr.value = v; + pr.keys.clear(); + pr.expr = None; + } +} + +fn set_expr(l: &mut Layer, path: &str, text: String) { + if let Some(pr) = l.props.prop_mut(path) { + pr.expr = Some(Expression { text, enabled: true }); + } +} + +/// What evaluation needs from the session (cloned so edits can evaluate their own project). +struct Env { + time: effectcraft_time::Tick, + expr: Option>, + footage: std::sync::Arc, +} + +impl Env { + fn of(s: &Session, cid: ItemId) -> Env { + Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() } + } + fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> { + EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) } + } +} + +/// The camera a rig command acts on: `layer`, else a selected camera, else the active camera. +fn camera_p(s: &Session, p: &Value, cid: ItemId, cmd: &str) -> Result { + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let id = match p.get("layer").or_else(|| p.get("camera")) { + Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, "no such layer"))?, + None => s + .state + .selected_layers + .iter() + .copied() + .find(|id| comp.layer(*id).is_some_and(Layer::is_camera)) + .or_else(|| comp.active_camera(s.time_of(cid)).map(|l| l.id)) + .ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?, + }; + if !comp.layer(id).is_some_and(Layer::is_camera) { + return Err(bad(cmd, "the layer is not a camera")); + } + Ok(id) +} + +/// Distance from a camera to what it looks at: its point of interest (two-node), else its zoom. +fn focus_distance(ctx: &EvalCtx, cam: &Layer) -> f64 { + let st = camera::layer_camera(ctx, cam); + if camera::is_two_node(cam) + && let Some(tr) = cam.transform() + { + let poi = Vec3::from(ctx.v3(cam, tr, "poi", [0.0; 3])); + let poi = camera::parent_world(ctx, cam).apply(poi); + return (poi - st.eye).length().max(1.0); + } + st.zoom +} + +// ---------------------------------------------------------------- stereo 3D rig + +/// Stereo rig settings (Stereo 3D Controls). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct StereoParams { + /// 0 Stereo Pair (Left & Right), 1 Center & Right, 2 Center & Left. + pub configuration: u32, + /// Interaxial separation, % of the comp width. + pub scene_depth: f64, + pub convergence: bool, + /// 0 the camera's point of interest, 1 its zoom plane. + pub convergence_of: u32, + pub z_offset: f64, +} + +/// Eye offsets along the camera's x axis, in units of the interaxial separation. +pub fn eye_offsets(configuration: u32) -> [f64; 2] { + match configuration { + 1 => [0.0, 1.0], + 2 => [-1.0, 0.0], + _ => [-0.5, 0.5], + } +} + +/// (position, point of interest) of the left (`eye` 0) or right (1) camera, from the master +/// camera's eye, axes, focus distance and zoom. +pub fn eye_camera(sp: &StereoParams, eye: usize, comp_w: f64, master: (Vec3, Vec3, Vec3), focus: f64, zoom: f64) -> (Vec3, Vec3) { + let (e, fwd, right) = master; + let d = sp.scene_depth / 100.0 * comp_w; + let pos = e + right * (eye_offsets(sp.configuration)[eye.min(1)] * d); + let poi = if sp.convergence { + let dc = if sp.convergence_of == 1 { zoom } else { focus } + sp.z_offset; + e + fwd * dc.max(1.0) + } else { + pos + fwd * focus + }; + (pos, poi) +} + +/// Expression snippets that rebuild [`eye_camera`] from the master camera and the controls. +fn eye_exprs(src_comp: &str, master: &str, controls: &str, two_node: bool, eye: usize) -> (String, String, String) { + let q = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into()); + let k = eye_offsets(0)[eye.min(1)]; + let k1 = eye_offsets(1)[eye.min(1)]; + let k2 = eye_offsets(2)[eye.min(1)]; + let head = format!( + "var src = comp({c});\nvar m = src.layer({m});\nvar fx = src.layer({x}).effect(\"Stereo 3D Controls\");\n\ + var d = fx(\"Stereo Scene Depth\").value / 100 * src.width;\nvar cfg = fx(\"Configuration\").value;\n\ + var k = cfg == 2 ? {k1} : (cfg == 3 ? {k2} : {k});\n\ + var E = m.toWorld([0, 0, 0]);\nvar F = normalize(m.toWorldVec([0, 0, 1]));\n\ + var eye = add(E, mul(normalize(m.toWorldVec([1, 0, 0])), k * d));\n", + c = q(src_comp), + m = q(master), + x = q(controls), + ); + let dm = if two_node { "Math.max(length(sub(m.transform.pointOfInterest, m.transform.position)), 1)" } else { "m.cameraOption.zoom" }; + let pos = format!("{head}eye;"); + let poi = format!( + "{head}var dm = {dm};\nvar dc = (fx(\"Convergence Of\").value == 2 ? m.cameraOption.zoom : dm) + fx(\"Convergence Z Offset\").value;\n\ + fx(\"Enable Convergence\").value ? add(E, mul(F, Math.max(dc, 1))) : add(eye, mul(F, dm));" + ); + let zoom = format!("comp({}).layer({}).cameraOption.zoom;", q(src_comp), q(master)); + (pos, poi, zoom) +} + +fn stereo_p(p: &Value, cmd: &str) -> Result { + let pick = |k: &str, opts: &[&str], ids: &[&str]| -> Result> { + match p.get(k) { + None => Ok(None), + Some(Value::Number(n)) => Ok(n.as_u64().map(|v| (v as u32).min(opts.len() as u32 - 1))), + Some(Value::String(s)) => { + let n = s.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], ""); + let i = ids + .iter() + .position(|v| v.to_ascii_lowercase() == n) + .or_else(|| opts.iter().position(|o| o.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "") == n)); + i.map(|i| Some(i as u32)).ok_or_else(|| bad(cmd, format!("{k}: {}", ids.join("|")))) + } + _ => Err(bad(cmd, format!("{k}: {}", ids.join("|")))), + } + }; + Ok(StereoParams { + configuration: pick("configuration", &STEREO_CONFIGURATIONS, &["stereoPair", "centerRight", "centerLeft"])?.unwrap_or(0), + scene_depth: f_p(p, "sceneDepth").unwrap_or(3.0).clamp(0.0, 100.0), + convergence: b_p(p, "convergence").unwrap_or(false), + convergence_of: pick("convergenceOf", &STEREO_CONVERGENCE, &["poi", "zoom"])?.unwrap_or(0), + z_offset: f_p(p, "zOffset").unwrap_or(0.0), + }) +} + +fn unique_item_name(proj: &Project, base: &str) -> String { + let mut name = base.to_string(); + let mut k = 2; + while proj.items.values().any(|i| i.name == name) { + name = format!("{base} {k}"); + k += 1; + } + name +} + +/// An empty comp with the settings of `src`. +fn sibling_comp(src: &Comp) -> Comp { + let mut c = src.clone(); + c.layers.clear(); + c.markers.clear(); + c +} + +fn stereo_rig(s: &mut Session, p: &Value) -> Result { + let cmd = "camera.stereoRig"; + let cid = comp_id(s, p)?; + let sp = stereo_p(p, cmd)?; + let view3d = p.get("view3d").and_then(Value::as_u64).unwrap_or(5) as u32; + let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let src_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default(); + let t = s.time_of(cid); + let w = src.width as f64; + let existing = src.active_camera(t).map(|l| l.id); + let env = Env::of(s, cid); + let r = s.edit("Create Stereo 3D Rig", None, |proj, st| { + // The master camera: the comp's active camera, else a new one. + let master_id = match existing { + Some(id) => id, + None => { + let name = src.unique_layer_name("Master Camera"); + let l = build::layer(proj, &src, &name, LayerSource::Camera, (src.width, src.height), None); + insert_layer(proj, st, cid, l)? + } + }; + let controls_name = proj.comp(cid).map(|c| c.unique_layer_name("Stereo 3D Controls")).unwrap_or_default(); + let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?; + let mut ctl = build::layer(proj, &src_now, &controls_name, LayerSource::Null, (src.width, src.height), None); + ctl.switches.video = false; + let spec = effectcraft_effects::find(STEREO_CONTROLS).ok_or_else(|| bad(cmd, "Stereo 3D Controls effect missing"))?; + let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut proj.next_id), spec.name, [100.0, 100.0]); + for (k, v) in [ + ("configuration", KV::Enum(sp.configuration)), + ("sceneDepth", KV::Scalar(sp.scene_depth)), + ("convergence", KV::Bool(sp.convergence)), + ("convergenceOf", KV::Enum(sp.convergence_of)), + ("zOffset", KV::Scalar(sp.z_offset)), + ] { + if let Some(pr) = fx.get_mut(k) { + pr.value = v; + } + } + if let Some(g) = ctl.props.sub_mut("effects") { + g.children.push(fx.into()); + } + let ctl_id = ctl.id; + let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?; + let at = src_now.layers.iter().position(|l| l.id == master_id).unwrap_or(0); + proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(at, ctl); + // Master camera frame at the current time. + let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?; + let master = src_now.layer(master_id).cloned().ok_or(EngineError::NoComp)?; + let ctx = env.ctx(proj, cid, &src_now); + let (e, fwd, down) = layer_frame(&ctx, &master); + let right = down.cross(fwd).normalize(); + let focus = focus_distance(&ctx, &master); + let zoom = camera::layer_camera(&ctx, &master).zoom; + let two = camera::is_two_node(&master); + // Left and right eye comps: the source comp collapsed, seen by an eye camera. + let mut eye_comps = vec![]; + for (eye, label) in [(0usize, "Left"), (1, "Right")] { + let name = unique_item_name(proj, &format!("{src_name} {label} Eye")); + let ec = sibling_comp(&src_now); + let eid = proj.add_item(&name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(ec.clone().into())); + let mut nest = build::layer(proj, &ec, &src_name, LayerSource::Comp { item: cid }, (src.width, src.height), Some(src.duration)); + nest.switches.three_d = true; + nest.switches.collapse = true; + let (pos, poi) = eye_camera(&sp, eye, w, (e, fwd, right), focus, zoom); + let mut cam = build::layer(proj, &ec, &format!("{label} Eye Camera"), LayerSource::Camera, (src.width, src.height), None); + set(&mut cam, "transform/position", KV::Vec3(arr(pos))); + set(&mut cam, "transform/poi", KV::Vec3(arr(poi))); + set(&mut cam, "cameraOptions/zoom", KV::Scalar(zoom)); + set(&mut cam, "cameraOptions/focusDistance", KV::Scalar(focus)); + let (pe, poe, ze) = eye_exprs(&src_name, &master.name, &controls_name, two, eye); + set_expr(&mut cam, "transform/position", pe); + set_expr(&mut cam, "transform/poi", poe); + set_expr(&mut cam, "cameraOptions/zoom", ze); + cam.auto_orient = AutoOrient::TowardsPointOfInterest; + let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?; + comp.layers = vec![cam, nest]; + eye_comps.push(eid); + } + // The output comp: both eyes through 3D Glasses. + let out_name = unique_item_name(proj, &format!("{src_name} Stereo 3D")); + let oc = sibling_comp(&src_now); + let oid = proj.add_item(&out_name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(oc.clone().into())); + let left_name = proj.item(eye_comps[0]).map(|i| i.name.clone()).unwrap_or_default(); + let right_name = proj.item(eye_comps[1]).map(|i| i.name.clone()).unwrap_or_default(); + let mut left = build::layer(proj, &oc, &left_name, LayerSource::Comp { item: eye_comps[0] }, (src.width, src.height), Some(src.duration)); + let mut right = build::layer(proj, &oc, &right_name, LayerSource::Comp { item: eye_comps[1] }, (src.width, src.height), Some(src.duration)); + right.switches.video = false; + let glasses = effectcraft_effects::find("ec.perspective.3dglasses").ok_or_else(|| bad(cmd, "3D Glasses effect missing"))?; + let mut g = effectcraft_effects::instantiate(glasses, &mut Ids(&mut proj.next_id), glasses.name, [w, src.height as f64]); + for (k, v) in [("leftView", KV::Layer(Some(left.id.0))), ("rightView", KV::Layer(Some(right.id.0))), ("view3d", KV::Enum(view3d.min(8)))] { + if let Some(pr) = g.get_mut(k) { + pr.value = v; + } + } + if let Some(fxg) = left.props.sub_mut("effects") { + fxg.children.push(g.into()); + } + let (lid, rid) = (left.id, right.id); + proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right]; + st.project_selection = vec![oid]; + Ok(json!({ + "controls": ctl_id.0, + "master": master_id.0, + "leftComp": eye_comps[0].0, + "rightComp": eye_comps[1].0, + "stereoComp": oid.0, + "leftLayer": lid.0, + "rightLayer": rid.0, + })) + })?; + Ok(r) +} + +// ---------------------------------------------------------------- orbit null + +/// Create Orbit Null: a 3D null at the camera's point of interest becomes the camera's parent, +/// so rotating the null orbits the camera around what it looks at. +fn orbit_null(s: &mut Session, p: &Value) -> Result { + let cmd = "camera.orbitNull"; + let cid = comp_id(s, p)?; + let cam_id = camera_p(s, p, cid, cmd)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let cam = comp.layer(cam_id).cloned().ok_or(EngineError::NoComp)?; + if cam.parent.is_some() { + return Err(bad(cmd, format!("`{}` already has a parent", cam.name))); + } + let env = Env::of(s, cid); + let ctx = env.ctx(&s.project, cid, &comp); + let (eye, fwd, _) = layer_frame(&ctx, &cam); + let target = eye + fwd * focus_distance(&ctx, &cam); + let name = comp.unique_layer_name(&format!("{} Orbit Null", cam.name)); + let r = s.edit("Create Orbit Null", None, |proj, st| { + let mut null = build::layer(proj, &comp, &name, LayerSource::Null, (comp.width, comp.height), None); + null.switches.three_d = true; + null.switches.video = true; + set(&mut null, "transform/position", KV::Vec3(arr(target))); + // World → null space (a translated, unrotated, unscaled null: p − P + anchor). + let anchor = Vec3::from(null.props.prop("transform/anchor").map(|a| a.value.as_vec3()).unwrap_or([0.0; 3])); + let to_null = |v: [f64; 3]| arr(Vec3::from(v) - target + anchor); + let nid = null.id; + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let at = c.layers.iter().position(|l| l.id == cam_id).unwrap_or(0); + c.layers.insert(at, null); + let l = c.layer_mut(cam_id).ok_or(EngineError::NoComp)?; + l.parent = Some(nid); + for path in ["transform/position", "transform/poi"] { + if let Some(pr) = l.props.prop_mut(path) { + pr.value = KV::Vec3(to_null(pr.value.as_vec3())); + for k in &mut pr.keys { + k.value = KV::Vec3(to_null(k.value.as_vec3())); + } + } + } + st.selected_layers = vec![nid]; + Ok(json!({"null": nid.0, "camera": cam_id.0, "position": arr(target)})) + })?; + Ok(r) +} + +// ---------------------------------------------------------------- control light with camera + +/// Control Light with Camera: the light rides on the camera (parented at the camera's eye, +/// shining where it looks). Again (or `on: false`) releases it where it is. +fn control_light(s: &mut Session, p: &Value) -> Result { + let cmd = "light.controlWithCamera"; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let lights: Vec = match p.get("layers").or_else(|| p.get("layer")) { + Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(&comp, v)).collect(), + Some(v) => resolve_layer(&comp, v).into_iter().collect(), + None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(Layer::is_light)).collect(), + }; + if lights.is_empty() || lights.iter().any(|id| !comp.layer(*id).is_some_and(Layer::is_light)) { + return Err(bad(cmd, "select a light layer")); + } + let cam_id = match p.get("camera") { + Some(v) => resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(Layer::is_camera)).ok_or_else(|| bad(cmd, "no such camera"))?, + None => comp.active_camera(s.time_of(cid)).map(|l| l.id).ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?, + }; + let on = b_p(p, "on").unwrap_or_else(|| comp.layer(lights[0]).is_none_or(|l| l.parent != Some(cam_id))); + let env = Env::of(s, cid); + let ctx = env.ctx(&s.project, cid, &comp); + // Released lights keep their current world placement. + let frames: Vec<(LayerId, Vec3, Vec3)> = lights + .iter() + .filter_map(|id| comp.layer(*id)) + .map(|l| { + let (e, f, _) = layer_frame(&ctx, l); + (l.id, e, f) + }) + .collect(); + let (w, h) = (comp.width as f64, comp.height as f64); + s.edit(if on { "Control Light with Camera" } else { "Release Light from Camera" }, None, |proj, _| { + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, e, f) in &frames { + let l = c.layer_mut(*id).ok_or(EngineError::NoComp)?; + if on { + l.parent = Some(cam_id); + set(l, "transform/position", KV::Vec3([0.0; 3])); + set(l, "transform/poi", KV::Vec3([0.0, 0.0, 1000.0])); + set(l, "transform/orientation", KV::Vec3([0.0; 3])); + for k in ["transform/rotationX", "transform/rotationY", "transform/rotation"] { + set(l, k, KV::Scalar(0.0)); + } + l.auto_orient = AutoOrient::Off; + } else if l.parent == Some(cam_id) { + l.parent = None; + set(l, "transform/position", KV::Vec3(arr(*e))); + set(l, "transform/poi", KV::Vec3(arr(*e + *f * w.max(h)))); + if l.transform().is_some_and(|t| t.get("poi").is_some()) { + l.auto_orient = AutoOrient::TowardsPointOfInterest; + } + } + } + Ok(()) + })?; + Ok(json!({"controlled": on, "camera": cam_id.0, "lights": lights.iter().map(|l| l.0).collect::>()})) +} + +// ---------------------------------------------------------------- cameras and lights from models + +/// Model layers a "from 3D Model" command reads: `layers`/`layer`, else the selected model layers. +fn model_layers(s: &Session, p: &Value, comp: &Comp, cmd: &str) -> Result> { + let ids: Vec = match p.get("layers").or_else(|| p.get("layer")) { + Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(), + Some(v) => resolve_layer(comp, v).into_iter().collect(), + None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))).collect(), + }; + if ids.is_empty() || ids.iter().any(|id| !comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))) { + return Err(bad(cmd, "select a 3D model layer")); + } + Ok(ids) +} + +/// A camera or light found in a model: (name, world eye, world forward, the model data). +enum FromModel { + Camera { name: String, eye: Vec3, fwd: Vec3, zoom: f64 }, + Light { name: String, eye: Vec3, fwd: Vec3, light: effectcraft_model::ModelLight }, +} + +fn from_model(s: &mut Session, p: &Value, lights: bool) -> Result { + let cmd = if lights { "light.fromModel" } else { "camera.fromModel" }; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let ids = model_layers(s, p, &comp, cmd)?; + let env = Env::of(s, cid); + let ctx = env.ctx(&s.project, cid, &comp); + let mut found = vec![]; + for id in &ids { + let l = comp.layer(*id).ok_or(EngineError::NoComp)?; + let LayerSource::Model { item } = l.source else { continue }; + let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { continue }; + let Some(m) = s.footage.model(item, f) else { return Err(bad(cmd, format!("can't read the model of `{}`", l.name))) }; + let unit = l.props.sub("geometryOptions").map_or(1.0, |g| ctx.f(l, g, "unitScale", 1.0)); + let to_world = ctx.world_matrix(l) * model_to_layer(unit); + for pl in m.placed(lights, None, 0.0) { + let w: Mat4 = to_world * pl.world; + let eye = w.apply(Vec3::ZERO); + let fwd = w.apply_vec(vec3(0.0, 0.0, -1.0)).normalize(); + if lights { + let ml = m.lights[pl.index].clone(); + found.push(FromModel::Light { name: ml.name.clone(), eye, fwd, light: ml }); + } else { + let mc = &m.cameras[pl.index]; + let zoom = match mc.projection { + effectcraft_model::CameraProjection::Perspective { yfov, .. } => comp.height as f64 * 0.5 / (yfov * 0.5).tan(), + // Orthographic cameras become perspective ones framing the same height. + effectcraft_model::CameraProjection::Orthographic { .. } => effectcraft_geom::default_camera_zoom(comp.width as f64), + }; + found.push(FromModel::Camera { name: mc.name.clone(), eye, fwd, zoom }); + } + } + } + if found.is_empty() { + return Err(bad(cmd, if lights { "the model has no lights (KHR_lights_punctual)" } else { "the model has no cameras" })); + } + let made = s.edit(if lights { "Create Lights from 3D Model" } else { "Create Cameras from 3D Model" }, None, |proj, st| { + let mut made = vec![]; + for f in &found { + let c = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?; + let l = match f { + FromModel::Camera { name, eye, fwd, zoom } => { + let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Camera, (c.width, c.height), None); + set(&mut l, "transform/position", KV::Vec3(arr(*eye))); + set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * *zoom))); + set(&mut l, "cameraOptions/zoom", KV::Scalar(*zoom)); + set(&mut l, "cameraOptions/focusDistance", KV::Scalar(*zoom)); + l + } + FromModel::Light { name, eye, fwd, light } => { + let kind = match light.kind { + effectcraft_model::ModelLightKind::Directional => LightKind::Parallel, + effectcraft_model::ModelLightKind::Point => LightKind::Point, + effectcraft_model::ModelLightKind::Spot { .. } => LightKind::Spot, + }; + let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Light { kind }, (c.width, c.height), None); + set(&mut l, "transform/position", KV::Vec3(arr(*eye))); + set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * c.width.max(c.height) as f64))); + let col = light.color; + set(&mut l, "lightOptions/color", KV::Color([col[0], col[1], col[2], 1.0])); + // Intensity: 1 unit of the file (lux / candela) = 100%. + set(&mut l, "lightOptions/intensity", KV::Scalar((light.intensity * 100.0).clamp(0.0, 1000.0))); + if let effectcraft_model::ModelLightKind::Spot { inner, outer } = light.kind { + set(&mut l, "lightOptions/coneAngle", KV::Scalar((outer * 2.0).to_degrees().clamp(0.0, 180.0))); + set(&mut l, "lightOptions/coneFeather", KV::Scalar(((1.0 - inner / outer.max(1e-9)) * 100.0).clamp(0.0, 100.0))); + } + if let Some(r) = light.range { + set(&mut l, "lightOptions/falloff", KV::Enum(1)); + set(&mut l, "lightOptions/falloffDistance", KV::Scalar(r * 100.0)); + } + l + } + }; + made.push(insert_layer(proj, st, cid, l)?.0); + } + Ok(made) + })?; + Ok(json!({"layers": made})) +} + +// ---------------------------------------------------------------- environment background + +/// Create Environment Light Background Layer: a layer showing the environment light's image as +/// the scene's backdrop (an equirectangular sky drawn through the camera). +fn environment_background(s: &mut Session, p: &Value) -> Result { + let cmd = "light.environmentBackground"; + let cid = comp_id(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone(); + let t = s.time_of(cid); + let is_env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: LightKind::Environment }); + let light = match p.get("layer") { + Some(v) => resolve_layer(&comp, v).and_then(|id| comp.layer(id)).filter(|l| is_env(l)).ok_or_else(|| bad(cmd, "not an environment light"))?, + None => s + .state + .selected_layers + .iter() + .filter_map(|id| comp.layer(*id)) + .find(|l| is_env(l)) + .or_else(|| comp.layers.iter().find(|l| is_env(l))) + .ok_or_else(|| bad(cmd, "the composition has no environment light (Layer ▸ New ▸ Light ▸ Environment)"))?, + }; + let src_layer = light + .props + .prop("lightOptions/source") + .and_then(|pr| pr.value_at(light.layer_time(t)).as_layer()) + .and_then(|id| comp.layer(LayerId(id))) + .or_else(|| comp.layers.iter().find(|l| l.environment)) + .ok_or_else(|| bad(cmd, "the environment light has no Source (or set an Environment Layer)"))? + .clone(); + if !matches!(src_layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) { + return Err(bad(cmd, "the environment source must be a footage, comp or solid layer")); + } + let light_id = light.id; + let name = comp.unique_layer_name(&format!("{} Background", src_layer.name)); + let size = effectcraft_render::source_size(&s.project, &src_layer); + let id = s.edit("Create Environment Light Background Layer", None, |proj, st| { + let mut l = build::layer(proj, &comp, &name, src_layer.source.clone(), size, None); + l.switches.three_d = true; + l.environment_background = true; + // The backdrop sits at the bottom of the stack. + let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + let id = l.id; + c.layers.push(l); + st.selected_layers = vec![id]; + Ok(id) + })?; + Ok(json!({"layer": id.0, "light": light_id.0, "source": src_layer.id.0})) +} + +pub fn specs() -> Vec { + vec![ + cmd!( + "camera.stereoRig", + "Create Stereo 3D Rig", + ["Layer", "Camera"], + None, + "{comp?, configuration?: stereoPair|centerRight|centerLeft, sceneDepth? (% of comp width, default 3), convergence?: bool, convergenceOf?: poi|zoom, zOffset?, view3d?: 3D Glasses view index (default 5 Balanced Colored Red Blue)}", + has_comp, + stereo_rig + ), + cmd!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], None, "{comp?, layer?: camera (default selected/active)}", has_comp, orbit_null), + cmd!( + "camera.fromModel", + "Create Cameras from 3D Model", + ["Layer", "Camera"], + None, + "{comp?, layers?: 3D model layers (default selected)}", + has_comp, + |s, p| { from_model(s, p, false) } + ), + cmd!( + "light.fromModel", + "Create Lights from 3D Model", + ["Layer", "Light"], + None, + "{comp?, layers?: 3D model layers (default selected)}", + has_comp, + |s, p| { from_model(s, p, true) } + ), + cmd!( + "light.controlWithCamera", + "Control Light with Camera", + ["Layer", "Light"], + None, + "{comp?, layers?: lights (default selected), camera? (default active), on?: bool (default toggle)}", + has_comp, + control_light + ), + cmd!( + "light.environmentBackground", + "Create Environment Light Background Layer", + ["Layer", "Light"], + None, + "{comp?, layer?: environment light (default selected / first)}", + has_comp, + environment_background + ), + ] +} diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index 273ecf99..2751fbc3 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -25,17 +25,6 @@ pub fn specs() -> Vec { stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"), stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"), // Cameras / lights / materials / 3D views (3D milestone). - stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"), - stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"), - stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"), - stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"), - stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"), - stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"), - stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"), - stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"), - // Color management. - stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"), - stub!("view.simulateOutput", "Simulate Output", [], "{profile}"), // Keyframes / text / tracking. stub!("keys.audioToKeyframes", "Convert Audio to Keyframes", ["Animation", "Keyframe Assistant"], "{}"), stub!("keys.rpfCameraImport", "RPF Camera Import", ["Animation", "Keyframe Assistant"], "{}"), diff --git a/crates/engine/src/commands/three_d.rs b/crates/engine/src/commands/three_d.rs index 55060d17..c9ce4f4d 100644 --- a/crates/engine/src/commands/three_d.rs +++ b/crates/engine/src/commands/three_d.rs @@ -297,7 +297,7 @@ fn views(s: &mut Session, cid: ItemId) -> &mut Views3D { } fn view_p(p: &Value, cmd: &str) -> Result { - let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?; + let v = str_p(p, "view").ok_or_else(|| bad(cmd, "missing `view` (activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3)"))?; View3D::from_id(v).ok_or_else(|| bad(cmd, format!("unknown view `{v}`"))) } @@ -334,6 +334,7 @@ view_cmd!(view_bottom, View3D::Bottom); view_cmd!(view_custom1, View3D::Custom1); view_cmd!(view_custom2, View3D::Custom2); view_cmd!(view_custom3, View3D::Custom3); +view_cmd!(view_default, View3D::Default); fn last_view(s: &mut Session, p: &Value) -> Result { let cid = comp_id(s, p)?; @@ -697,11 +698,12 @@ pub fn specs() -> Vec { "Switch 3D View", [], None, - "{view: activeCamera|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}", + "{view: activeCamera|default|front|left|top|back|right|bottom|custom1|custom2|custom3, comp?}", has_comp, set_3d_view ), cmd!("view.3d.activeCamera", "Active Camera", ["View", "Switch 3D View"], Some("F12"), "{comp?}", has_comp, view_active), + cmd!("view.3d.default", "Default", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_default), cmd!("view.3d.front", "Front", ["View", "Switch 3D View"], Some("F10"), "{comp?}", has_comp, view_front), cmd!("view.3d.left", "Left", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_left), cmd!("view.3d.top", "Top", ["View", "Switch 3D View"], None, "{comp?}", has_comp, view_top), diff --git a/crates/engine/src/commands/time.rs b/crates/engine/src/commands/time.rs index d2971b71..0b6d19bc 100644 --- a/crates/engine/src/commands/time.rs +++ b/crates/engine/src/commands/time.rs @@ -1,5 +1,6 @@ //! Time navigation (CTI) commands. +use effectcraft_project::{Comp, TimeDisplayStyle}; use effectcraft_time::Tick; use serde_json::{Value, json}; @@ -9,7 +10,36 @@ use crate::{EngineError, Result, Session, cmd}; fn report(s: &Session) -> Value { let t = s.time(); let frame = s.active_comp().map(|c| c.frame_rate.frame_at(t)).unwrap_or(0); - json!({"time": t.seconds(), "frame": frame}) + let display = s.active_comp().map(|c| display_time(s, c, t)); + json!({"time": t.seconds(), "frame": frame, "display": display}) +} + +/// A comp time as the project's Time Display Style shows it: timecode (`0:00:02:15`, `;` for +/// drop-frame), frames (`00063`, from Start Numbering Frames At) or Feet + Frames (`0003+15`). +pub fn display_time(s: &Session, comp: &Comp, t: Tick) -> String { + let fr = comp.frame_rate; + let st = &s.project.settings; + match st.time_display { + TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + st.frame_start), + TimeDisplayStyle::Feet35 | TimeDisplayStyle::Feet16 => { + effectcraft_time::format_feet_frames(fr.frame_at(t + comp.display_start) + st.frame_start, st.time_display.frames_per_foot().unwrap_or(16)) + } + TimeDisplayStyle::Timecode => effectcraft_time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false), + } +} + +/// Parse a time typed in the project's Time Display Style (relative `+n`/`-n` from frame +/// `current`) into a comp frame. +pub fn parse_display_time(s: &Session, comp: &Comp, text: &str, current: i64) -> std::result::Result { + let fr = comp.frame_rate; + let st = &s.project.settings; + match st.time_display.frames_per_foot() { + Some(pf) => { + let start = fr.frame_at(comp.display_start) + st.frame_start; + effectcraft_time::parse_feet_frames(text, pf, current + start).map(|f| f - start).map_err(|e| e.0) + } + None => effectcraft_time::parse_timecode(text, fr, fr.supports_drop_frame(), current).map_err(|e| e.0), + } } fn set(s: &mut Session, p: &Value) -> Result { @@ -18,7 +48,7 @@ fn set(s: &mut Session, p: &Value) -> Result { comp.frame_rate.tick_of(f) } else if let Some(tc) = str_p(p, "timecode") { let cur = comp.frame_rate.frame_at(s.time()); - let f = effectcraft_time::parse_timecode(tc, comp.frame_rate, comp.frame_rate.supports_drop_frame(), cur).map_err(|e| super::bad("time.set", e.0))?; + let f = parse_display_time(s, comp, tc, cur).map_err(|e| super::bad("time.set", e))?; comp.frame_rate.tick_of(f) } else { Tick::from_seconds_f64(f_p(p, "time").ok_or_else(|| super::bad("time.set", "need `time`, `frame` or `timecode`"))?) diff --git a/crates/engine/src/commands/viewer_cmds.rs b/crates/engine/src/commands/viewer_cmds.rs index 768733b4..b8b36f15 100644 --- a/crates/engine/src/commands/viewer_cmds.rs +++ b/crates/engine/src/commands/viewer_cmds.rs @@ -10,8 +10,8 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use super::{CommandSpec, always, b_p, bad, comp_id, f_p, has_comp, merge_p, str_p}; -use crate::viewer::{Channel, Snapshot}; -use crate::{EngineError, Result, Session, VertexRef, cmd}; +use crate::viewer::{Channel, SimProfile, Simulation, Snapshot}; +use crate::{EngineError, Result, Session, VertexRef, cmd, query}; /// Composition ▸ Preview ▸ Fast Previews modes. #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -61,8 +61,8 @@ impl FastPreviews { } } -/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews). -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] +/// Viewer display options (Show Channel, exposure, snapshot, Fast Previews, display colour). +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] #[serde(default)] pub struct ViewOptions { pub channel: Channel, @@ -73,6 +73,36 @@ pub struct ViewOptions { /// Show Snapshot (F5 held): the viewer shows the snapshot instead of the frame. pub show_snapshot: bool, pub fast_previews: FastPreviews, + /// View ▸ Use Display Color Management (with a working space): the frame is converted + /// from the working space to the display; off shows the working space's numbers. + pub display_color_management: bool, + /// View ▸ Simulate Output. + pub simulation: Simulation, + /// The profile View ▸ Simulate Output ▸ Custom... uses. + pub custom_simulation: Simulation, +} + +impl Default for ViewOptions { + fn default() -> Self { + ViewOptions { + channel: Channel::default(), + colorized: false, + exposure: 0.0, + show_snapshot: false, + fast_previews: FastPreviews::default(), + display_color_management: true, + simulation: Simulation::default(), + custom_simulation: Simulation { profile: SimProfile::Linear, preserve_rgb: true }, + } + } +} + +/// View ▸ Split with New Locked Viewer: a second Composition viewer that keeps showing one +/// comp and 3D view whatever comp is active. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +pub struct LockedViewer { + pub comp: effectcraft_project::ItemId, + pub view: effectcraft_render::three_d::View3D, } fn snapping(s: &mut Session, p: &Value) -> Result { @@ -281,9 +311,97 @@ fn shape_layer_or_none(s: &Session) -> std::result::Result<(), String> { has_comp(s) } +// ---------------------------------------------------------------- display colour, locked viewer + +/// View ▸ Use Display Color Management (toggle, or `value`). +fn display_cm(s: &mut Session, p: &Value) -> Result { + let v = b_p(p, "value").unwrap_or(!s.state.viewer.display_color_management); + s.state.viewer.display_color_management = v; + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"displayColorManagement": v, "workingSpace": s.project.settings.working_space.map(|w| w.id()), "display": s.prefs.previews.display_profile})) +} + +/// View ▸ Simulate Output ▸ (profile). `custom` uses (and with `space` sets) the Custom profile. +fn simulate_output(s: &mut Session, p: &Value) -> Result { + let cmd = "view.simulateOutput"; + let name = str_p(p, "profile").unwrap_or("none"); + let ids = SimProfile::ALL.iter().map(|p| p.id()).collect::>().join("|"); + let sim = if name.eq_ignore_ascii_case("custom") { + let mut c = s.state.viewer.custom_simulation; + if let Some(sp) = str_p(p, "space") { + c.profile = SimProfile::parse(sp).ok_or_else(|| bad(cmd, format!("space: {ids}")))?; + } + if let Some(v) = b_p(p, "preserveRgb") { + c.preserve_rgb = v; + } + s.state.viewer.custom_simulation = c; + c + } else { + let profile = SimProfile::parse(name).ok_or_else(|| bad(cmd, format!("profile: {ids}|custom")))?; + Simulation { profile, preserve_rgb: b_p(p, "preserveRgb").unwrap_or(false) } + }; + s.state.viewer.simulation = sim; + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"profile": sim.profile.id(), "label": sim.profile.label(), "preserveRgb": sim.preserve_rgb})) +} + +/// View ▸ Split with New Locked Viewer: lock a second viewer to the comp (default the active +/// one) and its current 3D view (or `view`). +fn split_locked(s: &mut Session, p: &Value) -> Result { + let cid = comp_id(s, p)?; + let view = match str_p(p, "view") { + Some(v) => effectcraft_render::three_d::View3D::from_id(v).ok_or_else(|| bad("view.splitLockedViewer", format!("unknown view `{v}`")))?, + None => s.state.views3d.get(&cid).map(|v| v.current).unwrap_or_default(), + }; + s.state.locked_viewer = Some(LockedViewer { comp: cid, view }); + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"comp": cid.0, "view": view.id()})) +} + +fn close_locked(s: &mut Session, _: &Value) -> Result { + let was = s.state.locked_viewer.take().is_some(); + s.events.push(crate::Event::ProjectChanged { revision: s.revision }); + Ok(json!({"closed": was})) +} + +fn has_locked(s: &Session) -> std::result::Result<(), String> { + if s.state.locked_viewer.is_some() { Ok(()) } else { Err("no locked viewer".into()) } +} + +/// The viewer's colour settings and what they do. +fn display_color_state(s: &mut Session, _: &Value) -> Result { + let v = &s.state.viewer; + Ok(json!({ + "displayColorManagement": v.display_color_management, + "simulation": {"profile": v.simulation.profile.id(), "preserveRgb": v.simulation.preserve_rgb}, + "display": s.prefs.previews.display_profile, + "workingSpace": s.project.settings.working_space.map(|w| w.id()), + "active": crate::viewer::DisplayColor::of(s).is_some(), + "lockedViewer": s.state.locked_viewer.map(|l| json!({"comp": l.comp.0, "view": l.view.id()})), + })) +} + pub fn specs() -> Vec { vec![ cmd!("view.snapping", "Snapping", ["View"], None, "{value?}", always, snapping), + cmd!("view.displayColorManagement", "Use Display Color Management", ["View"], None, "{value?}", always, display_cm), + cmd!( + "view.simulateOutput", + "Simulate Output", + [], + None, + "{profile: none|rec709|ntsc|pal|mac18|srgb|rec2020|p3|linear|custom, preserveRgb?, space? (custom)}", + always, + simulate_output + ), + cmd!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], None, "{comp?, view?: 3D view id}", has_comp, split_locked), + cmd!("view.closeLockedViewer", "Close Locked Viewer", [], None, "{}", has_locked, close_locked), + query!( + "view.displayColor", + "Viewer Color State", + "{} → display colour management, output simulation, display profile, locked viewer", + display_color_state + ), cmd!("view.channel", "Show Channel", [], None, "{channel: rgb|red|green|blue|alpha|rgbStraight, colorized?, toggle?}", always, channel), cmd!("view.exposure", "Adjust Exposure", [], None, "{stops? | delta?}", always, exposure), cmd!("view.resetExposure", "Reset Exposure", [], None, "{}", always, reset_exposure), diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 508fb72e..9333329a 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -216,6 +216,9 @@ pub struct EditorState { /// Essential Graphics can expose). #[serde(default)] pub essential_solo: bool, + /// View ▸ Split with New Locked Viewer: the second viewer's comp and 3D view. + #[serde(default)] + pub locked_viewer: Option, } fn one_view() -> u8 { @@ -689,6 +692,8 @@ mod tests_anim_tools; #[cfg(test)] mod tests_camera_track; #[cfg(test)] +mod tests_color_view; +#[cfg(test)] mod tests_disk_cache; #[cfg(test)] mod tests_effects; @@ -711,6 +716,8 @@ mod tests_project_items; #[cfg(test)] mod tests_proxy; #[cfg(test)] +mod tests_rig3d; +#[cfg(test)] mod tests_settings; #[cfg(test)] mod tests_stubs; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 0470625c..51fa4e02 100644 --- a/crates/engine/src/menus.rs +++ b/crates/engine/src/menus.rs @@ -168,6 +168,17 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option { "view.layout" => Some(params.get("views").and_then(Value::as_u64) == Some(s.state.view_layout.max(1) as u64)), "view.shareViewOptions" => Some(s.state.share_view_options), "view.snapping" => Some(s.state.snapping), + "view.displayColorManagement" => Some(s.state.viewer.display_color_management), + "view.simulateOutput" => { + let want = pstr("profile")?; + let sim = s.state.viewer.simulation; + Some(if want == "custom" { + sim.profile != crate::viewer::SimProfile::None && sim == s.state.viewer.custom_simulation + } else { + crate::viewer::SimProfile::parse(want) == Some(sim.profile) + && (sim != s.state.viewer.custom_simulation || sim.profile == crate::viewer::SimProfile::None) + }) + } "layer.mask.motionBlur" | "layer.mask.featherFalloff" | "path.rotoBezier" => { use effectcraft_project::{FeatherFalloff, GroupKind, MaskMotionBlur}; let GroupKind::Mask { motion_blur, feather_falloff, roto_bezier, .. } = layer?.masks()?.groups().next()?.kind else { return None }; @@ -855,6 +866,9 @@ View SDTV PAL | view.simulateOutput {"profile":"pal"} Legacy Macintosh RGB (Gamma 1.8) | view.simulateOutput {"profile":"mac18"} Internet Standard RGB (sRGB) | view.simulateOutput {"profile":"srgb"} + UHDTV (Rec. 2020) | view.simulateOutput {"profile":"rec2020"} + Display P3 | view.simulateOutput {"profile":"p3"} + Linear (1.0 Gamma) | view.simulateOutput {"profile":"linear"} Custom... | view.simulateOutput {"profile":"custom"} --- Show Rulers | view.rulers diff --git a/crates/engine/src/prefs.rs b/crates/engine/src/prefs.rs index 5a2afab8..2d744226 100644 --- a/crates/engine/src/prefs.rs +++ b/crates/engine/src/prefs.rs @@ -104,6 +104,8 @@ page!(Previews { /// `faster` or `moreAccurate`. zoom_quality: String = "moreAccurate".into(), show_gpu_info: bool = false, + /// The monitor's colour space for View ▸ Use Display Color Management: `srgb` or `p3`. + display_profile: String = "srgb".into(), }); page!(Appearance { @@ -728,6 +730,7 @@ pub fn pages() -> Vec { s("previews.cacheFramesWhenIdle", "Cache Frames When Idle", B, true), s("previews.fastPreviews", "Fast Previews (Draft 3D, Faster Effects)", B, true), s("previews.zoomQuality", "Viewer Zoom Quality", Kind::Choice(&[("Faster", "faster"), ("More Accurate", "moreAccurate")]), false), + s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true), Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" }, ], }, diff --git a/crates/engine/src/tests_color_view.rs b/crates/engine/src/tests_color_view.rs new file mode 100644 index 00000000..c0f83d66 --- /dev/null +++ b/crates/engine/src/tests_color_view.rs @@ -0,0 +1,223 @@ +//! Viewer and colour completion (M7.7): View ▸ Switch 3D View ▸ Default, Split with New Locked +//! Viewer, Use Display Color Management, Simulate Output; Project Settings ▸ Color Engine, +//! ACES working spaces, HDR compand / tone mapping, Rec. 2100 output, Feet + Frames. + +use effectcraft_project::{ColorEngine, ColorSpace, HdrMode, Project, TimeDisplayStyle}; +use effectcraft_render::RenderOpts; +use effectcraft_render::three_d::View3D; +use effectcraft_time::Tick; +use serde_json::json; + +use crate::Session; +use crate::viewer::{DisplayColor, SimProfile, Simulation}; + +fn session() -> Session { + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "C", "width": 64, "height": 48, "frameRate": 24, "duration": 10})).unwrap(); + s +} + +#[test] +fn default_3d_view_ignores_camera_layers() { + let mut s = session(); + let cid = s.active_comp_id().unwrap(); + s.execute("layer.newCamera", json!({"position": [32, 24, -300], "poi": [32, 24, 0]})).unwrap(); + assert_eq!(s.execute("view.3d.default", json!({})).unwrap()["view"], "default"); + let vs = s.state.views3d.get(&cid).unwrap().clone(); + assert_eq!(vs.current, View3D::Default); + // The comp's default camera: at −zoom looking at the centre, not the camera layer. + let cam = s.view_camera(cid).unwrap(); + let zoom = effectcraft_geom::default_camera_zoom(64.0); + assert!((cam.eye.z + zoom).abs() < 1e-9 && (cam.eye.x - 32.0).abs() < 1e-9 && !cam.ortho, "{:?}", cam.eye); + // Orbitable like a custom view; Reset 3D View restores it; Last view goes back. + s.execute("camera.orbit", json!({"yaw": 30})).unwrap(); + assert!((s.view_camera(cid).unwrap().eye.x - 32.0).abs() > 1.0); + s.execute("view.reset3DView", json!({})).unwrap(); + assert!((s.view_camera(cid).unwrap().eye.x - 32.0).abs() < 1e-9); + s.execute("view.3d.last", json!({})).unwrap(); + assert_eq!(s.state.views3d[&cid].current, View3D::ActiveCamera); + assert_eq!(s.execute("view.set3DView", json!({"view": "Default"})).unwrap()["view"], "default"); +} + +#[test] +fn locked_viewer_keeps_its_comp() { + let mut s = session(); + let a = s.active_comp_id().unwrap(); + s.execute("view.3d.top", json!({})).unwrap(); + assert!(!s.is_enabled("view.closeLockedViewer")); + let r = s.execute("view.splitLockedViewer", json!({})).unwrap(); + assert_eq!(r, json!({"comp": a.0, "view": "top"})); + // Another comp becomes active; the locked viewer stays on the first. + s.execute("comp.new", json!({"name": "B"})).unwrap(); + assert_ne!(s.active_comp_id(), Some(a)); + assert_eq!(s.state.locked_viewer.unwrap().comp, a); + let st = s.execute("view.displayColor", json!({})).unwrap(); + assert_eq!(st["lockedViewer"]["comp"], a.0); + // Serde of the editor state; explicit comp and view. + let back: crate::EditorState = serde_json::from_value(serde_json::to_value(&s.state).unwrap()).unwrap(); + assert_eq!(back.locked_viewer, s.state.locked_viewer); + s.execute("view.splitLockedViewer", json!({"comp": a.0, "view": "custom2"})).unwrap(); + assert_eq!(s.state.locked_viewer.unwrap().view, View3D::Custom2); + assert!(s.execute("view.splitLockedViewer", json!({"view": "sideways"})).is_err()); + assert_eq!(s.execute("view.closeLockedViewer", json!({})).unwrap()["closed"], true); + assert!(s.state.locked_viewer.is_none()); +} + +fn px(r: u8, g: u8, b: u8) -> Vec<[u8; 4]> { + vec![[r, g, b, 255]] +} + +#[test] +fn display_color_management_and_simulation() { + let mut s = session(); + // Unmanaged projects: nothing to convert. + assert!(DisplayColor::of(&s).is_none()); + s.execute("file.projectSettings", json!({"workingSpace": "rec2020"})).unwrap(); + // Managed, sRGB display, no simulation: the frame is already display-referred. + assert!(DisplayColor::of(&s).is_none()); + assert_eq!(crate::menus::checked(&s, "view.displayColorManagement", &json!({})), Some(true)); + // A Display P3 monitor: pure sRGB red becomes less saturated P3 numbers. + s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "p3"})).unwrap(); + let mut p = px(255, 0, 0); + DisplayColor::of(&s).unwrap().apply(&mut p); + assert!((p[0][0] as i32 - 234).abs() <= 2 && (p[0][1] as i32 - 51).abs() <= 3 && (p[0][2] as i32 - 35).abs() <= 3, "{p:?}"); + // Display colour management off: the working space's (Rec. 2020) numbers go to the screen. + s.execute("view.displayColorManagement", json!({})).unwrap(); + assert_eq!(crate::menus::checked(&s, "view.displayColorManagement", &json!({})), Some(false)); + let mut q = px(255, 0, 0); + DisplayColor::of(&s).unwrap().apply(&mut q); + assert!(q[0][0] < 230 && q[0][1] > 60, "raw Rec. 2020 numbers: {q:?}"); + s.execute("view.displayColorManagement", json!({"value": true})).unwrap(); + s.execute("prefs.set", json!({"key": "previews.displayProfile", "value": "srgb"})).unwrap(); + // Simulate Output ▸ Linear with Preserve RGB: linear numbers shown as-is (darker mid-grey). + s.execute("view.simulateOutput", json!({"profile": "linear", "preserveRgb": true})).unwrap(); + let mut g = px(128, 128, 128); + DisplayColor::of(&s).unwrap().apply(&mut g); + assert!((g[0][0] as i32 - 55).abs() <= 2, "{g:?}"); + // Without Preserve RGB the round trip through 8-bit linear bands the shadows. + s.execute("view.simulateOutput", json!({"profile": "linear"})).unwrap(); + let dc = DisplayColor::of(&s).unwrap(); + let levels: std::collections::BTreeSet = (0..32u8) + .map(|v| { + let mut p = px(v, v, v); + dc.apply(&mut p); + p[0][0] + }) + .collect(); + assert!(levels.len() < 20, "banding: {levels:?}"); + assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "linear"})), Some(true)); + // Rec. 709 simulation keeps in-gamut colours; legacy Mac gamma 1.8 with Preserve RGB darkens. + s.execute("view.simulateOutput", json!({"profile": "rec709"})).unwrap(); + let mut c = px(200, 100, 50); + DisplayColor::of(&s).unwrap().apply(&mut c); + assert!((c[0][0] as i32 - 200).abs() <= 2 && (c[0][1] as i32 - 100).abs() <= 2, "{c:?}"); + s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap(); + let mut m = px(128, 128, 128); + DisplayColor::of(&s).unwrap().apply(&mut m); + assert!(m[0][0] < 120, "gamma 1.8 numbers on an sRGB display look darker: {m:?}"); + // Custom… uses (and sets) the custom profile. + let r = s.execute("view.simulateOutput", json!({"profile": "custom", "space": "p3", "preserveRgb": false})).unwrap(); + assert_eq!(r["profile"], "p3"); + assert_eq!(s.state.viewer.custom_simulation, Simulation { profile: SimProfile::P3, preserve_rgb: false }); + assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "custom"})), Some(true)); + s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap(); + assert!(DisplayColor::of(&s).is_none()); + assert!(s.execute("view.simulateOutput", json!({"profile": "cmyk"})).is_err()); + // Every menu profile parses. + for p in SimProfile::ALL { + assert_eq!(SimProfile::parse(p.id()), Some(p)); + } +} + +fn red_solid(s: &mut Session, hex: &str) { + s.execute("layer.newSolid", json!({"color": hex, "width": 64, "height": 48})).unwrap(); +} + +#[test] +fn color_engine_aces_working_spaces_and_hdr() { + let mut s = session(); + let cid = s.active_comp_id().unwrap(); + // OCIO engine: ACEScg working space and the tone-mapped display by default. + s.execute("file.projectSettings", json!({"colorEngine": "ocio"})).unwrap(); + let st = s.project.settings.clone(); + assert_eq!((st.color_engine, st.working_space, st.hdr), (ColorEngine::Ocio, Some(ColorSpace::AcesCg), HdrMode::ToneMap)); + assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec709"})).is_err(), "not in the OCIO config"); + s.execute("file.projectSettings", json!({"workingSpace": "aces2065"})).unwrap(); + assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec2100pq"})).is_err(), "not a working space"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::AcesCg)); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.settings.color_engine, ColorEngine::Adobe); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.settings.color_engine, ColorEngine::Ocio); + // 32 bpc, a solid of 4.0 (over-range) in ACEScg: tone mapped below white, not clipped. + s.execute("file.projectSettings", json!({"bitDepth": 32, "hdr": "toneMap"})).unwrap(); + s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 64, "height": 48})).unwrap(); + let lid = s.active_comp().unwrap().layers[0].id.0; + s.execute("effect.apply", json!({"layers": [lid], "effect": "Exposure"})).unwrap(); + s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/master/exposure", "value": 2.0})).unwrap(); + let mapped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0]; + assert!(mapped[0] > 0.5 && mapped[0] < 0.99, "tone mapped: {mapped:?}"); + s.execute("file.projectSettings", json!({"hdr": "clip"})).unwrap(); + let clipped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0]; + assert!(clipped[0] > 1.1, "32 bpc keeps over-range: {clipped:?}"); + s.execute("file.projectSettings", json!({"hdr": "compand"})).unwrap(); + let comp = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0]; + assert!(comp[0] > 0.9 && comp[0] < 1.0, "companded: {comp:?}"); + // Rec. 2100 PQ output: HDR, no tone mapping: over-range white encodes above SDR white's 0.58. + s.execute("file.projectSettings", json!({"outputSpace": "rec2100pq", "hdr": "toneMap"})).unwrap(); + let pq = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0]; + assert!(pq[0] > 0.6 && pq[0] < 0.8, "PQ: {pq:?}"); + s.execute("file.projectSettings", json!({"outputSpace": "rec2100hlg"})).unwrap(); + let hlg = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0]; + assert!(hlg[0] > 0.85, "HLG: {hlg:?}"); + assert!(s.execute("file.projectSettings", json!({"hdr": "glow"})).is_err()); + // Serde keeps the new settings; older files load with the defaults. + let back: Project = serde_json::from_str(&serde_json::to_string(&*s.project).unwrap()).unwrap(); + assert_eq!(back.settings, s.project.settings); + let mut v = serde_json::to_value(&s.project.settings).unwrap(); + for k in ["color_engine", "hdr", "output_space"] { + v.as_object_mut().unwrap().remove(k); + } + let old: effectcraft_project::ProjectSettings = serde_json::from_value(v).unwrap(); + assert_eq!((old.color_engine, old.hdr, old.output_space), (ColorEngine::Adobe, HdrMode::Clip, None)); +} + +#[test] +fn aces_working_space_matches_the_ocio_config() { + // ACEScg in the colour module and in the OCIO effects' built-in config agree. + let mut s = session(); + s.execute("file.projectSettings", json!({"workingSpace": "acescg"})).unwrap(); + red_solid(&mut s, "#808080"); + let cid = s.active_comp_id().unwrap(); + // Authored ACEScg values (0.5 linear AP1 grey) → sRGB display. + let out = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0]; + let want = effectcraft_color::linear_to_srgb(128.0 / 255.0); + assert!((out[0] - want).abs() < 0.01 && (out[1] - want).abs() < 0.01, "{out:?} vs {want}"); +} + +#[test] +fn feet_and_frames_display_and_entry() { + let mut s = session(); + let comp = s.active_comp().unwrap().clone(); + s.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap(); + assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Feet35); + let t = comp.frame_rate.tick_of(16 * 3 + 5); + assert_eq!(crate::commands::time::display_time(&s, &comp, t), "0003+05"); + // Typed feet+frames in the time field. + let r = s.execute("time.set", json!({"timecode": "2+10"})).unwrap(); + assert_eq!(r["frame"], 42); + assert_eq!(r["display"], "0002+10"); + let r = s.execute("time.set", json!({"timecode": "+6"})).unwrap(); + assert_eq!(r["frame"], 48); + s.execute("file.projectSettings", json!({"timeDisplay": "feet16"})).unwrap(); + assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "0002+15"); + s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap(); + assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "00095"); + s.execute("file.projectSettings", json!({"timeDisplay": "timecode"})).unwrap(); + assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(48)), "0:00:02:00"); + assert!(s.execute("file.projectSettings", json!({"timeDisplay": "furlongs"})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames); + assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16")); +} diff --git a/crates/engine/src/tests_menu_cmds.rs b/crates/engine/src/tests_menu_cmds.rs index 13087ca3..4456a12c 100644 --- a/crates/engine/src/tests_menu_cmds.rs +++ b/crates/engine/src/tests_menu_cmds.rs @@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() { let ev = frontend_events(&mut s); assert_eq!(ev[0].0, "view.zoomIn"); assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"}))); - for id in ["keys.audioToKeyframes", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] { + for id in ["keys.audioToKeyframes", "layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] { assert!(!s.is_enabled(id), "{id}"); assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}"); } diff --git a/crates/engine/src/tests_rig3d.rs b/crates/engine/src/tests_rig3d.rs new file mode 100644 index 00000000..d3b96510 --- /dev/null +++ b/crates/engine/src/tests_rig3d.rs @@ -0,0 +1,338 @@ +//! Camera and light rigs (M7.7): Create Stereo 3D Rig, Create Orbit Null, Control Light with +//! Camera, Create Cameras / Lights from 3D Model and Create Environment Light Background Layer +//! (behaviour, values, pixels, undo/redo, serde). + +use std::sync::Arc; + +use effectcraft_geom::{Vec3, vec3}; +use effectcraft_keyframe::Value as KV; +use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project}; +use effectcraft_render::three_d::camera::layer_frame; +use effectcraft_render::{EvalCtx, FootageSource, RenderOpts}; +use effectcraft_time::{FrameRate, Tick}; +use serde_json::{Value, json}; + +use crate::{Importer, Session}; + +fn session() -> Session { + let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() }; + s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap(); + s +} + +fn cid(s: &Session) -> ItemId { + s.active_comp_id().unwrap() +} + +fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer { + s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone() +} + +fn v3(l: &Layer, path: &str) -> [f64; 3] { + l.props.prop(path).unwrap().value.as_vec3() +} + +fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool { + (0..3).all(|i| (a[i] - b[i]).abs() <= tol) +} + +/// World (eye, forward) of a layer in the active comp at time 0. +fn frame(s: &Session, id: u64) -> (Vec3, Vec3) { + let c = cid(s); + let comp = s.project.comp(c).unwrap(); + let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None }; + let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap()); + (e, f) +} + +fn camera(s: &mut Session) -> u64 { + s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap() +} + +#[test] +fn stereo_rig_builds_eye_comps_and_glasses() { + let mut s = session(); + let src = cid(&s); + let cam = camera(&mut s); + let items_before = s.project.items.len(); + let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); + assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps"); + assert_eq!(r["master"].as_u64(), Some(cam)); + // Controls on a hidden null in the source comp. + let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap()); + assert_eq!(ctl.name, "Stereo 3D Controls"); + assert!(!ctl.switches.video); + let fx = ctl.effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS)); + assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0)); + // Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp. + let d = 0.04 * 640.0; + for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] { + let ec = ItemId(r[key].as_u64().unwrap()); + assert_eq!(s.project.item(ec).unwrap().name, name); + let c = s.project.comp(ec).unwrap(); + assert_eq!((c.width, c.height), (640, 360)); + assert_eq!(c.layers.len(), 2); + let cam = &c.layers[0]; + assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam)); + assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position")); + // Parallel eyes (no convergence): each looks straight ahead. + assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi")); + assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0)); + for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] { + let e = cam.props.prop(p).unwrap().expr.clone().unwrap(); + assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text); + } + let nest = &c.layers[1]; + assert_eq!(nest.source, LayerSource::Comp { item: src }); + assert!(nest.switches.collapse && nest.switches.three_d); + } + // The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden. + let oc = ItemId(r["stereoComp"].as_u64().unwrap()); + let out = s.project.comp(oc).unwrap(); + assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D"); + let (l, rr) = (r["leftLayer"].as_u64().unwrap(), r["rightLayer"].as_u64().unwrap()); + assert_eq!(out.layers[0].id.0, l); + assert!(!out.layers[1].switches.video); + let g = out.layers[0].effects().unwrap().groups().next().unwrap().clone(); + assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses")); + assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l))); + assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr))); + // One undo step; redo; serde. + let json = serde_json::to_string(&*s.project).unwrap(); + let back: Project = serde_json::from_str(&json).unwrap(); + assert_eq!(back, *s.project); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(s.project.items.len(), items_before); + assert_eq!(s.project.comp(src).unwrap().layers.len(), 1); + s.execute("edit.redo", json!({})).unwrap(); + assert_eq!(s.project.items.len(), items_before + 3); +} + +#[test] +fn stereo_rig_configurations_and_convergence() { + let mut s = session(); + camera(&mut s); + let r = s.execute("camera.stereoRig", json!({"configuration": "centerRight", "sceneDepth": 5, "convergence": true})).unwrap(); + let d = 0.05 * 640.0; + let eye = |key: &str| s.project.comp(ItemId(r[key].as_u64().unwrap())).unwrap().layers[0].clone(); + let (l, rr) = (eye("leftComp"), eye("rightComp")); + assert!(close3(v3(&l, "transform/position"), [320.0, 180.0, -800.0], 1e-6)); + assert!(close3(v3(&rr, "transform/position"), [320.0 + d, 180.0, -800.0], 1e-6)); + // Converged on the master's point of interest. + assert!(close3(v3(&l, "transform/poi"), [320.0, 180.0, 0.0], 1e-6)); + assert!(close3(v3(&rr, "transform/poi"), [320.0, 180.0, 0.0], 1e-6)); + // A comp without a camera gets a Master Camera. + let mut s = session(); + let r = s.execute("camera.stereoRig", json!({"configuration": "centerLeft"})).unwrap(); + let m = layer_in(&s, cid(&s), r["master"].as_u64().unwrap()); + assert_eq!(m.name, "Master Camera"); + assert!(s.execute("camera.stereoRig", json!({"configuration": "sideways"})).is_err()); +} + +/// The eye cameras' expressions reproduce the static values (and follow the master). +#[test] +fn stereo_rig_maths() { + use crate::commands::rig3d::{StereoParams, eye_camera}; + let sp = StereoParams { configuration: 0, scene_depth: 10.0, convergence: false, convergence_of: 0, z_offset: 0.0 }; + let master = (vec3(0.0, 0.0, -100.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0)); + let (p0, q0) = eye_camera(&sp, 0, 1000.0, master, 100.0, 500.0); + assert!(close3([p0.x, p0.y, p0.z], [-50.0, 0.0, -100.0], 1e-9) && close3([q0.x, q0.y, q0.z], [-50.0, 0.0, 0.0], 1e-9)); + let sp = StereoParams { convergence: true, convergence_of: 1, z_offset: 50.0, ..sp }; + let (_, q1) = eye_camera(&sp, 1, 1000.0, master, 100.0, 500.0); + // Zoom plane (500) + Z offset (50) in front of the master eye. + assert!(close3([q1.x, q1.y, q1.z], [0.0, 0.0, 450.0], 1e-9), "{q1:?}"); +} + +#[test] +fn orbit_null_parents_the_camera_at_its_point_of_interest() { + let mut s = session(); + let cam = camera(&mut s); + let (eye0, f0) = frame(&s, cam); + let r = s.execute("camera.orbitNull", json!({})).unwrap(); + let nid = r["null"].as_u64().unwrap(); + let n = layer_in(&s, cid(&s), nid); + assert!(n.is_3d() && matches!(n.source, LayerSource::Null)); + assert_eq!(n.name, "Cam Orbit Null"); + assert!(close3(v3(&n, "transform/position"), [320.0, 180.0, 0.0], 1e-9)); + let c = layer_in(&s, cid(&s), cam); + assert_eq!(c.parent, Some(LayerId(nid))); + // The camera stays where it was. + let (eye1, f1) = frame(&s, cam); + assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}"); + // Turning the null orbits the camera around the point of interest. + s.execute("prop.set", json!({"layer": nid, "path": "transform/rotationY", "value": 90})).unwrap(); + let (eye2, f2) = frame(&s, cam); + let poi = vec3(320.0, 180.0, 0.0); + assert!(((eye2 - poi).length() - 800.0).abs() < 1e-6, "{eye2:?}"); + assert!((eye2.z).abs() < 1e-6 && (eye2.x - 320.0).abs() > 700.0, "{eye2:?}"); + assert!(((poi - eye2).normalize() - f2).length() < 1e-6, "still looks at the POI: {f2:?}"); + // A parented camera can't get another orbit null; undo restores the camera. + assert!(s.execute("camera.orbitNull", json!({"layer": cam})).is_err()); + s.execute("edit.undo", json!({})).unwrap(); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer_in(&s, cid(&s), cam).parent, None); + assert!(s.active_comp().unwrap().layer(LayerId(nid)).is_none()); +} + +#[test] +fn control_light_with_camera_follows_and_releases() { + let mut s = session(); + let cam = camera(&mut s); + let light = s.execute("layer.newLight", json!({"kind": "Spot", "position": [0, 0, -500], "poi": [100, 100, 0]})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.select", json!({"layers": [light]})).unwrap(); + let r = s.execute("light.controlWithCamera", json!({})).unwrap(); + assert_eq!(r["controlled"], true); + let l = layer_in(&s, cid(&s), light); + assert_eq!(l.parent, Some(LayerId(cam))); + let (ce, cf) = frame(&s, cam); + let (le, lf) = frame(&s, light); + assert!((le - ce).length() < 1e-6 && (lf - cf).length() < 1e-6, "light at the camera: {le:?} {lf:?}"); + // Moving the camera carries the light. + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [100, 180, -800]})).unwrap(); + let (le2, _) = frame(&s, light); + assert!((le2 - vec3(100.0, 180.0, -800.0)).length() < 1e-6, "{le2:?}"); + // Again: released where it is (unparented, aimed the same way). + let r = s.execute("light.controlWithCamera", json!({"layers": [light]})).unwrap(); + assert_eq!(r["controlled"], false); + let l = layer_in(&s, cid(&s), light); + assert_eq!(l.parent, None); + let (le3, lf3) = frame(&s, light); + assert!((le3 - le2).length() < 1e-6, "{le3:?}"); + let (_, cf2) = frame(&s, cam); + assert!((lf3 - cf2).length() < 1e-6, "{lf3:?} {cf2:?}"); + s.execute("edit.undo", json!({})).unwrap(); + assert_eq!(layer_in(&s, cid(&s), light).parent, Some(LayerId(cam))); + // Not a light → error. + assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err()); +} + +// ---------------------------------------------------------------- from 3D model + +struct DiskModels; + +impl FootageSource for DiskModels { + fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option> { + None + } + fn model(&self, _: ItemId, f: &Footage) -> Option> { + let bytes = std::fs::read(&f.path).ok()?; + effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new) + } +} + +struct ModelImporter; + +impl Importer for ModelImporter { + fn probe(&self, path: &str) -> Result { + effectcraft_model::format_of(path).ok_or("not a model")?; + Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() }) + } +} + +const RIGGED: &str = r#"{ + "asset": {"version": "2.0"}, + "scenes": [{"nodes": [0, 1, 2]}], + "nodes": [ + {"name": "ShotCam", "camera": 0, "translation": [0, 0, 5]}, + {"name": "Key", "translation": [0, 3, 0], "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 0}}}, + {"name": "Fill", "translation": [2, 0, 0], "extensions": {"KHR_lights_punctual": {"light": 1}}} + ], + "cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}], + "extensions": {"KHR_lights_punctual": {"lights": [ + {"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}}, + {"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4} + ]}} +}"#; + +fn rigged_model(s: &mut Session) -> u64 { + let dir = std::env::temp_dir().join(format!("ec-rig3d-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("rigged.gltf"); + std::fs::write(&path, RIGGED).unwrap(); + let r = s.execute("layer.newModel", json!({"path": path.to_string_lossy()})).unwrap(); + r["layer"].as_u64().unwrap() +} + +#[test] +fn cameras_and_lights_from_model() { + let mut s = session(); + let model = rigged_model(&mut s); + let m = layer_in(&s, cid(&s), model); + let unit = m.props.prop("geometryOptions/unitScale").unwrap().value.as_f64(); + let pos = v3(&m, "transform/position"); + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("camera.fromModel", json!({})).unwrap(); + let cams = r["layers"].as_array().unwrap(); + assert_eq!(cams.len(), 1); + let c = layer_in(&s, cid(&s), cams[0].as_u64().unwrap()); + assert_eq!(c.name, "Shot"); + // glTF +Z (towards the viewer) is AE −Z; the camera looks into the scene (+Z). + let want = [pos[0], pos[1], pos[2] - 5.0 * unit]; + assert!(close3(v3(&c, "transform/position"), want, 1e-6), "{:?} vs {want:?}", v3(&c, "transform/position")); + let zoom = 180.0 / (0.3f64).tan(); + assert!((c.props.prop("cameraOptions/zoom").unwrap().value.as_f64() - zoom).abs() < 1e-6); + let (_, f) = frame(&s, c.id.0); + assert!((f - vec3(0.0, 0.0, 1.0)).length() < 1e-6, "{f:?}"); + // Lights: a spot (cone 2 × outer, feather from inner/outer) and a point light. + s.execute("layer.select", json!({"layers": [model]})).unwrap(); + let r = s.execute("light.fromModel", json!({})).unwrap(); + let ls: Vec = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect(); + assert_eq!(ls.len(), 2); + let key = ls.iter().find(|l| l.name == "Key Light").unwrap(); + assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot }); + assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6); + assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6); + assert!((key.props.prop("lightOptions/intensity").unwrap().value.as_f64() - 150.0).abs() < 1e-9); + // Points down in glTF (−Y) = down on screen in AE (+Y). + let (ke, kf) = frame(&s, key.id.0); + assert!((kf - vec3(0.0, 1.0, 0.0)).length() < 1e-5, "{kf:?}"); + assert!((ke.y - (pos[1] - 3.0 * unit)).abs() < 1e-6, "{ke:?}"); + let fill = ls.iter().find(|l| l.name == "Fill Light").unwrap(); + assert_eq!(fill.source, LayerSource::Light { kind: LightKind::Point }); + assert!((fill.props.prop("lightOptions/falloffDistance").unwrap().value.as_f64() - 400.0).abs() < 1e-9); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(key.id).is_none()); + // Not a model layer → error. + assert!(s.execute("camera.fromModel", json!({"layers": [c.id.0]})).is_err()); +} + +// ---------------------------------------------------------------- environment background + +fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] { + img.data[(y * img.width + x) as usize] +} + +#[test] +fn environment_background_draws_the_sky_through_the_camera() { + let mut s = session(); + let scene = cid(&s); + // An equirectangular "sky": red upper hemisphere, blue lower. + let sky = s.execute("comp.new", json!({"name": "Sky", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 200, "height": 100})).unwrap(); + let top = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 50})).unwrap()["layer"].as_u64().unwrap(); + s.execute("prop.set", json!({"layer": top, "path": "transform/position", "value": [100, 25]})).unwrap(); + s.execute("comp.open", json!({"comp": scene})).unwrap(); + let src = s.execute("layer.addItem", json!({"item": sky})).unwrap()["layer"].as_u64().unwrap(); + s.execute("layer.environment", json!({"layers": [src], "on": true})).unwrap(); + // No environment light yet. + assert!(s.execute("light.environmentBackground", json!({})).is_err()); + let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap(); + let r = s.execute("light.environmentBackground", json!({})).unwrap(); + assert_eq!(r["light"].as_u64(), Some(env)); + assert_eq!(r["source"].as_u64(), Some(src)); + let bg = layer_in(&s, scene, r["layer"].as_u64().unwrap()); + assert!(bg.environment_background && bg.is_3d()); + assert_eq!(bg.source, LayerSource::Comp { item: ItemId(sky) }); + assert_eq!(s.active_comp().unwrap().layers.last().unwrap().id, bg.id, "at the bottom"); + let img = s.render(scene, Tick::ZERO, RenderOpts::default()); + // Looking at the horizon: rays above it see red, below it blue. + let (t, b) = (px(&img, 320, 5), px(&img, 320, 354)); + assert!(t[0] > 0.9 && t[2] < 0.1 && t[3] > 0.99, "top {t:?}"); + assert!(b[2] > 0.9 && b[0] < 0.1, "bottom {b:?}"); + // Serde keeps the flag; undo removes the layer. + let json = serde_json::to_value(&bg).unwrap(); + assert_eq!(json["environment_background"], Value::Bool(true)); + s.execute("edit.undo", json!({})).unwrap(); + assert!(s.active_comp().unwrap().layer(bg.id).is_none()); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index b1525969..997552bc 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() { assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id))); } // What remains a stub is still disabled. - for id in ["view.displayColorManagement", "keys.audioToKeyframes"] { + for id in ["layer.autoTrace", "keys.audioToKeyframes"] { assert!(!s.is_enabled(id), "{id}"); } } diff --git a/crates/engine/src/viewer.rs b/crates/engine/src/viewer.rs index 0c002fa1..bba10122 100644 --- a/crates/engine/src/viewer.rs +++ b/crates/engine/src/viewer.rs @@ -5,6 +5,7 @@ use std::sync::Arc; +use effectcraft_color::{ColorSpace, space}; use effectcraft_geom::vec2; use effectcraft_project::{Layer, LayerId}; use effectcraft_raster::Image; @@ -359,6 +360,273 @@ pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, } } +// ---------------------------------------------------------------- display colour management + +/// View ▸ Simulate Output: the output device a preview imitates. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum SimProfile { + #[default] + None, + /// HDTV (Rec. 709): ITU-R BT.709 primaries, BT.1886 gamma 2.4. + Rec709, + /// UHDTV (Rec. 2020): ITU-R BT.2020 primaries, gamma 2.4. + Rec2020, + /// Display P3: SMPTE EG 432-1 primaries, D65, the sRGB curve. + P3, + /// Internet Standard RGB (sRGB, IEC 61966-2-1). + Srgb, + /// Linear light with Rec. 709 primaries (no transfer curve). + Linear, + /// SDTV NTSC: SMPTE 170M primaries, gamma 2.4. + Ntsc, + /// SDTV PAL: EBU Tech 3213 primaries, gamma 2.4. + Pal, + /// Legacy Macintosh RGB: Rec. 709 primaries, gamma 1.8. + Mac18, +} + +/// A transfer curve of a simulated or display profile. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum Curve { + Srgb, + Gamma(f32), + Linear, + /// A colour space's own curve (PQ, HLG…). + Space(space::Curve), +} + +impl Curve { + fn decode(self, v: f32) -> f32 { + let a = v.abs(); + let l = match self { + Curve::Srgb => srgb_to_linear(a), + Curve::Gamma(g) => a.powf(g), + Curve::Linear => a, + Curve::Space(c) => c.decode(a), + }; + l.copysign(v) + } + fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + Curve::Srgb => linear_to_srgb(a), + Curve::Gamma(g) => a.powf(1.0 / g), + Curve::Linear => a, + Curve::Space(c) => c.encode(a), + }; + e.copysign(v) + } +} + +const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]]; + +impl SimProfile { + pub const ALL: [SimProfile; 9] = [ + SimProfile::None, + SimProfile::Rec709, + SimProfile::Ntsc, + SimProfile::Pal, + SimProfile::Mac18, + SimProfile::Srgb, + SimProfile::Rec2020, + SimProfile::P3, + SimProfile::Linear, + ]; + + pub fn id(self) -> &'static str { + match self { + SimProfile::None => "none", + SimProfile::Rec709 => "rec709", + SimProfile::Rec2020 => "rec2020", + SimProfile::P3 => "p3", + SimProfile::Srgb => "srgb", + SimProfile::Linear => "linear", + SimProfile::Ntsc => "ntsc", + SimProfile::Pal => "pal", + SimProfile::Mac18 => "mac18", + } + } + + pub fn label(self) -> &'static str { + match self { + SimProfile::None => "No Output Simulation", + SimProfile::Rec709 => "HDTV (Rec. 709)", + SimProfile::Rec2020 => "UHDTV (Rec. 2020)", + SimProfile::P3 => "Display P3", + SimProfile::Srgb => "Internet Standard RGB (sRGB)", + SimProfile::Linear => "Linear (1.0 Gamma)", + SimProfile::Ntsc => "SDTV NTSC", + SimProfile::Pal => "SDTV PAL", + SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)", + } + } + + pub fn parse(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + SimProfile::ALL + .into_iter() + .find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase() == k) + .or(match k.as_str() { + "off" => Some(SimProfile::None), + "hdtv" | "bt709" => Some(SimProfile::Rec709), + "bt2020" | "uhdtv" => Some(SimProfile::Rec2020), + "displayp3" => Some(SimProfile::P3), + "lin" | "linearlight" => Some(SimProfile::Linear), + _ => None, + }) + } + + /// (primaries, curve); `None` for No Output Simulation. + pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> { + Some(match self { + SimProfile::None => return None, + SimProfile::Rec709 => (P709, Curve::Gamma(2.4)), + SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)), + SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb), + SimProfile::Srgb => (P709, Curve::Srgb), + SimProfile::Linear => (P709, Curve::Linear), + SimProfile::Ntsc => ([[0.630, 0.340], [0.310, 0.595], [0.155, 0.070]], Curve::Gamma(2.4)), + SimProfile::Pal => ([[0.640, 0.330], [0.290, 0.600], [0.150, 0.060]], Curve::Gamma(2.4)), + SimProfile::Mac18 => (P709, Curve::Gamma(1.8)), + }) + } +} + +/// View ▸ Simulate Output setting: the profile, and Preserve RGB (the output numbers are sent +/// to the display unconverted, as on a device that doesn't colour-manage). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(default)] +pub struct Simulation { + pub profile: SimProfile, + pub preserve_rgb: bool, +} + +/// An RGB space for viewer conversions: primaries (D65) and curve. +#[derive(Clone, Copy, Debug, PartialEq)] +struct Prof { + /// Linear RGB → XYZ D65. + xyz: [[f64; 3]; 3], + curve: Curve, +} + +impl Prof { + fn new(prims: [[f64; 2]; 3], curve: Curve) -> Prof { + Prof { xyz: space::rgb_to_xyz(prims, D65), curve } + } + /// A colour space (linear when `linear`). + fn of(cs: ColorSpace, linear: bool) -> Prof { + let curve = if linear { Curve::Linear } else { Curve::Space(cs.curve()) }; + Prof { xyz: cs.to_xyz(), curve } + } +} + +/// A linear-RGB matrix plus curves between two profiles. +#[derive(Clone, Copy, Debug)] +struct Conv { + from: Curve, + m: Option<[[f32; 3]; 3]>, + to: Curve, +} + +impl Conv { + fn new(a: Prof, b: Prof) -> Conv { + let m = (a.xyz != b.xyz).then(|| { + let m = space::mul(&space::invert(&b.xyz), &a.xyz); + m.map(|r| r.map(|v| v as f32)) + }); + Conv { from: a.curve, m, to: b.curve } + } + fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let l = c.map(|v| self.from.decode(v)); + let l = match &self.m { + Some(m) => [0, 1, 2].map(|i| m[i][0] * l[0] + m[i][1] * l[1] + m[i][2] * l[2]), + None => l, + }; + l.map(|v| self.to.encode(v)) + } +} + +const D65: [f64; 2] = [0.3127, 0.3290]; + +/// The viewer's colour conversion from the rendered frame (sRGB-encoded display pixels) to +/// what the monitor shows (View ▸ Use Display Color Management, View ▸ Simulate Output and +/// the display profile in Settings ▸ Previews). `None` when nothing changes. +#[derive(Clone, Copy, Debug)] +pub struct DisplayColor { + sim: Option<(Conv, Option)>, + display: Option, +} + +impl DisplayColor { + /// `working`: the project working space (None = unmanaged: nothing is converted, as in + /// After Effects); `linear`: the working space is linear; `source`: the space rendered + /// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color + /// Management; `display`: the monitor's space. + pub fn new(working: Option, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option { + let ws = working?; + let src = Prof::of(source, false); + if !dcm { + // Without display colour management the working space's numbers go to the screen. + let raw = Conv::new(src, Prof::of(ws, linear)); + return Some(DisplayColor { sim: None, display: Some(raw) }); + } + let sim = sim.profile.space().map(|(prims, curve)| { + let out = Prof::new(prims, curve); + ( + Conv::new(src, out), + Some(if sim.preserve_rgb { + Conv::new(Prof::new(P709, Curve::Srgb), Prof::of(display, false)) + } else { + Conv::new(out, Prof::of(display, false)) + }), + ) + }); + let display = (display != source).then(|| Conv::new(src, Prof::of(display, false))); + (sim.is_some() || display.is_some()).then_some(DisplayColor { sim, display }) + } + + /// The conversion a session's viewer uses. + pub fn of(s: &crate::Session) -> Option { + let st = &s.project.settings; + let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb); + let v = &s.state.viewer; + let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear()); + DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation) + } + + /// One straight colour (0..1 floats). + pub fn apply_straight(&self, c: [f32; 3]) -> [f32; 3] { + let c = match &self.sim { + // Through the output's 8-bit encoding (clipped and quantised like the real output), + // then shown on the display: converted from the output profile, or — Preserve RGB — + // the output numbers taken as if they were sRGB. + Some((to, show)) => { + let o = to.apply(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0); + show.map_or(o, |b| b.apply(o)) + } + None => self.display.map_or(c, |d| d.apply(c)), + }; + c.map(|v| v.clamp(0.0, 1.0)) + } + + /// Premultiplied 8-bit RGBA pixels in place. + pub fn apply(&self, px: &mut [[u8; 4]]) { + use rayon::prelude::*; + px.par_iter_mut().for_each(|p| { + let a = p[3]; + if a == 0 { + return; + } + let af = a as f32 / 255.0; + let c = [0, 1, 2].map(|i| (p[i] as f32 / 255.0 / af).min(1.0)); + let o = self.apply_straight(c); + for i in 0..3 { + p[i] = (o[i] * af * 255.0 + 0.5) as u8; + } + }); + } +} + // ---------------------------------------------------------------- snapshot /// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5). diff --git a/crates/expr/src/prelude.js b/crates/expr/src/prelude.js index 1655f75d..39770ee1 100644 --- a/crates/expr/src/prelude.js +++ b/crates/expr/src/prelude.js @@ -526,6 +526,8 @@ Layer.prototype = { get opacity() { return this.transform.opacity; }, get anchorPoint() { return this.transform.anchorPoint; }, get text() { return this.__group('text'); }, + get cameraOption() { return this.__group('cameraOptions'); }, + get lightOption() { return this.__group('lightOptions'); }, get marker() { return new Markers(this.__c, this.__l); }, effect: function (k) { var g = __child(this.__c, this.__l, '', 'effects'); diff --git a/crates/gpu/src/ops.rs b/crates/gpu/src/ops.rs index 775e86c1..f8248f99 100644 --- a/crates/gpu/src/ops.rs +++ b/crates/gpu/src/ops.rs @@ -144,11 +144,13 @@ pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage { } fn curve_id(s: Option) -> u32 { - use effectcraft_color::ColorSpace::*; - match s { - None => 0, - Some(Srgb | DisplayP3) => 1, - Some(Rec709 | Rec2020) => 2, + use effectcraft_color::space::Curve; + match s.map(|s| s.curve()) { + None | Some(Curve::Linear) => 0, + Some(Curve::Srgb) => 1, + Some(Curve::Gamma24) => 2, + Some(Curve::Pq) => 3, + Some(Curve::Hlg) => 4, } } diff --git a/crates/gpu/src/shaders/common.wgsl b/crates/gpu/src/shaders/common.wgsl index dee86b16..2949c0dc 100644 --- a/crates/gpu/src/shaders/common.wgsl +++ b/crates/gpu/src/shaders/common.wgsl @@ -428,7 +428,44 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> vec3 { return vec3(hsl_channel(h + 1.0 / 3.0, p, q), hsl_channel(h, p, q), hsl_channel(h - 1.0 / 3.0, p, q)); } -// ColorSpace transfer curves: 1 = sRGB (sRGB, Display P3), 2 = 2.4 power (Rec. 709 / 2020). +// ColorSpace transfer curves: 1 = sRGB (sRGB, Display P3), 2 = 2.4 power (Rec. 709 / 2020), +// 3 = SMPTE ST 2084 PQ and 4 = BT.2100 HLG (linear 1.0 = the BT.2408 reference white). +const PQ_M1: f32 = 0.1593017578125; +const PQ_M2: f32 = 78.84375; +const PQ_C1: f32 = 0.8359375; +const PQ_C2: f32 = 18.8515625; +const PQ_C3: f32 = 18.6875; +const PQ_WHITE: f32 = 0.0203; +const HLG_A: f32 = 0.17883277; +const HLG_B: f32 = 0.28466892; +const HLG_C: f32 = 0.55991073; +const HLG_REF: f32 = 0.26496256; + +fn pq_decode(e: f32) -> f32 { + let p = pow(clamp(e, 0.0, 1.0), 1.0 / PQ_M2); + return pow(max(p - PQ_C1, 0.0) / (PQ_C2 - PQ_C3 * p), 1.0 / PQ_M1) / PQ_WHITE; +} + +fn pq_encode(l: f32) -> f32 { + let y = pow(max(l * PQ_WHITE, 0.0), PQ_M1); + return pow((PQ_C1 + PQ_C2 * y) / (1.0 + PQ_C3 * y), PQ_M2); +} + +fn hlg_decode(v: f32) -> f32 { + if (v <= 0.5) { + return v * v / 3.0 / HLG_REF; + } + return (exp((v - HLG_C) / HLG_A) + HLG_B) / 12.0 / HLG_REF; +} + +fn hlg_encode(l: f32) -> f32 { + let e = max(l * HLG_REF, 0.0); + if (e <= 1.0 / 12.0) { + return sqrt(3.0 * e); + } + return HLG_A * log(12.0 * e - HLG_B) + HLG_C; +} + fn curve_decode(curve: u32, v: f32) -> f32 { let a = abs(v); var l = a; @@ -436,6 +473,10 @@ fn curve_decode(curve: u32, v: f32) -> f32 { l = srgb_to_linear(a); } else if (curve == 2u) { l = powz(a, 2.4); + } else if (curve == 3u) { + l = pq_decode(a); + } else if (curve == 4u) { + l = hlg_decode(a); } return select(l, -l, v < 0.0); } @@ -447,6 +488,10 @@ fn curve_encode(curve: u32, v: f32) -> f32 { e = linear_to_srgb(a); } else if (curve == 2u) { e = powz(a, 1.0 / 2.4); + } else if (curve == 3u) { + e = pq_encode(a); + } else if (curve == 4u) { + e = hlg_encode(a); } return select(e, -e, v < 0.0); } diff --git a/crates/gpu/src/walk.rs b/crates/gpu/src/walk.rs index ea56df7a..6051b4aa 100644 --- a/crates/gpu/src/walk.rs +++ b/crates/gpu/src/walk.rs @@ -48,6 +48,14 @@ pub(crate) fn render<'g>(e: &mut Enc<'g>, r: &Renderer, comp_id: ItemId, t: Tick canvas = ops::quantize(e, &canvas, l); } } + if r.depth() == 0 + && let Some((lin, mode, enc)) = pipe.output_hdr() + { + on_cpu(e, &mut canvas, |img| { + effectcraft_render::color::output_hdr(img, lin, mode, enc); + pipe.quantize(img); + })?; + } Some(canvas) } diff --git a/crates/host/src/lib.rs b/crates/host/src/lib.rs index e07f1f76..b5de7714 100644 --- a/crates/host/src/lib.rs +++ b/crates/host/src/lib.rs @@ -468,4 +468,46 @@ mod tests { s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap(); assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0); } + /// Create Stereo 3D Rig: the eye cameras' expressions follow the master camera and the + /// Stereo 3D Controls (and agree with the values the command wrote). + #[test] + fn stereo_rig_expressions_follow_the_master_camera() { + let mut s = super::session(); + s.execute("comp.new", json!({"name": "Shot", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap(); + let src = s.active_comp_id().unwrap(); + let cam = s.execute("layer.newCamera", json!({"name": "Main", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"] + .as_u64() + .unwrap(); + let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap(); + let ctl = r["controls"].as_u64().unwrap(); + let left = effectcraft_engine::project::ItemId(r["leftComp"].as_u64().unwrap()); + // The left eye camera's evaluated value (expressions on). + let v3 = |s: &effectcraft_engine::Session, path: &str| { + let comp = s.project.comp(left).unwrap(); + let layer = &comp.layers[0]; + let ctx = effectcraft_engine::render::EvalCtx { + project: &s.project, + comp_id: left, + comp, + time: Default::default(), + expr: s.expr.as_deref(), + footage: None, + }; + ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3() + }; + let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6); + let d = 0.04 * 640.0; + assert!(close(v3(&s, "transform/position"), [320.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position")); + assert!(close(v3(&s, "transform/poi"), [320.0 - d / 2.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi")); + // Moving the master moves the eyes. + s.execute("comp.open", json!({"comp": src.0})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [300, 180, -800]})).unwrap(); + s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [300, 180, 0]})).unwrap(); + assert!(close(v3(&s, "transform/position"), [300.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position")); + // Controls: Center & Left puts the left eye a full separation left; convergence toes in. + s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/configuration", "value": 2})).unwrap(); + s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/convergence", "value": true})).unwrap(); + assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position")); + assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi")); + } } diff --git a/crates/model/src/gltf.rs b/crates/model/src/gltf.rs index 375884bc..fa2e930a 100644 --- a/crates/model/src/gltf.rs +++ b/crates/model/src/gltf.rs @@ -7,8 +7,9 @@ //! (base colour, metallic-roughness, normal, occlusion and emissive textures; alpha modes; //! double-sided; `KHR_materials_emissive_strength`, `KHR_materials_unlit`), embedded or //! external PNG/JPEG images with sampler wrap modes, skins and animations (step, linear and -//! cubic-spline translation/rotation/scale). Sparse accessors, morph targets, cameras and -//! lights are ignored. +//! cubic-spline translation/rotation/scale), perspective and orthographic cameras, and +//! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets +//! are ignored. use std::sync::Arc; @@ -363,6 +364,8 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< matrix, mesh: idx(n, "mesh").filter(|&m| m < meshes.len()), skin: idx(n, "skin"), + camera: idx(n, "camera"), + light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")), children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(), }); } @@ -434,7 +437,54 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option>) -> Result< animations.push(anim); } - Ok(Model { meshes, nodes, roots, materials, textures, skins, animations }) + let cameras = arr(&j, "cameras").iter().enumerate().map(|(i, c)| camera(c, i)).collect(); + let lights = j + .get("extensions") + .and_then(|e| e.get("KHR_lights_punctual")) + .map(|e| arr(e, "lights").iter().enumerate().map(|(i, l)| light(l, i)).collect()) + .unwrap_or_default(); + Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights }) +} + +fn name_or(v: &Value, d: String) -> String { + v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string).unwrap_or(d) +} + +/// A glTF camera (glTF 2.0 §5.12): `perspective` {yfov, aspectRatio?, znear} or +/// `orthographic` {xmag, ymag}. +fn camera(c: &Value, i: usize) -> crate::ModelCamera { + let projection = match (c.get("type").and_then(Value::as_str), c.get("orthographic")) { + (Some("orthographic"), Some(o)) => crate::CameraProjection::Orthographic { xmag: num(o, "xmag", 1.0), ymag: num(o, "ymag", 1.0) }, + _ => { + let p = c.get("perspective").cloned().unwrap_or(Value::Null); + crate::CameraProjection::Perspective { + yfov: num(&p, "yfov", 0.8).clamp(1e-3, 3.1), + aspect: p.get("aspectRatio").and_then(Value::as_f64), + znear: num(&p, "znear", 0.01), + } + } + }; + crate::ModelCamera { name: name_or(c, format!("Camera {}", i + 1)), projection } +} + +/// A `KHR_lights_punctual` light: type, color, intensity, range, spot cone angles. +fn light(l: &Value, i: usize) -> crate::ModelLight { + let kind = match l.get("type").and_then(Value::as_str) { + Some("directional") => crate::ModelLightKind::Directional, + Some("spot") => { + let s = l.get("spot").cloned().unwrap_or(Value::Null); + let outer = num(&s, "outerConeAngle", std::f64::consts::FRAC_PI_4).clamp(1e-3, std::f64::consts::FRAC_PI_2); + crate::ModelLightKind::Spot { inner: num(&s, "innerConeAngle", 0.0).clamp(0.0, outer), outer } + } + _ => crate::ModelLightKind::Point, + }; + crate::ModelLight { + name: name_or(l, format!("Light {}", i + 1)), + kind, + color: nums(l, "color", [1.0; 3]), + intensity: num(l, "intensity", 1.0).max(0.0), + range: l.get("range").and_then(Value::as_f64), + } } fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result> { diff --git a/crates/model/src/lib.rs b/crates/model/src/lib.rs index 290e5500..93a6a9f9 100644 --- a/crates/model/src/lib.rs +++ b/crates/model/src/lib.rs @@ -295,6 +295,10 @@ pub struct Node { pub matrix: Option, pub mesh: Option, pub skin: Option, + /// A camera of [`Model::cameras`] at this node (looking down the node's −Z, +Y up). + pub camera: Option, + /// A light of [`Model::lights`] at this node (`KHR_lights_punctual`, shining down −Z). + pub light: Option, pub children: Vec, } @@ -308,6 +312,8 @@ impl Default for Node { matrix: None, mesh: None, skin: None, + camera: None, + light: None, children: vec![], } } @@ -495,6 +501,60 @@ pub struct Model { pub textures: Vec>, pub skins: Vec, pub animations: Vec, + /// Cameras embedded in the file (glTF `cameras`). + pub cameras: Vec, + /// Punctual lights embedded in the file (glTF `KHR_lights_punctual`). + pub lights: Vec, +} + +/// A camera embedded in a model file. +#[derive(Clone, Debug, PartialEq)] +pub struct ModelCamera { + pub name: String, + pub projection: CameraProjection, +} + +/// glTF camera projections. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum CameraProjection { + /// Vertical field of view (radians), optional aspect ratio, near plane. + Perspective { yfov: f64, aspect: Option, znear: f64 }, + /// Half the orthographic view's width and height (model units). + Orthographic { xmag: f64, ymag: f64 }, +} + +/// Kinds of `KHR_lights_punctual` lights. +#[derive(Clone, Copy, Debug, PartialEq)] +pub enum ModelLightKind { + Directional, + Point, + /// Inner and outer cone angles (radians, from the axis). + Spot { + inner: f64, + outer: f64, + }, +} + +/// A punctual light embedded in a model file. +#[derive(Clone, Debug, PartialEq)] +pub struct ModelLight { + pub name: String, + pub kind: ModelLightKind, + /// Linear RGB. + pub color: [f64; 3], + /// Candela (point, spot) or lux (directional). + pub intensity: f64, + pub range: Option, +} + +/// A camera or light placed in model space by [`Model::placed`]. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Placed { + /// Index into [`Model::cameras`] or [`Model::lights`]. + pub index: usize, + pub node: usize, + /// Node → model space. + pub world: Mat4, } /// One mesh placed in model space by [`Model::instances`]. @@ -579,6 +639,31 @@ impl Model { out } + /// The scene's cameras (`lights = false`) or lights (`true`) placed at animation `clip` + /// time `t`, in node order. + pub fn placed(&self, lights: bool, clip: Option, t: f64) -> Vec { + let world = self.world_pose(&self.local_pose(clip, t)); + let mut reach = vec![false; self.nodes.len()]; + let mut stack: Vec = self.roots.clone(); + while let Some(i) = stack.pop() { + if i >= self.nodes.len() || reach[i] { + continue; + } + reach[i] = true; + stack.extend(self.nodes[i].children.iter().copied()); + } + let count = if lights { self.lights.len() } else { self.cameras.len() }; + self.nodes + .iter() + .enumerate() + .filter(|(i, _)| reach[*i]) + .filter_map(|(i, n)| { + let index = if lights { n.light } else { n.camera }?; + (index < count).then_some(Placed { index, node: i, world: world[i] }) + }) + .collect() + } + /// Model-space bounds of the rest pose (None for an empty model). pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> { let mut lo = [f64::INFINITY; 3]; diff --git a/crates/model/src/tests.rs b/crates/model/src/tests.rs index 31040b7c..1a3b1322 100644 --- a/crates/model/src/tests.rs +++ b/crates/model/src/tests.rs @@ -316,3 +316,47 @@ fn contours_group_into_outlines_by_nesting() { assert_eq!(g.len(), 3); assert_eq!(g.iter().map(|o| o.holes.len()).sum::(), 2); } + +#[test] +fn gltf_cameras_and_punctual_lights() { + let doc = r#"{ + "asset": {"version": "2.0"}, + "scene": 0, + "scenes": [{"nodes": [0, 2]}], + "nodes": [ + {"name": "Rig", "translation": [0, 0, 10], "children": [1]}, + {"name": "Cam", "camera": 0, "translation": [1, 0, 0]}, + {"name": "Sun", "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 1}}}, + {"name": "Orphan", "camera": 1} + ], + "cameras": [ + {"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.5, "znear": 0.1, "aspectRatio": 1.5}}, + {"type": "orthographic", "orthographic": {"xmag": 2, "ymag": 1, "znear": 0, "zfar": 10}} + ], + "extensions": {"KHR_lights_punctual": {"lights": [ + {"type": "spot", "name": "Key", "color": [1, 0.5, 0.25], "intensity": 3, "spot": {"innerConeAngle": 0.2, "outerConeAngle": 0.4}}, + {"type": "directional", "intensity": 2} + ]}} + }"#; + let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap(); + assert_eq!(m.cameras.len(), 2); + assert_eq!(m.cameras[0].name, "Shot"); + assert_eq!(m.cameras[0].projection, CameraProjection::Perspective { yfov: 0.5, aspect: Some(1.5), znear: 0.1 }); + assert_eq!(m.cameras[1].projection, CameraProjection::Orthographic { xmag: 2.0, ymag: 1.0 }); + assert_eq!(m.lights.len(), 2); + assert_eq!(m.lights[0].kind, ModelLightKind::Spot { inner: 0.2, outer: 0.4 }); + assert_eq!(m.lights[0].color, [1.0, 0.5, 0.25]); + assert_eq!(m.lights[1].kind, ModelLightKind::Directional); + assert_eq!(m.lights[1].name, "Light 2"); + // Only nodes of the scene are placed; the camera node inherits its parent's translation. + let cams = m.placed(false, None, 0.0); + assert_eq!(cams.len(), 1); + assert_eq!((cams[0].index, cams[0].node), (0, 1)); + let eye = cams[0].world.apply(vec3(0.0, 0.0, 0.0)); + assert!((eye.x - 1.0).abs() < 1e-9 && (eye.z - 10.0).abs() < 1e-9, "{eye:?}"); + let lights = m.placed(true, None, 0.0); + assert_eq!(lights.len(), 1); + // −90° about X: the light's −Z axis points down (−Y in glTF). + let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0)); + assert!((d.y + 1.0).abs() < 1e-6, "{d:?}"); +} diff --git a/crates/project/src/build.rs b/crates/project/src/build.rs index 931a846b..fe39082d 100644 --- a/crates/project/src/build.rs +++ b/crates/project/src/build.rs @@ -847,6 +847,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource markers: vec![], markers_locked: false, environment: false, + environment_background: false, auto_orient, props: root, } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index e9a641e8..470c4edc 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -95,6 +95,62 @@ pub enum TimeDisplayStyle { #[default] Timecode, Frames, + /// Feet + Frames, 35 mm film (16 frames per foot). + Feet35, + /// Feet + Frames, 16 mm film (40 frames per foot). + Feet16, +} + +impl TimeDisplayStyle { + /// Frames per foot of the Feet + Frames styles. + pub fn frames_per_foot(self) -> Option { + match self { + TimeDisplayStyle::Feet35 => Some(16), + TimeDisplayStyle::Feet16 => Some(40), + _ => None, + } + } + pub fn id(self) -> &'static str { + match self { + TimeDisplayStyle::Timecode => "timecode", + TimeDisplayStyle::Frames => "frames", + TimeDisplayStyle::Feet35 => "feet35", + TimeDisplayStyle::Feet16 => "feet16", + } + } + 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() { + "timecode" => Some(TimeDisplayStyle::Timecode), + "frames" => Some(TimeDisplayStyle::Frames), + "feet35" | "feetframes" | "feetandframes" | "feet35mm" | "35mm" | "feetframes35mm" => Some(TimeDisplayStyle::Feet35), + "feet16" | "feet16mm" | "16mm" | "feetframes16mm" => Some(TimeDisplayStyle::Feet16), + _ => None, + } + } +} + +/// Project Settings ▸ Color ▸ Color Engine. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ColorEngine { + /// Built-in colour management (ICC-style working spaces). + #[default] + Adobe, + /// OCIO managed, with the built-in configuration (ACES working spaces). + Ocio, +} + +/// Project Settings ▸ Color ▸ HDR: how over-range (HDR) values reach a standard-dynamic-range +/// display or output. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum HdrMode { + /// Values above 1.0 clip. + #[default] + Clip, + /// Highlights above 80% are companded (an exponential knee) into the remaining range. + Compand, + /// Extended Reinhard tone mapping of luminance (white at 4.0). + ToneMap, } #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] @@ -118,6 +174,16 @@ pub struct ProjectSettings { /// when a GPU adapter exists) or Mercury Software Only (`false`, the CPU compositor). #[serde(default = "yes")] pub gpu_acceleration: bool, + /// Color Engine (Adobe-style built-in or OCIO with ACES working spaces). + #[serde(default)] + pub color_engine: ColorEngine, + /// HDR handling for standard-dynamic-range output (display and renders). + #[serde(default)] + pub hdr: HdrMode, + /// The space the top-level comp is output in (renders and the viewer's source). `None` = + /// sRGB; Rec. 2100 PQ / HLG give HDR output. + #[serde(default)] + pub output_space: Option, } impl Default for ProjectSettings { @@ -131,6 +197,9 @@ impl Default for ProjectSettings { frame_start: 0, audio_sample_rate: 48_000, gpu_acceleration: true, + color_engine: ColorEngine::Adobe, + hdr: HdrMode::Clip, + output_space: None, } } } @@ -552,6 +621,10 @@ pub struct Layer { /// reflections) of Advanced 3D comps instead of being drawn. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub environment: bool, + /// Layer ▸ Light ▸ Create Environment Light Background Layer: the layer's (equirectangular) + /// image is drawn as the 3D scene's backdrop, seen through the camera, instead of as a card. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub environment_background: bool, /// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…). pub props: PropGroup, } diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index dbb65cb6..8b8f0e97 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -17,7 +17,7 @@ //! pixels to their parent). Without a working space the sRGB curve is used. use effectcraft_color::{ColorSpace, Conversion}; -use effectcraft_project::ProjectSettings; +use effectcraft_project::{HdrMode, ProjectSettings}; use effectcraft_raster::Image; use rayon::prelude::*; @@ -32,12 +32,24 @@ pub struct Pipe { pub linear: bool, /// Blend in linear light (and the working space is not already linear). pub linear_blend: bool, + /// The top-level output's space (sRGB unless Project Settings choose another). + pub out: ColorSpace, + /// HDR handling for standard-dynamic-range output. + pub hdr: HdrMode, } 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 } + // ACES working spaces are linear light by definition. + let linear = s.working_space.is_some_and(|w| s.linearize || w.is_linear()); + Pipe { + levels: s.bit_depth.levels(), + space: s.working_space, + linear, + linear_blend: s.blend_linear && !linear, + out: s.output_space.unwrap_or(ColorSpace::Srgb), + hdr: s.hdr, + } } /// The space whose curve encodes working-space pixels (sRGB when unmanaged). @@ -67,10 +79,22 @@ impl Pipe { self.linear_blend.then(|| Conversion::delinearize(self.curve())) } - /// Working space → sRGB display (top-level comp output). + /// Working space → the output space (top-level comp output; sRGB by default). `None` when + /// unmanaged or when [`Pipe::output_hdr`] applies. pub fn output(&self) -> Option { let ws = self.space?; - Conversion::new(ws, self.linear, ColorSpace::Srgb, false) + if self.output_hdr().is_some() { + return None; + } + Conversion::new(ws, self.linear, self.out, false) + } + + /// Output with HDR compand / tone mapping (standard-dynamic-range outputs only): working + /// space → linear output primaries, the HDR curve, then the output's encoding. + pub fn output_hdr(&self) -> Option<(Option, HdrMode, Conversion)> { + let ws = self.space?; + (self.hdr != HdrMode::Clip && !self.out.is_hdr()) + .then(|| (Conversion::new(ws, self.linear, self.out, true), self.hdr, Conversion::delinearize(self.out))) } /// One pixel clamped and quantised to the bit depth. @@ -122,6 +146,7 @@ impl Pipe { 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 = k.wrapping_mul(31).wrapping_add(self.out as u64 * 4 + self.hdr as u64); k } } @@ -202,3 +227,42 @@ pub fn convert(img: &mut Image, c: &Conversion) { *p = [o[0] * a, o[1] * a, o[2] * a, a]; }); } + +/// Compand: an exponential knee from 80%, approaching 1.0 (linear light, per channel). +pub fn compand(v: f32) -> f32 { + const K: f32 = 0.8; + if v <= K { v } else { K + (1.0 - K) * (1.0 - (-(v - K) / (1.0 - K)).exp()) } +} + +/// Extended Reinhard tone mapping of luminance with white at `W` (linear light). +pub fn tone_map(c: [f32; 3]) -> [f32; 3] { + const W: f32 = 4.0; + let l = effectcraft_color::luminance(c[0], c[1], c[2]); + if l <= 0.0 { + return c; + } + let lm = l * (1.0 + l / (W * W)) / (1.0 + l); + let k = lm / l; + c.map(|v| v * k) +} + +/// The HDR step of [`Pipe::output_hdr`] on premultiplied pixels. +pub fn output_hdr(img: &mut Image, to_linear: Option, mode: HdrMode, encode: Conversion) { + img.data.par_iter_mut().for_each(|p| { + let a = p[3]; + if a <= 0.0 { + return; + } + let mut c = [p[0] / a, p[1] / a, p[2] / a]; + if let Some(t) = &to_linear { + c = t.apply(c); + } + c = match mode { + HdrMode::Clip => c, + HdrMode::Compand => c.map(compand), + HdrMode::ToneMap => tone_map(c), + }; + let o = encode.apply(c); + *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 58a04193..aa990f0e 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -551,6 +551,12 @@ impl<'a> Renderer<'a> { color::convert(&mut canvas, &c); self.pipe.quantize(&mut canvas); } + if self.depth == 0 + && let Some((lin, mode, enc)) = self.pipe.output_hdr() + { + color::output_hdr(&mut canvas, lin, mode, enc); + self.pipe.quantize(&mut canvas); + } canvas } @@ -1016,7 +1022,7 @@ impl<'a> Renderer<'a> { /// The region of interest of a top-level 2D frame: (x, y) offset of the output in output /// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp). - fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> { + pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> { let r = self.opts.roi.filter(|_| self.depth == 0)?; let s = self.opts.scale; if r[2] <= 0.0 || r[3] <= 0.0 { @@ -1256,17 +1262,29 @@ impl<'a> Renderer<'a> { } /// Apply the track matte / preserve transparency to an isolated layer render, then blend. - fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) { + fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, iso: Image, canvas: &mut Image, opacity: f32) { + self.composite_iso_with(ctx, layer, iso, canvas, opacity, None); + } + + /// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D + /// matte seen through the camera); otherwise the matte layer is placed in 2D. + pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option) { let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind))); let preserve = layer.preserve_transparency; if let Some((m, kind)) = matte { - let mut mimg = Image::new(canvas.width, canvas.height); - if m.is_active_at(ctx.time) - && let Some(mb) = self.layer_buf(ctx, m) - { - let mo = ctx.opacity(m) as f32; - self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo); - } + let mimg = match matte_img { + Some(i) if i.width == canvas.width && i.height == canvas.height => i, + _ => { + let mut mimg = Image::new(canvas.width, canvas.height); + if m.is_active_at(ctx.time) + && let Some(mb) = self.layer_buf(ctx, m) + { + let mo = ctx.opacity(m) as f32; + self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo); + } + mimg + } + }; iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| { let k = match kind { MatteKind::Alpha => q[3], diff --git a/crates/render/src/three_d/adv/mod.rs b/crates/render/src/three_d/adv/mod.rs index 04f23885..f8690b8f 100644 --- a/crates/render/src/three_d/adv/mod.rs +++ b/crates/render/src/three_d/adv/mod.rs @@ -140,25 +140,135 @@ pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> scene::build(r, ctx, run, out) } -/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`. -pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) { - let s = scene::build(r, ctx, run, (canvas.width, canvas.height)); - if s.indices.is_empty() { - return; +/// Rasterise one scene (GPU when available, else CPU) and resolve it. +fn raster_resolved(r: &Renderer, s: &Scene) -> (Image, Vec) { + let target = r.active_accel().and_then(|a| a.raster_3d(s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(s)); + resolve(&target, s.ssaa.max(1)) +} + +/// Number of motion-blur sub-samples for a set of layers (1 = none blurred). +fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize { + if !layers.iter().any(|l| scene::motion_blurred(r, ctx, l)) { + return 1; } - let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s)); - let (mut img, depth) = resolve(&target, s.ssaa.max(1)); + if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize } +} + +/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and +/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is +/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the +/// depth is the nearest over the samples. +pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec)> { + let n = mb_samples(r, ctx, layers); + let (mut img, depth, linear) = if n <= 1 { + let s = scene::build(r, ctx, layers, out); + if s.indices.is_empty() { + return None; + } + let (img, depth) = raster_resolved(r, &s); + (img, depth, s.linear_io) + } else { + let fd = ctx.comp.frame_duration().seconds(); + let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0); + let mut acc = Image::new(out.0, out.1); + let mut depth = vec![f32::INFINITY; (out.0 * out.1) as usize]; + let mut linear = false; + let mut any = false; + let k = 1.0 / n as f32; + for i in 0..n { + let f = phase + angle * i as f64 / (n - 1) as f64; + let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd)); + let s = scene::build_at(r, ctx, Some(&sub), layers, out); + linear = s.linear_io; + if s.indices.is_empty() { + continue; + } + any = true; + let (img, d) = raster_resolved(r, &s); + acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, p)| { + for c in 0..4 { + a[c] += p[c] * k; + } + }); + depth.par_iter_mut().zip(d.par_iter()).for_each(|(a, b)| *a = a.min(*b)); + } + if !any { + return None; + } + (acc, depth, linear) + }; let cam = camera_for(r, ctx); if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { img = depth_of_field(&img, &depth, &dof, r.opts.scale); } - if !s.linear_io { + if !linear { encode(&mut img); } - canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| { - let k = 1.0 - s[3]; - *d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k]; - }); + Some((img, depth)) +} + +/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path. +fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool { + l.blend_mode != effectcraft_color::BlendMode::Normal + || l.preserve_transparency + || l.track_matte.is_some_and(|tm| tm.layer != l.id && ctx.comp.layer(tm.layer).is_some()) +} + +/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`. +/// +/// Layers with a blend mode, a track matte or Preserve Transparency can't blend inside the +/// depth-buffered scene: they are rendered on their own (lit by the same lights, through the +/// same camera), hidden where the rest of the run is nearer, then composited through the 2D +/// path (matte, Preserve Transparency, blend mode) from the farthest to the nearest. +pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) { + let out = (canvas.width, canvas.height); + let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l)); + let main = render_layers(r, ctx, &plain, out); + if let Some((img, _)) = &main { + canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| { + let k = 1.0 - s[3]; + *d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k]; + }); + } + if special.is_empty() { + return; + } + let cam = camera_for(r, ctx); + let mut order: Vec<(f64, &Layer)> = special + .into_iter() + .map(|l| { + let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3])); + (cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l) + }) + .collect(); + order.sort_by(|a, b| b.0.total_cmp(&a.0)); + for (_, l) in order { + let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue }; + if let Some((m, md)) = &main { + iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| { + if *mz < *z - 0.01 * z.abs().max(1.0) { + let k = 1.0 - q[3].clamp(0.0, 1.0); + for c in p.iter_mut() { + *c *= k; + } + } + }); + } + // A 3D matte layer is drawn through the camera too. + let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| { + let mut solo = m.clone(); + solo.track_matte = None; + solo.blend_mode = effectcraft_color::BlendMode::Normal; + solo.preserve_transparency = false; + solo.switches.video = true; + if m.is_active_at(ctx.time) { + render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1)) + } else { + Image::new(out.0, out.1) + } + }); + r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d); + } } #[cfg(test)] diff --git a/crates/render/src/three_d/adv/scene.rs b/crates/render/src/three_d/adv/scene.rs index 14251745..2c3b03bc 100644 --- a/crates/render/src/three_d/adv/scene.rs +++ b/crates/render/src/three_d/adv/scene.rs @@ -806,6 +806,20 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 { /// Build the scene of a run of 3D layers. pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene { + build_at(r, ctx, None, run, out) +} + +/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option). +pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool { + r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur +} + +/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights +/// are evaluated at `sub`'s time; the other layers stay at `ctx`'s. +pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: &[&Layer], out: (u32, u32)) -> Scene { + let base = ctx; + let at = sub.unwrap_or(ctx); + let ctx = at; let cam = camera_for(r, ctx); let lin = r.pipe.linear || r.pipe.linear_blend; let ssaa = ssaa_for(r, out); @@ -816,6 +830,7 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32) cam, }; for layer in run { + let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base }; if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() { continue; } diff --git a/crates/render/src/three_d/adv/tests.rs b/crates/render/src/three_d/adv/tests.rs index 42a69522..4e8b9c3b 100644 --- a/crates/render/src/three_d/adv/tests.rs +++ b/crates/render/src/three_d/adv/tests.rs @@ -312,3 +312,116 @@ fn extruded_text_renders_as_a_mesh() { let img = render(&p, cid); assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200); } + +// ---------------------------------------------------------------- motion blur, blend modes, mattes + +fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId { + p.add_item("S", Default::default(), None, ItemKind::Solid(effectcraft_project::Solid { color, width: w, height: h, pixel_aspect: 1.0 })) +} + +/// Pixels of row `y` whose alpha is partial (motion-blur smear or anti-aliasing). +fn partial(img: &crate::Image, y: u32) -> usize { + (0..img.width).filter(|x| (0.05..0.95).contains(&img.data[(y * img.width + x) as usize][3])).count() +} + +#[test] +fn motion_blur_smears_moving_meshes() { + let (mut p, cid) = project(160, 120); + add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| { + set(l, "geometryOptions/width", Value::Scalar(30.0)); + set(l, "geometryOptions/height", Value::Scalar(30.0)); + set(l, "geometryOptions/depth", Value::Scalar(30.0)); + // 60 px per frame to the right. + let pr = l.props.prop_mut("transform/position").unwrap(); + pr.keys = vec![ + effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 60.0, 0.0])), + effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0 + 30.0 * 60.0, 60.0, 0.0])), + ]; + }); + let sharp = render(&p, cid); + let hard = partial(&sharp, 60); + assert!(hard <= 4, "no blur without the switch: {hard}"); + p.comp_mut(cid).unwrap().layers[0].switches.motion_blur = true; + let blurred = render(&p, cid); + let smear = partial(&blurred, 60); + // A 180° shutter smears over half a frame of motion (≈ 30 px). + assert!(smear > 20, "smeared: {smear}"); + // Coverage is conserved: the summed alpha along the row stays about the cube's width. + let sum = |im: &crate::Image| (0..160).map(|x| im.data[60 * 160 + x][3]).sum::(); + assert!((sum(&blurred) - sum(&sharp)).abs() < 4.0, "{} vs {}", sum(&blurred), sum(&sharp)); + // The comp's motion blur switch and the render option turn it off. + p.comp_mut(cid).unwrap().enable_motion_blur = false; + assert!(partial(&render(&p, cid), 60) <= 4); + p.comp_mut(cid).unwrap().enable_motion_blur = true; + let r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() }); + assert!(partial(&r.comp_frame(cid, Tick::ZERO), 60) <= 4); + // Shutter angle 0 has no exposure time: no smear. + p.comp_mut(cid).unwrap().shutter_angle = 0.0; + assert!(partial(&render(&p, cid), 60) <= 4); +} + +#[test] +fn blend_modes_and_occlusion_in_advanced_3d() { + let (mut p, cid) = project(120, 90); + let red = solid(&mut p, [1.0, 0.0, 0.0], 120, 90); + let gray = solid(&mut p, [0.5, 0.5, 0.5], 60, 40); + let blue = solid(&mut p, [0.0, 0.0, 1.0], 20, 20); + // Back: a red card; middle: a gray Multiply card; front: a small blue card on the left. + add(&mut p, cid, LayerSource::Solid { item: red }, |l| { + l.switches.three_d = true; + }); + add(&mut p, cid, LayerSource::Solid { item: gray }, |l| { + l.switches.three_d = true; + l.blend_mode = effectcraft_color::BlendMode::Multiply; + set(l, "transform/position", Value::Vec3([60.0, 45.0, -20.0])); + set(l, "transform/anchor", Value::Vec3([30.0, 20.0, 0.0])); + }); + add(&mut p, cid, LayerSource::Solid { item: blue }, |l| { + l.switches.three_d = true; + set(l, "transform/position", Value::Vec3([45.0, 45.0, -40.0])); + set(l, "transform/anchor", Value::Vec3([10.0, 10.0, 0.0])); + }); + let img = render(&p, cid); + let at = |x: usize, y: usize| img.data[y * 120 + x]; + // Multiply over red: half red, no gray. + let c = at(70, 45); + assert!((c[0] - 0.5).abs() < 0.02 && c[1] < 0.02 && c[2] < 0.02 && c[3] > 0.99, "multiplied {c:?}"); + // Outside the gray card: plain red. + let r = at(115, 5); + assert!(r[0] > 0.98 && r[1] < 0.02, "{r:?}"); + // The nearer blue card hides the Multiply card behind it. + let b = at(40, 45); + assert!(b[2] > 0.98 && b[0] < 0.02, "occluded {b:?}"); + // Normal mode for comparison: gray covers the red. + p.comp_mut(cid).unwrap().layers[1].blend_mode = effectcraft_color::BlendMode::Normal; + let n = render(&p, cid).data[45 * 120 + 70]; + assert!((n[0] - 0.5).abs() < 0.02 && (n[1] - 0.5).abs() < 0.02, "{n:?}"); +} + +#[test] +fn track_mattes_in_advanced_3d() { + let (mut p, cid) = project(120, 90); + let white = solid(&mut p, [1.0, 1.0, 1.0], 120, 90); + let small = solid(&mut p, [1.0, 1.0, 1.0], 40, 30); + // Layer order (top first): the matte, then the matted layer. + add(&mut p, cid, LayerSource::Solid { item: white }, |l| l.switches.three_d = true); + add(&mut p, cid, LayerSource::Solid { item: small }, |l| { + l.switches.three_d = true; + l.switches.video = false; + // A 3D matte is seen through the camera. + set(l, "transform/position", Value::Vec3([30.0, 30.0, 0.0])); + set(l, "transform/anchor", Value::Vec3([20.0, 15.0, 0.0])); + }); + let matte = p.comp(cid).unwrap().layers[0].id; + p.comp_mut(cid).unwrap().layers[1].track_matte = Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::Alpha }); + let img = render(&p, cid); + let inside = img.data[30 * 120 + 30]; + let outside = img.data[70 * 120 + 100]; + assert!(inside[3] > 0.99 && inside[0] > 0.99, "{inside:?}"); + assert!(outside[3] < 0.01, "{outside:?}"); + // An inverted alpha matte keeps the outside instead. + p.comp_mut(cid).unwrap().layers[1].track_matte = + Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::AlphaInverted }); + let img = render(&p, cid); + assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99); +} diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index 626a7685..b0a5f472 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -340,11 +340,15 @@ pub enum View3D { Custom1, Custom2, Custom3, + /// The comp's default camera (50 mm, looking at the comp centre), ignoring camera layers; + /// orbitable like the custom views. + Default, } impl View3D { - pub const ALL: [View3D; 10] = [ + pub const ALL: [View3D; 11] = [ View3D::ActiveCamera, + View3D::Default, View3D::Front, View3D::Left, View3D::Top, @@ -367,6 +371,7 @@ impl View3D { View3D::Custom1 => "Custom View 1", View3D::Custom2 => "Custom View 2", View3D::Custom3 => "Custom View 3", + View3D::Default => "Default", } } /// Stable id used by commands (`activeCamera`, `front`, `custom1`…). @@ -382,6 +387,7 @@ impl View3D { View3D::Custom1 => "custom1", View3D::Custom2 => "custom2", View3D::Custom3 => "custom3", + View3D::Default => "default", } } pub fn from_id(s: &str) -> Option { @@ -433,6 +439,7 @@ pub fn default_view_cam(v: View3D, w: f64, h: f64) -> ViewCam { View3D::Custom1 => custom(30.0, -20.0), View3D::Custom2 => custom(0.0, -35.0), View3D::Custom3 => custom(-30.0, -20.0), + View3D::Default => ViewCam { eye: arr(vec3(w / 2.0, h / 2.0, -zoom)), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false }, } } diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 85b60a76..732aee68 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -338,6 +338,15 @@ fn scene_lights(ctx: &EvalCtx) -> Vec { /// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`. pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) { + // Environment background layers: an infinitely distant sky behind the rest of the run. + if run.iter().any(|l| l.environment_background) { + for l in run.iter().filter(|l| l.environment_background) { + draw_sky(r, ctx, l, canvas); + } + let rest: Vec<&Layer> = run.iter().copied().filter(|l| !l.environment_background).collect(); + draw_run(r, ctx, &rest, canvas); + return; + } // 3D adjustment layers act on everything below them in the stack (like 2D ones): split. if let Some(k) = run.iter().position(|l| l.switches.adjustment) { draw_run(r, ctx, &run[..k], canvas); @@ -793,3 +802,49 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)]; aux } + +/// Rotation (radians) of the comp's first Environment light, which also turns its backdrop. +pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 { + ctx.comp + .layers + .iter() + .find(|l| matches!(l.source, effectcraft_project::LayerSource::Light { kind: LightKind::Environment }) && l.is_active_at(ctx.time)) + .and_then(|l| l.props.sub("lightOptions").map(|g| ctx.f(l, g, "rotation", 0.0))) + .unwrap_or(0.0) + .to_radians() as f32 +} + +/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each +/// pixel's view direction through the camera, as an infinitely distant backdrop. +pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { + let Some(buf) = r.blend_layer_buf(ctx, layer) else { return }; + let img = &buf.img; + if img.width == 0 || img.height == 0 { + return; + } + let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0); + if op <= 0.0 { + return; + } + let cam = camera_for(r, ctx); + let (fwd, right, down) = (cam.forward(), cam.right(), cam.down()); + let s = r.opts.scale.max(1e-9); + let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64); + let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y)); + let rot = environment_rotation(ctx); + let (iw, ih) = (img.width as f64, img.height as f64); + canvas.rows_mut().for_each(|(y, row)| { + let cy = (y as f64 + 0.5 + oy) / s; + for (x, d) in row.iter_mut().enumerate() { + let cx = (x as f64 + 0.5 + ox) / s; + let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() }; + let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot); + // Wrap horizontally across the seam. + let sx = (u as f64 * iw).clamp(0.5, iw - 0.5); + let sy = (v as f64 * ih).clamp(0.5, ih - 0.5); + let p = img.sample_bilinear(sx, sy); + let k = 1.0 - p[3] * op; + *d = [p[0] * op + d[0] * k, p[1] * op + d[1] * k, p[2] * op + d[2] * k, p[3] * op + d[3] * k]; + } + }); +} diff --git a/crates/time/src/lib.rs b/crates/time/src/lib.rs index c3077490..3d4c2923 100644 --- a/crates/time/src/lib.rs +++ b/crates/time/src/lib.rs @@ -435,6 +435,55 @@ pub fn parse_timecode(input: &str, rate: FrameRate, drop_frame: bool, current: i }) } +/// Format a frame count as Feet + Frames (film footage counting) with `per_foot` frames per +/// foot (35 mm film: 16, 16 mm: 40): `0012+07`. Negative counts get a leading `-`. +pub fn format_feet_frames(frame: i64, per_foot: i64) -> String { + let pf = per_foot.max(1); + let sign = if frame < 0 { "-" } else { "" }; + let a = frame.abs(); + let w = if pf > 100 { 3 } else { 2 }; + format!("{sign}{:04}+{:0w$}", a / pf, a % pf) +} + +/// Parse a Feet + Frames field into a frame count: `12+07` (feet + frames; frames may overflow +/// a foot), `-1+04`, a bare frame count (`250`), or `+n` / `-n` frames relative to `current`. +pub fn parse_feet_frames(input: &str, per_foot: i64, current: i64) -> Result { + let s = input.trim(); + let bad = || ParseError(format!("not feet+frames: `{input}`")); + let num = |t: &str| -> Result { + let t = t.trim(); + if t.is_empty() { + return Ok(0); + } + if !t.bytes().all(|b| b.is_ascii_digit()) { + return Err(bad()); + } + t.parse::().map_err(|_| ParseError("number too large".into())) + }; + if s.is_empty() { + return Err(ParseError("empty feet+frames".into())); + } + let (sign, body) = match s.as_bytes()[0] { + b'-' => (-1, &s[1..]), + b'+' => (1, &s[1..]), + _ => (0, s), + }; + match body.rfind('+') { + Some(i) => { + let v = num(&body[..i])? * per_foot.max(1) + num(&body[i + 1..])?; + // A sign before feet+frames is the sign of the count, not a relative move. + Ok(if sign < 0 { -v } else { v }) + } + None => { + let v = num(body)?; + Ok(match sign { + 0 => v, + k => current + k * v, + }) + } + } +} + /// Common audio sample rates offered in sequence settings. pub const SAMPLE_RATES: [i64; 6] = [32_000, 44_100, 48_000, 88_200, 96_000, 192_000]; @@ -525,4 +574,29 @@ mod tests { prop_assert_eq!(parse_timecode(&txt, r, false, 0).unwrap(), f); } } + + #[test] + fn feet_and_frames() { + // 35 mm: 16 frames per foot; 16 mm: 40. + assert_eq!(format_feet_frames(0, 16), "0000+00"); + assert_eq!(format_feet_frames(16 * 12 + 7, 16), "0012+07"); + assert_eq!(format_feet_frames(95, 40), "0002+15"); + assert_eq!(format_feet_frames(-20, 16), "-0001+04"); + assert_eq!(parse_feet_frames("12+07", 16, 0), Ok(199)); + assert_eq!(parse_feet_frames("0012+07", 16, 0), Ok(199)); + assert_eq!(parse_feet_frames("2+15", 40, 0), Ok(95)); + assert_eq!(parse_feet_frames("-1+04", 16, 0), Ok(-20)); + assert_eq!(parse_feet_frames("1+20", 16, 0), Ok(36), "frames may overflow a foot"); + assert_eq!(parse_feet_frames("250", 16, 0), Ok(250)); + assert_eq!(parse_feet_frames("+10", 16, 100), Ok(110)); + assert_eq!(parse_feet_frames("-10", 16, 100), Ok(90)); + assert_eq!(parse_feet_frames("+", 16, 5), Ok(5)); + assert!(parse_feet_frames("1:2", 16, 0).is_err()); + assert!(parse_feet_frames("", 16, 0).is_err()); + for f in [0, 1, 15, 16, 17, 1234, 99_999] { + for pf in [16, 40] { + assert_eq!(parse_feet_frames(&format_feet_frames(f, pf), pf, 0), Ok(f)); + } + } + } } diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 3e051948..39640ef6 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -286,6 +286,10 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ("HDTV (Rec. 709)", json!("rec709")), ("Rec. 2020", json!("rec2020")), ("Display P3", json!("p3")), + ("ACEScg", json!("acescg")), + ("ACES2065-1", json!("aces2065")), + ("Rec. 2100 PQ", json!("rec2100pq")), + ("Rec. 2100 HLG", json!("rec2100hlg")), ], f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), ), @@ -316,8 +320,27 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { Field::choice( "timeDisplay", "Time display style", - &[("Timecode", json!("timecode")), ("Frames", json!("frames"))], - usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)), + &[ + ("Timecode", json!("timecode")), + ("Frames", json!("frames")), + ("Feet + Frames (35mm)", json!("feet35")), + ("Feet + Frames (16mm)", json!("feet16")), + ], + { + use effectcraft_engine::project::TimeDisplayStyle as T; + match st.time_display { + T::Timecode => 0, + T::Frames => 1, + T::Feet35 => 2, + T::Feet16 => 3, + } + }, + ), + Field::choice( + "colorEngine", + "Color engine", + &[("Adobe-style built-in", json!("adobe")), ("OCIO (built-in ACES config)", json!("ocio"))], + usize::from(st.color_engine == effectcraft_engine::project::ColorEngine::Ocio), ), Field::choice( "workingSpace", @@ -328,11 +351,42 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ("HDTV (Rec. 709)", json!("rec709")), ("Rec. 2020", json!("rec2020")), ("Display P3", json!("p3")), + ("ACEScg", json!("acescg")), + ("ACES2065-1", json!("aces2065")), ], - st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::WORKING.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), + Field::choice( + "hdr", + "HDR on SDR displays and outputs", + &[("Clip", json!("clip")), ("Compand", json!("compand")), ("Tone map", json!("toneMap"))], + st.hdr as usize, + ), + Field::choice( + "outputSpace", + "Output color space", + &[ + ("sRGB IEC61966-2.1 (default)", json!("default")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ("Rec. 2100 PQ (HDR)", json!("rec2100pq")), + ("Rec. 2100 HLG (HDR)", json!("rec2100hlg")), + ], + { + use effectcraft_engine::project::ColorSpace as C; + match st.output_space { + Some(C::Rec709) => 1, + Some(C::Rec2020) => 2, + Some(C::DisplayP3) => 3, + Some(C::Rec2100Pq) => 4, + Some(C::Rec2100Hlg) => 5, + _ => 0, + } + }, + ), // Video Rendering and Effects ▸ Use. Field::choice( "renderer", diff --git a/crates/ui-egui/src/panels/mod.rs b/crates/ui-egui/src/panels/mod.rs index 2e6d03ae..d0fb3a3e 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -41,7 +41,7 @@ pub mod viewer_tools; pub mod waveform; use effectcraft_engine::Session; -use effectcraft_engine::project::{Comp, TimeDisplayStyle}; +use effectcraft_engine::project::Comp; use effectcraft_engine::time::Tick; use egui::Rect; @@ -59,14 +59,10 @@ pub enum DragPayload { Property { layer: u64, prop: u64 }, } -/// The current time formatted per project settings (timecode with `;` for drop-frame, or frames). +/// The current time formatted per project settings (timecode with `;` for drop-frame, frames +/// or Feet + Frames). pub fn timecode(session: &Session, comp: &Comp, t: Tick) -> String { - let fr = comp.frame_rate; - match session.project.settings.time_display { - TimeDisplayStyle::Frames => format!("{:05}", fr.frame_at(t) + session.project.settings.frame_start), - // AE's display: `0:00:02:15` (non-drop-frame counting, as AE shows new comps). - TimeDisplayStyle::Timecode => effectcraft_engine::time::format_timecode_ae(fr.frame_at(t + comp.display_start), fr, false), - } + effectcraft_engine::commands::time::display_time(session, comp, t) } pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, p: PanelKind, rect: Rect) { diff --git a/crates/ui-egui/src/panels/viewer.rs b/crates/ui-egui/src/panels/viewer.rs index cdbd47b2..04999a90 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -312,6 +312,70 @@ fn aux_views( main } +/// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel +/// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle. +fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect { + let Some(lv) = app.session.state.locked_viewer else { return full }; + let Some(comp) = app.session.project.comp(lv.comp).cloned() else { return full }; + let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default(); + let gap = 2.0; + let mid = full.center().x; + let r = Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y)); + let main = Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max); + let ctx = ui.ctx().clone(); + let p = ui.painter().clone(); + p.rect_filled(Rect::from_min_max(pos2(r.max.x, full.min.y), pos2(main.min.x, full.max.y)), 0.0, Color32::from_black_alpha(200)); + p.rect_filled(r, 0.0, bg); + let (cw, ch) = (comp.width as f32, comp.height as f32); + let fit = ((r.width() - 20.0) / cw).min((r.height() - 44.0) / ch).max(0.01); + let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit)); + let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64; + let t = app.session.time_of(lv.comp); + let cam = (lv.view != View3D::ActiveCamera && comp.has_3d()) + .then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state()); + let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session); + let key = { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h); + h.finish() + }; + let id = egui::Id::new("viewer-locked"); + let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id)); + let tex = match cached { + Some((k, tex)) if k == key => tex, + _ => { + let opts = effectcraft_engine::render::RenderOpts { scale, view: cam, ..Default::default() }; + let img = app.session.render(lv.comp, t, opts); + let mut ci = crate::frames::to_color_image(&img); + if let Some(dc) = dc { + let mut px: Vec<[u8; 4]> = ci.pixels.iter().map(|c| c.to_array()).collect(); + dc.apply(&mut px); + ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()); + } + let tex = ctx.load_texture("viewer-locked", ci, egui::TextureOptions::LINEAR); + ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); + tex + } + }; + let b = comp.background; + p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8)); + p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE); + p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside); + let label = format!("Locked: {name} \u{2014} {}", if comp.has_3d() { lv.view.label() } else { "Active Camera" }); + p.text(r.left_top() + vec2(8.0, 8.0), Align2::LEFT_TOP, &label, Tokens::ui(11.0), Color32::from_white_alpha(210)); + app.auto.add("viewer.locked", r, &label); + // Close the locked viewer. + let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0)); + let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click()); + p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) }); + app.auto.add("viewer.locked.close", close, "Close Locked Viewer"); + if resp.clicked() { + let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({})); + } + main +} + pub(crate) fn checker(p: &egui::Painter, r: Rect) { let s = 10.0; p.rect_filled(r, 0.0, Color32::from_gray(0xcc)); @@ -380,6 +444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard); // View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which // keeps the overlays and the interaction. + let full = locked_pane(app, ui, full, pasteboard); let area = aux_views(app, ui, &comp, cid, full, pasteboard); p.rect_filled(area, 0.0, pasteboard); // View ▸ Show Rulers takes a strip on the top and left. diff --git a/crates/ui-egui/src/panels/viewer_tools.rs b/crates/ui-egui/src/panels/viewer_tools.rs index 36178036..ff98f778 100644 --- a/crates/ui-egui/src/panels/viewer_tools.rs +++ b/crates/ui-egui/src/panels/viewer_tools.rs @@ -307,8 +307,13 @@ pub(crate) fn showing_snapshot(app: &EffectcraftApp, ctx: &egui::Context) -> boo app.session.snapshot.is_some() && (app.session.state.viewer.show_snapshot || f5) } -fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32) -> egui::ColorImage { +/// Show Channel and exposure, then the display colour conversion (View ▸ Use Display Color +/// Management / Simulate Output). +fn transformed(img: &egui::ColorImage, ch: Channel, colorized: bool, stops: f32, dc: Option) -> egui::ColorImage { let mut px: Vec<[u8; 4]> = img.pixels.iter().map(|c| c.to_array()).collect(); + if let Some(dc) = dc.filter(|_| matches!(ch, Channel::Rgb | Channel::RgbStraight)) { + dc.apply(&mut px); + } vw::display_transform(&mut px, ch, colorized, stops); egui::ColorImage::new(img.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect()) } @@ -336,14 +341,18 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: } return; } - let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0; + let dc = vw::DisplayColor::of(&app.session); + // The display conversion's inputs, for the texture caches. + let dc_key = format!("{:?}{:?}{}", dc.is_some(), opts.simulation, app.session.prefs.previews.display_profile) + + &format!("{:?}{:?}{}", app.session.project.settings.working_space, app.session.project.settings.output_space, opts.display_color_management); + let plain = opts.channel == Channel::Rgb && opts.exposure == 0.0 && dc.is_none(); let uv = Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)); if snap { let Some(s) = app.session.snapshot.clone() else { return }; let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h); + (std::sync::Arc::as_ptr(&s.image) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-snapshot-tex"); @@ -351,7 +360,7 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: Some((k, tex)) if k == key => tex, _ => { let img = crate::frames::to_color_image(&s.image); - let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure) }; + let img = if plain { img } else { transformed(&img, opts.channel, opts.colorized, opts.exposure, dc) }; let tex = ctx.load_texture("viewer-snapshot", img, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex @@ -394,14 +403,14 @@ pub(crate) fn draw_frame(app: &mut EffectcraftApp, ctx: &egui::Context, painter: let key = { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); - (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits()).hash(&mut h); + (std::sync::Arc::as_ptr(&src) as usize, opts.channel.id(), opts.colorized, opts.exposure.to_bits(), &dc_key).hash(&mut h); h.finish() }; let id = egui::Id::new("viewer-display-tex"); let tex = match ctx.data(|d| d.get_temp::<(u64, egui::TextureHandle)>(id)) { Some((k, tex)) if k == key => tex, _ => { - let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure), egui::TextureOptions::LINEAR); + let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone()))); tex } diff --git a/docs/architecture.md b/docs/architecture.md index 39acd396..0487cbd5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -101,9 +101,15 @@ transparent ones are sorted back to front and blended. The software rasteriser `Accelerator::raster_3d` with a wgpu render pipeline (`advanced3d.wgsl`) that repeats the shading step for step, and tests compare the two. Resolve, depth of field (a gather blur by each pixel's circle of confusion from the camera's Depth of Field settings) and the conversion -back to the working encoding run on the CPU for both. Not yet: motion blur, track mattes and -blend modes inside an Advanced 3D run, collapsed 3D precomps (drawn flattened), morph targets, -extruded strokes. +back to the working encoding run on the CPU for both. **Motion blur** renders the scene at +sub-samples spread over the comp's shutter (angle, phase, samples per frame) — layers with the +Motion Blur switch, the camera and the lights at the sub-sample time, other layers at the frame +time — and averages them. Layers with a **blend mode, a track matte or Preserve Transparency** +are rendered on their own through the same camera and lights, hidden where the rest of the run +is nearer, and composited from the farthest to the nearest through the 2D path (a 3D matte is +itself drawn through the camera). **Environment Light Background** layers (Layer ▸ Light) draw +their equirectangular image behind the run, looked up by each pixel's view direction. Not yet: +collapsed 3D precomps (drawn flattened), morph targets, extruded strokes. A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of its evaluated inputs, excluding the transform. Static and transform-only layers render once; diff --git a/docs/effects.md b/docs/effects.md index 75d32478..a7717d75 100644 --- a/docs/effects.md +++ b/docs/effects.md @@ -3,11 +3,11 @@ -EffectCraft ships 303 effects with 2434 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. +EffectCraft ships 304 effects with 2446 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over. -- **GPU**: 24 effects also run on the GPU compositor with identical results. -- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping. -- **Status**: 261 implemented in full, 42 partial (what is missing is listed). +- **GPU**: 25 effects also run on the GPU compositor with identical results. +- **32**: 304 effects process 32-bit float (HDR, overbright) pixels without clamping. +- **Status**: 262 implemented in full, 42 partial (what is missing is listed). Effects are addressed by id (`ec..`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`). @@ -113,8 +113,8 @@ Effects are addressed by id (`ec..`) or display name in commands | Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented | | Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files | | OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented | -| OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented | -| OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented | +| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented | +| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented | | OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented | | OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented | | PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented | @@ -182,6 +182,7 @@ Effects are addressed by id (`ec..`) or display name in commands | Layer Control | `ec.control.layer` | 1 | GPU | 32 | Implemented | | Point Control | `ec.control.point` | 1 | GPU | 32 | Implemented | | Slider Control | `ec.control.slider` | 1 | GPU | 32 | Implemented | +| Stereo 3D Controls | `ec.control.stereo3d` | 5 | GPU | 32 | Implemented | ## Generate diff --git a/docs/parity.md b/docs/parity.md index fbe12a5f..38df7882 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -22,8 +22,7 @@ Tracking 88%, Effects 85%, Web 65%. What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates; -approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur -and blend modes; a few viewer and colour-management menu items; OpenType features and variable +approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI; Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and the "better than After Effects" items (a plug-in API, branching history, GPU particles). @@ -120,10 +119,10 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) | | Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) | | Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) | -| 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | +| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 | | Effects | ≈ 80% | 4.5 | GPU versions of more effects and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | -| Project | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | +| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) | | Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) | @@ -143,7 +142,7 @@ Mocha and Cineware are third-party and not counted. |---|---|---| | 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. | | Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere to Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. | -| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are not read yet. | +| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read (Configuration ▸ Custom: colorspaces, roles, displays/views; Matrix, File, Exponent, Log, LogAffine, CDL, Range and Group transforms). | | Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. | | Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. | | Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. | diff --git a/docs/preferences.md b/docs/preferences.md index cb05229e..4e37704e 100644 --- a/docs/preferences.md +++ b/docs/preferences.md @@ -89,6 +89,7 @@ These rows are in the dialog for After Effects parity and are stored, but nothin - `composition.hardwareAcceleratePanels`: Hardware Accelerate Composition, Layer and Footage Panels - `previews.showInternalWireframes`: Show Internal Wireframes - `previews.zoomQuality`: Viewer Zoom Quality +- `previews.displayProfile`: Display Color Space - `appearance.useLabelColorForTabs`: Use Label Color for Related Tabs - `appearance.cycleMaskColors`: Cycle Mask Colors - `appearance.useGradients`: Use Gradients