mirror of
https://github.com/storytold/effectcraft.git
synced 2026-11-15 23:02:14 +00:00
Cineon Converter: Gamma 1 gives linear output and saved projects keep their render (#2312)
## Behavior A new Cineon Converter opens with Gamma 1.7. Its Version parameter is 2 by default. A converter saved before Version existed opens as version 1, retaining its Gamma value, keyframes, and expression text and rendering on its original scale. Reset keeps the instance's Version. In version 2: - Log to Linear raises the exposure to `1 / gamma` before subtracting the black offset and normalizing: `(E^(1/g) - O^(1/g)) / (1 - O^(1/g))`, with `E = 10^((code - white) * 0.002 / 0.6)` and `O` the exposure at the black point. Gamma 1 gives linear output, and a larger Gamma lightens the midtones. Highlight Rolloff then applies as before, and Linear to Log is the inverse. - Log to Log returns code values unchanged at Gamma 1 and raises the normalized code value to `1 / gamma` at other values. With custom black or white points, the code values first go through the Gamma 1 conversion. The CPU and GPU paths match. Version 1 instances render as before, and Interpret Footage ▸ Cineon Settings keeps its existing formula, which agrees with the version 2 Converter at Gamma 1. The public Cineon Converter documentation (https://helpx.adobe.com/after-effects/using/utility-effects.html) describes Gamma as a midtone control and gives no defaults or formula. The default, the Log to Linear curve and the Log to Log behavior come from running and scripting After Effects 26.5 on my own machine and reading back the rendered pixels. In After Effects, choosing Log to Log also sets Gamma to 1 and Highlight Rolloff to 0; EffectCraft does not do that yet, because no command sets one parameter from another. Animation preset format 3 records `cineonVersion` for a saved Gamma property and stores effect key times relative to the first key. Presets in formats 1 and 2 without that tag contain version 1 Gamma values; format 2 already has relative effect key times. Applying a whole old Cineon Converter also gives it version 1. Exposed Essential Properties Gamma values convert to the target converter version where their target is unambiguous (the conversion keeps the linear point; other Gamma values follow each version's own curve); enabled expressions on another scale are skipped instead of rewriting their source. A saved Gamma records the Version its converter has at the current time. Applied to a converter whose Version is keyframed, a Gamma preset keeps those keys: the Gamma is converted to the one version they hold, or skipped when they hold several or an expression sets the Version. A preset that also holds the Version sets it from that property. The project schema remains 2. `docs/architecture.md` describes the behavior above. ## Validation Effect tests check version 2 Log to Linear at Gamma 1.7 (codes 95 to 685) and Gamma 2.2 (codes 180 to 620), and Log to Log at Gamma 1 (Rolloff 0 and 20) and Gamma 1.7, against the measured 8-bit values within one or two codes, plus round trips, version 1 rendering and the Interpret Footage agreement at Gamma 1. GPU tests compare the version 2 paths with the CPU. Engine tests cover old projects, Reset, presets, keyframes, and Essential Properties. Commands run on `74d398c2`: - `cargo fmt --all -- --check` - `cargo clippy -p effectcraft-effects -p effectcraft-gpu -p effectcraft-project -p effectcraft-engine --all-targets -- -D warnings` - `cargo test -p effectcraft-effects`: 546 passed (including `effects_doc_is_current`, so `docs/effects.md` needs no change), 1 ignored, plus 6 - `cargo test -p effectcraft-project`: 47 passed - `cargo test -p effectcraft-gpu`: 253 passed, 1 ignored - `cargo test -p effectcraft-engine`: 1,007 passed, 5 ignored, plus 27
This commit is contained in:
1 parent
7cbefaf70f
commit
d6c9ad6643
12 files changed
+1101
-79
No files matched your search
@@ -131,7 +131,7 @@ pub use sim2::{
|
||||
CausticsSetup, FoamDisc, FoamEnv, FoamPlan, Piece, PiecePlan, PieceTex, PixelPlan, WavePlan, Waves, caustics_setup, foam_full_plan, piece_plan, pixel_plan,
|
||||
wave_world_plan,
|
||||
};
|
||||
pub use utility::{Lut, load_lut, lut_file, lut_files, lut_params};
|
||||
pub use utility::{Lut, cineon_gamma, cineon_gamma_scale, load_lut, lut_file, lut_files, lut_params};
|
||||
pub use vr::{equi_dir as vr_equi_dir, rotation as vr_rotation};
|
||||
|
||||
/// Effect categories in Effects & Presets order.
|
||||
|
||||
@@ -165,6 +165,9 @@ pub fn legacy_default(effect: &str, param: &str) -> Option<Value> {
|
||||
("ec.transition.linearwipe", "legacyFeather") => Value::Bool(true),
|
||||
// 3D Channel: Cryptomatte's Matte Only put the matte in alpha too (#3127).
|
||||
("ec.3d.cryptomatte", crate::channel3d::MATTE_ONLY_IN_ALPHA) => Value::Bool(true),
|
||||
// Utility: Cineon Converter version 1, where Gamma 1.7 gave linear output
|
||||
// (`crate::utility::cineon_gamma`).
|
||||
("ec.utility.cineon", "version") => Value::Scalar(1.0),
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
@@ -819,4 +822,30 @@ not a stroke",
|
||||
to_effect_space(spec, &mut twirl, SIZE, [0.0; 2], &|_| [0.0; 2]);
|
||||
assert_eq!(twirl, before);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_converter_saved_before_its_version_upgrades_to_version_1() {
|
||||
let spec = find("ec.utility.cineon").unwrap();
|
||||
let mut next = 1;
|
||||
let fresh = instantiate(spec, &mut Ids(&mut next), "Cineon Converter", [100.0, 50.0]);
|
||||
assert_eq!(fresh.get("version").unwrap().value, Value::Scalar(2.0));
|
||||
assert_eq!(fresh.get("gamma").unwrap().value, Value::Scalar(1.7));
|
||||
// Gamma, its keyframes and its expression are kept as saved.
|
||||
let mut g = fresh.clone();
|
||||
take_prop(&mut g, "version");
|
||||
let gamma = g.get_mut("gamma").unwrap();
|
||||
gamma.value = Value::Scalar(2.2);
|
||||
gamma.keys = vec![effectcraft_keyframe::Keyframe::new(Default::default(), Value::Scalar(3.4))];
|
||||
gamma.expr = Some(effectcraft_project::Expression { text: "value * 2".into(), enabled: true });
|
||||
let saved = gamma.clone();
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert_eq!(g.get("version").unwrap().value, Value::Scalar(1.0));
|
||||
assert_eq!(g.get("gamma"), Some(&saved));
|
||||
// Saved without Gamma too: it takes version 1's default, which gave linear output.
|
||||
let mut g = fresh.clone();
|
||||
take_prop(&mut g, "version");
|
||||
take_prop(&mut g, "gamma");
|
||||
assert!(upgrade_instance(spec, &mut g, &mut Ids(&mut next), [100.0, 50.0]));
|
||||
assert_eq!(g.get("gamma").unwrap().value, Value::Scalar(1.7));
|
||||
}
|
||||
}
|
||||
+208
-49
@@ -10,7 +10,7 @@ use effectcraft_project::ParamUi;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::util::{premul, unpremul};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, col, num, p, popup, slider};
|
||||
use crate::{Buf, EffectCtx, EffectSpec, Params, col, num, p, popup, slider};
|
||||
|
||||
fn spec(id: &'static str, name: &'static str, params: Vec<crate::ParamSpec>, render: crate::RenderFn) -> EffectSpec {
|
||||
EffectSpec { id, name, category: "Utility", params, render, gpu: false, float: true }
|
||||
@@ -102,8 +102,10 @@ pub(crate) struct Cineon {
|
||||
pub white: f32,
|
||||
pub ib: f32,
|
||||
pub iw: f32,
|
||||
/// Version 2: [`cineon_gamma`]. Version 1: the saved Gamma, where 1.7 gives linear output.
|
||||
pub gamma: f32,
|
||||
pub rolloff: f32,
|
||||
pub version_2: bool,
|
||||
}
|
||||
|
||||
impl Cineon {
|
||||
@@ -113,14 +115,25 @@ impl Cineon {
|
||||
fn knee(&self) -> f32 {
|
||||
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
|
||||
}
|
||||
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
|
||||
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
|
||||
/// also uses with `1 / gamma`).
|
||||
/// Normalized code value (code / 1023) to linear output.
|
||||
pub fn log_to_lin(&self, v: f32) -> f32 {
|
||||
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
|
||||
let lin = if self.version_2 {
|
||||
let offset = self.offset().powf(1.0 / self.gamma.max(0.001));
|
||||
let exposure = 10f32.powf((v * 1023.0 - self.white) * 0.002 / 0.6).powf(1.0 / self.gamma.max(0.001));
|
||||
let mut lin = ((exposure - offset) / (1.0 - offset).max(1e-6)).max(0.0);
|
||||
let k = self.knee();
|
||||
if k < 1.0 && lin > k {
|
||||
let w = 1.0 - k;
|
||||
lin = k + w * (1.0 - (-(lin - k) / w).exp());
|
||||
}
|
||||
lin
|
||||
} else {
|
||||
// Version 1 keeps its earlier arithmetic, so saved instances render the same bits.
|
||||
effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff)
|
||||
};
|
||||
self.ib + (self.iw - self.ib) * lin
|
||||
}
|
||||
/// Linear → normalised code value.
|
||||
/// Linear to normalized code value.
|
||||
pub fn lin_to_log(&self, v: f32) -> f32 {
|
||||
let off = self.offset();
|
||||
let d = self.iw - self.ib;
|
||||
@@ -130,27 +143,61 @@ impl Cineon {
|
||||
let w = 1.0 - k;
|
||||
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
|
||||
}
|
||||
lin = lin.max(0.0).powf(self.gamma.max(0.01) / 1.7);
|
||||
let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002;
|
||||
let exposure = if self.version_2 {
|
||||
let root_offset = off.powf(1.0 / self.gamma.max(0.001));
|
||||
(lin.max(0.0) * (1.0 - root_offset) + root_offset).max(0.0).powf(self.gamma.max(0.001))
|
||||
} else {
|
||||
lin.max(0.0).powf(self.gamma.max(0.01) / 1.7) * (1.0 - off) + off
|
||||
};
|
||||
let code = self.white + exposure.max(1e-9).log10() * 0.6 / 0.002;
|
||||
code / 1023.0
|
||||
}
|
||||
}
|
||||
|
||||
/// The Gamma that gives linear output in Cineon Converter version 1.
|
||||
pub const CINEON_V1_LINEAR_GAMMA: f64 = 1.7;
|
||||
|
||||
/// The Gamma scale used by Cineon Converter version `version`: `1 / 1.7` for version 1, 1 for
|
||||
/// version 2. Preset conversion aligns the linear points but not other Gamma values. Version 1
|
||||
/// instances (saved before the hidden Version parameter existed; see
|
||||
/// [`crate::migrate::legacy_default`]) raised the linear value to `1.7 / gamma`.
|
||||
pub fn cineon_gamma_scale(version: f64) -> f64 {
|
||||
if version < 2.0 { 1.0 / CINEON_V1_LINEAR_GAMMA } else { 1.0 }
|
||||
}
|
||||
|
||||
/// Cineon Converter's Gamma on the version 2 scale ([`cineon_gamma_scale`]), limited to `0.01 /
|
||||
/// 1.7` or more: version 1 limited its saved Gamma to 0.01.
|
||||
pub fn cineon_gamma(params: &Params) -> f64 {
|
||||
(params.f("gamma") * cineon_gamma_scale(params.f("version"))).max(0.01 * cineon_gamma_scale(1.0))
|
||||
}
|
||||
|
||||
fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf {
|
||||
let version_2 = ctx.params.f("version") >= 2.0;
|
||||
let c = Cineon {
|
||||
black: ctx.params.f("tenBitBlackPoint") as f32,
|
||||
white: ctx.params.f("tenBitWhitePoint") as f32,
|
||||
ib: ctx.params.f("internalBlackPoint") as f32,
|
||||
iw: ctx.params.f("internalWhitePoint") as f32,
|
||||
gamma: ctx.params.f("gamma") as f32,
|
||||
gamma: if version_2 { cineon_gamma(ctx.params) } else { ctx.params.f("gamma") } as f32,
|
||||
rolloff: ctx.params.f("highlightRolloff") as f32,
|
||||
version_2,
|
||||
};
|
||||
let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff };
|
||||
let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff, version_2: false };
|
||||
let kind = ctx.params.e("conversionType");
|
||||
b.img.map_straight(|v| {
|
||||
v.map(|x| match kind {
|
||||
0 => c.lin_to_log(x),
|
||||
1 => c.log_to_lin(x),
|
||||
_ if c.version_2 => {
|
||||
// Custom points use the Gamma 1 code conversion before the code-value power.
|
||||
let code = if (c.black, c.white, c.ib, c.iw) == (95.0, 685.0, 0.0, 1.0) {
|
||||
x
|
||||
} else {
|
||||
let unit = Cineon { gamma: 1.0, ..c };
|
||||
std.lin_to_log(unit.log_to_lin(x))
|
||||
};
|
||||
code.max(0.0).powf(1.0 / c.gamma.max(0.001))
|
||||
}
|
||||
_ => std.lin_to_log(c.log_to_lin(x)),
|
||||
})
|
||||
});
|
||||
@@ -432,8 +479,10 @@ pub fn specs() -> Vec<EffectSpec> {
|
||||
p("internalBlackPoint", "Internal Black Point", num(0.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
|
||||
p("tenBitWhitePoint", "10 Bit White Point", num(685.0), slider(0.0, 1023.0, 0.0, 1023.0, 0)),
|
||||
p("internalWhitePoint", "Internal White Point", num(1.0), slider(-1.0, 2.0, 0.0, 1.0, 3)),
|
||||
p("gamma", "Gamma", num(1.7), slider(0.1, 5.0, 0.1, 5.0, 2)),
|
||||
p("gamma", "Gamma", num(1.7), slider(0.05, 5.0, 0.1, 5.0, 2)),
|
||||
p("highlightRolloff", "Highlight Rolloff", num(20.0), slider(0.0, 100.0, 0.0, 100.0, 0)),
|
||||
// 2: Gamma 1 gives linear output (`cineon_gamma`).
|
||||
p("version", "Version", num(2.0), ParamUi::Hidden),
|
||||
],
|
||||
cineon,
|
||||
),
|
||||
@@ -646,29 +695,133 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn cineon_round_trip() {
|
||||
let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: 20.0 };
|
||||
for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] {
|
||||
let v = code / 1023.0;
|
||||
let back = c.lin_to_log(c.log_to_lin(v));
|
||||
assert!((back - v).abs() < 1e-3, "{code}: {back}");
|
||||
for gamma in [1.0, 1.7, 0.6] {
|
||||
let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma, rolloff: 20.0, version_2: true };
|
||||
for code in [100.0f32, 200.0, 350.0, 500.0, 600.0] {
|
||||
let v = code / 1023.0;
|
||||
let back = c.lin_to_log(c.log_to_lin(v));
|
||||
assert!((back - v).abs() < 1e-3, "gamma {gamma}, {code}: {back}");
|
||||
}
|
||||
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
|
||||
}
|
||||
let custom = Cineon { black: 120.0, white: 700.0, ib: 0.1, iw: 0.9, gamma: 2.2, rolloff: 35.0, version_2: true };
|
||||
for code in [120.0f32, 250.0, 400.0, 560.0, 700.0] {
|
||||
let value = code / 1023.0;
|
||||
let back = custom.lin_to_log(custom.log_to_lin(value));
|
||||
assert!((back - value).abs() < 1e-5, "code {code}: {back}");
|
||||
}
|
||||
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
|
||||
/// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
|
||||
/// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
|
||||
/// linear light (code 400 is 0.10251).
|
||||
/// Gamma 1 gives linear light: Kodak's printing-density curve with nothing
|
||||
/// raised, so code 400 is (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251. A larger
|
||||
/// Gamma lightens the midtones, and Linear to Log undoes it.
|
||||
#[test]
|
||||
fn cineon_gamma_1_is_linear_and_larger_lightens() {
|
||||
let c = |gamma| Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma, rolloff: 0.0, version_2: true };
|
||||
let lin = c(1.0).log_to_lin(400.0 / 1023.0);
|
||||
assert!((lin - 0.10251).abs() < 1e-4, "{lin}");
|
||||
let lighter = c(2.0).log_to_lin(400.0 / 1023.0);
|
||||
let offset = c(2.0).offset().sqrt();
|
||||
let exposure = 10f32.powf((400.0 - 685.0) * 0.002 / 0.6).sqrt();
|
||||
assert!((lighter - (exposure - offset) / (1.0 - offset)).abs() < 1e-4, "{lighter}");
|
||||
assert!((c(2.0).lin_to_log(lighter) - 400.0 / 1023.0).abs() < 1e-4);
|
||||
let out = run("ec.utility.cineon", &[("gamma", num(1.0))], {
|
||||
let mut img = Image::new(1, 1);
|
||||
img.set(0, 0, [400.0 / 1023.0, 400.0 / 1023.0, 400.0 / 1023.0, 1.0]);
|
||||
img
|
||||
});
|
||||
assert!((out.img.data[0][0] - 0.10251).abs() < 1e-4, "Gamma 1 is linear: {:?}", out.img.data[0]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_log_to_log_gamma_1_keeps_code_values() {
|
||||
let codes = [95.0f32, 250.0, 400.0, 560.0, 685.0, 800.0];
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let value = code / 1023.0;
|
||||
img.set(x as u32, 0, [value, value, value, 1.0]);
|
||||
}
|
||||
for rolloff in [0.0, 20.0] {
|
||||
let out = run("ec.utility.cineon", &[("conversionType", Value::Enum(2)), ("gamma", num(1.0)), ("highlightRolloff", num(rolloff))], img.clone());
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
assert!((out.img.data[x][0] - code / 1023.0).abs() < 1e-6, "code {code}: {}", out.img.data[x][0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_version_2_log_to_linear_matches_observed_codes() {
|
||||
for (gamma, codes, expected) in [
|
||||
(1.7, &[95.0, 180.0, 250.0, 320.0, 400.0, 480.0, 560.0, 620.0, 685.0][..], &[0, 9, 19, 34, 57, 89, 136, 185, 255][..]),
|
||||
(2.2, &[180.0, 250.0, 320.0, 400.0, 480.0, 560.0, 620.0][..], &[13, 27, 44, 71, 106, 152, 196][..]),
|
||||
] {
|
||||
let c = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma, rolloff: 0.0, version_2: true };
|
||||
for (&code, &want) in codes.iter().zip(expected) {
|
||||
let got = (c.log_to_lin(code / 1023.0).clamp(0.0, 1.0) * 255.0).round() as i32;
|
||||
assert!((got - want).abs() <= 1, "Gamma {gamma}, code {code}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_version_2_log_to_log_matches_observed_codes() {
|
||||
let codes = [95.0f32, 180.0, 250.0, 320.0, 400.0, 480.0, 560.0, 620.0, 685.0, 800.0];
|
||||
let expected = [63, 92, 111, 129, 146, 164, 179, 190, 202, 221];
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let out = run("ec.utility.cineon", &[("conversionType", Value::Enum(2)), ("gamma", num(1.7))], img);
|
||||
for (x, (&code, &want)) in codes.iter().zip(&expected).enumerate() {
|
||||
let got = (out.img.data[x][0].clamp(0.0, 1.0) * 255.0).round() as i32;
|
||||
assert!((got - want).abs() <= 2, "code {code}: {got} vs {want}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A version 1 instance renders as version 1 did: Gamma 1.7 gives linear light, and a
|
||||
/// version 1 Gamma of 3.4 has the old post-normalization power.
|
||||
#[test]
|
||||
fn cineon_version_1_gamma_17_is_linear() {
|
||||
let px = |vals: &[(&str, Value)]| {
|
||||
let mut img = Image::new(1, 1);
|
||||
img.set(0, 0, [400.0 / 1023.0, 400.0 / 1023.0, 400.0 / 1023.0, 1.0]);
|
||||
run("ec.utility.cineon", vals, img).img.data[0][0]
|
||||
};
|
||||
let v1 = |gamma: f64| px(&[("version", num(1.0)), ("gamma", num(gamma)), ("highlightRolloff", num(0.0))]);
|
||||
assert!((v1(1.7) - 0.10251).abs() < 1e-4, "{}", v1(1.7));
|
||||
let v2 = px(&[("gamma", num(2.0)), ("highlightRolloff", num(0.0))]);
|
||||
assert!((v1(3.4) - v2).abs() > 1e-3, "{} vs {v2}", v1(3.4));
|
||||
// Version 1 limited the saved Gamma to 0.01: Gamma 0 raised the linear value to 170.
|
||||
let code = 684.0 / 1023.0;
|
||||
let base = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: 0.0, version_2: false }.log_to_lin(code);
|
||||
let mut img = Image::new(1, 1);
|
||||
img.set(0, 0, [code, code, code, 1.0]);
|
||||
let zero = run("ec.utility.cineon", &[("version", num(1.0)), ("gamma", num(0.0)), ("highlightRolloff", num(0.0))], img).img.data[0][0];
|
||||
assert!((zero - base.powf(170.0)).abs() < 1e-4, "{zero} != {}", base.powf(170.0));
|
||||
// The version 2 formula differs even when Gamma uses the version 1 scale.
|
||||
let mut img = Image::new(1, 1);
|
||||
img.set(0, 0, [code, code, code, 1.0]);
|
||||
let v2_floor = run("ec.utility.cineon", &[("gamma", num(0.01 / 1.7)), ("highlightRolloff", num(0.0))], img).img.data[0][0];
|
||||
assert!((v2_floor - zero).abs() > 1e-4, "{v2_floor} vs {zero}");
|
||||
// An instance without the parameter reads as version 1.
|
||||
let s = crate::find("ec.utility.cineon").unwrap();
|
||||
let mut params = Params { values: s.params.iter().filter(|p| p.id != "version").map(|p| (p.id.to_string(), p.default.clone())).collect() };
|
||||
params.values.insert("gamma".into(), num(1.7));
|
||||
assert!((cineon_gamma(¶ms) - 1.0).abs() < 1e-12);
|
||||
}
|
||||
|
||||
/// Interpret Footage and version 2 Converter agree at Gamma 1.
|
||||
#[test]
|
||||
fn cineon_converter_matches_interpret_footage() {
|
||||
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
|
||||
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
|
||||
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0)] {
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
|
||||
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma)), ("highlightRolloff", num(rolloff))];
|
||||
let out = run("ec.utility.cineon", &vals, img);
|
||||
let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
@@ -681,9 +834,9 @@ mod tests {
|
||||
assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
|
||||
}
|
||||
|
||||
/// The Cineon Converter gives the same values as before Cineon Settings shared its
|
||||
/// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
|
||||
/// conversion types.
|
||||
/// Saved version 1 Cineon Converter effects give the same bits as the converter before the
|
||||
/// Version parameter (`log_to_lin` and `lin_to_log` below), at the default and custom black
|
||||
/// and white points, for several Gammas and all three conversion types.
|
||||
#[test]
|
||||
fn cineon_converter_output_is_unchanged() {
|
||||
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
|
||||
@@ -708,29 +861,35 @@ mod tests {
|
||||
lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7);
|
||||
(white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
|
||||
}
|
||||
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
|
||||
for gamma in [None, Some(2.4)] {
|
||||
for kind in 0..3 {
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let mut vals = vec![("conversionType", Value::Enum(kind))];
|
||||
if let Some(g) = gamma {
|
||||
vals.push(("gamma", num(g)));
|
||||
}
|
||||
let out = run("ec.utility.cineon", &vals, img);
|
||||
let g = gamma.unwrap_or(1.7) as f32;
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
let want = match kind {
|
||||
0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
|
||||
1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
|
||||
_ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
|
||||
};
|
||||
let got = out.img.data[x][0];
|
||||
assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
|
||||
let codes = [0.0f32, 95.0, 120.0, 250.0, 400.0, 560.0, 685.0, 700.0, 800.0, 1023.0];
|
||||
for (black, white) in [(95.0f32, 685.0f32), (120.0, 700.0)] {
|
||||
for g in [1.7, 1.0, 2.4, 0.6, 3.4, 0.0] {
|
||||
for kind in 0..3 {
|
||||
let mut img = Image::new(codes.len() as u32, 1);
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
img.set(x as u32, 0, [v, v, v, 1.0]);
|
||||
}
|
||||
let vals = [
|
||||
("version", num(1.0)),
|
||||
("conversionType", Value::Enum(kind)),
|
||||
("gamma", num(g)),
|
||||
("tenBitBlackPoint", num(f64::from(black))),
|
||||
("tenBitWhitePoint", num(f64::from(white))),
|
||||
];
|
||||
let out = run("ec.utility.cineon", &vals, img);
|
||||
let g = g as f32;
|
||||
for (x, code) in codes.iter().enumerate() {
|
||||
let v = code / 1023.0;
|
||||
let want = match kind {
|
||||
0 => lin_to_log(v, g, 20.0, black, white),
|
||||
1 => log_to_lin(v, g, 20.0, black, white),
|
||||
_ => lin_to_log(log_to_lin(v, g, 20.0, black, white), 1.7, 20.0, 95.0, 685.0),
|
||||
};
|
||||
let got = out.img.data[x][0];
|
||||
// Bit for bit: a saved version 1 instance renders exactly as before.
|
||||
assert_eq!(want.to_bits(), got.to_bits(), "points {black}-{white}, gamma {g}, type {kind}, code {code}: {want} vs {got}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,10 +17,15 @@ use crate::{EngineError, KeyRef, Result, Session, cmd};
|
||||
|
||||
/// Preset file format version tag (`*.ecpreset`, JSON).
|
||||
pub const PRESET_TAG: &str = "effectcraftAnimationPreset";
|
||||
/// The preset format version that `anim.savePreset` writes.
|
||||
pub const PRESET_VERSION: u64 = 3;
|
||||
/// The first preset version that stores effect keys relative to the earliest key (#1971).
|
||||
const RELATIVE_EFFECT_KEYS: u64 = 2;
|
||||
/// The first preset version that stores text animators and selectors under `textAnimators`.
|
||||
const TEXT_ANIMATORS: u64 = 3;
|
||||
|
||||
/// The preset format version written under [`PRESET_TAG`]. 4: a property that is a Cineon
|
||||
/// Converter's Gamma, directly or through Essential Properties, records that Cineon Converter's
|
||||
/// version in `cineonVersion`; the Gamma properties of version 1 to 3 presets are version 1 Gammas.
|
||||
pub const PRESET_VERSION: u64 = 4;
|
||||
|
||||
fn has_selected_props(s: &Session) -> std::result::Result<(), String> {
|
||||
has_comp(s)?;
|
||||
@@ -105,7 +110,8 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
continue;
|
||||
}
|
||||
let (Some(pr), Some(mp)) = (l.props.find(uid), match_path_of(&l.props, uid)) else { continue };
|
||||
props.push((l.layer_time(t), mp, pr.clone()));
|
||||
let version = cineon_gamma_version(&s.project, l, uid, Some(t), NESTING);
|
||||
props.push((l.layer_time(t), mp, pr.clone(), version));
|
||||
}
|
||||
if props.is_empty() && effects.is_empty() && animators.is_empty() {
|
||||
return Err(bad("anim.savePreset", "select properties or effects to save"));
|
||||
@@ -113,7 +119,7 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
// Key times are stored relative to the earliest key so the preset applies at the CTI.
|
||||
let first = props
|
||||
.iter()
|
||||
.flat_map(|(_, _, pr)| pr.keys.iter().map(|k| k.time))
|
||||
.flat_map(|(_, _, pr, _)| pr.keys.iter().map(|k| k.time))
|
||||
.chain(earliest_key(effects.iter().map(|(_, g)| g).chain(&animators)))
|
||||
.min()
|
||||
.unwrap_or(Tick::ZERO);
|
||||
@@ -122,12 +128,16 @@ fn save_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
let props_json: Vec<Value> = props
|
||||
.iter()
|
||||
.map(|(_, mp, pr)| {
|
||||
.map(|(_, mp, pr, version)| {
|
||||
let mut pr = pr.clone();
|
||||
for k in &mut pr.keys {
|
||||
k.time -= first;
|
||||
}
|
||||
json!({"path": mp, "value": pr.value, "keys": pr.keys, "expression": pr.expr})
|
||||
let mut v = json!({"path": mp, "value": pr.value, "keys": pr.keys, "expression": pr.expr});
|
||||
if let Some((version, _)) = version {
|
||||
v["cineonVersion"] = json!(version);
|
||||
}
|
||||
v
|
||||
})
|
||||
.collect();
|
||||
let fx_json: Vec<Value> = effects.iter().map(|(_, g)| serde_json::to_value(g).unwrap_or_default()).collect();
|
||||
@@ -198,7 +208,19 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
retime_effect(g, 1.0, "anim.applyPreset", |t| Ok(Tick(t.0.checked_sub(first.0).ok_or_else(|| effect_time_error("anim.applyPreset"))?)))?;
|
||||
}
|
||||
}
|
||||
let props: Vec<(String, Property)> = doc["properties"]
|
||||
// A Cineon Converter saved before version 2 gets its Version (1), as when a project opens.
|
||||
if let Some(spec) = effectcraft_effects::find("ec.utility.cineon") {
|
||||
for g in &mut effects {
|
||||
if matches!(&g.kind, GroupKind::Effect { effect } if effect == spec.id) {
|
||||
// The uids are reassigned when the group is applied.
|
||||
let mut next = 0;
|
||||
effectcraft_effects::migrate::upgrade_instance(spec, g, &mut Ids(&mut next), [0.0; 2]);
|
||||
}
|
||||
}
|
||||
}
|
||||
// The Cineon Converter version of each saved Gamma; a format 1 to 3 preset holds version 1 Gammas.
|
||||
let legacy = doc[PRESET_TAG].as_u64().unwrap_or(1) < PRESET_VERSION;
|
||||
let props: Vec<(String, Property, Option<f64>)> = doc["properties"]
|
||||
.as_array()
|
||||
.map(|a| {
|
||||
a.iter()
|
||||
@@ -210,12 +232,29 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut pr = Property::new(0, "", "", value);
|
||||
pr.keys = keys;
|
||||
pr.expr = expr;
|
||||
Some((path, pr))
|
||||
let version = v["cineonVersion"].as_f64().or(legacy.then_some(1.0));
|
||||
Some((path, pr, version))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
let saved_3d = props.iter().any(|(path, src)| path == "text/perChar3d" && src.value.as_bool());
|
||||
// Per layer and property: the version of the Cineon Converter Gammas the property drives
|
||||
// through Essential Properties.
|
||||
let exposed: Vec<Vec<Option<f64>>> = ids
|
||||
.iter()
|
||||
.map(|lid| {
|
||||
let l = s.project.comp(cid).and_then(|c| c.layer(*lid));
|
||||
props
|
||||
.iter()
|
||||
.map(|(path, _, _)| {
|
||||
let l = l?;
|
||||
let pr = l.props.prop(&resolve_effect_path(l, path))?;
|
||||
cineon_gamma_version(&s.project, l, pr.uid, Some(s.time()), NESTING).and_then(|(v, direct)| (!direct).then_some(v))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
.collect();
|
||||
let saved_3d = props.iter().any(|(path, src, _)| path == "text/perChar3d" && src.value.as_bool());
|
||||
let t = s.time();
|
||||
// Effect bounds per layer size the point defaults of parameters added since the preset was saved.
|
||||
let comp = s.project.comp(cid).ok_or(EngineError::NoComp)?;
|
||||
@@ -225,7 +264,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
let mut skipped = vec![];
|
||||
s.edit("Apply Animation Preset", None, |proj, _| {
|
||||
let mut next = proj.next_id;
|
||||
for lid in &ids {
|
||||
for (li, lid) in ids.iter().enumerate() {
|
||||
let size = sizes.get(lid).copied().unwrap_or([100.0, 100.0]);
|
||||
let l = layer_mut(proj, cid, *lid)?;
|
||||
let lt = l.layer_time(t);
|
||||
@@ -248,7 +287,41 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
applied += 1;
|
||||
}
|
||||
}
|
||||
for (path, src) in &props {
|
||||
for (pi, (path, src, version)) in props.iter().enumerate() {
|
||||
// A Gamma of a Cineon Converter whose Version is keyframed is converted to that
|
||||
// Version, and skipped when the keys hold more than one version or an expression
|
||||
// sets the Version. A preset that holds the Version too sets it.
|
||||
let version_path = cineon_version_path(path).filter(|vp| !props.iter().any(|(p, v, _)| p == vp && matches!(v.value, KV::Scalar(_))));
|
||||
let keyed = match (version, &version_path) {
|
||||
(Some(_), Some(vp)) => {
|
||||
l.props.prop(&resolve_effect_path(l, vp)).filter(|pr| pr.has_expression() || pr.keys.iter().any(|k| !k.muted)).map(constant_version)
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
let target = match keyed {
|
||||
Some(None) => {
|
||||
skipped.push(path.clone());
|
||||
continue;
|
||||
}
|
||||
Some(to) => to,
|
||||
None => exposed[li][pi],
|
||||
};
|
||||
// An exposed Gamma is converted to the scale of the Cineon Converters it drives.
|
||||
// One with an enabled expression is skipped: expressions are applied as saved.
|
||||
let rescaled;
|
||||
let src = match (version, target) {
|
||||
(Some(from), Some(to)) if *from != to && src.has_expression() => {
|
||||
skipped.push(path.clone());
|
||||
continue;
|
||||
}
|
||||
(Some(from), Some(to)) if *from != to => {
|
||||
let mut c = src.clone();
|
||||
rescale_gamma(&mut c, effectcraft_effects::cineon_gamma_scale(*from) / effectcraft_effects::cineon_gamma_scale(to));
|
||||
rescaled = c;
|
||||
&rescaled
|
||||
}
|
||||
_ => src,
|
||||
};
|
||||
let resolved = resolve_effect_path(l, path);
|
||||
match l.props.prop_mut(&resolved) {
|
||||
Some(pr) if std::mem::discriminant(&pr.value) == std::mem::discriminant(&src.value) => {
|
||||
@@ -267,7 +340,18 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
applied += 1;
|
||||
}
|
||||
_ => skipped.push(path.clone()),
|
||||
_ => {
|
||||
skipped.push(path.clone());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
// A Cineon Converter's Gamma sets the instance's Version to the one it was saved
|
||||
// with, unless that Version is keyframed or the preset holds it.
|
||||
if keyed.is_none()
|
||||
&& let (Some(v), Some(vp)) = (version, &version_path)
|
||||
&& let Some(pr) = l.props.prop_mut(&resolve_effect_path(l, vp))
|
||||
{
|
||||
pr.value = KV::Scalar(*v);
|
||||
}
|
||||
}
|
||||
// After the properties, so a saved Per-character 3D setting counts: it needs a 3D layer
|
||||
@@ -299,7 +383,7 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
// Without this, a preset that fits no layer does nothing and says nothing.
|
||||
if applied == 0 && !skipped.is_empty() {
|
||||
let legacy_text = doc[PRESET_TAG].as_u64().unwrap_or(1) < PRESET_VERSION && props.iter().any(|(path, _)| path.starts_with("text/animators/"));
|
||||
let legacy_text = doc[PRESET_TAG].as_u64().unwrap_or(1) < TEXT_ANIMATORS && props.iter().any(|(path, _, _)| path.starts_with("text/animators/"));
|
||||
let message = if legacy_text {
|
||||
"nothing in this preset fits the layer. A text animation preset saved by an earlier version only updates animators a layer already has: save it again"
|
||||
} else {
|
||||
@@ -319,6 +403,83 @@ fn apply_preset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
Ok(json!({"applied": applied, "skipped": skipped}))
|
||||
}
|
||||
|
||||
/// The preset path of the Version of the Cineon Converter whose Gamma is at preset path `path`
|
||||
/// (`effects/<match name>[#n]/gamma`, as [`match_path_of`] writes it).
|
||||
fn cineon_version_path(path: &str) -> Option<String> {
|
||||
let (seg, tail) = path.strip_prefix("effects/")?.split_once('/')?;
|
||||
(seg.rsplit_once('#').map_or(seg, |(m, _)| m) == "ec.utility.cineon" && tail == "gamma").then(|| format!("effects/{seg}/version"))
|
||||
}
|
||||
|
||||
/// How many nested comps [`cineon_gamma_version`] follows Essential Properties through.
|
||||
const NESTING: u32 = 16;
|
||||
|
||||
/// The version of the Cineon Converter whose Gamma property `uid` of `l` is, and whether that
|
||||
/// Cineon Converter is on `l`. With `t`, a keyframed Version is read at comp time `t`; without
|
||||
/// it, a Version whose keyframes hold more than one value has no version. A Version that an
|
||||
/// expression sets has none either. An Essential
|
||||
/// Properties value of a precomp layer counts when its control drives only Cineon Converter
|
||||
/// Gammas of one version, in the nested comp or, through further Essential Properties, in comps
|
||||
/// up to `depth` levels deeper.
|
||||
fn cineon_gamma_version(p: &Project, l: &Layer, uid: Uid, t: Option<Tick>, depth: u32) -> Option<(f64, bool)> {
|
||||
let direct = l.effects().and_then(|fx| {
|
||||
fx.groups().find(|g| matches!(&g.kind, GroupKind::Effect { effect } if effect == "ec.utility.cineon") && g.get("gamma").is_some_and(|pr| pr.uid == uid))
|
||||
});
|
||||
if let Some(g) = direct {
|
||||
let Some(pr) = g.get("version") else { return Some((1.0, true)) };
|
||||
if pr.has_expression() {
|
||||
return None;
|
||||
}
|
||||
let version = match t {
|
||||
Some(t) => pr.value_at(l.layer_time(t)).as_f64(),
|
||||
None => constant_version(pr)?,
|
||||
};
|
||||
return Some((version, true));
|
||||
}
|
||||
let LayerSource::Comp { item } = l.source else { return None };
|
||||
let depth = depth.checked_sub(1)?;
|
||||
let ctl = effectcraft_project::essential::group(l)?.find(uid).and_then(|pr| effectcraft_project::essential::control_of(&pr.match_id))?;
|
||||
let c = p.comp(item)?;
|
||||
let mut version = None;
|
||||
for (lid, target) in c.essential.as_ref()?.resolve(ctl)?.kind.targets() {
|
||||
let (v, _) = cineon_gamma_version(p, c.layer(lid)?, target, None, depth)?;
|
||||
if version.is_some_and(|x| x != v) {
|
||||
return None;
|
||||
}
|
||||
version = Some(v);
|
||||
}
|
||||
version.map(|v| (v, false))
|
||||
}
|
||||
|
||||
/// The value a Cineon Converter's Version holds at every time: its value without keyframes
|
||||
/// that play, and none when the keyframes that play hold more than one value or ease with a
|
||||
/// speed that moves the value between them, or when an expression sets it.
|
||||
fn constant_version(pr: &Property) -> Option<f64> {
|
||||
if pr.has_expression() {
|
||||
return None;
|
||||
}
|
||||
let keys: Vec<&Keyframe> = pr.keys.iter().filter(|k| !k.muted).collect();
|
||||
let first = keys.first().map_or_else(|| pr.value.as_f64(), |k| k.value.as_f64());
|
||||
let still = |interp: Interp, eases: &[effectcraft_keyframe::Ease]| interp != Interp::Bezier || eases.iter().all(|e| e.speed == 0.0);
|
||||
let eases_between =
|
||||
keys.windows(2).any(|w| w[0].out_interp != Interp::Hold && (!still(w[0].out_interp, &w[0].out_ease) || !still(w[1].in_interp, &w[1].in_ease)));
|
||||
(keys.iter().all(|k| k.value.as_f64() == first) && !eases_between).then_some(first)
|
||||
}
|
||||
|
||||
/// Multiplies a Gamma value, its keyframes and their speeds by `f`.
|
||||
fn rescale_gamma(pr: &mut Property, f: f64) {
|
||||
if let KV::Scalar(x) = &mut pr.value {
|
||||
*x *= f;
|
||||
}
|
||||
for k in &mut pr.keys {
|
||||
if let KV::Scalar(x) = &mut k.value {
|
||||
*x *= f;
|
||||
}
|
||||
for e in k.in_ease.iter_mut().chain(k.out_ease.iter_mut()) {
|
||||
e.speed *= f;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- keyframes
|
||||
|
||||
fn add_keyframe(s: &mut Session, _: &Value) -> Result<Value> {
|
||||
|
||||
@@ -1173,9 +1173,14 @@ fn reset(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
}
|
||||
.ok_or_else(|| bad("effect.reset", "unknown effect"))?;
|
||||
let name = g.name.clone();
|
||||
// Reset keeps a Cineon Converter's Version.
|
||||
let cineon_version = if spec.id == "ec.utility.cineon" { g.get("version").map(|v| v.value.as_f64()) } else { None };
|
||||
s.edit(&format!("Reset {name}"), None, |proj, _| {
|
||||
let g = layer_mut(proj, cid, lid)?.props.find_group_mut(uid).ok_or_else(|| bad("effect.reset", "gone"))?;
|
||||
for ps in &spec.params {
|
||||
if ps.id == "version" && cineon_version.is_some() {
|
||||
continue;
|
||||
}
|
||||
// (`group/param` ids live in twirl-down groups.)
|
||||
if let Some(pr) = g.prop_mut(ps.id) {
|
||||
pr.set_value_at(lt, effectcraft_effects::default_value(ps, size));
|
||||
|
||||
@@ -681,6 +681,535 @@ fn opening_a_schema_1_project_moves_shape_layer_effect_points_into_effect_space(
|
||||
check(&s3);
|
||||
}
|
||||
|
||||
/// Writes `s`'s project as a build before Cineon Converter version 2 saved it: schema 2, and no
|
||||
/// hidden Version parameter on Cineon Converter instances. Returns the folder and the file.
|
||||
fn save_before_cineon_version_2(s: &Session, name: &str) -> (std::path::PathBuf, String) {
|
||||
fn drop_version(v: &mut serde_json::Value) {
|
||||
match v {
|
||||
serde_json::Value::Object(o) => {
|
||||
if o.get("match") == Some(&json!("ec.utility.cineon"))
|
||||
&& let Some(serde_json::Value::Array(children)) = o.get_mut("children")
|
||||
{
|
||||
children.retain(|c| c["match"] != json!("version"));
|
||||
}
|
||||
o.values_mut().for_each(drop_version);
|
||||
}
|
||||
serde_json::Value::Array(a) => a.iter_mut().for_each(drop_version),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut old: serde_json::Value = serde_json::from_str(&s.project.to_file_json().unwrap()).unwrap();
|
||||
old["schema"] = json!(2);
|
||||
drop_version(&mut old);
|
||||
let dir = std::env::temp_dir().join(format!("{name}-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let path = dir.join("old.ecproj").to_string_lossy().to_string();
|
||||
std::fs::write(&path, old.to_string()).unwrap();
|
||||
(dir, path)
|
||||
}
|
||||
|
||||
/// Opening a project with a Cineon Converter saved before version 2 leaves the text of every
|
||||
/// expression as it was saved: one on an unrelated property (a wiggle) and one on Gamma.
|
||||
#[test]
|
||||
fn opening_an_old_cineon_converter_project_keeps_expression_text() {
|
||||
let (mut s, a, b) = comp_with_two_solids();
|
||||
let fx = apply(&mut s, a, "Cineon Converter");
|
||||
let wiggle = "wiggle(2, 10)";
|
||||
let on_gamma = "value + time / 10";
|
||||
s.execute("prop.setExpression", json!({"layer": b, "path": "transform/position", "expression": wiggle})).unwrap();
|
||||
let gamma = effect_group(&s, a, fx).prop("gamma").unwrap().uid;
|
||||
s.execute("prop.setExpression", json!({"layer": a, "prop": gamma, "expression": on_gamma})).unwrap();
|
||||
let (dir, path) = save_before_cineon_version_2(&s, "ec-cineon-expressions");
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
let position = layer(&s2, b).props.prop("transform/position").and_then(|p| p.expr.clone()).map(|e| e.text);
|
||||
assert_eq!(position.as_deref(), Some(wiggle));
|
||||
let gamma = effect_group(&s2, a, fx).prop("gamma").and_then(|p| p.expr.clone()).map(|e| e.text);
|
||||
assert_eq!(gamma.as_deref(), Some(on_gamma));
|
||||
}
|
||||
|
||||
/// Cineon Converter version 1's Log to Linear: the linear value raised to `1.7 / gamma` (no
|
||||
/// Highlight Rolloff).
|
||||
fn cineon_version_1(v: f32, gamma: f32) -> f32 {
|
||||
let off = 10f32.powf((95.0 - 685.0) * 0.002 / 0.6);
|
||||
((10f32.powf((v * 1023.0 - 685.0) * 0.002 / 0.6) - off) / (1.0 - off)).max(0.0).powf(1.7 / gamma)
|
||||
}
|
||||
|
||||
fn cineon_version_2(v: f32, gamma: f32) -> f32 {
|
||||
let offset = 10f32.powf((95.0 - 685.0) * 0.002 / 0.6).powf(1.0 / gamma);
|
||||
let exposure = 10f32.powf((v * 1023.0 - 685.0) * 0.002 / 0.6).powf(1.0 / gamma);
|
||||
((exposure - offset) / (1.0 - offset)).max(0.0)
|
||||
}
|
||||
|
||||
/// A project saved before Cineon Converter version 2 opens with each instance at version 1,
|
||||
/// where Gamma 1.7 gives linear output: Gamma and its keyframes keep their saved values, and the
|
||||
/// frames render as they did. Saving and opening again changes nothing. A Cineon Converter
|
||||
/// applied after that is version 2, where Gamma 1 gives linear output.
|
||||
#[test]
|
||||
fn opening_an_old_cineon_converter_project_keeps_what_it_renders() {
|
||||
let mut s = Session::default();
|
||||
// 32 bpc, so frames are not rounded to 8-bit steps.
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let animated = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let input = s.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
let fx = apply(&mut s, animated, "Cineon Converter");
|
||||
s.execute("prop.set", json!({"layer": animated, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();
|
||||
animate_effect(&mut s, animated, fx, "gamma", &[(0.0, 2.2), (1.0, 3.4)]);
|
||||
let still = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let still_fx = apply(&mut s, still, "Cineon Converter");
|
||||
s.execute("prop.set", json!({"layer": still, "path": "effects/#1/gamma", "value": 2.2})).unwrap();
|
||||
s.execute("layer.setSwitch", json!({"layers": [still], "switch": "video", "value": false})).unwrap();
|
||||
|
||||
let (dir, path) = save_before_cineon_version_2(&s, "ec-cineon-render");
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let check = |s2: &Session| {
|
||||
let prop = |lid: u64, uid: u64, id: &str| effect_group(s2, lid, uid).prop(id).unwrap().clone();
|
||||
assert_eq!(prop(animated, fx, "version").value, KV::Scalar(1.0));
|
||||
assert_eq!(prop(still, still_fx, "version").value, KV::Scalar(1.0));
|
||||
let keys: Vec<f64> = prop(animated, fx, "gamma").keys.iter().map(|k| k.value.as_f64()).collect();
|
||||
assert_eq!(keys, [2.2, 3.4]);
|
||||
assert_eq!(prop(still, still_fx, "gamma").value, KV::Scalar(2.2));
|
||||
for (t, gamma) in [(0.0, 2.2), (0.5, 2.8), (1.0, 3.4)] {
|
||||
let got = s2.render(cid, Tick::from_seconds_f64(t), Default::default()).get(4, 4);
|
||||
for c in 0..3 {
|
||||
let want = cineon_version_1(input[c], gamma);
|
||||
assert!((got[c] - want).abs() < 1e-4, "{t} s, channel {c}: {} rendered, {want} before", got[c]);
|
||||
}
|
||||
}
|
||||
};
|
||||
check(&s2);
|
||||
s2.execute("file.save", json!({"path": path})).unwrap();
|
||||
let mut s3 = Session::default();
|
||||
s3.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
check(&s3);
|
||||
// A new instance on top is version 2 with Gamma 1.7.
|
||||
let top = s3.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let top_fx = apply(&mut s3, top, "Cineon Converter");
|
||||
s3.execute("prop.set", json!({"layer": top, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();
|
||||
assert_eq!(effect_group(&s3, top, top_fx).prop("version").unwrap().value, KV::Scalar(2.0));
|
||||
assert_eq!(effect_group(&s3, top, top_fx).prop("gamma").unwrap().value, KV::Scalar(1.7));
|
||||
let got = s3.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
for c in 0..3 {
|
||||
let want = cineon_version_2(input[c], 1.7);
|
||||
assert!((got[c] - want).abs() < 1e-4, "channel {c}: {} rendered, {want}", got[c]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A precomp layer that overrides an exposed Gamma of an old Cineon Converter keeps its saved
|
||||
/// value, and the nested version 1 instance renders it as before.
|
||||
#[test]
|
||||
fn opening_an_old_project_keeps_an_overridden_cineon_gamma() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let input = s.render(inner, Tick::ZERO, Default::default()).get(4, 4);
|
||||
apply(&mut s, solid, "Cineon Converter");
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": solid, "path": "effects/#1/gamma", "value": 1.7})).unwrap();
|
||||
let control = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let main = s.active_comp_id().unwrap();
|
||||
let inst = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
s.execute("essential.set", json!({"layer": inst, "control": control, "value": 2.2})).unwrap();
|
||||
|
||||
let (dir, path) = save_before_cineon_version_2(&s, "ec-cineon-essential");
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
let c = s2.execute("essential.instance", json!({"comp": main.0, "layer": inst})).unwrap()["controls"][0].clone();
|
||||
assert_eq!(c["overridden"], json!(true), "{c}");
|
||||
assert_eq!(c["value"].as_f64(), Some(2.2), "{c}");
|
||||
let got = s2.render(main, Tick::ZERO, Default::default()).get(4, 4);
|
||||
for ch in 0..3 {
|
||||
let want = cineon_version_1(input[ch], 2.2);
|
||||
assert!((got[ch] - want).abs() < 1e-4, "channel {ch}: {} rendered, {want} before", got[ch]);
|
||||
}
|
||||
}
|
||||
|
||||
/// A project saved by this build opens in a build from before Cineon Converter version 2 as
|
||||
/// `docs/architecture.md` describes. The file is still schema 2. That build's registry has no
|
||||
/// Version parameter: opening keeps the saved one, and Gamma is read on version 1's scale. A
|
||||
/// version 1 instance holds its Gamma as saved before, so it renders there as it did; a version 2
|
||||
/// instance's Gamma 1.7 renders there as a version 1 Gamma of 1.7.
|
||||
#[test]
|
||||
fn a_project_saved_now_opens_in_an_older_build_as_documented() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let old = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let input = s.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
let old_fx = apply(&mut s, old, "Cineon Converter");
|
||||
s.execute("prop.set", json!({"layer": old, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": old, "path": "effects/#1/gamma", "value": 2.2})).unwrap();
|
||||
let (dir, path) = save_before_cineon_version_2(&s, "ec-cineon-older-build");
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let new = s2.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let new_fx = apply(&mut s2, new, "Cineon Converter");
|
||||
s2.execute("prop.set", json!({"layer": new, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();
|
||||
s2.execute("file.save", json!({"path": path})).unwrap();
|
||||
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
assert_eq!(saved["schema"], json!(2));
|
||||
|
||||
// The saved Cineon Converter groups, read with the older registry's Cineon Converter.
|
||||
fn groups(v: &serde_json::Value, out: &mut Vec<PropGroup>) {
|
||||
match v {
|
||||
serde_json::Value::Object(o) => {
|
||||
if o.get("match") == Some(&json!("ec.utility.cineon")) {
|
||||
out.push(serde_json::from_value(v.clone()).unwrap());
|
||||
}
|
||||
o.values().for_each(|c| groups(c, out));
|
||||
}
|
||||
serde_json::Value::Array(a) => a.iter().for_each(|c| groups(c, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut saved_groups = vec![];
|
||||
groups(&saved, &mut saved_groups);
|
||||
let spec = effectcraft_effects::find("ec.utility.cineon").unwrap();
|
||||
let older = effectcraft_effects::EffectSpec {
|
||||
params: spec
|
||||
.params
|
||||
.iter()
|
||||
.filter(|p| p.id != "version")
|
||||
.map(|p| effectcraft_effects::ParamSpec { id: p.id, name: p.name, default: p.default.clone(), ui: p.ui.clone() })
|
||||
.collect(),
|
||||
..*spec
|
||||
};
|
||||
let opened_in_older = |uid: u64| {
|
||||
let mut g = saved_groups.iter().find(|g| g.uid == uid).unwrap().clone();
|
||||
let mut next = 1_000_000;
|
||||
effectcraft_effects::migrate::upgrade_instance(&older, &mut g, &mut effectcraft_project::build::Ids(&mut next), [8.0, 8.0]);
|
||||
(g.get("version").map(|p| p.value.clone()), g.get("gamma").unwrap().value.clone())
|
||||
};
|
||||
assert_eq!(opened_in_older(old_fx), (Some(KV::Scalar(1.0)), KV::Scalar(2.2)));
|
||||
assert_eq!(opened_in_older(new_fx), (Some(KV::Scalar(2.0)), KV::Scalar(1.7)));
|
||||
|
||||
// This build renders the version 1 instance as the older build does: Gamma 2.2 read as a
|
||||
// version 1 Gamma. The older build reads the new instance's Gamma 1.7 on the version 1 scale.
|
||||
let got_new = s2.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
s2.execute("layer.setSwitch", json!({"layers": [new], "switch": "video", "value": false})).unwrap();
|
||||
let got_old = s2.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
for c in 0..3 {
|
||||
let want = cineon_version_1(input[c], 2.2);
|
||||
assert!((got_old[c] - want).abs() < 1e-4, "channel {c}: {} rendered, {want} in the older build", got_old[c]);
|
||||
let want_new = cineon_version_2(input[c], 1.7);
|
||||
assert!((got_new[c] - want_new).abs() < 1e-4, "channel {c}: {} rendered, {want_new}", got_new[c]);
|
||||
assert!((cineon_version_1(input[c], 1.7) - want_new).abs() > 1e-3, "channel {c}: the older build renders Gamma 1.7 differently");
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset on a version 1 Cineon Converter keeps its Version, so its other Gamma keyframes render
|
||||
/// as before, and keys Gamma to version 1's linear Gamma (1.7) at the current time.
|
||||
#[test]
|
||||
fn resetting_an_old_cineon_converter_keeps_its_version() {
|
||||
let mut s = Session::default();
|
||||
s.execute("file.projectSettings", json!({"bitDepth": 32})).unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let l = s.execute("layer.newSolid", json!({"color": [0.4, 0.5, 0.6], "width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
let input = s.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
let fx = apply(&mut s, l, "Cineon Converter");
|
||||
s.execute("prop.set", json!({"layer": l, "path": "effects/#1/highlightRolloff", "value": 0.0})).unwrap();
|
||||
animate_effect(&mut s, l, fx, "gamma", &[(0.0, 2.2), (1.0, 3.4)]);
|
||||
let (dir, path) = save_before_cineon_version_2(&s, "ec-cineon-reset");
|
||||
let mut s2 = Session::default();
|
||||
s2.execute("file.open", json!({"path": path})).unwrap();
|
||||
let _ = std::fs::remove_dir_all(dir);
|
||||
s2.execute("time.set", json!({"time": 1.5})).unwrap();
|
||||
s2.execute("effect.reset", json!({"layer": l, "effect": fx})).unwrap();
|
||||
let g = effect_group(&s2, l, fx);
|
||||
assert_eq!(g.prop("version").unwrap().value, KV::Scalar(1.0));
|
||||
let keys: Vec<f64> = g.prop("gamma").unwrap().keys.iter().map(|k| k.value.as_f64()).collect();
|
||||
assert_eq!(keys, [2.2, 3.4, 1.7]);
|
||||
let got = s2.render(cid, Tick::ZERO, Default::default()).get(4, 4);
|
||||
for c in 0..3 {
|
||||
let want = cineon_version_1(input[c], 2.2);
|
||||
assert!((got[c] - want).abs() < 1e-4, "channel {c}: {} rendered, {want} before", got[c]);
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes what a build before Cineon Converter version 2 did not write into an animation
|
||||
/// preset: format 1, and no `cineonVersion` on its properties.
|
||||
fn preset_before_cineon_version_2(preset: &serde_json::Value) -> serde_json::Value {
|
||||
let mut old = preset.clone();
|
||||
old["effectcraftAnimationPreset"] = json!(1);
|
||||
for p in old["properties"].as_array_mut().unwrap() {
|
||||
p.as_object_mut().unwrap().remove("cineonVersion");
|
||||
}
|
||||
old
|
||||
}
|
||||
|
||||
/// A Cineon Converter's Gamma saved alone in an animation preset records its instance's
|
||||
/// version, and applying it sets the Version of the instance it lands on. A preset saved before
|
||||
/// format 4 records none, and its Gamma lands on the instance as a version 1 value.
|
||||
#[test]
|
||||
fn cineon_gamma_presets_carry_the_version() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
let fx = apply(&mut s, a, "Cineon Converter");
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 1.3})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/gamma"})).unwrap();
|
||||
let file = std::env::temp_dir().join(format!("ec-cineon-gamma-preset-{}.ecpreset", std::process::id()));
|
||||
let saved = s.execute("anim.savePreset", json!({"path": file})).unwrap();
|
||||
assert_eq!(saved["properties"], json!(1));
|
||||
let preset: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert_eq!(preset["effectcraftAnimationPreset"], json!(4));
|
||||
assert_eq!(preset["properties"][0]["path"], json!("effects/ec.utility.cineon/gamma"));
|
||||
assert_eq!(preset["properties"][0]["cineonVersion"], json!(2.0));
|
||||
let get = |s: &Session, id: &str| layer(s, a).effects().unwrap().groups().next().unwrap().get(id).unwrap().value.clone();
|
||||
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/version", "value": 1.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 4.0})).unwrap();
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset.clone(), "layers": [a]})).unwrap();
|
||||
assert_eq!((&r["applied"], &r["skipped"]), (&json!(1), &json!([])), "{r}");
|
||||
assert_eq!((get(&s, "gamma"), get(&s, "version")), (KV::Scalar(1.3), KV::Scalar(2.0)));
|
||||
|
||||
s.execute("anim.applyPreset", json!({"preset": preset_before_cineon_version_2(&preset), "layers": [a]})).unwrap();
|
||||
assert_eq!((get(&s, "gamma"), get(&s, "version")), (KV::Scalar(1.3), KV::Scalar(1.0)));
|
||||
|
||||
// Format 2 already had relative effect keys and format 3 whole text animators, but their
|
||||
// Gammas had no version tag.
|
||||
for format in [2, 3] {
|
||||
s.execute("anim.applyPreset", json!({"preset": preset.clone(), "layers": [a]})).unwrap();
|
||||
assert_eq!(get(&s, "version"), KV::Scalar(2.0), "format {format}");
|
||||
let mut untagged = preset.clone();
|
||||
untagged["effectcraftAnimationPreset"] = json!(format);
|
||||
untagged["properties"][0].as_object_mut().unwrap().remove("cineonVersion");
|
||||
s.execute("anim.applyPreset", json!({"preset": untagged, "layers": [a]})).unwrap();
|
||||
assert_eq!((get(&s, "gamma"), get(&s, "version")), (KV::Scalar(1.3), KV::Scalar(1.0)), "format {format}");
|
||||
}
|
||||
|
||||
// A whole Cineon Converter from a preset saved before version 2 is applied as version 1,
|
||||
// and a Gamma preset applied to it later sets its Version.
|
||||
select_effect(&mut s, a, fx);
|
||||
let file = std::env::temp_dir().join(format!("ec-cineon-effect-preset-{}.ecpreset", std::process::id()));
|
||||
s.execute("anim.savePreset", json!({"path": file})).unwrap();
|
||||
let mut effect: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
effect["effects"][0]["children"].as_array_mut().unwrap().retain(|c| c["match"] != json!("version"));
|
||||
s.execute("anim.applyPreset", json!({"preset": effect, "layers": [a]})).unwrap();
|
||||
let second = |s: &Session, id: &str| layer(s, a).effects().unwrap().groups().nth(1).unwrap().get(id).map(|p| p.value.clone());
|
||||
assert_eq!(second(&s, "version"), Some(KV::Scalar(1.0)));
|
||||
let mut onto_second = preset;
|
||||
for p in onto_second["properties"].as_array_mut().unwrap() {
|
||||
p["path"] = json!(p["path"].as_str().unwrap().replace("ec.utility.cineon", "ec.utility.cineon#2"));
|
||||
}
|
||||
s.execute("anim.applyPreset", json!({"preset": onto_second, "layers": [a]})).unwrap();
|
||||
assert_eq!((second(&s, "gamma"), second(&s, "version")), (Some(KV::Scalar(1.3)), Some(KV::Scalar(2.0))));
|
||||
}
|
||||
|
||||
/// A Gamma preset records the Version its Cineon Converter has at the time it is saved. Applied
|
||||
/// to a Cineon Converter whose Version is keyframed, it keeps the keys and is converted to the
|
||||
/// one version they hold, or skipped when they hold several. A nested Cineon Converter's
|
||||
/// keyframed Version counts only when its keys that play hold one value.
|
||||
#[test]
|
||||
fn cineon_gamma_presets_follow_a_keyframed_version() {
|
||||
let (mut s, a, _) = comp_with_two_solids();
|
||||
apply(&mut s, a, "Cineon Converter");
|
||||
for (time, value) in [(0.0, 2.0), (1.0, 1.0)] {
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "effects/#1/version", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
s.execute("time.set", json!({"time": 2.0})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 1.3})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/gamma"})).unwrap();
|
||||
let file = std::env::temp_dir().join(format!("ec-cineon-keyed-version-{}.ecpreset", std::process::id()));
|
||||
s.execute("anim.savePreset", json!({"path": file})).unwrap();
|
||||
let preset: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert_eq!(preset["properties"][0]["cineonVersion"], json!(1.0));
|
||||
|
||||
// Applied to a Cineon Converter whose Version keys hold more than one version, the Gamma is
|
||||
// skipped and the keys stay.
|
||||
let get = |s: &Session, id: &str| layer(s, a).effects().unwrap().groups().next().unwrap().get(id).unwrap().clone();
|
||||
let keys = get(&s, "version").keys;
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 4.0})).unwrap();
|
||||
let err = s.execute("anim.applyPreset", json!({"preset": preset.clone(), "layers": [a]})).unwrap_err();
|
||||
assert!(err.to_string().contains("nothing in this preset fits"), "{err}");
|
||||
assert_eq!((get(&s, "gamma").value, get(&s, "version").keys), (KV::Scalar(4.0), keys));
|
||||
|
||||
// When the keys hold one version, the Gamma is converted to it and the keys stay.
|
||||
s.execute("prop.addKey", json!({"layer": a, "path": "effects/#1/version", "time": 0.0, "value": 1.0})).unwrap();
|
||||
let keys = get(&s, "version").keys;
|
||||
let mut version_2 = preset;
|
||||
version_2["properties"][0]["cineonVersion"] = json!(2.0);
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": version_2.clone(), "layers": [a]})).unwrap();
|
||||
assert_eq!((&r["applied"], &r["skipped"]), (&json!(1), &json!([])), "{r}");
|
||||
let gamma = get(&s, "gamma").value.as_f64();
|
||||
assert!((gamma - 1.3 * 1.7).abs() < 1e-9, "{gamma}");
|
||||
assert_eq!(get(&s, "version").keys, keys);
|
||||
|
||||
// Keys of one value that ease with a speed move the Version between them, so the Gamma is
|
||||
// skipped. A muted key of another value does not count, and neither does the ease of a key
|
||||
// with no other key that plays.
|
||||
let cid = s.active_comp_id().unwrap();
|
||||
let fx = layer(&s, a).effects().unwrap().groups().next().unwrap().uid;
|
||||
let edit_keys = |s: &mut Session, f: &dyn Fn(&mut [effectcraft_keyframe::Keyframe])| {
|
||||
s.edit("keys", None, |project, _| {
|
||||
let pr = project.comp_mut(cid).unwrap().layer_mut(LayerId(a)).unwrap().props.find_group_mut(fx).unwrap().get_mut("version").unwrap();
|
||||
f(&mut pr.keys);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
};
|
||||
edit_keys(&mut s, &|keys| {
|
||||
keys[0].out_interp = Interp::Bezier;
|
||||
keys[0].out_ease = vec![Ease { speed: 3.0, influence: 50.0 }];
|
||||
});
|
||||
let mut with_gamma = version_2.clone();
|
||||
with_gamma["properties"].as_array_mut().unwrap().push(json!({"path": "transform/opacity", "value": KV::Scalar(80.0), "keys": []}));
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": with_gamma, "layers": [a]})).unwrap();
|
||||
assert_eq!(r["skipped"], json!(["effects/ec.utility.cineon/gamma"]), "{r}");
|
||||
edit_keys(&mut s, &|keys| {
|
||||
keys[1].value = KV::Scalar(2.0);
|
||||
keys[1].muted = true;
|
||||
});
|
||||
s.execute("prop.set", json!({"layer": a, "path": "effects/#1/gamma", "value": 4.0})).unwrap();
|
||||
s.execute("anim.applyPreset", json!({"preset": version_2.clone(), "layers": [a]})).unwrap();
|
||||
let gamma = get(&s, "gamma").value.as_f64();
|
||||
assert!((gamma - 1.3 * 1.7).abs() < 1e-9, "{gamma}");
|
||||
|
||||
// A preset that holds the Version after the Gamma sets the Version, and the Gamma lands as
|
||||
// saved.
|
||||
edit_keys(&mut s, &|keys| keys[1].muted = false);
|
||||
let mut both = version_2;
|
||||
both["properties"].as_array_mut().unwrap().push(json!({"path": "effects/ec.utility.cineon/version", "value": KV::Scalar(2.0), "keys": []}));
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": both.clone(), "layers": [a]})).unwrap();
|
||||
assert_eq!((&r["applied"], &r["skipped"]), (&json!(2), &json!([])), "{r}");
|
||||
let version = get(&s, "version");
|
||||
assert_eq!((get(&s, "gamma").value, version.value, version.keys.len()), (KV::Scalar(1.3), KV::Scalar(2.0), 0));
|
||||
// Listed before the Gamma, the preset's Version stays as the preset has it.
|
||||
let mut first = both.clone();
|
||||
let props = first["properties"].as_array_mut().unwrap();
|
||||
let saved_version = props.pop().unwrap();
|
||||
props.insert(0, saved_version);
|
||||
props[0]["value"] = json!(KV::Scalar(1.0));
|
||||
s.execute("anim.applyPreset", json!({"preset": first.clone(), "layers": [a]})).unwrap();
|
||||
assert_eq!((get(&s, "gamma").value, get(&s, "version").value), (KV::Scalar(1.3), KV::Scalar(1.0)));
|
||||
// A Version of another type is skipped, and the Gamma sets the Version it was saved with.
|
||||
first["properties"][0]["value"] = json!(KV::Bool(true));
|
||||
s.execute("anim.applyPreset", json!({"preset": first, "layers": [a]})).unwrap();
|
||||
assert_eq!(get(&s, "version").value, KV::Scalar(2.0));
|
||||
|
||||
// A Version that an expression sets has no version to record or convert to.
|
||||
s.execute("prop.setExpression", json!({"layer": a, "path": "effects/#1/version", "expression": "1"})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": a, "path": "effects/#1/gamma"})).unwrap();
|
||||
let file = std::env::temp_dir().join(format!("ec-cineon-version-expression-{}.ecpreset", std::process::id()));
|
||||
s.execute("anim.savePreset", json!({"path": file})).unwrap();
|
||||
let saved: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert_eq!(saved["properties"][0]["cineonVersion"], json!(null));
|
||||
let mut tagged = saved;
|
||||
tagged["properties"][0]["cineonVersion"] = json!(2.0);
|
||||
tagged["properties"].as_array_mut().unwrap().push(json!({"path": "transform/opacity", "value": KV::Scalar(80.0), "keys": []}));
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": tagged, "layers": [a]})).unwrap();
|
||||
assert_eq!(r["skipped"], json!(["effects/ec.utility.cineon/gamma"]), "{r}");
|
||||
|
||||
// A Gamma exposed from a nested comp records a keyframed Version only when its keys all
|
||||
// hold one value.
|
||||
for (second, recorded) in [(1.0, json!(null)), (2.0, json!(2.0))] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let inner = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
apply(&mut s, solid, "Cineon Converter");
|
||||
for (time, value) in [(0.0, 2.0), (1.0, second)] {
|
||||
s.execute("prop.addKey", json!({"layer": solid, "path": "effects/#1/version", "time": time, "value": value})).unwrap();
|
||||
}
|
||||
let control = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": inner.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let path = format!("essential/{}", effectcraft_project::essential::match_id(control));
|
||||
s.execute("prop.select", json!({"layer": instance, "path": path})).unwrap();
|
||||
let file = std::env::temp_dir().join(format!("ec-cineon-keyed-nested-{}-{second}.ecpreset", std::process::id()));
|
||||
s.execute("anim.savePreset", json!({"path": file})).unwrap();
|
||||
let preset: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert_eq!(preset["properties"][0]["cineonVersion"], recorded, "second key {second}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A Gamma exposed through Essential Properties (directly, or again from a middle comp) is
|
||||
/// converted to the version of the Cineon Converter it drives when the preset was saved with
|
||||
/// another version, or before version 2. A control that also drives another property keeps the
|
||||
/// saved value.
|
||||
#[test]
|
||||
fn presets_convert_exposed_cineon_gamma_to_the_driven_version() {
|
||||
for nested in [false, true] {
|
||||
for mixed in [false, true] {
|
||||
let mut s = Session::default();
|
||||
s.execute("comp.new", json!({"name": "Inner", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let mut source = s.active_comp_id().unwrap();
|
||||
let solid = s.execute("layer.newSolid", json!({"width": 8, "height": 8})).unwrap()["layer"].as_u64().unwrap();
|
||||
apply(&mut s, solid, "Cineon Converter");
|
||||
let mut control = s.execute("essential.addProperty", json!({"layer": solid, "path": "effects/#1/gamma"})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
if mixed {
|
||||
s.execute("essential.linkProperty", json!({"control": control, "layer": solid, "path": "transform/opacity"})).unwrap();
|
||||
}
|
||||
if nested {
|
||||
s.execute("comp.new", json!({"name": "Middle", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let middle = s.active_comp_id().unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": source.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let path = format!("essential/{}", effectcraft_project::essential::match_id(control));
|
||||
control = s.execute("essential.addProperty", json!({"layer": instance, "path": path})).unwrap()["controls"][0].as_u64().unwrap();
|
||||
source = middle;
|
||||
}
|
||||
s.execute("comp.new", json!({"name": "Main", "width": 8, "height": 8, "frameRate": 30, "duration": 2})).unwrap();
|
||||
let instance = s.execute("layer.addItem", json!({"item": source.0})).unwrap()["layer"].as_u64().unwrap();
|
||||
let path = format!("essential/{}", effectcraft_project::essential::match_id(control));
|
||||
s.execute("essential.set", json!({"layer": instance, "control": control, "value": 2.2})).unwrap();
|
||||
s.execute("prop.select", json!({"layer": instance, "path": path})).unwrap();
|
||||
let file = std::env::temp_dir().join(format!("ec-cineon-exposed-preset-{}-{nested}-{mixed}.ecpreset", std::process::id()));
|
||||
s.execute("anim.savePreset", json!({"path": file})).unwrap();
|
||||
let preset: serde_json::Value = serde_json::from_slice(&std::fs::read(&file).unwrap()).unwrap();
|
||||
std::fs::remove_file(file).unwrap();
|
||||
assert_eq!(preset["properties"][0]["path"], json!(path));
|
||||
let recorded = if mixed { json!(null) } else { json!(2.0) };
|
||||
assert_eq!(preset["properties"][0]["cineonVersion"], recorded, "nested={nested}, mixed={mixed}");
|
||||
let mut as_version_1 = preset.clone();
|
||||
as_version_1["properties"][0]["cineonVersion"] = json!(1.0);
|
||||
let cases = [(preset.clone(), 2.2), (preset_before_cineon_version_2(&preset), 2.2 / 1.7), (as_version_1, 2.2 / 1.7)];
|
||||
for (i, (preset, converted)) in cases.into_iter().enumerate() {
|
||||
s.execute("essential.set", json!({"layer": instance, "control": control, "value": 0.5})).unwrap();
|
||||
let result = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [instance]})).unwrap();
|
||||
assert_eq!(result["skipped"], json!([]));
|
||||
let value = s.execute("prop.get", json!({"layer": instance, "path": path})).unwrap()["value"].as_f64().unwrap();
|
||||
let expected = if mixed { 2.2 } else { converted };
|
||||
assert!((value - expected).abs() < 1e-9, "nested={nested}, mixed={mixed}, case {i}: {value} != {expected}");
|
||||
}
|
||||
// With an expression, a Gamma saved on another version is skipped. It is the only
|
||||
// property in the preset, so nothing is applied and the command reports that.
|
||||
let mut with_expression = preset_before_cineon_version_2(&preset);
|
||||
with_expression["properties"][0]["expression"] = json!({"text": "1.7", "enabled": true});
|
||||
s.execute("essential.set", json!({"layer": instance, "control": control, "value": 0.5})).unwrap();
|
||||
let steps = s.history.undo.len();
|
||||
let result = s.execute("anim.applyPreset", json!({"preset": with_expression.clone(), "layers": [instance]}));
|
||||
if mixed {
|
||||
assert_eq!(result.unwrap()["skipped"], json!([]), "nested={nested}");
|
||||
} else {
|
||||
assert!(result.unwrap_err().to_string().contains("nothing in this preset fits"), "nested={nested}");
|
||||
assert_eq!(s.history.undo.len(), steps, "nested={nested}");
|
||||
// Beside a property that fits, the Gamma is listed as skipped.
|
||||
let mut with_opacity = with_expression;
|
||||
with_opacity["properties"].as_array_mut().unwrap().push(json!({"path": "transform/opacity", "value": KV::Scalar(80.0), "keys": []}));
|
||||
let result = s.execute("anim.applyPreset", json!({"preset": with_opacity, "layers": [instance]})).unwrap();
|
||||
assert_eq!((&result["applied"], &result["skipped"]), (&json!(1), &json!([path])), "nested={nested}");
|
||||
}
|
||||
let value = s.execute("prop.get", json!({"layer": instance, "path": path})).unwrap()["value"].as_f64().unwrap();
|
||||
assert!((value - if mixed { 2.2 } else { 0.5 }).abs() < 1e-9, "nested={nested}, mixed={mixed}: {value}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// #410: `effect.warning` reports what an OCIO effect can't render as asked (Effect Controls
|
||||
/// shows it under the effect's name): a transform of its custom config that passes colours
|
||||
/// through, a colour space the config lacks or a config it can't read; nothing for the
|
||||
|
||||
@@ -139,7 +139,7 @@ fn one_selected_property_saves_its_whole_animator() {
|
||||
let end = saved[0].prop("selectors/rangeSelector/end").unwrap().uid;
|
||||
let path = save(&mut s, "one-property", source, &[end]);
|
||||
let file: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
assert_eq!(file["effectcraftAnimationPreset"], 3);
|
||||
assert_eq!(file["effectcraftAnimationPreset"], 4);
|
||||
assert_eq!(file["properties"], json!([]));
|
||||
assert_eq!(file["textAnimators"].as_array().unwrap().len(), 1);
|
||||
let target = new_text(&mut s, "Target");
|
||||
@@ -338,6 +338,49 @@ fn an_older_preset_updates_animators_and_asks_to_be_saved_again_without_them() {
|
||||
assert_eq!(s.history.undo.len(), steps);
|
||||
}
|
||||
|
||||
/// A format 3 preset holds whole animators, and its Cineon Converter Gamma has no
|
||||
/// `cineonVersion`. Its animators are made on the layer and its Gamma lands as a version 1
|
||||
/// value. The same preset in format 4 keeps the Gamma's version 2. A loose animator path in a
|
||||
/// format 3 preset is not reported as saved by an earlier version.
|
||||
#[test]
|
||||
fn a_format_3_preset_applies_its_animators_and_a_version_1_cineon_gamma() {
|
||||
let mut s = session();
|
||||
let source = new_text(&mut s, "Source");
|
||||
let animator = fade_on(&mut s, source, "Fade In");
|
||||
let cineon = |s: &Session, layer: u64, id: &str| {
|
||||
let l = s.active_comp().unwrap().layer(LayerId(layer)).unwrap();
|
||||
l.effects().unwrap().groups().next().unwrap().prop(id).unwrap().clone()
|
||||
};
|
||||
s.execute("effect.apply", json!({"layers": [source], "effect": "Cineon Converter"})).unwrap();
|
||||
s.execute("prop.set", json!({"layer": source, "path": "effects/#1/gamma", "value": 1.3})).unwrap();
|
||||
let gamma = cineon(&s, source, "gamma").uid;
|
||||
let path = save(&mut s, "format-3", source, &[animator, gamma]);
|
||||
let format_4: Value = serde_json::from_str(&std::fs::read_to_string(&path).unwrap()).unwrap();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert_eq!(format_4["effectcraftAnimationPreset"], 4);
|
||||
assert_eq!(format_4["textAnimators"].as_array().unwrap().len(), 1);
|
||||
assert_eq!(format_4["properties"][0]["cineonVersion"], json!(2.0));
|
||||
let mut format_3 = format_4.clone();
|
||||
format_3["effectcraftAnimationPreset"] = json!(3);
|
||||
format_3["properties"][0].as_object_mut().unwrap().remove("cineonVersion");
|
||||
|
||||
for (preset, version) in [(format_3, 1.0), (format_4, 2.0)] {
|
||||
let target = new_text(&mut s, "Target");
|
||||
s.execute("effect.apply", json!({"layers": [target], "effect": "Cineon Converter"})).unwrap();
|
||||
assert_eq!(cineon(&s, target, "version").value, KV::Scalar(2.0));
|
||||
let r = s.execute("anim.applyPreset", json!({"preset": preset, "layers": [target]})).unwrap();
|
||||
assert_eq!((r["applied"].as_u64(), r["skipped"].clone()), (Some(2), json!([])), "version {version}");
|
||||
assert_eq!(names(&s, target), ["Fade In"], "version {version}");
|
||||
assert_eq!((cineon(&s, target, "gamma").value, cineon(&s, target, "version").value), (KV::Scalar(1.3), KV::Scalar(version)));
|
||||
}
|
||||
|
||||
let loose = json!({"effectcraftAnimationPreset": 3, "properties": [{"path": "text/animators/animator/properties/opacity", "value": KV::Scalar(40.0), "keys": []}], "effects": []});
|
||||
let fresh = new_text(&mut s, "Fresh");
|
||||
let message = s.execute("anim.applyPreset", json!({"preset": loose, "layers": [fresh]})).unwrap_err().to_string();
|
||||
assert!(message.contains("nothing in this preset fits the selected layer(s)"), "{message}");
|
||||
assert!(!message.contains("save it again"), "{message}");
|
||||
}
|
||||
|
||||
/// A malformed file, or a key that can't move to a later layer's playhead, is an error that
|
||||
/// changes nothing, on the layers before it too.
|
||||
#[test]
|
||||
|
||||
@@ -229,8 +229,11 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
|
||||
let rolloff = pr.f("highlightRolloff") as f32;
|
||||
let offset = |black: f32, white: f32| 10f32.powf((black - white) * 0.002 / 0.6);
|
||||
let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
|
||||
p.u[0] = [0, pr.e("conversionType"), 0, 0];
|
||||
p.f[0] = [offset(black, white), knee, white, pr.f("gamma") as f32];
|
||||
let version_2 = pr.f("version") >= 2.0;
|
||||
// Version 1 keeps the saved Gamma, where 1.7 gives linear output.
|
||||
let gamma = if version_2 { effectcraft_effects::cineon_gamma(pr) } else { pr.f("gamma") };
|
||||
p.u[0] = [0, pr.e("conversionType"), version_2 as u32, 0];
|
||||
p.f[0] = [offset(black, white), knee, white, gamma as f32];
|
||||
p.f[1] = [pr.f("internalBlackPoint") as f32, pr.f("internalWhitePoint") as f32, 0.0, 0.0];
|
||||
p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.7];
|
||||
}
|
||||
|
||||
@@ -258,10 +258,17 @@ fn fp2_vector(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
// 3 CC Overbrights, 4 Color Link (alpha), 5 Color Link (blend), 6 CC Composite.
|
||||
|
||||
// utility::Cineon::log_to_lin; c = (offset, knee, white, gamma), r = (internal black, white).
|
||||
fn fp2_log_to_lin(v: f32, c: vec4<f32>, r: vec2<f32>) -> f32 {
|
||||
// Version 1 takes the saved Gamma, where 1.7 gives linear output.
|
||||
fn fp2_log_to_lin(v: f32, c: vec4<f32>, r: vec2<f32>, version_2: bool) -> f32 {
|
||||
let code = v * 1023.0;
|
||||
var lin = (pow(10.0, (code - c.z) * 0.002 / 0.6) - c.x) / max(1.0 - c.x, 1e-6);
|
||||
lin = powz(max(lin, 0.0), 1.7 / max(c.w, 0.01));
|
||||
let exposure = pow(10.0, (code - c.z) * 0.002 / 0.6);
|
||||
var lin: f32;
|
||||
if (version_2) {
|
||||
let root_offset = pow(c.x, 1.0 / max(c.w, 0.001));
|
||||
lin = max((pow(exposure, 1.0 / max(c.w, 0.001)) - root_offset) / max(1.0 - root_offset, 1e-6), 0.0);
|
||||
} else {
|
||||
lin = powz(max((exposure - c.x) / max(1.0 - c.x, 1e-6), 0.0), 1.7 / max(c.w, 0.01));
|
||||
}
|
||||
let k = c.y;
|
||||
if (k < 1.0 && lin > k) {
|
||||
let w = 1.0 - k;
|
||||
@@ -271,7 +278,7 @@ fn fp2_log_to_lin(v: f32, c: vec4<f32>, r: vec2<f32>) -> f32 {
|
||||
}
|
||||
|
||||
// utility::Cineon::lin_to_log.
|
||||
fn fp2_lin_to_log(v: f32, c: vec4<f32>, r: vec2<f32>) -> f32 {
|
||||
fn fp2_lin_to_log(v: f32, c: vec4<f32>, r: vec2<f32>, version_2: bool) -> f32 {
|
||||
let d = r.y - r.x;
|
||||
var lin = (v - r.x) / select(d, 1e-6, abs(d) < 1e-6);
|
||||
let k = c.y;
|
||||
@@ -279,8 +286,14 @@ fn fp2_lin_to_log(v: f32, c: vec4<f32>, r: vec2<f32>) -> f32 {
|
||||
let w = 1.0 - k;
|
||||
lin = k - w * log(max(1.0 - (lin - k) / w, 1e-6));
|
||||
}
|
||||
lin = powz(max(lin, 0.0), max(c.w, 0.01) / 1.7);
|
||||
let code = c.z + log(max(lin * (1.0 - c.x) + c.x, 1e-9)) * 0.4342944819032518 * 0.6 / 0.002;
|
||||
var exposure: f32;
|
||||
if (version_2) {
|
||||
let root_offset = pow(c.x, 1.0 / max(c.w, 0.001));
|
||||
exposure = powz(max(lin, 0.0) * (1.0 - root_offset) + root_offset, max(c.w, 0.001));
|
||||
} else {
|
||||
exposure = powz(max(lin, 0.0), max(c.w, 0.01) / 1.7) * (1.0 - c.x) + c.x;
|
||||
}
|
||||
let code = c.z + log(max(exposure, 1e-9)) * 0.4342944819032518 * 0.6 / 0.002;
|
||||
return code / 1023.0;
|
||||
}
|
||||
|
||||
@@ -292,15 +305,25 @@ fn fp2_spow(v: f32, e: f32) -> f32 {
|
||||
fn fp2_cineon(v: f32) -> f32 {
|
||||
let c = P.f[0];
|
||||
let r = P.f[1].xy;
|
||||
let version_2 = P.u[0].z != 0u;
|
||||
switch P.u[0].y {
|
||||
case 0u: {
|
||||
return fp2_lin_to_log(v, c, r);
|
||||
return fp2_lin_to_log(v, c, r, version_2);
|
||||
}
|
||||
case 1u: {
|
||||
return fp2_log_to_lin(v, c, r);
|
||||
return fp2_log_to_lin(v, c, r, version_2);
|
||||
}
|
||||
default: {
|
||||
return fp2_lin_to_log(fp2_log_to_lin(v, c, r), P.f[2], vec2<f32>(0.0, 1.0));
|
||||
if (version_2) {
|
||||
// Default points keep the input code; custom points pass through Gamma 1 first.
|
||||
var code = v;
|
||||
if (c.x != P.f[2].x || c.z != 685.0 || r.x != 0.0 || r.y != 1.0) {
|
||||
let unit = vec4<f32>(c.xyz, 1.0);
|
||||
code = fp2_lin_to_log(fp2_log_to_lin(v, unit, r, true), P.f[2], vec2<f32>(0.0, 1.0), false);
|
||||
}
|
||||
return powz(max(code, 0.0), 1.0 / max(c.w, 0.001));
|
||||
}
|
||||
return fp2_lin_to_log(fp2_log_to_lin(v, c, r, false), P.f[2], vec2<f32>(0.0, 1.0), false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -314,7 +337,7 @@ fn fp2_point(@builtin(global_invocation_id) gid: vec3<u32>) {
|
||||
let px = textureLoad(src, p, 0);
|
||||
var o = px;
|
||||
switch P.u[0].x {
|
||||
// f[0], f[1], f[2]: see fp2_cineon; u[0].y = conversion type.
|
||||
// f[0], f[1], f[2]: see fp2_cineon; u[0].y = conversion type, z = version 2.
|
||||
case 0u: {
|
||||
if (px.w > 0.0) {
|
||||
let c = px.xyz / px.w;
|
||||
|
||||
@@ -179,7 +179,14 @@ fn composites_and_colour() {
|
||||
env_case("ec.color.colorlink", &[("sourceLayer", Value::Layer(Some(9))), ("sampleSource", e(4)), ("clip", n(5.0))]);
|
||||
effect_case("ec.utility.cineon", &[]);
|
||||
effect_case("ec.utility.cineon", &[("conversionType", e(0)), ("gamma", n(2.2))]);
|
||||
effect_case("ec.utility.cineon", &[("version", n(1.0)), ("gamma", n(2.2))]);
|
||||
effect_case("ec.utility.cineon", &[("version", n(1.0)), ("gamma", n(0.0)), ("conversionType", e(0))]);
|
||||
effect_case("ec.utility.cineon", &[("conversionType", e(2)), ("highlightRolloff", n(60.0)), ("tenBitWhitePoint", n(800.0))]);
|
||||
effect_case("ec.utility.cineon", &[("conversionType", e(1)), ("gamma", n(1.7)), ("highlightRolloff", n(0.0))]);
|
||||
effect_case("ec.utility.cineon", &[("conversionType", e(1)), ("gamma", n(2.2)), ("highlightRolloff", n(0.0))]);
|
||||
effect_case("ec.utility.cineon", &[("conversionType", e(2)), ("gamma", n(1.0)), ("highlightRolloff", n(0.0))]);
|
||||
effect_case("ec.utility.cineon", &[("conversionType", e(2)), ("gamma", n(1.7))]);
|
||||
env_case_allow("ec.utility.cineon", &[("version", n(1.0)), ("conversionType", e(2)), ("gamma", n(2.2))], 0.005);
|
||||
effect_case("ec.utility.hdrcompander", &[("gain", n(4.0)), ("gamma", n(2.0))]);
|
||||
effect_case("ec.utility.hdrcompander", &[("mode", e(1)), ("gain", n(3.0)), ("gamma", n(1.5))]);
|
||||
effect_case("ec.utility.hdrcompression", &[]);
|
||||
@@ -210,6 +217,31 @@ fn hdr_compression_over_range() {
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cineon_version_2_paths_match_cpu() {
|
||||
for vals in [
|
||||
&[("conversionType", e(2)), ("gamma", n(1.0)), ("highlightRolloff", n(0.0))][..],
|
||||
&[("conversionType", e(2)), ("gamma", n(1.0)), ("highlightRolloff", n(20.0))][..],
|
||||
&[("conversionType", e(2)), ("gamma", n(1.7)), ("highlightRolloff", n(20.0))][..],
|
||||
&[("conversionType", e(1)), ("gamma", n(2.2)), ("highlightRolloff", n(0.0))][..],
|
||||
] {
|
||||
env_case_allow("ec.utility.cineon", vals, 0.005);
|
||||
}
|
||||
let Some(g) = gpu() else { return };
|
||||
let spec = effectcraft_effects::find("ec.utility.cineon").unwrap();
|
||||
let mut params =
|
||||
effectcraft_effects::Params { values: spec.params.iter().map(|ps| (ps.id.to_string(), effectcraft_effects::default_value(ps, [1.0, 1.0]))).collect() };
|
||||
params.values.insert("gamma".into(), n(1.7));
|
||||
params.values.insert("highlightRolloff".into(), n(0.0));
|
||||
let mut img = effectcraft_raster::Image::new(1, 1);
|
||||
img.set(0, 0, [400.0 / 1023.0, 400.0 / 1023.0, 400.0 / 1023.0, 1.0]);
|
||||
let buf = Buf { img, offset: [0.0; 2], scale: 1.0 };
|
||||
let ctx = effectcraft_effects::EffectCtx { params: ¶ms, time: 0.0, layer_size: [1.0, 1.0], seed: 0, adjustment: false, env: EffectEnv::default() };
|
||||
let out = effectcraft_render::Accelerator::effects(g, &[effectcraft_render::FxStep { spec, ctx }], &buf, None).expect("the GPU runs the effect");
|
||||
let got = (out.img.get(0, 0)[0].clamp(0.0, 1.0) * 255.0).round() as i32;
|
||||
assert!((got - 57).abs() <= 1, "code 400: {got} vs 57");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stylize() {
|
||||
let _flips = allow_llvmpipe_boundary_flips();
|
||||
|
||||
@@ -172,12 +172,12 @@ impl CineonSettings {
|
||||
}
|
||||
}
|
||||
|
||||
/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter
|
||||
/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit
|
||||
/// Cineon log to linear for Interpret Footage ▸ Cineon Settings and version 1 Cineon Converter
|
||||
/// effects. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit
|
||||
/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to
|
||||
/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff
|
||||
/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes
|
||||
/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default
|
||||
/// a `scale` of 1 (Gamma 1 gives linear light) and a version 1 Cineon Converter passes 1.7 (its
|
||||
/// Gamma 1.7 gives linear light).
|
||||
pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 {
|
||||
// Kodak printing density: 0.002 density per code value, negative gamma 0.6.
|
||||
|
||||
@@ -121,6 +121,44 @@ their bottom-right quarter. Tracking data of effects that analyse footage (Warp
|
||||
Camera Tracker, Tracked Shape Matte) is left as it is: footage has a source rectangle, whose effect space
|
||||
did not change.
|
||||
|
||||
Cineon Converter has a hidden Version parameter (`version`). In version 2, Log to Linear raises
|
||||
the exposure to `1 / gamma` before subtracting the black offset and normalizing at the white
|
||||
point. Highlight Rolloff then limits highlights. Linear to Log reverses those operations. Log to
|
||||
Log raises the normalized code value to `1 / gamma` at the default black and white points. Custom
|
||||
points use a Gamma 1 conversion before that power. In version 1, Log to Linear raises the
|
||||
black-offset normalized value to `1.7 / gamma`, and Log to Log uses its original reference curve.
|
||||
A new instance is version 2 with
|
||||
Gamma 1.7. An instance saved before the parameter existed opens as version 1
|
||||
(`migrate::legacy_default`), with its Gamma, keyframes and expressions unchanged, and renders as
|
||||
it did. A whole Cineon Converter applied from an animation preset saved before the parameter
|
||||
existed is also version 1. Opening a project does not change any expression or the project schema.
|
||||
Reset keeps an instance's Version and sets Gamma to 1.7.
|
||||
|
||||
A project saved with the Version parameter opens in an earlier build, since its schema is still 2.
|
||||
That build keeps the Version parameter it does not know and reads every Gamma on version 1's
|
||||
scale. A version 1 instance renders there as it did before. A version 2 instance's Gamma is read
|
||||
there as a version 1 Gamma, so its default Gamma 1.7 renders as linear. It renders lighter in this
|
||||
build. Earlier builds apply format 4 animation presets the same way and ignore
|
||||
`cineonVersion`.
|
||||
|
||||
Animation presets are format 4 (`effectcraftAnimationPreset: 4`). A saved property that is a
|
||||
Cineon Converter's Gamma records that instance's version at the current time in `cineonVersion`.
|
||||
So does an Essential Properties value whose control drives only Cineon Converter Gammas of one
|
||||
version, directly or through nested comps. A keyframed Version counts there only when its keys
|
||||
that play hold one value without easing between them. A Version that an expression sets has no
|
||||
version to record. The Gamma properties of format 1 to 3 presets are version 1 Gammas. Applying a
|
||||
Gamma to a Cineon Converter on the layer sets that instance's Version to the recorded one. When
|
||||
that Version is keyframed, its keys stay and the Gamma is converted to the one version they hold;
|
||||
the Gamma is skipped when the keys hold several versions or an expression sets the Version. A
|
||||
preset that also holds the Version sets it from that property.
|
||||
Applying it to an Essential Properties value converts the value, its keyframes and their speeds to
|
||||
the version of the Gammas that the control drives (dividing by 1.7 from version 1 to version 2).
|
||||
That conversion aligns the linear points, but the two versions use different curves at other
|
||||
Gamma values. Interpret Footage Cineon Settings continues to apply Gamma after black-offset
|
||||
normalization, so its output agrees with version 2 Converter at Gamma 1.
|
||||
Expressions in a preset are applied as saved, and an Essential Properties Gamma with an enabled
|
||||
expression that was saved on another version is skipped.
|
||||
|
||||
## 4. Rendering
|
||||
|
||||
`Renderer::comp_frame(comp, t)` walks the layers bottom to top. For each visible layer it renders
|
||||
|
||||
Reference in new issue
Block a user