collective

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

diff --git a/README.md b/README.md--- a/README.md+++ b/README.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 purediff --git a/apps/effectcraft-cli/src/main.rs b/apps/effectcraft-cli/src/main.rs--- a/apps/effectcraft-cli/src/main.rs+++ b/apps/effectcraft-cli/src/main.rs@@ -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 terminaldiff --git a/crates/engine/src/cmd/render_queue.rs b/crates/engine/src/cmd/render_queue.rs--- a/crates/engine/src/cmd/render_queue.rs+++ b/crates/engine/src/cmd/render_queue.rs@@ -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         ),diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs--- a/crates/engine/src/render_queue.rs+++ b/crates/engine/src/render_queue.rs@@ -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,diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs--- a/crates/engine/src/rq_tests.rs+++ b/crates/engine/src/rq_tests.rs@@ -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); } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml--- a/crates/export/Cargo.toml+++ b/crates/export/Cargo.toml@@ -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 }diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs--- a/crates/export/src/lib.rs+++ b/crates/export/src/lib.rs@@ -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()))),     }diff --git a/crates/export/tests/it/main.rs b/crates/export/tests/it/main.rs--- a/crates/export/tests/it/main.rs+++ b/crates/export/tests/it/main.rs@@ -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;diff --git a/crates/export/tests/it/roundtrip.rs b/crates/export/tests/it/roundtrip.rs--- a/crates/export/tests/it/roundtrip.rs+++ b/crates/export/tests/it/roundtrip.rs@@ -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) };diff --git a/crates/export/tests/it/targa_sequence.rs b/crates/export/tests/it/targa_sequence.rsnew file mode 100644--- /dev/null+++ b/crates/export/tests/it/targa_sequence.rs@@ -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");+    }+}diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs--- a/crates/project/src/render_queue.rs+++ b/crates/project/src/render_queue.rs@@ -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));diff --git a/docs/architecture.md b/docs/architecture.md--- a/docs/architecture.md+++ b/docs/architecture.md@@ -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 |diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md--- a/docs/file-format-parity.md+++ b/docs/file-format-parity.md@@ -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