@waterwx/dsh-novel-forge 1.2.0 → 1.3.0

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.
package/src/engine.ts CHANGED
@@ -28,6 +28,7 @@ import { join } from 'node:path'
28
28
  import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOptions, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm'
29
29
  import type { Context } from '@deepseek-ai/cordis'
30
30
  import { emptyProjectAssets, renderAllAssets, styleEngineSystemPrompt } from './assets.ts'
31
+ import { getComicStylePrompt } from './comic-presets.ts'
31
32
  import type {
32
33
  AuditIssue,
33
34
  AuthorReview,
@@ -43,9 +44,8 @@ import type {
43
44
  ReviewReport,
44
45
  RoleRecord,
45
46
  RoleStatusCard,
47
+ SceneCard,
46
48
  StoryBible,
47
- StoryboardPlanResponse,
48
- StoryboardResponse,
49
49
  Volume,
50
50
  WorldState,
51
51
  } from './protocol.ts'
@@ -423,6 +423,7 @@ function bibleSystemPrompt(): string {
423
423
  '3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。',
424
424
  '4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。',
425
425
  '5. 风格列出叙事基调、节奏、POV 等写作风格要点。',
426
+ '6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。',
426
427
  '输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。',
427
428
  '重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。',
428
429
  '重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。',
@@ -432,8 +433,26 @@ function bibleSystemPrompt(): string {
432
433
  }
433
434
 
434
435
  /** Extract the story bible from an outline. */
435
- export async function extractBible(ctx: Context, config: NovelConfig, outline: string): Promise<StoryBible> {
436
- const user = `请为下面这部小说提炼设定圣经:\n\n${outline}`
436
+ export async function extractBible(ctx: Context, config: NovelConfig, outline: string, project?: ProjectState): Promise<StoryBible> {
437
+ // 已写章节/编年录只在「确认真实姓名与已确立设定」时参考,仍忠于大纲、不新增大纲外设定。
438
+ const written = (project?.chapters ?? []).filter(c => c.status !== 'pending' && c.status !== 'generating' && c.file !== undefined)
439
+ const excerpts: string[] = []
440
+ for (const chapter of written.slice(0, 3)) {
441
+ const body = readChapterFile(config.outputDir, chapter)
442
+ if (body === undefined) continue
443
+ const text = body.replace(/^#.*$/gm, '').trim()
444
+ if (text.length > 0) excerpts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2200)}`)
445
+ }
446
+ const facts = (project?.facts ?? []).slice(-40)
447
+ const user = [
448
+ `请为下面这部小说提炼设定圣经:\n\n${outline}`,
449
+ facts.length > 0
450
+ ? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join('\n')}`
451
+ : '',
452
+ excerpts.length > 0
453
+ ? `\n\n【已写章节正文摘录】角色姓名、身份以正文为准(大纲未点名时用正文里的真实姓名,禁止用「主角(描述)」占位名):\n${excerpts.join('\n\n')}`
454
+ : '',
455
+ ].filter(s => s !== '').join('\n')
437
456
  const text = await complete(ctx, config, {
438
457
  system: bibleSystemPrompt(),
439
458
  user,
@@ -464,6 +483,13 @@ export async function extractBible(ctx: Context, config: NovelConfig, outline: s
464
483
  }))
465
484
  .filter(card => card.name !== '')
466
485
  : []
486
+ // 占位名修复:大纲未点名主角时模型可能输出「主角(…)」,用角色库真实主角名替换。
487
+ const realProtagonist = (project?.roles ?? []).find(r => r.roleLabel === 'protagonist')?.name?.trim()
488
+ if (realProtagonist !== undefined && realProtagonist !== '') {
489
+ for (const card of characters) {
490
+ if (card.role === 'protagonist' && /^(主角|未命名)/.test(card.name)) card.name = realProtagonist
491
+ }
492
+ }
467
493
  const bible: StoryBible = {
468
494
  genre: typeof raw.genre === 'string' ? raw.genre : '',
469
495
  worldRules: strArray(raw.worldRules),
@@ -642,6 +668,7 @@ export async function planChapters(
642
668
  const volume = project.volumes?.find(v => v.no === volumeNo)
643
669
  const existing = project.chapters
644
670
  const startNo = existing.length === 0 ? 1 : Math.max(...existing.map(c => c.no)) + 1
671
+ const NL = String.fromCharCode(10)
645
672
  const continuation = existing.length > 0
646
673
  const latestFacts = continuation && Array.isArray(project.facts)
647
674
  ? project.facts.slice(-15).map(f => `[第${f.chapterNo}章] ${f.text.slice(0, 150)}`).join('\n')
@@ -658,13 +685,14 @@ export async function planChapters(
658
685
  } catch { /* 文件缺失时忽略,仅依赖编年录 */ }
659
686
  }
660
687
  }
661
- // 续写模式下大纲截断到「关键剧情桥段」之前:只保留设定(人设/金手指/规则/战力),
662
- // 去掉分卷主线步骤模板,避免模型照抄已完成的剧情再走一遍。
688
+ // 续写模式大纲注入:精简大纲(≤2 万字)直接全量,避免误伤「分卷收官规划」(如第五卷双结局);
689
+ // 超大 docx 大纲才节选——截到「记忆梗」之前,保留分卷大纲与红线,去掉后续冗余。
663
690
  const outlineBlock = continuation
664
691
  ? (() => {
665
- const cut = project.outline.indexOf('七、关键剧情桥段')
666
- if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + '\n(大纲后续剧情桥段与分卷细节从略;续写请以「上一章结尾原文」与「最新剧情状态」为剧情起点)'
667
- return project.outline.slice(0, 3000) + '\n…(大纲过长已节选)'
692
+ if (project.outline.length <= 20000) return project.outline
693
+ const cut = project.outline.indexOf('十、全书核心弹幕记忆梗')
694
+ if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + '\n(大纲过长,末尾记忆梗从略)'
695
+ return project.outline.slice(0, 8000) + '\n…(大纲过长已节选)'
668
696
  })()
669
697
  : project.outline
670
698
  const user = [
@@ -675,7 +703,7 @@ export async function planChapters(
675
703
  ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。`
676
704
  : '请规划全书开篇章节。',
677
705
  continuation
678
- ? '【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。'
706
+ ? '【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。' + NL + '若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否“感觉像结尾”为准。'
679
707
  : '',
680
708
  prevTail !== ''
681
709
  ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}`
@@ -684,8 +712,8 @@ export async function planChapters(
684
712
  ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}`
685
713
  : '',
686
714
  continuation
687
- ? '已有章节:\n'
688
- + existing.map(c => {
715
+ ? '已有章节(共 ' + existing.length + ' 章;仅列最近 80 章,更早的以标题计数为准,剧情以「编年录」为权威):\n'
716
+ + existing.slice(-80).map(c => {
689
717
  const sm = c.summary !== undefined && c.summary !== '' ? `(${c.summary.slice(0, 120)})` : ''
690
718
  return `第${c.no}章《${c.title}》${sm}`
691
719
  }).join('\n')
@@ -806,9 +834,11 @@ export async function reviewChapter(
806
834
  .filter(issue => issue.item !== '')
807
835
  : []
808
836
  const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
837
+ // 无 high 问题且评分 ≥60 → 直接通过(medium/low 主观项记录但不阻塞,避免“72 分卡线”空转修订)
838
+ const passed = score >= config.reviewPassScore || (issues.every(i => i.severity !== 'high') && score >= 60)
809
839
  const report: ReviewReport = {
810
840
  score,
811
- passed: score >= config.reviewPassScore,
841
+ passed,
812
842
  verdict: typeof raw.verdict === 'string' ? raw.verdict.slice(0, 200) : '',
813
843
  issues,
814
844
  reviewedAt: new Date().toISOString(),
@@ -860,8 +890,8 @@ export async function reviewChapterText(
860
890
  .filter(issue => issue.item !== '')
861
891
  : []
862
892
  const score = typeof raw.score === 'number' ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60
863
- // 验证模式判定:原 high 全部解决 + 无新增 high → 通过(主观项不再卡修订)。
864
- let passed = score >= config.reviewPassScore
893
+ // 非验证模式:无 high 且评分 ≥60 → 通过;验证模式判定:原 high 全部解决 + 无新增 high → 通过(主观项不再卡修订)。
894
+ let passed = score >= config.reviewPassScore || (issues.every(i => i.severity !== 'high') && score >= 60)
865
895
  if (previousReport !== undefined) {
866
896
  const hasHigh = issues.some(i => i.severity === 'high')
867
897
  const prevHigh = previousReport.issues.filter(i => i.severity === 'high')
@@ -1074,7 +1104,7 @@ export async function extractRoles(
1074
1104
  '覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如"矮胖姑娘")用其身份简称收录并标注。',
1075
1105
  '数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。',
1076
1106
  '每个角色输出:',
1077
- '1. name:角色名(或身份简称)。',
1107
+ '1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。',
1078
1108
  '2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。',
1079
1109
  '3. identity:身份一句话(宗门/势力/血脉/职业)。',
1080
1110
  '4. traits:3-6 个性格标签。',
@@ -1090,12 +1120,23 @@ export async function extractRoles(
1090
1120
  ].join('\n')
1091
1121
  const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
1092
1122
  const existingRoles = project.roles ?? []
1123
+ // 正文摘录:让提炼真正读到书的内容(角色姓名/身份/关系以正文为准)。
1124
+ const excerptParts: string[] = []
1125
+ for (const chapter of written.slice(0, 3)) {
1126
+ const body = readChapterFile(config.outputDir, chapter)
1127
+ if (body === undefined) continue
1128
+ const text = body.replace(/^#.*$/gm, '').trim()
1129
+ if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2400)}`)
1130
+ }
1093
1131
  const user = [
1094
1132
  `书名:《${project.bookName}》`,
1095
1133
  existingRoles.length > 0
1096
1134
  ? `已收录角色(跳过,不要输出):${existingRoles.map(r => r.name).join('、')}`
1097
1135
  : '',
1098
1136
  `大纲(节选前 3000 字):\n${project.outline.slice(0, 3000)}`,
1137
+ excerptParts.length > 0
1138
+ ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join('\n\n')}`
1139
+ : '',
1099
1140
  project.bible !== undefined && project.bible.characters.length > 0
1100
1141
  ? `已有角色卡(补充信息):\n${project.bible.characters.map(c => `- ${c.name}(${c.role}):${c.traits.join('、')}${c.goals !== '' ? `;目标:${c.goals}` : ''}`).join('\n')}`
1101
1142
  : '',
@@ -1130,12 +1171,91 @@ export async function extractRoles(
1130
1171
  return roles
1131
1172
  }
1132
1173
 
1174
+ /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
1175
+ export async function extractScenes(
1176
+ ctx: Context,
1177
+ config: NovelConfig,
1178
+ project: ProjectState,
1179
+ ): Promise<SceneCard[]> {
1180
+ const system = [
1181
+ '你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道"在哪、什么时间光态、拍什么情节、人物什么状态"。',
1182
+ '按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。',
1183
+ '每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。',
1184
+ '数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。',
1185
+ '每个场景输出:',
1186
+ '1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。',
1187
+ '2. act:幕归属(第一幕后场/第二幕卖场/第三幕深仓/第四幕外界/第五幕抉择)。',
1188
+ '3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。',
1189
+ '4. summary:一句话定位(空间类型/功能/氛围)。',
1190
+ '5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤")。',
1191
+ '6. characterState:主角/关键角色在该场景的状态一句话(含标志物细节如标签磨损、手腕红痕)。',
1192
+ '7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。',
1193
+ '8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。',
1194
+ '9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。',
1195
+ '10. zh:中文生图提示词(连贯一段,写实电影感,含环境/光线/氛围/人物状态)。',
1196
+ '11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。',
1197
+ '12. tags:3-6 个关键标签。',
1198
+ '13. source:依据来源说明(哪几章哪些描写)。',
1199
+ '若故事存在结局抉择(如双分支),最后 1-2 个场景应为「抉择场景」,zh 里写明分支 A/B 两种画面。',
1200
+ '输出必须是合法 JSON 数组,不要输出其他文字:[{"name":"...", "act":"...", "moment":"...", "summary":"...", "beats":[...], "characterState":"...", "elements":[...], "palette":[...], "moods":[...], "zh":"...", "en":"...", "tags":[...], "source":"..."}]',
1201
+ '重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
1202
+ ].join('\n')
1203
+ const written = project.chapters.filter(c => c.status !== 'pending' && c.status !== 'generating')
1204
+ const excerptParts: string[] = []
1205
+ for (const chapter of written.slice(0, 4)) {
1206
+ const body = readChapterFile(config.outputDir, chapter)
1207
+ if (body === undefined) continue
1208
+ const text = body.replace(/^#.*$/gm, '').trim()
1209
+ if (text.length > 0) excerptParts.push('第' + chapter.no + '章《' + chapter.title + '》' + '\n' + text.slice(0, 1800))
1210
+ }
1211
+ const user = [
1212
+ '书名:《' + project.bookName + '》',
1213
+ (project.facts ?? []).length > 0
1214
+ ? '编年录(最近 80 条,场景地点以这里为准):' + '\n' + (project.facts ?? []).slice(-80).map(f => '[第' + f.chapterNo + '章] ' + f.text.slice(0, 80)).join('\n')
1215
+ : '',
1216
+ excerptParts.length > 0
1217
+ ? '已写正文摘录(场景描写以正文为准):' + '\n' + excerptParts.join('\n')
1218
+ : '',
1219
+ '只输出 JSON 数组。',
1220
+ ].filter(s => s !== '').join('\n')
1221
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1222
+ const raw = parseJsonArray<Record<string, unknown>>(text)
1223
+ const strArr = (v: unknown): string[] => Array.isArray(v) ? v.filter((x): x is string => typeof x === 'string' && x.trim() !== '') : []
1224
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1225
+ const scenes: SceneCard[] = []
1226
+ for (const entry of raw) {
1227
+ if (typeof entry !== 'object' || entry === null) continue
1228
+ const name = str(entry.name).slice(0, 40)
1229
+ if (name === '') continue
1230
+ scenes.push({
1231
+ name,
1232
+ act: str(entry.act).slice(0, 40),
1233
+ moment: str(entry.moment).slice(0, 60),
1234
+ summary: str(entry.summary).slice(0, 120),
1235
+ beats: strArr(entry.beats).map(t => t.slice(0, 120)).slice(0, 3),
1236
+ characterState: str(entry.characterState).slice(0, 160),
1237
+ elements: strArr(entry.elements).map(t => t.slice(0, 60)).slice(0, 6),
1238
+ palette: strArr(entry.palette).map(t => t.slice(0, 40)).slice(0, 4),
1239
+ moods: strArr(entry.moods).map(t => t.slice(0, 20)).slice(0, 4),
1240
+ zh: str(entry.zh).slice(0, 800),
1241
+ en: str(entry.en).slice(0, 600),
1242
+ tags: strArr(entry.tags).map(t => t.slice(0, 24)).slice(0, 6),
1243
+ source: str(entry.source).slice(0, 120),
1244
+ })
1245
+ }
1246
+ return scenes
1247
+ }
1248
+
1133
1249
  /** 动漫形象描述词(中文描述 + 英文 booru 标签 + 关键外貌标签)。 */
1134
1250
  export interface RoleVisualPrompt {
1135
1251
  zh: string
1136
1252
  en: string
1137
1253
  tags: string[]
1138
1254
  source: string
1255
+ /** 该角色所需情绪表情清单(6-12 个,如 疲惫/麻木/压抑悲伤)。 */
1256
+ expressions?: string[]
1257
+ /** 四类精修提示词(立绘/四视图/表情/细节,一次提炼直接产出)。 */
1258
+ promptKit?: RoleRecord['promptKit']
1139
1259
  }
1140
1260
 
1141
1261
  /**
@@ -1153,7 +1273,24 @@ export async function extractRoleVisual(
1153
1273
  if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1154
1274
 
1155
1275
  // 1. 扫描正文:收集该角色出场且可能含外貌描写的段落(最近 60 章内,每章最多 2 段,共 12 段)。
1276
+ // 支持“描述型角色名”(如「灰蓝工装女人」):先精确匹配,匹配不到时用角色名拆分出的关键词兜底。
1156
1277
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/
1278
+ // 从角色名 + 身份描述中提取可检索关键词,支持“描述型角色名”(灰蓝工装女人)和“身份型角色名”(富商)。
1279
+ const roleText = `${role.name} ${role.identity ?? ''}`
1280
+ const colorWords = ['深灰', '灰蓝', '灰', '蓝', '白', '黑', '红', '青', '紫', '银', '金', '黄', '绿', '粉', '棕']
1281
+ const garmentWords = ['工装', '西装', '定制西装', '袍', '裙', '衫', '衣', '裤', '靴', '鞋', '帽', '腰带', '制服', '外套', '马甲', '夹克']
1282
+ const roleWords = ['女人', '男人', '老者', '老人', '中年', '青年', '少女', '姑娘', '男子', '女子', '工人', '路人', '购买者']
1283
+ const locationWords = ['别墅', '小区', '街区']
1284
+ const identityTokens = (role.identity ?? '').split(/[,,。\s]+/).filter(t => t.length >= 2)
1285
+ const searchTokens = Array.from(new Set([
1286
+ roleName,
1287
+ ...identityTokens,
1288
+ ...colorWords.filter(w => roleText.includes(w)),
1289
+ ...garmentWords.filter(w => roleText.includes(w)),
1290
+ ...roleWords.filter(w => roleText.includes(w)),
1291
+ ...locationWords.filter(w => roleText.includes(w)),
1292
+ ])).filter(t => t.length >= 2)
1293
+ const matchesRole = (para: string): boolean => para.includes(roleName) || searchTokens.some(tok => para.includes(tok))
1157
1294
  const excerpts: Array<{ no: number; text: string }> = []
1158
1295
  const written = project.chapters
1159
1296
  .filter(c => c.status !== 'pending' && c.status !== 'generating' && c.file !== undefined)
@@ -1165,7 +1302,7 @@ export async function extractRoleVisual(
1165
1302
  let perChapter = 0
1166
1303
  for (const para of paras) {
1167
1304
  if (perChapter >= 2 || excerpts.length >= 12) break
1168
- if (!para.includes(roleName)) continue
1305
+ if (!matchesRole(para)) continue
1169
1306
  // 优先外貌描写段(含外貌关键词),否则纯动作段也收(LLM 自己判断)。
1170
1307
  if (appearanceHints.test(para) || excerpts.length < 4) {
1171
1308
  excerpts.push({ no: chapter.no, text: para.slice(0, 220) })
@@ -1178,19 +1315,30 @@ export async function extractRoleVisual(
1178
1315
  throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`)
1179
1316
  }
1180
1317
 
1181
- // 2. LLM 提炼。
1318
+ // 2. LLM 提炼:一次输出 锚点 + 表情清单 + 四类精修提示词。
1319
+ const rules = project.visualRules ?? []
1182
1320
  const system = [
1183
- '你是一位动漫角色设定师。根据网文正文中该角色的实际外貌描写,提炼「动漫形象描述词」,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。',
1321
+ '你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。',
1184
1322
  '硬性要求(依据优先):',
1185
1323
  '1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。',
1186
- '2. 正文未明确写到的项目(如瞳色没写),用「未定」标注,不要编造。',
1187
- '3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合。',
1188
- '输出三个部分:',
1189
- '- zh:中文外貌描述,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。',
1190
- '- en:英文绘图标签,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装(如 chinese hanfu / daoist robe)、气质、背景无关项。不要输出负面提示词。',
1324
+ '2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。',
1325
+ '3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。',
1326
+ '4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。',
1327
+ '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1328
+ ...rules.map(r => '- ' + r),
1329
+ '输出六部分:',
1330
+ '- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。',
1331
+ '- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。',
1191
1332
  '- tags:中文关键标签数组,5-10 个(如 ["黑发","束发","青色道袍","清秀","腰悬古玉"])。',
1192
1333
  '- source:说明依据(如"第1章/第8章外貌描写;瞳色未明确")。',
1193
- '输出必须是合法 JSON 对象:{"zh": "...", "en": "...", "tags": [...], "source": "..."}',
1334
+ '- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 ["疲惫","麻木","压抑悲伤","皱眉","紧绷","放空"]),依据正文情绪描写与角色处境推断。',
1335
+ '- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):',
1336
+ ' · portrait 立绘:角色设计稿定位,纯白纯色背景,全身完整无裁切,无多余杂物;字段流:年龄感、身高(有依据才写)、发型发色、胡茬、眼眸、面部气质、上身服装分件(含磨损)、下身、鞋、标志物细节。',
1337
+ ' · sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,全身完整,四个视角分别描述,服装与标志物细节完整展示。',
1338
+ ' · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
1339
+ ' · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
1340
+ 'promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。',
1341
+ '输出必须是合法 JSON 对象:{"zh": "...", "en": "...", "tags": [...], "source": "...", "expressions": [...], "promptKit": {"portrait": {"zh":"...", "en":"..."}, "sheet": {"zh":"...", "en":"..."}, "expressions": [{"name":"疲惫","zh":"...","en":"..."}], "details": {"zh":"...", "en":"..."}}}',
1194
1342
  '重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
1195
1343
  '重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。',
1196
1344
  ].join('\n')
@@ -1202,20 +1350,137 @@ export async function extractRoleVisual(
1202
1350
  ...excerpts.map(e => `[第${e.no}章] ${e.text}`),
1203
1351
  '只输出 JSON 对象。',
1204
1352
  ].join('\n\n')
1205
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 4000) })
1206
- const raw = parseJsonObject<{ zh?: unknown; en?: unknown; tags?: unknown; source?: unknown }>(text)
1353
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1354
+ const raw = parseJsonObject<{ zh?: unknown; en?: unknown; tags?: unknown; source?: unknown; expressions?: unknown; promptKit?: unknown }>(text)
1207
1355
  const zh = typeof raw.zh === 'string' ? raw.zh.trim().slice(0, 500) : ''
1208
1356
  const en = typeof raw.en === 'string' ? raw.en.trim().slice(0, 1500) : ''
1209
1357
  const tags = Array.isArray(raw.tags)
1210
1358
  ? raw.tags.filter((t): t is string => typeof t === 'string' && t.trim() !== '').map(t => t.trim().slice(0, 20)).slice(0, 12)
1211
1359
  : []
1212
1360
  const source = typeof raw.source === 'string' ? raw.source.trim().slice(0, 300) : ''
1361
+ const expressions = Array.isArray(raw.expressions)
1362
+ ? raw.expressions.filter((e): e is string => typeof e === 'string' && e.trim() !== '').map(e => e.trim().slice(0, 12)).slice(0, 12)
1363
+ : []
1364
+ // 四类精修提示词(一次提炼直接产出)
1365
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1366
+ const pair = (v: unknown): { zh: string; en: string } => {
1367
+ const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
1368
+ return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
1369
+ }
1370
+ const kitRaw = (typeof raw.promptKit === 'object' && raw.promptKit !== null) ? raw.promptKit as Record<string, unknown> : {}
1371
+ const kitExpr = Array.isArray(kitRaw.expressions) ? kitRaw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
1372
+ let promptKit: RoleRecord['promptKit']
1373
+ try {
1374
+ promptKit = {
1375
+ portrait: pair(kitRaw.portrait),
1376
+ sheet: pair(kitRaw.sheet),
1377
+ expressions: kitExpr.map(e => ({
1378
+ name: str(e.name).slice(0, 12) || '表情',
1379
+ zh: str(e.zh).slice(0, 800),
1380
+ en: str(e.en).slice(0, 1200),
1381
+ })).slice(0, 12),
1382
+ details: pair(kitRaw.details),
1383
+ }
1384
+ } catch {
1385
+ promptKit = undefined
1386
+ }
1213
1387
  if (zh === '' || en === '') {
1214
1388
  throw new Error('形象描述提炼失败:LLM 未返回有效 JSON')
1215
1389
  }
1216
- return { zh, en, tags, source }
1390
+ return { zh, en, tags, source, expressions, promptKit }
1391
+ }
1392
+
1393
+ /**
1394
+ * 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
1395
+ * LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
1396
+ */
1397
+ export async function generateRolePromptKit(
1398
+ ctx: Context,
1399
+ config: NovelConfig,
1400
+ project: ProjectState,
1401
+ roleName: string,
1402
+ ): Promise<RoleRecord['promptKit']> {
1403
+ const role = (project.roles ?? []).find(r => r.name === roleName)
1404
+ if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
1405
+ if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
1406
+ const rules = project.visualRules ?? []
1407
+ const system = [
1408
+ '你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。',
1409
+ '四类:',
1410
+ '1. portrait 立绘:全身/半身正视图,含完整服装与标志物。',
1411
+ '2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。',
1412
+ '3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。',
1413
+ '4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。',
1414
+ '【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):',
1415
+ ...rules.map(r => '- ' + r),
1416
+ 'zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。',
1417
+ '输出必须是合法 JSON 对象:{"portrait": {"zh": "...", "en": "..."}, "sheet": {"zh": "...", "en": "..."}, "expressions": [{"name": "疲惫", "zh": "...", "en": "..."}], "details": {"zh": "...", "en": "..."}}',
1418
+ '重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。',
1419
+ ].join('\n')
1420
+ const user = [
1421
+ `角色:${role.name}(${role.identity ?? ''})`,
1422
+ `中文锚点:${role.imagePrompt.zh}`,
1423
+ `英文锚点:${role.imagePrompt.en}`,
1424
+ `关键标签:${role.imagePrompt.tags.join('、')}`,
1425
+ `表情清单:${(role.expressions ?? []).join('、') || '(未提供,按角色气质推断 6 个)'}`,
1426
+ '只输出 JSON 对象。',
1427
+ ].join('\n\n')
1428
+ const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1429
+ const raw = parseJsonObject<Record<string, unknown>>(text)
1430
+ const str = (v: unknown): string => typeof v === 'string' ? v.trim() : ''
1431
+ const pair = (v: unknown): { zh: string; en: string } => {
1432
+ const o = (typeof v === 'object' && v !== null) ? v as Record<string, unknown> : {}
1433
+ return { zh: str(o.zh).slice(0, 800), en: str(o.en).slice(0, 1200) }
1434
+ }
1435
+ const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
1436
+ const kit: RoleRecord['promptKit'] = {
1437
+ portrait: pair(raw.portrait),
1438
+ sheet: pair(raw.sheet),
1439
+ expressions: expressionsRaw.map(e => ({
1440
+ name: str(e.name).slice(0, 12) || '表情',
1441
+ zh: str(e.zh).slice(0, 800),
1442
+ en: str(e.en).slice(0, 1200),
1443
+ })).slice(0, 12),
1444
+ details: pair(raw.details),
1445
+ }
1446
+ if (kit.portrait.zh === '' || kit.portrait.en === '') {
1447
+ throw new Error('提示词精修失败:LLM 未返回有效 JSON')
1448
+ }
1449
+ return kit
1450
+ }
1451
+
1452
+ /** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
1453
+ export async function extractVisualRules(
1454
+ ctx: Context,
1455
+ config: NovelConfig,
1456
+ project: ProjectState,
1457
+ ): Promise<string[]> {
1458
+ const system = [
1459
+ '你是一位 AI 绘图的视觉规则设计师。本书的设定是"反常识"的,生图/生视频模型默认会画成现实世界的样子,你需要提炼 3-6 条「视觉规则」钉住本书的视觉世界观。',
1460
+ '规则要求:',
1461
+ '1. 每条必须可执行:明确"画面里必须出现什么/禁止出现什么"(如"货架上的一切商品都是活人,禁止画成罐头/饮料/日用品")。',
1462
+ '2. 覆盖本书最容易被模型画错的 2-4 个核心反常识点。',
1463
+ '3. 每条 40 字内,禁止泛泛而谈。',
1464
+ '输出必须是合法 JSON 数组:["规则1", "规则2", ...],不要输出其他文字。',
1465
+ ].join('\n')
1466
+ const bible = project.bible
1467
+ const user = [
1468
+ `书名:《${project.bookName}》`,
1469
+ `题材:${bible?.genre ?? ''}`,
1470
+ bible !== undefined && bible.worldRules.length > 0
1471
+ ? `世界规则:\n${bible.worldRules.map(r => '- ' + r).join('\n')}`
1472
+ : '',
1473
+ bible !== undefined && bible.redLines.length > 0
1474
+ ? `红线:\n${bible.redLines.map(r => '- ' + r).join('\n')}`
1475
+ : '',
1476
+ '只输出 JSON 数组。',
1477
+ ].filter(s => s !== '').join('\n\n')
1478
+ const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000) })
1479
+ const raw = parseJsonArray<string>(text)
1480
+ return raw.map(r => typeof r === 'string' ? r.trim().slice(0, 80) : '').filter(r => r !== '').slice(0, 8)
1217
1481
  }
1218
1482
 
1483
+
1219
1484
  /**
1220
1485
  * 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
1221
1486
  * @param count 本次生成几个(默认 3,最多 3)
@@ -1444,171 +1709,62 @@ export async function breakdownBook(
1444
1709
  }
1445
1710
  }
1446
1711
 
1447
- /**
1448
- * 漫剧分镜生成:把一章正文改编为短视频漫剧分镜(吸收 manga-script-master 方法论)。
1449
- * 产出:本集标题 + 赛道节奏说明 + 角色视觉锚点卡 + 分镜表(8-12 格)+ 结尾钩子。
1450
- * 角色锚点优先复用角色库 imagePrompt(无则从正文提炼)。
1451
- */
1452
- export async function generateStoryboard(
1712
+ export async function generateRoleReferenceImage(
1453
1713
  ctx: Context,
1454
1714
  config: NovelConfig,
1455
1715
  project: ProjectState,
1456
1716
  outputDir: string,
1457
- chapterNo: number,
1458
- genre: string,
1459
- platform: string,
1460
- tool: string,
1461
- ): Promise<StoryboardResponse> {
1462
- const chapter = project.chapters.find(c => c.no === chapterNo)
1463
- if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
1464
- const body = readChapterFile(outputDir, chapter)
1465
- if (body === undefined) throw new Error(`章节 ${chapterNo} 尚未生成正文`)
1466
-
1467
- // 角色锚点:复用角色库 imagePrompt(优先)或章节内出场角色名。
1468
- const roles = project.roles ?? []
1469
- const roleCards = roles
1470
- .filter(r => r.imagePrompt !== undefined && (body.includes(r.name) || chapter.summary?.includes(r.name) === true))
1471
- .map(r => ({
1472
- name: r.name,
1473
- anchor: r.imagePrompt!.zh.slice(0, 120),
1474
- tags: r.imagePrompt!.en.slice(0, 300),
1475
- }))
1476
- .slice(0, 3)
1477
-
1478
- const system = [
1479
- '你是一位精通短视频算法与 AIGC 的顶级漫剧导演。把给定的一章网文改编为高完播率的漫剧分镜。',
1480
- '硬性要求:',
1481
- '1. 前 3 秒必须有强冲突或视觉冲击(黄金开局);每 10 秒至少一个小高潮;结尾必须有悬念钩子。',
1482
- '2. 所有情感必须具象化为动作(不能写"他很愤怒",要写"他拳头砸碎桌角,碎片飞溅")。',
1483
- '3. 台词每句 ≤15 字,用画面推进叙事。',
1484
- '4. 总时长 60-120 秒,分镜 8-12 格,每格标注时间码、景别、转场/动效。',
1485
- '5. 输出 JSON:',
1486
- '{"title": "本集标题(核心设定+冲突+悬念句式)", "pacingNote": "赛道判断与节奏说明(按爽文/甜宠/悬疑/搞笑模型)", "hook": "本集钩子一句话", "panels": [{"timecode": "0:00-0:03", "shot": "特写", "visual": "具象画面描述", "dialogue": "(情绪)台词", "transition": "硬切/闪白等", "prompt": "英文AI提示词:角色标签+表情+动作+场景+景别+光影+风格+9:16"}], "endingHook": "结尾钩子台词"}',
1487
- '6. 英文 prompt 里的角色标签必须复用提供的角色锚点标签;多角色同框用 left/right/foreground 分隔。',
1488
- '7. 严禁在提示词里出现文字/水印(画面内字幕后期加)。',
1489
- '重要:直接输出 JSON,字符串内不含换行。',
1490
- ].join('\n')
1491
-
1492
- const user = [
1493
- `书名:《${project.bookName}》`,
1494
- `章节:第 ${chapter.no} 章《${chapter.title}》`,
1495
- chapter.summary !== undefined ? `章节摘要:${chapter.summary}` : '',
1496
- `目标平台:${platform}(默认抖音竖屏 9:16)`,
1497
- `赛道:${genre !== '' ? genre : '(由你按内容判断:爽文/甜宠/悬疑/搞笑)'}`,
1498
- `AI 工具:${tool}(影响提示词格式)`,
1499
- roleCards.length > 0
1500
- ? `角色视觉锚点(英文标签全分镜强制复用):\n${roleCards.map(r => `- ${r.name}:${r.tags}`).join('\n')}`
1501
- : '(无角色锚点卡,从正文提炼 1-2 个核心角色并给出标签)',
1502
- '==================== 正文 ====================',
1503
- body.replace(/^#\s+.*$/m, '').trim().slice(0, 6000),
1504
- '只输出 JSON 对象。',
1505
- ].join('\n\n')
1506
-
1507
- const text = await complete(ctx, config, { system, user, temperature: 0.5, maxTokens: Math.max(config.maxTokens, 12000) })
1508
- const raw = parseJsonObject<Record<string, unknown>>(text)
1509
- const str = (v: unknown, fallback = ''): string => typeof v === 'string' ? v.trim() : fallback
1510
- const panelsRaw = Array.isArray(raw.panels) ? raw.panels.filter((p): p is Record<string, unknown> => typeof p === 'object' && p !== null) : []
1511
- const panels: StoryboardResponse['panels'] = panelsRaw
1512
- .map((p, i) => ({
1513
- index: i + 1,
1514
- timecode: str(p.timecode, `0:${String(i * 10).padStart(2, '0')}`),
1515
- shot: str(p.shot, '中景'),
1516
- visual: str(p.visual),
1517
- dialogue: str(p.dialogue),
1518
- transition: str(p.transition, '硬切'),
1519
- prompt: str(p.prompt),
1520
- }))
1521
- .filter(p => p.visual !== '')
1522
- .slice(0, 12)
1523
-
1524
- return {
1525
- title: str(raw.title, `第 ${chapter.no} 集`),
1526
- pacingNote: str(raw.pacingNote, '(按内容判断赛道节奏)'),
1527
- hook: str(raw.hook),
1528
- characters: roleCards.map(r => ({
1529
- name: r.name,
1530
- visualAnchor: r.anchor,
1531
- tags: r.tags,
1532
- expressions: [],
1533
- })),
1534
- panels,
1535
- endingHook: str(raw.endingHook),
1536
- }
1537
- }
1538
-
1539
- /**
1540
- * 漫剧分集计划:AI 通读一卷的章节标题+摘要+beats,按故事弧线把章节分组为漫剧集。
1541
- * 原则(吸收 manga-script 节奏模型):高潮章单独成集、过渡章合并、断点选在钩子最强处。
1542
- */
1543
- export async function planStoryboardEpisodes(
1544
- ctx: Context,
1545
- config: NovelConfig,
1546
- project: ProjectState,
1547
- volumeNo: number,
1548
- platform: string,
1549
- maxEpisodes: number,
1550
- ): Promise<StoryboardPlanResponse> {
1551
- const volume = project.volumes?.find(v => v.no === volumeNo)
1552
- if (volume === undefined) throw new Error(`第 ${volumeNo} 卷不存在(卷计划未生成或卷号错误)`)
1553
- const chapters = project.chapters
1554
- .filter(c => c.volume === volumeNo && c.status !== 'pending' && c.status !== 'generating')
1555
- .sort((a, b) => a.no - b.no)
1556
- if (chapters.length === 0) throw new Error(`第 ${volumeNo} 卷没有已写章节`)
1557
-
1558
- // 每章给压缩摘要:标题 + beats 首段 + summary(限长)。
1559
- const chapterBriefs = chapters.map(c => {
1560
- const beatsHead = (c.beats ?? '').split('\n')[0] ?? ''
1561
- return `第${c.no}章《${c.title}》|${beatsHead.slice(0, 60)}|${(c.summary ?? '').slice(0, 100)}`
1717
+ name: string,
1718
+ style = '',
1719
+ ): Promise<string> {
1720
+ const role = project.roles?.find(r => r.name === name)
1721
+ if (role === undefined) throw new Error(`角色 ${name} 不存在`)
1722
+ if (role.imagePrompt === undefined) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`)
1723
+ if (config.imageApiEnabled !== true) throw new Error('豆包生图未启用,请在设置中开启')
1724
+ const apiKey = config.imageApiKey
1725
+ const model = config.imageApiModel
1726
+ if (apiKey === undefined || apiKey === '' || model === undefined || model === '') {
1727
+ throw new Error('请先在设置中配置豆包 API Key 和生图模型')
1728
+ }
1729
+ const stylePrompt = getComicStylePrompt(style)
1730
+ const prompt = [
1731
+ role.imagePrompt.zh,
1732
+ role.imagePrompt.en,
1733
+ role.imagePrompt.tags.join(', '),
1734
+ stylePrompt,
1735
+ 'character reference sheet, front view, clean background, consistent design',
1736
+ ].filter(Boolean).join(', ')
1737
+
1738
+ const res = await fetch('https://ark.cn-beijing.volces.com/api/v3/images/generations', {
1739
+ method: 'POST',
1740
+ headers: {
1741
+ 'Content-Type': 'application/json',
1742
+ Authorization: `Bearer ${apiKey}`,
1743
+ },
1744
+ body: JSON.stringify({
1745
+ model,
1746
+ prompt,
1747
+ response_format: 'url',
1748
+ size: '2K',
1749
+ stream: false,
1750
+ watermark: false,
1751
+ }),
1562
1752
  })
1563
-
1564
- const system = [
1565
- '你是一位精通短视频算法与漫剧节奏的总导演。把给定的一卷网文改编为「漫剧分集计划」——不是按章分,而是按故事弧线分集。',
1566
- '分集原则:',
1567
- '1. 高潮章(大冲突/身份揭晓/大收获)单独成集或两章一集;过渡章(赶路/准备/日常)2-3 章合并为一集;支线穿插章并入相邻主线圈。',
1568
- '2. 每集 60-120 秒 ≈ 1-2 章正文,但以"叙事任务完整"为准,不被章节号束缚。',
1569
- '3. 断点选在钩子最强处:每集结尾必须留下让观众点下集的悬念。',
1570
- '4. 全集数控制在 8 到上限之间(上限由输入给出),宁少勿碎。',
1571
- '5. 卷的开局集要强(3 秒钩子),卷的结尾集要收束本卷弧线并埋下卷间钩子。',
1572
- '输出 JSON:',
1573
- '{"strategy": "本卷漫剧化策略一句话(赛道判断+整体节奏安排)", "episodes": [{"title": "集标题(冲突+悬念句式)", "chapters": [章号数组], "narrativeJob": "这集讲什么、为什么这么分(1-2句)", "openingHook": "开头3秒钩子", "endingHook": "结尾钩子"}]}',
1574
- '硬性要求:episodes 按章号顺序排列且覆盖全部给定章节(不得遗漏);chapters 数组必须连续(如 [81,82,83]);每集 chapters 至少 1 章、最多 4 章;所有文字用简体中文。',
1575
- '重要:直接输出 JSON,字符串内不含换行。',
1576
- ].join('\n')
1577
-
1578
- const user = [
1579
- `书名:《${project.bookName}》`,
1580
- `第 ${volumeNo} 卷《${volume.title}》(${volume.chapterStart}-${volume.chapterEnd} 章)`,
1581
- `卷定位:${volume.summary}`,
1582
- `目标平台:${platform}(竖屏 9:16)`,
1583
- `本卷已写章节(${chapters.length} 章,按序):\n${chapterBriefs.join('\n')}`,
1584
- `全集数上限:${maxEpisodes}`,
1585
- '只输出 JSON 对象。',
1586
- ].join('\n\n')
1587
-
1588
- const text = await complete(ctx, config, { system, user, temperature: 0.4, maxTokens: Math.max(config.maxTokens, 12000) })
1589
- const raw = parseJsonObject<{ strategy?: unknown; episodes?: unknown }>(text)
1590
- const episodesRaw = Array.isArray(raw.episodes) ? raw.episodes.filter((e): e is Record<string, unknown> => typeof e === 'object' && e !== null) : []
1591
- const str = (v: unknown, fallback = ''): string => typeof v === 'string' ? v.trim() : fallback
1592
- const episodes: StoryboardPlanResponse['episodes'] = []
1593
- for (const e of episodesRaw) {
1594
- const chaptersArr = Array.isArray(e.chapters) ? e.chapters.filter((n): n is number => typeof n === 'number' && Number.isInteger(n) && n > 0) : []
1595
- if (chaptersArr.length === 0) continue
1596
- episodes.push({
1597
- index: episodes.length + 1,
1598
- title: str(e.title, `第 ${episodes.length + 1} 集`),
1599
- chapters: chaptersArr,
1600
- narrativeJob: str(e.narrativeJob),
1601
- openingHook: str(e.openingHook),
1602
- endingHook: str(e.endingHook),
1603
- })
1604
- }
1605
- if (episodes.length === 0) throw new Error('分集计划生成失败:LLM 未返回有效集数')
1606
-
1607
- return {
1608
- strategy: str(raw.strategy, '(按内容判断赛道节奏)'),
1609
- episodes,
1610
- chaptersScanned: chapters.length,
1611
- }
1753
+ if (!res.ok) {
1754
+ const text = await res.text()
1755
+ throw new Error(`豆包生图失败(${res.status}):${text}`)
1756
+ }
1757
+ const json = (await res.json()) as { data?: Array<{ url?: string }> }
1758
+ const url = json.data?.[0]?.url
1759
+ if (url === undefined || url === '') throw new Error('豆包生图未返回图片 URL')
1760
+
1761
+ const imgRes = await fetch(url)
1762
+ if (!imgRes.ok) throw new Error('下载豆包图片失败')
1763
+ const buf = Buffer.from(await imgRes.arrayBuffer())
1764
+ const contentType = imgRes.headers.get('content-type') || 'image/jpeg'
1765
+ const dataUrl = `data:${contentType};base64,${buf.toString('base64')}`
1766
+ role.imageUrl = dataUrl
1767
+ return dataUrl
1612
1768
  }
1613
1769
 
1614
1770
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */