@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/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 async function saveAndAttachResult(ctx, exec, cfg, {
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
+ }