From 0acc2a4677e0e3744906ec91d0b02fe8e87a7b1d Mon Sep 17 00:00:00 2001 From: dotCooCoo Date: Thu, 29 Oct 2026 02:00:35 -0700 Subject: [PATCH] Cineon: Interpret Footage and the Cineon Converter share one log-to-linear function effectcraft_project::cineon_log_to_lin does the log-to-linear conversion for Interpret Footage > Cineon Settings and the Cineon Converter's Log to Linear. Both raise the result to 1 / Gamma, so Gamma 1 gives linear light and the same Gamma gives the same values in both. The converter's Linear to Log, its Log to Log reference and the GPU shader use the same Gamma scale, with a smallest Gamma of 0.01 / 1.7 (CINEON_MIN_GAMMA). A test runs the converter at Gamma 1.7 (the default), 1 and 2.4 and checks it against Cineon Settings at each code value. --- crates/effects/src/utility.rs | 43 +++++++++++++++++++-------- crates/gpu/src/fx_pixel2.rs | 2 +- crates/gpu/src/shaders/fx_pixel2.wgsl | 6 ++-- crates/project/src/footage.rs | 34 ++++++++++++++------- crates/project/src/lib.rs | 2 +- 5 files changed, 61 insertions(+), 26 deletions(-) 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::{