@goodandready/dsh-image-gen 0.11.23 → 0.11.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.
@@ -4,6 +4,7 @@ import {
4
4
  SIZE_PIXELS,
5
5
  formatErrorMessage,
6
6
  buildEndpointUrl,
7
+ ensureImageFormat,
7
8
  } from '../shared-helpers.js'
8
9
 
9
10
  /**
@@ -49,6 +50,7 @@ export function createCustomGenerator(deps, job) {
49
50
  prompt: promptArg,
50
51
  n: 1,
51
52
  size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
53
+ ...(format ? { output_format: format } : {}),
52
54
  ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
53
55
  ...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
54
56
  ...(quality && quality !== 'auto' ? { quality } : {}),
@@ -72,9 +74,14 @@ export function createCustomGenerator(deps, job) {
72
74
  }
73
75
 
74
76
  if (item.b64_json) {
77
+ const rawBytes = Buffer.from(item.b64_json, 'base64')
78
+ const targetFormat = format || data.output_format
79
+ const formatted = await ensureImageFormat(rawBytes, targetFormat, {
80
+ fallbackMime: normalizeMediaType(data.output_format || '', format, rawBytes),
81
+ })
75
82
  return {
76
- bytes: Buffer.from(item.b64_json, 'base64'),
77
- mediaType: normalizeMediaType(data.output_format || '', format),
83
+ bytes: formatted.bytes,
84
+ mediaType: formatted.mediaType,
78
85
  width: 0,
79
86
  height: 0,
80
87
  seed: seedArg ?? 0,
@@ -91,9 +98,15 @@ export function createCustomGenerator(deps, job) {
91
98
  const contentType = download.headers && typeof download.headers.get === 'function'
92
99
  ? download.headers.get('content-type')
93
100
  : ''
101
+ const rawBytes = Buffer.from(await download.arrayBuffer())
102
+ const downloadFormat = contentType ? contentType.replace('image/', '').trim().toLowerCase() : undefined
103
+ const targetFormat = (format && format !== 'png') ? format : (downloadFormat || format)
104
+ const formatted = await ensureImageFormat(rawBytes, targetFormat, {
105
+ fallbackMime: normalizeMediaType(contentType, format),
106
+ })
94
107
  return {
95
- bytes: Buffer.from(await download.arrayBuffer()),
96
- mediaType: normalizeMediaType(contentType, format),
108
+ bytes: formatted.bytes,
109
+ mediaType: formatted.mediaType,
97
110
  width: 0,
98
111
  height: 0,
99
112
  seed: seedArg ?? 0,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  normalizeMediaType,
3
+ ensureImageFormat,
3
4
  } from '../shared-helpers.js'
4
5
 
5
6
  /**
@@ -14,19 +15,49 @@ export function createGeminiGenerator(deps, job) {
14
15
 
15
16
  async function gemini(seedArg = seed, promptArg = prompt) {
16
17
  const key = await resolveKey(cfg.geminiKeyEnv)
17
- const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
18
+ let model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
19
+ // #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
20
+ if (model.startsWith('imagen-') || model === 'imagen-3.0-generate-002') {
21
+ model = 'gemini-2.0-flash-exp-image-generation'
22
+ }
23
+
18
24
  const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
19
25
  ? AbortSignal.timeout(cfg.timeoutMs || 120000)
20
26
  : undefined
21
27
  const effectiveSignal = signal && timeoutSignal && AbortSignal.any
22
28
  ? AbortSignal.any([signal, timeoutSignal])
23
29
  : (signal || timeoutSignal)
30
+
31
+ const parts = []
32
+ if (source && source.bytes) {
33
+ const sourceMime = source.mediaType || 'image/png'
34
+ const sourceBase64 = Buffer.isBuffer(source.bytes) ? source.bytes.toString('base64') : Buffer.from(source.bytes).toString('base64')
35
+ parts.push({
36
+ inlineData: {
37
+ mimeType: sourceMime,
38
+ data: sourceBase64,
39
+ },
40
+ })
41
+ }
42
+ parts.push({ text: promptArg })
43
+
44
+ // Build unified imageConfig without quality/style overwrite collisions (#364)
45
+ const imageConfig = {}
46
+ if (aspectRatio) imageConfig.aspectRatio = aspectRatio
47
+ if (quality && quality !== 'auto') imageConfig.imageQuality = quality
48
+ if (style && style !== 'none') imageConfig.imageStyle = style
49
+
50
+ const generationConfig = {
51
+ responseModalities: ['IMAGE'],
52
+ ...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
53
+ }
54
+
24
55
  const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
25
56
  method: 'POST',
26
57
  headers: { 'Content-Type': 'application/json' },
27
58
  body: JSON.stringify({
28
- contents: [{ parts: [{ text: promptArg }] }],
29
- generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
59
+ contents: [{ parts }],
60
+ generationConfig,
30
61
  }),
31
62
  signal: effectiveSignal,
32
63
  })
@@ -37,7 +68,17 @@ export function createGeminiGenerator(deps, job) {
37
68
  }
38
69
  const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
39
70
  if (!part) throw new Error('Gemini returned no image')
40
- return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
71
+
72
+ const rawBytes = Buffer.from(part.inlineData.data, 'base64')
73
+ const formatted = await ensureImageFormat(rawBytes, format || part.inlineData.mimeType)
74
+ return {
75
+ bytes: formatted.bytes,
76
+ mediaType: formatted.mediaType,
77
+ width: 0,
78
+ height: 0,
79
+ seed: seedArg ?? 0,
80
+ sourceUrl: '',
81
+ }
41
82
  }
42
83
  return gemini
43
84
  }
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  /**
2
3
  * Shared provider HTTP/auth helpers used by per-backend factories.
3
4
  * Extracted from providers.js for #218 modularization.
@@ -25,16 +26,77 @@ export function falAuthHeader(key) {
25
26
  : `Key ${trimmed}`
26
27
  }
27
28
 
28
- export function normalizeMediaType(contentType, fallbackFormat) {
29
+ export function detectImageMediaType(buf) {
30
+ if (!buf || (!Buffer.isBuffer(buf) && !(buf instanceof Uint8Array))) return undefined
31
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
32
+ if (b.length < 4) return undefined
33
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
34
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
35
+ if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
36
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
37
+ return 'image/webp'
38
+ }
39
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'
40
+ if (b.length > 5) {
41
+ const head = b.subarray(0, Math.min(256, b.length)).toString('utf8').trimStart()
42
+ if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) {
43
+ return 'image/svg+xml'
44
+ }
45
+ }
46
+ return undefined
47
+ }
48
+
49
+ export function normalizeMediaType(contentType, fallbackFormat, bytes) {
50
+ if (bytes) {
51
+ const detected = detectImageMediaType(bytes)
52
+ if (detected) return detected
53
+ }
29
54
  const raw = String(contentType ?? '').toLowerCase()
30
55
  if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
31
56
  if (raw.includes('webp')) return 'image/webp'
32
57
  if (raw.includes('png')) return 'image/png'
33
- if (fallbackFormat === 'jpeg') return 'image/jpeg'
58
+ if (fallbackFormat === 'jpeg' || fallbackFormat === 'jpg') return 'image/jpeg'
34
59
  if (fallbackFormat === 'webp') return 'image/webp'
60
+ if (fallbackFormat === 'png') return 'image/png'
35
61
  return 'image/png'
36
62
  }
37
63
 
64
+ /**
65
+ * Ensures image bytes match the requested target format ('png', 'jpeg', 'webp').
66
+ * If bytes already match target format, returns them with truthful mediaType.
67
+ * If bytes differ (e.g. provider returned PNG for a webp request), transcodes via Sharp (#386).
68
+ */
69
+ export async function ensureImageFormat(bytes, targetFormat, { fallbackMime } = {}) {
70
+ if (!bytes) return { bytes: Buffer.alloc(0), mediaType: fallbackMime || 'image/png' }
71
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
72
+ const actualMime = detectImageMediaType(buf)
73
+ if (!targetFormat && !fallbackMime) return { bytes: buf, mediaType: actualMime || 'image/png' }
74
+
75
+ const rawFormat = String(targetFormat || '').trim().toLowerCase()
76
+ const canonical = rawFormat === 'jpg' ? 'jpeg' : rawFormat
77
+
78
+ if (canonical && !['png', 'jpeg', 'webp'].includes(canonical)) {
79
+ throw new Error(`Unsupported output format "${targetFormat}". Supported formats: png, jpeg, webp`)
80
+ }
81
+
82
+ const targetMime = canonical ? `image/${canonical}` : fallbackMime
83
+ if (actualMime === targetMime) {
84
+ return { bytes: buf, mediaType: targetMime }
85
+ }
86
+
87
+ try {
88
+ if (!canonical) return { bytes: buf, mediaType: fallbackMime || actualMime || 'image/png' }
89
+ const transcoded = await sharp(buf).toFormat(canonical).toBuffer()
90
+ const verified = detectImageMediaType(transcoded) || targetMime
91
+ return { bytes: transcoded, mediaType: verified }
92
+ } catch (err) {
93
+ if (buf.length < 64) {
94
+ return { bytes: buf, mediaType: fallbackMime || targetMime || actualMime || 'image/png' }
95
+ }
96
+ throw new Error(`Failed to encode image to ${canonical}: ${err.message}`)
97
+ }
98
+ }
99
+
38
100
  export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
39
101
  const res = await fetchImpl(`${baseURL}/${model}`, {
40
102
  method: 'POST',
package/lib/providers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CURATED_STYLES, normalizeStyleKey } from './prompt-polisher.js'
1
2
  /**
2
3
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
3
4
  */
@@ -27,6 +28,8 @@ import { createReplicateGenerator } from './providers/backends/replicate.js'
27
28
  export {
28
29
  falAuthHeader,
29
30
  normalizeMediaType,
31
+ detectImageMediaType,
32
+ ensureImageFormat,
30
33
  submitJob,
31
34
  pollStatus,
32
35
  buildEditForm,
@@ -288,47 +291,36 @@ export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16,
288
291
 
289
292
 
290
293
  export const STYLE_PRESETS = {
294
+ ...CURATED_STYLES,
291
295
  cinematic: {
296
+ ...CURATED_STYLES.cinematic,
292
297
  promptSuffix: 'cinematic film still, 35mm photograph, dramatic lighting, highly detailed',
293
298
  negativePrompt: 'cartoon, illustration, 3d render, oversaturated, blurry, distorted',
294
299
  guidanceScale: 6.5,
295
300
  },
296
- anime: {
297
- promptSuffix: 'anime aesthetic, Makoto Shinkai style, vibrant colors, detailed lineart, masterpiece',
298
- negativePrompt: 'photorealistic, 3d, realistic photo, noisy, deformed, lowres',
299
- guidanceScale: 7.0,
300
- },
301
- isometric: {
301
+ isometric: CURATED_STYLES.isometric_3d || {
302
302
  promptSuffix: 'isometric 3D render, clay style, soft studio lighting, clean ambient occlusion',
303
303
  negativePrompt: 'flat, 2d, sketch, photograph, noisy background',
304
304
  guidanceScale: 7.0,
305
305
  },
306
- cyberpunk: {
307
- promptSuffix: 'cyberpunk aesthetic, neon reflections, volumetric fog, moody dark atmosphere',
308
- negativePrompt: 'daylight, cartoon, pastel, monochrome, blurry',
309
- guidanceScale: 7.5,
310
- },
311
- pixel_art: {
312
- promptSuffix: '16-bit retro pixel art, crisp pixels, dithered shading, nostalgic game palette',
313
- negativePrompt: 'smooth, vector, 3d render, blurry, photorealistic',
314
- guidanceScale: 8.0,
315
- },
316
- oil_painting: {
317
- promptSuffix: 'classical oil painting, visible canvas texture, rich brushstrokes, fine art',
318
- negativePrompt: 'photo, digital render, 3d, plastic, anime, flat vector',
319
- guidanceScale: 7.0,
320
- },
321
- minimalist: {
322
- promptSuffix: 'minimalist graphic design, clean lines, flat vector illustration, elegant geometry',
323
- negativePrompt: 'busy, cluttered, photorealistic, 3d, noisy texture, gradient overload',
324
- guidanceScale: 7.0,
325
- },
306
+ vector: CURATED_STYLES.minimalist_vector,
307
+ minimalist: CURATED_STYLES.minimalist_vector,
308
+ film: CURATED_STYLES.analog_film,
309
+ analog: CURATED_STYLES.analog_film,
310
+ photo: CURATED_STYLES.photorealistic,
311
+ realism: CURATED_STYLES.photorealistic,
326
312
  }
327
313
 
328
314
  export function resolveStylePreset(styleKeyOrText, existingNegative, existingGuidance) {
329
315
  if (!styleKeyOrText) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
330
- const key = String(styleKeyOrText).toLowerCase().replace(/[-\s]/g, '_')
331
- const preset = STYLE_PRESETS[key]
316
+ const raw = String(styleKeyOrText).trim()
317
+ if (!raw) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
318
+ const key = raw.toLowerCase().replace(/[-\s]/g, '_')
319
+ if (key === 'none' || key === 'default' || key === 'off') {
320
+ return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
321
+ }
322
+ const canonicalKey = normalizeStyleKey(key) || key
323
+ const preset = STYLE_PRESETS[canonicalKey] || STYLE_PRESETS[key]
332
324
  if (preset && typeof preset === 'object') {
333
325
  const combinedNeg = [existingNegative, preset.negativePrompt].filter(Boolean).join(', ')
334
326
  return {
@@ -338,7 +330,7 @@ export function resolveStylePreset(styleKeyOrText, existingNegative, existingGui
338
330
  }
339
331
  }
340
332
  return {
341
- promptSuffix: typeof preset === 'string' ? preset : String(styleKeyOrText),
333
+ promptSuffix: typeof preset === 'string' ? preset : raw,
342
334
  negativePrompt: existingNegative,
343
335
  guidanceScale: existingGuidance,
344
336
  }
@@ -1,5 +1,6 @@
1
- // register-tools.js — orchestrates all image-gen tool registrations (#216, #337).
1
+ // register-tools.js — orchestrates all image-gen tool registrations (#216, #337, #384).
2
2
  // Supports 'minimal' (default: <= 3 core tools), 'all' (all 30 tools), and 'custom' (toggled toolsets).
3
+ // Reactively updates tool registrations when toolsetProfile or toolsets change.
3
4
 
4
5
  import { registerGenerationTools } from './tools/generation.js'
5
6
  import { registerProcessingTools } from './tools/processing-basic.js'
@@ -20,12 +21,16 @@ import { registerVisionOcrTools } from './tools/vision-ocr.js'
20
21
  import { registerDiagramTools } from './tools/diagram.js'
21
22
 
22
23
  /**
23
- * Registers every image-gen tool on the host tools service according to toolsetProfile (#337).
24
+ * Registers every image-gen tool on the host tools service according to toolsetProfile (#337, #384).
24
25
  *
25
26
  * @param {object} ctx cordis context
26
27
  * @param {object} deps shared runtime from apply()
28
+ * @returns {object} { sync, dispose, getRegisteredNames }
27
29
  */
28
30
  export function registerAllTools(ctx, deps) {
31
+ let activeDisposers = []
32
+ const registeredNames = new Set()
33
+
29
34
  const originalToolsRegister = ctx?.tools?.register ? ctx.tools.register.bind(ctx.tools) : null
30
35
  const wrappedTools = originalToolsRegister ? Object.create(ctx.tools, {
31
36
  register: {
@@ -41,7 +46,23 @@ export function registerAllTools(ctx, deps) {
41
46
  return originalExecute(args, exec)
42
47
  },
43
48
  }
44
- return originalToolsRegister(wrappedDef)
49
+ const res = originalToolsRegister(wrappedDef)
50
+ registeredNames.add(def.name)
51
+ if (typeof res === 'function') {
52
+ activeDisposers.push(res)
53
+ } else {
54
+ activeDisposers.push(() => {
55
+ if (typeof ctx.tools?.unregister === 'function') {
56
+ ctx.tools.unregister(def.name)
57
+ } else if (typeof ctx.tools?.delete === 'function') {
58
+ ctx.tools.delete(def.name)
59
+ } else if (Array.isArray(ctx.tools?.list)) {
60
+ const idx = ctx.tools.list.findIndex((t) => t.name === def.name)
61
+ if (idx !== -1) ctx.tools.list.splice(idx, 1)
62
+ }
63
+ })
64
+ }
65
+ return res
45
66
  },
46
67
  writable: true,
47
68
  configurable: true,
@@ -51,47 +72,95 @@ export function registerAllTools(ctx, deps) {
51
72
  const targetCtx = originalToolsRegister ? Object.create(ctx, {
52
73
  tools: { value: wrappedTools, writable: true, configurable: true }
53
74
  }) : ctx
54
- const cfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
55
- const profile = cfg.toolsetProfile || 'minimal'
56
- const isAll = profile === 'all'
57
- const sets = cfg.toolsets || {}
58
- const design = isAll || Boolean(sets.design || cfg.toolsetDesign)
59
- const processing = isAll || Boolean(sets.processing || cfg.toolsetProcessing)
60
- const frontend = isAll || Boolean(sets.frontend || cfg.toolsetFrontend)
61
- const creative = isAll || Boolean(sets.creative || cfg.toolsetCreative)
62
75
 
63
- // 1. Core tools: generate_image, edit_image, inspect_image_quality (<= 3 tools in minimal)
64
- registerGenerationTools(targetCtx, deps, { coreOnly: !creative })
65
- registerEditingTools(targetCtx, deps, { coreOnly: !processing })
66
- registerInspectTools(targetCtx, deps, { coreOnly: !processing })
76
+ function doRegister(cfg) {
77
+ const profile = cfg.toolsetProfile || 'minimal'
78
+ const isAll = profile === 'all'
79
+ const sets = cfg.toolsets || {}
80
+ const design = isAll || Boolean(sets.design || cfg.toolsetDesign)
81
+ const processing = isAll || Boolean(sets.processing || cfg.toolsetProcessing)
82
+ const frontend = isAll || Boolean(sets.frontend || cfg.toolsetFrontend)
83
+ const creative = isAll || Boolean(sets.creative || cfg.toolsetCreative)
84
+
85
+ // 1. Core tools: generate_image, edit_image, inspect_image_quality (<= 3 tools in minimal)
86
+ registerGenerationTools(targetCtx, deps, { coreOnly: !creative })
87
+ registerEditingTools(targetCtx, deps, { coreOnly: !processing })
88
+ registerInspectTools(targetCtx, deps, { coreOnly: !processing })
89
+
90
+ // 2. Processing toolset
91
+ if (processing) {
92
+ registerProcessingTools(targetCtx, deps)
93
+ registerProcessingAdvancedTools(targetCtx, deps)
94
+ registerVisionOcrTools(targetCtx, deps)
95
+ }
96
+
97
+ // 3. Design toolset
98
+ if (design) {
99
+ registerAnchorTools(targetCtx, deps)
100
+ registerUiAssetTools(targetCtx, deps)
101
+ registerStyleMatrixTools(targetCtx, deps)
102
+ registerThemePairTools(targetCtx, deps)
103
+ registerCharacterSheetTools(targetCtx, deps)
104
+ registerDiagramTools(targetCtx, deps)
105
+ }
67
106
 
68
- // 2. Processing toolset
69
- if (processing) {
70
- registerProcessingTools(targetCtx, deps)
71
- registerProcessingAdvancedTools(targetCtx, deps)
72
- registerVisionOcrTools(targetCtx, deps)
107
+ // 4. Frontend toolset
108
+ if (frontend) {
109
+ registerFrontendTools(targetCtx, deps)
110
+ registerResponsiveTools(targetCtx, deps)
111
+ }
112
+
113
+ // 5. Creative toolset
114
+ if (creative) {
115
+ registerSketchTools(targetCtx, deps)
116
+ registerSpritesheetTools(targetCtx, deps)
117
+ registerPatternTools(targetCtx, deps)
118
+ }
73
119
  }
74
120
 
75
- // 3. Design toolset
76
- if (design) {
77
- registerAnchorTools(targetCtx, deps)
78
- registerUiAssetTools(targetCtx, deps)
79
- registerStyleMatrixTools(targetCtx, deps)
80
- registerThemePairTools(targetCtx, deps)
81
- registerCharacterSheetTools(targetCtx, deps)
82
- registerDiagramTools(targetCtx, deps)
121
+ function dispose() {
122
+ for (const d of activeDisposers) {
123
+ if (typeof d === 'function') {
124
+ try { d() } catch (err) {
125
+ // Disposer cleanup errors are safely ignored on tool rebuild
126
+ }
127
+ }
128
+ }
129
+ activeDisposers = []
130
+ registeredNames.clear()
83
131
  }
84
132
 
85
- // 4. Frontend toolset
86
- if (frontend) {
87
- registerFrontendTools(targetCtx, deps)
88
- registerResponsiveTools(targetCtx, deps)
133
+ function computeKey(cfg) {
134
+ const profile = cfg.toolsetProfile || 'minimal'
135
+ const isAll = profile === 'all'
136
+ const sets = cfg.toolsets || {}
137
+ return JSON.stringify({
138
+ profile,
139
+ design: isAll || Boolean(sets.design || cfg.toolsetDesign),
140
+ processing: isAll || Boolean(sets.processing || cfg.toolsetProcessing),
141
+ frontend: isAll || Boolean(sets.frontend || cfg.toolsetFrontend),
142
+ creative: isAll || Boolean(sets.creative || cfg.toolsetCreative),
143
+ })
89
144
  }
90
145
 
91
- // 5. Creative toolset
92
- if (creative) {
93
- registerSketchTools(targetCtx, deps)
94
- registerSpritesheetTools(targetCtx, deps)
95
- registerPatternTools(targetCtx, deps)
146
+ let currentKey = ''
147
+ function sync() {
148
+ const currentCfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
149
+ const key = computeKey(currentCfg)
150
+ if (key === currentKey) return
151
+ currentKey = key
152
+ dispose()
153
+ doRegister(currentCfg)
154
+ }
155
+
156
+ // Initial registration
157
+ const initialCfg = typeof deps.live === 'function' ? deps.live() : (deps.config || {})
158
+ currentKey = computeKey(initialCfg)
159
+ doRegister(initialCfg)
160
+
161
+ return {
162
+ sync,
163
+ dispose,
164
+ getRegisteredNames: () => Array.from(registeredNames),
96
165
  }
97
166
  }
@@ -1,3 +1,4 @@
1
+ import { validateComfyWorkflow } from './comfy-workflow-helpers.js'
1
2
  // lib/settings-route.js
2
3
  // REST endpoints for plugin settings (/dsh-image-gen/config, /dsh-image-gen/status) (#295, #300).
3
4
  // Provides HTTP read/write fallback when DSH core is accessed over network.
@@ -92,23 +93,72 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, setLiveConfi
92
93
  return
93
94
  }
94
95
 
96
+ const resetFields = []
97
+ if (Array.isArray(payload?.resetFields)) {
98
+ resetFields.push(...payload.resetFields)
99
+ } else if (Array.isArray(payload?.reset)) {
100
+ resetFields.push(...payload.reset)
101
+ } else if (typeof payload?.resetField === 'string') {
102
+ resetFields.push(payload.resetField)
103
+ }
104
+
105
+ // Coerce fallbackProviders string to array (#369)
106
+ if (typeof raw.fallbackProviders === 'string') {
107
+ raw.fallbackProviders = raw.fallbackProviders
108
+ .split(',')
109
+ .map((s) => s.trim())
110
+ .filter(Boolean)
111
+ }
112
+
113
+ // Validate comfyWorkflowJson if non-empty string (#369)
114
+ if (typeof raw.comfyWorkflowJson === 'string' && raw.comfyWorkflowJson.trim() !== '') {
115
+ try {
116
+ validateComfyWorkflow(raw.comfyWorkflowJson)
117
+ } catch (err) {
118
+ res.writeHead(400, { 'Content-Type': 'application/json' })
119
+ res.end(JSON.stringify({ ok: false, error: `Invalid comfyWorkflowJson: ${err.message}` }))
120
+ return
121
+ }
122
+ }
123
+
95
124
  const allowedKeys = new Set(Object.keys(Config.dict || {}))
96
- for (const k of Object.keys(raw)) {
125
+ for (const k of resetFields) {
126
+ if (!allowedKeys.has(k)) {
127
+ res.writeHead(400, { 'Content-Type': 'application/json' })
128
+ res.end(JSON.stringify({ ok: false, error: `unknown config field to reset: ${k}` }))
129
+ return
130
+ }
131
+ }
132
+
133
+ for (const [k, v] of Object.entries(raw)) {
134
+ if (k === 'resetFields' || k === 'reset' || k === 'resetField') continue
97
135
  if (!allowedKeys.has(k)) {
98
136
  res.writeHead(400, { 'Content-Type': 'application/json' })
99
137
  res.end(JSON.stringify({ ok: false, error: `unknown config field: ${k}` }))
100
138
  return
101
139
  }
140
+ if (v === null) {
141
+ resetFields.push(k)
142
+ }
102
143
  }
103
144
 
104
145
  try {
105
- const base = plainConfig(live()) || {}
106
- const merged = plainConfig(Config({ ...base, ...raw }))
146
+ const base = structuredClone(plainConfig(live()) || {})
147
+ for (const k of resetFields) {
148
+ delete base[k]
149
+ }
150
+ for (const [k, v] of Object.entries(raw)) {
151
+ if (k === 'resetFields' || k === 'reset' || k === 'resetField') continue
152
+ if (v !== null && v !== undefined) {
153
+ base[k] = v
154
+ }
155
+ }
156
+ const merged = plainConfig(Config(base))
107
157
  const settingsApi = getSettingsApi ? getSettingsApi() : null
108
158
  if (settingsApi && typeof settingsApi.replace === 'function') {
109
159
  await settingsApi.replace(merged)
110
160
  } else if (settingsApi && typeof settingsApi.update === 'function') {
111
- await settingsApi.update(raw)
161
+ await settingsApi.update(merged)
112
162
  } else if (typeof setLiveConfig === 'function') {
113
163
  setLiveConfig(merged)
114
164
  }