8000 filmcraft/crates/export/src/lib.rs at main · hmmhmmhm/filmcraft · GitHub
[go: up one dir, main page]

Skip to content

Latest commit

 

History

History
1131 lines (1063 loc) · 45.4 KB

lib.rs

File metadata and controls

1131 lines (1063 loc) · 45.4 KB
//! 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
//! 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.
use std::io::Write;
mod audio_out;
mod job;
mod mxf_out;
mod pcm;
mod pipeline;
pub mod presets;
pub mod settings;
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<T> = std::result::Result<T, ExportError>;
#[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,
/// 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, 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,
#[serde(rename = "gif", alias = "Gif")]
Gif,
#[serde(rename = "wav", alias = "Wav")]
Wav,
#[serde(rename = "aiff", alias = "Aiff")]
Aiff,
/// 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<Format> {
Some(match s.to_ascii_lowercase().replace([' ', '-', '_', '.'], "").as_str() {
"h264" | "mp4" | "avc" | "m4v" => Format::H264,
"prores" | "mov" | "appleprores" => Format::ProRes,
"dnxhr" | "dnxhd" | "dnx" | "avid" | "aviddnxhr" | "aviddnxhd" | "vc3" => Format::DnxHr,
"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,
"gif" | "animatedgif" => Format::Gif,
"wav" | "waveform" | "waveformaudio" => Format::Wav,
"aif" | "aiff" | "aifc" => Format::Aiff,
_ => 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::ProRes => "prores",
Format::DnxHr => "dnxhr",
Format::Mjpeg => "mjpeg",
Format::PngSequence => "png",
Format::TiffSequence => "tiff",
Format::BmpSequence => "bmp",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aiff",
Format::MxfOp1a => "mxf-op1a",
Format::MxfOpAtom => "mxf-opatom",
}
}
pub fn extension(self) -> &'static str {
match self {
Format::H264 => "mp4",
Format::ProRes | Format::DnxHr | Format::Mjpeg => "mov",
Format::PngSequence => "png",
Format::TiffSequence => "tif",
Format::BmpSequence => "bmp",
Format::Gif => "gif",
Format::Wav => "wav",
Format::Aiff => "aif",
Format::MxfOp1a | Format::MxfOpAtom => "mxf",
}
}
pub fn label(self) -> &'static str {
match self {
Format::H264 => "H.264",
Format::ProRes => "Apple ProRes",
Format::DnxHr => "Avid DNxHR",
Format::Mjpeg => "QuickTime (Motion JPEG)",
Format::PngSequence => "PNG",
Format::TiffSequence => "TIFF",
Format::BmpSequence => "BMP",
Format::Gif => "Animated GIF",
Format::Wav => "Waveform Audio",
Format::Aiff => "AIFF",
Format::MxfOp1a => "MXF OP1a",
Format::MxfOpAtom => "MXF OP-Atom",
}
}
/// An MXF container format.
pub fn is_mxf(self) -> bool {
matches!(self, Format::MxfOp1a | Format::MxfOpAtom)
}
pub const ALL: [Format; 12] = [
Format::H264,
Format::ProRes,
Format::DnxHr,
Format::Mjpeg,
Format::PngSequence,
Format::TiffSequence,
Format::BmpSequence,
Format::Gif,
Format::Wav,
Format::Aiff,
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<TimeRange>,
/// Output scale (1.0 = sequence frame size).
pub scale: f32,
pub include_audio: bool,
/// Quality 0–100 for lossy codecs.
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` or `hq` (empty = HQ).
#[serde(default)]
pub prores_profile: String,
/// DNxHR profile: `lb`, `sq`, `hq` or `hqx` (empty = HQ).
#[serde(default)]
pub dnx_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,
/// 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<FrameRate>,
/// 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<u8>,
pub bitrate_mode: BitrateMode,
/// VBR maximum bitrate (None = 1.5 × target).
pub max_bitrate_kbps: Option<u32>,
/// Adaptive bitrate (the Match Source presets): bits per pixel per frame; replaces
/// `bitrate_kbps` with `width × height × fps × bpp / 1000`.
pub adaptive_bitrate: Option<f32>,
/// Frames between keyframes (None = 2 seconds).
pub keyframe_distance: Option<u32>,
/// 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<String>,
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<OutputSink>,
}
/// Receives in-memory export output: `(path, bytes)` per finished file.
#[derive(Clone)]
pub struct OutputSink(pub Arc<dyn Fn(&str, Vec<u8>) -> 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")
}
}
/// An export's output file: on disk, or in memory for an [`OutputSink`].
enum Out {
File(std::io::BufWriter<std::fs::File>),
Mem(std::io::Cursor<Vec<u8>>),
}
impl Out {
fn create(settings: &ExportSettings) -> Result<Out> {
Out::create_path(settings, &settings.path)
}
/// An output file at `path` (OP-Atom exports write several).
fn create_path(settings: &ExportSettings, path: &str) -> Result<Out> {
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<u64> {
self.finish_path(settings, &settings.path)
}
fn finish_path(self, settings: &ExportSettings, path: &str) -> Result<u64> {
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<u8>) -> Result<u64> {
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<usize> {
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<u64> {
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 self.transfer == 16 {
v.mastering_display = Some(filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001));
v.content_light = Some((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(),
mxf_video_codec: MxfVideoCodec::default(),
sdr: false,
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(),
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,
}
}
}
impl ExportSettings {
/// Reject settings the encoders cannot honour.
pub fn validate(&self) -> Result<()> {
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()));
}
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<String>,
pub error: Mutex<Option<String>>,
/// What loudness normalization measured (when it ran).
pub loudness: Mutex<Option<LoudnessReport>>,
}
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
}
fn set_status(&self, s: impl Into<String>) {
*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<String>,
}
/// A packet produced by a video encoder.
pub struct EncodedPacket {
pub data: Vec<u8>,
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<Vec<EncodedPacket>>;
fn flush(&mut self) -> Result<Vec<EncodedPacket>>;
/// Media start offset for an edit list (B-frame delay), in the encoder timescale.
fn media_start(&self) -> Option<i64> {
None
}
/// First-pass statistics of a two-pass encode (H.264).
fn pass_stats(&self) -> Option<filmcraft_h264enc::PassStats> {
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<f32>]) -> Result<Vec<Vec<u8>>>;
fn flush(&mut self) -> Result<Vec<Vec<u8>>>;
/// Encoder delay (priming) in samples.
fn priming(&self) -> u32;
/// Samples per access unit (1024 for AAC).
fn frame_size(&self) -> u32;
}
pub type EncoderFactory = fn(format: Format, width: u32, height: u32, rate: FrameRate, settings: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>>;
pub type AudioEncoderFactory = fn(format: Format, sample_rate: u32, channels: u32, settings: &ExportSettings) -> Option<Result<Box<dyn AudioEncoder>>>;
fn video_factories() -> &'static RwLock<Vec<EncoderFactory>> {
static F: OnceLock<RwLock<Vec<EncoderFactory>>> = OnceLock::new();
F.get_or_init(|| RwLock::new(vec![h264_factory, prores_factory, dnx_factory, mjpeg_factory]))
}
fn audio_factories() -> &'static RwLock<Vec<AudioEncoderFactory>> {
static F: OnceLock<RwLock<Vec<AudioEncoderFactory>>> = OnceLock::new();
F.get_or_init(|| RwLock::new(vec![aac_factory]))
}
pub fn register_encoder(f: EncoderFactory) {
video_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
}
pub fn register_audio_encoder(f: AudioEncoderFactory) {
audio_factories().write().unwrap_or_else(|e| e.into_inner()).insert(0, f);
}
/// Whether a format can currently be exported. Every [`Format`] has a built-in encoder; this stays
/// as the hook for formats whose encoders are registered at runtime.
pub fn available(format: Format) -> bool {
Format::ALL.contains(&format)
}
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<Vec<EncodedPacket>> {
let rgb: Vec<u8> = 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<Vec<EncodedPacket>> {
Ok(Vec::new())
}
}
fn mjpeg_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
(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<dyn VideoEncoder>))
}
/// 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<f32>]) -> Result<Vec<Vec<u8>>> {
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<Vec<Vec<u8>>> {
Ok(self.enc.flush())
}
fn priming(&self) -> u32 {
self.enc.priming_samples()
}
fn frame_size(&self) -> u32 {
1024
}
}
fn aac_factory(_format: Format, sample_rate: u32, channels: u32, s: &ExportSettings) -> Option<Result<Box<dyn AudioEncoder>>> {
let bps = s.audio.bitrate_kbps.clamp(32, 512) * 1000;
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<dyn AudioEncoder>)
.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,
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<Vec<EncodedPacket>> {
let mut fr = filmcraft_prores::Frame::new(f.width, f.height, filmcraft_prores::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),
}
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<Vec<EncodedPacket>> {
Ok(Vec::new())
}
}
/// 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 s 10BC8 rc = &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,
_ => 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<Result<Box<dyn VideoEncoder>>> {
(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 };
}
Ok(Box::new(ProResEncoder { enc: filmcraft_prores::Encoder::with_config(cfg), profile, w, h, rate, signal: s.signal }) as Box<dyn VideoEncoder>)
})
}
/// 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<Vec<EncodedPacket>> {
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<Vec<EncodedPacket>> {
Ok(Vec::new())
}
}
fn dnx_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
(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<dyn VideoEncoder>)
})
}
/// 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<u8>,
u: Vec<u8>,
v: Vec<u8>,
signal: ColorSignal,
}
impl H264Encoder {
fn packets(&self, ps: Vec<filmcraft_h264enc::Packet>) -> Vec<EncodedPacket> {
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<Vec<EncodedPacket>> {
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<Vec<EncodedPacket>> {
let ps = self.enc.flush();
Ok(self.packets(ps))
}
fn media_start(&self) -> Option<i64> {
// 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<filmcraft_h264enc::PassStats> {
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<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
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<u8>, u: &mut Vec<u8>, v: &mut Vec<u8>) {
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;
}
});
}
fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
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);
let kbps = s.bitrate_kbps.max(100);
let max = s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps * 3 / 2);
cfg.rate = match s.bitrate_mode {
BitrateMode::Cbr => filmcraft_h264enc::RateControl::Cbr { kbps },
_ => 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 s.signal.transfer == 16 {
let md = filmcraft_isobmff::MasteringDisplay::bt2020(1000.0, 0.0001).to_bytes();
let mut b = [0u8; 24];
b.copy_from_slice(&md);
cfg.mastering_display = Some(b);
cfg.content_light = Some((0, 0));
}
}
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<dyn VideoEncoder>)
.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<TimeRange> {
let q = project.sequence(seq).ok_or(ExportError::NoSequence)?;
if let Some(r) = settings.range {
return Ok(r);
}
let fd = q.settings.frame_rate.frame_duration();
let a = q.mark_in.unwrap_or(Tick::ZERO);
let b = q.mark_out.map(|o| o + fd).unwrap_or(q.duration());
Ok(TimeRange::from_bounds(a, b.max(a + fd)))
}
/// 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))
}
/// An in-memory writer that stays reachable after the encoder that owns a clone is dropped.
#[derive(Clone, Default)]
struct SharedBuf(Arc<Mutex<Vec<u8>>>);
impl Write for SharedBuf {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
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<u8>, w: u32, h: u32) -> Result<Vec<u8>> {
encode_still(Format::PngSequence, rgba, w, h)
0