@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
1,036 lines (1,021 loc) • 63.1 kB
JavaScript
"use strict";
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
var desc = Object.getOwnPropertyDescriptor(m, k);
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
desc = { enumerable: true, get: function() { return m[k]; } };
}
Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
if (k2 === undefined) k2 = k;
o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
var ownKeys = function(o) {
ownKeys = Object.getOwnPropertyNames || function (o) {
var ar = [];
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
return ar;
};
return ownKeys(o);
};
return function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
__setModuleDefault(result, mod);
return result;
};
})();
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
Object.defineProperty(exports, "__esModule", { value: true });
const Log_1 = __importDefault(require("../core/Log"));
const ImageCodec_1 = __importDefault(require("../core/ImageCodec"));
const ModelDesignUtilities_1 = __importDefault(require("../minecraft/ModelDesignUtilities"));
const TexturedRectangleGenerator_1 = __importDefault(require("../minecraft/TexturedRectangleGenerator"));
const TextureEffects_1 = require("../minecraft/TextureEffects");
/**
* Static utility class for PNG generation and image manipulation.
* Provides methods for encoding RGBA pixel data to PNG, rendering SVG to PNG,
* generating textures from atlas regions, and stitching images together.
*/
class ImageGenerationUtilities {
static _crc32Table;
// Cached browser instance for rendering - avoids launching a new browser each time
// Note: Only the browser is cached, contexts are created fresh for each operation
static _cachedBrowser = null;
/**
* Encode RGBA pixel data to PNG format.
* Delegates to ImageCodec for cross-platform encoding.
*
* @param pixels RGBA pixel data (4 bytes per pixel)
* @param width Image width in pixels
* @param height Image height in pixels
* @returns PNG data as Uint8Array, or undefined on error
*/
static encodeRgbaToPng(pixels, width, height) {
return ImageCodec_1.default.encodeToPngSync(pixels, width, height);
}
/**
* Create a PNG chunk with type, data, and CRC.
*
* @param type 4-character chunk type (e.g., "IHDR", "IDAT", "IEND")
* @param data Chunk data
* @returns Complete chunk including length, type, data, and CRC
*/
static createPngChunk(type, data) {
const length = data.length;
const chunk = new Uint8Array(4 + 4 + length + 4);
const view = new DataView(chunk.buffer);
// Length
view.setUint32(0, length, false);
// Type
for (let i = 0; i < 4; i++) {
chunk[4 + i] = type.charCodeAt(i);
}
// Data
chunk.set(data, 8);
// CRC32 of type + data
const crc = ImageGenerationUtilities.crc32(chunk.subarray(4, 8 + length));
view.setUint32(8 + length, crc, false);
return chunk;
}
/**
* Calculate CRC32 checksum for PNG chunk validation.
*
* @param data Data to calculate CRC32 for
* @returns CRC32 checksum as unsigned 32-bit integer
*/
static crc32(data) {
let crc = 0xffffffff;
const table = ImageGenerationUtilities.getCrc32Table();
for (let i = 0; i < data.length; i++) {
crc = table[(crc ^ data[i]) & 0xff] ^ (crc >>> 8);
}
return (crc ^ 0xffffffff) >>> 0;
}
/**
* Get the CRC32 lookup table, generating it on first call.
*
* @returns CRC32 lookup table
*/
static getCrc32Table() {
if (ImageGenerationUtilities._crc32Table) {
return ImageGenerationUtilities._crc32Table;
}
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let j = 0; j < 8; j++) {
c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
}
table[i] = c;
}
ImageGenerationUtilities._crc32Table = table;
return table;
}
/**
* Render an SVG string to a PNG data URL using resvg-js.
* This enables SVG support including gradients, shapes, and complex graphics.
*
* @param svgContent SVG content as a string
* @param width Output image width in pixels
* @param height Output image height in pixels
* @returns PNG as data URL, or undefined if rendering fails
*/
static async renderSvgToDataUrl(svgContent, width, height) {
try {
// Use resvg for fast, reliable SVG to PNG conversion without browser dependency
const { Resvg } = await Promise.resolve().then(() => __importStar(require("@resvg/resvg-js")));
// Ensure SVG has proper dimensions set
// If the SVG doesn't have width/height attributes, we need to add them
let processedSvg = svgContent;
if (!svgContent.includes('width="') && !svgContent.includes("width='")) {
// Add width and height to the SVG element
processedSvg = svgContent.replace("<svg", `<svg width="${width}" height="${height}"`);
}
const resvg = new Resvg(processedSvg, {
fitTo: {
mode: "width",
value: width,
},
background: "transparent",
});
const pngData = resvg.render();
const pngBuffer = pngData.asPng();
// Convert buffer to data URL
const base64 = pngBuffer.toString("base64");
return `data:image/png;base64,${base64}`;
}
catch (e) {
Log_1.default.debugAlert(`renderSvgToDataUrl failed: ${e}`);
return undefined;
}
}
/**
* Close the cached browser instance.
* Call this when done with rendering to clean up resources.
*/
static async closeCachedBrowser() {
if (ImageGenerationUtilities._cachedBrowser) {
try {
await ImageGenerationUtilities._cachedBrowser.close();
}
catch {
// Ignore close errors
}
ImageGenerationUtilities._cachedBrowser = null;
}
}
/**
* Ensure the cached browser is available.
* Initializes the browser if not already running.
* If the browser has disconnected, creates a new one.
*/
static async ensureCachedBrowser() {
// Check if the cached browser is still connected
if (ImageGenerationUtilities._cachedBrowser) {
try {
// Check if browser is still connected by checking isConnected()
if (!ImageGenerationUtilities._cachedBrowser.isConnected()) {
Log_1.default.debug("Cached browser disconnected, will create new one");
ImageGenerationUtilities._cachedBrowser = null;
}
}
catch {
// If we can't check connection status, assume it's invalid
Log_1.default.debug("Cached browser in invalid state, will create new one");
ImageGenerationUtilities._cachedBrowser = null;
}
}
if (!ImageGenerationUtilities._cachedBrowser) {
// Set environment variable to skip font loading wait during screenshots
// This prevents timeout issues when fonts fail to load
process.env["PW_TEST_SCREENSHOT_NO_FONTS_READY"] = "1";
const playwright = await Promise.resolve().then(() => __importStar(require("playwright")));
// Try to find a working browser
const browserConfigs = [
{ name: "System Chrome", options: { channel: "chrome", headless: true } },
{ name: "System Edge", options: { channel: "msedge", headless: true } },
{ name: "Playwright Chromium", options: { headless: true } },
];
for (const config of browserConfigs) {
try {
ImageGenerationUtilities._cachedBrowser = await playwright.chromium.launch(config.options);
Log_1.default.debug(`ImageGenerationUtilities: Launched browser using ${config.name}`);
break;
}
catch {
continue;
}
}
}
}
/**
* Generate a PNG texture from atlas regions.
* Uses Playwright to render SVG content, falls back to simple color fill for color-only regions.
*
* @param atlasRegions Array of atlas regions with position, size, and content
* @param textureSize Texture dimensions as [width, height]
* @param pixelsPerUnit Pixels per Minecraft unit for pixel art scaling. Default: 2
* @returns PNG as data URL, or undefined if generation fails
*/
static async generateTextureFromAtlas(atlasRegions, textureSize, pixelsPerUnit = 2) {
const [width, height] = textureSize;
// Check if any region has SVG content (not noise) - if so, use Playwright rendering
// Noise textures are now rendered directly with pixel data for better performance
const hasSvgContent = atlasRegions.some((region) => region.content.svg);
if (hasSvgContent) {
// Generate the full SVG atlas and render it with Playwright
const atlasSvg = ModelDesignUtilities_1.default.generateAtlasSvg(atlasRegions, textureSize);
const rendered = await ImageGenerationUtilities.renderSvgToDataUrl(atlasSvg, width, height);
if (rendered) {
return rendered;
}
// Fall back to simple rendering if Playwright fails
Log_1.default.debug("SVG rendering failed, falling back to simple color rendering");
}
// Pixel-based rendering - handles colors and noise directly without Playwright
const pixels = new Uint8Array(width * height * 4);
// Initialize with transparent black
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 0; // R
pixels[i + 1] = 0; // G
pixels[i + 2] = 0; // B
pixels[i + 3] = 0; // A (transparent)
}
// Fill in each atlas region
for (const region of atlasRegions) {
// Prefer background (new unified format), then fall back to noise/color (legacy)
if (region.content.background) {
// Generate textured rectangle directly as pixels
const bgPixels = TexturedRectangleGenerator_1.default.generatePixels(region.content.background, region.width, region.height, `region-${region.x}-${region.y}`);
// Copy pixels to atlas at the correct position
for (let dy = 0; dy < region.height && region.y + dy < height; dy++) {
for (let dx = 0; dx < region.width && region.x + dx < width; dx++) {
const srcIdx = (dy * region.width + dx) * 4;
const dstIdx = ((region.y + dy) * width + (region.x + dx)) * 4;
pixels[dstIdx] = bgPixels[srcIdx];
pixels[dstIdx + 1] = bgPixels[srcIdx + 1];
pixels[dstIdx + 2] = bgPixels[srcIdx + 2];
pixels[dstIdx + 3] = bgPixels[srcIdx + 3];
}
}
}
else if (region.content.noise) {
// Legacy: Generate noise texture directly as pixels - no SVG/Playwright needed
const noisePixels = TexturedRectangleGenerator_1.default.generateNoisePixels(region.content.noise, region.width, region.height, `region-${region.x}-${region.y}`);
// Copy noise pixels to atlas at the correct position
for (let dy = 0; dy < region.height && region.y + dy < height; dy++) {
for (let dx = 0; dx < region.width && region.x + dx < width; dx++) {
const srcIdx = (dy * region.width + dx) * 4;
const dstIdx = ((region.y + dy) * width + (region.x + dx)) * 4;
pixels[dstIdx] = noisePixels[srcIdx];
pixels[dstIdx + 1] = noisePixels[srcIdx + 1];
pixels[dstIdx + 2] = noisePixels[srcIdx + 2];
pixels[dstIdx + 3] = noisePixels[srcIdx + 3];
}
}
}
else if (region.content.color) {
// Fill with solid color
const parsed = ModelDesignUtilities_1.default.parseColor(region.content.color);
const color = { r: parsed.r, g: parsed.g, b: parsed.b, a: parsed.a ?? 255 };
for (let y = region.y; y < region.y + region.height && y < height; y++) {
for (let x = region.x; x < region.x + region.width && x < width; x++) {
const idx = (y * width + x) * 4;
pixels[idx] = color.r;
pixels[idx + 1] = color.g;
pixels[idx + 2] = color.b;
pixels[idx + 3] = color.a;
}
}
}
else if (region.content.svg) {
// If we reach here with SVG content, Playwright rendering failed
// Use a placeholder magenta color to make it obvious
for (let y = region.y; y < region.y + region.height && y < height; y++) {
for (let x = region.x; x < region.x + region.width && x < width; x++) {
const idx = (y * width + x) * 4;
pixels[idx] = 255; // R (magenta)
pixels[idx + 1] = 0; // G
pixels[idx + 2] = 255; // B
pixels[idx + 3] = 255; // A
}
}
}
// Apply pixel art on top of background (if present)
// This is done separately so pixel art can overlay any background type
if (region.content.pixelArt && region.content.pixelArt.length > 0) {
// Create a temporary buffer for this region
const regionPixels = new Uint8Array(region.width * region.height * 4);
// Copy current region content to temp buffer
for (let dy = 0; dy < region.height && region.y + dy < height; dy++) {
for (let dx = 0; dx < region.width && region.x + dx < width; dx++) {
const srcIdx = ((region.y + dy) * width + (region.x + dx)) * 4;
const dstIdx = (dy * region.width + dx) * 4;
regionPixels[dstIdx] = pixels[srcIdx];
regionPixels[dstIdx + 1] = pixels[srcIdx + 1];
regionPixels[dstIdx + 2] = pixels[srcIdx + 2];
regionPixels[dstIdx + 3] = pixels[srcIdx + 3];
}
}
// Apply pixel art layers
TexturedRectangleGenerator_1.default.applyPixelArtLayers(regionPixels, region.width, region.height, region.content.pixelArt, pixelsPerUnit);
// Copy back to atlas
for (let dy = 0; dy < region.height && region.y + dy < height; dy++) {
for (let dx = 0; dx < region.width && region.x + dx < width; dx++) {
const srcIdx = (dy * region.width + dx) * 4;
const dstIdx = ((region.y + dy) * width + (region.x + dx)) * 4;
pixels[dstIdx] = regionPixels[srcIdx];
pixels[dstIdx + 1] = regionPixels[srcIdx + 1];
pixels[dstIdx + 2] = regionPixels[srcIdx + 2];
pixels[dstIdx + 3] = regionPixels[srcIdx + 3];
}
}
}
// Apply post-processing effects (lighting, borders, overlays, etc.)
// This is done after pixel art so effects can modify the complete texture
if (region.content.effects) {
// Create a temporary buffer for this region
const regionPixels = new Uint8Array(region.width * region.height * 4);
// Copy current region content to temp buffer
for (let dy = 0; dy < region.height && region.y + dy < height; dy++) {
for (let dx = 0; dx < region.width && region.x + dx < width; dx++) {
const srcIdx = ((region.y + dy) * width + (region.x + dx)) * 4;
const dstIdx = (dy * region.width + dx) * 4;
regionPixels[dstIdx] = pixels[srcIdx];
regionPixels[dstIdx + 1] = pixels[srcIdx + 1];
regionPixels[dstIdx + 2] = pixels[srcIdx + 2];
regionPixels[dstIdx + 3] = pixels[srcIdx + 3];
}
}
// Apply effects to region
(0, TextureEffects_1.applyTextureEffects)(regionPixels, region.width, region.height, region.content.effects);
// Copy back to atlas
for (let dy = 0; dy < region.height && region.y + dy < height; dy++) {
for (let dx = 0; dx < region.width && region.x + dx < width; dx++) {
const srcIdx = (dy * region.width + dx) * 4;
const dstIdx = ((region.y + dy) * width + (region.x + dx)) * 4;
pixels[dstIdx] = regionPixels[srcIdx];
pixels[dstIdx + 1] = regionPixels[srcIdx + 1];
pixels[dstIdx + 2] = regionPixels[srcIdx + 2];
pixels[dstIdx + 3] = regionPixels[srcIdx + 3];
}
}
}
}
// Encode as PNG using a minimal PNG encoder
const pngData = ImageGenerationUtilities.encodeRgbaToPng(pixels, width, height);
if (pngData) {
return `data:image/png;base64,${Buffer.from(pngData).toString("base64")}`;
}
return undefined;
}
/**
* Stitch multiple images together with labels in a grid layout.
* Uses Playwright to compose images and add text labels.
*
* @param images Array of images with labels and PNG data (may include isWide property for pyramid layout)
* @param singleWidth Width of each individual image (half-width for pyramid layout)
* @param singleHeight Height of each individual image
* @param cols Number of columns in grid (default: images.length for horizontal row)
* @param rows Number of rows in grid (default: 1 for horizontal row)
* @param layout Optional layout mode: "pyramid" for 2-on-top, 1-spanning-bottom layout
* @returns Stitched image as PNG data, or first image on failure
*/
static async stitchImagesWithLabels(images, singleWidth, singleHeight, cols, rows, layout) {
try {
// Use cached browser for performance
await ImageGenerationUtilities.ensureCachedBrowser();
if (!ImageGenerationUtilities._cachedBrowser) {
Log_1.default.debug("No browser available for image stitching");
return images[0]?.imageData; // Fall back to first image
}
// Use provided grid dimensions or default to horizontal row
const gridCols = cols ?? images.length;
const gridRows = rows ?? 1;
const isPyramidLayout = layout === "pyramid" && images.length === 3;
const labelHeight = 30;
const totalWidth = singleWidth * gridCols;
const totalHeight = (singleHeight + labelHeight) * gridRows;
// Create fresh context for reliability (browser is cached, contexts are cheap)
let context;
try {
context = await ImageGenerationUtilities._cachedBrowser.newContext({
viewport: { width: totalWidth, height: totalHeight },
});
const page = await context.newPage();
// Create HTML with canvas for stitching
const imageDataUrls = images.map((img) => {
const base64 = Buffer.from(img.imageData).toString("base64");
return `data:image/png;base64,${base64}`;
});
// Track which images are wide (for pyramid layout)
const isWideFlags = images.map((img) => img.isWide === true);
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; background: transparent; }
canvas { display: block; }
</style>
</head>
<body>
<canvas id="canvas" width="${totalWidth}" height="${totalHeight}"></canvas>
<script>
async function stitch() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
// Fill background
ctx.fillStyle = '#1a1a2e';
ctx.fillRect(0, 0, ${totalWidth}, ${totalHeight});
const imageUrls = ${JSON.stringify(imageDataUrls)};
const labels = ${JSON.stringify(images.map((i) => i.label))};
const isWideFlags = ${JSON.stringify(isWideFlags)};
const isPyramidLayout = ${isPyramidLayout};
const singleWidth = ${singleWidth};
const singleHeight = ${singleHeight};
const labelHeight = ${labelHeight};
const gridCols = ${gridCols};
const cellHeight = singleHeight + labelHeight;
const totalWidth = ${totalWidth};
// Load and draw each image
for (let i = 0; i < imageUrls.length; i++) {
const isWide = isWideFlags[i];
const imgWidth = isWide ? totalWidth : singleWidth;
// Calculate position based on layout
let xOffset, yOffset;
if (isPyramidLayout) {
if (i < 2) {
// First two images: top row, side by side
xOffset = i * singleWidth;
yOffset = 0;
} else {
// Third image: bottom row, spanning full width
xOffset = 0;
yOffset = cellHeight;
}
} else {
// Standard grid layout
const col = i % gridCols;
const row = Math.floor(i / gridCols);
xOffset = col * singleWidth;
yOffset = row * cellHeight;
}
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = imageUrls[i];
});
// Draw label background at top of cell
ctx.fillStyle = 'rgba(0, 0, 0, 0.7)';
ctx.fillRect(xOffset, yOffset, imgWidth, labelHeight);
// Draw label text
ctx.fillStyle = '#ffffff';
ctx.font = 'bold 16px Arial, sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(labels[i], xOffset + imgWidth / 2, yOffset + labelHeight / 2);
// Draw image (below label)
ctx.drawImage(img, xOffset, yOffset + labelHeight, imgWidth, singleHeight);
// Draw vertical separator line between columns (not for wide images)
if (!isWide && xOffset > 0) {
ctx.strokeStyle = '#444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(xOffset, yOffset);
ctx.lineTo(xOffset, yOffset + cellHeight);
ctx.stroke();
}
// Draw horizontal separator line between rows
if (yOffset > 0 && xOffset === 0) {
ctx.strokeStyle = '#444';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.moveTo(0, yOffset);
ctx.lineTo(${totalWidth}, yOffset);
ctx.stroke();
}
}
window.stitchComplete = true;
}
stitch().catch(e => { window.stitchError = e.message; });
</script>
</body>
</html>
`;
await page.setContent(html, { waitUntil: "domcontentloaded" });
// Wait for stitching to complete
// @ts-ignore
await page.waitForFunction(() => window.stitchComplete || window.stitchError, {
timeout: 5000,
});
// @ts-ignore
const error = await page.evaluate(() => window.stitchError);
if (error) {
Log_1.default.debug(`Image stitch error: ${error}`);
return images[0]?.imageData;
}
// Get the canvas as PNG - use page.screenshot with clip
// (locator.screenshot has stability issues)
const canvasBuffer = await page.screenshot({
type: "png",
clip: { x: 0, y: 0, width: totalWidth, height: totalHeight },
timeout: 5000,
});
return canvasBuffer;
}
finally {
// Always clean up the context
if (context) {
try {
await context.close();
}
catch {
// Ignore close errors
}
}
}
}
catch (e) {
Log_1.default.debug(`Image stitching failed: ${e}`);
return images[0]?.imageData; // Fall back to first image
}
}
/**
* Convert PNG image data to JPEG format with optional quality setting.
* Uses browser canvas for encoding.
*
* @param pngData PNG image data as Uint8Array
* @param quality JPEG quality 1-100 (default: 80)
* @returns JPEG image data as Uint8Array, or original PNG on failure
*/
static async convertPngToJpeg(pngData, quality = 80) {
try {
// Use cached browser for performance
await ImageGenerationUtilities.ensureCachedBrowser();
if (!ImageGenerationUtilities._cachedBrowser) {
Log_1.default.debug("No browser available for JPEG conversion");
return pngData; // Return original PNG
}
// Create a temporary page/context for conversion (small viewport is fine)
const tempContext = await ImageGenerationUtilities._cachedBrowser.newContext();
const page = await tempContext.newPage();
const base64Png = Buffer.from(pngData).toString("base64");
const dataUrl = `data:image/png;base64,${base64Png}`;
// Use browser canvas to convert PNG to JPEG
const html = `
<!DOCTYPE html>
<html>
<head><style>body { margin: 0; }</style></head>
<body>
<canvas id="canvas"></canvas>
<script>
async function convert() {
const img = new Image();
await new Promise((resolve, reject) => {
img.onload = resolve;
img.onerror = reject;
img.src = "${dataUrl}";
});
const canvas = document.getElementById('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
// Convert to JPEG
const jpegDataUrl = canvas.toDataURL('image/jpeg', ${quality / 100});
window.jpegData = jpegDataUrl;
window.convertComplete = true;
}
convert().catch(e => { window.convertError = e.message; });
</script>
</body>
</html>
`;
await page.setContent(html);
// Wait for conversion
// @ts-ignore
await page.waitForFunction(() => window.convertComplete || window.convertError, {
timeout: 5000,
});
// @ts-ignore
const error = await page.evaluate(() => window.convertError);
if (error) {
Log_1.default.debug(`JPEG conversion error: ${error}`);
await tempContext.close();
return pngData;
}
// @ts-ignore
const jpegDataUrl = await page.evaluate(() => window.jpegData);
await tempContext.close();
// Extract base64 data from data URL
const base64Match = jpegDataUrl.match(/^data:image\/jpeg;base64,(.*)$/);
if (base64Match) {
return new Uint8Array(Buffer.from(base64Match[1], "base64"));
}
return pngData; // Return original on failure
}
catch (e) {
Log_1.default.debug(`JPEG conversion failed: ${e}`);
return pngData; // Return original PNG on failure
}
}
/**
* Recompress a PNG image with maximum compression level (9).
* Decodes the PNG, then re-encodes with higher compression.
* This can significantly reduce file size for screenshots.
*
* @param pngData PNG image data as Uint8Array
* @returns Recompressed PNG data, or original on failure
*/
static recompressPng(pngData) {
try {
const zlib = require("zlib");
// Parse the PNG to extract IHDR and pixel data
const parsed = ImageGenerationUtilities.parsePng(pngData);
if (!parsed) {
return pngData; // Return original if parsing fails
}
const { width, height, pixels } = parsed;
// PNG signature
const signature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
// IHDR chunk
const ihdr = new Uint8Array(13);
const ihdrView = new DataView(ihdr.buffer);
ihdrView.setUint32(0, width, false);
ihdrView.setUint32(4, height, false);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type (RGBA)
ihdr[10] = 0; // compression
ihdr[11] = 0; // filter
ihdr[12] = 0; // interlace
const ihdrChunk = ImageGenerationUtilities.createPngChunk("IHDR", ihdr);
// Prepare raw data with filter bytes (use sub filter for potentially better compression)
const rawData = new Uint8Array(height * (1 + width * 4));
for (let y = 0; y < height; y++) {
rawData[y * (1 + width * 4)] = 0; // filter byte (none - simplest)
for (let x = 0; x < width; x++) {
const srcIdx = (y * width + x) * 4;
const dstIdx = y * (1 + width * 4) + 1 + x * 4;
rawData[dstIdx] = pixels[srcIdx]; // R
rawData[dstIdx + 1] = pixels[srcIdx + 1]; // G
rawData[dstIdx + 2] = pixels[srcIdx + 2]; // B
rawData[dstIdx + 3] = pixels[srcIdx + 3]; // A
}
}
// Compress with maximum level (9)
const compressed = zlib.deflateSync(rawData, { level: 9 });
const idatChunk = ImageGenerationUtilities.createPngChunk("IDAT", new Uint8Array(compressed));
// IEND chunk
const iendChunk = ImageGenerationUtilities.createPngChunk("IEND", new Uint8Array(0));
// Combine all chunks
const totalLength = signature.length + ihdrChunk.length + idatChunk.length + iendChunk.length;
const png = new Uint8Array(totalLength);
let offset = 0;
png.set(signature, offset);
offset += signature.length;
png.set(ihdrChunk, offset);
offset += ihdrChunk.length;
png.set(idatChunk, offset);
offset += idatChunk.length;
png.set(iendChunk, offset);
// Only return if smaller
if (png.length < pngData.length) {
return png;
}
return pngData;
}
catch (e) {
Log_1.default.debug(`PNG recompression failed: ${e}`);
return pngData; // Return original on failure
}
}
/**
* Parse a PNG file to extract width, height, and pixel data.
* Supports only 8-bit RGBA (color type 6) non-interlaced PNGs.
*
* @param pngData PNG file data
* @returns Parsed data or undefined on failure
*/
static parsePng(pngData) {
try {
const zlib = require("zlib");
// Check PNG signature
const signature = [137, 80, 78, 71, 13, 10, 26, 10];
for (let i = 0; i < 8; i++) {
if (pngData[i] !== signature[i]) {
return undefined; // Not a valid PNG
}
}
let offset = 8;
let width = 0;
let height = 0;
let bitDepth = 0;
let colorType = 0;
const idatChunks = [];
// Parse chunks
while (offset < pngData.length) {
const length = new DataView(pngData.buffer, pngData.byteOffset + offset, 4).getUint32(0, false);
const type = String.fromCharCode(pngData[offset + 4], pngData[offset + 5], pngData[offset + 6], pngData[offset + 7]);
if (type === "IHDR") {
const ihdrData = pngData.slice(offset + 8, offset + 8 + length);
const view = new DataView(ihdrData.buffer, ihdrData.byteOffset, ihdrData.length);
width = view.getUint32(0, false);
height = view.getUint32(4, false);
bitDepth = ihdrData[8];
colorType = ihdrData[9];
// Only support 8-bit RGBA for now
if (bitDepth !== 8 || colorType !== 6) {
return undefined;
}
}
else if (type === "IDAT") {
idatChunks.push(pngData.slice(offset + 8, offset + 8 + length));
}
else if (type === "IEND") {
break;
}
offset += 12 + length; // 4 (length) + 4 (type) + length + 4 (CRC)
}
if (width === 0 || height === 0 || idatChunks.length === 0) {
return undefined;
}
// Concatenate IDAT chunks and decompress
const totalIdatLength = idatChunks.reduce((sum, chunk) => sum + chunk.length, 0);
const combinedIdat = new Uint8Array(totalIdatLength);
let idatOffset = 0;
for (const chunk of idatChunks) {
combinedIdat.set(chunk, idatOffset);
idatOffset += chunk.length;
}
const decompressed = zlib.inflateSync(combinedIdat);
// Remove filter bytes and extract raw RGBA pixels
const pixels = new Uint8Array(width * height * 4);
const bytesPerRow = 1 + width * 4; // 1 filter byte + RGBA data
for (let y = 0; y < height; y++) {
const filterByte = decompressed[y * bytesPerRow];
const rowStart = y * bytesPerRow + 1;
const pixelRowStart = y * width * 4;
// Apply reverse filter
for (let x = 0; x < width * 4; x++) {
let value = decompressed[rowStart + x];
if (filterByte === 1) {
// Sub filter
const a = x >= 4 ? pixels[pixelRowStart + x - 4] : 0;
value = (value + a) & 0xff;
}
else if (filterByte === 2) {
// Up filter
const b = y > 0 ? pixels[pixelRowStart - width * 4 + x] : 0;
value = (value + b) & 0xff;
}
else if (filterByte === 3) {
// Average filter
const a = x >= 4 ? pixels[pixelRowStart + x - 4] : 0;
const b = y > 0 ? pixels[pixelRowStart - width * 4 + x] : 0;
value = (value + Math.floor((a + b) / 2)) & 0xff;
}
else if (filterByte === 4) {
// Paeth filter
const a = x >= 4 ? pixels[pixelRowStart + x - 4] : 0;
const b = y > 0 ? pixels[pixelRowStart - width * 4 + x] : 0;
const c = x >= 4 && y > 0 ? pixels[pixelRowStart - width * 4 + x - 4] : 0;
value = (value + ImageGenerationUtilities.paethPredictor(a, b, c)) & 0xff;
}
// filterByte === 0 means no filter, value stays as-is
pixels[pixelRowStart + x] = value;
}
}
return { width, height, pixels };
}
catch (e) {
Log_1.default.debug(`PNG parsing failed: ${e}`);
return undefined;
}
}
/**
* Paeth predictor function for PNG decompression.
*/
static paethPredictor(a, b, c) {
const p = a + b - c;
const pa = Math.abs(p - a);
const pb = Math.abs(p - b);
const pc = Math.abs(p - c);
if (pa <= pb && pa <= pc)
return a;
if (pb <= pc)
return b;
return c;
}
/**
* Extract texture swatches from a model design.
* Collects all named textures from the textures dictionary,
* plus unique solid colors used on faces.
*
* @param design The model design to extract swatches from
* @returns Array of texture swatches with labels and content
*/
static extractTextureSwatches(design) {
const swatches = [];
const seenColors = new Set();
// Extract from textures dictionary
if (design.textures) {
for (const [name, textureDef] of Object.entries(design.textures)) {
const swatch = { label: name };
if (textureDef.svg) {
swatch.svg = textureDef.svg;
}
else if (textureDef.background) {
// Generate SVG from textured rectangle configuration (new format)
const bgSvg = TexturedRectangleGenerator_1.default.generateTexturedRectangleSvg(textureDef.background, 48, 48, `swatch-${name}`);
swatch.svg = bgSvg;
}
else if (textureDef.noise) {
// Legacy: Generate SVG from noise configuration
const noiseSvg = TexturedRectangleGenerator_1.default.generateNoiseSvg(textureDef.noise, 48, 48, `swatch-${name}`);
swatch.svg = noiseSvg;
}
else if (textureDef.color) {
swatch.color = typeof textureDef.color === "string" ? textureDef.color : undefined;
if (typeof textureDef.color === "object") {
const c = textureDef.color;
swatch.color = `rgb(${c.r}, ${c.g}, ${c.b})`;
}
}
if (swatch.color || swatch.svg) {
swatches.push(swatch);
if (swatch.color) {
seenColors.add(swatch.color.toLowerCase());
}
}
}
}
// Extract unique solid colors from face definitions
if (design.bones) {
for (const bone of design.bones) {
if (bone.cubes) {
for (const cube of bone.cubes) {
if (cube.faces) {
const faceNames = ["north", "south", "east", "west", "up", "down"];
for (const faceName of faceNames) {
const face = cube.faces[faceName];
if (face && face.color && !face.textureId && !face.svg) {
let colorStr;
if (typeof face.color === "string") {
colorStr = face.color;
}
else if (typeof face.color === "object") {
const c = face.color;
colorStr = `rgb(${c.r}, ${c.g}, ${c.b})`;
}
else {
continue;
}
const colorKey = colorStr.toLowerCase();
if (!seenColors.has(colorKey)) {
seenColors.add(colorKey);
swatches.push({ label: colorStr, color: colorStr });
}
}
}
}
}
}
}
}
return swatches;
}
/**
* Generate a swatch strip and append it to the main image in a single browser session.
* This is more efficient than calling generateSwatchStrip and appendSwatchStrip separately.
*
* @param mainImage The main image PNG data
* @param swatches Array of texture swatches to render
* @param mainWidth Width of the main image
* @param mainHeight Height of the main image
* @param swatchesPerRow Number of swatches per row (default: 6)
* @returns Combined image with swatches below, or original image on failure
*/
static async generateAndAppendSwatchStrip(mainImage, swatches, mainWidth, mainHeight, swatchesPerRow = 6) {
if (swatches.length === 0) {
return mainImage;
}
let freshBrowser = null;
try {
// Calculate swatch strip dimensions
const padding = 4;
const swatchSize = Math.min(48, Math.floor((mainWidth - padding * 2) / swatchesPerRow) - padding * 2);
const labelHeight = 16;
const cellWidth = Math.floor(mainWidth / swatchesPerRow);
const cellHeight = swatchSize + labelHeight + padding * 2;
const numRows = Math.ceil(swatches.length / swatchesPerRow);
const titleHeight = 18;
const stripHeight = titleHeight + numRows * cellHeight + padding;
const totalHeight = mainHeight + stripHeight;
// Launch a single fresh browser for all operations
const playwright = await Promise.resolve().then(() => __importStar(require("playwright")));
freshBrowser = await playwright.chromium.launch({ channel: "chrome", headless: true });
const context = await freshBrowser.newContext({
viewport: { width: mainWidth, height: totalHeight },
});
try {
const page = await context.newPage();
const mainBase64 = Buffer.from(mainImage).toString("base64");
// Build the swatch elements HTML
// Note: y positions are relative to swatchArea div, not the page
const swatchElements = swatches
.map((swatch, i) => {
const col = i % swatchesPerRow;
const row = Math.floor(i / swatchesPerRow);
const x = col * cellWidth + padding;
const y = titleHeight + row * cellHeight;
let fillContent = "";
if (swatch.svg) {
const svgMatch = swatch.svg.match(/<svg[^>]*>([\s\S]*?)<\/svg>/i);
const innerSvg = svgMatch ? svgMatch[1] : "";
const viewBoxMatch = swatch.svg.match(/viewBox=["']([^"']+)["']/i);
const viewBox = viewBoxMatch ? viewBoxMatch[1] : "0 0 16 16";
fillContent = `<svg viewBox="${viewBox}" width="${swatchSize}" height="${swatchSize}">${innerSvg}</svg>`;
}
else if (swatch.color) {
fillContent = `<div style="width: ${swatchSize}px; height: ${swatchSize}px; background-color: ${swatch.color}; border-radius: 2px;"></div>`;
}
return `
<div style="position: absolute; left: ${x}px; top: ${y}px; width: ${cellWidth - padding * 2}px; text-align: center;">
<div style="display: flex; justify-content: center; align-items: center; border: 1px solid #555; border-radius: 3px; overflow: hidden; width: ${swatchSize}px; height: ${swatchSize}px; margin: 0 auto;">
${fillContent}
</div>
<div style="color: #ccc; font-size: 10px; font-family: Arial, sans-serif; margin-top: 2px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;">
${swatch.label}
</div>
</div>
`;
})
.join("");
const windowRef = "window";
const html = `
<!DOCTYPE html>
<html>
<head>
<style>
body { margin: 0; padding: 0; background: transparent; }
canvas { display: block; }
</style>
</head>
<body>
<div id="container" style="position: relative; width: ${mainWidth}px; height: ${totalHeight}px;">
<canvas id="canvas" width="${mainWidth}" height="${mainHeight}"></canvas>
<div id="swatchArea" style="position: absolute; left: 0; top: ${mainHeight}px; width: 100%; height: ${stripHeight}px; background: #1a1a2e;">
<div style="position: absolute; left: 0; top: 0; width: 100%; height: ${titleHeight}px; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.4);">
<span style="color: #888; font-size: 11px; font-family: Arial, sans-serif;">Textures</span>
</div>
${swatchElements}
</div>
</div>
<script>
async function render() {
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const mainImg = new Image();
await new Promise((resolve, reject) => {
mainImg.onload = resolve;
mainImg.onerror = reject;
mainImg.src = 'data:image/png;base64,${mainBase64}';
});
ctx.drawImage(mainImg, 0, 0, ${mainWidth}, ${mainHeight});
${windowRef}.renderComplete = true;
}
render().catch(e => { ${windowRef}.renderError = e.message; });
</script>
</body>
</html>
`;
await page.setContent(html, { waitUntil: "domcontentloaded", timeout: 10000 });
// Wait for rendering
// @ts-ignore - accessing window properties set by browser script
await page.waitForFunction(() => window.renderComplete || window.renderError, {
timeout: 10000,
});
// @ts-ignore - accessing window properties set by browser script
const error = await page.evaluate(() => window.renderError);
if (error) {
Log_1.default.debug(`Swatch strip render error: ${error}`);
return mainImage;
}
// Brief wait for any final rendering
await page.waitForTimeout(50);
// Capture the combined image
const combinedBuffer = await page.scree