@minecraft/creator-tools
Version:
Minecraft Creator Tools command line and libraries.
686 lines (685 loc) • 30.4 kB
JavaScript
;
// 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 });
/**
* PlaywrightPageRenderer - Utility for rendering 3D content to PNG using Playwright.
*
* Browser resolution strategy (in order):
* 1. Use system Chrome (channel: "chrome") - works on dev machines with Chrome installed
* 2. Use system Edge (channel: "msedge") - works on Windows machines with Edge
* 3. Use Playwright's bundled Chromium - requires `npx playwright install chromium`
* 4. Use executable at CHROMIUM_PATH environment variable - for container scenarios
*
* See app/docs/PlaywrightBrowserManagement.md for a discussion of Playwright session
* management techniques.
*/
const Log_1 = __importDefault(require("../core/Log"));
class PlaywrightPageRenderer {
_baseUrl;
_browser = null;
_browserName = "";
_playwright = null;
// Persistent context/page for fast batch rendering
_persistentContext = null;
_persistentPage = null;
_persistentViewport = null;
_lastModelPath = null; // Track last model key (without camera params) to detect changes
_lastFullUrl = null; // Track full URL to detect camera param changes
constructor(baseUrl = "http://localhost:6126") {
this._baseUrl = baseUrl;
}
/**
* Reset the persistent page/context. Call this when switching between
* different model types to ensure clean state.
*/
async resetPersistentPage() {
if (this._persistentContext) {
try {
await this._persistentContext.close();
}
catch {
// Ignore close errors
}
}
this._persistentContext = null;
this._persistentPage = null;
this._persistentViewport = null;
this._lastModelPath = null;
this._lastFullUrl = null;
}
/**
* Get the list of browser configurations to try, in order of preference.
*/
_getBrowserConfigs() {
const configs = [];
// Common args for all browsers - includes flags for:
// - Security sandbox (needed for CI runners)
// - Cross-origin resource loading (needed for mctools.dev textures)
// - WebGL in headless mode (critical for 3D rendering on CI)
const commonArgs = [
"--no-sandbox",
"--disable-setuid-sandbox",
"--disable-web-security", // Allow cross-origin requests (needed for mctools.dev textures)
"--allow-running-insecure-content",
// WebGL flags for headless rendering - without these, canvas may render black
"--enable-webgl",
"--use-gl=angle", // Use ANGLE for WebGL (works on most systems)
"--use-angle=swiftshader", // Use SwiftShader software renderer as fallback
"--enable-unsafe-swiftshader", // Enable SwiftShader for software WebGL on CI
"--enable-gpu",
"--ignore-gpu-blocklist", // Allow WebGL even on blocklisted GPUs
"--disable-gpu-sandbox", // Needed for some CI environments
];
// 1. System Chrome (most common on dev machines)
configs.push({
name: "System Chrome",
launchOptions: {
channel: "chrome",
headless: true,
args: commonArgs,
},
});
// 2. System Edge (common on Windows)
configs.push({
name: "System Edge",
launchOptions: {
channel: "msedge",
headless: true,
args: commonArgs,
},
});
// 3. Environment-specified Chromium path (for containers)
const chromiumPath = process.env.CHROMIUM_PATH;
if (chromiumPath) {
configs.push({
name: "Custom Chromium (CHROMIUM_PATH)",
launchOptions: {
executablePath: chromiumPath,
headless: true,
args: commonArgs,
},
});
}
// 4. Common Linux container paths
const linuxPaths = ["/usr/bin/chromium", "/usr/bin/chromium-browser", "/usr/bin/google-chrome"];
for (const linuxPath of linuxPaths) {
configs.push({
name: `Linux Chromium (${linuxPath})`,
launchOptions: {
executablePath: linuxPath,
headless: true,
args: commonArgs,
},
});
}
// 5. Playwright's bundled Chromium (requires npx playwright install)
configs.push({
name: "Playwright Bundled Chromium",
launchOptions: {
headless: true,
args: commonArgs,
},
});
return configs;
}
/**
* Initialize the browser with fallback strategy.
* Tries multiple browser configurations until one works.
*/
async initialize() {
if (this._browser) {
return true;
}
try {
// Dynamic import of playwright
this._playwright = await Promise.resolve().then(() => __importStar(require("playwright")));
}
catch (e) {
Log_1.default.fail("Playwright not installed. Run: npm install playwright");
return false;
}
const configs = this._getBrowserConfigs();
for (const config of configs) {
try {
Log_1.default.verbose(`Trying browser: ${config.name}...`);
this._browser = await this._playwright.chromium.launch(config.launchOptions);
this._browserName = config.name;
Log_1.default.verbose(`HeadlessRenderer: Using ${config.name}`);
return true;
}
catch (e) {
Log_1.default.verbose(`${config.name} not available: ${e.message}`);
continue;
}
}
Log_1.default.fail("HeadlessRenderer: No browser available. Options:\n" +
" 1. Install Chrome or Edge on your system\n" +
" 2. Run: npx playwright install chromium\n" +
" 3. In containers, install chromium and set CHROMIUM_PATH=/usr/bin/chromium");
return false;
}
/**
* Warm up the browser by creating and destroying a test context.
* This ensures the browser is fully ready before the first real render.
* Call this after initialize() to improve first-render reliability.
*/
async warmUp() {
if (!this._browser) {
return false;
}
try {
Log_1.default.verbose("PlaywrightPageRenderer: Warming up browser...");
const testContext = await this._browser.newContext({
viewport: { width: 100, height: 100 },
});
const testPage = await testContext.newPage();
// Navigate to a simple about:blank to exercise the browser
await testPage.goto("about:blank", { waitUntil: "load", timeout: 5000 });
await testContext.close();
Log_1.default.verbose("PlaywrightPageRenderer: Browser warm-up complete");
return true;
}
catch (e) {
Log_1.default.debugAlert(`PlaywrightPageRenderer: Warm-up failed: ${e.message}`);
return false;
}
}
/**
* Check if the browser is connected and responsive.
*/
isBrowserReady() {
return this._browser !== null && this._browser.isConnected();
}
/**
* Render a model to PNG.
*
* @param modelPath - URL path to the model viewer page
* @param options - Rendering options
*/
async renderModel(modelPath, options = {}) {
const width = options.width ?? 512;
const height = options.height ?? 512;
const renderWaitTime = options.renderWaitTime ?? 3000;
if (!this._browser) {
const initialized = await this.initialize();
if (!initialized) {
return {
imageData: undefined,
error: "Failed to initialize browser",
};
}
}
try {
const context = await this._browser.newContext({
viewport: { width, height },
});
const page = await context.newPage();
// Capture console messages for debugging
page.on("console", (msg) => {
const type = msg.type();
const text = msg.text();
if (type === "error" || type === "warning") {
Log_1.default.debugAlert(`Browser console ${type}: ${text}`);
}
else {
Log_1.default.verbose(`Browser console ${type}: ${text}`);
}
});
// Capture page errors
page.on("pageerror", (error) => {
Log_1.default.debugAlert(`Browser page error: ${error.message}`);
});
// Navigate to the model viewer page
const fullUrl = `${this._baseUrl}${modelPath}`;
Log_1.default.verbose(`Navigating to: ${fullUrl}`);
// Use domcontentloaded instead of networkidle to prevent hangs when fetching textures
await page.goto(fullUrl, { waitUntil: "domcontentloaded" });
// Wait for the scene to render
await page.waitForTimeout(renderWaitTime);
// Debug: Log the page content if verbose (use short timeout to avoid blocking on CI)
const bodyText = await page
.locator("body")
.textContent({ timeout: 5000 })
.catch(() => null);
if (bodyText) {
Log_1.default.verbose(`Page body text: ${bodyText.substring(0, 500)}`);
}
// Debug: Check what elements exist
const rootElement = await page.locator("#root").count();
Log_1.default.verbose(`#root element count: ${rootElement}`);
// Ensure canvas exists
const canvasCount = await page.locator("canvas").count();
Log_1.default.verbose(`Canvas element count: ${canvasCount}`);
if (canvasCount === 0) {
// Check for error messages in the page (use short timeout to avoid blocking)
const errorText = await page
.locator(".error, .Error, [class*='error']")
.textContent({ timeout: 5000 })
.catch(() => null);
if (errorText) {
Log_1.default.debugAlert(`Page error text: ${errorText}`);
}
await context.close();
return {
imageData: undefined,
error: "No canvas element found - scene may not have rendered." + (errorText ? " " + errorText : ""),
};
}
// Use page-level screenshot instead of canvas element screenshot.
// locator.screenshot() waits for element "stability" (no bounding box changes between
// animation frames), which can fail unpredictably with WebGL/Babylon.js canvases that
// resize during initialization. page.screenshot() captures the viewport directly.
const format = options.imageFormat || "png";
const canvasTimeout = options.canvasTimeout ?? 30000;
// Wait for canvas to be visible before capturing
try {
await page.locator("canvas").first().waitFor({ state: "visible", timeout: canvasTimeout });
}
catch {
Log_1.default.debugAlert("Canvas element not visible within timeout, attempting screenshot anyway.");
}
const screenshotBuffer = await page.screenshot({
type: format,
quality: format === "jpeg" ? options.jpegQuality || 80 : undefined,
omitBackground: false,
fullPage: false,
timeout: canvasTimeout,
});
await context.close();
return {
imageData: new Uint8Array(screenshotBuffer),
browserUsed: this._browserName,
imageFormat: format,
};
}
catch (e) {
Log_1.default.debugAlert(`renderModel error: ${e.message}`);
// Ensure context is closed even on error to prevent resource leaks
try {
const contexts = this._browser?.contexts?.() ?? [];
for (const ctx of contexts) {
await ctx.close().catch(() => { });
}
}
catch {
// Ignore cleanup errors
}
return {
imageData: undefined,
error: `Rendering failed: ${e.message}`,
};
}
}
/**
* Fast render method that reuses page for batch operations.
* Much faster than renderModel() because it skips context creation overhead.
*
* @param modelPath - URL path to navigate to
* @param options - Rendering options
*/
async renderModelFast(modelPath, options = {}) {
const width = options.width ?? 512;
const height = options.height ?? 512;
const renderWaitTime = options.renderWaitTime ?? 1500; // Shorter default for fast mode
if (!this._browser) {
const initialized = await this.initialize();
if (!initialized) {
return { imageData: undefined, error: "Failed to initialize browser" };
}
}
// Check if browser is still connected
if (!this.isBrowserReady()) {
return {
imageData: undefined,
error: "Browser is disconnected. Please retry - the browser will be reinitialized.",
};
}
try {
// Extract a model identifier from the path (geometry file path is the key)
// For MCP previews, this is /temp/preview-geometry.json which is the same across calls,
// so we need to detect when the actual content changes by checking if the URL changed
// For block viewer, path changes with each block
const modelKey = modelPath.split("&cameraX=")[0]; // Strip camera params to get model key
// Check if we need to recreate the context due to viewport size change, model change, or force flag
const needsNewContext = options.forceNewContext ||
!this._persistentContext ||
!this._persistentViewport ||
this._persistentViewport.width !== width ||
this._persistentViewport.height !== height ||
this._lastModelPath !== modelKey; // Also reset when model changes
// Track full URL to detect when only camera params change
// Also force reload when explicitly requested (for multi-angle renders)
const needsReload = options.forceReload ||
(!needsNewContext && this._persistentPage && this._lastFullUrl && this._lastFullUrl !== modelPath);
if (needsNewContext) {
// Close existing context if any
if (this._persistentContext) {
try {
await this._persistentContext.close();
}
catch (e) {
// Ignore close errors
}
this._persistentContext = null;
this._persistentPage = null;
// Brief delay to allow browser to fully clean up previous context
await new Promise((resolve) => setTimeout(resolve, 50));
}
try {
this._persistentContext = await this._browser.newContext({
viewport: { width, height },
});
this._persistentPage = await this._persistentContext.newPage();
}
catch (e) {
return { imageData: undefined, error: `Failed to create browser context: ${e.message}` };
}
this._persistentViewport = { width, height };
this._lastModelPath = modelKey;
}
const page = this._persistentPage;
// Defensive check - page should never be null at this point
if (!page) {
return { imageData: undefined, error: "Browser page is null - context creation may have failed silently" };
}
const fullUrl = `${this._baseUrl}${modelPath}`;
// DEBUG: Capture console logs from the browser
page.on("console", (msg) => {
const text = msg.text();
if (text.includes("[STRUCTURE DEBUG]") || text.includes("[TEXTURE DEBUG]")) {
console.log(`[BROWSER] ${text}`);
}
});
// If only camera params changed (same modelKey, different full URL), reload the page
// to force React to reinitialize with new props
if (needsReload && !needsNewContext) {
// BUG FIX: When reloading, the old canvas is still visible during navigation.
// This causes waitFor({ state: "visible" }) to return immediately with stale content.
// Solution: Navigate and wait for a fresh render cycle.
try {
await page.goto(fullUrl, { waitUntil: "load", timeout: 30000 });
}
catch (e) {
return { imageData: undefined, error: `Page navigation failed (reload): ${e.message}` };
}
// After navigation, wait for the canvas to appear
// The canvas waitFor below will handle this, but we add a small delay
// to ensure React has time to process the new URL params and re-render
await page.waitForTimeout(200);
}
else if (!needsNewContext) {
// Reusing context without reload - URL hasn't changed
// This shouldn't happen in multi-angle mode, but handle it anyway
}
else {
// New context - navigate for the first time
try {
await page.goto(fullUrl, { waitUntil: "load", timeout: 30000 });
}
catch (e) {
return { imageData: undefined, error: `Page navigation failed: ${e.message}` };
}
}
this._lastFullUrl = modelPath;
// Wait for canvas to appear - use configurable timeout for large structures
// Default increased to 10s for slower CI environments
const canvasTimeout = options.canvasTimeout ?? 10000;
try {
await page.locator("canvas").first().waitFor({ state: "visible", timeout: canvasTimeout });
}
catch (e) {
// Canvas not found, provide helpful error
return {
imageData: undefined,
error: `Canvas not found within ${canvasTimeout}ms timeout. Structure may be too large or page failed to load.`,
};
}
// Brief wait for rendering to complete
await page.waitForTimeout(renderWaitTime);
// Capture screenshot of just the canvas element
const canvas = page.locator("canvas").first();
const format = options.imageFormat || "png";
let screenshotBuffer;
try {
screenshotBuffer = await canvas.screenshot({
type: format,
quality: format === "jpeg" ? options.jpegQuality || 80 : undefined,
omitBackground: false,
timeout: canvasTimeout, // Use same timeout for screenshot
});
}
catch (screenshotError) {
return {
imageData: undefined,
error: `Screenshot capture failed: ${screenshotError.message}. Canvas may have disappeared or browser context was closed.`,
};
}
return {
imageData: new Uint8Array(screenshotBuffer),
browserUsed: this._browserName,
imageFormat: format,
};
}
catch (e) {
// Provide more context about the error
const errorStack = e.stack ? e.stack.split("\n").slice(0, 3).join(" | ") : "";
return {
imageData: undefined,
error: `Fast render failed: ${e.message}${errorStack ? ` [${errorStack}]` : ""}`,
};
}
}
/**
* Render a block to PNG using the BlockViewer.
* Uses headless mode to hide UI chrome and get full-viewport canvas.
*/
async renderBlock(blockName, options = {}) {
const flat = options.flatBackground ? "&flatbg=true" : "";
return this.renderModel(`/?mode=blockviewer&block=${encodeURIComponent(blockName)}&headless=true${flat}`, options);
}
/**
* Render multiple blocks efficiently, reusing the browser instance and page.
* Uses fast mode by default for significantly better performance.
* @param blocks - Array of { name, outputPath } objects
* @param options - Rendering options (fastMode defaults to true for batch)
* @param onProgress - Optional callback for progress reporting
* @returns Array of results with block names
*/
async renderBlocks(blocks, options = {}, onProgress) {
const results = [];
const fs = await Promise.resolve().then(() => __importStar(require("fs")));
const useFastMode = options.fastMode !== false; // Default to true for batch
// Initialize browser once
if (!this._browser) {
const initialized = await this.initialize();
if (!initialized) {
return blocks.map((b) => ({ name: b.name, success: false, error: "Failed to initialize browser" }));
}
}
for (let i = 0; i < blocks.length; i++) {
const block = blocks[i];
if (onProgress) {
onProgress(block.name, i, blocks.length);
}
// Use headless=true to hide UI chrome and get full-viewport canvas
const flat = options.flatBackground ? "&flatbg=true" : "";
const modelPath = `/?mode=blockviewer&block=${encodeURIComponent(block.name)}&headless=true${flat}`;
const result = useFastMode
? await this.renderModelFast(modelPath, options)
: await this.renderModel(modelPath, options);
if (result.imageData) {
try {
fs.writeFileSync(block.outputPath, Buffer.from(result.imageData));
results.push({ name: block.name, success: true });
}
catch (e) {
results.push({ name: block.name, success: false, error: `Failed to write file: ${e.message}` });
}
}
else {
results.push({ name: block.name, success: false, error: result.error });
}
}
// Clean up persistent context after batch
if (this._persistentContext) {
await this._persistentContext.close();
this._persistentContext = null;
this._persistentPage = null;
}
return results;
}
/**
* Render a mob/entity to PNG using the MobViewer.
* Uses headless mode to hide UI chrome and get full-viewport canvas.
*/
async renderMob(mobId, options = {}) {
return this.renderModel(`/?mode=mobviewer&mob=${encodeURIComponent(mobId)}&headless=true`, options);
}
/**
* Render an item/attachable to PNG using the ItemViewer.
* Uses headless mode to hide UI chrome and get full-viewport canvas.
*/
async renderItem(itemId, options = {}) {
return this.renderModel(`/?mode=itemviewer&item=${encodeURIComponent(itemId)}&headless=true`, options);
}
/**
* Render multiple mobs efficiently, reusing the browser instance and page.
* Uses fast mode by default for significantly better performance.
* @param mobs - Array of { name, outputPath } objects
* @param options - Rendering options (fastMode defaults to true for batch)
* @param onProgress - Optional callback for progress reporting
* @returns Array of results with mob names
*/
async renderMobs(mobs, options = {}, onProgress) {
return this._renderBatch(mobs, (name) => `/?mode=mobviewer&mob=${encodeURIComponent(name)}&headless=true`, options, onProgress);
}
/**
* Render multiple items/attachables efficiently, reusing the browser instance and page.
* Uses fast mode by default for significantly better performance.
* @param items - Array of { name, outputPath } objects
* @param options - Rendering options (fastMode defaults to true for batch)
* @param onProgress - Optional callback for progress reporting
* @returns Array of results with item names
*/
async renderItems(items, options = {}, onProgress) {
return this._renderBatch(items, (name) => `/?mode=itemviewer&item=${encodeURIComponent(name)}&headless=true`, options, onProgress);
}
/**
* Shared batch rendering logic for mobs, items, or any entity type.
* Reuses the browser instance and page for efficiency.
*/
async _renderBatch(entries, buildPath, options = {}, onProgress) {
const results = [];
const fs = await Promise.resolve().then(() => __importStar(require("fs")));
const useFastMode = options.fastMode !== false; // Default to true for batch
// Initialize browser once
if (!this._browser) {
const initialized = await this.initialize();
if (!initialized) {
return entries.map((e) => ({ name: e.name, success: false, error: "Failed to initialize browser" }));
}
}
for (let i = 0; i < entries.length; i++) {
const entry = entries[i];
if (onProgress) {
onProgress(entry.name, i, entries.length);
}
const modelPath = buildPath(entry.name);
const result = useFastMode
? await this.renderModelFast(modelPath, options)
: await this.renderModel(modelPath, options);
if (result.imageData) {
try {
fs.writeFileSync(entry.outputPath, Buffer.from(result.imageData));
results.push({ name: entry.name, success: true });
}
catch (e) {
const msg = e instanceof Error ? e.message : String(e);
results.push({ name: entry.name, success: false, error: `Failed to write file: ${msg}` });
}
}
else {
results.push({ name: entry.name, success: false, error: result.error });
}
}
// Clean up persistent context after batch
if (this._persistentContext) {
await this._persistentContext.close();
this._persistentContext = null;
this._persistentPage = null;
}
return results;
}
/**
* Render a custom model geometry with texture to PNG.
* Uses a custom route that accepts geometry and texture data.
*
* @param geometryId - Identifier for the geometry (used in URL)
* @param options - Rendering options
*/
async renderCustomModel(geometryId, options = {}) {
return this.renderModel(`/?mode=modelviewer&geometry=${encodeURIComponent(geometryId)}`, options);
}
/**
* Close the browser and clean up resources.
*/
async close() {
if (this._browser) {
try {
// Use a timeout to prevent hanging if the browser process is unresponsive
await Promise.race([
this._browser.close(),
new Promise((_, reject) => setTimeout(() => reject(new Error("Browser close timeout")), 15000)),
]);
}
catch (e) {
Log_1.default.debugAlert(`Browser close issue: ${e.message}`);
}
this._browser = null;
}
}
/**
* Get information about the current browser being used.
*/
getBrowserInfo() {
return this._browserName || "Not initialized";
}
}
exports.default = PlaywrightPageRenderer;