Export: CRF (constant quality) bitrate encoding for H.264

Adds BitrateMode::Crf with ExportSettings::crf (0-51, default 23) and
maps it to the built-in encoder's RateControl::Crf, which export never
used. The Export panel offers "CRF (constant quality)" with a Quality
(CRF) slider in place of the bitrate fields; file.exportMedia takes
`crf` and `bitrateMode: "crf"`. H.265 (hardware only) rejects it, and
VideoToolbox / NVENC decline it so the built-in encoder runs. Settings
saved before this read as CRF 23.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
JordyUSA 2026-10-10 17:09:05 +07:00
parent f04651adc5
commit 3a46156861
13 changed files with 111 additions and 8 deletions

View File

@ -1154,7 +1154,7 @@ fn build() -> Vec<CommandSpec> {
"Media…",
["File", "Export"],
None,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass"?,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
r#"{"path":str,"preset":str?,"settings":ExportSettings?,"format":"h264|hevc|prores|dnxhr|apv|mjpeg|mxf-op1a|mxf-opatom|png|tiff|bmp|gif|wav|aiff"?,"width":u32?,"height":u32?,"fps":f64?,"bitrateKbps":u32?,"bitrateMode":"cbr|vbr1Pass|vbr2Pass|crf"?,"crf":0..51=23,"hardwareEncoding":"off|auto"?,"scale":f32=1,"audio":bool=true,"quality":0..100,"burnCaptions":bool=false,"alpha":bool=false,"captionSidecar":"srt|vtt"?,"loudnessLufs":f64?,"proresProfile":"proxy|lt|standard|hq"?,"dnxProfile":"lb|sq|hq|hqx"?,"apvProfile":"422-10|422-12|444-10|444-12"?,"mxfVideoCodec":"dnxhr|proRes|h264"?,"sequence":id?,"range":"entire|inOut|workArea|custom"?,"startSeconds":f64?,"endSeconds":f64?,"wait":bool=false}"#,
has_seq,
crate::export_tools::export_media
),

View File

@ -481,6 +481,20 @@ fn hardware_encoding_is_off_unless_asked_for() {
assert_eq!(old.hardware_encoding, Off);
}
#[test]
fn crf_param_picks_constant_quality() {
use filmcraft_export::BitrateMode::{Cbr, Crf, Vbr1Pass};
let s = demo();
let setting = |p: Value| crate::export_tools::settings_from_params(&s, &p, "file.exportMedia").map(|(_, st)| (st.bitrate_mode, st.crf));
assert_eq!(setting(json!({"path": "x.mp4"})).unwrap(), (Vbr1Pass, 23.0));
// a factor alone means CRF mode; the mode can also be named, or another one kept explicitly
assert_eq!(setting(json!({"path": "x.mp4", "crf": 18})).unwrap(), (Crf, 18.0));
assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "crf"})).unwrap(), (Crf, 23.0));
assert_eq!(setting(json!({"path": "x.mp4", "bitrateMode": "cbr", "crf": 18})).unwrap(), (Cbr, 18.0));
let e = setting(json!({"path": "x.mp4", "bitrateMode": "abr"})).unwrap_err().to_string();
assert!(e.contains("crf"), "{e}");
}
#[test]
fn queue_exports_several_sequences_and_ranges() {
let mut s = demo();

View File

@ -26,7 +26,7 @@ use std::sync::Arc;
use std::sync::atomic::Ordering;
use filmcraft_export::presets::{DEFAULT_PRESET, preset_key};
use filmcraft_export::{ExportPreset, ExportSettings, Format, HardwareEncoding};
use filmcraft_export::{BitrateMode, ExportPreset, ExportSettings, Format, HardwareEncoding};
use filmcraft_project::{ItemId, Project};
use filmcraft_time::{FrameRate, Tick, TimeRange};
use serde::{Deserialize, Serialize};
@ -213,7 +213,13 @@ pub fn settings_from_params(s: &Session, p: &Value, cmd: &str) -> Result<(Option
settings.max_bitrate_kbps = Some(v);
}
if let Some(v) = str_p(p, "bitrateMode") {
settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass"))?;
settings.bitrate_mode = serde_json::from_value(json!(v)).map_err(|_| bad(cmd, "bitrateMode: cbr | vbr1Pass | vbr2Pass | crf"))?;
}
if let Some(v) = f64_p(p, "crf") {
settings.crf = v as f32;
if p.get("bitrateMode").is_none() {
settings.bitrate_mode = BitrateMode::Crf;
}
}
if let Some(v) = crate::commands::checked_u32_p(p, "keyframeDistance", cmd)? {
settings.keyframe_distance = Some(v);

View File

@ -274,6 +274,10 @@ pub struct ExportSettings {
/// stream needs is raised.
pub h264_level: Option<u8>,
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`).
@ -467,6 +471,7 @@ impl Default for ExportSettings {
h264_profile: H264Profile::High,
h264_level: None,
bitrate_mode: BitrateMode::default(),
crf: DEFAULT_CRF,
hardware_encoding: HardwareEncoding::default(),
max_bitrate_kbps: None,
adaptive_bitrate: None,
@ -485,6 +490,13 @@ impl Default for ExportSettings {
}
}
/// [`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<()> {
@ -519,6 +531,14 @@ 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.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.crf.is_finite() || !(0.0..=51.0).contains(&self.crf) {
return Err(ExportError::Unsupported("CRF must be between 0 and 51".into()));
}
}
Ok(())
}
}
@ -1344,6 +1364,8 @@ fn h264_factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSetti
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 {

View File

@ -86,6 +86,9 @@ pub enum BitrateMode {
Vbr1Pass,
/// Variable bitrate, two passes (the picture is rendered and analysed first).
Vbr2Pass,
/// Constant quality: every frame is coded at [`ExportSettings::crf`] and the bitrate follows the
/// picture (H.264, built-in encoder only).
Crf,
}
impl BitrateMode {
@ -94,6 +97,7 @@ impl BitrateMode {
BitrateMode::Cbr => "CBR",
BitrateMode::Vbr1Pass => "VBR, 1 pass",
BitrateMode::Vbr2Pass => "VBR, 2 pass",
BitrateMode::Crf => "CRF (constant quality)",
}
}
}
@ -504,6 +508,7 @@ impl ExportSettings {
);
v += &match self.bitrate_mode {
BitrateMode::Cbr => format!(", {}", mbps(r.target_kbps)),
BitrateMode::Crf => format!(" {}", self.crf),
_ => format!(", Target {}, Max {}", mbps(r.target_kbps), mbps(r.max_kbps)),
};
v += &format!(", keyframe every {} frames", r.keyint);

View File

@ -324,6 +324,40 @@ fn two_pass_and_cbr_h264() {
}
}
#[test]
fn crf_h264() {
// settings saved before CRF existed read as the default; the range is checked
let old: ExportSettings = serde_json::from_value(serde_json::json!({"format": "h264", "bitrateMode": "vbr1Pass"})).unwrap();
assert_eq!(old.crf, DEFAULT_CRF);
let c: ExportSettings = serde_json::from_value(serde_json::json!({"bitrateMode": "crf", "crf": 18.0})).unwrap();
assert_eq!((c.bitrate_mode, c.crf), (BitrateMode::Crf, 18.0));
for bad in [-1.0, 51.5, f32::NAN, f32::INFINITY] {
assert!(ExportSettings { bitrate_mode: BitrateMode::Crf, crf: bad, ..Default::default() }.validate().is_err(), "{bad}");
}
// the value only matters in CRF mode
assert!(ExportSettings { crf: f32::NAN, ..Default::default() }.validate().is_ok());
let summary = c.summary(1920, 1080, FrameRate::FPS_25, 48_000, Tick(10 * TICKS_PER_SECOND));
assert!(summary.video.contains("CRF (constant quality) 18"), "{}", summary.video);
// one pass at constant quality: a lower factor spends more bits, and both decode to the picture
let (p, seq, m) = matte([0.2, 0.5, 0.8, 1.0], 320, 180, None);
let dir = Scratch::new("crf");
let mut sizes = Vec::new();
for crf in [10.0, 40.0] {
let path = dir.path(&format!("crf{crf}.mp4"));
let s = ExportSettings { format: Format::H264, path: path.clone(), bitrate_mode: BitrateMode::Crf, crf, include_audio: false, ..Default::default() };
let prog = Progress::default();
let r = export(&p, seq, &s, &m, &prog).unwrap();
assert_eq!((r.frames, prog.total.load(Ordering::Relaxed)), (24, 24));
let bytes: Arc<[u8]> = std::fs::read(&path).unwrap().into();
sizes.push(bytes.len());
let src = filmcraft_codecs::open_bytes("x.mp4", bytes).unwrap();
let f = src.video_frame(FrameRequest::full(Tick(TICKS_PER_SECOND / 2))).unwrap().to_rgba8();
assert!((f[2] as i32 - 204).abs() < 12 && (f[1] as i32 - 127).abs() < 12, "CRF {crf}: {:?}", &f[..4]);
}
assert!(sizes[0] > sizes[1], "CRF 10 {} bytes, CRF 40 {} bytes", sizes[0], sizes[1]);
}
#[test]
fn h264_profile_level_and_keyframes() {
let (p, seq, m) = matte([0.5, 0.5, 0.5, 1.0], 320, 180, None);
@ -516,6 +550,8 @@ fn h265_is_a_format_that_needs_a_registered_encoder() {
let e = ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..h.clone() }.validate().unwrap_err();
assert!(e.to_string().contains("two-pass"), "{e}");
assert!(ExportSettings { bitrate_mode: BitrateMode::Cbr, ..h.clone() }.validate().is_ok());
let e = ExportSettings { bitrate_mode: BitrateMode::Crf, ..h.clone() }.validate().unwrap_err();
assert!(e.to_string().contains("CRF"), "{e}");
// formats with a built-in encoder are always available; this one only when a registered probe says so
assert!(Format::ALL.iter().filter(|f| f.has_builtin_encoder()).all(|f| available(*f)));

View File

@ -33,6 +33,9 @@ pub fn config_for(w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Result
if s.bitrate_mode == BitrateMode::Vbr2Pass || !matches!(s.h264_pass, H264Pass::Single) {
return Err("two-pass encoding".into());
}
if s.bitrate_mode == BitrateMode::Crf {
return Err("CRF (constant quality)".into());
}
if s.signal.is_hdr() {
return Err("HDR".into());
}

View File

@ -162,6 +162,9 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
if !matches!(s.h264_pass, H264Pass::Single) || (hevc && s.bitrate_mode == BitrateMode::Vbr2Pass) {
return Err("two-pass VBR".into());
}
if s.bitrate_mode == BitrateMode::Crf {
return Err("CRF (constant quality)".into());
}
// the hardware path writes the aspect ratio of H.264 only
if hevc && s.pixel_aspect.is_some_and(|(n, d)| n != d) {
return Err("non-square pixels".into());
@ -357,6 +360,7 @@ mod tests {
("an unwritable colour signal", ExportSettings { signal: ColorSignal { primaries: 9, transfer: 1, matrix: 1 }, ..ok.clone() }),
("HDR with BT.709 primaries", ExportSettings { signal: ColorSignal { primaries: 1, transfer: 16, matrix: 9 }, ..ok.clone() }),
("two-pass", ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }),
("CRF", ExportSettings { bitrate_mode: BitrateMode::Crf, ..ok.clone() }),
("analysis pass", ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }),
("interlaced", ExportSettings { field_order: FieldOrder::UpperFirst, ..ok.clone() }),
("non-square pixels", ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }),

View File

@ -106,6 +106,7 @@ fn declines_what_the_hardware_path_does_not_take() {
let declined = |s: &ExportSettings, what: &str| assert!(config_for(W, H, FPS, s).is_err(), "{what} must not use the hardware encoder");
declined(&ExportSettings { hardware_encoding: HardwareEncoding::Off, ..ok.clone() }, "hardware encoding off");
declined(&ExportSettings { bitrate_mode: BitrateMode::Vbr2Pass, ..ok.clone() }, "two-pass VBR");
declined(&ExportSettings { bitrate_mode: BitrateMode::Crf, ..ok.clone() }, "CRF");
declined(&ExportSettings { h264_pass: H264Pass::First, ..ok.clone() }, "an analysis pass");
declined(&ExportSettings { signal: filmcraft_export::ColorSignal::PQ, ..ok.clone() }, "HDR");
declined(&ExportSettings { pixel_aspect: Some((4, 3)), ..ok.clone() }, "non-square pixels");

View File

@ -2893,6 +2893,7 @@
Target Bitrate Velocidad de bits objetivo
Adaptive ({bpp} bits per pixel) Adaptable ({bpp} bits por píxel)
Maximum Bitrate Velocidad de bits máxima
Quality (CRF) Calidad (CRF)
Key Frame Distance Distancia entre fotogramas clave
Quality Calidad
Render at Maximum Depth Procesar a la máxima profundidad
@ -3297,6 +3298,7 @@
Upper First Superior primero
VBR, 1 pass VBR, 1 pasada
VBR, 2 pass VBR, 2 pasadas
CRF (constant quality) CRF (calidad constante)
Vimeo 1080p Full HD Vimeo 1080p Full HD
Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD
Voice-over Record Grabación de voz en off

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

View File

@ -2887,6 +2887,7 @@
Target Bitrate ターゲットビットレート
Adaptive ({bpp} bits per pixel) アダプティブ (1 ピクセルあたり {bpp} ビット)
Maximum Bitrate 最大ビットレート
Quality (CRF) 品質 (CRF)
Key Frame Distance キーフレーム間隔
Quality 画質
Render at Maximum Depth 最大深度でレンダリングする
@ -3291,6 +3292,7 @@
Upper First 上位フィールド優先
VBR, 1 pass VBR、1 パス
VBR, 2 pass VBR、2 パス
CRF (constant quality) CRF (品質一定)
Vimeo 1080p Full HD Vimeo 1080p Full HD
Vimeo 2160p 4K Ultra HD Vimeo 2160p 4K Ultra HD
Voice-over Record ナレーションを録音

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

View File

@ -557,7 +557,7 @@ mod tests {
ex::MxfVideoCodec::ALL.iter().for_each(|c| push(&mut out, c.label()));
ex::Placement::ALL.iter().for_each(|p| push(&mut out, p.label()));
[ex::FieldOrder::Progressive, ex::FieldOrder::UpperFirst, ex::FieldOrder::LowerFirst].iter().for_each(|f| push(&mut out, f.label()));
[ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass].iter().for_each(|m| push(&mut out, m.label()));
[ex::BitrateMode::Cbr, ex::BitrateMode::Vbr1Pass, ex::BitrateMode::Vbr2Pass, ex::BitrateMode::Crf].iter().for_each(|m| push(&mut out, m.label()));
[ex::H264Profile::Baseline, ex::H264Profile::Main, ex::H264Profile::High].iter().for_each(|p| push(&mut out, p.label()));
crate::panels::export_mode::RANGES.iter().for_each(|(_, l)| push(&mut out, l));
crate::panels::export_mode::PARS.iter().for_each(|(l, _)| push(&mut out, l));

View File

@ -642,14 +642,22 @@ fn video_section(ui: &mut egui::Ui, reg: &mut Reg, s: &mut ExportSettings, t: &T
});
}
row(ui, t, tl!("Bitrate Encoding"), |ui| {
// a hardware encoder has one pass: H.265 offers no two-pass mode
let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass];
let labels: Vec<(String, bool)> = o.iter().map(|m| (m.label().to_string(), !(hevc && *m == BitrateMode::Vbr2Pass))).collect();
// a hardware encoder has one pass and no constant-quality mode: H.265 offers neither
let o = [BitrateMode::Cbr, BitrateMode::Vbr1Pass, BitrateMode::Vbr2Pass, BitrateMode::Crf];
let labels: Vec<(String, bool)> =
o.iter().map(|m| (m.label().to_string(), !(hevc && matches!(m, BitrateMode::Vbr2Pass | BitrateMode::Crf)))).collect();
if let Some(i) = combo(ui, reg, "export.video.bitrateMode", s.bitrate_mode.label(), &labels, 140.0) {
s.bitrate_mode = o[i];
}
});
if let Some(bpp) = s.adaptive_bitrate {
if s.bitrate_mode == BitrateMode::Crf {
row(ui, t, tl!("Quality (CRF)"), |ui| {
let mut crf = if s.crf.is_finite() { f64::from(s.crf) } else { f64::from(filmcraft_engine::export::DEFAULT_CRF) };
if drag(ui, reg, "export.video.crf", &mut crf, 0.0..=51.0, 0.1, "", 0) {
s.crf = crf.round() as f32;
}
});
} else if let Some(bpp) = s.adaptive_bitrate {
row(ui, t, tl!("Target Bitrate"), |ui| {
ui.label(egui::RichText::new(tlf!("Adaptive ({bpp} bits per pixel)", bpp)).size(12.0));
let r = ui.small_button(tl!("Set"));