@goodandready/dsh-image-gen 0.10.23 → 0.10.25
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 +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +15 -0
- package/lib/client.js +161 -33
- package/lib/cost-meter.js +4 -2
- package/lib/generation-cache.js +6 -3
- package/lib/index.js +3 -1
- package/lib/loop-guard.js +2 -0
- package/lib/pattern-helpers.js +87 -0
- package/lib/prompt-polisher.js +0 -7
- package/lib/provider-utils.js +302 -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 +95 -847
- package/lib/register-tools.js +11 -1
- package/lib/responsive-helpers.js +94 -0
- package/lib/spritesheet-helpers.js +65 -0
- package/lib/tools/editing.js +2 -0
- package/lib/tools/pattern.js +125 -0
- package/lib/tools/{processing.js → processing-advanced.js} +11 -270
- package/lib/tools/processing-basic.js +327 -0
- package/lib/tools/responsive.js +267 -0
- package/lib/tools/sketch.js +167 -0
- package/lib/tools/spritesheet.js +151 -0
- package/package.json +3 -2
package/lib/providers.js
CHANGED
|
@@ -1,239 +1,90 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
|
|
3
3
|
*/
|
|
4
|
-
export function resolveApiKeyCandidates(ref) {
|
|
5
|
-
if (!ref) return []
|
|
6
|
-
const candidates = [ref]
|
|
7
|
-
if (ref === 'FAL_API_KEY') candidates.push('FAL_KEY')
|
|
8
|
-
else if (ref === 'FAL_KEY') candidates.push('FAL_API_KEY')
|
|
9
|
-
return candidates
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
export const PROVIDER_MAX_COUNTS = {
|
|
14
|
-
fal: 4,
|
|
15
|
-
replicate: 4,
|
|
16
|
-
custom: 10,
|
|
17
|
-
seedream: 10,
|
|
18
|
-
gemini: 4,
|
|
19
|
-
codex: 1,
|
|
20
|
-
grok: 1,
|
|
21
|
-
local: 4,
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export function clampProviderCount(provider, count) {
|
|
25
|
-
const max = PROVIDER_MAX_COUNTS[provider] || 4
|
|
26
|
-
return normalizeCount(count, max)
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
export function buildEndpointUrl(baseURL, pathSuffix) {
|
|
30
|
-
const base = String(baseURL || '').trim().replace(/\/+$/, '')
|
|
31
|
-
const suffix = String(pathSuffix || '').trim().replace(/^\/+/, '')
|
|
32
|
-
return `${base}/${suffix}`
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export function createAbortError(message = 'Image generation cancelled') {
|
|
36
|
-
const err = new Error(message)
|
|
37
|
-
err.name = 'AbortError'
|
|
38
|
-
return err
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
/** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
|
|
43
|
-
export function estimateSharpnessAndVariance(bytes) {
|
|
44
|
-
const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
|
|
45
|
-
if (!buf || buf.length < 64) {
|
|
46
|
-
return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
|
|
47
|
-
}
|
|
48
|
-
let sum = 0
|
|
49
|
-
let diffSum = 0
|
|
50
|
-
const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
|
|
51
|
-
let count = 0
|
|
52
|
-
for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
|
|
53
|
-
const v = buf[i]
|
|
54
|
-
const nextV = buf[i + sampleStep]
|
|
55
|
-
sum += v
|
|
56
|
-
diffSum += Math.abs(v - nextV)
|
|
57
|
-
count++
|
|
58
|
-
}
|
|
59
|
-
const avgDiff = count > 0 ? diffSum / count : 0
|
|
60
|
-
if (avgDiff < 2) {
|
|
61
|
-
return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
|
|
62
|
-
}
|
|
63
|
-
const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
|
|
64
|
-
return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** Adaptive color palette quantizer for SVG vectorization. */
|
|
68
|
-
export function quantizePalette(colorMode = 'color', paletteSize = 16) {
|
|
69
|
-
if (colorMode === 'binary') return ['#000000', '#ffffff']
|
|
70
|
-
if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
|
|
71
|
-
// Standard vibrant UI vector palette
|
|
72
|
-
return [
|
|
73
|
-
'#000000', '#ffffff', '#e11d48', '#2563eb',
|
|
74
|
-
'#16a34a', '#ca8a04', '#9333ea', '#0891b2',
|
|
75
|
-
'#475569', '#64748b', '#94a3b8', '#cbd5e1'
|
|
76
|
-
].slice(0, Math.max(2, paletteSize))
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
/** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
|
|
81
|
-
export function formatA1111Parameters(metadata = {}) {
|
|
82
|
-
if (typeof metadata === 'string') return metadata
|
|
83
|
-
const prompt = metadata.prompt || ''
|
|
84
|
-
const neg = metadata.negative_prompt || metadata.negativePrompt || ''
|
|
85
|
-
const seed = metadata.seed ?? ''
|
|
86
|
-
const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
|
|
87
|
-
const model = metadata.model || metadata.provider || ''
|
|
88
|
-
const steps = metadata.steps || 20
|
|
89
|
-
const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
|
|
90
|
-
|
|
91
|
-
let out = prompt
|
|
92
|
-
if (neg) out += `\nNegative prompt: ${neg}`
|
|
93
|
-
out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
|
|
94
|
-
return out
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
import { createHash } from 'node:crypto'
|
|
99
|
-
|
|
100
|
-
/** Deterministic sha256 hash for image generation caching. */
|
|
101
|
-
export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
|
|
102
|
-
const norm = [
|
|
103
|
-
String(provider || '').trim().toLowerCase(),
|
|
104
|
-
String(model || '').trim().toLowerCase(),
|
|
105
|
-
String(prompt || '').trim(),
|
|
106
|
-
String(seed ?? ''),
|
|
107
|
-
String(size || '').trim().toLowerCase(),
|
|
108
|
-
String(style || '').trim().toLowerCase(),
|
|
109
|
-
].join('|')
|
|
110
|
-
return createHash('sha256').update(norm).digest('hex')
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
// Image generation provider implementations.
|
|
114
|
-
//
|
|
115
|
-
// A provider receives a generation job and returns finished image bytes. Everything
|
|
116
|
-
// that follows (attachments, workspace files, links, card rendering) is shared
|
|
117
|
-
// across all providers and lives in index.js.
|
|
118
|
-
//
|
|
119
|
-
// Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
|
|
120
|
-
|
|
121
|
-
export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
|
|
122
|
-
|
|
123
|
-
/** Clamp count from tool arguments to range 1..4. */
|
|
124
|
-
/** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
|
|
125
|
-
export function fallbackOrder(primary) {
|
|
126
|
-
return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Iterates through candidate generators, returning the first successful result.
|
|
131
|
-
* Throws an aggregate error if all candidates fail.
|
|
132
|
-
* @param generators - array of (key, seed) => Promise<generated> functions.
|
|
133
|
-
* @param order - candidate key evaluation order.
|
|
134
|
-
*/
|
|
135
|
-
|
|
136
|
-
/** Calculate exponential backoff delay with jitter. */
|
|
137
|
-
export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
|
|
138
|
-
const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
|
|
139
|
-
const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
|
|
140
|
-
return Math.max(100, Math.floor(exp + jitter))
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
/** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
|
|
144
|
-
export function isFatalClientError(error) {
|
|
145
|
-
const msg = (error?.message || String(error || '')).toLowerCase()
|
|
146
|
-
return (
|
|
147
|
-
msg.includes('content policy') ||
|
|
148
|
-
msg.includes('safety system') ||
|
|
149
|
-
msg.includes('nsfw') ||
|
|
150
|
-
msg.includes('moderation') ||
|
|
151
|
-
msg.includes('bad request (http 400') ||
|
|
152
|
-
msg.includes('invalid_prompt') ||
|
|
153
|
-
msg.includes('prompt is required') ||
|
|
154
|
-
msg.includes('unsupported image format') ||
|
|
155
|
-
msg.includes('402 payment required') ||
|
|
156
|
-
msg.includes('insufficient_quota') ||
|
|
157
|
-
msg.includes('insufficient credits') ||
|
|
158
|
-
msg.includes('exceeded your current quota') ||
|
|
159
|
-
msg.includes('balance is insufficient')
|
|
160
|
-
)
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
/**
|
|
164
|
-
* Safely extract a human-readable message from any error, object, or response.
|
|
165
|
-
* Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
|
|
166
|
-
*/
|
|
167
|
-
export function formatErrorMessage(e, providerKey = '') {
|
|
168
|
-
if (e === null || e === undefined) {
|
|
169
|
-
return providerKey ? `${providerKey}: unknown error` : 'unknown error'
|
|
170
|
-
}
|
|
171
|
-
|
|
172
|
-
let msg = ''
|
|
173
|
-
if (typeof e === 'string') {
|
|
174
|
-
msg = e
|
|
175
|
-
} else if (typeof e === 'object') {
|
|
176
|
-
if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
|
|
177
|
-
msg = e.message
|
|
178
|
-
} else if (typeof e.detail === 'string' && e.detail) {
|
|
179
|
-
msg = e.detail
|
|
180
|
-
} else if (e.error) {
|
|
181
|
-
if (typeof e.error === 'string') msg = e.error
|
|
182
|
-
else if (typeof e.error.message === 'string') msg = e.error.message
|
|
183
|
-
else if (typeof e.error.detail === 'string') msg = e.error.detail
|
|
184
|
-
else {
|
|
185
|
-
try { msg = JSON.stringify(e.error) } catch {}
|
|
186
|
-
}
|
|
187
|
-
} else if (typeof e.statusText === 'string' && e.statusText) {
|
|
188
|
-
msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
|
|
189
|
-
} else if (typeof e.cause === 'string') {
|
|
190
|
-
msg = e.cause
|
|
191
|
-
} else if (e.cause && typeof e.cause.message === 'string') {
|
|
192
|
-
msg = e.cause.message
|
|
193
|
-
}
|
|
194
4
|
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
5
|
+
// Utilities and constants extracted to provider-utils.js (#239)
|
|
6
|
+
import {
|
|
7
|
+
resolveApiKeyCandidates,
|
|
8
|
+
PROVIDER_MAX_COUNTS,
|
|
9
|
+
clampProviderCount,
|
|
10
|
+
buildEndpointUrl,
|
|
11
|
+
createAbortError,
|
|
12
|
+
estimateSharpnessAndVariance,
|
|
13
|
+
quantizePalette,
|
|
14
|
+
formatA1111Parameters,
|
|
15
|
+
computeGenerationHash,
|
|
16
|
+
PROVIDER_KEYS,
|
|
17
|
+
fallbackOrder,
|
|
18
|
+
calculateBackoff,
|
|
19
|
+
isFatalClientError,
|
|
20
|
+
formatErrorMessage,
|
|
21
|
+
resolveSubscriptionSize,
|
|
22
|
+
SUBSCRIPTION_SIZES,
|
|
23
|
+
IMAGE_SIZES,
|
|
24
|
+
OUTPUT_FORMATS,
|
|
25
|
+
SIZE_PIXELS,
|
|
26
|
+
normalizeCount,
|
|
27
|
+
} from './provider-utils.js'
|
|
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
|
+
|
|
65
|
+
// Re-export utilities so existing imports from './providers.js' continue to work
|
|
66
|
+
export {
|
|
67
|
+
resolveApiKeyCandidates,
|
|
68
|
+
PROVIDER_MAX_COUNTS,
|
|
69
|
+
clampProviderCount,
|
|
70
|
+
buildEndpointUrl,
|
|
71
|
+
createAbortError,
|
|
72
|
+
estimateSharpnessAndVariance,
|
|
73
|
+
quantizePalette,
|
|
74
|
+
formatA1111Parameters,
|
|
75
|
+
computeGenerationHash,
|
|
76
|
+
PROVIDER_KEYS,
|
|
77
|
+
fallbackOrder,
|
|
78
|
+
calculateBackoff,
|
|
79
|
+
isFatalClientError,
|
|
80
|
+
formatErrorMessage,
|
|
81
|
+
resolveSubscriptionSize,
|
|
82
|
+
SUBSCRIPTION_SIZES,
|
|
83
|
+
IMAGE_SIZES,
|
|
84
|
+
OUTPUT_FORMATS,
|
|
85
|
+
SIZE_PIXELS,
|
|
86
|
+
normalizeCount,
|
|
87
|
+
} from './provider-utils.js'
|
|
237
88
|
|
|
238
89
|
export async function tryGenerate(generators, order, seed, promptArg) {
|
|
239
90
|
const refusals = []
|
|
@@ -251,11 +102,6 @@ export async function tryGenerate(generators, order, seed, promptArg) {
|
|
|
251
102
|
throw new Error(`Image generation failed on all providers. ${refusals.join('; ')}`)
|
|
252
103
|
}
|
|
253
104
|
|
|
254
|
-
export function normalizeCount(count, max = 4) {
|
|
255
|
-
const n = Math.floor(Number(count))
|
|
256
|
-
if (!Number.isFinite(n)) return 1
|
|
257
|
-
return Math.max(1, Math.min(max, n))
|
|
258
|
-
}
|
|
259
105
|
|
|
260
106
|
// Subscription services use their own aspect ratio taxonomy.
|
|
261
107
|
// Normalize named sizes to standard square, landscape, and portrait equivalents.
|
|
@@ -264,82 +110,7 @@ export function normalizeCount(count, max = 4) {
|
|
|
264
110
|
* Supports named sizes (landscape_16_9, etc.), aspect ratios (16:9, 3:2, 9:16),
|
|
265
111
|
* and pixel dimensions [width, height], preserving requested proportions.
|
|
266
112
|
*/
|
|
267
|
-
export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
|
|
268
|
-
if (size && SUBSCRIPTION_SIZES[size]) {
|
|
269
|
-
return SUBSCRIPTION_SIZES[size]
|
|
270
|
-
}
|
|
271
|
-
const ratio = String(aspectRatio || '').trim()
|
|
272
|
-
if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
|
|
273
|
-
return '1536x1024'
|
|
274
|
-
}
|
|
275
|
-
if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
|
|
276
|
-
return '1024x1536'
|
|
277
|
-
}
|
|
278
|
-
if (ratio === '1:1') {
|
|
279
|
-
return '1024x1024'
|
|
280
|
-
}
|
|
281
|
-
if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
|
|
282
|
-
const [w, h] = aspectPixels
|
|
283
|
-
if (w > h) return '1536x1024'
|
|
284
|
-
if (h > w) return '1024x1536'
|
|
285
|
-
return '1024x1024'
|
|
286
|
-
}
|
|
287
|
-
return '1024x1024'
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
export const SUBSCRIPTION_SIZES = {
|
|
291
|
-
square_hd: '1024x1024',
|
|
292
|
-
square: '1024x1024',
|
|
293
|
-
portrait_4_3: '1024x1536',
|
|
294
|
-
portrait_16_9: '1024x1536',
|
|
295
|
-
landscape_4_3: '1536x1024',
|
|
296
|
-
landscape_16_9: '1536x1024',
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
/** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
|
|
300
|
-
export const IMAGE_SIZES = [
|
|
301
|
-
'square_hd',
|
|
302
|
-
'square',
|
|
303
|
-
'portrait_4_3',
|
|
304
|
-
'portrait_16_9',
|
|
305
|
-
'landscape_4_3',
|
|
306
|
-
'landscape_16_9',
|
|
307
|
-
]
|
|
308
|
-
|
|
309
|
-
/** Output formats accepted by the model. */
|
|
310
|
-
export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
311
|
-
|
|
312
|
-
// Named sizes are a unified abstraction across all providers.
|
|
313
|
-
// FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
|
|
314
|
-
export const SIZE_PIXELS = {
|
|
315
|
-
square_hd: '1024x1024',
|
|
316
|
-
square: '512x512',
|
|
317
|
-
portrait_4_3: '768x1024',
|
|
318
|
-
portrait_16_9: '576x1024',
|
|
319
|
-
landscape_4_3: '1024x768',
|
|
320
|
-
landscape_16_9: '1024x576',
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
/** Normalize a raw key into a FAL `Authorization: Key <key>` value. */
|
|
324
|
-
export function falAuthHeader(key) {
|
|
325
|
-
const trimmed = String(key ?? '').trim()
|
|
326
|
-
if (!trimmed) return ''
|
|
327
|
-
return trimmed.startsWith('Key ') || trimmed.startsWith('key ')
|
|
328
|
-
? trimmed
|
|
329
|
-
: `Key ${trimmed}`
|
|
330
|
-
}
|
|
331
|
-
|
|
332
113
|
/** Map a content type onto the attachment service's supported set. */
|
|
333
|
-
export function normalizeMediaType(contentType, fallbackFormat) {
|
|
334
|
-
const raw = String(contentType ?? '').toLowerCase()
|
|
335
|
-
if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
|
|
336
|
-
if (raw.includes('webp')) return 'image/webp'
|
|
337
|
-
if (raw.includes('png')) return 'image/png'
|
|
338
|
-
if (fallbackFormat === 'jpeg') return 'image/jpeg'
|
|
339
|
-
if (fallbackFormat === 'webp') return 'image/webp'
|
|
340
|
-
return 'image/png'
|
|
341
|
-
}
|
|
342
|
-
|
|
343
114
|
/** Generation metadata written to companion sidecar file. */
|
|
344
115
|
export function buildSidecar({
|
|
345
116
|
prompt, size, format, seed, provider, deliverAs,
|
|
@@ -349,58 +120,7 @@ export function buildSidecar({
|
|
|
349
120
|
}
|
|
350
121
|
|
|
351
122
|
/** Submit a generation job to the FAL queue. */
|
|
352
|
-
export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
|
|
353
|
-
const res = await fetchImpl(`${baseURL}/${model}`, {
|
|
354
|
-
method: 'POST',
|
|
355
|
-
headers: {
|
|
356
|
-
Authorization: falAuthHeader(key),
|
|
357
|
-
'Content-Type': 'application/json',
|
|
358
|
-
},
|
|
359
|
-
body: JSON.stringify(body),
|
|
360
|
-
signal,
|
|
361
|
-
})
|
|
362
|
-
const data = await res.json().catch(() => ({}))
|
|
363
|
-
if (!res.ok || !data.request_id) {
|
|
364
|
-
const detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data).slice(0, 600)
|
|
365
|
-
throw new Error(`FAL submit failed (HTTP ${res.status}): ${detail}`)
|
|
366
|
-
}
|
|
367
|
-
return data
|
|
368
|
-
}
|
|
369
|
-
|
|
370
123
|
/** Poll the FAL status endpoint until completion, failure, timeout, or abort. */
|
|
371
|
-
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
|
|
372
|
-
const deadline = Date.now() + timeoutMs
|
|
373
|
-
let attempt = 0
|
|
374
|
-
for (;;) {
|
|
375
|
-
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
376
|
-
if (Date.now() > deadline) throw new Error(`FAL generation timed out after ${timeoutMs} ms`)
|
|
377
|
-
if (attempt > 0) {
|
|
378
|
-
const delay = calculateBackoff(attempt - 1, pollIntervalMs, 5000)
|
|
379
|
-
await new Promise((resolve) => {
|
|
380
|
-
const timer = setTimeout(resolve, delay)
|
|
381
|
-
if (signal) {
|
|
382
|
-
signal.addEventListener('abort', () => {
|
|
383
|
-
clearTimeout(timer)
|
|
384
|
-
resolve()
|
|
385
|
-
}, { once: true })
|
|
386
|
-
}
|
|
387
|
-
})
|
|
388
|
-
}
|
|
389
|
-
attempt++
|
|
390
|
-
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
391
|
-
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
392
|
-
const data = await res.json().catch(() => ({}))
|
|
393
|
-
const status = data.status
|
|
394
|
-
if (status === 'COMPLETED') return data
|
|
395
|
-
if (status === 'ERROR' || data.error || data.detail) {
|
|
396
|
-
throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
|
|
397
|
-
}
|
|
398
|
-
if (status !== 'IN_QUEUE' && status !== 'IN_PROGRESS') {
|
|
399
|
-
throw new Error(`Unexpected FAL status "${status}": ${JSON.stringify(data).slice(0, 300)}`)
|
|
400
|
-
}
|
|
401
|
-
}
|
|
402
|
-
}
|
|
403
|
-
|
|
404
124
|
/**
|
|
405
125
|
* @param deps {{fetchImpl: Function, resolveKey: (ref: string) => Promise<string>, cfg: object}}
|
|
406
126
|
* @param job {{prompt: string, size: string, format: string, seed: number|undefined, signal: AbortSignal}}
|
|
@@ -411,51 +131,11 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
411
131
|
|
|
412
132
|
/** Named size -> [width, height] for local APIs. */
|
|
413
133
|
/** Aspect ratio -> [width, height] (pixels, normalized to 1024 base). */
|
|
414
|
-
export const ASPECT_RATIOS = {
|
|
415
|
-
'1:1': [1024, 1024],
|
|
416
|
-
'16:9': [1344, 768],
|
|
417
|
-
'9:16': [768, 1344],
|
|
418
|
-
'4:3': [1152, 896],
|
|
419
|
-
'3:4': [896, 1152],
|
|
420
|
-
'3:2': [1152, 768],
|
|
421
|
-
'2:3': [768, 1152],
|
|
422
|
-
}
|
|
423
|
-
|
|
424
134
|
/** Pixel dimensions: aspectPixels (if specified) or mapped from named size. */
|
|
425
135
|
/** Snap a pixel dimension to nearest multiple of 64. */
|
|
426
|
-
export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
|
|
427
|
-
const n = Math.round(Number(dim) / 64) * 64
|
|
428
|
-
return Math.max(minVal, Math.min(maxVal, n))
|
|
429
|
-
}
|
|
430
136
|
|
|
431
|
-
export function snapDimensions(width, height) {
|
|
432
|
-
return [snapToMultipleOf64(width), snapToMultipleOf64(height)]
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
export function pxSize(aspectPixels, size) {
|
|
436
|
-
if (aspectPixels) return snapDimensions(aspectPixels[0], aspectPixels[1])
|
|
437
|
-
const [w, h] = sizeToPixels(size)
|
|
438
|
-
return snapDimensions(w, h)
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
export function sizeToPixels(size) {
|
|
442
|
-
const px = SIZE_PIXELS[size]
|
|
443
|
-
if (!px) return [1024, 1024]
|
|
444
|
-
const [w, h] = px.split('x').map(Number)
|
|
445
|
-
return [w, h]
|
|
446
|
-
}
|
|
447
137
|
|
|
448
138
|
/** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
|
|
449
|
-
export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
450
|
-
const form = new FormData()
|
|
451
|
-
form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
|
|
452
|
-
if (mask) form.append('mask', new Blob([mask.bytes], { type: mask.mediaType || 'image/png' }), 'mask.png')
|
|
453
|
-
form.append('prompt', prompt)
|
|
454
|
-
if (size) form.append('size', size)
|
|
455
|
-
if (strength !== undefined) form.append('strength', String(strength))
|
|
456
|
-
return form
|
|
457
|
-
}
|
|
458
|
-
|
|
459
139
|
/** Compare two images: ratio of differing pixels (0..1). */
|
|
460
140
|
export async function pixelDiff(a, b) {
|
|
461
141
|
if (!a || !b) return { error: 'missing image' }
|
|
@@ -503,438 +183,19 @@ export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
|
|
|
503
183
|
}
|
|
504
184
|
}
|
|
505
185
|
|
|
506
|
-
export function extractComfyNodeErrors(entry) {
|
|
507
|
-
if (!entry || typeof entry !== 'object') return ''
|
|
508
|
-
const status = entry.status
|
|
509
|
-
if (status && status.status_str === 'error') {
|
|
510
|
-
const msgs = status.messages || []
|
|
511
|
-
const errList = []
|
|
512
|
-
for (const m of msgs) {
|
|
513
|
-
if (Array.isArray(m) && m[0] === 'execution_error') {
|
|
514
|
-
const d = m[1] || {}
|
|
515
|
-
errList.push(`node ${d.node_id || 'unknown'} (${d.node_type || ''}): ${d.exception_message || d.exception_type || 'execution failed'}`)
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
if (errList.length) return errList.join('; ')
|
|
519
|
-
return status.status_str || 'execution error'
|
|
520
|
-
}
|
|
521
|
-
return ''
|
|
522
|
-
}
|
|
523
186
|
|
|
524
187
|
export function makeProviders(deps, job) {
|
|
525
|
-
const
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
if (seedArg !== undefined) body.seed = seedArg
|
|
536
|
-
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
537
|
-
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
538
|
-
if (format !== 'png') body.output_format = format
|
|
539
|
-
if (isEdit) {
|
|
540
|
-
body.image_url = `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`
|
|
541
|
-
if (mask && mask.bytes) {
|
|
542
|
-
body.mask_url = `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}`
|
|
543
|
-
}
|
|
544
|
-
if (strength !== undefined) {
|
|
545
|
-
body.strength = strength
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
|
|
549
|
-
const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
|
|
550
|
-
const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
|
|
551
|
-
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
|
|
552
|
-
|
|
553
|
-
const resultRes = await fetchImpl(statusBody.response_url, {
|
|
554
|
-
headers: { Authorization: falAuthHeader(key) },
|
|
555
|
-
signal,
|
|
556
|
-
})
|
|
557
|
-
const result = await resultRes.json().catch(() => ({}))
|
|
558
|
-
const image = result.images && result.images[0]
|
|
559
|
-
if (!image || !image.url) {
|
|
560
|
-
throw new Error(`FAL returned no images: ${JSON.stringify(result).slice(0, 600)}`)
|
|
561
|
-
}
|
|
562
|
-
const download = await fetchImpl(image.url, { signal })
|
|
563
|
-
if (!download.ok) {
|
|
564
|
-
throw new Error(`Failed to download generated image (HTTP ${download.status})`)
|
|
565
|
-
}
|
|
566
|
-
return {
|
|
567
|
-
bytes: Buffer.from(await download.arrayBuffer()),
|
|
568
|
-
mediaType: normalizeMediaType(image.content_type, format),
|
|
569
|
-
width: image.width ?? 0,
|
|
570
|
-
height: image.height ?? 0,
|
|
571
|
-
seed: result.seed ?? seedArg ?? 0,
|
|
572
|
-
sourceUrl: image.url,
|
|
573
|
-
cost: result.cost,
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
|
|
577
|
-
// OpenAI-compatible image API. Single HTTP request instead of queue polling.
|
|
578
|
-
async function custom(seedArg = seed, promptArg = prompt) {
|
|
579
|
-
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
580
|
-
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
581
|
-
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
582
|
-
|
|
583
|
-
// Empty key reference indicates unauthenticated gateway.
|
|
584
|
-
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
585
|
-
const headers = { 'Content-Type': 'application/json' }
|
|
586
|
-
if (key) headers.Authorization = `Bearer ${key}`
|
|
587
|
-
|
|
588
|
-
// Omit response_format; modern models return either base64 or URL.
|
|
589
|
-
const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
|
|
590
|
-
const res = await fetchImpl(endpoint, {
|
|
591
|
-
method: 'POST',
|
|
592
|
-
headers,
|
|
593
|
-
body: source
|
|
594
|
-
? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
|
|
595
|
-
: JSON.stringify({
|
|
596
|
-
model: cfg.customModel,
|
|
597
|
-
prompt: promptArg,
|
|
598
|
-
n: 1,
|
|
599
|
-
size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
600
|
-
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
601
|
-
...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
|
|
602
|
-
...(quality && quality !== 'auto' ? { quality } : {}),
|
|
603
|
-
}),
|
|
604
|
-
signal,
|
|
605
|
-
})
|
|
606
|
-
const data = await res.json().catch(() => ({}))
|
|
607
|
-
if (!res.ok) {
|
|
608
|
-
if (res.status === 503) {
|
|
609
|
-
const msg = (data?.error?.message || JSON.stringify(data)).toLowerCase()
|
|
610
|
-
if (msg.includes('no available channel') || msg.includes('model_not_found')) {
|
|
611
|
-
throw new Error(`Custom image API has no provisioned channel for "${cfg.customModel}" (HTTP 503); check gateway channel and routing configuration`)
|
|
612
|
-
}
|
|
613
|
-
}
|
|
614
|
-
const detail = formatErrorMessage(data?.error || data)
|
|
615
|
-
throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
|
|
616
|
-
}
|
|
617
|
-
const item = data?.data?.[0]
|
|
618
|
-
if (!item) {
|
|
619
|
-
throw new Error(`Image API returned no images: ${JSON.stringify(data).slice(0, 600)}`)
|
|
620
|
-
}
|
|
621
|
-
|
|
622
|
-
if (item.b64_json) {
|
|
623
|
-
return {
|
|
624
|
-
bytes: Buffer.from(item.b64_json, 'base64'),
|
|
625
|
-
mediaType: normalizeMediaType(data.output_format || '', format),
|
|
626
|
-
width: 0,
|
|
627
|
-
height: 0,
|
|
628
|
-
seed: seedArg ?? 0,
|
|
629
|
-
sourceUrl: '',
|
|
630
|
-
}
|
|
631
|
-
}
|
|
632
|
-
if (!item.url) {
|
|
633
|
-
throw new Error(`Image API returned neither b64_json nor url: ${JSON.stringify(item).slice(0, 300)}`)
|
|
634
|
-
}
|
|
635
|
-
const download = await fetchImpl(item.url, { signal })
|
|
636
|
-
if (!download.ok) {
|
|
637
|
-
throw new Error(`Failed to download generated image (HTTP ${download.status})`)
|
|
638
|
-
}
|
|
639
|
-
const contentType = download.headers && typeof download.headers.get === 'function'
|
|
640
|
-
? download.headers.get('content-type')
|
|
641
|
-
: ''
|
|
642
|
-
return {
|
|
643
|
-
bytes: Buffer.from(await download.arrayBuffer()),
|
|
644
|
-
mediaType: normalizeMediaType(contentType, format),
|
|
645
|
-
width: 0,
|
|
646
|
-
height: 0,
|
|
647
|
-
seed: seedArg ?? 0,
|
|
648
|
-
sourceUrl: item.url,
|
|
649
|
-
}
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
// Subscription generation: dsh-subscriptions handles session authentication.
|
|
653
|
-
function subscription(provider) {
|
|
654
|
-
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
655
|
-
if (source) {
|
|
656
|
-
return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
|
|
657
|
-
}
|
|
658
|
-
const images = deps.subscriptionImages
|
|
659
|
-
if (!images || typeof images.generate !== 'function') {
|
|
660
|
-
return {
|
|
661
|
-
ok: false,
|
|
662
|
-
provider,
|
|
663
|
-
reason: `${provider}: requires dsh-subscriptions plugin to manage session authentication`,
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
|
-
let produced
|
|
667
|
-
try {
|
|
668
|
-
produced = await images.generate({
|
|
669
|
-
provider,
|
|
670
|
-
prompt: promptArg,
|
|
671
|
-
size: resolveSubscriptionSize(size, aspectPixels, aspectRatio),
|
|
672
|
-
quality: cfg.subscriptionQuality || undefined,
|
|
673
|
-
signal,
|
|
674
|
-
})
|
|
675
|
-
} catch (e) {
|
|
676
|
-
return { ok: false, provider, reason: formatErrorMessage(e, provider) }
|
|
677
|
-
}
|
|
678
|
-
const first = Array.isArray(produced) ? produced[0] : null
|
|
679
|
-
if (!first || !first.b64_json) {
|
|
680
|
-
return { ok: false, provider, reason: `${provider}: no image returned in response` }
|
|
681
|
-
}
|
|
682
|
-
return {
|
|
683
|
-
bytes: Buffer.from(first.b64_json, 'base64'),
|
|
684
|
-
// Subscription outputs default to PNG media type.
|
|
685
|
-
mediaType: 'image/png',
|
|
686
|
-
width: 0,
|
|
687
|
-
height: 0,
|
|
688
|
-
seed: seedArg ?? 0,
|
|
689
|
-
sourceUrl: '',
|
|
690
|
-
revisedPrompt: first.revisedPrompt || '',
|
|
691
|
-
}
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
|
|
695
|
-
// Local generation: ComfyUI (queue + poll) or Automatic1111 (txt2img).
|
|
696
|
-
async function local(seedArg = seed, promptArg = prompt) {
|
|
697
|
-
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
698
|
-
if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
|
|
699
|
-
const [width, height] = pxSize(aspectPixels, size)
|
|
700
|
-
const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
|
|
701
|
-
|
|
702
|
-
if (kind === 'a1111') {
|
|
703
|
-
const isImg2Img = Boolean(source && source.bytes)
|
|
704
|
-
const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
|
|
705
|
-
const body = {
|
|
706
|
-
prompt: promptArg,
|
|
707
|
-
negative_prompt: negativePrompt,
|
|
708
|
-
width,
|
|
709
|
-
height,
|
|
710
|
-
steps: cfg.localSteps ?? 20,
|
|
711
|
-
cfg_scale: cfg.localCfg ?? 7,
|
|
712
|
-
seed: seedArg ?? -1,
|
|
713
|
-
}
|
|
714
|
-
if (isImg2Img) {
|
|
715
|
-
body.init_images = [Buffer.from(source.bytes).toString('base64')]
|
|
716
|
-
body.denoising_strength = strength ?? (mask ? 0.75 : 0.35)
|
|
717
|
-
if (mask && mask.bytes) {
|
|
718
|
-
body.mask = Buffer.from(mask.bytes).toString('base64')
|
|
719
|
-
}
|
|
720
|
-
}
|
|
721
|
-
if (cfg.localModel) body.override_settings = { sd_model_checkpoint: cfg.localModel }
|
|
722
|
-
const res = await fetchImpl(endpoint, {
|
|
723
|
-
method: 'POST',
|
|
724
|
-
headers: { 'Content-Type': 'application/json' },
|
|
725
|
-
body: JSON.stringify(body),
|
|
726
|
-
signal,
|
|
727
|
-
})
|
|
728
|
-
if (!res.ok) {
|
|
729
|
-
throw new Error(`Local A1111 failed (HTTP ${res.status}): ${String(await res.text().catch(() => '')).slice(0, 300)}`)
|
|
730
|
-
}
|
|
731
|
-
const data = await res.json().catch(() => ({}))
|
|
732
|
-
const b64 = data.images && data.images[0]
|
|
733
|
-
if (!b64) throw new Error('Local A1111 returned no images')
|
|
734
|
-
return {
|
|
735
|
-
bytes: Buffer.from(b64, 'base64'),
|
|
736
|
-
mediaType: normalizeMediaType('image/png', format),
|
|
737
|
-
width,
|
|
738
|
-
height,
|
|
739
|
-
seed: seedArg ?? 0,
|
|
740
|
-
sourceUrl: '',
|
|
741
|
-
}
|
|
742
|
-
}
|
|
743
|
-
|
|
744
|
-
// ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
|
|
745
|
-
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
746
|
-
const workflow = {
|
|
747
|
-
prompt: {
|
|
748
|
-
'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] } },
|
|
749
|
-
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
|
|
750
|
-
'5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
|
751
|
-
'6': { class_type: 'CLIPTextEncode', inputs: { text: promptArg, clip: ['4', 1] } },
|
|
752
|
-
'7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
|
|
753
|
-
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
754
|
-
'9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
|
|
755
|
-
},
|
|
756
|
-
}
|
|
757
|
-
const submit = await fetchImpl(`${base}/prompt`, {
|
|
758
|
-
method: 'POST',
|
|
759
|
-
headers: { 'Content-Type': 'application/json' },
|
|
760
|
-
body: JSON.stringify({ prompt: workflow, client_id: promptId }),
|
|
761
|
-
signal,
|
|
762
|
-
})
|
|
763
|
-
if (!submit.ok) {
|
|
764
|
-
throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
|
|
765
|
-
}
|
|
766
|
-
const submitData = await submit.json().catch(() => ({}))
|
|
767
|
-
const pid = submitData.prompt_id
|
|
768
|
-
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
769
|
-
|
|
770
|
-
const deadline = Date.now() + cfg.timeoutMs
|
|
771
|
-
let attempt = 0
|
|
772
|
-
for (;;) {
|
|
773
|
-
if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
774
|
-
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
775
|
-
if (attempt > 0) {
|
|
776
|
-
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
777
|
-
await new Promise((resolve) => {
|
|
778
|
-
const timer = setTimeout(resolve, cDelay)
|
|
779
|
-
if (signal) {
|
|
780
|
-
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
781
|
-
}
|
|
782
|
-
})
|
|
783
|
-
}
|
|
784
|
-
attempt++
|
|
785
|
-
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
786
|
-
if (!hist.ok) continue
|
|
787
|
-
const histData = await hist.json().catch(() => ({}))
|
|
788
|
-
const entry = histData[pid]
|
|
789
|
-
if (entry) {
|
|
790
|
-
const comfyErr = extractComfyNodeErrors(entry)
|
|
791
|
-
if (comfyErr) {
|
|
792
|
-
throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
|
|
793
|
-
}
|
|
794
|
-
}
|
|
795
|
-
if (entry && entry.outputs) {
|
|
796
|
-
const outputs = entry.outputs
|
|
797
|
-
const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
|
|
798
|
-
if (img) {
|
|
799
|
-
const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
|
|
800
|
-
if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
|
|
801
|
-
return {
|
|
802
|
-
bytes: Buffer.from(await dl.arrayBuffer()),
|
|
803
|
-
mediaType: normalizeMediaType('image/png', format),
|
|
804
|
-
width,
|
|
805
|
-
height,
|
|
806
|
-
seed: seedArg ?? 0,
|
|
807
|
-
sourceUrl: '',
|
|
808
|
-
}
|
|
809
|
-
}
|
|
810
|
-
}
|
|
811
|
-
}
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
// Seedream (ByteDance): OpenAI-compatible images API.
|
|
815
|
-
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
816
|
-
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
817
|
-
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
818
|
-
const res = await fetchImpl(`${base}/images/generations`, {
|
|
819
|
-
method: 'POST',
|
|
820
|
-
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
821
|
-
body: JSON.stringify({
|
|
822
|
-
model: cfg.seedreamModel || 'seedream-4.0',
|
|
823
|
-
prompt: promptArg,
|
|
824
|
-
n: 1,
|
|
825
|
-
size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
826
|
-
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
827
|
-
...(quality !== undefined ? { quality } : {}),
|
|
828
|
-
...(style !== undefined ? { style } : {}),
|
|
829
|
-
}),
|
|
830
|
-
signal,
|
|
831
|
-
})
|
|
832
|
-
const data = await res.json().catch(() => ({}))
|
|
833
|
-
if (!res.ok) {
|
|
834
|
-
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
835
|
-
throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
|
|
836
|
-
}
|
|
837
|
-
const item = data?.data?.[0]
|
|
838
|
-
if (!item) throw new Error('Seedream returned no images')
|
|
839
|
-
if (item.b64_json) {
|
|
840
|
-
return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
841
|
-
}
|
|
842
|
-
if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
|
|
843
|
-
const dl = await fetchImpl(item.url, { signal })
|
|
844
|
-
if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
|
|
845
|
-
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
846
|
-
}
|
|
847
|
-
|
|
848
|
-
// Gemini (Google): generateContent / imagen via GenAI API.
|
|
849
|
-
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
850
|
-
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
851
|
-
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
852
|
-
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
|
|
853
|
-
method: 'POST',
|
|
854
|
-
headers: { 'Content-Type': 'application/json' },
|
|
855
|
-
body: JSON.stringify({
|
|
856
|
-
contents: [{ parts: [{ text: promptArg }] }],
|
|
857
|
-
generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
|
|
858
|
-
}),
|
|
859
|
-
signal,
|
|
860
|
-
})
|
|
861
|
-
const data = await res.json().catch(() => ({}))
|
|
862
|
-
if (!res.ok) {
|
|
863
|
-
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
864
|
-
throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
|
|
865
|
-
}
|
|
866
|
-
const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
|
|
867
|
-
if (!part) throw new Error('Gemini returned no image')
|
|
868
|
-
return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
869
|
-
}
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
// Replicate provider: API submit & polling
|
|
873
|
-
async function replicate(seedArg = seed, promptArg = prompt) {
|
|
874
|
-
const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
|
|
875
|
-
if (!key) throw new Error('Replicate API token is not configured (Settings → Image generation → replicateKeyEnv)')
|
|
876
|
-
const model = cfg.replicateModel || 'black-forest-labs/flux-schnell'
|
|
877
|
-
const [width, height] = pxSize(aspectPixels, size)
|
|
878
|
-
const body = {
|
|
879
|
-
input: {
|
|
880
|
-
prompt: promptArg,
|
|
881
|
-
aspect_ratio: aspectPixels ? `${aspectPixels[0]}:${aspectPixels[1]}` : (ASPECT_RATIOS[size] || '1:1'),
|
|
882
|
-
seed: seedArg,
|
|
883
|
-
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
|
|
884
|
-
...(source && source.bytes ? {
|
|
885
|
-
image: `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`,
|
|
886
|
-
...(mask && mask.bytes ? { mask: `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}` } : {}),
|
|
887
|
-
...(strength !== undefined ? { prompt_strength: strength } : {}),
|
|
888
|
-
} : {}),
|
|
889
|
-
},
|
|
890
|
-
}
|
|
891
|
-
const res = await fetchImpl(`https://api.replicate.com/v1/models/${model}/predictions`, {
|
|
892
|
-
method: 'POST',
|
|
893
|
-
headers: {
|
|
894
|
-
Authorization: `Bearer ${key}`,
|
|
895
|
-
'Content-Type': 'application/json',
|
|
896
|
-
},
|
|
897
|
-
body: JSON.stringify(body),
|
|
898
|
-
signal,
|
|
899
|
-
})
|
|
900
|
-
const data = await res.json().catch(() => ({}))
|
|
901
|
-
if (!res.ok) {
|
|
902
|
-
throw new Error(`Replicate submit failed (HTTP ${res.status}): ${data.detail || JSON.stringify(data).slice(0, 300)}`)
|
|
903
|
-
}
|
|
904
|
-
let pred = data
|
|
905
|
-
if (pred.status !== 'succeeded') {
|
|
906
|
-
const pollUrl = pred.urls?.get || `https://api.replicate.com/v1/predictions/${pred.id}`
|
|
907
|
-
const deadline = Date.now() + (cfg.timeoutMs || 180000)
|
|
908
|
-
let attempt = 0
|
|
909
|
-
while (pred.status !== 'succeeded') {
|
|
910
|
-
if (signal?.aborted) throw new Error('Replicate generation cancelled')
|
|
911
|
-
if (Date.now() > deadline) throw new Error('Replicate generation timed out')
|
|
912
|
-
if (pred.status === 'failed' || pred.status === 'canceled') {
|
|
913
|
-
throw new Error(`Replicate failed: ${pred.error || 'unknown error'}`)
|
|
914
|
-
}
|
|
915
|
-
const repDelay = calculateBackoff(attempt++, cfg.pollIntervalMs || 1000, 5000)
|
|
916
|
-
await new Promise((r) => setTimeout(r, repDelay))
|
|
917
|
-
const pRes = await fetchImpl(pollUrl, { headers: { Authorization: `Bearer ${key}` }, signal })
|
|
918
|
-
pred = await pRes.json().catch(() => ({}))
|
|
919
|
-
}
|
|
920
|
-
}
|
|
921
|
-
const output = Array.isArray(pred.output) ? pred.output[0] : pred.output
|
|
922
|
-
if (!output) throw new Error('Replicate returned no image output')
|
|
923
|
-
const dl = await fetchImpl(output, { signal })
|
|
924
|
-
if (!dl.ok) throw new Error(`Replicate download failed (HTTP ${dl.status})`)
|
|
925
|
-
const bytes = Buffer.from(await dl.arrayBuffer())
|
|
926
|
-
return {
|
|
927
|
-
bytes,
|
|
928
|
-
mediaType: normalizeMediaType(dl.headers?.get?.('content-type') || 'image/png', format),
|
|
929
|
-
width,
|
|
930
|
-
height,
|
|
931
|
-
seed: seedArg ?? 0,
|
|
932
|
-
cost: estimateCost('replicate', model),
|
|
933
|
-
sourceUrl: output,
|
|
934
|
-
}
|
|
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),
|
|
935
198
|
}
|
|
936
|
-
|
|
937
|
-
return { fal, custom, codex: subscription('codex'), grok: subscription('grok'), local, seedream, gemini, replicate }
|
|
938
199
|
}
|
|
939
200
|
|
|
940
201
|
|
|
@@ -1039,21 +300,6 @@ export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16,
|
|
|
1039
300
|
}
|
|
1040
301
|
|
|
1041
302
|
|
|
1042
|
-
export function estimateCost(provider, model, { count = 1 } = {}) {
|
|
1043
|
-
const p = String(provider).toLowerCase()
|
|
1044
|
-
if (p === 'fal') {
|
|
1045
|
-
if (String(model).includes('schnell') || String(model).includes('klein')) return 0.003 * count
|
|
1046
|
-
if (String(model).includes('dev')) return 0.025 * count
|
|
1047
|
-
if (String(model).includes('clarity') || String(model).includes('upscale')) return 0.01 * count
|
|
1048
|
-
return 0.005 * count
|
|
1049
|
-
}
|
|
1050
|
-
if (p === 'replicate') return 0.003 * count
|
|
1051
|
-
if (p === 'seedream') return 0.004 * count
|
|
1052
|
-
if (p === 'gemini') return 0.03 * count
|
|
1053
|
-
if (p === 'codex' || p === 'grok' || p === 'local') return 0.0
|
|
1054
|
-
return 0.01 * count
|
|
1055
|
-
}
|
|
1056
|
-
|
|
1057
303
|
|
|
1058
304
|
export const STYLE_PRESETS = {
|
|
1059
305
|
cinematic: {
|
|
@@ -1143,6 +389,7 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
|
|
|
1143
389
|
}
|
|
1144
390
|
|
|
1145
391
|
/** Direct image editing (inpainting / img2img) via selected provider. */
|
|
392
|
+
/** @internal — direct provider call path for integration tests */
|
|
1146
393
|
export async function editImageDirect(deps, job) {
|
|
1147
394
|
const providers = makeProviders(deps, job)
|
|
1148
395
|
const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
|
|
@@ -1152,6 +399,7 @@ export async function editImageDirect(deps, job) {
|
|
|
1152
399
|
}
|
|
1153
400
|
|
|
1154
401
|
/** Direct image variation via selected provider. */
|
|
402
|
+
/** @internal — direct provider call path for integration tests */
|
|
1155
403
|
export async function varyImageDirect(deps, job) {
|
|
1156
404
|
const providers = makeProviders(deps, {
|
|
1157
405
|
...job,
|