use core::time::Duration;
use crate::frame::{LumaFrame, RgbFrame, TimeRange, Timebase, Timestamp};
use derive_more::{Display, IsVariant};
#[cfg(feature = "serde")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, IsVariant, Display)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
#[display("{}", self.as_str())]
#[non_exhaustive]
pub enum Method {
#[default]
Floor,
Ceiling,
}
impl Method {
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn as_str(&self) -> &'static str {
match self {
Method::Floor => "floor",
Method::Ceiling => "ceiling",
}
}
}
#[derive(Debug, Clone)]
#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
pub struct Options {
threshold: u8,
method: Method,
fade_bias: f64,
add_final_scene: bool,
#[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
min_duration: Duration,
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 {
threshold: 12,
method: Method::Floor,
fade_bias: 0.0,
add_final_scene: false,
min_duration: Duration::from_secs(1),
initial_cut: true,
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn threshold(&self) -> u8 {
self.threshold
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_threshold(mut self, val: u8) -> Self {
self.set_threshold(val);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_threshold(&mut self, val: u8) -> &mut Self {
self.threshold = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn method(&self) -> Method {
self.method
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_method(mut self, val: Method) -> Self {
self.set_method(val);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_method(&mut self, val: Method) -> &mut Self {
self.method = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn fade_bias(&self) -> f64 {
self.fade_bias
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_fade_bias(mut self, val: f64) -> Self {
self.set_fade_bias(val);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_fade_bias(&mut self, val: f64) -> &mut Self {
self.fade_bias = val;
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn add_final_scene(&self) -> bool {
self.add_final_scene
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn with_add_final_scene(mut self, val: bool) -> Self {
self.set_add_final_scene(val);
self
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn set_add_final_scene(&mut self, val: bool) -> &mut Self {
self.add_final_scene = 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.set_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.set_min_frames(frames, fps);
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 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, Copy, PartialEq, Eq, Hash)]
enum FadeType {
In,
Out,
}
#[derive(Debug, Clone)]
pub struct Detector {
options: Options,
processed_frame: bool,
last_scene_cut: Option<Timestamp>,
last_fade_frame: Option<Timestamp>,
last_fade_type: FadeType,
last_avg: Option<f64>,
last_fade_range: Option<TimeRange>,
}
impl Detector {
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn new(options: Options) -> Self {
Self {
options,
processed_frame: false,
last_scene_cut: None,
last_fade_frame: None,
last_fade_type: FadeType::In,
last_avg: None,
last_fade_range: None,
}
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn options(&self) -> &Options {
&self.options
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn last_avg(&self) -> Option<f64> {
self.last_avg
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub const fn last_fade_range(&self) -> Option<TimeRange> {
self.last_fade_range
}
pub fn process_luma(&mut self, frame: LumaFrame<'_>) -> Option<Timestamp> {
let mean = luma_mean(&frame);
self.process_with_mean(mean, frame.timestamp())
}
pub fn process_rgb(&mut self, frame: RgbFrame<'_>) -> Option<Timestamp> {
let mean = rgb_mean(&frame);
self.process_with_mean(mean, frame.timestamp())
}
pub fn finish(&mut self, _last_ts: Timestamp) -> Option<Timestamp> {
let cut = self.final_cut();
let range_after = cut.map(TimeRange::instant);
self.clear();
self.last_fade_range = range_after;
cut
}
fn final_cut(&self) -> Option<Timestamp> {
if !self.options.add_final_scene {
return None;
}
if self.last_fade_type != FadeType::Out {
return None;
}
let fade_frame = self.last_fade_frame?;
let min_elapsed = match &self.last_scene_cut {
Some(last) => fade_frame
.duration_since(last)
.is_some_and(|d| d >= self.options.min_duration),
None => true,
};
if min_elapsed { Some(fade_frame) } else { None }
}
#[cfg_attr(not(tarpaulin), inline(always))]
pub fn clear(&mut self) {
self.processed_frame = false;
self.last_scene_cut = None;
self.last_fade_frame = None;
self.last_fade_type = FadeType::In;
self.last_avg = None;
self.last_fade_range = None;
}
fn process_with_mean(&mut self, mean: f64, ts: Timestamp) -> Option<Timestamp> {
self.last_avg = Some(mean);
if self.last_scene_cut.is_none() {
self.last_scene_cut = Some(if self.options.initial_cut {
ts.saturating_sub_duration(self.options.min_duration)
} else {
ts
});
}
let thresh = self.options.threshold as f64;
let dark = match self.options.method {
Method::Floor => mean < thresh,
Method::Ceiling => mean >= thresh,
};
let mut cut: Option<Timestamp> = None;
if self.processed_frame {
match self.last_fade_type {
FadeType::In if dark => {
self.last_fade_type = FadeType::Out;
self.last_fade_frame = Some(ts);
}
FadeType::Out if !dark => {
if let Some(f_out) = self.last_fade_frame {
let placed = interpolate_cut(f_out, ts, self.options.fade_bias);
let min_elapsed = match &self.last_scene_cut {
Some(last) => placed
.duration_since(last)
.is_some_and(|d| d >= self.options.min_duration),
None => true,
};
if min_elapsed {
cut = Some(placed);
self.last_scene_cut = Some(placed);
let f_in_same = ts.rescale_to(f_out.timebase());
self.last_fade_range = Some(TimeRange::new(
f_out.pts(),
f_in_same.pts(),
f_out.timebase(),
));
}
}
self.last_fade_type = FadeType::In;
self.last_fade_frame = Some(ts);
}
_ => {}
}
} else {
self.last_fade_frame = Some(ts);
self.last_fade_type = if dark { FadeType::Out } else { FadeType::In };
self.processed_frame = true;
}
cut
}
}
fn luma_mean(frame: &LumaFrame<'_>) -> f64 {
let data = frame.data();
let w = frame.width() as usize;
let h = frame.height() as usize;
let s = frame.stride() as usize;
let mut sum: u64 = 0;
for y in 0..h {
let row_start = y * s;
let row = &data[row_start..row_start + w];
for &v in row {
sum += v as u64;
}
}
let n = w * h;
if n == 0 { 0.0 } else { sum as f64 / n as f64 }
}
fn rgb_mean(frame: &RgbFrame<'_>) -> f64 {
let data = frame.data();
let w = frame.width() as usize;
let h = frame.height() as usize;
let s = frame.stride() as usize;
let row_bytes = w * 3;
let mut sum: u64 = 0;
for y in 0..h {
let row_start = y * s;
let row = &data[row_start..row_start + row_bytes];
for &v in row {
sum += v as u64;
}
}
let n = row_bytes * h;
if n == 0 { 0.0 } else { sum as f64 / n as f64 }
}
fn interpolate_cut(f_out: Timestamp, f_in: Timestamp, bias: f64) -> Timestamp {
let bias = bias.clamp(-1.0, 1.0);
let f_in_same = if f_in.timebase() == f_out.timebase() {
f_in
} else {
f_in.rescale_to(f_out.timebase())
};
let delta = f_in_same.pts() - f_out.pts();
let lerp = (1.0 + bias) * 0.5;
let offset = (delta as f64 * lerp) as i64;
Timestamp::new(f_out.pts() + offset, f_out.timebase())
}
#[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(data: &[u8], w: u32, h: u32, pts: i64) -> LumaFrame<'_> {
LumaFrame::new(data, w, h, w, Timestamp::new(pts, tb()))
}
fn rgb(data: &[u8], w: u32, h: u32, pts: i64) -> RgbFrame<'_> {
RgbFrame::new(data, w, h, w * 3, Timestamp::new(pts, tb()))
}
#[test]
fn luma_mean_uniform() {
let buf = [128u8; 64 * 48];
let m = luma_mean(&luma(&buf, 64, 48, 0));
assert!((m - 128.0).abs() < 1e-9);
}
#[test]
fn rgb_mean_uniform() {
let buf = [64u8; 32 * 24 * 3];
let m = rgb_mean(&rgb(&buf, 32, 24, 0));
assert!((m - 64.0).abs() < 1e-9);
}
#[test]
fn rgb_mean_mixed_channels() {
let mut buf = vec![0u8; 4 * 4 * 3];
for i in 0..(4 * 4) {
buf[i * 3] = 30;
buf[i * 3 + 1] = 60;
buf[i * 3 + 2] = 150;
}
let m = rgb_mean(&rgb(&buf, 4, 4, 0));
assert!((m - 80.0).abs() < 1e-9);
}
#[test]
fn interpolate_cut_midpoint_mixed_timebase() {
let f_out = Timestamp::new(1000, Timebase::new(1, nz32(1000)));
let f_in = Timestamp::new(180_000, Timebase::new(1, nz32(90_000)));
let cut = interpolate_cut(f_out, f_in, 0.0);
assert_eq!(cut.pts(), 1500);
assert_eq!(cut.timebase(), f_out.timebase());
}
#[test]
fn interpolate_cut_bias_bounds() {
let f_out = Timestamp::new(100, Timebase::new(1, nz32(1000)));
let f_in = Timestamp::new(200, Timebase::new(1, nz32(1000)));
assert_eq!(interpolate_cut(f_out, f_in, -1.0).pts(), 100);
assert_eq!(interpolate_cut(f_out, f_in, 1.0).pts(), 200);
assert_eq!(interpolate_cut(f_out, f_in, -5.0).pts(), 100);
assert_eq!(interpolate_cut(f_out, f_in, 5.0).pts(), 200);
}
fn uniform_luma(intensity: u8, _pts: i64) -> Vec<u8> {
vec![intensity; 64]
}
#[test]
fn first_frame_emits_no_cut() {
let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
let buf = uniform_luma(5, 0);
assert!(det.process_luma(luma(&buf, 8, 8, 0)).is_none());
assert_eq!(det.last_avg(), Some(5.0));
}
#[test]
fn fade_out_then_fade_in_emits_cut_at_midpoint() {
let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
assert!(det.process_luma(luma(&bright, 8, 8, 0)).is_none());
assert!(det.process_luma(luma(&bright, 8, 8, 100)).is_none());
assert!(det.process_luma(luma(&dark, 8, 8, 200)).is_none());
assert!(det.process_luma(luma(&dark, 8, 8, 300)).is_none());
let cut = det.process_luma(luma(&bright, 8, 8, 400));
assert!(cut.is_some(), "expected cut on fade-in");
assert_eq!(cut.unwrap().pts(), 300);
}
#[test]
fn fade_bias_places_cut_at_fade_out_or_fade_in() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_fade_bias(-1.0),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200));
let cut = det.process_luma(luma(&bright, 8, 8, 400)).unwrap();
assert_eq!(cut.pts(), 200);
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_fade_bias(1.0),
);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200));
let cut = det.process_luma(luma(&bright, 8, 8, 400)).unwrap();
assert_eq!(cut.pts(), 400);
}
#[test]
fn min_duration_suppresses_cuts() {
let mut det = Detector::new(Options::default());
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 1000));
let c1 = det.process_luma(luma(&bright, 8, 8, 1500));
assert!(c1.is_some(), "first cut should fire (gap >= 1s from seed)");
det.process_luma(luma(&dark, 8, 8, 1600));
let c2 = det.process_luma(luma(&bright, 8, 8, 1700));
assert!(c2.is_none(), "second cut should be suppressed within 1s");
}
#[test]
fn ceiling_method_fires_on_rising_edge() {
let mut det = Detector::new(
Options::default()
.with_method(Method::Ceiling)
.with_threshold(200)
.with_min_duration(Duration::from_millis(0)),
);
let dim = uniform_luma(100, 0);
let bright = uniform_luma(250, 0);
det.process_luma(luma(&dim, 8, 8, 0));
det.process_luma(luma(&bright, 8, 8, 100));
let cut = det.process_luma(luma(&dim, 8, 8, 200));
assert!(cut.is_some());
}
#[test]
fn last_fade_range_exposes_full_endpoints() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_fade_bias(0.0),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200)); let cut = det.process_luma(luma(&bright, 8, 8, 400)).expect("cut");
assert_eq!(cut.pts(), 300);
let range = det.last_fade_range().expect("range");
assert_eq!(range.start_pts(), 200);
assert_eq!(range.end_pts(), 400);
assert_eq!(range.timebase(), tb());
assert_eq!(range.duration(), Some(Duration::from_millis(200)));
assert_eq!(range.interpolate(0.5).pts(), 300);
}
#[test]
fn last_fade_range_cleared_by_clear() {
let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200));
det.process_luma(luma(&bright, 8, 8, 400));
assert!(det.last_fade_range().is_some());
det.clear();
assert!(det.last_fade_range().is_none());
}
#[test]
fn last_fade_range_survives_finish_as_instant() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_add_final_scene(true),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200)); let final_cut = det.finish(Timestamp::new(400, tb())).expect("final cut");
assert_eq!(final_cut.pts(), 200);
let range = det.last_fade_range().expect("range after finish");
assert!(range.is_instant());
assert_eq!(range.start_pts(), 200);
assert_eq!(range.end_pts(), 200);
}
#[test]
fn finish_emits_final_cut_when_ending_in_fade_out() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_add_final_scene(true),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&bright, 8, 8, 100));
det.process_luma(luma(&dark, 8, 8, 200));
det.process_luma(luma(&dark, 8, 8, 300));
let final_cut = det.finish(Timestamp::new(400, tb()));
assert!(final_cut.is_some());
assert_eq!(final_cut.unwrap().pts(), 200);
}
#[test]
fn finish_returns_none_when_add_final_scene_disabled() {
let mut det = Detector::new(
Options::default().with_min_duration(Duration::from_millis(0)),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200));
assert!(det.finish(Timestamp::new(400, tb())).is_none());
}
#[test]
fn finish_clears_state() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_add_final_scene(true),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 200));
assert!(det.last_avg().is_some());
let final_cut = det.finish(Timestamp::new(400, tb()));
assert!(final_cut.is_some());
assert!(
det.last_avg().is_none(),
"finish should have cleared last_avg"
);
assert!(det.finish(Timestamp::new(500, tb())).is_none());
assert!(det.process_luma(luma(&bright, 8, 8, 1_000_000)).is_none());
det.process_luma(luma(&dark, 8, 8, 1_000_200));
let cut = det.process_luma(luma(&bright, 8, 8, 1_000_400));
assert!(cut.is_some(), "detector should be reusable after finish()");
}
#[test]
fn finish_returns_none_when_ending_in_fade_in() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(0))
.with_add_final_scene(true),
);
let bright = uniform_luma(200, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&bright, 8, 8, 100));
assert!(det.finish(Timestamp::new(200, tb())).is_none());
}
#[test]
fn clear_resets_stream_state() {
let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 100));
let cut1 = det.process_luma(luma(&bright, 8, 8, 200));
assert!(cut1.is_some());
det.clear();
assert!(det.last_avg().is_none());
assert!(det.process_luma(luma(&dark, 8, 8, 1_000_000)).is_none());
let cut2 = det.process_luma(luma(&bright, 8, 8, 1_000_100));
assert!(cut2.is_some(), "cut detection resumes after clear");
}
#[test]
fn min_duration_gate_measured_from_emitted_cut_not_fade_in() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(200))
.with_fade_bias(0.0),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 100));
let cut1 = det.process_luma(luma(&bright, 8, 8, 200)).expect("cut1");
assert_eq!(cut1.pts(), 150);
det.process_luma(luma(&dark, 8, 8, 300));
let cut2 = det.process_luma(luma(&bright, 8, 8, 400));
assert!(
cut2.is_some(),
"cut2 should fire — 350 - 150 = 200 ms meets the gate",
);
assert_eq!(cut2.unwrap().pts(), 350);
}
#[test]
fn final_cut_gated_on_fade_frame_not_last_ts() {
let mut det = Detector::new(
Options::default()
.with_min_duration(Duration::from_millis(200))
.with_fade_bias(0.0)
.with_add_final_scene(true),
);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 100));
det.process_luma(luma(&bright, 8, 8, 200));
det.process_luma(luma(&dark, 8, 8, 250));
let final_cut = det.finish(Timestamp::new(10_000, tb()));
assert!(
final_cut.is_none(),
"final cut must be suppressed — 250 is only 100 ms from the previous cut (150)"
);
}
#[test]
fn process_rgb_equivalent_to_luma_for_uniform_frames() {
let mut det_l = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
let mut det_r = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
let luma_bright = uniform_luma(200, 0);
let luma_dark = uniform_luma(5, 0);
let rgb_bright = vec![200u8; 64 * 3];
let rgb_dark = vec![5u8; 64 * 3];
det_l.process_luma(luma(&luma_bright, 8, 8, 0));
det_l.process_luma(luma(&luma_dark, 8, 8, 200));
let cut_l = det_l.process_luma(luma(&luma_bright, 8, 8, 400));
det_r.process_rgb(rgb(&rgb_bright, 8, 8, 0));
det_r.process_rgb(rgb(&rgb_dark, 8, 8, 200));
let cut_r = det_r.process_rgb(rgb(&rgb_bright, 8, 8, 400));
assert_eq!(cut_l.map(|t| t.pts()), cut_r.map(|t| t.pts()));
}
#[test]
fn method_as_str_all_variants() {
assert_eq!(Method::Floor.as_str(), "floor");
assert_eq!(Method::Ceiling.as_str(), "ceiling");
}
#[test]
fn options_accessors_builders_setters_roundtrip() {
let fps30 = Timebase::new(30, nz32(1));
let opts = Options::default()
.with_threshold(50)
.with_method(Method::Ceiling)
.with_fade_bias(0.25)
.with_add_final_scene(true)
.with_min_duration(Duration::from_millis(750))
.with_initial_cut(false);
assert_eq!(opts.threshold(), 50);
assert_eq!(opts.method(), Method::Ceiling);
assert_eq!(opts.fade_bias(), 0.25);
assert!(opts.add_final_scene());
assert_eq!(opts.min_duration(), Duration::from_millis(750));
assert!(!opts.initial_cut());
let opts_frames = Options::default().with_min_frames(15, fps30);
assert_eq!(opts_frames.min_duration(), Duration::from_millis(500));
let mut opts = Options::default();
opts
.set_threshold(100)
.set_method(Method::Floor)
.set_fade_bias(-0.5)
.set_add_final_scene(true)
.set_min_duration(Duration::from_secs(2))
.set_initial_cut(true);
assert_eq!(opts.threshold(), 100);
assert_eq!(opts.method(), Method::Floor);
assert_eq!(opts.fade_bias(), -0.5);
assert!(opts.add_final_scene());
assert!(opts.initial_cut());
opts.set_min_frames(60, fps30);
assert_eq!(opts.min_duration(), Duration::from_secs(2));
}
#[test]
fn detector_options_accessor() {
let opts = Options::default().with_threshold(77);
let det = Detector::new(opts);
assert_eq!(det.options().threshold(), 77);
}
#[test]
fn initial_cut_false_seeds_last_cut_at_ts() {
let opts = Options::default()
.with_min_duration(Duration::from_millis(200))
.with_initial_cut(false);
let mut det = Detector::new(opts);
let bright = uniform_luma(200, 0);
let dark = uniform_luma(5, 0);
det.process_luma(luma(&bright, 8, 8, 0));
det.process_luma(luma(&dark, 8, 8, 50));
let cut = det.process_luma(luma(&bright, 8, 8, 150));
assert!(
cut.is_none(),
"cut should be suppressed with initial_cut=false"
);
}
}