/** * 插图提示词萃取:从 content/chapters/*.json 提取需要配插图的场景, * 生成一份可一次性上传给 ChatGPT 网页版的提示词文件(docs/art/illustration-prompts.md)。 * * 运行:pnpm art:prompts * * 萃取范围: * - P0 讲述点场景变体(narration.variants,情感高潮戏,最优先) * - P0 过场(cutscene) * - P1 章节主视觉(每章一张) * - P2 场景对话节点(dialogue 且 sprite="scene") * 编号与章节/节点 ID 的映射表附在文末,下载后按编号重命名归位。 */ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..'); interface SceneVariant { signature: string; variantId: string; label: string; tint?: string } interface StoryNode { id: string; type: string; speaker?: string; text?: string; sprite?: string; pointId?: string; mechanism?: string; variants?: SceneVariant[]; video?: string; caption?: string; } interface Chapter { id: string; act: number; title: string; nodes: StoryNode[] } interface ArtItem { code: string; // 出图编号,如 C1-V01 priority: 'P0' | 'P1' | 'P2'; source: string; // 来源(章节/节点/变体) filename: string; // 建议保存路径 scene: string; // 画面说明(中文,喂给出图模型) } const CHAPTERS = ['chapter1', 'chapter2', 'chapter3']; /** 清掉文案里的玩家提示括号与条件标记,只留画面内容 */ function cleanSceneText(text: string): string { return text .replace(/【若[^】]*】/g, '') .replace(/([^)]*(?:点击|确认|知晓|提示|按住|选择|视为)[^)]*)/g, '') .replace(/\s+/g, ' ') .trim(); } function truncate(text: string, max = 220): string { return text.length > max ? text.slice(0, max) + '……' : text; } const items: ArtItem[] = []; for (const chId of CHAPTERS) { const chapter = JSON.parse(readFileSync(join(ROOT, 'content/chapters', `${chId}.json`), 'utf-8')) as Chapter; const chNum = chId.replace('chapter', 'C'); items.push({ code: `${chNum}-KV`, priority: 'P1', source: `${chId} 章节主视觉`, filename: `assets/art/${chId}/key-visual.png`, scene: `章节主视觉:${chapter.title}。考古悬疑题材的主视觉构图,留出标题空间。`, }); let sceneIdx = 0, variantIdx = 0, cutIdx = 0; const seen = new Set(); for (const node of chapter.nodes) { if (node.type === 'narration' && node.variants) { for (const v of node.variants) { variantIdx += 1; const code = `${chNum}-V${String(variantIdx).padStart(2, '0')}`; items.push({ code, priority: 'P0', source: `${node.pointId}(${node.mechanism})变体 ${v.variantId} · 情绪签名「${v.signature}」`, filename: `assets/art/${chId}/${node.pointId}-${v.variantId}.png`, scene: cleanSceneText(v.label), }); } } else if (node.type === 'cutscene') { cutIdx += 1; items.push({ code: `${chNum}-CS${cutIdx}`, priority: 'P0', source: `${node.id} 过场(${node.video})`, filename: `assets/art/${chId}/${node.id}-cutscene.png`, scene: cleanSceneText(node.caption ?? ''), }); } else if (node.type === 'dialogue' && node.sprite === 'scene' && node.text) { const scene = cleanSceneText(node.text); if (scene.length < 20 || seen.has(scene)) continue; seen.add(scene); sceneIdx += 1; const isOpen = node.id.includes('open'); items.push({ code: `${chNum}-S${String(sceneIdx).padStart(2, '0')}`, priority: isOpen ? 'P1' : 'P2', source: `${node.id} 场景旁白`, filename: `assets/art/${chId}/${node.id}.png`, scene: truncate(scene), }); } } } const STYLE = [ '统一美术风格(每张图都严格套用):水彩手绘漫画质感,暗色调悬疑氛围,', '电影级光影与构图,克制的暖冷对比;考古现场的旧照片颗粒感与奇幻微光结合。', '横幅 16:9。画面中不出现任何文字、字母、数字、水印。', '人物一律以中远景、背影或剪影出现,不要正脸特写(角色立绘后续单独处理,靠远景保持角色一致性)。', ].join(''); const HEADER = `# 《苍梧疑冢》Act 1 插图批量生成任务 你好。附件/下文是一份互动叙事游戏的插图需求清单,共 ${items.length} 张。 请按以下规则逐张生成插图: ## 全局风格 ${STYLE} ## 工作规则 1. 每个编号条目生成**一张**插图,按编号顺序来(编号即优先级,P0 最先)。 2. 每条目的"画面"字段是场景描述,请在不违背风格的前提下自由构图; 描述若引用剧情文本,取其中的**视觉内容**(环境、光线、器物、氛围),忽略叙事性语句。 3. 每条目生成后,先单独展示这张图并标注其编号(如"C1-V01"),便于我逐张下载核对。 4. 每批生成 4 张后停下来,等我回复"继续"再生成下一批——避免触发生成限额导致风格漂移。 5. 如果某条的描述让你不确定画什么,不要自己发明剧情元素,按最接近描述字面意思的静物/空镜处理。 --- ## 插图清单 `; const PRIORITY_ORDER: Record = { P0: 0, P1: 1, P2: 2 }; const sorted = [...items].sort((a, b) => PRIORITY_ORDER[a.priority] - PRIORITY_ORDER[b.priority]); const sections: string[] = []; let currentPriority = ''; for (const item of sorted) { if (item.priority !== currentPriority) { currentPriority = item.priority; const label = { P0: 'P0 · 讲述点变体与过场(情感高潮,最优先)', P1: 'P1 · 章节主视觉与开场场景', P2: 'P2 · 一般场景' }[item.priority]; sections.push(`\n### ${label}\n`); } sections.push(`**${item.code}**\n画面:${item.scene}\n`); } const MAPPING = `\n---\n\n## 编号映射表(出图后按"建议文件名"重命名归位)\n\n| 编号 | 来源 | 建议文件名 |\n|---|---|---|\n${sorted.map((i) => `| ${i.code} | ${i.source} | ${i.filename} |`).join('\n')}\n`; mkdirSync(join(ROOT, 'docs/art'), { recursive: true }); const outPath = join(ROOT, 'docs/art/illustration-prompts.md'); writeFileSync(outPath, HEADER + sections.join('\n') + MAPPING, 'utf-8'); const counts = { P0: 0, P1: 0, P2: 0 }; for (const i of sorted) counts[i.priority] += 1; console.log(`✓ 共 ${items.length} 张插图需求(P0:${counts.P0} P1:${counts.P1} P2:${counts.P2})`); console.log(`✓ 输出:${outPath}`);