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:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user