494 lines
14 KiB
JavaScript
494 lines
14 KiB
JavaScript
#!/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, 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()
|
||
}
|