use anyhow::{Context, Result};
use fast_image_resize::images::Image;
use fast_image_resize::{FilterType, PixelType, ResizeAlg, ResizeOptions, Resizer};
use mozjpeg::{ColorSpace, Compress, Decompress};
use std::sync::OnceLock;
use std::sync::atomic::{AtomicUsize, Ordering};
fn fwd_lut() -> &'static [u16; 256] {
static LUT: OnceLock<[u16; 256]> = OnceLock::new();
LUT.get_or_init(|| {
let mut t = [0u16; 256];
for (i, v) in t.iter_mut().enumerate() {
let s = i as f64 / 255.0;
let lin = if s <= 0.04045 {
s / 12.92
} else {
((s + 0.055) / 1.055).powf(2.4)
};
*v = (lin * 65535.0 + 0.5) as u16;
}
t
})
}
fn fwd_lut_f32() -> &'static [f32; 256] {
static LUT: OnceLock<[f32; 256]> = OnceLock::new();
LUT.get_or_init(|| {
let mut t = [0f32; 256];
let fwd = fwd_lut();
for (d, &v) in t.iter_mut().zip(fwd.iter()) {
*d = v as f32;
}
t
})
}
fn back_lut() -> &'static [u8; 65536] {
static LUT: OnceLock<Box<[u8; 65536]>> = OnceLock::new();
LUT.get_or_init(|| {
let mut t = vec![0u8; 65536].into_boxed_slice();
for (i, v) in t.iter_mut().enumerate() {
let lin = i as f64 / 65535.0;
let s = if lin <= 0.003_130_8 {
12.92 * lin
} else {
1.055 * lin.powf(1.0 / 2.4) - 0.055
};
*v = (s * 255.0 + 0.5) as u8;
}
t.try_into().unwrap()
})
}
#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
pub enum Encoder {
#[default]
Jpegli,
MozFast,
MozSmall,
}
impl Encoder {
pub fn from_preset(preset: &str) -> Self {
match preset {
"fast" => Encoder::MozFast,
"small" => Encoder::MozSmall,
_ => Encoder::Jpegli,
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub enum PngEffort {
Fastest,
Fast,
Balanced,
High,
}
#[derive(Clone, Debug)]
pub struct Params {
pub max_width: u32,
pub max_height: u32,
pub quality: f32,
pub encoder: Encoder,
pub parallel: usize,
pub output: Option<ImageFormat>,
pub webp_quality: Option<f32>,
pub png_effort: Option<PngEffort>,
pub png_quantize: Option<bool>,
pub png_quantize_colors: Option<u16>,
pub auto_rotate: Option<bool>,
pub icc: Option<bool>,
pub flatten_bg: Option<[u8; 3]>,
pub linear_light: Option<bool>,
#[cfg(feature = "avif")]
pub avif_quality: Option<u8>,
}
impl Default for Params {
fn default() -> Self {
Params {
max_width: u32::MAX,
max_height: u32::MAX,
quality: 80.0,
encoder: Encoder::Jpegli,
parallel: 1,
output: None,
webp_quality: None,
png_effort: None,
png_quantize: None,
png_quantize_colors: None,
auto_rotate: None,
icc: None,
flatten_bg: None,
linear_light: None,
#[cfg(feature = "avif")]
avif_quality: None,
}
}
}
fn format_max_dimension(format: ImageFormat) -> Option<u32> {
match format {
ImageFormat::Webp => Some(16383),
_ => None,
}
}
fn clamp_to_format(p: &Params, target: ImageFormat) -> Params {
let Some(cap) = format_max_dimension(target) else {
return p.clone();
};
if p.max_width <= cap && p.max_height <= cap {
return p.clone();
}
Params {
max_width: p.max_width.min(cap),
max_height: p.max_height.min(cap),
..p.clone()
}
}
fn fit_dims(src_w: usize, src_h: usize, max_w: u32, max_h: u32) -> (usize, usize) {
let scale = f64::min(
max_w as f64 / src_w as f64,
f64::min(max_h as f64 / src_h as f64, 1.0),
);
(
((src_w as f64 * scale).round() as usize).max(1),
((src_h as f64 * scale).round() as usize).max(1),
)
}
fn dct_scale_num(src_w: usize, src_h: usize, dst_w: usize, dst_h: usize, margin: f64) -> u8 {
let (need_w, need_h) = (
(dst_w as f64 * margin).ceil() as usize,
(dst_h as f64 * margin).ceil() as usize,
);
for num in 1..=8u8 {
let sw = (src_w * num as usize).div_ceil(8);
let sh = (src_h * num as usize).div_ceil(8);
if (sw >= need_w && sh >= need_h) || (sw >= src_w && sh >= src_h) {
return num;
}
}
8
}
fn dct_margin() -> f64 {
crate::config::config().dct_margin
}
#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug)]
pub enum ImageFormat {
Jpeg,
Png,
Webp,
Avif,
}
impl ImageFormat {
pub fn content_type(self) -> &'static str {
match self {
ImageFormat::Jpeg => "image/jpeg",
ImageFormat::Png => "image/png",
ImageFormat::Webp => "image/webp",
ImageFormat::Avif => "image/avif",
}
}
pub fn from_token(token: &str) -> Option<ImageFormat> {
match token {
"jpg" | "jpeg" => Some(ImageFormat::Jpeg),
"png" => Some(ImageFormat::Png),
"webp" => Some(ImageFormat::Webp),
"avif" => Some(ImageFormat::Avif),
_ => None,
}
}
fn sniff(header: &[u8; 12]) -> Option<ImageFormat> {
if header.starts_with(&[0xFF, 0xD8]) {
Some(ImageFormat::Jpeg)
} else if header.starts_with(b"\x89PNG\r\n\x1a\n") {
Some(ImageFormat::Png)
} else if &header[0..4] == b"RIFF" && &header[8..12] == b"WEBP" {
Some(ImageFormat::Webp)
} else if &header[4..8] == b"ftyp"
&& (&header[8..12] == b"avif" || &header[8..12] == b"avis")
{
Some(ImageFormat::Avif)
} else {
None
}
}
}
pub fn probe(bytes: &[u8]) -> Result<(ImageFormat, usize, usize), Error> {
probe_inner(bytes).map_err(|e| Error::classify(e, false))
}
fn probe_inner(bytes: &[u8]) -> Result<(ImageFormat, usize, usize)> {
let mut header = [0u8; 12];
anyhow::ensure!(bytes.len() >= 12, "source too short");
header.copy_from_slice(&bytes[..12]);
let format = ImageFormat::sniff(&header).context("unsupported image format")?;
match format {
ImageFormat::Jpeg => {
let (w, h) = crate::panic_guard::catch_unwind_as_error("JPEG header parse", || {
Decompress::new_mem(bytes).map(|dec| dec.size())
})?
.context("parse JPEG")?;
Ok((format, w, h))
}
ImageFormat::Png => {
let mut r = png::Decoder::new(std::io::Cursor::new(bytes))
.read_info()
.context("parse PNG")?;
let info = r.info();
let dims = (info.width as usize, info.height as usize);
let _ = r.next_row();
Ok((format, dims.0, dims.1))
}
ImageFormat::Webp => unsafe {
use libwebp_sys as w;
let mut features: w::WebPBitstreamFeatures = std::mem::zeroed();
let status = w::WebPGetFeatures(bytes.as_ptr(), bytes.len(), &mut features);
anyhow::ensure!(
status == w::VP8StatusCode::VP8_STATUS_OK,
"parse WebP header"
);
Ok((format, features.width as usize, features.height as usize))
},
#[cfg(feature = "avif")]
ImageFormat::Avif => {
let (w, h) = crate::avif::probe_avif(bytes)?;
Ok((format, w, h))
}
#[cfg(not(feature = "avif"))]
ImageFormat::Avif => anyhow::bail!("AVIF support is not enabled in this build"),
}
}
pub fn process(bytes: &[u8], p: &Params) -> Result<(Vec<u8>, ImageFormat), Error> {
process_reader(std::io::Cursor::new(bytes), p).map_err(|e| Error::classify(e, false))
}
fn process_reader<R: std::io::Read>(mut reader: R, p: &Params) -> Result<(Vec<u8>, ImageFormat)> {
let mut header = [0u8; 12];
std::io::Read::read_exact(&mut reader, &mut header).context("source too short")?;
let format = ImageFormat::sniff(&header).context("unsupported image format")?;
let target = p.output.unwrap_or(format);
let p = &clamp_to_format(p, target);
#[cfg(not(feature = "avif"))]
anyhow::ensure!(
target != ImageFormat::Avif,
"AVIF support is not enabled in this build"
);
let reader = std::io::BufReader::new(std::io::Read::chain(&header[..], reader));
let _active = ActiveGuard::enter();
SCRATCH.with(|s| {
let s = &mut *s.borrow_mut();
let out = match format {
ImageFormat::Jpeg => crate::panic_guard::catch_unwind_as_error("JPEG decode", || {
jpeg::process_jpeg(s, reader, target, p)
})??,
ImageFormat::Png => process_png(s, reader, target, p)?,
ImageFormat::Webp => process_webp(s, reader, target, p)?,
#[cfg(feature = "avif")]
ImageFormat::Avif => process_avif(s, reader, target, p)?,
#[cfg(not(feature = "avif"))]
ImageFormat::Avif => anyhow::bail!("AVIF support is not enabled in this build"),
};
Ok((out, target))
})
}
pub fn process_path(path: &std::path::Path, p: &Params) -> Result<(Vec<u8>, ImageFormat), Error> {
let inner = || -> Result<(Vec<u8>, ImageFormat)> {
let file = std::fs::File::open(path).context("open source")?;
process_reader(file, p)
};
inner().map_err(|e| Error::classify(e, false))
}
#[cfg(feature = "server")]
fn http_agent() -> &'static ureq::Agent {
static AGENT: OnceLock<ureq::Agent> = OnceLock::new();
AGENT.get_or_init(|| {
let cfg = crate::config::config();
ureq::Agent::config_builder()
.timeout_global(Some(std::time::Duration::from_secs(cfg.upstream_timeout)))
.timeout_connect(Some(std::time::Duration::from_secs(
cfg.upstream_connect_timeout,
)))
.max_redirects(0)
.build()
.into()
})
}
#[cfg(feature = "server")]
fn max_source_bytes() -> u64 {
crate::config::config().max_source_bytes
}
#[cfg(feature = "server")]
static UPSTREAM_RETRIES: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
#[cfg(feature = "server")]
pub fn upstream_retry_count() -> u64 {
UPSTREAM_RETRIES.load(std::sync::atomic::Ordering::Relaxed)
}
#[cfg(feature = "server")]
fn transient_fetch_error(e: &ureq::Error) -> bool {
matches!(
e,
ureq::Error::Io(_) | ureq::Error::ConnectionFailed | ureq::Error::HostNotFound
)
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct ServerFault;
impl std::fmt::Display for ServerFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("internal image-processing error")
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct SourceRejected;
impl std::fmt::Display for SourceRejected {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("source key rejected")
}
}
#[derive(Debug, Clone, Copy)]
pub(crate) struct UpstreamFault;
impl std::fmt::Display for UpstreamFault {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str("upstream image fetch failed")
}
}
#[cfg(feature = "server")]
struct CappedReader<R> {
inner: R,
remaining: u64,
}
#[cfg(feature = "server")]
impl<R: std::io::Read> std::io::Read for CappedReader<R> {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
if self.remaining == 0 {
let mut probe = [0u8; 1];
return match self.inner.read(&mut probe)? {
0 => Ok(0),
_ => Err(std::io::Error::new(
std::io::ErrorKind::FileTooLarge,
"source exceeds OXIMG_MAX_SOURCE_BYTES",
)),
};
}
let want = buf
.len()
.min(usize::try_from(self.remaining).unwrap_or(usize::MAX));
let n = self.inner.read(&mut buf[..want])?;
self.remaining -= n as u64;
Ok(n)
}
}
#[cfg(feature = "server")]
pub fn process_url(url: &str, p: &Params) -> Result<(Vec<u8>, ImageFormat), Error> {
process_url_inner(url, p).map_err(|e| Error::classify(e, true))
}
#[cfg(feature = "server")]
pub fn process_gcs(bucket: &str, key: &str, p: &Params) -> Result<(Vec<u8>, ImageFormat), Error> {
let inner = || -> Result<(Vec<u8>, ImageFormat)> {
let resp = gcs::fetch(bucket, key)?;
process_response(resp, p)
};
inner().map_err(|e| Error::classify(e, true))
}
#[cfg(feature = "server")]
pub fn gcs_startup() -> Result<(), String> {
gcs::startup()
}
#[cfg(feature = "server")]
fn process_url_inner(url: &str, p: &Params) -> Result<(Vec<u8>, ImageFormat)> {
let map_fetch_err = |e: ureq::Error| match e {
ureq::Error::StatusCode(404) => anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::NotFound,
"source returned 404",
)),
ureq::Error::StatusCode(code @ (400 | 414)) => {
anyhow::anyhow!("origin rejected the request ({code})").context(SourceRejected)
}
other => anyhow::Error::new(other)
.context("fetch source")
.context(UpstreamFault),
};
let resp = match http_agent().get(url).call() {
Ok(r) => r,
Err(e) if transient_fetch_error(&e) => {
UPSTREAM_RETRIES.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
std::thread::sleep(std::time::Duration::from_millis(100));
http_agent().get(url).call().map_err(map_fetch_err)?
}
Err(e) => return Err(map_fetch_err(e)),
};
process_response(resp, p)
}
#[cfg(feature = "server")]
fn process_response(
resp: ureq::http::Response<ureq::Body>,
p: &Params,
) -> Result<(Vec<u8>, ImageFormat)> {
let cap = max_source_bytes();
if let Some(len) = resp
.headers()
.get("content-length")
.and_then(|v| v.to_str().ok())
.and_then(|v| v.parse::<u64>().ok())
&& len > cap
{
return Err(anyhow::Error::new(std::io::Error::new(
std::io::ErrorKind::FileTooLarge,
format!("source is {len} bytes, over the {cap}-byte limit"),
)));
}
if resp.status().is_redirection() {
return Err(anyhow::anyhow!(
"origin answered {} (redirects are not followed)",
resp.status()
)
.context(UpstreamFault));
}
let reader = CappedReader {
inner: resp.into_body().into_reader(),
remaining: cap,
};
process_reader(reader, p)
}
thread_local! {
static SCRATCH: std::cell::RefCell<Scratch> = std::cell::RefCell::new(Scratch::default());
}
#[derive(Default)]
struct Scratch {
chunk8: Vec<u8>,
src16: Vec<u16>,
dst16: Vec<u16>,
srcbuf: Vec<u8>,
out8: Vec<u8>,
resizer: Option<Resizer>,
#[cfg(feature = "avif")]
y16: Vec<u16>,
#[cfg(feature = "avif")]
cb16: Vec<u16>,
#[cfg(feature = "avif")]
cr16: Vec<u16>,
}
fn scratch_u16(buf: &mut Vec<u16>, len: usize) -> &mut [u16] {
if buf.len() < len {
buf.resize(len, 0);
}
&mut buf[..len]
}
fn scratch_u8(buf: &mut Vec<u8>, len: usize) -> &mut [u8] {
if buf.len() < len {
buf.resize(len, 0);
}
&mut buf[..len]
}
fn u16_as_bytes(buf: &[u16]) -> &[u8] {
unsafe { std::slice::from_raw_parts(buf.as_ptr().cast(), buf.len() * 2) }
}
fn u16_as_bytes_mut(buf: &mut [u16]) -> &mut [u8] {
unsafe { std::slice::from_raw_parts_mut(buf.as_mut_ptr().cast(), buf.len() * 2) }
}
#[allow(clippy::too_many_arguments)]
fn resize_bands(
src_bytes: &[u8],
dec_w: usize,
dec_h: usize,
dst_bytes: &mut [u8],
dst_w: usize,
dst_h: usize,
px: PixelType,
threads: usize,
fallback: &mut Option<Resizer>,
) -> Result<()> {
let opts = ResizeOptions::new()
.resize_alg(ResizeAlg::Convolution(FilterType::Lanczos3))
.use_alpha(false);
let src_view =
fast_image_resize::images::ImageRef::new(dec_w as u32, dec_h as u32, src_bytes, px)?;
if threads <= 1 || dst_h < 2 * threads {
#[cfg(target_arch = "x86_64")]
if !crate::config::config().fir_backend {
if px == PixelType::U16x3 {
return resize_u16x3_picscale(src_bytes, dec_w, dec_h, dst_bytes, dst_w, dst_h);
}
if px == PixelType::U16x4 && crate::resize_avx2::Avx2::available() {
return crate::resize_avx2::resize_u16_avx2(
src_bytes, dec_w, dec_h, dst_bytes, dst_w, dst_h, 4,
);
}
}
#[cfg(target_arch = "aarch64")]
if matches!(px, PixelType::U16x3 | PixelType::U16x4)
&& !crate::config::config().fir_backend
&& std::arch::is_aarch64_feature_detected!("neon")
{
return crate::resize_neon::resize_u16_neon(
src_bytes,
dec_w,
dec_h,
dst_bytes,
dst_w,
dst_h,
px.size() / 2,
);
}
let mut dst_view = Image::from_slice_u8(dst_w as u32, dst_h as u32, dst_bytes, px)?;
let resizer = fallback.get_or_insert_with(Resizer::new);
resizer.resize(&src_view, &mut dst_view, &opts)?;
return Ok(());
}
let row_bytes = dst_w * px.size();
let rows_per = dst_h.div_ceil(threads);
let sy = dec_h as f64 / dst_h as f64;
std::thread::scope(|sc| -> Result<()> {
let mut handles = Vec::new();
for (i, band) in dst_bytes.chunks_mut(rows_per * row_bytes).enumerate() {
let band_h = band.len() / row_bytes;
let crop_top = (i * rows_per) as f64 * sy;
let crop_h = band_h as f64 * sy;
let src_view = &src_view;
handles.push(sc.spawn(move || -> Result<()> {
let mut dst_view = Image::from_slice_u8(dst_w as u32, band_h as u32, band, px)?;
Resizer::new().resize(
src_view,
&mut dst_view,
&opts.crop(0.0, crop_top, dec_w as f64, crop_h),
)?;
Ok(())
}));
}
for h in handles {
h.join().expect("resize band panicked")?;
}
Ok(())
})
}
#[cfg(target_arch = "x86_64")]
fn resize_u16x3_picscale(
src_bytes: &[u8],
src_w: usize,
src_h: usize,
dst_bytes: &mut [u8],
dst_w: usize,
dst_h: usize,
) -> Result<()> {
use pic_scale::{ImageStore, ImageStoreMut, ResamplingFunction, Scaler, ThreadingPolicy};
let (pre, src16, post) = unsafe { src_bytes.align_to::<u16>() };
anyhow::ensure!(pre.is_empty() && post.is_empty(), "unaligned u16 src");
let (pre, dst16, post) = unsafe { dst_bytes.align_to_mut::<u16>() };
anyhow::ensure!(pre.is_empty() && post.is_empty(), "unaligned u16 dst");
let src_store = ImageStore::<u16, 3>::from_slice(src16, src_w, src_h)
.map_err(|e| anyhow::anyhow!("pic-scale src: {e:?}"))?;
let mut dst_store = ImageStoreMut::<u16, 3>::from_slice(dst16, dst_w, dst_h)
.map_err(|e| anyhow::anyhow!("pic-scale dst: {e:?}"))?;
dst_store.bit_depth = 16;
let scaler =
Scaler::new(ResamplingFunction::Lanczos3).set_threading_policy(ThreadingPolicy::Single);
let plan = scaler
.plan_rgb_resampling16(src_store.size(), dst_store.size(), 16)
.map_err(|e| anyhow::anyhow!("pic-scale plan: {e:?}"))?;
plan.resample(&src_store, &mut dst_store)
.map_err(|e| anyhow::anyhow!("pic-scale resample: {e:?}"))?;
Ok(())
}
static ACTIVE_PIPELINES: AtomicUsize = AtomicUsize::new(0);
struct ActiveGuard;
impl ActiveGuard {
fn enter() -> ActiveGuard {
ACTIVE_PIPELINES.fetch_add(1, Ordering::Relaxed);
ActiveGuard
}
}
impl Drop for ActiveGuard {
fn drop(&mut self) {
ACTIVE_PIPELINES.fetch_sub(1, Ordering::Relaxed);
}
}
fn logical_cpus() -> usize {
static N: OnceLock<usize> = OnceLock::new();
*N.get_or_init(|| {
std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
})
}
fn overlap_mode() -> u8 {
static M: OnceLock<u8> = OnceLock::new();
*M.get_or_init(|| match std::env::var("OXIMG_OVERLAP").as_deref() {
Ok("0") => 0,
Ok("1") => 1,
_ => 2,
})
}
fn overlap_gate() -> bool {
match overlap_mode() {
0 => false,
1 => true,
_ => ACTIVE_PIPELINES.load(Ordering::Relaxed) * 2 <= logical_cpus(),
}
}
fn linear_light(p: &Params) -> bool {
p.linear_light
.unwrap_or_else(|| crate::config::config().linear_light)
}
fn auto_rotate(p: &Params) -> bool {
p.auto_rotate
.unwrap_or_else(|| crate::config::config().auto_rotate)
}
fn icc_passthrough(p: &Params) -> bool {
p.icc
.unwrap_or_else(|| crate::config::config().icc_passthrough)
}
fn target_supports_icc(target: ImageFormat) -> bool {
match target {
ImageFormat::Jpeg | ImageFormat::Png | ImageFormat::Webp => true,
#[cfg(feature = "avif")]
ImageFormat::Avif => true,
#[cfg(not(feature = "avif"))]
ImageFormat::Avif => false,
}
}
pub(crate) const ICC_CAP: usize = 4 * 1024 * 1024;
pub(crate) fn check_src_pixels(w: usize, h: usize) -> Result<()> {
let cap = crate::config::config().max_src_pixels;
let px = (w as u64).saturating_mul(h as u64);
if px > cap {
anyhow::bail!(std::io::Error::new(
std::io::ErrorKind::FileTooLarge,
format!("source is {w}x{h} ({px} pixels), over the OXIMG_MAX_SRC_PIXELS limit ({cap})"),
));
}
Ok(())
}
mod cmyk;
mod encode;
mod error;
mod formats;
mod fuse;
#[cfg(feature = "server")]
mod gcs;
mod jpeg;
#[cfg(test)]
mod tests;
use cmyk::*;
pub use encode::encode;
use encode::*;
pub use error::{Error, ErrorKind};
use formats::*;
use fuse::*;
pub use jpeg::decode_and_resize;
#[cfg_attr(not(test), allow(unused_imports))] use jpeg::*;