UNPKG

image-stitch

Version:

Stitch images together efficiently with multi-format support (PNG, JPEG, HEIC), streaming, for node.js and web

218 lines 8.12 kB
/** * JPEG Encoder Wrapper * * Lightweight wrapper around the WASM JPEG encoder that provides: * - Single initialization of WASM module * - Async generator API returning byte chunks * - Handles worker/wasm module loading differences between Node and browser * - Streaming encode that yields JPEG marker/data chunks as they're generated */ import { init as jpegEncoderInit, StreamingJpegEncoder, WasmColorType } from 'jpeg-encoder-wasm'; let wasmInitialized = false; /** * Initialize the WASM module * Handles both Node.js and browser environments */ async function initWasm() { if (wasmInitialized) { return; } // In Node.js, we need to load the wasm file manually // The new package structure has both ESM and CJS builds with proper WASM loading if (typeof process !== 'undefined' && process.versions?.node) { try { // Dynamic imports to avoid bundler issues const { readFileSync } = await import('node:fs'); const { join } = await import('node:path'); // Try to load from node_modules using require.resolve let wasmPath; try { // Try require.resolve first (works in both CJS and ESM with createRequire) wasmPath = require.resolve('jpeg-encoder-wasm/pkg/esm/jpeg_encoder_bg.wasm'); } catch { // Fallback: use cwd-relative path for ESM wasmPath = join(process.cwd(), 'node_modules', 'jpeg-encoder-wasm', 'pkg', 'esm', 'jpeg_encoder_bg.wasm'); } const wasmBuffer = readFileSync(wasmPath); await jpegEncoderInit(wasmBuffer); } catch (err) { // Final fallback: let the module find the wasm itself // This works in browser and some Node environments await jpegEncoderInit(); } } else { // In browser, the module will fetch the wasm automatically await jpegEncoderInit(); } wasmInitialized = true; } /** * Streaming JPEG encoder that yields byte chunks * * Takes RGBA scanlines (8-bit per channel) and yields JPEG data chunks. * Scanlines must be provided in 8-line MCU (Minimum Coded Unit) strips. * * @example * ```ts * const encoder = new JpegEncoder({ width: 800, height: 600, quality: 85 }); * * // Yield header * for await (const chunk of encoder.header()) { * process.stdout.write(chunk); * } * * // Yield scanline data (in 8-line strips) * for (let y = 0; y < 600; y += 8) { * const strip = new Uint8Array(800 * 8 * 4); // width * 8 lines * 4 channels * // ... fill strip with RGBA data ... * for await (const chunk of encoder.encodeStrip(strip)) { * process.stdout.write(chunk); * } * } * * // Yield footer * for await (const chunk of encoder.finish()) { * process.stdout.write(chunk); * } * ``` */ export class JpegEncoder { width; height; quality; encoder = null; initialized = false; constructor(options) { this.width = options.width; this.height = options.height; this.quality = options.quality ?? 85; // Validate parameters if (this.width <= 0 || this.height <= 0) { throw new Error(`Invalid dimensions: ${this.width}x${this.height}`); } if (this.quality < 1 || this.quality > 100) { throw new Error(`Invalid quality: ${this.quality} (must be 1-100)`); } } /** * Initialize the encoder * * NOTE: The WASM encoder includes the JPEG header in the first encode_strip() call, * so we don't call header_bytes() to avoid duplicate SOI markers. */ async *header() { if (this.initialized) { throw new Error('Encoder already initialized'); } await initWasm(); // Create the encoder instance this.encoder = new StreamingJpegEncoder(this.width, this.height, WasmColorType.Rgba, this.quality); // Don't yield header_bytes() - the first encode_strip() call includes the header this.initialized = true; // Return empty generator (no bytes to yield) return; } /** * Encode a strip of scanlines (handles 1-8 lines) * * @param strip - RGBA data for scanlines (width * lines * 4 bytes, lines ≤ 8) * @param _lastScanline - Unused (kept for API compatibility) * @returns Async generator yielding JPEG data chunks * * Note: The WASM encoder handles partial strips internally and will pad * using edge pixel repetition to avoid white blending artifacts. */ async *encodeStrip(strip, _lastScanline = null) { if (!this.initialized || !this.encoder) { throw new Error('Encoder not initialized. Call header() first.'); } // Pass the strip directly to the WASM encoder // It handles partial strips and pads using edge pixel repetition const encoded = this.encoder.encode_strip(strip); if (encoded.length > 0) { yield encoded; } } /** * Finalize the encoding and yield any remaining data * * NOTE: The WASM encoder's finish() method already includes the EOI marker, * so we don't call footer_bytes() to avoid duplicate EOI markers. */ async *finish() { if (!this.initialized || !this.encoder) { throw new Error('Encoder not initialized. Call header() first.'); } // Get remaining buffered data (includes EOI marker) const finalData = this.encoder.finish(); if (finalData.length > 0) { yield finalData; } // Don't call footer_bytes() - finish() already includes EOI marker // Clean up this.encoder.free(); this.encoder = null; this.initialized = false; } /** * Convenience method to encode all data at once * * @param data - Full RGBA image data (width * height * 4 bytes) * @returns Promise resolving to complete JPEG file as Uint8Array */ async encodeToBuffer(data) { const chunks = []; let totalLength = 0; // Header for await (const chunk of this.header()) { chunks.push(chunk); totalLength += chunk.length; } // Encode in 8-line strips const stripHeight = 8; const scanlineSize = this.width * 4; for (let y = 0; y < this.height; y += stripHeight) { const remainingLines = Math.min(stripHeight, this.height - y); const offset = y * this.width * 4; const size = this.width * remainingLines * 4; const strip = data.subarray(offset, offset + size); // Extract last scanline from this strip for edge pixel repetition const lastScanlineOffset = offset + (remainingLines - 1) * scanlineSize; const lastScanline = data.subarray(lastScanlineOffset, lastScanlineOffset + scanlineSize); for await (const chunk of this.encodeStrip(strip, lastScanline)) { chunks.push(chunk); totalLength += chunk.length; } } // Footer for await (const chunk of this.finish()) { chunks.push(chunk); totalLength += chunk.length; } // Combine all chunks const result = new Uint8Array(totalLength); let offset = 0; for (const chunk of chunks) { result.set(chunk, offset); offset += chunk.length; } return result; } } /** * Convenience function to encode RGBA data to JPEG * * @param data - RGBA image data (width * height * 4 bytes) * @param width - Image width in pixels * @param height - Image height in pixels * @param quality - JPEG quality (1-100, default: 85) * @returns Promise resolving to complete JPEG file as Uint8Array */ export async function encodeJpeg(data, width, height, quality = 85) { const encoder = new JpegEncoder({ width, height, quality }); return encoder.encodeToBuffer(data); } //# sourceMappingURL=jpeg-encoder.js.map