#[derive(Debug, Clone, PartialEq)]
pub struct DissolveRegion {
pub start_frame: usize,
pub end_frame: usize,
pub start_luminance: f64,
pub end_luminance: f64,
pub direction: DissolveDirection,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DissolveDirection {
Increasing,
Decreasing,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum WipeDirection {
LeftToRight,
RightToLeft,
TopToBottom,
BottomToTop,
}
fn mean_luminance(frame: &[u8], width: u32, height: u32) -> f64 {
let npixels = (width as usize) * (height as usize);
if npixels == 0 {
return 0.0;
}
if frame.len() >= npixels * 3 {
let sum: u64 = frame[..npixels * 3]
.chunks_exact(3)
.map(|px| {
let r = px[0] as u64;
let g = px[1] as u64;
let b = px[2] as u64;
(299 * r + 587 * g + 114 * b + 500) / 1000
})
.sum();
sum as f64 / npixels as f64
} else {
let sum: u64 = frame[..npixels].iter().map(|&b| b as u64).sum();
sum as f64 / npixels as f64
}
}
pub fn detect_dissolve(frames: &[&[u8]], width: u32, height: u32) -> Option<DissolveRegion> {
if frames.len() < 2 {
return None;
}
let luminances: Vec<f64> = frames
.iter()
.map(|f| mean_luminance(f, width, height))
.collect();
let n = luminances.len();
let monotone_inc = luminances.windows(2).all(|w| w[1] >= w[0]);
let monotone_dec = luminances.windows(2).all(|w| w[1] <= w[0]);
let delta = (luminances[n - 1] - luminances[0]).abs();
if delta < 2.0 {
return None;
}
if monotone_inc {
Some(DissolveRegion {
start_frame: 0,
end_frame: n - 1,
start_luminance: luminances[0],
end_luminance: luminances[n - 1],
direction: DissolveDirection::Increasing,
})
} else if monotone_dec {
Some(DissolveRegion {
start_frame: 0,
end_frame: n - 1,
start_luminance: luminances[0],
end_luminance: luminances[n - 1],
direction: DissolveDirection::Decreasing,
})
} else {
None
}
}
fn column_mean(frame: &[u8], width: u32, height: u32, col: usize) -> f64 {
let npixels = width as usize * height as usize;
let h = height as usize;
let w = width as usize;
if col >= w || frame.is_empty() {
return 0.0;
}
if frame.len() >= npixels * 3 {
let sum: u64 = (0..h)
.map(|row| {
let idx = (row * w + col) * 3;
if idx + 2 < frame.len() {
let r = frame[idx] as u64;
let g = frame[idx + 1] as u64;
let b = frame[idx + 2] as u64;
(299 * r + 587 * g + 114 * b + 500) / 1000
} else {
0
}
})
.sum();
sum as f64 / h as f64
} else {
let sum: u64 = (0..h)
.map(|row| {
let idx = row * w + col;
if idx < frame.len() {
frame[idx] as u64
} else {
0
}
})
.sum();
sum as f64 / h as f64
}
}
fn row_mean(frame: &[u8], width: u32, height: u32, row: usize) -> f64 {
let npixels = width as usize * height as usize;
let h = height as usize;
let w = width as usize;
if row >= h || frame.is_empty() {
return 0.0;
}
if frame.len() >= npixels * 3 {
let start = row * w * 3;
let end = start + w * 3;
if end > frame.len() {
return 0.0;
}
let sum: u64 = frame[start..end]
.chunks_exact(3)
.map(|px| {
let r = px[0] as u64;
let g = px[1] as u64;
let b = px[2] as u64;
(299 * r + 587 * g + 114 * b + 500) / 1000
})
.sum();
sum as f64 / w as f64
} else {
let start = row * w;
let end = (start + w).min(frame.len());
let sum: u64 = frame[start..end].iter().map(|&b| b as u64).sum();
sum as f64 / w as f64
}
}
fn find_max_diff_column(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32) -> (usize, f64) {
let w = width as usize;
let mut best_col = 0;
let mut best_diff = 0.0_f64;
for col in 0..w {
let ma = column_mean(frame_a, width, height, col);
let mb = column_mean(frame_b, width, height, col);
let diff = (ma - mb).abs();
if diff > best_diff {
best_diff = diff;
best_col = col;
}
}
(best_col, best_diff)
}
fn find_max_diff_row(frame_a: &[u8], frame_b: &[u8], width: u32, height: u32) -> (usize, f64) {
let h = height as usize;
let mut best_row = 0;
let mut best_diff = 0.0_f64;
for row in 0..h {
let ma = row_mean(frame_a, width, height, row);
let mb = row_mean(frame_b, width, height, row);
let diff = (ma - mb).abs();
if diff > best_diff {
best_diff = diff;
best_row = row;
}
}
(best_row, best_diff)
}
const WIPE_DIFF_THRESHOLD: f64 = 15.0;
pub fn detect_wipe(
frame_a: &[u8],
frame_b: &[u8],
width: u32,
height: u32,
) -> Option<WipeDirection> {
if frame_a.is_empty() || frame_b.is_empty() || width == 0 || height == 0 {
return None;
}
let (col_boundary, col_diff) = find_max_diff_column(frame_a, frame_b, width, height);
let (row_boundary, row_diff) = find_max_diff_row(frame_a, frame_b, width, height);
if col_diff < WIPE_DIFF_THRESHOLD && row_diff < WIPE_DIFF_THRESHOLD {
return None;
}
let w = width as usize;
let h = height as usize;
if col_diff >= row_diff {
if col_boundary < w / 2 {
Some(WipeDirection::LeftToRight)
} else {
Some(WipeDirection::RightToLeft)
}
} else {
if row_boundary < h / 2 {
Some(WipeDirection::TopToBottom)
} else {
Some(WipeDirection::BottomToTop)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn gray_frame(w: usize, h: usize, val: u8) -> Vec<u8> {
vec![val; w * h]
}
#[test]
fn test_dissolve_detection_fade_out() {
let frames_data: Vec<Vec<u8>> =
(0..6u8).map(|i| gray_frame(16, 16, 200 - i * 30)).collect();
let frame_refs: Vec<&[u8]> = frames_data.iter().map(|v| v.as_slice()).collect();
let result = detect_dissolve(&frame_refs, 16, 16);
assert!(result.is_some(), "Expected dissolve to be detected");
let region = result.expect("dissolve should be detected");
assert_eq!(region.direction, DissolveDirection::Decreasing);
assert_eq!(region.start_frame, 0);
assert_eq!(region.end_frame, 5);
assert!(region.start_luminance > region.end_luminance);
}
#[test]
fn test_dissolve_detection_fade_in() {
let values: [u8; 5] = [20, 65, 110, 155, 200];
let frames_data: Vec<Vec<u8>> = values.iter().map(|&v| gray_frame(8, 8, v)).collect();
let frame_refs: Vec<&[u8]> = frames_data.iter().map(|v| v.as_slice()).collect();
let result = detect_dissolve(&frame_refs, 8, 8);
assert!(
result.is_some(),
"Expected dissolve (fade-in) to be detected"
);
assert_eq!(
result.expect("dissolve should be detected").direction,
DissolveDirection::Increasing
);
}
#[test]
fn test_dissolve_not_detected_on_static_scene() {
let frames_data: Vec<Vec<u8>> = (0..5).map(|_| gray_frame(8, 8, 128)).collect();
let frame_refs: Vec<&[u8]> = frames_data.iter().map(|v| v.as_slice()).collect();
let result = detect_dissolve(&frame_refs, 8, 8);
assert!(result.is_none(), "Static scene should not be a dissolve");
}
#[test]
fn test_dissolve_not_detected_on_non_monotone() {
let values: [u8; 5] = [100, 150, 200, 100, 50];
let frames_data: Vec<Vec<u8>> = values.iter().map(|&v| gray_frame(8, 8, v)).collect();
let frame_refs: Vec<&[u8]> = frames_data.iter().map(|v| v.as_slice()).collect();
let result = detect_dissolve(&frame_refs, 8, 8);
assert!(
result.is_none(),
"Non-monotone luminance should not be a dissolve"
);
}
#[test]
fn test_dissolve_insufficient_frames() {
let frame = gray_frame(8, 8, 100);
let frame_refs: Vec<&[u8]> = vec![frame.as_slice()];
let result = detect_dissolve(&frame_refs, 8, 8);
assert!(result.is_none());
}
#[test]
fn test_wipe_detection_left_to_right() {
let w = 20usize;
let h = 10usize;
let mut frame_a = vec![50u8; w * h];
let mut frame_b = vec![200u8; w * h];
for row in 0..h {
for col in w / 2..w {
frame_a[row * w + col] = 200;
frame_b[row * w + col] = 50;
}
}
let result = detect_wipe(&frame_a, &frame_b, w as u32, h as u32);
assert!(result.is_some(), "Wipe should be detected");
let dir = result.expect("wipe should be detected");
assert!(
matches!(dir, WipeDirection::LeftToRight | WipeDirection::RightToLeft),
"Expected a horizontal wipe, got {dir:?}"
);
}
#[test]
fn test_wipe_detection_top_to_bottom() {
let w = 10usize;
let h = 20usize;
let mut frame_a = vec![50u8; w * h];
let mut frame_b = vec![200u8; w * h];
for row in h / 2..h {
for col in 0..w {
frame_a[row * w + col] = 200;
frame_b[row * w + col] = 50;
}
}
let result = detect_wipe(&frame_a, &frame_b, w as u32, h as u32);
assert!(result.is_some(), "Wipe should be detected");
let dir = result.expect("wipe should be detected");
assert!(
matches!(dir, WipeDirection::TopToBottom | WipeDirection::BottomToTop),
"Expected a vertical wipe, got {dir:?}"
);
}
#[test]
fn test_wipe_not_detected_on_identical_frames() {
let frame = gray_frame(10, 10, 128);
let result = detect_wipe(&frame, &frame, 10, 10);
assert!(result.is_none(), "Identical frames should produce no wipe");
}
#[test]
fn test_wipe_empty_frame() {
let result = detect_wipe(&[], &[], 10, 10);
assert!(result.is_none());
}
}