From 575642a5edd56677f12211772065b672c5cf71b5 Mon Sep 17 00:00:00 2001 From: vangel76 Date: Sun, 11 Oct 2026 12:43:33 +0200 Subject: [PATCH] #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 --- crates/engine/src/commands/render_queue.rs | 6 +- crates/engine/src/render_queue.rs | 5 + crates/engine/src/rq_tests.rs | 2 +- crates/export/Cargo.toml | 2 +- crates/export/src/encode.rs | 7 +- crates/export/src/lib.rs | 6 +- crates/export/src/uncompressed.rs | 132 ++++++++++++ crates/export/tests/uncompressed_mov.rs | 222 +++++++++++++++++++++ crates/project/src/render_queue.rs | 35 +++- crates/ui-egui/src/panels/render_queue.rs | 2 +- docs/file-format-parity.md | 9 +- docs/gaps.md | 2 +- 12 files changed, 410 insertions(+), 20 deletions(-) create mode 100644 crates/export/src/uncompressed.rs create mode 100644 crates/export/tests/uncompressed_mov.rs diff --git a/crates/engine/src/commands/render_queue.rs b/crates/engine/src/commands/render_queue.rs index d167df9a..93a56ced 100644 --- a/crates/engine/src/commands/render_queue.rs +++ b/crates/engine/src/commands/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|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 { "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 { "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 ), diff --git a/crates/engine/src/render_queue.rs b/crates/engine/src/render_queue.rs index 666a56cc..aae05a38 100644 --- a/crates/engine/src/render_queue.rs +++ b/crates/engine/src/render_queue.rs @@ -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, diff --git a/crates/engine/src/rq_tests.rs b/crates/engine/src/rq_tests.rs index fe376668..a45299d5 100644 --- a/crates/engine/src/rq_tests.rs +++ b/crates/engine/src/rq_tests.rs @@ -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); } diff --git a/crates/export/Cargo.toml b/crates/export/Cargo.toml index f4814d6c..2ef6f5ad 100644 --- 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) 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 diff --git a/crates/export/src/encode.rs b/crates/export/src/encode.rs index 098a8c11..e4a45561 100644 --- a/crates/export/src/encode.rs +++ b/crates/export/src/encode.rs @@ -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 = 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 { job.pixels(&img, comp, channels, w, h) }; diff --git a/crates/export/src/lib.rs b/crates/export/src/lib.rs index 5d8405c0..eda97995 100644 --- a/crates/export/src/lib.rs +++ b/crates/export/src/lib.rs @@ -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)?, diff --git a/crates/export/src/uncompressed.rs b/crates/export/src/uncompressed.rs new file mode 100644 index 00000000..1bd1d9ba --- /dev/null +++ b/crates/export/src/uncompressed.rs @@ -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 { + 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 { + 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> { + 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> { + 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 = (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()); + } +} diff --git a/crates/export/tests/uncompressed_mov.rs b/crates/export/tests/uncompressed_mov.rs new file mode 100644 index 00000000..1f7a6657 --- /dev/null +++ b/crates/export/tests/uncompressed_mov.rs @@ -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> { + 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> { + 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>> { + 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 { + 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 = 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 = out.stdout.as_chunks::<4>().0.iter().map(|b| f32::from_le_bytes(*b)).collect(); + let rms = |v: Vec| (v.iter().map(|x| (*x as f64).powi(2)).sum::() / 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}"); + } +} diff --git a/crates/project/src/render_queue.rs b/crates/project/src/render_queue.rs index d74e183d..5c13dffc 100644 --- a/crates/project/src/render_queue.rs +++ b/crates/project/src/render_queue.rs @@ -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 { 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::(&json).unwrap().format, f); + } + #[test] fn hevc_av1_codec_options() { assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc)); diff --git a/crates/ui-egui/src/panels/render_queue.rs b/crates/ui-egui/src/panels/render_queue.rs index 502b8410..b66d0d47 100644 --- a/crates/ui-egui/src/panels/render_queue.rs +++ b/crates/ui-egui/src/panels/render_queue.rs @@ -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()}))); } diff --git a/docs/file-format-parity.md b/docs/file-format-parity.md index 66f18b47..592670f5 100644 --- a/docs/file-format-parity.md +++ b/docs/file-format-parity.md @@ -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). diff --git a/docs/gaps.md b/docs/gaps.md index acb22a63..0c567e4b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -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) |