diff --git a/native/Cargo.lock b/native/Cargo.lock index 7fd93005a..ce5e0abfc 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -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]] diff --git a/native/crates/engine/Cargo.toml b/native/crates/engine/Cargo.toml index 7ac7a8756..d718e23ed 100644 --- a/native/crates/engine/Cargo.toml +++ b/native/crates/engine/Cargo.toml @@ -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" diff --git a/native/crates/engine/src/image.rs b/native/crates/engine/src/image.rs new file mode 100644 index 000000000..22969ef30 --- /dev/null +++ b/native/crates/engine/src/image.rs @@ -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 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, +} + +type ImageTask = task::Async; + +#[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 { + 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> { + 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 { + 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> { + 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}"))), + } +} diff --git a/native/crates/engine/src/lib.rs b/native/crates/engine/src/lib.rs index 0f6736e4d..1068a34cd 100644 --- a/native/crates/engine/src/lib.rs +++ b/native/crates/engine/src/lib.rs @@ -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; diff --git a/native/crates/engine/src/task.rs b/native/crates/engine/src/task.rs new file mode 100644 index 000000000..a5a012c63 --- /dev/null +++ b/native/crates/engine/src/task.rs @@ -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, +} + +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) -> 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 +where + T: Send + 'static, +{ + cancel_token: CancelToken, + work: Option Result + Send>>, +} + +impl Task for Blocking +where + T: ToNapiValue + TypeName + Send + 'static, +{ + type JsValue = T; + type Output = T; + + fn compute(&mut self) -> Result { + 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 { + Ok(output) + } +} + +pub type Async = AsyncTask>; + +/// Create an `AsyncTask` that runs blocking work on libuv's thread pool. +pub fn blocking( + _tag: &'static str, + cancel_token: impl Into, + work: F, +) -> AsyncTask> +where + F: FnOnce(CancelToken) -> Result + Send + 'static, + T: ToNapiValue + TypeName + Send + 'static, +{ + AsyncTask::new(Blocking { + cancel_token: cancel_token.into(), + work: Some(Box::new(work)), + }) +} diff --git a/packages/native/package.json b/packages/native/package.json index 276ca324e..aa1fc7f30 100644 --- a/packages/native/package.json +++ b/packages/native/package.json @@ -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": [ diff --git a/packages/native/src/__tests__/image.test.mjs b/packages/native/src/__tests__/image.test.mjs new file mode 100644 index 000000000..91f297ed6 --- /dev/null +++ b/packages/native/src/__tests__/image.test.mjs @@ -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/); + }); +}); diff --git a/packages/native/src/image/index.ts b/packages/native/src/image/index.ts new file mode 100644 index 000000000..d27df47bb --- /dev/null +++ b/packages/native/src/image/index.ts @@ -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) + .NativeImage as NativeImageConstructor; + +interface NativeImageConstructor { + parse(bytes: Uint8Array): Promise; +} + +/** + * 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 { + return NativeImageClass.parse(bytes); +} diff --git a/packages/native/src/image/types.ts b/packages/native/src/image/types.ts new file mode 100644 index 000000000..5a9dbb8b5 --- /dev/null +++ b/packages/native/src/image/types.ts @@ -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; + /** Resize to the specified dimensions. Returns a new NativeImage Promise. */ + resize( + width: number, + height: number, + filter: SamplingFilter, + ): Promise; +} diff --git a/packages/native/src/index.ts b/packages/native/src/index.ts index c3ebe2a61..7d06991ff 100644 --- a/packages/native/src/index.ts +++ b/packages/native/src/index.ts @@ -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"; diff --git a/packages/native/src/native.ts b/packages/native/src/native.ts index 3596c6124..2339e8abf 100644 --- a/packages/native/src/native.ts +++ b/packages/native/src/native.ts @@ -48,4 +48,5 @@ export const native = loadNative() as { readImageFromClipboard: () => Promise; astGrep: (options: unknown) => unknown; astEdit: (options: unknown) => unknown; + NativeImage: unknown; };