use std::path::{Path, PathBuf};
use std::time::Duration;
use ff_format::{PixelFormat, VideoFrame};
use crate::DecodeError;
use crate::VideoDecoder;
pub struct FrameExtractor {
input: PathBuf,
interval: Duration,
}
impl FrameExtractor {
pub fn new(input: impl AsRef<Path>) -> Self {
Self {
input: input.as_ref().to_path_buf(),
interval: Duration::from_secs(1),
}
}
#[must_use]
pub fn interval(self, d: Duration) -> Self {
Self {
interval: d,
..self
}
}
pub fn run(self) -> Result<Vec<VideoFrame>, DecodeError> {
if self.interval.is_zero() {
return Err(DecodeError::AnalysisFailed {
reason: "interval must be positive".to_string(),
});
}
let mut decoder = VideoDecoder::open(&self.input).build()?;
let duration = decoder.duration();
let mut frames = Vec::new();
let mut ts = Duration::ZERO;
while ts < duration {
match decoder.extract_frame(ts) {
Ok(frame) => frames.push(frame),
Err(DecodeError::NoFrameAtTimestamp { .. }) => {
log::warn!(
"frame not available, skipping timestamp={ts:?} input={}",
self.input.display()
);
}
Err(e) => return Err(e),
}
ts += self.interval;
}
let frame_count = frames.len();
log::debug!(
"frame extraction complete frames={frame_count} interval={interval:?}",
interval = self.interval
);
Ok(frames)
}
}
pub struct ThumbnailSelector {
input: PathBuf,
candidate_interval: Duration,
}
impl ThumbnailSelector {
pub fn new(input: impl AsRef<Path>) -> Self {
Self {
input: input.as_ref().to_path_buf(),
candidate_interval: Duration::from_secs(5),
}
}
#[must_use]
pub fn candidate_interval(self, d: Duration) -> Self {
Self {
candidate_interval: d,
..self
}
}
pub fn run(self) -> Result<VideoFrame, DecodeError> {
if self.candidate_interval.is_zero() {
return Err(DecodeError::AnalysisFailed {
reason: "candidate_interval must be positive".to_string(),
});
}
let mut decoder = VideoDecoder::open(&self.input)
.output_format(PixelFormat::Rgb24)
.build()?;
let duration = decoder.duration();
let mut best: Option<(f64, VideoFrame)> = None;
let mut ts = Duration::ZERO;
while ts < duration {
let frame = match decoder.extract_frame(ts) {
Ok(f) => f,
Err(DecodeError::NoFrameAtTimestamp { .. }) => {
log::warn!(
"frame not available, skipping timestamp={ts:?} input={}",
self.input.display()
);
ts += self.candidate_interval;
continue;
}
Err(e) => return Err(e),
};
let luma = mean_luma(&frame);
if !(10.0..=245.0).contains(&luma) {
ts += self.candidate_interval;
continue;
}
let sharpness = laplacian_variance(&frame);
if sharpness >= 100.0 {
log::debug!(
"thumbnail selected timestamp={ts:?} luma={luma:.1} sharpness={sharpness:.1}"
);
return Ok(frame);
}
let keep = best
.as_ref()
.is_none_or(|(best_sharpness, _)| sharpness > *best_sharpness);
if keep {
best = Some((sharpness, frame));
}
ts += self.candidate_interval;
}
if let Some((sharpness, frame)) = best {
log::debug!(
"thumbnail fallback used sharpness={sharpness:.1} input={}",
self.input.display()
);
return Ok(frame);
}
Err(DecodeError::AnalysisFailed {
reason: "no suitable thumbnail frame found".to_string(),
})
}
}
fn mean_luma(frame: &VideoFrame) -> f64 {
let width = frame.width() as usize;
let height = frame.height() as usize;
let pixel_count = width * height;
if pixel_count == 0 {
return 0.0;
}
let Some(plane) = frame.plane(0) else {
return 0.0;
};
let Some(stride) = frame.stride(0) else {
return 0.0;
};
let mut sum = 0.0_f64;
for row in 0..height {
let row_start = row * stride;
for col in 0..width {
let offset = row_start + col * 3;
let r = f64::from(plane[offset]);
let g = f64::from(plane[offset + 1]);
let b = f64::from(plane[offset + 2]);
sum += 0.299 * r + 0.587 * g + 0.114 * b;
}
}
#[allow(clippy::cast_precision_loss)]
{
sum / pixel_count as f64
}
}
fn laplacian_variance(frame: &VideoFrame) -> f64 {
let width = frame.width() as usize;
let height = frame.height() as usize;
if width < 3 || height < 3 {
return 0.0;
}
let Some(plane) = frame.plane(0) else {
return 0.0;
};
let Some(stride) = frame.stride(0) else {
return 0.0;
};
let luma_at = |row: usize, col: usize| -> f64 {
let offset = row * stride + col * 3;
let r = f64::from(plane[offset]);
let g = f64::from(plane[offset + 1]);
let b = f64::from(plane[offset + 2]);
0.299 * r + 0.587 * g + 0.114 * b
};
let inner_count = (width - 2) * (height - 2);
let mut responses = Vec::with_capacity(inner_count);
for row in 1..(height - 1) {
for col in 1..(width - 1) {
let lap = luma_at(row - 1, col)
+ luma_at(row + 1, col)
+ luma_at(row, col - 1)
+ luma_at(row, col + 1)
- 4.0 * luma_at(row, col);
responses.push(lap);
}
}
#[allow(clippy::cast_precision_loss)]
let n = inner_count as f64;
let mean = responses.iter().sum::<f64>() / n;
responses
.iter()
.map(|x| (x - mean) * (x - mean))
.sum::<f64>()
/ n
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn frame_extractor_zero_interval_should_err() {
let result = FrameExtractor::new("irrelevant.mp4")
.interval(Duration::ZERO)
.run();
assert!(
matches!(result, Err(DecodeError::AnalysisFailed { .. })),
"expected AnalysisFailed for zero interval, got {result:?}"
);
}
#[test]
fn frame_extractor_should_return_correct_frame_count() {
let extractor = FrameExtractor::new("video.mp4").interval(Duration::from_secs(1));
assert_eq!(extractor.interval, Duration::from_secs(1));
}
#[test]
fn thumbnail_selector_zero_interval_should_return_analysis_failed() {
let result = ThumbnailSelector::new("irrelevant.mp4")
.candidate_interval(Duration::ZERO)
.run();
assert!(
matches!(result, Err(DecodeError::AnalysisFailed { .. })),
"expected AnalysisFailed for zero interval, got {result:?}"
);
}
fn make_rgb24_frame(width: u32, height: u32, fill: [u8; 3]) -> VideoFrame {
use ff_format::{PooledBuffer, Timestamp};
let stride = width as usize * 3;
let mut data = vec![0u8; stride * height as usize];
for pixel in data.chunks_mut(3) {
pixel[0] = fill[0];
pixel[1] = fill[1];
pixel[2] = fill[2];
}
VideoFrame::new(
vec![PooledBuffer::standalone(data)],
vec![stride],
width,
height,
PixelFormat::Rgb24,
Timestamp::default(),
false,
)
.unwrap()
}
#[test]
fn mean_luma_should_return_correct_value_for_solid_color() {
let frame = make_rgb24_frame(4, 4, [255, 0, 0]);
let luma = mean_luma(&frame);
assert!(
(luma - 76.245).abs() < 0.1,
"expected luma ≈ 76.245 for pure red, got {luma:.3}"
);
}
#[test]
fn thumbnail_selector_should_skip_black_frames() {
let frame = make_rgb24_frame(4, 4, [0, 0, 0]);
let luma = mean_luma(&frame);
assert!(
luma < 10.0,
"expected luma < 10 for black frame, got {luma:.3}"
);
}
#[test]
fn laplacian_variance_blurry_should_return_low_value() {
let frame = make_rgb24_frame(8, 8, [128, 64, 32]);
let variance = laplacian_variance(&frame);
assert!(
variance < 1.0,
"expected near-zero variance for uniform frame, got {variance:.3}"
);
}
}