UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

569 lines (568 loc) 21.2 kB
"use strict"; // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); /* eslint-enable @typescript-eslint/no-explicit-any */ /** * ImageCodec - Unified cross-platform image encoding/decoding utilities * * ARCHITECTURE NOTES: * ------------------ * This module consolidates all image encoding/decoding logic that was previously * scattered across multiple files: * * - TGA decoding: Previously in TextureDefinition, Model2DRenderer, project.worker, * ImageManager, ModelMeshFactory * - PNG decoding: Previously in Model2DRenderer, TextureDefinition, project.worker * - PNG encoding: Previously in ImageGenerationUtilities * * PLATFORM SUPPORT: * ----------------- * This module supports three environments: * * 1. **Node.js** (CLI, server, VS Code extension host): * - Uses pngjs for fast synchronous PNG encoding/decoding * - Uses zlib for compression * - Uses Buffer for binary data * - Import: `import ImageCodec from "../core/ImageCodec"` * * 2. **Browser main thread** (web app, Electron renderer): * - Uses Canvas/HTMLImageElement for PNG decoding * - Uses createImageBitmap + canvas.toDataURL for encoding * - Falls back to Pako for zlib if available * * 3. **Web Worker** (project.worker.ts): * - Uses OffscreenCanvas + createImageBitmap * - No DOM access * * ENVIRONMENT DETECTION: * ---------------------- * The module auto-detects the environment using: * - `typeof Buffer !== "undefined"` for Node.js * - `typeof createImageBitmap !== "undefined"` for browser/worker * - `typeof OffscreenCanvas !== "undefined"` for web worker * - `typeof document !== "undefined"` for browser main thread * * For explicit control, use CreatorToolsHost.isNodeJs. * * USAGE: * ------ * ```typescript * import ImageCodec, { IDecodedImage } from "../core/ImageCodec"; * * // Decode any image type (auto-detects format) * const decoded = await ImageCodec.decodeAuto(data); * * // Decode specific format * const png = await ImageCodec.decodePng(data); * const tga = await ImageCodec.decodeTga(data); * * // Encode to PNG * const pngBytes = await ImageCodec.encodeToPng(pixels, width, height); * * // Convert TGA to PNG * const pngBytes = await ImageCodec.tgaToPng(tgaData); * * // Check format * if (ImageCodec.isTgaData(data)) { ... } * * // Get data URL * const dataUrl = ImageCodec.toDataUrl(pngBytes, "image/png"); * ``` * * NODE.JS ONLY USAGE: * ------------------- * For Node.js-only code (tests, CLI), you can import ImageCodecNode directly * for synchronous operations: * * ```typescript * import ImageCodecNode from "../local/ImageCodecNode"; * const decoded = ImageCodecNode.decodePng(data); // synchronous * ``` * * Related files: * - ImageCodecNode.ts - Node.js-specific implementation (pngjs, zlib) * - ImageGenerationUtilities.ts - Higher-level image generation (SVG→PNG, atlas) * - TextureDefinition.ts - Minecraft texture file wrapper * - Model2DRenderer.ts - 2D model rendering * - ModelMeshFactory.ts - 3D mesh creation */ const Log_1 = __importDefault(require("./Log")); const Utilities_1 = __importDefault(require("./Utilities")); const tga_codec_1 = require("@lunapaint/tga-codec"); const CreatorToolsHost_1 = __importDefault(require("../app/CreatorToolsHost")); /** * Check if we're in a browser environment with canvas support. */ function isBrowserEnvironment() { return typeof createImageBitmap !== "undefined" || typeof document !== "undefined"; } /** * Check if we're in a web worker (OffscreenCanvas available, no document). */ function isWebWorkerEnvironment() { return typeof OffscreenCanvas !== "undefined" && typeof document === "undefined"; } /** * Unified image encoding/decoding utilities. * * This is the main entry point for all image operations. * It automatically selects the best implementation for the current environment. */ class ImageCodec { // Cached CRC32 table for PNG encoding (browser fallback) static _crc32Table; // ============================================================================ // TGA DECODING // ============================================================================ /** * Decode TGA image data to RGBA pixels. * Works in all environments (Node.js, browser, web worker). * * Uses @lunapaint/tga-codec which handles: * - Uncompressed true-color (type 2) * - Uncompressed grayscale (type 3) * - RLE compressed (types 9, 10, 11) * - Various bit depths (8, 16, 24, 32) * * @param data Raw TGA file bytes * @returns Decoded image with RGBA pixels, or undefined if decoding fails */ static async decodeTga(data) { try { // Use the statically imported decodeTga function // This ensures the codec is bundled and works in web workers const decoded = await (0, tga_codec_1.decodeTga)(data); return { width: decoded.image.width, height: decoded.image.height, pixels: new Uint8Array(decoded.image.data), }; } catch (e) { Log_1.default.debug(`TGA decode failed: ${e}`); return undefined; } } // ============================================================================ // PNG DECODING // ============================================================================ /** * Decode PNG image data to RGBA pixels. * Automatically uses the best decoder for the current environment. * * - Node.js: Uses pngjs (synchronous, fast) * - Browser: Uses createImageBitmap + Canvas (async) * - Web Worker: Uses createImageBitmap + OffscreenCanvas (async) * * @param data Raw PNG file bytes * @returns Decoded image with RGBA pixels, or undefined if decoding fails */ static async decodePng(data) { // Try Node.js decoder first via platform thunk (faster, synchronous) if (CreatorToolsHost_1.default.decodePng) { try { const result = CreatorToolsHost_1.default.decodePng(data); if (result) return result; } catch (e) { Log_1.default.debug(`Node PNG decode failed, falling back to browser: ${e}`); } } // Fall back to browser decoder return this.decodePngBrowser(data); } /** * Decode PNG using browser APIs (createImageBitmap + Canvas). * Works in browser main thread and web workers. * * @param data Raw PNG file bytes * @returns Decoded image, or undefined if not in browser or decoding fails */ static async decodePngBrowser(data) { if (typeof createImageBitmap === "undefined") { return undefined; } try { const blob = new Blob([data], { type: "image/png" }); const imageBitmap = await createImageBitmap(blob); let canvas; let ctx; if (isWebWorkerEnvironment()) { canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); ctx = canvas.getContext("2d"); } else if (typeof document !== "undefined") { canvas = document.createElement("canvas"); canvas.width = imageBitmap.width; canvas.height = imageBitmap.height; ctx = canvas.getContext("2d"); } else { return undefined; } if (!ctx) return undefined; ctx.drawImage(imageBitmap, 0, 0); const imageData = ctx.getImageData(0, 0, imageBitmap.width, imageBitmap.height); return { width: imageBitmap.width, height: imageBitmap.height, pixels: new Uint8Array(imageData.data), }; } catch (e) { Log_1.default.debug(`Browser PNG decode failed: ${e}`); return undefined; } } // ============================================================================ // JPEG DECODING // ============================================================================ /** * Decode JPEG image data to RGBA pixels. * Only works in browser environments (uses createImageBitmap). * * @param data Raw JPEG file bytes * @returns Decoded image with RGBA pixels, or undefined if decoding fails */ static async decodeJpeg(data) { if (!isBrowserEnvironment()) { Log_1.default.debug("JPEG decoding requires browser environment"); return undefined; } try { const blob = new Blob([data], { type: "image/jpeg" }); if (typeof createImageBitmap === "undefined") { return undefined; } const imageBitmap = await createImageBitmap(blob); let canvas; let ctx; if (isWebWorkerEnvironment()) { canvas = new OffscreenCanvas(imageBitmap.width, imageBitmap.height); ctx = canvas.getContext("2d"); } else if (typeof document !== "undefined") { canvas = document.createElement("canvas"); canvas.width = imageBitmap.width; canvas.height = imageBitmap.height; ctx = canvas.getContext("2d"); } else { return undefined; } if (!ctx) return undefined; ctx.drawImage(imageBitmap, 0, 0); const imageData = ctx.getImageData(0, 0, imageBitmap.width, imageBitmap.height); return { width: imageBitmap.width, height: imageBitmap.height, pixels: new Uint8Array(imageData.data), }; } catch (e) { Log_1.default.debug(`JPEG decode failed: ${e}`); return undefined; } } // ============================================================================ // UNIFIED DECODING // ============================================================================ /** * Decode image data to RGBA pixels based on specified format. * * @param data Raw image file bytes * @param format File format ("png", "tga", "jpg", "jpeg") * @returns Decoded image, or undefined if decoding fails */ static async decode(data, format) { const normalizedFormat = format.toLowerCase().replace(".", ""); switch (normalizedFormat) { case "tga": return this.decodeTga(data); case "png": return this.decodePng(data); case "jpg": case "jpeg": return this.decodeJpeg(data); default: Log_1.default.debug(`Unsupported image format: ${format}`); return undefined; } } /** * Decode image data, auto-detecting format from file header. * * @param data Raw image file bytes * @returns Decoded image, or undefined if format unknown or decoding fails */ static async decodeAuto(data) { const format = this.detectFormat(data); if (!format) { Log_1.default.debug("Could not detect image format from file header"); return undefined; } return this.decode(data, format); } // ============================================================================ // PNG ENCODING // ============================================================================ /** * Encode RGBA pixel data to PNG format. * Automatically uses the best encoder for the current environment. * * - Node.js: Uses pngjs (synchronous, optimized) * - Browser: Uses canvas.toBlob (async) * * @param pixels RGBA pixel data (4 bytes per pixel) * @param width Image width in pixels * @param height Image height in pixels * @returns PNG file bytes, or undefined if encoding fails */ static async encodeToPng(pixels, width, height) { // Try Node.js encoder first via platform thunk if (CreatorToolsHost_1.default.encodeToPng) { try { const result = CreatorToolsHost_1.default.encodeToPng(pixels, width, height); if (result) return result; } catch (e) { Log_1.default.debug(`Node PNG encode failed, falling back to browser: ${e}`); } } // Fall back to browser encoder return this.encodeToPngBrowser(pixels, width, height); } /** * Synchronous PNG encoding (Node.js only). * Use this when you need synchronous behavior and know you're in Node.js. * * @param pixels RGBA pixel data (4 bytes per pixel) * @param width Image width in pixels * @param height Image height in pixels * @returns PNG file bytes, or undefined if not in Node.js or encoding fails */ static encodeToPngSync(pixels, width, height) { // Use platform thunk if available (Node.js environments) if (CreatorToolsHost_1.default.encodeToPng) { try { return CreatorToolsHost_1.default.encodeToPng(pixels, width, height); } catch (e) { Log_1.default.debug(`Sync PNG encode failed: ${e}`); return undefined; } } // Not available in browser environments return undefined; } /** * Encode RGBA pixels to PNG using browser Canvas API. * * @param pixels RGBA pixel data (4 bytes per pixel) * @param width Image width in pixels * @param height Image height in pixels * @returns PNG file bytes, or undefined if not in browser or encoding fails */ static async encodeToPngBrowser(pixels, width, height) { try { let offscreenCanvas; let htmlCanvas; let ctx; if (isWebWorkerEnvironment()) { offscreenCanvas = new OffscreenCanvas(width, height); ctx = offscreenCanvas.getContext("2d"); } else if (typeof document !== "undefined") { htmlCanvas = document.createElement("canvas"); htmlCanvas.width = width; htmlCanvas.height = height; ctx = htmlCanvas.getContext("2d"); } else { return undefined; } if (!ctx) return undefined; const imageData = ctx.createImageData(width, height); imageData.data.set(pixels); ctx.putImageData(imageData, 0, 0); // Get PNG data if (offscreenCanvas) { const blob = await offscreenCanvas.convertToBlob({ type: "image/png" }); const arrayBuffer = await blob.arrayBuffer(); return new Uint8Array(arrayBuffer); } else if (htmlCanvas) { return new Promise((resolve) => { htmlCanvas.toBlob(async (blob) => { if (!blob) { resolve(undefined); return; } const arrayBuffer = await blob.arrayBuffer(); resolve(new Uint8Array(arrayBuffer)); }, "image/png", 1.0); }); } return undefined; } catch (e) { Log_1.default.debug(`Browser PNG encode failed: ${e}`); return undefined; } } // ============================================================================ // FORMAT DETECTION // ============================================================================ /** * Check if data is a PNG file (magic number: 0x89 0x50 0x4E 0x47). */ static isPngData(data) { return data.length >= 4 && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47; } /** * Check if data is a JPEG file (magic number: 0xFF 0xD8 0xFF). */ static isJpegData(data) { return data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff; } /** * Check if data is a TGA file. * * TGA has no magic number, so we check: * 1. It's NOT PNG or JPEG (which have magic numbers) * 2. Byte 2 (image type) is a valid TGA type * * Valid TGA types: * - 1: Uncompressed color-mapped * - 2: Uncompressed true-color * - 3: Uncompressed grayscale * - 9: RLE color-mapped * - 10: RLE true-color * - 11: RLE grayscale */ static isTgaData(data) { if (data.length < 18) return false; if (this.isPngData(data) || this.isJpegData(data)) return false; const imageType = data[2]; return [1, 2, 3, 9, 10, 11].includes(imageType); } /** * Detect image format from file header bytes. * * @param data Raw file bytes * @returns Detected format, or undefined if unknown */ static detectFormat(data) { if (this.isPngData(data)) return "png"; if (this.isJpegData(data)) return "jpg"; if (this.isTgaData(data)) return "tga"; return undefined; } // ============================================================================ // DATA URL CONVERSION // ============================================================================ /** * Convert raw image bytes to a data URL. * * @param data Image file bytes (PNG, JPEG, etc.) * @param mimeType MIME type (e.g., "image/png") * @returns Data URL string (data:image/png;base64,...) */ static toDataUrl(data, mimeType) { const base64 = Utilities_1.default.uint8ArrayToBase64(data); return `data:${mimeType};base64,${base64}`; } /** * Convert decoded image to PNG data URL. * * @param image Decoded image with RGBA pixels * @returns PNG data URL, or undefined if encoding fails */ static async toPngDataUrl(image) { const pngData = await this.encodeToPng(image.pixels, image.width, image.height); if (!pngData) return undefined; return this.toDataUrl(pngData, "image/png"); } // ============================================================================ // TGA TO PNG CONVERSION // ============================================================================ /** * Convert TGA data directly to PNG data. * * @param tgaData Raw TGA file bytes * @returns PNG file bytes, or undefined if conversion fails */ static async tgaToPng(tgaData) { const decoded = await this.decodeTga(tgaData); if (!decoded) return undefined; return this.encodeToPng(decoded.pixels, decoded.width, decoded.height); } /** * Convert TGA data to PNG data URL. * * @param tgaData Raw TGA file bytes * @returns PNG data URL (data:image/png;base64,...), or undefined if fails */ static async tgaToPngDataUrl(tgaData) { const pngData = await this.tgaToPng(tgaData); if (!pngData) return undefined; return this.toDataUrl(pngData, "image/png"); } // ============================================================================ // PIXEL MANIPULATION UTILITIES // ============================================================================ /** * Convert BGRA pixel data to RGBA. * TGA files often store pixels in BGRA format. * * @param pixels BGRA pixel data (modified in place) * @returns The same array with R and B swapped */ static bgraToRgba(pixels) { for (let i = 0; i < pixels.length; i += 4) { const b = pixels[i]; pixels[i] = pixels[i + 2]; // R = B pixels[i + 2] = b; // B = R } return pixels; } /** * Create a solid color image. * * @param width Image width * @param height Image height * @param r Red component (0-255) * @param g Green component (0-255) * @param b Blue component (0-255) * @param a Alpha component (0-255, default 255) * @returns Decoded image with solid color */ static createSolidColor(width, height, r, g, b, a = 255) { const pixels = new Uint8Array(width * height * 4); for (let i = 0; i < pixels.length; i += 4) { pixels[i] = r; pixels[i + 1] = g; pixels[i + 2] = b; pixels[i + 3] = a; } return { width, height, pixels }; } } exports.default = ImageCodec;