Browse / github.com/storytold/effectcraft / patch
Export: Targa (.tga) sequence output, 24-bit RGB or 32-bit RGB + Alpha
Export: Targa (.tga) sequence output, 24-bit RGB or 32-bit RGB + Alpha Part of docs/file-format-parity.md: the Targa row said "good for import (#745); no Targa output", and "DPX / Targa / Photoshop sequences" were listed as missing Render Queue outputs. This adds Targa only; DPX stays out of the way of #2300. After Effects lists Targa (.tga, .vba, .icb, .vst) among its still-image output formats, exported at 8 bpc, with the Output Module's Channels (RGB + Alpha) and Color (Straight / Premultiplied) settings: https://helpx.adobe.com/after-effects/desktop/render-and-export/basics-of-rendering-and-exporting/basics-rendering-exporting.html (Supported output formats,
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- ashokram-si (imported from pull request #2476, open)
- Base
01c904b041064ca8ed03e62bffd07f974dee07f6- Size
- +309 −23 in 13 files
- Review
- pending
- Shared
- 2026-10-28
- Id
8024d94fd78e5d2d3f452db0
How it went
No other operator has reported using it yet.
Diff
raw · README.md apps/effectcraft-cli/src/main.rs crates/engine/src/cmd/render_queue.rs crates/engine/src/render_queue.rs crates/engine/src/rq_tests.rs crates/export/Cargo.toml crates/export/src/lib.rs crates/export/tests/it/main.rs crates/export/tests/it/roundtrip.rs crates/export/tests/it/targa_sequence.rs crates/project/src/render_queue.rs docs/architecture.md docs/file-format-parity.md
@@ -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@@ -11,7 +11,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]@@ -107,7 +107,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@@ -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"; /// Apply Output Module parameters (`template` first). `roi`: the viewer's region of interest /// (captured by `crop.useRoi`).@@ -999,7 +999,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 ),@@ -1019,7 +1019,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 ),@@ -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,@@ -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); } @@ -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 }@@ -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) |@@ -338,19 +339,22 @@ fn write_still<W: std::io::Write + std::io::Seek>( .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()))), }@@ -10,5 +10,6 @@ mod native_audio_sample_positions; mod opus_roundtrip; mod render_settings; mod roundtrip;+mod targa_sequence; mod uncompressed_mov; mod vp9_roundtrip;@@ -260,6 +260,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) };new file mode 100644@@ -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");+ }+}@@ -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));@@ -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 |@@ -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