@goodandready/dsh-image-gen 0.10.24 → 0.10.26
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/README.md +14 -0
- package/README.ru.md +14 -0
- package/README.zh.md +14 -0
- package/lib/attachment-helper.js +82 -0
- package/lib/client.js +149 -21
- package/lib/history.js +93 -0
- package/lib/index.js +25 -167
- package/lib/pattern-helpers.js +87 -0
- package/lib/providers/backends/custom.js +93 -0
- package/lib/providers/backends/fal.js +67 -0
- package/lib/providers/backends/gemini.js +37 -0
- package/lib/providers/backends/local.js +136 -0
- package/lib/providers/backends/replicate.js +83 -0
- package/lib/providers/backends/seedream.js +49 -0
- package/lib/providers/backends/subscription.js +58 -0
- package/lib/providers/shared-helpers.js +164 -0
- package/lib/providers.js +46 -553
- package/lib/register-tools.js +8 -0
- package/lib/responsive-helpers.js +94 -0
- package/lib/spritesheet-helpers.js +65 -0
- package/lib/tools/editing.js +2 -0
- package/lib/tools/generation-pack.js +74 -0
- package/lib/tools/generation.js +2 -66
- package/lib/tools/pattern.js +125 -0
- package/lib/tools/processing-advanced.js +7 -1
- package/lib/tools/responsive.js +267 -0
- package/lib/tools/sketch.js +167 -0
- package/lib/tools/spritesheet.js +151 -0
- package/package.json +7 -4
package/lib/providers.js
CHANGED
|
@@ -26,6 +26,42 @@ import {
|
|
|
26
26
|
normalizeCount,
|
|
27
27
|
} from './provider-utils.js'
|
|
28
28
|
|
|
29
|
+
// Per-backend factories (#218)
|
|
30
|
+
import { createFalGenerator } from './providers/backends/fal.js'
|
|
31
|
+
import { createCustomGenerator } from './providers/backends/custom.js'
|
|
32
|
+
import { createSubscriptionGenerator } from './providers/backends/subscription.js'
|
|
33
|
+
import { createLocalGenerator } from './providers/backends/local.js'
|
|
34
|
+
import { createSeedreamGenerator } from './providers/backends/seedream.js'
|
|
35
|
+
import { createGeminiGenerator } from './providers/backends/gemini.js'
|
|
36
|
+
import { createReplicateGenerator } from './providers/backends/replicate.js'
|
|
37
|
+
export {
|
|
38
|
+
falAuthHeader,
|
|
39
|
+
normalizeMediaType,
|
|
40
|
+
submitJob,
|
|
41
|
+
pollStatus,
|
|
42
|
+
buildEditForm,
|
|
43
|
+
estimateCost,
|
|
44
|
+
pxSize,
|
|
45
|
+
ASPECT_RATIOS,
|
|
46
|
+
sizeToPixels,
|
|
47
|
+
snapToMultipleOf64,
|
|
48
|
+
snapDimensions,
|
|
49
|
+
extractComfyNodeErrors,
|
|
50
|
+
} from './providers/shared-helpers.js'
|
|
51
|
+
|
|
52
|
+
import {
|
|
53
|
+
falAuthHeader,
|
|
54
|
+
normalizeMediaType,
|
|
55
|
+
submitJob,
|
|
56
|
+
pollStatus,
|
|
57
|
+
buildEditForm,
|
|
58
|
+
estimateCost,
|
|
59
|
+
pxSize,
|
|
60
|
+
snapToMultipleOf64,
|
|
61
|
+
snapDimensions,
|
|
62
|
+
extractComfyNodeErrors,
|
|
63
|
+
} from './providers/shared-helpers.js'
|
|
64
|
+
|
|
29
65
|
// Re-export utilities so existing imports from './providers.js' continue to work
|
|
30
66
|
export {
|
|
31
67
|
resolveApiKeyCandidates,
|
|
@@ -74,25 +110,7 @@ export async function tryGenerate(generators, order, seed, promptArg) {
|
|
|
74
110
|
* Supports named sizes (landscape_16_9, etc.), aspect ratios (16:9, 3:2, 9:16),
|
|
75
111
|
* and pixel dimensions [width, height], preserving requested proportions.
|
|
76
112
|
*/
|
|
77
|
-
export function falAuthHeader(key) {
|
|
78
|
-
const trimmed = String(key ?? '').trim()
|
|
79
|
-
if (!trimmed) return ''
|
|
80
|
-
return trimmed.startsWith('Key ') || trimmed.startsWith('key ')
|
|
81
|
-
? trimmed
|
|
82
|
-
: `Key ${trimmed}`
|
|
83
|
-
}
|
|
84
|
-
|
|
85
113
|
/** Map a content type onto the attachment service's supported set. */
|
|
86
|
-
export function normalizeMediaType(contentType, fallbackFormat) {
|
|
87
|
-
const raw = String(contentType ?? '').toLowerCase()
|
|
88
|
-
if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
|
|
89
|
-
if (raw.includes('webp')) return 'image/webp'
|
|
90
|
-
if (raw.includes('png')) return 'image/png'
|
|
91
|
-
if (fallbackFormat === 'jpeg') return 'image/jpeg'
|
|
92
|
-
if (fallbackFormat === 'webp') return 'image/webp'
|
|
93
|
-
return 'image/png'
|
|
94
|
-
}
|
|
95
|
-
|
|
96
114
|
/** Generation metadata written to companion sidecar file. */
|
|
97
115
|
export function buildSidecar({
|
|
98
116
|
prompt, size, format, seed, provider, deliverAs,
|
|
@@ -102,58 +120,7 @@ export function buildSidecar({
|
|
|
102
120
|
}
|
|
103
121
|
|
|
104
122
|
/** Submit a generation job to the FAL queue. */
|
|
105
|
-
export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
|
|
106
|
-
const res = await fetchImpl(`${baseURL}/${model}`, {
|
|
107
|
-
method: 'POST',
|
|
108
|
-
headers: {
|
|
109
|
-
Authorization: falAuthHeader(key),
|
|
110
|
-
'Content-Type': 'application/json',
|
|
111
|
-
},
|
|
112
|
-
body: JSON.stringify(body),
|
|
113
|
-
signal,
|
|
114
|
-
})
|
|
115
|
-
const data = await res.json().catch(() => ({}))
|
|
116
|
-
if (!res.ok || !data.request_id) {
|
|
117
|
-
const detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data).slice(0, 600)
|
|
118
|
-
throw new Error(`FAL submit failed (HTTP ${res.status}): ${detail}`)
|
|
119
|
-
}
|
|
120
|
-
return data
|
|
121
|
-
}
|
|
122
|
-
|
|
123
123
|
/** Poll the FAL status endpoint until completion, failure, timeout, or abort. */
|
|
124
|
-
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
|
|
125
|
-
const deadline = Date.now() + timeoutMs
|
|
126
|
-
let attempt = 0
|
|
127
|
-
for (;;) {
|
|
128
|
-
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
129
|
-
if (Date.now() > deadline) throw new Error(`FAL generation timed out after ${timeoutMs} ms`)
|
|
130
|
-
if (attempt > 0) {
|
|
131
|
-
const delay = calculateBackoff(attempt - 1, pollIntervalMs, 5000)
|
|
132
|
-
await new Promise((resolve) => {
|
|
133
|
-
const timer = setTimeout(resolve, delay)
|
|
134
|
-
if (signal) {
|
|
135
|
-
signal.addEventListener('abort', () => {
|
|
136
|
-
clearTimeout(timer)
|
|
137
|
-
resolve()
|
|
138
|
-
}, { once: true })
|
|
139
|
-
}
|
|
140
|
-
})
|
|
141
|
-
}
|
|
142
|
-
attempt++
|
|
143
|
-
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
144
|
-
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
145
|
-
const data = await res.json().catch(() => ({}))
|
|
146
|
-
const status = data.status
|
|
147
|
-
if (status === 'COMPLETED') return data
|
|
148
|
-
if (status === 'ERROR' || data.error || data.detail) {
|
|
149
|
-
throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
|
|
150
|
-
}
|
|
151
|
-
if (status !== 'IN_QUEUE' && status !== 'IN_PROGRESS') {
|
|
152
|
-
throw new Error(`Unexpected FAL status "${status}": ${JSON.stringify(data).slice(0, 300)}`)
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
|
|
157
124
|
/**
|
|
158
125
|
* @param deps {{fetchImpl: Function, resolveKey: (ref: string) => Promise<string>, cfg: object}}
|
|
159
126
|
* @param job {{prompt: string, size: string, format: string, seed: number|undefined, signal: AbortSignal}}
|
|
@@ -164,51 +131,11 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
164
131
|
|
|
165
132
|
/** Named size -> [width, height] for local APIs. */
|
|
166
133
|
/** Aspect ratio -> [width, height] (pixels, normalized to 1024 base). */
|
|
167
|
-
export const ASPECT_RATIOS = {
|
|
168
|
-
'1:1': [1024, 1024],
|
|
169
|
-
'16:9': [1344, 768],
|
|
170
|
-
'9:16': [768, 1344],
|
|
171
|
-
'4:3': [1152, 896],
|
|
172
|
-
'3:4': [896, 1152],
|
|
173
|
-
'3:2': [1152, 768],
|
|
174
|
-
'2:3': [768, 1152],
|
|
175
|
-
}
|
|
176
|
-
|
|
177
134
|
/** Pixel dimensions: aspectPixels (if specified) or mapped from named size. */
|
|
178
135
|
/** Snap a pixel dimension to nearest multiple of 64. */
|
|
179
|
-
export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
|
|
180
|
-
const n = Math.round(Number(dim) / 64) * 64
|
|
181
|
-
return Math.max(minVal, Math.min(maxVal, n))
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
export function snapDimensions(width, height) {
|
|
185
|
-
return [snapToMultipleOf64(width), snapToMultipleOf64(height)]
|
|
186
|
-
}
|
|
187
136
|
|
|
188
|
-
export function pxSize(aspectPixels, size) {
|
|
189
|
-
if (aspectPixels) return snapDimensions(aspectPixels[0], aspectPixels[1])
|
|
190
|
-
const [w, h] = sizeToPixels(size)
|
|
191
|
-
return snapDimensions(w, h)
|
|
192
|
-
}
|
|
193
|
-
|
|
194
|
-
export function sizeToPixels(size) {
|
|
195
|
-
const px = SIZE_PIXELS[size]
|
|
196
|
-
if (!px) return [1024, 1024]
|
|
197
|
-
const [w, h] = px.split('x').map(Number)
|
|
198
|
-
return [w, h]
|
|
199
|
-
}
|
|
200
137
|
|
|
201
138
|
/** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
|
|
202
|
-
export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
203
|
-
const form = new FormData()
|
|
204
|
-
form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
|
|
205
|
-
if (mask) form.append('mask', new Blob([mask.bytes], { type: mask.mediaType || 'image/png' }), 'mask.png')
|
|
206
|
-
form.append('prompt', prompt)
|
|
207
|
-
if (size) form.append('size', size)
|
|
208
|
-
if (strength !== undefined) form.append('strength', String(strength))
|
|
209
|
-
return form
|
|
210
|
-
}
|
|
211
|
-
|
|
212
139
|
/** Compare two images: ratio of differing pixels (0..1). */
|
|
213
140
|
export async function pixelDiff(a, b) {
|
|
214
141
|
if (!a || !b) return { error: 'missing image' }
|
|
@@ -256,438 +183,19 @@ export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
|
|
|
256
183
|
}
|
|
257
184
|
}
|
|
258
185
|
|
|
259
|
-
export function extractComfyNodeErrors(entry) {
|
|
260
|
-
if (!entry || typeof entry !== 'object') return ''
|
|
261
|
-
const status = entry.status
|
|
262
|
-
if (status && status.status_str === 'error') {
|
|
263
|
-
const msgs = status.messages || []
|
|
264
|
-
const errList = []
|
|
265
|
-
for (const m of msgs) {
|
|
266
|
-
if (Array.isArray(m) && m[0] === 'execution_error') {
|
|
267
|
-
const d = m[1] || {}
|
|
268
|
-
errList.push(`node ${d.node_id || 'unknown'} (${d.node_type || ''}): ${d.exception_message || d.exception_type || 'execution failed'}`)
|
|
269
|
-
}
|
|
270
|
-
}
|
|
271
|
-
if (errList.length) return errList.join('; ')
|
|
272
|
-
return status.status_str || 'execution error'
|
|
273
|
-
}
|
|
274
|
-
return ''
|
|
275
|
-
}
|
|
276
186
|
|
|
277
187
|
export function makeProviders(deps, job) {
|
|
278
|
-
const
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (seedArg !== undefined) body.seed = seedArg
|
|
289
|
-
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
290
|
-
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
291
|
-
if (format !== 'png') body.output_format = format
|
|
292
|
-
if (isEdit) {
|
|
293
|
-
body.image_url = `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`
|
|
294
|
-
if (mask && mask.bytes) {
|
|
295
|
-
body.mask_url = `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}`
|
|
296
|
-
}
|
|
297
|
-
if (strength !== undefined) {
|
|
298
|
-
body.strength = strength
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
|
|
303
|
-
const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
|
|
304
|
-
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
|
|
305
|
-
|
|
306
|
-
const resultRes = await fetchImpl(statusBody.response_url, {
|
|
307
|
-
headers: { Authorization: falAuthHeader(key) },
|
|
308
|
-
signal,
|
|
309
|
-
})
|
|
310
|
-
const result = await resultRes.json().catch(() => ({}))
|
|
311
|
-
const image = result.images && result.images[0]
|
|
312
|
-
if (!image || !image.url) {
|
|
313
|
-
throw new Error(`FAL returned no images: ${JSON.stringify(result).slice(0, 600)}`)
|
|
314
|
-
}
|
|
315
|
-
const download = await fetchImpl(image.url, { signal })
|
|
316
|
-
if (!download.ok) {
|
|
317
|
-
throw new Error(`Failed to download generated image (HTTP ${download.status})`)
|
|
318
|
-
}
|
|
319
|
-
return {
|
|
320
|
-
bytes: Buffer.from(await download.arrayBuffer()),
|
|
321
|
-
mediaType: normalizeMediaType(image.content_type, format),
|
|
322
|
-
width: image.width ?? 0,
|
|
323
|
-
height: image.height ?? 0,
|
|
324
|
-
seed: result.seed ?? seedArg ?? 0,
|
|
325
|
-
sourceUrl: image.url,
|
|
326
|
-
cost: result.cost,
|
|
327
|
-
}
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
// OpenAI-compatible image API. Single HTTP request instead of queue polling.
|
|
331
|
-
async function custom(seedArg = seed, promptArg = prompt) {
|
|
332
|
-
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
333
|
-
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
334
|
-
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
335
|
-
|
|
336
|
-
// Empty key reference indicates unauthenticated gateway.
|
|
337
|
-
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
338
|
-
const headers = { 'Content-Type': 'application/json' }
|
|
339
|
-
if (key) headers.Authorization = `Bearer ${key}`
|
|
340
|
-
|
|
341
|
-
// Omit response_format; modern models return either base64 or URL.
|
|
342
|
-
const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
|
|
343
|
-
const res = await fetchImpl(endpoint, {
|
|
344
|
-
method: 'POST',
|
|
345
|
-
headers,
|
|
346
|
-
body: source
|
|
347
|
-
? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
|
|
348
|
-
: JSON.stringify({
|
|
349
|
-
model: cfg.customModel,
|
|
350
|
-
prompt: promptArg,
|
|
351
|
-
n: 1,
|
|
352
|
-
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
353
|
-
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
354
|
-
...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
|
|
355
|
-
...(quality && quality !== 'auto' ? { quality } : {}),
|
|
356
|
-
}),
|
|
357
|
-
signal,
|
|
358
|
-
})
|
|
359
|
-
const data = await res.json().catch(() => ({}))
|
|
360
|
-
if (!res.ok) {
|
|
361
|
-
if (res.status === 503) {
|
|
362
|
-
const msg = (data?.error?.message || JSON.stringify(data)).toLowerCase()
|
|
363
|
-
if (msg.includes('no available channel') || msg.includes('model_not_found')) {
|
|
364
|
-
throw new Error(`Custom image API has no provisioned channel for "${cfg.customModel}" (HTTP 503); check gateway channel and routing configuration`)
|
|
365
|
-
}
|
|
366
|
-
}
|
|
367
|
-
const detail = formatErrorMessage(data?.error || data)
|
|
368
|
-
throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
|
|
369
|
-
}
|
|
370
|
-
const item = data?.data?.[0]
|
|
371
|
-
if (!item) {
|
|
372
|
-
throw new Error(`Image API returned no images: ${JSON.stringify(data).slice(0, 600)}`)
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
if (item.b64_json) {
|
|
376
|
-
return {
|
|
377
|
-
bytes: Buffer.from(item.b64_json, 'base64'),
|
|
378
|
-
mediaType: normalizeMediaType(data.output_format || '', format),
|
|
379
|
-
width: 0,
|
|
380
|
-
height: 0,
|
|
381
|
-
seed: seedArg ?? 0,
|
|
382
|
-
sourceUrl: '',
|
|
383
|
-
}
|
|
384
|
-
}
|
|
385
|
-
if (!item.url) {
|
|
386
|
-
throw new Error(`Image API returned neither b64_json nor url: ${JSON.stringify(item).slice(0, 300)}`)
|
|
387
|
-
}
|
|
388
|
-
const download = await fetchImpl(item.url, { signal })
|
|
389
|
-
if (!download.ok) {
|
|
390
|
-
throw new Error(`Failed to download generated image (HTTP ${download.status})`)
|
|
391
|
-
}
|
|
392
|
-
const contentType = download.headers && typeof download.headers.get === 'function'
|
|
393
|
-
? download.headers.get('content-type')
|
|
394
|
-
: ''
|
|
395
|
-
return {
|
|
396
|
-
bytes: Buffer.from(await download.arrayBuffer()),
|
|
397
|
-
mediaType: normalizeMediaType(contentType, format),
|
|
398
|
-
width: 0,
|
|
399
|
-
height: 0,
|
|
400
|
-
seed: seedArg ?? 0,
|
|
401
|
-
sourceUrl: item.url,
|
|
402
|
-
}
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
// Subscription generation: dsh-subscriptions handles session authentication.
|
|
406
|
-
function subscription(provider) {
|
|
407
|
-
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
408
|
-
if (source) {
|
|
409
|
-
return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
|
|
410
|
-
}
|
|
411
|
-
const images = deps.subscriptionImages
|
|
412
|
-
if (!images || typeof images.generate !== 'function') {
|
|
413
|
-
return {
|
|
414
|
-
ok: false,
|
|
415
|
-
provider,
|
|
416
|
-
reason: `${provider}: requires dsh-subscriptions plugin to manage session authentication`,
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
let produced
|
|
420
|
-
try {
|
|
421
|
-
produced = await images.generate({
|
|
422
|
-
provider,
|
|
423
|
-
prompt: promptArg,
|
|
424
|
-
size: resolveSubscriptionSize(size, aspectPixels, aspectRatio),
|
|
425
|
-
quality: cfg.subscriptionQuality || undefined,
|
|
426
|
-
signal,
|
|
427
|
-
})
|
|
428
|
-
} catch (e) {
|
|
429
|
-
return { ok: false, provider, reason: formatErrorMessage(e, provider) }
|
|
430
|
-
}
|
|
431
|
-
const first = Array.isArray(produced) ? produced[0] : null
|
|
432
|
-
if (!first || !first.b64_json) {
|
|
433
|
-
return { ok: false, provider, reason: `${provider}: no image returned in response` }
|
|
434
|
-
}
|
|
435
|
-
return {
|
|
436
|
-
bytes: Buffer.from(first.b64_json, 'base64'),
|
|
437
|
-
// Subscription outputs default to PNG media type.
|
|
438
|
-
mediaType: 'image/png',
|
|
439
|
-
width: 0,
|
|
440
|
-
height: 0,
|
|
441
|
-
seed: seedArg ?? 0,
|
|
442
|
-
sourceUrl: '',
|
|
443
|
-
revisedPrompt: first.revisedPrompt || '',
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
|
|
448
|
-
// Local generation: ComfyUI (queue + poll) or Automatic1111 (txt2img).
|
|
449
|
-
async function local(seedArg = seed, promptArg = prompt) {
|
|
450
|
-
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
451
|
-
if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
|
|
452
|
-
const [width, height] = pxSize(aspectPixels, size)
|
|
453
|
-
const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
|
|
454
|
-
|
|
455
|
-
if (kind === 'a1111') {
|
|
456
|
-
const isImg2Img = Boolean(source && source.bytes)
|
|
457
|
-
const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
|
|
458
|
-
const body = {
|
|
459
|
-
prompt: promptArg,
|
|
460
|
-
negative_prompt: negativePrompt,
|
|
461
|
-
width,
|
|
462
|
-
height,
|
|
463
|
-
steps: cfg.localSteps ?? 20,
|
|
464
|
-
cfg_scale: cfg.localCfg ?? 7,
|
|
465
|
-
seed: seedArg ?? -1,
|
|
466
|
-
}
|
|
467
|
-
if (isImg2Img) {
|
|
468
|
-
body.init_images = [Buffer.from(source.bytes).toString('base64')]
|
|
469
|
-
body.denoising_strength = strength ?? (mask ? 0.75 : 0.35)
|
|
470
|
-
if (mask && mask.bytes) {
|
|
471
|
-
body.mask = Buffer.from(mask.bytes).toString('base64')
|
|
472
|
-
}
|
|
473
|
-
}
|
|
474
|
-
if (cfg.localModel) body.override_settings = { sd_model_checkpoint: cfg.localModel }
|
|
475
|
-
const res = await fetchImpl(endpoint, {
|
|
476
|
-
method: 'POST',
|
|
477
|
-
headers: { 'Content-Type': 'application/json' },
|
|
478
|
-
body: JSON.stringify(body),
|
|
479
|
-
signal,
|
|
480
|
-
})
|
|
481
|
-
if (!res.ok) {
|
|
482
|
-
throw new Error(`Local A1111 failed (HTTP ${res.status}): ${String(await res.text().catch(() => '')).slice(0, 300)}`)
|
|
483
|
-
}
|
|
484
|
-
const data = await res.json().catch(() => ({}))
|
|
485
|
-
const b64 = data.images && data.images[0]
|
|
486
|
-
if (!b64) throw new Error('Local A1111 returned no images')
|
|
487
|
-
return {
|
|
488
|
-
bytes: Buffer.from(b64, 'base64'),
|
|
489
|
-
mediaType: normalizeMediaType('image/png', format),
|
|
490
|
-
width,
|
|
491
|
-
height,
|
|
492
|
-
seed: seedArg ?? 0,
|
|
493
|
-
sourceUrl: '',
|
|
494
|
-
}
|
|
495
|
-
}
|
|
496
|
-
|
|
497
|
-
// ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
|
|
498
|
-
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
499
|
-
const workflow = {
|
|
500
|
-
prompt: {
|
|
501
|
-
'3': { class_type: 'KSampler', inputs: { seed: seedArg ?? 0, steps: cfg.localSteps ?? 20, cfg: cfg.localCfg ?? 7, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0] } },
|
|
502
|
-
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
|
|
503
|
-
'5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
|
504
|
-
'6': { class_type: 'CLIPTextEncode', inputs: { text: promptArg, clip: ['4', 1] } },
|
|
505
|
-
'7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
|
|
506
|
-
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
507
|
-
'9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
|
|
508
|
-
},
|
|
509
|
-
}
|
|
510
|
-
const submit = await fetchImpl(`${base}/prompt`, {
|
|
511
|
-
method: 'POST',
|
|
512
|
-
headers: { 'Content-Type': 'application/json' },
|
|
513
|
-
body: JSON.stringify({ prompt: workflow, client_id: promptId }),
|
|
514
|
-
signal,
|
|
515
|
-
})
|
|
516
|
-
if (!submit.ok) {
|
|
517
|
-
throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
|
|
518
|
-
}
|
|
519
|
-
const submitData = await submit.json().catch(() => ({}))
|
|
520
|
-
const pid = submitData.prompt_id
|
|
521
|
-
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
522
|
-
|
|
523
|
-
const deadline = Date.now() + cfg.timeoutMs
|
|
524
|
-
let attempt = 0
|
|
525
|
-
for (;;) {
|
|
526
|
-
if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
527
|
-
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
528
|
-
if (attempt > 0) {
|
|
529
|
-
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
530
|
-
await new Promise((resolve) => {
|
|
531
|
-
const timer = setTimeout(resolve, cDelay)
|
|
532
|
-
if (signal) {
|
|
533
|
-
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
534
|
-
}
|
|
535
|
-
})
|
|
536
|
-
}
|
|
537
|
-
attempt++
|
|
538
|
-
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
539
|
-
if (!hist.ok) continue
|
|
540
|
-
const histData = await hist.json().catch(() => ({}))
|
|
541
|
-
const entry = histData[pid]
|
|
542
|
-
if (entry) {
|
|
543
|
-
const comfyErr = extractComfyNodeErrors(entry)
|
|
544
|
-
if (comfyErr) {
|
|
545
|
-
throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
if (entry && entry.outputs) {
|
|
549
|
-
const outputs = entry.outputs
|
|
550
|
-
const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
|
|
551
|
-
if (img) {
|
|
552
|
-
const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
|
|
553
|
-
if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
|
|
554
|
-
return {
|
|
555
|
-
bytes: Buffer.from(await dl.arrayBuffer()),
|
|
556
|
-
mediaType: normalizeMediaType('image/png', format),
|
|
557
|
-
width,
|
|
558
|
-
height,
|
|
559
|
-
seed: seedArg ?? 0,
|
|
560
|
-
sourceUrl: '',
|
|
561
|
-
}
|
|
562
|
-
}
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
// Seedream (ByteDance): OpenAI-compatible images API.
|
|
568
|
-
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
569
|
-
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
570
|
-
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
571
|
-
const res = await fetchImpl(`${base}/images/generations`, {
|
|
572
|
-
method: 'POST',
|
|
573
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
574
|
-
body: JSON.stringify({
|
|
575
|
-
model: cfg.seedreamModel || 'seedream-4.0',
|
|
576
|
-
prompt: promptArg,
|
|
577
|
-
n: 1,
|
|
578
|
-
size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
579
|
-
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
580
|
-
...(quality !== undefined ? { quality } : {}),
|
|
581
|
-
...(style !== undefined ? { style } : {}),
|
|
582
|
-
}),
|
|
583
|
-
signal,
|
|
584
|
-
})
|
|
585
|
-
const data = await res.json().catch(() => ({}))
|
|
586
|
-
if (!res.ok) {
|
|
587
|
-
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
588
|
-
throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
|
|
589
|
-
}
|
|
590
|
-
const item = data?.data?.[0]
|
|
591
|
-
if (!item) throw new Error('Seedream returned no images')
|
|
592
|
-
if (item.b64_json) {
|
|
593
|
-
return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
594
|
-
}
|
|
595
|
-
if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
|
|
596
|
-
const dl = await fetchImpl(item.url, { signal })
|
|
597
|
-
if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
|
|
598
|
-
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
// Gemini (Google): generateContent / imagen via GenAI API.
|
|
602
|
-
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
603
|
-
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
604
|
-
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
605
|
-
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
|
|
606
|
-
method: 'POST',
|
|
607
|
-
headers: { 'Content-Type': 'application/json' },
|
|
608
|
-
body: JSON.stringify({
|
|
609
|
-
contents: [{ parts: [{ text: promptArg }] }],
|
|
610
|
-
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
|
|
611
|
-
}),
|
|
612
|
-
signal,
|
|
613
|
-
})
|
|
614
|
-
const data = await res.json().catch(() => ({}))
|
|
615
|
-
if (!res.ok) {
|
|
616
|
-
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
617
|
-
throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
|
|
618
|
-
}
|
|
619
|
-
const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
|
|
620
|
-
if (!part) throw new Error('Gemini returned no image')
|
|
621
|
-
return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
// Replicate provider: API submit & polling
|
|
626
|
-
async function replicate(seedArg = seed, promptArg = prompt) {
|
|
627
|
-
const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
|
|
628
|
-
if (!key) throw new Error('Replicate API token is not configured (Settings → Image generation → replicateKeyEnv)')
|
|
629
|
-
const model = cfg.replicateModel || 'black-forest-labs/flux-schnell'
|
|
630
|
-
const [width, height] = pxSize(aspectPixels, size)
|
|
631
|
-
const body = {
|
|
632
|
-
input: {
|
|
633
|
-
prompt: promptArg,
|
|
634
|
-
aspect_ratio: aspectPixels ? `${aspectPixels[0]}:${aspectPixels[1]}` : (ASPECT_RATIOS[size] || '1:1'),
|
|
635
|
-
seed: seedArg,
|
|
636
|
-
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
|
|
637
|
-
...(source && source.bytes ? {
|
|
638
|
-
image: `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`,
|
|
639
|
-
...(mask && mask.bytes ? { mask: `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}` } : {}),
|
|
640
|
-
...(strength !== undefined ? { prompt_strength: strength } : {}),
|
|
641
|
-
} : {}),
|
|
642
|
-
},
|
|
643
|
-
}
|
|
644
|
-
const res = await fetchImpl(`https://api.replicate.com/v1/models/${model}/predictions`, {
|
|
645
|
-
method: 'POST',
|
|
646
|
-
headers: {
|
|
647
|
-
Authorization: `Bearer ${key}`,
|
|
648
|
-
'Content-Type': 'application/json',
|
|
649
|
-
},
|
|
650
|
-
body: JSON.stringify(body),
|
|
651
|
-
signal,
|
|
652
|
-
})
|
|
653
|
-
const data = await res.json().catch(() => ({}))
|
|
654
|
-
if (!res.ok) {
|
|
655
|
-
throw new Error(`Replicate submit failed (HTTP ${res.status}): ${data.detail || JSON.stringify(data).slice(0, 300)}`)
|
|
656
|
-
}
|
|
657
|
-
let pred = data
|
|
658
|
-
if (pred.status !== 'succeeded') {
|
|
659
|
-
const pollUrl = pred.urls?.get || `https://api.replicate.com/v1/predictions/${pred.id}`
|
|
660
|
-
const deadline = Date.now() + (cfg.timeoutMs || 180000)
|
|
661
|
-
let attempt = 0
|
|
662
|
-
while (pred.status !== 'succeeded') {
|
|
663
|
-
if (signal?.aborted) throw new Error('Replicate generation cancelled')
|
|
664
|
-
if (Date.now() > deadline) throw new Error('Replicate generation timed out')
|
|
665
|
-
if (pred.status === 'failed' || pred.status === 'canceled') {
|
|
666
|
-
throw new Error(`Replicate failed: ${pred.error || 'unknown error'}`)
|
|
667
|
-
}
|
|
668
|
-
const repDelay = calculateBackoff(attempt++, cfg.pollIntervalMs || 1000, 5000)
|
|
669
|
-
await new Promise((r) => setTimeout(r, repDelay))
|
|
670
|
-
const pRes = await fetchImpl(pollUrl, { headers: { Authorization: `Bearer ${key}` }, signal })
|
|
671
|
-
pred = await pRes.json().catch(() => ({}))
|
|
672
|
-
}
|
|
673
|
-
}
|
|
674
|
-
const output = Array.isArray(pred.output) ? pred.output[0] : pred.output
|
|
675
|
-
if (!output) throw new Error('Replicate returned no image output')
|
|
676
|
-
const dl = await fetchImpl(output, { signal })
|
|
677
|
-
if (!dl.ok) throw new Error(`Replicate download failed (HTTP ${dl.status})`)
|
|
678
|
-
const bytes = Buffer.from(await dl.arrayBuffer())
|
|
679
|
-
return {
|
|
680
|
-
bytes,
|
|
681
|
-
mediaType: normalizeMediaType(dl.headers?.get?.('content-type') || 'image/png', format),
|
|
682
|
-
width,
|
|
683
|
-
height,
|
|
684
|
-
seed: seedArg ?? 0,
|
|
685
|
-
cost: estimateCost('replicate', model),
|
|
686
|
-
sourceUrl: output,
|
|
687
|
-
}
|
|
188
|
+
const subscription = createSubscriptionGenerator(deps, job)
|
|
189
|
+
return {
|
|
190
|
+
fal: createFalGenerator(deps, job),
|
|
191
|
+
custom: createCustomGenerator(deps, job),
|
|
192
|
+
codex: subscription('codex'),
|
|
193
|
+
grok: subscription('grok'),
|
|
194
|
+
local: createLocalGenerator(deps, job),
|
|
195
|
+
seedream: createSeedreamGenerator(deps, job),
|
|
196
|
+
gemini: createGeminiGenerator(deps, job),
|
|
197
|
+
replicate: createReplicateGenerator(deps, job),
|
|
688
198
|
}
|
|
689
|
-
|
|
690
|
-
return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local, seedream, gemini, replicate }
|
|
691
199
|
}
|
|
692
200
|
|
|
693
201
|
|
|
@@ -792,21 +300,6 @@ export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16,
|
|
|
792
300
|
}
|
|
793
301
|
|
|
794
302
|
|
|
795
|
-
export function estimateCost(provider, model, { count = 1 } = {}) {
|
|
796
|
-
const p = String(provider).toLowerCase()
|
|
797
|
-
if (p === 'fal') {
|
|
798
|
-
if (String(model).includes('schnell') || String(model).includes('klein')) return 0.003 * count
|
|
799
|
-
if (String(model).includes('dev')) return 0.025 * count
|
|
800
|
-
if (String(model).includes('clarity') || String(model).includes('upscale')) return 0.01 * count
|
|
801
|
-
return 0.005 * count
|
|
802
|
-
}
|
|
803
|
-
if (p === 'replicate') return 0.003 * count
|
|
804
|
-
if (p === 'seedream') return 0.004 * count
|
|
805
|
-
if (p === 'gemini') return 0.03 * count
|
|
806
|
-
if (p === 'codex' || p === 'grok' || p === 'local') return 0.0
|
|
807
|
-
return 0.01 * count
|
|
808
|
-
}
|
|
809
|
-
|
|
810
303
|
|
|
811
304
|
export const STYLE_PRESETS = {
|
|
812
305
|
cinematic: {
|
package/lib/register-tools.js
CHANGED
|
@@ -7,6 +7,10 @@ import { registerProcessingAdvancedTools } from './tools/processing-advanced.js'
|
|
|
7
7
|
import { registerEditingTools } from './tools/editing.js'
|
|
8
8
|
import { registerInspectTools } from './tools/inspect.js'
|
|
9
9
|
import { registerFrontendTools } from './tools/frontend.js'
|
|
10
|
+
import { registerSketchTools } from './tools/sketch.js'
|
|
11
|
+
import { registerSpritesheetTools } from './tools/spritesheet.js'
|
|
12
|
+
import { registerPatternTools } from './tools/pattern.js'
|
|
13
|
+
import { registerResponsiveTools } from './tools/responsive.js'
|
|
10
14
|
|
|
11
15
|
/**
|
|
12
16
|
* Registers every image-gen tool on the host tools service.
|
|
@@ -21,4 +25,8 @@ export function registerAllTools(ctx, deps) {
|
|
|
21
25
|
registerEditingTools(ctx, deps)
|
|
22
26
|
registerInspectTools(ctx, deps)
|
|
23
27
|
registerFrontendTools(ctx, deps)
|
|
28
|
+
registerSketchTools(ctx, deps)
|
|
29
|
+
registerSpritesheetTools(ctx, deps)
|
|
30
|
+
registerPatternTools(ctx, deps)
|
|
31
|
+
registerResponsiveTools(ctx, deps)
|
|
24
32
|
}
|