use std::ffi::CString;
use std::ptr::null;
use std::time::Duration;
use ffmpeg_sys_next::{av_guess_format, avcodec_find_encoder_by_name, avfilter_get_by_name};
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, PartialEq, Eq)]
pub enum Dither {
None,
Bayer { scale: u8 },
Sierra2,
FloydSteinberg,
}
impl Dither {
fn filter_name(self) -> &'static str {
match self {
Dither::None => "none",
Dither::Bayer { .. } => "bayer",
Dither::Sierra2 => "sierra2",
Dither::FloydSteinberg => "floyd_steinberg",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GifLoop {
Infinite,
Count(u32),
Once,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GifTrim {
pub start_us: i64,
pub duration_us: i64,
}
impl GifTrim {
pub fn from_micros(start_us: i64, duration_us: i64) -> Self {
Self {
start_us,
duration_us,
}
}
pub fn from_durations(start: Duration, duration: Duration) -> Self {
Self {
start_us: micros_saturating(start),
duration_us: micros_saturating(duration),
}
}
}
impl From<(Duration, Duration)> for GifTrim {
fn from((start, duration): (Duration, Duration)) -> Self {
Self::from_durations(start, duration)
}
}
fn micros_saturating(d: Duration) -> i64 {
d.as_micros().min(i64::MAX as u128) as i64
}
#[derive(Debug, Clone)]
pub struct GifOptions {
pub fps: u32,
pub width: Option<u32>,
pub trim: Option<GifTrim>,
pub dither: Dither,
pub max_colors: Option<u32>,
pub loop_: GifLoop,
}
impl Default for GifOptions {
fn default() -> Self {
Self {
fps: 12,
width: None,
trim: None,
dither: Dither::Sierra2,
max_colors: None,
loop_: GifLoop::Infinite,
}
}
}
pub fn animated_gif(
input: impl Into<Input>,
output: impl Into<Output>,
opts: GifOptions,
) -> Result<()> {
validate(&opts)?;
check_capabilities(opts.width.is_some())?;
let filter_desc = build_gif_desc(&opts);
let input = apply_trim(input.into(), opts.trim);
let output = configure_output(output.into(), opts.loop_);
FfmpegContext::builder()
.input(input)
.filter_desc(filter_desc)
.output(output)
.build()?
.start()?
.wait()
}
pub(crate) fn build_gif_desc(opts: &GifOptions) -> String {
let mut desc = format!("[0:v]fps={}", opts.fps);
if let Some(width) = opts.width {
desc.push_str(&format!(",scale={width}:-1:flags=lanczos"));
}
desc.push_str(",split[a][b];[a]palettegen");
if let Some(max_colors) = opts.max_colors {
desc.push_str(&format!("=max_colors={max_colors}"));
}
desc.push_str("[p];[b][p]paletteuse=dither=");
desc.push_str(opts.dither.filter_name());
if let Dither::Bayer { scale } = opts.dither {
desc.push_str(&format!(":bayer_scale={scale}"));
}
desc.push_str("[v]");
desc
}
fn apply_trim(input: Input, trim: Option<GifTrim>) -> Input {
match trim {
Some(trim) => input
.set_start_time_us(trim.start_us)
.set_recording_time_us(trim.duration_us),
None => input,
}
}
fn configure_output(output: Output, gif_loop: GifLoop) -> Output {
output
.set_format("gif")
.add_stream_map("[v]")
.set_format_opt("loop", loop_option_value(gif_loop))
}
fn loop_option_value(gif_loop: GifLoop) -> String {
match gif_loop {
GifLoop::Infinite => "0".to_string(),
GifLoop::Once => "-1".to_string(),
GifLoop::Count(count) => count.to_string(),
}
}
fn validate(opts: &GifOptions) -> Result<()> {
if opts.fps == 0 || opts.fps > 1000 {
return Err(Error::InvalidRecipeArg(format!(
"fps must be in the range 1..=1000, got {}",
opts.fps
)));
}
if opts.width == Some(0) {
return Err(Error::InvalidRecipeArg(
"width must be greater than 0 (use None to keep the source width)".to_string(),
));
}
if let Some(max_colors) = opts.max_colors {
if !(4..=256).contains(&max_colors) {
return Err(Error::InvalidRecipeArg(format!(
"max_colors must be in the range 4..=256, got {max_colors}"
)));
}
}
if let Dither::Bayer { scale } = opts.dither {
if scale > 5 {
return Err(Error::InvalidRecipeArg(format!(
"bayer_scale must be in the range 0..=5, got {scale}"
)));
}
}
match opts.loop_ {
GifLoop::Count(0) => {
return Err(Error::InvalidRecipeArg(
"loop count of 0 is ambiguous; use GifLoop::Infinite for endless playback"
.to_string(),
));
}
GifLoop::Count(count) if count > 65_535 => {
return Err(Error::InvalidRecipeArg(format!(
"loop count must be in the range 1..=65535, got {count}"
)));
}
_ => {}
}
if let Some(trim) = opts.trim {
if trim.start_us < 0 {
return Err(Error::InvalidRecipeArg(format!(
"trim start must be >= 0 us, got {}",
trim.start_us
)));
}
if trim.duration_us <= 0 {
return Err(Error::InvalidRecipeArg(format!(
"trim duration must be > 0 us, got {}",
trim.duration_us
)));
}
}
Ok(())
}
fn check_capabilities(needs_scale: bool) -> Result<()> {
let mut required = vec!["fps", "split", "palettegen", "paletteuse"];
if needs_scale {
required.push("scale");
}
unsafe {
for name in required {
let c_name = CString::new(name)
.map_err(|_| Error::InvalidRecipeArg(format!("invalid filter name {name:?}")))?;
if avfilter_get_by_name(c_name.as_ptr()).is_null() {
return Err(Error::InvalidRecipeArg(format!(
"this FFmpeg build is missing the '{name}' filter required for GIF export"
)));
}
}
let gif = CString::new("gif")
.map_err(|_| Error::InvalidRecipeArg("invalid format name \"gif\"".to_string()))?;
if av_guess_format(gif.as_ptr(), null(), null()).is_null() {
return Err(Error::InvalidRecipeArg(
"this FFmpeg build is missing the 'gif' muxer".to_string(),
));
}
if avcodec_find_encoder_by_name(gif.as_ptr()).is_null() {
return Err(Error::InvalidRecipeArg(
"this FFmpeg build is missing the 'gif' encoder".to_string(),
));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn desc_default() {
let desc = build_gif_desc(&GifOptions::default());
assert_eq!(
desc,
"[0:v]fps=12,split[a][b];[a]palettegen[p];[b][p]paletteuse=dither=sierra2[v]"
);
}
#[test]
fn desc_with_width_scales() {
let opts = GifOptions {
width: Some(480),
..GifOptions::default()
};
assert_eq!(
build_gif_desc(&opts),
"[0:v]fps=12,scale=480:-1:flags=lanczos,split[a][b];\
[a]palettegen[p];[b][p]paletteuse=dither=sierra2[v]"
);
}
#[test]
fn desc_without_width_omits_scale() {
let opts = GifOptions {
width: None,
..GifOptions::default()
};
assert!(!build_gif_desc(&opts).contains("scale="));
}
#[test]
fn desc_with_max_colors() {
let opts = GifOptions {
max_colors: Some(64),
..GifOptions::default()
};
assert!(build_gif_desc(&opts).contains("[a]palettegen=max_colors=64[p]"));
}
#[test]
fn desc_bayer_carries_scale() {
let opts = GifOptions {
dither: Dither::Bayer { scale: 3 },
..GifOptions::default()
};
assert!(build_gif_desc(&opts).ends_with("paletteuse=dither=bayer:bayer_scale=3[v]"));
}
#[test]
fn desc_dither_names() {
let name = |d: Dither| {
let opts = GifOptions {
dither: d,
..GifOptions::default()
};
build_gif_desc(&opts)
};
assert!(name(Dither::None).ends_with("dither=none[v]"));
assert!(name(Dither::Sierra2).ends_with("dither=sierra2[v]"));
assert!(name(Dither::FloydSteinberg).ends_with("dither=floyd_steinberg[v]"));
}
#[test]
fn desc_full_combination() {
let opts = GifOptions {
fps: 15,
width: Some(480),
trim: Some(GifTrim::from_micros(5_000_000, 3_000_000)),
dither: Dither::Bayer { scale: 2 },
max_colors: Some(128),
loop_: GifLoop::Once,
};
assert_eq!(
build_gif_desc(&opts),
"[0:v]fps=15,scale=480:-1:flags=lanczos,split[a][b];\
[a]palettegen=max_colors=128[p];[b][p]paletteuse=dither=bayer:bayer_scale=2[v]"
);
}
#[test]
fn loop_values() {
assert_eq!(loop_option_value(GifLoop::Infinite), "0");
assert_eq!(loop_option_value(GifLoop::Once), "-1");
assert_eq!(loop_option_value(GifLoop::Count(5)), "5");
}
#[test]
fn configure_output_sets_gif_map_and_loop() {
let output = configure_output(Output::from("out.gif"), GifLoop::Infinite);
assert_eq!(output.format.as_deref(), Some("gif"));
assert_eq!(output.stream_map_specs.len(), 1);
assert_eq!(output.stream_map_specs[0].linklabel, "[v]");
assert!(!output.stream_map_specs[0].copy);
let loop_value = output
.format_opts
.as_ref()
.and_then(|opts| opts.get("loop"))
.map(String::as_str);
assert_eq!(loop_value, Some("0"));
}
#[test]
fn configure_output_count_loop() {
let output = configure_output(Output::from("out.gif"), GifLoop::Count(3));
let loop_value = output
.format_opts
.as_ref()
.and_then(|opts| opts.get("loop"))
.map(String::as_str);
assert_eq!(loop_value, Some("3"));
}
#[test]
fn apply_trim_sets_input_window() {
let input = apply_trim(
Input::from("in.mp4"),
Some(GifTrim::from_micros(1_000_000, 2_000_000)),
);
assert_eq!(input.start_time_us, Some(1_000_000));
assert_eq!(input.recording_time_us, Some(2_000_000));
}
#[test]
fn apply_trim_none_leaves_input_untouched() {
let input = apply_trim(Input::from("in.mp4"), None);
assert_eq!(input.start_time_us, None);
assert_eq!(input.recording_time_us, None);
}
#[test]
fn trim_from_durations_converts_to_micros() {
let trim = GifTrim::from_durations(Duration::from_secs(1), Duration::from_millis(2500));
assert_eq!(trim.start_us, 1_000_000);
assert_eq!(trim.duration_us, 2_500_000);
}
#[test]
fn validate_rejects_zero_fps() {
let opts = GifOptions {
fps: 0,
..GifOptions::default()
};
assert!(matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_rejects_excessive_fps() {
let opts = GifOptions {
fps: 1_000_000,
..GifOptions::default()
};
assert!(matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_rejects_zero_width() {
let opts = GifOptions {
width: Some(0),
..GifOptions::default()
};
assert!(matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_max_colors_bounds() {
for bad in [0u32, 3, 257, 1000] {
let opts = GifOptions {
max_colors: Some(bad),
..GifOptions::default()
};
assert!(
matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))),
"max_colors={bad} should be rejected"
);
}
for ok in [4u32, 128, 256] {
let opts = GifOptions {
max_colors: Some(ok),
..GifOptions::default()
};
assert!(
validate(&opts).is_ok(),
"max_colors={ok} should be accepted"
);
}
}
#[test]
fn validate_rejects_bayer_scale_above_five() {
let opts = GifOptions {
dither: Dither::Bayer { scale: 6 },
..GifOptions::default()
};
assert!(matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))));
let ok = GifOptions {
dither: Dither::Bayer { scale: 5 },
..GifOptions::default()
};
assert!(validate(&ok).is_ok());
}
#[test]
fn validate_rejects_loop_count_zero() {
let opts = GifOptions {
loop_: GifLoop::Count(0),
..GifOptions::default()
};
assert!(matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))));
}
#[test]
fn validate_rejects_loop_count_over_max() {
let opts = GifOptions {
loop_: GifLoop::Count(65_536),
..GifOptions::default()
};
assert!(matches!(validate(&opts), Err(Error::InvalidRecipeArg(_))));
let ok = GifOptions {
loop_: GifLoop::Count(65_535),
..GifOptions::default()
};
assert!(validate(&ok).is_ok());
}
#[test]
fn validate_rejects_bad_trim() {
let negative_start = GifOptions {
trim: Some(GifTrim::from_micros(-1, 1_000_000)),
..GifOptions::default()
};
assert!(matches!(
validate(&negative_start),
Err(Error::InvalidRecipeArg(_))
));
let zero_duration = GifOptions {
trim: Some(GifTrim::from_micros(0, 0)),
..GifOptions::default()
};
assert!(matches!(
validate(&zero_duration),
Err(Error::InvalidRecipeArg(_))
));
let ok = GifOptions {
trim: Some(GifTrim::from_micros(0, 1)),
..GifOptions::default()
};
assert!(validate(&ok).is_ok());
}
}