Merge pull request #2300 from dotCooCoo/feat/cineon-settings

Interpret Footage: Cineon Settings convert DPX and Cineon log scans
This commit is contained in:
BFlat
2026-10-30 07:05:36 +07:00
committed by GitHub
18 changed files with 758 additions and 42 deletions
+85 -10
View File
@@ -100,17 +100,11 @@ impl Cineon {
fn knee(&self) -> f32 {
1.0 - (self.rolloff / 100.0).clamp(0.0, 0.99)
}
/// Normalised code value (code / 1023) → linear.
/// Normalized code value (code / 1023) → linear, raised to `1.7 / gamma`
/// ([`effectcraft_project::cineon_log_to_lin`], which Interpret Footage ▸ Cineon Settings
/// also uses with `1 / gamma`).
pub fn log_to_lin(&self, v: f32) -> f32 {
let off = self.offset();
let code = v * 1023.0;
let mut lin = (10f32.powf((code - self.white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / self.gamma.max(0.01));
let k = self.knee();
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
let lin = effectcraft_project::cineon_log_to_lin(v, self.black, self.white, 1.7, self.gamma, self.rolloff);
self.ib + (self.iw - self.ib) * lin
}
/// Linear → normalised code value.
@@ -495,6 +489,87 @@ mod tests {
assert!(c.log_to_lin(95.0 / 1023.0).abs() < 1e-4);
}
/// Interpret Footage ▸ Cineon Settings and the Cineon Converter's Log to Linear give the same
/// values for the same points and Highlight Rolloff when the converter's Gamma is 1.7 times
/// Cineon Settings' Gamma: the converter's default 1.7 and Cineon Settings' Gamma 1 both give
/// linear light (code 400 is 0.10251).
#[test]
fn cineon_converter_matches_interpret_footage() {
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
for (gamma, rolloff) in [(1.0, 20.0), (1.0, 0.0), (1.7, 50.0), (2.4, 50.0)] {
let mut img = Image::new(codes.len() as u32, 1);
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let vals = [("conversionType", Value::Enum(1)), ("gamma", num(gamma * 1.7)), ("highlightRolloff", num(rolloff))];
let out = run("ec.utility.cineon", &vals, img);
let settings = effectcraft_project::CineonSettings { log_conversion: true, gamma: gamma as f32, rolloff: rolloff as f32, ..Default::default() };
for (x, code) in codes.iter().enumerate() {
let footage = settings.convert(code / 1023.0);
let effect = out.img.data[x][0];
assert!((footage - effect).abs() < 1e-6, "gamma {gamma}, rolloff {rolloff}, code {code}: {footage} vs {effect}");
}
}
let linear = effectcraft_project::CineonSettings { gamma: 1.0, rolloff: 0.0, ..Default::default() };
assert!((linear.convert(400.0 / 1023.0) - 0.10251).abs() < 1e-4);
}
/// The Cineon Converter gives the same values as before Cineon Settings shared its
/// log-to-linear function, at the default Gamma (1.7) and at Gamma 2.4, for all three
/// conversion types.
#[test]
fn cineon_converter_output_is_unchanged() {
fn log_to_lin(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = (10f32.powf((v * 1023.0 - white) * 0.002 / 0.6) - off) / (1.0 - off).max(1e-6);
lin = lin.max(0.0).powf(1.7 / gamma.max(0.01));
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k + w * (1.0 - (-(lin - k) / w).exp());
}
lin
}
fn lin_to_log(v: f32, gamma: f32, rolloff: f32, black: f32, white: f32) -> f32 {
let off = 10f32.powf((black - white) * 0.002 / 0.6);
let mut lin = v;
let k = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if k < 1.0 && lin > k {
let w = 1.0 - k;
lin = k - w * (1.0 - (lin - k) / w).max(1e-6).ln();
}
lin = lin.max(0.0).powf(gamma.max(0.01) / 1.7);
(white + (lin * (1.0 - off) + off).max(1e-9).log10() * 0.6 / 0.002) / 1023.0
}
let codes = [0.0f32, 95.0, 250.0, 400.0, 560.0, 685.0, 800.0, 1023.0];
for gamma in [None, Some(2.4)] {
for kind in 0..3 {
let mut img = Image::new(codes.len() as u32, 1);
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
img.set(x as u32, 0, [v, v, v, 1.0]);
}
let mut vals = vec![("conversionType", Value::Enum(kind))];
if let Some(g) = gamma {
vals.push(("gamma", num(g)));
}
let out = run("ec.utility.cineon", &vals, img);
let g = gamma.unwrap_or(1.7) as f32;
for (x, code) in codes.iter().enumerate() {
let v = code / 1023.0;
let want = match kind {
0 => lin_to_log(v, g, 20.0, 95.0, 685.0),
1 => log_to_lin(v, g, 20.0, 95.0, 685.0),
_ => lin_to_log(log_to_lin(v, g, 20.0, 95.0, 685.0), 1.7, 20.0, 95.0, 685.0),
};
let got = out.img.data[x][0];
assert!((want - got).abs() < 1e-6, "gamma {g}, type {kind}, code {code}: {want} vs {got}");
}
}
}
}
#[test]
fn hdr_compression_behaviour() {
let mut img = Image::new(2, 1);
+3 -2
View File
@@ -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 {
+154 -3
View File
@@ -5,7 +5,7 @@
use std::collections::{BTreeMap, BTreeSet};
use effectcraft_color::Label;
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
use effectcraft_time::{FrameRate, Tick};
use serde_json::{Value, json};
@@ -601,6 +601,131 @@ pub(crate) struct Interpretation {
preserve_rgb: Option<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.
@@ -694,9 +819,23 @@ impl Interpretation {
v.as_f64().filter(|x| x.is_finite() && (0.0..=StartTimecode::MAX as f64).contains(x)).map(|x| x.round() as i64).ok_or_else(start_err)?,
)),
};
it.cineon = CineonEdit::parse(p, cmd)?;
Ok(it)
}
/// Check the Cineon Settings this interpretation gives each of `footage`: an error names a
/// value out of range, or Cineon parameters when none of the footage is DPX or Cineon.
pub(crate) fn check<'a>(&self, footage: impl IntoIterator<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);
@@ -756,6 +895,10 @@ impl Interpretation {
if let Some(v) = self.preserve_rgb {
f.preserve_rgb = v;
}
// Checked before the edit (`Interpretation::check`).
if let Ok(Some(c)) = self.cineon.settings_for(f, "file.interpretFootage") {
f.cineon = c;
}
// After the frame rate: timecode is read at the footage's rate.
if let Some(start) = self.start.as_ref().and_then(|t| t.frame(f.frame_rate)) {
f.start_timecode = start;
@@ -802,6 +945,11 @@ fn interpret(s: &mut Session, p: &Value) -> Result<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)) {
@@ -837,7 +985,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,
})
@@ -868,6 +1016,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);
@@ -1097,7 +1248,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Main...",
["File", "Interpret Footage"],
Some("Cmd+Alt+G"),
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start)}",
"{items?|item?, frameRate?: fps|\"file\", alpha?: straight|premultiplied|ignore|guess, guessAlpha?, matteColor?|premulColor?, invertAlpha?, loop?, pixelAspect?, fields?: off|upper|lower, colorProfile?: srgb|rec709|rec2020|p3|auto, linearLight?, preserveRgb? (the file's values unconverted), overrideStart?: bool (false: Use Source File Timecode), startTimecode?: timecode|frame (Override Start), logConversion? (Cineon Settings of DPX and Cineon files), cineonUnits?: 10bit|8bit|float, convertedBlack?, convertedWhite?, pointUnits?: 10bit|8bit|float (the units of convertedBlack and convertedWhite; default cineonUnits, else the footage's Units), tenBitBlack?, tenBitWhite? (0..1023), currentGamma? (0.1..10), highlightRolloff? (0..100)}",
has_footage_selection,
interpret
),
+6
View File
@@ -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") {
+2 -1
View File
@@ -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?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage)}",
"{items?|item?, alpha?, guessAlpha?, matteColor?|premulColor?, invertAlpha?, frameRate?, loop?, pixelAspect?, fields?, colorProfile?, linearLight?, preserveRgb?, overrideStart?, startTimecode? (as file.interpretFootage), logConversion?, cineonUnits?, convertedBlack?, convertedWhite?, pointUnits?, tenBitBlack?, tenBitWhite?, currentGamma?, highlightRolloff? (Cineon Settings of DPX and Cineon proxies)}",
has_proxy,
interpret_proxy
),
+1 -1
View File
@@ -96,7 +96,7 @@ impl Session {
effectcraft_project::ItemKind::Footage(f) if f.has_video => {
let f = f.clone();
Some(Box::new(move || {
let img = footage.frame(item, &f, Tick(0))?;
let img = effectcraft_render::cineon_frame(footage.frame(item, &f, Tick(0))?, &f);
// Point-sample down to at most `max_side` on the long side.
let step = (img.width.max(img.height) as f32 / max_side.max(1) as f32).ceil().max(1.0) as u32;
let (w, h) = (img.width.div_ceil(step), img.height.div_ceil(step));
+138 -5
View File
@@ -1,6 +1,6 @@
//! Proxies (Set Proxy, Use Proxy, Proxy Use render settings, comp proxies, Create Proxy) and
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light), rendered through
//! a synthetic footage source.
//! Interpret Footage (fields, pixel aspect, alpha guess / invert, linear light, Cineon Settings),
//! rendered through a synthetic footage source.
use std::sync::Arc;
@@ -36,6 +36,12 @@ fn file_px(path: &str, x: u32, y: u32) -> [f32; 4] {
"matte_black.png" => [0.0, 0.5, 0.0, 0.5],
"matte_white.png" => [0.5, 1.0, 0.5, 0.5],
"half.png" => [1.0, 1.0, 1.0, 0.25],
// A log film scan: code values 95, 685, 400 and 1023 across, as the DPX reader hands
// them out (code / 1023).
"scan.dpx" | "plate.dpx" => {
let v = [95.0, 685.0, 400.0, 1023.0][x.min(3) as usize] / 1023.0;
[v, v, v, 1.0]
}
_ => {
let _ = x;
[1.0, 1.0, 1.0, 1.0]
@@ -47,7 +53,7 @@ fn size_of(path: &str) -> (u32, u32) {
match std::path::Path::new(path).file_name().and_then(std::ffi::OsStr::to_str).unwrap_or("") {
"red16.png" => (16, 16),
"blue8.png" => (8, 8),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" => (4, 4),
"lines.mov" | "matte_black.png" | "matte_white.png" | "half.png" | "scan.dpx" | "plate.dpx" => (4, 4),
_ => (10, 10),
}
}
@@ -64,7 +70,12 @@ impl FootageSource for Synthetic {
for y in 0..h {
for x in 0..w {
// Decoders hand out premultiplied pixels (straight file colour × alpha).
let p = file_px(&f.path, x, y);
let mut p = file_px(&f.path, x, y);
// A log scan sequence: code 95 on even frames, 685 on odd ones.
if f.path.ends_with("frames.dpx") {
let v = if f.frame_rate.frame_at(t) % 2 == 0 { 95.0 } else { 685.0 } / 1023.0;
p = [v, v, v, 1.0];
}
img.set(x, y, [p[0] * p[3], p[1] * p[3], p[2] * p[3], p[3]]);
}
}
@@ -76,7 +87,7 @@ struct Probe;
impl crate::Importer for Probe {
fn probe(&self, path: &str) -> Result<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 },
@@ -431,3 +442,125 @@ fn guess_alpha_invert_and_linear_light() {
s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap();
assert!(foot(&s, w).preserve_rgb);
}
/// Color ▸ Cineon Settings: Logarithmic Conversion turns a DPX file's log code values into the
/// Current Gamma between the converted points (off, they show as stored); the converted points
/// are read and reported in the chosen Units; out-of-range values and footage that is not DPX
/// or Cineon are refused; Remember / Apply Interpretation and undo carry the settings.
#[test]
fn cineon_settings_convert_dpx_code_values() {
let mut s = session();
let d = import(&mut s, "/m/scan.dpx");
let other = import(&mut s, "/m/plate.dpx");
let png = import(&mut s, "/m/red16.png");
let foot = |s: &Session, i: ItemId| match &s.project.item(i).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
let at = |s: &Session, x: i64| px(s, c, 0.0, RenderOpts::default(), x, 0)[0];
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
// Off: the code values as stored.
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
// The checked path (control channel, MCP) accepts every Cineon parameter the doc lists.
let out = s.execute_checked("file.interpretFootage", json!({"items": [d.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
assert_eq!(out["items"][0]["cineon"]["logConversion"], true, "{out}");
assert!(near(at(&s, 0), 0.0) && near(at(&s, 1), 1.0), "{} {}", at(&s, 0), at(&s, 1));
assert!(near(at(&s, 2), 0.26190), "code 400 at gamma 1.7: {}", at(&s, 2));
s.execute("file.interpretFootage", json!({"item": d.0, "currentGamma": 1})).unwrap();
assert!(near(at(&s, 2), 0.10251), "code 400 in linear light: {}", at(&s, 2));
// Converted points, in 8-bit units.
let out = s
.execute_checked("file.interpretFootage", json!({"items": [d.0], "cineonUnits": "8bit", "convertedBlack": 16, "convertedWhite": 235, "currentGamma": 1, "tenBitBlack": 95, "tenBitWhite": 685}))
.unwrap();
assert_eq!(out["items"][0]["cineon"]["units"], "8bit");
assert!((out["items"][0]["cineon"]["convertedWhite"].as_f64().unwrap() - 235.0).abs() < 1e-3, "{out}");
assert!(near(at(&s, 0), 16.0 / 255.0) && near(at(&s, 1), 235.0 / 255.0), "{} {}", at(&s, 0), at(&s, 1));
// The form sends the points in the units it showed them in (`pointUnits`) with a new Units.
s.execute_checked("file.interpretFootage", json!({"items": [d.0], "pointUnits": "8bit", "cineonUnits": "float", "convertedWhite": 255})).unwrap();
let f = foot(&s, d);
assert_eq!(f.cineon.units, effectcraft_project::CineonUnits::Float);
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
// Refused: a black point at or above the white point, values out of range, and footage
// that is not DPX or Cineon. Nothing changes.
let before = foot(&s, d).cineon;
for bad in [
json!({"item": d.0, "tenBitBlack": 700}),
json!({"item": d.0, "tenBitWhite": 2000}),
json!({"item": d.0, "currentGamma": 0}),
json!({"item": d.0, "highlightRolloff": -1}),
json!({"item": d.0, "convertedWhite": 1.5}),
json!({"item": d.0, "cineonUnits": "12bit"}),
json!({"item": png.0, "logConversion": true}),
] {
assert!(s.execute("file.interpretFootage", bad.clone()).is_err(), "{bad}");
}
assert_eq!(foot(&s, d).cineon, before);
assert!(foot(&s, png).cineon == effectcraft_project::CineonSettings::default());
// With DPX and other footage selected, only the DPX file takes the settings.
s.execute("file.interpretFootage", json!({"items": [png.0, other.0], "logConversion": true})).unwrap();
assert!(foot(&s, other).cineon.log_conversion && !foot(&s, png).cineon.log_conversion);
s.execute("edit.undo", json!({})).unwrap();
assert!(!foot(&s, other).cineon.log_conversion);
// Remember / Apply Interpretation.
s.state.project_selection = vec![d];
s.execute("file.rememberInterpretation", json!({"item": d.0})).unwrap();
s.execute("file.applyInterpretation", json!({"items": [other.0, png.0]})).unwrap();
assert_eq!(foot(&s, other).cineon, before);
assert_eq!(foot(&s, png).cineon, effectcraft_project::CineonSettings::default());
// Off again: as stored.
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": false})).unwrap();
assert!(near(at(&s, 2), 400.0 / 1023.0), "{}", at(&s, 2));
// Frame Mix blends converted frames: halfway between code 95 (black) and 685 (white) at
// Current Gamma 1 is 0.5, not the conversion of the blended code values.
let seq = import(&mut s, "/m/frames.dpx");
s.execute("file.interpretFootage", json!({"item": seq.0, "logConversion": true, "currentGamma": 1, "highlightRolloff": 0})).unwrap();
s.execute("comp.new", json!({"name": "Blend", "width": 10, "height": 10, "frameRate": 20, "duration": 1})).unwrap();
let bc = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": seq.0})).unwrap();
s.execute("layer.frameBlending", json!({"mode": "frameMix"})).unwrap();
let mid = px(&s, bc, 0.05, RenderOpts::default(), 5, 5)[0];
assert!(near(mid, 0.5), "frame-mixed log frames: {mid}");
// A DPX proxy of a composition: Interpret Proxy's settings convert it where it stands in.
s.execute("comp.new", json!({"name": "Inner", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let inner = s.active_comp_id().unwrap();
s.execute("comp.new", json!({"name": "Outer", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let outer = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": inner.0})).unwrap();
s.execute("file.setProxy", json!({"item": inner.0, "path": "/m/scan.dpx"})).unwrap();
let proxied = |s: &Session, x: i64| px(s, outer, 0.0, RenderOpts::default(), x, 0)[0];
assert!(near(proxied(&s, 1), 685.0 / 1023.0), "{}", proxied(&s, 1));
s.execute_checked("file.interpretProxy", json!({"items": [inner.0], "logConversion": true, "highlightRolloff": 0})).unwrap();
assert!(near(proxied(&s, 0), 0.0) && near(proxied(&s, 1), 1.0), "{} {}", proxied(&s, 0), proxied(&s, 1));
}
/// The Footage panel, the Project panel thumbnail and the tracker's source frames show DPX
/// footage with its Cineon Settings, as a composition renders it.
#[test]
fn cineon_settings_apply_to_footage_panel_thumbnail_and_tracking() {
let mut s = session();
let d = import(&mut s, "/m/scan.dpx");
s.execute("comp.new", json!({"name": "C", "width": 4, "height": 4, "frameRate": 10, "duration": 1})).unwrap();
let c = s.active_comp_id().unwrap();
s.execute("layer.addItem", json!({"item": d.0})).unwrap();
s.execute("file.interpretFootage", json!({"item": d.0, "logConversion": true, "highlightRolloff": 0})).unwrap();
let near = |a: f32, b: f32| (a - b).abs() < 1.5 / 255.0;
let rendered = px(&s, c, 0.0, RenderOpts::default(), 2, 0)[0];
assert!(near(rendered, 0.26190), "code 400 at gamma 1.7: {rendered}");
let f = match &s.project.item(d).unwrap().kind {
ItemKind::Footage(f) => f.clone(),
_ => panic!("not footage"),
};
let panel = crate::commands::footage_panel::frame(&s, d, &f, Tick::ZERO).unwrap().get(2, 0)[0];
assert!(near(panel, rendered), "Footage panel: {panel}");
let (w, _, rgba) = (s.thumbnail_job(d, Tick::ZERO, 64).unwrap())().unwrap();
assert_eq!(w, 4);
let thumb = rgba[8] as f32 / 255.0;
assert!(near(thumb, rendered), "Project panel thumbnail: {thumb}");
let comp = s.project.comp(c).unwrap();
let r = crate::render::Renderer::new(&s.project, s.footage.as_ref(), RenderOpts::default());
let (img, _) = crate::tracking::source_frame(&r, &s.project, c, comp, &comp.layers[0], Tick::ZERO, None).unwrap();
assert!(near(img.get(2, 0)[0], rendered), "tracker source frame: {}", img.get(2, 0)[0]);
}
+151
View File
@@ -52,6 +52,142 @@ pub enum AlphaMode {
Ignore,
}
/// Interpret Footage ▸ Cineon Settings ▸ Units: how the Converted Black and White Points are
/// shown and entered.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CineonUnits {
/// 10-bit code values (0–1023).
#[default]
TenBit,
/// 8-bit values (0–255).
EightBit,
/// Floating point (0–1).
Float,
}
impl CineonUnits {
pub const ALL: [CineonUnits; 3] = [CineonUnits::TenBit, CineonUnits::EightBit, CineonUnits::Float];
/// The id commands use: `10bit`, `8bit` or `float`.
pub fn id(self) -> &'static str {
match self {
CineonUnits::TenBit => "10bit",
CineonUnits::EightBit => "8bit",
CineonUnits::Float => "float",
}
}
pub fn label(self) -> &'static str {
match self {
CineonUnits::TenBit => "10-bit",
CineonUnits::EightBit => "8-bit",
CineonUnits::Float => "Float",
}
}
pub fn parse(s: &str) -> Option<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 {
let lin = cineon_log_to_lin(v, self.black, self.white, 1.0, self.gamma, self.rolloff);
let out = (self.converted_black + (self.converted_white - self.converted_black) * lin) / Self::MAX;
if out.is_finite() { out } else { 0.0 }
}
}
/// Cineon log to linear, shared by Interpret Footage ▸ Cineon Settings and the Cineon Converter
/// effect. `v` is a code value as 0–1 of the 10-bit range; `black` and `white` are the 10 Bit
/// Black and White Points. The result is 0 at the black point and 1 at the white point, raised to
/// `scale / gamma`, with values above `1 - rolloff / 100` ramped off toward 1 (Highlight Rolloff
/// 0–100; 0 keeps over-range values). A Gamma below 0.01 is raised to 0.01. Cineon Settings passes
/// a `scale` of 1 (Gamma 1 gives linear light) and the Cineon Converter passes 1.7 (its default
/// Gamma 1.7 gives linear light).
pub fn cineon_log_to_lin(v: f32, black: f32, white: f32, scale: f32, gamma: f32, rolloff: f32) -> f32 {
// Kodak printing density: 0.002 density per code value, negative gamma 0.6.
let exposure = |code: f32| 10f32.powf((code - white) * 0.002 / 0.6);
let offset = exposure(black);
let mut lin = ((exposure(v * CineonSettings::MAX) - offset) / (1.0 - offset).max(1e-6)).max(0.0);
lin = lin.powf(scale / gamma.max(0.01));
let knee = 1.0 - (rolloff / 100.0).clamp(0.0, 0.99);
if knee < 1.0 && lin > knee {
let w = 1.0 - knee;
lin = knee + w * (1.0 - (-(lin - knee) / w).exp());
}
lin
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Footage {
pub path: String,
@@ -98,6 +234,9 @@ pub struct Footage {
/// colour profile, Interpret As Linear Light and the working space don't apply.
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub preserve_rgb: bool,
/// Interpret Footage ▸ Cineon Settings (DPX and Cineon files only; [`Footage::cineon_applies`]).
#[serde(default, skip_serializing_if = "is_default")]
pub cineon: CineonSettings,
/// Data footage ([`FootageKind::Data`]): the file's text (JSON, CSV or TSV), kept in the
/// project so expressions read it everywhere (and on the web).
#[serde(default, skip_serializing_if = "Option::is_none")]
@@ -149,6 +288,7 @@ impl Default for Footage {
invert_alpha: false,
linear_light: false,
preserve_rgb: false,
cineon: CineonSettings::default(),
data: None,
layer: None,
page: 0,
@@ -160,6 +300,17 @@ impl Default for Footage {
}
impl Footage {
/// The file is DPX or Cineon (the formats Interpret Footage ▸ Cineon Settings applies to).
pub fn is_cineon_file(&self) -> bool {
let ext = std::path::Path::new(&self.path).extension().and_then(|e| e.to_str()).unwrap_or("").to_ascii_lowercase();
matches!(ext.as_str(), "dpx" | "cin")
}
/// Cineon Settings ▸ Logarithmic Conversion applies: it is on and the file is DPX or Cineon.
pub fn cineon_applies(&self) -> bool {
self.cineon.log_conversion && self.is_cineon_file()
}
/// Interpret As Linear Light applies (Preserve RGB passes the file's values through).
pub fn linear_light_applies(&self) -> bool {
self.linear_light && !self.preserve_rgb
+1 -1
View File
@@ -36,7 +36,7 @@ mod layer;
mod settings;
pub use comp::{Comp, Guide, Renderer, Resolution};
pub use footage::{AlphaMode, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer};
pub use footage::{AlphaMode, CineonSettings, CineonUnits, FieldOrder, Footage, FootageKind, Proxy, Solid, SourceLayer, cineon_log_to_lin};
pub use item::{Item, ItemId, ItemKind, LayerId};
pub use layer::{AutoOrient, FrameBlend, Layer, LayerSource, LightKind, MatteKind, PrimitiveKind, Quality, Sampling, Switches, TrackMatte};
pub use settings::{
+43
View File
@@ -291,6 +291,49 @@ fn comp_contains_ends_on_cycles_long_chains_and_shared_precomps() {
assert!(!p.comp_contains(c[n - 1], c[0]));
}
/// Interpret Footage ▸ Cineon Settings: the 10 Bit Black and White Points become the converted
/// points, Current Gamma 1 gives linear light, Highlight Rolloff 0 keeps over-range values and
/// a higher one ramps them off; the settings apply only to DPX and Cineon files, and projects
/// saved without them read the defaults.
#[test]
fn cineon_settings_convert_log_code_values() {
use crate::{CineonSettings, CineonUnits, Footage};
let code = |v: f32| v / 1023.0;
let c = CineonSettings { log_conversion: true, rolloff: 0.0, ..Default::default() };
assert!(c.convert(code(95.0)).abs() < 1e-6, "black point");
assert!((c.convert(code(685.0)) - 1.0).abs() < 1e-5, "white point");
// Code 400: (10^(-0.95) - 10^(-1.9667)) / (1 - 10^(-1.9667)) = 0.10251 linear, ^(1/1.7).
assert!((c.convert(code(400.0)) - 0.26190).abs() < 1e-4, "{}", c.convert(code(400.0)));
let linear = CineonSettings { gamma: 1.0, ..c };
assert!((linear.convert(code(400.0)) - 0.10251).abs() < 1e-4, "{}", linear.convert(code(400.0)));
assert!(c.convert(code(1023.0)) > 1.5, "over range kept: {}", c.convert(code(1023.0)));
// The default rolloff (20) ramps the white point and above off, up to 1.
let rolled = CineonSettings { rolloff: 20.0, ..c };
let (w, mid, top) = (rolled.convert(code(685.0)), rolled.convert(code(720.0)), rolled.convert(code(1023.0)));
assert!(w > 0.8 && w < mid && mid < top && top <= 1.0, "{w} {mid} {top}");
let points = CineonSettings { converted_black: 64.0, converted_white: 940.0, ..c };
assert!((points.convert(code(95.0)) - 64.0 / 1023.0).abs() < 1e-5);
assert!((points.convert(code(685.0)) - 940.0 / 1023.0).abs() < 1e-5);
// Units.
assert!((CineonUnits::EightBit.from_ten_bit(1023.0) - 255.0).abs() < 1e-9);
assert!((CineonUnits::Float.to_ten_bit(0.5) - 511.5).abs() < 1e-9);
assert_eq!(CineonUnits::parse("8-bit"), Some(CineonUnits::EightBit));
assert!(CineonUnits::ALL.iter().all(|u| CineonUnits::parse(u.id()) == Some(*u)));
// DPX and Cineon files only, and only with Logarithmic Conversion on.
let on = CineonSettings { log_conversion: true, ..Default::default() };
let dpx = Footage { path: "/m/plate.0001.DPX".into(), cineon: on, ..Default::default() };
assert!(dpx.cineon_applies());
assert!(Footage { path: "/m/scan.cin".into(), cineon: on, ..Default::default() }.cineon_applies());
assert!(!Footage { path: "/m/still.png".into(), cineon: on, ..Default::default() }.cineon_applies());
assert!(!Footage { path: "/m/plate.dpx".into(), ..Default::default() }.cineon_applies());
// Saved only when changed; read back, and absent in older projects.
let plain = serde_json::to_value(Footage { path: "/m/a.dpx".into(), ..Default::default() }).unwrap();
assert!(plain.get("cineon").is_none(), "{plain}");
let saved = serde_json::to_string(&dpx).unwrap();
assert_eq!(serde_json::from_str::<Footage>(&saved).unwrap().cineon, on);
assert_eq!(serde_json::from_value::<Footage>(plain).unwrap().cineon, CineonSettings::default());
}
/// A Wiggle Paths 100 000 groups deep gets Spatial Phase on a 2 MiB stack.
#[test]
fn upgrading_deeply_nested_shape_groups_fits_a_small_stack() {
+19 -1
View File
@@ -1,6 +1,7 @@
use super::footage::NoFootage;
use super::opts::RenderOpts;
use super::{EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
use super::{Arc, EvalCtx, Footage, FrameBlend, FrameRate, Image, ItemId, Layer, LayerSource, Project, Renderer, TICKS_PER_SECOND, Tick, shapes, text};
use rayon::prelude::*;
/// The layer's frame blending mode, if the comp's Enable Frame Blending switch is on.
/// One field of an interlaced frame as a full frame: the field's lines (`parity` 0 = even /
@@ -33,6 +34,23 @@ pub fn field_frame(img: &Image, parity: usize) -> Image {
out
}
/// Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion converts the log code values of
/// a decoded (premultiplied) frame of `f` to the Current Gamma. When the conversion does not
/// apply, the frame is returned unchanged.
pub fn cineon_frame(img: Arc<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 {
+2 -2
View File
@@ -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;
+13 -7
View File
@@ -1,6 +1,6 @@
use super::accel::{Accelerator, Backend, CpuFx, FxStep, FxTarget};
use super::footage::{FootageSource, is_vector_footage};
use super::frames::{blend_frames, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::frames::{blend_frames, cineon_frame, content_bounds, field_frame, frame_blend_mode, frame_position, frame_size, interpret_pixels, roi_inside};
use super::fx_host::{FxHost, essential_overrides};
use super::opts::{Collapse3d, LayerTiming, MAX_THROUGH, RenderOpts, Through};
use super::{
@@ -829,16 +829,17 @@ impl<'a> Renderer<'a> {
if f.fields != effectcraft_project::FieldOrder::Off && matches!(f.kind, FootageKind::Video | FootageKind::Sequence) {
let field_rate = FrameRate::new(f.frame_rate.num * 2, f.frame_rate.den);
let i = field_rate.frame_at(t).max(0);
let img = self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?;
let img = cineon_frame(self.footage.frame_with(item, f, f.frame_rate.tick_of(i / 2), None, self.pipe.float_curve(f))?, f);
let dominant_upper = f.fields == effectcraft_project::FieldOrder::UpperFirst;
// Upper field = even lines (0, 2, …).
let parity = if (i % 2 == 0) == dominant_upper { 0 } else { 1 };
return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f, self.pipe.linear_footage_space(f))));
}
// Interpretation is per pixel but not linear: it runs on the frame before resampling.
let size = size.filter(|_| !(f.invert_alpha || f.linear_light_applies()));
let per_pixel = f.invert_alpha || f.linear_light_applies();
let size = size.filter(|_| !(per_pixel || f.cineon_applies()));
let img = self.footage_frame_raw(ctx, layer, item, f, t, size)?;
if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
if per_pixel { Some(Arc::new(interpret_pixels(&img, f, self.pipe.linear_footage_space(f)))) } else { Some(img) }
}
fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick, size: Option<(u32, u32)>) -> Option<Arc<Image>> {
@@ -856,6 +857,10 @@ impl<'a> Renderer<'a> {
if f.alpha == effectcraft_project::AlphaMode::Premultiplied {
f.premul_color.map(f32::to_bits).hash(&mut h);
}
if f.cineon_applies() {
let c = &f.cineon;
[c.converted_black, c.converted_white, c.black, c.white, c.gamma, c.rolloff].map(f32::to_bits).hash(&mut h);
}
h.finish()
});
if let (Some(c), Some(k)) = (self.cache, key)
@@ -863,8 +868,9 @@ impl<'a> Renderer<'a> {
{
return Some(Arc::new(b.img.clone()));
}
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float);
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float);
// Log scans are converted before they blend: the conversion is not linear.
let a = self.footage.frame_with(item, f, f.frame_rate.tick_of(i), None, float).map(|a| cineon_frame(a, f));
let b = self.footage.frame_with(item, f, f.frame_rate.tick_of(i + 1), None, float).map(|b| cineon_frame(b, f));
if let (Some(a), Some(b)) = (a, b) {
let img = blend_frames(mode, &a, &b, w as f32);
if let (Some(c), Some(k)) = (self.cache_to_keep(), key) {
@@ -874,7 +880,7 @@ impl<'a> Renderer<'a> {
}
}
}
self.footage.frame_with(item, f, t, size, float)
self.footage.frame_with(item, f, t, size, float).map(|img| cineon_frame(img, f))
}
/// Source → masks, clamped/quantised to the project depth.
+1 -1
View File
@@ -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])));
+130 -4
View File
@@ -233,7 +233,7 @@ fn new_comp_from_selection_form(s: &effectcraft_engine::Session) -> (String, Vec
}
/// The Interpret Footage dialog for the selected footage (or its proxy), if there is one.
fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Option<(String, Vec<Field>)> {
fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool, base: &mut Value) -> Option<(String, Vec<Field>)> {
let f = s.state.project_selection.first().and_then(|i| s.project.item(*i)).and_then(|it| match (&it.kind, &it.proxy) {
(_, Some(px)) if proxy => Some(px.footage.clone()),
(effectcraft_engine::project::ItemKind::Footage(f), _) if !proxy => Some(f.clone()),
@@ -286,6 +286,30 @@ fn interpret_footage_form(s: &effectcraft_engine::Session, proxy: bool) -> Optio
Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb),
Field::bool("linearLight", "Interpret As Linear Light", f.linear_light),
];
// Color ▸ Cineon Settings (DPX and Cineon files). The converted points show in the
// footage's Units, and `pointUnits` reads them back in those units when Units changes.
if f.is_cineon_file() {
use effectcraft_engine::project::CineonUnits;
let c = f.cineon;
let units: Vec<(&str, Value)> = CineonUnits::ALL.iter().map(|u| (u.label(), json!(u.id()))).collect();
fields.extend([
Field::note("cineonSettings", "Cineon Settings"),
Field::bool("logConversion", "Logarithmic Conversion", c.log_conversion),
Field::num("convertedBlack", "Converted Black Point", c.units.from_ten_bit(f64::from(c.converted_black))),
Field::num("convertedWhite", "Converted White Point", c.units.from_ten_bit(f64::from(c.converted_white))),
Field::num("tenBitBlack", "10 Bit Black Point", f64::from(c.black)),
Field::num("tenBitWhite", "10 Bit White Point", f64::from(c.white)),
Field::num("currentGamma", "Current Gamma", f64::from(c.gamma)),
Field::num("highlightRolloff", "Highlight Rolloff", f64::from(c.rolloff)),
Field::choice("cineonUnits", "Units", &units, CineonUnits::ALL.iter().position(|u| *u == c.units).unwrap_or(0)),
]);
if !base.is_object() {
*base = json!({});
}
if let Some(m) = base.as_object_mut() {
m.insert("pointUnits".into(), json!(c.units.id()));
}
}
// After Frame Rate: an image sequence's frames (missing ones show colour bars, as in
// After Effects), then Start Timecode.
let mut timing = Vec::new();
@@ -487,7 +511,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
let layer = comp.and_then(|c| s.state.selected_layers.first().and_then(|l| c.layer(*l)));
let t = s.time();
let lt = layer.map(|l| l.layer_time(t)).unwrap_or(t);
let base = p.clone();
let mut base = p.clone();
let (title, fields): (String, Vec<Field>) = match id {
// Photoshop files: Import Kind (Footage / Composition / – Retain Layer Sizes). Dropped
// files start at Settings ▸ Import ▸ Default Drag Import As.
@@ -651,10 +675,33 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool {
],
),
"file.interpretFootage" | "file.interpretProxy"
if !has(p, &["frameRate", "alpha", "loop", "pixelAspect", "colorProfile", "fields", "invertAlpha", "matteColor", "linearLight", "startFrame"]) =>
if !has(
p,
&[
"frameRate",
"alpha",
"loop",
"pixelAspect",
"colorProfile",
"fields",
"invertAlpha",
"matteColor",
"linearLight",
"startFrame",
"logConversion",
"cineonUnits",
"pointUnits",
"convertedBlack",
"convertedWhite",
"tenBitBlack",
"tenBitWhite",
"currentGamma",
"highlightRolloff",
],
) =>
{
let proxy = id == "file.interpretProxy";
let Some(form) = interpret_footage_form(s, proxy) else { return false };
let Some(form) = interpret_footage_form(s, proxy, &mut base) else { return false };
form
}
"file.projectSettings" if p.as_object().is_none_or(|m| m.is_empty()) => project_settings_form(s),
@@ -1074,4 +1121,83 @@ mod tests {
assert_eq!(r["path"], path.as_str());
assert!(std::fs::read_to_string(&path).unwrap().contains("<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:?}");
}
}
+1 -1
View File
@@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| BMP | R | yes | — | good |
| WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects |
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import |
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX |
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX |
| Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output |
| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) |
| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared |
+7 -2
View File
@@ -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
+1 -1
View File
@@ -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) |