Files
MineGeo/scripts/render-cinematics.mjs
pavel444-byte ad73132f15
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
Add cinematic banner, real-site trailer, and first-visit intro
2026-10-17 19:54:23 +05:00

304 lines
9.6 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.
`;
}