mirror of
https://github.com/storytold/filmcraft.git
synced 2026-10-14 19:26:59 -04:00
#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:
1 parent
9f6d75350c
commit
fb5863bc8a
13 files changed
+326
-18
No files matched your search
@@ -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
|
||||
),
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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)?
|
||||
}
|
||||
|
||||
@@ -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
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
@@ -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.
|
@@ -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.
|
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in new issue
Block a user