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_rgb
diff --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) |
