Browse / github.com/storytold/effectcraft / patch
Interpret Footage: Cineon Settings convert DPX and Cineon log scans
Interpret Footage: Cineon Settings convert DPX and Cineon log scans ## What After Effects' Interpret Footage has a Cineon Settings dialog for DPX and Cineon files (Color Management tab): Converted Black and White Points, 10 Bit Black and White Points, Current Gamma, Highlight Rolloff, Logarithmic Conversion and Units ([Preparing and importing still images, "Cineon and DPX footage items"](https://web.archive.org/web/20151022092630/https://helpx.adobe.com/after-effects/using/preparing-importing-still-images.html#cineon_and_dpx_footage_items)). EffectCraft imported both formats (#756) but always showed the log code values as stored (`docs/file-format-parity.md`:
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- dotCooCoo (imported from pull request #2300, open)
- Base
dd12ed8f9c341a6081e08c8fd840aa3bb4325537- Size
- +658 −30 in 17 files
- Review
- pending
- Shared
- 2026-10-28
- Id
fa7ffc1660a19b9f38fcbf37
How it went
No other operator has reported using it yet.
Diff
raw · crates/engine/src/analysis/tracking.rs crates/engine/src/cmd/file_more.rs crates/engine/src/cmd/footage_panel.rs crates/engine/src/cmd/proxy.rs crates/engine/src/session/render.rs crates/engine/src/tests/proxy.rs crates/project/src/footage.rs crates/project/src/lib.rs crates/project/src/tests.rs crates/render/src/frames.rs crates/render/src/lib.rs crates/render/src/pipeline.rs crates/ui-egui/src/panels/footage_panel.rs crates/ui-egui/src/panels/forms.rs docs/file-format-parity.md docs/footage.md docs/gaps.md
@@ -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 {@@ -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}; @@ -563,6 +563,131 @@ pub(crate) struct Interpretation { preserve_rgb: Option<bool>, /// Start Timecode. start: Option<StartTimecode>,+ /// 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<bool>,+ units: Option<CineonUnits>,+ /// The units the Converted Black and White Points are given in (`pointUnits`, else `cineonUnits`,+ /// else the footage's own Units).+ point_units: Option<CineonUnits>,+ converted_black: Option<f64>,+ converted_white: Option<f64>,+ /// 10 Bit Black and White Points.+ black: Option<f64>,+ white: Option<f64>,+ gamma: Option<f64>,+ rolloff: Option<f64>,+}++impl CineonEdit {+ fn parse(p: &Value, cmd: &str) -> Result<CineonEdit> {+ let units = |key: &str| -> Result<Option<CineonUnits>> {+ 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<Option<f64>> {+ 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<Option<CineonSettings>> {+ 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<f32> {+ 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.@@ -656,9 +781,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<Item = &'a Footage>, 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);@@ -718,6 +857,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;@@ -764,6 +907,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> { 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<Option<(AlphaMode, [f32; 3])>> = items .iter() .map(|i| match (it.guess, s.project.item(*i).map(|x| &x.kind)) {@@ -799,7 +947,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result<Value> { "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, })@@ -830,6 +978,9 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result<Value> { 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);@@ -1035,7 +1186,7 @@ pub fn specs() -> Vec<CommandSpec> { "Main...", ["File", "Interpret Footage"], Some("Cmd+Alt+G"),- "{items?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, 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?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?, 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 ),@@ -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<std::sync::Arc<effectcraft_raster::Image>> {+ s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))+}+ fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> { let (_, fr) = source_timing(s, item); if let Some(t) = f_p(p, "time") {@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> { 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<CommandSpec> { "Proxy...", ["File", "Interpret Footage"], None,- "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?}",+ "{items?|item?, alpha?, matteColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, logConversion? (Cineon Settings of DPX and Cineon proxies, as file.interpretFootage), cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff?}", has_proxy, interpret_proxy ),@@ -56,7 +56,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));@@ -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; @@ -35,6 +35,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]@@ -46,19 +52,24 @@ 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), } } impl FootageSource for Synthetic {- fn frame(&self, _: ItemId, f: &Footage, _t: Tick) -> Option<Arc<Image>> {+ fn frame(&self, _: ItemId, f: &Footage, t: Tick) -> Option<Arc<Image>> { let (w, h) = size_of(&f.path); let mut img = Image::new(w, h); 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]]); } }@@ -70,7 +81,7 @@ struct Probe; impl crate::Importer for Probe { fn probe(&self, path: &str) -> Result<Footage, String> { 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 },@@ -281,3 +292,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]);+}@@ -52,6 +52,130 @@ 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<CineonUnits> {+ 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 {+ // 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 out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX;+ if out.is_finite() { out } else { 0.0 }+ }+}+ #[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] pub struct Footage { pub path: String,@@ -98,6 +222,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")]@@ -145,6 +272,7 @@ impl Default for Footage { invert_alpha: false, linear_light: false, preserve_rgb: false,+ cineon: CineonSettings::default(), data: None, layer: None, page: 0,@@ -155,6 +283,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@@ -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}; pub use item::{Item, ItemId, ItemKind, LayerId}; pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte}; pub use settings::{@@ -290,3 +290,46 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() { assert!(p.comp_contains(c[0], c[n - 1])); 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::<Footage>(&saved).unwrap().cineon, on);+ assert_eq!(serde_json::from_value::<Footage>(plain).unwrap().cineon, CineonSettings::default());+}@@ -33,6 +33,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<Image>, f: &Footage) -> Arc<Image> {+ 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 {@@ -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;@@ -604,7 +604,7 @@ impl<'a> Renderer<'a> { if let Some(pf) = self.proxy_for(*item) && let Some(nc) = self.project.comp(*item) {- let img = self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?;+ let img = cineon_frame(self.footage.frame_with(*item, pf, lt, None, self.pipe.float_encoding())?, pf); return Some(self.footage_buf(&img, pf, nc.width, nc.height)); } // Essential Properties overrides render the nested comp with this instance's@@ -715,16 +715,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<Arc<Image>> {@@ -738,6 +739,10 @@ impl<'a> Renderer<'a> { use std::hash::{Hash, Hasher}; let mut h = std::collections::hash_map::DefaultHasher::new(); (0x00f8_a3e5_u64, item, i, (w as f32).to_bits(), mode as u8, f.frame_rate.num, f.frame_rate.den, &f.path, float).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)@@ -745,8 +750,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) {@@ -756,7 +762,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.@@ -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])));@@ -213,7 +213,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<Field>) = match id { // Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped // files start at Settings ▸ Import ▸ Default Drag Import As.@@ -422,7 +422,30 @@ 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 f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {@@ -477,6 +500,30 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { 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();@@ -1058,4 +1105,83 @@ mod tests { assert_eq!(r["path"], path.as_str()); assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml")); }++ #[test]+ fn interpretation_menu_applies_explicit_cineon_parameters_without_a_dialog() {+ use effectcraft_engine::project::{Footage, FootageKind, ItemKind, Proxy};+ for command in ["file.interpretFootage", "file.interpretProxy"] {+ let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());+ let footage = Footage { path: "/m/scan.dpx".into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() };+ let project = std::sync::Arc::make_mut(&mut app.session.project);+ let item = project.add_item("scan.dpx", Default::default(), None, ItemKind::Footage(footage.clone()));+ project.item_mut(item).unwrap().proxy = Some(Box::new(Proxy { footage, enabled: true }));+ app.session.state.project_selection = vec![item];+ let ctx = egui::Context::default();+ for params in [+ json!({"logConversion": true}),+ json!({"cineonUnits": "10bit"}),+ json!({"pointUnits": "10bit"}),+ json!({"convertedBlack": 10.0}),+ json!({"convertedWhite": 900.0}),+ json!({"tenBitBlack": 90.0}),+ json!({"tenBitWhite": 690.0}),+ json!({"currentGamma": 2.2}),+ json!({"highlightRolloff": 30.0}),+ ] {+ crate::menus::invoke(&mut app, &ctx, command, params.clone()).unwrap();+ assert!(app.dialog.is_none(), "{command}: {params}");+ }+ let item = app.session.project.item(item).unwrap();+ let footage = if command == "file.interpretProxy" {+ &item.proxy.as_ref().unwrap().footage+ } else {+ let ItemKind::Footage(footage) = &item.kind else { panic!("not footage") };+ footage+ };+ assert!(footage.cineon.log_conversion);+ assert_eq!(footage.cineon.gamma, 2.2);+ assert_eq!(footage.cineon.converted_white, 900.0);+ }+ }++ /// Interpret Footage shows Cineon Settings for DPX footage only. The converted points show in+ /// the footage's Units, and choosing other Units reads the shown values in the old ones.+ #[test]+ fn interpret_footage_shows_cineon_settings_for_dpx() {+ use effectcraft_engine::project::{CineonUnits, Footage, FootageKind, ItemKind};+ let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());+ let still =+ |path: &str| ItemKind::Footage(Footage { path: path.into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() });+ let project = std::sync::Arc::make_mut(&mut app.session.project);+ let dpx = project.add_item("scan.dpx", Default::default(), None, still("/m/scan.dpx"));+ let png = project.add_item("still.png", Default::default(), None, still("/m/still.png"));+ let keys = |app: &EffectcraftApp| app.dialog_state.form.fields.iter().map(|f| f.key.clone()).collect::<Vec<_>>();+ 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:?}");+ } }@@ -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) | none | good for import (#745); no Targa output |-| 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 |@@ -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 @@ -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) |