use std::fs;
use std::path::Path;
use oxideav_core::{
CodecId, CodecParameters, Error, PixelFormat, Result, RuntimeContext, VideoPlane,
};
use oxideav_image::{Image, ImageError, OpenOptions, SaveOptions};
use crate::image_sink::{self, LayoutHint};
use crate::op::{AlphaOp, Op};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum OutputClass {
Scene,
Vector,
Raster(RasterFormat),
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
#[non_exhaustive]
pub enum RasterFormat {
Png,
Jpeg,
Bmp,
Webp,
Registry,
}
pub fn classify_output(path_or_template: &str) -> Result<OutputClass> {
classify_output_with(None, path_or_template)
}
pub fn classify_output_with(
ctx: Option<&RuntimeContext>,
path_or_template: &str,
) -> Result<OutputClass> {
let ext = ext_of(path_or_template)
.ok_or_else(|| {
Error::invalid(format!(
"convert: output '{path_or_template}' has no extension"
))
})?
.to_ascii_lowercase();
match ext.as_str() {
"pdf" => Ok(OutputClass::Scene),
"svg" => Ok(OutputClass::Vector),
"png" | "apng" => Ok(OutputClass::Raster(RasterFormat::Png)),
"jpg" | "jpeg" => Ok(OutputClass::Raster(RasterFormat::Jpeg)),
"bmp" | "dib" => Ok(OutputClass::Raster(RasterFormat::Bmp)),
"webp" => Ok(OutputClass::Raster(RasterFormat::Webp)),
"ico" => Err(Error::unsupported(format!(
"convert: output '{path_or_template}': .ico is written by the icon side-channel, not as a raster frame"
))),
other => match ctx {
Some(ctx) if registry_encodes(ctx, other) => {
Ok(OutputClass::Raster(RasterFormat::Registry))
}
Some(_) => Err(Error::unsupported(format!(
"convert: output extension '.{other}' has no registered still-image encoder (png/jpg/bmp/webp always work; tiff/gif/qoi/… need their crate registered)"
))),
None => Err(Error::unsupported(format!(
"convert: output extension '.{other}' not in the registry-free raster set (png/jpg/bmp/webp/svg/pdf)"
))),
},
}
}
fn registry_encodes(ctx: &RuntimeContext, ext: &str) -> bool {
oxideav_image::encoder_options(ctx, ext, &SaveOptions::default()).is_ok()
}
fn ext_of(path: &str) -> Option<&str> {
let last = path.rsplit('/').next().unwrap_or(path);
let last = last.split('?').next().unwrap_or(last);
let dot = last.rfind('.')?;
Some(&last[dot + 1..])
}
#[derive(Debug, Clone)]
pub struct RgbaImage {
pub width: u32,
pub height: u32,
pub pixels: Vec<u8>,
pub stride: usize,
}
impl RgbaImage {
pub fn is_rgb(&self) -> bool {
self.stride == (self.width as usize) * 3
}
pub fn pixel_format(&self) -> PixelFormat {
if self.is_rgb() {
PixelFormat::Rgb24
} else {
PixelFormat::Rgba
}
}
pub fn from_image(img: &Image) -> Result<RgbaImage> {
let pixels = img
.to_rgba8()
.map_err(|e| gateway_error("convert: pixel conversion failed", e))?;
Ok(RgbaImage {
width: img.width(),
height: img.height(),
stride: img.width() as usize * 4,
pixels,
})
}
pub fn to_image(&self) -> Result<Image> {
Image::from_planes(
self.width,
self.height,
self.pixel_format(),
vec![VideoPlane {
stride: self.stride,
data: self.pixels.clone(),
}],
)
.map_err(|e| gateway_error("convert: raster buffer", e))
}
}
pub fn gateway_error(what: &str, e: ImageError) -> Error {
match e {
ImageError::Core(e) => e,
ImageError::Io(e) => Error::Io(e),
ImageError::UnknownFormat(m) | ImageError::Unsupported(m) => {
Error::unsupported(format!("{what}: {m}"))
}
other => Error::invalid(format!("{what}: {other}")),
}
}
pub fn read_image_to_rgba(ctx: &RuntimeContext, input: &str) -> Result<RgbaImage> {
let opts = OpenOptions::new().with_max_frames(1);
let file = oxideav_image::open_with(ctx, input, &opts)
.map_err(|e| gateway_error(&format!("convert: failed to read {input}"), e))?;
RgbaImage::from_image(file.primary())
}
pub fn encode_raster_to_path(
ctx: &RuntimeContext,
img: &RgbaImage,
path: &str,
ops: &[Op],
) -> Result<()> {
let bytes = encode_raster(ctx, img, path, ops)?;
fs::write(Path::new(path), bytes)
.map_err(|e| Error::invalid(format!("convert: failed to write {path}: {e}")))?;
Ok(())
}
pub fn encode_raster(
ctx: &RuntimeContext,
img: &RgbaImage,
path: &str,
ops: &[Op],
) -> Result<Vec<u8>> {
let ext = ext_of(path)
.ok_or_else(|| Error::invalid(format!("convert: output '{path}' has no extension")))?
.to_ascii_lowercase();
let image = img.to_image()?;
let opts = save_options(ctx, img.pixel_format(), path, &ext, ops)?;
oxideav_image::encode(ctx, &image, &ext, &opts).map_err(|e| {
gateway_error(
&format!("convert: {} encode failed", ext.to_ascii_uppercase()),
e,
)
})
}
fn save_options(
ctx: &RuntimeContext,
src: PixelFormat,
path: &str,
ext: &str,
ops: &[Op],
) -> Result<SaveOptions> {
let mut opts = SaveOptions::new();
if let Some(q) = last_quality(ops) {
opts = opts.with_quality(q);
}
let codec = image_sink::resolve_output_codec(None, path, ctx);
let user: Vec<(String, String)> = ops
.iter()
.filter_map(|o| match o {
Op::EncoderOpt { key, value } => Some((key.clone(), value.clone())),
_ => None,
})
.collect();
for (k, v) in image_sink::encoder_options(ctx, codec.as_deref(), Some(ext), &user)? {
opts = opts.with_option(k, v);
}
let has_codec_opt = opts.options.iter().any(|(k, _)| k == "codec");
let Some(codec) = codec.filter(|_| !has_codec_opt) else {
return Ok(opts);
};
let Some(accepted) = image_sink::encoder_accepted_formats(ctx, &codec) else {
return Ok(opts);
};
let target = if accepted.is_empty() {
probe_undeclared_encoder(ctx, &codec, src, &opts)
} else {
let hint = LayoutHint::for_output(path);
Some(
image_sink::pick_sink_format(src, &accepted, hint, last_depth(ops)).unwrap_or({
if accepted.contains(&src) {
src
} else {
accepted[0]
}
}),
)
};
if let Some(t) = target {
opts = opts.with_pixel_format(t);
}
Ok(opts)
}
const UNDECLARED_ORDER: &[PixelFormat] = &[
PixelFormat::Yuv420P,
PixelFormat::Yuv422P,
PixelFormat::Yuv444P,
PixelFormat::Rgb24,
PixelFormat::Rgba,
PixelFormat::Gray8,
];
fn probe_undeclared_encoder(
ctx: &RuntimeContext,
codec: &str,
src: PixelFormat,
opts: &SaveOptions,
) -> Option<PixelFormat> {
let mut params = CodecParameters::video(CodecId::new(codec));
params.width = Some(16);
params.height = Some(16);
if let Some(q) = opts.quality {
params.options = params.options.set("quality", q.to_string());
}
for (k, v) in &opts.options {
params.options = params.options.set(k.clone(), v.clone());
}
let packed_rgb = matches!(
src,
PixelFormat::Rgb24
| PixelFormat::Rgba
| PixelFormat::Bgr24
| PixelFormat::Bgra
| PixelFormat::Argb
| PixelFormat::Abgr
| PixelFormat::Rgb48Le
| PixelFormat::Rgba64Le
);
let first = (!packed_rgb).then_some(src);
first
.into_iter()
.chain(UNDECLARED_ORDER.iter().copied())
.filter(|&f| f == src || oxideav_pixfmt::supports(src, f))
.find(|&f| {
params.pixel_format = Some(f);
ctx.codecs.first_encoder(¶ms).is_ok()
})
}
fn last_quality(ops: &[Op]) -> Option<u8> {
ops.iter().rev().find_map(|o| match o {
Op::Quality(q) => Some((*q).min(100) as u8),
_ => None,
})
}
fn last_depth(ops: &[Op]) -> Option<u8> {
ops.iter().rev().find_map(|o| match o {
Op::Depth(d) => Some(*d),
_ => None,
})
}
pub fn apply_alpha_ops(img: &mut RgbaImage, ops: &[Op], bg: [u8; 4]) {
for op in ops {
if let Op::Alpha(a) = op {
match a {
AlphaOp::On => {}
AlphaOp::Off => drop_alpha(img),
AlphaOp::Remove => flatten_alpha_over(img, bg),
AlphaOp::Set => set_alpha(img, 255),
AlphaOp::Transparent => set_alpha(img, 0),
}
}
}
}
fn flatten_alpha_over(img: &mut RgbaImage, bg: [u8; 4]) {
if img.is_rgb() {
return;
}
let bg_r = bg[0] as u32;
let bg_g = bg[1] as u32;
let bg_b = bg[2] as u32;
for px in img.pixels.chunks_exact_mut(4) {
let a = px[3] as u32;
if a == 255 {
continue;
}
let inv = 255 - a;
let r = (px[0] as u32 * a + bg_r * inv + 127) / 255;
let g = (px[1] as u32 * a + bg_g * inv + 127) / 255;
let b = (px[2] as u32 * a + bg_b * inv + 127) / 255;
px[0] = r as u8;
px[1] = g as u8;
px[2] = b as u8;
px[3] = 255;
}
}
fn drop_alpha(img: &mut RgbaImage) {
if img.is_rgb() {
return;
}
let w = img.width as usize;
let h = img.height as usize;
let mut out = Vec::with_capacity(w * h * 3);
for px in img.pixels.chunks_exact(4) {
out.extend_from_slice(&px[..3]);
}
img.pixels = out;
img.stride = w * 3;
}
fn set_alpha(img: &mut RgbaImage, value: u8) {
if img.is_rgb() {
return;
}
for px in img.pixels.chunks_exact_mut(4) {
px[3] = value;
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn classify_output_covers_known_targets() {
assert!(matches!(classify_output("out.pdf"), Ok(OutputClass::Scene)));
assert!(matches!(
classify_output("out.svg"),
Ok(OutputClass::Vector)
));
assert!(matches!(
classify_output("out.png"),
Ok(OutputClass::Raster(RasterFormat::Png))
));
assert!(matches!(
classify_output("out.jpg"),
Ok(OutputClass::Raster(RasterFormat::Jpeg))
));
assert!(matches!(
classify_output("out.jpeg"),
Ok(OutputClass::Raster(RasterFormat::Jpeg))
));
assert!(matches!(
classify_output("out.bmp"),
Ok(OutputClass::Raster(RasterFormat::Bmp))
));
assert!(matches!(
classify_output("out.webp"),
Ok(OutputClass::Raster(RasterFormat::Webp))
));
assert!(classify_output("out.tiff").is_err());
assert!(classify_output("out.gif").is_err());
assert!(classify_output("out.ico").is_err());
assert!(classify_output("noext").is_err());
}
#[test]
fn classify_with_an_empty_registry_matches_the_pure_classification() {
let ctx = RuntimeContext::new();
for out in [
"out.pdf", "out.svg", "out.png", "out.jpg", "out.bmp", "out.webp",
] {
assert_eq!(
classify_output_with(Some(&ctx), out).unwrap(),
classify_output(out).unwrap(),
"{out}"
);
}
for out in ["out.tiff", "out.gif", "out.ico", "out.mp4", "noext"] {
assert!(classify_output_with(Some(&ctx), out).is_err(), "{out}");
}
}
#[test]
fn rgba_image_round_trips_through_the_gateway_image() {
let img = RgbaImage {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 4, 5, 6, 7, 8],
stride: 8,
};
let gw = img.to_image().unwrap();
assert_eq!(gw.format(), PixelFormat::Rgba);
assert_eq!((gw.width(), gw.height()), (2, 1));
let back = RgbaImage::from_image(&gw).unwrap();
assert_eq!(back.pixels, img.pixels);
assert_eq!(back.stride, 8);
let rgb = RgbaImage {
width: 2,
height: 1,
pixels: vec![1, 2, 3, 5, 6, 7],
stride: 6,
};
let gw = rgb.to_image().unwrap();
assert_eq!(gw.format(), PixelFormat::Rgb24);
let back = RgbaImage::from_image(&gw).unwrap();
assert_eq!(back.pixels, vec![1, 2, 3, 255, 5, 6, 7, 255]);
}
#[test]
fn padded_rows_are_kept_as_a_stride() {
let img = RgbaImage {
width: 1,
height: 2,
pixels: vec![1, 2, 3, 4, 0, 0, 0, 0, 5, 6, 7, 8, 0, 0, 0, 0],
stride: 8,
};
let back = RgbaImage::from_image(&img.to_image().unwrap()).unwrap();
assert_eq!(back.pixels, vec![1, 2, 3, 4, 5, 6, 7, 8]);
}
#[test]
fn encode_without_a_registry_is_a_typed_unsupported_error() {
let ctx = RuntimeContext::new();
let img = RgbaImage {
width: 1,
height: 1,
pixels: vec![1, 2, 3, 4],
stride: 4,
};
let err = encode_raster(&ctx, &img, "out.png", &[]).unwrap_err();
assert!(matches!(err, Error::Unsupported(_)), "{err:?}");
}
#[test]
fn drop_alpha_shrinks_buffer_three_quarters() {
let mut img = RgbaImage {
width: 2,
height: 2,
pixels: vec![
10, 20, 30, 40, 50, 60, 70, 80, 90, 100, 110, 120, 130, 140, 150, 160,
],
stride: 8,
};
drop_alpha(&mut img);
assert_eq!(img.stride, 6);
assert_eq!(img.pixels.len(), 12);
assert_eq!(img.pixels[..3], [10, 20, 30]);
assert_eq!(img.pixels[3..6], [50, 60, 70]);
}
#[test]
fn flatten_alpha_over_white_preserves_opaque_pixels() {
let mut img = RgbaImage {
width: 2,
height: 1,
pixels: vec![255, 0, 0, 255, 0, 0, 0, 0],
stride: 8,
};
flatten_alpha_over(&mut img, [255, 255, 255, 255]);
assert_eq!(img.pixels[..4], [255, 0, 0, 255]);
assert_eq!(img.pixels[4..], [255, 255, 255, 255]);
}
#[test]
fn set_alpha_to_zero_leaves_colour_intact() {
let mut img = RgbaImage {
width: 1,
height: 1,
pixels: vec![10, 20, 30, 100],
stride: 4,
};
set_alpha(&mut img, 0);
assert_eq!(img.pixels, [10, 20, 30, 0]);
}
}