#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:
parent
9a7d3e7699
commit
575642a5ed
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
||||
|
|
|
|||
|
|
@ -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)?,
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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()})));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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) |
|
||||
|
|
|
|||
Loading…
Reference in New Issue