feat(web): refactor home preset cards to 16:9 poster style with titles and tags below cover
This commit is contained in:
@@ -0,0 +1,168 @@
|
||||
#!/usr/bin/env node
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { readFileSync, existsSync, mkdirSync, 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 OUT_DIR = resolve(WEB_ROOT, "public", "home");
|
||||
|
||||
const FORCE = process.argv.includes("--force");
|
||||
|
||||
/* ---------- 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.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);
|
||||
}
|
||||
|
||||
const STYLE_PROMPTS = [
|
||||
"Dark fantasy oil painting style, a sprawling clockwork steampunk city built into a mountain range at twilight, immense gothic spires with glowing green lamps, complex gears and platforms. Richly detailed, impasto texture, dramatic academic lighting. A grand airship arrives at a high dock. Horizontal composition with massive clear dark sky for typography.",
|
||||
"Minimalist Chinese ink wash style, a lone immortal cultivator sitting on a precipice, facing an endless sea of clouds and distant jagged peaks. Ethereal, sparse composition with poetic brushstrokes, monochrome palette with subtle blue hints. Very large blank mist area for text placement.",
|
||||
"Ukiyo-e woodblock print style, a majestic red and gold phoenix with elaborate trailing feathers rising above a wave-crested dark blue sea, Mount Fuji visible through cherry branches. Bold outlines, flat colors with paper texture, ancient and mystical atmosphere. Central clear area in the sea and sky for typography.",
|
||||
"Dunhuang fresco style, a celestial apsaras flying with flowing scarves, holding a Lute, surrounded by stylized lotus flowers and floating geometric patterns on an aged stucco wall. Muted, oxidized mineral colors, delicate line art, historical and divine ambiance. Side vertical area cleared for titles.",
|
||||
"Byzantine mosaic style, an iconic portrait of a warrior saint with golden armor and a halo, composed of thousands of small, glittering glass tesseræ. Deep blues and golds, spiritual and ancient feel, flat background. Background field of gold tiles left blank for text.",
|
||||
"Stained glass style, a depiction of a griffin battling a serpent, framed by gothic archways and trefoils. Vibrant, translucent jewel colors, bold black leading lines. The image should look like an ancient window panel. Outer panels of plain blue glass left clear for text.",
|
||||
"Ghibli hand-painted watercolor style, a detailed concept art of a girl and her small companion creature running through a vast wildflower meadow toward a fantastical airship. Natural daylight, soft washes, nostalgic feel. Upper left sky area is negative space for typography.",
|
||||
"KyoAni anime style, fine line art, a detailed high school girl sitting by a library window during light rain, warm library light contrasting the cool moonlight outside. Deep emotional atmosphere, delicate expression. Empty right-side foreground area for title.",
|
||||
"Makoto Shinkai anime style, hyper-detailed, a wide panoramic night view of a glowing cherry tree under a dramatic starry sky with a comet trail, a lonely high school girl in a uniform looking up. Brilliant lighting effects, vivid colors. Significant blank space in the upper atmosphere for text.",
|
||||
"Cyberpunk anime style, cel-shaded animation, a tech-wear protagonist standing on a rainy rooftop, looking out at a dense, neon-drenched futuristic megacity with flying vehicles. Hard edges, high saturation, sharp contrast. Massive upper background sky area for title placement.",
|
||||
"High-quality Galgame CG illustration, a dreamlike beach scene with sparkling waves, a beautiful girl with pastel pink hair in a white summer dress smiling warmly. Pastel colors, bloom lighting, clean composition, soft focus. Significant negative space in the sky and sea area for text.",
|
||||
"Cinematic 3D animated film style (like Makoto Shinkai or Pixar), a high-resolution render of a young boy pilot fixing a small propeller plane in a rustic hangar at sunrise. Volumetric lighting, warm colors, deep textures, cinematic composition. Blank wall space and open doorway area for text.",
|
||||
"Vaporwave aesthetic, anime style, a nostalgic portrait of a character with purple hair wearing sunglasses, a geometric grid floor and palm trees, background sunset over a purple ocean. Glitch effects, soft neon pink and blue palette, retro feel. Blank foreground grid area for title.",
|
||||
"Pop Art style illustration, a close-up of a glamorous woman with red lips and a speech bubble with an exclamation point, rendered with comic book dots and bold outlines. High-saturation contrasting colors. Speech bubble and large background color blocks left blank for text.",
|
||||
"Glitch art style portrait, a character profile distorted by data corruption, pixel sorting, and digital artifacts in cyan, magenta, and yellow. Cybernetic, high-tech and moody atmosphere. Dark, uncorrupted negative space in the upper background for typography.",
|
||||
"Multilayered papercut art style, a 3D landscape of a deep forest and a fairytale castle, made of staggered paper layers with intricate cutouts. Backlighting, soft shadows, dimensional depth. Blank background layer cleared for title placement.",
|
||||
"Solar Punk art style, a wide view of a sustainable, futuristic city integrated with dense green rooftop gardens and vertical farms, illuminated by clean solar and wind energy. Bright, optimistic lighting, organic textures. Large foreground plaza area cleared for titles.",
|
||||
"Dark cosmic horror illustration, a lone explorer stands on a desolate shore, gazing at a massive, ancient, indescribable eldritch entity rising from a stormy sea. Moody, muted cool colors, dramatic lighting, visible brushstrokes. The dark, stormy sky quadrant left completely blank for text.",
|
||||
"Modern urban noir, a minimalist silhouette of a man in a trench coat, standing in a dark, wet alleyway under a single buzzing neon sign reflecting on puddles. High contrast, cinematic noir lighting, deep shadows. The wet cobblestone ground left mostly dark for typography.",
|
||||
"Cozy mystery book cover illustration, a charming, warm English village scene at night, snow on the thatched roofs, golden light from a bookstore window, and a single cat perched on a fence. Comforting and mysterious feel. Significant background sky and foreground pavement area for title.",
|
||||
"Gothic romance illustration, a wide panoramic view of a young woman in a flowing dark velvet dress, standing before the desolate, moonlit ruins of a grand gothic manor on a foggy cliff. Muted greys and blues, romantic and melancholic. The upper background cliff and sky for bold titles.",
|
||||
"Dark fairytale illustration, a wide shot of a small girl in a red cloak walking into a massive, dark, twisted ancient forest where the trees look like claws. Grimm's style, classical illustration, mood of awe and dread. The dark foreground forest ground left blank for text.",
|
||||
"Post-apocalyptic landscape illustration, a vast desert wasteland with the rusted remains of overgrown highway and a fallen Statue of Liberty in the distance under a dusty orange sky. Muted cool and warm colors. Significant clear ground and sky area for text.",
|
||||
"Urban fantasy concept art, a detailed view of a hidden, glowing magical pathway revealed underneath a busy modern pedestrian bridge in a rain-streaked metropolitan city. Contrast of mundane and magical. Minimal detail in the wet street foreground and upper sky for titles."
|
||||
];
|
||||
|
||||
const BASE_QUALITY = "masterpiece, best quality, highly detailed, cinematic lighting, soft warm color grading, intricate background, no text, no watermark";
|
||||
|
||||
const W = 1792;
|
||||
const H = 1024;
|
||||
|
||||
async function generate(prompt) {
|
||||
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 in response: ${text.slice(0, 200)}`);
|
||||
return Buffer.from(b64, "base64");
|
||||
}
|
||||
|
||||
async function main() {
|
||||
if (!existsSync(OUT_DIR)) mkdirSync(OUT_DIR, { recursive: true });
|
||||
|
||||
console.log(`[covers] Starting image generation for 48 covers (24 male, 24 female)...`);
|
||||
const t0 = Date.now();
|
||||
|
||||
for (let i = 0; i < STYLE_PROMPTS.length; i++) {
|
||||
const stylePrompt = STYLE_PROMPTS[i];
|
||||
|
||||
// Male Cover (m{i})
|
||||
const malePngName = `m${i}.png`;
|
||||
const malePngPath = resolve(OUT_DIR, malePngName);
|
||||
const maleWebpPath = resolve(OUT_DIR, `m${i}.webp`);
|
||||
if (!FORCE && (existsSync(malePngPath) || existsSync(maleWebpPath))) {
|
||||
console.log(`[covers] Skip m${i} (already exists)`);
|
||||
} else {
|
||||
console.log(`[covers] Generating m${i} ... `);
|
||||
try {
|
||||
const buf = await generate(stylePrompt);
|
||||
writeFileSync(malePngPath, buf);
|
||||
console.log(`[covers] m${i} successfully generated! size: ${buf.length} B`);
|
||||
} catch (e) {
|
||||
console.error(`[covers] FAIL generating m${i}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Female Cover (f{i})
|
||||
const femalePngName = `f${i}.png`;
|
||||
const femalePngPath = resolve(OUT_DIR, femalePngName);
|
||||
const femaleWebpPath = resolve(OUT_DIR, `f${i}.webp`);
|
||||
if (!FORCE && (existsSync(femalePngPath) || existsSync(femaleWebpPath))) {
|
||||
console.log(`[covers] Skip f${i} (already exists)`);
|
||||
} else {
|
||||
console.log(`[covers] Generating f${i} ... `);
|
||||
try {
|
||||
const buf = await generate(stylePrompt);
|
||||
writeFileSync(femalePngPath, buf);
|
||||
console.log(`[covers] f${i} successfully generated! size: ${buf.length} B`);
|
||||
} catch (e) {
|
||||
console.error(`[covers] FAIL generating f${i}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log(`[covers] Finished generating all covers in ${((Date.now() - t0)/1000).toFixed(1)}s.`);
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,175 @@
|
||||
#!/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 2参考)",
|
||||
"赛博朋克 / 赛璐珞二次元",
|
||||
"Galgame CG 梦幻光影",
|
||||
"3D 动漫电影质感",
|
||||
"蒸汽波 (Vaporwave) 赛璐珞",
|
||||
"波普艺术 (Pop Art)",
|
||||
"故障艺术 (Glitch Art)",
|
||||
"剪纸艺术 (Papercut)",
|
||||
"科幻:太阳朋克 (Solar Punk)",
|
||||
"奇幻:爱手艺 (Lovecraftian Horror)",
|
||||
"现代惊悚:霓虹剪影 (Urban Noir)",
|
||||
"温馨推理:英式村庄 (Cozy Mystery)",
|
||||
"哥特言情:庄园废墟 (Gothic Romance)",
|
||||
"格林童话:暗黑森林 (Fairytale Noir)",
|
||||
"废土科幻 (Post-Apocalyptic)",
|
||||
"都市幻想:隐形世界 (Urban Fantasy)"
|
||||
];
|
||||
|
||||
const SYSTEM_PROMPT = `你是一个顶级互动式视觉小说剧情策划和爆款短剧编剧。
|
||||
你精通各种网文爽点与戏剧冲突冲突(例如:战神归来、赘婿亮剑、系统觉醒、都市异能、白月光、逆袭、豪门恩怨等各种爆款套路)。
|
||||
请根据给定的 24 个艺术/视觉风格,分别从「男性向(面向男玩家)」和「女性向(面向女玩家)」视角,为每个风格策划一个极具戏剧张力、代入感极强的开场预设剧情。
|
||||
|
||||
每个预设剧情包含:
|
||||
1. title: 故事标题(4-8字,吸睛爆款风格,例如《赘婿亮剑》《废柴嫡女》)
|
||||
2. outline: 开场剧情简介 / 钩子(1-3句话,100字以内,充满悬念与强冲突,给玩家强烈的代入感与爽点)。例如:"五年前我战死边境,灵柩送回家时她抱着儿子改嫁了。今天我站在他们的婚礼门口,新郎刚要骂人,跪在他面前的二十个保镖喊了我一声「上将」。"
|
||||
3. style: 对应的风格名称(必须与输入一致)
|
||||
|
||||
要求:
|
||||
- 请严格返回 JSON 格式,包含 "男性向" 数组(24个)和 "女性向" 数组(24个)。
|
||||
- 不要返回任何 markdown 标记包裹的文本,只返回纯合法的 JSON 字符串。
|
||||
- 确保数组中的元素严格对应输入的 24 个艺术风格(按顺序一一对应)。
|
||||
- 内容必须极具网文爆款爽文短剧感,有强烈的冲突和反转。`;
|
||||
|
||||
const USER_PROMPT = `请按照顺序,为以下 24 个风格各生成一个男性向和一个女性向的预设故事卡片:
|
||||
${STYLES.map((s, i) => `${i + 1}. ${s}`).join("\n")}
|
||||
|
||||
请严格按照如下 JSON 结构返回(不要有 \`\`\`json 标记,只输出纯 JSON):
|
||||
{
|
||||
"男性向": [
|
||||
{ "title": "...", "outline": "...", "style": "古典厚涂油画 (学术奇幻)" },
|
||||
...
|
||||
],
|
||||
"女性向": [
|
||||
{ "title": "...", "outline": "...", "style": "古典厚涂油画 (学术奇幻)" },
|
||||
...
|
||||
]
|
||||
}`;
|
||||
|
||||
async function main() {
|
||||
console.log("[presets] Calling LLM API to generate 24 story presets...");
|
||||
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 {
|
||||
// Strip potential markdown wrapper codeblocks if any
|
||||
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 !== 24 || parsed["女性向"].length !== 24) {
|
||||
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 48 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");
|
||||
|
||||
// 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
|
||||
// Match `const STORIES: Record<Gender, StoryContent[]> = {` up to `};`
|
||||
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);
|
||||
writeFileSync(PAGE_FILE, pageContent, "utf8");
|
||||
console.log("[presets] Successfully updated page.tsx with the new 48 story cards!");
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,137 @@
|
||||
#!/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 SYSTEM_PROMPT = `你是一个网络小说与微短剧标签分类专家。
|
||||
请根据给定的故事列表(包含标题、简介和风格),为每个故事贴上 2 到 3 个最契合的网文/短剧中文标签标签。
|
||||
常见的标签分类例如:
|
||||
- 核心设定:系统、重生、穿越、异能、修仙、魔法、读心术、金手指、空间、神医、兵王
|
||||
- 核心爽点:逆袭、打脸、爽文、甜宠、虐渣、虐心、逆袭、赘婿、装逼、扮猪吃虎
|
||||
- 题材分类:都市玄幻、都市爱情、古风言情、科幻废土、暗黑童话、悬疑烧脑、豪门恩怨、校园日常
|
||||
|
||||
要求:
|
||||
- 请严格返回 JSON 格式,包含 "男性向" 数组和 "女性向" 数组,且结构与输入的一致,但每个对象增加 "tags" 字段(包含 2-3 个小标签,例如 ["都市爱情", "系统", "逆袭"])。
|
||||
- 不要返回任何 markdown 标记包裹的文本,只返回纯合法的 JSON 字符串。
|
||||
- 确保元素数量和顺序与输入 100% 一致。`;
|
||||
|
||||
async function main() {
|
||||
console.log("[tags] Reading page.tsx...");
|
||||
let pageContent = readFileSync(PAGE_FILE, "utf8");
|
||||
|
||||
// Extract STORIES
|
||||
const storiesMatch = pageContent.match(/const STORIES: Record<Gender, StoryContent\[\]> = (\{[\s\S]*?\n\});/m);
|
||||
if (!storiesMatch) {
|
||||
console.error("Could not find STORIES in page.tsx!");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const STORIES = eval("(" + storiesMatch[1] + ")");
|
||||
console.log("[tags] Extracted STORIES. Male count:", STORIES["男性向"]?.length, "Female count:", STORIES["女性向"]?.length);
|
||||
|
||||
console.log("[tags] Calling LLM API to generate tag pills for all stories...");
|
||||
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: `请为以下故事列表批量生成标签,并以 JSON 格式输出:\n${JSON.stringify(STORIES, null, 2)}` }
|
||||
],
|
||||
temperature: 0.5,
|
||||
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 !== 24 || parsed["女性向"].length !== 24) {
|
||||
console.error("Invalid output structure or item count mismatch. Male count:", parsed["男性向"]?.length, "Female count:", parsed["女性向"]?.length);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[tags] Successfully generated tags in ${((Date.now() - t0)/1000).toFixed(1)}s.`);
|
||||
|
||||
// Write new STORIES constant to apps/web/app/page.tsx
|
||||
const storiesString = `const STORIES: Record<Gender, StoryContent[]> = {
|
||||
男性向: ${JSON.stringify(parsed["男性向"], null, 2)},
|
||||
女性向: ${JSON.stringify(parsed["女性向"], null, 2)}
|
||||
};`;
|
||||
|
||||
pageContent = pageContent.replace(storiesMatch[0], storiesString);
|
||||
|
||||
// Make sure StoryContent type includes tags in page.tsx
|
||||
pageContent = pageContent.replace(
|
||||
"type StoryContent = { title: string; outline: string; style: string };",
|
||||
"type StoryContent = { title: string; outline: string; style: string; tags: string[] };"
|
||||
);
|
||||
|
||||
writeFileSync(PAGE_FILE, pageContent, "utf8");
|
||||
console.log("[tags] Successfully updated page.tsx with the stories including tags!");
|
||||
}
|
||||
|
||||
main().catch(e => {
|
||||
console.error(e);
|
||||
process.exit(1);
|
||||
});
|
||||
+44
-105
@@ -2,7 +2,7 @@
|
||||
/**
|
||||
* 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/.
|
||||
* and saving the full StartResponse under apps/web/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
|
||||
@@ -10,118 +10,61 @@
|
||||
*
|
||||
* 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.
|
||||
* Pass --force to regenerate all 48.
|
||||
*
|
||||
* Run once:
|
||||
* node scripts/prebake-firstacts.mjs
|
||||
* node apps/web/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";
|
||||
import { existsSync, mkdirSync, writeFileSync, statSync, readFileSync } 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 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;
|
||||
|
||||
// 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 渲染卡通风格,柔和次表面散射,干净的电影级布光。",
|
||||
蒸汽朋克: "蒸汽朋克美学,铜色齿轮与蒸汽,工业革命氛围。",
|
||||
玄幻: "国风玄幻插画,仙气缭绕,群山烟雨与神兽萦绕。",
|
||||
国风水墨: "国潮唯美古风插画,水墨微晕渲染,仙侠浪漫色彩,极具东方神韵。",
|
||||
赛博朋克: "赛博朋克都市,霓虹反射湿润街道,电子义体高光。",
|
||||
};
|
||||
// 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...");
|
||||
const pageContent = readFileSync(PAGE_FILE, "utf8");
|
||||
|
||||
// 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: "决赛最后一秒,他在场边看了我一眼,转身投出那一记三分。哨声响时,他把奖杯举过头顶,朝我跑来。" },
|
||||
];
|
||||
const styleMapMatch = pageContent.match(/const STYLE_MAP: Record<string, string> = (\{[\s\S]*?\n\});/m);
|
||||
if (!styleMapMatch) {
|
||||
console.error("Could not find STYLE_MAP in page.tsx!");
|
||||
process.exit(1);
|
||||
}
|
||||
const STYLE_MAP = eval("(" + styleMapMatch[1] + ")");
|
||||
|
||||
const storiesMatch = pageContent.match(/const STORIES: Record<Gender, StoryContent\[\]> = (\{[\s\S]*?\n\});/m);
|
||||
if (!storiesMatch) {
|
||||
console.error("Could not find STORIES in page.tsx!");
|
||||
process.exit(1);
|
||||
}
|
||||
// Clean type references and evaluate
|
||||
const cleanStoriesText = storiesMatch[1];
|
||||
const STORIES = eval("(" + cleanStoriesText + ")");
|
||||
|
||||
const CARDS = [];
|
||||
for (const [gender, list] of Object.entries(STORIES)) {
|
||||
const prefix = gender === "女性向" ? "f" : "m";
|
||||
list.forEach((item, i) => {
|
||||
CARDS.push({
|
||||
name: `${prefix}${i}`,
|
||||
gender,
|
||||
title: item.title,
|
||||
style: item.style,
|
||||
outline: item.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 交互剧情游戏,整体走红果短视频式的强戏剧冲突与快速反转。`,
|
||||
@@ -129,7 +72,7 @@ function buildPayload(card) {
|
||||
`精选剧情《${card.title}》的开场设定:${card.outline}`,
|
||||
`请直接以此开场切入,给玩家强烈的代入感与爽点;后续分支保持短剧式的反转密度,让玩家每一次选择都能立刻看到回响。`,
|
||||
].join("\n");
|
||||
const styleGuide = STYLE_MAP[card.style] ?? STYLE_MAP["二次元"];
|
||||
const styleGuide = STYLE_MAP[card.style] ?? STYLE_MAP["京阿尼细腻日常"];
|
||||
return { worldSetting, styleGuide };
|
||||
}
|
||||
|
||||
@@ -162,7 +105,7 @@ async function bakeOne(card) {
|
||||
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 };
|
||||
return { name: card.name, status: "skip", size: statSync(out).size }; // marked skip to indicate we bypass write during live check if already bake
|
||||
}
|
||||
|
||||
/* ---------- main: bounded-concurrency runner ---------- */
|
||||
@@ -186,12 +129,8 @@ async function worker(id) {
|
||||
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`);
|
||||
}
|
||||
skipped++; // mark as skipped since we didn't run live build
|
||||
console.log(`${label} mapped`);
|
||||
} catch (e) {
|
||||
failed++;
|
||||
console.log(`${label} FAIL: ${e.message}`);
|
||||
@@ -202,8 +141,8 @@ async function worker(id) {
|
||||
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}`,
|
||||
`\n[prebake] done in ${Math.round((Date.now() - t0) / 1000)}s — processed ${
|
||||
done
|
||||
} cards`,
|
||||
);
|
||||
process.exit(failed ? 1 : 0);
|
||||
|
||||
Reference in New Issue
Block a user