Files
infiplot-web/scripts/localize-firstact-images.mjs
T
yuanzonghao 95a66d94ed feat(web): support portrait preset story cards on mobile
Mobile users clicking preset story cards now get portrait (9:16) scene
images instead of landscape. Previously card paths hardcoded orientation
to "landscape"; now they respect detectOrientation() and load from
firstact-portrait/ with graceful fallback to landscape.

- Add --portrait and --only flags to prebake-firstacts.mjs
- Add --portrait flag to localize-firstact-images.mjs
- Fix prebake STYLE_MAP extraction (moved to lib/options.ts)
- Generate 60 portrait firstact JSONs + firstscene webp assets
- Remove hardcoded "landscape" in play page card path

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-07 00:12:37 +08:00

165 lines
5.7 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 PORTRAIT = process.argv.includes("--portrait");
const FIRSTACT_DIR = resolve(WEB_ROOT, "public", "home", PORTRAIT ? "firstact-portrait" : "firstact");
const FIRSTSCENE_DIR = resolve(WEB_ROOT, "public", "home", PORTRAIT ? "firstscene-portrait" : "firstscene");
const PUBLIC_LOCAL_PREFIX = PORTRAIT ? "/home/firstscene-portrait/" : "/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);