diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..1f2ddeb7b 100644 --- a/crates/effects/src/utility.rs +++ b/crates/effects/src/utility.rs @@ -100,17 +100,11 @@ impl Cineon { fn knee(&self) -> f32 { 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99) } - /// Normalised code value (code / 1023) → linear. + /// Normalized code value (code / 1023) → linear, raised to `1 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses). pub fn log_to_lin(&self, v: f32) -> f32 { - let off = self.offset(); - let code = v * 1023.0; - let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6); - lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01)); - let k = self.knee(); - if k < 1.0 && lin > k { - let w = 1.0 - k; - lin = k + w * (1.0 - (-(lin - k) / w).exp()); - } + let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -123,7 +117,7 @@ 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); + lin = lin.max(0.0).powf(self.gamma.max(effectcraft_project::CINEON_MIN_GAMMA)); let code = self.white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002; code / 1023.0 } @@ -138,7 +132,7 @@ fn cineon(ctx: &EffectCtx, mut b: Buf) -> Buf { gamma: ctx.params.f("gamma") as f32, rolloff: ctx.params.f("highlightRolloff") as f32, }; - 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.0, rolloff: c.rolloff }; let kind = ctx.params.e("conversionType"); b.img.map_straight(|v| { v.map(|x| match kind { @@ -495,6 +489,31 @@ mod tests { 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, Gamma and Highlight Rolloff: at the default Gamma (1.7) and at + /// Gamma 1, which gives linear light (code 400 is 0.10251). + #[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.7, 20.0), (1.0, 0.0), (2.4, 50.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)), ("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() { + let footage = settings.convert(code / 1023.0); + let effect = out.img.data[x][0]; + assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}"); + } + } + let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() }; + assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4); + } + #[test] fn hdr_compression_behaviour() { let mut img = Image::new(2, 1); diff --git a/crates/gpu/src/fx_pixel2.rs b/crates/gpu/src/fx_pixel2.rs index acba936d0..6e3bc4ab0 100644 --- a/crates/gpu/src/fx_pixel2.rs +++ b/crates/gpu/src/fx_pixel2.rs @@ -228,7 +228,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option { p.u[0] = [0, pr.e("conversionType"), 0, 0]; p.f[0] = [offset(black, white), knee, white, pr.f("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]; + p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.0]; } "ec.utility.hdrcompander" => { p.u[0] = [1, (pr.e("mode") == 1) as u32, 0, 0]; diff --git a/crates/gpu/src/shaders/fx_pixel2.wgsl b/crates/gpu/src/shaders/fx_pixel2.wgsl index a63a91fed..2b4dc0577 100644 --- a/crates/gpu/src/shaders/fx_pixel2.wgsl +++ b/crates/gpu/src/shaders/fx_pixel2.wgsl @@ -257,11 +257,13 @@ fn fp2_vector(@builtin(global_invocation_id) gid: vec3) { // u[0].x = effect: 0 Cineon Converter, 1 HDR Compander, 2 HDR Highlight Compression, // 3 CC Overbrights, 4 Color Link (alpha), 5 Color Link (blend), 6 CC Composite. +const FP2_CINEON_MIN_GAMMA: f32 = 0.01 / 1.7; + // utility::Cineon::log_to_lin; c = (offset, knee, white, gamma), r = (internal black, white). fn fp2_log_to_lin(v: f32, c: vec4, r: vec2) -> 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)); + lin = powz(max(lin, 0.0), 1.0 / max(c.w, FP2_CINEON_MIN_GAMMA)); let k = c.y; if (k < 1.0 && lin > k) { let w = 1.0 - k; @@ -279,7 +281,7 @@ fn fp2_lin_to_log(v: f32, c: vec4, r: vec2) -> 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); + lin = powz(max(lin, 0.0), max(c.w, FP2_CINEON_MIN_GAMMA)); let code = c.z + log(max(lin * (1.0 - c.x) + c.x, 1e-9)) * 0.4342944819032518 * 0.6 / 0.002; return code / 1023.0; } diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index f1e894809..e26d77417 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -161,21 +161,35 @@ impl CineonSettings { /// A code value (0–1 of the 10-bit range) converted from log to the Current Gamma and the /// converted black and white levels (0–1; above 1 for over-range highlights). pub fn convert(&self, v: f32) -> f32 { - // Kodak printing density: 0.002 density per code value, negative gamma 0.6. - let exposure = |code: f32| 10f32.powf((code - self.white) * 0.002 / 0.6); - let offset = exposure(self.black); - let mut lin = ((exposure(v * Self::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); - lin = lin.powf(1.0 / self.gamma.max(0.01)); - let knee = 1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99); - if knee < 1.0 && lin > knee { - let w = 1.0 - knee; - lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); - } + let lin = cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff); let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX; if out.is_finite() { out } else { 0.0 } } } +/// The smallest Gamma [`cineon_log_to_lin`] and the Cineon Converter's Linear to Log use. +pub const CINEON_MIN_GAMMA: f32 = 0.01 / 1.7; + +/// 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 +/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to +/// `1 / gamma` (Gamma 1 gives linear light), with values above `1 - rolloff / 100` ramped off +/// toward 1 (Highlight Rolloff 0–100; 0 keeps over-range values). A Gamma below +/// [`CINEON_MIN_GAMMA`] is raised to it. +pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, gamma: f32, rolloff: f32) -> f32 { + // Kodak printing density: 0.002 density per code value, negative gamma 0.6. + let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6); + let offset = exposure(black); + let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0); + lin = lin.powf(1.0 / gamma.max(CINEON_MIN_GAMMA)); + let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99); + if knee < 1.0 && lin > knee { + let w = 1.0 - knee; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index a59a2f40a..52f55fcac 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -36,7 +36,7 @@ mod layer; mod settings; pub use comp::{Comp, Guide, Renderer, Resolution}; -pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +pub use footage::{AlphaMode, CINEON_MIN_GAMMA, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{