@goodandready/dsh-image-gen 0.10.24 → 0.10.26

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