diff --git a/crates/color/src/blend.rs b/crates/color/src/blend.rs index ee239c3ce..2305f12af 100644 --- a/crates/color/src/blend.rs +++ b/crates/color/src/blend.rs @@ -157,6 +157,37 @@ impl BlendMode { matches!(self, BlendMode::StencilAlpha | BlendMode::StencilLuma | BlendMode::SilhouetteAlpha | BlendMode::SilhouetteLuma) } + /// Modes whose formulas are defined for over-range (HDR, 32 bpc) values. The others clamp + /// their inputs to 0..1 before blending, as After Effects documents for 32 bpc projects + /// (in 8/16 bpc everything is already in range). + pub fn supports_hdr(self) -> bool { + matches!( + self, + BlendMode::Normal + | BlendMode::Dissolve + | BlendMode::DancingDissolve + | BlendMode::Darken + | BlendMode::Multiply + | BlendMode::LinearBurn + | BlendMode::DarkerColor + | BlendMode::Add + | BlendMode::Lighten + | BlendMode::Screen + | BlendMode::LinearDodge + | BlendMode::LighterColor + | BlendMode::Difference + | BlendMode::ClassicDifference + | BlendMode::Subtract + | BlendMode::Divide + | BlendMode::StencilAlpha + | BlendMode::StencilLuma + | BlendMode::SilhouetteAlpha + | BlendMode::SilhouetteLuma + | BlendMode::AlphaAdd + | BlendMode::LuminescentPremul + ) + } + pub fn next(self) -> BlendMode { let i = BlendMode::ALL.iter().position(|m| *m == self).unwrap_or(0); BlendMode::ALL[(i + 1) % BlendMode::ALL.len()] @@ -364,8 +395,12 @@ pub fn blend_pixel(mode: BlendMode, dst: [f32; 4], src: [f32; 4], noise: f32) -> if da <= 0.0 { return src; } - let cs = [src[0] / sa, src[1] / sa, src[2] / sa]; - let cb = [dst[0] / da, dst[1] / da, dst[2] / da]; + let mut cs = [src[0] / sa, src[1] / sa, src[2] / sa]; + let mut cb = [dst[0] / da, dst[1] / da, dst[2] / da]; + if !mode.supports_hdr() { + cs = cs.map(|v| v.clamp(0.0, 1.0)); + cb = cb.map(|v| v.clamp(0.0, 1.0)); + } let b = non_separable(mode, cb, cs); let ao = sa + da - sa * da; let mut o = [0.0f32; 4]; @@ -432,6 +467,16 @@ mod tests { assert_eq!(blend_pixel(BlendMode::SilhouetteAlpha, d, [0.0, 0.0, 0.0, 0.0], 0.0), d); } + #[test] + fn hdr_modes() { + // Add and Screen keep over-range values (32 bpc); Overlay clamps its inputs. + let d = [2.0, 2.0, 2.0, 1.0]; + let s = [0.5, 0.5, 0.5, 1.0]; + assert!((blend_pixel(BlendMode::Add, d, s, 0.5)[0] - 2.5).abs() < 1e-6); + assert!((blend_pixel(BlendMode::Screen, d, s, 0.5)[0] - 1.5).abs() < 1e-6); + assert!(blend_pixel(BlendMode::Overlay, d, s, 0.5)[0] <= 1.0); + } + #[test] fn names_roundtrip() { for m in BlendMode::ALL { diff --git a/crates/color/src/lib.rs b/crates/color/src/lib.rs index e292d9177..9212b020e 100644 --- a/crates/color/src/lib.rs +++ b/crates/color/src/lib.rs @@ -6,9 +6,11 @@ //! colour and composited with Porter-Duff source-over on premultiplied pixels. pub mod blend; +pub mod space; pub use blend::{BlendMode, blend_pixel}; use serde::{Deserialize, Serialize}; +pub use space::{ColorSpace, Conversion}; /// Straight (un-premultiplied) RGBA, 0..1 nominal (32 bpc may exceed). #[derive(Clone, Copy, Debug, Default, PartialEq, Serialize, Deserialize)] diff --git a/crates/color/src/space.rs b/crates/color/src/space.rs new file mode 100644 index 000000000..2bc935135 --- /dev/null +++ b/crates/color/src/space.rs @@ -0,0 +1,273 @@ +//! RGB colour spaces for colour management: the project working space, footage interpretation +//! and the display. +//! +//! Each space is defined by its published primaries and white point and its transfer function: +//! +//! * **sRGB IEC 61966-2-1** — Rec. 709 primaries, D65, the piecewise sRGB curve. +//! * **Rec. 709** — ITU-R BT.709-6 primaries, D65, the BT.1886 display EOTF (gamma 2.4). +//! * **Rec. 2020** — ITU-R BT.2020-2 primaries, D65, BT.1886 (gamma 2.4). +//! * **Display P3** — SMPTE EG 432-1 (DCI-P3) primaries with D65 and the sRGB curve. +//! +//! RGB → XYZ matrices are derived from the primaries (the standard construction from chromaticity +//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), so +//! conversions between any two spaces are exact up to `f64` rounding. Transfer functions extend +//! to over-range and negative values (32 bpc) by mirroring around zero. + +use serde::{Deserialize, Serialize}; + +/// An RGB colour space (all D65). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum ColorSpace { + /// sRGB IEC 61966-2-1. + Srgb, + /// HDTV Rec. 709 (gamma 2.4). + Rec709, + /// UHDTV Rec. 2020 (gamma 2.4). + Rec2020, + /// Display P3. + DisplayP3, +} + +/// CIE xy chromaticity of D65. +const D65: [f64; 2] = [0.3127, 0.3290]; + +impl ColorSpace { + pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3]; + + /// Display name (as in Project Settings ▸ Color ▸ Working Space). + pub fn label(self) -> &'static str { + match self { + ColorSpace::Srgb => "sRGB IEC61966-2.1", + ColorSpace::Rec709 => "HDTV (Rec. 709)", + ColorSpace::Rec2020 => "Rec. 2020", + ColorSpace::DisplayP3 => "Display P3", + } + } + + /// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`). + pub fn id(self) -> &'static str { + match self { + ColorSpace::Srgb => "srgb", + ColorSpace::Rec709 => "rec709", + ColorSpace::Rec2020 => "rec2020", + ColorSpace::DisplayP3 => "p3", + } + } + + /// Parse an id or label (case-insensitive, loose). + pub fn parse(s: &str) -> Option { + let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::().to_ascii_lowercase(); + match k.as_str() { + "srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb), + "rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709), + "rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020), + "p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3), + _ => None, + } + } + + /// Red, green and blue primaries (CIE xy). + pub fn primaries(self) -> [[f64; 2]; 3] { + match self { + ColorSpace::Srgb | ColorSpace::Rec709 => [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]], + ColorSpace::Rec2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]], + ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]], + } + } + + /// Linear RGB → CIE XYZ (Y of white = 1). + pub fn to_xyz(self) -> [[f64; 3]; 3] { + rgb_to_xyz(self.primaries(), D65) + } + + /// Encoded value → linear light. + pub fn decode(self, v: f32) -> f32 { + let a = v.abs(); + let l = match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::srgb_to_linear(a), + ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(2.4), + }; + l.copysign(v) + } + + /// Linear light → encoded value. + pub fn encode(self, v: f32) -> f32 { + let a = v.abs(); + let e = match self { + ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::linear_to_srgb(a), + ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(1.0 / 2.4), + }; + e.copysign(v) + } +} + +/// The standard RGB → XYZ matrix from primaries and white point. +pub fn rgb_to_xyz(p: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] { + let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]]; + let (r, g, b) = (xyz(p[0]), xyz(p[1]), xyz(p[2])); + let m = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]]; + let w = xyz(white); + let s = mul_vec(&invert(&m), w); + let mut o = m; + for row in &mut o { + for j in 0..3 { + row[j] *= s[j]; + } + } + o +} + +/// 3×3 matrix inverse (the matrices here are always well conditioned). +pub fn invert(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0]; + let det = m[0][0] * c(1, 1, 2, 2) - m[0][1] * c(1, 0, 2, 2) + m[0][2] * c(1, 0, 2, 1); + let k = 1.0 / det; + [ + [c(1, 1, 2, 2) * k, -c(0, 1, 2, 2) * k, c(0, 1, 1, 2) * k], + [-c(1, 0, 2, 2) * k, c(0, 0, 2, 2) * k, -c(0, 0, 1, 2) * k], + [c(1, 0, 2, 1) * k, -c(0, 0, 2, 1) * k, c(0, 0, 1, 1) * k], + ] +} + +pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] { + let mut o = [[0.0; 3]; 3]; + for i in 0..3 { + for j in 0..3 { + o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum(); + } + } + o +} + +pub fn mul_vec(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] { + [0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2]) +} + +/// A colour conversion between two spaces, each either encoded (with its transfer curve) or +/// linear. Applied to straight (un-premultiplied) RGB. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Conversion { + /// Decode the input with this space's curve first. + pub decode: Option, + /// Linear RGB matrix (None = identity primaries). + pub matrix: Option<[[f32; 3]; 3]>, + /// Encode the output with this space's curve last. + pub encode: Option, +} + +impl Conversion { + /// From `from` (linear when `from_linear`) to `to` (linear when `to_linear`). `None` when + /// the conversion is the identity. + pub fn new(from: ColorSpace, from_linear: bool, to: ColorSpace, to_linear: bool) -> Option { + let same_primaries = from.primaries() == to.primaries(); + let same_curve = from_linear == to_linear && (from_linear || curve_eq(from, to)); + if same_primaries && same_curve { + return None; + } + let matrix = (!same_primaries).then(|| { + let m = mul(&invert(&to.to_xyz()), &from.to_xyz()); + m.map(|r| r.map(|v| v as f32)) + }); + // Same primaries, both encoded with different curves (sRGB ↔ Rec. 709), or a matrix in + // between: go through linear. + let decode = (!from_linear).then_some(from); + let encode = (!to_linear).then_some(to); + Some(Conversion { decode, matrix, encode }) + } + + /// Decode only (encoded → linear in the same space). + pub fn linearize(space: ColorSpace) -> Conversion { + Conversion { decode: Some(space), matrix: None, encode: None } + } + + /// Encode only (linear → encoded in the same space). + pub fn delinearize(space: ColorSpace) -> Conversion { + Conversion { decode: None, matrix: None, encode: Some(space) } + } + + #[inline] + pub fn apply(&self, c: [f32; 3]) -> [f32; 3] { + let mut c = c; + if let Some(s) = self.decode { + c = c.map(|v| s.decode(v)); + } + if let Some(m) = &self.matrix { + c = [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]); + } + if let Some(s) = self.encode { + c = c.map(|v| s.encode(v)); + } + c + } +} + +fn curve_eq(a: ColorSpace, b: ColorSpace) -> bool { + let srgb = |s: ColorSpace| matches!(s, ColorSpace::Srgb | ColorSpace::DisplayP3); + srgb(a) == srgb(b) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn srgb_matrix_matches_iec() { + // IEC 61966-2-1 (rounded to 4 places). + let m = ColorSpace::Srgb.to_xyz(); + let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]]; + for i in 0..3 { + for j in 0..3 { + assert!((m[i][j] - want[i][j]).abs() < 2e-4, "{i}{j}: {} vs {}", m[i][j], want[i][j]); + } + } + // Rec. 2020 luminance row (BT.2020: 0.2627, 0.6780, 0.0593). + let y = ColorSpace::Rec2020.to_xyz()[1]; + assert!((y[0] - 0.2627).abs() < 1e-4 && (y[1] - 0.6780).abs() < 1e-4 && (y[2] - 0.0593).abs() < 1e-4, "{y:?}"); + } + + #[test] + fn round_trips() { + let samples = [[0.2f32, 0.5, 0.8], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.03, 0.9, 0.4], [1.5, 0.2, 2.0]]; + for a in ColorSpace::ALL { + for b in ColorSpace::ALL { + for (al, bl) in [(false, false), (true, false), (false, true), (true, true)] { + let fwd = Conversion::new(a, al, b, bl); + let back = Conversion::new(b, bl, a, al); + assert_eq!(fwd.is_none(), back.is_none()); + for c in samples { + let mid = fwd.map(|f| f.apply(c)).unwrap_or(c); + let o = back.map(|f| f.apply(mid)).unwrap_or(mid); + for i in 0..3 { + // Pure power curves amplify f32 rounding near zero. + assert!((o[i] - c[i]).abs() < 2e-3, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}"); + } + } + } + } + } + } + + #[test] + fn white_is_preserved_and_gamut_grows() { + let c = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, false).unwrap(); + let w = c.apply([1.0, 1.0, 1.0]); + assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}"); + // Pure sRGB red sits inside Rec. 2020: less saturated there. + let r = c.apply([1.0, 0.0, 0.0]); + assert!(r[0] < 1.0 && r[1] > 0.0 && r[2] > 0.0, "{r:?}"); + // sRGB ↔ Rec. 709 share primaries: only the curve changes. + let k = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec709, false).unwrap(); + assert!(k.matrix.is_none()); + assert!(Conversion::new(ColorSpace::Srgb, true, ColorSpace::Rec709, true).is_none()); + } + + #[test] + fn transfer_extends_over_range() { + let s = ColorSpace::Srgb; + assert!((s.decode(s.encode(4.0)) - 4.0).abs() < 1e-4); + assert!((s.decode(s.encode(-0.25)) + 0.25).abs() < 1e-5); + assert!((ColorSpace::Rec709.decode(0.5) - 0.5f32.powf(2.4)).abs() < 1e-6); + assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb)); + assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3)); + } +} diff --git a/crates/engine/src/commands/file.rs b/crates/engine/src/commands/file.rs index 92e8836e0..ee61f9007 100644 --- a/crates/engine/src/commands/file.rs +++ b/crates/engine/src/commands/file.rs @@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result { if let Some(l) = p.get("linearize").and_then(Value::as_bool) { proj.settings.linearize = l; } + if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) { + proj.settings.blend_linear = l; + } + if let Some(w) = p.get("workingSpace") { + proj.settings.working_space = match w.as_str() { + None | Some("none" | "None" | "") => None, + Some(n) => Some( + effectcraft_project::ColorSpace::parse(n) + .ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?, + ), + }; + } if let Some(t) = str_p(p, "timeDisplay") { proj.settings.time_display = if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode }; @@ -172,7 +184,7 @@ pub fn specs() -> Vec { "Project Settings...", ["File"], Some("Cmd+Alt+Shift+K"), - "{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}", + "{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}", always, project_settings ), diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index 0180d2798..87c1e2dff 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage { codec: "Placeholder".into(), missing: true, sequence: vec![], + color_profile: None, } } @@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result { }; let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32); let par = f_p(p, "pixelAspect"); + // Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB). + let profile = match str_p(p, "colorProfile") { + Some("auto" | "none" | "") => Some(None), + Some(c) => match effectcraft_project::ColorSpace::parse(c) { + Some(cs) => Some(Some(cs)), + None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))), + }, + None => None, + }; s.edit("Interpret Footage", None, |proj, _| { for i in &items { if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) { @@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result { if let Some(x) = par { f.pixel_aspect = x; } + if let Some(c) = profile { + f.color_profile = c; + } } } Ok(()) @@ -634,7 +647,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}", + "{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}", has_footage_selection, interpret ), diff --git a/crates/engine/src/commands/layer.rs b/crates/engine/src/commands/layer.rs index 09a50660c..38a09b07f 100644 --- a/crates/engine/src/commands/layer.rs +++ b/crates/engine/src/commands/layer.rs @@ -629,6 +629,57 @@ fn timing(s: &mut Session, p: &Value) -> Result { Ok(Value::Null) } +/// Slip edit: move a layer's source (footage or precomp) under its fixed in and out points, like +/// dragging the source-duration bar in After Effects. `delta` in seconds or `frames`; positive +/// moves the source later. Clamped so the source still covers the in–out span. Keyframes and +/// layer markers are in layer time, so they travel with the source. +fn slip(s: &mut Session, p: &Value) -> Result { + let (cid, ids) = layers_p(s, p)?; + let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?; + let fr = comp.frame_rate; + let delta = match (p.get("frames").and_then(Value::as_i64), f_p(p, "delta")) { + (Some(f), _) => fr.tick_of(f), + (None, Some(d)) => fr.snap_nearest(Tick::from_seconds_f64(d)), + _ => return Err(bad("layer.slip", "delta (seconds) or frames required")), + }; + // Allowed shift per layer: the source span [lo, hi] in comp time must keep covering in..out. + let mut shifts: Vec<(LayerId, Tick)> = Vec::new(); + for l in comp.layers.iter().filter(|l| ids.contains(&l.id)) { + if l.props.get("timeRemap").is_some() { + continue; + } + let dur = match &l.source { + LayerSource::Footage { item } => match s.project.item(*item).map(|i| &i.kind) { + Some(ItemKind::Footage(f)) if f.kind != effectcraft_project::FootageKind::Still => Some(Tick(f.duration.0 * f.loop_count.max(1) as i64)), + _ => None, + }, + LayerSource::Comp { item } => s.project.comp(*item).map(|c| c.duration), + _ => None, + }; + let Some(dur) = dur else { continue }; + let (a, b) = (l.comp_time(Tick::ZERO), l.comp_time(dur)); + let (lo, hi) = (a.min(b), a.max(b)); + let (min_d, max_d) = (l.out_point - hi, l.in_point - lo); + if min_d > max_d { + continue; + } + shifts.push((l.id, delta.max(min_d).min(max_d))); + } + if shifts.is_empty() { + return Err(bad("layer.slip", "select a footage or precomp layer (without time remapping)")); + } + s.edit("Slip Edit", merge_p(p), |proj, _| { + let comp = proj.comp_mut(cid).ok_or(EngineError::NoComp)?; + for (id, d) in &shifts { + if let Some(l) = comp.layer_mut(*id) { + l.start_time += *d; + } + } + Ok(()) + })?; + Ok(json!({"slipped": shifts.iter().map(|(id, d)| json!({"layer": id.0, "delta": d.seconds()})).collect::>()})) +} + fn arrange(s: &mut Session, p: &Value) -> Result { let (cid, ids) = layers_p(s, p)?; let how = str_p(p, "to").unwrap_or("front").to_string(); @@ -970,6 +1021,17 @@ pub fn specs() -> Vec { has_layers, timing ), + cmd!("layer.slip", "Slip Edit", [], None, "{layers?, delta? (seconds) | frames?, merge?}", has_layers, slip), + cmd!("layer.slipBack", "Slip Edit 1 Frame Earlier", [], Some("Alt+PageUp"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(-1); + slip(s, &p) + }), + cmd!("layer.slipForward", "Slip Edit 1 Frame Later", [], Some("Alt+PageDown"), "{layers?}", has_layers, |s, p| { + let mut p = p.clone(); + p["frames"] = json!(1); + slip(s, &p) + }), cmd!("layer.arrange", "Arrange", ["Layer", "Arrange"], None, "{layers?, to: front|forward|backward|back, index?}", has_layers, arrange), cmd!("layer.precompose", "Pre-compose...", ["Layer"], Some("Cmd+Shift+C"), "{layers?, name?}", has_layers, precompose), cmd!( diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 8b5c5f43b..564dbc1bd 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -520,6 +520,8 @@ mod tests_3d; #[cfg(test)] mod tests_effects; #[cfg(test)] +mod tests_fidelity; +#[cfg(test)] mod tests_lottie; #[cfg(test)] mod tests_menu_cmds; diff --git a/crates/engine/src/tests_fidelity.rs b/crates/engine/src/tests_fidelity.rs new file mode 100644 index 000000000..e5e84c03d --- /dev/null +++ b/crates/engine/src/tests_fidelity.rs @@ -0,0 +1,83 @@ +//! Render-fidelity commands: project colour settings, footage colour profile, slip edit. + +use effectcraft_project::{BitDepth, ColorSpace}; +use serde_json::json; + +use crate::Session; + +#[test] +fn project_colour_settings_are_undoable() { + let mut s = Session::default(); + s.execute("file.projectSettings", json!({"bitDepth": 32, "workingSpace": "rec2020", "linearize": true, "blendLinear": true})).unwrap(); + let st = &s.project.settings; + assert_eq!((st.bit_depth, st.working_space, st.linearize, st.blend_linear), (BitDepth::Bpc32, Some(ColorSpace::Rec2020), true, true)); + s.execute("file.projectSettings", json!({"workingSpace": "Display P3"})).unwrap(); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3)); + s.execute("file.projectSettings", json!({"workingSpace": "none"})).unwrap(); + assert_eq!(s.project.settings.working_space, None); + assert!(s.execute("file.projectSettings", json!({"workingSpace": "acescg-ish"})).is_err()); + assert!(s.undo()); + assert_eq!(s.project.settings.working_space, Some(ColorSpace::DisplayP3)); + assert!(s.undo() && s.undo()); + assert_eq!(s.project.settings.bit_depth, BitDepth::Bpc8); + assert!(!s.project.settings.blend_linear); + // The Project panel's depth button cycles 8 → 16 → 32 → 8. + for want in ["16 bpc", "32 bpc", "8 bpc"] { + assert_eq!(s.execute("file.cycleBitDepth", json!({})).unwrap(), json!(want)); + } +} + +#[test] +fn footage_colour_profile_is_interpreted() { + let mut s = Session::default(); + let id = s.execute("file.importPlaceholder", json!({"name": "clip", "width": 64, "height": 36, "frameRate": 24, "duration": 2})).unwrap(); + let item = id["item"].as_u64().or_else(|| id.as_u64()).map(effectcraft_project::ItemId).unwrap_or_else(|| *s.project.items.keys().last().unwrap()); + let profile = |s: &Session| match &s.project.item(item).unwrap().kind { + effectcraft_project::ItemKind::Footage(f) => f.color_profile, + _ => panic!("not footage"), + }; + s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "rec2020"})).unwrap(); + assert_eq!(profile(&s), Some(ColorSpace::Rec2020)); + s.execute("file.interpretFootage", json!({"items": [item.0], "colorProfile": "auto"})).unwrap(); + assert_eq!(profile(&s), None); + assert!(s.undo()); + assert_eq!(profile(&s), Some(ColorSpace::Rec2020)); +} + +#[test] +fn slip_moves_the_source_under_fixed_in_and_out() { + use effectcraft_time::Tick; + let mut s = Session::default(); + s.execute("comp.new", json!({"name": "Main", "width": 100, "height": 100, "frameRate": 30, "duration": 10})).unwrap(); + let nested = s.execute("comp.new", json!({"name": "Nested", "duration": 4, "open": false})).unwrap()["comp"].as_u64().unwrap(); + s.execute("layer.addItem", json!({"item": nested})).unwrap(); + let id = s.active_comp().unwrap().layers[0].id.0; + // Trim to 1 s .. 3 s of a 4 s source. + s.execute("layer.timing", json!({"layers": [id], "in": 1.0, "out": 3.0})).unwrap(); + let get = |s: &Session| s.active_comp().unwrap().layers[0].clone(); + let before = get(&s); + assert_eq!((before.start_time, before.in_point.seconds(), before.out_point.seconds()), (Tick::ZERO, 1.0, 3.0)); + // Slip the source 0.5 s later: in/out stay, the source (start) moves. + s.execute("layer.slip", json!({"layers": [id], "delta": 0.5})).unwrap(); + let l = get(&s); + assert_eq!((l.start_time.seconds(), l.in_point, l.out_point), (0.5, before.in_point, before.out_point)); + // The source time at comp 2 s is now 1.5 s. + assert_eq!(l.layer_time(Tick::from_seconds_f64(2.0)).seconds(), 1.5); + // Clamped so the source still covers in..out: at most start = in (1 s). + let r = s.execute("layer.slip", json!({"layers": [id], "delta": 5.0})).unwrap(); + assert_eq!(get(&s).start_time.seconds(), 1.0); + assert_eq!(r["slipped"][0]["delta"], json!(0.5)); + // Alt+PageUp / Alt+PageDown nudge by a frame. + s.execute("layer.slipBack", json!({"layers": [id]})).unwrap(); + assert!((get(&s).start_time.seconds() - (1.0 - 1.0 / 30.0)).abs() < 1e-9); + s.execute("layer.slipForward", json!({"layers": [id]})).unwrap(); + assert_eq!(get(&s).start_time.seconds(), 1.0); + // Undo walks back through the slips. + assert!(s.undo() && s.undo() && s.undo() && s.undo()); + assert_eq!(get(&s).start_time, Tick::ZERO); + assert_eq!(crate::find_command("layer.slipBack").and_then(|c| c.shortcut), Some("Alt+PageUp")); + assert_eq!(crate::find_command("layer.slipForward").and_then(|c| c.shortcut), Some("Alt+PageDown")); + // Layers without a source duration can't slip. + s.execute("layer.newSolid", json!({"color": "#ff0000"})).unwrap(); + assert!(s.execute("layer.slip", json!({"delta": 0.5})).is_err()); +} diff --git a/crates/engine/src/tests_stubs.rs b/crates/engine/src/tests_stubs.rs index c1356ff2e..141858caa 100644 --- a/crates/engine/src/tests_stubs.rs +++ b/crates/engine/src/tests_stubs.rs @@ -256,6 +256,7 @@ impl Importer for MockImporter { codec: "mock".into(), missing: false, sequence: vec![], + color_profile: None, }) } } diff --git a/crates/engine/src/tests_track.rs b/crates/engine/src/tests_track.rs index b3c2a1747..7ea69cfa6 100644 --- a/crates/engine/src/tests_track.rs +++ b/crates/engine/src/tests_track.rs @@ -100,6 +100,7 @@ fn setup(make: impl Fn(u32) -> Image + Send + Sync + 'static) -> (Session, Layer codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); @@ -408,6 +409,7 @@ fn analyze_1080p_single_point_speed() { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = p.add_item("hd", Label::Aqua, None, ItemKind::Footage(footage)); let comp = p.comp(cid).unwrap().clone(); diff --git a/crates/lottie/src/import.rs b/crates/lottie/src/import.rs index 9ad38f394..c87dc1879 100644 --- a/crates/lottie/src/import.rs +++ b/crates/lottie/src/import.rs @@ -170,6 +170,7 @@ pub fn import( codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let item = project.add_item(&nm, Label::Lavender, Some(folder), ItemKind::Footage(foot)); cx.items.push(item); diff --git a/crates/lottie/src/tests.rs b/crates/lottie/src/tests.rs index 0cd38a096..5140133c8 100644 --- a/crates/lottie/src/tests.rs +++ b/crates/lottie/src/tests.rs @@ -591,6 +591,7 @@ fn images_embed_as_base64_and_come_back() { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }), ); let c = comp(&p, cid); diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index a248cfc67..4e1c56110 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -71,6 +71,20 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) - codec, missing: false, sequence: Vec::new(), + color_profile: v.and_then(|v| profile_of(&v.color)), + } +} + +/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or +/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve. +fn profile_of(c: &filmcraft_color::ColorInfo) -> Option { + use effectcraft_project::ColorSpace; + use filmcraft_color::{Primaries, Transfer}; + match (c.primaries, c.transfer) { + (Primaries::Bt2020, _) => Some(ColorSpace::Rec2020), + (Primaries::P3D65, _) => Some(ColorSpace::DisplayP3), + (_, Transfer::Bt709) => Some(ColorSpace::Rec709), + _ => None, } } @@ -99,6 +113,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha: codec, missing: false, sequence: Vec::new(), + color_profile: None, } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 722f3fe19..88fc6d02a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -13,6 +13,7 @@ pub mod tracking; use std::collections::BTreeMap; use std::sync::Arc; +pub use effectcraft_color::ColorSpace; use effectcraft_color::{BlendMode, Label}; use effectcraft_time::{FrameRate, Tick}; pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path}; @@ -70,6 +71,22 @@ impl BitDepth { pub fn is_float(self) -> bool { self == BitDepth::Bpc32 } + /// Quantisation levels per channel of the integer depths (8 bpc: 255; 16 bpc: 32768, After + /// Effects' "15 + 1" bit range), `None` for 32 bpc float. + pub fn levels(self) -> Option { + match self { + BitDepth::Bpc8 => Some(255.0), + BitDepth::Bpc16 => Some(32768.0), + BitDepth::Bpc32 => None, + } + } + pub fn bits(self) -> u32 { + match self { + BitDepth::Bpc8 => 8, + BitDepth::Bpc16 => 16, + BitDepth::Bpc32 => 32, + } + } } #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] @@ -82,8 +99,16 @@ pub enum TimeDisplayStyle { #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct ProjectSettings { pub bit_depth: BitDepth, - /// Blend in linear light instead of the sRGB-encoded working space. + /// Linearize Working Space: the working space uses linear light (1.0 gamma) for everything + /// (sources, effects, blending). Needs a working space. pub linearize: bool, + /// Working Space (colour management). `None` = no colour management. + #[serde(default)] + pub working_space: Option, + /// Blend Colors Using 1.0 Gamma: layers blend in linear light (sources and effects stay in + /// the working space's encoding). + #[serde(default)] + pub blend_linear: bool, pub time_display: TimeDisplayStyle, /// Frame numbering starts at 0 (or 1). pub frame_start: i64, @@ -92,7 +117,15 @@ pub struct ProjectSettings { impl Default for ProjectSettings { fn default() -> Self { - ProjectSettings { bit_depth: BitDepth::Bpc8, linearize: false, time_display: TimeDisplayStyle::Timecode, frame_start: 0, audio_sample_rate: 48_000 } + ProjectSettings { + bit_depth: BitDepth::Bpc8, + linearize: false, + working_space: None, + blend_linear: false, + time_display: TimeDisplayStyle::Timecode, + frame_start: 0, + audio_sample_rate: 48_000, + } } } @@ -520,6 +553,9 @@ pub struct Footage { /// Image sequence files (when kind = Sequence). #[serde(default, skip_serializing_if = "Vec::is_empty")] pub sequence: Vec, + /// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub color_profile: Option, } fn one() -> u32 { diff --git a/crates/raster/src/flow.rs b/crates/raster/src/flow.rs new file mode 100644 index 000000000..c398eadd2 --- /dev/null +++ b/crates/raster/src/flow.rs @@ -0,0 +1,290 @@ +//! Optical flow by block matching, and motion-compensated frame interpolation (the Pixel Motion +//! mode of frame blending). +//! +//! The method is the textbook hierarchical block matcher used in video coding and frame-rate +//! conversion: build a luma pyramid of both frames; at the coarsest level, find for each block of +//! frame A the integer displacement into frame B with the smallest sum of absolute differences +//! (SAD) in a search window; at each finer level, start from the doubled coarse vectors (and the +//! neighbours' vectors) and refine in a small window; finish with a sub-pixel parabola fit of the +//! SAD around the best vector and a 3×3 vector median to remove outliers. +//! +//! Interpolation at a fraction `w` between the frames warps both towards the in-between time — +//! A forward by `w·F`, B back by `(1 − w)·F` — and cross-fades them, so moving detail stays sharp +//! instead of double-exposing as with a plain frame mix. + +use rayon::prelude::*; + +use crate::Image; + +/// A block motion field: one vector per `block × block` tile of frame A (in pixels, A → B). +#[derive(Clone, Debug, Default, PartialEq)] +pub struct Flow { + pub width: u32, + pub height: u32, + pub block: u32, + /// Tiles across / down. + pub cols: usize, + pub rows: usize, + pub v: Vec<[f32; 2]>, +} + +impl Flow { + /// The motion at pixel position (x, y), bilinearly interpolated between tile centres. + pub fn at(&self, x: f64, y: f64) -> [f32; 2] { + if self.v.is_empty() { + return [0.0; 2]; + } + let b = self.block as f64; + let fx = (x / b - 0.5).clamp(0.0, (self.cols - 1) as f64); + let fy = (y / b - 0.5).clamp(0.0, (self.rows - 1) as f64); + let (x0, y0) = (fx.floor() as usize, fy.floor() as usize); + let (x1, y1) = ((x0 + 1).min(self.cols - 1), (y0 + 1).min(self.rows - 1)); + let (tx, ty) = ((fx - x0 as f64) as f32, (fy - y0 as f64) as f32); + let g = |c: usize, r: usize| self.v[r * self.cols + c]; + let mut o = [0.0; 2]; + for i in 0..2 { + let top = g(x0, y0)[i] + (g(x1, y0)[i] - g(x0, y0)[i]) * tx; + let bot = g(x0, y1)[i] + (g(x1, y1)[i] - g(x0, y1)[i]) * tx; + o[i] = top + (bot - top) * ty; + } + o + } +} + +/// A single-channel plane. +struct Plane { + w: usize, + h: usize, + d: Vec, +} + +impl Plane { + fn luma(img: &Image) -> Plane { + let d = img.data.par_iter().map(|p| 0.2126 * p[0] + 0.7152 * p[1] + 0.0722 * p[2] + 0.25 * p[3]).collect(); + Plane { w: img.width as usize, h: img.height as usize, d } + } + fn half(&self) -> Plane { + let (w, h) = ((self.w / 2).max(1), (self.h / 2).max(1)); + let d = (0..w * h) + .into_par_iter() + .map(|i| { + let (x, y) = (i % w * 2, i / w * 2); + let g = |dx: usize, dy: usize| self.d[(y + dy).min(self.h - 1) * self.w + (x + dx).min(self.w - 1)]; + (g(0, 0) + g(1, 0) + g(0, 1) + g(1, 1)) * 0.25 + }) + .collect(); + Plane { w, h, d } + } + #[inline] + fn get(&self, x: i64, y: i64) -> f32 { + self.d[y.clamp(0, self.h as i64 - 1) as usize * self.w + x.clamp(0, self.w as i64 - 1) as usize] + } +} + +/// SAD of the `bs` block at (bx, by) in `a` against `b` displaced by (dx, dy). +fn sad(a: &Plane, b: &Plane, bx: i64, by: i64, bs: i64, dx: i64, dy: i64) -> f32 { + let mut s = 0.0; + for y in by..by + bs { + for x in bx..bx + bs { + s += (a.get(x, y) - b.get(x + dx, y + dy)).abs(); + } + } + s +} + +/// Block-matching flow from `a` to `b` (same size). `block` is the tile size at full resolution; +/// `radius` the search range at the coarsest level (motion up to about `radius · 2^levels`). +pub fn block_flow(a: &Image, b: &Image, block: u32, radius: i32) -> Flow { + let bs = block.max(4) as usize; + let (w, h) = (a.width as usize, a.height as usize); + let cols = w.div_ceil(bs).max(1); + let rows = h.div_ceil(bs).max(1); + let mut flow = Flow { width: a.width, height: a.height, block: bs as u32, cols, rows, v: vec![[0.0; 2]; cols * rows] }; + if a.width != b.width || a.height != b.height || w < bs || h < bs { + return flow; + } + // Pyramids (level 0 = full resolution); stop while blocks still fit. + let mut pa = vec![Plane::luma(a)]; + let mut pb = vec![Plane::luma(b)]; + while pa.len() < 4 && pa.last().is_some_and(|p| p.w / 2 >= bs * 2 && p.h / 2 >= bs * 2) { + let (na, nb) = (pa.last().map(Plane::half), pb.last().map(Plane::half)); + pa.extend(na); + pb.extend(nb); + } + let top = pa.len() - 1; + // Integer vectors at the current level, per full-resolution tile. + let mut cur: Vec<[i64; 2]> = vec![[0, 0]; cols * rows]; + for level in (0..=top).rev() { + let (la, lb) = (&pa[level], &pb[level]); + let k = 1i64 << level; + // The block covers the tile's footprint at this level (at least 4 pixels). + let lbs = ((bs as i64) / k).max(4); + let r = if level == top { radius as i64 } else { 1 }; + let prev = cur.clone(); + cur = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, rr) = ((i % cols) as i64, (i / cols) as i64); + let bx = c * bs as i64 / k; + let by = rr * bs as i64 / k; + // Candidates: this tile's and its neighbours' vectors from the coarser level. + let mut seeds: Vec<[i64; 2]> = vec![[0, 0]]; + if level != top { + for (dc, dr) in [(0, 0), (-1, 0), (1, 0), (0, -1), (0, 1)] { + let (nc, nr) = (c + dc, rr + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = prev[nr as usize * cols + nc as usize]; + seeds.push([v[0] * 2, v[1] * 2]); + } + } + } + seeds.sort_unstable(); + seeds.dedup(); + let mut best = ([0i64, 0i64], f32::INFINITY); + for s in seeds { + for dy in -r..=r { + for dx in -r..=r { + let d = [s[0] + dx, s[1] + dy]; + // A tiny bias towards short vectors breaks ties in flat areas. + let e = sad(la, lb, bx, by, lbs, d[0], d[1]) + 1e-4 * (d[0].abs() + d[1].abs()) as f32; + if e < best.1 { + best = (d, e); + } + } + } + } + best.0 + }) + .collect(); + } + // Sub-pixel refinement (parabola through the SAD of the neighbours) at full resolution. + let (la, lb) = (&pa[0], &pb[0]); + let bsi = bs as i64; + let fine: Vec<[f32; 2]> = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (bx, by) = ((i % cols) as i64 * bsi, (i / cols) as i64 * bsi); + let d = cur[i]; + let e0 = sad(la, lb, bx, by, bsi, d[0], d[1]); + let fit = |em: f32, ep: f32| { + let den = em - 2.0 * e0 + ep; + if den > 1e-6 { (0.5 * (em - ep) / den).clamp(-0.5, 0.5) } else { 0.0 } + }; + let sx = fit(sad(la, lb, bx, by, bsi, d[0] - 1, d[1]), sad(la, lb, bx, by, bsi, d[0] + 1, d[1])); + let sy = fit(sad(la, lb, bx, by, bsi, d[0], d[1] - 1), sad(la, lb, bx, by, bsi, d[0], d[1] + 1)); + [d[0] as f32 + sx, d[1] as f32 + sy] + }) + .collect(); + // 3×3 vector median (component-wise) against outliers. + flow.v = (0..cols * rows) + .into_par_iter() + .map(|i| { + let (c, r) = ((i % cols) as i64, (i / cols) as i64); + let mut xs = Vec::with_capacity(9); + let mut ys = Vec::with_capacity(9); + for dr in -1..=1 { + for dc in -1..=1 { + let (nc, nr) = (c + dc, r + dr); + if nc >= 0 && nr >= 0 && (nc as usize) < cols && (nr as usize) < rows { + let v = fine[nr as usize * cols + nc as usize]; + xs.push(v[0]); + ys.push(v[1]); + } + } + } + xs.sort_by(f32::total_cmp); + ys.sort_by(f32::total_cmp); + [xs[xs.len() / 2], ys[ys.len() / 2]] + }) + .collect(); + flow +} + +/// Cross-fade of two frames: `a·(1 − w) + b·w` (Frame Mix). +pub fn mix(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return if w < 0.5 { a.clone() } else { b.clone() }; + } + let mut out = a.clone(); + out.data.par_iter_mut().zip(b.data.par_iter()).for_each(|(p, q)| { + for c in 0..4 { + p[c] += (q[c] - p[c]) * w; + } + }); + out +} + +/// Motion-compensated frame at fraction `w` (0 = `a`, 1 = `b`) between two frames (Pixel Motion). +pub fn interpolate(a: &Image, b: &Image, w: f32) -> Image { + if a.width != b.width || a.height != b.height { + return mix(a, b, w); + } + let flow = block_flow(a, b, 8, 4); + let mut out = Image::new(a.width, a.height); + let wd = w as f64; + out.rows_mut().for_each(|(y, row)| { + let cy = y as f64 + 0.5; + for (x, px) in row.iter_mut().enumerate() { + let cx = x as f64 + 0.5; + let f = flow.at(cx, cy); + let (fx, fy) = (f[0] as f64, f[1] as f64); + let pa = a.sample_bilinear_clamped(cx - wd * fx, cy - wd * fy); + let pb = b.sample_bilinear_clamped(cx + (1.0 - wd) * fx, cy + (1.0 - wd) * fy); + for c in 0..4 { + px[c] = pa[c] + (pb[c] - pa[c]) * w; + } + } + }); + out +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A textured image shifted right by `dx` pixels. + fn texture(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let (u, v) = (x as f64 - dx, y as f64); + let s = 0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos() + 0.1 * (u * 0.013 * v * 0.011).sin(); + let s = s as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img + } + + fn mae(a: &Image, b: &Image, margin: u32) -> f32 { + let mut s = 0.0; + let mut n = 0; + for y in margin..a.height - margin { + for x in margin..a.width - margin { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3; + } + } + s / n as f32 + } + + #[test] + fn flow_finds_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let f = block_flow(&a, &b, 8, 4); + let m = f.at(64.0, 48.0); + assert!((m[0] - 6.0).abs() < 0.3 && m[1].abs() < 0.3, "{m:?}"); + } + + #[test] + fn pixel_motion_beats_frame_mix_on_translation() { + let a = texture(128, 96, 0.0); + let b = texture(128, 96, 6.0); + let truth = texture(128, 96, 3.0); + let pm = mae(&interpolate(&a, &b, 0.5), &truth, 12); + let fm = mae(&mix(&a, &b, 0.5), &truth, 12); + assert!(pm < fm * 0.25, "pixel motion {pm} vs frame mix {fm}"); + } +} diff --git a/crates/raster/src/lib.rs b/crates/raster/src/lib.rs index 643de9e7f..dfe538486 100644 --- a/crates/raster/src/lib.rs +++ b/crates/raster/src/lib.rs @@ -4,6 +4,7 @@ //! heavy operation is row-parallel with rayon. pub mod blur; +pub mod flow; pub mod warp; use effectcraft_color::{BlendMode, blend_pixel}; diff --git a/crates/render/src/audio.rs b/crates/render/src/audio.rs index 246ff683e..4884411f6 100644 --- a/crates/render/src/audio.rs +++ b/crates/render/src/audio.rs @@ -329,6 +329,7 @@ mod tests { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, }; let fid = p.add_item("x.wav", effectcraft_color::Label::SeaFoam, None, ItemKind::Footage(f)); let comp = Comp::new(64, 64, FrameRate::new(25, 1), Tick::from_seconds_f64(2.0)); diff --git a/crates/render/src/cache.rs b/crates/render/src/cache.rs index d1dc692c2..8cd27d598 100644 --- a/crates/render/src/cache.rs +++ b/crates/render/src/cache.rs @@ -288,9 +288,13 @@ fn key_with(ctx: &EvalCtx, layer: &Layer, scale: f64, draft: bool, footage: bool hash_debug(&mut h, f); item.hash(&mut h); ctx.source_time(layer).0.hash(&mut h); + // Frame blending mixes neighbouring source frames. + ctx.comp.enable_frame_blending.hash(&mut h); } _ => return None, } + // Bit depth and colour management change the pixels. + crate::color::Pipe::of(&ctx.project.settings).key().hash(&mut h); ctx.comp_id.hash(&mut h); ctx.comp.width.hash(&mut h); ctx.comp.height.hash(&mut h); diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs new file mode 100644 index 000000000..7b190b7d9 --- /dev/null +++ b/crates/render/src/color.rs @@ -0,0 +1,114 @@ +//! Project colour settings in the compositor: bit depth, working space, linear blending. +//! +//! * **Bit depth.** Pixels are `f32` throughout, but 8 and 16 bpc projects behave like integer +//! pipelines: each layer's pixels are clamped to 0..1 and quantised to the depth after its +//! source and masks and after every effect, and the comp is clamped and quantised after every +//! layer is blended. Over-range values (Add/Screen of bright layers, Exposure…) therefore only +//! survive in 32 bpc. 16 bpc uses After Effects' 0..32768 range. +//! * **Working space** (colour management). Footage is converted from its colour profile (its +//! metadata, else sRGB) into the working space; the top-level comp output is converted from +//! the working space to the sRGB display. Without a working space nothing is converted. +//! * **Linearize Working Space**: the working space is linear light, so sources (footage and +//! the colours of solids, text and shapes, which are authored in the working space's encoding) +//! are linearised before masks and effects, and everything runs in linear. +//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels +//! are linearised just before they are transformed and blended, the comp accumulates in linear +//! and is encoded back to the working space when the comp is done (so precomps hand encoded +//! pixels to their parent). Without a working space the sRGB curve is used. + +use effectcraft_color::{ColorSpace, Conversion}; +use effectcraft_project::ProjectSettings; +use effectcraft_raster::Image; +use rayon::prelude::*; + +/// The colour pipeline of a project (see the module docs). +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct Pipe { + /// Quantisation levels (255, 32768) or `None` for 32 bpc float. + pub levels: Option, + /// Working space (`None` = unmanaged). + pub space: Option, + /// The working space is linear. + pub linear: bool, + /// Blend in linear light (and the working space is not already linear). + pub linear_blend: bool, +} + +impl Pipe { + pub fn of(s: &ProjectSettings) -> Pipe { + let linear = s.linearize && s.working_space.is_some(); + Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear } + } + + /// The space whose curve encodes working-space pixels (sRGB when unmanaged). + fn curve(&self) -> ColorSpace { + self.space.unwrap_or(ColorSpace::Srgb) + } + + /// Footage with colour profile `profile` (`None` = sRGB) → working space. + pub fn media_in(&self, profile: Option) -> Option { + let ws = self.space?; + Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear) + } + + /// Authored colours (solids, text, shapes) → working space: linearised in a linear working + /// space. + pub fn authored_in(&self) -> Option { + self.linear.then(|| Conversion::linearize(self.curve())) + } + + /// Working space → blending space (linear when blending with 1.0 gamma). + pub fn to_blend(&self) -> Option { + self.linear_blend.then(|| Conversion::linearize(self.curve())) + } + + /// Blending space → working space. + pub fn from_blend(&self) -> Option { + self.linear_blend.then(|| Conversion::delinearize(self.curve())) + } + + /// Working space → sRGB display (top-level comp output). + pub fn output(&self) -> Option { + let ws = self.space?; + Conversion::new(ws, self.linear, ColorSpace::Srgb, false) + } + + /// Clamp and quantise to the bit depth (no-op in 32 bpc). + pub fn quantize(&self, img: &mut Image) { + if let Some(l) = self.levels { + quantize(img, l); + } + } + + /// Hash of everything that changes pixels (for cache keys). + pub fn key(&self) -> u64 { + let mut k = self.levels.map_or(0, |l| l as u64); + k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11)); + k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64); + k + } +} + +/// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps. +pub fn quantize(img: &mut Image, levels: f32) { + let inv = 1.0 / levels; + img.data.par_iter_mut().for_each(|p| { + let a = (p[3].clamp(0.0, 1.0) * levels).round() * inv; + p[3] = a; + for c in 0..3 { + p[c] = (p[c].clamp(0.0, a) * levels).round() * inv; + } + }); +} + +/// Convert premultiplied pixels (on straight colour). +pub fn convert(img: &mut Image, c: &Conversion) { + img.data.par_iter_mut().for_each(|p| { + let a = p[3]; + if a <= 0.0 { + return; + } + let o = c.apply([p[0] / a, p[1] / a, p[2] / a]); + *p = [o[0] * a, o[1] * a, o[2] * a, a]; + }); +} diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index dc7db2d4e..05c05efa5 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -8,6 +8,7 @@ pub mod audio; pub mod cache; +pub mod color; pub mod eval; pub mod masks; pub mod shapes; @@ -20,11 +21,11 @@ use std::sync::Arc; pub use cache::{CacheStats, LayerCache}; use effectcraft_color::BlendMode; use effectcraft_effects::{Buf, EffectCtx, EffectEnv, EffectHost, LayerPixels, Params}; -use effectcraft_geom::{Mat3, vec2}; -use effectcraft_project::{Comp, Footage, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; +use effectcraft_geom::{Mat3, Mat4, vec2}; +use effectcraft_project::{Comp, Footage, FootageKind, FrameBlend, GroupKind, ItemId, ItemKind, Layer, LayerSource, MatteKind, Project, Quality, Sampling}; pub use effectcraft_raster::Image; use effectcraft_raster::{WarpOpts, composite_warp}; -use effectcraft_time::Tick; +use effectcraft_time::{FrameRate, TICKS_PER_SECOND, Tick}; pub use eval::{EvalCtx, ExprHost, source_size}; use rayon::prelude::*; @@ -64,7 +65,7 @@ impl EffectHost for FxHost<'_, '_, '_> { if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { return None; } - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); // The cached layer buffer is shared; effects get their own copy. let buf = if masks_and_effects { (*sub.content_buf(self.ctx, other)?).clone() } else { sub.source(self.ctx, other)? }; let size = source_size(self.r.project, other); @@ -77,7 +78,7 @@ impl EffectHost for FxHost<'_, '_, '_> { if other.id == self.layer.id || self.r.depth > MAX_FX_DEPTH { return None; } - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); let buf = (*sub.layer_input(self.ctx, other, 0)?).clone(); let size = source_size(self.r.project, other); let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -101,7 +102,7 @@ impl EffectHost for FxHost<'_, '_, '_> { } let n = effects.min(self.index.load(std::sync::atomic::Ordering::Relaxed)); let t = self.layer.comp_time(Tick::from_seconds_f64(layer_time)); - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); sub.layer_input(&self.ctx.at(t), self.layer, n).map(|b| (*b).clone()) } @@ -111,7 +112,7 @@ impl EffectHost for FxHost<'_, '_, '_> { return None; } let ctx = self.ctx.at(Tick::from_seconds_f64(comp_time)); - let sub = Renderer { depth: self.r.depth + 1, ..*self.r }; + let sub = self.r.nested(); let buf = if masks_and_effects { (*sub.content_buf(&ctx, other)?).clone() } else { (*sub.layer_input(&ctx, other, 0)?).clone() }; let size = source_size(self.r.project, other); let size = if size.0 == 0 { [self.ctx.comp.width as f64, self.ctx.comp.height as f64] } else { [size.0 as f64, size.1 as f64] }; @@ -157,6 +158,26 @@ pub struct Renderer<'a> { pub profile: Option<&'a std::sync::Mutex>>, /// Current nesting depth (precomp recursion guard). depth: usize, + /// The project's colour pipeline (bit depth, working space, linear blending). + pub(crate) pipe: color::Pipe, + /// Collapsed precomp being drawn into its parent: nested comp pixels → parent comp pixels + /// (before the output scale). + outer: Option, + /// Opacity of the collapsed precomp layers this comp is drawn through. + opacity_mul: f32, + /// Collapsed precomp: nested 3D layers join the parent's 3D space. + pub(crate) collapse3d: Option>, +} + +/// How a collapsed precomp's 3D layers are placed in the parent (see `Renderer::collapse_into`). +#[derive(Clone, Copy)] +pub(crate) struct Collapse3d<'a> { + /// Nested comp world → parent world (the precomp layer's world transform). + pub world: Mat4, + /// The parent's camera. + pub cam: three_d::CameraState, + /// The outermost comp's context (its lights light the nested layers). + pub parent: EvalCtx<'a>, } /// Time spent on one layer of one frame (milliseconds). @@ -176,7 +197,139 @@ pub struct LayerTiming { impl<'a> Renderer<'a> { pub fn new(project: &'a Project, footage: &'a dyn FootageSource, opts: RenderOpts) -> Renderer<'a> { - Renderer { project, footage, expr: None, opts, cache: None, profile: None, depth: 0 } + Renderer { + project, + footage, + expr: None, + opts, + cache: None, + profile: None, + depth: 0, + pipe: color::Pipe::of(&project.settings), + outer: None, + opacity_mul: 1.0, + collapse3d: None, + } + } + + /// A renderer for nested work (precomps, layers read by effects): one level deeper. + fn nested(&self) -> Renderer<'a> { + Renderer { depth: self.depth + 1, outer: None, opacity_mul: 1.0, collapse3d: None, ..*self } + } + + /// The nested comp of a precomp layer whose transformations collapse into this comp: the + /// Collapse Transformations switch is on and the layer has no masks, effects or layer + /// styles (those force a flattened render of the precomp, as in After Effects). + pub(crate) fn collapsed(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + if !layer.switches.collapse || layer.switches.adjustment || self.depth > 12 { + return None; + } + let LayerSource::Comp { item } = &layer.source else { return None }; + if self.project.comp_contains(*item, ctx.comp_id) || self.project.comp(*item).is_none() { + return None; + } + if layer.masks().is_some_and(|m| !m.children.is_empty()) { + return None; + } + if layer.switches.effects && layer.effects().is_some_and(|fx| fx.groups().any(|g| g.enabled)) { + return None; + } + if styles::active(ctx, layer) { + return None; + } + Some(*item) + } + + /// A renderer and context that draw a collapsed precomp's layers straight into this comp: + /// their transforms are concatenated with the precomp layer's (one resample, no + /// rasterisation at the precomp's bounds), their opacity multiplied by `opacity`, and their + /// 3D layers use this comp's camera and lights. + pub(crate) fn collapse_into(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, opacity: f32) -> Option<(Renderer<'a>, EvalCtx<'a>)> { + let nc = self.project.comp(item)?; + let nctx = EvalCtx { comp_id: item, comp: nc, time: ctx.source_time(layer), ..*ctx }; + let (l2c, _) = ctx.layer_to_comp(layer); + let outer = Some(self.outer.map_or(l2c, |o| o * l2c)); + let world = ctx.world_matrix(layer); + let c3 = Collapse3d { + world: self.collapse3d.map_or(world, |c| c.world * world), + cam: three_d::compose::camera_for(self, ctx), + parent: self.collapse3d.map_or(*ctx, |c| c.parent), + }; + Some((Renderer { depth: self.depth + 1, outer, opacity_mul: opacity, collapse3d: Some(c3), ..*self }, nctx)) + } + + pub(crate) fn opacity_mul(&self) -> f32 { + self.opacity_mul + } + + /// Draw a collapsed 2D precomp layer: nested layers blend straight into `canvas` with their + /// own modes (Normal precomp, no matte); otherwise they are drawn into an isolated buffer + /// that then takes the precomp layer's track matte, Preserve Transparency and blend mode. + fn draw_collapsed(&self, ctx: &EvalCtx<'a>, layer: &Layer, item: ItemId, canvas: &mut Image) { + let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; + if opacity <= 0.0 { + return; + } + let matte = layer.track_matte.is_some_and(|tm| tm.layer != layer.id && ctx.comp.layer(tm.layer).is_some()); + if layer.blend_mode == BlendMode::Normal && !matte && !layer.preserve_transparency { + if let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, opacity) { + sub.draw_comp(&nctx, canvas); + } + return; + } + let Some((sub, nctx)) = self.collapse_into(ctx, layer, item, 1.0) else { return }; + let mut iso = Image::new(canvas.width, canvas.height); + sub.draw_comp(&nctx, &mut iso); + self.composite_iso(ctx, layer, iso, canvas, opacity); + } + + /// Rasterisation scale of a layer's source: the output scale, or — for Continuously + /// Rasterize text and shape layers — the output scale times the layer's on-screen scale + /// (quarter-octave steps, rounded up, so vector content is drawn at its final size and + /// stays sharp when scaled up). + pub fn raster_scale(&self, ctx: &EvalCtx, layer: &Layer) -> f64 { + let s = self.opts.scale; + if !layer.switches.collapse || !matches!(layer.source, LayerSource::Text | LayerSource::Shape) { + return s; + } + let (l2c, _) = ctx.layer_to_comp(layer); + let m = self.outer.map_or(l2c, |o| o * l2c); + let a = layer.transform().map(|tr| ctx.v3(layer, tr, "anchor", [0.0; 3])).unwrap_or([0.0; 3]); + let p0 = m.apply(vec2(a[0], a[1])); + let px = m.apply(vec2(a[0] + 1.0, a[1])); + let py = m.apply(vec2(a[0], a[1] + 1.0)); + let k = ((px.x - p0.x).hypot(px.y - p0.y)).max((py.x - p0.x).hypot(py.y - p0.y)); + if !k.is_finite() || k <= 1e-6 { + return s; + } + let mut k = 2f64.powf((k.log2() * 4.0).ceil() / 4.0).clamp(1.0 / 16.0, 64.0); + // Keep the buffer within about 16 million pixels. + if let Some(b) = content_bounds(ctx, layer) { + let area = ((b[2] - b[0]) * (b[3] - b[1])).max(1.0) * s * s; + k = k.min((16.0e6 / area).sqrt().max(1.0)); + } + s * k + } + + /// Draw a Wireframe-quality layer: its bounds as a one-pixel outline. + fn draw_wireframe(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { + let Some(b) = content_bounds(ctx, layer) else { return }; + let s = self.opts.scale; + let (l2c, _) = ctx.layer_to_comp(layer); + let m = Mat3::scale(vec2(s, s)) * self.outer.map_or(l2c, |o| o * l2c); + let c = [(b[0], b[1]), (b[2], b[1]), (b[2], b[3]), (b[0], b[3])].map(|(x, y)| m.apply(vec2(x, y))); + let (w, h) = (canvas.width as i64, canvas.height as i64); + for i in 0..4 { + let (p, q) = (c[i], c[(i + 1) % 4]); + let n = ((q.x - p.x).abs().max((q.y - p.y).abs()) * 2.0).ceil().clamp(1.0, 1.0e5) as usize; + for j in 0..=n { + let t = j as f64 / n as f64; + let (x, y) = ((p.x + (q.x - p.x) * t).floor() as i64, (p.y + (q.y - p.y) * t).floor() as i64); + if x >= 0 && y >= 0 && x < w && y < h { + canvas.set(x as u32, y as u32, [1.0, 1.0, 1.0, 1.0]); + } + } + } } fn ctx(&self, comp_id: ItemId, comp: &'a Comp, t: Tick) -> EvalCtx<'a> { @@ -194,6 +347,24 @@ impl<'a> Renderer<'a> { return canvas; } let ctx = self.ctx(comp_id, comp, t); + self.draw_comp(&ctx, &mut canvas); + if let Some(c) = self.pipe.from_blend() { + color::convert(&mut canvas, &c); + } + self.pipe.quantize(&mut canvas); + // The top-level comp leaves the working space for the (sRGB) display / output. + if self.depth == 0 + && let Some(c) = self.pipe.output() + { + color::convert(&mut canvas, &c); + self.pipe.quantize(&mut canvas); + } + canvas + } + + /// Draw a comp's layers (bottom to top) into `canvas` (blending space). + fn draw_comp(&self, ctx: &EvalCtx<'a>, canvas: &mut Image) { + let (comp, t) = (ctx.comp, ctx.time); let any_solo = comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); // Bottom-to-top, with runs of consecutive 3D layers depth-sorted (farthest first). let visible: Vec<&Layer> = comp @@ -209,17 +380,18 @@ impl<'a> Renderer<'a> { while j < visible.len() && visible[j].is_3d() { j += 1; } - three_d::compose::draw_run(self, &ctx, &visible[i..j], &mut canvas); + three_d::compose::draw_run(self, ctx, &visible[i..j], canvas); i = j; } else { - self.draw_layer(&ctx, visible[i], &mut canvas); + match self.collapsed(ctx, visible[i]) { + Some(item) => self.draw_collapsed(ctx, visible[i], item, canvas), + None => self.draw_layer(ctx, visible[i], canvas), + } i += 1; } + // 8/16 bpc: the comp is an integer buffer after every layer. + self.pipe.quantize(canvas); } - if !self.project.settings.bit_depth.is_float() { - canvas.clamp01(); - } - canvas } /// Evaluate an effect instance's parameters. @@ -271,6 +443,8 @@ impl<'a> Renderer<'a> { let ectx = EffectCtx { params: ¶ms, time: lt.seconds(), layer_size, seed: g.uid as u32, adjustment, env }; let t0 = web_time::Instant::now(); buf = effectcraft_effects::apply(spec, &ectx, buf); + // 8/16 bpc effects write integer pixels. + self.pipe.quantize(&mut buf.img); if let Some(v) = timing.as_deref_mut() { v.push((spec.id.to_string(), t0.elapsed().as_secs_f64() * 1e3)); } @@ -289,15 +463,30 @@ impl<'a> Renderer<'a> { if layer.switches.adjustment { return Some(Buf { img: Image::filled(w, h, [1.0; 4]), offset: [0.0; 2], scale: s }); } - let c = sol.color; + let mut c = sol.color; + if let Some(conv) = self.pipe.authored_in() { + c = conv.apply(c); + } Some(Buf { img: Image::filled(w, h, [c[0], c[1], c[2], 1.0]), offset: [0.0; 2], scale: s }) } LayerSource::Comp { item } => { if self.project.comp_contains(*item, ctx.comp_id) { return None; } - let sub = Renderer { depth: self.depth + 1, ..*self }; + let sub = self.nested(); let lt = ctx.source_time(layer); + // Frame blending between the nested comp's frames (time-stretched/remapped). + let mode = frame_blend_mode(ctx, layer); + if mode != FrameBlend::Off + && let Some(nc) = self.project.comp(*item) + { + let (i, w) = frame_position(lt, nc.frame_rate); + if w > 1e-6 { + let a = sub.comp_frame(*item, nc.frame_rate.tick_of(i)); + let b = sub.comp_frame(*item, nc.frame_rate.tick_of(i + 1)); + return Some(Buf { img: blend_frames(mode, &a, &b, w as f32), offset: [0.0; 2], scale: s }); + } + } Some(Buf { img: sub.comp_frame(*item, lt), offset: [0.0; 2], scale: s }) } LayerSource::Footage { item } => { @@ -307,40 +496,123 @@ impl<'a> Renderer<'a> { return None; } let lt = ctx.source_time(layer); - let img = self.footage.frame(*item, f, lt)?; + let img = self.footage_frame(ctx, layer, *item, f, lt)?; let k = if img.width > 0 { f.width.max(1) as f64 / img.width as f64 } else { 1.0 }; // Keep native pixels when downsampling is small; resample otherwise. - if s < 0.75 { + let mut buf = if s < 0.75 { let w = ((f.width as f64 * s).round() as u32).max(1); let h = ((f.height as f64 * s).round() as u32).max(1); - return Some(Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s }); + Buf { img: effectcraft_raster::resample(&img, w, h), offset: [0.0; 2], scale: s } + } else { + Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k } + }; + if let Some(c) = self.pipe.media_in(f.color_profile) { + color::convert(&mut buf.img, &c); } - Some(Buf { img: (*img).clone(), offset: [0.0; 2], scale: 1.0 / k }) + Some(buf) } - LayerSource::Text => Some(text::render(ctx, layer, s)), - LayerSource::Shape => layer.props.sub("contents").map(|c| shapes::render(ctx, layer, c, s)), + LayerSource::Text => Some(self.authored(text::render(ctx, layer, self.raster_scale(ctx, layer)))), + LayerSource::Shape => layer.props.sub("contents").map(|c| self.authored(shapes::render(ctx, layer, c, self.raster_scale(ctx, layer)))), _ => None, } } + /// Authored colours into the working space (linear working spaces). + fn authored(&self, mut buf: Buf) -> Buf { + if let Some(c) = self.pipe.authored_in() { + color::convert(&mut buf.img, &c); + } + buf + } + + /// A footage frame at source time `t`, frame-blended between the two nearest source frames + /// when the layer's Frame Blending switch (and the comp's Enable Frame Blending) is on and + /// `t` falls between frames (footage rate ≠ comp rate, time stretch, time remapping). + fn footage_frame(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { + let mode = frame_blend_mode(ctx, layer); + if mode != FrameBlend::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { + let (i, w) = frame_position(t, f.frame_rate); + if w > 1e-6 { + // Blended frames are cached (Pixel Motion is expensive). + let key = self.cache.map(|_| { + use std::hash::{Hash, Hasher}; + let mut h = std::collections::hash_map::DefaultHasher::new(); + (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path).hash(&mut h); + h.finish() + }); + if let (Some(c), Some(k)) = (self.cache, key) + && let Some(b) = c.get(k) + { + return Some(Arc::new(b.img.clone())); + } + let a = self.footage.frame(item, f, f.frame_rate.tick_of(i)); + let b = self.footage.frame(item, f, f.frame_rate.tick_of(i + 1)); + if let (Some(a), Some(b)) = (a, b) { + let img = blend_frames(mode, &a, &b, w as f32); + if let (Some(c), Some(k)) = (self.cache, key) { + c.insert(k, Arc::new(Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 })); + } + return Some(Arc::new(img)); + } + } + } + self.footage.frame(item, f, t) + } + + /// Source → masks, clamped/quantised to the project depth. + fn masked_source(&self, ctx: &EvalCtx, layer: &Layer) -> Option { + let mut buf = self.source(ctx, layer)?; + masks::apply(ctx, layer, &mut buf); + self.pipe.quantize(&mut buf.img); + Some(buf) + } + /// Fully processed layer buffer (source → masks → effects → layer styles, flattened). /// Served from the layer cache when the layer's content key matches. pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + self.layer_buf_keyed(ctx, layer).map(|(b, _)| b) + } + + /// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma). + pub(crate) fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option> { + let (b, k) = self.layer_buf_keyed(ctx, layer)?; + Some(self.to_blend(b, k)) + } + + /// A buffer converted to the blending space (cached under a key derived from `key`). + pub(crate) fn to_blend(&self, b: Arc, key: Option) -> Arc { + let Some(c) = self.pipe.to_blend() else { return b }; + let key = key.map(|k| cache::derive(k, 0x1ea7)); + if let (Some(cache), Some(k)) = (self.cache, key) + && let Some(x) = cache.get(k) + { + return x; + } + let mut nb = (*b).clone(); + color::convert(&mut nb.img, &c); + let nb = Arc::new(nb); + if let (Some(cache), Some(k)) = (self.cache, key) { + cache.insert(k, nb.clone()); + } + nb + } + + fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc, Option)> { let st = self.styled(ctx, layer, None)?; if st.passes.is_empty() { - return Some(st.body); + return Some((st.body, st.key)); } let key = st.key.map(|k| cache::derive(k, 0xf1a7)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { - return Some(b); + return Some((b, key)); } let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten()); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, flat.clone()); } - Some(flat) + Some((flat, key)) } /// A layer's source pixels at the context time, before masks, effects and the transform @@ -356,7 +628,7 @@ impl<'a> Renderer<'a> { } fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc, Option)> { - let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.opts.scale, self.opts.draft)); + let key = self.cache.and_then(|_| cache::layer_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { @@ -365,8 +637,7 @@ impl<'a> Renderer<'a> { } return Some((b, key)); } - let mut buf = self.source(ctx, layer)?; - masks::apply(ctx, layer, &mut buf); + let buf = self.masked_source(ctx, layer)?; let buf = Arc::new(self.apply_effects_timed(ctx, layer, buf, false, usize::MAX, timing.map(|t| &mut t.effects))); if let (Some(c), Some(k)) = (self.cache, key) { c.insert(k, buf.clone()); @@ -378,14 +649,13 @@ impl<'a> Renderer<'a> { /// (what Time effects read at neighbouring times). Served from / stored in the layer cache /// under its own key, so scrubbing reuses frames rendered for earlier output frames. pub fn layer_input(&self, ctx: &EvalCtx, layer: &Layer, effects: usize) -> Option> { - let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.opts.scale, self.opts.draft, effects)); + let key = self.cache.and_then(|_| cache::input_key(ctx, layer, self.raster_scale(ctx, layer), self.opts.draft, effects)); if let (Some(c), Some(k)) = (self.cache, key) && let Some(b) = c.get(k) { return Some(b); } - let mut buf = self.source(ctx, layer)?; - masks::apply(ctx, layer, &mut buf); + let mut buf = self.masked_source(ctx, layer)?; if effects > 0 { buf = self.apply_effects_timed(ctx, layer, buf, false, effects, None); } @@ -400,7 +670,7 @@ impl<'a> Renderer<'a> { fn styled(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option { let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?; if !styles::active(ctx, layer) { - return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true }); + return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true }); } let modes = styles::pass_modes(ctx, layer); let key = ckey.map(|k| cache::styles_key(ctx, layer, k)); @@ -409,7 +679,7 @@ impl<'a> Renderer<'a> { { let passes: Option> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect(); if let Some(passes) = passes { - return Some(StyledLayer { passes, body, content, key, plain: false }); + return Some(StyledLayer { passes, body, content, key, ckey, plain: false }); } } let t0 = web_time::Instant::now(); @@ -428,11 +698,25 @@ impl<'a> Renderer<'a> { c.insert(cache::derive(k, i as u64 + 1), b.clone()); } } - Some(StyledLayer { passes, body, content, key, plain: false }) + Some(StyledLayer { passes, body, content, key, ckey, plain: false }) + } + + /// The styled layer's buffers in the blending space. + fn styled_to_blend(&self, st: StyledLayer) -> StyledLayer { + if self.pipe.to_blend().is_none() { + return st; + } + let body = self.to_blend(st.body, st.key); + let content = if st.plain { body.clone() } else { self.to_blend(st.content, st.ckey) }; + let passes = st.passes.into_iter().enumerate().map(|(i, (b, m))| (self.to_blend(b, st.key.map(|k| cache::derive(k, i as u64 + 1))), m)).collect(); + StyledLayer { passes, body, content, ..st } } fn sampling(&self, layer: &Layer) -> effectcraft_raster::Sampling { - if self.opts.draft || layer.switches.quality == Quality::Draft { + if layer.switches.quality == Quality::Draft { + // Draft quality: no interpolation (nearest neighbour), as After Effects' Draft. + effectcraft_raster::Sampling::Nearest + } else if self.opts.draft { effectcraft_raster::Sampling::Bilinear } else if layer.switches.sampling == Sampling::Bicubic { effectcraft_raster::Sampling::Bicubic @@ -445,6 +729,7 @@ impl<'a> Renderer<'a> { fn buf_matrix(&self, ctx: &EvalCtx, layer: &Layer, buf: &Buf) -> Mat3 { let s = self.opts.scale; let (l2c, _) = ctx.layer_to_comp(layer); + let l2c = self.outer.map_or(l2c, |o| o * l2c); Mat3::scale(vec2(s, s)) * l2c * Mat3::scale(vec2(1.0 / buf.scale, 1.0 / buf.scale)) * Mat3::translate(vec2(-buf.offset[0], -buf.offset[1])) } @@ -477,7 +762,11 @@ impl<'a> Renderer<'a> { } fn draw_layer(&self, ctx: &EvalCtx, layer: &Layer, canvas: &mut Image) { - let opacity = ctx.opacity(layer) as f32; + let opacity = ctx.opacity(layer) as f32 * self.opacity_mul; + if layer.switches.quality == Quality::Wireframe && !layer.switches.adjustment { + self.draw_wireframe(ctx, layer, canvas); + return; + } if opacity <= 0.0 && !layer.switches.adjustment { return; } @@ -488,6 +777,7 @@ impl<'a> Renderer<'a> { let mut timing = self.profile.map(|_| LayerTiming { depth: self.depth, layer: layer.name.clone(), ..Default::default() }); let t0 = web_time::Instant::now(); let Some(st) = self.styled(ctx, layer, timing.as_mut()) else { return }; + let st = self.styled_to_blend(st); let t1 = web_time::Instant::now(); if st.plain { self.composite_layer(ctx, layer, &st.body, canvas, opacity); @@ -595,8 +885,15 @@ impl<'a> Renderer<'a> { masks::apply(ctx, layer, &mut foot); let mut matte = Image::new(canvas.width, canvas.height); self.place(ctx, layer, &foot, &mut matte, BlendMode::Normal, 1.0); - let below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale }; - let adjusted = self.apply_effects(ctx, layer, below, true); + let mut below = Buf { img: canvas.clone(), offset: [0.0; 2], scale: self.opts.scale }; + // Effects run in the working space, not the linear blending space. + if let Some(c) = self.pipe.from_blend() { + color::convert(&mut below.img, &c); + } + let mut adjusted = self.apply_effects(ctx, layer, below, true); + if let Some(c) = self.pipe.to_blend() { + color::convert(&mut adjusted.img, &c); + } if adjusted.img.width != canvas.width || adjusted.img.height != canvas.height { return; } @@ -615,12 +912,37 @@ struct StyledLayer { body: Arc, /// Pre-style pixels (knockout shape). content: Arc, - /// Styled cache key. + /// Styled cache key (the content key when plain). key: Option, + /// Content cache key. + ckey: Option, /// No layer styles: composite `body` the usual way. plain: bool, } +/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. +fn frame_blend_mode(ctx: &EvalCtx, layer: &Layer) -> FrameBlend { + if ctx.comp.enable_frame_blending { layer.switches.frame_blend } else { FrameBlend::Off } +} + +/// Position of source time `t` among frames at `rate`: the frame at or before `t` and the +/// fraction (0..1) of the way to the next frame. Exact (integer tick arithmetic), so frames that +/// line up exactly report a fraction of 0. +pub fn frame_position(t: Tick, rate: FrameRate) -> (i64, f64) { + let num = t.0 as i128 * rate.num as i128; + let den = TICKS_PER_SECOND as i128 * rate.den as i128; + (num.div_euclid(den) as i64, num.rem_euclid(den) as f64 / den as f64) +} + +/// Blend two neighbouring frames `w` of the way from `a` to `b`: a cross-fade (Frame Mix) or a +/// motion-compensated interpolation (Pixel Motion). +pub fn blend_frames(mode: FrameBlend, a: &Image, b: &Image, w: f32) -> Image { + match mode { + FrameBlend::PixelMotion => effectcraft_raster::flow::interpolate(a, b, w), + _ => effectcraft_raster::flow::mix(a, b, w), + } +} + /// Convenience: render one frame of a comp with no footage source. pub fn render_frame(project: &Project, comp: ItemId, t: Tick, scale: f64) -> Image { Renderer::new(project, &NoFootage, RenderOpts { scale, ..Default::default() }).comp_frame(comp, t) @@ -659,6 +981,12 @@ pub fn kurbo_path(sp: &effectcraft_keyframe::ShapePath) -> kurbo::BezPath { #[cfg(test)] mod tests_audio_fx; #[cfg(test)] +mod tests_collapse; +#[cfg(test)] +mod tests_color; +#[cfg(test)] +mod tests_frame_blend; +#[cfg(test)] mod tests_paint; #[cfg(test)] mod tests_time; diff --git a/crates/render/src/tests.rs b/crates/render/src/tests.rs index dc1f3cdaf..a5a7d8b6d 100644 --- a/crates/render/src/tests.rs +++ b/crates/render/src/tests.rs @@ -8,6 +8,8 @@ use crate::render_frame; fn setup() -> (Project, ItemId, Comp) { let mut p = Project::default(); + // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; let comp = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) diff --git a/crates/render/src/tests_audio_fx.rs b/crates/render/src/tests_audio_fx.rs index 173ffc88f..dfd49ccc3 100644 --- a/crates/render/src/tests_audio_fx.rs +++ b/crates/render/src/tests_audio_fx.rs @@ -50,6 +50,7 @@ fn audio_footage() -> Footage { codec: String::new(), missing: false, sequence: vec![], + color_profile: None, } } diff --git a/crates/render/src/tests_collapse.rs b/crates/render/src/tests_collapse.rs new file mode 100644 index 000000000..d3cf954cf --- /dev/null +++ b/crates/render/src/tests_collapse.rs @@ -0,0 +1,202 @@ +//! Collapse Transformations, Continuously Rasterize and the Quality switch. + +use effectcraft_color::{BlendMode, Label}; +use effectcraft_keyframe::Value; +use effectcraft_project::build::{self, Ids}; +use effectcraft_project::{BitDepth, Comp, ItemId, ItemKind, Layer, LayerSource, Project, Quality, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{Image, render_frame}; + +fn project() -> Project { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + p +} + +fn solid(p: &mut Project, comp: &Comp, color: [f32; 3], size: (u32, u32), pos: [f64; 3]) -> Layer { + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: size.0, height: size.1, pixel_aspect: 1.0 })); + let mut l = build::layer(p, comp, "S", LayerSource::Solid { item: sid }, size, None); + l.props.prop_mut("transform/position").unwrap().value = Value::Vec3(pos); + l +} + +fn set(l: &mut Layer, path: &str, v: Value) { + l.props.prop_mut(path).unwrap().value = v; +} + +fn max_diff(a: &Image, b: &Image) -> f32 { + a.data.iter().zip(&b.data).flat_map(|(p, q)| (0..4).map(move |c| (p[c] - q[c]).abs())).fold(0.0, f32::max) +} + +/// Outer 200×100 comp: a grey background, and either a precomp (scaled 150%, rotated) of two +/// solids — the top one in Add mode — or the same two solids parented to a null that carries the +/// precomp layer's transform (the manual flattening). +fn scene(collapse: bool, flatten: bool) -> (Project, ItemId) { + let mut p = project(); + let outer = Comp::new(200, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut a = solid(&mut p, &inner, [0.6, 0.1, 0.1], (40, 40), [40.0, 50.0, 0.0]); + let mut b = solid(&mut p, &inner, [0.1, 0.3, 0.6], (40, 40), [60.0, 50.0, 0.0]); + b.blend_mode = BlendMode::Add; + let bg = solid(&mut p, &outer, [0.3, 0.3, 0.3], (200, 100), [100.0, 50.0, 0.0]); + let xf = |l: &mut Layer| { + set(l, "transform/anchor", Value::Vec3([50.0, 50.0, 0.0])); + set(l, "transform/position", Value::Vec3([100.0, 50.0, 0.0])); + set(l, "transform/scale", Value::Vec3([150.0, 150.0, 100.0])); + set(l, "transform/rotation", Value::Scalar(20.0)); + }; + let cid; + if flatten { + let mut null = build::layer(&mut p, &outer, "Null", LayerSource::Null, (100, 100), None); + xf(&mut null); + a.parent = Some(null.id); + b.parent = Some(null.id); + let mut c = outer.clone(); + c.layers = vec![null, b, a, bg]; + cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + } else { + let mut inner = inner; + inner.layers = vec![b, a]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (100, 100), None); + xf(&mut pre); + pre.switches.collapse = collapse; + let mut c = outer.clone(); + c.layers = vec![pre, bg]; + cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + } + (p, cid) +} + +#[test] +fn collapsed_precomp_equals_manual_flattening() { + let (p, cid) = scene(false, true); + let flat = render_frame(&p, cid, Tick::ZERO, 1.0); + let (p, cid) = scene(true, false); + let collapsed = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(max_diff(&flat, &collapsed) < 1e-4, "collapsed differs from flattening by {}", max_diff(&flat, &collapsed)); + // Without collapsing, Add only sees the precomp's own layers (not the grey below), and the + // precomp is resampled twice. + let (p, cid) = scene(false, false); + let nested = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(max_diff(&flat, &nested) > 0.2); + // Opacity of the collapsed layer fades every nested layer. + let (mut p, cid) = scene(true, false); + set(&mut p.comp_mut(cid).unwrap().layers[0], "transform/opacity", Value::Scalar(50.0)); + let half = render_frame(&p, cid, Tick::ZERO, 1.0); + let (x, y) = (100, 50); + let (bg, full) = (0.3, collapsed.get(x, y)[0]); + assert!((half.get(x, y)[0] - (bg + (full - bg) * 0.5)).abs() < 0.03, "{:?} vs {full}", half.get(x, y)); +} + +#[test] +fn masks_on_a_collapsed_layer_force_a_flattened_render() { + let (mut p, cid) = scene(true, false); + let (p2, cid2) = scene(false, false); + // Add a mask covering everything: the layer renders like an uncollapsed precomp. + let mut next = p.next_id; + let mask = build::mask( + &mut Ids(&mut next), + "Mask 1", + effectcraft_keyframe::ShapePath::rect([50.0, 50.0], 4000.0, 4000.0), + effectcraft_project::MaskMode::Add, + [255, 255, 0], + ); + p.next_id = next; + p.comp_mut(cid).unwrap().layers[0].props.sub_mut("masks").unwrap().children.push(mask.into()); + let a = render_frame(&p, cid, Tick::ZERO, 1.0); + let b = render_frame(&p2, cid2, Tick::ZERO, 1.0); + assert!(max_diff(&a, &b) < 1e-4, "{}", max_diff(&a, &b)); +} + +#[test] +fn collapsed_3d_layers_join_the_parent_scene() { + // Parent: a red 3D solid at z = 0 above (in the stack) a collapsed 3D precomp holding a + // blue 3D solid at z = −200 (nearer the camera). Collapsed, depth sorting puts blue in front; + // uncollapsed, the precomp is one plane at z = 0 drawn below the red. + let run = |collapse: bool| { + let mut p = project(); + let outer = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let inner = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut blue = solid(&mut p, &inner, [0.0, 0.0, 1.0], (60, 60), [100.0, 100.0, -200.0]); + blue.switches.three_d = true; + let mut inner = inner; + inner.layers = vec![blue]; + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (200, 200), None); + pre.switches.three_d = true; + pre.switches.collapse = collapse; + let mut red = solid(&mut p, &outer, [1.0, 0.0, 0.0], (100, 100), [100.0, 100.0, 0.0]); + red.switches.three_d = true; + let mut c = outer; + c.layers = vec![red, pre]; + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0).get(100, 100) + }; + let c = run(true); + assert!(c[2] > 0.99 && c[0] < 0.01, "collapsed: blue in front {c:?}"); + let n = run(false); + assert!(n[0] > 0.99 && n[2] < 0.01, "uncollapsed: red on top {n:?}"); +} + +/// Pixels along row `y` from `x0` to `x1` with partial coverage (an edge's width). +fn edge_width(img: &Image, y: i64, x0: i64, x1: i64) -> usize { + (x0..x1).filter(|&x| (0.02..0.98).contains(&img.get(x, y)[3])).count() +} + +#[test] +fn continuously_rasterized_shape_stays_sharp_when_scaled_up() { + let run = |cr: bool| { + let mut p = project(); + let comp = Comp::new(200, 200, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = build::layer(&mut p, &comp, "Shape", LayerSource::Shape, (200, 200), None); + let mut next = p.next_id; + let mut ids = Ids(&mut next); + let rect = build::shape_rect(&mut ids, [10.0, 10.0], [0.0, 0.0], 0.0); + let fill = build::shape_fill(&mut ids, [1.0, 1.0, 1.0, 1.0]); + let g = build::shape_group(&mut ids, "Rectangle 1", vec![rect, fill]); + p.next_id = next; + l.props.sub_mut("contents").unwrap().children.push(g.into()); + set(&mut l, "transform/anchor", Value::Vec3([0.0, 0.0, 0.0])); + set(&mut l, "transform/position", Value::Vec3([100.3, 100.3, 0.0])); + set(&mut l, "transform/scale", Value::Vec3([800.0, 800.0, 100.0])); + l.switches.collapse = cr; + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + render_frame(&p, cid, Tick::ZERO, 1.0) + }; + // The 10 px square scaled 800% spans 60.3..140.3. + let soft = run(false); + let sharp = run(true); + let ws = edge_width(&soft, 100, 50, 75); + let wc = edge_width(&sharp, 100, 50, 75); + assert!(ws >= 4, "bitmap scaling blurs the edge: {ws}"); + assert!(wc <= 1, "continuously rasterized edge: {wc}"); + assert!(sharp.get(100, 100)[3] > 0.99 && sharp.get(55, 100)[3] < 0.01); +} + +#[test] +fn wireframe_quality_draws_the_layer_bounds() { + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = solid(&mut p, &comp, [1.0, 0.0, 0.0], (40, 40), [50.0, 50.0, 0.0]); + l.switches.quality = Quality::Wireframe; + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert_eq!(img.get(50, 50)[3], 0.0, "no fill"); + assert_eq!(img.get(30, 50), [1.0, 1.0, 1.0, 1.0], "outline"); + // Draft quality samples nearest-neighbour: no partial pixels at a half-pixel offset. + let mut p = project(); + let comp = Comp::new(100, 100, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let mut l = solid(&mut p, &comp, [1.0, 1.0, 1.0], (40, 40), [50.5, 50.0, 0.0]); + l.switches.quality = Quality::Draft; + let mut c = comp; + c.layers = vec![l]; + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(c.into())); + let img = render_frame(&p, cid, Tick::ZERO, 1.0); + assert!(img.data.iter().all(|q| q[3] == 0.0 || q[3] == 1.0)); +} diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs new file mode 100644 index 000000000..87f9dd1ae --- /dev/null +++ b/crates/render/src/tests_color.rs @@ -0,0 +1,227 @@ +//! Bit depth, working space and linear blending. + +use std::sync::Arc; + +use effectcraft_color::{BlendMode, ColorSpace, Label}; +use effectcraft_project::build; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame}; + +fn setup(depth: BitDepth) -> (Project, ItemId, Comp) { + let mut p = Project::default(); + p.settings.bit_depth = depth; + let comp = Comp::new(40, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + (p, cid, comp) +} + +fn add_solid(p: &mut Project, cid: ItemId, color: [f32; 3], mode: BlendMode, opacity: f64) { + let comp = p.comp(cid).unwrap().clone(); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: 40, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(p, &comp, "S", LayerSource::Solid { item: sid }, (40, 20), None); + l.blend_mode = mode; + l.props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(opacity); + // New layers go on top. + p.comp_mut(cid).unwrap().layers.insert(0, l); +} + +fn px(p: &Project, cid: ItemId) -> [f32; 4] { + render_frame(p, cid, Tick::ZERO, 1.0).get(10, 10) +} + +#[test] +fn add_clamps_in_8_and_16_but_not_32_bpc() { + for (depth, want) in [(BitDepth::Bpc8, 1.0), (BitDepth::Bpc16, 1.0), (BitDepth::Bpc32, 1.6)] { + let (mut p, cid, _) = setup(depth); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0); + let v = px(&p, cid)[0]; + assert!((v - want).abs() < 1e-5, "{depth:?}: {v}"); + } + // Screen of over-range values survives in 32 bpc only: a 1.6 base screened with 0.5. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0); + add_solid(&mut p, cid, [0.5, 0.5, 0.5], BlendMode::Screen, 100.0); + let v = px(&p, cid)[0]; + assert!((v - (1.6 + 0.5 - 0.8)).abs() < 1e-5, "{v}"); +} + +#[test] +fn quantization_steps_per_depth() { + let want = |depth| match depth { + BitDepth::Bpc8 => (0.3f32 * 255.0).round() / 255.0, + BitDepth::Bpc16 => (0.3f32 * 32768.0).round() / 32768.0, + BitDepth::Bpc32 => 0.3, + }; + for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] { + let (mut p, cid, _) = setup(depth); + add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0); + let v = px(&p, cid)[0]; + assert!((v - want(depth)).abs() < 1e-7, "{depth:?}: {v} vs {}", want(depth)); + } + // 8 bpc: 77/255 = 0.30196 (one step is 1/255); 16 bpc is within 1/65536. + let (mut p8, c8, _) = setup(BitDepth::Bpc8); + add_solid(&mut p8, c8, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0); + assert!((px(&p8, c8)[0] - 77.0 / 255.0).abs() < 1e-7); + // A 1% opacity layer of white over black lands on a whole 8-bit step. + let (mut p, cid, _) = setup(BitDepth::Bpc8); + add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 1.0); + let v = px(&p, cid)[0] * 255.0; + assert!((v - v.round()).abs() < 1e-4 && (v - 2.55).abs() < 1.0, "{v}"); +} + +#[test] +fn linear_blending_of_red_over_green() { + // 50% red over green: gamma blending gives (0.5, 0.5, 0); 1.0-gamma blending mixes linear + // light, so each channel is the sRGB encoding of 0.5 linear ≈ 0.735. + let run = |linear: bool| { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.blend_linear = linear; + add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0); + px(&p, cid) + }; + let g = run(false); + assert!((g[0] - 0.5).abs() < 1e-5 && (g[1] - 0.5).abs() < 1e-5 && g[2].abs() < 1e-6, "{g:?}"); + let l = run(true); + let e = effectcraft_color::linear_to_srgb(0.5); + assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4 && l[2].abs() < 1e-6, "{l:?}"); + assert!((e - 0.7354).abs() < 1e-3); + // Unblended (opaque) layers are unchanged by linear blending. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.blend_linear = true; + add_solid(&mut p, cid, [0.25, 0.5, 0.75], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!((o[0] - 0.25).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.75).abs() < 1e-5, "{o:?}"); +} + +#[test] +fn linearized_working_space_blends_linear_and_round_trips_colours() { + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.linearize = true; + add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0); + let l = px(&p, cid); + let e = effectcraft_color::linear_to_srgb(0.5); + assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4, "{l:?}"); + // An opaque solid shows its own colour (linearised in, encoded out). + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.linearize = true; + add_solid(&mut p, cid, [0.2, 0.5, 0.9], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!((o[0] - 0.2).abs() < 1e-4 && (o[1] - 0.5).abs() < 1e-4 && (o[2] - 0.9).abs() < 1e-4, "{o:?}"); + // Linearize needs a working space (like AE, where it is disabled without one). + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.linearize = true; + add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0); + assert!((px(&p, cid)[0] - 0.5).abs() < 1e-5); +} + +#[test] +fn wide_working_space_colours_leave_the_display_gamut() { + // Pure Rec. 2020 red is outside sRGB: in 32 bpc the display conversion keeps the + // over-range/negative values; 8 bpc clamps them. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Rec2020); + add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!(o[0] > 1.0 && o[1] < 0.0 && o[2] < 0.0, "{o:?}"); + p.settings.bit_depth = BitDepth::Bpc8; + let o = px(&p, cid); + assert!((o[0] - 1.0).abs() < 1e-6 && o[1] == 0.0 && o[2] == 0.0, "{o:?}"); + // sRGB working space: no conversion at all. + let (mut p, cid, _) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Srgb); + add_solid(&mut p, cid, [0.3, 0.6, 0.9], BlendMode::Normal, 100.0); + let o = px(&p, cid); + assert!((o[0] - 0.3).abs() < 1e-6 && (o[2] - 0.9).abs() < 1e-6); +} + +/// One flat-colour footage frame. +struct Flat([f32; 3]); +impl FootageSource for Flat { + fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option> { + let c = self.0; + Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0]))) + } +} + +fn footage(profile: Option) -> Footage { + Footage { + path: "x.png".into(), + kind: FootageKind::Still, + width: 40, + height: 20, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_30, + native_rate: None, + duration: Tick::ZERO, + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + color_profile: profile, + } +} + +#[test] +fn footage_profile_is_converted_into_the_working_space() { + let render = |ws: Option, profile: Option| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_space = ws; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(footage(profile))); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = Flat([1.0, 0.0, 0.0]); + Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5) + }; + // Unmanaged: the file's values pass straight through. + let o = render(None, Some(ColorSpace::Rec2020)); + assert_eq!(o, [1.0, 0.0, 0.0, 1.0]); + // Managed: Rec. 2020 red shown on an sRGB display is beyond sRGB red. + let o = render(Some(ColorSpace::Srgb), Some(ColorSpace::Rec2020)); + assert!(o[0] > 1.0 && o[1] < 0.0, "{o:?}"); + // Untagged footage is sRGB: a round trip through a Rec. 2020 working space. + let o = render(Some(ColorSpace::Rec2020), None); + assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}"); +} + +#[test] +fn layer_cache_is_keyed_by_colour_settings() { + let (mut p, cid, _) = setup(BitDepth::Bpc8); + add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0); + let cache = LayerCache::default(); + let frame = |p: &Project| { + let mut r = Renderer::new(p, &crate::NoFootage, RenderOpts::default()); + r.cache = Some(&cache); + r.comp_frame(cid, Tick::ZERO).get(10, 10)[0] + }; + let a = frame(&p); + assert!((a - 77.0 / 255.0).abs() < 1e-7); + assert_eq!(cache.stats().entries, 1); + p.settings.bit_depth = BitDepth::Bpc32; + let b = frame(&p); + assert!((b - 0.3).abs() < 1e-7, "stale 8 bpc pixels: {b}"); + p.settings.working_space = Some(ColorSpace::Srgb); + p.settings.linearize = true; + let _ = frame(&p); + assert_eq!(cache.stats().entries, 3, "each setting renders its own entry"); + // Back to 8 bpc reuses the first entry. + p.settings.bit_depth = BitDepth::Bpc8; + p.settings.working_space = None; + p.settings.linearize = false; + let hits = cache.stats().hits; + assert!((frame(&p) - a).abs() < 1e-9); + assert_eq!(cache.stats().hits, hits + 1); +} diff --git a/crates/render/src/tests_frame_blend.rs b/crates/render/src/tests_frame_blend.rs new file mode 100644 index 000000000..c172b81c8 --- /dev/null +++ b/crates/render/src/tests_frame_blend.rs @@ -0,0 +1,168 @@ +//! Frame blending (Frame Mix and Pixel Motion) for footage and precomp layers. + +use std::sync::Arc; + +use rayon::prelude::*; + +use effectcraft_color::Label; +use effectcraft_keyframe::{Keyframe, Value}; +use effectcraft_project::build; +use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, FrameBlend, ItemId, ItemKind, LayerSource, Project, Solid}; +use effectcraft_time::{FrameRate, Tick}; + +use crate::{FootageSource, Image, RenderOpts, Renderer, frame_position}; + +#[test] +fn frame_mix_weights_for_24_to_30_conform() { + // Comp frame k at 30 fps sits at source frame 0.8·k of 24 fps footage. + let want = [(0, 0.0), (0, 0.8), (1, 0.6), (2, 0.4), (3, 0.2), (4, 0.0), (4, 0.8)]; + for (k, (i, w)) in want.into_iter().enumerate() { + let t = FrameRate::FPS_30.tick_of(k as i64); + let (fi, fw) = frame_position(t, FrameRate::FPS_24); + assert_eq!(fi, i, "comp frame {k}"); + assert!((fw - w).abs() < 1e-12, "comp frame {k}: {fw} vs {w}"); + } + // NTSC 29.97 footage in a 29.97 comp lines up exactly. + for k in [0, 1, 7, 1001, 30_000] { + assert_eq!(frame_position(FrameRate::FPS_29_97.tick_of(k), FrameRate::FPS_29_97), (k, 0.0)); + } +} + +/// Footage whose frame `i` is a flat grey of `i / 10` (24 fps), or a textured pattern moving +/// 6 px right per frame. +struct Frames { + moving: bool, +} + +fn pattern(w: u32, h: u32, dx: f64) -> Image { + let mut img = Image::new(w, h); + img.rows_mut().for_each(|(y, row)| { + for (x, p) in row.iter_mut().enumerate() { + let (u, v) = (x as f64 - dx, y as f64); + let s = (0.5 + 0.2 * (u * 0.21 + v * 0.07).sin() + 0.15 * (u * 0.05 - v * 0.19).cos()) as f32; + *p = [s, s * 0.8, 1.0 - s, 1.0]; + } + }); + img +} + +impl FootageSource for Frames { + fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option> { + let i = f.frame_rate.frame_at(t); + if self.moving { + return Some(Arc::new(pattern(f.width, f.height, 6.0 * i as f64))); + } + let g = i as f32 / 10.0; + Some(Arc::new(Image::filled(f.width, f.height, [g, g, g, 1.0]))) + } +} + +fn setup(mode: FrameBlend, size: (u32, u32)) -> (Project, ItemId) { + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let comp = Comp::new(size.0, size.1, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); + let f = Footage { + path: "clip.mov".into(), + kind: FootageKind::Video, + width: size.0, + height: size.1, + pixel_aspect: 1.0, + frame_rate: FrameRate::FPS_24, + native_rate: None, + duration: Tick::from_seconds_f64(2.0), + has_video: true, + has_audio: false, + alpha: AlphaMode::Ignore, + premul_color: [0.0; 3], + loop_count: 1, + codec: String::new(), + missing: false, + sequence: vec![], + color_profile: None, + }; + let fid = p.add_item("clip", Label::Aqua, None, ItemKind::Footage(f)); + let mut l = build::layer(&mut p, &comp, "clip", LayerSource::Footage { item: fid }, size, None); + l.switches.frame_blend = mode; + p.comp_mut(cid).unwrap().layers.push(l); + (p, cid) +} + +fn frame(p: &Project, cid: ItemId, k: i64, moving: bool) -> Image { + let src = Frames { moving }; + Renderer::new(p, &src, RenderOpts::default()).comp_frame(cid, FrameRate::FPS_30.tick_of(k)) +} + +#[test] +fn frame_mix_blends_footage_frames() { + let (p, cid) = setup(FrameBlend::FrameMix, (16, 8)); + // Comp frame 1 → source 0.8: 0.2·frame 0 + 0.8·frame 1 = 0.08. + let v = frame(&p, cid, 1, false).get(4, 4)[0]; + assert!((v - 0.08).abs() < 1e-6, "{v}"); + let v = frame(&p, cid, 2, false).get(4, 4)[0]; + assert!((v - (0.4 * 0.1 + 0.6 * 0.2)).abs() < 1e-6, "{v}"); + // Off (layer switch or comp switch): the frame at or before. + let (p, cid) = setup(FrameBlend::Off, (16, 8)); + assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6); + let (mut p, cid) = setup(FrameBlend::FrameMix, (16, 8)); + p.comp_mut(cid).unwrap().enable_frame_blending = false; + assert!((frame(&p, cid, 1, false).get(4, 4)[0] - 0.0).abs() < 1e-6); +} + +fn mae(a: &Image, b: &Image, margin: u32) -> f32 { + let mut s = 0.0; + let mut n = 0; + for y in margin..a.height - margin { + for x in margin..a.width - margin { + let (p, q) = (a.get(x as i64, y as i64), b.get(x as i64, y as i64)); + s += (0..3).map(|c| (p[c] - q[c]).abs()).sum::(); + n += 3; + } + } + s / n as f32 +} + +#[test] +fn pixel_motion_beats_frame_mix_on_moving_footage() { + let size = (128, 96); + // Comp frame 2 → source frame 1.6: the pattern 9.6 px along. + let truth = pattern(size.0, size.1, 6.0 * 1.6); + let (p, cid) = setup(FrameBlend::FrameMix, size); + let mix = mae(&frame(&p, cid, 2, true), &truth, 14); + let (p, cid) = setup(FrameBlend::PixelMotion, size); + let pm = mae(&frame(&p, cid, 2, true), &truth, 14); + assert!(pm < mix * 0.3, "pixel motion {pm} vs frame mix {mix}"); +} + +#[test] +fn time_stretched_precomp_frame_mixes() { + // Inner comp: a white solid moving 10 px per frame from x = 10. The precomp is stretched to + // 200%, so odd outer frames fall halfway between inner frames. + let mut p = Project::default(); + p.settings.bit_depth = BitDepth::Bpc32; + let mut inner = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); + let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color: [1.0; 3], width: 10, height: 20, pixel_aspect: 1.0 })); + let mut l = build::layer(&mut p, &inner, "S", LayerSource::Solid { item: sid }, (10, 20), None); + // Anchor at the left edge: x = 10 + 10 per frame. + l.props.prop_mut("transform/anchor").unwrap().value = Value::Vec3([0.0, 10.0, 0.0]); + l.props.prop_mut("transform/position").unwrap().keys = + vec![Keyframe::new(Tick::ZERO, Value::Vec3([10.0, 10.0, 0.0])), Keyframe::new(Tick::from_seconds_f64(1.0), Value::Vec3([310.0, 10.0, 0.0]))]; + inner.layers.push(l); + let iid = p.add_item("Inner", Label::Sandstone, None, ItemKind::Comp(inner.into())); + let outer = Comp::new(400, 20, FrameRate::FPS_30, Tick::from_seconds_f64(4.0)); + let cid = p.add_item("Outer", Label::Sandstone, None, ItemKind::Comp(outer.clone().into())); + let mut pre = build::layer(&mut p, &outer, "Inner", LayerSource::Comp { item: iid }, (400, 20), None); + pre.stretch = 200.0; + pre.out_point = Tick::from_seconds_f64(4.0); + pre.switches.frame_blend = FrameBlend::FrameMix; + p.comp_mut(cid).unwrap().layers.push(pre); + // Outer frame 1 → inner frame 0.5: solids at 10..20 (frame 0) and 20..30 (frame 1), half each. + let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0); + for x in [12, 17, 22, 27] { + assert!((img.get(x, 10)[3] - 0.5).abs() < 5e-3, "x {x}: {:?}", img.get(x, 10)); + } + // Without frame blending the nested comp renders at the in-between time: one solid, 15..25. + p.comp_mut(cid).unwrap().layers[0].switches.frame_blend = FrameBlend::Off; + let img = crate::render_frame(&p, cid, FrameRate::FPS_30.tick_of(1), 1.0); + assert!(img.get(12, 10)[3] < 1e-3 && (img.get(17, 10)[3] - 1.0).abs() < 1e-3 && (img.get(22, 10)[3] - 1.0).abs() < 1e-3 && img.get(27, 10)[3] < 1e-3); +} diff --git a/crates/render/src/three_d/compose.rs b/crates/render/src/three_d/compose.rs index 53023c507..53ba8cdbc 100644 --- a/crates/render/src/three_d/compose.rs +++ b/crates/render/src/three_d/compose.rs @@ -150,6 +150,10 @@ fn unproject(g: &Geo, sx: f64, sy: f64) -> Option<(f64, f64, f64)> { /// The camera a renderer uses at a context time (view override only for the top-level comp). pub(crate) fn camera_for(r: &Renderer, ctx: &EvalCtx) -> CameraState { + // Collapsed precomps see through the parent's camera. + if let Some(c) = r.collapse3d { + return c.cam; + } match r.opts.view { Some(v) if r.depth == 0 => v, _ => active_camera(ctx), @@ -167,7 +171,7 @@ fn mb_samples(r: &Renderer, ctx: &EvalCtx, layer: &Layer) -> usize { /// Prepare a layer (buffer, geometry, material). `None` when it shows nothing. pub(crate) fn prepare<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, order: usize, out: (u32, u32), in_run: bool) -> Option> { - let buf = r.layer_buf(ctx, layer)?; + let buf = r.blend_layer_buf(ctx, layer)?; prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY) } @@ -176,7 +180,7 @@ pub(crate) fn prepare_all<'a>(r: &Renderer, ctx: &EvalCtx<'a>, layer: &'a Layer, if crate::text::per_char_3d(ctx, layer) { return crate::text::per_char_planes(ctx, layer, r.opts.scale) .into_iter() - .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, Arc::new(buf), m)) + .filter_map(|(buf, m)| prepare_with(r, ctx, layer, order, out, in_run, r.to_blend(Arc::new(buf), None), m)) .collect(); } prepare(r, ctx, layer, order, out, in_run).into_iter().collect() @@ -200,7 +204,8 @@ fn prepare_with<'a>( let comp = (ctx.comp.width as f64, ctx.comp.height as f64); let s = r.opts.scale; let cam = camera_for(r, ctx); - let world = ctx.world_matrix(layer) * local; + let outer = r.collapse3d.map_or(Mat4::IDENTITY, |c| c.world); + let world = outer * ctx.world_matrix(layer) * local; // Depth of field: blur the layer by its circle of confusion at its centre. if let Some(dof) = cam.dof.filter(|_| !ctx.comp.draft_3d && !r.opts.draft) { let b = buf_bounds(&buf); @@ -234,7 +239,7 @@ fn prepare_with<'a>( let f = phase + angle * i as f64 / (n - 1) as f64; let sub = ctx.at(ctx.time + Tick::from_seconds_f64(f * fd)); let c = camera_for(r, &sub); - geo(&c, sub.world_matrix(layer) * local, &buf, comp, s, out) + geo(&c, outer * sub.world_matrix(layer) * local, &buf, comp, s, out) }) .collect() }; @@ -253,7 +258,7 @@ fn prepare_with<'a>( bicubic: matches!(r.sampling(layer), effectcraft_raster::Sampling::Bicubic), buf, geos, - opacity: ctx.opacity(layer) as f32, + opacity: ctx.opacity(layer) as f32 * r.opacity_mul(), draw: in_run && mat.casts_shadows != 2, mat, matte: None, @@ -319,12 +324,18 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut return; } let out = (canvas.width, canvas.height); - let lights = scene_lights(ctx); + // A collapsed precomp's 3D layers are lit by the outer comp's lights. + let lights = scene_lights(&r.collapse3d.map_or(*ctx, |c| c.parent)); let mut items: Vec = run .par_iter() .enumerate() - .filter(|(_, l)| ctx.opacity(l) > 0.0) + .filter(|(_, l)| ctx.opacity(l) > 0.0 && l.switches.quality != effectcraft_project::Quality::Wireframe) .flat_map_iter(|(i, l)| { + // Stack order with room for a collapsed precomp's layers in between. + let i = i * 1024; + if let Some(item) = r.collapsed(ctx, l) { + return collapsed_items(r, ctx, l, item, i, out); + } let mut its = prepare_all(r, ctx, l, i, out, true); if !its.is_empty() { let m = matte_for(r, ctx, l, out); @@ -351,6 +362,49 @@ pub(crate) fn draw_run(r: &Renderer, ctx: &EvalCtx, run: &[&Layer], canvas: &mut items.sort_by_key(|i| i.order); let casters: Vec<&Item> = items.iter().chain(extra.iter()).filter(|i| i.mat.casts_shadows != 0 && !i.geos.is_empty()).collect(); composite(canvas, &items, &lights, &casters, run[0].id.0 as u32); + for l in run.iter().filter(|l| l.switches.quality == effectcraft_project::Quality::Wireframe) { + r.draw_layer(ctx, l, canvas); + } +} + +/// A collapsed 3D precomp layer inside a 3D run: its nested layers become planes in this comp's +/// 3D space (world = precomp layer's world × nested world), depth-sorted with the run, so they +/// intersect the parent's 3D layers; nested 2D layers lie on the precomp layer's plane in stack +/// order. (Nested adjustment layers are skipped here.) +fn collapsed_items<'a>( + r: &Renderer<'a>, + ctx: &EvalCtx<'a>, + layer: &'a Layer, + item: effectcraft_project::ItemId, + order: usize, + out: (u32, u32), +) -> Vec> { + let op = ctx.opacity(layer) as f32 * r.opacity_mul(); + let Some((sub, nctx)) = r.collapse_into(ctx, layer, item, op) else { return vec![] }; + let t = nctx.time; + let any_solo = nctx.comp.layers.iter().any(|l| l.switches.solo && l.source.is_av() && l.is_active_at(t)); + let nested: Vec<&'a Layer> = nctx + .comp + .layers + .iter() + .rev() + .filter(|l| l.is_active_at(t) && l.has_video() && !l.switches.adjustment && (!any_solo || l.switches.solo) && (r.opts.guides || !l.switches.guide)) + .collect(); + let mut items = Vec::new(); + for (k, l) in nested.into_iter().enumerate() { + if nctx.opacity(l) <= 0.0 { + continue; + } + let mut its = prepare_all(&sub, &nctx, l, order + 1 + k.min(1022), out, true); + if !its.is_empty() { + let m = matte_for(&sub, &nctx, l, out); + for it in &mut its { + it.matte = m.clone(); + } + } + items.extend(its); + } + items } /// Per-pixel depth-sorted compositing of prepared planes. diff --git a/crates/render/src/three_d/tests.rs b/crates/render/src/three_d/tests.rs index 2a068e3ac..01e0399df 100644 --- a/crates/render/src/three_d/tests.rs +++ b/crates/render/src/three_d/tests.rs @@ -14,6 +14,8 @@ const H: u32 = 100; fn setup() -> (Project, ItemId, Comp) { let mut p = Project::default(); + // Exact float maths; 8/16 bpc quantisation has its own tests (tests_color). + p.settings.bit_depth = effectcraft_project::BitDepth::Bpc32; let comp = Comp::new(W, H, FrameRate::FPS_30, Tick::from_seconds_f64(2.0)); let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into())); (p, cid, comp) diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index 77e432b94..e0f1a5f21 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -218,7 +218,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), ], ), - "file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect"]) => { + "file.interpretFootage" if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile"]) => { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match &it.kind { effectcraft_engine::project::ItemKind::Footage(f) => Some(f.clone()), _ => None, @@ -236,6 +236,18 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ), Field::num("loop", "Loop (times)", f.loop_count as f64), Field::num("pixelAspect", "Pixel aspect ratio", f.pixel_aspect), + Field::choice( + "colorProfile", + "Color profile", + &[ + ("Embedded / sRGB", json!("auto")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ], + f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), ], ) } @@ -261,7 +273,20 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { &[("Timecode", json!("timecode")), ("Frames", json!("frames"))], usize::from(matches!(st.time_display, effectcraft_engine::project::TimeDisplayStyle::Frames)), ), + Field::choice( + "workingSpace", + "Working space", + &[ + ("None", json!("none")), + ("sRGB IEC61966-2.1", json!("srgb")), + ("HDTV (Rec. 709)", json!("rec709")), + ("Rec. 2020", json!("rec2020")), + ("Display P3", json!("p3")), + ], + st.working_space.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), + ), Field::bool("linearize", "Linearize working space", st.linearize), + Field::bool("blendLinear", "Blend colors using 1.0 gamma", st.blend_linear), ], ) } diff --git a/crates/ui-egui/src/panels/project.rs b/crates/ui-egui/src/panels/project.rs index 4d0e0bb12..31d7d4de1 100644 --- a/crates/ui-egui/src/panels/project.rs +++ b/crates/ui-egui/src/panels/project.rs @@ -311,9 +311,15 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let dresp = ui.interact(dr, egui::Id::new("bpc"), Sense::click()); p.text(dr.center(), Align2::CENTER_CENTER, depth, Tokens::ui(11.5), if dresp.hovered() { t.text } else { t.text_dim }); app.auto.add("project.bitDepth", dr, depth); + // Click cycles 8/16/32 bpc; Alt-click opens Project Settings (as in After Effects). if dresp.clicked() { - actions.push(("file.cycleBitDepth".into(), json!({}))); + if ui.input(|i| i.modifiers.alt) { + actions.push(("file.projectSettings".into(), json!({}))); + } else { + actions.push(("file.cycleBitDepth".into(), json!({}))); + } } + dresp.on_hover_text("Project color depth: click to cycle 8/16/32 bpc, Alt-click for Project Settings"); let tr = Rect::from_min_size(pos2(foot.max.x - 30.0, foot.min.y + 3.0), vec2(22.0, 22.0)); if widgets::icon_button(ui, tr, Icon::Trash, false, &t, egui::Id::new("ptrash")).on_hover_text("Delete selected project items").clicked() { let sel = app.session.state.project_selection.clone(); diff --git a/crates/ui-egui/src/panels/timeline.rs b/crates/ui-egui/src/panels/timeline.rs index ab85e8eb1..be891090f 100644 --- a/crates/ui-egui/src/panels/timeline.rs +++ b/crates/ui-egui/src/panels/timeline.rs @@ -953,8 +953,40 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { { let xs = tm.x(layer.start_time.seconds()); let xe = tm.x(layer.comp_time(d).seconds()); - let ghost = Rect::from_min_max(pos2(xs, r.min.y + 9.0), pos2(xe, r.max.y - 9.0)); + let ghost = Rect::from_min_max(pos2(xs.min(xe), r.min.y + 9.0), pos2(xs.max(xe), r.max.y - 9.0)); gp.rect_filled(ghost, 1.0, lc.gamma_multiply(0.18)); + // Dragging the source bar outside the in/out span slips the source. + for (side, gr) in [ + ("l", Rect::from_min_max(ghost.min, pos2(bar.min.x, ghost.max.y))), + ("r", Rect::from_min_max(pos2(bar.max.x, ghost.min.y), ghost.max)), + ] { + if gr.width() < 2.0 { + continue; + } + let gresp = ui.interact(gr, egui::Id::new(("ghost", side, layer.id.0)), Sense::drag()).on_hover_text("Drag to slip the source"); + app.auto.add(&format!("timeline.layer.{}.source{}", layer.id.0, side), gr, "Slip"); + if gresp.hovered() || gresp.dragged() { + ctx.set_cursor_icon(egui::CursorIcon::ResizeHorizontal); + } + let acc_id = egui::Id::new(("slip-acc", layer.id.0)); + if gresp.dragged() { + let mut acc: f32 = ctx.data(|d| d.get_temp(acc_id).unwrap_or(0.0)); + acc += gresp.drag_delta().x; + let frames = ((acc as f64 / pps) / fd).trunc() as i64; + if frames != 0 { + acc -= (frames as f64 * fd * pps) as f32; + actions.push(( + "layer.slip".into(), + json!({"layers": [layer.id.0], "frames": frames, "merge": format!("slip-{}", layer.id.0)}), + )); + } + ctx.data_mut(|d| d.insert_temp(acc_id, acc)); + } + if gresp.drag_stopped() { + ctx.data_mut(|d| d.remove::(acc_id)); + ui_actions.push(UiAct::EndMerge); + } + } } app.auto.add(&format!("timeline.layer.{}.bar", layer.id.0), bar, &layer.name); // Interactions: move body, trim edges. diff --git a/docs/architecture.md b/docs/architecture.md index b78865817..dc95f35b5 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -103,6 +103,30 @@ groups into `Params` keys (`effects::flatten_params`). Commands: `paint.*`, `pup Half, Third and Quarter resolution render proportionally fewer pixels end to end. +**Colour and bit depth** (`crates/render/src/color.rs`, `crates/color/src/space.rs`). Pixels are +`f32`, but 8 and 16 bpc projects clamp and quantise each layer after its source and masks and +after every effect, and the comp after every layer (16 bpc uses 0..32768), so over-range values +(Add, Screen, Exposure…) only survive in 32 bpc; blend modes without HDR support clamp their +inputs in 32 bpc. With a working space (sRGB, Rec. 709, Rec. 2020, Display P3; matrices derived +from the published primaries), footage is converted from its colour profile (stream metadata or +Interpret Footage, else sRGB) and the top-level comp is converted to the sRGB display. Linearize +Working Space runs everything in linear light; Blend Colors Using 1.0 Gamma linearises only for +blending. Layer cache keys include these settings. + +**Frame blending**: footage and precomp layers whose source time falls between source frames +(rate conform, stretch, remap) blend the two neighbouring frames, by cross-fade (Frame Mix) or by +hierarchical block-matching optical flow and a bidirectional warp (Pixel Motion, +`raster::flow`). + +**Collapse Transformations**: a collapsed precomp layer without masks, effects or styles draws +its nested layers straight into the parent with concatenated transforms (one resample), so +nested blend modes and adjustment layers act on the parent's layers; nested 3D layers use the +parent's camera and lights and, when the precomp layer is 3D, are depth-sorted with the parent's +3D run. Masks, effects or styles force a flattened render. On text and shape layers the switch is +Continuously Rasterize: the source is rasterised at its on-screen scale. Quality: Draft samples +nearest-neighbour, Wireframe draws the layer bounds. Slip edit (`layer.slip`, Alt+PageUp/Down, +dragging the source bar in the timeline) moves the source under fixed in/out points. + **Motion tracking** (Animation ▸ Track Motion / Stabilize Motion, Window ▸ Tracker): a tracker is a `Tracker` group under the layer's Motion Trackers group, with Track Point groups (Feature Center, Feature Size, Search Offset, Search Size, Confidence, Attach Point, Attach Point Offset)