@waterwx/dsh-novel-forge 1.7.0 → 1.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. package/lib/client.js +2537 -1858
  2. package/lib/client.js.map +1 -1
  3. package/lib/index.js +657 -45
  4. package/lib/index.js.map +1 -1
  5. package/lib/types/client/api.d.ts +2 -0
  6. package/lib/types/client/locales.d.ts +0 -1
  7. package/lib/types/client/panel/FlowGuide.d.ts +21 -0
  8. package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
  9. package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
  10. package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
  11. package/lib/types/engine.d.ts +15 -8
  12. package/lib/types/protocol.d.ts +124 -0
  13. package/package.json +2 -1
  14. package/src/client/api.ts +5 -0
  15. package/src/client/locales.ts +0 -1
  16. package/src/client/panel/AssetsTab.tsx +22 -22
  17. package/src/client/panel/AssistantTab.tsx +3 -3
  18. package/src/client/panel/BookshelfBar.tsx +1 -1
  19. package/src/client/panel/CreateBookView.tsx +10 -10
  20. package/src/client/panel/FlowGuide.tsx +83 -0
  21. package/src/client/panel/MangaRoleLibrary.tsx +810 -0
  22. package/src/client/panel/MangaWorkspace.tsx +359 -297
  23. package/src/client/panel/NovelPanel.tsx +107 -107
  24. package/src/client/panel/ReaderView.tsx +4 -4
  25. package/src/client/panel/RunPanel.tsx +8 -8
  26. package/src/client/panel/SceneLibrary.tsx +68 -67
  27. package/src/client/panel/StoryboardTab.tsx +105 -41
  28. package/src/client/panel/StyleCard.tsx +13 -13
  29. package/src/client/panel/StyleGallery.tsx +2 -2
  30. package/src/client/panel/WorldTab.tsx +5 -5
  31. package/src/client/panel/panel.module.css +861 -510
  32. package/src/client/panel/views.tsx +24 -24
  33. package/src/engine.ts +482 -51
  34. package/src/protocol.ts +125 -0
  35. package/src/routes.ts +203 -0
  36. package/src/client/panel/RoleVisualPanel.tsx +0 -485
package/src/engine.ts CHANGED
@@ -53,6 +53,8 @@ import type {
53
53
  StoryboardShot,
54
54
  StoryboardPrompt,
55
55
  ChapterStoryboard,
56
+ MangaRoleCandidate,
57
+ MangaRoleCard,
56
58
  Volume,
57
59
  WorldState,
58
60
  } from './protocol.ts'
@@ -151,6 +153,7 @@ export function mergeVolatileFromDisk(outputDir: string, project: ProjectState):
151
153
  if (disk === undefined) return
152
154
  project.bible = disk.bible
153
155
  project.roles = disk.roles
156
+ project.mangaRoles = disk.mangaRoles
154
157
  project.plotlines = disk.plotlines
155
158
  project.roleStatus = disk.roleStatus
156
159
  project.blurb = disk.blurb
@@ -1133,8 +1136,9 @@ export async function extractRoles(
1133
1136
  ): Promise<RoleRecord[]> {
1134
1137
  const system = [
1135
1138
  '你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。',
1136
- '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注。',
1137
- '数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。',
1139
+ '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。',
1140
+ '数量控制:最多输出 10 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。',
1141
+ '重要:正常一部完整故事应提炼 6-10 个角色;若少于 4 个通常说明漏提炼,必须重新核对正文摘录。',
1138
1142
  '重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。',
1139
1143
  '输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。',
1140
1144
  '每个角色输出:',
@@ -1195,22 +1199,24 @@ export async function extractRoles(
1195
1199
  let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1196
1200
  let raw = parseJsonArray<Record<string, unknown>>(text)
1197
1201
  const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
1198
- if (raw.length === 0 || !hasProtagonist) {
1199
- // LLM 偶发输出非 JSON / 空数组 / 漏主角:重试一次(追加明确指令),避免静默返回空或缺主角的候选。
1202
+ const tooFew = raw.length > 0 && raw.length < 4
1203
+ if (raw.length === 0 || !hasProtagonist || tooFew) {
1204
+ // LLM 偶发输出非 JSON / 空数组 / 漏主角 / 角色过少:重试一次(追加明确指令),避免静默返回空或缺角色的候选。
1200
1205
  const hint = raw.length === 0
1201
1206
  ? '\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。'
1202
- : '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
1207
+ : tooFew
1208
+ ? '\n上一次输出角色过少(不足 4 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 6-10 个。'
1209
+ : '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
1203
1210
  text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1204
1211
  raw = parseJsonArray<Record<string, unknown>>(text)
1205
1212
  }
1206
1213
  const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
1207
1214
  const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1208
- const roles: RoleRecord[] = []
1209
- for (const entry of raw) {
1210
- if (typeof entry !== 'object' || entry === null) continue
1215
+ const toRole = (entry: Record<string, unknown>): RoleRecord | undefined => {
1216
+ if (typeof entry !== 'object' || entry === null) return undefined
1211
1217
  const name = typeof entry.name === 'string' ? entry.name.trim().slice(0, 30) : ''
1212
- if (name === '') continue
1213
- roles.push({
1218
+ if (name === '') return undefined
1219
+ return {
1214
1220
  name,
1215
1221
  roleLabel: labels.has(entry.roleLabel as string) ? entry.roleLabel as RoleRecord['roleLabel'] : 'support',
1216
1222
  identity: typeof entry.identity === 'string' ? entry.identity.slice(0, 100) : '',
@@ -1219,7 +1225,54 @@ export async function extractRoles(
1219
1225
  relations: strArr(entry.relations).map(r => r.slice(0, 60)).slice(0, 10),
1220
1226
  arc: strArr(entry.arc).map(a => a.slice(0, 120)).slice(0, 10),
1221
1227
  knowledge: strArr(entry.knowledge).map(k => k.slice(0, 120)).slice(0, 12),
1222
- })
1228
+ }
1229
+ }
1230
+ const roles: RoleRecord[] = []
1231
+ for (const entry of raw) {
1232
+ const role = toRole(entry)
1233
+ if (role !== undefined) roles.push(role)
1234
+ }
1235
+ // 完整性补漏:通用第二轮——检查是否遗漏「身份型/功能性角色」(站长/律师/警察等反复出现者),不依赖任何名单。
1236
+ if (roles.length > 0) {
1237
+ const names = roles.map(r => r.name).join('、')
1238
+ const patchSystem = system + '\n上一次已提炼角色:' + names + '。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。'
1239
+ try {
1240
+ const patchText = await complete(ctx, config, { system: patchSystem, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1241
+ const patchRaw = parseJsonArray<Record<string, unknown>>(patchText)
1242
+ const existing = new Set(roles.map(r => r.name))
1243
+ for (const entry of patchRaw) {
1244
+ const role = toRole(entry)
1245
+ if (role === undefined || existing.has(role.name)) continue
1246
+ existing.add(role.name)
1247
+ roles.push(role)
1248
+ if (roles.length >= 10) break
1249
+ }
1250
+ } catch { /* 补漏失败不阻塞主结果 */ }
1251
+ }
1252
+ // 确定性兜底:正文中反复出现的身份型称呼(站长/律师/警察等)若 LLM 仍漏掉,按出现次数直接补条(通用、不依赖名单)。
1253
+ const ROLE_TITLE_HINTS = ['站长', '律师', '检察官', '法官', '警察', '店主', '老板', '经理', '局长', '医生', '老师', '护士', '房东', '司机', '保安', '主管', '队长', '厂长', '董事长', '总裁', '市长', '道长', '掌门', '师父', '师傅', '管家']
1254
+ const existingNames = new Set(roles.map(r => r.name))
1255
+ const titleCount = new Map<string, number>()
1256
+ for (const chapter of written) {
1257
+ const body = readChapterFile(config.outputDir, chapter)
1258
+ if (body === undefined) continue
1259
+ for (const t of ROLE_TITLE_HINTS) {
1260
+ let idx = 0
1261
+ let n = 0
1262
+ while ((idx = body.indexOf(t, idx)) !== -1) {
1263
+ n++
1264
+ idx += t.length
1265
+ }
1266
+ titleCount.set(t, (titleCount.get(t) ?? 0) + n)
1267
+ }
1268
+ }
1269
+ for (const t of ROLE_TITLE_HINTS) {
1270
+ if (roles.length >= 10) break
1271
+ const covered = existingNames.has(t) || [...existingNames].some(n => n.includes(t))
1272
+ if (!covered && (titleCount.get(t) ?? 0) >= 3) {
1273
+ existingNames.add(t)
1274
+ roles.push({ name: t, roleLabel: 'support', identity: '身份型角色(正文反复出现)', traits: [], goals: '', relations: [], arc: [], knowledge: [] })
1275
+ }
1223
1276
  }
1224
1277
  return roles
1225
1278
  }
@@ -1511,22 +1564,28 @@ export interface RoleVisualPrompt {
1511
1564
  promptKit?: RoleRecord['promptKit']
1512
1565
  }
1513
1566
 
1567
+ /** 可提炼形象的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
1568
+ interface RoleVisualSource {
1569
+ name: string
1570
+ identity?: string
1571
+ traits?: string[]
1572
+ }
1573
+
1514
1574
  /**
1515
- * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
1516
- * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
1575
+ * 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
1576
+ * 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
1517
1577
  */
1518
- export async function extractRoleVisual(
1578
+ async function extractRoleVisualFrom(
1519
1579
  ctx: Context,
1520
1580
  config: NovelConfig,
1521
1581
  project: ProjectState,
1522
1582
  outputDir: string,
1523
- roleName: string,
1583
+ role: RoleVisualSource,
1524
1584
  styleId?: string,
1525
1585
  filterId?: string,
1586
+ shortDrama = false,
1526
1587
  ): Promise<RoleVisualPrompt> {
1527
- const role = (project.roles ?? []).find(r => r.name === roleName)
1528
- if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1529
- role.promptStyleId = styleId
1588
+ const roleName = role.name
1530
1589
 
1531
1590
  // 1. 扫描正文:收集该角色出场且可能含外貌描写的段落(最近 60 章内,每章最多 2 段,共 12 段)。
1532
1591
  // 支持“描述型角色名”(如「灰蓝工装女人」):先精确匹配,匹配不到时用角色名拆分出的关键词兜底。
@@ -1599,6 +1658,8 @@ export async function extractRoleVisual(
1599
1658
  '2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。',
1600
1659
  '3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。',
1601
1660
  '4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。',
1661
+ '5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
1662
+ ...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。'] : []),
1602
1663
  '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1603
1664
  ...rules.map(r => '- ' + r),
1604
1665
  '输出六部分:',
@@ -1620,7 +1681,7 @@ export async function extractRoleVisual(
1620
1681
  const user = [
1621
1682
  `书名:《${project.bookName}》`,
1622
1683
  `目标角色:${role.name}(${role.identity})`,
1623
- role.traits.length > 0 ? `性格标签:${role.traits.join('、')}` : '',
1684
+ (role.traits ?? []).length > 0 ? `性格标签:${role.traits!.join('、')}` : '',
1624
1685
  `正文出场描写(含外貌线索的段落):`,
1625
1686
  ...excerpts.map(e => `[第${e.no}章] ${e.text}`),
1626
1687
  '只输出 JSON 对象。',
@@ -1676,22 +1737,65 @@ export async function extractRoleVisual(
1676
1737
  return { zh, en, tags, source, expressions, promptKit }
1677
1738
  }
1678
1739
 
1679
- /**
1680
- * 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
1681
- * LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
1682
- */
1683
- export async function generateRolePromptKit(
1740
+ /** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
1741
+ export async function extractRoleVisual(
1684
1742
  ctx: Context,
1685
1743
  config: NovelConfig,
1686
1744
  project: ProjectState,
1745
+ outputDir: string,
1687
1746
  roleName: string,
1688
1747
  styleId?: string,
1689
1748
  filterId?: string,
1690
- ): Promise<RoleRecord['promptKit']> {
1749
+ ): Promise<RoleVisualPrompt> {
1691
1750
  const role = (project.roles ?? []).find(r => r.name === roleName)
1692
1751
  if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1693
- if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
1694
1752
  role.promptStyleId = styleId
1753
+ return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId)
1754
+ }
1755
+
1756
+ /** 漫剧角色卡:提炼形象锚点并写回漫剧卡(status → anchored)。 */
1757
+ export async function extractMangaRoleVisual(
1758
+ ctx: Context,
1759
+ config: NovelConfig,
1760
+ project: ProjectState,
1761
+ outputDir: string,
1762
+ cardId: string,
1763
+ styleId?: string,
1764
+ filterId?: string,
1765
+ ): Promise<RoleVisualPrompt> {
1766
+ const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
1767
+ if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
1768
+ const visual = await extractRoleVisualFrom(ctx, config, project, outputDir, { name: card.name, identity: card.identity, traits: card.traits }, styleId, filterId, project.shortDramaMode === true)
1769
+ card.promptStyleId = styleId
1770
+ card.imagePrompt = visual
1771
+ if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions
1772
+ if (visual.promptKit !== undefined) card.promptKit = visual.promptKit
1773
+ if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
1774
+ card.updatedAt = new Date().toISOString()
1775
+ return visual
1776
+ }
1777
+
1778
+ /** 可精修提示词包的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
1779
+ interface RoleKitSource {
1780
+ name: string
1781
+ identity?: string
1782
+ imagePrompt: NonNullable<RoleRecord['imagePrompt']>
1783
+ expressions?: string[]
1784
+ }
1785
+
1786
+ /**
1787
+ * 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
1788
+ */
1789
+ async function generateRolePromptKitFrom(
1790
+ ctx: Context,
1791
+ config: NovelConfig,
1792
+ project: ProjectState,
1793
+ role: RoleKitSource,
1794
+ styleId?: string,
1795
+ filterId?: string,
1796
+ shortDrama = false,
1797
+ ): Promise<RoleRecord['promptKit']> {
1798
+ const roleName = role.name
1695
1799
  const rules = project.visualRules ?? []
1696
1800
  const styleWords = styleKeywords(styleId, filterId)
1697
1801
  const system = [
@@ -1703,6 +1807,8 @@ export async function generateRolePromptKit(
1703
1807
  '2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。',
1704
1808
  '3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
1705
1809
  '4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
1810
+ '5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
1811
+ ...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。'] : []),
1706
1812
  '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1707
1813
  ...rules.map(r => '- ' + r),
1708
1814
  'zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。',
@@ -1740,6 +1846,186 @@ export async function generateRolePromptKit(
1740
1846
  return kit
1741
1847
  }
1742
1848
 
1849
+ /** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
1850
+ export async function generateRolePromptKit(
1851
+ ctx: Context,
1852
+ config: NovelConfig,
1853
+ project: ProjectState,
1854
+ roleName: string,
1855
+ styleId?: string,
1856
+ filterId?: string,
1857
+ ): Promise<RoleRecord['promptKit']> {
1858
+ const role = (project.roles ?? []).find(r => r.name === roleName)
1859
+ if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1860
+ if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
1861
+ role.promptStyleId = styleId
1862
+ return generateRolePromptKitFrom(ctx, config, project, { name: role.name, identity: role.identity, imagePrompt: role.imagePrompt, expressions: role.expressions }, styleId, filterId)
1863
+ }
1864
+
1865
+ /** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
1866
+ export async function generateMangaRolePromptKit(
1867
+ ctx: Context,
1868
+ config: NovelConfig,
1869
+ project: ProjectState,
1870
+ cardId: string,
1871
+ styleId?: string,
1872
+ filterId?: string,
1873
+ ): Promise<RoleRecord['promptKit']> {
1874
+ const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
1875
+ if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
1876
+ if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
1877
+ const kit = await generateRolePromptKitFrom(ctx, config, project, { name: card.name, identity: card.identity, imagePrompt: card.imagePrompt, expressions: card.expressions }, styleId, filterId, project.shortDramaMode === true)
1878
+ card.promptStyleId = styleId
1879
+ card.promptKit = kit
1880
+ if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
1881
+ card.updatedAt = new Date().toISOString()
1882
+ return kit
1883
+ }
1884
+
1885
+ /**
1886
+ * 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
1887
+ * 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
1888
+ * 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
1889
+ */
1890
+ export async function nominateMangaRoles(
1891
+ ctx: Context,
1892
+ config: NovelConfig,
1893
+ project: ProjectState,
1894
+ outputDir: string,
1895
+ chapterNo: number,
1896
+ ): Promise<MangaRoleCandidate[]> {
1897
+ const entry = (project.storyboards ?? []).find(e => e.chapterNo === chapterNo)
1898
+ if (entry === undefined) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`)
1899
+
1900
+ // 收集该章分镜的结构化角色引用(骨架 + 表级 + 镜头级,去重)。
1901
+ const names: string[] = []
1902
+ const pushNames = (list: string[] | undefined): void => {
1903
+ for (const n of list ?? []) {
1904
+ const t = n.trim().slice(0, 20)
1905
+ if (t !== '' && !names.includes(t)) names.push(t)
1906
+ }
1907
+ }
1908
+ pushNames(entry.skeleton?.characters)
1909
+ pushNames(entry.table?.characters)
1910
+ for (const s of entry.table?.shots ?? []) pushNames(s.characters)
1911
+ if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`)
1912
+
1913
+ // 已建漫剧卡的直接标 already_imported(按漫剧名或来源名匹配)。
1914
+ const imported = new Set<string>()
1915
+ for (const c of project.mangaRoles ?? []) {
1916
+ imported.add(c.name)
1917
+ if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') imported.add(c.sourceRoleName)
1918
+ }
1919
+ const already: MangaRoleCandidate[] = names.filter(n => imported.has(n)).map(n => ({
1920
+ rawName: n,
1921
+ verdict: 'already_imported' as const,
1922
+ matches: [],
1923
+ suggested: emptyCandidateSuggestion(n),
1924
+ }))
1925
+ const fresh = names.filter(n => !imported.has(n))
1926
+ if (fresh.length === 0) return already
1927
+
1928
+ const chapter = project.chapters.find(c => c.no === chapterNo)
1929
+ const body = chapter !== undefined ? readChapterFile(outputDir, chapter) : undefined
1930
+ const roles = project.roles ?? []
1931
+
1932
+ // 规则阶段:精确名 + 身份/简称匹配,短名单 ≤5。
1933
+ const shortlists: Array<{ rawName: string; roleNames: string[] }> = fresh.map(rawName => {
1934
+ const shortlist: string[] = []
1935
+ const push = (n: string): void => { if (!shortlist.includes(n)) shortlist.push(n) }
1936
+ for (const r of roles) {
1937
+ if (shortlist.length >= 5) break
1938
+ const n = r.name
1939
+ if (n === rawName) { push(n); continue }
1940
+ if (n.includes(rawName) || rawName.includes(n)) { push(n); continue }
1941
+ const ident = r.identity ?? ''
1942
+ if (ident !== '' && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n)
1943
+ }
1944
+ return { rawName, roleNames: shortlist }
1945
+ })
1946
+
1947
+ // LLM 阶段:确认是/否 + 选哪个 + 漫剧卡建议(只能从短名单里选,不做开放检索)。
1948
+ const beats = (entry.skeleton?.beats ?? []).map(b => `[${b.id}] ${b.event}`).join('\n')
1949
+ const system = [
1950
+ '你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。',
1951
+ '任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。',
1952
+ '规则:',
1953
+ '1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。',
1954
+ '2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。',
1955
+ '3. matched/ambiguous 时给出漫剧卡建议:name(漫剧用名,默认与角色名一致)、identity(身份一句话,30 字内)、coreFunction(protagonist=主角/mentor=导师/love_interest=感情线/antagonist=反派/sidekick=搭档/informant=线人/functional=功能性)、protagonistRelation(enemy/friend/mentor/lover/exploit=利用/neutral)、speechStyle(口头禅或说话方式)、traits(不超过 3 个极致性格标签)、appearance(1-2 个辨识度外貌点)、keyScenes(本章该角色 1-2 个关键剧情节点,格式「第N章 xxx」)。',
1956
+ '4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。',
1957
+ ...(project.shortDramaMode === true
1958
+ ? ['5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。']
1959
+ : []),
1960
+ '输出必须是合法 JSON 数组:[{"rawName": "...", "verdict": "...", "roleName": "...或省略", "suggested": {...}}],每个提名都要有,数组长度必须等于提名数。',
1961
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1962
+ ].join('\n')
1963
+ const user = [
1964
+ `章节:第 ${chapterNo} 章《${chapter?.title ?? ''}》`,
1965
+ '==== 剧情骨架(节拍) ====',
1966
+ beats !== '' ? beats : '(无骨架节拍)',
1967
+ '==== 角色提名与候选名单 ====',
1968
+ shortlists.map(s => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join('、')}` : `[提名] ${s.rawName} → 候选:(无)`).join('\n'),
1969
+ body !== undefined ? '==== 章节正文(前 2500 字,判断称谓归属用) ====\n' + body.replace(/^#.*$/gm, '').trim().slice(0, 2500) : '',
1970
+ '只输出 JSON 数组。',
1971
+ ].filter(x => x !== '').join('\n\n')
1972
+ const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low' })
1973
+ const raw = parseJsonArray<Record<string, unknown>>(text)
1974
+
1975
+ const verdicts = new Set(['matched', 'ambiguous', 'not_in_library'])
1976
+ const fnMap: Record<string, MangaRoleCard['coreFunction']> = { protagonist: 'protagonist', mentor: 'mentor', love_interest: 'love_interest', antagonist: 'antagonist', sidekick: 'sidekick', informant: 'informant', functional: 'functional' }
1977
+ const relMap: Record<string, MangaRoleCard['protagonistRelation']> = { enemy: 'enemy', friend: 'friend', mentor: 'mentor', lover: 'lover', exploit: 'exploit', neutral: 'neutral' }
1978
+ const byName = new Map<string, Record<string, unknown>>()
1979
+ for (const e of raw) {
1980
+ if (typeof e === 'object' && e !== null && typeof e.rawName === 'string') byName.set(e.rawName.trim(), e)
1981
+ }
1982
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 100) : ''
1983
+ const strArr = (v: unknown, n: number): string[] => Array.isArray(v)
1984
+ ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim().slice(0, 20)).slice(0, n)
1985
+ : []
1986
+
1987
+ const out: MangaRoleCandidate[] = []
1988
+ for (const item of shortlists) {
1989
+ const judge = byName.get(item.rawName)
1990
+ const judgeVerdict = judge !== undefined ? str(judge.verdict) : ''
1991
+ const verdict = verdicts.has(judgeVerdict) ? judgeVerdict as MangaRoleCandidate['verdict'] : (item.roleNames.length > 0 ? 'ambiguous' : 'not_in_library')
1992
+ let roleName: string | undefined
1993
+ if (judge !== undefined) {
1994
+ const chosen = str(judge.roleName)
1995
+ if (chosen !== '' && item.roleNames.includes(chosen)) roleName = chosen
1996
+ }
1997
+ if (verdict === 'matched' && roleName === undefined && item.roleNames.length === 1) roleName = item.roleNames[0]
1998
+ const sug = (typeof judge?.suggested === 'object' && judge?.suggested !== null) ? judge.suggested as Record<string, unknown> : {}
1999
+ const novelHint: MangaRoleCandidate['novelHint'] = verdict === 'not_in_library' && body !== undefined
2000
+ ? (body.includes(item.rawName) ? 'backfill' : 'manga_new')
2001
+ : undefined
2002
+ const suggested = {
2003
+ name: (str(sug.name) !== '' ? str(sug.name) : (roleName ?? item.rawName)).slice(0, 30),
2004
+ identity: str(sug.identity).slice(0, 60),
2005
+ coreFunction: fnMap[str(sug.coreFunction)] ?? 'functional',
2006
+ protagonistRelation: relMap[str(sug.protagonistRelation)] ?? 'neutral',
2007
+ speechStyle: str(sug.speechStyle).slice(0, 60),
2008
+ traits: strArr(sug.traits, 3),
2009
+ appearance: str(sug.appearance).slice(0, 100),
2010
+ keyScenes: strArr(sug.keyScenes, 3),
2011
+ }
2012
+ out.push({
2013
+ rawName: item.rawName,
2014
+ verdict,
2015
+ matches: item.roleNames.map(n => ({ roleName: n, reason: roleName === n ? 'LLM 确认' : '规则候选' })),
2016
+ matchedRoleName: roleName,
2017
+ novelHint,
2018
+ suggested,
2019
+ })
2020
+ }
2021
+ return [...already, ...out]
2022
+ }
2023
+
2024
+ /** 空建议(already_imported 等无需 LLM 的候选用)。 */
2025
+ function emptyCandidateSuggestion(name: string): MangaRoleCandidate['suggested'] {
2026
+ return { name, identity: '', coreFunction: 'functional', protagonistRelation: 'neutral', speechStyle: '', traits: [], appearance: '', keyScenes: [] }
2027
+ }
2028
+
1743
2029
  /** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
1744
2030
  export async function extractVisualRules(
1745
2031
  ctx: Context,
@@ -2035,18 +2321,13 @@ export async function testImageEndpoint(baseURL: string, apiKey: string, model?:
2035
2321
  }
2036
2322
  }
2037
2323
 
2038
- export async function generateRoleReferenceImage(
2039
- ctx: Context,
2324
+ /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
2325
+ async function generateReferenceImageFrom(
2040
2326
  config: NovelConfig,
2041
- project: ProjectState,
2042
- outputDir: string,
2043
- name: string,
2327
+ anchor: NonNullable<RoleRecord['imagePrompt']>,
2044
2328
  style = '',
2045
2329
  modelId?: string,
2046
2330
  ): Promise<string> {
2047
- const role = project.roles?.find(r => r.name === name)
2048
- if (role === undefined) throw new Error(`角色 ${name} 不存在`)
2049
- if (role.imagePrompt === undefined) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`)
2050
2331
  if (config.imageApiEnabled !== true) throw new Error('生图未启用,请在设置中启用一个生图模型')
2051
2332
  const activeImage = (modelId !== undefined && modelId !== '' ? (config.imageModels ?? []).find(m => m.id === modelId) : undefined)
2052
2333
  ?? (config.imageModels ?? []).find(m => m.enabled)
@@ -2060,9 +2341,9 @@ export async function generateRoleReferenceImage(
2060
2341
  }
2061
2342
  const stylePrompt = getComicStylePrompt(style)
2062
2343
  const prompt = [
2063
- role.imagePrompt.zh,
2064
- role.imagePrompt.en,
2065
- role.imagePrompt.tags.join(', '),
2344
+ anchor.zh,
2345
+ anchor.en,
2346
+ anchor.tags.join(', '),
2066
2347
  stylePrompt,
2067
2348
  'character reference sheet, front view, clean background, consistent design',
2068
2349
  ].filter(Boolean).join(', ')
@@ -2094,11 +2375,46 @@ export async function generateRoleReferenceImage(
2094
2375
  if (!imgRes.ok) throw new Error('下载生图结果失败')
2095
2376
  const buf = Buffer.from(await imgRes.arrayBuffer())
2096
2377
  const contentType = imgRes.headers.get('content-type') || 'image/jpeg'
2097
- const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`
2378
+ return `data:${contentType};base64,${buf.toString('base64')}`
2379
+ }
2380
+
2381
+ /** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
2382
+ export async function generateRoleReferenceImage(
2383
+ ctx: Context,
2384
+ config: NovelConfig,
2385
+ project: ProjectState,
2386
+ outputDir: string,
2387
+ name: string,
2388
+ style = '',
2389
+ modelId?: string,
2390
+ ): Promise<string> {
2391
+ const role = project.roles?.find(r => r.name === name)
2392
+ if (role === undefined) throw new Error(`角色 ${name} 不存在`)
2393
+ if (role.imagePrompt === undefined) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`)
2394
+ const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId)
2098
2395
  role.imageUrl = dataUrl
2099
2396
  return dataUrl
2100
2397
  }
2101
2398
 
2399
+ /** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
2400
+ export async function generateMangaRoleReferenceImage(
2401
+ ctx: Context,
2402
+ config: NovelConfig,
2403
+ project: ProjectState,
2404
+ outputDir: string,
2405
+ cardId: string,
2406
+ style = '',
2407
+ modelId?: string,
2408
+ ): Promise<string> {
2409
+ const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
2410
+ if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
2411
+ if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
2412
+ const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId)
2413
+ card.imageUrl = dataUrl
2414
+ card.updatedAt = new Date().toISOString()
2415
+ return dataUrl
2416
+ }
2417
+
2102
2418
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
2103
2419
  export async function analyzePlotlineHealth(
2104
2420
  ctx: Context,
@@ -2615,6 +2931,57 @@ function saveChapterStoryboard(project: ProjectState, outputDir: string, entry:
2615
2931
  saveProject(outputDir, project)
2616
2932
  }
2617
2933
 
2934
+ /** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
2935
+ function guessCharactersFromRoles(project: ProjectState, body: string, limit = 12): string[] {
2936
+ const out: string[] = []
2937
+ for (const r of project.roles ?? []) {
2938
+ if (out.length >= limit) break
2939
+ if (r.name !== '' && !out.includes(r.name) && body.includes(r.name)) out.push(r.name)
2940
+ }
2941
+ return out
2942
+ }
2943
+
2944
+ /** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
2945
+ function sanitizeCharacters(raw: unknown, limit: number): string[] {
2946
+ if (!Array.isArray(raw)) return []
2947
+ const out: string[] = []
2948
+ for (const v of raw) {
2949
+ if (typeof v !== 'string') continue
2950
+ const name = v.trim().slice(0, 20)
2951
+ if (name === '' || out.includes(name)) continue
2952
+ out.push(name)
2953
+ if (out.length >= limit) break
2954
+ }
2955
+ return out
2956
+ }
2957
+
2958
+ /** 漫剧卡绑定索引:按分镜称谓(正文确切名)解析漫剧卡(卡名/来源名精确优先,包含兜底)。 */
2959
+ interface MangaRoleBindingIndex {
2960
+ byId: Map<string, MangaRoleCard>
2961
+ resolve: (name: string) => MangaRoleCard | undefined
2962
+ }
2963
+ function buildMangaRoleBindings(project: ProjectState): MangaRoleBindingIndex {
2964
+ const cards = project.mangaRoles ?? []
2965
+ const byName = new Map<string, MangaRoleCard>()
2966
+ for (const c of cards) {
2967
+ if (c.name !== '') byName.set(c.name, c)
2968
+ if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') byName.set(c.sourceRoleName, c)
2969
+ }
2970
+ const byId = new Map<string, MangaRoleCard>(cards.map(c => [c.id, c]))
2971
+ return {
2972
+ byId,
2973
+ resolve: (name: string): MangaRoleCard | undefined => {
2974
+ const exact = byName.get(name)
2975
+ if (exact !== undefined) return exact
2976
+ for (const c of cards) {
2977
+ if (c.name !== '' && (c.name.includes(name) || name.includes(c.name))) return c
2978
+ if (c.sourceRoleName !== undefined && c.sourceRoleName !== '' && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c
2979
+ }
2980
+ return undefined
2981
+ },
2982
+ }
2983
+ }
2984
+
2618
2985
  /**
2619
2986
  * 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
2620
2987
  * 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
@@ -2644,6 +3011,16 @@ export async function generateStoryboardTable(
2644
3011
  // 场景卡:仅列名称与定位(正文命中优先,至多 6 个)。
2645
3012
  const usedScenes = (project.scenes ?? []).filter(s => body.includes(s.name)).slice(0, 6)
2646
3013
  const scenes = usedScenes.map(s => `${s.name}:${s.summary ?? ''}`)
3014
+ // 漫剧定妆卡:本章正文命中漫剧卡(卡名或来源名),注入定妆锚点供画面遵守。
3015
+ const bindings = buildMangaRoleBindings(project)
3016
+ const mangaCardsInBody = bindings.byId.size > 0
3017
+ ? [...bindings.byId.values()].filter(c => body.includes(c.name) || (c.sourceRoleName !== undefined && body.includes(c.sourceRoleName))).slice(0, 8)
3018
+ : []
3019
+ const mangaLines = mangaCardsInBody.map(c => {
3020
+ const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 90) : (c.appearance !== '' ? c.appearance : '')
3021
+ const ref = c.imageUrl !== undefined ? ';参考图:有' : (c.gallery ?? []).some(g => g.label.includes('立绘')) ? ';参考图:有(立绘)' : ''
3022
+ return `${c.name}(${c.identity ?? ''}):定妆${anchor !== '' ? '「' + anchor + '」' : '(锚点未生成)'}${ref}`
3023
+ })
2647
3024
  const rules = (project.visualRules ?? []).map(r => '- ' + r)
2648
3025
 
2649
3026
  const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
@@ -2656,7 +3033,7 @@ export async function generateStoryboardTable(
2656
3033
  const system = [
2657
3034
  '你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
2658
3035
  '任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
2659
- '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态"}]}',
3036
+ '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
2660
3037
  '景别取值:远景/全景/中景/近景/特写。',
2661
3038
  '硬性要求:',
2662
3039
  '【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
@@ -2667,6 +3044,8 @@ export async function generateStoryboardTable(
2667
3044
  '4. 台词/音效/光效无则空字符串,不要编造。',
2668
3045
  '5. duration 1-12 秒。',
2669
3046
  '6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。',
3047
+ '7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。',
3048
+ '8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。',
2670
3049
  ].join('\n')
2671
3050
  const user = [
2672
3051
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -2674,6 +3053,7 @@ export async function generateStoryboardTable(
2674
3053
  `弧线:${skeleton.arc}`,
2675
3054
  skeleton.beats.map(b => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== undefined ? ';承接:' + b.cause : ''})`).join('\n'),
2676
3055
  roles.length > 0 ? '==== 出场角色锚点 ====\n' + roles.join('\n') : '',
3056
+ mangaLines.length > 0 ? '==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n' + mangaLines.join('\n') : '',
2677
3057
  scenes.length > 0 ? '==== 相关场景 ====\n' + scenes.join('\n') : '',
2678
3058
  rules.length > 0 ? '==== 本书视觉规则(必须内嵌进 visual) ====\n' + rules.join('\n') : '',
2679
3059
  '==== 章节正文(画面细节以此为准) ====',
@@ -2698,6 +3078,7 @@ export async function generateStoryboardTable(
2698
3078
  light: typeof entry.light === 'string' ? entry.light.trim().slice(0, 80) : '',
2699
3079
  prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
2700
3080
  nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
3081
+ characters: sanitizeCharacters(entry.characters, 4),
2701
3082
  }))
2702
3083
  .filter(s => s.visual !== '')
2703
3084
  : []
@@ -2708,7 +3089,33 @@ export async function generateStoryboardTable(
2708
3089
  if (missing.length > 0) {
2709
3090
  console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(',')} 无镜头覆盖`)
2710
3091
  }
2711
- const table: StoryboardTable = { chapterNo, shots, usedScenes: usedScenes.map(s => s.name) }
3092
+ // 表级 characters:各镜头去重汇总;LLM 漏标时用骨架/角色库兜底。
3093
+ const tableChars: string[] = []
3094
+ const tableRoleIds: string[] = []
3095
+ for (const s of shots) {
3096
+ // 定妆绑定:镜头 characters 称谓 → 漫剧卡 id(供出图/生视频时按卡取定妆图)。
3097
+ const ids: string[] = []
3098
+ for (const n of s.characters ?? []) {
3099
+ const card = bindings.resolve(n)
3100
+ if (card !== undefined && !ids.includes(card.id)) ids.push(card.id)
3101
+ }
3102
+ s.mangaRoleIds = ids.length > 0 ? ids : undefined
3103
+ for (const c of s.characters ?? []) {
3104
+ if (!tableChars.includes(c)) tableChars.push(c)
3105
+ if (tableChars.length >= 12) break
3106
+ }
3107
+ for (const id of ids) {
3108
+ if (!tableRoleIds.includes(id)) tableRoleIds.push(id)
3109
+ }
3110
+ }
3111
+ const fallbackChars = (skeleton.characters ?? guessCharactersFromRoles(project, body, 12)).slice(0, 12)
3112
+ const table: StoryboardTable = {
3113
+ chapterNo,
3114
+ shots,
3115
+ usedScenes: usedScenes.map(s => s.name),
3116
+ characters: tableChars.length > 0 ? tableChars : fallbackChars,
3117
+ mangaRoleIds: tableRoleIds.length > 0 ? tableRoleIds : undefined,
3118
+ }
2712
3119
  saveChapterStoryboard(project, outputDir, { chapterNo, table, updatedAt: new Date().toISOString() })
2713
3120
  return table
2714
3121
  }
@@ -2737,13 +3144,22 @@ export async function generateStoryboardPrompts(
2737
3144
  filterStyle?.keywords,
2738
3145
  ].filter((v): v is string => v !== undefined && v !== '').join(',') || '3D动漫,超精细建模,电影光影'
2739
3146
  const rules = (project.visualRules ?? []).map(r => '- ' + r)
2740
- const shotLines = table.shots.map(s =>
2741
- `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
3147
+ const bindings = buildMangaRoleBindings(project)
3148
+ const shotLines = table.shots.map(s => {
3149
+ const bound = (s.characters ?? []).map(n => bindings.resolve(n)).filter((c): c is MangaRoleCard => c !== undefined)
3150
+ const makeUp = bound.map(c => {
3151
+ const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 60) : (c.appearance !== '' ? c.appearance : '')
3152
+ const ref = c.imageUrl !== undefined ? '(参考图)' : ''
3153
+ return c.name + ':' + anchor + ref
3154
+ }).join(';')
3155
+ return `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
3156
+ 出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
3157
+ 定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
2742
3158
  画面:${s.visual}
2743
3159
  台词:${s.line !== '' ? s.line : '(无)'}
2744
3160
  光效:${s.light !== '' ? s.light : '(无)'}
2745
3161
  承接:${s.prevState} → ${s.nextState}`
2746
- ).join('\n\n')
3162
+ }).join('\n\n')
2747
3163
  const system = [
2748
3164
  '你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。',
2749
3165
  '任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。',
@@ -2752,7 +3168,7 @@ export async function generateStoryboardPrompts(
2752
3168
  '重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。',
2753
3169
  '硬性要求:',
2754
3170
  '1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。',
2755
- '2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。',
3171
+ '2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。',
2756
3172
  '3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。',
2757
3173
  '4. 输出合法 JSON 对象:{"prompts": [{"shotId": "s1", "text": "..."}]},所有镜头都要有,顺序一致。',
2758
3174
  '5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。',
@@ -2767,13 +3183,19 @@ export async function generateStoryboardPrompts(
2767
3183
  const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
2768
3184
  const raw = parseJsonObject<{ prompts?: unknown }>(text)
2769
3185
  const shotIds = new Set(table.shots.map(s => s.id))
3186
+ const shotBinding = new Map<string, string[]>(table.shots.map(s => [s.id, s.mangaRoleIds ?? []]))
2770
3187
  const prompts: StoryboardPrompt[] = Array.isArray(raw.prompts)
2771
3188
  ? raw.prompts
2772
3189
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
2773
- .map(entry => ({
2774
- shotId: shotIds.has(entry.shotId as string) ? entry.shotId as string : '',
2775
- text: ensureStyleEmbedded(typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '', stylePrefix, 'zh'),
2776
- }))
3190
+ .map(entry => {
3191
+ const shotId = shotIds.has(entry.shotId as string) ? entry.shotId as string : ''
3192
+ const ids = shotBinding.get(shotId) ?? []
3193
+ return {
3194
+ shotId,
3195
+ text: ensureStyleEmbedded(typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '', stylePrefix, 'zh'),
3196
+ mangaRoleIds: ids.length > 0 ? ids : undefined,
3197
+ }
3198
+ })
2777
3199
  .filter(x => x.shotId !== '' && x.text !== '')
2778
3200
  : []
2779
3201
  if (prompts.length === 0) throw new Error('模型未输出有效提示词,请重试')
@@ -2800,13 +3222,14 @@ export async function generateStoryboardSkeleton(
2800
3222
  const system = [
2801
3223
  '你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
2802
3224
  '任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
2803
- '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(如 压抑→隐忍→惊惧)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}]}',
3225
+ '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(如 压抑→隐忍→惊惧)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
2804
3226
  '硬性要求:',
2805
3227
  '1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
2806
3228
  '2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
2807
3229
  '3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。',
2808
3230
  '4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
2809
3231
  '5. 直接输出 JSON 本身,不要输出思考过程或其他文字。',
3232
+ '6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。',
2810
3233
  ].join('\n')
2811
3234
  const user = [
2812
3235
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -2815,7 +3238,7 @@ export async function generateStoryboardSkeleton(
2815
3238
  body.replace(/^#\s+.*$/m, '').trim(),
2816
3239
  ].join('\n')
2817
3240
  const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
2818
- const raw = parseJsonObject<{ arc?: unknown; beats?: unknown }>(text)
3241
+ const raw = parseJsonObject<{ arc?: unknown; beats?: unknown; characters?: unknown }>(text)
2819
3242
  const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
2820
3243
  const functions = new Set(['铺垫', '冲突', '转折', '高潮', '收束', '伏笔', '人物塑造'])
2821
3244
  const beats: StoryboardBeat[] = Array.isArray(raw.beats)
@@ -2831,7 +3254,15 @@ export async function generateStoryboardSkeleton(
2831
3254
  .filter(b => b.event !== '')
2832
3255
  : []
2833
3256
  if (beats.length === 0) throw new Error('模型未输出有效节拍链,请重试')
2834
- const skeleton: StoryboardSkeleton = { chapterNo, arc: arc !== '' ? arc : '(本章弧线未生成)', beats }
3257
+ // 出场角色:LLM 结构化输出优先,漏填时用角色库命中正文兜底。
3258
+ const skeletonChars = sanitizeCharacters(raw.characters, 12)
3259
+ const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12)
3260
+ const skeleton: StoryboardSkeleton = {
3261
+ chapterNo,
3262
+ arc: arc !== '' ? arc : '(本章弧线未生成)',
3263
+ beats,
3264
+ characters: chars.length > 0 ? chars : undefined,
3265
+ }
2835
3266
  saveChapterStoryboard(project, outputDir, { chapterNo, skeleton, updatedAt: new Date().toISOString() })
2836
3267
  return skeleton
2837
3268
  }