@waterwx/dsh-novel-forge 1.2.0 → 1.3.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.
package/lib/index.js CHANGED
@@ -614,6 +614,56 @@ function styleEngineSystemPrompt() {
614
614
  ].join("\n");
615
615
  }
616
616
  //#endregion
617
+ //#region src/comic-presets.ts
618
+ const COMIC_STYLE_PRESETS = [
619
+ {
620
+ id: "japan",
621
+ label: "日漫 / 黑白",
622
+ prompt: "manga style, screentone, bold ink lines, high contrast, black and white, cel shading",
623
+ layoutHint: "页漫优先"
624
+ },
625
+ {
626
+ id: "korea",
627
+ label: "韩漫 / 条漫",
628
+ prompt: "manhwa style, soft cel shading, vibrant colors, clean lineart, webtoon vertical composition, glossy highlights",
629
+ layoutHint: "条漫优先"
630
+ },
631
+ {
632
+ id: "china",
633
+ label: "国漫 / 古风",
634
+ prompt: "chinese ink painting style, flowing brush strokes, traditional palette, ethereal atmosphere, xianxia aesthetic",
635
+ layoutHint: "页漫/条漫均可"
636
+ },
637
+ {
638
+ id: "america",
639
+ label: "美漫 / 超级英雄",
640
+ prompt: "american comic style, bold outlines, dramatic lighting, dynamic poses, vibrant colors, halftone shading",
641
+ layoutHint: "页漫优先"
642
+ },
643
+ {
644
+ id: "chibi",
645
+ label: "Q版 / 搞笑",
646
+ prompt: "chibi style, cute proportions, big head, exaggerated expressions, simple background, bright colors",
647
+ layoutHint: "条漫/四格优先"
648
+ },
649
+ {
650
+ id: "realistic",
651
+ label: "写实 / 电影感",
652
+ prompt: "realistic cinematic style, detailed textures, natural lighting, film grain, high detail",
653
+ layoutHint: "页漫/全彩"
654
+ },
655
+ {
656
+ id: "cyber",
657
+ label: "赛博 / 暗黑",
658
+ prompt: "cyberpunk style, neon lights, dark atmosphere, high contrast, futuristic city, cool tones",
659
+ layoutHint: "页漫/全彩"
660
+ }
661
+ ];
662
+ function getComicStylePrompt(styleId) {
663
+ if (!styleId) return "";
664
+ return COMIC_STYLE_PRESETS.find((s) => s.id === styleId)?.prompt ?? "";
665
+ }
666
+ //#endregion
617
667
  //#region src/engine.ts
618
668
  /**
619
669
  * Novel engine — the host half's core: LLM-driven story-bible extraction,
@@ -1215,6 +1265,7 @@ function bibleSystemPrompt() {
1215
1265
  "3. 世界规则覆盖力量体系、金手指机制、势力、地理等所有硬性规则,逐条列出。",
1216
1266
  "4. 红线列出大纲中明确禁止的内容(如无后宫、不圣母、无无脑碾压等)。",
1217
1267
  "5. 风格列出叙事基调、节奏、POV 等写作风格要点。",
1268
+ "6. 角色名必须用正文/编年录中的真实姓名;若大纲只写「主角」未点名,而已写章节或编年录中有名字,则用该真实姓名;禁止输出「主角(描述)」这类把身份塞进名字的占位名。",
1218
1269
  "输出必须是合法 JSON 对象,不要输出任何其他文字或 Markdown 代码块标记。",
1219
1270
  "重要:所有字符串值内部不得包含换行符(不要用多行字符串),JSON 必须在一段内完整结束。",
1220
1271
  "重要:直接输出 JSON 结果本身,不要把思考过程或推理内容写在输出里。",
@@ -1223,8 +1274,21 @@ function bibleSystemPrompt() {
1223
1274
  ].join("\n");
1224
1275
  }
1225
1276
  /** Extract the story bible from an outline. */
1226
- async function extractBible(ctx, config, outline) {
1227
- const user = `请为下面这部小说提炼设定圣经:\n\n${outline}`;
1277
+ async function extractBible(ctx, config, outline, project) {
1278
+ const written = (project?.chapters ?? []).filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
1279
+ const excerpts = [];
1280
+ for (const chapter of written.slice(0, 3)) {
1281
+ const body = readChapterFile(config.outputDir, chapter);
1282
+ if (body === void 0) continue;
1283
+ const text = body.replace(/^#.*$/gm, "").trim();
1284
+ if (text.length > 0) excerpts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2200)}`);
1285
+ }
1286
+ const facts = (project?.facts ?? []).slice(-40);
1287
+ const user = [
1288
+ `请为下面这部小说提炼设定圣经:\n\n${outline}`,
1289
+ facts.length > 0 ? `\n\n【已写章节事实(编年录)】用于确认真实角色姓名与已确立设定;忠于大纲,不要新增大纲外设定:\n${facts.map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 100)}`).join("\n")}` : "",
1290
+ excerpts.length > 0 ? `\n\n【已写章节正文摘录】角色姓名、身份以正文为准(大纲未点名时用正文里的真实姓名,禁止用「主角(描述)」占位名):\n${excerpts.join("\n\n")}` : ""
1291
+ ].filter((s) => s !== "").join("\n");
1228
1292
  const raw = parseJsonObject(await complete(ctx, config, {
1229
1293
  system: bibleSystemPrompt(),
1230
1294
  user,
@@ -1245,6 +1309,10 @@ async function extractBible(ctx, config, outline) {
1245
1309
  relations: typeof entry.relations === "string" ? entry.relations : "",
1246
1310
  knowledge: strArray(entry.knowledge)
1247
1311
  })).filter((card) => card.name !== "") : [];
1312
+ const realProtagonist = (project?.roles ?? []).find((r) => r.roleLabel === "protagonist")?.name?.trim();
1313
+ if (realProtagonist !== void 0 && realProtagonist !== "") {
1314
+ for (const card of characters) if (card.role === "protagonist" && /^(主角|未命名)/.test(card.name)) card.name = realProtagonist;
1315
+ }
1248
1316
  const bible = {
1249
1317
  genre: typeof raw.genre === "string" ? raw.genre : "",
1250
1318
  worldRules: strArray(raw.worldRules),
@@ -1417,17 +1485,18 @@ async function planChapters(ctx, config, project, chapterCount, volumeNo, output
1417
1485
  } catch {}
1418
1486
  }
1419
1487
  const outlineBlock = continuation ? (() => {
1420
- const cut = project.outline.indexOf("七、关键剧情桥段");
1421
- if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + "\n(大纲后续剧情桥段与分卷细节从略;续写请以「上一章结尾原文」与「最新剧情状态」为剧情起点)";
1422
- return project.outline.slice(0, 3e3) + "\n…(大纲过长已节选)";
1488
+ if (project.outline.length <= 2e4) return project.outline;
1489
+ const cut = project.outline.indexOf("十、全书核心弹幕记忆梗");
1490
+ if (cut > 1500) return project.outline.slice(0, cut).trimEnd() + "\n(大纲过长,末尾记忆梗从略)";
1491
+ return project.outline.slice(0, 8e3) + "\n…(大纲过长已节选)";
1423
1492
  })() : project.outline;
1424
1493
  const user = [
1425
1494
  "请为下面这部小说规划章节。",
1426
1495
  volume !== void 0 ? `本次只规划第 ${volume.no} 卷《${volume.title}》的章节:\n${volume.summary}` : continuation ? `本书已有 ${existing.length} 章已规划/已写作(见下方「已有章节」)。请规划**后续**章节:从第 ${startNo} 章开始。` : "请规划全书开篇章节。",
1427
- continuation ? "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。" : "",
1496
+ continuation ? "【续写硬性要求】已有章节的剧情不得重写或重复,章节标题也不得与已有章节重复。以下情节均已在已有章节中发生过,后续章节**绝对不得再次出现**:穿越、暴雨送餐、滴血认主/古玉认主、首次进入墟境、用废铁淬炼首件法器、绝境肉身入鼎洗炼(该机缘已用尽)、杀死白袍弟子与灰衣随从、藏尸水沟。\n若本次规划已进入大纲的收尾区间(接近全书规划总章数),最后 5-10 章必须按大纲推进到大结局(终极抉择/清算/双结局等),**禁止以悬念、逃离、未解之谜收尾**——收尾区间按大纲卷定位判断,不以当前剧情是否“感觉像结尾”为准。" : "",
1428
1497
  prevTail !== "" ? `【上一章(第 ${startNo - 1} 章)结尾原文】第 ${startNo} 章必须紧接此状态继续,从新的事件写起,不得回顾重述:\n${prevTail}` : "",
1429
1498
  latestFacts !== "" ? `【最新剧情状态(本书编年录,第 ${startNo - 1} 章结尾的事实)】规划续写时必须以此为起点,时间线、人物状态与地点衔接一致:\n${latestFacts}` : "",
1430
- continuation ? "已有章节:\n" + existing.map((c) => {
1499
+ continuation ? "已有章节(共 " + existing.length + " 章;仅列最近 80 章,更早的以标题计数为准,剧情以「编年录」为权威):\n" + existing.slice(-80).map((c) => {
1431
1500
  const sm = c.summary !== void 0 && c.summary !== "" ? `(${c.summary.slice(0, 120)})` : "";
1432
1501
  return `第${c.no}章《${c.title}》${sm}`;
1433
1502
  }).join("\n") : "",
@@ -1536,7 +1605,7 @@ async function reviewChapter(ctx, config, project, outputDir, chapterNo) {
1536
1605
  const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
1537
1606
  const report = {
1538
1607
  score,
1539
- passed: score >= config.reviewPassScore,
1608
+ passed: score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60,
1540
1609
  verdict: typeof raw.verdict === "string" ? raw.verdict.slice(0, 200) : "",
1541
1610
  issues,
1542
1611
  reviewedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -1574,7 +1643,7 @@ async function reviewChapterText(ctx, config, project, text, previousReport) {
1574
1643
  suggestion: typeof entry.suggestion === "string" ? entry.suggestion : ""
1575
1644
  })).filter((issue) => issue.item !== "") : [];
1576
1645
  const score = typeof raw.score === "number" ? Math.max(0, Math.min(100, Math.round(raw.score))) : 60;
1577
- let passed = score >= config.reviewPassScore;
1646
+ let passed = score >= config.reviewPassScore || issues.every((i) => i.severity !== "high") && score >= 60;
1578
1647
  if (previousReport !== void 0) {
1579
1648
  const hasHigh = issues.some((i) => i.severity === "high");
1580
1649
  const prevHighResolved = previousReport.issues.filter((i) => i.severity === "high").every((p) => !issues.some((i) => i.item.includes(p.item.slice(0, 12))));
@@ -1745,7 +1814,7 @@ async function extractRoles(ctx, config, project) {
1745
1814
  "覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注。",
1746
1815
  "数量控制:最多输出 10 个角色,宁缺毋滥;路人级一次带过的不要收录。",
1747
1816
  "每个角色输出:",
1748
- "1. name:角色名(或身份简称)。",
1817
+ "1. name:角色名(或身份简称)。主角/有名配角必须用正文中实际出现的人名(如「沈放」),禁止用「主角(38岁超市理货员)」这类把身份塞进名字的占位名;正文确实没点名时才可用身份简称(如「富商」「灰衣老人」)。",
1749
1818
  "2. roleLabel:定位——protagonist=主角;female_lead=女主(唯一知己/感情线核心,无后宫前提下只此一位);female_support=重要女配;support=普通配角;antagonist=反派;extra=路人/背景。",
1750
1819
  "3. identity:身份一句话(宗门/势力/血脉/职业)。",
1751
1820
  "4. traits:3-6 个性格标签。",
@@ -1761,12 +1830,20 @@ async function extractRoles(ctx, config, project) {
1761
1830
  ].join("\n");
1762
1831
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1763
1832
  const existingRoles = project.roles ?? [];
1833
+ const excerptParts = [];
1834
+ for (const chapter of written.slice(0, 3)) {
1835
+ const body = readChapterFile(config.outputDir, chapter);
1836
+ if (body === void 0) continue;
1837
+ const text = body.replace(/^#.*$/gm, "").trim();
1838
+ if (text.length > 0) excerptParts.push(`第${chapter.no}章《${chapter.title}》\n${text.slice(0, 2400)}`);
1839
+ }
1764
1840
  const raw = parseJsonArray(await complete(ctx, config, {
1765
1841
  system,
1766
1842
  user: [
1767
1843
  `书名:《${project.bookName}》`,
1768
1844
  existingRoles.length > 0 ? `已收录角色(跳过,不要输出):${existingRoles.map((r) => r.name).join("、")}` : "",
1769
1845
  `大纲(节选前 3000 字):\n${project.outline.slice(0, 3e3)}`,
1846
+ excerptParts.length > 0 ? `已写正文摘录(角色姓名/身份/关系以正文为准):\n${excerptParts.join("\n\n")}` : "",
1770
1847
  project.bible !== void 0 && project.bible.characters.length > 0 ? `已有角色卡(补充信息):\n${project.bible.characters.map((c) => `- ${c.name}(${c.role}):${c.traits.join("、")}${c.goals !== "" ? `;目标:${c.goals}` : ""}`).join("\n")}` : "",
1771
1848
  (project.facts ?? []).length > 0 ? `编年录(最近 60 条):\n${(project.facts ?? []).slice(-60).map((f) => `[第${f.chapterNo}章] ${f.text.slice(0, 80)}`).join("\n")}` : "",
1772
1849
  written.length > 0 ? `已写章节标题(${written.length} 章):\n${written.map((c) => `第${c.no}章《${c.title}》`).join("、")}` : "",
@@ -1802,6 +1879,75 @@ async function extractRoles(ctx, config, project) {
1802
1879
  }
1803
1880
  return roles;
1804
1881
  }
1882
+ /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
1883
+ async function extractScenes(ctx, config, project) {
1884
+ const system = [
1885
+ "你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
1886
+ "按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。",
1887
+ "每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。",
1888
+ "数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。",
1889
+ "每个场景输出:",
1890
+ "1. name:场景名(地点+光态,如「后场通道·装卸口(雨夜)」)。",
1891
+ "2. act:幕归属(第一幕后场/第二幕卖场/第三幕深仓/第四幕外界/第五幕抉择)。",
1892
+ "3. moment:时间光态(夜间闭店后/雨夜/凌晨/频闪灯…)。",
1893
+ "4. summary:一句话定位(空间类型/功能/氛围)。",
1894
+ "5. beats:关键情节镜头 1-3 条(人物动作+情绪+镜头推进,如\"沈放佝偻站在货架过道,镜头推近,情绪从漠然过渡到压抑悲伤\")。",
1895
+ "6. characterState:主角/关键角色在该场景的状态一句话(含标志物细节如标签磨损、手腕红痕)。",
1896
+ "7. elements:环境构成数组 3-6 项(空间结构/陈设/标志物)。",
1897
+ "8. palette:色调与光影数组 2-4 项(颜色词,可附 HEX)。",
1898
+ "9. moods:氛围关键词 2-4 个(压抑/神秘/空旷/悲凉…)。",
1899
+ "10. zh:中文生图提示词(连贯一段,写实电影感,含环境/光线/氛围/人物状态)。",
1900
+ "11. en:英文生图提示词(booru 风格逗号分隔,含 photorealistic/cinematic)。",
1901
+ "12. tags:3-6 个关键标签。",
1902
+ "13. source:依据来源说明(哪几章哪些描写)。",
1903
+ "若故事存在结局抉择(如双分支),最后 1-2 个场景应为「抉择场景」,zh 里写明分支 A/B 两种画面。",
1904
+ "输出必须是合法 JSON 数组,不要输出其他文字:[{\"name\":\"...\", \"act\":\"...\", \"moment\":\"...\", \"summary\":\"...\", \"beats\":[...], \"characterState\":\"...\", \"elements\":[...], \"palette\":[...], \"moods\":[...], \"zh\":\"...\", \"en\":\"...\", \"tags\":[...], \"source\":\"...\"}]",
1905
+ "重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
1906
+ ].join("\n");
1907
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating");
1908
+ const excerptParts = [];
1909
+ for (const chapter of written.slice(0, 4)) {
1910
+ const body = readChapterFile(config.outputDir, chapter);
1911
+ if (body === void 0) continue;
1912
+ const text = body.replace(/^#.*$/gm, "").trim();
1913
+ if (text.length > 0) excerptParts.push("第" + chapter.no + "章《" + chapter.title + "》\n" + text.slice(0, 1800));
1914
+ }
1915
+ const raw = parseJsonArray(await complete(ctx, config, {
1916
+ system,
1917
+ user: [
1918
+ "书名:《" + project.bookName + "》",
1919
+ (project.facts ?? []).length > 0 ? "编年录(最近 80 条,场景地点以这里为准):\n" + (project.facts ?? []).slice(-80).map((f) => "[第" + f.chapterNo + "章] " + f.text.slice(0, 80)).join("\n") : "",
1920
+ excerptParts.length > 0 ? "已写正文摘录(场景描写以正文为准):\n" + excerptParts.join("\n") : "",
1921
+ "只输出 JSON 数组。"
1922
+ ].filter((s) => s !== "").join("\n"),
1923
+ temperature: .4,
1924
+ maxTokens: Math.max(config.maxTokens, 12e3)
1925
+ }));
1926
+ const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
1927
+ const str = (v) => typeof v === "string" ? v.trim() : "";
1928
+ const scenes = [];
1929
+ for (const entry of raw) {
1930
+ if (typeof entry !== "object" || entry === null) continue;
1931
+ const name = str(entry.name).slice(0, 40);
1932
+ if (name === "") continue;
1933
+ scenes.push({
1934
+ name,
1935
+ act: str(entry.act).slice(0, 40),
1936
+ moment: str(entry.moment).slice(0, 60),
1937
+ summary: str(entry.summary).slice(0, 120),
1938
+ beats: strArr(entry.beats).map((t) => t.slice(0, 120)).slice(0, 3),
1939
+ characterState: str(entry.characterState).slice(0, 160),
1940
+ elements: strArr(entry.elements).map((t) => t.slice(0, 60)).slice(0, 6),
1941
+ palette: strArr(entry.palette).map((t) => t.slice(0, 40)).slice(0, 4),
1942
+ moods: strArr(entry.moods).map((t) => t.slice(0, 20)).slice(0, 4),
1943
+ zh: str(entry.zh).slice(0, 800),
1944
+ en: str(entry.en).slice(0, 600),
1945
+ tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
1946
+ source: str(entry.source).slice(0, 120)
1947
+ });
1948
+ }
1949
+ return scenes;
1950
+ }
1805
1951
  /**
1806
1952
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
1807
1953
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
@@ -1810,6 +1956,72 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
1810
1956
  const role = (project.roles ?? []).find((r) => r.name === roleName);
1811
1957
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
1812
1958
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
1959
+ const roleText = `${role.name} ${role.identity ?? ""}`;
1960
+ const colorWords = [
1961
+ "深灰",
1962
+ "灰蓝",
1963
+ "灰",
1964
+ "蓝",
1965
+ "白",
1966
+ "黑",
1967
+ "红",
1968
+ "青",
1969
+ "紫",
1970
+ "银",
1971
+ "金",
1972
+ "黄",
1973
+ "绿",
1974
+ "粉",
1975
+ "棕"
1976
+ ];
1977
+ const garmentWords = [
1978
+ "工装",
1979
+ "西装",
1980
+ "定制西装",
1981
+ "袍",
1982
+ "裙",
1983
+ "衫",
1984
+ "衣",
1985
+ "裤",
1986
+ "靴",
1987
+ "鞋",
1988
+ "帽",
1989
+ "腰带",
1990
+ "制服",
1991
+ "外套",
1992
+ "马甲",
1993
+ "夹克"
1994
+ ];
1995
+ const roleWords = [
1996
+ "女人",
1997
+ "男人",
1998
+ "老者",
1999
+ "老人",
2000
+ "中年",
2001
+ "青年",
2002
+ "少女",
2003
+ "姑娘",
2004
+ "男子",
2005
+ "女子",
2006
+ "工人",
2007
+ "路人",
2008
+ "购买者"
2009
+ ];
2010
+ const locationWords = [
2011
+ "别墅",
2012
+ "小区",
2013
+ "街区"
2014
+ ];
2015
+ const identityTokens = (role.identity ?? "").split(/[,,。\s]+/).filter((t) => t.length >= 2);
2016
+ const searchTokens = Array.from(/* @__PURE__ */ new Set([
2017
+ roleName,
2018
+ ...identityTokens,
2019
+ ...colorWords.filter((w) => roleText.includes(w)),
2020
+ ...garmentWords.filter((w) => roleText.includes(w)),
2021
+ ...roleWords.filter((w) => roleText.includes(w)),
2022
+ ...locationWords.filter((w) => roleText.includes(w))
2023
+ ])).filter((t) => t.length >= 2);
2024
+ const matchesRole = (para) => para.includes(roleName) || searchTokens.some((tok) => para.includes(tok));
1813
2025
  const excerpts = [];
1814
2026
  const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
1815
2027
  for (const chapter of written) {
@@ -1819,7 +2031,7 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
1819
2031
  let perChapter = 0;
1820
2032
  for (const para of paras) {
1821
2033
  if (perChapter >= 2 || excerpts.length >= 12) break;
1822
- if (!para.includes(roleName)) continue;
2034
+ if (!matchesRole(para)) continue;
1823
2035
  if (appearanceHints.test(para) || excerpts.length < 4) {
1824
2036
  excerpts.push({
1825
2037
  no: chapter.no,
@@ -1833,17 +2045,27 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
1833
2045
  if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
1834
2046
  const raw = parseJsonObject(await complete(ctx, config, {
1835
2047
  system: [
1836
- "你是一位动漫角色设定师。根据网文正文中该角色的实际外貌描写,提炼「动漫形象描述词」,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
2048
+ "你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
1837
2049
  "硬性要求(依据优先):",
1838
2050
  "1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
1839
- "2. 正文未明确写到的项目(如瞳色没写),用「未定」标注,不要编造。",
1840
- "3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合。",
1841
- "输出三个部分:",
1842
- "- zh:中文外貌描述,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
1843
- "- en:英文绘图标签,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装(如 chinese hanfu / daoist robe)、气质、背景无关项。不要输出负面提示词。",
2051
+ "2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
2052
+ "3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。",
2053
+ "4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
2054
+ "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
2055
+ ...(project.visualRules ?? []).map((r) => "- " + r),
2056
+ "输出六部分:",
2057
+ "- zh:中文形象锚点,一段连贯文字(60-150 字):发色发型、瞳色、脸型气质、服装(颜色款式)、身材、标志性物件。",
2058
+ "- en:英文形象锚点,booru 风格、逗号分隔、小写,30-50 个标签:含性别(1boy/1girl)、发色、发型、瞳色、服装、气质、标志物。不要输出负面提示词。",
1844
2059
  "- tags:中文关键标签数组,5-10 个(如 [\"黑发\",\"束发\",\"青色道袍\",\"清秀\",\"腰悬古玉\"])。",
1845
2060
  "- source:说明依据(如\"第1章/第8章外貌描写;瞳色未明确\")。",
1846
- "输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\"}",
2061
+ "- expressions:该角色在本书剧情中需要的情绪表情清单数组,6-12 个(如 [\"疲惫\",\"麻木\",\"压抑悲伤\",\"皱眉\",\"紧绷\",\"放空\"]),依据正文情绪描写与角色处境推断。",
2062
+ "- promptKit:四类精修提示词(字段化+出图约束,参考示例结构):",
2063
+ " · portrait 立绘:角色设计稿定位,纯白纯色背景,全身完整无裁切,无多余杂物;字段流:年龄感、身高(有依据才写)、发型发色、胡茬、眼眸、面部气质、上身服装分件(含磨损)、下身、鞋、标志物细节。",
2064
+ " · sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,全身完整,四个视角分别描述,服装与标志物细节完整展示。",
2065
+ " · expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
2066
+ " · details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出(如标签/印记/工牌/袖口磨损/鞋),每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
2067
+ "promptKit 每段 zh:连贯中文 60-150 字;en:booru 标签 30-50 个。",
2068
+ "输出必须是合法 JSON 对象:{\"zh\": \"...\", \"en\": \"...\", \"tags\": [...], \"source\": \"...\", \"expressions\": [...], \"promptKit\": {\"portrait\": {\"zh\":\"...\", \"en\":\"...\"}, \"sheet\": {\"zh\":\"...\", \"en\":\"...\"}, \"expressions\": [{\"name\":\"疲惫\",\"zh\":\"...\",\"en\":\"...\"}], \"details\": {\"zh\":\"...\", \"en\":\"...\"}}}",
1847
2069
  "重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
1848
2070
  "重要:直接输出 JSON 结果本身,不要把思考过程写在输出里。"
1849
2071
  ].join("\n"),
@@ -1856,19 +2078,126 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName) {
1856
2078
  "只输出 JSON 对象。"
1857
2079
  ].join("\n\n"),
1858
2080
  temperature: .4,
1859
- maxTokens: Math.max(config.maxTokens, 4e3)
2081
+ maxTokens: Math.max(config.maxTokens, 12e3)
1860
2082
  }));
1861
2083
  const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
1862
2084
  const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
1863
2085
  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) : [];
1864
2086
  const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
2087
+ 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) : [];
2088
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2089
+ const pair = (v) => {
2090
+ const o = typeof v === "object" && v !== null ? v : {};
2091
+ return {
2092
+ zh: str(o.zh).slice(0, 800),
2093
+ en: str(o.en).slice(0, 1200)
2094
+ };
2095
+ };
2096
+ const kitRaw = typeof raw.promptKit === "object" && raw.promptKit !== null ? raw.promptKit : {};
2097
+ const kitExpr = Array.isArray(kitRaw.expressions) ? kitRaw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
2098
+ let promptKit;
2099
+ try {
2100
+ promptKit = {
2101
+ portrait: pair(kitRaw.portrait),
2102
+ sheet: pair(kitRaw.sheet),
2103
+ expressions: kitExpr.map((e) => ({
2104
+ name: str(e.name).slice(0, 12) || "表情",
2105
+ zh: str(e.zh).slice(0, 800),
2106
+ en: str(e.en).slice(0, 1200)
2107
+ })).slice(0, 12),
2108
+ details: pair(kitRaw.details)
2109
+ };
2110
+ } catch {
2111
+ promptKit = void 0;
2112
+ }
1865
2113
  if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
1866
2114
  return {
1867
2115
  zh,
1868
2116
  en,
1869
2117
  tags,
1870
- source
2118
+ source,
2119
+ expressions,
2120
+ promptKit
2121
+ };
2122
+ }
2123
+ /**
2124
+ * 角色四类生图提示词精修包(promptKit):以锚点 + 表情清单 + 视觉规则为输入,
2125
+ * LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
2126
+ */
2127
+ async function generateRolePromptKit(ctx, config, project, roleName) {
2128
+ const role = (project.roles ?? []).find((r) => r.name === roleName);
2129
+ if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2130
+ if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
2131
+ const raw = parseJsonObject(await complete(ctx, config, {
2132
+ system: [
2133
+ "你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
2134
+ "四类:",
2135
+ "1. portrait 立绘:全身/半身正视图,含完整服装与标志物。",
2136
+ "2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
2137
+ "3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
2138
+ "4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
2139
+ "【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
2140
+ ...(project.visualRules ?? []).map((r) => "- " + r),
2141
+ "zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
2142
+ "输出必须是合法 JSON 对象:{\"portrait\": {\"zh\": \"...\", \"en\": \"...\"}, \"sheet\": {\"zh\": \"...\", \"en\": \"...\"}, \"expressions\": [{\"name\": \"疲惫\", \"zh\": \"...\", \"en\": \"...\"}], \"details\": {\"zh\": \"...\", \"en\": \"...\"}}",
2143
+ "重要:所有字符串值内部不得包含换行符;直接输出 JSON 结果本身。"
2144
+ ].join("\n"),
2145
+ user: [
2146
+ `角色:${role.name}(${role.identity ?? ""})`,
2147
+ `中文锚点:${role.imagePrompt.zh}`,
2148
+ `英文锚点:${role.imagePrompt.en}`,
2149
+ `关键标签:${role.imagePrompt.tags.join("、")}`,
2150
+ `表情清单:${(role.expressions ?? []).join("、") || "(未提供,按角色气质推断 6 个)"}`,
2151
+ "只输出 JSON 对象。"
2152
+ ].join("\n\n"),
2153
+ temperature: .4,
2154
+ maxTokens: Math.max(config.maxTokens, 12e3)
2155
+ }));
2156
+ const str = (v) => typeof v === "string" ? v.trim() : "";
2157
+ const pair = (v) => {
2158
+ const o = typeof v === "object" && v !== null ? v : {};
2159
+ return {
2160
+ zh: str(o.zh).slice(0, 800),
2161
+ en: str(o.en).slice(0, 1200)
2162
+ };
2163
+ };
2164
+ const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
2165
+ const kit = {
2166
+ portrait: pair(raw.portrait),
2167
+ sheet: pair(raw.sheet),
2168
+ expressions: expressionsRaw.map((e) => ({
2169
+ name: str(e.name).slice(0, 12) || "表情",
2170
+ zh: str(e.zh).slice(0, 800),
2171
+ en: str(e.en).slice(0, 1200)
2172
+ })).slice(0, 12),
2173
+ details: pair(raw.details)
1871
2174
  };
2175
+ if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
2176
+ return kit;
2177
+ }
2178
+ /** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
2179
+ async function extractVisualRules(ctx, config, project) {
2180
+ const system = [
2181
+ "你是一位 AI 绘图的视觉规则设计师。本书的设定是\"反常识\"的,生图/生视频模型默认会画成现实世界的样子,你需要提炼 3-6 条「视觉规则」钉住本书的视觉世界观。",
2182
+ "规则要求:",
2183
+ "1. 每条必须可执行:明确\"画面里必须出现什么/禁止出现什么\"(如\"货架上的一切商品都是活人,禁止画成罐头/饮料/日用品\")。",
2184
+ "2. 覆盖本书最容易被模型画错的 2-4 个核心反常识点。",
2185
+ "3. 每条 40 字内,禁止泛泛而谈。",
2186
+ "输出必须是合法 JSON 数组:[\"规则1\", \"规则2\", ...],不要输出其他文字。"
2187
+ ].join("\n");
2188
+ const bible = project.bible;
2189
+ return parseJsonArray(await complete(ctx, config, {
2190
+ system,
2191
+ user: [
2192
+ `书名:《${project.bookName}》`,
2193
+ `题材:${bible?.genre ?? ""}`,
2194
+ bible !== void 0 && bible.worldRules.length > 0 ? `世界规则:\n${bible.worldRules.map((r) => "- " + r).join("\n")}` : "",
2195
+ bible !== void 0 && bible.redLines.length > 0 ? `红线:\n${bible.redLines.map((r) => "- " + r).join("\n")}` : "",
2196
+ "只输出 JSON 数组。"
2197
+ ].filter((s) => s !== "").join("\n\n"),
2198
+ temperature: .3,
2199
+ maxTokens: Math.max(config.maxTokens, 4e3)
2200
+ })).map((r) => typeof r === "string" ? r.trim().slice(0, 80) : "").filter((r) => r !== "").slice(0, 8);
1872
2201
  }
1873
2202
  /**
1874
2203
  * 开书想法 → AI 大纲:输入一句话想法,生成 2-3 个方向不同、可直接开书的完整大纲方案。
@@ -2069,134 +2398,49 @@ async function breakdownBook(ctx, config, project, outputDir, scope = "recent",
2069
2398
  usedTokens
2070
2399
  };
2071
2400
  }
2072
- /**
2073
- * 漫剧分镜生成:把一章正文改编为短视频漫剧分镜(吸收 manga-script-master 方法论)。
2074
- * 产出:本集标题 + 赛道节奏说明 + 角色视觉锚点卡 + 分镜表(8-12 格)+ 结尾钩子。
2075
- * 角色锚点优先复用角色库 imagePrompt(无则从正文提炼)。
2076
- */
2077
- async function generateStoryboard(ctx, config, project, outputDir, chapterNo, genre, platform, tool) {
2078
- const chapter = project.chapters.find((c) => c.no === chapterNo);
2079
- if (chapter === void 0) throw new Error(`章节 ${chapterNo} 不在计划中`);
2080
- const body = readChapterFile(outputDir, chapter);
2081
- if (body === void 0) throw new Error(`章节 ${chapterNo} 尚未生成正文`);
2082
- const roleCards = (project.roles ?? []).filter((r) => r.imagePrompt !== void 0 && (body.includes(r.name) || chapter.summary?.includes(r.name) === true)).map((r) => ({
2083
- name: r.name,
2084
- anchor: r.imagePrompt.zh.slice(0, 120),
2085
- tags: r.imagePrompt.en.slice(0, 300)
2086
- })).slice(0, 3);
2087
- const raw = parseJsonObject(await complete(ctx, config, {
2088
- system: [
2089
- "你是一位精通短视频算法与 AIGC 的顶级漫剧导演。把给定的一章网文改编为高完播率的漫剧分镜。",
2090
- "硬性要求:",
2091
- "1. 前 3 秒必须有强冲突或视觉冲击(黄金开局);每 10 秒至少一个小高潮;结尾必须有悬念钩子。",
2092
- "2. 所有情感必须具象化为动作(不能写\"他很愤怒\",要写\"他拳头砸碎桌角,碎片飞溅\")。",
2093
- "3. 台词每句 ≤15 字,用画面推进叙事。",
2094
- "4. 总时长 60-120 秒,分镜 8-12 格,每格标注时间码、景别、转场/动效。",
2095
- "5. 输出 JSON:",
2096
- "{\"title\": \"本集标题(核心设定+冲突+悬念句式)\", \"pacingNote\": \"赛道判断与节奏说明(按爽文/甜宠/悬疑/搞笑模型)\", \"hook\": \"本集钩子一句话\", \"panels\": [{\"timecode\": \"0:00-0:03\", \"shot\": \"特写\", \"visual\": \"具象画面描述\", \"dialogue\": \"(情绪)台词\", \"transition\": \"硬切/闪白等\", \"prompt\": \"英文AI提示词:角色标签+表情+动作+场景+景别+光影+风格+9:16\"}], \"endingHook\": \"结尾钩子台词\"}",
2097
- "6. 英文 prompt 里的角色标签必须复用提供的角色锚点标签;多角色同框用 left/right/foreground 分隔。",
2098
- "7. 严禁在提示词里出现文字/水印(画面内字幕后期加)。",
2099
- "重要:直接输出 JSON,字符串内不含换行。"
2100
- ].join("\n"),
2101
- user: [
2102
- `书名:《${project.bookName}》`,
2103
- `章节:第 ${chapter.no} 章《${chapter.title}》`,
2104
- chapter.summary !== void 0 ? `章节摘要:${chapter.summary}` : "",
2105
- `目标平台:${platform}(默认抖音竖屏 9:16)`,
2106
- `赛道:${genre !== "" ? genre : "(由你按内容判断:爽文/甜宠/悬疑/搞笑)"}`,
2107
- `AI 工具:${tool}(影响提示词格式)`,
2108
- roleCards.length > 0 ? `角色视觉锚点(英文标签全分镜强制复用):\n${roleCards.map((r) => `- ${r.name}:${r.tags}`).join("\n")}` : "(无角色锚点卡,从正文提炼 1-2 个核心角色并给出标签)",
2109
- "==================== 正文 ====================",
2110
- body.replace(/^#\s+.*$/m, "").trim().slice(0, 6e3),
2111
- "只输出 JSON 对象。"
2112
- ].join("\n\n"),
2113
- temperature: .5,
2114
- maxTokens: Math.max(config.maxTokens, 12e3)
2115
- }));
2116
- const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
2117
- const panels = (Array.isArray(raw.panels) ? raw.panels.filter((p) => typeof p === "object" && p !== null) : []).map((p, i) => ({
2118
- index: i + 1,
2119
- timecode: str(p.timecode, `0:${String(i * 10).padStart(2, "0")}`),
2120
- shot: str(p.shot, "中景"),
2121
- visual: str(p.visual),
2122
- dialogue: str(p.dialogue),
2123
- transition: str(p.transition, "硬切"),
2124
- prompt: str(p.prompt)
2125
- })).filter((p) => p.visual !== "").slice(0, 12);
2126
- return {
2127
- title: str(raw.title, `第 ${chapter.no} 集`),
2128
- pacingNote: str(raw.pacingNote, "(按内容判断赛道节奏)"),
2129
- hook: str(raw.hook),
2130
- characters: roleCards.map((r) => ({
2131
- name: r.name,
2132
- visualAnchor: r.anchor,
2133
- tags: r.tags,
2134
- expressions: []
2135
- })),
2136
- panels,
2137
- endingHook: str(raw.endingHook)
2138
- };
2139
- }
2140
- /**
2141
- * 漫剧分集计划:AI 通读一卷的章节标题+摘要+beats,按故事弧线把章节分组为漫剧集。
2142
- * 原则(吸收 manga-script 节奏模型):高潮章单独成集、过渡章合并、断点选在钩子最强处。
2143
- */
2144
- async function planStoryboardEpisodes(ctx, config, project, volumeNo, platform, maxEpisodes) {
2145
- const volume = project.volumes?.find((v) => v.no === volumeNo);
2146
- if (volume === void 0) throw new Error(`第 ${volumeNo} 卷不存在(卷计划未生成或卷号错误)`);
2147
- const chapters = project.chapters.filter((c) => c.volume === volumeNo && c.status !== "pending" && c.status !== "generating").sort((a, b) => a.no - b.no);
2148
- if (chapters.length === 0) throw new Error(`第 ${volumeNo} 卷没有已写章节`);
2149
- const chapterBriefs = chapters.map((c) => {
2150
- const beatsHead = (c.beats ?? "").split("\n")[0] ?? "";
2151
- return `第${c.no}章《${c.title}》|${beatsHead.slice(0, 60)}|${(c.summary ?? "").slice(0, 100)}`;
2401
+ async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "") {
2402
+ const role = project.roles?.find((r) => r.name === name);
2403
+ if (role === void 0) throw new Error(`角色 ${name} 不存在`);
2404
+ if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
2405
+ if (config.imageApiEnabled !== true) throw new Error("豆包生图未启用,请在设置中开启");
2406
+ const apiKey = config.imageApiKey;
2407
+ const model = config.imageApiModel;
2408
+ if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置豆包 API Key 和生图模型");
2409
+ const stylePrompt = getComicStylePrompt(style);
2410
+ const prompt = [
2411
+ role.imagePrompt.zh,
2412
+ role.imagePrompt.en,
2413
+ role.imagePrompt.tags.join(", "),
2414
+ stylePrompt,
2415
+ "character reference sheet, front view, clean background, consistent design"
2416
+ ].filter(Boolean).join(", ");
2417
+ const res = await fetch("https://ark.cn-beijing.volces.com/api/v3/images/generations", {
2418
+ method: "POST",
2419
+ headers: {
2420
+ "Content-Type": "application/json",
2421
+ Authorization: `Bearer ${apiKey}`
2422
+ },
2423
+ body: JSON.stringify({
2424
+ model,
2425
+ prompt,
2426
+ response_format: "url",
2427
+ size: "2K",
2428
+ stream: false,
2429
+ watermark: false
2430
+ })
2152
2431
  });
2153
- const raw = parseJsonObject(await complete(ctx, config, {
2154
- system: [
2155
- "你是一位精通短视频算法与漫剧节奏的总导演。把给定的一卷网文改编为「漫剧分集计划」——不是按章分,而是按故事弧线分集。",
2156
- "分集原则:",
2157
- "1. 高潮章(大冲突/身份揭晓/大收获)单独成集或两章一集;过渡章(赶路/准备/日常)2-3 章合并为一集;支线穿插章并入相邻主线圈。",
2158
- "2. 每集 60-120 秒 ≈ 1-2 章正文,但以\"叙事任务完整\"为准,不被章节号束缚。",
2159
- "3. 断点选在钩子最强处:每集结尾必须留下让观众点下集的悬念。",
2160
- "4. 全集数控制在 8 到上限之间(上限由输入给出),宁少勿碎。",
2161
- "5. 卷的开局集要强(3 秒钩子),卷的结尾集要收束本卷弧线并埋下卷间钩子。",
2162
- "输出 JSON:",
2163
- "{\"strategy\": \"本卷漫剧化策略一句话(赛道判断+整体节奏安排)\", \"episodes\": [{\"title\": \"集标题(冲突+悬念句式)\", \"chapters\": [章号数组], \"narrativeJob\": \"这集讲什么、为什么这么分(1-2句)\", \"openingHook\": \"开头3秒钩子\", \"endingHook\": \"结尾钩子\"}]}",
2164
- "硬性要求:episodes 按章号顺序排列且覆盖全部给定章节(不得遗漏);chapters 数组必须连续(如 [81,82,83]);每集 chapters 至少 1 章、最多 4 章;所有文字用简体中文。",
2165
- "重要:直接输出 JSON,字符串内不含换行。"
2166
- ].join("\n"),
2167
- user: [
2168
- `书名:《${project.bookName}》`,
2169
- `第 ${volumeNo} 卷《${volume.title}》(${volume.chapterStart}-${volume.chapterEnd} 章)`,
2170
- `卷定位:${volume.summary}`,
2171
- `目标平台:${platform}(竖屏 9:16)`,
2172
- `本卷已写章节(${chapters.length} 章,按序):\n${chapterBriefs.join("\n")}`,
2173
- `全集数上限:${maxEpisodes}`,
2174
- "只输出 JSON 对象。"
2175
- ].join("\n\n"),
2176
- temperature: .4,
2177
- maxTokens: Math.max(config.maxTokens, 12e3)
2178
- }));
2179
- const episodesRaw = Array.isArray(raw.episodes) ? raw.episodes.filter((e) => typeof e === "object" && e !== null) : [];
2180
- const str = (v, fallback = "") => typeof v === "string" ? v.trim() : fallback;
2181
- const episodes = [];
2182
- for (const e of episodesRaw) {
2183
- const chaptersArr = Array.isArray(e.chapters) ? e.chapters.filter((n) => typeof n === "number" && Number.isInteger(n) && n > 0) : [];
2184
- if (chaptersArr.length === 0) continue;
2185
- episodes.push({
2186
- index: episodes.length + 1,
2187
- title: str(e.title, `第 ${episodes.length + 1} 集`),
2188
- chapters: chaptersArr,
2189
- narrativeJob: str(e.narrativeJob),
2190
- openingHook: str(e.openingHook),
2191
- endingHook: str(e.endingHook)
2192
- });
2432
+ if (!res.ok) {
2433
+ const text = await res.text();
2434
+ throw new Error(`豆包生图失败(${res.status}):${text}`);
2193
2435
  }
2194
- if (episodes.length === 0) throw new Error("分集计划生成失败:LLM 未返回有效集数");
2195
- return {
2196
- strategy: str(raw.strategy, "(按内容判断赛道节奏)"),
2197
- episodes,
2198
- chaptersScanned: chapters.length
2199
- };
2436
+ const url = (await res.json()).data?.[0]?.url;
2437
+ if (url === void 0 || url === "") throw new Error("豆包生图未返回图片 URL");
2438
+ const imgRes = await fetch(url);
2439
+ if (!imgRes.ok) throw new Error("下载豆包图片失败");
2440
+ const buf = Buffer.from(await imgRes.arrayBuffer());
2441
+ const dataUrl = `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
2442
+ role.imageUrl = dataUrl;
2443
+ return dataUrl;
2200
2444
  }
2201
2445
  /** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
2202
2446
  async function analyzePlotlineHealth(ctx, config, project) {
@@ -3655,6 +3899,8 @@ const NOVEL_API = {
3655
3899
  plotlines: "/api/dsh-novel-forge/plotlines",
3656
3900
  /** 角色库:AI 提炼 / 采纳 / 更新 / 删除。 */
3657
3901
  roles: "/api/dsh-novel-forge/roles",
3902
+ scenes: "/api/dsh-novel-forge/scenes",
3903
+ visualRules: "/api/dsh-novel-forge/visual-rules",
3658
3904
  /** 作者复盘补跑:对已写章节补齐 authorReview(全书流式 / 单章 JSON)。 */
3659
3905
  reviewBackfill: "/api/dsh-novel-forge/review/backfill",
3660
3906
  /** 章节复位:generating 卡死 → pending(可重新生成)。 */
@@ -3668,9 +3914,8 @@ const NOVEL_API = {
3668
3914
  /** 拆书分析:对已写章节做结构/人物/文风/卖点四维体检(两阶段:源笔记→分节分析)。 */
3669
3915
  breakdown: "/api/dsh-novel-forge/breakdown",
3670
3916
  /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(可适配豆包/Seedance/SD)。 */
3671
- storyboard: "/api/dsh-novel-forge/storyboard",
3672
3917
  /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
3673
- storyboardPlan: "/api/dsh-novel-forge/storyboard/plan",
3918
+ /** 漫画脚本:章节 → 分页分格漫画脚本(含角色视觉锚点)。 */
3674
3919
  /** 生产单:启动批量生产(计划补足 + 逐章生成 + 被拒分级处理)。 */
3675
3920
  runStart: "/api/dsh-novel-forge/run/start",
3676
3921
  /** 生产单控制:pause / resume / stop。 */
@@ -4663,7 +4908,7 @@ function makeRoutes(deps) {
4663
4908
  return;
4664
4909
  }
4665
4910
  try {
4666
- const bible = await extractBible(ctx, config, outline);
4911
+ const bible = await extractBible(ctx, config, outline, project);
4667
4912
  const now = (/* @__PURE__ */ new Date()).toISOString();
4668
4913
  const next = project ?? createProject(outline);
4669
4914
  next.bible = bible;
@@ -5949,7 +6194,71 @@ function makeRoutes(deps) {
5949
6194
  if (idx === -1) project.roles.push(r);
5950
6195
  else project.roles[idx] = r;
5951
6196
  } else if (op === "remove" && body?.name !== void 0) project.roles = project.roles.filter((x) => x.name !== body.name);
5952
- else if (op === "visual") {
6197
+ else if (op === "image") {
6198
+ const name = body?.name?.trim();
6199
+ const dataUrl = body?.dataUrl?.trim();
6200
+ if (name === void 0 || name === "") {
6201
+ writeJson(res, 400, { error: "name(角色名)必填" });
6202
+ return;
6203
+ }
6204
+ if (dataUrl === void 0 || dataUrl === "") {
6205
+ writeJson(res, 400, { error: "dataUrl(参考图)必填" });
6206
+ return;
6207
+ }
6208
+ const role = project.roles.find((r) => r.name === name);
6209
+ if (role === void 0) {
6210
+ writeJson(res, 404, { error: `角色 ${name} 不存在` });
6211
+ return;
6212
+ }
6213
+ const label = body?.label?.trim() ?? "";
6214
+ if (label !== "") {
6215
+ role.gallery ??= [];
6216
+ role.gallery = role.gallery.filter((g) => g.label !== label);
6217
+ role.gallery.push({
6218
+ label,
6219
+ dataUrl
6220
+ });
6221
+ if (label === "立绘" || label.includes("立绘")) role.imageUrl = dataUrl;
6222
+ } else role.imageUrl = dataUrl;
6223
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6224
+ saveProject(config.outputDir, project);
6225
+ writeJson(res, 200, {
6226
+ roles: project.roles,
6227
+ imageUrl: role.imageUrl
6228
+ });
6229
+ return;
6230
+ } else if (op === "removeImage") {
6231
+ const name = body?.name?.trim();
6232
+ const label = body?.label?.trim() ?? "";
6233
+ const role = project.roles.find((r) => r.name === name);
6234
+ if (role !== void 0) if (label !== "") {
6235
+ role.gallery = (role.gallery ?? []).filter((g) => g.label !== label);
6236
+ if (label === "立绘" || label.includes("立绘")) role.imageUrl = void 0;
6237
+ } else role.imageUrl = void 0;
6238
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6239
+ saveProject(config.outputDir, project);
6240
+ writeJson(res, 200, { roles: project.roles });
6241
+ return;
6242
+ } else if (op === "imageGenerate") {
6243
+ const name = body?.name?.trim();
6244
+ if (name === void 0 || name === "") {
6245
+ writeJson(res, 400, { error: "name(角色名)必填" });
6246
+ return;
6247
+ }
6248
+ try {
6249
+ const imageUrl = await generateRoleReferenceImage(ctx, config, project, config.outputDir, name, body?.style ?? "");
6250
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6251
+ saveProject(config.outputDir, project);
6252
+ writeJson(res, 200, {
6253
+ roles: project.roles,
6254
+ imageUrl
6255
+ });
6256
+ return;
6257
+ } catch (error) {
6258
+ writeJson(res, 500, { error: `角色参考图生成失败:${error.message}` });
6259
+ return;
6260
+ }
6261
+ } else if (op === "visual") {
5953
6262
  const name = body?.name?.trim();
5954
6263
  if (name === void 0 || name === "") {
5955
6264
  writeJson(res, 400, { error: "name(角色名)必填" });
@@ -5958,7 +6267,11 @@ function makeRoutes(deps) {
5958
6267
  try {
5959
6268
  const visual = await extractRoleVisual(ctx, config, project, config.outputDir, name);
5960
6269
  const role = project.roles.find((r) => r.name === name);
5961
- if (role !== void 0) role.imagePrompt = visual;
6270
+ if (role !== void 0) {
6271
+ role.imagePrompt = visual;
6272
+ if ((visual.expressions ?? []).length > 0) role.expressions = visual.expressions;
6273
+ if (visual.promptKit !== void 0) role.promptKit = visual.promptKit;
6274
+ }
5962
6275
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
5963
6276
  saveProject(config.outputDir, project);
5964
6277
  writeJson(res, 200, {
@@ -5970,6 +6283,27 @@ function makeRoutes(deps) {
5970
6283
  writeJson(res, 500, { error: `形象提炼失败:${error.message}` });
5971
6284
  return;
5972
6285
  }
6286
+ } else if (op === "promptKit") {
6287
+ const name = body?.name?.trim();
6288
+ if (name === void 0 || name === "") {
6289
+ writeJson(res, 400, { error: "name(角色名)必填" });
6290
+ return;
6291
+ }
6292
+ try {
6293
+ const kit = await generateRolePromptKit(ctx, config, project, name);
6294
+ const role = project.roles.find((r) => r.name === name);
6295
+ if (role !== void 0) role.promptKit = kit;
6296
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6297
+ saveProject(config.outputDir, project);
6298
+ writeJson(res, 200, {
6299
+ roles: project.roles,
6300
+ promptKit: kit
6301
+ });
6302
+ return;
6303
+ } catch (error) {
6304
+ writeJson(res, 500, { error: `提示词精修失败:${error.message}` });
6305
+ return;
6306
+ }
5973
6307
  } else {
5974
6308
  writeJson(res, 400, { error: "未知的 roles op" });
5975
6309
  return;
@@ -6192,46 +6526,97 @@ function makeRoutes(deps) {
6192
6526
  }
6193
6527
  }
6194
6528
  };
6195
- /** 漫剧分镜生成:章节 → 角色锚点 + 分镜表(适配豆包/Seedance/SD)。 */
6196
- const storyboardRoute = {
6529
+ /** 场景库:AI 提炼 / 采纳 / 更新 / 删除 / 图集。 */
6530
+ const scenesRoute = {
6197
6531
  kind: "exact",
6198
- path: NOVEL_API.storyboard,
6532
+ path: NOVEL_API.scenes,
6199
6533
  handler: async (req, res) => {
6200
6534
  if (!guard(req, res, "POST")) return;
6201
6535
  const config = getConfig();
6202
6536
  const project = requireProject(res);
6203
6537
  if (project === void 0) return;
6204
6538
  const body = await readJsonBody(req);
6205
- if (!Number.isInteger(body?.chapterNo)) {
6206
- writeJson(res, 400, { error: "chapterNo 须为正整数" });
6539
+ if (project.scenes === void 0) project.scenes = [];
6540
+ const op = body?.op;
6541
+ if (op === "extract") try {
6542
+ const candidates = await extractScenes(ctx, config, project);
6543
+ writeJson(res, 200, {
6544
+ scenes: project.scenes,
6545
+ candidates
6546
+ });
6207
6547
  return;
6208
- }
6209
- try {
6210
- writeJson(res, 200, await generateStoryboard(ctx, config, project, config.outputDir, body.chapterNo, body?.genre ?? "", body?.platform ?? "抖音", body?.tool ?? "doubao"));
6211
6548
  } catch (error) {
6212
- writeJson(res, 500, { error: `分镜生成失败:${error.message}` });
6549
+ writeJson(res, 500, { error: `场景提炼失败:${error.message}` });
6550
+ return;
6551
+ }
6552
+ else if ((op === "adopt" || op === "update") && body?.scene !== void 0) {
6553
+ const s = body.scene;
6554
+ const idx = project.scenes.findIndex((x) => x.name === s.name);
6555
+ if (idx === -1) project.scenes.push(s);
6556
+ else project.scenes[idx] = s;
6557
+ } else if (op === "remove" && body?.name !== void 0) project.scenes = project.scenes.filter((x) => x.name !== body.name);
6558
+ else if (op === "image") {
6559
+ const name = body?.name?.trim();
6560
+ const dataUrl = body?.dataUrl?.trim();
6561
+ if (name === void 0 || name === "" || dataUrl === void 0 || dataUrl === "") {
6562
+ writeJson(res, 400, { error: "name(场景名)与 dataUrl 必填" });
6563
+ return;
6564
+ }
6565
+ const scene = project.scenes.find((x) => x.name === name);
6566
+ if (scene === void 0) {
6567
+ writeJson(res, 404, { error: `场景 ${name} 不存在` });
6568
+ return;
6569
+ }
6570
+ const label = body?.label?.trim() ?? "全景";
6571
+ scene.gallery ??= [];
6572
+ scene.gallery = scene.gallery.filter((g) => g.label !== label);
6573
+ scene.gallery.push({
6574
+ label,
6575
+ dataUrl
6576
+ });
6577
+ } else if (op === "removeImage") {
6578
+ const name = body?.name?.trim();
6579
+ const label = body?.label?.trim() ?? "";
6580
+ const scene = project.scenes.find((x) => x.name === name);
6581
+ if (scene !== void 0 && label !== "") scene.gallery = (scene.gallery ?? []).filter((g) => g.label !== label);
6582
+ } else {
6583
+ writeJson(res, 400, { error: `未知操作 ${op}` });
6584
+ return;
6213
6585
  }
6586
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6587
+ saveProject(config.outputDir, project);
6588
+ writeJson(res, 200, { scenes: project.scenes });
6214
6589
  }
6215
6590
  };
6216
- /** 漫剧分集计划:读一卷 → 按故事弧线分集(高潮拆集/过渡并章)。 */
6217
- const storyboardPlanRoute = {
6591
+ /** 视觉世界观规则:从道藏提炼(生图/生视频纠偏),或手动保存。 */
6592
+ const visualRulesRoute = {
6218
6593
  kind: "exact",
6219
- path: NOVEL_API.storyboardPlan,
6594
+ path: NOVEL_API.visualRules,
6220
6595
  handler: async (req, res) => {
6221
6596
  if (!guard(req, res, "POST")) return;
6222
6597
  const config = getConfig();
6223
6598
  const project = requireProject(res);
6224
6599
  if (project === void 0) return;
6225
6600
  const body = await readJsonBody(req);
6226
- if (!Number.isInteger(body?.volumeNo) || (body?.volumeNo ?? 0) < 1) {
6227
- writeJson(res, 400, { error: "volumeNo 须为正整数" });
6601
+ if (body?.op === "extract") try {
6602
+ const rules = await extractVisualRules(ctx, config, project);
6603
+ project.visualRules = rules;
6604
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6605
+ saveProject(config.outputDir, project);
6606
+ writeJson(res, 200, { rules });
6228
6607
  return;
6229
- }
6230
- try {
6231
- writeJson(res, 200, await planStoryboardEpisodes(ctx, config, project, body.volumeNo, body?.platform ?? "抖音", body?.maxEpisodes ?? 25));
6232
6608
  } catch (error) {
6233
- writeJson(res, 500, { error: `分集计划生成失败:${error.message}` });
6609
+ writeJson(res, 500, { error: `视觉规则提炼失败:${error.message}` });
6610
+ return;
6611
+ }
6612
+ else if (body?.op === "save" && Array.isArray(body.rules)) {
6613
+ project.visualRules = body.rules.filter((r) => typeof r === "string" && r.trim() !== "").map((r) => r.trim().slice(0, 80)).slice(0, 12);
6614
+ project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
6615
+ saveProject(config.outputDir, project);
6616
+ writeJson(res, 200, { rules: project.visualRules });
6617
+ return;
6234
6618
  }
6619
+ writeJson(res, 400, { error: "op 须为 extract 或 save" });
6235
6620
  }
6236
6621
  };
6237
6622
  /** 生产单执行器(单例):计划补足 → 逐章生成 → 被拒分级处理 → 断点续跑。 */
@@ -6277,6 +6662,8 @@ function makeRoutes(deps) {
6277
6662
  renameRoute,
6278
6663
  plotlinesRoute,
6279
6664
  rolesRoute,
6665
+ scenesRoute,
6666
+ visualRulesRoute,
6280
6667
  sensitiveRoute,
6281
6668
  reviewBackfillRoute,
6282
6669
  chapterResetRoute,
@@ -6285,8 +6672,6 @@ function makeRoutes(deps) {
6285
6672
  openFolderRoute,
6286
6673
  outlineSuggestRoute,
6287
6674
  breakdownRoute,
6288
- storyboardRoute,
6289
- storyboardPlanRoute,
6290
6675
  {
6291
6676
  kind: "exact",
6292
6677
  path: NOVEL_API.runStart,
@@ -6377,7 +6762,10 @@ const Config = z.object({
6377
6762
  reviewPassScore: z.number().default(70),
6378
6763
  autoReview: z.boolean().default(true),
6379
6764
  autoAuthorReview: z.boolean().default(true),
6380
- autoReviewAfterRevise: z.boolean().default(true)
6765
+ autoReviewAfterRevise: z.boolean().default(true),
6766
+ imageApiKey: z.string().default(""),
6767
+ imageApiModel: z.string().default(""),
6768
+ imageApiEnabled: z.boolean().default(false)
6381
6769
  });
6382
6770
  /** Schema defaults, re-read for hand-built test contexts. */
6383
6771
  const DEFAULT_ANNOUNCE = true;
@@ -6409,7 +6797,10 @@ function resolveConfig(value) {
6409
6797
  reviewPassScore: value?.reviewPassScore ?? DEFAULT_REVIEW_PASS_SCORE,
6410
6798
  autoReview: value?.autoReview ?? DEFAULT_AUTO_REVIEW,
6411
6799
  autoAuthorReview: value?.autoAuthorReview ?? DEFAULT_AUTO_AUTHOR_REVIEW,
6412
- autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE
6800
+ autoReviewAfterRevise: value?.autoReviewAfterRevise ?? DEFAULT_AUTO_REVIEW_AFTER_REVISE,
6801
+ imageApiKey: value?.imageApiKey,
6802
+ imageApiModel: value?.imageApiModel,
6803
+ imageApiEnabled: value?.imageApiEnabled ?? false
6413
6804
  };
6414
6805
  }
6415
6806
  /**
@@ -6441,6 +6832,9 @@ function apply(ctx, config) {
6441
6832
  if (patch.autoReview !== void 0) next.autoReview = patch.autoReview;
6442
6833
  if (patch.autoAuthorReview !== void 0) next.autoAuthorReview = patch.autoAuthorReview;
6443
6834
  if (patch.autoReviewAfterRevise !== void 0) next.autoReviewAfterRevise = patch.autoReviewAfterRevise;
6835
+ if (patch.imageApiKey !== void 0) next.imageApiKey = patch.imageApiKey;
6836
+ if (patch.imageApiModel !== void 0) next.imageApiModel = patch.imageApiModel;
6837
+ if (patch.imageApiEnabled !== void 0) next.imageApiEnabled = patch.imageApiEnabled;
6444
6838
  const settings = ctx.get("settings");
6445
6839
  if (settings !== void 0) await settings.update(NOVEL_SETTINGS_NAMESPACE, next);
6446
6840
  else current = () => ({