Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
304 lines
9.6 KiB
JavaScript
304 lines
9.6 KiB
JavaScript
// 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.
|
||
`;
|
||
}
|