koishi-plugin-p-draw 1.3.5 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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, fileURLToPath } = require('url')
6
+ const { pathToFileURL } = require('url')
7
7
 
8
8
  exports.name = 'p-draw'
9
9
 
@@ -28,12 +28,7 @@ exports.usage = `
28
28
  - **联网搜索:** 描述中带 \`联网\` / \`搜索\` / \`查一下\` 等词时,会先联网搜索补充角色设定(需配置 Tavily Key)。
29
29
  - **画师组:** \`创建画师组 名称=tags\` \`切换画师组 名称\` \`查看画师组\` \`删除画师组 名称\`
30
30
  - **固定角色:** \`添加角色 名称=tags\`
31
- - **以图生图:** \`p-draw i2i <描述>\`,并在同一条消息里附一张原图(文件/截图/链接均可)。发送后会询问处理模式:
32
- - **① 换风格(漫画化)**:保留原图构图,把图片转成二次元画风(用 ControlNet 锁结构)
33
- - **② 换装换姿势**:保留角色长相,重新设计服装/姿势/场景(用 IP-Adapter 保脸)
34
- - **③ 取消**:不生成
35
- - 可加 \`--denoise 0.6\` 单独调整强度;未装 ControlNet/IPAdapter 时自动回退普通 img2img。
36
- `;
31
+ `;
37
32
 
38
33
  const { zhCN } = require('./lib/i18n')
39
34
  exports.Config = Schema.object({
@@ -48,7 +43,6 @@ exports.Config = Schema.object({
48
43
 
49
44
  // 模型文件
50
45
  unetName: Schema.string().default('anima-base-v1.0.safetensors').description('主模型文件名(需与 ComfyUI models/diffusion_models 下的文件名完全一致,含连字符;默认 anima-base-v1.0.safetensors)'),
51
- i2iUnetName: Schema.string().default('anima-base-v1.0.safetensors').description('「换风格(漫画化)」工作流专用主模型文件名。LLLite 权重按 block 数逐块训练,当前 LLLite 权重为 28-block,必须搭配 28-block 模型(anima-base-v1.0 / anima-aesthetic-v1.1);不要用 40-block 的 Anima-2.9B,否则报 depth_embed slices missing。留空则用主模型 unetName'),
52
46
  modelParams: Schema.dict(Schema.object({
53
47
  samplerName: Schema.string().description('采样器(如 er_sde / res_2s / dpmpp_2m)'),
54
48
  scheduler: Schema.string().description('调度器(如 simple / beta57 / normal)'),
@@ -98,20 +92,7 @@ exports.Config = Schema.object({
98
92
  multiPrice: Schema.number().default(900).description('多人指令(p-draw 多人)单张消耗的 P 点'),
99
93
  couponPrice: Schema.number().default(3000).description('提示词优化券单价(P 点/张,购买询问时显示;可自动读取 data/p-shop.json 里的价格覆盖)'),
100
94
  couponAskTimeout: Schema.number().default(60).description('提示词优化券确认等待时间(秒)'),
101
- img2imgDenoise: Schema.number().default(0.55).description('普通以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)'),
102
- i2iMode: Schema.string().default('ask').description('i2i 模式选择方式:ask=每次询问 / style=直接换风格(漫画化)/ ootd=直接换装换姿势 / plain=普通 img2img'),
103
- i2iAskTimeout: Schema.number().default(60).description('i2i 模式询问等待时间(秒)'),
104
- seriesAskTimeout: Schema.number().default(60).description('连续图 LLM 使用确认等待时间(秒)'),
105
- i2iStyleDenoise: Schema.number().default(0.75).description('换风格模式(漫画化)的去噪强度,越大风格变化越彻底(建议 0.7-0.85)'),
106
- i2iOotdDenoise: Schema.number().default(0.55).description('换装换姿势模式(保留角色)的去噪强度(建议 0.5-0.6)'),
107
- i2iControlNetStrength: Schema.number().default(0.7).description('换风格模式的 ControlNet-LLLite 强度,越大构图锁得越死(建议 0.5-0.8;需安装 kohya-ss/ComfyUI-Anima-LLLite 节点与权重)'),
108
- i2iIPAdapterPath: Schema.string().default('').description('Anima IP-Adapter 模型文件路径(换装换姿势模式保脸用;需安装 comfyui-anima-ipadapter 节点并把模型路径填到这里)'),
109
- i2iIPAdapterWeight: Schema.number().default(0.8).description('换装换姿势模式的 IP-Adapter 权重,越大角色特征保留越强(建议 0.6-1.0)'),
110
- controlNetModel: Schema.string().default('').description('Anima ControlNet-LLLite 权重文件名(留空自动检测 anima-lllite 系;需放到 ComfyUI/models/controlnet,如 anima-lllite-lineart-test-1.safetensors)'),
111
- taggerEnabled: Schema.boolean().default(false).description('i2i 前自动识图(需 ComfyUI 安装 WD14 Tagger 节点与模型;识别出的标签会注入提示词优化)'),
112
- taggerModel: Schema.string().default('wd-v1-4-convnext-tagger-v2').description('识图模型名(WD14 Tagger 节点里可选模型)'),
113
- taggerThreshold: Schema.number().default(0.35).description('识图标签置信度阈值'),
114
- taggerCharacterThreshold: Schema.number().default(0.85).description('识图角色标签置信度阈值'),
95
+ seriesAskTimeout: Schema.number().default(60).description('连续图 LLM 使用确认等待时间(秒)'),
115
96
  adminUsers: Schema.array(Schema.string()).default([]).description('免 P 点管理员用户 ID 列表'),
116
97
  outputLogs: Schema.boolean().default(true).description('是否在控制台输出详细日志'),
117
98
 
@@ -138,7 +119,7 @@ exports.Config = Schema.object({
138
119
  // 纯函数库已拆分到 lib/(解析 / tag 清洗 / 工作流 / Comfy 等待 / 多人规划 / HTTP 客户端)
139
120
  // ------------------------------------------------------------------
140
121
  const {
141
- normalizeBaseUrl, escapeRe, parseGenerationSize, parseBatchCount, parseSeed, parseDenoise,
122
+ normalizeBaseUrl, escapeRe, parseGenerationSize, parseBatchCount, parseSeed,
142
123
  stripRawPrefix, splitPositiveNegativePrompt, parseNameTags, parsePresetList, mergeTagText,
143
124
  } = require('./lib/parse')
144
125
  const {
@@ -146,8 +127,7 @@ const {
146
127
  NO_ARTIST_RE, NO_STYLE_RE,
147
128
  } = require('./lib/tags')
148
129
  const {
149
- animaT2IWorkflow, buildWorkflow, animaI2IWorkflow, animaStyleI2IWorkflow,
150
- animaOotdI2IWorkflow, buildI2IWorkflow, customWorkflow,
130
+ buildWorkflow,
151
131
  } = require('./lib/workflows')
152
132
  const { outputImages, waitComfyResult } = require('./lib/comfy')
153
133
  const { materializeImageSource } = require('./lib/media')
@@ -260,16 +240,14 @@ exports.apply = async function apply(ctx, cfg) {
260
240
  if (objectInfo) {
261
241
  const unetList = availableModels(objectInfo, 'UNETLoader', 'unet_name')
262
242
  const clipList = availableModels(objectInfo, 'CLIPLoader', 'clip_name')
263
- const vaeList = availableModels(objectInfo, 'VAELoader', 'vae_name')
264
- payload.unet_available = unetList.includes(cfg.unetName)
265
- payload.unet_models = unetList
266
- payload.i2i_unet_available = unetList.includes(String(cfg.i2iUnetName || '').trim() || cfg.unetName)
267
- payload.clip_available = clipList.includes(cfg.clipName)
268
- payload.vae_available = vaeList.includes(cfg.vaeName)
243
+ const vaeList = availableModels(objectInfo, 'VAELoader', 'vae_name')
244
+ payload.unet_available = unetList.includes(cfg.unetName)
245
+ payload.unet_models = unetList
246
+ payload.clip_available = clipList.includes(cfg.clipName)
247
+ payload.vae_available = vaeList.includes(cfg.vaeName)
269
248
  } else {
270
- payload.unet_available = undefined
271
- payload.i2i_unet_available = undefined
272
- payload.clip_available = undefined
249
+ payload.unet_available = undefined
250
+ payload.clip_available = undefined
273
251
  payload.vae_available = undefined
274
252
  }
275
253
  return payload
@@ -312,10 +290,9 @@ exports.apply = async function apply(ctx, cfg) {
312
290
  const lines = [
313
291
  `ComfyUI 状态:在线`,
314
292
  `版本:${payload.comfyui_version || '未知'}`,
315
- `GPU:${payload.gpu || '未知'}(显存 ${payload.vram_total_mb}MB / 空闲 ${payload.vram_free_mb}MB)`,
316
- `主模型:${cfg.unetName} ${modelStatus(cfg.unetName, payload.unet_available)}`,
317
- `换风格模型:${cfg.i2iUnetName || cfg.unetName} ${modelStatus(cfg.i2iUnetName || cfg.unetName, payload.i2i_unet_available)}`,
318
- `文本编码器:${cfg.clipName} ${modelStatus(cfg.clipName, payload.clip_available)}`,
293
+ `GPU:${payload.gpu || '未知'}(显存 ${payload.vram_total_mb}MB / 空闲 ${payload.vram_free_mb}MB)`,
294
+ `主模型:${cfg.unetName} ${modelStatus(cfg.unetName, payload.unet_available)}`,
295
+ `文本编码器:${cfg.clipName} ${modelStatus(cfg.clipName, payload.clip_available)}`,
319
296
  `VAE:${cfg.vaeName} ${modelStatus(cfg.vaeName, payload.vae_available)}`,
320
297
  `可用尺寸:${payload.allowed_sizes.join('、')}`,
321
298
  ]
@@ -488,21 +465,21 @@ exports.apply = async function apply(ctx, cfg) {
488
465
  }
489
466
  }
490
467
 
491
- // 生成图片使用合并转发;每张图后紧跟实际使用的最终提示词,不附原消息引用。
468
+ // 生成图片使用合并转发;每张图后紧跟实际使用的正负面提示词,不附原消息引用。
492
469
  async function sendImagesAsForward(session, outputs) {
493
470
  const nodes = []
494
471
  const fallback = []
495
472
  for (const output of outputs) {
496
473
  const src = typeof output === 'string' ? output : output.src
497
474
  const prompt = typeof output === 'string' ? '' : String(output.prompt || '')
475
+ const negativePrompt = typeof output === 'string' ? '' : String(output.negativePrompt || '')
498
476
  const materialized = await materializeImageSource(src)
499
477
  const image = Buffer.isBuffer(materialized) ? h.image(materialized) : h.image(src)
500
478
  nodes.push(h('message', image))
501
479
  fallback.push(image)
502
- if (prompt) {
503
- nodes.push(h('message', prompt))
504
- fallback.push(prompt)
505
- }
480
+ const promptText = `Positive:\n${prompt}\n\nNegative:\n${negativePrompt}`
481
+ nodes.push(h('message', promptText))
482
+ fallback.push(promptText)
506
483
  }
507
484
  try {
508
485
  await session.send(h('figure', nodes))
@@ -597,14 +574,7 @@ exports.apply = async function apply(ctx, cfg) {
597
574
  const cfgVal = Number(overrides.cfg) || workCfg.cfg
598
575
  const seed = Number(overrides.seed) || crypto.randomInt(1, 2 ** 32 - 1)
599
576
  const negativePrompt = joinPromptParts([overrides.negativePrompt || cfg.negativePrompt || ''])
600
- const i2iImage = overrides.i2iImage
601
- if (i2iImage && cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
602
- return { ok: false, message: 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。' }
603
- }
604
- const i2iOpts = overrides.i2i || {}
605
- const promptBody = i2iImage
606
- ? buildI2IWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, i2iImage, i2iOpts.mode || 'plain', i2iOpts.denoise != null ? i2iOpts.denoise : null, i2iOpts.caps || null).promptBody
607
- : buildWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, Boolean(size))
577
+ const promptBody = buildWorkflow(workCfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, Boolean(size))
608
578
 
609
579
  const clientId = crypto.randomUUID()
610
580
  const submit = await comfyPost('/prompt', { prompt: promptBody, client_id: clientId }, 20000)
@@ -664,9 +634,10 @@ exports.apply = async function apply(ctx, cfg) {
664
634
  width,
665
635
  height,
666
636
  steps,
667
- cfg: cfgVal,
668
- prompt_id: promptId,
669
- }
637
+ cfg: cfgVal,
638
+ prompt_id: promptId,
639
+ negativePrompt,
640
+ }
670
641
  }
671
642
 
672
643
  // ---------------- LLM 提示词优化 ----------------
@@ -770,7 +741,7 @@ exports.apply = async function apply(ctx, cfg) {
770
741
  }
771
742
  }
772
743
 
773
- async function optimizePrompt(session, userPrompt, force = false, img2imgRule = '', precomputedSearch = null) {
744
+ async function optimizePrompt(session, userPrompt, force = false, precomputedSearch = null) {
774
745
  if (!cfg.promptOptimizeEnabled && !force) {
775
746
  return { ok: true, prompt: userPrompt, reason: 'optimize_disabled' }
776
747
  }
@@ -786,7 +757,7 @@ exports.apply = async function apply(ctx, cfg) {
786
757
  searchBlock = await webSearch(userPrompt)
787
758
  }
788
759
  const characterRule = buildCharacterRule(userPrompt)
789
- const defaultTemplate = `你是为图像生成模型编写正面提示词的 AI 画师。\n\n请根据用户的原始要求设计一幅完整、协调、具有视觉吸引力的画面,并将结果输出为英文 Danbooru-style tags。\n\n输出要求:\n- 只输出一行英文 tags,使用英文逗号分隔。\n- 不要输出解释、分析、标题、编号、Markdown、代码块或中文。\n- 不要输出 masterpiece、best quality、score 等质量前缀。\n- 不要输出画师 tags;质量词和画师组会由程序另行拼接。\n- 尽量使用模型容易理解的可见画面描述。\n- 保持用户明确指定的角色、主体、人数、关键服装、动作、表情和道具。\n- 以最终图像协调、精致、有表现力和好看为优先。\n\n角色和动态上下文:\n{character_rule}\n{img2img_rule}\n{search_block}\n\n用户原始要求:\n{theme}`
760
+ const defaultTemplate = `你是为图像生成模型编写正面提示词的 AI 画师。\n\n请根据用户的原始要求设计一幅完整、协调、具有视觉吸引力的画面,并将结果输出为英文 Danbooru-style tags。\n\n输出要求:\n- 只输出一行英文 tags,使用英文逗号分隔。\n- 不要输出解释、分析、标题、编号、Markdown、代码块或中文。\n- 不要输出 masterpiece、best quality、score 等质量前缀。\n- 不要输出画师 tags;质量词和画师组会由程序另行拼接。\n- 尽量使用模型容易理解的可见画面描述。\n- 保持用户明确指定的角色、主体、人数、关键服装、动作、表情和道具。\n- 以最终图像协调、精致、有表现力和好看为优先。\n\n角色和动态上下文:\n{character_rule}\n{search_block}\n\n用户原始要求:\n{theme}`
790
761
  const template = (cfg.promptOptimizeTemplate || '').trim() || defaultTemplate
791
762
  const searchBlockText = searchBlock
792
763
  ? `联网搜索参考信息(请尽量依据这些内容补全角色外观与设定):\n${searchBlock}`
@@ -795,10 +766,6 @@ exports.apply = async function apply(ctx, cfg) {
795
766
  .replace(/\{theme\}/g, userPrompt)
796
767
  .replace(/\{search_block\}/g, searchBlockText)
797
768
  .replace(/\{character_rule\}/g, characterRule)
798
- .replace(/\{outfit_transfer_rule\}/g, '')
799
- .replace(/\{reference_rule\}/g, '')
800
- .replace(/\{img2img_rule\}/g, img2imgRule)
801
- .replace(/\{sensual_rule\}/g, '')
802
769
  .replace(/[ \t]+\n/g, '\n')
803
770
  .replace(/\n{3,}/g, '\n\n')
804
771
  .trim()
@@ -1195,11 +1162,11 @@ exports.apply = async function apply(ctx, cfg) {
1195
1162
 
1196
1163
  // 视觉校验(anima_verify + generation_verifier 移植):对生成的图片跑视觉 LLM,
1197
1164
  // 不合格则用相同提示词重试(最多 multiCandidateCount 张),按多候选规则挑选并返回结果。
1198
- async function verifyGeneratedImages(session, images, userRequest, prompt, size, planCount, unet) {
1165
+ async function verifyGeneratedImages(session, images, userRequest, prompt, size, planCount, unet, negativePrompt) {
1199
1166
  const verifyBaseUrl = String(cfg.verifyLlmBaseUrl || '').trim()
1200
1167
  const verifyModel = String(cfg.verifyLlmModel || '').trim()
1201
1168
  if (!verifyBaseUrl || !verifyModel) {
1202
- return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt }
1169
+ return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt, negativePrompt }
1203
1170
  }
1204
1171
  const passScore = Math.max(0, Math.min(10, parseInt(cfg.multiVerifyPassScore) || 6))
1205
1172
  const candidateCount = Math.max(1, Math.min(3, parseInt(cfg.multiCandidateCount) || 2))
@@ -1207,9 +1174,10 @@ exports.apply = async function apply(ctx, cfg) {
1207
1174
  const systemPrompt = buildVerifySystemPrompt(true, planCount)
1208
1175
  const candidates = []
1209
1176
  let lastVerdict = null
1210
- let retries = 0
1211
- let currentImages = images
1212
- let currentPrompt = prompt
1177
+ let retries = 0
1178
+ let currentImages = images
1179
+ let currentPrompt = prompt
1180
+ let currentNegativePrompt = negativePrompt
1213
1181
 
1214
1182
  async function verifyOnce(imgs, userReq) {
1215
1183
  const controller = new AbortController()
@@ -1307,15 +1275,15 @@ exports.apply = async function apply(ctx, cfg) {
1307
1275
  data = extractVerifyJson(reply)
1308
1276
  } catch (e) {
1309
1277
  logger.warn(`多人视觉校验失败:${e.message}`)
1310
- return { ok: true, degraded: true, message: session.text('.multi-verify-error', [String(e && e.message || e)]), verdict: null, outputs: images, prompt: currentPrompt }
1278
+ return { ok: true, degraded: true, message: session.text('.multi-verify-error', [String(e && e.message || e)]), verdict: null, outputs: images, prompt: currentPrompt, negativePrompt: currentNegativePrompt }
1311
1279
  }
1312
1280
  if (!data) {
1313
1281
  logger.warn(`多人视觉校验返回无法解析:${reply.slice(0, 200)}`)
1314
- return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt: currentPrompt }
1282
+ return { ok: true, degraded: true, message: '', verdict: null, outputs: images, prompt: currentPrompt, negativePrompt: currentNegativePrompt }
1315
1283
  }
1316
1284
  const verdict = verdictFromData(data)
1317
1285
  verdict.skipped = false
1318
- candidates.push({ outputs: currentImages, verdict, prompt: currentPrompt })
1286
+ candidates.push({ outputs: currentImages, verdict, prompt: currentPrompt, negativePrompt: currentNegativePrompt })
1319
1287
  selectedOutputs = currentImages
1320
1288
  selectedVerdict = verdict
1321
1289
 
@@ -1329,12 +1297,13 @@ exports.apply = async function apply(ctx, cfg) {
1329
1297
  if (hint) {
1330
1298
  currentPrompt = `${userRequest}\n【上次问题,请修正】${hint}`
1331
1299
  }
1332
- const regen = await runComfyGenerate(currentPrompt, size, { unet })
1300
+ const regen = await runComfyGenerate(currentPrompt, size, { unet, negativePrompt: currentNegativePrompt })
1333
1301
  if (!regen.ok) {
1334
1302
  logger.warn(`多人校验重试生成失败:${regen.message}`)
1335
1303
  break
1336
1304
  }
1337
- currentImages = regen.outputs
1305
+ currentImages = regen.outputs
1306
+ currentNegativePrompt = regen.negativePrompt || currentNegativePrompt
1338
1307
  }
1339
1308
 
1340
1309
  // 多候选挑选
@@ -1345,7 +1314,7 @@ exports.apply = async function apply(ctx, cfg) {
1345
1314
  selectedOutputs = best.outputs
1346
1315
  selectedVerdict = best.verdict
1347
1316
  if (!multiAccepted && !cfg.multiSendDegradedCandidate) {
1348
- return { ok: false, discarded: true, message: session.text('.multi-verify-discarded'), verdict: selectedVerdict, outputs: [], prompt: best.prompt }
1317
+ return { ok: false, discarded: true, message: session.text('.multi-verify-discarded'), verdict: selectedVerdict, outputs: [], prompt: best.prompt, negativePrompt: best.negativePrompt }
1349
1318
  }
1350
1319
  const noteParts = []
1351
1320
  if (multiAccepted) {
@@ -1354,7 +1323,7 @@ exports.apply = async function apply(ctx, cfg) {
1354
1323
  noteParts.push(session.text('.multi-verify-degraded', selectedVerdict.issues.length ? '(' + selectedVerdict.issues.join(';').slice(0, 80) + ')' : ''))
1355
1324
  }
1356
1325
  if (retries) noteParts.push(session.text('.multi-verify-failed', [selectedVerdict.issues.length ? ':' + selectedVerdict.issues.join(';').slice(0, 80) : '', retries]))
1357
- return { ok: true, degraded: false, message: noteParts.join('\n'), verdict: selectedVerdict, outputs: selectedOutputs, prompt: best.prompt }
1326
+ return { ok: true, degraded: false, message: noteParts.join('\n'), verdict: selectedVerdict, outputs: selectedOutputs, prompt: best.prompt, negativePrompt: best.negativePrompt }
1358
1327
  }
1359
1328
 
1360
1329
  function buildVerifySystemPrompt(multiPerson, planCount) {
@@ -1367,7 +1336,7 @@ exports.apply = async function apply(ctx, cfg) {
1367
1336
  }
1368
1337
 
1369
1338
  // 共享批量执行器:一次性扣除总价,逐张生成,单张失败只退该张单价。
1370
- // runOne(i) 需返回 { ok, outputs, seed, prompt, message? };返回数组为多张输出(如视觉校验候选)。
1339
+ // runOne(i) 需返回 { ok, outputs, seed, prompt, negativePrompt, message? };返回数组为多张输出(如视觉校验候选)。
1371
1340
  async function executeBatch(USERID, isAdmin, count, unitPrice, runOne) {
1372
1341
  const results = []
1373
1342
  let successCount = 0
@@ -1381,7 +1350,7 @@ exports.apply = async function apply(ctx, cfg) {
1381
1350
  const outputs = Array.isArray(item.outputs) ? item.outputs : (item.outputs ? [item.outputs] : [])
1382
1351
  if (item.ok && outputs.length) {
1383
1352
  successCount += 1
1384
- results.push({ i, ok: true, outputs, seed: item.seed, prompt: item.prompt || '', note: item.note || '' })
1353
+ results.push({ i, ok: true, outputs, seed: item.seed, prompt: item.prompt || '', negativePrompt: item.negativePrompt || '', note: item.note || '' })
1385
1354
  } else {
1386
1355
  if (!isAdmin) await refundP(USERID, unitPrice)
1387
1356
  if (cfg.outputLogs) logger.warn(`批量第 ${i + 1} 张生成失败(${USERID}):${item.message || '无输出'}`)
@@ -1477,11 +1446,11 @@ exports.apply = async function apply(ctx, cfg) {
1477
1446
  }
1478
1447
  if (!result.ok || !result.outputs || !result.outputs.length) return result
1479
1448
  if (!cfg.multiVerifyEnabled) {
1480
- return { ok: true, outputs: result.outputs, seed: result.seed, prompt: finalPrompt, note: session.text('.multi-degraded', ['(未启用校验或未配置视觉模型)']) }
1449
+ return { ok: true, outputs: result.outputs, seed: result.seed, prompt: finalPrompt, negativePrompt: result.negativePrompt, note: session.text('.multi-degraded', ['(未启用校验或未配置视觉模型)']) }
1481
1450
  }
1482
- const verified = await verifyGeneratedImages(session, result.outputs, text, finalPrompt, size, plan.characters.length, unet)
1451
+ const verified = await verifyGeneratedImages(session, result.outputs, text, finalPrompt, size, plan.characters.length, unet, result.negativePrompt || multiNegative)
1483
1452
  if (!verified.ok) return { ok: false, message: verified.message }
1484
- return { ok: true, outputs: verified.outputs, seed: result.seed, prompt: verified.prompt || finalPrompt, note: verified.message || '' }
1453
+ return { ok: true, outputs: verified.outputs, seed: result.seed, prompt: verified.prompt || finalPrompt, negativePrompt: verified.negativePrompt || result.negativePrompt || multiNegative, note: verified.message || '' }
1485
1454
  }
1486
1455
 
1487
1456
  const { results, successCount } = await executeBatch(USERID, isAdmin, count, price, runOne)
@@ -1495,7 +1464,7 @@ exports.apply = async function apply(ctx, cfg) {
1495
1464
  for (const item of results) {
1496
1465
  if (item.ok) {
1497
1466
  allOutputs.push(...item.outputs)
1498
- forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt })))
1467
+ forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt, negativePrompt: item.negativePrompt })))
1499
1468
  if (item.seed != null) seeds.push(item.seed)
1500
1469
  if (item.note) notes.push(item.note)
1501
1470
  } else {
@@ -1630,8 +1599,8 @@ exports.apply = async function apply(ctx, cfg) {
1630
1599
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 连续图已扣除 ${count * price} P 点(${count} 阶段 × ${price},seed=${seed}),余额 ${saving - count * price}`)
1631
1600
  }
1632
1601
 
1633
- // 队列:预排队全部阶段(先查容量再入队)
1634
- const queued = await enqueueBatch(count, (i) => runComfyGenerate(stagePrompts[i], size, { seed, unet }), { USERID, isAdmin, totalPrice: count * price })
1602
+ // 队列:预排队全部阶段(先查容量再入队)
1603
+ const queued = await enqueueBatch(count, (i) => runComfyGenerate(stagePrompts[i], size, { seed, unet }), { USERID, isAdmin, totalPrice: count * price })
1635
1604
  if (!queued.ok) return queued.message
1636
1605
  const queuedTasks = queued.tasks
1637
1606
  const firstPosition = queued.firstPosition
@@ -1647,15 +1616,14 @@ exports.apply = async function apply(ctx, cfg) {
1647
1616
  await sendNotices(session, notice)
1648
1617
  await sendNotices(session, [feedback.chargeNotice], { quote: true })
1649
1618
 
1650
- // 单阶段生成(共用 seed)
1651
- const runOne = async (i) => {
1652
- let result
1619
+ // 单阶段生成(共用 seed)
1620
+ const runOne = async (i) => {
1621
+ let result
1653
1622
  if (cfg.queueEnabled) {
1654
1623
  try { result = await queuedTasks[i] } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
1655
1624
  } else {
1656
1625
  try { result = await runComfyGenerate(stagePrompts[i], size, { seed, unet }) } catch (e) { result = { ok: false, message: `生成失败:${e.message}` } }
1657
1626
  }
1658
- result.prompt = stagePrompts[i]
1659
1627
  return result
1660
1628
  }
1661
1629
 
@@ -1669,7 +1637,7 @@ exports.apply = async function apply(ctx, cfg) {
1669
1637
  for (const item of results) {
1670
1638
  if (item.ok) {
1671
1639
  allOutputs.push(...item.outputs)
1672
- forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || stagePrompts[item.i] || '' })))
1640
+ forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || stagePrompts[item.i] || '', negativePrompt: item.negativePrompt })))
1673
1641
  if (item.seed != null) seeds.push(item.seed)
1674
1642
  } else {
1675
1643
  failures.push(`第 ${item.i + 1} 阶段:${item.message}`)
@@ -1851,13 +1819,7 @@ exports.apply = async function apply(ctx, cfg) {
1851
1819
  return await handleGenerateSeries(session, seriesMatch[1].trim())
1852
1820
  }
1853
1821
 
1854
- // 以图生图指令:p-draw i2i <描述>(需同一条消息附带原图)
1855
- const i2iMatch = text.match(/^i2i\s*(.*)$/i)
1856
- if (i2iMatch) {
1857
- return await handleGenerateI2I(session, i2iMatch[1].trim())
1858
- }
1859
-
1860
- // 多人指令:p-draw 多人 <描述>
1822
+ // 多人指令:p-draw 多人 <描述>
1861
1823
  const multiMatch = text.match(/^(?:多人|多人生图|双人|三人|群像)\s*(.*)$/)
1862
1824
  if (multiMatch) {
1863
1825
  return await handleGenerateMulti(session, multiMatch[1].trim())
@@ -1982,230 +1944,10 @@ exports.apply = async function apply(ctx, cfg) {
1982
1944
  return await handleGenerate(session, text)
1983
1945
  })
1984
1946
 
1985
- // ---------------- 以图生图(p-draw i2i) ----------------
1986
- async function handleGenerateI2I(session, rawText) {
1987
- if (cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
1988
- return session.text('.i2i-no-custom-workflow')
1989
- }
1990
- const image = await extractImageFromSession(session)
1991
- if (!image) return session.text('.i2i-no-image')
1992
- let uploadName
1993
- try {
1994
- uploadName = await uploadImageToComfyui(image)
1995
- } catch (e) {
1996
- logger.warn(`上传原图失败:${e.message}`)
1997
- return session.text('.i2i-upload-fail', [e.message])
1998
- }
1999
- if (cfg.outputLogs) logger.info(`[p-draw] i2i ${session.userId} 原图已上传:${uploadName}`)
2000
-
2001
- // 能力检测(ControlNet / Anima IP-Adapter)
2002
- const caps = await detectI2ICapabilities()
2003
-
2004
- // 处理模式:i2iMode=ask 时交互询问;style/ootd/plain 直接固定
2005
- let mode = 'plain'
2006
- const cfgMode = String(cfg.i2iMode || 'ask').toLowerCase()
2007
- if (cfgMode === 'style' || cfgMode === 'ootd') {
2008
- mode = cfgMode
2009
- } else if (cfgMode === 'ask' && typeof session.prompt === 'function') {
2010
- const chosen = await askI2IMode(session)
2011
- if (chosen === 'cancel') return session.text('.i2i-mode-cancelled')
2012
- if (chosen === null) {
2013
- await session.send(session.text('.i2i-mode-cancelled'))
2014
- return ''
2015
- }
2016
- mode = chosen
2017
- if (mode === 'style') await session.send(session.text('.i2i-mode-style'))
2018
- if (mode === 'ootd') await session.send(session.text('.i2i-mode-ootd'))
2019
- }
2020
- if (cfg.outputLogs) logger.info(`[p-draw] i2i ${session.userId} 模式=${mode} ControlNet=${caps.controlNet.available ? caps.controlNet.model : '-'} IPAdapter=${caps.ipAdapter.available ? 'on' : 'off'}`)
2021
-
2022
- // 所选模式依赖的节点缺失时给出提示(仍会回退普通 img2img,不中断)
2023
- const notices = []
2024
- if (mode === 'style' && (!caps.controlNet.available || !caps.controlNet.model)) {
2025
- notices.push(session.text('.i2i-no-controlnet'))
2026
- }
2027
- if (mode === 'ootd' && (!caps.ipAdapter.available || !String(cfg.i2iIPAdapterPath || '').trim())) {
2028
- notices.push(session.text('.i2i-no-ipadapter'))
2029
- }
2030
- if (notices.length) {
2031
- try { await session.send(notices.join('\n')) } catch (e) { logger.warn(`发送 i2i 提示失败:${e.message}`) }
2032
- }
2033
-
2034
- // 识图:仅当启用、非无优化模式、且已配置 LLM(识图结果会注入提示词优化)时才执行
2035
- let taggerTags = ''
2036
- if (cfg.taggerEnabled && cfg.llmModel && cfg.llmBaseUrl && !stripRawPrefix(rawText).raw) {
2037
- try {
2038
- taggerTags = await taggerImage(uploadName)
2039
- if (cfg.outputLogs) logger.info(`[p-draw] i2i ${session.userId} 识图完成:${taggerTags.slice(0, 120)}${taggerTags.length > 120 ? '...' : ''}`)
2040
- } catch (e) {
2041
- logger.warn(`识图失败(继续生图):${e.message}`)
2042
- }
2043
- }
2044
- return await handleGenerate(session, rawText, uploadName, taggerTags, { mode, caps })
2045
- }
2046
-
2047
- // 识图预工作流:LoadImage → WD14Tagger|pysssss,读回原图 tags
2048
- async function taggerImage(uploadName) {
2049
- const model = String(cfg.taggerModel || 'wd-v1-4-convnext-tagger-v2').trim()
2050
- const taggerNodeId = '61'
2051
- const promptBody = {
2052
- '60': { class_type: 'LoadImage', inputs: { image: uploadName } },
2053
- [taggerNodeId]: {
2054
- class_type: 'WD14Tagger|pysssss',
2055
- inputs: {
2056
- image: ['60', 0],
2057
- model,
2058
- threshold: Number(cfg.taggerThreshold) || 0.35,
2059
- character_threshold: Number(cfg.taggerCharacterThreshold) || 0.85,
2060
- replace_underscore: true,
2061
- trailing_comma: false,
2062
- exclude_tags: '',
2063
- },
2064
- },
2065
- }
2066
- const clientId = crypto.randomUUID()
2067
- const submit = await comfyPost('/prompt', { prompt: promptBody, client_id: clientId }, 20000)
2068
- const promptId = submit && submit.prompt_id
2069
- if (!promptId) throw new Error('识图工作流提交失败')
2070
- const timeoutMs = Math.max(1, parseInt(cfg.timeout) || 300) * 1000
2071
- const pollMs = Math.max(1, parseInt(cfg.pollInterval) || 2) * 1000
2072
- const history = await waitComfyResult(ctx, comfyGet, baseUrl(), promptId, clientId, timeoutMs, pollMs)
2073
- if (!history) throw new Error('识图超时')
2074
- const nodeOutput = history.outputs && history.outputs[taggerNodeId]
2075
- const tags = (nodeOutput && Array.isArray(nodeOutput.tags) ? nodeOutput.tags : []).filter(Boolean)
2076
- const first = String(tags[0] || '').trim()
2077
- if (!first) throw new Error('识图未返回标签')
2078
- return first
2079
- }
2080
-
2081
- // 从会话消息里取第一张图片并下载为内存 Buffer
2082
- async function extractImageFromSession(session) {
2083
- const elements = session.elements || []
2084
- const img = elements.find((e) => e.type === 'img' || e.type === 'image')
2085
- if (!img) return null
2086
- const attrs = img.attrs || img.data || {}
2087
- const src = String(attrs.src || '')
2088
- if (!src) return null
2089
- try {
2090
- if (/^file:\/\//i.test(src)) {
2091
- // 修复:Windows 下 file:///K:/... 用 replace 会得到 /K:/...(无法读取),用 fileURLToPath 解析
2092
- let filePath
2093
- try {
2094
- filePath = fileURLToPath(src)
2095
- } catch (e) {
2096
- filePath = src.replace(/^file:\/\//i, '')
2097
- }
2098
- const buffer = await fsp.readFile(filePath)
2099
- return { buffer, ext: path.extname(filePath) || '.png' }
2100
- }
2101
- if (/^data:/i.test(src)) {
2102
- const m = src.match(/^data:image\/([a-zA-Z0-9+]+);base64,(.+)$/)
2103
- if (!m) return null
2104
- const kind = m[1].toLowerCase()
2105
- const ext = kind === 'jpeg' ? '.jpg' : kind === 'webp' ? '.webp' : kind === 'png' ? '.png' : '.' + (kind || 'png')
2106
- return { buffer: Buffer.from(m[2], 'base64'), ext }
2107
- }
2108
- const res = await fetch(src)
2109
- if (!res.ok) return null
2110
- const buffer = Buffer.from(await res.arrayBuffer())
2111
- const mime = String(res.headers.get('content-type') || '')
2112
- const ext = mime.includes('jpeg') ? '.jpg' : mime.includes('webp') ? '.webp' : '.png'
2113
- return { buffer, ext }
2114
- } catch (e) {
2115
- logger.warn(`读取原图失败:${e.message}`)
2116
- return null
2117
- }
2118
- }
2119
-
2120
- // 上传图片到 ComfyUI input 目录,返回 ComfyUI 使用的文件名
2121
- async function uploadImageToComfyui(image) {
2122
- const filename = `pdraw_i2i_${Date.now()}_${crypto.randomBytes(4).toString('hex')}${image.ext || '.png'}`
2123
- const form = new FormData()
2124
- form.append('image', new Blob([image.buffer]), filename)
2125
- form.append('overwrite', 'true')
2126
- form.append('type', 'input')
2127
- const res = await fetch(baseUrl() + '/upload/image', { method: 'POST', body: form })
2128
- if (!res.ok) throw new Error(`HTTP ${res.status}`)
2129
- const data = await res.json().catch(() => ({}))
2130
- if (!data || !data.name) throw new Error('ComfyUI 未返回上传文件名')
2131
- return data.name
2132
- }
2133
-
2134
- // 检测 i2i 可用能力:Anima ControlNet-LLLite 节点与权重(comfyui-anima-lllite / kohya-ss/ComfyUI-Anima-LLLite)、
2135
- // LineArt 预处理器(comfyui_controlnet_aux,可选)与 Anima IP-Adapter 专用节点(comfyui-anima-ipadapter)。
2136
- // 检测失败全部视为不可用(回退普通 img2img)。
2137
- // 注意:Qwen-Image InstantX ControlNet 与 Anima(MiniTrainDIT,3584 维)架构不兼容,这里只认 LLLite 权重。
2138
- async function detectI2ICapabilities() {
2139
- const caps = { controlNet: { available: false, model: null, preprocessor: 'Canny' }, ipAdapter: { available: false } }
2140
- let info
2141
- try {
2142
- info = await getObjectInfoCached()
2143
- } catch (e) {
2144
- return caps
2145
- }
2146
- if (!info || typeof info !== 'object') return caps
2147
- // LineArt 预处理器(comfyui_controlnet_aux):AnimeLineArt 更贴近漫画线条,优先;LineArt 次之;都没有则回退 Canny
2148
- if (info.AnimeLineArtPreprocessor) {
2149
- caps.controlNet.preprocessor = 'AnimeLineArtPreprocessor'
2150
- } else if (info.LineArtPreprocessor) {
2151
- caps.controlNet.preprocessor = 'LineArtPreprocessor'
2152
- }
2153
- // Anima ControlNet-LLLite:lllite_name 从 models/controlnet 目录读取,只挑 anima-lllite 系权重
2154
- if (info.AnimaLLLiteApply_sdscripts) {
2155
- const llliteModels = availableModels(info, 'AnimaLLLiteApply_sdscripts', 'lllite_name')
2156
- if (cfg.outputLogs) logger.info(`[p-draw] i2i LLLite 节点存在,models/controlnet 文件列表=${JSON.stringify(llliteModels)}`)
2157
- if (llliteModels.length) {
2158
- const configured = String(cfg.controlNetModel || '').trim()
2159
- const norm = (s) => String(s).toLowerCase().replace(/[-_ ]/g, '')
2160
- const prefers = ['anima-lllite-lineart', 'anima-lllite', 'lllite-lineart', 'lllite']
2161
- const pool = llliteModels.filter((m) => /lllite/i.test(String(m)))
2162
- const scored = pool.map((m) => ({ m, s: prefers.reduce((acc, p, i) => acc + (norm(m) === norm(p) ? prefers.length - i : 0), 0) }))
2163
- scored.sort((a, b) => b.s - a.s)
2164
- const best = (scored[0] && scored[0].m) || null
2165
- caps.controlNet.model = configured && llliteModels.includes(configured) ? configured : best
2166
- caps.controlNet.available = !!caps.controlNet.model
2167
- }
2168
- // 换风格专用主模型必须是 28-block(与 LLLite 权重匹配),检测它是否已安装
2169
- const styleModel = String(cfg.i2iUnetName || '').trim() || cfg.unetName
2170
- const unetList = availableModels(info, 'UNETLoader', 'unet_name')
2171
- if (unetList.length && !unetList.includes(styleModel)) {
2172
- logger.warn(`[p-draw] i2i 换风格专用主模型 ${styleModel} 不在 models/diffusion_models 中(现有:${JSON.stringify(unetList)})。LLLite 权重为 28-block,请安装 anima-base-v1.0 或修改 i2iUnetName 配置,否则换风格会报 depth_embed slices missing`)
2173
- caps.controlNet.modelMismatch = true
2174
- }
2175
- } else if (cfg.outputLogs) {
2176
- logger.info('[p-draw] i2i 未检测到 AnimaLLLiteApply_sdscripts 节点(请确认已安装 kohya-ss/ComfyUI-Anima-LLLite 并重启 ComfyUI)')
2177
- }
2178
- if (info.AnimaIPAdapterLoader && info.AnimaIPAdapterApply && info.AnimaSiglipeEncodeImage) {
2179
- caps.ipAdapter.available = true
2180
- }
2181
- if (cfg.outputLogs) logger.info(`[p-draw] i2i 能力检测:ControlNet(LLLite)=${JSON.stringify(caps.controlNet)} IPAdapter=${JSON.stringify(caps.ipAdapter)}`)
2182
- return caps
2183
- }
2184
-
2185
- // 交互式询问 i2i 处理模式,返回 'style' / 'ootd' / 'cancel' / null(超时或无法询问时 null)
2186
- async function askI2IMode(session, i18nAsk) {
2187
- if (typeof session.prompt !== 'function') return null
2188
- await session.send(i18nAsk || session.text('.i2i-mode-ask'))
2189
- const reply = await session.prompt((cfg.i2iAskTimeout || 60) * 1000).catch(() => null)
2190
- const ans = String((reply && (reply.content != null ? reply.content : reply)) || '').trim()
2191
- if (/^(1|①|换风格|风格|漫画|漫画化|style|stylechange)$/i.test(ans)) return 'style'
2192
- if (/^(2|②|换装|换装换姿势|换衣服|换姿势|ootd|outfit)$/i.test(ans)) return 'ootd'
2193
- if (/^(3|③|取消|不生成|不要|算了|cancel|no)$/i.test(ans)) return 'cancel'
2194
- if (!ans) return null
2195
- await session.send(session.text('.i2i-mode-invalid'))
2196
- return askI2IMode(session, i18nAsk)
2197
- }
2198
-
2199
- async function handleGenerate(session, rawText, i2iImage = null, taggerTags = '', i2iOpts = null) {
2200
- const USERID = session.userId
2201
- const isAdmin = isAdminUser(session)
2202
- const unet = await resolveUnet(USERID)
2203
- const i2iRule = i2iImage
2204
- ? '这是以图生图(img2img):原图的构图、主体与色调会被保留。请主要描述你希望发生的变化(风格、服装、表情、场景改造、细节调整等),不要重复描述原图已有的细节。'
2205
- + (taggerTags
2206
- ? `\n\n原图内容参考(本地识图自动识别出的标签,用于了解原图已包含的内容;不要照抄全部标签,只参考与用户改动需求相关的部分):\n${taggerTags}`
2207
- : '')
2208
- : ''
1947
+ async function handleGenerate(session, rawText) {
1948
+ const USERID = session.userId
1949
+ const isAdmin = isAdminUser(session)
1950
+ const unet = await resolveUnet(USERID)
2209
1951
  if (cfg.outputLogs) {
2210
1952
  logger.info(`[p-draw] 请求 userId=${USERID} isAdmin=${isAdmin} adminUsers=${JSON.stringify(cfg.adminUsers || [])} normalizeId=${normalizeId(USERID)}`)
2211
1953
  }
@@ -2219,12 +1961,9 @@ exports.apply = async function apply(ctx, cfg) {
2219
1961
  const parsedBatch = parseBatchCount(parsedSize.prompt, cfg.batchMax)
2220
1962
  // 固定种子解析(--seed:xxx / --seed xxx / --seed=xxx),并从提示词中剥离
2221
1963
  const parsedSeed = parseSeed(parsedBatch.prompt)
2222
- // i2i 去噪强度解析(--denoise 0.6 / --去噪 0.4),并从提示词中剥离
2223
- const parsedDenoise = i2iImage ? parseDenoise(parsedSeed.prompt) : { denoise: null, prompt: parsedSeed.prompt }
2224
- const text = parsedDenoise.prompt
2225
- const seed = parsedSeed.seed
2226
- const denoise = parsedDenoise.denoise
2227
- const count = parsedBatch.count
1964
+ const text = parsedSeed.prompt
1965
+ const seed = parsedSeed.seed
1966
+ const count = parsedBatch.count
2228
1967
 
2229
1968
  // P 点校验(按总价 = 张数 × 单价)
2230
1969
  // P 点校验(按总价 = 张数 × 单价)
@@ -2258,13 +1997,13 @@ exports.apply = async function apply(ctx, cfg) {
2258
1997
  const tokenOpt = !globalOpt && !isAdmin && cfg.llmModel && cfg.llmBaseUrl
2259
1998
  if (globalOpt) {
2260
1999
  // 全局优化开启:一次优化,整批复用同一提示词
2261
- const optimized = await optimizePrompt(session, userPrompt, false, i2iRule)
2000
+ const optimized = await optimizePrompt(session, userPrompt, false)
2262
2001
  finalPrompt = optimized.prompt
2263
2002
  degraded = !optimized.ok
2264
2003
  optimizedReason = optimized.reason || ''
2265
2004
  } else if (adminOpt) {
2266
2005
  // 管理员在全局关闭时也免费优化(不耗券)
2267
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule)
2006
+ const optimized = await optimizePrompt(session, userPrompt, true)
2268
2007
  finalPrompt = optimized.prompt
2269
2008
  degraded = !optimized.ok
2270
2009
  optimizedReason = optimized.reason || ''
@@ -2316,14 +2055,9 @@ exports.apply = async function apply(ctx, cfg) {
2316
2055
  if (cfg.outputLogs) logger.info(`[p-draw] ${USERID} 已扣除 ${count * cfg.price} P 点(${count} 张 × ${cfg.price}),余额 ${saving - count * cfg.price}`)
2317
2056
  }
2318
2057
 
2319
- // i2i 运行参数:模式(style/ootd/plain)、--denoise 覆盖值、检测到的能力
2320
- const i2iRun = i2iImage
2321
- ? { i2iImage, i2i: { mode: (i2iOpts && i2iOpts.mode) || 'plain', denoise: denoise != null ? denoise : null, caps: (i2iOpts && i2iOpts.caps) || null } }
2322
- : {}
2323
2058
  // 用户手写了 negative: 区块时,默认负面词仍保留;用户 tag 只补充未出现的部分。
2324
2059
  const generationOverrides = Object.assign(
2325
2060
  { unet, seed },
2326
- i2iRun,
2327
2061
  userNegativePrompt ? { negativePrompt: mergeNegativePrompts(cfg.negativePrompt, userNegativePrompt) } : {},
2328
2062
  )
2329
2063
 
@@ -2334,7 +2068,7 @@ exports.apply = async function apply(ctx, cfg) {
2334
2068
  const queued = await enqueueBatch(count, async (i) => {
2335
2069
  let p = finalPrompt
2336
2070
  if (perImageOptimize) {
2337
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
2071
+ const optimized = await optimizePrompt(session, userPrompt, true, searchCache)
2338
2072
  p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
2339
2073
  }
2340
2074
  const generated = await runComfyGenerate(p, parsedSize.size, generationOverrides)
@@ -2370,7 +2104,7 @@ exports.apply = async function apply(ctx, cfg) {
2370
2104
  const runOne = async (i) => {
2371
2105
  let p = finalPrompt
2372
2106
  if (perImageOptimize) {
2373
- const optimized = await optimizePrompt(session, userPrompt, true, i2iRule, searchCache)
2107
+ const optimized = await optimizePrompt(session, userPrompt, true, searchCache)
2374
2108
  p = appendInlineProtectedTags(composePrompt(optimized.prompt || userPrompt, raw).prompt, userPrompt, raw)
2375
2109
  }
2376
2110
  let result
@@ -2393,7 +2127,7 @@ exports.apply = async function apply(ctx, cfg) {
2393
2127
  for (const item of results) {
2394
2128
  if (item.ok) {
2395
2129
  allOutputs.push(...item.outputs)
2396
- forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt })))
2130
+ forwardOutputs.push(...item.outputs.map(src => ({ src, prompt: item.prompt || finalPrompt, negativePrompt: item.negativePrompt })))
2397
2131
  if (item.seed != null) seeds.push(item.seed)
2398
2132
  } else {
2399
2133
  failures.push(`第 ${item.i + 1} 张:${item.message}`)
@@ -2561,5 +2295,5 @@ exports.apply = async function apply(ctx, cfg) {
2561
2295
  })
2562
2296
 
2563
2297
  // 暴露内部接口供自动化测试调用(Koishi 忽略 apply 返回值,不影响生产行为)
2564
- return { couponConfirmFlow, buyCouponsAndConsume, normalizeConfirm, resolveCouponPrice, handleGenerateI2I, extractImageFromSession, uploadImageToComfyui, taggerImage, animaI2IWorkflow, animaStyleI2IWorkflow, animaOotdI2IWorkflow, buildI2IWorkflow, detectI2ICapabilities, parseDenoise, sendNotices, sendImagesAsForward, executeBatch }
2298
+ return { couponConfirmFlow, buyCouponsAndConsume, normalizeConfirm, resolveCouponPrice, sendNotices, sendImagesAsForward, executeBatch }
2565
2299
  }
package/lib/i18n.js CHANGED
@@ -43,20 +43,7 @@ const zhCN = {
43
43
  multiPrice: { $description: '多人指令(p-draw 多人)单张消耗的 P 点' },
44
44
  couponPrice: { $description: '提示词优化券单价(P 点/张,购买询问时显示)' },
45
45
  couponAskTimeout: { $description: '提示词优化券确认等待时间(秒)' },
46
- img2imgDenoise: { $description: '普通以图生图(p-draw i2i)的去噪强度,越小越接近原图(建议 0.4-0.7)' },
47
- i2iMode: { $description: 'i2i 模式选择方式:ask=每次询问 / style=直接换风格(漫画化)/ ootd=直接换装换姿势 / plain=普通 img2img' },
48
- i2iAskTimeout: { $description: 'i2i 模式询问等待时间(秒)' },
49
46
  seriesAskTimeout: { $description: '连续图 LLM 使用确认等待时间(秒)' },
50
- i2iStyleDenoise: { $description: '换风格模式(漫画化)的去噪强度,越大风格变化越彻底(建议 0.7-0.85)' },
51
- i2iOotdDenoise: { $description: '换装换姿势模式(保留角色)的去噪强度(建议 0.5-0.6)' },
52
- i2iControlNetStrength: { $description: '换风格模式的 ControlNet 强度,越大构图锁得越死(建议 0.5-0.8)' },
53
- i2iIPAdapterPath: { $description: 'Anima IP-Adapter 模型文件路径(换装换姿势模式保脸用;需安装 comfyui-anima-ipadapter 节点并把模型路径填到这里)' },
54
- i2iIPAdapterWeight: { $description: '换装换姿势模式的 IP-Adapter 权重,越大角色特征保留越强(建议 0.6-1.0)' },
55
- controlNetModel: { $description: 'ControlNet 模型文件名(留空自动检测 Qwen/Anima 系 ControlNet;需放到 ComfyUI/models/controlnet)' },
56
- taggerEnabled: { $description: 'i2i 前自动识图(需 ComfyUI 安装 WD14 Tagger 节点与模型;识别出的标签会注入提示词优化)' },
57
- taggerModel: { $description: '识图模型名(WD14 Tagger 节点里可选模型)' },
58
- taggerThreshold: { $description: '识图标签置信度阈值' },
59
- taggerCharacterThreshold: { $description: '识图角色标签置信度阈值' },
60
47
  adminUsers: { $description: '免 P 点管理员用户 ID 列表' },
61
48
  outputLogs: { $description: '是否在控制台输出详细日志' },
62
49
  multiVerifyEnabled: { $description: '多人图生成后启用视觉校验(需配置下方视觉模型)' },
@@ -76,7 +63,7 @@ const zhCN = {
76
63
  description: '连接本地 ComfyUI 生图,消耗 P 点',
77
64
  messages: {
78
65
  'not-permitted': 'ComfyUI 助手已关闭,或当前用户没有使用权限。',
79
- 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 连接状态与模型可用性)',
66
+ usage: 'p-draw 帮助(本指令名可自行更换,如 /anm):\n\n【生成】\n p-draw <描述>\n 可加 --seed 数字、x3 / 3张、竖图 / 横图 / 方图\n 无优化 <tags> 可直接使用手写 tags\n\n【多人生成】\n p-draw 多人 <描述>(2-4 人画面,依赖 LLM 规划)\n\n【连续图】\n p-draw 连续 <角色>:<阶段1> → <阶段2> → ...\n 每阶段独立 T2I,共用 seed;阶段分隔:→ / -> / |\n\n【提示词优化】\n 配置 LLM 后可将自然语言转为 Danbooru tags;普通用户可使用提示词优化券\n\n【画师组】\n 创建 / 追加 / 切换 / 查看 / 删除画师组\n\n【固定角色】\n 添加角色 名称=tags\n\n【模型】\n p-draw 模型(查看) / p-draw 模型 <名称>(切换)/ p-draw 模型 默认(重置)\n\n【状态】\n p-draw 状态\n\n生成图片以合并转发发送,并附对应的 Positive / Negative 提示词;扣费提醒单独引用原指令。',
80
67
  'account-notExists': '君现在还没有 p 点,请先签到哦',
81
68
  'no-enough-p': '君的 p 点不够 {0}p 哦,先去签个到吧qwq',
82
69
  'no-prompt': '请提供画面描述,例如:p-draw 一个女孩,白色裙子',
@@ -133,16 +120,6 @@ const zhCN = {
133
120
  'model-no-draw': '「模型」只能用来切换/查看模型,不能生图。请先用「p-draw 模型 <名称>」切换,再单独发送要画的内容。',
134
121
  'model-ambiguous': '「{0}」匹配到多个模型,请写得更具体些:\n{1}',
135
122
  'no-optimize': '提示:本次未使用 LLM 优化({0})。',
136
- 'i2i-no-image': 'i2i(以图生图)需要附一张原图。用法:p-draw i2i <描述>,并在同一条消息里带上图片。',
137
- 'i2i-upload-fail': '原图上传 ComfyUI 失败:{0}',
138
- 'i2i-no-custom-workflow': 'i2i(以图生图)暂不支持自定义工作流(customWorkflowEnabled),请关闭后再试。',
139
- 'i2i-mode-ask': '请选择 i2i 处理模式:\n① 换风格(漫画化)——保留原图构图,转成二次元画风\n② 换装换姿势——保留角色长相,重新设计服装/姿势/场景\n③ 取消——不生成\n回复 1 / 2 / 3 或对应名称即可。',
140
- 'i2i-mode-invalid': '没有理解你的选择。请回复 ①换风格(漫画化) / ②换装换姿势 / ③取消。',
141
- 'i2i-mode-cancelled': '已取消本次 i2i 生图。',
142
- 'i2i-mode-style': '已选择【换风格(漫画化)】:保留原图构图,转成二次元画风。',
143
- 'i2i-mode-ootd': '已选择【换装换姿势】:保留角色长相,重新设计服装/姿势/场景。',
144
- 'i2i-no-controlnet': '未检测到可用的 Anima ControlNet-LLLite,本次「换风格」将使用普通 img2img,构图保留效果会弱一些。',
145
- 'i2i-no-ipadapter': '未检测到可用的 Anima IP-Adapter,本次「换装换姿势」将使用普通 img2img,角色保留效果会弱一些。',
146
123
  'multi-no-llm': '多人指令需要 LLM 规划,但当前未配置 llmBaseUrl / llmModel。请管理员在配置中填写后使用。',
147
124
  },
148
125
  },
package/lib/parse.js CHANGED
@@ -1,4 +1,4 @@
1
- // 解析器纯函数库:地址/尺寸/批量/seed/denoise/raw 前缀/tag 合并/预设解析。
1
+ // 解析器纯函数库:地址/尺寸/批量/seed/raw 前缀/tag 合并/预设解析。
2
2
  // 不依赖 koishi,可独立单测。
3
3
 
4
4
  // ------------------------------------------------------------------
@@ -152,18 +152,6 @@ function parseSeed(text) {
152
152
  return { seed, prompt: cleaned }
153
153
  }
154
154
 
155
- // 去噪强度解析:--denoise 0.3 / --denoise=0.6 / --去噪 0.4,并从提示词里剥离
156
- function parseDenoise(text) {
157
- const prompt = String(text || '').trim()
158
- const m = prompt.match(/(?<!\S)--(?:denoise|去噪)\s*(?:=|=|:|:)?\s*(\d+(?:\.\d+)?)/i)
159
- if (!m) return { denoise: null, prompt }
160
- const denoise = Math.min(1, Math.max(0, parseFloat(m[1])))
161
- const cleaned = (prompt.slice(0, m.index) + ' ' + prompt.slice(m.index + m[0].length)).trim()
162
- .replace(/^[\s,,;;::]+|[\s,,;;::]+$/g, '')
163
- .replace(/\s+/g, ' ')
164
- return { denoise, prompt: cleaned }
165
- }
166
-
167
155
  // ------------------------------------------------------------------
168
156
  // 提示词辅助(移植自 anima prompt_presets)
169
157
  // ------------------------------------------------------------------
@@ -269,7 +257,6 @@ module.exports = {
269
257
  cnNumValue,
270
258
  parseBatchCount,
271
259
  parseSeed,
272
- parseDenoise,
273
260
  RAW_PREFIXES,
274
261
  stripRawPrefix,
275
262
  splitPositiveNegativePrompt,
package/lib/workflows.js CHANGED
@@ -39,149 +39,6 @@ function buildWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal
39
39
  return animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed)
40
40
  }
41
41
 
42
- // 以图生图工作流(p-draw i2i):LoadImage → VAEEncode → KSampler(denoise<1)
43
- // 输入图编码为 latent 作为起点,尺寸保持原图,去噪强度由 cfg.img2imgDenoise 控制。
44
- function animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) {
45
- return {
46
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
47
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
48
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
49
- '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
50
- '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
51
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
52
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
53
- '19': {
54
- class_type: 'KSampler',
55
- inputs: {
56
- model: ['44', 0],
57
- positive: ['11', 0],
58
- negative: ['12', 0],
59
- latent_image: ['30', 0],
60
- seed,
61
- steps,
62
- cfg: cfgVal,
63
- sampler_name: cfg.samplerName,
64
- scheduler: cfg.scheduler,
65
- denoise,
66
- },
67
- },
68
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
69
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i' } },
70
- }
71
- }
72
-
73
- // 「换风格(漫画化)」i2i 工作流:LoadImage → 预处理器(LineArt / Canny)→ AnimaLLLiteApply_sdscripts 锁构图,
74
- // VAEEncode 原图作起点,KSampler 用较高 denoise 整体转二次元风格。
75
- // 注意:Anima 是 MiniTrainDIT 架构(隐藏层 3584 维),与 Qwen-Image 系 ControlNet 不兼容
76
- // (InstantX 控制网期望 3584 维文本嵌入,Anima 只给 1024 维 → LayerNorm 崩溃)。
77
- // 因此这里使用 Anima 原生支持的 ControlNet-LLLite(kohya-ss/ComfyUI-Anima-LLLite),
78
- // 节点直接对 MODEL 打补丁并返回补丁后的模型,交给 KSampler。
79
- // 预处理器:comfyui_controlnet_aux 的 AnimeLineArt/LineArt 节点(需安装该自定义节点);
80
- // 未安装时回退 ComfyUI 内置 Canny。anima-lllite-lineart 权重是按 lineart 训练的,用 LineArt 比 Canny 更贴。
81
- function animaStyleI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, controlNetModel, controlNetStrength, denoise, preprocessor) {
82
- const detectRes = Math.max(width, height)
83
- const preprocessorNode = preprocessor === 'AnimeLineArtPreprocessor' || preprocessor === 'LineArtPreprocessor'
84
- ? { class_type: preprocessor, inputs: { image: ['13', 0], detect_resolution: detectRes, resolution: detectRes } }
85
- : { class_type: 'Canny', inputs: { image: ['13', 0], low_threshold: 0.4, high_threshold: 0.8 } }
86
- return {
87
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.i2iUnetName || cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
88
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
89
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
90
- '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
91
- '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
92
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
93
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
94
- '70': preprocessorNode,
95
- '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 } },
96
- '19': {
97
- class_type: 'KSampler',
98
- inputs: {
99
- model: ['71', 0],
100
- positive: ['11', 0],
101
- negative: ['12', 0],
102
- latent_image: ['30', 0],
103
- seed,
104
- steps,
105
- cfg: cfgVal,
106
- sampler_name: cfg.samplerName,
107
- scheduler: cfg.scheduler,
108
- denoise,
109
- },
110
- },
111
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
112
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i_style' } },
113
- }
114
- }
115
-
116
- // 「换装换姿势」i2i 工作流:LoadImage → AnimaSiglipeEncodeImage → AnimaIPAdapterApply 保角色特征,
117
- // VAEEncode 原图作起点,KSampler 用中等 denoise 重画服装/姿势/场景。
118
- function animaOotdI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, ipAdapterPath, ipAdapterWeight, denoise) {
119
- return {
120
- '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
121
- '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
122
- '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
123
- '13': { class_type: 'LoadImage', inputs: { image: inputImage } },
124
- '30': { class_type: 'VAEEncode', inputs: { pixels: ['13', 0], vae: ['15', 0] } },
125
- '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
126
- '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
127
- '80': { class_type: 'AnimaIPAdapterLoader', inputs: { ipadapter_path: ipAdapterPath } },
128
- '81': { class_type: 'AnimaSiglipeEncodeImage', inputs: { image: ['13', 0] } },
129
- '82': { class_type: 'AnimaIPAdapterApply', inputs: { model: ['44', 0], ipadapter: ['80', 0], siglip_features: ['81', 0], start_at: 0, end_at: 1, weight: ipAdapterWeight } },
130
- '19': {
131
- class_type: 'KSampler',
132
- inputs: {
133
- model: ['82', 0],
134
- positive: ['11', 0],
135
- negative: ['12', 0],
136
- latent_image: ['30', 0],
137
- seed,
138
- steps,
139
- cfg: cfgVal,
140
- sampler_name: cfg.samplerName,
141
- scheduler: cfg.scheduler,
142
- denoise,
143
- },
144
- },
145
- '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
146
- '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm_i2i_ootd' } },
147
- }
148
- }
149
-
150
- // 根据 i2i 模式与已检测到的节点能力,决定最终工作流与去噪强度。
151
- // mode:style=换风格(漫画化)/ ootd=换装换姿势 / 其他=基础 img2img
152
- // caps:{ controlNet: { available, model }, ipAdapter: { available } }(由 detectI2ICapabilities 返回)
153
- // 返回 { promptBody, kind, denoise },kind 为 plain / controlnet / ipadapter。
154
- function buildI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, mode, denoiseOverride, caps) {
155
- const fallbackDenoise = Number(cfg.img2imgDenoise) || 0.55
156
- if (mode === 'style') {
157
- if (caps && caps.controlNet && caps.controlNet.available && caps.controlNet.model) {
158
- const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iStyleDenoise) || 0.75)
159
- return {
160
- kind: 'controlnet',
161
- denoise,
162
- promptBody: animaStyleI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, caps.controlNet.model, Number(cfg.i2iControlNetStrength) || 0.7, denoise, caps.controlNet.preprocessor),
163
- }
164
- }
165
- // 没有 ControlNet:换风格又不想崩构图,denoise 自动压到 0.5 以内
166
- const denoise = denoiseOverride != null ? denoiseOverride : Math.min(Number(cfg.i2iStyleDenoise) || 0.75, 0.5)
167
- return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
168
- }
169
- if (mode === 'ootd') {
170
- if (caps && caps.ipAdapter && caps.ipAdapter.available && String(cfg.i2iIPAdapterPath || '').trim()) {
171
- const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iOotdDenoise) || 0.55)
172
- return {
173
- kind: 'ipadapter',
174
- denoise,
175
- promptBody: animaOotdI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, String(cfg.i2iIPAdapterPath).trim(), Number(cfg.i2iIPAdapterWeight) || 0.8, denoise),
176
- }
177
- }
178
- const denoise = denoiseOverride != null ? denoiseOverride : (Number(cfg.i2iOotdDenoise) || 0.55)
179
- return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
180
- }
181
- const denoise = denoiseOverride != null ? denoiseOverride : fallbackDenoise
182
- return { kind: 'plain', denoise, promptBody: animaI2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, inputImage, denoise) }
183
- }
184
-
185
42
  function customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
186
43
  // lib/ 子目录下运行,customWorkflowPath 相对插件根目录,故需要回退一层
187
44
  const rawPath = path.resolve(__dirname, '..', cfg.customWorkflowPath)
@@ -276,10 +133,6 @@ function conditioningTextNodeIds(workflow, inputName, textNodes) {
276
133
  module.exports = {
277
134
  animaT2IWorkflow,
278
135
  buildWorkflow,
279
- animaI2IWorkflow,
280
- animaStyleI2IWorkflow,
281
- animaOotdI2IWorkflow,
282
- buildI2IWorkflow,
283
136
  customWorkflow,
284
137
  conditioningTextNodeIds,
285
138
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "koishi-plugin-p-draw",
3
3
  "description": "p点-绘图,连接本地 ComfyUI 生图。支持自然语言优化、尺寸选择、画师组、固定角色、生成队列,需要配合 p-qiandao 使用",
4
- "version": "1.3.5",
4
+ "version": "1.4.0",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
package/readme.md CHANGED
@@ -6,7 +6,7 @@ p点-绘图插件,连接本地 ComfyUI 生图,参考 AstrBot 的 [anima 绘
6
6
 
7
7
  - `p-draw <描述>`(别名:画图 / 生图 / 绘图 / 画画)文生图。
8
8
  - `p-draw 多人 <描述>` 多人生图(2-4 人画面):LLM 结构化规划人物分组、互动关系与统一场景,未指定尺寸时按人数/肢体接触自动选横图,生成后可选视觉校验。
9
- - `p-draw 连续 <角色>:<阶段1> → <阶段2> → ...` 连续图/过程图(同一角色的变化过程):全阶段共用同一 seed,角色外观尽量一致,默认横图,支持 `|` 分隔与 `--seed` 覆盖。**仅保留 `连续` 一个别名**。
9
+ - `p-draw 连续 <角色>:<阶段1> → <阶段2> → ...` 连续图/过程图(同一角色的变化过程):每阶段独立使用 T2I、全阶段共用同一 seed,角色外观尽量一致,默认横图,支持 `|` 分隔与 `--seed` 覆盖。**仅保留 `连续` 一个别名**。
10
10
  - 批量张数:描述后加 `x3` / `×3` / `3张` / `三张` / `--数量 3` 可一次生成多张(上限 `batchMax`,默认 4),按张数 × 单价扣 P 点。
11
11
  - 自然语言自动优化为 Danbooru tags(可选,需配置 LLM 接口)。
12
12
  - 联网搜索:描述中带 `联网`/`搜索`/`查一下` 等词时自动补充角色设定(需 Tavily Key)。
@@ -14,15 +14,13 @@ p点-绘图插件,连接本地 ComfyUI 生图,参考 AstrBot 的 [anima 绘
14
14
  - 尺寸选择:`竖图` `横图` `方图` `长竖图` `宽屏`,或 `1024x1536:描述` / `--尺寸 1216x832`。
15
15
  - 画师组管理(创建 / 追加 / 切换 / 查看 / 删除)。
16
16
  - 固定角色(`添加角色 名称=tags`)。
17
- - **以图生图**:`p-draw i2i <描述>` 并在同一条消息附一张原图(文件 / 截图 / 链接均可),发送后会让用户选择处理方式(可用 `i2iMode` 固定跳过询问):**① 换风格(漫画化)**——保留原图构图,整体转成二次元画风,优先用 Anima **ControlNet-LLLite**(Canny 锁构图);**② 换装换姿势**——保留角色长相,重新设计服装 / 姿势 / 场景,优先用 Anima IP-Adapter(保脸);**③ 取消**——不生成。支持 `--denoise 0.6` / `--去噪 0.4` 单独调整强度;未安装对应节点 / 模型时自动回退普通 img2img(纯 `LoadImage → VAEEncode → KSampler(denoise<1)`,**无需修改 ComfyUI**),去噪强度由 `img2imgDenoise` 控制(默认 0.55)。不支持自定义工作流(`customWorkflowEnabled`)时使用 i2i 会提示关闭。
18
- - **i2i 自动识图**:配置 `taggerEnabled` 后,i2i 会先通过 ComfyUI 的 **WD14 Tagger** 节点对原图识图,把识别出的标签注入提示词优化,让不支持读图的 LLM(如 DeepSeek)也能"看到"原图内容。需要在 ComfyUI `custom_nodes` 安装 [ComfyUI-WD14-Tagger](https://github.com/pythongosssss/ComfyUI-WD14-Tagger),并把模型 `.onnx` + `.csv` 放进 `custom_nodes/ComfyUI-WD14-Tagger/models/`(模型名由 `taggerModel` 指定,默认 `wd-v1-4-convnext-tagger-v2`)。仅当已配置 LLM(`llmBaseUrl`/`llmModel`)且非 `无优化` 模式时才会识图;识图失败会跳过并正常生图。
19
17
  - **用户自选模型**:`p-draw 模型` 查看当前与可用模型(自动读取 ComfyUI 的 UNET 列表),`p-draw 模型 <名称>` 切换(支持模糊匹配,如 `anima-aesthetic`),`p-draw 模型 默认` 恢复默认。偏好按用户保存(`p_draw_config.user_models`),单人 / 多人 / 连续图都生效;未自选时用 `unetName` 默认模型。自定义工作流(`customWorkflowEnabled`)不参与模型切换。
20
18
  - 画师组与固定角色**保存在数据库 `p_draw_config` 表中**,管理指令不触发插件重载(不会打断正在生成的图,也不会把配置恢复成默认)。数据库中的值在重启后覆盖配置里的同名项。
21
19
  - 自定义 ComfyUI 工作流 JSON。
22
20
  - 生成队列(逐张顺序执行,防止争抢 GPU)。
23
21
  - `p-draw 状态` 查看 ComfyUI 连接状态与模型可用性。
24
22
  - `p-draw help`(或 `帮助`)查看全部指令帮助,包含生成 / 多人 / 批量张数 / 尺寸 / 画师组 / 状态等。
25
- - 发图时会引用你触发指令的原消息。
23
+ - 发图时会以合并转发发送,每张图片后附对应的 Positive / Negative 提示词;扣费提醒单独引用触发指令。
26
24
 
27
25
  ## 安装
28
26
 
@@ -52,7 +50,6 @@ plugins:
52
50
  画图 创建画师组 千代风格=@artist_a, @artist_b,
53
51
  画图 切换画师组 千代风格
54
52
  画图 添加角色 狐莉=1girl, solo, fox girl
55
- 画图 i2i 换成晚礼服,背景换成舞台灯光 --denoise 0.6(同一条消息附原图,随后回复 1=换风格 / 2=换装换姿势 / 3=取消)
56
53
  画图 多人 左边若叶睦抱着吉他,右边千早爱音牵着她的手
57
54
  画图 多人 若叶睦与千早爱音十指相扣 x2
58
55
  画图 连续 圣女:白色长发红瞳、圣洁白裙、端庄微笑 → 发梢染黑、眼神迷离、白裙开裂 → 黑发红瞳、妖艳邪笑、黑色破损礼服
@@ -67,27 +64,16 @@ plugins:
67
64
  |----|------|
68
65
  | `comfyuiBaseUrl` | ComfyUI API 地址(默认 `http://127.0.0.1:8188`) |
69
66
  | `unetName` / `clipName` / `vaeName` | 模型文件名,需与 ComfyUI 下拉框中的文件名一致 |
70
- | `i2iUnetName` | 「换风格(漫画化)」专用主模型文件名,默认 `anima-base-v1.0.safetensors`。LLLite 权重按 block 数逐块训练(当前权重为 28-block),必须配 28-block 模型(`anima-base-v1.0` / `anima-aesthetic-v1.1`);**不要用 40-block 的 `Anima-2.9B`**,否则报 `depth_embed slices missing`。留空则用 `unetName` |
71
67
  | `modelParams` | 每个模型独立参数覆盖(字典):`key`=模型文件名,`value`=要覆盖的 `samplerName` / `scheduler` / `steps` / `cfg` / `width` / `height`。让不同模型用各自最佳采样参数,只填需要覆盖的字段,命令级 `--steps`/`--cfg` 仍优先 |
72
68
  | `customWorkflowEnabled` / `customWorkflowPath` | 使用自定义工作流 JSON(相对插件目录) |
73
69
  | `width` / `height` / `allowedSizes` | 默认尺寸与可用尺寸列表 |
74
70
  | `steps` / `cfg` / `samplerName` / `scheduler` | 采样参数 |
75
71
  | `qualityPrefix` / `negativePrompt` | 质量词前缀与负面提示词 |
76
72
  | `promptOptimizeEnabled` / `llmBaseUrl` / `llmApiKey` / `llmModel` | 自然语言优化(OpenAI 兼容接口) |
77
- | `promptOptimizeTemplate` | 自然语言优化模板(支持 `{theme}` `{search_block}` `{character_rule}` `{img2img_rule}` 占位符) |
73
+ | `promptOptimizeTemplate` | 自然语言优化模板(支持 `{theme}` `{search_block}` `{character_rule}` 占位符) |
78
74
  | `webSearchEnabled` / `tavilyApiKey` / `webSearchMaxResults` / `webSearchDepth` / `webSearchQueryTemplate` | 联网搜索(指令带 `联网`/`搜索` 触发,需 Tavily Key) |
79
75
  | `fixedCharacters` / `artistPresets` / `activeArtistPreset` / `styleTags` | 角色、画师组与画风 |
80
76
  | `queueEnabled` / `queueMaxRequests` | 生成队列 |
81
- | `img2imgDenoise` | 普通以图生图(`p-draw i2i`)去噪强度,默认 0.55(建议 0.4-0.7,越小越接近原图) |
82
- | `i2iMode` | i2i 模式选择方式:`ask`=每次询问(默认) / `style`=直接换风格(漫画化) / `ootd`=直接换装换姿势 / `plain`=普通 img2img |
83
- | `i2iAskTimeout` | i2i 处理方式询问等待时间(秒,默认 60) |
84
- | `i2iStyleDenoise` | 换风格(漫画化)去噪强度,默认 0.75(建议 0.7-0.85,越大风格变化越彻底;无 ControlNet 时自动压到 0.5 防崩) |
85
- | `i2iOotdDenoise` | 换装换姿势去噪强度,默认 0.55(建议 0.5-0.6) |
86
- | `i2iControlNetStrength` | 换风格模式的 ControlNet-LLLite 强度,默认 0.7(建议 0.5-0.8,越大构图锁得越死) |
87
- | `controlNetModel` | Anima ControlNet-LLLite 权重文件名(留空自动检测 `anima-lllite` 系,如 `anima-lllite-lineart-test-1.safetensors`;需放到 ComfyUI `models/controlnet/`) |
88
- | `i2iIPAdapterPath` | Anima IP-Adapter 模型文件**路径**(换装换姿势保脸用,如 `E:/anima/ipadapter.safetensors`;需安装 [comfyui-anima-ipadapter](https://github.com/Wenaka2004/comfyui-anima-ipadapter) 节点) |
89
- | `i2iIPAdapterWeight` | 换装换姿势模式的 IP-Adapter 权重,默认 0.8(建议 0.6-1.0,越大角色特征保留越强) |
90
- | `taggerEnabled` / `taggerModel` / `taggerThreshold` / `taggerCharacterThreshold` | i2i 自动识图开关、识图模型名、标签阈值 / 角色标签阈值(需 ComfyUI 安装 WD14 Tagger 节点与模型) |
91
77
  | `couponPrice` / `couponAskTimeout` | 提示词优化券单价(P 点/张,默认 3000,可被 `data/p-shop.json` 覆盖)与交互确认等待时间(秒,默认 60) |
92
78
  | `batchMax` | 单次指令最多生成的张数(`x3` / `3张` / `--数量 3` 等写法),默认 4 |
93
79
  | `price` / `multiPrice` / `adminUsers` | 单张 / 多人单张消耗的 P 点与免单管理员 |
@@ -105,7 +91,7 @@ plugins:
105
91
  - 生成失败会自动退回 P 点。
106
92
  - 固定种子 `--seed 数字`(支持 `--seed:` `--seed=` `--seed=` 形式)在**单张 / 批量 / 连续图**里都可用,且会从提示词中自动剥离,不会混进 tags。
107
93
  - 多人指令的视觉校验需要单独配置视觉模型(`verifyLlm*`),未配置时会跳过校验直接发送。
108
- - 连续图用「同一角色 + 全阶段共用 seed」保证一致性。固定角色的身份 tags(角色名 / 种族 / 尖耳朵等)会**始终注入每一阶段**,保证图跟角色相关;阶段描述里明确改变的外观(发色/瞳色/种族变化等)会自动从固定 tags 中剔除,避免"固定银发把黑化阶段拉回去"。
94
+ - 连续图每阶段独立使用 T2I 并共用 seed,固定角色的身份 tags(角色名 / 种族 / 尖耳朵等)会**始终注入每一阶段**,保证图跟角色相关;阶段描述里明确改变的外观(发色/瞳色/种族变化等)会自动从固定 tags 中剔除,避免"固定银发把黑化阶段拉回去"。
109
95
  - **连续图配置了 LLM(`llmBaseUrl`/`llmModel`)时会交互式询问是否使用 LLM 逐阶段优化**:回复 `1 / 是` 使用 LLM(把中文描述转成 Danbooru tags),回复 `2 / 否` 不使用 LLM(直接按你输入的内容生图,适合已写好英文 tags 的情况),回复 `3 / 取消` 则不生成。等待时间由 `seriesAskTimeout`(秒)控制;未配置 LLM 或平台不支持交互时,配置了 LLM 就自动逐阶段优化。anima 是 tag 模型,中文不翻译就体现不到画面里。阶段描述尽量写**能看见的变化**(发色渐变、眼神、服装破损、光环变黑、表情),心理/抽象描述("感到厌恶""沉迷")模型很难画出来。
110
96
  - **提示词优化券**:当全局 `promptOptimizeEnabled` 关闭时,普通用户生图会**交互式询问**是否使用券(记录在 `p_system.llmToken`):
111
97
  - 有券 → 询问是否使用;确认后消耗券并 LLM 优化,**拒绝则取消本次生图**(不扣 P);
@@ -113,5 +99,4 @@ plugins:
113
99
  - 券为一次性、**按张数消耗**:`x3` 需 3 张,每张图独立做一次 LLM 优化;券不足时该批不优化也不扣券(提示 `token-short`)。等待回复时间由 `couponAskTimeout`(秒)控制。
114
100
  - **管理员在全局关闭时自动免费优化**(不耗券);`无优化` 原样模式不消耗、不询问。平台不支持交互确认时退回自动消耗逻辑。
115
101
  - **未使用优化的提示**:单人生图没走 LLM 优化时会明确提示原因——未配置 LLM、未消耗优化券、或 `无优化` 原样模式;多人指令强依赖 LLM 规划,未配置 `llmBaseUrl`/`llmModel` 时会直接提示「多人指令需要 LLM 规划」。
116
- - **i2i 增强模式依赖的节点**:换风格(漫画化)需要 ComfyUI 安装 [kohya-ss/ComfyUI-Anima-LLLite](https://github.com/kohya-ss/ComfyUI-Anima-LLLite) 自定义节点,并在 `models/controlnet/` 放一个 `anima-lllite` 系权重(`AnimaLLLiteApply_sdscripts` 节点下拉可见)。**LLLite 权重按 block 数逐块训练,必须与所用模型一致:当前权重为 28-block,换风格工作流默认用 `i2iUnetName`(`anima-base-v1.0`,28-block);若用 40-block 的 `Anima-2.9B` 会报 `depth_embed slices missing for module idx(es) [28..39]`。****注意:Anima 是 MiniTrainDIT 架构(3584 维),与 Qwen-Image 系 ControlNet(如 `Qwen-Image-InstantX-ControlNet-Union`)不兼容,装了也不会被本插件使用。** 可选安装 [comfyui_controlnet_aux](https://github.com/Fannovel16/comfyui_controlnet_aux) 获得 LineArt 预处理器(优先 `AnimeLineArtPreprocessor`,其次 `LineArtPreprocessor`),线条比内置 Canny 更贴合 lllite-lineart 权重;未安装时自动回退 Canny。换装换姿势需要安装 [comfyui-anima-ipadapter](https://github.com/Wenaka2004/comfyui-anima-ipadapter) 自定义节点并配置 `i2iIPAdapterPath`。以上节点缺失时都会自动回退普通 img2img 并提示,不影响出图。
117
102
  - 本地模型不一定认识新角色,可用 `添加角色` 手动补充角色 tags。