#!/usr/bin/env node // novel-characters — deterministic helpers for the novel-characters skill. // Zero dependencies on purpose: the skill must work in any directory // without an npm install. Node 18+ (stdlib only). import { existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'; import { basename, join, relative, resolve, sep } from 'node:path'; import { fileURLToPath } from 'node:url'; /* ------------------------------------------------------------------ */ /* chunk */ /* ------------------------------------------------------------------ */ // 块大小的权衡:块越大,单块阅读越重;块越小,块数越多,跨块归并 // (全管线语义最难的一步)的接缝就越多。现在的模型读 4 万字符毫无压力, // 所以往大取——接缝少三倍,漏归并的机会也少三倍。 // 上限 24 块,扣掉 23 段重叠净覆盖约 93 万字符。 export const CHUNK_SIZE = 40_000; export const CHUNK_OVERLAP = 1_200; export const MAX_CHUNKS = 24; /** * Split source text on paragraph boundaries into overlapping chunks. * Overlap keeps a character introduced at a chunk seam visible to both sides. */ export function chunkText(text) { const clean = text.replace(/\r\n/g, '\n').trim(); if (!clean) return []; if (clean.length <= CHUNK_SIZE) return [clean]; const chunks = []; let cursor = 0; while (cursor < clean.length && chunks.length < MAX_CHUNKS) { let end = Math.min(cursor + CHUNK_SIZE, clean.length); if (end < clean.length) { // Prefer a paragraph break, then a sentence end, inside the last 20%. const windowStart = cursor + Math.floor(CHUNK_SIZE * 0.8); const window = clean.slice(windowStart, end); const para = window.lastIndexOf('\n\n'); const sentence = Math.max( window.lastIndexOf('。'), window.lastIndexOf('!'), window.lastIndexOf('?'), window.lastIndexOf('. '), ); const offset = para >= 0 ? para : sentence; if (offset >= 0) end = windowStart + offset + 1; } chunks.push(clean.slice(cursor, end).trim()); if (end >= clean.length) break; cursor = Math.max(end - CHUNK_OVERLAP, cursor + 1); } return chunks; } /* ------------------------------------------------------------------ */ /* merge */ /* ------------------------------------------------------------------ */ /** * Merge per-chunk rosters into one cast, keyed by name AND alias so that * 陆行远 / 陆 / 姑娘 collapse onto the same person regardless of which * chunk saw which form first. */ export function mergeRoster(batches) { const byKey = new Map(); const keyOf = (s) => String(s).trim().toLowerCase(); for (const batch of batches) { for (const entry of batch ?? []) { if (!entry?.name) continue; const aliases = Array.isArray(entry.aliases) ? entry.aliases : []; const candidates = [entry.name, ...aliases].map(keyOf).filter(Boolean); const existingKey = candidates.find((c) => byKey.has(c)); const target = existingKey ? byKey.get(existingKey) : { name: String(entry.name).trim(), aliases: [], notes: [], quotes: [] }; for (const alias of [entry.name, ...aliases]) { const trimmed = String(alias).trim(); if (trimmed && trimmed !== target.name && !target.aliases.includes(trimmed)) { target.aliases.push(trimmed); } } if (entry.note && String(entry.note).trim()) target.notes.push(String(entry.note).trim()); for (const quote of entry.quotes ?? []) { const trimmed = String(quote).trim(); if (trimmed && !target.quotes.includes(trimmed)) target.quotes.push(trimmed); } for (const c of candidates) byKey.set(c, target); } } // Collapse the alias-keyed index back to one entry per character. const unique = new Map(); for (const value of byKey.values()) unique.set(keyOf(value.name), value); // More chunks mentioning a character == more screen time. return [...unique.values()].sort((a, b) => b.notes.length - a.notes.length); } /** * 疑似同人候选。跨块身份消解是语义问题,确定性代码只能做廉价的那一半: * 名字包含(「陆」⊂「陆行远」)是很强的合并信号,精确匹配却收敛不了它—— * 除非某块恰好把「陆」列成了「陆行远」的别名。「陆先生」和「行远」是不是 * 同一个人则连包含都测不出,所以候选只是嫌疑名单,判断留给模型, * 复核结果写成 merges.json 再用 --apply 确定性落地。 */ export function mergeCandidates(cast) { const keysOf = (c) => [c.name, ...(c.aliases ?? [])].map((s) => String(s).trim()).filter(Boolean); const candidates = []; for (let i = 0; i < cast.length; i++) { for (let j = i + 1; j < cast.length; j++) { let reason = null; for (const a of keysOf(cast[i])) { for (const b of keysOf(cast[j])) { const [short, long] = a.length <= b.length ? [a, b] : [b, a]; if (short.toLowerCase() === long.toLowerCase()) continue; // 相等的键 mergeRoster 已经收敛了 // 拉丁文字短侧至少 3 个字符,否则「Al」⊂「Alex」这类噪音太多;CJK 单字就有信息量 const min = CJK.test(short) ? 1 : 3; if (short.length >= min && long.toLowerCase().includes(short.toLowerCase())) { reason = `「${short}」⊂「${long}」`; break; } } if (reason) break; } if (reason) candidates.push({ a: cast[i].name, b: cast[j].name, reason }); } } return candidates; } /** * 把模型复核后的合并决定落地。merges: [{ keep, absorb: [...] }], * keep / absorb 用 name 或任一 alias 定位都行。找不到就整体报错—— * 静默跳过会让调用方以为合并成功了。 * 不改动传入的 cast:全程在副本上操作,抛错后入参照常可用。 */ export function applyMerges(cast, merges) { cast = cast.map((c) => ({ ...c, aliases: [...c.aliases], notes: [...c.notes], quotes: [...c.quotes] })); const keyOf = (s) => String(s).trim().toLowerCase(); const index = new Map(); for (const c of cast) for (const k of [c.name, ...c.aliases]) index.set(keyOf(k), c); const removed = new Set(); const problems = []; for (const m of merges ?? []) { const target = index.get(keyOf(m?.keep ?? '')); if (!target) { problems.push(`keep「${m?.keep}」在归并结果里找不到`); continue; } for (const name of m?.absorb ?? []) { const victim = index.get(keyOf(name)); if (!victim) { problems.push(`absorb「${name}」在归并结果里找不到`); continue; } if (victim === target) continue; // 本来就是同一个人,不算错 for (const alias of [victim.name, ...victim.aliases]) { if (alias !== target.name && !target.aliases.includes(alias)) target.aliases.push(alias); } target.notes.push(...victim.notes); for (const q of victim.quotes) if (!target.quotes.includes(q)) target.quotes.push(q); for (const k of [victim.name, ...victim.aliases]) index.set(keyOf(k), target); removed.add(victim); } } if (problems.length) throw new Error(problems.join('\n')); return cast.filter((c) => !removed.has(c)).sort((a, b) => b.notes.length - a.notes.length); } /* ------------------------------------------------------------------ */ /* slug */ /* ------------------------------------------------------------------ */ /** Filesystem-safe stem for a character name, CJK preserved. */ export function slug(name) { const cleaned = String(name) .trim() .replace(/[\s/\\:*?"<>|]+/g, '-') .replace(/^-+|-+$/g, ''); return cleaned || 'character'; } /* ------------------------------------------------------------------ */ /* i18n */ /* ------------------------------------------------------------------ */ /* * 报告语言。默认 zh。 * 内置 zh / en 两套界面文案;给了其他语言码就用 en 的界面骨架, * 但角色内容仍按那个语言生成——界面词没翻译总比整篇乱掉强。 */ export const DEFAULT_LANG = 'zh'; const STRINGS = { zh: { kicker: '角色设定集', titleTail: ' · 角色', docTitle: (s) => `${s} · 角色设定集`, counts: (n, shots) => `${n} 位角色${shots ? ` · ${shots} 张设定图` : ''} · 按戏份排序`, synopsis: '故事摘要', indexLabel: '角色索引', aka: '又称', groups: { persona: '画像', image: '形象', voice: '声音' }, persona: { gender: '性别', ageRange: '年龄', identity: '身份', appearance: '外貌', temperament: '性情', motivation: '动机', arc: '人物弧光', relationships: '关系', evidence: '原文依据', }, image: { prompt: '出图提示词 · 喂出图模型用这条', promptLocal: '出图提示词(中文对照)', negative: '反向提示词', sheet: '角色设定图提示词 EN', }, voice: { timbre: '音色', pitch: '音高', pace: '语速', accent: '口音', emotion: '情绪', referenceHint: '类比', prompt: '音色提示词 · 喂 TTS 引擎用这条', }, importance: { protagonist: '主角', major: '主要角色', supporting: '配角', minor: '龙套' }, graphTitle: '关系图谱', graphHint: '悬停看关系,点击进角色', graphCounts: (n, e) => `${n} 位角色 · ${e} 组关系`, exportJson: '导出 JSON', graphLabels: '关系文字', graphEmpty: '这批角色之间没有互相指认的关系', graphDangling: (n) => `另有 ${n} 条关系指向没做画像的角色,图里不画`, relationsAll: '全部关系', copy: '复制', copied: '已复制', copyFailed: '复制失败', copyJson: '复制整份角色 JSON', sheetCaption: '左:半身像 右:全身三视图', noImage: '尚未出图', noImageHint: '用下方提示词生成', colophonA: '画像与提示词由模型依据原文生成,', colophonB: '标记处为原文未明说、为可用性补全的内容。', mdTitle: (s) => `# ${s} — 角色表`, mdCast: (n, names) => `共 ${n} 位角色:${names}`, mdSynopsis: '## 故事摘要', searchPlaceholder: '搜索角色、特质、身份', rosterTitle: '角色 · 按戏份排序', footnote: '标注(推断)的条目为原文未明写、依据文本推演。', noMatch: '没有匹配的角色', voiceTag: 'VOICE', expandAll: '全部展开', zoomImage: '放大查看', copyImage: '复制图片', closeImage: '关闭', }, en: { kicker: 'CHARACTER BIBLE', titleTail: ' · Cast', docTitle: (s) => `${s} · Character Bible`, counts: (n, shots) => `${n} character${n === 1 ? '' : 's'}${shots ? ` · ${shots} sheet${shots === 1 ? '' : 's'}` : ''} · ordered by prominence`, synopsis: 'Synopsis', indexLabel: 'Cast index', aka: 'a.k.a.', groups: { persona: 'Profile', image: 'Design', voice: 'Voice' }, persona: { gender: 'Gender', ageRange: 'Age', identity: 'Standing', appearance: 'Appearance', temperament: 'Temperament', motivation: 'Motivation', arc: 'Arc', relationships: 'Relationships', evidence: 'From the text', }, image: { prompt: 'Image prompt · feed this to the image model', promptLocal: 'Image prompt (local translation)', negative: 'Negative prompt', sheet: 'Model sheet prompt', }, voice: { timbre: 'Timbre', pitch: 'Pitch', pace: 'Pace', accent: 'Accent', emotion: 'Emotion', referenceHint: 'Sounds like', prompt: 'Voice prompt · feed this to the TTS engine', }, importance: { protagonist: 'Lead', major: 'Major', supporting: 'Supporting', minor: 'Minor' }, graphTitle: 'Relationship map', graphHint: 'Hover to trace, click to open', graphCounts: (n, e) => `${n} character${n === 1 ? '' : 's'} · ${e} link${e === 1 ? '' : 's'}`, exportJson: 'Export JSON', graphLabels: 'Link labels', graphEmpty: 'No one in this cast names anyone else', graphDangling: (n) => `${n} more link${n === 1 ? '' : 's'} point to characters without a profile and are not drawn`, relationsAll: 'All links', copy: 'Copy', copied: 'Copied', copyFailed: 'Failed', copyJson: 'Copy full JSON', sheetCaption: 'Left: bust Right: full-body turnaround', noImage: 'Not generated yet', noImageHint: 'use the prompts below', colophonA: 'Profiles and prompts are model-generated from the source text; ', colophonB: 'marks what the text does not state and was filled in for usability.', mdTitle: (s) => `# ${s} — Cast`, mdCast: (n, names) => `${n} characters: ${names}`, mdSynopsis: '## Synopsis', searchPlaceholder: 'Search characters, traits, roles', rosterTitle: 'Cast · by prominence', footnote: 'Anything marked (inferred) is not stated in the text and was reasoned from it.', noMatch: 'No matching character', voiceTag: 'VOICE', expandAll: 'Expand all', zoomImage: 'View larger', copyImage: 'Copy image', closeImage: 'Close', }, ja: { kicker: 'キャラクター設定集', titleTail: ' · 登場人物', docTitle: (s) => `${s} · キャラクター設定集`, counts: (n, shots) => `${n}人${shots ? ` · 設定画 ${shots}枚` : ''} · 出番順`, synopsis: 'あらすじ', indexLabel: '登場人物一覧', aka: '別名', groups: { persona: '人物像', image: 'ビジュアル', voice: '声' }, persona: { gender: '性別', ageRange: '年齢', identity: '立場', appearance: '外見', temperament: '性格', motivation: '動機', arc: '人物の変化', relationships: '関係', evidence: '原文の根拠', }, image: { prompt: '画像プロンプト · 画像モデルにはこれを', promptLocal: '画像プロンプト(対訳)', negative: 'ネガティブプロンプト', sheet: 'キャラ設定画プロンプト EN', }, voice: { timbre: '声質', pitch: '音域', pace: '話速', accent: '訛り', emotion: '感情', referenceHint: 'たとえるなら', prompt: '音声プロンプト · TTS にはこれを', }, importance: { protagonist: '主役', major: '主要人物', supporting: '脇役', minor: '端役' }, graphTitle: '相関図', graphHint: 'ホバーで関係、クリックで詳細', graphCounts: (n, e) => `${n}人 · ${e}組の関係`, exportJson: 'JSON を書き出す', graphLabels: '関係ラベル', graphEmpty: 'この登場人物どうしを結ぶ関係はありません', graphDangling: (n) => `他に${n}件、設定を作っていない人物への関係があります(図には出ません)`, relationsAll: '関係一覧', copy: 'コピー', copied: 'コピー済み', copyFailed: '失敗', copyJson: 'JSON をコピー', sheetCaption: '左:バストアップ 右:三面図', noImage: '未生成', noImageHint: '下のプロンプトで生成', colophonA: '人物像とプロンプトは原文をもとにモデルが生成したものです。', colophonB: 'の箇所は原文に明記がなく、実用のために補ったものです。', mdTitle: (s) => `# ${s} — 登場人物`, mdCast: (n, names) => `全${n}人:${names}`, mdSynopsis: '## あらすじ', searchPlaceholder: 'キャラクター・特徴・立場を検索', rosterTitle: '登場人物 · 出番順', footnote: '(推断)の箇所は原文に明記がなく、本文から推し量ったものです。', noMatch: '該当するキャラクターがいません', voiceTag: 'VOICE', expandAll: 'すべて展開', zoomImage: '拡大表示', copyImage: '画像をコピー', closeImage: '閉じる', }, }; /** * 取界面文案。 * * 两层:内置表覆盖常用语言;其他语言由 skill 在生成时翻译一份塞进 * cast.json 的 `ui`,这里合并进来。这样支持的语言不受内置表限制。 * * @param lang 语言码 * @param overrides cast.json 的 `ui`,可以只覆盖一部分键 */ export function strings(lang = DEFAULT_LANG, overrides = null) { const base = STRINGS[lang] ?? STRINGS.en; if (!overrides || typeof overrides !== 'object') return base; // 只合两层——STRINGS 的嵌套就两层深,够用且不会被脏数据带偏。 const merged = { ...base }; for (const [k, v] of Object.entries(overrides)) { if (typeof base[k] === 'function') continue; // 函数模板不接受覆盖 if (v && typeof v === 'object' && !Array.isArray(v) && base[k] && typeof base[k] === 'object') { merged[k] = { ...base[k], ...v }; } else if (typeof v === 'string') { merged[k] = v; } } return merged; } export const SUPPORTED_UI_LANGS = Object.keys(STRINGS); /** 需要 skill 补一份 `ui` 翻译的语言(内置表里没有的)。 */ export const needsUiTranslation = (lang) => !SUPPORTED_UI_LANGS.includes(lang); /** `ui` 里可覆盖的键——供 ui-template 子命令生成骨架。 */ export function uiTemplate() { const en = STRINGS.en; const out = {}; for (const [k, v] of Object.entries(en)) { if (typeof v === 'function') continue; out[k] = v && typeof v === 'object' ? { ...v } : v; } return out; } /* ------------------------------------------------------------------ */ /* seed — 从大纲预填角色表骨架 */ /* ------------------------------------------------------------------ */ /** * outline 的 tier 映射成 cast 的 importance。 * * 注意两边粒度不一样:outline 的 `lead` 是「主角组」,男女主与主反派都在里面, * 对应 cast 的 protagonist 与 major 两档。这里一律给 protagonist,由模型照 * seedNote 里的 role(女主 / 男主 / 反派)把主角之外的改成 major——**分档本身 * 不推翻,只在主角组内部细分**。 */ export const TIER_TO_IMPORTANCE = { lead: 'protagonist', support: 'supporting', functional: 'minor' }; /** * 从 outline.json 预填 cast.json 骨架。 * * 大纲是角色设定的上游:谁进谁不进、谁是主角组,在改编阶段就拍板了,这一层 * 不再判断一遍。搬过来的是**大纲已经定死的事实**(角色码、名字、分档、人物线、 * 由原著的谁合并而来),留空的是**这一层才该做的设计**(别名、画像、形象提示词、 * 音色提示词)——别名要读原文才知道,大纲里没有。 * * 与 novel-art / novel-script 的 seedFromOutline 同一个形状:搬事实、留设计、 * 附一条 seedNote 带上下文。产出是骨架不是成品,直接跑 validate 会报一堆 * 字段缺失,那是预期的。 */ export function seedFromOutline(outline) { const characters = (outline?.characters ?? []).map((c) => { const note = [ c?.id ? `角色码 ${c.id}` : null, c?.role ? `大纲定位:${c.role}` : null, c?.tier ? `大纲分档:${c.tier}` : null, (c?.from ?? []).length ? `合并自:${(c.from ?? []).join('、')}` : null, ].filter(Boolean).join(' '); return { // 从大纲搬来的事实,不用再想 ...(c?.id ? { id: c.id } : {}), name: c?.name ?? '', importance: TIER_TO_IMPORTANCE[c?.tier] ?? 'supporting', // 这一层才该做的设计,先占位 aliases: [], oneLiner: '', persona: { gender: '', ageRange: '', identity: '', appearance: '', personality: [], temperament: '', motivation: '', // 人物弧光大纲已经写了,直接用;觉得不对回去改大纲,别在这里改一个不一样的 arc: c?.arc ?? '', relationships: [], evidence: [], }, image: { prompt: '', promptLocal: '', negativePrompt: '', tags: [], sheet: '' }, voice: { timbre: '', pitch: '', pace: '', accent: '', emotion: '', prompt: '', referenceHint: '' }, ...(note ? { seedNote: note } : {}), }; }); return { source: outline?.source ?? '', lang: outline?.lang ?? DEFAULT_LANG, summary: '', characters, }; } /* ------------------------------------------------------------------ */ /* validate */ /* ------------------------------------------------------------------ */ const IMPORTANCE = ['protagonist', 'major', 'supporting', 'minor']; /** 中日韩表意文字与假名、谚文——图像/TTS 提示词里出现就说明串语言了。 */ const CJK = /[㐀-鿿぀-ヿ가-힯]/; /** 假名单独一条:用来把日文和中文区分开。 */ const KANA = /[぀-ヿ]/; const PERSONA_STRINGS = ['gender', 'ageRange', 'identity', 'appearance', 'temperament', 'motivation', 'arc']; /** 机器输入,永远英文——图像和 TTS 引擎都吃英文最稳,跟报告语言无关。 */ const MACHINE_FIELDS = { image: ['prompt', 'negativePrompt', 'sheet'], voice: ['prompt'] }; /** 给人读的,跟随报告语言。 */ const HUMAN_VOICE_FIELDS = ['timbre', 'pitch', 'pace', 'accent', 'emotion', 'referenceHint']; const normalise = (s) => String(s).replace(/\s+/g, ''); /** * @param characters 角色卡数组 * @param sourceText 原文;null 则跳过逐字引文校验 * @param lang 报告语言,决定人类可读字段该是什么语言 */ export function validateCast(characters, sourceText, lang = DEFAULT_LANG) { const problems = []; const flatSource = sourceText === null ? null : normalise(sourceText); const at = (name, msg) => problems.push(`[${name}] ${msg}`); if (!Array.isArray(characters) || characters.length === 0) { return ['cast 为空或不是数组']; } // --- 同批角色的提示词不许雷同 --- /* * profile-pass.md 早就写着「同一批角色之间要能区分开,别把长相和声线做成一个样」, * 但没有门。模型第二趟出卡时容易套同一个模板:个体描述写得短,剩下全是真实感样板 * 与固定的构图光照尾巴——两个年龄性别接近的角色出来就是同一个人(issue #9)。 * * 判定:按词集合算 Jaccard 相似度,超阈值就点名那一对。 * 阈值取 0.75,是量出来的不是拍的——自带样例四个角色两两最高 39%, * 而「只改年龄与衣服颜色」的雷同用例是 98%,中间余量极大。 * * image.sheet 刻意不查:三分区排版规范是大段固定文本,真实角色之间本来就有 63% * 重合,设门必然误拦——误拦的门比没有门更糟。 */ const promptSim = (a, b) => { const words = (s) => new Set(String(s ?? '').toLowerCase().split(/[^a-z]+/).filter((w) => w.length > 2)); const A = words(a); const B = words(b); if (A.size < 8 || B.size < 8) return 0; const inter = [...A].filter((w) => B.has(w)).length; return inter / new Set([...A, ...B]).size; }; const SIM_MAX = 0.75; for (const [field, label] of [['prompt', '出图提示词'], ['voice', '音色提示词']]) { const get = (c) => (field === 'prompt' ? c?.image?.prompt : c?.voice?.prompt); for (let i = 0; i < characters.length; i += 1) { for (let j = i + 1; j < characters.length; j += 1) { const sim = promptSim(get(characters[i]), get(characters[j])); if (sim >= SIM_MAX) { const pct = Math.round(sim * 100); problems.push( `${characters[i]?.name ?? '(无名)'} 与 ${characters[j]?.name ?? '(无名)'} 的${label}雷同 ${pct}%` + `(上限 ${Math.round(SIM_MAX * 100)}%)——同一批角色要能区分开,别套同一个模板`, ); } } } } for (const c of characters) { const name = c?.name ?? '(无名)'; // --- 结构 --- if (typeof c?.name !== 'string' || !c.name.trim()) at(name, '缺少 name'); if (!Array.isArray(c?.aliases)) at(name, 'aliases 必须是数组'); if (!IMPORTANCE.includes(c?.importance)) { at(name, `importance 必须是 ${IMPORTANCE.join('/')},实际是 ${JSON.stringify(c?.importance)}`); } if (typeof c?.oneLiner !== 'string' || !c.oneLiner.trim()) at(name, '缺少 oneLiner'); const persona = c?.persona; if (!persona || typeof persona !== 'object') { at(name, '缺少 persona'); } else { for (const f of PERSONA_STRINGS) { if (typeof persona[f] !== 'string' || !persona[f].trim()) at(name, `persona.${f} 缺失或为空`); } if (!Array.isArray(persona.personality)) at(name, 'persona.personality 必须是数组'); if (!Array.isArray(persona.relationships)) at(name, 'persona.relationships 必须是数组'); if (!Array.isArray(persona.evidence)) at(name, 'persona.evidence 必须是数组'); } const image = c?.image; if (!image || typeof image !== 'object') { at(name, '缺少 image'); } else { for (const f of ['prompt', 'negativePrompt']) { if (typeof image[f] !== 'string' || !image[f].trim()) at(name, `image.${f} 缺失或为空`); } if (typeof image.sheet !== 'string' || !image.sheet.trim()) { at(name, 'image.sheet 缺失或为空(角色设定图提示词)'); } if (!Array.isArray(image.tags)) at(name, 'image.tags 必须是数组'); } const voice = c?.voice; if (!voice || typeof voice !== 'object') { at(name, '缺少 voice'); } else { for (const f of [...HUMAN_VOICE_FIELDS, 'prompt']) { if (typeof voice[f] !== 'string' || !voice[f].trim()) at(name, `voice.${f} 缺失或为空`); } /* * 音色提示词里不许出现引号包起来的台词。 * profile-pass.md 第 7 条写着「描述乐器本身,不是某一句台词的演绎」, * 但模型会写出「杀意藏在『规矩就是规矩』这类客套话里」这种句子—— * 台词一进去,有些 TTS 引擎会把它当成要朗读的内容(生产里踩过)。 * 只查成对的引号,判定干净:正常的音色描述不需要引任何一句话。 */ /* * 音色提示词要紧凑,不要散文。 * voice design 引擎(Qwen3-TTS Voice Design / ElevenLabs / MiniMax)吃的是 * 参数密度:年龄性别、音色、音区、共鸣、动态、音量、语速、语调、口音、默认情绪。 * 写成流畅的英文散文会把这些参数稀释掉——生产里实测对比过,紧凑版明显更好。 * * 上限 400 字符是量出来的:自带样例四个角色 218–245,而被实测判为过冗余的 * 散文版是 490–514,中间余量很大。它拦的是「写成小作文」,不是「写得细」。 */ if (typeof voice.prompt === 'string' && voice.prompt.length > 400) { at(name, `voice.prompt ${voice.prompt.length} 字符,超过 400——音色提示词要紧凑的参数串,不是散文;参数写全但别铺陈`); } if (typeof voice.prompt === 'string') { const quoted = voice.prompt.match(/[「『"“][^」』"”]{2,}[」』"”]/); if (quoted) { at(name, `voice.prompt 里出现了引号台词「${quoted[0].slice(0, 24)}」——音色提示词描述的是嗓子本身,不是某一句台词的演绎`); } } } // --- 引文必须逐字 --- if (flatSource && Array.isArray(persona?.evidence)) { for (const quote of persona.evidence) { if (typeof quote !== 'string') { at(name, 'persona.evidence 里有非字符串'); } else if (!flatSource.includes(normalise(quote))) { at(name, `引文不是原文逐字片段:${quote}`); } } } // --- 出图提示词不许出现人名 --- if (image) { const names = [c?.name, ...(Array.isArray(c?.aliases) ? c.aliases : [])].filter( (n) => typeof n === 'string' && n.trim(), ); for (const field of ['prompt', 'promptLocal', 'sheet']) { const value = image[field]; if (typeof value !== 'string') continue; for (const n of names) { if (value.includes(n)) at(name, `image.${field} 里出现了人名「${n}」`); } } } // --- 语言分工 --- // 机器字段永远英文;人类字段跟随报告语言。 // 只有 zh / en 能可靠自动判别,其他语言不猜、跳过。 for (const [group, fields] of Object.entries(MACHINE_FIELDS)) { const obj = c?.[group]; if (!obj) continue; for (const f of fields) { if (typeof obj[f] === 'string' && CJK.test(obj[f])) { at(name, `${group}.${f} 是喂给模型的,必须英文,但含中日韩字符`); } } } if (Array.isArray(image?.tags)) { for (const t of image.tags) { if (typeof t === 'string' && CJK.test(t)) at(name, `image.tags 必须英文,但「${t}」含中日韩字符`); } } // 只有这三种能可靠自动判别,其他语言不猜、跳过——误报比漏报更烦人。 if (voice) { for (const f of HUMAN_VOICE_FIELDS) { const v = voice[f]; if (typeof v !== 'string' || !v.trim()) continue; if (lang === 'en' && CJK.test(v)) at(name, `voice.${f} 应为英文,但含中日韩字符`); if (lang === 'zh' && !CJK.test(v)) at(name, `voice.${f} 应为中文,实际是「${v}」`); if (lang === 'zh' && KANA.test(v)) at(name, `voice.${f} 应为中文,但含日文假名`); if (lang === 'ja' && !KANA.test(v) && !CJK.test(v)) { at(name, `voice.${f} 应为日文,实际是「${v}」`); } } } } return problems; } /* ------------------------------------------------------------------ */ /* assemble */ /* ------------------------------------------------------------------ */ /** * 把第二趟的角色卡合成 cast.json 的顶层结构。纯机械活——之前留给模型 * 手拼,手拼会丢字段、写错顶层键。 * * 排序:importance 分档,同档按 order(merge 输出的名字顺序,即戏份 * 顺序)。不给 order 就保持传入顺序——CLI 按文件名读卡,那是 slug * 字典序不是戏份序,所以报告要「按戏份排序」就必须给 order。 */ export function assembleCast(cards, { source, lang = DEFAULT_LANG, summary = '', ui = null, order = null } = {}) { const rank = (c) => { const i = IMPORTANCE.indexOf(c?.importance); return i < 0 ? IMPORTANCE.length : i; // 越界的排最后,让 validate 去报,这里不崩 }; const orderIndex = new Map((order ?? []).map((name, i) => [String(name).trim().toLowerCase(), i])); // 不在 order 里的排到同档末尾(保持传入顺序),不报错——卡是从 merge 结果生成的,正常对得上 const byOrder = (c) => orderIndex.get(String(c?.name ?? '').trim().toLowerCase()) ?? orderIndex.size; const characters = cards .map((card, i) => [card, i]) .sort((a, b) => rank(a[0]) - rank(b[0]) || byOrder(a[0]) - byOrder(b[0]) || a[1] - b[1]) .map(([card]) => card); const cast = { source, lang }; if (ui) cast.ui = ui; cast.summary = summary; cast.characters = characters; return cast; } /* ------------------------------------------------------------------ */ /* render — markdown */ /* ------------------------------------------------------------------ */ export function renderMarkdown(characters, source, summary = '', lang = DEFAULT_LANG, ui = null) { const t = strings(lang, ui); const out = [t.mdTitle(source), '', t.mdCast(characters.length, characters.map((c) => c.name).join('、')), '']; if (summary) out.push(t.mdSynopsis, '', summary, ''); for (const c of characters) { const { persona, image, voice } = c; out.push('---', ''); out.push(`## ${c.name}${c.aliases.length ? `(${c.aliases.join('、')})` : ''}`, ''); out.push(`> ${t.importance[c.importance] ?? c.importance} · ${c.oneLiner}`, ''); if (c.sheetImage) out.push(`![${c.name} ${t.sheetCaption}](${c.sheetImage})`, ''); out.push(`### ${t.groups.persona}`, ''); out.push(`- **${t.persona.gender}**:${persona.gender}`); out.push(`- **${t.persona.ageRange}**:${persona.ageRange}`); out.push(`- **${t.persona.identity}**:${persona.identity}`); if (persona.personality.length) out.push(`- ${persona.personality.join(' / ')}`); out.push(''); out.push(`**${t.persona.appearance}** ${persona.appearance}`, ''); out.push(`**${t.persona.temperament}** ${persona.temperament}`, ''); out.push(`**${t.persona.motivation}** ${persona.motivation}`, ''); out.push(`**${t.persona.arc}** ${persona.arc}`, ''); if (persona.relationships.length) { out.push(`**${t.persona.relationships}**`, ''); for (const r of persona.relationships) out.push(`- ${r.name} — ${r.relation}`); out.push(''); } if (persona.evidence.length) { out.push(`**${t.persona.evidence}**`, ''); for (const q of persona.evidence) out.push(`> ${q}`, ''); } out.push(`### ${t.groups.image}`, ''); if (image.tags.length) out.push(`\`${image.tags.join('`, `')}\``, ''); out.push(`**${t.image.prompt}**`, '', '```text', image.prompt, '```', ''); if (image.promptLocal) out.push(`${image.promptLocal}`, ''); out.push(`**${t.image.negative}**`, '', '```text', image.negativePrompt, '```', ''); out.push(`**${t.image.sheet}**`, '', '```text', image.sheet, '```', ''); out.push(`### ${t.groups.voice}`, ''); for (const f of HUMAN_VOICE_FIELDS) out.push(`- **${t.voice[f]}**:${voice[f]}`); out.push(''); out.push(`**${t.voice.prompt}**`, '', '```text', voice.prompt, '```', ''); } return out.join('\n'); } /* ------------------------------------------------------------------ */ /* render — html */ /* ------------------------------------------------------------------ */ /* * 三栏工作台。设计约定见 references/report-style.md。不能破的: * 1. 双字域:衬线=叙事与原文,无衬线=分析,等宽=喂给机器的提示词 * 2. 「(推断)」自动高亮,让读者一眼分清有据和补全 * 3. 一次只看一个角色,靠左栏切换 + 顶栏搜索找人 * 4. 打印时全部展开——屏幕上一次一个,纸上要是完整的一份 */ const esc = (s) => String(s ?? '') .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); /** 推断标记:半角/全角 × 中英四种写法都要认,模型不挑食地乱产。 */ const INFERRED = /(\s*(?:推断|inferred)[^)]*)|\(\s*(?:推断|inferred)[^)]*\)/gi; const marked = (s) => esc(s).replace(INFERRED, (m) => `${m}`); const IMPORTANCE_ORDER = ['protagonist', 'major', 'supporting', 'minor']; /** 左栏的一条角色。缩略图取设定图的左半边——那里正好是半身像。 */ function renderRosterItem(c, index, t) { const meta = [c.persona?.gender, c.persona?.ageRange].filter(Boolean).join(' · '); const hay = [ c.name, ...(c.aliases ?? []), c.persona?.identity, c.persona?.gender, c.persona?.ageRange, ...(c.persona?.personality ?? []), c.oneLiner, ] .filter(Boolean) .join(' '); return ``; } function renderCharacter(c, index, t) { const { persona, image, voice } = c; const promptRow = (label, value) => !value ? '' : `
${esc(label)}

${esc(value)}

`; const kv = (label, value) => !value ? '' : `
${esc(label)}
${marked(value)}
`; const block = (label, body) => !body ? '' : `

${esc(label)}

${marked(body)}

`; const plate = c.sheetImage ? `

${esc(t.sheetCaption)}

` : `
${esc(c.name)} · ${esc(t.noImage)}
${esc(t.noImageHint)}
`; return `
${String(index + 1).padStart(2, '0')}

${esc(c.name)}

${esc(t.importance[c.importance] ?? c.importance)} ${c.aliases.length ? `${esc(t.aka)} ${esc(c.aliases.join(' · '))}` : ''} ${marked(c.oneLiner)}
${plate}
${block(t.persona.appearance, persona.appearance)} ${block(t.persona.temperament, persona.temperament)} ${block(t.persona.motivation, persona.motivation)} ${block(t.persona.arc, persona.arc)}
${ persona.evidence.length ? `

${esc(t.persona.evidence)}

${persona.evidence.map((q) => `
${esc(q)}
`).join('')}
` : '' }
${promptRow(t.image.prompt, image.prompt)} ${promptRow(t.image.sheet, image.sheet)} ${promptRow(t.image.negative, image.negativePrompt)} ${promptRow(t.image.promptLocal, image.promptLocal)}
${promptRow(t.voice.prompt, voice.prompt)}
`; } /* ------------------------------------------------------------------ */ /* render — 关系图谱 */ /* ------------------------------------------------------------------ */ /* * 圆环布局 + 向心贝塞尔。位置在 Node 里算好直接写进内联 SVG, * 浏览器端只管高亮和跳转——报告要能离线双击打开,不许引任何库。 */ /** 节点大小按戏份分档,一眼能看出谁是主角。 */ const NODE_R = { protagonist: 11, major: 9, supporting: 7, minor: 5.5 }; const r1 = (n) => Math.round(n * 10) / 10; /** * 把 persona.relationships 解析成无向边。 * * 按**名字 + 别名**建索引:老周的关系里写「老伯」也要连到同一个节点, * 只按 name 匹配会把一半的边漏掉。同一对人的两条单向记述合并成一条边, * 两个方向的说法都留着。指向没做画像的人算 dangling——不画,但要报数。 */ export function buildGraph(characters) { const key = (s) => String(s).trim().toLowerCase(); const index = new Map(); for (const c of characters) { index.set(key(c.name), c.name); for (const a of c.aliases ?? []) index.set(key(a), c.name); } const edges = new Map(); let dangling = 0; for (const c of characters) { for (const r of c.persona?.relationships ?? []) { if (!r || typeof r.name !== 'string') continue; const target = index.get(key(r.name)); if (!target || target === c.name) { dangling++; continue; } const [a, b] = [c.name, target].sort(); const k = `${a}${b}`; if (!edges.has(k)) edges.set(k, { a, b, notes: [] }); edges.get(k).notes.push({ from: c.name, text: String(r.relation ?? '') }); } } return { edges: [...edges.values()], dangling }; } function renderGraph(ordered, t) { const { edges, dangling } = buildGraph(ordered); const n = ordered.length; // 半径跟人数走,四个人不必撑满一整张画布;两侧留 110 给名字 const R = Math.max(130, Math.min(260, 40 + n * 14)); const side = Math.round((R + 110) * 2); const c0 = side / 2; const pos = new Map(); ordered.forEach((c, i) => { const a = -Math.PI / 2 + (i * 2 * Math.PI) / Math.max(n, 1); pos.set(c.name, { x: c0 + R * Math.cos(a), y: c0 + R * Math.sin(a), cos: Math.cos(a), sin: Math.sin(a) }); }); // 控制点往圆心拉,弦是弯的——直线在人多时会糊成一团网 const arcs = edges.map((e, i) => { const p = pos.get(e.a); const q = pos.get(e.b); const cxq = c0 + ((p.x + q.x) / 2 - c0) * 0.35; const cyq = c0 + ((p.y + q.y) / 2 - c0) * 0.35; // 标签沿弦错位排:正对面的两条弦中点都在圆心,全放 t=0.5 会叠成一坨 const t = 0.5 + ((i % 3) - 1) * 0.14; const u = 1 - t; return { e, d: `M${r1(p.x)} ${r1(p.y)} Q${r1(cxq)} ${r1(cyq)} ${r1(q.x)} ${r1(q.y)}`, lx: u * u * p.x + 2 * u * t * cxq + t * t * q.x, ly: u * u * p.y + 2 * u * t * cyq + t * t * q.y, }; }); const paths = arcs .map((a) => ``) .join(''); // 弦上的关系文字:取最短的一条说法截断,全文进 当原生 tooltip const labels = arcs .map((a) => { const notes = a.e.notes.filter((x) => x.text.trim()); if (!notes.length) return ''; const pick = notes.reduce((s, x) => ([...x.text].length < [...s.text].length ? x : s), notes[0]); // 六个字。再长就压到隔壁那条弦上去了——全文在 title 和右侧关系表里 const chars = [...pick.text.trim()]; const text = chars.length > 6 ? `${chars.slice(0, 6).join('')}…` : chars.join(''); const full = notes.map((x) => `${x.from} · ${x.text}`).join('\n'); return `<text class="glabel" data-a="${esc(a.e.a)}" data-b="${esc(a.e.b)}" x="${r1(a.lx)}" y="${r1(a.ly)}" text-anchor="middle" dominant-baseline="middle">${esc(text)}<title>${esc(full)}`; }) .join(''); const dots = ordered .map((c) => { const p = pos.get(c.name); // 圆顶和圆底的名字居中放,两侧的往外甩,免得压在节点上 const flat = Math.abs(p.cos) < 0.25; const anchor = flat ? 'middle' : p.cos < 0 ? 'end' : 'start'; const lx = c0 + (R + 15) * p.cos; const ly = c0 + (R + 15) * p.sin + (flat ? (p.sin < 0 ? -6 : 14) : 4.5); return ` ${esc(c.name)} `; }) .join(''); const rows = edges .map( (e) => ``, ) .join(''); // 边少就直接把关系文字标上;边一多就糊成一团,默认收起来,开关留给用户 const labelsOn = edges.length <= 14; return `

${esc(t.graphTitle)}

${esc(t.graphCounts(n, edges.length))} ${esc(t.graphHint)}
${paths} ${dots} ${labels} ${edges.length ? '' : `

${esc(t.graphEmpty)}

`}
`; } /* * 报告里内嵌的那份数据,形状**就是 cast.json**——编辑完能直接喂回 * `render` 重新出报告,不另立一套导出格式。 * * `<` 转成 <:JSON 里 `<` 只可能出现在字符串值中,整体替换是安全的, * 而不转的话正文里一个 ` c.sheetImage).length; const ordered = [...characters].sort( (a, b) => IMPORTANCE_ORDER.indexOf(a.importance) - IMPORTANCE_ORDER.indexOf(b.importance), ); return ` ${esc(t.docTitle(source))}

${esc(source)}

${esc(t.kicker)}· ${esc(t.counts(characters.length, shots))}
${renderGraph(ordered, t)} ${ordered.map((c, i) => renderCharacter(c, i, t)).join('\n')}
`; } /* ------------------------------------------------------------------ */ /* CLI */ /* ------------------------------------------------------------------ */ const USAGE = `novel-characters.mjs — novel-characters skill 的确定性工具 seed 从大纲预填角色表骨架(打印到 stdout,画像与提示词留空待填) chunk 段落感知重叠切块,写 chunk-NN.txt,打印块数 merge 归并 roster-*.json,打印 {characters, mergeCandidates} [--apply merges.json] 落地复核后的合并决定:{"merges":[{"keep":…,"absorb":[…]}]} assemble --source <书名> [--lang] [--out] 把 card-*.json + 故事摘要(+ 界面翻译)合成 cast.json [--summary ] 故事摘要文件(默认 /summary.txt) [--ui ] 界面文案翻译(默认 /ui.json,内置语言不需要) [--order merged.json] 同档角色的戏份顺序(默认自动找 /merged.json) validate 校验;有违规逐条打印并 exit 1 render [--html|--md] 渲染报告到 stdout(默认 --md) slug 角色名转安全文件名 ui-template [lang] 打印界面文案骨架,供翻译成内置表没有的语言 通用选项: --lang 报告语言,默认取 cast.json 的 lang,再默认 ${DEFAULT_LANG} 内置界面文案:${SUPPORTED_UI_LANGS.join(' / ')};其他语言码用英文界面骨架 render 选项: --source 报告标题用的书名(默认取 cast.json 的 source 或文件名) --images 设定图所在目录,任意路径(相对当前目录解析);默认 cast.json 同级的 images/ 会去找 /-sheet.png;报告里的图片路径按「报告写在 cast.json 旁边」计算`; function readJson(path) { return JSON.parse(readFileSync(resolve(path), 'utf8')); } /** 取 --flag 的值,没有就返回 fallback。 */ function flag(rest, name, fallback = null) { const i = rest.indexOf(name); return i >= 0 && rest[i + 1] ? rest[i + 1] : fallback; } /** cast.json 可以是 {source, lang, summary, characters},也可以是裸数组(旧格式)。 */ function loadCast(path) { const raw = readJson(path); const characters = Array.isArray(raw) ? raw : raw.characters; if (!Array.isArray(characters)) throw new Error(`${path} 里没有 characters 数组`); return { characters, source: Array.isArray(raw) ? null : raw.source, summary: Array.isArray(raw) ? '' : (raw.summary ?? ''), lang: Array.isArray(raw) ? DEFAULT_LANG : (raw.lang ?? DEFAULT_LANG), ui: Array.isArray(raw) ? null : (raw.ui ?? null), }; } 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 === 'chunk') { const [book, workdir] = rest; if (!book || !workdir) throw new Error('用法:chunk '); const text = readFileSync(resolve(book), 'utf8'); const chunks = chunkText(text); mkdirSync(resolve(workdir), { recursive: true }); chunks.forEach((c, i) => { writeFileSync(join(resolve(workdir), `chunk-${String(i).padStart(2, '0')}.txt`), c, 'utf8'); }); const truncated = chunks.length >= MAX_CHUNKS && text.length > CHUNK_SIZE * MAX_CHUNKS; console.log( JSON.stringify( { chunks: chunks.length, chars: text.length, workdir: resolve(workdir), truncated }, null, 2, ), ); if (truncated) console.error(`⚠️ 文本超过 ${MAX_CHUNKS} 块上限,尾部未扫描`); return; } if (cmd === 'merge') { const [workdir] = rest; if (!workdir || workdir.startsWith('--')) throw new Error('用法:merge [--apply merges.json]'); const dir = resolve(workdir); const files = readdirSync(dir).filter((f) => /^roster-.*\.json$/.test(f)).sort(); if (!files.length) throw new Error(`${dir} 里没有 roster-*.json`); const batches = files.map((f) => { const raw = readJson(join(dir, f)); return Array.isArray(raw) ? raw : (raw.characters ?? []); }); let cast = mergeRoster(batches); const applyPath = flag(rest, '--apply'); if (applyPath) { const raw = readJson(applyPath); cast = applyMerges(cast, Array.isArray(raw) ? raw : (raw.merges ?? [])); } // mergeCandidates 是嫌疑名单不是判决:由调用方复核后写 merges.json 再 --apply console.log(JSON.stringify({ characters: cast, mergeCandidates: mergeCandidates(cast) }, null, 2)); return; } if (cmd === 'assemble') { const [workdir] = rest; if (!workdir || workdir.startsWith('--')) { throw new Error('用法:assemble --source <书名> [--lang zh] [--out cast.json]'); } const dir = resolve(workdir); const sourceName = flag(rest, '--source'); if (!sourceName) throw new Error('assemble 需要 --source <书名>'); const lang = flag(rest, '--lang', DEFAULT_LANG); const files = readdirSync(dir).filter((f) => /^card-.*\.json$/.test(f)).sort(); if (!files.length) throw new Error(`${dir} 里没有 card-*.json`); // 宽容解析:一份坏卡不废整批——逐个点名,重跑坏的那个角色就行 const problems = []; const cards = []; const seen = new Map(); for (const f of files) { let card; try { card = readJson(join(dir, f)); } catch (error) { problems.push(`${f} 不是合法 JSON(${error.message})——重跑这个角色即可`); continue; } if (!card || typeof card !== 'object' || Array.isArray(card) || !card.name) { problems.push(`${f} 不是单张角色卡(缺 name)——重跑这个角色即可`); continue; } if (seen.has(card.name)) { problems.push(`「${card.name}」有两份卡(${seen.get(card.name)} 和 ${f}),删掉多余的那份`); continue; } seen.set(card.name, f); cards.push(card); } // 三份附带文件都能单独指定路径;不给才去工作目录里找 skill 自己写下的那份 const summaryPath = resolve(flag(rest, '--summary', join(dir, 'summary.txt'))); const summary = existsSync(summaryPath) ? readFileSync(summaryPath, 'utf8').trim() : ''; if (!summary) problems.push(`缺 ${summaryPath}(故事摘要)——用报告语言写 3–5 句进去,或用 --summary 指定`); const uiPath = resolve(flag(rest, '--ui', join(dir, 'ui.json'))); const ui = existsSync(uiPath) ? readJson(uiPath) : null; if (needsUiTranslation(lang) && !ui) { problems.push(`lang=${lang} 不在内置界面语言里,缺 ${uiPath}——用 ui-template 生成骨架翻译后放进去,或用 --ui 指定`); } // 同档角色的戏份顺序来自 merge 的输出——卡按文件名读入是 slug 字典序, // 不给顺序的话「按戏份排序」就名存实亡 const orderPath = flag(rest, '--order', existsSync(join(dir, 'merged.json')) ? join(dir, 'merged.json') : null); let order = null; if (orderPath) { const raw = readJson(orderPath); const list = Array.isArray(raw) ? raw : (raw.characters ?? []); order = list.map((e) => (typeof e === 'string' ? e : e?.name)).filter(Boolean); } else { console.error(`⚠️ 没有 --order 也没有 ${join(dir, 'merged.json')},同档角色将按文件名序而不是戏份序`); } if (problems.length) { console.error(`✗ ${problems.length} 处问题:\n`); for (const p of problems) console.error(' ' + p); process.exit(1); } const cast = assembleCast(cards, { source: sourceName, lang, summary, ui, order }); const json = JSON.stringify(cast, null, 2) + '\n'; const out = flag(rest, '--out'); if (out) { writeFileSync(resolve(out), json, 'utf8'); console.log(`✓ ${cast.characters.length} 个角色 → ${resolve(out)}`); } else { process.stdout.write(json); } return; } if (cmd === 'validate') { const [castPath, bookPath] = rest; if (!castPath) throw new Error('用法:validate '); const { characters, summary, lang: castLang, ui } = loadCast(castPath); const lang = flag(rest, '--lang', castLang); const source = bookPath ? readFileSync(resolve(bookPath), 'utf8') : null; if (!bookPath) console.error('⚠️ 没给原文,跳过逐字引文校验'); const problems = validateCast(characters, source, lang); // 顶层的故事摘要——报告要用,缺了就没法在顶部交代背景 if (typeof summary !== 'string' || !summary.trim()) { problems.unshift('顶层缺少 summary(故事摘要),报告顶部会空着'); } // 内置表没有这个语言,又没给 ui 翻译 —— 报告界面会露出英文 if (needsUiTranslation(lang) && !ui) { problems.unshift( `lang=${lang} 不在内置界面语言(${SUPPORTED_UI_LANGS.join('/')})里,` + '顶层需要一份 ui 翻译,否则界面文案会是英文。' + '用 `ui-template` 生成骨架后翻译填进去。', ); } if (problems.length) { console.error(`✗ ${problems.length} 处违规:\n`); for (const p of problems) console.error(' ' + p); process.exit(1); } console.log(`✓ ${characters.length} 个角色全部通过校验(lang=${lang})`); return; } if (cmd === 'render') { const [castPath] = rest; if (!castPath) throw new Error('用法:render [--html|--md]'); const html = rest.includes('--html'); const imagesFlag = flag(rest, '--images'); const sourceFlag = flag(rest, '--source'); const { characters, source, summary, lang: castLang, ui } = loadCast(castPath); const lang = flag(rest, '--lang', castLang); const title = sourceFlag ?? source ?? basename(castPath).replace(/\.[^.]+$/, ''); // 图是用户在下游出好的素材,放哪由用户定:--images 按普通命令行路径解析, // 不给才退回 cast.json 同级的 images/。报告默认写在 cast.json 旁边, // src 写成相对那里的路径,整个目录一起挪也不断。图不存在就渲染成占位。 const outDir = resolve(castPath, '..'); const imagesDir = imagesFlag ? resolve(imagesFlag) : join(outDir, 'images'); for (const c of characters) { const abs = join(imagesDir, `${slug(c.name)}-sheet.png`); if (existsSync(abs)) c.sheetImage = relative(outDir, abs).split(sep).join('/'); } process.stdout.write( (html ? renderHtml(characters, title, summary, lang, ui) : renderMarkdown(characters, title, summary, lang, ui)) + '\n', ); return; } if (cmd === 'ui-template') { const lang = rest[0] ?? ''; console.log( JSON.stringify( { note: `把下面每个值翻译成 ${lang},整块放进 cast.json 的顶层 "ui"`, ui: uiTemplate() }, null, 2, ), ); return; } if (cmd === 'slug') { if (!rest[0]) throw new Error('用法:slug '); console.log(slug(rest[0])); return; } throw new Error(`未知命令 ${cmd}\n\n${USAGE}`); } // 只有直接运行才跑 CLI —— selftest.mjs 需要 import 这些函数。 // 两边都取 realpath:软链安装时 argv[1] 是链接路径,而 import.meta.url // 已被 Node 解析成真实路径,不归一化就永远不相等。 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); } }