163 lines
6.6 KiB
TypeScript
163 lines
6.6 KiB
TypeScript
/**
|
||
* 插图提示词萃取:从 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<string>();
|
||
|
||
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<ArtItem['priority'], number> = { 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}`);
|