@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/index.ts CHANGED
@@ -199,6 +199,7 @@ export function apply(ctx: Context, config?: Config): void {
199
199
  if (patch.imageApiKey !== undefined) next.imageApiKey = patch.imageApiKey
200
200
  if (patch.imageApiModel !== undefined) next.imageApiModel = patch.imageApiModel
201
201
  if (patch.imageApiEnabled !== undefined) next.imageApiEnabled = patch.imageApiEnabled
202
+ if (patch.imageModels !== undefined) next.imageModels = patch.imageModels
202
203
  // Persist through the settings seam when available; otherwise keep in memory.
203
204
  // (ctx.get is the non-strict service access — no inject requirement, same
204
205
  // pattern installSettingsSection itself uses.)
package/src/protocol.ts CHANGED
@@ -4,6 +4,16 @@
4
4
  * state file format, and the NDJSON generation stream frames all live here so
5
5
  * both halves spell exactly one vocabulary.
6
6
  */
7
+ import type {
8
+ ShotSizeId,
9
+ CameraMoveId,
10
+ CompositionId,
11
+ LightingId,
12
+ } from './shot-language.ts'
13
+ import type {
14
+ StoryFunctionId,
15
+ EmotionId,
16
+ } from './story-beat-language.ts'
7
17
 
8
18
  /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
9
19
  export const NOVEL_API = {
@@ -44,6 +54,8 @@ export const NOVEL_API = {
44
54
  bookshelfImportTextPreview: '/api/dsh-novel-forge/bookshelf/import-text/preview',
45
55
  /** 漫剧方案管理:create/remove/activate。 */
46
56
  manhuaPlans: '/api/dsh-novel-forge/manhua/plans',
57
+ /** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
58
+ mangaRoles: '/api/dsh-novel-forge/manga/roles',
47
59
  /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
48
60
  storyboardSkeleton: '/api/dsh-novel-forge/storyboard/skeleton',
49
61
  /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
@@ -210,10 +222,10 @@ export interface StoryboardBeat {
210
222
  id: string
211
223
  /** 事件一句话(发生了什么)。 */
212
224
  event: string
213
- /** 情绪走向(如 压抑→隐忍→惊惧)。 */
214
- emotion: string
215
- /** 叙事功能:铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。 */
216
- function: string
225
+ /** 情绪走向(情绪词 id 数组,来自 story-beat-language 词库,链式)。 */
226
+ emotion: EmotionId[]
227
+ /** 叙事功能(枚举 id,来自 story-beat-language 词库)。 */
228
+ function: StoryFunctionId
217
229
  /** 因果:承接上一节拍的原因(可选)。 */
218
230
  cause?: string
219
231
  }
@@ -225,6 +237,8 @@ export interface StoryboardSkeleton {
225
237
  arc: string
226
238
  /** 剧情节拍链(时间顺序,因果连贯)。 */
227
239
  beats: StoryboardBeat[]
240
+ /** 本章全部出镜角色(正文中的确切称谓,去重;漫剧角色库提名地基)。 */
241
+ characters?: string[]
228
242
  }
229
243
 
230
244
  /** POST /storyboard/skeleton 请求:生成单章剧情骨架。 */
@@ -243,10 +257,12 @@ export interface StoryboardShot {
243
257
  id: string
244
258
  /** 挂载的节拍 id(骨架中的 b1…)。 */
245
259
  beatId: string
246
- /** 景别:远景/全景/中景/近景/特写。 */
247
- shot: string
248
- /** 机位与运镜(如 低机位仰拍 + 缓慢推近)。 */
249
- camera: string
260
+ /** 景别(枚举 id,来自 shot-language 词库)。 */
261
+ shot: ShotSizeId
262
+ /** 机位与运镜(枚举 id 数组,来自 shot-language 词库)。 */
263
+ camera: CameraMoveId[]
264
+ /** 构图(可选,枚举 id)。 */
265
+ composition?: CompositionId
250
266
  /** 时长(秒,1-12)。 */
251
267
  duration: number
252
268
  /** 画面内容:角色动作 + 表情 + 服装/标志物。 */
@@ -255,12 +271,16 @@ export interface StoryboardShot {
255
271
  line: string
256
272
  /** 音效(无则空字符串)。 */
257
273
  sound: string
258
- /** 光效(如 雨夜顶光 + 手机屏幕冷光)。 */
259
- light: string
274
+ /** 光效(枚举 id 数组,来自 shot-language 词库)。 */
275
+ light: LightingId[]
260
276
  /** 承接上一镜头结尾状态(位置/动作/情绪/服装)。 */
261
277
  prevState: string
262
278
  /** 本镜头结束状态。 */
263
279
  nextState: string
280
+ /** 本镜头出镜角色(引用小说库规范名/漫剧卡名,1-4 个;漫剧角色库提名地基)。 */
281
+ characters?: string[]
282
+ /** 本镜头已绑定漫剧卡的 id(定妆图引用,供出图/生视频时按卡取参考图)。 */
283
+ mangaRoleIds?: string[]
264
284
  }
265
285
 
266
286
  /** 分镜·导演级:单章分镜表。 */
@@ -269,6 +289,10 @@ export interface StoryboardTable {
269
289
  shots: StoryboardShot[]
270
290
  /** 生成时注入的场景卡名称(前端标注消费关系)。 */
271
291
  usedScenes?: string[]
292
+ /** 本章全部出镜角色(各镜头 characters 去重汇总;骨架有则兜底)。 */
293
+ characters?: string[]
294
+ /** 本章已绑定漫剧卡的 id(去重汇总,定妆图引用)。 */
295
+ mangaRoleIds?: string[]
272
296
  }
273
297
 
274
298
  /** POST /storyboard/table 请求:骨架 → 分镜表。 */
@@ -293,6 +317,8 @@ export interface StoryboardPrompt {
293
317
  shotId: string
294
318
  /** 即梦/视频模型可粘贴的中文提示词(画面+运镜+光效+风格词块)。 */
295
319
  text: string
320
+ /** 本镜头绑定的漫剧卡 id(定妆图引用:出图/生视频时按卡取 imageUrl/立绘)。 */
321
+ mangaRoleIds?: string[]
296
322
  }
297
323
 
298
324
  /** 分镜持久化:单章的分镜产出(骨架 + 分镜表 + 视频提示词,可分别存在)。 */
@@ -354,6 +380,113 @@ export interface MangaPlansResponse {
354
380
  plans: MangaPlan[]
355
381
  }
356
382
 
383
+ /** 漫剧角色卡:制作角色(要建模/出图/锁一致性的「上镜角色」),与小说角色库弱关联。 */
384
+ export interface MangaRoleCard {
385
+ /** 稳定 id。 */
386
+ id: string
387
+ /** 来源小说角色名(只读追溯,非强外键;禁止反向写回小说库)。 */
388
+ sourceRoleName?: string
389
+ /** 漫剧用名(可短剧化改名)。 */
390
+ name: string
391
+ /** 身份一句话。 */
392
+ identity: string
393
+ /** 核心功能:主角/导师/感情线/反派/搭档/线人/功能性。 */
394
+ coreFunction: 'protagonist' | 'mentor' | 'love_interest' | 'antagonist' | 'sidekick' | 'informant' | 'functional'
395
+ /** 与主角的关系。 */
396
+ protagonistRelation: 'enemy' | 'friend' | 'mentor' | 'lover' | 'exploit' | 'neutral'
397
+ /** 口头禅/说话方式(配音一致性)。 */
398
+ speechStyle: string
399
+ /** ≤3 个极致性格标签。 */
400
+ traits: string[]
401
+ /** 1-2 个辨识度外貌点。 */
402
+ appearance: string
403
+ /** 3-5 个关键剧情节点。 */
404
+ keyScenes: string[]
405
+ /** 上场集数(分镜提名导入时记录)。 */
406
+ appearsInEpisodes: number[]
407
+ /** 待匹配 → 待确认 → 已导入 → 已定妆 → 已归档。 */
408
+ status: 'pending_match' | 'pending_confirm' | 'imported' | 'anchored' | 'archived'
409
+ /** 形象资产(沿用小说角色库同款结构)。 */
410
+ imagePrompt?: RoleRecord['imagePrompt']
411
+ /** 情绪表情清单(锚点提炼时产出)。 */
412
+ expressions?: string[]
413
+ /** 四类精修生图提示词(立绘/四视图/表情/细节)。 */
414
+ promptKit?: RoleRecord['promptKit']
415
+ /** 角色参考图。 */
416
+ imageUrl?: string
417
+ /** 图集(图像锚点)。 */
418
+ gallery?: RoleImage[]
419
+ /** 生成锚点/精修时的方案风格 id(旧风格检测用)。 */
420
+ promptStyleId?: string
421
+ createdAt: string
422
+ updatedAt: string
423
+ }
424
+
425
+ /** 分镜提名 → 小说角色库匹配的一条候选。 */
426
+ export interface MangaRoleCandidate {
427
+ /** 分镜里的原始称谓(正文确切用法)。 */
428
+ rawName: string
429
+ /** matched=命中小说角色;ambiguous=多个候选待定;not_in_library=小说库没有;already_imported=已建漫剧卡(跳过)。 */
430
+ verdict: 'matched' | 'ambiguous' | 'not_in_library' | 'already_imported'
431
+ /** 规则阶段候选短名单(≤5,精确名优先 + 身份/简称匹配)。 */
432
+ matches: Array<{ roleName: string; reason: string }>
433
+ /** LLM/规则确认的命中角色名(ambiguous 时可能为空)。 */
434
+ matchedRoleName?: string
435
+ /** not_in_library 时给出建议:backfill=正文有该称谓但小说库漏提炼(回小说库补);manga_new=漫剧新增角色(直接建卡)。 */
436
+ novelHint?: 'backfill' | 'manga_new'
437
+ /** 采纳时预填的漫剧卡建议。 */
438
+ suggested: {
439
+ name: string
440
+ identity: string
441
+ coreFunction: MangaRoleCard['coreFunction']
442
+ protagonistRelation: MangaRoleCard['protagonistRelation']
443
+ speechStyle: string
444
+ traits: string[]
445
+ appearance: string
446
+ keyScenes: string[]
447
+ }
448
+ }
449
+
450
+ /** POST /manga/roles 请求:漫剧角色库操作。 */
451
+ export interface MangaRolesRequest {
452
+ op: 'nominate' | 'adopt' | 'update' | 'remove' | 'visual' | 'promptKit' | 'image' | 'removeImage' | 'imageGenerate' | 'mode'
453
+ /** op='mode':短剧精简模式开关。 */
454
+ shortDramaMode?: boolean
455
+ /** op='nominate':从哪章分镜提名角色。 */
456
+ chapterNo?: number
457
+ /** op='adopt' / op='update':漫剧角色卡。 */
458
+ card?: MangaRoleCard
459
+ /** op='remove' / 'visual' / 'promptKit' / 'image' / 'removeImage' / 'imageGenerate':漫剧卡 id。 */
460
+ id?: string
461
+ /** op='visual' / 'promptKit':漫剧方案基底风格 id。 */
462
+ styleId?: string
463
+ /** 可选滤镜风格 id。 */
464
+ filterId?: string
465
+ /** op='image':定妆图 dataURL。 */
466
+ dataUrl?: string
467
+ /** op='image':图集用途标签(立绘/四视图/表情-x/场景/细节);缺省视为角色参考图(imageUrl)。 */
468
+ label?: string
469
+ /** op='imageGenerate':漫画风格预设 id(可选)。 */
470
+ style?: string
471
+ /** op='imageGenerate':生图模型库条目 id(缺省用启用条目)。 */
472
+ modelId?: string
473
+ }
474
+
475
+ /** POST /manga/roles 响应。 */
476
+ export interface MangaRolesResponse {
477
+ cards: MangaRoleCard[]
478
+ /** op='nominate' 时的 AI 提名候选。 */
479
+ candidates?: MangaRoleCandidate[]
480
+ /** op='visual' 时的形象锚点(已写入漫剧卡)。 */
481
+ visual?: RoleRecord['imagePrompt']
482
+ /** op='promptKit' 时的四类精修提示词包(已写入漫剧卡)。 */
483
+ promptKit?: RoleRecord['promptKit']
484
+ /** op='image' / 'imageGenerate' 时的定妆图 dataURL(已写入漫剧卡)。 */
485
+ imageUrl?: string
486
+ /** op='mode' 时的短剧精简模式状态。 */
487
+ shortDramaMode?: boolean
488
+ }
489
+
357
490
 
358
491
  /** Chapter lifecycle states (the writing pipeline's state machine). */
359
492
  export type ChapterStatus =
@@ -1045,6 +1178,10 @@ export interface ProjectState {
1045
1178
  plotlines?: Plotline[]
1046
1179
  /** 角色库(作者维护 + AI 提炼的主表)。 */
1047
1180
  roles?: RoleRecord[]
1181
+ /** 漫剧角色库(制作角色投影:从分镜提名导入,独立于小说角色库)。 */
1182
+ mangaRoles?: MangaRoleCard[]
1183
+ /** 短剧精简模式:漫剧角色库按 5-8 上镜角色 / 功能标签 / 关系闭环 / 人设极致化约束。 */
1184
+ shortDramaMode?: boolean
1048
1185
  /** 场景库(作者维护 + AI 提炼的主表)。 */
1049
1186
  scenes?: SceneCard[]
1050
1187
  /** 视觉世界观规则(生图/生视频必须遵守,从道藏提炼,注入所有提示词)。 */
package/src/routes.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type { IncomingMessage, ServerResponse } from 'node:http'
12
- import { exec } from 'node:child_process'
12
+ import { spawn } from 'node:child_process'
13
13
  import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
14
14
  import { join, basename, extname } from 'node:path'
15
15
  import { homedir } from 'node:os'
@@ -60,6 +60,9 @@ import {
60
60
  type MangaPlan,
61
61
  type MangaPlansRequest,
62
62
  type MangaPlansResponse,
63
+ type MangaRoleCard,
64
+ type MangaRolesRequest,
65
+ type MangaRolesResponse,
63
66
  type LoadOutlineResponse,
64
67
  type NovelConfig,
65
68
  type PlotlinesRequest,
@@ -142,12 +145,16 @@ import {
142
145
  extractRoles,
143
146
  extractScenes,
144
147
  generateRolePromptKit,
148
+ generateMangaRolePromptKit,
145
149
  extractVisualRules,
146
150
  extractRoleVisual,
151
+ extractMangaRoleVisual,
152
+ nominateMangaRoles,
147
153
  suggestOutlines,
148
154
  reverseOutlineFromChapters,
149
155
  breakdownBook,
150
156
  generateRoleReferenceImage,
157
+ generateMangaRoleReferenceImage,
151
158
  testImageEndpoint,
152
159
  generateStoryboardSkeleton,
153
160
  generateStoryboardTable,
@@ -1919,6 +1926,201 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
1919
1926
  },
1920
1927
  }
1921
1928
 
1929
+ // ------------------------------------------------------- manga roles
1930
+ /** 漫剧角色库:从分镜提名(规则+LLM 两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
1931
+ const mangaRolesRoute: WebRoute = {
1932
+ kind: 'exact',
1933
+ path: NOVEL_API.mangaRoles,
1934
+ handler: async (req, res) => {
1935
+ if (!guard(req, res, 'POST')) return
1936
+ const config = getConfig()
1937
+ const project = requireProject(res)
1938
+ if (project === undefined) return
1939
+ const body = await readJsonBody<MangaRolesRequest>(req)
1940
+ if (project.mangaRoles === undefined) project.mangaRoles = []
1941
+ const op = body?.op
1942
+ if (op === 'nominate') {
1943
+ const chapterNo = body?.chapterNo
1944
+ if (typeof chapterNo !== 'number' || chapterNo <= 0) {
1945
+ writeJson(res, 400, { error: 'chapterNo(章号)必填' })
1946
+ return
1947
+ }
1948
+ try {
1949
+ const candidates = await nominateMangaRoles(ctx, config, project, config.outputDir, chapterNo)
1950
+ writeJson(res, 200, { cards: project.mangaRoles, candidates } satisfies MangaRolesResponse)
1951
+ return
1952
+ } catch (error) {
1953
+ writeJson(res, 500, { error: `提名失败:${(error as Error).message}` })
1954
+ return
1955
+ }
1956
+ } else if (op === 'adopt' || op === 'update') {
1957
+ const card = body?.card
1958
+ if (card === undefined || typeof card !== 'object') {
1959
+ writeJson(res, 400, { error: 'card(漫剧角色卡)必填' })
1960
+ return
1961
+ }
1962
+ const name = (card.name ?? '').trim()
1963
+ if (name === '') {
1964
+ writeJson(res, 400, { error: '漫剧角色名不能为空' })
1965
+ return
1966
+ }
1967
+ const now = new Date().toISOString()
1968
+ const existing = card.id !== '' ? project.mangaRoles.find(c => c.id === card.id) : undefined
1969
+ const next: MangaRoleCard = {
1970
+ id: card.id !== '' ? card.id : 'mr-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 6),
1971
+ sourceRoleName: card.sourceRoleName !== undefined && card.sourceRoleName !== '' ? card.sourceRoleName : undefined,
1972
+ name,
1973
+ identity: card.identity ?? '',
1974
+ coreFunction: card.coreFunction ?? 'functional',
1975
+ protagonistRelation: card.protagonistRelation ?? 'neutral',
1976
+ speechStyle: card.speechStyle ?? '',
1977
+ traits: Array.isArray(card.traits) ? card.traits.map(t => String(t).slice(0, 20)).filter(t => t !== '').slice(0, 3) : [],
1978
+ appearance: card.appearance ?? '',
1979
+ keyScenes: Array.isArray(card.keyScenes) ? card.keyScenes.map(k => String(k).slice(0, 120)).filter(k => k !== '').slice(0, 6) : [],
1980
+ appearsInEpisodes: Array.isArray(card.appearsInEpisodes)
1981
+ ? [...new Set(card.appearsInEpisodes.map(n => Number(n)).filter(n => Number.isFinite(n) && n > 0))].sort((a, b) => a - b).slice(0, 200)
1982
+ : [],
1983
+ status: card.status ?? 'imported',
1984
+ imagePrompt: card.imagePrompt,
1985
+ expressions: card.expressions,
1986
+ promptKit: card.promptKit,
1987
+ imageUrl: card.imageUrl,
1988
+ gallery: card.gallery,
1989
+ promptStyleId: card.promptStyleId,
1990
+ createdAt: existing?.createdAt ?? now,
1991
+ updatedAt: now,
1992
+ }
1993
+ if (existing !== undefined) {
1994
+ const idx = project.mangaRoles.findIndex(c => c.id === card.id)
1995
+ project.mangaRoles[idx] = next
1996
+ } else {
1997
+ project.mangaRoles.push(next)
1998
+ }
1999
+ project.updatedAt = now
2000
+ saveProject(config.outputDir, project)
2001
+ writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
2002
+ return
2003
+ } else if (op === 'remove') {
2004
+ const id = body?.id
2005
+ if (typeof id !== 'string' || id === '') {
2006
+ writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2007
+ return
2008
+ }
2009
+ project.mangaRoles = project.mangaRoles.filter(c => c.id !== id)
2010
+ project.updatedAt = new Date().toISOString()
2011
+ saveProject(config.outputDir, project)
2012
+ writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
2013
+ return
2014
+ } else if (op === 'visual') {
2015
+ const id = body?.id?.trim()
2016
+ if (id === undefined || id === '') {
2017
+ writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2018
+ return
2019
+ }
2020
+ try {
2021
+ const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId, body?.filterId)
2022
+ project.updatedAt = new Date().toISOString()
2023
+ saveProject(config.outputDir, project)
2024
+ writeJson(res, 200, { cards: project.mangaRoles, visual } satisfies MangaRolesResponse)
2025
+ return
2026
+ } catch (error) {
2027
+ writeJson(res, 500, { error: `形象锚点生成失败:${(error as Error).message}` })
2028
+ return
2029
+ }
2030
+ } else if (op === 'promptKit') {
2031
+ const id = body?.id?.trim()
2032
+ if (id === undefined || id === '') {
2033
+ writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2034
+ return
2035
+ }
2036
+ try {
2037
+ const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId, body?.filterId)
2038
+ project.updatedAt = new Date().toISOString()
2039
+ saveProject(config.outputDir, project)
2040
+ writeJson(res, 200, { cards: project.mangaRoles, promptKit: kit } satisfies MangaRolesResponse)
2041
+ return
2042
+ } catch (error) {
2043
+ writeJson(res, 500, { error: `提示词精修失败:${(error as Error).message}` })
2044
+ return
2045
+ }
2046
+ } else if (op === 'image') {
2047
+ // 定妆图上传:带标签进图集;标签含「立绘」时同步为参考图(imageUrl)。
2048
+ const id = body?.id?.trim()
2049
+ const dataUrl = body?.dataUrl?.trim()
2050
+ if (id === undefined || id === '') {
2051
+ writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2052
+ return
2053
+ }
2054
+ if (dataUrl === undefined || dataUrl === '') {
2055
+ writeJson(res, 400, { error: 'dataUrl(定妆图)必填' })
2056
+ return
2057
+ }
2058
+ const card = project.mangaRoles.find(c => c.id === id)
2059
+ if (card === undefined) {
2060
+ writeJson(res, 404, { error: '漫剧角色卡不存在' })
2061
+ return
2062
+ }
2063
+ const label = body?.label?.trim() ?? ''
2064
+ if (label !== '') {
2065
+ card.gallery ??= []
2066
+ card.gallery = card.gallery.filter(g => g.label !== label)
2067
+ card.gallery.push({ label, dataUrl })
2068
+ if (label.includes('立绘')) card.imageUrl = dataUrl
2069
+ } else {
2070
+ card.imageUrl = dataUrl
2071
+ }
2072
+ card.updatedAt = new Date().toISOString()
2073
+ project.updatedAt = new Date().toISOString()
2074
+ saveProject(config.outputDir, project)
2075
+ writeJson(res, 200, { cards: project.mangaRoles, imageUrl: card.imageUrl } satisfies MangaRolesResponse)
2076
+ return
2077
+ } else if (op === 'removeImage') {
2078
+ const id = body?.id?.trim()
2079
+ const label = body?.label?.trim() ?? ''
2080
+ const card = project.mangaRoles.find(c => c.id === id)
2081
+ if (card !== undefined) {
2082
+ if (label !== '') {
2083
+ card.gallery = (card.gallery ?? []).filter(g => g.label !== label)
2084
+ if (label.includes('立绘')) card.imageUrl = undefined
2085
+ } else {
2086
+ card.imageUrl = undefined
2087
+ }
2088
+ card.updatedAt = new Date().toISOString()
2089
+ }
2090
+ project.updatedAt = new Date().toISOString()
2091
+ saveProject(config.outputDir, project)
2092
+ writeJson(res, 200, { cards: project.mangaRoles } satisfies MangaRolesResponse)
2093
+ return
2094
+ } else if (op === 'imageGenerate') {
2095
+ const id = body?.id?.trim()
2096
+ if (id === undefined || id === '') {
2097
+ writeJson(res, 400, { error: 'id(漫剧卡 id)必填' })
2098
+ return
2099
+ }
2100
+ try {
2101
+ const imageUrl = await generateMangaRoleReferenceImage(ctx, config, project, config.outputDir, id, body?.style ?? '', body?.modelId)
2102
+ project.updatedAt = new Date().toISOString()
2103
+ saveProject(config.outputDir, project)
2104
+ writeJson(res, 200, { cards: project.mangaRoles, imageUrl } satisfies MangaRolesResponse)
2105
+ return
2106
+ } catch (error) {
2107
+ writeJson(res, 500, { error: `定妆图生成失败:${(error as Error).message}` })
2108
+ return
2109
+ }
2110
+ } else if (op === 'mode') {
2111
+ // 短剧精简模式开关(漫剧角色库 5-8 人 / 功能标签 / 关系闭环 / 人设极致化)。
2112
+ project.shortDramaMode = body?.shortDramaMode === true
2113
+ project.updatedAt = new Date().toISOString()
2114
+ saveProject(config.outputDir, project)
2115
+ writeJson(res, 200, { cards: project.mangaRoles, shortDramaMode: project.shortDramaMode } satisfies MangaRolesResponse)
2116
+ return
2117
+ } else {
2118
+ writeJson(res, 400, { error: '未知的 manga/roles op' })
2119
+ return
2120
+ }
2121
+ },
2122
+ }
2123
+
1922
2124
  // ------------------------------------------------------- chapter reset
1923
2125
  /** 章节复位:generating 卡死 → pending(可重新生成)。 */
1924
2126
  const chapterResetRoute: WebRoute = {
@@ -2094,12 +2296,18 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2094
2296
  if (!guard(req, res, 'POST')) return
2095
2297
  const config = getConfig()
2096
2298
  const dir = config.outputDir
2097
- exec(`explorer "${dir.replace(/"/g, '')}"`, (error) => {
2098
- if (error) {
2099
- writeJson(res, 500, { ok: false, error: error.message })
2100
- } else {
2101
- writeJson(res, 200, { ok: true })
2102
- }
2299
+ const child = spawn('explorer', [dir], { shell: false })
2300
+ let responded = false
2301
+ child.on('error', (error) => {
2302
+ if (responded) return
2303
+ responded = true
2304
+ writeJson(res, 500, { ok: false, error: error.message })
2305
+ })
2306
+ child.on('exit', (code) => {
2307
+ if (responded) return
2308
+ responded = true
2309
+ if (code === 0) writeJson(res, 200, { ok: true })
2310
+ else writeJson(res, 500, { ok: false, error: `explorer 退出码 ${code}` })
2103
2311
  })
2104
2312
  },
2105
2313
  }
@@ -2587,6 +2795,7 @@ export function makeRoutes(deps: NovelRoutesDeps): WebRoute[] {
2587
2795
  renameRoute,
2588
2796
  plotlinesRoute,
2589
2797
  rolesRoute,
2798
+ mangaRolesRoute,
2590
2799
  scenesRoute,
2591
2800
  visualRulesRoute,
2592
2801
  sensitiveRoute,
package/src/run.ts CHANGED
@@ -306,11 +306,12 @@ export class ProductionRunner {
306
306
 
307
307
  private applyDraft(project: ProjectState, chapter: ChapterPlan, draft: string, report: ReviewReport): void {
308
308
  const config = this.deps.getConfig()
309
+ const outputDir = this.bookDir ?? config.outputDir
309
310
  const fileName = chapterFileName(chapter)
310
- mkdirSync(config.outputDir, { recursive: true })
311
- const targetPath = join(config.outputDir, fileName)
311
+ mkdirSync(outputDir, { recursive: true })
312
+ const targetPath = join(outputDir, fileName)
312
313
  if (existsSync(targetPath)) {
313
- copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
314
+ copyFileSync(targetPath, join(outputDir, `${fileName.replace(/\.md$/, '')}.bak.md`))
314
315
  }
315
316
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, 'utf8')
316
317
  chapter.pendingDraft = undefined
@@ -325,6 +326,6 @@ export class ProductionRunner {
325
326
  }
326
327
  chapter.error = undefined
327
328
  project.updatedAt = new Date().toISOString()
328
- saveProject(config.outputDir, project)
329
+ saveProject(outputDir, project)
329
330
  }
330
331
  }