From 613912e79e9acbae64028d34f5af23a318074998 Mon Sep 17 00:00:00 2001 From: JaimeShirazi <73824309+JaimeShirazi@users.noreply.github.com> Date: Sun, 6 Sep 2026 16:41:48 +1000 Subject: [PATCH] feat: added rawvideo rgba support Having to create temporary png files when piping isn't ideal, so with this you should be able to pipe raw RGBA directly into gifski --- README.md | 15 +++ src/bin/gifski.rs | 76 ++++++++++--- src/bin/raw_rgba_source.rs | 221 +++++++++++++++++++++++++++++++++++++ tests/tests.rs | 137 +++++++++++++++++++++++ 4 files changed, 432 insertions(+), 17 deletions(-) create mode 100644 src/bin/raw_rgba_source.rs diff --git a/README.md b/README.md index 463c7f7..a15c799 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,21 @@ Note that there's `-` at the end of the command. This tells `gifski` to read fro `gifski` may automatically downsize the video if it has resolution too high for a GIF. Use `--width=1280` if you can tolerate getting huge file sizes. +### Preserving transparency + +`gifski`'s implementation of YUV4MPEG2 does not preserve transparency. To preserve transparency without creating files directly, pipe tightly-packed raw RGBA bytes in the `rawvideo` format from ffmpeg instead. + +```sh +ffmpeg -i video.mp4 -pix_fmt rgba -f rawvideo - | gifski --raw-rgba --raw-size 640x360 --fps=20 -o anim.gif - +``` + +Including `--raw-rgba` tells gifski to treat the input as raw RGBA. Files with the `.raw` extension still must include this tag. Raw RGBA has no metadata, so `--raw-size` must match the input dimensions. For the same reason, the provided `--fps` value is assumed to be the input frame rate, and defaults to 20 when absent. + +Raw RGBA input is assumed sRGB color space with straight alpha. Perform color space transformation and frame interpolation before piping into gifski, though you may still rescale via `--width` and `--height`. + +> [!WARNING] +> Raw RGBA can quickly consume large amounts of data, so prefer YUV4MPEG for opaque input where possible. + ### From PNG frames A directory full of PNG frames can be used as an input too. You can export them from any animation software. If you have `ffmpeg` installed, you can also export frames with it: diff --git a/src/bin/gifski.rs b/src/bin/gifski.rs index b812848..108610f 100644 --- a/src/bin/gifski.rs +++ b/src/bin/gifski.rs @@ -19,6 +19,7 @@ use yuv::color::MatrixCoefficients; mod ffmpeg_source; mod gif_source; mod png; +mod raw_rgba_source; mod source; mod y4m_source; use crate::source::Source; @@ -38,9 +39,9 @@ use std::thread; use std::time::Duration; #[cfg(feature = "video")] -const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg, or multiple PNG image files"; +const VIDEO_FRAMES_ARG_HELP: &str = "one video file supported by FFmpeg, multiple PNG image files, or raw RGBA"; #[cfg(not(feature = "video"))] -const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation frames, or a .y4m file"; +const VIDEO_FRAMES_ARG_HELP: &str = "PNG image files for the animation frames, a .y4m file, or raw RGBA"; fn main() { if let Err(e) = bin_main() { @@ -70,13 +71,13 @@ fn bin_main() -> BinResult<()> { .arg(Arg::new("fps") .long("fps") .short('r') - .help("Frame rate of animation. If using PNG files as \ - input, this means the speed, as all frames are \ - kept.\nIf video is used, it will be resampled to \ - this constant rate by dropping and/or duplicating \ - frames.\nDefault is 20 for videos. No effect for \ - PNG input. For GIF input, it will be used to drop \ - frames if present.") + .help("Frame rate of animation. If using PNG files or \ + raw RGBA as input, this means the speed, as all \ + frames are kept.\nIf video is used, it will be \ + resampled to this constant rate by dropping and/or \ + duplicating frames.\nDefault is 20 for videos and \ + image sequences. For GIF input, it will be used to \ + drop frames if present.") .value_parser(value_parser!(f32)) .value_name("num")) .arg(Arg::new("fast-forward") @@ -161,6 +162,23 @@ fn bin_main() -> BinResult<()> { .action(ArgAction::SetTrue) .hide_short_help(true) .help("Make animation play forwards then backwards")) + .arg(Arg::new("raw-rgba") + .long("raw-rgba") + .num_args(0) + .action(ArgAction::SetTrue) + .hide_short_help(true) + .help("Read the input as tightly-packed 8-bit RGBA frames") + .requires("raw-size") + .conflicts_with("bounce") + .conflicts_with("y4m-color-override")) + .arg(Arg::new("raw-size") + .long("raw-size") + .help("Dimensions of the raw RGBA input") + .num_args(1) + .hide_short_help(true) + .requires("raw-rgba") + .value_parser(raw_rgba_source::parse_size) + .value_name("WIDTHxHEIGHT")) .arg(Arg::new("fixed-color") .long("fixed-color") .help("Always include this color in the palette") @@ -192,8 +210,10 @@ fn bin_main() -> BinResult<()> { e.exit() }); + let raw_size = matches.get_one::("raw-size").copied(); let mut frames: Vec<&str> = matches.get_many::("FILES").ok_or("?")?.map(|s| s.as_str()).collect(); let bounce = matches.get_flag("bounce"); + let is_raw = matches.get_flag("raw-rgba"); if !matches.get_flag("nosort") && frames.len() > 1 { frames.sort_by(|a, b| natord::compare(a, b)); } @@ -294,7 +314,7 @@ fn bin_main() -> BinResult<()> { } else { SrcPath::Path(path.clone()) }; - match file_type(&mut src).unwrap_or(FileType::Other) { + match file_type(&mut src, is_raw).unwrap_or(FileType::Other) { FileType::PNG | FileType::JPEG => return Err("Only a single image file was given as an input. This is not enough to make an animation.".into()), FileType::GIF => { if !quiet && (width.is_none() && settings.quality > 50) { @@ -305,7 +325,7 @@ fn bin_main() -> BinResult<()> { _ if path.is_dir() => { return Err(format!("{} is a directory, not a PNG file", path.display()).into()); }, - other_type => get_video_decoder(other_type, src, rate, in_color_space, settings)?, + other_type => get_video_decoder(other_type, src, rate, in_color_space, settings, raw_size)?, } } else { if bounce { @@ -315,7 +335,7 @@ fn bin_main() -> BinResult<()> { if speed != 1.0 { eprintln!("warning: --fast-forward option is for videos. It doesn't make sense for images. Use --fps only."); } - let file_type = file_type(&mut SrcPath::Path(frames[0].clone())).unwrap_or(FileType::Other); + let file_type = file_type(&mut SrcPath::Path(frames[0].clone()), is_raw).unwrap_or(FileType::Other); match file_type { FileType::JPEG => { return Err("JPEG format is unsuitable for conversion to GIF.\n\n\ @@ -325,6 +345,7 @@ fn bin_main() -> BinResult<()> { }, FileType::GIF => return unexpected("GIF"), FileType::Y4M => return unexpected("Y4M"), + FileType::RAW => return unexpected("RAW"), _ => Box::new(png::Lodecoder::new(frames, rate)), } }; @@ -466,15 +487,19 @@ fn parse_color_space(value: &str) -> Result { #[allow(clippy::upper_case_acronyms)] #[derive(PartialEq)] enum FileType { - PNG, GIF, JPEG, Y4M, Other, + PNG, GIF, JPEG, Y4M, RAW, Other, } -fn file_type(src: &mut SrcPath) -> BinResult { +fn file_type(src: &mut SrcPath, raw_expected: bool) -> BinResult { + if raw_expected { + return Ok(FileType::RAW); + } let mut buf = [0; 4]; match src { SrcPath::Path(path) => match path.extension() { Some(e) if e.eq_ignore_ascii_case("y4m") => return Ok(FileType::Y4M), Some(e) if e.eq_ignore_ascii_case("png") => return Ok(FileType::PNG), + Some(e) if e.eq_ignore_ascii_case("raw") => return Ok(FileType::RAW), _ => { let mut file = std::fs::File::open(path)?; file.read_exact(&mut buf)?; @@ -503,6 +528,15 @@ fn file_type(src: &mut SrcPath) -> BinResult { Ok(FileType::Other) } +#[test] +fn raw_file_type_selection() { + let mut explicit = SrcPath::Path(PathBuf::from("anything.png")); + assert!(matches!(file_type(&mut explicit, true), Ok(FileType::RAW))); + + let mut extension = SrcPath::Path(PathBuf::from("anything.raw")); + assert!(matches!(file_type(&mut extension, false), Ok(FileType::RAW))); +} + fn check_if_paths_exist(paths: &[PathBuf]) -> BinResult<()> { for path in paths { // stdin is ok @@ -572,9 +606,11 @@ impl fmt::Display for DestPath<'_> { } #[cfg(feature = "video")] -fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, settings: Settings) -> BinResult> { +fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, settings: Settings, raw_size: Option) -> BinResult> { Ok(if ftype == FileType::Y4M { Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?) + } else if ftype == FileType::RAW { + Box::new(raw_rgba_source::RawRgbaDecoder::new(src, raw_size, fps)?) } else { Box::new(ffmpeg_source::FfmpegDecoder::new(src, fps, settings)?) }) @@ -582,9 +618,11 @@ fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_s #[cfg(not(feature = "video"))] #[cold] -fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, _: Settings) -> BinResult> { +fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_space: Option, _: Settings, raw_size: Option) -> BinResult> { if ftype == FileType::Y4M { Ok(Box::new(y4m_source::Y4MDecoder::new(src, fps, in_color_space)?)) + } else if ftype == FileType::RAW { + Ok(Box::new(raw_rgba_source::RawRgbaDecoder::new(src, raw_size, fps)?)) } else { let path = match &src { SrcPath::Path(path) => path, @@ -593,10 +631,14 @@ fn get_video_decoder(ftype: FileType, src: SrcPath, fps: source::Fps, in_color_s let rel_path = path.file_name().map_or(path, Path::new); Err(format!(r#"Video support is permanently disabled in this distribution of gifski. -The only 'video' format supported at this time is YUV4MPEG2, which can be piped from ffmpeg: +YUV4MPEG2 input can be piped from ffmpeg: ffmpeg -i "{src}" -f yuv4mpegpipe - | gifski -o "{gif}" - +Raw RGBA input can also be piped from ffmpeg: + + ffmpeg -i "{src}" -pix_fmt rgba -f rawvideo - | gifski --raw-rgba --raw-size [WIDTH]x[HEIGHT] --fps [FPS] -o "{gif}" - + To enable full video decoding you need to recompile gifski from source. https://github.com/imageoptim/gifski diff --git a/src/bin/raw_rgba_source.rs b/src/bin/raw_rgba_source.rs new file mode 100644 index 0000000..a7e22b5 --- /dev/null +++ b/src/bin/raw_rgba_source.rs @@ -0,0 +1,221 @@ +use crate::source::{DEFAULT_FPS, Fps, Source}; +use crate::{BinResult, SrcPath}; +use gifski::Collector; +use imgref::ImgVec; +use rgb::RGBA8; +use std::io::{BufRead, BufReader, ErrorKind}; + +#[derive(Debug, Copy, Clone, PartialEq, Eq)] +pub struct RawSize { + width: u16, + height: u16, +} + +impl RawSize { + fn width(self) -> usize { + usize::from(self.width) + } + + fn height(self) -> usize { + usize::from(self.height) + } + + fn pixel_count(self) -> Result { + self.width().checked_mul(self.height()).ok_or("Raw RGBA dimensions are too large") + } + + fn frame_bytes(self) -> Result { + self.pixel_count()?.checked_mul(4).ok_or("Raw RGBA frame size is too large") + } +} + +pub fn parse_size(value: &str) -> Result { + let value = value.trim(); + let (width, height) = value.split_once('x') + .or_else(|| value.split_once('X')) + .ok_or_else(|| format!("raw RGBA size must be WIDTHxHEIGHT, not '{value}'"))?; + let width = width.trim().parse::().map_err(|_| format!("invalid raw RGBA width in '{value}'"))?; + let height = height.trim().parse::().map_err(|_| format!("invalid raw RGBA height in '{value}'"))?; + if width == 0 || height == 0 { + return Err("raw RGBA width and height must both be greater than zero".into()); + } + if width > u32::from(u16::MAX) || height > u32::from(u16::MAX) { + return Err("raw RGBA width and height must not exceed 65535 pixels".into()); + } + let size = RawSize { + width: width as u16, + height: height as u16, + }; + size.frame_bytes().map_err(|error| error.to_owned())?; + Ok(size) +} + +pub struct RawRgbaDecoder { + reader: Box, + size: RawSize, + pixel_count: usize, + frames_per_second: f64, + total_frames: Option, +} + +impl RawRgbaDecoder { + pub fn new(src: SrcPath, size: Option, rate: Fps) -> BinResult { + let size = size.ok_or("Raw RGBA input requires --raw-rgba and --raw-size WIDTHxHEIGHT")?; + let pixel_count = size.pixel_count()?; + let frame_bytes = size.frame_bytes()?; + let (reader, total_frames) = match src { + SrcPath::Path(path) => { + let metadata = std::fs::metadata(&path)?; + if metadata.is_dir() { + return Err(format!("{} is a directory, not a raw RGBA file", path.display()).into()); + } + let total_frames = if metadata.is_file() { + let frame_bytes = frame_bytes as u64; + if metadata.len() % frame_bytes != 0 { + return Err(format!( + "Raw RGBA file {} is {} bytes long, which is not a multiple of the {frame_bytes}-byte frame size", + path.display(), metadata.len(), + ).into()); + } + Some(metadata.len() / frame_bytes) + } else { + None + }; + let file = std::fs::File::open(&path)?; + (Box::new(BufReader::new(file)) as Box, total_frames) + }, + SrcPath::Stdin(reader) => (Box::new(reader) as Box, None), + }; + + let frames_per_second = f64::from(rate.fps.unwrap_or(DEFAULT_FPS)) * f64::from(rate.speed); + if !frames_per_second.is_finite() || frames_per_second <= 0. { + return Err("Raw RGBA frame rate must be a positive finite number".into()); + } + + Ok(Self { + reader, + size, + pixel_count, + frames_per_second, + total_frames, + }) + } +} + +impl Source for RawRgbaDecoder { + fn total_frames(&self) -> Option { + self.total_frames + } + + fn collect(&mut self, c: &mut Collector) -> BinResult<()> { + let mut frame_index = 0; + while let Some(pixels) = read_frame(&mut *self.reader, self.pixel_count, frame_index)? { + let pixels = ImgVec::new(pixels, self.size.width(), self.size.height()); + c.add_frame_rgba(frame_index, pixels, frame_index as f64 / self.frames_per_second)?; + frame_index = frame_index.checked_add(1).ok_or("Too many raw RGBA frames")?; + } + Ok(()) + } +} + +fn read_frame(reader: &mut dyn BufRead, pixel_count: usize, frame_index: usize) -> BinResult>> { + loop { + match reader.fill_buf() { + Ok(buffer) if buffer.is_empty() => return Ok(None), + Ok(_) => break, + Err(err) if err.kind() == ErrorKind::Interrupted => {}, + Err(err) => return Err(format!("Unable to read raw RGBA frame {frame_index}: {err}").into()), + } + } + + let mut pixels = Vec::new(); + pixels.try_reserve_exact(pixel_count)?; + pixels.resize(pixel_count, RGBA8::new(0, 0, 0, 0)); + let bytes: &mut [u8] = rgb::bytemuck::cast_slice_mut(&mut pixels); + let mut bytes_read = 0; + while bytes_read < bytes.len() { + match reader.read(&mut bytes[bytes_read..]) { + Ok(0) => return Err(format!( + "Raw RGBA frame {frame_index} is truncated: expected {} bytes, received {bytes_read}", + bytes.len(), + ).into()), + Ok(read) => bytes_read += read, + Err(err) if err.kind() == ErrorKind::Interrupted => {}, + Err(err) => return Err(format!("Unable to read raw RGBA frame {frame_index}: {err}").into()), + } + } + Ok(Some(pixels)) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::io::{self, Cursor, Read}; + + #[test] + fn parses_size() { + assert_eq!(parse_size("640x360").unwrap(), RawSize { width: 640, height: 360 }); + assert_eq!(parse_size("1X2").unwrap(), RawSize { width: 1, height: 2 }); + assert!(parse_size("640").is_err()); + assert!(parse_size("0x360").is_err()); + assert!(parse_size("640x0").is_err()); + assert!(parse_size("65536x1").is_err()); + assert!(parse_size("1x65536").is_err()); + assert!(parse_size("one-by-two").is_err()); + } + + #[test] + fn reads_rgba_components_and_frame_boundaries() { + let data = vec![ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + ]; + let mut reader = Cursor::new(data); + assert_eq!(read_frame(&mut reader, 2, 0).unwrap().unwrap(), vec![ + RGBA8::new(1, 2, 3, 4), + RGBA8::new(5, 6, 7, 8), + ]); + assert_eq!(read_frame(&mut reader, 2, 1).unwrap().unwrap(), vec![ + RGBA8::new(9, 10, 11, 12), + RGBA8::new(13, 14, 15, 16), + ]); + assert!(read_frame(&mut reader, 2, 2).unwrap().is_none()); + } + + #[test] + fn reports_a_truncated_frame() { + let mut reader = Cursor::new(vec![0; 7]); + let error = read_frame(&mut reader, 2, 3).unwrap_err().to_string(); + assert!(error.contains("frame 3 is truncated")); + assert!(error.contains("expected 8 bytes, received 7")); + } + + struct ChunkedReader { + data: Cursor>, + interrupt_next: bool, + } + + impl Read for ChunkedReader { + fn read(&mut self, buf: &mut [u8]) -> io::Result { + if std::mem::take(&mut self.interrupt_next) { + return Err(ErrorKind::Interrupted.into()); + } + let len = buf.len().min(2); + self.data.read(&mut buf[..len]) + } + } + + #[test] + fn handles_short_and_interrupted_reads() { + let reader = ChunkedReader { + data: Cursor::new(vec![1, 2, 3, 4, 5, 6, 7, 8]), + interrupt_next: true, + }; + let mut reader = BufReader::with_capacity(2, reader); + assert_eq!(read_frame(&mut reader, 2, 0).unwrap().unwrap(), vec![ + RGBA8::new(1, 2, 3, 4), + RGBA8::new(5, 6, 7, 8), + ]); + assert!(read_frame(&mut reader, 2, 1).unwrap().is_none()); + } +} diff --git a/tests/tests.rs b/tests/tests.rs index fc38de6..cc659a3 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -280,3 +280,140 @@ fn dump(filename: &str, px: ImgRef) { let (buf, w, h) = px.to_contiguous_buf(); lodepng::encode32_file(format!("/tmp/gifski-test-{filename}.png"), &buf, w, h).unwrap(); } + +#[cfg(feature = "binary")] +mod raw_rgba_cli { + use super::{assert_images_eq, for_each_frame}; + use imgref::ImgVec; + use rgb::RGBA8; + use std::io::Write; + use std::process::{Command, Output, Stdio}; + + fn run_gifski(args: &[&str], input: &[u8]) -> Output { + let mut child = Command::new(env!("CARGO_BIN_EXE_gifski")) + .args(args) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + + { + let mut stdin = child.stdin.take().unwrap(); + stdin.write_all(input).unwrap(); + } + + child.wait_with_output().unwrap() + } + + #[track_caller] + fn assert_raw_output( + output: Output, + expected_frames: &[ImgVec], + expected_delays: &[u16], + ) { + assert!( + output.status.success(), + "{}", + String::from_utf8_lossy(&output.stderr), + ); + + let mut frame_index = 0; + let mut delays = Vec::new(); + + for_each_frame(&output.stdout, |_, frame, actual| { + let expected = expected_frames.get(frame_index).unwrap_or_else(|| { + panic!("unexpected extra raw RGBA frame {frame_index}"); + }); + + assert_eq!( + (actual.width(), actual.height()), + (expected.width(), expected.height()), + "incorrect dimensions for raw RGBA frame {frame_index}", + ); + + assert_images_eq( + expected.as_ref(), + actual, + 0.8, + format_args!("raw RGBA frame {frame_index}"), + ); + + delays.push(frame.delay); + frame_index += 1; + }); + + assert_eq!( + frame_index, + expected_frames.len(), + "incorrect number of raw RGBA frames", + ); + assert_eq!(delays.as_slice(), expected_delays); + } + + #[test] + fn raw_rgba() { + let input = [ + // Frame 0 + 255, 0, 0, 255, + 0, 255, 0, 255, + 0, 0, 255, 255, + 10, 20, 30, 0, + // Frame 1 + 255, 255, 0, 255, + 40, 50, 60, 0, + 255, 0, 255, 255, + 0, 255, 255, 255, + ]; + + let expected = [ + ImgVec::new(vec![ + RGBA8::new(255, 0, 0, 255), + RGBA8::new(0, 255, 0, 255), + RGBA8::new(0, 0, 255, 255), + RGBA8::new(10, 20, 30, 0), + ], 2, 2), + ImgVec::new(vec![ + RGBA8::new(255, 255, 0, 255), + RGBA8::new(40, 50, 60, 0), + RGBA8::new(255, 0, 255, 255), + RGBA8::new(0, 255, 255, 255), + ], 2, 2), + ]; + + let output = run_gifski(&[ + "--raw-rgba", + "--raw-size", "2x2", + "--quality", "100", + "--output", "-", + "-", + ], &input); + + // No --fps: raw RGBA should inherit the 20 FPS default. + assert_raw_output(output, &expected, &[5, 5]); + } + + #[test] + fn raw_rgba_resize() { + let mut input = [255, 0, 0, 255].repeat(4); + input.extend([0, 0, 255, 255].repeat(4)); + + let expected = [ + ImgVec::new(vec![RGBA8::new(255, 0, 0, 255)], 1, 1), + ImgVec::new(vec![RGBA8::new(0, 0, 255, 255)], 1, 1), + ]; + + let output = run_gifski(&[ + "--raw-rgba", + "--raw-size", "2x2", + "--width", "1", + "--height", "1", + "--fps", "10", + "--quality", "100", + "--output", "-", + "-", + ], &input); + + assert_raw_output(output, &expected, &[10, 10]); + } +} \ No newline at end of file