Add cinematic banner, real-site trailer, and first-visit intro
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s

This commit is contained in:
2026-10-17 19:54:23 +05:00
parent 128caff2a2
commit ad73132f15
19 changed files with 1578 additions and 0 deletions

View File

@@ -0,0 +1,303 @@
// 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.
`;
}