diff --git a/crates/effects/src/utility.rs b/crates/effects/src/utility.rs index 1d3b3ee4a..e41b42e99 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.7 / gamma` + /// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings + /// also uses with `1 / gamma`). 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, 1.7, self.gamma, self.rolloff); self.ib + (self.iw - self.ib) * lin } /// Linear → normalised code value. @@ -495,6 +489,87 @@ 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 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.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 * 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() { + 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); + } + + /// 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); diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs index fe04f882b..221b28602 100644 --- a/crates/engine/src/analysis/tracking.rs +++ b/crates/engine/src/analysis/tracking.rs @@ -197,7 +197,8 @@ pub(crate) struct Work { } /// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels). -/// Footage at its native size is used straight from the footage source (no copy). +/// Footage at its native size is used straight from the footage source (no copy unless its +/// Cineon Settings convert it). pub(crate) fn source_frame( r: &Renderer, project: &Project, @@ -215,7 +216,7 @@ pub(crate) fn source_frame( && img.width == f.width && img.height == f.height { - return Some((img, [0.0; 2])); + return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2])); } let buf = r.layer_source(&ctx, layer)?; if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 { diff --git a/crates/engine/src/cmd/file_more.rs b/crates/engine/src/cmd/file_more.rs index 0f9584a07..c5018ae7c 100644 --- a/crates/engine/src/cmd/file_more.rs +++ b/crates/engine/src/cmd/file_more.rs @@ -5,7 +5,7 @@ use std::collections::{BTreeMap, BTreeSet}; use effectcraft_color::Label; -use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; +use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits}; use effectcraft_time::{FrameRate, Tick}; use serde_json::{Value, json}; @@ -601,6 +601,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option, /// Start Timecode. start: Option, + /// Cineon Settings (DPX and Cineon footage). + cineon: CineonEdit, +} + +/// Interpret Footage ▸ Cineon Settings parsed from command parameters (every field optional). +#[derive(Clone, Debug, Default)] +struct CineonEdit { + /// Logarithmic Conversion. + log: Option, + units: Option, + /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`, + /// else the footage's own Units). + point_units: Option, + converted_black: Option, + converted_white: Option, + /// 10 Bit Black and White Points. + black: Option, + white: Option, + gamma: Option, + rolloff: Option, +} + +impl CineonEdit { + fn parse(p: &Value, cmd: &str) -> Result { + let units = |key: &str| -> Result> { + match str_p(p, key) { + None => Ok(None), + Some(u) => CineonUnits::parse(u).map(Some).ok_or_else(|| bad(cmd, format!("{key}: 10bit|8bit|float, not `{u}`"))), + } + }; + let num = |key: &str, lo: f64, hi: f64| -> Result> { + match p.get(key) { + None | Some(Value::Null) => Ok(None), + Some(v) => v + .as_f64() + .filter(|x| x.is_finite() && (lo..=hi).contains(x)) + .map(Some) + .ok_or_else(|| bad(cmd, format!("{key}: a number from {lo} to {hi}"))), + } + }; + let units_now = units("cineonUnits")?; + Ok(CineonEdit { + log: b_p(p, "logConversion"), + units: units_now, + point_units: units("pointUnits")?.or(units_now), + // Range-checked once their units are known (`settings_for`). + converted_black: num("convertedBlack", f64::MIN, f64::MAX)?, + converted_white: num("convertedWhite", f64::MIN, f64::MAX)?, + black: num("tenBitBlack", 0.0, 1023.0)?, + white: num("tenBitWhite", 0.0, 1023.0)?, + gamma: num("currentGamma", 0.1, 10.0)?, + rolloff: num("highlightRolloff", 0.0, 100.0)?, + }) + } + + fn is_empty(&self) -> bool { + self.log.is_none() + && self.units.is_none() + && self.converted_black.is_none() + && self.converted_white.is_none() + && self.black.is_none() + && self.white.is_none() + && self.gamma.is_none() + && self.rolloff.is_none() + } + + /// Footage `f`'s Cineon Settings after this edit; `None` when nothing changes them (no Cineon + /// parameters, or `f` is not a DPX or Cineon file). + fn settings_for(&self, f: &Footage, cmd: &str) -> Result> { + if self.is_empty() || !f.is_cineon_file() { + return Ok(None); + } + let mut c = f.cineon; + let units = self.point_units.unwrap_or(c.units); + let point = |v: f64| -> Result { + let ten = units.to_ten_bit(v); + let max = units.from_ten_bit(f64::from(CineonSettings::MAX)); + if (0.0..=f64::from(CineonSettings::MAX)).contains(&ten) { + Ok(ten as f32) + } else { + Err(bad(cmd, format!("converted points: 0 to {max} in {} units", units.label()))) + } + }; + if let Some(v) = self.converted_black { + c.converted_black = point(v)?; + } + if let Some(v) = self.converted_white { + c.converted_white = point(v)?; + } + if let Some(v) = self.log { + c.log_conversion = v; + } + if let Some(u) = self.units { + c.units = u; + } + if let Some(v) = self.black { + c.black = v as f32; + } + if let Some(v) = self.white { + c.white = v as f32; + } + if let Some(v) = self.gamma { + c.gamma = v as f32; + } + if let Some(v) = self.rolloff { + c.rolloff = v as f32; + } + if c.black >= c.white { + return Err(bad(cmd, "the 10 Bit Black Point must be below the 10 Bit White Point")); + } + Ok(Some(c)) + } +} + +/// Footage `f`'s Cineon Settings as commands report them (`null` for files they don't apply to). +fn cineon_json(f: &Footage) -> Value { + if !f.is_cineon_file() { + return Value::Null; + } + let c = &f.cineon; + json!({ + "logConversion": c.log_conversion, "units": c.units.id(), + "convertedBlack": c.units.from_ten_bit(f64::from(c.converted_black)), "convertedWhite": c.units.from_ten_bit(f64::from(c.converted_white)), + "tenBitBlack": c.black, "tenBitWhite": c.white, "currentGamma": c.gamma, "highlightRolloff": c.rolloff, + }) } /// Interpret Footage ▸ Start Timecode. @@ -694,9 +819,23 @@ impl Interpretation { v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?, )), }; + it.cineon = CineonEdit::parse(p, cmd)?; Ok(it) } + /// Check the Cineon Settings this interpretation gives each of `footage`: an error names a + /// value out of range, or Cineon parameters when none of the footage is DPX or Cineon. + pub(crate) fn check<'a>(&self, footage: impl IntoIterator, cmd: &str) -> Result<()> { + if self.cineon.is_empty() { + return Ok(()); + } + let mut any = false; + for f in footage { + any |= self.cineon.settings_for(f, cmd)?.is_some(); + } + if any { Ok(()) } else { Err(bad(cmd, "Cineon Settings apply to DPX and Cineon footage only")) } + } + pub(crate) fn apply(&self, f: &mut Footage, guessed: Option<(AlphaMode, [f32; 3])>) { let before = f.frame_rate; self.apply_settings(f, guessed); @@ -756,6 +895,10 @@ impl Interpretation { if let Some(v) = self.preserve_rgb { f.preserve_rgb = v; } + // Checked before the edit (`Interpretation::check`). + if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") { + f.cineon = c; + } // After the frame rate: timecode is read at the footage's rate. if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) { f.start_timecode = start; @@ -802,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result { return Err(bad("file.interpretFootage", "select footage")); } let it = Interpretation::parse(p, "file.interpretFootage")?; + let footage = items.iter().filter_map(|i| match s.project.item(*i).map(|x| &x.kind) { + Some(ItemKind::Footage(f)) => Some(f), + _ => None, + }); + it.check(footage, "file.interpretFootage")?; let guesses: Vec> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) { @@ -837,7 +985,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { "item": i.0, "alpha": format!("{:?}", f.alpha), "matteColor": f.premul_color, "invertAlpha": f.invert_alpha, "fields": f.fields.label(), "pixelAspect": f.pixel_aspect, "loop": f.loop_count, "frameRate": f.frame_rate.as_f64(), "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), - "frames": f.sequence_frames(), + "frames": f.sequence_frames(), "cineon": cineon_json(f), })), _ => None, }) @@ -868,6 +1016,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.linear_light = src.linear_light; f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; + if f.is_cineon_file() && src.is_cineon_file() { + f.cineon = src.cineon; + } if src.native_rate.is_some() { if f.native_rate.is_none() { f.native_rate = Some(f.frame_rate); @@ -1097,7 +1248,7 @@ pub fn specs() -> Vec { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"), - "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}", + "{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}", has_footage_selection, interpret ), diff --git a/crates/engine/src/cmd/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs index 1bd0d2236..26cc8e5c9 100644 --- a/crates/engine/src/cmd/footage_panel.rs +++ b/crates/engine/src/cmd/footage_panel.rs @@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time:: } } +/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings +/// applied. +pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option> { + s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f)) +} + fn source_time(s: &Session, p: &Value, item: ItemId) -> Option { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") { diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs index d15f93730..99650875c 100644 --- a/crates/engine/src/cmd/proxy.rs +++ b/crates/engine/src/cmd/proxy.rs @@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result { return Err(bad(C, "the selected items have no proxy")); } let it = super::file_more::Interpretation::parse(p, C)?; + it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?; let guesses: Vec<_> = items .iter() .map(|i| { @@ -229,7 +230,7 @@ pub fn specs() -> Vec { "Proxy...", ["File", "Interpret Footage"], None, - "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}", + "{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}", has_proxy, interpret_proxy ), diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs index 6f2d273ad..86127a510 100644 --- a/crates/engine/src/session/render.rs +++ b/crates/engine/src/session/render.rs @@ -96,7 +96,7 @@ impl Session { effectcraft_project::ItemKind::Footage(f) if f.has_video => { let f = f.clone(); Some(Box::new(move || { - let img = footage.frame(item, &f, Tick(0))?; + let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f); // Point-sample down to at most `max_side` on the long side. let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32; let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step)); diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs index 941a9a96b..40927b8b8 100644 --- a/crates/engine/src/tests/proxy.rs +++ b/crates/engine/src/tests/proxy.rs @@ -1,6 +1,6 @@ //! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and -//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through -//! a synthetic footage source. +//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings), +//! rendered through a synthetic footage source. use std::sync::Arc; @@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] { "matte_black.png" => [0.0, 0.5, 0.0, 0.5], "matte_white.png" => [0.5, 1.0, 0.5, 0.5], "half.png" => [1.0, 1.0, 1.0, 0.25], + // A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands + // them out (code / 1023). + "scan.dpx" | "plate.dpx" => { + let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0; + [v, v, v, 1.0] + } _ => { let _ = x; [1.0, 1.0, 1.0, 1.0] @@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) { match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") { "red16.png" => (16, 16), "blue8.png" => (8, 8), - "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4), + "lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4), _ => (10, 10), } } @@ -64,7 +70,12 @@ impl FootageSource for Synthetic { for y in 0..h { for x in 0..w { // Decoders hand out premultiplied pixels (straight file colour × alpha). - let p = file_px(&f.path, x, y); + let mut p = file_px(&f.path, x, y); + // A log scan sequence: code 95 on even frames, 685 on odd ones. + if f.path.ends_with("frames.dpx") { + let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0; + p = [v, v, v, 1.0]; + } img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]); } } @@ -76,7 +87,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result { let (w, h) = size_of(path); - let video = path.ends_with(".mov"); + let video = path.ends_with(".mov") || path.ends_with("frames.dpx"); Ok(Footage { path: path.into(), kind: if video { FootageKind::Video } else { FootageKind::Still }, @@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).preserve_rgb); } + +/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the +/// Current Gamma between the converted points (off, they show as stored); the converted points +/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX +/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings. +#[test] +fn cineon_settings_convert_dpx_code_values() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + let other = import(&mut s, "/m/plate.dpx"); + let png = import(&mut s, "/m/red16.png"); + let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0]; + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + // Off: the code values as stored. + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // The checked path (control channel, MCP) accepts every Cineon parameter the doc lists. + let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}"); + assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1)); + assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2)); + s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap(); + assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2)); + // Converted points, in 8-bit units. + let out = s + .execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685})) + .unwrap(); + assert_eq!(out["items"][0]["cineon"]["units"], "8bit"); + assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}"); + assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1)); + // The form sends the points in the units it showed them in (`pointUnits`) with a new Units. + s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap(); + let f = foot(&s, d); + assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Refused: a black point at or above the white point, values out of range, and footage + // that is not DPX or Cineon. Nothing changes. + let before = foot(&s, d).cineon; + for bad in [ + json!({"item": d.0, "tenBitBlack": 700}), + json!({"item": d.0, "tenBitWhite": 2000}), + json!({"item": d.0, "currentGamma": 0}), + json!({"item": d.0, "highlightRolloff": -1}), + json!({"item": d.0, "convertedWhite": 1.5}), + json!({"item": d.0, "cineonUnits": "12bit"}), + json!({"item": png.0, "logConversion": true}), + ] { + assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}"); + } + assert_eq!(foot(&s, d).cineon, before); + assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default()); + // With DPX and other footage selected, only the DPX file takes the settings. + s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap(); + assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion); + s.execute("edit.undo", json!({})).unwrap(); + assert!(!foot(&s, other).cineon.log_conversion); + // Remember / Apply Interpretation. + s.state.project_selection = vec![d]; + s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap(); + assert_eq!(foot(&s, other).cineon, before); + assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default()); + // Off again: as stored. + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap(); + assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2)); + // Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at + // Current Gamma 1 is 0.5, not the conversion of the blended code values. + let seq = import(&mut s, "/m/frames.dpx"); + s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap(); + s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap(); + let bc = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": seq.0})).unwrap(); + s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap(); + let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0]; + assert!(near(mid, 0.5), "frame-mixed log frames: {mid}"); + // A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in. + s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let inner = s.active_comp_id().unwrap(); + s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let outer = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": inner.0})).unwrap(); + s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap(); + let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0]; + assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1)); + s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap(); + assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1)); +} + +/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX +/// footage with its Cineon Settings, as a composition renders it. +#[test] +fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() { + let mut s = session(); + let d = import(&mut s, "/m/scan.dpx"); + s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap(); + let c = s.active_comp_id().unwrap(); + s.execute("layer.addItem", json!({"item": d.0})).unwrap(); + s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap(); + let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0; + let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0]; + assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}"); + let f = match &s.project.item(d).unwrap().kind { + ItemKind::Footage(f) => f.clone(), + _ => panic!("not footage"), + }; + let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0]; + assert!(near(panel, rendered), "Footage panel: {panel}"); + let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap(); + assert_eq!(w, 4); + let thumb = rgba[8] as f32 / 255.0; + assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}"); + let comp = s.project.comp(c).unwrap(); + let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default()); + let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap(); + assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]); +} diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs index 486bc25f7..336272d77 100644 --- a/crates/project/src/footage.rs +++ b/crates/project/src/footage.rs @@ -52,6 +52,142 @@ pub enum AlphaMode { Ignore, } +/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are +/// shown and entered. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum CineonUnits { + /// 10-bit code values (0–1023). + #[default] + TenBit, + /// 8-bit values (0–255). + EightBit, + /// Floating point (0–1). + Float, +} + +impl CineonUnits { + pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float]; + + /// The id commands use: `10bit`, `8bit` or `float`. + pub fn id(self) -> &'static str { + match self { + CineonUnits::TenBit => "10bit", + CineonUnits::EightBit => "8bit", + CineonUnits::Float => "float", + } + } + + pub fn label(self) -> &'static str { + match self { + CineonUnits::TenBit => "10-bit", + CineonUnits::EightBit => "8-bit", + CineonUnits::Float => "Float", + } + } + + pub fn parse(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '_', '-'], "").as_str() { + "10bit" | "10" => Some(CineonUnits::TenBit), + "8bit" | "8" => Some(CineonUnits::EightBit), + "float" | "32bit" => Some(CineonUnits::Float), + _ => None, + } + } + + /// A 10-bit value (0–1023) in these units. + pub fn from_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 255.0 / 1023.0, + CineonUnits::Float => v / 1023.0, + } + } + + /// A value in these units as a 10-bit value (0–1023). + pub fn to_ten_bit(self, v: f64) -> f64 { + match self { + CineonUnits::TenBit => v, + CineonUnits::EightBit => v * 1023.0 / 255.0, + CineonUnits::Float => v * 1023.0, + } + } +} + +/// Interpret Footage ▸ Cineon Settings of DPX and Cineon footage: how the file's logarithmic +/// (printing density) code values become the footage's pixels. Code values are counted in 10-bit +/// units whatever the file's bit depth. +#[derive(Clone, Copy, Debug, PartialEq, Serialize, Deserialize)] +#[serde(default)] +pub struct CineonSettings { + /// Logarithmic Conversion: convert the code values from log to the Current Gamma. When it + /// is off, the code values are shown as stored. + pub log_conversion: bool, + /// Converted Black Point: the level the 10 Bit Black Point becomes (10-bit units). + pub converted_black: f32, + /// Converted White Point: the level the 10 Bit White Point becomes (10-bit units). + pub converted_white: f32, + /// 10 Bit Black Point: the code value of minimum density. + pub black: f32, + /// 10 Bit White Point: the code value of maximum density. + pub white: f32, + /// Current Gamma: the gamma the converted values are encoded with (1: linear light). + pub gamma: f32, + /// Highlight Rolloff (0–100): values near and above the white point ramp off gradually + /// instead of clipping; 0 keeps over-range values. + pub rolloff: f32, + /// Units of the Converted Black and White Points. + pub units: CineonUnits, +} + +impl Default for CineonSettings { + fn default() -> Self { + CineonSettings { + log_conversion: false, + converted_black: 0.0, + converted_white: 1023.0, + black: 95.0, + white: 685.0, + gamma: 1.7, + rolloff: 20.0, + units: CineonUnits::TenBit, + } + } +} + +impl CineonSettings { + /// The highest 10-bit value. + pub const MAX: f32 = 1023.0; + + /// 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, 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 } + } +} + +/// 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 +/// `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(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; + lin = knee + w * (1.0 - (-(lin - knee) / w).exp()); + } + lin +} + #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String, @@ -98,6 +234,9 @@ pub struct Footage { /// colour profile, Interpret As Linear Light and the working space don't apply. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub preserve_rgb: bool, + /// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]). + #[serde(default, skip_serializing_if = "is_default")] + pub cineon: CineonSettings, /// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the /// project so expressions read it everywhere (and on the web). #[serde(default, skip_serializing_if = "Option::is_none")] @@ -149,6 +288,7 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false, + cineon: CineonSettings::default(), data: None, layer: None, page: 0, @@ -160,6 +300,17 @@ impl Default for Footage { } impl Footage { + /// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to). + pub fn is_cineon_file(&self) -> bool { + let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase(); + matches!(ext.as_str(), "dpx" | "cin") + } + + /// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon. + pub fn cineon_applies(&self) -> bool { + self.cineon.log_conversion && self.is_cineon_file() + } + /// Interpret As Linear Light applies (Preserve RGB passes the file's values through). pub fn linear_light_applies(&self) -> bool { self.linear_light && !self.preserve_rgb diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 8e1409c37..37584eec4 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, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer}; +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::{ diff --git a/crates/project/src/tests.rs b/crates/project/src/tests.rs index c0bdb012f..afd3d3f03 100644 --- a/crates/project/src/tests.rs +++ b/crates/project/src/tests.rs @@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(!p.comp_contains(c[n - 1], c[0])); } +/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted +/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and +/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects +/// saved without them read the defaults. +#[test] +fn cineon_settings_convert_log_code_values() { + use crate::{CineonSettings, CineonUnits, Footage}; + let code = |v: f32| v / 1023.0; + let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() }; + assert!(c.convert(code(95.0)).abs() < 1e-6, "black point"); + assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point"); + // Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7). + assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0))); + let linear = CineonSettings { gamma: 1.0, ..c }; + assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0))); + assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0))); + // The default rolloff (20) ramps the white point and above off, up to 1. + let rolled = CineonSettings { rolloff: 20.0, ..c }; + let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0))); + assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}"); + let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c }; + assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5); + assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5); + // Units. + assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9); + assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9); + assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit)); + assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u))); + // DPX and Cineon files only, and only with Logarithmic Conversion on. + let on = CineonSettings { log_conversion: true, ..Default::default() }; + let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() }; + assert!(dpx.cineon_applies()); + assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies()); + assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies()); + // Saved only when changed; read back, and absent in older projects. + let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap(); + assert!(plain.get("cineon").is_none(), "{plain}"); + let saved = serde_json::to_string(&dpx).unwrap(); + assert_eq!(serde_json::from_str::(&saved).unwrap().cineon, on); + assert_eq!(serde_json::from_value::(plain).unwrap().cineon, CineonSettings::default()); +} + /// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack. #[test] fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() { diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs index 3024dfc52..7d1929ecd 100644 --- a/crates/render/src/frames.rs +++ b/crates/render/src/frames.rs @@ -1,6 +1,7 @@ use super::footage::NoFootage; use super::opts::RenderOpts; -use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text}; +use rayon::prelude::*; /// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on. /// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even / @@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { out } +/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of +/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not +/// apply, the frame is returned unchanged. +pub fn cineon_frame(img: Arc, f: &Footage) -> Arc { + if !f.cineon_applies() { + return img; + } + let cs = f.cineon; + let mut out = (*img).clone(); + out.data.par_iter_mut().for_each(|p| { + let a = p[3]; + let c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }.map(|v| cs.convert(v)); + *p = [c[0] * a, c[1] * a, c[2] * a, a]; + }); + Arc::new(out) +} + /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels; linear light is encoded in `space` ([`color::Pipe::linear_footage_space`]). pub fn interpret_pixels(img: &Image, f: &Footage, space: effectcraft_color::ColorSpace) -> Image { diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 0c2f11e4c..dd5335623 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -48,8 +48,8 @@ mod pipeline; pub use accel::{Accelerator, Backend, FxStep, FxTarget}; pub use footage::{CancellableFootage, FloatCurve, FootageSource, NoFootage, is_vector_footage}; pub use frames::{ - adaptive_samples, blend_frames, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, render_frame, - roi_inside, + adaptive_samples, blend_frames, cineon_frame, content_bounds, extended_region, field_frame, frame_position, frame_size, interpret_pixels, kurbo_path, + render_frame, roi_inside, }; #[cfg(test)] use fx_host::FxHost; diff --git a/crates/render/src/pipeline.rs b/crates/render/src/pipeline.rs index 2d0114e31..a4927144f 100644 --- a/crates/render/src/pipeline.rs +++ b/crates/render/src/pipeline.rs @@ -1,6 +1,6 @@ use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget}; use super::footage::{FootageSource, is_vector_footage}; -use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; +use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside}; use super::fx_host::{FxHost, essential_overrides}; use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through}; use super::{ @@ -829,16 +829,17 @@ impl<'a> Renderer<'a> { if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) { let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den); let i = field_rate.frame_at(t).max(0); - let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?; + let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f); let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst; // Upper field = even lines (0, 2, …). let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 }; return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f)))); } // Interpretation is per pixel but not linear: it runs on the frame before resampling. - let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies())); + let per_pixel = f.invert_alpha || f.linear_light_applies(); + let size = size.filter(|_| !(per_pixel || f.cineon_applies())); let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?; - if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } + if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option> { @@ -856,6 +857,10 @@ impl<'a> Renderer<'a> { if f.alpha == effectcraft_project::AlphaMode::Premultiplied { f.premul_color.map(f32::to_bits).hash(&mut h); } + if f.cineon_applies() { + let c = &f.cineon; + [c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h); + } h.finish() }); if let (Some(c), Some(k)) = (self.cache, key) @@ -863,8 +868,9 @@ impl<'a> Renderer<'a> { { return Some(Arc::new(b.img.clone())); } - let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float); - let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float); + // Log scans are converted before they blend: the conversion is not linear. + let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f)); + let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f)); if let (Some(a), Some(b)) = (a, b) { let img = blend_frames(mode, &a, &b, w as f32); if let (Some(c), Some(k)) = (self.cache_to_keep(), key) { @@ -874,7 +880,7 @@ impl<'a> Renderer<'a> { } } } - self.footage.frame_with(item, f, t, size, float) + self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f)) } /// Source → masks, clamped/quantised to the project depth. diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs index 64c2ced12..ef6458475 100644 --- a/crates/ui-egui/src/panels/footage_panel.rs +++ b/crates/ui-egui/src/panels/footage_panel.rs @@ -68,7 +68,7 @@ pub fn show(app: &mut EffectcraftApp, ui: &mut egui::Ui, rect: Rect) { let cached: Option<((u64, i64, u64), egui::TextureHandle, [u32; 2])> = ctx.data(|d| d.get_temp(tid)); let tex = match cached { Some((k, tex, size)) if k == key => Some((tex, size)), - _ => app.session.footage.frame(view.item, f, view.time).map(|img| { + _ => effectcraft_engine::commands::footage_panel::frame(&app.session, view.item, f, view.time).map(|img| { let ci = egui::ColorImage::from_rgba_unmultiplied([img.width as usize, img.height as usize], &img.to_rgba8()); let tex = crate::frames::load_fitted(&ctx, "footage-panel", ci, egui::TextureOptions::LINEAR); ctx.data_mut(|d| d.insert_temp(tid, (key, tex.clone(), [img.width, img.height]))); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index e121d5d02..29364275b 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec } /// The Interpret Footage dialog for the selected footage (or its proxy), if there is one. -fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec)> { +fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec)> { let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) { (_, Some(px)) if proxy => Some(px.footage.clone()), (effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()), @@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; + // Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the + // footage's Units, and `pointUnits` reads them back in those units when Units changes. + if f.is_cineon_file() { + use effectcraft_engine::project::CineonUnits; + let c = f.cineon; + let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect(); + fields.extend([ + Field::note("cineonSettings", "Cineon Settings"), + Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion), + Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))), + Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))), + Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)), + Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)), + Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)), + Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)), + Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)), + ]); + if !base.is_object() { + *base = json!({}); + } + if let Some(m) = base.as_object_mut() { + m.insert("pointUnits".into(), json!(c.units.id())); + } + } // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in // After Effects), then Start Timecode. let mut timing = Vec::new(); @@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l))); let t = s.time(); let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t); - let base = p.clone(); + let mut base = p.clone(); let (title, fields): (String, Vec) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As. @@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], ), "file.interpretFootage" | "file.interpretProxy" - if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) => + if !has( + p, + &[ + "frameRate", + "alpha", + "loop", + "pixelAspect", + "colorProfile", + "fields", + "invertAlpha", + "matteColor", + "linearLight", + "startFrame", + "logConversion", + "cineonUnits", + "pointUnits", + "convertedBlack", + "convertedWhite", + "tenBitBlack", + "tenBitWhite", + "currentGamma", + "highlightRolloff", + ], + ) => { let proxy = id == "file.interpretProxy"; - let Some(form) = interpret_footage_form(s, proxy) else { return false }; + let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false }; form } "file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s), @@ -1074,4 +1121,83 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains(">(); + app.session.state.project_selection = vec![png]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app)); + app.session.state.project_selection = vec![dpx]; + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] { + assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app)); + } + // Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit). + let form = &mut app.dialog_state.form; + for f in form.fields.iter_mut() { + match (f.key.as_str(), &mut f.kind) { + ("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1, + ("logConversion", FieldKind::Bool(b)) => *b = true, + _ => {} + } + } + let params = form.params(); + assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}"); + app.session.execute("file.interpretFootage", params).unwrap(); + let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") }; + assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon); + assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon); + // Opened again, the point shows in 8-bit units. + assert!(open_form(&mut app, "file.interpretFootage", &json!({}))); + let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone()); + assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}"); + } } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index e10a242fa..50dff8aff 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | BMP | R | yes | — | good | | WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects | | Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import | -| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX | +| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX | | Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output | | SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) | | Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared | diff --git a/docs/footage.md b/docs/footage.md index f23bcdcc7..f7f1df21b 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -94,16 +94,21 @@ used, and layers that ran to the end of the footage keep doing so. | Fields, pixel aspect, loop | As in After Effects. | | **Assign Profile**, **Interpret As Linear Light** | The colour profile the file's values are in (the working space converts from it), and whether they are linear light. | | **Preserve RGB** | The file's values enter the composition unconverted: no colour profile, linear light or working-space conversion, and float files (OpenEXR, float TIFF) keep their linear values instead of being sRGB-encoded. Use it to convert chosen layers yourself with an OCIO effect, or to read data passes (depth, mist, normals) as numbers. | +| **Cineon Settings** (DPX and Cineon files) | With *Logarithmic Conversion* on, the file's log code values are converted to the *Current Gamma* (1.7 by default; 1 gives linear light): the *10 Bit Black Point* (95) becomes the *Converted Black Point* (0), and with *Highlight Rolloff* at 0 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) bends the top 20% of the converted range into a curve, as the Cineon Converter effect does: the white point then converts to a level below the Converted White Point, and brighter code values stay below it. At 0, a 32 bpc project keeps over-range values. *Units* shows the converted points as 10-bit, 8-bit or float values. With Logarithmic Conversion off (the default), the code values are shown as stored. | Settings ▸ Import ▸ **Report Missing Frames** (on by default) lists the missing frame numbers when a sequence imports; Interpret Footage lists them too. **File ▸ Interpret Footage ▸ Remember Interpretation / Apply Interpretation** copy the alpha, -fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too, and Cineon Settings between +DPX and Cineon files) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", "startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": -false` goes back to Use Source File Timecode). +false` goes back to Use Source File Timecode). Cineon Settings: `{"logConversion": true, +"tenBitBlack": 95, "tenBitWhite": 685, "currentGamma": 1.7, "highlightRolloff": 20, +"cineonUnits": "8bit", "convertedBlack": 0, "convertedWhite": 255}` (converted points in +`cineonUnits`, else the footage's Units). ## Multi-layer OpenEXR diff --git a/docs/gaps.md b/docs/gaps.md index 29ef1c99d..bf69ab859 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest. | 14 | **Object Matte** (26.2, disk cache in 26.5) | First slice (#783): the Object Matte effect (Effect ▸ Matte) and `objectMatte.apply` turn a point (and optional box) on a reference frame into a matte and follow the object up to 600 frames each way, or segment every frame on its own; Roto Brush's engine, cache and model (MobileSAM from Settings ▸ Roto Brush, classical graph cut without it). Since then: the Object Matte tool in the viewer (click / drag a box, hover crosshair), the box as the model's box prompt, background tracking with progress and cancel, Freeze, and segmentations kept in the disk cache (26.5). Missing: a comparison with After Effects ([plan](fidelity/object-matte.md)) | The new one-click roto | 10–20 h | G7 · [target-app-parity.md](target-app-parity.md) | | 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it (answered: CUDA / NVENC need Nvidia's proprietary SDK through FFI, which the no-`unsafe` rule forbids); open PR #819 adds hardware decoding via `filmcraft-platform`; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) | | 16 | **Localization depth** | Menus in 11 languages (ja, zh-hans, zh-hant, uk, es, pt-br, ru, fr, de, cs, ka: 628 rows each); panels in 7 (ja, zh-hans, zh-hant, uk, pt-br, cs, ka); effect names translated in Simplified Chinese and Georgian (307, plus the generated Effect menu), their ~1,650 parameter names untranslated; Korean and Italian (After Effects ships them) absent; Hangul probe added (#599 closed) | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) | -| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | +| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored unless Interpret Footage ▸ Cineon Settings converts it; MXF OP1a / OP-Atom since #767; FilmCraft has no AVI demuxer); #431 and #324 closed | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) | | 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) | | 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) | | 20 | **Multi-monitor and video output** | #486: on Windows, macOS and X11 every floating panel is an OS window of its own (Undock Panel, a drag out of the main window, panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so can go onto another screen (on Wayland, the web and in embedded viewports floating panels stay in the app window, since Wayland reports no window positions); tabs and groups drag between windows, shortcuts work in them, and the current and named workspaces restore their places; still missing: Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |