@waterwx/dsh-novel-forge 1.7.0 → 1.7.2
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/client.js +2537 -1858
- package/lib/client.js.map +1 -1
- package/lib/index.js +657 -45
- package/lib/index.js.map +1 -1
- package/lib/types/client/api.d.ts +2 -0
- package/lib/types/client/locales.d.ts +0 -1
- package/lib/types/client/panel/FlowGuide.d.ts +21 -0
- package/lib/types/client/panel/MangaRoleLibrary.d.ts +18 -0
- package/lib/types/client/panel/MangaWorkspace.d.ts +2 -2
- package/lib/types/client/panel/StoryboardTab.d.ts +5 -1
- package/lib/types/engine.d.ts +15 -8
- package/lib/types/protocol.d.ts +124 -0
- package/package.json +2 -1
- package/src/client/api.ts +5 -0
- package/src/client/locales.ts +0 -1
- package/src/client/panel/AssetsTab.tsx +22 -22
- package/src/client/panel/AssistantTab.tsx +3 -3
- package/src/client/panel/BookshelfBar.tsx +1 -1
- package/src/client/panel/CreateBookView.tsx +10 -10
- package/src/client/panel/FlowGuide.tsx +83 -0
- package/src/client/panel/MangaRoleLibrary.tsx +810 -0
- package/src/client/panel/MangaWorkspace.tsx +359 -297
- package/src/client/panel/NovelPanel.tsx +107 -107
- package/src/client/panel/ReaderView.tsx +4 -4
- package/src/client/panel/RunPanel.tsx +8 -8
- package/src/client/panel/SceneLibrary.tsx +68 -67
- package/src/client/panel/StoryboardTab.tsx +105 -41
- package/src/client/panel/StyleCard.tsx +13 -13
- package/src/client/panel/StyleGallery.tsx +2 -2
- package/src/client/panel/WorldTab.tsx +5 -5
- package/src/client/panel/panel.module.css +861 -510
- package/src/client/panel/views.tsx +24 -24
- package/src/engine.ts +482 -51
- package/src/protocol.ts +125 -0
- package/src/routes.ts +203 -0
- package/src/client/panel/RoleVisualPanel.tsx +0 -485
package/lib/index.js
CHANGED
|
@@ -950,6 +950,7 @@ function mergeVolatileFromDisk(outputDir, project) {
|
|
|
950
950
|
if (disk === void 0) return;
|
|
951
951
|
project.bible = disk.bible;
|
|
952
952
|
project.roles = disk.roles;
|
|
953
|
+
project.mangaRoles = disk.mangaRoles;
|
|
953
954
|
project.plotlines = disk.plotlines;
|
|
954
955
|
project.roleStatus = disk.roleStatus;
|
|
955
956
|
project.blurb = disk.blurb;
|
|
@@ -2023,8 +2024,9 @@ async function refreshPlotlineProgress(ctx, config, project, line) {
|
|
|
2023
2024
|
async function extractRoles(ctx, config, project) {
|
|
2024
2025
|
const system = [
|
|
2025
2026
|
"你是一位网文角色库管理员。请根据本书的大纲、设定、编年录与章节摘要,提炼完整的角色库。",
|
|
2026
|
-
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\"
|
|
2027
|
-
"数量控制:最多输出 10
|
|
2027
|
+
"覆盖原则:所有在编年录/章节中实际出场或有名有姓的角色都应收录;无名的功能性人物(如\"矮胖姑娘\")用其身份简称收录并标注;反复出现且有剧情作用的身份型角色(站长、律师、警察、法官、店主等)必须收录。",
|
|
2028
|
+
"数量控制:最多输出 10 个角色;覆盖优先——主角、主要反派、重要配角(女主/关键配角)必须全收,所有有名有姓者必收;只有真正一次性路人(无名字、无剧情作用)才可省略。",
|
|
2029
|
+
"重要:正常一部完整故事应提炼 6-10 个角色;若少于 4 个通常说明漏提炼,必须重新核对正文摘录。",
|
|
2028
2030
|
"重要:正文中若有明确的主角与主要反派,必须分别以 protagonist / antagonist 收录,禁止遗漏;反派确实未出场时才可省略。",
|
|
2029
2031
|
"输出优先级:主角(protagonist)与主要反派(antagonist)必须优先输出并完整刻画,其次女主/重要配角;判断不出名字时用正文中的身份称呼。",
|
|
2030
2032
|
"每个角色输出:",
|
|
@@ -2078,9 +2080,10 @@ async function extractRoles(ctx, config, project) {
|
|
|
2078
2080
|
});
|
|
2079
2081
|
let raw = parseJsonArray(text);
|
|
2080
2082
|
const hasProtagonist = raw.some((e) => typeof e === "object" && e !== null && e.roleLabel === "protagonist");
|
|
2081
|
-
|
|
2083
|
+
const tooFew = raw.length > 0 && raw.length < 4;
|
|
2084
|
+
if (raw.length === 0 || !hasProtagonist || tooFew) {
|
|
2082
2085
|
text = await complete(ctx, config, {
|
|
2083
|
-
system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
|
|
2086
|
+
system: system + (raw.length === 0 ? "\n上一次输出为空或格式不正确。请直接输出 JSON 数组(即使只有一个角色也要输出),不要输出其他文字。" : tooFew ? "\n上一次输出角色过少(不足 4 个)。这是一部完整故事,请重新核对正文摘录:主角、主要反派、重要配角与所有有名有姓的角色都要收录(宁多勿漏),输出 6-10 个。" : "\n上一次输出中缺少主角(roleLabel 为 protagonist 的角色)。请重新输出完整 JSON 数组,务必包含正文中的主角。"),
|
|
2084
2087
|
user,
|
|
2085
2088
|
temperature: .3,
|
|
2086
2089
|
maxTokens: Math.max(config.maxTokens, 24e3),
|
|
@@ -2097,12 +2100,11 @@ async function extractRoles(ctx, config, project) {
|
|
|
2097
2100
|
"extra"
|
|
2098
2101
|
]);
|
|
2099
2102
|
const strArr = (v) => Array.isArray(v) ? v.filter((x) => typeof x === "string" && x.trim() !== "") : [];
|
|
2100
|
-
const
|
|
2101
|
-
|
|
2102
|
-
if (typeof entry !== "object" || entry === null) continue;
|
|
2103
|
+
const toRole = (entry) => {
|
|
2104
|
+
if (typeof entry !== "object" || entry === null) return void 0;
|
|
2103
2105
|
const name = typeof entry.name === "string" ? entry.name.trim().slice(0, 30) : "";
|
|
2104
|
-
if (name === "")
|
|
2105
|
-
|
|
2106
|
+
if (name === "") return void 0;
|
|
2107
|
+
return {
|
|
2106
2108
|
name,
|
|
2107
2109
|
roleLabel: labels.has(entry.roleLabel) ? entry.roleLabel : "support",
|
|
2108
2110
|
identity: typeof entry.identity === "string" ? entry.identity.slice(0, 100) : "",
|
|
@@ -2111,7 +2113,92 @@ async function extractRoles(ctx, config, project) {
|
|
|
2111
2113
|
relations: strArr(entry.relations).map((r) => r.slice(0, 60)).slice(0, 10),
|
|
2112
2114
|
arc: strArr(entry.arc).map((a) => a.slice(0, 120)).slice(0, 10),
|
|
2113
2115
|
knowledge: strArr(entry.knowledge).map((k) => k.slice(0, 120)).slice(0, 12)
|
|
2114
|
-
}
|
|
2116
|
+
};
|
|
2117
|
+
};
|
|
2118
|
+
const roles = [];
|
|
2119
|
+
for (const entry of raw) {
|
|
2120
|
+
const role = toRole(entry);
|
|
2121
|
+
if (role !== void 0) roles.push(role);
|
|
2122
|
+
}
|
|
2123
|
+
if (roles.length > 0) {
|
|
2124
|
+
const names = roles.map((r) => r.name).join("、");
|
|
2125
|
+
const patchSystem = system + "\n上一次已提炼角色:" + names + "。\n现在只输出「遗漏的角色」JSON 数组:检查正文摘录中反复出现、有固定身份称呼(如站长、律师、警察、法官、店主、老师)且对剧情有作用的角色;一次性路人不要输出。没有遗漏就输出 []。字段与上面相同。";
|
|
2126
|
+
try {
|
|
2127
|
+
const patchRaw = parseJsonArray(await complete(ctx, config, {
|
|
2128
|
+
system: patchSystem,
|
|
2129
|
+
user,
|
|
2130
|
+
temperature: .3,
|
|
2131
|
+
maxTokens: Math.max(config.maxTokens, 12e3),
|
|
2132
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2133
|
+
}));
|
|
2134
|
+
const existing = new Set(roles.map((r) => r.name));
|
|
2135
|
+
for (const entry of patchRaw) {
|
|
2136
|
+
const role = toRole(entry);
|
|
2137
|
+
if (role === void 0 || existing.has(role.name)) continue;
|
|
2138
|
+
existing.add(role.name);
|
|
2139
|
+
roles.push(role);
|
|
2140
|
+
if (roles.length >= 10) break;
|
|
2141
|
+
}
|
|
2142
|
+
} catch {}
|
|
2143
|
+
}
|
|
2144
|
+
const ROLE_TITLE_HINTS = [
|
|
2145
|
+
"站长",
|
|
2146
|
+
"律师",
|
|
2147
|
+
"检察官",
|
|
2148
|
+
"法官",
|
|
2149
|
+
"警察",
|
|
2150
|
+
"店主",
|
|
2151
|
+
"老板",
|
|
2152
|
+
"经理",
|
|
2153
|
+
"局长",
|
|
2154
|
+
"医生",
|
|
2155
|
+
"老师",
|
|
2156
|
+
"护士",
|
|
2157
|
+
"房东",
|
|
2158
|
+
"司机",
|
|
2159
|
+
"保安",
|
|
2160
|
+
"主管",
|
|
2161
|
+
"队长",
|
|
2162
|
+
"厂长",
|
|
2163
|
+
"董事长",
|
|
2164
|
+
"总裁",
|
|
2165
|
+
"市长",
|
|
2166
|
+
"道长",
|
|
2167
|
+
"掌门",
|
|
2168
|
+
"师父",
|
|
2169
|
+
"师傅",
|
|
2170
|
+
"管家"
|
|
2171
|
+
];
|
|
2172
|
+
const existingNames = new Set(roles.map((r) => r.name));
|
|
2173
|
+
const titleCount = /* @__PURE__ */ new Map();
|
|
2174
|
+
for (const chapter of written) {
|
|
2175
|
+
const body = readChapterFile(config.outputDir, chapter);
|
|
2176
|
+
if (body === void 0) continue;
|
|
2177
|
+
for (const t of ROLE_TITLE_HINTS) {
|
|
2178
|
+
let idx = 0;
|
|
2179
|
+
let n = 0;
|
|
2180
|
+
while ((idx = body.indexOf(t, idx)) !== -1) {
|
|
2181
|
+
n++;
|
|
2182
|
+
idx += t.length;
|
|
2183
|
+
}
|
|
2184
|
+
titleCount.set(t, (titleCount.get(t) ?? 0) + n);
|
|
2185
|
+
}
|
|
2186
|
+
}
|
|
2187
|
+
for (const t of ROLE_TITLE_HINTS) {
|
|
2188
|
+
if (roles.length >= 10) break;
|
|
2189
|
+
if (!(existingNames.has(t) || [...existingNames].some((n) => n.includes(t))) && (titleCount.get(t) ?? 0) >= 3) {
|
|
2190
|
+
existingNames.add(t);
|
|
2191
|
+
roles.push({
|
|
2192
|
+
name: t,
|
|
2193
|
+
roleLabel: "support",
|
|
2194
|
+
identity: "身份型角色(正文反复出现)",
|
|
2195
|
+
traits: [],
|
|
2196
|
+
goals: "",
|
|
2197
|
+
relations: [],
|
|
2198
|
+
arc: [],
|
|
2199
|
+
knowledge: []
|
|
2200
|
+
});
|
|
2201
|
+
}
|
|
2115
2202
|
}
|
|
2116
2203
|
return roles;
|
|
2117
2204
|
}
|
|
@@ -2457,13 +2544,11 @@ function withStyle(pair, styleWords) {
|
|
|
2457
2544
|
};
|
|
2458
2545
|
}
|
|
2459
2546
|
/**
|
|
2460
|
-
*
|
|
2461
|
-
*
|
|
2547
|
+
* 底层实现:为任意角色源提炼「动漫形象描述词」(不写库,写库由上层调用方负责)。
|
|
2548
|
+
* 扫描该角色出场的已写章节正文,截取含外貌描写的段落,交给 LLM 提炼中文描述 + 英文绘图标签。
|
|
2462
2549
|
*/
|
|
2463
|
-
async function
|
|
2464
|
-
const
|
|
2465
|
-
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
2466
|
-
role.promptStyleId = styleId;
|
|
2550
|
+
async function extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId, shortDrama = false) {
|
|
2551
|
+
const roleName = role.name;
|
|
2467
2552
|
const appearanceHints = /(发|眉|眼|眸|脸|肤|唇|身材|身高|衣|袍|裙|衫|靴|腰带|气质|模样|长相|容貌|披|束|扎|戴|佩|挂|绣|青|白|黑|红|蓝|紫|灰|银|金|少年|青年|少女|汉子|老者|中年|纤细|挺拔|瘦削|壮实|清秀|俊朗|英气|阴鸷|慈眉)/;
|
|
2468
2553
|
const roleText = `${role.name} ${role.identity ?? ""}`;
|
|
2469
2554
|
const colorWords = [
|
|
@@ -2577,6 +2662,8 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
|
|
|
2577
2662
|
"2. 正文未明确写到的项目(如瞳色没写、身高没写),用「未定」标注或直接不写,不要编造数值。",
|
|
2578
2663
|
"3. 服装优先取正文明确出现的(颜色+款式),多次出现取最常穿的组合;服装按分件组织(上身/下身/鞋/配饰)。",
|
|
2579
2664
|
"4. 标志物(标签/印记/饰品)必须出现在每段提示词中——它们是一致性的命根子。",
|
|
2665
|
+
"5. 立绘/四视图/细节是「角色设定稿」:禁止写瞬间动作与道具使用状态(握手机、看屏幕、未接来电、走路、回头等),禁止写剧情状态与场景背景;只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
|
|
2666
|
+
...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(如「极端偏执」「绝对冷血」,单一维度拉到最满);外貌只保留 1-2 个最强辨识度点,弱化平凡细节;服装/标志物更夸张醒目。"] : [],
|
|
2580
2667
|
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
2581
2668
|
...rules.map((r) => "- " + r),
|
|
2582
2669
|
"输出六部分:",
|
|
@@ -2598,7 +2685,7 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
|
|
|
2598
2685
|
user: [
|
|
2599
2686
|
`书名:《${project.bookName}》`,
|
|
2600
2687
|
`目标角色:${role.name}(${role.identity})`,
|
|
2601
|
-
role.traits.length > 0 ? `性格标签:${role.traits.join("、")}` : "",
|
|
2688
|
+
(role.traits ?? []).length > 0 ? `性格标签:${role.traits.join("、")}` : "",
|
|
2602
2689
|
`正文出场描写(含外貌线索的段落):`,
|
|
2603
2690
|
...excerpts.map((e) => `[第${e.no}章] ${e.text}`),
|
|
2604
2691
|
"只输出 JSON 对象。"
|
|
@@ -2658,15 +2745,35 @@ async function extractRoleVisual(ctx, config, project, outputDir, roleName, styl
|
|
|
2658
2745
|
promptKit
|
|
2659
2746
|
};
|
|
2660
2747
|
}
|
|
2661
|
-
/**
|
|
2662
|
-
|
|
2663
|
-
* LLM 产出 立绘/四视图/表情×N/细节 四套 zh+en 提示词(比前端拼装更精炼)。
|
|
2664
|
-
*/
|
|
2665
|
-
async function generateRolePromptKit(ctx, config, project, roleName, styleId, filterId) {
|
|
2748
|
+
/** 小说角色库:提炼单个角色的形象锚点并写回角色卡。 */
|
|
2749
|
+
async function extractRoleVisual(ctx, config, project, outputDir, roleName, styleId, filterId) {
|
|
2666
2750
|
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
2667
2751
|
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
2668
|
-
if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
|
|
2669
2752
|
role.promptStyleId = styleId;
|
|
2753
|
+
return extractRoleVisualFrom(ctx, config, project, outputDir, role, styleId, filterId);
|
|
2754
|
+
}
|
|
2755
|
+
/** 漫剧角色卡:提炼形象锚点并写回漫剧卡(status → anchored)。 */
|
|
2756
|
+
async function extractMangaRoleVisual(ctx, config, project, outputDir, cardId, styleId, filterId) {
|
|
2757
|
+
const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
|
|
2758
|
+
if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
|
|
2759
|
+
const visual = await extractRoleVisualFrom(ctx, config, project, outputDir, {
|
|
2760
|
+
name: card.name,
|
|
2761
|
+
identity: card.identity,
|
|
2762
|
+
traits: card.traits
|
|
2763
|
+
}, styleId, filterId, project.shortDramaMode === true);
|
|
2764
|
+
card.promptStyleId = styleId;
|
|
2765
|
+
card.imagePrompt = visual;
|
|
2766
|
+
if ((visual.expressions ?? []).length > 0) card.expressions = visual.expressions;
|
|
2767
|
+
if (visual.promptKit !== void 0) card.promptKit = visual.promptKit;
|
|
2768
|
+
if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
|
|
2769
|
+
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2770
|
+
return visual;
|
|
2771
|
+
}
|
|
2772
|
+
/**
|
|
2773
|
+
* 底层实现:基于角色源的形象锚点 + 表情清单 + 视觉规则产出四类生图提示词(不写库)。
|
|
2774
|
+
*/
|
|
2775
|
+
async function generateRolePromptKitFrom(ctx, config, project, role, styleId, filterId, shortDrama = false) {
|
|
2776
|
+
role.name;
|
|
2670
2777
|
const rules = project.visualRules ?? [];
|
|
2671
2778
|
const styleWords = styleKeywords(styleId, filterId);
|
|
2672
2779
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
@@ -2679,6 +2786,8 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
|
|
|
2679
2786
|
"2. sheet 四视图:同一角色的 正面/左侧面/右侧面/背面 四视图设定表(character sheet),纯白背景,四个视角分别描述。",
|
|
2680
2787
|
"3. expressions 表情:每个表情一段,脸部特写(头部到锁骨),纯白背景,五官与角色定稿完全一致,只换情绪表达(眼神/嘴角/眉),皮肤纹理细节完整,无多余杂物。",
|
|
2681
2788
|
"4. details 细节:多组局部细节集合参考图(一张图多个局部框),纯白背景;把该角色全部标志物逐项列出,每项一句特写描述;细节清晰锐利,角色细节参考稿,无多余杂物。",
|
|
2789
|
+
"5. 立绘/四视图/细节为「角色设定稿」:禁止瞬间动作、道具使用状态与剧情状态(握手机、看屏幕、未接来电等),只保留可长期存在的外貌、服装与常驻标志物(工牌、饰品等)。",
|
|
2790
|
+
...shortDrama ? ["6. 短剧精简模式·人设极致化:性格标签必须极致化(单一维度拉到最满);外貌只保留 1-2 个最强辨识度点;服装/标志物更夸张醒目,一眼可认。"] : [],
|
|
2682
2791
|
"【本书视觉规则】(必须内嵌进每段提示词,保证设定不跑偏):",
|
|
2683
2792
|
...rules.map((r) => "- " + r),
|
|
2684
2793
|
"zh 要求:连贯中文,写实电影感,60-150 字/段;en 要求:booru 风格逗号分隔标签,30-50 个/段。",
|
|
@@ -2721,6 +2830,198 @@ async function generateRolePromptKit(ctx, config, project, roleName, styleId, fi
|
|
|
2721
2830
|
if (kit.portrait.zh === "" || kit.portrait.en === "") throw new Error("提示词精修失败:LLM 未返回有效 JSON");
|
|
2722
2831
|
return kit;
|
|
2723
2832
|
}
|
|
2833
|
+
/** 小说角色库:角色四类生图提示词精修包(写回角色卡)。 */
|
|
2834
|
+
async function generateRolePromptKit(ctx, config, project, roleName, styleId, filterId) {
|
|
2835
|
+
const role = (project.roles ?? []).find((r) => r.name === roleName);
|
|
2836
|
+
if (role === void 0) throw new Error(`角色「${roleName}」不在角色库中`);
|
|
2837
|
+
if (role.imagePrompt === void 0) throw new Error(`角色「${roleName}」还没有形象锚点,请先生成锚点`);
|
|
2838
|
+
role.promptStyleId = styleId;
|
|
2839
|
+
return generateRolePromptKitFrom(ctx, config, project, {
|
|
2840
|
+
name: role.name,
|
|
2841
|
+
identity: role.identity,
|
|
2842
|
+
imagePrompt: role.imagePrompt,
|
|
2843
|
+
expressions: role.expressions
|
|
2844
|
+
}, styleId, filterId);
|
|
2845
|
+
}
|
|
2846
|
+
/** 漫剧角色卡:四类生图提示词精修包(写回漫剧卡,status → anchored)。 */
|
|
2847
|
+
async function generateMangaRolePromptKit(ctx, config, project, cardId, styleId, filterId) {
|
|
2848
|
+
const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
|
|
2849
|
+
if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
|
|
2850
|
+
if (card.imagePrompt === void 0) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`);
|
|
2851
|
+
const kit = await generateRolePromptKitFrom(ctx, config, project, {
|
|
2852
|
+
name: card.name,
|
|
2853
|
+
identity: card.identity,
|
|
2854
|
+
imagePrompt: card.imagePrompt,
|
|
2855
|
+
expressions: card.expressions
|
|
2856
|
+
}, styleId, filterId, project.shortDramaMode === true);
|
|
2857
|
+
card.promptStyleId = styleId;
|
|
2858
|
+
card.promptKit = kit;
|
|
2859
|
+
if (card.status === "imported" || card.status === "pending_confirm") card.status = "anchored";
|
|
2860
|
+
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
2861
|
+
return kit;
|
|
2862
|
+
}
|
|
2863
|
+
/**
|
|
2864
|
+
* 漫剧角色库·提名(两段式):从某章分镜的 characters 提名候选角色名 →
|
|
2865
|
+
* 规则过滤(精确名 + 身份/简称匹配,短名单 ≤5)→ LLM 确认(是/否 + 选哪个,不做开放检索)→
|
|
2866
|
+
* 返回带漫剧卡建议的候选(未匹配时给出「回小说库补提炼 / 漫剧直接创建」判定)。
|
|
2867
|
+
*/
|
|
2868
|
+
async function nominateMangaRoles(ctx, config, project, outputDir, chapterNo) {
|
|
2869
|
+
const entry = (project.storyboards ?? []).find((e) => e.chapterNo === chapterNo);
|
|
2870
|
+
if (entry === void 0) throw new Error(`第 ${chapterNo} 章还没有分镜产出,请先在「分镜」页生成剧情骨架/分镜表`);
|
|
2871
|
+
const names = [];
|
|
2872
|
+
const pushNames = (list) => {
|
|
2873
|
+
for (const n of list ?? []) {
|
|
2874
|
+
const t = n.trim().slice(0, 20);
|
|
2875
|
+
if (t !== "" && !names.includes(t)) names.push(t);
|
|
2876
|
+
}
|
|
2877
|
+
};
|
|
2878
|
+
pushNames(entry.skeleton?.characters);
|
|
2879
|
+
pushNames(entry.table?.characters);
|
|
2880
|
+
for (const s of entry.table?.shots ?? []) pushNames(s.characters);
|
|
2881
|
+
if (names.length === 0) throw new Error(`第 ${chapterNo} 章的分镜还没有结构化角色(characters 为空),请重新生成剧情骨架/分镜表`);
|
|
2882
|
+
const imported = /* @__PURE__ */ new Set();
|
|
2883
|
+
for (const c of project.mangaRoles ?? []) {
|
|
2884
|
+
imported.add(c.name);
|
|
2885
|
+
if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "") imported.add(c.sourceRoleName);
|
|
2886
|
+
}
|
|
2887
|
+
const already = names.filter((n) => imported.has(n)).map((n) => ({
|
|
2888
|
+
rawName: n,
|
|
2889
|
+
verdict: "already_imported",
|
|
2890
|
+
matches: [],
|
|
2891
|
+
suggested: emptyCandidateSuggestion(n)
|
|
2892
|
+
}));
|
|
2893
|
+
const fresh = names.filter((n) => !imported.has(n));
|
|
2894
|
+
if (fresh.length === 0) return already;
|
|
2895
|
+
const chapter = project.chapters.find((c) => c.no === chapterNo);
|
|
2896
|
+
const body = chapter !== void 0 ? readChapterFile(outputDir, chapter) : void 0;
|
|
2897
|
+
const roles = project.roles ?? [];
|
|
2898
|
+
const shortlists = fresh.map((rawName) => {
|
|
2899
|
+
const shortlist = [];
|
|
2900
|
+
const push = (n) => {
|
|
2901
|
+
if (!shortlist.includes(n)) shortlist.push(n);
|
|
2902
|
+
};
|
|
2903
|
+
for (const r of roles) {
|
|
2904
|
+
if (shortlist.length >= 5) break;
|
|
2905
|
+
const n = r.name;
|
|
2906
|
+
if (n === rawName) {
|
|
2907
|
+
push(n);
|
|
2908
|
+
continue;
|
|
2909
|
+
}
|
|
2910
|
+
if (n.includes(rawName) || rawName.includes(n)) {
|
|
2911
|
+
push(n);
|
|
2912
|
+
continue;
|
|
2913
|
+
}
|
|
2914
|
+
const ident = r.identity ?? "";
|
|
2915
|
+
if (ident !== "" && (ident === rawName || ident.includes(rawName) || rawName.includes(ident))) push(n);
|
|
2916
|
+
}
|
|
2917
|
+
return {
|
|
2918
|
+
rawName,
|
|
2919
|
+
roleNames: shortlist
|
|
2920
|
+
};
|
|
2921
|
+
});
|
|
2922
|
+
const beats = (entry.skeleton?.beats ?? []).map((b) => `[${b.id}] ${b.event}`).join("\n");
|
|
2923
|
+
const raw = parseJsonArray(await complete(ctx, config, {
|
|
2924
|
+
system: [
|
|
2925
|
+
"你是一位漫剧选角导演。下面给出「分镜角色提名」与「小说角色库候选名单」。",
|
|
2926
|
+
"任务:为每个提名判定它对应小说角色库中的哪一个候选(或判定小说库没有对应角色),并预填一张「漫剧角色卡」的建议信息。",
|
|
2927
|
+
"规则:",
|
|
2928
|
+
"1. roleName 只能从该提名的候选名单中选,禁止输出名单之外的名字(不做开放检索)。",
|
|
2929
|
+
"2. verdict:matched=候选名单里确实有对应角色(选最像的那个);ambiguous=名单里有多个候选且无法确定(此时 roleName 可留空或选最可能的一个);not_in_library=候选名单为空或都不对应。",
|
|
2930
|
+
"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」)。",
|
|
2931
|
+
"4. 身份型提名(如「持枪者」「围观群众」):候选名单有则匹配;名单没有且正文确有该称谓 → not_in_library;正文也没有 → not_in_library。",
|
|
2932
|
+
...project.shortDramaMode === true ? ["5. 短剧精简模式(本书已开启):只保留 5-8 个上镜角色——主角/反派/感情线/关键配角;功能性路人不上卡(此类提名直接 not_in_library)。每个角色必须给出明确的 coreFunction 与 protagonistRelation;性格标签极致化;候选超过 8 个时按戏份重要性裁剪到最核心 8 个。"] : [],
|
|
2933
|
+
"输出必须是合法 JSON 数组:[{\"rawName\": \"...\", \"verdict\": \"...\", \"roleName\": \"...或省略\", \"suggested\": {...}}],每个提名都要有,数组长度必须等于提名数。",
|
|
2934
|
+
"重要:所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。"
|
|
2935
|
+
].join("\n"),
|
|
2936
|
+
user: [
|
|
2937
|
+
`章节:第 ${chapterNo} 章《${chapter?.title ?? ""}》`,
|
|
2938
|
+
"==== 剧情骨架(节拍) ====",
|
|
2939
|
+
beats !== "" ? beats : "(无骨架节拍)",
|
|
2940
|
+
"==== 角色提名与候选名单 ====",
|
|
2941
|
+
shortlists.map((s) => s.roleNames.length > 0 ? `[提名] ${s.rawName} → 候选:${s.roleNames.join("、")}` : `[提名] ${s.rawName} → 候选:(无)`).join("\n"),
|
|
2942
|
+
body !== void 0 ? "==== 章节正文(前 2500 字,判断称谓归属用) ====\n" + body.replace(/^#.*$/gm, "").trim().slice(0, 2500) : "",
|
|
2943
|
+
"只输出 JSON 数组。"
|
|
2944
|
+
].filter((x) => x !== "").join("\n\n"),
|
|
2945
|
+
temperature: .2,
|
|
2946
|
+
maxTokens: Math.max(config.maxTokens, 8e3),
|
|
2947
|
+
reasoning: config.analysisReasoning ?? "low"
|
|
2948
|
+
}));
|
|
2949
|
+
const verdicts = /* @__PURE__ */ new Set([
|
|
2950
|
+
"matched",
|
|
2951
|
+
"ambiguous",
|
|
2952
|
+
"not_in_library"
|
|
2953
|
+
]);
|
|
2954
|
+
const fnMap = {
|
|
2955
|
+
protagonist: "protagonist",
|
|
2956
|
+
mentor: "mentor",
|
|
2957
|
+
love_interest: "love_interest",
|
|
2958
|
+
antagonist: "antagonist",
|
|
2959
|
+
sidekick: "sidekick",
|
|
2960
|
+
informant: "informant",
|
|
2961
|
+
functional: "functional"
|
|
2962
|
+
};
|
|
2963
|
+
const relMap = {
|
|
2964
|
+
enemy: "enemy",
|
|
2965
|
+
friend: "friend",
|
|
2966
|
+
mentor: "mentor",
|
|
2967
|
+
lover: "lover",
|
|
2968
|
+
exploit: "exploit",
|
|
2969
|
+
neutral: "neutral"
|
|
2970
|
+
};
|
|
2971
|
+
const byName = /* @__PURE__ */ new Map();
|
|
2972
|
+
for (const e of raw) if (typeof e === "object" && e !== null && typeof e.rawName === "string") byName.set(e.rawName.trim(), e);
|
|
2973
|
+
const str = (v) => typeof v === "string" ? v.trim().slice(0, 100) : "";
|
|
2974
|
+
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) : [];
|
|
2975
|
+
const out = [];
|
|
2976
|
+
for (const item of shortlists) {
|
|
2977
|
+
const judge = byName.get(item.rawName);
|
|
2978
|
+
const judgeVerdict = judge !== void 0 ? str(judge.verdict) : "";
|
|
2979
|
+
const verdict = verdicts.has(judgeVerdict) ? judgeVerdict : item.roleNames.length > 0 ? "ambiguous" : "not_in_library";
|
|
2980
|
+
let roleName;
|
|
2981
|
+
if (judge !== void 0) {
|
|
2982
|
+
const chosen = str(judge.roleName);
|
|
2983
|
+
if (chosen !== "" && item.roleNames.includes(chosen)) roleName = chosen;
|
|
2984
|
+
}
|
|
2985
|
+
if (verdict === "matched" && roleName === void 0 && item.roleNames.length === 1) roleName = item.roleNames[0];
|
|
2986
|
+
const sug = typeof judge?.suggested === "object" && judge?.suggested !== null ? judge.suggested : {};
|
|
2987
|
+
const novelHint = verdict === "not_in_library" && body !== void 0 ? body.includes(item.rawName) ? "backfill" : "manga_new" : void 0;
|
|
2988
|
+
const suggested = {
|
|
2989
|
+
name: (str(sug.name) !== "" ? str(sug.name) : roleName ?? item.rawName).slice(0, 30),
|
|
2990
|
+
identity: str(sug.identity).slice(0, 60),
|
|
2991
|
+
coreFunction: fnMap[str(sug.coreFunction)] ?? "functional",
|
|
2992
|
+
protagonistRelation: relMap[str(sug.protagonistRelation)] ?? "neutral",
|
|
2993
|
+
speechStyle: str(sug.speechStyle).slice(0, 60),
|
|
2994
|
+
traits: strArr(sug.traits, 3),
|
|
2995
|
+
appearance: str(sug.appearance).slice(0, 100),
|
|
2996
|
+
keyScenes: strArr(sug.keyScenes, 3)
|
|
2997
|
+
};
|
|
2998
|
+
out.push({
|
|
2999
|
+
rawName: item.rawName,
|
|
3000
|
+
verdict,
|
|
3001
|
+
matches: item.roleNames.map((n) => ({
|
|
3002
|
+
roleName: n,
|
|
3003
|
+
reason: roleName === n ? "LLM 确认" : "规则候选"
|
|
3004
|
+
})),
|
|
3005
|
+
matchedRoleName: roleName,
|
|
3006
|
+
novelHint,
|
|
3007
|
+
suggested
|
|
3008
|
+
});
|
|
3009
|
+
}
|
|
3010
|
+
return [...already, ...out];
|
|
3011
|
+
}
|
|
3012
|
+
/** 空建议(already_imported 等无需 LLM 的候选用)。 */
|
|
3013
|
+
function emptyCandidateSuggestion(name) {
|
|
3014
|
+
return {
|
|
3015
|
+
name,
|
|
3016
|
+
identity: "",
|
|
3017
|
+
coreFunction: "functional",
|
|
3018
|
+
protagonistRelation: "neutral",
|
|
3019
|
+
speechStyle: "",
|
|
3020
|
+
traits: [],
|
|
3021
|
+
appearance: "",
|
|
3022
|
+
keyScenes: []
|
|
3023
|
+
};
|
|
3024
|
+
}
|
|
2724
3025
|
/** ✨ 从道藏/红线提炼「视觉世界观规则」:生图/生视频必须遵守的设定纠偏(如"商品=人,禁止常规超市商品")。 */
|
|
2725
3026
|
async function extractVisualRules(ctx, config, project) {
|
|
2726
3027
|
const system = [
|
|
@@ -2997,10 +3298,8 @@ async function testImageEndpoint(baseURL, apiKey, model) {
|
|
|
2997
3298
|
};
|
|
2998
3299
|
}
|
|
2999
3300
|
}
|
|
3000
|
-
|
|
3001
|
-
|
|
3002
|
-
if (role === void 0) throw new Error(`角色 ${name} 不存在`);
|
|
3003
|
-
if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
|
|
3301
|
+
/** 底层:按形象锚点调用生图接口生成定妆图(不写库;写库由上层调用方负责)。 */
|
|
3302
|
+
async function generateReferenceImageFrom(config, anchor, style = "", modelId) {
|
|
3004
3303
|
if (config.imageApiEnabled !== true) throw new Error("生图未启用,请在设置中启用一个生图模型");
|
|
3005
3304
|
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
3305
|
const apiKey = activeImage?.apiKey ?? config.imageApiKey;
|
|
@@ -3010,9 +3309,9 @@ async function generateRoleReferenceImage(ctx, config, project, outputDir, name,
|
|
|
3010
3309
|
if (apiKey === void 0 || apiKey === "" || model === void 0 || model === "") throw new Error("请先在设置中配置生图模型的 API Key 和模型 id");
|
|
3011
3310
|
const stylePrompt = getComicStylePrompt(style);
|
|
3012
3311
|
const prompt = [
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3312
|
+
anchor.zh,
|
|
3313
|
+
anchor.en,
|
|
3314
|
+
anchor.tags.join(", "),
|
|
3016
3315
|
stylePrompt,
|
|
3017
3316
|
"character reference sheet, front view, clean background, consistent design"
|
|
3018
3317
|
].filter(Boolean).join(", ");
|
|
@@ -3040,10 +3339,27 @@ async function generateRoleReferenceImage(ctx, config, project, outputDir, name,
|
|
|
3040
3339
|
const imgRes = await fetch(url);
|
|
3041
3340
|
if (!imgRes.ok) throw new Error("下载生图结果失败");
|
|
3042
3341
|
const buf = Buffer.from(await imgRes.arrayBuffer());
|
|
3043
|
-
|
|
3342
|
+
return `data:${imgRes.headers.get("content-type") || "image/jpeg"};base64,${buf.toString("base64")}`;
|
|
3343
|
+
}
|
|
3344
|
+
/** 小说角色库:生成角色定妆图并写回角色卡(imageUrl)。 */
|
|
3345
|
+
async function generateRoleReferenceImage(ctx, config, project, outputDir, name, style = "", modelId) {
|
|
3346
|
+
const role = project.roles?.find((r) => r.name === name);
|
|
3347
|
+
if (role === void 0) throw new Error(`角色 ${name} 不存在`);
|
|
3348
|
+
if (role.imagePrompt === void 0) throw new Error(`角色 ${name} 还没有形象锚点,请先生成锚点`);
|
|
3349
|
+
const dataUrl = await generateReferenceImageFrom(config, role.imagePrompt, style, modelId);
|
|
3044
3350
|
role.imageUrl = dataUrl;
|
|
3045
3351
|
return dataUrl;
|
|
3046
3352
|
}
|
|
3353
|
+
/** 漫剧角色卡:生成定妆图并写回漫剧卡(imageUrl)。 */
|
|
3354
|
+
async function generateMangaRoleReferenceImage(ctx, config, project, outputDir, cardId, style = "", modelId) {
|
|
3355
|
+
const card = (project.mangaRoles ?? []).find((c) => c.id === cardId);
|
|
3356
|
+
if (card === void 0) throw new Error(`漫剧角色卡 ${cardId} 不存在`);
|
|
3357
|
+
if (card.imagePrompt === void 0) throw new Error(`「${card.name}」还没有形象锚点,请先生成锚点`);
|
|
3358
|
+
const dataUrl = await generateReferenceImageFrom(config, card.imagePrompt, style, modelId);
|
|
3359
|
+
card.imageUrl = dataUrl;
|
|
3360
|
+
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3361
|
+
return dataUrl;
|
|
3362
|
+
}
|
|
3047
3363
|
/** 🩺 剧情健康检查:基于已写章节数/各线状态/编年录,判断是否需要新线及添加时机。 */
|
|
3048
3364
|
async function analyzePlotlineHealth(ctx, config, project) {
|
|
3049
3365
|
const system = [
|
|
@@ -3448,6 +3764,47 @@ function saveChapterStoryboard(project, outputDir, entry) {
|
|
|
3448
3764
|
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
3449
3765
|
saveProject(outputDir, project);
|
|
3450
3766
|
}
|
|
3767
|
+
/** 确定性兜底:正文中出现的角色库角色名(LLM 漏填 characters 时使用)。 */
|
|
3768
|
+
function guessCharactersFromRoles(project, body, limit = 12) {
|
|
3769
|
+
const out = [];
|
|
3770
|
+
for (const r of project.roles ?? []) {
|
|
3771
|
+
if (out.length >= limit) break;
|
|
3772
|
+
if (r.name !== "" && !out.includes(r.name) && body.includes(r.name)) out.push(r.name);
|
|
3773
|
+
}
|
|
3774
|
+
return out;
|
|
3775
|
+
}
|
|
3776
|
+
/** 清洗 LLM 输出的角色名数组:去空 / 去重 / 限长 / 限字数。 */
|
|
3777
|
+
function sanitizeCharacters(raw, limit) {
|
|
3778
|
+
if (!Array.isArray(raw)) return [];
|
|
3779
|
+
const out = [];
|
|
3780
|
+
for (const v of raw) {
|
|
3781
|
+
if (typeof v !== "string") continue;
|
|
3782
|
+
const name = v.trim().slice(0, 20);
|
|
3783
|
+
if (name === "" || out.includes(name)) continue;
|
|
3784
|
+
out.push(name);
|
|
3785
|
+
if (out.length >= limit) break;
|
|
3786
|
+
}
|
|
3787
|
+
return out;
|
|
3788
|
+
}
|
|
3789
|
+
function buildMangaRoleBindings(project) {
|
|
3790
|
+
const cards = project.mangaRoles ?? [];
|
|
3791
|
+
const byName = /* @__PURE__ */ new Map();
|
|
3792
|
+
for (const c of cards) {
|
|
3793
|
+
if (c.name !== "") byName.set(c.name, c);
|
|
3794
|
+
if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "") byName.set(c.sourceRoleName, c);
|
|
3795
|
+
}
|
|
3796
|
+
return {
|
|
3797
|
+
byId: new Map(cards.map((c) => [c.id, c])),
|
|
3798
|
+
resolve: (name) => {
|
|
3799
|
+
const exact = byName.get(name);
|
|
3800
|
+
if (exact !== void 0) return exact;
|
|
3801
|
+
for (const c of cards) {
|
|
3802
|
+
if (c.name !== "" && (c.name.includes(name) || name.includes(c.name))) return c;
|
|
3803
|
+
if (c.sourceRoleName !== void 0 && c.sourceRoleName !== "" && (c.sourceRoleName.includes(name) || name.includes(c.sourceRoleName))) return c;
|
|
3804
|
+
}
|
|
3805
|
+
}
|
|
3806
|
+
};
|
|
3807
|
+
}
|
|
3451
3808
|
/**
|
|
3452
3809
|
* 分镜·导演级:剧情骨架 → 分镜表(镜头级)。
|
|
3453
3810
|
* 只做画面层:景别/机位运镜/时长/画面/台词/音效/光效 + 状态连续;禁止改剧情(骨架只读)。
|
|
@@ -3461,6 +3818,12 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
3461
3818
|
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
3819
|
const usedScenes = (project.scenes ?? []).filter((s) => body.includes(s.name)).slice(0, 6);
|
|
3463
3820
|
const scenes = usedScenes.map((s) => `${s.name}:${s.summary ?? ""}`);
|
|
3821
|
+
const bindings = buildMangaRoleBindings(project);
|
|
3822
|
+
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) => {
|
|
3823
|
+
const anchor = c.imagePrompt !== void 0 ? c.imagePrompt.zh.slice(0, 90) : c.appearance !== "" ? c.appearance : "";
|
|
3824
|
+
const ref = c.imageUrl !== void 0 ? ";参考图:有" : (c.gallery ?? []).some((g) => g.label.includes("立绘")) ? ";参考图:有(立绘)" : "";
|
|
3825
|
+
return `${c.name}(${c.identity ?? ""}):定妆${anchor !== "" ? "「" + anchor + "」" : "(锚点未生成)"}${ref}`;
|
|
3826
|
+
});
|
|
3464
3827
|
const rules = (project.visualRules ?? []).map((r) => "- " + r);
|
|
3465
3828
|
const baseStyle = styleId !== void 0 ? findStyle(styleId) : void 0;
|
|
3466
3829
|
const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
|
|
@@ -3472,7 +3835,7 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
3472
3835
|
system: [
|
|
3473
3836
|
"你是一位从业 10 年的电影导演兼分镜师,专长网文改编影视化。",
|
|
3474
3837
|
"任务:把「剧情骨架」的每一个节拍展开为 1-3 个电影镜头,输出分镜表——只做画面层,禁止新增或改变剧情(骨架是只读输入)。",
|
|
3475
|
-
"输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\"}]}",
|
|
3838
|
+
"输出合法 JSON 对象:{\"shots\": [{\"beatId\": \"骨架节拍id\", \"shot\": \"景别\", \"camera\": \"机位与运镜\", \"duration\": 秒数, \"visual\": \"画面内容\", \"line\": \"台词/旁白\", \"sound\": \"音效\", \"light\": \"光效\", \"prevState\": \"承接上一镜头结尾状态\", \"nextState\": \"本镜头结束状态\", \"characters\": [\"出镜角色名\"]}]}",
|
|
3476
3839
|
"景别取值:远景/全景/中景/近景/特写。",
|
|
3477
3840
|
"硬性要求:",
|
|
3478
3841
|
"【视觉风格】(必须内嵌进 visual/light 的画面措辞与光效描述):",
|
|
@@ -3482,7 +3845,9 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
3482
3845
|
"3. visual 必须写明:角色动作 + 表情 + 服装/标志物(标志物来自下方视觉规则与角色锚点,逐镜头保持)。",
|
|
3483
3846
|
"4. 台词/音效/光效无则空字符串,不要编造。",
|
|
3484
3847
|
"5. duration 1-12 秒。",
|
|
3485
|
-
"6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。"
|
|
3848
|
+
"6. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束,不要输出其他文字。",
|
|
3849
|
+
"7. 每个镜头必须输出 characters:本镜头出镜角色名数组(1-4 个),从「出场角色锚点」或正文中选取,使用正文里的确切称谓(如「周野」「周野的律师」),禁止自造名/缩写;路人或群像可用身份词(如「围观群众」)。",
|
|
3850
|
+
"8. 人物外观/服装/标志物必须遵守「漫剧定妆卡」与「出场角色锚点」:同一角色逐镜头保持完全一致(服装、发色、标志物禁止更换);定妆卡未覆盖的路人可用通用描述。"
|
|
3486
3851
|
].join("\n"),
|
|
3487
3852
|
user: [
|
|
3488
3853
|
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
@@ -3490,6 +3855,7 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
3490
3855
|
`弧线:${skeleton.arc}`,
|
|
3491
3856
|
skeleton.beats.map((b) => `[${b.id}] [${b.function}] ${b.event}(情绪:${b.emotion}${b.cause !== void 0 ? ";承接:" + b.cause : ""})`).join("\n"),
|
|
3492
3857
|
roles.length > 0 ? "==== 出场角色锚点 ====\n" + roles.join("\n") : "",
|
|
3858
|
+
mangaLines.length > 0 ? "==== 漫剧定妆卡(角色外观以此为准,禁止改换服装/发色/标志物) ====\n" + mangaLines.join("\n") : "",
|
|
3493
3859
|
scenes.length > 0 ? "==== 相关场景 ====\n" + scenes.join("\n") : "",
|
|
3494
3860
|
rules.length > 0 ? "==== 本书视觉规则(必须内嵌进 visual) ====\n" + rules.join("\n") : "",
|
|
3495
3861
|
"==== 章节正文(画面细节以此为准) ====",
|
|
@@ -3518,16 +3884,35 @@ async function generateStoryboardTable(ctx, config, project, outputDir, chapterN
|
|
|
3518
3884
|
sound: typeof entry.sound === "string" ? entry.sound.trim().slice(0, 80) : "",
|
|
3519
3885
|
light: typeof entry.light === "string" ? entry.light.trim().slice(0, 80) : "",
|
|
3520
3886
|
prevState: typeof entry.prevState === "string" ? entry.prevState.trim().slice(0, 150) : "",
|
|
3521
|
-
nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : ""
|
|
3887
|
+
nextState: typeof entry.nextState === "string" ? entry.nextState.trim().slice(0, 150) : "",
|
|
3888
|
+
characters: sanitizeCharacters(entry.characters, 4)
|
|
3522
3889
|
})).filter((s) => s.visual !== "") : [];
|
|
3523
3890
|
if (shots.length === 0) throw new Error("模型未输出有效镜头,请重试");
|
|
3524
3891
|
const covered = new Set(shots.map((s) => s.beatId));
|
|
3525
3892
|
const missing = skeleton.beats.filter((b) => !covered.has(b.id)).map((b) => b.id);
|
|
3526
3893
|
if (missing.length > 0) console.warn(`[dsh-novel-forge] storyboard: beat ${missing.join(",")} 无镜头覆盖`);
|
|
3894
|
+
const tableChars = [];
|
|
3895
|
+
const tableRoleIds = [];
|
|
3896
|
+
for (const s of shots) {
|
|
3897
|
+
const ids = [];
|
|
3898
|
+
for (const n of s.characters ?? []) {
|
|
3899
|
+
const card = bindings.resolve(n);
|
|
3900
|
+
if (card !== void 0 && !ids.includes(card.id)) ids.push(card.id);
|
|
3901
|
+
}
|
|
3902
|
+
s.mangaRoleIds = ids.length > 0 ? ids : void 0;
|
|
3903
|
+
for (const c of s.characters ?? []) {
|
|
3904
|
+
if (!tableChars.includes(c)) tableChars.push(c);
|
|
3905
|
+
if (tableChars.length >= 12) break;
|
|
3906
|
+
}
|
|
3907
|
+
for (const id of ids) if (!tableRoleIds.includes(id)) tableRoleIds.push(id);
|
|
3908
|
+
}
|
|
3909
|
+
const fallbackChars = (skeleton.characters ?? guessCharactersFromRoles(project, body, 12)).slice(0, 12);
|
|
3527
3910
|
const table = {
|
|
3528
3911
|
chapterNo,
|
|
3529
3912
|
shots,
|
|
3530
|
-
usedScenes: usedScenes.map((s) => s.name)
|
|
3913
|
+
usedScenes: usedScenes.map((s) => s.name),
|
|
3914
|
+
characters: tableChars.length > 0 ? tableChars : fallbackChars,
|
|
3915
|
+
mangaRoleIds: tableRoleIds.length > 0 ? tableRoleIds : void 0
|
|
3531
3916
|
};
|
|
3532
3917
|
saveChapterStoryboard(project, outputDir, {
|
|
3533
3918
|
chapterNo,
|
|
@@ -3548,11 +3933,21 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
|
|
|
3548
3933
|
const filterStyle = filterId !== void 0 ? findStyle(filterId) : void 0;
|
|
3549
3934
|
const stylePrefix = [baseStyle?.keywords, filterStyle?.keywords].filter((v) => v !== void 0 && v !== "").join(",") || "3D动漫,超精细建模,电影光影";
|
|
3550
3935
|
const rules = (project.visualRules ?? []).map((r) => "- " + r);
|
|
3551
|
-
const
|
|
3936
|
+
const bindings = buildMangaRoleBindings(project);
|
|
3937
|
+
const shotLines = table.shots.map((s) => {
|
|
3938
|
+
const makeUp = (s.characters ?? []).map((n) => bindings.resolve(n)).filter((c) => c !== void 0).map((c) => {
|
|
3939
|
+
const anchor = c.imagePrompt !== void 0 ? c.imagePrompt.zh.slice(0, 60) : c.appearance !== "" ? c.appearance : "";
|
|
3940
|
+
const ref = c.imageUrl !== void 0 ? "(参考图)" : "";
|
|
3941
|
+
return c.name + ":" + anchor + ref;
|
|
3942
|
+
}).join(";");
|
|
3943
|
+
return `[${s.id}] 节拍${s.beatId} · ${s.shot} · ${s.camera} · ${s.duration}s
|
|
3944
|
+
出场:${s.characters !== void 0 && s.characters.length > 0 ? s.characters.join("、") : "(未标注)"}
|
|
3945
|
+
定妆:${makeUp !== "" ? makeUp : "(未绑定漫剧卡)"}
|
|
3552
3946
|
画面:${s.visual}
|
|
3553
3947
|
台词:${s.line !== "" ? s.line : "(无)"}
|
|
3554
3948
|
光效:${s.light !== "" ? s.light : "(无)"}
|
|
3555
|
-
承接:${s.prevState} → ${s.nextState}
|
|
3949
|
+
承接:${s.prevState} → ${s.nextState}`;
|
|
3950
|
+
}).join("\n\n");
|
|
3556
3951
|
const raw = parseJsonObject(await complete(ctx, config, {
|
|
3557
3952
|
system: [
|
|
3558
3953
|
"你是一位资深影视分镜提示词工程师,熟悉即梦 AI / Seedance 等视频生成模型的中文提示词写法。",
|
|
@@ -3562,7 +3957,7 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
|
|
|
3562
3957
|
"重要:每段提示词开头必须原样嵌入风格词块原文,不得省略、不得改写。",
|
|
3563
3958
|
"硬性要求:",
|
|
3564
3959
|
"1. 只写画面与镜头,禁止写音频/台词配音说明(视频模型无音频)。",
|
|
3565
|
-
"2.
|
|
3960
|
+
"2. 服装/发色/标志物必须沿用镜头表、定妆卡与视觉规则,禁止自行更换(同一角色跨镜头保持一致)。",
|
|
3566
3961
|
"3. 运镜用通俗电影词(推近/拉远/跟随/低机位仰拍/过肩/手持晃动/固定机位),不要用分镜术语缩写。",
|
|
3567
3962
|
"4. 输出合法 JSON 对象:{\"prompts\": [{\"shotId\": \"s1\", \"text\": \"...\"}]},所有镜头都要有,顺序一致。",
|
|
3568
3963
|
"5. 字符串内不得包含换行符,JSON 必须在一段内完整结束。"
|
|
@@ -3579,10 +3974,16 @@ async function generateStoryboardPrompts(ctx, config, project, outputDir, chapte
|
|
|
3579
3974
|
reasoning: config.analysisReasoning ?? "low"
|
|
3580
3975
|
}));
|
|
3581
3976
|
const shotIds = new Set(table.shots.map((s) => s.id));
|
|
3582
|
-
const
|
|
3583
|
-
|
|
3584
|
-
|
|
3585
|
-
|
|
3977
|
+
const shotBinding = new Map(table.shots.map((s) => [s.id, s.mangaRoleIds ?? []]));
|
|
3978
|
+
const prompts = Array.isArray(raw.prompts) ? raw.prompts.filter((v) => typeof v === "object" && v !== null).map((entry) => {
|
|
3979
|
+
const shotId = shotIds.has(entry.shotId) ? entry.shotId : "";
|
|
3980
|
+
const ids = shotBinding.get(shotId) ?? [];
|
|
3981
|
+
return {
|
|
3982
|
+
shotId,
|
|
3983
|
+
text: ensureStyleEmbedded(typeof entry.text === "string" ? entry.text.trim().slice(0, 400) : "", stylePrefix, "zh"),
|
|
3984
|
+
mangaRoleIds: ids.length > 0 ? ids : void 0
|
|
3985
|
+
};
|
|
3986
|
+
}).filter((x) => x.shotId !== "" && x.text !== "") : [];
|
|
3586
3987
|
if (prompts.length === 0) throw new Error("模型未输出有效提示词,请重试");
|
|
3587
3988
|
saveChapterStoryboard(project, outputDir, {
|
|
3588
3989
|
chapterNo,
|
|
@@ -3604,13 +4005,14 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
|
|
|
3604
4005
|
system: [
|
|
3605
4006
|
"你是一位从业 15 年的电影编剧,专长网文改编影视化,深谙三幕结构与节拍(beat)写作。",
|
|
3606
4007
|
"任务:把这一章改编成影视化「剧情骨架」——只做故事层,不做画面。",
|
|
3607
|
-
"输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}]}",
|
|
4008
|
+
"输出合法 JSON 对象:{\"arc\": \"本章弧线一句话(起承转合,20-60字)\", \"beats\": [{\"event\": \"事件一句话(发生了什么)\", \"emotion\": \"人物情绪走向(如 压抑→隐忍→惊惧)\", \"function\": \"铺垫|冲突|转折|高潮|收束|伏笔|人物塑造\", \"cause\": \"承接上一节拍的原因(可省略)\"}], \"characters\": [\"出镜角色名1\", \"出镜角色名2\"]}",
|
|
3608
4009
|
"硬性要求:",
|
|
3609
4010
|
"1. beats 数量 4-9 个,严格按时间顺序,因果链完整:前一个 beat 的结果是后一个 beat 的原因。",
|
|
3610
4011
|
"2. 必须覆盖本章全部剧情要点与正文关键事件,遗漏关键事件视为失败。",
|
|
3611
4012
|
"3. 只输出剧情骨架,禁止写画面描写、机位运镜、台词细节、音效(那是导演阶段的事)。",
|
|
3612
4013
|
"4. 所有字符串值内部不得包含换行符,JSON 必须在一段内完整结束。",
|
|
3613
|
-
"5. 直接输出 JSON 本身,不要输出思考过程或其他文字。"
|
|
4014
|
+
"5. 直接输出 JSON 本身,不要输出思考过程或其他文字。",
|
|
4015
|
+
"6. 额外输出 characters:本章真正出镜(说话/行动/被正面描写)的角色名数组,全部使用正文中的确切称谓(如「周野」「周野的律师」,不要改名或拼接),去重,3-10 个;路人或群像可用身份词(如「围观群众」)。"
|
|
3614
4016
|
].join("\n"),
|
|
3615
4017
|
user: [
|
|
3616
4018
|
`章节《${chapter.title}》(第 ${chapter.no} 章)`,
|
|
@@ -3640,10 +4042,13 @@ async function generateStoryboardSkeleton(ctx, config, project, outputDir, chapt
|
|
|
3640
4042
|
cause: typeof entry.cause === "string" && entry.cause.trim() !== "" ? entry.cause.trim().slice(0, 150) : void 0
|
|
3641
4043
|
})).filter((b) => b.event !== "") : [];
|
|
3642
4044
|
if (beats.length === 0) throw new Error("模型未输出有效节拍链,请重试");
|
|
4045
|
+
const skeletonChars = sanitizeCharacters(raw.characters, 12);
|
|
4046
|
+
const chars = skeletonChars.length > 0 ? skeletonChars : guessCharactersFromRoles(project, body, 12);
|
|
3643
4047
|
const skeleton = {
|
|
3644
4048
|
chapterNo,
|
|
3645
4049
|
arc: arc !== "" ? arc : "(本章弧线未生成)",
|
|
3646
|
-
beats
|
|
4050
|
+
beats,
|
|
4051
|
+
characters: chars.length > 0 ? chars : void 0
|
|
3647
4052
|
};
|
|
3648
4053
|
saveChapterStoryboard(project, outputDir, {
|
|
3649
4054
|
chapterNo,
|
|
@@ -4775,6 +5180,8 @@ const NOVEL_API = {
|
|
|
4775
5180
|
bookshelfImportTextPreview: "/api/dsh-novel-forge/bookshelf/import-text/preview",
|
|
4776
5181
|
/** 漫剧方案管理:create/remove/activate。 */
|
|
4777
5182
|
manhuaPlans: "/api/dsh-novel-forge/manhua/plans",
|
|
5183
|
+
/** 漫剧角色库:从分镜提名(规则+LLM两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
|
|
5184
|
+
mangaRoles: "/api/dsh-novel-forge/manga/roles",
|
|
4778
5185
|
/** 分镜·编剧级:单章 → 剧情骨架(节拍链)。 */
|
|
4779
5186
|
storyboardSkeleton: "/api/dsh-novel-forge/storyboard/skeleton",
|
|
4780
5187
|
/** 分镜·导演级:骨架 → 分镜表(镜头级)。 */
|
|
@@ -7307,6 +7714,210 @@ function makeRoutes(deps) {
|
|
|
7307
7714
|
writeJson(res, 200, { roles: project.roles });
|
|
7308
7715
|
}
|
|
7309
7716
|
};
|
|
7717
|
+
/** 漫剧角色库:从分镜提名(规则+LLM 两段式)/ 建卡 / 更新 / 删除 / 形象锚点 / 精修提示词。 */
|
|
7718
|
+
const mangaRolesRoute = {
|
|
7719
|
+
kind: "exact",
|
|
7720
|
+
path: NOVEL_API.mangaRoles,
|
|
7721
|
+
handler: async (req, res) => {
|
|
7722
|
+
if (!guard(req, res, "POST")) return;
|
|
7723
|
+
const config = getConfig();
|
|
7724
|
+
const project = requireProject(res);
|
|
7725
|
+
if (project === void 0) return;
|
|
7726
|
+
const body = await readJsonBody(req);
|
|
7727
|
+
if (project.mangaRoles === void 0) project.mangaRoles = [];
|
|
7728
|
+
const op = body?.op;
|
|
7729
|
+
if (op === "nominate") {
|
|
7730
|
+
const chapterNo = body?.chapterNo;
|
|
7731
|
+
if (typeof chapterNo !== "number" || chapterNo <= 0) {
|
|
7732
|
+
writeJson(res, 400, { error: "chapterNo(章号)必填" });
|
|
7733
|
+
return;
|
|
7734
|
+
}
|
|
7735
|
+
try {
|
|
7736
|
+
const candidates = await nominateMangaRoles(ctx, config, project, config.outputDir, chapterNo);
|
|
7737
|
+
writeJson(res, 200, {
|
|
7738
|
+
cards: project.mangaRoles,
|
|
7739
|
+
candidates
|
|
7740
|
+
});
|
|
7741
|
+
return;
|
|
7742
|
+
} catch (error) {
|
|
7743
|
+
writeJson(res, 500, { error: `提名失败:${error.message}` });
|
|
7744
|
+
return;
|
|
7745
|
+
}
|
|
7746
|
+
} else if (op === "adopt" || op === "update") {
|
|
7747
|
+
const card = body?.card;
|
|
7748
|
+
if (card === void 0 || typeof card !== "object") {
|
|
7749
|
+
writeJson(res, 400, { error: "card(漫剧角色卡)必填" });
|
|
7750
|
+
return;
|
|
7751
|
+
}
|
|
7752
|
+
const name = (card.name ?? "").trim();
|
|
7753
|
+
if (name === "") {
|
|
7754
|
+
writeJson(res, 400, { error: "漫剧角色名不能为空" });
|
|
7755
|
+
return;
|
|
7756
|
+
}
|
|
7757
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
7758
|
+
const existing = card.id !== "" ? project.mangaRoles.find((c) => c.id === card.id) : void 0;
|
|
7759
|
+
const next = {
|
|
7760
|
+
id: card.id !== "" ? card.id : "mr-" + Date.now().toString(36) + "-" + Math.random().toString(36).slice(2, 6),
|
|
7761
|
+
sourceRoleName: card.sourceRoleName !== void 0 && card.sourceRoleName !== "" ? card.sourceRoleName : void 0,
|
|
7762
|
+
name,
|
|
7763
|
+
identity: card.identity ?? "",
|
|
7764
|
+
coreFunction: card.coreFunction ?? "functional",
|
|
7765
|
+
protagonistRelation: card.protagonistRelation ?? "neutral",
|
|
7766
|
+
speechStyle: card.speechStyle ?? "",
|
|
7767
|
+
traits: Array.isArray(card.traits) ? card.traits.map((t) => String(t).slice(0, 20)).filter((t) => t !== "").slice(0, 3) : [],
|
|
7768
|
+
appearance: card.appearance ?? "",
|
|
7769
|
+
keyScenes: Array.isArray(card.keyScenes) ? card.keyScenes.map((k) => String(k).slice(0, 120)).filter((k) => k !== "").slice(0, 6) : [],
|
|
7770
|
+
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) : [],
|
|
7771
|
+
status: card.status ?? "imported",
|
|
7772
|
+
imagePrompt: card.imagePrompt,
|
|
7773
|
+
expressions: card.expressions,
|
|
7774
|
+
promptKit: card.promptKit,
|
|
7775
|
+
imageUrl: card.imageUrl,
|
|
7776
|
+
gallery: card.gallery,
|
|
7777
|
+
promptStyleId: card.promptStyleId,
|
|
7778
|
+
createdAt: existing?.createdAt ?? now,
|
|
7779
|
+
updatedAt: now
|
|
7780
|
+
};
|
|
7781
|
+
if (existing !== void 0) {
|
|
7782
|
+
const idx = project.mangaRoles.findIndex((c) => c.id === card.id);
|
|
7783
|
+
project.mangaRoles[idx] = next;
|
|
7784
|
+
} else project.mangaRoles.push(next);
|
|
7785
|
+
project.updatedAt = now;
|
|
7786
|
+
saveProject(config.outputDir, project);
|
|
7787
|
+
writeJson(res, 200, { cards: project.mangaRoles });
|
|
7788
|
+
return;
|
|
7789
|
+
} else if (op === "remove") {
|
|
7790
|
+
const id = body?.id;
|
|
7791
|
+
if (typeof id !== "string" || id === "") {
|
|
7792
|
+
writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
|
|
7793
|
+
return;
|
|
7794
|
+
}
|
|
7795
|
+
project.mangaRoles = project.mangaRoles.filter((c) => c.id !== id);
|
|
7796
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7797
|
+
saveProject(config.outputDir, project);
|
|
7798
|
+
writeJson(res, 200, { cards: project.mangaRoles });
|
|
7799
|
+
return;
|
|
7800
|
+
} else if (op === "visual") {
|
|
7801
|
+
const id = body?.id?.trim();
|
|
7802
|
+
if (id === void 0 || id === "") {
|
|
7803
|
+
writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
|
|
7804
|
+
return;
|
|
7805
|
+
}
|
|
7806
|
+
try {
|
|
7807
|
+
const visual = await extractMangaRoleVisual(ctx, config, project, config.outputDir, id, body?.styleId, body?.filterId);
|
|
7808
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7809
|
+
saveProject(config.outputDir, project);
|
|
7810
|
+
writeJson(res, 200, {
|
|
7811
|
+
cards: project.mangaRoles,
|
|
7812
|
+
visual
|
|
7813
|
+
});
|
|
7814
|
+
return;
|
|
7815
|
+
} catch (error) {
|
|
7816
|
+
writeJson(res, 500, { error: `形象锚点生成失败:${error.message}` });
|
|
7817
|
+
return;
|
|
7818
|
+
}
|
|
7819
|
+
} else if (op === "promptKit") {
|
|
7820
|
+
const id = body?.id?.trim();
|
|
7821
|
+
if (id === void 0 || id === "") {
|
|
7822
|
+
writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
|
|
7823
|
+
return;
|
|
7824
|
+
}
|
|
7825
|
+
try {
|
|
7826
|
+
const kit = await generateMangaRolePromptKit(ctx, config, project, id, body?.styleId, body?.filterId);
|
|
7827
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7828
|
+
saveProject(config.outputDir, project);
|
|
7829
|
+
writeJson(res, 200, {
|
|
7830
|
+
cards: project.mangaRoles,
|
|
7831
|
+
promptKit: kit
|
|
7832
|
+
});
|
|
7833
|
+
return;
|
|
7834
|
+
} catch (error) {
|
|
7835
|
+
writeJson(res, 500, { error: `提示词精修失败:${error.message}` });
|
|
7836
|
+
return;
|
|
7837
|
+
}
|
|
7838
|
+
} else if (op === "image") {
|
|
7839
|
+
const id = body?.id?.trim();
|
|
7840
|
+
const dataUrl = body?.dataUrl?.trim();
|
|
7841
|
+
if (id === void 0 || id === "") {
|
|
7842
|
+
writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
|
|
7843
|
+
return;
|
|
7844
|
+
}
|
|
7845
|
+
if (dataUrl === void 0 || dataUrl === "") {
|
|
7846
|
+
writeJson(res, 400, { error: "dataUrl(定妆图)必填" });
|
|
7847
|
+
return;
|
|
7848
|
+
}
|
|
7849
|
+
const card = project.mangaRoles.find((c) => c.id === id);
|
|
7850
|
+
if (card === void 0) {
|
|
7851
|
+
writeJson(res, 404, { error: "漫剧角色卡不存在" });
|
|
7852
|
+
return;
|
|
7853
|
+
}
|
|
7854
|
+
const label = body?.label?.trim() ?? "";
|
|
7855
|
+
if (label !== "") {
|
|
7856
|
+
card.gallery ??= [];
|
|
7857
|
+
card.gallery = card.gallery.filter((g) => g.label !== label);
|
|
7858
|
+
card.gallery.push({
|
|
7859
|
+
label,
|
|
7860
|
+
dataUrl
|
|
7861
|
+
});
|
|
7862
|
+
if (label.includes("立绘")) card.imageUrl = dataUrl;
|
|
7863
|
+
} else card.imageUrl = dataUrl;
|
|
7864
|
+
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7865
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7866
|
+
saveProject(config.outputDir, project);
|
|
7867
|
+
writeJson(res, 200, {
|
|
7868
|
+
cards: project.mangaRoles,
|
|
7869
|
+
imageUrl: card.imageUrl
|
|
7870
|
+
});
|
|
7871
|
+
return;
|
|
7872
|
+
} else if (op === "removeImage") {
|
|
7873
|
+
const id = body?.id?.trim();
|
|
7874
|
+
const label = body?.label?.trim() ?? "";
|
|
7875
|
+
const card = project.mangaRoles.find((c) => c.id === id);
|
|
7876
|
+
if (card !== void 0) {
|
|
7877
|
+
if (label !== "") {
|
|
7878
|
+
card.gallery = (card.gallery ?? []).filter((g) => g.label !== label);
|
|
7879
|
+
if (label.includes("立绘")) card.imageUrl = void 0;
|
|
7880
|
+
} else card.imageUrl = void 0;
|
|
7881
|
+
card.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7882
|
+
}
|
|
7883
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7884
|
+
saveProject(config.outputDir, project);
|
|
7885
|
+
writeJson(res, 200, { cards: project.mangaRoles });
|
|
7886
|
+
return;
|
|
7887
|
+
} else if (op === "imageGenerate") {
|
|
7888
|
+
const id = body?.id?.trim();
|
|
7889
|
+
if (id === void 0 || id === "") {
|
|
7890
|
+
writeJson(res, 400, { error: "id(漫剧卡 id)必填" });
|
|
7891
|
+
return;
|
|
7892
|
+
}
|
|
7893
|
+
try {
|
|
7894
|
+
const imageUrl = await generateMangaRoleReferenceImage(ctx, config, project, config.outputDir, id, body?.style ?? "", body?.modelId);
|
|
7895
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7896
|
+
saveProject(config.outputDir, project);
|
|
7897
|
+
writeJson(res, 200, {
|
|
7898
|
+
cards: project.mangaRoles,
|
|
7899
|
+
imageUrl
|
|
7900
|
+
});
|
|
7901
|
+
return;
|
|
7902
|
+
} catch (error) {
|
|
7903
|
+
writeJson(res, 500, { error: `定妆图生成失败:${error.message}` });
|
|
7904
|
+
return;
|
|
7905
|
+
}
|
|
7906
|
+
} else if (op === "mode") {
|
|
7907
|
+
project.shortDramaMode = body?.shortDramaMode === true;
|
|
7908
|
+
project.updatedAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
7909
|
+
saveProject(config.outputDir, project);
|
|
7910
|
+
writeJson(res, 200, {
|
|
7911
|
+
cards: project.mangaRoles,
|
|
7912
|
+
shortDramaMode: project.shortDramaMode
|
|
7913
|
+
});
|
|
7914
|
+
return;
|
|
7915
|
+
} else {
|
|
7916
|
+
writeJson(res, 400, { error: "未知的 manga/roles op" });
|
|
7917
|
+
return;
|
|
7918
|
+
}
|
|
7919
|
+
}
|
|
7920
|
+
};
|
|
7310
7921
|
/** 章节复位:generating 卡死 → pending(可重新生成)。 */
|
|
7311
7922
|
const chapterResetRoute = {
|
|
7312
7923
|
kind: "exact",
|
|
@@ -7884,6 +8495,7 @@ function makeRoutes(deps) {
|
|
|
7884
8495
|
renameRoute,
|
|
7885
8496
|
plotlinesRoute,
|
|
7886
8497
|
rolesRoute,
|
|
8498
|
+
mangaRolesRoute,
|
|
7887
8499
|
scenesRoute,
|
|
7888
8500
|
visualRulesRoute,
|
|
7889
8501
|
sensitiveRoute,
|