From e042823697f00a2ff7b1d665478c220d4c2abebc Mon Sep 17 00:00:00 2001 From: can1357 Date: Sun, 1 Feb 2026 15:20:19 +0100 Subject: [PATCH] feat: refactored PhotonImage API to instance methods with simplified naming - Refactored PhotonImage API from standalone functions to instance methods with simplified naming conventions. - Renamed PhotonImage.new_from_byteslice() to parse() for a simpler, more intuitive factory method API. - Converted PhotonImage.get_width() and get_height() methods to width and height getter properties. - Consolidated four separate encoding methods (getBytes, getBytesJpeg, getBytesWebp, getBytesGif) into a single encode(format, quality) method. - Moved resize() from a standalone function to a PhotonImage instance method. - Added ImageFormat enum to specify encoding formats (PNG, JPEG, WEBP, GIF) in the encode method. - Removed manual resource management via free() and Symbol.dispose, simplifying PhotonImage lifecycle. --- crates/pi-natives/src/image.rs | 131 +++++++----------- .../coding-agent/src/utils/image-convert.ts | 5 +- .../coding-agent/src/utils/image-resize.ts | 18 ++- packages/natives/CHANGELOG.md | 18 ++- packages/natives/README.md | 8 +- packages/natives/src/image/index.ts | 110 +-------------- packages/natives/src/index.ts | 2 - packages/natives/src/native.ts | 53 +++---- 8 files changed, 116 insertions(+), 229 deletions(-) diff --git a/crates/pi-natives/src/image.rs b/crates/pi-natives/src/image.rs index e11d548f2..7dade7edc 100644 --- a/crates/pi-natives/src/image.rs +++ b/crates/pi-natives/src/image.rs @@ -3,7 +3,7 @@ //! Provides only the subset of functionality needed: //! - Load image from bytes (PNG, JPEG, WebP, GIF) //! - Get dimensions -//! - Resize with Lanczos3 filter +//! - Resize with configurable filter //! - Export as PNG, JPEG, WebP, or GIF use std::{io::Cursor, sync::Arc}; @@ -38,7 +38,7 @@ impl From for FilterType { } } -/// Image container for native interop. Stores image directly (no Arc overhead). +/// Image container for native interop. #[napi] pub struct PhotonImage { img: Arc, @@ -51,8 +51,8 @@ impl PhotonImage { /// /// # Errors /// Returns an error if the image format cannot be detected or decoded. - #[napi(factory, js_name = "newFromByteslice")] - pub async fn new_from_byteslice(bytes: Uint8Array) -> Result { + #[napi(factory, js_name = "parse")] + pub async fn parse(bytes: Uint8Array) -> Result { let bytes = bytes.as_ref().to_vec(); let img = spawn_blocking(move || -> Result { let reader = ImageReader::new(Cursor::new(bytes)) @@ -72,92 +72,33 @@ impl PhotonImage { } /// Get the width of the image. - #[napi(js_name = "getWidth")] + #[napi(getter, js_name = "width")] pub fn get_width(&self) -> u32 { self.img.width() } /// Get the height of the image. - #[napi(js_name = "getHeight")] + #[napi(getter, js_name = "height")] pub fn get_height(&self) -> u32 { self.img.height() } - /// Export image as PNG bytes. + /// Encode image to bytes in the specified format. + /// + /// Format values (matching `ImageFormat` enum in TS): + /// - 0: PNG (quality ignored) + /// - 1: JPEG (quality 0-100) + /// - 2: WebP (lossless, quality ignored) + /// - 3: GIF (quality ignored) /// /// # Errors - /// Returns an error if PNG encoding fails. - #[napi(js_name = "getBytes")] - pub async fn get_bytes(&self) -> Result { + /// Returns an error if encoding fails or format is invalid. + #[napi(js_name = "encode")] + pub async fn encode(&self, format: u8, quality: u8) -> Result { let img = Arc::clone(&self.img); - let capacity = (img.width() * img.height() * 4) as usize; - let buffer = spawn_blocking(move || -> Result> { - let mut buffer = Vec::with_capacity(capacity); - img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Png) - .map_err(|e| Error::from_reason(format!("Failed to encode PNG: {e}")))?; - Ok(buffer) - }) - .await - .map_err(|e| Error::from_reason(format!("PNG encode task failed: {e}")))??; - Ok(Uint8Array::from(buffer)) - } - - /// Export image as JPEG bytes with specified quality (0-100). - /// - /// # Errors - /// Returns an error if JPEG encoding fails. - #[napi(js_name = "getBytesJpeg")] - pub async fn get_bytes_jpeg(&self, quality: u8) -> Result { - let img = Arc::clone(&self.img); - let capacity = (img.width() * img.height() * 3) as usize; - let buffer = spawn_blocking(move || -> Result> { - let mut buffer = Vec::with_capacity(capacity); - 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) - }) - .await - .map_err(|e| Error::from_reason(format!("JPEG encode task failed: {e}")))??; - Ok(Uint8Array::from(buffer)) - } - - /// Export image as lossless WebP bytes. - /// - /// # Errors - /// Returns an error if WebP encoding fails. - #[napi(js_name = "getBytesWebp")] - pub async fn get_bytes_webp(&self) -> Result { - let img = Arc::clone(&self.img); - let capacity = (img.width() * img.height() * 4) as usize; - let buffer = spawn_blocking(move || -> Result> { - let mut buffer = Vec::with_capacity(capacity); - 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) - }) - .await - .map_err(|e| Error::from_reason(format!("WebP encode task failed: {e}")))??; - Ok(Uint8Array::from(buffer)) - } - - /// Export image as GIF bytes. - /// - /// # Errors - /// Returns an error if GIF encoding fails. - #[napi(js_name = "getBytesGif")] - pub async fn get_bytes_gif(&self) -> Result { - let img = Arc::clone(&self.img); - let capacity = (img.width() * img.height()) as usize; - let buffer = spawn_blocking(move || -> Result> { - let mut buffer = Vec::with_capacity(capacity); - img.write_to(&mut Cursor::new(&mut buffer), ImageFormat::Gif) - .map_err(|e| Error::from_reason(format!("Failed to encode GIF: {e}")))?; - Ok(buffer) - }) - .await - .map_err(|e| Error::from_reason(format!("GIF encode task failed: {e}")))??; + let buffer = spawn_blocking(move || encode_image(&img, format, quality)) + .await + .map_err(|e| Error::from_reason(format!("Encode task failed: {e}")))??; Ok(Uint8Array::from(buffer)) } @@ -171,3 +112,37 @@ impl PhotonImage { Ok(Self { img: Arc::new(resized) }) } } + +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/packages/coding-agent/src/utils/image-convert.ts b/packages/coding-agent/src/utils/image-convert.ts index 1d14049d3..b129beb06 100644 --- a/packages/coding-agent/src/utils/image-convert.ts +++ b/packages/coding-agent/src/utils/image-convert.ts @@ -1,4 +1,5 @@ import { PhotonImage } from "@oh-my-pi/pi-natives"; +import { ImageFormat } from "@oh-my-pi/pi-natives/native"; /** * Convert image to PNG format for terminal display. @@ -14,8 +15,8 @@ export async function convertToPng( } try { - using image = await PhotonImage.new_from_byteslice(new Uint8Array(Buffer.from(base64Data, "base64"))); - const pngBuffer = await image.get_bytes(); + const image = await PhotonImage.parse(new Uint8Array(Buffer.from(base64Data, "base64"))); + const pngBuffer = await image.encode(ImageFormat.PNG, 100); return { data: Buffer.from(pngBuffer).toString("base64"), mimeType: "image/png", diff --git a/packages/coding-agent/src/utils/image-resize.ts b/packages/coding-agent/src/utils/image-resize.ts index 1ca8bb090..c460b86c0 100644 --- a/packages/coding-agent/src/utils/image-resize.ts +++ b/packages/coding-agent/src/utils/image-resize.ts @@ -1,5 +1,6 @@ import type { ImageContent } from "@oh-my-pi/pi-ai"; -import { PhotonImage, resize, SamplingFilter } from "@oh-my-pi/pi-natives"; +import { PhotonImage, SamplingFilter } from "@oh-my-pi/pi-natives"; +import { ImageFormat } from "@oh-my-pi/pi-natives/native"; export interface ImageResizeOptions { maxWidth?: number; // Default: 2000 @@ -54,10 +55,10 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption const inputBuffer = Buffer.from(img.data, "base64"); try { - using image = await PhotonImage.new_from_byteslice(new Uint8Array(inputBuffer)); + const image = await PhotonImage.parse(inputBuffer); - const originalWidth = image.get_width(); - const originalHeight = image.get_height(); + const originalWidth = image.width; + const originalHeight = image.height; const format = img.mimeType?.split("/")[1] ?? "png"; // Check if already within all limits (dimensions AND size) @@ -91,11 +92,14 @@ export async function resizeImage(img: ImageContent, options?: ImageResizeOption async function tryBothFormats( width: number, height: number, - jpegQuality: number, + quality: number, ): Promise<{ buffer: Uint8Array; mimeType: string }> { - using resized = await resize(image!, width, height, SamplingFilter.Lanczos3); + const resized = await image.resize(width, height, SamplingFilter.Lanczos3); - const [pngBuffer, jpegBuffer] = await Promise.all([resized.get_bytes(), resized.get_bytes_jpeg(jpegQuality)]); + const [pngBuffer, jpegBuffer] = await Promise.all([ + resized.encode(ImageFormat.PNG, quality), + resized.encode(ImageFormat.JPEG, quality), + ]); return pickSmaller( { buffer: pngBuffer, mimeType: "image/png" }, diff --git a/packages/natives/CHANGELOG.md b/packages/natives/CHANGELOG.md index 01e084507..bfe64e229 100644 --- a/packages/natives/CHANGELOG.md +++ b/packages/natives/CHANGELOG.md @@ -1,12 +1,21 @@ # Changelog ## [Unreleased] +### Breaking Changes + +- Removed `resize()` function; use `PhotonImage.resize()` method instead +- Removed `terminateImageWorker()` function +- Changed `PhotonImage.new_from_byteslice()` to `PhotonImage.parse()` +- Changed `PhotonImage.get_bytes()` to `encode(ImageFormat.PNG, 100)` +- Changed `PhotonImage.get_bytes_jpeg(quality)` to `encode(ImageFormat.JPEG, quality)` +- Removed `get_width()` and `get_height()` methods; use `width` and `height` properties instead +- Removed manual resource management via `free()` and `Symbol.dispose` ### Added -- Added `sessionEnv` option to `ShellExecuteOptions` for setting environment variables once per session -- Added `sessionKey` option to `ShellExecuteOptions` for managing persistent brush shell instances -- Added `snapshotPath` option to `ShellExecuteOptions` to source bash session snapshots +- Added `ImageFormat` enum for specifying output format (PNG, JPEG, WEBP, GIF) in `encode()` method +- Added `SamplingFilter` as exported enum instead of object +- Added `Shell` class with persistent session options (`sessionEnv`, `snapshotPath`) and a `run()` command API - Exported `getSystemInfo()` function and `SystemInfo` type for retrieving system information including distro, kernel, CPU, and disk details - Exported `copyToClipboard()` and `readImageFromClipboard()` functions for clipboard operations - Exported `ClipboardImage` type for clipboard image data with MIME type information @@ -15,7 +24,8 @@ ### Changed -- Updated `env` option documentation in `ShellExecuteOptions` to clarify it applies variables for the current command only +- Changed `PhotonImage` API to use instance methods (`resize()`, `encode()`) instead of standalone functions +- Changed `PhotonImage` to use property accessors for `width` and `height` instead of getter methods ## [9.7.0] - 2026-02-01 diff --git a/packages/natives/README.md b/packages/natives/README.md index 4642a9cb9..168f23794 100644 --- a/packages/natives/README.md +++ b/packages/natives/README.md @@ -11,7 +11,7 @@ Native Rust functionality via N-API. ## Usage ```typescript -import { grep, find, PhotonImage, resize, SamplingFilter } from "@oh-my-pi/pi-natives"; +import { grep, find, PhotonImage, SamplingFilter, ImageFormat } from "@oh-my-pi/pi-natives"; // Grep for a pattern const results = await grep({ @@ -29,9 +29,9 @@ const files = await find({ }); // Image processing -using image = await PhotonImage.new_from_byteslice(bytes); -using resized = await resize(image, 800, 600, SamplingFilter.Lanczos3); -const pngBytes = await resized.get_bytes(); +const image = await PhotonImage.parse(bytes); +const resized = await image.resize(800, 600, SamplingFilter.Lanczos3); +const pngBytes = await resized.encode(ImageFormat.PNG, 100); ``` ## Building diff --git a/packages/natives/src/image/index.ts b/packages/natives/src/image/index.ts index 1fa4fc7c3..caf572769 100644 --- a/packages/natives/src/image/index.ts +++ b/packages/natives/src/image/index.ts @@ -2,112 +2,8 @@ * Image processing via native bindings. */ -import { type NativePhotonImage, native } from "../native"; +import { native, SamplingFilter } from "../native"; -const images = new Map(); -let nextHandle = 1; +const { PhotonImage } = native; -function registerImage(image: NativePhotonImage): number { - const handle = nextHandle++; - images.set(handle, image); - return handle; -} - -function getImage(handle: number): NativePhotonImage { - const image = images.get(handle); - if (!image) { - throw new Error("Image already freed"); - } - return image; -} - -export const SamplingFilter = native.SamplingFilter; -export type SamplingFilter = (typeof SamplingFilter)[keyof typeof SamplingFilter]; - -/** - * Image handle for async operations. - */ -export class PhotonImage { - #handle: number; - #freed = false; - - private constructor(handle: number) { - this.#handle = handle; - } - - /** @internal */ - static _create(handle: number): PhotonImage { - if (!images.has(handle)) { - throw new Error("Invalid image handle"); - } - return new PhotonImage(handle); - } - - /** - * Load an image from encoded bytes (PNG, JPEG, WebP, GIF). - */ - static async new_from_byteslice(bytes: Uint8Array): Promise { - const image = await native.PhotonImage.newFromByteslice(bytes); - const handle = registerImage(image); - return new PhotonImage(handle); - } - - /** @internal */ - _getHandle(): number { - if (this.#freed) throw new Error("Image already freed"); - return this.#handle; - } - - #native(): NativePhotonImage { - if (this.#freed) throw new Error("Image already freed"); - return getImage(this.#handle); - } - - /** Get image width in pixels. */ - get_width(): number { - return this.#native().getWidth(); - } - - /** Get image height in pixels. */ - get_height(): number { - return this.#native().getHeight(); - } - - /** Export as PNG bytes. */ - async get_bytes(): Promise { - return this.#native().getBytes(); - } - - /** Export as JPEG bytes with specified quality (0-100). */ - async get_bytes_jpeg(quality: number): Promise { - return this.#native().getBytesJpeg(quality); - } - - /** Release native resources. */ - free() { - if (this.#freed) return; - this.#freed = true; - images.delete(this.#handle); - } - - /** Alias for free() to support using-declarations. */ - [Symbol.dispose](): void { - this.free(); - } -} - -/** - * Resize an image to the specified dimensions. - * Returns a new PhotonImage (original is not modified). - */ -export async function resize(image: PhotonImage, width: number, height: number, filter: number): Promise { - const nativeImage = getImage(image._getHandle()); - const resized = await nativeImage.resize(width, height, filter); - const handle = registerImage(resized); - return PhotonImage._create(handle); -} - -/** - * Terminate image resources (no-op for native bindings). - */ -export function terminate(): void {} +export { PhotonImage, SamplingFilter }; diff --git a/packages/natives/src/index.ts b/packages/natives/src/index.ts index 2fda4e74c..bb9f2a37e 100644 --- a/packages/natives/src/index.ts +++ b/packages/natives/src/index.ts @@ -74,9 +74,7 @@ export async function find(options: FindOptions, onMatch?: (match: FindMatch) => export { PhotonImage, - resize, SamplingFilter, - terminate as terminateImageWorker, } from "./image/index"; // ============================================================================= diff --git a/packages/natives/src/native.ts b/packages/natives/src/native.ts index bff33eca6..9bb055bb2 100644 --- a/packages/natives/src/native.ts +++ b/packages/natives/src/native.ts @@ -36,37 +36,43 @@ export interface ParsedKittyResult { eventType?: KeyEventType; } -export interface NativePhotonImage { - getWidth(): number; - getHeight(): number; - getBytes(): Promise; - getBytesJpeg(quality: number): Promise; - getBytesWebp(): Promise; - getBytesGif(): Promise; - resize(width: number, height: number, filter: number): Promise; +export const enum ImageFormat { + PNG = 0, + JPEG = 1, + WEBP = 2, + GIF = 3, } -export interface NativePhotonImageConstructor { - newFromByteslice(bytes: Uint8Array): Promise; - prototype: NativePhotonImage; +export interface PhotonImage { + get width(): number; + get height(): number; + encode(format: ImageFormat, quality: number): Promise; + resize(width: number, height: number, filter: number): Promise; } -export interface NativeSamplingFilter { - Nearest: 1; - Triangle: 2; - CatmullRom: 3; - Gaussian: 4; - Lanczos3: 5; +export interface PhotonImageConstructor { + parse(bytes: Uint8Array): Promise; + prototype: PhotonImage; +} + +export const enum SamplingFilter { + Nearest = 1, + Triangle = 2, + CatmullRom = 3, + Gaussian = 4, + Lanczos3 = 5, } import type { GrepMatch } from "./grep/types"; +export type TsFunc = (error: Error | null, value: T) => void; + export interface NativeBindings { copyToClipboard(text: string): Promise; readImageFromClipboard(): Promise; - find(options: FindOptions, onMatch?: (error: Error | null, match: FindMatch) => void): Promise; + find(options: FindOptions, onMatch?: TsFunc): Promise; fuzzyFind(options: FuzzyFindOptions): Promise; - grep(options: GrepOptions, onMatch?: (error: Error | null, match: GrepMatch) => void): Promise; + grep(options: GrepOptions, onMatch?: TsFunc): Promise; search(content: string | Uint8Array, options: SearchOptions): SearchResult; hasMatch( content: string | Uint8Array, @@ -78,8 +84,8 @@ export interface NativeBindings { highlightCode(code: string, lang: string | null | undefined, colors: HighlightColors): string; supportsLanguage(lang: string): boolean; getSupportedLanguages(): string[]; - SamplingFilter: NativeSamplingFilter; - PhotonImage: NativePhotonImageConstructor; + SamplingFilter: SamplingFilter; + PhotonImage: PhotonImageConstructor; truncateToWidth(text: string, maxWidth: number, ellipsisKind: number, pad: boolean): string; wrapTextWithAnsi(text: string, width: number): string[]; sliceWithWidth(line: string, startCol: number, length: number, strict: boolean): SliceWithWidthResult; @@ -92,10 +98,7 @@ export interface NativeBindings { strictAfter: boolean, ): ExtractSegmentsResult; matchesKittySequence(data: string, expectedCodepoint: number, expectedModifier: number): boolean; - executeShell( - options: ShellExecuteOptions, - onChunk?: (error: Error | null, chunk: string) => void, - ): Promise; + executeShell(options: ShellExecuteOptions, onChunk?: TsFunc): Promise; abortShellExecution(executionId: string): void; parseKey(data: string, kittyProtocolActive: boolean): string | null; matchesLegacySequence(data: string, keyName: string): boolean;