#!/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() }