effectcraft/crates/export/src/lib.rs

407 lines
18 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

//! Render Queue jobs: render a composition's time span and write it through an output module.
//!
//! | Format | Container / files | Video | Alpha | Audio |
//! |---|---|---|---|---|
//! | 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 |
//! | 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 | — |
//! | WebM | WebM (Matroska) | VP9 profile 0 (`effectcraft-vp9enc`: key + inter frames, a key frame every 2 s, loop filter), 8-bit 4:2:0; or AV1 (`effectcraft-av1enc`) | VP9 alpha (BlockAdditional) | Opus (`effectcraft-opusenc`: SILK / hybrid / CELT by bitrate and application), 48 kHz |
//! | HEVC | MP4 (`hvc1`) | `effectcraft-hevcenc` Main / Main 10 4:2:0, I + P slices, bitrate or constant quality, level, key-frame interval | no | AAC-LC (FilmCraft) |
//! | AV1 | MP4 (`av01`) | `effectcraft-av1enc` Main 8/10-bit 4:2:0, key + inter frames | no | AAC-LC (FilmCraft) |
//! | WAV / AIFF | RIFF WAVE / AIFF | — | — | PCM 16/24-bit (WAV also 32-bit float), mono/stereo (audio only) |
//!
//! Every frame goes through [`pipeline`]: the Render Settings overrides (Effects, Solo Switches,
//! Guide Layers, Frame Blending, Motion Blur, Color Depth), field rendering (two fields half a
//! frame apart woven line by line, with optional 3:2 pulldown from film frames at 4/5 of the
//! rate), then the Output Module's Crop (edges or the region of interest) and Resize. Channels
//! may be RGB, RGB + Alpha (straight or premultiplied) or the alpha matte alone. With Use Storage
//! Overflow and a [`StorageQuota`] hook, files that don't fit go to the overflow folders. A
//! render log (Errors Only / Plus Settings / Plus Per Frame Info) is written next to the output.
//!
//! Frames are rendered in parallel batches (rayon; one batch ≈ one frame per core) and handed to
//! the encoder in order. Progress is reported after each batch through a callback that can cancel
//! the job by returning `false`. Codecs and the MP4/MOV muxer are FilmCraft's pure-Rust crates
//! (plan ADR 0001); nothing outside this crate touches them.
//!
//! H.264 requires even dimensions: odd output sizes are cropped by one pixel (padded when the
//! output is a single pixel wide/high).
#![deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable)]
mod encode;
mod hevc_av1;
mod out;
mod pipeline;
mod webm;
mod webm_av1;
use web_time::Instant;
use effectcraft_project::render_queue::{AudioOutput, Channels, OutputFormat, OutputModule, RenderLog, RenderQuality, RenderSettings, sequence_path};
use effectcraft_project::{Comp, ItemId, Project};
use effectcraft_raster::Image;
use effectcraft_render::{ExprHost, FootageSource};
pub use effectcraft_project::render_queue;
pub use effectcraft_project::render_queue::StorageQuota;
pub use out::Sink;
pub(crate) use pipeline::Cx;
#[derive(Debug, thiserror::Error)]
pub enum ExportError {
#[error("composition not found")]
NoComp,
#[error("cancelled")]
Cancelled,
#[error("i/o: {0}")]
Io(String),
#[error("encode: {0}")]
Encode(String),
#[error("{0}")]
Unsupported(String),
}
pub type Result<T> = std::result::Result<T, ExportError>;
fn io(e: impl std::fmt::Display) -> ExportError {
ExportError::Io(e.to_string())
}
/// One export: a comp, its Render Settings and Output Module, and the resolved output path.
pub struct Job<'a> {
pub project: &'a Project,
pub footage: &'a dyn FootageSource,
pub expr: Option<&'a dyn ExprHost>,
/// GPU compositor: used when the project's renderer is Mercury GPU Acceleration.
pub accel: Option<&'a dyn effectcraft_render::Accelerator>,
pub comp: ItemId,
pub settings: &'a RenderSettings,
pub output: &'a OutputModule,
/// Output file. For image sequences a run of `#` (or `[#####]`) becomes the frame number; a
/// path without one gets `_NNNNN` before the extension.
pub path: &'a str,
/// Hand finished files to this instead of writing them to disk (web downloads, tests).
pub sink: Option<&'a Sink>,
/// Settings ▸ General ▸ Switches Affect Nested Comps (see `RenderOpts::nested_switches`).
pub nested_switches: bool,
/// Render log, storage overflow.
pub options: JobOptions<'a>,
}
/// Job options beyond the Render Settings and Output Module.
#[derive(Default)]
pub struct JobOptions<'a> {
/// The render log written next to the output.
pub log: RenderLog,
/// What the log calls the job (e.g. "#2 Main Comp").
pub label: String,
/// Free-space hook for Use Storage Overflow (`None`: never full).
pub storage: Option<&'a dyn StorageQuota>,
/// Overflow folders, in order.
pub overflow: Vec<String>,
}
/// Progress after a batch of frames.
#[derive(Clone, Copy, Debug, Default)]
pub struct Progress {
pub done: u64,
pub total: u64,
pub elapsed: f64,
}
impl Progress {
pub fn fraction(&self) -> f64 {
if self.total == 0 { 0.0 } else { self.done as f64 / self.total as f64 }
}
/// Estimated seconds remaining.
pub fn remaining(&self) -> Option<f64> {
(self.done > 0).then(|| self.elapsed / self.done as f64 * self.total.saturating_sub(self.done) as f64)
}
}
/// What an export produced.
#[derive(Clone, Debug, PartialEq)]
pub struct Report {
/// The movie file, or the first file of a sequence.
pub path: String,
pub frames: u64,
pub width: u32,
pub height: u32,
pub seconds: f64,
pub bytes: u64,
pub audio: bool,
/// The render log, when one was written.
pub log: Option<String>,
/// Files that went to an overflow folder.
pub overflow: Vec<String>,
}
/// Formats this build can write (all of them; kept as the hook for optional encoders).
pub fn available_formats() -> Vec<OutputFormat> {
OutputFormat::ALL.to_vec()
}
/// The size of the written frames for a job (crop, resize; H.264 rounds to even).
pub fn output_size(comp: &Comp, settings: &RenderSettings, output: &OutputModule) -> (u32, u32) {
output.output_size(comp, settings)
}
/// Whether the job writes an audio track.
pub fn wants_audio(job: &Job) -> bool {
job.output.format.supports_audio()
&& match job.output.audio {
AudioOutput::On => true,
AudioOutput::Off => false,
AudioOutput::Auto => effectcraft_render::audio::comp_has_audio(job.project, job.comp),
}
}
/// Run the export (blocking). `progress` is called after every batch; returning `false` cancels.
pub fn export(job: &Job, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
effectcraft_render::passes::block_on(export_async(job, progress))
}
/// [`export`] as a future: with an accelerator whose readbacks are deferred (WebGPU in a
/// browser worker) every frame renders in passes, awaiting the device in between
/// ([`effectcraft_render::passes`]); otherwise it never waits and frames render in parallel
/// batches as in [`export`].
pub async fn export_async(job: &Job<'_>, progress: &mut dyn FnMut(&Progress) -> bool) -> Result<Report> {
let t0 = Instant::now();
let cx = Cx::new(job);
let mut r = export_with(&cx, progress, t0).await;
let log = cx.write_log(&r, t0.elapsed().as_secs_f64());
if let Ok(rep) = &mut r {
rep.log = log;
rep.overflow = cx.overflowed.lock().unwrap_or_else(|e| e.into_inner()).clone();
}
r
}
async fn export_with(cx: &Cx<'_>, progress: &mut dyn FnMut(&Progress) -> bool, t0: Instant) -> Result<Report> {
let comp = cx.comp().ok_or(ExportError::NoComp)?;
let total = cx.settings.frame_count(comp);
let mut st = State { t0, total, done: 0, progress };
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::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)?,
OutputFormat::Aiff => webm::audio_file(cx, comp, true, &mut st)?,
f if f.is_sequence() => sequence(cx, comp, w, h, &mut st).await?,
f => return Err(ExportError::Unsupported(f.label().into())),
};
report.seconds = t0.elapsed().as_secs_f64();
report.frames = total;
(report.width, report.height) = (w, h);
Ok(report)
}
pub(crate) struct State<'p> {
t0: Instant,
total: u64,
done: u64,
progress: &'p mut dyn FnMut(&Progress) -> bool,
}
impl State<'_> {
fn snapshot(&self) -> Progress {
Progress { done: self.done, total: self.total, elapsed: self.t0.elapsed().as_secs_f64() }
}
/// Count `n` more frames; `Err(Cancelled)` when the callback says stop.
pub(crate) fn advance(&mut self, n: u64) -> Result<()> {
self.done += n;
let p = self.snapshot();
if (self.progress)(&p) { Ok(()) } else { Err(ExportError::Cancelled) }
}
}
/// Frames per parallel batch.
pub(crate) fn batch_size() -> u64 {
rayon::current_num_threads().clamp(2, 16) as u64
}
/// Crop/pad 8-bit RGBA to `w`×`h` (codec rounding).
pub(crate) fn fit(px: Vec<u8>, iw: u32, ih: u32, w: u32, h: u32) -> Vec<u8> {
if iw == w && ih == h {
return px;
}
let mut out = vec![0u8; (w * h * 4) as usize];
for y in 0..h.min(ih) as usize {
let n = w.min(iw) as usize * 4;
out[y * w as usize * 4..y * w as usize * 4 + n].copy_from_slice(&px[y * iw as usize * 4..y * iw as usize * 4 + n]);
}
out
}
/// The comp frame number of output frame 0 (sequence numbering follows comp frames, like AE).
fn first_frame_number(job: &Job, comp: &Comp) -> i64 {
job.settings.first_frame(comp)
}
async fn sequence(cx: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result<Report> {
let f0 = first_frame_number(cx, comp);
let fmt = cx.output.format;
let channels = match cx.output.channels {
Channels::Rgba if !fmt.supports_alpha() => Channels::Rgb,
c => c,
};
let batch = batch_size();
let mut bytes = 0u64;
let mut i = 0;
while i < st.total {
let end = (i + batch).min(st.total);
let ks: Vec<u64> = (i..end).filter(|k| !(cx.settings.skip_existing && out::exists(cx.sink, &sequence_path(cx.path, f0 + *k as i64)))).collect();
let written: Vec<Result<u64>> = cx
.frames(comp, ks, |k, img| {
let path = sequence_path(cx.path, f0 + k as i64);
// Encode in memory first: the storage hook decides where it fits.
let mut buf = std::io::Cursor::new(Vec::new());
write_still(cx, &mut buf, fmt, &img, comp, channels, w, h, cx.output.quality)?;
let data = buf.into_inner();
let path = cx.place(&path, data.len() as u64);
let mut f = out::create(cx.sink, &path)?;
std::io::Write::write_all(&mut f, &data).map_err(io)?;
f.finish()
})
.await;
for r in written {
bytes += r?;
}
st.advance(end - i)?;
i = end;
}
Ok(Report { path: sequence_path(cx.path, f0), frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
}
#[allow(clippy::too_many_arguments)]
fn write_still<W: std::io::Write + std::io::Seek>(
cx: &Cx,
file: &mut W,
fmt: OutputFormat,
img: &Image,
comp: &Comp,
channels: Channels,
w: u32,
h: u32,
quality: u8,
) -> Result<()> {
use image::{ExtendedColorType, ImageEncoder, ImageFormat};
match fmt {
OutputFormat::ExrSequence => {
// Linear light, premultiplied (the EXR convention); RGB keeps alpha = 1 over the background.
let lin = |v: f32| if v <= 0.04045 { v / 12.92 } else { ((v + 0.055) / 1.055).powf(2.4) };
let mut data = Vec::with_capacity((w * h * 4) as usize);
for y in 0..h {
for x in 0..w {
let p = if x < img.width && y < img.height { img.data[img.idx(x, y)] } else { [0.0; 4] };
let (c, a) = match channels {
Channels::Alpha => {
let a = p[3].clamp(0.0, 1.0);
([lin(a); 3], 1.0)
}
Channels::Rgba => {
let a = p[3].clamp(0.0, 1.0);
let s = |c: f32| if a > 0.0 { lin((c / a).clamp(0.0, 1.0)) * a } else { 0.0 };
([s(p[0]), s(p[1]), s(p[2])], a)
}
Channels::Rgb => {
let k = 1.0 - p[3].clamp(0.0, 1.0);
let c = |i: usize| lin((p[i] + comp.background[i] * k).clamp(0.0, 1.0));
([c(0), c(1), c(2)], 1.0)
}
};
data.extend_from_slice(&[c[0], c[1], c[2], a]);
}
}
let buf = image::Rgba32FImage::from_raw(w, h, data).ok_or_else(|| ExportError::Encode("exr buffer".into()))?;
image::DynamicImage::ImageRgba32F(buf).write_to(file, ImageFormat::OpenExr).map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::JpegSequence => {
let px = cx.pixels(img, comp, if channels == Channels::Alpha { Channels::Alpha } else { Channels::Rgb }, w, h);
let rgb: Vec<u8> = px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect();
image::codecs::jpeg::JpegEncoder::new_with_quality(file, quality.clamp(1, 100))
.write_image(&rgb, w, h, ExtendedColorType::Rgb8)
.map_err(|e| ExportError::Encode(e.to_string()))
}
OutputFormat::PngSequence | OutputFormat::TiffSequence => {
let px = cx.pixels(img, comp, channels, w, h);
let (data, ct) = match channels {
Channels::Rgba => (px, ExtendedColorType::Rgba8),
Channels::Alpha => (px.as_chunks::<4>().0.iter().map(|p| p[0]).collect(), ExtendedColorType::L8),
Channels::Rgb => (px.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(), ExtendedColorType::Rgb8),
};
if fmt == OutputFormat::PngSequence {
image::codecs::png::PngEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
} else {
// (the TIFF encoder needs Seek)
image::codecs::tiff::TiffEncoder::new(file).write_image(&data, w, h, ct).map_err(|e| ExportError::Encode(e.to_string()))
}
}
f => Err(ExportError::Unsupported(format!("{} is not a still format", f.label()))),
}
}
async fn gif_export(job: &Cx<'_>, comp: &Comp, w: u32, h: u32, st: &mut State<'_>) -> Result<Report> {
if w > u16::MAX as u32 || h > u16::MAX as u32 {
return Err(ExportError::Unsupported("GIF frames are limited to 65535 px".into()));
}
let path = job.place(job.path, 1);
let mut file = out::create(job.sink, &path)?;
let mut enc = gif::Encoder::new(&mut file, w as u16, h as u16, &[]).map_err(|e| ExportError::Encode(e.to_string()))?;
enc.set_repeat(if job.output.gif_loop { gif::Repeat::Infinite } else { gif::Repeat::Finite(0) }).map_err(|e| ExportError::Encode(e.to_string()))?;
let rate = job.settings.rate(comp);
// GIF delays are in 1/100 s: carry the rounding error so long animations keep their timing.
let cs_per_frame = 100.0 * rate.den as f64 / rate.num as f64;
let mut clock = 0.0f64;
let mut emitted = 0u64;
let batch = batch_size();
let mut i = 0;
while i < st.total {
let end = (i + batch).min(st.total);
let frames: Vec<gif::Frame<'static>> = job
.frames(comp, (i..end).collect(), |_, img| {
let mut px = job.pixels(&img, comp, job.output.channels, w, h);
gif::Frame::from_rgba_speed(w as u16, h as u16, &mut px, 10)
})
.await;
for mut f in frames {
clock += cs_per_frame;
let d = (clock.round() as u64).saturating_sub(emitted).max(1);
emitted += d;
f.delay = d.min(u16::MAX as u64) as u16;
if job.output.channels == Channels::Rgba {
f.dispose = gif::DisposalMethod::Background;
}
enc.write_frame(&f).map_err(|e| ExportError::Encode(e.to_string()))?;
}
st.advance(end - i)?;
i = end;
}
drop(enc);
let bytes = file.finish()?;
Ok(Report { path, frames: 0, width: w, height: h, seconds: 0.0, bytes, audio: false, log: None, overflow: vec![] })
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fit_crops_and_pads() {
let px: Vec<u8> = (0..3 * 3 * 4).map(|v| v as u8).collect();
let c = fit(px.clone(), 3, 3, 2, 2);
assert_eq!(c.len(), 16);
assert_eq!(&c[0..8], &px[0..8]);
assert_eq!(&c[8..16], &px[12..20]);
let p = fit(px, 3, 3, 4, 4);
assert_eq!(p.len(), 64);
assert_eq!(&p[12..16], &[0, 0, 0, 0]);
assert_eq!(OutputFormat::H264.coded_size(1, 101), (2, 100));
assert_eq!(OutputFormat::Gif.coded_size(1, 101), (1, 101));
}
}