Skip to main content

oximedia_optimize/
scene_encode.rs

1#![allow(dead_code)]
2//! Scene-aware encoding optimization.
3//!
4//! This module provides scene-level encoding strategies that adjust encoding
5//! parameters based on scene characteristics (complexity, motion, texture).
6//! It operates at a higher level than per-frame optimization, making decisions
7//! about GOP structure, QP offsets, and bitrate allocation on a scene-by-scene
8//! basis.
9//!
10//! The [`LookaheadSceneQp`] analyzer uses lookahead frame data to make
11//! scene-cut-aware QP adjustments, boosting quality at scene boundaries
12//! and smoothly transitioning between scenes of different complexity.
13
14use std::collections::VecDeque;
15
16/// Scene type classification for encoding decisions.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
18pub enum SceneType {
19    /// Static or near-static scene (e.g., title card, credits).
20    Static,
21    /// Talking head or slow-paced dialogue scene.
22    Dialogue,
23    /// Moderate action with some motion.
24    Moderate,
25    /// Fast action scene with high motion and complexity.
26    Action,
27    /// Scene with lots of fine detail (e.g., foliage, crowds).
28    HighDetail,
29    /// Dark or low-light scene.
30    DarkScene,
31    /// Scene transition / crossfade.
32    Transition,
33}
34
35/// Metrics describing a scene's visual characteristics.
36#[derive(Debug, Clone)]
37pub struct SceneMetrics {
38    /// Scene index in the stream.
39    pub scene_index: u32,
40    /// Frame index where the scene starts.
41    pub start_frame: u64,
42    /// Frame index where the scene ends (exclusive).
43    pub end_frame: u64,
44    /// Average spatial complexity (0.0 - 1.0).
45    pub spatial_complexity: f64,
46    /// Average temporal complexity / motion (0.0 - 1.0).
47    pub temporal_complexity: f64,
48    /// Average luminance (0.0 - 1.0).
49    pub avg_luminance: f64,
50    /// Luminance variance.
51    pub luminance_variance: f64,
52    /// Texture density (0.0 - 1.0).
53    pub texture_density: f64,
54    /// Classified scene type.
55    pub scene_type: SceneType,
56}
57
58impl SceneMetrics {
59    /// Creates new scene metrics.
60    #[must_use]
61    pub fn new(scene_index: u32, start_frame: u64, end_frame: u64) -> Self {
62        Self {
63            scene_index,
64            start_frame,
65            end_frame,
66            spatial_complexity: 0.5,
67            temporal_complexity: 0.5,
68            avg_luminance: 0.5,
69            luminance_variance: 0.1,
70            texture_density: 0.5,
71            scene_type: SceneType::Moderate,
72        }
73    }
74
75    /// Returns the number of frames in this scene.
76    #[must_use]
77    pub fn frame_count(&self) -> u64 {
78        self.end_frame.saturating_sub(self.start_frame)
79    }
80
81    /// Returns a combined complexity score (0.0 - 1.0).
82    #[must_use]
83    pub fn combined_complexity(&self) -> f64 {
84        (self.spatial_complexity * 0.4
85            + self.temporal_complexity * 0.4
86            + self.texture_density * 0.2)
87            .clamp(0.0, 1.0)
88    }
89
90    /// Returns true if this is a dark scene.
91    #[must_use]
92    pub fn is_dark(&self) -> bool {
93        self.avg_luminance < 0.2
94    }
95}
96
97/// Encoding parameters for a scene.
98#[derive(Debug, Clone)]
99pub struct SceneEncodeParams {
100    /// QP offset relative to base QP (can be negative for higher quality).
101    pub qp_offset: f64,
102    /// Bitrate allocation weight (1.0 = normal, >1.0 = more bits).
103    pub bitrate_weight: f64,
104    /// Recommended GOP size in frames.
105    pub gop_size: u32,
106    /// Whether to force a keyframe at scene start.
107    pub force_keyframe: bool,
108    /// Minimum QP allowed.
109    pub min_qp: f64,
110    /// Maximum QP allowed.
111    pub max_qp: f64,
112    /// B-frame count for this scene.
113    pub b_frames: u32,
114    /// Whether to enable adaptive quantization.
115    pub enable_aq: bool,
116    /// AQ strength (0.0 - 2.0).
117    pub aq_strength: f64,
118}
119
120impl Default for SceneEncodeParams {
121    fn default() -> Self {
122        Self {
123            qp_offset: 0.0,
124            bitrate_weight: 1.0,
125            gop_size: 250,
126            force_keyframe: true,
127            min_qp: 0.0,
128            max_qp: 51.0,
129            b_frames: 3,
130            enable_aq: true,
131            aq_strength: 1.0,
132        }
133    }
134}
135
136impl SceneEncodeParams {
137    /// Creates new default scene encoding parameters.
138    #[must_use]
139    pub fn new() -> Self {
140        Self::default()
141    }
142
143    /// Returns the effective QP given a base QP.
144    #[must_use]
145    pub fn effective_qp(&self, base_qp: f64) -> f64 {
146        (base_qp + self.qp_offset).clamp(self.min_qp, self.max_qp)
147    }
148}
149
150/// Scene-based encoding optimizer.
151#[derive(Debug)]
152pub struct SceneEncoder {
153    /// Base QP for the encoding session.
154    base_qp: f64,
155    /// Target bitrate in bps.
156    target_bitrate_bps: u64,
157    /// Frame rate.
158    frame_rate: f64,
159    /// Scene parameters generated so far.
160    scene_params: Vec<SceneEncodeParams>,
161    /// Scene metrics history.
162    scene_history: VecDeque<SceneMetrics>,
163    /// Maximum scene history length.
164    max_history: usize,
165}
166
167impl SceneEncoder {
168    /// Creates a new scene encoder with target settings.
169    #[must_use]
170    pub fn new(base_qp: f64, target_bitrate_bps: u64, frame_rate: f64) -> Self {
171        Self {
172            base_qp,
173            target_bitrate_bps,
174            frame_rate,
175            scene_params: Vec::new(),
176            scene_history: VecDeque::new(),
177            max_history: 100,
178        }
179    }
180
181    /// Returns the base QP.
182    #[must_use]
183    pub fn base_qp(&self) -> f64 {
184        self.base_qp
185    }
186
187    /// Returns the target bitrate.
188    #[must_use]
189    pub fn target_bitrate_bps(&self) -> u64 {
190        self.target_bitrate_bps
191    }
192
193    /// Generates encoding parameters for a scene based on its metrics.
194    #[must_use]
195    pub fn generate_params(&self, metrics: &SceneMetrics) -> SceneEncodeParams {
196        let mut params = SceneEncodeParams::default();
197
198        // Determine QP offset based on scene type and complexity
199        params.qp_offset = self.compute_qp_offset(metrics);
200        params.bitrate_weight = self.compute_bitrate_weight(metrics);
201        params.gop_size = self.compute_gop_size(metrics);
202        params.b_frames = self.compute_b_frames(metrics);
203        params.aq_strength = self.compute_aq_strength(metrics);
204
205        // Always force keyframe at scene boundaries
206        params.force_keyframe = true;
207
208        params
209    }
210
211    /// Processes a scene: records metrics and generates params.
212    pub fn process_scene(&mut self, metrics: SceneMetrics) -> SceneEncodeParams {
213        let params = self.generate_params(&metrics);
214        self.scene_params.push(params.clone());
215        self.scene_history.push_back(metrics);
216        if self.scene_history.len() > self.max_history {
217            self.scene_history.pop_front();
218        }
219        params
220    }
221
222    /// Returns scene parameters generated so far.
223    #[must_use]
224    pub fn scene_params(&self) -> &[SceneEncodeParams] {
225        &self.scene_params
226    }
227
228    /// Returns the number of scenes processed.
229    #[must_use]
230    pub fn scenes_processed(&self) -> usize {
231        self.scene_params.len()
232    }
233
234    /// Returns the scene history.
235    #[must_use]
236    pub fn scene_history(&self) -> &VecDeque<SceneMetrics> {
237        &self.scene_history
238    }
239
240    /// Computes the QP offset for a scene.
241    fn compute_qp_offset(&self, metrics: &SceneMetrics) -> f64 {
242        match metrics.scene_type {
243            SceneType::Static => -4.0,    // boost quality for static
244            SceneType::Dialogue => -2.0,  // slightly boost dialogue
245            SceneType::Moderate => 0.0,   // neutral
246            SceneType::Action => 2.0,     // relax for action
247            SceneType::HighDetail => 1.0, // slight relax for heavy detail
248            SceneType::DarkScene => -3.0, // boost dark scenes (artifacts visible)
249            SceneType::Transition => 3.0, // relax during transitions
250        }
251    }
252
253    /// Computes the bitrate weight for a scene.
254    fn compute_bitrate_weight(&self, metrics: &SceneMetrics) -> f64 {
255        let complexity = metrics.combined_complexity();
256        // Map complexity [0,1] to weight [0.6, 1.8]
257        0.6 + complexity * 1.2
258    }
259
260    /// Computes GOP size based on temporal characteristics.
261    #[allow(clippy::cast_possible_truncation)]
262    #[allow(clippy::cast_sign_loss)]
263    fn compute_gop_size(&self, metrics: &SceneMetrics) -> u32 {
264        let frame_count = metrics.frame_count();
265        // GOP should not exceed scene length
266        let max_gop = frame_count.min(300) as u32;
267
268        match metrics.scene_type {
269            SceneType::Static => max_gop.min(300),
270            SceneType::Dialogue => max_gop.min(250),
271            SceneType::Moderate => max_gop.min(200),
272            SceneType::Action => max_gop.min(120),
273            SceneType::HighDetail => max_gop.min(150),
274            SceneType::DarkScene => max_gop.min(250),
275            SceneType::Transition => max_gop.min(60),
276        }
277    }
278
279    /// Computes B-frame count for a scene.
280    fn compute_b_frames(&self, metrics: &SceneMetrics) -> u32 {
281        match metrics.scene_type {
282            SceneType::Static => 5,
283            SceneType::Dialogue => 4,
284            SceneType::Moderate => 3,
285            SceneType::Action => 2,
286            SceneType::HighDetail => 3,
287            SceneType::DarkScene => 4,
288            SceneType::Transition => 1,
289        }
290    }
291
292    /// Computes adaptive quantization strength.
293    fn compute_aq_strength(&self, metrics: &SceneMetrics) -> f64 {
294        if metrics.is_dark() {
295            // Stronger AQ for dark scenes to reduce banding
296            1.5
297        } else if metrics.spatial_complexity > 0.7 {
298            1.2
299        } else {
300            1.0
301        }
302    }
303
304    /// Returns the average complexity across all processed scenes.
305    #[must_use]
306    #[allow(clippy::cast_precision_loss)]
307    pub fn avg_scene_complexity(&self) -> f64 {
308        if self.scene_history.is_empty() {
309            return 0.0;
310        }
311        let total: f64 = self
312            .scene_history
313            .iter()
314            .map(|m| m.combined_complexity())
315            .sum();
316        total / self.scene_history.len() as f64
317    }
318}
319
320/// Lookahead-based scene-aware QP adjustment.
321///
322/// Uses a window of lookahead frames to detect scene cuts and adjust QP
323/// based on upcoming content complexity. This prevents quality drops at
324/// scene boundaries by pre-allocating bits and smoothly transitioning
325/// QP between scenes of different complexity.
326#[derive(Debug)]
327pub struct LookaheadSceneQp {
328    /// Number of lookahead frames.
329    lookahead_depth: usize,
330    /// Base QP for the encoding session.
331    base_qp: f64,
332    /// Lookahead buffer of frame complexities.
333    complexity_buffer: VecDeque<FrameLookaheadInfo>,
334    /// QP adjustment history for smoothing.
335    qp_history: VecDeque<f64>,
336    /// Maximum QP delta from base.
337    max_qp_delta: f64,
338    /// QP smoothing factor (0.0-1.0, higher = more smoothing).
339    smoothing: f64,
340    /// Scene cut boost: extra QP reduction at scene boundaries.
341    scene_cut_boost: f64,
342    /// Total frames processed.
343    frames_processed: u64,
344}
345
346/// Per-frame information used by the lookahead QP adjuster.
347#[derive(Debug, Clone)]
348pub struct FrameLookaheadInfo {
349    /// Frame index.
350    pub frame_index: u64,
351    /// Spatial complexity (0.0-1.0).
352    pub spatial_complexity: f64,
353    /// Temporal complexity / motion (0.0-1.0).
354    pub temporal_complexity: f64,
355    /// Whether this frame is a detected scene cut.
356    pub is_scene_cut: bool,
357    /// Average luminance.
358    pub avg_luminance: f64,
359}
360
361impl FrameLookaheadInfo {
362    /// Creates a new frame info entry.
363    pub fn new(frame_index: u64) -> Self {
364        Self {
365            frame_index,
366            spatial_complexity: 0.5,
367            temporal_complexity: 0.5,
368            is_scene_cut: false,
369            avg_luminance: 0.5,
370        }
371    }
372
373    /// Returns the combined complexity of this frame.
374    pub fn combined_complexity(&self) -> f64 {
375        (self.spatial_complexity * 0.5 + self.temporal_complexity * 0.5).clamp(0.0, 1.0)
376    }
377}
378
379/// Result of lookahead QP analysis for a single frame.
380#[derive(Debug, Clone)]
381pub struct LookaheadQpResult {
382    /// Recommended QP for this frame.
383    pub recommended_qp: f64,
384    /// QP delta from base.
385    pub qp_delta: f64,
386    /// Whether this frame is at/near a scene cut.
387    pub near_scene_cut: bool,
388    /// Distance to the next scene cut in frames (0 = this frame is a cut).
389    pub distance_to_next_cut: Option<usize>,
390    /// Upcoming complexity average (from lookahead).
391    pub upcoming_complexity: f64,
392}
393
394impl LookaheadSceneQp {
395    /// Creates a new lookahead scene QP adjuster.
396    pub fn new(lookahead_depth: usize, base_qp: f64) -> Self {
397        Self {
398            lookahead_depth: lookahead_depth.max(1),
399            base_qp,
400            complexity_buffer: VecDeque::new(),
401            qp_history: VecDeque::new(),
402            max_qp_delta: 6.0,
403            smoothing: 0.3,
404            scene_cut_boost: 3.0,
405            frames_processed: 0,
406        }
407    }
408
409    /// Sets the maximum QP delta.
410    pub fn set_max_qp_delta(&mut self, delta: f64) {
411        self.max_qp_delta = delta.max(0.0);
412    }
413
414    /// Sets the scene cut QP boost.
415    pub fn set_scene_cut_boost(&mut self, boost: f64) {
416        self.scene_cut_boost = boost.max(0.0);
417    }
418
419    /// Sets the QP smoothing factor.
420    pub fn set_smoothing(&mut self, smoothing: f64) {
421        self.smoothing = smoothing.clamp(0.0, 1.0);
422    }
423
424    /// Feeds a frame's lookahead info into the buffer.
425    pub fn feed_frame(&mut self, info: FrameLookaheadInfo) {
426        self.complexity_buffer.push_back(info);
427        // Keep buffer at lookahead depth + some margin
428        while self.complexity_buffer.len() > self.lookahead_depth * 2 {
429            self.complexity_buffer.pop_front();
430        }
431    }
432
433    /// Analyzes the current frame considering lookahead data and returns QP recommendation.
434    ///
435    /// The algorithm:
436    /// 1. Computes upcoming complexity from lookahead frames
437    /// 2. Detects proximity to scene cuts
438    /// 3. Boosts QP at scene boundaries (lower QP = more bits for I-frames)
439    /// 4. Smoothly transitions QP between scenes of different complexity
440    /// 5. Applies temporal smoothing to prevent QP oscillation
441    #[allow(clippy::cast_precision_loss)]
442    pub fn analyze_frame(&mut self, current: &FrameLookaheadInfo) -> LookaheadQpResult {
443        // Find scene cuts in the lookahead window
444        let distance_to_cut = self.find_next_scene_cut();
445        let near_scene_cut =
446            current.is_scene_cut || distance_to_cut.map(|d| d < 3).unwrap_or(false);
447
448        // Compute upcoming complexity from lookahead
449        let upcoming_complexity = self.compute_upcoming_complexity();
450
451        // Base QP delta from complexity
452        let complexity_delta = self.complexity_to_qp_delta(upcoming_complexity);
453
454        // Scene cut adjustment
455        let scene_cut_delta = if current.is_scene_cut {
456            // Strong boost at the scene cut itself (first frame of new scene)
457            -self.scene_cut_boost
458        } else if let Some(dist) = distance_to_cut {
459            if dist <= 2 {
460                // Ramp down bits before the cut (let the I-frame have more)
461                let ramp_factor = dist as f64 / 3.0;
462                self.scene_cut_boost * 0.3 * ramp_factor
463            } else {
464                0.0
465            }
466        } else {
467            0.0
468        };
469
470        // Dark scene adjustment
471        let dark_delta = if current.avg_luminance < 0.15 {
472            -1.5 // Boost dark scenes
473        } else {
474            0.0
475        };
476
477        // Combine deltas
478        let raw_delta = complexity_delta + scene_cut_delta + dark_delta;
479        let clamped_delta = raw_delta.clamp(-self.max_qp_delta, self.max_qp_delta);
480
481        // Apply temporal smoothing
482        let smoothed_delta = if let Some(&last_qp) = self.qp_history.back() {
483            let last_delta = last_qp - self.base_qp;
484            if current.is_scene_cut {
485                // No smoothing at scene cuts
486                clamped_delta
487            } else {
488                last_delta * self.smoothing + clamped_delta * (1.0 - self.smoothing)
489            }
490        } else {
491            clamped_delta
492        };
493
494        let recommended_qp = (self.base_qp + smoothed_delta).clamp(1.0, 51.0);
495
496        // Record history
497        self.qp_history.push_back(recommended_qp);
498        if self.qp_history.len() > self.lookahead_depth {
499            self.qp_history.pop_front();
500        }
501        self.frames_processed += 1;
502
503        LookaheadQpResult {
504            recommended_qp,
505            qp_delta: smoothed_delta,
506            near_scene_cut,
507            distance_to_next_cut: distance_to_cut,
508            upcoming_complexity,
509        }
510    }
511
512    /// Returns the number of frames processed.
513    pub fn frames_processed(&self) -> u64 {
514        self.frames_processed
515    }
516
517    /// Resets the lookahead state.
518    pub fn reset(&mut self) {
519        self.complexity_buffer.clear();
520        self.qp_history.clear();
521        self.frames_processed = 0;
522    }
523
524    /// Finds the distance to the next scene cut in the lookahead buffer.
525    fn find_next_scene_cut(&self) -> Option<usize> {
526        for (i, info) in self.complexity_buffer.iter().enumerate() {
527            if info.is_scene_cut && i > 0 {
528                return Some(i);
529            }
530        }
531        None
532    }
533
534    /// Computes the average complexity of upcoming frames in the buffer.
535    #[allow(clippy::cast_precision_loss)]
536    fn compute_upcoming_complexity(&self) -> f64 {
537        if self.complexity_buffer.is_empty() {
538            return 0.5;
539        }
540        let count = self.complexity_buffer.len().min(self.lookahead_depth);
541        let sum: f64 = self
542            .complexity_buffer
543            .iter()
544            .take(count)
545            .map(|f| f.combined_complexity())
546            .sum();
547        sum / count as f64
548    }
549
550    /// Maps complexity to QP delta using a sigmoid-like curve.
551    fn complexity_to_qp_delta(&self, complexity: f64) -> f64 {
552        // More complex = lower QP (more bits)
553        // Map [0,1] complexity to [-max_delta, +max_delta] delta
554        let centered = complexity - 0.5;
555        -centered * self.max_qp_delta * 2.0
556    }
557}
558
559/// Bitrate distribution plan across scenes.
560#[derive(Debug, Clone)]
561pub struct SceneBitratePlan {
562    /// Scene index to allocated bitrate in bps.
563    allocations: Vec<SceneBitrateAlloc>,
564}
565
566/// A single scene's bitrate allocation.
567#[derive(Debug, Clone)]
568pub struct SceneBitrateAlloc {
569    /// Scene index.
570    pub scene_index: u32,
571    /// Allocated bitrate in bps.
572    pub allocated_bps: u64,
573    /// Frame count in the scene.
574    pub frame_count: u64,
575    /// Allocated bits for the entire scene.
576    pub total_bits: u64,
577}
578
579impl SceneBitratePlan {
580    /// Creates a new bitrate plan.
581    #[must_use]
582    pub fn new() -> Self {
583        Self {
584            allocations: Vec::new(),
585        }
586    }
587
588    /// Distributes a total bitrate budget across scenes weighted by encode params.
589    #[must_use]
590    #[allow(clippy::cast_precision_loss)]
591    pub fn distribute(
592        scenes: &[SceneMetrics],
593        params: &[SceneEncodeParams],
594        total_bitrate_bps: u64,
595        fps: f64,
596    ) -> Self {
597        if scenes.is_empty() || params.is_empty() || fps <= 0.0 {
598            return Self::new();
599        }
600
601        let len = scenes.len().min(params.len());
602
603        // Compute weighted frame counts
604        let weighted_total: f64 = (0..len)
605            .map(|i| scenes[i].frame_count() as f64 * params[i].bitrate_weight)
606            .sum();
607
608        if weighted_total <= 0.0 {
609            return Self::new();
610        }
611
612        let total_frames: u64 = scenes[..len].iter().map(|s| s.frame_count()).sum();
613        let total_bits = (total_bitrate_bps as f64 * total_frames as f64 / fps) as u64;
614
615        let mut allocations = Vec::with_capacity(len);
616        for i in 0..len {
617            let weight = scenes[i].frame_count() as f64 * params[i].bitrate_weight / weighted_total;
618            let scene_bits = (total_bits as f64 * weight) as u64;
619            let scene_bps = if scenes[i].frame_count() > 0 {
620                (scene_bits as f64 * fps / scenes[i].frame_count() as f64) as u64
621            } else {
622                0
623            };
624
625            allocations.push(SceneBitrateAlloc {
626                scene_index: scenes[i].scene_index,
627                allocated_bps: scene_bps,
628                frame_count: scenes[i].frame_count(),
629                total_bits: scene_bits,
630            });
631        }
632
633        Self { allocations }
634    }
635
636    /// Returns the allocations.
637    #[must_use]
638    pub fn allocations(&self) -> &[SceneBitrateAlloc] {
639        &self.allocations
640    }
641
642    /// Returns the number of scene allocations.
643    #[must_use]
644    pub fn len(&self) -> usize {
645        self.allocations.len()
646    }
647
648    /// Returns true if no allocations exist.
649    #[must_use]
650    pub fn is_empty(&self) -> bool {
651        self.allocations.is_empty()
652    }
653
654    /// Returns total allocated bits.
655    #[must_use]
656    pub fn total_bits(&self) -> u64 {
657        self.allocations.iter().map(|a| a.total_bits).sum()
658    }
659}
660
661impl Default for SceneBitratePlan {
662    fn default() -> Self {
663        Self::new()
664    }
665}
666
667#[cfg(test)]
668mod tests {
669    use super::*;
670
671    fn make_scene(index: u32, start: u64, end: u64, st: SceneType) -> SceneMetrics {
672        let mut m = SceneMetrics::new(index, start, end);
673        m.scene_type = st;
674        m.spatial_complexity = match st {
675            SceneType::Static => 0.1,
676            SceneType::Dialogue => 0.3,
677            SceneType::Moderate => 0.5,
678            SceneType::Action => 0.8,
679            SceneType::HighDetail => 0.9,
680            SceneType::DarkScene => 0.4,
681            SceneType::Transition => 0.6,
682        };
683        m.temporal_complexity = match st {
684            SceneType::Static => 0.05,
685            SceneType::Dialogue => 0.2,
686            SceneType::Moderate => 0.5,
687            SceneType::Action => 0.9,
688            SceneType::HighDetail => 0.4,
689            SceneType::DarkScene => 0.3,
690            SceneType::Transition => 0.7,
691        };
692        if st == SceneType::DarkScene {
693            m.avg_luminance = 0.1;
694        }
695        m
696    }
697
698    #[test]
699    fn test_scene_metrics_frame_count() {
700        let m = SceneMetrics::new(0, 10, 50);
701        assert_eq!(m.frame_count(), 40);
702    }
703
704    #[test]
705    fn test_scene_metrics_combined_complexity() {
706        let mut m = SceneMetrics::new(0, 0, 100);
707        m.spatial_complexity = 0.8;
708        m.temporal_complexity = 0.6;
709        m.texture_density = 0.5;
710        let cc = m.combined_complexity();
711        // 0.8*0.4 + 0.6*0.4 + 0.5*0.2 = 0.32 + 0.24 + 0.10 = 0.66
712        assert!((cc - 0.66).abs() < 1e-6);
713    }
714
715    #[test]
716    fn test_scene_metrics_is_dark() {
717        let mut m = SceneMetrics::new(0, 0, 100);
718        m.avg_luminance = 0.1;
719        assert!(m.is_dark());
720        m.avg_luminance = 0.5;
721        assert!(!m.is_dark());
722    }
723
724    #[test]
725    fn test_scene_encode_params_default() {
726        let p = SceneEncodeParams::default();
727        assert!((p.qp_offset - 0.0).abs() < 1e-9);
728        assert!((p.bitrate_weight - 1.0).abs() < 1e-9);
729        assert_eq!(p.gop_size, 250);
730    }
731
732    #[test]
733    fn test_effective_qp() {
734        let p = SceneEncodeParams {
735            qp_offset: -3.0,
736            min_qp: 10.0,
737            max_qp: 45.0,
738            ..SceneEncodeParams::default()
739        };
740        assert!((p.effective_qp(28.0) - 25.0).abs() < 1e-9);
741        // Clamp to min
742        assert!((p.effective_qp(11.0) - 10.0).abs() < 1e-9);
743    }
744
745    #[test]
746    fn test_scene_encoder_new() {
747        let enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
748        assert!((enc.base_qp() - 26.0).abs() < 1e-9);
749        assert_eq!(enc.target_bitrate_bps(), 5_000_000);
750    }
751
752    #[test]
753    fn test_generate_params_static() {
754        let enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
755        let scene = make_scene(0, 0, 300, SceneType::Static);
756        let params = enc.generate_params(&scene);
757        assert!(params.qp_offset < 0.0); // Should boost quality
758        assert!(params.gop_size <= 300);
759    }
760
761    #[test]
762    fn test_generate_params_action() {
763        let enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
764        let scene = make_scene(0, 0, 200, SceneType::Action);
765        let params = enc.generate_params(&scene);
766        assert!(params.qp_offset > 0.0); // Should relax
767        assert!(params.b_frames <= 3);
768    }
769
770    #[test]
771    fn test_generate_params_dark() {
772        let enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
773        let scene = make_scene(0, 0, 200, SceneType::DarkScene);
774        let params = enc.generate_params(&scene);
775        assert!(params.aq_strength > 1.0); // Stronger AQ for dark
776    }
777
778    #[test]
779    fn test_process_scene() {
780        let mut enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
781        let scene = make_scene(0, 0, 100, SceneType::Moderate);
782        let _params = enc.process_scene(scene);
783        assert_eq!(enc.scenes_processed(), 1);
784    }
785
786    #[test]
787    fn test_avg_scene_complexity() {
788        let mut enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
789        enc.process_scene(make_scene(0, 0, 100, SceneType::Static));
790        enc.process_scene(make_scene(1, 100, 200, SceneType::Action));
791        let avg = enc.avg_scene_complexity();
792        assert!(avg > 0.0 && avg < 1.0);
793    }
794
795    #[test]
796    fn test_bitrate_plan_distribute() {
797        let scenes = vec![
798            make_scene(0, 0, 100, SceneType::Static),
799            make_scene(1, 100, 300, SceneType::Action),
800        ];
801        let enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
802        let params: Vec<_> = scenes.iter().map(|s| enc.generate_params(s)).collect();
803
804        let plan = SceneBitratePlan::distribute(&scenes, &params, 5_000_000, 30.0);
805        assert_eq!(plan.len(), 2);
806        assert!(!plan.is_empty());
807        assert!(plan.total_bits() > 0);
808    }
809
810    #[test]
811    fn test_bitrate_plan_empty() {
812        let plan = SceneBitratePlan::distribute(&[], &[], 5_000_000, 30.0);
813        assert!(plan.is_empty());
814        assert_eq!(plan.total_bits(), 0);
815    }
816
817    #[test]
818    fn test_bitrate_plan_action_gets_more_bits() {
819        let scenes = vec![
820            make_scene(0, 0, 100, SceneType::Static),
821            make_scene(1, 100, 200, SceneType::Action),
822        ];
823        let enc = SceneEncoder::new(26.0, 5_000_000, 30.0);
824        let params: Vec<_> = scenes.iter().map(|s| enc.generate_params(s)).collect();
825
826        let plan = SceneBitratePlan::distribute(&scenes, &params, 5_000_000, 30.0);
827        let allocs = plan.allocations();
828        // Action scene should get higher bitrate due to higher weight
829        assert!(allocs[1].allocated_bps > allocs[0].allocated_bps);
830    }
831
832    // --- New tests for LookaheadSceneQp ---
833
834    #[test]
835    fn test_lookahead_scene_qp_creation() {
836        let la = LookaheadSceneQp::new(20, 28.0);
837        assert_eq!(la.frames_processed(), 0);
838        assert!((la.base_qp - 28.0).abs() < f64::EPSILON);
839    }
840
841    #[test]
842    fn test_lookahead_scene_qp_no_lookahead() {
843        let mut la = LookaheadSceneQp::new(10, 28.0);
844        let frame = FrameLookaheadInfo::new(0);
845        let result = la.analyze_frame(&frame);
846        // With no lookahead data and neutral complexity, should be near base QP
847        assert!(
848            (result.recommended_qp - 28.0).abs() < 7.0,
849            "QP should be near base: {}",
850            result.recommended_qp
851        );
852    }
853
854    #[test]
855    fn test_lookahead_scene_cut_boost() {
856        let mut la = LookaheadSceneQp::new(10, 28.0);
857        la.set_scene_cut_boost(4.0);
858
859        // Feed some normal frames
860        for i in 0..5 {
861            let mut info = FrameLookaheadInfo::new(i);
862            info.spatial_complexity = 0.5;
863            info.temporal_complexity = 0.5;
864            la.feed_frame(info.clone());
865            la.analyze_frame(&info);
866        }
867
868        // Now a scene cut frame
869        let mut cut_frame = FrameLookaheadInfo::new(5);
870        cut_frame.is_scene_cut = true;
871        cut_frame.spatial_complexity = 0.6;
872        la.feed_frame(cut_frame.clone());
873        let result = la.analyze_frame(&cut_frame);
874
875        assert!(result.near_scene_cut, "Should detect scene cut");
876        // Scene cut should lower QP (boost quality)
877        assert!(
878            result.qp_delta < 0.0,
879            "Scene cut should produce negative QP delta: {}",
880            result.qp_delta
881        );
882    }
883
884    #[test]
885    fn test_lookahead_upcoming_complexity_high() {
886        let mut la = LookaheadSceneQp::new(10, 28.0);
887
888        // Feed high complexity frames into lookahead
889        for i in 0..10 {
890            let mut info = FrameLookaheadInfo::new(i);
891            info.spatial_complexity = 0.9;
892            info.temporal_complexity = 0.8;
893            la.feed_frame(info);
894        }
895
896        let frame = FrameLookaheadInfo::new(0);
897        let result = la.analyze_frame(&frame);
898        // High upcoming complexity should produce negative delta (more bits)
899        assert!(
900            result.upcoming_complexity > 0.7,
901            "Upcoming complexity should be high: {}",
902            result.upcoming_complexity
903        );
904        assert!(
905            result.qp_delta < 0.0,
906            "High complexity should lower QP: {}",
907            result.qp_delta
908        );
909    }
910
911    #[test]
912    fn test_lookahead_upcoming_complexity_low() {
913        let mut la = LookaheadSceneQp::new(10, 28.0);
914
915        // Feed low complexity frames into lookahead
916        for i in 0..10 {
917            let mut info = FrameLookaheadInfo::new(i);
918            info.spatial_complexity = 0.1;
919            info.temporal_complexity = 0.1;
920            la.feed_frame(info);
921        }
922
923        let frame = FrameLookaheadInfo::new(0);
924        let result = la.analyze_frame(&frame);
925        // Low upcoming complexity should produce positive delta (save bits)
926        assert!(
927            result.upcoming_complexity < 0.3,
928            "Upcoming complexity should be low: {}",
929            result.upcoming_complexity
930        );
931        assert!(
932            result.qp_delta > 0.0,
933            "Low complexity should raise QP: {}",
934            result.qp_delta
935        );
936    }
937
938    #[test]
939    fn test_lookahead_distance_to_cut() {
940        let mut la = LookaheadSceneQp::new(10, 28.0);
941
942        // Feed 5 normal frames, then a scene cut
943        for i in 0..5 {
944            la.feed_frame(FrameLookaheadInfo::new(i));
945        }
946        let mut cut = FrameLookaheadInfo::new(5);
947        cut.is_scene_cut = true;
948        la.feed_frame(cut);
949
950        let frame = FrameLookaheadInfo::new(0);
951        let result = la.analyze_frame(&frame);
952        assert!(
953            result.distance_to_next_cut.is_some(),
954            "Should find upcoming scene cut"
955        );
956    }
957
958    #[test]
959    fn test_lookahead_dark_scene_boost() {
960        let mut la = LookaheadSceneQp::new(10, 28.0);
961
962        let mut dark_frame = FrameLookaheadInfo::new(0);
963        dark_frame.avg_luminance = 0.1;
964        dark_frame.spatial_complexity = 0.5;
965        dark_frame.temporal_complexity = 0.5;
966        la.feed_frame(dark_frame.clone());
967
968        let result = la.analyze_frame(&dark_frame);
969        // Dark scene should get quality boost (lower QP)
970        assert!(
971            result.recommended_qp < 28.0 + 1.0,
972            "Dark scene should get lower QP: {}",
973            result.recommended_qp
974        );
975    }
976
977    #[test]
978    fn test_lookahead_qp_clamped() {
979        let mut la = LookaheadSceneQp::new(10, 28.0);
980        la.set_max_qp_delta(3.0);
981
982        // Very high complexity
983        for i in 0..10 {
984            let mut info = FrameLookaheadInfo::new(i);
985            info.spatial_complexity = 1.0;
986            info.temporal_complexity = 1.0;
987            la.feed_frame(info);
988        }
989
990        let frame = FrameLookaheadInfo::new(0);
991        let result = la.analyze_frame(&frame);
992        assert!(
993            result.qp_delta >= -3.0 && result.qp_delta <= 3.0,
994            "QP delta should be clamped to max: {}",
995            result.qp_delta
996        );
997        assert!(result.recommended_qp >= 1.0 && result.recommended_qp <= 51.0);
998    }
999
1000    #[test]
1001    fn test_lookahead_smoothing() {
1002        let mut la = LookaheadSceneQp::new(10, 28.0);
1003        la.set_smoothing(0.5);
1004
1005        // Process a static frame
1006        let mut info1 = FrameLookaheadInfo::new(0);
1007        info1.spatial_complexity = 0.1;
1008        info1.temporal_complexity = 0.1;
1009        la.feed_frame(info1.clone());
1010        let r1 = la.analyze_frame(&info1);
1011
1012        // Then a complex frame (non-scene-cut)
1013        let mut info2 = FrameLookaheadInfo::new(1);
1014        info2.spatial_complexity = 0.9;
1015        info2.temporal_complexity = 0.9;
1016        la.feed_frame(info2.clone());
1017        let r2 = la.analyze_frame(&info2);
1018
1019        // Due to smoothing, the jump should be dampened
1020        let delta_change = (r2.qp_delta - r1.qp_delta).abs();
1021        assert!(
1022            delta_change < 12.0,
1023            "Smoothing should dampen QP changes: delta_change={}",
1024            delta_change
1025        );
1026    }
1027
1028    #[test]
1029    fn test_lookahead_reset() {
1030        let mut la = LookaheadSceneQp::new(10, 28.0);
1031        la.feed_frame(FrameLookaheadInfo::new(0));
1032        la.analyze_frame(&FrameLookaheadInfo::new(0));
1033        la.reset();
1034        assert_eq!(la.frames_processed(), 0);
1035    }
1036
1037    #[test]
1038    fn test_frame_lookahead_info_combined_complexity() {
1039        let mut info = FrameLookaheadInfo::new(0);
1040        info.spatial_complexity = 0.8;
1041        info.temporal_complexity = 0.4;
1042        let cc = info.combined_complexity();
1043        assert!((cc - 0.6).abs() < 0.01);
1044    }
1045
1046    #[test]
1047    fn test_lookahead_multiple_scene_cuts() {
1048        let mut la = LookaheadSceneQp::new(20, 28.0);
1049
1050        // Simulate: normal -> cut -> normal -> cut
1051        for i in 0..5 {
1052            la.feed_frame(FrameLookaheadInfo::new(i));
1053        }
1054        let mut cut1 = FrameLookaheadInfo::new(5);
1055        cut1.is_scene_cut = true;
1056        la.feed_frame(cut1);
1057        for i in 6..10 {
1058            la.feed_frame(FrameLookaheadInfo::new(i));
1059        }
1060        let mut cut2 = FrameLookaheadInfo::new(10);
1061        cut2.is_scene_cut = true;
1062        la.feed_frame(cut2);
1063
1064        let frame = FrameLookaheadInfo::new(0);
1065        let result = la.analyze_frame(&frame);
1066        // Should find the first upcoming cut
1067        assert!(result.distance_to_next_cut.is_some());
1068    }
1069}