Cineon: the Cineon Converter keeps its Gamma scale

cineon_log_to_lin takes the exponent scale from its caller. Interpret
Footage > Cineon Settings passes 1 (Gamma 1 gives linear light) and the
Cineon Converter passes 1.7, as before, so the converter's output and
its default Gamma 1.7 are unchanged. The converter's Linear to Log, Log
to Log reference and GPU shader return to their earlier code.

A test checks the converter at its default Gamma and at 2.4, for all
three conversion types, against the earlier formula.
This commit is contained in:
dotCooCoo
2026-10-29 06:46:51 -07:00
parent 0acc2a4677
commit 2bef8aa669
5 changed files with 76 additions and 24 deletions
+65 -9
View File
@@ -100,11 +100,11 @@ 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 / gamma`
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
/// also uses).
/// also uses with `1 / gamma`).
pub fn log_to_lin(&self, v: f32) -> f32 {
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff);
let lin = 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.
@@ -117,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(effectcraft_project::CINEON_MIN_GAMMA));
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;
code / 1023.0
}
@@ -132,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.0, rolloff: c.rolloff };
let std = Cineon { black: 95.0, white: 685.0, ib: 0.0, iw: 1.0, gamma: 1.7, rolloff: c.rolloff };
let kind = ctx.params.e("conversionType");
b.img.map_straight(|v| {
v.map(|x| match kind {
@@ -490,18 +490,19 @@ mod tests {
}
/// 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).
/// 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).
#[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)] {
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.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 vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("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() {
@@ -514,6 +515,61 @@ 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.
#[test]
fn cineon_converter_output_is_unchanged() {
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
lin
}
fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = v;
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
}
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}");
}
}
}
}
#[test]
fn hdr_compression_behaviour() {
let mut img = Image::new(2, 1);
+1 -1
View File
@@ -228,7 +228,7 @@ fn point(e: &mut Enc, id: &str, ctx: &EffectCtx, b: GBuf) -> Option<GBuf> {
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.0];
p.f[2] = [offset(95.0, 685.0), knee, 685.0, 1.7];
}
"ec.utility.hdrcompander" => {
p.u[0] = [1, (pr.e("mode") == 1) as u32, 0, 0];
+2 -4
View File
@@ -257,13 +257,11 @@ fn fp2_vector(@builtin(global_invocation_id) gid: vec3<u32>) {
// 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<f32>, r: vec2<f32>) -> 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.0 / max(c.w, FP2_CINEON_MIN_GAMMA));
lin = powz(max(lin, 0.0), 1.7 / max(c.w, 0.01));
let k = c.y;
if (k < 1.0 && lin > k) {
let w = 1.0 - k;
@@ -281,7 +279,7 @@ 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, FP2_CINEON_MIN_GAMMA));
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;
return code / 1023.0;
}
+7 -9
View File
@@ -161,27 +161,25 @@ 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 {
let lin = cineon_log_to_lin(v, self.black, self.white, self.gamma, self.rolloff);
let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, 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 {
/// `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
/// 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.
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));
lin = lin.powf(scale / gamma.max(0.01));
let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if knee < 1.0 && lin > knee {
let w = 1.0 - knee;
+1 -1
View File
@@ -36,7 +36,7 @@ mod layer;
mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
pub use footage::{AlphaMode, CINEON_MIN_GAMMA, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin};
pub use footage::{AlphaMode, 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::{