From fe52bc0b2cc4d1aefb532027c8ce95d7b67f7332 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 00:58:15 +0200 Subject: [PATCH] #821: import Targa and DPX stills and image sequences Problem: Premiere imports Targa and DPX as stills and numbered sequences. FilmCraft exports both (#779) but could not read either: the extensions were not importable and the image crate has no DPX reader, so even our own Targa / DPX renders could not come back in. Fix: clean-room decoders in filmcraft-media. Targa from the TGA 2.0 spec: image types 1/2/3 and RLE 9/10/11, colour maps, 15/16/24/32-bit, 8/16-bit grayscale, any origin. DPX from SMPTE ST 268: first element, luma / RGB / RGBA / ABGR, 8/16-bit, filled 10/12-bit (methods A and B), either byte order, padded lines, orientations 0-3. Both bound allocations by the data present and reject hostile headers with errors. Stills and image sequences share one decode_raster entry point; "tga" and "dpx" join the importable still extensions. Co-Authored-By: Claude Opus 5.5 --- crates/codecs/tests/media_oracle.rs | 63 +++++- crates/media/src/dpx.rs | 273 +++++++++++++++++++++++++ crates/media/src/lib.rs | 6 +- crates/media/src/sequence.rs | 15 +- crates/media/src/still.rs | 41 +++- crates/media/src/tga.rs | 299 ++++++++++++++++++++++++++++ docs/file-format-parity.md | 6 +- docs/gaps.md | 7 +- 8 files changed, 683 insertions(+), 27 deletions(-) create mode 100644 crates/media/src/dpx.rs create mode 100644 crates/media/src/tga.rs diff --git a/crates/codecs/tests/media_oracle.rs b/crates/codecs/tests/media_oracle.rs index db7745dd0..4fc6638e7 100644 --- a/crates/codecs/tests/media_oracle.rs +++ b/crates/codecs/tests/media_oracle.rs @@ -1,6 +1,7 @@ -//! Image sequences and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, JPEG, -//! TIFF, BMP, WebP when available) and BWF files; the sequence frames must equal ffmpeg's decode of -//! the same image2 sequence (lossless formats exactly, JPEG within a few levels), and the BWF +//! Image sequences, stills and Broadcast WAV against ffmpeg: ffmpeg writes numbered stills (PNG, +//! JPEG, TIFF, BMP, WebP when available, Targa, DPX), single Targa / DPX stills in every layout it +//! has, and BWF files; our frames must equal ffmpeg's decode of the same files (lossless formats +//! exactly, JPEG within a few levels, high bit depths within one 8-bit step), and the BWF //! TimeReference must give the start timecode. mod common; @@ -114,6 +115,62 @@ fn webp_sequence_lossless() { check(&ff, "webp", &["-c:v", "libwebp", "-lossless", "1"], 0); } +#[test] +fn targa_and_dpx_sequences_exact() { + let ff = filmcraft_testkit::require_ffmpeg!(); + check(&ff, "tga", &[], 0); + check(&ff, "dpx", &["-pix_fmt", "gbrp10be"], 1); +} + +/// One `ext` still in `pix_fmt` (95 × 63, so scan lines need padding; translucent when the format +/// has alpha) opened like an imported file must match ffmpeg's RGBA decode within `tol` (exactly +/// when `tol` is 0). +fn still_matches(ff: &Path, ext: &str, pix_fmt: &str, extra: &[&str], tol: u8) { + let name = format!("still_{pix_fmt}{}.{ext}", extra.join("")); + let alpha = if pix_fmt.contains('a') && pix_fmt != "pal8" { ",format=rgba,geq=r='r(X,Y)':g='g(X,Y)':b='b(X,Y)':a='X*2+Y'" } else { "" }; + let vf = format!("scale=95:63{alpha}"); + let mut args = vec!["-f", "lavfi", "-i", "testsrc2=size=96x64", "-frames:v", "1", "-vf", &vf, "-pix_fmt", pix_fmt]; + args.extend_from_slice(extra); + let Some(f) = fixture(ff, &name, &args) else { + eprintln!("SKIPPED: ffmpeg cannot write {name}"); + return; + }; + let src = filmcraft_codecs::open_bytes(&name, bytes(&f)).unwrap_or_else(|e| panic!("{name}: {e}")); + assert_eq!(src.info().kind, MediaKind::Still, "{name}"); + let v = src.info().video.as_ref().unwrap(); + assert_eq!((v.width, v.height), (95, 63), "{name}"); + let ours = rgba(&src.video_frame(FrameRequest::full(filmcraft_time::Tick(0))).unwrap()); + // deeper than 8 bits: ffmpeg's 16-bit decode, rounded to 8 bits (its own 8-bit conversion dithers) + let want: Vec = if tol == 0 { + ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba", "-"]) + } else { + let wide = ffmpeg_out(ff, &["-i", f.to_str().unwrap(), "-f", "rawvideo", "-pix_fmt", "rgba64le", "-"]); + wide.as_chunks::<2>().0.iter().map(|c| ((u32::from(u16::from_le_bytes(*c)) * 255 + 32767) / 65535) as u8).collect() + }; + assert_eq!(ours.len(), want.len(), "{name}"); + let d = ours.iter().zip(&want).map(|(a, b)| a.abs_diff(*b)).max().unwrap(); + assert!(d <= tol, "{name}: differs by {d}"); + assert_eq!(v.has_alpha, want.as_chunks::<4>().0.iter().any(|p| p[3] != 255), "{name}"); + eprintln!("{name}: max diff {d}"); +} + +#[test] +fn targa_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for pix_fmt in ["bgr24", "bgra", "rgb555le", "gray", "pal8"] { + still_matches(&ff, "tga", pix_fmt, &[], 0); // run-length encoded (ffmpeg's default) + still_matches(&ff, "tga", pix_fmt, &["-rle", "0"], 0); + } +} + +#[test] +fn dpx_stills_every_layout() { + let ff = filmcraft_testkit::require_ffmpeg!(); + for (pix_fmt, tol) in [("rgb24", 0), ("rgba", 0), ("gray", 0), ("rgb48be", 1), ("rgba64be", 1), ("gray16be", 1), ("gbrp10be", 1), ("gbrp12be", 1)] { + still_matches(&ff, "dpx", pix_fmt, &[], tol); + } +} + #[test] fn missing_frames_hold_the_previous_frame() { let ff = filmcraft_testkit::require_ffmpeg!(); diff --git a/crates/media/src/dpx.rs b/crates/media/src/dpx.rs new file mode 100644 index 000000000..2d5832faf --- /dev/null +++ b/crates/media/src/dpx.rs @@ -0,0 +1,273 @@ +//! DPX (`.dpx`) stills, decoded clean-room from SMPTE ST 268 (Digital Picture Exchange). +//! +//! The first image element is decoded: luma, RGB, RGBA or ABGR, 8, 10, 12 or 16 bits per +//! component, big-endian (`SDPX`) or little-endian (`XPDS`) files. 10-bit components are filled into +//! 32-bit words (packing 1, method A: two padding bits at the bottom; packing 2, method B: at the +//! top), 12-bit components fill 16-bit words the same two ways. Scan lines start on a 32-bit +//! boundary and may carry end-of-line padding. Orientations 0–3 (either scan direction, either line +//! order) are honoured. Code values map linearly onto 8 bits (full range); a logarithmic (printing +//! density) transfer is shown as stored, without a film-log conversion. +//! +//! Not decoded (an "unsupported" error): run-length encoded elements, the 10/12-bit "packed" +//! layout (packing 0), Y'CbCr descriptors, 1-bit and floating-point components, transposed +//! orientations. + +use crate::{MediaError, Result, still::Raster}; + +/// Generic file + image information header: everything we read lies below this. +const HEADER: usize = 1408; +/// First image element of the image information header (72 bytes each). +const ELEMENT: usize = 780; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; +/// "Undefined" value of numeric header fields. +const UNDEFINED: u32 = u32::MAX; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: DPX: {what}")) +} + +fn unsupported(name: &str, what: String) -> MediaError { + MediaError::Unsupported(format!("{name}: DPX {what}")) +} + +/// Whether `bytes` starts with the DPX magic number in either byte order. +pub fn sniff(bytes: &[u8]) -> bool { + bytes.starts_with(b"SDPX") || bytes.starts_with(b"XPDS") +} + +/// Header and sample reads in the file's byte order. +#[derive(Clone, Copy)] +struct Order { + big: bool, +} + +impl Order { + fn u16(self, b: &[u8], at: usize) -> u16 { + let v = [b.get(at).copied().unwrap_or(0), b.get(at + 1).copied().unwrap_or(0)]; + if self.big { u16::from_be_bytes(v) } else { u16::from_le_bytes(v) } + } + + fn u32(self, b: &[u8], at: usize) -> u32 { + let v = core::array::from_fn(|i| b.get(at + i).copied().unwrap_or(0)); + if self.big { u32::from_be_bytes(v) } else { u32::from_le_bytes(v) } + } +} + +/// How components are stored in a scan line. +#[derive(Clone, Copy)] +enum Packing { + /// One byte per component. + Bytes, + /// One 16-bit word per component; the value is `word >> shift` (12-bit method A: 4). + Words { shift: u32 }, + /// Three 10-bit components per 32-bit word, the first in the high bits, above `pad` bits. + Ten { pad: u32 }, +} + +impl Packing { + /// Bytes of a scan line holding `comps` components, before end-of-line padding (lines start on + /// a 32-bit boundary). + fn line_bytes(self, comps: usize) -> Option { + let raw = match self { + Packing::Bytes => comps, + Packing::Words { .. } => comps.checked_mul(2)?, + Packing::Ten { .. } => comps.div_ceil(3).checked_mul(4)?, + }; + raw.checked_next_multiple_of(4) + } + + /// The `comps` component values of one scan line. + fn unpack(self, line: &[u8], comps: usize, order: Order, out: &mut Vec) { + out.clear(); + match self { + Packing::Bytes => out.extend(line.iter().take(comps).map(|&b| u16::from(b))), + Packing::Words { shift } => out.extend((0..comps).map(|i| order.u16(line, i * 2) >> shift)), + Packing::Ten { pad } => { + for w in 0..comps.div_ceil(3) { + let word = order.u32(line, w * 4); + for s in [20 + pad, 10 + pad, pad] { + if out.len() < comps { + out.push(((word >> s) & 0x3FF) as u16); + } + } + } + } + } + } +} + +/// Decode the first image element of a DPX file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let order = match bytes.get(..4) { + Some(b"SDPX") => Order { big: true }, + Some(b"XPDS") => Order { big: false }, + _ => return Err(err(name, "not a DPX file")), + }; + if bytes.len() < HEADER { + return Err(err(name, "truncated header")); + } + let orientation = order.u16(bytes, 768); + let (w, h) = (order.u32(bytes, 772) as usize, order.u32(bytes, 776) as usize); + let el = ELEMENT; + let descriptor = bytes.get(el + 20).copied().unwrap_or(0); + let bits = bytes.get(el + 23).copied().unwrap_or(0); + let packing = order.u16(bytes, el + 24); + let encoding = order.u16(bytes, el + 26); + let element_offset = order.u32(bytes, el + 28); + let eol_padding = order.u32(bytes, el + 32); + if w == 0 || h == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(h).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + // component order per pixel: indices of R, G, B, A (None: opaque) in the stored components + let (n, map): (usize, [Option; 4]) = match descriptor { + 6 => (1, [Some(0), Some(0), Some(0), None]), + 50 => (3, [Some(0), Some(1), Some(2), None]), + 51 => (4, [Some(0), Some(1), Some(2), Some(3)]), + 52 => (4, [Some(3), Some(2), Some(1), Some(0)]), + d => return Err(unsupported(name, format!("descriptor {d}"))), + }; + if encoding != 0 { + return Err(unsupported(name, "run-length encoding".into())); + } + let layout = match (bits, packing) { + (8, _) => Packing::Bytes, + (16, _) => Packing::Words { shift: 0 }, + (10, 1) => Packing::Ten { pad: 2 }, + (10, 2) => Packing::Ten { pad: 0 }, + (12, 1) => Packing::Words { shift: 4 }, + (12, 2) => Packing::Words { shift: 0 }, + (b, p) => return Err(unsupported(name, format!("{b}-bit components with packing {p}"))), + }; + let (flip_x, flip_y) = match orientation { + 0 => (false, false), + 1 => (true, false), + 2 => (false, true), + 3 => (true, true), + o => return Err(unsupported(name, format!("orientation {o}"))), + }; + let comps = w.checked_mul(n).ok_or_else(|| err(name, "picture too large"))?; + let pad = if eol_padding == UNDEFINED { 0 } else { eol_padding as usize }; + let stride = layout.line_bytes(comps).and_then(|l| l.checked_add(pad)).ok_or_else(|| err(name, "picture too large"))?; + let offset = match element_offset { + 0 | UNDEFINED => order.u32(bytes, 4) as usize, + o => o as usize, + }; + // the last line needs no end-of-line padding + let size = stride.checked_mul(h - 1).and_then(|s| s.checked_add(stride - pad)).ok_or_else(|| err(name, "picture too large"))?; + let data = offset.checked_add(size).and_then(|end| bytes.get(offset..end)).ok_or_else(|| err(name, "truncated image data"))?; + let max = (1u32 << bits) - 1; + let to8 = |v: u16| ((u32::from(v).min(max) * 255 + max / 2) / max) as u8; + let mut rgba = vec![0u8; pixels * 4]; + let mut line = Vec::with_capacity(comps); + for y in 0..h { + let src = data.get(y * stride..).unwrap_or_default(); + layout.unpack(src, comps, order, &mut line); + let dy = if flip_y { h - 1 - y } else { y }; + for x in 0..w { + let dx = if flip_x { w - 1 - x } else { x }; + let o = (dy * w + dx) * 4; + let Some(px) = rgba.get_mut(o..o + 4) else { continue }; + for (c, slot) in map.iter().enumerate() { + let v = match slot { + Some(i) => line.get(x * n + i).map_or(0, |&v| to8(v)), + None => 255, + }; + if let Some(d) = px.get_mut(c) { + *d = v; + } + } + } + } + let has_alpha = map[3].is_some() && rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: h as u32, rgba, has_alpha }) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A minimal DPX file (header + `data`) in the given byte order. + fn file(big: bool, w: u32, h: u32, descriptor: u8, bits: u8, packing: u16, orientation: u16, data: &[u8]) -> Vec { + let mut f = vec![0u8; 2048]; + let p32 = |f: &mut Vec, at: usize, v: u32| f[at..at + 4].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + let p16 = |f: &mut Vec, at: usize, v: u16| f[at..at + 2].copy_from_slice(&if big { v.to_be_bytes() } else { v.to_le_bytes() }); + f[0..4].copy_from_slice(if big { b"SDPX" } else { b"XPDS" }); + p32(&mut f, 4, 2048); + p16(&mut f, 768, orientation); + p16(&mut f, 770, 1); + p32(&mut f, 772, w); + p32(&mut f, 776, h); + f[ELEMENT + 20] = descriptor; + f[ELEMENT + 23] = bits; + p16(&mut f, ELEMENT + 24, packing); + p32(&mut f, ELEMENT + 28, UNDEFINED); + p32(&mut f, ELEMENT + 32, UNDEFINED); + f.extend_from_slice(data); + f + } + + #[test] + fn ten_bit_method_a_and_b() { + let word = |r: u32, g: u32, b: u32, pad: u32| ((r << (20 + pad)) | (g << (10 + pad)) | (b << pad)).to_be_bytes(); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 2)); + data.extend_from_slice(&word(4, 1019, 0, 2)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + assert!(!r.has_alpha); + let mut data = Vec::new(); + data.extend_from_slice(&word(1023, 0, 512, 0)); + data.extend_from_slice(&word(4, 1019, 0, 0)); + let r = decode("a.dpx", &file(true, 2, 1, 50, 10, 2, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 128, 255, 1, 254, 0, 255]); + } + + #[test] + fn rgba_components_roll_across_ten_bit_words() { + // 2 RGBA pixels = 8 components = 3 words; the last word is half empty + let comps = [1023u32, 0, 0, 1023, 0, 1023, 0, 0]; + let mut data = Vec::new(); + for c in comps.chunks(3) { + let get = |i: usize| c.get(i).copied().unwrap_or(0); + data.extend_from_slice(&((get(0) << 22) | (get(1) << 12) | (get(2) << 2)).to_le_bytes()); + } + let r = decode("a.dpx", &file(false, 2, 1, 51, 10, 1, 0, &data)).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + assert!(r.has_alpha); + } + + #[test] + fn eight_bit_lines_pad_to_words_and_orientation_flips() { + // 1 × 2 RGB: each 3-byte line padded to 4; bottom-to-top, right-to-left orientation 3 + let r = decode("a.dpx", &file(false, 1, 2, 50, 8, 0, 3, &[1, 2, 3, 0, 4, 5, 6, 0])).unwrap(); + assert_eq!(r.rgba, [4, 5, 6, 255, 1, 2, 3, 255]); + // 2 × 1 luma, 16-bit big-endian; ABGR, 12-bit method A + let r = decode("a.dpx", &file(true, 2, 1, 6, 16, 0, 0, &[0xFF, 0xFF, 0x80, 0x00])).unwrap(); + assert_eq!(r.rgba, [255, 255, 255, 255, 128, 128, 128, 255]); + let r = decode("a.dpx", &file(true, 1, 1, 52, 12, 1, 0, &[0x80, 0x00, 0x00, 0x00, 0x00, 0x00, 0xFF, 0xF0])).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 128]); + } + + #[test] + fn hostile_headers_fail_cleanly() { + // huge dimensions with no data, truncated data, unsupported layouts + assert!(decode("a.dpx", &file(true, u32::MAX, u32::MAX, 50, 10, 1, 0, &[])).is_err()); + assert!(decode("a.dpx", &file(true, 4096, 4096, 50, 10, 1, 0, &[0; 64])).is_err()); + assert!(decode("a.dpx", &file(true, 2, 2, 50, 10, 1, 0, &[0; 12])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 100, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 10, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 32, 0, 0, &[0; 16])).is_err()); + assert!(decode("a.dpx", &file(true, 1, 1, 50, 8, 0, 5, &[0; 4])).is_err()); + assert!(decode("a.dpx", &file(true, 0, 1, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", b"SDPX").is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[4..8].copy_from_slice(&u32::MAX.to_be_bytes()); + assert!(decode("a.dpx", &f).is_err()); + let mut f = file(true, 1, 1, 50, 8, 0, 0, &[0; 4]); + f[ELEMENT + 32..ELEMENT + 36].copy_from_slice(&(u32::MAX - 1).to_be_bytes()); + assert!(decode("a.dpx", &file(true, 1, 2, 50, 8, 0, 0, &[0; 4])).is_err()); + assert!(decode("a.dpx", &f).is_ok()); + } +} diff --git a/crates/media/src/lib.rs b/crates/media/src/lib.rs index bfc94de79..a73451e7d 100644 --- a/crates/media/src/lib.rs +++ b/crates/media/src/lib.rs @@ -12,11 +12,13 @@ pub mod cache; pub mod cancel; pub mod digits; +pub mod dpx; pub mod generators; pub mod pending; pub mod reader; pub mod sequence; pub mod still; +pub mod tga; pub mod wav; use std::path::Path; @@ -169,7 +171,7 @@ pub trait MediaSource: Send + Sync { pub type SharedSource = Arc; /// File extensions we recognise at import (the media browser filters on these). -pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp"]; +pub const STILL_EXTENSIONS: &[&str] = &["png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "tga", "dpx"]; pub const AUDIO_EXTENSIONS: &[&str] = &["wav", "wave", "bwf", "aif", "aiff", "mp3", "mp2", "m4a", "aac", "flac", "ogg", "opus"]; pub const VIDEO_EXTENSIONS: &[&str] = &[ "mp4", "m4v", "mov", "mkv", "webm", "avi", "mxf", "mts", "m2ts", "m2t", "ts", "mpg", "mpeg", "vob", "mod", "tod", "m2v", "m1v", "mpv", "3gp", "y4m", "apv", @@ -193,7 +195,7 @@ pub fn open_bytes(name: &str, bytes: Arc<[u8]>, extra: &[Opener]) -> Result bool { - b.starts_with(b"\x89PNG") +/// Whether `b` (the contents of the file `name`) is a still image we decode. +pub fn sniff(name: &str, b: &[u8]) -> bool { + crate::dpx::sniff(b) + || crate::tga::sniff(name, b) + || b.starts_with(b"\x89PNG") || b.starts_with(&[0xFF, 0xD8, 0xFF]) || b.starts_with(b"GIF8") || (b.len() > 12 && &b[0..4] == b"RIFF" && &b[8..12] == b"WEBP") @@ -20,6 +23,31 @@ pub fn sniff(b: &[u8]) -> bool { || b.starts_with(b"BM") } +/// A decoded picture: straight-alpha RGBA8, top row first. +pub struct Raster { + pub width: u32, + pub height: u32, + pub rgba: Vec, + /// Whether any pixel is not fully opaque. + pub has_alpha: bool, +} + +/// Decode a still image file (`name` picks Targa, which has no magic number; the content picks +/// every other format). +pub fn decode_raster(name: &str, bytes: &[u8]) -> Result { + if crate::dpx::sniff(bytes) { + return crate::dpx::decode(name, bytes); + } + if crate::tga::sniff(name, bytes) { + return crate::tga::decode(name, bytes); + } + let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; + let has_alpha = img.color().has_alpha(); + let rgba = img.to_rgba8(); + let (width, height) = rgba.dimensions(); + Ok(Raster { width, height, rgba: rgba.into_raw(), has_alpha }) +} + pub struct StillSource { info: MediaInfo, frame: Arc, @@ -27,11 +55,8 @@ pub struct StillSource { impl StillSource { pub fn decode(name: &str, bytes: &[u8]) -> Result { - let img = image::load_from_memory(bytes).map_err(|e| MediaError::Decode(format!("{name}: {e}")))?; - let has_alpha = img.color().has_alpha(); - let rgba = img.to_rgba8(); - let (w, h) = rgba.dimensions(); - Ok(Self::from_rgba(name, w, h, rgba.into_raw(), has_alpha, bytes.len() as u64)) + let r = decode_raster(name, bytes)?; + Ok(Self::from_rgba(name, r.width, r.height, r.rgba, r.has_alpha, bytes.len() as u64)) } pub fn from_rgba(name: &str, w: u32, h: u32, rgba: Vec, has_alpha: bool, file_size: u64) -> Self { diff --git a/crates/media/src/tga.rs b/crates/media/src/tga.rs new file mode 100644 index 000000000..06e9295db --- /dev/null +++ b/crates/media/src/tga.rs @@ -0,0 +1,299 @@ +//! Targa (`.tga`) stills, decoded clean-room from the Truevision TGA File Format Specification 2.0. +//! +//! Image types 1/2/3 (colour-mapped, true-colour, grayscale) and their run-length encoded forms +//! 9/10/11; 15/16-bit (5-5-5, the top bit as alpha when the descriptor says one attribute bit), +//! 24-bit BGR and 32-bit BGRA true-colour, 8-bit grayscale and 16-bit grayscale + alpha, colour maps +//! of 15/16/24/32-bit entries indexed by 8 or 16 bits; any origin corner. + +use crate::{MediaError, Result, still::Raster}; + +/// Length of the fixed header. +const HEADER: usize = 18; +/// TGA 2.0 files end with this signature (after two 32-bit area offsets). +const FOOTER: &[u8] = b"TRUEVISION-XFILE.\0"; +/// Largest picture we decode (16384 × 16384): bounds the allocation a header can ask for. +const MAX_PIXELS: usize = 1 << 28; + +fn err(name: &str, what: &str) -> MediaError { + MediaError::Decode(format!("{name}: Targa: {what}")) +} + +/// Whether `bytes` looks like a Targa file: a TGA 2.0 footer, or (for the original format, which +/// has no signature) a `.tga` name and a plausible header. +pub fn sniff(name: &str, bytes: &[u8]) -> bool { + let header_ok = || { + let Some(h) = bytes.first_chunk::
() else { return false }; + let (map_type, kind, depth) = (h[1], h[2], h[16]); + let (w, ht) = (u16::from_le_bytes([h[12], h[13]]), u16::from_le_bytes([h[14], h[15]])); + map_type <= 1 && matches!(kind, 1 | 2 | 3 | 9 | 10 | 11) && w > 0 && ht > 0 && matches!(depth, 8 | 15 | 16 | 24 | 32) + }; + let ext = name.rsplit_once('.').map(|(_, e)| e.to_ascii_lowercase()); + let named = matches!(ext.as_deref(), Some("tga" | "targa" | "icb" | "vda" | "vst")); + header_ok() && (named || bytes.ends_with(FOOTER)) +} + +/// Pixel layout of the stored values (after the colour-map lookup, for mapped images). +#[derive(Clone, Copy)] +enum Layout { + /// 15/16-bit `ARRRRRGG GGGBBBBB`, little-endian; `alpha`: the top bit is an alpha bit. + Rgb555 { + alpha: bool, + }, + Bgr24, + Bgra32, + Gray8, + GrayAlpha16, +} + +impl Layout { + fn true_colour(depth: u8, attr_bits: u8) -> Option { + Some(match depth { + 15 => Layout::Rgb555 { alpha: false }, + 16 => Layout::Rgb555 { alpha: attr_bits == 1 }, + 24 => Layout::Bgr24, + 32 => Layout::Bgra32, + _ => return None, + }) + } + + fn bytes(self) -> usize { + match self { + Layout::Rgb555 { .. } | Layout::GrayAlpha16 => 2, + Layout::Bgr24 => 3, + Layout::Bgra32 => 4, + Layout::Gray8 => 1, + } + } + + /// RGBA of one stored value (`p` holds exactly [`bytes`](Self::bytes) bytes). + fn rgba(self, p: &[u8]) -> [u8; 4] { + let b = |i: usize| p.get(i).copied().unwrap_or(0); + match self { + Layout::Rgb555 { alpha } => { + let v = u16::from_le_bytes([b(0), b(1)]); + let five = |s: u16| { + let c = ((v >> s) & 31) as u8; + (c << 3) | (c >> 2) + }; + let a = if !alpha || v & 0x8000 != 0 { 255 } else { 0 }; + [five(10), five(5), five(0), a] + } + Layout::Bgr24 => [b(2), b(1), b(0), 255], + Layout::Bgra32 => [b(2), b(1), b(0), b(3)], + Layout::Gray8 => [b(0), b(0), b(0), 255], + Layout::GrayAlpha16 => [b(0), b(0), b(0), b(1)], + } + } +} + +/// Decode a Targa file to straight-alpha RGBA8, top row first. +pub fn decode(name: &str, bytes: &[u8]) -> Result { + let h = bytes.first_chunk::
().ok_or_else(|| err(name, "truncated header"))?; + let (id_len, map_type, kind) = (usize::from(h[0]), h[1], h[2]); + let map_first = usize::from(u16::from_le_bytes([h[3], h[4]])); + let map_len = usize::from(u16::from_le_bytes([h[5], h[6]])); + let map_depth = h[7]; + let (w, ht) = (usize::from(u16::from_le_bytes([h[12], h[13]])), usize::from(u16::from_le_bytes([h[14], h[15]]))); + let (depth, descriptor) = (h[16], h[17]); + let attr_bits = descriptor & 0x0F; + let (right_to_left, top_to_bottom) = (descriptor & 0x10 != 0, descriptor & 0x20 != 0); + if w == 0 || ht == 0 { + return Err(err(name, "empty picture")); + } + let pixels = w.checked_mul(ht).filter(|&n| n <= MAX_PIXELS).ok_or_else(|| err(name, "picture too large"))?; + let rle = kind >= 9; + // stored value layout and, for colour-mapped images, the palette + let mut palette: Vec<[u8; 4]> = Vec::new(); + let map_entry_layout = if map_type == 1 { + Some(Layout::true_colour(map_depth, if map_depth == 16 { attr_bits.min(1) } else { 0 }).ok_or_else(|| err(name, "colour map entry size"))?) + } else { + None + }; + let mut at = HEADER.checked_add(id_len).ok_or_else(|| err(name, "header"))?; + if let Some(l) = map_entry_layout { + let size = map_len.checked_mul(l.bytes()).ok_or_else(|| err(name, "colour map"))?; + let end = at.checked_add(size).ok_or_else(|| err(name, "colour map"))?; + let map = bytes.get(at..end).ok_or_else(|| err(name, "truncated colour map"))?; + palette = map.chunks_exact(l.bytes()).map(|e| l.rgba(e)).collect(); + at = end; + } + let (layout, mapped) = match kind & 7 { + 1 => { + if map_type != 1 || !matches!(depth, 8 | 16) { + return Err(err(name, "colour-mapped picture without an 8/16-bit index and a colour map")); + } + (if depth == 8 { Layout::Gray8 } else { Layout::GrayAlpha16 }, true) + } + 2 => (Layout::true_colour(depth, attr_bits).ok_or_else(|| err(name, "true-colour pixel depth"))?, false), + 3 => match depth { + 8 => (Layout::Gray8, false), + 16 => (Layout::GrayAlpha16, false), + _ => return Err(err(name, "grayscale pixel depth")), + }, + _ => return Err(MediaError::Unsupported(format!("{name}: Targa image type {kind}"))), + }; + let data = bytes.get(at..).unwrap_or_default(); + let bpp = layout.bytes(); + // the stored values, in file order + let stored: Vec = if rle { + unpack_rle(name, data, pixels, bpp)? + } else { + let n = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + data.get(..n).ok_or_else(|| err(name, "truncated pixel data"))?.to_vec() + }; + let mut rgba = vec![0u8; pixels * 4]; + for (i, v) in stored.chunks_exact(bpp).enumerate().take(pixels) { + let (x, y) = (i % w, i / w); + let x = if right_to_left { w - 1 - x } else { x }; + let y = if top_to_bottom { y } else { ht - 1 - y }; + let px = if mapped { + let byte = |i: usize| v.get(i).copied().unwrap_or(0); + let index = if bpp == 1 { usize::from(byte(0)) } else { usize::from(u16::from_le_bytes([byte(0), byte(1)])) }; + *index.checked_sub(map_first).and_then(|k| palette.get(k)).ok_or_else(|| err(name, "colour index outside the colour map"))? + } else { + layout.rgba(v) + }; + let o = (y * w + x) * 4; + if let Some(d) = rgba.get_mut(o..o + 4) { + d.copy_from_slice(&px); + } + } + // 32-bit files from writers that leave the attribute bits unset often store zero alpha + // everywhere: such a picture is opaque, not invisible + let alpha_used = match (layout, mapped) { + (Layout::Bgra32, false) => attr_bits != 0 || rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 0), + _ => true, + }; + if !alpha_used { + rgba.as_chunks_mut::<4>().0.iter_mut().for_each(|p| p[3] = 255); + } + let has_alpha = rgba.as_chunks::<4>().0.iter().any(|p| p[3] != 255); + Ok(Raster { width: w as u32, height: ht as u32, rgba, has_alpha }) +} + +/// Expand run-length packets into `pixels` stored values of `bpp` bytes each. Packets may span +/// scan lines (the TGA 2.0 spec discourages it; older writers do it). +fn unpack_rle(name: &str, mut data: &[u8], pixels: usize, bpp: usize) -> Result> { + // a two-byte packet expands to at most 128 values: a header asking for more is lying + let bound = data.len().saturating_mul(64).saturating_add(128); + if pixels > bound { + return Err(err(name, "truncated run-length data")); + } + let total = pixels.checked_mul(bpp).ok_or_else(|| err(name, "picture too large"))?; + let mut out = Vec::with_capacity(total); + while out.len() < total { + let (&head, rest) = data.split_first().ok_or_else(|| err(name, "truncated run-length data"))?; + let count = usize::from(head & 0x7F) + 1; + let want = (total - out.len()).min(count * bpp); + if head & 0x80 != 0 { + let v = rest.get(..bpp).ok_or_else(|| err(name, "truncated run-length data"))?; + for _ in 0..want / bpp { + out.extend_from_slice(v); + } + data = rest.get(bpp..).unwrap_or_default(); + } else { + let n = count * bpp; + let v = rest.get(..n).ok_or_else(|| err(name, "truncated run-length data"))?; + out.extend_from_slice(v.get(..want).unwrap_or(v)); + data = rest.get(n..).unwrap_or_default(); + } + } + Ok(out) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn header(kind: u8, w: u16, h: u16, depth: u8, descriptor: u8) -> Vec { + let mut b = vec![0, 0, kind, 0, 0, 0, 0, 0, 0, 0, 0, 0]; + b.extend_from_slice(&w.to_le_bytes()); + b.extend_from_slice(&h.to_le_bytes()); + b.extend_from_slice(&[depth, descriptor]); + b + } + + #[test] + fn bottom_up_bgr_is_flipped() { + let mut f = header(2, 2, 2, 24, 0); + // bottom row first: blue, green; then top row: red, white + f.extend_from_slice(&[255, 0, 0, 0, 255, 0, 0, 0, 255, 255, 255, 255]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.width, r.height, r.has_alpha), (2, 2, false)); + assert_eq!(r.rgba, [255, 0, 0, 255, 255, 255, 255, 255, 0, 0, 255, 255, 0, 255, 0, 255]); + } + + #[test] + fn rle_runs_and_raw_packets_cross_lines() { + let mut f = header(10, 3, 2, 32, 0x28); + // a run of 4 translucent red, then 2 raw pixels + f.extend_from_slice(&[0x83, 0, 0, 255, 128, 0x01, 1, 2, 3, 255, 4, 5, 6, 255]); + let r = decode("a.tga", &f).unwrap(); + assert!(r.has_alpha); + assert_eq!(&r.rgba[..16], &[255, 0, 0, 128].repeat(4)[..]); + assert_eq!(&r.rgba[16..], &[3, 2, 1, 255, 6, 5, 4, 255]); + } + + #[test] + fn colour_map_and_16_bit_alpha() { + let mut f = header(1, 2, 1, 8, 0x20); + f[1] = 1; // colour map present + f[3..5].copy_from_slice(&1u16.to_le_bytes()); // first entry index 1 + f[5..7].copy_from_slice(&2u16.to_le_bytes()); + f[7] = 24; + f.extend_from_slice(&[10, 20, 30, 40, 50, 60, 2, 1]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [60, 50, 40, 255, 30, 20, 10, 255]); + f[18 + 6] = 0; // index 0 is below the map's first entry + assert!(decode("a.tga", &f).is_err()); + + let mut f = header(2, 2, 1, 16, 0x21); + f.extend_from_slice(&0xFC00u16.to_le_bytes()); // opaque full red + f.extend_from_slice(&0x03E0u16.to_le_bytes()); // transparent full green + let r = decode("a.tga", &f).unwrap(); + assert_eq!(r.rgba, [255, 0, 0, 255, 0, 255, 0, 0]); + } + + #[test] + fn zero_alpha_32_bit_without_attribute_bits_is_opaque() { + let mut f = header(2, 1, 1, 32, 0); + f.extend_from_slice(&[1, 2, 3, 0]); + let r = decode("a.tga", &f).unwrap(); + assert_eq!((r.rgba.as_slice(), r.has_alpha), (&[3, 2, 1, 255][..], false)); + } + + #[test] + fn hostile_headers_fail_without_allocating() { + // 65535 × 65535 RLE from a few bytes, truncated raw data, bad types + let mut f = header(10, 65535, 65535, 32, 0); + f.extend_from_slice(&[0xFF, 1, 2, 3, 4]); + assert!(decode("a.tga", &f).is_err()); + let mut f = header(2, 4, 4, 24, 0); + f.extend_from_slice(&[0; 10]); + assert!(decode("a.tga", &f).is_err()); + assert!(decode("a.tga", &header(2, 1, 1, 7, 0)).is_err()); + assert!(decode("a.tga", &header(1, 1, 1, 8, 0)).is_err()); + assert!(decode("a.tga", &[0; 5]).is_err()); + for n in 0..40 { + let mut f = header(9, 3, 3, 8, 0); + f[1] = 1; + f[5] = 2; + f[7] = 16; + f.extend_from_slice(&[0x82, 1, 0, 1, 0x01, 0, 0]); + f.truncate(n); + let _ = decode("a.tga", &f); + } + } + + #[test] + fn sniffs_by_footer_or_name() { + let mut f = header(2, 1, 1, 24, 0); + f.extend_from_slice(&[0, 0, 0]); + assert!(sniff("x.TGA", &f)); + assert!(!sniff("x.bin", &f)); + f.extend_from_slice(&[0; 8]); + f.extend_from_slice(FOOTER); + assert!(sniff("x.bin", &f)); + assert!(!sniff("x.tga", b"\x89PNG\r\n\x1a\n0000000000")); + } +} diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index b62d78530..2ac9b655c 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -56,8 +56,8 @@ only). "Tested" names the evidence. | 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% | -| **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% | +| **DPX** | read + write | yes: luma / RGB / RGBA, 8–16-bit, filled 10/12-bit, either byte order (#821); no packed 10/12-bit, RLE or Y'CbCr | sequences (10-bit RGB, BT.709; #779) | 80% | +| 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% | | Radiance HDR, AI / EPS, ICO, PTL | read | no | — | 0% | @@ -84,7 +84,7 @@ only). "Tested" names the evidence. ## Summary **File formats dimension: ~60%, 140–240 h** (codecs counted separately). The beta blocker is -`.prproj`. After it, by user impact: AVI and WMV import, EXR / DPX / PSD stills, AAF / OMF +`.prproj`. After it, by user impact: AVI and WMV import, EXR / PSD stills, AAF / OMF validation in Avid and Pro Tools, DVD / Blu-ray / DCP / broadcast MXF delivery. ## Revision history diff --git a/docs/gaps.md b/docs/gaps.md index 83fb12b36..8b4b1bb52 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -165,9 +165,10 @@ platforms** G4, G9, G17; **performance** G16; **ecosystem** G11; **measurement** substreams (played as the 5.1 core), AVI (#598; listed as an extension but no demuxer), WMV / ASF, DV / DVCPRO / DV100, MPEG-4 Part 2, FLAC in MP4 (#603), camera RAW (R3D incl. R3D NE, ARRIRAW, Sony RAW / X-OCN, Canon RAW, BRAW, ProRes RAW; #345, #383), JPEG 2000 / - JPEG XS MXF, Cinema DNG; stills: PSD, EXR, DPX, Targa, HEIF / HEIC, Radiance HDR, AI / EPS; - transparent video (#402); Sony start timecode in `rtmd` (#460); stereo 3D flag (#500); MKV - multi-audio (#601). + JPEG XS MXF, Cinema DNG; stills: PSD, EXR, HEIF / HEIC, Radiance HDR, AI / EPS, and the rarer + DPX layouts (packed 10/12-bit, run-length, Y'CbCr; Targa and DPX stills and sequences import + since #821, deeper than 8 bits kept at 8); transparent video (#402); Sony start timecode in + `rtmd` (#460); stereo 3D flag (#500); MKV multi-audio (#601). - **Estimate:** 120–200 h (camera RAW alone 60–100 h, and some SDKs have licences we cannot use: clean-room from public specs only where specs exist). Docs: [codec-parity.md](codec-parity.md), [file-format-parity.md](file-format-parity.md).