Merge pull request #879 from vangel76/gap-exr-compression

#874: compressed (RLE / ZIPS / ZIP) OpenEXR export
This commit is contained in:
Brandon Thomas 2026-10-11 22:01:49 -04:00
commit 9432300f0e
15 changed files with 994 additions and 73 deletions

View File

@ -1545,7 +1545,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|exr|gif|wav|aiff|aac|flac"?,"audioCodec":"auto|aac|pcm|flac"?,"flacLevel":0..8=5,"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|aac|flac"?,"audioCodec":"auto|aac|pcm|flac"?,"flacLevel":0..8=5,"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,"exrCompression":"none|rle|zips|zip"?,"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
),

View File

@ -551,6 +551,21 @@ fn crf_param_picks_constant_quality() {
assert!(e.contains("crf"), "{e}");
}
#[test]
fn exr_compression_param_is_parsed_and_defaults_to_none() {
use filmcraft_export::ExrCompression::{None as Plain, Rle, Zip, Zips};
let s = demo();
let setting = |p: Value| crate::export_tools::settings_from_params(&s, &p, "file.exportMedia").map(|(_, st)| st.exr_compression);
assert_eq!(setting(json!({"path": "x.exr", "format": "exr"})).unwrap(), Plain);
for (name, want) in [("none", Plain), ("rle", Rle), ("zips", Zips), ("zip", Zip)] {
assert_eq!(setting(json!({"path": "x.exr", "format": "exr", "exrCompression": name})).unwrap(), want);
}
let e = setting(json!({"path": "x.exr", "format": "exr", "exrCompression": "piz"})).unwrap_err().to_string();
assert!(e.contains("none | rle | zips | zip"), "{e}");
let old: filmcraft_export::ExportSettings = serde_json::from_value(json!({"format": "exr"})).unwrap();
assert_eq!(old.exr_compression, Plain);
}
#[test]
fn queue_exports_several_sequences_and_ranges() {
let mut s = demo();

View File

@ -301,6 +301,9 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
if let Some(v) = bool_p(p, "alpha") {
settings.alpha = v;
}
if let Some(v) = str_p(p, "exrCompression") {
settings.exr_compression = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "exrCompression: none | rle | zips | zip"))?;
}
match (crate::commands::checked_u32_p(p, "width", cmd)?, crate::commands::checked_u32_p(p, "height", cmd)?) {
(Some(w), Some(h)) => settings.frame_size = Some((w, h)),
(None, None) => {}

View File

@ -0,0 +1,609 @@
//! A small DEFLATE encoder (RFC 1951) in a zlib wrapper (RFC 1950), written from the RFCs for the
//! ZIP-compressed OpenEXR chunks.
//!
//! Greedy LZ77 over a 32 KB window (hash chains of bounded length), then each block of tokens is
//! written as whichever of stored, fixed-Huffman or dynamic-Huffman is smallest. Huffman code
//! lengths are limited (15 bits, 7 for the code-length code) by halving the symbol frequencies
//! until the tree fits. Every code has at least two symbols, so all codes are complete. The
//! output favours simplicity and correctness over ratio; worst-case expansion is the stored-block
//! overhead (5 bytes per 64 KB).
use std::cmp::Reverse;
use std::collections::BinaryHeap;
const WINDOW: usize = 32 * 1024;
const MIN_MATCH: usize = 3;
const MAX_MATCH: usize = 258;
/// Hash chain candidates tried per position.
const MAX_CHAIN: usize = 48;
/// Tokens per block (a new set of Huffman codes after this many).
const BLOCK_TOKENS: usize = 1 << 15;
const MAX_STORED: usize = 65_535;
const LEN_BASE: [u16; 29] = [3, 4, 5, 6, 7, 8, 9, 10, 11, 13, 15, 17, 19, 23, 27, 31, 35, 43, 51, 59, 67, 83, 99, 115, 131, 163, 195, 227, 258];
const LEN_EXTRA: [u8; 29] = [0, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 2, 2, 2, 2, 3, 3, 3, 3, 4, 4, 4, 4, 5, 5, 5, 5, 0];
const DIST_BASE: [u16; 30] =
[1, 2, 3, 4, 5, 7, 9, 13, 17, 25, 33, 49, 65, 97, 129, 193, 257, 385, 513, 769, 1025, 1537, 2049, 3073, 4097, 6145, 8193, 12289, 16385, 24577];
const DIST_EXTRA: [u8; 30] = [0, 0, 0, 0, 1, 1, 2, 2, 3, 3, 4, 4, 5, 5, 6, 6, 7, 7, 8, 8, 9, 9, 10, 10, 11, 11, 12, 12, 13, 13];
/// Order in which the code-length code's lengths are sent.
const CL_ORDER: [usize; 19] = [16, 17, 18, 0, 8, 7, 9, 6, 10, 5, 11, 4, 12, 3, 13, 2, 14, 1, 15];
/// `data` compressed as a zlib stream (CMF 0x78: deflate, 32 KB window).
pub(crate) fn zlib(data: &[u8]) -> Vec<u8> {
let mut w = Bits { out: Vec::with_capacity(data.len() / 2 + 64), acc: 0, n: 0 };
w.out.extend_from_slice(&[0x78, 0x9C]); // (0x78 << 8 | 0x9C) % 31 == 0
deflate(data, &mut w);
w.align();
w.out.extend_from_slice(&adler32(data).to_be_bytes());
w.out
}
/// Adler-32 (RFC 1950 §9); 5552 bytes is the longest run that cannot overflow the sums.
fn adler32(data: &[u8]) -> u32 {
let (mut a, mut b) = (1_u32, 0_u32);
for c in data.chunks(5552) {
for &x in c {
a += u32::from(x);
b += a;
}
a %= 65_521;
b %= 65_521;
}
(b << 16) | a
}
/// LSB-first bit writer.
struct Bits {
out: Vec<u8>,
acc: u64,
n: u32,
}
impl Bits {
/// The low `n` bits of `v` (n ≤ 32).
fn put(&mut self, v: u32, n: u32) {
let mask = (1_u64 << n) - 1;
self.acc |= (u64::from(v) & mask) << self.n;
self.n += n;
while self.n >= 8 {
self.out.push(self.acc as u8);
self.acc >>= 8;
self.n -= 8;
}
}
fn align(&mut self) {
if self.n > 0 {
self.out.push(self.acc as u8);
}
self.acc = 0;
self.n = 0;
}
}
#[derive(Clone, Copy)]
enum Tok {
Lit(u8),
Match { len: u16, dist: u16 },
}
/// Index into [`LEN_BASE`] of a match length (3..=258).
fn len_code(len: u16) -> usize {
LEN_BASE.partition_point(|&b| b <= len).saturating_sub(1)
}
/// Index into [`DIST_BASE`] of a distance (1..=32768).
fn dist_code(dist: u16) -> usize {
DIST_BASE.partition_point(|&b| b <= dist).saturating_sub(1)
}
/// LZ77 match finder: hash heads and chains over the window.
struct Matcher<'a> {
data: &'a [u8],
head: Vec<u32>,
prev: Vec<u32>,
bits: u32,
}
impl<'a> Matcher<'a> {
fn new(data: &'a [u8]) -> Self {
// tables sized to the input, so short chunks (single scan lines) stay cheap
let span = data.len().clamp(256, WINDOW).next_power_of_two();
let bits = span.trailing_zeros().clamp(8, 15);
Matcher { data, head: vec![0; 1 << bits], prev: vec![0; span], bits }
}
fn hash(&self, p: usize) -> Option<usize> {
let b = self.data.get(p..p + MIN_MATCH)?;
let v = (u32::from(b[0]) << 16) | (u32::from(b[1]) << 8) | u32::from(b[2]);
Some((v.wrapping_mul(0x9E37_79B1) >> (32 - self.bits)) as usize)
}
/// Record position `p` (stored as `p + 1`; 0 = empty).
fn insert(&mut self, p: usize) {
let Some(h) = self.hash(p) else { return };
let mask = self.prev.len() - 1;
self.prev[p & mask] = self.head[h];
self.head[h] = p as u32 + 1;
}
/// The longest earlier match at `p`: (length, distance), length 0 if none.
fn longest(&self, p: usize) -> (usize, usize) {
let Some(h) = self.hash(p) else { return (0, 0) };
let max = (self.data.len() - p).min(MAX_MATCH);
let here = &self.data[p..p + max];
let mask = self.prev.len() - 1;
let (mut best, mut best_dist) = (0, 0);
let mut cand = self.head[h] as usize;
for _ in 0..MAX_CHAIN {
let Some(c) = cand.checked_sub(1) else { break };
if c >= p || p - c > WINDOW {
break;
}
let l = self.data[c..].iter().zip(here).take_while(|(a, b)| a == b).count();
if l > best {
(best, best_dist) = (l, p - c);
if l == max {
break;
}
}
let next = self.prev[c & mask] as usize;
// chains only go back; anything else is a stale slot
if next == 0 || next > c {
break;
}
cand = next;
}
(best, best_dist)
}
}
/// The deflate blocks of `data`, the last one final.
fn deflate(data: &[u8], w: &mut Bits) {
if data.is_empty() {
// a final fixed-Huffman block holding only end-of-block (code 0000000)
w.put(1, 1);
w.put(1, 2);
w.put(0, 7);
return;
}
// positions are kept in u32; longer inputs (never an OpenEXR chunk) go out stored
let mut m = (data.len() < u32::MAX as usize).then(|| Matcher::new(data));
let mut toks = Vec::with_capacity(BLOCK_TOKENS.min(data.len()) + 1);
let (mut start, mut i) = (0, 0);
while i < data.len() {
let (len, dist) = m.as_ref().map_or((0, 0), |m| m.longest(i));
let step = if len >= MIN_MATCH {
toks.push(Tok::Match { len: len as u16, dist: dist as u16 });
len
} else {
toks.push(Tok::Lit(data[i]));
1
};
if let Some(m) = m.as_mut() {
for p in i..i + step {
m.insert(p);
}
}
i += step;
if toks.len() >= BLOCK_TOKENS || i == data.len() {
block(w, &toks, &data[start..i], i == data.len());
toks.clear();
start = i;
}
}
}
/// Bits the tokens take with the given code lengths (EOB included).
fn data_bits(lf: &[u32], df: &[u32], ll: &[u8], dl: &[u8]) -> u64 {
let mut n = 0_u64;
for (s, (&f, &l)) in lf.iter().zip(ll).enumerate() {
let extra = s.checked_sub(257).and_then(|k| LEN_EXTRA.get(k)).copied().unwrap_or(0);
n += u64::from(f) * u64::from(l + extra);
}
for (d, (&f, &l)) in df.iter().zip(dl).enumerate() {
n += u64::from(f) * u64::from(l + DIST_EXTRA.get(d).copied().unwrap_or(0));
}
n
}
/// One block (split into several stored blocks when stored is smallest and `raw` is long).
fn block(w: &mut Bits, toks: &[Tok], raw: &[u8], last: bool) {
let mut lf = [0_u32; 286];
let mut df = [0_u32; 30];
for t in toks {
match *t {
Tok::Lit(b) => lf[b as usize] += 1,
Tok::Match { len, dist } => {
lf[257 + len_code(len)] += 1;
df[dist_code(dist)] += 1;
}
}
}
lf[256] += 1;
// dynamic codes and their header
let ll = lengths(&lf, 15);
let dl = lengths(&df, 15);
let hlit = ll.iter().rposition(|&l| l > 0).map_or(257, |p| (p + 1).max(257));
let hdist = dl.iter().rposition(|&l| l > 0).map_or(1, |p| p + 1);
let seq = rle_lengths(&[&ll[..hlit], &dl[..hdist]].concat());
let mut cf = [0_u32; 19];
for &(s, _) in &seq {
cf[s as usize] += 1;
}
let cl = lengths(&cf, 7);
let hclen = CL_ORDER.iter().rposition(|&s| cl[s] > 0).map_or(4, |p| (p + 1).max(4));
let seq_bits: u64 = seq.iter().map(|&(s, _)| u64::from(cl[s as usize]) + [2_u64, 3, 7].get((s as usize).wrapping_sub(16)).copied().unwrap_or(0)).sum();
let dyn_bits = 3 + 14 + 3 * hclen as u64 + seq_bits + data_bits(&lf, &df, &ll, &dl);
let (fll, fdl) = fixed_lengths();
let fixed_bits = 3 + data_bits(&lf, &df, &fll, &fdl);
// per stored piece: header, at most 7 padding bits, LEN and NLEN
let pieces = raw.len().div_ceil(MAX_STORED).max(1) as u64;
let stored_bits = pieces * (3 + 7 + 32) + 8 * raw.len() as u64;
if stored_bits <= fixed_bits.min(dyn_bits) {
let n = raw.chunks(MAX_STORED).count();
for (k, piece) in raw.chunks(MAX_STORED).enumerate() {
w.put(u32::from(last && k + 1 == n), 1);
w.put(0, 2);
w.align();
let len = piece.len() as u16;
w.out.extend_from_slice(&len.to_le_bytes());
w.out.extend_from_slice(&(!len).to_le_bytes());
w.out.extend_from_slice(piece);
}
return;
}
w.put(u32::from(last), 1);
if fixed_bits <= dyn_bits {
w.put(1, 2);
write_tokens(w, toks, &fll, &fdl);
return;
}
w.put(2, 2);
w.put((hlit - 257) as u32, 5);
w.put((hdist - 1) as u32, 5);
w.put((hclen - 4) as u32, 4);
for &s in &CL_ORDER[..hclen] {
w.put(u32::from(cl[s]), 3);
}
let cc = codes(&cl);
for &(s, x) in &seq {
w.put(u32::from(cc[s as usize]), u32::from(cl[s as usize]));
match s {
16 => w.put(u32::from(x), 2),
17 => w.put(u32::from(x), 3),
18 => w.put(u32::from(x), 7),
_ => {}
}
}
write_tokens(w, toks, &ll, &dl);
}
/// The fixed Huffman code lengths (RFC 1951 §3.2.6).
fn fixed_lengths() -> ([u8; 288], [u8; 30]) {
let mut ll = [8_u8; 288];
ll[144..256].fill(9);
ll[256..280].fill(7);
(ll, [5; 30])
}
fn write_tokens(w: &mut Bits, toks: &[Tok], ll: &[u8], dl: &[u8]) {
let lc = codes(ll);
let dc = codes(dl);
let sym = |w: &mut Bits, s: usize| w.put(u32::from(lc[s]), u32::from(ll[s]));
for t in toks {
match *t {
Tok::Lit(b) => sym(w, b as usize),
Tok::Match { len, dist } => {
let k = len_code(len);
sym(w, 257 + k);
w.put(u32::from(len - LEN_BASE[k]), u32::from(LEN_EXTRA[k]));
let d = dist_code(dist);
w.put(u32::from(dc[d]), u32::from(dl[d]));
w.put(u32::from(dist - DIST_BASE[d]), u32::from(DIST_EXTRA[d]));
}
}
}
sym(w, 256);
}
/// Canonical Huffman codes for `lens`, bit-reversed for the LSB-first writer.
fn codes(lens: &[u8]) -> Vec<u16> {
let mut count = [0_u32; 16];
for &l in lens {
if (1..16).contains(&l) {
count[l as usize] += 1;
}
}
let mut next = [0_u32; 16];
let mut code = 0;
for bits in 1..16 {
code = (code + count[bits - 1]) << 1;
next[bits] = code;
}
lens.iter()
.map(|&l| {
if !(1..16).contains(&l) {
return 0;
}
let c = next[l as usize];
next[l as usize] += 1;
(c.reverse_bits() >> (32 - u32::from(l))) as u16
})
.collect()
}
/// Code lengths for `freq`, none longer than `limit`; at least two symbols always get a code.
fn lengths(freq: &[u32], limit: u8) -> Vec<u8> {
let mut f: Vec<u64> = freq.iter().map(|&x| u64::from(x)).collect();
let mut used = f.iter().filter(|&&x| x > 0).count();
for x in f.iter_mut() {
if used >= 2 {
break;
}
if *x == 0 {
*x = 1;
used += 1;
}
}
// halving converges: once every used frequency is 1 the tree is balanced (depth ≤ 9 here)
for _ in 0..66 {
let l = huffman(&f);
if l.iter().all(|&d| d <= limit) {
return l;
}
for x in f.iter_mut().filter(|x| **x > 0) {
*x = (*x >> 1).max(1);
}
}
huffman(&f)
}
/// Huffman tree depths of the symbols with non-zero frequency (0 for the rest).
fn huffman(f: &[u64]) -> Vec<u8> {
let n = f.len();
let mut heap: BinaryHeap<Reverse<(u64, usize)>> = f.iter().enumerate().filter(|(_, w)| **w > 0).map(|(i, &w)| Reverse((w, i))).collect();
let mut parent = vec![usize::MAX; 2 * n];
let mut next = n;
while heap.len() > 1 {
let (Some(Reverse((wa, a))), Some(Reverse((wb, b)))) = (heap.pop(), heap.pop()) else { break };
parent[a] = next;
parent[b] = next;
heap.push(Reverse((wa + wb, next)));
next += 1;
}
// parents always come after their children, so walk down from the root
let mut depth = vec![0_u32; next];
for i in (0..next).rev() {
if let Some(&p) = parent.get(i).filter(|&&p| p < next) {
depth[i] = depth[p].saturating_add(1);
}
}
depth[..n].iter().map(|&d| d.min(u32::from(u8::MAX)) as u8).collect()
}
/// Code lengths run-length coded with the code-length alphabet: (symbol, extra-bits value).
fn rle_lengths(lens: &[u8]) -> Vec<(u8, u8)> {
let mut out = Vec::new();
let mut i = 0;
while i < lens.len() {
let l = lens[i];
let run = lens[i..].iter().take_while(|&&x| x == l).count();
let mut r = run;
if l == 0 {
while r >= 11 {
let k = r.min(138);
out.push((18, (k - 11) as u8));
r -= k;
}
if r >= 3 {
out.push((17, (r - 3) as u8));
r = 0;
}
} else {
out.push((l, 0));
r -= 1;
while r >= 3 {
let k = r.min(6);
out.push((16, (k - 3) as u8));
r -= k;
}
}
out.extend(std::iter::repeat_n((l, 0), r));
i += run;
}
out
}
#[cfg(test)]
pub(crate) mod tests {
use super::*;
/// A minimal inflater (RFC 1951), only to check the encoder; returns None on any error.
pub(crate) fn inflate(z: &[u8]) -> Option<Vec<u8>> {
let (cmf, flg) = (*z.first()?, *z.get(1)?);
if cmf & 0x0F != 8 || (u16::from(cmf) << 8 | u16::from(flg)) % 31 != 0 || flg & 0x20 != 0 {
return None;
}
let mut pos = 16_usize; // bit position
let bit = |pos: &mut usize| -> Option<u32> {
let b = (*z.get(*pos / 8)? >> (*pos % 8)) & 1;
*pos += 1;
Some(u32::from(b))
};
let bits = |pos: &mut usize, n: u32| -> Option<u32> {
let mut v = 0;
for k in 0..n {
v |= bit(pos)? << k;
}
Some(v)
};
// decode one symbol with canonical lengths (slow but simple)
let decode = |pos: &mut usize, lens: &[u8]| -> Option<usize> {
let (mut code, mut first, mut index) = (0_i32, 0_i32, 0_i32);
let mut count = [0_i32; 16];
for &l in lens {
count[l as usize] += 1;
}
let mut sorted: Vec<usize> = (0..lens.len()).filter(|&s| lens[s] > 0).collect();
sorted.sort_by_key(|&s| (lens[s], s));
for len in 1..16 {
code |= bit(pos)? as i32;
let c = count[len];
if code - c < first {
return sorted.get((index + code - first) as usize).copied();
}
index += c;
first = (first + c) << 1;
code <<= 1;
}
None
};
let mut out = Vec::new();
loop {
let last = bit(&mut pos)?;
match bits(&mut pos, 2)? {
0 => {
pos = pos.div_ceil(8) * 8;
let at = pos / 8;
let len = u16::from_le_bytes([*z.get(at)?, *z.get(at + 1)?]);
let nlen = u16::from_le_bytes([*z.get(at + 2)?, *z.get(at + 3)?]);
if len != !nlen {
return None;
}
out.extend_from_slice(z.get(at + 4..at + 4 + len as usize)?);
pos = (at + 4 + len as usize) * 8;
}
t @ (1 | 2) => {
let (ll, dl) = if t == 1 {
let (a, b) = fixed_lengths();
(a.to_vec(), b.to_vec())
} else {
let hlit = bits(&mut pos, 5)? as usize + 257;
let hdist = bits(&mut pos, 5)? as usize + 1;
let hclen = bits(&mut pos, 4)? as usize + 4;
let mut cl = [0_u8; 19];
for &s in &CL_ORDER[..hclen] {
cl[s] = bits(&mut pos, 3)? as u8;
}
let mut lens = Vec::new();
while lens.len() < hlit + hdist {
match decode(&mut pos, &cl)? {
s @ 0..=15 => lens.push(s as u8),
16 => {
let p = *lens.last()?;
lens.extend(std::iter::repeat_n(p, 3 + bits(&mut pos, 2)? as usize));
}
17 => lens.extend(std::iter::repeat_n(0, 3 + bits(&mut pos, 3)? as usize)),
_ => lens.extend(std::iter::repeat_n(0, 11 + bits(&mut pos, 7)? as usize)),
}
}
if lens.len() != hlit + hdist {
return None;
}
(lens[..hlit].to_vec(), lens[hlit..].to_vec())
};
loop {
let s = decode(&mut pos, &ll)?;
if s < 256 {
out.push(s as u8);
} else if s == 256 {
break;
} else {
let k = s - 257;
let len = *LEN_BASE.get(k)? as usize + bits(&mut pos, u32::from(LEN_EXTRA[k]))? as usize;
let d = decode(&mut pos, &dl)?;
let dist = *DIST_BASE.get(d)? as usize + bits(&mut pos, u32::from(DIST_EXTRA[d]))? as usize;
let from = out.len().checked_sub(dist)?;
for j in 0..len {
out.push(out[from + j]);
}
}
}
}
_ => return None,
}
if last == 1 {
break;
}
}
let at = pos.div_ceil(8);
let sum = u32::from_be_bytes(z.get(at..at + 4)?.try_into().ok()?);
(sum == adler32(&out) && at + 4 == z.len()).then_some(out)
}
/// A deterministic pseudo-random byte stream.
fn noise(n: usize, seed: u32) -> Vec<u8> {
let mut s = seed | 1;
(0..n)
.map(|_| {
s ^= s << 13;
s ^= s >> 17;
s ^= s << 5;
s as u8
})
.collect()
}
#[test]
fn round_trips() {
let mut cases: Vec<Vec<u8>> = vec![vec![], vec![7], vec![1, 2], vec![0; 1], vec![0; 300_000], b"abcabcabcabcabcabd".to_vec()];
cases.push(noise(100_000, 9)); // incompressible: stored blocks
cases.push(noise(70_000, 3).iter().map(|b| b % 4).collect()); // few symbols: dynamic
cases.push((0..200_000_u32).map(|i| ((i / 7) % 251) as u8).collect()); // long repeats
// text-like: a small vocabulary repeated at varying distances
let words: [&[u8]; 6] = [b"red ", b"green ", b"blue ", b"alpha ", b"half ", b"linear "];
cases.push(noise(40_000, 5).iter().flat_map(|b| words[*b as usize % 6].iter().copied()).collect());
// skewed frequencies: Huffman lengths beyond 15 before limiting
let mut fib = Vec::new();
let (mut a, mut b) = (1_usize, 1_usize);
for s in 0..25_u8 {
fib.extend(std::iter::repeat_n(s.wrapping_mul(37), a.min(200_000)));
(a, b) = (b, a + b);
}
let r = noise(fib.len() * 4, 11);
for i in (1..fib.len()).rev() {
let j = u32::from_le_bytes(r[i * 4..i * 4 + 4].try_into().unwrap()) as usize % (i + 1);
fib.swap(i, j);
}
cases.push(fib);
for (k, data) in cases.iter().enumerate() {
let z = zlib(data);
assert_eq!(inflate(&z).as_deref(), Some(data.as_slice()), "case {k}");
// worst case: stored overhead only
assert!(z.len() <= data.len() + (data.len().div_ceil(BLOCK_TOKENS) + 1) * 5 + 6, "case {k}: {} > {}", z.len(), data.len());
}
// repetitive data actually compresses
assert!(zlib(&cases[4]).len() < 2_000);
assert!(zlib(&cases[8]).len() < 20_000);
}
#[test]
fn length_limits_hold() {
// Fibonacci frequencies give the deepest possible Huffman tree
let mut f = vec![0_u32; 286];
let (mut a, mut b) = (1_u32, 1_u32);
for x in f.iter_mut().take(30) {
*x = a;
(a, b) = (b, a.saturating_add(b));
}
let l = lengths(&f, 15);
assert!(l.iter().all(|&d| d <= 15));
// a complete code: Kraft sum exactly 1
let kraft: f64 = l.iter().filter(|&&d| d > 0).map(|&d| 0.5_f64.powi(i32::from(d))).sum();
assert!((kraft - 1.0).abs() < 1e-12);
// one used symbol still gets a two-symbol code
let l = lengths(&[0, 5, 0], 7);
assert_eq!(l.iter().filter(|&&d| d == 1).count(), 2);
}
#[test]
fn adler_matches_rfc_example() {
// the classic test vector for "Wikipedia"
assert_eq!(adler32(b"Wikipedia"), 0x11E6_0398);
assert_eq!(adler32(&[]), 1);
}
}

View File

@ -17,6 +17,7 @@ use std::io::Write;
mod aac_out;
mod audio_out;
mod deflate;
mod job;
mod mxf_out;
mod pace;
@ -347,6 +348,9 @@ pub struct ExportSettings {
/// Other formats ignore it.
#[serde(default)]
pub alpha: bool,
/// Compression of OpenEXR sequences (uncompressed unless set); other formats ignore it.
#[serde(default)]
pub exr_compression: ExrCompression,
/// Output frame size (None = Match Source: the sequence size times `scale`).
pub frame_size: Option<(u32, u32)>,
/// Output frame rate (None = Match Source).
@ -560,6 +564,7 @@ impl Default for ExportSettings {
mxf_video_codec: MxfVideoCodec::default(),
sdr: false,
alpha: false,
exr_compression: ExrCompression::None,
frame_size: None,
frame_rate: None,
scaling: Scaling::default(),
@ -1785,6 +1790,11 @@ pub fn encode_still(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool)
/// [`encode_still`] with the JPEG quality (1–100; ignored by the lossless formats).
pub fn encode_still_with_quality(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool, quality: u8) -> Result<Vec<u8>> {
encode_still_with(format, rgba, w, h, alpha, quality, ExrCompression::None)
}
/// [`encode_still_with_quality`] with the OpenEXR compression (ignored by the other formats).
pub fn encode_still_with(format: Format, rgba: Vec<u8>, w: u32, h: u32, alpha: bool, quality: u8, exr: ExrCompression) -> Result<Vec<u8>> {
let enc = |e: image::ImageError| ExportError::Encode(e.to_string());
let mut out = std::io::Cursor::new(Vec::new());
match format {
@ -1801,7 +1811,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::ExrSequence => return raster::exr(&rgba, w, h, alpha, exr),
Format::PngSequence => {
image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)?
}
@ -1939,7 +1949,15 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
let written: Vec<Result<u64>> = (f..end)
.into_par_iter()
.map(|fi| {
let data = encode_still_with_quality(settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha, settings.quality)?;
let data = encode_still_with(
settings.format,
pipe.frame(fi, sources).0,
w,
h,
settings.alpha,
settings.quality,
settings.exr_compression,
)?;
write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data)
})
.collect();

View File

@ -5,9 +5,11 @@
//! (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};
//! B, G, R (and A) channels, scene-linear Rec.709 values with premultiplied alpha, as EXR readers
//! expect; uncompressed, RLE, ZIPS (zlib per scan line) or ZIP (zlib per 16 scan lines). The
//! compressed modes first split each chunk's bytes into even and odd halves and delta-code them
//! (the format's predictor); a chunk that would not shrink is stored raw, as the format allows.
use crate::{ExportError, ExrCompression, Result};
/// `w * h * 4` when `rgba` holds exactly that many bytes.
fn checked_len(rgba: &[u8], w: u32, h: u32) -> Result<usize> {
@ -164,15 +166,20 @@ fn half(v: f32) -> 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>> {
pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool, compression: ExrCompression) -> Result<Vec<u8>> {
let n = checked_len(rgba, w, h)?;
let too_large = || ExportError::Unsupported(format!("an OpenEXR frame of {w}×{h} is too large"));
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")));
return Err(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")))?;
// a chunk's byte count is a signed 32-bit field
let lines = compression.lines();
if line.checked_mul(lines).and_then(|c| i32::try_from(c).ok()).is_none() {
return Err(too_large());
}
let mut hd = Vec::with_capacity(512);
hd.extend_from_slice(&[0x76, 0x2F, 0x31, 0x01]); // magic number 20000630
@ -198,7 +205,7 @@ pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
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, "compression", "compression", &[compression.code()]);
attr(&mut hd, "dataWindow", "box2i", &window);
attr(&mut hd, "displayWindow", "box2i", &window);
attr(&mut hd, "lineOrder", "lineOrder", &[0]); // INCREASING_Y
@ -207,45 +214,102 @@ pub(crate) fn exr(rgba: &[u8], w: u32, h: u32, alpha: bool) -> Result<Vec<u8>> {
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);
// offset table (one entry per chunk), then the chunks: first y, data size, data
let chunks = (h as usize).div_ceil(lines);
let table = chunks.checked_mul(8);
let body = (h as usize).checked_mul(line + 8);
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()))?;
// the uncompressed size: compressed chunks only shrink it
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 table_at = out.len();
out.resize(table_at + chunks * 8, 0);
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];
let wn = w as usize;
let mut px = vec![0_u16; wn * names.len()];
let mut raw = Vec::with_capacity(line * lines);
for (k, block) in rgba[..n].chunks(row * lines).enumerate() {
raw.clear();
for src in block.chunks_exact(row) {
// planar per line: every pixel of the first channel, then the next channel …
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];
}
}
}
raw.extend(px.iter().flat_map(|v| v.to_le_bytes()));
}
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()));
let packed = match compression {
ExrCompression::None => None,
ExrCompression::Rle => Some(rle(&predict(&raw))),
ExrCompression::Zips | ExrCompression::Zip => Some(crate::deflate::zlib(&predict(&raw))),
};
// a chunk that does not shrink is stored as it is (readers compare the sizes)
let data = packed.as_deref().filter(|p| p.len() < raw.len()).unwrap_or(&raw);
let offset = (out.len() as u64).to_le_bytes();
out[table_at + k * 8..table_at + k * 8 + 8].copy_from_slice(&offset);
out.extend_from_slice(&((k * lines) as i32).to_le_bytes());
out.extend_from_slice(&(data.len() as i32).to_le_bytes());
out.extend_from_slice(data);
}
Ok(out)
}
/// The OpenEXR RLE / ZIP predictor: bytes split into even and odd halves, then each byte replaced
/// by its difference from the previous one, plus 128.
fn predict(raw: &[u8]) -> Vec<u8> {
let half = raw.len().div_ceil(2);
let mut t = vec![0_u8; raw.len()];
for (i, &b) in raw.iter().enumerate() {
t[if i % 2 == 0 { i / 2 } else { half + i / 2 }] = b;
}
let mut p = t.first().copied().unwrap_or(0);
for v in t.iter_mut().skip(1) {
let d = v.wrapping_sub(p).wrapping_add(128);
p = *v;
*v = d;
}
t
}
/// OpenEXR run-length coding: a signed count byte, then either `count + 1` copies of one byte
/// (count ≥ 0, runs of 3 to 128) or `-count` literal bytes (1 to 127).
fn rle(src: &[u8]) -> Vec<u8> {
let mut out = Vec::with_capacity(src.len() + src.len() / 127 + 1);
let mut i = 0;
while i < src.len() {
let b = src[i];
let run = src[i..].iter().take(128).take_while(|&&x| x == b).count();
if run >= 3 {
out.extend_from_slice(&[(run - 1) as u8, b]);
i += run;
continue;
}
// literals up to the next run of three (or 127 bytes)
let s = i;
while i < src.len() && i - s < 127 {
if src.get(i..i + 3).is_some_and(|t| t[0] == t[1] && t[1] == t[2]) {
break;
}
i += 1;
}
out.push(((i - s) as u8).wrapping_neg());
out.extend_from_slice(&src[s..i]);
}
out
}
#[cfg(test)]
mod tests {
use super::*;
@ -322,7 +386,7 @@ mod tests {
// 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 f = exr(&px, 2, 1, alpha, ExrCompression::None).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;
@ -353,36 +417,134 @@ mod tests {
}
}
/// A 40×35 test picture: gradients, a flat band and a noisy corner, with varying alpha (35
/// rows: the last 16-line ZIP chunk is short).
fn picture() -> (Vec<u8>, u32, u32) {
let (w, h) = (40_u32, 35_u32);
let px = (0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
.flat_map(|(x, y)| {
let noise = (x * 7919 + y * 104_729) % 251;
let r = if y < 12 {
(x * 6) as u8
} else if y < 20 {
200
} else {
noise as u8
};
[r, (y * 7) as u8, ((x + y) * 3) as u8, [255, 128, 0, 64][(x / 10) as usize % 4]]
})
.collect();
(px, w, h)
}
/// The chunks of an OpenEXR file we wrote: (first y, data).
fn chunks(f: &[u8], count: usize) -> Vec<(u32, &[u8])> {
let end = find(f, b"screenWindowWidth\0float\0") + 24 + 4 + 4 + 1;
(0..count)
.map(|k| {
let off = u64::from_le_bytes(f[end + k * 8..end + k * 8 + 8].try_into().unwrap()) as usize;
let n = le32(f, off + 4) as usize;
(le32(f, off), &f[off + 8..off + 8 + n])
})
.collect()
}
/// Undo [`predict`].
fn unpredict(t: &[u8]) -> Vec<u8> {
let mut t = t.to_vec();
for i in 1..t.len() {
t[i] = t[i - 1].wrapping_add(t[i]).wrapping_sub(128);
}
let half = t.len().div_ceil(2);
(0..t.len()).map(|i| t[if i % 2 == 0 { i / 2 } else { half + i / 2 }]).collect()
}
fn unrle(mut s: &[u8]) -> Vec<u8> {
let mut out = Vec::new();
while let [c, rest @ ..] = s {
let c = *c as i8;
if c < 0 {
out.extend_from_slice(&rest[..c.unsigned_abs() as usize]);
s = &rest[c.unsigned_abs() as usize..];
} else {
out.extend(std::iter::repeat_n(rest[0], c as usize + 1));
s = &rest[1..];
}
}
out
}
#[test]
fn rle_codes_runs_and_literals() {
for src in [vec![], vec![5], vec![1, 1], vec![9; 300], (0..=255).collect::<Vec<u8>>(), [vec![1, 2], vec![3; 3], vec![4, 4, 5]].concat()] {
assert_eq!(unrle(&rle(&src)), src);
}
assert_eq!(rle(&[7; 130]), vec![127, 7, 0xFE, 7, 7]);
assert_eq!(rle(&[1, 2, 2, 2]), vec![0xFF, 1, 2, 2]);
}
/// Every compression stores the same pixels as the uncompressed file, in chunks of the right
/// height, and the compressed files are smaller.
#[test]
fn compressed_exr_holds_the_same_pixels() {
let (px, w, h) = picture();
for alpha in [false, true] {
let plain = exr(&px, w, h, alpha, ExrCompression::None).unwrap();
let lines: Vec<&[u8]> = chunks(&plain, h as usize).into_iter().map(|(_, d)| d).collect();
for c in [ExrCompression::Rle, ExrCompression::Zips, ExrCompression::Zip] {
let f = exr(&px, w, h, alpha, c).unwrap();
let cmp = find(&f, b"compression\0compression\0") + 24;
assert_eq!(f[cmp + 4], c.code());
assert!(f.len() < plain.len() * 3 / 4, "{c:?}: {} vs {}", f.len(), plain.len());
let n = (h as usize).div_ceil(c.lines());
for (k, (y, data)) in chunks(&f, n).into_iter().enumerate() {
assert_eq!(y as usize, k * c.lines());
let want: Vec<u8> = lines[k * c.lines()..((k + 1) * c.lines()).min(h as usize)].concat();
let got = if data.len() == want.len() {
data.to_vec()
} else if c == ExrCompression::Rle {
unpredict(&unrle(data))
} else {
unpredict(&crate::deflate::tests::inflate(data).unwrap())
};
assert_eq!(got, want, "{c:?} chunk {k}");
}
}
}
}
/// ffmpeg (external test oracle) decodes our OpenEXR files to the values we meant: linear
/// light, premultiplied alpha, rows top to bottom.
/// light, premultiplied alpha, rows top to bottom; in every compression.
#[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 (px, w, h) = picture();
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}");
for c in ExrCompression::ALL {
for alpha in [false, true] {
let path = dir.join(format!("f{alpha}-{c:?}.exr"));
std::fs::write(&path, exr(&px, w, h, alpha, c).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(), "{c:?}: {}", String::from_utf8_lossy(&out.stderr));
let n = (w * h) as usize;
assert_eq!(out.stdout.len(), n * 4 * 4, "{c:?}");
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, "{c:?}, alpha {alpha}, pixel {i}, plane {p}: {got} vs {want}");
}
}
}
}
@ -397,8 +559,10 @@ 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());
for c in ExrCompression::ALL {
assert!(exr(&[0; 4], u32::MAX, u32::MAX, true, c).is_err());
assert!(exr(&[0; 7], 2, 1, false, c).is_err());
assert!(exr(&[], 0, 1, false, c).is_err());
}
}
}

View File

@ -160,6 +160,46 @@ impl MxfVideoCodec {
}
}
/// Compression of OpenEXR stills (the file's `compression` attribute). Every mode is lossless.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub enum ExrCompression {
/// Uncompressed.
#[default]
None,
/// Run-length coded bytes (RLE_COMPRESSION), one scan line per chunk.
Rle,
/// zlib (deflate) per scan line (ZIPS_COMPRESSION).
Zips,
/// zlib (deflate) per block of 16 scan lines (ZIP_COMPRESSION).
Zip,
}
impl ExrCompression {
pub const ALL: [ExrCompression; 4] = [ExrCompression::None, ExrCompression::Rle, ExrCompression::Zips, ExrCompression::Zip];
pub fn label(self) -> &'static str {
match self {
ExrCompression::None => "None",
ExrCompression::Rle => "RLE",
ExrCompression::Zips => "ZIP (1 scan line)",
ExrCompression::Zip => "ZIP (16 scan lines)",
}
}
/// The value of the OpenEXR `compression` attribute.
pub(crate) fn code(self) -> u8 {
match self {
ExrCompression::None => 0,
ExrCompression::Rle => 1,
ExrCompression::Zips => 2,
ExrCompression::Zip => 3,
}
}
/// Scan lines per chunk.
pub(crate) fn lines(self) -> usize {
if self == ExrCompression::Zip { 16 } else { 1 }
}
}
/// Audio codec. `Auto` = AAC in MP4 and AAC Audio, PCM in QuickTime / WAV / AIFF, FLAC in FLAC.
/// MP4 and QuickTime can also carry FLAC; MXF is always PCM.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
@ -499,8 +539,11 @@ 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,
// two bytes per half-float channel; lossless compression roughly halves it
Format::ExrSequence => {
let k = if self.exr_compression == ExrCompression::None { 1.0 } else { 0.5 };
px * if self.keeps_alpha() { 8.0 } else { 6.0 } * 8.0 * fps * k
}
Format::Gif => px * 0.6 * 8.0 * fps,
Format::Wav | Format::Aiff | Format::Flac | Format::MxfOp1a | Format::MxfOpAtom => 0.0,
};
@ -587,7 +630,13 @@ 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",
Format::ExrSequence => {
v += ", 16-bit half float, linear";
match self.exr_compression {
ExrCompression::None => v += ", uncompressed",
c => v += &format!(", {} compression", c.label()),
}
}
_ => {}
}
v

View File

@ -203,6 +203,41 @@ fn openexr_sequences() {
}
}
/// Compressed OpenEXR sequences (#874): the export setting reaches every still, which shrinks
/// and (ffmpeg as the external oracle) decodes to the same pixels as the uncompressed file.
#[test]
fn openexr_sequences_compress() {
let (p, seq, m) = matte([0.2, 0.6, 1.0, 0.5], 64, 36, None);
let mut files = Vec::new();
for c in ExrCompression::ALL {
let dir = Scratch::new(&format!("seq-exr-{c:?}"));
let mut s = ExportSettings { format: Format::ExrSequence, path: dir.path("c.exr"), alpha: true, exr_compression: c, ..Default::default() };
s.range = Some(TimeRange::new(Tick::ZERO, FrameRate::FPS_24.tick_of(1)));
export(&p, seq, &s, &m, &Progress::default()).unwrap();
let path = dir.0.join("c000.exr");
let bytes = std::fs::read(&path).unwrap();
let at = bytes.windows(24).position(|w| w == b"compression\0compression\0").unwrap() + 28;
assert_eq!(bytes[at], [0, 1, 2, 3][files.len()], "{c:?}");
files.push((c, path, bytes.len(), dir));
}
let plain = files[0].2;
for (c, _, len, _) in &files[1..] {
assert!(*len < plain / 2, "{c:?}: {len} bytes vs {plain} uncompressed");
}
let Some(ffmpeg) = filmcraft_testkit::ffmpeg_or_skip("compressed OpenEXR sequences") else { return };
let decode = |path: &Path| {
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));
out.stdout
};
let want = decode(&files[0].1);
assert_eq!(want.len(), 64 * 36 * 16);
for (c, path, _, _) in &files[1..] {
assert!(decode(path) == want, "{c:?} decodes differently");
}
}
/// `alpha` keeps straight alpha in PNG and TIFF sequences; off (the default) flattens over black (#160).
#[test]
fn image_sequences_keep_alpha_when_asked() {

View File

@ -3570,6 +3570,9 @@
Edit Project Notes Editar notas del proyecto
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notas de este proyecto: tareas pendientes, instrucciones, números de fotograma… Se guardan con el proyecto.
Use Effect Shutter Angle Usar el ángulo de obturación del efecto
Compression Compresión
ZIP (1 scan line) ZIP (1 línea de exploración)
ZIP (16 scan lines) ZIP (16 líneas de exploración)
Safe graphics mode: {mode} Modo gráfico seguro: {mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un inicio anterior falló con los gráficos predeterminados. Actualizar el controlador gráfico suele solucionarlo.
Use Default Graphics Usar gráficos predeterminados

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3546,6 +3546,9 @@
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. Notes pour ce projet : tâches, instructions, numéros d'image… Enregistrées avec le projet.
Use Effect Shutter Angle Utiliser l'angle d'obturation de l'effet
Settings Préférences
Compression Compression
ZIP (1 scan line) ZIP (1 ligne de balayage)
ZIP (16 scan lines) ZIP (16 lignes de balayage)
Safe graphics mode: {mode} Mode graphique sans échec : {mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. Un démarrage précédent a échoué avec les graphismes par défaut. Mettre à jour le pilote graphique résout généralement le problème.
Use Default Graphics Utiliser les graphismes par défaut

Can't render this file because it contains an unexpected character in line 5 and column 83.

View File

@ -3552,6 +3552,9 @@
Edit Project Notes プロジェクトメモを編集
Notes for this project: to-dos, instructions, frame numbers… Saved with the project. このプロジェクトのメモ: やること、指示、フレーム番号など… プロジェクトと一緒に保存されます。
GPU acceleration is unavailable; preview renders on the CPU GPU アクセラレーションを利用できません。プレビューは CPU でレンダリングされます
Compression 圧縮
ZIP (1 scan line) ZIP(1 スキャンライン)
ZIP (16 scan lines) ZIP(16 スキャンライン)
Caption Tracks キャプショントラック
Delete Caption Track キャプショントラックを削除

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -3548,6 +3548,9 @@
Delete Caption Track 删除字幕轨道
Delete Caption Tracks 删除多个字幕轨道
Delete Empty Caption Tracks 删除空字幕轨道
Compression 压缩
ZIP (1 scan line) ZIP(1 条扫描线)
ZIP (16 scan lines) ZIP(16 条扫描线)
Safe graphics mode: {mode} 安全图形模式:{mode}
An earlier start failed with the default graphics. Updating the graphics driver usually fixes this. 上次启动时默认图形模式失败。更新显卡驱动程序通常可以解决此问题。
Use Default Graphics 使用默认图形

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -23,8 +23,8 @@
use egui::{Align2, Color32, Rect, Sense, pos2, vec2};
use filmcraft_engine::export::presets::{DEFAULT_PRESET, preset_key};
use filmcraft_engine::export::{
AudioCodec, BitrateMode, ExportSettings, FieldOrder, Format, GpuRendering, H264Profile, HardwareEncoding, Multiplexer, MxfVideoCodec, Placement, Scaling,
TextOverlay, builtin_presets, format_bytes,
AudioCodec, BitrateMode, ExportSettings, ExrCompression, FieldOrder, Format, GpuRendering, H264Profile, HardwareEncoding, Multiplexer, MxfVideoCodec,
Placement, Scaling, TextOverlay, builtin_presets, format_bytes,
};
use filmcraft_engine::time::{FrameRate, Tick};
use serde::{Deserialize, Serialize};
@ -810,6 +810,21 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
}
});
}
Format::ExrSequence => {
row(ui, t, tl!("Compression"), |ui| {
// every mode is lossless; RLE and the ZIP variants shrink the files
let label = |c: ExrCompression| match c {
ExrCompression::None => tl!("None"),
ExrCompression::Rle => "RLE",
ExrCompression::Zips => tl!("ZIP (1 scan line)"),
ExrCompression::Zip => tl!("ZIP (16 scan lines)"),
};
let labels: Vec<&str> = ExrCompression::ALL.iter().map(|&c| label(c)).collect();
if let Some(i) = combo(ui, reg, "export.video.exrCompression", label(s.exr_compression), &opts(&labels), 220.0) {
s.exr_compression = ExrCompression::ALL[i];
}
});
}
Format::Mjpeg | Format::JpegSequence => {
row(ui, t, tl!("Quality"), |ui| {
let mut q = s.quality as f64;

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 | sequences (half float, linear RGB / premultiplied RGBA, uncompressed; #806) | 35% |
| **OpenEXR** | read + write | no | sequences (half float, linear RGB / premultiplied RGBA; uncompressed, RLE, ZIPS or ZIP; #806, #874) | 35% |
| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); packed 10/12-bit, Y'CbCr 4:2:2 / 4:4:4 with or without alpha (#841); no RLE | sequences (10-bit RGB, BT.709; #779) | 90% |
| Targa | read + write | yes: every image type, RLE, colour maps, 15–32-bit (#821) | sequences (24-bit, 32-bit with alpha; #779) | 95% |
| HEIF / HEIC | read | no | — | 0% |

View File

@ -216,11 +216,12 @@ 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, compressed (ZIP / PIZ / DWA) OpenEXR, MP3 audio, interlaced
encoding, smart render, HEVC software encoder (HEVC exists only where a hardware encoder does).
AS-10 / AS-11, XDCAM HD MXF, P2, PIZ / PXR24 / B44 / DWA OpenEXR compression, MP3 audio,
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); OpenEXR
sequences (half float, linear, uncompressed; #806); AAC Audio (audio-only `.m4a`, or ADTS
`.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
sequences (half float, linear; uncompressed, RLE, ZIPS or ZIP; #806, #874); AAC Audio
(audio-only `.m4a`, or ADTS `.aac`; #814); ProRes 4444 / 4444 XQ in the export UI (#342).
- **Estimate:** 60–110 h. Doc: [file-format-parity.md](file-format-parity.md).
## G14. Localization: 8 of the 12 key languages missing