use crate::core::container_info::get_duration_us;
use crate::core::context::ffmpeg_context::FfmpegContext;
use crate::core::context::input::Input;
use crate::core::context::output::Output;
use crate::error::{Error, Result};
#[derive(Debug, Clone, Copy)]
pub enum At {
Sec(f64),
Frame(u64),
Percent(f64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SeekMode {
InputSeek,
FilterScan,
}
#[derive(Debug, Clone)]
pub struct ThumbnailOptions {
pub at: At,
pub width: Option<u32>,
pub height: Option<u32>,
pub seek: SeekMode,
pub quality: u8,
}
impl Default for ThumbnailOptions {
fn default() -> Self {
Self {
at: At::Sec(0.0),
width: Some(320),
height: None,
seek: SeekMode::InputSeek,
quality: 2,
}
}
}
#[derive(Debug, Clone, Copy)]
pub enum Every {
Sec(f64),
Frames(u64),
EvenlySpread,
}
#[derive(Debug, Clone)]
pub struct SpriteSheetOptions {
pub grid: (u32, u32),
pub every: Every,
pub cell: (u32, u32),
pub quality: u8,
}
impl Default for SpriteSheetOptions {
fn default() -> Self {
Self {
grid: (5, 5),
every: Every::EvenlySpread,
cell: (160, 90),
quality: 2,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Locator {
TimeUs(i64),
Frame(u64),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SpriteSampling {
IntervalUs(i64),
FrameStride(u64),
EvenSpread { numerator: i64, duration_us: i64 },
}
pub(crate) fn validate_quality(quality: u8) -> Result<()> {
if !(2..=31).contains(&quality) {
return Err(Error::InvalidRecipeArg(format!(
"quality must be in 2..=31, got {quality}"
)));
}
Ok(())
}
pub(crate) fn sec_to_us(sec: f64) -> Result<i64> {
if !sec.is_finite() {
return Err(Error::InvalidRecipeArg(format!(
"time in seconds must be finite, got {sec}"
)));
}
if sec < 0.0 {
return Err(Error::InvalidRecipeArg(format!(
"time in seconds must be non-negative, got {sec}"
)));
}
let us = sec * 1_000_000.0;
if us > i64::MAX as f64 {
return Err(Error::InvalidRecipeArg(format!(
"time {sec}s overflows i64 microseconds"
)));
}
Ok(us as i64)
}
pub(crate) fn percent_to_us(percent: f64, duration_us: i64) -> Result<i64> {
if !percent.is_finite() {
return Err(Error::InvalidRecipeArg(format!(
"percent must be finite, got {percent}"
)));
}
if !(0.0..=100.0).contains(&percent) {
return Err(Error::InvalidRecipeArg(format!(
"percent must be in 0..=100, got {percent}"
)));
}
if duration_us < 0 {
return Err(Error::InvalidRecipeArg(format!(
"input duration is unknown ({duration_us}us); use At::Sec instead of At::Percent"
)));
}
Ok(((percent / 100.0) * duration_us as f64) as i64)
}
pub(crate) fn build_scale(width: Option<u32>, height: Option<u32>) -> Result<String> {
if matches!(width, Some(0)) || matches!(height, Some(0)) {
return Err(Error::InvalidRecipeArg(format!(
"scale width/height must be > 0, got width={width:?} height={height:?}"
)));
}
match (width, height) {
(Some(w), None) => Ok(format!("scale={w}:-2")),
(None, Some(h)) => Ok(format!("scale=-2:{h}")),
(Some(w), Some(h)) => Ok(format!("scale={w}:{h}")),
(None, None) => Err(Error::InvalidRecipeArg(
"thumbnail requires width or height (both were None)".to_string(),
)),
}
}
pub(crate) fn build_thumbnail_desc(
locator: &Locator,
seek: SeekMode,
scale: &str,
) -> (Option<i64>, String) {
match locator {
Locator::TimeUs(us) => match seek {
SeekMode::InputSeek => (Some(*us), format!("[0:v]{scale}[thumb]")),
SeekMode::FilterScan => (
None,
format!("[0:v]select='gte(t\\,{us}/1000000)',{scale}[thumb]"),
),
},
Locator::Frame(n) => (None, format!("[0:v]select='eq(n\\,{n})',{scale}[thumb]")),
}
}
pub(crate) fn sprite_sampling_desc(sampling: &SpriteSampling) -> String {
match sampling {
SpriteSampling::IntervalUs(interval_us) => format!("fps=1000000/{interval_us}"),
SpriteSampling::FrameStride(stride) => format!("select='not(mod(n\\,{stride}))'"),
SpriteSampling::EvenSpread {
numerator,
duration_us,
} => format!("fps={numerator}/{duration_us}"),
}
}
pub(crate) fn build_sprite_desc(
sampling: &SpriteSampling,
cell_w: u32,
cell_h: u32,
cols: u32,
rows: u32,
nb_frames: u64,
) -> String {
let sample = sprite_sampling_desc(sampling);
format!(
"[0:v]{sample},scale={cell_w}:{cell_h},tile=layout={cols}x{rows}:nb_frames={nb_frames}[thumb]"
)
}
pub fn thumbnail(
input: impl Into<Input>,
output: impl Into<Output>,
opts: ThumbnailOptions,
) -> crate::error::Result<()> {
let input: Input = input.into();
let output: Output = output.into();
validate_quality(opts.quality)?;
let scale = build_scale(opts.width, opts.height)?;
let locator = match opts.at {
At::Sec(s) => Locator::TimeUs(sec_to_us(s)?),
At::Frame(n) => Locator::Frame(n),
At::Percent(p) => {
let url = input.url.clone().ok_or_else(|| {
Error::InvalidRecipeArg(
"At::Percent requires a probeable file/URL input; callback inputs are \
unsupported — use At::Sec instead"
.to_string(),
)
})?;
let duration_us = get_duration_us(url).map_err(|e| {
Error::InvalidRecipeArg(format!(
"At::Percent needs the input duration but probing failed: {e}; use At::Sec instead"
))
})?;
Locator::TimeUs(percent_to_us(p, duration_us)?)
}
};
let (input_seek_us, filter_desc) = build_thumbnail_desc(&locator, opts.seek, &scale);
let input = match input_seek_us {
Some(us) => input.set_start_time_us(us),
None => input,
};
let output = output
.set_max_video_frames(1_i64)
.set_video_qscale(opts.quality as i32)
.add_stream_map("[thumb]");
FfmpegContext::builder()
.input(input)
.filter_desc(filter_desc)
.output(output)
.build()?
.start()?
.wait()
}
pub fn sprite_sheet(
input: impl Into<Input>,
output: impl Into<Output>,
opts: SpriteSheetOptions,
) -> crate::error::Result<()> {
let input: Input = input.into();
let output: Output = output.into();
validate_quality(opts.quality)?;
let (cols, rows) = opts.grid;
let (cell_w, cell_h) = opts.cell;
validate_grid_cell(cols, rows, cell_w, cell_h)?;
let nb_frames = (cols as u64).checked_mul(rows as u64).ok_or_else(|| {
Error::InvalidRecipeArg(format!("grid {cols}x{rows} overflows the frame count"))
})?;
let sampling = match opts.every {
Every::Sec(s) => {
let interval_us = sec_to_us(s)?;
if interval_us <= 0 {
return Err(Error::InvalidRecipeArg(format!(
"Every::Sec interval must be > 0 seconds, got {s}"
)));
}
SpriteSampling::IntervalUs(interval_us)
}
Every::Frames(n) => {
if n == 0 {
return Err(Error::InvalidRecipeArg(
"Every::Frames(0) is invalid".to_string(),
));
}
SpriteSampling::FrameStride(n)
}
Every::EvenlySpread => {
let numerator: i64 = nb_frames
.checked_mul(1_000_000)
.and_then(|n| i64::try_from(n).ok())
.ok_or_else(|| {
Error::InvalidRecipeArg(format!(
"grid {cols}x{rows} overflows the fps numerator"
))
})?;
let url = input.url.clone().ok_or_else(|| {
Error::InvalidRecipeArg(
"Every::EvenlySpread requires a probeable file/URL input; callback inputs \
are unsupported — use Every::Sec instead"
.to_string(),
)
})?;
let duration_us = get_duration_us(url).map_err(|e| {
Error::InvalidRecipeArg(format!(
"Every::EvenlySpread needs the input duration but probing failed: {e}; \
use Every::Sec instead"
))
})?;
if duration_us <= 0 {
return Err(Error::InvalidRecipeArg(format!(
"input duration is unknown ({duration_us}us); use Every::Sec instead of \
Every::EvenlySpread"
)));
}
SpriteSampling::EvenSpread {
numerator,
duration_us,
}
}
};
let filter_desc = build_sprite_desc(&sampling, cell_w, cell_h, cols, rows, nb_frames);
let output = output
.set_max_video_frames(1_i64)
.set_video_qscale(opts.quality as i32)
.add_stream_map("[thumb]");
FfmpegContext::builder()
.input(input)
.filter_desc(filter_desc)
.output(output)
.build()?
.start()?
.wait()
}
pub(crate) fn validate_grid_cell(cols: u32, rows: u32, cell_w: u32, cell_h: u32) -> Result<()> {
if cols == 0 || rows == 0 {
return Err(Error::InvalidRecipeArg(format!(
"sprite grid must be > 0 in both dimensions, got {cols}x{rows}"
)));
}
if cell_w == 0 || cell_h == 0 {
return Err(Error::InvalidRecipeArg(format!(
"sprite cell size must be > 0 in both dimensions, got {cell_w}x{cell_h}"
)));
}
let out_w = u64::from(cols).checked_mul(u64::from(cell_w));
let out_h = u64::from(rows).checked_mul(u64::from(cell_h));
match (out_w, out_h) {
(Some(w), Some(h)) if w <= 65_535 && h <= 65_535 => {}
_ => {
return Err(Error::InvalidRecipeArg(format!(
"sprite sheet size ({cols}*{cell_w}) x ({rows}*{cell_h}) exceeds the 65535x65535 limit"
)));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scale_width_only() {
assert_eq!(build_scale(Some(320), None).unwrap(), "scale=320:-2");
}
#[test]
fn scale_height_only() {
assert_eq!(build_scale(None, Some(240)).unwrap(), "scale=-2:240");
}
#[test]
fn scale_both() {
assert_eq!(build_scale(Some(320), Some(240)).unwrap(), "scale=320:240");
}
#[test]
fn scale_both_none_errs() {
assert!(matches!(
build_scale(None, None),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn scale_zero_errs() {
assert!(matches!(
build_scale(Some(0), None),
Err(Error::InvalidRecipeArg(_))
));
assert!(matches!(
build_scale(Some(320), Some(0)),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sec_to_us_ok() {
assert_eq!(sec_to_us(12.0).unwrap(), 12_000_000);
assert_eq!(sec_to_us(0.0).unwrap(), 0);
assert_eq!(sec_to_us(1.5).unwrap(), 1_500_000);
}
#[test]
fn sec_to_us_rejects_bad() {
assert!(sec_to_us(f64::NAN).is_err());
assert!(sec_to_us(f64::INFINITY).is_err());
assert!(sec_to_us(f64::NEG_INFINITY).is_err());
assert!(sec_to_us(-1.0).is_err());
}
#[test]
fn percent_to_us_ok() {
assert_eq!(percent_to_us(50.0, 10_000_000).unwrap(), 5_000_000);
assert_eq!(percent_to_us(0.0, 10_000_000).unwrap(), 0);
assert_eq!(percent_to_us(100.0, 10_000_000).unwrap(), 10_000_000);
}
#[test]
fn percent_to_us_rejects_bad() {
assert!(percent_to_us(-1.0, 10_000_000).is_err());
assert!(percent_to_us(101.0, 10_000_000).is_err());
assert!(percent_to_us(f64::NAN, 10_000_000).is_err());
assert!(percent_to_us(50.0, -1).is_err());
}
#[test]
fn thumbnail_desc_input_seek() {
let (seek, desc) = build_thumbnail_desc(
&Locator::TimeUs(12_000_000),
SeekMode::InputSeek,
"scale=320:-2",
);
assert_eq!(seek, Some(12_000_000));
assert_eq!(desc, "[0:v]scale=320:-2[thumb]");
}
#[test]
fn thumbnail_desc_filter_scan() {
let (seek, desc) = build_thumbnail_desc(
&Locator::TimeUs(12_000_000),
SeekMode::FilterScan,
"scale=320:-2",
);
assert_eq!(seek, None);
assert_eq!(
desc,
"[0:v]select='gte(t\\,12000000/1000000)',scale=320:-2[thumb]"
);
}
#[test]
fn thumbnail_desc_frame_ignores_seek() {
let (seek, desc) =
build_thumbnail_desc(&Locator::Frame(7), SeekMode::InputSeek, "scale=-2:240");
assert_eq!(seek, None);
assert_eq!(desc, "[0:v]select='eq(n\\,7)',scale=-2:240[thumb]");
}
#[test]
fn sprite_sampling_strings() {
assert_eq!(
sprite_sampling_desc(&SpriteSampling::IntervalUs(2_000_000)),
"fps=1000000/2000000"
);
assert_eq!(
sprite_sampling_desc(&SpriteSampling::FrameStride(10)),
"select='not(mod(n\\,10))'"
);
assert_eq!(
sprite_sampling_desc(&SpriteSampling::EvenSpread {
numerator: 25_000_000,
duration_us: 100_000_000,
}),
"fps=25000000/100000000"
);
}
#[test]
fn sprite_desc_full() {
let desc = build_sprite_desc(&SpriteSampling::IntervalUs(2_000_000), 160, 90, 5, 5, 25);
assert_eq!(
desc,
"[0:v]fps=1000000/2000000,scale=160:90,tile=layout=5x5:nb_frames=25[thumb]"
);
}
#[test]
fn sprite_desc_frame_stride() {
let desc = build_sprite_desc(&SpriteSampling::FrameStride(10), 160, 90, 4, 3, 12);
assert_eq!(
desc,
"[0:v]select='not(mod(n\\,10))',scale=160:90,tile=layout=4x3:nb_frames=12[thumb]"
);
}
#[test]
fn thumbnail_rejects_no_dims() {
let opts = ThumbnailOptions {
width: None,
height: None,
..Default::default()
};
assert!(matches!(
thumbnail("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn thumbnail_rejects_bad_quality() {
let lo = ThumbnailOptions {
quality: 1,
..Default::default()
};
let hi = ThumbnailOptions {
quality: 32,
..Default::default()
};
assert!(matches!(
thumbnail("x.mp4", "o.jpg", lo),
Err(Error::InvalidRecipeArg(_))
));
assert!(matches!(
thumbnail("x.mp4", "o.jpg", hi),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn thumbnail_rejects_bad_time() {
let neg = ThumbnailOptions {
at: At::Sec(-1.0),
..Default::default()
};
let nan = ThumbnailOptions {
at: At::Sec(f64::NAN),
..Default::default()
};
assert!(matches!(
thumbnail("x.mp4", "o.jpg", neg),
Err(Error::InvalidRecipeArg(_))
));
assert!(matches!(
thumbnail("x.mp4", "o.jpg", nan),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sprite_rejects_zero_grid() {
let opts = SpriteSheetOptions {
grid: (0, 5),
every: Every::Frames(10),
cell: (160, 90),
quality: 2,
};
assert!(matches!(
sprite_sheet("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sprite_rejects_oversized_sheet() {
let opts = SpriteSheetOptions {
grid: (65_536, 65_536),
every: Every::Frames(10),
cell: (1, 1),
quality: 2,
};
assert!(matches!(
sprite_sheet("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sprite_rejects_zero_cell() {
let opts = SpriteSheetOptions {
grid: (5, 5),
every: Every::Frames(10),
cell: (160, 0),
quality: 2,
};
assert!(matches!(
sprite_sheet("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sprite_rejects_frames_zero() {
let opts = SpriteSheetOptions {
grid: (5, 5),
every: Every::Frames(0),
cell: (160, 90),
quality: 2,
};
assert!(matches!(
sprite_sheet("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sprite_rejects_sec_zero() {
let opts = SpriteSheetOptions {
grid: (5, 5),
every: Every::Sec(0.0),
cell: (160, 90),
quality: 2,
};
assert!(matches!(
sprite_sheet("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
#[test]
fn sprite_rejects_bad_quality() {
let opts = SpriteSheetOptions {
grid: (5, 5),
every: Every::Frames(10),
cell: (160, 90),
quality: 100,
};
assert!(matches!(
sprite_sheet("x.mp4", "o.jpg", opts),
Err(Error::InvalidRecipeArg(_))
));
}
}