#![forbid(unsafe_code)]
#![allow(clippy::cast_precision_loss)]
#![allow(clippy::cast_possible_truncation)]
#![allow(clippy::cast_sign_loss)]
#![allow(clippy::cast_lossless)]
use crate::error::{CodecError, CodecResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SceneContentType {
HighMotion,
MidMotion,
StaticScene,
Transition,
SceneCut,
}
impl SceneContentType {
#[must_use]
pub fn complexity_multiplier(self) -> f32 {
match self {
Self::HighMotion => 1.55,
Self::MidMotion => 1.10,
Self::StaticScene => 0.65,
Self::Transition => 0.80,
Self::SceneCut => 1.40, }
}
#[must_use]
pub fn label(self) -> &'static str {
match self {
Self::HighMotion => "high-motion",
Self::MidMotion => "mid-motion",
Self::StaticScene => "static",
Self::Transition => "transition",
Self::SceneCut => "scene-cut",
}
}
}
#[derive(Debug, Clone)]
pub struct FrameContentMetrics {
pub frame_index: u64,
pub spatial_complexity: f32,
pub temporal_complexity: f32,
pub normalised_sad: f32,
pub is_scene_cut: bool,
}
impl FrameContentMetrics {
#[must_use]
pub fn from_raw(
frame_index: u64,
spatial_var: f32,
inter_frame_sad: f64,
frame_pixels: u32,
) -> Self {
let spatial_complexity = (spatial_var / 16256.0_f32).min(1.0).max(0.0);
let max_sad = 255.0_f64 * frame_pixels as f64;
let normalised_sad = if max_sad > 0.0 {
(inter_frame_sad / max_sad).min(1.0).max(0.0) as f32
} else {
0.0
};
let is_scene_cut = normalised_sad > 0.15;
let temporal_complexity = normalised_sad;
Self {
frame_index,
spatial_complexity,
temporal_complexity,
normalised_sad,
is_scene_cut,
}
}
#[must_use]
pub fn classify(&self) -> SceneContentType {
if self.is_scene_cut {
return SceneContentType::SceneCut;
}
if self.temporal_complexity > 0.06
&& self.temporal_complexity < 0.15
&& self.spatial_complexity < 0.3
{
return SceneContentType::Transition;
}
if self.temporal_complexity >= 0.15 {
return SceneContentType::HighMotion;
}
if self.temporal_complexity >= 0.04 {
return SceneContentType::MidMotion;
}
SceneContentType::StaticScene
}
}
#[derive(Debug, Clone)]
pub struct Scene {
pub start_frame: u64,
pub end_frame: u64,
pub content_type: SceneContentType,
pub avg_spatial: f32,
pub avg_temporal: f32,
}
impl Scene {
#[must_use]
pub fn frame_count(&self) -> u64 {
self.end_frame.saturating_sub(self.start_frame) + 1
}
#[must_use]
pub fn bit_multiplier(&self) -> f32 {
let ct_mult = self.content_type.complexity_multiplier();
let spatial_boost = 1.0 + 0.3 * self.avg_spatial;
0.6 * ct_mult + 0.4 * spatial_boost
}
}
#[derive(Debug, Clone)]
pub struct SceneAdaptiveConfig {
pub target_bitrate: u64,
pub frame_rate: f64,
pub scene_cut_threshold: f32,
pub min_scene_frames: u32,
pub max_per_frame_ratio: f32,
pub min_per_frame_ratio: f32,
}
impl Default for SceneAdaptiveConfig {
fn default() -> Self {
Self {
target_bitrate: 4_000_000, frame_rate: 30.0,
scene_cut_threshold: 0.15,
min_scene_frames: 4,
max_per_frame_ratio: 4.0,
min_per_frame_ratio: 0.10,
}
}
}
impl SceneAdaptiveConfig {
#[must_use]
pub fn avg_bits_per_frame(&self) -> f64 {
if self.frame_rate > 0.0 {
self.target_bitrate as f64 / self.frame_rate
} else {
0.0
}
}
}
#[derive(Debug, Clone)]
pub struct FrameBitTarget {
pub frame_index: u64,
pub target_bits: u64,
pub content_type: SceneContentType,
pub multiplier: f32,
}
pub struct SceneAdaptiveAllocator {
config: SceneAdaptiveConfig,
pending: Vec<FrameContentMetrics>,
scenes: Vec<Scene>,
targets: Vec<FrameBitTarget>,
current_scene_frames: Vec<FrameContentMetrics>,
frames_since_cut: u32,
}
impl SceneAdaptiveAllocator {
#[must_use]
pub fn new(config: SceneAdaptiveConfig) -> Self {
Self {
config,
pending: Vec::new(),
scenes: Vec::new(),
targets: Vec::new(),
current_scene_frames: Vec::new(),
frames_since_cut: 0,
}
}
pub fn push_frame(&mut self, metrics: FrameContentMetrics) -> CodecResult<()> {
self.frames_since_cut += 1;
let is_cut = metrics.is_scene_cut
&& self.frames_since_cut >= self.config.min_scene_frames
&& !self.current_scene_frames.is_empty();
if is_cut {
self.close_current_scene()?;
self.frames_since_cut = 0;
}
self.current_scene_frames.push(metrics);
Ok(())
}
pub fn flush(&mut self) -> CodecResult<()> {
if !self.current_scene_frames.is_empty() {
self.close_current_scene()?;
}
Ok(())
}
pub fn drain_targets(&mut self) -> Vec<FrameBitTarget> {
std::mem::take(&mut self.targets)
}
#[must_use]
pub fn scenes(&self) -> &[Scene] {
&self.scenes
}
fn close_current_scene(&mut self) -> CodecResult<()> {
if self.current_scene_frames.is_empty() {
return Ok(());
}
let frames = std::mem::take(&mut self.current_scene_frames);
let n = frames.len() as f32;
let avg_spatial = frames.iter().map(|f| f.spatial_complexity).sum::<f32>() / n;
let avg_temporal = frames.iter().map(|f| f.temporal_complexity).sum::<f32>() / n;
let content_type = dominant_content_type(&frames);
let start_frame = frames
.first()
.ok_or_else(|| CodecError::InvalidData("empty scene".into()))?
.frame_index;
let end_frame = frames
.last()
.ok_or_else(|| CodecError::InvalidData("empty scene".into()))?
.frame_index;
let scene = Scene {
start_frame,
end_frame,
content_type,
avg_spatial,
avg_temporal,
};
let scene_mult = scene.bit_multiplier();
let avg_bits = self.config.avg_bits_per_frame();
let scene_total_budget = avg_bits * frames.len() as f64 * scene_mult as f64;
let weights: Vec<f32> = frames
.iter()
.map(|f| {
let ct_mult = f.classify().complexity_multiplier();
ct_mult * (1.0 + 0.5 * f.spatial_complexity + 0.5 * f.temporal_complexity)
})
.collect();
let weight_sum: f32 = weights.iter().sum();
let weight_sum = if weight_sum > 0.0 { weight_sum } else { 1.0 };
for (frame_metrics, w) in frames.iter().zip(weights.iter()) {
let raw_bits = scene_total_budget * (*w as f64 / weight_sum as f64);
let min_bits = avg_bits * self.config.min_per_frame_ratio as f64;
let max_bits = avg_bits * self.config.max_per_frame_ratio as f64;
let target_bits = raw_bits.min(max_bits).max(min_bits) as u64;
self.targets.push(FrameBitTarget {
frame_index: frame_metrics.frame_index,
target_bits,
content_type: frame_metrics.classify(),
multiplier: *w / (weight_sum / frames.len() as f32),
});
}
self.scenes.push(scene);
Ok(())
}
}
fn dominant_content_type(frames: &[FrameContentMetrics]) -> SceneContentType {
if frames.iter().any(|f| f.is_scene_cut) {
return SceneContentType::SceneCut;
}
let mut counts = [0u32; 5]; for f in frames {
let idx = match f.classify() {
SceneContentType::HighMotion => 0,
SceneContentType::MidMotion => 1,
SceneContentType::StaticScene => 2,
SceneContentType::Transition => 3,
SceneContentType::SceneCut => 4,
};
counts[idx] += 1;
}
let max_idx = counts
.iter()
.enumerate()
.max_by_key(|&(_, &c)| c)
.map(|(i, _)| i)
.unwrap_or(2);
match max_idx {
0 => SceneContentType::HighMotion,
1 => SceneContentType::MidMotion,
3 => SceneContentType::Transition,
4 => SceneContentType::SceneCut,
_ => SceneContentType::StaticScene,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_from_raw_static_frame() {
let m = FrameContentMetrics::from_raw(0, 100.0, 0.001 * 1920.0 * 1080.0, 1920 * 1080);
assert!(!m.is_scene_cut);
assert!(m.spatial_complexity > 0.0 && m.spatial_complexity < 1.0);
assert!(m.temporal_complexity < 0.15);
}
#[test]
fn test_from_raw_scene_cut() {
let pixels = 1920u32 * 1080;
let sad = 0.20 * 255.0 * (pixels as f64);
let m = FrameContentMetrics::from_raw(5, 8000.0, sad, pixels);
assert!(m.is_scene_cut);
assert_eq!(m.classify(), SceneContentType::SceneCut);
}
#[test]
fn test_classify_static() {
let m = FrameContentMetrics {
frame_index: 0,
spatial_complexity: 0.1,
temporal_complexity: 0.01,
normalised_sad: 0.01,
is_scene_cut: false,
};
assert_eq!(m.classify(), SceneContentType::StaticScene);
}
#[test]
fn test_classify_high_motion() {
let m = FrameContentMetrics {
frame_index: 1,
spatial_complexity: 0.5,
temporal_complexity: 0.40,
normalised_sad: 0.40,
is_scene_cut: false,
};
assert_eq!(m.classify(), SceneContentType::HighMotion);
}
#[test]
fn test_classify_transition() {
let m = FrameContentMetrics {
frame_index: 2,
spatial_complexity: 0.20,
temporal_complexity: 0.10,
normalised_sad: 0.10,
is_scene_cut: false,
};
assert_eq!(m.classify(), SceneContentType::Transition);
}
#[test]
fn test_multipliers_ordering() {
assert!(
SceneContentType::HighMotion.complexity_multiplier()
> SceneContentType::StaticScene.complexity_multiplier()
);
assert!(
SceneContentType::SceneCut.complexity_multiplier()
>= SceneContentType::MidMotion.complexity_multiplier()
);
}
fn make_metrics(frame_index: u64, temporal: f32, is_cut: bool) -> FrameContentMetrics {
FrameContentMetrics {
frame_index,
spatial_complexity: 0.3,
temporal_complexity: temporal,
normalised_sad: temporal,
is_scene_cut: is_cut,
}
}
#[test]
fn test_allocator_single_scene() {
let cfg = SceneAdaptiveConfig {
target_bitrate: 1_000_000,
frame_rate: 10.0,
..Default::default()
};
let mut alloc = SceneAdaptiveAllocator::new(cfg);
for i in 0..10u64 {
alloc.push_frame(make_metrics(i, 0.05, false)).unwrap();
}
alloc.flush().unwrap();
let targets = alloc.drain_targets();
assert_eq!(targets.len(), 10, "all 10 frames should have targets");
for t in &targets {
assert!(t.target_bits > 0, "target_bits must be positive");
}
}
#[test]
fn test_allocator_two_scenes() {
let cfg = SceneAdaptiveConfig {
target_bitrate: 2_000_000,
frame_rate: 25.0,
min_scene_frames: 2,
..Default::default()
};
let mut alloc = SceneAdaptiveAllocator::new(cfg);
for i in 0..5u64 {
alloc.push_frame(make_metrics(i, 0.01, false)).unwrap();
}
alloc.push_frame(make_metrics(5, 0.50, true)).unwrap();
for i in 6..10u64 {
alloc.push_frame(make_metrics(i, 0.35, false)).unwrap();
}
alloc.flush().unwrap();
let targets = alloc.drain_targets();
assert_eq!(targets.len(), 10);
let scene1_avg: f64 = targets[..5]
.iter()
.map(|t| t.target_bits as f64)
.sum::<f64>()
/ 5.0;
let scene2_avg: f64 = targets[5..]
.iter()
.map(|t| t.target_bits as f64)
.sum::<f64>()
/ 5.0;
assert!(
scene2_avg > scene1_avg,
"high-motion scene should get more bits: {} vs {}",
scene2_avg,
scene1_avg
);
}
#[test]
fn test_allocator_clamps_targets() {
let cfg = SceneAdaptiveConfig {
target_bitrate: 500_000,
frame_rate: 30.0,
max_per_frame_ratio: 3.0,
min_per_frame_ratio: 0.2,
..Default::default()
};
let avg_bits = cfg.avg_bits_per_frame();
let mut alloc = SceneAdaptiveAllocator::new(cfg.clone());
for i in 0..30u64 {
alloc.push_frame(make_metrics(i, 0.99, false)).unwrap();
}
alloc.flush().unwrap();
let targets = alloc.drain_targets();
for t in &targets {
let ratio = t.target_bits as f64 / avg_bits;
assert!(
ratio <= cfg.max_per_frame_ratio as f64 + 1e-6,
"ratio {} exceeds max {}",
ratio,
cfg.max_per_frame_ratio
);
assert!(
ratio >= cfg.min_per_frame_ratio as f64 - 1e-6,
"ratio {} below min {}",
ratio,
cfg.min_per_frame_ratio
);
}
}
#[test]
fn test_scene_descriptors() {
let cfg = SceneAdaptiveConfig {
min_scene_frames: 2,
..Default::default()
};
let mut alloc = SceneAdaptiveAllocator::new(cfg);
for i in 0..4u64 {
alloc.push_frame(make_metrics(i, 0.01, false)).unwrap();
}
alloc.push_frame(make_metrics(4, 0.50, true)).unwrap();
for i in 5..8u64 {
alloc.push_frame(make_metrics(i, 0.20, false)).unwrap();
}
alloc.flush().unwrap();
let scenes = alloc.scenes().to_vec();
assert_eq!(scenes.len(), 2, "should detect exactly 2 scenes");
assert_eq!(scenes[0].start_frame, 0);
assert_eq!(scenes[0].end_frame, 3);
assert_eq!(scenes[1].start_frame, 4);
}
#[test]
fn test_avg_bits_per_frame() {
let cfg = SceneAdaptiveConfig {
target_bitrate: 3_000_000,
frame_rate: 30.0,
..Default::default()
};
let expected = 3_000_000.0 / 30.0;
let got = cfg.avg_bits_per_frame();
assert!(
(got - expected).abs() < 1.0,
"expected ~{expected}, got {got}"
);
}
}