@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/README.md +14 -0
- package/README.ru.md +14 -0
- package/README.zh.md +14 -0
- package/lib/attachment-helper.js +82 -0
- package/lib/client.js +149 -21
- package/lib/history.js +93 -0
- package/lib/index.js +25 -167
- package/lib/pattern-helpers.js +87 -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 +46 -553
- package/lib/register-tools.js +8 -0
- package/lib/responsive-helpers.js +94 -0
- package/lib/spritesheet-helpers.js +65 -0
- package/lib/tools/editing.js +2 -0
- package/lib/tools/generation-pack.js +74 -0
- package/lib/tools/generation.js +2 -66
- package/lib/tools/pattern.js +125 -0
- package/lib/tools/processing-advanced.js +7 -1
- package/lib/tools/responsive.js +267 -0
- package/lib/tools/sketch.js +167 -0
- package/lib/tools/spritesheet.js +151 -0
- package/package.json +7 -4
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMediaType,
|
|
3
|
+
estimateCost,
|
|
4
|
+
pxSize,
|
|
5
|
+
calculateBackoff,
|
|
6
|
+
ASPECT_RATIOS,
|
|
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 createReplicateGenerator(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 replicate(seedArg = seed, promptArg = prompt) {
|
|
20
|
+
const key = await resolveKey(cfg.replicateKeyEnv || 'REPLICATE_API_TOKEN')
|
|
21
|
+
if (!key) throw new Error('Replicate API token is not configured (Settings → Image generation → replicateKeyEnv)')
|
|
22
|
+
const model = cfg.replicateModel || 'black-forest-labs/flux-schnell'
|
|
23
|
+
const [width, height] = pxSize(aspectPixels, size)
|
|
24
|
+
const body = {
|
|
25
|
+
input: {
|
|
26
|
+
prompt: promptArg,
|
|
27
|
+
aspect_ratio: aspectPixels ? `${aspectPixels[0]}:${aspectPixels[1]}` : (ASPECT_RATIOS[size] || '1:1'),
|
|
28
|
+
seed: seedArg,
|
|
29
|
+
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
|
|
30
|
+
...(source && source.bytes ? {
|
|
31
|
+
image: `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`,
|
|
32
|
+
...(mask && mask.bytes ? { mask: `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}` } : {}),
|
|
33
|
+
...(strength !== undefined ? { prompt_strength: strength } : {}),
|
|
34
|
+
} : {}),
|
|
35
|
+
},
|
|
36
|
+
}
|
|
37
|
+
const res = await fetchImpl(`https://api.replicate.com/v1/models/${model}/predictions`, {
|
|
38
|
+
method: 'POST',
|
|
39
|
+
headers: {
|
|
40
|
+
Authorization: `Bearer ${key}`,
|
|
41
|
+
'Content-Type': 'application/json',
|
|
42
|
+
},
|
|
43
|
+
body: JSON.stringify(body),
|
|
44
|
+
signal,
|
|
45
|
+
})
|
|
46
|
+
const data = await res.json().catch(() => ({}))
|
|
47
|
+
if (!res.ok) {
|
|
48
|
+
throw new Error(`Replicate submit failed (HTTP ${res.status}): ${data.detail || JSON.stringify(data).slice(0, 300)}`)
|
|
49
|
+
}
|
|
50
|
+
let pred = data
|
|
51
|
+
if (pred.status !== 'succeeded') {
|
|
52
|
+
const pollUrl = pred.urls?.get || `https://api.replicate.com/v1/predictions/${pred.id}`
|
|
53
|
+
const deadline = Date.now() + (cfg.timeoutMs || 180000)
|
|
54
|
+
let attempt = 0
|
|
55
|
+
while (pred.status !== 'succeeded') {
|
|
56
|
+
if (signal?.aborted) throw new Error('Replicate generation cancelled')
|
|
57
|
+
if (Date.now() > deadline) throw new Error('Replicate generation timed out')
|
|
58
|
+
if (pred.status === 'failed' || pred.status === 'canceled') {
|
|
59
|
+
throw new Error(`Replicate failed: ${pred.error || 'unknown error'}`)
|
|
60
|
+
}
|
|
61
|
+
const repDelay = calculateBackoff(attempt++, cfg.pollIntervalMs || 1000, 5000)
|
|
62
|
+
await new Promise((r) => setTimeout(r, repDelay))
|
|
63
|
+
const pRes = await fetchImpl(pollUrl, { headers: { Authorization: `Bearer ${key}` }, signal })
|
|
64
|
+
pred = await pRes.json().catch(() => ({}))
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
const output = Array.isArray(pred.output) ? pred.output[0] : pred.output
|
|
68
|
+
if (!output) throw new Error('Replicate returned no image output')
|
|
69
|
+
const dl = await fetchImpl(output, { signal })
|
|
70
|
+
if (!dl.ok) throw new Error(`Replicate download failed (HTTP ${dl.status})`)
|
|
71
|
+
const bytes = Buffer.from(await dl.arrayBuffer())
|
|
72
|
+
return {
|
|
73
|
+
bytes,
|
|
74
|
+
mediaType: normalizeMediaType(dl.headers?.get?.('content-type') || 'image/png', format),
|
|
75
|
+
width,
|
|
76
|
+
height,
|
|
77
|
+
seed: seedArg ?? 0,
|
|
78
|
+
cost: estimateCost('replicate', model),
|
|
79
|
+
sourceUrl: output,
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
return replicate
|
|
83
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMediaType,
|
|
3
|
+
SIZE_PIXELS,
|
|
4
|
+
} from '../shared-helpers.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
8
|
+
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
9
|
+
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
10
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
11
|
+
*/
|
|
12
|
+
export function createSeedreamGenerator(deps, job) {
|
|
13
|
+
const { fetchImpl, resolveKey, cfg } = deps
|
|
14
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
15
|
+
|
|
16
|
+
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
17
|
+
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
18
|
+
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
19
|
+
const res = await fetchImpl(`${base}/images/generations`, {
|
|
20
|
+
method: 'POST',
|
|
21
|
+
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
|
22
|
+
body: JSON.stringify({
|
|
23
|
+
model: cfg.seedreamModel || 'seedream-4.0',
|
|
24
|
+
prompt: promptArg,
|
|
25
|
+
n: 1,
|
|
26
|
+
size: (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
|
|
27
|
+
...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
|
|
28
|
+
...(quality !== undefined ? { quality } : {}),
|
|
29
|
+
...(style !== undefined ? { style } : {}),
|
|
30
|
+
}),
|
|
31
|
+
signal,
|
|
32
|
+
})
|
|
33
|
+
const data = await res.json().catch(() => ({}))
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const detail = data?.error?.message || JSON.stringify(data).slice(0, 600)
|
|
36
|
+
throw new Error(`Seedream failed (HTTP ${res.status}): ${detail}`)
|
|
37
|
+
}
|
|
38
|
+
const item = data?.data?.[0]
|
|
39
|
+
if (!item) throw new Error('Seedream returned no images')
|
|
40
|
+
if (item.b64_json) {
|
|
41
|
+
return { bytes: Buffer.from(item.b64_json, 'base64'), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
|
|
42
|
+
}
|
|
43
|
+
if (!item.url) throw new Error('Seedream returned neither b64_json nor url')
|
|
44
|
+
const dl = await fetchImpl(item.url, { signal })
|
|
45
|
+
if (!dl.ok) throw new Error(`Seedream download failed (HTTP ${dl.status})`)
|
|
46
|
+
return { bytes: Buffer.from(await dl.arrayBuffer()), mediaType: normalizeMediaType('image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: item.url }
|
|
47
|
+
}
|
|
48
|
+
return seedream
|
|
49
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import {
|
|
2
|
+
formatErrorMessage,
|
|
3
|
+
resolveSubscriptionSize,
|
|
4
|
+
} from '../shared-helpers.js'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
|
|
8
|
+
* @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
|
|
9
|
+
* negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
|
|
10
|
+
* quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
|
|
11
|
+
*/
|
|
12
|
+
export function createSubscriptionGenerator(deps, job) {
|
|
13
|
+
const { fetchImpl, resolveKey, cfg } = deps
|
|
14
|
+
const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
|
|
15
|
+
|
|
16
|
+
function subscription(provider) {
|
|
17
|
+
return async function generate(seedArg = seed, promptArg = prompt) {
|
|
18
|
+
if (source) {
|
|
19
|
+
return { ok: false, provider, reason: `${provider}: does not support image editing — use fal, custom, or local` }
|
|
20
|
+
}
|
|
21
|
+
const images = deps.subscriptionImages
|
|
22
|
+
if (!images || typeof images.generate !== 'function') {
|
|
23
|
+
return {
|
|
24
|
+
ok: false,
|
|
25
|
+
provider,
|
|
26
|
+
reason: `${provider}: requires dsh-subscriptions plugin to manage session authentication`,
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
let produced
|
|
30
|
+
try {
|
|
31
|
+
produced = await images.generate({
|
|
32
|
+
provider,
|
|
33
|
+
prompt: promptArg,
|
|
34
|
+
size: resolveSubscriptionSize(size, aspectPixels, aspectRatio),
|
|
35
|
+
quality: cfg.subscriptionQuality || undefined,
|
|
36
|
+
signal,
|
|
37
|
+
})
|
|
38
|
+
} catch (e) {
|
|
39
|
+
return { ok: false, provider, reason: formatErrorMessage(e, provider) }
|
|
40
|
+
}
|
|
41
|
+
const first = Array.isArray(produced) ? produced[0] : null
|
|
42
|
+
if (!first || !first.b64_json) {
|
|
43
|
+
return { ok: false, provider, reason: `${provider}: no image returned in response` }
|
|
44
|
+
}
|
|
45
|
+
return {
|
|
46
|
+
bytes: Buffer.from(first.b64_json, 'base64'),
|
|
47
|
+
// Subscription outputs default to PNG media type.
|
|
48
|
+
mediaType: 'image/png',
|
|
49
|
+
width: 0,
|
|
50
|
+
height: 0,
|
|
51
|
+
seed: seedArg ?? 0,
|
|
52
|
+
sourceUrl: '',
|
|
53
|
+
revisedPrompt: first.revisedPrompt || '',
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return subscription
|
|
58
|
+
}
|
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared provider HTTP/auth helpers used by per-backend factories.
|
|
3
|
+
* Extracted from providers.js for #218 modularization.
|
|
4
|
+
*/
|
|
5
|
+
import {
|
|
6
|
+
SIZE_PIXELS,
|
|
7
|
+
formatErrorMessage,
|
|
8
|
+
buildEndpointUrl,
|
|
9
|
+
resolveSubscriptionSize,
|
|
10
|
+
calculateBackoff,
|
|
11
|
+
} from '../provider-utils.js'
|
|
12
|
+
|
|
13
|
+
export {
|
|
14
|
+
SIZE_PIXELS,
|
|
15
|
+
formatErrorMessage,
|
|
16
|
+
buildEndpointUrl,
|
|
17
|
+
resolveSubscriptionSize,
|
|
18
|
+
calculateBackoff,
|
|
19
|
+
}
|
|
20
|
+
export function falAuthHeader(key) {
|
|
21
|
+
const trimmed = String(key ?? '').trim()
|
|
22
|
+
if (!trimmed) return ''
|
|
23
|
+
return trimmed.startsWith('Key ') || trimmed.startsWith('key ')
|
|
24
|
+
? trimmed
|
|
25
|
+
: `Key ${trimmed}`
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function normalizeMediaType(contentType, fallbackFormat) {
|
|
29
|
+
const raw = String(contentType ?? '').toLowerCase()
|
|
30
|
+
if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
|
|
31
|
+
if (raw.includes('webp')) return 'image/webp'
|
|
32
|
+
if (raw.includes('png')) return 'image/png'
|
|
33
|
+
if (fallbackFormat === 'jpeg') return 'image/jpeg'
|
|
34
|
+
if (fallbackFormat === 'webp') return 'image/webp'
|
|
35
|
+
return 'image/png'
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
|
|
39
|
+
const res = await fetchImpl(`${baseURL}/${model}`, {
|
|
40
|
+
method: 'POST',
|
|
41
|
+
headers: {
|
|
42
|
+
Authorization: falAuthHeader(key),
|
|
43
|
+
'Content-Type': 'application/json',
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify(body),
|
|
46
|
+
signal,
|
|
47
|
+
})
|
|
48
|
+
const data = await res.json().catch(() => ({}))
|
|
49
|
+
if (!res.ok || !data.request_id) {
|
|
50
|
+
const detail = typeof data.detail === 'string' ? data.detail : JSON.stringify(data).slice(0, 600)
|
|
51
|
+
throw new Error(`FAL submit failed (HTTP ${res.status}): ${detail}`)
|
|
52
|
+
}
|
|
53
|
+
return data
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
|
|
57
|
+
const deadline = Date.now() + timeoutMs
|
|
58
|
+
let attempt = 0
|
|
59
|
+
for (;;) {
|
|
60
|
+
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
61
|
+
if (Date.now() > deadline) throw new Error(`FAL generation timed out after ${timeoutMs} ms`)
|
|
62
|
+
if (attempt > 0) {
|
|
63
|
+
const delay = calculateBackoff(attempt - 1, pollIntervalMs, 5000)
|
|
64
|
+
await new Promise((resolve) => {
|
|
65
|
+
const timer = setTimeout(resolve, delay)
|
|
66
|
+
if (signal) {
|
|
67
|
+
signal.addEventListener('abort', () => {
|
|
68
|
+
clearTimeout(timer)
|
|
69
|
+
resolve()
|
|
70
|
+
}, { once: true })
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
}
|
|
74
|
+
attempt++
|
|
75
|
+
if (signal?.aborted) throw new Error('FAL generation cancelled')
|
|
76
|
+
const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
|
|
77
|
+
const data = await res.json().catch(() => ({}))
|
|
78
|
+
const status = data.status
|
|
79
|
+
if (status === 'COMPLETED') return data
|
|
80
|
+
if (status === 'ERROR' || data.error || data.detail) {
|
|
81
|
+
throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
|
|
82
|
+
}
|
|
83
|
+
if (status !== 'IN_QUEUE' && status !== 'IN_PROGRESS') {
|
|
84
|
+
throw new Error(`Unexpected FAL status "${status}": ${JSON.stringify(data).slice(0, 300)}`)
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function buildEditForm({ source, mask, prompt, size, strength }) {
|
|
90
|
+
const form = new FormData()
|
|
91
|
+
form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
|
|
92
|
+
if (mask) form.append('mask', new Blob([mask.bytes], { type: mask.mediaType || 'image/png' }), 'mask.png')
|
|
93
|
+
form.append('prompt', prompt)
|
|
94
|
+
if (size) form.append('size', size)
|
|
95
|
+
if (strength !== undefined) form.append('strength', String(strength))
|
|
96
|
+
return form
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function estimateCost(provider, model, { count = 1 } = {}) {
|
|
100
|
+
const p = String(provider).toLowerCase()
|
|
101
|
+
if (p === 'fal') {
|
|
102
|
+
if (String(model).includes('schnell') || String(model).includes('klein')) return 0.003 * count
|
|
103
|
+
if (String(model).includes('dev')) return 0.025 * count
|
|
104
|
+
if (String(model).includes('clarity') || String(model).includes('upscale')) return 0.01 * count
|
|
105
|
+
return 0.005 * count
|
|
106
|
+
}
|
|
107
|
+
if (p === 'replicate') return 0.003 * count
|
|
108
|
+
if (p === 'seedream') return 0.004 * count
|
|
109
|
+
if (p === 'gemini') return 0.03 * count
|
|
110
|
+
if (p === 'codex' || p === 'grok' || p === 'local') return 0.0
|
|
111
|
+
return 0.01 * count
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
export function snapToMultipleOf64(dim, minVal = 256, maxVal = 2048) {
|
|
116
|
+
const n = Math.round(Number(dim) / 64) * 64
|
|
117
|
+
return Math.max(minVal, Math.min(maxVal, n))
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function snapDimensions(width, height) {
|
|
121
|
+
return [snapToMultipleOf64(width), snapToMultipleOf64(height)]
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export function extractComfyNodeErrors(entry) {
|
|
125
|
+
if (!entry || typeof entry !== 'object') return ''
|
|
126
|
+
const status = entry.status
|
|
127
|
+
if (status && status.status_str === 'error') {
|
|
128
|
+
const msgs = status.messages || []
|
|
129
|
+
const errList = []
|
|
130
|
+
for (const m of msgs) {
|
|
131
|
+
if (Array.isArray(m) && m[0] === 'execution_error') {
|
|
132
|
+
const d = m[1] || {}
|
|
133
|
+
errList.push(`node ${d.node_id || 'unknown'} (${d.node_type || ''}): ${d.exception_message || d.exception_type || 'execution failed'}`)
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (errList.length) return errList.join('; ')
|
|
137
|
+
return status.status_str || 'execution error'
|
|
138
|
+
}
|
|
139
|
+
return ''
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function pxSize(aspectPixels, size) {
|
|
143
|
+
if (aspectPixels) return snapDimensions(aspectPixels[0], aspectPixels[1])
|
|
144
|
+
const [w, h] = sizeToPixels(size)
|
|
145
|
+
return snapDimensions(w, h)
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export const ASPECT_RATIOS = {
|
|
149
|
+
'1:1': [1024, 1024],
|
|
150
|
+
'16:9': [1344, 768],
|
|
151
|
+
'9:16': [768, 1344],
|
|
152
|
+
'4:3': [1152, 896],
|
|
153
|
+
'3:4': [896, 1152],
|
|
154
|
+
'3:2': [1152, 768],
|
|
155
|
+
'2:3': [768, 1152],
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function sizeToPixels(size) {
|
|
159
|
+
const px = SIZE_PIXELS[size]
|
|
160
|
+
if (!px) return [1024, 1024]
|
|
161
|
+
const [w, h] = px.split('x').map(Number)
|
|
162
|
+
return [w, h]
|
|
163
|
+
}
|
|
164
|
+
|