@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
package/lib/index.js
CHANGED
|
@@ -16,6 +16,18 @@ import z from '@deepseek-ai/schemastery'
|
|
|
16
16
|
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
|
17
17
|
import { mkdir, readFile, writeFile } from 'node:fs/promises'
|
|
18
18
|
import { enforceSecurePermissions } from './security.js'
|
|
19
|
+
import { saveAndAttachResult } from './attachment-helper.js'
|
|
20
|
+
import {
|
|
21
|
+
historyFile,
|
|
22
|
+
findCachedGeneration,
|
|
23
|
+
findCachedByPrompt,
|
|
24
|
+
findCached,
|
|
25
|
+
cachedResult,
|
|
26
|
+
pruneHistory,
|
|
27
|
+
readHistory,
|
|
28
|
+
writeHistory,
|
|
29
|
+
filterHistory,
|
|
30
|
+
} from './history.js'
|
|
19
31
|
import { existsSync, unlinkSync } from 'node:fs'
|
|
20
32
|
import os from 'node:os'
|
|
21
33
|
import path from 'node:path'
|
|
@@ -37,6 +49,18 @@ import { registerPluginUpdater } from './updater.js'
|
|
|
37
49
|
|
|
38
50
|
|
|
39
51
|
export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
|
|
52
|
+
export { saveAndAttachResult }
|
|
53
|
+
export {
|
|
54
|
+
historyFile,
|
|
55
|
+
findCachedGeneration,
|
|
56
|
+
findCachedByPrompt,
|
|
57
|
+
findCached,
|
|
58
|
+
cachedResult,
|
|
59
|
+
pruneHistory,
|
|
60
|
+
readHistory,
|
|
61
|
+
writeHistory,
|
|
62
|
+
filterHistory,
|
|
63
|
+
}
|
|
40
64
|
|
|
41
65
|
|
|
42
66
|
/**
|
|
@@ -45,85 +69,7 @@ export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMedi
|
|
|
45
69
|
|
|
46
70
|
|
|
47
71
|
|
|
48
|
-
export
|
|
49
|
-
bytes,
|
|
50
|
-
mediaType,
|
|
51
|
-
name,
|
|
52
|
-
stem,
|
|
53
|
-
prompt,
|
|
54
|
-
size,
|
|
55
|
-
format,
|
|
56
|
-
seed,
|
|
57
|
-
provider,
|
|
58
|
-
model,
|
|
59
|
-
cost,
|
|
60
|
-
sourceUrl,
|
|
61
|
-
deliverAs,
|
|
62
|
-
args = {},
|
|
63
|
-
action = 'generated',
|
|
64
|
-
}) {
|
|
65
|
-
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
|
|
66
|
-
|
|
67
|
-
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
68
|
-
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
69
|
-
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
70
|
-
await mkdir(outDir, { recursive: true })
|
|
71
|
-
const filePath = path.join(outDir, name)
|
|
72
|
-
await writeFile(filePath, bytes)
|
|
73
|
-
|
|
74
|
-
await writeFile(
|
|
75
|
-
path.join(outDir, `${stem}.json`),
|
|
76
|
-
JSON.stringify(buildSidecar({
|
|
77
|
-
prompt,
|
|
78
|
-
size,
|
|
79
|
-
format,
|
|
80
|
-
seed,
|
|
81
|
-
provider,
|
|
82
|
-
deliverAs,
|
|
83
|
-
width: attachment.width,
|
|
84
|
-
height: attachment.height,
|
|
85
|
-
mediaType,
|
|
86
|
-
attachmentId: attachment.attachmentId,
|
|
87
|
-
url: deliverAs === 'image' && sourceUrl ? sourceUrl : localUrl,
|
|
88
|
-
cost,
|
|
89
|
-
}), null, 2),
|
|
90
|
-
)
|
|
91
|
-
|
|
92
|
-
const summary = buildDualOutputMarkdown({
|
|
93
|
-
action,
|
|
94
|
-
filePath,
|
|
95
|
-
width: attachment.width,
|
|
96
|
-
height: attachment.height,
|
|
97
|
-
mediaType,
|
|
98
|
-
seed,
|
|
99
|
-
provider,
|
|
100
|
-
model: model || cfg.model || cfg.customModel || 'default',
|
|
101
|
-
cost,
|
|
102
|
-
attachmentId: attachment.attachmentId,
|
|
103
|
-
})
|
|
104
|
-
|
|
105
|
-
return toLosslessJson({
|
|
106
|
-
summary,
|
|
107
|
-
path: filePath,
|
|
108
|
-
url: deliverAs === 'image' && sourceUrl ? sourceUrl : localUrl,
|
|
109
|
-
width: attachment.width,
|
|
110
|
-
height: attachment.height,
|
|
111
|
-
seed,
|
|
112
|
-
prompt,
|
|
113
|
-
cost,
|
|
114
|
-
format: mediaType.replace('image/', ''),
|
|
115
|
-
attachment: {
|
|
116
|
-
attachmentId: attachment.attachmentId,
|
|
117
|
-
mediaType: attachment.mediaType,
|
|
118
|
-
bytes: attachment.bytes,
|
|
119
|
-
width: attachment.width,
|
|
120
|
-
height: attachment.height,
|
|
121
|
-
name: attachment.name,
|
|
122
|
-
},
|
|
123
|
-
})
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
export const name = 'dsh-image-gen'
|
|
72
|
+
export const name = '@goodandready/dsh-image-gen'
|
|
127
73
|
|
|
128
74
|
/** Settings namespace the Web card edits. */
|
|
129
75
|
const NS = 'dsh-image-gen'
|
|
@@ -394,94 +340,6 @@ function migrateLegacySettings(sctx, scope) {
|
|
|
394
340
|
}
|
|
395
341
|
|
|
396
342
|
/** History directory: persists across restarts. */
|
|
397
|
-
export function historyFile() {
|
|
398
|
-
return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
|
|
399
|
-
}
|
|
400
|
-
|
|
401
|
-
/** Read history from disk file (empty array if not found). */
|
|
402
|
-
/** Find history entry matching seed+prompt if file exists. */
|
|
403
|
-
/** Find history entry matching prompt text if file exists. */
|
|
404
|
-
export function findCachedGeneration(entries, hash) {
|
|
405
|
-
if (!hash || !Array.isArray(entries)) return undefined
|
|
406
|
-
return entries.find((e) => e.cacheHash === hash)
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export async function findCachedByPrompt(entries, prompt) {
|
|
410
|
-
return entries.find((e) => e.prompt === prompt)
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
export async function findCached(entries, seed, prompt) {
|
|
414
|
-
if (seed === undefined) return undefined
|
|
415
|
-
return entries.find((e) => e.seed === seed && e.prompt === prompt)
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
/** Return cached entry if file exists on disk, otherwise undefined. */
|
|
419
|
-
export async function cachedResult(entry) {
|
|
420
|
-
if (!entry || !entry.path) return undefined
|
|
421
|
-
if (!entry.path || !existsSync(entry.path)) return undefined
|
|
422
|
-
return {
|
|
423
|
-
path: entry.path,
|
|
424
|
-
url: entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : '',
|
|
425
|
-
thumbnailUrl: entry.thumbnailUrl || (entry.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(entry.attachmentId)}` : ''),
|
|
426
|
-
width: entry.width || 1024,
|
|
427
|
-
height: entry.height || 1024,
|
|
428
|
-
seed: entry.seed,
|
|
429
|
-
prompt: entry.prompt,
|
|
430
|
-
cost: 0,
|
|
431
|
-
format: (entry.mediaType || 'image/png').replace('image/', ''),
|
|
432
|
-
cached: true,
|
|
433
|
-
attachment: entry.attachmentId ? {
|
|
434
|
-
attachmentId: entry.attachmentId,
|
|
435
|
-
mediaType: entry.mediaType || 'image/png',
|
|
436
|
-
bytes: entry.bytes || 0,
|
|
437
|
-
width: entry.width || 1024,
|
|
438
|
-
height: entry.height || 1024,
|
|
439
|
-
name: entry.name || '',
|
|
440
|
-
} : undefined,
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
/** Prune files and history records older than pruneDays (including sidecars). */
|
|
445
|
-
export async function pruneHistory(entries, pruneDays) {
|
|
446
|
-
if (!pruneDays || pruneDays <= 0) return entries
|
|
447
|
-
const cutoff = Date.now() - pruneDays * 86400000
|
|
448
|
-
const kept = []
|
|
449
|
-
for (const e of entries) {
|
|
450
|
-
const created = e.createdAt ? Date.parse(e.createdAt) : NaN
|
|
451
|
-
if (Number.isFinite(created) && created < cutoff) {
|
|
452
|
-
try { unlinkSync(e.path) } catch (err) { /* file already deleted */ }
|
|
453
|
-
try { unlinkSync(e.path.replace(/\.[^.]+$/, '.json')) } catch (err) { /* sidecar */ }
|
|
454
|
-
continue
|
|
455
|
-
}
|
|
456
|
-
kept.push(e)
|
|
457
|
-
}
|
|
458
|
-
return kept
|
|
459
|
-
}
|
|
460
|
-
|
|
461
|
-
export async function readHistory() {
|
|
462
|
-
try {
|
|
463
|
-
const raw = await readFile(historyFile(), 'utf-8')
|
|
464
|
-
const parsed = JSON.parse(raw)
|
|
465
|
-
return Array.isArray(parsed) ? parsed : []
|
|
466
|
-
} catch (e) {
|
|
467
|
-
return []
|
|
468
|
-
}
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/** Save history to disk file (atomic overwrite). */
|
|
472
|
-
export async function writeHistory(entries) {
|
|
473
|
-
try {
|
|
474
|
-
await mkdir(path.dirname(historyFile()), { recursive: true })
|
|
475
|
-
await writeFile(historyFile(), JSON.stringify(entries, null, 2))
|
|
476
|
-
enforceSecurePermissions(historyFile())
|
|
477
|
-
} catch (e) { /* history write failure is non-fatal */ }
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/** Filter entries whose files exist on disk; newest first. */
|
|
481
|
-
export function filterHistory(entries, exists) {
|
|
482
|
-
return entries.filter((e) => exists(e.path)).slice(0, 50)
|
|
483
|
-
}
|
|
484
|
-
|
|
485
343
|
/** Collect text chunks from llm.stream iterator. */
|
|
486
344
|
export async function collectText(iterable) {
|
|
487
345
|
let out = ''
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import {
|
|
2
|
+
normalizeMediaType,
|
|
3
|
+
pxSize,
|
|
4
|
+
calculateBackoff,
|
|
5
|
+
extractComfyNodeErrors,
|
|
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 createLocalGenerator(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 local(seedArg = seed, promptArg = prompt) {
|
|
19
|
+
const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
|
|
20
|
+
if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
|
|
21
|
+
const [width, height] = pxSize(aspectPixels, size)
|
|
22
|
+
const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
|
|
23
|
+
|
|
24
|
+
if (kind === 'a1111') {
|
|
25
|
+
const isImg2Img = Boolean(source && source.bytes)
|
|
26
|
+
const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
|
|
27
|
+
const body = {
|
|
28
|
+
prompt: promptArg,
|
|
29
|
+
negative_prompt: negativePrompt,
|
|
30
|
+
width,
|
|
31
|
+
height,
|
|
32
|
+
steps: cfg.localSteps ?? 20,
|
|
33
|
+
cfg_scale: cfg.localCfg ?? 7,
|
|
34
|
+
seed: seedArg ?? -1,
|
|
35
|
+
}
|
|
36
|
+
if (isImg2Img) {
|
|
37
|
+
body.init_images = [Buffer.from(source.bytes).toString('base64')]
|
|
38
|
+
body.denoising_strength = strength ?? (mask ? 0.75 : 0.35)
|
|
39
|
+
if (mask && mask.bytes) {
|
|
40
|
+
body.mask = Buffer.from(mask.bytes).toString('base64')
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (cfg.localModel) body.override_settings = { sd_model_checkpoint: cfg.localModel }
|
|
44
|
+
const res = await fetchImpl(endpoint, {
|
|
45
|
+
method: 'POST',
|
|
46
|
+
headers: { 'Content-Type': 'application/json' },
|
|
47
|
+
body: JSON.stringify(body),
|
|
48
|
+
signal,
|
|
49
|
+
})
|
|
50
|
+
if (!res.ok) {
|
|
51
|
+
throw new Error(`Local A1111 failed (HTTP ${res.status}): ${String(await res.text().catch(() => '')).slice(0, 300)}`)
|
|
52
|
+
}
|
|
53
|
+
const data = await res.json().catch(() => ({}))
|
|
54
|
+
const b64 = data.images && data.images[0]
|
|
55
|
+
if (!b64) throw new Error('Local A1111 returned no images')
|
|
56
|
+
return {
|
|
57
|
+
bytes: Buffer.from(b64, 'base64'),
|
|
58
|
+
mediaType: normalizeMediaType('image/png', format),
|
|
59
|
+
width,
|
|
60
|
+
height,
|
|
61
|
+
seed: seedArg ?? 0,
|
|
62
|
+
sourceUrl: '',
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
|
|
67
|
+
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
68
|
+
const workflow = {
|
|
69
|
+
prompt: {
|
|
70
|
+
'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] } },
|
|
71
|
+
'4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
|
|
72
|
+
'5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
|
|
73
|
+
'6': { class_type: 'CLIPTextEncode', inputs: { text: promptArg, clip: ['4', 1] } },
|
|
74
|
+
'7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
|
|
75
|
+
'8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
|
|
76
|
+
'9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
|
|
77
|
+
},
|
|
78
|
+
}
|
|
79
|
+
const submit = await fetchImpl(`${base}/prompt`, {
|
|
80
|
+
method: 'POST',
|
|
81
|
+
headers: { 'Content-Type': 'application/json' },
|
|
82
|
+
body: JSON.stringify({ prompt: workflow, client_id: promptId }),
|
|
83
|
+
signal,
|
|
84
|
+
})
|
|
85
|
+
if (!submit.ok) {
|
|
86
|
+
throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
|
|
87
|
+
}
|
|
88
|
+
const submitData = await submit.json().catch(() => ({}))
|
|
89
|
+
const pid = submitData.prompt_id
|
|
90
|
+
if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
|
|
91
|
+
|
|
92
|
+
const deadline = Date.now() + cfg.timeoutMs
|
|
93
|
+
let attempt = 0
|
|
94
|
+
for (;;) {
|
|
95
|
+
if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
|
|
96
|
+
if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
|
|
97
|
+
if (attempt > 0) {
|
|
98
|
+
const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
|
|
99
|
+
await new Promise((resolve) => {
|
|
100
|
+
const timer = setTimeout(resolve, cDelay)
|
|
101
|
+
if (signal) {
|
|
102
|
+
signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
|
|
103
|
+
}
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
attempt++
|
|
107
|
+
const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
|
|
108
|
+
if (!hist.ok) continue
|
|
109
|
+
const histData = await hist.json().catch(() => ({}))
|
|
110
|
+
const entry = histData[pid]
|
|
111
|
+
if (entry) {
|
|
112
|
+
const comfyErr = extractComfyNodeErrors(entry)
|
|
113
|
+
if (comfyErr) {
|
|
114
|
+
throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (entry && entry.outputs) {
|
|
118
|
+
const outputs = entry.outputs
|
|
119
|
+
const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
|
|
120
|
+
if (img) {
|
|
121
|
+
const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
|
|
122
|
+
if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
|
|
123
|
+
return {
|
|
124
|
+
bytes: Buffer.from(await dl.arrayBuffer()),
|
|
125
|
+
mediaType: normalizeMediaType('image/png', format),
|
|
126
|
+
width,
|
|
127
|
+
height,
|
|
128
|
+
seed: seedArg ?? 0,
|
|
129
|
+
sourceUrl: '',
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return local
|
|
136
|
+
}
|