M2.4: bit depth + linear blending + working space

8/16 bpc projects clamp and quantise layer pixels after source/masks and
every effect, and the comp after every layer (16 bpc = 0..32768); 32 bpc
keeps over-range values. Blend modes without HDR support clamp their inputs
in 32 bpc. Working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices
derived from the published primaries), footage colour profiles from stream
metadata or Interpret Footage, Linearize Working Space and Blend Colors Using
1.0 Gamma. Layer cache keys include the colour settings. Project panel depth
button: click cycles, Alt-click opens Project Settings.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-02 10:03:36 -04:00
parent ea1bd0e0c4
commit 029c0da5e5
24 changed files with 957 additions and 42 deletions

View File

@ -157,6 +157,37 @@ impl BlendMode {
matches!(self, BlendMode::StencilAlpha | BlendMode::StencilLuma | BlendMode::SilhouetteAlpha | BlendMode::SilhouetteLuma)
}
/// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp
/// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects
/// (in 8/16 bpc everything is already in range).
pub fn supports_hdr(self) -> bool {
matches!(
self,
BlendMode::Normal
| BlendMode::Dissolve
| BlendMode::DancingDissolve
| BlendMode::Darken
| BlendMode::Multiply
| BlendMode::LinearBurn
| BlendMode::DarkerColor
| BlendMode::Add
| BlendMode::Lighten
| BlendMode::Screen
| BlendMode::LinearDodge
| BlendMode::LighterColor
| BlendMode::Difference
| BlendMode::ClassicDifference
| BlendMode::Subtract
| BlendMode::Divide
| BlendMode::StencilAlpha
| BlendMode::StencilLuma
| BlendMode::SilhouetteAlpha
| BlendMode::SilhouetteLuma
| BlendMode::AlphaAdd
| BlendMode::LuminescentPremul
)
}
pub fn next(self) -> BlendMode {
let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0);
BlendMode::ALL[(i + 1) % BlendMode::ALL.len()]
@ -364,8 +395,12 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) ->
if da <= 0.0 {
return src;
}
let cs = [src[0] / sa, src[1] / sa, src[2] / sa];
let cb = [dst[0] / da, dst[1] / da, dst[2] / da];
let mut cs = [src[0] / sa, src[1] / sa, src[2] / sa];
let mut cb = [dst[0] / da, dst[1] / da, dst[2] / da];
if !mode.supports_hdr() {
cs = cs.map(|v| v.clamp(0.0, 1.0));
cb = cb.map(|v| v.clamp(0.0, 1.0));
}
let b = non_separable(mode, cb, cs);
let ao = sa + da - sa * da;
let mut o = [0.0f32; 4];
@ -432,6 +467,16 @@ mod tests {
assert_eq!(blend_pixel(BlendMode::SilhouetteAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), d);
}
#[test]
fn hdr_modes() {
// Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs.
let d = [2.0, 2.0, 2.0, 1.0];
let s = [0.5, 0.5, 0.5, 1.0];
assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6);
assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6);
assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0);
}
#[test]
fn names_roundtrip() {
for m in BlendMode::ALL {

View File

@ -6,9 +6,11 @@
//! colour and composited with Porter-Duff source-over on premultiplied pixels.
pub mod blend;
pub mod space;
pub use blend::{BlendMode, blend_pixel};
use serde::{Deserialize, Serialize};
pub use space::{ColorSpace, Conversion};
/// Straight (un-premultiplied) RGBA, 0..1 nominal (32 bpc may exceed).
#[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)]

273
crates/color/src/space.rs Normal file
View File

@ -0,0 +1,273 @@
//! RGB colour spaces for colour management: the project working space, footage interpretation
//! and the display.
//!
//! Each space is defined by its published primaries and white point and its transfer function:
//!
//! * **sRGB IEC 61966-2-1** — Rec. 709 primaries, D65, the piecewise sRGB curve.
//! * **Rec. 709** — ITU-R BT.709-6 primaries, D65, the BT.1886 display EOTF (gamma 2.4).
//! * **Rec. 2020** — ITU-R BT.2020-2 primaries, D65, BT.1886 (gamma 2.4).
//! * **Display P3** — SMPTE EG 432-1 (DCI-P3) primaries with D65 and the sRGB curve.
//!
//! RGB → XYZ matrices are derived from the primaries (the standard construction from chromaticity
//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), so
//! conversions between any two spaces are exact up to `f64` rounding. Transfer functions extend
//! to over-range and negative values (32 bpc) by mirroring around zero.
use serde::{Deserialize, Serialize};
/// An RGB colour space (all D65).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub enum ColorSpace {
/// sRGB IEC 61966-2-1.
Srgb,
/// HDTV Rec. 709 (gamma 2.4).
Rec709,
/// UHDTV Rec. 2020 (gamma 2.4).
Rec2020,
/// Display P3.
DisplayP3,
}
/// CIE xy chromaticity of D65.
const D65: [f64; 2] = [0.3127, 0.3290];
impl ColorSpace {
pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3];
/// Display name (as in Project Settings ▸ Color ▸ Working Space).
pub fn label(self) -> &'static str {
match self {
ColorSpace::Srgb => "sRGB IEC61966-2.1",
ColorSpace::Rec709 => "HDTV (Rec. 709)",
ColorSpace::Rec2020 => "Rec. 2020",
ColorSpace::DisplayP3 => "Display P3",
}
}
/// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`).
pub fn id(self) -> &'static str {
match self {
ColorSpace::Srgb => "srgb",
ColorSpace::Rec709 => "rec709",
ColorSpace::Rec2020 => "rec2020",
ColorSpace::DisplayP3 => "p3",
}
}
/// Parse an id or label (case-insensitive, loose).
pub fn parse(s: &str) -> Option<ColorSpace> {
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
match k.as_str() {
"srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb),
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
"rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020),
"p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3),
_ => None,
}
}
/// Red, green and blue primaries (CIE xy).
pub fn primaries(self) -> [[f64; 2]; 3] {
match self {
ColorSpace::Srgb | ColorSpace::Rec709 => [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]],
ColorSpace::Rec2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
}
}
/// Linear RGB → CIE XYZ (Y of white = 1).
pub fn to_xyz(self) -> [[f64; 3]; 3] {
rgb_to_xyz(self.primaries(), D65)
}
/// Encoded value → linear light.
pub fn decode(self, v: f32) -> f32 {
let a = v.abs();
let l = match self {
ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::srgb_to_linear(a),
ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(2.4),
};
l.copysign(v)
}
/// Linear light → encoded value.
pub fn encode(self, v: f32) -> f32 {
let a = v.abs();
let e = match self {
ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::linear_to_srgb(a),
ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(1.0 / 2.4),
};
e.copysign(v)
}
}
/// The standard RGB → XYZ matrix from primaries and white point.
pub fn rgb_to_xyz(p: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] {
let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]];
let (r, g, b) = (xyz(p[0]), xyz(p[1]), xyz(p[2]));
let m = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
let w = xyz(white);
let s = mul_vec(&invert(&m), w);
let mut o = m;
for row in &mut o {
for j in 0..3 {
row[j] *= s[j];
}
}
o
}
/// 3×3 matrix inverse (the matrices here are always well conditioned).
pub fn invert(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
let det = m[0][0] * c(1, 1, 2, 2) - m[0][1] * c(1, 0, 2, 2) + m[0][2] * c(1, 0, 2, 1);
let k = 1.0 / det;
[
[c(1, 1, 2, 2) * k, -c(0, 1, 2, 2) * k, c(0, 1, 1, 2) * k],
[-c(1, 0, 2, 2) * k, c(0, 0, 2, 2) * k, -c(0, 0, 1, 2) * k],
[c(1, 0, 2, 1) * k, -c(0, 0, 2, 1) * k, c(0, 0, 1, 1) * k],
]
}
pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
let mut o = [[0.0; 3]; 3];
for i in 0..3 {
for j in 0..3 {
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
}
}
o
}
pub fn mul_vec(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
[0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2])
}
/// A colour conversion between two spaces, each either encoded (with its transfer curve) or
/// linear. Applied to straight (un-premultiplied) RGB.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Conversion {
/// Decode the input with this space's curve first.
pub decode: Option<ColorSpace>,
/// Linear RGB matrix (None = identity primaries).
pub matrix: Option<[[f32; 3]; 3]>,
/// Encode the output with this space's curve last.
pub encode: Option<ColorSpace>,
}
impl Conversion {
/// From `from` (linear when `from_linear`) to `to` (linear when `to_linear`). `None` when
/// the conversion is the identity.
pub fn new(from: ColorSpace, from_linear: bool, to: ColorSpace, to_linear: bool) -> Option<Conversion> {
let same_primaries = from.primaries() == to.primaries();
let same_curve = from_linear == to_linear && (from_linear || curve_eq(from, to));
if same_primaries && same_curve {
return None;
}
let matrix = (!same_primaries).then(|| {
let m = mul(&invert(&to.to_xyz()), &from.to_xyz());
m.map(|r| r.map(|v| v as f32))
});
// Same primaries, both encoded with different curves (sRGB ↔ Rec. 709), or a matrix in
// between: go through linear.
let decode = (!from_linear).then_some(from);
let encode = (!to_linear).then_some(to);
Some(Conversion { decode, matrix, encode })
}
/// Decode only (encoded → linear in the same space).
pub fn linearize(space: ColorSpace) -> Conversion {
Conversion { decode: Some(space), matrix: None, encode: None }
}
/// Encode only (linear → encoded in the same space).
pub fn delinearize(space: ColorSpace) -> Conversion {
Conversion { decode: None, matrix: None, encode: Some(space) }
}
#[inline]
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
let mut c = c;
if let Some(s) = self.decode {
c = c.map(|v| s.decode(v));
}
if let Some(m) = &self.matrix {
c = [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]);
}
if let Some(s) = self.encode {
c = c.map(|v| s.encode(v));
}
c
}
}
fn curve_eq(a: ColorSpace, b: ColorSpace) -> bool {
let srgb = |s: ColorSpace| matches!(s, ColorSpace::Srgb | ColorSpace::DisplayP3);
srgb(a) == srgb(b)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn srgb_matrix_matches_iec() {
// IEC 61966-2-1 (rounded to 4 places).
let m = ColorSpace::Srgb.to_xyz();
let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
for i in 0..3 {
for j in 0..3 {
assert!((m[i][j] - want[i][j]).abs() < 2e-4, "{i}{j}: {} vs {}", m[i][j], want[i][j]);
}
}
// Rec. 2020 luminance row (BT.2020: 0.2627, 0.6780, 0.0593).
let y = ColorSpace::Rec2020.to_xyz()[1];
assert!((y[0] - 0.2627).abs() < 1e-4 && (y[1] - 0.6780).abs() < 1e-4 && (y[2] - 0.0593).abs() < 1e-4, "{y:?}");
}
#[test]
fn round_trips() {
let samples = [[0.2f32, 0.5, 0.8], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.03, 0.9, 0.4], [1.5, 0.2, 2.0]];
for a in ColorSpace::ALL {
for b in ColorSpace::ALL {
for (al, bl) in [(false, false), (true, false), (false, true), (true, true)] {
let fwd = Conversion::new(a, al, b, bl);
let back = Conversion::new(b, bl, a, al);
assert_eq!(fwd.is_none(), back.is_none());
for c in samples {
let mid = fwd.map(|f| f.apply(c)).unwrap_or(c);
let o = back.map(|f| f.apply(mid)).unwrap_or(mid);
for i in 0..3 {
// Pure power curves amplify f32 rounding near zero.
assert!((o[i] - c[i]).abs() < 2e-3, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}");
}
}
}
}
}
}
#[test]
fn white_is_preserved_and_gamut_grows() {
let c = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, false).unwrap();
let w = c.apply([1.0, 1.0, 1.0]);
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}");
// Pure sRGB red sits inside Rec. 2020: less saturated there.
let r = c.apply([1.0, 0.0, 0.0]);
assert!(r[0] < 1.0 && r[1] > 0.0 && r[2] > 0.0, "{r:?}");
// sRGB ↔ Rec. 709 share primaries: only the curve changes.
let k = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec709, false).unwrap();
assert!(k.matrix.is_none());
assert!(Conversion::new(ColorSpace::Srgb, true, ColorSpace::Rec709, true).is_none());
}
#[test]
fn transfer_extends_over_range() {
let s = ColorSpace::Srgb;
assert!((s.decode(s.encode(4.0)) - 4.0).abs() < 1e-4);
assert!((s.decode(s.encode(-0.25)) + 0.25).abs() < 1e-5);
assert!((ColorSpace::Rec709.decode(0.5) - 0.5f32.powf(2.4)).abs() < 1e-6);
assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb));
assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3));
}
}

View File

@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(l) = p.get("linearize").and_then(Value::as_bool) {
proj.settings.linearize = l;
}
if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) {
proj.settings.blend_linear = l;
}
if let Some(w) = p.get("workingSpace") {
proj.settings.working_space = match w.as_str() {
None | Some("none" | "None" | "") => None,
Some(n) => Some(
effectcraft_project::ColorSpace::parse(n)
.ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?,
),
};
}
if let Some(t) = str_p(p, "timeDisplay") {
proj.settings.time_display =
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
@ -172,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Project Settings...",
["File"],
Some("Cmd+Alt+Shift+K"),
"{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}",
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}",
always,
project_settings
),

View File

@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage {
codec: "Placeholder".into(),
missing: true,
sequence: vec![],
color_profile: None,
}
}
@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
};
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
let par = f_p(p, "pixelAspect");
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
let profile = match str_p(p, "colorProfile") {
Some("auto" | "none" | "") => Some(None),
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
Some(cs) => Some(Some(cs)),
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
},
None => None,
};
s.edit("Interpret Footage", None, |proj, _| {
for i in &items {
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
if let Some(x) = par {
f.pixel_aspect = x;
}
if let Some(c) = profile {
f.color_profile = c;
}
}
}
Ok(())
@ -634,7 +647,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}",
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
has_footage_selection,
interpret
),

View File

@ -518,6 +518,8 @@ mod tests;
#[cfg(test)]
mod tests_3d;
#[cfg(test)]
mod tests_fidelity;
#[cfg(test)]
mod tests_effects;
#[cfg(test)]
mod tests_lottie;

View File

@ -0,0 +1,28 @@
//! Render-fidelity commands: project colour settings, footage colour profile, slip edit.
use effectcraft_project::{BitDepth, ColorSpace};
use serde_json::json;
use crate::Session;
#[test]
fn project_colour_settings_are_undoable() {
let mut s = Session::default();
s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "rec2020", "linearize": true, "blendLinear": true})).unwrap();
let st = &s.project.settings;
assert_eq!((st.bit_depth, st.working_space, st.linearize, st.blend_linear), (BitDepth::Bpc32, Some(ColorSpace::Rec2020), true, true));
s.execute("file.projectSettings", json!({"workingSpace": "Display P3"})).unwrap();
assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3));
s.execute("file.projectSettings", json!({"workingSpace": "none"})).unwrap();
assert_eq!(s.project.settings.working_space, None);
assert!(s.execute("file.projectSettings", json!({"workingSpace": "acescg-ish"})).is_err());
assert!(s.undo());
assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3));
assert!(s.undo() && s.undo());
assert_eq!(s.project.settings.bit_depth, BitDepth::Bpc8);
assert!(!s.project.settings.blend_linear);
// The Project panel's depth button cycles 8 → 16 → 32 → 8.
for want in ["16 bpc", "32 bpc", "8 bpc"] {
assert_eq!(s.execute("file.cycleBitDepth", json!({})).unwrap(), json!(want));
}
}

View File

@ -256,6 +256,7 @@ impl Importer for MockImporter {
codec: "mock".into(),
missing: false,
sequence: vec![],
color_profile: None,
})
}
}

View File

@ -100,6 +100,7 @@ fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, Layer
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
};
let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();
@ -408,6 +409,7 @@ fn analyze_1080p_single_point_speed() {
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
};
let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage));
let comp = p.comp(cid).unwrap().clone();

View File

@ -170,6 +170,7 @@ pub fn import(
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
};
let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot));
cx.items.push(item);

View File

@ -591,6 +591,7 @@ fn images_embed_as_base64_and_come_back() {
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
}),
);
let c = comp(&p, cid);

View File

@ -71,6 +71,20 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -
codec,
missing: false,
sequence: Vec::new(),
color_profile: v.and_then(|v| profile_of(&v.color)),
}
}
/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or
/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve.
fn profile_of(c: &filmcraft_color::ColorInfo) -> Option<effectcraft_project::ColorSpace> {
use effectcraft_project::ColorSpace;
use filmcraft_color::{Primaries, Transfer};
match (c.primaries, c.transfer) {
(Primaries::Bt2020, _) => Some(ColorSpace::Rec2020),
(Primaries::P3D65, _) => Some(ColorSpace::DisplayP3),
(_, Transfer::Bt709) => Some(ColorSpace::Rec709),
_ => None,
}
}
@ -99,6 +113,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha:
codec,
missing: false,
sequence: Vec::new(),
color_profile: None,
}
}

View File

@ -13,6 +13,7 @@ pub mod tracking;
use std::collections::BTreeMap;
use std::sync::Arc;
pub use effectcraft_color::ColorSpace;
use effectcraft_color::{BlendMode, Label};
use effectcraft_time::{FrameRate, Tick};
pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path};
@ -70,6 +71,22 @@ impl BitDepth {
pub fn is_float(self) -> bool {
self == BitDepth::Bpc32
}
/// Quantisation levels per channel of the integer depths (8 bpc: 255; 16 bpc: 32768, After
/// Effects' "15 + 1" bit range), `None` for 32 bpc float.
pub fn levels(self) -> Option<f32> {
match self {
BitDepth::Bpc8 => Some(255.0),
BitDepth::Bpc16 => Some(32768.0),
BitDepth::Bpc32 => None,
}
}
pub fn bits(self) -> u32 {
match self {
BitDepth::Bpc8 => 8,
BitDepth::Bpc16 => 16,
BitDepth::Bpc32 => 32,
}
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -82,8 +99,16 @@ pub enum TimeDisplayStyle {
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct ProjectSettings {
pub bit_depth: BitDepth,
/// Blend in linear light instead of the sRGB-encoded working space.
/// Linearize Working Space: the working space uses linear light (1.0 gamma) for everything
/// (sources, effects, blending). Needs a working space.
pub linearize: bool,
/// Working Space (colour management). `None` = no colour management.
#[serde(default)]
pub working_space: Option<ColorSpace>,
/// Blend Colors Using 1.0 Gamma: layers blend in linear light (sources and effects stay in
/// the working space's encoding).
#[serde(default)]
pub blend_linear: bool,
pub time_display: TimeDisplayStyle,
/// Frame numbering starts at 0 (or 1).
pub frame_start: i64,
@ -92,7 +117,15 @@ pub struct ProjectSettings {
impl Default for ProjectSettings {
fn default() -> Self {
ProjectSettings { bit_depth: BitDepth::Bpc8, linearize: false, time_display: TimeDisplayStyle::Timecode, frame_start: 0, audio_sample_rate: 48_000 }
ProjectSettings {
bit_depth: BitDepth::Bpc8,
linearize: false,
working_space: None,
blend_linear: false,
time_display: TimeDisplayStyle::Timecode,
frame_start: 0,
audio_sample_rate: 48_000,
}
}
}
@ -520,6 +553,9 @@ pub struct Footage {
/// Image sequence files (when kind = Sequence).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub sequence: Vec<String>,
/// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub color_profile: Option<ColorSpace>,
}
fn one() -> u32 {

View File

@ -329,6 +329,7 @@ mod tests {
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
};
let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f));
let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0));

View File

@ -291,6 +291,8 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool
}
_ => return None,
}
// Bit depth and colour management change the pixels.
crate::color::Pipe::of(&ctx.project.settings).key().hash(&mut h);
ctx.comp_id.hash(&mut h);
ctx.comp.width.hash(&mut h);
ctx.comp.height.hash(&mut h);

114
crates/render/src/color.rs Normal file
View File

@ -0,0 +1,114 @@
//! Project colour settings in the compositor: bit depth, working space, linear blending.
//!
//! * **Bit depth.** Pixels are `f32` throughout, but 8 and 16 bpc projects behave like integer
//! pipelines: each layer's pixels are clamped to 0..1 and quantised to the depth after its
//! source and masks and after every effect, and the comp is clamped and quantised after every
//! layer is blended. Over-range values (Add/Screen of bright layers, Exposure…) therefore only
//! survive in 32 bpc. 16 bpc uses After Effects' 0..32768 range.
//! * **Working space** (colour management). Footage is converted from its colour profile (its
//! metadata, else sRGB) into the working space; the top-level comp output is converted from
//! the working space to the sRGB display. Without a working space nothing is converted.
//! * **Linearize Working Space**: the working space is linear light, so sources (footage and
//! the colours of solids, text and shapes, which are authored in the working space's encoding)
//! are linearised before masks and effects, and everything runs in linear.
//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels
//! are linearised just before they are transformed and blended, the comp accumulates in linear
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
//! pixels to their parent). Without a working space the sRGB curve is used.
use effectcraft_color::{ColorSpace, Conversion};
use effectcraft_project::ProjectSettings;
use effectcraft_raster::Image;
use rayon::prelude::*;
/// The colour pipeline of a project (see the module docs).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Pipe {
/// Quantisation levels (255, 32768) or `None` for 32 bpc float.
pub levels: Option<f32>,
/// Working space (`None` = unmanaged).
pub space: Option<ColorSpace>,
/// The working space is linear.
pub linear: bool,
/// Blend in linear light (and the working space is not already linear).
pub linear_blend: bool,
}
impl Pipe {
pub fn of(s: &ProjectSettings) -> Pipe {
let linear = s.linearize && s.working_space.is_some();
Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear }
}
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
fn curve(&self) -> ColorSpace {
self.space.unwrap_or(ColorSpace::Srgb)
}
/// Footage with colour profile `profile` (`None` = sRGB) → working space.
pub fn media_in(&self, profile: Option<ColorSpace>) -> Option<Conversion> {
let ws = self.space?;
Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear)
}
/// Authored colours (solids, text, shapes) → working space: linearised in a linear working
/// space.
pub fn authored_in(&self) -> Option<Conversion> {
self.linear.then(|| Conversion::linearize(self.curve()))
}
/// Working space → blending space (linear when blending with 1.0 gamma).
pub fn to_blend(&self) -> Option<Conversion> {
self.linear_blend.then(|| Conversion::linearize(self.curve()))
}
/// Blending space → working space.
pub fn from_blend(&self) -> Option<Conversion> {
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
}
/// Working space → sRGB display (top-level comp output).
pub fn output(&self) -> Option<Conversion> {
let ws = self.space?;
Conversion::new(ws, self.linear, ColorSpace::Srgb, false)
}
/// Clamp and quantise to the bit depth (no-op in 32 bpc).
pub fn quantize(&self, img: &mut Image) {
if let Some(l) = self.levels {
quantize(img, l);
}
}
/// Hash of everything that changes pixels (for cache keys).
pub fn key(&self) -> u64 {
let mut k = self.levels.map_or(0, |l| l as u64);
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
k
}
}
/// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps.
pub fn quantize(img: &mut Image, levels: f32) {
let inv = 1.0 / levels;
img.data.par_iter_mut().for_each(|p| {
let a = (p[3].clamp(0.0, 1.0) * levels).round() * inv;
p[3] = a;
for c in 0..3 {
p[c] = (p[c].clamp(0.0, a) * levels).round() * inv;
}
});
}
/// Convert premultiplied pixels (on straight colour).
pub fn convert(img: &mut Image, c: &Conversion) {
img.data.par_iter_mut().for_each(|p| {
let a = p[3];
if a <= 0.0 {
return;
}
let o = c.apply([p[0] / a, p[1] / a, p[2] / a]);
*p = [o[0] * a, o[1] * a, o[2] * a, a];
});
}

View File

@ -8,6 +8,7 @@
pub mod audio;
pub mod cache;
pub mod color;
pub mod eval;
pub mod masks;
pub mod shapes;
@ -64,7 +65,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
return None;
}
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
let sub = self.r.nested();
// The cached layer buffer is shared; effects get their own copy.
let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? };
let size = source_size(self.r.project, other);
@ -77,7 +78,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH {
return None;
}
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
let sub = self.r.nested();
let buf = (*sub.layer_input(self.ctx, other, 0)?).clone();
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
@ -101,7 +102,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
}
let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed));
let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time));
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
let sub = self.r.nested();
sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone())
}
@ -111,7 +112,7 @@ impl EffectHost for FxHost<'_, '_, '_> {
return None;
}
let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time));
let sub = Renderer { depth: self.r.depth + 1, ..*self.r };
let sub = self.r.nested();
let buf = if masks_and_effects { (*sub.content_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() };
let size = source_size(self.r.project, other);
let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] };
@ -157,6 +158,8 @@ pub struct Renderer<'a> {
pub profile: Option<&'a std::sync::Mutex<Vec<LayerTiming>>>,
/// Current nesting depth (precomp recursion guard).
depth: usize,
/// The project's colour pipeline (bit depth, working space, linear blending).
pub(crate) pipe: color::Pipe,
}
/// Time spent on one layer of one frame (milliseconds).
@ -176,7 +179,12 @@ pub struct LayerTiming {
impl<'a> Renderer<'a> {
pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> {
Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 }
Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0, pipe: color::Pipe::of(&project.settings) }
}
/// A renderer for nested work (precomps, layers read by effects): one level deeper.
fn nested(&self) -> Renderer<'a> {
Renderer { depth: self.depth + 1, ..*self }
}
fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> {
@ -194,6 +202,24 @@ impl<'a> Renderer<'a> {
return canvas;
}
let ctx = self.ctx(comp_id, comp, t);
self.draw_comp(&ctx, &mut canvas);
if let Some(c) = self.pipe.from_blend() {
color::convert(&mut canvas, &c);
}
self.pipe.quantize(&mut canvas);
// The top-level comp leaves the working space for the (sRGB) display / output.
if self.depth == 0
&& let Some(c) = self.pipe.output()
{
color::convert(&mut canvas, &c);
self.pipe.quantize(&mut canvas);
}
canvas
}
/// Draw a comp's layers (bottom to top) into `canvas` (blending space).
fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image) {
let (comp, t) = (ctx.comp, ctx.time);
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t));
// Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first).
let visible: Vec<&Layer> = comp
@ -209,17 +235,15 @@ impl<'a> Renderer<'a> {
while j < visible.len() && visible[j].is_3d() {
j += 1;
}
three_d::compose::draw_run(self, &ctx, &visible[i..j], &mut canvas);
three_d::compose::draw_run(self, ctx, &visible[i..j], canvas);
i = j;
} else {
self.draw_layer(&ctx, visible[i], &mut canvas);
self.draw_layer(ctx, visible[i], canvas);
i += 1;
}
// 8/16 bpc: the comp is an integer buffer after every layer.
self.pipe.quantize(canvas);
}
if !self.project.settings.bit_depth.is_float() {
canvas.clamp01();
}
canvas
}
/// Evaluate an effect instance's parameters.
@ -271,6 +295,8 @@ impl<'a> Renderer<'a> {
let ectx = EffectCtx { params: &params, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env };
let t0 = web_time::Instant::now();
buf = effectcraft_effects::apply(spec, &ectx, buf);
// 8/16 bpc effects write integer pixels.
self.pipe.quantize(&mut buf.img);
if let Some(v) = timing.as_deref_mut() {
v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3));
}
@ -289,14 +315,17 @@ impl<'a> Renderer<'a> {
if layer.switches.adjustment {
return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s });
}
let c = sol.color;
let mut c = sol.color;
if let Some(conv) = self.pipe.authored_in() {
c = conv.apply(c);
}
Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s })
}
LayerSource::Comp { item } => {
if self.project.comp_contains(*item, ctx.comp_id) {
return None;
}
let sub = Renderer { depth: self.depth + 1, ..*self };
let sub = self.nested();
let lt = ctx.source_time(layer);
Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s })
}
@ -307,40 +336,94 @@ impl<'a> Renderer<'a> {
return None;
}
let lt = ctx.source_time(layer);
let img = self.footage.frame(*item, f, lt)?;
let img = self.footage_frame(ctx, layer, *item, f, lt)?;
let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 };
// Keep native pixels when downsampling is small; resample otherwise.
if s < 0.75 {
let mut buf = if s < 0.75 {
let w = ((f.width as f64 * s).round() as u32).max(1);
let h = ((f.height as f64 * s).round() as u32).max(1);
return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s });
Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }
} else {
Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }
};
if let Some(c) = self.pipe.media_in(f.color_profile) {
color::convert(&mut buf.img, &c);
}
Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k })
Some(buf)
}
LayerSource::Text => Some(text::render(ctx, layer, s)),
LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)),
LayerSource::Text => Some(self.authored(text::render(ctx, layer, s))),
LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, s))),
_ => None,
}
}
/// Authored colours into the working space (linear working spaces).
fn authored(&self, mut buf: Buf) -> Buf {
if let Some(c) = self.pipe.authored_in() {
color::convert(&mut buf.img, &c);
}
buf
}
/// A footage frame at source time `t`.
fn footage_frame(&self, _ctx: &EvalCtx, _layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> {
self.footage.frame(item, f, t)
}
/// Source → masks, clamped/quantised to the project depth.
fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Buf> {
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
self.pipe.quantize(&mut buf.img);
Some(buf)
}
/// Fully processed layer buffer (source → masks → effects → layer styles, flattened).
/// Served from the layer cache when the layer's content key matches.
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
self.layer_buf_keyed(ctx, layer).map(|(b, _)| b)
}
/// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma).
pub(crate) fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
let (b, k) = self.layer_buf_keyed(ctx, layer)?;
Some(self.to_blend(b, k))
}
/// A buffer converted to the blending space (cached under a key derived from `key`).
pub(crate) fn to_blend(&self, b: Arc<Buf>, key: Option<u64>) -> Arc<Buf> {
let Some(c) = self.pipe.to_blend() else { return b };
let key = key.map(|k| cache::derive(k, 0x1ea7));
if let (Some(cache), Some(k)) = (self.cache, key)
&& let Some(x) = cache.get(k)
{
return x;
}
let mut nb = (*b).clone();
color::convert(&mut nb.img, &c);
let nb = Arc::new(nb);
if let (Some(cache), Some(k)) = (self.cache, key) {
cache.insert(k, nb.clone());
}
nb
}
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>)> {
let st = self.styled(ctx, layer, None)?;
if st.passes.is_empty() {
return Some(st.body);
return Some((st.body, st.key));
}
let key = st.key.map(|k| cache::derive(k, 0xf1a7));
if let (Some(c), Some(k)) = (self.cache, key)
&& let Some(b) = c.get(k)
{
return Some(b);
return Some((b, key));
}
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, flat.clone());
}
Some(flat)
Some((flat, key))
}
/// A layer's source pixels at the context time, before masks, effects and the transform
@ -365,8 +448,7 @@ impl<'a> Renderer<'a> {
}
return Some((b, key));
}
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
let buf = self.masked_source(ctx, layer)?;
let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects)));
if let (Some(c), Some(k)) = (self.cache, key) {
c.insert(k, buf.clone());
@ -384,8 +466,7 @@ impl<'a> Renderer<'a> {
{
return Some(b);
}
let mut buf = self.source(ctx, layer)?;
masks::apply(ctx, layer, &mut buf);
let mut buf = self.masked_source(ctx, layer)?;
if effects > 0 {
buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None);
}
@ -400,7 +481,7 @@ impl<'a> Renderer<'a> {
fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<StyledLayer> {
let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?;
if !styles::active(ctx, layer) {
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true });
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true });
}
let modes = styles::pass_modes(ctx, layer);
let key = ckey.map(|k| cache::styles_key(ctx, layer, k));
@ -409,7 +490,7 @@ impl<'a> Renderer<'a> {
{
let passes: Option<Vec<_>> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect();
if let Some(passes) = passes {
return Some(StyledLayer { passes, body, content, key, plain: false });
return Some(StyledLayer { passes, body, content, key, ckey, plain: false });
}
}
let t0 = web_time::Instant::now();
@ -428,7 +509,18 @@ impl<'a> Renderer<'a> {
c.insert(cache::derive(k, i as u64 + 1), b.clone());
}
}
Some(StyledLayer { passes, body, content, key, plain: false })
Some(StyledLayer { passes, body, content, key, ckey, plain: false })
}
/// The styled layer's buffers in the blending space.
fn styled_to_blend(&self, st: StyledLayer) -> StyledLayer {
if self.pipe.to_blend().is_none() {
return st;
}
let body = self.to_blend(st.body, st.key);
let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) };
let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect();
StyledLayer { passes, body, content, ..st }
}
fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling {
@ -488,6 +580,7 @@ impl<'a> Renderer<'a> {
let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() });
let t0 = web_time::Instant::now();
let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return };
let st = self.styled_to_blend(st);
let t1 = web_time::Instant::now();
if st.plain {
self.composite_layer(ctx, layer, &st.body, canvas, opacity);
@ -595,8 +688,15 @@ impl<'a> Renderer<'a> {
masks::apply(ctx, layer, &mut foot);
let mut matte = Image::new(canvas.width, canvas.height);
self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0);
let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
let adjusted = self.apply_effects(ctx, layer, below, true);
let mut below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale };
// Effects run in the working space, not the linear blending space.
if let Some(c) = self.pipe.from_blend() {
color::convert(&mut below.img, &c);
}
let mut adjusted = self.apply_effects(ctx, layer, below, true);
if let Some(c) = self.pipe.to_blend() {
color::convert(&mut adjusted.img, &c);
}
if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height {
return;
}
@ -615,8 +715,10 @@ struct StyledLayer {
body: Arc<Buf>,
/// Pre-style pixels (knockout shape).
content: Arc<Buf>,
/// Styled cache key.
/// Styled cache key (the content key when plain).
key: Option<u64>,
/// Content cache key.
ckey: Option<u64>,
/// No layer styles: composite `body` the usual way.
plain: bool,
}
@ -659,6 +761,8 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath {
#[cfg(test)]
mod tests_audio_fx;
#[cfg(test)]
mod tests_color;
#[cfg(test)]
mod tests_paint;
#[cfg(test)]
mod tests_time;

View File

@ -8,6 +8,8 @@ use crate::render_frame;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)

View File

@ -50,6 +50,7 @@ fn audio_footage() -> Footage {
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: None,
}
}

View File

@ -0,0 +1,227 @@
//! Bit depth, working space and linear blending.
use std::sync::Arc;
use effectcraft_color::{BlendMode, ColorSpace, Label};
use effectcraft_project::build;
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
use effectcraft_time::{FrameRate, Tick};
use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
let mut p = Project::default();
p.settings.bit_depth = depth;
let comp = Comp::new(40, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)
}
fn add_solid(p: &mut Project, cid: ItemId, color: [f32; 3], mode: BlendMode, opacity: f64) {
let comp = p.comp(cid).unwrap().clone();
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: 40, height: 20, pixel_aspect: 1.0 }));
let mut l = build::layer(p, &comp, "S", LayerSource::Solid { item: sid }, (40, 20), None);
l.blend_mode = mode;
l.props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(opacity);
// New layers go on top.
p.comp_mut(cid).unwrap().layers.insert(0, l);
}
fn px(p: &Project, cid: ItemId) -> [f32; 4] {
render_frame(p, cid, Tick::ZERO, 1.0).get(10, 10)
}
#[test]
fn add_clamps_in_8_and_16_but_not_32_bpc() {
for (depth, want) in [(BitDepth::Bpc8, 1.0), (BitDepth::Bpc16, 1.0), (BitDepth::Bpc32, 1.6)] {
let (mut p, cid, _) = setup(depth);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
let v = px(&p, cid)[0];
assert!((v - want).abs() < 1e-5, "{depth:?}: {v}");
}
// Screen of over-range values survives in 32 bpc only: a 1.6 base screened with 0.5.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
add_solid(&mut p, cid, [0.5, 0.5, 0.5], BlendMode::Screen, 100.0);
let v = px(&p, cid)[0];
assert!((v - (1.6 + 0.5 - 0.8)).abs() < 1e-5, "{v}");
}
#[test]
fn quantization_steps_per_depth() {
let want = |depth| match depth {
BitDepth::Bpc8 => (0.3f32 * 255.0).round() / 255.0,
BitDepth::Bpc16 => (0.3f32 * 32768.0).round() / 32768.0,
BitDepth::Bpc32 => 0.3,
};
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
let (mut p, cid, _) = setup(depth);
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
let v = px(&p, cid)[0];
assert!((v - want(depth)).abs() < 1e-7, "{depth:?}: {v} vs {}", want(depth));
}
// 8 bpc: 77/255 = 0.30196 (one step is 1/255); 16 bpc is within 1/65536.
let (mut p8, c8, _) = setup(BitDepth::Bpc8);
add_solid(&mut p8, c8, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
assert!((px(&p8, c8)[0] - 77.0 / 255.0).abs() < 1e-7);
// A 1% opacity layer of white over black lands on a whole 8-bit step.
let (mut p, cid, _) = setup(BitDepth::Bpc8);
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 1.0);
let v = px(&p, cid)[0] * 255.0;
assert!((v - v.round()).abs() < 1e-4 && (v - 2.55).abs() < 1.0, "{v}");
}
#[test]
fn linear_blending_of_red_over_green() {
// 50% red over green: gamma blending gives (0.5, 0.5, 0); 1.0-gamma blending mixes linear
// light, so each channel is the sRGB encoding of 0.5 linear ≈ 0.735.
let run = |linear: bool| {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.blend_linear = linear;
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
px(&p, cid)
};
let g = run(false);
assert!((g[0] - 0.5).abs() < 1e-5 && (g[1] - 0.5).abs() < 1e-5 && g[2].abs() < 1e-6, "{g:?}");
let l = run(true);
let e = effectcraft_color::linear_to_srgb(0.5);
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4 && l[2].abs() < 1e-6, "{l:?}");
assert!((e - 0.7354).abs() < 1e-3);
// Unblended (opaque) layers are unchanged by linear blending.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.blend_linear = true;
add_solid(&mut p, cid, [0.25, 0.5, 0.75], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!((o[0] - 0.25).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.75).abs() < 1e-5, "{o:?}");
}
#[test]
fn linearized_working_space_blends_linear_and_round_trips_colours() {
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
let l = px(&p, cid);
let e = effectcraft_color::linear_to_srgb(0.5);
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4, "{l:?}");
// An opaque solid shows its own colour (linearised in, encoded out).
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
add_solid(&mut p, cid, [0.2, 0.5, 0.9], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!((o[0] - 0.2).abs() < 1e-4 && (o[1] - 0.5).abs() < 1e-4 && (o[2] - 0.9).abs() < 1e-4, "{o:?}");
// Linearize needs a working space (like AE, where it is disabled without one).
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.linearize = true;
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
assert!((px(&p, cid)[0] - 0.5).abs() < 1e-5);
}
#[test]
fn wide_working_space_colours_leave_the_display_gamut() {
// Pure Rec. 2020 red is outside sRGB: in 32 bpc the display conversion keeps the
// over-range/negative values; 8 bpc clamps them.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Rec2020);
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!(o[0] > 1.0 && o[1] < 0.0 && o[2] < 0.0, "{o:?}");
p.settings.bit_depth = BitDepth::Bpc8;
let o = px(&p, cid);
assert!((o[0] - 1.0).abs() < 1e-6 && o[1] == 0.0 && o[2] == 0.0, "{o:?}");
// sRGB working space: no conversion at all.
let (mut p, cid, _) = setup(BitDepth::Bpc32);
p.settings.working_space = Some(ColorSpace::Srgb);
add_solid(&mut p, cid, [0.3, 0.6, 0.9], BlendMode::Normal, 100.0);
let o = px(&p, cid);
assert!((o[0] - 0.3).abs() < 1e-6 && (o[2] - 0.9).abs() < 1e-6);
}
/// One flat-colour footage frame.
struct Flat([f32; 3]);
impl FootageSource for Flat {
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
let c = self.0;
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
}
}
fn footage(profile: Option<ColorSpace>) -> Footage {
Footage {
path: "x.png".into(),
kind: FootageKind::Still,
width: 40,
height: 20,
pixel_aspect: 1.0,
frame_rate: FrameRate::FPS_30,
native_rate: None,
duration: Tick::ZERO,
has_video: true,
has_audio: false,
alpha: AlphaMode::Ignore,
premul_color: [0.0; 3],
loop_count: 1,
codec: String::new(),
missing: false,
sequence: vec![],
color_profile: profile,
}
}
#[test]
fn footage_profile_is_converted_into_the_working_space() {
let render = |ws: Option<ColorSpace>, profile: Option<ColorSpace>| {
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
p.settings.working_space = ws;
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(footage(profile)));
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
p.comp_mut(cid).unwrap().layers.push(l);
let src = Flat([1.0, 0.0, 0.0]);
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
};
// Unmanaged: the file's values pass straight through.
let o = render(None, Some(ColorSpace::Rec2020));
assert_eq!(o, [1.0, 0.0, 0.0, 1.0]);
// Managed: Rec. 2020 red shown on an sRGB display is beyond sRGB red.
let o = render(Some(ColorSpace::Srgb), Some(ColorSpace::Rec2020));
assert!(o[0] > 1.0 && o[1] < 0.0, "{o:?}");
// Untagged footage is sRGB: a round trip through a Rec. 2020 working space.
let o = render(Some(ColorSpace::Rec2020), None);
assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}");
}
#[test]
fn layer_cache_is_keyed_by_colour_settings() {
let (mut p, cid, _) = setup(BitDepth::Bpc8);
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
let cache = LayerCache::default();
let frame = |p: &Project| {
let mut r = Renderer::new(p, &crate::NoFootage, RenderOpts::default());
r.cache = Some(&cache);
r.comp_frame(cid, Tick::ZERO).get(10, 10)[0]
};
let a = frame(&p);
assert!((a - 77.0 / 255.0).abs() < 1e-7);
assert_eq!(cache.stats().entries, 1);
p.settings.bit_depth = BitDepth::Bpc32;
let b = frame(&p);
assert!((b - 0.3).abs() < 1e-7, "stale 8 bpc pixels: {b}");
p.settings.working_space = Some(ColorSpace::Srgb);
p.settings.linearize = true;
let _ = frame(&p);
assert_eq!(cache.stats().entries, 3, "each setting renders its own entry");
// Back to 8 bpc reuses the first entry.
p.settings.bit_depth = BitDepth::Bpc8;
p.settings.working_space = None;
p.settings.linearize = false;
let hits = cache.stats().hits;
assert!((frame(&p) - a).abs() < 1e-9);
assert_eq!(cache.stats().hits, hits + 1);
}

View File

@ -167,7 +167,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize {
/// Prepare a layer (buffer, geometry, material). `None` when it shows nothing.
pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option<Item<'a>> {
let buf = r.layer_buf(ctx, layer)?;
let buf = r.blend_layer_buf(ctx, layer)?;
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
}
@ -176,7 +176,7 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer,
if crate::text::per_char_3d(ctx, layer) {
return crate::text::per_char_planes(ctx, layer, r.opts.scale)
.into_iter()
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, Arc::new(buf), m))
.filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m))
.collect();
}
prepare(r, ctx, layer, order, out, in_run).into_iter().collect()

View File

@ -14,6 +14,8 @@ const H: u32 = 100;
fn setup() -> (Project, ItemId, Comp) {
let mut p = Project::default();
// Exact float maths; 8/16 bpc quantisation has its own tests (tests_color).
p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32;
let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
(p, cid, comp)

View File

@ -218,7 +218,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
),
],
),
"file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect"]) => {
"file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => {
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind {
effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()),
_ => None,
@ -236,6 +236,18 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
),
Field::num("loop", "Loop (times)", f.loop_count as f64),
Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect),
Field::choice(
"colorProfile",
"Color profile",
&[
("Embedded / sRGB", json!("auto")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
],
f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
],
)
}
@ -261,7 +273,20 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
&[("Timecode", json!("timecode")), ("Frames", json!("frames"))],
usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)),
),
Field::choice(
"workingSpace",
"Working space",
&[
("None", json!("none")),
("sRGB IEC61966-2.1", json!("srgb")),
("HDTV (Rec. 709)", json!("rec709")),
("Rec. 2020", json!("rec2020")),
("Display P3", json!("p3")),
],
st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)),
),
Field::bool("linearize", "Linearize working space", st.linearize),
Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear),
],
)
}

View File

@ -311,9 +311,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) {
let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click());
p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim });
app.auto.add("project.bitDepth", dr, depth);
// Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects).
if dresp.clicked() {
actions.push(("file.cycleBitDepth".into(), json!({})));
if ui.input(|i| i.modifiers.alt) {
actions.push(("file.projectSettings".into(), json!({})));
} else {
actions.push(("file.cycleBitDepth".into(), json!({})));
}
}
dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings");
let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0));
if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() {
let sel = app.session.state.project_selection.clone();