mirror of
https://github.com/storytold/effectcraft.git
synced 2026-10-30 13:08:07 -06:00
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:
@@ -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) };
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user