@waterwx/dsh-novel-forge 1.7.0 → 1.7.3

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 (47) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +3197 -1887
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1026 -84
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/client/api.d.ts +2 -0
  7. package/lib/types/client/locales.d.ts +0 -1
  8. package/lib/types/client/panel/FlowGuide.d.ts +21 -0
  9. package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
  10. package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
  11. package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
  12. package/lib/types/engine.d.ts +15 -8
  13. package/lib/types/protocol.d.ts +138 -10
  14. package/lib/types/shot-language.d.ts +46 -0
  15. package/lib/types/story-beat-language.d.ts +35 -0
  16. package/package.json +5 -2
  17. package/skills/chapter-batch/SKILL.md +81 -0
  18. package/src/client/api.ts +5 -0
  19. package/src/client/locales.ts +0 -1
  20. package/src/client/panel/AssetsTab.tsx +22 -22
  21. package/src/client/panel/AssistantTab.tsx +3 -3
  22. package/src/client/panel/BookshelfBar.tsx +1 -1
  23. package/src/client/panel/CreateBookView.tsx +14 -10
  24. package/src/client/panel/FlowGuide.tsx +83 -0
  25. package/src/client/panel/ImportModal.tsx +11 -2
  26. package/src/client/panel/MangaRoleLibrary.tsx +810 -0
  27. package/src/client/panel/MangaWorkspace.tsx +359 -297
  28. package/src/client/panel/NovelPanel.tsx +108 -107
  29. package/src/client/panel/ReaderView.tsx +4 -4
  30. package/src/client/panel/RunPanel.tsx +9 -9
  31. package/src/client/panel/SceneLibrary.tsx +68 -67
  32. package/src/client/panel/ShelfView.tsx +2 -2
  33. package/src/client/panel/StoryboardTab.tsx +145 -49
  34. package/src/client/panel/StyleCard.tsx +13 -13
  35. package/src/client/panel/StyleGallery.tsx +2 -2
  36. package/src/client/panel/WorldTab.tsx +5 -5
  37. package/src/client/panel/panel.module.css +861 -510
  38. package/src/client/panel/views.tsx +24 -24
  39. package/src/engine.ts +514 -62
  40. package/src/index.ts +1 -0
  41. package/src/protocol.ts +147 -10
  42. package/src/routes.ts +216 -7
  43. package/src/run.ts +5 -4
  44. package/src/shot-language.ts +181 -0
  45. package/src/story-beat-language.ts +130 -0
  46. package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
  47. package/src/client/panel/RoleVisualPanel.tsx +0 -485
package/src/engine.ts CHANGED
@@ -30,6 +30,21 @@ import type { Context } from '@deepseek-ai/cordis'
30
30
  import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
31
31
  import { getComicStylePrompt } from './comic-presets.ts'
32
32
  import { findStyle, styleKeywords } from './style-library.ts'
33
+ import {
34
+ normalizeShotSize,
35
+ normalizeCameras,
36
+ normalizeComposition,
37
+ normalizeLightings,
38
+ sizeZh,
39
+ cameraZh,
40
+ lightZh,
41
+ } from './shot-language.ts'
42
+ import {
43
+ normalizeStoryFunction,
44
+ normalizeEmotions,
45
+ functionZh,
46
+ emotionZh,
47
+ } from './story-beat-language.ts'
33
48
  import type {
34
49
  AuditIssue,
35
50
  AuthorReview,
@@ -53,6 +68,8 @@ import type {
53
68
  StoryboardShot,
54
69
  StoryboardPrompt,
55
70
  ChapterStoryboard,
71
+ MangaRoleCandidate,
72
+ MangaRoleCard,
56
73
  Volume,
57
74
  WorldState,
58
75
  } from './protocol.ts'
@@ -151,6 +168,7 @@ export function mergeVolatileFromDisk(outputDir: string, project: ProjectState):
151
168
  if (disk === undefined) return
152
169
  project.bible = disk.bible
153
170
  project.roles = disk.roles
171
+ project.mangaRoles = disk.mangaRoles
154
172
  project.plotlines = disk.plotlines
155
173
  project.roleStatus = disk.roleStatus
156
174
  project.blurb = disk.blurb
@@ -1133,8 +1151,9 @@ export async function extractRoles(
1133
1151
  ): Promise<RoleRecord[]> {
1134
1152
  const system = [
1135
1153
  '你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。',
1136
- '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注。',
1137
- '数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。',
1154
+ '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。',
1155
+ '数量控制:最多输出 10 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。',
1156
+ '重要:正常一部完整故事应提炼 6-10 个角色;若少于 4 个通常说明漏提炼,必须重新核对正文摘录。',
1138
1157
  '重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。',
1139
1158
  '输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。',
1140
1159
  '每个角色输出:',
@@ -1195,22 +1214,24 @@ export async function extractRoles(
1195
1214
  let text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1196
1215
  let raw = parseJsonArray<Record<string, unknown>>(text)
1197
1216
  const hasProtagonist = raw.some(e => typeof e === 'object' && e !== null && e.roleLabel === 'protagonist')
1198
- if (raw.length === 0 || !hasProtagonist) {
1199
- // LLM 偶发输出非 JSON / 空数组 / 漏主角:重试一次(追加明确指令),避免静默返回空或缺主角的候选。
1217
+ const tooFew = raw.length > 0 && raw.length < 4
1218
+ if (raw.length === 0 || !hasProtagonist || tooFew) {
1219
+ // LLM 偶发输出非 JSON / 空数组 / 漏主角 / 角色过少:重试一次(追加明确指令),避免静默返回空或缺角色的候选。
1200
1220
  const hint = raw.length === 0
1201
1221
  ? '\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。'
1202
- : '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
1222
+ : tooFew
1223
+ ? '\n上一次输出角色过少(不足 4 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 6-10 个。'
1224
+ : '\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。'
1203
1225
  text = await complete(ctx, config, { system: system + hint, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 24000), reasoning: config.analysisReasoning ?? 'low' })
1204
1226
  raw = parseJsonArray<Record<string, unknown>>(text)
1205
1227
  }
1206
1228
  const labels = new Set(['protagonist', 'female_lead', 'female_support', 'support', 'antagonist', 'extra'])
1207
1229
  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
1230
+ const toRole = (entry: Record<string, unknown>): RoleRecord | undefined => {
1231
+ if (typeof entry !== 'object' || entry === null) return undefined
1211
1232
  const name = typeof entry.name === 'string' ? entry.name.trim().slice(0, 30) : ''
1212
- if (name === '') continue
1213
- roles.push({
1233
+ if (name === '') return undefined
1234
+ return {
1214
1235
  name,
1215
1236
  roleLabel: labels.has(entry.roleLabel as string) ? entry.roleLabel as RoleRecord['roleLabel'] : 'support',
1216
1237
  identity: typeof entry.identity === 'string' ? entry.identity.slice(0, 100) : '',
@@ -1219,7 +1240,54 @@ export async function extractRoles(
1219
1240
  relations: strArr(entry.relations).map(r => r.slice(0, 60)).slice(0, 10),
1220
1241
  arc: strArr(entry.arc).map(a => a.slice(0, 120)).slice(0, 10),
1221
1242
  knowledge: strArr(entry.knowledge).map(k => k.slice(0, 120)).slice(0, 12),
1222
- })
1243
+ }
1244
+ }
1245
+ const roles: RoleRecord[] = []
1246
+ for (const entry of raw) {
1247
+ const role = toRole(entry)
1248
+ if (role !== undefined) roles.push(role)
1249
+ }
1250
+ // 完整性补漏:通用第二轮——检查是否遗漏「身份型/功能性角色」(站长/律师/警察等反复出现者),不依赖任何名单。
1251
+ if (roles.length > 0) {
1252
+ const names = roles.map(r => r.name).join('、')
1253
+ const patchSystem = system + '\n上一次已提炼角色:' + names + '。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。'
1254
+ try {
1255
+ const patchText = await complete(ctx, config, { system: patchSystem, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 12000), reasoning: config.analysisReasoning ?? 'low' })
1256
+ const patchRaw = parseJsonArray<Record<string, unknown>>(patchText)
1257
+ const existing = new Set(roles.map(r => r.name))
1258
+ for (const entry of patchRaw) {
1259
+ const role = toRole(entry)
1260
+ if (role === undefined || existing.has(role.name)) continue
1261
+ existing.add(role.name)
1262
+ roles.push(role)
1263
+ if (roles.length >= 10) break
1264
+ }
1265
+ } catch { /* 补漏失败不阻塞主结果 */ }
1266
+ }
1267
+ // 确定性兜底:正文中反复出现的身份型称呼(站长/律师/警察等)若 LLM 仍漏掉,按出现次数直接补条(通用、不依赖名单)。
1268
+ const ROLE_TITLE_HINTS = ['站长', '律师', '检察官', '法官', '警察', '店主', '老板', '经理', '局长', '医生', '老师', '护士', '房东', '司机', '保安', '主管', '队长', '厂长', '董事长', '总裁', '市长', '道长', '掌门', '师父', '师傅', '管家']
1269
+ const existingNames = new Set(roles.map(r => r.name))
1270
+ const titleCount = new Map<string, number>()
1271
+ for (const chapter of written) {
1272
+ const body = readChapterFile(config.outputDir, chapter)
1273
+ if (body === undefined) continue
1274
+ for (const t of ROLE_TITLE_HINTS) {
1275
+ let idx = 0
1276
+ let n = 0
1277
+ while ((idx = body.indexOf(t, idx)) !== -1) {
1278
+ n++
1279
+ idx += t.length
1280
+ }
1281
+ titleCount.set(t, (titleCount.get(t) ?? 0) + n)
1282
+ }
1283
+ }
1284
+ for (const t of ROLE_TITLE_HINTS) {
1285
+ if (roles.length >= 10) break
1286
+ const covered = existingNames.has(t) || [...existingNames].some(n => n.includes(t))
1287
+ if (!covered && (titleCount.get(t) ?? 0) >= 3) {
1288
+ existingNames.add(t)
1289
+ roles.push({ name: t, roleLabel: 'support', identity: '身份型角色(正文反复出现)', traits: [], goals: '', relations: [], arc: [], knowledge: [] })
1290
+ }
1223
1291
  }
1224
1292
  return roles
1225
1293
  }
@@ -1511,22 +1579,28 @@ export interface RoleVisualPrompt {
1511
1579
  promptKit?: RoleRecord['promptKit']
1512
1580
  }
1513
1581
 
1582
+ /** 可提炼形象的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
1583
+ interface RoleVisualSource {
1584
+ name: string
1585
+ identity?: string
1586
+ traits?: string[]
1587
+ }
1588
+
1514
1589
  /**
1515
- * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
1516
- * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
1590
+ * 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
1591
+ * 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
1517
1592
  */
1518
- export async function extractRoleVisual(
1593
+ async function extractRoleVisualFrom(
1519
1594
  ctx: Context,
1520
1595
  config: NovelConfig,
1521
1596
  project: ProjectState,
1522
1597
  outputDir: string,
1523
- roleName: string,
1598
+ role: RoleVisualSource,
1524
1599
  styleId?: string,
1525
1600
  filterId?: string,
1601
+ shortDrama = false,
1526
1602
  ): Promise<RoleVisualPrompt> {
1527
- const role = (project.roles ?? []).find(r => r.name === roleName)
1528
- if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1529
- role.promptStyleId = styleId
1603
+ const roleName = role.name
1530
1604
 
1531
1605
  // 1. 扫描正文:收集该角色出场且可能含外貌描写的段落(最近 60 章内,每章最多 2 段,共 12 段)。
1532
1606
  // 支持“描述型角色名”(如「灰蓝工装女人」):先精确匹配,匹配不到时用角色名拆分出的关键词兜底。
@@ -1599,6 +1673,8 @@ export async function extractRoleVisual(
1599
1673
  '2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。',
1600
1674
  '3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。',
1601
1675
  '4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。',
1676
+ '5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
1677
+ ...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。'] : []),
1602
1678
  '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1603
1679
  ...rules.map(r => '- ' + r),
1604
1680
  '输出六部分:',
@@ -1620,7 +1696,7 @@ export async function extractRoleVisual(
1620
1696
  const user = [
1621
1697
  `书名:《${project.bookName}》`,
1622
1698
  `目标角色:${role.name}(${role.identity})`,
1623
- role.traits.length > 0 ? `性格标签:${role.traits.join('、')}` : '',
1699
+ (role.traits ?? []).length > 0 ? `性格标签:${role.traits!.join('、')}` : '',
1624
1700
  `正文出场描写(含外貌线索的段落):`,
1625
1701
  ...excerpts.map(e => `[第${e.no}章] ${e.text}`),
1626
1702
  '只输出 JSON 对象。',
@@ -1676,22 +1752,65 @@ export async function extractRoleVisual(
1676
1752
  return { zh, en, tags, source, expressions, promptKit }
1677
1753
  }
1678
1754
 
1679
- /**
1680
- * 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
1681
- * LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
1682
- */
1683
- export async function generateRolePromptKit(
1755
+ /** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
1756
+ export async function extractRoleVisual(
1684
1757
  ctx: Context,
1685
1758
  config: NovelConfig,
1686
1759
  project: ProjectState,
1760
+ outputDir: string,
1687
1761
  roleName: string,
1688
1762
  styleId?: string,
1689
1763
  filterId?: string,
1690
- ): Promise<RoleRecord['promptKit']> {
1764
+ ): Promise<RoleVisualPrompt> {
1691
1765
  const role = (project.roles ?? []).find(r => r.name === roleName)
1692
1766
  if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1693
- if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
1694
1767
  role.promptStyleId = styleId
1768
+ return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId)
1769
+ }
1770
+
1771
+ /** 漫剧角色卡:提炼形象锚点并写回漫剧卡(status → anchored)。 */
1772
+ export async function extractMangaRoleVisual(
1773
+ ctx: Context,
1774
+ config: NovelConfig,
1775
+ project: ProjectState,
1776
+ outputDir: string,
1777
+ cardId: string,
1778
+ styleId?: string,
1779
+ filterId?: string,
1780
+ ): Promise<RoleVisualPrompt> {
1781
+ const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
1782
+ if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
1783
+ const visual = await extractRoleVisualFrom(ctx, config, project, outputDir, { name: card.name, identity: card.identity, traits: card.traits }, styleId, filterId, project.shortDramaMode === true)
1784
+ card.promptStyleId = styleId
1785
+ card.imagePrompt = visual
1786
+ if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions
1787
+ if (visual.promptKit !== undefined) card.promptKit = visual.promptKit
1788
+ if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
1789
+ card.updatedAt = new Date().toISOString()
1790
+ return visual
1791
+ }
1792
+
1793
+ /** 可精修提示词包的「角色源」:小说角色卡或漫剧角色卡的最小公共面。 */
1794
+ interface RoleKitSource {
1795
+ name: string
1796
+ identity?: string
1797
+ imagePrompt: NonNullable<RoleRecord['imagePrompt']>
1798
+ expressions?: string[]
1799
+ }
1800
+
1801
+ /**
1802
+ * 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
1803
+ */
1804
+ async function generateRolePromptKitFrom(
1805
+ ctx: Context,
1806
+ config: NovelConfig,
1807
+ project: ProjectState,
1808
+ role: RoleKitSource,
1809
+ styleId?: string,
1810
+ filterId?: string,
1811
+ shortDrama = false,
1812
+ ): Promise<RoleRecord['promptKit']> {
1813
+ const roleName = role.name
1695
1814
  const rules = project.visualRules ?? []
1696
1815
  const styleWords = styleKeywords(styleId, filterId)
1697
1816
  const system = [
@@ -1703,6 +1822,8 @@ export async function generateRolePromptKit(
1703
1822
  '2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。',
1704
1823
  '3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
1705
1824
  '4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
1825
+ '5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。',
1826
+ ...(shortDrama ? ['6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。'] : []),
1706
1827
  '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1707
1828
  ...rules.map(r => '- ' + r),
1708
1829
  'zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。',
@@ -1740,6 +1861,186 @@ export async function generateRolePromptKit(
1740
1861
  return kit
1741
1862
  }
1742
1863
 
1864
+ /** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
1865
+ export async function generateRolePromptKit(
1866
+ ctx: Context,
1867
+ config: NovelConfig,
1868
+ project: ProjectState,
1869
+ roleName: string,
1870
+ styleId?: string,
1871
+ filterId?: string,
1872
+ ): Promise<RoleRecord['promptKit']> {
1873
+ const role = (project.roles ?? []).find(r => r.name === roleName)
1874
+ if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1875
+ if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
1876
+ role.promptStyleId = styleId
1877
+ return generateRolePromptKitFrom(ctx, config, project, { name: role.name, identity: role.identity, imagePrompt: role.imagePrompt, expressions: role.expressions }, styleId, filterId)
1878
+ }
1879
+
1880
+ /** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
1881
+ export async function generateMangaRolePromptKit(
1882
+ ctx: Context,
1883
+ config: NovelConfig,
1884
+ project: ProjectState,
1885
+ cardId: string,
1886
+ styleId?: string,
1887
+ filterId?: string,
1888
+ ): Promise<RoleRecord['promptKit']> {
1889
+ const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
1890
+ if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
1891
+ if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
1892
+ const kit = await generateRolePromptKitFrom(ctx, config, project, { name: card.name, identity: card.identity, imagePrompt: card.imagePrompt, expressions: card.expressions }, styleId, filterId, project.shortDramaMode === true)
1893
+ card.promptStyleId = styleId
1894
+ card.promptKit = kit
1895
+ if (card.status === 'imported' || card.status === 'pending_confirm') card.status = 'anchored'
1896
+ card.updatedAt = new Date().toISOString()
1897
+ return kit
1898
+ }
1899
+
1900
+ /**
1901
+ * 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
1902
+ * 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
1903
+ * 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
1904
+ */
1905
+ export async function nominateMangaRoles(
1906
+ ctx: Context,
1907
+ config: NovelConfig,
1908
+ project: ProjectState,
1909
+ outputDir: string,
1910
+ chapterNo: number,
1911
+ ): Promise<MangaRoleCandidate[]> {
1912
+ const entry = (project.storyboards ?? []).find(e => e.chapterNo === chapterNo)
1913
+ if (entry === undefined) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`)
1914
+
1915
+ // 收集该章分镜的结构化角色引用(骨架 + 表级 + 镜头级,去重)。
1916
+ const names: string[] = []
1917
+ const pushNames = (list: string[] | undefined): void => {
1918
+ for (const n of list ?? []) {
1919
+ const t = n.trim().slice(0, 20)
1920
+ if (t !== '' && !names.includes(t)) names.push(t)
1921
+ }
1922
+ }
1923
+ pushNames(entry.skeleton?.characters)
1924
+ pushNames(entry.table?.characters)
1925
+ for (const s of entry.table?.shots ?? []) pushNames(s.characters)
1926
+ if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`)
1927
+
1928
+ // 已建漫剧卡的直接标 already_imported(按漫剧名或来源名匹配)。
1929
+ const imported = new Set<string>()
1930
+ for (const c of project.mangaRoles ?? []) {
1931
+ imported.add(c.name)
1932
+ if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') imported.add(c.sourceRoleName)
1933
+ }
1934
+ const already: MangaRoleCandidate[] = names.filter(n => imported.has(n)).map(n => ({
1935
+ rawName: n,
1936
+ verdict: 'already_imported' as const,
1937
+ matches: [],
1938
+ suggested: emptyCandidateSuggestion(n),
1939
+ }))
1940
+ const fresh = names.filter(n => !imported.has(n))
1941
+ if (fresh.length === 0) return already
1942
+
1943
+ const chapter = project.chapters.find(c => c.no === chapterNo)
1944
+ const body = chapter !== undefined ? readChapterFile(outputDir, chapter) : undefined
1945
+ const roles = project.roles ?? []
1946
+
1947
+ // 规则阶段:精确名 + 身份/简称匹配,短名单 ≤5。
1948
+ const shortlists: Array<{ rawName: string; roleNames: string[] }> = fresh.map(rawName => {
1949
+ const shortlist: string[] = []
1950
+ const push = (n: string): void => { if (!shortlist.includes(n)) shortlist.push(n) }
1951
+ for (const r of roles) {
1952
+ if (shortlist.length >= 5) break
1953
+ const n = r.name
1954
+ if (n === rawName) { push(n); continue }
1955
+ if (n.includes(rawName) || rawName.includes(n)) { push(n); continue }
1956
+ const ident = r.identity ?? ''
1957
+ if (ident !== '' && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n)
1958
+ }
1959
+ return { rawName, roleNames: shortlist }
1960
+ })
1961
+
1962
+ // LLM 阶段:确认是/否 + 选哪个 + 漫剧卡建议(只能从短名单里选,不做开放检索)。
1963
+ const beats = (entry.skeleton?.beats ?? []).map(b => `[${b.id}] ${b.event}`).join('\n')
1964
+ const system = [
1965
+ '你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。',
1966
+ '任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。',
1967
+ '规则:',
1968
+ '1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。',
1969
+ '2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。',
1970
+ '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」)。',
1971
+ '4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。',
1972
+ ...(project.shortDramaMode === true
1973
+ ? ['5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。']
1974
+ : []),
1975
+ '输出必须是合法 JSON 数组:[{"rawName": "...", "verdict": "...", "roleName": "...或省略", "suggested": {...}}],每个提名都要有,数组长度必须等于提名数。',
1976
+ '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1977
+ ].join('\n')
1978
+ const user = [
1979
+ `章节:第 ${chapterNo} 章《${chapter?.title ?? ''}》`,
1980
+ '==== 剧情骨架(节拍) ====',
1981
+ beats !== '' ? beats : '(无骨架节拍)',
1982
+ '==== 角色提名与候选名单 ====',
1983
+ shortlists.map(s => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join('、')}` : `[提名] ${s.rawName} → 候选:(无)`).join('\n'),
1984
+ body !== undefined ? '==== 章节正文(前 2500 字,判断称谓归属用) ====\n' + body.replace(/^#.*$/gm, '').trim().slice(0, 2500) : '',
1985
+ '只输出 JSON 数组。',
1986
+ ].filter(x => x !== '').join('\n\n')
1987
+ const text = await complete(ctx, config, { system, user, temperature: 0.2, maxTokens: Math.max(config.maxTokens, 8000), reasoning: config.analysisReasoning ?? 'low' })
1988
+ const raw = parseJsonArray<Record<string, unknown>>(text)
1989
+
1990
+ const verdicts = new Set(['matched', 'ambiguous', 'not_in_library'])
1991
+ const fnMap: Record<string, MangaRoleCard['coreFunction']> = { protagonist: 'protagonist', mentor: 'mentor', love_interest: 'love_interest', antagonist: 'antagonist', sidekick: 'sidekick', informant: 'informant', functional: 'functional' }
1992
+ const relMap: Record<string, MangaRoleCard['protagonistRelation']> = { enemy: 'enemy', friend: 'friend', mentor: 'mentor', lover: 'lover', exploit: 'exploit', neutral: 'neutral' }
1993
+ const byName = new Map<string, Record<string, unknown>>()
1994
+ for (const e of raw) {
1995
+ if (typeof e === 'object' && e !== null && typeof e.rawName === 'string') byName.set(e.rawName.trim(), e)
1996
+ }
1997
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim().slice(0, 100) : ''
1998
+ const strArr = (v: unknown, n: number): string[] => Array.isArray(v)
1999
+ ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '').map(x => x.trim().slice(0, 20)).slice(0, n)
2000
+ : []
2001
+
2002
+ const out: MangaRoleCandidate[] = []
2003
+ for (const item of shortlists) {
2004
+ const judge = byName.get(item.rawName)
2005
+ const judgeVerdict = judge !== undefined ? str(judge.verdict) : ''
2006
+ const verdict = verdicts.has(judgeVerdict) ? judgeVerdict as MangaRoleCandidate['verdict'] : (item.roleNames.length > 0 ? 'ambiguous' : 'not_in_library')
2007
+ let roleName: string | undefined
2008
+ if (judge !== undefined) {
2009
+ const chosen = str(judge.roleName)
2010
+ if (chosen !== '' && item.roleNames.includes(chosen)) roleName = chosen
2011
+ }
2012
+ if (verdict === 'matched' && roleName === undefined && item.roleNames.length === 1) roleName = item.roleNames[0]
2013
+ const sug = (typeof judge?.suggested === 'object' && judge?.suggested !== null) ? judge.suggested as Record<string, unknown> : {}
2014
+ const novelHint: MangaRoleCandidate['novelHint'] = verdict === 'not_in_library' && body !== undefined
2015
+ ? (body.includes(item.rawName) ? 'backfill' : 'manga_new')
2016
+ : undefined
2017
+ const suggested = {
2018
+ name: (str(sug.name) !== '' ? str(sug.name) : (roleName ?? item.rawName)).slice(0, 30),
2019
+ identity: str(sug.identity).slice(0, 60),
2020
+ coreFunction: fnMap[str(sug.coreFunction)] ?? 'functional',
2021
+ protagonistRelation: relMap[str(sug.protagonistRelation)] ?? 'neutral',
2022
+ speechStyle: str(sug.speechStyle).slice(0, 60),
2023
+ traits: strArr(sug.traits, 3),
2024
+ appearance: str(sug.appearance).slice(0, 100),
2025
+ keyScenes: strArr(sug.keyScenes, 3),
2026
+ }
2027
+ out.push({
2028
+ rawName: item.rawName,
2029
+ verdict,
2030
+ matches: item.roleNames.map(n => ({ roleName: n, reason: roleName === n ? 'LLM 确认' : '规则候选' })),
2031
+ matchedRoleName: roleName,
2032
+ novelHint,
2033
+ suggested,
2034
+ })
2035
+ }
2036
+ return [...already, ...out]
2037
+ }
2038
+
2039
+ /** 空建议(already_imported 等无需 LLM 的候选用)。 */
2040
+ function emptyCandidateSuggestion(name: string): MangaRoleCandidate['suggested'] {
2041
+ return { name, identity: '', coreFunction: 'functional', protagonistRelation: 'neutral', speechStyle: '', traits: [], appearance: '', keyScenes: [] }
2042
+ }
2043
+
1743
2044
  /** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
1744
2045
  export async function extractVisualRules(
1745
2046
  ctx: Context,
@@ -2035,18 +2336,13 @@ export async function testImageEndpoint(baseURL: string, apiKey: string, model?:
2035
2336
  }
2036
2337
  }
2037
2338
 
2038
- export async function generateRoleReferenceImage(
2039
- ctx: Context,
2339
+ /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
2340
+ async function generateReferenceImageFrom(
2040
2341
  config: NovelConfig,
2041
- project: ProjectState,
2042
- outputDir: string,
2043
- name: string,
2342
+ anchor: NonNullable<RoleRecord['imagePrompt']>,
2044
2343
  style = '',
2045
2344
  modelId?: string,
2046
2345
  ): 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
2346
  if (config.imageApiEnabled !== true) throw new Error('生图未启用,请在设置中启用一个生图模型')
2051
2347
  const activeImage = (modelId !== undefined && modelId !== '' ? (config.imageModels ?? []).find(m => m.id === modelId) : undefined)
2052
2348
  ?? (config.imageModels ?? []).find(m => m.enabled)
@@ -2060,9 +2356,9 @@ export async function generateRoleReferenceImage(
2060
2356
  }
2061
2357
  const stylePrompt = getComicStylePrompt(style)
2062
2358
  const prompt = [
2063
- role.imagePrompt.zh,
2064
- role.imagePrompt.en,
2065
- role.imagePrompt.tags.join(', '),
2359
+ anchor.zh,
2360
+ anchor.en,
2361
+ anchor.tags.join(', '),
2066
2362
  stylePrompt,
2067
2363
  'character reference sheet, front view, clean background, consistent design',
2068
2364
  ].filter(Boolean).join(', ')
@@ -2094,11 +2390,46 @@ export async function generateRoleReferenceImage(
2094
2390
  if (!imgRes.ok) throw new Error('下载生图结果失败')
2095
2391
  const buf = Buffer.from(await imgRes.arrayBuffer())
2096
2392
  const contentType = imgRes.headers.get('content-type') || 'image/jpeg'
2097
- const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`
2393
+ return `data:${contentType};base64,${buf.toString('base64')}`
2394
+ }
2395
+
2396
+ /** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
2397
+ export async function generateRoleReferenceImage(
2398
+ ctx: Context,
2399
+ config: NovelConfig,
2400
+ project: ProjectState,
2401
+ outputDir: string,
2402
+ name: string,
2403
+ style = '',
2404
+ modelId?: string,
2405
+ ): Promise<string> {
2406
+ const role = project.roles?.find(r => r.name === name)
2407
+ if (role === undefined) throw new Error(`角色 ${name} 不存在`)
2408
+ if (role.imagePrompt === undefined) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`)
2409
+ const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId)
2098
2410
  role.imageUrl = dataUrl
2099
2411
  return dataUrl
2100
2412
  }
2101
2413
 
2414
+ /** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
2415
+ export async function generateMangaRoleReferenceImage(
2416
+ ctx: Context,
2417
+ config: NovelConfig,
2418
+ project: ProjectState,
2419
+ outputDir: string,
2420
+ cardId: string,
2421
+ style = '',
2422
+ modelId?: string,
2423
+ ): Promise<string> {
2424
+ const card = (project.mangaRoles ?? []).find(c => c.id === cardId)
2425
+ if (card === undefined) throw new Error(`漫剧角色卡 ${cardId} 不存在`)
2426
+ if (card.imagePrompt === undefined) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`)
2427
+ const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId)
2428
+ card.imageUrl = dataUrl
2429
+ card.updatedAt = new Date().toISOString()
2430
+ return dataUrl
2431
+ }
2432
+
2102
2433
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
2103
2434
  export async function analyzePlotlineHealth(
2104
2435
  ctx: Context,
@@ -2615,6 +2946,57 @@ function saveChapterStoryboard(project: ProjectState, outputDir: string, entry:
2615
2946
  saveProject(outputDir, project)
2616
2947
  }
2617
2948
 
2949
+ /** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
2950
+ function guessCharactersFromRoles(project: ProjectState, body: string, limit = 12): string[] {
2951
+ const out: string[] = []
2952
+ for (const r of project.roles ?? []) {
2953
+ if (out.length >= limit) break
2954
+ if (r.name !== '' && !out.includes(r.name) && body.includes(r.name)) out.push(r.name)
2955
+ }
2956
+ return out
2957
+ }
2958
+
2959
+ /** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
2960
+ function sanitizeCharacters(raw: unknown, limit: number): string[] {
2961
+ if (!Array.isArray(raw)) return []
2962
+ const out: string[] = []
2963
+ for (const v of raw) {
2964
+ if (typeof v !== 'string') continue
2965
+ const name = v.trim().slice(0, 20)
2966
+ if (name === '' || out.includes(name)) continue
2967
+ out.push(name)
2968
+ if (out.length >= limit) break
2969
+ }
2970
+ return out
2971
+ }
2972
+
2973
+ /** 漫剧卡绑定索引:按分镜称谓(正文确切名)解析漫剧卡(卡名/来源名精确优先,包含兜底)。 */
2974
+ interface MangaRoleBindingIndex {
2975
+ byId: Map<string, MangaRoleCard>
2976
+ resolve: (name: string) => MangaRoleCard | undefined
2977
+ }
2978
+ function buildMangaRoleBindings(project: ProjectState): MangaRoleBindingIndex {
2979
+ const cards = project.mangaRoles ?? []
2980
+ const byName = new Map<string, MangaRoleCard>()
2981
+ for (const c of cards) {
2982
+ if (c.name !== '') byName.set(c.name, c)
2983
+ if (c.sourceRoleName !== undefined && c.sourceRoleName !== '') byName.set(c.sourceRoleName, c)
2984
+ }
2985
+ const byId = new Map<string, MangaRoleCard>(cards.map(c => [c.id, c]))
2986
+ return {
2987
+ byId,
2988
+ resolve: (name: string): MangaRoleCard | undefined => {
2989
+ const exact = byName.get(name)
2990
+ if (exact !== undefined) return exact
2991
+ for (const c of cards) {
2992
+ if (c.name !== '' && (c.name.includes(name) || name.includes(c.name))) return c
2993
+ if (c.sourceRoleName !== undefined && c.sourceRoleName !== '' && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c
2994
+ }
2995
+ return undefined
2996
+ },
2997
+ }
2998
+ }
2999
+
2618
3000
  /**
2619
3001
  * 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
2620
3002
  * 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
@@ -2644,6 +3026,16 @@ export async function generateStoryboardTable(
2644
3026
  // 场景卡:仅列名称与定位(正文命中优先,至多 6 个)。
2645
3027
  const usedScenes = (project.scenes ?? []).filter(s => body.includes(s.name)).slice(0, 6)
2646
3028
  const scenes = usedScenes.map(s => `${s.name}:${s.summary ?? ''}`)
3029
+ // 漫剧定妆卡:本章正文命中漫剧卡(卡名或来源名),注入定妆锚点供画面遵守。
3030
+ const bindings = buildMangaRoleBindings(project)
3031
+ const mangaCardsInBody = bindings.byId.size > 0
3032
+ ? [...bindings.byId.values()].filter(c => body.includes(c.name) || (c.sourceRoleName !== undefined && body.includes(c.sourceRoleName))).slice(0, 8)
3033
+ : []
3034
+ const mangaLines = mangaCardsInBody.map(c => {
3035
+ const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 90) : (c.appearance !== '' ? c.appearance : '')
3036
+ const ref = c.imageUrl !== undefined ? ';参考图:有' : (c.gallery ?? []).some(g => g.label.includes('立绘')) ? ';参考图:有(立绘)' : ''
3037
+ return `${c.name}(${c.identity ?? ''}):定妆${anchor !== '' ? '「' + anchor + '」' : '(锚点未生成)'}${ref}`
3038
+ })
2647
3039
  const rules = (project.visualRules ?? []).map(r => '- ' + r)
2648
3040
 
2649
3041
  const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
@@ -2656,8 +3048,11 @@ export async function generateStoryboardTable(
2656
3048
  const system = [
2657
3049
  '你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
2658
3050
  '任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
2659
- '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态"}]}',
2660
- '景别取值:远景/全景/中景/近景/特写。',
3051
+ '输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "composition": "构图", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
3052
+ '景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。',
3053
+ '运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。',
3054
+ '光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。',
3055
+ '构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。',
2661
3056
  '硬性要求:',
2662
3057
  '【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
2663
3058
  ...styleLines,
@@ -2667,6 +3062,8 @@ export async function generateStoryboardTable(
2667
3062
  '4. 台词/音效/光效无则空字符串,不要编造。',
2668
3063
  '5. duration 1-12 秒。',
2669
3064
  '6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。',
3065
+ '7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。',
3066
+ '8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。',
2670
3067
  ].join('\n')
2671
3068
  const user = [
2672
3069
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -2674,30 +3071,32 @@ export async function generateStoryboardTable(
2674
3071
  `弧线:${skeleton.arc}`,
2675
3072
  skeleton.beats.map(b => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== undefined ? ';承接:' + b.cause : ''})`).join('\n'),
2676
3073
  roles.length > 0 ? '==== 出场角色锚点 ====\n' + roles.join('\n') : '',
3074
+ mangaLines.length > 0 ? '==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n' + mangaLines.join('\n') : '',
2677
3075
  scenes.length > 0 ? '==== 相关场景 ====\n' + scenes.join('\n') : '',
2678
3076
  rules.length > 0 ? '==== 本书视觉规则(必须内嵌进 visual) ====\n' + rules.join('\n') : '',
2679
3077
  '==== 章节正文(画面细节以此为准) ====',
2680
3078
  body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
2681
3079
  ].filter(s => s !== '').join('\n\n')
2682
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
3080
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
2683
3081
  const raw = parseJsonObject<{ shots?: unknown }>(text)
2684
3082
  const beatIds = new Set(skeleton.beats.map(b => b.id))
2685
- const shotKinds = new Set(['远景', '全景', '中景', '近景', '特写'])
2686
3083
  const shots: StoryboardShot[] = Array.isArray(raw.shots)
2687
3084
  ? raw.shots
2688
3085
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
2689
3086
  .map((entry, i) => ({
2690
3087
  id: 's' + (i + 1),
2691
3088
  beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
2692
- shot: shotKinds.has(entry.shot as string) ? entry.shot as string : '中景',
2693
- camera: typeof entry.camera === 'string' ? entry.camera.trim().slice(0, 80) : '',
3089
+ shot: normalizeShotSize(entry.shot as string | undefined),
3090
+ camera: normalizeCameras(entry.camera as string | undefined),
3091
+ composition: normalizeComposition(entry.composition as string | undefined),
2694
3092
  duration: typeof entry.duration === 'number' && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
2695
3093
  visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
2696
3094
  line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
2697
3095
  sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
2698
- light: typeof entry.light === 'string' ? entry.light.trim().slice(0, 80) : '',
3096
+ light: normalizeLightings(entry.light as string | undefined),
2699
3097
  prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
2700
3098
  nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
3099
+ characters: sanitizeCharacters(entry.characters, 4),
2701
3100
  }))
2702
3101
  .filter(s => s.visual !== '')
2703
3102
  : []
@@ -2708,7 +3107,33 @@ export async function generateStoryboardTable(
2708
3107
  if (missing.length > 0) {
2709
3108
  console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(',')} 无镜头覆盖`)
2710
3109
  }
2711
- const table: StoryboardTable = { chapterNo, shots, usedScenes: usedScenes.map(s => s.name) }
3110
+ // 表级 characters:各镜头去重汇总;LLM 漏标时用骨架/角色库兜底。
3111
+ const tableChars: string[] = []
3112
+ const tableRoleIds: string[] = []
3113
+ for (const s of shots) {
3114
+ // 定妆绑定:镜头 characters 称谓 → 漫剧卡 id(供出图/生视频时按卡取定妆图)。
3115
+ const ids: string[] = []
3116
+ for (const n of s.characters ?? []) {
3117
+ const card = bindings.resolve(n)
3118
+ if (card !== undefined && !ids.includes(card.id)) ids.push(card.id)
3119
+ }
3120
+ s.mangaRoleIds = ids.length > 0 ? ids : undefined
3121
+ for (const c of s.characters ?? []) {
3122
+ if (!tableChars.includes(c)) tableChars.push(c)
3123
+ if (tableChars.length >= 12) break
3124
+ }
3125
+ for (const id of ids) {
3126
+ if (!tableRoleIds.includes(id)) tableRoleIds.push(id)
3127
+ }
3128
+ }
3129
+ const fallbackChars = (skeleton.characters ?? guessCharactersFromRoles(project, body, 12)).slice(0, 12)
3130
+ const table: StoryboardTable = {
3131
+ chapterNo,
3132
+ shots,
3133
+ usedScenes: usedScenes.map(s => s.name),
3134
+ characters: tableChars.length > 0 ? tableChars : fallbackChars,
3135
+ mangaRoleIds: tableRoleIds.length > 0 ? tableRoleIds : undefined,
3136
+ }
2712
3137
  saveChapterStoryboard(project, outputDir, { chapterNo, table, updatedAt: new Date().toISOString() })
2713
3138
  return table
2714
3139
  }
@@ -2737,13 +3162,22 @@ export async function generateStoryboardPrompts(
2737
3162
  filterStyle?.keywords,
2738
3163
  ].filter((v): v is string => v !== undefined && v !== '').join(',') || '3D动漫,超精细建模,电影光影'
2739
3164
  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
3165
+ const bindings = buildMangaRoleBindings(project)
3166
+ const shotLines = table.shots.map(s => {
3167
+ const bound = (s.characters ?? []).map(n => bindings.resolve(n)).filter((c): c is MangaRoleCard => c !== undefined)
3168
+ const makeUp = bound.map(c => {
3169
+ const anchor = c.imagePrompt !== undefined ? c.imagePrompt.zh.slice(0, 60) : (c.appearance !== '' ? c.appearance : '')
3170
+ const ref = c.imageUrl !== undefined ? '(参考图)' : ''
3171
+ return c.name + ':' + anchor + ref
3172
+ }).join(';')
3173
+ return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
3174
+ 出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
3175
+ 定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
2742
3176
  画面:${s.visual}
2743
3177
  台词:${s.line !== '' ? s.line : '(无)'}
2744
- 光效:${s.light !== '' ? s.light : '(无)'}
3178
+ 光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}
2745
3179
  承接:${s.prevState} → ${s.nextState}`
2746
- ).join('\n\n')
3180
+ }).join('\n\n')
2747
3181
  const system = [
2748
3182
  '你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。',
2749
3183
  '任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。',
@@ -2752,7 +3186,7 @@ export async function generateStoryboardPrompts(
2752
3186
  '重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。',
2753
3187
  '硬性要求:',
2754
3188
  '1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。',
2755
- '2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。',
3189
+ '2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。',
2756
3190
  '3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。',
2757
3191
  '4. 输出合法 JSON 对象:{"prompts": [{"shotId": "s1", "text": "..."}]},所有镜头都要有,顺序一致。',
2758
3192
  '5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。',
@@ -2764,16 +3198,22 @@ export async function generateStoryboardPrompts(
2764
3198
  shotLines,
2765
3199
  '只输出 JSON 对象。',
2766
3200
  ].filter(x => x !== '').join('\n\n')
2767
- const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
3201
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 16000), reasoning: config.analysisReasoning ?? 'low' })
2768
3202
  const raw = parseJsonObject<{ prompts?: unknown }>(text)
2769
3203
  const shotIds = new Set(table.shots.map(s => s.id))
3204
+ const shotBinding = new Map<string, string[]>(table.shots.map(s => [s.id, s.mangaRoleIds ?? []]))
2770
3205
  const prompts: StoryboardPrompt[] = Array.isArray(raw.prompts)
2771
3206
  ? raw.prompts
2772
3207
  .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
- }))
3208
+ .map(entry => {
3209
+ const shotId = shotIds.has(entry.shotId as string) ? entry.shotId as string : ''
3210
+ const ids = shotBinding.get(shotId) ?? []
3211
+ return {
3212
+ shotId,
3213
+ text: ensureStyleEmbedded(typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '', stylePrefix, 'zh'),
3214
+ mangaRoleIds: ids.length > 0 ? ids : undefined,
3215
+ }
3216
+ })
2777
3217
  .filter(x => x.shotId !== '' && x.text !== '')
2778
3218
  : []
2779
3219
  if (prompts.length === 0) throw new Error('模型未输出有效提示词,请重试')
@@ -2800,13 +3240,16 @@ export async function generateStoryboardSkeleton(
2800
3240
  const system = [
2801
3241
  '你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
2802
3242
  '任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
2803
- '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(如 压抑→隐忍→惊惧)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}]}',
3243
+ '输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(情绪词数组)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
3244
+ '情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。',
3245
+ '功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。',
2804
3246
  '硬性要求:',
2805
3247
  '1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
2806
3248
  '2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
2807
3249
  '3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。',
2808
3250
  '4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
2809
3251
  '5. 直接输出 JSON 本身,不要输出思考过程或其他文字。',
3252
+ '6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。',
2810
3253
  ].join('\n')
2811
3254
  const user = [
2812
3255
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -2815,23 +3258,32 @@ export async function generateStoryboardSkeleton(
2815
3258
  body.replace(/^#\s+.*$/m, '').trim(),
2816
3259
  ].join('\n')
2817
3260
  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)
3261
+ const raw = parseJsonObject<{ arc?: unknown; beats?: unknown; characters?: unknown }>(text)
2819
3262
  const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
2820
- const functions = new Set(['铺垫', '冲突', '转折', '高潮', '收束', '伏笔', '人物塑造'])
2821
3263
  const beats: StoryboardBeat[] = Array.isArray(raw.beats)
2822
3264
  ? raw.beats
2823
3265
  .filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
2824
3266
  .map((entry, i) => ({
2825
3267
  id: 'b' + (i + 1),
2826
3268
  event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
2827
- emotion: typeof entry.emotion === 'string' ? entry.emotion.trim().slice(0, 100) : '',
2828
- function: functions.has(entry.function as string) ? entry.function as string : '铺垫',
3269
+ emotion: Array.isArray(entry.emotion)
3270
+ ? normalizeEmotions(entry.emotion.filter((x): x is string => typeof x === 'string').join('→'))
3271
+ : normalizeEmotions(typeof entry.emotion === 'string' ? entry.emotion : undefined),
3272
+ function: normalizeStoryFunction(typeof entry.function === 'string' ? entry.function : undefined),
2829
3273
  cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
2830
3274
  }))
2831
3275
  .filter(b => b.event !== '')
2832
3276
  : []
2833
3277
  if (beats.length === 0) throw new Error('模型未输出有效节拍链,请重试')
2834
- const skeleton: StoryboardSkeleton = { chapterNo, arc: arc !== '' ? arc : '(本章弧线未生成)', beats }
3278
+ // 出场角色:LLM 结构化输出优先,漏填时用角色库命中正文兜底。
3279
+ const skeletonChars = sanitizeCharacters(raw.characters, 12)
3280
+ const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12)
3281
+ const skeleton: StoryboardSkeleton = {
3282
+ chapterNo,
3283
+ arc: arc !== '' ? arc : '(本章弧线未生成)',
3284
+ beats,
3285
+ characters: chars.length > 0 ? chars : undefined,
3286
+ }
2835
3287
  saveChapterStoryboard(project, outputDir, { chapterNo, skeleton, updatedAt: new Date().toISOString() })
2836
3288
  return skeleton
2837
3289
  }