Merge main into M14.5 (settings and menus)
Display Color Space setting joins the live list; View3D::Default maps to view.3d.default for the Assign Shortcut submenu; stub tests use the stubs that remain. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
commit
fa27d000ea
|
|
@ -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<Conversion> {
|
||||
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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,13 @@ use effectcraft_project::ParamUi;
|
|||
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
|
||||
/// Id of the Stereo 3D Controls effect (Layer ▸ Camera ▸ Create Stereo 3D Rig).
|
||||
pub const STEREO_CONTROLS: &str = "ec.control.stereo3d";
|
||||
/// Stereo 3D Controls ▸ Configuration: which eyes are offset from the master camera.
|
||||
pub const STEREO_CONFIGURATIONS: [&str; 3] = ["Stereo Pair (Left & Right)", "Center & Right", "Center & Left"];
|
||||
/// Stereo 3D Controls ▸ Convergence Of: the zero-parallax distance.
|
||||
pub const STEREO_CONVERGENCE: [&str; 2] = ["Camera Point of Interest", "Camera Zoom Plane"];
|
||||
|
||||
fn passthrough(_: &EffectCtx, b: Buf) -> Buf {
|
||||
b
|
||||
}
|
||||
|
|
@ -24,5 +31,17 @@ pub fn specs() -> Vec<EffectSpec> {
|
|||
spec("ec.control.point3d", "3D Point Control", vec![p("point", "3D Point", Value::Vec3([0.0, 0.0, 0.0]), ParamUi::Point3)]),
|
||||
spec("ec.control.layer", "Layer Control", vec![p("layer", "Layer", Value::Layer(None), ParamUi::Layer)]),
|
||||
spec("ec.control.dropdown", "Dropdown Menu Control", vec![p("menu", "Menu", Value::Enum(0), popup(&["Item 1", "Item 2", "Item 3"]))]),
|
||||
// Layer ▸ Camera ▸ Create Stereo 3D Rig: the rig's eye cameras read these (expressions).
|
||||
spec(
|
||||
STEREO_CONTROLS,
|
||||
"Stereo 3D Controls",
|
||||
vec![
|
||||
p("configuration", "Configuration", Value::Enum(0), popup(&STEREO_CONFIGURATIONS)),
|
||||
p("sceneDepth", "Stereo Scene Depth", num(3.0), slider(0.0, 100.0, 0.0, 10.0, 2)),
|
||||
p("convergence", "Enable Convergence", Value::Bool(false), ParamUi::Checkbox),
|
||||
p("convergenceOf", "Convergence Of", Value::Enum(0), popup(&STEREO_CONVERGENCE)),
|
||||
p("zOffset", "Convergence Z Offset", num(0.0), slider(-100_000.0, 100_000.0, -2000.0, 2000.0, 1)),
|
||||
],
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ mod channel3d;
|
|||
mod color2;
|
||||
mod color3;
|
||||
mod color_fx;
|
||||
mod controls;
|
||||
pub mod controls;
|
||||
mod distort;
|
||||
mod distort2;
|
||||
mod distort3;
|
||||
|
|
@ -37,6 +37,7 @@ mod noise2;
|
|||
mod noise3;
|
||||
mod obsolete;
|
||||
mod ocio;
|
||||
pub mod ocio_config;
|
||||
pub mod paint;
|
||||
mod perspective;
|
||||
mod perspective2;
|
||||
|
|
|
|||
|
|
@ -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<std::sync::Arc<crate::ocio_config::Config>> {
|
||||
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<EffectSpec> {
|
|||
"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<EffectSpec> {
|
|||
"OCIO Display Transform",
|
||||
cc,
|
||||
vec![
|
||||
p("config", "Configuration", Value::Enum(0), popup(&["Built-in (EffectCraft minimal)"])),
|
||||
p("config", "Configuration", Value::Enum(0), popup(CONFIGS)),
|
||||
p("configFile", "Config File (.ocio)", Value::Str(String::new()), ParamUi::Text),
|
||||
space_param("source", "Input Color Space", lin709),
|
||||
p("sourceName", "Source (config name)", Value::Str(String::new()), ParamUi::Text),
|
||||
p("display", "Display Device", Value::Enum(0), popup(&DISPLAYS.iter().map(|d| d.0).collect::<Vec<_>>())),
|
||||
p("displayName", "Display (config name)", Value::Str(String::new()), ParamUi::Text),
|
||||
p("view", "View Transform", Value::Enum(0), popup(VIEWS)),
|
||||
p("viewName", "View (config name)", Value::Str(String::new()), ParamUi::Text),
|
||||
direction(),
|
||||
],
|
||||
ocio_display,
|
||||
|
|
|
|||
|
|
@ -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 (`!<View> {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), `!<Tag>` 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<Yaml>),
|
||||
Map(Vec<(String, Yaml)>),
|
||||
/// A `!<Tag>` node.
|
||||
Tagged(String, Box<Yaml>),
|
||||
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<f64> {
|
||||
self.str()?.trim().parse().ok()
|
||||
}
|
||||
fn nums(&self) -> Vec<f64> {
|
||||
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<u8> {
|
||||
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<String>, 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<Arc<FileXform>>,
|
||||
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<Xf>),
|
||||
Inverse(Box<Xf>),
|
||||
/// 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<String>) {
|
||||
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<PathBuf> {
|
||||
let p = Path::new(src);
|
||||
if p.is_absolute() {
|
||||
return p.exists().then(|| p.to_path_buf());
|
||||
}
|
||||
let mut dirs: Vec<PathBuf> = 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<String>,
|
||||
pub family: String,
|
||||
pub is_data: bool,
|
||||
pub to_ref: Option<Xf>,
|
||||
pub from_ref: Option<Xf>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
pub roles: Vec<(String, String)>,
|
||||
pub spaces: Vec<ConfigSpace>,
|
||||
/// 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<Config, String> {
|
||||
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<String> = 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<String> {
|
||||
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<HashMap<String, Option<Arc<Config>>>>;
|
||||
|
||||
/// Load (and cache) a config from a `.ocio` path or the config text itself.
|
||||
pub fn load_config(src: &str) -> Option<Arc<Config>> {
|
||||
let t = src.trim();
|
||||
if t.is_empty() {
|
||||
return None;
|
||||
}
|
||||
static CACHE: OnceLock<ConfigCache> = 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:
|
||||
- !<View> {name: Standard, colorspace: gamma22}
|
||||
- !<View> {name: Raw, colorspace: raw}
|
||||
|
||||
colorspaces:
|
||||
- !<ColorSpace>
|
||||
name: lin
|
||||
family: ""
|
||||
description: |
|
||||
The reference.
|
||||
Linear.
|
||||
isdata: false
|
||||
|
||||
- !<ColorSpace>
|
||||
name: gamma22
|
||||
aliases: [g22, "gamma 2.2"]
|
||||
to_scene_reference: !<ExponentTransform> {value: [2.2, 2.2, 2.2, 1]}
|
||||
|
||||
- !<ColorSpace>
|
||||
name: half
|
||||
from_reference: !<MatrixTransform> {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]}
|
||||
|
||||
- !<ColorSpace>
|
||||
name: log2
|
||||
from_reference: !<GroupTransform>
|
||||
children:
|
||||
- !<MatrixTransform> {matrix: [2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 2, 0, 0, 0, 0, 1]}
|
||||
- !<LogTransform> {base: 2}
|
||||
|
||||
- !<ColorSpace>
|
||||
name: affine
|
||||
to_reference: !<LogAffineTransform> {base: 10, logSideSlope: 0.5, logSideOffset: 0.2, linSideSlope: 2, linSideOffset: 0.01, direction: inverse}
|
||||
|
||||
- !<ColorSpace>
|
||||
name: lut
|
||||
to_reference: !<FileTransform> {src: double.cube, interpolation: linear}
|
||||
|
||||
- !<ColorSpace>
|
||||
name: raw
|
||||
isdata: true
|
||||
|
||||
- !<ColorSpace>
|
||||
name: weird
|
||||
to_reference: !<ExposureContrastTransform> {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: !<T> {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 - !<View> {name: Video, colorspace: g2}\ncolorspaces:\n - !<ColorSpace>\n name: lin\n - !<ColorSpace>\n name: g2\n to_reference: !<ExponentTransform> {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);
|
||||
}
|
||||
}
|
||||
|
|
@ -242,6 +242,7 @@ pub(crate) fn view_command(v: effectcraft_render::three_d::View3D) -> &'static s
|
|||
use effectcraft_render::three_d::View3D::*;
|
||||
match v {
|
||||
ActiveCamera => "view.3d.activeCamera",
|
||||
Default => "view.3d.default",
|
||||
Front => "view.3d.front",
|
||||
Left => "view.3d.left",
|
||||
Top => "view.3d.top",
|
||||
|
|
|
|||
|
|
@ -326,15 +326,52 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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::<Vec<_>>().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,
|
||||
|
|
@ -347,8 +384,8 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
};
|
||||
}
|
||||
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(())
|
||||
})?;
|
||||
|
|
@ -421,7 +458,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ mod proxy;
|
|||
pub mod puppet;
|
||||
mod query;
|
||||
mod render_queue;
|
||||
pub(crate) mod rig3d;
|
||||
pub mod roto_cmds;
|
||||
mod settings;
|
||||
mod shape_stroke;
|
||||
|
|
@ -48,7 +49,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;
|
||||
|
|
@ -123,6 +124,7 @@ pub fn command_specs() -> &'static [CommandSpec] {
|
|||
v.extend(styles::specs());
|
||||
v.extend(three_d::specs());
|
||||
v.extend(model3d::specs());
|
||||
v.extend(rig3d::specs());
|
||||
v.extend(text_anim::specs());
|
||||
v.extend(text_edit::specs());
|
||||
v.extend(layer_time::specs());
|
||||
|
|
|
|||
|
|
@ -0,0 +1,614 @@
|
|||
//! Camera and light rigs: Layer ▸ Camera ▸ Create Stereo 3D Rig / Create Orbit Null / Create
|
||||
//! Cameras from 3D Model, Layer ▸ Light ▸ Create Lights from 3D Model / Control Light with
|
||||
//! Camera / Create Environment Light Background Layer.
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_effects::controls::{STEREO_CONFIGURATIONS, STEREO_CONTROLS, STEREO_CONVERGENCE};
|
||||
use effectcraft_geom::{Mat4, Vec3, vec3};
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::build::{self, Ids};
|
||||
use effectcraft_project::{AutoOrient, Comp, Expression, ItemId, ItemKind, Layer, LayerId, LayerSource, LightKind, Project};
|
||||
use effectcraft_render::EvalCtx;
|
||||
use effectcraft_render::three_d::adv::scene::model_to_layer;
|
||||
use effectcraft_render::three_d::camera::{self, layer_frame};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use super::layer::insert_layer;
|
||||
use super::{CommandSpec, b_p, bad, comp_id, f_p, has_comp, resolve_layer};
|
||||
use crate::{EngineError, Result, Session, cmd};
|
||||
|
||||
fn arr(v: Vec3) -> [f64; 3] {
|
||||
[v.x, v.y, v.z]
|
||||
}
|
||||
|
||||
/// Set a property's static value (clears its keyframes and expression).
|
||||
fn set(l: &mut Layer, path: &str, v: KV) {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.value = v;
|
||||
pr.keys.clear();
|
||||
pr.expr = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn set_expr(l: &mut Layer, path: &str, text: String) {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.expr = Some(Expression { text, enabled: true });
|
||||
}
|
||||
}
|
||||
|
||||
/// What evaluation needs from the session (cloned so edits can evaluate their own project).
|
||||
struct Env {
|
||||
time: effectcraft_time::Tick,
|
||||
expr: Option<std::sync::Arc<dyn effectcraft_render::ExprHost>>,
|
||||
footage: std::sync::Arc<dyn effectcraft_render::FootageSource>,
|
||||
}
|
||||
|
||||
impl Env {
|
||||
fn of(s: &Session, cid: ItemId) -> Env {
|
||||
Env { time: s.time_of(cid), expr: s.expr.clone(), footage: s.footage.clone() }
|
||||
}
|
||||
fn ctx<'a>(&'a self, proj: &'a Project, cid: ItemId, comp: &'a Comp) -> EvalCtx<'a> {
|
||||
EvalCtx { project: proj, comp_id: cid, comp, time: self.time, expr: self.expr.as_deref(), footage: Some(self.footage.as_ref()) }
|
||||
}
|
||||
}
|
||||
|
||||
/// The camera a rig command acts on: `layer`, else a selected camera, else the active camera.
|
||||
fn camera_p(s: &Session, p: &Value, cid: ItemId, cmd: &str) -> Result<LayerId> {
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
let id = match p.get("layer").or_else(|| p.get("camera")) {
|
||||
Some(v) => resolve_layer(comp, v).ok_or_else(|| bad(cmd, "no such layer"))?,
|
||||
None => s
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.copied()
|
||||
.find(|id| comp.layer(*id).is_some_and(Layer::is_camera))
|
||||
.or_else(|| comp.active_camera(s.time_of(cid)).map(|l| l.id))
|
||||
.ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
|
||||
};
|
||||
if !comp.layer(id).is_some_and(Layer::is_camera) {
|
||||
return Err(bad(cmd, "the layer is not a camera"));
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Distance from a camera to what it looks at: its point of interest (two-node), else its zoom.
|
||||
fn focus_distance(ctx: &EvalCtx, cam: &Layer) -> f64 {
|
||||
let st = camera::layer_camera(ctx, cam);
|
||||
if camera::is_two_node(cam)
|
||||
&& let Some(tr) = cam.transform()
|
||||
{
|
||||
let poi = Vec3::from(ctx.v3(cam, tr, "poi", [0.0; 3]));
|
||||
let poi = camera::parent_world(ctx, cam).apply(poi);
|
||||
return (poi - st.eye).length().max(1.0);
|
||||
}
|
||||
st.zoom
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- stereo 3D rig
|
||||
|
||||
/// Stereo rig settings (Stereo 3D Controls).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct StereoParams {
|
||||
/// 0 Stereo Pair (Left & Right), 1 Center & Right, 2 Center & Left.
|
||||
pub configuration: u32,
|
||||
/// Interaxial separation, % of the comp width.
|
||||
pub scene_depth: f64,
|
||||
pub convergence: bool,
|
||||
/// 0 the camera's point of interest, 1 its zoom plane.
|
||||
pub convergence_of: u32,
|
||||
pub z_offset: f64,
|
||||
}
|
||||
|
||||
/// Eye offsets along the camera's x axis, in units of the interaxial separation.
|
||||
pub fn eye_offsets(configuration: u32) -> [f64; 2] {
|
||||
match configuration {
|
||||
1 => [0.0, 1.0],
|
||||
2 => [-1.0, 0.0],
|
||||
_ => [-0.5, 0.5],
|
||||
}
|
||||
}
|
||||
|
||||
/// (position, point of interest) of the left (`eye` 0) or right (1) camera, from the master
|
||||
/// camera's eye, axes, focus distance and zoom.
|
||||
pub fn eye_camera(sp: &StereoParams, eye: usize, comp_w: f64, master: (Vec3, Vec3, Vec3), focus: f64, zoom: f64) -> (Vec3, Vec3) {
|
||||
let (e, fwd, right) = master;
|
||||
let d = sp.scene_depth / 100.0 * comp_w;
|
||||
let pos = e + right * (eye_offsets(sp.configuration)[eye.min(1)] * d);
|
||||
let poi = if sp.convergence {
|
||||
let dc = if sp.convergence_of == 1 { zoom } else { focus } + sp.z_offset;
|
||||
e + fwd * dc.max(1.0)
|
||||
} else {
|
||||
pos + fwd * focus
|
||||
};
|
||||
(pos, poi)
|
||||
}
|
||||
|
||||
/// Expression snippets that rebuild [`eye_camera`] from the master camera and the controls.
|
||||
fn eye_exprs(src_comp: &str, master: &str, controls: &str, two_node: bool, eye: usize) -> (String, String, String) {
|
||||
let q = |s: &str| serde_json::to_string(s).unwrap_or_else(|_| "\"\"".into());
|
||||
let k = eye_offsets(0)[eye.min(1)];
|
||||
let k1 = eye_offsets(1)[eye.min(1)];
|
||||
let k2 = eye_offsets(2)[eye.min(1)];
|
||||
let head = format!(
|
||||
"var src = comp({c});\nvar m = src.layer({m});\nvar fx = src.layer({x}).effect(\"Stereo 3D Controls\");\n\
|
||||
var d = fx(\"Stereo Scene Depth\").value / 100 * src.width;\nvar cfg = fx(\"Configuration\").value;\n\
|
||||
var k = cfg == 2 ? {k1} : (cfg == 3 ? {k2} : {k});\n\
|
||||
var E = m.toWorld([0, 0, 0]);\nvar F = normalize(m.toWorldVec([0, 0, 1]));\n\
|
||||
var eye = add(E, mul(normalize(m.toWorldVec([1, 0, 0])), k * d));\n",
|
||||
c = q(src_comp),
|
||||
m = q(master),
|
||||
x = q(controls),
|
||||
);
|
||||
let dm = if two_node { "Math.max(length(sub(m.transform.pointOfInterest, m.transform.position)), 1)" } else { "m.cameraOption.zoom" };
|
||||
let pos = format!("{head}eye;");
|
||||
let poi = format!(
|
||||
"{head}var dm = {dm};\nvar dc = (fx(\"Convergence Of\").value == 2 ? m.cameraOption.zoom : dm) + fx(\"Convergence Z Offset\").value;\n\
|
||||
fx(\"Enable Convergence\").value ? add(E, mul(F, Math.max(dc, 1))) : add(eye, mul(F, dm));"
|
||||
);
|
||||
let zoom = format!("comp({}).layer({}).cameraOption.zoom;", q(src_comp), q(master));
|
||||
(pos, poi, zoom)
|
||||
}
|
||||
|
||||
fn stereo_p(p: &Value, cmd: &str) -> Result<StereoParams> {
|
||||
let pick = |k: &str, opts: &[&str], ids: &[&str]| -> Result<Option<u32>> {
|
||||
match p.get(k) {
|
||||
None => Ok(None),
|
||||
Some(Value::Number(n)) => Ok(n.as_u64().map(|v| (v as u32).min(opts.len() as u32 - 1))),
|
||||
Some(Value::String(s)) => {
|
||||
let n = s.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "");
|
||||
let i = ids
|
||||
.iter()
|
||||
.position(|v| v.to_ascii_lowercase() == n)
|
||||
.or_else(|| opts.iter().position(|o| o.to_ascii_lowercase().replace([' ', '-', '_', '&', '(', ')'], "") == n));
|
||||
i.map(|i| Some(i as u32)).ok_or_else(|| bad(cmd, format!("{k}: {}", ids.join("|"))))
|
||||
}
|
||||
_ => Err(bad(cmd, format!("{k}: {}", ids.join("|")))),
|
||||
}
|
||||
};
|
||||
Ok(StereoParams {
|
||||
configuration: pick("configuration", &STEREO_CONFIGURATIONS, &["stereoPair", "centerRight", "centerLeft"])?.unwrap_or(0),
|
||||
scene_depth: f_p(p, "sceneDepth").unwrap_or(3.0).clamp(0.0, 100.0),
|
||||
convergence: b_p(p, "convergence").unwrap_or(false),
|
||||
convergence_of: pick("convergenceOf", &STEREO_CONVERGENCE, &["poi", "zoom"])?.unwrap_or(0),
|
||||
z_offset: f_p(p, "zOffset").unwrap_or(0.0),
|
||||
})
|
||||
}
|
||||
|
||||
fn unique_item_name(proj: &Project, base: &str) -> String {
|
||||
let mut name = base.to_string();
|
||||
let mut k = 2;
|
||||
while proj.items.values().any(|i| i.name == name) {
|
||||
name = format!("{base} {k}");
|
||||
k += 1;
|
||||
}
|
||||
name
|
||||
}
|
||||
|
||||
/// An empty comp with the settings of `src`.
|
||||
fn sibling_comp(src: &Comp) -> Comp {
|
||||
let mut c = src.clone();
|
||||
c.layers.clear();
|
||||
c.markers.clear();
|
||||
c
|
||||
}
|
||||
|
||||
fn stereo_rig(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.stereoRig";
|
||||
let cid = comp_id(s, p)?;
|
||||
let sp = stereo_p(p, cmd)?;
|
||||
let view3d = p.get("view3d").and_then(Value::as_u64).unwrap_or(5) as u32;
|
||||
let src = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let src_name = s.project.item(cid).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let t = s.time_of(cid);
|
||||
let w = src.width as f64;
|
||||
let existing = src.active_camera(t).map(|l| l.id);
|
||||
let env = Env::of(s, cid);
|
||||
let r = s.edit("Create Stereo 3D Rig", None, |proj, st| {
|
||||
// The master camera: the comp's active camera, else a new one.
|
||||
let master_id = match existing {
|
||||
Some(id) => id,
|
||||
None => {
|
||||
let name = src.unique_layer_name("Master Camera");
|
||||
let l = build::layer(proj, &src, &name, LayerSource::Camera, (src.width, src.height), None);
|
||||
insert_layer(proj, st, cid, l)?
|
||||
}
|
||||
};
|
||||
let controls_name = proj.comp(cid).map(|c| c.unique_layer_name("Stereo 3D Controls")).unwrap_or_default();
|
||||
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
|
||||
let mut ctl = build::layer(proj, &src_now, &controls_name, LayerSource::Null, (src.width, src.height), None);
|
||||
ctl.switches.video = false;
|
||||
let spec = effectcraft_effects::find(STEREO_CONTROLS).ok_or_else(|| bad(cmd, "Stereo 3D Controls effect missing"))?;
|
||||
let mut fx = effectcraft_effects::instantiate(spec, &mut Ids(&mut proj.next_id), spec.name, [100.0, 100.0]);
|
||||
for (k, v) in [
|
||||
("configuration", KV::Enum(sp.configuration)),
|
||||
("sceneDepth", KV::Scalar(sp.scene_depth)),
|
||||
("convergence", KV::Bool(sp.convergence)),
|
||||
("convergenceOf", KV::Enum(sp.convergence_of)),
|
||||
("zOffset", KV::Scalar(sp.z_offset)),
|
||||
] {
|
||||
if let Some(pr) = fx.get_mut(k) {
|
||||
pr.value = v;
|
||||
}
|
||||
}
|
||||
if let Some(g) = ctl.props.sub_mut("effects") {
|
||||
g.children.push(fx.into());
|
||||
}
|
||||
let ctl_id = ctl.id;
|
||||
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
|
||||
let at = src_now.layers.iter().position(|l| l.id == master_id).unwrap_or(0);
|
||||
proj.comp_mut(cid).ok_or(EngineError::NoComp)?.layers.insert(at, ctl);
|
||||
// Master camera frame at the current time.
|
||||
let src_now = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
|
||||
let master = src_now.layer(master_id).cloned().ok_or(EngineError::NoComp)?;
|
||||
let ctx = env.ctx(proj, cid, &src_now);
|
||||
let (e, fwd, down) = layer_frame(&ctx, &master);
|
||||
let right = down.cross(fwd).normalize();
|
||||
let focus = focus_distance(&ctx, &master);
|
||||
let zoom = camera::layer_camera(&ctx, &master).zoom;
|
||||
let two = camera::is_two_node(&master);
|
||||
// Left and right eye comps: the source comp collapsed, seen by an eye camera.
|
||||
let mut eye_comps = vec![];
|
||||
for (eye, label) in [(0usize, "Left"), (1, "Right")] {
|
||||
let name = unique_item_name(proj, &format!("{src_name} {label} Eye"));
|
||||
let ec = sibling_comp(&src_now);
|
||||
let eid = proj.add_item(&name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(ec.clone().into()));
|
||||
let mut nest = build::layer(proj, &ec, &src_name, LayerSource::Comp { item: cid }, (src.width, src.height), Some(src.duration));
|
||||
nest.switches.three_d = true;
|
||||
nest.switches.collapse = true;
|
||||
let (pos, poi) = eye_camera(&sp, eye, w, (e, fwd, right), focus, zoom);
|
||||
let mut cam = build::layer(proj, &ec, &format!("{label} Eye Camera"), LayerSource::Camera, (src.width, src.height), None);
|
||||
set(&mut cam, "transform/position", KV::Vec3(arr(pos)));
|
||||
set(&mut cam, "transform/poi", KV::Vec3(arr(poi)));
|
||||
set(&mut cam, "cameraOptions/zoom", KV::Scalar(zoom));
|
||||
set(&mut cam, "cameraOptions/focusDistance", KV::Scalar(focus));
|
||||
let (pe, poe, ze) = eye_exprs(&src_name, &master.name, &controls_name, two, eye);
|
||||
set_expr(&mut cam, "transform/position", pe);
|
||||
set_expr(&mut cam, "transform/poi", poe);
|
||||
set_expr(&mut cam, "cameraOptions/zoom", ze);
|
||||
cam.auto_orient = AutoOrient::TowardsPointOfInterest;
|
||||
let comp = proj.comp_mut(eid).ok_or(EngineError::NoComp)?;
|
||||
comp.layers = vec![cam, nest];
|
||||
eye_comps.push(eid);
|
||||
}
|
||||
// The output comp: both eyes through 3D Glasses.
|
||||
let out_name = unique_item_name(proj, &format!("{src_name} Stereo 3D"));
|
||||
let oc = sibling_comp(&src_now);
|
||||
let oid = proj.add_item(&out_name, Label::Sandstone, proj.item(cid).and_then(|i| i.parent), ItemKind::Comp(oc.clone().into()));
|
||||
let left_name = proj.item(eye_comps[0]).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let right_name = proj.item(eye_comps[1]).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let mut left = build::layer(proj, &oc, &left_name, LayerSource::Comp { item: eye_comps[0] }, (src.width, src.height), Some(src.duration));
|
||||
let mut right = build::layer(proj, &oc, &right_name, LayerSource::Comp { item: eye_comps[1] }, (src.width, src.height), Some(src.duration));
|
||||
right.switches.video = false;
|
||||
let glasses = effectcraft_effects::find("ec.perspective.3dglasses").ok_or_else(|| bad(cmd, "3D Glasses effect missing"))?;
|
||||
let mut g = effectcraft_effects::instantiate(glasses, &mut Ids(&mut proj.next_id), glasses.name, [w, src.height as f64]);
|
||||
for (k, v) in [("leftView", KV::Layer(Some(left.id.0))), ("rightView", KV::Layer(Some(right.id.0))), ("view3d", KV::Enum(view3d.min(8)))] {
|
||||
if let Some(pr) = g.get_mut(k) {
|
||||
pr.value = v;
|
||||
}
|
||||
}
|
||||
if let Some(fxg) = left.props.sub_mut("effects") {
|
||||
fxg.children.push(g.into());
|
||||
}
|
||||
let (lid, rid) = (left.id, right.id);
|
||||
proj.comp_mut(oid).ok_or(EngineError::NoComp)?.layers = vec![left, right];
|
||||
st.project_selection = vec![oid];
|
||||
Ok(json!({
|
||||
"controls": ctl_id.0,
|
||||
"master": master_id.0,
|
||||
"leftComp": eye_comps[0].0,
|
||||
"rightComp": eye_comps[1].0,
|
||||
"stereoComp": oid.0,
|
||||
"leftLayer": lid.0,
|
||||
"rightLayer": rid.0,
|
||||
}))
|
||||
})?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- orbit null
|
||||
|
||||
/// Create Orbit Null: a 3D null at the camera's point of interest becomes the camera's parent,
|
||||
/// so rotating the null orbits the camera around what it looks at.
|
||||
fn orbit_null(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "camera.orbitNull";
|
||||
let cid = comp_id(s, p)?;
|
||||
let cam_id = camera_p(s, p, cid, cmd)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let cam = comp.layer(cam_id).cloned().ok_or(EngineError::NoComp)?;
|
||||
if cam.parent.is_some() {
|
||||
return Err(bad(cmd, format!("`{}` already has a parent", cam.name)));
|
||||
}
|
||||
let env = Env::of(s, cid);
|
||||
let ctx = env.ctx(&s.project, cid, &comp);
|
||||
let (eye, fwd, _) = layer_frame(&ctx, &cam);
|
||||
let target = eye + fwd * focus_distance(&ctx, &cam);
|
||||
let name = comp.unique_layer_name(&format!("{} Orbit Null", cam.name));
|
||||
let r = s.edit("Create Orbit Null", None, |proj, st| {
|
||||
let mut null = build::layer(proj, &comp, &name, LayerSource::Null, (comp.width, comp.height), None);
|
||||
null.switches.three_d = true;
|
||||
null.switches.video = true;
|
||||
set(&mut null, "transform/position", KV::Vec3(arr(target)));
|
||||
// World → null space (a translated, unrotated, unscaled null: p − P + anchor).
|
||||
let anchor = Vec3::from(null.props.prop("transform/anchor").map(|a| a.value.as_vec3()).unwrap_or([0.0; 3]));
|
||||
let to_null = |v: [f64; 3]| arr(Vec3::from(v) - target + anchor);
|
||||
let nid = null.id;
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let at = c.layers.iter().position(|l| l.id == cam_id).unwrap_or(0);
|
||||
c.layers.insert(at, null);
|
||||
let l = c.layer_mut(cam_id).ok_or(EngineError::NoComp)?;
|
||||
l.parent = Some(nid);
|
||||
for path in ["transform/position", "transform/poi"] {
|
||||
if let Some(pr) = l.props.prop_mut(path) {
|
||||
pr.value = KV::Vec3(to_null(pr.value.as_vec3()));
|
||||
for k in &mut pr.keys {
|
||||
k.value = KV::Vec3(to_null(k.value.as_vec3()));
|
||||
}
|
||||
}
|
||||
}
|
||||
st.selected_layers = vec![nid];
|
||||
Ok(json!({"null": nid.0, "camera": cam_id.0, "position": arr(target)}))
|
||||
})?;
|
||||
Ok(r)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- control light with camera
|
||||
|
||||
/// Control Light with Camera: the light rides on the camera (parented at the camera's eye,
|
||||
/// shining where it looks). Again (or `on: false`) releases it where it is.
|
||||
fn control_light(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "light.controlWithCamera";
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let lights: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(&comp, v)).collect(),
|
||||
Some(v) => resolve_layer(&comp, v).into_iter().collect(),
|
||||
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(Layer::is_light)).collect(),
|
||||
};
|
||||
if lights.is_empty() || lights.iter().any(|id| !comp.layer(*id).is_some_and(Layer::is_light)) {
|
||||
return Err(bad(cmd, "select a light layer"));
|
||||
}
|
||||
let cam_id = match p.get("camera") {
|
||||
Some(v) => resolve_layer(&comp, v).filter(|id| comp.layer(*id).is_some_and(Layer::is_camera)).ok_or_else(|| bad(cmd, "no such camera"))?,
|
||||
None => comp.active_camera(s.time_of(cid)).map(|l| l.id).ok_or_else(|| bad(cmd, "the composition has no camera (Layer ▸ New ▸ Camera)"))?,
|
||||
};
|
||||
let on = b_p(p, "on").unwrap_or_else(|| comp.layer(lights[0]).is_none_or(|l| l.parent != Some(cam_id)));
|
||||
let env = Env::of(s, cid);
|
||||
let ctx = env.ctx(&s.project, cid, &comp);
|
||||
// Released lights keep their current world placement.
|
||||
let frames: Vec<(LayerId, Vec3, Vec3)> = lights
|
||||
.iter()
|
||||
.filter_map(|id| comp.layer(*id))
|
||||
.map(|l| {
|
||||
let (e, f, _) = layer_frame(&ctx, l);
|
||||
(l.id, e, f)
|
||||
})
|
||||
.collect();
|
||||
let (w, h) = (comp.width as f64, comp.height as f64);
|
||||
s.edit(if on { "Control Light with Camera" } else { "Release Light from Camera" }, None, |proj, _| {
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
for (id, e, f) in &frames {
|
||||
let l = c.layer_mut(*id).ok_or(EngineError::NoComp)?;
|
||||
if on {
|
||||
l.parent = Some(cam_id);
|
||||
set(l, "transform/position", KV::Vec3([0.0; 3]));
|
||||
set(l, "transform/poi", KV::Vec3([0.0, 0.0, 1000.0]));
|
||||
set(l, "transform/orientation", KV::Vec3([0.0; 3]));
|
||||
for k in ["transform/rotationX", "transform/rotationY", "transform/rotation"] {
|
||||
set(l, k, KV::Scalar(0.0));
|
||||
}
|
||||
l.auto_orient = AutoOrient::Off;
|
||||
} else if l.parent == Some(cam_id) {
|
||||
l.parent = None;
|
||||
set(l, "transform/position", KV::Vec3(arr(*e)));
|
||||
set(l, "transform/poi", KV::Vec3(arr(*e + *f * w.max(h))));
|
||||
if l.transform().is_some_and(|t| t.get("poi").is_some()) {
|
||||
l.auto_orient = AutoOrient::TowardsPointOfInterest;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
Ok(json!({"controlled": on, "camera": cam_id.0, "lights": lights.iter().map(|l| l.0).collect::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- cameras and lights from models
|
||||
|
||||
/// Model layers a "from 3D Model" command reads: `layers`/`layer`, else the selected model layers.
|
||||
fn model_layers(s: &Session, p: &Value, comp: &Comp, cmd: &str) -> Result<Vec<LayerId>> {
|
||||
let ids: Vec<LayerId> = match p.get("layers").or_else(|| p.get("layer")) {
|
||||
Some(Value::Array(a)) => a.iter().filter_map(|v| resolve_layer(comp, v)).collect(),
|
||||
Some(v) => resolve_layer(comp, v).into_iter().collect(),
|
||||
None => s.state.selected_layers.iter().copied().filter(|id| comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))).collect(),
|
||||
};
|
||||
if ids.is_empty() || ids.iter().any(|id| !comp.layer(*id).is_some_and(|l| matches!(l.source, LayerSource::Model { .. }))) {
|
||||
return Err(bad(cmd, "select a 3D model layer"));
|
||||
}
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
/// A camera or light found in a model: (name, world eye, world forward, the model data).
|
||||
enum FromModel {
|
||||
Camera { name: String, eye: Vec3, fwd: Vec3, zoom: f64 },
|
||||
Light { name: String, eye: Vec3, fwd: Vec3, light: effectcraft_model::ModelLight },
|
||||
}
|
||||
|
||||
fn from_model(s: &mut Session, p: &Value, lights: bool) -> Result<Value> {
|
||||
let cmd = if lights { "light.fromModel" } else { "camera.fromModel" };
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let ids = model_layers(s, p, &comp, cmd)?;
|
||||
let env = Env::of(s, cid);
|
||||
let ctx = env.ctx(&s.project, cid, &comp);
|
||||
let mut found = vec![];
|
||||
for id in &ids {
|
||||
let l = comp.layer(*id).ok_or(EngineError::NoComp)?;
|
||||
let LayerSource::Model { item } = l.source else { continue };
|
||||
let Some(ItemKind::Footage(f)) = s.project.item(item).map(|i| &i.kind) else { continue };
|
||||
let Some(m) = s.footage.model(item, f) else { return Err(bad(cmd, format!("can't read the model of `{}`", l.name))) };
|
||||
let unit = l.props.sub("geometryOptions").map_or(1.0, |g| ctx.f(l, g, "unitScale", 1.0));
|
||||
let to_world = ctx.world_matrix(l) * model_to_layer(unit);
|
||||
for pl in m.placed(lights, None, 0.0) {
|
||||
let w: Mat4 = to_world * pl.world;
|
||||
let eye = w.apply(Vec3::ZERO);
|
||||
let fwd = w.apply_vec(vec3(0.0, 0.0, -1.0)).normalize();
|
||||
if lights {
|
||||
let ml = m.lights[pl.index].clone();
|
||||
found.push(FromModel::Light { name: ml.name.clone(), eye, fwd, light: ml });
|
||||
} else {
|
||||
let mc = &m.cameras[pl.index];
|
||||
let zoom = match mc.projection {
|
||||
effectcraft_model::CameraProjection::Perspective { yfov, .. } => comp.height as f64 * 0.5 / (yfov * 0.5).tan(),
|
||||
// Orthographic cameras become perspective ones framing the same height.
|
||||
effectcraft_model::CameraProjection::Orthographic { .. } => effectcraft_geom::default_camera_zoom(comp.width as f64),
|
||||
};
|
||||
found.push(FromModel::Camera { name: mc.name.clone(), eye, fwd, zoom });
|
||||
}
|
||||
}
|
||||
}
|
||||
if found.is_empty() {
|
||||
return Err(bad(cmd, if lights { "the model has no lights (KHR_lights_punctual)" } else { "the model has no cameras" }));
|
||||
}
|
||||
let made = s.edit(if lights { "Create Lights from 3D Model" } else { "Create Cameras from 3D Model" }, None, |proj, st| {
|
||||
let mut made = vec![];
|
||||
for f in &found {
|
||||
let c = proj.comp(cid).cloned().ok_or(EngineError::NoComp)?;
|
||||
let l = match f {
|
||||
FromModel::Camera { name, eye, fwd, zoom } => {
|
||||
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Camera, (c.width, c.height), None);
|
||||
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
|
||||
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * *zoom)));
|
||||
set(&mut l, "cameraOptions/zoom", KV::Scalar(*zoom));
|
||||
set(&mut l, "cameraOptions/focusDistance", KV::Scalar(*zoom));
|
||||
l
|
||||
}
|
||||
FromModel::Light { name, eye, fwd, light } => {
|
||||
let kind = match light.kind {
|
||||
effectcraft_model::ModelLightKind::Directional => LightKind::Parallel,
|
||||
effectcraft_model::ModelLightKind::Point => LightKind::Point,
|
||||
effectcraft_model::ModelLightKind::Spot { .. } => LightKind::Spot,
|
||||
};
|
||||
let mut l = build::layer(proj, &c, &c.unique_layer_name(name), LayerSource::Light { kind }, (c.width, c.height), None);
|
||||
set(&mut l, "transform/position", KV::Vec3(arr(*eye)));
|
||||
set(&mut l, "transform/poi", KV::Vec3(arr(*eye + *fwd * c.width.max(c.height) as f64)));
|
||||
let col = light.color;
|
||||
set(&mut l, "lightOptions/color", KV::Color([col[0], col[1], col[2], 1.0]));
|
||||
// Intensity: 1 unit of the file (lux / candela) = 100%.
|
||||
set(&mut l, "lightOptions/intensity", KV::Scalar((light.intensity * 100.0).clamp(0.0, 1000.0)));
|
||||
if let effectcraft_model::ModelLightKind::Spot { inner, outer } = light.kind {
|
||||
set(&mut l, "lightOptions/coneAngle", KV::Scalar((outer * 2.0).to_degrees().clamp(0.0, 180.0)));
|
||||
set(&mut l, "lightOptions/coneFeather", KV::Scalar(((1.0 - inner / outer.max(1e-9)) * 100.0).clamp(0.0, 100.0)));
|
||||
}
|
||||
if let Some(r) = light.range {
|
||||
set(&mut l, "lightOptions/falloff", KV::Enum(1));
|
||||
set(&mut l, "lightOptions/falloffDistance", KV::Scalar(r * 100.0));
|
||||
}
|
||||
l
|
||||
}
|
||||
};
|
||||
made.push(insert_layer(proj, st, cid, l)?.0);
|
||||
}
|
||||
Ok(made)
|
||||
})?;
|
||||
Ok(json!({"layers": made}))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- environment background
|
||||
|
||||
/// Create Environment Light Background Layer: a layer showing the environment light's image as
|
||||
/// the scene's backdrop (an equirectangular sky drawn through the camera).
|
||||
fn environment_background(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let cmd = "light.environmentBackground";
|
||||
let cid = comp_id(s, p)?;
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?.clone();
|
||||
let t = s.time_of(cid);
|
||||
let is_env = |l: &Layer| matches!(l.source, LayerSource::Light { kind: LightKind::Environment });
|
||||
let light = match p.get("layer") {
|
||||
Some(v) => resolve_layer(&comp, v).and_then(|id| comp.layer(id)).filter(|l| is_env(l)).ok_or_else(|| bad(cmd, "not an environment light"))?,
|
||||
None => s
|
||||
.state
|
||||
.selected_layers
|
||||
.iter()
|
||||
.filter_map(|id| comp.layer(*id))
|
||||
.find(|l| is_env(l))
|
||||
.or_else(|| comp.layers.iter().find(|l| is_env(l)))
|
||||
.ok_or_else(|| bad(cmd, "the composition has no environment light (Layer ▸ New ▸ Light ▸ Environment)"))?,
|
||||
};
|
||||
let src_layer = light
|
||||
.props
|
||||
.prop("lightOptions/source")
|
||||
.and_then(|pr| pr.value_at(light.layer_time(t)).as_layer())
|
||||
.and_then(|id| comp.layer(LayerId(id)))
|
||||
.or_else(|| comp.layers.iter().find(|l| l.environment))
|
||||
.ok_or_else(|| bad(cmd, "the environment light has no Source (or set an Environment Layer)"))?
|
||||
.clone();
|
||||
if !matches!(src_layer.source, LayerSource::Footage { .. } | LayerSource::Comp { .. } | LayerSource::Solid { .. }) {
|
||||
return Err(bad(cmd, "the environment source must be a footage, comp or solid layer"));
|
||||
}
|
||||
let light_id = light.id;
|
||||
let name = comp.unique_layer_name(&format!("{} Background", src_layer.name));
|
||||
let size = effectcraft_render::source_size(&s.project, &src_layer);
|
||||
let id = s.edit("Create Environment Light Background Layer", None, |proj, st| {
|
||||
let mut l = build::layer(proj, &comp, &name, src_layer.source.clone(), size, None);
|
||||
l.switches.three_d = true;
|
||||
l.environment_background = true;
|
||||
// The backdrop sits at the bottom of the stack.
|
||||
let c = proj.comp_mut(cid).ok_or(EngineError::NoComp)?;
|
||||
let id = l.id;
|
||||
c.layers.push(l);
|
||||
st.selected_layers = vec![id];
|
||||
Ok(id)
|
||||
})?;
|
||||
Ok(json!({"layer": id.0, "light": light_id.0, "source": src_layer.id.0}))
|
||||
}
|
||||
|
||||
pub fn specs() -> Vec<CommandSpec> {
|
||||
vec![
|
||||
cmd!(
|
||||
"camera.stereoRig",
|
||||
"Create Stereo 3D Rig",
|
||||
["Layer", "Camera"],
|
||||
None,
|
||||
"{comp?, configuration?: stereoPair|centerRight|centerLeft, sceneDepth? (% of comp width, default 3), convergence?: bool, convergenceOf?: poi|zoom, zOffset?, view3d?: 3D Glasses view index (default 5 Balanced Colored Red Blue)}",
|
||||
has_comp,
|
||||
stereo_rig
|
||||
),
|
||||
cmd!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], None, "{comp?, layer?: camera (default selected/active)}", has_comp, orbit_null),
|
||||
cmd!(
|
||||
"camera.fromModel",
|
||||
"Create Cameras from 3D Model",
|
||||
["Layer", "Camera"],
|
||||
None,
|
||||
"{comp?, layers?: 3D model layers (default selected)}",
|
||||
has_comp,
|
||||
|s, p| { from_model(s, p, false) }
|
||||
),
|
||||
cmd!(
|
||||
"light.fromModel",
|
||||
"Create Lights from 3D Model",
|
||||
["Layer", "Light"],
|
||||
None,
|
||||
"{comp?, layers?: 3D model layers (default selected)}",
|
||||
has_comp,
|
||||
|s, p| { from_model(s, p, true) }
|
||||
),
|
||||
cmd!(
|
||||
"light.controlWithCamera",
|
||||
"Control Light with Camera",
|
||||
["Layer", "Light"],
|
||||
None,
|
||||
"{comp?, layers?: lights (default selected), camera? (default active), on?: bool (default toggle)}",
|
||||
has_comp,
|
||||
control_light
|
||||
),
|
||||
cmd!(
|
||||
"light.environmentBackground",
|
||||
"Create Environment Light Background Layer",
|
||||
["Layer", "Light"],
|
||||
None,
|
||||
"{comp?, layer?: environment light (default selected / first)}",
|
||||
has_comp,
|
||||
environment_background
|
||||
),
|
||||
]
|
||||
}
|
||||
|
|
@ -25,17 +25,6 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
stub!("layer.autoTrace", "Auto-trace...", ["Layer"], "{}"),
|
||||
stub!("layer.sceneEditDetection", "Scene Edit Detection...", ["Layer"], "{}"),
|
||||
// Cameras / lights / materials / 3D views (3D milestone).
|
||||
stub!("camera.stereoRig", "Create Stereo 3D Rig", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.orbitNull", "Create Orbit Null", ["Layer", "Camera"], "{}"),
|
||||
stub!("camera.fromModel", "Create Cameras from 3D Model", ["Layer", "Camera"], "{}"),
|
||||
stub!("light.fromModel", "Create Lights from 3D Model", ["Layer", "Light"], "{}"),
|
||||
stub!("light.controlWithCamera", "Control Light with Camera", ["Layer", "Light"], "{}"),
|
||||
stub!("light.environmentBackground", "Create Environment Light Background Layer", ["Layer", "Light"], "{}"),
|
||||
stub!("view.3d.default", "Default", ["View", "Switch 3D View"], "{}"),
|
||||
stub!("view.splitLockedViewer", "Split with New Locked Viewer", ["View"], "{}"),
|
||||
// Color management.
|
||||
stub!("view.displayColorManagement", "Use Display Color Management", ["View"], "{}"),
|
||||
stub!("view.simulateOutput", "Simulate Output", [], "{profile}"),
|
||||
// Keyframes / text / tracking.
|
||||
// Workspaces and panels that don't exist yet.
|
||||
stub!("window.unavailablePanel", "Panel", [], "{panel}"),
|
||||
|
|
|
|||
|
|
@ -297,7 +297,7 @@ fn views(s: &mut Session, cid: ItemId) -> &mut Views3D {
|
|||
}
|
||||
|
||||
fn view_p(p: &Value, cmd: &str) -> Result<View3D> {
|
||||
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<Value> {
|
||||
let cid = comp_id(s, p)?;
|
||||
|
|
@ -697,11 +698,12 @@ pub fn specs() -> Vec<CommandSpec> {
|
|||
"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),
|
||||
|
|
|
|||
|
|
@ -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<i64, String> {
|
||||
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<Value> {
|
||||
|
|
@ -18,7 +48,7 @@ fn set(s: &mut Session, p: &Value) -> Result<Value> {
|
|||
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`"))?)
|
||||
|
|
|
|||
|
|
@ -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<Value> {
|
||||
|
|
@ -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<Value> {
|
||||
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<Value> {
|
||||
let cmd = "view.simulateOutput";
|
||||
let name = str_p(p, "profile").unwrap_or("none");
|
||||
let ids = SimProfile::ALL.iter().map(|p| p.id()).collect::<Vec<_>>().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<Value> {
|
||||
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<Value> {
|
||||
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<Value> {
|
||||
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<CommandSpec> {
|
||||
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),
|
||||
|
|
|
|||
|
|
@ -220,6 +220,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<commands::viewer_cmds::LockedViewer>,
|
||||
}
|
||||
|
||||
fn one_view() -> u8 {
|
||||
|
|
@ -742,6 +745,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;
|
||||
|
|
@ -766,6 +771,8 @@ mod tests_project_items;
|
|||
#[cfg(test)]
|
||||
mod tests_proxy;
|
||||
#[cfg(test)]
|
||||
mod tests_rig3d;
|
||||
#[cfg(test)]
|
||||
mod tests_settings;
|
||||
#[cfg(test)]
|
||||
mod tests_stubs;
|
||||
|
|
|
|||
|
|
@ -298,6 +298,17 @@ pub fn checked(s: &Session, command: &str, params: &Value) -> Option<bool> {
|
|||
"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 };
|
||||
|
|
@ -1007,6 +1018,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
|
||||
|
|
|
|||
|
|
@ -105,6 +105,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 {
|
||||
|
|
@ -915,6 +917,7 @@ pub fn pages() -> Vec<Page> {
|
|||
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")]), true),
|
||||
s("previews.displayProfile", "Display Color Space", Kind::Choice(&[("sRGB", "srgb"), ("Display P3", "p3")]), true),
|
||||
Button { label: "GPU Information...", command: "app.gpuInfo", params: "{}" },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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<u8> = (0..32u8)
|
||||
.map(|v| {
|
||||
let mut p = px(v, v, v);
|
||||
dc.apply(&mut p);
|
||||
p[0][0]
|
||||
})
|
||||
.collect();
|
||||
assert!(levels.len() < 20, "banding: {levels:?}");
|
||||
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "linear"})), Some(true));
|
||||
// Rec. 709 simulation keeps in-gamut colours; legacy Mac gamma 1.8 with Preserve RGB darkens.
|
||||
s.execute("view.simulateOutput", json!({"profile": "rec709"})).unwrap();
|
||||
let mut c = px(200, 100, 50);
|
||||
DisplayColor::of(&s).unwrap().apply(&mut c);
|
||||
assert!((c[0][0] as i32 - 200).abs() <= 2 && (c[0][1] as i32 - 100).abs() <= 2, "{c:?}");
|
||||
s.execute("view.simulateOutput", json!({"profile": "mac18", "preserveRgb": true})).unwrap();
|
||||
let mut m = px(128, 128, 128);
|
||||
DisplayColor::of(&s).unwrap().apply(&mut m);
|
||||
assert!(m[0][0] < 120, "gamma 1.8 numbers on an sRGB display look darker: {m:?}");
|
||||
// Custom… uses (and sets) the custom profile.
|
||||
let r = s.execute("view.simulateOutput", json!({"profile": "custom", "space": "p3", "preserveRgb": false})).unwrap();
|
||||
assert_eq!(r["profile"], "p3");
|
||||
assert_eq!(s.state.viewer.custom_simulation, Simulation { profile: SimProfile::P3, preserve_rgb: false });
|
||||
assert_eq!(crate::menus::checked(&s, "view.simulateOutput", &json!({"profile": "custom"})), Some(true));
|
||||
s.execute("view.simulateOutput", json!({"profile": "none"})).unwrap();
|
||||
assert!(DisplayColor::of(&s).is_none());
|
||||
assert!(s.execute("view.simulateOutput", json!({"profile": "cmyk"})).is_err());
|
||||
// Every menu profile parses.
|
||||
for p in SimProfile::ALL {
|
||||
assert_eq!(SimProfile::parse(p.id()), Some(p));
|
||||
}
|
||||
}
|
||||
|
||||
fn red_solid(s: &mut Session, hex: &str) {
|
||||
s.execute("layer.newSolid", json!({"color": hex, "width": 64, "height": 48})).unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn color_engine_aces_working_spaces_and_hdr() {
|
||||
let mut s = session();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
// OCIO engine: ACEScg working space and the tone-mapped display by default.
|
||||
s.execute("file.projectSettings", json!({"colorEngine": "ocio"})).unwrap();
|
||||
let st = s.project.settings.clone();
|
||||
assert_eq!((st.color_engine, st.working_space, st.hdr), (ColorEngine::Ocio, Some(ColorSpace::AcesCg), HdrMode::ToneMap));
|
||||
assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec709"})).is_err(), "not in the OCIO config");
|
||||
s.execute("file.projectSettings", json!({"workingSpace": "aces2065"})).unwrap();
|
||||
assert!(s.execute("file.projectSettings", json!({"workingSpace": "rec2100pq"})).is_err(), "not a working space");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.settings.working_space, Some(ColorSpace::AcesCg));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.settings.color_engine, ColorEngine::Adobe);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.settings.color_engine, ColorEngine::Ocio);
|
||||
// 32 bpc, a solid of 4.0 (over-range) in ACEScg: tone mapped below white, not clipped.
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32, "hdr": "toneMap"})).unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#ffffff", "width": 64, "height": 48})).unwrap();
|
||||
let lid = s.active_comp().unwrap().layers[0].id.0;
|
||||
s.execute("effect.apply", json!({"layers": [lid], "effect": "Exposure"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": lid, "path": "effects/#1/master/exposure", "value": 2.0})).unwrap();
|
||||
let mapped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
|
||||
assert!(mapped[0] > 0.5 && mapped[0] < 0.99, "tone mapped: {mapped:?}");
|
||||
s.execute("file.projectSettings", json!({"hdr": "clip"})).unwrap();
|
||||
let clipped = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
|
||||
assert!(clipped[0] > 1.1, "32 bpc keeps over-range: {clipped:?}");
|
||||
s.execute("file.projectSettings", json!({"hdr": "compand"})).unwrap();
|
||||
let comp = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
|
||||
assert!(comp[0] > 0.9 && comp[0] < 1.0, "companded: {comp:?}");
|
||||
// Rec. 2100 PQ output: HDR, no tone mapping: over-range white encodes above SDR white's 0.58.
|
||||
s.execute("file.projectSettings", json!({"outputSpace": "rec2100pq", "hdr": "toneMap"})).unwrap();
|
||||
let pq = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
|
||||
assert!(pq[0] > 0.6 && pq[0] < 0.8, "PQ: {pq:?}");
|
||||
s.execute("file.projectSettings", json!({"outputSpace": "rec2100hlg"})).unwrap();
|
||||
let hlg = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
|
||||
assert!(hlg[0] > 0.85, "HLG: {hlg:?}");
|
||||
assert!(s.execute("file.projectSettings", json!({"hdr": "glow"})).is_err());
|
||||
// Serde keeps the new settings; older files load with the defaults.
|
||||
let back: Project = serde_json::from_str(&serde_json::to_string(&*s.project).unwrap()).unwrap();
|
||||
assert_eq!(back.settings, s.project.settings);
|
||||
let mut v = serde_json::to_value(&s.project.settings).unwrap();
|
||||
for k in ["color_engine", "hdr", "output_space"] {
|
||||
v.as_object_mut().unwrap().remove(k);
|
||||
}
|
||||
let old: effectcraft_project::ProjectSettings = serde_json::from_value(v).unwrap();
|
||||
assert_eq!((old.color_engine, old.hdr, old.output_space), (ColorEngine::Adobe, HdrMode::Clip, None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn aces_working_space_matches_the_ocio_config() {
|
||||
// ACEScg in the colour module and in the OCIO effects' built-in config agree.
|
||||
let mut s = session();
|
||||
s.execute("file.projectSettings", json!({"workingSpace": "acescg"})).unwrap();
|
||||
red_solid(&mut s, "#808080");
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
// Authored ACEScg values (0.5 linear AP1 grey) → sRGB display.
|
||||
let out = s.render(cid, Tick::ZERO, RenderOpts::default()).data[0];
|
||||
let want = effectcraft_color::linear_to_srgb(128.0 / 255.0);
|
||||
assert!((out[0] - want).abs() < 0.01 && (out[1] - want).abs() < 0.01, "{out:?} vs {want}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn feet_and_frames_display_and_entry() {
|
||||
let mut s = session();
|
||||
let comp = s.active_comp().unwrap().clone();
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "feet35"})).unwrap();
|
||||
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Feet35);
|
||||
let t = comp.frame_rate.tick_of(16 * 3 + 5);
|
||||
assert_eq!(crate::commands::time::display_time(&s, &comp, t), "0003+05");
|
||||
// Typed feet+frames in the time field.
|
||||
let r = s.execute("time.set", json!({"timecode": "2+10"})).unwrap();
|
||||
assert_eq!(r["frame"], 42);
|
||||
assert_eq!(r["display"], "0002+10");
|
||||
let r = s.execute("time.set", json!({"timecode": "+6"})).unwrap();
|
||||
assert_eq!(r["frame"], 48);
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "feet16"})).unwrap();
|
||||
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "0002+15");
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "frames"})).unwrap();
|
||||
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(95)), "00095");
|
||||
s.execute("file.projectSettings", json!({"timeDisplay": "timecode"})).unwrap();
|
||||
assert_eq!(crate::commands::time::display_time(&s, &comp, comp.frame_rate.tick_of(48)), "0:00:02:00");
|
||||
assert!(s.execute("file.projectSettings", json!({"timeDisplay": "furlongs"})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.settings.time_display, TimeDisplayStyle::Frames);
|
||||
assert_eq!(serde_json::to_value(TimeDisplayStyle::Feet16).unwrap(), json!("Feet16"));
|
||||
}
|
||||
|
|
@ -487,7 +487,7 @@ fn frontend_commands_emit_events_and_stubs_are_disabled() {
|
|||
let ev = frontend_events(&mut s);
|
||||
assert_eq!(ev[0].0, "view.zoomIn");
|
||||
assert_eq!(ev[1], ("window.panel".to_string(), json!({"panel": "align"})));
|
||||
for id in ["layer.autoTrace", "camera.stereoRig", "view.displayColorManagement", "view.3d.default", "layer.create", "track.motion"] {
|
||||
for id in ["layer.autoTrace", "layer.sceneEditDetection", "layer.create", "track.motion"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
assert!(matches!(s.execute(id, json!({})), Err(EngineError::Disabled(..))), "{id}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,338 @@
|
|||
//! Camera and light rigs (M7.7): Create Stereo 3D Rig, Create Orbit Null, Control Light with
|
||||
//! Camera, Create Cameras / Lights from 3D Model and Create Environment Light Background Layer
|
||||
//! (behaviour, values, pixels, undo/redo, serde).
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_geom::{Vec3, vec3};
|
||||
use effectcraft_keyframe::Value as KV;
|
||||
use effectcraft_project::{Footage, FootageKind, GroupKind, ItemId, Layer, LayerId, LayerSource, LightKind, Project};
|
||||
use effectcraft_render::three_d::camera::layer_frame;
|
||||
use effectcraft_render::{EvalCtx, FootageSource, RenderOpts};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
use crate::{Importer, Session};
|
||||
|
||||
fn session() -> Session {
|
||||
let mut s = Session { footage: Arc::new(DiskModels), importer: Some(Arc::new(ModelImporter)), ..Default::default() };
|
||||
s.execute("comp.new", json!({"name": "Scene", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
|
||||
s
|
||||
}
|
||||
|
||||
fn cid(s: &Session) -> ItemId {
|
||||
s.active_comp_id().unwrap()
|
||||
}
|
||||
|
||||
fn layer_in(s: &Session, comp: ItemId, id: u64) -> Layer {
|
||||
s.project.comp(comp).unwrap().layer(LayerId(id)).unwrap().clone()
|
||||
}
|
||||
|
||||
fn v3(l: &Layer, path: &str) -> [f64; 3] {
|
||||
l.props.prop(path).unwrap().value.as_vec3()
|
||||
}
|
||||
|
||||
fn close3(a: [f64; 3], b: [f64; 3], tol: f64) -> bool {
|
||||
(0..3).all(|i| (a[i] - b[i]).abs() <= tol)
|
||||
}
|
||||
|
||||
/// World (eye, forward) of a layer in the active comp at time 0.
|
||||
fn frame(s: &Session, id: u64) -> (Vec3, Vec3) {
|
||||
let c = cid(s);
|
||||
let comp = s.project.comp(c).unwrap();
|
||||
let ctx = EvalCtx { project: &s.project, comp_id: c, comp, time: Tick::ZERO, expr: None, footage: None };
|
||||
let (e, f, _) = layer_frame(&ctx, comp.layer(LayerId(id)).unwrap());
|
||||
(e, f)
|
||||
}
|
||||
|
||||
fn camera(s: &mut Session) -> u64 {
|
||||
s.execute("layer.newCamera", json!({"name": "Cam", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_rig_builds_eye_comps_and_glasses() {
|
||||
let mut s = session();
|
||||
let src = cid(&s);
|
||||
let cam = camera(&mut s);
|
||||
let items_before = s.project.items.len();
|
||||
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before + 3, "left, right and stereo comps");
|
||||
assert_eq!(r["master"].as_u64(), Some(cam));
|
||||
// Controls on a hidden null in the source comp.
|
||||
let ctl = layer_in(&s, src, r["controls"].as_u64().unwrap());
|
||||
assert_eq!(ctl.name, "Stereo 3D Controls");
|
||||
assert!(!ctl.switches.video);
|
||||
let fx = ctl.effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&fx.kind, GroupKind::Effect { effect } if effect == effectcraft_effects::controls::STEREO_CONTROLS));
|
||||
assert_eq!(fx.get("sceneDepth").unwrap().value, KV::Scalar(4.0));
|
||||
// Eye comps: an eye camera (two-node, linked by expressions) and the collapsed source comp.
|
||||
let d = 0.04 * 640.0;
|
||||
for (key, name, x) in [("leftComp", "Scene Left Eye", 320.0 - d / 2.0), ("rightComp", "Scene Right Eye", 320.0 + d / 2.0)] {
|
||||
let ec = ItemId(r[key].as_u64().unwrap());
|
||||
assert_eq!(s.project.item(ec).unwrap().name, name);
|
||||
let c = s.project.comp(ec).unwrap();
|
||||
assert_eq!((c.width, c.height), (640, 360));
|
||||
assert_eq!(c.layers.len(), 2);
|
||||
let cam = &c.layers[0];
|
||||
assert!(cam.is_camera() && effectcraft_render::three_d::camera::is_two_node(cam));
|
||||
assert!(close3(v3(cam, "transform/position"), [x, 180.0, -800.0], 1e-6), "{:?}", v3(cam, "transform/position"));
|
||||
// Parallel eyes (no convergence): each looks straight ahead.
|
||||
assert!(close3(v3(cam, "transform/poi"), [x, 180.0, 0.0], 1e-6), "{:?}", v3(cam, "transform/poi"));
|
||||
assert_eq!(cam.props.prop("cameraOptions/zoom").unwrap().value, KV::Scalar(900.0));
|
||||
for p in ["transform/position", "transform/poi", "cameraOptions/zoom"] {
|
||||
let e = cam.props.prop(p).unwrap().expr.clone().unwrap();
|
||||
assert!(e.text.contains("comp(\"Scene\")") && e.text.contains("Cam"), "{p}: {}", e.text);
|
||||
}
|
||||
let nest = &c.layers[1];
|
||||
assert_eq!(nest.source, LayerSource::Comp { item: src });
|
||||
assert!(nest.switches.collapse && nest.switches.three_d);
|
||||
}
|
||||
// The stereo comp: left eye on top with 3D Glasses fed by both eyes; right eye hidden.
|
||||
let oc = ItemId(r["stereoComp"].as_u64().unwrap());
|
||||
let out = s.project.comp(oc).unwrap();
|
||||
assert_eq!(s.project.item(oc).unwrap().name, "Scene Stereo 3D");
|
||||
let (l, rr) = (r["leftLayer"].as_u64().unwrap(), r["rightLayer"].as_u64().unwrap());
|
||||
assert_eq!(out.layers[0].id.0, l);
|
||||
assert!(!out.layers[1].switches.video);
|
||||
let g = out.layers[0].effects().unwrap().groups().next().unwrap().clone();
|
||||
assert!(matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.perspective.3dglasses"));
|
||||
assert_eq!(g.get("leftView").unwrap().value, KV::Layer(Some(l)));
|
||||
assert_eq!(g.get("rightView").unwrap().value, KV::Layer(Some(rr)));
|
||||
// One undo step; redo; serde.
|
||||
let json = serde_json::to_string(&*s.project).unwrap();
|
||||
let back: Project = serde_json::from_str(&json).unwrap();
|
||||
assert_eq!(back, *s.project);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before);
|
||||
assert_eq!(s.project.comp(src).unwrap().layers.len(), 1);
|
||||
s.execute("edit.redo", json!({})).unwrap();
|
||||
assert_eq!(s.project.items.len(), items_before + 3);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stereo_rig_configurations_and_convergence() {
|
||||
let mut s = session();
|
||||
camera(&mut s);
|
||||
let r = s.execute("camera.stereoRig", json!({"configuration": "centerRight", "sceneDepth": 5, "convergence": true})).unwrap();
|
||||
let d = 0.05 * 640.0;
|
||||
let eye = |key: &str| s.project.comp(ItemId(r[key].as_u64().unwrap())).unwrap().layers[0].clone();
|
||||
let (l, rr) = (eye("leftComp"), eye("rightComp"));
|
||||
assert!(close3(v3(&l, "transform/position"), [320.0, 180.0, -800.0], 1e-6));
|
||||
assert!(close3(v3(&rr, "transform/position"), [320.0 + d, 180.0, -800.0], 1e-6));
|
||||
// Converged on the master's point of interest.
|
||||
assert!(close3(v3(&l, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
|
||||
assert!(close3(v3(&rr, "transform/poi"), [320.0, 180.0, 0.0], 1e-6));
|
||||
// A comp without a camera gets a Master Camera.
|
||||
let mut s = session();
|
||||
let r = s.execute("camera.stereoRig", json!({"configuration": "centerLeft"})).unwrap();
|
||||
let m = layer_in(&s, cid(&s), r["master"].as_u64().unwrap());
|
||||
assert_eq!(m.name, "Master Camera");
|
||||
assert!(s.execute("camera.stereoRig", json!({"configuration": "sideways"})).is_err());
|
||||
}
|
||||
|
||||
/// The eye cameras' expressions reproduce the static values (and follow the master).
|
||||
#[test]
|
||||
fn stereo_rig_maths() {
|
||||
use crate::commands::rig3d::{StereoParams, eye_camera};
|
||||
let sp = StereoParams { configuration: 0, scene_depth: 10.0, convergence: false, convergence_of: 0, z_offset: 0.0 };
|
||||
let master = (vec3(0.0, 0.0, -100.0), vec3(0.0, 0.0, 1.0), vec3(1.0, 0.0, 0.0));
|
||||
let (p0, q0) = eye_camera(&sp, 0, 1000.0, master, 100.0, 500.0);
|
||||
assert!(close3([p0.x, p0.y, p0.z], [-50.0, 0.0, -100.0], 1e-9) && close3([q0.x, q0.y, q0.z], [-50.0, 0.0, 0.0], 1e-9));
|
||||
let sp = StereoParams { convergence: true, convergence_of: 1, z_offset: 50.0, ..sp };
|
||||
let (_, q1) = eye_camera(&sp, 1, 1000.0, master, 100.0, 500.0);
|
||||
// Zoom plane (500) + Z offset (50) in front of the master eye.
|
||||
assert!(close3([q1.x, q1.y, q1.z], [0.0, 0.0, 450.0], 1e-9), "{q1:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orbit_null_parents_the_camera_at_its_point_of_interest() {
|
||||
let mut s = session();
|
||||
let cam = camera(&mut s);
|
||||
let (eye0, f0) = frame(&s, cam);
|
||||
let r = s.execute("camera.orbitNull", json!({})).unwrap();
|
||||
let nid = r["null"].as_u64().unwrap();
|
||||
let n = layer_in(&s, cid(&s), nid);
|
||||
assert!(n.is_3d() && matches!(n.source, LayerSource::Null));
|
||||
assert_eq!(n.name, "Cam Orbit Null");
|
||||
assert!(close3(v3(&n, "transform/position"), [320.0, 180.0, 0.0], 1e-9));
|
||||
let c = layer_in(&s, cid(&s), cam);
|
||||
assert_eq!(c.parent, Some(LayerId(nid)));
|
||||
// The camera stays where it was.
|
||||
let (eye1, f1) = frame(&s, cam);
|
||||
assert!((eye1 - eye0).length() < 1e-6 && (f1 - f0).length() < 1e-6, "{eye0:?} {eye1:?}");
|
||||
// Turning the null orbits the camera around the point of interest.
|
||||
s.execute("prop.set", json!({"layer": nid, "path": "transform/rotationY", "value": 90})).unwrap();
|
||||
let (eye2, f2) = frame(&s, cam);
|
||||
let poi = vec3(320.0, 180.0, 0.0);
|
||||
assert!(((eye2 - poi).length() - 800.0).abs() < 1e-6, "{eye2:?}");
|
||||
assert!((eye2.z).abs() < 1e-6 && (eye2.x - 320.0).abs() > 700.0, "{eye2:?}");
|
||||
assert!(((poi - eye2).normalize() - f2).length() < 1e-6, "still looks at the POI: {f2:?}");
|
||||
// A parented camera can't get another orbit null; undo restores the camera.
|
||||
assert!(s.execute("camera.orbitNull", json!({"layer": cam})).is_err());
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer_in(&s, cid(&s), cam).parent, None);
|
||||
assert!(s.active_comp().unwrap().layer(LayerId(nid)).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn control_light_with_camera_follows_and_releases() {
|
||||
let mut s = session();
|
||||
let cam = camera(&mut s);
|
||||
let light = s.execute("layer.newLight", json!({"kind": "Spot", "position": [0, 0, -500], "poi": [100, 100, 0]})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.select", json!({"layers": [light]})).unwrap();
|
||||
let r = s.execute("light.controlWithCamera", json!({})).unwrap();
|
||||
assert_eq!(r["controlled"], true);
|
||||
let l = layer_in(&s, cid(&s), light);
|
||||
assert_eq!(l.parent, Some(LayerId(cam)));
|
||||
let (ce, cf) = frame(&s, cam);
|
||||
let (le, lf) = frame(&s, light);
|
||||
assert!((le - ce).length() < 1e-6 && (lf - cf).length() < 1e-6, "light at the camera: {le:?} {lf:?}");
|
||||
// Moving the camera carries the light.
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [100, 180, -800]})).unwrap();
|
||||
let (le2, _) = frame(&s, light);
|
||||
assert!((le2 - vec3(100.0, 180.0, -800.0)).length() < 1e-6, "{le2:?}");
|
||||
// Again: released where it is (unparented, aimed the same way).
|
||||
let r = s.execute("light.controlWithCamera", json!({"layers": [light]})).unwrap();
|
||||
assert_eq!(r["controlled"], false);
|
||||
let l = layer_in(&s, cid(&s), light);
|
||||
assert_eq!(l.parent, None);
|
||||
let (le3, lf3) = frame(&s, light);
|
||||
assert!((le3 - le2).length() < 1e-6, "{le3:?}");
|
||||
let (_, cf2) = frame(&s, cam);
|
||||
assert!((lf3 - cf2).length() < 1e-6, "{lf3:?} {cf2:?}");
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert_eq!(layer_in(&s, cid(&s), light).parent, Some(LayerId(cam)));
|
||||
// Not a light → error.
|
||||
assert!(s.execute("light.controlWithCamera", json!({"layers": [cam]})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- from 3D model
|
||||
|
||||
struct DiskModels;
|
||||
|
||||
impl FootageSource for DiskModels {
|
||||
fn frame(&self, _: ItemId, _: &Footage, _: Tick) -> Option<Arc<effectcraft_raster::Image>> {
|
||||
None
|
||||
}
|
||||
fn model(&self, _: ItemId, f: &Footage) -> Option<Arc<effectcraft_model::Model>> {
|
||||
let bytes = std::fs::read(&f.path).ok()?;
|
||||
effectcraft_model::load(&f.path, &bytes, &|_| None).ok().map(Arc::new)
|
||||
}
|
||||
}
|
||||
|
||||
struct ModelImporter;
|
||||
|
||||
impl Importer for ModelImporter {
|
||||
fn probe(&self, path: &str) -> Result<Footage, String> {
|
||||
effectcraft_model::format_of(path).ok_or("not a model")?;
|
||||
Ok(Footage { path: path.into(), kind: FootageKind::Model, frame_rate: FrameRate::FPS_30, codec: "glTF".into(), ..Default::default() })
|
||||
}
|
||||
}
|
||||
|
||||
const RIGGED: &str = r#"{
|
||||
"asset": {"version": "2.0"},
|
||||
"scenes": [{"nodes": [0, 1, 2]}],
|
||||
"nodes": [
|
||||
{"name": "ShotCam", "camera": 0, "translation": [0, 0, 5]},
|
||||
{"name": "Key", "translation": [0, 3, 0], "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 0}}},
|
||||
{"name": "Fill", "translation": [2, 0, 0], "extensions": {"KHR_lights_punctual": {"light": 1}}}
|
||||
],
|
||||
"cameras": [{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.6, "znear": 0.1}}],
|
||||
"extensions": {"KHR_lights_punctual": {"lights": [
|
||||
{"type": "spot", "name": "Key Light", "color": [1, 0.8, 0.6], "intensity": 1.5, "spot": {"innerConeAngle": 0.25, "outerConeAngle": 0.5}},
|
||||
{"type": "point", "name": "Fill Light", "intensity": 0.5, "range": 4}
|
||||
]}}
|
||||
}"#;
|
||||
|
||||
fn rigged_model(s: &mut Session) -> u64 {
|
||||
let dir = std::env::temp_dir().join(format!("ec-rig3d-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("rigged.gltf");
|
||||
std::fs::write(&path, RIGGED).unwrap();
|
||||
let r = s.execute("layer.newModel", json!({"path": path.to_string_lossy()})).unwrap();
|
||||
r["layer"].as_u64().unwrap()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cameras_and_lights_from_model() {
|
||||
let mut s = session();
|
||||
let model = rigged_model(&mut s);
|
||||
let m = layer_in(&s, cid(&s), model);
|
||||
let unit = m.props.prop("geometryOptions/unitScale").unwrap().value.as_f64();
|
||||
let pos = v3(&m, "transform/position");
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("camera.fromModel", json!({})).unwrap();
|
||||
let cams = r["layers"].as_array().unwrap();
|
||||
assert_eq!(cams.len(), 1);
|
||||
let c = layer_in(&s, cid(&s), cams[0].as_u64().unwrap());
|
||||
assert_eq!(c.name, "Shot");
|
||||
// glTF +Z (towards the viewer) is AE −Z; the camera looks into the scene (+Z).
|
||||
let want = [pos[0], pos[1], pos[2] - 5.0 * unit];
|
||||
assert!(close3(v3(&c, "transform/position"), want, 1e-6), "{:?} vs {want:?}", v3(&c, "transform/position"));
|
||||
let zoom = 180.0 / (0.3f64).tan();
|
||||
assert!((c.props.prop("cameraOptions/zoom").unwrap().value.as_f64() - zoom).abs() < 1e-6);
|
||||
let (_, f) = frame(&s, c.id.0);
|
||||
assert!((f - vec3(0.0, 0.0, 1.0)).length() < 1e-6, "{f:?}");
|
||||
// Lights: a spot (cone 2 × outer, feather from inner/outer) and a point light.
|
||||
s.execute("layer.select", json!({"layers": [model]})).unwrap();
|
||||
let r = s.execute("light.fromModel", json!({})).unwrap();
|
||||
let ls: Vec<Layer> = r["layers"].as_array().unwrap().iter().map(|v| layer_in(&s, cid(&s), v.as_u64().unwrap())).collect();
|
||||
assert_eq!(ls.len(), 2);
|
||||
let key = ls.iter().find(|l| l.name == "Key Light").unwrap();
|
||||
assert_eq!(key.source, LayerSource::Light { kind: LightKind::Spot });
|
||||
assert!((key.props.prop("lightOptions/coneAngle").unwrap().value.as_f64() - 1.0f64.to_degrees()).abs() < 1e-6);
|
||||
assert!((key.props.prop("lightOptions/coneFeather").unwrap().value.as_f64() - 50.0).abs() < 1e-6);
|
||||
assert!((key.props.prop("lightOptions/intensity").unwrap().value.as_f64() - 150.0).abs() < 1e-9);
|
||||
// Points down in glTF (−Y) = down on screen in AE (+Y).
|
||||
let (ke, kf) = frame(&s, key.id.0);
|
||||
assert!((kf - vec3(0.0, 1.0, 0.0)).length() < 1e-5, "{kf:?}");
|
||||
assert!((ke.y - (pos[1] - 3.0 * unit)).abs() < 1e-6, "{ke:?}");
|
||||
let fill = ls.iter().find(|l| l.name == "Fill Light").unwrap();
|
||||
assert_eq!(fill.source, LayerSource::Light { kind: LightKind::Point });
|
||||
assert!((fill.props.prop("lightOptions/falloffDistance").unwrap().value.as_f64() - 400.0).abs() < 1e-9);
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layer(key.id).is_none());
|
||||
// Not a model layer → error.
|
||||
assert!(s.execute("camera.fromModel", json!({"layers": [c.id.0]})).is_err());
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- environment background
|
||||
|
||||
fn px(img: &effectcraft_raster::Image, x: u32, y: u32) -> [f32; 4] {
|
||||
img.data[(y * img.width + x) as usize]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn environment_background_draws_the_sky_through_the_camera() {
|
||||
let mut s = session();
|
||||
let scene = cid(&s);
|
||||
// An equirectangular "sky": red upper hemisphere, blue lower.
|
||||
let sky = s.execute("comp.new", json!({"name": "Sky", "width": 200, "height": 100, "frameRate": 30, "duration": 2})).unwrap()["comp"].as_u64().unwrap();
|
||||
s.execute("layer.newSolid", json!({"color": "#0000ff", "width": 200, "height": 100})).unwrap();
|
||||
let top = s.execute("layer.newSolid", json!({"color": "#ff0000", "width": 200, "height": 50})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("prop.set", json!({"layer": top, "path": "transform/position", "value": [100, 25]})).unwrap();
|
||||
s.execute("comp.open", json!({"comp": scene})).unwrap();
|
||||
let src = s.execute("layer.addItem", json!({"item": sky})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("layer.environment", json!({"layers": [src], "on": true})).unwrap();
|
||||
// No environment light yet.
|
||||
assert!(s.execute("light.environmentBackground", json!({})).is_err());
|
||||
let env = s.execute("layer.newLight", json!({"kind": "Environment"})).unwrap()["layer"].as_u64().unwrap();
|
||||
let r = s.execute("light.environmentBackground", json!({})).unwrap();
|
||||
assert_eq!(r["light"].as_u64(), Some(env));
|
||||
assert_eq!(r["source"].as_u64(), Some(src));
|
||||
let bg = layer_in(&s, scene, r["layer"].as_u64().unwrap());
|
||||
assert!(bg.environment_background && bg.is_3d());
|
||||
assert_eq!(bg.source, LayerSource::Comp { item: ItemId(sky) });
|
||||
assert_eq!(s.active_comp().unwrap().layers.last().unwrap().id, bg.id, "at the bottom");
|
||||
let img = s.render(scene, Tick::ZERO, RenderOpts::default());
|
||||
// Looking at the horizon: rays above it see red, below it blue.
|
||||
let (t, b) = (px(&img, 320, 5), px(&img, 320, 354));
|
||||
assert!(t[0] > 0.9 && t[2] < 0.1 && t[3] > 0.99, "top {t:?}");
|
||||
assert!(b[2] > 0.9 && b[0] < 0.1, "bottom {b:?}");
|
||||
// Serde keeps the flag; undo removes the layer.
|
||||
let json = serde_json::to_value(&bg).unwrap();
|
||||
assert_eq!(json["environment_background"], Value::Bool(true));
|
||||
s.execute("edit.undo", json!({})).unwrap();
|
||||
assert!(s.active_comp().unwrap().layer(bg.id).is_none());
|
||||
}
|
||||
|
|
@ -332,7 +332,7 @@ fn implemented_stubs_are_enabled_and_workspaces_are_frontend() {
|
|||
assert!(s.drain_events().iter().any(|e| matches!(e, Event::Frontend { command, .. } if command == id)));
|
||||
}
|
||||
// What remains a stub is still disabled.
|
||||
for id in ["view.displayColorManagement", "layer.autoTrace"] {
|
||||
for id in ["layer.autoTrace", "layer.sceneEditDetection"] {
|
||||
assert!(!s.is_enabled(id), "{id}");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<SimProfile> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
SimProfile::ALL
|
||||
.into_iter()
|
||||
.find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().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<Conv>)>,
|
||||
display: Option<Conv>,
|
||||
}
|
||||
|
||||
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<ColorSpace>, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option<DisplayColor> {
|
||||
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<DisplayColor> {
|
||||
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).
|
||||
|
|
|
|||
|
|
@ -526,6 +526,8 @@ Layer.prototype = {
|
|||
get opacity() { return this.transform.opacity; },
|
||||
get anchorPoint() { return this.transform.anchorPoint; },
|
||||
get text() { return this.__group('text'); },
|
||||
get cameraOption() { return this.__group('cameraOptions'); },
|
||||
get lightOption() { return this.__group('lightOptions'); },
|
||||
get marker() { return new Markers(this.__c, this.__l); },
|
||||
effect: function (k) {
|
||||
var g = __child(this.__c, this.__l, '', 'effects');
|
||||
|
|
|
|||
|
|
@ -144,11 +144,13 @@ pub fn quantize(e: &mut Enc, img: &GpuImage, levels: f32) -> GpuImage {
|
|||
}
|
||||
|
||||
fn curve_id(s: Option<effectcraft_color::ColorSpace>) -> 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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -428,7 +428,44 @@ fn hsl_to_rgb(h: f32, s: f32, l: f32) -> vec3<f32> {
|
|||
return vec3<f32>(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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -469,4 +469,46 @@ mod tests {
|
|||
s.execute("prop.setExpression", json!({"layer": a, "path": "transform/opacity", "expression": "50"})).unwrap();
|
||||
assert_eq!(value(&s, a, "transform/opacity").as_f64(), 50.0);
|
||||
}
|
||||
/// Create Stereo 3D Rig: the eye cameras' expressions follow the master camera and the
|
||||
/// Stereo 3D Controls (and agree with the values the command wrote).
|
||||
#[test]
|
||||
fn stereo_rig_expressions_follow_the_master_camera() {
|
||||
let mut s = super::session();
|
||||
s.execute("comp.new", json!({"name": "Shot", "width": 640, "height": 360, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let src = s.active_comp_id().unwrap();
|
||||
let cam = s.execute("layer.newCamera", json!({"name": "Main", "position": [320, 180, -800], "poi": [320, 180, 0], "zoom": 900})).unwrap()["layer"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let r = s.execute("camera.stereoRig", json!({"sceneDepth": 4})).unwrap();
|
||||
let ctl = r["controls"].as_u64().unwrap();
|
||||
let left = effectcraft_engine::project::ItemId(r["leftComp"].as_u64().unwrap());
|
||||
// The left eye camera's evaluated value (expressions on).
|
||||
let v3 = |s: &effectcraft_engine::Session, path: &str| {
|
||||
let comp = s.project.comp(left).unwrap();
|
||||
let layer = &comp.layers[0];
|
||||
let ctx = effectcraft_engine::render::EvalCtx {
|
||||
project: &s.project,
|
||||
comp_id: left,
|
||||
comp,
|
||||
time: Default::default(),
|
||||
expr: s.expr.as_deref(),
|
||||
footage: None,
|
||||
};
|
||||
ctx.value(layer, layer.props.prop(path).unwrap()).as_vec3()
|
||||
};
|
||||
let close = |a: [f64; 3], b: [f64; 3]| (0..3).all(|i| (a[i] - b[i]).abs() < 1e-6);
|
||||
let d = 0.04 * 640.0;
|
||||
assert!(close(v3(&s, "transform/position"), [320.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
||||
assert!(close(v3(&s, "transform/poi"), [320.0 - d / 2.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
|
||||
// Moving the master moves the eyes.
|
||||
s.execute("comp.open", json!({"comp": src.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/position", "value": [300, 180, -800]})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": cam, "path": "transform/poi", "value": [300, 180, 0]})).unwrap();
|
||||
assert!(close(v3(&s, "transform/position"), [300.0 - d / 2.0, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
||||
// Controls: Center & Left puts the left eye a full separation left; convergence toes in.
|
||||
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/configuration", "value": 2})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": ctl, "path": "effects/#1/convergence", "value": true})).unwrap();
|
||||
assert!(close(v3(&s, "transform/position"), [300.0 - d, 180.0, -800.0]), "{:?}", v3(&s, "transform/position"));
|
||||
assert!(close(v3(&s, "transform/poi"), [300.0, 180.0, 0.0]), "{:?}", v3(&s, "transform/poi"));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,8 +7,9 @@
|
|||
//! (base colour, metallic-roughness, normal, occlusion and emissive textures; alpha modes;
|
||||
//! double-sided; `KHR_materials_emissive_strength`, `KHR_materials_unlit`), embedded or
|
||||
//! external PNG/JPEG images with sampler wrap modes, skins and animations (step, linear and
|
||||
//! cubic-spline translation/rotation/scale). Sparse accessors, morph targets, cameras and
|
||||
//! lights are ignored.
|
||||
//! cubic-spline translation/rotation/scale), perspective and orthographic cameras, and
|
||||
//! `KHR_lights_punctual` lights (directional, point, spot). Sparse accessors and morph targets
|
||||
//! are ignored.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
|
|
@ -363,6 +364,8 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
|||
matrix,
|
||||
mesh: idx(n, "mesh").filter(|&m| m < meshes.len()),
|
||||
skin: idx(n, "skin"),
|
||||
camera: idx(n, "camera"),
|
||||
light: n.get("extensions").and_then(|e| e.get("KHR_lights_punctual")).and_then(|e| idx(e, "light")),
|
||||
children: arr(n, "children").iter().filter_map(Value::as_u64).map(|c| c as usize).collect(),
|
||||
});
|
||||
}
|
||||
|
|
@ -434,7 +437,54 @@ pub fn parse(bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Vec<u8>>) -> Result<
|
|||
animations.push(anim);
|
||||
}
|
||||
|
||||
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations })
|
||||
let cameras = arr(&j, "cameras").iter().enumerate().map(|(i, c)| camera(c, i)).collect();
|
||||
let lights = j
|
||||
.get("extensions")
|
||||
.and_then(|e| e.get("KHR_lights_punctual"))
|
||||
.map(|e| arr(e, "lights").iter().enumerate().map(|(i, l)| light(l, i)).collect())
|
||||
.unwrap_or_default();
|
||||
Ok(Model { meshes, nodes, roots, materials, textures, skins, animations, cameras, lights })
|
||||
}
|
||||
|
||||
fn name_or(v: &Value, d: String) -> String {
|
||||
v.get("name").and_then(Value::as_str).filter(|s| !s.is_empty()).map(str::to_string).unwrap_or(d)
|
||||
}
|
||||
|
||||
/// A glTF camera (glTF 2.0 §5.12): `perspective` {yfov, aspectRatio?, znear} or
|
||||
/// `orthographic` {xmag, ymag}.
|
||||
fn camera(c: &Value, i: usize) -> crate::ModelCamera {
|
||||
let projection = match (c.get("type").and_then(Value::as_str), c.get("orthographic")) {
|
||||
(Some("orthographic"), Some(o)) => crate::CameraProjection::Orthographic { xmag: num(o, "xmag", 1.0), ymag: num(o, "ymag", 1.0) },
|
||||
_ => {
|
||||
let p = c.get("perspective").cloned().unwrap_or(Value::Null);
|
||||
crate::CameraProjection::Perspective {
|
||||
yfov: num(&p, "yfov", 0.8).clamp(1e-3, 3.1),
|
||||
aspect: p.get("aspectRatio").and_then(Value::as_f64),
|
||||
znear: num(&p, "znear", 0.01),
|
||||
}
|
||||
}
|
||||
};
|
||||
crate::ModelCamera { name: name_or(c, format!("Camera {}", i + 1)), projection }
|
||||
}
|
||||
|
||||
/// A `KHR_lights_punctual` light: type, color, intensity, range, spot cone angles.
|
||||
fn light(l: &Value, i: usize) -> crate::ModelLight {
|
||||
let kind = match l.get("type").and_then(Value::as_str) {
|
||||
Some("directional") => crate::ModelLightKind::Directional,
|
||||
Some("spot") => {
|
||||
let s = l.get("spot").cloned().unwrap_or(Value::Null);
|
||||
let outer = num(&s, "outerConeAngle", std::f64::consts::FRAC_PI_4).clamp(1e-3, std::f64::consts::FRAC_PI_2);
|
||||
crate::ModelLightKind::Spot { inner: num(&s, "innerConeAngle", 0.0).clamp(0.0, outer), outer }
|
||||
}
|
||||
_ => crate::ModelLightKind::Point,
|
||||
};
|
||||
crate::ModelLight {
|
||||
name: name_or(l, format!("Light {}", i + 1)),
|
||||
kind,
|
||||
color: nums(l, "color", [1.0; 3]),
|
||||
intensity: num(l, "intensity", 1.0).max(0.0),
|
||||
range: l.get("range").and_then(Value::as_f64),
|
||||
}
|
||||
}
|
||||
|
||||
fn primitive(d: &Doc, p: &Value, materials: &[Material]) -> Result<Option<Primitive>> {
|
||||
|
|
|
|||
|
|
@ -295,6 +295,10 @@ pub struct Node {
|
|||
pub matrix: Option<Mat4>,
|
||||
pub mesh: Option<usize>,
|
||||
pub skin: Option<usize>,
|
||||
/// A camera of [`Model::cameras`] at this node (looking down the node's −Z, +Y up).
|
||||
pub camera: Option<usize>,
|
||||
/// A light of [`Model::lights`] at this node (`KHR_lights_punctual`, shining down −Z).
|
||||
pub light: Option<usize>,
|
||||
pub children: Vec<usize>,
|
||||
}
|
||||
|
||||
|
|
@ -308,6 +312,8 @@ impl Default for Node {
|
|||
matrix: None,
|
||||
mesh: None,
|
||||
skin: None,
|
||||
camera: None,
|
||||
light: None,
|
||||
children: vec![],
|
||||
}
|
||||
}
|
||||
|
|
@ -495,6 +501,60 @@ pub struct Model {
|
|||
pub textures: Vec<Arc<Texture>>,
|
||||
pub skins: Vec<Skin>,
|
||||
pub animations: Vec<Animation>,
|
||||
/// Cameras embedded in the file (glTF `cameras`).
|
||||
pub cameras: Vec<ModelCamera>,
|
||||
/// Punctual lights embedded in the file (glTF `KHR_lights_punctual`).
|
||||
pub lights: Vec<ModelLight>,
|
||||
}
|
||||
|
||||
/// A camera embedded in a model file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelCamera {
|
||||
pub name: String,
|
||||
pub projection: CameraProjection,
|
||||
}
|
||||
|
||||
/// glTF camera projections.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum CameraProjection {
|
||||
/// Vertical field of view (radians), optional aspect ratio, near plane.
|
||||
Perspective { yfov: f64, aspect: Option<f64>, znear: f64 },
|
||||
/// Half the orthographic view's width and height (model units).
|
||||
Orthographic { xmag: f64, ymag: f64 },
|
||||
}
|
||||
|
||||
/// Kinds of `KHR_lights_punctual` lights.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub enum ModelLightKind {
|
||||
Directional,
|
||||
Point,
|
||||
/// Inner and outer cone angles (radians, from the axis).
|
||||
Spot {
|
||||
inner: f64,
|
||||
outer: f64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A punctual light embedded in a model file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct ModelLight {
|
||||
pub name: String,
|
||||
pub kind: ModelLightKind,
|
||||
/// Linear RGB.
|
||||
pub color: [f64; 3],
|
||||
/// Candela (point, spot) or lux (directional).
|
||||
pub intensity: f64,
|
||||
pub range: Option<f64>,
|
||||
}
|
||||
|
||||
/// A camera or light placed in model space by [`Model::placed`].
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Placed {
|
||||
/// Index into [`Model::cameras`] or [`Model::lights`].
|
||||
pub index: usize,
|
||||
pub node: usize,
|
||||
/// Node → model space.
|
||||
pub world: Mat4,
|
||||
}
|
||||
|
||||
/// One mesh placed in model space by [`Model::instances`].
|
||||
|
|
@ -579,6 +639,31 @@ impl Model {
|
|||
out
|
||||
}
|
||||
|
||||
/// The scene's cameras (`lights = false`) or lights (`true`) placed at animation `clip`
|
||||
/// time `t`, in node order.
|
||||
pub fn placed(&self, lights: bool, clip: Option<usize>, t: f64) -> Vec<Placed> {
|
||||
let world = self.world_pose(&self.local_pose(clip, t));
|
||||
let mut reach = vec![false; self.nodes.len()];
|
||||
let mut stack: Vec<usize> = self.roots.clone();
|
||||
while let Some(i) = stack.pop() {
|
||||
if i >= self.nodes.len() || reach[i] {
|
||||
continue;
|
||||
}
|
||||
reach[i] = true;
|
||||
stack.extend(self.nodes[i].children.iter().copied());
|
||||
}
|
||||
let count = if lights { self.lights.len() } else { self.cameras.len() };
|
||||
self.nodes
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| reach[*i])
|
||||
.filter_map(|(i, n)| {
|
||||
let index = if lights { n.light } else { n.camera }?;
|
||||
(index < count).then_some(Placed { index, node: i, world: world[i] })
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Model-space bounds of the rest pose (None for an empty model).
|
||||
pub fn bounds(&self) -> Option<([f64; 3], [f64; 3])> {
|
||||
let mut lo = [f64::INFINITY; 3];
|
||||
|
|
|
|||
|
|
@ -316,3 +316,47 @@ fn contours_group_into_outlines_by_nesting() {
|
|||
assert_eq!(g.len(), 3);
|
||||
assert_eq!(g.iter().map(|o| o.holes.len()).sum::<usize>(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn gltf_cameras_and_punctual_lights() {
|
||||
let doc = r#"{
|
||||
"asset": {"version": "2.0"},
|
||||
"scene": 0,
|
||||
"scenes": [{"nodes": [0, 2]}],
|
||||
"nodes": [
|
||||
{"name": "Rig", "translation": [0, 0, 10], "children": [1]},
|
||||
{"name": "Cam", "camera": 0, "translation": [1, 0, 0]},
|
||||
{"name": "Sun", "rotation": [-0.7071068, 0, 0, 0.7071068], "extensions": {"KHR_lights_punctual": {"light": 1}}},
|
||||
{"name": "Orphan", "camera": 1}
|
||||
],
|
||||
"cameras": [
|
||||
{"type": "perspective", "name": "Shot", "perspective": {"yfov": 0.5, "znear": 0.1, "aspectRatio": 1.5}},
|
||||
{"type": "orthographic", "orthographic": {"xmag": 2, "ymag": 1, "znear": 0, "zfar": 10}}
|
||||
],
|
||||
"extensions": {"KHR_lights_punctual": {"lights": [
|
||||
{"type": "spot", "name": "Key", "color": [1, 0.5, 0.25], "intensity": 3, "spot": {"innerConeAngle": 0.2, "outerConeAngle": 0.4}},
|
||||
{"type": "directional", "intensity": 2}
|
||||
]}}
|
||||
}"#;
|
||||
let m = crate::gltf::parse(doc.as_bytes(), &|_| None).unwrap();
|
||||
assert_eq!(m.cameras.len(), 2);
|
||||
assert_eq!(m.cameras[0].name, "Shot");
|
||||
assert_eq!(m.cameras[0].projection, CameraProjection::Perspective { yfov: 0.5, aspect: Some(1.5), znear: 0.1 });
|
||||
assert_eq!(m.cameras[1].projection, CameraProjection::Orthographic { xmag: 2.0, ymag: 1.0 });
|
||||
assert_eq!(m.lights.len(), 2);
|
||||
assert_eq!(m.lights[0].kind, ModelLightKind::Spot { inner: 0.2, outer: 0.4 });
|
||||
assert_eq!(m.lights[0].color, [1.0, 0.5, 0.25]);
|
||||
assert_eq!(m.lights[1].kind, ModelLightKind::Directional);
|
||||
assert_eq!(m.lights[1].name, "Light 2");
|
||||
// Only nodes of the scene are placed; the camera node inherits its parent's translation.
|
||||
let cams = m.placed(false, None, 0.0);
|
||||
assert_eq!(cams.len(), 1);
|
||||
assert_eq!((cams[0].index, cams[0].node), (0, 1));
|
||||
let eye = cams[0].world.apply(vec3(0.0, 0.0, 0.0));
|
||||
assert!((eye.x - 1.0).abs() < 1e-9 && (eye.z - 10.0).abs() < 1e-9, "{eye:?}");
|
||||
let lights = m.placed(true, None, 0.0);
|
||||
assert_eq!(lights.len(), 1);
|
||||
// −90° about X: the light's −Z axis points down (−Y in glTF).
|
||||
let d = lights[0].world.apply_vec(vec3(0.0, 0.0, -1.0));
|
||||
assert!((d.y + 1.0).abs() < 1e-6, "{d:?}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -847,6 +847,7 @@ pub fn layer(project: &mut Project, comp: &Comp, name: &str, source: LayerSource
|
|||
markers: vec![],
|
||||
markers_locked: false,
|
||||
environment: false,
|
||||
environment_background: false,
|
||||
auto_orient,
|
||||
props: root,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i64> {
|
||||
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<TimeDisplayStyle> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().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<ColorSpace>,
|
||||
}
|
||||
|
||||
impl Default for ProjectSettings {
|
||||
|
|
@ -131,6 +197,9 @@ impl Default for ProjectSettings {
|
|||
frame_start: 0,
|
||||
audio_sample_rate: 48_000,
|
||||
gpu_acceleration: true,
|
||||
color_engine: ColorEngine::Adobe,
|
||||
hdr: HdrMode::Clip,
|
||||
output_space: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -552,6 +621,10 @@ pub struct Layer {
|
|||
/// reflections) of Advanced 3D comps instead of being drawn.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment: bool,
|
||||
/// Layer ▸ Light ▸ Create Environment Light Background Layer: the layer's (equirectangular)
|
||||
/// image is drawn as the 3D scene's backdrop, seen through the camera, instead of as a card.
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub environment_background: bool,
|
||||
/// The property tree (Masks, Effects, Transform, Text, Contents, Camera/Light options…).
|
||||
pub props: PropGroup,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<Conversion> {
|
||||
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<Conversion>, 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<Conversion>, 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];
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -602,6 +602,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
|
||||
}
|
||||
|
||||
|
|
@ -1067,7 +1073,7 @@ impl<'a> Renderer<'a> {
|
|||
|
||||
/// The region of interest of a top-level 2D frame: (x, y) offset of the output in output
|
||||
/// pixels and its size. `None` renders the whole comp (no ROI, or a nested comp).
|
||||
fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
pub(crate) fn roi_offset(&self) -> Option<(f64, f64, u32, u32)> {
|
||||
let r = self.opts.roi.filter(|_| self.depth == 0)?;
|
||||
let s = self.opts.scale;
|
||||
if r[2] <= 0.0 || r[3] <= 0.0 {
|
||||
|
|
@ -1307,17 +1313,29 @@ impl<'a> Renderer<'a> {
|
|||
}
|
||||
|
||||
/// Apply the track matte / preserve transparency to an isolated layer render, then blend.
|
||||
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32) {
|
||||
fn composite_iso(&self, ctx: &EvalCtx, layer: &Layer, iso: Image, canvas: &mut Image, opacity: f32) {
|
||||
self.composite_iso_with(ctx, layer, iso, canvas, opacity, None);
|
||||
}
|
||||
|
||||
/// [`Self::composite_iso`] with the track matte's pixels already drawn (`matte`, e.g. a 3D
|
||||
/// matte seen through the camera); otherwise the matte layer is placed in 2D.
|
||||
pub(crate) fn composite_iso_with(&self, ctx: &EvalCtx, layer: &Layer, mut iso: Image, canvas: &mut Image, opacity: f32, matte_img: Option<Image>) {
|
||||
let matte = layer.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != layer.id).map(|m| (m, tm.kind)));
|
||||
let preserve = layer.preserve_transparency;
|
||||
if let Some((m, kind)) = matte {
|
||||
let mut mimg = Image::new(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
&& let Some(mb) = self.layer_buf(ctx, m)
|
||||
{
|
||||
let mo = ctx.opacity(m) as f32;
|
||||
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
|
||||
}
|
||||
let mimg = match matte_img {
|
||||
Some(i) if i.width == canvas.width && i.height == canvas.height => i,
|
||||
_ => {
|
||||
let mut mimg = Image::new(canvas.width, canvas.height);
|
||||
if m.is_active_at(ctx.time)
|
||||
&& let Some(mb) = self.layer_buf(ctx, m)
|
||||
{
|
||||
let mo = ctx.opacity(m) as f32;
|
||||
self.place(ctx, m, &mb, &mut mimg, BlendMode::Normal, mo);
|
||||
}
|
||||
mimg
|
||||
}
|
||||
};
|
||||
iso.data.par_iter_mut().zip(mimg.data.par_iter()).for_each(|(p, q)| {
|
||||
let k = match kind {
|
||||
MatteKind::Alpha => q[3],
|
||||
|
|
|
|||
|
|
@ -140,25 +140,135 @@ pub fn scene_of(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) ->
|
|||
scene::build(r, ctx, run, out)
|
||||
}
|
||||
|
||||
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
let s = scene::build(r, ctx, run, (canvas.width, canvas.height));
|
||||
if s.indices.is_empty() {
|
||||
return;
|
||||
/// Rasterise one scene (GPU when available, else CPU) and resolve it.
|
||||
fn raster_resolved(r: &Renderer, s: &Scene) -> (Image, Vec<f32>) {
|
||||
let target = r.active_accel().and_then(|a| a.raster_3d(s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(s));
|
||||
resolve(&target, s.ssaa.max(1))
|
||||
}
|
||||
|
||||
/// Number of motion-blur sub-samples for a set of layers (1 = none blurred).
|
||||
fn mb_samples(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer]) -> usize {
|
||||
if !layers.iter().any(|l| scene::motion_blurred(r, ctx, l)) {
|
||||
return 1;
|
||||
}
|
||||
let target = r.active_accel().and_then(|a| a.raster_3d(&s)).filter(|t| t.width == s.width && t.height == s.height).unwrap_or_else(|| raster::render(&s));
|
||||
let (mut img, depth) = resolve(&target, s.ssaa.max(1));
|
||||
if r.opts.draft { 4 } else { ctx.comp.motion_blur_samples.clamp(2, 64) as usize }
|
||||
}
|
||||
|
||||
/// Render `layers` as one Advanced 3D scene: premultiplied pixels in the canvas's encoding and
|
||||
/// the camera depth of each pixel (∞ where nothing was drawn). With motion blur the scene is
|
||||
/// rendered at sub-samples spread over the comp's shutter (angle and phase) and averaged; the
|
||||
/// depth is the nearest over the samples.
|
||||
pub(crate) fn render_layers(r: &Renderer, ctx: &EvalCtx, layers: &[&Layer], out: (u32, u32)) -> Option<(Image, Vec<f32>)> {
|
||||
let n = mb_samples(r, ctx, layers);
|
||||
let (mut img, depth, linear) = if n <= 1 {
|
||||
let s = scene::build(r, ctx, layers, out);
|
||||
if s.indices.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let (img, depth) = raster_resolved(r, &s);
|
||||
(img, depth, s.linear_io)
|
||||
} else {
|
||||
let fd = ctx.comp.frame_duration().seconds();
|
||||
let (angle, phase) = (ctx.comp.shutter_angle / 360.0, ctx.comp.shutter_phase / 360.0);
|
||||
let mut acc = Image::new(out.0, out.1);
|
||||
let mut depth = vec![f32::INFINITY; (out.0 * out.1) as usize];
|
||||
let mut linear = false;
|
||||
let mut any = false;
|
||||
let k = 1.0 / n as f32;
|
||||
for i in 0..n {
|
||||
let f = phase + angle * i as f64 / (n - 1) as f64;
|
||||
let sub = ctx.at(ctx.time + effectcraft_time::Tick::from_seconds_f64(f * fd));
|
||||
let s = scene::build_at(r, ctx, Some(&sub), layers, out);
|
||||
linear = s.linear_io;
|
||||
if s.indices.is_empty() {
|
||||
continue;
|
||||
}
|
||||
any = true;
|
||||
let (img, d) = raster_resolved(r, &s);
|
||||
acc.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(a, p)| {
|
||||
for c in 0..4 {
|
||||
a[c] += p[c] * k;
|
||||
}
|
||||
});
|
||||
depth.par_iter_mut().zip(d.par_iter()).for_each(|(a, b)| *a = a.min(*b));
|
||||
}
|
||||
if !any {
|
||||
return None;
|
||||
}
|
||||
(acc, depth, linear)
|
||||
};
|
||||
let cam = camera_for(r, ctx);
|
||||
if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) {
|
||||
img = depth_of_field(&img, &depth, &dof, r.opts.scale);
|
||||
}
|
||||
if !s.linear_io {
|
||||
if !linear {
|
||||
encode(&mut img);
|
||||
}
|
||||
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
|
||||
let k = 1.0 - s[3];
|
||||
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
|
||||
});
|
||||
Some((img, depth))
|
||||
}
|
||||
|
||||
/// Layers whose blend mode, track matte or Preserve Transparency needs the 2D compositing path.
|
||||
fn needs_2d_composite(ctx: &EvalCtx, l: &Layer) -> bool {
|
||||
l.blend_mode != effectcraft_color::BlendMode::Normal
|
||||
|| l.preserve_transparency
|
||||
|| l.track_matte.is_some_and(|tm| tm.layer != l.id && ctx.comp.layer(tm.layer).is_some())
|
||||
}
|
||||
|
||||
/// Draw a run of 3D layers with the Advanced 3D renderer into `canvas`.
|
||||
///
|
||||
/// Layers with a blend mode, a track matte or Preserve Transparency can't blend inside the
|
||||
/// depth-buffered scene: they are rendered on their own (lit by the same lights, through the
|
||||
/// same camera), hidden where the rest of the run is nearer, then composited through the 2D
|
||||
/// path (matte, Preserve Transparency, blend mode) from the farthest to the nearest.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
let out = (canvas.width, canvas.height);
|
||||
let (special, plain): (Vec<&Layer>, Vec<&Layer>) = run.iter().copied().partition(|l| needs_2d_composite(ctx, l));
|
||||
let main = render_layers(r, ctx, &plain, out);
|
||||
if let Some((img, _)) = &main {
|
||||
canvas.data.par_iter_mut().zip(img.data.par_iter()).for_each(|(d, s)| {
|
||||
let k = 1.0 - s[3];
|
||||
*d = [s[0] + d[0] * k, s[1] + d[1] * k, s[2] + d[2] * k, s[3] + d[3] * k];
|
||||
});
|
||||
}
|
||||
if special.is_empty() {
|
||||
return;
|
||||
}
|
||||
let cam = camera_for(r, ctx);
|
||||
let mut order: Vec<(f64, &Layer)> = special
|
||||
.into_iter()
|
||||
.map(|l| {
|
||||
let anchor = l.transform().map_or([0.0; 3], |tr| ctx.v3(l, tr, "anchor", [0.0; 3]));
|
||||
(cam.depth(ctx.world_matrix(l).apply(effectcraft_geom::Vec3::from(anchor))), l)
|
||||
})
|
||||
.collect();
|
||||
order.sort_by(|a, b| b.0.total_cmp(&a.0));
|
||||
for (_, l) in order {
|
||||
let Some((mut iso, depth)) = render_layers(r, ctx, &[l], out) else { continue };
|
||||
if let Some((m, md)) = &main {
|
||||
iso.data.par_iter_mut().zip(depth.par_iter()).zip(m.data.par_iter().zip(md.par_iter())).for_each(|((p, z), (q, mz))| {
|
||||
if *mz < *z - 0.01 * z.abs().max(1.0) {
|
||||
let k = 1.0 - q[3].clamp(0.0, 1.0);
|
||||
for c in p.iter_mut() {
|
||||
*c *= k;
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
// A 3D matte layer is drawn through the camera too.
|
||||
let matte3d = l.track_matte.and_then(|tm| ctx.comp.layer(tm.layer).filter(|m| m.id != l.id && m.is_3d())).map(|m| {
|
||||
let mut solo = m.clone();
|
||||
solo.track_matte = None;
|
||||
solo.blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
solo.preserve_transparency = false;
|
||||
solo.switches.video = true;
|
||||
if m.is_active_at(ctx.time) {
|
||||
render_layers(r, ctx, &[&solo], out).map(|(i, _)| i).unwrap_or_else(|| Image::new(out.0, out.1))
|
||||
} else {
|
||||
Image::new(out.0, out.1)
|
||||
}
|
||||
});
|
||||
r.composite_iso_with(ctx, l, iso, canvas, 1.0, matte3d);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
|
|||
|
|
@ -806,6 +806,20 @@ fn ssaa_for(r: &Renderer, out: (u32, u32)) -> u32 {
|
|||
|
||||
/// Build the scene of a run of 3D layers.
|
||||
pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)) -> Scene {
|
||||
build_at(r, ctx, None, run, out)
|
||||
}
|
||||
|
||||
/// Whether a layer's motion is blurred (Motion Blur switch, comp setting and render option).
|
||||
pub(crate) fn motion_blurred(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> bool {
|
||||
r.opts.motion_blur && ctx.comp.enable_motion_blur && layer.switches.motion_blur
|
||||
}
|
||||
|
||||
/// [`build`] at a motion-blur sub-sample: layers with Motion Blur on, the camera and the lights
|
||||
/// are evaluated at `sub`'s time; the other layers stay at `ctx`'s.
|
||||
pub(crate) fn build_at(r: &Renderer, ctx: &EvalCtx, sub: Option<&EvalCtx>, run: &[&Layer], out: (u32, u32)) -> Scene {
|
||||
let base = ctx;
|
||||
let at = sub.unwrap_or(ctx);
|
||||
let ctx = at;
|
||||
let cam = camera_for(r, ctx);
|
||||
let lin = r.pipe.linear || r.pipe.linear_blend;
|
||||
let ssaa = ssaa_for(r, out);
|
||||
|
|
@ -816,6 +830,7 @@ pub(crate) fn build(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], out: (u32, u32)
|
|||
cam,
|
||||
};
|
||||
for layer in run {
|
||||
let ctx = if sub.is_some() && motion_blurred(r, base, layer) { at } else { base };
|
||||
if layer.environment || layer.switches.adjustment || ctx.opacity(layer) <= 0.0 || !layer.has_video() {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -312,3 +312,116 @@ fn extruded_text_renders_as_a_mesh() {
|
|||
let img = render(&p, cid);
|
||||
assert!(img.data.iter().filter(|q| q[3] > 0.5).count() > 200);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- motion blur, blend modes, mattes
|
||||
|
||||
fn solid(p: &mut Project, color: [f32; 3], w: u32, h: u32) -> ItemId {
|
||||
p.add_item("S", Default::default(), None, ItemKind::Solid(effectcraft_project::Solid { color, width: w, height: h, pixel_aspect: 1.0 }))
|
||||
}
|
||||
|
||||
/// Pixels of row `y` whose alpha is partial (motion-blur smear or anti-aliasing).
|
||||
fn partial(img: &crate::Image, y: u32) -> usize {
|
||||
(0..img.width).filter(|x| (0.05..0.95).contains(&img.data[(y * img.width + x) as usize][3])).count()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn motion_blur_smears_moving_meshes() {
|
||||
let (mut p, cid) = project(160, 120);
|
||||
add(&mut p, cid, LayerSource::Primitive { kind: PrimitiveKind::Cube }, |l| {
|
||||
set(l, "geometryOptions/width", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/height", Value::Scalar(30.0));
|
||||
set(l, "geometryOptions/depth", Value::Scalar(30.0));
|
||||
// 60 px per frame to the right.
|
||||
let pr = l.props.prop_mut("transform/position").unwrap();
|
||||
pr.keys = vec![
|
||||
effectcraft_keyframe::Keyframe::new(Tick::ZERO, Value::Vec3([50.0, 60.0, 0.0])),
|
||||
effectcraft_keyframe::Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([50.0 + 30.0 * 60.0, 60.0, 0.0])),
|
||||
];
|
||||
});
|
||||
let sharp = render(&p, cid);
|
||||
let hard = partial(&sharp, 60);
|
||||
assert!(hard <= 4, "no blur without the switch: {hard}");
|
||||
p.comp_mut(cid).unwrap().layers[0].switches.motion_blur = true;
|
||||
let blurred = render(&p, cid);
|
||||
let smear = partial(&blurred, 60);
|
||||
// A 180° shutter smears over half a frame of motion (≈ 30 px).
|
||||
assert!(smear > 20, "smeared: {smear}");
|
||||
// Coverage is conserved: the summed alpha along the row stays about the cube's width.
|
||||
let sum = |im: &crate::Image| (0..160).map(|x| im.data[60 * 160 + x][3]).sum::<f32>();
|
||||
assert!((sum(&blurred) - sum(&sharp)).abs() < 4.0, "{} vs {}", sum(&blurred), sum(&sharp));
|
||||
// The comp's motion blur switch and the render option turn it off.
|
||||
p.comp_mut(cid).unwrap().enable_motion_blur = false;
|
||||
assert!(partial(&render(&p, cid), 60) <= 4);
|
||||
p.comp_mut(cid).unwrap().enable_motion_blur = true;
|
||||
let r = Renderer::new(&p, &NoFootage, RenderOpts { motion_blur: false, ..Default::default() });
|
||||
assert!(partial(&r.comp_frame(cid, Tick::ZERO), 60) <= 4);
|
||||
// Shutter angle 0 has no exposure time: no smear.
|
||||
p.comp_mut(cid).unwrap().shutter_angle = 0.0;
|
||||
assert!(partial(&render(&p, cid), 60) <= 4);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn blend_modes_and_occlusion_in_advanced_3d() {
|
||||
let (mut p, cid) = project(120, 90);
|
||||
let red = solid(&mut p, [1.0, 0.0, 0.0], 120, 90);
|
||||
let gray = solid(&mut p, [0.5, 0.5, 0.5], 60, 40);
|
||||
let blue = solid(&mut p, [0.0, 0.0, 1.0], 20, 20);
|
||||
// Back: a red card; middle: a gray Multiply card; front: a small blue card on the left.
|
||||
add(&mut p, cid, LayerSource::Solid { item: red }, |l| {
|
||||
l.switches.three_d = true;
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Solid { item: gray }, |l| {
|
||||
l.switches.three_d = true;
|
||||
l.blend_mode = effectcraft_color::BlendMode::Multiply;
|
||||
set(l, "transform/position", Value::Vec3([60.0, 45.0, -20.0]));
|
||||
set(l, "transform/anchor", Value::Vec3([30.0, 20.0, 0.0]));
|
||||
});
|
||||
add(&mut p, cid, LayerSource::Solid { item: blue }, |l| {
|
||||
l.switches.three_d = true;
|
||||
set(l, "transform/position", Value::Vec3([45.0, 45.0, -40.0]));
|
||||
set(l, "transform/anchor", Value::Vec3([10.0, 10.0, 0.0]));
|
||||
});
|
||||
let img = render(&p, cid);
|
||||
let at = |x: usize, y: usize| img.data[y * 120 + x];
|
||||
// Multiply over red: half red, no gray.
|
||||
let c = at(70, 45);
|
||||
assert!((c[0] - 0.5).abs() < 0.02 && c[1] < 0.02 && c[2] < 0.02 && c[3] > 0.99, "multiplied {c:?}");
|
||||
// Outside the gray card: plain red.
|
||||
let r = at(115, 5);
|
||||
assert!(r[0] > 0.98 && r[1] < 0.02, "{r:?}");
|
||||
// The nearer blue card hides the Multiply card behind it.
|
||||
let b = at(40, 45);
|
||||
assert!(b[2] > 0.98 && b[0] < 0.02, "occluded {b:?}");
|
||||
// Normal mode for comparison: gray covers the red.
|
||||
p.comp_mut(cid).unwrap().layers[1].blend_mode = effectcraft_color::BlendMode::Normal;
|
||||
let n = render(&p, cid).data[45 * 120 + 70];
|
||||
assert!((n[0] - 0.5).abs() < 0.02 && (n[1] - 0.5).abs() < 0.02, "{n:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn track_mattes_in_advanced_3d() {
|
||||
let (mut p, cid) = project(120, 90);
|
||||
let white = solid(&mut p, [1.0, 1.0, 1.0], 120, 90);
|
||||
let small = solid(&mut p, [1.0, 1.0, 1.0], 40, 30);
|
||||
// Layer order (top first): the matte, then the matted layer.
|
||||
add(&mut p, cid, LayerSource::Solid { item: white }, |l| l.switches.three_d = true);
|
||||
add(&mut p, cid, LayerSource::Solid { item: small }, |l| {
|
||||
l.switches.three_d = true;
|
||||
l.switches.video = false;
|
||||
// A 3D matte is seen through the camera.
|
||||
set(l, "transform/position", Value::Vec3([30.0, 30.0, 0.0]));
|
||||
set(l, "transform/anchor", Value::Vec3([20.0, 15.0, 0.0]));
|
||||
});
|
||||
let matte = p.comp(cid).unwrap().layers[0].id;
|
||||
p.comp_mut(cid).unwrap().layers[1].track_matte = Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::Alpha });
|
||||
let img = render(&p, cid);
|
||||
let inside = img.data[30 * 120 + 30];
|
||||
let outside = img.data[70 * 120 + 100];
|
||||
assert!(inside[3] > 0.99 && inside[0] > 0.99, "{inside:?}");
|
||||
assert!(outside[3] < 0.01, "{outside:?}");
|
||||
// An inverted alpha matte keeps the outside instead.
|
||||
p.comp_mut(cid).unwrap().layers[1].track_matte =
|
||||
Some(effectcraft_project::TrackMatte { layer: matte, kind: effectcraft_project::MatteKind::AlphaInverted });
|
||||
let img = render(&p, cid);
|
||||
assert!(img.data[30 * 120 + 30][3] < 0.01 && img.data[70 * 120 + 100][3] > 0.99);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -340,11 +340,15 @@ pub enum View3D {
|
|||
Custom1,
|
||||
Custom2,
|
||||
Custom3,
|
||||
/// The comp's default camera (50 mm, looking at the comp centre), ignoring camera layers;
|
||||
/// orbitable like the custom views.
|
||||
Default,
|
||||
}
|
||||
|
||||
impl View3D {
|
||||
pub const ALL: [View3D; 10] = [
|
||||
pub const ALL: [View3D; 11] = [
|
||||
View3D::ActiveCamera,
|
||||
View3D::Default,
|
||||
View3D::Front,
|
||||
View3D::Left,
|
||||
View3D::Top,
|
||||
|
|
@ -367,6 +371,7 @@ impl View3D {
|
|||
View3D::Custom1 => "Custom View 1",
|
||||
View3D::Custom2 => "Custom View 2",
|
||||
View3D::Custom3 => "Custom View 3",
|
||||
View3D::Default => "Default",
|
||||
}
|
||||
}
|
||||
/// Stable id used by commands (`activeCamera`, `front`, `custom1`…).
|
||||
|
|
@ -382,6 +387,7 @@ impl View3D {
|
|||
View3D::Custom1 => "custom1",
|
||||
View3D::Custom2 => "custom2",
|
||||
View3D::Custom3 => "custom3",
|
||||
View3D::Default => "default",
|
||||
}
|
||||
}
|
||||
pub fn from_id(s: &str) -> Option<View3D> {
|
||||
|
|
@ -433,6 +439,7 @@ pub fn default_view_cam(v: View3D, w: f64, h: f64) -> ViewCam {
|
|||
View3D::Custom1 => custom(30.0, -20.0),
|
||||
View3D::Custom2 => custom(0.0, -35.0),
|
||||
View3D::Custom3 => custom(-30.0, -20.0),
|
||||
View3D::Default => ViewCam { eye: arr(vec3(w / 2.0, h / 2.0, -zoom)), poi: arr(c), down: [0.0, 1.0, 0.0], zoom, ortho: false },
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -338,6 +338,15 @@ fn scene_lights(ctx: &EvalCtx) -> Vec<LightState> {
|
|||
|
||||
/// Draw a run of consecutive 3D layers (bottom-to-top order) into `canvas`.
|
||||
pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut Image) {
|
||||
// Environment background layers: an infinitely distant sky behind the rest of the run.
|
||||
if run.iter().any(|l| l.environment_background) {
|
||||
for l in run.iter().filter(|l| l.environment_background) {
|
||||
draw_sky(r, ctx, l, canvas);
|
||||
}
|
||||
let rest: Vec<&Layer> = run.iter().copied().filter(|l| !l.environment_background).collect();
|
||||
draw_run(r, ctx, &rest, canvas);
|
||||
return;
|
||||
}
|
||||
// 3D adjustment layers act on everything below them in the stack (like 2D ones): split.
|
||||
if let Some(k) = run.iter().position(|l| l.switches.adjustment) {
|
||||
draw_run(r, ctx, &run[..k], canvas);
|
||||
|
|
@ -793,3 +802,49 @@ pub(crate) fn aux_pass(r: &Renderer, ctx: &EvalCtx) -> effectcraft_raster::AuxCh
|
|||
aux.manifests = vec![("CryptoObject".into(), manifest_obj), ("CryptoMaterial".into(), manifest_mat)];
|
||||
aux
|
||||
}
|
||||
|
||||
/// Rotation (radians) of the comp's first Environment light, which also turns its backdrop.
|
||||
pub(crate) fn environment_rotation(ctx: &EvalCtx) -> f32 {
|
||||
ctx.comp
|
||||
.layers
|
||||
.iter()
|
||||
.find(|l| matches!(l.source, effectcraft_project::LayerSource::Light { kind: LightKind::Environment }) && l.is_active_at(ctx.time))
|
||||
.and_then(|l| l.props.sub("lightOptions").map(|g| ctx.f(l, g, "rotation", 0.0)))
|
||||
.unwrap_or(0.0)
|
||||
.to_radians() as f32
|
||||
}
|
||||
|
||||
/// Draw an Environment Light Background layer: its (equirectangular) image looked up by each
|
||||
/// pixel's view direction through the camera, as an infinitely distant backdrop.
|
||||
pub(crate) fn draw_sky(r: &Renderer, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) {
|
||||
let Some(buf) = r.blend_layer_buf(ctx, layer) else { return };
|
||||
let img = &buf.img;
|
||||
if img.width == 0 || img.height == 0 {
|
||||
return;
|
||||
}
|
||||
let op = (ctx.opacity(layer) as f32 * r.opacity_mul()).clamp(0.0, 1.0);
|
||||
if op <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let cam = camera_for(r, ctx);
|
||||
let (fwd, right, down) = (cam.forward(), cam.right(), cam.down());
|
||||
let s = r.opts.scale.max(1e-9);
|
||||
let (cw, ch) = (ctx.comp.width as f64, ctx.comp.height as f64);
|
||||
let (ox, oy) = r.roi_offset().map_or((0.0, 0.0), |(x, y, _, _)| (x, y));
|
||||
let rot = environment_rotation(ctx);
|
||||
let (iw, ih) = (img.width as f64, img.height as f64);
|
||||
canvas.rows_mut().for_each(|(y, row)| {
|
||||
let cy = (y as f64 + 0.5 + oy) / s;
|
||||
for (x, d) in row.iter_mut().enumerate() {
|
||||
let cx = (x as f64 + 0.5 + ox) / s;
|
||||
let dir = if cam.ortho { fwd } else { (fwd * cam.zoom + right * (cx - cw / 2.0) + down * (cy - ch / 2.0)).normalize() };
|
||||
let (u, v) = super::adv::shade::equirect_uv([dir.x as f32, dir.y as f32, dir.z as f32], rot);
|
||||
// Wrap horizontally across the seam.
|
||||
let sx = (u as f64 * iw).clamp(0.5, iw - 0.5);
|
||||
let sy = (v as f64 * ih).clamp(0.5, ih - 0.5);
|
||||
let p = img.sample_bilinear(sx, sy);
|
||||
let k = 1.0 - p[3] * op;
|
||||
*d = [p[0] * op + d[0] * k, p[1] * op + d[1] * k, p[2] * op + d[2] * k, p[3] * op + d[3] * k];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<i64, ParseError> {
|
||||
let s = input.trim();
|
||||
let bad = || ParseError(format!("not feet+frames: `{input}`"));
|
||||
let num = |t: &str| -> Result<i64, ParseError> {
|
||||
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::<i64>().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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -42,7 +42,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;
|
||||
|
||||
|
|
@ -60,14 +60,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) {
|
||||
|
|
|
|||
|
|
@ -312,6 +312,70 @@ fn aux_views(
|
|||
main
|
||||
}
|
||||
|
||||
/// View ▸ Split with New Locked Viewer: the locked viewer on the left of the Composition panel
|
||||
/// (its comp and 3D view stay put whatever comp is active). Returns the main viewer's rectangle.
|
||||
fn locked_pane(app: &mut EffectcraftApp, ui: &mut egui::Ui, full: Rect, bg: Color32) -> Rect {
|
||||
let Some(lv) = app.session.state.locked_viewer else { return full };
|
||||
let Some(comp) = app.session.project.comp(lv.comp).cloned() else { return full };
|
||||
let name = app.session.project.item(lv.comp).map(|i| i.name.clone()).unwrap_or_default();
|
||||
let gap = 2.0;
|
||||
let mid = full.center().x;
|
||||
let r = Rect::from_min_max(full.min, pos2(mid - gap / 2.0, full.max.y));
|
||||
let main = Rect::from_min_max(pos2(mid + gap / 2.0, full.min.y), full.max);
|
||||
let ctx = ui.ctx().clone();
|
||||
let p = ui.painter().clone();
|
||||
p.rect_filled(Rect::from_min_max(pos2(r.max.x, full.min.y), pos2(main.min.x, full.max.y)), 0.0, Color32::from_black_alpha(200));
|
||||
p.rect_filled(r, 0.0, bg);
|
||||
let (cw, ch) = (comp.width as f32, comp.height as f32);
|
||||
let fit = ((r.width() - 20.0) / cw).min((r.height() - 44.0) / ch).max(0.01);
|
||||
let cr = Rect::from_center_size(r.center(), vec2(cw * fit, ch * fit));
|
||||
let scale = (fit * ctx.pixels_per_point()).min(1.0) as f64;
|
||||
let t = app.session.time_of(lv.comp);
|
||||
let cam = (lv.view != View3D::ActiveCamera && comp.has_3d())
|
||||
.then(|| app.session.state.views3d.get(&lv.comp).cloned().unwrap_or_default().cam(lv.view, comp.width as f64, comp.height as f64).state());
|
||||
let dc = effectcraft_engine::viewer::DisplayColor::of(&app.session);
|
||||
let key = {
|
||||
use std::hash::{Hash, Hasher};
|
||||
let mut h = std::collections::hash_map::DefaultHasher::new();
|
||||
(app.session.revision, lv.comp.0, t.0, scale.to_bits(), format!("{cam:?}{}", dc.is_some())).hash(&mut h);
|
||||
h.finish()
|
||||
};
|
||||
let id = egui::Id::new("viewer-locked");
|
||||
let cached: Option<(u64, egui::TextureHandle)> = ctx.data(|d| d.get_temp(id));
|
||||
let tex = match cached {
|
||||
Some((k, tex)) if k == key => tex,
|
||||
_ => {
|
||||
let opts = effectcraft_engine::render::RenderOpts { scale, view: cam, ..Default::default() };
|
||||
let img = app.session.render(lv.comp, t, opts);
|
||||
let mut ci = crate::frames::to_color_image(&img);
|
||||
if let Some(dc) = dc {
|
||||
let mut px: Vec<[u8; 4]> = ci.pixels.iter().map(|c| c.to_array()).collect();
|
||||
dc.apply(&mut px);
|
||||
ci = egui::ColorImage::new(ci.size, px.into_iter().map(|a| Color32::from_rgba_premultiplied(a[0], a[1], a[2], a[3])).collect());
|
||||
}
|
||||
let tex = ctx.load_texture("viewer-locked", ci, egui::TextureOptions::LINEAR);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
tex
|
||||
}
|
||||
};
|
||||
let b = comp.background;
|
||||
p.rect_filled(cr, 0.0, Color32::from_rgb((b[0] * 255.0) as u8, (b[1] * 255.0) as u8, (b[2] * 255.0) as u8));
|
||||
p.image(tex.id(), cr, Rect::from_min_max(pos2(0.0, 0.0), pos2(1.0, 1.0)), Color32::WHITE);
|
||||
p.rect_stroke(cr, 0.0, Stroke::new(1.0, Color32::from_black_alpha(160)), StrokeKind::Outside);
|
||||
let label = format!("Locked: {name} \u{2014} {}", if comp.has_3d() { lv.view.label() } else { "Active Camera" });
|
||||
p.text(r.left_top() + vec2(8.0, 8.0), Align2::LEFT_TOP, &label, Tokens::ui(11.0), Color32::from_white_alpha(210));
|
||||
app.auto.add("viewer.locked", r, &label);
|
||||
// Close the locked viewer.
|
||||
let close = Rect::from_min_size(pos2(r.max.x - 26.0, r.min.y + 4.0), vec2(20.0, 20.0));
|
||||
let resp = ui.interact(close, egui::Id::new("viewer-locked-close"), Sense::click());
|
||||
p.text(close.center(), Align2::CENTER_CENTER, "\u{00d7}", Tokens::ui(14.0), if resp.hovered() { Color32::WHITE } else { Color32::from_white_alpha(170) });
|
||||
app.auto.add("viewer.locked.close", close, "Close Locked Viewer");
|
||||
if resp.clicked() {
|
||||
let _ = app.session.execute("view.closeLockedViewer", serde_json::json!({}));
|
||||
}
|
||||
main
|
||||
}
|
||||
|
||||
pub(crate) fn checker(p: &egui::Painter, r: Rect) {
|
||||
let s = 10.0;
|
||||
p.rect_filled(r, 0.0, Color32::from_gray(0xcc));
|
||||
|
|
@ -380,6 +444,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
|
|||
let pasteboard = app.ui.viewer.pasteboard.map(|[r, g, b]| Color32::from_rgb(r, g, b)).unwrap_or(t.pasteboard);
|
||||
// View ▸ Switch View Layout: extra views (Top / Front / Right) beside the main view, which
|
||||
// keeps the overlays and the interaction.
|
||||
let full = locked_pane(app, ui, full, pasteboard);
|
||||
let area = aux_views(app, ui, &comp, cid, full, pasteboard);
|
||||
p.rect_filled(area, 0.0, pasteboard);
|
||||
// View ▸ Show Rulers takes a strip on the top and left.
|
||||
|
|
|
|||
|
|
@ -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<vw::DisplayColor>) -> 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())
|
||||
}
|
||||
|
|
@ -341,14 +346,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");
|
||||
|
|
@ -356,7 +365,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
|
||||
|
|
@ -400,14 +409,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), zoom_opts);
|
||||
let tex = ctx.load_texture("viewer-display", transformed(&src, opts.channel, opts.colorized, opts.exposure, dc), zoom_opts);
|
||||
ctx.data_mut(|d| d.insert_temp(id, (key, tex.clone())));
|
||||
tex
|
||||
}
|
||||
|
|
|
|||
|
|
@ -101,9 +101,15 @@ transparent ones are sorted back to front and blended. The software rasteriser
|
|||
`Accelerator::raster_3d` with a wgpu render pipeline (`advanced3d.wgsl`) that repeats the
|
||||
shading step for step, and tests compare the two. Resolve, depth of field (a gather blur by
|
||||
each pixel's circle of confusion from the camera's Depth of Field settings) and the conversion
|
||||
back to the working encoding run on the CPU for both. Not yet: motion blur, track mattes and
|
||||
blend modes inside an Advanced 3D run, collapsed 3D precomps (drawn flattened), morph targets,
|
||||
extruded strokes.
|
||||
back to the working encoding run on the CPU for both. **Motion blur** renders the scene at
|
||||
sub-samples spread over the comp's shutter (angle, phase, samples per frame) — layers with the
|
||||
Motion Blur switch, the camera and the lights at the sub-sample time, other layers at the frame
|
||||
time — and averages them. Layers with a **blend mode, a track matte or Preserve Transparency**
|
||||
are rendered on their own through the same camera and lights, hidden where the rest of the run
|
||||
is nearer, and composited from the farthest to the nearest through the 2D path (a 3D matte is
|
||||
itself drawn through the camera). **Environment Light Background** layers (Layer ▸ Light) draw
|
||||
their equirectangular image behind the run, looked up by each pixel's view direction. Not yet:
|
||||
collapsed 3D precomps (drawn flattened), morph targets, extruded strokes.
|
||||
|
||||
A **layer cache** keeps each layer's finished pixels (source, masks and effects) keyed by a hash of
|
||||
its evaluated inputs, excluding the transform. Static and transform-only layers render once;
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
<!-- Generated from the effect registry (crates/effects/src/catalog.rs); do not edit by hand.
|
||||
Regenerate: UPDATE_DOCS=1 cargo test -p effectcraft-effects --lib effects_doc_is_current -->
|
||||
|
||||
EffectCraft ships 303 effects with 2434 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
EffectCraft ships 304 effects with 2446 parameters in total, grouped into the same categories as the Effects & Presets panel. Every effect is our own implementation, written from public behaviour descriptions and standard image-processing literature. Parameter names, order, popup options, units, defaults and ranges follow the reference application so that projects, expressions and muscle memory carry over.
|
||||
|
||||
- **GPU**: 24 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 303 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 261 implemented in full, 42 partial (what is missing is listed).
|
||||
- **GPU**: 25 effects also run on the GPU compositor with identical results.
|
||||
- **32**: 304 effects process 32-bit float (HDR, overbright) pixels without clamping.
|
||||
- **Status**: 262 implemented in full, 42 partial (what is missing is listed).
|
||||
|
||||
Effects are addressed by id (`ec.<category>.<name>`) or display name in commands, scripts and the control channel, and every parameter by its id (`effects/#1/blurriness`).
|
||||
|
||||
|
|
@ -113,8 +113,8 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Levels (Individual Controls) | `ec.color.levelsic` | 27 | | 32 | Implemented |
|
||||
| Lumetri Color | `ec.color.lumetri` | 38 | | 32 | Partial: no HSL Secondary, hue/saturation curves, HDR mode or look files |
|
||||
| OCIO CDL Transform | `ec.color.ociocdl` | 12 | | 32 | Implemented |
|
||||
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 4 | | 32 | Implemented |
|
||||
| OCIO Display Transform | `ec.color.ociodisplay` | 5 | | 32 | Implemented |
|
||||
| OCIO Color Space Transform | `ec.color.ociocolorspace` | 7 | | 32 | Implemented |
|
||||
| OCIO Display Transform | `ec.color.ociodisplay` | 9 | | 32 | Implemented |
|
||||
| OCIO File Transform | `ec.color.ociofile` | 4 | | 32 | Implemented |
|
||||
| OCIO Look Transform | `ec.color.ociolook` | 6 | | 32 | Implemented |
|
||||
| PS Arbitrary Map | `ec.color.psarbitrarymap` | 2 | | 32 | Implemented |
|
||||
|
|
@ -182,6 +182,7 @@ Effects are addressed by id (`ec.<category>.<name>`) or display name in commands
|
|||
| Layer Control | `ec.control.layer` | 1 | GPU | 32 | Implemented |
|
||||
| Point Control | `ec.control.point` | 1 | GPU | 32 | Implemented |
|
||||
| Slider Control | `ec.control.slider` | 1 | GPU | 32 | Implemented |
|
||||
| Stereo 3D Controls | `ec.control.stereo3d` | 5 | GPU | 32 | Implemented |
|
||||
|
||||
## Generate
|
||||
|
||||
|
|
|
|||
|
|
@ -22,8 +22,7 @@ Tracking 88%, Effects 85%, Web 65%.
|
|||
What is left, in priority order: the web app's depth (threads, storage, audio, non-blocking
|
||||
renders); stroke taper/wave and multi-segment dashes; variable mask feather points; camera iris/bokeh and focus-link commands; GPU
|
||||
rendering of 3D runs and adjustment layers; Render Queue field render, crop/resize and templates;
|
||||
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; Advanced 3D motion blur
|
||||
and blend modes; a few viewer and colour-management menu items; OpenType features and variable
|
||||
approximated effects (Subspace Warp, Key Cleaner) and Liquify's viewer brush; OpenType features and variable
|
||||
font axes; AI/EPS/PDF import; more codecs; 59 preferences not yet wired; ScriptUI;
|
||||
Lumetri Scopes, Footage, Media Browser and Metadata panels; Content-Aware Fill; face tracking; and
|
||||
the "better than After Effects" items (a plug-in API, branching history, GPU particles).
|
||||
|
|
@ -120,10 +119,10 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.)
|
|||
| Animation | 67% | 10.0 | puppet, Wiggler/Smoother/Motion Sketch (motion-path handles and the graph editor transform box landed in M5.8; keyframe colour labels and Select Keyframe Label Group, Graph Editor snapping to markers / layer ends in M13.5) |
|
||||
| Text | ≈ 91% | 0.8 | no OpenType feature panel, no extruded strokes, variable-axis animation changes outlines but not advances (vertical Roman / Tate-Chu-Yoko, forced LTR paragraphs, caret on animated and path text, Variable Font Axes and Lottie style runs landed in M13.5; extruded, bevelled text in M7.6; per-character styles, paragraph settings, on-canvas editing and the `sourceText` style API in M9.9–M9.10) |
|
||||
| Web | 85% | 1.0 | viewer frames and Roto Brush propagation still on the page's thread; GPU effects in the browser (browser storage, Web Audio, Web Worker renders/analyses, WebGPU viewer and offline install landed in M15.2) |
|
||||
| 3D | 78% | 9.3 | multi-view layouts, Advanced 3D motion blur and blend modes (and iris shapes in Advanced 3D's depth of field), cameras/lights from models; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
|
||||
| 3D | 85% | 6.5 | multi-view layouts, iris shapes in Advanced 3D's depth of field, collapsed 3D precomps in Advanced 3D, the Extended Viewer; stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6 |
|
||||
| Effects | ≈ 80% | 4.5 | GPU versions of more effects and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) |
|
||||
| Interface | 74% | 5.0 | a richer Learn area (Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) |
|
||||
| Project | ≈ 60% | 6.5 | 8/16/32-bit pipeline and colour management, auto-save, folder moves (proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Project | ≈ 68% | 5.5 | auto-save, folder moves, OCIO displays beyond the built-in tone map (Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3) |
|
||||
| Masks & roto | 74% | 5.0 | Roto Brush's learned (3.0) segmentation model (variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) |
|
||||
| Preview | 62% | 5.0 | GPU 3D and adjustment layers (persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) |
|
||||
| Tracking | ≈ 85% | 2.5 | face tracking, Subspace Warp's mesh warp, lens distortion in the camera solve (Rolling Shutter Repair landed in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker landed in M6.x / M12.5 / M12.6) |
|
||||
|
|
@ -143,7 +142,7 @@ Mocha and Cineware are third-party and not counted.
|
|||
|---|---|---|
|
||||
| 3D Channel | 3D Channel Extract, Cryptomatte, Depth Matte, Depth of Field, EXtractoR, Fog 3D, ID Matte, IDentifier | Read auxiliary channels: multi-layer OpenEXR (any `layer.channel`, Cryptomatte manifests from the header) or the compositor's own depth / layer-ID / normal / UV / Cryptomatte pass for precomps of 3D comps. |
|
||||
| Immersive Video | VR Blur, VR Chromatic Aberrations, VR Color Gradients, VR Converter, VR De-Noise, VR Digital Glitch, VR Fractal Noise, VR Glow, VR Plane to Sphere, VR Rotate Sphere, VR Sharpen, VR Sphere to Plane | Seam-aware equirectangular processing (wrap-around, pole continuation, latitude-scaled filters), mono / over-under / side-by-side layouts; VR Converter between equirectangular, cube maps (4:3, 3:2, 6:1), sphere map and 2D. |
|
||||
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are not read yet. |
|
||||
| Color Correction | OCIO CDL / Color Space / Display / File / Look Transform, Color Stabilizer | Built-in minimal OCIO-style config (ACES2065-1, ACEScg, ACEScct, sRGB, Rec.709, Rec.2020, Display P3, linear variants, XYZ) from published matrices and transfer functions; `.cube` / `.3dl` / `.csp` LUTs and ASC `.cc` / `.ccc` / `.cdl`. Custom `.ocio` config files are read (Configuration ▸ Custom: colorspaces, roles, displays/views; Matrix, File, Exponent, Log, LogAffine, CDL, Range and Group transforms). |
|
||||
| Distort | CC Flo Motion, Liquify, Rolling Shutter Repair | Liquify strokes are effect data replayed into a displacement mesh (`liquify.stroke` / `liquify.clear` commands; no interactive viewer brush yet). Rolling Shutter Repair uses the KLT tracker. |
|
||||
| Blur & Sharpen | Camera-Shake Deblur, CC Radial Blur | Deblur substitutes aligned patches from sharper neighbouring frames. |
|
||||
| Audio | Compressor, Distortion, Gate | Applied in the mixdown like the other audio effects. |
|
||||
|
|
|
|||
|
|
@ -85,6 +85,7 @@ Commands (CLI, MCP, control channel):
|
|||
around the current time)
|
||||
- `previews.showInternalWireframes`: outlines of the layers inside a selected collapsed precomp
|
||||
- `previews.zoomQuality`: Faster = nearest-neighbour viewer scaling, More Accurate = bilinear
|
||||
- `previews.displayProfile`: the monitor's colour space for View ▸ Use Display Color Management
|
||||
- `appearance.useLabelColorForTabs`: Composition / Timeline tabs show the comp's label colour,
|
||||
Effect Controls / Properties the layer's
|
||||
- `appearance.cycleMaskColors`: new masks cycle through the mask colours (off: all the first)
|
||||
|
|
|
|||
Loading…
Reference in New Issue