Merge pull request #765 from vangel76/gap-dpx-import

#756: import DPX and Cineon stills and sequences

# Conflicts:
#	crates/media/tests/footage.rs
#	crates/ui-egui/src/menus.rs
#	docs/file-format-parity.md
#	docs/gaps.md
This commit is contained in:
Brandon Thomas 2026-10-12 03:20:04 -04:00
commit 3d02e65f2b
10 changed files with 627 additions and 5 deletions

View File

@ -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", "stl", "fbx"];

View File

@ -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}");
}

481
crates/media/src/dpx.rs Normal file
View File

@ -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<u8> {
self.b.get(o).copied()
}
fn u16(&self, o: usize) -> Option<u16> {
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<u32> {
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<Option<Header>> {
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<Header> {
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<Header> {
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<Option<Footage>> {
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<Option<image::DynamicImage>> {
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<u8> {
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());
}
}

View File

@ -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;
@ -68,6 +69,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 {

View File

@ -736,6 +736,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);
}

View File

@ -60,6 +60,10 @@ pub fn probe_model(path: &str, bytes: &[u8], resolve: &dyn Fn(&str) -> Option<Ve
/// Probe in-memory file contents; `path` names the footage (and is stored as `Footage::path`).
pub fn probe_bytes(path: &str, bytes: Arc<[u8]>) -> Result<Footage> {
// 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);
}

View File

@ -756,3 +756,123 @@ fn stl_imports_as_a_model() {
assert_eq!(f.kind, FootageKind::Model);
assert_eq!(f.codec, "STL (1 meshes, 1 triangles)");
}
/// `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}");
}
}

View File

@ -1236,8 +1236,8 @@ fn file_dialog(app: &mut EffectcraftApp, id: &str, params: &Value) -> Option<Res
return None;
}
const MEDIA: &[&str] = &[
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "wav", "aif", "aiff", "mp3", "flac", "ogg",
"opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
"mp4", "mov", "m4v", "mkv", "webm", "png", "jpg", "jpeg", "gif", "webp", "tif", "tiff", "bmp", "exr", "dpx", "cin", "wav", "aif", "aiff", "mp3",
"flac", "ogg", "opus", "svg", "pdf", "ai", "eps", "psd", "psb", "tga", "hdr", "gltf", "glb", "obj", "stl", "fbx", "json", "csv", "tsv",
];
// A host whose modal dialogs would run a nested event loop inside this frame (macOS: a
// drag over the window then re-entered winit's event handler and aborted, #511) shows the

View File

@ -49,7 +49,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| BMP | R | yes | — | good |
| WebP | — | yes | — | beyond After Effects |
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output |
| DPX / Cineon `.dpx` `.cin` | R/W | none | none | Media Browser lists it but import refuses (gap 17); common in film VFX |
| DPX / Cineon `.dpx` `.cin` | R/W | yes (still and sequence; 8/10/12/16-bit luma, RGB, RGBA, both byte orders; log code values shown as stored) | none | partial: import (#756); no Interpret Footage ▸ Cineon Settings (log to linear), no DPX output; common in film VFX |
| Radiance `.hdr` | R/W | yes (still and sequence, 32-bit linear float like OpenEXR) | none | good for import (#745); no Radiance output |
| SGI, PICT, ElectricImage, RLA/RPF images | R | none | none | Legacy; RPF camera data import exists (`keys.rpfCameraImport`) |
| Camera Raw (DNG, CR3, NEF…) | R (Camera Raw) | none | none | LightCraft has decoders that could be shared |

View File

@ -73,7 +73,7 @@ are grouped by area; the issue tracker has the rest.
| 14 | **Object Matte** (26.2, disk cache in 26.5) | Not present; MobileSAM is already in `effectcraft-segment` and could back it | The new one-click roto; Roto Brush covers part of it | 15–30 h | G7 · [target-app-parity.md](target-app-parity.md) |
| 15 | **Hardware video decode and encode** | No VideoToolbox, NVENC, Quick Sync or VA-API; #498 asks for it; FilmCraft has VideoToolbox / NVENC / VA-API paths to borrow | 4K playback and export speed | 25–45 h | G5/G6 · [hardware-parity.md](hardware-parity.md) |
| 16 | **Localization depth** | Menus in 6 languages; panels in 4; effect names and ~1,650 effect parameter names untranslated; French, German, Korean, Italian, Russian (After Effects ships them) absent; #599 Hangul boxes on macOS | After Effects is used heavily in Japan, China, Korea, Brazil, Europe | 60–110 h + native review | G9 · [localization-parity.md](localization-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists DPX, AVI, MXF; `media::is_importable` has none of them (Targa and Radiance HDR import since #745); #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats (DPX) | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 17 | **Media Browser lists files import refuses** | `media_browser.rs` lists AVI, MXF; `media::is_importable` has none of them (Targa and Radiance HDR import since #745; DPX and Cineon since #756, log shown as stored: no Cineon Settings conversion yet); #431 any image imports as a sequence; #324 MP3 goes silent | Confusing failures on common VFX formats | 15–30 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 18 | **Relative paths and relinking** | Projects store absolute footage paths; no relink when a project moves | Moving or sharing a project breaks it | 6–12 h | G4 · [file-format-parity.md](file-format-parity.md) |
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |