Files
effectcraft/crates/engine/src/viewer.rs
T
vangel76andClaude Sonnet 5.5 f11792827e Gap 21: percentage guides and effect colour labels (#1095)
Guides can store a percent of the comp so they follow comp-size changes
(View > Add Percentage Guide, Convert Guides to Percentage / Pixels); an
effect instance has an optional colour label (effect.setLabel, Effect Controls
header bar and Label menu). Both are serde-defaulted, so old projects load.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
2026-10-12 21:07:36 +02:00

1142 lines
45 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::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use effectcraft_color::{ColorSpace, space};
use effectcraft_geom::{Mat3, vec2};
use effectcraft_keyframe::Value;
use effectcraft_project::{Layer, LayerId, Property};
use effectcraft_raster::Image;
use effectcraft_render::{EvalCtx, ExprHost};
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,
/// A line's extent along itself (y of a vertical line, x of a horizontal one): a layer's
/// edge without Snap Edges Extended. `None` runs across the whole comp.
#[serde(default)]
pub span: Option<[f64; 2]>,
}
impl SnapTarget {
pub fn point(pos: [f64; 2], priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::Point, pos, priority, source, span: None }
}
pub fn vline(x: f64, priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::VLine, pos: [x, 0.0], priority, source, span: None }
}
pub fn hline(y: f64, priority: u8, source: SnapSource) -> SnapTarget {
SnapTarget { kind: SnapKind::HLine, pos: [0.0, y], priority, source, span: None }
}
/// The line limited to `span` (`None`: across the comp).
pub fn within(self, span: Option<[f64; 2]>) -> SnapTarget {
SnapTarget { span: span.map(|[a, b]| [a.min(b), a.max(b)]), ..self }
}
}
/// Tools bar ▸ Snapping options, the two After Effects' Snapping menu has: Snap Edges Extended
/// (layer edges snap along their whole line, beyond the layer's bounds) and Snap to Features in
/// Collapsed Compositions and Text Layers (the layers inside a precomp layer with Collapse
/// Transformations on are targets too). Both are on by default. Every layer feature snaps
/// (corners, edge midpoints, centre, anchor point, mask and shape path points), as in After
/// Effects.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, rename_all = "camelCase")]
pub struct SnapFeatures {
pub edges_extended: bool,
pub collapsed_features: bool,
}
impl Default for SnapFeatures {
fn default() -> Self {
SnapFeatures { edges_extended: true, collapsed_features: true }
}
}
impl SnapFeatures {
/// The options in the Tools bar's Snapping menu order: (parameter key, label).
pub const OPTIONS: [(&'static str, &'static str); 2] =
[("edgesExtended", "Snap Edges Extended"), ("collapsedFeatures", "Snap to Features in Collapsed Compositions and Text Layers")];
/// The option called `key` (an [`OPTIONS`](Self::OPTIONS) key).
pub fn option_mut(&mut self, key: &str) -> Option<&mut bool> {
Some(match key {
"edgesExtended" => &mut self.edges_extended,
"collapsedFeatures" => &mut self.collapsed_features,
_ => return None,
})
}
pub fn option(mut self, key: &str) -> bool {
self.option_mut(key).is_some_and(|v| *v)
}
}
/// 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 {
// (`along`: where the source is along a line, for its span.)
let (d, slot, along) = match t.kind {
SnapKind::Point => (((t.pos[0] - s[0]).powi(2) + (t.pos[1] - s[1]).powi(2)).sqrt(), &mut best_pt, 0.0),
SnapKind::VLine => ((t.pos[0] - s[0]).abs(), &mut best_x, s[1]),
SnapKind::HLine => ((t.pos[1] - s[1]).abs(), &mut best_y, s[0]),
};
if d > tol || t.span.is_some_and(|[a, b]| along < a || along > b) {
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, anchor point and
/// mask / shape path vertices (no box for layers without bounds).
pub fn layer_features(ctx: &EvalCtx, layer: &Layer) -> Vec<[f64; 2]> {
let (m, _) = ctx.layer_to_comp(layer);
let mut out = layer_box(ctx, layer, &m).map(|b| b.points()).unwrap_or_default();
out.extend(anchor_feature(ctx, layer, &m));
out.extend(layer_vertices(ctx, layer));
out
}
/// A layer's content box in comp space: corners (top left, top right, bottom right, bottom
/// left), edge midpoints (top, right, bottom, left) and centre.
struct BoxPoints {
corners: [[f64; 2]; 4],
mids: [[f64; 2]; 4],
center: [f64; 2],
}
impl BoxPoints {
/// Corners, edge midpoints and centre.
fn points(&self) -> Vec<[f64; 2]> {
self.corners.iter().chain(&self.mids).chain([&self.center]).copied().collect()
}
}
/// A layer's content box (`m`: layer → comp).
fn layer_box(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option<BoxPoints> {
let b = effectcraft_render::content_bounds(ctx, layer)?;
let (cx, cy) = ((b[0] + b[2]) / 2.0, (b[1] + b[3]) / 2.0);
let c = |x: f64, y: f64| {
let q = m.apply(vec2(x, y));
[q.x, q.y]
};
Some(BoxPoints {
corners: [c(b[0], b[1]), c(b[2], b[1]), c(b[2], b[3]), c(b[0], b[3])],
mids: [c(cx, b[1]), c(b[2], cy), c(cx, b[3]), c(b[0], cy)],
center: c(cx, cy),
})
}
/// A layer's anchor point (`m`: layer → comp).
fn anchor_feature(ctx: &EvalCtx, layer: &Layer, m: &Mat3) -> Option<[f64; 2]> {
let a = ctx.v3(layer, layer.transform()?, "anchor", [0.0; 3]);
let q = m.apply(vec2(a[0], a[1]));
Some([q.x, q.y])
}
/// 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(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 {
/// Layer and comp targets (the Snapping checkbox, inverted while Cmd/Ctrl is held).
pub layers: bool,
pub features: SnapFeatures,
pub guides: bool,
pub grid: bool,
pub grid_spacing: f64,
}
type SnapValues = HashMap<(u64, u64, u64, i64), Result<Value, String>>;
/// Bounds, vertices and transforms can ask for the same expression repeatedly. Share its
/// value only within this immutable snap query; a new pointer update starts a fresh cache.
struct SnapExpressions<'a> {
inner: &'a dyn ExprHost,
values: Mutex<SnapValues>,
}
impl ExprHost for SnapExpressions<'_> {
fn eval(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, value: &Value) -> Result<Value, String> {
let key = (ctx.comp_id.0, layer.id.0, prop.uid, ctx.time.0);
if let Some(value) = self.values.lock().unwrap_or_else(PoisonError::into_inner).get(&key).cloned() {
return value;
}
// Never hold the lock while evaluating: expressions may query other properties.
let result = self.inner.eval(ctx, layer, prop, value);
let mut values = self.values.lock().unwrap_or_else(PoisonError::into_inner);
if values.len() < 8192 {
values.insert(key, result.clone());
}
result
}
fn eval_text_selector(&self, ctx: &EvalCtx, layer: &Layer, prop: &Property, index: usize, total: usize, selector: [f64; 3]) -> Result<[f64; 3], String> {
self.inner.eval_text_selector(ctx, layer, prop, index, total, selector)
}
}
/// Snap targets of a comp at the context time: other visible layers' features and vertices and
/// the comp's edges and centre (with `layers`, as the Snapping options allow), and guides and
/// grid lines (when on). Layers in `exclude` (the dragged ones) are skipped.
pub fn targets(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
let memo = ctx.expr.map(|inner| SnapExpressions { inner, values: Mutex::default() });
let ctx = EvalCtx { expr: memo.as_ref().map(|h| h as &dyn ExprHost), ..*ctx };
targets_in(&ctx, exclude, opts)
}
fn targets_in(ctx: &EvalCtx, exclude: &[LayerId], opts: SnapOptions) -> Vec<SnapTarget> {
let comp = ctx.comp;
let (w, h) = (comp.width as f64, comp.height as f64);
let f = opts.features;
let mut out = vec![];
if opts.layers {
// The comp's edge and centre lines, along the comp without Snap Edges Extended.
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([0.0, w]), Some([0.0, h])) };
let mut lines = |x: &[f64], y: &[f64]| {
out.extend(x.iter().map(|x| SnapTarget::vline(*x, PRI_COMP, SnapSource::Comp).within(ys)));
out.extend(y.iter().map(|y| SnapTarget::hline(*y, PRI_COMP, SnapSource::Comp).within(xs)));
};
lines(&[0.0, w], &[0.0, h]);
lines(&[w / 2.0], &[h / 2.0]);
out.push(SnapTarget::point([w / 2.0, h / 2.0], PRI_COMP, SnapSource::Comp));
for l in visible_layers(ctx, exclude) {
out.extend(layer_targets(ctx, l, true, f));
}
}
if opts.guides {
for g in &comp.guides {
let pos = g.px(comp.width, comp.height);
out.push(if g.vertical { SnapTarget::vline(pos, PRI_GUIDE, SnapSource::Guide) } else { SnapTarget::hline(pos, 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
}
/// How deep Snap to Features in Collapsed Compositions looks into nested collapsed precomps.
const MAX_COLLAPSED_DEPTH: usize = 8;
/// Visible layers of a comp at the context time: active, eye on, and soloed while any layer is.
fn visible_layers<'a>(ctx: &EvalCtx<'a>, exclude: &'a [LayerId]) -> impl Iterator<Item = &'a Layer> {
let comp = ctx.comp;
let time = ctx.time;
let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(time));
comp.layers.iter().filter(move |l| l.is_active_at(time) && l.switches.video && (!any_solo || l.switches.solo) && !exclude.contains(&l.id))
}
/// A 2D layer's snap targets: its box points, its edges as lines while unrotated (across the
/// comp with Snap Edges Extended, along the layer otherwise), its anchor point unless `anchor` is
/// false (Pan Behind drags it, the box stays put), its mask and shape path vertices and, with
/// Snap to Features in Collapsed Compositions, the targets of the visible layers inside a
/// collapsed precomp layer. None for cameras, lights and 3D layers.
pub fn layer_targets(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures) -> Vec<SnapTarget> {
layer_targets_at(ctx, l, anchor, f, 0)
}
fn layer_targets_at(ctx: &EvalCtx, l: &Layer, anchor: bool, f: SnapFeatures, depth: usize) -> Vec<SnapTarget> {
if l.is_camera() || l.is_light() || l.is_3d() {
return vec![];
}
let (m, _) = ctx.layer_to_comp(l);
let src = SnapSource::Layer(l.id);
let mut out = vec![];
if let Some(b) = layer_box(ctx, l, &m) {
let [nw, ne, _, sw] = b.corners;
if (nw[1] - ne[1]).abs() < 1e-6 && (nw[0] - sw[0]).abs() < 1e-6 {
let (xs, ys) = if f.edges_extended { (None, None) } else { (Some([nw[0], ne[0]]), Some([nw[1], sw[1]])) };
for x in [nw[0], ne[0]] {
out.push(SnapTarget::vline(x, PRI_LAYER, src).within(ys));
}
for y in [nw[1], sw[1]] {
out.push(SnapTarget::hline(y, PRI_LAYER, src).within(xs));
}
}
out.extend(b.points().into_iter().map(|p| SnapTarget::point(p, PRI_LAYER, src)));
}
out.extend(anchor_feature(ctx, l, &m).filter(|_| anchor).map(|p| SnapTarget::point(p, PRI_LAYER, src)));
out.extend(layer_vertices(ctx, l).into_iter().map(|p| SnapTarget::point(p, PRI_VERTEX, SnapSource::Vertex(l.id))));
if f.collapsed_features
&& l.switches.collapse
&& depth < MAX_COLLAPSED_DEPTH
&& let effectcraft_project::LayerSource::Comp { item } = l.source
&& let Some(nc) = ctx.project.comp(item)
{
out.extend(collapsed_targets(ctx, l, item, nc, &m, f, depth));
}
out
}
/// The targets of the layers inside collapsed precomp layer `l` (showing comp `nc`), in the
/// outer comp (`m`: `l`'s layer → comp). They highlight `l` when they snap. Lines stay lines
/// only while `l` is unrotated; otherwise only the points snap.
fn collapsed_targets(
ctx: &EvalCtx,
l: &Layer,
item: effectcraft_project::ItemId,
nc: &effectcraft_project::Comp,
m: &Mat3,
f: SnapFeatures,
depth: usize,
) -> Vec<SnapTarget> {
let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.nested_time(l), ..*ctx };
let axis_aligned = m.0[0][1].abs() < 1e-9 && m.0[1][0].abs() < 1e-9;
let (sx, ox, sy, oy) = (m.0[0][0], m.0[0][2], m.0[1][1], m.0[1][2]);
let mut out = vec![];
for nl in visible_layers(&nctx, &[]) {
for t in layer_targets_at(&nctx, nl, true, f, depth + 1) {
let source = match t.source {
SnapSource::Vertex(_) => SnapSource::Vertex(l.id),
_ => SnapSource::Layer(l.id),
};
let mapped = match t.kind {
SnapKind::Point => {
let q = m.apply(vec2(t.pos[0], t.pos[1]));
Some(SnapTarget::point([q.x, q.y], t.priority, source))
}
SnapKind::VLine if axis_aligned => {
Some(SnapTarget::vline(sx * t.pos[0] + ox, t.priority, source).within(t.span.map(|[a, b]| [sy * a + oy, sy * b + oy])))
}
SnapKind::HLine if axis_aligned => {
Some(SnapTarget::hline(sy * t.pos[1] + oy, t.priority, source).within(t.span.map(|[a, b]| [sx * a + ox, sx * b + ox])))
}
_ => None,
};
out.extend(mapped);
}
}
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>,
}
#[cfg(test)]
mod snap_expression_tests {
use super::*;
use serde_json::json;
use std::sync::atomic::{AtomicUsize, Ordering};
struct PositionHost(AtomicUsize);
impl ExprHost for PositionHost {
fn eval(&self, ctx: &EvalCtx, _: &Layer, _: &Property, value: &Value) -> Result<Value, String> {
self.0.fetch_add(1, Ordering::Relaxed);
let v = value.components();
Ok(Value::Vec3([v[0] + 10. * ctx.time.seconds(), v[1], 0.]))
}
}
#[test]
fn snapping_reuses_expressions_within_one_query_but_refreshes_time_and_edits() {
let mut s = crate::Session::default();
s.execute("comp.new", json!({"width":320,"height":180,"duration":5})).unwrap();
let lid = s.execute("layer.newSolid", json!({"width":40,"height":20})).unwrap()["layer"].as_u64().unwrap();
s.execute("prop.setExpression", json!({"layer":lid,"path":"transform/position","expression":"value"})).unwrap();
let cid = s.active_comp_id().unwrap();
let host = PositionHost(AtomicUsize::new(0));
let opts = SnapOptions { layers: true, ..Default::default() };
let query = |s: &crate::Session, time, cached| {
let ctx = EvalCtx { expr: Some(&host), ..EvalCtx::new(&s.project, cid, s.project.comp(cid).unwrap(), time) };
if cached { targets(&ctx, &[], opts) } else { targets_in(&ctx, &[], opts) }
};
let expected = query(&s, Tick::ZERO, false);
assert!(host.0.swap(0, Ordering::Relaxed) > 1, "fixture must visit the same expression for multiple geometry features");
assert_eq!(query(&s, Tick::ZERO, true), expected);
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
let moved = query(&s, Tick::from_seconds_f64(1.), true);
assert_ne!(moved, expected, "a new time must not reuse stale targets");
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
s.execute("prop.set", json!({"layer":lid,"path":"transform/position","value":[200,100,0]})).unwrap();
let edited = query(&s, Tick::ZERO, true);
assert_ne!(edited, expected, "a new project revision must not reuse stale targets");
assert_eq!(host.0.swap(0, Ordering::Relaxed), 1);
}
}