@waterwx/dsh-novel-forge 1.4.1 → 1.6.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.
Files changed (50) hide show
  1. package/assets/styles/arcane-thick-3d.webp +0 -0
  2. package/assets/styles/chinese-ink.webp +0 -0
  3. package/assets/styles/clay-stopmotion-3d.webp +0 -0
  4. package/assets/styles/dark-ghibli.webp +0 -0
  5. package/assets/styles/dark-gothic-3d.webp +0 -0
  6. package/assets/styles/film-photo.webp +0 -0
  7. package/assets/styles/guofeng-heavy.webp +0 -0
  8. package/assets/styles/horror-game-3d.webp +0 -0
  9. package/assets/styles/japan-cel-dark.webp +0 -0
  10. package/assets/styles/korean-webtoon.webp +0 -0
  11. package/assets/styles/low-cyberpunk-3d.webp +0 -0
  12. package/assets/styles/nextgen-cg.webp +0 -0
  13. package/assets/styles/noir-film.webp +0 -0
  14. package/assets/styles/openworld-survival.webp +0 -0
  15. package/assets/styles/paper-cut-3d.webp +0 -0
  16. package/assets/styles/pixar-adult-3d.webp +0 -0
  17. package/assets/styles/pop-marvel-2d.webp +0 -0
  18. package/assets/styles/shinkai-anime.webp +0 -0
  19. package/assets/styles/tactical-stealth.webp +0 -0
  20. package/assets/styles/vaporwave-3d.webp +0 -0
  21. package/assets/styles/vhs90.webp +0 -0
  22. package/lib/client.js +10541 -8910
  23. package/lib/client.js.map +1 -1
  24. package/lib/index.js +763 -36
  25. package/lib/index.js.map +1 -1
  26. package/lib/types/client/api.d.ts +8 -0
  27. package/lib/types/client/panel/MangaWorkspace.d.ts +13 -0
  28. package/lib/types/client/panel/NovelPanel.d.ts +1 -1
  29. package/lib/types/client/panel/RoleVisualPanel.d.ts +13 -0
  30. package/lib/types/client/panel/SceneLibrary.d.ts +14 -0
  31. package/lib/types/client/panel/StoryboardTab.d.ts +15 -0
  32. package/lib/types/client/panel/StyleCard.d.ts +8 -0
  33. package/lib/types/client/panel/StyleGallery.d.ts +1 -0
  34. package/lib/types/engine.d.ts +20 -4
  35. package/lib/types/protocol.d.ts +159 -0
  36. package/lib/types/style-library.d.ts +33 -0
  37. package/package.json +2 -1
  38. package/src/client/api.ts +20 -0
  39. package/src/client/panel/MangaWorkspace.tsx +293 -0
  40. package/src/client/panel/NovelPanel.tsx +8 -638
  41. package/src/client/panel/RoleVisualPanel.tsx +468 -0
  42. package/src/client/panel/SceneLibrary.tsx +395 -0
  43. package/src/client/panel/StoryboardTab.tsx +370 -0
  44. package/src/client/panel/StyleCard.tsx +90 -0
  45. package/src/client/panel/StyleGallery.tsx +58 -0
  46. package/src/client/panel/panel.module.css +12 -12
  47. package/src/engine.ts +377 -25
  48. package/src/protocol.ts +174 -0
  49. package/src/routes.ts +193 -3
  50. package/src/style-library.ts +78 -0
package/lib/index.js CHANGED
@@ -4,6 +4,7 @@ import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-sett
4
4
  import z from "schemastery";
5
5
  import { exec } from "node:child_process";
6
6
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
7
+ import { fileURLToPath } from "node:url";
7
8
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
8
9
  import { randomBytes } from "node:crypto";
9
10
  import { strFromU8, unzipSync } from "fflate";
@@ -664,6 +665,195 @@ function getComicStylePrompt(styleId) {
664
665
  return COMIC_STYLE_PRESETS.find((s) => s.id === styleId)?.prompt ?? "";
665
666
  }
666
667
  //#endregion
668
+ //#region src/style-library.ts
669
+ /** 内置风格模板(18 个)。 */
670
+ const STYLE_LIBRARY = [
671
+ {
672
+ id: "pixar-adult-3d",
673
+ name: "成人向皮克斯 3D",
674
+ category: "3d",
675
+ traits: "CG 三维渲染,材质细腻,微表情表现力强;需压制 Q 版可爱感",
676
+ keywords: "3D皮克斯动画,成人写实CGI,电影光影,拒绝Q版",
677
+ weight: 1
678
+ },
679
+ {
680
+ id: "arcane-thick-3d",
681
+ name: "双城之战美漫厚涂 3D",
682
+ category: "3d",
683
+ traits: "粗重轮廓线,高对比硬阴影,材质粗粝,悲剧、压抑、科幻题材适配高",
684
+ keywords: "双城之战美术,3D美漫厚涂,强轮廓光影,暗调粗粝渲染",
685
+ weight: .95
686
+ },
687
+ {
688
+ id: "nextgen-cg",
689
+ name: "次世代游戏 CG",
690
+ category: "3d",
691
+ traits: "接近真人的三维渲染,照片级布料、皮肤材质,冷峻写实",
692
+ keywords: "次世代游戏CG渲染,超写实3D,真实物理材质,电影布光",
693
+ weight: .9
694
+ },
695
+ {
696
+ id: "low-cyberpunk-3d",
697
+ name: "底层赛博朋克 3D",
698
+ category: "3d",
699
+ traits: "锈蚀工业、破碎霓虹、屏幕噪点;避开华丽高楼,偏向破败底层",
700
+ keywords: "底层赛博朋克3D,废土工业,破碎霓虹,画面噪点色散",
701
+ weight: .85
702
+ },
703
+ {
704
+ id: "vaporwave-3d",
705
+ name: "复古蒸汽波 3D",
706
+ category: "3d",
707
+ traits: "粉-蓝-紫霓虹轮廓、90 年代复古网格、胶片色散颗粒,颓废疏离",
708
+ keywords: "3D复古蒸汽波,90年代三维渲染,霓虹轮廓光,胶片颗粒",
709
+ weight: .8
710
+ },
711
+ {
712
+ id: "dark-gothic-3d",
713
+ name: "暗黑哥特 3D",
714
+ category: "3d",
715
+ traits: "大面积阴影、冷青色调、衰败破败质感,适合幽暗密闭空间",
716
+ keywords: "3D暗黑哥特,冷青暗光,衰败质感,大面积阴影",
717
+ weight: .8
718
+ },
719
+ {
720
+ id: "clay-stopmotion-3d",
721
+ name: "黏土 / 定格 3D",
722
+ category: "3d",
723
+ traits: "捏塑颗粒肌理,手工定格质感;可做压抑向,不局限可爱风",
724
+ keywords: "3D黏土定格渲染,泥塑肌理,电影打光",
725
+ weight: .7
726
+ },
727
+ {
728
+ id: "openworld-survival",
729
+ name: "开放世界生存游戏风",
730
+ category: "game",
731
+ traits: "场景破败感强,货架、仓库、管线、灰尘、锈蚀明显;人物带一点“生存者”气质;适合探索感、逃亡感、世界观展示",
732
+ keywords: "开放世界生存游戏风格,废土场景,破败仓库,锈蚀金属货架,灰尘粒子,冷色写实光影",
733
+ weight: .8
734
+ },
735
+ {
736
+ id: "horror-game-3d",
737
+ name: "恐怖游戏 3D",
738
+ category: "game",
739
+ traits: "大面积阴影,荧光灯频闪,空间压抑;适合悬疑、压迫、精神异常段落",
740
+ keywords: "恐怖游戏 3D 风格,昏暗仓库,老旧荧光灯,硬阴影,压迫空间,冷灰蓝调,低饱和",
741
+ weight: .75
742
+ },
743
+ {
744
+ id: "tactical-stealth",
745
+ name: "战术潜行游戏风",
746
+ category: "game",
747
+ traits: "低视角、第三人称镜头感;人物在货架之间移动,光影克制;适合紧张剧情",
748
+ keywords: "战术潜行游戏视角,第三人称镜头,货架通道,冷色暗光,真实3D渲染,紧张压迫氛围",
749
+ weight: .7
750
+ },
751
+ {
752
+ id: "korean-webtoon",
753
+ name: "韩漫条漫风",
754
+ category: "2d",
755
+ traits: "修长人物,低饱和高级灰,氛围感强,都市向漫剧爆款画风",
756
+ keywords: "韩漫2D绘画,精致人物,低饱和灰调,氛围感插画",
757
+ weight: 1
758
+ },
759
+ {
760
+ id: "japan-cel-dark",
761
+ name: "日系赛璐璐动画",
762
+ category: "2d",
763
+ traits: "干净勾线,硬阴影;分明亮版、暗调悲剧版(边缘行者质感)",
764
+ keywords: "日系赛璐璐动画,清晰轮廓线,硬阴影,暗调都市动画",
765
+ weight: .9
766
+ },
767
+ {
768
+ id: "shinkai-anime",
769
+ name: "新海诚动画风",
770
+ category: "2d",
771
+ traits: "极致环境光影、丁达尔、空气中尘埃粒子,擅长雨夜、夜景外景",
772
+ keywords: "新海诚动画渲染,细腻环境光,漂浮尘埃粒子",
773
+ weight: .85
774
+ },
775
+ {
776
+ id: "pop-marvel-2d",
777
+ name: "波普美漫",
778
+ category: "2d",
779
+ traits: "网点半调、强撞色,线条张扬,节奏冲击力强",
780
+ keywords: "平行宇宙波普美漫,网点半调,漫画粗线条",
781
+ weight: .8
782
+ },
783
+ {
784
+ id: "dark-ghibli",
785
+ name: "暗黑吉卜力手绘",
786
+ category: "2d",
787
+ traits: "手绘水彩肌理;强制关闭温暖治愈,做残酷冷调版本",
788
+ keywords: "吉卜力手绘动画,暗黑写实,水彩肌理,拒绝暖光治愈",
789
+ weight: .75
790
+ },
791
+ {
792
+ id: "noir-film",
793
+ name: "黑色电影 Noir",
794
+ category: "film",
795
+ traits: "高反差黑白,只保留少量色彩作为视觉锚点,张力极强",
796
+ keywords: "黑色电影,大部分黑白,局部保留色彩,硬侧光,胶片颗粒",
797
+ stackable: true,
798
+ weight: 1
799
+ },
800
+ {
801
+ id: "vhs90",
802
+ name: "VHS 90 录像带复古",
803
+ category: "film",
804
+ traits: "扫描线、色彩偏移、磁带噪点,模拟老旧档案录像",
805
+ keywords: "VHS录像带质感,扫描线,色彩偏移,磁带噪点",
806
+ stackable: true,
807
+ weight: .9
808
+ },
809
+ {
810
+ id: "film-photo",
811
+ name: "胶片电影写真",
812
+ category: "film",
813
+ traits: "电影机拍摄质感,轻微颗粒,景深虚化,无夸张特效",
814
+ keywords: "电影写真质感,35mm胶片,胶片颗粒,浅景深",
815
+ stackable: true,
816
+ weight: .85
817
+ },
818
+ {
819
+ id: "chinese-ink",
820
+ name: "新中式水墨动画",
821
+ category: "craft",
822
+ traits: "水墨晕染,留白写意",
823
+ keywords: "水墨动画渲染,写意晕染,国风2D",
824
+ weight: 1
825
+ },
826
+ {
827
+ id: "paper-cut-3d",
828
+ name: "纸雕定格",
829
+ category: "craft",
830
+ traits: "多层纸张镂空叠层,光影穿透纸面",
831
+ keywords: "立体纸雕定格,纸张肌理,层叠镂空光影",
832
+ weight: .9
833
+ },
834
+ {
835
+ id: "guofeng-heavy",
836
+ name: "工笔重彩国风",
837
+ category: "craft",
838
+ traits: "精细线条,矿物颜料质感,古典华美",
839
+ keywords: "工笔重彩,国风2D绘画,矿物颜料质感,精细勾线",
840
+ weight: .8
841
+ }
842
+ ];
843
+ /** 取单个风格(未找到返回 undefined)。 */
844
+ function findStyle(id) {
845
+ return STYLE_LIBRARY.find((s) => s.id === id);
846
+ }
847
+ /** 按基底 + 滤镜拼接风格关键词(角色图/分镜/视频提示词共用;缺省回退 3D 动漫)。 */
848
+ function styleKeywords(styleId, filterId) {
849
+ const parts = [];
850
+ const base = styleId !== void 0 ? findStyle(styleId) : void 0;
851
+ if (base !== void 0) parts.push(base.keywords);
852
+ const filter = filterId !== void 0 ? findStyle(filterId) : void 0;
853
+ if (filter !== void 0) parts.push(filter.keywords);
854
+ return parts.join(",") || "3D动漫,超精细建模,电影光影";
855
+ }
856
+ //#endregion
667
857
  //#region src/engine.ts
668
858
  /**
669
859
  * Novel engine — the host half's core: LLM-driven story-bible extraction,
@@ -1926,10 +2116,13 @@ async function extractRoles(ctx, config, project) {
1926
2116
  return roles;
1927
2117
  }
1928
2118
  /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
1929
- async function extractScenes(ctx, config, project) {
2119
+ async function extractScenes(ctx, config, project, styleId, filterId) {
2120
+ const styleWords = styleKeywords(styleId, filterId);
1930
2121
  const system = [
1931
2122
  "你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
1932
2123
  "按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。",
2124
+ "【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):" + styleWords,
2125
+ "重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
1933
2126
  "每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。",
1934
2127
  "数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。",
1935
2128
  "每个场景输出:",
@@ -1987,10 +2180,11 @@ async function extractScenes(ctx, config, project) {
1987
2180
  elements: strArr(entry.elements).map((t) => t.slice(0, 60)).slice(0, 6),
1988
2181
  palette: strArr(entry.palette).map((t) => t.slice(0, 40)).slice(0, 4),
1989
2182
  moods: strArr(entry.moods).map((t) => t.slice(0, 20)).slice(0, 4),
1990
- zh: str(entry.zh).slice(0, 800),
1991
- en: str(entry.en).slice(0, 600),
2183
+ zh: ensureStyleEmbedded(str(entry.zh).slice(0, 800), styleWords, "zh"),
2184
+ en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, "en"),
1992
2185
  tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
1993
- source: str(entry.source).slice(0, 120)
2186
+ source: str(entry.source).slice(0, 120),
2187
+ styleId
1994
2188
  });
1995
2189
  }
1996
2190
  return scenes;
@@ -2164,13 +2358,112 @@ function importBookTextFromText(raw, outputDir, bookName) {
2164
2358
  function importBookText(filePath, outputDir) {
2165
2359
  return importBookTextFromText(decodeTextSmart(readFileSync(filePath)), outputDir, basename(filePath, extname(filePath)).slice(0, 40) || "导入小说");
2166
2360
  }
2361
+ /** 常见职业/身份尾缀:角色名如「周野律师」正文可能只写「周野」或「周野的律师」。 */
2362
+ const ROLE_NAME_SUFFIXES = [
2363
+ "律师",
2364
+ "辩护律师",
2365
+ "医生",
2366
+ "老师",
2367
+ "教授",
2368
+ "先生",
2369
+ "女士",
2370
+ "小姐",
2371
+ "警官",
2372
+ "警察",
2373
+ "局长",
2374
+ "总经理",
2375
+ "经理",
2376
+ "老板",
2377
+ "师父",
2378
+ "师傅",
2379
+ "道长",
2380
+ "老祖",
2381
+ "长老",
2382
+ "掌门",
2383
+ "少主",
2384
+ "公主",
2385
+ "王子",
2386
+ "王妃",
2387
+ "皇后",
2388
+ "皇帝",
2389
+ "王爷",
2390
+ "公子",
2391
+ "姑娘",
2392
+ "夫人",
2393
+ "太太",
2394
+ "大人",
2395
+ "将军",
2396
+ "丞相",
2397
+ "尚书",
2398
+ "员外"
2399
+ ];
2400
+ /**
2401
+ * 从角色名/身份拆出正文可能使用的检索词:
2402
+ * - specific:具体称谓(含职业尾缀或 3 字以上身份片段),如「周野的律师」「辩护律师」「律师」——优先用,避免误抓到同名主干(周野)的段落;
2403
+ * - stems:名字主干(如「周野」),最后兜底。
2404
+ */
2405
+ function roleFallbackTokens(name, identity) {
2406
+ const specific = /* @__PURE__ */ new Set();
2407
+ const stems = /* @__PURE__ */ new Set();
2408
+ const addName = (s) => {
2409
+ const t = s.trim();
2410
+ if (t.length < 2) return;
2411
+ specific.add(t);
2412
+ for (const q of [
2413
+ "辩护",
2414
+ "助理",
2415
+ "高级",
2416
+ "首席",
2417
+ "御用",
2418
+ "御前",
2419
+ "专职"
2420
+ ]) if (t.includes(q)) specific.add(t.replace(q, ""));
2421
+ for (const suf of ROLE_NAME_SUFFIXES) if (t.endsWith(suf) && t.length > suf.length + 1) {
2422
+ const stem = t.slice(0, -suf.length);
2423
+ specific.add(stem + "的" + suf);
2424
+ specific.add(suf);
2425
+ if (stem.length >= 2) stems.add(stem);
2426
+ }
2427
+ };
2428
+ addName(name);
2429
+ for (const part of (identity ?? "").split(/[的,,。\s]+/)) {
2430
+ const p = part.trim();
2431
+ if (p.length < 2) continue;
2432
+ if (p.length >= 3 || ROLE_NAME_SUFFIXES.some((s) => p.endsWith(s))) {
2433
+ specific.add(p);
2434
+ for (const suf of ROLE_NAME_SUFFIXES) if (p.endsWith(suf) && p.length > suf.length + 1) {
2435
+ specific.add(suf);
2436
+ stems.add(p.slice(0, -suf.length));
2437
+ }
2438
+ } else stems.add(p);
2439
+ }
2440
+ return {
2441
+ specific: [...specific].filter((t) => t.length >= 2),
2442
+ stems: [...stems].filter((t) => t.length >= 2)
2443
+ };
2444
+ }
2445
+ /** 保证风格词块已内嵌(LLM 偶发漏嵌时兜底):zh 段首前缀,en 末尾追加。 */
2446
+ function ensureStyleEmbedded(text, styleWords, lang) {
2447
+ const t = text.trim();
2448
+ if (styleWords === "" || t === "") return t;
2449
+ if (t.includes(styleWords)) return t;
2450
+ return lang === "zh" ? styleWords + "," + t : t + "," + styleWords;
2451
+ }
2452
+ /** 对 zh/en 一对提示词统一补风格词块。 */
2453
+ function withStyle(pair, styleWords) {
2454
+ return {
2455
+ zh: ensureStyleEmbedded(pair.zh, styleWords, "zh"),
2456
+ en: ensureStyleEmbedded(pair.en, styleWords, "en")
2457
+ };
2458
+ }
2167
2459
  /**
2168
2460
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
2169
2461
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
2170
2462
  */
2171
- async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2463
+ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
2172
2464
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2173
2465
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2466
+ role.promptStyleId = styleId;
2174
2467
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
2175
2468
  const roleText = `${role.name} ${role.identity ?? ""}`;
2176
2469
  const colorWords = [
@@ -2237,38 +2530,55 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2237
2530
  ...roleWords.filter((w) => roleText.includes(w)),
2238
2531
  ...locationWords.filter((w) => roleText.includes(w))
2239
2532
  ])).filter((t) => t.length >= 2);
2240
- const matchesRole = (para) => para.includes(roleName) || searchTokens.some((tok) => para.includes(tok));
2533
+ const matchesRole = (para, tokens) => para.includes(roleName) || tokens.some((tok) => para.includes(tok));
2241
2534
  const excerpts = [];
2242
- const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
2243
- for (const chapter of written) {
2244
- const body = readChapterFile(outputDir, chapter);
2245
- if (body === void 0) continue;
2246
- const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
2247
- let perChapter = 0;
2248
- for (const para of paras) {
2249
- if (perChapter >= 2 || excerpts.length >= 12) break;
2250
- if (!matchesRole(para)) continue;
2251
- if (appearanceHints.test(para) || excerpts.length < 4) {
2252
- excerpts.push({
2253
- no: chapter.no,
2254
- text: para.slice(0, 220)
2255
- });
2256
- perChapter++;
2535
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
2536
+ /** 扫描一批章节,收集该角色出场且可能含外貌描写的段落(每章最多 2 段,共 12 段)。 */
2537
+ const scanChapters = (list, tokens) => {
2538
+ for (const chapter of list) {
2539
+ if (excerpts.length >= 12) break;
2540
+ const body = readChapterFile(outputDir, chapter);
2541
+ if (body === void 0) continue;
2542
+ const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
2543
+ let perChapter = 0;
2544
+ for (const para of paras) {
2545
+ if (perChapter >= 2 || excerpts.length >= 12) break;
2546
+ if (!matchesRole(para, tokens)) continue;
2547
+ if (appearanceHints.test(para) || excerpts.length < 4) {
2548
+ excerpts.push({
2549
+ no: chapter.no,
2550
+ text: para.slice(0, 220)
2551
+ });
2552
+ perChapter++;
2553
+ }
2257
2554
  }
2258
2555
  }
2259
- if (excerpts.length >= 12) break;
2556
+ };
2557
+ scanChapters(written.slice(-60), searchTokens);
2558
+ if (excerpts.length === 0) scanChapters(written, searchTokens);
2559
+ if (excerpts.length === 0) {
2560
+ const fb = roleFallbackTokens(role.name, role.identity);
2561
+ if (fb.specific.length > 0) scanChapters(written, fb.specific);
2260
2562
  }
2261
- if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
2563
+ if (excerpts.length === 0) {
2564
+ const fb = roleFallbackTokens(role.name, role.identity);
2565
+ if (fb.stems.length > 0) scanChapters(written, fb.stems);
2566
+ }
2567
+ if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(已扫描全书及角色名拆分词),请确认角色名与正文一致,或该角色尚未在正文出场`);
2568
+ const rules = project.visualRules ?? [];
2569
+ const styleWords = styleKeywords(styleId, filterId);
2262
2570
  const raw = parseJsonObject(await complete(ctx, config, {
2263
2571
  system: [
2264
2572
  "你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
2573
+ "【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):" + styleWords,
2574
+ "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2265
2575
  "硬性要求(依据优先):",
2266
2576
  "1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
2267
2577
  "2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
2268
2578
  "3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。",
2269
2579
  "4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
2270
2580
  "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
2271
- ...(project.visualRules ?? []).map((r) => "- " + r),
2581
+ ...rules.map((r) => "- " + r),
2272
2582
  "输出六部分:",
2273
2583
  "- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
2274
2584
  "- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
@@ -2297,8 +2607,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2297
2607
  maxTokens: Math.max(config.maxTokens, 12e3),
2298
2608
  reasoning: config.analysisReasoning ?? "low"
2299
2609
  }));
2300
- const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2301
- const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
2610
+ let zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2611
+ let en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
2302
2612
  const tags = Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string" && t.trim() !== "").map((t) => t.trim().slice(0, 20)).slice(0, 12) : [];
2303
2613
  const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
2304
2614
  const expressions = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 12)).slice(0, 12) : [];
@@ -2328,6 +2638,17 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2328
2638
  promptKit = void 0;
2329
2639
  }
2330
2640
  if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
2641
+ zh = ensureStyleEmbedded(zh, styleWords, "zh");
2642
+ en = ensureStyleEmbedded(en, styleWords, "en");
2643
+ if (promptKit !== void 0) promptKit = {
2644
+ portrait: withStyle(promptKit.portrait, styleWords),
2645
+ sheet: withStyle(promptKit.sheet, styleWords),
2646
+ expressions: promptKit.expressions.map((e) => ({
2647
+ ...e,
2648
+ ...withStyle(e, styleWords)
2649
+ })),
2650
+ details: withStyle(promptKit.details, styleWords)
2651
+ };
2331
2652
  return {
2332
2653
  zh,
2333
2654
  en,
@@ -2341,20 +2662,25 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
2341
2662
  * 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
2342
2663
  * LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
2343
2664
  */
2344
- async function generateRolePromptKit(ctx, config, project, roleName) {
2665
+ async function generateRolePromptKit(ctx, config, project, roleName, styleId, filterId) {
2345
2666
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2346
2667
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2347
2668
  if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
2669
+ role.promptStyleId = styleId;
2670
+ const rules = project.visualRules ?? [];
2671
+ const styleWords = styleKeywords(styleId, filterId);
2348
2672
  const raw = parseJsonObject(await complete(ctx, config, {
2349
2673
  system: [
2350
2674
  "你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
2675
+ "【当前视觉风格】(每类提示词必须内嵌):" + styleWords,
2676
+ "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2351
2677
  "四类:",
2352
2678
  "1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
2353
2679
  "2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
2354
2680
  "3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
2355
2681
  "4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
2356
2682
  "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
2357
- ...(project.visualRules ?? []).map((r) => "- " + r),
2683
+ ...rules.map((r) => "- " + r),
2358
2684
  "zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
2359
2685
  "输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"sheet\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}], \"details\": {\"zh\": \"...\", \"en\": \"...\"}}",
2360
2686
  "重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
@@ -2381,14 +2707,16 @@ async function generateRolePromptKit(ctx, config, project, roleName) {
2381
2707
  };
2382
2708
  const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
2383
2709
  const kit = {
2384
- portrait: pair(raw.portrait),
2385
- sheet: pair(raw.sheet),
2710
+ portrait: withStyle(pair(raw.portrait), styleWords),
2711
+ sheet: withStyle(pair(raw.sheet), styleWords),
2386
2712
  expressions: expressionsRaw.map((e) => ({
2387
2713
  name: str(e.name).slice(0, 12) || "表情",
2388
- zh: str(e.zh).slice(0, 800),
2389
- en: str(e.en).slice(0, 1200)
2714
+ ...withStyle({
2715
+ zh: str(e.zh).slice(0, 800),
2716
+ en: str(e.en).slice(0, 1200)
2717
+ }, styleWords)
2390
2718
  })).slice(0, 12),
2391
- details: pair(raw.details)
2719
+ details: withStyle(pair(raw.details), styleWords)
2392
2720
  };
2393
2721
  if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
2394
2722
  return kit;
@@ -3049,6 +3377,229 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
3049
3377
  saveProject(outputDir, project);
3050
3378
  return chapter.summary;
3051
3379
  }
3380
+ /** 把分镜产出写入项目持久化(按章 upsert)。重新生成上游时级联清掉下游旧产物:
3381
+ * 新骨架 → 清分镜表+提示词;新分镜表 → 清提示词;新提示词不动上游。 */
3382
+ function saveChapterStoryboard(project, outputDir, entry) {
3383
+ if (project.storyboards === void 0) project.storyboards = [];
3384
+ const idx = project.storyboards.findIndex((e) => e.chapterNo === entry.chapterNo);
3385
+ const next = {
3386
+ ...(idx === -1 ? void 0 : project.storyboards[idx]) ?? {},
3387
+ ...entry
3388
+ };
3389
+ if (entry.skeleton !== void 0) {
3390
+ next.table = void 0;
3391
+ next.prompts = void 0;
3392
+ }
3393
+ if (entry.table !== void 0) next.prompts = void 0;
3394
+ if (idx === -1) project.storyboards.push(next);
3395
+ else project.storyboards[idx] = next;
3396
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3397
+ saveProject(outputDir, project);
3398
+ }
3399
+ /**
3400
+ * 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
3401
+ * 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
3402
+ */
3403
+ async function generateStoryboardTable(ctx, config, project, outputDir, chapterNo, skeleton, styleId, filterId) {
3404
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
3405
+ if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
3406
+ const body = readChapterFile(outputDir, chapter);
3407
+ if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
3408
+ if ((skeleton.beats ?? []).length === 0) throw new Error("骨架为空,请先生成剧情骨架");
3409
+ const roles = (project.roles ?? []).filter((r) => body.includes(r.name)).slice(0, 8).map((r) => `${r.name}(${r.roleLabel === "protagonist" ? "主角" : r.roleLabel === "antagonist" ? "反派" : r.roleLabel === "female_lead" ? "女主" : "配角"}):${r.identity ?? ""}`);
3410
+ const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6);
3411
+ const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
3412
+ const rules = (project.visualRules ?? []).map((r) => "- " + r);
3413
+ const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
3414
+ const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
3415
+ const styleLines = [];
3416
+ if (baseStyle !== void 0) styleLines.push("- 基底风格「" + baseStyle.name + "」:" + baseStyle.keywords);
3417
+ if (filterStyle !== void 0) styleLines.push("- 叠加滤镜「" + filterStyle.name + "」:" + filterStyle.keywords);
3418
+ if (styleLines.length === 0) styleLines.push("- (未选择风格,默认 3D 动漫超精细建模质感)");
3419
+ const raw = parseJsonObject(await complete(ctx, config, {
3420
+ system: [
3421
+ "你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
3422
+ "任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
3423
+ "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\"}]}",
3424
+ "景别取值:远景/全景/中景/近景/特写。",
3425
+ "硬性要求:",
3426
+ "【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
3427
+ ...styleLines,
3428
+ "1. 按骨架节拍顺序输出镜头,每个节拍至少 1 个镜头,总镜头数 8-25。",
3429
+ "2. 镜头间连续:下一镜头的 prevState 必须与上一镜头的 nextState 一致(人物位置/动作/情绪/服装),禁止瞬移、服装消失、情绪跳变。",
3430
+ "3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
3431
+ "4. 台词/音效/光效无则空字符串,不要编造。",
3432
+ "5. duration 1-12 秒。",
3433
+ "6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。"
3434
+ ].join("\n"),
3435
+ user: [
3436
+ `章节《${chapter.title}》(第 ${chapter.no} 章)`,
3437
+ "==== 剧情骨架(只读,禁止改动) ====",
3438
+ `弧线:${skeleton.arc}`,
3439
+ skeleton.beats.map((b) => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== void 0 ? ";承接:" + b.cause : ""})`).join("\n"),
3440
+ roles.length > 0 ? "==== 出场角色锚点 ====\n" + roles.join("\n") : "",
3441
+ scenes.length > 0 ? "==== 相关场景 ====\n" + scenes.join("\n") : "",
3442
+ rules.length > 0 ? "==== 本书视觉规则(必须内嵌进 visual) ====\n" + rules.join("\n") : "",
3443
+ "==== 章节正文(画面细节以此为准) ====",
3444
+ body.replace(/^#\s+.*$/m, "").trim().slice(0, 3e3)
3445
+ ].filter((s) => s !== "").join("\n\n"),
3446
+ temperature: .3,
3447
+ maxTokens: Math.max(config.maxTokens, 6e3),
3448
+ reasoning: config.analysisReasoning ?? "low"
3449
+ }));
3450
+ const beatIds = new Set(skeleton.beats.map((b) => b.id));
3451
+ const shotKinds = /* @__PURE__ */ new Set([
3452
+ "远景",
3453
+ "全景",
3454
+ "中景",
3455
+ "近景",
3456
+ "特写"
3457
+ ]);
3458
+ const shots = Array.isArray(raw.shots) ? raw.shots.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
3459
+ id: "s" + (i + 1),
3460
+ beatId: beatIds.has(entry.beatId) ? entry.beatId : skeleton.beats[0].id,
3461
+ shot: shotKinds.has(entry.shot) ? entry.shot : "中景",
3462
+ camera: typeof entry.camera === "string" ? entry.camera.trim().slice(0, 80) : "",
3463
+ duration: typeof entry.duration === "number" && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
3464
+ visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
3465
+ line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
3466
+ sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
3467
+ light: typeof entry.light === "string" ? entry.light.trim().slice(0, 80) : "",
3468
+ prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
3469
+ nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : ""
3470
+ })).filter((s) => s.visual !== "") : [];
3471
+ if (shots.length === 0) throw new Error("模型未输出有效镜头,请重试");
3472
+ const covered = new Set(shots.map((s) => s.beatId));
3473
+ const missing = skeleton.beats.filter((b) => !covered.has(b.id)).map((b) => b.id);
3474
+ if (missing.length > 0) console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(",")} 无镜头覆盖`);
3475
+ const table = {
3476
+ chapterNo,
3477
+ shots,
3478
+ usedScenes: usedScenes.map((s) => s.name)
3479
+ };
3480
+ saveChapterStoryboard(project, outputDir, {
3481
+ chapterNo,
3482
+ table,
3483
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3484
+ });
3485
+ return table;
3486
+ }
3487
+ /**
3488
+ * 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。
3489
+ * 每镜头一段:风格词块(基底+滤镜)+ 画面内容(角色动作/服装标志物)+ 机位运镜 + 光效。
3490
+ * 提示词聚焦画面与镜头(视频模型无音频,台词/音效不注入)。
3491
+ */
3492
+ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapterNo, table, styleId, filterId) {
3493
+ if ((table.shots ?? []).length === 0) throw new Error("分镜表为空,请先生成分镜表");
3494
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
3495
+ const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
3496
+ const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
3497
+ const stylePrefix = [baseStyle?.keywords, filterStyle?.keywords].filter((v) => v !== void 0 && v !== "").join(",") || "3D动漫,超精细建模,电影光影";
3498
+ const rules = (project.visualRules ?? []).map((r) => "- " + r);
3499
+ const shotLines = table.shots.map((s) => `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
3500
+ 画面:${s.visual}
3501
+ 台词:${s.line !== "" ? s.line : "(无)"}
3502
+ 光效:${s.light !== "" ? s.light : "(无)"}
3503
+ 承接:${s.prevState} → ${s.nextState}`).join("\n\n");
3504
+ const raw = parseJsonObject(await complete(ctx, config, {
3505
+ system: [
3506
+ "你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。",
3507
+ "任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。",
3508
+ "提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。",
3509
+ "风格词块(必须原样放在每段开头):" + stylePrefix,
3510
+ "重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
3511
+ "硬性要求:",
3512
+ "1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
3513
+ "2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。",
3514
+ "3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。",
3515
+ "4. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\": \"s1\", \"text\": \"...\"}]},所有镜头都要有,顺序一致。",
3516
+ "5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。"
3517
+ ].join("\n"),
3518
+ user: [
3519
+ `章节《${chapter?.title ?? ""}》(第 ${chapterNo} 章)`,
3520
+ rules.length > 0 ? "本书视觉规则(必须遵守):\n" + rules.join("\n") : "",
3521
+ "==== 分镜表 ====",
3522
+ shotLines,
3523
+ "只输出 JSON 对象。"
3524
+ ].filter((x) => x !== "").join("\n\n"),
3525
+ temperature: .3,
3526
+ maxTokens: Math.max(config.maxTokens, 6e3),
3527
+ reasoning: config.analysisReasoning ?? "low"
3528
+ }));
3529
+ const shotIds = new Set(table.shots.map((s) => s.id));
3530
+ const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
3531
+ shotId: shotIds.has(entry.shotId) ? entry.shotId : "",
3532
+ text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh")
3533
+ })).filter((x) => x.shotId !== "" && x.text !== "") : [];
3534
+ if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
3535
+ saveChapterStoryboard(project, outputDir, {
3536
+ chapterNo,
3537
+ prompts,
3538
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3539
+ });
3540
+ return prompts;
3541
+ }
3542
+ /**
3543
+ * 分镜·编剧级:单章 → 剧情骨架(节拍链)。
3544
+ * 只做故事层(事件/情绪/功能/因果),不做画面;导演级分镜在其上展开。
3545
+ */
3546
+ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapterNo) {
3547
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
3548
+ if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
3549
+ const body = readChapterFile(outputDir, chapter);
3550
+ if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
3551
+ const raw = parseJsonObject(await complete(ctx, config, {
3552
+ system: [
3553
+ "你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。",
3554
+ "任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。",
3555
+ "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}]}",
3556
+ "硬性要求:",
3557
+ "1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。",
3558
+ "2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。",
3559
+ "3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。",
3560
+ "4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
3561
+ "5. 直接输出 JSON 本身,不要输出思考过程或其他文字。"
3562
+ ].join("\n"),
3563
+ user: [
3564
+ `章节《${chapter.title}》(第 ${chapter.no} 章)`,
3565
+ `剧情要点:${chapter.beats !== void 0 && chapter.beats !== "" ? chapter.beats : "(未填写)"}`,
3566
+ "==================== 章节正文 ====================",
3567
+ body.replace(/^#\s+.*$/m, "").trim()
3568
+ ].join("\n"),
3569
+ temperature: .3,
3570
+ maxTokens: Math.max(config.maxTokens, 4e3),
3571
+ reasoning: config.analysisReasoning ?? "low"
3572
+ }));
3573
+ const arc = typeof raw.arc === "string" ? raw.arc.trim().slice(0, 200) : "";
3574
+ const functions = /* @__PURE__ */ new Set([
3575
+ "铺垫",
3576
+ "冲突",
3577
+ "转折",
3578
+ "高潮",
3579
+ "收束",
3580
+ "伏笔",
3581
+ "人物塑造"
3582
+ ]);
3583
+ const beats = Array.isArray(raw.beats) ? raw.beats.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
3584
+ id: "b" + (i + 1),
3585
+ event: typeof entry.event === "string" ? entry.event.trim().slice(0, 200) : "",
3586
+ emotion: typeof entry.emotion === "string" ? entry.emotion.trim().slice(0, 100) : "",
3587
+ function: functions.has(entry.function) ? entry.function : "铺垫",
3588
+ cause: typeof entry.cause === "string" && entry.cause.trim() !== "" ? entry.cause.trim().slice(0, 150) : void 0
3589
+ })).filter((b) => b.event !== "") : [];
3590
+ if (beats.length === 0) throw new Error("模型未输出有效节拍链,请重试");
3591
+ const skeleton = {
3592
+ chapterNo,
3593
+ arc: arc !== "" ? arc : "(本章弧线未生成)",
3594
+ beats
3595
+ };
3596
+ saveChapterStoryboard(project, outputDir, {
3597
+ chapterNo,
3598
+ skeleton,
3599
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
3600
+ });
3601
+ return skeleton;
3602
+ }
3052
3603
  /**
3053
3604
  * 反向推大纲:从已写章节正文反推出全书总纲(分卷 + 章节要点 + 主线/人物弧线/伏笔清单)。
3054
3605
  * 两阶段:分批提取章节事件摘要 → 汇总生成大纲。不修改章节/设定,只返回大纲文本。
@@ -4170,6 +4721,14 @@ const NOVEL_API = {
4170
4721
  bookshelfImportText: "/api/dsh-novel-forge/bookshelf/import-text",
4171
4722
  /** 导入 txt/md 全本:拆章预览(不落盘)。 */
4172
4723
  bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
4724
+ /** 漫剧方案管理:create/remove/activate。 */
4725
+ manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
4726
+ /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
4727
+ storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
4728
+ /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
4729
+ storyboardTable: "/api/dsh-novel-forge/storyboard/table",
4730
+ /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
4731
+ storyboardPrompts: "/api/dsh-novel-forge/storyboard/prompts",
4173
4732
  /** 重置项目(可选携带新大纲):清空设定/卷/章节/伏笔/资产/事实库。 */
4174
4733
  reset: "/api/dsh-novel-forge/reset",
4175
4734
  /** 全书一致性质检:LLM 扫描已生成章节,输出矛盾问题清单。 */
@@ -5013,6 +5572,8 @@ async function* runAssistantTurn(ctx, config, project, outputDir, userMessage) {
5013
5572
  //#region src/routes.ts
5014
5573
  /** Cap on JSON request bodies (generous: cover images travel as base64). */
5015
5574
  const MAX_JSON_BODY_BYTES = 64 * 1024 * 1024;
5575
+ /** 包内置风格效果图目录(assets/styles,随 npm 包分发)。 */
5576
+ const builtinStyleDir = fileURLToPath(new URL("../assets/styles/", import.meta.url));
5016
5577
  /** Loopback-only fence (mirrors the family plugins' pairing routes). */
5017
5578
  function isLoopbackRequest(request) {
5018
5579
  const address = request.socket.remoteAddress;
@@ -6644,7 +7205,7 @@ function makeRoutes(deps) {
6644
7205
  return;
6645
7206
  }
6646
7207
  try {
6647
- const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
7208
+ const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name, body?.styleId, body?.filterId);
6648
7209
  const role = project.roles.find((r) => r.name === name);
6649
7210
  if (role !== void 0) {
6650
7211
  role.imagePrompt = visual;
@@ -6669,7 +7230,7 @@ function makeRoutes(deps) {
6669
7230
  return;
6670
7231
  }
6671
7232
  try {
6672
- const kit = await generateRolePromptKit(ctx, config, project, name);
7233
+ const kit = await generateRolePromptKit(ctx, config, project, name, body?.styleId, body?.filterId);
6673
7234
  const role = project.roles.find((r) => r.name === name);
6674
7235
  if (role !== void 0) role.promptKit = kit;
6675
7236
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
@@ -6934,6 +7495,167 @@ function makeRoutes(deps) {
6934
7495
  }
6935
7496
  }
6936
7497
  };
7498
+ /** 分镜·提示词级:分镜表 → 即梦可粘贴视频提示词。 */
7499
+ const storyboardPromptsRoute = {
7500
+ kind: "exact",
7501
+ path: NOVEL_API.storyboardPrompts,
7502
+ handler: async (req, res) => {
7503
+ if (!guard(req, res, "POST")) return;
7504
+ const config = getConfig();
7505
+ const project = requireProject(res);
7506
+ if (project === void 0) return;
7507
+ const body = await readJsonBody(req);
7508
+ const no = body?.chapterNo;
7509
+ if (!Number.isInteger(no) || no === void 0 || no < 1) {
7510
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
7511
+ return;
7512
+ }
7513
+ if (body?.table === void 0 || (body.table.shots ?? []).length === 0) {
7514
+ writeJson(res, 400, { error: "table 不能为空,请先生成分镜表" });
7515
+ return;
7516
+ }
7517
+ try {
7518
+ writeJson(res, 200, { prompts: await generateStoryboardPrompts(ctx, config, project, config.outputDir, no, body.table, body?.styleId, body?.filterId) });
7519
+ } catch (error) {
7520
+ writeJson(res, 500, { error: `视频提示词生成失败:${error.message}` });
7521
+ }
7522
+ }
7523
+ };
7524
+ /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
7525
+ const storyboardTableRoute = {
7526
+ kind: "exact",
7527
+ path: NOVEL_API.storyboardTable,
7528
+ handler: async (req, res) => {
7529
+ if (!guard(req, res, "POST")) return;
7530
+ const config = getConfig();
7531
+ const project = requireProject(res);
7532
+ if (project === void 0) return;
7533
+ const body = await readJsonBody(req);
7534
+ const no = body?.chapterNo;
7535
+ if (!Number.isInteger(no) || no === void 0 || no < 1) {
7536
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
7537
+ return;
7538
+ }
7539
+ if (body?.skeleton === void 0 || (body.skeleton.beats ?? []).length === 0) {
7540
+ writeJson(res, 400, { error: "skeleton 不能为空,请先生成剧情骨架" });
7541
+ return;
7542
+ }
7543
+ try {
7544
+ writeJson(res, 200, { table: await generateStoryboardTable(ctx, config, project, config.outputDir, no, body.skeleton, body?.styleId, body?.filterId) });
7545
+ } catch (error) {
7546
+ writeJson(res, 500, { error: `分镜表生成失败:${error.message}` });
7547
+ }
7548
+ }
7549
+ };
7550
+ /** 漫剧方案管理:create / remove / activate。 */
7551
+ const manhuaPlansRoute = {
7552
+ kind: "exact",
7553
+ path: NOVEL_API.manhuaPlans,
7554
+ handler: async (req, res) => {
7555
+ if (!guard(req, res, "POST")) return;
7556
+ const config = getConfig();
7557
+ const project = requireProject(res);
7558
+ if (project === void 0) return;
7559
+ const body = await readJsonBody(req);
7560
+ if (project.mangaPlans === void 0) project.mangaPlans = [];
7561
+ if (body?.op === "create") {
7562
+ const name = body.name?.trim();
7563
+ const styleId = body.styleId?.trim();
7564
+ if (name === void 0 || name === "" || styleId === void 0 || styleId === "") {
7565
+ writeJson(res, 400, { error: "name 与 styleId 不能为空" });
7566
+ return;
7567
+ }
7568
+ if (project.mangaPlans.some((p) => p.name === name)) {
7569
+ writeJson(res, 400, { error: `方案名「${name}」已存在` });
7570
+ return;
7571
+ }
7572
+ const id = `manga-${Date.now().toString(36)}`;
7573
+ project.mangaPlans.push({
7574
+ id,
7575
+ name: name.slice(0, 40),
7576
+ styleId,
7577
+ filterId: body.filterId?.trim() !== "" ? body.filterId?.trim() : void 0,
7578
+ active: project.mangaPlans.length === 0,
7579
+ createdAt: (/* @__PURE__ */ new Date()).toISOString(),
7580
+ updatedAt: (/* @__PURE__ */ new Date()).toISOString()
7581
+ });
7582
+ } else if (body?.op === "remove" && body.id !== void 0) {
7583
+ const removed = project.mangaPlans.find((p) => p.id === body.id);
7584
+ project.mangaPlans = project.mangaPlans.filter((p) => p.id !== body.id);
7585
+ if (removed?.active === true && project.mangaPlans.length > 0) project.mangaPlans[0].active = true;
7586
+ } else if (body?.op === "activate" && body.id !== void 0) project.mangaPlans.forEach((p) => {
7587
+ p.active = p.id === body.id;
7588
+ });
7589
+ else {
7590
+ writeJson(res, 400, { error: "op 须为 create/remove/activate" });
7591
+ return;
7592
+ }
7593
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
7594
+ saveProject(config.outputDir, project);
7595
+ writeJson(res, 200, { plans: project.mangaPlans });
7596
+ }
7597
+ };
7598
+ /** 风格库效果图:GET /styles/image?id=<styleId>,从 ~/.dsh/dsh-novel-forge-styles/<id>.png 读取。 */
7599
+ const styleImageRoute = {
7600
+ kind: "exact",
7601
+ path: "/api/dsh-novel-forge/styles/image",
7602
+ handler: async (req, res) => {
7603
+ if (!isLoopbackRequest(req)) {
7604
+ writeJson(res, 403, { error: "forbidden: loopback-only" });
7605
+ return;
7606
+ }
7607
+ const id = (new URL(req.url ?? "", "http://localhost").searchParams.get("id") ?? "").replace(/[^a-z0-9-]/gi, "");
7608
+ if (id === "") {
7609
+ writeJson(res, 404, { error: "style image not found" });
7610
+ return;
7611
+ }
7612
+ const base = join(homedir(), ".dsh", "dsh-novel-forge-styles");
7613
+ const candidates = [
7614
+ {
7615
+ file: join(base, "thumbs", id + ".webp"),
7616
+ type: "image/webp"
7617
+ },
7618
+ {
7619
+ file: join(base, id + ".png"),
7620
+ type: "image/png"
7621
+ },
7622
+ {
7623
+ file: join(builtinStyleDir, id + ".webp"),
7624
+ type: "image/webp"
7625
+ }
7626
+ ];
7627
+ for (const c of candidates) if (existsSync(c.file)) {
7628
+ res.writeHead(200, {
7629
+ "content-type": c.type,
7630
+ "cache-control": "public, max-age=86400"
7631
+ });
7632
+ res.end(readFileSync(c.file));
7633
+ return;
7634
+ }
7635
+ writeJson(res, 404, { error: "style image not found" });
7636
+ }
7637
+ };
7638
+ /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
7639
+ const storyboardSkeletonRoute = {
7640
+ kind: "exact",
7641
+ path: NOVEL_API.storyboardSkeleton,
7642
+ handler: async (req, res) => {
7643
+ if (!guard(req, res, "POST")) return;
7644
+ const config = getConfig();
7645
+ const project = requireProject(res);
7646
+ if (project === void 0) return;
7647
+ const no = (await readJsonBody(req))?.chapterNo;
7648
+ if (!Number.isInteger(no) || no === void 0 || no < 1) {
7649
+ writeJson(res, 400, { error: "chapterNo 须为正整数" });
7650
+ return;
7651
+ }
7652
+ try {
7653
+ writeJson(res, 200, { skeleton: await generateStoryboardSkeleton(ctx, config, project, config.outputDir, no) });
7654
+ } catch (error) {
7655
+ writeJson(res, 500, { error: `剧情骨架生成失败:${error.message}` });
7656
+ }
7657
+ }
7658
+ };
6937
7659
  /** 拆书分析:对已写章节做结构/人物/文风/卖点体检(两阶段:源笔记→分节分析)。 */
6938
7660
  const breakdownRoute = {
6939
7661
  kind: "exact",
@@ -6964,7 +7686,7 @@ function makeRoutes(deps) {
6964
7686
  if (project.scenes === void 0) project.scenes = [];
6965
7687
  const op = body?.op;
6966
7688
  if (op === "extract") try {
6967
- const candidates = await extractScenes(ctx, config, project);
7689
+ const candidates = await extractScenes(ctx, config, project, body?.styleId, body?.filterId);
6968
7690
  writeJson(res, 200, {
6969
7691
  scenes: project.scenes,
6970
7692
  candidates
@@ -7100,6 +7822,11 @@ function makeRoutes(deps) {
7100
7822
  openFolderRoute,
7101
7823
  outlineSuggestRoute,
7102
7824
  outlineReverseRoute,
7825
+ manhuaPlansRoute,
7826
+ styleImageRoute,
7827
+ storyboardSkeletonRoute,
7828
+ storyboardTableRoute,
7829
+ storyboardPromptsRoute,
7103
7830
  breakdownRoute,
7104
7831
  {
7105
7832
  kind: "exact",