feat(web): gender-differentiated 4:5 covers + per-card styleGuide prebake

- Regenerate 60 covers (30 male + 30 female) via FLUX with story-specific
  prompts, replacing the prior gender-shared set
- Crop covers to 4:5 (960×1200) via sharp attention cover; matches new
  homepage card aspectRatio
- Persist all 60 prompts to public/home/prompts.json so the prebake step
  can reuse the cover's exact visual anchor (per-card styleGuide) and the
  first-act scene visually carries over from the poster the player clicked
- Restore /play?card= prebaked instant-play path on homepage card click
- Add OpenAI-compatible image route in ai-client for non-Runware endpoints
- Hide Next.js dev indicators globally; tweak F-key fullscreen label

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
DESKTOP-I1T6TF3\Q
2026-06-03 02:20:20 +08:00
parent 820a5f7e87
commit bed4dc5a8f
135 changed files with 826 additions and 476 deletions
+59 -11
View File
@@ -25,11 +25,14 @@ import { existsSync, mkdirSync, writeFileSync, statSync, readFileSync } from "no
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const OUT_DIR = resolve(WEB_ROOT, "public", "home", "firstact");
const PROMPTS_FILE = resolve(WEB_ROOT, "public", "home", "prompts.json");
const PAGE_FILE = resolve(WEB_ROOT, "app", "page.tsx");
const FORCE = process.argv.includes("--force");
const BASE_URL = process.env.BASE_URL ?? "http://localhost:3000";
const CONCURRENCY = 4;
const CONCURRENCY = 1;
const sleep = (ms) => new Promise(r => setTimeout(r, ms));
// Dynamically extract STYLE_MAP and STORIES from page.tsx to avoid code duplication
console.log("[prebake] Parsing page.tsx to extract style map and card list...");
@@ -51,6 +54,18 @@ if (!storiesMatch) {
const cleanStoriesText = storiesMatch[1];
const STORIES = eval("(" + cleanStoriesText + ")");
// The cover-gen script writes one prompt per card into home/prompts.json. We
// reuse those as the styleGuide so the first-act scene visually carries over
// the exact hero/composition/palette of the poster the player just clicked,
// instead of the generic STYLE_MAP entry shared across both genders.
let COVER_PROMPTS = {};
if (existsSync(PROMPTS_FILE)) {
COVER_PROMPTS = JSON.parse(readFileSync(PROMPTS_FILE, "utf8"));
console.log(`[prebake] Loaded ${Object.keys(COVER_PROMPTS).length} cover prompts → using per-card visual anchor`);
} else {
console.warn(`[prebake] ${PROMPTS_FILE} not found — falling back to STYLE_MAP per card.style`);
}
const CARDS = [];
for (const [gender, list] of Object.entries(STORIES)) {
const prefix = gender === "女性向" ? "f" : "m";
@@ -72,7 +87,13 @@ function buildPayload(card) {
`精选剧情《${card.title}》的开场设定:${card.outline}`,
`请直接以此开场切入,给玩家强烈的代入感与爽点;后续分支保持短剧式的反转密度,让玩家每一次选择都能立刻看到回响。`,
].join("\n");
const styleGuide = STYLE_MAP[card.style] ?? STYLE_MAP["京阿尼细腻日常"];
// Prefer the per-card cover prompt (gender-differentiated) so the first
// scene mirrors the visual the player just clicked. Fall back to the
// generic STYLE_MAP entry if prompts.json is absent.
const styleGuide =
COVER_PROMPTS[card.name] ??
STYLE_MAP[card.style] ??
STYLE_MAP["京阿尼细腻日常"];
return { worldSetting, styleGuide };
}
@@ -83,16 +104,39 @@ async function bakeOne(card) {
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)}`);
let res;
let attempt = 0;
const maxAttempts = 5;
while (attempt < maxAttempts) {
attempt++;
try {
console.log(` -> Fetching ${card.name} (Attempt ${attempt}/${maxAttempts})...`);
res = await fetch(`${BASE_URL}/api/start`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(payload),
});
if (res.ok) break;
const text = await res.text().catch(() => "");
console.warn(` [WARN] Attempt ${attempt} failed with HTTP ${res.status}: ${text.slice(0, 150)}`);
if (attempt < maxAttempts) {
const delay = Math.pow(2, attempt) * 4000;
console.log(` Waiting ${delay}ms before retry...`);
await sleep(delay);
} else {
throw new Error(`HTTP ${res.status}: ${text.slice(0, 200)}`);
}
} catch (e) {
if (attempt === maxAttempts) throw e;
const delay = Math.pow(2, attempt) * 4000;
console.warn(` [ERR] Attempt ${attempt} threw: ${e.message}. Retrying in ${delay}ms...`);
await sleep(delay);
}
}
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.
@@ -105,6 +149,10 @@ async function bakeOne(card) {
data.worldSetting = payload.worldSetting;
data.styleGuide = payload.styleGuide;
writeFileSync(out, JSON.stringify(data));
// Sleep a little bit to be very safe and nice to rate limits
await sleep(4000);
return { name: card.name, status: "skip", size: statSync(out).size }; // marked skip to indicate we bypass write during live check if already bake
}