Port image processing from Oh My Pi's pi-natives crate, adapted for napi-rs v2. Exposes NativeImage class with async parse/encode/resize methods backed by the Rust `image` crate (PNG, JPEG, WebP, GIF support). Includes: - task.rs: lightweight async task scheduling for libuv thread pool - image.rs: NativeImage class with SamplingFilter enum - TypeScript types and wrapper (parseImage, ImageFormat, SamplingFilter) - 8 passing tests covering decode, encode, resize, round-trip, error cases Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
137 lines
4.6 KiB
Rust
137 lines
4.6 KiB
Rust
//! Image decode, encode, and resize via N-API.
|
|
//!
|
|
//! Provides:
|
|
//! - Load image from bytes (PNG, JPEG, WebP, GIF)
|
|
//! - Get dimensions
|
|
//! - Resize with configurable sampling filter
|
|
//! - Export as PNG, JPEG, WebP, or GIF
|
|
|
|
use std::{io::Cursor, sync::Arc};
|
|
|
|
use image::{
|
|
DynamicImage, ImageFormat, ImageReader,
|
|
codecs::{jpeg::JpegEncoder, webp::WebPEncoder},
|
|
imageops::FilterType,
|
|
};
|
|
use napi::bindgen_prelude::*;
|
|
use napi_derive::napi;
|
|
|
|
use crate::task;
|
|
|
|
/// Sampling filter for resize operations.
|
|
#[napi]
|
|
pub enum SamplingFilter {
|
|
/// Nearest-neighbor sampling (fast, low quality).
|
|
Nearest = 1,
|
|
/// Triangle filter (linear interpolation).
|
|
Triangle = 2,
|
|
/// Catmull-Rom filter with sharper edges.
|
|
CatmullRom = 3,
|
|
/// Gaussian filter for smoother results.
|
|
Gaussian = 4,
|
|
/// Lanczos3 filter for high-quality downscaling.
|
|
Lanczos3 = 5,
|
|
}
|
|
|
|
impl From<SamplingFilter> for FilterType {
|
|
fn from(filter: SamplingFilter) -> Self {
|
|
match filter {
|
|
SamplingFilter::Nearest => Self::Nearest,
|
|
SamplingFilter::Triangle => Self::Triangle,
|
|
SamplingFilter::CatmullRom => Self::CatmullRom,
|
|
SamplingFilter::Gaussian => Self::Gaussian,
|
|
SamplingFilter::Lanczos3 => Self::Lanczos3,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Image container for native interop.
|
|
#[napi]
|
|
pub struct NativeImage {
|
|
img: Arc<DynamicImage>,
|
|
}
|
|
|
|
type ImageTask = task::Async<NativeImage>;
|
|
|
|
#[napi]
|
|
impl NativeImage {
|
|
/// Decode encoded image bytes (PNG, JPEG, WebP, GIF) into a NativeImage.
|
|
#[napi(js_name = "parse")]
|
|
pub fn parse(bytes: Uint8Array) -> ImageTask {
|
|
let bytes = bytes.as_ref().to_vec();
|
|
task::blocking("image.decode", (), move |_| -> Result<Self> {
|
|
let img = decode_image_from_bytes(&bytes)?;
|
|
Ok(Self { img: Arc::new(img) })
|
|
})
|
|
}
|
|
|
|
/// Image width in pixels.
|
|
#[napi(getter, js_name = "width")]
|
|
pub fn get_width(&self) -> u32 {
|
|
self.img.width()
|
|
}
|
|
|
|
/// Image height in pixels.
|
|
#[napi(getter, js_name = "height")]
|
|
pub fn get_height(&self) -> u32 {
|
|
self.img.height()
|
|
}
|
|
|
|
/// Encode to bytes. Format: 0=PNG, 1=JPEG, 2=WebP, 3=GIF.
|
|
#[napi(js_name = "encode")]
|
|
pub fn encode(&self, format: u8, quality: u8) -> task::Async<Vec<u8>> {
|
|
let img = Arc::clone(&self.img);
|
|
task::blocking("image.encode", (), move |_| encode_image(&img, format, quality))
|
|
}
|
|
|
|
/// Resize to exact dimensions. Returns a new NativeImage.
|
|
#[napi(js_name = "resize")]
|
|
pub fn resize(&self, width: u32, height: u32, filter: SamplingFilter) -> ImageTask {
|
|
let img = Arc::clone(&self.img);
|
|
task::blocking("image.resize", (), move |_| {
|
|
Ok(Self { img: Arc::new(img.resize_exact(width, height, filter.into())) })
|
|
})
|
|
}
|
|
}
|
|
|
|
fn decode_image_from_bytes(bytes: &[u8]) -> Result<DynamicImage> {
|
|
let reader = ImageReader::new(Cursor::new(bytes))
|
|
.with_guessed_format()
|
|
.map_err(|e| Error::from_reason(format!("Failed to detect image format: {e}")))?;
|
|
reader
|
|
.decode()
|
|
.map_err(|e| Error::from_reason(format!("Failed to decode image: {e}")))
|
|
}
|
|
|
|
fn encode_image(img: &DynamicImage, format: u8, quality: u8) -> Result<Vec<u8>> {
|
|
let (w, h) = (img.width(), img.height());
|
|
match format {
|
|
0 => {
|
|
let mut buffer = Vec::with_capacity((w * h * 4) as usize);
|
|
img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png)
|
|
.map_err(|e| Error::from_reason(format!("Failed to encode PNG: {e}")))?;
|
|
Ok(buffer)
|
|
},
|
|
1 => {
|
|
let mut buffer = Vec::with_capacity((w * h * 3) as usize);
|
|
let encoder = JpegEncoder::new_with_quality(&mut buffer, quality);
|
|
img.write_with_encoder(encoder)
|
|
.map_err(|e| Error::from_reason(format!("Failed to encode JPEG: {e}")))?;
|
|
Ok(buffer)
|
|
},
|
|
2 => {
|
|
let mut buffer = Vec::with_capacity((w * h * 4) as usize);
|
|
let encoder = WebPEncoder::new_lossless(&mut buffer);
|
|
img.write_with_encoder(encoder)
|
|
.map_err(|e| Error::from_reason(format!("Failed to encode WebP: {e}")))?;
|
|
Ok(buffer)
|
|
},
|
|
3 => {
|
|
let mut buffer = Vec::with_capacity((w * h) as usize);
|
|
img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Gif)
|
|
.map_err(|e| Error::from_reason(format!("Failed to encode GIF: {e}")))?;
|
|
Ok(buffer)
|
|
},
|
|
_ => Err(Error::from_reason(format!("Invalid image format: {format}"))),
|
|
}
|
|
}
|