From b5fe54c08b3ba657d43ca8029cd4655a966f02d2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 01:33:10 +0100 Subject: [PATCH 1/4] Render Queue OpenEXR: choose compression and half or 32-bit float (#2911) OpenEXR sequence output modules get two settings, exrCompression (none, rle, zips, zip, piz, pxr24, b44, b44a, dwaa, dwab; scan lines in each method's own block height) and exrBitDepth (half or float). The defaults are ZIP and 32-bit float, so existing projects render the same bytes as before. They go through the project file, Output Module templates, the renderQueue commands and renderQueue.formats, the CLI (--exr-compression, --exr-depth), the Output Module menu (lossy methods are marked) and the render log. Half rounds each sample to the nearest half float. Co-Authored-By: Claude Opus 5.5 --- apps/effectcraft-cli/src/main.rs | 13 +- apps/effectcraft-cli/tests/it/cli.rs | 52 ++++++ crates/engine/src/cmd/render_queue.rs | 23 ++- crates/engine/src/render_queue.rs | 5 +- crates/engine/src/tests/codec_options.rs | 50 +++++ crates/export/src/exr_out.rs | 215 ++++++++++++++++++++++ crates/export/src/lib.rs | 16 +- crates/export/tests/it/roundtrip.rs | 99 ++++++++++ crates/project/src/render_queue.rs | 182 ++++++++++++++++++ crates/project/src/render_templates.rs | 15 ++ crates/ui-egui/src/i18n/ui.rs | 33 ++++ crates/ui-egui/src/panels/render_queue.rs | 50 ++++- docs/agents.md | 8 + docs/file-format-parity.md | 6 +- 14 files changed, 744 insertions(+), 23 deletions(-) create mode 100644 crates/export/src/exr_out.rs diff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs index 6ba352247..c49ed26fc 100644 --- a/apps/effectcraft-cli/src/main.rs +++ b/apps/effectcraft-cli/src/main.rs @@ -17,6 +17,7 @@ //! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off] //! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100] //! [--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] +//! [--exr-compression none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab] [--exr-depth half|float] //! (a relative --out is relative to the working directory; --queue outputs follow the project's own settings) //! [--progress] report progress on stderr when it is not a terminal, up to four lines a second: //! `progress ` @@ -113,9 +114,13 @@ const USAGE: &str = "usage: effectcraft-cli ) @@ -189,6 +194,8 @@ const VALUED: &[&str] = &[ "--webm-codec", "--audio-bitrate", "--opus-app", + "--exr-compression", + "--exr-depth", // mcp "--automation-read-root", "--automation-write-root", @@ -876,6 +883,8 @@ fn render(args: &Args, json_out: bool) -> Result<(), Failure> { ("--rate-control", "rateControl"), ("--webm-codec", "webmCodec"), ("--opus-app", "opusApplication"), + ("--exr-compression", "exrCompression"), + ("--exr-depth", "exrBitDepth"), ] { if let Some(v) = args.opt(flag) { p[key] = json!(v); diff --git a/apps/effectcraft-cli/tests/it/cli.rs b/apps/effectcraft-cli/tests/it/cli.rs index e9a96dd7e..dbc27b5e7 100644 --- a/apps/effectcraft-cli/tests/it/cli.rs +++ b/apps/effectcraft-cli/tests/it/cli.rs @@ -973,3 +973,55 @@ fn render_without_progress_writes_no_progress_lines() { let stderr = render_demo_stderr("no-progress.gif", &[]); assert!(!stderr.lines().any(|l| l.starts_with("progress")), "{stderr}"); } + +/// `render --exr-compression C --exr-depth half` writes OpenEXR frames with that compression and +/// half-float channels (#2911); an unknown value is an error naming the parameter. +#[test] +fn render_exr_compression_and_depth() { + let root = tmp("render-exr-options"); + let _ = std::fs::remove_dir_all(&root); + std::fs::create_dir_all(&root).unwrap(); + let project = root.join("x.ecproj"); + let p = project.to_str().unwrap(); + ok_json(&[ + "run", + "comp.new", + r#"{"name":"T","width":64,"height":36,"duration":1}"#, + "layer.newSolid", + r#"{"width":64,"height":36}"#, + "--empty", + "--save-as", + p, + ]); + let out = root.join("f_[#####].exr"); + let reply = ok_json(&[ + "render", + "--project", + p, + "--start", + "0", + "--end", + "0.01", + "--exr-compression", + "piz", + "--exr-depth", + "half", + "--out", + out.to_str().unwrap(), + ]); + assert_eq!(reply["rendered"].as_array().unwrap().len(), 1, "{reply}"); + let bytes = std::fs::read(root.join("f_00000.exr")).unwrap(); + // The `compression` attribute: name, type name, a 1-byte size, then PIZ (4). + let attr = b"compression\0compression\0\x01\0\0\0"; + let at = bytes.windows(attr.len()).position(|w| w == attr).expect("compression attribute"); + assert_eq!(bytes[at + attr.len()], 4, "PIZ"); + // Each channel in `chlist`: its name, then the pixel type (1 = half). + for name in [&b"R\0"[..], b"G\0", b"B\0", b"A\0"] { + let half = [name, &[1, 0, 0, 0]].concat(); + assert!(bytes.windows(half.len()).any(|w| w == half.as_slice()), "{:?} is half float", std::str::from_utf8(name)); + } + let (code, reply) = run_json(&["render", "--project", p, "--exr-depth", "8", "--out", out.to_str().unwrap()]); + assert_eq!(code, 1, "{reply}"); + assert!(reply["error"].as_str().unwrap().contains("exrBitDepth: half|float"), "{reply}"); + let _ = std::fs::remove_dir_all(&root); +} diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index a3934c537..511836d6b 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -6,9 +6,9 @@ //! `index` (1-based, the # column). use effectcraft_project::render_queue::{ - AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, FieldRender, - OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderSettings, - RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, without_project_extension, + AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, DEFAULT_SEQUENCE_TEMPLATE, DEFAULT_TEMPLATE, EffectsMode, ExrBitDepth, + ExrCompression, FieldRender, OutputFormat, OutputModule, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, + RenderQueueItem, RenderSettings, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, post_render_parse, without_project_extension, }; use effectcraft_project::render_templates::{RenderTemplates, TemplateKind, TemplateSlot}; use effectcraft_time::{FrameRate, Tick}; @@ -202,6 +202,9 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) { om.name = t.name.clone(); } +/// The `exrCompression` parameter's values. +const EXR_COMPRESSIONS: &str = "none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab"; + /// The `format` parameter's values. const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff"; @@ -344,6 +347,12 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< if let Some(pp) = str_p(p, "proresProfile") { om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; } + if let Some(c) = enum_p(p, "exrCompression", cmd, ExrCompression::from_name, EXR_COMPRESSIONS)? { + om.exr_compression = c; + } + if let Some(d) = enum_p(p, "exrBitDepth", cmd, ExrBitDepth::from_name, "half|float")? { + om.exr_bit_depth = d; + } apply_crop(om, roi, p, cmd)?; apply_resize(om, p, cmd)?; if let Some(a) = p.get("audio") { @@ -745,6 +754,8 @@ fn formats(s: &mut Session, _: &Value) -> Result { Ok(json!({ "formats": v, "proresProfiles": ProResProfile::ALL.iter().map(|p| p.label()).collect::>(), + "exrCompressions": ExrCompression::ALL.iter().map(|c| json!({"id": c.name(), "label": c.label(), "lossyHalf": c.is_lossy(ExrBitDepth::Half), "lossyFloat": c.is_lossy(ExrBitDepth::Float)})).collect::>(), + "exrBitDepths": ExrBitDepth::ALL.iter().map(|d| d.name()).collect::>(), "codecProfiles": CodecProfile::ALL.iter().map(|p| p.label()).collect::>(), "hevcLevels": VideoCodecOptions::HEVC_LEVELS.iter().map(level).collect::>(), "av1Levels": VideoCodecOptions::AV1_LEVELS.iter().map(level).collect::>(), @@ -1036,7 +1047,7 @@ pub fn specs() -> Vec { "Add to Render Queue", ["Composition"], ["Cmd+M", "Cmd+Shift+/"], - "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", + "{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, exrCompression?, exrBitDepth?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}", can_add, add ), @@ -1056,7 +1067,7 @@ pub fn specs() -> Vec { "Output Module Settings...", [], None, - "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", + "{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, exrCompression?: none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab (OpenEXR, scan lines; default zip; lossy: pxr24 on float, b44/b44a on half, dwaa/dwab), exrBitDepth?: half|float (OpenEXR; default float), profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}", has_items, set_output_module ), @@ -1074,7 +1085,7 @@ pub fn specs() -> Vec { "Add Output Module", ["Composition"], None, - "{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, audio?}", + "{item?|index? (default: the last item), format?, output?, channels?, quality?, bitrate?, proresProfile?, exrCompression?, exrBitDepth?, audio?}", has_items, add_output_module ), diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 41474ceef..efe9e6678 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -181,6 +181,8 @@ pub fn output_bits(format: OutputFormat, depth: effectcraft_project::BitDepth) - BitDepth::Bpc32 => 32, }; match format { + // The default; a module's half float ([`OutputModule::exr_bit_depth`]) is read where the + // module is known. OutputFormat::ExrSequence => 32, OutputFormat::PngSequence | OutputFormat::TiffSequence => project.min(16), OutputFormat::ProRes => 10, @@ -460,7 +462,8 @@ impl Session { Some(p) if formats.is_none_or(|f| f.contains(&om.format)) => { // Settings ▸ Export: Append Bit Depth to File Name, Segment Sequences / // Movie Files. - let bits = output_bits(om.format, self.project.settings.bit_depth); + let bits = + if om.format == OutputFormat::ExrSequence { om.exr_bit_depth.bits() } else { output_bits(om.format, self.project.settings.bit_depth) }; let p = self.prefs.output_name(&p, bits); modules.extend(self.segments(&m, &p)); } diff --git a/crates/engine/src/tests/codec_options.rs b/crates/engine/src/tests/codec_options.rs index af7249329..9fee9d7cb 100644 --- a/crates/engine/src/tests/codec_options.rs +++ b/crates/engine/src/tests/codec_options.rs @@ -171,3 +171,53 @@ fn an_unknown_comp_is_named_in_the_error() { assert!(e.contains(&format!("no composition {name}")), "{cmd}: {e}"); } } + +/// OpenEXR compression and bit depth over the command API (#2911): set on add, on an Output +/// Module and on a second module, listed by `renderQueue.formats`, refused when unknown, and kept +/// through a project save and load. A module that doesn't name them stays ZIP, 32-bit float. +#[test] +fn exr_compression_and_bit_depth_options() { + use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression}; + let mut s = session(); + let a = s.execute("renderQueue.add", json!({"format": "exr"})).unwrap(); + let om = &s.project.render_queue[0].output; + assert_eq!((om.format, om.exr_compression, om.exr_bit_depth), (OutputFormat::ExrSequence, ExrCompression::Zip, ExrBitDepth::Float)); + let id = a["item"].clone(); + s.execute("renderQueue.setOutputModule", json!({"item": id, "exrCompression": "piz", "exrBitDepth": "half"})).unwrap(); + let om = &s.project.render_queue[0].output; + assert_eq!((om.exr_compression, om.exr_bit_depth, om.name.as_str()), (ExrCompression::Piz, ExrBitDepth::Half, "Custom")); + s.execute("renderQueue.setOutputModule", json!({"item": id, "exrCompression": "DWAB", "exrBitDepth": 32})).unwrap(); + let om = &s.project.render_queue[0].output; + assert_eq!((om.exr_compression, om.exr_bit_depth), (ExrCompression::Dwab, ExrBitDepth::Float)); + for bad in [json!({"exrCompression": "htj2k"}), json!({"exrBitDepth": "8"}), json!({"exrCompression": 3})] { + let mut p = bad.clone(); + p["item"] = id.clone(); + assert!(s.execute("renderQueue.setOutputModule", p).is_err(), "{bad}"); + } + assert_eq!(s.project.render_queue[0].output.exr_compression, ExrCompression::Dwab, "a refused value changes nothing"); + s.execute("render.addOutputModule", json!({"item": id, "format": "exr", "exrCompression": "b44a", "exrBitDepth": "half"})).unwrap(); + let extra = &s.project.render_queue[0].extra_outputs[0]; + assert_eq!((extra.exr_compression, extra.exr_bit_depth), (ExrCompression::B44a, ExrBitDepth::Half)); + let b = s.execute("renderQueue.add", json!({"format": "exr", "exrCompression": "rle", "exrBitDepth": "16"})).unwrap(); + assert_eq!(b["output"]["exr_compression"], "Rle", "{b}"); + let log = s.project.render_queue[1].output.describe(); + assert!(log.contains(&("Compression", "RLE".to_string())) && log.contains(&("Bit Depth", "16-bit Half Float".to_string())), "{log:?}"); + + let f = s.execute("renderQueue.formats", json!({})).unwrap(); + let ids: Vec<&str> = f["exrCompressions"].as_array().unwrap().iter().filter_map(|c| c["id"].as_str()).collect(); + assert_eq!(ids, ["none", "rle", "zips", "zip", "piz", "pxr24", "b44", "b44a", "dwaa", "dwab"]); + assert_eq!(f["exrBitDepths"], json!(["half", "float"])); + + // Saved and opened again, the project keeps them. + let dir = std::env::temp_dir().join(format!("ec-exr-options-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("exr.ecproj"); + std::fs::write(&path, s.project.to_file_json().unwrap()).unwrap(); + let mut t = Session::default(); + t.execute("file.open", json!({"path": path})).unwrap(); + let q = &t.project.render_queue; + assert_eq!((q[0].output.exr_compression, q[0].output.exr_bit_depth), (ExrCompression::Dwab, ExrBitDepth::Float)); + assert_eq!((q[0].extra_outputs[0].exr_compression, q[0].extra_outputs[0].exr_bit_depth), (ExrCompression::B44a, ExrBitDepth::Half)); + assert_eq!((q[1].output.exr_compression, q[1].output.exr_bit_depth), (ExrCompression::Rle, ExrBitDepth::Half)); + let _ = std::fs::remove_dir_all(&dir); +} diff --git a/crates/export/src/exr_out.rs b/crates/export/src/exr_out.rs new file mode 100644 index 000000000..a23bbe236 --- /dev/null +++ b/crates/export/src/exr_out.rs @@ -0,0 +1,215 @@ +//! OpenEXR sequence frames: one single-part scan-line file per frame, RGBA, with the Output +//! Module's compression and sample type (#2911). +//! +//! Scan lines, not tiles: `image`'s encoder wrote 64×64 RLE tiles, which FilmCraft and other +//! scan-line readers refuse (#2805). Each compression keeps its own block height (1 line for +//! None, RLE and ZIPS; 16 for ZIP and PXR24; 32 for PIZ, B44, B44A and DWAA; 256 for DWAB) and the +//! `exr` crate's default levels for ZIP and DWA. Half-float samples are rounded from the 32-bit +//! render to the nearest half, ties to even. The lossy methods (PXR24 on float, B44 / B44A on half, +//! DWAA / DWAB at either depth) keep every linear sample within 0.03 + 6 % of its value in the +//! tests below. DWAA / DWAB files come out about the same size at either depth. + +use std::io::{Seek, Write}; + +use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression}; +use exr::prelude::{Blocks, Compression, Encoding, Image, Layer, LayerAttributes, LineOrder, SpecificChannels, Vec2, WritableImage, f16}; + +/// The `exr` crate's compression for an Output Module setting. +pub(crate) fn compression(c: ExrCompression) -> Compression { + match c { + ExrCompression::None => Compression::Uncompressed, + ExrCompression::Rle => Compression::RLE, + ExrCompression::Zips => Compression::ZIP1, + ExrCompression::Zip => Compression::ZIP16, + ExrCompression::Piz => Compression::PIZ, + ExrCompression::Pxr24 => Compression::PXR24, + ExrCompression::B44 => Compression::B44, + ExrCompression::B44a => Compression::B44A, + ExrCompression::Dwaa => Compression::DWAA(None), + ExrCompression::Dwab => Compression::DWAB(None), + } +} + +/// Scan lines, top to bottom, with compression `c`. +fn encoding(c: ExrCompression) -> Encoding { + Encoding { compression: compression(c), blocks: Blocks::ScanLines, line_order: LineOrder::Increasing } +} + +/// Write `w`×`h` RGBA pixels (`data`: four linear, premultiplied samples per pixel, rows top to +/// bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data` +/// doesn't cover is written as transparent black. +pub(crate) fn write_rgba( + file: &mut W, + data: &[f32], + (w, h): (usize, usize), + (c, depth): (ExrCompression, ExrBitDepth), +) -> exr::error::UnitResult { + let row = w.saturating_mul(4); + let px = move |p: Vec2| -> [f32; 4] { + let o = p.y().saturating_mul(row).saturating_add(p.x().saturating_mul(4)); + match data.get(o..o.saturating_add(4)) { + Some(&[r, g, b, a]) => [r, g, b, a], + _ => [0.0; 4], + } + }; + match depth { + ExrBitDepth::Float => { + let image = Image::from_layer(Layer::new( + (w, h), + LayerAttributes::default(), + encoding(c), + SpecificChannels::rgba(move |p: Vec2| { + let [r, g, b, a] = px(p); + (r, g, b, a) + }), + )); + image.write().to_buffered(file) + } + ExrBitDepth::Half => { + let image = Image::from_layer(Layer::new( + (w, h), + LayerAttributes::default(), + encoding(c), + SpecificChannels::rgba(move |p: Vec2| { + let [r, g, b, a] = px(p).map(f16::from_f32); + (r, g, b, a) + }), + )); + image.write().to_buffered(file) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use exr::prelude::{ReadChannels, ReadLayers}; + use exr::prelude::{SampleType, read}; + use std::io::Cursor; + + /// A frame with a smooth gradient, grain, over-range highlights, a hard-edged high-contrast + /// block and a soft alpha ramp (premultiplied), like a render with text over a plate. + fn frame(w: usize, h: usize) -> Vec { + let mut s = 0x9e37_79b9u32; + let mut grain = move || { + s ^= s << 13; + s ^= s >> 17; + s ^= s << 5; + (s as f32 / u32::MAX as f32 - 0.5) * 0.02 + }; + let mut d = Vec::with_capacity(w * h * 4); + for y in 0..h { + for x in 0..w { + let (fx, fy) = (x as f32 / w as f32, y as f32 / h as f32); + let a = ((1.0 - fx) * 3.0).clamp(0.0, 1.0); + let text = (w / 3..2 * w / 3).contains(&x) && (h / 3..h / 2).contains(&y) && (x % 6 < 2 || y % 7 == 0); + let c = if text { [6.0; 3] } else { [0.18 + 0.8 * fx, 0.05 + 0.6 * fy, 0.4 * (1.0 - fx) + 0.2 * fy] }; + let g = grain(); + d.extend_from_slice(&[(c[0] + g).max(0.0) * a, (c[1] + g).max(0.0) * a, (c[2] + g).max(0.0) * a, a]); + } + } + d + } + + fn write(data: &[f32], size: (usize, usize), c: ExrCompression, depth: ExrBitDepth) -> Vec { + let mut buf = Cursor::new(Vec::new()); + write_rgba(&mut buf, data, size, (c, depth)).unwrap(); + buf.into_inner() + } + + /// The samples of an RGBA file as f32, and the sample type it stores. + fn read_back(bytes: &[u8]) -> (Vec, SampleType) { + let image = read() + .no_deep_data() + .largest_resolution_level() + .rgba_channels( + |res, _| (res.width(), vec![0f32; res.width() * res.height() * 4]), + |(w, v): &mut (usize, Vec), p: Vec2, (r, g, b, a): (f32, f32, f32, f32)| { + let o = (p.y() * *w + p.x()) * 4; + v[o..o + 4].copy_from_slice(&[r, g, b, a]); + }, + ) + .first_valid_layer() + .all_attributes() + .from_buffered(Cursor::new(bytes)) + .unwrap(); + let ty = image.layer_data.channel_data.channels.0.sample_type; + (image.layer_data.channel_data.pixels.1, ty) + } + + /// Every compression at both depths reads back: the lossless ones exactly (half: exactly the + /// f16 rounding of the render), the lossy ones within the tolerance the docs give. The project + /// crate's lossy table matches the `exr` crate's own. + #[test] + fn every_compression_and_depth_round_trips() { + let (w, h) = (97, 61); + let src = frame(w, h); + for depth in ExrBitDepth::ALL { + let want: Vec = match depth { + ExrBitDepth::Float => src.clone(), + ExrBitDepth::Half => src.iter().map(|v| f16::from_f32(*v).to_f32()).collect(), + }; + let ty = if depth == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 }; + for c in ExrCompression::ALL { + assert_eq!(c.is_lossy(depth), !compression(c).is_lossless_for(ty), "{c:?} {depth:?}"); + let (got, stored) = read_back(&write(&src, (w, h), c, depth)); + assert_eq!(stored, ty, "{c:?} {depth:?}"); + assert_eq!(got.len(), want.len()); + if c.is_lossy(depth) { + for (i, (g, s)) in got.iter().zip(&want).enumerate() { + assert!((g - s).abs() <= 0.03 + 0.06 * s.abs(), "{c:?} {depth:?} sample {i}: {g} vs {s}"); + } + } else { + assert!(got.iter().zip(&want).all(|(g, s)| g.to_bits() == s.to_bits()), "{c:?} {depth:?} is not exact"); + } + } + } + } + + /// Files are single-part scan-line images (no tile bit in the version field) with the chosen + /// compression, also when the frame spans several blocks and ends in a partial one. + #[test] + fn frames_are_scan_lines_with_the_chosen_compression() { + let (w, h) = (80, 300); + let src = frame(w, h); + for depth in ExrBitDepth::ALL { + for c in ExrCompression::ALL { + let bytes = write(&src, (w, h), c, depth); + assert_eq!(&bytes[..4], &[0x76, 0x2f, 0x31, 0x01], "magic"); + assert_eq!(&bytes[4..6], &[2, 0], "{c:?} {depth:?}: version 2, no tile / multi-part flags"); + let meta = exr::meta::MetaData::read_from_buffered(Cursor::new(&bytes), false).unwrap(); + assert_eq!(meta.headers.len(), 1); + assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines, "{c:?}"); + assert_eq!(meta.headers[0].compression, compression(c), "{c:?}"); + assert_eq!(meta.headers[0].line_order, LineOrder::Increasing); + } + } + } + + /// The default (ZIP, 32-bit float) writes exactly what the Render Queue wrote before the + /// setting existed (#2812), so re-rendering an existing project changes no file. + #[test] + fn the_default_matches_the_earlier_output_byte_for_byte() { + let (w, h) = (64, 48); + let src = frame(w, h); + let pixels = SpecificChannels::rgba(|p: Vec2| { + let o = p.y() * w * 4 + p.x() * 4; + (src[o], src[o + 1], src[o + 2], src[o + 3]) + }); + let layer = Layer::new((w, h), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels); + let mut before = Cursor::new(Vec::new()); + Image::from_layer(layer).write().to_buffered(&mut before).unwrap(); + let default = (ExrCompression::default(), ExrBitDepth::default()); + assert_eq!(default, (ExrCompression::Zip, ExrBitDepth::Float)); + assert_eq!(write(&src, (w, h), default.0, default.1), before.into_inner()); + } + + /// Data shorter than the frame (never produced by the Render Queue) writes transparent black + /// for the missing pixels instead of panicking. + #[test] + fn short_data_is_padded_not_a_panic() { + let (got, _) = read_back(&write(&[1.0, 0.5, 0.25, 1.0], (3, 2), ExrCompression::Zip, ExrBitDepth::Half)); + assert_eq!(&got[..4], &[1.0, 0.5, 0.25, 1.0]); + assert!(got[4..].iter().all(|v| *v == 0.0)); + } +} diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 560d07978..c094d518c 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -6,7 +6,7 @@ //! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo | //! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — | -//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — | +//! | OpenEXR sequence | one file per frame, scan lines | 32-bit float (default) or half float, linear light, premultiplied; compression None, RLE, ZIPS, ZIP (default), PIZ, PXR24, B44, B44A, DWAA, DWAB | yes | — | //! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — | //! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — | //! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz | @@ -34,6 +34,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod encode; +mod exr_out; mod hevc_av1; mod out; mod pipeline; @@ -326,16 +327,9 @@ fn write_still(cx: &Cx, file: &mut W, img: &I data.extend_from_slice(&[c[0], c[1], c[2], a]); } } - // Scan lines with ZIP like `comp.saveFrameAsExr`: `image`'s encoder writes 64×64 RLE tiles, - // which FilmCraft and other scan-line readers refuse (#2805). - use exr::prelude::{Encoding, Layer, LayerAttributes, SpecificChannels, Vec2, WritableImage}; - let row = w as usize * 4; - let pixels = SpecificChannels::rgba(|p: Vec2| { - let o = p.y() * row + p.x() * 4; - (data[o], data[o + 1], data[o + 2], data[o + 3]) - }); - let layer = Layer::new((w as usize, h as usize), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels); - exr::image::Image::from_layer(layer).write().to_buffered(file).map_err(|e| ExportError::Encode(e.to_string())) + // Scan lines with the module's compression and sample type (`exr_out`). + let om = cx.output; + exr_out::write_rgba(file, &data, (w as usize, h as usize), (om.exr_compression, om.exr_bit_depth)).map_err(|e| ExportError::Encode(e.to_string())) } OutputFormat::JpegSequence => { let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h); diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index fbad2ca48..e27434248 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -318,6 +318,105 @@ fn exr_sequence_writes_scan_lines() { assert_eq!(meta.headers[0].compression, exr::compression::Compression::ZIP16); } +/// The RGBA samples of an EXR file, as stored (f16 widened to f32), and their sample types. +fn exr_samples(path: &Path) -> (Vec, Vec) { + use exr::prelude::{ReadChannels, ReadLayers}; + let image = exr::prelude::read() + .no_deep_data() + .largest_resolution_level() + .rgba_channels( + |res, _| (res.width(), vec![0f32; res.width() * res.height() * 4]), + |(w, v): &mut (usize, Vec), p: exr::prelude::Vec2, (r, g, b, a): (f32, f32, f32, f32)| { + let o = (p.y() * *w + p.x()) * 4; + v[o..o + 4].copy_from_slice(&[r, g, b, a]); + }, + ) + .first_valid_layer() + .all_attributes() + .from_file(path) + .expect("read exr"); + let c = &image.layer_data.channel_data.channels; + (image.layer_data.channel_data.pixels.1, vec![c.0.sample_type, c.1.sample_type, c.2.sample_type, c.3.as_ref().expect("alpha").sample_type]) +} + +/// Every OpenEXR compression at both bit depths renders through the Render Queue as scan lines +/// with that compression and sample type, and reads back through the media pool (#2911): +/// lossless 32-bit float matches the uncompressed render exactly, lossless half float stores +/// exactly the half rounding of it, and lossy methods stay within the tolerance the docs give. +#[test] +fn exr_compressions_and_bit_depths_round_trip() { + use effectcraft_export::render_queue::{ExrBitDepth, ExrCompression}; + use exr::prelude::{SampleType, f16}; + let (p, cid) = project(None); + let render = |c: ExrCompression, d: ExrBitDepth| { + let dir = out_dir(&format!("seq-exr-{}-{}", c.name(), d.name())); + let om = OutputModule { channels: Channels::Rgba, exr_compression: c, exr_bit_depth: d, ..OutputModule::for_format(OutputFormat::ExrSequence) }; + run(&p, cid, &NoFootage, &om, &dir.join("f_[#####].exr")); + dir.join("f_00007.exr") + }; + let pooled = |path: &Path| { + let f = effectcraft_media::probe(path).expect("probe"); + MediaPool::new().frame_at(&f, Tick(0)).expect("decode") + }; + let reference = render(ExrCompression::None, ExrBitDepth::Float); + let (want, _) = exr_samples(&reference); + let want_pooled = pooled(&reference); + assert!(want.iter().any(|v| *v > 0.5) && want.iter().any(|v| *v == 0.0), "the frame has red, background and transparent corners"); + for d in ExrBitDepth::ALL { + let ty = if d == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 }; + for c in ExrCompression::ALL { + let path = render(c, d); + let bytes = std::fs::read(&path).expect("exr"); + assert_eq!(&bytes[4..6], &[2, 0], "{c:?} {d:?}: single-part scan lines"); + let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header"); + assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines, "{c:?} {d:?}"); + assert_eq!(meta.headers[0].compression.to_string(), exr_compression_name(c), "{c:?} {d:?}"); + let (got, types) = exr_samples(&path); + assert_eq!(types, [ty; 4], "{c:?} {d:?}"); + let lossy = c.is_lossy(d); + for (i, (g, w)) in got.iter().zip(&want).enumerate() { + let w = if d == ExrBitDepth::Half { f16::from_f32(*w).to_f32() } else { *w }; + if lossy { + assert!((g - w).abs() <= 0.03 + 0.06 * w.abs(), "{c:?} {d:?} sample {i}: {g} vs {w}"); + } else { + assert_eq!(g.to_bits(), w.to_bits(), "{c:?} {d:?} sample {i}: {g} vs {w}"); + } + } + let img = pooled(&path); + assert_eq!((img.width, img.height), (W, H)); + let tol = if lossy { + 0.06 + } else if d == ExrBitDepth::Half { + 2e-3 + } else { + 0.0 + }; + for (k, (a, b)) in img.data.iter().zip(&want_pooled.data).enumerate() { + assert!(a.iter().zip(b).all(|(x, y)| (x - y).abs() <= tol), "{c:?} {d:?} pixel {k}: {a:?} vs {b:?}"); + } + } + } +} + +/// The `exr` crate's name for the compression an Output Module setting writes. +fn exr_compression_name(c: effectcraft_export::render_queue::ExrCompression) -> String { + use effectcraft_export::render_queue::ExrCompression::*; + use exr::compression::Compression as X; + match c { + None => X::Uncompressed, + Rle => X::RLE, + Zips => X::ZIP1, + Zip => X::ZIP16, + Piz => X::PIZ, + Pxr24 => X::PXR24, + B44 => X::B44, + B44a => X::B44A, + Dwaa => X::DWAA(Option::None), + Dwab => X::DWAB(Option::None), + } + .to_string() +} + /// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour. fn bright_project(color: [f32; 3]) -> (Project, ItemId) { let mut p = Project::default(); diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index c40096905..cdada7cf0 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -895,6 +895,136 @@ impl ProResProfile { } } +/// OpenEXR sequence compression. Every method writes scan lines, in blocks of the method's own +/// height (1 line for None, RLE and ZIPS; 16 for ZIP and PXR24; 32 for PIZ, B44, B44A and DWAA; +/// 256 for DWAB). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExrCompression { + None, + Rle, + /// ZIP, one scan line per block. + Zips, + /// ZIP, 16 scan lines per block. + #[default] + Zip, + Piz, + /// Lossy for 32-bit float (rounded to 24 bits), lossless for half float. + Pxr24, + /// Lossy for half float; 32-bit float channels are stored uncompressed. + B44, + /// B44 that also packs flat areas smaller; lossy for half float, 32-bit float is stored uncompressed. + B44a, + /// Lossy DCT, 32 scan lines per block. + Dwaa, + /// Lossy DCT, 256 scan lines per block. + Dwab, +} + +impl ExrCompression { + pub const ALL: [ExrCompression; 10] = [ + ExrCompression::None, + ExrCompression::Rle, + ExrCompression::Zips, + ExrCompression::Zip, + ExrCompression::Piz, + ExrCompression::Pxr24, + ExrCompression::B44, + ExrCompression::B44a, + ExrCompression::Dwaa, + ExrCompression::Dwab, + ]; + /// The parameter value (`exrCompression`). + pub fn name(self) -> &'static str { + match self { + ExrCompression::None => "none", + ExrCompression::Rle => "rle", + ExrCompression::Zips => "zips", + ExrCompression::Zip => "zip", + ExrCompression::Piz => "piz", + ExrCompression::Pxr24 => "pxr24", + ExrCompression::B44 => "b44", + ExrCompression::B44a => "b44a", + ExrCompression::Dwaa => "dwaa", + ExrCompression::Dwab => "dwab", + } + } + pub fn label(self) -> &'static str { + match self { + ExrCompression::None => "None", + ExrCompression::Rle => "RLE", + ExrCompression::Zips => "ZIPS (1 scan line)", + ExrCompression::Zip => "ZIP (16 scan lines)", + ExrCompression::Piz => "PIZ", + ExrCompression::Pxr24 => "PXR24", + ExrCompression::B44 => "B44", + ExrCompression::B44a => "B44A", + ExrCompression::Dwaa => "DWAA", + ExrCompression::Dwab => "DWAB", + } + } + /// Whether the written samples can differ from the rendered ones at `depth` (half float is + /// already rounded from the 32-bit render; that rounding is not counted here). + pub fn is_lossy(self, depth: ExrBitDepth) -> bool { + match self { + ExrCompression::None | ExrCompression::Rle | ExrCompression::Zips | ExrCompression::Zip | ExrCompression::Piz => false, + ExrCompression::Pxr24 => depth == ExrBitDepth::Float, + ExrCompression::B44 | ExrCompression::B44a => depth == ExrBitDepth::Half, + ExrCompression::Dwaa | ExrCompression::Dwab => true, + } + } + /// Parse a compression name: `none`/`uncompressed`, `rle`, `zips`/`zip1` (Nuke's "Zip (1 + /// scanline)"), `zip`/`zip16`, + /// `piz`, `pxr24`, `b44`, `b44a`, `dwaa`, `dwab` (any case). + pub fn from_name(s: &str) -> Option { + let n = s.to_ascii_lowercase().replace([' ', '-', '_', '(', ')'], ""); + match n.as_str() { + "uncompressed" | "off" => Some(ExrCompression::None), + "zip1" | "zips1scanline" | "zip1scanline" | "zip1scanlines" => Some(ExrCompression::Zips), + "zip16" | "zip16scanlines" => Some(ExrCompression::Zip), + _ => ExrCompression::ALL.into_iter().find(|c| c.name() == n), + } + } +} + +/// OpenEXR sequence sample type: 16-bit half float (the usual VFX interchange) or 32-bit float. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub enum ExrBitDepth { + Half, + #[default] + Float, +} + +impl ExrBitDepth { + pub const ALL: [ExrBitDepth; 2] = [ExrBitDepth::Half, ExrBitDepth::Float]; + /// The parameter value (`exrBitDepth`). + pub fn name(self) -> &'static str { + match self { + ExrBitDepth::Half => "half", + ExrBitDepth::Float => "float", + } + } + pub fn label(self) -> &'static str { + match self { + ExrBitDepth::Half => "16-bit Half Float", + ExrBitDepth::Float => "32-bit Float", + } + } + pub fn bits(self) -> u32 { + match self { + ExrBitDepth::Half => 16, + ExrBitDepth::Float => 32, + } + } + /// Parse `half`/`16`/`f16` or `float`/`32`/`f32` (any case). + pub fn from_name(s: &str) -> Option { + match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() { + "half" | "16" | "f16" | "16bit" | "halffloat" | "16bithalffloat" => Some(ExrBitDepth::Half), + "float" | "32" | "f32" | "32bit" | "full" | "fullfloat" | "32bitfloat" => Some(ExrBitDepth::Float), + _ => None, + } + } +} + /// HEVC / AV1 profile: 8-bit (HEVC Main, AV1 Main 8-bit) or 10-bit (HEVC Main 10, AV1 Main 10-bit). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub enum CodecProfile { @@ -1065,6 +1195,10 @@ pub struct OutputModule { /// Movies: frames between key frames (0 = automatic, two seconds). pub keyframe_interval: u32, pub prores_profile: ProResProfile, + /// OpenEXR sequence: compression (scan lines). + pub exr_compression: ExrCompression, + /// OpenEXR sequence: half or 32-bit float samples. + pub exr_bit_depth: ExrBitDepth, pub crop: Crop, pub resize: Resize, pub audio: AudioOutput, @@ -1099,6 +1233,8 @@ impl Default for OutputModule { webm_bitrate: false, keyframe_interval: 0, prores_profile: ProResProfile::Hq, + exr_compression: ExrCompression::Zip, + exr_bit_depth: ExrBitDepth::Float, crop: Crop::default(), resize: Resize::default(), audio: AudioOutput::Auto, @@ -1155,6 +1291,10 @@ impl OutputModule { match self.format { OutputFormat::H264 => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))), OutputFormat::ProRes => v.push(("Codec", self.prores_profile.label().into())), + OutputFormat::ExrSequence => { + v.push(("Compression", self.exr_compression_label())); + v.push(("Bit Depth", self.exr_bit_depth.label().into())); + } OutputFormat::WebM if self.webm_bitrate => v.push(("Bitrate", format!("{} kbps", self.bitrate_kbps))), OutputFormat::WebM | OutputFormat::JpegSequence => v.push(("Quality", self.quality.to_string())), _ => {} @@ -1215,6 +1355,11 @@ impl OutputModule { } self.output = format!("{stem}.{}", format.extension()); } + /// The OpenEXR compression's label, marked when it is lossy at the module's bit depth. + pub fn exr_compression_label(&self) -> String { + let c = self.exr_compression; + if c.is_lossy(self.exr_bit_depth) { format!("{} (lossy)", c.label()) } else { c.label().into() } + } /// Whether this module can write alpha (the format can, and WebM uses VP9). pub fn supports_alpha(&self) -> bool { self.format.supports_alpha() && !(self.format == OutputFormat::WebM && self.webm_codec == WebmVideoCodec::Av1) @@ -1246,6 +1391,7 @@ impl OutputModule { if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() } ), OutputFormat::Hevc | OutputFormat::Av1 => format!("{} · {}", self.format.label(), self.codec_summary()), + OutputFormat::ExrSequence => format!("{} · {} · {} · {ch}", self.format.label(), self.exr_compression_label(), self.exr_bit_depth.label()), OutputFormat::WebM if self.webm_codec == WebmVideoCodec::Av1 => { format!("WebM (AV1 + Opus {} kbps) · {}", self.opus_bitrate_kbps, self.codec_summary()) } @@ -1895,4 +2041,40 @@ mod tests { let old: OutputModule = serde_json::from_str(r#"{"format":"H264","bitrate_kbps":5000}"#).unwrap(); assert_eq!((old.codec, old.webm_codec, old.opus_bitrate_kbps), (VideoCodecOptions::default(), WebmVideoCodec::Vp9, 192)); } + + /// OpenEXR compression and bit depth (#2911): names parse, lossy methods are marked for the + /// depth they lose data at, and modules saved before the settings existed load as ZIP, 32-bit + /// float (what those projects rendered). + #[test] + fn exr_compression_and_bit_depth() { + for c in ExrCompression::ALL { + assert_eq!(ExrCompression::from_name(c.name()), Some(c)); + assert_eq!(ExrCompression::from_name(&c.name().to_ascii_uppercase()), Some(c)); + } + assert_eq!(ExrCompression::from_name("uncompressed"), Some(ExrCompression::None)); + assert_eq!(ExrCompression::from_name("zip16"), Some(ExrCompression::Zip)); + assert_eq!(ExrCompression::from_name("ZIP1"), Some(ExrCompression::Zips)); + assert_eq!(ExrCompression::from_name("Zip (1 scanline)"), Some(ExrCompression::Zips)); + assert_eq!(ExrCompression::from_name("Zip (16 scanlines)"), Some(ExrCompression::Zip)); + assert_eq!(ExrCompression::from_name("htj2k"), None); + assert_eq!(ExrBitDepth::from_name("16"), Some(ExrBitDepth::Half)); + assert_eq!(ExrBitDepth::from_name("Half"), Some(ExrBitDepth::Half)); + assert_eq!(ExrBitDepth::from_name("f32"), Some(ExrBitDepth::Float)); + assert_eq!(ExrBitDepth::from_name("8"), None); + let lossy = |d| ExrCompression::ALL.into_iter().filter(|c| c.is_lossy(d)).map(ExrCompression::name).collect::>(); + assert_eq!(lossy(ExrBitDepth::Float), ["pxr24", "dwaa", "dwab"]); + assert_eq!(lossy(ExrBitDepth::Half), ["b44", "b44a", "dwaa", "dwab"]); + + let mut m = OutputModule::for_format(OutputFormat::ExrSequence); + assert_eq!((m.exr_compression, m.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float)); + assert_eq!(m.summary(), "OpenEXR Sequence · ZIP (16 scan lines) · 32-bit Float · RGB"); + m.exr_compression = ExrCompression::Dwaa; + m.exr_bit_depth = ExrBitDepth::Half; + assert!(m.describe().contains(&("Compression", "DWAA (lossy)".to_string())), "{:?}", m.describe()); + assert!(m.describe().contains(&("Bit Depth", "16-bit Half Float".to_string()))); + let json = serde_json::to_string(&m).unwrap(); + assert_eq!(serde_json::from_str::(&json).unwrap(), m); + let old: OutputModule = serde_json::from_str(r#"{"format":"ExrSequence","channels":"Rgba"}"#).unwrap(); + assert_eq!((old.exr_compression, old.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float)); + } } diff --git a/crates/project/src/render_templates.rs b/crates/project/src/render_templates.rs index cadfab2ad..ea3560682 100644 --- a/crates/project/src/render_templates.rs +++ b/crates/project/src/render_templates.rs @@ -323,4 +323,19 @@ mod tests { t.delete(TemplateKind::OutputModule, "High Quality").unwrap(); assert_eq!(t.output_module("High Quality").unwrap().format, OutputFormat::ProRes); } + + /// An Output Module template keeps its OpenEXR compression and bit depth through a save and + /// load (#2911); the built-in EXR template stays ZIP, 32-bit float. + #[test] + fn exr_options_survive_a_template_round_trip() { + use crate::render_queue::{ExrBitDepth, ExrCompression}; + let mut t = RenderTemplates::default(); + let builtin = t.output_module("OpenEXR Sequence with Alpha").unwrap(); + assert_eq!((builtin.exr_compression, builtin.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float)); + let m = OutputModule { exr_compression: ExrCompression::Piz, exr_bit_depth: ExrBitDepth::Half, ..OutputModule::for_format(OutputFormat::ExrSequence) }; + t.save_output_module("EXR Half PIZ", m); + let back: RenderTemplates = serde_json::from_str(&serde_json::to_string(&t).unwrap()).unwrap(); + let m = back.output_module("exr half piz").unwrap(); + assert_eq!((m.format, m.exr_compression, m.exr_bit_depth), (OutputFormat::ExrSequence, ExrCompression::Piz, ExrBitDepth::Half)); + } } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index dd00b4123..f4b1586cc 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -329,6 +329,9 @@ const JAPANESE: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "プリセットフォルダーのパスをコピー"), ("Copy Source Path", "ソースパスをコピー"), ("Effects: {}", "エフェクト:{}"), + ("EXR Bit Depth: {}", "EXR ビット深度:{}"), + ("EXR Compression: {}", "EXR 圧縮:{}"), + ("EXR Compression: {} (lossy)", "EXR 圧縮:{}(非可逆)"), ("Empty folder", "空のフォルダー"), ("Enable Depth of Field", "被写界深度を有効化"), ("Enable Expression", "エクスプレッションを有効化"), @@ -1451,6 +1454,9 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "复制预设文件夹路径"), ("Copy Source Path", "复制源路径"), ("Effects: {}", "效果:{}"), + ("EXR Bit Depth: {}", "EXR 位深度:{}"), + ("EXR Compression: {}", "EXR 压缩:{}"), + ("EXR Compression: {} (lossy)", "EXR 压缩:{}(有损)"), ("Empty folder", "空文件夹"), ("Enable Depth of Field", "启用景深"), ("Enable Expression", "启用表达式"), @@ -2558,6 +2564,9 @@ const SWEDISH: &[(&str, &str)] = &[ ("Effects & Presets options", "Alternativ för Effekter och förinställningar"), ("Copy Presets Folder Path", "Kopiera sökväg till förinställningsmappen"), ("Effects: {}", "Effekter: {}"), + ("EXR Bit Depth: {}", "EXR-bitdjup: {}"), + ("EXR Compression: {}", "EXR-komprimering: {}"), + ("EXR Compression: {} (lossy)", "EXR-komprimering: {} (förstörande)"), ("Empty folder", "Tom mapp"), ("Enable Depth of Field", "Aktivera skärpedjup"), ("Enable Expression", "Aktivera uttryck"), @@ -3816,6 +3825,9 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "複製預設集資料夾路徑"), ("Copy Source Path", "複製來源路徑"), ("Effects: {}", "效果:{}"), + ("EXR Bit Depth: {}", "EXR 位元深度:{}"), + ("EXR Compression: {}", "EXR 壓縮:{}"), + ("EXR Compression: {} (lossy)", "EXR 壓縮:{}(失真)"), ("Empty folder", "空資料夾"), ("Enable Depth of Field", "啟用景深"), ("Enable Expression", "啟用表達式"), @@ -4877,6 +4889,9 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "Копіювати шлях до папки наборів"), ("Copy Source Path", "Копіювати шлях до джерела"), ("Effects: {}", "Ефекти: {}"), + ("EXR Bit Depth: {}", "Бітова глибина EXR: {}"), + ("EXR Compression: {}", "Стиснення EXR: {}"), + ("EXR Compression: {} (lossy)", "Стиснення EXR: {} (із втратами)"), ("Empty folder", "Порожня тека"), ("Enable Depth of Field", "Увімкнути глибину різкості"), ("Enable Expression", "Увімкнути вираз"), @@ -5440,6 +5455,9 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "Copiar caminho da pasta de predefinições"), ("Copy Source Path", "Copiar caminho da origem"), ("Effects: {}", "Efeitos: {}"), + ("EXR Bit Depth: {}", "Profundidade de bits EXR: {}"), + ("EXR Compression: {}", "Compactação EXR: {}"), + ("EXR Compression: {} (lossy)", "Compactação EXR: {} (com perdas)"), ("Empty folder", "Pasta vazia"), ("Enable Depth of Field", "Ativar profundidade de campo"), ("Enable Expression", "Ativar expressão"), @@ -6200,6 +6218,9 @@ const POLISH: &[(&str, &str)] = &[ ("Effects & Presets options", "Opcje efektów i ustawień"), ("Copy Presets Folder Path", "Kopiuj ścieżkę folderu ustawień"), ("Effects: {}", "Efekty: {}"), + ("EXR Bit Depth: {}", "Głębia bitowa EXR: {}"), + ("EXR Compression: {}", "Kompresja EXR: {}"), + ("EXR Compression: {} (lossy)", "Kompresja EXR: {} (stratna)"), ("Empty folder", "Pusty folder"), ("Enable Depth of Field", "Włącz głębię ostrości"), ("Enable Expression", "Włącz wyrażenie"), @@ -6946,6 +6967,9 @@ const CZECH: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "Kopírovat cestu ke složce přednastavení"), ("Copy Source Path", "Kopírovat cestu ke zdroji"), ("Effects: {}", "Efekty: {}"), + ("EXR Bit Depth: {}", "Bitová hloubka EXR: {}"), + ("EXR Compression: {}", "Komprese EXR: {}"), + ("EXR Compression: {} (lossy)", "Komprese EXR: {} (ztrátová)"), ("Empty folder", "Prázdná složka"), ("Enable Depth of Field", "Povolit hloubku ostrosti"), ("Enable Expression", "Povolit výraz"), @@ -7511,6 +7535,9 @@ const SPANISH: &[(&str, &str)] = &[ ("Effects", "Efectos"), ("Effects & Presets options", "Opciones de Efectos y ajustes preestablecidos"), ("Effects: {}", "Efectos: {}"), + ("EXR Bit Depth: {}", "Profundidad de bits EXR: {}"), + ("EXR Compression: {}", "Compresión EXR: {}"), + ("EXR Compression: {} (lossy)", "Compresión EXR: {} (con pérdida)"), ("Empty folder", "Carpeta vacía"), ("Enable Depth of Field", "Habilitar profundidad de campo"), ("Enable Expression", "Habilitar expresión"), @@ -8064,6 +8091,9 @@ const GEORGIAN: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "პრესეტების საქაღალდის მისამართის კოპირება"), ("Copy Source Path", "წყაროს მისამართის კოპირება"), ("Effects: {}", "ეფექტები: {}"), + ("EXR Bit Depth: {}", "EXR ბიტის სიღრმე: {}"), + ("EXR Compression: {}", "EXR შეკუმშვა: {}"), + ("EXR Compression: {} (lossy)", "EXR შეკუმშვა: {} (დანაკარგით)"), ("Empty folder", "ცარიელი საქაღალდე"), ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), ("Enable Expression", "ექსპრესიის ჩართვა"), @@ -9029,6 +9059,9 @@ const FRENCH: &[(&str, &str)] = &[ ("Copy Presets Folder Path", "Copier le chemin du dossier des préréglages"), ("Copy Source Path", "Copier le chemin source"), ("Effects: {}", "Effets : {}"), + ("EXR Bit Depth: {}", "Profondeur de bits EXR : {}"), + ("EXR Compression: {}", "Compression EXR : {}"), + ("EXR Compression: {} (lossy)", "Compression EXR : {} (avec perte)"), ("Empty folder", "Dossier vide"), ("Enable Depth of Field", "Activer la profondeur de champ"), ("Enable Expression", "Activer l’expression"), diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 21dc828ca..8fe01ed24 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -13,8 +13,9 @@ use crate::i18n::{tr, tr_args}; use effectcraft_engine::project::render_queue::{ - AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, FieldRender, OpusApplication, OutputFormat, PostRenderAction, - ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality, SwitchOverride, TimeSpan, WebmVideoCodec, + AlphaMode, AudioFormat, AudioOutput, Channels, ColorDepth, CurrentOrOff, EffectsMode, ExrBitDepth, ExrCompression, FieldRender, OpusApplication, + OutputFormat, PostRenderAction, ProResProfile, Pulldown, RESIZE_PRESETS, RenderLog, RenderQuality, RenderQueueItem, RenderStatus, ResizeQuality, + SwitchOverride, TimeSpan, WebmVideoCodec, }; use effectcraft_engine::project::render_templates::RenderTemplates; use egui::{Align2, Color32, Rect, Sense, Stroke, StrokeKind, pos2, vec2}; @@ -177,6 +178,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren v.push((mark(o.prores_profile == p, p.label()), json!({"proresProfile": format!("{p:?}")}))); } } + OutputFormat::ExrSequence => exr_menu(o, &mut v), OutputFormat::H264 => { v.push(("-".into(), Value::Null)); for kbps in [2_000, 5_000, 10_000, 15_000, 20_000, 40_000] { @@ -272,6 +274,19 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren v } +/// OpenEXR options: compression (marked where lossy at the module's bit depth) and bit depth. +fn exr_menu(o: &effectcraft_engine::project::render_queue::OutputModule, v: &mut Vec<(String, Value)>) { + v.push(("-".into(), Value::Null)); + for c in ExrCompression::ALL { + let label = if c.is_lossy(o.exr_bit_depth) { "EXR Compression: {} (lossy)" } else { "EXR Compression: {}" }; + v.push((mark(o.exr_compression == c, &tr_args(label, &[&(c.label())])), json!({"exrCompression": c.name()}))); + } + v.push(("-".into(), Value::Null)); + for d in ExrBitDepth::ALL { + v.push((mark(o.exr_bit_depth == d, &tr_args("EXR Bit Depth: {}", &[&(d.label())])), json!({"exrBitDepth": d.name()}))); + } +} + /// HEVC / AV1 options: profile, level, rate control (bitrate or constant quality), key frames. fn codec_menu(o: &effectcraft_engine::project::render_queue::OutputModule, v: &mut Vec<(String, Value)>) { use effectcraft_engine::project::render_queue::{CodecProfile, RateControlMode, VideoCodecOptions}; @@ -1078,4 +1093,35 @@ mod tests { pr.finished = true; assert_eq!(super::live_times(&ctx, &pr, 400.0), (31.0, Some(72.0)), "a finished job stands still"); } + + /// #2911: an OpenEXR module's menu offers every compression and both bit depths, ticks the + /// current ones and marks the methods that are lossy at the current depth; other formats + /// don't show them. + #[test] + fn exr_menu_offers_compression_and_bit_depth() { + use effectcraft_engine::project::render_queue::{ExrBitDepth, OutputFormat, OutputModule, RenderQueueItem}; + use effectcraft_engine::project::{ItemId, render_templates::RenderTemplates}; + crate::i18n::ui::set_current("en"); + let mut it = RenderQueueItem::new(1, ItemId(1)); + it.output = OutputModule::for_format(OutputFormat::ExrSequence); + let menu = |it: &RenderQueueItem| super::output_menu(it, &OutputFormat::ALL, &RenderTemplates::default()); + let find = |m: &[(String, serde_json::Value)], label: &str| m.iter().find(|(l, _)| l.trim_start_matches('✓').trim() == label).cloned(); + let m = menu(&it); + assert_eq!(find(&m, "EXR Compression: ZIP (16 scan lines)").unwrap().0, "✓ EXR Compression: ZIP (16 scan lines)"); + assert_eq!(find(&m, "EXR Compression: PIZ").unwrap().1, serde_json::json!({"exrCompression": "piz"})); + assert!(find(&m, "EXR Compression: PXR24 (lossy)").is_some() && find(&m, "EXR Compression: B44").is_some()); + assert_eq!(find(&m, "EXR Compression: ZIPS (1 scan line)").unwrap().1, serde_json::json!({"exrCompression": "zips"})); + assert_eq!(find(&m, "EXR Bit Depth: 32-bit Float").unwrap().0, "✓ EXR Bit Depth: 32-bit Float"); + assert_eq!(find(&m, "EXR Bit Depth: 16-bit Half Float").unwrap().1, serde_json::json!({"exrBitDepth": "half"})); + assert_eq!(m.iter().filter(|(l, _)| l.contains("EXR Compression: ")).count(), 10); + it.output.exr_bit_depth = ExrBitDepth::Half; + let m = menu(&it); + assert!( + find(&m, "EXR Compression: PXR24").is_some() + && find(&m, "EXR Compression: B44 (lossy)").is_some() + && find(&m, "EXR Compression: DWAA (lossy)").is_some() + ); + it.output = OutputModule::for_format(OutputFormat::PngSequence); + assert!(!menu(&it).iter().any(|(l, _)| l.contains("EXR Compression") || l.contains("EXR Bit Depth"))); + } } diff --git a/docs/agents.md b/docs/agents.md index 34162aacd..7b5391bf3 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -319,6 +319,13 @@ build real projects through these interfaces; they are worked examples of everyt proresProfile: 4444, output}` then `renderQueue.render {"wait": true}`. Check a render by importing it (`file.import`, `file.newCompFromSelection`) and `render_frame {transparent: true}` for its alpha (ProRes 4444 and WebM VP9 alpha both read back as straight alpha). + OpenEXR sequences (`format: exr`) are scan-line files, linear and premultiplied, and take + `exrCompression: none|rle|zips|zip|piz|pxr24|b44|b44a|dwaa|dwab` (default `zip`, 16-line + blocks; `zips` is ZIP one scan line per block, the "Zip (1 scanline)" many compositing + pipelines default to) and `exrBitDepth: half|float` (default `float`); `renderQueue.formats` + lists them. + PXR24 on float, B44 / B44A on half and DWAA / DWAB are lossy (in the tests every linear sample + stays within 0.03 + 6 % of its value); B44 / B44A store 32-bit float uncompressed. Half rounds each sample to the nearest half float. **A render never writes one file twice either:** when two outputs of the job (two items, a `renderQueue.duplicate` copy, output modules, segments) name the same file, the whole render is refused before anything is written, naming both items. Sequences clash only on frames both @@ -749,6 +756,7 @@ effectcraft-cli exec layer.newNull --bridge 9877 # same commands effectcraft-cli exec file.exportLottie '{"comp":"Main","path":"main.json","includeExpressions":true}' main.ecproj --json effectcraft-cli exec file.importLottie '{"path":"anim.json"}' main.ecproj --save effectcraft-cli render main.ecproj --comp Main --out main.mov --format prores --progress # progress lines on stderr for a program that reads them +effectcraft-cli render main.ecproj --comp Main --out 'main_[#####].exr' --exr-compression piz --exr-depth half # half-float PIZ OpenEXR frames ``` A long `run` doesn't fit on a command line (Windows caps it at 32 767 characters, Linux one diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 6eeef1719..8f9eee205 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -41,7 +41,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na | PNG | R/W | yes | yes (sequence) | good | | JPEG | R/W | yes | yes (sequence) | good | | TIFF | R/W | yes | yes (sequence) | good | -| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (32-bit sequence) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | +| OpenEXR (multi-layer, Cryptomatte) | R/W | yes | yes (scan-line sequence, half or 32-bit float, every compression but HTJ2K) | good; HTJ2K compression fails (#482); EXRs without an unnamed RGB layer decode twice (#474) | | Photoshop PSD / PSB | R (footage, composition) / W (layers, sequence) | yes (layers, groups, masks, styles, smart objects) | Save Frame As ▸ Photoshop Layers; no Photoshop sequence output | good, from Adobe's published spec | | Illustrator `.ai`, PDF, EPS | R | yes (vector, Continuously Rasterize, Create Shapes; Composition and Composition - Retain Layer Sizes, #1536) | — | partial: no JPX / JBIG2 images, no Illustrator procset EPS | | SVG | R | yes | — | good; SVG markup on the clipboard pastes as a shape layer (26.3, #738); Illustrator's own clipboard data (PDF/AI) doesn't | @@ -100,6 +100,10 @@ trips and renders, checked by tests, not against After Effects), **partial** (na H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM; #372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR / Targa sequences, animated GIF, WAV, AIFF. +OpenEXR sequences are scan-line RGBA files in half or 32-bit float (default) with None, RLE, ZIPS, +ZIP (default), PIZ, PXR24, B44, B44A, DWAA or DWAB compression; PXR24 on float, B44 / B44A on half +and DWAA / DWAB are lossy (#2911). No tiled or multi-part output, no HTJ2K (the `exr` crate can't +write it). Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed single-file video (Animation / PNG codecs), DPX / Photoshop sequences, MXF, DNxHR, hardware encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file From 51d7e530a019761accdc6d1e31165018dd606ed2 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 01:52:06 +0100 Subject: [PATCH 2/4] Follow-up to #2911: B44 at float is labelled uncompressed, half holds over-range samples B44 and B44A pack only half floats, so at 32-bit float the menu and the render log say the samples stay uncompressed. Half-float frames hold samples above 65 504 at that limit instead of writing infinity. The EXR parameters move into their own helper; exr_out is public for other writers. Co-Authored-By: Claude Opus 5.5 --- crates/engine/src/cmd/render_queue.rs | 18 ++++++--- crates/engine/src/tests/codec_options.rs | 17 +++++++++ crates/export/src/exr_out.rs | 45 +++++++++++++++++------ crates/export/src/lib.rs | 2 +- crates/project/src/render_queue.rs | 23 ++++++++++-- crates/ui-egui/src/i18n/ui.rs | 11 ++++++ crates/ui-egui/src/panels/render_queue.rs | 11 +++++- docs/agents.md | 2 +- 8 files changed, 104 insertions(+), 25 deletions(-) diff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs index 511836d6b..8c457ec77 100644 --- a/crates/engine/src/cmd/render_queue.rs +++ b/crates/engine/src/cmd/render_queue.rs @@ -314,6 +314,17 @@ fn apply_resize(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { Ok(()) } +/// OpenEXR `exrCompression` and `exrBitDepth`. +fn apply_exr(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> { + if let Some(c) = enum_p(p, "exrCompression", cmd, ExrCompression::from_name, EXR_COMPRESSIONS)? { + om.exr_compression = c; + } + if let Some(d) = enum_p(p, "exrBitDepth", cmd, ExrBitDepth::from_name, "half|float")? { + om.exr_bit_depth = d; + } + Ok(()) +} + /// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest /// (captured by `crop.useRoi`). fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option<[f64; 4]>, p: &Value, cmd: &str) -> Result { @@ -347,12 +358,7 @@ fn apply_output(om: &mut OutputModule, templates: &RenderTemplates, roi: Option< if let Some(pp) = str_p(p, "proresProfile") { om.prores_profile = ProResProfile::from_name(pp).ok_or_else(|| bad(cmd, "proresProfile: proxy|lt|standard|hq|4444|4444xq"))?; } - if let Some(c) = enum_p(p, "exrCompression", cmd, ExrCompression::from_name, EXR_COMPRESSIONS)? { - om.exr_compression = c; - } - if let Some(d) = enum_p(p, "exrBitDepth", cmd, ExrBitDepth::from_name, "half|float")? { - om.exr_bit_depth = d; - } + apply_exr(om, p, cmd)?; apply_crop(om, roi, p, cmd)?; apply_resize(om, p, cmd)?; if let Some(a) = p.get("audio") { diff --git a/crates/engine/src/tests/codec_options.rs b/crates/engine/src/tests/codec_options.rs index 9fee9d7cb..e94dbb876 100644 --- a/crates/engine/src/tests/codec_options.rs +++ b/crates/engine/src/tests/codec_options.rs @@ -221,3 +221,20 @@ fn exr_compression_and_bit_depth_options() { assert_eq!((q[1].output.exr_compression, q[1].output.exr_bit_depth), (ExrCompression::Rle, ExrBitDepth::Half)); let _ = std::fs::remove_dir_all(&dir); } + +/// Settings ▸ Export ▸ Append Bit Depth to File Name names a half-float OpenEXR module's frames +/// `_16bpc` and a 32-bit one's `_32bpc` (#2911). +#[test] +fn append_bit_depth_follows_the_exr_bit_depth() { + use effectcraft_project::render_queue::{ExrBitDepth, RenderQueueItem}; + let mut s = session(); + s.execute("prefs.set", json!({"values": {"export.appendBitsToName": true}})).unwrap(); + let mut item = RenderQueueItem::new(1, s.active_comp_id().unwrap()); + item.output = effectcraft_project::render_queue::OutputModule::for_format(OutputFormat::ExrSequence); + item.output.output = "/out/Main_[#####].exr".into(); + for (depth, tag) in [(ExrBitDepth::Half, "_16bpc"), (ExrBitDepth::Float, "_32bpc")] { + item.output.exr_bit_depth = depth; + let outs = s.outputs_of(&item, None).unwrap(); + assert!(outs.iter().all(|(_, p)| p.contains(tag)), "{depth:?}: {outs:?}"); + } +} diff --git a/crates/export/src/exr_out.rs b/crates/export/src/exr_out.rs index a23bbe236..ff70db85a 100644 --- a/crates/export/src/exr_out.rs +++ b/crates/export/src/exr_out.rs @@ -7,15 +7,25 @@ //! `exr` crate's default levels for ZIP and DWA. Half-float samples are rounded from the 32-bit //! render to the nearest half, ties to even. The lossy methods (PXR24 on float, B44 / B44A on half, //! DWAA / DWAB at either depth) keep every linear sample within 0.03 + 6 % of its value in the -//! tests below. DWAA / DWAB files come out about the same size at either depth. +//! tests below. Half float holds at most ±65 504: larger samples are written as that limit, not +//! as infinity. use std::io::{Seek, Write}; use effectcraft_project::render_queue::{ExrBitDepth, ExrCompression}; use exr::prelude::{Blocks, Compression, Encoding, Image, Layer, LayerAttributes, LineOrder, SpecificChannels, Vec2, WritableImage, f16}; +/// The largest finite half float. +const HALF_MAX: f32 = 65_504.0; + +/// A sample as half float: rounded to the nearest half (ties to even), held to ±65 504 so an +/// over-range render doesn't turn into infinity. NaN stays NaN. +fn to_half(v: f32) -> f16 { + f16::from_f32(v.clamp(-HALF_MAX, HALF_MAX)) +} + /// The `exr` crate's compression for an Output Module setting. -pub(crate) fn compression(c: ExrCompression) -> Compression { +pub fn compression(c: ExrCompression) -> Compression { match c { ExrCompression::None => Compression::Uncompressed, ExrCompression::Rle => Compression::RLE, @@ -35,15 +45,10 @@ fn encoding(c: ExrCompression) -> Encoding { Encoding { compression: compression(c), blocks: Blocks::ScanLines, line_order: LineOrder::Increasing } } -/// Write `w`×`h` RGBA pixels (`data`: four linear, premultiplied samples per pixel, rows top to -/// bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data` +/// Write `w`×`h` RGBA pixels (`data`: four samples per pixel as they are to be written, +/// premultiplied, rows top to bottom) as one OpenEXR frame, its blocks compressed in parallel as before. A pixel `data` /// doesn't cover is written as transparent black. -pub(crate) fn write_rgba( - file: &mut W, - data: &[f32], - (w, h): (usize, usize), - (c, depth): (ExrCompression, ExrBitDepth), -) -> exr::error::UnitResult { +pub fn write_rgba(file: &mut W, data: &[f32], (w, h): (usize, usize), (c, depth): (ExrCompression, ExrBitDepth)) -> exr::error::UnitResult { let row = w.saturating_mul(4); let px = move |p: Vec2| -> [f32; 4] { let o = p.y().saturating_mul(row).saturating_add(p.x().saturating_mul(4)); @@ -71,7 +76,7 @@ pub(crate) fn write_rgba( LayerAttributes::default(), encoding(c), SpecificChannels::rgba(move |p: Vec2| { - let [r, g, b, a] = px(p).map(f16::from_f32); + let [r, g, b, a] = px(p).map(to_half); (r, g, b, a) }), )); @@ -152,7 +157,11 @@ mod tests { let ty = if depth == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 }; for c in ExrCompression::ALL { assert_eq!(c.is_lossy(depth), !compression(c).is_lossless_for(ty), "{c:?} {depth:?}"); - let (got, stored) = read_back(&write(&src, (w, h), c, depth)); + let bytes = write(&src, (w, h), c, depth); + if c.is_lossy(depth) { + assert!(bytes.len() < write(&src, (w, h), ExrCompression::None, depth).len(), "{c:?} {depth:?}: smaller than None"); + } + let (got, stored) = read_back(&bytes); assert_eq!(stored, ty, "{c:?} {depth:?}"); assert_eq!(got.len(), want.len()); if c.is_lossy(depth) { @@ -204,6 +213,18 @@ mod tests { assert_eq!(write(&src, (w, h), default.0, default.1), before.into_inner()); } + /// Half-float files hold samples above the half range at its largest finite value, not as + /// infinity; the 32-bit file keeps them. + #[test] + fn half_clamps_over_range_samples_to_the_largest_half() { + let data = [1.0e6, 70_000.0, 65_504.0, 1.0, -1.0e6, 0.5, f32::NAN, 1.0]; + let (half, _) = read_back(&write(&data, (2, 1), ExrCompression::Zip, ExrBitDepth::Half)); + assert_eq!(&half[..6], &[65_504.0, 65_504.0, 65_504.0, 1.0, -65_504.0, 0.5]); + assert!(half[6].is_nan() && half.iter().all(|v| !v.is_infinite())); + let (float, _) = read_back(&write(&data, (2, 1), ExrCompression::Zip, ExrBitDepth::Float)); + assert_eq!(&float[..2], &[1.0e6, 70_000.0]); + } + /// Data shorter than the frame (never produced by the Render Queue) writes transparent black /// for the missing pixels instead of panicking. #[test] diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index c094d518c..e04c465f9 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -34,7 +34,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod encode; -mod exr_out; +pub mod exr_out; mod hevc_av1; mod out; mod pipeline; diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index cdada7cf0..8635bc9a8 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -972,6 +972,11 @@ impl ExrCompression { ExrCompression::Dwaa | ExrCompression::Dwab => true, } } + /// Whether the method leaves the samples uncompressed at `depth` (B44 and B44A pack only half + /// floats). + pub fn stores_uncompressed(self, depth: ExrBitDepth) -> bool { + self == ExrCompression::None || (matches!(self, ExrCompression::B44 | ExrCompression::B44a) && depth == ExrBitDepth::Float) + } /// Parse a compression name: `none`/`uncompressed`, `rle`, `zips`/`zip1` (Nuke's "Zip (1 /// scanline)"), `zip`/`zip16`, /// `piz`, `pxr24`, `b44`, `b44a`, `dwaa`, `dwab` (any case). @@ -1355,10 +1360,17 @@ impl OutputModule { } self.output = format!("{stem}.{}", format.extension()); } - /// The OpenEXR compression's label, marked when it is lossy at the module's bit depth. + /// The OpenEXR compression's label, marked when it is lossy at the module's bit depth or, for + /// B44 / B44A at 32-bit float, stores the samples uncompressed. pub fn exr_compression_label(&self) -> String { - let c = self.exr_compression; - if c.is_lossy(self.exr_bit_depth) { format!("{} (lossy)", c.label()) } else { c.label().into() } + let (c, d) = (self.exr_compression, self.exr_bit_depth); + if c.is_lossy(d) { + format!("{} (lossy)", c.label()) + } else if c != ExrCompression::None && c.stores_uncompressed(d) { + format!("{} (uncompressed at float)", c.label()) + } else { + c.label().into() + } } /// Whether this module can write alpha (the format can, and WebM uses VP9). pub fn supports_alpha(&self) -> bool { @@ -2074,6 +2086,11 @@ mod tests { assert!(m.describe().contains(&("Bit Depth", "16-bit Half Float".to_string()))); let json = serde_json::to_string(&m).unwrap(); assert_eq!(serde_json::from_str::(&json).unwrap(), m); + m.exr_compression = ExrCompression::B44a; + m.exr_bit_depth = ExrBitDepth::Float; + assert_eq!(m.exr_compression_label(), "B44A (uncompressed at float)"); + m.exr_bit_depth = ExrBitDepth::Half; + assert_eq!(m.exr_compression_label(), "B44A (lossy)"); let old: OutputModule = serde_json::from_str(r#"{"format":"ExrSequence","channels":"Rgba"}"#).unwrap(); assert_eq!((old.exr_compression, old.exr_bit_depth), (ExrCompression::Zip, ExrBitDepth::Float)); } diff --git a/crates/ui-egui/src/i18n/ui.rs b/crates/ui-egui/src/i18n/ui.rs index f4b1586cc..cb8979b38 100644 --- a/crates/ui-egui/src/i18n/ui.rs +++ b/crates/ui-egui/src/i18n/ui.rs @@ -332,6 +332,7 @@ const JAPANESE: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "EXR ビット深度:{}"), ("EXR Compression: {}", "EXR 圧縮:{}"), ("EXR Compression: {} (lossy)", "EXR 圧縮:{}(非可逆)"), + ("EXR Compression: {} (uncompressed at float)", "EXR 圧縮:{}(float では非圧縮)"), ("Empty folder", "空のフォルダー"), ("Enable Depth of Field", "被写界深度を有効化"), ("Enable Expression", "エクスプレッションを有効化"), @@ -1457,6 +1458,7 @@ const SIMPLIFIED_CHINESE: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "EXR 位深度:{}"), ("EXR Compression: {}", "EXR 压缩:{}"), ("EXR Compression: {} (lossy)", "EXR 压缩:{}(有损)"), + ("EXR Compression: {} (uncompressed at float)", "EXR 压缩:{}(浮点下不压缩)"), ("Empty folder", "空文件夹"), ("Enable Depth of Field", "启用景深"), ("Enable Expression", "启用表达式"), @@ -2567,6 +2569,7 @@ const SWEDISH: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "EXR-bitdjup: {}"), ("EXR Compression: {}", "EXR-komprimering: {}"), ("EXR Compression: {} (lossy)", "EXR-komprimering: {} (förstörande)"), + ("EXR Compression: {} (uncompressed at float)", "EXR-komprimering: {} (okomprimerad vid float)"), ("Empty folder", "Tom mapp"), ("Enable Depth of Field", "Aktivera skärpedjup"), ("Enable Expression", "Aktivera uttryck"), @@ -3828,6 +3831,7 @@ const TRADITIONAL_CHINESE: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "EXR 位元深度:{}"), ("EXR Compression: {}", "EXR 壓縮:{}"), ("EXR Compression: {} (lossy)", "EXR 壓縮:{}(失真)"), + ("EXR Compression: {} (uncompressed at float)", "EXR 壓縮:{}(浮點時不壓縮)"), ("Empty folder", "空資料夾"), ("Enable Depth of Field", "啟用景深"), ("Enable Expression", "啟用表達式"), @@ -4892,6 +4896,7 @@ const UKRAINIAN: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "Бітова глибина EXR: {}"), ("EXR Compression: {}", "Стиснення EXR: {}"), ("EXR Compression: {} (lossy)", "Стиснення EXR: {} (із втратами)"), + ("EXR Compression: {} (uncompressed at float)", "Стиснення EXR: {} (без стиснення для float)"), ("Empty folder", "Порожня тека"), ("Enable Depth of Field", "Увімкнути глибину різкості"), ("Enable Expression", "Увімкнути вираз"), @@ -5458,6 +5463,7 @@ const PORTUGUESE: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "Profundidade de bits EXR: {}"), ("EXR Compression: {}", "Compactação EXR: {}"), ("EXR Compression: {} (lossy)", "Compactação EXR: {} (com perdas)"), + ("EXR Compression: {} (uncompressed at float)", "Compactação EXR: {} (sem compactação em float)"), ("Empty folder", "Pasta vazia"), ("Enable Depth of Field", "Ativar profundidade de campo"), ("Enable Expression", "Ativar expressão"), @@ -6221,6 +6227,7 @@ const POLISH: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "Głębia bitowa EXR: {}"), ("EXR Compression: {}", "Kompresja EXR: {}"), ("EXR Compression: {} (lossy)", "Kompresja EXR: {} (stratna)"), + ("EXR Compression: {} (uncompressed at float)", "Kompresja EXR: {} (bez kompresji dla float)"), ("Empty folder", "Pusty folder"), ("Enable Depth of Field", "Włącz głębię ostrości"), ("Enable Expression", "Włącz wyrażenie"), @@ -6970,6 +6977,7 @@ const CZECH: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "Bitová hloubka EXR: {}"), ("EXR Compression: {}", "Komprese EXR: {}"), ("EXR Compression: {} (lossy)", "Komprese EXR: {} (ztrátová)"), + ("EXR Compression: {} (uncompressed at float)", "Komprese EXR: {} (u float bez komprese)"), ("Empty folder", "Prázdná složka"), ("Enable Depth of Field", "Povolit hloubku ostrosti"), ("Enable Expression", "Povolit výraz"), @@ -7538,6 +7546,7 @@ const SPANISH: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "Profundidad de bits EXR: {}"), ("EXR Compression: {}", "Compresión EXR: {}"), ("EXR Compression: {} (lossy)", "Compresión EXR: {} (con pérdida)"), + ("EXR Compression: {} (uncompressed at float)", "Compresión EXR: {} (sin compresión en float)"), ("Empty folder", "Carpeta vacía"), ("Enable Depth of Field", "Habilitar profundidad de campo"), ("Enable Expression", "Habilitar expresión"), @@ -8094,6 +8103,7 @@ const GEORGIAN: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "EXR ბიტის სიღრმე: {}"), ("EXR Compression: {}", "EXR შეკუმშვა: {}"), ("EXR Compression: {} (lossy)", "EXR შეკუმშვა: {} (დანაკარგით)"), + ("EXR Compression: {} (uncompressed at float)", "EXR შეკუმშვა: {} (float-ზე შეკუმშვის გარეშე)"), ("Empty folder", "ცარიელი საქაღალდე"), ("Enable Depth of Field", "ველის სიღრმის ჩართვა"), ("Enable Expression", "ექსპრესიის ჩართვა"), @@ -9062,6 +9072,7 @@ const FRENCH: &[(&str, &str)] = &[ ("EXR Bit Depth: {}", "Profondeur de bits EXR : {}"), ("EXR Compression: {}", "Compression EXR : {}"), ("EXR Compression: {} (lossy)", "Compression EXR : {} (avec perte)"), + ("EXR Compression: {} (uncompressed at float)", "Compression EXR : {} (non compressé en float)"), ("Empty folder", "Dossier vide"), ("Enable Depth of Field", "Activer la profondeur de champ"), ("Enable Expression", "Activer l’expression"), diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 8fe01ed24..de26638f9 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -278,7 +278,14 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren fn exr_menu(o: &effectcraft_engine::project::render_queue::OutputModule, v: &mut Vec<(String, Value)>) { v.push(("-".into(), Value::Null)); for c in ExrCompression::ALL { - let label = if c.is_lossy(o.exr_bit_depth) { "EXR Compression: {} (lossy)" } else { "EXR Compression: {}" }; + let d = o.exr_bit_depth; + let label = if c.is_lossy(d) { + "EXR Compression: {} (lossy)" + } else if c != ExrCompression::None && c.stores_uncompressed(d) { + "EXR Compression: {} (uncompressed at float)" + } else { + "EXR Compression: {}" + }; v.push((mark(o.exr_compression == c, &tr_args(label, &[&(c.label())])), json!({"exrCompression": c.name()}))); } v.push(("-".into(), Value::Null)); @@ -1109,7 +1116,7 @@ mod tests { let m = menu(&it); assert_eq!(find(&m, "EXR Compression: ZIP (16 scan lines)").unwrap().0, "✓ EXR Compression: ZIP (16 scan lines)"); assert_eq!(find(&m, "EXR Compression: PIZ").unwrap().1, serde_json::json!({"exrCompression": "piz"})); - assert!(find(&m, "EXR Compression: PXR24 (lossy)").is_some() && find(&m, "EXR Compression: B44").is_some()); + assert!(find(&m, "EXR Compression: PXR24 (lossy)").is_some() && find(&m, "EXR Compression: B44 (uncompressed at float)").is_some()); assert_eq!(find(&m, "EXR Compression: ZIPS (1 scan line)").unwrap().1, serde_json::json!({"exrCompression": "zips"})); assert_eq!(find(&m, "EXR Bit Depth: 32-bit Float").unwrap().0, "✓ EXR Bit Depth: 32-bit Float"); assert_eq!(find(&m, "EXR Bit Depth: 16-bit Half Float").unwrap().1, serde_json::json!({"exrBitDepth": "half"})); diff --git a/docs/agents.md b/docs/agents.md index 7b5391bf3..3bba23932 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -325,7 +325,7 @@ build real projects through these interfaces; they are worked examples of everyt pipelines default to) and `exrBitDepth: half|float` (default `float`); `renderQueue.formats` lists them. PXR24 on float, B44 / B44A on half and DWAA / DWAB are lossy (in the tests every linear sample - stays within 0.03 + 6 % of its value); B44 / B44A store 32-bit float uncompressed. Half rounds each sample to the nearest half float. + stays within 0.03 + 6 % of its value); B44 / B44A store 32-bit float uncompressed. Half rounds each sample to the nearest half float and holds it to ±65 504. **A render never writes one file twice either:** when two outputs of the job (two items, a `renderQueue.duplicate` copy, output modules, segments) name the same file, the whole render is refused before anything is written, naming both items. Sequences clash only on frames both From 50340011f5c819c0d6e27b6bdcf0cb53e47106a7 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Tue, 3 Nov 2026 01:53:16 +0100 Subject: [PATCH 3/4] Follow-up to #2911: clippy in the OpenEXR round-trip test Co-Authored-By: Claude Opus 5.5 --- crates/export/tests/it/roundtrip.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index e27434248..bba9fc2bd 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -361,7 +361,7 @@ fn exr_compressions_and_bit_depths_round_trip() { let reference = render(ExrCompression::None, ExrBitDepth::Float); let (want, _) = exr_samples(&reference); let want_pooled = pooled(&reference); - assert!(want.iter().any(|v| *v > 0.5) && want.iter().any(|v| *v == 0.0), "the frame has red, background and transparent corners"); + assert!(want.iter().any(|v| *v > 0.5) && want.contains(&0.0), "the frame has red, background and transparent corners"); for d in ExrBitDepth::ALL { let ty = if d == ExrBitDepth::Half { SampleType::F16 } else { SampleType::F32 }; for c in ExrCompression::ALL { From 81c9476081e34a8ed9b7c2b3541a926608ac2b27 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Wed, 4 Nov 2026 00:14:35 +0100 Subject: [PATCH 4/4] Render Queue EXR test: name what the default is compared against (review point) CodeRabbit: the test name and doc claimed the default equals output from before the setting existed. The reference is the exr crate's SMALL_LOSSLESS preset (the layout since #2812); renamed and reworded, comparison unchanged. Co-Authored-By: Claude Sonnet 5.5 --- crates/export/src/exr_out.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/crates/export/src/exr_out.rs b/crates/export/src/exr_out.rs index ff70db85a..92afadcc8 100644 --- a/crates/export/src/exr_out.rs +++ b/crates/export/src/exr_out.rs @@ -195,10 +195,11 @@ mod tests { } } - /// The default (ZIP, 32-bit float) writes exactly what the Render Queue wrote before the - /// setting existed (#2812), so re-rendering an existing project changes no file. + /// The default (ZIP, 32-bit float) writes the `exr` crate's `SMALL_LOSSLESS` preset (scan + /// lines, ZIP), the layout the Render Queue has written since #2812. The reference is that + /// preset, not files from before #2812, which were tiled RLE. #[test] - fn the_default_matches_the_earlier_output_byte_for_byte() { + fn the_default_matches_the_small_lossless_preset_byte_for_byte() { let (w, h) = (64, 48); let src = frame(w, h); let pixels = SpecificChannels::rgba(|p: Vec2| {