Files
infiplot-web/scripts/generate-presets.mjs
DESKTOP-I1T6TF3\Q bed4dc5a8f 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>
2026-06-03 02:26:35 +08:00

193 lines
7.3 KiB
JavaScript

#!/usr/bin/env node
import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
import { readFileSync, writeFileSync } from "node:fs";
const __dirname = dirname(fileURLToPath(import.meta.url));
const WEB_ROOT = resolve(__dirname, "..");
const ENV_FILE = resolve(WEB_ROOT, ".env.local");
const PAGE_FILE = resolve(WEB_ROOT, "app", "page.tsx");
/* ---------- env loading ---------- */
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.TEXT_BASE_URL;
const API_KEY = env.TEXT_API_KEY;
const MODEL = env.TEXT_MODEL;
if (!BASE_URL || !API_KEY || !MODEL) {
console.error("Missing TEXT_BASE_URL / TEXT_API_KEY / TEXT_MODEL in", ENV_FILE);
process.exit(2);
}
const STYLES = [
"古典厚涂油画 (学术奇幻)",
"极简中国水墨 (Image 0参考升级版)",
"浮世绘木刻 (美人画升级)",
"莫高窟壁画风 (敦煌学)",
"细密画 (波斯/伊斯兰风)",
"镶嵌画 (拜占庭/马赛克)",
"彩绘玻璃 (哥特风)",
"吉卜力治愈手绘 (Image 4参考)",
"京阿尼细腻日常 (Image 5参考)",
"新海诚唯美光影 (Image 2参考)",
"赛博朋克 / 赛璐珞二次元",
"Galgame CG 梦幻光影",
"3D 动漫电影质感",
"蒸汽波 (Vaporwave) 赛璐珞",
"极简矢量插画 (Minimalist Vector)",
"低多边形 (Low Poly)",
"双重曝光 (Double Exposure)",
"波普艺术 (Pop Art)",
"故障艺术 (Glitch Art)",
"瑞士平面设计 (Typography-Centric)",
"剪纸艺术 (Papercut)",
"科幻:太阳朋克 (Solar Punk)",
"奇幻:爱手艺 (Lovecraftian Horror)",
"现代惊悚:霓虹剪影 (Urban Noir)",
"温馨推理:英式村庄 (Cozy Mystery)",
"哥特言情:庄园废墟 (Gothic Romance)",
"格林童话:暗黑森林 (Fairytale Noir)",
"废土科幻 (Post-Apocalyptic)",
"都市幻想:隐形世界 (Urban Fantasy)",
"文字与图形:抽象主义 (BookPosterLayout)"
];
const SYSTEM_PROMPT = `你是一个顶级互动式视觉小说剧情策划和爆款短剧编剧。
你精通各种网文爽点与戏剧冲突(例如:战神归来、赘婿亮剑、系统觉醒、都市异能、白月光、逆袭、豪门恩怨、重生、虐心、甜宠、扮猪吃虎等爆款套路)。
请根据给定的 30 个艺术/视觉风格,分别从「男性向(面向男玩家)」和「女性向(面向女玩家)」视角,为每个风格策划一个极具戏剧张力、代入感极强的开场预设剧情。
每个预设剧情包含:
1. title: 故事标题(4-8字,吸睛爆款风格,例如《贤者陨落》《棺中新娘》《辐射新娘》)
2. outline: 开场剧情简介 / 钩子(1-3句话,100字以内,充满悬念与强冲突,给玩家强烈的代入感与爽点)。
3. tags: 数组,包含 2 到 3 个最契合 the grid 系统的网文/短剧中文分类标签(例如:["逆袭", "系统", "都市玄幻"]、["重生", "虐心", "科幻废土"]、["甜宠", "穿越", "古风言情"]等)。
4. style: 对应的风格名称(必须与输入一致)
要求:
- 请严格返回 JSON 格式,包含 "男性向" 数组(30个)和 "女性向" 数组(30个)。
- 不要返回任何 markdown 标记包裹的文本,只返回纯合法的 JSON 字符串。
- 确保数组中的元素严格对应输入的 30 个艺术风格(按顺序一一对应,共 60 个故事卡片)。
- 内容必须极具网文爆款爽文短剧感,有强烈的冲突 and 反转。`;
const USER_PROMPT = `请按照顺序,为以下 30 个风格各生成一个男性向和一个女性向的预设故事卡片(包含 title, outline, tags, style 字段):
${STYLES.map((s, i) => `${i + 1}. ${s}`).join("\n")}
请严格按照如下 JSON 结构返回(不要有 \`\`\`json 标记,只输出纯 JSON):
{
"男性向": [
{ "title": "...", "outline": "...", "tags": ["...", "..."], "style": "古典厚涂油画 (学术奇幻)" },
...
],
"女性向": [
{ "title": "...", "outline": "...", "tags": ["...", "..."], "style": "古典厚涂油画 (学术奇幻)" },
...
]
}`;
async function main() {
console.log("[presets] Calling LLM API to generate 30 story presets with tags...");
const t0 = Date.now();
const url = BASE_URL.replace(/\/$/, "") + "/chat/completions";
const res = await fetch(url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${API_KEY}`
},
body: JSON.stringify({
model: MODEL,
messages: [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: USER_PROMPT }
],
temperature: 0.85,
response_format: { type: "json_object" }
})
});
if (!res.ok) {
const txt = await res.text();
console.error(`HTTP error ${res.status}: ${txt}`);
process.exit(1);
}
const data = await res.json();
const rawText = data.choices?.[0]?.message?.content;
if (!rawText) {
console.error("No content in LLM response", JSON.stringify(data));
process.exit(1);
}
let parsed;
try {
const cleanJsonText = rawText.replace(/^```json\s*/i, "").replace(/```$/, "").trim();
parsed = JSON.parse(cleanJsonText);
} catch (e) {
console.error("Failed to parse JSON from LLM output. Raw content:\n", rawText);
process.exit(1);
}
if (!parsed["男性向"] || !parsed["女性向"] || parsed["男性向"].length !== 30 || parsed["女性向"].length !== 30) {
console.error("Invalid output structure or item count mismatch. Male count:", parsed["男性向"]?.length, "Female count:", parsed["女性向"]?.length);
process.exit(1);
}
console.log(`[presets] Successfully generated 60 stories in ${((Date.now() - t0)/1000).toFixed(1)}s.`);
// Write new STORIES constant to apps/web/app/page.tsx
console.log("[presets] Reading page.tsx...");
let pageContent = readFileSync(PAGE_FILE, "utf8");
// Normalize line endings to LF
const hasCrlf = pageContent.includes('\r\n');
if (hasCrlf) {
pageContent = pageContent.replace(/\r\n/g, '\n');
}
// Format the STORIES constant string
const storiesString = `const STORIES: Record<Gender, StoryContent[]> = {
男性向: ${JSON.stringify(parsed["男性向"], null, 2)},
女性向: ${JSON.stringify(parsed["女性向"], null, 2)}
};`;
// Locate the old STORIES constant
const storiesRegex = /const STORIES: Record<Gender, StoryContent\[\]> = \{[\s\S]*?\n\};/m;
if (!storiesRegex.test(pageContent)) {
console.error("Could not find 'const STORIES: Record<Gender, StoryContent[]> = {' in page.tsx!");
process.exit(1);
}
pageContent = pageContent.replace(storiesRegex, storiesString);
// Restore line endings if they were originally CRLF
if (hasCrlf) {
pageContent = pageContent.replace(/\n/g, '\r\n');
}
writeFileSync(PAGE_FILE, pageContent, "utf8");
console.log("[presets] Successfully updated page.tsx with the new 60 story cards (including tags)!");
}
main().catch(e => {
console.error(e);
process.exit(1);
});