diff --git a/oven/README.md b/oven/README.md index 92e0682a86..a6a554a603 100644 --- a/oven/README.md +++ b/oven/README.md @@ -218,6 +218,21 @@ Tars the directory (minus build artifacts), uploads it, streams the log, and writes the PDF next to the source. Reads `OVEN_URL` (default `https://oven.aesthetic.computer`) and `OS_BUILD_ADMIN_KEY` (else the vault). +### Oskiewar Replay Oven + +`POST /oskiewar-reel` accepts `{day,index,ref,theme}` under the Oven admin key. +It checks out the exact Git commit, runs a latest-build bot fight, renders its +replay as a fixed-step 60 Hz image sequence, and applies media, sync, and motion +gates. It never publishes. + +- `GET /oskiewar-reel` — active and recent jobs +- `GET /oskiewar-reel/:id?logs=1` — status and logs +- `GET /oskiewar-reel/:id/{reel|cover|thumbnail|sidecar}` — artifacts +- `POST /oskiewar-reel/:id/cancel` — cancel + +Submit with `npm run oskiewar:oven -- --day YYYY-MM-DD --index 0`. Artifacts +land in the normal local Reel queue for review and publication. + ## Deployment Strategy ### Following Existing Patterns diff --git a/oven/deploy.fish b/oven/deploy.fish index 8c18383a47..2f9cbb3c35 100755 --- a/oven/deploy.fish +++ b/oven/deploy.fish @@ -221,6 +221,7 @@ scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/baker. scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/grabber.mjs root@$DROPLET_IP:/opt/oven/ scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/recap-builder.mjs root@$DROPLET_IP:/opt/oven/ scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/recap-git-poller.mjs root@$DROPLET_IP:/opt/oven/ +scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SCRIPT_DIR/oskiewar-reel-builder.mjs root@$DROPLET_IP:/opt/oven/ scp -i "$HOME/.ssh/$SSH_KEY_NAME" -o StrictHostKeyChecking=no $SERVICE_ENV root@$DROPLET_IP:/opt/oven/.env # Update .env for production diff --git a/oven/oskiewar-reel-builder.mjs b/oven/oskiewar-reel-builder.mjs new file mode 100644 index 0000000000..7c057e67b0 --- /dev/null +++ b/oven/oskiewar-reel-builder.mjs @@ -0,0 +1,148 @@ +// Remote Oskiewar Reel jobs for Oven. +// +// A job checks out an immutable commit from origin/main, runs the latest bot +// fight through the fixed-step Replay Oven, and exposes review artifacts. It +// never holds Instagram credentials and can never publish. + +import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; +import { promises as fs } from "node:fs"; +import path from "node:path"; + +const GIT_REPO_DIR = process.env.NATIVE_GIT_DIR || "/opt/oven/native-git"; +const WORK_ROOT = process.env.OSKIEWAR_REEL_WORK_DIR || "/tmp/oven-oskiewar-reels"; +const MAX_RECENT = 20; +const MAX_LOGS = 4000; +const jobs = new Map(); +const order = []; +let activeJobId = null; + +const now = () => new Date().toISOString(); +const run = (command, args, options = {}) => new Promise((resolve, reject) => { + const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"], ...options }); + let stdout = "", stderr = ""; + child.stdout.on("data", (chunk) => { stdout += chunk; options.onLine?.("stdout", chunk); }); + child.stderr.on("data", (chunk) => { stderr += chunk; options.onLine?.("stderr", chunk); }); + child.on("error", reject); + child.on("close", (code) => code === 0 ? resolve(stdout.trim()) + : reject(new Error((stderr || stdout || `${command} exited ${code}`).trim()))); + options.onProcess?.(child); +}); + +function log(job, stream, chunk) { + for (const raw of String(chunk).split(/\r?\n/)) { + const line = raw.trimEnd(); + if (!line) continue; + job.logs.push({ ts: now(), stream, line }); + if (job.logs.length > MAX_LOGS) job.logs.splice(0, job.logs.length - MAX_LOGS); + const progress = line.match(/offline replay (\d+)\/(\d+) exact frames/); + if (progress) { + job.stage = "offline-render"; + job.percent = 25 + Math.round(Number(progress[1]) / Number(progress[2]) * 60); + } else if (line.includes("audio tee")) { job.stage = "bot-fight"; job.percent = 15; } + else if (line.includes("meta spec")) { job.stage = "verify"; job.percent = 92; } + job.updatedAt = now(); + } +} + +function snapshot(job, includeLogs = false) { + return { + id: job.id, day: job.day, index: job.index, ref: job.ref, + resolvedRef: job.resolvedRef, status: job.status, stage: job.stage, + percent: job.percent, createdAt: job.createdAt, startedAt: job.startedAt, + updatedAt: job.updatedAt, finishedAt: job.finishedAt, error: job.error, + reelId: job.reelId, files: job.files, ...(includeLogs ? { logs: job.logs } : {}), + }; +} + +async function findResult(queue) { + const entries = await fs.readdir(queue, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const sidecar = path.join(queue, entry.name, "reel.json"); + try { + const record = JSON.parse(await fs.readFile(sidecar, "utf8")); + return { record, dir: path.dirname(sidecar) }; + } catch {} + } + throw new Error("Reel factory finished without a reel.json artifact"); +} + +async function execute(job) { + const root = path.join(WORK_ROOT, job.id); + const source = path.join(root, "source"); + const queue = path.join(root, "queue"); + try { + job.status = "running"; job.stage = "checkout"; job.percent = 2; + job.startedAt = now(); job.updatedAt = job.startedAt; + await fs.mkdir(root, { recursive: true }); + await run("git", ["fetch", "origin", "main", "--quiet"], { cwd: GIT_REPO_DIR }); + job.resolvedRef = await run("git", ["rev-parse", `${job.ref}^{commit}`], { cwd: GIT_REPO_DIR }); + await run("git", ["worktree", "add", "--detach", source, job.resolvedRef], + { cwd: GIT_REPO_DIR }); + job.stage = "bot-fight"; job.percent = 8; + await run(process.execPath, ["xbox/live/marketing/reel.mjs", + "--day", job.day, "--index", String(job.index), "--slots-per-day", "3", + "--no-replays", "--out", queue, "--theme", job.theme], { + cwd: source, env: { ...process.env, TERM: "dumb", FORCE_COLOR: "0" }, + onLine: (stream, chunk) => log(job, stream, chunk), + onProcess: (process) => { job.process = process; }, + }); + job.process = null; + const { record, dir } = await findResult(queue); + if (!record.meta?.ok || !record.sync?.ok || !record.motion?.ok) + throw new Error("remote artifact failed media, sync, or fixed-step motion gate"); + if (record.sourceCommit !== job.resolvedRef) + throw new Error(`artifact commit ${record.sourceCommit} does not match job ${job.resolvedRef}`); + job.reelId = record.id; + job.files = { reel: path.join(dir, "reel.mp4"), cover: path.join(dir, "cover.jpg"), + thumbnail: path.join(dir, "thumbnail-10-percent.jpg"), sidecar: path.join(dir, "reel.json") }; + job.status = "success"; job.stage = "done"; job.percent = 100; + } catch (error) { + job.status = job.status === "cancelled" ? "cancelled" : "failed"; + job.stage = job.status; job.error = error.message || String(error); + } finally { + job.process = null; job.finishedAt = now(); job.updatedAt = job.finishedAt; + await run("git", ["worktree", "remove", "--force", source], { cwd: GIT_REPO_DIR }) + .catch(() => {}); + if (activeJobId === job.id) activeJobId = null; + } +} + +export function startOskiewarReel(options = {}) { + const day = String(options.day || new Date().toISOString().slice(0, 10)); + const index = Number(options.index ?? 0); + const ref = String(options.ref || "origin/main"); + const theme = options.theme === "dark" ? "dark" : "light"; + if (!/^\d{4}-\d{2}-\d{2}$/.test(day)) throw Object.assign(new Error("invalid day"), { code: "BAD_REEL_JOB" }); + if (!Number.isInteger(index) || index < 0 || index > 2) + throw Object.assign(new Error("index must be 0, 1, or 2"), { code: "BAD_REEL_JOB" }); + if (!/^[A-Za-z0-9_./-]+$/.test(ref)) throw Object.assign(new Error("invalid git ref"), { code: "BAD_REEL_JOB" }); + if (activeJobId) throw Object.assign(new Error(`Oskiewar Reel job already running: ${activeJobId}`), + { code: "REEL_JOB_BUSY", activeJobId }); + const id = randomUUID().slice(0, 10); + const job = { id, day, index, ref, theme, resolvedRef: null, status: "queued", + stage: "queued", percent: 0, createdAt: now(), startedAt: null, + updatedAt: now(), finishedAt: null, error: null, reelId: null, + files: null, logs: [], process: null }; + jobs.set(id, job); order.unshift(id); activeJobId = id; + while (order.length > MAX_RECENT) jobs.delete(order.pop()); + execute(job).catch(() => {}); + return snapshot(job); +} + +export const getOskiewarReel = (id, includeLogs = false) => + jobs.has(id) ? snapshot(jobs.get(id), includeLogs) : null; +export const getOskiewarReels = () => ({ activeJobId, + active: activeJobId ? snapshot(jobs.get(activeJobId)) : null, + recent: order.map((id) => snapshot(jobs.get(id))) }); +export function getOskiewarReelFile(id, name) { + const job = jobs.get(id); + return job?.status === "success" && job.files?.[name] || null; +} +export function cancelOskiewarReel(id) { + const job = jobs.get(id); + if (!job?.process || job.status !== "running") return { ok: false, error: "not running" }; + job.status = "cancelled"; job.process.kill("SIGTERM"); + return { ok: true }; +} diff --git a/oven/server.mjs b/oven/server.mjs index 81d2342036..6ffc74eaf3 100644 --- a/oven/server.mjs +++ b/oven/server.mjs @@ -24,6 +24,8 @@ import { startPapersBuild, getPapersBuild, getPapersBuildsSummary, cancelPapersB import { startPoller as startPapersGitPoller, getPollerStatus as getPapersPollerStatus } from './papers-git-poller.mjs'; import { startPaperCrunch, getPaperCrunch, getPaperCrunchesSummary, getPaperCrunchPdf, cancelPaperCrunch, CRUNCH_LIMITS } from './paper-crunch.mjs'; import { startRecapBuild, getRecapBuild, getRecapBuildsSummary, cancelRecapBuild, getRecapMp4Path } from './recap-builder.mjs'; +import { startOskiewarReel, getOskiewarReel, getOskiewarReels, + getOskiewarReelFile, cancelOskiewarReel } from './oskiewar-reel-builder.mjs'; import { startPoller as startRecapGitPoller, getPollerStatus as getRecapPollerStatus } from './recap-git-poller.mjs'; import { join, dirname, basename } from 'path'; import { fileURLToPath } from 'url'; @@ -4132,6 +4134,48 @@ app.post('/recap-build/:jobId/cancel', requireOSBuildAdmin, (req, res) => { return res.json(result); }); +// ── Oskiewar Replay Oven ────────────────────────────────────────────────── +// Remote-only rendering. Oven receives a date/slot/ref, checks out that exact +// commit, runs a latest-build bot fight, and returns gated review artifacts. +// Instagram credentials and publication remain on the operator machine. +app.get('/oskiewar-reel', requireOSBuildAdmin, (req, res) => + res.json(getOskiewarReels())); + +app.get('/oskiewar-reel/:jobId', requireOSBuildAdmin, (req, res) => { + const job = getOskiewarReel(req.params.jobId, + req.query.logs === '1' || req.query.logs === 'true'); + if (!job) return res.status(404).json({ error: 'Job not found' }); + return res.json(job); +}); + +app.get('/oskiewar-reel/:jobId/:artifact(reel|cover|thumbnail|sidecar)', + requireOSBuildAdmin, (req, res) => { + const file = getOskiewarReelFile(req.params.jobId, req.params.artifact); + if (!file) return res.status(404).json({ error: 'Artifact not available' }); + const type = req.params.artifact === 'reel' ? 'video/mp4' + : req.params.artifact === 'sidecar' ? 'application/json' : 'image/jpeg'; + res.type(type).sendFile(file); + }); + +app.post('/oskiewar-reel', requireOSBuildAdmin, (req, res) => { + try { + const job = startOskiewarReel(req.body || {}); + addServerLog('info', '🥊', `Oskiewar Reel job ${job.id} · ${job.day} #${job.index}`); + return res.status(202).json(job); + } catch (error) { + if (error.code === 'REEL_JOB_BUSY') + return res.status(409).json({ error: error.message, activeJobId: error.activeJobId }); + if (error.code === 'BAD_REEL_JOB') return res.status(400).json({ error: error.message }); + return res.status(500).json({ error: error.message }); + } +}); + +app.post('/oskiewar-reel/:jobId/cancel', requireOSBuildAdmin, (req, res) => { + const result = cancelOskiewarReel(req.params.jobId); + if (!result.ok) return res.status(400).json(result); + return res.json(result); +}); + // ── OS Release Upload ────────────────────────────────────────────────────── // Accepts a vmlinuz binary + metadata, uploads to DO Spaces as OTA release. // Auth: AC token (Bearer) verified against Auth0 userinfo. diff --git a/package.json b/package.json index 7e983aedf6..51da15e9c2 100644 --- a/package.json +++ b/package.json @@ -37,6 +37,7 @@ "instagram:accounts": "node toolchain/instagram/ig.mjs accounts", "instagram:whistlegraph": "node toolchain/instagram/whistlegraph-ig.mjs", "instagram:aesthetic": "node toolchain/instagram/aesthetic-ig.mjs", + "oskiewar:oven": "node toolchain/instagram/oskiewar-oven.mjs", "xbox:test:oskiewar:blackbox": "node xbox/live/tests/blackbox-rounds.mjs", "xbox:burn:oskiewar-social": "node xbox/live/render-social-preview.mjs", "xbox:check:oskiewar-social": "node xbox/live/render-social-preview.mjs --check", diff --git a/toolchain/instagram/oskiewar-oven.mjs b/toolchain/instagram/oskiewar-oven.mjs new file mode 100644 index 0000000000..ed6fa659c0 --- /dev/null +++ b/toolchain/instagram/oskiewar-oven.mjs @@ -0,0 +1,73 @@ +#!/usr/bin/env node +// Submit one latest-build bot fight to the remote Replay Oven and download the +// gated artifacts. Publication is intentionally a separate local command. + +import { execFileSync } from "node:child_process"; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { join, resolve } from "node:path"; + +const OVEN = (process.env.OVEN_URL || "https://oven.aesthetic.computer").replace(/\/$/, ""); +const argv = process.argv.slice(2); +const value = (name, fallback) => { + const at = argv.indexOf(`--${name}`); + return at >= 0 ? argv[at + 1] : fallback; +}; +const day = value("day", new Date().toISOString().slice(0, 10)); +const index = Number(value("index", "0")); +const ref = value("ref", "origin/main"); +const theme = value("theme", "light"); +const outRoot = resolve(value("out", "tmp/oskiewar-reels/queue")); + +function adminKey() { + if (process.env.OS_BUILD_ADMIN_KEY?.trim()) return process.env.OS_BUILD_ADMIN_KEY.trim(); + const plain = join(homedir(), "aesthetic-computer-vault", "oven", "os-build-admin-key.txt"); + if (existsSync(plain)) return readFileSync(plain, "utf8").trim(); + if (existsSync(`${plain}.gpg`)) { + try { + return execFileSync("gpg", ["--pinentry-mode", "loopback", "-d", `${plain}.gpg`], + { encoding: "utf8", stdio: ["inherit", "pipe", "ignore"] }).trim(); + } catch {} + } + throw new Error("Oven admin key is unavailable"); +} + +const key = adminKey(); +async function ask(path, init = {}) { + const response = await fetch(`${OVEN}${path}`, { ...init, + headers: { authorization: `Bearer ${key}`, ...(init.headers || {}) } }); + if (!response.ok) { + const body = await response.json().catch(() => ({})); + throw new Error(body.error || `Oven returned HTTP ${response.status}`); + } + return response; +} + +const started = await ask("/oskiewar-reel", { method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ day, index, ref, theme }) }); +let job = await started.json(); +console.log(`🥊 Oven job ${job.id} · ${day} #${index} · ${ref}`); + +let last = ""; +while (!["success", "failed", "cancelled"].includes(job.status)) { + await new Promise((done) => setTimeout(done, 2000)); + job = await (await ask(`/oskiewar-reel/${job.id}`)).json(); + const line = `${job.stage} ${job.percent}%`; + if (line !== last) { console.log(` ${line}`); last = line; } +} +if (job.status !== "success") throw new Error(job.error || `Oven job ${job.status}`); + +const dir = join(outRoot, job.reelId); +mkdirSync(dir, { recursive: true }); +for (const [name, file] of Object.entries({ reel: "reel.mp4", cover: "cover.jpg", + thumbnail: "thumbnail-10-percent.jpg", sidecar: "reel.json" })) { + const response = await ask(`/oskiewar-reel/${job.id}/${name}`); + writeFileSync(join(dir, file), Buffer.from(await response.arrayBuffer())); +} +const record = JSON.parse(readFileSync(join(dir, "reel.json"), "utf8")); +if (record.sourceCommit !== job.resolvedRef || !record.meta?.ok || + !record.sync?.ok || !record.motion?.ok) + throw new Error("downloaded Reel failed commit or quality verification"); +console.log(`✓ ${record.id} · ${record.motion.sourceFps} fixed-step source fps · ${record.sourceCommit.slice(0, 9)}`); +console.log(dir); diff --git a/xbox/live/MARKETING.md b/xbox/live/MARKETING.md index 2181c691c1..ecbd367b96 100644 --- a/xbox/live/MARKETING.md +++ b/xbox/live/MARKETING.md @@ -53,11 +53,14 @@ renderer is moved onto a fixed timestep. ### 2 · Render — the real game, at the real shape -Headless Chrome runs `mac-test.html` + `oskiewar.js` through the same frame driver -a player gets. Two halves, both borrowed from `marketing/av-reels`: - -- **Video** — CDP `Page.startScreencast` → timestamped JPEG frames, concatenated - with per-frame durations so playback is true speed. Measured 59.8–60.0 fps. +The remote Replay Oven runs `mac-test.html` + `oskiewar.js` from an immutable +`origin/main` commit. A latest-build bot fight produces the authoritative +replay and audio; then the replay is painted again through the same renderer at +an exact fixed 60 Hz. + +- **Video** — the live pass is never publication footage. Its replay is stepped + offline and captured once per simulation tick. A sparse browser screencast + can no longer be disguised as 60 fps by duplicated frames. - **Audio** — `AudioNode.prototype.connect` is patched before boot so anything routed to `ctx.destination` also tees into a `MediaStreamDestination` an in-page `MediaRecorder` records. This is the established AC technique; there @@ -140,19 +143,25 @@ The ledger records segment, seed, slot, kind, round, timestamp, media id, and later the retrieved insights — which is the whole reason a segment is recorded at all. `reel.mjs --report` rolls it up per market. +Remote production starts with `npm run oskiewar:oven -- --day YYYY-MM-DD +--index 0`. Oven checks out the requested commit, forces bot-only source, and +returns the mp4, cover, thumbnail, and sidecar. It has no Instagram credentials. +The local publish command remains the only road to Instagram. The motion gate +requires `fixed-step-60` and at least 59.5 source frames per second. + --- ## Measured throughput, and the slot grid it justifies -On one M-series laptop, Chrome headless, nothing else running: +Latest fixed-step proof on an M-series laptop, Chrome headless: | | | |---|---| -| Capture rate | **59.8–60.0 fps** at 1080×1920 (median frame gap 16.70 ms), encoded at 60 CFR | -| Reel length | one full round — **35–40 s** | -| Render wall clock | **~107 s** per reel, warm-up round included | -| Ratio | **2.4–3.0× realtime** | -| Output | 1080×1920, H.264, AAC 127 kbps / 48 kHz, ~5 MB | +| Source cadence | **60 fixed-step frames/s** at 1080×1920 | +| Proof Reel | 11.2 s, 674 source frames | +| Render wall clock | 161 s, including live bot fight, offline replay, and encode | +| Audio sync | 23/23 onsets, 4 ms median skew, 30 ms worst skew | +| Output | 1080×1920, H.264/AAC, 1.6 MB | **Three slots a day.** The defence is arithmetic, not vibes: diff --git a/xbox/live/frame-driver.mjs b/xbox/live/frame-driver.mjs index 010ec89f4b..62103eded9 100644 --- a/xbox/live/frame-driver.mjs +++ b/xbox/live/frame-driver.mjs @@ -25,6 +25,7 @@ export function createFrameDriver({ let simulationTime = 0; let timerHandle = null; let rafHandle = null; + let offlineStarted = false; const stats = { simulationFps, renderFrames: 0, @@ -107,6 +108,27 @@ export function createFrameDriver({ return { stats, + // Deterministic capture lane. The caller advances exactly one simulation + // tick and one paint per invocation; no rAF/timer or wall-clock catch-up + // participates. This is deliberately unavailable while the live driver + // is running. + stepOffline() { + if (running) throw new Error("cannot step offline while frame driver is running"); + if (!offlineStarted) { + offlineStarted = true; + stats.startedAt = now(); + simulationTime = stats.startedAt - interval; + sampleInput(); + } + simulationTime += interval; + runSimulation(false); + const started = now(); + paint(simulationTime, 0); + stats.renderFrames++; + stats.lastRenderAt = simulationTime; + stats.lastRenderCostMs = Math.max(0, now() - started); + return { frame: stats.renderFrames, simulationTime }; + }, start() { if (running) return; running = true; diff --git a/xbox/live/mac-test.html b/xbox/live/mac-test.html index 03111c82fc..1f5c49daa5 100644 --- a/xbox/live/mac-test.html +++ b/xbox/live/mac-test.html @@ -1096,12 +1096,17 @@ }, }); globalThis.__oskiewarFrameStats = driver.stats; + const offlineRender = new URLSearchParams(location.search).has("offline-render"); + if (offlineRender) { + globalThis.__oskiewarOfflineStep = () => driver.stepOffline(); + globalThis.__oskiewarOfflineReady = true; + } addEventListener("visibilitychange", () => driver.setVisible(document.visibilityState === "visible")); addEventListener("pointermove", syncSelectionCursor, { passive: true }); addEventListener("pointerleave", () => document.body.classList.remove("selection-hover"), { passive: true }); - driver.start(); + if (!offlineRender) driver.start(); } start().catch((error) => { console.error(error); diff --git a/xbox/live/marketing/reel.mjs b/xbox/live/marketing/reel.mjs index 8f2d667c0e..f6722767f6 100644 --- a/xbox/live/marketing/reel.mjs +++ b/xbox/live/marketing/reel.mjs @@ -14,6 +14,7 @@ // card — trimmed at both ends, uncut in between. See MARKETING.md for the // measured cost per reel and the slot grid it justifies. +import { execFileSync } from "node:child_process"; import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync } from "node:fs"; import { join, resolve } from "node:path"; import { pickSource, seed32 } from "./source.mjs"; @@ -37,6 +38,12 @@ export const staging = resolve(flags.out || join(repo, "tmp/oskiewar-reels/queue")); const slotsPerDay = Number(flags["slots-per-day"] || 3); const log = console.log; +const sourceCommit = (() => { + try { + return execFileSync("git", ["rev-parse", "HEAD"], + { cwd: repo, encoding: "utf8" }).trim(); + } catch { return "unknown"; } +})(); function listQueue() { if (!existsSync(staging)) return []; @@ -78,8 +85,8 @@ async function buildSlot(day, index) { mkdirSync(work, { recursive: true }); const render = await bakeReplay({ ...spec, out: work }, { log }); - // The capture *is* the reel — nothing is drawn on it and nothing rescales - // it, so it moves into place rather than being encoded a second time. + // Replay Oven already returned the fixed-step delivery master; dressing adds + // no pixels, so it moves into place without another encode. const reel = join(dir, "reel.mp4"); renameSync(render.base, reel); const poster = cover(reel, join(dir, "cover.jpg")); @@ -88,6 +95,13 @@ async function buildSlot(day, index) { // The feedback loop: the game stamped every sound it asked for; measure // what the encoded file actually plays and hold the two together. const sync = verifySync(reel, render.signals || []); + const motion = { + mode: render.frameCadence || "unknown", + frames: render.frames, + seconds: render.seconds, + sourceFps: render.seconds > 0 ? +(render.frames / render.seconds).toFixed(2) : 0, + }; + motion.ok = motion.mode === "fixed-step-60" && motion.sourceFps >= 59.5; log(` sync ${sync.ok ? "✓" : "✗"} · ${sync.expectedSignals} signals expected` + ` · ${sync.matchedOnsets}/${sync.heardOnsets} onsets` + ` · median ${sync.medianSkew ?? "—"}s · worst ${sync.worstSkew ?? "—"}s` + @@ -98,13 +112,14 @@ async function buildSlot(day, index) { if (!render.complete) log(` ⚠ the match never finished inside the ${spec.cap}s cap — fragment`); - const record = { ...spec, builtAt: new Date().toISOString(), + const record = { ...spec, sourceCommit, builtAt: new Date().toISOString(), render: { wall: render.wall, frames: render.frames, + liveFrames: render.liveFrames, frameCadence: render.frameCadence, seconds: render.seconds, hasAudio: render.hasAudio, matches: render.matches, rounds: render.rounds, complete: render.complete, replayPostsSwallowed: render.replayPosts }, files: { reel, cover: poster, thumbnail: tenth }, - meta: spec1080, sync, signals: render.signals || [] }; + meta: spec1080, sync, motion, signals: render.signals || [] }; writeSidecar(join(dir, "reel.json"), record); log(`${spec1080.ok ? "✓" : "✗"} ${spec.id} · ${spec1080.width}×${spec1080.height} · ` + `${Math.floor(spec1080.seconds / 60)}m${String(Math.round(spec1080.seconds % 60)) @@ -112,6 +127,7 @@ async function buildSlot(day, index) { `${render.rounds.length} rounds · meta spec ${spec1080.ok ? "pass" : "FAIL"}`); if (!spec1080.ok) for (const [name, check] of Object.entries(spec1080.checks)) if (!check.ok) log(` ✗ ${name}: ${check.value}`); + if (!motion.ok) log(` ✗ motion: ${motion.mode} · ${motion.sourceFps} source fps`); return record; } @@ -119,6 +135,8 @@ async function buildSlot(day, index) { // and the cron's --auto. Uploads to Spaces, runs Meta's three-step sequence, // and writes the ledger — the record insights get hung on later. async function goLive(record) { + if (!record.meta?.ok || !record.sync?.ok || !record.motion?.ok) + throw new Error(`${record.id} is not publishable: media, sync, and fixed-step motion must all pass`); const paths = { reel: record.files.reel, cover: record.files.cover }; const bucket = process.env.OSKIEWAR_SPACES_BUCKET || "art-aesthetic-computer"; const urls = await uploadPublic(paths, @@ -180,9 +198,9 @@ async function main() { // on 2026-08-09 after approving the pipeline reel by reel. if (flags.auto) { for (const record of built) { - if (!record.meta.ok || !record.sync?.ok) { + if (!record.meta.ok || !record.sync?.ok || !record.motion?.ok) { log(`⛔ ${record.id} held for review — ` + - `${!record.meta.ok ? "spec" : "sync"} gate failed`); + `${!record.meta.ok ? "spec" : !record.sync?.ok ? "sync" : "motion"} gate failed`); continue; } const posted = await goLive(record); diff --git a/xbox/live/marketing/render.mjs b/xbox/live/marketing/render.mjs index e71a4b11e0..19a95865eb 100644 --- a/xbox/live/marketing/render.mjs +++ b/xbox/live/marketing/render.mjs @@ -73,6 +73,46 @@ async function loadPuppeteer() { return (await import(`${dir}/lib/esm/puppeteer/puppeteer.js`)).default; } +async function captureOfflineReplay({ browser, shell, demo, frames, width, + height, theme, seconds, log }) { + const round = String(demo?.roundName || demo?.matchName || "").replace(/^ow-/, ""); + if (!round) throw new Error("completed bot fight did not return a replay name"); + rmSync(frames, { recursive: true, force: true }); + mkdirSync(frames, { recursive: true }); + + const page = await browser.newPage(); + try { + await page.setViewport({ width, height, deviceScaleFactor: 1 }); + await page.emulateMediaFeatures([ + { name: "prefers-color-scheme", value: theme === "light" ? "light" : "dark" }]); + await page.evaluateOnNewDocument(() => { + globalThis.WebSocket = function () { + return { readyState: 3, send() {}, close() {}, + addEventListener() {}, removeEventListener() {} }; + }; + }); + await page.goto(`${shell.origin}/${round}?social-preview&replay-oven&offline-render`, + { waitUntil: "domcontentloaded", timeout: 45000 }); + await page.evaluate(() => document.fonts.ready); + await page.waitForFunction(() => globalThis.__oskiewarOfflineReady === true && + globalThis.__oskiewarReplayReady === true, { timeout: 15000 }); + + const total = Math.max(1, Math.ceil(seconds * 60)); + const stamps = []; + for (let index = 0; index < total; index++) { + await page.evaluate(() => globalThis.__oskiewarOfflineStep()); + const file = `frame-${String(index).padStart(5, "0")}.jpg`; + await page.screenshot({ path: join(frames, file), type: "jpeg", quality: 92 }); + stamps.push({ file, at: index / 60 }); + if ((index + 1) % 120 === 0 || index + 1 === total) + log(` offline replay ${index + 1}/${total} exact frames`); + } + return stamps; + } finally { + await page.close(); + } +} + // A fight worth watching, spelled in the keys a person actually holds. // `oskiewar.js` reads pad one from W/A/S/D + F(kick) G(shield) H(punch) V(item), // so the score is legible against the on-screen legend. @@ -350,13 +390,21 @@ export async function renderReel(spec, { log = console.log } = {}) { log(` ${stamps.length} frames over ${captured.seconds.toFixed(1)}s · audio ${ haveAudio ? (captured.audioBytes / 1000).toFixed(0) + "KB" : "MISSING"}`); - // Per-frame durations from the real timestamps, so a dropped repaint - // stretches its frame instead of speeding the whole reel up. - const list = stamps.map((stamp, index) => { - const span = index < stamps.length - 1 - ? stamps[index + 1].at - stamp.at : 1 / 60; - return `file 'frames/${stamp.file}'\nduration ${Math.max(span, 1 / 240).toFixed(4)}`; - }).join("\n") + `\nfile 'frames/${stamps.at(-1).file}'\n`; + const replayName = shell.demos.at(-1)?.roundName; + const replayDemo = replayName ? shell.replayBodies.get(replayName) : null; + if (!replayDemo) throw new Error("completed bot fight replay payload is missing"); + const offlineStamps = await captureOfflineReplay({ browser, shell, + demo: replayDemo, frames, width, height, theme, + seconds: captured.seconds, log }); + captured.liveFrames = captured.frames; + captured.frames = offlineStamps.length; + captured.frameCadence = "fixed-step-60"; + + // Every source image owns exactly one frame. FFmpeg no longer launders a + // sparse screencast into nominal 60 fps by duplicating long-held images. + const list = offlineStamps.map((stamp) => { + return `file 'frames/${stamp.file}'\nduration ${(1 / 60).toFixed(6)}`; + }).join("\n") + `\nfile 'frames/${offlineStamps.at(-1).file}'\n`; writeFileSync(join(out, "frames.txt"), list); const audioLeadMs = haveAudio && audio.startedAt && zero !== null diff --git a/xbox/live/marketing/replay-oven.mjs b/xbox/live/marketing/replay-oven.mjs index 000643c93e..b329922a47 100644 --- a/xbox/live/marketing/replay-oven.mjs +++ b/xbox/live/marketing/replay-oven.mjs @@ -8,11 +8,11 @@ import { renderReel } from "./render.mjs"; export const replayOvenProfile = Object.freeze({ fps: 60, - sourceQuality: 100, + sourceQuality: 92, videoCodec: "h264", crf: 14, hud: false, - offlinePasses: Object.freeze(["contrast", "color", "detail"]), + offlinePasses: Object.freeze(["fixed-step", "contrast", "color", "detail"]), }); export function bakeReplay(spec, options) { diff --git a/xbox/live/marketing/shell.mjs b/xbox/live/marketing/shell.mjs index 603ad3c06d..4df29d1cf2 100644 --- a/xbox/live/marketing/shell.mjs +++ b/xbox/live/marketing/shell.mjs @@ -60,6 +60,7 @@ export async function serveShell({ replays = "stub", log = () => {} } = {}) { // best-of-five target is the only honest end-of-match signal the page // gives, and it arrives before the result card does. const demos = []; + const replayBodies = new Map(); const readBody = (request) => new Promise((done) => { let body = ""; request.on("data", (chunk) => { body += chunk; }); @@ -79,6 +80,8 @@ export async function serveShell({ replays = "stub", log = () => {} } = {}) { const body = await readBody(request); try { const demo = JSON.parse(body); + const key = String(demo.roundName || demo.matchName || "").replace(/^ow-/, ""); + if (key) replayBodies.set(key, demo); demos.push({ at: Date.now(), roundName: demo.roundName || demo.matchName, roundIndex: demo.roundIndex ?? 0, winner: demo.winner ?? null, finalRoundWins: demo.finalRoundWins || [0, 0], @@ -88,6 +91,13 @@ export async function serveShell({ replays = "stub", log = () => {} } = {}) { response.end(JSON.stringify({ ok: true, stored: false, sink: true })); return; } + const requested = String(url.searchParams.get("id") || "").replace(/^ow-/, ""); + if (request.method === "GET" && requested && replayBodies.has(requested)) { + response.writeHead(200, { "content-type": "application/json", + "cache-control": "no-store" }); + response.end(JSON.stringify({ replay: replayBodies.get(requested) })); + return; + } if (replays === "proxy") { const upstream = await fetch( `https://aesthetic.computer${url.pathname}${url.search}`, @@ -129,6 +139,7 @@ export async function serveShell({ replays = "stub", log = () => {} } = {}) { return { origin, demos, + replayBodies, get replayPosts() { return posts; }, close: () => new Promise((closed) => server.close(closed)), }; diff --git a/xbox/live/oskiewar.js b/xbox/live/oskiewar.js index 71f678a3b8..d121a70316 100644 --- a/xbox/live/oskiewar.js +++ b/xbox/live/oskiewar.js @@ -2252,6 +2252,7 @@ function handleRoundViewer(message) { roundViewerDemo = message.content; roundViewerDemoStartedAt = now; roundViewerMode = "DEMO"; + globalThis.__oskiewarReplayReady = true; return; } if (message.type === "state") { diff --git a/xbox/live/tests/frame-driver.test.mjs b/xbox/live/tests/frame-driver.test.mjs index 87f8038ee7..b568546df6 100644 --- a/xbox/live/tests/frame-driver.test.mjs +++ b/xbox/live/tests/frame-driver.test.mjs @@ -114,3 +114,17 @@ test("pauses simulation timers while hidden and resumes without fast-forwarding" h.driver.stop(); assert.equal(h.timerCount(), 0); }); + +test("offline stepping produces one fixed simulation tick and paint per frame", () => { + const h = harness(); + for (let frame = 0; frame < 6; frame++) h.driver.stepOffline(); + + assert.equal(h.simulations.length, 6); + assert.equal(h.paints.length, 6); + assert.equal(h.driver.stats.simulationTicks, 6); + assert.equal(h.driver.stats.renderFrames, 6); + assert.deepEqual(h.simulations.map((at) => Math.round(at * 100) / 100), + [0, 16.67, 33.33, 50, 66.67, 83.33]); + h.driver.start(); + assert.throws(() => h.driver.stepOffline(), /cannot step offline/); +});