@goodandready/dsh-image-gen 0.10.23 → 0.10.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +15 -0
- package/README.ru.md +15 -0
- package/README.zh.md +15 -0
- package/lib/client.js +161 -33
- package/lib/cost-meter.js +4 -2
- package/lib/generation-cache.js +6 -3
- package/lib/index.js +3 -1
- package/lib/loop-guard.js +2 -0
- package/lib/pattern-helpers.js +87 -0
- package/lib/prompt-polisher.js +0 -7
- package/lib/provider-utils.js +302 -0
- package/lib/providers/backends/custom.js +93 -0
- package/lib/providers/backends/fal.js +67 -0
- package/lib/providers/backends/gemini.js +37 -0
- package/lib/providers/backends/local.js +136 -0
- package/lib/providers/backends/replicate.js +83 -0
- package/lib/providers/backends/seedream.js +49 -0
- package/lib/providers/backends/subscription.js +58 -0
- package/lib/providers/shared-helpers.js +164 -0
- package/lib/providers.js +95 -847
- package/lib/register-tools.js +11 -1
- package/lib/responsive-helpers.js +94 -0
- package/lib/spritesheet-helpers.js +65 -0
- package/lib/tools/editing.js +2 -0
- package/lib/tools/pattern.js +125 -0
- package/lib/tools/{processing.js → processing-advanced.js} +11 -270
- package/lib/tools/processing-basic.js +327 -0
- package/lib/tools/responsive.js +267 -0
- package/lib/tools/sketch.js +167 -0
- package/lib/tools/spritesheet.js +151 -0
- package/package.json +3 -2
|
@@ -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
|
+
}
|
package/lib/prompt-polisher.js
CHANGED
|
@@ -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
|
+
}
|