@waterwx/dsh-novel-forge 1.7.2 → 1.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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,
@@ -3835,8 +4167,11 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3835
4167
  system: [
3836
4168
  "你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
3837
4169
  "任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
3838
- "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
3839
- "景别取值:远景/全景/中景/近景/特写。",
4170
+ "输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"composition\": \"构图\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
4171
+ "景别取值(只能从这些词中选一):大远景/远景/全景/中景/中近景/近景/特写/大特写。",
4172
+ "运镜取值(可组合,用加号连接):固定机位/推近/拉远/左摇/右摇/左横移/右横移/跟随/升镜/降镜/环绕/手持晃动/低机位仰拍/高机位俯拍/过肩镜头。",
4173
+ "光效取值(可组合,用加号连接):顺光/侧光/逆光/顶光/伦勃朗光/霓虹光/硬光/柔光/氛围光/高反差。",
4174
+ "构图取值(可选,只能从这些词中选一):三分法/中心对称/引导线/前景遮挡/低机位/俯拍/对称构图。",
3840
4175
  "硬性要求:",
3841
4176
  "【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
3842
4177
  ...styleLines,
@@ -3862,27 +4197,21 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3862
4197
  body.replace(/^#\s+.*$/m, "").trim().slice(0, 3e3)
3863
4198
  ].filter((s) => s !== "").join("\n\n"),
3864
4199
  temperature: .3,
3865
- maxTokens: Math.max(config.maxTokens, 6e3),
4200
+ maxTokens: Math.max(config.maxTokens, 16e3),
3866
4201
  reasoning: config.analysisReasoning ?? "low"
3867
4202
  }));
3868
4203
  const beatIds = new Set(skeleton.beats.map((b) => b.id));
3869
- const shotKinds = /* @__PURE__ */ new Set([
3870
- "远景",
3871
- "全景",
3872
- "中景",
3873
- "近景",
3874
- "特写"
3875
- ]);
3876
4204
  const shots = Array.isArray(raw.shots) ? raw.shots.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
3877
4205
  id: "s" + (i + 1),
3878
4206
  beatId: beatIds.has(entry.beatId) ? entry.beatId : skeleton.beats[0].id,
3879
- shot: shotKinds.has(entry.shot) ? entry.shot : "中景",
3880
- 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),
3881
4210
  duration: typeof entry.duration === "number" && entry.duration >= 1 && entry.duration <= 12 ? Math.round(entry.duration) : 4,
3882
4211
  visual: typeof entry.visual === "string" ? entry.visual.trim().slice(0, 300) : "",
3883
4212
  line: typeof entry.line === "string" ? entry.line.trim().slice(0, 120) : "",
3884
4213
  sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
3885
- light: typeof entry.light === "string" ? entry.light.trim().slice(0, 80) : "",
4214
+ light: normalizeLightings(entry.light),
3886
4215
  prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
3887
4216
  nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : "",
3888
4217
  characters: sanitizeCharacters(entry.characters, 4)
@@ -3940,12 +4269,12 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3940
4269
  const ref = c.imageUrl !== void 0 ? "(参考图)" : "";
3941
4270
  return c.name + ":" + anchor + ref;
3942
4271
  }).join(";");
3943
- return `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
4272
+ return `[${s.id}] 节拍${s.beatId} · ${sizeZh(s.shot)} · ${cameraZh(s.camera)} · ${s.duration}s
3944
4273
  出场:${s.characters !== void 0 && s.characters.length > 0 ? s.characters.join("、") : "(未标注)"}
3945
4274
  定妆:${makeUp !== "" ? makeUp : "(未绑定漫剧卡)"}
3946
4275
  画面:${s.visual}
3947
4276
  台词:${s.line !== "" ? s.line : "(无)"}
3948
- 光效:${s.light !== "" ? s.light : "(无)"}
4277
+ 光效:${lightZh(s.light) !== "" ? lightZh(s.light) : "(无)"}
3949
4278
  承接:${s.prevState} → ${s.nextState}`;
3950
4279
  }).join("\n\n");
3951
4280
  const raw = parseJsonObject(await complete(ctx, config, {
@@ -3970,7 +4299,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3970
4299
  "只输出 JSON 对象。"
3971
4300
  ].filter((x) => x !== "").join("\n\n"),
3972
4301
  temperature: .3,
3973
- maxTokens: Math.max(config.maxTokens, 6e3),
4302
+ maxTokens: Math.max(config.maxTokens, 16e3),
3974
4303
  reasoning: config.analysisReasoning ?? "low"
3975
4304
  }));
3976
4305
  const shotIds = new Set(table.shots.map((s) => s.id));
@@ -4005,7 +4334,9 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
4005
4334
  system: [
4006
4335
  "你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。",
4007
4336
  "任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。",
4008
- "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}], \"characters\": [\"出镜角色名1\", \"出镜角色名2\"]}",
4337
+ "输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(情绪词数组)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}], \"characters\": [\"出镜角色名1\", \"出镜角色名2\"]}",
4338
+ "情绪词取值(从这些词中选 1-3 个,用数组输出,按情绪发展顺序):平静/淡然/期待/好奇/警觉/压抑/隐忍/担忧/焦躁/不安/惊惧/愤怒/崩溃/决绝/痛心/释然/悲凉/得意/重生/麻木。",
4339
+ "功能取值(只能从这些词中选一):铺垫/冲突/转折/高潮/收束/伏笔/人物塑造。",
4009
4340
  "硬性要求:",
4010
4341
  "1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。",
4011
4342
  "2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。",
@@ -4025,20 +4356,11 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
4025
4356
  reasoning: config.analysisReasoning ?? "low"
4026
4357
  }));
4027
4358
  const arc = typeof raw.arc === "string" ? raw.arc.trim().slice(0, 200) : "";
4028
- const functions = /* @__PURE__ */ new Set([
4029
- "铺垫",
4030
- "冲突",
4031
- "转折",
4032
- "高潮",
4033
- "收束",
4034
- "伏笔",
4035
- "人物塑造"
4036
- ]);
4037
4359
  const beats = Array.isArray(raw.beats) ? raw.beats.filter((v) => typeof v === "object" && v !== null).map((entry, i) => ({
4038
4360
  id: "b" + (i + 1),
4039
4361
  event: typeof entry.event === "string" ? entry.event.trim().slice(0, 200) : "",
4040
- emotion: typeof entry.emotion === "string" ? entry.emotion.trim().slice(0, 100) : "",
4041
- 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),
4042
4364
  cause: typeof entry.cause === "string" && entry.cause.trim() !== "" ? entry.cause.trim().slice(0, 150) : void 0
4043
4365
  })).filter((b) => b.event !== "") : [];
4044
4366
  if (beats.length === 0) throw new Error("模型未输出有效节拍链,请重试");
@@ -5113,10 +5435,11 @@ var ProductionRunner = class {
5113
5435
  }
5114
5436
  applyDraft(project, chapter, draft, report) {
5115
5437
  const config = this.deps.getConfig();
5438
+ const outputDir = this.bookDir ?? config.outputDir;
5116
5439
  const fileName = chapterFileName(chapter);
5117
- mkdirSync(config.outputDir, { recursive: true });
5118
- const targetPath = join(config.outputDir, fileName);
5119
- 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`));
5120
5443
  writeFileSync(targetPath, `# 第${chapter.no}章 ${chapter.title}\n\n${draft}\n`, "utf8");
5121
5444
  chapter.pendingDraft = void 0;
5122
5445
  chapter.chars = draft.length;
@@ -5130,17 +5453,11 @@ var ProductionRunner = class {
5130
5453
  }
5131
5454
  chapter.error = void 0;
5132
5455
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5133
- saveProject(config.outputDir, project);
5456
+ saveProject(outputDir, project);
5134
5457
  }
5135
5458
  };
5136
5459
  //#endregion
5137
5460
  //#region src/protocol.ts
5138
- /**
5139
- * dsh-novel-forge — shared protocol between the host half (Node) and the
5140
- * browser half (web GUI). Route paths, request/response shapes, the project
5141
- * state file format, and the NDJSON generation stream frames all live here so
5142
- * both halves spell exactly one vocabulary.
5143
- */
5144
5461
  /** The /api/dsh-novel-forge route family (same-origin, loopback-fenced). */
5145
5462
  const NOVEL_API = {
5146
5463
  status: "/api/dsh-novel-forge/status",
@@ -8083,12 +8400,24 @@ function makeRoutes(deps) {
8083
8400
  handler: async (req, res) => {
8084
8401
  if (!guard(req, res, "POST")) return;
8085
8402
  const dir = getConfig().outputDir;
8086
- exec(`explorer "${dir.replace(/"/g, "")}"`, (error) => {
8087
- 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, {
8088
8409
  ok: false,
8089
8410
  error: error.message
8090
8411
  });
8091
- 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
+ });
8092
8421
  });
8093
8422
  }
8094
8423
  };
@@ -8719,6 +9048,7 @@ function apply(ctx, config) {
8719
9048
  if (patch.imageApiKey !== void 0) next.imageApiKey = patch.imageApiKey;
8720
9049
  if (patch.imageApiModel !== void 0) next.imageApiModel = patch.imageApiModel;
8721
9050
  if (patch.imageApiEnabled !== void 0) next.imageApiEnabled = patch.imageApiEnabled;
9051
+ if (patch.imageModels !== void 0) next.imageModels = patch.imageModels;
8722
9052
  const settings = ctx.get("settings");
8723
9053
  if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
8724
9054
  else current = () => ({