fix(export): a bitrateKbps in settings is the bitrate over an adaptive preset

A `bitrateKbps` given in `settings` was ignored when the starting settings had an adaptive
bitrate (every Match Source preset): `ExportSettings::resolve` uses `adaptive_bitrate` in place of
`bitrate_kbps` whenever it is set, and only the flat `bitrateKbps` cleared it. Over "Match Source -
Adaptive High Bitrate", `settings: {bitrateKbps: 5000}` exported 720x480 at 29.97 at the preset's
2,072 kbps.

It now clears the adaptive bitrate as the flat parameter does, unless `adaptiveBitrate` is given
with it.
This commit is contained in:
Derek Trimm
2026-10-18 20:55:33 -05:00
parent c49efe070f
commit 3bff19a508
3 changed files with 34 additions and 1 deletions
+27
View File
@@ -639,6 +639,33 @@ fn hardware_encoding_is_off_unless_asked_for() {
assert_eq!(old.hardware_encoding, Off);
}
/// A `bitrateKbps` in `settings` is the bitrate, as the flat `bitrateKbps` is, also over a preset
/// whose bitrate adapts to the frame size (every Match Source preset).
#[test]
fn settings_bitrate_replaces_a_presets_adaptive_bitrate() {
use filmcraft_export::ExportSettings;
use filmcraft_export::presets::DEFAULT_PRESET;
use filmcraft_time::FrameRate;
let s = demo();
let settings = |p: &Value| crate::export_tools::settings_from_params(&s, p, "file.exportMedia").map(|(_, st)| st).unwrap();
let target = |st: &ExportSettings| st.resolve(720, 480, FrameRate::FPS_29_97, 48_000).target_kbps;
// the preset alone: 720 x 480 x 29.97 fps x 0.2 bits per pixel
assert_eq!(target(&settings(&json!({"path": "x.mp4", "preset": DEFAULT_PRESET}))), 2072);
for p in [
json!({"path": "x.mp4", "preset": DEFAULT_PRESET, "settings": {"bitrateKbps": 5000}}),
json!({"path": "x.mp4", "preset": DEFAULT_PRESET, "bitrateKbps": 5000}),
json!({"path": "x.mp4", "settings": {"bitrateKbps": 5000}}),
json!({"path": "x.mp4", "bitrateKbps": 5000}),
] {
let st = settings(&p);
assert_eq!(st.adaptive_bitrate, None, "{p}");
assert_eq!(target(&st), 5000, "{p}");
}
// an adaptive bitrate given with it still wins
let p = json!({"path": "x.mp4", "preset": DEFAULT_PRESET, "settings": {"bitrateKbps": 5000, "adaptiveBitrate": 0.1}});
assert_eq!(target(&settings(&p)), 1036);
}
#[test]
fn crf_param_picks_constant_quality() {
use filmcraft_export::BitrateMode::{Cbr, Crf, Vbr1Pass};
+5
View File
@@ -218,6 +218,11 @@ fn apply_nested_settings(settings: &mut ExportSettings, params: &Value, cmd: &st
let mut value = serde_json::to_value(&*settings).map_err(|error| EngineError::Other(error.to_string()))?;
merge(&mut value, patch);
*settings = serde_json::from_value(value).map_err(|error| bad(cmd, format!("settings: {error}")))?;
// A bitrate given in `settings` is the bitrate, as the flat `bitrateKbps` is: an adaptive
// bitrate from the preset would otherwise replace it (`ExportSettings::resolve`).
if patch.get("bitrateKbps").is_some() && patch.get("adaptiveBitrate").is_none() {
settings.adaptive_bitrate = None;
}
// serde ignores unknown keys; detect misspelled or misplaced settings before returning.
let known = serde_json::to_value(&*settings).map_err(|error| EngineError::Other(error.to_string()))?;
if let Some(path) = unknown_key(&known, patch, "") {
+2 -1
View File
@@ -692,7 +692,8 @@ file.exportMedia {path, preset?, settings?, format?, range?, …} export.qui
- **Presets.** `export::presets::builtin_presets()` are our own definitions (Match Source adaptive
H.264 at 0.2 / 0.1 / 0.05 bits per pixel, 1080p / 2160p delivery, vertical 1080×1920, ProRes,
DNxHR, MXF OP1a (DNxHR HQ / ProRes 422 HQ / H.264) and OP-Atom (DNxHR, Avid), image sequences, GIF, WAV / AIFF). User presets and favourites persist in
`<data dir>/export-presets.json` (`export.presets.*`).
`<data dir>/export-presets.json` (`export.presets.*`). A `bitrateKbps` in `settings` turns the
adaptive bitrate off, as the flat parameter does, unless `adaptiveBitrate` comes with it.
- **Queue.** `export.queue.add` snapshots the project and the resolved settings (several sequences
or ranges add several items); `export.queue.start` encodes ready items one after another as
ordinary jobs, advanced by `Session::poll_persistence` / `pump_jobs` (or synchronously with