1use core::time::Duration;
69
70use crate::frame::{LumaFrame, RgbFrame, TimeRange, Timebase, Timestamp};
71
72use derive_more::{Display, IsVariant};
73
74#[cfg(feature = "serde")]
75use serde::{Deserialize, Serialize};
76
77#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, IsVariant, Display)]
79#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
80#[cfg_attr(feature = "serde", serde(rename_all = "snake_case"))]
81#[display("{}", self.as_str())]
82#[non_exhaustive]
83pub enum Method {
84 #[default]
87 Floor,
88 Ceiling,
91}
92
93impl Method {
94 #[cfg_attr(not(tarpaulin), inline(always))]
96 pub const fn as_str(&self) -> &'static str {
97 match self {
98 Method::Floor => "floor",
99 Method::Ceiling => "ceiling",
100 }
101 }
102}
103
104#[derive(Debug, Clone)]
107#[cfg_attr(feature = "serde", derive(Serialize, Deserialize))]
108pub struct Options {
109 threshold: u8,
110 method: Method,
111 fade_bias: f64,
112 add_final_scene: bool,
113 #[cfg_attr(feature = "serde", serde(with = "humantime_serde"))]
114 min_duration: Duration,
115 initial_cut: bool,
116}
117
118impl Default for Options {
119 #[cfg_attr(not(tarpaulin), inline(always))]
120 fn default() -> Self {
121 Self::new()
122 }
123}
124
125impl Options {
126 #[cfg_attr(not(tarpaulin), inline(always))]
131 pub const fn new() -> Self {
132 Self {
133 threshold: 12,
134 method: Method::Floor,
135 fade_bias: 0.0,
136 add_final_scene: false,
137 min_duration: Duration::from_secs(1),
138 initial_cut: true,
139 }
140 }
141
142 #[cfg_attr(not(tarpaulin), inline(always))]
147 pub const fn threshold(&self) -> u8 {
148 self.threshold
149 }
150
151 #[cfg_attr(not(tarpaulin), inline(always))]
153 pub const fn with_threshold(mut self, val: u8) -> Self {
154 self.set_threshold(val);
155 self
156 }
157
158 #[cfg_attr(not(tarpaulin), inline(always))]
160 pub const fn set_threshold(&mut self, val: u8) -> &mut Self {
161 self.threshold = val;
162 self
163 }
164
165 #[cfg_attr(not(tarpaulin), inline(always))]
167 pub const fn method(&self) -> Method {
168 self.method
169 }
170
171 #[cfg_attr(not(tarpaulin), inline(always))]
173 pub const fn with_method(mut self, val: Method) -> Self {
174 self.set_method(val);
175 self
176 }
177
178 #[cfg_attr(not(tarpaulin), inline(always))]
180 pub const fn set_method(&mut self, val: Method) -> &mut Self {
181 self.method = val;
182 self
183 }
184
185 #[cfg_attr(not(tarpaulin), inline(always))]
190 pub const fn fade_bias(&self) -> f64 {
191 self.fade_bias
192 }
193
194 #[cfg_attr(not(tarpaulin), inline(always))]
196 pub const fn with_fade_bias(mut self, val: f64) -> Self {
197 self.set_fade_bias(val);
198 self
199 }
200
201 #[cfg_attr(not(tarpaulin), inline(always))]
203 pub const fn set_fade_bias(&mut self, val: f64) -> &mut Self {
204 self.fade_bias = val;
205 self
206 }
207
208 #[cfg_attr(not(tarpaulin), inline(always))]
211 pub const fn add_final_scene(&self) -> bool {
212 self.add_final_scene
213 }
214
215 #[cfg_attr(not(tarpaulin), inline(always))]
217 pub const fn with_add_final_scene(mut self, val: bool) -> Self {
218 self.set_add_final_scene(val);
219 self
220 }
221
222 #[cfg_attr(not(tarpaulin), inline(always))]
224 pub const fn set_add_final_scene(&mut self, val: bool) -> &mut Self {
225 self.add_final_scene = val;
226 self
227 }
228
229 #[cfg_attr(not(tarpaulin), inline(always))]
231 pub const fn min_duration(&self) -> Duration {
232 self.min_duration
233 }
234
235 #[cfg_attr(not(tarpaulin), inline(always))]
237 pub const fn with_min_duration(mut self, val: Duration) -> Self {
238 self.set_min_duration(val);
239 self
240 }
241
242 #[cfg_attr(not(tarpaulin), inline(always))]
244 pub const fn set_min_duration(&mut self, val: Duration) -> &mut Self {
245 self.min_duration = val;
246 self
247 }
248
249 #[cfg_attr(not(tarpaulin), inline(always))]
253 pub const fn with_min_frames(mut self, frames: u32, fps: Timebase) -> Self {
254 self.set_min_frames(frames, fps);
255 self
256 }
257
258 #[cfg_attr(not(tarpaulin), inline(always))]
260 pub const fn set_min_frames(&mut self, frames: u32, fps: Timebase) -> &mut Self {
261 self.min_duration = fps.frames_to_duration(frames);
262 self
263 }
264
265 #[cfg_attr(not(tarpaulin), inline(always))]
272 pub const fn initial_cut(&self) -> bool {
273 self.initial_cut
274 }
275
276 #[cfg_attr(not(tarpaulin), inline(always))]
278 pub const fn with_initial_cut(mut self, val: bool) -> Self {
279 self.initial_cut = val;
280 self
281 }
282
283 #[cfg_attr(not(tarpaulin), inline(always))]
285 pub const fn set_initial_cut(&mut self, val: bool) -> &mut Self {
286 self.initial_cut = val;
287 self
288 }
289}
290
291#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
293enum FadeType {
294 In,
296 Out,
298}
299
300#[derive(Debug, Clone)]
303pub struct Detector {
304 options: Options,
305 processed_frame: bool,
306 last_scene_cut: Option<Timestamp>,
307 last_fade_frame: Option<Timestamp>,
309 last_fade_type: FadeType,
310 last_avg: Option<f64>,
311 last_fade_range: Option<TimeRange>,
315}
316
317impl Detector {
318 #[cfg_attr(not(tarpaulin), inline(always))]
320 pub fn new(options: Options) -> Self {
321 Self {
322 options,
323 processed_frame: false,
324 last_scene_cut: None,
325 last_fade_frame: None,
326 last_fade_type: FadeType::In,
327 last_avg: None,
328 last_fade_range: None,
329 }
330 }
331
332 #[cfg_attr(not(tarpaulin), inline(always))]
334 pub const fn options(&self) -> &Options {
335 &self.options
336 }
337
338 #[cfg_attr(not(tarpaulin), inline(always))]
342 pub const fn last_avg(&self) -> Option<f64> {
343 self.last_avg
344 }
345
346 #[cfg_attr(not(tarpaulin), inline(always))]
361 pub const fn last_fade_range(&self) -> Option<TimeRange> {
362 self.last_fade_range
363 }
364
365 pub fn process_luma(&mut self, frame: LumaFrame<'_>) -> Option<Timestamp> {
370 let mean = luma_mean(&frame);
371 self.process_with_mean(mean, frame.timestamp())
372 }
373
374 pub fn process_rgb(&mut self, frame: RgbFrame<'_>) -> Option<Timestamp> {
381 let mean = rgb_mean(&frame);
382 self.process_with_mean(mean, frame.timestamp())
383 }
384
385 pub fn finish(&mut self, _last_ts: Timestamp) -> Option<Timestamp> {
397 let cut = self.final_cut();
398 let range_after = cut.map(TimeRange::instant);
403 self.clear();
404 self.last_fade_range = range_after;
405 cut
406 }
407
408 fn final_cut(&self) -> Option<Timestamp> {
411 if !self.options.add_final_scene {
412 return None;
413 }
414 if self.last_fade_type != FadeType::Out {
415 return None;
416 }
417 let fade_frame = self.last_fade_frame?;
418 let min_elapsed = match &self.last_scene_cut {
423 Some(last) => fade_frame
424 .duration_since(last)
425 .is_some_and(|d| d >= self.options.min_duration),
426 None => true,
427 };
428 if min_elapsed { Some(fade_frame) } else { None }
429 }
430
431 #[cfg_attr(not(tarpaulin), inline(always))]
434 pub fn clear(&mut self) {
435 self.processed_frame = false;
436 self.last_scene_cut = None;
437 self.last_fade_frame = None;
438 self.last_fade_type = FadeType::In;
439 self.last_avg = None;
440 self.last_fade_range = None;
441 }
442
443 fn process_with_mean(&mut self, mean: f64, ts: Timestamp) -> Option<Timestamp> {
445 self.last_avg = Some(mean);
446 if self.last_scene_cut.is_none() {
447 self.last_scene_cut = Some(if self.options.initial_cut {
448 ts.saturating_sub_duration(self.options.min_duration)
449 } else {
450 ts
451 });
452 }
453
454 let thresh = self.options.threshold as f64;
455 let dark = match self.options.method {
459 Method::Floor => mean < thresh,
460 Method::Ceiling => mean >= thresh,
461 };
462
463 let mut cut: Option<Timestamp> = None;
464
465 if self.processed_frame {
466 match self.last_fade_type {
467 FadeType::In if dark => {
468 self.last_fade_type = FadeType::Out;
470 self.last_fade_frame = Some(ts);
471 }
472 FadeType::Out if !dark => {
473 if let Some(f_out) = self.last_fade_frame {
475 let placed = interpolate_cut(f_out, ts, self.options.fade_bias);
476 let min_elapsed = match &self.last_scene_cut {
480 Some(last) => placed
481 .duration_since(last)
482 .is_some_and(|d| d >= self.options.min_duration),
483 None => true,
484 };
485 if min_elapsed {
486 cut = Some(placed);
487 self.last_scene_cut = Some(placed);
488 let f_in_same = ts.rescale_to(f_out.timebase());
493 self.last_fade_range = Some(TimeRange::new(
494 f_out.pts(),
495 f_in_same.pts(),
496 f_out.timebase(),
497 ));
498 }
499 }
500 self.last_fade_type = FadeType::In;
501 self.last_fade_frame = Some(ts);
502 }
503 _ => {}
504 }
505 } else {
506 self.last_fade_frame = Some(ts);
508 self.last_fade_type = if dark { FadeType::Out } else { FadeType::In };
509 self.processed_frame = true;
510 }
511
512 cut
513 }
514}
515
516fn luma_mean(frame: &LumaFrame<'_>) -> f64 {
519 let data = frame.data();
520 let w = frame.width() as usize;
521 let h = frame.height() as usize;
522 let s = frame.stride() as usize;
523 let mut sum: u64 = 0;
524 for y in 0..h {
525 let row_start = y * s;
526 let row = &data[row_start..row_start + w];
527 for &v in row {
528 sum += v as u64;
529 }
530 }
531 let n = w * h;
532 if n == 0 { 0.0 } else { sum as f64 / n as f64 }
533}
534
535fn rgb_mean(frame: &RgbFrame<'_>) -> f64 {
538 let data = frame.data();
539 let w = frame.width() as usize;
540 let h = frame.height() as usize;
541 let s = frame.stride() as usize;
542 let row_bytes = w * 3;
543 let mut sum: u64 = 0;
544 for y in 0..h {
545 let row_start = y * s;
546 let row = &data[row_start..row_start + row_bytes];
547 for &v in row {
548 sum += v as u64;
549 }
550 }
551 let n = row_bytes * h;
552 if n == 0 { 0.0 } else { sum as f64 / n as f64 }
553}
554
555fn interpolate_cut(f_out: Timestamp, f_in: Timestamp, bias: f64) -> Timestamp {
563 let bias = bias.clamp(-1.0, 1.0);
564 let f_in_same = if f_in.timebase() == f_out.timebase() {
565 f_in
566 } else {
567 f_in.rescale_to(f_out.timebase())
568 };
569 let delta = f_in_same.pts() - f_out.pts();
570 let lerp = (1.0 + bias) * 0.5;
571 let offset = (delta as f64 * lerp) as i64;
572 Timestamp::new(f_out.pts() + offset, f_out.timebase())
573}
574
575#[cfg(all(test, feature = "std"))]
576mod tests {
577 use super::*;
578 use core::num::NonZeroU32;
579
580 const fn nz32(n: u32) -> NonZeroU32 {
581 match NonZeroU32::new(n) {
582 Some(v) => v,
583 None => panic!("zero"),
584 }
585 }
586
587 fn tb() -> Timebase {
588 Timebase::new(1, nz32(1000)) }
590
591 fn luma(data: &[u8], w: u32, h: u32, pts: i64) -> LumaFrame<'_> {
592 LumaFrame::new(data, w, h, w, Timestamp::new(pts, tb()))
593 }
594
595 fn rgb(data: &[u8], w: u32, h: u32, pts: i64) -> RgbFrame<'_> {
596 RgbFrame::new(data, w, h, w * 3, Timestamp::new(pts, tb()))
597 }
598
599 #[test]
600 fn luma_mean_uniform() {
601 let buf = [128u8; 64 * 48];
602 let m = luma_mean(&luma(&buf, 64, 48, 0));
603 assert!((m - 128.0).abs() < 1e-9);
604 }
605
606 #[test]
607 fn rgb_mean_uniform() {
608 let buf = [64u8; 32 * 24 * 3];
609 let m = rgb_mean(&rgb(&buf, 32, 24, 0));
610 assert!((m - 64.0).abs() < 1e-9);
611 }
612
613 #[test]
614 fn rgb_mean_mixed_channels() {
615 let mut buf = vec![0u8; 4 * 4 * 3];
617 for i in 0..(4 * 4) {
618 buf[i * 3] = 30;
619 buf[i * 3 + 1] = 60;
620 buf[i * 3 + 2] = 150;
621 }
622 let m = rgb_mean(&rgb(&buf, 4, 4, 0));
623 assert!((m - 80.0).abs() < 1e-9);
624 }
625
626 #[test]
627 fn interpolate_cut_midpoint_mixed_timebase() {
628 let f_out = Timestamp::new(1000, Timebase::new(1, nz32(1000)));
630 let f_in = Timestamp::new(180_000, Timebase::new(1, nz32(90_000)));
631 let cut = interpolate_cut(f_out, f_in, 0.0);
632 assert_eq!(cut.pts(), 1500);
634 assert_eq!(cut.timebase(), f_out.timebase());
635 }
636
637 #[test]
638 fn interpolate_cut_bias_bounds() {
639 let f_out = Timestamp::new(100, Timebase::new(1, nz32(1000)));
640 let f_in = Timestamp::new(200, Timebase::new(1, nz32(1000)));
641 assert_eq!(interpolate_cut(f_out, f_in, -1.0).pts(), 100);
642 assert_eq!(interpolate_cut(f_out, f_in, 1.0).pts(), 200);
643 assert_eq!(interpolate_cut(f_out, f_in, -5.0).pts(), 100);
645 assert_eq!(interpolate_cut(f_out, f_in, 5.0).pts(), 200);
646 }
647
648 fn uniform_luma(intensity: u8, _pts: i64) -> Vec<u8> {
650 vec![intensity; 64]
651 }
652
653 #[test]
654 fn first_frame_emits_no_cut() {
655 let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
656 let buf = uniform_luma(5, 0);
658 assert!(det.process_luma(luma(&buf, 8, 8, 0)).is_none());
659 assert_eq!(det.last_avg(), Some(5.0));
660 }
661
662 #[test]
663 fn fade_out_then_fade_in_emits_cut_at_midpoint() {
664 let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
667
668 let bright = uniform_luma(200, 0);
669 let dark = uniform_luma(5, 0);
670
671 assert!(det.process_luma(luma(&bright, 8, 8, 0)).is_none());
673 assert!(det.process_luma(luma(&bright, 8, 8, 100)).is_none());
674 assert!(det.process_luma(luma(&dark, 8, 8, 200)).is_none());
676 assert!(det.process_luma(luma(&dark, 8, 8, 300)).is_none());
677 let cut = det.process_luma(luma(&bright, 8, 8, 400));
679 assert!(cut.is_some(), "expected cut on fade-in");
680 assert_eq!(cut.unwrap().pts(), 300);
681 }
682
683 #[test]
684 fn fade_bias_places_cut_at_fade_out_or_fade_in() {
685 let mut det = Detector::new(
687 Options::default()
688 .with_min_duration(Duration::from_millis(0))
689 .with_fade_bias(-1.0),
690 );
691 let bright = uniform_luma(200, 0);
692 let dark = uniform_luma(5, 0);
693 det.process_luma(luma(&bright, 8, 8, 0));
694 det.process_luma(luma(&dark, 8, 8, 200));
695 let cut = det.process_luma(luma(&bright, 8, 8, 400)).unwrap();
696 assert_eq!(cut.pts(), 200);
697
698 let mut det = Detector::new(
700 Options::default()
701 .with_min_duration(Duration::from_millis(0))
702 .with_fade_bias(1.0),
703 );
704 det.process_luma(luma(&bright, 8, 8, 0));
705 det.process_luma(luma(&dark, 8, 8, 200));
706 let cut = det.process_luma(luma(&bright, 8, 8, 400)).unwrap();
707 assert_eq!(cut.pts(), 400);
708 }
709
710 #[test]
711 fn min_duration_suppresses_cuts() {
712 let mut det = Detector::new(Options::default());
716 let bright = uniform_luma(200, 0);
717 let dark = uniform_luma(5, 0);
718
719 det.process_luma(luma(&bright, 8, 8, 0));
722 det.process_luma(luma(&dark, 8, 8, 1000));
723 let c1 = det.process_luma(luma(&bright, 8, 8, 1500));
724 assert!(c1.is_some(), "first cut should fire (gap >= 1s from seed)");
725
726 det.process_luma(luma(&dark, 8, 8, 1600));
729 let c2 = det.process_luma(luma(&bright, 8, 8, 1700));
730 assert!(c2.is_none(), "second cut should be suppressed within 1s");
731 }
732
733 #[test]
734 fn ceiling_method_fires_on_rising_edge() {
735 let mut det = Detector::new(
737 Options::default()
738 .with_method(Method::Ceiling)
739 .with_threshold(200)
740 .with_min_duration(Duration::from_millis(0)),
741 );
742 let dim = uniform_luma(100, 0);
743 let bright = uniform_luma(250, 0);
744
745 det.process_luma(luma(&dim, 8, 8, 0));
746 det.process_luma(luma(&bright, 8, 8, 100));
748 let cut = det.process_luma(luma(&dim, 8, 8, 200));
750 assert!(cut.is_some());
751 }
752
753 #[test]
754 fn last_fade_range_exposes_full_endpoints() {
755 let mut det = Detector::new(
756 Options::default()
757 .with_min_duration(Duration::from_millis(0))
758 .with_fade_bias(0.0),
759 );
760 let bright = uniform_luma(200, 0);
761 let dark = uniform_luma(5, 0);
762
763 det.process_luma(luma(&bright, 8, 8, 0));
764 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);
769
770 let range = det.last_fade_range().expect("range");
772 assert_eq!(range.start_pts(), 200);
773 assert_eq!(range.end_pts(), 400);
774 assert_eq!(range.timebase(), tb());
775 assert_eq!(range.duration(), Some(Duration::from_millis(200)));
777 assert_eq!(range.interpolate(0.5).pts(), 300);
779 }
780
781 #[test]
782 fn last_fade_range_cleared_by_clear() {
783 let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
784 let bright = uniform_luma(200, 0);
785 let dark = uniform_luma(5, 0);
786 det.process_luma(luma(&bright, 8, 8, 0));
787 det.process_luma(luma(&dark, 8, 8, 200));
788 det.process_luma(luma(&bright, 8, 8, 400));
789 assert!(det.last_fade_range().is_some());
790 det.clear();
791 assert!(det.last_fade_range().is_none());
792 }
793
794 #[test]
795 fn last_fade_range_survives_finish_as_instant() {
796 let mut det = Detector::new(
797 Options::default()
798 .with_min_duration(Duration::from_millis(0))
799 .with_add_final_scene(true),
800 );
801 let bright = uniform_luma(200, 0);
802 let dark = uniform_luma(5, 0);
803 det.process_luma(luma(&bright, 8, 8, 0));
804 det.process_luma(luma(&dark, 8, 8, 200)); let final_cut = det.finish(Timestamp::new(400, tb())).expect("final cut");
806 assert_eq!(final_cut.pts(), 200);
807 let range = det.last_fade_range().expect("range after finish");
809 assert!(range.is_instant());
810 assert_eq!(range.start_pts(), 200);
811 assert_eq!(range.end_pts(), 200);
812 }
813
814 #[test]
815 fn finish_emits_final_cut_when_ending_in_fade_out() {
816 let mut det = Detector::new(
817 Options::default()
818 .with_min_duration(Duration::from_millis(0))
819 .with_add_final_scene(true),
820 );
821 let bright = uniform_luma(200, 0);
822 let dark = uniform_luma(5, 0);
823
824 det.process_luma(luma(&bright, 8, 8, 0));
825 det.process_luma(luma(&bright, 8, 8, 100));
826 det.process_luma(luma(&dark, 8, 8, 200));
828 det.process_luma(luma(&dark, 8, 8, 300));
829
830 let final_cut = det.finish(Timestamp::new(400, tb()));
831 assert!(final_cut.is_some());
832 assert_eq!(final_cut.unwrap().pts(), 200);
833 }
834
835 #[test]
836 fn finish_returns_none_when_add_final_scene_disabled() {
837 let mut det = Detector::new(
838 Options::default().with_min_duration(Duration::from_millis(0)),
839 );
841 let bright = uniform_luma(200, 0);
842 let dark = uniform_luma(5, 0);
843 det.process_luma(luma(&bright, 8, 8, 0));
844 det.process_luma(luma(&dark, 8, 8, 200));
845 assert!(det.finish(Timestamp::new(400, tb())).is_none());
846 }
847
848 #[test]
849 fn finish_clears_state() {
850 let mut det = Detector::new(
853 Options::default()
854 .with_min_duration(Duration::from_millis(0))
855 .with_add_final_scene(true),
856 );
857 let bright = uniform_luma(200, 0);
858 let dark = uniform_luma(5, 0);
859
860 det.process_luma(luma(&bright, 8, 8, 0));
861 det.process_luma(luma(&dark, 8, 8, 200));
862 assert!(det.last_avg().is_some());
863
864 let final_cut = det.finish(Timestamp::new(400, tb()));
865 assert!(final_cut.is_some());
866 assert!(
867 det.last_avg().is_none(),
868 "finish should have cleared last_avg"
869 );
870
871 assert!(det.finish(Timestamp::new(500, tb())).is_none());
873
874 assert!(det.process_luma(luma(&bright, 8, 8, 1_000_000)).is_none());
876 det.process_luma(luma(&dark, 8, 8, 1_000_200));
877 let cut = det.process_luma(luma(&bright, 8, 8, 1_000_400));
878 assert!(cut.is_some(), "detector should be reusable after finish()");
879 }
880
881 #[test]
882 fn finish_returns_none_when_ending_in_fade_in() {
883 let mut det = Detector::new(
884 Options::default()
885 .with_min_duration(Duration::from_millis(0))
886 .with_add_final_scene(true),
887 );
888 let bright = uniform_luma(200, 0);
889 det.process_luma(luma(&bright, 8, 8, 0));
890 det.process_luma(luma(&bright, 8, 8, 100));
891 assert!(det.finish(Timestamp::new(200, tb())).is_none());
892 }
893
894 #[test]
895 fn clear_resets_stream_state() {
896 let mut det = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
897 let bright = uniform_luma(200, 0);
898 let dark = uniform_luma(5, 0);
899
900 det.process_luma(luma(&bright, 8, 8, 0));
902 det.process_luma(luma(&dark, 8, 8, 100));
903 let cut1 = det.process_luma(luma(&bright, 8, 8, 200));
904 assert!(cut1.is_some());
905
906 det.clear();
907 assert!(det.last_avg().is_none());
908
909 assert!(det.process_luma(luma(&dark, 8, 8, 1_000_000)).is_none());
911 let cut2 = det.process_luma(luma(&bright, 8, 8, 1_000_100));
915 assert!(cut2.is_some(), "cut detection resumes after clear");
916 }
917
918 #[test]
919 fn min_duration_gate_measured_from_emitted_cut_not_fade_in() {
920 let mut det = Detector::new(
944 Options::default()
945 .with_min_duration(Duration::from_millis(200))
946 .with_fade_bias(0.0),
947 );
948 let bright = uniform_luma(200, 0);
949 let dark = uniform_luma(5, 0);
950
951 det.process_luma(luma(&bright, 8, 8, 0));
952 det.process_luma(luma(&dark, 8, 8, 100));
953 let cut1 = det.process_luma(luma(&bright, 8, 8, 200)).expect("cut1");
954 assert_eq!(cut1.pts(), 150);
955
956 det.process_luma(luma(&dark, 8, 8, 300));
957 let cut2 = det.process_luma(luma(&bright, 8, 8, 400));
958 assert!(
959 cut2.is_some(),
960 "cut2 should fire — 350 - 150 = 200 ms meets the gate",
961 );
962 assert_eq!(cut2.unwrap().pts(), 350);
963 }
964
965 #[test]
966 fn final_cut_gated_on_fade_frame_not_last_ts() {
967 let mut det = Detector::new(
980 Options::default()
981 .with_min_duration(Duration::from_millis(200))
982 .with_fade_bias(0.0)
983 .with_add_final_scene(true),
984 );
985 let bright = uniform_luma(200, 0);
986 let dark = uniform_luma(5, 0);
987
988 det.process_luma(luma(&bright, 8, 8, 0));
989 det.process_luma(luma(&dark, 8, 8, 100));
990 det.process_luma(luma(&bright, 8, 8, 200));
991 det.process_luma(luma(&dark, 8, 8, 250));
992
993 let final_cut = det.finish(Timestamp::new(10_000, tb()));
994 assert!(
995 final_cut.is_none(),
996 "final cut must be suppressed — 250 is only 100 ms from the previous cut (150)"
997 );
998 }
999
1000 #[test]
1001 fn process_rgb_equivalent_to_luma_for_uniform_frames() {
1002 let mut det_l = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
1005 let mut det_r = Detector::new(Options::default().with_min_duration(Duration::from_millis(0)));
1006
1007 let luma_bright = uniform_luma(200, 0);
1008 let luma_dark = uniform_luma(5, 0);
1009 let rgb_bright = vec![200u8; 64 * 3];
1010 let rgb_dark = vec![5u8; 64 * 3];
1011
1012 det_l.process_luma(luma(&luma_bright, 8, 8, 0));
1013 det_l.process_luma(luma(&luma_dark, 8, 8, 200));
1014 let cut_l = det_l.process_luma(luma(&luma_bright, 8, 8, 400));
1015
1016 det_r.process_rgb(rgb(&rgb_bright, 8, 8, 0));
1017 det_r.process_rgb(rgb(&rgb_dark, 8, 8, 200));
1018 let cut_r = det_r.process_rgb(rgb(&rgb_bright, 8, 8, 400));
1019
1020 assert_eq!(cut_l.map(|t| t.pts()), cut_r.map(|t| t.pts()));
1021 }
1022
1023 #[test]
1024 fn method_as_str_all_variants() {
1025 assert_eq!(Method::Floor.as_str(), "floor");
1026 assert_eq!(Method::Ceiling.as_str(), "ceiling");
1027 }
1028
1029 #[test]
1030 fn options_accessors_builders_setters_roundtrip() {
1031 let fps30 = Timebase::new(30, nz32(1));
1032
1033 let opts = Options::default()
1035 .with_threshold(50)
1036 .with_method(Method::Ceiling)
1037 .with_fade_bias(0.25)
1038 .with_add_final_scene(true)
1039 .with_min_duration(Duration::from_millis(750))
1040 .with_initial_cut(false);
1041 assert_eq!(opts.threshold(), 50);
1042 assert_eq!(opts.method(), Method::Ceiling);
1043 assert_eq!(opts.fade_bias(), 0.25);
1044 assert!(opts.add_final_scene());
1045 assert_eq!(opts.min_duration(), Duration::from_millis(750));
1046 assert!(!opts.initial_cut());
1047
1048 let opts_frames = Options::default().with_min_frames(15, fps30);
1050 assert_eq!(opts_frames.min_duration(), Duration::from_millis(500));
1051
1052 let mut opts = Options::default();
1054 opts
1055 .set_threshold(100)
1056 .set_method(Method::Floor)
1057 .set_fade_bias(-0.5)
1058 .set_add_final_scene(true)
1059 .set_min_duration(Duration::from_secs(2))
1060 .set_initial_cut(true);
1061 assert_eq!(opts.threshold(), 100);
1062 assert_eq!(opts.method(), Method::Floor);
1063 assert_eq!(opts.fade_bias(), -0.5);
1064 assert!(opts.add_final_scene());
1065 assert!(opts.initial_cut());
1066
1067 opts.set_min_frames(60, fps30);
1068 assert_eq!(opts.min_duration(), Duration::from_secs(2));
1069 }
1070
1071 #[test]
1072 fn detector_options_accessor() {
1073 let opts = Options::default().with_threshold(77);
1074 let det = Detector::new(opts);
1075 assert_eq!(det.options().threshold(), 77);
1076 }
1077
1078 #[test]
1079 fn initial_cut_false_seeds_last_cut_at_ts() {
1080 let opts = Options::default()
1086 .with_min_duration(Duration::from_millis(200))
1087 .with_initial_cut(false);
1088 let mut det = Detector::new(opts);
1089 let bright = uniform_luma(200, 0);
1090 let dark = uniform_luma(5, 0);
1091
1092 det.process_luma(luma(&bright, 8, 8, 0));
1095 det.process_luma(luma(&dark, 8, 8, 50));
1096 let cut = det.process_luma(luma(&bright, 8, 8, 150));
1097 assert!(
1098 cut.is_none(),
1099 "cut should be suppressed with initial_cut=false"
1100 );
1101 }
1102}