From 9994f5d3092d30e7ad068d1e85b1048e9e32f9fb Mon Sep 17 00:00:00 2001 From: Brandon Thomas Date: Sat, 3 Oct 2026 02:25:56 -0400 Subject: [PATCH] M7.7: viewer and colour completion View > Switch 3D View > Default, Split with New Locked Viewer, Use Display Color Management (sRGB/P3 display in Settings > Previews), Simulate Output (Rec. 709/2020, P3, sRGB, Linear, NTSC, PAL, Mac 1.8, Custom; Preserve RGB). Project Settings: Color Engine (OCIO built-in config with ACEScg/ACES2065-1 working spaces), HDR clip/compand/ tone map, output space incl. Rec. 2100 PQ/HLG (SMPTE ST 2084, BT.2100, BT.2408 reference white; GPU curves too), Time Display Feet + Frames (35mm/16mm) display and entry. OCIO effects read custom .ocio configs (YAML subset; Matrix/File/Exponent/Log/ LogAffine/CDL/Range/Group transforms, roles, displays/views). Co-Authored-By: Claude Opus 5.5 --- crates/color/src/space.rs | 255 +++++- crates/effects/src/lib.rs | 1 + crates/effects/src/ocio.rs | 62 +- crates/effects/src/ocio_config.rs | 928 ++++++++++++++++++++++ crates/engine/src/commands/file.rs | 47 +- crates/engine/src/commands/mod.rs | 2 +- crates/engine/src/commands/stubs.rs | 5 - crates/engine/src/commands/three_d.rs | 6 +- crates/engine/src/commands/time.rs | 34 +- crates/engine/src/commands/viewer_cmds.rs | 126 ++- crates/engine/src/lib.rs | 5 + crates/engine/src/menus.rs | 14 + crates/engine/src/prefs.rs | 3 + crates/engine/src/tests_color_view.rs | 223 ++++++ crates/engine/src/viewer.rs | 268 +++++++ crates/gpu/src/ops.rs | 12 +- crates/gpu/src/shaders/common.wgsl | 47 +- crates/gpu/src/walk.rs | 8 + crates/project/src/lib.rs | 69 ++ crates/render/src/color.rs | 74 +- crates/render/src/lib.rs | 6 + crates/render/src/three_d/camera.rs | 9 +- crates/time/src/lib.rs | 74 ++ crates/ui-egui/src/panels/forms.rs | 60 +- crates/ui-egui/src/panels/mod.rs | 12 +- crates/ui-egui/src/panels/viewer.rs | 65 ++ crates/ui-egui/src/panels/viewer_tools.rs | 21 +- docs/parity.md | 9 +- docs/preferences.md | 1 + 29 files changed, 2362 insertions(+), 84 deletions(-) create mode 100644 crates/effects/src/ocio_config.rs create mode 100644 crates/engine/src/tests_color_view.rs 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/lib.rs b/crates/effects/src/lib.rs index 39912324..42fb9f3c 100644 --- a/crates/effects/src/lib.rs +++ b/crates/effects/src/lib.rs @@ -34,6 +34,7 @@ mod noise; mod noise2; 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 d868a8f9..b3ffe363 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", "Source", lin709), + p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text), p("display", "Display", 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", 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 48285a33..58e89598 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 4d2adea4..63909f63 100644 --- a/crates/engine/src/commands/mod.rs +++ b/crates/engine/src/commands/mod.rs @@ -44,7 +44,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; diff --git a/crates/engine/src/commands/stubs.rs b/crates/engine/src/commands/stubs.rs index c2683e60..24fea4e7 100644 --- a/crates/engine/src/commands/stubs.rs +++ b/crates/engine/src/commands/stubs.rs @@ -29,11 +29,6 @@ pub fn specs() -> Vec { stub!("camera.linkFocusToPoi", "Link Focus Distance to Point of Interest", ["Layer", "Camera"], "{}"), stub!("camera.linkFocusToLayer", "Link Focus Distance to Layer", ["Layer", "Camera"], "{}"), stub!("camera.setFocusToLayer", "Set Focus Distance to Layer", ["Layer", "Camera"], "{}"), - 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 c0161510..9bc2d99c 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 c9d931d7..3fc74b1e 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -215,6 +215,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 { @@ -651,6 +654,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; diff --git a/crates/engine/src/menus.rs b/crates/engine/src/menus.rs index 66d19894..b1041aaf 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 }; @@ -853,6 +864,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..316d2e5a --- /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/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/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/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/project/src/lib.rs b/crates/project/src/lib.rs index fb7f7208..018c6abd 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, } } } 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 86d1bd9a..7de8cbd1 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 } diff --git a/crates/render/src/three_d/camera.rs b/crates/render/src/three_d/camera.rs index fb307b8c..5d33ea3f 100644 --- a/crates/render/src/three_d/camera.rs +++ b/crates/render/src/three_d/camera.rs @@ -322,11 +322,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, @@ -349,6 +353,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`…). @@ -364,6 +369,7 @@ impl View3D { View3D::Custom1 => "custom1", View3D::Custom2 => "custom2", View3D::Custom3 => "custom3", + View3D::Default => "default", } } pub fn from_id(s: &str) -> Option { @@ -415,6 +421,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/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 2c1f95ee..aee74e66 100644 --- a/crates/ui-egui/src/panels/mod.rs +++ b/crates/ui-egui/src/panels/mod.rs @@ -40,7 +40,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; @@ -58,14 +58,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 64babbf2..f157e0bb 100644 --- a/crates/ui-egui/src/panels/viewer.rs +++ b/crates/ui-egui/src/panels/viewer.rs @@ -286,6 +286,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)); @@ -354,6 +418,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 79688e19..4ad14510 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 @@ -378,14 +387,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/parity.md b/docs/parity.md index 3517c484..5e344ae2 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 | 65% | 10.4 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8) | | Text | ≈ 86% | 1.3 | vertical type is basic (upright characters, no tate-chu-yoko), no OpenType feature panel, no extruded strokes (extruded, bevelled text landed in M7.6); per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API landed (M9.9–M9.10) | | Web | 60% | 2.5 | threads, browser storage, audio | -| 3D | 76% | 10.0 | multi-view layouts, Advanced 3D motion blur and blend modes, cameras/lights from models; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 | +| 3D | 84% | 7.0 | multi-view layouts, 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; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) landed in M7.4–M7.6 | | Effects | ≈ 75% | 5.0 | GPU versions of more effects (every After Effects effect exists since M9.11, M12.5 and M12.6) | | Interface | 70% | 6.0 | Timeline horizontal scrolling of many columns, a richer Learn area (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 | 68% | 6.0 | variable-width mask feather points, Roto Brush's learned (3.0) segmentation model (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) | @@ -140,7 +139,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