95a66d94ed
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>
165 lines
5.7 KiB
JavaScript
165 lines
5.7 KiB
JavaScript
#!/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);
|