diff --git a/crates/engine/src/media_browser.rs b/crates/engine/src/media_browser.rs index 3247a588..24cb4f3a 100644 --- a/crates/engine/src/media_browser.rs +++ b/crates/engine/src/media_browser.rs @@ -194,7 +194,7 @@ pub struct Entry { } const VIDEO: &[&str] = &["mp4", "m4v", "mov", "webm", "mkv", "avi", "mxf", "gif"]; -const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "hdr"]; +const IMAGE: &[&str] = &["png", "jpg", "jpeg", "tif", "tiff", "exr", "bmp", "webp", "psd", "psb", "svg", "tga", "dpx", "cin", "hdr"]; const AUDIO: &[&str] = &["wav", "aif", "aiff", "mp3", "m4a", "aac", "flac", "ogg", "opus"]; const DATA: &[&str] = &["json", "csv", "tsv", "mgjson"]; const MODEL: &[&str] = &["gltf", "glb", "obj"]; diff --git a/crates/engine/src/tests_sequence.rs b/crates/engine/src/tests_sequence.rs index a89485f9..7f937596 100644 --- a/crates/engine/src/tests_sequence.rs +++ b/crates/engine/src/tests_sequence.rs @@ -195,3 +195,15 @@ fn replace_footage_and_reload_keep_sequences() { assert_eq!((f.sequence.len(), f.frame_rate, f.start_timecode), (5, FrameRate::from_f64(12.0), Some(2))); assert_eq!(f.duration, FrameRate::from_f64(12.0).tick_of(5)); } + +/// #756: numbered DPX and Cineon scans import as sequences, as in After Effects (`.cin` was not +/// an image to the Media Browser, so its runs came in one file at a time). +#[test] +fn dpx_and_cineon_runs_import_as_sequences() { + let dir = folder(&["plate.0001.dpx", "plate.0002.dpx", "plate.0003.dpx", "scan_01.cin", "scan_02.cin"]); + let mut s = session(); + let r = s.execute("file.import", json!({"paths": [at(&dir, "plate.0002.dpx"), at(&dir, "scan_01.cin")], "sequence": true})).unwrap(); + let mut got: Vec<(String, usize)> = r["items"].as_array().unwrap().iter().map(|i| footage(&s, i)).map(|(n, f)| (n, f.sequence.len())).collect(); + got.sort(); + assert_eq!(got, [("plate.[0001-0003].dpx".to_string(), 3), ("scan_[01-02].cin".to_string(), 2)], "{r}"); +} diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs new file mode 100644 index 00000000..a5b2abda --- /dev/null +++ b/crates/media/src/dpx.rs @@ -0,0 +1,481 @@ +//! DPX (SMPTE 268M) and Kodak Cineon stills, the film-scan interchange formats, read from the +//! public specifications. +//! +//! Supported: luma, RGB, RGBA and ABGR (DPX) or one / three channels (Cineon), 8-, 10-, 12- and +//! 16-bit unsigned samples, 10-bit filled into 32-bit words with the pad bits low (DPX method A, +//! Cineon "32-bit left justified") or high (method B, "right justified"), 12-bit in 16-bit words, +//! big- and little-endian files, the four unrotated image orientations and the pixel aspect ratio. +//! Code values are shown as stored (a log scan looks flat, as in After Effects without a Cineon +//! conversion); the codec names the transfer (`log`, `linear`, …). Anything else (run-length +//! encoding, signed or float samples, Y'CbCr, packed 10/12-bit, transposed images) is refused +//! with an error naming it. Every field is read with bounds checks. + +use effectcraft_project::{AlphaMode, Footage}; +use rayon::prelude::*; + +use crate::{MediaError, Result}; + +/// File extensions of DPX and Cineon files. +pub(crate) const EXTENSIONS: &[&str] = &["dpx", "cin"]; + +/// Largest image accepted (8192 × 8192): bigger headers are refused before allocating. +const MAX_PIXELS: u64 = 1 << 26; + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Kind { + Dpx, + Cineon, +} + +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Layout { + Luma, + Rgb, + Rgba, + Abgr, +} + +impl Layout { + fn components(self) -> usize { + match self { + Layout::Luma => 1, + Layout::Rgb => 3, + Layout::Rgba | Layout::Abgr => 4, + } + } + fn name(self) -> &'static str { + match self { + Layout::Luma => "luma", + Layout::Rgb => "RGB", + Layout::Rgba => "RGBA", + Layout::Abgr => "ABGR", + } + } +} + +/// How samples are stored in a line. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +enum Packing { + /// One byte per sample. + Bytes8, + /// One 16-bit word per sample. + Words16, + /// Three 10-bit samples per 32-bit word, first in the high bits, two pad bits low. + Filled10A, + /// Three 10-bit samples per 32-bit word, two pad bits high. + Filled10B, + /// One 12-bit sample per 16-bit word, four pad bits low. + Filled12A, + /// One 12-bit sample per 16-bit word, four pad bits high. + Filled12B, +} + +impl Packing { + fn max(self) -> u32 { + match self { + Packing::Bytes8 => 0xff, + Packing::Words16 => 0xffff, + Packing::Filled10A | Packing::Filled10B => 0x3ff, + Packing::Filled12A | Packing::Filled12B => 0xfff, + } + } + fn bits(self) -> u32 { + match self { + Packing::Bytes8 => 8, + Packing::Words16 => 16, + Packing::Filled10A | Packing::Filled10B => 10, + Packing::Filled12A | Packing::Filled12B => 12, + } + } + /// Bytes holding `n` samples, padded to a 32-bit word (lines start on word boundaries). + fn line_bytes(self, n: u64) -> u64 { + match self { + Packing::Bytes8 => n.div_ceil(4) * 4, + Packing::Words16 | Packing::Filled12A | Packing::Filled12B => (n * 2).div_ceil(4) * 4, + Packing::Filled10A | Packing::Filled10B => n.div_ceil(3) * 4, + } + } +} + +/// What a header says about the picture. +#[derive(Clone, Debug)] +struct Header { + kind: Kind, + big_endian: bool, + width: u32, + height: u32, + layout: Layout, + packing: Packing, + /// Byte offset of the first line. + offset: u64, + /// Bytes from one line's start to the next. + stride: u64, + flip_x: bool, + flip_y: bool, + pixel_aspect: f64, + /// Transfer characteristic for the codec name (`log`, `linear`, …), empty when unstated. + transfer: &'static str, +} + +/// Bounds-checked reads in the file's byte order. +struct Reader<'a> { + b: &'a [u8], + big: bool, +} + +impl Reader<'_> { + fn u8(&self, o: usize) -> Option { + self.b.get(o).copied() + } + fn u16(&self, o: usize) -> Option { + let s: [u8; 2] = self.b.get(o..o.checked_add(2)?)?.try_into().ok()?; + Some(if self.big { u16::from_be_bytes(s) } else { u16::from_le_bytes(s) }) + } + fn u32(&self, o: usize) -> Option { + let s: [u8; 4] = self.b.get(o..o.checked_add(4)?)?.try_into().ok()?; + Some(if self.big { u32::from_be_bytes(s) } else { u32::from_le_bytes(s) }) + } +} + +const UNDEFINED32: u32 = u32::MAX; + +/// DPX or Cineon, and the byte order, from the magic number. +fn sniff(bytes: &[u8]) -> Option<(Kind, bool)> { + match bytes.get(..4)? { + b"SDPX" => Some((Kind::Dpx, true)), + b"XPDS" => Some((Kind::Dpx, false)), + [0x80, 0x2a, 0x5f, 0xd7] => Some((Kind::Cineon, true)), + [0xd7, 0x5f, 0x2a, 0x80] => Some((Kind::Cineon, false)), + _ => None, + } +} + +fn unsupported(path: &str, what: impl std::fmt::Display) -> MediaError { + MediaError::Unsupported(format!("{path}: {what}")) +} + +fn truncated(path: &str, kind: Kind) -> MediaError { + let name = if kind == Kind::Dpx { "DPX" } else { "Cineon" }; + MediaError::Decode(format!("{path}: truncated {name} header")) +} + +fn parse(path: &str, bytes: &[u8]) -> Result> { + let Some((kind, big_endian)) = sniff(bytes) else { return Ok(None) }; + let r = Reader { b: bytes, big: big_endian }; + let h = match kind { + Kind::Dpx => parse_dpx(path, &r)?, + Kind::Cineon => parse_cineon(path, &r)?, + }; + let pixels = u64::from(h.width) * u64::from(h.height); + if h.width == 0 || h.height == 0 || pixels > MAX_PIXELS { + return Err(MediaError::Decode(format!("{path}: image size {} × {} out of range", h.width, h.height))); + } + let need = h.offset.saturating_add(h.stride.saturating_mul(u64::from(h.height))); + if (bytes.len() as u64) < need { + return Err(MediaError::Decode(format!("{path}: truncated image data ({} of {need} bytes)", bytes.len()))); + } + Ok(Some(h)) +} + +fn parse_dpx(path: &str, r: &Reader) -> Result
{ + let t = || truncated(path, Kind::Dpx); + let image_offset = r.u32(4).ok_or_else(t)?; + let orientation = r.u16(768).ok_or_else(t)?; + let elements = r.u16(770).ok_or_else(t)?; + let width = r.u32(772).ok_or_else(t)?; + let height = r.u32(776).ok_or_else(t)?; + // The first image element (the picture; further elements are alternative views). + let sign = r.u32(780).ok_or_else(t)?; + let descriptor = r.u8(800).ok_or_else(t)?; + let transfer = r.u8(801).ok_or_else(t)?; + let bits = r.u8(803).ok_or_else(t)?; + let packing = r.u16(804).ok_or_else(t)?; + let encoding = r.u16(806).ok_or_else(t)?; + let data_offset = r.u32(808).ok_or_else(t)?; + let eol_padding = r.u32(812).ok_or_else(t)?; + if elements == 0 || elements == u16::MAX { + return Err(unsupported(path, "DPX file without an image element")); + } + if sign == 1 { + return Err(unsupported(path, "DPX with signed samples is not supported")); + } + if encoding == 1 { + return Err(unsupported(path, "run-length encoded DPX is not supported")); + } + let layout = match descriptor { + 6 => Layout::Luma, + 50 => Layout::Rgb, + 51 => Layout::Rgba, + 52 => Layout::Abgr, + 100..=103 => return Err(unsupported(path, "DPX with Y'CbCr samples is not supported")), + d => return Err(unsupported(path, format!("DPX image element descriptor {d} is not supported (luma, RGB, RGBA and ABGR are)"))), + }; + let packing = match (bits, packing) { + (8, _) => Packing::Bytes8, + (16, _) => Packing::Words16, + (10, 1) => Packing::Filled10A, + (10, 2) => Packing::Filled10B, + (12, 1) => Packing::Filled12A, + (12, 2) => Packing::Filled12B, + (10 | 12, _) => return Err(unsupported(path, format!("DPX with packed {bits}-bit samples (packing {packing}) is not supported"))), + (b, _) => return Err(unsupported(path, format!("DPX with {b}-bit samples is not supported (8, 10, 12 and 16 are)"))), + }; + let (flip_x, flip_y) = match orientation { + 0 | u16::MAX => (false, false), + 1 => (true, false), + 2 => (false, true), + 3 => (true, true), + o => return Err(unsupported(path, format!("DPX orientation {o} (transposed image) is not supported"))), + }; + let offset = if data_offset == 0 || data_offset == UNDEFINED32 { image_offset } else { data_offset }; + let eol = if eol_padding == UNDEFINED32 { 0 } else { eol_padding }; + let samples = u64::from(width) * layout.components() as u64; + // Pixel aspect ratio (orientation header), when stated. + let pixel_aspect = match (r.u32(1628), r.u32(1632)) { + (Some(h), Some(v)) if h != 0 && v != 0 && h != UNDEFINED32 && v != UNDEFINED32 => { + let a = f64::from(h) / f64::from(v); + if (0.1..=10.0).contains(&a) { a } else { 1.0 } + } + _ => 1.0, + }; + let transfer = match transfer { + 1 => "log (printing density)", + 2 => "linear", + 3 => "log", + 5 | 6 => "Rec. 709", + 13 => "ADX", + _ => "", + }; + Ok(Header { + kind: Kind::Dpx, + big_endian: r.big, + width, + height, + layout, + packing, + offset: u64::from(offset), + stride: packing.line_bytes(samples) + u64::from(eol), + flip_x, + flip_y, + pixel_aspect, + transfer, + }) +} + +fn parse_cineon(path: &str, r: &Reader) -> Result
{ + let t = || truncated(path, Kind::Cineon); + let offset = r.u32(4).ok_or_else(t)?; + let orientation = r.u8(192).ok_or_else(t)?; + let channels = r.u8(193).ok_or_else(t)?; + // Channel 0's description; every channel must match it. + let bits = r.u8(198).ok_or_else(t)?; + let width = r.u32(200).ok_or_else(t)?; + let height = r.u32(204).ok_or_else(t)?; + let interleave = r.u8(680).ok_or_else(t)?; + let packing = r.u8(681).ok_or_else(t)?; + let signed = r.u8(682).ok_or_else(t)?; + let eol_padding = r.u32(684).ok_or_else(t)?; + let layout = match channels { + 1 => Layout::Luma, + 3 => Layout::Rgb, + c => return Err(unsupported(path, format!("Cineon with {c} channels is not supported (1 and 3 are)"))), + }; + for c in 1..usize::from(channels) { + let base = 196 + 28 * c; + if r.u8(base + 2) != Some(bits) || r.u32(base + 4) != Some(width) || r.u32(base + 8) != Some(height) { + return Err(unsupported(path, "Cineon channels of different sizes or depths are not supported")); + } + } + if interleave != 0 { + return Err(unsupported(path, "Cineon with line or channel interleave is not supported")); + } + if signed == 1 { + return Err(unsupported(path, "Cineon with signed samples is not supported")); + } + let packing = match (bits, packing) { + (8, _) => Packing::Bytes8, + (16, _) => Packing::Words16, + (10, 5) => Packing::Filled10A, + (10, 6) => Packing::Filled10B, + (10, p) => return Err(unsupported(path, format!("Cineon with 10-bit packing {p} is not supported (32-bit words are)"))), + (b, _) => return Err(unsupported(path, format!("Cineon with {b}-bit samples is not supported (8, 10 and 16 are)"))), + }; + let (flip_x, flip_y) = match orientation { + 0 | u8::MAX => (false, false), + 1 => (false, true), + 2 => (true, false), + 3 => (true, true), + o => return Err(unsupported(path, format!("Cineon orientation {o} (transposed image) is not supported"))), + }; + let eol = if eol_padding == UNDEFINED32 { 0 } else { eol_padding }; + let samples = u64::from(width) * layout.components() as u64; + Ok(Header { + kind: Kind::Cineon, + big_endian: r.big, + width, + height, + layout, + packing, + offset: u64::from(offset), + stride: packing.line_bytes(samples) + u64::from(eol), + flip_x, + flip_y, + pixel_aspect: 1.0, + transfer: "log", + }) +} + +/// Footage for a DPX or Cineon file (`None` for other formats). +pub(crate) fn probe(path: &str, bytes: &[u8]) -> Result> { + let Some(h) = parse(path, bytes)? else { return Ok(None) }; + let alpha = matches!(h.layout, Layout::Rgba | Layout::Abgr); + let mut f = crate::probe::still_footage(path, h.width, h.height, image::ImageFormat::Png, alpha); + f.alpha = if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }; + f.pixel_aspect = h.pixel_aspect; + let name = if h.kind == Kind::Dpx { "DPX" } else { "Cineon" }; + f.codec = format!("{name} {}-bit {}", h.packing.bits(), h.layout.name()); + if !h.transfer.is_empty() { + f.codec = format!("{}, {}", f.codec, h.transfer); + } + Ok(Some(f)) +} + +/// The picture of a DPX or Cineon file as 16-bit samples (`None` for other formats). +pub(crate) fn decode(path: &str, bytes: &[u8]) -> Result> { + let Some(h) = parse(path, bytes)? else { return Ok(None) }; + let (w, ht) = (h.width as usize, h.height as usize); + // Output components: luma, RGB or RGBA (ABGR reordered). + let nc = h.layout.components(); + let row_len = w * nc; + let mut out = vec![0u16; row_len * ht]; + let max = h.packing.max(); + out.par_chunks_mut(row_len).enumerate().for_each(|(y, row)| { + let sy = if h.flip_y { ht - 1 - y } else { y }; + let start = h.offset.saturating_add(h.stride.saturating_mul(sy as u64)); + let line = usize::try_from(start).ok().and_then(|s| bytes.get(s..)).unwrap_or(&[]); + for x in 0..w { + let sx = if h.flip_x { w - 1 - x } else { x }; + for c in 0..nc { + // ABGR stores alpha first and colour reversed. + let sc = if h.layout == Layout::Abgr { 3 - c } else { c }; + let v = sample(line, h.packing, h.big_endian, sx * nc + sc); + if let Some(o) = row.get_mut(x * nc + c) { + *o = ((u64::from(v.min(max)) * 0xffff + u64::from(max) / 2) / u64::from(max)) as u16; + } + } + } + }); + let (w32, h32) = (h.width, h.height); + let img = match h.layout { + Layout::Luma => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageLuma16), + Layout::Rgb => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgb16), + Layout::Rgba | Layout::Abgr => image::ImageBuffer::from_raw(w32, h32, out).map(image::DynamicImage::ImageRgba16), + }; + img.map(Some).ok_or_else(|| MediaError::Decode(format!("{path}: image buffer size mismatch"))) +} + +/// Sample `i` of a line (0 past its end; the header check makes that unreachable for valid +/// headers). +#[inline] +fn sample(line: &[u8], packing: Packing, big: bool, i: usize) -> u32 { + let word16 = |o: usize| -> u32 { + match line.get(o..o + 2) { + Some(&[a, b]) => u32::from(if big { u16::from_be_bytes([a, b]) } else { u16::from_le_bytes([a, b]) }), + _ => 0, + } + }; + let word32 = |o: usize| -> u32 { + match line.get(o..o + 4) { + Some(&[a, b, c, d]) => { + if big { + u32::from_be_bytes([a, b, c, d]) + } else { + u32::from_le_bytes([a, b, c, d]) + } + } + _ => 0, + } + }; + match packing { + Packing::Bytes8 => line.get(i).copied().map_or(0, u32::from), + Packing::Words16 => word16(i * 2), + Packing::Filled12A => word16(i * 2) >> 4, + Packing::Filled12B => word16(i * 2) & 0xfff, + Packing::Filled10A => (word32(i / 3 * 4) >> (22 - 10 * (i % 3) as u32)) & 0x3ff, + Packing::Filled10B => (word32(i / 3 * 4) >> (20 - 10 * (i % 3) as u32)) & 0x3ff, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal big-endian DPX header for `w` × `h` with descriptor, bits and packing. + fn dpx_header(w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16) -> Vec { + let mut b = vec![0u8; 2048]; + b[..4].copy_from_slice(b"SDPX"); + b[4..8].copy_from_slice(&2048u32.to_be_bytes()); + b[768..770].copy_from_slice(&orientation.to_be_bytes()); + b[770..772].copy_from_slice(&1u16.to_be_bytes()); + b[772..776].copy_from_slice(&w.to_be_bytes()); + b[776..780].copy_from_slice(&h.to_be_bytes()); + b[800] = descriptor; + b[801] = 3; + b[803] = bits; + b[804..806].copy_from_slice(&packing.to_be_bytes()); + b + } + + #[test] + fn ten_bit_methods_a_and_b_and_orientation() { + // 2 × 1 RGB, method A: R G B in bits 31..22, 21..12, 11..2. + let mut a = dpx_header(2, 1, 50, 10, 1, 0); + for px in [(1023u32, 512u32, 0u32), (0, 1, 1023)] { + a.extend_from_slice(&((px.0 << 22) | (px.1 << 12) | (px.2 << 2)).to_be_bytes()); + } + let img = decode("a.dpx", &a).expect("decode").expect("dpx").to_rgb16(); + assert_eq!(img.get_pixel(0, 0).0, [65535, 32800, 0]); + assert_eq!(img.get_pixel(1, 0).0, [0, 64, 65535]); + let f = probe("a.dpx", &a).expect("probe").expect("dpx"); + assert_eq!(f.codec, "DPX 10-bit RGB, log"); + // method B: pad bits high + let mut b = dpx_header(1, 1, 50, 10, 2, 1); + b.extend_from_slice(&((1023u32 << 20) | (512 << 10)).to_be_bytes()); + let img = decode("b.dpx", &b).expect("decode").expect("dpx").to_rgb16(); + assert_eq!(img.get_pixel(0, 0).0, [65535, 32800, 0]); + // orientation 2: bottom-to-top lines + let mut c = dpx_header(1, 2, 6, 8, 0, 2); + c.extend_from_slice(&[10, 0, 0, 0, 200, 0, 0, 0]); + let img = decode("c.dpx", &c).expect("decode").expect("dpx").to_luma16(); + assert_eq!((img.get_pixel(0, 0).0[0], img.get_pixel(0, 1).0[0]), (200 * 257, 10 * 257)); + } + + #[test] + fn hostile_headers_never_panic() { + assert!(parse("x", b"not a dpx").expect("other").is_none()); + let good = { + let mut a = dpx_header(4, 4, 50, 10, 1, 0); + a.extend(std::iter::repeat_n(0x5a, 64)); + a + }; + assert!(decode("g.dpx", &good).expect("ok").is_some()); + for n in [4, 8, 600, 790, 805, 2047, 2048, 2100] { + assert!(decode("t.dpx", good.get(..n).expect("cut")).is_err(), "{n}"); + } + // huge, zero, unsupported + for (w, h, d, bits, p, o) in + [(u32::MAX, u32::MAX, 50, 10, 1, 0), (0, 4, 50, 10, 1, 0), (4, 4, 102, 10, 1, 0), (4, 4, 50, 10, 0, 0), (4, 4, 50, 32, 0, 0), (4, 4, 50, 10, 1, 5)] + { + let mut b = dpx_header(w, h, d, bits, p, o); + b.extend(std::iter::repeat_n(0, 64)); + assert!(probe("h.dpx", &b).is_err() && decode("h.dpx", &b).is_err()); + } + // run-length encoding and offsets past the end are refused + let mut rle = good.clone(); + rle[806..808].copy_from_slice(&1u16.to_be_bytes()); + assert!(matches!(probe("r.dpx", &rle), Err(MediaError::Unsupported(_)))); + let mut far = good.clone(); + far[808..812].copy_from_slice(&u32::MAX.wrapping_sub(1).to_be_bytes()); + assert!(probe("f.dpx", &far).is_err()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index 000c1624..92cadff2 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -16,6 +16,7 @@ #![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)] mod convert; +mod dpx; pub mod exr_channels; mod gif_anim; mod layered; @@ -67,6 +68,7 @@ pub fn is_importable(path: &std::path::Path) -> bool { || STILL_EXTENSIONS.contains(&ext.as_str()) || AUDIO_EXTENSIONS.contains(&ext.as_str()) || VIDEO_EXTENSIONS.contains(&ext.as_str()) + || dpx::EXTENSIONS.contains(&ext.as_str()) } pub(crate) fn ext_of(path: &std::path::Path) -> String { diff --git a/crates/media/src/pool.rs b/crates/media/src/pool.rs index ce980c56..91f6206c 100644 --- a/crates/media/src/pool.rs +++ b/crates/media/src/pool.rs @@ -728,6 +728,9 @@ impl Inner { None if &**path == PLACEHOLDER => Ok(colour_bars(footage.width, footage.height)), None => { let bytes = self.read(path)?; + if let Some(img) = crate::dpx::decode(path, &bytes)? { + return Ok(dynamic_to_image(&img, op)); + } if let Some(img) = crate::layered::decode(path, &bytes, footage, op)? { return Ok(img); } diff --git a/crates/media/src/probe.rs b/crates/media/src/probe.rs index 6e8718fd..1a3fcda4 100644 --- a/crates/media/src/probe.rs +++ b/crates/media/src/probe.rs @@ -58,6 +58,10 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option) -> Result { + // DPX / Cineon film scans (our own reader; `image` has none). + if let Some(f) = crate::dpx::probe(path, &bytes)? { + return Ok(f); + } if let Some(f) = crate::layered::probe(path, &bytes)? { return Ok(f); } diff --git a/crates/media/tests/footage.rs b/crates/media/tests/footage.rs index d26b1a1d..f842171a 100644 --- a/crates/media/tests/footage.rs +++ b/crates/media/tests/footage.rs @@ -674,3 +674,123 @@ fn conformed_flac_audio_reads_back_like_the_decoder() { assert_eq!(pool.audio_samples(&f, at, 4800, 48_000), decoded); let _ = std::fs::remove_dir_all(&folder); } + +/// `src` decoded by ffmpeg to 8-bit (`deep`: 16-bit) RGBA, premultiplied like the pool's frames. +fn ffmpeg_rgba(src: &Path, deep: bool) -> Vec<[f32; 4]> { + let out = Command::new("ffmpeg") + .args(["-hide_banner", "-loglevel", "error", "-i"]) + .arg(src) + .args(["-sws_flags", "accurate_rnd+full_chroma_int+bitexact", "-f", "rawvideo", "-pix_fmt", if deep { "rgba64le" } else { "rgba" }, "-"]) + .output() + .expect("run ffmpeg"); + assert!(out.status.success(), "ffmpeg decode of {}", src.display()); + let px = |v: [f32; 4]| [v[0] * v[3], v[1] * v[3], v[2] * v[3], v[3]]; + if !deep { + return out.stdout.as_chunks::<4>().0.iter().map(|p| px(p.map(|v| v as f32 / 255.0))).collect(); + } + out.stdout.as_chunks::<8>().0.iter().map(|p| px(std::array::from_fn(|i| u16::from_le_bytes([p[2 * i], p[2 * i + 1]]) as f32 / 65535.0))).collect() +} + +/// #756: DPX stills in every layout and depth ffmpeg's encoder writes (8-bit luma / RGB / RGBA / +/// ABGR, 10- and 12-bit filled RGB, 16-bit luma / RGB / RGBA, big- and little-endian, lines +/// padded to 32-bit words) import and decode as ffmpeg decodes them. +#[test] +fn dpx_stills_match_ffmpeg() { + let Some(png) = alpha_png() else { return }; + let src = png.to_str().expect("utf8"); + let formats = [ + ("rgb24", "DPX 8-bit RGB", false), + ("rgba", "DPX 8-bit RGBA", true), + ("abgr", "DPX 8-bit ABGR", true), + ("gray", "DPX 8-bit luma", false), + ("gray16be", "DPX 16-bit luma", false), + ("gbrp10be", "DPX 10-bit RGB", false), + ("gbrp10le", "DPX 10-bit RGB", false), + ("gbrp12be", "DPX 12-bit RGB", false), + ("gbrp12le", "DPX 12-bit RGB", false), + ("rgb48be", "DPX 16-bit RGB", false), + ("rgba64le", "DPX 16-bit RGBA", true), + ]; + for (pf, codec, alpha) in formats { + // 250 px wide: 8-bit and 10-bit lines need padding to a 32-bit word. + let Some(p) = ffmpeg_fixture(&format!("dpx-{pf}.dpx"), &["-i", src, "-vf", "scale=250:100", "-pix_fmt", pf]) else { return }; + assert!(effectcraft_media::is_importable(&p)); + let f = probe(&p).expect(pf); + assert_eq!(f.kind, FootageKind::Still); + assert_eq!((f.width, f.height, f.codec.as_str()), (250, 100, codec), "{pf}"); + assert_eq!(f.alpha, if alpha { AlphaMode::Straight } else { AlphaMode::Ignore }, "{pf}"); + let img = MediaPool::new().frame_at(&f, Tick::ZERO).expect(pf); + let want = ffmpeg_rgba(&p, !codec.contains("8-bit")); + assert_eq!(want.len(), img.data.len(), "{pf}"); + let worst = img.data.iter().zip(&want).flat_map(|(a, b)| (0..4).map(move |k| (a[k] - b[k]).abs())).fold(0.0f32, f32::max); + // (within one 16-bit step: ffmpeg widens 10/12-bit samples by bit replication) + assert!(worst < 2.0 / 65535.0, "{pf}: worst difference {worst}"); + } +} + +/// #756: a numbered run of DPX files plays as an image sequence. +#[test] +fn dpx_sequence_plays() { + let Some(first) = ffmpeg_fixture("dpxseq/plate.0001.dpx", &["-f", "lavfi", "-i", "color=c=0x202020:size=32x16", "-frames:v", "1", "-pix_fmt", "gbrp10le"]) + else { + return; + }; + let Some(second) = ffmpeg_fixture("dpxseq/plate.0002.dpx", &["-f", "lavfi", "-i", "color=c=0xe0e0e0:size=32x16", "-frames:v", "1", "-pix_fmt", "gbrp10le"]) + else { + return; + }; + let mut f = probe(&first).expect("probe"); + f.kind = FootageKind::Sequence; + f.sequence = [&first, &second].iter().map(|p| p.to_string_lossy().to_string()).collect(); + f.sync_sequence_duration(); + let pool = MediaPool::new(); + let level = |n| pool.frame_at(&f, t_of(f.frame_rate, n)).expect("frame").data[0][0]; + assert!((level(0) - 32.0 / 255.0).abs() < 0.01 && (level(1) - 224.0 / 255.0).abs() < 0.01, "{} {}", level(0), level(1)); +} + +/// #756: a Kodak Cineon file (no ffmpeg encoder: written here from the spec, 10-bit RGB +/// left-justified in big-endian 32-bit words; checked against ImageMagick's reader when written) +/// shows its log code values as stored; a truncated one is refused, never a panic. +#[test] +fn cineon_still_shows_code_values() { + let (w, h) = (5u32, 3u32); + let code = |x: u32, y: u32| [(x * 37 + y * 101) % 1024, (x * 11 + y * 7 + 300) % 1024, (x * 3 + y * 251 + 700) % 1024]; + let mut b = vec![0u8; 1024]; + b[..4].copy_from_slice(&[0x80, 0x2a, 0x5f, 0xd7]); + b[4..8].copy_from_slice(&1024u32.to_be_bytes()); + b[8..12].copy_from_slice(&1024u32.to_be_bytes()); + b[24..28].copy_from_slice(b"V4.5"); + b[193] = 3; + for c in 0..3 { + let o = 196 + 28 * c; + b[o + 1] = c as u8 + 1; + b[o + 2] = 10; + b[o + 4..o + 8].copy_from_slice(&w.to_be_bytes()); + b[o + 8..o + 12].copy_from_slice(&h.to_be_bytes()); + } + b[681] = 5; + for y in 0..h { + for x in 0..w { + let [r, g, bl] = code(x, y); + b.extend_from_slice(&((r << 22) | (g << 12) | (bl << 2)).to_be_bytes()); + } + } + let bytes: Arc<[u8]> = b.clone().into(); + let f = effectcraft_media::probe_bytes("scan.cin", bytes.clone()).expect("probe"); + assert_eq!((f.kind, f.width, f.height, f.codec.as_str(), f.alpha), (FootageKind::Still, 5, 3, "Cineon 10-bit RGB, log", AlphaMode::Ignore)); + assert!(effectcraft_media::is_importable(Path::new("scan.cin"))); + let pool = MediaPool::new(); + pool.add_bytes("scan.cin", bytes); + let img = pool.frame_at(&f, Tick::ZERO).expect("frame"); + for y in 0..h { + for x in 0..w { + let want = code(x, y).map(|v| v as f32 / 1023.0); + let got = img.get(i64::from(x), i64::from(y)); + assert!((0..3).all(|k| (got[k] - want[k]).abs() < 1e-4) && got[3] == 1.0, "{x},{y}: {got:?} vs {want:?}"); + } + } + for n in [3, 100, 700, 1024, b.len() - 1] { + let cut: Arc<[u8]> = b.get(..n).expect("cut").into(); + assert!(effectcraft_media::probe_bytes("cut.cin", cut).is_err(), "{n}"); + } +} diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 5c61b497..a134b248 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -1225,8 +1225,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option