diff --git a/xbox/steam/store-page/fill.md b/xbox/steam/store-page/fill.md index 5a13fc68c7..153e9c3be5 100644 --- a/xbox/steam/store-page/fill.md +++ b/xbox/steam/store-page/fill.md @@ -1,9 +1,14 @@ # oskiewar — store page fill sheet (appid 5280790) -One row per partner-site field, in the order the editor walks them, with the -exact value to paste or the file to upload. `copy.md` and `metadata.md` are -the reasoning; this is the keyboard. Anything marked *human* is a decision, -not a paste. +**Filled and saved 2026-09-15.** Every store-presence checklist item is +green. This sheet stays as the record of what went in, and as the recipe if +a field has to be re-entered. Anything marked *human* was, or still is, a +decision rather than a paste. + +Still open on the page: **a price**, and the items that fall out of the +**build upload** (platform support, package/depot matching, at least one +build). The trailer is rendered by `render-trailer.mjs` and uploads under +the Trailers tab. Editor: https://partner.steamgames.com/apps/landing/5280790 → **Edit Store Page**. @@ -15,13 +20,13 @@ Editor: https://partner.steamgames.com/apps/landing/5280790 → **Edit Store Pag | Developer | `Jeffrey Alan Scudder` *(human: or "Aesthetic Computer" — public on the page)* | | Publisher | same as developer | | Franchise | leave blank | -| Release date | *human* — "Coming soon" now; earliest 2026-10-01 | +| Release date | Oct 15 2026, 10:00 AM PDT, shown to customers as "Coming Soon" | | Supported languages | English — Interface ✓ · Full Audio ✓ · Subtitles ✗ | | Genre (primary) | Action | | Genre (secondary) | Indie | | Tags (first five weigh) | Fighting · 2D Fighter · Local Multiplayer · Physics · Arcade · PvP · Multiplayer · Singleplayer · Action · Indie · Minimalist · Funny · Retro · Controller · Competitive | | Categories | Single-player ✓ · Multi-player ✓ · PvP ✓ · Shared/Split Screen PvP ✓ · Full Controller Support — *only after the pad-only pass* · Remote Play Together — *only after a two-network test* | -| Controller support | Full (Xbox) — same caveat | +| Controller support | **Partial** (declared via the wizard). The shell has no pad-reachable quit, which is one of Valve's full-support criteria | | Accessibility | *human* — no text entry, no chat; nothing to declare yet | | Anti-cheat | None | | Social links | https://oskiewar.com · https://instagram.com/whistlegraph *(human: which ones)* | @@ -58,9 +63,18 @@ Editor: https://partner.steamgames.com/apps/landing/5280790 → **Edit Store Pag ## Trailers -None yet. The page can be submitted without one; Valve's review treats a -missing trailer as a weakness, not a block. Do not press Release while one -is still encoding. +`node xbox/steam/store-page/render-trailer.mjs` renders whole rounds through +the same Replay Oven the Instagram reels use, at 1920×1080 and 60 fps, and +encodes to Valve's reference preset (H.264 high, ~20 Mbps, AAC 192k at +48 kHz, +faststart). Upload `assets/trailer.mp4`; the required thumbnail is +`assets/trailer-thumbnail.jpg`, which is a frame of the video itself. + +Clips use `hud: "reel"` — the reel dress (matchup card, clean round, winner +called afterwards) rather than the full oven UI, whose intro/fight/outro +progress bar reads as a video scrubber on a store page. Cuts fall between +whole rounds, so nothing inside a round is cut. + +Do not press Release while a trailer is still encoding — Valve blocks it. ## Store Settings / Content survey diff --git a/xbox/steam/store-page/render-trailer.mjs b/xbox/steam/store-page/render-trailer.mjs index 86ea1c342f..afe1b39160 100644 --- a/xbox/steam/store-page/render-trailer.mjs +++ b/xbox/steam/store-page/render-trailer.mjs @@ -47,30 +47,51 @@ const CLIPS = [ const log = (line) => console.log(line); mkdirSync(work, { recursive: true }); +// One clip per process. Each render drives a headless Chrome through ~2000 +// frames at 1920x1080; four of them in one process exhausted memory on an +// 8 GB machine and Chrome died mid-clip with a bare "Target closed". A child +// process per clip hands every byte back between rounds, and the frames +// (~480 MB each) are pruned once the clip's master exists. +async function renderClip(clip) { + const out = join(work, clip.id); + rmSync(out, { recursive: true, force: true }); + log(`\n🎬 ${clip.id} · ${clip.door} · seed "${clip.seed}"`); + const render = await bakeReplay({ + id: clip.id, kind: "self-play", seed: clip.seed, door: clip.door, + // The reel dress, not the full oven UI: a matchup card names both + // fighters, the round itself plays under nothing at all, and the + // winner is called afterwards. The full HUD drags the oven's + // intro/fight/outro scrubber along with it, which on a store page + // reads as a video control rather than part of the game. + hud: "reel", + cap: 45, width: 1920, height: 1080, theme: "dark", out, + }, { log }); + rmSync(join(out, "frames"), { recursive: true, force: true }); + log(` → ${render.base} (${render.frames} frames, outcome ${ + render.outcome?.cause || render.outcome?.mode || "?"})`); + return render.base; +} + +const only = flags.get("only"); +if (only) { + const clip = CLIPS.find((entry) => entry.id === only) || + { id: only, door: flags.get("door") || "fight", seed: flags.get("seed") || only }; + await renderClip(clip); + process.exit(0); +} + const clips = []; -if (!flags.has("encode")) { - for (const clip of CLIPS) { - const out = join(work, clip.id); - rmSync(out, { recursive: true, force: true }); - log(`\n🎬 ${clip.id} · ${clip.door} · seed "${clip.seed}"`); - const render = await bakeReplay({ - id: clip.id, kind: "self-play", seed: clip.seed, door: clip.door, - // The reel dress, not the full oven UI: a matchup card names both - // fighters, the round itself plays under nothing at all, and the - // winner is called afterwards. The full HUD drags the oven's - // intro/fight/outro scrubber along with it, which on a store page - // reads as a video control rather than part of the game. - hud: "reel", - cap: 45, width: 1920, height: 1080, theme: "dark", out, - }, { log }); - clips.push(render.base); - log(` → ${render.base} (${render.frames} frames)`); - } -} else { - for (const clip of CLIPS) { - const base = join(work, clip.id, "base.mp4"); - if (existsSync(base)) clips.push(base); +for (const clip of CLIPS) { + const base = join(work, clip.id, "base.mp4"); + if (!flags.has("encode") && !existsSync(base)) { + const child = spawnSync(process.execPath, + [fileURLToPath(import.meta.url), `--only=${clip.id}`, + `--door=${clip.door}`, `--seed=${clip.seed}`], + { stdio: "inherit" }); + if (child.status !== 0) throw new Error(`${clip.id} failed (exit ${child.status})`); } + if (existsSync(base)) clips.push(base); + else log(` ⚠ ${clip.id} produced no master — skipping it`); } if (!clips.length) throw new Error("no clips rendered — drop --encode or check the log");