Files
effectcraft/crates/export/tests/it/targa_sequence.rs
T
ashokram-si c51a08ceb4 Export: Targa (.tga) sequence output, 24-bit RGB or 32-bit RGB + Alpha
After Effects lists Targa among its still-image output formats; EffectCraft
imported Targa (#745) but could not render to it.

- OutputFormat::TargaSequence ("Targa Sequence", `.tga`, numbered like the
  other sequences; `tga` / `targa` as a format name or output extension).
- Written by the `image` crate's Targa encoder (its `tga` feature, already
  enabled in effectcraft-media; no new crates, Cargo.lock unchanged), with
  run-length compression off: an 18-byte header, top-left origin, 24-bit BGR
  for RGB, 32-bit BGRA with 8 alpha bits in the image descriptor for
  RGB + Alpha (straight or premultiplied), 8-bit grey for Alpha.
- The render queue's size estimate counts the uncompressed bytes.
- CLI usage, the renderQueue command specs, README, architecture.md and
  file-format-parity.md name the new format.

Tests: each Targa frame holds exactly the pixels of the PNG sequence of the
same job, with the expected header, numbering and size; the frames read back
through the Targa importer as the same picture (straight, and premultiplied
when interpreted so); the image-sequence round trip, the format parser and
the renderQueue commands cover it too.
2026-10-26 15:34:12 +05:30

219 lines
11 KiB
Rust
Raw 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 sequence output: every file is an uncompressed TGA (Truevision TGA 2.0 specification:
//! an 18-byte header, BGR(A) pixels, top-left origin, the alpha bits in the image descriptor)
//! holding exactly the pixels the lossless PNG sequence of the same job writes, and EffectCraft's
//! own Targa import (#745) reads the frames back as the same picture.
//!
//! The comp is 7×4 @ 10 fps, 3 frames: frame 0 is the background alone, frames 1 and 2 add a
//! 2×2 solid in the top two rows (so a flipped picture shows), on a half pixel horizontally, so
//! its left and right columns carry partial alpha.
use std::path::{Path, PathBuf};
use std::sync::Arc;
use effectcraft_color::Label;
use effectcraft_export::render_queue::{AlphaMode, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
use effectcraft_export::{Job, export};
use effectcraft_keyframe::Value;
use effectcraft_media::MediaPool;
use effectcraft_project::{Comp, Footage, FootageKind, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::NoFootage;
use effectcraft_time::{FrameRate, Tick};
const W: u32 = 7;
const H: u32 = 4;
const FRAMES: u64 = 3;
fn out_dir(name: &str) -> PathBuf {
let d = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../target/test-out/export").join(name);
let _ = std::fs::remove_dir_all(&d);
std::fs::create_dir_all(&d).expect("mkdir");
d
}
fn project() -> (Project, ItemId) {
let mut p = Project::default();
let mut comp = Comp::new(W, H, FrameRate::new(10, 1), Tick::from_seconds_f64(FRAMES as f64 / 10.0));
comp.background = [0.1, 0.3, 0.6];
let sid = p.add_item("Red", Label::Red, None, ItemKind::Solid(Solid { color: [0.9, 0.2, 0.1], width: 2, height: 2, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (2, 2), None);
l.props.prop_mut("transform/position").expect("position").value = Value::Vec3([3.5, 1.0, 0.0]);
l.in_point = comp.frame_rate.tick_of(1);
comp.layers.push(l);
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
/// Render the comp through `om` to `dir/f_[#####].<ext>`; the first frame's path.
fn render(p: &Project, cid: ItemId, om: &OutputModule, dir: &Path) -> String {
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let path = dir.join(format!("f_[#####].{}", om.format.extension())).to_string_lossy().to_string();
let job = Job {
project: p,
footage: &NoFootage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
let r = export(&job, &mut |_| true).expect("export");
assert_eq!((r.frames, r.width, r.height), (FRAMES, W, H));
r.path
}
/// The numbered files of a render of `ext`: frames 0..FRAMES and nothing after.
fn frame_paths(dir: &Path, ext: &str) -> Vec<String> {
let template = dir.join(format!("f_[#####].{ext}")).to_string_lossy().to_string();
assert!(!Path::new(&sequence_path(&template, FRAMES as i64)).exists(), "no frame after the last");
(0..FRAMES as i64).map(|k| sequence_path(&template, k)).collect()
}
/// A Targa file's header fields and its pixels as top-down RGBA bytes.
struct Tga {
image_type: u8,
width: u16,
height: u16,
depth: u8,
descriptor: u8,
rgba: Vec<u8>,
}
fn read_tga(path: &str) -> Tga {
let b = std::fs::read(path).unwrap_or_else(|e| panic!("{path}: {e}"));
let u16_at = |i: usize| u16::from_le_bytes([b[i], b[i + 1]]);
assert_eq!((b[0], b[1]), (0, 0), "{path}: no image ID, no colour map");
assert_eq!((u16_at(8), u16_at(10)), (0, 0), "{path}: X / Y origin");
let (width, height, depth, descriptor) = (u16_at(12), u16_at(14), b[16], b[17]);
let bpp = usize::from(depth / 8);
// Uncompressed, without an image ID, colour map or 2.0 footer: header and pixels only.
assert_eq!(b.len(), 18 + usize::from(width) * usize::from(height) * bpp, "{path}: file size");
let rgba = b[18..]
.chunks(bpp)
.flat_map(|p| match p {
[b, g, r, a] => [*r, *g, *b, *a],
[b, g, r] => [*r, *g, *b, 255],
[v] => [*v, *v, *v, 255],
_ => panic!("{path}: {depth}-bit pixels"),
})
.collect();
Tga { image_type: b[2], width, height, depth, descriptor, rgba }
}
fn png_rgba(path: &str) -> Vec<u8> {
image::open(path).unwrap_or_else(|e| panic!("{path}: {e}")).to_rgba8().into_raw()
}
#[test]
fn targa_sequence_writes_the_png_sequence_pixels() {
let (p, cid) = project();
for (channels, alpha_mode, image_type, depth, descriptor) in [
(Channels::Rgb, AlphaMode::Straight, 2, 24, 0x20),
(Channels::Rgba, AlphaMode::Straight, 2, 32, 0x28),
(Channels::Rgba, AlphaMode::Premultiplied, 2, 32, 0x28),
(Channels::Alpha, AlphaMode::Straight, 3, 8, 0x20),
] {
let name = format!("tga-{channels:?}-{alpha_mode:?}");
let tga_dir = out_dir(&name);
let png_dir = out_dir(&format!("{name}-png"));
let om = OutputModule { channels, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) };
let first = render(&p, cid, &om, &tga_dir);
assert!(first.ends_with("f_00000.tga"), "{first}");
render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, ..om.clone() }, &png_dir);
let mut frames = Vec::new();
for (tga, png) in frame_paths(&tga_dir, "tga").iter().zip(frame_paths(&png_dir, "png")) {
let t = read_tga(tga);
assert_eq!((t.image_type, t.width, t.height, t.depth, t.descriptor), (image_type, W as u16, H as u16, depth, descriptor), "{tga}");
assert_eq!(t.rgba, png_rgba(&png), "{tga} matches {png}");
frames.push(t.rgba);
}
// Frame 0 is background only; frames 1 and 2 hold the solid, so the numbering follows
// the comp's frames.
let at = |f: &[u8], x: u32, y: u32| -> [u8; 4] {
let i = ((y * W + x) * 4) as usize;
[f[i], f[i + 1], f[i + 2], f[i + 3]]
};
assert_ne!(at(&frames[0], 3, 1), at(&frames[1], 3, 1), "{name}");
assert_eq!(frames[1], frames[2], "{name}");
// Top-down rows: the solid is in the top half, the bottom rows are still background.
assert_eq!(at(&frames[1], 3, 3), at(&frames[0], 3, 3), "{name}: bottom row");
match channels {
Channels::Rgb => {
assert_eq!(at(&frames[0], 0, 0)[3], 255);
assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid");
}
Channels::Rgba => {
assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 0], "{name}: transparent background");
assert_eq!(at(&frames[1], 3, 1), [230, 51, 26, 255], "{name}: the opaque solid");
let edge = at(&frames[1], 2, 1);
assert!(edge[3] > 0 && edge[3] < 255, "{name}: partial alpha at the solid's edge {edge:?}");
if alpha_mode == AlphaMode::Straight {
// Unmatted from the renderer's premultiplied pixel: the solid's colour, within rounding.
assert!(edge[..3].iter().zip([230u8, 51, 26]).all(|(a, b)| a.abs_diff(b) <= 1), "{name}: straight colour {edge:?}");
} else {
assert!(edge[0] < 230 && edge[0] <= edge[3], "{name}: premultiplied colour {edge:?}");
}
}
Channels::Alpha => {
assert_eq!(at(&frames[0], 0, 0), [0, 0, 0, 255], "{name}: no matte yet");
assert_eq!(at(&frames[1], 3, 1), [255, 255, 255, 255], "{name}: the solid's matte");
}
}
}
}
/// Numbered stills read back as an image sequence through the media layer (Targa: #745).
fn import_sequence(paths: &[String]) -> (Footage, MediaPool) {
let pool = MediaPool::new();
pool.set_read_ahead(false);
let first = effectcraft_media::probe(&paths[0]).expect("probe our output");
assert_eq!((first.kind, first.width, first.height), (FootageKind::Still, W, H));
for p in paths {
let bytes: Arc<[u8]> = std::fs::read(p).expect("read").into();
pool.add_bytes(p, bytes);
}
let mut footage = Footage { kind: FootageKind::Sequence, sequence: paths.to_vec(), ..first };
footage.sync_sequence_duration();
(footage, pool)
}
#[test]
fn targa_sequence_round_trips_through_targa_import() {
let (p, cid) = project();
for alpha_mode in [AlphaMode::Straight, AlphaMode::Premultiplied] {
let name = format!("tga-import-{alpha_mode:?}");
let tga_dir = out_dir(&name);
let png_dir = out_dir(&format!("{name}-png"));
let om = OutputModule { channels: Channels::Rgba, alpha_mode, ..OutputModule::for_format(OutputFormat::TargaSequence) };
render(&p, cid, &om, &tga_dir);
// The reference: the same job as a straight-alpha PNG sequence (PNG's alpha is labelled).
render(&p, cid, &OutputModule { format: OutputFormat::PngSequence, alpha_mode: AlphaMode::Straight, ..om.clone() }, &png_dir);
let (mut tga, tga_pool) = import_sequence(&frame_paths(&tga_dir, "tga"));
let (png, png_pool) = import_sequence(&frame_paths(&png_dir, "png"));
assert_eq!(tga.codec, "TGA");
assert_eq!(tga.sequence_frames(), FRAMES as i64);
// Targa doesn't label its alpha: import guesses straight, and Interpret Footage sets
// premultiplied for a premultiplied render.
assert_eq!(tga.alpha, effectcraft_project::AlphaMode::Straight);
if alpha_mode == AlphaMode::Premultiplied {
tga.alpha = effectcraft_project::AlphaMode::Premultiplied;
}
// Premultiplying to 8 bits rounds partially transparent colour.
let tol = if alpha_mode == AlphaMode::Straight { 0.0 } else { 1.0 / 255.0 };
for k in 0..FRAMES as i64 {
let a = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(k)).expect("decode Targa");
let b = png_pool.frame_at(&png, png.frame_rate.tick_of(k)).expect("decode PNG");
assert_eq!((a.width, a.height), (W, H));
for (x, y) in a.data.iter().zip(&b.data) {
assert!(x.iter().zip(y).all(|(x, y)| (x - y).abs() <= tol), "{name} frame {k}: {x:?} != {y:?}");
}
}
let opaque = tga_pool.frame_at(&tga, tga.frame_rate.tick_of(1)).expect("decode");
assert_eq!(opaque.data[opaque.idx(0, 0)][3], 0.0, "{name}: the background stays transparent");
assert_eq!(opaque.data[opaque.idx(3, 1)][3], 1.0, "{name}: the solid is opaque");
}
}