use std::path::Path;
use std::time::Duration;
use image::{AnimationDecoder, DynamicImage};
pub fn decode_static(path: &Path) -> Option<DynamicImage> {
image::ImageReader::open(path)
.ok()?
.with_guessed_format()
.ok()?
.decode()
.ok()
}
pub fn dimensions(path: &Path) -> Option<(u32, u32)> {
image::ImageReader::open(path)
.ok()?
.with_guessed_format()
.ok()?
.into_dimensions()
.ok()
}
const MIN_FRAME_DELAY: Duration = Duration::from_millis(20);
const DEFAULT_FRAME_DELAY: Duration = Duration::from_millis(100);
const MAX_GIF_BYTES: usize = 128 * 1024 * 1024;
pub fn decode_gif(path: &Path) -> Option<Vec<(DynamicImage, Duration)>> {
decode_gif_with_budget(path, MAX_GIF_BYTES)
}
fn shrink_target(w: u32, h: u32, shrink: u32) -> (u32, u32) {
((w / shrink).max(1), (h / shrink).max(1))
}
fn decode_gif_with_budget(path: &Path, budget: usize) -> Option<Vec<(DynamicImage, Duration)>> {
let file = std::fs::File::open(path).ok()?;
let decoder = image::codecs::gif::GifDecoder::new(std::io::BufReader::new(file)).ok()?;
let mut out: Vec<(DynamicImage, Duration)> = Vec::new();
let mut canvas: Option<(u32, u32)> = None; let mut shrink = 1u32;
let mut bytes = 0usize;
for f in decoder.into_frames() {
let f = f.ok()?;
let delay: Duration = f.delay().into();
let delay = if delay < MIN_FRAME_DELAY {
DEFAULT_FRAME_DELAY
} else {
delay
};
let mut img = DynamicImage::ImageRgba8(f.into_buffer());
let (cw, ch) = *canvas.get_or_insert((img.width(), img.height()));
if shrink > 1 {
let (tw, th) = shrink_target(cw, ch, shrink);
img = img.resize_exact(tw, th, image::imageops::FilterType::Triangle);
}
bytes += (img.width() as usize) * (img.height() as usize) * 4;
out.push((img, delay));
while bytes > budget && shrink < (1 << 16) {
shrink *= 2;
let (tw, th) = shrink_target(cw, ch, shrink);
bytes = 0;
for (im, _) in out.iter_mut() {
if im.width() != tw || im.height() != th {
*im = im.resize_exact(tw, th, image::imageops::FilterType::Triangle);
}
bytes += (im.width() as usize) * (im.height() as usize) * 4;
}
}
}
if out.len() < 2 {
return None; }
Some(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn decode_gif_real_sample_has_multiple_frames() {
let p = Path::new("samples/sample.gif");
if !p.exists() {
return; }
let frames = decode_gif(p).expect("sample.gif はアニメーションとしてデコードできるはず");
assert!(frames.len() > 1, "アニメ GIF は 2 フレーム以上");
let (w0, h0) = (frames[0].0.width(), frames[0].0.height());
assert!(w0 > 0 && h0 > 0);
assert!(frames
.iter()
.all(|(img, d)| { img.width() == w0 && img.height() == h0 && *d >= MIN_FRAME_DELAY }));
}
#[test]
fn decode_gif_budget_downscales_but_keeps_all_frames() {
use image::codecs::gif::GifEncoder;
use image::{Delay, Frame, Rgba, RgbaImage};
let dir = std::env::temp_dir().join("konoma_gif_budget_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let p = dir.join("big.gif");
{
let out = std::fs::File::create(&p).unwrap();
let mut enc = GifEncoder::new(out);
let frames = (0..4u8).map(|i| {
Frame::from_parts(
RgbaImage::from_pixel(64, 64, Rgba([i * 60, 100, 200, 255])),
0,
0,
Delay::from_numer_denom_ms(100, 1),
)
});
enc.encode_frames(frames).unwrap();
}
let frames = decode_gif_with_budget(&p, 20_000).expect("アニメとしてデコードできる");
assert_eq!(frames.len(), 4, "フレームは捨てない");
let (w, h) = (frames[0].0.width(), frames[0].0.height());
assert!(w < 64 && h < 64, "予算超過で縮小される: {w}x{h}");
assert!(
frames
.iter()
.all(|(im, _)| im.width() == w && im.height() == h),
"全フレーム同寸法(アニメ巡回の前提)"
);
let total: usize = frames
.iter()
.map(|(im, _)| im.width() as usize * im.height() as usize * 4)
.sum();
assert!(total <= 20_000, "合計バイトが予算内: {total}");
let frames = decode_gif(&p).expect("既定予算でもデコードできる");
assert_eq!((frames[0].0.width(), frames[0].0.height()), (64, 64));
std::fs::remove_dir_all(&dir).ok();
}
#[test]
fn decode_static_reads_png_and_rejects_non_image() {
let dir = std::env::temp_dir().join("konoma_decode_static_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let png = dir.join("tiny.png");
image::DynamicImage::ImageRgb8(image::RgbImage::from_pixel(7, 3, image::Rgb([9, 9, 9])))
.save(&png)
.unwrap();
let img = decode_static(&png).expect("PNG はデコードできる");
assert_eq!((img.width(), img.height()), (7, 3));
let bad = dir.join("notimg.png");
std::fs::write(&bad, b"definitely not an image").unwrap();
assert!(decode_static(&bad).is_none(), "非画像は None");
assert!(decode_static(&dir.join("missing.png")).is_none());
std::fs::remove_dir_all(&dir).ok();
}
}