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