#372: uncompressed QuickTime (raw RGB / ARGB) output module format

Problem: the Render Queue had no single-file uncompressed output, so
handing a render to an external encoder meant a lossy codec, ProRes,
or an image sequence plus a separate WAV.

Fix: add the "QuickTime (Uncompressed)" Output Module format. Frames
are stored as QuickTime 'raw ' samples: 8-bit RGB (depth 24, rows
padded to even bytes) or ARGB with RGB + Alpha (depth 32), written
through FilmCraft's existing QuickTime muxer with PCM audio
(16/24-bit or float) like ProRes. Format name `uncompressed` on the
control channel; disk-space estimate and Sample Format menu included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
vangel76 2026-10-11 12:43:33 +02:00
parent 9a7d3e7699
commit 575642a5ed
12 changed files with 410 additions and 20 deletions

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|webm|png|jpeg|tiff|exr|gif|wav|aiff";
const FORMATS: &str = "format: h264|hevc|av1|prores|uncompressed|webm|png|jpeg|tiff|exr|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"],
Some("Cmd+M"),
"{comp?: id|name, template?: Render Settings template, format?: h264|hevc|av1|prores|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|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|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|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
),

View File

@ -203,6 +203,11 @@ pub fn bytes_per_frame(om: &OutputModule, w: u32, h: u32, fps: f64) -> f64 {
};
mbps(rate) * px / 29.97
}
// 24-bit RGB rows padded to even bytes, or 32-bit ARGB.
OutputFormat::Uncompressed => {
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
}
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,

View File

@ -162,7 +162,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(), 12);
assert_eq!(f["formats"].as_array().unwrap().len(), 13);
assert_eq!(f["formats"][0]["available"], false);
}

View File

@ -1,6 +1,6 @@
[package]
name = "effectcraft-export"
description = "Render Queue jobs and output modules: H.264 MP4 (+AAC) and ProRes 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 sequences, animated GIF, WAV/AIFF"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@ -1,5 +1,5 @@
//! Movie export: FilmCraft H.264, HEVC (`effectcraft-hevcenc`) and AV1 (`effectcraft-av1enc`)
//! → MP4 (+ AAC) and ProRes → MOV (+ PCM), muxed by
//! → MP4 (+ AAC), ProRes and uncompressed `raw ` → MOV (+ PCM), muxed by
//! FilmCraft's ISO BMFF / QuickTime writer.
use std::io::Write;
@ -253,16 +253,17 @@ pub(crate) async fn movie(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut St
let rate = job.settings.rate(comp);
let fmt = job.output.format;
let channels = match job.output.channels {
Channels::Rgba if fmt != OutputFormat::ProRes => Channels::Rgb,
Channels::Rgba if !matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) => Channels::Rgb,
c => c,
};
let mut venc: Box<dyn VideoEncoder> = match fmt {
OutputFormat::H264 => Box::new(H264::new(w, h, rate, job.output.bitrate_kbps)?),
OutputFormat::Hevc => Box::new(crate::hevc_av1::Hevc::new(w, h, rate, job.output)?),
OutputFormat::Av1 => Box::new(crate::hevc_av1::Av1::new(w, h, rate, job.output)?),
OutputFormat::Uncompressed => Box::new(crate::uncompressed::Raw::new(w, h, channels == Channels::Rgba)?),
_ => Box::new(ProRes::new(w, h, job.output.prores_profile, if channels == Channels::Alpha { Channels::Rgb } else { channels })),
};
let brand = if fmt == OutputFormat::ProRes { Brand::Mov } else { Brand::Mp4 };
let brand = if matches!(fmt, OutputFormat::ProRes | OutputFormat::Uncompressed) { Brand::Mov } else { Brand::Mp4 };
let batch = batch_size();
let render = |_: u64, img: effectcraft_raster::Image| -> Vec<u8> { job.pixels(&img, comp, channels, w, h) };

View File

@ -4,6 +4,7 @@
//! |---|---|---|---|---|
//! | H.264 | MP4 | FilmCraft H.264 High, 8-bit 4:2:0 BT.709 limited, VBR | no | AAC-LC (FilmCraft), mono/stereo |
//! | ProRes | QuickTime MOV | FilmCraft ProRes 422 Proxy/LT/422/HQ, 10-bit; 4444/4444 XQ 4:4:4 | 4444 (16-bit alpha coding) | PCM 16/24-bit or 32-bit float, mono/stereo |
//! | 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 | — |
//! | Animated GIF | GIF89a | 256-colour palette per frame (NeuQuant) | 1-bit | — |
@ -35,6 +36,7 @@ mod encode;
mod hevc_av1;
mod out;
mod pipeline;
mod uncompressed;
mod webm;
mod webm_av1;
@ -192,7 +194,9 @@ async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t
st.advance(0)?;
let (w, h) = output_size(comp, cx.settings, cx.output);
let mut report = match cx.output.format {
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes => encode::movie(cx, comp, w, h, &mut st).await?,
OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::ProRes | OutputFormat::Uncompressed => {
encode::movie(cx, comp, w, h, &mut st).await?
}
OutputFormat::Gif => gif_export(cx, comp, w, h, &mut st).await?,
OutputFormat::WebM => webm::webm(cx, comp, w, h, &mut st).await?,
OutputFormat::Wav => webm::audio_file(cx, comp, false, &mut st)?,

View File

@ -0,0 +1,132 @@
//! Uncompressed QuickTime video (`raw `): 8-bit RGB ("Millions of Colors", depth 24) or ARGB
//! ("Millions of Colors+", depth 32), one frame per sample, muxed by FilmCraft's QuickTime writer
//! next to PCM audio like ProRes.
//!
//! Layout from the QuickTime File Format specification (Apple, "Video Sample Description" and
//! "Uncompressed RGB" formats): pixels are stored top to bottom, components big-endian in
//! A R G B / R G B order, and each row is padded to an even number of bytes (only depth 24 at odd
//! widths needs it).
use filmcraft_isobmff::{CodecConfig, FourCc, SampleEntry, VideoParams};
use crate::encode::{Packet, VideoEncoder};
use crate::{ExportError, Result};
pub(crate) struct Raw {
w: u32,
h: u32,
alpha: bool,
}
impl Raw {
/// `alpha`: write ARGB (depth 32); otherwise RGB (depth 24).
pub(crate) fn new(w: u32, h: u32, alpha: bool) -> Result<Raw> {
if w == 0 || h == 0 || w > u16::MAX as u32 || h > u16::MAX as u32 {
return Err(ExportError::Unsupported(format!("uncompressed QuickTime frames must be 1–65535 px, not {w}×{h}")));
}
Ok(Raw { w, h, alpha })
}
fn depth(&self) -> u16 {
if self.alpha { 32 } else { 24 }
}
/// Bytes per stored row (even).
fn row_bytes(&self) -> usize {
let n = self.w as usize * if self.alpha { 4 } else { 3 };
n + (n & 1)
}
/// The `raw ` video sample description payload (after the 8-byte box header).
fn description(&self) -> Vec<u8> {
let mut b = Vec::with_capacity(78);
b.extend_from_slice(&[0; 6]); // reserved
b.extend_from_slice(&1u16.to_be_bytes()); // data reference index
b.extend_from_slice(&[0; 4]); // version, revision level
b.extend_from_slice(&[0; 4]); // vendor
b.extend_from_slice(&0u32.to_be_bytes()); // temporal quality
b.extend_from_slice(&1024u32.to_be_bytes()); // spatial quality: codecLosslessQuality
b.extend_from_slice(&(self.w as u16).to_be_bytes());
b.extend_from_slice(&(self.h as u16).to_be_bytes());
b.extend_from_slice(&0x0048_0000u32.to_be_bytes()); // 72 dpi
b.extend_from_slice(&0x0048_0000u32.to_be_bytes());
b.extend_from_slice(&0u32.to_be_bytes()); // data size
b.extend_from_slice(&1u16.to_be_bytes()); // frames per sample
let name = b"None";
let mut nb = [0u8; 32];
nb[0] = name.len() as u8;
nb[1..=name.len()].copy_from_slice(name);
b.extend_from_slice(&nb);
b.extend_from_slice(&self.depth().to_be_bytes());
b.extend_from_slice(&(-1i16).to_be_bytes()); // no colour table
b
}
}
impl VideoEncoder for Raw {
fn sample_entry(&self) -> SampleEntry {
let fourcc = FourCc(*b"raw ");
SampleEntry {
format: fourcc,
data_reference_index: 1,
// Written verbatim; `video` makes the track a video track with the right size.
codec: CodecConfig::Unknown { fourcc, raw: self.description() },
video: Some(VideoParams { width: self.w as u16, height: self.h as u16, depth: self.depth(), ..Default::default() }),
audio: None,
bitrate: None,
}
}
fn encode(&mut self, rgba: &[u8], _index: u64) -> Result<Vec<Packet>> {
let (w, h) = (self.w as usize, self.h as usize);
let row = self.row_bytes();
let size = row.checked_mul(h).ok_or_else(|| ExportError::Encode("frame too large".into()))?;
let mut data = vec![0u8; size];
for (y, out) in data.chunks_exact_mut(row).enumerate() {
let src = rgba.get(y * w * 4..(y + 1) * w * 4).ok_or_else(|| ExportError::Encode("short frame".into()))?;
for (p, o) in src.as_chunks::<4>().0.iter().zip(out.chunks_exact_mut(if self.alpha { 4 } else { 3 })) {
if self.alpha {
o.copy_from_slice(&[p[3], p[0], p[1], p[2]]);
} else {
o.copy_from_slice(&p[..3]);
}
}
}
Ok(vec![Packet { data, key: true, cto: 0 }])
}
fn flush(&mut self) -> Result<Vec<Packet>> {
Ok(vec![])
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rgb_rows_are_padded_to_even_bytes() {
let mut r = Raw::new(3, 2, false).unwrap();
let rgba: Vec<u8> = (0..24).collect();
let p = r.encode(&rgba, 0).unwrap();
assert_eq!(p[0].data, [0, 1, 2, 4, 5, 6, 8, 9, 10, 0, 12, 13, 14, 16, 17, 18, 20, 21, 22, 0]);
assert_eq!(r.sample_entry().video.unwrap().depth, 24);
}
#[test]
fn argb_moves_alpha_first() {
let mut r = Raw::new(1, 1, true).unwrap();
assert_eq!(r.encode(&[10, 20, 30, 40], 0).unwrap()[0].data, [40, 10, 20, 30]);
let e = r.sample_entry();
let CodecConfig::Unknown { raw, .. } = e.codec else { panic!("raw entry") };
assert_eq!(raw.len(), 78);
assert_eq!(&raw[74..76], &32u16.to_be_bytes());
}
#[test]
fn hostile_sizes_are_errors() {
assert!(Raw::new(0, 1, false).is_err());
assert!(Raw::new(70_000, 1, true).is_err());
assert!(Raw::new(2, 2, true).unwrap().encode(&[0; 4], 0).is_err());
}
}

View File

@ -0,0 +1,222 @@
//! Uncompressed QuickTime (`raw `) output (#372): every frame stored is exactly the frame the
//! lossless PNG sequence of the same job writes, read back with FilmCraft's demuxer and — when
//! installed — decoded by ffmpeg as an external oracle. Audio rides along as PCM.
//!
//! The comp is 33×20 so 24-bit rows (99 bytes) need their padding byte, and a 16-px solid centred
//! in it sits on a half pixel, so its edges carry partial alpha.
use std::path::{Path, PathBuf};
use std::process::Command;
use effectcraft_color::Label;
use effectcraft_export::render_queue::{AudioFormat, AudioOutput, Channels, OutputFormat, OutputModule, RenderSettings, TimeSpan, sequence_path};
use effectcraft_export::{Job, export};
use effectcraft_media::MediaPool;
use effectcraft_project::{Comp, ItemId, ItemKind, LayerSource, Project, Solid, build};
use effectcraft_render::FootageSource;
use effectcraft_time::{FrameRate, Tick};
use filmcraft_isobmff::TrackKind;
const W: u32 = 33;
const H: u32 = 20;
const FRAMES: u64 = 6;
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
}
/// 16-bit stereo 48 kHz WAV: a 440 Hz sine at 0.5 on the left, silence on the right.
fn write_tone(path: &Path, secs: f64) {
let sr = 48_000u32;
let n = (secs * sr as f64) as usize;
let mut data = Vec::with_capacity(n * 4);
for i in 0..n {
let s = (0.5 * (2.0 * std::f64::consts::PI * 440.0 * i as f64 / sr as f64).sin() * 32767.0).round() as i16;
data.extend_from_slice(&s.to_le_bytes());
data.extend_from_slice(&0i16.to_le_bytes());
}
let mut w = Vec::new();
w.extend_from_slice(b"RIFF");
w.extend_from_slice(&(36 + data.len() as u32).to_le_bytes());
w.extend_from_slice(b"WAVEfmt ");
w.extend_from_slice(&16u32.to_le_bytes());
w.extend_from_slice(&1u16.to_le_bytes());
w.extend_from_slice(&2u16.to_le_bytes());
w.extend_from_slice(&sr.to_le_bytes());
w.extend_from_slice(&(sr * 4).to_le_bytes());
w.extend_from_slice(&4u16.to_le_bytes());
w.extend_from_slice(&16u16.to_le_bytes());
w.extend_from_slice(b"data");
w.extend_from_slice(&(data.len() as u32).to_le_bytes());
w.extend_from_slice(&data);
std::fs::write(path, w).expect("write wav");
}
fn project(audio: Option<&Path>) -> (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: 16, height: 12, pixel_aspect: 1.0 }));
let mut l = build::layer(&mut p, &comp, "Red", LayerSource::Solid { item: sid }, (16, 12), None);
l.in_point = comp.frame_rate.tick_of(2);
comp.layers.push(l);
if let Some(path) = audio {
let f = effectcraft_media::probe(path).expect("probe wav");
let fid = p.add_item("tone.wav", Label::SeaFoam, None, ItemKind::Footage(f));
let l = build::layer(&mut p, &comp, "tone", LayerSource::Footage { item: fid }, (0, 0), None);
comp.layers.push(l);
}
let cid = p.add_item("Comp 1", Label::Sandstone, None, ItemKind::Comp(comp.into()));
(p, cid)
}
fn run(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, path: &Path) -> effectcraft_export::Report {
let s = RenderSettings { time_span: TimeSpan::LengthOfComp, ..Default::default() };
let path = path.to_string_lossy().to_string();
let job = Job {
project: p,
footage,
expr: None,
accel: None,
comp: cid,
settings: &s,
output: om,
path: &path,
sink: None,
options: Default::default(),
nested_switches: true,
};
export(&job, &mut |_| true).expect("export")
}
/// The job's frames as the PNG sequence writes them (lossless 8-bit), as RGBA.
fn reference_frames(p: &Project, cid: ItemId, footage: &dyn FootageSource, om: &OutputModule, dir: &Path) -> Vec<Vec<u8>> {
let png = OutputModule { channels: om.channels, alpha_mode: om.alpha_mode, ..OutputModule::for_format(OutputFormat::PngSequence) };
let pattern = dir.join("ref_[#####].png").to_string_lossy().to_string();
run(p, cid, footage, &png, Path::new(&pattern));
(0..FRAMES).map(|k| image::open(sequence_path(&pattern, k as i64)).expect("png").to_rgba8().into_raw()).collect()
}
/// The frames as stored in the movie, unpacked to RGBA (rows padded to even bytes).
fn stored_frames(path: &Path, alpha: bool) -> Vec<Vec<u8>> {
let bytes = std::fs::read(path).expect("read movie");
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
assert!(mov.is_quicktime);
let t = mov.tracks.iter().find(|t| t.kind == TrackKind::Video).expect("video track");
let e = &t.entries[0];
assert_eq!(&e.format.0, b"raw ");
let v = e.video.as_ref().expect("visual entry");
assert_eq!((v.width, v.height, v.depth), (W as u16, H as u16, if alpha { 32 } else { 24 }));
assert_eq!(t.samples.len() as u64, FRAMES);
let bpp = if alpha { 4 } else { 3 };
let row = (W as usize * bpp).next_multiple_of(2);
t.samples
.iter()
.map(|s| {
assert_eq!(s.size as usize, row * H as usize);
let data = &bytes[s.offset as usize..][..s.size as usize];
let mut rgba = Vec::new();
for r in data.chunks_exact(row) {
for p in r[..W as usize * bpp].chunks_exact(bpp) {
rgba.extend_from_slice(&if alpha { [p[1], p[2], p[3], p[0]] } else { [p[0], p[1], p[2], 255] });
}
}
rgba
})
.collect()
}
/// All frames decoded by ffmpeg (external oracle) as RGBA, or `None` without ffmpeg.
fn ffmpeg_frames(path: &Path) -> Option<Vec<Vec<u8>>> {
let out =
Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-i"]).arg(path).args(["-f", "rawvideo", "-pix_fmt", "rgba", "-"]).output().ok()?;
assert!(out.status.success(), "ffmpeg failed to decode {}: {}", path.display(), String::from_utf8_lossy(&out.stderr));
Some(out.stdout.as_chunks::<{ (W * H * 4) as usize }>().0.iter().map(|f| f.to_vec()).collect())
}
fn ffprobe_streams(path: &Path) -> Option<String> {
let out = Command::new("ffprobe")
.args([
"-v",
"error",
"-show_entries",
"stream=codec_type,codec_name,codec_tag_string,pix_fmt,width,height,sample_rate,channels",
"-of",
"compact=p=0:nk=1",
])
.arg(path)
.output()
.ok()?;
assert!(out.status.success(), "ffprobe rejected {}", path.display());
Some(String::from_utf8_lossy(&out.stdout).to_string())
}
fn check(name: &str, channels: Channels) {
let d = out_dir(name);
let (p, cid) = project(None);
let om = OutputModule { channels, ..OutputModule::for_format(OutputFormat::Uncompressed) };
let path = d.join("comp.mov");
let r = run(&p, cid, &effectcraft_render::NoFootage, &om, &path);
assert_eq!((r.width, r.height, r.frames, r.audio), (W, H, FRAMES, false));
let mut want_rgba = reference_frames(&p, cid, &effectcraft_render::NoFootage, &om, &d);
let alpha = channels == Channels::Rgba;
if !alpha {
// The PNG is RGB (opaque); `to_rgba8` already filled alpha with 255.
assert!(want_rgba.iter().all(|f| f.as_chunks::<4>().0.iter().all(|p| p[3] == 255)));
} else {
let a: Vec<u8> = want_rgba[3].as_chunks::<4>().0.iter().map(|p| p[3]).collect();
assert!(a.contains(&0) && a.contains(&255) && a.iter().any(|v| *v > 0 && *v < 255), "partial alpha at the solid's edges");
}
assert_eq!(stored_frames(&path, alpha), want_rgba, "stored frames are the rendered frames");
if let Some(got) = ffmpeg_frames(&path) {
assert_eq!(got.len() as u64, FRAMES);
want_rgba.truncate(got.len());
assert!(got == want_rgba, "ffmpeg decodes the rendered frames exactly");
let s = ffprobe_streams(&path).unwrap_or_default();
let fmt = if alpha { "argb" } else { "rgb24" };
assert_eq!(s.trim(), format!("rawvideo|video|raw |{W}|{H}|{fmt}"), "{s}");
} else {
eprintln!("ffmpeg not found: skipping oracle decode");
}
}
#[test]
fn uncompressed_rgb_odd_width() {
check("uncompressed-rgb", Channels::Rgb);
}
#[test]
fn uncompressed_rgba_straight() {
check("uncompressed-rgba", Channels::Rgba);
}
#[test]
fn uncompressed_with_pcm_audio() {
let d = out_dir("uncompressed-audio");
let wav = d.join("tone.wav");
write_tone(&wav, 1.0);
let (p, cid) = project(Some(&wav));
let pool = MediaPool::new();
let om = OutputModule { audio: AudioOutput::Auto, audio_format: AudioFormat::S24, ..OutputModule::for_format(OutputFormat::Uncompressed) };
let path = d.join("comp.mov");
assert!(run(&p, cid, &pool, &om, &path).audio);
let bytes = std::fs::read(&path).expect("read");
let mov = filmcraft_isobmff::open(bytes.as_slice()).expect("demux");
let a = mov.tracks.iter().find(|t| t.kind == TrackKind::Audio).expect("audio track");
assert_eq!(a.timescale, 48_000);
// 0.6 s of audio at 48 kHz.
let frames: u64 = a.samples.iter().map(|s| s.duration as u64).sum();
assert!((frames as i64 - 28_800).abs() <= 1, "{frames} audio frames");
if let Some(s) = ffprobe_streams(&path) {
assert!(s.lines().any(|l| l == "pcm_s24le|audio|lpcm|48000|2"), "{s}");
let out = Command::new("ffmpeg").args(["-hide_banner", "-loglevel", "error", "-i"]).arg(&path).args(["-f", "f32le", "-"]).output().expect("ffmpeg");
let pcm: Vec<f32> = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect();
let rms = |v: Vec<f32>| (v.iter().map(|x| (*x as f64).powi(2)).sum::<f64>() / v.len().max(1) as f64).sqrt();
let left = rms(pcm.iter().step_by(2).copied().collect());
// A sine of amplitude 0.5 → RMS ≈ 0.354.
assert!((left - 0.3536).abs() < 0.01, "left RMS {left}");
}
}

View File

@ -522,14 +522,18 @@ pub enum OutputFormat {
/// AV1 video (+ AAC audio) in MP4 (`av01`). WebM with AV1: [`OutputFormat::WebM`] with
/// [`WebmVideoCodec::Av1`].
Av1,
/// Uncompressed 8-bit RGB / ARGB (`raw `) in QuickTime (+ PCM audio), for handing to an
/// external encoder.
Uncompressed,
}
impl OutputFormat {
pub const ALL: [OutputFormat; 12] = [
pub const ALL: [OutputFormat; 13] = [
OutputFormat::H264,
OutputFormat::Hevc,
OutputFormat::Av1,
OutputFormat::ProRes,
OutputFormat::Uncompressed,
OutputFormat::WebM,
OutputFormat::PngSequence,
OutputFormat::JpegSequence,
@ -554,12 +558,13 @@ impl OutputFormat {
OutputFormat::Aiff => "AIFF",
OutputFormat::Hevc => "HEVC (H.265)",
OutputFormat::Av1 => "AV1",
OutputFormat::Uncompressed => "QuickTime (Uncompressed)",
}
}
pub fn extension(self) -> &'static str {
match self {
OutputFormat::H264 => "mp4",
OutputFormat::ProRes => "mov",
OutputFormat::ProRes | OutputFormat::Uncompressed => "mov",
OutputFormat::PngSequence => "png",
OutputFormat::JpegSequence => "jpg",
OutputFormat::TiffSequence => "tif",
@ -575,7 +580,7 @@ impl OutputFormat {
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
}
pub fn is_movie(self) -> bool {
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)
}
/// Formats whose video codec options live in [`OutputModule::codec`] (HEVC, AV1).
pub fn has_codec_options(self) -> bool {
@ -597,8 +602,8 @@ impl OutputFormat {
let even = |v: u32| if v < 2 { 2 } else { v & !1 };
if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) }
}
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`,
/// `exr`, `gif`, or a label.
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `uncompressed`, `png`, `jpeg`/`jpg`,
/// `tiff`/`tif`, `exr`, `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() {
@ -606,6 +611,7 @@ impl OutputFormat {
"hevc" | "h265" | "hevch265" | "hvc1" | "x265" => OutputFormat::Hevc,
"av1" | "av01" | "mp4av1" => OutputFormat::Av1,
"prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes,
"uncompressed" | "quicktimeuncompressed" | "rawvideo" => OutputFormat::Uncompressed,
"png" | "pngsequence" => OutputFormat::PngSequence,
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
"tif" | "tiff" | "tiffsequence" => OutputFormat::TiffSequence,
@ -1637,6 +1643,25 @@ mod tests {
assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0));
}
#[test]
fn uncompressed_quicktime_format() {
let f = OutputFormat::from_name("uncompressed").unwrap();
assert_eq!(f, OutputFormat::Uncompressed);
assert_eq!(OutputFormat::from_name(&format!("{f:?}")), Some(f));
assert!(OutputFormat::ALL.contains(&f));
assert!(f.is_movie() && f.supports_alpha() && f.supports_audio());
assert_eq!(f.coded_size(3, 5), (3, 5));
// `.mov` still guesses ProRes; switching keeps the name.
assert_eq!(OutputFormat::from_path("a.mov"), Some(OutputFormat::ProRes));
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.mov", 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));

View File

@ -245,7 +245,7 @@ fn output_menu(it: &RenderQueueItem, available: &[OutputFormat], templates: &Ren
}
v.push((mark(o.audio_channels == 1, "Audio Channels: Mono"), json!({"audioChannels": "mono"})));
v.push((mark(o.audio_channels != 1, "Audio Channels: Stereo"), json!({"audioChannels": "stereo"})));
if matches!(o.format, OutputFormat::ProRes | OutputFormat::Wav | OutputFormat::Aiff) {
if matches!(o.format, OutputFormat::ProRes | OutputFormat::Uncompressed | OutputFormat::Wav | OutputFormat::Aiff) {
for f in [AudioFormat::S16, AudioFormat::S24, AudioFormat::F32] {
v.push((mark(o.audio_format == f, &tr_args("Sample Format: {}", &[&(f.label())])), json!({"audioFormat": f.label()})));
}

View File

@ -96,11 +96,12 @@ 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`, 12)
## Render queue outputs (`OutputFormat::ALL`, 13)
H.264 MP4, HEVC MP4, AV1 MP4, ProRes MOV, WebM (VP9 or AV1 + Opus), PNG / JPEG / TIFF / OpenEXR
sequences, animated GIF, WAV, AIFF. Missing against After Effects + Media Encoder: uncompressed
or lossless single-file video (#372), DPX / Targa / Photoshop sequences, MXF, DNxHR, hardware
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.
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
encoders ([hardware-parity.md](hardware-parity.md)), and rendering safely over an existing file
(#325).

View File

@ -78,7 +78,7 @@ are grouped by area; the issue tracker has the rest.
| 19 | **Plug-in ecosystem** | No After Effects SDK hosting (#600, #327 OpenFX, #334, #376 plug-in manager); our WebAssembly API has no third-party plug-ins | Many pro projects depend on Trapcode, Element, Saber… | 40–80 h for API growth; SDK hosting needs an owner decision | G8 · [scripting-parity.md](scripting-parity.md) |
| 20 | **Multi-monitor and video output** | #486 multi-screen, floating panels on other screens, Mercury Transmit to video hardware (we have a second preview window only) | Two-screen setups are standard for professionals | 10–20 h | G2 · [hardware-parity.md](hardware-parity.md) |
| 21 | **Small 26.x features** | Proportional Scrubbing (26.2), copy frame to clipboard and paste SVG/AI as shapes (26.3), variable-font filter (26.3), percentage guides and effect colour labels (26.5), the rebuilt Effect Controls (26.5) | Users of the current version expect them | 15–25 h | — · [ui-parity.md](ui-parity.md) |
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; #372 uncompressed single-file output; #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 22 | **Encoder efficiency** | HEVC / AV1 have no B-frames, multi-reference or SAO / CDEF; Opus no FEC / DTX; single-file intermediates are 8-bit uncompressed QuickTime only (#372: no 16-bpc, AVI or lossless-compressed codec); #325 render over existing file | Larger files than After Effects + Media Encoder | 20–40 h | G6 · [file-format-parity.md](file-format-parity.md) |
| 23 | **Essential Graphics interchange** | Templates are `.ectemplate`, not `.mogrt`; #338 Scale exposes X and Y; #611 FilmCraft rejects exported `.fcgt` | Premiere editors can't use our templates | 15–30 h | — · [file-format-parity.md](file-format-parity.md) |
| 24 | **Timeline depth requests** | #601 expand precomps in place, #602 reorderable render stack, #604 guide-only effects, #355 bulk keyframes, #425 keyframe confusion, #428 more shortcuts | Power-user speed | 15–30 h | G2 · [ui-parity.md](ui-parity.md) |
| 25 | **Accessibility** | AccessKit is enabled in eframe and tests read focus labels, but no screen-reader pass, keyboard navigation audit or contrast check | Excludes users; After Effects supports screen readers poorly too | 15–30 h | G9 · [ui-parity.md](ui-parity.md) |