@goodandready/dsh-image-gen 0.10.22 → 0.10.24
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/client.js +176 -210
- package/lib/cost-meter.js +4 -2
- package/lib/generation-cache.js +6 -3
- package/lib/index.js +39 -32
- package/lib/loop-guard.js +2 -0
- package/lib/prompt-polisher.js +0 -7
- package/lib/provider-utils.js +302 -0
- package/lib/providers.js +132 -331
- package/lib/register-tools.js +3 -1
- package/lib/resolve-image.js +1 -1
- package/lib/tools/generation.js +9 -9
- package/lib/tools/{processing.js → processing-advanced.js} +4 -269
- package/lib/tools/processing-basic.js +327 -0
- package/lib/updater.js +338 -0
- package/package.json +2 -2
package/lib/providers.js
CHANGED
|
@@ -1,240 +1,54 @@
|
|
|
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
|
-
// Провайдеры генерации изображений.
|
|
114
|
-
//
|
|
115
|
-
// Провайдер получает задание и возвращает готовые байты картинки. Всё, что
|
|
116
|
-
// происходит дальше — вложение, файл в рабочей папке, ссылка и карточка
|
|
117
|
-
// в разговоре — общее для всех провайдеров и живёт в index.js.
|
|
118
|
-
//
|
|
119
|
-
// Сеть приходит параметром (fetchImpl), ключ — через resolveKey, поэтому оба
|
|
120
|
-
// провайдера проверяются юнит-тестами без единого реального запроса.
|
|
121
|
-
|
|
122
|
-
export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
|
|
123
|
-
|
|
124
|
-
/** Привести count из аргумента инструмента к диапазону 1..4. */
|
|
125
|
-
/** Порядок провайдеров для fallback: основной первым, остальные по PROVIDER_KEYS. */
|
|
126
|
-
export function fallbackOrder(primary) {
|
|
127
|
-
return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
/**
|
|
131
|
-
* Перебирает генераторов по порядку, возвращает первый успешный результат.
|
|
132
|
-
* При отказе всех бросает с перечислением причин.
|
|
133
|
-
* @param generators - массив функций (key, seed) => Promise<generated>.
|
|
134
|
-
* @param order - порядок ключей, длина = generators.length.
|
|
135
|
-
*/
|
|
136
|
-
|
|
137
|
-
/** Calculate exponential backoff delay with jitter. */
|
|
138
|
-
export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
|
|
139
|
-
const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
|
|
140
|
-
const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
|
|
141
|
-
return Math.max(100, Math.floor(exp + jitter))
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
/** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
|
|
145
|
-
export function isFatalClientError(error) {
|
|
146
|
-
const msg = (error?.message || String(error || '')).toLowerCase()
|
|
147
|
-
return (
|
|
148
|
-
msg.includes('content policy') ||
|
|
149
|
-
msg.includes('safety system') ||
|
|
150
|
-
msg.includes('nsfw') ||
|
|
151
|
-
msg.includes('moderation') ||
|
|
152
|
-
msg.includes('bad request (http 400') ||
|
|
153
|
-
msg.includes('invalid_prompt') ||
|
|
154
|
-
msg.includes('prompt is required') ||
|
|
155
|
-
msg.includes('unsupported image format') ||
|
|
156
|
-
msg.includes('402 payment required') ||
|
|
157
|
-
msg.includes('insufficient_quota') ||
|
|
158
|
-
msg.includes('insufficient credits') ||
|
|
159
|
-
msg.includes('exceeded your current quota') ||
|
|
160
|
-
msg.includes('balance is insufficient')
|
|
161
|
-
)
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/**
|
|
165
|
-
* Safely extract a human-readable message from any error, object, or response.
|
|
166
|
-
* Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
|
|
167
|
-
*/
|
|
168
|
-
export function formatErrorMessage(e, providerKey = '') {
|
|
169
|
-
if (e === null || e === undefined) {
|
|
170
|
-
return providerKey ? `${providerKey}: unknown error` : 'unknown error'
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
let msg = ''
|
|
174
|
-
if (typeof e === 'string') {
|
|
175
|
-
msg = e
|
|
176
|
-
} else if (typeof e === 'object') {
|
|
177
|
-
if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
|
|
178
|
-
msg = e.message
|
|
179
|
-
} else if (typeof e.detail === 'string' && e.detail) {
|
|
180
|
-
msg = e.detail
|
|
181
|
-
} else if (e.error) {
|
|
182
|
-
if (typeof e.error === 'string') msg = e.error
|
|
183
|
-
else if (typeof e.error.message === 'string') msg = e.error.message
|
|
184
|
-
else if (typeof e.error.detail === 'string') msg = e.error.detail
|
|
185
|
-
else {
|
|
186
|
-
try { msg = JSON.stringify(e.error) } catch {}
|
|
187
|
-
}
|
|
188
|
-
} else if (typeof e.statusText === 'string' && e.statusText) {
|
|
189
|
-
msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
|
|
190
|
-
} else if (typeof e.cause === 'string') {
|
|
191
|
-
msg = e.cause
|
|
192
|
-
} else if (e.cause && typeof e.cause.message === 'string') {
|
|
193
|
-
msg = e.cause.message
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
if (!msg || msg === '[object Object]') {
|
|
197
|
-
try {
|
|
198
|
-
const json = JSON.stringify(e)
|
|
199
|
-
if (json && json !== '{}') {
|
|
200
|
-
msg = json.slice(0, 500)
|
|
201
|
-
}
|
|
202
|
-
} catch {}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
if (!msg || msg === '[object Object]') {
|
|
206
|
-
const keys = Object.getOwnPropertyNames(e)
|
|
207
|
-
if (keys.length) {
|
|
208
|
-
msg = `error [${keys.join(', ')}]`
|
|
209
|
-
} else {
|
|
210
|
-
msg = String(e)
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
} else {
|
|
214
|
-
msg = String(e)
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
msg = String(msg || 'unknown error').trim()
|
|
218
|
-
|
|
219
|
-
if (providerKey) {
|
|
220
|
-
const prefixRegex = new RegExp(`^${providerKey}\\s*:\\s*`, 'i')
|
|
221
|
-
while (prefixRegex.test(msg)) {
|
|
222
|
-
msg = msg.replace(prefixRegex, '').trim()
|
|
223
|
-
}
|
|
224
|
-
}
|
|
225
|
-
|
|
226
|
-
if (!msg || msg === '[object Object]') {
|
|
227
|
-
msg = 'unknown error object'
|
|
228
|
-
} else if (msg.includes('[object Object]')) {
|
|
229
|
-
msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
|
|
230
|
-
}
|
|
231
|
-
|
|
232
|
-
if (providerKey) {
|
|
233
|
-
return `${providerKey}: ${msg}`
|
|
234
|
-
}
|
|
235
4
|
|
|
236
|
-
|
|
237
|
-
|
|
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
|
+
// Re-export utilities so existing imports from './providers.js' continue to work
|
|
30
|
+
export {
|
|
31
|
+
resolveApiKeyCandidates,
|
|
32
|
+
PROVIDER_MAX_COUNTS,
|
|
33
|
+
clampProviderCount,
|
|
34
|
+
buildEndpointUrl,
|
|
35
|
+
createAbortError,
|
|
36
|
+
estimateSharpnessAndVariance,
|
|
37
|
+
quantizePalette,
|
|
38
|
+
formatA1111Parameters,
|
|
39
|
+
computeGenerationHash,
|
|
40
|
+
PROVIDER_KEYS,
|
|
41
|
+
fallbackOrder,
|
|
42
|
+
calculateBackoff,
|
|
43
|
+
isFatalClientError,
|
|
44
|
+
formatErrorMessage,
|
|
45
|
+
resolveSubscriptionSize,
|
|
46
|
+
SUBSCRIPTION_SIZES,
|
|
47
|
+
IMAGE_SIZES,
|
|
48
|
+
OUTPUT_FORMATS,
|
|
49
|
+
SIZE_PIXELS,
|
|
50
|
+
normalizeCount,
|
|
51
|
+
} from './provider-utils.js'
|
|
238
52
|
|
|
239
53
|
export async function tryGenerate(generators, order, seed, promptArg) {
|
|
240
54
|
const refusals = []
|
|
@@ -252,77 +66,14 @@ export async function tryGenerate(generators, order, seed, promptArg) {
|
|
|
252
66
|
throw new Error(`Image generation failed on all providers. ${refusals.join('; ')}`)
|
|
253
67
|
}
|
|
254
68
|
|
|
255
|
-
export function normalizeCount(count, max = 4) {
|
|
256
|
-
const n = Math.floor(Number(count))
|
|
257
|
-
if (!Number.isFinite(n)) return 1
|
|
258
|
-
return Math.max(1, Math.min(max, n))
|
|
259
|
-
}
|
|
260
69
|
|
|
261
|
-
//
|
|
262
|
-
//
|
|
70
|
+
// Subscription services use their own aspect ratio taxonomy.
|
|
71
|
+
// Normalize named sizes to standard square, landscape, and portrait equivalents.
|
|
263
72
|
/**
|
|
264
|
-
*
|
|
265
|
-
*
|
|
266
|
-
*
|
|
73
|
+
* Resolves dimensions for subscription services (codex / grok).
|
|
74
|
+
* Supports named sizes (landscape_16_9, etc.), aspect ratios (16:9, 3:2, 9:16),
|
|
75
|
+
* and pixel dimensions [width, height], preserving requested proportions.
|
|
267
76
|
*/
|
|
268
|
-
export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
|
|
269
|
-
if (size && SUBSCRIPTION_SIZES[size]) {
|
|
270
|
-
return SUBSCRIPTION_SIZES[size]
|
|
271
|
-
}
|
|
272
|
-
const ratio = String(aspectRatio || '').trim()
|
|
273
|
-
if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
|
|
274
|
-
return '1536x1024'
|
|
275
|
-
}
|
|
276
|
-
if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
|
|
277
|
-
return '1024x1536'
|
|
278
|
-
}
|
|
279
|
-
if (ratio === '1:1') {
|
|
280
|
-
return '1024x1024'
|
|
281
|
-
}
|
|
282
|
-
if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
|
|
283
|
-
const [w, h] = aspectPixels
|
|
284
|
-
if (w > h) return '1536x1024'
|
|
285
|
-
if (h > w) return '1024x1536'
|
|
286
|
-
return '1024x1024'
|
|
287
|
-
}
|
|
288
|
-
return '1024x1024'
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
export const SUBSCRIPTION_SIZES = {
|
|
292
|
-
square_hd: '1024x1024',
|
|
293
|
-
square: '1024x1024',
|
|
294
|
-
portrait_4_3: '1024x1536',
|
|
295
|
-
portrait_16_9: '1024x1536',
|
|
296
|
-
landscape_4_3: '1536x1024',
|
|
297
|
-
landscape_16_9: '1536x1024',
|
|
298
|
-
}
|
|
299
|
-
|
|
300
|
-
/** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
|
|
301
|
-
export const IMAGE_SIZES = [
|
|
302
|
-
'square_hd',
|
|
303
|
-
'square',
|
|
304
|
-
'portrait_4_3',
|
|
305
|
-
'portrait_16_9',
|
|
306
|
-
'landscape_4_3',
|
|
307
|
-
'landscape_16_9',
|
|
308
|
-
]
|
|
309
|
-
|
|
310
|
-
/** Output formats accepted by the model. */
|
|
311
|
-
export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
|
|
312
|
-
|
|
313
|
-
// Именованные размеры — единый язык инструмента: он не должен меняться от того,
|
|
314
|
-
// какой провайдер включён. FAL понимает их как есть, OpenAI-совместимые API
|
|
315
|
-
// хотят ШxВ — для них перевод.
|
|
316
|
-
export const SIZE_PIXELS = {
|
|
317
|
-
square_hd: '1024x1024',
|
|
318
|
-
square: '512x512',
|
|
319
|
-
portrait_4_3: '768x1024',
|
|
320
|
-
portrait_16_9: '576x1024',
|
|
321
|
-
landscape_4_3: '1024x768',
|
|
322
|
-
landscape_16_9: '1024x576',
|
|
323
|
-
}
|
|
324
|
-
|
|
325
|
-
/** Normalize a raw key into a FAL `Authorization: Key <key>` value. */
|
|
326
77
|
export function falAuthHeader(key) {
|
|
327
78
|
const trimmed = String(key ?? '').trim()
|
|
328
79
|
if (!trimmed) return ''
|
|
@@ -342,7 +93,7 @@ export function normalizeMediaType(contentType, fallbackFormat) {
|
|
|
342
93
|
return 'image/png'
|
|
343
94
|
}
|
|
344
95
|
|
|
345
|
-
/**
|
|
96
|
+
/** Generation metadata written to companion sidecar file. */
|
|
346
97
|
export function buildSidecar({
|
|
347
98
|
prompt, size, format, seed, provider, deliverAs,
|
|
348
99
|
width, height, mediaType, attachmentId, url, cost, createdAt = new Date().toISOString(),
|
|
@@ -406,13 +157,13 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
|
|
|
406
157
|
/**
|
|
407
158
|
* @param deps {{fetchImpl: Function, resolveKey: (ref: string) => Promise<string>, cfg: object}}
|
|
408
159
|
* @param job {{prompt: string, size: string, format: string, seed: number|undefined, signal: AbortSignal}}
|
|
409
|
-
* @returns
|
|
410
|
-
* {bytes, mediaType, width, height, seed, sourceUrl}.
|
|
411
|
-
*
|
|
160
|
+
* @returns providers by key; each yields
|
|
161
|
+
* {bytes, mediaType, width, height, seed, sourceUrl}. Zero width/height
|
|
162
|
+
* lets the attachments service measure image dimensions directly.
|
|
412
163
|
*/
|
|
413
164
|
|
|
414
|
-
/**
|
|
415
|
-
/**
|
|
165
|
+
/** Named size -> [width, height] for local APIs. */
|
|
166
|
+
/** Aspect ratio -> [width, height] (pixels, normalized to 1024 base). */
|
|
416
167
|
export const ASPECT_RATIOS = {
|
|
417
168
|
'1:1': [1024, 1024],
|
|
418
169
|
'16:9': [1344, 768],
|
|
@@ -423,7 +174,7 @@ export const ASPECT_RATIOS = {
|
|
|
423
174
|
'2:3': [768, 1152],
|
|
424
175
|
}
|
|
425
176
|
|
|
426
|
-
/**
|
|
177
|
+
/** Pixel dimensions: aspectPixels (if specified) or mapped from named size. */
|
|
427
178
|
/** Snap a pixel dimension to nearest multiple of 64. */
|
|
428
179
|
export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
|
|
429
180
|
const n = Math.round(Number(dim) / 64) * 64
|
|
@@ -447,7 +198,7 @@ export function sizeToPixels(size) {
|
|
|
447
198
|
return [w, h]
|
|
448
199
|
}
|
|
449
200
|
|
|
450
|
-
/**
|
|
201
|
+
/** Assemble multipart body for /images/edits (OpenAI-compatible edit). */
|
|
451
202
|
export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
452
203
|
const form = new FormData()
|
|
453
204
|
form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
|
|
@@ -458,7 +209,7 @@ export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
|
458
209
|
return form
|
|
459
210
|
}
|
|
460
211
|
|
|
461
|
-
/**
|
|
212
|
+
/** Compare two images: ratio of differing pixels (0..1). */
|
|
462
213
|
export async function pixelDiff(a, b) {
|
|
463
214
|
if (!a || !b) return { error: 'missing image' }
|
|
464
215
|
if (a.length !== b.length) return { error: 'size mismatch', diffRatio: 1 }
|
|
@@ -469,9 +220,9 @@ export async function pixelDiff(a, b) {
|
|
|
469
220
|
return { diffRatio: diff / a.length }
|
|
470
221
|
}
|
|
471
222
|
|
|
472
|
-
/**
|
|
223
|
+
/** Extract detailed ComfyUI node error messages from /history/{pid} response. */
|
|
473
224
|
|
|
474
|
-
/**
|
|
225
|
+
/** Safe attachment persistence with fallback when ctx.attachments is unavailable. */
|
|
475
226
|
export async function saveAttachmentSafe(ctx, { bytes, mediaType, name }) {
|
|
476
227
|
if (ctx && ctx.attachments && typeof ctx.attachments.saveImage === 'function') {
|
|
477
228
|
try {
|
|
@@ -576,20 +327,18 @@ export function makeProviders(deps, job) {
|
|
|
576
327
|
}
|
|
577
328
|
}
|
|
578
329
|
|
|
579
|
-
//
|
|
330
|
+
// OpenAI-compatible image API. Single HTTP request instead of queue polling.
|
|
580
331
|
async function custom(seedArg = seed, promptArg = prompt) {
|
|
581
332
|
const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
|
|
582
333
|
if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
|
|
583
334
|
if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
|
|
584
335
|
|
|
585
|
-
//
|
|
586
|
-
// локальный шлюз. Это законный случай, а не недонастройка.
|
|
336
|
+
// Empty key reference indicates unauthenticated gateway.
|
|
587
337
|
const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
|
|
588
338
|
const headers = { 'Content-Type': 'application/json' }
|
|
589
339
|
if (key) headers.Authorization = `Bearer ${key}`
|
|
590
340
|
|
|
591
|
-
// response_format
|
|
592
|
-
// всё равно приходит либо base64, либо ссылкой — принимаем оба.
|
|
341
|
+
// Omit response_format; modern models return either base64 or URL.
|
|
593
342
|
const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
|
|
594
343
|
const res = await fetchImpl(endpoint, {
|
|
595
344
|
method: 'POST',
|
|
@@ -653,20 +402,18 @@ export function makeProviders(deps, job) {
|
|
|
653
402
|
}
|
|
654
403
|
}
|
|
655
404
|
|
|
656
|
-
//
|
|
657
|
-
// только запрос и разбор ответа. Токен сюда не попадает вовсе — служба
|
|
658
|
-
// отдаёт готовую картинку, а не ключ доступа.
|
|
405
|
+
// Subscription generation: dsh-subscriptions handles session authentication.
|
|
659
406
|
function subscription(provider) {
|
|
660
407
|
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
661
408
|
if (source) {
|
|
662
|
-
return { ok: false, provider, reason: `${provider}:
|
|
409
|
+
return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
|
|
663
410
|
}
|
|
664
411
|
const images = deps.subscriptionImages
|
|
665
412
|
if (!images || typeof images.generate !== 'function') {
|
|
666
413
|
return {
|
|
667
414
|
ok: false,
|
|
668
415
|
provider,
|
|
669
|
-
reason: `${provider}:
|
|
416
|
+
reason: `${provider}: requires dsh-subscriptions plugin to manage session authentication`,
|
|
670
417
|
}
|
|
671
418
|
}
|
|
672
419
|
let produced
|
|
@@ -683,11 +430,11 @@ export function makeProviders(deps, job) {
|
|
|
683
430
|
}
|
|
684
431
|
const first = Array.isArray(produced) ? produced[0] : null
|
|
685
432
|
if (!first || !first.b64_json) {
|
|
686
|
-
return { ok: false, provider, reason: `${provider}:
|
|
433
|
+
return { ok: false, provider, reason: `${provider}: no image returned in response` }
|
|
687
434
|
}
|
|
688
435
|
return {
|
|
689
436
|
bytes: Buffer.from(first.b64_json, 'base64'),
|
|
690
|
-
//
|
|
437
|
+
// Subscription outputs default to PNG media type.
|
|
691
438
|
mediaType: 'image/png',
|
|
692
439
|
width: 0,
|
|
693
440
|
height: 0,
|
|
@@ -698,7 +445,7 @@ export function makeProviders(deps, job) {
|
|
|
698
445
|
}
|
|
699
446
|
}
|
|
700
447
|
|
|
701
|
-
//
|
|
448
|
+
// Local generation: ComfyUI (queue + poll) or Automatic1111 (txt2img).
|
|
702
449
|
async function local(seedArg = seed, promptArg = prompt) {
|
|
703
450
|
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
704
451
|
if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
|
|
@@ -747,7 +494,7 @@ export function makeProviders(deps, job) {
|
|
|
747
494
|
}
|
|
748
495
|
}
|
|
749
496
|
|
|
750
|
-
// ComfyUI: submit
|
|
497
|
+
// ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
|
|
751
498
|
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
752
499
|
const workflow = {
|
|
753
500
|
prompt: {
|
|
@@ -817,7 +564,7 @@ export function makeProviders(deps, job) {
|
|
|
817
564
|
}
|
|
818
565
|
}
|
|
819
566
|
|
|
820
|
-
// Seedream (ByteDance): OpenAI
|
|
567
|
+
// Seedream (ByteDance): OpenAI-compatible images API.
|
|
821
568
|
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
822
569
|
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
823
570
|
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
@@ -851,7 +598,7 @@ export function makeProviders(deps, job) {
|
|
|
851
598
|
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
852
599
|
}
|
|
853
600
|
|
|
854
|
-
// Gemini (Google):
|
|
601
|
+
// Gemini (Google): generateContent / imagen via GenAI API.
|
|
855
602
|
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
856
603
|
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
857
604
|
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
@@ -1148,7 +895,8 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
|
|
|
1148
895
|
return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
|
|
1149
896
|
}
|
|
1150
897
|
|
|
1151
|
-
/**
|
|
898
|
+
/** Direct image editing (inpainting / img2img) via selected provider. */
|
|
899
|
+
/** @internal — direct provider call path for integration tests */
|
|
1152
900
|
export async function editImageDirect(deps, job) {
|
|
1153
901
|
const providers = makeProviders(deps, job)
|
|
1154
902
|
const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
|
|
@@ -1157,7 +905,8 @@ export async function editImageDirect(deps, job) {
|
|
|
1157
905
|
return fn(job.seed, job.prompt)
|
|
1158
906
|
}
|
|
1159
907
|
|
|
1160
|
-
/**
|
|
908
|
+
/** Direct image variation via selected provider. */
|
|
909
|
+
/** @internal — direct provider call path for integration tests */
|
|
1161
910
|
export async function varyImageDirect(deps, job) {
|
|
1162
911
|
const providers = makeProviders(deps, {
|
|
1163
912
|
...job,
|
|
@@ -1270,6 +1019,58 @@ export async function testProviderConnection(deps, providerId) {
|
|
|
1270
1019
|
return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
|
|
1271
1020
|
}
|
|
1272
1021
|
|
|
1022
|
+
if (p === 'replicate') {
|
|
1023
|
+
const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
|
|
1024
|
+
if (!key) return { ok: false, latencyMs: 0, message: 'Replicate API token not configured' }
|
|
1025
|
+
const res = await fetchImpl('https://api.replicate.com/v1/models', {
|
|
1026
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
1027
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1028
|
+
})
|
|
1029
|
+
const latencyMs = Date.now() - start
|
|
1030
|
+
if (res.status === 401 || res.status === 403) {
|
|
1031
|
+
return { ok: false, latencyMs, message: `Replicate auth error (HTTP ${res.status})` }
|
|
1032
|
+
}
|
|
1033
|
+
if (res.ok) {
|
|
1034
|
+
return { ok: true, latencyMs, message: 'Replicate API reachable' }
|
|
1035
|
+
}
|
|
1036
|
+
return { ok: false, latencyMs, message: `Replicate returned HTTP ${res.status}` }
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
if (p === 'gemini') {
|
|
1040
|
+
const key = await resolveKey(cfg.geminiKeyEnv || 'GEMINI_API_KEY')
|
|
1041
|
+
if (!key) return { ok: false, latencyMs: 0, message: 'Google Gemini API key not configured' }
|
|
1042
|
+
const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
|
|
1043
|
+
const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}?key=${encodeURIComponent(key)}`, {
|
|
1044
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1045
|
+
})
|
|
1046
|
+
const latencyMs = Date.now() - start
|
|
1047
|
+
if (res.status === 400 || res.status === 401 || res.status === 403) {
|
|
1048
|
+
return { ok: false, latencyMs, message: `Gemini API auth error (HTTP ${res.status})` }
|
|
1049
|
+
}
|
|
1050
|
+
if (res.ok) {
|
|
1051
|
+
return { ok: true, latencyMs, message: 'Gemini Vision API operational' }
|
|
1052
|
+
}
|
|
1053
|
+
return { ok: false, latencyMs, message: `Gemini returned HTTP ${res.status}` }
|
|
1054
|
+
}
|
|
1055
|
+
|
|
1056
|
+
if (p === 'seedream') {
|
|
1057
|
+
const url = cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1'
|
|
1058
|
+
const key = await resolveKey(cfg.seedreamKeyEnv || 'SEEDREAM_API_KEY')
|
|
1059
|
+
const headers = key ? { Authorization: `Bearer ${key}` } : {}
|
|
1060
|
+
const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
|
|
1061
|
+
headers,
|
|
1062
|
+
signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
|
|
1063
|
+
}).catch(() => null)
|
|
1064
|
+
const latencyMs = Date.now() - start
|
|
1065
|
+
if (res && (res.ok || res.status === 404 || res.status === 405)) {
|
|
1066
|
+
return { ok: true, latencyMs, message: 'Seedream endpoint reachable' }
|
|
1067
|
+
}
|
|
1068
|
+
if (res && (res.status === 401 || res.status === 403)) {
|
|
1069
|
+
return { ok: false, latencyMs, message: `Seedream auth error (HTTP ${res.status})` }
|
|
1070
|
+
}
|
|
1071
|
+
return { ok: true, latencyMs, message: 'Seedream provider endpoint configured' }
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1273
1074
|
return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
|
|
1274
1075
|
} catch (err) {
|
|
1275
1076
|
return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
|
package/lib/register-tools.js
CHANGED
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
// Each tool lives in lib/tools/* and is registered inside a labeled ctx.effect.
|
|
3
3
|
|
|
4
4
|
import { registerGenerationTools } from './tools/generation.js'
|
|
5
|
-
import { registerProcessingTools } from './tools/processing.js'
|
|
5
|
+
import { registerProcessingTools } from './tools/processing-basic.js'
|
|
6
|
+
import { registerProcessingAdvancedTools } from './tools/processing-advanced.js'
|
|
6
7
|
import { registerEditingTools } from './tools/editing.js'
|
|
7
8
|
import { registerInspectTools } from './tools/inspect.js'
|
|
8
9
|
import { registerFrontendTools } from './tools/frontend.js'
|
|
@@ -16,6 +17,7 @@ import { registerFrontendTools } from './tools/frontend.js'
|
|
|
16
17
|
export function registerAllTools(ctx, deps) {
|
|
17
18
|
registerGenerationTools(ctx, deps)
|
|
18
19
|
registerProcessingTools(ctx, deps)
|
|
20
|
+
registerProcessingAdvancedTools(ctx, deps)
|
|
19
21
|
registerEditingTools(ctx, deps)
|
|
20
22
|
registerInspectTools(ctx, deps)
|
|
21
23
|
registerFrontendTools(ctx, deps)
|
package/lib/resolve-image.js
CHANGED
|
@@ -230,7 +230,7 @@ export async function analyzeImageWithVision(ctx, exec, source) {
|
|
|
230
230
|
}
|
|
231
231
|
}
|
|
232
232
|
/**
|
|
233
|
-
*
|
|
233
|
+
* Formats structured text report for safe return to LLM (#150 Dual-Output).
|
|
234
234
|
*/
|
|
235
235
|
export function buildDualOutputMarkdown({
|
|
236
236
|
action = 'generated',
|