collective

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

diff --git a/crates/engine/src/analysis/tracking.rs b/crates/engine/src/analysis/tracking.rs--- 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--- 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}; @@ -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         ),diff --git a/crates/engine/src/cmd/footage_panel.rs b/crates/engine/src/cmd/footage_panel.rs--- 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<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") {diff --git a/crates/engine/src/cmd/proxy.rs b/crates/engine/src/cmd/proxy.rs--- 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<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         ),diff --git a/crates/engine/src/session/render.rs b/crates/engine/src/session/render.rs--- a/crates/engine/src/session/render.rs+++ b/crates/engine/src/session/render.rs@@ -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));diff --git a/crates/engine/src/tests/proxy.rs b/crates/engine/src/tests/proxy.rs--- 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; @@ -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]);+}diff --git a/crates/project/src/footage.rs b/crates/project/src/footage.rs--- a/crates/project/src/footage.rs+++ b/crates/project/src/footage.rs@@ -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_rgbdiff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs--- 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}; 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--- a/crates/project/src/tests.rs+++ b/crates/project/src/tests.rs@@ -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());+}diff --git a/crates/render/src/frames.rs b/crates/render/src/frames.rs--- a/crates/render/src/frames.rs+++ b/crates/render/src/frames.rs@@ -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 {diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs--- 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--- a/crates/render/src/pipeline.rs+++ b/crates/render/src/pipeline.rs@@ -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.diff --git a/crates/ui-egui/src/panels/footage_panel.rs b/crates/ui-egui/src/panels/footage_panel.rs--- 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--- a/crates/ui-egui/src/panels/forms.rs+++ b/crates/ui-egui/src/panels/forms.rs@@ -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:?}");+    } }diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- 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) | 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 |diff --git a/docs/footage.md b/docs/footage.md--- 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--- 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) |