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.
This commit is contained in:
dotCooCoo
2026-10-29 02:00:35 -07:00
parent ab3d264541
commit 0acc2a4677
5 changed files with 61 additions and 26 deletions
+31 -12
View File
@@ -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);
+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.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];
+4 -2
View File
@@ -257,11 +257,13 @@ 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.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<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);
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;
}
+24 -10
View File
@@ -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,
+1 -1
View File
@@ -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::{