HW6: AV1 export format, enabled where a hardware encoder registers one (#30)

Format::Av1 in export settings and jobs, SampleEntry::av1 (av01 + av1C) in
isobmff, and the Export panel's Format list, profile and encoder rows. No
encoder ships here: like H.265, AV1 is listed greyed out until a platform
encoder registers a probe, and a job without one fails with "AV1 encoder not
available yet". Split out of #409; the VA-API AV1 encoder follows on main's
vaapi::va design.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Manuel Camacho 2026-10-10 10:46:20 -07:00
parent 98ba9acbce
commit 7f4bf2157b
9 changed files with 106 additions and 30 deletions

View File

@ -72,7 +72,7 @@ use rayon::prelude::*;
use crate::audio_out::AudioOut;
use crate::mxf_out::{MxfMux, MxfSetup};
use crate::pipeline::Pipeline;
use crate::settings::{AudioCodec, BitrateMode, Multiplexer};
use crate::settings::{AudioCodec, BitrateMode};
use crate::{
AudioEncoder, ColorSignal, EncodedPacket, EncoderFrame, ExportError, ExportSettings, Format, H264Pass, Out, Progress, Report, Result, Stage, VideoEncoder,
audio_factories, export_range, frame_span, note_stage, timed, video_factories,
@ -186,7 +186,10 @@ pub struct Exporter {
/// Whether [`Exporter`] handles a format.
pub fn stepped(format: Format) -> bool {
matches!(format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom)
matches!(
format,
Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom
)
}
/// Whether an export is HDR: an HDR working space, SDR not asked for, and a format whose encoder
@ -249,7 +252,7 @@ impl Exporter {
progress.set_status(format!("Exporting {} frames ({})", nframes, settings.format.label()));
}
let venc = make_venc(&settings, pipe.w, pipe.h, pipe.rate)?;
let brand = if settings.format.is_h26x() && settings.multiplexer == Multiplexer::Mp4 { Brand::Mp4 } else { Brand::Mov };
let brand = if settings.format.is_mp4_with(settings.multiplexer) { Brand::Mp4 } else { Brand::Mov };
let audio = if settings.has_audio() { Some(AudioOut::new(project.clone(), seq, &settings, range)?) } else { None };
let aenc: Option<Box<dyn AudioEncoder>> = match (&audio, settings.audio_codec()) {
(Some(a), AudioCodec::Aac) => {

View File

@ -70,6 +70,10 @@ pub enum Format {
/// ([`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,
@ -112,6 +116,7 @@ impl Format {
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,
@ -132,6 +137,7 @@ impl Format {
match self {
Format::H264 => "h264",
Format::Hevc => "hevc",
Format::Av1 => "av1",
Format::ProRes => "prores",
Format::DnxHr => "dnxhr",
Format::Apv => "apv",
@ -148,7 +154,7 @@ impl Format {
}
pub fn extension(self) -> &'static str {
match self {
Format::H264 | Format::Hevc => "mp4",
Format::H264 | Format::Hevc | Format::Av1 => "mp4",
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "mov",
Format::PngSequence => "png",
Format::TiffSequence => "tif",
@ -163,6 +169,7 @@ impl Format {
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",
@ -184,15 +191,24 @@ impl Format {
/// 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 {
self != Format::Hevc
!matches!(self, Format::Hevc | Format::Av1)
}
/// 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)
}
pub const ALL: [Format; 14] = [
/// 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; 15] = [
Format::H264,
Format::Hevc,
Format::Av1,
Format::ProRes,
Format::DnxHr,
Format::Apv,
@ -524,6 +540,9 @@ impl ExportSettings {
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()));
}
Ok(())
}
}
@ -1641,7 +1660,7 @@ pub fn export(project: &Arc<Project>, seq: ItemId, settings: &ExportSettings, so
}
(total, count)
}
Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg | Format::MxfOp1a | Format::MxfOpAtom => {
// 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)));
}

View File

@ -422,8 +422,8 @@ impl ExportSettings {
pub fn audio_codec(&self) -> AudioCodec {
match (self.format, self.audio.codec) {
(Format::Wav | Format::Aiff | Format::MxfOp1a | Format::MxfOpAtom, _) => AudioCodec::Pcm,
(f, _) if f.is_h26x() && self.multiplexer == Multiplexer::Mp4 => AudioCodec::Aac,
(_, AudioCodec::Auto) if self.format.is_h26x() => AudioCodec::Aac,
(f, _) if f.is_mp4_with(self.multiplexer) => AudioCodec::Aac,
(_, AudioCodec::Auto) if self.format.is_mp4_video() => AudioCodec::Aac,
(_, AudioCodec::Auto) => AudioCodec::Pcm,
(_, c) => c,
}
@ -451,7 +451,7 @@ impl ExportSettings {
let fps = r.rate.num as f64 / r.rate.den as f64;
let px = r.width as f64 * r.height as f64;
let video_bps = match self.video_format() {
Format::H264 | Format::Hevc => r.target_kbps as f64 * 1000.0,
Format::H264 | Format::Hevc | Format::Av1 => r.target_kbps as f64 * 1000.0,
Format::ProRes => crate::prores_profile(&self.prores_profile).nominal_mbps_1080p30() * 1e6 * px / (1920.0 * 1080.0) * fps / 29.97,
Format::DnxHr => {
// nominal 1080p29.97 data rates of the DNxHR profiles (Mb/s)
@ -534,6 +534,14 @@ impl ExportSettings {
};
v += &format!(", keyframe every {} frames", r.keyint);
}
Format::Av1 => {
v += &format!(", AV1 Main (hardware encoder), {}", self.bitrate_mode.label());
v += &match self.bitrate_mode {
BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)),
_ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)),
};
v += &format!(", keyframe every {} frames", r.keyint);
}
Format::ProRes => {
use filmcraft_prores::Profile;
v += match crate::prores_profile(&self.prores_profile) {
@ -565,7 +573,7 @@ impl ExportSettings {
};
let container = match self.format {
Format::H264 | Format::Hevc if self.multiplexer == Multiplexer::Mov => "QuickTime",
Format::H264 | Format::Hevc => "MP4",
Format::H264 | Format::Hevc | Format::Av1 => "MP4",
Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg => "QuickTime",
Format::MxfOp1a | Format::MxfOpAtom => self.mxf_video_codec.label(),
Format::PngSequence | Format::TiffSequence | Format::BmpSequence => "Image sequence",

View File

@ -487,6 +487,37 @@ fn a_time_left_reads_like_a_clock() {
assert!(!format_eta(Duration::MAX).is_empty(), "the largest duration cannot overflow");
}
#[test]
fn av1_is_an_mp4_format_that_needs_a_registered_encoder() {
for name in ["av1", "AV1", "av01"] {
assert_eq!(Format::from_name(name), Some(Format::Av1), "{name}");
}
assert_eq!(Format::from_name(Format::Av1.id()), Some(Format::Av1));
assert_eq!((Format::Av1.extension(), Format::Av1.label()), ("mp4", "AV1"));
assert!(Format::ALL.contains(&Format::Av1));
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "av1"})).unwrap();
assert_eq!(s.format, Format::Av1);
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "av1");
// MP4 with AAC only: a QuickTime multiplexer setting left over from H.264 does not apply
let a = ExportSettings { format: Format::Av1, ..Default::default() };
assert_eq!((a.audio_codec(), a.extension()), (AudioCodec::Aac, "mp4"));
let mov = ExportSettings { multiplexer: Multiplexer::Mov, ..a.clone() };
assert_eq!((mov.audio_codec(), mov.extension()), (AudioCodec::Aac, "mp4"));
let b = a.estimate_bytes(1920, 1088, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND));
assert!((b as f64 - 25.4e6).abs() < 0.2e6, "{b}");
let summary = a.summary(1920, 1088, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND));
assert_eq!(summary.format, "AV1 (MP4)");
assert!(summary.video.contains("AV1 Main"), "{}", summary.video);
let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..a.clone() }.validate().unwrap_err();
assert!(e.to_string().contains("two-pass"), "{e}");
assert!(!Format::Av1.has_builtin_encoder());
assert!(!available(Format::Av1), "nothing registered an encoder for it");
let (p, seq, m) = matte([0.0, 0.0, 1.0, 1.0], 160, 96, None);
let sc = Scratch::new("av1-missing");
let err = export(&p, seq, &ExportSettings { path: sc.path("x.mp4"), include_audio: false, ..a.clone() }, &m, &Progress::default()).unwrap_err().to_string();
assert!(err.contains("AV1") && err.contains("encoder not available"), "{err}");
}
#[test]
fn h265_is_a_format_that_needs_a_registered_encoder() {
use std::sync::atomic::{AtomicBool, Ordering};
@ -496,7 +527,7 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
}
assert_eq!(Format::from_name(Format::Hevc.id()), Some(Format::Hevc));
assert_eq!((Format::Hevc.extension(), Format::Hevc.label()), ("mp4", "H.265 (HEVC)"));
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 14);
assert!(Format::ALL.contains(&Format::Hevc) && Format::ALL.len() == 15);
let s: ExportSettings = serde_json::from_value(serde_json::json!({"format": "hevc"})).unwrap();
assert_eq!(s.format, Format::Hevc);
assert_eq!(serde_json::to_value(&s).unwrap()["format"], "hevc");

View File

@ -1275,6 +1275,10 @@ impl SampleEntry {
pub fn hevc(cfg: HevcConfig, width: u16, height: u16) -> Self {
Self::video(FourCc(*b"hvc1"), CodecConfig::Hevc(cfg), width, height)
}
/// AV1 entry (`av01`).
pub fn av1(cfg: Av1Config, width: u16, height: u16) -> Self {
Self::video(FourCc(*b"av01"), CodecConfig::Av1(cfg), width, height)
}
/// ProRes entry; `fourcc` one of `apch`/`apcn`/`apcs`/`apco`/`ap4h`/`ap4x`.
pub fn prores(fourcc: FourCc, width: u16, height: u16) -> Self {
let mut e = Self::video(fourcc, CodecConfig::ProRes { fourcc }, width, height);

View File

@ -3062,6 +3062,7 @@
Go to Sequence End Ir al final de la secuencia
Go to Sequence Start Ir al inicio de la secuencia
H.264 H.264
AV1 AV1
H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080 para sitios de video, VBR 1 pasada, 16 Mbps
H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080, VBR 1 pasada, 20 Mbps objetivo, AAC 320 kbps
H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080, VBR 2 pasadas, 18 Mbps
@ -3415,6 +3416,7 @@
Main (8-bit; Main 10 for HDR sequences) Main (8 bits; Main 10 para secuencias HDR)
Encoder Codificador
Hardware (H.265 has no software encoder) Hardware (H.265 no tiene codificador por software)
Hardware (AV1 has no software encoder) Hardware (AV1 no tiene codificador por software)
Text Properties Propiedades de texto
Text Box Height Altura del cuadro de texto
Paragraph Text Texto de párrafo

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -3057,6 +3057,7 @@
Go to Sequence End シーケンスの終了位置へ移動
Go to Sequence Start シーケンスの開始位置へ移動
H.264 H.264
AV1 AV1
H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps 動画共有サイト向けの H.264 1920×1080、VBR 1 パス、16 Mbps
H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080、VBR 1 パス、ターゲット 20 Mbps、AAC 320 kbps
H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080、VBR 2 パス、18 Mbps
@ -3410,6 +3411,7 @@
Main (8-bit; Main 10 for HDR sequences) メイン (8 ビット、HDR シーケンスではメイン 10)
Encoder エンコーダー
Hardware (H.265 has no software encoder) ハードウェア (H.265 にはソフトウェアエンコーダーがありません)
Hardware (AV1 has no software encoder) ハードウェア (AV1 にはソフトウェアエンコーダーがありません)
Text Properties テキストのプロパティ
Text Box Height テキストボックスの高さ
Paragraph Text 段落テキスト

Can't render this file because it contains an unexpected character in line 11 and column 59.

View File

@ -3050,6 +3050,7 @@
Go to Sequence End 转到序列末尾
Go to Sequence Start 转到序列开头
H.264 H.264
AV1 AV1
H.264 1920×1080 for video sharing sites, VBR 1 pass, 16 Mbps H.264 1920×1080(视频分享网站),VBR 1 遍,16 Mbps
H.264 1920×1080, VBR 1 pass, 20 Mbps target, AAC 320 kbps H.264 1920×1080,VBR 1 遍,目标 20 Mbps,AAC 320 kbps
H.264 1920×1080, VBR 2 pass, 18 Mbps H.264 1920×1080,VBR 2 遍,18 Mbps
@ -3403,6 +3404,7 @@
Main (8-bit; Main 10 for HDR sequences) 主要(8 位;HDR 序列使用主要 10)
Encoder 编码器
Hardware (H.265 has no software encoder) 硬件(H.265 无软件编码器)
Hardware (AV1 has no software encoder) 硬件(AV1 无软件编码器)
Text Properties 文本属性
Text Box Height 文本框高度
Paragraph Text 段落文本

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -473,7 +473,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
}
if (s.has_audio()
|| !s.has_video()
|| s.format.is_h26x()
|| s.format.is_mp4_video()
|| matches!(s.format, Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
|| s.format.is_mxf())
&& !s.is_image_sequence()
@ -521,7 +521,7 @@ fn settings_column(app: &mut FilmcraftApp, ui: &mut egui::Ui, rect: Rect) {
if section(ui, &mut reg, &mut ex.open_sections, "effects", tl!("Effects"), &t) {
pick_overlay = effects_section(ui, &mut reg, s, &t);
}
if matches!(s.format, Format::H264 | Format::Hevc | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
if matches!(s.format, Format::H264 | Format::Hevc | Format::Av1 | Format::ProRes | Format::DnxHr | Format::Apv | Format::Mjpeg)
&& section(ui, &mut reg, &mut ex.open_sections, "metadata", tl!("Metadata"), &t)
{
let m = &mut s.metadata;
@ -612,17 +612,23 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
});
}
match s.video_format() {
f @ (Format::H264 | Format::Hevc) => {
let hevc = f == Format::Hevc;
f @ (Format::H264 | Format::Hevc | Format::Av1) => {
// H.265 and AV1: hardware encoders only (one profile, level chosen by the encoder)
let hevc = f != Format::H264;
if hevc {
// the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder
let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) {
(true, true) => tl!("Main 10 (10-bit, HDR)"),
(true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"),
(false, _) => tl!("Main (8-bit)"),
};
row(ui, t, tl!("Profile"), |ui| ui.label(label));
row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)")));
if f == Format::Av1 {
row(ui, t, tl!("Profile"), |ui| ui.label(tl!("Main (8-bit)")));
row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (AV1 has no software encoder)")));
} else {
// the hardware encoder writes Main (8-bit 4:2:0), or Main 10 for HDR sequences where it can; level chosen by the encoder
let label = match (filmcraft_engine::export::hdr_available(Format::Hevc), seq_hdr && !s.sdr) {
(true, true) => tl!("Main 10 (10-bit, HDR)"),
(true, false) => tl!("Main (8-bit; Main 10 for HDR sequences)"),
(false, _) => tl!("Main (8-bit)"),
};
row(ui, t, tl!("Profile"), |ui| ui.label(label));
row(ui, t, tl!("Encoder"), |ui| ui.label(tl!("Hardware (H.265 has no software encoder)")));
}
} else {
row(ui, t, tl!("Profile"), |ui| {
let o = [H264Profile::Baseline, H264Profile::Main, H264Profile::High];
@ -686,9 +692,9 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
}
});
// The system's hardware encoder where there is one (VideoToolbox on macOS, NVENC on NVIDIA
// GPUs on Windows); everything it does not take (two-pass, HDR, MXF) and every machine
// without one keeps the built-in encoder.
// H.265 has only the hardware encoder: choosing the format is the opt-in.
// GPUs on Windows, VA-API on Linux); everything it does not take (two-pass, HDR, MXF) and
// every machine without one keeps the built-in encoder.
// H.265 and AV1 have only hardware encoders: choosing the format is the opt-in.
if !hevc {
row(ui, t, "Hardware Encoding", |ui| {
let mut on = s.hardware_encoding == HardwareEncoding::Auto;
@ -758,13 +764,12 @@ fn audio_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
}
}
row(ui, t, tl!("Audio Format"), |ui| {
let fixed = s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4 || audio_only || s.format.is_mxf();
let fixed = s.format.is_mp4_with(s.multiplexer) || audio_only || s.format.is_mxf();
let cur = match s.audio_codec() {
AudioCodec::Aac => "AAC",
_ => tl!("Uncompressed (PCM)"),
};
let labels =
vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !(s.format.is_h26x() && s.multiplexer == Multiplexer::Mp4))];
let labels = vec![("AAC".to_string(), !audio_only), (tl!("Uncompressed (PCM)").to_string(), !s.format.is_mp4_with(s.multiplexer))];
if fixed {
ui.label(cur);
} else if let Some(i) = combo(ui, reg, "export.audio.codec", cur, &labels, 180.0) {