#!/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(`![${s.name} ${t.sheetCaption}](${s.sheetImage})`, ''); 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(`![${pr.name} ${t.propSheetCaption}](${pr.sheetImage})`, ''); 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) => ``).join('')}${rows.map((r) => `${r.map((c) => ``).join('')}`).join('\n')}
${esc(c)}
${c}
`; 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) => `
  1. ${esc(a.name)}${esc(a.desc)}
  2. `).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) => `
  1. ${esc(a.name)}${esc(a.desc)}
  2. `).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 = ``; 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)}

${esc(t.colophon)}

`; } /* ------------------------------------------------------------------ */ /* 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); } }