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 <noreply@anthropic.com>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-11-03 01:52:06 +01:00
1 parent b5fe54c08b
commit 51d7e530a0
8 files changed
+104 -25

No files matched your search

+12 -6
View File
@@ -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<bool> {
@@ -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") {
+17
View File
@@ -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:?}");
}
}
+33 -12
View File
@@ -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<W: Write + Seek>(
file: &mut W,
data: &[f32],
(w, h): (usize, usize),
(c, depth): (ExrCompression, ExrBitDepth),
) -> exr::error::UnitResult {
pub fn write_rgba<W: Write + Seek>(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<usize>| -> [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<W: Write + Seek>(
LayerAttributes::default(),
encoding(c),
SpecificChannels::rgba(move |p: Vec2<usize>| {
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]
+1 -1
View File
@@ -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;
+20 -3
View File
@@ -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::<OutputModule>(&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));
}
+11
View File
@@ -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"),
+9 -2
View File
@@ -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"}));
+1 -1
View File
@@ -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