Export: Targa (.tga) sequence output, 24-bit RGB or 32-bit RGB + Alpha

After Effects lists Targa among its still-image output formats; EffectCraft
imported Targa (#745) but could not render to it.

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

Tests: each Targa frame holds exactly the pixels of the PNG sequence of the
same job, with the expected header, numbering and size; the frames read back
through the Targa importer as the same picture (straight, and premultiplied
when interpreted so); the image-sequence round trip, the format parser and
the renderQueue commands cover it too.
This commit is contained in:
ashokram-si
2026-10-26 15:34:12 +05:30
parent 2961258879
commit c51a08ceb4
13 changed files with 309 additions and 23 deletions
+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";
/// 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
),
+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);
}