Zum Inhalt

scripts/render-sequence.mjs

// Deterministic PNG image sequence from the engine, served by this script's own Vite dev server:
//   node scripts/render-sequence.mjs --scene city --preset kuro --seed 7 --seconds 15 --fps 24
//   node scripts/render-sequence.mjs --scene city --crash forward --seconds 1.6
//   node scripts/render-sequence.mjs --scene city --seconds 2 --check   # render twice, compare
//
// How it is deterministic without touching the engine: Playwright's page.clock fakes Date,
// performance.now, timers and requestAnimationFrame (paused before the page loads, advanced by
// exactly 1000/fps ms per frame); Math.random is replaced by a seeded generator before the page
// loads; ?seed= pins settings.seedLock; ?hud=off and ?ping=off keep readouts, control bar, kanji
// and the radar ping out of the picture; the compositor's animation clock is frozen (CDP) and every
// CSS transition is seeked to the faked time per frame. Each stage was measured against two runs
// (see the commit history and ANGEBOT.md). The dev server is started here (DEV build: the crash
// hook window.__kuro exists only there) on a free port, so no foreign server can be captured.
// Output: <out>/frame-NNNN.png + bildfolge.json (written only after the last frame).
import { chromium } from 'playwright';
import { createServer } from 'vite';
import { execFileSync } from 'node:child_process';
import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parseArgs, frameName, pageUrl, reverseFrames, buildManifest, compareManifests, bewerteDifferenzen, darfGeleertWerden, dirtyFromPorcelain, seededRandomSource, sha256 } from './lib/bildfolge.mjs';

const REPO = resolve(dirname(fileURLToPath(import.meta.url)), '..');

function fail(msg, code = 2) { console.error(`error: ${msg}`); process.exit(code); }

let opts;
try { opts = parseArgs(process.argv.slice(2)); } catch (e) { fail(e.message); }

// Provenance: git runs in the repo, not in whatever directory the script was started from.
const porcelain = execFileSync('git', ['status', '--porcelain'], { cwd: REPO, encoding: 'utf8' });
const unsauber = dirtyFromPorcelain(porcelain);
if (unsauber.length && !opts.allowDirty) fail(`working tree is dirty, provenance would not describe the build:\n  ${unsauber.join('\n  ')}\nCommit first, or pass --allow-dirty for a measurement run.`);
const commit = execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO, encoding: 'utf8' }).trim();
const playwrightVersion = JSON.parse(readFileSync(join(REPO, 'node_modules/playwright/package.json'), 'utf8')).version;

// Everything that can refuse does so before a browser or server starts.
const outDirs = opts.check ? [resolve(opts.out + '-check-a'), resolve(opts.out + '-check-b')] : [resolve(opts.out)];
for (const d of outDirs) {
  const entries = existsSync(d) ? readdirSync(d) : null;
  if (!darfGeleertWerden(entries)) fail(`refusing to clear ${d}: it holds files that are not ours (${entries.slice(0, 5).join(', ')}${entries.length > 5 ? ', …' : ''}). Pick an empty --out.`);
}
if (opts.check) {
  try { execFileSync('ffmpeg', ['-version'], { stdio: 'ignore' }); } catch { fail('--check needs ffmpeg in PATH (pixel comparison); install it or run without --check'); }
}

/**
 * Bind CSS transitions/animations to the faked clock. page.clock fakes timers and rAF, but the
 * compositor drives CSS transitions by wall time — the crash sequence (collapse line 210 ms, flashes
 * 40/70 ms) differed between runs until the compositor clock was frozen (Animation.setPlaybackRate 0)
 * and each frame seeks every animation to the elapsed fake time since it was first seen.
 */
async function syncAnimations(page, fakeMs) {
  await page.evaluate((now) => {
    // Force a style flush first: a transition created inside the last tick does not exist until
    // styles are recalculated, and whether that happened before this call depended on real time.
    void document.documentElement.offsetHeight;
    for (const a of document.getAnimations()) {
      if (a.__fakeStart === undefined) a.__fakeStart = now;   // playback rate is 0 (CDP), nothing moved since it started
      const t = now - a.__fakeStart;
      const end = a.effect?.getComputedTiming?.().endTime ?? Infinity;
      if (t >= end) a.finish(); else a.currentTime = t;
    }
  }, fakeMs);
}

/** CDP virtual time: advance by `ms`, resolve when the budget is spent. */
async function runVirtual(cdp, ms) {
  await new Promise((resolve, reject) => {
    cdp.once('Emulation.virtualTimeBudgetExpired', resolve);
    cdp.send('Emulation.setVirtualTimePolicy', { policy: 'advance', budget: ms }).catch(reject);
  });
}

async function capture(outDir, base) {
  rmSync(outDir, { recursive: true, force: true });
  mkdirSync(outDir, { recursive: true });
  const stepMs = 1000 / opts.fps;
  const total = Math.max(1, Math.round(opts.seconds * opts.fps));
  const frames = [];
  // metal: headful, real GPU (same as render-proof.mjs). swiftshader: headless software rasterizer —
  // slower, no window on the desktop, measured less reproducible (1 of 48 frames identical).
  const browser = opts.renderer === 'metal'
    ? await chromium.launch({ headless: false, args: ['--use-gl=angle', '--use-angle=metal', '--ignore-gpu-blocklist'] })
    : await chromium.launch({ headless: true, args: ['--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', '--ignore-gpu-blocklist'] });
  const chromiumVersion = browser.version();
  try {
    const context = await browser.newContext({ viewport: { width: opts.width, height: opts.height }, deviceScaleFactor: 1, reducedMotion: 'no-preference' });
    const page = await context.newPage();
    await page.addInitScript(seededRandomSource(opts.seed));
    // Freeze the compositor's animation clock from the start: a CSS transition begins in real time the
    // moment a style changes, and a 40 ms flash could finish before syncAnimations() ever sees it.
    const cdp = await context.newCDPSession(page);
    await cdp.send('Animation.enable');
    await cdp.send('Animation.setPlaybackRate', { playbackRate: 0 });
    const url = pageUrl(opts, base);
    if (opts.clock === 'page') {
      // pauseAt() JUMPS to the given time: between install() and pauseAt() a few real milliseconds pass,
      // so pausing "now" would leave each run on a slightly different clock. Jumping 100 ms ahead lands
      // every run on the same one.
      const t0 = new Date('2026-01-01T00:00:00Z');
      await page.clock.install({ time: t0 });
      await page.clock.pauseAt(new Date(t0.getTime() + 100));
      await page.goto(url, { waitUntil: 'load' });
    } else {
      await page.goto(url, { waitUntil: 'load' });
    }
    // Let fetches (textures, fonts, modules) land before the first fake tick: an asset that arrives
    // mid-warmup shifts object creation and with it every Math.random consumer after it.
    await page.waitForLoadState('networkidle');
    await page.waitForTimeout(1000);
    const warmupMs = Math.round(opts.warmup * 1000);
    if (opts.clock === 'page') {
      // Even with the clock paused, performance.now() reads 0 or 1 ms after load depending on the run
      // (measured: 1, 1, 0 in three headful runs). One millisecond moves timer and rAF boundaries and
      // made the crash diverge in two checks of three. Align every run to 2 ms, and refuse to go on if
      // that fails — a manifest from a misaligned run would promise a determinism it cannot have.
      const p = await page.evaluate(() => performance.now());
      await page.clock.runFor(Math.max(0, 2 - Math.round(p)));
      const p2 = await page.evaluate(() => performance.now());
      if (Math.round(p2) !== 2) throw new Error(`performance.now() is ${p2} after alignment (expected 2); the run would not be reproducible`);
      await page.clock.runFor(warmupMs);
    } else {
      await runVirtual(cdp, warmupMs);
    }
    if (opts.crash !== 'none') {
      const ok = await page.evaluate(() => { const c = window.__kuro; if (!c?.crt?.playCrash) return false; void c.crt.playCrash(); return true; });
      if (!ok) throw new Error('window.__kuro.crt.playCrash not available — the page is not the DEV build');
    }
    await syncAnimations(page, warmupMs);
    for (let i = 0; i < total; i++) {
      if (i > 0) { if (opts.clock === 'page') await page.clock.runFor(stepMs); else await runVirtual(cdp, stepMs); }
      await syncAnimations(page, warmupMs + i * stepMs);
      const png = await page.screenshot({ type: 'png', animations: 'allow', caret: 'hide' });
      // The screenshot forces a rendering opportunity, and that is when finished transitions dispatch
      // their events. Give those tasks a moment of real time before the next fake tick.
      await page.waitForTimeout(25);
      const file = frameName(i);
      writeFileSync(join(outDir, file), png);
      frames.push({ file, t: Number((i * stepMs / 1000).toFixed(4)), sha256: sha256(png) });
      if (i % 24 === 0) process.stdout.write(`\r  frame ${i + 1}/${total}`);
    }
    process.stdout.write('\n');
  } finally {
    await browser.close();
  }
  let out = frames;
  if (opts.crash === 'reverse') {
    out = reverseFrames(frames);
    const tmp = join(outDir, '.rev'); mkdirSync(tmp);
    for (const f of out) writeFileSync(join(tmp, f.file), readFileSync(join(outDir, f.source)));
    for (const f of frames) rmSync(join(outDir, f.file));
    for (const f of out) writeFileSync(join(outDir, f.file), readFileSync(join(tmp, f.file)));
    rmSync(tmp, { recursive: true });
  }
  const manifest = buildManifest({ ...opts, seconds: total / opts.fps, base }, out, { commit, unsauber, playwright: playwrightVersion, chromium: chromiumVersion });
  writeFileSync(join(outDir, 'bildfolge.json'), JSON.stringify(manifest, null, 2) + '\n');
  return manifest;
}

/** Per-frame pixel difference between two runs via ffmpeg: max absolute 8-bit difference over R, G and B. */
function pixelDiff(fileA, fileB) {
  const raw = execFileSync('ffmpeg', ['-hide_banner', '-loglevel', 'error', '-i', fileA, '-i', fileB, '-filter_complex', '[0:v][1:v]blend=all_mode=difference,format=rgb24', '-f', 'rawvideo', '-pix_fmt', 'rgb24', '-'], { maxBuffer: 1 << 28 });
  let max = 0;
  for (const b of raw) if (b > max) max = b;
  return { max };
}

// This script serves the page itself: a dev server already on :5173 could belong to another project
// or another checkout, and the manifest would still name this repo's commit as the source.
const server = await createServer({ root: REPO, configFile: join(REPO, 'vite.config.ts'), logLevel: 'error', server: { port: 5199, strictPort: false, host: '127.0.0.1' } });
await server.listen();
const base = server.resolvedUrls?.local?.[0]?.replace(/\/$/, '') ?? `http://127.0.0.1:${server.config.server.port}`;
try {
  if (opts.check) {
    const [dirA, dirB] = outDirs;
    const a = await capture(dirA, base);
    const b = await capture(dirB, base);
    const hashes = compareManifests(a, b);
    const differing = new Set(hashes.verschieden);
    const diffs = a.frames.map((f) => ({ file: f.file, hashGleich: !differing.has(f.file), ...pixelDiff(join(dirA, f.file), join(dirB, f.file)) }));
    const v = bewerteDifferenzen(diffs);
    console.log(`check: ${hashes.anzahl} frames, ${v.identisch} byte-identical, max pixel difference ${v.max}/255 -> ${v.stufe}${v.abweichend.length ? ' (' + v.abweichend.slice(0, 8).join(', ') + ')' : ''}`);
    process.exitCode = v.ok ? 0 : 1;
  } else {
    const m = await capture(outDirs[0], base);
    console.log(`${m.frames.length} frames (${opts.width}x${opts.height}, ${opts.fps} fps, ${m.seconds} s, clock ${opts.clock}, crash ${opts.crash}) -> ${outDirs[0]}/bildfolge.json`);
    console.log('Now LOOK at the frames — a hash proves sameness, not that the scene is there.');
  }
} finally {
  await server.close();
}