ninelands-reddit/scripts/capture/trailer.mjs
Lukas Müllner 2fb1592c6b fix(trailer): „Done“ im Realm ist kein Hauptknopf mehr
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-14 10:20:02 +01:00

494 lines
14 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.

#!/usr/bin/env node
// Trailer (Vimeo/Devpost), Galerie-Bilder (3:2) und Vimeo-Cover aufnehmen.
// Das echte Spiel (public/ nach build:client) läuft in einer Bühne: links Untertitel, rechts das Handy.
// Die API ist eine Attrappe; ein Bot spielt die Karte vom 2026-10-10 (vor dem ersten Spieltag, verrät nichts).
// Eigene Reddit-Aufnahmen (optional) in dist/trailer/input/: feed.* (Post im Feed), comment.* (Kommentar im
// Thread) – mp4/mov/webm, hochkant; sie werden an passender Stelle im Handy gezeigt.
// Aufruf: npm run capture:trailer → dist/marketing/trailer.mp4, vimeo-cover.png, gallery/*.png
import {execFileSync} from 'node:child_process'
import {
copyFileSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
rmSync,
statSync,
} from 'node:fs'
import {createServer} from 'node:http'
import {dirname, extname, join, resolve} from 'node:path'
import {fileURLToPath, pathToFileURL} from 'node:url'
import {build} from 'esbuild'
import {chromium} from 'playwright'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const work = join(root, 'dist/trailer')
const site = join(work, 'site')
const input = join(work, 'input')
const out = join(root, 'dist/marketing')
const gallery = join(out, 'gallery')
rmSync(site, {recursive: true, force: true})
rmSync(gallery, {recursive: true, force: true})
for (const d of [site, input, out, gallery]) mkdirSync(d, {recursive: true})
const DAY = '2026-10-10'
const SEED = 1
const ffmpeg = (...args) =>
execFileSync('ffmpeg', ['-v', 'error', '-y', ...args])
// 1. Spiel bauen, Bühne und Partie-Planer bündeln
execFileSync('npm', ['run', 'build:client'], {cwd: root, stdio: 'inherit'})
await build({
entryPoints: [join(root, 'scripts/capture/stage.ts')],
bundle: true,
format: 'esm',
outdir: site,
logLevel: 'warning',
loader: {'.woff2': 'file', '.woff': 'file'},
assetNames: 'fonts/[name]-[hash]',
})
copyFileSync(join(root, 'scripts/capture/stage.html'), join(site, 'stage.html'))
await build({
entryPoints: [join(root, 'scripts/capture/plan.ts')],
bundle: true,
platform: 'node',
format: 'esm',
outfile: join(work, 'plan.mjs'),
logLevel: 'warning',
})
const {plan, UNIT_TYPES} = await import(
pathToFileURL(join(work, 'plan.mjs')).href
)
const p = plan(DAY, SEED)
// Reihenfolge der Figuren-Knöpfe im Spiel
const UNIT_ORDER = UNIT_TYPES
console.log(
`Partie ${DAY}/${SEED}: ${p.moves.length} Züge, ${p.stars}★, ${p.gold} Gold, ${p.lands} Länder`,
)
// 2. Eigene Aufnahmen für Chromium umwandeln (Playwright-Chromium kann kein H.264)
const clips = {}
for (const name of ['feed', 'comment']) {
const src = readdirSync(input).find(f => f.startsWith(`${name}.`))
if (!src) continue
mkdirSync(join(site, 'clips'), {recursive: true})
ffmpeg(
'-i',
join(input, src),
'-t',
'8',
'-vf',
'scale=840:-2',
'-an',
'-c:v',
'libvpx-vp9',
'-b:v',
'3M',
join(site, 'clips', `${name}.webm`),
)
clips[name] = `/clips/${name}.webm`
}
// 3. API-Attrappe: Tag, Abgabe, Aussehen, Kommentar
const top = [
{rank: 1, name: 'meadow_warden', stars: 3, gold: p.gold + 6, lands: 9},
{rank: 2, name: 'you', stars: p.stars, gold: p.gold, lands: p.lands},
{rank: 3, name: 'orcish_accountant', stars: 2, gold: p.gold - 9, lands: 8},
{rank: 4, name: 'quietfarmer', stars: 2, gold: p.gold - 14, lands: 9},
{rank: 5, name: 'ninefold', stars: 1, gold: p.gold - 30, lands: 7},
]
let state
function reset() {
state = {
day: DAY,
number: 12,
mapId: 'TRAILER',
trait: p.trait,
goals: p.goals,
today: true,
loggedIn: true,
mine: null,
stats: {streak: 6, bestStreak: 6, played: 11, totalStars: 18},
top: top.filter(r => r.name !== 'you').map((r, i) => ({...r, rank: i + 1})),
players: 40,
look: {
color: 'purple',
faction: 'kingdom',
theme: 'autumn',
banner: 'pennant',
},
commented: false,
}
}
reset()
const types = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.mp3': 'audio/mpeg',
'.webm': 'video/webm',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
}
const json = (res, body) => {
res.writeHead(200, {'Content-Type': 'application/json'})
res.end(JSON.stringify(body))
}
const body = async req => {
let b = ''
for await (const c of req) b += c
return b ? JSON.parse(b) : {}
}
const server = createServer(async (req, res) => {
const path = decodeURIComponent((req.url ?? '/').split('?')[0])
if (path === '/api/day') return json(res, state)
if (path === '/api/look') {
state.look = (await body(req)).look
return json(res, {look: state.look})
}
if (path === '/api/comment') {
state.commented = true
return json(res, {posted: true})
}
if (path === '/api/submit') {
await body(req)
const mine = {stars: p.stars, gold: p.gold, lands: p.lands, rank: 2}
state.mine = mine
state.top = top
state.players = 41
state.stats = {
streak: 7,
bestStreak: 7,
played: 12,
totalStars: 18 + p.stars,
}
return json(res, {
counted: true,
result: mine,
played: {stars: p.stars, gold: p.gold, lands: p.lands},
stats: state.stats,
top,
players: 41,
})
}
if (path.startsWith('/api/')) return json(res, {})
for (const dir of [site, join(root, 'public')]) {
const file = join(dir, path === '/' ? 'stage.html' : path)
if (file.startsWith(dir) && isFile(file)) {
res.writeHead(200, {
'Content-Type': types[extname(file)] ?? 'application/octet-stream',
})
res.end(readFileSync(file))
return
}
}
res.writeHead(404)
res.end()
})
function isFile(f) {
try {
return statSync(f).isFile()
} catch {
return false
}
}
await new Promise(r => server.listen(0, '127.0.0.1', r))
const base = `http://127.0.0.1:${server.address().port}/`
// 4. Szenen – dieselben für Video (mit Pausen) und Standbilder (snap)
/**
* @param {import('playwright').Page} page
* @param {{fast: boolean, snap: (name: string) => Promise<void>}} o
*/
async function scenes(page, o) {
const wait = ms => (o.fast ? Promise.resolve() : page.waitForTimeout(ms))
const stage = (fn, ...args) =>
page.evaluate(([f, a]) => window.stage[f](...a), [fn, args])
const frameOf = name => page.frame({url: u => u.pathname.endsWith(name)})
/** Finger zum Element, tippen, klicken */
async function tap(locator, hoverMs = 350, click = true) {
const box = await locator.boundingBox()
if (box)
await stage('finger', box.x + box.width / 2, box.y + box.height / 2)
await locator.hover()
await wait(hoverMs)
await stage('press')
if (click) await locator.click()
}
// Galeriebild „Land beanspruchen“: Vorschau des einträglichsten Zugs ab Zug 6
let snapAt = 6
for (let i = 6; i < p.moves.length - 2; i++)
if (p.moves[i].net > p.moves[snapAt].net) snapAt = i
reset()
await page.goto(base)
await page.evaluate(() => document.fonts.ready)
await stage('show', 'post', '/splash.html')
await stage(
'caption',
'Ninelands',
'A new 9×9 map every day.',
'A daily strategy game on Reddit',
{
logo: true,
instant: true,
},
)
await page.waitForTimeout(500)
const t0 = Date.now()
await o.snap('01-daily-post')
await wait(2800)
await stage(
'caption',
'One map for the whole subreddit',
'Every day a fresh post – everyone plays the same map.',
)
await wait(1800)
if (clips.feed && !o.fast) {
await stage('show', 'clip', clips.feed)
await wait(Math.min(8, await stage('clipDuration')) * 1000)
await stage('show', 'post')
}
const splash = frameOf('splash.html')
await tap(splash.locator('button.primary'), 500, false)
await wait(300)
await stage('finger', null)
await stage('show', 'game', '/game.html')
const g = frameOf('game.html')
await g.waitForSelector('.board .cell')
await stage(
'caption',
'Build your realm',
'Place a castle, then farmers, miners and knights.',
)
await wait(900)
let tool = 'HIVEMOTHER'
for (let i = 0; i < p.moves.length; i++) {
const m = p.moves[i]
if (m.unit !== tool) {
await tap(g.locator('.tool').nth(UNIT_ORDER.indexOf(m.unit)), 200)
tool = m.unit
await wait(150)
}
if (i === 4)
await stage(
'caption',
'Every piece claims land',
'Claimed land is gone – plan where you build.',
)
if (i === 13)
await stage(
'caption',
'Gold and lands',
'Mine ore and herbs, rule a land by claiming half of it.',
)
const cell = g.locator(`.board .cell[data-i="${m.cell}"]`)
if (i === snapAt) {
await cell.hover()
await o.snap('02-claim-land')
}
await tap(cell, i < 4 ? 650 : 320)
if (i === 0) tool = 'HIVEWORKER' // nach der Burg wählt das Spiel Bauern
await wait(i < 4 ? 350 : 180)
}
await page.mouse.move(5, 5)
await stage('finger', null)
await stage(
'caption',
'Three goals, three stars',
'Reach the gold goal, rule the lands – or both.',
)
await wait(1600)
await o.snap('03-three-stars')
if (!p.natural) {
await stage(
'caption',
'Know when to stop',
'No good move left? End the game and keep your gold.',
)
await wait(900)
const end = g.locator('button.end')
await tap(end, 300)
await wait(500)
await tap(end, 200)
}
await g.waitForSelector('.overlay .result')
await stage('finger', null)
await stage(
'caption',
'Only your first try counts',
'Climb the daily leaderboard and keep your streak alive.',
)
await wait(2600)
await o.snap('04-result')
const comment = g.locator('.overlay button', {hasText: 'Post to comments'})
await stage(
'caption',
'Talk about it',
'Post your result to the comments with one tap.',
)
await wait(700)
await tap(comment, 400)
await wait(1400)
if (clips.comment && !o.fast) {
await stage('show', 'clip', clips.comment)
await wait(Math.min(8, await stage('clipDuration')) * 1000)
}
await stage('finger', null)
// Zurück im Post: neues Ergebnis, Realm mit frisch freigeschaltetem Winterbrett
await stage('show', 'post', '/splash.html')
await stage(
'caption',
'Unlock new realms',
'Streaks and stars unlock boards, factions and banners.',
)
await wait(900)
// Realm öffnet im Vollbild (requestExpandedMode gibt es hier nicht → nur antippen, Bühne wechselt)
await tap(frameOf('splash.html').locator('.realm-btn'), 300, false)
await wait(300)
await stage('finger', null)
await stage('show', 'game', '/realm.html')
const r = frameOf('realm.html')
await r.waitForSelector('.realm-item')
await wait(600)
await tap(r.locator('.realm-item', {hasText: 'Winter'}).first(), 400)
await wait(900)
await stage('finger', null)
await o.snap('05-realm')
await wait(800)
await tap(r.locator('.overlay button', {hasText: 'Done'}), 300, false)
await wait(300)
await stage('finger', null)
await stage('show', 'post', '/splash.html')
await wait(700)
await stage(
'caption',
'Ninelands',
'Play today’s map on r/Ninelands',
'A daily port of Ninelands · ninelands.stackteam.at',
{
logo: true,
},
)
await wait(3200)
await o.snap('06-outro')
return t0
}
// 5. Aufnahme
const browser = await chromium.launch()
try {
const videoDir = join(work, 'video')
rmSync(videoDir, {recursive: true, force: true})
const ctx = await browser.newContext({
viewport: {width: 1920, height: 1080},
recordVideo: {dir: videoDir, size: {width: 1920, height: 1080}},
})
const start = Date.now()
const page = await ctx.newPage()
page.on('pageerror', e => console.error(`Seitenfehler: ${e.message}`))
const t0 = await scenes(page, {fast: false, snap: async () => {}})
const end = Date.now()
await ctx.close()
const webm = join(videoDir, readdirSync(videoDir)[0])
const from = (t0 - start) / 1000
const length = (end - t0) / 1000
const music = join(root, 'public/audio/music/peaceful-village.mp3')
ffmpeg(
'-ss',
from.toFixed(2),
'-i',
webm,
'-i',
music,
'-t',
length.toFixed(2),
'-r',
'30',
'-c:v',
'libx264',
'-preset',
'slow',
'-crf',
'18',
'-pix_fmt',
'yuv420p',
'-af',
`afade=t=in:d=1,afade=t=out:st=${(length - 2).toFixed(2)}:d=2`,
'-c:a',
'aac',
'-b:a',
'160k',
'-map',
'0:v',
'-map',
'1:a',
'-shortest',
'-movflags',
'+faststart',
join(out, 'trailer.mp4'),
)
console.log(
`trailer.mp4 ${length.toFixed(1)} s ${Math.round(statSync(join(out, 'trailer.mp4')).size / 1024)} KB`,
)
// Standbilder ohne Pausen: Galerie 3:2 (1800 × 1200) mit Titelbild, Vimeo-Cover 16:9 (1920 × 1080)
const COVER = [
'Ninelands',
'A new 9×9 map every day.',
'A daily strategy game on Reddit',
]
async function stills(viewport, onSnap) {
const still = await browser.newPage({viewport})
still.on('pageerror', e => console.error(`Seitenfehler: ${e.message}`))
await scenes(still, {
fast: true,
snap: async name => {
await still.evaluate(() => window.stage.finger(null))
await still.waitForTimeout(450) // Animationen auslaufen lassen
await onSnap(still, name)
},
})
await still.close()
}
/** Titelbild: Logo links, Brett mit drei Sternen rechts */
async function cover(page, path) {
const old = await page.evaluate(() => [
document.getElementById('title').textContent,
document.getElementById('sub').textContent,
])
await page.evaluate(
c => window.stage.caption(c[0], c[1], c[2], {logo: true, instant: true}),
COVER,
)
await page.screenshot({path})
await page.evaluate(
o => window.stage.caption(o[0], o[1], '', {instant: true}),
old,
)
}
await stills({width: 1800, height: 1200}, async (page, name) => {
if (name === '03-three-stars')
await cover(page, join(gallery, '00-cover.png'))
await page.screenshot({path: join(gallery, `${name}.png`)})
})
await stills({width: 1920, height: 1080}, async (page, name) => {
if (name === '03-three-stars')
await cover(page, join(out, 'vimeo-cover.png'))
})
for (const f of readdirSync(gallery))
console.log(
`gallery/${f} ${Math.round(statSync(join(gallery, f)).size / 1024)} KB`,
)
if (!existsSync(join(out, 'vimeo-cover.png'))) console.error('Cover fehlt')
} finally {
await browser.close()
server.close()
}