#!/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}} 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, peoples 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.primary', {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() }