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.
This commit is contained in:
can1357
2026-02-01 15:20:19 +01:00
parent df1a8bfe02
commit e042823697
8 changed files with 116 additions and 229 deletions
+53 -78
View File
@@ -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<SamplingFilter> for FilterType {
}
}
/// Image container for native interop. Stores image directly (no Arc overhead).
/// Image container for native interop.
#[napi]
pub struct PhotonImage {
img: Arc<DynamicImage>,
@@ -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<Self> {
#[napi(factory, js_name = "parse")]
pub async fn parse(bytes: Uint8Array) -> Result<Self> {
let bytes = bytes.as_ref().to_vec();
let img = spawn_blocking(move || -> Result<DynamicImage> {
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<Uint8Array> {
/// Returns an error if encoding fails or format is invalid.
#[napi(js_name = "encode")]
pub async fn encode(&self, format: u8, quality: u8) -> Result<Uint8Array> {
let img = Arc::clone(&self.img);
let capacity = (img.width() * img.height() * 4) as usize;
let buffer = spawn_blocking(move || -> Result<Vec<u8>> {
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<Uint8Array> {
let img = Arc::clone(&self.img);
let capacity = (img.width() * img.height() * 3) as usize;
let buffer = spawn_blocking(move || -> Result<Vec<u8>> {
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<Uint8Array> {
let img = Arc::clone(&self.img);
let capacity = (img.width() * img.height() * 4) as usize;
let buffer = spawn_blocking(move || -> Result<Vec<u8>> {
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<Uint8Array> {
let img = Arc::clone(&self.img);
let capacity = (img.width() * img.height()) as usize;
let buffer = spawn_blocking(move || -> Result<Vec<u8>> {
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<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}"))),
}
}
@@ -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",
@@ -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" },
+14 -4
View File
@@ -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
+4 -4
View File
@@ -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
+3 -107
View File
@@ -2,112 +2,8 @@
* Image processing via native bindings.
*/
import { type NativePhotonImage, native } from "../native";
import { native, SamplingFilter } from "../native";
const images = new Map<number, NativePhotonImage>();
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<PhotonImage> {
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<Uint8Array> {
return this.#native().getBytes();
}
/** Export as JPEG bytes with specified quality (0-100). */
async get_bytes_jpeg(quality: number): Promise<Uint8Array> {
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<PhotonImage> {
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 };
-2
View File
@@ -74,9 +74,7 @@ export async function find(options: FindOptions, onMatch?: (match: FindMatch) =>
export {
PhotonImage,
resize,
SamplingFilter,
terminate as terminateImageWorker,
} from "./image/index";
// =============================================================================
+28 -25
View File
@@ -36,37 +36,43 @@ export interface ParsedKittyResult {
eventType?: KeyEventType;
}
export interface NativePhotonImage {
getWidth(): number;
getHeight(): number;
getBytes(): Promise<Uint8Array>;
getBytesJpeg(quality: number): Promise<Uint8Array>;
getBytesWebp(): Promise<Uint8Array>;
getBytesGif(): Promise<Uint8Array>;
resize(width: number, height: number, filter: number): Promise<NativePhotonImage>;
export const enum ImageFormat {
PNG = 0,
JPEG = 1,
WEBP = 2,
GIF = 3,
}
export interface NativePhotonImageConstructor {
newFromByteslice(bytes: Uint8Array): Promise<NativePhotonImage>;
prototype: NativePhotonImage;
export interface PhotonImage {
get width(): number;
get height(): number;
encode(format: ImageFormat, quality: number): Promise<Uint8Array>;
resize(width: number, height: number, filter: number): Promise<PhotonImage>;
}
export interface NativeSamplingFilter {
Nearest: 1;
Triangle: 2;
CatmullRom: 3;
Gaussian: 4;
Lanczos3: 5;
export interface PhotonImageConstructor {
parse(bytes: Uint8Array): Promise<PhotonImage>;
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<T> = (error: Error | null, value: T) => void;
export interface NativeBindings {
copyToClipboard(text: string): Promise<void>;
readImageFromClipboard(): Promise<ClipboardImage | null>;
find(options: FindOptions, onMatch?: (error: Error | null, match: FindMatch) => void): Promise<FindResult>;
find(options: FindOptions, onMatch?: TsFunc<FindMatch>): Promise<FindResult>;
fuzzyFind(options: FuzzyFindOptions): Promise<FuzzyFindResult>;
grep(options: GrepOptions, onMatch?: (error: Error | null, match: GrepMatch) => void): Promise<GrepResult>;
grep(options: GrepOptions, onMatch?: TsFunc<GrepMatch>): Promise<GrepResult>;
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<ShellExecuteResult>;
executeShell(options: ShellExecuteOptions, onChunk?: TsFunc<string>): Promise<ShellExecuteResult>;
abortShellExecution(executionId: string): void;
parseKey(data: string, kittyProtocolActive: boolean): string | null;
matchesLegacySequence(data: string, keyName: string): boolean;