diff --git a/Cargo.lock b/Cargo.lock index f45086f9b..162594099 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1555,6 +1555,7 @@ dependencies = [ "effectcraft-render", "effectcraft-time", "effectcraft-vp9enc", + "exr", "filmcraft-aac", "filmcraft-h264enc", "filmcraft-isobmff", diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index f2c185262..c477d2787 100644 --- a/crates/export/Cargo.toml +++ b/crates/export/Cargo.toml @@ -23,6 +23,7 @@ effectcraft-vp9enc = { workspace = true } effectcraft-hevcenc = { workspace = true } effectcraft-av1enc = { workspace = true } image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] } +exr = { workspace = true } gif = { workspace = true } rayon = { workspace = true } web-time = { workspace = true } diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 1b69a87bd..560d07978 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -297,7 +297,7 @@ struct StillOutput { fn write_still(cx: &Cx, file: &mut W, img: &Image, comp: &Comp, output: StillOutput) -> Result<()> { let StillOutput { format: fmt, channels, size: (w, h), quality } = output; - use image::{ExtendedColorType, ImageEncoder, ImageFormat}; + use image::{ExtendedColorType, ImageEncoder}; match fmt { OutputFormat::ExrSequence => { // Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background. @@ -326,8 +326,16 @@ fn write_still(cx: &Cx, file: &mut W, img: &I data.extend_from_slice(&[c[0], c[1], c[2], a]); } } - let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?; - image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string())) + // Scan lines with ZIP like `comp.saveFrameAsExr`: `image`'s encoder writes 64×64 RLE tiles, + // which FilmCraft and other scan-line readers refuse (#2805). + use exr::prelude::{Encoding, Layer, LayerAttributes, SpecificChannels, Vec2, WritableImage}; + let row = w as usize * 4; + let pixels = SpecificChannels::rgba(|p: Vec2| { + let o = p.y() * row + p.x() * 4; + (data[o], data[o + 1], data[o + 2], data[o + 3]) + }); + let layer = Layer::new((w as usize, h as usize), LayerAttributes::default(), Encoding::SMALL_LOSSLESS, pixels); + exr::image::Image::from_layer(layer).write().to_buffered(file).map_err(|e| ExportError::Encode(e.to_string())) } OutputFormat::JpegSequence => { let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h); diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs index 477191f64..fbad2ca48 100644 --- a/crates/export/tests/it/roundtrip.rs +++ b/crates/export/tests/it/roundtrip.rs @@ -302,6 +302,22 @@ fn exr_sequence_is_linear() { assert!((c[2] - 0.1329).abs() < 0.01 && (c[3] - 1.0).abs() < 1e-6, "{c:?}"); } +/// Sequence frames are scan-line EXR, not tiled, so scan-line readers such as FilmCraft open +/// them (#2805). +#[test] +fn exr_sequence_writes_scan_lines() { + let (p, cid) = project(None); + let d = out_dir("seq-exr-scanline"); + let om = OutputModule::for_format(OutputFormat::ExrSequence); + run(&p, cid, &NoFootage, &om, &d.join("f_[#####].exr")); + let bytes = std::fs::read(d.join("f_00000.exr")).expect("exr"); + // Version field: 2, then the flags byte whose 0x02 bit marks a single-part tiled file. + assert_eq!(&bytes[4..6], &[2, 0], "{:02x?}", &bytes[..8]); + let meta = exr::meta::MetaData::read_from_buffered(std::io::Cursor::new(&bytes), false).expect("exr header"); + assert_eq!(meta.headers[0].blocks, exr::meta::BlockDescription::ScanLines); + assert_eq!(meta.headers[0].compression, exr::compression::Compression::ZIP16); +} + /// A 32 bpc project whose only layer is a full-frame opaque solid of the given colour. fn bright_project(color: [f32; 3]) -> (Project, ItemId) { let mut p = Project::default();