UNPKG

@minecraft/creator-tools

Version:

Minecraft Creator Tools command line and libraries.

686 lines (685 loc) 30.4 kB
"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 }); /** * 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;