1#![forbid(unsafe_code)]
20#![allow(clippy::cast_precision_loss)]
21#![allow(clippy::cast_possible_truncation)]
22#![allow(clippy::cast_sign_loss)]
23#![allow(clippy::cast_lossless)]
24
25use crate::error::{CodecError, CodecResult};
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
36pub enum SceneContentType {
37 HighMotion,
39 MidMotion,
41 StaticScene,
43 Transition,
45 SceneCut,
47}
48
49impl SceneContentType {
50 #[must_use]
54 pub fn complexity_multiplier(self) -> f32 {
55 match self {
56 Self::HighMotion => 1.55,
57 Self::MidMotion => 1.10,
58 Self::StaticScene => 0.65,
59 Self::Transition => 0.80,
60 Self::SceneCut => 1.40, }
62 }
63
64 #[must_use]
66 pub fn label(self) -> &'static str {
67 match self {
68 Self::HighMotion => "high-motion",
69 Self::MidMotion => "mid-motion",
70 Self::StaticScene => "static",
71 Self::Transition => "transition",
72 Self::SceneCut => "scene-cut",
73 }
74 }
75}
76
77#[derive(Debug, Clone)]
83pub struct FrameContentMetrics {
84 pub frame_index: u64,
86 pub spatial_complexity: f32,
88 pub temporal_complexity: f32,
90 pub normalised_sad: f32,
92 pub is_scene_cut: bool,
94}
95
96impl FrameContentMetrics {
97 #[must_use]
109 pub fn from_raw(
110 frame_index: u64,
111 spatial_var: f32,
112 inter_frame_sad: f64,
113 frame_pixels: u32,
114 ) -> Self {
115 let spatial_complexity = (spatial_var / 16256.0_f32).min(1.0).max(0.0);
117
118 let max_sad = 255.0_f64 * frame_pixels as f64;
120 let normalised_sad = if max_sad > 0.0 {
121 (inter_frame_sad / max_sad).min(1.0).max(0.0) as f32
122 } else {
123 0.0
124 };
125
126 let is_scene_cut = normalised_sad > 0.15;
128 let temporal_complexity = normalised_sad;
129
130 Self {
131 frame_index,
132 spatial_complexity,
133 temporal_complexity,
134 normalised_sad,
135 is_scene_cut,
136 }
137 }
138
139 #[must_use]
141 pub fn classify(&self) -> SceneContentType {
142 if self.is_scene_cut {
143 return SceneContentType::SceneCut;
144 }
145 if self.temporal_complexity > 0.06
147 && self.temporal_complexity < 0.15
148 && self.spatial_complexity < 0.3
149 {
150 return SceneContentType::Transition;
151 }
152 if self.temporal_complexity >= 0.15 {
154 return SceneContentType::HighMotion;
155 }
156 if self.temporal_complexity >= 0.04 {
158 return SceneContentType::MidMotion;
159 }
160 SceneContentType::StaticScene
161 }
162}
163
164#[derive(Debug, Clone)]
170pub struct Scene {
171 pub start_frame: u64,
173 pub end_frame: u64,
175 pub content_type: SceneContentType,
177 pub avg_spatial: f32,
179 pub avg_temporal: f32,
181}
182
183impl Scene {
184 #[must_use]
186 pub fn frame_count(&self) -> u64 {
187 self.end_frame.saturating_sub(self.start_frame) + 1
188 }
189
190 #[must_use]
192 pub fn bit_multiplier(&self) -> f32 {
193 let ct_mult = self.content_type.complexity_multiplier();
195 let spatial_boost = 1.0 + 0.3 * self.avg_spatial;
196 0.6 * ct_mult + 0.4 * spatial_boost
197 }
198}
199
200#[derive(Debug, Clone)]
206pub struct SceneAdaptiveConfig {
207 pub target_bitrate: u64,
209 pub frame_rate: f64,
211 pub scene_cut_threshold: f32,
214 pub min_scene_frames: u32,
217 pub max_per_frame_ratio: f32,
220 pub min_per_frame_ratio: f32,
222}
223
224impl Default for SceneAdaptiveConfig {
225 fn default() -> Self {
226 Self {
227 target_bitrate: 4_000_000, frame_rate: 30.0,
229 scene_cut_threshold: 0.15,
230 min_scene_frames: 4,
231 max_per_frame_ratio: 4.0,
232 min_per_frame_ratio: 0.10,
233 }
234 }
235}
236
237impl SceneAdaptiveConfig {
238 #[must_use]
240 pub fn avg_bits_per_frame(&self) -> f64 {
241 if self.frame_rate > 0.0 {
242 self.target_bitrate as f64 / self.frame_rate
243 } else {
244 0.0
245 }
246 }
247}
248
249#[derive(Debug, Clone)]
251pub struct FrameBitTarget {
252 pub frame_index: u64,
254 pub target_bits: u64,
256 pub content_type: SceneContentType,
258 pub multiplier: f32,
260}
261
262pub struct SceneAdaptiveAllocator {
268 config: SceneAdaptiveConfig,
269 pending: Vec<FrameContentMetrics>,
271 scenes: Vec<Scene>,
273 targets: Vec<FrameBitTarget>,
275 current_scene_frames: Vec<FrameContentMetrics>,
277 frames_since_cut: u32,
279}
280
281impl SceneAdaptiveAllocator {
282 #[must_use]
284 pub fn new(config: SceneAdaptiveConfig) -> Self {
285 Self {
286 config,
287 pending: Vec::new(),
288 scenes: Vec::new(),
289 targets: Vec::new(),
290 current_scene_frames: Vec::new(),
291 frames_since_cut: 0,
292 }
293 }
294
295 pub fn push_frame(&mut self, metrics: FrameContentMetrics) -> CodecResult<()> {
300 self.frames_since_cut += 1;
301
302 let is_cut = metrics.is_scene_cut
303 && self.frames_since_cut >= self.config.min_scene_frames
304 && !self.current_scene_frames.is_empty();
305
306 if is_cut {
307 self.close_current_scene()?;
308 self.frames_since_cut = 0;
309 }
310
311 self.current_scene_frames.push(metrics);
312 Ok(())
313 }
314
315 pub fn flush(&mut self) -> CodecResult<()> {
319 if !self.current_scene_frames.is_empty() {
320 self.close_current_scene()?;
321 }
322 Ok(())
323 }
324
325 pub fn drain_targets(&mut self) -> Vec<FrameBitTarget> {
327 std::mem::take(&mut self.targets)
328 }
329
330 #[must_use]
332 pub fn scenes(&self) -> &[Scene] {
333 &self.scenes
334 }
335
336 fn close_current_scene(&mut self) -> CodecResult<()> {
343 if self.current_scene_frames.is_empty() {
344 return Ok(());
345 }
346
347 let frames = std::mem::take(&mut self.current_scene_frames);
348
349 let n = frames.len() as f32;
351 let avg_spatial = frames.iter().map(|f| f.spatial_complexity).sum::<f32>() / n;
352 let avg_temporal = frames.iter().map(|f| f.temporal_complexity).sum::<f32>() / n;
353
354 let content_type = dominant_content_type(&frames);
356
357 let start_frame = frames
358 .first()
359 .ok_or_else(|| CodecError::InvalidData("empty scene".into()))?
360 .frame_index;
361 let end_frame = frames
362 .last()
363 .ok_or_else(|| CodecError::InvalidData("empty scene".into()))?
364 .frame_index;
365
366 let scene = Scene {
367 start_frame,
368 end_frame,
369 content_type,
370 avg_spatial,
371 avg_temporal,
372 };
373
374 let scene_mult = scene.bit_multiplier();
375 let avg_bits = self.config.avg_bits_per_frame();
376
377 let scene_total_budget = avg_bits * frames.len() as f64 * scene_mult as f64;
380
381 let weights: Vec<f32> = frames
383 .iter()
384 .map(|f| {
385 let ct_mult = f.classify().complexity_multiplier();
386 ct_mult * (1.0 + 0.5 * f.spatial_complexity + 0.5 * f.temporal_complexity)
387 })
388 .collect();
389 let weight_sum: f32 = weights.iter().sum();
390 let weight_sum = if weight_sum > 0.0 { weight_sum } else { 1.0 };
391
392 for (frame_metrics, w) in frames.iter().zip(weights.iter()) {
393 let raw_bits = scene_total_budget * (*w as f64 / weight_sum as f64);
394 let min_bits = avg_bits * self.config.min_per_frame_ratio as f64;
396 let max_bits = avg_bits * self.config.max_per_frame_ratio as f64;
397 let target_bits = raw_bits.min(max_bits).max(min_bits) as u64;
398
399 self.targets.push(FrameBitTarget {
400 frame_index: frame_metrics.frame_index,
401 target_bits,
402 content_type: frame_metrics.classify(),
403 multiplier: *w / (weight_sum / frames.len() as f32),
404 });
405 }
406
407 self.scenes.push(scene);
408 Ok(())
409 }
410}
411
412fn dominant_content_type(frames: &[FrameContentMetrics]) -> SceneContentType {
416 if frames.iter().any(|f| f.is_scene_cut) {
418 return SceneContentType::SceneCut;
419 }
420 let mut counts = [0u32; 5]; for f in frames {
422 let idx = match f.classify() {
423 SceneContentType::HighMotion => 0,
424 SceneContentType::MidMotion => 1,
425 SceneContentType::StaticScene => 2,
426 SceneContentType::Transition => 3,
427 SceneContentType::SceneCut => 4,
428 };
429 counts[idx] += 1;
430 }
431 let max_idx = counts
432 .iter()
433 .enumerate()
434 .max_by_key(|&(_, &c)| c)
435 .map(|(i, _)| i)
436 .unwrap_or(2);
437 match max_idx {
438 0 => SceneContentType::HighMotion,
439 1 => SceneContentType::MidMotion,
440 3 => SceneContentType::Transition,
441 4 => SceneContentType::SceneCut,
442 _ => SceneContentType::StaticScene,
443 }
444}
445
446#[cfg(test)]
451mod tests {
452 use super::*;
453
454 #[test]
457 fn test_from_raw_static_frame() {
458 let m = FrameContentMetrics::from_raw(0, 100.0, 0.001 * 1920.0 * 1080.0, 1920 * 1080);
459 assert!(!m.is_scene_cut);
460 assert!(m.spatial_complexity > 0.0 && m.spatial_complexity < 1.0);
461 assert!(m.temporal_complexity < 0.15);
462 }
463
464 #[test]
465 fn test_from_raw_scene_cut() {
466 let pixels = 1920u32 * 1080;
468 let sad = 0.20 * 255.0 * (pixels as f64);
469 let m = FrameContentMetrics::from_raw(5, 8000.0, sad, pixels);
470 assert!(m.is_scene_cut);
471 assert_eq!(m.classify(), SceneContentType::SceneCut);
472 }
473
474 #[test]
475 fn test_classify_static() {
476 let m = FrameContentMetrics {
477 frame_index: 0,
478 spatial_complexity: 0.1,
479 temporal_complexity: 0.01,
480 normalised_sad: 0.01,
481 is_scene_cut: false,
482 };
483 assert_eq!(m.classify(), SceneContentType::StaticScene);
484 }
485
486 #[test]
487 fn test_classify_high_motion() {
488 let m = FrameContentMetrics {
489 frame_index: 1,
490 spatial_complexity: 0.5,
491 temporal_complexity: 0.40,
492 normalised_sad: 0.40,
493 is_scene_cut: false,
494 };
495 assert_eq!(m.classify(), SceneContentType::HighMotion);
496 }
497
498 #[test]
499 fn test_classify_transition() {
500 let m = FrameContentMetrics {
501 frame_index: 2,
502 spatial_complexity: 0.20,
503 temporal_complexity: 0.10,
504 normalised_sad: 0.10,
505 is_scene_cut: false,
506 };
507 assert_eq!(m.classify(), SceneContentType::Transition);
508 }
509
510 #[test]
513 fn test_multipliers_ordering() {
514 assert!(
516 SceneContentType::HighMotion.complexity_multiplier()
517 > SceneContentType::StaticScene.complexity_multiplier()
518 );
519 assert!(
521 SceneContentType::SceneCut.complexity_multiplier()
522 >= SceneContentType::MidMotion.complexity_multiplier()
523 );
524 }
525
526 fn make_metrics(frame_index: u64, temporal: f32, is_cut: bool) -> FrameContentMetrics {
529 FrameContentMetrics {
530 frame_index,
531 spatial_complexity: 0.3,
532 temporal_complexity: temporal,
533 normalised_sad: temporal,
534 is_scene_cut: is_cut,
535 }
536 }
537
538 #[test]
539 fn test_allocator_single_scene() {
540 let cfg = SceneAdaptiveConfig {
541 target_bitrate: 1_000_000,
542 frame_rate: 10.0,
543 ..Default::default()
544 };
545 let mut alloc = SceneAdaptiveAllocator::new(cfg);
546 for i in 0..10u64 {
547 alloc.push_frame(make_metrics(i, 0.05, false)).unwrap();
548 }
549 alloc.flush().unwrap();
550 let targets = alloc.drain_targets();
551 assert_eq!(targets.len(), 10, "all 10 frames should have targets");
552 for t in &targets {
553 assert!(t.target_bits > 0, "target_bits must be positive");
554 }
555 }
556
557 #[test]
558 fn test_allocator_two_scenes() {
559 let cfg = SceneAdaptiveConfig {
560 target_bitrate: 2_000_000,
561 frame_rate: 25.0,
562 min_scene_frames: 2,
563 ..Default::default()
564 };
565 let mut alloc = SceneAdaptiveAllocator::new(cfg);
566 for i in 0..5u64 {
568 alloc.push_frame(make_metrics(i, 0.01, false)).unwrap();
569 }
570 alloc.push_frame(make_metrics(5, 0.50, true)).unwrap();
572 for i in 6..10u64 {
574 alloc.push_frame(make_metrics(i, 0.35, false)).unwrap();
575 }
576 alloc.flush().unwrap();
577 let targets = alloc.drain_targets();
578 assert_eq!(targets.len(), 10);
579 let scene1_avg: f64 = targets[..5]
581 .iter()
582 .map(|t| t.target_bits as f64)
583 .sum::<f64>()
584 / 5.0;
585 let scene2_avg: f64 = targets[5..]
586 .iter()
587 .map(|t| t.target_bits as f64)
588 .sum::<f64>()
589 / 5.0;
590 assert!(
591 scene2_avg > scene1_avg,
592 "high-motion scene should get more bits: {} vs {}",
593 scene2_avg,
594 scene1_avg
595 );
596 }
597
598 #[test]
599 fn test_allocator_clamps_targets() {
600 let cfg = SceneAdaptiveConfig {
601 target_bitrate: 500_000,
602 frame_rate: 30.0,
603 max_per_frame_ratio: 3.0,
604 min_per_frame_ratio: 0.2,
605 ..Default::default()
606 };
607 let avg_bits = cfg.avg_bits_per_frame();
608 let mut alloc = SceneAdaptiveAllocator::new(cfg.clone());
609 for i in 0..30u64 {
610 alloc.push_frame(make_metrics(i, 0.99, false)).unwrap();
612 }
613 alloc.flush().unwrap();
614 let targets = alloc.drain_targets();
615 for t in &targets {
616 let ratio = t.target_bits as f64 / avg_bits;
617 assert!(
618 ratio <= cfg.max_per_frame_ratio as f64 + 1e-6,
619 "ratio {} exceeds max {}",
620 ratio,
621 cfg.max_per_frame_ratio
622 );
623 assert!(
624 ratio >= cfg.min_per_frame_ratio as f64 - 1e-6,
625 "ratio {} below min {}",
626 ratio,
627 cfg.min_per_frame_ratio
628 );
629 }
630 }
631
632 #[test]
633 fn test_scene_descriptors() {
634 let cfg = SceneAdaptiveConfig {
635 min_scene_frames: 2,
636 ..Default::default()
637 };
638 let mut alloc = SceneAdaptiveAllocator::new(cfg);
639 for i in 0..4u64 {
640 alloc.push_frame(make_metrics(i, 0.01, false)).unwrap();
641 }
642 alloc.push_frame(make_metrics(4, 0.50, true)).unwrap();
643 for i in 5..8u64 {
644 alloc.push_frame(make_metrics(i, 0.20, false)).unwrap();
645 }
646 alloc.flush().unwrap();
647 let scenes = alloc.scenes().to_vec();
648 assert_eq!(scenes.len(), 2, "should detect exactly 2 scenes");
649 assert_eq!(scenes[0].start_frame, 0);
650 assert_eq!(scenes[0].end_frame, 3);
651 assert_eq!(scenes[1].start_frame, 4);
652 }
653
654 #[test]
655 fn test_avg_bits_per_frame() {
656 let cfg = SceneAdaptiveConfig {
657 target_bitrate: 3_000_000,
658 frame_rate: 30.0,
659 ..Default::default()
660 };
661 let expected = 3_000_000.0 / 30.0;
662 let got = cfg.avg_bits_per_frame();
663 assert!(
664 (got - expected).abs() < 1.0,
665 "expected ~{expected}, got {got}"
666 );
667 }
668}