//! Export: render a sequence range and encode it to a file. //! //! Frames are rendered in parallel batches (one frame per core, each frame itself row-parallel), //! then encoded and muxed in order; audio is mixed per batch and interleaved. Progress and cancel //! are shared atomics so the UI (Export mode, header progress) and MCP can observe/cancel jobs. //! //! Video encoders implement [`VideoEncoder`]; codec crates register theirs with //! [`register_encoder`] (H.264, ProRes …). Built in: Motion-JPEG (MOV), PNG / TIFF / BMP / JPEG / //! Targa / DPX / OpenEXR sequences, GIF, WAV and AIFF. //! //! [`ExportSettings`] carries every Export-mode setting (frame size, rate, bitrate encoding, audio //! format, multiplexer, captions, effects, metadata) as serde data; [`presets`] defines the //! built-in presets. #![cfg_attr(not(test), deny(clippy::unwrap_used, clippy::expect_used, clippy::panic, clippy::unimplemented, clippy::todo, clippy::unreachable))] use std::io::Write; mod aac_out; mod audio_out; mod deflate; mod job; mod mxf_out; mod pace; mod pcm; mod pipeline; pub mod presets; mod raster; pub mod settings; pub mod still; pub use audio_out::LoudnessReport; pub use job::{Exporter, Step, stepped}; pub use mxf_out::opatom_audio_paths; pub use pcm::{image_sequence_path, write_aiff, write_wav}; pub use pipeline::limit_rgba8; pub use presets::{ExportPreset, builtin_presets}; pub use settings::*; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; use std::sync::{Arc, Mutex, OnceLock, RwLock}; use filmcraft_isobmff::SampleEntry; use filmcraft_project::{ItemId, Project}; use filmcraft_render::SourceProvider; use filmcraft_time::{FrameRate, Tick, TimeRange}; use rayon::prelude::*; use serde::{Deserialize, Serialize}; #[derive(Debug, thiserror::Error)] pub enum ExportError { #[error("no such sequence")] NoSequence, #[error("unsupported: {0}")] Unsupported(String), #[error("I/O: {0}")] Io(String), #[error("encode: {0}")] Encode(String), #[error("cancelled")] Cancelled, } pub type Result = std::result::Result; #[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Format { /// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.264 video + AAC audio. #[default] #[serde(rename = "h264", alias = "H264")] H264, /// MPEG-4 (or QuickTime, see [`Multiplexer`]), H.265 / HEVC Main (8-bit) video + AAC audio. There is no /// built-in encoder: a platform hardware encoder registers one ([`register_encoder`]) and says so /// with [`register_format_probe`]; [`available`] is false without it. HDR sequences export as HDR /// (Main 10, PQ / HLG) only where an encoder also says so with [`register_hdr_probe`] /// ([`hdr_available`]); elsewhere they are tone-mapped to SDR. #[serde(rename = "hevc", alias = "Hevc")] Hevc, /// MPEG-4, AV1 Main (8-bit) video + AAC audio. Like H.265 it has no built-in encoder: offered /// where a hardware encoder registers one. #[serde(rename = "av1", alias = "Av1")] Av1, /// QuickTime, Apple ProRes 422 (HQ unless the settings pick another flavour) + PCM. #[serde(rename = "prores", alias = "ProRes")] ProRes, /// QuickTime, Avid DNxHR (HQ unless the settings pick another profile) + PCM. #[serde(rename = "dnxhr", alias = "DnxHr")] DnxHr, /// QuickTime, APV (Advanced Professional Video, RFC 9924; 422-10 unless the settings pick /// another profile) + PCM. #[serde(rename = "apv", alias = "Apv")] Apv, /// QuickTime, Motion-JPEG + PCM. #[serde(rename = "mjpeg", alias = "Mjpeg")] Mjpeg, /// Numbered PNG stills ([`image_sequence_path`]). #[serde(rename = "png", alias = "PngSequence")] PngSequence, /// Numbered TIFF stills. #[serde(rename = "tiff", alias = "TiffSequence")] TiffSequence, /// Numbered BMP stills. #[serde(rename = "bmp", alias = "BmpSequence")] BmpSequence, /// Numbered JPEG stills (baseline, [`ExportSettings::quality`]). #[serde(rename = "jpg", alias = "JpegSequence")] JpegSequence, /// Numbered Targa stills (uncompressed, 24-bit, or 32-bit with [`ExportSettings::alpha`]). #[serde(rename = "tga", alias = "TgaSequence")] TgaSequence, /// Numbered DPX stills (SMPTE ST 268, 10-bit RGB, BT.709). #[serde(rename = "dpx", alias = "DpxSequence")] DpxSequence, /// Numbered OpenEXR stills (half-float scene-linear RGB, RGBA with [`ExportSettings::alpha`]). #[serde(rename = "exr", alias = "ExrSequence")] ExrSequence, #[serde(rename = "gif", alias = "Gif")] Gif, #[serde(rename = "wav", alias = "Wav")] Wav, #[serde(rename = "aiff", alias = "Aiff")] Aiff, /// AAC Audio: the mix alone, AAC-LC in an MPEG-4 audio file (`.m4a`), or a raw ADTS stream when /// the path ends in `.aac`. #[serde(rename = "aac", alias = "Aac")] Aac, /// FLAC (RFC 9639): lossless compressed audio. #[serde(rename = "flac", alias = "Flac")] Flac, /// MXF OP1a (SMPTE ST 378): frame-wrapped DNxHR, ProRes or H.264 ([`MxfVideoCodec`]) + PCM. #[serde(rename = "mxf-op1a", alias = "MxfOp1a")] MxfOp1a, /// MXF OP-Atom (SMPTE ST 390, Avid style): the picture in one file, one mono PCM file per /// audio channel ([`opatom_audio_paths`]). #[serde(rename = "mxf-opatom", alias = "MxfOpAtom")] MxfOpAtom, } impl Format { pub fn from_name(s: &str) -> Option { Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() { "h264" | "mp4" | "avc" | "m4v" => Format::H264, "hevc" | "h265" | "hvc1" | "hev1" | "x265" => Format::Hevc, "av1" | "av01" => Format::Av1, "prores" | "mov" | "appleprores" => Format::ProRes, "dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr, "apv" | "apv1" => Format::Apv, "mxf" | "mxfop1a" | "op1a" => Format::MxfOp1a, "mxfopatom" | "opatom" | "mxfatom" | "avidmxf" => Format::MxfOpAtom, "mjpeg" | "motionjpeg" | "jpeg" => Format::Mjpeg, "png" | "pngsequence" => Format::PngSequence, "tif" | "tiff" | "tiffsequence" => Format::TiffSequence, "bmp" | "bmpsequence" => Format::BmpSequence, // "jpeg" alone stays Motion-JPEG (QuickTime), as it always was "jpg" | "jpgsequence" | "jpegsequence" => Format::JpegSequence, "tga" | "targa" | "tgasequence" | "targasequence" => Format::TgaSequence, "dpx" | "dpxsequence" => Format::DpxSequence, "exr" | "openexr" | "exrsequence" | "openexrsequence" => Format::ExrSequence, "gif" | "animatedgif" => Format::Gif, "wav" | "waveform" | "waveformaudio" => Format::Wav, "aif" | "aiff" | "aifc" => Format::Aiff, "aac" | "aacaudio" | "m4a" | "adts" => Format::Aac, "flac" | "fla" => Format::Flac, _ => return None, }) } /// Stable id, as accepted by [`Format::from_name`] and used in serialized settings. pub fn id(self) -> &'static str { match self { Format::H264 => "h264", Format::Hevc => "hevc", Format::Av1 => "av1", Format::ProRes => "prores", Format::DnxHr => "dnxhr", Format::Apv => "apv", Format::Mjpeg => "mjpeg", Format::PngSequence => "png", Format::TiffSequence => "tiff", Format::BmpSequence => "bmp", Format::JpegSequence => "jpg", Format::TgaSequence => "tga", Format::DpxSequence => "dpx", Format::ExrSequence => "exr", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aiff", Format::Aac => "aac", Format::Flac => "flac", Format::MxfOp1a => "mxf-op1a", Format::MxfOpAtom => "mxf-opatom", } } pub fn extension(self) -> &'static str { match self { Format::H264 | Format::Hevc | Format::Av1 => "mp4", Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov", Format::PngSequence => "png", Format::TiffSequence => "tif", Format::BmpSequence => "bmp", Format::JpegSequence => "jpg", Format::TgaSequence => "tga", Format::DpxSequence => "dpx", Format::ExrSequence => "exr", Format::Gif => "gif", Format::Wav => "wav", Format::Aiff => "aif", Format::Aac => "m4a", Format::Flac => "flac", Format::MxfOp1a | Format::MxfOpAtom => "mxf", } } pub fn label(self) -> &'static str { match self { Format::H264 => "H.264", Format::Hevc => "H.265 (HEVC)", Format::Av1 => "AV1", Format::ProRes => "Apple ProRes", Format::DnxHr => "Avid DNxHR", Format::Apv => "APV", Format::Mjpeg => "QuickTime (Motion JPEG)", Format::PngSequence => "PNG", Format::TiffSequence => "TIFF", Format::BmpSequence => "BMP", Format::JpegSequence => "JPEG", Format::TgaSequence => "Targa", Format::DpxSequence => "DPX", Format::ExrSequence => "OpenEXR", Format::Gif => "Animated GIF", Format::Wav => "Waveform Audio", Format::Aiff => "AIFF", Format::Aac => "AAC Audio", Format::Flac => "FLAC", Format::MxfOp1a => "MXF OP1a", Format::MxfOpAtom => "MXF OP-Atom", } } /// Numbered stills, one per frame ([`image_sequence_path`]). pub fn is_image_sequence(self) -> bool { matches!( self, Format::PngSequence | Format::TiffSequence | Format::BmpSequence | Format::JpegSequence | Format::TgaSequence | Format::DpxSequence | Format::ExrSequence ) } /// An MXF container format. pub fn is_mxf(self) -> bool { matches!(self, Format::MxfOp1a | Format::MxfOpAtom) } /// Whether the crate carries an encoder for the format; the others need one registered at runtime /// ([`register_format_probe`]). pub fn has_builtin_encoder(self) -> bool { !matches!(self, Format::Hevc | Format::Av1) } /// Sound without picture: Waveform Audio, AIFF, AAC Audio and FLAC. pub fn is_audio_only(self) -> bool { matches!(self, Format::Wav | Format::Aiff | Format::Aac | Format::Flac) } /// H.264 or H.265: MPEG-4 (or QuickTime) with AAC audio, set up with the same bitrate controls. pub fn is_h26x(self) -> bool { matches!(self, Format::H264 | Format::Hevc) } /// A bitrate-controlled MPEG-4 video format with AAC audio: H.264, H.265 or AV1 (MPEG-4 only). pub fn is_mp4_video(self) -> bool { matches!(self, Format::H264 | Format::Hevc | Format::Av1) } /// Whether the file is MPEG-4 rather than QuickTime for `mux`. pub fn is_mp4_with(self, mux: Multiplexer) -> bool { self == Format::Av1 || (self.is_h26x() && mux == Multiplexer::Mp4) } pub const ALL: [Format; 21] = [ Format::H264, Format::Hevc, Format::Av1, Format::ProRes, Format::DnxHr, Format::Apv, Format::Mjpeg, Format::PngSequence, Format::TiffSequence, Format::BmpSequence, Format::JpegSequence, Format::TgaSequence, Format::DpxSequence, Format::ExrSequence, Format::Gif, Format::Wav, Format::Aiff, Format::Aac, Format::Flac, Format::MxfOp1a, Format::MxfOpAtom, ]; } /// Two-pass state of an H.264 export (set by the exporter, not by callers). #[derive(Clone, Debug, Default)] pub enum H264Pass { #[default] Single, First, Second(filmcraft_h264enc::PassStats), } /// Every Export-mode setting. Serialized in camelCase; every field is optional on input. #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(default, rename_all = "camelCase")] pub struct ExportSettings { pub format: Format, pub path: String, /// Timeline range (default: In/Out if set, else the whole sequence). pub range: Option, /// Output scale (1.0 = sequence frame size). pub scale: f32, pub include_audio: bool, /// Quality 0–100 for lossy codecs (Motion-JPEG, JPEG sequences). pub quality: u8, /// Target video bitrate (kbps) for bitrate-driven encoders. pub bitrate_kbps: u32, /// Burn the visible caption tracks into the picture (Export ▸ Captions ▸ Burn Captions Into /// Video). pub burn_captions: bool, /// This export is one part of a larger job (render previews): the caller owns /// `progress.total`, `finished` and the final status; this call only adds to `done`. #[serde(default)] pub part_of_batch: bool, /// ProRes flavour: `proxy`, `lt`, `standard`, `hq`, `4444` or `4444xq` (empty = HQ). #[serde(default)] pub prores_profile: String, /// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ). #[serde(default)] pub dnx_profile: String, /// APV profile: `422-10`, `422-12`, `444-10` or `444-12` (empty = `422-10`). #[serde(default)] pub apv_profile: String, /// Video codec of the MXF formats (DNxHR unless set; the ProRes / DNxHR profile fields apply). #[serde(default)] pub mxf_video_codec: MxfVideoCodec, /// Encode display-referred SDR (Rec. 709, tone mapped) even when the sequence works in /// Rec. 2100 PQ/HLG. Otherwise H.264 and ProRes exports of an HDR sequence are encoded in the /// sequence's HDR space and signal it (VUI / `colr` / `mdcv` / `clli` / SEI). #[serde(default)] pub sdr: bool, /// Keep the alpha channel in PNG, TIFF, Targa and OpenEXR sequences and ProRes 4444 / 4444 XQ /// QuickTime movies (straight alpha, Premiere's "Include Alpha Channel"; OpenEXR stores it /// premultiplied, as the format defines). Off, every frame is flattened over black (#160). /// Other formats ignore it. #[serde(default)] pub alpha: bool, /// Compression of OpenEXR sequences (uncompressed unless set); other formats ignore it. #[serde(default)] pub exr_compression: ExrCompression, /// Output frame size (None = Match Source: the sequence size times `scale`). pub frame_size: Option<(u32, u32)>, /// Output frame rate (None = Match Source). pub frame_rate: Option, /// How the picture fills a frame of another aspect ratio. pub scaling: Scaling, /// Pixel aspect ratio (None = square pixels). Signalled in the H.264 VUI. pub pixel_aspect: Option<(u32, u32)>, pub field_order: FieldOrder, pub h264_profile: H264Profile, /// H.264 level × 10 (41 = 4.1); None = the lowest level that fits. A level below what the /// stream needs is raised. pub h264_level: Option, pub bitrate_mode: BitrateMode, /// Constant rate factor of [`BitrateMode::Crf`], 0 (best) to 51 (smallest); 23 by default, /// 18 is visually near-lossless. #[serde(default = "default_crf")] pub crf: f32, /// May H.264 be encoded by the system's hardware encoder (VideoToolbox on macOS, NVENC on /// Windows)? Off unless asked for: hardware output depends on the machine, so it is not /// byte-reproducible like the built-in encoder's (`determinism_tests`). #[serde(default)] pub hardware_encoding: HardwareEncoding, /// Composite the exported frames on the GPU instead of the CPU reference renderer (Export ▸ /// GPU rendering: Auto / Off, Off by default). Off = the CPU reference renderer exactly. #[serde(default)] pub gpu_rendering: GpuRendering, /// VBR maximum bitrate (None = 1.5 × target). pub max_bitrate_kbps: Option, /// Adaptive bitrate (the Match Source presets): bits per pixel per frame; replaces /// `bitrate_kbps` with `width × height × fps × bpp / 1000`. pub adaptive_bitrate: Option, /// Frames between keyframes (None = 2 seconds). pub keyframe_distance: Option, /// Render at Maximum Depth. FilmCraft always composites in 32-bit float, so this changes /// nothing; it is kept so presets round-trip. pub render_at_max_depth: bool, /// Use Maximum Render Quality: render at full size and scale with an area filter. pub max_render_quality: bool, pub audio: AudioSettings, pub multiplexer: Multiplexer, /// Captions ▸ Create Sidecar File: `srt` or `vtt` next to the output (written by the engine). pub caption_sidecar: Option, pub effects: ExportEffects, pub metadata: ExportMetadata, /// Two-pass state (set by the exporter). #[serde(skip)] pub h264_pass: H264Pass, /// Colour signalling chosen by [`export`] for the encoders (not set by callers). #[serde(skip)] pub signal: ColorSignal, /// Encode into memory and hand each finished file (path, bytes) to this sink instead of /// writing `path` (hosts without a filesystem: the web app offers the file as a download). #[serde(skip)] pub sink: Option, } /// Receives in-memory export output: `(path, bytes)` per finished file. #[derive(Clone)] pub struct OutputSink(pub Arc) -> std::io::Result<()> + Send + Sync>); impl std::fmt::Debug for OutputSink { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("OutputSink") } } /// How an audio-only export codes its samples. enum AudioWriter { Pcm(pcm::PcmContainer), Flac(Box), } /// An export's output file: on disk, or in memory for an [`OutputSink`]. enum Out { File(std::io::BufWriter), Mem(std::io::Cursor>), } impl Out { fn create(settings: &ExportSettings) -> Result { Out::create_path(settings, &settings.path) } /// An output file at `path` (OP-Atom exports write several). fn create_path(settings: &ExportSettings, path: &str) -> Result { if settings.sink.is_some() { return Ok(Out::Mem(std::io::Cursor::new(Vec::new()))); } let f = std::fs::File::create(path).map_err(|e| ExportError::Io(format!("{path}: {e}")))?; Ok(Out::File(std::io::BufWriter::new(f))) } /// Flush (and hand in-memory output to the sink); returns the file size. fn finish(self, settings: &ExportSettings) -> Result { self.finish_path(settings, &settings.path) } fn finish_path(self, settings: &ExportSettings, path: &str) -> Result { match self { Out::File(mut w) => { w.flush().map_err(|e| ExportError::Io(e.to_string()))?; Ok(std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)) } Out::Mem(c) => write_output(settings, path, c.into_inner()), } } } /// Write one finished output file (to the sink when there is one); returns its size. fn write_output(settings: &ExportSettings, path: &str, data: Vec) -> Result { let n = data.len() as u64; match &settings.sink { Some(sink) => (sink.0)(path, data), None => std::fs::write(path, &data), } .map_err(|e| ExportError::Io(e.to_string()))?; Ok(n) } impl Write for Out { fn write(&mut self, buf: &[u8]) -> std::io::Result { match self { Out::File(w) => w.write(buf), Out::Mem(w) => w.write(buf), } } fn flush(&mut self) -> std::io::Result<()> { match self { Out::File(w) => w.flush(), Out::Mem(w) => w.flush(), } } } impl std::io::Seek for Out { fn seek(&mut self, pos: std::io::SeekFrom) -> std::io::Result { match self { Out::File(w) => w.seek(pos), Out::Mem(w) => w.seek(pos), } } } /// Colour description of the encoded stream (ITU-T H.273 code points). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct ColorSignal { pub primaries: u8, pub transfer: u8, pub matrix: u8, } impl Default for ColorSignal { fn default() -> Self { ColorSignal { primaries: 1, transfer: 1, matrix: 1 } } } impl ColorSignal { pub const PQ: ColorSignal = ColorSignal { primaries: 9, transfer: 16, matrix: 9 }; pub const HLG: ColorSignal = ColorSignal { primaries: 9, transfer: 18, matrix: 9 }; pub fn is_hdr(&self) -> bool { matches!(self.transfer, 16 | 18) } /// (Kr, Kb) of the matrix. pub fn kr_kb(&self) -> (f32, f32) { if self.matrix == 9 { (0.2627, 0.0593) } else { (0.2126, 0.0722) } } /// Sample-entry boxes: `colr` (nclx for MP4, nclc for MOV) plus `mdcv`/`clli` for PQ /// (mastering display BT.2020/D65, 1000/0.0001 cd/m²; MaxCLL/MaxFALL 0 = unknown). pub fn apply_to(&self, e: &mut SampleEntry, mov: bool) { if !self.is_hdr() { return; } if let Some(v) = e.video.as_mut() { let (p, t, m) = (self.primaries as u16, self.transfer as u16, self.matrix as u16); v.color = Some(if mov { filmcraft_isobmff::ColorInfo::Nclc { primaries: p, transfer: t, matrix: m } } else { filmcraft_isobmff::ColorInfo::Nclx { primaries: p, transfer: t, matrix: m, full_range: false } }); if let Some((md, cll)) = self.static_metadata() { v.mastering_display = Some(md); v.content_light = Some(cll); } } } /// HDR10 static metadata of a PQ signal: the mastering display (BT.2020/D65, 1000 / 0.0001 cd/m²) /// and (MaxCLL, MaxFALL) = (0, 0), unknown. `None` for HLG and SDR. The one definition of these /// values: the `mdcv` / `clli` boxes and the SEI messages of every encoder come from here. pub fn static_metadata(&self) -> Option<(filmcraft_isobmff::MasteringDisplay, (u16, u16))> { (self.transfer == 16).then(|| (filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001), (0, 0))) } } impl Default for ExportSettings { fn default() -> Self { Self { format: Format::H264, path: String::new(), range: None, scale: 1.0, include_audio: true, quality: 90, bitrate_kbps: 20_000, burn_captions: false, part_of_batch: false, prores_profile: String::new(), dnx_profile: String::new(), apv_profile: String::new(), mxf_video_codec: MxfVideoCodec::default(), sdr: false, alpha: false, exr_compression: ExrCompression::None, frame_size: None, frame_rate: None, scaling: Scaling::default(), pixel_aspect: None, field_order: FieldOrder::Progressive, h264_profile: H264Profile::High, h264_level: None, bitrate_mode: BitrateMode::default(), crf: DEFAULT_CRF, hardware_encoding: HardwareEncoding::default(), gpu_rendering: GpuRendering::Off, max_bitrate_kbps: None, adaptive_bitrate: None, keyframe_distance: None, render_at_max_depth: false, max_render_quality: false, audio: AudioSettings::default(), multiplexer: Multiplexer::Mp4, caption_sidecar: None, effects: ExportEffects::default(), metadata: ExportMetadata::default(), h264_pass: H264Pass::Single, signal: ColorSignal::default(), sink: None, } } } /// [`ExportSettings::crf`] of new settings and of settings saved before it existed. pub const DEFAULT_CRF: f32 = 23.0; fn default_crf() -> f32 { DEFAULT_CRF } impl ExportSettings { /// Reject settings the encoders cannot honour. pub fn validate(&self) -> Result<()> { if let Some(range) = self.range { validate_range(range)?; } if !self.scale.is_finite() || self.scale <= 0.0 { return Err(ExportError::Unsupported("output scale must be finite and positive".into())); } if let Some((width, height)) = self.frame_size { filmcraft_project::validate_frame_size(width, height).map_err(ExportError::Unsupported)?; } if let Some(rate) = self.frame_rate && (rate.num <= 0 || rate.den <= 0 || rate.as_f64() > 1000.0 || rate.num > i64::from(u32::MAX) || rate.den > i64::from(u32::MAX) || (i128::from(filmcraft_time::TICKS_PER_SECOND) * i128::from(rate.den) / i128::from(rate.num.max(1))) > i128::from(Tick::MAX.0)) { return Err(ExportError::Unsupported("output frame rate must be positive and at most 1000 fps".into())); } if self.audio.sample_rate.is_some_and(|rate| rate == 0 || rate > 384_000) { return Err(ExportError::Unsupported("output sample rate must be between 1 and 384000 Hz".into())); } if self.audio.float && self.audio.bits != 32 { return Err(ExportError::Unsupported("floating-point audio is 32-bit: set the sample size to 32".into())); } let sample_format = self.audio.sample_format(); if (self.has_audio() || self.format.is_audio_only()) && self.audio_codec() != AudioCodec::Aac && !self.supports_sample_format(sample_format) { let codec = if self.audio_codec() == AudioCodec::Flac { "FLAC" } else { self.format.label() }; return Err(ExportError::Unsupported(format!("{codec} audio has no {} samples: choose 16, 24 or 32 bit", sample_format.label()))); } if self.field_order != FieldOrder::Progressive && self.has_video() { return Err(ExportError::Unsupported(format!("{} field order: FilmCraft's encoders write progressive frames", self.field_order.label()))); } if self.effects.image_overlay.enabled && self.effects.image_overlay.path.trim().is_empty() { return Err(ExportError::Unsupported("image overlay: no image file chosen".into())); } if self.format == Format::Hevc && self.bitrate_mode == BitrateMode::Vbr2Pass { return Err(ExportError::Unsupported("H.265 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); } if self.format == Format::Av1 && self.bitrate_mode == BitrateMode::Vbr2Pass { return Err(ExportError::Unsupported("AV1 export has no two-pass mode: choose CBR or VBR, 1 pass".into())); } if self.bitrate_mode == BitrateMode::Crf { if self.format == Format::Hevc { return Err(ExportError::Unsupported("H.265 export has no CRF mode: choose CBR or VBR, 1 pass".into())); } if self.format == Format::Av1 { return Err(ExportError::Unsupported("AV1 export has no CRF mode: choose CBR or VBR, 1 pass".into())); } if !self.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) { return Err(ExportError::Unsupported("CRF must be between 0 and 51".into())); } } Ok(()) } } /// Shared progress/cancel state of an export job. #[derive(Default)] pub struct Progress { pub done: AtomicU64, pub total: AtomicU64, pub cancel: AtomicBool, pub finished: AtomicBool, pub status: Mutex, pub error: Mutex>, /// What loudness normalization measured (when it ran). pub loudness: Mutex>, /// Readings of `done` over time, for [`Progress::eta`]. pace: Mutex, } impl Progress { pub fn fraction(&self) -> f32 { let t = self.total.load(Ordering::Relaxed).max(1); self.done.load(Ordering::Relaxed) as f32 / t as f32 } /// The estimated time left, from the job's speed over the last 15 seconds. Each call is also a /// reading of that speed, so ask regularly (the UI does on every frame it draws the job). /// `None` until a second of readings shows progress, and once the job is done or finished. pub fn eta(&self) -> Option { self.eta_at(web_time::Instant::now()) } /// [`Progress::eta`] at a given time (tests, and hosts with a clock of their own). pub fn eta_at(&self, now: web_time::Instant) -> Option { let (done, total) = (self.done.load(Ordering::Relaxed), self.total.load(Ordering::Relaxed)); let mut pace = self.pace.lock().unwrap_or_else(|e| e.into_inner()); pace.observe(now, done); if self.finished.load(Ordering::Relaxed) || done >= total { return None; } pace.eta(total - done) } fn set_status(&self, s: impl Into) { *self.status.lock().unwrap_or_else(|e| e.into_inner()) = s.into(); } } #[derive(Clone, Debug, Serialize)] pub struct Report { pub path: String, pub frames: u64, pub seconds: f64, pub bytes: u64, pub render_fps: f64, /// Further files written besides `path` (MXF OP-Atom audio files). #[serde(skip_serializing_if = "Vec::is_empty")] pub extra_files: Vec, } /// A packet produced by a video encoder. pub struct EncodedPacket { pub data: Vec, pub key: bool, /// Duration in encoder timescale units. pub duration: u32, /// pts − dts in encoder timescale units. pub composition_offset: i32, } /// Input picture for encoders: straight sRGB RGBA8 (encoders convert to their own YUV), or for /// HDR exports the encoded R'G'B' (PQ/HLG, BT.2020; 3 floats per pixel, 0..1) in `hdr`. pub struct EncoderFrame<'a> { pub width: u32, pub height: u32, pub rgba: &'a [u8], pub hdr: Option<&'a [f32]>, pub index: u64, } pub trait VideoEncoder: Send { /// MP4/MOV sample entry (codec config) — may only be complete after the first frame. fn sample_entry(&self) -> SampleEntry; fn timescale(&self) -> u32; fn encode(&mut self, frame: &EncoderFrame) -> Result>; fn flush(&mut self) -> Result>; /// Media start offset for an edit list (B-frame delay), in the encoder timescale. fn media_start(&self) -> Option { None } /// First-pass statistics of a two-pass encode (H.264). fn pass_stats(&self) -> Option { None } } /// Audio encoder (AAC) plugged in by codec crates; PCM is built in. pub trait AudioEncoder: Send { fn sample_entry(&self) -> SampleEntry; fn encode(&mut self, planar: &[Vec]) -> Result>>; fn flush(&mut self) -> Result>>; /// Encoder delay (priming) in samples. fn priming(&self) -> u32; /// Samples per access unit (1024 for AAC). fn frame_size(&self) -> u32; /// [`AudioEncoder::flush`] with each access unit's duration, for codecs whose last unit is /// shorter than [`AudioEncoder::frame_size`] (FLAC); by default every unit is a full frame. fn flush_timed(&mut self) -> Result, u32)>> { let n = self.frame_size(); Ok(self.flush()?.into_iter().map(|au| (au, n)).collect()) } /// The sample entry once everything is encoded, when it differs from the first one (the FLAC /// STREAMINFO learns its frame sizes and MD5 at the end). fn final_sample_entry(&self) -> Option { None } } pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option>>; pub type AudioEncoderFactory = fn(format: Format, sample_rate: u32, channels: u32, settings: &ExportSettings) -> Option>>; fn video_factories() -> &'static RwLock> { static F: OnceLock>> = OnceLock::new(); F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, apv_factory, mjpeg_factory])) } fn audio_factories() -> &'static RwLock> { static F: OnceLock>> = OnceLock::new(); F.get_or_init(|| RwLock::new(vec![aac_factory])) } /// Hardware encoder counters (`perf.stats` `export.hardware`): pictures encoded by hardware /// encoders, encoders created, and requests a hardware encoder declined (the software encoder /// took them). #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] pub struct HwEncodeStats { pub frames: u64, pub sessions: u64, pub declined: u64, } static HW_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static HW_SESSIONS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static HW_DECLINED: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// The hardware encoder counters so far. pub fn hw_encode_stats() -> HwEncodeStats { use std::sync::atomic::Ordering::Relaxed; HwEncodeStats { frames: HW_FRAMES.load(Relaxed), sessions: HW_SESSIONS.load(Relaxed), declined: HW_DECLINED.load(Relaxed) } } /// A hardware encoder encoded a picture. pub fn note_hw_encode_frame() { HW_FRAMES.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } /// A hardware encoder was created. pub fn note_hw_encode_session() { HW_SESSIONS.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } /// A hardware encoder declined a request (the software encoder takes it). pub fn note_hw_encode_declined() { HW_DECLINED.fetch_add(1, std::sync::atomic::Ordering::Relaxed); } /// The stages of an export whose wall time [`stage_times`] adds up (`perf.stats` `export.stages`). The /// render of one batch overlaps the encoding of the previous one (`Exporter::overlapping`), so /// [`Stage::Render`] and [`Stage::Encode`] with its neighbours can sum to more than the export took; /// [`Stage::Wait`] is the time left waiting for the render. #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum Stage { /// [`Exporter::new`]: settings, pipeline, encoder and audio set-up. Setup, /// The loudness pass before the first frame (normalised audio only). Loudness, /// Rendering a batch of frames (parallel; includes decoding the sources, effects, colour). Render, /// Encoding the rendered frames, colour conversion included (serial). Encode, /// The part of [`Stage::Encode`] spent converting RGB to the encoder's YUV. Convert, /// Mixing and encoding audio and writing it to the container. Audio, /// Writing video samples to the container (and creating it). Mux, /// Flushing the encoders and finishing the file. Finish, /// Time the encoding side waited, after encoding a batch, for the next batch to finish rendering /// (only with overlap: large means the export is render-bound, near zero encode-bound). Wait, } const STAGES: usize = 9; impl Stage { pub const ALL: [Stage; STAGES] = [Stage::Setup, Stage::Loudness, Stage::Render, Stage::Encode, Stage::Convert, Stage::Audio, Stage::Mux, Stage::Finish, Stage::Wait]; /// The name in `perf.stats`. pub fn name(self) -> &'static str { match self { Stage::Setup => "setup", Stage::Loudness => "loudness", Stage::Render => "render", Stage::Encode => "encode", Stage::Convert => "convert", Stage::Audio => "audio", Stage::Mux => "mux", Stage::Finish => "finish", Stage::Wait => "wait", } } } static STAGE_NS: [std::sync::atomic::AtomicU64; STAGES] = [const { std::sync::atomic::AtomicU64::new(0) }; STAGES]; /// Wall time each export stage has taken so far in this process, in nanoseconds, summed over every /// stepped export (they only grow: diff two readings to measure one export). Measuring costs two /// clock reads per stage per batch; nothing an export does depends on it. pub fn stage_times() -> Vec<(Stage, u64)> { Stage::ALL.iter().map(|s| (*s, STAGE_NS.get(*s as usize).map_or(0, |a| a.load(std::sync::atomic::Ordering::Relaxed)))).collect() } /// Add `d` to a stage's wall time (also for encoders outside this crate: [`Stage::Convert`]). pub fn note_stage(stage: Stage, d: std::time::Duration) { if let Some(a) = STAGE_NS.get(stage as usize) { a.fetch_add(u64::try_from(d.as_nanos()).unwrap_or(u64::MAX), std::sync::atomic::Ordering::Relaxed); } } /// Run `f`, adding its wall time to `stage`. pub fn timed(stage: Stage, f: impl FnOnce() -> T) -> T { let t = web_time::Instant::now(); let r = f(); note_stage(stage, t.elapsed()); r } /// Register a video encoder factory (tried before the built-in ones and those registered earlier). /// Registering the same factory twice is harmless. pub fn register_encoder(f: EncoderFactory) { let mut g = video_factories().write().unwrap_or_else(|e| e.into_inner()); if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) { g.insert(0, f); } } /// Whether `f` is among the registered video encoder factories (startup diagnostics, tests). pub fn encoder_registered(f: EncoderFactory) -> bool { video_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f)) } /// Renders one export frame on something other than the CPU reference renderer — today the GPU /// compositor (`filmcraft-gpu`), registered by the app or CLI at startup. `render` returns the /// frame exactly as [`filmcraft_render::render_sequence`] would (same size, premultiplied /// linear-light RGBA), or `None` to let the CPU render this frame (no adapter, a plan the GPU /// cannot draw, any internal error): the CPU renderer is the reference and the fallback. pub trait FrameRenderer: Send { fn render( &mut self, project: &Project, seq: ItemId, t: Tick, opts: filmcraft_render::RenderOptions, sources: &dyn SourceProvider, ) -> Option; } /// Builds the frame renderer for one export, or `None` when this host cannot (no adapter). pub type FrameRendererFactory = fn() -> Option>; fn frame_renderer_factories() -> &'static RwLock> { static F: OnceLock>> = OnceLock::new(); F.get_or_init(|| RwLock::new(Vec::new())) } /// Register a frame-renderer factory (tried before those registered earlier). Registering the /// same factory twice is harmless. pub fn register_frame_renderer(f: FrameRendererFactory) { let mut g = frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner()); if !g.iter().any(|x| std::ptr::fn_addr_eq(*x, f)) { g.insert(0, f); } } /// Whether `f` is among the registered frame-renderer factories (startup diagnostics, tests). pub fn frame_renderer_registered(f: FrameRendererFactory) -> bool { frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner()).iter().any(|x| std::ptr::fn_addr_eq(*x, f)) } /// Clear the registered frame-renderer factories. Test-only: registration is a process-wide /// startup step and tests that register their own must not see each other's. #[cfg(test)] pub(crate) fn reset_frame_renderers_for_tests() { frame_renderer_factories().write().unwrap_or_else(|e| e.into_inner()).clear(); GPU_FRAMES.store(0, Ordering::Relaxed); GPU_FALLBACKS.store(0, Ordering::Relaxed); } /// Pick the first registered factory that can build a renderer for this host. pub(crate) fn build_frame_renderer() -> Option> { let g = frame_renderer_factories().read().unwrap_or_else(|e| e.into_inner()); g.iter().filter_map(|f| f()).next() } static GPU_FRAMES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static GPU_FALLBACKS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); static LOCK_WAIT_NS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// A frame was composited by the registered GPU renderer. pub fn note_gpu_frame() { GPU_FRAMES.fetch_add(1, Ordering::Relaxed); } /// The GPU renderer declined a frame (the CPU reference renderer took it). pub fn note_gpu_fallback() { GPU_FALLBACKS.fetch_add(1, Ordering::Relaxed); } /// Time an export worker waited for a free GPU renderer (process-wide, for the bench). pub(crate) fn note_lock_wait(d: std::time::Duration) { LOCK_WAIT_NS.fetch_add(u64::try_from(d.as_nanos()).unwrap_or(u64::MAX), Ordering::Relaxed); } /// Total time export workers waited for a GPU renderer since [`reset_export_lock_wait`]. pub fn export_lock_wait() -> std::time::Duration { std::time::Duration::from_nanos(LOCK_WAIT_NS.load(Ordering::Relaxed)) } /// Start [`export_lock_wait`] again from zero. pub fn reset_export_lock_wait() { LOCK_WAIT_NS.store(0, Ordering::Relaxed); } /// GPU-vs-CPU frame counters of the export renderer (per-process, like the hardware ones). #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub struct GpuRenderStats { pub frames: u64, pub fallbacks: u64, } pub fn gpu_render_stats() -> GpuRenderStats { GpuRenderStats { frames: GPU_FRAMES.load(Ordering::Relaxed), fallbacks: GPU_FALLBACKS.load(Ordering::Relaxed) } } pub fn register_audio_encoder(f: AudioEncoderFactory) { audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f); } type FormatProbe = (Format, fn() -> bool); fn format_probes() -> &'static RwLock> { static P: OnceLock>> = OnceLock::new(); P.get_or_init(|| RwLock::new(Vec::new())) } /// Say how to find out whether the encoder of a format without a built-in one ([`Format::has_builtin_encoder`]) /// works on this machine: `probe` runs when [`available`] asks (it should cache its answer). /// Registering the same probe twice is harmless. pub fn register_format_probe(format: Format, probe: fn() -> bool) { let mut g = format_probes().write().unwrap_or_else(|e| e.into_inner()); if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) { g.push((format, probe)); } } fn hdr_probes() -> &'static RwLock> { static P: OnceLock>> = OnceLock::new(); P.get_or_init(|| RwLock::new(Vec::new())) } /// Say how to find out whether the registered encoder of `format` can write HDR (PQ / HLG) on this /// machine: `probe` runs when [`hdr_available`] asks (it should cache its answer). Only H.265 needs /// one: its encoders are hardware-only and not all of them take 10-bit pictures, so an HDR sequence /// exports as tone-mapped SDR unless a probe says otherwise. Registering the same probe twice is harmless. pub fn register_hdr_probe(format: Format, probe: fn() -> bool) { let mut g = hdr_probes().write().unwrap_or_else(|e| e.into_inner()); if !g.iter().any(|(f, p)| *f == format && std::ptr::fn_addr_eq(*p, probe)) { g.push((format, probe)); } } /// Whether a registered probe says the encoder of `format` writes HDR here (false without one). pub fn hdr_available(format: Format) -> bool { // the probes run outside the lock: one may take a while let probes: Vec bool> = hdr_probes().read().unwrap_or_else(|e| e.into_inner()).iter().filter(|(f, _)| *f == format).map(|(_, p)| *p).collect(); probes.into_iter().any(|p| p()) } /// Whether a format can currently be exported: it has a built-in encoder, or a registered probe /// says its encoder works here. pub fn available(format: Format) -> bool { if !Format::ALL.contains(&format) { return false; } format.has_builtin_encoder() || format_probes().read().unwrap_or_else(|e| e.into_inner()).iter().any(|(f, probe)| *f == format && probe()) } struct MjpegEncoder { w: u16, h: u16, quality: u8, rate: FrameRate, } impl VideoEncoder for MjpegEncoder { fn sample_entry(&self) -> SampleEntry { SampleEntry::jpeg(self.w, self.h) } fn timescale(&self) -> u32 { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { let rgb: Vec = f.rgba.as_chunks::<4>().0.iter().flat_map(|p| [p[0], p[1], p[2]]).collect(); let mut out = Vec::new(); let mut enc = image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, self.quality); enc.encode(&rgb, f.width, f.height, image::ExtendedColorType::Rgb8).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data: out, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) } fn flush(&mut self) -> Result> { Ok(Vec::new()) } } fn mjpeg_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { (format == Format::Mjpeg).then(|| Ok(Box::new(MjpegEncoder { w: w as u16, h: h as u16, quality: s.quality.clamp(1, 100), rate }) as Box)) } /// AAC-LC (our encoder), 320 kbps stereo by default. struct AacEncoder { enc: filmcraft_aac::Encoder, rate: u32, channels: u32, } impl AudioEncoder for AacEncoder { fn sample_entry(&self) -> SampleEntry { SampleEntry::aac(self.enc.audio_specific_config(), self.channels, self.rate) } fn encode(&mut self, planar: &[Vec]) -> Result>> { let refs: Vec<&[f32]> = if planar.len() == 6 { // ours: L, R, C, LFE, Ls, Rs → AAC channel configuration 6: C, L, R, Ls, Rs, LFE [2usize, 0, 1, 4, 5, 3].iter().map(|&c| planar[c].as_slice()).collect() } else { planar.iter().map(Vec::as_slice).collect() }; Ok(self.enc.encode(&refs)) } fn flush(&mut self) -> Result>> { Ok(self.enc.flush()) } fn priming(&self) -> u32 { self.enc.priming_samples() } fn frame_size(&self) -> u32 { 1024 } } /// FLAC (our encoder) in MP4 / QuickTime: one frame of 4096 samples per access unit. pub(crate) struct FlacAudio { enc: filmcraft_flac::Encoder, format: SampleFormat, /// Samples of the frame `flush` returns (shorter than a full block). tail: u32, } impl FlacAudio { pub(crate) fn new(sample_rate: u32, channels: u32, s: &ExportSettings) -> Result { let format = s.audio.sample_format(); let ch = u8::try_from(channels).map_err(|_| ExportError::Unsupported("FLAC: too many audio channels".into()))?; let mut cfg = filmcraft_flac::EncoderConfig::new(sample_rate, ch, format.bits() as u8); cfg.level = s.audio.flac_level.min(8); let enc = filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?; Ok(FlacAudio { enc, format, tail: 0 }) } } impl AudioEncoder for FlacAudio { fn sample_entry(&self) -> SampleEntry { SampleEntry::flac(&self.enc.streaminfo()) } fn encode(&mut self, planar: &[Vec]) -> Result>> { let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| self.format.quantise(s)).collect()).collect(); let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); self.enc.encode_frames(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}"))) } fn flush(&mut self) -> Result>> { Ok(self.flush_timed()?.into_iter().map(|(au, _)| au).collect()) } fn flush_timed(&mut self) -> Result, u32)>> { self.tail = self.enc.pending_samples() as u32; let last = self.enc.finish(); Ok(if last.is_empty() { Vec::new() } else { vec![(last, self.tail)] }) } fn priming(&self) -> u32 { 0 } fn frame_size(&self) -> u32 { 4096 } fn final_sample_entry(&self) -> Option { Some(SampleEntry::flac(&self.enc.streaminfo())) } } fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option>> { // AAC caps a frame at 6144 bits per channel (ISO/IEC 14496-3 §4.5.3.2): 6 bits per sample, so // 264.6 kbps for stereo at 22.05 kHz; a higher setting is capped instead of refused let max = (6 * sample_rate as u64 * channels.max(1) as u64).min(u32::MAX as u64) as u32; let bps = (s.audio.bitrate_kbps.clamp(32, 512) * 1000).min(max); Some( filmcraft_aac::Encoder::new(filmcraft_aac::EncoderConfig::cbr(sample_rate, channels as usize, bps)) .map(|enc| Box::new(AacEncoder { enc, rate: sample_rate, channels }) as Box) .map_err(|e| ExportError::Encode(e.to_string())), ) } /// ProRes 422 encoder (HQ unless the settings pick another flavour): sRGB/709 RGBA8 → 10-bit limited-range BT.709 4:2:2. struct ProResEncoder { enc: filmcraft_prores::Encoder, profile: filmcraft_prores::Profile, /// Code the frames' alpha channel (4444 profiles with Include Alpha Channel). alpha: bool, w: u32, h: u32, rate: FrameRate, signal: ColorSignal, } impl VideoEncoder for ProResEncoder { fn sample_entry(&self) -> SampleEntry { let mut e = SampleEntry::prores(filmcraft_isobmff::FourCc(self.profile.fourcc()), self.w as u16, self.h as u16); self.signal.apply_to(&mut e, true); e } fn timescale(&self) -> u32 { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { let chroma = self.profile.chroma(); let mut fr = filmcraft_prores::Frame::new(f.width, f.height, chroma, 10, self.alpha); let (w, h) = (f.width as usize, f.height as usize); timed(Stage::Convert, || match (chroma, f.hdr) { (filmcraft_prores::ChromaFormat::Yuv444, hdr) => { let (kr, kb) = if hdr.is_some() { self.signal.kr_kb() } else { (0.2126, 0.0722) }; to_yuv444_10(f.rgba, hdr, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr, fr.alpha.as_deref_mut()) } (_, Some(rgb)) => { let (kr, kb) = self.signal.kr_kb(); rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) } (_, None) => rgba_to_yuv422_10(f.rgba, w, h, &mut fr.y, &mut fr.cb, &mut fr.cr), }); let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) } fn flush(&mut self) -> Result> { Ok(Vec::new()) } } /// Limited-range 10-bit 4:4:4 with matrix (Kr, Kb) from straight RGBA8, or from encoded R'G'B' /// floats (3 per pixel) when `hdr` is given. `alpha` (full range, 0 = transparent) takes the /// RGBA8 alpha; it stays opaque for float input, which carries none. #[allow(clippy::too_many_arguments)] pub fn to_yuv444_10( rgba: &[u8], hdr: Option<&[f32]>, w: usize, h: usize, kr: f32, kb: f32, y: &mut [u16], cb: &mut [u16], cr: &mut [u16], alpha: Option<&mut [u16]>, ) { let (w, n) = (w.max(1), w.saturating_mul(h)); let kg = 1.0 - kr - kb; let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); let rgb_at = |i: usize| -> [f32; 3] { match hdr { Some(f) => f.get(i * 3..i * 3 + 3).map_or([0.0; 3], |p| [p[0], p[1], p[2]]), None => rgba.get(i * 4..i * 4 + 3).map_or([0.0; 3], |p| [p[0] as f32 / 255.0, p[1] as f32 / 255.0, p[2] as f32 / 255.0]), } }; y.par_chunks_mut(w).zip(cb.par_chunks_mut(w).zip(cr.par_chunks_mut(w))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { for (x, (yv, (cbv, crv))) in yr.iter_mut().zip(cbr.iter_mut().zip(crr.iter_mut())).enumerate() { let [r, g, b] = rgb_at(row * w + x); let yy = kr * r + kg * g + kb * b; *yv = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16; *cbv = (512.0 + 896.0 * (b - yy) / sb).round().clamp(4.0, 1019.0) as u16; *crv = (512.0 + 896.0 * (r - yy) / sr).round().clamp(4.0, 1019.0) as u16; } }); if let (Some(a), None) = (alpha, hdr) { for (i, dst) in a.iter_mut().enumerate().take(n) { *dst = rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16); } } } /// BT.709 limited-range 10-bit 4:2:2 from straight RGBA8 (chroma averaged horizontally). pub fn rgba_to_yuv422_10(rgba: &[u8], w: usize, h: usize, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { let cw = w.div_ceil(2); debug_assert!(y.len() >= w * h && rgba.len() >= w * h * 4); y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { let src = &rgba[row * w * 4..(row + 1) * w * 4]; let mut us = vec![0f32; w]; let mut vs = vec![0f32; w]; for x in 0..w { let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0); let yy = 0.2126 * r + 0.7152 * g + 0.0722 * b; yr[x] = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16; us[x] = (b - yy) / 1.8556; vs[x] = (r - yy) / 1.5748; } for cx in 0..cw { let a = cx * 2; let b2 = (a + 1).min(w - 1); let u = (us[a] + us[b2]) * 0.5; let v = (vs[a] + vs[b2]) * 0.5; cbr[cx] = (512.0 + 896.0 * u).round().clamp(4.0, 1019.0) as u16; crr[cx] = (512.0 + 896.0 * v).round().clamp(4.0, 1019.0) as u16; } }); } /// The ProRes profile named by [`ExportSettings::prores_profile`]. pub fn prores_profile(name: &str) -> filmcraft_prores::Profile { use filmcraft_prores::Profile; match name.to_ascii_lowercase().as_str() { "proxy" => Profile::Proxy, "lt" => Profile::Lt, "standard" | "422" => Profile::Standard, "4444" => Profile::P4444, "4444xq" | "4444 xq" | "4444-xq" | "xq" => Profile::P4444Xq, _ => Profile::Hq, } } /// Limited-range 10-bit 4:2:2 from encoded R'G'B' floats with matrix (Kr, Kb). #[allow(clippy::too_many_arguments)] pub fn rgbf_to_yuv422_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut [u16], cb: &mut [u16], cr: &mut [u16]) { let cw = w.div_ceil(2); debug_assert!(rgb.len() >= w * h * 3 && y.len() >= w * h); let kg = 1.0 - kr - kb; let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); y.par_chunks_mut(w).zip(cb.par_chunks_mut(cw).zip(cr.par_chunks_mut(cw))).enumerate().for_each(|(row, (yr, (cbr, crr)))| { let src = &rgb[row * w * 3..(row + 1) * w * 3]; let mut us = vec![0f32; w]; let mut vs = vec![0f32; w]; for x in 0..w { let (r, g, b) = (src[x * 3], src[x * 3 + 1], src[x * 3 + 2]); let yy = kr * r + kg * g + kb * b; yr[x] = (64.0 + 876.0 * yy).round().clamp(4.0, 1019.0) as u16; us[x] = (b - yy) / sb; vs[x] = (r - yy) / sr; } for cx in 0..cw { let a = cx * 2; let b2 = (a + 1).min(w - 1); cbr[cx] = (512.0 + 896.0 * (us[a] + us[b2]) * 0.5).round().clamp(4.0, 1019.0) as u16; crr[cx] = (512.0 + 896.0 * (vs[a] + vs[b2]) * 0.5).round().clamp(4.0, 1019.0) as u16; } }); } fn prores_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { (format == Format::ProRes).then(|| { let profile = prores_profile(&s.prores_profile); let mut cfg = filmcraft_prores::EncoderConfig::new(profile, w, h); if s.signal.is_hdr() { cfg.color = filmcraft_prores::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix }; } let alpha = s.keeps_alpha(); cfg.encode_alpha = alpha; let enc = filmcraft_prores::Encoder::with_config(cfg); Ok(Box::new(ProResEncoder { enc, profile, alpha, w, h, rate, signal: s.signal }) as Box) }) } /// The DNxHR profile named by [`ExportSettings::dnx_profile`]. pub fn dnx_profile(name: &str) -> filmcraft_dnx::Profile { use filmcraft_dnx::Profile; match name.to_ascii_lowercase().as_str() { "lb" => Profile::Lb, "sq" => Profile::Sq, "hqx" => Profile::Hqx, _ => Profile::Hq, } } /// DNxHR encoder (RGBA8 or HDR floats → BT.709 / BT.2020 limited-range 4:2:2; 8-bit for /// LB/SQ/HQ, 10-bit for HQX). struct DnxEncoder { enc: filmcraft_dnx::Encoder, w: u32, h: u32, rate: FrameRate, signal: ColorSignal, } impl VideoEncoder for DnxEncoder { fn sample_entry(&self) -> SampleEntry { let mut e = SampleEntry::dnx(filmcraft_isobmff::FourCc(*b"AVdh"), self.w as u16, self.h as u16); self.signal.apply_to(&mut e, true); e } fn timescale(&self) -> u32 { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { let mut fr = filmcraft_dnx::Frame::new(f.width, f.height, filmcraft_dnx::ChromaFormat::Yuv422, 10, false); match f.hdr { Some(rgb) => { let (kr, kb) = self.signal.kr_kb(); rgbf_to_yuv422_10(rgb, f.width as usize, f.height as usize, kr, kb, &mut fr.y, &mut fr.cb, &mut fr.cr) } None => rgba_to_yuv422_10(f.rgba, f.width as usize, f.height as usize, &mut fr.y, &mut fr.cb, &mut fr.cr), } // the encoder rescales 10-bit input to its coded depth let data = self.enc.encode(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) } fn flush(&mut self) -> Result> { Ok(Vec::new()) } } fn dnx_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { (format == Format::DnxHr).then(|| { let mut cfg = filmcraft_dnx::EncoderConfig::new(dnx_profile(&s.dnx_profile), w, h); if s.signal.primaries == 9 { cfg.color_volume = filmcraft_dnx::ColorVolume::Bt2020Ncl; } let enc = filmcraft_dnx::Encoder::with_config(cfg).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(Box::new(DnxEncoder { enc, w, h, rate, signal: s.signal }) as Box) }) } /// The APV profile named by [`ExportSettings::apv_profile`]. pub fn apv_profile(name: &str) -> filmcraft_apv::Profile { use filmcraft_apv::Profile; match name.to_ascii_lowercase().replace([' ', '_'], "-").trim_start_matches("apv-").trim_start_matches("apv") { "422-12" | "42212" => Profile::P422_12, "444-10" | "44410" => Profile::P444_10, "444-12" | "44412" => Profile::P444_12, "4444-10" | "444410" => Profile::P4444_10, "4444-12" | "444412" => Profile::P4444_12, "400-10" | "40010" => Profile::P400_10, _ => Profile::P422_10, } } /// APV encoder (RFC 9924: 10/12-bit 4:2:2 or 4:4:4 intra). struct ApvEncoder { enc: filmcraft_apv::Encoder, chroma: filmcraft_apv::ChromaFormat, w: u32, h: u32, rate: FrameRate, signal: ColorSignal, } impl VideoEncoder for ApvEncoder { fn sample_entry(&self) -> SampleEntry { let apvc = filmcraft_isobmff::ApvConfig::parse(&self.enc.decoder_config_record()).unwrap_or_default(); let mut e = SampleEntry::apv(apvc, self.w as u16, self.h as u16); self.signal.apply_to(&mut e, true); e } fn timescale(&self) -> u32 { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { let (w, h) = (f.width as usize, f.height as usize); let mut fr = filmcraft_apv::Frame::new(f.width, f.height, self.chroma, 10, self.chroma == filmcraft_apv::ChromaFormat::Yuv4444); let mut y422 = vec![0u16; w * h]; let cw = w.div_ceil(2); let mut cb422 = vec![0u16; cw * h]; let mut cr422 = vec![0u16; cw * h]; match f.hdr { Some(rgb) => { let (kr, kb) = self.signal.kr_kb(); rgbf_to_yuv422_10(rgb, w, h, kr, kb, &mut y422, &mut cb422, &mut cr422); } None => rgba_to_yuv422_10(f.rgba, w, h, &mut y422, &mut cb422, &mut cr422), } fr.y = y422; match self.chroma { filmcraft_apv::ChromaFormat::Monochrome => { fr.cb.clear(); fr.cr.clear(); } filmcraft_apv::ChromaFormat::Yuv422 => { fr.cb = cb422; fr.cr = cr422; } filmcraft_apv::ChromaFormat::Yuv444 | filmcraft_apv::ChromaFormat::Yuv4444 => { for row in 0..h { for x in 0..w { fr.cb[row * w + x] = cb422[row * cw + x / 2]; fr.cr[row * w + x] = cr422[row * cw + x / 2]; } } if let Some(a) = fr.alpha.as_mut() { for (i, dst) in a.iter_mut().enumerate() { *dst = f.rgba.get(i * 4 + 3).map_or(1023, |&v| ((v as u32 * 1023 + 127) / 255) as u16); } } } } let data = self.enc.encode_raw_au(&fr).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(vec![EncodedPacket { data, key: true, duration: self.rate.den as u32, composition_offset: 0 }]) } fn flush(&mut self) -> Result> { Ok(Vec::new()) } } fn apv_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { (format == Format::Apv).then(|| { let profile = apv_profile(&s.apv_profile); let mut cfg = filmcraft_apv::EncoderConfig::new(profile, w, h); if s.signal.is_hdr() { cfg.color = filmcraft_apv::ColorInfo { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false }; } let chroma = cfg.chroma; let enc = filmcraft_apv::Encoder::with_config(cfg).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(Box::new(ApvEncoder { enc, chroma, w, h, rate, signal: s.signal }) as Box) }) } /// H.264 High (our encoder): sRGB/709 RGBA8 → 8-bit limited-range BT.709 4:2:0, VBR at the /// requested bitrate, length-prefixed samples with the `avcC` in the sample entry. struct H264Encoder { enc: filmcraft_h264enc::Encoder, w: u32, h: u32, rate: FrameRate, y: Vec, u: Vec, v: Vec, signal: ColorSignal, } impl H264Encoder { fn packets(&self, ps: Vec) -> Vec { ps.into_iter() .map(|p| EncodedPacket { data: p.data, key: p.keyframe, duration: self.rate.den as u32, composition_offset: (p.pts - p.dts) as i32 }) .collect() } } impl VideoEncoder for H264Encoder { fn sample_entry(&self) -> SampleEntry { let cfg = filmcraft_isobmff::AvcConfig::parse(&self.enc.avcc()).unwrap_or_else(|_| { let (sps, pps) = self.enc.sps_pps(); filmcraft_isobmff::AvcConfig::new(vec![sps], vec![pps], 4) }); let mut e = SampleEntry::avc(cfg, self.w as u16, self.h as u16); self.signal.apply_to(&mut e, false); e } fn timescale(&self) -> u32 { self.rate.num as u32 } fn encode(&mut self, f: &EncoderFrame) -> Result> { timed(Stage::Convert, || match f.hdr { Some(rgb) => { let (kr, kb) = self.signal.kr_kb(); rgbf_to_yuv420_8(rgb, f.width as usize, f.height as usize, kr, kb, &mut self.y, &mut self.u, &mut self.v) } None => rgba_to_yuv420_8(f.rgba, f.width as usize, f.height as usize, &mut self.y, &mut self.u, &mut self.v), }); let cw = (f.width as usize).div_ceil(2); let frame = filmcraft_h264enc::YuvFrame { y: &self.y, u: &self.u, v: &self.v, y_stride: f.width as usize, uv_stride: cw }; let ps = self.enc.try_encode(&frame, f.index as i64 * self.rate.den).map_err(|e| ExportError::Encode(e.to_string()))?; Ok(self.packets(ps)) } fn flush(&mut self) -> Result> { let ps = self.enc.flush(); Ok(self.packets(ps)) } fn media_start(&self) -> Option { // With B-frames the first DTS is one frame before the first PTS. (self.enc.delay() > 0).then_some(self.rate.den) } fn pass_stats(&self) -> Option { self.enc.pass_stats() } } /// BT.709 limited-range 8-bit 4:2:0 from straight RGBA8 (2×2 chroma average). pub fn rgba_to_yuv420_8(rgba: &[u8], w: usize, h: usize, y: &mut Vec, u: &mut Vec, v: &mut Vec) { let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); y.resize(w * h, 0); u.resize(cw * ch, 0); v.resize(cw * ch, 0); y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| { let rows = yr.len() / w; let mut us = vec![0f32; cw]; let mut vs = vec![0f32; cw]; let mut cnt = vec![0f32; cw]; for dy in 0..rows { let row = cy * 2 + dy; let src = &rgba[row * w * 4..(row + 1) * w * 4]; for x in 0..w { let (r, g, b) = (src[x * 4] as f32 / 255.0, src[x * 4 + 1] as f32 / 255.0, src[x * 4 + 2] as f32 / 255.0); let yy = 0.2126 * r + 0.7152 * g + 0.0722 * b; yr[dy * w + x] = (16.0 + 219.0 * yy).round().clamp(1.0, 254.0) as u8; us[x / 2] += (b - yy) / 1.8556; vs[x / 2] += (r - yy) / 1.5748; cnt[x / 2] += 1.0; } } for cx in 0..cw { ur[cx] = (128.0 + 224.0 * us[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8; vr[cx] = (128.0 + 224.0 * vs[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8; } }); } /// Limited-range 8-bit 4:2:0 from encoded R'G'B' floats with matrix (Kr, Kb). #[allow(clippy::too_many_arguments)] pub fn rgbf_to_yuv420_8(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut Vec, u: &mut Vec, v: &mut Vec) { let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let kg = 1.0 - kr - kb; let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); y.resize(w * h, 0); u.resize(cw * ch, 0); v.resize(cw * ch, 0); y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| { let rows = yr.len() / w; let mut us = vec![0f32; cw]; let mut vs = vec![0f32; cw]; let mut cnt = vec![0f32; cw]; for dy in 0..rows { let row = cy * 2 + dy; let src = &rgb[row * w * 3..(row + 1) * w * 3]; for x in 0..w { let (r, g, b) = (src[x * 3], src[x * 3 + 1], src[x * 3 + 2]); let yy = kr * r + kg * g + kb * b; yr[dy * w + x] = (16.0 + 219.0 * yy).round().clamp(1.0, 254.0) as u8; us[x / 2] += (b - yy) / sb; vs[x / 2] += (r - yy) / sr; cnt[x / 2] += 1.0; } } for cx in 0..cw { ur[cx] = (128.0 + 224.0 * us[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8; vr[cx] = (128.0 + 224.0 * vs[cx] / cnt[cx]).round().clamp(1.0, 254.0) as u8; } }); } /// Limited-range 10-bit 4:2:0 (code values 0..1023, not MSB-aligned) from encoded R'G'B' floats with /// matrix (Kr, Kb): Y = 64 + 876·Y', C = 512 + 896·C, chroma averaged over each 2x2 block (one pixel /// or pair at an odd edge). `rgb` is 3 floats per pixel; NaN counts as 0, infinities and /// out-of-range values clamp to 0..1 first, and codes clamp to 4..1019 like [`rgbf_to_yuv422_10`] /// (so black is 64, peak white 940 and the chroma of any colour stays within 64..960). /// The planes are resized to `w * h` and `ceil(w / 2) * ceil(h / 2)`. A `rgb` slice of the wrong /// length, a zero size or a size whose arithmetic overflows is an error. #[allow(clippy::too_many_arguments)] pub fn rgbf_to_yuv420_10(rgb: &[f32], w: usize, h: usize, kr: f32, kb: f32, y: &mut Vec, u: &mut Vec, v: &mut Vec) -> Result<()> { let bad = || ExportError::Encode(format!("a {w}x{h} HDR picture needs w*h*3 floats, got {}", rgb.len())); let px = w.checked_mul(h).ok_or_else(bad)?; if w == 0 || h == 0 || px.checked_mul(3) != Some(rgb.len()) { return Err(bad()); } let (cw, ch) = (w.div_ceil(2), h.div_ceil(2)); let kg = 1.0 - kr - kb; let (sb, sr) = (2.0 * (1.0 - kb), 2.0 * (1.0 - kr)); y.clear(); y.resize(px, 0); u.clear(); u.resize(cw * ch, 0); v.clear(); v.resize(cw * ch, 0); let unit = |x: f32| if x.is_nan() { 0.0 } else { x.clamp(0.0, 1.0) }; let code = |x: f32| x.round().clamp(4.0, 1019.0) as u16; y.par_chunks_mut(w * 2).zip(u.par_chunks_mut(cw).zip(v.par_chunks_mut(cw))).enumerate().for_each(|(cy, (yr, (ur, vr)))| { let rows = yr.len() / w; let mut us = vec![0f32; cw]; let mut vs = vec![0f32; cw]; let mut cnt = vec![0f32; cw]; for (dy, yrow) in yr.chunks_exact_mut(w).enumerate().take(rows) { let row = cy * 2 + dy; let Some(src) = rgb.get(row * w * 3..(row + 1) * w * 3) else { continue }; for (x, (&[r, g, b], yo)) in src.as_chunks::<3>().0.iter().zip(yrow.iter_mut()).enumerate() { let (r, g, b) = (unit(r), unit(g), unit(b)); let yy = kr * r + kg * g + kb * b; *yo = code(64.0 + 876.0 * yy); if let (Some(a), Some(c), Some(n)) = (us.get_mut(x / 2), vs.get_mut(x / 2), cnt.get_mut(x / 2)) { *a += (b - yy) / sb; *c += (r - yy) / sr; *n += 1.0; } } } for (cx, (uo, vo)) in ur.iter_mut().zip(vr.iter_mut()).enumerate() { let n = cnt.get(cx).copied().unwrap_or(1.0).max(1.0); *uo = code(512.0 + 896.0 * us.get(cx).copied().unwrap_or(0.0) / n); *vo = code(512.0 + 896.0 * vs.get(cx).copied().unwrap_or(0.0) / n); } }); Ok(()) } /// Slices per H.264 picture: one per four macroblock rows. The encoder's default follows the core /// count, which would make the stream (and every decoded picture) depend on the machine; this is /// what it picks on a machine with enough cores. pub(crate) fn h264_slices(height: u32) -> usize { (height.div_ceil(16) as usize).div_ceil(4).max(1) } fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option>> { if format != Format::H264 { return None; } let mut cfg = filmcraft_h264enc::EncoderConfig::new(w, h, rate.num as u32, rate.den as u32); // MXF carries the Annex B byte stream (ST 381-3) with in-band parameter sets cfg.format = if s.format.is_mxf() { filmcraft_h264enc::PacketFormat::AnnexB } else { filmcraft_h264enc::PacketFormat::LengthPrefixed }; cfg.aud = s.format.is_mxf(); cfg.keyint = s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (rate.num as f64 / rate.den as f64 * 2.0).round().max(1.0) as u32); cfg.slices = h264_slices(h); let kbps = s.bitrate_kbps.max(100); let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or_else(|| (u64::from(kbps) * 3 / 2).min(u64::from(u32::MAX)) as u32); cfg.rate = match s.bitrate_mode { BitrateMode::Cbr => filmcraft_h264enc::RateControl::Cbr { kbps }, // the encoder clamps to 0..=51; a NaN from a hand-edited preset becomes the default BitrateMode::Crf => filmcraft_h264enc::RateControl::Crf(if s.crf.is_finite() { s.crf } else { DEFAULT_CRF }), _ => filmcraft_h264enc::RateControl::Vbr { target_kbps: kbps, max_kbps: max }, }; cfg.pass = match &s.h264_pass { H264Pass::Single => filmcraft_h264enc::Pass::Single, H264Pass::First => filmcraft_h264enc::Pass::First, H264Pass::Second(st) => filmcraft_h264enc::Pass::Second(st.clone()), }; cfg.profile = match s.h264_profile { H264Profile::Baseline => filmcraft_h264enc::Profile::Baseline, H264Profile::Main => filmcraft_h264enc::Profile::Main, H264Profile::High => filmcraft_h264enc::Profile::High, }; cfg.level = s.h264_level; if let Some((n, d)) = s.pixel_aspect { cfg.sar = (n.clamp(1, 65535) as u16, d.clamp(1, 65535) as u16); } if s.signal.is_hdr() { cfg.color = filmcraft_h264enc::ColorConfig { primaries: s.signal.primaries, transfer: s.signal.transfer, matrix: s.signal.matrix, full_range: false }; if let Some((md, cll)) = s.signal.static_metadata() { let mut b = [0u8; 24]; b.copy_from_slice(&md.to_bytes()); cfg.mastering_display = Some(b); cfg.content_light = Some(cll); } } Some( filmcraft_h264enc::Encoder::new(cfg) .map(|enc| Box::new(H264Encoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new(), signal: s.signal }) as Box) .map_err(|e| ExportError::Encode(e.to_string())), ) } /// The range to export (settings → In/Out → whole sequence). pub fn export_range(project: &Project, seq: ItemId, settings: &ExportSettings) -> Result { let q = project.sequence(seq).ok_or(ExportError::NoSequence)?; q.check_bounds().map_err(ExportError::Unsupported)?; if let Some(r) = settings.range { validate_range(r)?; return Ok(r); } let fd = q.settings.frame_rate.frame_duration(); let a = q.mark_in.unwrap_or(Tick::ZERO); let minimum_end = a.0.checked_add(fd.0).ok_or_else(|| ExportError::Unsupported("export In point overflows the time range".into()))?; let b = match q.mark_out { Some(out) => out.0.checked_add(fd.0).ok_or_else(|| ExportError::Unsupported("export Out point overflows the time range".into()))?, None => q.duration().0, }; let duration = b.max(minimum_end).checked_sub(a.0).ok_or_else(|| ExportError::Unsupported("export range duration overflows".into()))?; let range = TimeRange::new(a, Tick(duration)); validate_range(range)?; Ok(range) } /// Validate input time before frame/sample conversion or any `TimeRange::end` arithmetic. pub fn validate_range(range: TimeRange) -> Result<()> { if range.start.0 < 0 || range.duration.0 <= 0 || range.start.0.checked_add(range.duration.0).is_none_or(|end| end > Tick::MAX.0) { return Err(ExportError::Unsupported("export range must start at or after zero, have positive duration and end within supported time bounds".into())); } Ok(()) } /// Output frames `[f0, f1)` of `range` at `rate` (frame `f` is at `rate.tick_of(f)`). pub fn frame_span(rate: FrameRate, range: TimeRange) -> (i64, i64) { let f0 = rate.frame_at(range.start); let f1 = rate.frame_at(range.end() - Tick(1)) + 1; (f0, f1.max(f0)) } /// The delay of the `i`th GIF frame (from the start of the export) in centiseconds, the unit GIF /// stores. Each frame runs from its rounded start time to the next frame's, so the delays add up /// to the exact duration (24 fps: 4, 4, 5, 4, 4, 4 cs …) instead of every frame truncating /// 4.17 cs to 4 cs and the animation playing 4 % fast (#854). fn gif_delay_cs(rate: FrameRate, i: i64) -> u16 { let num = i128::from(rate.num.max(1)); let den = i128::from(rate.den.max(0)); // start of frame `k` in centiseconds, rounded half up let at = |k: i64| i128::from(k.max(0)).saturating_mul(200).saturating_mul(den).saturating_add(num) / num.saturating_mul(2); let cs = at(i.saturating_add(1)).saturating_sub(at(i)); u16::try_from(cs.clamp(0, i128::from(u16::MAX))).unwrap_or(u16::MAX) } /// An in-memory writer that stays reachable after the encoder that owns a clone is dropped. #[derive(Clone, Default)] struct SharedBuf(Arc>>); impl Write for SharedBuf { fn write(&mut self, buf: &[u8]) -> std::io::Result { self.0.lock().unwrap_or_else(|e| e.into_inner()).extend_from_slice(buf); Ok(buf.len()) } fn flush(&mut self) -> std::io::Result<()> { Ok(()) } } /// Encode straight sRGB RGBA8 pixels as a PNG file (thumbnails, previews). pub fn encode_png(rgba: Vec, w: u32, h: u32) -> Result> { encode_still(Format::PngSequence, rgba, w, h, false) } /// Encode one still of an image sequence (also Export Frame). PNG always carries the alpha /// channel it is given; `alpha` keeps it in TIFF, Targa and OpenEXR too, which are otherwise written /// as RGB. /// JPEG uses the default export quality ([`encode_still_with_quality`] picks another). pub fn encode_still(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool) -> Result> { encode_still_with_quality(format, rgba, w, h, alpha, ExportSettings::default().quality) } /// [`encode_still`] with the JPEG quality (1–100; ignored by the lossless formats). pub fn encode_still_with_quality(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool, quality: u8) -> Result> { encode_still_with(format, rgba, w, h, alpha, quality, ExrCompression::None) } /// [`encode_still_with_quality`] with the OpenEXR compression (ignored by the other formats). pub fn encode_still_with(format: Format, rgba: Vec, w: u32, h: u32, alpha: bool, quality: u8, exr: ExrCompression) -> Result> { let enc = |e: image::ImageError| ExportError::Encode(e.to_string()); let mut out = std::io::Cursor::new(Vec::new()); match format { Format::JpegSequence => { let rgb = raster::rgb(&rgba, w, h)?; image::ImageEncoder::write_image( image::codecs::jpeg::JpegEncoder::new_with_quality(&mut out, quality.clamp(1, 100)), &rgb, w, h, image::ExtendedColorType::Rgb8, ) .map_err(enc)? } Format::TgaSequence => return raster::tga(&rgba, w, h, alpha), Format::DpxSequence => return raster::dpx(&rgba, w, h), Format::ExrSequence => return raster::exr(&rgba, w, h, alpha, exr), Format::PngSequence => { image::ImageEncoder::write_image(image::codecs::png::PngEncoder::new(&mut out), &rgba, w, h, image::ExtendedColorType::Rgba8).map_err(enc)? } Format::TiffSequence | Format::BmpSequence => { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame size".into()))?; let f = if format == Format::TiffSequence { image::ImageFormat::Tiff } else { image::ImageFormat::Bmp }; if alpha && format == Format::TiffSequence { img.write_to(&mut out, f).map_err(enc)? } else { image::DynamicImage::ImageRgba8(img).to_rgb8().write_to(&mut out, f).map_err(enc)? } } _ => return Err(ExportError::Unsupported(format!("{} is not an image sequence", format.label()))), } Ok(out.into_inner()) } /// Run an export (blocking; call from a worker thread). pub fn export(project: &Arc, seq: ItemId, settings: &ExportSettings, sources: &dyn SourceProvider, progress: &Progress) -> Result { settings.validate()?; if stepped(settings.format) { let mut ex = Exporter::new(project.clone(), seq, settings, progress)?; loop { match ex.step(sources, progress)? { Step::Progress => {} Step::Done(r) => return Ok(r), // only asynchronous (web) sources defer; a blocking export cannot wait for them Step::Pending => return Err(ExportError::Io("media data is still loading; run the export as a stepped job".into())), } } } let t0 = web_time::Instant::now(); let range = export_range(project, seq, settings)?; let cancelled = || progress.cancel.load(Ordering::Relaxed); let batch = rayon::current_num_threads().clamp(2, 16) as i64; let (bytes, nframes) = match settings.format { Format::Aac => aac_out::export(project, seq, settings, range, sources, progress)?, Format::Wav | Format::Aiff | Format::Flac => { let mut a = audio_out::AudioOut::new(project.clone(), seq, settings, range)?; let (ch, sr, fmt) = (a.channels as u16, a.sr, settings.audio.sample_format()); let frames = a.remaining(); // the sizes are known up front: refuse an AIFF that cannot hold them before measuring let mut writer = match settings.format { Format::Flac => { let mut cfg = filmcraft_flac::EncoderConfig::new(sr, ch as u8, fmt.bits() as u8); cfg.level = settings.audio.flac_level.min(8); AudioWriter::Flac(Box::new(filmcraft_flac::Encoder::new(cfg).map_err(|e| ExportError::Unsupported(format!("FLAC: {e}")))?)) } Format::Wav => AudioWriter::Pcm(pcm::PcmContainer::Wav), _ => AudioWriter::Pcm(pcm::PcmContainer::Aiff), }; let head = match &writer { AudioWriter::Pcm(c) => pcm::header(*c, ch, sr, fmt, frames).map_err(ExportError::Unsupported)?, AudioWriter::Flac(enc) => enc.header(), }; // one second per chunk: memory stays flat however long the range is let chunk = i64::from(sr.max(1)); if !settings.part_of_batch { progress.total.store(frames.div_ceil(chunk as u64).max(1), Ordering::Relaxed); progress.set_status(format!("Exporting audio ({})", settings.format.label())); } a.measure(settings, sources, &cancelled)?; *progress.loudness.lock().unwrap_or_else(|e| e.into_inner()) = a.loudness; let mut out = Out::create(settings)?; let io = |e: std::io::Error| ExportError::Io(e.to_string()); out.write_all(&head).map_err(io)?; let mut buf = Vec::new(); while let Some(planar) = a.pull(a.out.saturating_add(chunk), sources) { if cancelled() { return Err(ExportError::Cancelled); } buf.clear(); match &mut writer { AudioWriter::Pcm(c) => pcm::encode(*c, &audio_out::interleave(&planar), fmt, &mut buf), AudioWriter::Flac(enc) => { let ints: Vec> = planar.iter().map(|c| c.iter().map(|&s| fmt.quantise(s)).collect()).collect(); let refs: Vec<&[i32]> = ints.iter().map(Vec::as_slice).collect(); buf = enc.encode(&refs).map_err(|e| ExportError::Encode(format!("FLAC: {e}")))?; } } out.write_all(&buf).map_err(io)?; if !settings.part_of_batch { progress.done.fetch_add(1, Ordering::Relaxed); } } match &mut writer { AudioWriter::Pcm(_) => out.write_all(pcm::pad(frames, ch, fmt)).map_err(io)?, AudioWriter::Flac(enc) => { out.write_all(&enc.finish()).map_err(io)?; // the frame sizes and MD5 are known now: fill them into STREAMINFO use std::io::{Seek, SeekFrom}; out.seek(SeekFrom::Start(filmcraft_flac::STREAMINFO_OFFSET)).map_err(io)?; out.write_all(&enc.streaminfo()).map_err(io)?; out.seek(SeekFrom::End(0)).map_err(io)?; } } let n = out.finish(settings)?; if settings.part_of_batch { progress.done.store(1, Ordering::Relaxed); } (n, frames) } f if f.is_image_sequence() || f == Format::Gif => { let pipe = pipeline::Pipeline::new(project.clone(), seq, settings, false)?; let (f0, f1) = frame_span(pipe.rate, range); let count = (f1 - f0) as u64; let (w, h) = (pipe.w, pipe.h); if !settings.part_of_batch { progress.total.store(count, Ordering::Relaxed); progress.set_status(format!("Exporting {count} frames ({})", settings.format.label())); } let mut total = 0u64; let gif_buf = SharedBuf::default(); let mut gif = if settings.format == Format::Gif { let mut enc = image::codecs::gif::GifEncoder::new_with_speed(gif_buf.clone(), 10); enc.set_repeat(image::codecs::gif::Repeat::Infinite).map_err(|e| ExportError::Encode(e.to_string()))?; Some(enc) } else { None }; let mut f = f0; while f < f1 { if cancelled() { return Err(ExportError::Cancelled); } let end = (f + batch).min(f1); if let Some(enc) = gif.as_mut() { let frames: Vec> = (f..end).into_par_iter().map(|fi| pipe.frame(fi, sources).0).collect(); for (fi, rgba) in (f..end).zip(frames) { let img = image::RgbaImage::from_raw(w, h, rgba).ok_or_else(|| ExportError::Encode("frame".into()))?; let delay = image::Delay::from_numer_denom_ms(u32::from(gif_delay_cs(pipe.rate, fi - f0)) * 10, 1); enc.encode_frame(image::Frame::from_parts(img, 0, 0, delay)).map_err(|e| ExportError::Encode(e.to_string()))?; } } else { let written: Vec> = (f..end) .into_par_iter() .map(|fi| { let data = encode_still_with( settings.format, pipe.frame(fi, sources).0, w, h, settings.alpha, settings.quality, settings.exr_compression, )?; write_output(settings, &pcm::image_sequence_path(&settings.path, (fi - f0) as u64, count), data) }) .collect(); for r in written { total += r?; } } progress.done.fetch_add((end - f) as u64, Ordering::Relaxed); f = end; } if let Some(enc) = gif { let buf = gif_buf.clone(); drop(enc); let data = std::mem::take(&mut *buf.0.lock().unwrap_or_else(|e| e.into_inner())); total = write_output(settings, &settings.path, data)?; } (total, count) } _ => { // Handled by the stepped exporter above; reaching here would be a dispatch bug. return Err(ExportError::Unsupported(format!("{:?} must run as a stepped export", settings.format))); } }; let secs = t0.elapsed().as_secs_f64(); if !settings.part_of_batch { progress.finished.store(true, Ordering::Relaxed); progress.set_status(format!("Done in {secs:.1}s")); } Ok(Report { path: settings.path.clone(), frames: nframes, seconds: secs, bytes, render_fps: nframes as f64 / secs.max(1e-6), extra_files: Vec::new() }) } #[cfg(test)] mod tests; #[cfg(test)] mod settings_tests; #[cfg(test)] mod surround_tests; #[cfg(test)] mod mxf_tests; #[cfg(test)] mod determinism_tests; #[cfg(test)] mod hdr_tests;