Files
filmcraft/crates/media/src/tga.rs
vangel76andClaude Opus 5.5 fe52bc0b2c #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>
2026-10-11 00:58:15 +02:00

300 lines
13 KiB
Rust
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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"));
}
}