mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
Interpret Footage: Cineon Settings convert DPX and Cineon log scans
DPX and Cineon footage showed its log code values as stored, with no way to convert them. Interpret Footage now has the Cineon Settings that After Effects' help page describes: Logarithmic Conversion, the Converted Black and White Points, the 10 Bit Black and White Points, Current Gamma, Highlight Rolloff and Units. With Logarithmic Conversion on, the code values of each decoded frame are converted from printing density to the Current Gamma between the converted points, before Separate Fields, Frame Blending, Interpret As Linear Light and the working space use them. With it off (the default), the values stay as stored. file.interpretFootage and file.interpretProxy take the settings as parameters and report them. They return an error for out-of-range values, and for Cineon parameters when none of the items is a DPX or Cineon file. Apply Interpretation copies the settings between DPX and Cineon files.
This commit is contained in:
@@ -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
|
||||
),
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -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(|n| n.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,96 @@ 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));
|
||||
}
|
||||
|
||||
@@ -1110,6 +1110,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,
|
||||
@@ -1156,6 +1280,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")]
|
||||
@@ -1203,6 +1330,7 @@ impl Default for Footage {
|
||||
invert_alpha: false,
|
||||
linear_light: false,
|
||||
preserve_rgb: false,
|
||||
cineon: CineonSettings::default(),
|
||||
data: None,
|
||||
layer: None,
|
||||
page: 0,
|
||||
@@ -1213,6 +1341,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
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -1266,7 +1266,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
|
||||
@@ -1377,16 +1377,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>> {
|
||||
@@ -1400,6 +1401,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)
|
||||
@@ -1407,8 +1412,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) {
|
||||
@@ -1418,7 +1424,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.
|
||||
@@ -2312,6 +2318,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 {
|
||||
|
||||
@@ -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.
|
||||
@@ -477,6 +477,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 +1082,45 @@ mod tests {
|
||||
assert_eq!(r["path"], path.as_str());
|
||||
assert!(std::fs::read_to_string(&path).unwrap().contains("<xmeml"));
|
||||
}
|
||||
|
||||
/// Interpret Footage shows Cineon Settings for DPX footage only. The converted points show in
|
||||
/// the footage's Units, and choosing other Units reads the shown values in the old ones.
|
||||
#[test]
|
||||
fn interpret_footage_shows_cineon_settings_for_dpx() {
|
||||
use effectcraft_engine::project::{CineonUnits, Footage, FootageKind, ItemKind};
|
||||
let mut app = EffectcraftApp::new(effectcraft_engine::Session::default());
|
||||
let still =
|
||||
|path: &str| ItemKind::Footage(Footage { path: path.into(), kind: FootageKind::Still, width: 4, height: 4, has_video: true, ..Default::default() });
|
||||
let project = std::sync::Arc::make_mut(&mut app.session.project);
|
||||
let dpx = project.add_item("scan.dpx", Default::default(), None, still("/m/scan.dpx"));
|
||||
let png = project.add_item("still.png", Default::default(), None, still("/m/still.png"));
|
||||
let keys = |app: &EffectcraftApp| app.dialog_state.form.fields.iter().map(|f| f.key.clone()).collect::<Vec<_>>();
|
||||
app.session.state.project_selection = vec![png];
|
||||
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
|
||||
assert!(!keys(&app).iter().any(|k| k == "logConversion"), "{:?}", keys(&app));
|
||||
app.session.state.project_selection = vec![dpx];
|
||||
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
|
||||
for k in ["logConversion", "convertedBlack", "convertedWhite", "tenBitBlack", "tenBitWhite", "currentGamma", "highlightRolloff", "cineonUnits"] {
|
||||
assert!(keys(&app).iter().any(|x| x == k), "{k}: {:?}", keys(&app));
|
||||
}
|
||||
// Units ▸ 8-bit with Converted White Point still showing 1023 (10-bit).
|
||||
let form = &mut app.dialog_state.form;
|
||||
for f in form.fields.iter_mut() {
|
||||
match (f.key.as_str(), &mut f.kind) {
|
||||
("cineonUnits", FieldKind::Choice { sel, .. }) => *sel = 1,
|
||||
("logConversion", FieldKind::Bool(b)) => *b = true,
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let params = form.params();
|
||||
assert_eq!((params["pointUnits"].as_str(), params["cineonUnits"].as_str()), (Some("10bit"), Some("8bit")), "{params}");
|
||||
app.session.execute("file.interpretFootage", params).unwrap();
|
||||
let Some(ItemKind::Footage(f)) = app.session.project.item(dpx).map(|i| &i.kind) else { panic!("not footage") };
|
||||
assert!(f.cineon.log_conversion && f.cineon.units == CineonUnits::EightBit, "{:?}", f.cineon);
|
||||
assert!((f.cineon.converted_white - 1023.0).abs() < 1e-3, "{:?}", f.cineon);
|
||||
// Opened again, the point shows in 8-bit units.
|
||||
assert!(open_form(&mut app, "file.interpretFootage", &json!({})));
|
||||
let white = app.dialog_state.form.fields.iter().find(|f| f.key == "convertedWhite").map(|f| f.kind.clone());
|
||||
assert!(matches!(white, Some(FieldKind::Number { value, .. }) if (value - 255.0).abs() < 1e-6), "{white:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
|
||||
| BMP | R | yes | — | good |
|
||||
| WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects |
|
||||
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output |
|
||||
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX |
|
||||
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored, or converted by Interpret Footage ▸ Cineon Settings ▸ Logarithmic Conversion) | none | partial: import (#756) and Cineon Settings; no DPX output; common in film VFX |
|
||||
| Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output |
|
||||
| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) |
|
||||
| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared |
|
||||
|
||||
+7
-2
@@ -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 the *10 Bit White Point* (685) becomes the *Converted White Point* (1023). *Highlight Rolloff* (20) rolls values near and above the white point off gradually; 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
@@ -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: any panel can move into a native window of its own (panel menu ▸ Move Panel to New Window, `window.panelWindow`) and so onto another screen, restored with the workspace; still missing: dragging tabs between those windows, app shortcuts while one has the focus, named workspaces remembering them, 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) |
|
||||
|
||||
Reference in New Issue
Block a user