Skip to main content

deepshrink_core/
budget.rs

1//! Pure bitrate-budgeting math for fitting media into a target size.
2//!
3//! All functions here are side-effect free and unit-tested. The classic
4//! two-pass budgeting model (see `docs/PRD/02-cli-spec.md`):
5//!
6//! ```text
7//! usable_bits   = target_bytes * 8 * (1 - overhead)
8//! video_bits    = usable_bits - audio_bitrate * duration
9//! video_bitrate = video_bits / duration
10//! ```
11
12/// Container/muxing overhead reserved from the target budget.
13pub const CONTAINER_OVERHEAD: f64 = 0.02;
14
15/// Default audio bitrate (bits/s) when keeping a track without a hard number.
16pub const DEFAULT_AUDIO_BPS: u64 = 128_000;
17
18/// Below this video bitrate we consider the result not worth encoding → the
19/// engine reports `Infeasible` (exit code 4) rather than producing mush.
20pub const ABSOLUTE_MIN_VIDEO_BPS: u64 = 60_000;
21
22/// A rung of the resolution ladder: a standard height and the minimum video
23/// bitrate (bits/s) below which that height stops being worthwhile for H.264.
24///
25/// Values are heuristics — TO BE TUNED with real VMAF measurements before release.
26#[derive(Debug, Clone, Copy)]
27pub struct Rung {
28    pub height: u32,
29    pub min_video_bps: u64,
30}
31
32/// Standard resolution ladder, highest first.
33pub const LADDER: &[Rung] = &[
34    Rung {
35        height: 1080,
36        min_video_bps: 2_500_000,
37    },
38    Rung {
39        height: 720,
40        min_video_bps: 1_200_000,
41    },
42    Rung {
43        height: 480,
44        min_video_bps: 600_000,
45    },
46    Rung {
47        height: 360,
48        min_video_bps: 350_000,
49    },
50    Rung {
51        height: 240,
52        min_video_bps: 200_000,
53    },
54    Rung {
55        height: 144,
56        min_video_bps: 100_000,
57    },
58];
59
60/// Video bitrate (bits/s) that fits a target size, after reserving audio and
61/// container overhead. Returns `None` if the budget can't hold any video.
62pub fn video_bitrate_bps(target_bytes: u64, duration_sec: f64, audio_bps: u64) -> Option<u64> {
63    if duration_sec <= 0.0 {
64        return None;
65    }
66    let usable_bits = target_bytes as f64 * 8.0 * (1.0 - CONTAINER_OVERHEAD);
67    let audio_bits = audio_bps as f64 * duration_sec;
68    let video_bits = usable_bits - audio_bits;
69    if video_bits <= 0.0 {
70        return None;
71    }
72    Some((video_bits / duration_sec) as u64)
73}
74
75/// Absolute target bytes for a `--reduce <pct>` request: keep `(1 - fraction)`
76/// of the original. `reduce_fraction` is the parsed fraction (0.70 for "70%").
77pub fn reduce_target_bytes(original_bytes: u64, reduce_fraction: f64) -> u64 {
78    let keep = (1.0 - reduce_fraction).clamp(0.0, 1.0);
79    (original_bytes as f64 * keep).round() as u64
80}
81
82/// Choose a target height (≤ source) for the given video bitrate.
83///
84/// Picks the highest ladder rung that (a) is no taller than the source and
85/// (b) has enough bitrate. If the bitrate is below every applicable rung's
86/// minimum, falls back to the smallest rung ≤ source; if the source is smaller
87/// than the whole ladder, keeps the source height.
88pub fn choose_height(src_height: u32, video_bps: u64) -> u32 {
89    for rung in LADDER {
90        if rung.height <= src_height && video_bps >= rung.min_video_bps {
91            return rung.height;
92        }
93    }
94    LADDER
95        .iter()
96        .filter(|r| r.height <= src_height)
97        .map(|r| r.height)
98        .min()
99        .unwrap_or(src_height)
100}
101
102/// Pick the highest audio bitrate from `candidates` (bits/s, descending) that
103/// still leaves room for at least `ABSOLUTE_MIN_VIDEO_BPS` of video. Returns
104/// `None` if even the lowest candidate leaves no viable video budget.
105pub fn fit_audio_bps(target_bytes: u64, duration_sec: f64, candidates: &[u64]) -> Option<u64> {
106    candidates.iter().copied().find(|&audio_bps| {
107        video_bitrate_bps(target_bytes, duration_sec, audio_bps)
108            .is_some_and(|v| v >= ABSOLUTE_MIN_VIDEO_BPS)
109    })
110}
111
112// --- VMAF-targeted CRF search (session 005) ---
113
114/// Find the highest CRF in `lo..=hi` (i.e. the smallest output) whose measured
115/// VMAF still meets `target`. `measure` maps a CRF to its VMAF score and is
116/// assumed monotonically non-increasing in CRF (higher CRF → lower quality).
117///
118/// Returns the chosen CRF and the VMAF it scored. If even `lo` (the best
119/// quality) can't reach `target`, returns `lo` and its score as a best effort.
120/// Runs at most `1 + ceil(log2(hi - lo + 1))` measurements via binary search.
121///
122/// This is the pure, unit-tested form of the search the engine drives with real
123/// encodes (see `engine::media::MediaEngine::run_crf_search`).
124pub fn search_crf(target: f64, lo: u8, hi: u8, mut measure: impl FnMut(u8) -> f64) -> (u8, f64) {
125    let base = measure(lo);
126    // Monotonic: if the best quality misses the target, nothing will.
127    if base < target {
128        return (lo, base);
129    }
130    let mut best = (lo, base);
131    let (mut a, mut b) = (lo as i32 + 1, hi as i32);
132    while a <= b {
133        let mid = a + (b - a) / 2;
134        let v = measure(mid as u8);
135        if v >= target {
136            best = (mid as u8, v);
137            a = mid + 1;
138        } else {
139            b = mid - 1;
140        }
141    }
142    best
143}
144
145// --- Pure-audio budgeting (session 003) ---
146
147/// Standard audio bitrate steps (bits/s, descending). We snap the raw budget
148/// *down* to one of these so the encoded track never exceeds the target.
149pub const AUDIO_STEPS: &[u64] = &[
150    320_000, 256_000, 192_000, 160_000, 128_000, 96_000, 80_000, 64_000, 48_000, 32_000, 24_000,
151    16_000, 12_000,
152];
153
154/// Below this audio bitrate even speech stops being intelligible → the engine
155/// reports `Infeasible` (exit code 4) rather than producing noise.
156pub const ABSOLUTE_MIN_AUDIO_BPS: u64 = 12_000;
157
158/// Raw audio bitrate (bits/s) that fits a target size for a pure-audio file,
159/// after reserving container overhead. `None` if the budget holds nothing.
160pub fn audio_bitrate_bps(target_bytes: u64, duration_sec: f64) -> Option<u64> {
161    if duration_sec <= 0.0 {
162        return None;
163    }
164    let usable_bits = target_bytes as f64 * 8.0 * (1.0 - CONTAINER_OVERHEAD);
165    let bps = usable_bits / duration_sec;
166    if bps <= 0.0 {
167        return None;
168    }
169    Some(bps as u64)
170}
171
172/// Snap a raw bitrate down to the nearest standard step (never above it). The
173/// caller must ensure `bps >= ABSOLUTE_MIN_AUDIO_BPS`, so a step always fits.
174pub fn snap_audio_bitrate(bps: u64) -> u64 {
175    AUDIO_STEPS
176        .iter()
177        .copied()
178        .find(|&step| step <= bps)
179        .unwrap_or_else(|| *AUDIO_STEPS.last().expect("non-empty ladder"))
180}
181
182#[cfg(test)]
183mod tests {
184    use super::*;
185
186    #[test]
187    fn video_bitrate_subtracts_audio_and_overhead() {
188        // 8 MB, 60 s, 128 kbps audio.
189        // usable = 8_000_000*8*0.98 = 62_720_000 bits
190        // audio  = 128_000*60       =  7_680_000 bits
191        // video  = 55_040_000 / 60  ≈ 917_333 bps
192        let v = video_bitrate_bps(8_000_000, 60.0, 128_000).unwrap();
193        assert!((916_000..=918_000).contains(&v), "got {v}");
194    }
195
196    #[test]
197    fn video_bitrate_none_when_audio_eats_budget() {
198        // Tiny target, long duration, fat audio → nothing left for video.
199        assert_eq!(video_bitrate_bps(100_000, 600.0, 128_000), None);
200        assert_eq!(video_bitrate_bps(1_000_000, 0.0, 0), None);
201    }
202
203    #[test]
204    fn reduce_keeps_complement() {
205        assert_eq!(reduce_target_bytes(1_000_000, 0.70), 300_000);
206        assert_eq!(reduce_target_bytes(1_000_000, 0.0), 1_000_000);
207        assert_eq!(reduce_target_bytes(1_000_000, 1.0), 0);
208    }
209
210    #[test]
211    fn choose_height_picks_best_feasible_rung() {
212        // Plenty of bitrate → keep 1080.
213        assert_eq!(choose_height(1080, 4_000_000), 1080);
214        // 1080 needs 2.5M; 800k only clears the 480 rung.
215        assert_eq!(choose_height(1080, 800_000), 480);
216        // 720 source, high bitrate → stays 720 (never upscales).
217        assert_eq!(choose_height(720, 5_000_000), 720);
218        // Very low bitrate → smallest rung ≤ source.
219        assert_eq!(choose_height(1080, 10_000), 144);
220        // Source below the ladder → unchanged.
221        assert_eq!(choose_height(100, 500_000), 100);
222    }
223
224    #[test]
225    fn audio_bitrate_fits_target() {
226        // 10 MB, 3480 s (58 min): usable = 10e6*8*0.98 = 78_400_000 bits
227        // bps = 78_400_000 / 3480 ≈ 22_528
228        let bps = audio_bitrate_bps(10_000_000, 3480.0).unwrap();
229        assert!((22_000..=23_000).contains(&bps), "got {bps}");
230        assert_eq!(audio_bitrate_bps(1_000_000, 0.0), None);
231    }
232
233    #[test]
234    fn snap_audio_floors_to_a_step() {
235        assert_eq!(snap_audio_bitrate(22_528), 16_000);
236        assert_eq!(snap_audio_bitrate(128_000), 128_000);
237        assert_eq!(snap_audio_bitrate(130_000), 128_000);
238        assert_eq!(snap_audio_bitrate(1_000_000), 320_000); // capped
239        assert_eq!(snap_audio_bitrate(12_000), 12_000);
240    }
241
242    #[test]
243    fn search_crf_picks_highest_meeting_target() {
244        // Synthetic monotonic model: VMAF falls 1.5 points per CRF step from a
245        // reference of 100 at CRF 18.
246        let vmaf = |crf: u8| 100.0 - 1.5 * (crf as f64 - 18.0);
247        // Target 91 → highest CRF with vmaf >= 91 is 24 (vmaf 91.0).
248        let (crf, v) = search_crf(91.0, 18, 32, vmaf);
249        assert_eq!(crf, 24);
250        assert!(v >= 91.0);
251        // The next CRF up must dip below the target (we picked the smallest file).
252        assert!(vmaf(crf + 1) < 91.0);
253    }
254
255    #[test]
256    fn search_crf_best_effort_when_unreachable() {
257        // Even the best quality (lo) can't reach the target → return lo.
258        let vmaf = |crf: u8| 80.0 - (crf as f64 - 18.0);
259        let (crf, v) = search_crf(95.0, 18, 32, vmaf);
260        assert_eq!(crf, 18);
261        assert_eq!(v, 80.0);
262    }
263
264    #[test]
265    fn search_crf_keeps_top_quality_when_all_pass() {
266        // Everything clears a very low target → the largest CRF (smallest file).
267        let vmaf = |crf: u8| 100.0 - 0.1 * (crf as f64 - 18.0);
268        let (crf, _) = search_crf(50.0, 18, 32, vmaf);
269        assert_eq!(crf, 32);
270    }
271
272    #[test]
273    fn fit_audio_steps_down() {
274        let candidates = [128_000, 96_000, 64_000, 48_000];
275        // Roomy target → top candidate.
276        assert_eq!(fit_audio_bps(8_000_000, 60.0, &candidates), Some(128_000));
277        // Tight target → 128k leaves too little video, so step down to 96k.
278        assert_eq!(fit_audio_bps(1_300_000, 60.0, &candidates), Some(96_000));
279        // Impossible → None (even 48k audio leaves no viable video budget).
280        assert_eq!(fit_audio_bps(50_000, 600.0, &candidates), None);
281    }
282}