@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.
- package/README.md +1 -1
- package/lib/client.js +3197 -1887
- package/lib/client.js.map +1 -1
- package/lib/index.js +1026 -84
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/locales.d.ts +0 -1
- package/lib/types/client/panel/FlowGuide.d.ts +21 -0
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
- package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
- package/lib/types/engine.d.ts +15 -8
- package/lib/types/protocol.d.ts +138 -10
- package/lib/types/shot-language.d.ts +46 -0
- package/lib/types/story-beat-language.d.ts +35 -0
- package/package.json +5 -2
- package/skills/chapter-batch/SKILL.md +81 -0
- package/src/client/api.ts +5 -0
- package/src/client/locales.ts +0 -1
- package/src/client/panel/AssetsTab.tsx +22 -22
- package/src/client/panel/AssistantTab.tsx +3 -3
- package/src/client/panel/BookshelfBar.tsx +1 -1
- package/src/client/panel/CreateBookView.tsx +14 -10
- package/src/client/panel/FlowGuide.tsx +83 -0
- package/src/client/panel/ImportModal.tsx +11 -2
- package/src/client/panel/MangaRoleLibrary.tsx +810 -0
- package/src/client/panel/MangaWorkspace.tsx +359 -297
- package/src/client/panel/NovelPanel.tsx +108 -107
- package/src/client/panel/ReaderView.tsx +4 -4
- package/src/client/panel/RunPanel.tsx +9 -9
- package/src/client/panel/SceneLibrary.tsx +68 -67
- package/src/client/panel/ShelfView.tsx +2 -2
- package/src/client/panel/StoryboardTab.tsx +145 -49
- package/src/client/panel/StyleCard.tsx +13 -13
- package/src/client/panel/StyleGallery.tsx +2 -2
- package/src/client/panel/WorldTab.tsx +5 -5
- package/src/client/panel/panel.module.css +861 -510
- package/src/client/panel/views.tsx +24 -24
- package/src/engine.ts +514 -62
- package/src/index.ts +1 -0
- package/src/protocol.ts +147 -10
- package/src/routes.ts +216 -7
- package/src/run.ts +5 -4
- package/src/shot-language.ts +181 -0
- package/src/story-beat-language.ts +130 -0
- package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
- 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:
|
|
215
|
-
/**
|
|
216
|
-
function:
|
|
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:
|
|
248
|
-
/**
|
|
249
|
-
camera:
|
|
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:
|
|
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 {
|
|
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
|
-
|
|
2098
|
-
|
|
2099
|
-
|
|
2100
|
-
|
|
2101
|
-
|
|
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(
|
|
311
|
-
const targetPath = join(
|
|
311
|
+
mkdirSync(outputDir, { recursive: true })
|
|
312
|
+
const targetPath = join(outputDir, fileName)
|
|
312
313
|
if (existsSync(targetPath)) {
|
|
313
|
-
copyFileSync(targetPath, join(
|
|
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(
|
|
329
|
+
saveProject(outputDir, project)
|
|
329
330
|
}
|
|
330
331
|
}
|