refactor: flatten monorepo to single web package (#12)

Flatten the pnpm monorepo (apps/web + packages/*) into a single web package at the repo root.

- Move app/lib/components/scripts/public to root; drop apps/web and packages/* wrappers
- Rewrite tsconfig paths (@infiplot/*) to ./lib/*; turbopack.root = __dirname
- Update Vercel (no root-directory) and Cloudflare (pnpm build:cf at root) deploy paths
- Regenerate pnpm-lock.yaml to drop stale workspace importers
- Bump engines.node to >=22 to match wrangler

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Zonghao Yuan
2026-06-03 00:55:45 +08:00
committed by GitHub
parent 9543c3dba1
commit dc5ecd60f6
221 changed files with 241 additions and 379 deletions
+250
View File
@@ -0,0 +1,250 @@
#!/usr/bin/env node
/**
* One-off generator: produces the InfiPlot homepage story cards via Runware
* FLUX.2 and writes them as PNGs under public/home/.
*
* Flat per-gender layout: 32 male-oriented (m0..m31) + 32 female-oriented
* (f0..f31). All cards are 832x1024 (≈4:5) to match the homepage StoryCard
* aspect — m{i} and f{i} share dimensions so 性向 crossfade never jumps.
*
* Each prompt bakes its per-card art style (anime / cinematic-real / xianxia
* ink / cyberpunk / steampunk / pixel / etc.) so the homepage feels visually
* varied rather than uniformly anime. Stories are 红果-short-drama framed.
*
* Reads IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL from .env.local.
*
* Run once:
* node scripts/generate-home-images.mjs
*
* Idempotent: skips any card whose .png or .webp already exists. Pass --force
* to regenerate everything.
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { readFileSync, existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const ENV_FILE = resolve(WEB_ROOT, ".env.local");
const OUT_DIR = resolve(WEB_ROOT, "public", "home");
const FORCE = process.argv.includes("--force");
/* ---------- env loading (tiny .env parser) ---------- */
function loadEnv(path) {
const txt = readFileSync(path, "utf8");
const env = {};
for (const raw of txt.split(/\r?\n/)) {
const line = raw.trim();
if (!line || line.startsWith("#")) continue;
const eq = line.indexOf("=");
if (eq < 0) continue;
const k = line.slice(0, eq).trim();
let v = line.slice(eq + 1).trim();
if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
v = v.slice(1, -1);
}
env[k] = v;
}
return env;
}
const env = loadEnv(ENV_FILE);
const BASE_URL = env.IMAGE_BASE_URL;
const API_KEY = env.IMAGE_API_KEY;
const MODEL = env.IMAGE_MODEL;
if (!BASE_URL || !API_KEY || !MODEL) {
console.error("Missing IMAGE_BASE_URL / IMAGE_API_KEY / IMAGE_MODEL in", ENV_FILE);
process.exit(2);
}
if (!BASE_URL.includes("runware.ai")) {
console.error("This script assumes Runware. Got:", BASE_URL);
process.exit(2);
}
/* ---------- prompts ---------- */
const BASE_QUALITY =
"masterpiece, best quality, highly detailed, cinematic lighting, soft warm color grading, intricate background, no text, no watermark";
const W = 832;
const H = 1024;
// Style suffixes — kept in sync with app/page.tsx STYLE_MAP so the
// homepage cover and the in-game styleGuide land on the same aesthetic.
const S = {
二次元: "anime visual novel illustration, japanese galgame aesthetic, soft warm natural light",
吉卜力: "studio ghibli watercolor style, hand-drawn animation, soft watercolor washes, warm healing tones",
真实系: "cinematic photorealism, soft natural lighting, subtle film grain",
超写实: "hyperrealistic portrait, cinematic studio lighting, perfect skin and fabric detail",
水彩: "watercolor illustration, wet bleeding brushstrokes, paper texture",
像素风: "16-bit pixel art, retro game palette, blocky geometric style",
日系动画: "modern japanese anime cel-shading, hard shadow layers, cel anime style",
"3D 渲染": "3D rendered toon style, soft subsurface scattering, clean cinematic lighting",
蒸汽朋克: "steampunk aesthetic, brass gears and steam, industrial revolution atmosphere",
玄幻: "chinese xianxia illustration, ethereal qi mist, distant misty mountains and mythic beasts",
国风水墨: "chinese ink wash illustration, romantic xianxia colors, eastern aesthetic",
赛博朋克: "cyberpunk city, neon reflections on wet streets, glowing cybernetic accents",
};
// 32 male-oriented cards (m0..m31), 红果 short-drama framing.
const MALE = [
{ name: "m0", prompt: `front gates of a glass-walled chinese wedding hall at dusk, a stoic man in a worn military overcoat standing alone outside facing the camera, behind him twenty black-suited bodyguards kneeling in two perfect rows on the marble steps, soft rain and faint smoke on the road, ${S["真实系"]}`, w: W, h: H },
{ name: "m1", prompt: `a young man in a clean grey shirt walking back into a small chinese mountain village at golden hour, an elderly farmer at the village entrance crying tears of joy and reaching out to him, terraced fields and warm cooking smoke from low houses, ${S["吉卜力"]}`, w: W, h: H },
{ name: "m2", prompt: `interior of a lavish chinese family banquet hall, a young man holding a teapot center frame surrounded by dozens of disdainful relatives glaring at him, through the open french doors behind him nine black rolls-royces lined up at the curb with chauffeurs bowing in unison, ${S["真实系"]}`, w: W, h: H },
{ name: "m3", prompt: `a young food delivery courier in a yellow uniform on a midnight neon-lit chinese street, his phone held up showing an incoming call labelled 'father', behind him a sleek black bentley pulling silently alongside the curb, ${S["二次元"]}`, w: W, h: H },
{ name: "m4", prompt: `a stoic ex-soldier in a dark hoodie standing protectively in front of a frightened young woman on a rainy small-town chinese street, a struck thug recoiling and falling back into a puddle behind, cold tense atmosphere, ${S["真实系"]}`, w: W, h: H },
{ name: "m5", prompt: `a young man kneeling on his bedroom floor at 4 am holding an open ring box, his girlfriend frozen in the half-open doorway with her suitcase, dawn light filtering through curtains, ${S["日系动画"]}`, w: W, h: H },
{ name: "m6", prompt: `a high school boy in summer uniform standing on a school rooftop at golden hour holding a folded exam paper, a girl in another uniform leaning over the far railing in the distance, summer cicadas and warm light, ${S["吉卜力"]}`, w: W, h: H },
{ name: "m7", prompt: `a young man kneeling at a stone grave in a chinese cemetery at twilight, soft white petals drifting around him, a barefoot young woman with the same face as the photo on the headstone standing behind the grave looking confused, ${S["二次元"]}`, w: W, h: H },
{ name: "m8", prompt: `a young man at 3 am in front of his bright phone screen flooded with an SSR character pull animation in his messy bedroom, a girl in his oversized T-shirt walking out of his bathroom rubbing sleepy eyes, ${S["3D 渲染"]}`, w: W, h: H },
{ name: "m9", prompt: `a young man at center frame surrounded by a holographic semicircle of seven beautiful women all turning to look at him in unison, a glowing red 30-second countdown floating overhead, ${S["二次元"]}`, w: W, h: H },
{ name: "m10", prompt: `interior of a dim chinese palace cold harem chamber, a thin young prince in disheveled white silk robes seated cross-legged on a stone floor, an old eunuch standing before him reading a death decree from a yellow imperial scroll, candlelight, ${S["国风水墨"]}`, w: W, h: H },
{ name: "m11", prompt: `a stylish silver-haired bishounen anime villain in a black school uniform standing in a cathedral school courtyard, the otome heroine timidly approaching him with a hand-written letter, falling petals, ${S["二次元"]}`, w: W, h: H },
{ name: "m12", prompt: `interior of a 1928 republican-era warlord mansion entrance hall, a young man in a dark military uniform lying on a red carpet with a slim trail of blood from his lips beside a half-drained porcelain cup, polished military boots and a shadowed figure approaching from the doorway, ${S["真实系"]}`, w: W, h: H },
{ name: "m13", prompt: `a young xianxia cultivator in tattered white robes standing atop a cloud-wreathed mountain peak, nine layers of crimson heavenly tribulation lightning crashing down, a silhouetted female figure standing inside the lightning clouds above shielding him, ${S["玄幻"]}`, w: W, h: H },
{ name: "m14", prompt: `a humble grey-robed temple sweeper holding a broom standing calmly in a chinese mountain monastery courtyard, an ornately armored demon lord recoiling backward from him while terrified senior monks kneel behind, swept leaves drifting between them, ${S["国风水墨"]}`, w: W, h: H },
{ name: "m15", prompt: `interior of a dim chinese college dormitory room at night, a young man recoiling on his bunk holding back another student whose pale veined face is biting at him, a single floating silver-blade-shaped droplet of blood suspended in mid-air between them, ${S["真实系"]}`, w: W, h: H },
{ name: "m16", prompt: `a soaked young man in a torn jacket standing on a rooftop of a ruined neon-lit asian metropolis during a thunderstorm, electricity arcing wildly between his clenched fist and the air, a horde of glowing-eyed infected silhouetted below, ${S["赛博朋克"]}`, w: W, h: H },
{ name: "m17", prompt: `interior of a luxurious dim-lit family banquet room, the protagonist seated calmly at the corner of a long table while seven imposing men in dark suits enter the doorway behind him and bow deeply in his direction, his father-in-law freezing mid-sentence at the head of the table, ${S["真实系"]}`, w: W, h: H },
{ name: "m18", prompt: `a quiet elderly chinese gentleman in a simple grey changshan standing in a noisy outdoor wet market holding a bunch of green onions, the vendor at his stall trembling and dropping the change, distant grey mountain ranges in the background, ${S["国风水墨"]}`, w: W, h: H },
{ name: "m19", prompt: `interior of a dim-lit luxurious chinese-style mansion at night, a man in a black silk shirt slowly lifting a red wedding veil to reveal the face of a young woman identical to his late sister, her eyes wide with fear, soft red lantern light, ${S["超写实"]}`, w: W, h: H },
{ name: "m20", prompt: `a 1930s republican-era man in a tailored grey suit and fedora standing in a smoke-filled shanghai bund alley between two doors marked in chinese characters, cigarette smoke and distant gramophone music, art deco neon glow, ${S["真实系"]}`, w: W, h: H },
{ name: "m21", prompt: `a humble grey-clothed sweeper carrying a tea tray walking through a chinese martial arts grand tournament hall, dozens of jianghu sect leaders frozen mid-strike with their raised swords trembling in the air around him, ${S["国风水墨"]}`, w: W, h: H },
{ name: "m22", prompt: `a tense high school senior in school uniform sitting at a desk under harsh fluorescent light, four serious men in dark suits standing behind him exchanging a sealed envelope across the desk, chalkboard with college entrance exam dates visible, ${S["日系动画"]}`, w: W, h: H },
{ name: "m23", prompt: `the wide marble entry of a luxury chinese wedding hall, a gaunt young man in a blood-stained hiking jacket and dusty backpack standing motionless at the doorway, a champagne glass shattering on the floor near a stunned tuxedoed groom in the background, ${S["真实系"]}`, w: W, h: H },
{ name: "m24", prompt: `a high school rooftop at golden hour, a fierce female transfer student in dishevelled uniform pinned with her bag torn open at her feet spilling a thick stack of pale blue love letters, the protagonist standing over her with a steady amused expression, ${S["日系动画"]}`, w: W, h: H },
{ name: "m25", prompt: `a sun-filled japanese classroom after school, a top student girl with neat braids slamming an exam paper down on the desk of the boy sitting behind her, his identical answers visible on his desk, soft afternoon dust rays, ${S["二次元"]}`, w: W, h: H },
{ name: "m26", prompt: `a grand fantasy hall during a class awakening ceremony, all classmates around in colored qi auras laughing and pointing at the protagonist who stands alone at center frame, a sudden blinding pillar of golden divine light bursting from him obliterating their auras, ${S["玄幻"]}`, w: W, h: H },
{ name: "m27", prompt: `a tiny stick-figure protagonist standing on lined notebook paper as a giant pink eraser descends from above, half of his pixel body already smudged and erased, blocky 16-bit world, ${S["像素风"]}`, w: W, h: H },
{ name: "m28", prompt: `a brass-and-wood airship deck above an endless sea of clouds, a one-eyed steampunk captain in a long coat handing a telescope to a young man whose hand is reaching out to take it, a distant black pirate balloon visible through the lens, ${S["蒸汽朋克"]}`, w: W, h: H },
{ name: "m29", prompt: `interior of a battered colony starship bridge, a 17-year-old protagonist in a torn pilot uniform climbing into a glowing main-cannon command chair, dozens of officers behind him saluting, a crimson alien planet glowing through the cracked viewport, ${S["赛博朋克"]}`, w: W, h: H },
{ name: "m30", prompt: `a young pilot in a worn flight suit standing on a brightly-lit mech arena platform at night, his stern coach pressing a captain's badge into his palm, the cockpit of a battered humanoid mech opening behind him, a holographic crowd fills the arena overhead, ${S["赛博朋克"]}`, w: W, h: H },
{ name: "m31", prompt: `a modern college campus plaza at golden hour, a beautiful girl standing opposite a wealthy young man holding flowers receiving her embrace, the protagonist watching from a few steps away with one hand in his coat pocket and a quiet smile, faint reflection of a black bentley parked at the curb behind him, ${S["真实系"]}`, w: W, h: H },
];
// 32 female-oriented cards (f0..f31), same trope categories — love-interest framing.
const FEMALE = [
{ name: "f0", prompt: `interior of an ornate ancient chinese general's manor courtyard, a delicate young noblewoman with a fresh red mark on her cheek kneeling on a stone tile, a stern handsome regent prince in dark silk court robes dismounting his horse outside the gate and striding toward her with murder in his eyes, ${S["国风水墨"]}`, w: W, h: H },
{ name: "f1", prompt: `a glamorous blonde-curl-haired villainess in a crimson ballgown sitting alone in a sun-drenched academy library reading an open game-design notebook with a faint smug smile, three handsome male love interests glaring at her in the background, ${S["二次元"]}`, w: W, h: H },
{ name: "f2", prompt: `a delicate hanfu-clad young woman holding a jade pendant standing alone at the entrance of an ancient ancestral hall under moonlight, the silhouette of the male protagonist watching from afar between distant cherry trees, ${S["玄幻"]}`, w: W, h: H },
{ name: "f3", prompt: `interior of an opulent imperial chinese palace throne hall, a regal young empress in gold-embroidered phoenix robes seated at the head, the emperor stepping down from the dragon throne and kneeling before her while three thousand court ladies gasp, ${S["国风水墨"]}`, w: W, h: H },
{ name: "f4", prompt: `a young bride in a white wedding dress writing a divorce paper at a vanity, her husband's handsome younger brother stepping into the bridal suite holding a fresh bouquet, both shocked, gilded chandelier above, ${S["二次元"]}`, w: W, h: H },
{ name: "f5", prompt: `a young woman calmly accepting a paper coffee cup from her boyfriend on a sunny city street with a small composed smile, her reflection in his sunglasses showing a streak of dark blood, ${S["真实系"]}`, w: W, h: H },
{ name: "f6", prompt: `a young woman in business attire holding a black umbrella stepping out from a luxury car onto a rainy city sidewalk and offering shelter to a startled young woman with a briefcase, neon shop reflections in the puddle, ${S["真实系"]}`, w: W, h: H },
{ name: "f7", prompt: `a poised young woman in a sharp white pantsuit standing in a glass-walled corporate boardroom signing a heavy contract while her surprised father watches from the doorway, downtown chinese skyline through the windows, ${S["真实系"]}`, w: W, h: H },
{ name: "f8", prompt: `interior of a luxurious dim-lit penthouse bridal suite at night, a delicate young bride in a white silk dress sitting on the edge of an enormous bed, a tall handsome man in an open black suit leaning down close to whisper into her ear, ${S["二次元"]}`, w: W, h: H },
{ name: "f9", prompt: `a young woman waking in an enormous luxury hotel bed clutching a gold-banded ring on her finger, the silhouette of a tall handsome man already fully dressed in a tailored suit standing at the floor-to-ceiling window adjusting his cufflinks, ${S["真实系"]}`, w: W, h: H },
{ name: "f10", prompt: `interior of a sleek modern penthouse kitchen at morning, a young woman in a silk robe holds half of a freshly torn-up divorce paper, her composed husband in a crisp white shirt holding the other half with a quiet smile, soft sunrise light, ${S["真实系"]}`, w: W, h: H },
{ name: "f11", prompt: `a school courtyard at twilight, a girl gaping in shock as her arch-rival classmate kneels on one knee before her offering a ring box, the rest of the class peeking around the corner in disbelief, ${S["二次元"]}`, w: W, h: H },
{ name: "f12", prompt: `a young woman at 4 am holding her phone bright with a UR-rarity gacha card showing the silhouette of a faceless CEO, behind her the same CEO from the card standing the next morning at her apartment doorway in a tailored suit holding a bouquet, ${S["3D 渲染"]}`, w: W, h: H },
{ name: "f13", prompt: `a tense high school hallway, the female protagonist pinned against a row of lockers by the school's coldest male character glaring down at her, a faint holographic system task panel hovering at the edge of her vision, ${S["二次元"]}`, w: W, h: H },
{ name: "f14", prompt: `a young woman standing at her open apartment doorway looking up in shock at a tall handsome CEO standing politely at her threshold holding a single suitcase, a faint holographic receipt with chinese characters floating above her shoulder, ${S["二次元"]}`, w: W, h: H },
{ name: "f15", prompt: `a young female streamer alone in her cozy bedroom at night facing her ring-light camera, a glowing top-donor badge with chinese characters floating prominently on the screen behind her, faint holographic image of a reclusive handsome CEO above the badge, ${S["日系动画"]}`, w: W, h: H },
{ name: "f16", prompt: `interior of a dim apartment at night, a frightened young woman gripping a chair backward against her front door while bloody hand-prints smear the door's glass panel from outside, a tense handsome man in a leather jacket with a pistol just inside her doorway looking back at her, ${S["真实系"]}`, w: W, h: H },
{ name: "f17", prompt: `the female protagonist standing in her apartment doorway hauling three large supply duffel bags, a tall standoffish handsome neighbor kneeling on the hallway floor in front of her holding up a half-eaten ration bar, ${S["真实系"]}`, w: W, h: H },
{ name: "f18", prompt: `a feared S-rank male esper in a long dark coat crouched on a girl's apartment doormat with puppy-dog eyes asking to be let in, faint flashes of supernatural ability suppressed at his fingertips, ${S["二次元"]}`, w: W, h: H },
{ name: "f19", prompt: `the female protagonist standing in her apartment doorway calmly closing it on a man frantically pounding from the outside, a different handsome man waiting calmly in her brightly-lit kitchen behind her holding two cups of coffee, ${S["真实系"]}`, w: W, h: H },
{ name: "f20", prompt: `a high school classroom at lunch, the female protagonist sitting at her desk opening her textbook to find a handwritten note slipped inside, a cold-faced top-of-class boy from the neighboring class watching from the doorway, ${S["二次元"]}`, w: W, h: H },
{ name: "f21", prompt: `a sun-flecked school hallway at golden hour, the long-admired campus prince walking up to the female protagonist with serious determination in his eyes, holding out a folded piece of paper with an address, ${S["吉卜力"]}`, w: W, h: H },
{ name: "f22", prompt: `a school's front gate at sunset, the female protagonist watching speechless as her ordinary-seeming class president is escorted into a black bentley by four men in dark suits, the class president turning back smiling and waving at her, ${S["二次元"]}`, w: W, h: H },
{ name: "f23", prompt: `a crowded school hallway between classes, the female protagonist's wrist firmly held by the school's coldest senior in a black uniform staring straight at her with quiet intensity, students freezing and watching, ${S["日系动画"]}`, w: W, h: H },
{ name: "f24", prompt: `interior of a 1930s republican-era shanghai mansion drawing room at dusk, a beautiful young woman in an embroidered cheongsam entering through a curtain doorway, a refined foreign-educated gentleman in a tailored grey suit calmly seated at the rosewood table pouring her father's tea, ${S["超写实"]}`, w: W, h: H },
{ name: "f25", prompt: `a 1930s shanghai bookshop interior at night, the female protagonist behind the counter looking up as a foreign-educated gentleman in a sharp tailored suit places a small wrapped parcel on the counter and meets her eyes with quiet urgency, ${S["真实系"]}`, w: W, h: H },
{ name: "f26", prompt: `interior of an ancient xianxia alchemy chamber, a female apprentice in pale robes accidentally tipping a master's bronze pill furnace as a brilliant pillar of golden qi erupts toward the ceiling, three elder masters bursting through the doorway in shock, ${S["玄幻"]}`, w: W, h: H },
{ name: "f27", prompt: `the entrance of a small chinese town at dusk, the female protagonist in dust-worn jianghu travel robes walking past the wooden archway, a tall young swordsman with a sealed letter in his hand kneeling at the foot of the gate looking up at her with three years of waiting in his eyes, ${S["国风水墨"]}`, w: W, h: H },
{ name: "f28", prompt: `a cozy modern apartment living room at night, a young woman sprawled on the sofa eating watermelon while watching a tv variety show on which a famous male celebrity smiles ambiguously about his wife, her phone exploding with thousands of incoming notifications, ${S["真实系"]}`, w: W, h: H },
{ name: "f29", prompt: `the doorway of a shared modern apartment at night, the female protagonist standing speechless as her year-long roommate places one hand on the doorframe beside her head leaning down with quiet sincerity, ${S["日系动画"]}`, w: W, h: H },
{ name: "f30", prompt: `interior of a dim underground bunker, the female protagonist crouched as a battered humanoid mech crashes through the metal door behind her, the cockpit opens and a young handsome man in pilot armor inside extends his hand toward her, sparks and emergency red light, ${S["赛博朋克"]}`, w: W, h: H },
{ name: "f31", prompt: `a brightly lit indoor basketball stadium overflowing with confetti at championship victory, a victorious handsome male player in jersey number 3 running across the court toward the camera holding up the gold trophy, the female protagonist standing courtside with hands over her mouth in tears of joy, ${S["日系动画"]}`, w: W, h: H },
];
const ALL = [...MALE, ...FEMALE];
/* ---------- Runware caller ---------- */
async function generate({ prompt, w, h }) {
const body = [
{
taskType: "imageInference",
taskUUID: crypto.randomUUID(),
model: MODEL,
positivePrompt: `${prompt}, ${BASE_QUALITY}`,
width: w,
height: h,
steps: 4,
CFGScale: 3.5,
numberResults: 1,
outputType: "base64Data",
outputFormat: "PNG",
},
];
const res = await fetch(BASE_URL.replace(/\/$/, ""), {
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${API_KEY}`,
},
body: JSON.stringify(body),
});
const text = await res.text();
let json;
try {
json = JSON.parse(text);
} catch {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`);
}
if (json.errors?.length) {
const e = json.errors[0];
throw new Error(`Runware [${e.code ?? "?"}]: ${e.message ?? "no msg"}`);
}
const b64 = json.data?.[0]?.imageBase64Data;
if (!b64) throw new Error(`No image data: ${text.slice(0, 200)}`);
return Buffer.from(b64, "base64");
}
/* ---------- main loop ---------- */
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const total = ALL.length;
let done = 0;
let skipped = 0;
let failed = 0;
const t0 = Date.now();
console.log(`[gen] ${total} cards → ${OUT_DIR}`);
for (const card of ALL) {
const out = resolve(OUT_DIR, `${card.name}.png`);
const webpOut = resolve(OUT_DIR, `${card.name}.webp`);
if (!FORCE && (existsSync(out) || existsSync(webpOut))) {
const path = existsSync(out) ? out : webpOut;
const size = statSync(path).size;
if (size > 1024) {
skipped++;
done++;
console.log(`[${done}/${total}] skip ${card.name} (${size} B)`);
continue;
}
}
const label = `[${++done}/${total}] ${card.name}`;
process.stdout.write(`${label}`);
const t = Date.now();
try {
const buf = await generate(card);
writeFileSync(out, buf);
process.stdout.write(`ok ${buf.length} B in ${Math.round((Date.now() - t) / 100) / 10}s\n`);
} catch (e) {
failed++;
process.stdout.write(`FAIL: ${e.message}\n`);
}
}
console.log(
`\n[gen] done in ${Math.round((Date.now() - t0) / 1000)}s — generated ${
done - skipped - failed
} / skipped ${skipped} / failed ${failed}`,
);
process.exit(failed ? 1 : 0);
+128
View File
@@ -0,0 +1,128 @@
#!/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;
}
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);
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env node
/**
* Compresses the freshly generated public/home/*.png into much
* smaller .webp files alongside them, then deletes the originals.
* Output webps target ~1200px on the long edge and quality 78.
*/
import { readdirSync, statSync, unlinkSync } from "node:fs";
import { resolve, dirname, extname, basename } from "node:path";
import { fileURLToPath } from "node:url";
import sharp from "sharp";
const __dirname = dirname(fileURLToPath(import.meta.url));
const DIR = resolve(__dirname, "..", "public", "home");
const MAX_EDGE = 1200;
const QUALITY = 78;
const files = readdirSync(DIR).filter((f) => f.toLowerCase().endsWith(".png"));
let totalIn = 0;
let totalOut = 0;
for (const f of files) {
const inPath = resolve(DIR, f);
const outPath = resolve(DIR, basename(f, extname(f)) + ".webp");
const inSize = statSync(inPath).size;
totalIn += inSize;
const img = sharp(inPath);
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 >= meta.height ? MAX_EDGE : undefined, height: meta.height > meta.width ? MAX_EDGE : undefined }) : img;
await resized.webp({ quality: QUALITY, effort: 5 }).toFile(outPath);
const outSize = statSync(outPath).size;
totalOut += outSize;
console.log(`${f.padEnd(16)} ${(inSize / 1024).toFixed(0).padStart(5)} KB → ${(outSize / 1024).toFixed(0).padStart(4)} KB`);
unlinkSync(inPath);
}
console.log(
`\nTotal: ${(totalIn / 1024 / 1024).toFixed(1)} MB → ${(totalOut / 1024 / 1024).toFixed(2)} MB`,
);
+209
View File
@@ -0,0 +1,209 @@
#!/usr/bin/env node
/**
* One-off generator: produces the InfiPlot homepage "instant-play" first-act
* JSONs by driving each curated card through the live engine (POST /api/start)
* and saving the full StartResponse under public/home/firstact/.
*
* The /play page detects ?card=<name> and hydrates Session from the JSON
* instead of calling /api/start, so click-to-play feels instant — only the
* Runware-CDN background download + decode happens after navigation.
*
* Assumes a dev server is running at http://localhost:3000 (override with
* BASE_URL env var). Idempotent: skips any card whose JSON already exists.
* Pass --force to regenerate all 64.
*
* Run once:
* node scripts/prebake-firstacts.mjs
*
* Concurrency 4 to avoid LLM/Runware/MiMo provider rate limits.
*/
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { existsSync, mkdirSync, writeFileSync, statSync } from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const OUT_DIR = resolve(WEB_ROOT, "public", "home", "firstact");
const FORCE = process.argv.includes("--force");
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const CONCURRENCY = 4;
// Mirror of app/page.tsx STYLE_MAP — keep these in sync. The engine
// only needs the prose styleGuide string; this script maps card.style → that.
const STYLE_MAP = {
二次元: "唯美二次元动漫插画,日系 galgame 精致质感,柔和温暖的自然光照。",
吉卜力: "吉卜力工作室风格,手绘动画质感,柔和水彩底色,温暖治愈的氛围。",
真实系: "真实电影感,柔和自然光照,胶片颗粒。",
超写实: "超写实人像与场景,电影级布光,皮肤与材质细节精致。",
水彩: "水彩插画,湿润晕染笔触,纸纹底色。",
像素风: "像素风格,复古游戏 16-bit 调色,方块化几何造型。",
日系动画: "现代日系动画 cel-shading,硬光阴影分层,赛璐璐风。",
"3D 渲染": "3D 渲染卡通风格,柔和次表面散射,干净的电影级布光。",
蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。",
玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。",
国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。",
赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。",
};
// Mirror of app/page.tsx STORIES, flat with name + gender. Indexes
// match the m0..m31 / f0..f31 cover filenames.
const CARDS = [
// 男性向 m0..m31
{ name: "m0", gender: "男性向", title: "战神归来", style: "真实系", outline: "五年前我战死边境,灵柩送回家时她抱着儿子改嫁了。今天我站在他们的婚礼门口,新郎刚要骂人,跪在他面前的二十个保镖喊了我一声「上将」。" },
{ name: "m1", gender: "男性向", title: "神医归乡", style: "吉卜力", outline: "在城里被嘲笑成「江湖野医生」的我,回了一趟老家。村口的老人见到我直接哭了:「您终于回来了,您当年的师父…病了。」其实他们不知道,我现在是国手第一。" },
{ name: "m2", gender: "男性向", title: "赘婿亮剑", style: "真实系", outline: "岳父大寿,我端着茶被全场嫌弃,一句「废物」让我滚出去。门外停着九辆悬挂军牌的劳斯莱斯,下来的人朝我深深一鞠躬:「少爷,集团等您回去签字。」" },
{ name: "m3", gender: "男性向", title: "送外卖的少主", style: "二次元", outline: "你以为我是给你送了三个月外卖的那个小哥?昨晚有人对我说:「少主,您隐姓埋名的三年,到了。」——而你昨天还笑我连一杯咖啡都买不起。" },
{ name: "m4", gender: "男性向", title: "兵王食言", style: "真实系", outline: "退役那天我答应过队长:「这辈子不再开枪。」但你今天在我面前打了她一巴掌,那我食言一次。" },
{ name: "m5", gender: "男性向", title: "重生分手前夜", style: "日系动画", outline: "凌晨四点,我醒在我们分手的那个夜晚——她正打开门要走。这一次,我先把戒指递了出去:「分手,但戒指你拿好,下个月你会用到它。」" },
{ name: "m6", gender: "男性向", title: "重生回到高考前", style: "吉卜力", outline: "我重生回到高考前一周。这一次,我提前知道了每一道压轴题,也知道了——三天后,她会在天台上跳下去。" },
{ name: "m7", gender: "男性向", title: "墓前签到", style: "二次元", outline: "我每天去亡妻的墓地签到,第七天,系统弹出一行字:「奖励到账:未亡人 × 1。」墓碑后走出一个长得和她一模一样的姑娘:「你是…谁?」" },
{ name: "m8", gender: "男性向", title: "凌晨四点抽卡", style: "3D 渲染", outline: "凌晨三点,我十连抽 SSR 出货,光柱从屏幕里溢出来。客厅响起脚步声,一个穿着我 T 恤的女人揉着眼睛走出来:「老公,你也太晚了。」" },
{ name: "m9", gender: "男性向", title: "系统选妃", style: "二次元", outline: "系统给了我七个未婚妻候选,每错一个,地图上就有一座城被抹掉。倒计时 30 秒,她们七个同时朝我看过来。" },
{ name: "m10", gender: "男性向", title: "穿成废柴皇子", style: "国风水墨", outline: "睁眼是冷宫废柴皇子,太监正在念赐死圣旨。我笑了——上辈子读的那本《这就是大唐》,是我自己写的。" },
{ name: "m11", gender: "男性向", title: "穿成乙游男配", style: "二次元", outline: "我穿成了乙游里第一章就被处刑的反派男配。倒计时三个月。可女主她…昨天竟然主动来找我了。" },
{ name: "m12", gender: "男性向", title: "毒酒之后", style: "真实系", outline: "睁眼是 1928 年,我刚被亲弟弟下毒,倒在少帅府的红毯上。门外军靴声逼近——他来确认我是不是真死了。" },
{ name: "m13", gender: "男性向", title: "九重雷劫", style: "玄幻", outline: "修了三百年,今夜九重雷劫降下。第八道劫雷劈开时,我看见劫云之上,那个一直在偷偷护我的人,竟是她。" },
{ name: "m14", gender: "男性向", title: "山门扫地僧", style: "国风水墨", outline: "我在山门扫地三十年,谁都看不起我。今日魔尊踏破山门,宗主跪地求饶。我抬头:「让一让,我去扫他。」" },
{ name: "m15", gender: "男性向", title: "末世第一夜", style: "真实系", outline: "同寝的兄弟开始啃我的脖子。我抬手将他甩开——指尖滴下的血珠悬在半空,凝结成了一柄银白小剑。" },
{ name: "m16", gender: "男性向", title: "雷霆觉醒", style: "赛博朋克", outline: "雷劈不死的第七天,我握紧了拳头。掌心炸开一道闪电,把面前的丧尸群一齐劈成了灰。" },
{ name: "m17", gender: "男性向", title: "家宴镇压", style: "真实系", outline: "家宴上岳父冷笑:「你也敢上桌?」我手机震了一下,是父亲发来的:「儿,神州七大家主,已到楼下。」" },
{ name: "m18", gender: "男性向", title: "买葱归来", style: "国风水墨", outline: "二十年前那场天工大会上消失的人——今天回菜市场买葱,被小贩多收了两毛。他笑了:「这二十年的利息,连本带利,今晚一起还。」" },
{ name: "m19", gender: "男性向", title: "红盖头之下", style: "超写实", outline: "敌对家族送来一个新娘,遮着红盖头。我掀开那一刻,下面是和我死去的妹妹一模一样的脸。她抬眼:「哥…你别杀我。」" },
{ name: "m20", gender: "男性向", title: "上海双面谍", style: "真实系", outline: "1936 年。军统让我潜入日方,日方让我潜入军统。今晚——他们要见面,而我必须同时出现在两间房里。" },
{ name: "m21", gender: "男性向", title: "比武场的茶博士", style: "国风水墨", outline: "比武大会上,我端着茶水路过,宗主们的剑突然全都举不起来了。我抬眼:「老衲只是看不下去你们吵架。」" },
{ name: "m22", gender: "男性向", title: "高考前夜", style: "日系动画", outline: "全市模考垫底的我,高考前夜被四个西装男按在桌前:「这次,你必须考第一。」原来——我爸是教育部的人。" },
{ name: "m23", gender: "男性向", title: "失踪一年", style: "真实系", outline: "我被宣告死亡 12 个月后,背着血迹斑斑的包,站在了她婚礼现场的门口。新郎认出我,杯子摔到了地上。" },
{ name: "m24", gender: "男性向", title: "天台堵她", style: "日系动画", outline: "学校最不好惹的那位转学生,第一天就堵了我的天台。我把她书包一扯——里面掉出来一沓我从小写的情书。" },
{ name: "m25", gender: "男性向", title: "转学第一天", style: "二次元", outline: "转学第一天,年级第一坐我后桌。下课她把试卷拍在我面前:「这道题,你为什么写得和我答案一字不差?」" },
{ name: "m26", gender: "男性向", title: "无职觉醒", style: "玄幻", outline: "成年礼上全班觉醒职业,只有我天命「无职」。所有人嘲笑我的时候,光柱从我身上炸开——觉醒结果:「神」。" },
{ name: "m27", gender: "男性向", title: "草稿纸里的我", style: "像素风", outline: "睁眼发现自己是一张草稿纸上的火柴小人,住在 16-bit 的网格世界里。橡皮擦从天而降,正在抹掉这一行字——也包括我。" },
{ name: "m28", gender: "男性向", title: "云上的国家", style: "蒸汽朋克", outline: "齿轮轰鸣的飞艇甲板上,独眼船长把望远镜递到我手里:「云的那一头有个国家,专门关像你这样的人。」" },
{ name: "m29", gender: "男性向", title: "舰桥上的少年", style: "赛博朋克", outline: "殖民母舰只剩 30 秒,主炮指挥官的椅子是空的。舰长抬眼看着 17 岁的我:「上去。整个人类,就交给你了。」" },
{ name: "m30", gender: "男性向", title: "末节队长服", style: "赛博朋克", outline: "全联盟都骂我废柴,机甲赛决赛末节,教练把队长徽章按在我手里:「上去,把这局赢回来——这一台,是人类最后的机甲。」" },
{ name: "m31", gender: "男性向", title: "学长的真面目", style: "真实系", outline: "三年青梅当众接过富二代的玫瑰,转身扑进他怀里。我笑了笑——明天,是我接手父亲那个上市公司的日子。" },
// 女性向 f0..f31
{ name: "f0", gender: "女性向", title: "废柴嫡女", style: "国风水墨", outline: "穿成将军府众人嫌弃的废柴嫡女,第一天就被打了一巴掌。门外冷面摄政王翻身下马,「我夫人的脸,谁敢动?」" },
{ name: "f1", gender: "女性向", title: "乙游恶役", style: "二次元", outline: "睁眼是乙游里五分钟必死的恶役千金,所有男主都恨我。我合上剧本笑了——上一世我是这游戏的主笔。" },
{ name: "f2", gender: "女性向", title: "白月光归来", style: "玄幻", outline: "穿成男主念念不忘的白月光,但全书她只有死亡这一种结局。我捏着男主送的玉佩走进祠堂——这一次,我不躲了。" },
{ name: "f3", gender: "女性向", title: "凤袍之下", style: "国风水墨", outline: "穿越来就是当朝皇后,三千佳丽看我笑话。皇上掀开龙袍跪在我面前:「皇后,朕想她想了三十年了。」" },
{ name: "f4", gender: "女性向", title: "嫁错重生", style: "二次元", outline: "嫁错了人毁了一辈子,重生回到婚礼前夜。这一次新娘休书我先写。新郎的弟弟突然走进来:「嫂子,要换人,换我。」" },
{ name: "f5", gender: "女性向", title: "那杯咖啡", style: "真实系", outline: "重生回到他亲手把我送进车祸的前夜。我笑着接过他递来的咖啡——这是一杯我前世死前最想泼他脸上的咖啡。" },
{ name: "f6", gender: "女性向", title: "雨中撑伞", style: "真实系", outline: "重生回到我亲手要了她命的前一天。她正抱着公文包路过我的车——这一次,我下车撑伞。" },
{ name: "f7", gender: "女性向", title: "三十亿合同", style: "真实系", outline: "重生回到我被父亲扫地出门的那个清晨。这一次,扫地出门前我把家族 30 亿的合同提前签了。" },
{ name: "f8", gender: "女性向", title: "替嫁霸总", style: "二次元", outline: "替姐姐嫁给那个传说眼瞎心冷的总裁。新婚夜他俯身在我耳边:「你姐没告诉你?我等了你三年了。」" },
{ name: "f9", gender: "女性向", title: "错嫁那一夜", style: "真实系", outline: "醉酒夜我闯进了错的酒店房间,醒来戒指已在手上。他穿好西装回头:「夫人,签字仪式三小时后。」" },
{ name: "f10", gender: "女性向", title: "撕了离婚书", style: "真实系", outline: "为了避税,我和那个最讨厌我的总裁假结婚一年。半年后他突然把离婚协议撕了——「续约。」" },
{ name: "f11", gender: "女性向", title: "死对头跪了", style: "二次元", outline: "天天和我互掐的死对头,今天跪在我面前。他递上戒指:「再吵下去要影响我们的孩子。」——什么孩子?!" },
{ name: "f12", gender: "女性向", title: "抽到的霸总", style: "3D 渲染", outline: "凌晨四点抽到 UR 卡——画面里是城里那个传说没人见过脸的盛家总裁。第二天他敲我家门:「我来报到。」" },
{ name: "f13", gender: "女性向", title: "攻略任务", style: "二次元", outline: "系统说:「攻略他,否则你死。」可他是这本书里唯一恨我入骨的人。今天他亲手把我堵在了墙角。" },
{ name: "f14", gender: "女性向", title: "商城上架", style: "二次元", outline: "系统商城上架了「市值 800 亿盛总 × 1」。我咬牙刷光积蓄。下一秒,他出现在我家门口:「夫人,我已购入。」" },
{ name: "f15", gender: "女性向", title: "老公赞助", style: "日系动画", outline: "直播间打赏榜第一名连续 30 天,备注写着「老公赞助」。我点开他的资料——城里那位传说从不出门的盛少。" },
{ name: "f16", gender: "女性向", title: "门外的他", style: "真实系", outline: "末世第一夜,门外是丧尸群的撕咬声。隔壁刚搬来的男人撞开我家门:「我能进来吗?我有一把枪。」" },
{ name: "f17", gender: "女性向", title: "末世空间", style: "真实系", outline: "末世爆发的第一天,我意外觉醒了储物空间。屯了三车物资回家,发现那个总欺负我的高冷邻居跪在我门口。" },
{ name: "f18", gender: "女性向", title: "异能撒娇", style: "二次元", outline: "末世里所有男人都怕的那位 S 级异能者,今天蹲在我家门口:「姐姐,能让我进去吗?外面…丧尸太可怕了。」" },
{ name: "f19", gender: "女性向", title: "末世重生", style: "真实系", outline: "重生回到末世爆发前一周。这一次,那个抛弃我的男人——我先把他赶出门,把上一世救我的人接回家。" },
{ name: "f20", gender: "女性向", title: "课桌里的纸条", style: "二次元", outline: "隔壁班那个高冷年级第一,今天把一本日记塞进我课桌。第一页写着:「她笑起来的时候,三角函数都没那么复杂。」" },
{ name: "f21", gender: "女性向", title: "校草八年", style: "吉卜力", outline: "暗恋了八年的校草,今天突然走到我面前:「跟我走,我已经查清楚了——把你妹妹接走的那个人在哪。」" },
{ name: "f22", gender: "女性向", title: "班长的秘密", style: "二次元", outline: "天天和我同桌的班长,今天被四个保镖按在校门口接走。临走前他回头喊:「老婆,我先回总部一趟。」" },
{ name: "f23", gender: "女性向", title: "走廊的手腕", style: "日系动画", outline: "走廊上人最多的时候,全校最不好惹的学长抓住了我的手腕:「我等了你三年,今天给我一个回应。」" },
{ name: "f24", gender: "女性向", title: "上海公馆", style: "超写实", outline: "1936,我是父亲遗产的唯一继承人,全上海都在等看我嫁谁。今晚我推开门——那个传说不要女人的留洋先生,在喝我父亲的茶。" },
{ name: "f25", gender: "女性向", title: "书店里的他", style: "真实系", outline: "我是租界一家书店的老板娘。今晚穿西装的他第三次坐在窗边,第一次开口:「小姐,可以借您的店…藏一个东西吗?」" },
{ name: "f26", gender: "女性向", title: "炼丹意外", style: "玄幻", outline: "我是仙门最废柴的炼丹弟子,三年没炼出一颗丹。今天偶然撞翻师尊的丹炉——一道光柱直冲云霄,惊动了三大长老。" },
{ name: "f27", gender: "女性向", title: "江湖归人", style: "国风水墨", outline: "我一个人闯江湖三年,今天回到那座小镇。门口的少年抬头:「师姐,你说过五年就回,我等了三年又两个月。」" },
{ name: "f28", gender: "女性向", title: "顶流的西瓜", style: "真实系", outline: "顶流男星上节目被问感情,他笑了笑:「我老婆?她现在大概在家里啃我刚买的西瓜。」全网爆炸——我正趴在沙发上看直播。" },
{ name: "f29", gender: "女性向", title: "同居一年", style: "日系动画", outline: "和合租室友同居一年了,今晚他突然把我堵在门口:「你说,我们…要不要别再装陌生人了?」" },
{ name: "f30", gender: "女性向", title: "机甲撞门", style: "赛博朋克", outline: "丧尸潮第七夜,全城断电。地下室的门被撞开,一架满是弹痕的机甲低下头,舱门弹开——里面坐着我那个失联三年的他。" },
{ name: "f31", gender: "女性向", title: "三分绝杀", style: "日系动画", outline: "决赛最后一秒,他在场边看了我一眼,转身投出那一记三分。哨声响时,他把奖杯举过头顶,朝我跑来。" },
];
// Same construction as page.tsx onCardClick. Locked plotStyle/pace at the
// canonical "多线转折 / 紧凑爽快" defaults — the prebake is one frozen pour
// of the story; the user's selector still applies on the homepage for
// custom typed-prompt sessions, just not for these curated cards.
function buildPayload(card) {
const worldSetting = [
`这是一款面向【${card.gender}】观众的 AI 交互剧情游戏,整体走红果短视频式的强戏剧冲突与快速反转。`,
`剧情风格:多线转折。内容节奏:紧凑爽快。`,
`精选剧情《${card.title}》的开场设定:${card.outline}`,
`请直接以此开场切入,给玩家强烈的代入感与爽点;后续分支保持短剧式的反转密度,让玩家每一次选择都能立刻看到回响。`,
].join("\n");
const styleGuide = STYLE_MAP[card.style] ?? STYLE_MAP["二次元"];
return { worldSetting, styleGuide };
}
async function bakeOne(card) {
const out = resolve(OUT_DIR, `${card.name}.json`);
if (!FORCE && existsSync(out)) {
const size = statSync(out).size;
if (size > 1024) return { name: card.name, status: "skip", size };
}
const payload = buildPayload(card);
const t = Date.now();
const res = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
const data = await res.json();
// Tag the JSON with the curated card identity so the /play page can show
// the right "lastExitLabel"-style chrome without us having to re-look it up.
data.cardName = card.name;
data.cardTitle = card.title;
data.cardGender = card.gender;
// StartResponse doesn't echo the inputs back — but the /play page needs to
// seed Session.worldSetting / Session.styleGuide so subsequent /api/scene
// calls (read on the server) see the right story bible + visual anchor.
data.worldSetting = payload.worldSetting;
data.styleGuide = payload.styleGuide;
writeFileSync(out, JSON.stringify(data));
return { name: card.name, status: "ok", ms: Date.now() - t, size: statSync(out).size };
}
/* ---------- main: bounded-concurrency runner ---------- */
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
const t0 = Date.now();
console.log(`[prebake] ${CARDS.length} cards → ${OUT_DIR} (concurrency=${CONCURRENCY})`);
let cursor = 0;
let done = 0;
let skipped = 0;
let failed = 0;
async function worker(id) {
while (true) {
const i = cursor++;
if (i >= CARDS.length) return;
const card = CARDS[i];
const label = `[${i + 1}/${CARDS.length}] ${card.name}`;
try {
const r = await bakeOne(card);
done++;
if (r.status === "skip") {
skipped++;
console.log(`${label} skip (${r.size} B)`);
} else {
console.log(`${label} ok ${(r.size / 1024).toFixed(0)} KB in ${(r.ms / 1000).toFixed(1)}s`);
}
} catch (e) {
failed++;
console.log(`${label} FAIL: ${e.message}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, (_, i) => worker(i)));
console.log(
`\n[prebake] done in ${Math.round((Date.now() - t0) / 1000)}s — wrote ${
done - skipped
} / skipped ${skipped} / failed ${failed}`,
);
process.exit(failed ? 1 : 0);