mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 04:57:54 -06:00
Merge pull request #2300 from dotCooCoo/feat/cineon-settings
Interpret Footage: Cineon Settings convert DPX and Cineon log scans
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -197,7 +197,8 @@ pub(crate) struct Work {
|
||||
}
|
||||
|
||||
/// The layer's source frame at comp time `t` at 100 % (layer pixels + offset = image pixels).
|
||||
/// Footage at its native size is used straight from the footage source (no copy).
|
||||
/// Footage at its native size is used straight from the footage source (no copy unless its
|
||||
/// Cineon Settings convert it).
|
||||
pub(crate) fn source_frame(
|
||||
r: &Renderer,
|
||||
project: &Project,
|
||||
@@ -215,7 +216,7 @@ pub(crate) fn source_frame(
|
||||
&& img.width == f.width
|
||||
&& img.height == f.height
|
||||
{
|
||||
return Some((img, [0.0; 2]));
|
||||
return Some((effectcraft_render::cineon_frame(img, f), [0.0; 2]));
|
||||
}
|
||||
let buf = r.layer_source(&ctx, layer)?;
|
||||
if (buf.scale - 1.0).abs() > 1e-9 && buf.scale > 0.0 {
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use effectcraft_color::Label;
|
||||
use effectcraft_project::{AlphaMode, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_project::{AlphaMode, CineonSettings, CineonUnits, Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, limits};
|
||||
use effectcraft_time::{FrameRate, Tick};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
@@ -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
|
||||
),
|
||||
|
||||
@@ -56,6 +56,12 @@ pub fn timecode(item: Option<&effectcraft_project::Item>, fr: effectcraft_time::
|
||||
}
|
||||
}
|
||||
|
||||
/// The frame the Footage panel shows: footage `f` at source time `t`, with its Cineon Settings
|
||||
/// applied.
|
||||
pub fn frame(s: &Session, item: ItemId, f: &effectcraft_project::Footage, t: Tick) -> Option<std::sync::Arc<effectcraft_raster::Image>> {
|
||||
s.footage.frame(item, f, t).map(|img| effectcraft_render::cineon_frame(img, f))
|
||||
}
|
||||
|
||||
fn source_time(s: &Session, p: &Value, item: ItemId) -> Option<Tick> {
|
||||
let (_, fr) = source_timing(s, item);
|
||||
if let Some(t) = f_p(p, "time") {
|
||||
|
||||
@@ -117,6 +117,7 @@ fn interpret_proxy(s: &mut Session, p: &Value) -> Result<Value> {
|
||||
return Err(bad(C, "the selected items have no proxy"));
|
||||
}
|
||||
let it = super::file_more::Interpretation::parse(p, C)?;
|
||||
it.check(items.iter().filter_map(|i| s.project.item(*i).and_then(|x| x.proxy.as_ref()).map(|px| &px.footage)), C)?;
|
||||
let guesses: Vec<_> = items
|
||||
.iter()
|
||||
.map(|i| {
|
||||
@@ -229,7 +230,7 @@ pub fn specs() -> Vec<CommandSpec> {
|
||||
"Proxy...",
|
||||
["File", "Interpret Footage"],
|
||||
None,
|
||||
"{items?|item?, alpha?, 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
|
||||
),
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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])));
|
||||
|
||||
@@ -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:?}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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) |
|
||||
|
||||
Reference in New Issue
Block a user