Files
infiplot-web/apps/web/scripts/localize-firstact-images.mjs
T
DESKTOP-I1T6TF3\Q d93c16d836 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>
2026-06-02 17:20:34 +08:00

129 lines
4.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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);