#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 <noreply@anthropic.com>
This commit is contained in:
vangel76
2026-10-11 00:58:15 +02:00
parent 9efe8f3112
commit fe52bc0b2c
8 changed files with 683 additions and 27 deletions
+60 -3
View File
@@ -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<u8> = 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!();
+273
View File
@@ -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<usize> {
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<u16>) {
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<Raster> {
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<usize>; 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<u8> {
let mut f = vec![0u8; 2048];
let p32 = |f: &mut Vec<u8>, 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<u8>, 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());
}
}
+4 -2
View File
@@ -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<dyn MediaSource>;
/// 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<Shar
if wav::sniff(&bytes) {
return Ok(Arc::new(wav::WavSource::parse(name, bytes)?));
}
if still::sniff(&bytes) {
if still::sniff(name, &bytes) {
return Ok(Arc::new(still::StillSource::decode(name, &bytes)?));
}
Err(MediaError::Unsupported(format!("{name}: unrecognised format")))
+7 -8
View File
@@ -3,7 +3,7 @@
//! - [`Numbered`] splits a file name around its last run of digits; [`sequence_frames`] lists the
//! frames from the chosen file's number up to the highest number present (gaps are `None`).
//! - [`ImageSequenceSource`] decodes frames on demand through a [`FrameLoader`] (the host's file
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF), keeps recently used frames
//! access) with the still decoders (PNG, JPEG, TIFF, BMP, WebP, GIF, Targa, DPX), keeps recently used frames
//! in a byte-budgeted [`FrameCache`], and shows the nearest earlier frame for a missing or
//! undecodable one (the nearest later frame before the first).
//! - The frame rate is the caller's (Settings ▸ Media ▸ Indeterminate Media Timebase).
@@ -134,9 +134,8 @@ impl ImageSequenceSource {
let bytes = loader(&first).map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound { MediaError::Offline(format!("{first}: {e}")) } else { MediaError::Io(format!("{first}: {e}")) }
})?;
let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{first}: {e}")))?;
let has_alpha = img.color().has_alpha();
let (w, h) = (img.width(), img.height());
let img = crate::still::decode_raster(&first, &bytes)?;
let (w, h, has_alpha) = (img.width, img.height, img.has_alpha);
let ext = first.rsplit('.').next().unwrap_or("image").to_ascii_uppercase();
let n = frames.len() as i64;
let info = MediaInfo {
@@ -161,7 +160,7 @@ impl ImageSequenceSource {
file_size: None,
};
let cache = FrameCache::new(256 << 20);
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(VideoFrame::rgba8(w, h, img.to_rgba8().into_raw())));
cache.insert(frames.iter().position(Option::is_some).unwrap_or(0), Arc::new(VideoFrame::rgba8(w, h, img.rgba)));
Ok(Self { info, frames, loader, cache })
}
@@ -183,10 +182,10 @@ impl ImageSequenceSource {
if let Some(f) = self.cache.get(&i) {
return Ok(f);
}
let path = self.frames[i].as_deref().ok_or(MediaError::NoStream("video"))?;
let path = self.frames.get(i).and_then(|f| f.as_deref()).ok_or(MediaError::NoStream("video"))?;
let bytes = (self.loader)(path).map_err(|e| MediaError::Io(format!("{path}: {e}")))?;
let img = image::load_from_memory(&bytes).map_err(|e| MediaError::Decode(format!("{path}: {e}")))?;
let f = Arc::new(VideoFrame::rgba8(img.width(), img.height(), img.to_rgba8().into_raw()));
let img = crate::still::decode_raster(path, &bytes)?;
let f = Arc::new(VideoFrame::rgba8(img.width, img.height, img.rgba));
self.cache.insert(i, f.clone());
Ok(f)
}
+33 -8
View File
@@ -1,4 +1,4 @@
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP) as media sources.
//! Still images (PNG, JPEG, GIF (first frame), WebP, TIFF, BMP, Targa, DPX) as media sources.
use std::sync::Arc;
@@ -10,8 +10,11 @@ use crate::{FrameRequest, MediaError, MediaInfo, MediaKind, MediaSource, Result,
/// Default still duration (Premiere's preference default is 5 seconds).
pub const DEFAULT_STILL_DURATION: Tick = Tick(5 * TICKS_PER_SECOND);
pub fn sniff(b: &[u8]) -> 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<u8>,
/// 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<Raster> {
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<VideoFrame>,
@@ -27,11 +55,8 @@ pub struct StillSource {
impl StillSource {
pub fn decode(name: &str, bytes: &[u8]) -> Result<Self> {
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<u8>, has_alpha: bool, file_size: u64) -> Self {
+299
View File
@@ -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::<HEADER>() 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<Self> {
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<Raster> {
let h = bytes.first_chunk::<HEADER>().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<u8> = 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<Vec<u8>> {
// 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<u8> {
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"));
}
}
+3 -3
View File
@@ -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
+4 -3
View File
@@ -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).