@waterwx/dsh-novel-forge 1.7.2 → 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 +918 -287
- package/lib/client.js.map +1 -1
- package/lib/index.js +372 -42
- package/lib/index.js.map +1 -1
- package/lib/types/protocol.d.ts +14 -10
- package/lib/types/shot-language.d.ts +46 -0
- package/lib/types/story-beat-language.d.ts +35 -0
- package/package.json +4 -2
- package/skills/chapter-batch/SKILL.md +81 -0
- package/src/client/panel/CreateBookView.tsx +4 -0
- package/src/client/panel/ImportModal.tsx +11 -2
- package/src/client/panel/NovelPanel.tsx +1 -0
- package/src/client/panel/RunPanel.tsx +1 -1
- package/src/client/panel/SceneLibrary.tsx +1 -1
- package/src/client/panel/ShelfView.tsx +2 -2
- package/src/client/panel/StoryboardTab.tsx +42 -10
- package/src/engine.ts +35 -14
- package/src/index.ts +1 -0
- package/src/protocol.ts +22 -10
- package/src/routes.ts +13 -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/lib/types/protocol.d.ts
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
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 { ShotSizeId, CameraMoveId, CompositionId, LightingId } from './shot-language.ts';
|
|
8
|
+
import type { StoryFunctionId, EmotionId } from './story-beat-language.ts';
|
|
7
9
|
/** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
|
|
8
10
|
export declare const NOVEL_API: {
|
|
9
11
|
readonly status: "/api/dsh-novel-forge/status";
|
|
@@ -208,10 +210,10 @@ export interface StoryboardBeat {
|
|
|
208
210
|
id: string;
|
|
209
211
|
/** 事件一句话(发生了什么)。 */
|
|
210
212
|
event: string;
|
|
211
|
-
/**
|
|
212
|
-
emotion:
|
|
213
|
-
/**
|
|
214
|
-
function:
|
|
213
|
+
/** 情绪走向(情绪词 id 数组,来自 story-beat-language 词库,链式)。 */
|
|
214
|
+
emotion: EmotionId[];
|
|
215
|
+
/** 叙事功能(枚举 id,来自 story-beat-language 词库)。 */
|
|
216
|
+
function: StoryFunctionId;
|
|
215
217
|
/** 因果:承接上一节拍的原因(可选)。 */
|
|
216
218
|
cause?: string;
|
|
217
219
|
}
|
|
@@ -239,10 +241,12 @@ export interface StoryboardShot {
|
|
|
239
241
|
id: string;
|
|
240
242
|
/** 挂载的节拍 id(骨架中的 b1…)。 */
|
|
241
243
|
beatId: string;
|
|
242
|
-
/**
|
|
243
|
-
shot:
|
|
244
|
-
/**
|
|
245
|
-
camera:
|
|
244
|
+
/** 景别(枚举 id,来自 shot-language 词库)。 */
|
|
245
|
+
shot: ShotSizeId;
|
|
246
|
+
/** 机位与运镜(枚举 id 数组,来自 shot-language 词库)。 */
|
|
247
|
+
camera: CameraMoveId[];
|
|
248
|
+
/** 构图(可选,枚举 id)。 */
|
|
249
|
+
composition?: CompositionId;
|
|
246
250
|
/** 时长(秒,1-12)。 */
|
|
247
251
|
duration: number;
|
|
248
252
|
/** 画面内容:角色动作 + 表情 + 服装/标志物。 */
|
|
@@ -251,8 +255,8 @@ export interface StoryboardShot {
|
|
|
251
255
|
line: string;
|
|
252
256
|
/** 音效(无则空字符串)。 */
|
|
253
257
|
sound: string;
|
|
254
|
-
/**
|
|
255
|
-
light:
|
|
258
|
+
/** 光效(枚举 id 数组,来自 shot-language 词库)。 */
|
|
259
|
+
light: LightingId[];
|
|
256
260
|
/** 承接上一镜头结尾状态(位置/动作/情绪/服装)。 */
|
|
257
261
|
prevState: string;
|
|
258
262
|
/** 本镜头结束状态。 */
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 镜头语言词库(分镜规范化基准)。
|
|
3
|
+
* 分镜表的 shot/camera/composition/light 字段统一从这里取值(英文 id),
|
|
4
|
+
* 生成提示词时用 zh 中文词块;适配英文平台时用 en。
|
|
5
|
+
* 目的:让"景别/运镜/构图/光效"有标准词表,避免 LLM 自由发挥导致漂移。
|
|
6
|
+
*/
|
|
7
|
+
/** 景别。 */
|
|
8
|
+
export type ShotSizeId = 'extreme_wide' | 'wide' | 'full' | 'medium' | 'medium_close' | 'close' | 'extreme_close' | 'big_extreme_close';
|
|
9
|
+
/** 运镜/机位。 */
|
|
10
|
+
export type CameraMoveId = 'static' | 'dolly_in' | 'dolly_out' | 'pan_left' | 'pan_right' | 'track_left' | 'track_right' | 'follow' | 'pedestal_up' | 'pedestal_down' | 'orbit' | 'handheld' | 'low_angle' | 'high_angle' | 'over_shoulder';
|
|
11
|
+
/** 构图。 */
|
|
12
|
+
export type CompositionId = 'rule_of_thirds' | 'center' | 'leading_line' | 'foreground' | 'low' | 'overhead' | 'symmetry';
|
|
13
|
+
/** 光效。 */
|
|
14
|
+
export type LightingId = 'front' | 'side' | 'back' | 'top' | 'rembrandt' | 'neon' | 'hard' | 'soft' | 'mood' | 'contrast';
|
|
15
|
+
export interface ShotLangEntry<T extends string> {
|
|
16
|
+
id: T;
|
|
17
|
+
/** 中文(提示词/展示用)。 */
|
|
18
|
+
zh: string;
|
|
19
|
+
/** 英文(英文平台适配用)。 */
|
|
20
|
+
en: string;
|
|
21
|
+
/** 一句话说明(下拉/卡片)。 */
|
|
22
|
+
hint: string;
|
|
23
|
+
}
|
|
24
|
+
export declare const SHOT_SIZES: readonly ShotLangEntry<ShotSizeId>[];
|
|
25
|
+
export declare const CAMERA_MOVES: readonly ShotLangEntry<CameraMoveId>[];
|
|
26
|
+
export declare const COMPOSITIONS: readonly ShotLangEntry<CompositionId>[];
|
|
27
|
+
export declare const LIGHTINGS: readonly ShotLangEntry<LightingId>[];
|
|
28
|
+
export interface ShotLanguage {
|
|
29
|
+
shot?: ShotSizeId;
|
|
30
|
+
camera: CameraMoveId[];
|
|
31
|
+
composition?: CompositionId;
|
|
32
|
+
light: LightingId[];
|
|
33
|
+
}
|
|
34
|
+
/** 从中文文本归一化到景别(处理旧数据/LLM 口语)。未知回退 medium。 */
|
|
35
|
+
export declare function normalizeShotSize(text: string | undefined): ShotSizeId;
|
|
36
|
+
/** 从中文文本归一化到运镜列表。 */
|
|
37
|
+
export declare function normalizeCameras(text: string | undefined): CameraMoveId[];
|
|
38
|
+
/** 从中文文本归一化到构图(可选)。 */
|
|
39
|
+
export declare function normalizeComposition(text: string | undefined): CompositionId | undefined;
|
|
40
|
+
/** 从中文文本归一化到光效列表。 */
|
|
41
|
+
export declare function normalizeLightings(text: string | undefined): LightingId[];
|
|
42
|
+
/** 取词条中文(ZHs 合并句子)。 */
|
|
43
|
+
export declare function sizeZh(id: ShotSizeId | undefined): string;
|
|
44
|
+
export declare function cameraZh(ids: CameraMoveId[] | undefined): string;
|
|
45
|
+
export declare function compoZh(id: CompositionId | undefined): string;
|
|
46
|
+
export declare function lightZh(ids: LightingId[] | undefined): string;
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 编剧词库(剧情骨架规范化基准)。
|
|
3
|
+
* 骨架的 function(叙事功能)与 emotion(情绪走向)统一从这里取值(英文 id),
|
|
4
|
+
* 生成提示词/展示时用 zh 中文。
|
|
5
|
+
* 目的:让"叙事功能/情绪"有标准词表,避免 LLM 自由发挥导致情绪链漂移。
|
|
6
|
+
*/
|
|
7
|
+
/** 叙事功能(对应节拍在剧情中的作用)。 */
|
|
8
|
+
export type StoryFunctionId = 'exposition' | 'conflict' | 'turn' | 'climax' | 'resolve' | 'foreshadow' | 'character';
|
|
9
|
+
/** 情绪词(低→高能量的情绪阶,角色在节拍中的主要情绪点)。 */
|
|
10
|
+
export type EmotionId = 'calm' | 'indifferent' | 'expectant' | 'curious' | 'alert' | 'suppressed' | 'enduring' | 'worried' | 'irritable' | 'uneasy' | 'terrified' | 'angry' | 'collapsing' | 'resolute' | 'grieved' | 'relieved' | 'bittersweet' | 'triumphant' | 'reborn' | 'numb';
|
|
11
|
+
export interface BeatLangEntry<T extends string> {
|
|
12
|
+
id: T;
|
|
13
|
+
/** 中文(展示用)。 */
|
|
14
|
+
zh: string;
|
|
15
|
+
/** 英文(英文平台适配用)。 */
|
|
16
|
+
en: string;
|
|
17
|
+
/** 一句话说明。 */
|
|
18
|
+
hint: string;
|
|
19
|
+
}
|
|
20
|
+
/** 叙事功能词表。 */
|
|
21
|
+
export declare const STORY_FUNCTIONS: readonly BeatLangEntry<StoryFunctionId>[];
|
|
22
|
+
/** 情绪词表(按能量/阶段排序)。 */
|
|
23
|
+
export declare const EMOTIONS: readonly BeatLangEntry<EmotionId>[];
|
|
24
|
+
export interface StoryBeatLanguage {
|
|
25
|
+
function: StoryFunctionId;
|
|
26
|
+
emotion: EmotionId[];
|
|
27
|
+
}
|
|
28
|
+
/** 从中文归一化叙事功能(未知回退 exposition)。 */
|
|
29
|
+
export declare function normalizeStoryFunction(text: string | undefined): StoryFunctionId;
|
|
30
|
+
/** 从中文文本归一化情绪词列表(可含→箭头链)。 */
|
|
31
|
+
export declare function normalizeEmotions(text: string | undefined): EmotionId[];
|
|
32
|
+
/** 叙事功能中文。 */
|
|
33
|
+
export declare function functionZh(id: StoryFunctionId | undefined): string;
|
|
34
|
+
/** 情绪中文(箭头连接)。 */
|
|
35
|
+
export declare function emotionZh(ids: EmotionId[] | undefined): string;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@waterwx/dsh-novel-forge",
|
|
3
3
|
"description": "AI 编译小说工作台 for the dsh web GUI: import a novel outline (docx/text), plan chapters with LLM, generate chapter by chapter (3000-4000 chars each), and save every chapter as Markdown into your chosen output folder.",
|
|
4
|
-
"version": "1.7.
|
|
4
|
+
"version": "1.7.3",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -80,9 +80,9 @@
|
|
|
80
80
|
"lib/**/*.js",
|
|
81
81
|
"lib/**/*.js.map",
|
|
82
82
|
"lib/**/*.d.ts",
|
|
83
|
-
"lib/**/*.d.ts.map",
|
|
84
83
|
"src",
|
|
85
84
|
"assets/styles",
|
|
85
|
+
"skills",
|
|
86
86
|
"scripts/patch-schemastery.mjs",
|
|
87
87
|
"cordis.patch.yml",
|
|
88
88
|
"README.md"
|
|
@@ -95,6 +95,8 @@
|
|
|
95
95
|
"watch": "tsdown --watch",
|
|
96
96
|
"typecheck": "tsc --noEmit",
|
|
97
97
|
"check:styles": "node scripts/check-theme-sizes.mjs",
|
|
98
|
+
"release": "node scripts/release.mjs",
|
|
99
|
+
"release:dry": "node scripts/release.mjs --dry-run",
|
|
98
100
|
"test": "vitest run"
|
|
99
101
|
}
|
|
100
102
|
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: novel-forge-chapter-batch
|
|
3
|
+
description: 批量生成小说章节并值守处理审稿未过的章节。当用户说「生成 N 章」「连写」「批量生成」「继续写章节」「值守处理未过章节」时使用。依赖 dsh-novel-forge 插件(http://127.0.0.1:3080/api/dsh-novel-forge/*)。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# 小说章节批量生产与值守处理
|
|
7
|
+
|
|
8
|
+
把「批量生成章节 + 审稿未过处理」固化为可复现流程。所有操作通过 dsh-novel-forge 的 HTTP API 执行。
|
|
9
|
+
|
|
10
|
+
## 0. 值守纪律(最高优先级,违反即空转)
|
|
11
|
+
|
|
12
|
+
1. **启动后台任务后立即停下,不轮询、不连续 wait、不做无意义动作**——后台任务(批量生成/处理)由运行时在完成时自动通知。
|
|
13
|
+
2. 只有收到「任务完成」通知或用户新消息时,才继续下一步(读结果 → 处理未过 → 汇报)。
|
|
14
|
+
3. 等待期间可以做「与当前任务无关」的其他有用工作(如用户提出的其他问题、代码改进),但不得为等任务而空转。
|
|
15
|
+
4. 任务因 web 重启中断时:等用户确认或检测到 3080 恢复后,复位卡死章节(generating/error → reset)再续跑,并向用户说明中断原因。
|
|
16
|
+
|
|
17
|
+
## 1. 前置检查(每次必做)
|
|
18
|
+
|
|
19
|
+
1. 确认服务在跑:`GET /api/dsh-novel-forge/status` 返回 200,读 `project.chapters` 数量与书名。
|
|
20
|
+
2. 确认目标章节状态:列出 `pending`(待生成)章节号区间,避免重复生成已 approved/rejected 的章。
|
|
21
|
+
3. **串行执行,禁止并行写 project.json**——多个脚本同时调 API 会造成状态互相覆盖(实测教训:第 166 章曾被并发回滚成 rejected)。
|
|
22
|
+
|
|
23
|
+
## 2. 批量生成
|
|
24
|
+
|
|
25
|
+
对目标章节区间(如 181-205)逐章串行调用:
|
|
26
|
+
|
|
27
|
+
```
|
|
28
|
+
POST /api/dsh-novel-forge/generate
|
|
29
|
+
{ "chapterNo": N, "skipReview": false }
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
- `skipReview: false` 走完整质量门(生成 → 摘要+编年录 → AI 审稿 → 作者复盘)。
|
|
33
|
+
- 响应为 NDJSON 流;找 `{"type":"review","report":{...}}` 帧取 `score`/`passed`。
|
|
34
|
+
- 每章 2-5 分钟;失败自动重试 1 次(间隔 3 秒)。
|
|
35
|
+
- 预估总时长并告知用户(25 章 ≈ 45-75 分钟),放后台跑,期间可并行做别的(只读操作不受影响)。
|
|
36
|
+
- 推荐用 Node 脚本(fetch + 逐章循环),避免 PowerShell 的引号/UTF-8 转义坑;脚本用完删除。
|
|
37
|
+
|
|
38
|
+
## 2. 未过章节处理(分级策略)
|
|
39
|
+
|
|
40
|
+
生成完成后汇总:通过 / 未过(rejected)/ 失败(error)。对未过章按此分级:
|
|
41
|
+
|
|
42
|
+
### A. 无 high(全是 medium/low 主观项)→ 豁免通过
|
|
43
|
+
```
|
|
44
|
+
POST /api/dsh-novel-forge/chapter/approve { "chapterNo": N }
|
|
45
|
+
```
|
|
46
|
+
主观项(套话/节奏/比喻/心理描写)不值得逐章磨,直接通过。
|
|
47
|
+
|
|
48
|
+
### B. 有 high 且问题明确(逻辑/设定/时间线/用词)→ 按意见修订 + 验证模式
|
|
49
|
+
1. 取 review 的 high(无 high 取前 3 条 medium),拼指令:
|
|
50
|
+
`按审稿意见修订(优先处理):\n[severity] item → suggestion`
|
|
51
|
+
2. `POST /rewrite` `{ chapterNo, instructions }` → 产出 pendingDraft
|
|
52
|
+
3. **验证模式**:`POST /chapter/check` `{ chapterNo, text: draft, previousReport: review }`——携带上一轮报告,LLM 只核对原意见是否解决 + 只挑新增 high,不全新找茬
|
|
53
|
+
4. 判定:`passed || issues 全非 high` → 可接受
|
|
54
|
+
5. 可接受 → `POST /draft/apply` `{ chapterNo, report: { score: max(score,70), passed: true, issues: [], ... } }` 落盘为 approved
|
|
55
|
+
6. 不可接受 → 看剩余 high 是否明确可修 → 第二轮修订(指令更精确,如「把第 141 章的养伤时间线统一为七天」)→ 再验证
|
|
56
|
+
7. **每章最多修订 2 轮**,仍不过 → 保留草稿,标记「待人工」,不硬来
|
|
57
|
+
|
|
58
|
+
### C. 有 high 但结构性(修订改不好,如逻辑矛盾被坐实)→ 重新生成
|
|
59
|
+
- `POST /draft/discard` 清草稿 → `POST /generate` 整章重写
|
|
60
|
+
- 重新生成后若只剩主观项 → 走 A 豁免
|
|
61
|
+
|
|
62
|
+
### D. error 状态(无审稿、chars 丢失)→ 重置重生成
|
|
63
|
+
- `POST /chapter/reset` `{ chapterNo }` → `POST /generate`
|
|
64
|
+
|
|
65
|
+
### 特殊注意
|
|
66
|
+
- **approved 但 review 有 high**:已通过状态保留,不折腾(high 可能已被正文处理或审稿误标)。
|
|
67
|
+
- **修订指令越精确越有效**:具体数字/行为(如「茶梗尺寸统一为半截指节」)一次就过;泛泛的「修正矛盾」容易改不到位。
|
|
68
|
+
|
|
69
|
+
## 3. 收尾
|
|
70
|
+
|
|
71
|
+
1. 最终验证:`GET /status` 读目标区间全部 approved。
|
|
72
|
+
2. 可选:对涉及剧情的章节刷新剧情线进度(`POST /plotlines` `{ op:'refresh', id }`)——注意刷新函数只读最近 8 章摘要,早期章节的线可能刷不准,必要时手动 update progress + link 章节。
|
|
73
|
+
3. 向用户汇报:通过/未过/失败数量 + 每章处理方式 + 遗留待人工项。
|
|
74
|
+
|
|
75
|
+
## 4. 已知陷阱(血泪教训)
|
|
76
|
+
|
|
77
|
+
- **并发写 project.json 会互相覆盖**——多个生成/处理任务必须串行。
|
|
78
|
+
- **rewrite 的草稿可能不落盘**(偶发 500 或时序)——应用前检查 `pendingDraft` 是否存在,不存在则重跑 rewrite。
|
|
79
|
+
- **PowerShell 调 API 的引号/中文转义是坑**——统一用 Node 脚本。
|
|
80
|
+
- **status 接口会做数据快照**——以磁盘 novel-project.json 为准复核关键状态。
|
|
81
|
+
- **验证模式 500 偶发**——重试一次即可(LLM 瞬时错误)。
|
|
@@ -188,7 +188,11 @@ export function CreateBookView({
|
|
|
188
188
|
<label className={css.fieldLabel}>大纲</label>
|
|
189
189
|
<div
|
|
190
190
|
className={`${css.dropzone} ${dragActive ? css.dropzoneActive : ''}`}
|
|
191
|
+
role="button"
|
|
192
|
+
tabIndex={0}
|
|
193
|
+
aria-label="选择或拖入 docx 大纲文件"
|
|
191
194
|
onClick={() => { outlineFileRef.current?.click() }}
|
|
195
|
+
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); outlineFileRef.current?.click() } }}
|
|
192
196
|
onDragOver={e => { e.preventDefault(); setDragActive(true) }}
|
|
193
197
|
onDragLeave={() => { setDragActive(false) }}
|
|
194
198
|
onDrop={e => {
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* B) txt/md 全本 → 点击选择文件,浏览器读取内容 → 服务器拆章预览 → 确认后导入。
|
|
5
5
|
* Mode B 也保留「服务器本地路径」高级选项(大文件或服务器端已有文件时用)。
|
|
6
6
|
*/
|
|
7
|
-
import { useRef, useState } from 'react'
|
|
7
|
+
import { useEffect, useRef, useState } from 'react'
|
|
8
8
|
import type { NovelApi } from '../api.ts'
|
|
9
9
|
import type { BookImportTextPreviewResponse } from '../../protocol.ts'
|
|
10
10
|
import css from './panel.module.css'
|
|
@@ -118,8 +118,17 @@ export function ImportModal({
|
|
|
118
118
|
|
|
119
119
|
const switchMode = (m: ImportMode) => { setMode(m); setError(''); setResult(null); setPreview(null) }
|
|
120
120
|
|
|
121
|
+
// Esc 关闭弹窗。
|
|
122
|
+
useEffect(() => {
|
|
123
|
+
const onKey = (e: KeyboardEvent): void => {
|
|
124
|
+
if (e.key === 'Escape') onClose()
|
|
125
|
+
}
|
|
126
|
+
window.addEventListener('keydown', onKey)
|
|
127
|
+
return () => { window.removeEventListener('keydown', onKey) }
|
|
128
|
+
}, [onClose])
|
|
129
|
+
|
|
121
130
|
return (
|
|
122
|
-
<div className={css.importModalOverlay} onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
131
|
+
<div className={css.importModalOverlay} role="dialog" aria-modal="true" aria-label="导入小说" onClick={e => { if (e.target === e.currentTarget) onClose() }}>
|
|
123
132
|
<div className={css.importModal}>
|
|
124
133
|
<div className={css.importModalHead}>
|
|
125
134
|
<span className={css.panelTitle} style={{ margin: 0 }}>📥 导入小说</span>
|
|
@@ -138,6 +138,7 @@ function statusBadge(chapter: ChapterPlan): { cls: string; label: string } {
|
|
|
138
138
|
case 'approved': return { cls: css.badgeDone, label: tt('plan.approved') }
|
|
139
139
|
case 'rejected': return { cls: css.badgeRejected, label: tt('plan.rejected') }
|
|
140
140
|
case 'error': return { cls: css.badgeError, label: tt('plan.error') }
|
|
141
|
+
default: return { cls: css.badgeError, label: tt('plan.error') }
|
|
141
142
|
}
|
|
142
143
|
}
|
|
143
144
|
|
|
@@ -169,7 +169,7 @@ export function RunPanel({ api, totalChapters }: { api: NovelApi; totalChapters:
|
|
|
169
169
|
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-6)' }}>
|
|
170
170
|
<div className={css.row} style={{ flexWrap: 'wrap', gap: 'var(--nf-space-8)' }}>
|
|
171
171
|
<span className={css.meta}>范围:第 {run.startNo} - {run.endNo} 章 · 当前:第 {run.currentNo} 章</span>
|
|
172
|
-
<span className={css.meta}>新生成 {run.stats
|
|
172
|
+
<span className={css.meta}>新生成 {run.stats?.generated ?? 0} · 修订通过 {run.stats?.revised ?? 0} · 豁免 {run.stats?.exempted ?? 0} · 重生成 {run.stats?.regenerated ?? 0} · 失败 {run.stats?.error ?? 0}</span>
|
|
173
173
|
</div>
|
|
174
174
|
<div className={css.bigProgressBar}>
|
|
175
175
|
<div className={css.bigProgressBarFill} style={{ width: `${Math.round(ratio * 100)}%` }} />
|
|
@@ -266,7 +266,7 @@ export function SceneLibrary({
|
|
|
266
266
|
{scenes.map(s => {
|
|
267
267
|
const cover = (s.gallery ?? [])[0]
|
|
268
268
|
return (
|
|
269
|
-
<div key={s.name} onClick={() => { openDetail(s.name) }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
269
|
+
<div key={s.name} onClick={() => { openDetail(s.name) }} role="button" tabIndex={0} aria-label={`打开场景 ${s.name}`} onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openDetail(s.name) } }} style={{ cursor: 'pointer', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-12)', overflow: 'hidden', background: 'var(--nf-bg-inset)', display: 'flex', flexDirection: 'column' }}>
|
|
270
270
|
<div style={{ height: 100, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'var(--nf-bg-inset)', borderBottom: '1px solid var(--nf-border)', position: 'relative' }}>
|
|
271
271
|
{cover !== undefined
|
|
272
272
|
? <img src={cover.dataUrl} alt={s.name} style={{ width: '100%', height: '100%', objectFit: 'cover' }} />
|
|
@@ -223,14 +223,14 @@ export function ShelfView({
|
|
|
223
223
|
))}
|
|
224
224
|
|
|
225
225
|
{/* 开书入口:进入独立向导页 */}
|
|
226
|
-
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook}>
|
|
226
|
+
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onAddBook} role="button" tabIndex={0} aria-label="开一本新书" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onAddBook() } }}>
|
|
227
227
|
<div className={css.bookAddIcon}><Plus size={28} /></div>
|
|
228
228
|
<span>开一本新书</span>
|
|
229
229
|
<span className={css.meta}>书名 + 大纲,开书即建项目</span>
|
|
230
230
|
</div>
|
|
231
231
|
|
|
232
232
|
{/* 导入入口:已有项目目录 / txt/md 全本 */}
|
|
233
|
-
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook}>
|
|
233
|
+
<div className={`${css.bookCard} ${css.bookAddCard}`} onClick={onImportBook} role="button" tabIndex={0} aria-label="导入已有小说" onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); onImportBook() } }}>
|
|
234
234
|
<div className={css.bookAddIcon}><Download size={28} /></div>
|
|
235
235
|
<span>导入已有小说</span>
|
|
236
236
|
<span className={css.meta}>项目目录或 txt/md 全本</span>
|
|
@@ -5,8 +5,25 @@
|
|
|
5
5
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
6
6
|
import type { NovelApi } from '../api.ts'
|
|
7
7
|
import type { ChapterPlan, ProjectState, StoryboardPrompt, StoryboardSkeleton, StoryboardTable } from '../../protocol.ts'
|
|
8
|
+
import {
|
|
9
|
+
sizeZh, cameraZh, compoZh, lightZh,
|
|
10
|
+
normalizeShotSize, normalizeCameras, normalizeComposition, normalizeLightings,
|
|
11
|
+
} from '../../shot-language.ts'
|
|
12
|
+
import { functionZh, emotionZh, normalizeStoryFunction, normalizeEmotions } from '../../story-beat-language.ts'
|
|
13
|
+
import type { StoryboardShot } from '../../protocol.ts'
|
|
8
14
|
import css from './panel.module.css'
|
|
9
15
|
|
|
16
|
+
/** 归一化旧分镜数据:把自由文本 shot/camera/light/composition 转成词库枚举。 */
|
|
17
|
+
function normalizeStoryboardShot(s: StoryboardShot): StoryboardShot {
|
|
18
|
+
return {
|
|
19
|
+
...s,
|
|
20
|
+
shot: normalizeShotSize(typeof s.shot === 'string' ? s.shot : undefined),
|
|
21
|
+
camera: normalizeCameras(typeof s.camera === 'string' ? s.camera : undefined),
|
|
22
|
+
composition: typeof s.composition === 'string' ? normalizeComposition(s.composition) : s.composition,
|
|
23
|
+
light: normalizeLightings(typeof s.light === 'string' ? s.light : undefined),
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
10
27
|
export function StoryboardTab({
|
|
11
28
|
api,
|
|
12
29
|
project,
|
|
@@ -76,8 +93,23 @@ export function StoryboardTab({
|
|
|
76
93
|
if (chapterNo === null) return
|
|
77
94
|
if (suppressRestoreRef.current === true) return
|
|
78
95
|
const entry = (project?.storyboards ?? []).find(e => e.chapterNo === chapterNo)
|
|
79
|
-
setSkeleton(prev =>
|
|
80
|
-
|
|
96
|
+
setSkeleton(prev => {
|
|
97
|
+
if (prev !== null) return prev
|
|
98
|
+
if (entry?.skeleton === undefined) return null
|
|
99
|
+
return {
|
|
100
|
+
...entry.skeleton,
|
|
101
|
+
beats: (entry.skeleton.beats ?? []).map(b => ({
|
|
102
|
+
...b,
|
|
103
|
+
function: normalizeStoryFunction(typeof b.function === 'string' ? b.function : undefined),
|
|
104
|
+
emotion: normalizeEmotions(typeof b.emotion === 'string' ? b.emotion : b.emotion?.join('→')),
|
|
105
|
+
})),
|
|
106
|
+
}
|
|
107
|
+
})
|
|
108
|
+
setTable(prev => {
|
|
109
|
+
if (prev !== null) return prev
|
|
110
|
+
if (entry?.table === undefined) return null
|
|
111
|
+
return { ...entry.table, shots: (entry.table.shots ?? []).map(normalizeStoryboardShot) }
|
|
112
|
+
})
|
|
81
113
|
setPrompts(prev => prev ?? entry?.prompts ?? null)
|
|
82
114
|
}, [chapterNo, project?.storyboards])
|
|
83
115
|
|
|
@@ -268,8 +300,8 @@ export function StoryboardTab({
|
|
|
268
300
|
<div key={b.id} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
|
|
269
301
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
270
302
|
<b>节拍 {i + 1}</b>
|
|
271
|
-
<span className={`${css.badge} ${b.function === '
|
|
272
|
-
<span className={css.meta}
|
|
303
|
+
<span className={`${css.badge} ${b.function === 'climax' ? css.badgeDone : b.function === 'turn' ? css.badgeWritten : css.badgePending}`}>{functionZh(b.function)}</span>
|
|
304
|
+
<span className={css.meta}>💭 {emotionZh(b.emotion)}</span>
|
|
273
305
|
</div>
|
|
274
306
|
<span>{b.event}</span>
|
|
275
307
|
{b.cause !== undefined && <span className={css.meta}>承接:{b.cause}</span>}
|
|
@@ -280,7 +312,7 @@ export function StoryboardTab({
|
|
|
280
312
|
type="button"
|
|
281
313
|
className={`${css.button} ${css.buttonSmall}`}
|
|
282
314
|
onClick={() => {
|
|
283
|
-
const text = `第${skeleton.chapterNo}章 弧线:${skeleton.arc}\n出场角色:${skeleton.characters !== undefined && skeleton.characters.length > 0 ? skeleton.characters.join('、') : '(未标注)'}\n\n` + skeleton.beats.map((b, i) => `${i + 1}. [${b.function}] ${b.event}(情绪:${b.emotion})${b.cause !== undefined ? `[承接:${b.cause}]` : ''}`).join('\n')
|
|
315
|
+
const text = `第${skeleton.chapterNo}章 弧线:${skeleton.arc}\n出场角色:${skeleton.characters !== undefined && skeleton.characters.length > 0 ? skeleton.characters.join('、') : '(未标注)'}\n\n` + skeleton.beats.map((b, i) => `${i + 1}. [${functionZh(b.function)}] ${b.event}(情绪:${emotionZh(b.emotion)})${b.cause !== undefined ? `[承接:${b.cause}]` : ''}`).join('\n')
|
|
284
316
|
void navigator.clipboard?.writeText(text)
|
|
285
317
|
}}
|
|
286
318
|
>
|
|
@@ -318,7 +350,7 @@ export function StoryboardTab({
|
|
|
318
350
|
onClick={() => {
|
|
319
351
|
const text = table.shots.map(s => {
|
|
320
352
|
const beat = skeleton?.beats.find(b => b.id === s.beatId)
|
|
321
|
-
return `镜头 ${s.id}(节拍 ${beat?.id ?? s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s)\n出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}\n定妆:${s.mangaRoleIds !== undefined && s.mangaRoleIds.length > 0 ? bindingNames(s.mangaRoleIds) : '(未绑定漫剧卡)'}\n画面:${s.visual}\n台词:${s.line !== '' ? s.line : '(无)'}\n音效:${s.sound !== '' ? s.sound : '(无)'}\n光效:${s.light !== '' ? s.light : '(无)'}\n承接:${s.prevState} → ${s.nextState}`
|
|
353
|
+
return `镜头 ${s.id}(节拍 ${beat?.id ?? s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s)\n出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}\n定妆:${s.mangaRoleIds !== undefined && s.mangaRoleIds.length > 0 ? bindingNames(s.mangaRoleIds) : '(未绑定漫剧卡)'}\n画面:${s.visual}\n台词:${s.line !== '' ? s.line : '(无)'}\n音效:${s.sound !== '' ? s.sound : '(无)'}\n光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}\n承接:${s.prevState} → ${s.nextState}`
|
|
322
354
|
}).join('\n\n')
|
|
323
355
|
void navigator.clipboard?.writeText(`第${table.chapterNo}章分镜表\n\n` + text)
|
|
324
356
|
}}
|
|
@@ -357,9 +389,9 @@ export function StoryboardTab({
|
|
|
357
389
|
<div key={s.id} style={{ display: 'flex', flexDirection: 'column', gap: 'var(--nf-space-4)', border: '1px solid var(--nf-border)', borderRadius: 'var(--nf-radius-10)', padding: 'var(--nf-space-8) var(--nf-space-10)' }}>
|
|
358
390
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
359
391
|
<b>镜头 {s.id}</b>
|
|
360
|
-
<span className={css.badge}>{s.shot}</span>
|
|
361
|
-
<span className={css.meta}>{s.camera} · {s.duration}s</span>
|
|
362
|
-
{beat !== undefined && <span className={css.meta}>节拍 {beat.id}「{beat.function}」</span>}
|
|
392
|
+
<span className={css.badge}>{sizeZh(s.shot)}</span>
|
|
393
|
+
<span className={css.meta}>{cameraZh(s.camera)}{compoZh(s.composition) !== '' ? ' · ' + compoZh(s.composition) : ''} · {s.duration}s</span>
|
|
394
|
+
{beat !== undefined && <span className={css.meta}>节拍 {beat.id}「{functionZh(beat.function)}」</span>}
|
|
363
395
|
{s.characters !== undefined && s.characters.length > 0 && (
|
|
364
396
|
<span className={css.meta}>👥 {s.characters.join('、')}</span>
|
|
365
397
|
)}
|
|
@@ -371,7 +403,7 @@ export function StoryboardTab({
|
|
|
371
403
|
<div className={css.row} style={{ flexWrap: 'wrap' }}>
|
|
372
404
|
<span className={css.meta}>💬 {s.line !== '' ? s.line : '(无台词)'}</span>
|
|
373
405
|
<span className={css.meta}>🔊 {s.sound !== '' ? s.sound : '(无音效)'}</span>
|
|
374
|
-
<span className={css.meta}>💡 {s.light !== '' ? s.light : '(无光效)'}</span>
|
|
406
|
+
<span className={css.meta}>💡 {lightZh(s.light) !== '' ? lightZh(s.light) : '(无光效)'}</span>
|
|
375
407
|
</div>
|
|
376
408
|
<span className={css.meta}>承接:{s.prevState} → {s.nextState}</span>
|
|
377
409
|
</div>
|
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,
|
|
@@ -3033,8 +3048,11 @@ export async function generateStoryboardTable(
|
|
|
3033
3048
|
const system = [
|
|
3034
3049
|
'你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。',
|
|
3035
3050
|
'任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。',
|
|
3036
|
-
'输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
|
|
3037
|
-
'
|
|
3051
|
+
'输出合法 JSON 对象:{"shots": [{"beatId": "骨架节拍id", "shot": "景别", "camera": "机位与运镜", "composition": "构图", "duration": 秒数, "visual": "画面内容", "line": "台词/旁白", "sound": "音效", "light": "光效", "prevState": "承接上一镜头结尾状态", "nextState": "本镜头结束状态", "characters": ["出镜角色名"]}]}',
|
|
3052
|
+
'景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。',
|
|
3053
|
+
'运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。',
|
|
3054
|
+
'光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。',
|
|
3055
|
+
'构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。',
|
|
3038
3056
|
'硬性要求:',
|
|
3039
3057
|
'【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):',
|
|
3040
3058
|
...styleLines,
|
|
@@ -3059,23 +3077,23 @@ export async function generateStoryboardTable(
|
|
|
3059
3077
|
'==== 章节正文(画面细节以此为准) ====',
|
|
3060
3078
|
body.replace(/^#\s+.*$/m, '').trim().slice(0, 3000),
|
|
3061
3079
|
].filter(s => s !== '').join('\n\n')
|
|
3062
|
-
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' })
|
|
3063
3081
|
const raw = parseJsonObject<{ shots?: unknown }>(text)
|
|
3064
3082
|
const beatIds = new Set(skeleton.beats.map(b => b.id))
|
|
3065
|
-
const shotKinds = new Set(['远景', '全景', '中景', '近景', '特写'])
|
|
3066
3083
|
const shots: StoryboardShot[] = Array.isArray(raw.shots)
|
|
3067
3084
|
? raw.shots
|
|
3068
3085
|
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3069
3086
|
.map((entry, i) => ({
|
|
3070
3087
|
id: 's' + (i + 1),
|
|
3071
3088
|
beatId: beatIds.has(entry.beatId as string) ? entry.beatId as string : skeleton.beats[0]!.id,
|
|
3072
|
-
shot:
|
|
3073
|
-
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),
|
|
3074
3092
|
duration: typeof entry.duration === 'number' && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
|
|
3075
3093
|
visual: typeof entry.visual === 'string' ? entry.visual.trim().slice(0, 300) : '',
|
|
3076
3094
|
line: typeof entry.line === 'string' ? entry.line.trim().slice(0, 120) : '',
|
|
3077
3095
|
sound: typeof entry.sound === 'string' ? entry.sound.trim().slice(0, 80) : '',
|
|
3078
|
-
light:
|
|
3096
|
+
light: normalizeLightings(entry.light as string | undefined),
|
|
3079
3097
|
prevState: typeof entry.prevState === 'string' ? entry.prevState.trim().slice(0, 150) : '',
|
|
3080
3098
|
nextState: typeof entry.nextState === 'string' ? entry.nextState.trim().slice(0, 150) : '',
|
|
3081
3099
|
characters: sanitizeCharacters(entry.characters, 4),
|
|
@@ -3152,12 +3170,12 @@ export async function generateStoryboardPrompts(
|
|
|
3152
3170
|
const ref = c.imageUrl !== undefined ? '(参考图)' : ''
|
|
3153
3171
|
return c.name + ':' + anchor + ref
|
|
3154
3172
|
}).join(';')
|
|
3155
|
-
return `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
|
|
3173
|
+
return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
|
|
3156
3174
|
出场:${s.characters !== undefined && s.characters.length > 0 ? s.characters.join('、') : '(未标注)'}
|
|
3157
3175
|
定妆:${makeUp !== '' ? makeUp : '(未绑定漫剧卡)'}
|
|
3158
3176
|
画面:${s.visual}
|
|
3159
3177
|
台词:${s.line !== '' ? s.line : '(无)'}
|
|
3160
|
-
光效:${s.light !== '' ? s.light : '(无)'}
|
|
3178
|
+
光效:${lightZh(s.light) !== '' ? lightZh(s.light) : '(无)'}
|
|
3161
3179
|
承接:${s.prevState} → ${s.nextState}`
|
|
3162
3180
|
}).join('\n\n')
|
|
3163
3181
|
const system = [
|
|
@@ -3180,7 +3198,7 @@ export async function generateStoryboardPrompts(
|
|
|
3180
3198
|
shotLines,
|
|
3181
3199
|
'只输出 JSON 对象。',
|
|
3182
3200
|
].filter(x => x !== '').join('\n\n')
|
|
3183
|
-
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' })
|
|
3184
3202
|
const raw = parseJsonObject<{ prompts?: unknown }>(text)
|
|
3185
3203
|
const shotIds = new Set(table.shots.map(s => s.id))
|
|
3186
3204
|
const shotBinding = new Map<string, string[]>(table.shots.map(s => [s.id, s.mangaRoleIds ?? []]))
|
|
@@ -3222,7 +3240,9 @@ export async function generateStoryboardSkeleton(
|
|
|
3222
3240
|
const system = [
|
|
3223
3241
|
'你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。',
|
|
3224
3242
|
'任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。',
|
|
3225
|
-
'输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "
|
|
3243
|
+
'输出合法 JSON 对象:{"arc": "本章弧线一句话(起承转合,20-60字)", "beats": [{"event": "事件一句话(发生了什么)", "emotion": "人物情绪走向(情绪词数组)", "function": "铺垫|冲突|转折|高潮|收束|伏笔|人物塑造", "cause": "承接上一节拍的原因(可省略)"}], "characters": ["出镜角色名1", "出镜角色名2"]}',
|
|
3244
|
+
'情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。',
|
|
3245
|
+
'功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。',
|
|
3226
3246
|
'硬性要求:',
|
|
3227
3247
|
'1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。',
|
|
3228
3248
|
'2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。',
|
|
@@ -3240,15 +3260,16 @@ export async function generateStoryboardSkeleton(
|
|
|
3240
3260
|
const text = await complete(ctx, config, { system, user, temperature: 0.3, maxTokens: Math.max(config.maxTokens, 4000), reasoning: config.analysisReasoning ?? 'low' })
|
|
3241
3261
|
const raw = parseJsonObject<{ arc?: unknown; beats?: unknown; characters?: unknown }>(text)
|
|
3242
3262
|
const arc = typeof raw.arc === 'string' ? raw.arc.trim().slice(0, 200) : ''
|
|
3243
|
-
const functions = new Set(['铺垫', '冲突', '转折', '高潮', '收束', '伏笔', '人物塑造'])
|
|
3244
3263
|
const beats: StoryboardBeat[] = Array.isArray(raw.beats)
|
|
3245
3264
|
? raw.beats
|
|
3246
3265
|
.filter((v): v is Record<string, unknown> => typeof v === 'object' && v !== null)
|
|
3247
3266
|
.map((entry, i) => ({
|
|
3248
3267
|
id: 'b' + (i + 1),
|
|
3249
3268
|
event: typeof entry.event === 'string' ? entry.event.trim().slice(0, 200) : '',
|
|
3250
|
-
emotion:
|
|
3251
|
-
|
|
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),
|
|
3252
3273
|
cause: typeof entry.cause === 'string' && entry.cause.trim() !== '' ? entry.cause.trim().slice(0, 150) : undefined,
|
|
3253
3274
|
}))
|
|
3254
3275
|
.filter(b => b.event !== '')
|
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.)
|