@waterwx/dsh-novel-forge 1.5.0 → 1.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/index.js CHANGED
@@ -2116,10 +2116,13 @@ async function extractRoles(ctx, config, project) {
2116
2116
  return roles;
2117
2117
  }
2118
2118
  /** ✨ AI 从全书提炼场景库:正文/编年录 → 高频重要场景的结构化视觉锚点。 */
2119
- async function extractScenes(ctx, config, project) {
2119
+ async function extractScenes(ctx, config, project, styleId, filterId) {
2120
+ const styleWords = styleKeywords(styleId, filterId);
2120
2121
  const system = [
2121
2122
  "你是一位网文漫剧场景导演。根据本书的大纲、编年录与已写章节摘录,提炼「镜头场景」——漫剧分镜/生图时每个镜头要知道\"在哪、什么时间光态、拍什么情节、人物什么状态\"。",
2122
2123
  "按五幕结构覆盖全书核心场景(短篇 10 章内可少于五幕,按实际剧情组织):第一幕后场/核心主场、第二幕对外卖场/营业区、第三幕禁忌区域/地下深仓、第四幕外部人间世界(短暂出逃)、第五幕回归与抉择。",
2124
+ "【当前视觉风格】(moment/palette/moods/zh/en 必须按此风格措辞,不能写中性描述):" + styleWords,
2125
+ "重要:每个场景的 zh 提示词段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2123
2126
  "每个场景必须是「镜头场景」而非仅场地:给出该场景的关键情节镜头(人物动作+情绪+镜头推进)。",
2124
2127
  "数量控制:最多输出 8 个场景;每个场景必须能对应到正文实际出现过的地点与情节,不得凭空虚构。",
2125
2128
  "每个场景输出:",
@@ -2177,10 +2180,11 @@ async function extractScenes(ctx, config, project) {
2177
2180
  elements: strArr(entry.elements).map((t) => t.slice(0, 60)).slice(0, 6),
2178
2181
  palette: strArr(entry.palette).map((t) => t.slice(0, 40)).slice(0, 4),
2179
2182
  moods: strArr(entry.moods).map((t) => t.slice(0, 20)).slice(0, 4),
2180
- zh: str(entry.zh).slice(0, 800),
2181
- en: str(entry.en).slice(0, 600),
2183
+ zh: ensureStyleEmbedded(str(entry.zh).slice(0, 800), styleWords, "zh"),
2184
+ en: ensureStyleEmbedded(str(entry.en).slice(0, 600), styleWords, "en"),
2182
2185
  tags: strArr(entry.tags).map((t) => t.slice(0, 24)).slice(0, 6),
2183
- source: str(entry.source).slice(0, 120)
2186
+ source: str(entry.source).slice(0, 120),
2187
+ styleId
2184
2188
  });
2185
2189
  }
2186
2190
  return scenes;
@@ -2354,6 +2358,104 @@ function importBookTextFromText(raw, outputDir, bookName) {
2354
2358
  function importBookText(filePath, outputDir) {
2355
2359
  return importBookTextFromText(decodeTextSmart(readFileSync(filePath)), outputDir, basename(filePath, extname(filePath)).slice(0, 40) || "导入小说");
2356
2360
  }
2361
+ /** 常见职业/身份尾缀:角色名如「周野律师」正文可能只写「周野」或「周野的律师」。 */
2362
+ const ROLE_NAME_SUFFIXES = [
2363
+ "律师",
2364
+ "辩护律师",
2365
+ "医生",
2366
+ "老师",
2367
+ "教授",
2368
+ "先生",
2369
+ "女士",
2370
+ "小姐",
2371
+ "警官",
2372
+ "警察",
2373
+ "局长",
2374
+ "总经理",
2375
+ "经理",
2376
+ "老板",
2377
+ "师父",
2378
+ "师傅",
2379
+ "道长",
2380
+ "老祖",
2381
+ "长老",
2382
+ "掌门",
2383
+ "少主",
2384
+ "公主",
2385
+ "王子",
2386
+ "王妃",
2387
+ "皇后",
2388
+ "皇帝",
2389
+ "王爷",
2390
+ "公子",
2391
+ "姑娘",
2392
+ "夫人",
2393
+ "太太",
2394
+ "大人",
2395
+ "将军",
2396
+ "丞相",
2397
+ "尚书",
2398
+ "员外"
2399
+ ];
2400
+ /**
2401
+ * 从角色名/身份拆出正文可能使用的检索词:
2402
+ * - specific:具体称谓(含职业尾缀或 3 字以上身份片段),如「周野的律师」「辩护律师」「律师」——优先用,避免误抓到同名主干(周野)的段落;
2403
+ * - stems:名字主干(如「周野」),最后兜底。
2404
+ */
2405
+ function roleFallbackTokens(name, identity) {
2406
+ const specific = /* @__PURE__ */ new Set();
2407
+ const stems = /* @__PURE__ */ new Set();
2408
+ const addName = (s) => {
2409
+ const t = s.trim();
2410
+ if (t.length < 2) return;
2411
+ specific.add(t);
2412
+ for (const q of [
2413
+ "辩护",
2414
+ "助理",
2415
+ "高级",
2416
+ "首席",
2417
+ "御用",
2418
+ "御前",
2419
+ "专职"
2420
+ ]) if (t.includes(q)) specific.add(t.replace(q, ""));
2421
+ for (const suf of ROLE_NAME_SUFFIXES) if (t.endsWith(suf) && t.length > suf.length + 1) {
2422
+ const stem = t.slice(0, -suf.length);
2423
+ specific.add(stem + "的" + suf);
2424
+ specific.add(suf);
2425
+ if (stem.length >= 2) stems.add(stem);
2426
+ }
2427
+ };
2428
+ addName(name);
2429
+ for (const part of (identity ?? "").split(/[的,,。\s]+/)) {
2430
+ const p = part.trim();
2431
+ if (p.length < 2) continue;
2432
+ if (p.length >= 3 || ROLE_NAME_SUFFIXES.some((s) => p.endsWith(s))) {
2433
+ specific.add(p);
2434
+ for (const suf of ROLE_NAME_SUFFIXES) if (p.endsWith(suf) && p.length > suf.length + 1) {
2435
+ specific.add(suf);
2436
+ stems.add(p.slice(0, -suf.length));
2437
+ }
2438
+ } else stems.add(p);
2439
+ }
2440
+ return {
2441
+ specific: [...specific].filter((t) => t.length >= 2),
2442
+ stems: [...stems].filter((t) => t.length >= 2)
2443
+ };
2444
+ }
2445
+ /** 保证风格词块已内嵌(LLM 偶发漏嵌时兜底):zh 段首前缀,en 末尾追加。 */
2446
+ function ensureStyleEmbedded(text, styleWords, lang) {
2447
+ const t = text.trim();
2448
+ if (styleWords === "" || t === "") return t;
2449
+ if (t.includes(styleWords)) return t;
2450
+ return lang === "zh" ? styleWords + "," + t : t + "," + styleWords;
2451
+ }
2452
+ /** 对 zh/en 一对提示词统一补风格词块。 */
2453
+ function withStyle(pair, styleWords) {
2454
+ return {
2455
+ zh: ensureStyleEmbedded(pair.zh, styleWords, "zh"),
2456
+ en: ensureStyleEmbedded(pair.en, styleWords, "en")
2457
+ };
2458
+ }
2357
2459
  /**
2358
2460
  * 为单个角色提炼「动漫形象描述词」:扫描该角色出场的已写章节正文,
2359
2461
  * 截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
@@ -2361,6 +2463,7 @@ function importBookText(filePath, outputDir) {
2361
2463
  async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
2362
2464
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2363
2465
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2466
+ role.promptStyleId = styleId;
2364
2467
  const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
2365
2468
  const roleText = `${role.name} ${role.identity ?? ""}`;
2366
2469
  const colorWords = [
@@ -2427,33 +2530,48 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2427
2530
  ...roleWords.filter((w) => roleText.includes(w)),
2428
2531
  ...locationWords.filter((w) => roleText.includes(w))
2429
2532
  ])).filter((t) => t.length >= 2);
2430
- const matchesRole = (para) => para.includes(roleName) || searchTokens.some((tok) => para.includes(tok));
2533
+ const matchesRole = (para, tokens) => para.includes(roleName) || tokens.some((tok) => para.includes(tok));
2431
2534
  const excerpts = [];
2432
- const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0).slice(-60);
2433
- for (const chapter of written) {
2434
- const body = readChapterFile(outputDir, chapter);
2435
- if (body === void 0) continue;
2436
- const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
2437
- let perChapter = 0;
2438
- for (const para of paras) {
2439
- if (perChapter >= 2 || excerpts.length >= 12) break;
2440
- if (!matchesRole(para)) continue;
2441
- if (appearanceHints.test(para) || excerpts.length < 4) {
2442
- excerpts.push({
2443
- no: chapter.no,
2444
- text: para.slice(0, 220)
2445
- });
2446
- perChapter++;
2535
+ const written = project.chapters.filter((c) => c.status !== "pending" && c.status !== "generating" && c.file !== void 0);
2536
+ /** 扫描一批章节,收集该角色出场且可能含外貌描写的段落(每章最多 2 段,共 12 段)。 */
2537
+ const scanChapters = (list, tokens) => {
2538
+ for (const chapter of list) {
2539
+ if (excerpts.length >= 12) break;
2540
+ const body = readChapterFile(outputDir, chapter);
2541
+ if (body === void 0) continue;
2542
+ const paras = body.split(/\n{2,}/).map((p) => p.trim()).filter((p) => p.length > 0);
2543
+ let perChapter = 0;
2544
+ for (const para of paras) {
2545
+ if (perChapter >= 2 || excerpts.length >= 12) break;
2546
+ if (!matchesRole(para, tokens)) continue;
2547
+ if (appearanceHints.test(para) || excerpts.length < 4) {
2548
+ excerpts.push({
2549
+ no: chapter.no,
2550
+ text: para.slice(0, 220)
2551
+ });
2552
+ perChapter++;
2553
+ }
2447
2554
  }
2448
2555
  }
2449
- if (excerpts.length >= 12) break;
2556
+ };
2557
+ scanChapters(written.slice(-60), searchTokens);
2558
+ if (excerpts.length === 0) scanChapters(written, searchTokens);
2559
+ if (excerpts.length === 0) {
2560
+ const fb = roleFallbackTokens(role.name, role.identity);
2561
+ if (fb.specific.length > 0) scanChapters(written, fb.specific);
2562
+ }
2563
+ if (excerpts.length === 0) {
2564
+ const fb = roleFallbackTokens(role.name, role.identity);
2565
+ if (fb.stems.length > 0) scanChapters(written, fb.stems);
2450
2566
  }
2451
- if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(仅搜索最近 60 章),请确认角色名与正文一致`);
2567
+ if (excerpts.length === 0) throw new Error(`正文中未找到「${roleName}」的出场描写(已扫描全书及角色名拆分词),请确认角色名与正文一致,或该角色尚未在正文出场`);
2452
2568
  const rules = project.visualRules ?? [];
2569
+ const styleWords = styleKeywords(styleId, filterId);
2453
2570
  const raw = parseJsonObject(await complete(ctx, config, {
2454
2571
  system: [
2455
2572
  "你是一位动漫角色设定师与 AI 绘图提示词工程师。根据网文正文中该角色的实际外貌描写,输出「形象锚点」与「四类生图提示词」——一次完成,用于 AI 绘图(NovelAI / Stable Diffusion / Midjourney / 豆包等)生成一致的角色立绘。",
2456
- "【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):" + styleKeywords(styleId, filterId),
2573
+ "【当前视觉风格】(portrait 立绘的「风格」字段必须原样使用,sheet/expressions/details 同样内嵌):" + styleWords,
2574
+ "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2457
2575
  "硬性要求(依据优先):",
2458
2576
  "1. 发色/发型/瞳色/服装/气质/标志物必须来自提供的正文段落,不得凭空发明。",
2459
2577
  "2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
@@ -2489,8 +2607,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2489
2607
  maxTokens: Math.max(config.maxTokens, 12e3),
2490
2608
  reasoning: config.analysisReasoning ?? "low"
2491
2609
  }));
2492
- const zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2493
- const en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
2610
+ let zh = typeof raw.zh === "string" ? raw.zh.trim().slice(0, 500) : "";
2611
+ let en = typeof raw.en === "string" ? raw.en.trim().slice(0, 1500) : "";
2494
2612
  const tags = Array.isArray(raw.tags) ? raw.tags.filter((t) => typeof t === "string" && t.trim() !== "").map((t) => t.trim().slice(0, 20)).slice(0, 12) : [];
2495
2613
  const source = typeof raw.source === "string" ? raw.source.trim().slice(0, 300) : "";
2496
2614
  const expressions = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "string" && e.trim() !== "").map((e) => e.trim().slice(0, 12)).slice(0, 12) : [];
@@ -2520,6 +2638,17 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
2520
2638
  promptKit = void 0;
2521
2639
  }
2522
2640
  if (zh === "" || en === "") throw new Error("形象描述提炼失败:LLM 未返回有效 JSON");
2641
+ zh = ensureStyleEmbedded(zh, styleWords, "zh");
2642
+ en = ensureStyleEmbedded(en, styleWords, "en");
2643
+ if (promptKit !== void 0) promptKit = {
2644
+ portrait: withStyle(promptKit.portrait, styleWords),
2645
+ sheet: withStyle(promptKit.sheet, styleWords),
2646
+ expressions: promptKit.expressions.map((e) => ({
2647
+ ...e,
2648
+ ...withStyle(e, styleWords)
2649
+ })),
2650
+ details: withStyle(promptKit.details, styleWords)
2651
+ };
2523
2652
  return {
2524
2653
  zh,
2525
2654
  en,
@@ -2537,11 +2666,14 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
2537
2666
  const role = (project.roles ?? []).find((r) => r.name === roleName);
2538
2667
  if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
2539
2668
  if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
2669
+ role.promptStyleId = styleId;
2540
2670
  const rules = project.visualRules ?? [];
2671
+ const styleWords = styleKeywords(styleId, filterId);
2541
2672
  const raw = parseJsonObject(await complete(ctx, config, {
2542
2673
  system: [
2543
2674
  "你是一位 AI 绘图提示词工程师。基于给定角色的形象锚点、表情清单与本书视觉规则,输出四类生图提示词(每类 zh+en 各一段)。",
2544
- "【当前视觉风格】(每类提示词必须内嵌):" + styleKeywords(styleId, filterId),
2675
+ "【当前视觉风格】(每类提示词必须内嵌):" + styleWords,
2676
+ "重要:每段 zh 提示词的段首必须原样嵌入【当前视觉风格】词块原文;en 提示词末尾追加风格标签。不得省略或改写。",
2545
2677
  "四类:",
2546
2678
  "1. portrait 立绘:正面站立全身人像,3D动漫超精细建模,纯白纯色背景;按字段流组织:身份(男子,角色名,外表年龄)→ 发型发色 → 胡茬 → 眼眸 → 面部 → 气质 → 上身服装分件(含磨损)→ 下身 → 鞋 → 标志物(含细节)→ 收尾(角色设计稿,细节完整展示,无多余杂物,全身完整无裁切)。",
2547
2679
  "2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
@@ -2575,14 +2707,16 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
2575
2707
  };
2576
2708
  const expressionsRaw = Array.isArray(raw.expressions) ? raw.expressions.filter((e) => typeof e === "object" && e !== null) : [];
2577
2709
  const kit = {
2578
- portrait: pair(raw.portrait),
2579
- sheet: pair(raw.sheet),
2710
+ portrait: withStyle(pair(raw.portrait), styleWords),
2711
+ sheet: withStyle(pair(raw.sheet), styleWords),
2580
2712
  expressions: expressionsRaw.map((e) => ({
2581
2713
  name: str(e.name).slice(0, 12) || "表情",
2582
- zh: str(e.zh).slice(0, 800),
2583
- en: str(e.en).slice(0, 1200)
2714
+ ...withStyle({
2715
+ zh: str(e.zh).slice(0, 800),
2716
+ en: str(e.en).slice(0, 1200)
2717
+ }, styleWords)
2584
2718
  })).slice(0, 12),
2585
- details: pair(raw.details)
2719
+ details: withStyle(pair(raw.details), styleWords)
2586
2720
  };
2587
2721
  if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
2588
2722
  return kit;
@@ -3243,15 +3377,22 @@ async function summarizeChapter(ctx, config, project, outputDir, chapterNo) {
3243
3377
  saveProject(outputDir, project);
3244
3378
  return chapter.summary;
3245
3379
  }
3246
- /** 把分镜产出写入项目持久化(按章 upsert)。 */
3380
+ /** 把分镜产出写入项目持久化(按章 upsert)。重新生成上游时级联清掉下游旧产物:
3381
+ * 新骨架 → 清分镜表+提示词;新分镜表 → 清提示词;新提示词不动上游。 */
3247
3382
  function saveChapterStoryboard(project, outputDir, entry) {
3248
3383
  if (project.storyboards === void 0) project.storyboards = [];
3249
3384
  const idx = project.storyboards.findIndex((e) => e.chapterNo === entry.chapterNo);
3250
- if (idx === -1) project.storyboards.push(entry);
3251
- else project.storyboards[idx] = {
3252
- ...project.storyboards[idx],
3385
+ const next = {
3386
+ ...(idx === -1 ? void 0 : project.storyboards[idx]) ?? {},
3253
3387
  ...entry
3254
3388
  };
3389
+ if (entry.skeleton !== void 0) {
3390
+ next.table = void 0;
3391
+ next.prompts = void 0;
3392
+ }
3393
+ if (entry.table !== void 0) next.prompts = void 0;
3394
+ if (idx === -1) project.storyboards.push(next);
3395
+ else project.storyboards[idx] = next;
3255
3396
  project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
3256
3397
  saveProject(outputDir, project);
3257
3398
  }
@@ -3266,7 +3407,8 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3266
3407
  if (body === void 0) throw new Error(`章节 ${chapterNo} 的正文文件不存在`);
3267
3408
  if ((skeleton.beats ?? []).length === 0) throw new Error("骨架为空,请先生成剧情骨架");
3268
3409
  const roles = (project.roles ?? []).filter((r) => body.includes(r.name)).slice(0, 8).map((r) => `${r.name}(${r.roleLabel === "protagonist" ? "主角" : r.roleLabel === "antagonist" ? "反派" : r.roleLabel === "female_lead" ? "女主" : "配角"}):${r.identity ?? ""}`);
3269
- const scenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6).map((s) => `${s.name}:${s.summary ?? ""}`);
3410
+ const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6);
3411
+ const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
3270
3412
  const rules = (project.visualRules ?? []).map((r) => "- " + r);
3271
3413
  const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
3272
3414
  const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
@@ -3332,7 +3474,8 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
3332
3474
  if (missing.length > 0) console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(",")} 无镜头覆盖`);
3333
3475
  const table = {
3334
3476
  chapterNo,
3335
- shots
3477
+ shots,
3478
+ usedScenes: usedScenes.map((s) => s.name)
3336
3479
  };
3337
3480
  saveChapterStoryboard(project, outputDir, {
3338
3481
  chapterNo,
@@ -3364,6 +3507,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3364
3507
  "任务:把分镜表的每个镜头写成一段可直接粘贴进视频生成工具的提示词。",
3365
3508
  "提示词结构(按顺序):风格词块 → 画面主体(人物动作+表情+服装标志物)→ 机位运镜(含时长感)→ 光效氛围。单段中文 60-140 字,逗号分隔,流畅自然。",
3366
3509
  "风格词块(必须原样放在每段开头):" + stylePrefix,
3510
+ "重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
3367
3511
  "硬性要求:",
3368
3512
  "1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
3369
3513
  "2. 服装/发色/标志物必须沿用镜头表与视觉规则,禁止自行更换。",
@@ -3385,7 +3529,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
3385
3529
  const shotIds = new Set(table.shots.map((s) => s.id));
3386
3530
  const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => ({
3387
3531
  shotId: shotIds.has(entry.shotId) ? entry.shotId : "",
3388
- text: typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : ""
3532
+ text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh")
3389
3533
  })).filter((x) => x.shotId !== "" && x.text !== "") : [];
3390
3534
  if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
3391
3535
  saveChapterStoryboard(project, outputDir, {
@@ -7542,7 +7686,7 @@ function makeRoutes(deps) {
7542
7686
  if (project.scenes === void 0) project.scenes = [];
7543
7687
  const op = body?.op;
7544
7688
  if (op === "extract") try {
7545
- const candidates = await extractScenes(ctx, config, project);
7689
+ const candidates = await extractScenes(ctx, config, project, body?.styleId, body?.filterId);
7546
7690
  writeJson(res, 200, {
7547
7691
  scenes: project.scenes,
7548
7692
  candidates