diff --git a/crates/engine/src/commands/file_more.rs b/crates/engine/src/commands/file_more.rs index e42fe9113..6a561461c 100644 --- a/crates/engine/src/commands/file_more.rs +++ b/crates/engine/src/commands/file_more.rs @@ -553,6 +553,8 @@ pub(crate) struct Interpretation { fields: Option, profile: Option>, linear: Option, + /// Color ▸ Preserve RGB. + preserve_rgb: Option, /// Start Timecode. start: Option, } @@ -631,6 +633,7 @@ impl Interpretation { None => None, }; it.linear = super::b_p(p, "linearLight"); + it.preserve_rgb = super::b_p(p, "preserveRgb"); // Start Timecode: Use Source File Timecode (`overrideStart: false`), or Override Start // (timecode or a frame number). let start_err = || bad(cmd, "startTimecode: timecode (0:00:41:20) or a frame number, 0 to 1e9"); @@ -705,6 +708,9 @@ impl Interpretation { if let Some(v) = self.linear { f.linear_light = v; } + if let Some(v) = self.preserve_rgb { + f.preserve_rgb = v; + } // 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; @@ -785,7 +791,7 @@ fn interpret(s: &mut Session, p: &Value) -> Result { Some(ItemKind::Footage(f)) => Some(json!({ "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, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), + "linearLight": f.linear_light, "preserveRgb": f.preserve_rgb, "startTimecode": f.timecode(0), "overrideStart": f.start_timecode.is_some(), "frames": f.sequence_frames(), })), _ => None, @@ -815,6 +821,7 @@ fn apply_interpretation(s: &mut Session, p: &Value) -> Result { f.fields = src.fields; f.invert_alpha = src.invert_alpha; f.linear_light = src.linear_light; + f.preserve_rgb = src.preserve_rgb; f.color_profile = src.color_profile; if src.native_rate.is_some() { if f.native_rate.is_none() { @@ -1025,7 +1032,7 @@ pub fn specs() -> Vec { "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?, 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)}", has_footage_selection, interpret ), diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 9b0a01da7..3247a588b 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -318,6 +318,7 @@ pub fn footage_metadata(f: &Footage) -> Value { "alpha": format!("{:?}", f.alpha), "colorProfile": f.color_profile.map(|c| format!("{c:?}")).unwrap_or_else(|| "sRGB (default)".into()), "linearLight": f.linear_light, + "preserveRgb": f.preserve_rgb, "fields": format!("{:?}", f.fields), "sequenceFiles": f.sequence.len(), "dataRows": f.data.as_ref().map(|d| d.lines().count().saturating_sub(1)), diff --git a/crates/engine/src/tests_proxy.rs b/crates/engine/src/tests_proxy.rs index 73ac2ad01..82cd1dcfd 100644 --- a/crates/engine/src/tests_proxy.rs +++ b/crates/engine/src/tests_proxy.rs @@ -272,4 +272,12 @@ fn guess_alpha_invert_and_linear_light() { s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap(); s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); assert!(foot(&s, w).linear_light); + // Color ▸ Preserve RGB (#411) is set, rendered and carried by Apply Interpretation. + let out = s.execute("file.interpretFootage", json!({"item": h.0, "preserveRgb": true})).unwrap(); + assert_eq!(out["items"][0]["preserveRgb"], true); + let p = px(&s, c, 0.0, RenderOpts::default(), 1, 1); + assert!((p[0] - 0.25).abs() < 0.01, "white at 25 %, unconverted: {p:?}"); + s.execute("file.rememberInterpretation", json!({"item": h.0})).unwrap(); + s.execute("file.applyInterpretation", json!({"items": [w.0]})).unwrap(); + assert!(foot(&s, w).preserve_rgb); } diff --git a/crates/media/src/convert.rs b/crates/media/src/convert.rs index 39fa386d2..114873e7a 100644 --- a/crates/media/src/convert.rs +++ b/crates/media/src/convert.rs @@ -6,7 +6,7 @@ //! Values stay gamma-encoded: like After Effects, footage pixels enter the working space as they //! are encoded in the file. Alpha follows the footage's [`AlphaMode`] interpretation. -use effectcraft_project::AlphaMode; +use effectcraft_project::{AlphaMode, Footage}; use effectcraft_raster::{Image, Px}; use filmcraft_color::{Matrix, Range}; use filmcraft_frame::{Chroma, PixelData, VideoFrame}; @@ -17,11 +17,24 @@ use rayon::prelude::*; pub(crate) struct AlphaOp { mode: AlphaMode, matte: [f32; 3], + /// Interpret Footage ▸ Preserve RGB: float values stay as the file stores them (not + /// sRGB-encoded). + preserve_rgb: bool, } impl AlphaOp { pub(crate) fn new(mode: AlphaMode, matte: [f32; 3]) -> Self { - Self { mode, matte } + Self { mode, matte, preserve_rgb: false } + } + /// The interpretation of footage `f`. + pub(crate) fn of(f: &Footage) -> Self { + Self { preserve_rgb: f.preserve_rgb, ..Self::new(f.alpha, f.premul_color) } + } + /// A linear float value as the compositor takes it: sRGB-encoded, or the file's own value + /// under Preserve RGB. + #[inline(always)] + fn float(self, v: f32) -> f32 { + if self.preserve_rgb { v } else { encode(v) } } /// `c` is the file's colour (straight or premultiplied as the mode says), `a` its alpha. #[inline(always)] @@ -72,7 +85,7 @@ pub(crate) fn frame_to_image_in(f: &VideoFrame, op: AlphaOp, mut buf: Vec) - for (o, s) in row.iter_mut().zip(src.as_chunks::<4>().0.iter()) { let a = s[3]; let inv = if a > 0.0 { 1.0 / a } else { 0.0 }; - let c = [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a]; + let c = [op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a]; *o = if op.mode == AlphaMode::Ignore { [c[0], c[1], c[2], 1.0] } else { [c[0], c[1], c[2], a] }; } }); @@ -231,7 +244,7 @@ fn convert_yuv(img: &mut Image, w: usize, h: usize, chroma: Chroma, p } /// Convert a still decoded by the `image` crate. Float images (OpenEXR, float TIFF) hold linear -/// light and are sRGB-encoded here (values above 1 are kept). +/// light and are sRGB-encoded here (values above 1 are kept), unless the footage preserves RGB. pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image { use image::DynamicImage as D; let (w, h) = (img.width(), img.height()); @@ -251,9 +264,9 @@ pub(crate) fn dynamic_to_image(img: &image::DynamicImage, op: AlphaOp) -> Image let a = s[3]; let c = if premul { let inv = if a > 0.0 { 1.0 / a } else { 0.0 }; - [encode(s[0] * inv) * a, encode(s[1] * inv) * a, encode(s[2] * inv) * a] + [op.float(s[0] * inv) * a, op.float(s[1] * inv) * a, op.float(s[2] * inv) * a] } else { - [encode(s[0]), encode(s[1]), encode(s[2])] + [op.float(s[0]), op.float(s[1]), op.float(s[2])] }; *o = op.apply(c, a.clamp(0.0, 1.0)); } diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index a66be05e6..f8651cd62 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -37,7 +37,9 @@ fn colour_bars(w: u32, h: u32) -> Image { struct Key { path: Arc, frame: i64, - alpha: u8, + /// The alpha interpretation (bits 0–1) and Preserve RGB (bit 2): they change the decoded + /// pixels. + interp: u8, matte: [u32; 3], } @@ -516,8 +518,9 @@ impl Inner { AlphaMode::Premultiplied => 1, AlphaMode::Ignore => 2, }; + let interp = alpha | (u8::from(footage.preserve_rgb) << 2); let matte = if footage.alpha == AlphaMode::Premultiplied { footage.premul_color.map(f32::to_bits) } else { [0; 3] }; - let key = |path: &str, frame| Key { path: path.into(), frame, alpha, matte }; + let key = |path: &str, frame| Key { path: path.into(), frame, interp, matte }; match footage.kind { FootageKind::Sequence if !footage.sequence.is_empty() => { let i = Self::frame_index(footage.frame_rate, t, footage.sequence_frames(), footage.loop_count); @@ -607,7 +610,7 @@ impl Inner { fn prefetch(self: &Arc, _footage: &Footage, _t: Tick) {} fn decode(&self, loc: &Loc, footage: &Footage) -> Result { - let op = AlphaOp::new(footage.alpha, footage.premul_color); + let op = AlphaOp::of(footage); let path = &loc.key.path; match loc.media_t { None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), @@ -759,7 +762,7 @@ mod tests { fn lru_respects_budget_and_recycles() { let mut c = Lru::default(); let img = || Arc::new(Image::new(16, 16)); // 4 KiB + overhead - let k = |i| Key { path: "a".into(), frame: i, alpha: 0, matte: [0; 3] }; + let k = |i| Key { path: "a".into(), frame: i, interp: 0, matte: [0; 3] }; let budget = 3 * (4096 + 64); for i in 0..10 { c.insert(k(i), img(), budget); @@ -774,4 +777,27 @@ mod tests { assert_eq!(c.take_spare(256).len(), 256); assert!(c.take_spare(17).is_empty()); } + + /// #411: Interpret Footage ▸ Preserve RGB reads a float OpenEXR's values as the file stores + /// them (no automatic linear → sRGB encode), so an OCIO effect is the only conversion; the + /// default interpretation still encodes. Both interpretations of one file decode separately. + #[test] + fn preserve_rgb_reads_float_exr_values_unconverted() { + use exr::prelude::*; + let (w, h) = (2usize, 2usize); + let ch = |n: &str, v: f32| AnyChannel::new(n, FlatSamples::F32(vec![v; w * h])); + let channels = AnyChannels::sort(vec![ch("R", 0.18), ch("G", 0.5), ch("B", 2.0), ch("A", 1.0)].into()); + let image = exr::image::Image::from_layer(Layer::new((w, h), LayerAttributes::default(), Encoding::FAST_LOSSLESS, channels)); + let mut bytes = std::io::Cursor::new(Vec::new()); + image.write().to_buffered(&mut bytes).unwrap(); + let b: Arc<[u8]> = bytes.into_inner().into(); + let pool = MediaPool::new(); + pool.add_bytes("/render.exr", b.clone()); + let f = crate::probe_bytes("/render.exr", b).unwrap(); + let encoded = pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0); + assert!((encoded[0] - 0.4613).abs() < 1e-3 && (encoded[1] - 0.7354).abs() < 1e-3 && encoded[2] > 1.3, "sRGB-encoded: {encoded:?}"); + let raw = pool.frame_at(&Footage { preserve_rgb: true, ..f.clone() }, Tick::ZERO).unwrap().get(0, 0); + assert_eq!(raw, [0.18, 0.5, 2.0, 1.0]); + assert_eq!(pool.frame_at(&f, Tick::ZERO).unwrap().get(0, 0), encoded, "the default interpretation is cached apart"); + } } diff --git a/crates/project/src/lib.rs b/crates/project/src/lib.rs index 55b7e552a..064ac2e0a 100644 --- a/crates/project/src/lib.rs +++ b/crates/project/src/lib.rs @@ -963,6 +963,11 @@ pub struct Footage { /// Interpret Footage ▸ Color ▸ Interpret As Linear Light: the file's values are linear. #[serde(default, skip_serializing_if = "std::ops::Not::not")] pub linear_light: bool, + /// Interpret Footage ▸ Color ▸ Preserve RGB: the file's values enter the composition + /// unconverted. Float footage (OpenEXR, float TIFF) is not sRGB-encoded on decode, and the + /// 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, /// 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")] @@ -1009,6 +1014,7 @@ impl Default for Footage { fields: FieldOrder::Off, invert_alpha: false, linear_light: false, + preserve_rgb: false, data: None, layer: None, page: 0, @@ -1019,6 +1025,11 @@ impl Default for Footage { } impl Footage { + /// 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 + } + /// Source time as the footage's timecode: its frame `frame` from Start Timecode /// (`0:00:41:20`), as the Footage panel shows it. pub fn timecode(&self, frame: i64) -> String { diff --git a/crates/render/src/color.rs b/crates/render/src/color.rs index aa8829edb..91abef27a 100644 --- a/crates/render/src/color.rs +++ b/crates/render/src/color.rs @@ -17,7 +17,7 @@ //! pixels to their parent). Without a working space the sRGB curve is used. use effectcraft_color::{ColorSpace, Conversion}; -use effectcraft_project::{HdrMode, ProjectSettings}; +use effectcraft_project::{Footage, HdrMode, ProjectSettings}; use effectcraft_raster::Image; use rayon::prelude::*; @@ -57,10 +57,11 @@ impl Pipe { self.space.unwrap_or(ColorSpace::Srgb) } - /// Footage with colour profile `profile` (`None` = sRGB) → working space. - pub fn media_in(&self, profile: Option) -> Option { - let ws = self.space?; - Conversion::new(profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear) + /// Footage `f` from its colour profile (`None` = sRGB) → working space; nothing for + /// Preserve RGB, whose values enter the working space as the file stores them. + pub fn media_in(&self, f: &Footage) -> Option { + let ws = self.space.filter(|_| !f.preserve_rgb)?; + Conversion::new(f.color_profile.unwrap_or(ColorSpace::Srgb), false, ws, self.linear) } /// Authored colours (solids, text, shapes) → working space: linearised in a linear working diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a35566b45..3964a4466 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -1089,7 +1089,7 @@ impl<'a> Renderer<'a> { let k = self.raster_scale(ctx, layer); if let Some(img) = self.footage.vector_frame(*item, f, k) { let mut buf = Buf { img: (*img).clone(), offset: [0.0; 2], scale: k }; - if let Some(c) = self.pipe.media_in(f.color_profile) { + if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); } return Some(buf); @@ -1127,7 +1127,7 @@ impl<'a> Renderer<'a> { } else { Buf { img: img.clone(), offset: [0.0; 2], scale: 1.0 / k } }; - if let Some(c) = self.pipe.media_in(f.color_profile) { + if let Some(c) = self.pipe.media_in(f) { color::convert(&mut buf.img, &c); } buf @@ -1156,7 +1156,7 @@ impl<'a> Renderer<'a> { return Some(Arc::new(interpret_pixels(&field_frame(&img, parity), f))); } let img = self.footage_frame_raw(ctx, layer, item, f, t)?; - if f.invert_alpha || f.linear_light { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } + if f.invert_alpha || f.linear_light_applies() { Some(Arc::new(interpret_pixels(&img, f))) } else { Some(img) } } fn footage_frame_raw(&self, ctx: &EvalCtx, layer: &Layer, item: ItemId, f: &Footage, t: Tick) -> Option> { @@ -2024,7 +2024,8 @@ pub fn field_frame(img: &Image, parity: usize) -> Image { /// Interpret Footage ▸ Invert Alpha and Interpret As Linear Light on decoded (premultiplied) /// pixels. pub fn interpret_pixels(img: &Image, f: &Footage) -> Image { - if !f.invert_alpha && !f.linear_light { + let linear = f.linear_light_applies(); + if !f.invert_alpha && !linear { return img.clone(); } let space = f.color_profile.unwrap_or(effectcraft_color::ColorSpace::Srgb); @@ -2032,7 +2033,7 @@ pub fn interpret_pixels(img: &Image, f: &Footage) -> Image { for p in out.data.iter_mut() { let a = p[3]; let mut c = if a > 1e-6 { [p[0] / a, p[1] / a, p[2] / a] } else { [0.0; 3] }; - if f.linear_light { + if linear { // The file's values are linear light: encode them like the rest of the footage. c = c.map(|v| space.encode(v)); } diff --git a/crates/render/src/tests_color.rs b/crates/render/src/tests_color.rs index 0430a72b6..a45fb69b2 100644 --- a/crates/render/src/tests_color.rs +++ b/crates/render/src/tests_color.rs @@ -198,6 +198,34 @@ fn footage_profile_is_converted_into_the_working_space() { assert!((o[0] - 1.0).abs() < 2e-3 && o[1].abs() < 2e-3 && o[2].abs() < 2e-3, "{o:?}"); } +/// #411: Interpret Footage ▸ Preserve RGB: the footage's values enter the composition as the file +/// stores them, whatever its colour profile, Interpret As Linear Light or the working space. +#[test] +fn preserve_rgb_footage_is_not_converted() { + let render = |linearize: bool, f: Footage| { + let (mut p, cid, comp) = setup(BitDepth::Bpc32); + p.settings.working_space = Some(ColorSpace::Rec2020); + p.settings.linearize = linearize; + let fid = p.add_item("F", Label::Aqua, None, ItemKind::Footage(f)); + let l = build::layer(&mut p, &comp, "F", LayerSource::Footage { item: fid }, (40, 20), None); + p.comp_mut(cid).unwrap().layers.push(l); + let src = Flat([0.18, 0.5, 0.75]); + let out = Renderer::new(&p, &src, RenderOpts::default()).comp_frame(cid, Tick::ZERO).get(5, 5); + // Back from the output conversion (working space → sRGB) to the working space's values. + let ws = effectcraft_color::Conversion::new(ColorSpace::Srgb, false, ColorSpace::Rec2020, linearize).unwrap(); + let c = ws.apply([out[0], out[1], out[2]]); + [c[0], c[1], c[2], out[3]] + }; + let preserve = Footage { preserve_rgb: true, linear_light: true, ..footage(Some(ColorSpace::DisplayP3)) }; + for linearize in [false, true] { + let o = render(linearize, preserve.clone()); + assert!((o[0] - 0.18).abs() < 1e-3 && (o[1] - 0.5).abs() < 1e-3 && (o[2] - 0.75).abs() < 1e-3, "linearize {linearize}: {o:?}"); + // Without Preserve RGB the profile and linear light convert the values. + let o = render(linearize, Footage { preserve_rgb: false, ..preserve.clone() }); + assert!((o[0] - 0.18).abs() > 0.05, "linearize {linearize}: {o:?}"); + } +} + #[test] fn layer_cache_is_keyed_by_colour_settings() { let (mut p, cid, _) = setup(BitDepth::Bpc8); diff --git a/crates/ui-egui/src/panels/forms.rs b/crates/ui-egui/src/panels/forms.rs index f41242f06..a9468451d 100644 --- a/crates/ui-egui/src/panels/forms.rs +++ b/crates/ui-egui/src/panels/forms.rs @@ -458,6 +458,7 @@ pub fn open_form(app: &mut EffectcraftApp, id: &str, p: &Value) -> bool { ], f.color_profile.map_or(0, |c| 1 + effectcraft_engine::project::ColorSpace::ALL.iter().position(|x| *x == c).unwrap_or(0)), ), + Field::bool("preserveRgb", "Preserve RGB", f.preserve_rgb), Field::bool("linearLight", "Interpret As Linear Light", f.linear_light), ]; // After Frame Rate: an image sequence's frames (missing ones show colour bars, as in diff --git a/docs/agents.md b/docs/agents.md index a023154a9..26e32f8cf 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -219,6 +219,9 @@ build real projects through these interfaces; they are worked examples of everyt colour bars, as in After Effects. `file.interpretFootage {"items":[id], "frameRate":12, "startTimecode":1001}` conforms them and sets Override Start (the item and layers that ran to its end get the new length). See [footage.md](footage.md). +* **Colour of footage**: `file.interpretFootage {"items":[id], "preserveRgb":true}` is Interpret + Footage ▸ Preserve RGB: the file's values unconverted (float EXRs stay linear), for a + per-layer OCIO conversion or data passes. * **Multi-layer OpenEXR**: the footage shows the beauty pass (an unnamed RGBA layer, else Combined / Beauty / Image / RGBA…, never Cryptomatte or depth). `layer.channels {layer}` lists a footage layer's EXR layers and diff --git a/docs/footage.md b/docs/footage.md index 3595e9834..9a933f035 100644 --- a/docs/footage.md +++ b/docs/footage.md @@ -62,22 +62,24 @@ used, and layers that ran to the end of the footage keep doing so. | **Alpha** | *Straight - Unmatted*, *Premultiplied - Matted With Color* (with the matte colour), *Ignore* (opaque), or *Guess* (EffectCraft looks at the first frame). *Invert Alpha* flips it. | | **Assume this frame rate** | The rate the frames play at. A 250-frame sequence at 25 fps lasts 10 seconds; at 24 fps, 10.4 seconds. | | **Start Timecode** | *Use Source File Timecode* (0:00:00:00), or *Override Start*: the timecode the footage's first frame shows in the Footage panel. It relabels source time only; the frames stay the same. For a 1001–1250 render at 24 fps, Override Start 0:00:41:17 (frame 1001) makes source time count from the render's own frame numbers. | -| Fields, pixel aspect, loop, colour profile, linear light | As in After Effects. | +| 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. | 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 to other footage. +fields, pixel aspect, loop and colour settings (Preserve RGB too) to other footage. Agents: `file.interpretFootage {"items": [id], "frameRate": 24, "alpha": "premultiplied", -"startTimecode": 1001}` (timecode or a frame number; `"overrideStart": false` goes back to Use -Source File Timecode). +"startTimecode": 1001, "preserveRgb": true}` (timecode or a frame number; `"overrideStart": +false` goes back to Use Source File Timecode). ## Multi-layer OpenEXR Float OpenEXR renders hold linear light; EffectCraft sRGB-encodes them as they are read, like -After Effects with Interpret As Linear Light for 32 bpc. +After Effects with Interpret As Linear Light for 32 bpc, unless the item preserves RGB (above). A multi-layer file shows its main RGBA channels, as in After Effects. Renders that keep every pass in named layers (Blender's `ViewLayer.Combined`, its compositor's File Output `Image`, Nuke's `beauty`) show the beauty pass: the layer named Combined, Beauty, Image, RGBA, RGB or diff --git a/docs/parity.md b/docs/parity.md index 0d52b90bc..215a995ca 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -172,7 +172,7 @@ the disk cache in the formats wave; the 3D Camera Tracker in M12.6.) | 3D | 88% | 5.5 | reflections of other layers (Advanced 3D surfaces reflect only the Environment light; Material Options ▸ Appears in Reflections is stored but has no visible effect yet, #261), multi-view layouts, the Extended Viewer for Advanced 3D comps (Classic 3D Extended Viewer landed in M13.5 UI completion); collapsed precomps of another size seen through the parent's camera render (fixed in M13.2); stereo rigs, orbit nulls, lights controlled by the camera, cameras/lights from glTF models, environment backgrounds, Advanced 3D motion blur, blend modes and track mattes landed in M7.7; Classic 3D iris-shaped bokeh with highlights, progressive depth of field on tilted layers and the focus-link commands landed in M13.5; Advanced 3D depth of field with the iris and highlight options, collapsed precomps as real Advanced 3D geometry and extruded text/shape strokes landed in M13.8; Advanced 3D (glTF/OBJ models, primitives, extruded text and shapes, PBR, image-based light, shadow maps, GPU rasteriser) in M7.4–M7.6; Advanced 3D end to end on the GPU (motion blur, iris depth of field, compositing) in M13.11 | | Effects | ≈ 85% | 3.0 | GPU versions of the remaining effects (280 run on the GPU since M13.29, EFF-5: M13.28 added the remaining pixel effects and the geometry and audio generators (Lightning, Advanced Lightning, Beam, Lens Flare, Radio Waves, Vegas, Stroke, Scribble, Write-on, Paint Bucket, Eyedropper Fill, CC Glue Gun, CC Threads, Audio Spectrum / Waveform, Basic / Path Text); M13.29 added the particle effects (CC Particle World / Systems II, Particle Playground, CC Ball Action, CC Pixel Polly, CC Scatterize, Curl Noise) and removed the fallbacks of Shatter's wireframe views, Foam's extras, Timewarp's Matte Layer, strong Warp Fisheye / Twist bends and custom `.ocio` configs; M13.23 added the 3D Channel and Immersive Video families, Apply Color LUT, the OCIO effects, Color Profile Converter and the simulations' render passes (Card Wipe included); M13.22 added the CC light family, the CC transitions, Block Dissolve, Radial Shadow, CC Bender / Blobbylize / Cylinder / Sphere / Spotlight / Environment, 3D Glasses, Numbers, Timecode and the time effects joined the 166 of M13.13; Fractal and the analysis / tool effects still render on the CPU) and the missing controls listed as partial in [effects.md](effects.md) (every After Effects effect exists since M9.11, M12.5 and M12.6; parameter names, order, twirl-downs, popups, units and defaults were aligned in M9.12) | | Interface | 75% | 5.0 | more Learn tutorials and pixel-level fidelity of dialogs (G2 on 9 October: the viewer's resize cursors point the way each handle scales and turn with the layer, #393; Ctrl/Cmd-click on the Timeline's or the Composition panel's current-time display toggles Timecode / Frames, #370; Tab / Shift+Tab in a value being typed commits it and types in the next / previous value of the panel (Timeline, Effect Controls), #361; right-clicking a property's name in the Timeline opens its menu (Reset, Edit Value…, Separate Dimensions, Add / Remove Expression, Add Property to Essential Graphics), #407; the in-window menu bar works from the keyboard (Alt, arrows, Enter, Escape) and the app's shortcuts wait while a menu is open, #279; the Timeline's composition marker bin and numpad * add comp markers, #275; Settings ▸ Appearance ▸ UI Scale (75–200 %, beyond After Effects, which follows the system's scaling), #284; G2 on 7 October: Project items, files and effects dropped on the Composition viewer, Project items dropped in the Timeline between layers and at a time, #85, #88, #89; popup menus moved to fit the window take clicks, so Render Queue templates apply, #117; View ▸ New Viewer landed in M13.32; an automation id on every control of Composition and Solid Settings, Group / Ungroup Shapes in the Layer menu, Window ▸ Learn, the flowchart shortcuts and Reveal in Explorer landed in M3.10 and M3.13; the Home ▸ Templates gallery (eight original built-in templates, user templates from File ▸ Save as Template…) and View ▸ Simulate Output ▸ My Custom RGB… landed in M13.25; Timeline layer reordering by drag, a non-snapping viewer pan, a working rename field and twirl arrows, and the full set of property reveal shortcuts — double presses, Alt+Shift keyframes, Ctrl+` — landed in M13.17–M13.20; the Home ▸ Learn tab with interactive tutorials and a UI fidelity pass landed in M13.5 UI completion; visual editors for Lumetri RGB / hue-saturation curves, Colorama's output cycle wheel, Glow's colour map and Reshape's correspondence points (viewer handles), a scrolling Preview panel and AE-style Composition / Timeline tabs (close, label swatch, viewer lock) landed in M13.10; Timeline outline and Project panel columns scroll horizontally, the Layer Style dialog, ROI resize handles, Pan Behind snapping and 3D Reference Axes landed in M13.5; native macOS menu bar, Timeline columns/search/reveal-add, Home screen with recent projects and all AE workspaces landed; viewer rulers/snapping/channels/snapshots landed in M0.13) | -| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297) | +| Project | ≈ 68% | 5.5 | OCIO displays beyond the built-in tone map; relative footage paths and relinking when a project moves (unsaved-changes prompts, a modified mark that follows undo and project files that name their version and always reopen landed in M3.9 and M3.14; auto-save and crash recovery in M3.7; Color Engine with OCIO/ACES working spaces, HDR compand/tone mapping, Rec. 2100 PQ/HLG output, Feet + Frames, display colour management, Simulate Output and the locked viewer landed in M7.7; proxies and Interpret Footage fields / pixel aspect / alpha guess landed: PRJ-8, PRJ-3; image sequence import options (Sequence, Force Alphabetical Order, frame rate, picked ranges, folders, the browser), missing-frame placeholders and Interpret Footage Start Timecode landed: #297; Interpret Footage ▸ Preserve RGB: #411) | | Masks & roto | 74% | 5.0 | (audit estimates; Roto Brush 2.0 / 3.0's trained model, MobileSAM in the swappable `segment` module, has since landed in M13.35; variable-width mask feather points with the Mask Feather tool landed in M13.5; mask tracking and Mask Interpolation landed in M6.6; Roto Brush & Refine Edge with graph-cut segmentation, flow propagation, edge matting, decontamination and Freeze in M6.7) | | Preview | 77% | 2.8 | GPU effects for the audio-driven effects and the stepped particle systems; content-keyed RAM preview frames (an edit drops every comp's), audio scrubbing, more snapshot slots (G2 on 7 October: Audio, Lock and Shy switches keep the cached frames and playback with audio waits for frames instead of skipping them, #103; running out of video memory renders the frame on the CPU and keeps fewer GPU frames instead of crashing, #106; RAM preview keys with the render options, least-recently-shown eviction, Auto resolution while playing, Cache Before Playback that fits, an achieved-fps readout, Cache Frames When Idle and purges that clear what they name landed in M4.5–M4.8; 3D channel, VR, OCIO, Card Wipe and the simulations' render passes run on the GPU since M13.23; Advanced 3D layers with blend modes / track mattes / Preserve Transparency and environment backgrounds composite on the GPU since M13.22; pooled GPU textures and fused quantisation since M13.13: Lower Third GPU warm 21 → 4 ms/frame, and Auto now picks the CPU or the GPU per comp from measured frame times; Advanced 3D runs — raster, motion blur, iris depth of field, compositing — and wireframes run on the GPU since M13.11: CPU warm 3394 → GPU warm 290 ms/frame at 1920×1080 (11.7×; Half 968 → 85 ms), GPU ≠ CPU on 0.005 % of pixels, on an M4 Pro under load; Classic 3D bokeh depth of field — iris shapes, highlights, fringe, progressive blur on tilted planes — runs in WGSL since M13.6: 3D Showcase GPU warm 205 → 54 ms/frame at full size on an M4 Pro; the Preview panel's five shortcuts with their own Include / Loop / Cache Before Playback / Range / Play From / Frame Rate / Skip / Resolution / Full Screen / stop options and `playback.settings.get/set` landed in M13.8; Classic 3D runs and adjustment layers composite on the GPU since M12.7; persistent disk cache with the blue cache bar landed; region of interest, snapshots, exposure and Fast Previews landed in M0.13) | | Tracking | ≈ 96% | 0.3 | face tracking's trained model (MediaPipe Face Landmarker, M13.36) is an optional download; without it the classical (skin model + feature components + shape model) fitter is weak on profile views and occluded faces; Rolling Shutter Ripple is approximated by Subspace Warp's mesh density (face tracking (Outline Only / Detailed Features with Face Track Points and Extract & Copy Face Measurements), Subspace Warp's content-preserving mesh warp on subspace-smoothed trajectories, and radial lens distortion (k1, k2) in the camera bundle adjustment with Undistort Footage landed in M13.3; Rolling Shutter Repair in M9.11; point tracker, mask tracking, Warp Stabilizer and the 3D Camera Tracker in M6.x / M12.5 / M12.6) |