Files
effectcraft/crates/engine/src/viewer.rs
T
Brandon ThomasandClaude Opus 5.5 f3715aac32 M13.30: web depth and polish
- GPU in browser job workers: each job worker opens its own WebGPU device
  (deferred readbacks); the Render Queue export and the analyses' frame loops
  are futures (render::passes, offload::run_request_async) whose frames
  render in passes, awaiting the device between them. GPU particles and
  Advanced 3D read back under keys too. workers.jobs reports passes and
  readbacks; the smoke test measures them.
- Layer buffers in the browser's OPFS disk cache: frame workers back their
  layer caches with a PrefetchStore; the page plans what to fetch before a
  request from recent misses (LayerPrefetch over the shared DiskIndex).
- Templates embed footage (default on, size-capped with a warning) and
  creating from a template extracts it next to the new project / into
  browser storage.
- ICC LUT profiles (lut8/lut16/lutAtoB/lutBtoA, from the public ICC spec) in
  Simulate Output > My Custom RGB, baked into a 3D LUT for the viewer.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-04 02:57:17 -04:00

904 lines
34 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Composition viewer maths shared by the frontends and tests: snapping (targets, tolerance and
//! priority), the Show Channel / Exposure display transforms, and the viewer snapshot.
//!
//! Nothing here touches the project; edits go through commands.
use std::sync::Arc;
use effectcraft_color::{ColorSpace, space};
use effectcraft_geom::vec2;
use effectcraft_project::{Layer, LayerId};
use effectcraft_raster::Image;
use effectcraft_render::EvalCtx;
use effectcraft_time::Tick;
use serde::{Deserialize, Serialize};
// ---------------------------------------------------------------- snapping
/// What a snap target is: a point (snaps both axes) or an axis-aligned line.
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum SnapKind {
Point,
/// A vertical line at `pos[0]` (snaps x only).
VLine,
/// A horizontal line at `pos[1]` (snaps y only).
HLine,
}
/// Where a snap target comes from (for feedback: the highlighted box / cross).
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub enum SnapSource {
Layer(LayerId),
Comp,
Guide,
Grid,
/// A mask or shape path vertex of a layer.
Vertex(LayerId),
}
#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)]
pub struct SnapTarget {
pub kind: SnapKind,
/// Comp pixels.
pub pos: [f64; 2],
/// Lower wins when two targets are equally near (vertices and layer features before
/// guides, guides before the grid).
pub priority: u8,
pub source: SnapSource,
}
impl SnapTarget {
pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::Point, pos, priority, source }
}
pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source }
}
pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source }
}
}
/// Priorities of the standard targets.
pub const PRI_VERTEX: u8 = 0;
pub const PRI_LAYER: u8 = 1;
pub const PRI_COMP: u8 = 2;
pub const PRI_GUIDE: u8 = 3;
pub const PRI_GRID: u8 = 4;
/// A snap: move the dragged feature by `delta` (comp pixels); `hits` are the targets it lands on.
#[derive(Clone, Debug, PartialEq)]
pub struct Snap {
pub delta: [f64; 2],
pub hits: Vec<SnapTarget>,
/// The snapped feature position.
pub at: [f64; 2],
}
fn better(a: (f64, u8), b: (f64, u8)) -> bool {
if (a.0 - b.0).abs() < 1e-6 { a.1 < b.1 } else { a.0 < b.0 }
}
/// Snap feature points `sources` (comp pixels) to the nearest target within `tol` comp pixels.
/// A point target snaps both axes and wins when it is at least as near as the best lines;
/// otherwise the nearest vertical and horizontal lines snap x and y independently. Ties go to
/// the lower priority value.
pub fn snap(sources: &[[f64; 2]], targets: &[SnapTarget], tol: f64) -> Option<Snap> {
let mut best_pt: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
let mut best_x: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
let mut best_y: Option<((f64, u8), [f64; 2], SnapTarget)> = None;
for s in sources {
for t in targets {
let (d, slot) = match t.kind {
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt),
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x),
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y),
};
if d > tol {
continue;
}
let key = (d, t.priority);
if slot.as_ref().is_none_or(|(k, _, _)| better(key, *k)) {
*slot = Some((key, *s, *t));
}
}
}
let line_best = [best_x.map(|b| b.0.0), best_y.map(|b| b.0.0)].into_iter().flatten().fold(f64::INFINITY, f64::min);
if let Some((k, s, t)) = best_pt
&& k.0 <= line_best + 1e-9
{
return Some(Snap { delta: [t.pos[0] - s[0], t.pos[1] - s[1]], hits: vec![t], at: t.pos });
}
if best_x.is_none() && best_y.is_none() {
return None;
}
let mut delta = [0.0; 2];
let mut hits = vec![];
let mut at = [f64::NAN; 2];
if let Some((_, s, t)) = best_x {
delta[0] = t.pos[0] - s[0];
at = [t.pos[0], s[1]];
hits.push(t);
}
if let Some((_, s, t)) = best_y {
delta[1] = t.pos[1] - s[1];
at = if at[0].is_nan() { [s[0], t.pos[1]] } else { [at[0], t.pos[1]] };
hits.push(t);
}
Some(Snap { delta, hits, at })
}
/// Snap features of a layer in comp space: corners, edge midpoints, centre and anchor point
/// (2D layers; `None` for layers without bounds).
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
let mut out = vec![];
let (m, _) = ctx.layer_to_comp(layer);
if let Some(b) = effectcraft_render::content_bounds(ctx, layer) {
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
for p in [[b[0], b[1]], [b[2], b[1]], [b[2], b[3]], [b[0], b[3]], [cx, b[1]], [b[2], cy], [cx, b[3]], [b[0], cy], [cx, cy]] {
let q = m.apply(vec2(p[0], p[1]));
out.push([q.x, q.y]);
}
}
if let Some(tr) = layer.transform() {
let a = ctx.v3(layer, tr, "anchor", [0.0; 3]);
let q = m.apply(vec2(a[0], a[1]));
out.push([q.x, q.y]);
}
out
}
/// Mask and shape-path vertices of a layer in comp space.
pub fn layer_vertices(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
let (m, _) = ctx.layer_to_comp(layer);
let mut out = vec![];
if let Some(masks) = layer.masks() {
for g in masks.groups() {
if let Some(sp) = g.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned()) {
out.extend(sp.vertices.iter().map(|v| {
let q = m.apply(vec2(v[0], v[1]));
[q.x, q.y]
}));
}
}
}
for (uid, sp) in shape_paths(ctx, layer) {
let pm = m * effectcraft_render::shapes::item_matrix(ctx, layer, uid).unwrap_or(effectcraft_geom::Mat3::IDENTITY);
out.extend(sp.vertices.iter().map(|v| {
let q = pm.apply(vec2(v[0], v[1]));
[q.x, q.y]
}));
}
out
}
/// Shape-layer Path items (uid, evaluated path in the item's group space).
pub fn shape_paths(ctx: &EvalCtx, layer: &Layer) -> Vec<(u64, effectcraft_keyframe::ShapePath)> {
let mut out = vec![];
let Some(c) = layer.props.sub("contents") else { return out };
fn walk(ctx: &EvalCtx, layer: &Layer, g: &effectcraft_project::PropGroup, out: &mut Vec<(u64, effectcraft_keyframe::ShapePath)>) {
for sub in g.groups() {
if sub.match_id == "path"
&& let Some(sp) = sub.get("path").map(|pr| ctx.value(layer, pr)).and_then(|v| v.as_path().cloned())
{
out.push((sub.uid, sp));
} else if sub.match_id == "group" || sub.match_id == "contents" {
walk(ctx, layer, sub, out);
}
}
}
walk(ctx, layer, c, &mut out);
out
}
/// Which target families are on.
#[derive(Clone, Copy, Debug, Default)]
pub struct SnapOptions {
pub guides: bool,
pub grid: bool,
pub grid_spacing: f64,
}
/// Snap targets of a comp at the context time: other layers' features and vertices, comp edges
/// and centre, and (when on) guides and grid lines. Layers in `exclude` (the dragged ones) are
/// skipped.
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
let comp = ctx.comp;
let (w, h) = (comp.width as f64, comp.height as f64);
let mut out = vec![
SnapTarget::vline(0.0, PRI_COMP, SnapSource::Comp),
SnapTarget::vline(w, PRI_COMP, SnapSource::Comp),
SnapTarget::vline(w / 2.0, PRI_COMP, SnapSource::Comp),
SnapTarget::hline(0.0, PRI_COMP, SnapSource::Comp),
SnapTarget::hline(h, PRI_COMP, SnapSource::Comp),
SnapTarget::hline(h / 2.0, PRI_COMP, SnapSource::Comp),
SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp),
];
for l in comp.layers.iter().filter(|l| l.is_active_at(ctx.time) && !exclude.contains(&l.id) && !l.is_camera() && !l.is_light() && !l.is_3d()) {
let f = layer_features(ctx, l);
// Edges of unrotated layers are lines too.
if f.len() >= 9 && (f[0][1] - f[1][1]).abs() < 1e-6 && (f[0][0] - f[3][0]).abs() < 1e-6 {
for x in [f[0][0], f[1][0]] {
out.push(SnapTarget { kind: SnapKind::VLine, pos: [x, f[0][1]], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
}
for y in [f[0][1], f[3][1]] {
out.push(SnapTarget { kind: SnapKind::HLine, pos: [f[0][0], y], priority: PRI_LAYER, source: SnapSource::Layer(l.id) });
}
}
out.extend(f.into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, SnapSource::Layer(l.id))));
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
}
if opts.guides {
for g in &comp.guides {
out.push(if g.vertical {
SnapTarget::vline(g.position, PRI_GUIDE, SnapSource::Guide)
} else {
SnapTarget::hline(g.position, PRI_GUIDE, SnapSource::Guide)
});
}
}
if opts.grid && opts.grid_spacing >= 1.0 {
let s = opts.grid_spacing;
let mut x = 0.0;
while x <= w + 1e-9 && out.len() < 20_000 {
out.push(SnapTarget::vline(x, PRI_GRID, SnapSource::Grid));
x += s;
}
let mut y = 0.0;
while y <= h + 1e-9 && out.len() < 40_000 {
out.push(SnapTarget::hline(y, PRI_GRID, SnapSource::Grid));
y += s;
}
}
out
}
// ---------------------------------------------------------------- channels and exposure
/// Show Channel and Color Management Settings.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Channel {
#[default]
Rgb,
Red,
Green,
Blue,
Alpha,
RgbStraight,
}
impl Channel {
pub const ALL: [Channel; 6] = [Channel::Rgb, Channel::Red, Channel::Green, Channel::Blue, Channel::Alpha, Channel::RgbStraight];
pub fn id(self) -> &'static str {
match self {
Channel::Rgb => "rgb",
Channel::Red => "red",
Channel::Green => "green",
Channel::Blue => "blue",
Channel::Alpha => "alpha",
Channel::RgbStraight => "rgbStraight",
}
}
pub fn label(self) -> &'static str {
match self {
Channel::Rgb => "RGB",
Channel::Red => "Red",
Channel::Green => "Green",
Channel::Blue => "Blue",
Channel::Alpha => "Alpha",
Channel::RgbStraight => "RGB Straight",
}
}
pub fn from_name(s: &str) -> Option<Channel> {
let n = s.to_ascii_lowercase().replace([' ', '_', '-'], "");
Channel::ALL.into_iter().find(|c| c.id().to_ascii_lowercase() == n || c.label().to_ascii_lowercase().replace(' ', "") == n)
}
}
fn srgb_to_linear(v: f32) -> f32 {
if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) }
}
fn linear_to_srgb(v: f32) -> f32 {
let v = v.max(0.0);
if v <= 0.003_130_8 { v * 12.92 } else { 1.055 * v.powf(1.0 / 2.4) - 0.055 }
}
/// 8-bit exposure lookup table: `stops` applied in linear light.
pub fn exposure_lut(stops: f32) -> [u8; 256] {
let k = 2f32.powf(stops);
let mut lut = [0u8; 256];
for (i, o) in lut.iter_mut().enumerate() {
let lin = srgb_to_linear(i as f32 / 255.0) * k;
*o = (linear_to_srgb(lin).clamp(0.0, 1.0) * 255.0 + 0.5) as u8;
}
lut
}
/// Apply the viewer's channel and exposure to premultiplied 8-bit RGBA pixels in place.
/// RGB shows the image (exposure only); Red/Green/Blue/Alpha show one channel as an opaque
/// grayscale image (or tinted in that channel's colour when `colorized`); RGB Straight shows the
/// unpremultiplied colour, opaque.
pub fn display_transform(px: &mut [[u8; 4]], channel: Channel, colorized: bool, stops: f32) {
let lut = (stops != 0.0).then(|| exposure_lut(stops));
let ex = |v: u8| lut.as_ref().map(|l| l[v as usize]).unwrap_or(v);
for p in px.iter_mut() {
let [r, g, b, a] = *p;
*p = match channel {
Channel::Rgb => {
if lut.is_none() {
continue;
}
// Exposure on straight colour, re-premultiplied.
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
let pm = |c: u8| ((ex(un(c)) as u32 * a as u32 + 127) / 255) as u8;
[pm(r), pm(g), pm(b), a]
}
Channel::Red | Channel::Green | Channel::Blue => {
let (i, c) = match channel {
Channel::Red => (0, r),
Channel::Green => (1, g),
_ => (2, b),
};
let v = ex(c);
if colorized {
let mut o = [0, 0, 0, 255];
o[i] = v;
o
} else {
[v, v, v, 255]
}
}
Channel::Alpha => {
let v = ex(a);
[v, v, v, 255]
}
Channel::RgbStraight => {
let un = |c: u8| if a == 0 { 0 } else { ((c as u32 * 255 + a as u32 / 2) / a as u32).min(255) as u8 };
[ex(un(r)), ex(un(g)), ex(un(b)), 255]
}
};
}
}
// ---------------------------------------------------------------- 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,
/// My Custom RGB: the user's own device ([`CustomRgb`], in Settings).
MyCustom,
}
/// 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; 10] = [
SimProfile::None,
SimProfile::Rec709,
SimProfile::Ntsc,
SimProfile::Pal,
SimProfile::Mac18,
SimProfile::Srgb,
SimProfile::Rec2020,
SimProfile::P3,
SimProfile::Linear,
SimProfile::MyCustom,
];
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",
SimProfile::MyCustom => "myCustom",
}
}
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)",
SimProfile::MyCustom => "My Custom RGB",
}
}
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().to_ascii_lowercase() == 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 and My Custom RGB (see [`CustomRgb`]).
pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> {
Some(match self {
SimProfile::None | SimProfile::MyCustom => 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,
}
/// View ▸ Simulate Output ▸ My Custom RGB…: a user-defined output device, kept in Settings
/// (`customRgb`). Defined by primaries and white point (CIE xy) and a gamma or the sRGB curve,
/// typed in or read from an RGB ICC profile.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct CustomRgb {
pub name: String,
pub red: [f64; 2],
pub green: [f64; 2],
pub blue: [f64; 2],
pub white: [f64; 2],
pub gamma: f64,
/// The sRGB piecewise curve instead of a pure power curve.
pub srgb_curve: bool,
/// The ICC profile the numbers were read from (empty: typed in).
pub icc: String,
/// A LUT-based (`A2B0`) profile's bytes: the simulation runs the profile's tables (baked
/// into a 3D LUT) instead of the numbers above, which then only approximate it.
#[serde(skip_serializing_if = "IccLut::is_empty")]
pub icc_lut: IccLut,
}
/// The bytes of a LUT-based ICC profile, kept in Settings as base64.
#[derive(Clone, Default, PartialEq)]
pub struct IccLut(pub Option<Arc<Vec<u8>>>);
impl IccLut {
pub fn is_empty(&self) -> bool {
self.0.is_none()
}
/// The parsed profile.
pub fn profile(&self) -> Option<effectcraft_color::icc::LutProfile> {
match effectcraft_color::icc::parse_profile(self.0.as_deref()?) {
Ok(effectcraft_color::icc::Profile::Lut(p)) => Some(p),
_ => None,
}
}
fn hash(&self) -> u64 {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
self.0.as_deref().hash(&mut h);
h.finish()
}
}
impl std::fmt::Debug for IccLut {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match &self.0 {
Some(b) => write!(f, "IccLut({} bytes, {:016x})", b.len(), self.hash()),
None => write!(f, "IccLut(none)"),
}
}
}
impl Serialize for IccLut {
fn serialize<S: serde::Serializer>(&self, s: S) -> std::result::Result<S::Ok, S::Error> {
s.serialize_str(&self.0.as_deref().map(|b| effectcraft_track::roto::rle::base64_encode(b)).unwrap_or_default())
}
}
impl<'de> Deserialize<'de> for IccLut {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> std::result::Result<Self, D::Error> {
let s = String::deserialize(d)?;
Ok(IccLut(effectcraft_track::roto::rle::base64_decode(&s).filter(|b| !b.is_empty()).map(Arc::new)))
}
}
impl Default for CustomRgb {
fn default() -> Self {
CustomRgb {
name: "My Custom RGB".into(),
red: P709[0],
green: P709[1],
blue: P709[2],
white: D65,
gamma: 2.2,
srgb_curve: false,
icc: String::new(),
icc_lut: IccLut::default(),
}
}
}
impl CustomRgb {
pub fn primaries(&self) -> [[f64; 2]; 3] {
[self.red, self.green, self.blue]
}
pub fn curve(&self) -> Curve {
if self.srgb_curve {
Curve::Srgb
} else if (self.gamma - 1.0).abs() < 1e-9 {
Curve::Linear
} else {
Curve::Gamma(self.gamma as f32)
}
}
/// Chromaticities inside (0, 1) with y > 0, a gamma in 0.1–10, and primaries that span a
/// triangle.
pub fn validate(&self) -> std::result::Result<(), String> {
for (n, c) in [("red", self.red), ("green", self.green), ("blue", self.blue), ("white", self.white)] {
if !(c[0] > 0.0 && c[1] > 0.0 && c[0] < 1.0 && c[1] < 1.0 && c[0] + c[1] <= 1.0) {
return Err(format!("{n}: chromaticity x, y must be in (0, 1) with x + y ≤ 1"));
}
}
if !self.srgb_curve && !(0.1..=10.0).contains(&self.gamma) {
return Err("gamma must be between 0.1 and 10".into());
}
let [r, g, b] = self.primaries();
let area = (g[0] - r[0]) * (b[1] - r[1]) - (b[0] - r[0]) * (g[1] - r[1]);
if area.abs() < 1e-6 {
return Err("the primaries must form a triangle".into());
}
Ok(())
}
/// Take primaries, white and curve from an RGB ICC profile. A LUT-based profile is kept
/// whole ([`CustomRgb::icc_lut`]); its numbers are the primaries' and white's colours
/// (adapted to D65) and the gamma that fits its neutral ramp.
pub fn from_icc(bytes: &[u8], path: &str) -> std::result::Result<CustomRgb, String> {
use effectcraft_color::icc::{Profile, parse_profile};
let stem = || std::path::Path::new(path).file_stem().map(|n| n.to_string_lossy().to_string()).unwrap_or_else(|| "ICC Profile".into());
let name = |d: &str| if d.trim().is_empty() { stem() } else { d.trim().to_string() };
let r = |c: [f64; 2]| c.map(|v| (v * 1e5).round() / 1e5);
match parse_profile(bytes)? {
Profile::Matrix(p) => {
let gamma = (p.trc.iter().map(|t| t.fit_gamma()).sum::<f64>() / 3.0 * 1000.0).round() / 1000.0;
Ok(CustomRgb {
name: name(&p.description),
red: r(p.primaries[0]),
green: r(p.primaries[1]),
blue: r(p.primaries[2]),
white: r(p.white),
gamma,
srgb_curve: p.trc[0].is_srgb(),
icc: path.to_string(),
icc_lut: IccLut::default(),
})
}
Profile::Lut(p) => {
let (prims, _, gamma) = p.approximate();
// The PCS is D50-relative: the device white shows as the viewer's D65.
let a = space::bradford(effectcraft_color::icc::D50, D65);
let adapt = |c: [f64; 2]| {
let v = space::mul_vec(&a, [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]);
let sum = v[0] + v[1] + v[2];
let c = [(v[0] / sum).clamp(1e-4, 0.9998), (v[1] / sum).clamp(1e-4, 0.9998)];
// Table rounding can put a primary a hair outside the spectrum locus.
let k = (0.9999 / (c[0] + c[1])).min(1.0);
[c[0] * k, c[1] * k]
};
Ok(CustomRgb {
name: name(&p.description),
red: r(adapt(prims[0])),
green: r(adapt(prims[1])),
blue: r(adapt(prims[2])),
white: D65,
gamma: (gamma * 1000.0).round() / 1000.0,
srgb_curve: false,
icc: path.to_string(),
icc_lut: IccLut(Some(Arc::new(bytes.to_vec()))),
})
}
}
}
fn prof(&self) -> Prof {
let m = space::rgb_to_xyz(self.primaries(), self.white);
let xyz = if self.white == D65 { m } else { space::mul(&space::bradford(self.white, D65), &m) };
Prof { xyz, curve: self.curve() }
}
}
/// 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, Debug)]
pub struct DisplayColor {
sim: Option<(Conv, Option<Conv>)>,
display: Option<Conv>,
/// A LUT-based My Custom RGB profile: the whole simulation baked into a 3D LUT.
lut: Option<Arc<effectcraft_color::icc::Lut3d>>,
}
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.
#[allow(clippy::too_many_arguments)]
pub fn new(
working: Option<ColorSpace>,
linear: bool,
source: ColorSpace,
dcm: bool,
display: ColorSpace,
sim: Simulation,
custom: &CustomRgb,
) -> 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), lut: None });
}
if sim.profile == SimProfile::MyCustom
&& let Some(lut) = lut_simulation(custom, src, display, sim.preserve_rgb)
{
return Some(DisplayColor { sim: None, display: None, lut: Some(lut) });
}
let out = match sim.profile {
SimProfile::MyCustom => Some(custom.prof()),
p => p.space().map(|(prims, curve)| Prof::new(prims, curve)),
};
let sim = out.map(|out| {
(
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, lut: None })
}
/// 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,
&s.prefs.custom_rgb,
)
}
/// Whether the simulation runs a LUT-based ICC profile (baked into a 3D LUT).
pub fn is_lut(&self) -> bool {
self.lut.is_some()
}
/// One straight colour (0..1 floats).
pub fn apply_straight(&self, c: [f32; 3]) -> [f32; 3] {
if let Some(l) = &self.lut {
return l.eval(c.map(|v| v.clamp(0.0, 1.0))).map(|v| v.clamp(0.0, 1.0));
}
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;
}
});
}
}
/// Grid points per axis of a baked LUT simulation (a profile without `B2A0` is inverted
/// numerically per point, so its grid is coarser).
const SIM_LUT_SIZE: usize = 33;
const SIM_LUT_SIZE_INVERTED: usize = 17;
/// My Custom RGB with a LUT-based profile: rendered colours (`src`) → the device's numbers
/// (`B2A0`, or `A2B0` inverted), clipped and quantised to 8 bits like the real output → what the
/// device shows (`A2B0`; Preserve RGB: the numbers taken as sRGB) → the display. Baked once per
/// profile and conversion (the last one is cached).
fn lut_simulation(custom: &CustomRgb, src: Prof, display: ColorSpace, preserve: bool) -> Option<Arc<effectcraft_color::icc::Lut3d>> {
type Cache = Option<(u64, Arc<effectcraft_color::icc::Lut3d>)>;
static CACHE: std::sync::Mutex<Cache> = std::sync::Mutex::new(None);
custom.icc_lut.0.as_ref()?;
let key = {
use std::hash::{Hash, Hasher};
let mut h = std::collections::hash_map::DefaultHasher::new();
(custom.icc_lut.hash(), format!("{src:?}{display:?}{preserve}")).hash(&mut h);
h.finish()
};
if let Some((k, l)) = CACHE.lock().ok()?.as_ref()
&& *k == key
{
return Some(l.clone());
}
let prof = custom.icc_lut.profile()?;
let to_d50 = space::mul(&space::bradford(D65, effectcraft_color::icc::D50), &src.xyz);
let from_d50 = space::bradford(effectcraft_color::icc::D50, D65);
let disp = Prof::of(display, false);
let disp_inv = space::invert(&disp.xyz);
let srgb_to_display = Conv::new(Prof::new(P709, Curve::Srgb), disp);
let size = if prof.b2a.is_some() { SIM_LUT_SIZE } else { SIM_LUT_SIZE_INVERTED };
let lut = effectcraft_color::icc::Lut3d::bake(size, |c| {
let lin = c.map(|v| src.curve.decode(v as f32) as f64);
let dev = prof.from_xyz(space::mul_vec(&to_d50, lin), c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0);
if preserve {
return srgb_to_display.apply(dev.map(|v| v as f32)).map(|v| v as f64);
}
let xyz = space::mul_vec(&from_d50, prof.to_xyz(dev));
space::mul_vec(&disp_inv, xyz).map(|v| disp.curve.encode(v as f32) as f64)
});
let lut = Arc::new(lut);
if let Ok(mut c) = CACHE.lock() {
*c = Some((key, lut.clone()));
}
Some(lut)
}
// ---------------------------------------------------------------- snapshot
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).
#[derive(Clone, Debug)]
pub struct Snapshot {
pub comp: effectcraft_project::ItemId,
pub time: Tick,
/// Render scale the image was taken at.
pub scale: f64,
pub image: Arc<Image>,
}