Add cinematic banner, real-site trailer, and first-visit intro
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
Some checks failed
Deploy MineGeo to Dokploy / deploy (push) Failing after 3m14s
This commit is contained in:
303
scripts/render-cinematics.mjs
Normal file
303
scripts/render-cinematics.mjs
Normal 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.
|
||||
`;
|
||||
}
|
||||
Reference in New Issue
Block a user