#!/usr/bin/env node
// novel-art — deterministic helpers for the novel-art skill (场景 + 道具).
// Zero dependencies on purpose: the skill must work in any directory
// without an npm install. Node 18+ (stdlib only).
import { existsSync, readFileSync, realpathSync } from 'node:fs';
import { join, relative, resolve, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
/* ------------------------------------------------------------------ */
/* slug */
/* ------------------------------------------------------------------ */
export function slug(name) {
const cleaned = String(name)
.trim()
.replace(/[\s/\\:*?"<>|·]+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned || 'scene';
}
/* ------------------------------------------------------------------ */
/* seed — 从 outline.json 确定性预填骨架 */
/* ------------------------------------------------------------------ */
/*
* 场景清单、主场景标记、出现集、承载爽点、复用方案在 outline.json 里
* 都是现成的——这些不该让模型重新想一遍。seed 把它们搬进骨架,
* 模型只填设计字段(设计意图 / 锚点 / 光照 / 提示词)。
*/
export function seedFromOutline(outline) {
const eps = Array.isArray(outline?.episodes) ? outline.episodes : [];
const beats = Array.isArray(outline?.beats) ? outline.beats : [];
const scenes = (outline?.scenes ?? []).map((s) => {
const episodes = eps.filter((e) => (e?.sceneIds ?? []).includes(s.id)).map((e) => e.ep);
const epSet = new Set(episodes);
const carried = [...new Set(beats.filter((b) => epSet.has(b.episode)).map((b) => b.type))];
return {
id: s.id,
name: s.name,
primary: !!s.primary,
// 模型要填的设计字段,先占位
summary: '',
anchors: [],
lighting: [],
image: { prompt: '', negativePrompt: '', sheet: '', tags: [] },
// 从 outline 搬来的事实,不用再想
...(s.reusePlan ? { seedNote: `outline 的复用方案:${s.reusePlan}——考虑做成某个主场景的变体(variantOf + changes)` } : {}),
usage: { episodes, beats: carried },
};
});
// 道具:大纲从 1.1.0 起带 props(id / name / function / beatIds),有就预填。
// 搬过来的是改编阶段拍板的事实——哪几件物件承载剧情、各自承载什么、托起哪几个
// 爽点、在哪几集出现。留空的是美术层的活:尺度、锚点、状态变体、白底提示词。
// 大纲没有 props 字段就返回空数组,模型照 prop-pass.md 从原文提取,跟以前一样。
const beatType = new Map((outline?.beats ?? []).map((b) => [b?.id, b?.type]));
const props = (outline?.props ?? []).map((pr) => {
const episodes = eps.filter((e) => (e?.propIds ?? []).includes(pr.id)).map((e) => e.ep);
return {
id: pr.id,
name: pr.name,
// 大纲的 function 就是这里的 summary:两边都指「它在戏里干什么」,不是材质描述
summary: pr.function ?? '',
// 模型要填的设计字段,先占位
scale: '',
anchors: [],
states: [],
relatedScenes: [],
carriedBy: [],
image: { prompt: '', negativePrompt: '', sheet: '', tags: [] },
// 从 outline 搬来的事实,不用再想
usage: { episodes, beats: (pr.beatIds ?? []).map((id) => beatType.get(id)).filter(Boolean) },
};
});
return { source: outline?.source ?? '', scenes, props };
}
/* ------------------------------------------------------------------ */
/* 质量门 */
/* ------------------------------------------------------------------ */
/*
* 与另外两个 skill 同一主张:checklist 是代码,不是给模型读的文字。
* AI 短剧环境资产的硬规则都在这——空景、锚点、光照状态、
* 提示词语言、风格与反向词匹配、变体引用完整。
*/
const thText = (s) => typeof s === 'string' && s.trim();
/** 中日韩表意文字与假名、谚文——出图提示词里出现就说明串语言了。 */
const CJK = /[㐀-鿿-ヿ가-]/;
/** 环境参考图必须空景:反向提示词里必须把「人」禁掉。 */
const PEOPLE_BAN = /people|human|figure|character|crowd/i;
export const ANCHOR_RANGE = [3, 5];
/*
* 道具尺度参照:AI 经常把手持道具画成家具尺寸——实拍不存在的坑。
* scale 是中文枚举,出图提示词里必须出现对应的英文短语,validate 会对着查。
*/
export const PROP_SCALES = {
手持级: 'handheld scale',
桌面级: 'tabletop scale',
家具级: 'furniture scale',
};
/** 道具参考图不许有手——拿着道具的手是最常见的污染。 */
const HANDS_BAN = /hand|finger/i;
export function gateReport(doc, castNames = null) {
const gates = [];
const add = (id, label, ok, detail = '') => gates.push({ id, label, ok, detail });
const scenes = Array.isArray(doc?.scenes) ? doc.scenes : [];
const props = Array.isArray(doc?.props) ? doc.props : [];
const bad = {
anchors: [], lighting: [], people: [], english: [], names: [], variant: [],
states: [], scale: [], hands: [], whitebg: [],
};
const ids = new Set(scenes.map((s) => s?.id));
// 场景与道具共用的门:锚点 / 空景 / 英文 / 角色名
for (const s of [...scenes, ...props]) {
const label = s?.name ?? s?.id ?? '(无名)';
// 锚点 3–5:少了认不出场景,多了 QC 核对不过来
const anchorN = Array.isArray(s?.anchors) ? s.anchors.length : 0;
if (anchorN < ANCHOR_RANGE[0] || anchorN > ANCHOR_RANGE[1]) bad.anchors.push(`${label}(${anchorN} 个)`);
// 光照状态 ≥1(仅场景):AI 换时段是重新生成,不是重新打灯
if (scenes.includes(s) && (!Array.isArray(s?.lighting) || s.lighting.length === 0)) bad.lighting.push(label);
// 空景:反向提示词必须禁人
const neg = s?.image?.negativePrompt ?? '';
if (!PEOPLE_BAN.test(neg)) bad.people.push(label);
// 机器字段永远英文(场景的光照 + 道具的状态一起扫)
const machine = [
s?.image?.prompt, s?.image?.negativePrompt, s?.image?.sheet,
...(s?.lighting ?? []).map((l) => l?.prompt),
...(s?.states ?? []).map((st) => st?.prompt),
...(s?.image?.tags ?? []),
];
if (machine.some((v) => typeof v === 'string' && CJK.test(v))) bad.english.push(label);
// 提示词不许出现角色名(给了 cast 才查)
if (castNames?.length) {
const texts = [
s?.image?.prompt, s?.image?.sheet,
...(s?.lighting ?? []).map((l) => l?.prompt),
...(s?.states ?? []).map((st) => st?.prompt),
];
for (const n of castNames) {
if (texts.some((v) => typeof v === 'string' && v.includes(n))) {
bad.names.push(`${label} 出现「${n}」`);
break;
}
}
}
// 变体引用完整
if (s?.variantOf !== undefined) {
if (!ids.has(s.variantOf) || s.variantOf === s.id) bad.variant.push(`${label} → ${s.variantOf}`);
else if (!thText(s?.changes)) bad.variant.push(`${label} 缺 changes`);
}
}
// 道具专属的门
for (const pr of props) {
const label = pr?.name ?? pr?.id ?? '(无名)';
// 状态 ≥1:合上/打开、藏着/摊开——每个状态都是一张要单独生成的参考
if (!Array.isArray(pr?.states) || pr.states.length === 0) bad.states.push(label);
// 尺度参照:scale 枚举 + 提示词里必须出现对应英文短语
const scaleEn = PROP_SCALES[pr?.scale];
if (!scaleEn) {
bad.scale.push(`${label}(scale 必须是 ${Object.keys(PROP_SCALES).join('/')})`);
} else if (![pr?.image?.prompt, pr?.image?.sheet].every((v) => typeof v === 'string' && v.includes(scaleEn))) {
bad.scale.push(`${label}(提示词缺「${scaleEn}」)`);
}
// 无手:拿着道具的手是最常见的污染,反向提示词必须禁
if (!HANDS_BAN.test(pr?.image?.negativePrompt ?? '')) bad.hands.push(label);
// 白底:道具参考图要被贴进各种镜头,必须纯白背景可抠
if (!/white background/i.test(pr?.image?.sheet ?? '')) bad.whitebg.push(label);
}
add('anchors', `一致性锚点 ${ANCHOR_RANGE[0]}–${ANCHOR_RANGE[1]} 个`, scenes.length + props.length > 0 && bad.anchors.length === 0, bad.anchors.join(';'));
add('lighting', '光照状态至少 1 个且落成提示词', scenes.length > 0 && bad.lighting.length === 0, bad.lighting.join(';'));
add('no-people', '参考图无人:反向提示词禁人(场景与道具都查)', scenes.length + props.length > 0 && bad.people.length === 0, bad.people.join(';'));
add('english', '出图提示词全部英文', scenes.length + props.length > 0 && bad.english.length === 0, bad.english.join(';'));
add(
'no-names',
'提示词不含角色名',
bad.names.length === 0,
castNames?.length ? bad.names.join(';') : '未提供 cast.json,本门跳过(视为通过)',
);
add('variants', '变体引用完整(variantOf 存在且带 changes)', bad.variant.length === 0, bad.variant.join(';'));
add('prop-states', '道具状态至少 1 个且落成提示词', props.length === 0 || bad.states.length === 0, bad.states.join(';'));
add('prop-scale', '道具尺度参照写进提示词', props.length === 0 || bad.scale.length === 0, bad.scale.join(';'));
add('prop-hands', '道具参考图无手:反向提示词禁手', props.length === 0 || bad.hands.length === 0, bad.hands.join(';'));
add('prop-white', '道具设定图纯白背景可抠', props.length === 0 || bad.whitebg.length === 0, bad.whitebg.join(';'));
return gates;
}
/* ------------------------------------------------------------------ */
/* validate */
/* ------------------------------------------------------------------ */
export function validateArt(doc, castNames = null) {
const problems = [];
const p = (msg) => problems.push(msg);
if (!doc || typeof doc !== 'object') return ['art.json 不是对象'];
if (!thText(doc.source)) p('缺少 source(剧名/书名)');
const scenes = doc.scenes;
if (!Array.isArray(scenes) || scenes.length === 0) {
p('scenes 为空');
return problems;
}
// 道具(可选块):给了就逐件查结构
for (const pr of doc.props ?? []) {
const label = pr?.name ?? pr?.id ?? '(无名)';
if (!/^P\d{2,}$/.test(pr?.id ?? '')) p(`[${label}] 道具 id 必须是 P01 这种格式`);
if (!thText(pr?.name)) p(`[${pr?.id}] 道具缺 name`);
if (!thText(pr?.summary)) p(`[${label}] 缺 summary(戏剧功能:这件道具承载什么剧情)`);
for (const a of pr?.anchors ?? []) {
if (!thText(a?.name) || !thText(a?.desc)) p(`[${label}] 锚点缺 name 或 desc`);
}
for (const st of pr?.states ?? []) {
if (!thText(st?.state)) p(`[${label}] 状态缺 state(合上/打开……)`);
if (!thText(st?.prompt)) p(`[${label}] 状态「${st?.state ?? '?'}」缺 prompt(英文)`);
}
const img = pr?.image;
if (!img || typeof img !== 'object') {
p(`[${label}] 缺 image`);
} else {
for (const f of ['prompt', 'negativePrompt', 'sheet']) {
if (!thText(img[f])) p(`[${label}] image.${f} 缺失或为空`);
}
if (!Array.isArray(img.tags)) p(`[${label}] image.tags 必须是数组`);
}
for (const sid of pr?.relatedScenes ?? []) {
if (!scenes.some((sc) => sc?.id === sid)) p(`[${label}] relatedScenes 里的 ${sid} 不存在`);
}
if (pr?.usage !== undefined) {
if (!Array.isArray(pr.usage?.episodes)) p(`[${label}] usage.episodes 必须是数组`);
if (!Array.isArray(pr.usage?.beats)) p(`[${label}] usage.beats 必须是数组`);
}
}
{
const seenP = new Set();
for (const pr of doc.props ?? []) {
if (seenP.has(pr?.id)) p(`道具 id ${pr.id} 重复`);
seenP.add(pr?.id);
}
}
const seen = new Set();
for (const s of scenes) {
const label = s?.name ?? s?.id ?? '(无名)';
if (!/^S\d{2,}$/.test(s?.id ?? '')) p(`[${label}] 场景 id 必须是 S01 这种格式`);
if (seen.has(s?.id)) p(`场景 id ${s.id} 重复`);
seen.add(s?.id);
if (!thText(s?.name)) p(`[${s?.id}] 缺 name`);
if (typeof s?.primary !== 'boolean') p(`[${label}] 缺 primary(是不是主场景)`);
if (!thText(s?.summary)) p(`[${label}] 缺 summary(设计意图:这个空间讲什么故事)`);
for (const a of s?.anchors ?? []) {
if (!thText(a?.name) || !thText(a?.desc)) p(`[${label}] 锚点缺 name 或 desc`);
}
for (const l of s?.lighting ?? []) {
if (!thText(l?.state)) p(`[${label}] 光照状态缺 state(晨雾/夜戏/黄昏……)`);
if (!thText(l?.prompt)) p(`[${label}] 光照「${l?.state ?? '?'}」缺 prompt(英文)`);
}
const img = s?.image;
if (!img || typeof img !== 'object') {
p(`[${label}] 缺 image`);
} else {
for (const f of ['prompt', 'negativePrompt', 'sheet']) {
if (!thText(img[f])) p(`[${label}] image.${f} 缺失或为空`);
}
if (!Array.isArray(img.tags)) p(`[${label}] image.tags 必须是数组`);
}
if (s?.usage !== undefined) {
if (!Array.isArray(s.usage?.episodes)) p(`[${label}] usage.episodes 必须是数组`);
if (!Array.isArray(s.usage?.beats)) p(`[${label}] usage.beats 必须是数组`);
}
}
// 质量门失败并入违规列表
for (const g of gateReport(doc, castNames)) {
if (!g.ok) p(`质量门未过:${g.label}${g.detail ? `(${g.detail})` : ''}`);
}
return problems;
}
/** 从 cast.json 提取名字 + 别名,给「提示词不含角色名」那道门用。 */
export function castNamesOf(cast) {
const chars = Array.isArray(cast) ? cast : (cast?.characters ?? []);
const names = [];
for (const c of chars) {
if (thText(c?.name)) names.push(c.name.trim());
for (const a of c?.aliases ?? []) if (thText(a)) names.push(a.trim());
}
return [...new Set(names)];
}
/* ------------------------------------------------------------------ */
/* render — 界面文案 */
/* ------------------------------------------------------------------ */
/*
* 内置 zh / en 两套,全部收在这张表里;zh 是基准,en 术语与 README.en.md 对齐。
* 只有报告界面走这张表——质量门文案、CLI 输出、art.json 里的数据都不在此列。
*/
/* 门标签与「跳过」提示的英文映射:质量门面板是报告的一部分,出英文报告时
* 这里做展示层翻译——gateReport 的逻辑与中文诊断文案一行不动(CLI 仍是中文)。
* 动态阈值由门自己算,映射里只写固定语义;未命中的 id 回落到原标签。 */
const GATE_LABELS_EN = {
'anchors': 'Consistency anchors, {0}–{1} per asset',
'lighting': 'At least one lighting state, written out as a prompt',
'no-people': 'No people in plates: negatives ban people (scenes and props)',
'english': 'All image prompts in English',
'no-names': 'Prompts carry no character names',
'variants': 'Variant references complete (variantOf exists and carries changes)',
'prop-states': 'At least one prop state, written out as a prompt',
'prop-scale': 'Prop scale reference written into the prompt',
'prop-hands': 'No hands in prop plates: negatives ban hands',
'prop-white': 'Prop sheets on pure white background, cut-out ready',
};
const GATE_SKIPS_EN = {
'未提供 outline.json,本门跳过(视为通过)': 'outline.json not provided — gate skipped (treated as passing)',
'未提供 art.json,本门跳过(视为通过)': 'art.json not provided — gate skipped (treated as passing)',
'未提供 script.json,本门跳过(视为通过)': 'script.json not provided — gate skipped (treated as passing)',
'未提供 outline/cast,本门跳过(视为通过)': 'outline/cast not provided — gate skipped (treated as passing)',
'未提供 cast.json,本门跳过(视为通过)': 'cast.json not provided — gate skipped (treated as passing)',
};
/** 报告里的门文案:英文界面取映射,未命中或中文界面回落原文。 */
const gateText = (g, lang) => {
if (lang !== 'en') return { label: g.label, detail: g.detail };
const en = GATE_LABELS_EN[g.id];
// 阈值仍由门自己算:把中文标签里出现的数字按序填进 {0} {1}
const nums = String(g.label).match(/\d+(?:\.\d+)?/g) ?? [];
const label = en ? en.replace(/\{(\d)\}/g, (m, i) => nums[Number(i)] ?? m) : g.label;
return { label, detail: GATE_SKIPS_EN[g.detail] ?? g.detail };
};
const I18N = {
zh: {
langCode: 'zh',
kicker: '美术设定集',
docTitle: (s) => `${s} · 美术设定集`,
exportJson: '导出 JSON',
gates: '质量门',
gatesPass: '全部通过',
gatesFail: (n) => `${n} 项未过`,
gatePill: (okN, total) => `质量门 ${okN} / ${total}`,
kpi: {
scenes: '场景', scenesSub: (p2, v) => `主场景 ${p2}${v ? ` · 变体 ${v}` : ''}`,
propsK: '叙事道具', propsSub: (st) => `${st} 个状态变体`,
anchors: '一致性锚点', anchorsSub: '生成镜头的核对表(场景 + 道具)',
lighting: '光照状态', lightingSub: '换时段 = 重新生成',
},
secList: '场景清单',
secCards: '场景设定卡',
secPropList: '道具清单',
secPropCards: '道具设定卡',
secGates: '质量门',
propListCols: ['ID', '道具', '尺度', '状态', '关联场景', '出现集', '锚点'],
statesTitle: '状态变体',
scaleLabel: '尺度',
relatedScenesLabel: '关联场景',
carriedByLabel: '关联角色',
propSheetCaption: '主视角+底部与右侧细节条 · 纯白背景',
listCols: ['ID', '场景', '类型', '出现集', '锚点', '光照', '变体'],
primaryScene: '主场景', onceScene: '一次性', variantScene: '变体',
anchorsTitle: '一致性锚点',
anchorsHint: '每次生成都必须出现的特征——认场景靠它,QC 也靠它',
lightingTitle: '光照与时段',
usageTitle: '出现集',
beatsTitle: '承载爽点',
variantTitle: '变体来源',
variantChanges: '改动',
promptsTitle: '出图提示词包',
promptMaster: '主视角 EN',
promptNegative: '反向提示词',
promptSheet: '设定图 EN',
copy: '复制', copied: '已复制', copyFailed: '复制失败', copyJson: '复制整个场景 JSON',
noImage: '尚未出图',
noImageHint: '用下方提示词生成',
zoomImage: '放大查看',
closeImage: '关闭',
sheetCaption: '主视角+底部与右侧细节条',
none: '—',
listSep: '、',
pairSep: ' ',
colon: ':',
colophon: '设定与提示词由模型依据大纲/原文生成,质量门由脚本确定性检查。参考图一律无人——人是另一层资产;道具参考图另加无手、纯白背景。',
},
en: {
langCode: 'en',
kicker: 'Art Bible',
docTitle: (s) => `${s} · Art Bible`,
exportJson: 'Export JSON',
gates: 'Quality gates',
gatesPass: 'All passed',
gatesFail: (n) => `${n} gate${n === 1 ? '' : 's'} failed`,
gatePill: (okN, total) => `Quality gates ${okN} / ${total}`,
kpi: {
scenes: 'Scenes', scenesSub: (p2, v) => `${p2} primary${v ? ` · ${v} variant${v === 1 ? '' : 's'}` : ''}`,
propsK: 'Narrative props', propsSub: (st) => `${st} state variant${st === 1 ? '' : 's'}`,
anchors: 'Consistency anchors', anchorsSub: 'The checklist for generated shots (scenes + props)',
lighting: 'Lighting states', lightingSub: 'A time-of-day change is a regeneration',
},
secList: 'Scene list',
secCards: 'Scene sheets',
secPropList: 'Prop list',
secPropCards: 'Prop sheets',
secGates: 'Quality gates',
propListCols: ['ID', 'Prop', 'Scale', 'States', 'Related scenes', 'Episodes', 'Anchors'],
statesTitle: 'Prop states',
scaleLabel: 'Scale',
relatedScenesLabel: 'Related scenes',
carriedByLabel: 'Carried by',
propSheetCaption: 'Master view + bottom & right detail strips · pure white background',
listCols: ['ID', 'Scene', 'Type', 'Episodes', 'Anchors', 'Lighting', 'Variant'],
primaryScene: 'Primary', onceScene: 'One-off', variantScene: 'Variant',
anchorsTitle: 'Consistency anchors',
anchorsHint: 'Features that must appear in every generation — viewers recognize the place by them, and QC checks against them',
lightingTitle: 'Lighting states',
usageTitle: 'Episodes',
beatsTitle: 'Beats',
variantTitle: 'Variant of',
variantChanges: 'Changes',
promptsTitle: 'Prompt pack',
promptMaster: 'Master view (EN)',
promptNegative: 'Negative prompt',
promptSheet: 'Sheet (EN)',
copy: 'Copy', copied: 'Copied', copyFailed: 'Copy failed', copyJson: 'Copy full JSON',
noImage: 'no image yet',
noImageHint: 'generate with the prompts below',
zoomImage: 'Click to zoom',
closeImage: 'Close',
sheetCaption: 'Master view + bottom & right detail strips',
none: '—',
listSep: ', ',
pairSep: ' · ',
colon: ': ',
colophon: 'Designs and prompts are model-generated from the outline/source text; quality gates are checked deterministically by the script. Reference images never contain people — characters are a separate asset layer; prop plates additionally ban hands and sit on a pure white background.',
},
};
const tOf = (lang) => {
if (lang && !I18N[lang]) throw new Error('报告界面语言目前内置 zh / en');
return I18N[lang ?? 'zh'];
};
/* ------------------------------------------------------------------ */
/* render — markdown */
/* ------------------------------------------------------------------ */
const esc = (s) =>
String(s ?? '')
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"');
const mdRow = (cells) => `| ${cells.map((c) => String(c ?? '').replace(/\|/g, '\\|')).join(' | ')} |`;
const mdHead = (cols) => [mdRow(cols), mdRow(cols.map(() => '---'))].join('\n');
export function renderMarkdown(doc, lang = null) {
const t = tOf(lang ?? doc?.lang);
const props = doc.props ?? [];
const out = [`# ${t.docTitle(doc.source)}`, ''];
out.push(`## ${t.secList}`, '', mdHead(t.listCols));
for (const s of doc.scenes) {
const type = s.variantOf ? `${t.variantScene} ← ${s.variantOf}` : s.primary ? t.primaryScene : t.onceScene;
out.push(mdRow([
s.id, s.name, type, s.usage?.episodes?.join(t.listSep) ?? t.none,
s.anchors.length, s.lighting.map((l) => l.state).join(t.listSep),
s.changes ?? t.none,
]));
}
out.push('');
for (const s of doc.scenes) {
out.push('---', '', `## ${s.id} ${s.name}`, '', s.summary, '');
if (s.sheetImage) out.push(``, '');
out.push(`### ${t.anchorsTitle}`, '');
s.anchors.forEach((a, i) => out.push(`${i + 1}. **${a.name}** — ${a.desc}`));
out.push('', `### ${t.lightingTitle}`, '');
for (const l of s.lighting) out.push(`- **${l.state}**${t.colon}\`${l.prompt}\``);
out.push('');
if (s.variantOf) out.push(`> ${t.variantTitle}${t.colon}${s.variantOf}${t.pairSep}${t.variantChanges}${t.colon}${s.changes}`, '');
out.push(`### ${t.promptsTitle}`, '');
out.push(`**${t.promptMaster}**`, '', '```text', s.image.prompt, '```', '');
out.push(`**${t.promptNegative}**`, '', '```text', s.image.negativePrompt, '```', '');
out.push(`**${t.promptSheet}**`, '', '```text', s.image.sheet, '```', '');
if (s.image.tags.length) out.push(`\`${s.image.tags.join('`, `')}\``, '');
}
if (props.length) {
out.push('---', '', `## ${t.secPropList}`, '', mdHead(t.propListCols));
for (const pr of props) {
out.push(mdRow([
pr.id, pr.name, pr.scale, pr.states.map((st) => st.state).join(t.listSep),
(pr.relatedScenes ?? []).join(t.listSep) || t.none,
pr.usage?.episodes?.join(t.listSep) ?? t.none, pr.anchors.length,
]));
}
out.push('');
for (const pr of props) {
out.push('---', '', `## ${pr.id} ${pr.name}`, '', pr.summary, '');
if (pr.sheetImage) out.push(``, '');
out.push(`### ${t.anchorsTitle}`, '');
pr.anchors.forEach((a, i) => out.push(`${i + 1}. **${a.name}** — ${a.desc}`));
out.push('', `### ${t.statesTitle}`, '');
for (const st of pr.states) out.push(`- **${st.state}**${t.colon}\`${st.prompt}\``);
out.push('');
out.push(`### ${t.promptsTitle}`, '');
out.push(`**${t.promptMaster}**`, '', '```text', pr.image.prompt, '```', '');
out.push(`**${t.promptNegative}**`, '', '```text', pr.image.negativePrompt, '```', '');
out.push(`**${t.promptSheet}**`, '', '```text', pr.image.sheet, '```', '');
}
}
return out.join('\n');
}
/* ------------------------------------------------------------------ */
/* render — html */
/* ------------------------------------------------------------------ */
/*
* 与另外两份报告同一套视觉语言:冷灰印张 + 铁锈红印记,1600 宽,
* 全部平铺可 Cmd+F,零外部依赖。设计约定见 references/report-style.md。
*/
function embedDoc(doc) {
return JSON.stringify(doc).replace(/ !g.ok);
const scenes = doc.scenes;
const props = doc.props ?? [];
const primaryN = scenes.filter((s) => s.primary).length;
const variantN = scenes.filter((s) => s.variantOf).length;
const anchorN = [...scenes, ...props].reduce((n, s) => n + s.anchors.length, 0);
const lightN = scenes.reduce((n, s) => n + s.lighting.length, 0);
const stateN = props.reduce((n, pr) => n + pr.states.length, 0);
const table = (cols, rows) =>
`
${cols.map((c) => `| ${esc(c)} | `).join('')}
${rows.map((r) => `${r.map((c) => `| ${c} | `).join('')}
`).join('\n')}
`;
const promptRow = (label, value) =>
!value
? ''
: `
${esc(label)}
${esc(value)}
`;
const listRows = scenes.map((s) => [
esc(s.id), esc(s.name),
esc(s.variantOf ? `${t.variantScene} ← ${s.variantOf}` : s.primary ? t.primaryScene : t.onceScene),
esc(s.usage?.episodes?.join(t.listSep) ?? t.none),
String(s.anchors.length),
esc(s.lighting.map((l) => l.state).join(t.listSep)),
esc(s.changes ?? t.none),
]);
const propListRows = props.map((pr) => [
esc(pr.id), esc(pr.name), esc(pr.scale), esc(pr.states.map((st) => st.state).join(t.listSep)),
esc((pr.relatedScenes ?? []).join(t.listSep) || t.none),
esc(pr.usage?.episodes?.join(t.listSep) ?? t.none), String(pr.anchors.length),
]);
const propCards = props
.map((pr) => {
const plate = pr.sheetImage
? `
${esc(t.propSheetCaption)}
`
: `${esc(pr.name)} · ${esc(t.noImage)}
${esc(t.noImageHint)}
`;
return `
${esc(pr.id)}
${esc(pr.name)}
${esc(pr.scale)}
${pr.usage?.episodes?.length ? `${esc(t.usageTitle)} ${esc(pr.usage.episodes.join(t.listSep))}${pr.usage.beats?.length ? `${esc(t.pairSep)}${esc(t.beatsTitle)} ${esc(pr.usage.beats.join(' · '))}` : ''}` : ''}
${esc(pr.summary)}
${plate}
${(pr.relatedScenes ?? []).length || pr.carriedBy ? `${(pr.relatedScenes ?? []).length ? `${esc(t.relatedScenesLabel)}${esc(t.colon)}${esc(pr.relatedScenes.join(t.listSep))}` : ''}${pr.carriedBy ? `${esc(t.pairSep)}${esc(t.carriedByLabel)}${esc(t.colon)}${esc([].concat(pr.carriedBy).join(t.listSep))}` : ''}
` : ''}
${esc(t.anchorsTitle)}${esc(t.anchorsHint)}
${pr.anchors.map((a) => `- ${esc(a.name)}${esc(a.desc)}
`).join('')}
${esc(t.statesTitle)}
${pr.states.map((st) => `
${esc(st.state)}${esc(st.prompt)}
`).join('')}
${promptRow(t.promptMaster, pr.image.prompt)}
${promptRow(t.promptSheet, pr.image.sheet)}
${promptRow(t.promptNegative, pr.image.negativePrompt)}
`;
})
.join('\n');
const cards = scenes
.map((s) => {
const plate = s.sheetImage
? `
${esc(t.sheetCaption)}
`
: `${esc(s.name)} · ${esc(t.noImage)}
${esc(t.noImageHint)}
`;
return `
${esc(s.id)}
${esc(s.name)}
${esc(s.variantOf ? t.variantScene : s.primary ? t.primaryScene : t.onceScene)}
${s.usage?.episodes?.length ? `${esc(t.usageTitle)} ${esc(s.usage.episodes.join(t.listSep))}${s.usage.beats?.length ? `${esc(t.pairSep)}${esc(t.beatsTitle)} ${esc(s.usage.beats.join(' · '))}` : ''}` : ''}
${esc(s.summary)}
${plate}
${s.variantOf ? `${esc(t.variantTitle)}${esc(t.colon)}${esc(s.variantOf)}${esc(t.pairSep)}${esc(t.variantChanges)}${esc(t.colon)}${esc(s.changes)}
` : ''}
${esc(t.anchorsTitle)}${esc(t.anchorsHint)}
${s.anchors.map((a) => `- ${esc(a.name)}${esc(a.desc)}
`).join('')}
${esc(t.lightingTitle)}
${s.lighting.map((l) => `
${esc(l.state)}${esc(l.prompt)}
`).join('')}
${promptRow(t.promptMaster, s.image.prompt)}
${promptRow(t.promptSheet, s.image.sheet)}
${promptRow(t.promptNegative, s.image.negativePrompt)}
`;
})
.join('\n');
const gateList = `
${gates
.map(
(g) => `- ${g.ok ? '✓' : '✗'}${esc(gateText(g, t.langCode).label)}${
!g.ok && g.detail ? `${esc(g.detail)}` : g.id === 'no-names' && g.detail ? `${esc(g.detail)}` : ''
}
`,
)
.join('\n ')}
`;
return `
${esc(t.docTitle(doc.source))}
${esc(doc.source)}
${esc(t.kicker)}
${failed.length ? '✗' : '✓'} ${esc(t.gatePill(gates.length - failed.length, gates.length))}
${esc(t.kpi.scenes)}
${scenes.length}
${esc(t.kpi.scenesSub(primaryN, variantN))}
${esc(t.kpi.propsK)}
${props.length}
${esc(t.kpi.propsSub(stateN))}
${esc(t.kpi.anchors)}
${anchorN}
${esc(t.kpi.anchorsSub)}
${esc(t.kpi.lighting)}
${lightN}
${esc(t.kpi.lightingSub)}
${failed.length ? `
✗ ${esc(t.gatesFail(failed.length))}${failed.map((g) => `${esc(gateText(g, t.langCode).label)}${g.detail ? ` — ${esc(gateText(g, t.langCode).detail)}` : ''}`).join('')}
` : ''}
01
${esc(t.secList)}
${table(t.listCols, listRows)}
02
${esc(t.secCards)}
${cards}
${props.length ? `
03
${esc(t.secPropList)}
${table(t.propListCols, propListRows)}
04
${esc(t.secPropCards)}
${propCards}
` : ''}
${props.length ? '05' : '03'}
${esc(t.secGates)}
${gateList}
${failed.length ? `${esc(t.gatesFail(failed.length))}` : esc(t.gatesPass)}
`;
}
/* ------------------------------------------------------------------ */
/* CLI */
/* ------------------------------------------------------------------ */
const USAGE = `novel-art.mjs — novel-art skill 的确定性工具(场景 + 道具)
seed 从大纲预填 art.json 骨架(打印到 stdout,道具留空待提取)
validate [--cast c.json] 校验;有违规逐条打印并 exit 1
给了 cast.json 才查「提示词不含角色名」
checkup [--cast c.json] 只打印质量门 ✓/✗,有未过项 exit 1
render [--html|--md] 渲染报告到 stdout(默认 --md)
[--lang zh|en] 界面语言优先级:--lang > art.json 顶层 lang 字段 > 中文
[--images ] 设定图所在目录,任意路径(相对当前目录解析);
默认 art.json 同级的 images/。找 /-sheet.png,
找到就嵌进报告;图片路径按「报告写在 art.json 旁边」计算
slug 场景名转安全文件名`;
function readJson(path) {
return JSON.parse(readFileSync(resolve(path), 'utf8'));
}
function flag(rest, name, fallback = null) {
const i = rest.indexOf(name);
return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback;
}
function main(argv) {
const [cmd, ...rest] = argv;
if (!cmd || cmd === '-h' || cmd === '--help') {
console.log(USAGE);
process.exit(cmd ? 0 : 1);
}
if (cmd === 'seed') {
const [path] = rest;
if (!path) throw new Error('用法:seed ');
console.log(JSON.stringify(seedFromOutline(readJson(path)), null, 2));
return;
}
if (cmd === 'validate' || cmd === 'checkup') {
const [path] = rest;
if (!path) throw new Error(`用法:${cmd} [--cast cast.json]`);
const doc = readJson(path);
const castPath = flag(rest, '--cast');
const names = castPath ? castNamesOf(readJson(castPath)) : null;
if (!castPath) console.error('⚠️ 没给 --cast,跳过「提示词不含角色名」检查');
if (cmd === 'checkup') {
const gates = gateReport(doc, names);
for (const g of gates) console.log(`${g.ok ? '✓' : '✗'} ${g.label}${!g.ok && g.detail ? ` — ${g.detail}` : ''}`);
const failedN = gates.filter((g) => !g.ok).length;
console.log(failedN ? `\n✗ ${failedN} 项未过` : '\n✓ 全部通过');
if (failedN) process.exit(1);
return;
}
const problems = validateArt(doc, names);
if (problems.length) {
console.error(`✗ ${problems.length} 处违规:\n`);
for (const x of problems) console.error(' ' + x);
process.exit(1);
}
console.log(`✓ ${doc.scenes.length} 个场景${(doc.props ?? []).length ? ` + ${doc.props.length} 件道具` : ''}全部通过校验`);
return;
}
if (cmd === 'render') {
const [path] = rest;
if (!path) throw new Error('用法:render [--html|--md] [--lang zh|en] [--images ]');
const doc = readJson(path);
const lang = flag(rest, '--lang');
// 图是用户在下游出好的素材,放哪由用户定:--images 按普通命令行路径解析,
// 不给才退回 art.json 同级的 images/。报告默认写在 art.json 旁边,
// src 写成相对那里的路径,整个目录一起挪也不断。图不存在就渲染成占位。
const outDir = resolve(path, '..');
const imagesFlag = flag(rest, '--images');
const imagesDir = imagesFlag ? resolve(imagesFlag) : join(outDir, 'images');
for (const item of [...doc.scenes, ...(doc.props ?? [])]) {
const abs = join(imagesDir, `${slug(item.name)}-sheet.png`);
if (existsSync(abs)) item.sheetImage = relative(outDir, abs).split(sep).join('/');
}
process.stdout.write((rest.includes('--html') ? renderHtml(doc, lang) : renderMarkdown(doc, lang)) + '\n');
return;
}
if (cmd === 'slug') {
if (!rest[0]) throw new Error('用法:slug ');
console.log(slug(rest[0]));
return;
}
throw new Error(`未知命令 ${cmd}\n\n${USAGE}`);
}
// 软链安装时 argv[1] 是链接路径,两边都取 realpath 才能比得上
function isMainModule() {
if (!process.argv[1]) return false;
try {
return realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url));
} catch {
return false;
}
}
if (isMainModule()) {
// `render ... | head` 这类管道提前关闭时安静退出,别甩 EPIPE 堆栈
process.stdout.on('error', (e) => {
if (e.code === 'EPIPE') process.exit(0);
throw e;
});
try {
main(process.argv.slice(2));
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
}
}