@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.
Files changed (47) hide show
  1. package/README.md +1 -1
  2. package/lib/client.js +3197 -1887
  3. package/lib/client.js.map +1 -1
  4. package/lib/index.js +1026 -84
  5. package/lib/index.js.map +1 -1
  6. package/lib/types/client/api.d.ts +2 -0
  7. package/lib/types/client/locales.d.ts +0 -1
  8. package/lib/types/client/panel/FlowGuide.d.ts +21 -0
  9. package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
  10. package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
  11. package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
  12. package/lib/types/engine.d.ts +15 -8
  13. package/lib/types/protocol.d.ts +138 -10
  14. package/lib/types/shot-language.d.ts +46 -0
  15. package/lib/types/story-beat-language.d.ts +35 -0
  16. package/package.json +5 -2
  17. package/skills/chapter-batch/SKILL.md +81 -0
  18. package/src/client/api.ts +5 -0
  19. package/src/client/locales.ts +0 -1
  20. package/src/client/panel/AssetsTab.tsx +22 -22
  21. package/src/client/panel/AssistantTab.tsx +3 -3
  22. package/src/client/panel/BookshelfBar.tsx +1 -1
  23. package/src/client/panel/CreateBookView.tsx +14 -10
  24. package/src/client/panel/FlowGuide.tsx +83 -0
  25. package/src/client/panel/ImportModal.tsx +11 -2
  26. package/src/client/panel/MangaRoleLibrary.tsx +810 -0
  27. package/src/client/panel/MangaWorkspace.tsx +359 -297
  28. package/src/client/panel/NovelPanel.tsx +108 -107
  29. package/src/client/panel/ReaderView.tsx +4 -4
  30. package/src/client/panel/RunPanel.tsx +9 -9
  31. package/src/client/panel/SceneLibrary.tsx +68 -67
  32. package/src/client/panel/ShelfView.tsx +2 -2
  33. package/src/client/panel/StoryboardTab.tsx +145 -49
  34. package/src/client/panel/StyleCard.tsx +13 -13
  35. package/src/client/panel/StyleGallery.tsx +2 -2
  36. package/src/client/panel/WorldTab.tsx +5 -5
  37. package/src/client/panel/panel.module.css +861 -510
  38. package/src/client/panel/views.tsx +24 -24
  39. package/src/engine.ts +514 -62
  40. package/src/index.ts +1 -0
  41. package/src/protocol.ts +147 -10
  42. package/src/routes.ts +216 -7
  43. package/src/run.ts +5 -4
  44. package/src/shot-language.ts +181 -0
  45. package/src/story-beat-language.ts +130 -0
  46. package/lib/types/client/panel/RoleVisualPanel.d.ts +0 -15
  47. package/src/client/panel/RoleVisualPanel.tsx +0 -485
package/lib/index.js CHANGED
@@ -2,7 +2,7 @@ import { homedir } from "node:os";
2
2
  import { basename, extname, join } from "node:path";
3
3
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
4
4
  import z from "schemastery";
5
- import { exec } from "node:child_process";
5
+ import { spawn } from "node:child_process";
6
6
  import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, writeFileSync } from "node:fs";
7
7
  import { fileURLToPath } from "node:url";
8
8
  import { BlockAssembler, ReasoningEffortId, createAssistantMessage, createUserMessage } from "@deepseek-ai/dsh-llm";
@@ -854,6 +854,338 @@ function styleKeywords(styleId, filterId) {
854
854
  return parts.join(",") || "3D动漫,超精细建模,电影光影";
855
855
  }
856
856
  //#endregion
857
+ //#region src/shot-language.ts
858
+ const SHOT_SIZES = [
859
+ {
860
+ id: "extreme_wide",
861
+ zh: "大远景",
862
+ en: "extreme wide shot",
863
+ hint: "环境为主,人物很小"
864
+ },
865
+ {
866
+ id: "wide",
867
+ zh: "远景",
868
+ en: "wide shot",
869
+ hint: "人在景中,交代环境"
870
+ },
871
+ {
872
+ id: "full",
873
+ zh: "全景",
874
+ en: "full shot",
875
+ hint: "全身』人物整体"
876
+ },
877
+ {
878
+ id: "medium",
879
+ zh: "中景",
880
+ en: "medium shot",
881
+ hint: "膝部以上,叙事主力"
882
+ },
883
+ {
884
+ id: "medium_close",
885
+ zh: "中近景",
886
+ en: "medium close-up",
887
+ hint: "胸以上,表情+动作"
888
+ },
889
+ {
890
+ id: "close",
891
+ zh: "近景",
892
+ en: "close-up",
893
+ hint: "肩以上,突出表情"
894
+ },
895
+ {
896
+ id: "extreme_close",
897
+ zh: "特写",
898
+ en: "extreme close-up",
899
+ hint: "脸/物细节"
900
+ },
901
+ {
902
+ id: "big_extreme_close",
903
+ zh: "大特写",
904
+ en: "big close-up",
905
+ hint: "局部(眼/手/标志物)"
906
+ }
907
+ ];
908
+ const CAMERA_MOVES = [
909
+ {
910
+ id: "static",
911
+ zh: "固定机位",
912
+ en: "static camera",
913
+ hint: "机位不动"
914
+ },
915
+ {
916
+ id: "dolly_in",
917
+ zh: "推近",
918
+ en: "dolly in",
919
+ hint: "镜头向主体逼近"
920
+ },
921
+ {
922
+ id: "dolly_out",
923
+ zh: "拉远",
924
+ en: "dolly out",
925
+ hint: "镜头远离主体"
926
+ },
927
+ {
928
+ id: "pan_left",
929
+ zh: "左摇",
930
+ en: "pan left",
931
+ hint: "机位不动,镜头左转"
932
+ },
933
+ {
934
+ id: "pan_right",
935
+ zh: "右摇",
936
+ en: "pan right",
937
+ hint: "机位不动,镜头右转"
938
+ },
939
+ {
940
+ id: "track_left",
941
+ zh: "左横移",
942
+ en: "track left",
943
+ hint: "机位随主体左移"
944
+ },
945
+ {
946
+ id: "track_right",
947
+ zh: "右横移",
948
+ en: "track right",
949
+ hint: "机位随主体右移"
950
+ },
951
+ {
952
+ id: "follow",
953
+ zh: "跟随",
954
+ en: "follow shot",
955
+ hint: "镜头跟着主体运动"
956
+ },
957
+ {
958
+ id: "pedestal_up",
959
+ zh: "升镜",
960
+ en: "pedestal up",
961
+ hint: "机位抬高"
962
+ },
963
+ {
964
+ id: "pedestal_down",
965
+ zh: "降镜",
966
+ en: "pedestal down",
967
+ hint: "机位降低"
968
+ },
969
+ {
970
+ id: "orbit",
971
+ zh: "环绕",
972
+ en: "orbit shot",
973
+ hint: "镜头绕主体转"
974
+ },
975
+ {
976
+ id: "handheld",
977
+ zh: "手持晃动",
978
+ en: "handheld",
979
+ hint: "真实感/紧张感"
980
+ },
981
+ {
982
+ id: "low_angle",
983
+ zh: "低机位仰拍",
984
+ en: "low angle",
985
+ hint: "突出威严/压迫"
986
+ },
987
+ {
988
+ id: "high_angle",
989
+ zh: "高机位俯拍",
990
+ en: "high angle",
991
+ hint: "突出渺小/全知"
992
+ },
993
+ {
994
+ id: "over_shoulder",
995
+ zh: "过肩镜头",
996
+ en: "over-the-shoulder",
997
+ hint: "对话常用"
998
+ }
999
+ ];
1000
+ const LIGHTINGS = [
1001
+ {
1002
+ id: "front",
1003
+ zh: "顺光",
1004
+ en: "front light",
1005
+ hint: "正面均匀照明"
1006
+ },
1007
+ {
1008
+ id: "side",
1009
+ zh: "侧光",
1010
+ en: "side light",
1011
+ hint: "明暗对比强"
1012
+ },
1013
+ {
1014
+ id: "back",
1015
+ zh: "逆光",
1016
+ en: "back light",
1017
+ hint: "轮廓光/剪影"
1018
+ },
1019
+ {
1020
+ id: "top",
1021
+ zh: "顶光",
1022
+ en: "top light",
1023
+ hint: "俯照硬朗"
1024
+ },
1025
+ {
1026
+ id: "rembrandt",
1027
+ zh: "伦勃朗光",
1028
+ en: "rembrandt lighting",
1029
+ hint: "侧逆光,经典人像"
1030
+ },
1031
+ {
1032
+ id: "neon",
1033
+ zh: "霓虹光",
1034
+ en: "neon lighting",
1035
+ hint: "赛博/夜景"
1036
+ },
1037
+ {
1038
+ id: "hard",
1039
+ zh: "硬光",
1040
+ en: "hard light",
1041
+ hint: "犀利阴影"
1042
+ },
1043
+ {
1044
+ id: "soft",
1045
+ zh: "柔光",
1046
+ en: "soft light",
1047
+ hint: "柔和过渡"
1048
+ },
1049
+ {
1050
+ id: "mood",
1051
+ zh: "氛围光",
1052
+ en: "mood lighting",
1053
+ hint: "情绪化色温"
1054
+ },
1055
+ {
1056
+ id: "contrast",
1057
+ zh: "高反差",
1058
+ en: "high contrast",
1059
+ hint: "黑白/强明暗"
1060
+ }
1061
+ ];
1062
+ /** 从中文文本归一化到景别(处理旧数据/LLM 口语)。未知回退 medium。 */
1063
+ function normalizeShotSize(text) {
1064
+ if (text === void 0 || text === "") return "medium";
1065
+ const t = text.trim();
1066
+ if (t.includes("大远景")) return "extreme_wide";
1067
+ if (t.includes("远景")) return "wide";
1068
+ if (t.includes("全景")) return "full";
1069
+ if (t.includes("中近景") || t.includes("中景")) return "medium";
1070
+ if (t.includes("近景") || t.includes("胸")) return "close";
1071
+ if (t.includes("大特写")) return "big_extreme_close";
1072
+ if (t.includes("特写")) return "extreme_close";
1073
+ return "medium";
1074
+ }
1075
+ /** 从中文文本归一化到运镜列表。 */
1076
+ function normalizeCameras(text) {
1077
+ if (text === void 0 || text === "") return ["static"];
1078
+ const t = text.trim();
1079
+ const out = [];
1080
+ const push = (id) => {
1081
+ if (!out.includes(id)) out.push(id);
1082
+ };
1083
+ if (t.includes("推近") || t.includes("推进")) push("dolly_in");
1084
+ if (t.includes("拉远") || t.includes("拉出")) push("dolly_out");
1085
+ if (t.includes("左摇")) push("pan_left");
1086
+ if (t.includes("右摇")) push("pan_right");
1087
+ if (t.includes("横移")) push(t.includes("左") ? "track_left" : "track_right");
1088
+ if (t.includes("跟随") || t.includes("跟拍")) push("follow");
1089
+ if (t.includes("升降") || t.includes("升") || t.includes("降")) push("pedestal_up");
1090
+ if (t.includes("环绕")) push("orbit");
1091
+ if (t.includes("手持") || t.includes("晃动")) push("handheld");
1092
+ if (t.includes("低机位") || t.includes("仰拍") || t.includes("仰")) push("low_angle");
1093
+ if (t.includes("高机位") || t.includes("俯拍") || t.includes("俯")) push("high_angle");
1094
+ if (t.includes("过肩")) push("over_shoulder");
1095
+ if (out.length === 0) push("static");
1096
+ return out;
1097
+ }
1098
+ /** 从中文文本归一化到构图(可选)。 */
1099
+ function normalizeComposition(text) {
1100
+ if (text === void 0 || text === "") return void 0;
1101
+ const t = text.trim();
1102
+ if (t.includes("三分")) return "rule_of_thirds";
1103
+ if (t.includes("中心") || t.includes("居中")) return "center";
1104
+ if (t.includes("引导")) return "leading_line";
1105
+ if (t.includes("前景")) return "foreground";
1106
+ if (t.includes("低机位")) return "low";
1107
+ if (t.includes("俯拍") || t.includes("俯视")) return "overhead";
1108
+ if (t.includes("对称")) return "symmetry";
1109
+ }
1110
+ /** 从中文文本归一化到光效列表。 */
1111
+ function normalizeLightings(text) {
1112
+ if (text === void 0 || text === "") return ["soft"];
1113
+ const t = text.trim();
1114
+ const out = [];
1115
+ const push = (id) => {
1116
+ if (!out.includes(id)) out.push(id);
1117
+ };
1118
+ if (t.includes("顺光")) push("front");
1119
+ if (t.includes("侧光") || t.includes("伦勃朗")) push(t.includes("伦勃朗") ? "rembrandt" : "side");
1120
+ if (t.includes("逆光")) push("back");
1121
+ if (t.includes("顶光")) push("top");
1122
+ if (t.includes("霓虹")) push("neon");
1123
+ if (t.includes("硬光")) push("hard");
1124
+ if (t.includes("柔光")) push("soft");
1125
+ if (t.includes("氛围") || t.includes("情绪")) push("mood");
1126
+ if (t.includes("高反差")) push("contrast");
1127
+ if (out.length === 0) push("soft");
1128
+ return out;
1129
+ }
1130
+ /** 取词条中文(ZHs 合并句子)。 */
1131
+ function sizeZh(id) {
1132
+ return SHOT_SIZES.find((e) => e.id === id)?.zh ?? "中景";
1133
+ }
1134
+ function cameraZh(ids) {
1135
+ if (ids === void 0 || ids.length === 0) return "固定机位";
1136
+ return CAMERA_MOVES.filter((e) => ids.includes(e.id)).map((e) => e.zh).join(" + ");
1137
+ }
1138
+ function lightZh(ids) {
1139
+ if (ids === void 0 || ids.length === 0) return "柔光";
1140
+ return LIGHTINGS.filter((e) => ids.includes(e.id)).map((e) => e.zh).join(" + ");
1141
+ }
1142
+ //#endregion
1143
+ //#region src/story-beat-language.ts
1144
+ /** 从中文归一化叙事功能(未知回退 exposition)。 */
1145
+ function normalizeStoryFunction(text) {
1146
+ if (text === void 0 || text === "") return "exposition";
1147
+ const t = text.trim();
1148
+ if (t.includes("铺垫")) return "exposition";
1149
+ if (t.includes("冲突")) return "conflict";
1150
+ if (t.includes("转折")) return "turn";
1151
+ if (t.includes("高潮")) return "climax";
1152
+ if (t.includes("收束")) return "resolve";
1153
+ if (t.includes("伏笔")) return "foreshadow";
1154
+ if (t.includes("人物塑造")) return "character";
1155
+ return "exposition";
1156
+ }
1157
+ /** 从中文文本归一化情绪词列表(可含→箭头链)。 */
1158
+ function normalizeEmotions(text) {
1159
+ if (text === void 0 || text === "") return ["calm"];
1160
+ const t = text.trim();
1161
+ const out = [];
1162
+ const push = (id) => {
1163
+ if (!out.includes(id)) out.push(id);
1164
+ };
1165
+ if (t.includes("平静")) push("calm");
1166
+ if (t.includes("淡然")) push("indifferent");
1167
+ if (t.includes("期待")) push("expectant");
1168
+ if (t.includes("好奇")) push("curious");
1169
+ if (t.includes("警觉")) push("alert");
1170
+ if (t.includes("压抑")) push("suppressed");
1171
+ if (t.includes("隐忍")) push("enduring");
1172
+ if (t.includes("担忧")) push("worried");
1173
+ if (t.includes("焦躁")) push("irritable");
1174
+ if (t.includes("不安")) push("uneasy");
1175
+ if (t.includes("惊惧")) push("terrified");
1176
+ if (t.includes("愤怒")) push("angry");
1177
+ if (t.includes("崩溃")) push("collapsing");
1178
+ if (t.includes("决绝")) push("resolute");
1179
+ if (t.includes("痛心")) push("grieved");
1180
+ if (t.includes("释然")) push("relieved");
1181
+ if (t.includes("悲凉")) push("bittersweet");
1182
+ if (t.includes("得意")) push("triumphant");
1183
+ if (t.includes("重生")) push("reborn");
1184
+ if (t.includes("麻木")) push("numb");
1185
+ if (out.length === 0) push("calm");
1186
+ return out;
1187
+ }
1188
+ //#endregion
857
1189
  //#region src/engine.ts
858
1190
  /**
859
1191
  * Novel engine — the host half's core: LLM-driven story-bible extraction,
@@ -950,6 +1282,7 @@ function mergeVolatileFromDisk(outputDir, project) {
950
1282
  if (disk === void 0) return;
951
1283
  project.bible = disk.bible;
952
1284
  project.roles = disk.roles;
1285
+ project.mangaRoles = disk.mangaRoles;
953
1286
  project.plotlines = disk.plotlines;
954
1287
  project.roleStatus = disk.roleStatus;
955
1288
  project.blurb = disk.blurb;
@@ -2023,8 +2356,9 @@ async function refreshPlotlineProgress(ctx, config, project, line) {
2023
2356
  async function extractRoles(ctx, config, project) {
2024
2357
  const system = [
2025
2358
  "你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
2026
- "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
2027
- "数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
2359
+ "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
2360
+ "数量控制:最多输出 10 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
2361
+ "重要:正常一部完整故事应提炼 6-10 个角色;若少于 4 个通常说明漏提炼,必须重新核对正文摘录。",
2028
2362
  "重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
2029
2363
  "输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
2030
2364
  "每个角色输出:",
@@ -2078,9 +2412,10 @@ async function extractRoles(ctx, config, project) {
2078
2412
  });
2079
2413
  let raw = parseJsonArray(text);
2080
2414
  const hasProtagonist = raw.some((e) => typeof e === "object" && e !== null && e.roleLabel === "protagonist");
2081
- if (raw.length === 0 || !hasProtagonist) {
2415
+ const tooFew = raw.length > 0 && raw.length < 4;
2416
+ if (raw.length === 0 || !hasProtagonist || tooFew) {
2082
2417
  text = await complete(ctx, config, {
2083
- system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
2418
+ system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足 4 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 6-10 个。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
2084
2419
  user,
2085
2420
  temperature: .3,
2086
2421
  maxTokens: Math.max(config.maxTokens, 24e3),
@@ -2097,12 +2432,11 @@ async function extractRoles(ctx, config, project) {
2097
2432
  "extra"
2098
2433
  ]);
2099
2434
  const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
2100
- const roles = [];
2101
- for (const entry of raw) {
2102
- if (typeof entry !== "object" || entry === null) continue;
2435
+ const toRole = (entry) => {
2436
+ if (typeof entry !== "object" || entry === null) return void 0;
2103
2437
  const name = typeof entry.name === "string" ? entry.name.trim().slice(0, 30) : "";
2104
- if (name === "") continue;
2105
- roles.push({
2438
+ if (name === "") return void 0;
2439
+ return {
2106
2440
  name,
2107
2441
  roleLabel: labels.has(entry.roleLabel) ? entry.roleLabel : "support",
2108
2442
  identity: typeof entry.identity === "string" ? entry.identity.slice(0, 100) : "",
@@ -2111,7 +2445,92 @@ async function extractRoles(ctx, config, project) {
2111
2445
  relations: strArr(entry.relations).map((r) => r.slice(0, 60)).slice(0, 10),
2112
2446
  arc: strArr(entry.arc).map((a) => a.slice(0, 120)).slice(0, 10),
2113
2447
  knowledge: strArr(entry.knowledge).map((k) => k.slice(0, 120)).slice(0, 12)
2114
- });
2448
+ };
2449
+ };
2450
+ const roles = [];
2451
+ for (const entry of raw) {
2452
+ const role = toRole(entry);
2453
+ if (role !== void 0) roles.push(role);
2454
+ }
2455
+ if (roles.length > 0) {
2456
+ const names = roles.map((r) => r.name).join("、");
2457
+ const patchSystem = system + "\n上一次已提炼角色:" + names + "。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。";
2458
+ try {
2459
+ const patchRaw = parseJsonArray(await complete(ctx, config, {
2460
+ system: patchSystem,
2461
+ user,
2462
+ temperature: .3,
2463
+ maxTokens: Math.max(config.maxTokens, 12e3),
2464
+ reasoning: config.analysisReasoning ?? "low"
2465
+ }));
2466
+ const existing = new Set(roles.map((r) => r.name));
2467
+ for (const entry of patchRaw) {
2468
+ const role = toRole(entry);
2469
+ if (role === void 0 || existing.has(role.name)) continue;
2470
+ existing.add(role.name);
2471
+ roles.push(role);
2472
+ if (roles.length >= 10) break;
2473
+ }
2474
+ } catch {}
2475
+ }
2476
+ const ROLE_TITLE_HINTS = [
2477
+ "站长",
2478
+ "律师",
2479
+ "检察官",
2480
+ "法官",
2481
+ "警察",
2482
+ "店主",
2483
+ "老板",
2484
+ "经理",
2485
+ "局长",
2486
+ "医生",
2487
+ "老师",
2488
+ "护士",
2489
+ "房东",
2490
+ "司机",
2491
+ "保安",
2492
+ "主管",
2493
+ "队长",
2494
+ "厂长",
2495
+ "董事长",
2496
+ "总裁",
2497
+ "市长",
2498
+ "道长",
2499
+ "掌门",
2500
+ "师父",
2501
+ "师傅",
2502
+ "管家"
2503
+ ];
2504
+ const existingNames = new Set(roles.map((r) => r.name));
2505
+ const titleCount = /* @__PURE__ */ new Map();
2506
+ for (const chapter of written) {
2507
+ const body = readChapterFile(config.outputDir, chapter);
2508
+ if (body === void 0) continue;
2509
+ for (const t of ROLE_TITLE_HINTS) {
2510
+ let idx = 0;
2511
+ let n = 0;
2512
+ while ((idx = body.indexOf(t, idx)) !== -1) {
2513
+ n++;
2514
+ idx += t.length;
2515
+ }
2516
+ titleCount.set(t, (titleCount.get(t) ?? 0) + n);
2517
+ }
2518
+ }
2519
+ for (const t of ROLE_TITLE_HINTS) {
2520
+ if (roles.length >= 10) break;
2521
+ if (!(existingNames.has(t) || [...existingNames].some((n) => n.includes(t))) && (titleCount.get(t) ?? 0) >= 3) {
2522
+ existingNames.add(t);
2523
+ roles.push({
2524
+ name: t,
2525
+ roleLabel: "support",
2526
+ identity: "身份型角色(正文反复出现)",
2527
+ traits: [],
2528
+ goals: "",
2529
+ relations: [],
2530
+ arc: [],
2531
+ knowledge: []
2532
+ });
2533
+ }
2115
2534
  }
2116
2535
  return roles;
2117
2536
  }
@@ -2457,13 +2876,11 @@ function withStyle(pair, styleWords) {
2457
2876
  };
2458
2877
  }
2459
2878
  /**
2460
- * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
2461
- * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
2879
+ * 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
2880
+ * 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
2462
2881
  */
2463
- async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
2464
- const role = (project.roles ?? []).find((r) => r.name === roleName);
2465
- if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2466
- role.promptStyleId = styleId;
2882
+ async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId, shortDrama = false) {
2883
+ const roleName = role.name;
2467
2884
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
2468
2885
  const roleText = `${role.name} ${role.identity ?? ""}`;
2469
2886
  const colorWords = [
@@ -2577,6 +2994,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2577
2994
  "2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
2578
2995
  "3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。",
2579
2996
  "4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
2997
+ "5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
2998
+ ...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。"] : [],
2580
2999
  "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
2581
3000
  ...rules.map((r) => "- " + r),
2582
3001
  "输出六部分:",
@@ -2598,7 +3017,7 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2598
3017
  user: [
2599
3018
  `书名:《${project.bookName}》`,
2600
3019
  `目标角色:${role.name}(${role.identity})`,
2601
- role.traits.length > 0 ? `性格标签:${role.traits.join("、")}` : "",
3020
+ (role.traits ?? []).length > 0 ? `性格标签:${role.traits.join("、")}` : "",
2602
3021
  `正文出场描写(含外貌线索的段落):`,
2603
3022
  ...excerpts.map((e) => `[第${e.no}章] ${e.text}`),
2604
3023
  "只输出 JSON 对象。"
@@ -2658,15 +3077,35 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2658
3077
  promptKit
2659
3078
  };
2660
3079
  }
2661
- /**
2662
- * 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
2663
- * LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
2664
- */
2665
- async function generateRolePromptKit(ctx, config, project, roleName, styleId, filterId) {
3080
+ /** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
3081
+ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
2666
3082
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2667
3083
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2668
- if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
2669
3084
  role.promptStyleId = styleId;
3085
+ return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId);
3086
+ }
3087
+ /** 漫剧角色卡:提炼形象锚点并写回漫剧卡(status → anchored)。 */
3088
+ async function extractMangaRoleVisual(ctx, config, project, outputDir, cardId, styleId, filterId) {
3089
+ const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
3090
+ if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
3091
+ const visual = await extractRoleVisualFrom(ctx, config, project, outputDir, {
3092
+ name: card.name,
3093
+ identity: card.identity,
3094
+ traits: card.traits
3095
+ }, styleId, filterId, project.shortDramaMode === true);
3096
+ card.promptStyleId = styleId;
3097
+ card.imagePrompt = visual;
3098
+ if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions;
3099
+ if (visual.promptKit !== void 0) card.promptKit = visual.promptKit;
3100
+ if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
3101
+ card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3102
+ return visual;
3103
+ }
3104
+ /**
3105
+ * 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
3106
+ */
3107
+ async function generateRolePromptKitFrom(ctx, config, project, role, styleId, filterId, shortDrama = false) {
3108
+ role.name;
2670
3109
  const rules = project.visualRules ?? [];
2671
3110
  const styleWords = styleKeywords(styleId, filterId);
2672
3111
  const raw = parseJsonObject(await complete(ctx, config, {
@@ -2679,6 +3118,8 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
2679
3118
  "2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
2680
3119
  "3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
2681
3120
  "4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
3121
+ "5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
3122
+ ...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。"] : [],
2682
3123
  "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
2683
3124
  ...rules.map((r) => "- " + r),
2684
3125
  "zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
@@ -2721,6 +3162,198 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
2721
3162
  if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
2722
3163
  return kit;
2723
3164
  }
3165
+ /** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
3166
+ async function generateRolePromptKit(ctx, config, project, roleName, styleId, filterId) {
3167
+ const role = (project.roles ?? []).find((r) => r.name === roleName);
3168
+ if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
3169
+ if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
3170
+ role.promptStyleId = styleId;
3171
+ return generateRolePromptKitFrom(ctx, config, project, {
3172
+ name: role.name,
3173
+ identity: role.identity,
3174
+ imagePrompt: role.imagePrompt,
3175
+ expressions: role.expressions
3176
+ }, styleId, filterId);
3177
+ }
3178
+ /** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
3179
+ async function generateMangaRolePromptKit(ctx, config, project, cardId, styleId, filterId) {
3180
+ const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
3181
+ if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
3182
+ if (card.imagePrompt === void 0) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`);
3183
+ const kit = await generateRolePromptKitFrom(ctx, config, project, {
3184
+ name: card.name,
3185
+ identity: card.identity,
3186
+ imagePrompt: card.imagePrompt,
3187
+ expressions: card.expressions
3188
+ }, styleId, filterId, project.shortDramaMode === true);
3189
+ card.promptStyleId = styleId;
3190
+ card.promptKit = kit;
3191
+ if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
3192
+ card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3193
+ return kit;
3194
+ }
3195
+ /**
3196
+ * 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
3197
+ * 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
3198
+ * 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
3199
+ */
3200
+ async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
3201
+ const entry = (project.storyboards ?? []).find((e) => e.chapterNo === chapterNo);
3202
+ if (entry === void 0) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`);
3203
+ const names = [];
3204
+ const pushNames = (list) => {
3205
+ for (const n of list ?? []) {
3206
+ const t = n.trim().slice(0, 20);
3207
+ if (t !== "" && !names.includes(t)) names.push(t);
3208
+ }
3209
+ };
3210
+ pushNames(entry.skeleton?.characters);
3211
+ pushNames(entry.table?.characters);
3212
+ for (const s of entry.table?.shots ?? []) pushNames(s.characters);
3213
+ if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`);
3214
+ const imported = /* @__PURE__ */ new Set();
3215
+ for (const c of project.mangaRoles ?? []) {
3216
+ imported.add(c.name);
3217
+ if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "") imported.add(c.sourceRoleName);
3218
+ }
3219
+ const already = names.filter((n) => imported.has(n)).map((n) => ({
3220
+ rawName: n,
3221
+ verdict: "already_imported",
3222
+ matches: [],
3223
+ suggested: emptyCandidateSuggestion(n)
3224
+ }));
3225
+ const fresh = names.filter((n) => !imported.has(n));
3226
+ if (fresh.length === 0) return already;
3227
+ const chapter = project.chapters.find((c) => c.no === chapterNo);
3228
+ const body = chapter !== void 0 ? readChapterFile(outputDir, chapter) : void 0;
3229
+ const roles = project.roles ?? [];
3230
+ const shortlists = fresh.map((rawName) => {
3231
+ const shortlist = [];
3232
+ const push = (n) => {
3233
+ if (!shortlist.includes(n)) shortlist.push(n);
3234
+ };
3235
+ for (const r of roles) {
3236
+ if (shortlist.length >= 5) break;
3237
+ const n = r.name;
3238
+ if (n === rawName) {
3239
+ push(n);
3240
+ continue;
3241
+ }
3242
+ if (n.includes(rawName) || rawName.includes(n)) {
3243
+ push(n);
3244
+ continue;
3245
+ }
3246
+ const ident = r.identity ?? "";
3247
+ if (ident !== "" && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n);
3248
+ }
3249
+ return {
3250
+ rawName,
3251
+ roleNames: shortlist
3252
+ };
3253
+ });
3254
+ const beats = (entry.skeleton?.beats ?? []).map((b) => `[${b.id}] ${b.event}`).join("\n");
3255
+ const raw = parseJsonArray(await complete(ctx, config, {
3256
+ system: [
3257
+ "你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。",
3258
+ "任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。",
3259
+ "规则:",
3260
+ "1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。",
3261
+ "2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。",
3262
+ "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」)。",
3263
+ "4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。",
3264
+ ...project.shortDramaMode === true ? ["5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。"] : [],
3265
+ "输出必须是合法 JSON 数组:[{\"rawName\": \"...\", \"verdict\": \"...\", \"roleName\": \"...或省略\", \"suggested\": {...}}],每个提名都要有,数组长度必须等于提名数。",
3266
+ "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
3267
+ ].join("\n"),
3268
+ user: [
3269
+ `章节:第 ${chapterNo} 章《${chapter?.title ?? ""}》`,
3270
+ "==== 剧情骨架(节拍) ====",
3271
+ beats !== "" ? beats : "(无骨架节拍)",
3272
+ "==== 角色提名与候选名单 ====",
3273
+ shortlists.map((s) => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join("、")}` : `[提名] ${s.rawName} → 候选:(无)`).join("\n"),
3274
+ body !== void 0 ? "==== 章节正文(前 2500 字,判断称谓归属用) ====\n" + body.replace(/^#.*$/gm, "").trim().slice(0, 2500) : "",
3275
+ "只输出 JSON 数组。"
3276
+ ].filter((x) => x !== "").join("\n\n"),
3277
+ temperature: .2,
3278
+ maxTokens: Math.max(config.maxTokens, 8e3),
3279
+ reasoning: config.analysisReasoning ?? "low"
3280
+ }));
3281
+ const verdicts = /* @__PURE__ */ new Set([
3282
+ "matched",
3283
+ "ambiguous",
3284
+ "not_in_library"
3285
+ ]);
3286
+ const fnMap = {
3287
+ protagonist: "protagonist",
3288
+ mentor: "mentor",
3289
+ love_interest: "love_interest",
3290
+ antagonist: "antagonist",
3291
+ sidekick: "sidekick",
3292
+ informant: "informant",
3293
+ functional: "functional"
3294
+ };
3295
+ const relMap = {
3296
+ enemy: "enemy",
3297
+ friend: "friend",
3298
+ mentor: "mentor",
3299
+ lover: "lover",
3300
+ exploit: "exploit",
3301
+ neutral: "neutral"
3302
+ };
3303
+ const byName = /* @__PURE__ */ new Map();
3304
+ for (const e of raw) if (typeof e === "object" && e !== null && typeof e.rawName === "string") byName.set(e.rawName.trim(), e);
3305
+ const str = (v) => typeof v === "string" ? v.trim().slice(0, 100) : "";
3306
+ const strArr = (v, n) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "").map((x) => x.trim().slice(0, 20)).slice(0, n) : [];
3307
+ const out = [];
3308
+ for (const item of shortlists) {
3309
+ const judge = byName.get(item.rawName);
3310
+ const judgeVerdict = judge !== void 0 ? str(judge.verdict) : "";
3311
+ const verdict = verdicts.has(judgeVerdict) ? judgeVerdict : item.roleNames.length > 0 ? "ambiguous" : "not_in_library";
3312
+ let roleName;
3313
+ if (judge !== void 0) {
3314
+ const chosen = str(judge.roleName);
3315
+ if (chosen !== "" && item.roleNames.includes(chosen)) roleName = chosen;
3316
+ }
3317
+ if (verdict === "matched" && roleName === void 0 && item.roleNames.length === 1) roleName = item.roleNames[0];
3318
+ const sug = typeof judge?.suggested === "object" && judge?.suggested !== null ? judge.suggested : {};
3319
+ const novelHint = verdict === "not_in_library" && body !== void 0 ? body.includes(item.rawName) ? "backfill" : "manga_new" : void 0;
3320
+ const suggested = {
3321
+ name: (str(sug.name) !== "" ? str(sug.name) : roleName ?? item.rawName).slice(0, 30),
3322
+ identity: str(sug.identity).slice(0, 60),
3323
+ coreFunction: fnMap[str(sug.coreFunction)] ?? "functional",
3324
+ protagonistRelation: relMap[str(sug.protagonistRelation)] ?? "neutral",
3325
+ speechStyle: str(sug.speechStyle).slice(0, 60),
3326
+ traits: strArr(sug.traits, 3),
3327
+ appearance: str(sug.appearance).slice(0, 100),
3328
+ keyScenes: strArr(sug.keyScenes, 3)
3329
+ };
3330
+ out.push({
3331
+ rawName: item.rawName,
3332
+ verdict,
3333
+ matches: item.roleNames.map((n) => ({
3334
+ roleName: n,
3335
+ reason: roleName === n ? "LLM 确认" : "规则候选"
3336
+ })),
3337
+ matchedRoleName: roleName,
3338
+ novelHint,
3339
+ suggested
3340
+ });
3341
+ }
3342
+ return [...already, ...out];
3343
+ }
3344
+ /** 空建议(already_imported 等无需 LLM 的候选用)。 */
3345
+ function emptyCandidateSuggestion(name) {
3346
+ return {
3347
+ name,
3348
+ identity: "",
3349
+ coreFunction: "functional",
3350
+ protagonistRelation: "neutral",
3351
+ speechStyle: "",
3352
+ traits: [],
3353
+ appearance: "",
3354
+ keyScenes: []
3355
+ };
3356
+ }
2724
3357
  /** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
2725
3358
  async function extractVisualRules(ctx, config, project) {
2726
3359
  const system = [
@@ -2997,10 +3630,8 @@ async function testImageEndpoint(baseURL, apiKey, model) {
2997
3630
  };
2998
3631
  }
2999
3632
  }
3000
- async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "", modelId) {
3001
- const role = project.roles?.find((r) => r.name === name);
3002
- if (role === void 0) throw new Error(`角色 ${name} 不存在`);
3003
- if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
3633
+ /** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
3634
+ async function generateReferenceImageFrom(config, anchor, style = "", modelId) {
3004
3635
  if (config.imageApiEnabled !== true) throw new Error("生图未启用,请在设置中启用一个生图模型");
3005
3636
  const activeImage = (modelId !== void 0 && modelId !== "" ? (config.imageModels ?? []).find((m) => m.id === modelId) : void 0) ?? (config.imageModels ?? []).find((m) => m.enabled) ?? (config.imageModels ?? [])[0];
3006
3637
  const apiKey = activeImage?.apiKey ?? config.imageApiKey;
@@ -3010,9 +3641,9 @@ async function generateRoleReferenceImage(ctx, config, project, outputDir, name,
3010
3641
  if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置生图模型的 API Key 和模型 id");
3011
3642
  const stylePrompt = getComicStylePrompt(style);
3012
3643
  const prompt = [
3013
- role.imagePrompt.zh,
3014
- role.imagePrompt.en,
3015
- role.imagePrompt.tags.join(", "),
3644
+ anchor.zh,
3645
+ anchor.en,
3646
+ anchor.tags.join(", "),
3016
3647
  stylePrompt,
3017
3648
  "character reference sheet, front view, clean background, consistent design"
3018
3649
  ].filter(Boolean).join(", ");
@@ -3040,10 +3671,27 @@ async function generateRoleReferenceImage(ctx, config, project, outputDir, name,
3040
3671
  const imgRes = await fetch(url);
3041
3672
  if (!imgRes.ok) throw new Error("下载生图结果失败");
3042
3673
  const buf = Buffer.from(await imgRes.arrayBuffer());
3043
- const dataUrl = `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
3674
+ return `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
3675
+ }
3676
+ /** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
3677
+ async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "", modelId) {
3678
+ const role = project.roles?.find((r) => r.name === name);
3679
+ if (role === void 0) throw new Error(`角色 ${name} 不存在`);
3680
+ if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
3681
+ const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId);
3044
3682
  role.imageUrl = dataUrl;
3045
3683
  return dataUrl;
3046
3684
  }
3685
+ /** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
3686
+ async function generateMangaRoleReferenceImage(ctx, config, project, outputDir, cardId, style = "", modelId) {
3687
+ const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
3688
+ if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
3689
+ if (card.imagePrompt === void 0) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`);
3690
+ const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId);
3691
+ card.imageUrl = dataUrl;
3692
+ card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3693
+ return dataUrl;
3694
+ }
3047
3695
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
3048
3696
  async function analyzePlotlineHealth(ctx, config, project) {
3049
3697
  const system = [
@@ -3448,6 +4096,47 @@ function saveChapterStoryboard(project, outputDir, entry) {
3448
4096
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3449
4097
  saveProject(outputDir, project);
3450
4098
  }
4099
+ /** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
4100
+ function guessCharactersFromRoles(project, body, limit = 12) {
4101
+ const out = [];
4102
+ for (const r of project.roles ?? []) {
4103
+ if (out.length >= limit) break;
4104
+ if (r.name !== "" && !out.includes(r.name) && body.includes(r.name)) out.push(r.name);
4105
+ }
4106
+ return out;
4107
+ }
4108
+ /** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
4109
+ function sanitizeCharacters(raw, limit) {
4110
+ if (!Array.isArray(raw)) return [];
4111
+ const out = [];
4112
+ for (const v of raw) {
4113
+ if (typeof v !== "string") continue;
4114
+ const name = v.trim().slice(0, 20);
4115
+ if (name === "" || out.includes(name)) continue;
4116
+ out.push(name);
4117
+ if (out.length >= limit) break;
4118
+ }
4119
+ return out;
4120
+ }
4121
+ function buildMangaRoleBindings(project) {
4122
+ const cards = project.mangaRoles ?? [];
4123
+ const byName = /* @__PURE__ */ new Map();
4124
+ for (const c of cards) {
4125
+ if (c.name !== "") byName.set(c.name, c);
4126
+ if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "") byName.set(c.sourceRoleName, c);
4127
+ }
4128
+ return {
4129
+ byId: new Map(cards.map((c) => [c.id, c])),
4130
+ resolve: (name) => {
4131
+ const exact = byName.get(name);
4132
+ if (exact !== void 0) return exact;
4133
+ for (const c of cards) {
4134
+ if (c.name !== "" && (c.name.includes(name) || name.includes(c.name))) return c;
4135
+ if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "" && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c;
4136
+ }
4137
+ }
4138
+ };
4139
+ }
3451
4140
  /**
3452
4141
  * 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
3453
4142
  * 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
@@ -3461,6 +4150,12 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3461
4150
  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 ?? ""}`);
3462
4151
  const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6);
3463
4152
  const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
4153
+ const bindings = buildMangaRoleBindings(project);
4154
+ const mangaLines = (bindings.byId.size > 0 ? [...bindings.byId.values()].filter((c) => body.includes(c.name) || c.sourceRoleName !== void 0 && body.includes(c.sourceRoleName)).slice(0, 8) : []).map((c) => {
4155
+ const anchor = c.imagePrompt !== void 0 ? c.imagePrompt.zh.slice(0, 90) : c.appearance !== "" ? c.appearance : "";
4156
+ const ref = c.imageUrl !== void 0 ? ";参考图:有" : (c.gallery ?? []).some((g) => g.label.includes("立绘")) ? ";参考图:有(立绘)" : "";
4157
+ return `${c.name}(${c.identity ?? ""}):定妆${anchor !== "" ? "「" + anchor + "」" : "(锚点未生成)"}${ref}`;
4158
+ });
3464
4159
  const rules = (project.visualRules ?? []).map((r) => "- " + r);
3465
4160
  const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
3466
4161
  const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
@@ -3472,8 +4167,11 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3472
4167
  system: [
3473
4168
  "你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
3474
4169
  "任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
3475
- "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\"}]}",
3476
- "景别取值:远景/全景/中景/近景/特写。",
4170
+ "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
4171
+ "景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。",
4172
+ "运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。",
4173
+ "光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。",
4174
+ "构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。",
3477
4175
  "硬性要求:",
3478
4176
  "【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
3479
4177
  ...styleLines,
@@ -3482,7 +4180,9 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3482
4180
  "3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
3483
4181
  "4. 台词/音效/光效无则空字符串,不要编造。",
3484
4182
  "5. duration 1-12 秒。",
3485
- "6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。"
4183
+ "6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。",
4184
+ "7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。",
4185
+ "8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。"
3486
4186
  ].join("\n"),
3487
4187
  user: [
3488
4188
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -3490,44 +4190,58 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3490
4190
  `弧线:${skeleton.arc}`,
3491
4191
  skeleton.beats.map((b) => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== void 0 ? ";承接:" + b.cause : ""})`).join("\n"),
3492
4192
  roles.length > 0 ? "==== 出场角色锚点 ====\n" + roles.join("\n") : "",
4193
+ mangaLines.length > 0 ? "==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n" + mangaLines.join("\n") : "",
3493
4194
  scenes.length > 0 ? "==== 相关场景 ====\n" + scenes.join("\n") : "",
3494
4195
  rules.length > 0 ? "==== 本书视觉规则(必须内嵌进 visual) ====\n" + rules.join("\n") : "",
3495
4196
  "==== 章节正文(画面细节以此为准) ====",
3496
4197
  body.replace(/^#\s+.*$/m, "").trim().slice(0, 3e3)
3497
4198
  ].filter((s) => s !== "").join("\n\n"),
3498
4199
  temperature: .3,
3499
- maxTokens: Math.max(config.maxTokens, 6e3),
4200
+ maxTokens: Math.max(config.maxTokens, 16e3),
3500
4201
  reasoning: config.analysisReasoning ?? "low"
3501
4202
  }));
3502
4203
  const beatIds = new Set(skeleton.beats.map((b) => b.id));
3503
- const shotKinds = /* @__PURE__ */ new Set([
3504
- "远景",
3505
- "全景",
3506
- "中景",
3507
- "近景",
3508
- "特写"
3509
- ]);
3510
4204
  const shots = Array.isArray(raw.shots) ? raw.shots.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
3511
4205
  id: "s" + (i + 1),
3512
4206
  beatId: beatIds.has(entry.beatId) ? entry.beatId : skeleton.beats[0].id,
3513
- shot: shotKinds.has(entry.shot) ? entry.shot : "中景",
3514
- camera: typeof entry.camera === "string" ? entry.camera.trim().slice(0, 80) : "",
4207
+ shot: normalizeShotSize(entry.shot),
4208
+ camera: normalizeCameras(entry.camera),
4209
+ composition: normalizeComposition(entry.composition),
3515
4210
  duration: typeof entry.duration === "number" && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
3516
4211
  visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
3517
4212
  line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
3518
4213
  sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
3519
- light: typeof entry.light === "string" ? entry.light.trim().slice(0, 80) : "",
4214
+ light: normalizeLightings(entry.light),
3520
4215
  prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
3521
- nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : ""
4216
+ nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : "",
4217
+ characters: sanitizeCharacters(entry.characters, 4)
3522
4218
  })).filter((s) => s.visual !== "") : [];
3523
4219
  if (shots.length === 0) throw new Error("模型未输出有效镜头,请重试");
3524
4220
  const covered = new Set(shots.map((s) => s.beatId));
3525
4221
  const missing = skeleton.beats.filter((b) => !covered.has(b.id)).map((b) => b.id);
3526
4222
  if (missing.length > 0) console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(",")} 无镜头覆盖`);
4223
+ const tableChars = [];
4224
+ const tableRoleIds = [];
4225
+ for (const s of shots) {
4226
+ const ids = [];
4227
+ for (const n of s.characters ?? []) {
4228
+ const card = bindings.resolve(n);
4229
+ if (card !== void 0 && !ids.includes(card.id)) ids.push(card.id);
4230
+ }
4231
+ s.mangaRoleIds = ids.length > 0 ? ids : void 0;
4232
+ for (const c of s.characters ?? []) {
4233
+ if (!tableChars.includes(c)) tableChars.push(c);
4234
+ if (tableChars.length >= 12) break;
4235
+ }
4236
+ for (const id of ids) if (!tableRoleIds.includes(id)) tableRoleIds.push(id);
4237
+ }
4238
+ const fallbackChars = (skeleton.characters ?? guessCharactersFromRoles(project, body, 12)).slice(0, 12);
3527
4239
  const table = {
3528
4240
  chapterNo,
3529
4241
  shots,
3530
- usedScenes: usedScenes.map((s) => s.name)
4242
+ usedScenes: usedScenes.map((s) => s.name),
4243
+ characters: tableChars.length > 0 ? tableChars : fallbackChars,
4244
+ mangaRoleIds: tableRoleIds.length > 0 ? tableRoleIds : void 0
3531
4245
  };
3532
4246
  saveChapterStoryboard(project, outputDir, {
3533
4247
  chapterNo,
@@ -3548,11 +4262,21 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3548
4262
  const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
3549
4263
  const stylePrefix = [baseStyle?.keywords, filterStyle?.keywords].filter((v) => v !== void 0 && v !== "").join(",") || "3D动漫,超精细建模,电影光影";
3550
4264
  const rules = (project.visualRules ?? []).map((r) => "- " + r);
3551
- const shotLines = table.shots.map((s) => `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
4265
+ const bindings = buildMangaRoleBindings(project);
4266
+ const shotLines = table.shots.map((s) => {
4267
+ const makeUp = (s.characters ?? []).map((n) => bindings.resolve(n)).filter((c) => c !== void 0).map((c) => {
4268
+ const anchor = c.imagePrompt !== void 0 ? c.imagePrompt.zh.slice(0, 60) : c.appearance !== "" ? c.appearance : "";
4269
+ const ref = c.imageUrl !== void 0 ? "(参考图)" : "";
4270
+ return c.name + ":" + anchor + ref;
4271
+ }).join(";");
4272
+ return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
4273
+ 出场:${s.characters !== void 0 && s.characters.length > 0 ? s.characters.join("、") : "(未标注)"}
4274
+ 定妆:${makeUp !== "" ? makeUp : "(未绑定漫剧卡)"}
3552
4275
  画面:${s.visual}
3553
4276
  台词:${s.line !== "" ? s.line : "(无)"}
3554
- 光效:${s.light !== "" ? s.light : "(无)"}
3555
- 承接:${s.prevState} → ${s.nextState}`).join("\n\n");
4277
+ 光效:${lightZh(s.light) !== "" ? lightZh(s.light) : "(无)"}
4278
+ 承接:${s.prevState} → ${s.nextState}`;
4279
+ }).join("\n\n");
3556
4280
  const raw = parseJsonObject(await complete(ctx, config, {
3557
4281
  system: [
3558
4282
  "你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。",
@@ -3562,7 +4286,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3562
4286
  "重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
3563
4287
  "硬性要求:",
3564
4288
  "1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
3565
- "2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。",
4289
+ "2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。",
3566
4290
  "3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。",
3567
4291
  "4. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\": \"s1\", \"text\": \"...\"}]},所有镜头都要有,顺序一致。",
3568
4292
  "5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。"
@@ -3575,14 +4299,20 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3575
4299
  "只输出 JSON 对象。"
3576
4300
  ].filter((x) => x !== "").join("\n\n"),
3577
4301
  temperature: .3,
3578
- maxTokens: Math.max(config.maxTokens, 6e3),
4302
+ maxTokens: Math.max(config.maxTokens, 16e3),
3579
4303
  reasoning: config.analysisReasoning ?? "low"
3580
4304
  }));
3581
4305
  const shotIds = new Set(table.shots.map((s) => s.id));
3582
- const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
3583
- shotId: shotIds.has(entry.shotId) ? entry.shotId : "",
3584
- text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh")
3585
- })).filter((x) => x.shotId !== "" && x.text !== "") : [];
4306
+ const shotBinding = new Map(table.shots.map((s) => [s.id, s.mangaRoleIds ?? []]));
4307
+ const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => {
4308
+ const shotId = shotIds.has(entry.shotId) ? entry.shotId : "";
4309
+ const ids = shotBinding.get(shotId) ?? [];
4310
+ return {
4311
+ shotId,
4312
+ text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh"),
4313
+ mangaRoleIds: ids.length > 0 ? ids : void 0
4314
+ };
4315
+ }).filter((x) => x.shotId !== "" && x.text !== "") : [];
3586
4316
  if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
3587
4317
  saveChapterStoryboard(project, outputDir, {
3588
4318
  chapterNo,
@@ -3604,13 +4334,16 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
3604
4334
  system: [
3605
4335
  "你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。",
3606
4336
  "任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。",
3607
- "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}]}",
4337
+ "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(情绪词数组)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}], \"characters\": [\"出镜角色名1\", \"出镜角色名2\"]}",
4338
+ "情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。",
4339
+ "功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。",
3608
4340
  "硬性要求:",
3609
4341
  "1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。",
3610
4342
  "2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。",
3611
4343
  "3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。",
3612
4344
  "4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
3613
- "5. 直接输出 JSON 本身,不要输出思考过程或其他文字。"
4345
+ "5. 直接输出 JSON 本身,不要输出思考过程或其他文字。",
4346
+ "6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。"
3614
4347
  ].join("\n"),
3615
4348
  user: [
3616
4349
  `章节《${chapter.title}》(第 ${chapter.no} 章)`,
@@ -3623,27 +4356,21 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
3623
4356
  reasoning: config.analysisReasoning ?? "low"
3624
4357
  }));
3625
4358
  const arc = typeof raw.arc === "string" ? raw.arc.trim().slice(0, 200) : "";
3626
- const functions = /* @__PURE__ */ new Set([
3627
- "铺垫",
3628
- "冲突",
3629
- "转折",
3630
- "高潮",
3631
- "收束",
3632
- "伏笔",
3633
- "人物塑造"
3634
- ]);
3635
4359
  const beats = Array.isArray(raw.beats) ? raw.beats.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
3636
4360
  id: "b" + (i + 1),
3637
4361
  event: typeof entry.event === "string" ? entry.event.trim().slice(0, 200) : "",
3638
- emotion: typeof entry.emotion === "string" ? entry.emotion.trim().slice(0, 100) : "",
3639
- function: functions.has(entry.function) ? entry.function : "铺垫",
4362
+ emotion: Array.isArray(entry.emotion) ? normalizeEmotions(entry.emotion.filter((x) => typeof x === "string").join("→")) : normalizeEmotions(typeof entry.emotion === "string" ? entry.emotion : void 0),
4363
+ function: normalizeStoryFunction(typeof entry.function === "string" ? entry.function : void 0),
3640
4364
  cause: typeof entry.cause === "string" && entry.cause.trim() !== "" ? entry.cause.trim().slice(0, 150) : void 0
3641
4365
  })).filter((b) => b.event !== "") : [];
3642
4366
  if (beats.length === 0) throw new Error("模型未输出有效节拍链,请重试");
4367
+ const skeletonChars = sanitizeCharacters(raw.characters, 12);
4368
+ const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12);
3643
4369
  const skeleton = {
3644
4370
  chapterNo,
3645
4371
  arc: arc !== "" ? arc : "(本章弧线未生成)",
3646
- beats
4372
+ beats,
4373
+ characters: chars.length > 0 ? chars : void 0
3647
4374
  };
3648
4375
  saveChapterStoryboard(project, outputDir, {
3649
4376
  chapterNo,
@@ -4708,10 +5435,11 @@ var ProductionRunner = class {
4708
5435
  }
4709
5436
  applyDraft(project, chapter, draft, report) {
4710
5437
  const config = this.deps.getConfig();
5438
+ const outputDir = this.bookDir ?? config.outputDir;
4711
5439
  const fileName = chapterFileName(chapter);
4712
- mkdirSync(config.outputDir, { recursive: true });
4713
- const targetPath = join(config.outputDir, fileName);
4714
- if (existsSync(targetPath)) copyFileSync(targetPath, join(config.outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
5440
+ mkdirSync(outputDir, { recursive: true });
5441
+ const targetPath = join(outputDir, fileName);
5442
+ if (existsSync(targetPath)) copyFileSync(targetPath, join(outputDir, `${fileName.replace(/\.md$/, "")}.bak.md`));
4715
5443
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
4716
5444
  chapter.pendingDraft = void 0;
4717
5445
  chapter.chars = draft.length;
@@ -4725,17 +5453,11 @@ var ProductionRunner = class {
4725
5453
  }
4726
5454
  chapter.error = void 0;
4727
5455
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
4728
- saveProject(config.outputDir, project);
5456
+ saveProject(outputDir, project);
4729
5457
  }
4730
5458
  };
4731
5459
  //#endregion
4732
5460
  //#region src/protocol.ts
4733
- /**
4734
- * dsh-novel-forge — shared protocol between the host half (Node) and the
4735
- * browser half (web GUI). Route paths, request/response shapes, the project
4736
- * state file format, and the NDJSON generation stream frames all live here so
4737
- * both halves spell exactly one vocabulary.
4738
- */
4739
5461
  /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
4740
5462
  const NOVEL_API = {
4741
5463
  status: "/api/dsh-novel-forge/status",
@@ -4775,6 +5497,8 @@ const NOVEL_API = {
4775
5497
  bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
4776
5498
  /** 漫剧方案管理:create/remove/activate。 */
4777
5499
  manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
5500
+ /** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
5501
+ mangaRoles: "/api/dsh-novel-forge/manga/roles",
4778
5502
  /** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
4779
5503
  storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
4780
5504
  /** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
@@ -7307,6 +8031,210 @@ function makeRoutes(deps) {
7307
8031
  writeJson(res, 200, { roles: project.roles });
7308
8032
  }
7309
8033
  };
8034
+ /** 漫剧角色库:从分镜提名(规则+LLM 两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
8035
+ const mangaRolesRoute = {
8036
+ kind: "exact",
8037
+ path: NOVEL_API.mangaRoles,
8038
+ handler: async (req, res) => {
8039
+ if (!guard(req, res, "POST")) return;
8040
+ const config = getConfig();
8041
+ const project = requireProject(res);
8042
+ if (project === void 0) return;
8043
+ const body = await readJsonBody(req);
8044
+ if (project.mangaRoles === void 0) project.mangaRoles = [];
8045
+ const op = body?.op;
8046
+ if (op === "nominate") {
8047
+ const chapterNo = body?.chapterNo;
8048
+ if (typeof chapterNo !== "number" || chapterNo <= 0) {
8049
+ writeJson(res, 400, { error: "chapterNo(章号)必填" });
8050
+ return;
8051
+ }
8052
+ try {
8053
+ const candidates = await nominateMangaRoles(ctx, config, project, config.outputDir, chapterNo);
8054
+ writeJson(res, 200, {
8055
+ cards: project.mangaRoles,
8056
+ candidates
8057
+ });
8058
+ return;
8059
+ } catch (error) {
8060
+ writeJson(res, 500, { error: `提名失败:${error.message}` });
8061
+ return;
8062
+ }
8063
+ } else if (op === "adopt" || op === "update") {
8064
+ const card = body?.card;
8065
+ if (card === void 0 || typeof card !== "object") {
8066
+ writeJson(res, 400, { error: "card(漫剧角色卡)必填" });
8067
+ return;
8068
+ }
8069
+ const name = (card.name ?? "").trim();
8070
+ if (name === "") {
8071
+ writeJson(res, 400, { error: "漫剧角色名不能为空" });
8072
+ return;
8073
+ }
8074
+ const now = (/* @__PURE__ */ new Date()).toISOString();
8075
+ const existing = card.id !== "" ? project.mangaRoles.find((c) => c.id === card.id) : void 0;
8076
+ const next = {
8077
+ id: card.id !== "" ? card.id : "mr-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6),
8078
+ sourceRoleName: card.sourceRoleName !== void 0 && card.sourceRoleName !== "" ? card.sourceRoleName : void 0,
8079
+ name,
8080
+ identity: card.identity ?? "",
8081
+ coreFunction: card.coreFunction ?? "functional",
8082
+ protagonistRelation: card.protagonistRelation ?? "neutral",
8083
+ speechStyle: card.speechStyle ?? "",
8084
+ traits: Array.isArray(card.traits) ? card.traits.map((t) => String(t).slice(0, 20)).filter((t) => t !== "").slice(0, 3) : [],
8085
+ appearance: card.appearance ?? "",
8086
+ keyScenes: Array.isArray(card.keyScenes) ? card.keyScenes.map((k) => String(k).slice(0, 120)).filter((k) => k !== "").slice(0, 6) : [],
8087
+ appearsInEpisodes: Array.isArray(card.appearsInEpisodes) ? [...new Set(card.appearsInEpisodes.map((n) => Number(n)).filter((n) => Number.isFinite(n) && n > 0))].sort((a, b) => a - b).slice(0, 200) : [],
8088
+ status: card.status ?? "imported",
8089
+ imagePrompt: card.imagePrompt,
8090
+ expressions: card.expressions,
8091
+ promptKit: card.promptKit,
8092
+ imageUrl: card.imageUrl,
8093
+ gallery: card.gallery,
8094
+ promptStyleId: card.promptStyleId,
8095
+ createdAt: existing?.createdAt ?? now,
8096
+ updatedAt: now
8097
+ };
8098
+ if (existing !== void 0) {
8099
+ const idx = project.mangaRoles.findIndex((c) => c.id === card.id);
8100
+ project.mangaRoles[idx] = next;
8101
+ } else project.mangaRoles.push(next);
8102
+ project.updatedAt = now;
8103
+ saveProject(config.outputDir, project);
8104
+ writeJson(res, 200, { cards: project.mangaRoles });
8105
+ return;
8106
+ } else if (op === "remove") {
8107
+ const id = body?.id;
8108
+ if (typeof id !== "string" || id === "") {
8109
+ writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
8110
+ return;
8111
+ }
8112
+ project.mangaRoles = project.mangaRoles.filter((c) => c.id !== id);
8113
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8114
+ saveProject(config.outputDir, project);
8115
+ writeJson(res, 200, { cards: project.mangaRoles });
8116
+ return;
8117
+ } else if (op === "visual") {
8118
+ const id = body?.id?.trim();
8119
+ if (id === void 0 || id === "") {
8120
+ writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
8121
+ return;
8122
+ }
8123
+ try {
8124
+ const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId, body?.filterId);
8125
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8126
+ saveProject(config.outputDir, project);
8127
+ writeJson(res, 200, {
8128
+ cards: project.mangaRoles,
8129
+ visual
8130
+ });
8131
+ return;
8132
+ } catch (error) {
8133
+ writeJson(res, 500, { error: `形象锚点生成失败:${error.message}` });
8134
+ return;
8135
+ }
8136
+ } else if (op === "promptKit") {
8137
+ const id = body?.id?.trim();
8138
+ if (id === void 0 || id === "") {
8139
+ writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
8140
+ return;
8141
+ }
8142
+ try {
8143
+ const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId, body?.filterId);
8144
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8145
+ saveProject(config.outputDir, project);
8146
+ writeJson(res, 200, {
8147
+ cards: project.mangaRoles,
8148
+ promptKit: kit
8149
+ });
8150
+ return;
8151
+ } catch (error) {
8152
+ writeJson(res, 500, { error: `提示词精修失败:${error.message}` });
8153
+ return;
8154
+ }
8155
+ } else if (op === "image") {
8156
+ const id = body?.id?.trim();
8157
+ const dataUrl = body?.dataUrl?.trim();
8158
+ if (id === void 0 || id === "") {
8159
+ writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
8160
+ return;
8161
+ }
8162
+ if (dataUrl === void 0 || dataUrl === "") {
8163
+ writeJson(res, 400, { error: "dataUrl(定妆图)必填" });
8164
+ return;
8165
+ }
8166
+ const card = project.mangaRoles.find((c) => c.id === id);
8167
+ if (card === void 0) {
8168
+ writeJson(res, 404, { error: "漫剧角色卡不存在" });
8169
+ return;
8170
+ }
8171
+ const label = body?.label?.trim() ?? "";
8172
+ if (label !== "") {
8173
+ card.gallery ??= [];
8174
+ card.gallery = card.gallery.filter((g) => g.label !== label);
8175
+ card.gallery.push({
8176
+ label,
8177
+ dataUrl
8178
+ });
8179
+ if (label.includes("立绘")) card.imageUrl = dataUrl;
8180
+ } else card.imageUrl = dataUrl;
8181
+ card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8182
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8183
+ saveProject(config.outputDir, project);
8184
+ writeJson(res, 200, {
8185
+ cards: project.mangaRoles,
8186
+ imageUrl: card.imageUrl
8187
+ });
8188
+ return;
8189
+ } else if (op === "removeImage") {
8190
+ const id = body?.id?.trim();
8191
+ const label = body?.label?.trim() ?? "";
8192
+ const card = project.mangaRoles.find((c) => c.id === id);
8193
+ if (card !== void 0) {
8194
+ if (label !== "") {
8195
+ card.gallery = (card.gallery ?? []).filter((g) => g.label !== label);
8196
+ if (label.includes("立绘")) card.imageUrl = void 0;
8197
+ } else card.imageUrl = void 0;
8198
+ card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8199
+ }
8200
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8201
+ saveProject(config.outputDir, project);
8202
+ writeJson(res, 200, { cards: project.mangaRoles });
8203
+ return;
8204
+ } else if (op === "imageGenerate") {
8205
+ const id = body?.id?.trim();
8206
+ if (id === void 0 || id === "") {
8207
+ writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
8208
+ return;
8209
+ }
8210
+ try {
8211
+ const imageUrl = await generateMangaRoleReferenceImage(ctx, config, project, config.outputDir, id, body?.style ?? "", body?.modelId);
8212
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8213
+ saveProject(config.outputDir, project);
8214
+ writeJson(res, 200, {
8215
+ cards: project.mangaRoles,
8216
+ imageUrl
8217
+ });
8218
+ return;
8219
+ } catch (error) {
8220
+ writeJson(res, 500, { error: `定妆图生成失败:${error.message}` });
8221
+ return;
8222
+ }
8223
+ } else if (op === "mode") {
8224
+ project.shortDramaMode = body?.shortDramaMode === true;
8225
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
8226
+ saveProject(config.outputDir, project);
8227
+ writeJson(res, 200, {
8228
+ cards: project.mangaRoles,
8229
+ shortDramaMode: project.shortDramaMode
8230
+ });
8231
+ return;
8232
+ } else {
8233
+ writeJson(res, 400, { error: "未知的 manga/roles op" });
8234
+ return;
8235
+ }
8236
+ }
8237
+ };
7310
8238
  /** 章节复位:generating 卡死 → pending(可重新生成)。 */
7311
8239
  const chapterResetRoute = {
7312
8240
  kind: "exact",
@@ -7472,12 +8400,24 @@ function makeRoutes(deps) {
7472
8400
  handler: async (req, res) => {
7473
8401
  if (!guard(req, res, "POST")) return;
7474
8402
  const dir = getConfig().outputDir;
7475
- exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
7476
- if (error) writeJson(res, 500, {
8403
+ const child = spawn("explorer", [dir], { shell: false });
8404
+ let responded = false;
8405
+ child.on("error", (error) => {
8406
+ if (responded) return;
8407
+ responded = true;
8408
+ writeJson(res, 500, {
7477
8409
  ok: false,
7478
8410
  error: error.message
7479
8411
  });
7480
- else writeJson(res, 200, { ok: true });
8412
+ });
8413
+ child.on("exit", (code) => {
8414
+ if (responded) return;
8415
+ responded = true;
8416
+ if (code === 0) writeJson(res, 200, { ok: true });
8417
+ else writeJson(res, 500, {
8418
+ ok: false,
8419
+ error: `explorer 退出码 ${code}`
8420
+ });
7481
8421
  });
7482
8422
  }
7483
8423
  };
@@ -7884,6 +8824,7 @@ function makeRoutes(deps) {
7884
8824
  renameRoute,
7885
8825
  plotlinesRoute,
7886
8826
  rolesRoute,
8827
+ mangaRolesRoute,
7887
8828
  scenesRoute,
7888
8829
  visualRulesRoute,
7889
8830
  sensitiveRoute,
@@ -8107,6 +9048,7 @@ function apply(ctx, config) {
8107
9048
  if (patch.imageApiKey !== void 0) next.imageApiKey = patch.imageApiKey;
8108
9049
  if (patch.imageApiModel !== void 0) next.imageApiModel = patch.imageApiModel;
8109
9050
  if (patch.imageApiEnabled !== void 0) next.imageApiEnabled = patch.imageApiEnabled;
9051
+ if (patch.imageModels !== void 0) next.imageModels = patch.imageModels;
8110
9052
  const settings = ctx.get("settings");
8111
9053
  if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
8112
9054
  else current = () => ({