ninelands-reddit/scripts/capture/record.mjs
Lukas Müllner 426776ee77 feat: Aufnahme der Katalog-Assets (Video, GIF, Standbild, Kachel)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-13 17:20:40 +01:00

190 lines
5.6 KiB
JavaScript
Raw Permalink 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
// Katalog-Assets für das Developer Portal aufnehmen (Maße laut Portal, Stand 2026-11-13):
// featuring.mp4 3:2, 1500×1000, 6–15 s, ≤ 30 fps, ≤ 3 MB
// fallback.gif 3:2, 1000×667, 3–10 s Schleife, Ziel ≤ 2 MB
// fallback.png 1500×1000, ≤ 800 KB (Standbild statt GIF)
// tile.png 560×256, ≤ 800 KB
// Nur das Brett, keine Menüs. Braucht ffmpeg. Aufruf: npm run capture (Ergebnis in dist/marketing/)
import {execFileSync} from 'node:child_process'
import {
copyFileSync,
mkdirSync,
readdirSync,
readFileSync,
statSync,
} from 'node:fs'
import {createServer} from 'node:http'
import {dirname, extname, join, resolve} from 'node:path'
import {fileURLToPath} from 'node:url'
import {build} from 'esbuild'
import {chromium} from 'playwright'
const root = resolve(dirname(fileURLToPath(import.meta.url)), '../..')
const site = join(root, 'dist/capture')
const out = join(root, 'dist/marketing')
mkdirSync(site, {recursive: true})
mkdirSync(out, {recursive: true})
// 1. Aufnahmeseite bauen (gleiche Defines wie build:client)
await build({
entryPoints: [join(root, 'scripts/capture/capture.ts')],
bundle: true,
format: 'esm',
outdir: site,
logLevel: 'warning',
define: {
'import.meta.env.MODE': '"reddit"',
'import.meta.env.BASE_URL': '"/"',
__API_ORIGIN__: '""',
},
loader: {'.woff2': 'file', '.woff': 'file'},
assetNames: 'fonts/[name]-[hash]',
})
copyFileSync(
join(root, 'scripts/capture/capture.html'),
join(site, 'index.html'),
)
// 2. Statischer Server: zuerst die Aufnahmeseite, dann public/ (Sprites)
const types = {
'.html': 'text/html',
'.js': 'text/javascript',
'.css': 'text/css',
'.png': 'image/png',
'.woff2': 'font/woff2',
'.woff': 'font/woff',
}
const server = createServer((req, res) => {
const path = decodeURIComponent((req.url ?? '/').split('?')[0])
for (const dir of [site, join(root, 'public')]) {
const file = join(dir, path === '/' ? 'index.html' : path)
if (file.startsWith(dir) && existsFile(file)) {
res.writeHead(200, {
'Content-Type': types[extname(file)] ?? 'application/octet-stream',
})
res.end(readFileSync(file))
return
}
}
res.writeHead(404)
res.end()
})
function existsFile(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}/`
const ffmpeg = (...args) =>
execFileSync('ffmpeg', ['-v', 'error', '-y', ...args])
const size = f => statSync(join(out, f)).size
const kb = f => `${Math.round(size(f) / 1024)} KB`
const browser = await chromium.launch()
try {
// 3. Video: Partie mit Vorschau im Takt; die ersten 2 Züge (Burg + Bauer) stehen schon im ersten Bild
const videoDir = join(site, 'video')
const ctx = await browser.newContext({
viewport: {width: 1500, height: 1000},
recordVideo: {dir: videoDir, size: {width: 1500, height: 1000}},
})
const t0 = Date.now()
const page = await ctx.newPage()
await page.goto(base)
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(400)
const start = (Date.now() - t0) / 1000
await page.evaluate(() => window.capture.play(2, 180, 200))
await page.waitForTimeout(2200) // Endbild mit allen Bannern stehen lassen
const length = (Date.now() - t0) / 1000 - start
await ctx.close()
const webm = join(videoDir, readdirSync(videoDir)[0])
// MP4 (H.264, 30 fps); CRF erhöhen, bis es unter 3 MB passt
for (const crf of [20, 24, 28, 32]) {
ffmpeg(
'-ss',
String(start),
'-i',
webm,
'-t',
String(Math.min(length, 15)),
'-r',
'30',
'-c:v',
'libx264',
'-preset',
'slow',
'-crf',
String(crf),
'-pix_fmt',
'yuv420p',
'-movflags',
'+faststart',
'-an',
join(out, 'featuring.mp4'),
)
if (size('featuring.mp4') <= 3 * 1024 * 1024) break
}
// GIF (aus der Mitte, 1000×667, eigene Palette); Stufen probieren, bis ≤ 2 MB
for (const [fps, colors, secs] of [
[12, 96, 8],
[10, 64, 8],
[10, 64, 6],
[8, 48, 6],
]) {
ffmpeg(
'-ss',
String(start + 1),
'-t',
String(secs),
'-i',
webm,
'-vf',
`fps=${fps},scale=1000:667:flags=lanczos,split[a][b];[a]palettegen=max_colors=${colors}:stats_mode=diff[p];[b][p]paletteuse=dither=none`,
'-loop',
'0',
join(out, 'fallback.gif'),
)
if (size('fallback.gif') <= 2 * 1024 * 1024) break
}
// 4. Standbilder: fertiges Brett (Fallback) und Kachel mit Schriftzug
const still = await browser.newPage({viewport: {width: 1500, height: 1000}})
await still.goto(base)
await still.evaluate(() => document.fonts.ready)
await still.evaluate(() => window.capture.jump(999))
await still.waitForTimeout(300)
await still.screenshot({path: join(out, 'fallback.png')})
await still.close()
const tile = await browser.newPage({viewport: {width: 1120, height: 512}})
await tile.goto(base)
await tile.evaluate(() => {
document.body.classList.add('tile')
return document.fonts.ready
})
await tile.evaluate(() => window.capture.jump(999))
await tile.waitForTimeout(300)
await tile.screenshot({path: join(site, 'tile@2x.png')})
await tile.close()
ffmpeg(
'-i',
join(site, 'tile@2x.png'),
'-vf',
'scale=560:256:flags=lanczos',
join(out, 'tile.png'),
)
console.log(`Fertig in ${out}:`)
for (const f of ['featuring.mp4', 'fallback.gif', 'fallback.png', 'tile.png'])
console.log(` ${f} ${kb(f)}`)
console.log(` Videolänge ≈ ${Math.min(length, 15).toFixed(1)} s`)
} finally {
await browser.close()
server.close()
}