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