@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
569 lines (568 loc) • 21.2 kB
JavaScript
;
// 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;