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>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-04 09:34:12 +08:00
parent b805b1d9c2
commit 010239de44
121 changed files with 120 additions and 85 deletions
+60 -25
View File
@@ -67,14 +67,26 @@ for (const f of files) {
continue;
}
const url = json.imageUrl;
// 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 imageUrl in JSON`);
console.log(`${name} FAIL: no Runware URL (imageUrl/imageUrlRemote)`);
continue;
}
if (!FORCE && url.startsWith(PUBLIC_LOCAL_PREFIX) && existsSync(localWebp)) {
if (
!FORCE &&
typeof json.imageUrl === "string" &&
json.imageUrl.startsWith(PUBLIC_LOCAL_PREFIX) &&
existsSync(localWebp)
) {
skipped++;
console.log(`${name} skip (already local)`);
continue;
@@ -82,39 +94,62 @@ for (const f of files) {
if (!url.startsWith("http")) {
failed++;
console.log(`${name} FAIL: imageUrl not http(s): ${url.slice(0, 60)}`);
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 {
const res = await fetch(url);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const buf = Buffer.from(await res.arrayBuffer());
bytesIn += buf.length;
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);
// 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;
}
const 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++;
console.log(
`${name} ok ${(buf.length / 1024).toFixed(0)} KB → ${(outSize / 1024).toFixed(0)} KB in ${((Date.now() - t) / 1000).toFixed(1)}s`,
);
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}`);