@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/lib/providers.js CHANGED
@@ -1,240 +1,54 @@
1
1
  /**
2
- * Возвращает список кандидатов для имени ключа API с учётом известных алиасов (напр. FAL_API_KEY <-> FAL_KEY).
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
- return msg
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
- // размеры FAL. Перевод один к одному по смыслу: квадрат, вертикаль, горизонталь.
70
+ // Subscription services use their own aspect ratio taxonomy.
71
+ // Normalize named sizes to standard square, landscape, and portrait equivalents.
263
72
  /**
264
- * Разрешает размер для подписочных сервисов (codex / grok).
265
- * Понимает именованные размеры (landscape_16_9 и др.), пропорции (16:9, 3:2, 9:16 и др.)
266
- * и размеры в пикселях [width, height], исключая дефолтный квадрат при запросе соотношений сторон.
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
- /** Метаданные одной генерации для sidecar-файла рядом с картинкой. */
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}. Нулевые width/height
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
- /** Именованный размер -> [width, height] для локальных API. */
415
- /** Соотношения сторон -> [w,h] (в пикселях, по базовой стороне 1024). */
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
- /** Размер в пикселях: aspectPixels (если задан) или из именованного размера. */
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
- /** Собрать multipart-тело для /images/edits (OpenAI-совместимый edit). */
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
- /** Сравнить два изображения: доля различающихся пикселей (0..1). */
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
- /** Извлечение детальных сообщений об ошибках нод ComfyUI из ответа /history/{pid}. */
223
+ /** Extract detailed ComfyUI node error messages from /history/{pid} response. */
473
224
 
474
- /** Безопасное сохранение вложения с fallback'ом при отсутствии ctx.attachments */
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
- // Любой OpenAI-совместимый API картинок. Один запрос вместо очереди FAL.
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 не отправляем: новые модели OpenAI его отвергают, а ответ
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}: не умеет править изображения — используйте fal, custom или local` }
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
- // Подписки отдают png; формата в ответе нет, поэтому объявляем прямо.
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
- // Локальная генерация: ComfyUI (очередь + опрос) или Automatic1111 (txt2img).
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 через /prompt, опрос /history/{prompt_id} до готовности.
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-совместимый API картинок.
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): images.generate через GenAI API.
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
- /** Прямой вызов редактирования (inpainting / img2img) через выбранного провайдера. */
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' }
@@ -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)
@@ -230,7 +230,7 @@ export async function analyzeImageWithVision(ctx, exec, source) {
230
230
  }
231
231
  }
232
232
  /**
233
- * Форматирует структурированный текстовый отчёт для безопасного возврата в LLM (#150 Dual-Output).
233
+ * Formats structured text report for safe return to LLM (#150 Dual-Output).
234
234
  */
235
235
  export function buildDualOutputMarkdown({
236
236
  action = 'generated',