${esc(e.synopsis)}
#!/usr/bin/env node
// novel-outline — deterministic helpers for the novel-outline skill.
// Zero dependencies on purpose: the skill must work in any directory
// without an npm install. Node 18+ (stdlib only).
import { mkdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
import { basename, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
/* ------------------------------------------------------------------ */
/* 常量与阈值 */
/* ------------------------------------------------------------------ */
/*
* 阈值参数化:「爽点间隔 ≤ 3 集」在不同平台不是一个数。
* outline.json 的 params.thresholds 可以逐项覆盖,不改代码。
*/
export const ADAPT_MODES = ['忠实', '抽核', '借壳'];
export const BEAT_WEIGHTS = ['major', 'minor'];
/*
* 角色分档。一刀切的「有名字角色 ≤ 6」混淆了两件事:观众要记住谁、
* 制作要维护多少张脸。分档把它拆开——每一档的一致性投入完全不同。
* 无名背景人不进表、不追踪、不限量。
*
* 从 novel-characters 的 cast.json 喂进来时按 importance 映射:
* protagonist/major → lead,supporting → support,minor → functional。
*/
export const CHARACTER_TIERS = ['lead', 'support', 'functional'];
export const TIER_LABELS = { lead: '主角组', support: '重要配角', functional: '功能性角色' };
/** AI 短剧的角色资产量折算——资产清单按这个自动汇总,不让模型写。 */
export const TIER_ASSET_SPEC = {
lead: '全套角色设定图 + 逐镜一致性核对',
support: '半身参考图,关键戏核对',
functional: '提示词直出,松散一致即可',
};
export const DEFAULT_THRESHOLDS = {
maxLeads: 5, // 主角组上限(男女主 + 主反派)
maxSupport: 10, // 有名字的重要配角上限
maxFunctional: 10, // 功能性角色上限(占脸不占名,name 用称呼标签)
maxBeatGap: 3, // 相邻爽点最大间隔(集)
maxProps: 8, // 叙事道具上限。跟主角数量一个量级——收多了就不是叙事道具,
// 是场景陈设,那归 novel-art 的场景锚点管
// maxPrimaryScenes 不在这里——它随集数动态,见 primarySceneCap()
};
/**
* 主场景上限随集数走。
*
* 这是给 **AI 短剧**定的数,不是实景剧组的数——场景是生成的,没有搭景钱,
* 「≤ 5」那种实景经济学在这里不成立。上限守的只剩两件事:每个主场景的
* **跨集一致性资产**(环境参考图、光照基调),以及观众的空间认知负担。
* 所以放得宽:观赏性直接吃场景多样性,别为省不存在的钱把戏憋在一个屋里。
*
* 上限 = clamp(4 + ⌈集数 / 10⌉, 5, 15)
*
* 锚点:6 集微型剧 5 个;60 集 10 个;110 集以上封顶 15。
* `params.thresholds.maxPrimaryScenes` 显式给了就用给的,动态值只是缺省。
*/
export function primarySceneCap(episodes) {
if (!Number.isInteger(episodes) || episodes < 1) return 8; // 没有集数信息给个居中值
return Math.max(5, Math.min(15, 4 + Math.ceil(episodes / 10)));
}
/**
* 生成难点关键词表:梗概里扫到就必须进该集的 warnings。
* 宁可多报不可漏报——预警清单的意义就是拍摄前有人看过一眼。
*/
export const RISK_PATTERNS = {
雨戏: /雨/,
肢体接触: /吻|拥抱|相拥|牵手|贴身|扭打|搂/,
人群: /人群|围观|众人|满堂|满座|集市|人山/,
手部特写: /手部|指尖|十指|特写.{0,4}手/,
};
/** 梗概必须是叙述体——出现引号对白就是在写剧本,越界。 */
const DIALOGUE_RE = /「|」|『|』|“|”/;
/* ------------------------------------------------------------------ */
/* chunk — 按章节分卷 */
/* ------------------------------------------------------------------ */
/*
* 长篇(80 万字级)塞不进上下文,两层 map-reduce:
* 章 → 卷(每卷 N 章出一份中间摘要)→ 全书。
* 识别不出章节标题就退回按字数切。
*/
export const CHAPTER_RE =
/^[ \t ]*(第[0-9零一二三四五六七八九十百千两]+[章回节卷部][^\n]*|楔子[^\n]*|序章[^\n]*|尾声[^\n]*|番外[^\n]*|Chapter\s+\d+[^\n]*)$/gm;
export const DEFAULT_PER_VOLUME = 15;
export const MAX_VOLUMES = 60;
export const FALLBACK_CHUNK = 20_000;
export const FALLBACK_OVERLAP = 500;
export function detectChapters(text) {
const found = [];
for (const m of text.matchAll(CHAPTER_RE)) {
found.push({ title: m[1].trim(), start: m.index });
}
return found;
}
/**
* @returns {{volumes: string[], chapters: number, truncated: boolean, mode: 'chapter'|'size'}}
*/
export function chunkVolumes(text, perVolume = DEFAULT_PER_VOLUME) {
const clean = text.replace(/\r\n/g, '\n').trim();
if (!clean) return { volumes: [], chapters: 0, truncated: false, mode: 'chapter' };
const chapters = detectChapters(clean);
// 章节太少:按字数切(带重叠,让卡在切口上的情节两边都看得见)
if (chapters.length < 2) {
const volumes = [];
let cursor = 0;
while (cursor < clean.length && volumes.length < MAX_VOLUMES) {
const end = Math.min(cursor + FALLBACK_CHUNK, clean.length);
volumes.push(clean.slice(cursor, end).trim());
if (end >= clean.length) break;
cursor = Math.max(end - FALLBACK_OVERLAP, cursor + 1);
}
const truncated = volumes.length >= MAX_VOLUMES && clean.length > FALLBACK_CHUNK * MAX_VOLUMES;
return { volumes, chapters: 0, truncated, mode: 'size' };
}
// 章前的引子归进第一卷
const starts = chapters.map((c) => c.start);
if (starts[0] > 0) starts.unshift(0);
const volumes = [];
for (let i = 0; i < starts.length && volumes.length < MAX_VOLUMES; i += perVolume) {
const from = starts[i];
const to = i + perVolume < starts.length ? starts[i + perVolume] : clean.length;
volumes.push(clean.slice(from, to).trim());
}
const covered = Math.min(starts.length, MAX_VOLUMES * perVolume);
const truncated = covered < starts.length;
return { volumes, chapters: chapters.length, truncated, mode: 'chapter' };
}
/* ------------------------------------------------------------------ */
/* slug */
/* ------------------------------------------------------------------ */
export function slug(name) {
const cleaned = String(name)
.trim()
.replace(/[\s/\\:*?"<>|]+/g, '-')
.replace(/^-+|-+$/g, '');
return cleaned || 'outline';
}
/* ------------------------------------------------------------------ */
/* 质量门 */
/* ------------------------------------------------------------------ */
/*
* checklist 的每一项都是代码,不是给模型读的文字——
* 交给模型自觉的清单,输出质量全看它当天心情。
*
* gateReport 产出带 ✓/✗ 的结构化结果(渲染进报告的「质量门」面板),
* validateOutline 把失败项合并进违规列表(CLI 用,exit 1)。
*/
const thText = (s) => typeof s === 'string' && s.trim();
function thresholdsOf(outline) {
const explicit = outline?.params?.thresholds ?? {};
const th = { ...DEFAULT_THRESHOLDS, ...explicit };
if (explicit.maxPrimaryScenes === undefined) th.maxPrimaryScenes = primarySceneCap(outline?.params?.episodes);
return th;
}
/** 每集的正文字段,关键词扫描和对白检查都扫这三栏。 */
const EP_TEXT_FIELDS = ['synopsis', 'hook', 'suspense'];
export function gateReport(outline) {
const th = thresholdsOf(outline);
const gates = [];
// enKey:中文标签会随条件变化的门(目前只有 refs),英文查表要另给一个键。
// 门 id 保持稳定不动——它是日志与下游对账的凭据。
const add = (id, label, ok, detail = '', enKey = null) =>
gates.push({ id, label, ok, detail, ...(enKey ? { enKey } : {}) });
const chars = Array.isArray(outline?.characters) ? outline.characters : [];
const scenes = Array.isArray(outline?.scenes) ? outline.scenes : [];
const beats = Array.isArray(outline?.beats) ? outline.beats : [];
const eps = Array.isArray(outline?.episodes) ? outline.episodes : [];
const total = outline?.params?.episodes ?? eps.length;
// props 是后加的字段。**没有这个字段的旧大纲要照常通过**——两道相关的门
// 都明说跳过而不是报错,否则每一份存量 outline.json 一升级就全红。
const hasProps = Array.isArray(outline?.props);
const props = hasProps ? outline.props : [];
// G1a–G1c 角色分档上限。主角组还要求至少 1 人——没有主角的剧不成立
const tierCap = { lead: th.maxLeads, support: th.maxSupport, functional: th.maxFunctional };
for (const tier of CHARACTER_TIERS) {
const n = chars.filter((c) => c?.tier === tier).length;
const needMin = tier === 'lead';
add(
`${tier}-cap`,
`${TIER_LABELS[tier]} ${needMin ? `1–${tierCap[tier]}` : `≤ ${tierCap[tier]}`} 人`,
(needMin ? n >= 1 : true) && n <= tierCap[tier],
`${n} 位`,
);
}
// G2 主场景上限
const primary = scenes.filter((s) => s?.primary);
add('scene-cap', `主场景 ≤ ${th.maxPrimaryScenes}`, scenes.length > 0 && primary.length <= th.maxPrimaryScenes, `${primary.length} 个`);
// G2b 叙事道具上限。只收有特写、跨集、承载剧情的那几件;数量失控说明把场景
// 陈设也收进来了。没有 props 字段的旧大纲明说跳过,不判失败。
add(
'prop-cap',
`叙事道具 ≤ ${th.maxProps} 件`,
!hasProps || props.length <= th.maxProps,
hasProps ? `${props.length} 件` : '大纲没有 props 字段,跳过',
);
// 场景/角色使用统计(G3、G10 共用)。
// 只给已登记的 id 计数——未知 id 塞进索引会让「引用不存在」那道门形同虚设。
const sceneUse = new Map(scenes.map((s) => [s?.id, 0]));
const charUse = new Map(chars.map((c) => [c?.id, 0]));
const propUse = new Map(props.map((pr) => [pr?.id, 0]));
for (const e of eps) {
for (const id of e?.sceneIds ?? []) if (sceneUse.has(id)) sceneUse.set(id, sceneUse.get(id) + 1);
for (const id of e?.characterIds ?? []) if (charUse.has(id)) charUse.set(id, charUse.get(id) + 1);
for (const id of e?.propIds ?? []) if (propUse.has(id)) propUse.set(id, propUse.get(id) + 1);
}
// G3 一次性场景要有规避方案
const onceNoPlan = scenes.filter((s) => sceneUse.get(s?.id) === 1 && !thText(s?.reusePlan));
add(
'once-scene',
'一次性场景已标注规避方案',
eps.length > 0 && onceNoPlan.length === 0,
onceNoPlan.length ? `缺:${onceNoPlan.map((s) => s.name ?? s.id).join('、')}` : '',
);
// G4 爽点间隔 ≤ N,首尾无真空
const beatEps = [...new Set(beats.map((b) => b?.episode).filter((n) => Number.isInteger(n)))].sort((a, b) => a - b);
let gapOk = beatEps.length > 0 && total > 0;
let gapDetail = '';
if (gapOk) {
if (beatEps[0] > th.maxBeatGap) {
gapOk = false;
gapDetail = `开头 ${beatEps[0] - 1} 集真空`;
}
for (let i = 1; i < beatEps.length && gapOk; i++) {
if (beatEps[i] - beatEps[i - 1] > th.maxBeatGap) {
gapOk = false;
gapDetail = `第 ${beatEps[i - 1]}–${beatEps[i]} 集之间断档`;
}
}
if (gapOk && total - beatEps[beatEps.length - 1] >= th.maxBeatGap) {
gapOk = false;
gapDetail = `结尾 ${total - beatEps[beatEps.length - 1]} 集真空`;
}
}
add('beat-gap', `爽点间隔 ≤ ${th.maxBeatGap} 集,无真空区`, gapOk, gapDetail);
// G5 第 1 集有钩子
add('ep1-hook', '第 1 集有钩子', eps.length > 0 && thText(eps[0]?.hook), '');
// G6 大爆点不能到最后一集才第一次出现
const majors = beats.filter((b) => (b?.weight ?? 'minor') === 'major').map((b) => b.episode);
add(
'major-early',
'大爆点不在最后一集才首次出现',
majors.length > 0 && Math.min(...majors) < total,
majors.length ? `最早在第 ${Math.min(...majors)} 集` : '没有 major 爽点',
);
// G7 每集三栏齐全(钩子/悬念必填)
const incomplete = eps.filter((e) => !EP_TEXT_FIELDS.every((f) => thText(e?.[f])));
add(
'ep-fields',
'每集梗概三栏齐全(含【钩子】【悬念】)',
eps.length > 0 && incomplete.length === 0,
incomplete.length ? `缺栏:第 ${incomplete.map((e) => e.ep).join('、')} 集` : '',
);
// G8 三人以上同框要有拆解方案
const crowdBad = eps.filter((e) => (e?.characterIds?.length ?? 0) >= 3 && !thText(e?.crowdPlan));
add(
'crowd-plan',
'三人以上同框已标注拆解方案',
crowdBad.length === 0,
crowdBad.length ? `缺:第 ${crowdBad.map((e) => e.ep).join('、')} 集` : '',
);
// G9 生成难点进预警清单(关键词扫描,宁可多报)
const riskBad = [];
for (const e of eps) {
const text = EP_TEXT_FIELDS.map((f) => e?.[f] ?? '').join(' ');
for (const [risk, re] of Object.entries(RISK_PATTERNS)) {
if (re.test(text) && !(e?.warnings ?? []).includes(risk)) riskBad.push(`第 ${e.ep} 集缺「${risk}」`);
}
}
add('risk-flag', '生成难点已进预警清单', eps.length > 0 && riskBad.length === 0, riskBad.join(';'));
// G10 引用完整:ID 都存在、没有失业角色、没有空转场景
const refBad = [];
for (const e of eps) {
for (const id of e?.sceneIds ?? []) if (!sceneUse.has(id)) refBad.push(`第 ${e.ep} 集引用了不存在的场景 ${id}`);
for (const id of e?.characterIds ?? []) if (!charUse.has(id)) refBad.push(`第 ${e.ep} 集引用了不存在的角色 ${id}`);
if (hasProps) {
for (const id of e?.propIds ?? []) if (!propUse.has(id)) refBad.push(`第 ${e.ep} 集引用了不存在的道具 ${id}`);
}
}
for (const b of beats) {
if (Number.isInteger(b?.episode) && (b.episode < 1 || b.episode > total)) {
refBad.push(`爽点 ${b.id} 落在不存在的第 ${b.episode} 集`);
}
}
for (const [id, n] of charUse) if (n === 0) refBad.push(`角色 ${id} 从未在任何一集出现`);
for (const [id, n] of sceneUse) if (n === 0) refBad.push(`场景 ${id} 从未被用到`);
if (hasProps) {
for (const [id, n] of propUse) if (n === 0) refBad.push(`道具 ${id} 从未在任何一集出现`);
// 道具关联的爽点必须真的存在——beatIds 指错等于这件道具没有戏剧理由
const beatIds = new Set(beats.map((b) => b?.id));
for (const pr of props) {
for (const bid of pr?.beatIds ?? []) {
if (!beatIds.has(bid)) refBad.push(`道具 ${pr.id} 关联了不存在的爽点 ${bid}`);
}
}
}
add(
'refs',
hasProps ? '场景/角色/道具引用完整,无失业角色、无空转场景、无零集道具' : '场景/角色引用完整,无失业角色、无空转场景',
eps.length > 0 && refBad.length === 0,
refBad.join(';'),
hasProps ? 'refs-props' : null,
);
// G11 梗概是叙述体
const dlgBad = eps.filter((e) => EP_TEXT_FIELDS.some((f) => DIALOGUE_RE.test(e?.[f] ?? '')));
add(
'no-dialogue',
'梗概是叙述体,无引号对白',
dlgBad.length === 0,
dlgBad.length ? `第 ${dlgBad.map((e) => e.ep).join('、')} 集出现引号` : '',
);
return gates;
}
/* ------------------------------------------------------------------ */
/* validate */
/* ------------------------------------------------------------------ */
/*
* 三档 stage 就是流程门:
* skeleton — 改编说明 + 人物 + 场景(快版拍板前)
* beats — skeleton + 爽点表(写分集之前必须过这档)
* full — 全部(默认)
* 「步骤 4 完成前不允许写分集梗概」靠这个变成可执行的,而不是一句话。
*/
export const STAGES = ['skeleton', 'beats', 'full'];
export function validateOutline(outline, stage = 'full') {
const problems = [];
const p = (msg) => problems.push(msg);
if (!outline || typeof outline !== 'object') return ['outline 不是对象'];
const th = thresholdsOf(outline);
// --- params ---
const params = outline.params;
if (!params || typeof params !== 'object') {
p('缺少 params(总集数/单集时长/题材/改编幅度)');
} else {
if (!Number.isInteger(params.episodes) || params.episodes < 1) p('params.episodes 必须是正整数');
if (!(params.minutesPerEpisode > 0)) p('params.minutesPerEpisode 必须大于 0');
if (!thText(params.genre)) p('params.genre 缺失——题材决定爽点类型,不能缺');
if (!ADAPT_MODES.includes(params.adaptMode)) {
p(`params.adaptMode 必须是 ${ADAPT_MODES.join('/')},实际是 ${JSON.stringify(params.adaptMode)}`);
}
}
// --- adaptation 改编说明 ---
const ad = outline.adaptation;
if (!ad || typeof ad !== 'object') {
p('缺少 adaptation(改编说明)');
} else {
if (!thText(ad.core)) p('adaptation.core 缺失——一句话核心是整份大纲的锚');
for (const key of ['keep', 'cut', 'merge', 'risks']) {
if (!Array.isArray(ad[key])) p(`adaptation.${key} 必须是数组`);
}
if (Array.isArray(ad.keep) && ad.keep.length === 0) p('adaptation.keep 至少要有一条——什么都不保还改编什么');
if (params?.adaptMode && params.adaptMode !== '忠实' && Array.isArray(ad.cut) && ad.cut.length === 0) {
p(`adaptMode=${params.adaptMode} 却一条线都没砍,说不过去`);
}
for (const [key, fields] of [['keep', ['what', 'why']], ['cut', ['what', 'why']], ['merge', ['what', 'why']], ['risks', ['what', 'plan']]]) {
for (const item of ad[key] ?? []) {
for (const f of fields) if (!thText(item?.[f])) p(`adaptation.${key} 里有条目缺 ${f}`);
}
}
// 决策补注(可选):给了就不能是空壳
for (const f of ['cutNote', 'mergeNote']) {
if (ad[f] !== undefined && !thText(ad[f])) p(`adaptation.${f} 给了但是空的——要么写结论,要么删掉这个键`);
}
}
// --- characters 人物表 ---
const chars = outline.characters;
if (!Array.isArray(chars) || chars.length === 0) {
p('characters 为空');
} else {
const tierCap = { lead: th.maxLeads, support: th.maxSupport, functional: th.maxFunctional };
for (const tier of CHARACTER_TIERS) {
const n = chars.filter((c) => c?.tier === tier).length;
if (n > tierCap[tier]) p(`${TIER_LABELS[tier]} ${n} 位,超过上限 ${tierCap[tier]}`);
}
if (!chars.some((c) => c?.tier === 'lead')) p('没有主角组(tier=lead)角色');
const seen = new Set();
for (const c of chars) {
const label = c?.name ?? c?.id ?? '(无名)';
if (!/^C\d{2,}$/.test(c?.id ?? '')) p(`[${label}] 角色 id 必须是 C01 这种格式`);
if (seen.has(c?.id)) p(`角色 id ${c.id} 重复`);
seen.add(c?.id);
if (!CHARACTER_TIERS.includes(c?.tier)) {
p(`[${label}] tier 必须是 ${CHARACTER_TIERS.join('/')}(主角组/重要配角/功能性角色)`);
}
for (const f of ['name', 'role']) if (!thText(c?.[f])) p(`[${label}] 缺 ${f}`);
// 功能性角色没有弧光是正常的——医生就是来缝针的
if (c?.tier !== 'functional' && !thText(c?.arc)) p(`[${label}] 缺 arc(主角组和重要配角必须有人物弧)`);
if (!Array.isArray(c?.from) || c.from.length === 0 || !c.from.every(thText)) {
p(`[${label}] 缺 from(← 改动记录:原著对应谁、合并了谁)`);
}
}
}
// --- scenes ---
const scenes = outline.scenes;
if (!Array.isArray(scenes) || scenes.length === 0) {
p('scenes 为空');
} else {
const primaryN = scenes.filter((s) => s?.primary).length;
if (primaryN > th.maxPrimaryScenes) p(`主场景 ${primaryN} 个,超过上限 ${th.maxPrimaryScenes}`);
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(是不是主场景)`);
}
}
// --- props 叙事道具 ---
// 可选字段:旧大纲没有 props 照常通过。写了就按结构查。
if (Array.isArray(outline?.props)) {
if (outline.props.length > th.maxProps) p(`叙事道具 ${outline.props.length} 件,超过上限 ${th.maxProps}`);
const seenP = new Set();
for (const pr of outline.props) {
const label = pr?.name ?? pr?.id ?? '(无名)';
if (!/^P\d{2,}$/.test(pr?.id ?? '')) p(`[${label}] 道具 id 必须是 P01 这种格式`);
if (seenP.has(pr?.id)) p(`道具 id ${pr.id} 重复`);
seenP.add(pr?.id);
if (!thText(pr?.name)) p(`[${pr?.id}] 道具缺 name`);
// function 是这一层唯一要拍板的东西:这件物件在戏里承载什么。
// 填不出来说明它不是叙事道具,是场景陈设——那归 novel-art 的场景锚点管。
if (!thText(pr?.function)) p(`[${label}] 道具缺 function(它在戏里承载什么;填不出来就不该进这张表)`);
if (pr?.beatIds !== undefined && !Array.isArray(pr.beatIds)) p(`[${label}] beatIds 必须是数组`);
}
}
if (stage === 'skeleton') return problems;
// --- beats 爽点表 ---
const beats = outline.beats;
if (!Array.isArray(beats) || beats.length === 0) {
p('beats 为空——爽点表是排片的骨架');
} else {
const seen = new Set();
for (const b of beats) {
const label = b?.id ?? '(无 id)';
if (!/^B\d{2,}$/.test(b?.id ?? '')) p(`[${label}] 爽点 id 必须是 B01 这种格式`);
if (seen.has(b?.id)) p(`爽点 id ${b.id} 重复`);
seen.add(b?.id);
if (!thText(b?.type)) p(`[${label}] 缺 type(打脸/揭破/反转……)`);
if (b?.weight !== undefined && !BEAT_WEIGHTS.includes(b.weight)) p(`[${label}] weight 只能是 ${BEAT_WEIGHTS.join('/')}`);
if (!Number.isInteger(b?.episode) || b.episode < 1) p(`[${label}] episode 必须是正整数`);
for (const f of ['setup', 'payoff']) if (!thText(b?.[f])) p(`[${label}] 缺 ${f}`);
}
// 间隔与 major 时机在 beats 档就要卡住——这两条错了,分集写完全废
for (const g of gateReport(outline)) {
if ((g.id === 'beat-gap' || g.id === 'major-early') && !g.ok) {
p(`质量门未过:${g.label}${g.detail ? `(${g.detail})` : ''}`);
}
}
}
if (stage === 'beats') return problems;
// --- episodes 分集梗概 ---
const eps = outline.episodes;
if (!Array.isArray(eps) || eps.length === 0) {
p('episodes 为空');
} else {
if (params?.episodes && eps.length !== params.episodes) {
p(`分集写了 ${eps.length} 集,params.episodes 说好 ${params.episodes} 集`);
}
eps.forEach((e, i) => {
if (e?.ep !== i + 1) p(`第 ${i + 1} 个条目的 ep 是 ${e?.ep},编号必须从 1 连续`);
if (!Array.isArray(e?.sceneIds) || e.sceneIds.length === 0) p(`第 ${e?.ep} 集缺 sceneIds`);
if (!Array.isArray(e?.characterIds) || e.characterIds.length === 0) p(`第 ${e?.ep} 集缺 characterIds`);
if (e?.warnings !== undefined && !Array.isArray(e.warnings)) p(`第 ${e?.ep} 集 warnings 必须是数组`);
});
// 其余全部质量门(beats 档已报过的两条不再重复)
for (const g of gateReport(outline)) {
if (g.id === 'beat-gap' || g.id === 'major-early') continue;
if (!g.ok) p(`质量门未过:${g.label}${g.detail ? `(${g.detail})` : ''}`);
}
}
return problems;
}
/* ------------------------------------------------------------------ */
/* 资产清单 — 算出来的,不让模型写 */
/* ------------------------------------------------------------------ */
/*
* 五件套的第五件。分集既然带了场景 ID + 角色 ID,
* 资产清单就是纯汇总——让模型手写它一定会漏。
*/
export function computeAssets(outline) {
const eps = Array.isArray(outline?.episodes) ? outline.episodes : [];
const scenes = (outline?.scenes ?? []).map((s) => {
const episodes = eps.filter((e) => (e?.sceneIds ?? []).includes(s.id)).map((e) => e.ep);
return { id: s.id, name: s.name, primary: !!s.primary, uses: episodes.length, episodes, reusePlan: s.reusePlan ?? null };
});
const characters = (outline?.characters ?? []).map((c) => {
const episodes = eps.filter((e) => (e?.characterIds ?? []).includes(c.id)).map((e) => e.ep);
return { id: c.id, name: c.name, role: c.role, tier: c.tier, uses: episodes.length, episodes };
});
// 道具跟场景同一个套路:分集既然带了 propIds,清单就是纯汇总。
// 没有 props 字段的旧大纲返回空数组,调用方按空处理即可。
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: pr.function ?? '',
uses: episodes.length, episodes, beatIds: pr.beatIds ?? [],
};
});
// 角色资产量折算:每档要备多少张脸、备到什么程度
const castPlan = CHARACTER_TIERS.map((tier) => {
const members = (outline?.characters ?? []).filter((c) => c?.tier === tier);
return { tier, label: TIER_LABELS[tier], count: members.length, names: members.map((c) => c.name), spec: TIER_ASSET_SPEC[tier] };
});
const warnings = {};
for (const e of eps) {
for (const w of e?.warnings ?? []) {
(warnings[w] ??= []).push(e.ep);
}
}
const beatsByType = {};
for (const b of outline?.beats ?? []) {
(beatsByType[b.type] ??= []).push(b.episode);
}
return { scenes, characters, props, castPlan, warnings, beatsByType };
}
/* ------------------------------------------------------------------ */
/* render — 界面文案 */
/* ------------------------------------------------------------------ */
/*
* 内置 zh / en 两套。全部文案收在这张表里,别把字符串散进模板——
* 再加语言就是再加一个键(novel-characters 就是这么长出来的)。
* 只翻译界面:outline.json 里的数据(爽点类型、改编幅度、质量门 label)原样出。
*/
/* 门标签与「跳过」提示的英文映射:质量门面板是报告的一部分,出英文报告时
* 这里做展示层翻译——gateReport 的逻辑与中文诊断文案一行不动(CLI 仍是中文)。
* 动态阈值由门自己算,映射里只写固定语义;未命中的 id 回落到原标签。 */
const GATE_LABELS_EN = {
'lead-cap': 'Leads {0}–{1}',
'support-cap': 'Named supporting cast ≤ {0}',
'functional-cap': 'Functional roles ≤ {0}',
'scene-cap': 'Primary scenes ≤ {0}',
'once-scene': 'One-off scenes carry a reuse plan',
'beat-gap': 'Beat gap ≤ {0} episodes, no dead zone',
'ep1-hook': 'Episode 1 has a hook',
'major-early': 'Major beats do not first appear only in the final episode',
'ep-fields': 'All three fields per episode (synopsis, hook, suspense)',
'crowd-plan': 'Three or more on screen carries a breakdown plan',
'risk-flag': 'Production risks flagged in the warning list',
'prop-cap': 'Narrative props ≤ {0}',
'refs': 'Scene / character references complete — no jobless characters, no unused scenes',
'refs-props': 'Scene / character / prop references complete — no jobless characters, no unused scenes, no unused props',
'no-dialogue': 'Synopses in narrative prose, no quoted dialogue',
};
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.enKey ?? 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',
htmlLang: 'zh',
kicker: '短剧改编大纲',
docTitle: (s) => `${s} · 短剧改编大纲`,
paramsLine: (p) =>
`${p.episodes} 集 × ${p.minutesPerEpisode} 分钟 · ${p.genre} · ${p.adaptMode}改编`,
exportJson: '导出 JSON',
gates: '质量门',
gatesPass: '全部通过',
gatesFail: (n) => `${n} 项未过`,
gatePill: (okN, total) => `质量门 ${okN} / ${total}`,
sections: {
decisions: '关键决策', rhythm: '爽点节奏', episodes: '分集梗概',
episodesOverview: '分集概览', matrix: '每集调度矩阵',
sceneOverview: '场景概览', plan: '资产量折算', gates: '质量门',
adaptation: '改编说明', characters: '人物表', beats: '爽点表', assets: '资产清单',
},
dec: {
cut: '砍了哪条线', merge: '合了哪些人', majors: '大爆点落在第几集',
castSlots: (n, l, s, f) => `${n} 个角色位(主角组 ${l} · 重要配角 ${s} · 功能性 ${f})`,
leads: '主角组', noCut: '未砍线(忠实改编)', noMajor: '没有 major 爽点',
first: '首个', final: '终局',
},
secNotes: {
decisions: '拍板过的三件事,落进纸面',
rhythm: (gap) => `间隔 ≤ ${gap} 集 · 无真空区`,
episodes: '核心交付 · 每集三栏齐全',
matrix: '一列 = 这一集要谁、在哪拍',
sceneOverview: '右上 = 出现集',
plan: '按档自动折算 · 不让模型写',
adaptation: '为什么这么改 · 附原文依据',
},
kpi: {
episodes: '总集数', perEp: (m) => `× ${m} 分钟`, runtime: (m) => `正片约 ${m} 分钟`,
beats: '爽点', beatsSub: (major, gap) => `${major} 大爆点${gap ? ` · 最大间隔 ${gap} 集` : ''}`,
cast: '角色', castSub: (l, s, f) => `主角 ${l} · 配角 ${s} · 功能 ${f}`,
scenes: '主场景', scenesOnce: (n) => (n ? `一次性场景 ${n},需复用方案` : '无一次性场景'),
risks: '生成难点', risksNone: '预警清单为空',
mode: '改编幅度', modeSub: (cut, merge) => `砍 ${cut} 线 · 合 ${merge} 组`,
},
legendMajor: '大爆点', legendMinor: '常规爽点',
gapNote: (n) => `— ${n} 集空档 —`,
tabTimeline: '时间轴', tabTable: '明细表',
showAllEps: (n) => `展开全部 ${n} 集`,
assetsAuto: '(由分集数据自动汇总)',
core: '一句话核心',
keep: '保留', cut: '砍掉', merge: '合并', risks: '风险与对策',
what: '内容', why: '理由', plan: '对策', evidence: '原文依据',
charCols: ['ID', '角色', '层级', '定位', '人物弧', '← 改动记录'],
tier: TIER_LABELS,
tierSpec: TIER_ASSET_SPEC,
castPlanTitle: '角色资产量折算',
castPlanCols: ['层级', '人数', '角色', '资产量'],
planSceneRow: '场景环境',
planSceneSpec: '主场景各一套环境参考 + 光照基调',
planSceneReuse: (names) => `(+${names.join('、')}复用)`,
planPropRow: '叙事道具',
planPropSpec: '每件一套白底设定图 + 状态变体,跨集要长一样',
planRiskRow: '生成难点',
planRiskSpec: '拍摄前逐条过预警清单',
beatCols: ['ID', '类型', '量级', '集', '铺垫', '兑现'],
weight: { major: '大爆点', minor: '常规' },
rhythm: '爽点节奏',
rhythmLegend: '■ 大爆点 □ 常规 · 无爽点',
matrixHead: '角色 / 场景 / 道具',
matrixTier: '层级',
matrixTotal: '合计',
matrixScenes: '场 景',
matrixProps: '道 具',
onceScene: '一次性',
primaryScene: '主场景',
reusePlanLabel: '复用方案',
beatsCarried: '承载爽点',
castSeen: '出场角色',
crowdOk: '同框拆解 ✓',
epTitle: (n) => `第 ${n} 集`,
epHook: '钩子',
epSuspense: '悬念',
epScenes: '场景',
epCast: '人物',
epCrowd: '同框拆解',
epWarnings: '预警',
epsParen: (list) => `(第 ${list.join('、')} 集)`,
epsCount: (n) => `${n} 集`,
sceneCols: ['ID', '场景', '主场景', '出现集', '次数', '复用方案'],
propCols: ['ID', '道具', '承载什么', '出现集', '次数', '关联爽点'],
castCols: ['ID', '角色', '定位', '出现集', '次数'],
warnCols: ['难点', '涉及集'],
beatTypeCols: ['爽点类型', '落点(集)'],
yes: '是', no: '否',
none: '—',
sep: '、', semi: ';', colon: ':', pairSep: ' ', tipSep: '|',
brk: (s) => `【${s}】`,
mdSec: (n, title) => `${'一二三四五六七八九'[n - 1]}、${title}`,
colophon: '大纲由模型依据原文生成,质量门由脚本确定性检查。',
},
en: {
langCode: 'en',
htmlLang: 'en',
kicker: 'Short-drama adaptation outline',
docTitle: (s) => `${s} · Short-Drama Adaptation Outline`,
paramsLine: (p) =>
`${p.episodes} eps × ${p.minutesPerEpisode} min · ${p.genre} · ${p.adaptMode} adaptation`,
exportJson: 'Export JSON',
gates: 'Quality gates',
gatesPass: 'All passed',
gatesFail: (n) => `${n} failed`,
gatePill: (okN, total) => `Gates ${okN} / ${total}`,
sections: {
decisions: 'Key decisions', rhythm: 'Beat rhythm', episodes: 'Per-episode synopses',
episodesOverview: 'Episode overview', matrix: 'Dispatch matrix',
sceneOverview: 'Scene overview', plan: 'Asset conversion', gates: 'Quality gates',
adaptation: 'Adaptation notes', characters: 'Cast table', beats: 'Beat table', assets: 'Asset list',
},
dec: {
cut: 'Which lines were cut', merge: 'Who got merged', majors: 'Where the major beats land',
castSlots: (n, l, s, f) => `${n} cast slots (leads ${l} · supporting ${s} · functional ${f})`,
leads: 'Leads', noCut: 'No lines cut (faithful adaptation)', noMajor: 'No major beats',
first: 'First', final: 'Final',
},
secNotes: {
decisions: 'The three sign-off items, on paper',
rhythm: (gap) => `gap ≤ ${gap} eps · no dead zones`,
episodes: 'Core deliverable · three fields per episode',
matrix: 'One column = who and where for that episode',
sceneOverview: 'Top right = episodes present',
plan: 'Converted per tier · never hand-written',
adaptation: 'Why these changes · with source evidence',
},
kpi: {
episodes: 'Episodes', perEp: (m) => `× ${m} min`, runtime: (m) => `about ${m} min of footage`,
beats: 'Beats', beatsSub: (major, gap) => `${major} major${gap ? ` · max gap ${gap} eps` : ''}`,
cast: 'Cast', castSub: (l, s, f) => `leads ${l} · support ${s} · functional ${f}`,
scenes: 'Primary scenes', scenesOnce: (n) => (n ? `${n} one-off, reuse plan required` : 'No one-off scenes'),
risks: 'Production risks', risksNone: 'Warning list empty',
mode: 'Adaptation mode', modeSub: (cut, merge) => `${cut} line(s) cut · ${merge} merge(s)`,
},
legendMajor: 'Major beat', legendMinor: 'Minor beat',
gapNote: (n) => `— ${n}-ep gap —`,
tabTimeline: 'Timeline', tabTable: 'Table',
showAllEps: (n) => `Show all ${n} episodes`,
assetsAuto: ' (auto-aggregated from episode data)',
core: 'One-line core',
keep: 'Keep', cut: 'Cut', merge: 'Merge', risks: 'Risks & plans',
what: 'What', why: 'Why', plan: 'Plan', evidence: 'Evidence',
charCols: ['ID', 'Name', 'Tier', 'Role', 'Arc', '← Change record'],
tier: { lead: 'Lead', support: 'Named supporting', functional: 'Functional' },
tierSpec: {
lead: 'Full model sheets + per-shot consistency checks',
support: 'Bust reference, checks on key scenes',
functional: 'Prompt-only, loose consistency is fine',
},
castPlanTitle: 'Cast asset conversion',
castPlanCols: ['Tier', 'Count', 'Cast', 'Asset workload'],
planSceneRow: 'Scene environments',
planSceneSpec: 'One environment reference set + lighting key per primary scene',
planSceneReuse: (names) => ` (+ ${names.join(', ')} via reuse)`,
planPropRow: 'Narrative props',
planPropSpec: 'One white-plate sheet plus state variants each; must stay identical across episodes',
planRiskRow: 'Production risks',
planRiskSpec: 'Walk the warning list before generation',
beatCols: ['ID', 'Type', 'Weight', 'Ep', 'Setup', 'Payoff'],
weight: { major: 'Major', minor: 'Minor' },
rhythm: 'Beat rhythm',
rhythmLegend: '■ major □ minor · none',
matrixHead: 'Character / Scene / Prop',
matrixTier: 'Tier',
matrixTotal: 'Total',
matrixScenes: 'Scenes',
matrixProps: 'Props',
onceScene: 'One-off',
primaryScene: 'Primary',
reusePlanLabel: 'Reuse plan',
beatsCarried: 'Beats carried',
castSeen: 'Cast seen',
crowdOk: 'Crowd plan ✓',
epTitle: (n) => `Episode ${n}`,
epHook: 'Hook',
epSuspense: 'Suspense',
epScenes: 'Scenes',
epCast: 'Cast',
epCrowd: 'Crowd plan',
epWarnings: 'Warnings',
epsParen: (list) => ` (ep ${list.join(', ')})`,
epsCount: (n) => `${n} eps`,
sceneCols: ['ID', 'Scene', 'Primary', 'Episodes', 'Uses', 'Reuse plan'],
propCols: ['ID', 'Prop', 'What it carries', 'Episodes', 'Uses', 'Beats'],
castCols: ['ID', 'Name', 'Role', 'Episodes', 'Uses'],
warnCols: ['Risk', 'Episodes'],
beatTypeCols: ['Beat type', 'Episodes'],
yes: 'Yes', no: 'No',
none: '—',
sep: ', ', semi: '; ', colon: ': ', pairSep: ' · ', tipSep: ' | ',
brk: (s) => `[${s}]`,
mdSec: (n, title) => `${n}. ${title}`,
colophon: 'Outline generated by the model from the source text; quality gates checked deterministically by script.',
},
};
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');
/** 人物表按档排:主角组在前,功能性角色垫底。 */
const byTier = (characters) =>
[...characters].sort((a, b) => CHARACTER_TIERS.indexOf(a.tier) - CHARACTER_TIERS.indexOf(b.tier));
export function renderMarkdown(outline, lang) {
const { source, params, adaptation: ad, characters, beats, episodes } = outline;
const t = tOf(lang ?? outline?.lang);
const assets = computeAssets(outline);
const gates = gateReport(outline);
const out = [];
out.push(`# ${t.docTitle(source)}`, '', `> ${t.paramsLine(params)}`, '');
// 质量门放最前面——先看有没有病,再看内容
out.push(`## ${t.gates}`, '');
for (const g of gates) out.push(`- ${g.ok ? '✅' : '❌'} ${gateText(g, t.langCode).label}${!g.ok && g.detail ? ` — ${gateText(g, t.langCode).detail}` : ''}`);
out.push('');
out.push(`## ${t.mdSec(1, t.sections.adaptation)}`, '', `**${t.core}**${t.pairSep}${ad.core}`, '');
const adTable = (title, rows, fields, labels) => {
if (!rows?.length) return;
out.push(`### ${title}`, '', mdHead(labels));
for (const r of rows) out.push(mdRow(fields.map((f) => r[f] ?? '')));
out.push('');
};
adTable(t.keep, ad.keep, ['what', 'why', 'evidence'], [t.what, t.why, t.evidence]);
adTable(t.cut, ad.cut, ['what', 'why'], [t.what, t.why]);
adTable(t.merge, ad.merge, ['what', 'why'], [t.what, t.why]);
adTable(t.risks, ad.risks, ['what', 'plan'], [t.what, t.plan]);
out.push(`## ${t.mdSec(2, t.sections.characters)}`, '', mdHead(t.charCols));
for (const c of byTier(characters)) {
out.push(mdRow([c.id, c.name, t.tier[c.tier] ?? c.tier, c.role, c.arc ?? '—', c.from.join(t.semi)]));
}
out.push('');
out.push(`## ${t.mdSec(3, t.sections.beats)}`, '', mdHead(t.beatCols));
for (const b of beats) {
out.push(mdRow([b.id, b.type, t.weight[b.weight ?? 'minor'], b.episode, b.setup, b.payoff]));
}
out.push('');
out.push(`## ${t.mdSec(4, t.sections.episodes)}`, '');
for (const e of episodes) {
out.push(`### ${t.epTitle(e.ep)}`, '', e.synopsis, '');
out.push(`- **${t.brk(t.epHook)}** ${e.hook}`);
out.push(`- **${t.brk(t.epSuspense)}** ${e.suspense}`);
out.push(`- ${t.epScenes}${t.colon}${e.sceneIds.join(t.sep)}${t.pairSep}${t.epCast}${t.colon}${e.characterIds.join(t.sep)}`);
if (e.crowdPlan) out.push(`- ${t.epCrowd}${t.colon}${e.crowdPlan}`);
if (e.warnings?.length) out.push(`- ⚠️ ${t.epWarnings}${t.colon}${e.warnings.join(t.sep)}`);
out.push('');
}
out.push(`## ${t.mdSec(5, t.sections.assets)}${t.assetsAuto}`, '');
out.push(mdHead(t.sceneCols));
for (const s of assets.scenes) {
out.push(mdRow([s.id, s.name, s.primary ? t.yes : t.no, s.episodes.join(t.sep), s.uses, s.reusePlan ?? '—']));
}
// 道具表:没有 props 的旧大纲不出这张表,不留一张空表占位
if (assets.props.length) {
out.push('', mdHead(t.propCols));
for (const pr of assets.props) {
out.push(mdRow([pr.id, pr.name, pr.function, pr.episodes.join(t.sep), pr.uses, pr.beatIds.join(t.sep) || '—']));
}
}
out.push('', mdHead(t.castCols));
for (const c of assets.characters) out.push(mdRow([c.id, c.name, c.role, c.episodes.join(t.sep), c.uses]));
out.push('');
out.push(`### ${t.castPlanTitle}`, '', mdHead(t.castPlanCols));
for (const t2 of assets.castPlan) {
out.push(mdRow([t.tier[t2.tier] ?? t2.label, t2.count, t2.names.join(t.sep) || '—', t.tierSpec[t2.tier] ?? t2.spec]));
}
out.push('');
if (Object.keys(assets.warnings).length) {
out.push(mdHead(t.warnCols));
for (const [w, epsIn] of Object.entries(assets.warnings)) out.push(mdRow([w, epsIn.join(t.sep)]));
out.push('');
}
out.push(mdHead(t.beatTypeCols));
for (const [type, epsIn] of Object.entries(assets.beatsByType)) out.push(mdRow([type, epsIn.join(t.sep)]));
out.push('');
return out.join('\n');
}
/* ------------------------------------------------------------------ */
/* render — html */
/* ------------------------------------------------------------------ */
/*
* 业内评审用的单页报告:1600 宽,全部平铺可 Cmd+F。设计约定见
* references/report-style.md。区块顺序按「先交付后存档」排:
* KPI 带 → 爽点节奏(时间轴)→ 分集梗概 → 调度矩阵 + 场景概览
* → 资产量折算 → 人物表 → 改编说明 → 质量门
* 所有图形都是内联 SVG/CSS —— 不引任何库,报告离线双击能开。
* 配色跑过 dataviz 验证器:大爆点 #8a3324 / 常规 #c56a4e,六项全过。
*/
/** 报告里内嵌的数据就是 outline.json 原样——编辑完能直接喂回 render。 */
function embedOutline(outline) {
return JSON.stringify(outline).replace(/ Math.round(n * 10) / 10;
/** 截断到 n 个字,超出加省略号。按码点数,中英混排不劈字。 */
const snip = (s, n) => {
const a = [...String(s ?? '')];
return a.length > n ? `${a.slice(0, n).join('')}…` : String(s ?? '');
};
/**
* 出现集列表 → 幽灵编号:连续区间合写(1,2,3 → 1–3),
* 离散且不超过 4 个用间隔点(1 · 6),再多只报数量。
*/
export function fmtEps(eps, t = I18N.zh) {
if (!eps?.length) return '—';
const a = [...eps].sort((x, y) => x - y);
if (a.length === 1) return String(a[0]);
const consecutive = a.every((v, i) => i === 0 || v === a[i - 1] + 1);
if (consecutive) return `${a[0]}–${a[a.length - 1]}`;
if (a.length <= 4) return a.join(' · ');
return t.epsCount(a.length);
}
/* ---------- 爽点节奏:剧情时间轴 ---------- */
/*
* 一条地平线贯穿全剧,爽点是轴上的节点,标签上下交替防撞。
* 60 集以上按每行 20 集折行,同一条轴的延续。
* 空档直接标在轴上;超过 maxBeatGap 的空档标成铁锈红——违规在图上自己喊。
*/
const RH = { W: 1520, PADX: 30, ROWH: 176, AXIS: 92, PER_ROW: 20 };
function renderRhythm(outline, t) {
const total = outline.params.episodes;
const beats = [...outline.beats].sort((a, b) => a.episode - b.episode);
const th = { ...DEFAULT_THRESHOLDS, ...(outline.params?.thresholds ?? {}) };
const cols = Math.min(total, RH.PER_ROW);
const colW = (RH.W - 2 * RH.PADX) / cols;
const rows = Math.ceil(total / cols);
const rowOf = (ep) => Math.floor((ep - 1) / cols);
const x = (ep) => r1(RH.PADX + (((ep - 1) % cols) + 0.5) * colW);
const axisY = (ep) => rowOf(ep) * RH.ROWH + RH.AXIS;
const parts = [];
const tickParts = []; // 刻度最后画——自带底衬,压在节点竖线上仍可读;反过来会被竖线盖住
// 每行一条轴线 + 集刻度
for (let r = 0; r < rows; r++) {
const epsInRow = Math.min(total - r * cols, cols);
const y = r * RH.ROWH + RH.AXIS;
parts.push(`
| ${esc(c)} | `).join('')}
|---|
| ${c} | `).join('')}
${esc(e.synopsis)}
| ${esc(t.matrixHead)} | ${esc(t.matrixTier)} | ${epHead}${esc(t.matrixTotal)} |
|---|
${onceNotes}
` : ''}${esc(t.dec.noCut)}
`} ${ad.cutNote ? `${esc(ad.cutNote)}
` : ''}${esc(t.dec.castSlots(characters.length, tierN.lead ?? 0, tierN.support ?? 0, tierN.functional ?? 0))}
${esc(t.dec.leads + t.colon + leadNames.join(t.sep))}
${ad.merge.length ? `${esc(ad.mergeNote)}
` : ''}${esc(t.dec.noMajor)}
`}${esc(ad.core)}
${ad.keep?.length ? `${esc(r.evidence)}` : esc(t.none)]))}` : ''} ${ad.cut?.length ? `
${failed.length ? `${esc(t.gatesFail(failed.length))}` : esc(t.gatesPass)}
${esc(t.colophon)}