M13.4: HEVC / AV1 output modules (MP4 hvc1/av01, WebM AV1), codec + Opus options in engine, UI, CLI

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas
2026-10-03 05:01:43 -04:00
co-authored by Claude Opus 5.5
parent 3a8b401e81
commit 280f7d509c
15 changed files with 906 additions and 49 deletions
+244 -7
View File
@@ -190,11 +190,18 @@ pub enum OutputFormat {
Wav,
/// Audio only: AIFF (16-bit PCM).
Aiff,
/// HEVC (H.265) video (+ AAC audio) in MP4 (`hvc1`).
Hevc,
/// AV1 video (+ AAC audio) in MP4 (`av01`). WebM with AV1: [`OutputFormat::WebM`] with
/// [`WebmVideoCodec::Av1`].
Av1,
}
impl OutputFormat {
pub const ALL: [OutputFormat; 10] = [
pub const ALL: [OutputFormat; 12] = [
OutputFormat::H264,
OutputFormat::Hevc,
OutputFormat::Av1,
OutputFormat::ProRes,
OutputFormat::WebM,
OutputFormat::PngSequence,
@@ -218,6 +225,8 @@ impl OutputFormat {
OutputFormat::WebM => "WebM (VP9 + Opus)",
OutputFormat::Wav => "WAV",
OutputFormat::Aiff => "AIFF",
OutputFormat::Hevc => "HEVC (H.265)",
OutputFormat::Av1 => "AV1",
}
}
pub fn extension(self) -> &'static str {
@@ -232,29 +241,34 @@ impl OutputFormat {
OutputFormat::WebM => "webm",
OutputFormat::Wav => "wav",
OutputFormat::Aiff => "aif",
OutputFormat::Hevc | OutputFormat::Av1 => "mp4",
}
}
pub fn is_sequence(self) -> bool {
matches!(self, OutputFormat::PngSequence | OutputFormat::JpegSequence | OutputFormat::TiffSequence | OutputFormat::ExrSequence)
}
pub fn is_movie(self) -> bool {
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::WebM)
matches!(self, OutputFormat::H264 | OutputFormat::ProRes | OutputFormat::WebM | OutputFormat::Hevc | OutputFormat::Av1)
}
/// Formats whose video codec options live in [`OutputModule::codec`] (HEVC, AV1).
pub fn has_codec_options(self) -> bool {
matches!(self, OutputFormat::Hevc | OutputFormat::Av1)
}
/// Audio-only outputs (no video is rendered).
pub fn is_audio_only(self) -> bool {
matches!(self, OutputFormat::Wav | OutputFormat::Aiff)
}
pub fn supports_alpha(self) -> bool {
!matches!(self, OutputFormat::H264 | OutputFormat::JpegSequence | OutputFormat::Wav | OutputFormat::Aiff)
!matches!(self, OutputFormat::H264 | OutputFormat::Hevc | OutputFormat::Av1 | OutputFormat::JpegSequence | OutputFormat::Wav | OutputFormat::Aiff)
}
pub fn supports_audio(self) -> bool {
self.is_movie() || self.is_audio_only()
}
/// The written frame size for a rendered size: H.264 needs even dimensions (rounded down,
/// The written frame size for a rendered size: H.264 and HEVC need even dimensions (rounded down,
/// at least 2).
pub fn coded_size(self, w: u32, h: u32) -> (u32, u32) {
let even = |v: u32| if v < 2 { 2 } else { v & !1 };
if self == OutputFormat::H264 { (even(w), even(h)) } else { (w, h) }
if matches!(self, OutputFormat::H264 | OutputFormat::Hevc) { (even(w), even(h)) } else { (w, h) }
}
/// Parse a format name: `h264`, `mp4`, `prores`, `mov`, `png`, `jpeg`/`jpg`, `tiff`/`tif`,
/// `exr`, `gif`, or a label.
@@ -262,6 +276,8 @@ impl OutputFormat {
let n = s.trim().to_ascii_lowercase().replace([' ', '.', '-', '_'], "");
Some(match n.as_str() {
"h264" | "mp4" | "avc" => OutputFormat::H264,
"hevc" | "h265" | "hevch265" | "hvc1" | "x265" => OutputFormat::Hevc,
"av1" | "av01" | "mp4av1" => OutputFormat::Av1,
"prores" | "mov" | "quicktime" | "quicktimeprores" => OutputFormat::ProRes,
"png" | "pngsequence" => OutputFormat::PngSequence,
"jpg" | "jpeg" | "jpegsequence" => OutputFormat::JpegSequence,
@@ -338,6 +354,161 @@ impl ProResProfile {
}
}
/// HEVC / AV1 profile: 8-bit (HEVC Main, AV1 Main 8-bit) or 10-bit (HEVC Main 10, AV1 Main 10-bit).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum CodecProfile {
#[default]
Main,
Main10,
}
impl CodecProfile {
pub const ALL: [CodecProfile; 2] = [CodecProfile::Main, CodecProfile::Main10];
pub fn label(self) -> &'static str {
match self {
CodecProfile::Main => "Main",
CodecProfile::Main10 => "Main 10",
}
}
pub fn bit_depth(self) -> u8 {
match self {
CodecProfile::Main => 8,
CodecProfile::Main10 => 10,
}
}
pub fn from_name(s: &str) -> Option<CodecProfile> {
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"main" | "main8" | "8" | "8bit" => Some(CodecProfile::Main),
"main10" | "10" | "10bit" => Some(CodecProfile::Main10),
_ => None,
}
}
}
/// HEVC / AV1 rate control: a target bitrate ([`OutputModule::bitrate_kbps`]) or constant
/// quality ([`VideoCodecOptions::quality`], CRF-like).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum RateControlMode {
#[default]
Bitrate,
Quality,
}
impl RateControlMode {
pub fn label(self) -> &'static str {
match self {
RateControlMode::Bitrate => "Target Bitrate",
RateControlMode::Quality => "Constant Quality",
}
}
pub fn from_name(s: &str) -> Option<RateControlMode> {
match s.to_ascii_lowercase().replace([' ', '-', '_'], "").as_str() {
"bitrate" | "vbr" | "abr" | "targetbitrate" => Some(RateControlMode::Bitrate),
"quality" | "crf" | "cq" | "constantquality" | "qp" => Some(RateControlMode::Quality),
_ => None,
}
}
}
/// Video codec options of the HEVC and AV1 formats (and AV1 in WebM).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default)]
pub struct VideoCodecOptions {
pub profile: CodecProfile,
/// Level × 10 (`41` = level 4.1); `None` picks the lowest level that fits the frame size and rate.
pub level: Option<u8>,
pub rate_control: RateControlMode,
/// Constant-quality setting 1–100 (100 ≈ visually lossless), used with [`RateControlMode::Quality`].
pub quality: u8,
/// Frames between key frames; 0 = automatic (about 2 seconds).
pub keyframe_interval: u32,
}
impl Default for VideoCodecOptions {
fn default() -> Self {
VideoCodecOptions { profile: CodecProfile::Main, level: None, rate_control: RateControlMode::Bitrate, quality: 70, keyframe_interval: 0 }
}
}
impl VideoCodecOptions {
/// HEVC levels (× 10) offered in the UI (Main tier).
pub const HEVC_LEVELS: [u8; 13] = [10, 20, 21, 30, 31, 40, 41, 50, 51, 52, 60, 61, 62];
/// AV1 levels (× 10) offered in the UI.
pub const AV1_LEVELS: [u8; 14] = [20, 21, 30, 31, 40, 41, 50, 51, 52, 53, 60, 61, 62, 63];
/// The key-frame interval in frames at `fps` (automatic: 2 s).
pub fn keyint(&self, fps: f64) -> u32 {
if self.keyframe_interval > 0 { self.keyframe_interval } else { (fps * 2.0).round().max(1.0) as u32 }
}
/// HEVC QP (0–51) for the constant-quality setting.
pub fn hevc_qp(&self) -> u8 {
(4.0 + (100 - self.quality.clamp(1, 100)) as f64 * 0.45).round() as u8
}
/// AV1 `base_q_idx` (1–255) for the constant-quality setting.
pub fn av1_qindex(&self) -> u8 {
(4.0 + (100 - self.quality.clamp(1, 100)) as f64 * 2.4).round() as u8
}
/// `general_level_idc` (30 × level).
pub fn hevc_level_idc(&self) -> Option<u8> {
self.level.map(|l| l.saturating_mul(3))
}
/// AV1 `seq_level_idx`: (major − 2) × 4 + minor.
pub fn av1_level_idx(&self) -> Option<u8> {
self.level.map(|l| ((l / 10).clamp(2, 7) - 2) * 4 + (l % 10).min(3))
}
pub fn level_label(&self) -> String {
match self.level {
None => "Auto".into(),
Some(l) => format!("{}.{}", l / 10, l % 10),
}
}
/// Parse `"auto"`, `"4.1"`, `"41"` (level × 10). `None` when unparsable; `Some(None)` = auto.
pub fn parse_level(s: &str) -> Option<Option<u8>> {
let s = s.trim().to_ascii_lowercase();
if s == "auto" || s.is_empty() {
return Some(None);
}
let v: f64 = s.trim_start_matches('l').parse().ok()?;
let l = if v >= 10.0 { v.round() } else { (v * 10.0).round() };
(10.0..=73.0).contains(&l).then_some(Some(l as u8))
}
}
/// The video codec of a WebM output.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum WebmVideoCodec {
#[default]
Vp9,
/// AV1 (`V_AV1`) with the [`OutputModule::codec`] options; no alpha.
Av1,
}
impl WebmVideoCodec {
pub fn label(self) -> &'static str {
match self {
WebmVideoCodec::Vp9 => "VP9",
WebmVideoCodec::Av1 => "AV1",
}
}
}
/// Opus coding application: general audio (music and mixed content) or voice (speech-tuned
/// SILK / hybrid coding at low bitrates).
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum OpusApplication {
#[default]
Audio,
Voip,
}
impl OpusApplication {
pub fn label(self) -> &'static str {
match self {
OpusApplication::Audio => "Audio",
OpusApplication::Voip => "Voice",
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(default)]
pub struct OutputModule {
@@ -347,13 +518,20 @@ pub struct OutputModule {
pub output: String,
/// JPEG quality 1–100.
pub quality: u8,
/// H.264 target bitrate.
/// H.264 target bitrate (also HEVC / AV1 with [`RateControlMode::Bitrate`]).
pub bitrate_kbps: u32,
pub prores_profile: ProResProfile,
pub audio: AudioOutput,
pub audio_sample_rate: u32,
/// GIF: loop forever.
pub gif_loop: bool,
/// HEVC / AV1 options.
pub codec: VideoCodecOptions,
/// WebM video codec.
pub webm_codec: WebmVideoCodec,
/// WebM Opus audio bitrate (stereo total), kbit/s.
pub opus_bitrate_kbps: u32,
pub opus_application: OpusApplication,
}
impl Default for OutputModule {
@@ -368,6 +546,10 @@ impl Default for OutputModule {
audio: AudioOutput::Auto,
audio_sample_rate: 48_000,
gif_loop: true,
codec: VideoCodecOptions::default(),
webm_codec: WebmVideoCodec::Vp9,
opus_bitrate_kbps: 192,
opus_application: OpusApplication::Audio,
}
}
}
@@ -384,7 +566,7 @@ impl OutputModule {
pub fn set_format(&mut self, format: OutputFormat) {
let old = self.format;
self.format = format;
if !format.supports_alpha() {
if !self.supports_alpha() {
self.channels = Channels::Rgb;
}
if self.output.contains("[fileExtension]") {
@@ -404,6 +586,20 @@ impl OutputModule {
}
self.output = format!("{stem}.{}", format.extension());
}
/// Whether this module can write alpha (the format can, and WebM uses VP9).
pub fn supports_alpha(&self) -> bool {
self.format.supports_alpha() && !(self.format == OutputFormat::WebM && self.webm_codec == WebmVideoCodec::Av1)
}
/// Codec options summary: `Main · 10000 kbps · Level Auto · Key every auto`.
fn codec_summary(&self) -> String {
let c = &self.codec;
let rate = match c.rate_control {
RateControlMode::Bitrate => format!("{} kbps", self.bitrate_kbps),
RateControlMode::Quality => format!("Quality {}", c.quality),
};
let gop = if c.keyframe_interval == 0 { "auto".to_string() } else { c.keyframe_interval.to_string() };
format!("{} · {rate} · Level {} · Key every {gop}", c.profile.label(), c.level_label())
}
/// One-line summary shown next to "Output Module:".
pub fn summary(&self) -> String {
let ch = match self.channels {
@@ -416,6 +612,10 @@ impl OutputModule {
"{} · {ch}",
if self.channels == Channels::Rgba && !self.prores_profile.is_4444() { "Apple ProRes 4444" } else { self.prores_profile.label() }
),
OutputFormat::Hevc | OutputFormat::Av1 => format!("{} · {}", self.format.label(), self.codec_summary()),
OutputFormat::WebM if self.webm_codec == WebmVideoCodec::Av1 => {
format!("WebM (AV1 + Opus {} kbps) · {}", self.opus_bitrate_kbps, self.codec_summary())
}
f => format!("{} · {ch}", f.label()),
}
}
@@ -632,4 +832,41 @@ mod tests {
assert_eq!(s.first_frame(&c), 30);
assert!(s.frame_time(&c, 0) >= Tick::from_seconds_f64(1.0));
}
#[test]
fn hevc_av1_codec_options() {
assert_eq!(OutputFormat::from_name("H.265"), Some(OutputFormat::Hevc));
assert_eq!(OutputFormat::from_name("hevc"), Some(OutputFormat::Hevc));
assert_eq!(OutputFormat::from_name("AV1"), Some(OutputFormat::Av1));
assert_eq!(OutputFormat::Hevc.coded_size(33, 17), (32, 16));
assert_eq!(OutputFormat::Av1.coded_size(33, 17), (33, 17));
assert!(!OutputFormat::Hevc.supports_alpha() && OutputFormat::Av1.is_movie() && OutputFormat::Av1.supports_audio());
assert_eq!(VideoCodecOptions::parse_level("4.1"), Some(Some(41)));
assert_eq!(VideoCodecOptions::parse_level("51"), Some(Some(51)));
assert_eq!(VideoCodecOptions::parse_level("auto"), Some(None));
assert_eq!(VideoCodecOptions::parse_level("9"), None);
let mut o = VideoCodecOptions { level: Some(41), ..Default::default() };
assert_eq!((o.hevc_level_idc(), o.av1_level_idx()), (Some(123), Some(9)));
o.level = Some(20);
assert_eq!(o.av1_level_idx(), Some(0));
o.quality = 100;
assert_eq!((o.hevc_qp(), o.av1_qindex()), (4, 4));
o.quality = 1;
assert!(o.hevc_qp() <= 51 && o.av1_qindex() >= 240);
assert_eq!(o.keyint(29.97), 60);
o.keyframe_interval = 1;
assert_eq!(o.keyint(29.97), 1);
// WebM with AV1 has no alpha; switching drops RGB + Alpha.
let mut m = OutputModule::for_format(OutputFormat::WebM);
m.channels = Channels::Rgba;
assert!(m.supports_alpha());
m.webm_codec = WebmVideoCodec::Av1;
assert!(!m.supports_alpha());
assert!(m.summary().starts_with("WebM (AV1 + Opus 192 kbps)"), "{}", m.summary());
let h = OutputModule::for_format(OutputFormat::Hevc);
assert_eq!(h.summary(), "HEVC (H.265) · Main · 10000 kbps · Level Auto · Key every auto");
// Older projects (no codec fields) load with the defaults.
let old: OutputModule = serde_json::from_str(r#"{"format":"H264","bitrate_kbps":5000}"#).unwrap();
assert_eq!((old.codec, old.webm_codec, old.opus_bitrate_kbps), (VideoCodecOptions::default(), WebmVideoCodec::Vp9, 192));
}
}