#806: export OpenEXR image sequences

Problem: Premiere offers OpenEXR as an image-sequence export format;
FilmCraft could not write EXR at all (docs/gaps.md G13).

Fix: a new export format (id exr, "OpenEXR") writing numbered .exr stills,
with a preset, the Include Alpha Channel option, the file.exportMedia format
list and es/ja/zh-cn strings. The writer in crates/export/src/raster.rs
follows the public OpenEXR file layout: version 2 single-part scan-line
file, half-float B/G/R(/A) channels, no compression, one scan line per
chunk, Rec.709 chromaticities; values are scene-linear (the pipeline's
transfer undone) with premultiplied alpha, as EXR defines. No new
dependency. ZIP / PIZ / DWA compression stays open.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
vangel76andClaude Opus 5.5 committed 2026-10-11 00:34:28 +02:00
1 parent 9f6d75350c
commit fb5863bc8a
13 files changed
+326 -18

No files matched your search

+1 -1
View File
@@ -1285,7 +1285,7 @@ fn build() -> Vec<CommandSpec> {
"Media…",
["File", "Export"],
None,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|jpg|tga|dpx|exr|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq|4444|4444xq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),
+2 -1
View File
@@ -137,7 +137,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
Some(a) => (a[0].as_u64().unwrap(), a[1].as_u64().unwrap()),
None => (1920, 1080),
};
if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx") {
if matches!(fmt.as_str(), "png" | "tiff" | "bmp" | "jpg" | "tga" | "dpx" | "exr") {
let files = files_in(&sub);
let ext = Path::new(&out).extension().unwrap().to_string_lossy().to_string();
assert_eq!(frames, 7, "frames 23..=29 at 23.976 fps");
@@ -151,6 +151,7 @@ fn every_builtin_preset_exports_and_ffprobe_confirms() {
"jpg" => "mjpeg",
"tga" => "targa",
"dpx" => "dpx",
"exr" => "exr",
_ => "bmp",
};
assert_eq!(v["codec_name"], codec, "{name}");
+27 -7
View File
@@ -6,7 +6,7 @@
//!
//! Video encoders implement [`VideoEncoder`]; codec crates register theirs with
//! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG /
//! Targa / DPX sequences, GIF, WAV and AIFF.
//! Targa / DPX / OpenEXR sequences, GIF, WAV and AIFF.
//!
//! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio
//! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the
@@ -106,6 +106,9 @@ pub enum Format {
/// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709).
#[serde(rename = "dpx", alias = "DpxSequence")]
DpxSequence,
/// Numbered OpenEXR stills (half-float scene-linear RGB, RGBA with [`ExportSettings::alpha`]).
#[serde(rename = "exr", alias = "ExrSequence")]
ExrSequence,
#[serde(rename = "gif", alias = "Gif")]
Gif,
#[serde(rename = "wav", alias = "Wav")]
@@ -140,6 +143,7 @@ impl Format {
"jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence,
"tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence,
"dpx" | "dpxsequence" => Format::DpxSequence,
"exr" | "openexr" | "exrsequence" | "openexrsequence" => Format::ExrSequence,
"gif" | "animatedgif" => Format::Gif,
"wav" | "waveform" | "waveformaudio" => Format::Wav,
"aif" | "aiff" | "aifc" => Format::Aiff,
@@ -162,6 +166,7 @@ impl Format {
Format::JpegSequence => "jpg",
Format::TgaSequence => "tga",
Format::DpxSequence => "dpx",
Format::ExrSequence => "exr",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
@@ -179,6 +184,7 @@ impl Format {
Format::JpegSequence => "jpg",
Format::TgaSequence => "tga",
Format::DpxSequence => "dpx",
Format::ExrSequence => "exr",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
@@ -200,6 +206,7 @@ impl Format {
Format::JpegSequence => "JPEG",
Format::TgaSequence => "Targa",
Format::DpxSequence => "DPX",
Format::ExrSequence => "OpenEXR",
Format::Gif => "Animated GIF",
Format::Wav => "Waveform Audio",
Format::Aiff => "AIFF",
@@ -209,7 +216,16 @@ impl Format {
}
/// Numbered stills, one per frame ([`image_sequence_path`]).
pub fn is_image_sequence(self) -> bool {
matches!(self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence)
matches!(
self,
Format::PngSequence
| Format::TiffSequence
| Format::BmpSequence
| Format::JpegSequence
| Format::TgaSequence
| Format::DpxSequence
| Format::ExrSequence
)
}
/// An MXF container format.
pub fn is_mxf(self) -> bool {
@@ -232,7 +248,7 @@ impl Format {
pub fn is_mp4_with(self, mux: Multiplexer) -> bool {
self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4)
}
pub const ALL: [Format; 18] = [
pub const ALL: [Format; 19] = [
Format::H264,
Format::Hevc,
Format::Av1,
@@ -246,6 +262,7 @@ impl Format {
Format::JpegSequence,
Format::TgaSequence,
Format::DpxSequence,
Format::ExrSequence,
Format::Gif,
Format::Wav,
Format::Aiff,
@@ -302,9 +319,10 @@ pub struct ExportSettings {
/// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI).
#[serde(default)]
pub sdr: bool,
/// Keep the alpha channel in PNG, TIFF and Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies
/// (straight alpha, Premiere's "Include Alpha Channel"). Off, every frame is flattened over
/// black (#160). Other formats ignore it.
/// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ
/// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it
/// premultiplied, as the format defines). Off, every frame is flattened over black (#160).
/// Other formats ignore it.
#[serde(default)]
pub alpha: bool,
/// Output frame size (None = Match Source: the sequence size times `scale`).
@@ -1659,7 +1677,8 @@ pub fn encode_png(rgba: Vec<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
}
/// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha
/// channel it is given; `alpha` keeps it in TIFF and Targa too, which are otherwise written as RGB.
/// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written
/// as RGB.
/// JPEG uses the default export quality ([`encode_still_with_quality`] picks another).
pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality)
@@ -1683,6 +1702,7 @@ pub fn encode_still_with_quality(format: Format, rgba: Vec<u8>, w: u32, h: u32,
}
Format::TgaSequence => return raster::tga(&rgba, w, h, alpha),
Format::DpxSequence => return raster::dpx(&rgba, w, h),
Format::ExrSequence => return raster::exr(&rgba, w, h, alpha),
Format::PngSequence => {
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)?
}
+6
View File
@@ -160,6 +160,12 @@ pub fn builtin_presets() -> Vec<ExportPreset> {
ExportSettings { format: Format::TgaSequence, ..Default::default() },
),
p("DPX Sequence", "Image Sequence", "Numbered DPX stills (10-bit RGB)", ExportSettings { format: Format::DpxSequence, ..Default::default() }),
p(
"OpenEXR Sequence",
"Image Sequence",
"Numbered OpenEXR stills (half float, linear, uncompressed)",
ExportSettings { format: Format::ExrSequence, ..Default::default() },
),
p(
"Animated GIF 640×360",
"Animated GIF",
+226 -1
View File
@@ -1,9 +1,12 @@
//! Uncompressed still formats written directly from RGBA8 frames: Targa and DPX.
//! Uncompressed still formats written directly from RGBA8 frames: Targa, DPX and OpenEXR.
//!
//! Targa follows the Truevision TGA File Format Specification 2.0 (image type 2, uncompressed
//! true-colour, bottom-left origin, the layout every reader accepts). DPX follows SMPTE ST 268
//! (version 2.0 header): one RGB image element, 10 bits per component packed into 32-bit
//! big-endian words (method A, two padding bits at the bottom), BT.709 transfer and colorimetry.
//! OpenEXR follows the public OpenEXR file layout (version 2, single-part scan-line file): half-float
//! B, G, R (and A) channels, no compression, one scan line per chunk, scene-linear Rec.709 values
//! with premultiplied alpha, as EXR readers expect.
use crate::{ExportError, Result};
/// `w * h * 4` when `rgba` holds exactly that many bytes.
@@ -129,6 +132,120 @@ pub(crate) fn dpx(rgba: &[u8], w: u32, h: u32) -> Result<Vec<u8>> {
Ok(out)
}
/// IEEE 754 binary16 bits of `v`, rounded to nearest even (overflow → infinity, NaN → quiet NaN).
fn half(v: f32) -> u16 {
let b = v.to_bits();
let sign = ((b >> 16) & 0x8000) as u16;
let exp = ((b >> 23) & 0xFF) as i32;
let man = b & 0x7F_FFFF;
if exp == 0xFF {
return sign | 0x7C00 | if man != 0 { 0x200 } else { 0 };
}
// rounds `m >> shift` to nearest, ties to even (`shift` is 1..=24)
let round = |m: u32, shift: u32| {
let (q, rem, mid) = (m >> shift, m & ((1 << shift) - 1), 1 << (shift - 1));
if rem > mid || (rem == mid && q & 1 == 1) { q + 1 } else { q }
};
let e = exp - 127 + 15;
if e >= 0x1F {
sign | 0x7C00
} else if e <= 0 {
if e < -10 {
return sign;
}
// subnormal half: the 24-bit significand scaled by 2^(e - 14); rounding up may carry
// into the smallest normal, which is the right encoding
sign | round(man | 0x80_0000, (14 - e) as u32) as u16
} else {
// a carry out of the mantissa bumps the exponent (up to infinity), as it should
sign | round(((e as u32) << 23) | man, 13) as u16
}
}
/// An OpenEXR file: half-float scene-linear Rec.709 RGB, or RGBA with `alpha` (premultiplied, as
/// EXR defines alpha; the frame's straight alpha is multiplied in after linearisation).
pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
let n = checked_len(rgba, w, h)?;
let (Ok(x_max), Ok(y_max)) = (i32::try_from(w - 1), i32::try_from(h - 1)) else {
return Err(ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")));
};
// channels in the alphabetical order the file layout requires
let names: &[u8] = if alpha { b"ABGR" } else { b"BGR" };
let line = (w as usize).checked_mul(names.len() * 2).ok_or_else(|| ExportError::Encode("frame size".into()))?;
let line_size = i32::try_from(line).map_err(|_| ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large")))?;
let mut hd = Vec::with_capacity(512);
hd.extend_from_slice(&[0x76, 0x2F, 0x31, 0x01]); // magic number 20000630
hd.extend_from_slice(&[2, 0, 0, 0]); // version 2, single-part scan-line file, short names
let attr = |hd: &mut Vec<u8>, name: &str, ty: &str, value: &[u8]| {
for s in [name, ty] {
hd.extend_from_slice(s.as_bytes());
hd.push(0);
}
hd.extend_from_slice(&(value.len() as i32).to_le_bytes());
hd.extend_from_slice(value);
};
let le = |v: &[f32]| v.iter().flat_map(|f| f.to_le_bytes()).collect::<Vec<u8>>();
let mut channels = Vec::new();
for &c in names {
channels.extend_from_slice(&[c, 0]);
channels.extend_from_slice(&1_i32.to_le_bytes()); // pixel type HALF
channels.extend_from_slice(&[0, 0, 0, 0]); // pLinear 0, reserved
channels.extend_from_slice(&1_i32.to_le_bytes()); // x sampling
channels.extend_from_slice(&1_i32.to_le_bytes()); // y sampling
}
channels.push(0);
let window: Vec<u8> = [0, 0, x_max, y_max].iter().flat_map(|v| v.to_le_bytes()).collect();
attr(&mut hd, "channels", "chlist", &channels);
attr(&mut hd, "chromaticities", "chromaticities", &le(&[0.64, 0.33, 0.30, 0.60, 0.15, 0.06, 0.3127, 0.3290]));
attr(&mut hd, "compression", "compression", &[0]); // NO_COMPRESSION
attr(&mut hd, "dataWindow", "box2i", &window);
attr(&mut hd, "displayWindow", "box2i", &window);
attr(&mut hd, "lineOrder", "lineOrder", &[0]); // INCREASING_Y
attr(&mut hd, "pixelAspectRatio", "float", &le(&[1.0]));
attr(&mut hd, "screenWindowCenter", "v2f", &le(&[0.0, 0.0]));
attr(&mut hd, "screenWindowWidth", "float", &le(&[1.0]));
hd.push(0); // end of header
// offset table (one chunk per scan line), then the chunks: y, data size, pixel data
let chunk = line + 8;
let table = (h as usize).checked_mul(8);
let body = (h as usize).checked_mul(chunk);
let size = table.zip(body).and_then(|(t, b)| hd.len().checked_add(t)?.checked_add(b)).ok_or_else(|| ExportError::Encode("frame size".into()))?;
let mut out = Vec::with_capacity(size);
out.extend_from_slice(&hd);
let first = (hd.len() + h as usize * 8) as u64;
for y in 0..h as u64 {
out.extend_from_slice(&(first + y * chunk as u64).to_le_bytes());
}
let lin = filmcraft_color::srgb_u8_to_linear_table();
let opaque: [u16; 256] = std::array::from_fn(|i| half(lin[i]));
let row = w as usize * 4;
let mut px = vec![0_u16; w as usize * names.len()];
for (y, src) in rgba[..n].chunks_exact(row).enumerate() {
// planar per line: every pixel of the first channel, then the next channel …
let wn = w as usize;
for (x, &[r, g, b, a]) in src.as_chunks::<4>().0.iter().enumerate() {
let rgb = [b, g, r];
if alpha {
let k = f32::from(a) / 255.0;
px[x] = half(k);
for (c, &v) in rgb.iter().enumerate() {
px[(c + 1) * wn + x] = if a == 255 { opaque[v as usize] } else { half(lin[v as usize] * k) };
}
} else {
for (c, &v) in rgb.iter().enumerate() {
px[c * wn + x] = opaque[v as usize];
}
}
}
out.extend_from_slice(&(y as i32).to_le_bytes());
out.extend_from_slice(&line_size.to_le_bytes());
out.extend(px.iter().flat_map(|v| v.to_le_bytes()));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
@@ -167,6 +284,111 @@ mod tests {
assert_eq!(&f[18..26], &[255, 0, 0, 128, 0, 0, 255, 255]);
}
#[test]
fn half_floats_round_to_nearest_even() {
for (v, h) in [
(0.0, 0x0000),
(-0.0, 0x8000),
(1.0, 0x3C00),
(0.5, 0x3800),
(-2.0, 0xC000),
(65504.0, 0x7BFF),
(65520.0, 0x7C00), // halfway past the largest half: ties to even = infinity
(1.0e9, 0x7C00), // overflow
(6.103_515_6e-5, 0x0400), // smallest normal
(5.960_464_5e-8, 0x0001), // smallest subnormal, 2^-24
(2.980_232_2e-8, 0x0000), // 2^-25: halfway to zero, ties to even
(1.0e-10, 0x0000),
(1.000_488_3, 0x3C00), // 1 + 2^-11: halfway, ties to even (down)
(1.001_464_8, 0x3C02), // 1 + 3·2^-11: halfway, ties to even (up)
(f32::INFINITY, 0x7C00),
] {
assert_eq!(half(v), h, "{v}");
}
assert_eq!(half(f32::NAN) & 0x7E00, 0x7E00);
}
fn le32(b: &[u8], at: usize) -> u32 {
u32::from_le_bytes(b[at..at + 4].try_into().unwrap())
}
/// Where `needle` (an attribute name, NUL-terminated) starts in the header.
fn find(b: &[u8], needle: &[u8]) -> usize {
b.windows(needle.len()).position(|w| w == needle).unwrap()
}
#[test]
fn exr_layout() {
// 2×1: opaque white, then (255, 0, 0) at alpha 0.5
let px = [255, 255, 255, 255, 255, 0, 0, 128];
for alpha in [false, true] {
let f = exr(&px, 2, 1, alpha).unwrap();
let nch = if alpha { 4 } else { 3 };
assert_eq!(&f[..8], &[0x76, 0x2F, 0x31, 0x01, 2, 0, 0, 0]);
let ch = find(&f, b"channels\0chlist\0") + 16;
assert_eq!(le32(&f, ch) as usize, nch * 18 + 1);
let names: Vec<u8> = (0..nch).map(|i| f[ch + 4 + i * 18]).collect();
assert_eq!(names, if alpha { b"ABGR".to_vec() } else { b"BGR".to_vec() });
let dw = find(&f, b"dataWindow\0box2i\0") + 17;
assert_eq!((le32(&f, dw), le32(&f, dw + 4), le32(&f, dw + 8), le32(&f, dw + 12)), (16, 0, 0, 1));
let cmp = find(&f, b"compression\0compression\0") + 24;
assert_eq!((le32(&f, cmp), f[cmp + 4]), (1, 0));
// header ends with a NUL; one offset (scan line 0) points at the only chunk
let end = f.len() - (8 + 2 * nch * 2) - 8;
assert_eq!(f[end - 1], 0);
let off = u64::from_le_bytes(f[end..end + 8].try_into().unwrap()) as usize;
assert_eq!(off, end + 8);
assert_eq!((le32(&f, off), le32(&f, off + 4) as usize), (0, 2 * nch * 2));
let hv = |c: usize, x: usize| u16::from_le_bytes(f[off + 8 + (c * 2 + x) * 2..][..2].try_into().unwrap());
let (b, g, r) = if alpha { (1, 2, 3) } else { (0, 1, 2) };
// white is 1.0 everywhere; red is linear 1.0 · alpha (premultiplied) or 1.0 flat
assert_eq!((hv(b, 0), hv(g, 0), hv(r, 0)), (0x3C00, 0x3C00, 0x3C00));
assert_eq!((hv(b, 1), hv(g, 1)), (0, 0));
if alpha {
assert_eq!((hv(0, 0), hv(0, 1)), (0x3C00, half(128.0 / 255.0)));
assert_eq!(hv(r, 1), half(128.0 / 255.0));
} else {
assert_eq!(hv(r, 1), 0x3C00);
}
}
}
/// ffmpeg (external test oracle) decodes our OpenEXR files to the values we meant: linear
/// light, premultiplied alpha, rows top to bottom.
#[test]
fn exr_decodes_in_ffmpeg() {
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR stills") else { return };
let (w, h) = (5_u32, 3_u32);
let px: Vec<u8> = (0..w * h).flat_map(|i| [(i * 17) as u8, (255 - i * 13) as u8, (i * 40 % 256) as u8, [255, 128, 0, 64][i as usize % 4]]).collect();
let dir = filmcraft_testkit::fixtures::workspace_root().join("target").join("export-tests").join(format!("exr-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let lin = filmcraft_color::srgb_u8_to_linear_table();
for alpha in [false, true] {
let path = dir.join(format!("f{alpha}.exr"));
std::fs::write(&path, exr(&px, w, h, alpha).unwrap()).unwrap();
let out = std::process::Command::new(&ffmpeg)
.args(["-v", "error", "-i"])
.arg(&path)
.args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let n = (w * h) as usize;
assert_eq!(out.stdout.len(), n * 4 * 4);
let plane = |p: usize, i: usize| f32::from_le_bytes(out.stdout[(p * n + i) * 4..][..4].try_into().unwrap());
for (i, &[r, g, b, a]) in px.as_chunks::<4>().0.iter().enumerate() {
let k = if alpha { f32::from(a) / 255.0 } else { 1.0 };
// gbrap planes: G, B, R, A
let want = [lin[g as usize] * k, lin[b as usize] * k, lin[r as usize] * k, k];
for (p, want) in want.into_iter().enumerate() {
let got = plane(p, i);
assert!((got - want).abs() <= 1e-3 + want * 1e-3, "alpha {alpha}, pixel {i}, plane {p}: {got} vs {want}");
}
}
}
let _ = std::fs::remove_dir_all(&dir);
}
#[test]
fn hostile_sizes_are_errors() {
assert!(dpx(&[0; 7], 2, 1).is_err());
@@ -175,5 +397,8 @@ mod tests {
assert!(tga(&[0; 4], u32::MAX, u32::MAX, false).is_err());
assert!(rgb(&[0; 3], 1, 1).is_err());
assert!(dpx(&[0; 4], u32::MAX, u32::MAX).is_err());
assert!(exr(&[0; 4], u32::MAX, u32::MAX, true).is_err());
assert!(exr(&[0; 7], 2, 1, false).is_err());
assert!(exr(&[], 0, 1, false).is_err());
}
}
+6 -3
View File
@@ -378,11 +378,11 @@ impl ExportSettings {
if self.format.is_mxf() { self.mxf_video_codec.encoder_format() } else { self.format }
}
/// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF and
/// Targa sequences and ProRes 4444 / 4444 XQ QuickTime movies.
/// Whether the output can carry an alpha channel ([`ExportSettings::alpha`]): PNG, TIFF,
/// Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ QuickTime movies.
pub fn supports_alpha(&self) -> bool {
match self.format {
Format::PngSequence | Format::TiffSequence | Format::TgaSequence => true,
Format::PngSequence | Format::TiffSequence | Format::TgaSequence | Format::ExrSequence => true,
Format::ProRes => crate::prores_profile(&self.prores_profile).chroma() == filmcraft_prores::ChromaFormat::Yuv444,
_ => false,
}
@@ -488,6 +488,8 @@ impl ExportSettings {
Format::TgaSequence => px * if self.keeps_alpha() { 4.0 } else { 3.0 } * 8.0 * fps,
// 10-bit RGB in one 32-bit word per pixel
Format::DpxSequence => px * 4.0 * 8.0 * fps,
// two bytes per half-float channel
Format::ExrSequence => px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps,
Format::Gif => px * 0.6 * 8.0 * fps,
Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
@@ -571,6 +573,7 @@ impl ExportSettings {
Format::Apv => v += &format!(", {}", crate::apv_profile(&self.apv_profile).name()),
Format::Mjpeg | Format::JpegSequence => v += &format!(", quality {}", self.quality),
Format::DpxSequence => v += ", 10-bit RGB",
Format::ExrSequence => v += ", 16-bit half float, linear",
_ => {}
}
v
+44 -1
View File
@@ -160,6 +160,49 @@ fn jpeg_targa_and_dpx_sequences() {
assert!(jpeg_sizes[0] > jpeg_sizes[1], "quality 95 must be larger than quality 20: {jpeg_sizes:?}");
}
/// OpenEXR sequences (#806): numbered like the others, half-float linear light, RGBA with
/// premultiplied alpha when asked; ffprobe / ffmpeg (external test oracles) read every still.
#[test]
fn openexr_sequences() {
let (p, seq, m) = matte([0.0, 0.0, 1.0, 0.5], 64, 36, None);
for alpha in [false, true] {
let dir = Scratch::new(&format!("seq-exr-{alpha}"));
let mut s = ExportSettings { format: Format::ExrSequence, path: dir.path("Shot.exr"), alpha, ..Default::default() };
s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(3)));
assert_eq!(Format::from_name("OpenEXR"), Some(Format::ExrSequence));
assert!(s.is_image_sequence() && s.supports_alpha() && s.keeps_alpha() == alpha && !s.has_audio());
let r = export(&p, seq, &s, &m, &Progress::default()).unwrap();
assert_eq!(r.frames, 3);
let want: Vec<String> = (0..3).map(|i| format!("Shot{i:03}.exr")).collect();
assert_eq!(files_in(&dir.0), want);
let first = dir.0.join(&want[0]);
let bytes = std::fs::read(&first).unwrap();
assert_eq!(&bytes[..4], &[0x76, 0x2F, 0x31, 0x01]);
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("OpenEXR sequences") else { continue };
let out = std::process::Command::new(ffmpeg)
.args(["-v", "error", "-i"])
.arg(&first)
.args(["-f", "rawvideo", "-pix_fmt", "gbrapf32le", "-"])
.output()
.unwrap();
assert!(out.status.success(), "{}", String::from_utf8_lossy(&out.stderr));
let n = 64 * 36;
assert_eq!(out.stdout.len(), n * 16);
let plane = |p: usize| -> Vec<f32> { out.stdout[p * n * 4..(p + 1) * n * 4].as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect() };
let (g, b, r, a) = (plane(0), plane(1), plane(2), plane(3));
for i in 0..n {
assert!(g[i].abs() < 1e-3 && r[i].abs() < 1e-3, "pixel {i}: no red or green");
if alpha {
// pure blue (linear 1.0) at 50 % opacity, premultiplied
assert!((a[i] - 0.5).abs() < 0.01 && (b[i] - a[i]).abs() < 2e-3, "pixel {i}: {} / {}", b[i], a[i]);
} else {
// flattened over black, then linearised: well below the encoded half level
assert!(a[i] == 1.0 && b[i] > 0.1 && b[i] < 0.5, "pixel {i}: {} / {}", b[i], a[i]);
}
}
}
}
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160).
#[test]
fn image_sequences_keep_alpha_when_asked() {
@@ -658,7 +701,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
}
assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc));
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)"));
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 18);
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 19);
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
assert_eq!(s.format, Format::Hevc);
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");
+3
View File
@@ -3182,6 +3182,9 @@
Numbered JPEG stills (quality 90) Imágenes fijas JPEG numeradas (calidad 90)
Numbered Targa stills (24-bit, uncompressed) Imágenes fijas Targa numeradas (24 bits, sin comprimir)
Numbered DPX stills (10-bit RGB) Imágenes fijas DPX numeradas (RGB de 10 bits)
OpenEXR OpenEXR
OpenEXR Sequence Secuencia OpenEXR
Numbered OpenEXR stills (half float, linear, uncompressed) Imágenes fijas OpenEXR numeradas (coma flotante de 16 bits, lineal, sin comprimir)
Numbered PNG stills at the sequence's frame size Imágenes fijas PNG numeradas al tamaño de fotograma de la secuencia
Numbered TIFF stills (RGB, 8-bit) Imágenes fijas TIFF numeradas (RGB, 8 bits)
Opacity 0 → 100 % over the first second Opacidad de 0 a 100 % durante el primer segundo
Can't render this file because it contains an unexpected character in line 3 and column 95.
+3
View File
@@ -3171,6 +3171,9 @@
Numbered JPEG stills (quality 90) 連番 JPEG 静止画 (品質 90)
Numbered Targa stills (24-bit, uncompressed) 連番 Targa 静止画 (24-bit、非圧縮)
Numbered DPX stills (10-bit RGB) 連番 DPX 静止画 (10-bit RGB)
OpenEXR OpenEXR
OpenEXR Sequence OpenEXR 連番画像
Numbered OpenEXR stills (half float, linear, uncompressed) 連番 OpenEXR 静止画 (16-bit 浮動小数点、リニア、非圧縮)
Numbered PNG stills at the sequence's frame size シーケンスと同じフレームサイズの連番 PNG 静止画
Numbered TIFF stills (RGB, 8-bit) 連番 TIFF 静止画 (RGB、8-bit)
Opacity 0 → 100 % over the first second 最初の 1 秒で不透明度を 0 → 100 % に変化
Can't render this file because it contains an unexpected character in line 11 and column 59.
+3
View File
@@ -3165,6 +3165,9 @@
Numbered JPEG stills (quality 90) 带编号的 JPEG 静帧(质量 90)
Numbered Targa stills (24-bit, uncompressed) 带编号的 Targa 静帧(24 位,未压缩)
Numbered DPX stills (10-bit RGB) 带编号的 DPX 静帧(10 位 RGB)
OpenEXR OpenEXR
OpenEXR Sequence OpenEXR 序列
Numbered OpenEXR stills (half float, linear, uncompressed) 带编号的 OpenEXR 静帧(16 位半精度浮点,线性,未压缩)
Numbered PNG stills at the sequence's frame size 带编号的 PNG 静帧(采用序列帧大小)
Numbered TIFF stills (RGB, 8-bit) 带编号的 TIFF 静帧(RGB,8 位)
Opacity 0 → 100 % over the first second 在最初一秒内不透明度从 0 → 100%
Can't render this file because it contains an unexpected character in line 3 and column 95.
+1 -1
View File
@@ -667,7 +667,7 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
| `mxf-op1a` | DNxHR (default), ProRes or H.264 (Annex B, long GOP), `mxfVideoCodec`; PCM 16/24-bit | MXF OP1a (`filmcraft-mxf` writer: frame-wrapped, index with temporal offsets, start timecode) |
| `mxf-opatom` | as `mxf-op1a` | Avid-style MXF OP-Atom: clip-wrapped picture at `path`, one mono PCM file per channel (`<stem>_A1.mxf` …, `Report::extra_files`) |
| `png`, `tiff`, `bmp`, `jpg` | `image` | numbered stills `<name>000.<ext>`, `<name>001.<ext>` … |
| `tga`, `dpx` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268) | numbered stills, as above |
| `tga`, `dpx`, `exr` | built in (`raster.rs`: uncompressed Targa 24/32-bit; DPX 10-bit RGB, SMPTE ST 268; OpenEXR half-float linear RGB(A), uncompressed scan lines) | numbered stills, as above |
| `gif`, `wav`, `aiff` | built in / `image` | GIF / RIFF WAVE (`WAVE_FORMAT_EXTENSIBLE` for 5.1) / AIFF (16- or 24-bit PCM) |
- **Reproducible.** The same project and settings give the same file on every machine. Frames render
+1 -1
View File
@@ -55,7 +55,7 @@ only). "Tested" names the evidence.
| PNG, JPEG, TIFF, BMP, GIF | read + write | yes | PNG / TIFF / BMP / JPEG sequences, GIF | 90% |
| WebP | — | yes | no | ahead |
| **PSD** (layers as sequence) | read (layered) | no | — | 0% |
| **OpenEXR** | read + write | no | no | 0% |
| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% |
| **DPX** | read + write | no | sequences (10-bit RGB, BT.709; #779) | 40% |
| Targa | read + write | no | sequences (24-bit, 32-bit with alpha; #779) | 40% |
| HEIF / HEIC | read | no | — | 0% |
+3 -2
View File
@@ -187,10 +187,11 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement**
## G13. Delivery formats
- **Missing:** AV1 export (software or hardware), MPEG-2 / DVD / Blu-ray, DCP, JPEG 2000 MXF,
AS-10 / AS-11, XDCAM HD MXF, P2, OpenEXR sequences, MP3 and AAC-only audio,
AS-10 / AS-11, XDCAM HD MXF, P2, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 and AAC-only audio,
ProRes 4444 / XQ in the export UI (#342; the encoder has the profiles), interlaced encoding,
smart render, HEVC software encoder (HEVC exists only where a hardware encoder does).
- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779).
- **Have:** PNG, TIFF, BMP, JPEG, Targa and DPX (10-bit) image sequences (#779); OpenEXR
sequences (half float, linear, uncompressed; #806).
- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing