mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-02 22:42:24 -06:00
Merge branch 'worktree-agent-a1cbc81aaaa5b80bc'
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -0,0 +1,273 @@
|
||||
//! RGB colour spaces for colour management: the project working space, footage interpretation
|
||||
//! and the display.
|
||||
//!
|
||||
//! Each space is defined by its published primaries and white point and its transfer function:
|
||||
//!
|
||||
//! * **sRGB IEC 61966-2-1** — Rec. 709 primaries, D65, the piecewise sRGB curve.
|
||||
//! * **Rec. 709** — ITU-R BT.709-6 primaries, D65, the BT.1886 display EOTF (gamma 2.4).
|
||||
//! * **Rec. 2020** — ITU-R BT.2020-2 primaries, D65, BT.1886 (gamma 2.4).
|
||||
//! * **Display P3** — SMPTE EG 432-1 (DCI-P3) primaries with D65 and the sRGB curve.
|
||||
//!
|
||||
//! RGB → XYZ matrices are derived from the primaries (the standard construction from chromaticity
|
||||
//! coordinates: columns of xyY → XYZ scaled so that RGB (1, 1, 1) maps to the white point), so
|
||||
//! conversions between any two spaces are exact up to `f64` rounding. Transfer functions extend
|
||||
//! to over-range and negative values (32 bpc) by mirroring around zero.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// An RGB colour space (all D65).
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
|
||||
pub enum ColorSpace {
|
||||
/// sRGB IEC 61966-2-1.
|
||||
Srgb,
|
||||
/// HDTV Rec. 709 (gamma 2.4).
|
||||
Rec709,
|
||||
/// UHDTV Rec. 2020 (gamma 2.4).
|
||||
Rec2020,
|
||||
/// Display P3.
|
||||
DisplayP3,
|
||||
}
|
||||
|
||||
/// CIE xy chromaticity of D65.
|
||||
const D65: [f64; 2] = [0.3127, 0.3290];
|
||||
|
||||
impl ColorSpace {
|
||||
pub const ALL: [ColorSpace; 4] = [ColorSpace::Srgb, ColorSpace::Rec709, ColorSpace::Rec2020, ColorSpace::DisplayP3];
|
||||
|
||||
/// Display name (as in Project Settings ▸ Color ▸ Working Space).
|
||||
pub fn label(self) -> &'static str {
|
||||
match self {
|
||||
ColorSpace::Srgb => "sRGB IEC61966-2.1",
|
||||
ColorSpace::Rec709 => "HDTV (Rec. 709)",
|
||||
ColorSpace::Rec2020 => "Rec. 2020",
|
||||
ColorSpace::DisplayP3 => "Display P3",
|
||||
}
|
||||
}
|
||||
|
||||
/// Short identifier used by commands and files (`srgb`, `rec709`, `rec2020`, `p3`).
|
||||
pub fn id(self) -> &'static str {
|
||||
match self {
|
||||
ColorSpace::Srgb => "srgb",
|
||||
ColorSpace::Rec709 => "rec709",
|
||||
ColorSpace::Rec2020 => "rec2020",
|
||||
ColorSpace::DisplayP3 => "p3",
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse an id or label (case-insensitive, loose).
|
||||
pub fn parse(s: &str) -> Option<ColorSpace> {
|
||||
let k: String = s.chars().filter(|c| c.is_ascii_alphanumeric()).collect::<String>().to_ascii_lowercase();
|
||||
match k.as_str() {
|
||||
"srgb" | "srgbiec6196621" | "srgbiec61966" | "iec6196621" => Some(ColorSpace::Srgb),
|
||||
"rec709" | "bt709" | "hdtvrec709" | "hdtv" | "709" => Some(ColorSpace::Rec709),
|
||||
"rec2020" | "bt2020" | "2020" | "uhdtv" => Some(ColorSpace::Rec2020),
|
||||
"p3" | "displayp3" | "p3d65" | "dcip3d65" => Some(ColorSpace::DisplayP3),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Red, green and blue primaries (CIE xy).
|
||||
pub fn primaries(self) -> [[f64; 2]; 3] {
|
||||
match self {
|
||||
ColorSpace::Srgb | ColorSpace::Rec709 => [[0.640, 0.330], [0.300, 0.600], [0.150, 0.060]],
|
||||
ColorSpace::Rec2020 => [[0.708, 0.292], [0.170, 0.797], [0.131, 0.046]],
|
||||
ColorSpace::DisplayP3 => [[0.680, 0.320], [0.265, 0.690], [0.150, 0.060]],
|
||||
}
|
||||
}
|
||||
|
||||
/// Linear RGB → CIE XYZ (Y of white = 1).
|
||||
pub fn to_xyz(self) -> [[f64; 3]; 3] {
|
||||
rgb_to_xyz(self.primaries(), D65)
|
||||
}
|
||||
|
||||
/// Encoded value → linear light.
|
||||
pub fn decode(self, v: f32) -> f32 {
|
||||
let a = v.abs();
|
||||
let l = match self {
|
||||
ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::srgb_to_linear(a),
|
||||
ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(2.4),
|
||||
};
|
||||
l.copysign(v)
|
||||
}
|
||||
|
||||
/// Linear light → encoded value.
|
||||
pub fn encode(self, v: f32) -> f32 {
|
||||
let a = v.abs();
|
||||
let e = match self {
|
||||
ColorSpace::Srgb | ColorSpace::DisplayP3 => crate::linear_to_srgb(a),
|
||||
ColorSpace::Rec709 | ColorSpace::Rec2020 => a.powf(1.0 / 2.4),
|
||||
};
|
||||
e.copysign(v)
|
||||
}
|
||||
}
|
||||
|
||||
/// The standard RGB → XYZ matrix from primaries and white point.
|
||||
pub fn rgb_to_xyz(p: [[f64; 2]; 3], white: [f64; 2]) -> [[f64; 3]; 3] {
|
||||
let xyz = |c: [f64; 2]| [c[0] / c[1], 1.0, (1.0 - c[0] - c[1]) / c[1]];
|
||||
let (r, g, b) = (xyz(p[0]), xyz(p[1]), xyz(p[2]));
|
||||
let m = [[r[0], g[0], b[0]], [r[1], g[1], b[1]], [r[2], g[2], b[2]]];
|
||||
let w = xyz(white);
|
||||
let s = mul_vec(&invert(&m), w);
|
||||
let mut o = m;
|
||||
for row in &mut o {
|
||||
for j in 0..3 {
|
||||
row[j] *= s[j];
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
/// 3×3 matrix inverse (the matrices here are always well conditioned).
|
||||
pub fn invert(m: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
let c = |r0: usize, c0: usize, r1: usize, c1: usize| m[r0][c0] * m[r1][c1] - m[r0][c1] * m[r1][c0];
|
||||
let det = m[0][0] * c(1, 1, 2, 2) - m[0][1] * c(1, 0, 2, 2) + m[0][2] * c(1, 0, 2, 1);
|
||||
let k = 1.0 / det;
|
||||
[
|
||||
[c(1, 1, 2, 2) * k, -c(0, 1, 2, 2) * k, c(0, 1, 1, 2) * k],
|
||||
[-c(1, 0, 2, 2) * k, c(0, 0, 2, 2) * k, -c(0, 0, 1, 2) * k],
|
||||
[c(1, 0, 2, 1) * k, -c(0, 0, 2, 1) * k, c(0, 0, 1, 1) * k],
|
||||
]
|
||||
}
|
||||
|
||||
pub fn mul(a: &[[f64; 3]; 3], b: &[[f64; 3]; 3]) -> [[f64; 3]; 3] {
|
||||
let mut o = [[0.0; 3]; 3];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
o[i][j] = (0..3).map(|k| a[i][k] * b[k][j]).sum();
|
||||
}
|
||||
}
|
||||
o
|
||||
}
|
||||
|
||||
pub fn mul_vec(m: &[[f64; 3]; 3], v: [f64; 3]) -> [f64; 3] {
|
||||
[0, 1, 2].map(|i| m[i][0] * v[0] + m[i][1] * v[1] + m[i][2] * v[2])
|
||||
}
|
||||
|
||||
/// A colour conversion between two spaces, each either encoded (with its transfer curve) or
|
||||
/// linear. Applied to straight (un-premultiplied) RGB.
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Conversion {
|
||||
/// Decode the input with this space's curve first.
|
||||
pub decode: Option<ColorSpace>,
|
||||
/// Linear RGB matrix (None = identity primaries).
|
||||
pub matrix: Option<[[f32; 3]; 3]>,
|
||||
/// Encode the output with this space's curve last.
|
||||
pub encode: Option<ColorSpace>,
|
||||
}
|
||||
|
||||
impl Conversion {
|
||||
/// From `from` (linear when `from_linear`) to `to` (linear when `to_linear`). `None` when
|
||||
/// the conversion is the identity.
|
||||
pub fn new(from: ColorSpace, from_linear: bool, to: ColorSpace, to_linear: bool) -> Option<Conversion> {
|
||||
let same_primaries = from.primaries() == to.primaries();
|
||||
let same_curve = from_linear == to_linear && (from_linear || curve_eq(from, to));
|
||||
if same_primaries && same_curve {
|
||||
return None;
|
||||
}
|
||||
let matrix = (!same_primaries).then(|| {
|
||||
let m = mul(&invert(&to.to_xyz()), &from.to_xyz());
|
||||
m.map(|r| r.map(|v| v as f32))
|
||||
});
|
||||
// Same primaries, both encoded with different curves (sRGB ↔ Rec. 709), or a matrix in
|
||||
// between: go through linear.
|
||||
let decode = (!from_linear).then_some(from);
|
||||
let encode = (!to_linear).then_some(to);
|
||||
Some(Conversion { decode, matrix, encode })
|
||||
}
|
||||
|
||||
/// Decode only (encoded → linear in the same space).
|
||||
pub fn linearize(space: ColorSpace) -> Conversion {
|
||||
Conversion { decode: Some(space), matrix: None, encode: None }
|
||||
}
|
||||
|
||||
/// Encode only (linear → encoded in the same space).
|
||||
pub fn delinearize(space: ColorSpace) -> Conversion {
|
||||
Conversion { decode: None, matrix: None, encode: Some(space) }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn apply(&self, c: [f32; 3]) -> [f32; 3] {
|
||||
let mut c = c;
|
||||
if let Some(s) = self.decode {
|
||||
c = c.map(|v| s.decode(v));
|
||||
}
|
||||
if let Some(m) = &self.matrix {
|
||||
c = [0, 1, 2].map(|i| m[i][0] * c[0] + m[i][1] * c[1] + m[i][2] * c[2]);
|
||||
}
|
||||
if let Some(s) = self.encode {
|
||||
c = c.map(|v| s.encode(v));
|
||||
}
|
||||
c
|
||||
}
|
||||
}
|
||||
|
||||
fn curve_eq(a: ColorSpace, b: ColorSpace) -> bool {
|
||||
let srgb = |s: ColorSpace| matches!(s, ColorSpace::Srgb | ColorSpace::DisplayP3);
|
||||
srgb(a) == srgb(b)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn srgb_matrix_matches_iec() {
|
||||
// IEC 61966-2-1 (rounded to 4 places).
|
||||
let m = ColorSpace::Srgb.to_xyz();
|
||||
let want = [[0.4124, 0.3576, 0.1805], [0.2126, 0.7152, 0.0722], [0.0193, 0.1192, 0.9505]];
|
||||
for i in 0..3 {
|
||||
for j in 0..3 {
|
||||
assert!((m[i][j] - want[i][j]).abs() < 2e-4, "{i}{j}: {} vs {}", m[i][j], want[i][j]);
|
||||
}
|
||||
}
|
||||
// Rec. 2020 luminance row (BT.2020: 0.2627, 0.6780, 0.0593).
|
||||
let y = ColorSpace::Rec2020.to_xyz()[1];
|
||||
assert!((y[0] - 0.2627).abs() < 1e-4 && (y[1] - 0.6780).abs() < 1e-4 && (y[2] - 0.0593).abs() < 1e-4, "{y:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn round_trips() {
|
||||
let samples = [[0.2f32, 0.5, 0.8], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.03, 0.9, 0.4], [1.5, 0.2, 2.0]];
|
||||
for a in ColorSpace::ALL {
|
||||
for b in ColorSpace::ALL {
|
||||
for (al, bl) in [(false, false), (true, false), (false, true), (true, true)] {
|
||||
let fwd = Conversion::new(a, al, b, bl);
|
||||
let back = Conversion::new(b, bl, a, al);
|
||||
assert_eq!(fwd.is_none(), back.is_none());
|
||||
for c in samples {
|
||||
let mid = fwd.map(|f| f.apply(c)).unwrap_or(c);
|
||||
let o = back.map(|f| f.apply(mid)).unwrap_or(mid);
|
||||
for i in 0..3 {
|
||||
// Pure power curves amplify f32 rounding near zero.
|
||||
assert!((o[i] - c[i]).abs() < 2e-3, "{a:?}{al}→{b:?}{bl}: {c:?} → {mid:?} → {o:?}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn white_is_preserved_and_gamut_grows() {
|
||||
let c = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, false).unwrap();
|
||||
let w = c.apply([1.0, 1.0, 1.0]);
|
||||
assert!(w.iter().all(|v| (v - 1.0).abs() < 1e-4), "{w:?}");
|
||||
// Pure sRGB red sits inside Rec. 2020: less saturated there.
|
||||
let r = c.apply([1.0, 0.0, 0.0]);
|
||||
assert!(r[0] < 1.0 && r[1] > 0.0 && r[2] > 0.0, "{r:?}");
|
||||
// sRGB ↔ Rec. 709 share primaries: only the curve changes.
|
||||
let k = Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec709, false).unwrap();
|
||||
assert!(k.matrix.is_none());
|
||||
assert!(Conversion::new(ColorSpace::Srgb, true, ColorSpace::Rec709, true).is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn transfer_extends_over_range() {
|
||||
let s = ColorSpace::Srgb;
|
||||
assert!((s.decode(s.encode(4.0)) - 4.0).abs() < 1e-4);
|
||||
assert!((s.decode(s.encode(-0.25)) + 0.25).abs() < 1e-5);
|
||||
assert!((ColorSpace::Rec709.decode(0.5) - 0.5f32.powf(2.4)).abs() < 1e-6);
|
||||
assert_eq!(ColorSpace::parse("sRGB IEC61966-2.1"), Some(ColorSpace::Srgb));
|
||||
assert_eq!(ColorSpace::parse("Display P3"), Some(ColorSpace::DisplayP3));
|
||||
}
|
||||
}
|
||||
@@ -140,6 +140,18 @@ fn project_settings(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(l) = p.get("linearize").and_then(Value::as_bool) {
|
||||
proj.settings.linearize = l;
|
||||
}
|
||||
if let Some(l) = p.get("blendLinear").or_else(|| p.get("blendColorsUsing1Gamma")).and_then(Value::as_bool) {
|
||||
proj.settings.blend_linear = l;
|
||||
}
|
||||
if let Some(w) = p.get("workingSpace") {
|
||||
proj.settings.working_space = match w.as_str() {
|
||||
None | Some("none" | "None" | "") => None,
|
||||
Some(n) => Some(
|
||||
effectcraft_project::ColorSpace::parse(n)
|
||||
.ok_or_else(|| bad("file.projectSettings", format!("workingSpace: none|srgb|rec709|rec2020|p3, not `{n}`")))?,
|
||||
),
|
||||
};
|
||||
}
|
||||
if let Some(t) = str_p(p, "timeDisplay") {
|
||||
proj.settings.time_display =
|
||||
if t.eq_ignore_ascii_case("frames") { effectcraft_project::TimeDisplayStyle::Frames } else { effectcraft_project::TimeDisplayStyle::Timecode };
|
||||
@@ -172,7 +184,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Project Settings...",
|
||||
["File"],
|
||||
Some("Cmd+Alt+Shift+K"),
|
||||
"{bitDepth?: 8|16|32, linearize?, timeDisplay?: timecode|frames}",
|
||||
"{bitDepth?: 8|16|32, workingSpace?: none|srgb|rec709|rec2020|p3, linearize?, blendLinear?, timeDisplay?: timecode|frames}",
|
||||
always,
|
||||
project_settings
|
||||
),
|
||||
|
||||
@@ -106,6 +106,7 @@ fn placeholder_footage(p: &Value) -> Footage {
|
||||
codec: "Placeholder".into(),
|
||||
missing: true,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -413,6 +414,15 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
};
|
||||
let loops = p.get("loop").and_then(Value::as_u64).map(|v| v.max(1) as u32);
|
||||
let par = f_p(p, "pixelAspect");
|
||||
// Color ▸ Assign Profile: a colour space id, or "auto"/"none" for the file's own (sRGB).
|
||||
let profile = match str_p(p, "colorProfile") {
|
||||
Some("auto" | "none" | "") => Some(None),
|
||||
Some(c) => match effectcraft_project::ColorSpace::parse(c) {
|
||||
Some(cs) => Some(Some(cs)),
|
||||
None => return Err(bad("file.interpretFootage", format!("colorProfile: srgb|rec709|rec2020|p3|auto, not `{c}`"))),
|
||||
},
|
||||
None => None,
|
||||
};
|
||||
s.edit("Interpret Footage", None, |proj, _| {
|
||||
for i in &items {
|
||||
if let Some(ItemKind::Footage(f)) = proj.item_mut(*i).map(|x| &mut x.kind) {
|
||||
@@ -431,6 +441,9 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
if let Some(x) = par {
|
||||
f.pixel_aspect = x;
|
||||
}
|
||||
if let Some(c) = profile {
|
||||
f.color_profile = c;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
@@ -634,7 +647,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Main...",
|
||||
["File", "Interpret Footage"],
|
||||
Some("Cmd+Alt+G"),
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?}",
|
||||
"{items?, frameRate?, alpha?: straight|premultiplied|ignore, loop?, pixelAspect?, colorProfile?: srgb|rec709|rec2020|p3|auto}",
|
||||
has_footage_selection,
|
||||
interpret
|
||||
),
|
||||
|
||||
@@ -629,6 +629,57 @@ fn timing(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<Value> {
|
||||
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::<Vec<_>>()}))
|
||||
}
|
||||
|
||||
fn arrange(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
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<CommandSpec> {
|
||||
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!(
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
@@ -256,6 +256,7 @@ impl Importer for MockImporter {
|
||||
codec: "mock".into(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -71,6 +71,20 @@ pub(crate) fn footage_from_info(path: &str, info: &filmcraft_media::MediaInfo) -
|
||||
codec,
|
||||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: v.and_then(|v| profile_of(&v.color)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The colour profile a video stream declares (H.273 primaries / transfer from the bitstream or
|
||||
/// container). `None` (interpreted as sRGB) for Rec. 709 primaries with an sRGB curve.
|
||||
fn profile_of(c: &filmcraft_color::ColorInfo) -> Option<effectcraft_project::ColorSpace> {
|
||||
use effectcraft_project::ColorSpace;
|
||||
use filmcraft_color::{Primaries, Transfer};
|
||||
match (c.primaries, c.transfer) {
|
||||
(Primaries::Bt2020, _) => Some(ColorSpace::Rec2020),
|
||||
(Primaries::P3D65, _) => Some(ColorSpace::DisplayP3),
|
||||
(_, Transfer::Bt709) => Some(ColorSpace::Rec709),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +113,7 @@ fn still_footage(path: &str, w: u32, h: u32, fmt: image::ImageFormat, has_alpha:
|
||||
codec,
|
||||
missing: false,
|
||||
sequence: Vec::new(),
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ pub mod tracking;
|
||||
use std::collections::BTreeMap;
|
||||
use std::sync::Arc;
|
||||
|
||||
pub use effectcraft_color::ColorSpace;
|
||||
use effectcraft_color::{BlendMode, Label};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
pub use props::{Expression, FeatherFalloff, GroupKind, MaskMode, MaskMotionBlur, Node, ParamUi, PropGroup, Property, Uid, parse_path};
|
||||
@@ -70,6 +71,22 @@ impl BitDepth {
|
||||
pub fn is_float(self) -> bool {
|
||||
self == BitDepth::Bpc32
|
||||
}
|
||||
/// Quantisation levels per channel of the integer depths (8 bpc: 255; 16 bpc: 32768, After
|
||||
/// Effects' "15 + 1" bit range), `None` for 32 bpc float.
|
||||
pub fn levels(self) -> Option<f32> {
|
||||
match self {
|
||||
BitDepth::Bpc8 => Some(255.0),
|
||||
BitDepth::Bpc16 => Some(32768.0),
|
||||
BitDepth::Bpc32 => None,
|
||||
}
|
||||
}
|
||||
pub fn bits(self) -> u32 {
|
||||
match self {
|
||||
BitDepth::Bpc8 => 8,
|
||||
BitDepth::Bpc16 => 16,
|
||||
BitDepth::Bpc32 => 32,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
@@ -82,8 +99,16 @@ pub enum TimeDisplayStyle {
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ProjectSettings {
|
||||
pub bit_depth: BitDepth,
|
||||
/// Blend in linear light instead of the sRGB-encoded working space.
|
||||
/// Linearize Working Space: the working space uses linear light (1.0 gamma) for everything
|
||||
/// (sources, effects, blending). Needs a working space.
|
||||
pub linearize: bool,
|
||||
/// Working Space (colour management). `None` = no colour management.
|
||||
#[serde(default)]
|
||||
pub working_space: Option<ColorSpace>,
|
||||
/// Blend Colors Using 1.0 Gamma: layers blend in linear light (sources and effects stay in
|
||||
/// the working space's encoding).
|
||||
#[serde(default)]
|
||||
pub blend_linear: bool,
|
||||
pub time_display: TimeDisplayStyle,
|
||||
/// Frame numbering starts at 0 (or 1).
|
||||
pub frame_start: i64,
|
||||
@@ -92,7 +117,15 @@ pub struct ProjectSettings {
|
||||
|
||||
impl Default for ProjectSettings {
|
||||
fn default() -> Self {
|
||||
ProjectSettings { bit_depth: BitDepth::Bpc8, linearize: false, time_display: TimeDisplayStyle::Timecode, frame_start: 0, audio_sample_rate: 48_000 }
|
||||
ProjectSettings {
|
||||
bit_depth: BitDepth::Bpc8,
|
||||
linearize: false,
|
||||
working_space: None,
|
||||
blend_linear: false,
|
||||
time_display: TimeDisplayStyle::Timecode,
|
||||
frame_start: 0,
|
||||
audio_sample_rate: 48_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -520,6 +553,9 @@ pub struct Footage {
|
||||
/// Image sequence files (when kind = Sequence).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub sequence: Vec<String>,
|
||||
/// Colour profile (from the file's metadata, or Interpret Footage). `None` = sRGB.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub color_profile: Option<ColorSpace>,
|
||||
}
|
||||
|
||||
fn one() -> u32 {
|
||||
|
||||
@@ -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<f32>,
|
||||
}
|
||||
|
||||
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::<f32>();
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! Project colour settings in the compositor: bit depth, working space, linear blending.
|
||||
//!
|
||||
//! * **Bit depth.** Pixels are `f32` throughout, but 8 and 16 bpc projects behave like integer
|
||||
//! pipelines: each layer's pixels are clamped to 0..1 and quantised to the depth after its
|
||||
//! source and masks and after every effect, and the comp is clamped and quantised after every
|
||||
//! layer is blended. Over-range values (Add/Screen of bright layers, Exposure…) therefore only
|
||||
//! survive in 32 bpc. 16 bpc uses After Effects' 0..32768 range.
|
||||
//! * **Working space** (colour management). Footage is converted from its colour profile (its
|
||||
//! metadata, else sRGB) into the working space; the top-level comp output is converted from
|
||||
//! the working space to the sRGB display. Without a working space nothing is converted.
|
||||
//! * **Linearize Working Space**: the working space is linear light, so sources (footage and
|
||||
//! the colours of solids, text and shapes, which are authored in the working space's encoding)
|
||||
//! are linearised before masks and effects, and everything runs in linear.
|
||||
//! * **Blend Colors Using 1.0 Gamma**: only blending is linear. Each layer's finished pixels
|
||||
//! are linearised just before they are transformed and blended, the comp accumulates in linear
|
||||
//! and is encoded back to the working space when the comp is done (so precomps hand encoded
|
||||
//! pixels to their parent). Without a working space the sRGB curve is used.
|
||||
|
||||
use effectcraft_color::{ColorSpace, Conversion};
|
||||
use effectcraft_project::ProjectSettings;
|
||||
use effectcraft_raster::Image;
|
||||
use rayon::prelude::*;
|
||||
|
||||
/// The colour pipeline of a project (see the module docs).
|
||||
#[derive(Clone, Copy, Debug, PartialEq)]
|
||||
pub struct Pipe {
|
||||
/// Quantisation levels (255, 32768) or `None` for 32 bpc float.
|
||||
pub levels: Option<f32>,
|
||||
/// Working space (`None` = unmanaged).
|
||||
pub space: Option<ColorSpace>,
|
||||
/// The working space is linear.
|
||||
pub linear: bool,
|
||||
/// Blend in linear light (and the working space is not already linear).
|
||||
pub linear_blend: bool,
|
||||
}
|
||||
|
||||
impl Pipe {
|
||||
pub fn of(s: &ProjectSettings) -> Pipe {
|
||||
let linear = s.linearize && s.working_space.is_some();
|
||||
Pipe { levels: s.bit_depth.levels(), space: s.working_space, linear, linear_blend: s.blend_linear && !linear }
|
||||
}
|
||||
|
||||
/// The space whose curve encodes working-space pixels (sRGB when unmanaged).
|
||||
fn curve(&self) -> ColorSpace {
|
||||
self.space.unwrap_or(ColorSpace::Srgb)
|
||||
}
|
||||
|
||||
/// Footage with colour profile `profile` (`None` = sRGB) → working space.
|
||||
pub fn media_in(&self, profile: Option<ColorSpace>) -> Option<Conversion> {
|
||||
let ws = self.space?;
|
||||
Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear)
|
||||
}
|
||||
|
||||
/// Authored colours (solids, text, shapes) → working space: linearised in a linear working
|
||||
/// space.
|
||||
pub fn authored_in(&self) -> Option<Conversion> {
|
||||
self.linear.then(|| Conversion::linearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Working space → blending space (linear when blending with 1.0 gamma).
|
||||
pub fn to_blend(&self) -> Option<Conversion> {
|
||||
self.linear_blend.then(|| Conversion::linearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Blending space → working space.
|
||||
pub fn from_blend(&self) -> Option<Conversion> {
|
||||
self.linear_blend.then(|| Conversion::delinearize(self.curve()))
|
||||
}
|
||||
|
||||
/// Working space → sRGB display (top-level comp output).
|
||||
pub fn output(&self) -> Option<Conversion> {
|
||||
let ws = self.space?;
|
||||
Conversion::new(ws, self.linear, ColorSpace::Srgb, false)
|
||||
}
|
||||
|
||||
/// Clamp and quantise to the bit depth (no-op in 32 bpc).
|
||||
pub fn quantize(&self, img: &mut Image) {
|
||||
if let Some(l) = self.levels {
|
||||
quantize(img, l);
|
||||
}
|
||||
}
|
||||
|
||||
/// Hash of everything that changes pixels (for cache keys).
|
||||
pub fn key(&self) -> u64 {
|
||||
let mut k = self.levels.map_or(0, |l| l as u64);
|
||||
k = k.wrapping_mul(31).wrapping_add(self.space.map_or(7, |s| s as u64 + 11));
|
||||
k = k.wrapping_mul(31).wrapping_add(self.linear as u64 * 2 + self.linear_blend as u64);
|
||||
k
|
||||
}
|
||||
}
|
||||
|
||||
/// Clamp premultiplied pixels to 0..1 (colour ≤ alpha) and round to `levels` steps.
|
||||
pub fn quantize(img: &mut Image, levels: f32) {
|
||||
let inv = 1.0 / levels;
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
let a = (p[3].clamp(0.0, 1.0) * levels).round() * inv;
|
||||
p[3] = a;
|
||||
for c in 0..3 {
|
||||
p[c] = (p[c].clamp(0.0, a) * levels).round() * inv;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Convert premultiplied pixels (on straight colour).
|
||||
pub fn convert(img: &mut Image, c: &Conversion) {
|
||||
img.data.par_iter_mut().for_each(|p| {
|
||||
let a = p[3];
|
||||
if a <= 0.0 {
|
||||
return;
|
||||
}
|
||||
let o = c.apply([p[0] / a, p[1] / a, p[2] / a]);
|
||||
*p = [o[0] * a, o[1] * a, o[2] * a, a];
|
||||
});
|
||||
}
|
||||
+367
-39
@@ -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<Vec<LayerTiming>>>,
|
||||
/// Current nesting depth (precomp recursion guard).
|
||||
depth: usize,
|
||||
/// The project's colour pipeline (bit depth, working space, linear blending).
|
||||
pub(crate) pipe: color::Pipe,
|
||||
/// Collapsed precomp being drawn into its parent: nested comp pixels → parent comp pixels
|
||||
/// (before the output scale).
|
||||
outer: Option<Mat3>,
|
||||
/// 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<Collapse3d<'a>>,
|
||||
}
|
||||
|
||||
/// 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<ItemId> {
|
||||
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<Arc<Image>> {
|
||||
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<Buf> {
|
||||
let mut buf = self.source(ctx, layer)?;
|
||||
masks::apply(ctx, layer, &mut buf);
|
||||
self.pipe.quantize(&mut buf.img);
|
||||
Some(buf)
|
||||
}
|
||||
|
||||
/// Fully processed layer buffer (source → masks → effects → layer styles, flattened).
|
||||
/// Served from the layer cache when the layer's content key matches.
|
||||
pub fn layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
self.layer_buf_keyed(ctx, layer).map(|(b, _)| b)
|
||||
}
|
||||
|
||||
/// [`Self::layer_buf`] in the blending space (linear with Blend Colors Using 1.0 Gamma).
|
||||
pub(crate) fn blend_layer_buf(&self, ctx: &EvalCtx, layer: &Layer) -> Option<Arc<Buf>> {
|
||||
let (b, k) = self.layer_buf_keyed(ctx, layer)?;
|
||||
Some(self.to_blend(b, k))
|
||||
}
|
||||
|
||||
/// A buffer converted to the blending space (cached under a key derived from `key`).
|
||||
pub(crate) fn to_blend(&self, b: Arc<Buf>, key: Option<u64>) -> Arc<Buf> {
|
||||
let Some(c) = self.pipe.to_blend() else { return b };
|
||||
let key = key.map(|k| cache::derive(k, 0x1ea7));
|
||||
if let (Some(cache), Some(k)) = (self.cache, key)
|
||||
&& let Some(x) = cache.get(k)
|
||||
{
|
||||
return x;
|
||||
}
|
||||
let mut nb = (*b).clone();
|
||||
color::convert(&mut nb.img, &c);
|
||||
let nb = Arc::new(nb);
|
||||
if let (Some(cache), Some(k)) = (self.cache, key) {
|
||||
cache.insert(k, nb.clone());
|
||||
}
|
||||
nb
|
||||
}
|
||||
|
||||
fn layer_buf_keyed(&self, ctx: &EvalCtx, layer: &Layer) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
let st = self.styled(ctx, layer, None)?;
|
||||
if st.passes.is_empty() {
|
||||
return Some(st.body);
|
||||
return Some((st.body, st.key));
|
||||
}
|
||||
let key = st.key.map(|k| cache::derive(k, 0xf1a7));
|
||||
if let (Some(c), Some(k)) = (self.cache, key)
|
||||
&& let Some(b) = c.get(k)
|
||||
{
|
||||
return Some(b);
|
||||
return Some((b, key));
|
||||
}
|
||||
let flat = Arc::new(styles::Styled { passes: st.passes.iter().map(|(b, m)| ((**b).clone(), *m)).collect(), body: (*st.body).clone() }.flatten());
|
||||
if let (Some(c), Some(k)) = (self.cache, key) {
|
||||
c.insert(k, flat.clone());
|
||||
}
|
||||
Some(flat)
|
||||
Some((flat, key))
|
||||
}
|
||||
|
||||
/// A layer's source pixels at the context time, before masks, effects and the transform
|
||||
@@ -356,7 +628,7 @@ impl<'a> Renderer<'a> {
|
||||
}
|
||||
|
||||
fn content_buf_timed(&self, ctx: &EvalCtx, layer: &Layer, mut timing: Option<&mut LayerTiming>) -> Option<(Arc<Buf>, Option<u64>)> {
|
||||
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<Arc<Buf>> {
|
||||
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<StyledLayer> {
|
||||
let (content, ckey) = self.content_buf_timed(ctx, layer, timing.as_deref_mut())?;
|
||||
if !styles::active(ctx, layer) {
|
||||
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: None, plain: true });
|
||||
return Some(StyledLayer { passes: vec![], body: content.clone(), content, key: ckey, ckey, plain: true });
|
||||
}
|
||||
let modes = styles::pass_modes(ctx, layer);
|
||||
let key = ckey.map(|k| cache::styles_key(ctx, layer, k));
|
||||
@@ -409,7 +679,7 @@ impl<'a> Renderer<'a> {
|
||||
{
|
||||
let passes: Option<Vec<_>> = modes.iter().enumerate().map(|(i, m)| c.get(cache::derive(k, i as u64 + 1)).map(|b| (b, *m))).collect();
|
||||
if let Some(passes) = passes {
|
||||
return Some(StyledLayer { passes, body, content, key, plain: false });
|
||||
return Some(StyledLayer { passes, body, content, key, ckey, plain: false });
|
||||
}
|
||||
}
|
||||
let t0 = web_time::Instant::now();
|
||||
@@ -428,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<Buf>,
|
||||
/// Pre-style pixels (knockout shape).
|
||||
content: Arc<Buf>,
|
||||
/// Styled cache key.
|
||||
/// Styled cache key (the content key when plain).
|
||||
key: Option<u64>,
|
||||
/// Content cache key.
|
||||
ckey: Option<u64>,
|
||||
/// No layer styles: composite `body` the usual way.
|
||||
plain: bool,
|
||||
}
|
||||
|
||||
/// 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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -50,6 +50,7 @@ fn audio_footage() -> Footage {
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
//! Bit depth, working space and linear blending.
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use effectcraft_color::{BlendMode, ColorSpace, Label};
|
||||
use effectcraft_project::build;
|
||||
use effectcraft_project::{AlphaMode, BitDepth, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
|
||||
use crate::{FootageSource, Image, LayerCache, RenderOpts, Renderer, render_frame};
|
||||
|
||||
fn setup(depth: BitDepth) -> (Project, ItemId, Comp) {
|
||||
let mut p = Project::default();
|
||||
p.settings.bit_depth = depth;
|
||||
let comp = Comp::new(40, 20, FrameRate::FPS_30, Tick::from_seconds_f64(2.0));
|
||||
let cid = p.add_item("Comp", Label::Sandstone, None, ItemKind::Comp(comp.clone().into()));
|
||||
(p, cid, comp)
|
||||
}
|
||||
|
||||
fn add_solid(p: &mut Project, cid: ItemId, color: [f32; 3], mode: BlendMode, opacity: f64) {
|
||||
let comp = p.comp(cid).unwrap().clone();
|
||||
let sid = p.add_item("S", Label::Red, None, ItemKind::Solid(Solid { color, width: 40, height: 20, pixel_aspect: 1.0 }));
|
||||
let mut l = build::layer(p, &comp, "S", LayerSource::Solid { item: sid }, (40, 20), None);
|
||||
l.blend_mode = mode;
|
||||
l.props.prop_mut("transform/opacity").unwrap().value = effectcraft_keyframe::Value::Scalar(opacity);
|
||||
// New layers go on top.
|
||||
p.comp_mut(cid).unwrap().layers.insert(0, l);
|
||||
}
|
||||
|
||||
fn px(p: &Project, cid: ItemId) -> [f32; 4] {
|
||||
render_frame(p, cid, Tick::ZERO, 1.0).get(10, 10)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_clamps_in_8_and_16_but_not_32_bpc() {
|
||||
for (depth, want) in [(BitDepth::Bpc8, 1.0), (BitDepth::Bpc16, 1.0), (BitDepth::Bpc32, 1.6)] {
|
||||
let (mut p, cid, _) = setup(depth);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
|
||||
let v = px(&p, cid)[0];
|
||||
assert!((v - want).abs() < 1e-5, "{depth:?}: {v}");
|
||||
}
|
||||
// Screen of over-range values survives in 32 bpc only: a 1.6 base screened with 0.5.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [0.8, 0.8, 0.8], BlendMode::Add, 100.0);
|
||||
add_solid(&mut p, cid, [0.5, 0.5, 0.5], BlendMode::Screen, 100.0);
|
||||
let v = px(&p, cid)[0];
|
||||
assert!((v - (1.6 + 0.5 - 0.8)).abs() < 1e-5, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn quantization_steps_per_depth() {
|
||||
let want = |depth| match depth {
|
||||
BitDepth::Bpc8 => (0.3f32 * 255.0).round() / 255.0,
|
||||
BitDepth::Bpc16 => (0.3f32 * 32768.0).round() / 32768.0,
|
||||
BitDepth::Bpc32 => 0.3,
|
||||
};
|
||||
for depth in [BitDepth::Bpc8, BitDepth::Bpc16, BitDepth::Bpc32] {
|
||||
let (mut p, cid, _) = setup(depth);
|
||||
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
|
||||
let v = px(&p, cid)[0];
|
||||
assert!((v - want(depth)).abs() < 1e-7, "{depth:?}: {v} vs {}", want(depth));
|
||||
}
|
||||
// 8 bpc: 77/255 = 0.30196 (one step is 1/255); 16 bpc is within 1/65536.
|
||||
let (mut p8, c8, _) = setup(BitDepth::Bpc8);
|
||||
add_solid(&mut p8, c8, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
|
||||
assert!((px(&p8, c8)[0] - 77.0 / 255.0).abs() < 1e-7);
|
||||
// A 1% opacity layer of white over black lands on a whole 8-bit step.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc8);
|
||||
add_solid(&mut p, cid, [0.0, 0.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 1.0, 1.0], BlendMode::Normal, 1.0);
|
||||
let v = px(&p, cid)[0] * 255.0;
|
||||
assert!((v - v.round()).abs() < 1e-4 && (v - 2.55).abs() < 1.0, "{v}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linear_blending_of_red_over_green() {
|
||||
// 50% red over green: gamma blending gives (0.5, 0.5, 0); 1.0-gamma blending mixes linear
|
||||
// light, so each channel is the sRGB encoding of 0.5 linear ≈ 0.735.
|
||||
let run = |linear: bool| {
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.blend_linear = linear;
|
||||
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
|
||||
px(&p, cid)
|
||||
};
|
||||
let g = run(false);
|
||||
assert!((g[0] - 0.5).abs() < 1e-5 && (g[1] - 0.5).abs() < 1e-5 && g[2].abs() < 1e-6, "{g:?}");
|
||||
let l = run(true);
|
||||
let e = effectcraft_color::linear_to_srgb(0.5);
|
||||
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4 && l[2].abs() < 1e-6, "{l:?}");
|
||||
assert!((e - 0.7354).abs() < 1e-3);
|
||||
// Unblended (opaque) layers are unchanged by linear blending.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.blend_linear = true;
|
||||
add_solid(&mut p, cid, [0.25, 0.5, 0.75], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 0.25).abs() < 1e-5 && (o[1] - 0.5).abs() < 1e-5 && (o[2] - 0.75).abs() < 1e-5, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn linearized_working_space_blends_linear_and_round_trips_colours() {
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
|
||||
let l = px(&p, cid);
|
||||
let e = effectcraft_color::linear_to_srgb(0.5);
|
||||
assert!((l[0] - e).abs() < 1e-4 && (l[1] - e).abs() < 1e-4, "{l:?}");
|
||||
// An opaque solid shows its own colour (linearised in, encoded out).
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [0.2, 0.5, 0.9], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 0.2).abs() < 1e-4 && (o[1] - 0.5).abs() < 1e-4 && (o[2] - 0.9).abs() < 1e-4, "{o:?}");
|
||||
// Linearize needs a working space (like AE, where it is disabled without one).
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.linearize = true;
|
||||
add_solid(&mut p, cid, [0.0, 1.0, 0.0], BlendMode::Normal, 100.0);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 50.0);
|
||||
assert!((px(&p, cid)[0] - 0.5).abs() < 1e-5);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wide_working_space_colours_leave_the_display_gamut() {
|
||||
// Pure Rec. 2020 red is outside sRGB: in 32 bpc the display conversion keeps the
|
||||
// over-range/negative values; 8 bpc clamps them.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Rec2020);
|
||||
add_solid(&mut p, cid, [1.0, 0.0, 0.0], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!(o[0] > 1.0 && o[1] < 0.0 && o[2] < 0.0, "{o:?}");
|
||||
p.settings.bit_depth = BitDepth::Bpc8;
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 1.0).abs() < 1e-6 && o[1] == 0.0 && o[2] == 0.0, "{o:?}");
|
||||
// sRGB working space: no conversion at all.
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
add_solid(&mut p, cid, [0.3, 0.6, 0.9], BlendMode::Normal, 100.0);
|
||||
let o = px(&p, cid);
|
||||
assert!((o[0] - 0.3).abs() < 1e-6 && (o[2] - 0.9).abs() < 1e-6);
|
||||
}
|
||||
|
||||
/// One flat-colour footage frame.
|
||||
struct Flat([f32; 3]);
|
||||
impl FootageSource for Flat {
|
||||
fn frame(&self, _: ItemId, f: &Footage, _: Tick) -> Option<Arc<Image>> {
|
||||
let c = self.0;
|
||||
Some(Arc::new(Image::filled(f.width, f.height, [c[0], c[1], c[2], 1.0])))
|
||||
}
|
||||
}
|
||||
|
||||
fn footage(profile: Option<ColorSpace>) -> Footage {
|
||||
Footage {
|
||||
path: "x.png".into(),
|
||||
kind: FootageKind::Still,
|
||||
width: 40,
|
||||
height: 20,
|
||||
pixel_aspect: 1.0,
|
||||
frame_rate: FrameRate::FPS_30,
|
||||
native_rate: None,
|
||||
duration: Tick::ZERO,
|
||||
has_video: true,
|
||||
has_audio: false,
|
||||
alpha: AlphaMode::Ignore,
|
||||
premul_color: [0.0; 3],
|
||||
loop_count: 1,
|
||||
codec: String::new(),
|
||||
missing: false,
|
||||
sequence: vec![],
|
||||
color_profile: profile,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_profile_is_converted_into_the_working_space() {
|
||||
let render = |ws: Option<ColorSpace>, profile: Option<ColorSpace>| {
|
||||
let (mut p, cid, comp) = setup(BitDepth::Bpc32);
|
||||
p.settings.working_space = ws;
|
||||
let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(footage(profile)));
|
||||
let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None);
|
||||
p.comp_mut(cid).unwrap().layers.push(l);
|
||||
let src = Flat([1.0, 0.0, 0.0]);
|
||||
Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5)
|
||||
};
|
||||
// Unmanaged: the file's values pass straight through.
|
||||
let o = render(None, Some(ColorSpace::Rec2020));
|
||||
assert_eq!(o, [1.0, 0.0, 0.0, 1.0]);
|
||||
// Managed: Rec. 2020 red shown on an sRGB display is beyond sRGB red.
|
||||
let o = render(Some(ColorSpace::Srgb), Some(ColorSpace::Rec2020));
|
||||
assert!(o[0] > 1.0 && o[1] < 0.0, "{o:?}");
|
||||
// Untagged footage is sRGB: a round trip through a Rec. 2020 working space.
|
||||
let o = render(Some(ColorSpace::Rec2020), None);
|
||||
assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn layer_cache_is_keyed_by_colour_settings() {
|
||||
let (mut p, cid, _) = setup(BitDepth::Bpc8);
|
||||
add_solid(&mut p, cid, [0.3, 0.3, 0.3], BlendMode::Normal, 100.0);
|
||||
let cache = LayerCache::default();
|
||||
let frame = |p: &Project| {
|
||||
let mut r = Renderer::new(p, &crate::NoFootage, RenderOpts::default());
|
||||
r.cache = Some(&cache);
|
||||
r.comp_frame(cid, Tick::ZERO).get(10, 10)[0]
|
||||
};
|
||||
let a = frame(&p);
|
||||
assert!((a - 77.0 / 255.0).abs() < 1e-7);
|
||||
assert_eq!(cache.stats().entries, 1);
|
||||
p.settings.bit_depth = BitDepth::Bpc32;
|
||||
let b = frame(&p);
|
||||
assert!((b - 0.3).abs() < 1e-7, "stale 8 bpc pixels: {b}");
|
||||
p.settings.working_space = Some(ColorSpace::Srgb);
|
||||
p.settings.linearize = true;
|
||||
let _ = frame(&p);
|
||||
assert_eq!(cache.stats().entries, 3, "each setting renders its own entry");
|
||||
// Back to 8 bpc reuses the first entry.
|
||||
p.settings.bit_depth = BitDepth::Bpc8;
|
||||
p.settings.working_space = None;
|
||||
p.settings.linearize = false;
|
||||
let hits = cache.stats().hits;
|
||||
assert!((frame(&p) - a).abs() < 1e-9);
|
||||
assert_eq!(cache.stats().hits, hits + 1);
|
||||
}
|
||||
@@ -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<Arc<Image>> {
|
||||
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::<f32>();
|
||||
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);
|
||||
}
|
||||
@@ -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<Item<'a>> {
|
||||
let buf = r.layer_buf(ctx, layer)?;
|
||||
let buf = r.blend_layer_buf(ctx, layer)?;
|
||||
prepare_with(r, ctx, layer, order, out, in_run, buf, Mat4::IDENTITY)
|
||||
}
|
||||
|
||||
@@ -176,7 +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<Item> = 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<Item<'a>> {
|
||||
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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
],
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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::<f32>(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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user