koishi-plugin-p-draw 1.2.13 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,285 @@
1
+ // ComfyUI 工作流构建(移植自 anima comfyui_workflows)。
2
+ // 纯函数,不依赖 koishi;customWorkflow 需要 fs/path。
3
+
4
+ const fs = require('fs')
5
+ const path = require('path')
6
+
7
+ function animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed) {
8
+ return {
9
+ '44': { class_type: 'UNETLoader', inputs: { unet_name: cfg.unetName, weight_dtype: 'fp8_e4m3fn' } },
10
+ '45': { class_type: 'CLIPLoader', inputs: { clip_name: cfg.clipName, type: 'stable_diffusion', device: 'default' } },
11
+ '15': { class_type: 'VAELoader', inputs: { vae_name: cfg.vaeName } },
12
+ '28': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
13
+ '11': { class_type: 'CLIPTextEncode', inputs: { text: prompt, clip: ['45', 0] } },
14
+ '12': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt, clip: ['45', 0] } },
15
+ '19': {
16
+ class_type: 'KSampler',
17
+ inputs: {
18
+ model: ['44', 0],
19
+ positive: ['11', 0],
20
+ negative: ['12', 0],
21
+ latent_image: ['28', 0],
22
+ seed,
23
+ steps,
24
+ cfg: cfgVal,
25
+ sampler_name: cfg.samplerName,
26
+ scheduler: cfg.scheduler,
27
+ denoise: 1,
28
+ },
29
+ },
30
+ '8': { class_type: 'VAEDecodeTiled', inputs: { samples: ['19', 0], vae: ['15', 0], tile_size: 512, overlap: 64, temporal_size: 64, temporal_overlap: 8 } },
31
+ '9': { class_type: 'SaveImage', inputs: { images: ['8', 0], filename_prefix: 'pdraw/anm' } },
32
+ }
33
+ }
34
+
35
+ function buildWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
36
+ if (cfg.customWorkflowEnabled && cfg.customWorkflowPath) {
37
+ return customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize)
38
+ }
39
+ return animaT2IWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed)
40
+ }
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
+ function customWorkflow(cfg, prompt, negativePrompt, width, height, steps, cfgVal, seed, explicitSize) {
186
+ // lib/ 子目录下运行,customWorkflowPath 相对插件根目录,故需要回退一层
187
+ const rawPath = path.resolve(__dirname, '..', cfg.customWorkflowPath)
188
+ let raw
189
+ try {
190
+ raw = JSON.parse(fs.readFileSync(rawPath, 'utf-8'))
191
+ } catch (e) {
192
+ throw new Error(`自定义工作流加载失败:${e.message}`)
193
+ }
194
+ const body = raw && typeof raw === 'object' && raw.prompt && typeof raw.prompt === 'object' ? raw.prompt : raw
195
+ if (!body || typeof body !== 'object') throw new Error('自定义工作流 JSON 无效')
196
+
197
+ const workflow = JSON.parse(JSON.stringify(body))
198
+ const textNodes = []
199
+ for (const [nodeId, node] of Object.entries(workflow)) {
200
+ if (!node || typeof node !== 'object') continue
201
+ const classType = String(node.class_type || '')
202
+ const inputs = node.inputs
203
+ if (classType.includes('TextEncode') && inputs && typeof inputs.text === 'string') {
204
+ textNodes.push(String(nodeId))
205
+ }
206
+ }
207
+ const positiveIds = conditioningTextNodeIds(workflow, 'positive', textNodes)
208
+ const negativeIds = conditioningTextNodeIds(workflow, 'negative', textNodes)
209
+ if (!positiveIds.length) throw new Error('自定义工作流中找不到正面提示词节点')
210
+ if (!negativeIds.length) throw new Error('自定义工作流中找不到负面提示词节点')
211
+ if (positiveIds.some(id => negativeIds.includes(id))) throw new Error('自定义工作流正负面节点有歧义')
212
+
213
+ for (const nodeId of positiveIds) {
214
+ if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = prompt
215
+ }
216
+ for (const nodeId of negativeIds) {
217
+ if (workflow[nodeId] && workflow[nodeId].inputs) workflow[nodeId].inputs.text = negativePrompt
218
+ }
219
+
220
+ for (const node of Object.values(workflow)) {
221
+ if (!node || typeof node !== 'object') continue
222
+ const classType = String(node.class_type || '')
223
+ const inputs = node.inputs
224
+ if (!inputs || typeof inputs !== 'object') continue
225
+ if (classType === 'SaveImage' && 'filename_prefix' in inputs) {
226
+ inputs.filename_prefix = 'pdraw/anm'
227
+ }
228
+ const override = Boolean(cfg.customWorkflowOverrideParameters)
229
+ if ((override || explicitSize) && classType === 'EmptyLatentImage') {
230
+ if ('width' in inputs) inputs.width = width
231
+ if ('height' in inputs) inputs.height = height
232
+ }
233
+ if (override && (classType === 'KSampler' || classType === 'KSamplerAdvanced')) {
234
+ if ('steps' in inputs) inputs.steps = steps
235
+ if ('cfg' in inputs) inputs.cfg = cfgVal
236
+ if (cfg.samplerName && 'sampler_name' in inputs) inputs.sampler_name = cfg.samplerName
237
+ if (cfg.scheduler && 'scheduler' in inputs) inputs.scheduler = cfg.scheduler
238
+ }
239
+ if ('seed' in inputs) inputs.seed = seed
240
+ if ('noise_seed' in inputs) inputs.noise_seed = seed
241
+ }
242
+ return workflow
243
+ }
244
+
245
+ function conditioningTextNodeIds(workflow, inputName, textNodes) {
246
+ const pending = []
247
+ for (const node of Object.values(workflow)) {
248
+ if (!node || typeof node !== 'object') continue
249
+ if (!['KSampler', 'KSamplerAdvanced'].includes(String(node.class_type || ''))) continue
250
+ const link = node.inputs && node.inputs[inputName]
251
+ if (Array.isArray(link) && link.length) pending.push(String(link[0]))
252
+ }
253
+ const found = []
254
+ const visited = new Set()
255
+ while (pending.length) {
256
+ const nodeId = pending.pop()
257
+ if (visited.has(nodeId)) continue
258
+ visited.add(nodeId)
259
+ if (textNodes.includes(nodeId)) {
260
+ found.push(nodeId)
261
+ continue
262
+ }
263
+ const node = workflow[nodeId]
264
+ const inputs = node && node.inputs
265
+ if (!inputs || typeof inputs !== 'object') continue
266
+ for (const value of Object.values(inputs)) {
267
+ if (Array.isArray(value) && value.length) {
268
+ const sourceId = String(value[0])
269
+ if (workflow[sourceId]) pending.push(sourceId)
270
+ }
271
+ }
272
+ }
273
+ return found
274
+ }
275
+
276
+ module.exports = {
277
+ animaT2IWorkflow,
278
+ buildWorkflow,
279
+ animaI2IWorkflow,
280
+ animaStyleI2IWorkflow,
281
+ animaOotdI2IWorkflow,
282
+ buildI2IWorkflow,
283
+ customWorkflow,
284
+ conditioningTextNodeIds,
285
+ }
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "koishi-plugin-p-draw",
3
3
  "description": "p点-绘图,连接本地 ComfyUI 生图。支持自然语言优化、尺寸选择、画师组、固定角色、生成队列,需要配合 p-qiandao 使用",
4
- "version": "1.2.13",
4
+ "version": "1.3.0",
5
5
  "main": "index.js",
6
6
  "files": [
7
7
  "index.js",
8
+ "lib",
8
9
  "readme.md"
9
10
  ],
10
11
  "contributors": [