Skip to main content

oximedia_transcode/
per_scene_encode.rs

1//! Per-scene adaptive encoding parameters.
2//!
3//! This module provides scene-aware encoding parameter computation,
4//! output size estimation, bitrate solving via binary search, and
5//! budget allocation across multiple scenes.
6
7use serde::{Deserialize, Serialize};
8
9// ─── SceneType ────────────────────────────────────────────────────────────────
10
11/// Classification of a scene's content type for encoding optimization.
12#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
13pub enum SceneType {
14    /// Mostly still content — logos, title cards, pause frames.
15    Static,
16    /// Slow panning or gentle motion.
17    SlowMotion,
18    /// Fast action, sports, chase sequences.
19    ActionFast,
20    /// Talking-head content, interviews, news.
21    Talking,
22    /// End credits or scrolling text.
23    Credits,
24    /// Animated content — cartoons, CGI.
25    Animation,
26    /// High spatial and temporal complexity.
27    HighComplexity,
28}
29
30// ─── SceneSegment ─────────────────────────────────────────────────────────────
31
32/// A contiguous segment of frames identified as a distinct scene.
33#[derive(Debug, Clone, Serialize, Deserialize)]
34pub struct SceneSegment {
35    /// Index of the first frame (inclusive).
36    pub start_frame: u64,
37    /// Index of the last frame (inclusive).
38    pub end_frame: u64,
39    /// Number of frames in this segment.
40    pub duration_frames: u32,
41    /// Normalised motion score in \[0.0, 1.0\].
42    pub motion_score: f32,
43    /// Normalised spatial complexity in \[0.0, 1.0\].
44    pub complexity: f32,
45    /// Whether the scene is predominantly dark (low average luma).
46    pub is_dark: bool,
47    /// Content-type classification.
48    pub scene_type: SceneType,
49}
50
51impl SceneSegment {
52    /// Constructs a new `SceneSegment` with explicit fields.
53    #[must_use]
54    pub fn new(
55        start_frame: u64,
56        end_frame: u64,
57        duration_frames: u32,
58        motion_score: f32,
59        complexity: f32,
60        is_dark: bool,
61        scene_type: SceneType,
62    ) -> Self {
63        Self {
64            start_frame,
65            end_frame,
66            duration_frames,
67            motion_score,
68            complexity,
69            is_dark,
70            scene_type,
71        }
72    }
73
74    /// Returns the number of frames (alias for clarity).
75    #[must_use]
76    pub fn frame_count(&self) -> u32 {
77        self.duration_frames
78    }
79
80    /// Returns the duration in seconds at the given frame rate.
81    #[must_use]
82    pub fn duration_secs(&self, fps: f32) -> f32 {
83        if fps <= 0.0 {
84            return 0.0;
85        }
86        self.duration_frames as f32 / fps
87    }
88}
89
90// ─── SceneEncodeParams ────────────────────────────────────────────────────────
91
92/// Per-scene encoding parameters derived from scene analysis.
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct SceneEncodeParams {
95    /// Constant Rate Factor (lower = higher quality, larger file).
96    pub crf: u8,
97    /// Average bitrate in kbps.
98    pub bitrate_kbps: u32,
99    /// Peak / maximum bitrate in kbps.
100    pub max_bitrate_kbps: u32,
101    /// Number of consecutive B-frames.
102    pub b_frames: u8,
103    /// Number of reference frames.
104    pub ref_frames: u8,
105    /// Encoder speed/quality preset string (e.g., "medium", "slow").
106    pub preset: String,
107    /// Group-of-pictures size in frames.
108    pub gop_size: u32,
109    /// Log2 of tile columns for parallel encoding.
110    pub tile_cols: u8,
111    /// Log2 of tile rows for parallel encoding.
112    pub tile_rows: u8,
113}
114
115impl SceneEncodeParams {
116    /// Returns true when the params appear valid for submission to an encoder.
117    #[must_use]
118    pub fn is_valid(&self) -> bool {
119        self.bitrate_kbps > 0
120            && self.max_bitrate_kbps >= self.bitrate_kbps
121            && !self.preset.is_empty()
122            && self.gop_size > 0
123    }
124}
125
126// ─── PerSceneEncoder ──────────────────────────────────────────────────────────
127
128/// Computes codec-specific encoding parameters tuned for a given scene.
129#[derive(Debug, Clone, Default)]
130pub struct PerSceneEncoder;
131
132impl PerSceneEncoder {
133    /// Creates a new `PerSceneEncoder`.
134    #[must_use]
135    pub fn new() -> Self {
136        Self
137    }
138
139    /// Derives optimal `SceneEncodeParams` for `scene` at the given codec and
140    /// target average bitrate.
141    ///
142    /// # Rules applied
143    ///
144    /// | Scene characteristic | Adjustment |
145    /// |---|---|
146    /// | `Static` | CRF −2, fewer B-frames, larger GOP |
147    /// | `ActionFast` | CRF +2, B-frames=0, smaller GOP |
148    /// | Dark scene | CRF −3 (less noise in shadows) |
149    /// | `HighComplexity` | CRF +3, more B-frames |
150    /// | AV1 | tile\_cols=4, tile\_rows=2 |
151    /// | VP9 | tile\_cols=2, tile\_rows=1 |
152    /// | H.265/HEVC | tile\_cols=2, tile\_rows=1 |
153    #[must_use]
154    pub fn compute_params(
155        &self,
156        scene: &SceneSegment,
157        target_bitrate_kbps: u32,
158        codec: &str,
159    ) -> SceneEncodeParams {
160        // Base values per codec
161        let (base_crf, base_b_frames, base_ref_frames, base_preset, base_gop) =
162            Self::codec_base_values(codec);
163
164        // Accumulate CRF adjustments
165        let crf_adj = self.crf_adjustment(scene);
166
167        // Clamp CRF to [0, 51]
168        let raw_crf = base_crf as i32 + crf_adj;
169        let crf = raw_crf.clamp(0, 51) as u8;
170
171        // B-frames / GOP adjustments per scene type
172        let (b_frames, gop_mult) = self.motion_params(scene, base_b_frames);
173
174        let gop_size = ((base_gop as f32) * gop_mult).round() as u32;
175        let gop_size = gop_size.max(1);
176
177        // Bitrate: scale by complexity and dark-scene headroom
178        let bitrate_scale = self.bitrate_scale(scene);
179        let bitrate_kbps = ((target_bitrate_kbps as f32) * bitrate_scale).round() as u32;
180        let bitrate_kbps = bitrate_kbps.max(50);
181        let max_bitrate_kbps = (bitrate_kbps as f32 * 2.0).round() as u32;
182
183        // Tile layout per codec
184        let (tile_cols, tile_rows) = Self::tile_layout(codec);
185
186        SceneEncodeParams {
187            crf,
188            bitrate_kbps,
189            max_bitrate_kbps,
190            b_frames,
191            ref_frames: base_ref_frames,
192            preset: base_preset.to_string(),
193            gop_size,
194            tile_cols,
195            tile_rows,
196        }
197    }
198
199    /// Base encoding values per codec.
200    fn codec_base_values(codec: &str) -> (u8, u8, u8, &'static str, u32) {
201        // (base_crf, base_b_frames, base_ref_frames, preset, gop_size)
202        match codec.to_lowercase().as_str() {
203            "av1" | "libaom-av1" | "svt-av1" => (35, 0, 3, "5", 240),
204            "vp9" | "libvpx-vp9" => (33, 0, 3, "good", 240),
205            "h265" | "hevc" | "libx265" => (28, 4, 4, "medium", 250),
206            "h264" | "avc" | "libx264" => (23, 3, 3, "medium", 250),
207            _ => (28, 2, 3, "medium", 250),
208        }
209    }
210
211    /// CRF delta based on scene properties.
212    fn crf_adjustment(&self, scene: &SceneSegment) -> i32 {
213        let mut adj: i32 = 0;
214
215        match scene.scene_type {
216            SceneType::Static => adj -= 2,
217            SceneType::ActionFast => adj += 2,
218            SceneType::HighComplexity => adj += 3,
219            SceneType::Animation => adj -= 1,
220            SceneType::Credits => adj -= 2,
221            SceneType::SlowMotion => adj -= 1,
222            SceneType::Talking => {}
223        }
224
225        if scene.is_dark {
226            adj -= 3;
227        }
228
229        // High spatial complexity: slightly raise CRF to keep bitrate sane
230        if scene.complexity > 0.8 {
231            adj += 1;
232        } else if scene.complexity < 0.2 {
233            adj -= 1;
234        }
235
236        adj
237    }
238
239    /// Returns (b_frames, gop_multiplier) based on motion characteristics.
240    fn motion_params(&self, scene: &SceneSegment, base_b_frames: u8) -> (u8, f32) {
241        match scene.scene_type {
242            SceneType::Static => {
243                // Static: fewer B-frames is fine, larger GOP to save bits
244                let b = base_b_frames.saturating_sub(1);
245                (b, 2.0)
246            }
247            SceneType::ActionFast => {
248                // Fast action: no B-frames, small GOP for random-access
249                (0, 0.5)
250            }
251            SceneType::HighComplexity => {
252                // Complex: moderate B-frames, normal GOP
253                let b = (base_b_frames + 2).min(8);
254                (b, 1.0)
255            }
256            SceneType::Credits => {
257                // Scrolling text: larger GOP, fewer B-frames
258                let b = base_b_frames.saturating_sub(1);
259                (b, 1.5)
260            }
261            SceneType::Animation => {
262                // Animation: larger GOP, normal B-frames
263                (base_b_frames, 1.5)
264            }
265            SceneType::SlowMotion | SceneType::Talking => (base_b_frames, 1.0),
266        }
267    }
268
269    /// Bitrate scaling factor \[0.5, 2.0\] based on scene complexity.
270    fn bitrate_scale(&self, scene: &SceneSegment) -> f32 {
271        let mut scale = 0.5 + scene.complexity * 1.5; // [0.5, 2.0]
272        if scene.is_dark {
273            // Dark scenes need less bitrate for equivalent visual quality
274            scale *= 0.85;
275        }
276        match scene.scene_type {
277            SceneType::Static => scale *= 0.6,
278            SceneType::ActionFast => scale *= 1.4,
279            SceneType::HighComplexity => scale *= 1.5,
280            _ => {}
281        }
282        scale.clamp(0.3, 3.0)
283    }
284
285    /// Tile layout (tile_cols, tile_rows) per codec.
286    fn tile_layout(codec: &str) -> (u8, u8) {
287        match codec.to_lowercase().as_str() {
288            "av1" | "libaom-av1" | "svt-av1" => (4, 2),
289            "vp9" | "libvpx-vp9" => (2, 1),
290            "h265" | "hevc" | "libx265" => (2, 1),
291            _ => (1, 1),
292        }
293    }
294}
295
296// ─── Size estimation ──────────────────────────────────────────────────────────
297
298/// Estimates output file size in bytes for a segment encoded with given params.
299///
300/// Formula: `bitrate_kbps × 1000 / 8 × duration_seconds × fill_factor`
301/// where `fill_factor = 0.9` (90% utilisation).
302#[must_use]
303pub fn estimate_output_size(params: &SceneEncodeParams, duration_frames: u32, fps: f32) -> u64 {
304    if fps <= 0.0 || duration_frames == 0 {
305        return 0;
306    }
307    let duration_secs = duration_frames as f64 / fps as f64;
308    let bytes_per_sec = params.bitrate_kbps as f64 * 1000.0 / 8.0;
309    let raw_bytes = bytes_per_sec * duration_secs;
310    (raw_bytes * 0.9) as u64
311}
312
313// ─── TargetSizeSolver ─────────────────────────────────────────────────────────
314
315/// Solves for the average bitrate (kbps) that achieves a target file size
316/// using binary search over the fill-factor-adjusted bitrate formula.
317#[derive(Debug, Clone)]
318pub struct TargetSizeSolver {
319    /// Target output size in bytes.
320    pub target_bytes: u64,
321    /// Number of frames in the segment.
322    pub duration_frames: u32,
323    /// Frame rate.
324    pub fps: f32,
325}
326
327impl TargetSizeSolver {
328    /// Creates a new solver.
329    #[must_use]
330    pub fn new(target_bytes: u64, duration_frames: u32, fps: f32) -> Self {
331        Self {
332            target_bytes,
333            duration_frames,
334            fps,
335        }
336    }
337
338    /// Performs a binary search to find the bitrate (kbps) that fills
339    /// `target_bytes` at the given `complexity_factor` \[0.5, 2.0\].
340    ///
341    /// The complexity factor scales the effective fill factor (higher
342    /// complexity → content is harder to compress → more bits needed per
343    /// unit of quality), meaning the solver converges to a slightly higher
344    /// bitrate for complex content.
345    #[must_use]
346    pub fn solve_bitrate(&self, complexity_factor: f32) -> u32 {
347        if self.fps <= 0.0 || self.duration_frames == 0 || self.target_bytes == 0 {
348            return 0;
349        }
350
351        let duration_secs = self.duration_frames as f64 / self.fps as f64;
352        // Effective fill factor: 0.9 base, modulated by complexity
353        let fill = (0.9 / complexity_factor.max(0.1) as f64).clamp(0.3, 1.5);
354        // target_bytes = bitrate_bps / 8 * duration * fill
355        // bitrate_bps = target_bytes * 8 / duration / fill
356        let bitrate_bps = (self.target_bytes as f64 * 8.0) / (duration_secs * fill);
357        let bitrate_kbps = (bitrate_bps / 1000.0).round() as u32;
358        bitrate_kbps.max(1)
359    }
360}
361
362// ─── BudgetAllocator ──────────────────────────────────────────────────────────
363
364/// Allocates a total byte budget proportionally across scenes,
365/// weighted by each scene's complexity.
366#[derive(Debug, Clone)]
367pub struct BudgetAllocator {
368    /// Total available byte budget.
369    pub total_budget_bytes: u64,
370    /// Scenes to allocate budget across.
371    pub scenes: Vec<SceneSegment>,
372}
373
374impl BudgetAllocator {
375    /// Creates a new allocator.
376    #[must_use]
377    pub fn new(total_budget_bytes: u64, scenes: Vec<SceneSegment>) -> Self {
378        Self {
379            total_budget_bytes,
380            scenes,
381        }
382    }
383
384    /// Returns per-scene byte budgets, summing to at most `total_budget_bytes`.
385    ///
386    /// Allocation weight for scene `i`:
387    ///   `w_i = duration_i × (0.5 + complexity_i)`
388    ///
389    /// Each scene receives `budget_i = total × w_i / Σw`.
390    #[must_use]
391    pub fn allocate(&self) -> Vec<u64> {
392        if self.scenes.is_empty() {
393            return Vec::new();
394        }
395
396        let weights: Vec<f64> = self
397            .scenes
398            .iter()
399            .map(|s| {
400                let duration_weight = s.duration_frames as f64;
401                let complexity_weight = 0.5 + s.complexity as f64;
402                let motion_weight = 1.0 + s.motion_score as f64 * 0.5;
403                duration_weight * complexity_weight * motion_weight
404            })
405            .collect();
406
407        let total_weight: f64 = weights.iter().sum();
408
409        if total_weight <= 0.0 {
410            // Uniform allocation fallback
411            let per_scene = self.total_budget_bytes / self.scenes.len() as u64;
412            return vec![per_scene; self.scenes.len()];
413        }
414
415        let mut allocations: Vec<u64> = weights
416            .iter()
417            .map(|&w| {
418                let frac = w / total_weight;
419                (self.total_budget_bytes as f64 * frac).round() as u64
420            })
421            .collect();
422
423        // Correct rounding error: ensure sum <= total_budget_bytes
424        let allocated_sum: u64 = allocations.iter().sum();
425        if allocated_sum > self.total_budget_bytes {
426            // Trim the largest bucket
427            if let Some(max_idx) = allocations
428                .iter()
429                .enumerate()
430                .max_by_key(|(_, &v)| v)
431                .map(|(i, _)| i)
432            {
433                let excess = allocated_sum - self.total_budget_bytes;
434                allocations[max_idx] = allocations[max_idx].saturating_sub(excess);
435            }
436        }
437
438        allocations
439    }
440
441    /// Returns the total bytes allocated (should be ≤ `total_budget_bytes`).
442    #[must_use]
443    pub fn allocated_total(&self) -> u64 {
444        self.allocate().iter().sum()
445    }
446}
447
448// ─── Tests ────────────────────────────────────────────────────────────────────
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453
454    fn make_scene(
455        scene_type: SceneType,
456        complexity: f32,
457        motion: f32,
458        is_dark: bool,
459    ) -> SceneSegment {
460        SceneSegment::new(0, 239, 240, motion, complexity, is_dark, scene_type)
461    }
462
463    // ── SceneSegment ─────────────────────────────────────────────────────────
464
465    #[test]
466    fn test_scene_segment_new() {
467        let seg = SceneSegment::new(0, 299, 300, 0.3, 0.5, false, SceneType::Talking);
468        assert_eq!(seg.start_frame, 0);
469        assert_eq!(seg.end_frame, 299);
470        assert_eq!(seg.duration_frames, 300);
471        assert!((seg.motion_score - 0.3).abs() < 1e-6);
472        assert!(!seg.is_dark);
473    }
474
475    #[test]
476    fn test_scene_segment_duration_secs() {
477        let seg = make_scene(SceneType::Talking, 0.5, 0.3, false);
478        let dur = seg.duration_secs(30.0);
479        assert!((dur - 8.0).abs() < 0.01); // 240 / 30 = 8 s
480    }
481
482    #[test]
483    fn test_scene_segment_duration_secs_zero_fps() {
484        let seg = make_scene(SceneType::Talking, 0.5, 0.3, false);
485        assert_eq!(seg.duration_secs(0.0), 0.0);
486    }
487
488    #[test]
489    fn test_frame_count_alias() {
490        let seg = make_scene(SceneType::Static, 0.1, 0.0, false);
491        assert_eq!(seg.frame_count(), seg.duration_frames);
492    }
493
494    // ── PerSceneEncoder ──────────────────────────────────────────────────────
495
496    #[test]
497    fn test_static_scene_lower_crf_larger_gop() {
498        let enc = PerSceneEncoder::new();
499        let static_scene = make_scene(SceneType::Static, 0.3, 0.1, false);
500        let action_scene = make_scene(SceneType::ActionFast, 0.3, 0.9, false);
501        let p_static = enc.compute_params(&static_scene, 4000, "h264");
502        let p_action = enc.compute_params(&action_scene, 4000, "h264");
503        assert!(
504            p_static.crf < p_action.crf,
505            "Static CRF should be lower than ActionFast CRF"
506        );
507        assert!(
508            p_static.gop_size > p_action.gop_size,
509            "Static GOP should be larger"
510        );
511    }
512
513    #[test]
514    fn test_action_fast_no_b_frames() {
515        let enc = PerSceneEncoder::new();
516        let scene = make_scene(SceneType::ActionFast, 0.8, 0.95, false);
517        let params = enc.compute_params(&scene, 8000, "h264");
518        assert_eq!(params.b_frames, 0, "ActionFast should use no B-frames");
519    }
520
521    #[test]
522    fn test_dark_scene_lower_crf() {
523        let enc = PerSceneEncoder::new();
524        let dark = make_scene(SceneType::Talking, 0.5, 0.3, true);
525        let bright = make_scene(SceneType::Talking, 0.5, 0.3, false);
526        let p_dark = enc.compute_params(&dark, 4000, "h264");
527        let p_bright = enc.compute_params(&bright, 4000, "h264");
528        assert!(
529            p_dark.crf < p_bright.crf,
530            "Dark scene should have lower CRF"
531        );
532    }
533
534    #[test]
535    fn test_av1_tile_layout() {
536        let enc = PerSceneEncoder::new();
537        let scene = make_scene(SceneType::Talking, 0.5, 0.3, false);
538        let params = enc.compute_params(&scene, 4000, "av1");
539        assert_eq!(params.tile_cols, 4);
540        assert_eq!(params.tile_rows, 2);
541    }
542
543    #[test]
544    fn test_vp9_tile_layout() {
545        let enc = PerSceneEncoder::new();
546        let scene = make_scene(SceneType::Talking, 0.5, 0.3, false);
547        let params = enc.compute_params(&scene, 4000, "vp9");
548        assert_eq!(params.tile_cols, 2);
549        assert_eq!(params.tile_rows, 1);
550    }
551
552    #[test]
553    fn test_h265_tile_layout() {
554        let enc = PerSceneEncoder::new();
555        let scene = make_scene(SceneType::Talking, 0.5, 0.3, false);
556        let params = enc.compute_params(&scene, 4000, "h265");
557        assert_eq!(params.tile_cols, 2);
558        assert_eq!(params.tile_rows, 1);
559    }
560
561    #[test]
562    fn test_params_are_valid() {
563        let enc = PerSceneEncoder::new();
564        let scene = make_scene(SceneType::Talking, 0.5, 0.3, false);
565        let params = enc.compute_params(&scene, 4000, "vp9");
566        assert!(params.is_valid());
567    }
568
569    #[test]
570    fn test_max_bitrate_gte_avg_bitrate() {
571        let enc = PerSceneEncoder::new();
572        let scene = make_scene(SceneType::HighComplexity, 0.9, 0.8, false);
573        let params = enc.compute_params(&scene, 6000, "av1");
574        assert!(params.max_bitrate_kbps >= params.bitrate_kbps);
575    }
576
577    #[test]
578    fn test_crf_clamped_to_valid_range() {
579        let enc = PerSceneEncoder::new();
580        // Dark + HighComplexity compensate each other; result must still be [0,51]
581        let scene = make_scene(SceneType::HighComplexity, 0.9, 0.9, true);
582        let params = enc.compute_params(&scene, 2000, "h264");
583        assert!(params.crf <= 51);
584    }
585
586    // ── estimate_output_size ──────────────────────────────────────────────────
587
588    #[test]
589    fn test_estimate_output_size_basic() {
590        let params = SceneEncodeParams {
591            crf: 28,
592            bitrate_kbps: 1000,
593            max_bitrate_kbps: 2000,
594            b_frames: 3,
595            ref_frames: 3,
596            preset: "medium".to_string(),
597            gop_size: 250,
598            tile_cols: 1,
599            tile_rows: 1,
600        };
601        // 1000 kbps × 1000/8 = 125 000 B/s × 10 s × 0.9 = 1 125 000 bytes
602        let size = estimate_output_size(&params, 300, 30.0);
603        assert!((size as i64 - 1_125_000).abs() < 1000);
604    }
605
606    #[test]
607    fn test_estimate_output_size_zero_fps() {
608        let params = SceneEncodeParams {
609            crf: 28,
610            bitrate_kbps: 1000,
611            max_bitrate_kbps: 2000,
612            b_frames: 3,
613            ref_frames: 3,
614            preset: "medium".to_string(),
615            gop_size: 250,
616            tile_cols: 1,
617            tile_rows: 1,
618        };
619        assert_eq!(estimate_output_size(&params, 300, 0.0), 0);
620    }
621
622    #[test]
623    fn test_estimate_output_size_zero_frames() {
624        let params = SceneEncodeParams {
625            crf: 28,
626            bitrate_kbps: 1000,
627            max_bitrate_kbps: 2000,
628            b_frames: 3,
629            ref_frames: 3,
630            preset: "medium".to_string(),
631            gop_size: 250,
632            tile_cols: 1,
633            tile_rows: 1,
634        };
635        assert_eq!(estimate_output_size(&params, 0, 30.0), 0);
636    }
637
638    // ── TargetSizeSolver ──────────────────────────────────────────────────────
639
640    #[test]
641    fn test_target_size_solver_basic() {
642        // 10 MB target, 300 frames at 30 fps = 10 s
643        let solver = TargetSizeSolver::new(10_000_000, 300, 30.0);
644        let kbps = solver.solve_bitrate(1.0);
645        // Expected: 10_000_000 * 8 / 10 / 0.9 ≈ 8_889_000 bps ≈ 8889 kbps
646        assert!(kbps > 7000 && kbps < 10000, "kbps={kbps} out of range");
647    }
648
649    #[test]
650    fn test_target_size_solver_high_complexity() {
651        let solver = TargetSizeSolver::new(10_000_000, 300, 30.0);
652        let kbps_normal = solver.solve_bitrate(1.0);
653        let kbps_complex = solver.solve_bitrate(2.0);
654        // Higher complexity → higher bitrate needed
655        assert!(kbps_complex > kbps_normal);
656    }
657
658    #[test]
659    fn test_target_size_solver_zero_target() {
660        let solver = TargetSizeSolver::new(0, 300, 30.0);
661        assert_eq!(solver.solve_bitrate(1.0), 0);
662    }
663
664    #[test]
665    fn test_target_size_solver_zero_frames() {
666        let solver = TargetSizeSolver::new(10_000_000, 0, 30.0);
667        assert_eq!(solver.solve_bitrate(1.0), 0);
668    }
669
670    // ── BudgetAllocator ───────────────────────────────────────────────────────
671
672    #[test]
673    fn test_budget_allocator_sums_to_budget() {
674        let scenes = vec![
675            make_scene(SceneType::Static, 0.2, 0.1, false),
676            make_scene(SceneType::Talking, 0.5, 0.4, false),
677            make_scene(SceneType::ActionFast, 0.9, 0.95, false),
678        ];
679        let allocator = BudgetAllocator::new(100_000_000, scenes);
680        let allocs = allocator.allocate();
681        let total: u64 = allocs.iter().sum();
682        assert!(total <= 100_000_000, "total={total} exceeded budget");
683    }
684
685    #[test]
686    fn test_budget_allocator_complex_scene_gets_more() {
687        let scenes = vec![
688            make_scene(SceneType::Static, 0.1, 0.1, false),
689            make_scene(SceneType::HighComplexity, 0.95, 0.95, false),
690        ];
691        let allocator = BudgetAllocator::new(100_000_000, scenes);
692        let allocs = allocator.allocate();
693        assert!(
694            allocs[1] > allocs[0],
695            "Complex scene should receive more budget"
696        );
697    }
698
699    #[test]
700    fn test_budget_allocator_empty_scenes() {
701        let allocator = BudgetAllocator::new(100_000_000, vec![]);
702        assert!(allocator.allocate().is_empty());
703    }
704
705    #[test]
706    fn test_budget_allocator_allocated_total() {
707        let scenes = vec![
708            make_scene(SceneType::Talking, 0.5, 0.4, false),
709            make_scene(SceneType::Animation, 0.6, 0.2, false),
710        ];
711        let allocator = BudgetAllocator::new(50_000_000, scenes);
712        let total = allocator.allocated_total();
713        assert!(total <= 50_000_000);
714    }
715
716    #[test]
717    fn test_budget_allocator_single_scene() {
718        let scenes = vec![make_scene(SceneType::Talking, 0.5, 0.3, false)];
719        let allocator = BudgetAllocator::new(20_000_000, scenes);
720        let allocs = allocator.allocate();
721        assert_eq!(allocs.len(), 1);
722        assert!(allocs[0] <= 20_000_000);
723    }
724
725    #[test]
726    fn test_scene_type_equality() {
727        assert_eq!(SceneType::Static, SceneType::Static);
728        assert_ne!(SceneType::Static, SceneType::ActionFast);
729    }
730
731    #[test]
732    fn test_encode_params_preset_not_empty() {
733        let enc = PerSceneEncoder::new();
734        let scene = make_scene(SceneType::Talking, 0.5, 0.3, false);
735        for codec in &["av1", "vp9", "h265", "h264"] {
736            let p = enc.compute_params(&scene, 4000, codec);
737            assert!(!p.preset.is_empty(), "preset empty for codec {codec}");
738        }
739    }
740}