// Offline media production. This uses an isolated renderer, not a user's browser. import { chromium } from "@playwright/test"; import { createServer } from "node:http"; import { readFile, writeFile, mkdir } from "node:fs/promises"; import { resolve } from "node:path"; import { spawn } from "node:child_process"; import { once } from "node:events"; import sharp from "sharp"; const root = process.cwd(); const output = resolve(root, "public/media"); const work = resolve(root, ".data/cinematic-render"); const previewOnly = process.argv.includes("--preview"); const bannerOnly = process.argv.includes("--banner-only"); const trailerOnly = process.argv.includes("--trailer-only"); await mkdir(output, { recursive: true }); await mkdir(work, { recursive: true }); const routes = new Map([ ["/", "cinematics/index.html"], ["/cinematics/scene.mjs", "cinematics/scene.mjs"], ["/three/three.module.js", "node_modules/three/build/three.module.js"], ["/three/three.core.js", "node_modules/three/build/three.core.js"], ["/fonts/Monocraft.ttf", "public/fonts/Monocraft.ttf"], ...["home", "catalog", "map", "game"].map((name) => [ `/shots/${name}.jpg`, `cinematics/screenshots/${name}.jpg`, ]), ]); const server = createServer(async (req, res) => { const path = routes.get(new URL(req.url, "http://localhost").pathname); if (!path) { res.writeHead(404); res.end(); return; } try { const type = path.endsWith(".mjs") || path.endsWith(".js") ? "text/javascript" : path.endsWith(".ttf") ? "font/ttf" : path.endsWith(".jpg") ? "image/jpeg" : "text/html"; res.setHeader("Content-Type", type); res.end(await readFile(resolve(root, path))); } catch { res.writeHead(500); res.end("Renderer asset unavailable"); } }); await new Promise((r) => server.listen(previewOnly ? 3110 : 0, "127.0.0.1", r)); const origin = `http://127.0.0.1:${server.address().port}`; if (previewOnly) { console.log( `Cinematic preview: ${origin}/?mode=banner or ${origin}/?time=25&still=1`, ); } else { let browser; try { browser = await chromium.launch({ headless: true, args: [ "--enable-gpu", "--enable-unsafe-swiftshader", "--disable-background-timer-throttling", ], }); const page = await browser.newPage({ viewport: { width: 1280, height: 720 }, deviceScaleFactor: 1, }); page.on("pageerror", (error) => { console.error("Renderer error:", error.message); }); const open = async (mode, width, height) => { await page.setViewportSize({ width, height }); await page.goto( `${origin}/?mode=${mode}&width=${width}&height=${height}&still=1`, ); await page.waitForFunction( () => window.cinematicReady === true, undefined, { timeout: 30000 }, ); }; if (!trailerOnly) { await open("banner", 1200, 480); const dir = resolve(work, "banner"); await mkdir(dir, { recursive: true }); for (let frame = 0; frame < 72; frame++) { await page.evaluate((t) => window.renderCinematic(t), frame / 24); await page.screenshot({ path: resolve(dir, `frame-${String(frame).padStart(3, "0")}.png`), type: "png", }); if (frame % 24 === 0) console.log(`Banner: ${frame}/72 frames`); } await runFfmpeg([ "-framerate", "24", "-i", resolve(dir, "frame-%03d.png"), "-filter_complex", "scale=600:240:flags=lanczos,split[a][b];[a]palettegen=max_colors=128:stats_mode=diff[p];[b][p]paletteuse=dither=bayer:bayer_scale=3:diff_mode=rectangle", "-loop", "0", resolve(output, "minegeo-discord-banner.gif"), ]); await page.evaluate(() => window.renderCinematic(0.75)); await sharp(await page.screenshot()) .resize(1200, 480) .png() .toFile(resolve(output, "minegeo-discord-banner.png")); console.log("Banner complete: 600 × 240, 3 seconds, seamless loop"); } if (!bannerOnly) { await open("trailer", 1280, 720); await page.evaluate(() => window.renderCinematic(6.5)); await sharp(await page.screenshot()) .jpeg({ quality: 90 }) .toFile(resolve(output, "minegeo-trailer-poster.jpg")); const soundtrack = resolve(work, "soundtrack.wav"); await writeFile(soundtrack, createSoundtrack()); const ffmpeg = spawn( "ffmpeg", [ "-y", "-hide_banner", "-loglevel", "warning", "-f", "image2pipe", "-vcodec", "png", "-framerate", "24", "-i", "pipe:0", "-i", soundtrack, "-c:v", "libx264", "-preset", "medium", "-crf", "20", "-pix_fmt", "yuv420p", "-c:a", "aac", "-b:a", "128k", "-t", "30", "-movflags", "+faststart", resolve(output, "minegeo-trailer.mp4"), ], { windowsHide: true, stdio: ["pipe", "ignore", "pipe"] }, ); let stderr = ""; ffmpeg.stderr.on("data", (b) => { stderr += b.toString(); }); let encoderError; ffmpeg.on("error", (e) => { encoderError = e; }); const done = once(ffmpeg, "close"); for (let frame = 0; frame < 720; frame++) { if (encoderError) throw encoderError; await page.evaluate((t) => window.renderCinematic(t), frame / 24); const png = await page.screenshot({ type: "png" }); if (!ffmpeg.stdin.write(png)) await once(ffmpeg.stdin, "drain"); if (frame % 48 === 0) console.log( `Trailer: ${frame}/720 frames (${Math.round((frame / 720) * 100)}%)`, ); } ffmpeg.stdin.end(); const [code] = await done; if (code !== 0) throw new Error(`FFmpeg failed: ${stderr}`); await writeFile(resolve(output, "minegeo-trailer.vtt"), createCaptions()); console.log( "Trailer complete: 1280 × 720, 30 seconds, original ambient soundtrack", ); } } finally { await browser?.close(); await new Promise((r) => server.close(r)); } } async function runFfmpeg(args) { const child = spawn( "ffmpeg", ["-y", "-hide_banner", "-loglevel", "warning", ...args], { windowsHide: true, stdio: ["ignore", "ignore", "pipe"] }, ); let stderr = ""; child.stderr.on("data", (b) => { stderr += b.toString(); }); const [code] = await once(child, "close"); if (code !== 0) throw new Error(`FFmpeg failed: ${stderr}`); } function createSoundtrack() { const rate = 48000, seconds = 30, length = rate * seconds; const buffer = Buffer.alloc(44 + length * 4); buffer.write("RIFF", 0); buffer.writeUInt32LE(buffer.length - 8, 4); buffer.write("WAVEfmt ", 8); buffer.writeUInt32LE(16, 16); buffer.writeUInt16LE(1, 20); buffer.writeUInt16LE(2, 22); buffer.writeUInt32LE(rate, 24); buffer.writeUInt32LE(rate * 4, 28); buffer.writeUInt16LE(4, 32); buffer.writeUInt16LE(16, 34); buffer.write("data", 36); buffer.writeUInt32LE(length * 4, 40); const chords = [ [146.832, 220, 293.665, 349.228], [130.813, 196, 261.626, 329.628], [116.541, 174.614, 233.082, 293.665], [130.813, 196, 261.626, 349.228], ]; for (let i = 0; i < length; i++) { const t = i / rate, chord = chords[Math.floor(t / 4) % 4]; const fade = Math.min(1, t / 2, (30 - t) / 2); const local = t % 4, change = Math.min(1, local / 0.4, (4 - local) / 0.4); let pad = 0; for (const f of chord) pad += (Math.sin(2 * Math.PI * f * t) + 0.25 * Math.sin(2 * Math.PI * f * 1.002 * t)) * 0.017; const step = Math.floor(t / 0.5), pluckTime = t % 0.5; const freq = chord[[0, 2, 1, 3, 2, 1, 3, 2][step % 8]] * 2; const pluck = Math.sin(2 * Math.PI * freq * t) * Math.exp(-pluckTime * 8) * 0.07; const beat = t % 1; const bass = Math.sin(2 * Math.PI * 55 * beat - 7 * Math.exp(-beat * 25)) * Math.exp(-beat * 12) * 0.045; const swell = [4, 9, 14, 19, 24].reduce( (n, c) => n + Math.exp(-(((t - c) / 0.45) ** 2)), 0, ); const air = (Math.sin(t * 4131) + Math.sin(t * 5999) + Math.sin(t * 7123)) * 0.004 * swell; const sample = (pad * (0.65 + 0.35 * change) + pluck + bass + air) * fade; const left = sample * (0.94 + 0.06 * Math.sin(t * 0.4)), right = sample * (0.94 - 0.06 * Math.sin(t * 0.4)); buffer.writeInt16LE( Math.round(Math.max(-1, Math.min(1, left)) * 32767), 44 + i * 4, ); buffer.writeInt16LE( Math.round(Math.max(-1, Math.min(1, right)) * 32767), 46 + i * 4, ); } return buffer; } function createCaptions() { return `WEBVTT 00:00.000 --> 00:04.000 [Ambient electronic music. A floating voxel island emerges.] Every world has a place. But can you find yours? 00:04.000 --> 00:09.000 [Screenshot of the real MineGeo homepage.] Your expedition starts here. A new way to see Minecraft. 00:09.000 --> 00:14.000 [Screenshot of the community map catalog.] Pick your world. Browse published maps from the community. 00:14.000 --> 00:19.000 [Real in-game panorama with a pin selected on the map.] Look. Guess. Discover. Look around, then place your pin. 00:19.000 --> 00:24.000 [Screenshot of map details and difficulty options.] Make it your expedition. Choose your map, difficulty, and game mode. 00:24.000 --> 00:30.000 MineGeo. Get lost in a familiar world. Your next adventure starts at minegeo.site. `; }