Skip to main content

scenesdetect/
threshold.rs

1//! Intensity-threshold scene detection — fade-in / fade-out transitions.
2//!
3//! This module implements [`Detector`](crate::threshold::Detector), a port
4//! of PySceneDetect's `detect-threshold` algorithm. Unlike the
5//! frame-difference detectors ([`histogram`](crate::histogram),
6//! [`phash`](crate::phash)), this one looks at the **absolute mean
7//! brightness** of each frame and fires when the mean crosses a threshold
8//! in one direction and then the other.
9//!
10//! Typical use: detecting fades-to-black between scenes in films.
11//!
12//! # Algorithm
13//!
14//! The detector runs a two-state machine, with the state determined by the
15//! current frame's mean intensity relative to `threshold`:
16//!
17//! - **`In`** — we're inside a lit scene (mean ≥ threshold, for `Floor`).
18//! - **`Out`** — we're in a fade-to-black (mean < threshold, for `Floor`).
19//!
20//! For each frame:
21//!
22//! 1. **Compute mean intensity.** For [`LumaFrame`](crate::frame::LumaFrame)
23//!    inputs, the mean of the Y plane. For
24//!    [`RgbFrame`](crate::frame::RgbFrame) inputs, the mean of all
25//!    3 × W × H bytes — mirroring Python's `numpy.mean(frame_img)` over a
26//!    BGR image.
27//! 2. **Check for a state transition.**
28//!    - `In → Out`: store this frame's timestamp as the fade-out start.
29//!    - `Out → In`: we just completed a full fade cycle. Emit a cut
30//!      **interpolated between the fade-out and fade-in endpoints** by
31//!      [`Options::fade_bias`](crate::threshold::Options::fade_bias), gated
32//!      by [`Options::min_duration`](crate::threshold::Options::min_duration).
33//!
34//! The interpolation is:
35//!
36//! ```text
37//! cut_time = f_out + (f_in - f_out) * (1 + fade_bias) / 2
38//! ```
39//!
40//! so `fade_bias = -1` places the cut at the fade-out frame, `0` at the
41//! midpoint (default), and `+1` at the fade-in frame.
42//!
43//! # End-of-stream handling
44//!
45//! If the stream ends while the detector is in `Out` state (fade-to-black
46//! without a recovery) and
47//! [`Options::add_final_scene`](crate::threshold::Options::add_final_scene)
48//! is set, calling
49//! [`Detector::finish`](crate::threshold::Detector::finish) emits one final
50//! cut at the fade-out frame. This represents "the last scene ended when
51//! the video faded out."
52//!
53//! [`Detector::clear`](crate::threshold::Detector::clear) resets stream
54//! state so the same detector instance can be reused for the next video.
55//!
56//! # [`Method`](crate::threshold::Method) variants
57//!
58//! - [`Method::Floor`](crate::threshold::Method::Floor) — "dark = below
59//!   threshold" (fade to black, default).
60//! - [`Method::Ceiling`](crate::threshold::Method::Ceiling) — "bright =
61//!   above threshold" (fade to white).
62//!
63//! # Attribution
64//!
65//! Ported from PySceneDetect's `detect-threshold` (BSD 3-Clause).
66//! See <https://scenedetect.com> for the original implementation.
67
68use 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/// Which direction of threshold crossing counts as a fade.
78#[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  /// Fade detected when mean pixel intensity **falls below** `threshold`.
85  /// Matches the classic "fade to black" case and is the default.
86  #[default]
87  Floor,
88  /// Fade detected when mean pixel intensity **rises above** `threshold`
89  /// (fade to white, or overexposure detection).
90  Ceiling,
91}
92
93impl Method {
94  /// Returns a human-friendly name for this method variant.
95  #[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/// Options for the intensity-threshold scene detector. See the
105/// [module docs](crate::threshold) for how each parameter shapes the algorithm.
106#[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  /// Creates a new `Options` with default values.
127  ///
128  /// Defaults: `threshold = 12`, `method = Floor`, `fade_bias = 0.0`,
129  /// `add_final_scene = false`, `min_duration = 1 s`.
130  #[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  /// Returns the mean-intensity threshold used for fade detection.
143  ///
144  /// Interpreted as an 8-bit brightness value in `[0, 255]`. Frames with a
145  /// mean below this (for [`Method::Floor`]) are considered "dark".
146  #[cfg_attr(not(tarpaulin), inline(always))]
147  pub const fn threshold(&self) -> u8 {
148    self.threshold
149  }
150
151  /// Set the threshold.
152  #[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  /// Set the threshold in place.
159  #[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  /// Returns the fade-detection [`Method`].
166  #[cfg_attr(not(tarpaulin), inline(always))]
167  pub const fn method(&self) -> Method {
168    self.method
169  }
170
171  /// Set the method.
172  #[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  /// Set the method in place.
179  #[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  /// Returns the fade bias, clamped to `[-1.0, 1.0]` at use time.
186  ///
187  /// Controls cut placement between the fade-out and fade-in frames:
188  /// `-1` = at fade-out, `0` = midpoint (default), `+1` = at fade-in.
189  #[cfg_attr(not(tarpaulin), inline(always))]
190  pub const fn fade_bias(&self) -> f64 {
191    self.fade_bias
192  }
193
194  /// Set the fade bias.
195  #[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  /// Set the fade bias in place.
202  #[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  /// Returns whether [`Detector::finish`] will emit a final cut when the
209  /// stream ends in the `Out` state.
210  #[cfg_attr(not(tarpaulin), inline(always))]
211  pub const fn add_final_scene(&self) -> bool {
212    self.add_final_scene
213  }
214
215  /// Set whether to emit a final cut at end-of-stream when in `Out` state.
216  #[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  /// Set whether to emit a final cut at end-of-stream in place.
223  #[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  /// Returns the minimum scene duration.
230  #[cfg_attr(not(tarpaulin), inline(always))]
231  pub const fn min_duration(&self) -> Duration {
232    self.min_duration
233  }
234
235  /// Set the minimum scene duration.
236  #[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  /// Set the minimum scene duration in place.
243  #[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  /// Set the minimum scene length as a number of frames at a given frame rate.
250  ///
251  /// See [`crate::histogram::Options::with_min_frames`] for the semantics.
252  #[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  /// In-place form of [`Self::with_min_frames`].
259  #[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  /// Whether the first detected cut is allowed to fire immediately.
266  ///
267  /// - `true` (default): the first complete fade cycle emits a cut as soon
268  ///   as the min-duration gate is satisfied relative to stream start.
269  /// - `false`: suppresses cuts until the stream has actually run for at
270  ///   least [`Self::min_duration`]. Matches PySceneDetect's default.
271  #[cfg_attr(not(tarpaulin), inline(always))]
272  pub const fn initial_cut(&self) -> bool {
273    self.initial_cut
274  }
275
276  /// Sets whether the first detected cut may fire immediately.
277  #[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  /// Sets `initial_cut` in place.
284  #[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/// Internal state: which side of the threshold the detector is currently on.
292#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
293enum FadeType {
294  /// Mean intensity above threshold (or below, for `Method::Ceiling`).
295  In,
296  /// Mean intensity below threshold (or above, for `Method::Ceiling`).
297  Out,
298}
299
300/// Intensity-threshold scene detector. See the
301/// [module documentation](crate::threshold) for the algorithm.
302#[derive(Debug, Clone)]
303pub struct Detector {
304  options: Options,
305  processed_frame: bool,
306  last_scene_cut: Option<Timestamp>,
307  /// Timestamp of the frame where the last fade transition occurred.
308  last_fade_frame: Option<Timestamp>,
309  last_fade_type: FadeType,
310  last_avg: Option<f64>,
311  /// Fade-out / fade-in endpoints of the most recent emission. Preserved
312  /// across [`Self::finish`] so callers can read it after an end-of-stream
313  /// cut; only [`Self::clear`] zeroes it.
314  last_fade_range: Option<TimeRange>,
315}
316
317impl Detector {
318  /// Creates a new detector with the given options.
319  #[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  /// Returns a reference to the options used by this detector.
333  #[cfg_attr(not(tarpaulin), inline(always))]
334  pub const fn options(&self) -> &Options {
335    &self.options
336  }
337
338  /// Returns the mean intensity of the most recently processed frame, or
339  /// `None` if no frame has been processed yet. Useful for diagnostics and
340  /// threshold tuning.
341  #[cfg_attr(not(tarpaulin), inline(always))]
342  pub const fn last_avg(&self) -> Option<f64> {
343    self.last_avg
344  }
345
346  /// Returns the fade-out / fade-in endpoints of the most recently emitted
347  /// cut, or `None` if no cut has fired since the last [`Self::clear`].
348  ///
349  /// The [`TimeRange`]'s `start` is the fade-out frame's timestamp; `end`
350  /// is the fade-in frame's timestamp (both in the fade-out frame's
351  /// timebase — `end` is rescaled if timebases differ between frames).
352  /// For cuts emitted by [`Self::finish`] there is no matching fade-in, so
353  /// the range is degenerate (`start == end == fade_out_ts`).
354  ///
355  /// `process_*` and `finish` return the single bias-interpolated point
356  /// between these two endpoints (see [`Options::fade_bias`]); this
357  /// accessor exposes the full range so callers that want the fade
358  /// duration — or want to pick a different interpolation — can get both
359  /// timestamps without recomputing.
360  #[cfg_attr(not(tarpaulin), inline(always))]
361  pub const fn last_fade_range(&self) -> Option<TimeRange> {
362    self.last_fade_range
363  }
364
365  /// Processes a luma (Y-plane) frame.
366  ///
367  /// The per-pixel "intensity" is the 8-bit Y value. Thresholds should be
368  /// interpreted in this luma scale.
369  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  /// Processes a packed 24-bit RGB (or BGR) frame.
375  ///
376  /// The per-pixel "intensity" is the average of the three channel bytes —
377  /// matching Python's `numpy.mean(frame_img)` over a BGR frame. Because
378  /// averaging is channel-order-agnostic, RGB and BGR inputs produce
379  /// identical results.
380  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  /// Signals that the stream has ended at `last_ts`. Returns a final cut if
386  /// the stream ended during a fade-out (state = `Out`) and
387  /// [`Options::add_final_scene`] is enabled.
388  ///
389  /// The returned cut is placed at the fade-out frame's timestamp (no bias
390  /// applied — there's no matching fade-in to interpolate against).
391  ///
392  /// `finish` **always calls [`Self::clear`] before returning**, so the same
393  /// detector instance is immediately ready for the next video. Subsequent
394  /// calls to `finish` without any intervening `process_*` will return
395  /// `None` (nothing to finish).
396  pub fn finish(&mut self, _last_ts: Timestamp) -> Option<Timestamp> {
397    let cut = self.final_cut();
398    // If we're emitting a final cut, record a degenerate range at the
399    // fade-out frame (no matching fade-in at end-of-stream). This lets
400    // callers query `last_fade_range()` after `finish` for consistency
401    // with mid-stream emissions.
402    let range_after = cut.map(TimeRange::instant);
403    self.clear();
404    self.last_fade_range = range_after;
405    cut
406  }
407
408  /// Computes the end-of-stream cut (if any) without mutating state —
409  /// [`Self::finish`] calls this, then clears.
410  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    // Gate on the cut we're about to emit (`fade_frame`), not on the last
419    // observed frame — otherwise a long tail of above-threshold frames
420    // after the fade-out would let us emit `fade_frame` even though it's
421    // closer than `min_duration` to the previous cut.
422    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  /// Resets the detector's streaming state so it can be reused for the
432  /// next video without reallocating.
433  #[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  /// Shared state-machine logic, parameterized by the per-frame mean.
444  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    // `dark` means "on the trigger side of the threshold":
456    //   Floor   → brightness < threshold
457    //   Ceiling → brightness ≥ threshold
458    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          // Fade-out just started.
469          self.last_fade_type = FadeType::Out;
470          self.last_fade_frame = Some(ts);
471        }
472        FadeType::Out if !dark => {
473          // Fade-in completes a fade cycle.
474          if let Some(f_out) = self.last_fade_frame {
475            let placed = interpolate_cut(f_out, ts, self.options.fade_bias);
476            // min_duration is measured from the previously emitted cut to
477            // the one we're about to emit (`placed`), so the gate is
478            // consistent with what the caller observes.
479            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              // Expose the full [fade_out, fade_in] range for callers who
489              // want richer info than the interpolated point. Rescale f_in
490              // into f_out's timebase so endpoints share a timebase
491              // (rescale_to is a no-op when timebases already match).
492              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      // First frame: seed the state and the fade reference.
507      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
516/// Mean of the Y plane (same pattern as the histogram detector's inner loop
517/// but summing into `u64` — 4K (8.3 M u8 pixels) stays well inside `u64`).
518fn 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
535/// Mean of all `width * height * 3` bytes in a packed RGB frame — matches
536/// `numpy.mean(frame_img)` over a BGR image in the original Python.
537fn 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
555/// Interpolates a cut between the fade-out and fade-in timestamps by the
556/// given `bias ∈ [-1, 1]`: `-1` places the cut at `f_out`, `0` at the
557/// midpoint, `+1` at `f_in`.
558///
559/// If the two timestamps have different timebases, `f_in` is rescaled into
560/// `f_out`'s timebase first (via [`Timestamp::rescale_to`]). Arithmetic is
561/// done in integer PTS units and rounded toward zero.
562fn 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)) // 1 ms units
589  }
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    // Every pixel R=30, G=60, B=150 → per-pixel avg = 80 → frame mean = 80.
616    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    // 1.0 s at 1/1000 timebase, 2.0 s at 1/90000 timebase.
629    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    // Midpoint of 1.0 s and 2.0 s = 1.5 s = 1500 ms in f_out's timebase.
633    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    // Out of range should clamp.
644    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  /// Helper: build a uniform luma frame of size 8x8 with given intensity.
649  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    // Start dark.
657    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    // Stream: bright → bright → DARK → DARK → BRIGHT (fade cycle).
665    // Defaults: threshold=12, fade_bias=0 → cut at midpoint.
666    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    // pts in 1/1000 timebase = ms.
672    assert!(det.process_luma(luma(&bright, 8, 8, 0)).is_none());
673    assert!(det.process_luma(luma(&bright, 8, 8, 100)).is_none());
674    // fade out begins at 200 ms.
675    assert!(det.process_luma(luma(&dark, 8, 8, 200)).is_none());
676    assert!(det.process_luma(luma(&dark, 8, 8, 300)).is_none());
677    // fade in completes at 400 ms → cut placed at midpoint of 200..400 = 300.
678    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    // bias = -1 → cut at fade-out frame.
686    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    // bias = +1 → cut at fade-in frame.
699    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    // 1 second gate (default). Time values chosen so the first cycle lands
713    // beyond the gate from the seeded `last_scene_cut` (pts=0), but the
714    // second cycle falls within the gate after the first cut.
715    let mut det = Detector::new(Options::default());
716    let bright = uniform_luma(200, 0);
717    let dark = uniform_luma(5, 0);
718
719    // First cycle: seed at 0 ms; fade-out at 1000 ms; fade-in at 1500 ms.
720    // Gap from seed = 1500 ms ≥ 1000 ms → cut fires.
721    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    // Second cycle immediately after: fade-out at 1600 ms, fade-in at 1700 ms.
727    // Gap from last cut (ts=1500) = 200 ms < 1 s → suppressed.
728    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    // With Method::Ceiling and threshold=200, brightness above 200 = "dark" state.
736    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    // dim → bright: enter Out.
747    det.process_luma(luma(&bright, 8, 8, 100));
748    // bright → dim: exit Out → In, cut fires.
749    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)); // fade-out begins
765    let cut = det.process_luma(luma(&bright, 8, 8, 400)).expect("cut"); // fade-in completes
766
767    // Interpolated midpoint.
768    assert_eq!(cut.pts(), 300);
769
770    // Full range available via accessor.
771    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    // Duration = 200 ms.
776    assert_eq!(range.duration(), Some(Duration::from_millis(200)));
777    // Interpolate midpoint matches the emitted cut.
778    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)); // fade-out at 200; never recovers
805    let final_cut = det.finish(Timestamp::new(400, tb())).expect("final cut");
806    assert_eq!(final_cut.pts(), 200);
807    // finish emits a degenerate range at the fade-out frame.
808    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    // fade out at 200; stream ends without fade-in.
827    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      // add_final_scene is false by default.
840    );
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    // Whether or not a final cut is emitted, finish() must leave the detector
851    // in a clean state — `last_avg` reset, no leftover fade reference.
852    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    // A second finish with no frames in between is a safe no-op.
872    assert!(det.finish(Timestamp::new(500, tb())).is_none());
873
874    // Processing a fresh stream works without an explicit clear().
875    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    // Video 1: prime, then complete a fade cycle.
901    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    // Video 2: start with dark; no cut until a fade-in completes.
910    assert!(det.process_luma(luma(&dark, 8, 8, 1_000_000)).is_none());
911    // One frame later we cross to bright — that's a fade-in but we came
912    // *from* Out at the start, not via a detected In → Out transition, so
913    // it completes a fade cycle and emits a cut.
914    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    // Regression: the min-duration gate is anchored on the *emitted* cut
921    // (the interpolated placement between fade-out and fade-in), not on the
922    // fade-in frame. Otherwise long fades consume part of the gate window.
923    //
924    // Schedule (min_duration = 200 ms, fade_bias = 0 so placed = midpoint):
925    //   bright(0) dark(100)  -> fade-out starts at 100
926    //   bright(200)          -> fade-in; cut1 placed = 150  (midpoint)
927    //   dark(250)            -> fade-out starts at 250
928    //   bright(300)          -> fade-in; cut2 placed = 275
929    //
930    // Between cut1 (150) and cut2 (275): 125 ms < 200 ms → cut2 must be
931    // suppressed. The previous code set `last_scene_cut = 200` (fade-in),
932    // so the gate from the fade-in's POV looked like 300 - 200 = 100 ms,
933    // which was also < 200 ms and therefore happened to suppress cut2 in
934    // this exact schedule. Stretch the second fade so it's >200 ms from
935    // fade-in but <200 ms from the emitted cut to surface the bug:
936    //   cut1 placed = 150, cut2 placed = 250 (150 ms apart).
937    //   fade-in (201→400) sits 200 ms from fade-in-1 (=200), 250 ms from
938    //   the previously-wrongly-recorded fade-in.
939    // Concretely: bright(0) dark(100) bright(200) (cut1 @150) dark(300)
940    // bright(400) -> cut2 placed = 350.
941    //   gate-from-emitted: 350 - 150 = 200  ✅ allowed (exactly min_duration)
942    //   gate-from-fade-in: 350 - 200 = 150  ❌ would suppress
943    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    // Regression: `finish()`'s min-duration gate compares the emitted
968    // `fade_frame` against the previous cut, not the `last_ts` argument.
969    // Otherwise a long tail of frames before finish() would let a final
970    // cut fire even though its timestamp is too close to the previous one.
971    //
972    // Schedule (min_duration = 200 ms, fade_bias = 0):
973    //   bright(0) dark(100) bright(200)   -> cut1 placed = 150
974    //   dark(250)                         -> fade-out at 250, no fade-in
975    //   finish(10_000)                    -> last_ts far in the future
976    //
977    // gate-from-fade_frame: 250 - 150 = 100 < 200 → suppress (correct).
978    // gate-from-last_ts:    10000 - 150 huge ≥ 200 → would emit (wrong).
979    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    // Uniform 100 RGB → mean 100; uniform 100 Y → mean 100. Same state
1003    // transitions, same cut placement.
1004    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    // Consuming builder form — each field round-trips.
1034    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    // with_min_frames alternate.
1049    let opts_frames = Options::default().with_min_frames(15, fps30);
1050    assert_eq!(opts_frames.min_duration(), Duration::from_millis(500));
1051
1052    // In-place setters, chainable.
1053    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    // With `initial_cut = false`, the first frame should seed
1081    // `last_scene_cut` to the frame's own ts (not ts - min_duration), so
1082    // the first complete fade-in-from-out transition that happens within
1083    // min_duration of the first frame is suppressed. This exercises the
1084    // `else` branch of the seed in process_with_mean.
1085    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    // A full fade cycle compressed into 200 ms — the emitted cut's placed
1093    // midpoint is too close to the seeded ts=0 anchor → gate fails.
1094    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}