#![allow(dead_code)]
#![allow(clippy::cast_precision_loss)]
#[derive(Debug, Clone)]
pub struct SceneChangeIdrConfig {
pub threshold: f32,
pub min_idr_interval: u32,
pub max_gop_length: u32,
pub force_first_idr: bool,
}
impl Default for SceneChangeIdrConfig {
fn default() -> Self {
Self {
threshold: 0.45,
min_idr_interval: 12,
max_gop_length: 250,
force_first_idr: true,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum FrameDecision {
Inter,
ForceIdr,
}
impl FrameDecision {
#[must_use]
pub fn is_idr(self) -> bool {
matches!(self, Self::ForceIdr)
}
}
#[derive(Debug, Clone)]
struct LumaHistogram {
bins: [f32; 256],
}
impl LumaHistogram {
fn from_luma(luma: &[u8]) -> Self {
let mut counts = [0u64; 256];
for &p in luma {
counts[p as usize] += 1;
}
let n = luma.len().max(1) as f32;
let mut bins = [0.0f32; 256];
for (b, &c) in bins.iter_mut().zip(counts.iter()) {
*b = c as f32 / n;
}
Self { bins }
}
fn l1_distance(&self, other: &LumaHistogram) -> f32 {
self.bins
.iter()
.zip(other.bins.iter())
.map(|(&a, &b)| (a - b).abs())
.sum::<f32>()
/ 2.0 }
}
#[derive(Debug)]
pub struct SceneChangeIdrController {
cfg: SceneChangeIdrConfig,
prev_hist: Option<LumaHistogram>,
frames_since_idr: u32,
frame_count: u64,
scene_change_count: u32,
regular_idr_count: u32,
idr_positions: Vec<u64>,
}
impl SceneChangeIdrController {
#[must_use]
pub fn new(cfg: SceneChangeIdrConfig) -> Self {
Self {
cfg,
prev_hist: None,
frames_since_idr: 0,
frame_count: 0,
scene_change_count: 0,
regular_idr_count: 0,
idr_positions: Vec::new(),
}
}
#[must_use]
pub fn default_controller() -> Self {
Self::new(SceneChangeIdrConfig::default())
}
pub fn push_frame(&mut self, luma: &[u8]) -> FrameDecision {
let current_hist = LumaHistogram::from_luma(luma);
let current_index = self.frame_count;
self.frame_count += 1;
if self.cfg.force_first_idr && current_index == 0 {
self.prev_hist = Some(current_hist);
self.frames_since_idr = 0;
self.regular_idr_count += 1;
self.idr_positions.push(current_index);
return FrameDecision::ForceIdr;
}
if self.frames_since_idr >= self.cfg.max_gop_length {
self.prev_hist = Some(current_hist);
self.frames_since_idr = 0;
self.regular_idr_count += 1;
self.idr_positions.push(current_index);
return FrameDecision::ForceIdr;
}
let decision = if let Some(ref prev) = self.prev_hist {
let diff = prev.l1_distance(¤t_hist);
if diff >= self.cfg.threshold && self.frames_since_idr >= self.cfg.min_idr_interval {
self.scene_change_count += 1;
self.idr_positions.push(current_index);
FrameDecision::ForceIdr
} else {
FrameDecision::Inter
}
} else {
self.idr_positions.push(current_index);
FrameDecision::ForceIdr
};
self.prev_hist = Some(current_hist);
if decision == FrameDecision::ForceIdr {
self.frames_since_idr = 0;
} else {
self.frames_since_idr += 1;
}
decision
}
#[must_use]
pub fn frame_count(&self) -> u64 {
self.frame_count
}
#[must_use]
pub fn scene_change_count(&self) -> u32 {
self.scene_change_count
}
#[must_use]
pub fn regular_idr_count(&self) -> u32 {
self.regular_idr_count
}
#[must_use]
pub fn idr_positions(&self) -> &[u64] {
&self.idr_positions
}
pub fn reset(&mut self) {
self.prev_hist = None;
self.frames_since_idr = 0;
self.frame_count = 0;
self.scene_change_count = 0;
self.regular_idr_count = 0;
self.idr_positions.clear();
}
#[must_use]
pub fn config(&self) -> &SceneChangeIdrConfig {
&self.cfg
}
}
#[derive(Debug, Clone, Default)]
pub struct SceneChangeDetector;
impl SceneChangeDetector {
#[must_use]
pub fn new() -> Self {
Self
}
#[must_use]
pub fn is_scene_change(hist_diff: f32, threshold: f32) -> bool {
hist_diff > threshold
}
}
#[cfg(test)]
mod tests {
use super::*;
fn uniform_luma(value: u8, size: usize) -> Vec<u8> {
vec![value; size]
}
fn ramp_luma(size: usize) -> Vec<u8> {
(0..size).map(|i| (i % 256) as u8).collect()
}
#[test]
fn test_first_frame_is_idr() {
let mut ctrl = SceneChangeIdrController::default_controller();
let luma = uniform_luma(128, 1920 * 1080);
let dec = ctrl.push_frame(&luma);
assert_eq!(dec, FrameDecision::ForceIdr);
}
#[test]
fn test_identical_frames_are_inter() {
let mut ctrl = SceneChangeIdrController::default_controller();
let luma = uniform_luma(100, 1024);
ctrl.push_frame(&luma);
let min = ctrl.cfg.min_idr_interval as usize + 1;
for _ in 0..min {
let dec = ctrl.push_frame(&luma);
assert_eq!(dec, FrameDecision::Inter);
}
}
#[test]
fn test_hard_cut_triggers_idr() {
let cfg = SceneChangeIdrConfig {
threshold: 0.30,
min_idr_interval: 2,
max_gop_length: 500,
force_first_idr: true,
};
let mut ctrl = SceneChangeIdrController::new(cfg);
let dark = uniform_luma(10, 1024);
ctrl.push_frame(&dark); ctrl.push_frame(&dark); ctrl.push_frame(&dark);
let bright = uniform_luma(250, 1024);
let dec = ctrl.push_frame(&bright);
assert_eq!(dec, FrameDecision::ForceIdr, "hard cut should force IDR");
}
#[test]
fn test_gop_boundary_triggers_idr() {
let cfg = SceneChangeIdrConfig {
threshold: 0.99, min_idr_interval: 0,
max_gop_length: 5,
force_first_idr: false,
};
let mut ctrl = SceneChangeIdrController::new(cfg);
let luma = ramp_luma(1024);
let decisions: Vec<FrameDecision> = (0..10).map(|_| ctrl.push_frame(&luma)).collect();
let idr_indices: Vec<usize> = decisions
.iter()
.enumerate()
.filter(|(_, &d)| d == FrameDecision::ForceIdr)
.map(|(i, _)| i)
.collect();
assert!(
idr_indices.contains(&0),
"frame 0 should be IDR (no prev hist)"
);
assert!(
idr_indices.contains(&6),
"frame 6 should be IDR (GOP boundary after 5 inter frames)"
);
}
#[test]
fn test_reset_clears_state() {
let mut ctrl = SceneChangeIdrController::default_controller();
let luma = uniform_luma(100, 64);
ctrl.push_frame(&luma);
ctrl.push_frame(&luma);
ctrl.reset();
assert_eq!(ctrl.frame_count(), 0);
assert!(ctrl.idr_positions().is_empty());
}
#[test]
fn test_idr_positions_logged() {
let mut ctrl = SceneChangeIdrController::default_controller();
let luma = uniform_luma(100, 64);
ctrl.push_frame(&luma); assert_eq!(ctrl.idr_positions(), &[0]);
}
#[test]
fn test_frame_decision_is_idr_helper() {
assert!(FrameDecision::ForceIdr.is_idr());
assert!(!FrameDecision::Inter.is_idr());
}
#[test]
fn scene_change_detector_above_threshold() {
assert!(SceneChangeDetector::is_scene_change(0.6, 0.45));
}
#[test]
fn scene_change_detector_at_threshold_is_not_change() {
assert!(!SceneChangeDetector::is_scene_change(0.45, 0.45));
}
#[test]
fn scene_change_detector_below_threshold() {
assert!(!SceneChangeDetector::is_scene_change(0.2, 0.45));
}
#[test]
fn scene_change_detector_zero_diff() {
assert!(!SceneChangeDetector::is_scene_change(0.0, 0.45));
}
#[test]
fn scene_change_detector_max_diff() {
assert!(SceneChangeDetector::is_scene_change(1.0, 0.0));
}
#[test]
fn test_min_idr_interval_respected() {
let cfg = SceneChangeIdrConfig {
threshold: 0.01, min_idr_interval: 10,
max_gop_length: 500,
force_first_idr: true,
};
let mut ctrl = SceneChangeIdrController::new(cfg);
let dark = uniform_luma(0, 512);
let bright = uniform_luma(255, 512);
ctrl.push_frame(&dark); for _ in 0..5 {
let dec = ctrl.push_frame(&bright);
assert_eq!(
dec,
FrameDecision::Inter,
"min_idr_interval should block early IDR"
);
}
}
}