@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.
@@ -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
+