Files
infiplot-web/scripts/localize-firstact-images.mjs
T
DESKTOP-I1T6TF3\Q 010239de44 fix(home): localize first-scene images — drop Runware URL TTL dependency
Card click flow now serves /home/firstscene/{name}.webp from Vercel static
hosting instead of fetching im.runware.ai/... — those URLs have a finite TTL
and would silently rot. Side benefit: backfilled the 18 stories that never had
a local webp (f14-f29, m14, m29), and refreshed the 44 stale webps left over
from a pre-prebake story batch so they actually match their cover art again.

Scope is scene.imageUrl only; characters[].basePortraitUrl still points at
Runware (painter consumes it server-side as referenceImages, where a local
public path won't resolve).

localize-firstact-images.mjs:
- skip the network when the local webp is already on disk (don't re-encode
  what's already correct)
- read imageUrlRemote as a fallback URL when imageUrl is already localized,
  so --force can refresh from the original Runware source
- also localize scene.imageUrl alongside the top-level imageUrl

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-04 09:34:12 +08:00

164 lines
5.6 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 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 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;
}
// Prefer the live `imageUrl`; fall back to `imageUrlRemote` when this JSON
// has already been localized in a previous run (the Runware URL we want to
// re-download lives there, not in `imageUrl`).
const remote =
json.imageUrl && json.imageUrl.startsWith("http")
? json.imageUrl
: json.imageUrlRemote;
const url = remote;
if (!url) {
failed++;
console.log(`${name} FAIL: no Runware URL (imageUrl/imageUrlRemote)`);
continue;
}
if (
!FORCE &&
typeof json.imageUrl === "string" &&
json.imageUrl.startsWith(PUBLIC_LOCAL_PREFIX) &&
existsSync(localWebp)
) {
skipped++;
console.log(`${name} skip (already local)`);
continue;
}
if (!url.startsWith("http")) {
failed++;
console.log(`${name} FAIL: Runware URL not http(s): ${url.slice(0, 60)}`);
continue;
}
// Runware URLs have a finite TTL — once they 404, the prebaked first-act
// becomes unplayable. Localizing the JSON pointer is the durable fix; the
// bytes themselves only need to be re-downloaded when the local webp is
// actually missing. If the webp is already on disk (44 of 60 today), skip
// the network and just rewrite the JSON to point at it.
const localWebpExists = existsSync(localWebp);
const t = Date.now();
try {
let outSize;
if (localWebpExists && !FORCE) {
outSize = statSync(localWebp).size;
} else {
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);
outSize = statSync(localWebp).size;
bytesOut += outSize;
}
json.imageUrl = localPublicPath;
// scene.imageUrl is the same image one level deeper — both fields are
// surfaced by /api/start to the play page, so we localize them in lockstep
// to keep the JSON self-consistent.
if (json.scene && typeof json.scene === "object") {
json.scene.imageUrl = localPublicPath;
}
json.imageUrlRemote = url; // keep the Runware URL around for forensics
writeFileSync(jsonPath, JSON.stringify(json));
downloaded++;
if (localWebpExists && !FORCE) {
console.log(`${name} ok (webp existed, rewrote JSON only) ${(outSize / 1024).toFixed(0)} KB`);
} else {
console.log(
`${name} ok ${(bytesIn ? "" : "")}${(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);