use core::time::Duration;
use derive_more::IsVariant;
use std::collections::VecDeque;
use thiserror::Error;
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
use crate::{
content,
frame::{HsvFrame, LumaFrame, RgbFrame, Timebase, Timestamp},
};
#[derive(Debug, Clone, Copy, PartialEq, IsVariant, Error)]
#[non_exhaustive]
pub enum Error {
#[error("window_width must be >= 1")]
ZeroWindowWidth,
#[error("window_width ({0}) is too large (1 + 2 * window_width overflows usize)")]
WindowWidthOverflow(u32),
#[error(transparent)]
Content(#[from] content::Error),
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Options {
adaptive_threshold: f64,
#[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
min_duration: Duration,
window_width: u32,
min_content_val: f64,
weights: content::Components,
kernel_size: Option<u32>,
simd: bool,
initial_cut: bool,
}
impl Default for Options {
#[cfg_attr(not(tarpaulin), inline(always))]
fn default() -> Self {
Self::new()
}
}
impl Options {
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn new() -> Self {
Self {
adaptive_threshold: 3.0,
min_duration: Duration::from_secs(1),
window_width: 2,
min_content_val: 15.0,
weights: content::DEFAULT_WEIGHTS,
kernel_size: None,
simd: true,
initial_cut: true,
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn adaptive_threshold(&self) -> f64 {
self.adaptive_threshold
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_adaptive_threshold(mut self, val: f64) -> Self {
self.adaptive_threshold = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_adaptive_threshold(&mut self, val: f64) -> &mut Self {
self.adaptive_threshold = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn min_duration(&self) -> Duration {
self.min_duration
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_min_duration(mut self, val: Duration) -> Self {
self.min_duration = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_min_duration(&mut self, val: Duration) -> &mut Self {
self.min_duration = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_min_frames(mut self, frames: u32, fps: Timebase) -> Self {
self.min_duration = fps.frames_to_duration(frames);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_min_frames(&mut self, frames: u32, fps: Timebase) -> &mut Self {
self.min_duration = fps.frames_to_duration(frames);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn window_width(&self) -> u32 {
self.window_width
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_window_width(mut self, val: u32) -> Self {
self.window_width = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_window_width(&mut self, val: u32) -> &mut Self {
self.window_width = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn min_content_val(&self) -> f64 {
self.min_content_val
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_min_content_val(mut self, val: f64) -> Self {
self.min_content_val = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_min_content_val(&mut self, val: f64) -> &mut Self {
self.min_content_val = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn weights(&self) -> &content::Components {
&self.weights
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_weights(mut self, val: content::Components) -> Self {
self.weights = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_weights(&mut self, val: content::Components) -> &mut Self {
self.weights = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn kernel_size(&self) -> Option<u32> {
self.kernel_size
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_kernel_size(mut self, val: Option<u32>) -> Self {
self.kernel_size = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_kernel_size(&mut self, val: Option<u32>) -> &mut Self {
self.kernel_size = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn simd(&self) -> bool {
self.simd
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_simd(mut self, val: bool) -> Self {
self.simd = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_simd(&mut self, val: bool) -> &mut Self {
self.simd = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn initial_cut(&self) -> bool {
self.initial_cut
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_initial_cut(mut self, val: bool) -> Self {
self.initial_cut = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_initial_cut(&mut self, val: bool) -> &mut Self {
self.initial_cut = val;
self
}
}
#[derive(Debug, Clone)]
pub struct Detector {
options: Options,
inner: content::Detector,
window_width: usize,
required_frames: usize,
buffer: VecDeque<(Timestamp, f64)>,
buffer_sum: f64,
last_cut_ts: Option<Timestamp>,
last_adaptive_ratio: Option<f64>,
}
impl Detector {
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn new(options: Options) -> Self {
Self::try_new(options).expect("invalid adaptive::Options")
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn try_new(options: Options) -> Result<Self, Error> {
if options.window_width == 0 {
return Err(Error::ZeroWindowWidth);
}
let inner = content::Detector::try_new(Self::build_content_options(&options))?;
let window_width = options.window_width as usize;
let required_frames = window_width
.checked_mul(2)
.and_then(|v| v.checked_add(1))
.ok_or(Error::WindowWidthOverflow(options.window_width))?;
Ok(Self {
options,
inner,
window_width,
required_frames,
buffer: VecDeque::new(),
buffer_sum: 0.0,
last_cut_ts: None,
last_adaptive_ratio: None,
})
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn options(&self) -> &Options {
&self.options
}
#[cfg_attr(not(tarpaulin), inline(always))]
const fn build_content_options(options: &Options) -> content::Options {
content::Options::new()
.with_weights(options.weights)
.with_kernel_size(options.kernel_size)
.with_simd(options.simd)
.with_threshold(f64::INFINITY)
.with_min_duration(Duration::from_secs(0))
.with_filter_mode(content::FilterMode::Suppress)
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn last_adaptive_ratio(&self) -> Option<f64> {
self.last_adaptive_ratio
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn last_score(&self) -> Option<f64> {
self.inner.last_score()
}
pub fn clear(&mut self) {
self.inner.clear();
self.buffer.clear();
self.buffer_sum = 0.0;
self.last_cut_ts = None;
self.last_adaptive_ratio = None;
}
pub fn process_luma(&mut self, frame: LumaFrame<'_>) -> Option<Timestamp> {
let ts = frame.timestamp();
self.inner.process_luma(frame);
self.push_and_check(ts)
}
pub fn process_bgr(&mut self, frame: RgbFrame<'_>) -> Option<Timestamp> {
let ts = frame.timestamp();
self.inner.process_bgr(frame);
self.push_and_check(ts)
}
pub fn process_hsv(&mut self, frame: HsvFrame<'_>) -> Option<Timestamp> {
let ts = frame.timestamp();
self.inner.process_hsv(frame);
self.push_and_check(ts)
}
fn push_and_check(&mut self, ts: Timestamp) -> Option<Timestamp> {
if self.buffer.capacity() == 0 {
self.buffer.reserve_exact(self.required_frames);
}
let score = self.inner.last_score()?;
self.buffer.push_back((ts, score));
self.buffer_sum += score;
while self.buffer.len() > self.required_frames {
if let Some((_, popped)) = self.buffer.pop_front() {
self.buffer_sum -= popped;
}
}
if self.buffer.len() < self.required_frames {
return None;
}
let (target_ts, target_score) = self.buffer[self.window_width];
let denom = (2 * self.window_width) as f64;
let avg = (self.buffer_sum - target_score) / denom;
let adaptive_ratio = if avg.abs() < 1e-5 {
if target_score >= self.options.min_content_val {
255.0
} else {
0.0
}
} else {
(target_score / avg).min(255.0)
};
self.last_adaptive_ratio = Some(adaptive_ratio);
if self.last_cut_ts.is_none() {
self.last_cut_ts = Some(if self.options.initial_cut {
target_ts.saturating_sub_duration(self.options.min_duration)
} else {
target_ts
});
}
let threshold_met = adaptive_ratio >= self.options.adaptive_threshold
&& target_score >= self.options.min_content_val;
let min_length_met = self
.last_cut_ts
.as_ref()
.and_then(|last| target_ts.duration_since(last))
.is_some_and(|d| d >= self.options.min_duration);
if threshold_met && min_length_met {
self.last_cut_ts = Some(target_ts);
Some(target_ts)
} else {
None
}
}
}
#[cfg(all(test, feature = "std"))]
mod tests {
use super::*;
use core::num::NonZeroU32;
const fn nz32(n: u32) -> NonZeroU32 {
match NonZeroU32::new(n) {
Some(v) => v,
None => panic!("zero"),
}
}
fn tb() -> Timebase {
Timebase::new(1, nz32(1000))
}
fn luma_frame<'a>(data: &'a [u8], w: u32, h: u32, pts: i64) -> LumaFrame<'a> {
LumaFrame::new(data, w, h, w, Timestamp::new(pts, tb()))
}
#[test]
fn try_new_rejects_zero_window_width() {
let opts = Options::default().with_window_width(0);
let err = Detector::try_new(opts).expect_err("should fail");
assert_eq!(err, Error::ZeroWindowWidth);
}
#[test]
fn try_new_propagates_content_zero_weights() {
let opts = Options::default().with_weights(content::Components::new(0.0, 0.0, 0.0, 0.0));
let err = Detector::try_new(opts).expect_err("should fail");
assert_eq!(err, Error::Content(content::Error::ZeroWeights));
}
#[test]
fn try_new_propagates_content_invalid_kernel() {
let opts = Options::default().with_kernel_size(Some(4));
let err = Detector::try_new(opts).expect_err("should fail");
assert_eq!(err, Error::Content(content::Error::InvalidKernelSize(4)));
}
#[test]
fn buffer_fills_before_emitting() {
let opts = Options::default()
.with_min_duration(Duration::from_millis(0))
.with_weights(content::LUMA_ONLY_WEIGHTS);
let mut det = Detector::new(opts);
let buf = vec![128u8; 64 * 48];
for i in 0..5i64 {
let cut = det.process_luma(luma_frame(&buf, 64, 48, i * 33));
if i < 4 {
assert!(cut.is_none(), "frame {i} should not emit");
}
}
}
#[test]
fn flat_content_produces_no_cut() {
let opts = Options::default()
.with_min_duration(Duration::from_millis(0))
.with_weights(content::LUMA_ONLY_WEIGHTS);
let mut det = Detector::new(opts);
let buf = vec![128u8; 64 * 48];
let mut emitted = 0;
for i in 0..30i64 {
if det.process_luma(luma_frame(&buf, 64, 48, i * 33)).is_some() {
emitted += 1;
}
}
assert_eq!(emitted, 0, "flat content has zero score → no cut");
}
#[test]
fn isolated_spike_emits_cut() {
let opts = Options::default()
.with_min_duration(Duration::from_millis(0))
.with_weights(content::LUMA_ONLY_WEIGHTS);
let mut det = Detector::new(opts);
let dim = vec![50u8; 64 * 48];
let bright = vec![250u8; 64 * 48];
let frames = [&dim, &dim, &dim, &bright, &dim, &dim, &dim, &dim, &dim];
let mut cuts = Vec::new();
for (i, f) in frames.iter().enumerate() {
let ts = (i as i64) * 33;
if let Some(c) = det.process_luma(luma_frame(f, 64, 48, ts)) {
cuts.push(c.pts());
}
}
assert!(!cuts.is_empty(), "expected at least one cut on spike");
}
#[test]
fn clear_resets_state() {
let opts = Options::default()
.with_min_duration(Duration::from_millis(0))
.with_weights(content::LUMA_ONLY_WEIGHTS);
let mut det = Detector::new(opts);
let buf = vec![128u8; 64 * 48];
for i in 0..10i64 {
det.process_luma(luma_frame(&buf, 64, 48, i * 33));
}
assert!(det.last_adaptive_ratio().is_some());
det.clear();
assert!(det.last_adaptive_ratio().is_none());
assert!(det.last_score().is_none());
}
#[test]
fn options_accessors_builders_setters_roundtrip() {
let fps30 = Timebase::new(30, nz32(1));
let weights = content::Components::new(0.25, 0.5, 0.75, 1.0);
let opts = Options::default()
.with_adaptive_threshold(4.0)
.with_min_duration(Duration::from_millis(250))
.with_window_width(8)
.with_min_content_val(20.0)
.with_weights(weights)
.with_kernel_size(Some(5))
.with_simd(false)
.with_initial_cut(false);
assert_eq!(opts.adaptive_threshold(), 4.0);
assert_eq!(opts.min_duration(), Duration::from_millis(250));
assert_eq!(opts.window_width(), 8);
assert_eq!(opts.min_content_val(), 20.0);
assert_eq!(*opts.weights(), weights);
assert_eq!(opts.kernel_size(), Some(5));
assert!(!opts.simd());
assert!(!opts.initial_cut());
let opts_frames = Options::default().with_min_frames(30, fps30);
assert_eq!(opts_frames.min_duration(), Duration::from_secs(1));
let mut opts = Options::default();
opts
.set_adaptive_threshold(5.0)
.set_min_duration(Duration::from_secs(2))
.set_window_width(16)
.set_min_content_val(30.0)
.set_weights(content::Components::new(1.0, 0.0, 0.0, 0.0))
.set_kernel_size(None)
.set_simd(true)
.set_initial_cut(true);
assert_eq!(opts.adaptive_threshold(), 5.0);
assert_eq!(opts.min_duration(), Duration::from_secs(2));
assert_eq!(opts.window_width(), 16);
assert_eq!(opts.min_content_val(), 30.0);
assert_eq!(opts.kernel_size(), None);
assert!(opts.simd());
assert!(opts.initial_cut());
opts.set_min_frames(60, fps30);
assert_eq!(opts.min_duration(), Duration::from_secs(2));
}
#[test]
fn detector_plumbing_accessors() {
let opts = Options::default()
.with_weights(content::LUMA_ONLY_WEIGHTS)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts.clone());
assert_eq!(det.options().window_width(), opts.window_width());
assert!(det.last_score().is_none());
assert!(det.last_adaptive_ratio().is_none());
let buf = vec![128u8; 64 * 48];
for i in 0..3i64 {
det.process_luma(luma_frame(&buf, 64, 48, i * 33));
}
assert!(det.last_score().is_some());
}
#[test]
fn process_bgr_and_process_hsv_entry_points() {
use crate::frame::{HsvFrame, RgbFrame};
let opts = Options::default().with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let bgr = vec![80u8; 32 * 32 * 3];
det.process_bgr(RgbFrame::new(&bgr, 32, 32, 32 * 3, Timestamp::new(0, tb())));
det.process_bgr(RgbFrame::new(
&bgr,
32,
32,
32 * 3,
Timestamp::new(33, tb()),
));
det.clear();
let h = vec![60u8; 32 * 32];
let s = vec![40u8; 32 * 32];
let v = vec![200u8; 32 * 32];
det.process_hsv(HsvFrame::new(
&h,
&s,
&v,
32,
32,
32,
Timestamp::new(0, tb()),
));
det.process_hsv(HsvFrame::new(
&h,
&s,
&v,
32,
32,
32,
Timestamp::new(33, tb()),
));
assert!(det.last_score().is_some());
}
#[test]
fn adaptive_ratio_saturates_when_neighbors_are_flat() {
let opts = Options::default()
.with_weights(content::LUMA_ONLY_WEIGHTS)
.with_window_width(1)
.with_min_content_val(5.0)
.with_min_duration(Duration::from_millis(0));
let mut det = Detector::new(opts);
let dim = vec![10u8; 32 * 32];
let bright = vec![250u8; 32 * 32];
let frames = [&dim, &dim, &dim, &bright, &dim];
for (i, f) in frames.iter().enumerate() {
det.process_luma(luma_frame(f, 32, 32, (i as i64) * 33));
}
assert!(det.last_adaptive_ratio().is_some());
}
#[test]
fn initial_cut_false_seeds_last_cut_at_target_ts() {
let opts = Options::default()
.with_weights(content::LUMA_ONLY_WEIGHTS)
.with_window_width(1)
.with_min_duration(Duration::from_millis(0))
.with_initial_cut(false);
let mut det = Detector::new(opts);
let buf = vec![128u8; 32 * 32];
for i in 0..5i64 {
det.process_luma(luma_frame(&buf, 32, 32, i * 33));
}
assert!(det.last_adaptive_ratio().is_some());
}
}