Merge pull request #2476 from ashokram-si/feat/targa-sequence-output

Export: Targa (.tga) sequence output, 24-bit RGB or 32-bit RGB + Alpha
This commit is contained in:
BFlat
2026-10-30 01:19:27 +07:00
committed by GitHub
13 changed files with 309 additions and 23 deletions
+1 -1
View File
@@ -155,7 +155,7 @@ tools, and Front, Top, Left and Custom views.
A dedicated Render Queue, with Render Settings and Output Modules: **H.264** MP4 and
**ProRes** MOV (Proxy to 4444 XQ with alpha) with audio, **HEVC** (Main / Main 10) and **AV1**
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
MP4, **WebM** (VP9 with inter frames and alpha, or AV1; Opus audio), PNG, JPEG, TIFF, Targa and 32-bit EXR sequences, animated GIF and WAV/AIFF. Field
rendering with 3:2 pulldown, effect/solo/guide/depth overrides, crop, region of interest and
resize, Render Settings and Output Module templates with defaults, post-render actions, storage
overflow and render logs. The same queue runs from the command line. Every encoder is pure
+2 -2
View File
@@ -12,7 +12,7 @@
//! effectcraft-cli get <comp> <layer> <path> [--time S] read a property
//! effectcraft-cli set <comp> <layer> <path> <value> [--time S] [--expression E]
//! effectcraft-cli render-frame [--comp C] [--time S|--frame N] [--max-side PX|--scale K] [--out F.png] [--transparent]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff] [--start S] [--end S]
//! effectcraft-cli render [--comp C] --out FILE [--format h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff] [--start S] [--end S]
//! [--work-area] [--fps N] [--resolution full|half|third|quarter|K] [--quality best|draft] [--channels rgb|rgba]
//! [--jpeg-quality N] [--bitrate KBPS] [--prores proxy|lt|standard|hq|4444|4444xq] [--audio auto|on|off]
//! [--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality 1-100]
@@ -112,7 +112,7 @@ const USAGE: &str = "usage: effectcraft-cli <info|commands|exec|run|props|get|se
[--jpeg-quality N] [--bitrate KBPS] [--prores PROFILE] [--audio auto|on|off]
[--profile main|main10] [--level auto|4.1] [--rate-control bitrate|quality] [--video-quality N]
[--keyint FRAMES] [--webm-codec vp9|av1] [--audio-bitrate KBPS] [--opus-app audio|voice] | --queue
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|gif|wav|aiff;
(formats h264|hevc|av1|prores|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff;
--profile..--keyint: HEVC / AV1, --audio-bitrate/--opus-app: WebM Opus)
a relative --out is relative to the working directory
--progress: progress lines on stderr when it is not a terminal
+3 -3
View File
@@ -203,7 +203,7 @@ fn apply_om_template(om: &mut OutputModule, t: &OutputModule) {
}
/// The `format` parameter's values.
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff";
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff";
/// `channels` (refusing RGBA where the format has no alpha) and `color`/`alphaMode`.
fn apply_channels_and_alpha(om: &mut OutputModule, p: &Value, cmd: &str) -> Result<()> {
@@ -1017,7 +1017,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Add to Render Queue",
["Composition"],
["Cmd+M", "Cmd+Shift+/"],
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, output?: path|template, log?: errorsOnly|plusSettings|plusPerFrameInfo, quality?: best|draft|1-100 (jpeg, webm), resolution?: full|half|third|quarter|scale, proxyUse?, effects?, solo?, guideLayers?, colorDepth?, frameBlending?, fieldRender?, pulldown?, motionBlur?, timeSpan?: workArea|comp|custom, start?: s, end?: s, duration?: s, frameRate?: fps|null, skipExisting?: bool, storageOverflow?: bool, channels?: rgb|rgba|alpha, color?, bitrate?: kbps, webmBitrate?, keyframeInterval?, proresProfile?, profile?, level?, rateControl?, webmCodec?, audioBitrate?, opusApplication?, crop?, resize?, includeProjectLink?, audio?: auto|on|off, sampleRate?, audioChannels?, audioFormat?, loop?: bool (values as in setRenderSettings / setOutputModule)}",
can_add,
add
),
@@ -1037,7 +1037,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Output Module Settings...",
[],
None,
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
"{item?|index?, module?: n (1 = first), template?: name, format?: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|tga|gif|wav|aiff, channels?: rgb|rgba|alpha, color?: straight|premultiplied, quality?: 1-100, bitrate?: kbps, webmBitrate?: bool, keyframeInterval?: frames (0 = auto), proresProfile?: proxy|lt|standard|hq|4444|4444xq, profile?: main|main10 (HEVC/AV1), level?: auto|4.1, rateControl?: bitrate|quality, webmCodec?: vp9|av1, audioBitrate?: Opus kbps, opusApplication?: audio|voice, crop?: bool|{useRoi?, roi?, top?, left?, bottom?, right?}, cropTop?, cropLeft?, cropBottom?, cropRight?, resize?: bool|{preset?, width?, height?, lockAspect?, quality?: low|high}, resizeWidth?, resizeHeight?, postRenderAction?: none|import|importAndReplace|setProxy, includeProjectLink?: bool, audio?: auto|on|off, sampleRate?, audioChannels?: mono|stereo, audioFormat?: 16|24|32, loop?: bool, output?}",
has_items,
set_output_module
),
+10
View File
@@ -208,6 +208,16 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
let bpp = if om.channels == effectcraft_project::render_queue::Channels::Rgba { 4 } else { 3 };
((w as u64 * bpp).next_multiple_of(2) * h as u64) as f64
}
// An 18-byte header and 24-bit BGR, 32-bit BGRA or 8-bit grey (Alpha) pixels, uncompressed.
OutputFormat::TargaSequence => {
use effectcraft_project::render_queue::Channels;
let bpp = match om.channels {
Channels::Rgb => 3,
Channels::Rgba => 4,
Channels::Alpha => 1,
};
(18 + w as u64 * h as u64 * bpp) as f64
}
OutputFormat::WebM => mbps(8.0) * px / fps,
OutputFormat::Gif => w as f64 * h as f64 * 0.5,
OutputFormat::Wav | OutputFormat::Aiff => om.audio_sample_rate as f64 * 4.0 / fps,
+20 -1
View File
@@ -116,6 +116,25 @@ fn persists_in_project_file() {
assert!(effectcraft_project::Project::from_json(&old).unwrap().render_queue.is_empty());
}
#[test]
fn targa_sequence_output_module() {
let (mut s, _) = rq_session();
let a = s.execute("renderQueue.add", json!({"format": "tga", "output": "/out/[compName]_[#####].tga", "channels": "rgba"})).unwrap();
let om = &s.project.render_queue[0].output;
assert_eq!((format!("{:?}", om.format), om.channels.label()), ("TargaSequence".to_string(), "RGB + Alpha"));
assert!(a["outputPath"].as_str().unwrap().ends_with("_[#####].tga"), "{a}");
// Uncompressed: an 18-byte header and 4 bytes a pixel with alpha, 3 without.
assert_eq!(crate::render_queue::bytes_per_frame(om, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 4.0);
s.execute("renderQueue.setOutputModule", json!({"index": 1, "channels": "rgb"})).unwrap();
assert_eq!(crate::render_queue::bytes_per_frame(&s.project.render_queue[0].output, 7, 4, 10.0), 18.0 + 7.0 * 4.0 * 3.0);
let f = s.execute("renderQueue.formats", json!({})).unwrap();
let tga = f["formats"].as_array().unwrap().iter().find(|f| f["id"] == "TargaSequence").cloned();
assert_eq!(
tga,
Some(json!({"id": "TargaSequence", "label": "Targa Sequence", "extension": "tga", "sequence": true, "alpha": true, "audio": false, "available": true}))
);
}
#[test]
fn failure_toast_names_the_first_reason() {
struct Failing;
@@ -186,7 +205,7 @@ fn without_exporter() {
s.execute("renderQueue.add", json!({})).unwrap();
assert!(!s.is_enabled("renderQueue.render"));
let f = s.execute("renderQueue.formats", json!({})).unwrap();
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
assert_eq!(f["formats"].as_array().unwrap().len(), 14);
assert_eq!(f["formats"][0]["available"], false);
}
+2 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "effectcraft-export"
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR sequences, animated GIF, WAV/AIFF"
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC), ProRes and uncompressed MOV (+PCM) via FilmCraft, HEVC / AV1 MP4 (+AAC) and WebM (VP9 or AV1 + Opus) via EffectCraft encoders, PNG/JPEG/TIFF/EXR/Targa sequences, animated GIF, WAV/AIFF"
version.workspace = true
edition.workspace = true
license.workspace = true
@@ -22,7 +22,7 @@ effectcraft-opusenc = { workspace = true }
effectcraft-vp9enc = { workspace = true }
effectcraft-hevcenc = { workspace = true }
effectcraft-av1enc = { workspace = true }
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr"] }
image = { workspace = true, features = ["png", "jpeg", "tiff", "exr", "tga"] }
gif = { workspace = true }
rayon = { workspace = true }
web-time = { workspace = true }
+9 -5
View File
@@ -7,6 +7,7 @@
//! | Uncompressed | QuickTime MOV (`raw `) | 8-bit RGB (depth 24, rows padded to even bytes) or ARGB (depth 32) | RGB + Alpha (straight or premultiplied) | PCM 16/24-bit or 32-bit float, mono/stereo |
//! | PNG / JPEG / TIFF sequence | one file per frame | 8-bit | PNG, TIFF | — |
//! | OpenEXR sequence | one file per frame | 32-bit float, linear light, premultiplied | yes | — |
//! | Targa sequence | one file per frame | uncompressed 8-bit: 24-bit RGB, 32-bit RGB + Alpha (8 alpha bits), 8-bit grey for Alpha; top-left origin | yes (straight or premultiplied) | — |
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz |
//! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) |
@@ -335,19 +336,22 @@ fn write_still<W: std::io::Write + std::io::Seek>(cx: &Cx, file: &mut W, img: &I
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
.map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
OutputFormat::PngSequence | OutputFormat::TiffSequence | OutputFormat::TargaSequence => {
let px = cx.pixels(img, comp, channels, w, h);
let (data, ct) = match channels {
Channels::Rgba => (px, ExtendedColorType::Rgba8),
Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8),
Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
};
if fmt == OutputFormat::PngSequence {
image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
} else {
match fmt {
OutputFormat::PngSequence => image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct),
// Uncompressed (the encoder's run-length compression is off), top-left origin, the
// alpha bits in the image descriptor: 8 for RGB + Alpha, 0 otherwise.
OutputFormat::TargaSequence => image::codecs::tga::TgaEncoder::new(file).disable_rle().write_image(&data, w, h, ct),
// (the TIFF encoder needs Seek)
image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
_ => image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct),
}
.map_err(|e| ExportError::Encode(e.to_string()))
}
f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))),
}
+1
View File
@@ -12,5 +12,6 @@ mod native_audio_sample_positions;
mod opus_roundtrip;
mod render_settings;
mod roundtrip;
mod targa_sequence;
mod uncompressed_mov;
mod vp9_roundtrip;
+2
View File
@@ -278,6 +278,8 @@ fn image_sequences_roundtrip() {
(OutputFormat::JpegSequence, "jpg", 0.06, Channels::Rgb),
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgb),
(OutputFormat::TiffSequence, "tif", 0.01, Channels::Rgba),
(OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgb),
(OutputFormat::TargaSequence, "tga", 0.01, Channels::Rgba),
] {
let d = out_dir(&format!("seq-{ext}-{ch:?}"));
let om = OutputModule { channels: ch, ..OutputModule::for_format(fmt) };
+218
View File
@@ -0,0 +1,218 @@
//! 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");
}
}
+35 -3
View File
@@ -509,6 +509,9 @@ pub enum OutputFormat {
TiffSequence,
/// OpenEXR sequence (32-bit float, linear light).
ExrSequence,
/// Targa sequence: uncompressed 24-bit RGB or 32-bit RGB + Alpha (8 alpha bits), top-left
/// origin.
TargaSequence,
/// Animated GIF.
Gif,
/// WebM: VP9 video (alpha with RGB + Alpha) + Opus audio.
@@ -528,7 +531,7 @@ pub enum OutputFormat {
}
impl OutputFormat {
pub const ALL: [OutputFormat; 13] = [
pub const ALL: [OutputFormat; 14] = [
OutputFormat::H264,
OutputFormat::Hevc,
OutputFormat::Av1,
@@ -539,6 +542,7 @@ impl OutputFormat {
OutputFormat::JpegSequence,
OutputFormat::TiffSequence,
OutputFormat::ExrSequence,
OutputFormat::TargaSequence,
OutputFormat::Gif,
OutputFormat::Wav,
OutputFormat::Aiff,
@@ -552,6 +556,7 @@ impl OutputFormat {
OutputFormat::JpegSequence => "JPEG Sequence",
OutputFormat::TiffSequence => "TIFF Sequence",
OutputFormat::ExrSequence => "OpenEXR Sequence",
OutputFormat::TargaSequence => "Targa Sequence",
OutputFormat::Gif => "Animated GIF",
OutputFormat::WebM => "WebM (VP9 + Opus)",
OutputFormat::Wav => "WAV",
@@ -569,6 +574,7 @@ impl OutputFormat {
OutputFormat::JpegSequence => "jpg",
OutputFormat::TiffSequence => "tif",
OutputFormat::ExrSequence => "exr",
OutputFormat::TargaSequence => "tga",
OutputFormat::Gif => "gif",
OutputFormat::WebM => "webm",
OutputFormat::Wav => "wav",
@@ -577,7 +583,10 @@ impl OutputFormat {
}
}
pub fn is_sequence(self) -> bool {
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
matches!(
self,
OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence | OutputFormat::TargaSequence
)
}
pub fn is_movie(self) -> bool {
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)
@@ -603,7 +612,7 @@ impl OutputFormat {
if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) }
}
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`,
/// `tiff`/`tif`, `exr`, `gif`, or a label.
/// `tiff`/`tif`, `exr`, `tga`/`targa`, `gif`, or a label.
pub fn from_name(s: &str) -> Option<OutputFormat> {
let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");
Some(match n.as_str() {
@@ -616,6 +625,7 @@ impl OutputFormat {
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
"exr" | "openexr" | "openexrsequence" => OutputFormat::ExrSequence,
"tga" | "targa" | "targasequence" => OutputFormat::TargaSequence,
"gif" | "animatedgif" => OutputFormat::Gif,
"webm" | "vp9" | "webmvp9opus" => OutputFormat::WebM,
"wav" | "wave" => OutputFormat::Wav,
@@ -1682,6 +1692,28 @@ mod tests {
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f);
}
#[test]
fn targa_sequence_format() {
let f = OutputFormat::from_name("tga").unwrap();
assert_eq!(f, OutputFormat::TargaSequence);
assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f));
assert_eq!(OutputFormat::from_name("Targa"), Some(f));
assert_eq!(OutputFormat::from_name("Targa Sequence"), Some(f));
assert_eq!(OutputFormat::from_path("/out/shot_00001.TGA"), Some(f));
assert!(OutputFormat::ALL.contains(&f));
assert!(f.is_sequence() && f.supports_alpha() && !f.supports_audio() && !f.is_movie());
assert_eq!((f.label(), f.extension()), ("Targa Sequence", "tga"));
assert_eq!(f.coded_size(3, 5), (3, 5));
// A movie name becomes a numbered sequence; RGB + Alpha survives the switch.
let mut m = OutputModule::for_format(OutputFormat::ProRes);
m.output = "/out/Comp 1.mov".into();
m.channels = Channels::Rgba;
m.set_format(f);
assert_eq!((m.output.as_str(), m.channels), ("/out/Comp 1_[#####].tga", Channels::Rgba));
let json = serde_json::to_string(&m).unwrap();
assert_eq!(serde_json::from_str::<OutputModule>(&json).unwrap().format, f);
}
#[test]
fn hevc_av1_codec_options() {
assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc));
+1 -1
View File
@@ -37,7 +37,7 @@ cpal or muda. Everything in L0 to L4, the egui UI and the web app also build for
| L3 | `render` | Evaluation and compositing: sources, masks, effects, transforms, 3D, motion blur, mattes, blending, layer cache, audio mixdown |
| L3 | `media` | Footage decoding (FilmCraft's pure-Rust codecs), image sequences, Photoshop and SVG stills, frame cache |
| L3 | `expr` | The expression engine (JavaScript via boa) with the After Effects object model |
| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR sequences, GIF, WAV/AIFF |
| L3 | `export` | Render queue encoding: H.264, HEVC and AV1 MP4, ProRes, WebM (VP9 + alpha or AV1, Opus), PNG/JPEG/TIFF/EXR/Targa sequences, GIF, WAV/AIFF |
| L3 | `gpu` | The GPU compositor and GPU effects on wgpu compute shaders (Metal, Vulkan, Direct3D 12, WebGPU), checked against the CPU renderer |
| L3 | `interchange` | Premiere Pro interop through timeline interchange (FCP7 XML, FCPXML, OTIO, EDL, AAF, OMF via FilmCraft's `filmcraft-interchange`): sequences ↔ compositions (§6a) |
| L3 | `lottie` | Lottie JSON / dotLottie import and export (layers, precomps, eased and spatial keyframes, shapes, masks, mattes) with a warnings list for what Lottie cannot express |
+5 -5
View File
@@ -48,7 +48,7 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| GIF | R/W | yes | yes (animated) | good |
| BMP | R | yes | — | good |
| WebP | — | yes (still; animated WebP as footage with its frames, timing and alpha, #762) | — | beyond After Effects |
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | none | good for import (#745); no Targa output |
| Targa `.tga` | R/W | yes (still and sequence; alpha interpreted on import) | yes (sequence; uncompressed 24-bit RGB or 32-bit RGB + Alpha, straight or premultiplied) | good: import (#745); output reads back through it unchanged; the alpha is not labelled in the file (no TGA 2.0 extension area), so it is interpreted on import |
| 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`) |
@@ -96,19 +96,19 @@ trips and renders, checked by tests, not against After Effects), **partial** (na
| Mocha shapes | R (Mocha AE) | own documented JSON | — | Mocha's format is not public |
| Vanishing Point `.vpe` | R | none | — | no public specification; the menu entry explains why |
## Render queue outputs (`OutputFormat::ALL`, 13)
## Render queue outputs (`OutputFormat::ALL`, 14)
H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, uncompressed 8-bit RGB / RGB + Alpha MOV (`raw `, + PCM;
#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR sequences, animated GIF, WAV, AIFF.
#372), WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR / Targa sequences, animated GIF, WAV, AIFF.
Missing against After Effects + Media Encoder: 16-bpc uncompressed, AVI and lossless-compressed
single-file video (Animation / PNG codecs), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware
single-file video (Animation / PNG codecs), DPX / Photoshop sequences, MXF, DNxHR, hardware
encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file
(#325).
## Estimate
**≈ 130–230 h**: `.aep` / `.aepx` import 80–160 h (after the owner decision), wiring TGA / DPX /
HDR / MXF / AVI / DNx import and DPX/TGA output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h,
HDR / MXF / AVI / DNx import and DPX output 10–20 h, FBX animation and skinning, USD and Substance 20–40 h,
relinking 6–12 h, `.mogrt`-compatible templates 15–30 h (partly parallel with the rest).
## Revision history