mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-25 10:36:56 -06:00
View > Switch 3D View > Default, Split with New Locked Viewer, Use Display Color Management (sRGB/P3 display in Settings > Previews), Simulate Output (Rec. 709/2020, P3, sRGB, Linear, NTSC, PAL, Mac 1.8, Custom; Preserve RGB). Project Settings: Color Engine (OCIO built-in config with ACEScg/ACES2065-1 working spaces), HDR clip/compand/ tone map, output space incl. Rec. 2100 PQ/HLG (SMPTE ST 2084, BT.2100, BT.2408 reference white; GPU curves too), Time Display Feet + Frames (35mm/16mm) display and entry. OCIO effects read custom .ocio configs (YAML subset; Matrix/File/Exponent/Log/ LogAffine/CDL/Range/Group transforms, roles, displays/views). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
641 lines
24 KiB
Rust
641 lines
24 KiB
Rust
//! 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,
|
|
}
|
|
|
|
/// A transfer curve of a simulated or display profile.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
pub enum Curve {
|
|
Srgb,
|
|
Gamma(f32),
|
|
Linear,
|
|
/// A colour space's own curve (PQ, HLG…).
|
|
Space(space::Curve),
|
|
}
|
|
|
|
impl Curve {
|
|
fn decode(self, v: f32) -> f32 {
|
|
let a = v.abs();
|
|
let l = match self {
|
|
Curve::Srgb => srgb_to_linear(a),
|
|
Curve::Gamma(g) => a.powf(g),
|
|
Curve::Linear => a,
|
|
Curve::Space(c) => c.decode(a),
|
|
};
|
|
l.copysign(v)
|
|
}
|
|
fn encode(self, v: f32) -> f32 {
|
|
let a = v.abs();
|
|
let e = match self {
|
|
Curve::Srgb => linear_to_srgb(a),
|
|
Curve::Gamma(g) => a.powf(1.0 / g),
|
|
Curve::Linear => a,
|
|
Curve::Space(c) => c.encode(a),
|
|
};
|
|
e.copysign(v)
|
|
}
|
|
}
|
|
|
|
const P709: [[f64; 2]; 3] = [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]];
|
|
|
|
impl SimProfile {
|
|
pub const ALL: [SimProfile; 9] = [
|
|
SimProfile::None,
|
|
SimProfile::Rec709,
|
|
SimProfile::Ntsc,
|
|
SimProfile::Pal,
|
|
SimProfile::Mac18,
|
|
SimProfile::Srgb,
|
|
SimProfile::Rec2020,
|
|
SimProfile::P3,
|
|
SimProfile::Linear,
|
|
];
|
|
|
|
pub fn id(self) -> &'static str {
|
|
match self {
|
|
SimProfile::None => "none",
|
|
SimProfile::Rec709 => "rec709",
|
|
SimProfile::Rec2020 => "rec2020",
|
|
SimProfile::P3 => "p3",
|
|
SimProfile::Srgb => "srgb",
|
|
SimProfile::Linear => "linear",
|
|
SimProfile::Ntsc => "ntsc",
|
|
SimProfile::Pal => "pal",
|
|
SimProfile::Mac18 => "mac18",
|
|
}
|
|
}
|
|
|
|
pub fn label(self) -> &'static str {
|
|
match self {
|
|
SimProfile::None => "No Output Simulation",
|
|
SimProfile::Rec709 => "HDTV (Rec. 709)",
|
|
SimProfile::Rec2020 => "UHDTV (Rec. 2020)",
|
|
SimProfile::P3 => "Display P3",
|
|
SimProfile::Srgb => "Internet Standard RGB (sRGB)",
|
|
SimProfile::Linear => "Linear (1.0 Gamma)",
|
|
SimProfile::Ntsc => "SDTV NTSC",
|
|
SimProfile::Pal => "SDTV PAL",
|
|
SimProfile::Mac18 => "Legacy Macintosh RGB (Gamma 1.8)",
|
|
}
|
|
}
|
|
|
|
pub fn parse(s: &str) -> Option<SimProfile> {
|
|
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
|
SimProfile::ALL
|
|
.into_iter()
|
|
.find(|p| p.id() == k || p.label().chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase() == k)
|
|
.or(match k.as_str() {
|
|
"off" => Some(SimProfile::None),
|
|
"hdtv" | "bt709" => Some(SimProfile::Rec709),
|
|
"bt2020" | "uhdtv" => Some(SimProfile::Rec2020),
|
|
"displayp3" => Some(SimProfile::P3),
|
|
"lin" | "linearlight" => Some(SimProfile::Linear),
|
|
_ => None,
|
|
})
|
|
}
|
|
|
|
/// (primaries, curve); `None` for No Output Simulation.
|
|
pub fn space(self) -> Option<([[f64; 2]; 3], Curve)> {
|
|
Some(match self {
|
|
SimProfile::None => return None,
|
|
SimProfile::Rec709 => (P709, Curve::Gamma(2.4)),
|
|
SimProfile::Rec2020 => (ColorSpace::Rec2020.primaries(), Curve::Gamma(2.4)),
|
|
SimProfile::P3 => (ColorSpace::DisplayP3.primaries(), Curve::Srgb),
|
|
SimProfile::Srgb => (P709, Curve::Srgb),
|
|
SimProfile::Linear => (P709, Curve::Linear),
|
|
SimProfile::Ntsc => ([[0.630, 0.340], [0.310, 0.595], [0.155, 0.070]], Curve::Gamma(2.4)),
|
|
SimProfile::Pal => ([[0.640, 0.330], [0.290, 0.600], [0.150, 0.060]], Curve::Gamma(2.4)),
|
|
SimProfile::Mac18 => (P709, Curve::Gamma(1.8)),
|
|
})
|
|
}
|
|
}
|
|
|
|
/// View ▸ Simulate Output setting: the profile, and Preserve RGB (the output numbers are sent
|
|
/// to the display unconverted, as on a device that doesn't colour-manage).
|
|
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
|
#[serde(default)]
|
|
pub struct Simulation {
|
|
pub profile: SimProfile,
|
|
pub preserve_rgb: bool,
|
|
}
|
|
|
|
/// An RGB space for viewer conversions: primaries (D65) and curve.
|
|
#[derive(Clone, Copy, Debug, PartialEq)]
|
|
struct Prof {
|
|
/// Linear RGB → XYZ D65.
|
|
xyz: [[f64; 3]; 3],
|
|
curve: Curve,
|
|
}
|
|
|
|
impl Prof {
|
|
fn new(prims: [[f64; 2]; 3], curve: Curve) -> Prof {
|
|
Prof { xyz: space::rgb_to_xyz(prims, D65), curve }
|
|
}
|
|
/// A colour space (linear when `linear`).
|
|
fn of(cs: ColorSpace, linear: bool) -> Prof {
|
|
let curve = if linear { Curve::Linear } else { Curve::Space(cs.curve()) };
|
|
Prof { xyz: cs.to_xyz(), curve }
|
|
}
|
|
}
|
|
|
|
/// A linear-RGB matrix plus curves between two profiles.
|
|
#[derive(Clone, Copy, Debug)]
|
|
struct Conv {
|
|
from: Curve,
|
|
m: Option<[[f32; 3]; 3]>,
|
|
to: Curve,
|
|
}
|
|
|
|
impl Conv {
|
|
fn new(a: Prof, b: Prof) -> Conv {
|
|
let m = (a.xyz != b.xyz).then(|| {
|
|
let m = space::mul(&space::invert(&b.xyz), &a.xyz);
|
|
m.map(|r| r.map(|v| v as f32))
|
|
});
|
|
Conv { from: a.curve, m, to: b.curve }
|
|
}
|
|
fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
|
let l = c.map(|v| self.from.decode(v));
|
|
let l = match &self.m {
|
|
Some(m) => [0, 1, 2].map(|i| m[i][0] * l[0] + m[i][1] * l[1] + m[i][2] * l[2]),
|
|
None => l,
|
|
};
|
|
l.map(|v| self.to.encode(v))
|
|
}
|
|
}
|
|
|
|
const D65: [f64; 2] = [0.3127, 0.3290];
|
|
|
|
/// The viewer's colour conversion from the rendered frame (sRGB-encoded display pixels) to
|
|
/// what the monitor shows (View ▸ Use Display Color Management, View ▸ Simulate Output and
|
|
/// the display profile in Settings ▸ Previews). `None` when nothing changes.
|
|
#[derive(Clone, Copy, Debug)]
|
|
pub struct DisplayColor {
|
|
sim: Option<(Conv, Option<Conv>)>,
|
|
display: Option<Conv>,
|
|
}
|
|
|
|
impl DisplayColor {
|
|
/// `working`: the project working space (None = unmanaged: nothing is converted, as in
|
|
/// After Effects); `linear`: the working space is linear; `source`: the space rendered
|
|
/// frames are in (the project's output space, sRGB by default); `dcm`: Use Display Color
|
|
/// Management; `display`: the monitor's space.
|
|
pub fn new(working: Option<ColorSpace>, linear: bool, source: ColorSpace, dcm: bool, display: ColorSpace, sim: Simulation) -> Option<DisplayColor> {
|
|
let ws = working?;
|
|
let src = Prof::of(source, false);
|
|
if !dcm {
|
|
// Without display colour management the working space's numbers go to the screen.
|
|
let raw = Conv::new(src, Prof::of(ws, linear));
|
|
return Some(DisplayColor { sim: None, display: Some(raw) });
|
|
}
|
|
let sim = sim.profile.space().map(|(prims, curve)| {
|
|
let out = Prof::new(prims, curve);
|
|
(
|
|
Conv::new(src, out),
|
|
Some(if sim.preserve_rgb {
|
|
Conv::new(Prof::new(P709, Curve::Srgb), Prof::of(display, false))
|
|
} else {
|
|
Conv::new(out, Prof::of(display, false))
|
|
}),
|
|
)
|
|
});
|
|
let display = (display != source).then(|| Conv::new(src, Prof::of(display, false)));
|
|
(sim.is_some() || display.is_some()).then_some(DisplayColor { sim, display })
|
|
}
|
|
|
|
/// The conversion a session's viewer uses.
|
|
pub fn of(s: &crate::Session) -> Option<DisplayColor> {
|
|
let st = &s.project.settings;
|
|
let display = ColorSpace::parse(&s.prefs.previews.display_profile).unwrap_or(ColorSpace::Srgb);
|
|
let v = &s.state.viewer;
|
|
let linear = st.working_space.is_some_and(|w| st.linearize || w.is_linear());
|
|
DisplayColor::new(st.working_space, linear, st.output_space.unwrap_or(ColorSpace::Srgb), v.display_color_management, display, v.simulation)
|
|
}
|
|
|
|
/// One straight colour (0..1 floats).
|
|
pub fn apply_straight(&self, c: [f32; 3]) -> [f32; 3] {
|
|
let c = match &self.sim {
|
|
// Through the output's 8-bit encoding (clipped and quantised like the real output),
|
|
// then shown on the display: converted from the output profile, or — Preserve RGB —
|
|
// the output numbers taken as if they were sRGB.
|
|
Some((to, show)) => {
|
|
let o = to.apply(c).map(|v| (v.clamp(0.0, 1.0) * 255.0).round() / 255.0);
|
|
show.map_or(o, |b| b.apply(o))
|
|
}
|
|
None => self.display.map_or(c, |d| d.apply(c)),
|
|
};
|
|
c.map(|v| v.clamp(0.0, 1.0))
|
|
}
|
|
|
|
/// Premultiplied 8-bit RGBA pixels in place.
|
|
pub fn apply(&self, px: &mut [[u8; 4]]) {
|
|
use rayon::prelude::*;
|
|
px.par_iter_mut().for_each(|p| {
|
|
let a = p[3];
|
|
if a == 0 {
|
|
return;
|
|
}
|
|
let af = a as f32 / 255.0;
|
|
let c = [0, 1, 2].map(|i| (p[i] as f32 / 255.0 / af).min(1.0));
|
|
let o = self.apply_straight(c);
|
|
for i in 0..3 {
|
|
p[i] = (o[i] * af * 255.0 + 0.5) as u8;
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
// ---------------------------------------------------------------- snapshot
|
|
|
|
/// Take Snapshot (Shift+F5): a rendered frame kept for comparison (Show Snapshot, F5).
|
|
#[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>,
|
|
}
|