@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.
@@ -0,0 +1,87 @@
1
+ // Pure helpers for generate_seamless_pattern (#178)
2
+
3
+ export function buildSeamlessPrompt(motif, style, density) {
4
+ const d = String(density || 'medium').toLowerCase()
5
+ const densityHint = d === 'low'
6
+ ? 'sparse motif spacing with generous empty background'
7
+ : d === 'high'
8
+ ? 'dense repeating motif coverage with minimal empty background'
9
+ : 'balanced motif spacing'
10
+ const styleBit = style ? ` Style: ${style}.` : ''
11
+ return `Seamless tileable repeating pattern of ${motif}.${styleBit} `
12
+ + 'Motifs must wrap continuously across left/right and top/bottom edges so the image tiles without visible seams. '
13
+ + `Flat even lighting, orthographic top-down view, ${densityHint}. `
14
+ + 'No vignette, no border frame, no watermark, no text.'
15
+ }
16
+
17
+ export function buildPatternCss({ className, sizePx, imagePath }) {
18
+ const name = className || 'dsh-pattern'
19
+ const size = sizePx || 512
20
+ const url = imagePath || `./${name}.png`
21
+ return `/* dsh-image-gen seamless pattern */
22
+ .${name} {
23
+ background-image: url('${url}');
24
+ background-repeat: repeat;
25
+ background-size: ${size}px ${size}px;
26
+ }
27
+ `
28
+ }
29
+
30
+ export function normalizeDensity(value) {
31
+ const v = String(value || 'medium').toLowerCase()
32
+ if (v === 'low' || v === 'high') return v
33
+ return 'medium'
34
+ }
35
+
36
+
37
+ /**
38
+ * Score edge wrap continuity on raw RGBA (or RGB) pixel rows.
39
+ * Returns 0..1 (1 = identical opposite edges). Used as a seam QA helper (#178).
40
+ * @param {Uint8Array} pixels
41
+ * @param {number} width
42
+ * @param {number} height
43
+ * @param {number} [channels=4]
44
+ */
45
+ export function scoreEdgeWrap(pixels, width, height, channels = 4) {
46
+ if (!pixels || width < 1 || height < 1) return 0
47
+ const stride = width * channels
48
+ let sum = 0
49
+ let n = 0
50
+ // left vs right columns
51
+ for (let y = 0; y < height; y++) {
52
+ const row = y * stride
53
+ for (let c = 0; c < channels; c++) {
54
+ const l = pixels[row + c]
55
+ const r = pixels[row + (width - 1) * channels + c]
56
+ sum += 1 - Math.abs(l - r) / 255
57
+ n++
58
+ }
59
+ }
60
+ // top vs bottom rows
61
+ for (let x = 0; x < width; x++) {
62
+ for (let c = 0; c < channels; c++) {
63
+ const top = pixels[x * channels + c]
64
+ const bot = pixels[(height - 1) * stride + x * channels + c]
65
+ sum += 1 - Math.abs(top - bot) / 255
66
+ n++
67
+ }
68
+ }
69
+ return n ? sum / n : 0
70
+ }
71
+
72
+ /** Tile RGBA image into cols x rows buffer (for 2x2 / 3x3 seam visual QA). */
73
+ export function tilePixels(pixels, width, height, channels, cols, rows) {
74
+ const outW = width * cols
75
+ const outH = height * rows
76
+ const out = new Uint8Array(outW * outH * channels)
77
+ for (let ry = 0; ry < rows; ry++) {
78
+ for (let cx = 0; cx < cols; cx++) {
79
+ for (let y = 0; y < height; y++) {
80
+ const srcOff = y * width * channels
81
+ const dstOff = ((ry * height + y) * outW + cx * width) * channels
82
+ out.set(pixels.subarray(srcOff, srcOff + width * channels), dstOff)
83
+ }
84
+ }
85
+ }
86
+ return { pixels: out, width: outW, height: outH }
87
+ }
@@ -185,10 +185,3 @@ export function polishPrompt(basePrompt, stylePreset = null, {
185
185
  }
186
186
  }
187
187
 
188
- export function listCuratedStyles() {
189
- return Object.values(CURATED_STYLES).map(s => ({
190
- id: s.id,
191
- label: s.label,
192
- guidanceScale: s.guidanceScale,
193
- }))
194
- }
@@ -0,0 +1,302 @@
1
+ // lib/provider-utils.js
2
+ // Shared provider utilities and size/format constants extracted from providers.js (#239)
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 normalizeCount(count, max = 4) {
25
+ const n = Math.floor(Number(count))
26
+ if (!Number.isFinite(n)) return 1
27
+ return Math.max(1, Math.min(max, n))
28
+ }
29
+
30
+ /** @internal — test helper for validating provider count limits */
31
+ export function clampProviderCount(provider, count) {
32
+ const max = PROVIDER_MAX_COUNTS[provider] || 4
33
+ return normalizeCount(count, max)
34
+ }
35
+
36
+ export function buildEndpointUrl(baseURL, pathSuffix) {
37
+ const base = String(baseURL || '').trim().replace(/\/+$/, '')
38
+ const suffix = String(pathSuffix || '').trim().replace(/^\/+/, '')
39
+ return `${base}/${suffix}`
40
+ }
41
+
42
+ /** @internal — test helper for abort signal simulation */
43
+ export function createAbortError(message = 'Image generation cancelled') {
44
+ const err = new Error(message)
45
+ err.name = 'AbortError'
46
+ return err
47
+ }
48
+
49
+
50
+ /** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
51
+ export function estimateSharpnessAndVariance(bytes) {
52
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
53
+ if (!buf || buf.length < 64) {
54
+ return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
55
+ }
56
+ let sum = 0
57
+ let diffSum = 0
58
+ const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
59
+ let count = 0
60
+ for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
61
+ const v = buf[i]
62
+ const nextV = buf[i + sampleStep]
63
+ sum += v
64
+ diffSum += Math.abs(v - nextV)
65
+ count++
66
+ }
67
+ const avgDiff = count > 0 ? diffSum / count : 0
68
+ if (avgDiff < 2) {
69
+ return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
70
+ }
71
+ const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
72
+ return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
73
+ }
74
+
75
+ /** Adaptive color palette quantizer for SVG vectorization. */
76
+ export function quantizePalette(colorMode = 'color', paletteSize = 16) {
77
+ if (colorMode === 'binary') return ['#000000', '#ffffff']
78
+ if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
79
+ // Standard vibrant UI vector palette
80
+ return [
81
+ '#000000', '#ffffff', '#e11d48', '#2563eb',
82
+ '#16a34a', '#ca8a04', '#9333ea', '#0891b2',
83
+ '#475569', '#64748b', '#94a3b8', '#cbd5e1'
84
+ ].slice(0, Math.max(2, paletteSize))
85
+ }
86
+
87
+
88
+ /** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
89
+ export function formatA1111Parameters(metadata = {}) {
90
+ if (typeof metadata === 'string') return metadata
91
+ const prompt = metadata.prompt || ''
92
+ const neg = metadata.negative_prompt || metadata.negativePrompt || ''
93
+ const seed = metadata.seed ?? ''
94
+ const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
95
+ const model = metadata.model || metadata.provider || ''
96
+ const steps = metadata.steps || 20
97
+ const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
98
+
99
+ let out = prompt
100
+ if (neg) out += `\nNegative prompt: ${neg}`
101
+ out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
102
+ return out
103
+ }
104
+
105
+
106
+ import { createHash } from 'node:crypto'
107
+
108
+ /** Deterministic sha256 hash for image generation caching. */
109
+ export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
110
+ const norm = [
111
+ String(provider || '').trim().toLowerCase(),
112
+ String(model || '').trim().toLowerCase(),
113
+ String(prompt || '').trim(),
114
+ String(seed ?? ''),
115
+ String(size || '').trim().toLowerCase(),
116
+ String(style || '').trim().toLowerCase(),
117
+ ].join('|')
118
+ return createHash('sha256').update(norm).digest('hex')
119
+ }
120
+
121
+ // Image generation provider implementations.
122
+ //
123
+ // A provider receives a generation job and returns finished image bytes. Everything
124
+ // that follows (attachments, workspace files, links, card rendering) is shared
125
+ // across all providers and lives in index.js.
126
+ //
127
+ // Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
128
+
129
+ export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
130
+
131
+ /** Clamp count from tool arguments to range 1..4. */
132
+ /** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
133
+ export function fallbackOrder(primary) {
134
+ return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
135
+ }
136
+
137
+ /**
138
+ * Iterates through candidate generators, returning the first successful result.
139
+ * Throws an aggregate error if all candidates fail.
140
+ * @param generators - array of (key, seed) => Promise<generated> functions.
141
+ * @param order - candidate key evaluation order.
142
+ */
143
+
144
+ /** Calculate exponential backoff delay with jitter. */
145
+ export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
146
+ const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
147
+ const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
148
+ return Math.max(100, Math.floor(exp + jitter))
149
+ }
150
+
151
+ /** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
152
+ export function isFatalClientError(error) {
153
+ const msg = (error?.message || String(error || '')).toLowerCase()
154
+ return (
155
+ msg.includes('content policy') ||
156
+ msg.includes('safety system') ||
157
+ msg.includes('nsfw') ||
158
+ msg.includes('moderation') ||
159
+ msg.includes('bad request (http 400') ||
160
+ msg.includes('invalid_prompt') ||
161
+ msg.includes('prompt is required') ||
162
+ msg.includes('unsupported image format') ||
163
+ msg.includes('402 payment required') ||
164
+ msg.includes('insufficient_quota') ||
165
+ msg.includes('insufficient credits') ||
166
+ msg.includes('exceeded your current quota') ||
167
+ msg.includes('balance is insufficient')
168
+ )
169
+ }
170
+
171
+ /**
172
+ * Safely extract a human-readable message from any error, object, or response.
173
+ * Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
174
+ */
175
+ export function formatErrorMessage(e, providerKey = '') {
176
+ if (e === null || e === undefined) {
177
+ return providerKey ? `${providerKey}: unknown error` : 'unknown error'
178
+ }
179
+
180
+ let msg = ''
181
+ if (typeof e === 'string') {
182
+ msg = e
183
+ } else if (typeof e === 'object') {
184
+ if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
185
+ msg = e.message
186
+ } else if (typeof e.detail === 'string' && e.detail) {
187
+ msg = e.detail
188
+ } else if (e.error) {
189
+ if (typeof e.error === 'string') msg = e.error
190
+ else if (typeof e.error.message === 'string') msg = e.error.message
191
+ else if (typeof e.error.detail === 'string') msg = e.error.detail
192
+ else {
193
+ try { msg = JSON.stringify(e.error) } catch { msg = String(e.error) }
194
+ }
195
+ } else if (typeof e.statusText === 'string' && e.statusText) {
196
+ msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
197
+ } else if (typeof e.cause === 'string') {
198
+ msg = e.cause
199
+ } else if (e.cause && typeof e.cause.message === 'string') {
200
+ msg = e.cause.message
201
+ }
202
+
203
+ if (!msg || msg === '[object Object]') {
204
+ try {
205
+ const json = JSON.stringify(e)
206
+ if (json && json !== '{}') {
207
+ msg = json.slice(0, 500)
208
+ }
209
+ } catch { /* response body not JSON; use raw text */ }
210
+ }
211
+
212
+ if (!msg || msg === '[object Object]') {
213
+ const keys = Object.getOwnPropertyNames(e)
214
+ if (keys.length) {
215
+ msg = `error [${keys.join(', ')}]`
216
+ } else {
217
+ msg = String(e)
218
+ }
219
+ }
220
+ } else {
221
+ msg = String(e)
222
+ }
223
+
224
+ msg = String(msg || 'unknown error').trim()
225
+
226
+ if (providerKey) {
227
+ const prefixRegex = new RegExp(`^${providerKey}\\s*:\\s*`, 'i')
228
+ while (prefixRegex.test(msg)) {
229
+ msg = msg.replace(prefixRegex, '').trim()
230
+ }
231
+ }
232
+
233
+ if (!msg || msg === '[object Object]') {
234
+ msg = 'unknown error object'
235
+ } else if (msg.includes('[object Object]')) {
236
+ msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
237
+ }
238
+
239
+ if (providerKey) {
240
+ return `${providerKey}: ${msg}`
241
+ }
242
+
243
+ return msg
244
+ }
245
+
246
+ export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
247
+ if (size && SUBSCRIPTION_SIZES[size]) {
248
+ return SUBSCRIPTION_SIZES[size]
249
+ }
250
+ const ratio = String(aspectRatio || '').trim()
251
+ if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
252
+ return '1536x1024'
253
+ }
254
+ if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
255
+ return '1024x1536'
256
+ }
257
+ if (ratio === '1:1') {
258
+ return '1024x1024'
259
+ }
260
+ if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
261
+ const [w, h] = aspectPixels
262
+ if (w > h) return '1536x1024'
263
+ if (h > w) return '1024x1536'
264
+ return '1024x1024'
265
+ }
266
+ return '1024x1024'
267
+ }
268
+
269
+ export const SUBSCRIPTION_SIZES = {
270
+ square_hd: '1024x1024',
271
+ square: '1024x1024',
272
+ portrait_4_3: '1024x1536',
273
+ portrait_16_9: '1024x1536',
274
+ landscape_4_3: '1536x1024',
275
+ landscape_16_9: '1536x1024',
276
+ }
277
+
278
+ /** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
279
+ export const IMAGE_SIZES = [
280
+ 'square_hd',
281
+ 'square',
282
+ 'portrait_4_3',
283
+ 'portrait_16_9',
284
+ 'landscape_4_3',
285
+ 'landscape_16_9',
286
+ ]
287
+
288
+ /** Output formats accepted by the model. */
289
+ export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
290
+
291
+ // Named sizes are a unified abstraction across all providers.
292
+ // FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
293
+ export const SIZE_PIXELS = {
294
+ square_hd: '1024x1024',
295
+ square: '512x512',
296
+ portrait_4_3: '768x1024',
297
+ portrait_16_9: '576x1024',
298
+ landscape_4_3: '1024x768',
299
+ landscape_16_9: '1024x576',
300
+ }
301
+
302
+ /** Normalize a raw key into a FAL `Authorization: Key <key>` value. */
@@ -0,0 +1,93 @@
1
+ import {
2
+ normalizeMediaType,
3
+ buildEditForm,
4
+ SIZE_PIXELS,
5
+ formatErrorMessage,
6
+ buildEndpointUrl,
7
+ } from '../shared-helpers.js'
8
+
9
+ /**
10
+ * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
11
+ * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
12
+ * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
13
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
14
+ */
15
+ export function createCustomGenerator(deps, job) {
16
+ const { fetchImpl, resolveKey, cfg } = deps
17
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
18
+
19
+ async function custom(seedArg = seed, promptArg = prompt) {
20
+ const base = String(cfg.customBaseURL || '').replace(/\/+$/, '')
21
+ if (!base) throw new Error('Custom image provider: base URL is not configured (Settings → Image generation)')
22
+ if (!cfg.customModel) throw new Error('Custom image provider: model is not configured')
23
+
24
+ // Empty key reference indicates unauthenticated gateway.
25
+ const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
26
+ const headers = { 'Content-Type': 'application/json' }
27
+ if (key) headers.Authorization = `Bearer ${key}`
28
+
29
+ // Omit response_format; modern models return either base64 or URL.
30
+ const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
31
+ const res = await fetchImpl(endpoint, {
32
+ method: 'POST',
33
+ headers,
34
+ body: source
35
+ ? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
36
+ : JSON.stringify({
37
+ model: cfg.customModel,
38
+ prompt: promptArg,
39
+ n: 1,
40
+ size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
41
+ ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
42
+ ...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
43
+ ...(quality && quality !== 'auto' ? { quality } : {}),
44
+ }),
45
+ signal,
46
+ })
47
+ const data = await res.json().catch(() => ({}))
48
+ if (!res.ok) {
49
+ if (res.status === 503) {
50
+ const msg = (data?.error?.message || JSON.stringify(data)).toLowerCase()
51
+ if (msg.includes('no available channel') || msg.includes('model_not_found')) {
52
+ throw new Error(`Custom image API has no provisioned channel for "${cfg.customModel}" (HTTP 503); check gateway channel and routing configuration`)
53
+ }
54
+ }
55
+ const detail = formatErrorMessage(data?.error || data)
56
+ throw new Error(`Image API failed (HTTP ${res.status}): ${detail}`)
57
+ }
58
+ const item = data?.data?.[0]
59
+ if (!item) {
60
+ throw new Error(`Image API returned no images: ${JSON.stringify(data).slice(0, 600)}`)
61
+ }
62
+
63
+ if (item.b64_json) {
64
+ return {
65
+ bytes: Buffer.from(item.b64_json, 'base64'),
66
+ mediaType: normalizeMediaType(data.output_format || '', format),
67
+ width: 0,
68
+ height: 0,
69
+ seed: seedArg ?? 0,
70
+ sourceUrl: '',
71
+ }
72
+ }
73
+ if (!item.url) {
74
+ throw new Error(`Image API returned neither b64_json nor url: ${JSON.stringify(item).slice(0, 300)}`)
75
+ }
76
+ const download = await fetchImpl(item.url, { signal })
77
+ if (!download.ok) {
78
+ throw new Error(`Failed to download generated image (HTTP ${download.status})`)
79
+ }
80
+ const contentType = download.headers && typeof download.headers.get === 'function'
81
+ ? download.headers.get('content-type')
82
+ : ''
83
+ return {
84
+ bytes: Buffer.from(await download.arrayBuffer()),
85
+ mediaType: normalizeMediaType(contentType, format),
86
+ width: 0,
87
+ height: 0,
88
+ seed: seedArg ?? 0,
89
+ sourceUrl: item.url,
90
+ }
91
+ }
92
+ return custom
93
+ }
@@ -0,0 +1,67 @@
1
+ import {
2
+ falAuthHeader,
3
+ normalizeMediaType,
4
+ submitJob,
5
+ pollStatus,
6
+ } from '../shared-helpers.js'
7
+
8
+ /**
9
+ * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
10
+ * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
11
+ * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
12
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
13
+ */
14
+ export function createFalGenerator(deps, job) {
15
+ const { fetchImpl, resolveKey, cfg } = deps
16
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
17
+
18
+ async function fal(seedArg = seed, promptArg = prompt) {
19
+ const key = await resolveKey(cfg.apiKeyEnv)
20
+ const isEdit = Boolean(source && source.bytes)
21
+ const targetModel = isEdit
22
+ ? (mask && mask.bytes ? 'fal-ai/flux-pro/v1/inpaint' : 'fal-ai/flux/dev/image-to-image')
23
+ : cfg.model
24
+ const body = { prompt: promptArg, image_size: size, num_images: 1 }
25
+ if (seedArg !== undefined) body.seed = seedArg
26
+ if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
27
+ if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
28
+ if (format !== 'png') body.output_format = format
29
+ if (isEdit) {
30
+ body.image_url = `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`
31
+ if (mask && mask.bytes) {
32
+ body.mask_url = `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}`
33
+ }
34
+ if (strength !== undefined) {
35
+ body.strength = strength
36
+ }
37
+ }
38
+
39
+ const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
40
+ const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
41
+ const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
42
+
43
+ const resultRes = await fetchImpl(statusBody.response_url, {
44
+ headers: { Authorization: falAuthHeader(key) },
45
+ signal,
46
+ })
47
+ const result = await resultRes.json().catch(() => ({}))
48
+ const image = result.images && result.images[0]
49
+ if (!image || !image.url) {
50
+ throw new Error(`FAL returned no images: ${JSON.stringify(result).slice(0, 600)}`)
51
+ }
52
+ const download = await fetchImpl(image.url, { signal })
53
+ if (!download.ok) {
54
+ throw new Error(`Failed to download generated image (HTTP ${download.status})`)
55
+ }
56
+ return {
57
+ bytes: Buffer.from(await download.arrayBuffer()),
58
+ mediaType: normalizeMediaType(image.content_type, format),
59
+ width: image.width ?? 0,
60
+ height: image.height ?? 0,
61
+ seed: result.seed ?? seedArg ?? 0,
62
+ sourceUrl: image.url,
63
+ cost: result.cost,
64
+ }
65
+ }
66
+ return fal
67
+ }
@@ -0,0 +1,37 @@
1
+ import {
2
+ normalizeMediaType,
3
+ } from '../shared-helpers.js'
4
+
5
+ /**
6
+ * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
7
+ * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
8
+ * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
9
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
10
+ */
11
+ export function createGeminiGenerator(deps, job) {
12
+ const { fetchImpl, resolveKey, cfg } = deps
13
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
14
+
15
+ async function gemini(seedArg = seed, promptArg = prompt) {
16
+ const key = await resolveKey(cfg.geminiKeyEnv)
17
+ const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
18
+ const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
19
+ method: 'POST',
20
+ headers: { 'Content-Type': 'application/json' },
21
+ body: JSON.stringify({
22
+ contents: [{ parts: [{ text: promptArg }] }],
23
+ generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
24
+ }),
25
+ signal,
26
+ })
27
+ const data = await res.json().catch(() => ({}))
28
+ if (!res.ok) {
29
+ const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
30
+ throw new Error(`Gemini failed (HTTP ${res.status}): ${detail}`)
31
+ }
32
+ const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
33
+ if (!part) throw new Error('Gemini returned no image')
34
+ return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
35
+ }
36
+ return gemini
37
+ }