@waterwx/dsh-novel-forge 1.4.1 → 1.5.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/assets/styles/arcane-thick-3d.webp +0 -0
- package/assets/styles/chinese-ink.webp +0 -0
- package/assets/styles/clay-stopmotion-3d.webp +0 -0
- package/assets/styles/dark-ghibli.webp +0 -0
- package/assets/styles/dark-gothic-3d.webp +0 -0
- package/assets/styles/film-photo.webp +0 -0
- package/assets/styles/guofeng-heavy.webp +0 -0
- package/assets/styles/horror-game-3d.webp +0 -0
- package/assets/styles/japan-cel-dark.webp +0 -0
- package/assets/styles/korean-webtoon.webp +0 -0
- package/assets/styles/low-cyberpunk-3d.webp +0 -0
- package/assets/styles/nextgen-cg.webp +0 -0
- package/assets/styles/noir-film.webp +0 -0
- package/assets/styles/openworld-survival.webp +0 -0
- package/assets/styles/paper-cut-3d.webp +0 -0
- package/assets/styles/pixar-adult-3d.webp +0 -0
- package/assets/styles/pop-marvel-2d.webp +0 -0
- package/assets/styles/shinkai-anime.webp +0 -0
- package/assets/styles/tactical-stealth.webp +0 -0
- package/assets/styles/vaporwave-3d.webp +0 -0
- package/assets/styles/vhs90.webp +0 -0
- package/lib/client.js +1455 -301
- package/lib/client.js.map +1 -1
- package/lib/index.js +589 -6
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +8 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +9 -0
- package/lib/types/client/panel/NovelPanel.d.ts +1 -1
- package/lib/types/client/panel/StoryboardTab.d.ts +13 -0
- package/lib/types/client/panel/StyleGallery.d.ts +1 -0
- package/lib/types/engine.d.ts +19 -3
- package/lib/types/protocol.d.ts +149 -0
- package/lib/types/style-library.d.ts +33 -0
- package/package.json +2 -1
- package/src/client/api.ts +20 -0
- package/src/client/panel/MangaWorkspace.tsx +185 -0
- package/src/client/panel/NovelPanel.tsx +18 -3
- package/src/client/panel/StoryboardTab.tsx +287 -0
- package/src/client/panel/StyleGallery.tsx +109 -0
- package/src/engine.ts +247 -0
- package/src/protocol.ts +164 -0
- package/src/routes.ts +192 -2
- package/src/style-library.ts +78 -0
package/src/engine.ts
CHANGED
|
@@ -29,6 +29,7 @@ import { createUserMessage, BlockAssembler, ReasoningEffortId, type GenerateOpti
|
|
|
29
29
|
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
|
+
import { findStyle, styleKeywords } from './style-library.ts'
|
|
32
33
|
import type {
|
|
33
34
|
AuditIssue,
|
|
34
35
|
AuthorReview,
|
|
@@ -46,6 +47,12 @@ import type {
|
|
|
46
47
|
RoleStatusCard,
|
|
47
48
|
SceneCard,
|
|
48
49
|
StoryBible,
|
|
50
|
+
StoryboardSkeleton,
|
|
51
|
+
StoryboardBeat,
|
|
52
|
+
StoryboardTable,
|
|
53
|
+
StoryboardShot,
|
|
54
|
+
StoryboardPrompt,
|
|
55
|
+
ChapterStoryboard,
|
|
49
56
|
Volume,
|
|
50
57
|
WorldState,
|
|
51
58
|
} from './protocol.ts'
|
|
@@ -1448,6 +1455,8 @@ export async function extractRoleVisual(
|
|
|
1448
1455
|
project: ProjectState,
|
|
1449
1456
|
outputDir: string,
|
|
1450
1457
|
roleName: string,
|
|
1458
|
+
styleId?: string,
|
|
1459
|
+
filterId?: string,
|
|
1451
1460
|
): Promise<RoleVisualPrompt> {
|
|
1452
1461
|
const role = (project.roles ?? []).find(r => r.name === roleName)
|
|
1453
1462
|
if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
|
|
@@ -1497,8 +1506,10 @@ export async function extractRoleVisual(
|
|
|
1497
1506
|
|
|
1498
1507
|
// 2. LLM 提炼:一次输出 锚点 + 表情清单 + 四类精修提示词。
|
|
1499
1508
|
const rules = project.visualRules ?? []
|
|
1509
|
+
const styleWords = styleKeywords(styleId, filterId)
|
|
1500
1510
|
const system = [
|
|
1501
1511
|
'你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。',
|
|
1512
|
+
'【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):' + styleWords,
|
|
1502
1513
|
'硬性要求(依据优先):',
|
|
1503
1514
|
'1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。',
|
|
1504
1515
|
'2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。',
|
|
@@ -1579,13 +1590,17 @@ export async function generateRolePromptKit(
|
|
|
1579
1590
|
config: NovelConfig,
|
|
1580
1591
|
project: ProjectState,
|
|
1581
1592
|
roleName: string,
|
|
1593
|
+
styleId?: string,
|
|
1594
|
+
filterId?: string,
|
|
1582
1595
|
): Promise<RoleRecord['promptKit']> {
|
|
1583
1596
|
const role = (project.roles ?? []).find(r => r.name === roleName)
|
|
1584
1597
|
if (role === undefined) throw new Error(`角色「${roleName}」不在角色库中`)
|
|
1585
1598
|
if (role.imagePrompt === undefined) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`)
|
|
1586
1599
|
const rules = project.visualRules ?? []
|
|
1600
|
+
const styleWords = styleKeywords(styleId, filterId)
|
|
1587
1601
|
const system = [
|
|
1588
1602
|
'你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。',
|
|
1603
|
+
'【当前视觉风格】(每类提示词必须内嵌):' + styleWords,
|
|
1589
1604
|
'四类:',
|
|
1590
1605
|
'1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。',
|
|
1591
1606
|
'2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。',
|
|
@@ -2444,6 +2459,238 @@ export async function summarizeChapter(
|
|
|
2444
2459
|
return chapter.summary
|
|
2445
2460
|
}
|
|
2446
2461
|
|
|
2462
|
+
/** 把分镜产出写入项目持久化(按章 upsert)。 */
|
|
2463
|
+
function saveChapterStoryboard(project: ProjectState, outputDir: string, entry: ChapterStoryboard): void {
|
|
2464
|
+
if (project.storyboards === undefined) project.storyboards = []
|
|
2465
|
+
const idx = project.storyboards.findIndex(e => e.chapterNo === entry.chapterNo)
|
|
2466
|
+
if (idx === -1) project.storyboards.push(entry)
|
|
2467
|
+
else project.storyboards[idx] = { ...project.storyboards[idx], ...entry }
|
|
2468
|
+
project.updatedAt = new Date().toISOString()
|
|
2469
|
+
saveProject(outputDir, project)
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
/**
|
|
2473
|
+
* 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
|
|
2474
|
+
* 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
|
|
2475
|
+
*/
|
|
2476
|
+
export async function generateStoryboardTable(
|
|
2477
|
+
ctx: Context,
|
|
2478
|
+
config: NovelConfig,
|
|
2479
|
+
project: ProjectState,
|
|
2480
|
+
outputDir: string,
|
|
2481
|
+
chapterNo: number,
|
|
2482
|
+
skeleton: StoryboardSkeleton,
|
|
2483
|
+
styleId?: string,
|
|
2484
|
+
filterId?: string,
|
|
2485
|
+
): Promise<StoryboardTable> {
|
|
2486
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2487
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
2488
|
+
const body = readChapterFile(outputDir, chapter)
|
|
2489
|
+
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
2490
|
+
if ((skeleton.beats ?? []).length === 0) throw new Error('骨架为空,请先生成剧情骨架')
|
|
2491
|
+
|
|
2492
|
+
|
|
2493
|
+
// 出场角色锚点:正文中出现过的角色名 + 身份(简短)。
|
|
2494
|
+
const roles = (project.roles ?? [])
|
|
2495
|
+
.filter(r => body.includes(r.name))
|
|
2496
|
+
.slice(0, 8)
|
|
2497
|
+
.map(r => `${r.name}(${r.roleLabel === 'protagonist' ? '主角' : r.roleLabel === 'antagonist' ? '反派' : r.roleLabel === 'female_lead' ? '女主' : '配角'}):${r.identity ?? ''}`)
|
|
2498
|
+
// 场景卡:仅列名称与定位(正文命中优先,至多 6 个)。
|
|
2499
|
+
const scenes = (project.scenes ?? [])
|
|
2500
|
+
.filter(s => body.includes(s.name))
|
|
2501
|
+
.slice(0, 6)
|
|
2502
|
+
.map(s => `${s.name}:${s.summary ?? ''}`)
|
|
2503
|
+
const rules = (project.visualRules ?? []).map(r => '- ' + r)
|
|
2504
|
+
|
|
2505
|
+
const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
|
|
2506
|
+
const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
|
|
2507
|
+
const styleLines: string[] = []
|
|
2508
|
+
if (baseStyle !== undefined) styleLines.push('- 基底风格「' + baseStyle.name + '」:' + baseStyle.keywords)
|
|
2509
|
+
if (filterStyle !== undefined) styleLines.push('- 叠加滤镜「' + filterStyle.name + '」:' + filterStyle.keywords)
|
|
2510
|
+
if (styleLines.length === 0) styleLines.push('- (未选择风格,默认 3D 动漫超精细建模质感)')
|
|
2511
|
+
|
|
2512
|
+
const system = [
|
|
2513
|
+
'你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
|
|
2514
|
+
'任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
|
|
2515
|
+
'输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态"}]}',
|
|
2516
|
+
'景别取值:远景/全景/中景/近景/特写。',
|
|
2517
|
+
'硬性要求:',
|
|
2518
|
+
'【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
|
|
2519
|
+
...styleLines,
|
|
2520
|
+
'1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-25。',
|
|
2521
|
+
'2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。',
|
|
2522
|
+
'3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。',
|
|
2523
|
+
'4. 台词/音效/光效无则空字符串,不要编造。',
|
|
2524
|
+
'5. duration 1-12 秒。',
|
|
2525
|
+
'6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。',
|
|
2526
|
+
].join('\n')
|
|
2527
|
+
const user = [
|
|
2528
|
+
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
2529
|
+
'==== 剧情骨架(只读,禁止改动) ====',
|
|
2530
|
+
`弧线:${skeleton.arc}`,
|
|
2531
|
+
skeleton.beats.map(b => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== undefined ? ';承接:' + b.cause : ''})`).join('\n'),
|
|
2532
|
+
roles.length > 0 ? '==== 出场角色锚点 ====\n' + roles.join('\n') : '',
|
|
2533
|
+
scenes.length > 0 ? '==== 相关场景 ====\n' + scenes.join('\n') : '',
|
|
2534
|
+
rules.length > 0 ? '==== 本书视觉规则(必须内嵌进 visual) ====\n' + rules.join('\n') : '',
|
|
2535
|
+
'==== 章节正文(画面细节以此为准) ====',
|
|
2536
|
+
body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
|
|
2537
|
+
].filter(s => s !== '').join('\n\n')
|
|
2538
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2539
|
+
const raw = parseJsonObject<{ shots?: unknown }>(text)
|
|
2540
|
+
const beatIds = new Set(skeleton.beats.map(b => b.id))
|
|
2541
|
+
const shotKinds = new Set(['远景', '全景', '中景', '近景', '特写'])
|
|
2542
|
+
const shots: StoryboardShot[] = Array.isArray(raw.shots)
|
|
2543
|
+
? raw.shots
|
|
2544
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
2545
|
+
.map((entry, i) => ({
|
|
2546
|
+
id: 's' + (i + 1),
|
|
2547
|
+
beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
|
|
2548
|
+
shot: shotKinds.has(entry.shot as string) ? entry.shot as string : '中景',
|
|
2549
|
+
camera: typeof entry.camera === 'string' ? entry.camera.trim().slice(0, 80) : '',
|
|
2550
|
+
duration: typeof entry.duration === 'number' && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
|
|
2551
|
+
visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
|
|
2552
|
+
line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
|
|
2553
|
+
sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
|
|
2554
|
+
light: typeof entry.light === 'string' ? entry.light.trim().slice(0, 80) : '',
|
|
2555
|
+
prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
|
|
2556
|
+
nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
|
|
2557
|
+
}))
|
|
2558
|
+
.filter(s => s.visual !== '')
|
|
2559
|
+
: []
|
|
2560
|
+
if (shots.length === 0) throw new Error('模型未输出有效镜头,请重试')
|
|
2561
|
+
// 覆盖自检:每个节拍至少 1 个镜头(缺失节拍告警)。
|
|
2562
|
+
const covered = new Set(shots.map(s => s.beatId))
|
|
2563
|
+
const missing = skeleton.beats.filter(b => !covered.has(b.id)).map(b => b.id)
|
|
2564
|
+
if (missing.length > 0) {
|
|
2565
|
+
console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(',')} 无镜头覆盖`)
|
|
2566
|
+
}
|
|
2567
|
+
const table: StoryboardTable = { chapterNo, shots }
|
|
2568
|
+
saveChapterStoryboard(project, outputDir, { chapterNo, table, updatedAt: new Date().toISOString() })
|
|
2569
|
+
return table
|
|
2570
|
+
}
|
|
2571
|
+
|
|
2572
|
+
/**
|
|
2573
|
+
* 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
|
|
2574
|
+
* 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
|
|
2575
|
+
* 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
|
|
2576
|
+
*/
|
|
2577
|
+
export async function generateStoryboardPrompts(
|
|
2578
|
+
ctx: Context,
|
|
2579
|
+
config: NovelConfig,
|
|
2580
|
+
project: ProjectState,
|
|
2581
|
+
outputDir: string,
|
|
2582
|
+
chapterNo: number,
|
|
2583
|
+
table: StoryboardTable,
|
|
2584
|
+
styleId?: string,
|
|
2585
|
+
filterId?: string,
|
|
2586
|
+
): Promise<StoryboardPrompt[]> {
|
|
2587
|
+
if ((table.shots ?? []).length === 0) throw new Error('分镜表为空,请先生成分镜表')
|
|
2588
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2589
|
+
const baseStyle = styleId !== undefined ? findStyle(styleId) : undefined
|
|
2590
|
+
const filterStyle = filterId !== undefined ? findStyle(filterId) : undefined
|
|
2591
|
+
const stylePrefix = [
|
|
2592
|
+
baseStyle?.keywords,
|
|
2593
|
+
filterStyle?.keywords,
|
|
2594
|
+
].filter((v): v is string => v !== undefined && v !== '').join(',') || '3D动漫,超精细建模,电影光影'
|
|
2595
|
+
const rules = (project.visualRules ?? []).map(r => '- ' + r)
|
|
2596
|
+
const shotLines = table.shots.map(s =>
|
|
2597
|
+
`[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
|
|
2598
|
+
画面:${s.visual}
|
|
2599
|
+
台词:${s.line !== '' ? s.line : '(无)'}
|
|
2600
|
+
光效:${s.light !== '' ? s.light : '(无)'}
|
|
2601
|
+
承接:${s.prevState} → ${s.nextState}`
|
|
2602
|
+
).join('\n\n')
|
|
2603
|
+
const system = [
|
|
2604
|
+
'你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。',
|
|
2605
|
+
'任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。',
|
|
2606
|
+
'提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。',
|
|
2607
|
+
'风格词块(必须原样放在每段开头):' + stylePrefix,
|
|
2608
|
+
'硬性要求:',
|
|
2609
|
+
'1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。',
|
|
2610
|
+
'2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。',
|
|
2611
|
+
'3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。',
|
|
2612
|
+
'4. 输出合法 JSON 对象:{"prompts": [{"shotId": "s1", "text": "..."}]},所有镜头都要有,顺序一致。',
|
|
2613
|
+
'5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。',
|
|
2614
|
+
].join('\n')
|
|
2615
|
+
const user = [
|
|
2616
|
+
`章节《${chapter?.title ?? ''}》(第 ${chapterNo} 章)`,
|
|
2617
|
+
rules.length > 0 ? '本书视觉规则(必须遵守):\n' + rules.join('\n') : '',
|
|
2618
|
+
'==== 分镜表 ====',
|
|
2619
|
+
shotLines,
|
|
2620
|
+
'只输出 JSON 对象。',
|
|
2621
|
+
].filter(x => x !== '').join('\n\n')
|
|
2622
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 6000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2623
|
+
const raw = parseJsonObject<{ prompts?: unknown }>(text)
|
|
2624
|
+
const shotIds = new Set(table.shots.map(s => s.id))
|
|
2625
|
+
const prompts: StoryboardPrompt[] = Array.isArray(raw.prompts)
|
|
2626
|
+
? raw.prompts
|
|
2627
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
2628
|
+
.map(entry => ({
|
|
2629
|
+
shotId: shotIds.has(entry.shotId as string) ? entry.shotId as string : '',
|
|
2630
|
+
text: typeof entry.text === 'string' ? entry.text.trim().slice(0, 400) : '',
|
|
2631
|
+
}))
|
|
2632
|
+
.filter(x => x.shotId !== '' && x.text !== '')
|
|
2633
|
+
: []
|
|
2634
|
+
if (prompts.length === 0) throw new Error('模型未输出有效提示词,请重试')
|
|
2635
|
+
saveChapterStoryboard(project, outputDir, { chapterNo, prompts, updatedAt: new Date().toISOString() })
|
|
2636
|
+
return prompts
|
|
2637
|
+
}
|
|
2638
|
+
|
|
2639
|
+
|
|
2640
|
+
/**
|
|
2641
|
+
* 分镜·编剧级:单章 → 剧情骨架(节拍链)。
|
|
2642
|
+
* 只做故事层(事件/情绪/功能/因果),不做画面;导演级分镜在其上展开。
|
|
2643
|
+
*/
|
|
2644
|
+
export async function generateStoryboardSkeleton(
|
|
2645
|
+
ctx: Context,
|
|
2646
|
+
config: NovelConfig,
|
|
2647
|
+
project: ProjectState,
|
|
2648
|
+
outputDir: string,
|
|
2649
|
+
chapterNo: number,
|
|
2650
|
+
): Promise<StoryboardSkeleton> {
|
|
2651
|
+
const chapter = project.chapters.find(c => c.no === chapterNo)
|
|
2652
|
+
if (chapter === undefined) throw new Error(`章节 ${chapterNo} 不在计划中`)
|
|
2653
|
+
const body = readChapterFile(outputDir, chapter)
|
|
2654
|
+
if (body === undefined) throw new Error(`章节 ${chapterNo} 的正文文件不存在`)
|
|
2655
|
+
const system = [
|
|
2656
|
+
'你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
|
|
2657
|
+
'任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
|
|
2658
|
+
'输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(如 压抑→隐忍→惊惧)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}]}',
|
|
2659
|
+
'硬性要求:',
|
|
2660
|
+
'1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
|
|
2661
|
+
'2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
|
|
2662
|
+
'3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。',
|
|
2663
|
+
'4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。',
|
|
2664
|
+
'5. 直接输出 JSON 本身,不要输出思考过程或其他文字。',
|
|
2665
|
+
].join('\n')
|
|
2666
|
+
const user = [
|
|
2667
|
+
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
2668
|
+
`剧情要点:${chapter.beats !== undefined && chapter.beats !== '' ? chapter.beats : '(未填写)'}`,
|
|
2669
|
+
'==================== 章节正文 ====================',
|
|
2670
|
+
body.replace(/^#\s+.*$/m, '').trim(),
|
|
2671
|
+
].join('\n')
|
|
2672
|
+
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
|
|
2673
|
+
const raw = parseJsonObject<{ arc?: unknown; beats?: unknown }>(text)
|
|
2674
|
+
const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
|
|
2675
|
+
const functions = new Set(['铺垫', '冲突', '转折', '高潮', '收束', '伏笔', '人物塑造'])
|
|
2676
|
+
const beats: StoryboardBeat[] = Array.isArray(raw.beats)
|
|
2677
|
+
? raw.beats
|
|
2678
|
+
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
2679
|
+
.map((entry, i) => ({
|
|
2680
|
+
id: 'b' + (i + 1),
|
|
2681
|
+
event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
|
|
2682
|
+
emotion: typeof entry.emotion === 'string' ? entry.emotion.trim().slice(0, 100) : '',
|
|
2683
|
+
function: functions.has(entry.function as string) ? entry.function as string : '铺垫',
|
|
2684
|
+
cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
|
|
2685
|
+
}))
|
|
2686
|
+
.filter(b => b.event !== '')
|
|
2687
|
+
: []
|
|
2688
|
+
if (beats.length === 0) throw new Error('模型未输出有效节拍链,请重试')
|
|
2689
|
+
const skeleton: StoryboardSkeleton = { chapterNo, arc: arc !== '' ? arc : '(本章弧线未生成)', beats }
|
|
2690
|
+
saveChapterStoryboard(project, outputDir, { chapterNo, skeleton, updatedAt: new Date().toISOString() })
|
|
2691
|
+
return skeleton
|
|
2692
|
+
}
|
|
2693
|
+
|
|
2447
2694
|
/**
|
|
2448
2695
|
* 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
|
|
2449
2696
|
* 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
|
package/src/protocol.ts
CHANGED
|
@@ -42,6 +42,14 @@ export const NOVEL_API = {
|
|
|
42
42
|
bookshelfImportText: '/api/dsh-novel-forge/bookshelf/import-text',
|
|
43
43
|
/** 导入 txt/md 全本:拆章预览(不落盘)。 */
|
|
44
44
|
bookshelfImportTextPreview: '/api/dsh-novel-forge/bookshelf/import-text/preview',
|
|
45
|
+
/** 漫剧方案管理:create/remove/activate。 */
|
|
46
|
+
manhuaPlans: '/api/dsh-novel-forge/manhua/plans',
|
|
47
|
+
/** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
|
|
48
|
+
storyboardSkeleton: '/api/dsh-novel-forge/storyboard/skeleton',
|
|
49
|
+
/** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
|
|
50
|
+
storyboardTable: '/api/dsh-novel-forge/storyboard/table',
|
|
51
|
+
/** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
|
|
52
|
+
storyboardPrompts: '/api/dsh-novel-forge/storyboard/prompts',
|
|
45
53
|
/** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
|
|
46
54
|
reset: '/api/dsh-novel-forge/reset',
|
|
47
55
|
/** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
|
|
@@ -194,6 +202,154 @@ export interface BookImportTextPreviewResponse {
|
|
|
194
202
|
skipped: string[]
|
|
195
203
|
}
|
|
196
204
|
|
|
205
|
+
/** 分镜·编剧级:剧情骨架中的一节拍。 */
|
|
206
|
+
export interface StoryboardBeat {
|
|
207
|
+
/** 节拍 id(如 b1、b2)。 */
|
|
208
|
+
id: string
|
|
209
|
+
/** 事件一句话(发生了什么)。 */
|
|
210
|
+
event: string
|
|
211
|
+
/** 情绪走向(如 压抑→隐忍→惊惧)。 */
|
|
212
|
+
emotion: string
|
|
213
|
+
/** 叙事功能:铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。 */
|
|
214
|
+
function: string
|
|
215
|
+
/** 因果:承接上一节拍的原因(可选)。 */
|
|
216
|
+
cause?: string
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** 分镜·编剧级:单章剧情骨架。 */
|
|
220
|
+
export interface StoryboardSkeleton {
|
|
221
|
+
chapterNo: number
|
|
222
|
+
/** 本章弧线一句话(起承转合)。 */
|
|
223
|
+
arc: string
|
|
224
|
+
/** 剧情节拍链(时间顺序,因果连贯)。 */
|
|
225
|
+
beats: StoryboardBeat[]
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** POST /storyboard/skeleton 请求:生成单章剧情骨架。 */
|
|
229
|
+
export interface StoryboardSkeletonRequest {
|
|
230
|
+
chapterNo: number
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** POST /storyboard/skeleton 响应。 */
|
|
234
|
+
export interface StoryboardSkeletonResponse {
|
|
235
|
+
skeleton: StoryboardSkeleton
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** 分镜·导演级:一个镜头。 */
|
|
239
|
+
export interface StoryboardShot {
|
|
240
|
+
/** 镜头 id(如 s1、s2)。 */
|
|
241
|
+
id: string
|
|
242
|
+
/** 挂载的节拍 id(骨架中的 b1…)。 */
|
|
243
|
+
beatId: string
|
|
244
|
+
/** 景别:远景/全景/中景/近景/特写。 */
|
|
245
|
+
shot: string
|
|
246
|
+
/** 机位与运镜(如 低机位仰拍 + 缓慢推近)。 */
|
|
247
|
+
camera: string
|
|
248
|
+
/** 时长(秒,1-12)。 */
|
|
249
|
+
duration: number
|
|
250
|
+
/** 画面内容:角色动作 + 表情 + 服装/标志物。 */
|
|
251
|
+
visual: string
|
|
252
|
+
/** 台词/旁白(无则空字符串)。 */
|
|
253
|
+
line: string
|
|
254
|
+
/** 音效(无则空字符串)。 */
|
|
255
|
+
sound: string
|
|
256
|
+
/** 光效(如 雨夜顶光 + 手机屏幕冷光)。 */
|
|
257
|
+
light: string
|
|
258
|
+
/** 承接上一镜头结尾状态(位置/动作/情绪/服装)。 */
|
|
259
|
+
prevState: string
|
|
260
|
+
/** 本镜头结束状态。 */
|
|
261
|
+
nextState: string
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** 分镜·导演级:单章分镜表。 */
|
|
265
|
+
export interface StoryboardTable {
|
|
266
|
+
chapterNo: number
|
|
267
|
+
shots: StoryboardShot[]
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/** POST /storyboard/table 请求:骨架 → 分镜表。 */
|
|
271
|
+
export interface StoryboardTableRequest {
|
|
272
|
+
chapterNo: number
|
|
273
|
+
/** 编剧级骨架(前端已生成/编辑后的版本)。 */
|
|
274
|
+
skeleton: StoryboardSkeleton
|
|
275
|
+
/** 漫剧方案基底风格 id(style-library);提供时按风格措辞画面描述。 */
|
|
276
|
+
styleId?: string
|
|
277
|
+
/** 可选滤镜风格 id(stackable)。 */
|
|
278
|
+
filterId?: string
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** POST /storyboard/table 响应。 */
|
|
282
|
+
export interface StoryboardTableResponse {
|
|
283
|
+
table: StoryboardTable
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
/** 分镜·提示词级:一个镜头的即梦视频提示词。 */
|
|
287
|
+
export interface StoryboardPrompt {
|
|
288
|
+
/** 对应镜头 id(s1…)。 */
|
|
289
|
+
shotId: string
|
|
290
|
+
/** 即梦/视频模型可粘贴的中文提示词(画面+运镜+光效+风格词块)。 */
|
|
291
|
+
text: string
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** 分镜持久化:单章的分镜产出(骨架 + 分镜表 + 视频提示词,可分别存在)。 */
|
|
295
|
+
export interface ChapterStoryboard {
|
|
296
|
+
chapterNo: number
|
|
297
|
+
skeleton?: StoryboardSkeleton
|
|
298
|
+
table?: StoryboardTable
|
|
299
|
+
prompts?: StoryboardPrompt[]
|
|
300
|
+
updatedAt: string
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/** POST /storyboard/prompts 请求:分镜表 → 视频提示词。 */
|
|
304
|
+
export interface StoryboardPromptsRequest {
|
|
305
|
+
chapterNo: number
|
|
306
|
+
/** 分镜表(前端已生成的镜头列表)。 */
|
|
307
|
+
table: StoryboardTable
|
|
308
|
+
/** 基底风格 id。 */
|
|
309
|
+
styleId?: string
|
|
310
|
+
/** 可选滤镜风格 id。 */
|
|
311
|
+
filterId?: string
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** POST /storyboard/prompts 响应。 */
|
|
315
|
+
export interface StoryboardPromptsResponse {
|
|
316
|
+
prompts: StoryboardPrompt[]
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
/** 漫剧方案:同一本书的多套视觉演绎(基底风格 + 可选滤镜)。 */
|
|
320
|
+
export interface MangaPlan {
|
|
321
|
+
/** 方案 id。 */
|
|
322
|
+
id: string
|
|
323
|
+
/** 方案名(如《保质期》3D 皮克斯版)。 */
|
|
324
|
+
name: string
|
|
325
|
+
/** 基底风格 id(style-library 中非 stackable 风格)。 */
|
|
326
|
+
styleId: string
|
|
327
|
+
/** 可选滤镜风格 id(style-library 中 stackable 风格)。 */
|
|
328
|
+
filterId?: string
|
|
329
|
+
/** 是否激活(分镜/角色图生成时使用)。 */
|
|
330
|
+
active: boolean
|
|
331
|
+
createdAt: string
|
|
332
|
+
updatedAt: string
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** POST /manhua/plans 请求:漫剧方案管理。 */
|
|
336
|
+
export interface MangaPlansRequest {
|
|
337
|
+
op: 'create' | 'remove' | 'activate'
|
|
338
|
+
/** create:方案名。 */
|
|
339
|
+
name?: string
|
|
340
|
+
/** create:基底风格 id。 */
|
|
341
|
+
styleId?: string
|
|
342
|
+
/** create:可选滤镜风格 id。 */
|
|
343
|
+
filterId?: string
|
|
344
|
+
/** remove/activate:方案 id。 */
|
|
345
|
+
id?: string
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** POST /manhua/plans 响应。 */
|
|
349
|
+
export interface MangaPlansResponse {
|
|
350
|
+
plans: MangaPlan[]
|
|
351
|
+
}
|
|
352
|
+
|
|
197
353
|
|
|
198
354
|
/** Chapter lifecycle states (the writing pipeline's state machine). */
|
|
199
355
|
export type ChapterStatus =
|
|
@@ -709,6 +865,10 @@ export interface RolesRequest {
|
|
|
709
865
|
label?: string
|
|
710
866
|
/** op='imageGenerate' 时的漫画风格预设 id(用于统一角色图与漫画页风格)。 */
|
|
711
867
|
style?: string
|
|
868
|
+
/** op='visual' / op='promptKit' 时的漫剧基底风格 id(角色图按方案风格重出)。 */
|
|
869
|
+
styleId?: string
|
|
870
|
+
/** op='visual' / op='promptKit' 时的可选滤镜风格 id。 */
|
|
871
|
+
filterId?: string
|
|
712
872
|
}
|
|
713
873
|
|
|
714
874
|
/** POST /roles 响应。 */
|
|
@@ -854,6 +1014,10 @@ export interface ProjectState {
|
|
|
854
1014
|
scenes?: SceneCard[]
|
|
855
1015
|
/** 视觉世界观规则(生图/生视频必须遵守,从道藏提炼,注入所有提示词)。 */
|
|
856
1016
|
visualRules?: string[]
|
|
1017
|
+
/** 分镜工作台产出(骨架/分镜表,按章持久化,刷新/切换不丢)。 */
|
|
1018
|
+
storyboards?: ChapterStoryboard[]
|
|
1019
|
+
/** 漫剧方案列表(同一本书的多套视觉演绎:基底风格 + 可选滤镜)。 */
|
|
1020
|
+
mangaPlans?: MangaPlan[]
|
|
857
1021
|
/** 人物志:角色当前状态聚合结果(从编年录刷新后存档,打开页面直接显示)。 */
|
|
858
1022
|
roleStatus?: RoleStatusCard[]
|
|
859
1023
|
/** 漫剧分镜结果缓存:章号 → 分镜(持久化,刷新不丢)。 */
|