Files
jev-match-game/scripts/extract-art-prompts.ts
T

163 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 插图提示词萃取:从 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}`);