koishi-plugin-p-draw 1.2.13 → 1.3.1
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/index.js +231 -1437
- package/lib/comfy.js +81 -0
- package/lib/http.js +74 -0
- package/lib/i18n.js +152 -0
- package/lib/media.js +22 -0
- package/lib/multi.js +262 -0
- package/lib/parse.js +265 -0
- package/lib/tags.js +261 -0
- package/lib/workflows.js +285 -0
- package/package.json +2 -1
package/index.js
CHANGED
|
@@ -3,7 +3,7 @@ const fs = require('fs')
|
|
|
3
3
|
const fsp = require('fs/promises')
|
|
4
4
|
const path = require('path')
|
|
5
5
|
const crypto = require('crypto')
|
|
6
|
-
const { pathToFileURL } = require('url')
|
|
6
|
+
const { pathToFileURL, fileURLToPath } = require('url')
|
|
7
7
|
|
|
8
8
|
exports.name = 'p-draw'
|
|
9
9
|
|
|
@@ -35,155 +35,7 @@ exports.usage = `
|
|
|
35
35
|
- 可加 \`--denoise 0.6\` 单独调整强度;未装 ControlNet/IPAdapter 时自动回退普通 img2img。
|
|
36
36
|
`;
|
|
37
37
|
|
|
38
|
-
const zhCN =
|
|
39
|
-
comfyuiBaseUrl: { $description: 'ComfyUI 地址' },
|
|
40
|
-
workflow: { $description: '工作流类型(内置 anima_t2i)' },
|
|
41
|
-
customWorkflowEnabled: { $description: '使用自定义 ComfyUI 工作流 JSON' },
|
|
42
|
-
customWorkflowPath: { $description: '自定义工作流 JSON 路径(相对插件目录)' },
|
|
43
|
-
customWorkflowOverrideParameters: { $description: '用插件参数覆盖自定义工作流参数' },
|
|
44
|
-
timeout: { $description: '单次生成超时(秒)' },
|
|
45
|
-
pollInterval: { $description: '生成状态查询间隔(秒)' },
|
|
46
|
-
unetName: { $description: '主模型文件名' },
|
|
47
|
-
clipName: { $description: '文本编码器文件名' },
|
|
48
|
-
vaeName: { $description: 'VAE 文件名' },
|
|
49
|
-
width: { $description: '默认宽度' },
|
|
50
|
-
height: { $description: '默认高度' },
|
|
51
|
-
allowedSizes: { $description: '可用尺寸列表(宽x高)' },
|
|
52
|
-
steps: { $description: '采样步数' },
|
|
53
|
-
cfg: { $description: 'CFG 强度' },
|
|
54
|
-
samplerName: { $description: '采样器' },
|
|
55
|
-
scheduler: { $description: '调度器' },
|
|
56
|
-
qualityPrefix: { $description: '质量词前缀' },
|
|
57
|
-
negativePrompt: { $description: '负面提示词' },
|
|
58
|
-
promptOptimizeEnabled: { $description: '启用自然语言优化(需要配置下方 LLM 接口)' },
|
|
59
|
-
llmBaseUrl: { $description: 'LLM 接口地址(OpenAI 兼容,例如 https://api.deepseek.com/v1)' },
|
|
60
|
-
llmApiKey: { $description: 'LLM API Key' },
|
|
61
|
-
llmModel: { $description: 'LLM 模型名(留空则不优化,原样生图)' },
|
|
62
|
-
llmMaxTokens: { $description: 'LLM 输出上限' },
|
|
63
|
-
webSearchEnabled: { $description: '启用联网搜索(指令里写“联网/搜索/查一下”等触发)' },
|
|
64
|
-
tavilyApiKey: { $description: 'Tavily API Key(联网搜索用,https://tavily.com 申请)' },
|
|
65
|
-
webSearchMaxResults: { $description: '联网搜索结果数量' },
|
|
66
|
-
webSearchDepth: { $description: '搜索深度(basic / advanced)' },
|
|
67
|
-
webSearchQueryTemplate: { $description: '搜索词模板({prompt} 代表用户需求)' },
|
|
68
|
-
promptOptimizeTemplate: { $description: '自然语言优化模板(支持 {theme} {search_block} 占位符)' },
|
|
69
|
-
fixedCharacters: { $description: '固定角色(格式:角色名=tags)' },
|
|
70
|
-
artistPresets: { $description: '画师组(格式:名称=tags)' },
|
|
71
|
-
activeArtistPreset: { $description: '启用的画师组名称' },
|
|
72
|
-
defaultArtistTags: { $description: '备用画师 tags' },
|
|
73
|
-
styleTags: { $description: '画风 tags' },
|
|
74
|
-
queueEnabled: { $description: '启用生成队列(逐张顺序执行)' },
|
|
75
|
-
queueMaxRequests: { $description: '队列最大任务数(0 表示不限制)' },
|
|
76
|
-
batchMax: { $description: '单次指令最多生成的张数(支持 x3 / 3张 / --数量 3 等写法)' },
|
|
77
|
-
price: { $description: '一张图消耗的 P 点' },
|
|
78
|
-
multiPrice: { $description: '多人指令(p-draw 多人)单张消耗的 P 点' },
|
|
79
|
-
couponPrice: { $description: '提示词优化券单价(P 点/张,购买询问时显示)' },
|
|
80
|
-
couponAskTimeout: { $description: '提示词优化券确认等待时间(秒)' },
|
|
81
|
-
img2imgDenoise: { $description: '普通以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)' },
|
|
82
|
-
i2iMode: { $description: 'i2i 模式选择方式:ask=每次询问 / style=直接换风格(漫画化)/ ootd=直接换装换姿势 / plain=普通 img2img' },
|
|
83
|
-
i2iAskTimeout: { $description: 'i2i 模式询问等待时间(秒)' },
|
|
84
|
-
seriesAskTimeout: { $description: '连续图 LLM 使用确认等待时间(秒)' },
|
|
85
|
-
i2iStyleDenoise: { $description: '换风格模式(漫画化)的去噪强度,越大风格变化越彻底(建议 0.7-0.85)' },
|
|
86
|
-
i2iOotdDenoise: { $description: '换装换姿势模式(保留角色)的去噪强度(建议 0.5-0.6)' },
|
|
87
|
-
i2iControlNetStrength: { $description: '换风格模式的 ControlNet 强度,越大构图锁得越死(建议 0.5-0.8)' },
|
|
88
|
-
i2iIPAdapterPath: { $description: 'Anima IP-Adapter 模型文件路径(换装换姿势模式保脸用;需安装 comfyui-anima-ipadapter 节点并把模型路径填到这里)' },
|
|
89
|
-
i2iIPAdapterWeight: { $description: '换装换姿势模式的 IP-Adapter 权重,越大角色特征保留越强(建议 0.6-1.0)' },
|
|
90
|
-
controlNetModel: { $description: 'ControlNet 模型文件名(留空自动检测 Qwen/Anima 系 ControlNet;需放到 ComfyUI/models/controlnet)' },
|
|
91
|
-
taggerEnabled: { $description: 'i2i 前自动识图(需 ComfyUI 安装 WD14 Tagger 节点与模型;识别出的标签会注入提示词优化)' },
|
|
92
|
-
taggerModel: { $description: '识图模型名(WD14 Tagger 节点里可选模型)' },
|
|
93
|
-
taggerThreshold: { $description: '识图标签置信度阈值' },
|
|
94
|
-
taggerCharacterThreshold: { $description: '识图角色标签置信度阈值' },
|
|
95
|
-
adminUsers: { $description: '免 P 点管理员用户 ID 列表' },
|
|
96
|
-
outputLogs: { $description: '是否在控制台输出详细日志' },
|
|
97
|
-
multiVerifyEnabled: { $description: '多人图生成后启用视觉校验(需配置下方视觉模型)' },
|
|
98
|
-
multiVerifyPassScore: { $description: '多人视觉校验合格分数(0-10)' },
|
|
99
|
-
multiCandidateCount: { $description: '多人候选采样数量(校验失败时最多重试 候选数-1 次)' },
|
|
100
|
-
multiSendDegradedCandidate: { $description: '多人候选全部不达标时仍发送最优候选' },
|
|
101
|
-
verifyLlmBaseUrl: { $description: '视觉校验 LLM 接口地址(OpenAI 兼容;留空则跳过校验)' },
|
|
102
|
-
verifyLlmApiKey: { $description: '视觉校验 LLM API Key' },
|
|
103
|
-
verifyLlmModel: { $description: '视觉校验 LLM 模型名(需支持图片输入,如 qwen-vl)' },
|
|
104
|
-
adminOnly: { $description: '仅管理员可用(adminUsers 中的用户)' },
|
|
105
|
-
allowedUserIds: { $description: '用户白名单(QQ 号,留空表示不限制)' },
|
|
106
|
-
blockedUserIds: { $description: '用户黑名单(QQ 号,黑名单优先于白名单)' },
|
|
107
|
-
allowedGroupIds: { $description: 'QQ 群白名单(群号,留空表示不限制)' },
|
|
108
|
-
blockedGroupIds: { $description: 'QQ 群黑名单(群号,黑名单优先于白名单)' },
|
|
109
|
-
commands: {
|
|
110
|
-
'p-draw': {
|
|
111
|
-
description: '连接本地 ComfyUI 生图,消耗 P 点',
|
|
112
|
-
messages: {
|
|
113
|
-
'not-permitted': 'ComfyUI 助手已关闭,或当前用户没有使用权限。',
|
|
114
|
-
usage: 'p-draw 帮助(本指令名可自行更换,如 /anm):\n\n【生成】\n p-draw <描述>\n 例:p-draw 一个女孩,白色裙子,立绘,简单背景\n 可加 --seed 数字 固定种子(单张 / 批量 / 连续图均支持)\n\n【多人生成】\n p-draw 多人 <描述>(2-4 人画面)\n 例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手\n\n【连续图】\n p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n 例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n 全阶段共用同一 seed,角色外观尽量一致;可加 --seed 数字 固定种子\n 阶段分隔:→ / -> / |\n\n【批量张数】\n 描述后加 x3 / ×3 / 3张 / 三张 / --数量 3,例:p-draw 一个女孩 x3\n 多张按 张数×单价 一次性扣 P 点,余额不足则不生成\n\n【尺寸】\n 竖图 / 横图 / 方图 / 长竖图 / 宽屏,或 1024x1536:描述 / --尺寸 1216x832\n 例:p-draw 竖图:狐莉站在梨花树下\n\n【原样模式】\n p-draw 无优化 masterpiece, best quality, 1girl, solo\n\n【提示词优化】\n 配置 LLM(llmBaseUrl/llmModel)后:\n 全局优化开启 → 每次自动把中文描述转成 Danbooru tags\n 全局优化关闭 → 生图时询问是否使用「提示词优化券」(p-shop 购买,每张图扣 1 张):\n 有券 → 确认后消耗券并优化,拒绝则取消本次生图\n 没券 → 询问是否购买(显示价格),确认后购买并消耗、优化生图;\n 拒绝一次会再次警告,再拒绝则直接生图(不优化);\n P 点不足买不起券时,会询问是否仍然生图\n 无优化 <tags> 原样生图,不耗券\n 连续图逐阶段强制优化;多人指令依赖 LLM 规划(未配置会提示)\n\n【画师组】\n 创建画师组 名称=tags / 追加画师组 名称=tags / 切换画师组 名称 / 查看画师组 / 删除画师组 名称\n\n【固定角色】\n 添加角色 名称=tags\n\n【以图生图】\n p-draw i2i <描述>,并在同一条消息里附一张原图(文件/截图/链接均可)\n 例:p-draw i2i 换成晚礼服,背景换成舞台灯光\n 发送后会询问处理模式(可配置 i2iMode 固定模式跳过询问):\n ① 换风格(漫画化):保留原图构图,转成二次元画风(需 ControlNet)\n ② 换装换姿势:保留角色长相,重新设计服装/姿势/场景(需 Anima IP-Adapter)\n ③ 取消:不生成\n 可加 --denoise 0.6 单独调整强度;未装 ControlNet/IPAdapter 时自动回退普通 img2img\n\n【模型】\n p-draw 模型(查看当前与可用模型) / p-draw 模型 名称(切换,支持模糊匹配)/ p-draw 模型 默认(重置)\n 例:p-draw 模型 anima-aesthetic\n\n【状态】\n p-draw 状态(查看 ComfyUI 连接状态与模型可用性)',
|
|
115
|
-
'account-notExists': '君现在还没有 p 点,请先签到哦',
|
|
116
|
-
'no-enough-p': '君的 p 点不够 {0}p 哦,先去签个到吧qwq',
|
|
117
|
-
'no-prompt': '请提供画面描述,例如:p-draw 一个女孩,白色裙子',
|
|
118
|
-
generating: '正在生成中,请稍候...',
|
|
119
|
-
charged: '已扣除 {0} P 点,出图后余额会再核对。',
|
|
120
|
-
queued: '已加入生成队列,当前第 {0} 位(队列上限 {1})。',
|
|
121
|
-
'prompt-degraded': '提示词优化服务不可用{0},本次已使用原始提示词继续生成;结果可能不符合 Danbooru Tag 预期。',
|
|
122
|
-
'token-used': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 提示词优化。',
|
|
123
|
-
'token-short': '提示词优化券不足(需 {0} 张,现有 {1} 张),本次未使用 LLM 优化。',
|
|
124
|
-
'coupon-ask-use': '你有提示词优化券 {0} 张,本次生图需要消耗 {1} 张。\n是否使用提示词优化券进行 LLM 优化?\n(回复「是」使用 / 回复「否」取消本次生图)',
|
|
125
|
-
'coupon-use-confirmed': '已消耗 {0} 张提示词优化券,本次每张图都会使用 LLM 优化。',
|
|
126
|
-
'coupon-use-cancelled': '已取消本次生图(未使用提示词优化券)。',
|
|
127
|
-
'coupon-cancelled': '未收到有效回复,本次操作已取消。',
|
|
128
|
-
'coupon-ask-buy': '提示词优化券不足(需 {0} 张,现有 {1} 张)。\n提示词优化券价格:{2} P/张,本次共需 {3} P。\n是否购买并使用?\n(回复「是」购买 / 回复「否」不购买)',
|
|
129
|
-
'coupon-buy-warn': '不使用提示词优化券的话,生成的图可能不好看。\n是否仍要购买并使用提示词优化券?\n(回复「是」购买 / 回复「否」直接生图)',
|
|
130
|
-
'coupon-bought-used': '已购买 {0} 张提示词优化券(扣除 {1} P),并消耗 {0} 张用于本次 LLM 优化。',
|
|
131
|
-
'coupon-buy-cancelled': '好的,本次不使用提示词优化券,直接生图。',
|
|
132
|
-
'coupon-buy-pshort': 'P 点不足,无法购买提示词优化券(需 {0} P,现有 {1} P)。\n是否仍然生图(不使用 LLM 优化)?\n(回复「是」生图 / 回复「否」取消本次生图)',
|
|
133
|
-
'coupon-consume-fail': '提示词优化券操作失败,本次未使用 LLM 优化。',
|
|
134
|
-
'generate-failed': '生成失败:{0}',
|
|
135
|
-
'generate-ok': '已扣除 {0} P 点,seed={1}',
|
|
136
|
-
'generate-ok-batch': '已扣除 {0} P 点,共 {1} 张(seed:{2})',
|
|
137
|
-
'batch-partial': '本次共生成 {0}/{1} 张,失败 {2} 张:{3}',
|
|
138
|
-
'batch-limit': '每次最多生成 {0} 张,本次已按 {0} 张处理。',
|
|
139
|
-
'batch-count': '本次共生成 {0} 张。',
|
|
140
|
-
'artist-format': '请使用「名称=tags」的格式。例:p-draw 创建画师组 千代风格=@artist_a, @artist_b,',
|
|
141
|
-
'artist-created': '已保存并启用画师组「{0}」:\n{1}',
|
|
142
|
-
'artist-appended': '已追加画师组「{0}」:\n{1}',
|
|
143
|
-
'artist-default-appended': '已追加默认画师 tags:\n{0}',
|
|
144
|
-
'artist-use-format': '请写要启用的画师组名称。例:p-draw 切换画师组 千代风格',
|
|
145
|
-
'artist-default': '已切回默认画师 tags。',
|
|
146
|
-
'artist-not-found': '没有找到画师组「{0}」。',
|
|
147
|
-
'artist-used': '已启用画师组「{0}」:\n{1}',
|
|
148
|
-
'artist-deleted': '已删除画师组「{0}」。',
|
|
149
|
-
'artist-delete-format': '请写要删除的画师组名称。例:p-draw 删除画师组 千代风格',
|
|
150
|
-
'character-format': '请使用「名称=tags」的格式。例:p-draw 添加角色 狐莉=1girl, solo, fox girl',
|
|
151
|
-
'character-created': '已保存角色「{0}」:\n{1}',
|
|
152
|
-
'multi-usage': '多人生图:p-draw 多人 <描述>(2-4 人画面)\n例:p-draw 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手',
|
|
153
|
-
'multi-verify-passed': '多人图已通过视觉校验({0} 分)。',
|
|
154
|
-
'multi-verify-failed': '多人图未通过视觉校验{0},已重试 {1} 次。',
|
|
155
|
-
'multi-verify-degraded': '多人图校验失败,已发送最优候选{0}。',
|
|
156
|
-
'multi-verify-discarded': '多人图校验失败且未启用降级发送,本次图片不发送。',
|
|
157
|
-
'multi-degraded': '多人视觉校验不可用{0},本次已直接发送生成结果。',
|
|
158
|
-
'multi-verify-error': '多人视觉校验调用失败:{0}',
|
|
159
|
-
'series-usage': '连续图:p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n例:p-draw 连续 少女:清纯校服 → 换上晚礼服 → 华丽登场\n或用 | 分隔,可加 --seed 固定种子保证角色一致。',
|
|
160
|
-
'series-llm-ask': '是否使用 LLM 优化连续图各阶段提示词?\n1. 是(使用 LLM,自动整理成 Danbooru tags)\n2. 否(不使用 LLM,直接用你输入的内容)\n3. 取消(不生成)\n请回复 1 / 2 / 3',
|
|
161
|
-
'series-llm-invalid': '未识别的回答,请回复 1(使用 LLM)/ 2(不使用 LLM)/ 3(取消)。',
|
|
162
|
-
'series-no-llm': '本次未使用 LLM 优化,直接使用你输入的描述/tags。',
|
|
163
|
-
'series-ok': '已扣除 {0} P 点,共 {1} 张连续图(seed={2})',
|
|
164
|
-
'model-usage': '当前模型:{0}\n可用模型:\n{1}\n用法:p-draw 模型 <名称>(支持模糊匹配,如 anima-aesthetic);p-draw 模型 默认 恢复默认。',
|
|
165
|
-
'model-switched': '已切换为模型「{0}」,对之后的生图生效。',
|
|
166
|
-
'model-reset': '已恢复默认模型「{0}」。',
|
|
167
|
-
'model-not-found': '未找到模型「{0}」。可用模型:\n{1}',
|
|
168
|
-
'model-no-draw': '「模型」只能用来切换/查看模型,不能生图。请先用「p-draw 模型 <名称>」切换,再单独发送要画的内容。',
|
|
169
|
-
'model-ambiguous': '「{0}」匹配到多个模型,请写得更具体些:\n{1}',
|
|
170
|
-
'no-optimize': '提示:本次未使用 LLM 优化({0})。',
|
|
171
|
-
'i2i-no-image': 'i2i(以图生图)需要附一张原图。用法:p-draw i2i <描述>,并在同一条消息里带上图片。',
|
|
172
|
-
'i2i-upload-fail': '原图上传 ComfyUI 失败:{0}',
|
|
173
|
-
'i2i-no-custom-workflow': 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。',
|
|
174
|
-
'i2i-mode-ask': '请选择 i2i 处理模式:\n① 换风格(漫画化)——保留原图构图,转成二次元画风\n② 换装换姿势——保留角色长相,重新设计服装/姿势/场景\n③ 取消——不生成\n回复 1 / 2 / 3 或对应名称即可。',
|
|
175
|
-
'i2i-mode-invalid': '没有理解你的选择。请回复 ①换风格(漫画化) / ②换装换姿势 / ③取消。',
|
|
176
|
-
'i2i-mode-cancelled': '已取消本次 i2i 生图。',
|
|
177
|
-
'i2i-mode-style': '已选择【换风格(漫画化)】:保留原图构图,转成二次元画风。',
|
|
178
|
-
'i2i-mode-ootd': '已选择【换装换姿势】:保留角色长相,重新设计服装/姿势/场景。',
|
|
179
|
-
'i2i-no-controlnet': '未检测到可用的 Anima ControlNet-LLLite,本次「换风格」将使用普通 img2img,构图保留效果会弱一些。',
|
|
180
|
-
'i2i-no-ipadapter': '未检测到可用的 Anima IP-Adapter,本次「换装换姿势」将使用普通 img2img,角色保留效果会弱一些。',
|
|
181
|
-
'multi-no-llm': '多人指令需要 LLM 规划,但当前未配置 llmBaseUrl / llmModel。请管理员在配置中填写后使用。',
|
|
182
|
-
},
|
|
183
|
-
},
|
|
184
|
-
},
|
|
185
|
-
}
|
|
186
|
-
|
|
38
|
+
const { zhCN } = require('./lib/i18n')
|
|
187
39
|
exports.Config = Schema.object({
|
|
188
40
|
// ComfyUI 连接
|
|
189
41
|
comfyuiBaseUrl: Schema.string().default('http://127.0.0.1:8188').description('ComfyUI 地址'),
|
|
@@ -282,1050 +134,28 @@ exports.Config = Schema.object({
|
|
|
282
134
|
'zh-CN': zhCN,
|
|
283
135
|
})
|
|
284
136
|
|
|
285
|
-
// ------------------------------------------------------------------
|
|
286
|
-
//
|
|
287
|
-
// ------------------------------------------------------------------
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
'长竖图': 9 / 16,
|
|
308
|
-
'手机竖屏': 9 / 16,
|
|
309
|
-
'宽屏': 16 / 9,
|
|
310
|
-
'超宽图': 16 / 9,
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
const SIZE_VALUE_PATTERN = String.raw`(?<width>\d{2,5})\s*[xX×**✕✖хХ]\s*(?<height>\d{2,5})`
|
|
314
|
-
|
|
315
|
-
function escapeRe(text) {
|
|
316
|
-
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
317
|
-
}
|
|
318
|
-
|
|
319
|
-
function parseGenerationSize(text, allowed) {
|
|
320
|
-
const prompt = String(text || '').trim()
|
|
321
|
-
let sizeMatch = null
|
|
322
|
-
const patterns = [
|
|
323
|
-
new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
|
|
324
|
-
new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*${SIZE_VALUE_PATTERN}`, 'i'),
|
|
325
|
-
new RegExp(String.raw`^\s*${SIZE_VALUE_PATTERN}\s*[::,,]`, 'i'),
|
|
326
|
-
]
|
|
327
|
-
for (const pattern of patterns) {
|
|
328
|
-
sizeMatch = prompt.match(pattern)
|
|
329
|
-
if (sizeMatch) break
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
let selected = null
|
|
333
|
-
if (sizeMatch) {
|
|
334
|
-
selected = [parseInt(sizeMatch.groups.width), parseInt(sizeMatch.groups.height)]
|
|
335
|
-
} else {
|
|
336
|
-
const aliases = Object.keys(SIZE_ALIASES)
|
|
337
|
-
.sort((a, b) => b.length - a.length)
|
|
338
|
-
.map(escapeRe)
|
|
339
|
-
.join('|')
|
|
340
|
-
const aliasPatterns = [
|
|
341
|
-
new RegExp(String.raw`(?<!\S)--(?:尺寸|分辨率)\s*(?:=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
|
|
342
|
-
new RegExp(String.raw`(?:尺寸|分辨率)\s*(?:为|是|=|=|:|:)?\s*(?<alias>${aliases})(?=$|\s|[::,,])`, 'i'),
|
|
343
|
-
new RegExp(String.raw`^\s*(?<alias>${aliases})(?=$|\s|[::,,])\s*[::,,]?`, 'i'),
|
|
344
|
-
]
|
|
345
|
-
for (const pattern of aliasPatterns) {
|
|
346
|
-
sizeMatch = prompt.match(pattern)
|
|
347
|
-
if (sizeMatch) break
|
|
348
|
-
}
|
|
349
|
-
if (sizeMatch && allowed.length) {
|
|
350
|
-
const targetRatio = SIZE_ALIASES[sizeMatch.groups.alias]
|
|
351
|
-
selected = allowed.reduce((best, size) => {
|
|
352
|
-
const a = Math.abs(size[0] / size[1] - targetRatio)
|
|
353
|
-
const b = Math.abs(size[0] * size[1] - 1024 * 1024)
|
|
354
|
-
const ba = Math.abs(best[0] / best[1] - targetRatio)
|
|
355
|
-
const bb = Math.abs(best[0] * best[1] - 1024 * 1024)
|
|
356
|
-
return a < ba || (a === ba && b < bb) ? size : best
|
|
357
|
-
})
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
if (!sizeMatch) return { prompt, size: null, error: null }
|
|
362
|
-
|
|
363
|
-
let cleaned = (prompt.slice(0, sizeMatch.index) + ' ' + prompt.slice(sizeMatch.index + sizeMatch[0].length)).trim()
|
|
364
|
-
cleaned = cleaned.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
|
|
365
|
-
cleaned = cleaned.replace(/([,,;;])\s*[,,;;]+/g, '$1')
|
|
366
|
-
cleaned = cleaned.replace(/\s+/g, ' ')
|
|
367
|
-
|
|
368
|
-
if (selected && allowed.length && !allowed.some(s => s[0] === selected[0] && s[1] === selected[1])) {
|
|
369
|
-
return {
|
|
370
|
-
prompt: cleaned,
|
|
371
|
-
size: null,
|
|
372
|
-
error: `尺寸 ${selected[0]}x${selected[1]} 不可用。可用尺寸:${allowed.map(s => `${s[0]}x${s[1]}`).join('、')}`,
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
if (selected === null) return { prompt: cleaned, size: null, error: '当前没有配置可用尺寸。' }
|
|
376
|
-
return { prompt: cleaned, size: selected, error: null }
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
// 批量张数解析:x3 / ×3 / 3张 / 三张 / --数量 3 / 数量:3
|
|
380
|
-
const BATCH_TOKEN_PATTERNS = [
|
|
381
|
-
/(?<!\S)(--|——)(?:数量|张数)\s*(?:=|=|:|:)?\s*(?<num>\d+)/i,
|
|
382
|
-
/(?:数量|张数)\s*(?:为|是|=|=|:|:)\s*(?<num>\d+)/i,
|
|
383
|
-
/(?<!\S)[x×X](?<num>\d+)(?![a-zA-Z0-9])/,
|
|
384
|
-
/(?<!\S)(?<num>[一二两三四五六七八九十]+)张/,
|
|
385
|
-
/(?<!\S)(?<num>\d+)张(?:图)?/,
|
|
386
|
-
]
|
|
387
|
-
|
|
388
|
-
const CN_NUM_MAP = { '一': 1, '两': 2, '二': 2, '三': 3, '四': 4, '五': 5, '六': 6, '七': 7, '八': 8, '九': 9, '十': 10 }
|
|
389
|
-
|
|
390
|
-
function cnNumValue(text) {
|
|
391
|
-
if (/^\d+$/.test(text)) return parseInt(text, 10)
|
|
392
|
-
if (CN_NUM_MAP[text] != null) return CN_NUM_MAP[text]
|
|
393
|
-
if (/^十[一二三四五六七八九]$/.test(text)) return 10 + CN_NUM_MAP[text.slice(1)]
|
|
394
|
-
if (text === '十') return 10
|
|
395
|
-
return 0
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
function parseBatchCount(text, max) {
|
|
399
|
-
const prompt = String(text || '').trim()
|
|
400
|
-
const cap = Math.max(1, parseInt(max) || 1)
|
|
401
|
-
let requested = 1
|
|
402
|
-
let matched = false
|
|
403
|
-
for (const pattern of BATCH_TOKEN_PATTERNS) {
|
|
404
|
-
const m = prompt.match(pattern)
|
|
405
|
-
if (!m) continue
|
|
406
|
-
const numText = m.groups && m.groups.num != null ? m.groups.num : ''
|
|
407
|
-
const value = cnNumValue(numText)
|
|
408
|
-
if (value >= 1) {
|
|
409
|
-
requested = value
|
|
410
|
-
matched = true
|
|
411
|
-
}
|
|
412
|
-
const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
|
|
413
|
-
.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
|
|
414
|
-
.replace(/\s+/g, ' ')
|
|
415
|
-
return { count: Math.min(requested, cap), requested, prompt: cleaned, matched, clamped: requested > cap }
|
|
416
|
-
}
|
|
417
|
-
return { count: 1, requested: 1, prompt, matched, clamped: false }
|
|
418
|
-
}
|
|
419
|
-
|
|
420
|
-
// 固定种子解析:--seed 17021628 / --seed:17021628 / --seed=17021628 / --seed=123
|
|
421
|
-
// 从提示词里剥离并返回 { seed, prompt }
|
|
422
|
-
function parseSeed(text) {
|
|
423
|
-
const prompt = String(text || '').trim()
|
|
424
|
-
const m = prompt.match(/(?<!\S)--seed\s*(?:=|=|:|:)?\s*(\d+)/i)
|
|
425
|
-
if (!m) return { seed: null, prompt }
|
|
426
|
-
const seed = parseInt(m[1], 10) >>> 0
|
|
427
|
-
const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
|
|
428
|
-
.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
|
|
429
|
-
.replace(/\s+/g, ' ')
|
|
430
|
-
return { seed, prompt: cleaned }
|
|
431
|
-
}
|
|
432
|
-
|
|
433
|
-
// 去噪强度解析:--denoise 0.3 / --denoise=0.6 / --去噪 0.4,并从提示词里剥离
|
|
434
|
-
function parseDenoise(text) {
|
|
435
|
-
const prompt = String(text || '').trim()
|
|
436
|
-
const m = prompt.match(/(?<!\S)--(?:denoise|去噪)\s*(?:=|=|:|:)?\s*(\d+(?:\.\d+)?)/i)
|
|
437
|
-
if (!m) return { denoise: null, prompt }
|
|
438
|
-
const denoise = Math.min(1, Math.max(0, parseFloat(m[1])))
|
|
439
|
-
const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
|
|
440
|
-
.replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
|
|
441
|
-
.replace(/\s+/g, ' ')
|
|
442
|
-
return { denoise, prompt: cleaned }
|
|
443
|
-
}
|
|
444
|
-
|
|
445
|
-
// ------------------------------------------------------------------
|
|
446
|
-
// 提示词辅助(移植自 anima prompt_presets)
|
|
447
|
-
// ------------------------------------------------------------------
|
|
448
|
-
const RAW_PREFIXES = [
|
|
449
|
-
'原样', '原样tags', '原样tag', '原样 tags', '原样 tag',
|
|
450
|
-
'直接画', '直接出图', '直接生图', '直接tags', '直接tag', '直接 tags', '直接 tag',
|
|
451
|
-
'不优化', '无优化', '无优化tags', '无优化tag', '无优化 tags', '无优化 tag',
|
|
452
|
-
'不要优化', '跳过优化', '跳过提示词优化', 'raw tags', 'raw tag', 'raw',
|
|
453
|
-
'no optimize', 'no optimization', '不用优化',
|
|
454
|
-
]
|
|
455
|
-
|
|
456
|
-
function stripRawPrefix(prompt) {
|
|
457
|
-
const text = String(prompt || '').trim()
|
|
458
|
-
const lowered = text.toLowerCase()
|
|
459
|
-
for (const prefix of RAW_PREFIXES) {
|
|
460
|
-
if (lowered.startsWith(prefix.toLowerCase())) {
|
|
461
|
-
return { raw: true, prompt: text.slice(prefix.length).replace(/^[\s,,;;::]+/, '').trim() }
|
|
462
|
-
}
|
|
463
|
-
}
|
|
464
|
-
return { raw: false, prompt: text }
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function mergeTagText(existing, addition) {
|
|
468
|
-
const tags = []
|
|
469
|
-
const seen = new Set()
|
|
470
|
-
for (const source of [existing, addition]) {
|
|
471
|
-
for (const tag of String(source || '').split(',')) {
|
|
472
|
-
const text = tag.trim()
|
|
473
|
-
if (!text) continue
|
|
474
|
-
const key = text.toLowerCase()
|
|
475
|
-
if (seen.has(key)) continue
|
|
476
|
-
tags.push(text)
|
|
477
|
-
seen.add(key)
|
|
478
|
-
}
|
|
479
|
-
}
|
|
480
|
-
return tags.join(', ') + (tags.length ? ',' : '')
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
function parseNameTags(text) {
|
|
484
|
-
const raw = String(text || '').trim()
|
|
485
|
-
const candidates = []
|
|
486
|
-
for (const separator of ['=', '=', ':', ':']) {
|
|
487
|
-
const index = raw.indexOf(separator)
|
|
488
|
-
if (index >= 0) candidates.push([index, separator])
|
|
489
|
-
}
|
|
490
|
-
candidates.sort((a, b) => a[0] - b[0])
|
|
491
|
-
for (const [index, separator] of candidates) {
|
|
492
|
-
const name = raw.slice(0, index).trim()
|
|
493
|
-
const tags = raw.slice(index + separator.length).trim()
|
|
494
|
-
if (!name || !tags) continue
|
|
495
|
-
if (name.includes(',') || name.includes('\n')) continue
|
|
496
|
-
if (/[@()[\]{}]/.test(name)) continue
|
|
497
|
-
if (['artist', 'tag', 'tags', 'prompt', 'positive', 'negative'].includes(name.toLowerCase())) continue
|
|
498
|
-
return { name, tags }
|
|
499
|
-
}
|
|
500
|
-
return null
|
|
501
|
-
}
|
|
502
|
-
|
|
503
|
-
function parsePresetList(list) {
|
|
504
|
-
const result = {}
|
|
505
|
-
for (const item of list || []) {
|
|
506
|
-
const text = String(item || '').trim()
|
|
507
|
-
if (!text) continue
|
|
508
|
-
const parsed = parseNameTags(text)
|
|
509
|
-
if (parsed) result[parsed.name] = parsed.tags
|
|
510
|
-
}
|
|
511
|
-
return result
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
// ------------------------------------------------------------------
|
|
515
|
-
// ComfyUI 工作流构建(移植自 anima comfyui_workflows)
|
|
516
|
-
// ------------------------------------------------------------------
|
|
517
|
-
function animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed) {
|
|
518
|
-
return {
|
|
519
|
-
'44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
|
|
520
|
-
'45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
|
|
521
|
-
'15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
|
|
522
|
-
'28': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
|
523
|
-
'11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
|
|
524
|
-
'12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
|
|
525
|
-
'19': {
|
|
526
|
-
class_type: 'KSampler',
|
|
527
|
-
inputs: {
|
|
528
|
-
model: ['44', 0],
|
|
529
|
-
positive: ['11', 0],
|
|
530
|
-
negative: ['12', 0],
|
|
531
|
-
latent_image: ['28', 0],
|
|
532
|
-
seed,
|
|
533
|
-
steps,
|
|
534
|
-
cfg: cfgVal,
|
|
535
|
-
sampler_name: cfg.samplerName,
|
|
536
|
-
scheduler: cfg.scheduler,
|
|
537
|
-
denoise: 1,
|
|
538
|
-
},
|
|
539
|
-
},
|
|
540
|
-
'8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
|
|
541
|
-
'9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm' } },
|
|
542
|
-
}
|
|
543
|
-
}
|
|
544
|
-
|
|
545
|
-
function buildWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
|
|
546
|
-
if (cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
|
|
547
|
-
return customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize)
|
|
548
|
-
}
|
|
549
|
-
return animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed)
|
|
550
|
-
}
|
|
551
|
-
|
|
552
|
-
// 以图生图工作流(p-draw i2i):LoadImage → VAEEncode → KSampler(denoise<1)
|
|
553
|
-
// 输入图编码为 latent 作为起点,尺寸保持原图,去噪强度由 cfg.img2imgDenoise 控制。
|
|
554
|
-
function animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) {
|
|
555
|
-
return {
|
|
556
|
-
'44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
|
|
557
|
-
'45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
|
|
558
|
-
'15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
|
|
559
|
-
'13': { class_type: 'LoadImage', inputs: { image: inputImage } },
|
|
560
|
-
'30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
|
|
561
|
-
'11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
|
|
562
|
-
'12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
|
|
563
|
-
'19': {
|
|
564
|
-
class_type: 'KSampler',
|
|
565
|
-
inputs: {
|
|
566
|
-
model: ['44', 0],
|
|
567
|
-
positive: ['11', 0],
|
|
568
|
-
negative: ['12', 0],
|
|
569
|
-
latent_image: ['30', 0],
|
|
570
|
-
seed,
|
|
571
|
-
steps,
|
|
572
|
-
cfg: cfgVal,
|
|
573
|
-
sampler_name: cfg.samplerName,
|
|
574
|
-
scheduler: cfg.scheduler,
|
|
575
|
-
denoise,
|
|
576
|
-
},
|
|
577
|
-
},
|
|
578
|
-
'8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
|
|
579
|
-
'9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i' } },
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
// 「换风格(漫画化)」i2i 工作流:LoadImage → 预处理器(LineArt / Canny)→ AnimaLLLiteApply_sdscripts 锁构图,
|
|
584
|
-
// VAEEncode 原图作起点,KSampler 用较高 denoise 整体转二次元风格。
|
|
585
|
-
// 注意:Anima 是 MiniTrainDIT 架构(隐藏层 3584 维),与 Qwen-Image 系 ControlNet 不兼容
|
|
586
|
-
// (InstantX 控制网期望 3584 维文本嵌入,Anima 只给 1024 维 → LayerNorm 崩溃)。
|
|
587
|
-
// 因此这里使用 Anima 原生支持的 ControlNet-LLLite(kohya-ss/ComfyUI-Anima-LLLite),
|
|
588
|
-
// 节点直接对 MODEL 打补丁并返回补丁后的模型,交给 KSampler。
|
|
589
|
-
// 预处理器:comfyui_controlnet_aux 的 AnimeLineArt/LineArt 节点(需安装该自定义节点);
|
|
590
|
-
// 未安装时回退 ComfyUI 内置 Canny。anima-lllite-lineart 权重是按 lineart 训练的,用 LineArt 比 Canny 更贴。
|
|
591
|
-
function animaStyleI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, controlNetModel, controlNetStrength, denoise, preprocessor) {
|
|
592
|
-
const detectRes = Math.max(width, height)
|
|
593
|
-
const preprocessorNode = preprocessor === 'AnimeLineArtPreprocessor' || preprocessor === 'LineArtPreprocessor'
|
|
594
|
-
? { class_type: preprocessor, inputs: { image: ['13', 0], detect_resolution: detectRes, resolution: detectRes } }
|
|
595
|
-
: { class_type: 'Canny', inputs: { image: ['13', 0], low_threshold: 0.4, high_threshold: 0.8 } }
|
|
596
|
-
return {
|
|
597
|
-
'44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.i2iUnetName || cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
|
|
598
|
-
'45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
|
|
599
|
-
'15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
|
|
600
|
-
'13': { class_type: 'LoadImage', inputs: { image: inputImage } },
|
|
601
|
-
'30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
|
|
602
|
-
'11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
|
|
603
|
-
'12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
|
|
604
|
-
'70': preprocessorNode,
|
|
605
|
-
'71': { class_type: 'AnimaLLLiteApply_sdscripts', inputs: { model: ['44', 0], lllite_name: controlNetModel, image: ['70', 0], strength: controlNetStrength, start_percent: 0, end_percent: 1, preserve_wrapper: true } },
|
|
606
|
-
'19': {
|
|
607
|
-
class_type: 'KSampler',
|
|
608
|
-
inputs: {
|
|
609
|
-
model: ['71', 0],
|
|
610
|
-
positive: ['11', 0],
|
|
611
|
-
negative: ['12', 0],
|
|
612
|
-
latent_image: ['30', 0],
|
|
613
|
-
seed,
|
|
614
|
-
steps,
|
|
615
|
-
cfg: cfgVal,
|
|
616
|
-
sampler_name: cfg.samplerName,
|
|
617
|
-
scheduler: cfg.scheduler,
|
|
618
|
-
denoise,
|
|
619
|
-
},
|
|
620
|
-
},
|
|
621
|
-
'8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
|
|
622
|
-
'9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i_style' } },
|
|
623
|
-
}
|
|
624
|
-
}
|
|
625
|
-
|
|
626
|
-
// 「换装换姿势」i2i 工作流:LoadImage → AnimaSiglipeEncodeImage → AnimaIPAdapterApply 保角色特征,
|
|
627
|
-
// VAEEncode 原图作起点,KSampler 用中等 denoise 重画服装/姿势/场景。
|
|
628
|
-
function animaOotdI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, ipAdapterPath, ipAdapterWeight, denoise) {
|
|
629
|
-
return {
|
|
630
|
-
'44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
|
|
631
|
-
'45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
|
|
632
|
-
'15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
|
|
633
|
-
'13': { class_type: 'LoadImage', inputs: { image: inputImage } },
|
|
634
|
-
'30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
|
|
635
|
-
'11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
|
|
636
|
-
'12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
|
|
637
|
-
'80': { class_type: 'AnimaIPAdapterLoader', inputs: { ipadapter_path: ipAdapterPath } },
|
|
638
|
-
'81': { class_type: 'AnimaSiglipeEncodeImage', inputs: { image: ['13', 0] } },
|
|
639
|
-
'82': { class_type: 'AnimaIPAdapterApply', inputs: { model: ['44', 0], ipadapter: ['80', 0], siglip_features: ['81', 0], start_at: 0, end_at: 1, weight: ipAdapterWeight } },
|
|
640
|
-
'19': {
|
|
641
|
-
class_type: 'KSampler',
|
|
642
|
-
inputs: {
|
|
643
|
-
model: ['82', 0],
|
|
644
|
-
positive: ['11', 0],
|
|
645
|
-
negative: ['12', 0],
|
|
646
|
-
latent_image: ['30', 0],
|
|
647
|
-
seed,
|
|
648
|
-
steps,
|
|
649
|
-
cfg: cfgVal,
|
|
650
|
-
sampler_name: cfg.samplerName,
|
|
651
|
-
scheduler: cfg.scheduler,
|
|
652
|
-
denoise,
|
|
653
|
-
},
|
|
654
|
-
},
|
|
655
|
-
'8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
|
|
656
|
-
'9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i_ootd' } },
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
// 根据 i2i 模式与已检测到的节点能力,决定最终工作流与去噪强度。
|
|
661
|
-
// mode:style=换风格(漫画化)/ ootd=换装换姿势 / 其他=基础 img2img
|
|
662
|
-
// caps:{ controlNet: { available, model }, ipAdapter: { available } }(由 detectI2ICapabilities 返回)
|
|
663
|
-
// 返回 { promptBody, kind, denoise },kind 为 plain / controlnet / ipadapter。
|
|
664
|
-
function buildI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, mode, denoiseOverride, caps) {
|
|
665
|
-
const fallbackDenoise = Number(cfg.img2imgDenoise) || 0.55
|
|
666
|
-
if (mode === 'style') {
|
|
667
|
-
if (caps && caps.controlNet && caps.controlNet.available && caps.controlNet.model) {
|
|
668
|
-
const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iStyleDenoise) || 0.75)
|
|
669
|
-
return {
|
|
670
|
-
kind: 'controlnet',
|
|
671
|
-
denoise,
|
|
672
|
-
promptBody: animaStyleI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, caps.controlNet.model, Number(cfg.i2iControlNetStrength) || 0.7, denoise, caps.controlNet.preprocessor),
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
// 没有 ControlNet:换风格又不想崩构图,denoise 自动压到 0.5 以内
|
|
676
|
-
const denoise = denoiseOverride != null ? denoiseOverride : Math.min(Number(cfg.i2iStyleDenoise) || 0.75, 0.5)
|
|
677
|
-
return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
|
|
678
|
-
}
|
|
679
|
-
if (mode === 'ootd') {
|
|
680
|
-
if (caps && caps.ipAdapter && caps.ipAdapter.available && String(cfg.i2iIPAdapterPath || '').trim()) {
|
|
681
|
-
const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iOotdDenoise) || 0.55)
|
|
682
|
-
return {
|
|
683
|
-
kind: 'ipadapter',
|
|
684
|
-
denoise,
|
|
685
|
-
promptBody: animaOotdI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, String(cfg.i2iIPAdapterPath).trim(), Number(cfg.i2iIPAdapterWeight) || 0.8, denoise),
|
|
686
|
-
}
|
|
687
|
-
}
|
|
688
|
-
const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iOotdDenoise) || 0.55)
|
|
689
|
-
return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
|
|
690
|
-
}
|
|
691
|
-
const denoise = denoiseOverride != null ? denoiseOverride : fallbackDenoise
|
|
692
|
-
return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
function customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
|
|
696
|
-
const rawPath = path.resolve(__dirname, cfg.customWorkflowPath)
|
|
697
|
-
let raw
|
|
698
|
-
try {
|
|
699
|
-
raw = JSON.parse(fs.readFileSync(rawPath, 'utf-8'))
|
|
700
|
-
} catch (e) {
|
|
701
|
-
throw new Error(`自定义工作流加载失败:${e.message}`)
|
|
702
|
-
}
|
|
703
|
-
const body = raw && typeof raw === 'object' && raw.prompt && typeof raw.prompt === 'object' ? raw.prompt : raw
|
|
704
|
-
if (!body || typeof body !== 'object') throw new Error('自定义工作流 JSON 无效')
|
|
705
|
-
|
|
706
|
-
const workflow = JSON.parse(JSON.stringify(body))
|
|
707
|
-
const textNodes = []
|
|
708
|
-
for (const [nodeId, node] of Object.entries(workflow)) {
|
|
709
|
-
if (!node || typeof node !== 'object') continue
|
|
710
|
-
const classType = String(node.class_type || '')
|
|
711
|
-
const inputs = node.inputs
|
|
712
|
-
if (classType.includes('TextEncode') && inputs && typeof inputs.text === 'string') {
|
|
713
|
-
textNodes.push(String(nodeId))
|
|
714
|
-
}
|
|
715
|
-
}
|
|
716
|
-
const positiveIds = conditioningTextNodeIds(workflow, 'positive', textNodes)
|
|
717
|
-
const negativeIds = conditioningTextNodeIds(workflow, 'negative', textNodes)
|
|
718
|
-
if (!positiveIds.length) throw new Error('自定义工作流中找不到正面提示词节点')
|
|
719
|
-
if (!negativeIds.length) throw new Error('自定义工作流中找不到负面提示词节点')
|
|
720
|
-
if (positiveIds.some(id => negativeIds.includes(id))) throw new Error('自定义工作流正负面节点有歧义')
|
|
721
|
-
|
|
722
|
-
for (const nodeId of positiveIds) {
|
|
723
|
-
if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = prompt
|
|
724
|
-
}
|
|
725
|
-
for (const nodeId of negativeIds) {
|
|
726
|
-
if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = negativePrompt
|
|
727
|
-
}
|
|
728
|
-
|
|
729
|
-
for (const node of Object.values(workflow)) {
|
|
730
|
-
if (!node || typeof node !== 'object') continue
|
|
731
|
-
const classType = String(node.class_type || '')
|
|
732
|
-
const inputs = node.inputs
|
|
733
|
-
if (!inputs || typeof inputs !== 'object') continue
|
|
734
|
-
if (classType === 'SaveImage' && 'filename_prefix' in inputs) {
|
|
735
|
-
inputs.filename_prefix = 'pdraw/anm'
|
|
736
|
-
}
|
|
737
|
-
const override = Boolean(cfg.customWorkflowOverrideParameters)
|
|
738
|
-
if ((override || explicitSize) && classType === 'EmptyLatentImage') {
|
|
739
|
-
if ('width' in inputs) inputs.width = width
|
|
740
|
-
if ('height' in inputs) inputs.height = height
|
|
741
|
-
}
|
|
742
|
-
if (override && (classType === 'KSampler' || classType === 'KSamplerAdvanced')) {
|
|
743
|
-
if ('steps' in inputs) inputs.steps = steps
|
|
744
|
-
if ('cfg' in inputs) inputs.cfg = cfgVal
|
|
745
|
-
if (cfg.samplerName && 'sampler_name' in inputs) inputs.sampler_name = cfg.samplerName
|
|
746
|
-
if (cfg.scheduler && 'scheduler' in inputs) inputs.scheduler = cfg.scheduler
|
|
747
|
-
}
|
|
748
|
-
if ('seed' in inputs) inputs.seed = seed
|
|
749
|
-
if ('noise_seed' in inputs) inputs.noise_seed = seed
|
|
750
|
-
}
|
|
751
|
-
return workflow
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
function conditioningTextNodeIds(workflow, inputName, textNodes) {
|
|
755
|
-
const pending = []
|
|
756
|
-
for (const node of Object.values(workflow)) {
|
|
757
|
-
if (!node || typeof node !== 'object') continue
|
|
758
|
-
if (!['KSampler', 'KSamplerAdvanced'].includes(String(node.class_type || ''))) continue
|
|
759
|
-
const link = node.inputs && node.inputs[inputName]
|
|
760
|
-
if (Array.isArray(link) && link.length) pending.push(String(link[0]))
|
|
761
|
-
}
|
|
762
|
-
const found = []
|
|
763
|
-
const visited = new Set()
|
|
764
|
-
while (pending.length) {
|
|
765
|
-
const nodeId = pending.pop()
|
|
766
|
-
if (visited.has(nodeId)) continue
|
|
767
|
-
visited.add(nodeId)
|
|
768
|
-
if (textNodes.includes(nodeId)) {
|
|
769
|
-
found.push(nodeId)
|
|
770
|
-
continue
|
|
771
|
-
}
|
|
772
|
-
const node = workflow[nodeId]
|
|
773
|
-
const inputs = node && node.inputs
|
|
774
|
-
if (!inputs || typeof inputs !== 'object') continue
|
|
775
|
-
for (const value of Object.values(inputs)) {
|
|
776
|
-
if (Array.isArray(value) && value.length) {
|
|
777
|
-
const sourceId = String(value[0])
|
|
778
|
-
if (workflow[sourceId]) pending.push(sourceId)
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
return found
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function outputImages(history) {
|
|
786
|
-
const images = []
|
|
787
|
-
const outputs = history.outputs || {}
|
|
788
|
-
if (outputs && typeof outputs === 'object') {
|
|
789
|
-
for (const nodeOutput of Object.values(outputs)) {
|
|
790
|
-
if (!nodeOutput || typeof nodeOutput !== 'object') continue
|
|
791
|
-
for (const image of nodeOutput.images || []) {
|
|
792
|
-
if (image && typeof image === 'object') images.push(image)
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
}
|
|
796
|
-
return images
|
|
797
|
-
}
|
|
798
|
-
|
|
799
|
-
// ------------------------------------------------------------------
|
|
800
|
-
// ComfyUI 结果等待:优先 WebSocket 事件,失败/不可用回退轮询
|
|
801
|
-
// ------------------------------------------------------------------
|
|
802
|
-
function waitViaWebSocket(baseUrl, promptId, clientId, timeoutMs) {
|
|
803
|
-
return new Promise((resolve) => {
|
|
804
|
-
let socket
|
|
805
|
-
let timer = null
|
|
806
|
-
let settled = false
|
|
807
|
-
const finish = (ok) => {
|
|
808
|
-
if (settled) return
|
|
809
|
-
settled = true
|
|
810
|
-
if (timer) clearTimeout(timer)
|
|
811
|
-
try { if (socket) socket.close() } catch (e) { /* ignore */ }
|
|
812
|
-
resolve(ok)
|
|
813
|
-
}
|
|
814
|
-
try {
|
|
815
|
-
const wsUrl = baseUrl.replace(/^https:/i, 'wss:').replace(/^http:/i, 'ws:') + `/ws?clientId=${encodeURIComponent(clientId)}`
|
|
816
|
-
socket = new WebSocket(wsUrl)
|
|
817
|
-
} catch (e) {
|
|
818
|
-
finish(false)
|
|
819
|
-
return
|
|
820
|
-
}
|
|
821
|
-
timer = setTimeout(() => finish(false), timeoutMs)
|
|
822
|
-
socket.onmessage = (ev) => {
|
|
823
|
-
let msg
|
|
824
|
-
try { msg = JSON.parse(String(ev.data)) } catch (e) { return }
|
|
825
|
-
if (!msg || typeof msg !== 'object') return
|
|
826
|
-
if (msg.type === 'execution_success' && msg.data && msg.data.prompt_id === promptId) { finish(true); return }
|
|
827
|
-
if (msg.type === 'execution_error' || msg.type === 'execution_interrupted') { finish(false) }
|
|
828
|
-
}
|
|
829
|
-
socket.onerror = () => finish(false)
|
|
830
|
-
socket.onclose = () => finish(false)
|
|
831
|
-
})
|
|
832
|
-
}
|
|
833
|
-
|
|
834
|
-
async function waitComfyResult(ctx, comfyGet, baseUrl, promptId, clientId, timeoutMs, pollMs) {
|
|
835
|
-
const deadline = Date.now() + timeoutMs
|
|
836
|
-
if (typeof WebSocket !== 'undefined') {
|
|
837
|
-
const remaining = Math.max(0, deadline - Date.now())
|
|
838
|
-
try {
|
|
839
|
-
const viaWs = await waitViaWebSocket(baseUrl, promptId, clientId, remaining)
|
|
840
|
-
if (viaWs) {
|
|
841
|
-
try {
|
|
842
|
-
const data = await comfyGet(`/history/${promptId}`, 20000)
|
|
843
|
-
if (data && data[promptId]) return data[promptId]
|
|
844
|
-
} catch (e) { /* fall through */ }
|
|
845
|
-
}
|
|
846
|
-
} catch (e) { /* fall through to polling */ }
|
|
847
|
-
}
|
|
848
|
-
while (Date.now() < deadline) {
|
|
849
|
-
try {
|
|
850
|
-
const data = await comfyGet(`/history/${promptId}`, 20000)
|
|
851
|
-
if (data && data[promptId]) return data[promptId]
|
|
852
|
-
} catch (e) { /* transient */ }
|
|
853
|
-
await ctx.sleep(pollMs)
|
|
854
|
-
}
|
|
855
|
-
return null
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
// ------------------------------------------------------------------
|
|
859
|
-
// Tag 清洗(移植自 anima tag_cleaner)
|
|
860
|
-
// ------------------------------------------------------------------
|
|
861
|
-
function splitTags(text) {
|
|
862
|
-
let cleaned = String(text || '')
|
|
863
|
-
cleaned = cleaned.replace(/```[\s\S]*?```/g, m => m.slice(3, -3).trim())
|
|
864
|
-
cleaned = cleaned.replace(/,/g, ',').replace(/、/g, ',').replace(/;/g, ',')
|
|
865
|
-
cleaned = cleaned.replace(/\n/g, ',')
|
|
866
|
-
cleaned = cleaned.replace(/^(?:positive|prompt|tags|提示词|正向提示词)\s*[::]/i, '')
|
|
867
|
-
const parts = cleaned.split(',').map(p => p.trim().replace(/^[\s,.;::]+|[\s,.;::]+$/g, ''))
|
|
868
|
-
return parts.filter(Boolean)
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
function normalizeTagKey(tag) {
|
|
872
|
-
let value = String(tag || '').trim().toLowerCase()
|
|
873
|
-
if (
|
|
874
|
-
value.startsWith('(') && value.endsWith(')') &&
|
|
875
|
-
(value.match(/\(/g) || []).length === 1 &&
|
|
876
|
-
(value.match(/\)/g) || []).length === 1
|
|
877
|
-
) {
|
|
878
|
-
value = value.slice(1, -1).trim()
|
|
879
|
-
}
|
|
880
|
-
value = value.replace(/:\s*[\d.]+$/, '')
|
|
881
|
-
value = value.replace(/\s+/g, ' ')
|
|
882
|
-
return value
|
|
883
|
-
}
|
|
884
|
-
|
|
885
|
-
function stripWrappingBrackets(text) {
|
|
886
|
-
let value = String(text || '').trim()
|
|
887
|
-
const pairs = { '(': ')', '[': ']', '{': '}' }
|
|
888
|
-
let changed = true
|
|
889
|
-
while (changed && value.length >= 2) {
|
|
890
|
-
changed = false
|
|
891
|
-
const left = value[0]
|
|
892
|
-
const right = pairs[left]
|
|
893
|
-
if (right && value.endsWith(right)) {
|
|
894
|
-
value = value.slice(1, -1).trim()
|
|
895
|
-
changed = true
|
|
896
|
-
}
|
|
897
|
-
}
|
|
898
|
-
return value
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
const ARTIST_FUNCTION_RE = /^artist\s*:\s*([^:=()[\]{}]+?)\s*(?:[:=]\s*[-+]?(?:\d+(?:\.\d+)?|\.\d+)\s*)?$/i
|
|
902
|
-
|
|
903
|
-
function normalizeAnimaArtistTag(tag) {
|
|
904
|
-
const raw = String(tag || '').trim()
|
|
905
|
-
if (!raw) return ''
|
|
906
|
-
if (raw.startsWith('@')) {
|
|
907
|
-
const name = raw.slice(1).trim().replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
|
|
908
|
-
return name ? `@${name}` : raw
|
|
909
|
-
}
|
|
910
|
-
const inner = stripWrappingBrackets(raw)
|
|
911
|
-
const match = ARTIST_FUNCTION_RE.exec(inner)
|
|
912
|
-
if (!match) return raw
|
|
913
|
-
let name = match[1].trim()
|
|
914
|
-
if (name.startsWith('@')) name = name.slice(1).trim()
|
|
915
|
-
name = name.replace(/_/g, ' ').replace(/\s+/g, ' ').trim()
|
|
916
|
-
return name ? `@${name}` : raw
|
|
917
|
-
}
|
|
918
|
-
|
|
919
|
-
function canonicalTagText(tag) {
|
|
920
|
-
const artistTag = normalizeAnimaArtistTag(tag)
|
|
921
|
-
if (artistTag.startsWith('@')) return artistTag
|
|
922
|
-
const key = normalizeTagKey(tag)
|
|
923
|
-
if (key === '1 girl') return '1girl'
|
|
924
|
-
if (key === 'punis') return 'penis'
|
|
925
|
-
if (['point a sword at the audience', 'point a sword at viewer', 'point sword at the audience', 'point sword at viewer'].includes(key)) return 'sword pointed at viewer'
|
|
926
|
-
return String(tag || '').trim()
|
|
927
|
-
}
|
|
928
|
-
|
|
929
|
-
// 内联画师标签(@name / artist:name)与内联质量词(masterpiece / best quality / score_N 等)。
|
|
930
|
-
// 提示词优化会把它们交给 LLM 重写并剥掉,这里在优化后把它们从原始输入中补回,
|
|
931
|
-
// 避免用户直接写在消息里的画师标签 / 质量词丢失。
|
|
932
|
-
const INLINE_QUALITY_RE = /^(masterpiece|best quality|amazing quality|high quality|good quality|score_\d+)$/i
|
|
933
|
-
|
|
934
|
-
function appendInlineProtectedTags(prompt, original, raw) {
|
|
935
|
-
if (raw || !original) return prompt
|
|
936
|
-
if (/(不用我的风格|不要我的风格|不使用我的风格|不要画师词|不用画师词|不加画师词|no artist)/i.test(original)) return prompt
|
|
937
|
-
const tags = []
|
|
938
|
-
const seen = new Set(splitTags(prompt).map(t => normalizeTagKey(t)))
|
|
939
|
-
for (const token of splitTags(original)) {
|
|
940
|
-
const t = String(token || '').trim()
|
|
941
|
-
if (!t) continue
|
|
942
|
-
const artist = normalizeAnimaArtistTag(t)
|
|
943
|
-
if (artist.startsWith('@')) {
|
|
944
|
-
const key = normalizeTagKey(artist)
|
|
945
|
-
if (!seen.has(key)) {
|
|
946
|
-
seen.add(key)
|
|
947
|
-
tags.push(artist)
|
|
948
|
-
}
|
|
949
|
-
continue
|
|
950
|
-
}
|
|
951
|
-
if (INLINE_QUALITY_RE.test(t)) {
|
|
952
|
-
const key = normalizeTagKey(t)
|
|
953
|
-
if (!seen.has(key)) {
|
|
954
|
-
seen.add(key)
|
|
955
|
-
tags.push(t)
|
|
956
|
-
}
|
|
957
|
-
}
|
|
958
|
-
}
|
|
959
|
-
if (!tags.length) return prompt
|
|
960
|
-
return prompt + ', ' + tags.join(', ')
|
|
961
|
-
}
|
|
962
|
-
|
|
963
|
-
const QUALITY_BLOCKLIST = new Set([
|
|
964
|
-
'masterpiece', 'best quality', 'score_7', 'score_6', 'score_5', 'score_4', 'score_3', 'score_2', 'score_1',
|
|
965
|
-
'safe', 'worst quality', 'low quality', 'artist name',
|
|
966
|
-
])
|
|
967
|
-
const CHARACTER_BLOCKLIST = new Set(['1 girl', '1girl', 'solo'])
|
|
968
|
-
const CHARACTER_IDENTITY_EXACT_BLOCKLIST = new Set([
|
|
969
|
-
'girl', 'boy', 'child', 'teenager', 'young adult', 'adult', 'mature', 'loli', 'shota', 'petite', 'aged down', 'age regression',
|
|
970
|
-
'vampire', 'angel', 'demon', 'fox girl', 'cat girl', 'animal girl',
|
|
971
|
-
'ahoge', 'bangs', 'blunt bangs', 'sidelocks', 'hair between eyes', 'long hair', 'short hair', 'medium hair', 'very long hair',
|
|
972
|
-
'twintails', 'low twintails', 'braids', 'side braid', 'ponytail', 'side ponytail', 'one side up', 'hair bun', 'double bun',
|
|
973
|
-
'heterochromia', 'blue eyes', 'red eyes', 'green eyes', 'pink eyes', 'purple eyes', 'yellow eyes', 'golden eyes', 'grey eyes',
|
|
974
|
-
'gray eyes', 'brown eyes', 'black eyes', 'black hair', 'brown hair', 'blonde hair', 'white hair', 'silver hair', 'blue hair',
|
|
975
|
-
'red hair', 'pink hair', 'purple hair', 'green hair', 'grey hair', 'gray hair',
|
|
976
|
-
'fox ears', 'cat ears', 'animal ears', 'pointed ears', 'tail', 'fox tail', 'cat tail', 'wings', 'angel wings', 'demon wings',
|
|
977
|
-
'horns', 'halo', 'fang', 'freckles',
|
|
978
|
-
])
|
|
979
|
-
const CHARACTER_IDENTITY_PATTERNS = [
|
|
980
|
-
/\b(?:black|brown|blonde|white|silver|blue|red|pink|purple|green|grey|gray|orange|gold|golden|light|dark|ice blue|silver white)\s+hair\b/,
|
|
981
|
-
/\b(?:black|brown|blue|red|pink|purple|green|grey|gray|gold|golden|light|dark|ice blue|amber)\s+eyes?\b/,
|
|
982
|
-
/\b(?:ears?|tail|wings?|horns?|halo|fangs?|heterochromia)\b/,
|
|
983
|
-
/\b(?:vampire|angel|demon|fox girl|cat girl|animal girl)\b/,
|
|
984
|
-
/\b(?:loli|shota|teenager|young adult|adult|mature|aged down|age regression)\b/,
|
|
985
|
-
]
|
|
986
|
-
const MULTI_CHARACTER_BLOCKLIST = new Set([
|
|
987
|
-
'2girls', '3girls', '4girls', '5girls', '6+girls', 'multiple girls',
|
|
988
|
-
'2boys', '3boys', '4boys', '5boys', '6+boys', 'multiple boys',
|
|
989
|
-
'multiple people', 'crowd', 'group', 'background characters', 'extra girl', 'extra person', 'clone', 'duplicate', 'twins',
|
|
990
|
-
])
|
|
991
|
-
const NON_VISUAL_TAGS = new Set(['holding nothing'])
|
|
992
|
-
const EXCLUSIVE_TAG_GROUPS = {
|
|
993
|
-
'looking at viewer': 'gaze_target', 'looking away': 'gaze_target',
|
|
994
|
-
'light rays': 'light_beams', 'sun rays': 'light_beams', 'sunbeams': 'light_beams', 'sunlight rays': 'light_beams',
|
|
995
|
-
'glowing': 'light_intensity', 'illuminated': 'light_intensity', 'bright': 'light_intensity', 'luminous': 'light_intensity', 'radiant': 'light_intensity',
|
|
996
|
-
'backlight': 'backlighting', 'backlighting': 'backlighting',
|
|
997
|
-
'rim light': 'rim_lighting', 'rim lighting': 'rim_lighting',
|
|
998
|
-
'soft light': 'soft_lighting', 'soft lighting': 'soft_lighting',
|
|
999
|
-
'floating particles': 'light_particles', 'light particles': 'light_particles', 'glowing particles': 'light_particles',
|
|
1000
|
-
'flowing dress': 'flowing_dress', 'dress flowing': 'flowing_dress',
|
|
1001
|
-
'hair blowing': 'wind_in_hair', 'wind in hair': 'wind_in_hair',
|
|
1002
|
-
'sad expression': 'sad_expression', 'sorrowful expression': 'sad_expression',
|
|
1003
|
-
'teary eyes': 'tearful_eyes', 'watery eyes': 'tearful_eyes', 'wet eyes': 'tearful_eyes',
|
|
1004
|
-
}
|
|
1005
|
-
const TAG_GROUP_LIMITS = { light_intensity: 2 }
|
|
1006
|
-
|
|
1007
|
-
function isCharacterIdentityTag(key) {
|
|
1008
|
-
const compact = normalizeTagKey(key).replace(/_/g, ' ')
|
|
1009
|
-
if (!compact) return false
|
|
1010
|
-
if (CHARACTER_IDENTITY_EXACT_BLOCKLIST.has(compact)) return true
|
|
1011
|
-
return CHARACTER_IDENTITY_PATTERNS.some(pattern => pattern.test(compact))
|
|
1012
|
-
}
|
|
1013
|
-
|
|
1014
|
-
function cleanContentTags(text, maxTags = 65, stripCharacterTags = true, protectedCoreTags = [], allowMultiCharacter = false) {
|
|
1015
|
-
const tags = splitTags(text)
|
|
1016
|
-
const seen = new Set()
|
|
1017
|
-
const cleaned = []
|
|
1018
|
-
const artistRe = /^@\S+/
|
|
1019
|
-
const protectedSet = new Set(protectedCoreTags.map(t => normalizeTagKey(t)))
|
|
1020
|
-
const parenthesizedCoreRe = /^[a-z0-9_.'-]+_\([a-z0-9_.' -]{2,60}\)$/i
|
|
1021
|
-
for (let tag of tags) {
|
|
1022
|
-
tag = canonicalTagText(tag)
|
|
1023
|
-
const key = normalizeTagKey(tag)
|
|
1024
|
-
if (!key) continue
|
|
1025
|
-
if (seen.has(key)) continue
|
|
1026
|
-
if (QUALITY_BLOCKLIST.has(key)) continue
|
|
1027
|
-
if (stripCharacterTags && CHARACTER_BLOCKLIST.has(key)) continue
|
|
1028
|
-
if (stripCharacterTags && isCharacterIdentityTag(key)) continue
|
|
1029
|
-
if (!allowMultiCharacter && MULTI_CHARACTER_BLOCKLIST.has(key)) continue
|
|
1030
|
-
if (protectedSet.size && parenthesizedCoreRe.test(key) && !protectedSet.has(key)) continue
|
|
1031
|
-
if (artistRe.test(tag.trim())) continue
|
|
1032
|
-
if (tag.length > 80) continue
|
|
1033
|
-
seen.add(key)
|
|
1034
|
-
cleaned.push(tag)
|
|
1035
|
-
}
|
|
1036
|
-
const semanticKeys = cleaned.map(tag => normalizeTagKey(stripWrappingBrackets(tag)))
|
|
1037
|
-
const fullNudityKey = semanticKeys.includes('nude') ? 'nude' : 'naked'
|
|
1038
|
-
const hasFullNudity = semanticKeys.includes(fullNudityKey)
|
|
1039
|
-
const hasSpecificMist = semanticKeys.includes('morning mist')
|
|
1040
|
-
const hasClosedEyes = semanticKeys.some(k => k === 'closed eyes' || k === 'eyes closed')
|
|
1041
|
-
const hasSheerFabric = semanticKeys.includes('sheer fabric')
|
|
1042
|
-
const groupCounts = {}
|
|
1043
|
-
const semanticCleaned = []
|
|
1044
|
-
cleaned.forEach((tag, i) => {
|
|
1045
|
-
const key = semanticKeys[i]
|
|
1046
|
-
if (NON_VISUAL_TAGS.has(key)) return
|
|
1047
|
-
if (hasFullNudity && ['nude', 'naked', 'topless', 'bottomless'].includes(key)) {
|
|
1048
|
-
if (key !== fullNudityKey) return
|
|
1049
|
-
}
|
|
1050
|
-
if (hasSpecificMist && key === 'mist') return
|
|
1051
|
-
if (hasClosedEyes && key.includes('looking') && key.includes('viewer')) return
|
|
1052
|
-
if (hasSheerFabric && key === 'translucent fabric') return
|
|
1053
|
-
const group = EXCLUSIVE_TAG_GROUPS[key.replace(/_/g, ' ')]
|
|
1054
|
-
if (group) {
|
|
1055
|
-
const count = groupCounts[group] || 0
|
|
1056
|
-
if (count >= (TAG_GROUP_LIMITS[group] != null ? TAG_GROUP_LIMITS[group] : 1)) return
|
|
1057
|
-
groupCounts[group] = count + 1
|
|
1058
|
-
}
|
|
1059
|
-
semanticCleaned.push(tag)
|
|
1060
|
-
})
|
|
1061
|
-
return semanticCleaned.slice(0, maxTags).join(', ')
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
function joinPromptParts(parts) {
|
|
1065
|
-
const tags = []
|
|
1066
|
-
const seen = new Set()
|
|
1067
|
-
for (const part of parts) {
|
|
1068
|
-
for (const tag of splitTags(part)) {
|
|
1069
|
-
const canonical = canonicalTagText(tag)
|
|
1070
|
-
const key = normalizeTagKey(canonical)
|
|
1071
|
-
if (!key || seen.has(key)) continue
|
|
1072
|
-
seen.add(key)
|
|
1073
|
-
tags.push(canonical)
|
|
1074
|
-
}
|
|
1075
|
-
}
|
|
1076
|
-
return tags.join(', ')
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// ------------------------------------------------------------------
|
|
1080
|
-
// 多人规划(移植自 anima multi_person_prompt)
|
|
1081
|
-
// ------------------------------------------------------------------
|
|
1082
|
-
const MULTI_PERSON_NEGATIVE_TAGS = [
|
|
1083
|
-
'split screen', 'comic panels', 'multiple views', 'character sheet',
|
|
1084
|
-
'duplicate characters', 'cloned character', 'extra person', 'extra girl', 'extra boy',
|
|
1085
|
-
'twins', 'merged bodies', 'fused characters',
|
|
1086
|
-
]
|
|
1087
|
-
|
|
1088
|
-
const MULTI_SAFE_SLOTS = new Set(['left', 'right', 'center', 'foreground', 'background', 'far left', 'far right'])
|
|
1089
|
-
const MULTI_UNSAFE_COMPOSITION_MARKERS = [
|
|
1090
|
-
'split screen', 'panel', 'multiple views', 'alternate views', 'character sheet',
|
|
1091
|
-
'top left', 'top right', 'bottom left', 'bottom right',
|
|
1092
|
-
]
|
|
1093
|
-
const MULTI_SAFE_SPATIAL_MODES = new Set(['shared_contact', 'shared_scene', 'explicit_positions'])
|
|
1094
|
-
|
|
1095
|
-
function buildMultiPersonPlanPrompt(userPrompt, fixedCharacters = {}) {
|
|
1096
|
-
const fixedNote = Object.keys(fixedCharacters).length
|
|
1097
|
-
? `Locally saved characters explicitly mentioned by the user:\n${JSON.stringify(fixedCharacters, null, 2)}`
|
|
1098
|
-
: 'No locally saved character name was detected.'
|
|
1099
|
-
return `Plan one coherent Anima image containing 2 to 4 people.
|
|
1100
|
-
|
|
1101
|
-
Use the user's requested identities, count, clothing, expressions, props, positions, and relationships. You may freely design compatible mutable details, background, lighting, and atmosphere when the user leaves them open.
|
|
1102
|
-
|
|
1103
|
-
Separate every person into an independent semantic block. Position slots are bookkeeping only and must never describe separate regions, panels, views, or sides of the image. Prefer one shared central group. Use explicit positions only when the user directly asks for left/right or foreground/background placement. Never use top_left, top_right, bottom_left, bottom_right, upper, lower, panel, or "side of the image".
|
|
1104
|
-
|
|
1105
|
-
For an existing named character, preserve the user's written name in "name" and provide the most likely Danbooru character tag in "danbooru_candidate". For an original or generic person, leave "danbooru_candidate" empty.
|
|
1106
|
-
|
|
1107
|
-
When a person matches one of the locally saved characters below, their saved tags are authoritative. Leave "appearance" empty and do not restate or alter their hair, eyes, species, ears, tail, body type, age, or fixed accessories. Only plan mutable clothing, expression, pose, and props.
|
|
1108
|
-
|
|
1109
|
-
Return JSON only with this exact shape:
|
|
1110
|
-
{
|
|
1111
|
-
"count_tags": ["2girls"],
|
|
1112
|
-
"common_tags": ["medium shot", "outdoors"],
|
|
1113
|
-
"characters": [
|
|
1114
|
-
{
|
|
1115
|
-
"slot": "left",
|
|
1116
|
-
"name": "character name from the user",
|
|
1117
|
-
"danbooru_candidate": "romanized_character_tag",
|
|
1118
|
-
"role": "short semantic role such as rider or supporting girl",
|
|
1119
|
-
"visual_label": "distinctive visible label such as white-haired fox girl",
|
|
1120
|
-
"identity_anchors": ["3 to 6 short appearance tags"],
|
|
1121
|
-
"emphasized_anchors": ["0 to 3 explicitly requested unusual traits"],
|
|
1122
|
-
"appearance": "Visible identity traits for a non-fixed character only; empty for a locally saved character.",
|
|
1123
|
-
"clothing": "One concise English clothing phrase.",
|
|
1124
|
-
"expression": "One concise English expression phrase.",
|
|
1125
|
-
"pose": "One concise English body pose that does not repeat the interaction.",
|
|
1126
|
-
"props": ["visible prop held or worn by this person"]
|
|
1127
|
-
},
|
|
1128
|
-
{
|
|
1129
|
-
"slot": "right",
|
|
1130
|
-
"name": "second character name from the user",
|
|
1131
|
-
"danbooru_candidate": "romanized_character_tag",
|
|
1132
|
-
"appearance": "",
|
|
1133
|
-
"clothing": "One concise English clothing phrase.",
|
|
1134
|
-
"expression": "One concise English expression phrase.",
|
|
1135
|
-
"pose": "One concise English body pose.",
|
|
1136
|
-
"props": []
|
|
1137
|
-
}
|
|
1138
|
-
],
|
|
1139
|
-
"relationship_tag": "holding hands",
|
|
1140
|
-
"interactions": [
|
|
1141
|
-
"Character A is holding Character B's hand."
|
|
1142
|
-
],
|
|
1143
|
-
"spatial_mode": "shared_contact",
|
|
1144
|
-
"composition": "A single unified full-frame composition using one camera view."
|
|
1145
|
-
}
|
|
1146
|
-
|
|
1147
|
-
Rules:
|
|
1148
|
-
- Include exactly 2 to 4 character objects.
|
|
1149
|
-
- count_tags must agree with the number and genders requested by the user.
|
|
1150
|
-
- common_tags contain only shared scene, framing, camera, lighting, atmosphere, and count tags.
|
|
1151
|
-
- relationship_tag is one short Danbooru-style relationship or action tag and appears immediately after the count tags in the final prompt.
|
|
1152
|
-
- Do not put character names or character-specific appearance in common_tags.
|
|
1153
|
-
- role is optional semantic bookkeeping and is not used to identify a person in the final interaction sentence.
|
|
1154
|
-
- visual_label must be a unique 2 to 6 word visible description derived from identity_anchors, such as "white-haired fox girl" or "silver-haired vampire girl". Do not use names, ordinal labels, rider, supporter, top, bottom, left, or right as visual_label.
|
|
1155
|
-
- identity_anchors must contain only 3 to 6 concise visible identity traits. For locally saved characters, select them only from the saved defining tags.
|
|
1156
|
-
- emphasized_anchors may contain at most 3 identity_anchors that the user explicitly requested and that are unusual, contrastive, or likely to be confused between people. Never invent emphasis.
|
|
1157
|
-
- For locally saved characters, appearance must be empty and saved defining tags must never be contradicted.
|
|
1158
|
-
- Do not output quality tags, safety tags, artist tags, Markdown, or explanations.
|
|
1159
|
-
- Keep character fields and relationships visually concrete.
|
|
1160
|
-
- Preserve the user's explicit interaction direction and gaze direction.
|
|
1161
|
-
- Put the complete directed relationship in exactly one interactions entry. Character pose fields must not repeat the relationship.
|
|
1162
|
-
- Refer to people inside interactions exclusively as Character A, Character B, Character C, or Character D. Never use their names, translated names, or Danbooru tags there.
|
|
1163
|
-
- spatial_mode must be shared_contact for physical interaction, shared_scene for a non-contact group, or explicit_positions only when the user explicitly requests relative positions.
|
|
1164
|
-
- Prefer a single coherent moment rather than multiple competing actions.
|
|
1165
|
-
- composition must use affirmative language to request one unified full-frame camera view.
|
|
1166
|
-
|
|
1167
|
-
${fixedNote}
|
|
1168
|
-
|
|
1169
|
-
User request:
|
|
1170
|
-
${userPrompt}
|
|
1171
|
-
`
|
|
1172
|
-
}
|
|
1173
|
-
|
|
1174
|
-
function cleanMultiText(value, limit) {
|
|
1175
|
-
return String(value || '').replace(/\s+/g, ' ').trim().slice(0, limit).trim()
|
|
1176
|
-
}
|
|
1177
|
-
|
|
1178
|
-
function multiStringTuple(value, limit, itemLimit) {
|
|
1179
|
-
if (!Array.isArray(value)) return []
|
|
1180
|
-
const result = []
|
|
1181
|
-
for (const item of value) {
|
|
1182
|
-
const text = cleanMultiText(item, itemLimit)
|
|
1183
|
-
if (text) result.push(text)
|
|
1184
|
-
}
|
|
1185
|
-
return result.slice(0, limit)
|
|
1186
|
-
}
|
|
1187
|
-
|
|
1188
|
-
function normalizeMultiSlot(value) {
|
|
1189
|
-
const slot = cleanMultiText(value, 40).toLowerCase().replace(/_/g, ' ').replace(/-/g, ' ').replace(/\s+/g, ' ').trim()
|
|
1190
|
-
return MULTI_SAFE_SLOTS.has(slot) ? slot : ''
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
function parseMultiPersonPlan(text) {
|
|
1194
|
-
let raw = String(text || '').trim()
|
|
1195
|
-
raw = raw.replace(/^```(?:json)?\s*/i, '')
|
|
1196
|
-
raw = raw.replace(/\s*```$/, '')
|
|
1197
|
-
const match = raw.match(/\{[\s\S]*\}/)
|
|
1198
|
-
if (match) raw = match[0]
|
|
1199
|
-
let data
|
|
1200
|
-
try {
|
|
1201
|
-
data = JSON.parse(raw)
|
|
1202
|
-
} catch (e) {
|
|
1203
|
-
return null
|
|
1204
|
-
}
|
|
1205
|
-
if (!data || typeof data !== 'object') return null
|
|
1206
|
-
const rawCharacters = data.characters
|
|
1207
|
-
if (!Array.isArray(rawCharacters) || rawCharacters.length < 2 || rawCharacters.length > 4) return null
|
|
1208
|
-
if (rawCharacters.some(item => !item || typeof item !== 'object')) return null
|
|
1209
|
-
|
|
1210
|
-
const defaultSlots = {
|
|
1211
|
-
2: ['left', 'right'],
|
|
1212
|
-
3: ['left', 'center', 'right'],
|
|
1213
|
-
4: ['far left', 'left', 'right', 'far right'],
|
|
1214
|
-
}[rawCharacters.length]
|
|
1215
|
-
const proposedSlots = rawCharacters.map(item => normalizeMultiSlot(item.slot))
|
|
1216
|
-
if (
|
|
1217
|
-
proposedSlots.some(slot => !slot) ||
|
|
1218
|
-
new Set(proposedSlots).size !== proposedSlots.length ||
|
|
1219
|
-
(rawCharacters.length === 2 && !(new Set(proposedSlots).size === 2 && ['left', 'right'].every(s => proposedSlots.includes(s)) || ['foreground', 'background'].every(s => proposedSlots.includes(s))))
|
|
1220
|
-
) {
|
|
1221
|
-
proposedSlots.splice(0, proposedSlots.length, ...defaultSlots)
|
|
1222
|
-
}
|
|
1223
|
-
|
|
1224
|
-
const characters = proposedSlots.map((slot, index) => {
|
|
1225
|
-
const item = rawCharacters[index]
|
|
1226
|
-
return {
|
|
1227
|
-
slot,
|
|
1228
|
-
name: cleanMultiText(item.name, 120),
|
|
1229
|
-
danbooru_candidate: cleanMultiText(item.danbooru_candidate, 160),
|
|
1230
|
-
appearance: cleanMultiText(item.appearance, 500),
|
|
1231
|
-
clothing: cleanMultiText(item.clothing, 400),
|
|
1232
|
-
expression: cleanMultiText(item.expression, 240),
|
|
1233
|
-
pose: cleanMultiText(item.pose, 400),
|
|
1234
|
-
props: multiStringTuple(item.props, 12, 120),
|
|
1235
|
-
role: cleanMultiText(item.role, 80),
|
|
1236
|
-
visual_label: cleanMultiText(item.visual_label, 100),
|
|
1237
|
-
identity_anchors: multiStringTuple(item.identity_anchors, 6, 100),
|
|
1238
|
-
emphasized_anchors: multiStringTuple(item.emphasized_anchors, 3, 100),
|
|
1239
|
-
}
|
|
1240
|
-
})
|
|
1241
|
-
|
|
1242
|
-
const countTags = multiStringTuple(data.count_tags, 8, 80)
|
|
1243
|
-
const commonTags = multiStringTuple(data.common_tags, 50, 100)
|
|
1244
|
-
const interactions = multiStringTuple(data.interactions, 1, 500)
|
|
1245
|
-
const composition = cleanMultiText(data.composition, 700)
|
|
1246
|
-
let spatialMode = cleanMultiText(data.spatial_mode, 40).toLowerCase()
|
|
1247
|
-
const relationshipTag = cleanMultiText(data.relationship_tag, 120)
|
|
1248
|
-
if (!MULTI_SAFE_SPATIAL_MODES.has(spatialMode)) spatialMode = interactions.length ? 'shared_contact' : 'shared_scene'
|
|
1249
|
-
let compositionSafe = composition
|
|
1250
|
-
if (MULTI_UNSAFE_COMPOSITION_MARKERS.some(marker => compositionSafe.toLowerCase().includes(marker))) compositionSafe = ''
|
|
1251
|
-
return {
|
|
1252
|
-
count_tags: countTags.length ? countTags : [`${characters.length}people`],
|
|
1253
|
-
common_tags: commonTags,
|
|
1254
|
-
characters,
|
|
1255
|
-
interactions,
|
|
1256
|
-
composition: compositionSafe,
|
|
1257
|
-
spatial_mode: spatialMode,
|
|
1258
|
-
relationship_tag: relationshipTag,
|
|
1259
|
-
}
|
|
1260
|
-
}
|
|
1261
|
-
|
|
1262
|
-
function renderMultiPersonCharacter(character, opts = {}) {
|
|
1263
|
-
const {
|
|
1264
|
-
alias = '', resolvedIdentity = '', fixedTags = '', groupedContact = false,
|
|
1265
|
-
explicitPositions = false, identityAnchors = [], includePose = true, asTagStream = false,
|
|
1266
|
-
} = opts
|
|
1267
|
-
let label = String(alias || character.visual_label || character.role || '').trim()
|
|
1268
|
-
if (explicitPositions && character.slot) label = `${character.slot} ${label}`
|
|
1269
|
-
const identity = String(resolvedIdentity || character.danbooru_candidate || '').trim()
|
|
1270
|
-
const details = []
|
|
1271
|
-
if (identity && !fixedTags) details.push(identity)
|
|
1272
|
-
if (identityAnchors.length) {
|
|
1273
|
-
details.push(...identityAnchors)
|
|
1274
|
-
} else if (fixedTags) {
|
|
1275
|
-
for (const part of fixedTags.split(',')) {
|
|
1276
|
-
const t = part.trim().replace(/^ +| +$/g, '').replace(/^\(|\)$/g, '')
|
|
1277
|
-
if (t) details.push(t)
|
|
1278
|
-
}
|
|
1279
|
-
}
|
|
1280
|
-
if (!identityAnchors.length && !fixedTags && character.appearance) details.push(character.appearance)
|
|
1281
|
-
if (character.clothing) details.push(character.clothing)
|
|
1282
|
-
if (character.expression) details.push(character.expression)
|
|
1283
|
-
if (includePose && character.pose) details.push(character.pose)
|
|
1284
|
-
if (character.props && character.props.length) details.push(...character.props)
|
|
1285
|
-
const joined = details.filter(Boolean).join(', ')
|
|
1286
|
-
if (asTagStream) return [label, joined].filter(Boolean).join(', ')
|
|
1287
|
-
return `${label}: ${joined}.`
|
|
1288
|
-
}
|
|
1289
|
-
|
|
1290
|
-
// ------------------------------------------------------------------
|
|
1291
|
-
// 多人尺寸自动选择(移植自 anima command_actions multi_person 分支)
|
|
1292
|
-
// ------------------------------------------------------------------
|
|
1293
|
-
function multiPersonAutoSize(prompt, allowedSizes) {
|
|
1294
|
-
if (!Array.isArray(allowedSizes) || !allowedSizes.length) return null
|
|
1295
|
-
const promptLower = String(prompt || '').toLowerCase()
|
|
1296
|
-
const threeOrMore = /\b(?:三|四|3|4)\s*(?:人|个|名|girls?|boys?|people)\b|\b(?:3|4)(?:girls?|boys?|people)\b/.test(promptLower)
|
|
1297
|
-
const verticallyStacked = [
|
|
1298
|
-
'骑在肩', '骑肩', '肩膀上', '背着', '抱起', '扑倒', '压在', '上下叠',
|
|
1299
|
-
'on the shoulders', 'piggyback', 'carrying', 'on top of', 'stacked',
|
|
1300
|
-
].some(marker => promptLower.includes(marker))
|
|
1301
|
-
const physicalContact = [
|
|
1302
|
-
'牵手', '拥抱', '接吻', '搂着', '抱着', '挽着',
|
|
1303
|
-
'holding hands', 'hugging', 'embracing', 'kissing', 'arm around',
|
|
1304
|
-
].some(marker => promptLower.includes(marker))
|
|
1305
|
-
const target = threeOrMore
|
|
1306
|
-
? [1216, 832]
|
|
1307
|
-
: verticallyStacked
|
|
1308
|
-
? [1024, 1536]
|
|
1309
|
-
: physicalContact
|
|
1310
|
-
? [1024, 1024]
|
|
1311
|
-
: [1152, 896]
|
|
1312
|
-
let best = allowedSizes[0]
|
|
1313
|
-
let bestScore = Infinity
|
|
1314
|
-
for (const size of allowedSizes) {
|
|
1315
|
-
const ratioDiff = Math.abs(size[0] / size[1] - target[0] / target[1])
|
|
1316
|
-
const areaDiff = Math.abs(size[0] * size[1] - target[0] * target[1])
|
|
1317
|
-
const score = ratioDiff * 10000 + areaDiff
|
|
1318
|
-
if (score < bestScore) {
|
|
1319
|
-
bestScore = score
|
|
1320
|
-
best = size
|
|
1321
|
-
}
|
|
1322
|
-
}
|
|
1323
|
-
return best
|
|
1324
|
-
}
|
|
1325
|
-
|
|
1326
|
-
// ------------------------------------------------------------------
|
|
1327
|
-
// 插件主体
|
|
1328
|
-
// ------------------------------------------------------------------
|
|
137
|
+
// ------------------------------------------------------------------
|
|
138
|
+
// 纯函数库已拆分到 lib/(解析 / tag 清洗 / 工作流 / Comfy 等待 / 多人规划 / HTTP 客户端)
|
|
139
|
+
// ------------------------------------------------------------------
|
|
140
|
+
const {
|
|
141
|
+
normalizeBaseUrl, escapeRe, parseGenerationSize, parseBatchCount, parseSeed, parseDenoise,
|
|
142
|
+
stripRawPrefix, parseNameTags, parsePresetList, mergeTagText,
|
|
143
|
+
} = require('./lib/parse')
|
|
144
|
+
const {
|
|
145
|
+
splitTags, canonicalTagText, joinPromptParts, cleanContentTags, appendInlineProtectedTags,
|
|
146
|
+
NO_ARTIST_RE, NO_STYLE_RE,
|
|
147
|
+
} = require('./lib/tags')
|
|
148
|
+
const {
|
|
149
|
+
animaT2IWorkflow, buildWorkflow, animaI2IWorkflow, animaStyleI2IWorkflow,
|
|
150
|
+
animaOotdI2IWorkflow, buildI2IWorkflow, customWorkflow,
|
|
151
|
+
} = require('./lib/workflows')
|
|
152
|
+
const { outputImages, waitComfyResult } = require('./lib/comfy')
|
|
153
|
+
const { materializeImageSource } = require('./lib/media')
|
|
154
|
+
const {
|
|
155
|
+
MULTI_PERSON_NEGATIVE_TAGS, buildMultiPersonPlanPrompt, parseMultiPersonPlan,
|
|
156
|
+
renderMultiPersonCharacter, multiPersonAutoSize,
|
|
157
|
+
} = require('./lib/multi')
|
|
158
|
+
const { buildComfyClient } = require('./lib/http')
|
|
1329
159
|
exports.apply = async function apply(ctx, cfg) {
|
|
1330
160
|
// 注意:不在此处 extend p_system 表 —— 该表由 p-qiandao 等 p 系插件创建。
|
|
1331
161
|
// 重复声明同一张表可能导致 Koishi 的 schema 迁移冲突,拖垮签到插件。
|
|
@@ -1368,83 +198,30 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
1368
198
|
return sizes
|
|
1369
199
|
}
|
|
1370
200
|
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
|
-
const timer = setTimeout(() => controller.abort(), timeout)
|
|
1374
|
-
try {
|
|
1375
|
-
const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
|
|
1376
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
1377
|
-
return await res.json()
|
|
1378
|
-
} finally {
|
|
1379
|
-
clearTimeout(timer)
|
|
1380
|
-
}
|
|
1381
|
-
}
|
|
1382
|
-
async function comfyPost(apiPath, body, timeout = 20000) {
|
|
1383
|
-
const controller = new AbortController()
|
|
1384
|
-
const timer = setTimeout(() => controller.abort(), timeout)
|
|
1385
|
-
try {
|
|
1386
|
-
const res = await fetch(baseUrl() + apiPath, {
|
|
1387
|
-
method: 'POST',
|
|
1388
|
-
headers: { 'Content-Type': 'application/json' },
|
|
1389
|
-
body: JSON.stringify(body),
|
|
1390
|
-
signal: controller.signal,
|
|
1391
|
-
})
|
|
1392
|
-
if (!res.ok) {
|
|
1393
|
-
// ComfyUI /prompt 校验失败时会返回 node_errors 等详细错误,尽量带出来便于排查
|
|
1394
|
-
const text = await res.text().catch(() => '')
|
|
1395
|
-
const detail = (() => {
|
|
1396
|
-
try {
|
|
1397
|
-
const data = JSON.parse(text)
|
|
1398
|
-
const nodeErrors = (data && data.node_errors) || (data && data.error && data.error.extra_info && data.error.extra_info.node_errors) || null
|
|
1399
|
-
if (nodeErrors && typeof nodeErrors === 'object') {
|
|
1400
|
-
const lines = Object.entries(nodeErrors).map(([id, e]) => {
|
|
1401
|
-
const cls = (e && e.class_type) || ''
|
|
1402
|
-
const errs = (e && Array.isArray(e.errors) && e.errors.length)
|
|
1403
|
-
? e.errors.map(x => `${(x && x.message) || ''}${x && x.details ? ' | ' + x.details : ''}`.trim()).join('; ')
|
|
1404
|
-
: JSON.stringify(e)
|
|
1405
|
-
return ` #${id} [${cls}]: ${errs}`
|
|
1406
|
-
})
|
|
1407
|
-
if (lines.length) return `\n${lines.join('\n')}`
|
|
1408
|
-
}
|
|
1409
|
-
if (data && data.error) {
|
|
1410
|
-
return `${data.error.message || ''}${data.error.details ? ' ' + data.error.details : ''}`.trim()
|
|
1411
|
-
}
|
|
1412
|
-
} catch (e) { /* ignore */ }
|
|
1413
|
-
return text.slice(0, 800)
|
|
1414
|
-
})()
|
|
1415
|
-
throw new Error(`HTTP ${res.status}${detail ? ':' + detail : ''}`)
|
|
1416
|
-
}
|
|
1417
|
-
return await res.json()
|
|
1418
|
-
} finally {
|
|
1419
|
-
clearTimeout(timer)
|
|
1420
|
-
}
|
|
1421
|
-
}
|
|
1422
|
-
async function comfyGetBytes(apiPath, timeout = 120000) {
|
|
1423
|
-
const controller = new AbortController()
|
|
1424
|
-
const timer = setTimeout(() => controller.abort(), timeout)
|
|
1425
|
-
try {
|
|
1426
|
-
const res = await fetch(baseUrl() + apiPath, { signal: controller.signal })
|
|
1427
|
-
if (!res.ok) throw new Error(`HTTP ${res.status}`)
|
|
1428
|
-
return Buffer.from(await res.arrayBuffer())
|
|
1429
|
-
} finally {
|
|
1430
|
-
clearTimeout(timer)
|
|
1431
|
-
}
|
|
1432
|
-
}
|
|
201
|
+
// ComfyUI HTTP 客户端(统一超时与错误详情提取,见 lib/http.js)
|
|
202
|
+
const { get: comfyGet, post: comfyPost, getBytes: comfyGetBytes } = buildComfyClient({ getBaseUrl: baseUrl })
|
|
1433
203
|
|
|
1434
204
|
// ---------------- 状态 ----------------
|
|
1435
205
|
let objectInfoCache = null
|
|
1436
206
|
let objectInfoCacheAt = 0
|
|
207
|
+
let objectInfoInFlight = null
|
|
1437
208
|
|
|
1438
209
|
// /object_info 可能返回体巨大或接口本身很慢(自定义节点多),
|
|
1439
210
|
// 用短超时 + 10 分钟缓存,避免每次状态检查都干等。
|
|
211
|
+
// 并发请求共用同一个 in-flight promise,避免同一时刻重复请求 /object_info。
|
|
1440
212
|
async function getObjectInfoCached() {
|
|
1441
213
|
if (objectInfoCache && Date.now() - objectInfoCacheAt < 10 * 60 * 1000) {
|
|
1442
214
|
return objectInfoCache
|
|
1443
215
|
}
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
1447
|
-
|
|
216
|
+
if (objectInfoInFlight) return objectInfoInFlight
|
|
217
|
+
objectInfoInFlight = comfyGet('/object_info', 5000)
|
|
218
|
+
.then((data) => {
|
|
219
|
+
objectInfoCache = data
|
|
220
|
+
objectInfoCacheAt = Date.now()
|
|
221
|
+
return data
|
|
222
|
+
})
|
|
223
|
+
.finally(() => { objectInfoInFlight = null })
|
|
224
|
+
return objectInfoInFlight
|
|
1448
225
|
}
|
|
1449
226
|
|
|
1450
227
|
async function statusPayload() {
|
|
@@ -1627,6 +404,110 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
1627
404
|
return { ok: true }
|
|
1628
405
|
}
|
|
1629
406
|
|
|
407
|
+
// ---------------- 共享辅助(三大 handler 共用骨架,去重) ----------------
|
|
408
|
+
|
|
409
|
+
// 画师 tags 解析 + no artist 守卫(用户明确「不要画师/不要风格」时跳过)。
|
|
410
|
+
// 语义:与负面词里的 artist name(去签名/水印)无关,只响应用户的显式拒绝。
|
|
411
|
+
function resolveArtistTags(userPrompt, opts = {}) {
|
|
412
|
+
const presets = parsePresetList(cfg.artistPresets)
|
|
413
|
+
let artistTags = ''
|
|
414
|
+
if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
|
|
415
|
+
artistTags = presets[cfg.activeArtistPreset]
|
|
416
|
+
} else if (cfg.defaultArtistTags) {
|
|
417
|
+
artistTags = String(cfg.defaultArtistTags).trim()
|
|
418
|
+
}
|
|
419
|
+
if (artistTags && !opts.skipGuard && NO_ARTIST_RE.test(String(userPrompt || ''))) return ''
|
|
420
|
+
return artistTags
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function resolveStyleTags(userPrompt, opts = {}) {
|
|
424
|
+
if (!cfg.styleTags) return ''
|
|
425
|
+
if (!opts.skipGuard && NO_STYLE_RE.test(String(userPrompt || ''))) return ''
|
|
426
|
+
return String(cfg.styleTags).trim()
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
// P 点余额预检(单图/多人/连续共用)
|
|
430
|
+
async function precheckPoints(session, USERID, isAdmin, totalPrice) {
|
|
431
|
+
if (isAdmin) return { ok: true }
|
|
432
|
+
const notExists = await isAccountExists(USERID)
|
|
433
|
+
if (!notExists) return { ok: false, message: session.text('.account-notExists') }
|
|
434
|
+
const usersdata = await getPUser(USERID)
|
|
435
|
+
const saving = usersdata?.p || 0
|
|
436
|
+
if (saving < totalPrice) return { ok: false, message: session.text('.no-enough-p', [totalPrice]) }
|
|
437
|
+
return { ok: true }
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// 预排队整批任务:先检查队列容量再入队,杜绝「部分入队后满、退款但任务仍执行」。
|
|
441
|
+
// 任一失败路径都按原逻辑退款(P 已在入队前扣除)。
|
|
442
|
+
async function enqueueBatch(count, work, { USERID, isAdmin, totalPrice }) {
|
|
443
|
+
const tasks = []
|
|
444
|
+
let firstPosition = null
|
|
445
|
+
if (!cfg.queueEnabled) return { ok: true, tasks, firstPosition }
|
|
446
|
+
const maxQueue = queueMax()
|
|
447
|
+
// 容量预检与入队循环之间没有 await,单线程内是原子的
|
|
448
|
+
if (maxQueue && queueInFlight + queueSize + count > maxQueue) {
|
|
449
|
+
if (!isAdmin) await refundP(USERID, totalPrice)
|
|
450
|
+
return { ok: false, message: `生成队列已满(最多 ${maxQueue} 个),本次请求已丢弃,请稍后再试。` }
|
|
451
|
+
}
|
|
452
|
+
for (let i = 0; i < count; i++) {
|
|
453
|
+
const q = enqueue(() => work(i))
|
|
454
|
+
if (!q.ok) {
|
|
455
|
+
if (!isAdmin) await refundP(USERID, totalPrice)
|
|
456
|
+
return { ok: false, message: q.message }
|
|
457
|
+
}
|
|
458
|
+
tasks.push(q.task)
|
|
459
|
+
if (firstPosition == null) firstPosition = q.position
|
|
460
|
+
}
|
|
461
|
+
return { ok: true, tasks, firstPosition }
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// 即时反馈的公共部分(队列/生成中 + 张数 + 扣费),返回待发送的行
|
|
465
|
+
function feedbackBase(session, { firstPosition, count, totalPrice, isAdmin }) {
|
|
466
|
+
const rows = []
|
|
467
|
+
if (cfg.queueEnabled) {
|
|
468
|
+
rows.push(session.text('.queued', [firstPosition, cfg.queueMaxRequests || '∞']))
|
|
469
|
+
if (count > 1) rows.push(session.text('.batch-count', [count]))
|
|
470
|
+
if (!isAdmin) rows.push(session.text('.charged', [totalPrice]))
|
|
471
|
+
} else {
|
|
472
|
+
rows.push(session.text('.generating'))
|
|
473
|
+
if (count > 1) rows.push(session.text('.batch-count', [count]))
|
|
474
|
+
if (!isAdmin) rows.push(session.text('.charged', [totalPrice]))
|
|
475
|
+
}
|
|
476
|
+
return rows
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
async function sendNotices(session, notices) {
|
|
480
|
+
if (!notices.length) return
|
|
481
|
+
try {
|
|
482
|
+
await session.send(notices.filter(Boolean).join('\n'))
|
|
483
|
+
} catch (e) {
|
|
484
|
+
logger.warn(`发送反馈消息失败:${e.message}`)
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
// 发图:引用用户触发指令的原消息,失败回退普通发送
|
|
489
|
+
async function sendImagesWithQuote(session, outputs) {
|
|
490
|
+
const imageElements = await Promise.all(outputs.map(async (src) => {
|
|
491
|
+
const materialized = await materializeImageSource(src)
|
|
492
|
+
return Buffer.isBuffer(materialized) ? h.image(materialized) : h.image(src)
|
|
493
|
+
}))
|
|
494
|
+
try {
|
|
495
|
+
await session.send(h.quote(session.messageId) + imageElements.join(''))
|
|
496
|
+
} catch (e) {
|
|
497
|
+
logger.warn(`发送图片失败(引用):${e.message}`)
|
|
498
|
+
await session.send(imageElements)
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// P 点读改写按用户串行化,避免并发指令互相覆盖余额
|
|
503
|
+
const userLocks = new Map()
|
|
504
|
+
function withUserLock(USERID, fn) {
|
|
505
|
+
const prev = userLocks.get(USERID) || Promise.resolve()
|
|
506
|
+
const next = prev.then(fn, fn)
|
|
507
|
+
userLocks.set(USERID, next.catch(() => {}))
|
|
508
|
+
return next
|
|
509
|
+
}
|
|
510
|
+
|
|
1630
511
|
// ---------------- 用户自选模型 ----------------
|
|
1631
512
|
// 从 ComfyUI /object_info(10 分钟缓存)读取真实的 UNET 模型列表
|
|
1632
513
|
async function listUnetModels() {
|
|
@@ -1876,7 +757,7 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
1876
757
|
}
|
|
1877
758
|
}
|
|
1878
759
|
|
|
1879
|
-
async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '') {
|
|
760
|
+
async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '', precomputedSearch = null) {
|
|
1880
761
|
if (!cfg.promptOptimizeEnabled && !force) {
|
|
1881
762
|
return { ok: true, prompt: userPrompt, reason: 'optimize_disabled' }
|
|
1882
763
|
}
|
|
@@ -1885,7 +766,10 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
1885
766
|
return { ok: false, prompt: userPrompt, reason: 'llm_not_configured' }
|
|
1886
767
|
}
|
|
1887
768
|
let searchBlock = ''
|
|
1888
|
-
if (
|
|
769
|
+
if (precomputedSearch != null) {
|
|
770
|
+
// 批量按张优化时由调用方复用同一份搜索结果,避免重复请求 Tavily
|
|
771
|
+
searchBlock = precomputedSearch
|
|
772
|
+
} else if (wantsWebSearch(userPrompt)) {
|
|
1889
773
|
searchBlock = await webSearch(userPrompt)
|
|
1890
774
|
}
|
|
1891
775
|
const characterRule = buildCharacterRule(userPrompt)
|
|
@@ -2014,19 +898,10 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2014
898
|
}
|
|
2015
899
|
}
|
|
2016
900
|
}
|
|
2017
|
-
const
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
} else if (cfg.defaultArtistTags) {
|
|
2022
|
-
artistTags = String(cfg.defaultArtistTags).trim()
|
|
2023
|
-
}
|
|
2024
|
-
if (artistTags && !/(不用我的风格|不要我的风格|不使用我的风格|不要画师词|不用画师词|不加画师词|no artist)/i.test(userPrompt)) {
|
|
2025
|
-
parts.push(artistTags)
|
|
2026
|
-
}
|
|
2027
|
-
if (cfg.styleTags && !/(不用我的风格|不要我的风格|不使用我的风格)/i.test(userPrompt)) {
|
|
2028
|
-
parts.push(String(cfg.styleTags).trim())
|
|
2029
|
-
}
|
|
901
|
+
const artistTags = resolveArtistTags(userPrompt)
|
|
902
|
+
if (artistTags) parts.push(artistTags)
|
|
903
|
+
const styleTags = resolveStyleTags(userPrompt)
|
|
904
|
+
if (styleTags) parts.push(styleTags)
|
|
2030
905
|
parts.push(userPrompt)
|
|
2031
906
|
return { prompt: joinPromptParts(parts), degraded: false }
|
|
2032
907
|
}
|
|
@@ -2293,15 +1168,11 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2293
1168
|
const contentClean = cleanContentTags(commonContent, 65, false, [], true)
|
|
2294
1169
|
const parts = []
|
|
2295
1170
|
if (cfg.qualityPrefix) parts.push(String(cfg.qualityPrefix).trim())
|
|
2296
|
-
|
|
2297
|
-
|
|
2298
|
-
if (cfg.activeArtistPreset && presets[cfg.activeArtistPreset]) {
|
|
2299
|
-
artistTags = presets[cfg.activeArtistPreset]
|
|
2300
|
-
} else if (cfg.defaultArtistTags) {
|
|
2301
|
-
artistTags = String(cfg.defaultArtistTags).trim()
|
|
2302
|
-
}
|
|
1171
|
+
// 多人同样遵守 no artist 守卫(与单图一致):用户说「不要画师」时跳过画师 tags
|
|
1172
|
+
const artistTags = resolveArtistTags(prompt)
|
|
2303
1173
|
if (artistTags) parts.push(artistTags)
|
|
2304
|
-
|
|
1174
|
+
const styleTags = resolveStyleTags(prompt)
|
|
1175
|
+
if (styleTags) parts.push(styleTags)
|
|
2305
1176
|
parts.push(contentClean || commonContent)
|
|
2306
1177
|
if (characterTagStream.length) parts.push(characterTagStream.join(', '))
|
|
2307
1178
|
let finalPrompt = joinPromptParts(parts)
|
|
@@ -2451,7 +1322,6 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2451
1322
|
break
|
|
2452
1323
|
}
|
|
2453
1324
|
currentImages = regen.outputs
|
|
2454
|
-
currentPrompt = regen.finalPrompt || currentPrompt
|
|
2455
1325
|
}
|
|
2456
1326
|
|
|
2457
1327
|
// 多候选挑选
|
|
@@ -2529,13 +1399,8 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2529
1399
|
const count = parsedBatch.count
|
|
2530
1400
|
|
|
2531
1401
|
// P 点校验(按总价 = 张数 × 单价)
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
if (!notExists) return session.text('.account-notExists')
|
|
2535
|
-
const usersdata = await getPUser(USERID)
|
|
2536
|
-
const saving = usersdata?.p || 0
|
|
2537
|
-
if (saving < count * price) return session.text('.no-enough-p', [count * price])
|
|
2538
|
-
}
|
|
1402
|
+
const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
|
|
1403
|
+
if (!pcheck.ok) return pcheck.message
|
|
2539
1404
|
|
|
2540
1405
|
// 未指定尺寸时按人数/接触关系自动选横图
|
|
2541
1406
|
let size = parsedSize.size
|
|
@@ -2575,40 +1440,17 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2575
1440
|
if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 多人已扣除 ${count * price} P 点(${count} 张 × ${price}),余额 ${saving - count * price}`)
|
|
2576
1441
|
}
|
|
2577
1442
|
|
|
2578
|
-
//
|
|
2579
|
-
const
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
const q = enqueue(() => runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }))
|
|
2584
|
-
if (!q.ok) {
|
|
2585
|
-
if (!isAdmin) await refundP(USERID, count * price)
|
|
2586
|
-
return q.message
|
|
2587
|
-
}
|
|
2588
|
-
queuedTasks.push(q.task)
|
|
2589
|
-
if (firstPosition == null) firstPosition = q.position
|
|
2590
|
-
}
|
|
2591
|
-
}
|
|
1443
|
+
// 队列:预排队全部任务(先查容量再入队,占满整体退回总价)
|
|
1444
|
+
const queued = await enqueueBatch(count, (i) => runComfyGenerate(finalPrompt, size, { negativePrompt: multiNegative, unet }), { USERID, isAdmin, totalPrice: count * price })
|
|
1445
|
+
if (!queued.ok) return queued.message
|
|
1446
|
+
const queuedTasks = queued.tasks
|
|
1447
|
+
const firstPosition = queued.firstPosition
|
|
2592
1448
|
|
|
2593
1449
|
// 即时反馈
|
|
2594
1450
|
const notice = []
|
|
2595
1451
|
if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
if (count > 1) notice.push(session.text('.batch-count', [count]))
|
|
2599
|
-
if (!isAdmin) notice.push(session.text('.charged', [count * price]))
|
|
2600
|
-
} else {
|
|
2601
|
-
notice.push(session.text('.generating'))
|
|
2602
|
-
if (count > 1) notice.push(session.text('.batch-count', [count]))
|
|
2603
|
-
if (!isAdmin) notice.push(session.text('.charged', [count * price]))
|
|
2604
|
-
}
|
|
2605
|
-
if (notice.length) {
|
|
2606
|
-
try {
|
|
2607
|
-
await session.send(notice.filter(Boolean).join('\n'))
|
|
2608
|
-
} catch (e) {
|
|
2609
|
-
logger.warn(`发送反馈消息失败:${e.message}`)
|
|
2610
|
-
}
|
|
2611
|
-
}
|
|
1452
|
+
notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin }))
|
|
1453
|
+
await sendNotices(session, notice)
|
|
2612
1454
|
|
|
2613
1455
|
// 单张生成 +(可选)视觉校验
|
|
2614
1456
|
const runOne = async (i) => {
|
|
@@ -2652,13 +1494,7 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2652
1494
|
if (cfg.outputLogs) logger.success(`${USERID} 多人生成成功 ${successCount}/${count} 张`)
|
|
2653
1495
|
|
|
2654
1496
|
// 发图:引用用户触发指令的原消息
|
|
2655
|
-
|
|
2656
|
-
try {
|
|
2657
|
-
await session.send(h.quote(session.messageId) + imageElements.join(''))
|
|
2658
|
-
} catch (e) {
|
|
2659
|
-
logger.warn(`发送多人图片失败:${e.message}`)
|
|
2660
|
-
await session.send(imageElements)
|
|
2661
|
-
}
|
|
1497
|
+
await sendImagesWithQuote(session, allOutputs)
|
|
2662
1498
|
|
|
2663
1499
|
const reply = []
|
|
2664
1500
|
if (count > 1) {
|
|
@@ -2732,13 +1568,8 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2732
1568
|
const stageList = stages.slice(0, count)
|
|
2733
1569
|
|
|
2734
1570
|
// P 点校验(按总价)
|
|
2735
|
-
|
|
2736
|
-
|
|
2737
|
-
if (!notExists) return session.text('.account-notExists')
|
|
2738
|
-
const usersdata = await getPUser(USERID)
|
|
2739
|
-
const saving = usersdata?.p || 0
|
|
2740
|
-
if (saving < count * price) return session.text('.no-enough-p', [count * price])
|
|
2741
|
-
}
|
|
1571
|
+
const pcheck = await precheckPoints(session, USERID, isAdmin, count * price)
|
|
1572
|
+
if (!pcheck.ok) return pcheck.message
|
|
2742
1573
|
|
|
2743
1574
|
// ComfyUI 就绪
|
|
2744
1575
|
const ready = await ensureComfyuiReady()
|
|
@@ -2782,20 +1613,11 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2782
1613
|
if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 连续图已扣除 ${count * price} P 点(${count} 阶段 × ${price},seed=${seed}),余额 ${saving - count * price}`)
|
|
2783
1614
|
}
|
|
2784
1615
|
|
|
2785
|
-
//
|
|
2786
|
-
const
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
const q = enqueue(() => runComfyGenerate(stagePrompts[i], size, { seed, unet }))
|
|
2791
|
-
if (!q.ok) {
|
|
2792
|
-
if (!isAdmin) await refundP(USERID, count * price)
|
|
2793
|
-
return q.message
|
|
2794
|
-
}
|
|
2795
|
-
queuedTasks.push(q.task)
|
|
2796
|
-
if (firstPosition == null) firstPosition = q.position
|
|
2797
|
-
}
|
|
2798
|
-
}
|
|
1616
|
+
// 队列:预排队全部阶段(先查容量再入队)
|
|
1617
|
+
const queued = await enqueueBatch(count, (i) => runComfyGenerate(stagePrompts[i], size, { seed, unet }), { USERID, isAdmin, totalPrice: count * price })
|
|
1618
|
+
if (!queued.ok) return queued.message
|
|
1619
|
+
const queuedTasks = queued.tasks
|
|
1620
|
+
const firstPosition = queued.firstPosition
|
|
2799
1621
|
|
|
2800
1622
|
// 即时反馈
|
|
2801
1623
|
const notice = []
|
|
@@ -2803,22 +1625,8 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2803
1625
|
if (identity && fixedChars[identity]) notice.push(`已固定角色「${identity}」的身份 tags,各阶段外观将保持一致。`)
|
|
2804
1626
|
if (!useLLM) notice.push(session.text('.series-no-llm'))
|
|
2805
1627
|
if (degradedStages) notice.push(session.text('.prompt-degraded', ['(连续图阶段优化失败,已使用原始描述)']))
|
|
2806
|
-
|
|
2807
|
-
|
|
2808
|
-
if (count > 1) notice.push(session.text('.batch-count', [count]))
|
|
2809
|
-
if (!isAdmin) notice.push(session.text('.charged', [count * price]))
|
|
2810
|
-
} else {
|
|
2811
|
-
notice.push(session.text('.generating'))
|
|
2812
|
-
if (count > 1) notice.push(session.text('.batch-count', [count]))
|
|
2813
|
-
if (!isAdmin) notice.push(session.text('.charged', [count * price]))
|
|
2814
|
-
}
|
|
2815
|
-
if (notice.length) {
|
|
2816
|
-
try {
|
|
2817
|
-
await session.send(notice.filter(Boolean).join('\n'))
|
|
2818
|
-
} catch (e) {
|
|
2819
|
-
logger.warn(`发送反馈消息失败:${e.message}`)
|
|
2820
|
-
}
|
|
2821
|
-
}
|
|
1628
|
+
notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * price, isAdmin }))
|
|
1629
|
+
await sendNotices(session, notice)
|
|
2822
1630
|
|
|
2823
1631
|
// 单阶段生成(共用 seed)
|
|
2824
1632
|
const runOne = async (i) => {
|
|
@@ -2854,13 +1662,7 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2854
1662
|
if (cfg.outputLogs) logger.success(`${USERID} 连续图生成成功 ${successCount}/${count} 阶段(seed=${seed})`)
|
|
2855
1663
|
|
|
2856
1664
|
// 发图:引用用户触发指令的原消息
|
|
2857
|
-
|
|
2858
|
-
try {
|
|
2859
|
-
await session.send(h.quote(session.messageId) + imageElements.join(''))
|
|
2860
|
-
} catch (e) {
|
|
2861
|
-
logger.warn(`发送连续图失败:${e.message}`)
|
|
2862
|
-
await session.send(imageElements)
|
|
2863
|
-
}
|
|
1665
|
+
await sendImagesWithQuote(session, allOutputs)
|
|
2864
1666
|
|
|
2865
1667
|
const reply = []
|
|
2866
1668
|
reply.push(session.text('.series-ok', [count * price, successCount, seed]))
|
|
@@ -2929,16 +1731,20 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
2929
1731
|
}
|
|
2930
1732
|
|
|
2931
1733
|
async function deductP(USERID, amount) {
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
2935
|
-
|
|
1734
|
+
return withUserLock(USERID, async () => {
|
|
1735
|
+
const user = await getPUser(USERID)
|
|
1736
|
+
const current = user?.p || 0
|
|
1737
|
+
await ctx.database.set('p_system', { userid: USERID }, { p: Math.max(0, current - amount) })
|
|
1738
|
+
return current
|
|
1739
|
+
})
|
|
2936
1740
|
}
|
|
2937
1741
|
|
|
2938
1742
|
async function refundP(USERID, amount) {
|
|
2939
|
-
|
|
2940
|
-
|
|
2941
|
-
|
|
1743
|
+
return withUserLock(USERID, async () => {
|
|
1744
|
+
const user = await getPUser(USERID)
|
|
1745
|
+
const current = user?.p || 0
|
|
1746
|
+
await ctx.database.set('p_system', { userid: USERID }, { p: current + amount })
|
|
1747
|
+
})
|
|
2942
1748
|
}
|
|
2943
1749
|
|
|
2944
1750
|
// ---------------- 画师组/角色管理(持久化到数据库,避免 scope.update 触发重载) ----------------
|
|
@@ -3260,7 +2066,13 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3260
2066
|
if (!src) return null
|
|
3261
2067
|
try {
|
|
3262
2068
|
if (/^file:\/\//i.test(src)) {
|
|
3263
|
-
|
|
2069
|
+
// 修复:Windows 下 file:///K:/... 用 replace 会得到 /K:/...(无法读取),用 fileURLToPath 解析
|
|
2070
|
+
let filePath
|
|
2071
|
+
try {
|
|
2072
|
+
filePath = fileURLToPath(src)
|
|
2073
|
+
} catch (e) {
|
|
2074
|
+
filePath = src.replace(/^file:\/\//i, '')
|
|
2075
|
+
}
|
|
3264
2076
|
const buffer = await fsp.readFile(filePath)
|
|
3265
2077
|
return { buffer, ext: path.extname(filePath) || '.png' }
|
|
3266
2078
|
}
|
|
@@ -3393,13 +2205,9 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3393
2205
|
const count = parsedBatch.count
|
|
3394
2206
|
|
|
3395
2207
|
// P 点校验(按总价 = 张数 × 单价)
|
|
3396
|
-
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
const usersdata = await getPUser(USERID)
|
|
3400
|
-
const saving = usersdata?.p || 0
|
|
3401
|
-
if (saving < count * cfg.price) return session.text('.no-enough-p', [count * cfg.price])
|
|
3402
|
-
}
|
|
2208
|
+
// P 点校验(按总价 = 张数 × 单价)
|
|
2209
|
+
const pcheck = await precheckPoints(session, USERID, isAdmin, count * cfg.price)
|
|
2210
|
+
if (!pcheck.ok) return pcheck.message
|
|
3403
2211
|
|
|
3404
2212
|
// 原样模式
|
|
3405
2213
|
const stripped = stripRawPrefix(text)
|
|
@@ -3489,27 +2297,21 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3489
2297
|
? { i2iImage, i2i: { mode: (i2iOpts && i2iOpts.mode) || 'plain', denoise: denoise != null ? denoise : null, caps: (i2iOpts && i2iOpts.caps) || null } }
|
|
3490
2298
|
: {}
|
|
3491
2299
|
|
|
3492
|
-
//
|
|
3493
|
-
const
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
|
|
3502
|
-
}
|
|
3503
|
-
return runComfyGenerate(p, parsedSize.size, Object.assign({ unet, seed }, i2iRun))
|
|
3504
|
-
})
|
|
3505
|
-
if (!q.ok) {
|
|
3506
|
-
if (!isAdmin) await refundP(USERID, count * cfg.price)
|
|
3507
|
-
return q.message
|
|
3508
|
-
}
|
|
3509
|
-
queuedTasks.push(q.task)
|
|
3510
|
-
if (firstPosition == null) firstPosition = q.position
|
|
2300
|
+
// 性能:按张优化(perImageOptimize)时联网搜索只做一次,各图复用同一份结果
|
|
2301
|
+
const searchCache = perImageOptimize && wantsWebSearch(userPrompt) ? await webSearch(userPrompt) : null
|
|
2302
|
+
|
|
2303
|
+
// 队列:预排队全部任务(先查容量再入队,占满整体退回总价)
|
|
2304
|
+
const queued = await enqueueBatch(count, async (i) => {
|
|
2305
|
+
let p = finalPrompt
|
|
2306
|
+
if (perImageOptimize) {
|
|
2307
|
+
const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
|
|
2308
|
+
p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
|
|
3511
2309
|
}
|
|
3512
|
-
|
|
2310
|
+
return runComfyGenerate(p, parsedSize.size, Object.assign({ unet, seed }, i2iRun))
|
|
2311
|
+
}, { USERID, isAdmin, totalPrice: count * cfg.price })
|
|
2312
|
+
if (!queued.ok) return queued.message
|
|
2313
|
+
const queuedTasks = queued.tasks
|
|
2314
|
+
const firstPosition = queued.firstPosition
|
|
3513
2315
|
|
|
3514
2316
|
// 先发一条即时反馈(扣费结果 / 队列位置 / 降级提示),
|
|
3515
2317
|
// 确保用户不会以为指令没反应。
|
|
@@ -3528,28 +2330,14 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3528
2330
|
notice.push(session.text('.no-optimize', [reasons[noOptimizeReason] || noOptimizeReason]))
|
|
3529
2331
|
}
|
|
3530
2332
|
if (parsedBatch.clamped) notice.push(session.text('.batch-limit', [count]))
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
if (count > 1) notice.push(session.text('.batch-count', [count]))
|
|
3534
|
-
if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
|
|
3535
|
-
} else {
|
|
3536
|
-
notice.push(session.text('.generating'))
|
|
3537
|
-
if (count > 1) notice.push(session.text('.batch-count', [count]))
|
|
3538
|
-
if (!isAdmin) notice.push(session.text('.charged', [count * cfg.price]))
|
|
3539
|
-
}
|
|
3540
|
-
if (notice.length) {
|
|
3541
|
-
try {
|
|
3542
|
-
await session.send(notice.filter(Boolean).join('\n'))
|
|
3543
|
-
} catch (e) {
|
|
3544
|
-
logger.warn(`发送反馈消息失败:${e.message}`)
|
|
3545
|
-
}
|
|
3546
|
-
}
|
|
2333
|
+
notice.push(...feedbackBase(session, { firstPosition, count, totalPrice: count * cfg.price, isAdmin }))
|
|
2334
|
+
await sendNotices(session, notice)
|
|
3547
2335
|
|
|
3548
2336
|
// 单张生成
|
|
3549
2337
|
const runOne = async (i) => {
|
|
3550
2338
|
let p = finalPrompt
|
|
3551
2339
|
if (perImageOptimize) {
|
|
3552
|
-
const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
|
|
2340
|
+
const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
|
|
3553
2341
|
p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
|
|
3554
2342
|
}
|
|
3555
2343
|
let result
|
|
@@ -3583,14 +2371,8 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3583
2371
|
|
|
3584
2372
|
if (cfg.outputLogs) logger.success(`${USERID} 生成成功 ${successCount}/${count} 张`)
|
|
3585
2373
|
|
|
3586
|
-
|
|
3587
|
-
|
|
3588
|
-
try {
|
|
3589
|
-
await session.send(h.quote(session.messageId) + imageElements.join(''))
|
|
3590
|
-
} catch (e) {
|
|
3591
|
-
logger.warn(`发送图片失败(引用):${e.message}`)
|
|
3592
|
-
await session.send(imageElements)
|
|
3593
|
-
}
|
|
2374
|
+
// 发图:引用用户触发指令的原消息
|
|
2375
|
+
await sendImagesWithQuote(session, allOutputs)
|
|
3594
2376
|
|
|
3595
2377
|
const reply = []
|
|
3596
2378
|
if (count > 1) {
|
|
@@ -3631,13 +2413,13 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3631
2413
|
// 没券/券不足:问是否购买(显示价格);拒绝一次再警告并问第二次,再拒绝直接生图
|
|
3632
2414
|
const askBuy = async () => {
|
|
3633
2415
|
await session.send(session.text('.coupon-ask-buy', [count, tokens, couponPrice, count * couponPrice]))
|
|
3634
|
-
const reply = await session.prompt(cfg.couponAskTimeout * 1000)
|
|
2416
|
+
const reply = await session.prompt(cfg.couponAskTimeout * 1000).catch(() => null)
|
|
3635
2417
|
return normalizeConfirm(reply)
|
|
3636
2418
|
}
|
|
3637
2419
|
let ans = await askBuy()
|
|
3638
2420
|
if (ans === false) {
|
|
3639
2421
|
await session.send(session.text('.coupon-buy-warn'))
|
|
3640
|
-
const reply = await session.prompt(cfg.couponAskTimeout * 1000)
|
|
2422
|
+
const reply = await session.prompt(cfg.couponAskTimeout * 1000).catch(() => null)
|
|
3641
2423
|
ans = normalizeConfirm(reply)
|
|
3642
2424
|
if (ans !== true) {
|
|
3643
2425
|
await session.send(session.text('.coupon-buy-cancelled'))
|
|
@@ -3695,8 +2477,15 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3695
2477
|
return null
|
|
3696
2478
|
}
|
|
3697
2479
|
|
|
3698
|
-
// 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice
|
|
2480
|
+
// 提示词优化券单价:优先读 data/p-shop.json 里覆盖的价格,否则用配置 couponPrice。
|
|
2481
|
+
// 加 60 秒 TTL 缓存,避免每次购买询问都同步读盘。
|
|
2482
|
+
let couponPriceCache = null
|
|
2483
|
+
let couponPriceCacheAt = 0
|
|
3699
2484
|
async function resolveCouponPrice() {
|
|
2485
|
+
if (couponPriceCache != null && Date.now() - couponPriceCacheAt < 60 * 1000) {
|
|
2486
|
+
return couponPriceCache
|
|
2487
|
+
}
|
|
2488
|
+
let price = cfg.couponPrice
|
|
3700
2489
|
const candidates = [
|
|
3701
2490
|
path.join(ctx.baseDir, 'data', 'p-shop.json'),
|
|
3702
2491
|
path.join(process.cwd(), 'data', 'p-shop.json'),
|
|
@@ -3707,14 +2496,19 @@ exports.apply = async function apply(ctx, cfg) {
|
|
|
3707
2496
|
const data = JSON.parse(fs.readFileSync(f, 'utf-8'))
|
|
3708
2497
|
if (data && typeof data === 'object') {
|
|
3709
2498
|
const item = data['提示词优化券']
|
|
3710
|
-
if (item && typeof item.price === 'number' && item.price > 0)
|
|
2499
|
+
if (item && typeof item.price === 'number' && item.price > 0) {
|
|
2500
|
+
price = item.price
|
|
2501
|
+
break
|
|
2502
|
+
}
|
|
3711
2503
|
}
|
|
3712
2504
|
}
|
|
3713
2505
|
} catch (e) {
|
|
3714
2506
|
logger.warn(`读取 p-shop.json 价格失败:${e.message}`)
|
|
3715
2507
|
}
|
|
3716
2508
|
}
|
|
3717
|
-
|
|
2509
|
+
couponPriceCache = price
|
|
2510
|
+
couponPriceCacheAt = Date.now()
|
|
2511
|
+
return price
|
|
3718
2512
|
}
|
|
3719
2513
|
|
|
3720
2514
|
// 启动时合并数据库里保存的运行时配置(画师组/固定角色)
|