feat(web): 红果-style homepage + instant-play prebaked first acts

Rewrites all 64 homepage cards (32 男性向 + 32 女性向) as short-drama hook
stories (战神归来 / 重生分手前夜 / 系统选妃 / 穿成乙游男配 / 末世异能 / 民国
谍战 / 修真渡劫 …) and regenerates each cover via FLUX in its assigned art
style (12 styles spread across 64 cards) at 832×1024 ≈4:5.

Click-to-play path: cards now jump straight to /play?card=<name> and hydrate
Session from /home/firstact/<name>.json — the engine pipeline (Architect +
Writer + CharacterDesigner + Painter) has been pre-run for 44/64 cards. The
remaining 20 (m14/m29/f14..f31) are pending an LLM credit top-up; their
clicks fall through to live /api/start for now.

Runware-hosted first-scene images are downloaded into /home/firstscene/
and the JSONs are rewritten to point at the local webp, so click → first
image is bounded by local-disk decode (~100ms) instead of CDN round-trip.

Scripts:
- scripts/generate-home-images.mjs  — rewrites all 64 cover prompts, per-card
  styles baked into prompts, 832×1024 dims to match StoryCard aspect
- scripts/prebake-firstacts.mjs     — POST /api/start × 64 with concurrency
  4, saves StartResponse to public/home/firstact/<name>.json
- scripts/localize-firstact-images.mjs — downloads each prebaked imageUrl
  to public/home/firstscene/<name>.webp (q80, ≤1600px) and rewrites JSON

README: adds Screenshots section (3×3 gallery) to README.md / README.zh-CN.md,
9 in-game shots compressed to docs/screenshots/*.webp (7.5MB → 680KB).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-02 17:20:34 +08:00
parent 9ae91dd3ed
commit d93c16d836
168 changed files with 680 additions and 544 deletions
@@ -0,0 +1,128 @@
#!/usr/bin/env node
/**
* Post-process for prebake-firstacts: each first-act JSON has imageUrl pointing
* at https://im.runware.ai/... which adds ~1-2s of remote-CDN download on first
* click. This script downloads every imageUrl to apps/web/public/home/firstscene/
* (webp, compressed) and rewrites the JSON's imageUrl to the local /home/...
* path, so click-to-play is bottlenecked only by JSON parse + local image
* decode (sub-100ms).
*
* Idempotent: a JSON whose imageUrl already points at /home/firstscene/ is
* skipped. Pass --force to re-download everything.
*
* Run once after prebake-firstacts.mjs completes:
* node apps/web/scripts/localize-firstact-images.mjs
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve, basename, extname } from "node:path";
import {
existsSync,
mkdirSync,
writeFileSync,
statSync,
readdirSync,
readFileSync,
} from "node:fs";
import sharp from "sharp";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const FIRSTACT_DIR = resolve(WEB_ROOT, "public", "home", "firstact");
const FIRSTSCENE_DIR = resolve(WEB_ROOT, "public", "home", "firstscene");
const PUBLIC_LOCAL_PREFIX = "/home/firstscene/";
const MAX_EDGE = 1600;
const QUALITY = 80;
const FORCE = process.argv.includes("--force");
if (!existsSync(FIRSTACT_DIR)) {
console.error(`Missing ${FIRSTACT_DIR} — run prebake-firstacts.mjs first.`);
process.exit(2);
}
if (!existsSync(FIRSTSCENE_DIR)) mkdirSync(FIRSTSCENE_DIR, { recursive: true });
const files = readdirSync(FIRSTACT_DIR).filter((f) => f.toLowerCase().endsWith(".json"));
let downloaded = 0;
let skipped = 0;
let failed = 0;
let bytesIn = 0;
let bytesOut = 0;
const t0 = Date.now();
console.log(`[localize] ${files.length} JSONs → ${FIRSTSCENE_DIR}`);
for (const f of files) {
const jsonPath = resolve(FIRSTACT_DIR, f);
const name = basename(f, extname(f)); // m0, f31, etc.
const localWebp = resolve(FIRSTSCENE_DIR, `${name}.webp`);
const localPublicPath = `${PUBLIC_LOCAL_PREFIX}${name}.webp`;
let json;
try {
json = JSON.parse(readFileSync(jsonPath, "utf8"));
} catch (e) {
failed++;
console.log(`${name} FAIL parse: ${e.message}`);
continue;
}
const url = json.imageUrl;
if (!url) {
failed++;
console.log(`${name} FAIL: no imageUrl in JSON`);
continue;
}
if (!FORCE && url.startsWith(PUBLIC_LOCAL_PREFIX) && existsSync(localWebp)) {
skipped++;
console.log(`${name} skip (already local)`);
continue;
}
if (!url.startsWith("http")) {
failed++;
console.log(`${name} FAIL: imageUrl not http(s): ${url.slice(0, 60)}`);
continue;
}
const t = Date.now();
try {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
bytesIn += buf.length;
// Downscale to 1600px long edge (Runware paints at 1792×1024 by default —
// the player canvas never needs more than ~1200-1600). Then webp 80.
const img = sharp(buf);
const meta = await img.metadata();
const longEdge = Math.max(meta.width ?? 0, meta.height ?? 0);
const resized = longEdge > MAX_EDGE
? img.resize({
width: (meta.width ?? 0) >= (meta.height ?? 0) ? MAX_EDGE : undefined,
height: (meta.height ?? 0) > (meta.width ?? 0) ? MAX_EDGE : undefined,
})
: img;
await resized.webp({ quality: QUALITY, effort: 5 }).toFile(localWebp);
const outSize = statSync(localWebp).size;
bytesOut += outSize;
json.imageUrl = localPublicPath;
json.imageUrlRemote = url; // keep the Runware URL around for forensics
writeFileSync(jsonPath, JSON.stringify(json));
downloaded++;
console.log(
`${name} ok ${(buf.length / 1024).toFixed(0)} KB → ${(outSize / 1024).toFixed(0)} KB in ${((Date.now() - t) / 1000).toFixed(1)}s`,
);
} catch (e) {
failed++;
console.log(`${name} FAIL: ${e.message}`);
}
}
console.log(
`\n[localize] done in ${Math.round((Date.now() - t0) / 1000)}s — wrote ${downloaded} / skipped ${skipped} / failed ${failed}\n` +
`[localize] bytes ${(bytesIn / 1024 / 1024).toFixed(1)} MB → ${(bytesOut / 1024 / 1024).toFixed(2)} MB`,
);
process.exit(failed ? 1 : 0);