feat: add native image module — decode, encode, and resize via Rust image crate

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>
This commit is contained in:
Lex Christopherson 2026-03-13 12:51:49 -06:00
parent e05292f772
commit 75fe5d3319
11 changed files with 485 additions and 5 deletions

32
native/Cargo.lock generated
View file

@ -124,6 +124,12 @@ dependencies = [
"error-code",
]
[[package]]
name = "color_quant"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b"
[[package]]
name = "convert_case"
version = "0.6.0"
@ -300,6 +306,16 @@ dependencies = [
"windows-link",
]
[[package]]
name = "gif"
version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f5df2ba84018d80c213569363bdcd0c64e6933c67fe4c1d60ecf822971a3c35e"
dependencies = [
"color_quant",
"weezl",
]
[[package]]
name = "globset"
version = "0.4.18"
@ -405,6 +421,7 @@ name = "gsd-engine"
version = "0.1.0"
dependencies = [
"arboard",
"gsd-ast",
"gsd-grep",
"image",
"napi",
@ -464,10 +481,25 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104"
dependencies = [
"bytemuck",
"byteorder-lite",
"color_quant",
"gif",
"image-webp",
"moxcms",
"num-traits",
"png",
"tiff",
"zune-core",
"zune-jpeg",
]
[[package]]
name = "image-webp"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3"
dependencies = [
"byteorder-lite",
"quick-error",
]
[[package]]

View file

@ -14,7 +14,12 @@ crate-type = ["cdylib"]
gsd-ast = { path = "../ast" }
gsd-grep = { path = "../grep" }
arboard = "3"
image = { version = "0.25", default-features = false, features = ["png"] }
image = { version = "0.25", default-features = false, features = [
"png",
"jpeg",
"gif",
"webp",
] }
napi = { version = "2", features = ["napi8"] }
napi-derive = "2"

View file

@ -0,0 +1,137 @@
//! 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}"))),
}
}

View file

@ -1,9 +1,8 @@
//! N-API addon for GSD.
//!
//! Exposes high-performance Rust modules to Node.js via napi-rs.
//! Architecture mirrors Oh My Pi's pi-natives crate:
//! ```text
//! JS (packages/native) -> N-API -> Rust modules (grep, ...)
//! JS (packages/native) -> N-API -> Rust modules (ast, clipboard, grep, image, ...)
//! ```
#![allow(clippy::needless_pass_by_value)]
@ -11,3 +10,5 @@
mod ast;
mod clipboard;
mod grep;
mod image;
mod task;

View file

@ -0,0 +1,89 @@
//! Blocking work scheduling for N-API exports.
//!
//! Runs CPU-bound or blocking Rust work on libuv's thread pool via napi's
//! `Task` trait, keeping the main JS thread free.
use std::time::{Duration, Instant};
use napi::{Env, Error, Result, Task, bindgen_prelude::*};
/// Token for cooperative cancellation of blocking work.
#[derive(Clone, Default)]
pub struct CancelToken {
deadline: Option<Instant>,
}
impl From<()> for CancelToken {
fn from((): ()) -> Self {
Self::default()
}
}
impl CancelToken {
/// Create a new cancel token from an optional timeout in milliseconds.
#[allow(dead_code)]
pub fn new(timeout_ms: Option<u32>) -> Self {
Self {
deadline: timeout_ms
.map(|ms| Instant::now() + Duration::from_millis(ms as u64)),
}
}
/// Check if cancellation has been requested.
#[allow(dead_code)]
pub fn heartbeat(&self) -> Result<()> {
if let Some(deadline) = self.deadline {
if deadline < Instant::now() {
return Err(Error::from_reason("Aborted: Timeout"));
}
}
Ok(())
}
}
/// Task that runs blocking work on libuv's thread pool.
pub struct Blocking<T>
where
T: Send + 'static,
{
cancel_token: CancelToken,
work: Option<Box<dyn FnOnce(CancelToken) -> Result<T> + Send>>,
}
impl<T> Task for Blocking<T>
where
T: ToNapiValue + TypeName + Send + 'static,
{
type JsValue = T;
type Output = T;
fn compute(&mut self) -> Result<Self::Output> {
let work = self
.work
.take()
.ok_or_else(|| Error::from_reason("BlockingTask: work already consumed"))?;
work(self.cancel_token.clone())
}
fn resolve(&mut self, _env: Env, output: Self::Output) -> Result<Self::JsValue> {
Ok(output)
}
}
pub type Async<T> = AsyncTask<Blocking<T>>;
/// Create an `AsyncTask` that runs blocking work on libuv's thread pool.
pub fn blocking<T, F>(
_tag: &'static str,
cancel_token: impl Into<CancelToken>,
work: F,
) -> AsyncTask<Blocking<T>>
where
F: FnOnce(CancelToken) -> Result<T> + Send + 'static,
T: ToNapiValue + TypeName + Send + 'static,
{
AsyncTask::new(Blocking {
cancel_token: cancel_token.into(),
work: Some(Box::new(work)),
})
}

View file

@ -1,14 +1,14 @@
{
"name": "@gsd/native",
"version": "0.1.0",
"description": "Native Rust bindings for GSD — high-performance grep and clipboard via N-API",
"description": "Native Rust bindings for GSD — high-performance native modules via N-API",
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"scripts": {
"build:native": "node ../../native/scripts/build.js",
"build:native:dev": "node ../../native/scripts/build.js --dev",
"test": "node --test src/__tests__/grep.test.mjs src/__tests__/clipboard.test.mjs"
"test": "node --test src/__tests__/grep.test.mjs src/__tests__/clipboard.test.mjs src/__tests__/image.test.mjs"
},
"exports": {
".": {
@ -26,6 +26,10 @@
"./ast": {
"types": "./src/ast/index.ts",
"import": "./src/ast/index.ts"
},
"./image": {
"types": "./src/image/index.ts",
"import": "./src/image/index.ts"
}
},
"files": [

View file

@ -0,0 +1,137 @@
import { test, describe } from "node:test";
import assert from "node:assert/strict";
import { createRequire } from "node:module";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { deflateSync } from "node:zlib";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const require = createRequire(import.meta.url);
const addonDir = path.resolve(__dirname, "..", "..", "..", "..", "native", "addon");
const platformTag = `${process.platform}-${process.arch}`;
const candidates = [
path.join(addonDir, `gsd_engine.${platformTag}.node`),
path.join(addonDir, "gsd_engine.dev.node"),
];
let native;
for (const candidate of candidates) {
try {
native = require(candidate);
break;
} catch {
// try next
}
}
if (!native) {
console.error("Native addon not found. Run 'npm run build:native -w @gsd/native' first.");
process.exit(1);
}
function crc32(buf) {
let crc = 0xffffffff;
const table = [];
for (let n = 0; n < 256; n++) {
let c = n;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[n] = c;
}
for (let i = 0; i < buf.length; i++) crc = table[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function createTestPng() {
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdrData = Buffer.alloc(13);
ihdrData.writeUInt32BE(2, 0);
ihdrData.writeUInt32BE(2, 4);
ihdrData[8] = 8;
ihdrData[9] = 2;
const ihdrType = Buffer.from("IHDR");
const ihdrCrc = Buffer.alloc(4);
ihdrCrc.writeUInt32BE(crc32(Buffer.concat([ihdrType, ihdrData])));
const ihdr = Buffer.concat([Buffer.from([0, 0, 0, 13]), ihdrType, ihdrData, ihdrCrc]);
const raw = Buffer.from([
0, 255, 0, 0, 255, 0, 0,
0, 255, 0, 0, 255, 0, 0,
]);
const compressed = deflateSync(raw);
const idatType = Buffer.from("IDAT");
const idatLen = Buffer.alloc(4);
idatLen.writeUInt32BE(compressed.length);
const idatCrc = Buffer.alloc(4);
idatCrc.writeUInt32BE(crc32(Buffer.concat([idatType, compressed])));
const idat = Buffer.concat([idatLen, idatType, compressed, idatCrc]);
const iendType = Buffer.from("IEND");
const iendCrc = Buffer.alloc(4);
iendCrc.writeUInt32BE(crc32(iendType));
const iend = Buffer.concat([Buffer.from([0, 0, 0, 0]), iendType, iendCrc]);
return Buffer.concat([signature, ihdr, idat, iend]);
}
const NativeImage = native.NativeImage;
describe("native image: NativeImage", () => {
test("NativeImage class exists with parse method", () => {
assert.ok(NativeImage, "NativeImage should be exported");
assert.equal(typeof NativeImage.parse, "function");
});
test("parse decodes PNG with correct dimensions", async () => {
const img = await NativeImage.parse(createTestPng());
assert.equal(img.width, 2);
assert.equal(img.height, 2);
});
test("encode to PNG produces valid PNG", async () => {
const img = await NativeImage.parse(createTestPng());
const encoded = await img.encode(0, 100);
assert.ok(encoded.length > 0);
assert.equal(encoded[0], 0x89);
assert.equal(encoded[1], 0x50);
assert.equal(encoded[2], 0x4e);
assert.equal(encoded[3], 0x47);
});
test("encode to JPEG produces valid JPEG", async () => {
const img = await NativeImage.parse(createTestPng());
const encoded = await img.encode(1, 80);
assert.ok(encoded.length > 0);
assert.equal(encoded[0], 0xff);
assert.equal(encoded[1], 0xd8);
});
test("resize returns correct dimensions", async () => {
const img = await NativeImage.parse(createTestPng());
const resized = await img.resize(10, 20, 5);
assert.equal(resized.width, 10);
assert.equal(resized.height, 20);
});
test("resize + encode round-trip", async () => {
const img = await NativeImage.parse(createTestPng());
const resized = await img.resize(4, 4, 1);
const encoded = await resized.encode(0, 100);
assert.ok(encoded.length > 0);
const reparsed = await NativeImage.parse(new Uint8Array(encoded));
assert.equal(reparsed.width, 4);
assert.equal(reparsed.height, 4);
});
test("rejects invalid image data", async () => {
await assert.rejects(
() => NativeImage.parse(new Uint8Array([0, 1, 2, 3, 4, 5])),
/Failed to (detect|decode) image/,
);
});
test("rejects invalid format number", async () => {
const img = await NativeImage.parse(createTestPng());
await assert.rejects(() => img.encode(99, 100), /Invalid image format/);
});
});

View file

@ -0,0 +1,28 @@
/**
* Native image processing module using N-API.
*
* High-performance image decode/encode/resize backed by the Rust `image` crate.
*/
import { native } from "../native.js";
import type { NativeImageHandle } from "./types.js";
import { ImageFormat, SamplingFilter } from "./types.js";
export { ImageFormat, SamplingFilter };
export type { NativeImageHandle };
const NativeImageClass = (native as Record<string, unknown>)
.NativeImage as NativeImageConstructor;
interface NativeImageConstructor {
parse(bytes: Uint8Array): Promise<NativeImageHandle>;
}
/**
* Decode image bytes (PNG, JPEG, WebP, GIF) into a NativeImage handle.
*
* Format is auto-detected from the byte content.
*/
export function parseImage(bytes: Uint8Array): Promise<NativeImageHandle> {
return NativeImageClass.parse(bytes);
}

View file

@ -0,0 +1,41 @@
/** Sampling filter for resize operations. */
export 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,
}
/** Output image format for encoding. */
export enum ImageFormat {
/** PNG (lossless, quality ignored). */
PNG = 0,
/** JPEG (lossy, quality 0-100). */
JPEG = 1,
/** WebP (lossless, quality ignored). */
WebP = 2,
/** GIF (quality ignored). */
GIF = 3,
}
/** Native image handle returned from parse(). */
export interface NativeImageHandle {
/** Image width in pixels. */
readonly width: number;
/** Image height in pixels. */
readonly height: number;
/** Encode to bytes in the specified format. Returns a Promise. */
encode(format: number, quality: number): Promise<number[]>;
/** Resize to the specified dimensions. Returns a new NativeImage Promise. */
resize(
width: number,
height: number,
filter: SamplingFilter,
): Promise<NativeImageHandle>;
}

View file

@ -2,8 +2,10 @@
* @gsd/native High-performance Rust modules exposed via N-API.
*
* Modules:
* - ast: AST-aware structural search and rewrite
* - clipboard: native clipboard access (text + image)
* - grep: ripgrep-backed regex search (content + filesystem)
* - image: decode, encode, and resize images
*/
export {
@ -34,3 +36,6 @@ export type {
AstReplaceOptions,
AstReplaceResult,
} from "./ast/index.js";
export { parseImage, ImageFormat, SamplingFilter } from "./image/index.js";
export type { NativeImageHandle } from "./image/index.js";

View file

@ -48,4 +48,5 @@ export const native = loadNative() as {
readImageFromClipboard: () => Promise<unknown>;
astGrep: (options: unknown) => unknown;
astEdit: (options: unknown) => unknown;
NativeImage: unknown;
};