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