@goodandready/dsh-image-gen 0.11.16 → 0.11.18
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/generation-helpers.js +250 -0
- package/lib/index.js +2 -2
- package/lib/provider-utils.js +4 -2
- package/lib/settings-route.js +1 -1
- package/lib/tools/anchor.js +5 -0
- package/lib/tools/character-sheet.js +1 -0
- package/lib/tools/diagram.js +1 -0
- package/lib/tools/generation-pack.js +1 -0
- package/lib/tools/generation.js +496 -651
- package/lib/tools/inspect.js +20 -11
- package/lib/tools/style-matrix.js +1 -0
- package/lib/tools/theme-pair.js +1 -0
- package/lib/tools/ui-asset.js +1 -0
- package/lib/tools/vision-ocr.js +1 -0
- package/lib/updater.js +2 -2
- package/package.json +1 -1
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
// lib/generation-helpers.js
|
|
2
|
+
// Extracted execution and prompt/caching helpers for generate_image tool (#349).
|
|
3
|
+
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import {
|
|
7
|
+
computeGenerationHash,
|
|
8
|
+
IMAGE_SIZES,
|
|
9
|
+
OUTPUT_FORMATS,
|
|
10
|
+
PROVIDER_KEYS,
|
|
11
|
+
buildSidecar,
|
|
12
|
+
ASPECT_RATIOS,
|
|
13
|
+
embedPngMetadata,
|
|
14
|
+
resolveStylePreset,
|
|
15
|
+
saveAttachmentSafe,
|
|
16
|
+
} from './providers.js'
|
|
17
|
+
import { enhancePrompt } from './prompt-enhancer.js'
|
|
18
|
+
import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
|
|
19
|
+
import { sanitizeNegativePrompt, supportsNegativePrompt } from './negative-sanitizer.js'
|
|
20
|
+
import { recordSpend } from './cost-meter.js'
|
|
21
|
+
import { setCachedGeneration } from './generation-cache.js'
|
|
22
|
+
import {
|
|
23
|
+
readHistory,
|
|
24
|
+
writeHistory,
|
|
25
|
+
pruneHistory,
|
|
26
|
+
} from './history.js'
|
|
27
|
+
|
|
28
|
+
/** Run an array of async task functions with a concurrency cap */
|
|
29
|
+
export async function asyncPool(tasks, concurrency = 3) {
|
|
30
|
+
const results = new Array(tasks.length)
|
|
31
|
+
let nextIdx = 0
|
|
32
|
+
async function worker() {
|
|
33
|
+
while (nextIdx < tasks.length) {
|
|
34
|
+
const idx = nextIdx++
|
|
35
|
+
results[idx] = await tasks[idx]()
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
const workers = Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker())
|
|
39
|
+
await Promise.all(workers)
|
|
40
|
+
return results
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Validates generation configuration and arguments */
|
|
44
|
+
export function validateGenerationParams(cfg, args) {
|
|
45
|
+
if (cfg.enabled === false) {
|
|
46
|
+
throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
|
|
47
|
+
}
|
|
48
|
+
const provider = PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal'
|
|
49
|
+
const deliverAs = cfg.deliverAs
|
|
50
|
+
const size = args.aspect_ratio ? 'custom' : (args.image_size ?? cfg.defaultSize)
|
|
51
|
+
if (!args.aspect_ratio && !IMAGE_SIZES.includes(size)) {
|
|
52
|
+
throw new Error(`Invalid image_size "${size}". One of: ${IMAGE_SIZES.join(', ')}`)
|
|
53
|
+
}
|
|
54
|
+
const aspectPixels = args.aspect_ratio ? ASPECT_RATIOS[args.aspect_ratio] : undefined
|
|
55
|
+
const format = args.output_format ?? cfg.defaultFormat
|
|
56
|
+
if (!OUTPUT_FORMATS.includes(format)) {
|
|
57
|
+
throw new Error(`Invalid output_format "${format}". One of: ${OUTPUT_FORMATS.join(', ')}`)
|
|
58
|
+
}
|
|
59
|
+
return { provider, deliverAs, size, aspectPixels, format }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
|
|
63
|
+
export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
|
|
64
|
+
const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
|
|
65
|
+
const effectiveStyle = args.style || cfg.stylePreset
|
|
66
|
+
const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
|
|
67
|
+
let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
|
|
68
|
+
let effectiveNegative = styleInfo.negativePrompt
|
|
69
|
+
const activeAnchor = getSessionAnchor(sessionId)
|
|
70
|
+
let resolvedFaceRef
|
|
71
|
+
|
|
72
|
+
const faceRefInput = args.face_reference || (activeAnchor && (activeAnchor.mode === 'face' || activeAnchor.mode === 'character') ? activeAnchor.image : undefined)
|
|
73
|
+
if (faceRefInput) {
|
|
74
|
+
try {
|
|
75
|
+
resolvedFaceRef = await resolveSource(ctx, exec, faceRefInput)
|
|
76
|
+
} catch (_err) {
|
|
77
|
+
/* non-fatal: face_reference resolve fallback */
|
|
78
|
+
resolvedFaceRef = { url: faceRefInput }
|
|
79
|
+
}
|
|
80
|
+
const facePlan = buildFaceConsistencyPrompt({
|
|
81
|
+
prompt: effectivePrompt,
|
|
82
|
+
faceStrength: args.face_strength || activeAnchor?.strength,
|
|
83
|
+
characterName: activeAnchor?.label,
|
|
84
|
+
})
|
|
85
|
+
effectivePrompt = facePlan.prompt
|
|
86
|
+
if (facePlan.negativePrompt) {
|
|
87
|
+
effectiveNegative = effectiveNegative ? `${effectiveNegative}, ${facePlan.negativePrompt}` : facePlan.negativePrompt
|
|
88
|
+
}
|
|
89
|
+
} else if (args.reference_image) {
|
|
90
|
+
effectivePrompt = `${effectivePrompt}, visual reference anchor`
|
|
91
|
+
} else if (activeAnchor) {
|
|
92
|
+
effectivePrompt = applyAnchorPromptHints(activeAnchor, effectivePrompt)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// #165: Negative prompt sanitizer for diffusion models
|
|
96
|
+
if (supportsNegativePrompt(provider, cfg.model || cfg.customModel)) {
|
|
97
|
+
effectiveNegative = sanitizeNegativePrompt({
|
|
98
|
+
positivePrompt: effectivePrompt,
|
|
99
|
+
userNegative: effectiveNegative,
|
|
100
|
+
autoInjectDefects: true,
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
enhanced,
|
|
106
|
+
effectivePrompt,
|
|
107
|
+
effectiveNegative,
|
|
108
|
+
effectiveGuidance: styleInfo.guidanceScale,
|
|
109
|
+
styleInfo,
|
|
110
|
+
activeAnchor,
|
|
111
|
+
resolvedFaceRef,
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Saves generated image to disk, sidecar, history, records spend, and builds return structure */
|
|
116
|
+
export async function saveGeneratedImageArtifact({
|
|
117
|
+
ctx,
|
|
118
|
+
exec,
|
|
119
|
+
cfg,
|
|
120
|
+
args,
|
|
121
|
+
gen,
|
|
122
|
+
promptArg,
|
|
123
|
+
jobSeed,
|
|
124
|
+
provider,
|
|
125
|
+
deliverAs,
|
|
126
|
+
size,
|
|
127
|
+
format,
|
|
128
|
+
effectiveNegative,
|
|
129
|
+
effectiveGuidance,
|
|
130
|
+
enhanced,
|
|
131
|
+
slugify,
|
|
132
|
+
estimatedCost,
|
|
133
|
+
totalCount,
|
|
134
|
+
}) {
|
|
135
|
+
const mediaType = gen.mediaType || 'image/png'
|
|
136
|
+
let bytes = gen.bytes
|
|
137
|
+
if (mediaType === 'image/png') {
|
|
138
|
+
bytes = embedPngMetadata(bytes, {
|
|
139
|
+
prompt: promptArg,
|
|
140
|
+
seed: gen.seed,
|
|
141
|
+
provider,
|
|
142
|
+
model: cfg.model || cfg.customModel,
|
|
143
|
+
negative_prompt: effectiveNegative,
|
|
144
|
+
guidance_scale: effectiveGuidance,
|
|
145
|
+
width: gen.width,
|
|
146
|
+
height: gen.height,
|
|
147
|
+
})
|
|
148
|
+
}
|
|
149
|
+
const extension = mediaType === 'image/jpeg' ? 'jpg' : mediaType === 'image/webp' ? 'webp' : 'png'
|
|
150
|
+
const stem = `${slugify(args.output_name || promptArg)}-${Date.now().toString(36)}-${jobSeed}`
|
|
151
|
+
const name = `${stem}.${extension}`
|
|
152
|
+
|
|
153
|
+
const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
|
|
154
|
+
|
|
155
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
156
|
+
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
157
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
158
|
+
await mkdir(outDir, { recursive: true })
|
|
159
|
+
const filePath = path.join(outDir, name)
|
|
160
|
+
await writeFile(filePath, bytes)
|
|
161
|
+
|
|
162
|
+
// Sidecar: metadata written alongside image file, making the output
|
|
163
|
+
// directory self-documenting without external database dependencies.
|
|
164
|
+
await writeFile(
|
|
165
|
+
path.join(outDir, `${stem}.json`),
|
|
166
|
+
JSON.stringify(buildSidecar({
|
|
167
|
+
prompt: promptArg,
|
|
168
|
+
size,
|
|
169
|
+
format,
|
|
170
|
+
seed: gen.seed,
|
|
171
|
+
provider,
|
|
172
|
+
deliverAs,
|
|
173
|
+
width: gen.width || attachment.width,
|
|
174
|
+
height: gen.height || attachment.height,
|
|
175
|
+
mediaType,
|
|
176
|
+
attachmentId: attachment.attachmentId,
|
|
177
|
+
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
178
|
+
cost: gen.cost,
|
|
179
|
+
}), null, 2),
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
const cacheHash = computeGenerationHash({
|
|
183
|
+
provider,
|
|
184
|
+
model: cfg.model || cfg.customModel,
|
|
185
|
+
prompt: promptArg,
|
|
186
|
+
seed: gen.seed,
|
|
187
|
+
size: args.image_size || cfg.defaultSize,
|
|
188
|
+
style: args.style || cfg.stylePreset,
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
// #170: Store in content-addressed disk cache
|
|
192
|
+
if (cfg.diskCache !== false) {
|
|
193
|
+
setCachedGeneration(cacheHash, {
|
|
194
|
+
bytes,
|
|
195
|
+
mediaType,
|
|
196
|
+
width: gen.width || attachment.width,
|
|
197
|
+
height: gen.height || attachment.height,
|
|
198
|
+
seed: gen.seed,
|
|
199
|
+
meta: { prompt: promptArg, provider, model: cfg.model || cfg.customModel, cost: gen.cost },
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
const entry = {
|
|
203
|
+
path: filePath,
|
|
204
|
+
prompt: promptArg,
|
|
205
|
+
provider,
|
|
206
|
+
model: cfg.model,
|
|
207
|
+
size,
|
|
208
|
+
format,
|
|
209
|
+
seed: gen.seed,
|
|
210
|
+
cacheHash,
|
|
211
|
+
thumbnailUrl: localUrl,
|
|
212
|
+
width: gen.width || attachment.width,
|
|
213
|
+
height: gen.height || attachment.height,
|
|
214
|
+
mediaType,
|
|
215
|
+
bytes: attachment.bytes,
|
|
216
|
+
createdAt: new Date().toISOString(),
|
|
217
|
+
attachmentId: attachment.attachmentId,
|
|
218
|
+
}
|
|
219
|
+
let current = await readHistory()
|
|
220
|
+
current = await pruneHistory(current, cfg.pruneDays)
|
|
221
|
+
current.unshift(entry)
|
|
222
|
+
if (current.length > (cfg.historyLimit || 50)) current.length = cfg.historyLimit || 50
|
|
223
|
+
await writeHistory(current)
|
|
224
|
+
|
|
225
|
+
// #167: Record spend in cost meter
|
|
226
|
+
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
227
|
+
ctx,
|
|
228
|
+
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
229
|
+
})
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
path: filePath,
|
|
233
|
+
url: deliverAs === 'image' && gen.sourceUrl ? gen.sourceUrl : localUrl,
|
|
234
|
+
width: gen.width || attachment.width,
|
|
235
|
+
height: gen.height || attachment.height,
|
|
236
|
+
seed: gen.seed,
|
|
237
|
+
prompt: promptArg,
|
|
238
|
+
...(enhanced?.enhanced ? { originalPrompt: args.prompt } : {}),
|
|
239
|
+
cost: gen.cost,
|
|
240
|
+
format: mediaType.replace('image/', ''),
|
|
241
|
+
attachment: {
|
|
242
|
+
attachmentId: attachment.attachmentId,
|
|
243
|
+
mediaType: attachment.mediaType,
|
|
244
|
+
bytes: attachment.bytes,
|
|
245
|
+
width: attachment.width,
|
|
246
|
+
height: attachment.height,
|
|
247
|
+
name: attachment.name,
|
|
248
|
+
},
|
|
249
|
+
}
|
|
250
|
+
}
|
package/lib/index.js
CHANGED
|
@@ -146,7 +146,7 @@ export function apply(ctx, rawConfig) {
|
|
|
146
146
|
return {
|
|
147
147
|
get: () => live(),
|
|
148
148
|
replace: async (next) => {
|
|
149
|
-
const parsed = Config(structuredClone(plainConfig(next)))
|
|
149
|
+
const parsed = plainConfig(Config(structuredClone(plainConfig(next))))
|
|
150
150
|
currentLiveConfig = parsed
|
|
151
151
|
const payload = volatileConfig(parsed)
|
|
152
152
|
if (typeof svc.replace === 'function') {
|
|
@@ -157,7 +157,7 @@ export function apply(ctx, rawConfig) {
|
|
|
157
157
|
return parsed
|
|
158
158
|
},
|
|
159
159
|
update: async (patch) => {
|
|
160
|
-
const merged = Config({ ...publicConfig(live()), ...plainConfig(patch) })
|
|
160
|
+
const merged = plainConfig(Config({ ...publicConfig(live()), ...plainConfig(patch) }))
|
|
161
161
|
currentLiveConfig = merged
|
|
162
162
|
const payload = volatileConfig(merged)
|
|
163
163
|
if (typeof svc.update === 'function') {
|
package/lib/provider-utils.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeErrorAndLogs } from './security.js'
|
|
1
2
|
// lib/provider-utils.js
|
|
2
3
|
// Shared provider utilities and size/format constants extracted from providers.js (#239)
|
|
3
4
|
|
|
@@ -234,11 +235,12 @@ export function formatErrorMessage(e, providerKey = '') {
|
|
|
234
235
|
msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
|
|
235
236
|
}
|
|
236
237
|
|
|
238
|
+
const sanitized = sanitizeErrorAndLogs(msg)
|
|
237
239
|
if (providerKey) {
|
|
238
|
-
return `${providerKey}: ${
|
|
240
|
+
return `${providerKey}: ${sanitized}`
|
|
239
241
|
}
|
|
240
242
|
|
|
241
|
-
return
|
|
243
|
+
return sanitized
|
|
242
244
|
}
|
|
243
245
|
|
|
244
246
|
export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
|
package/lib/settings-route.js
CHANGED
|
@@ -103,7 +103,7 @@ export function registerSettingsRoutes(ctx, { live, getSettingsApi, setLiveConfi
|
|
|
103
103
|
|
|
104
104
|
try {
|
|
105
105
|
const base = plainConfig(live()) || {}
|
|
106
|
-
const merged = Config({ ...base, ...raw })
|
|
106
|
+
const merged = plainConfig(Config({ ...base, ...raw }))
|
|
107
107
|
const settingsApi = getSettingsApi ? getSettingsApi() : null
|
|
108
108
|
if (settingsApi && typeof settingsApi.replace === 'function') {
|
|
109
109
|
await settingsApi.replace(merged)
|
package/lib/tools/anchor.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sanitizeErrorAndLogs } from '../security.js'
|
|
1
2
|
// anchor.js — set_style_anchor tool (#283).
|
|
2
3
|
// Manages persistent visual style & character reference anchors in session context.
|
|
3
4
|
|
|
@@ -69,6 +70,7 @@ export function registerAnchorTools(ctx, deps) {
|
|
|
69
70
|
},
|
|
70
71
|
isConcurrencySafe: () => true,
|
|
71
72
|
async execute(args, exec) {
|
|
73
|
+
try {
|
|
72
74
|
const cfg = live ? live() : {}
|
|
73
75
|
if (cfg.enabled === false) {
|
|
74
76
|
throw new Error('Image generation plugin is disabled in settings.')
|
|
@@ -121,6 +123,9 @@ export function registerAnchorTools(ctx, deps) {
|
|
|
121
123
|
anchor,
|
|
122
124
|
summary,
|
|
123
125
|
})
|
|
126
|
+
} catch (err) {
|
|
127
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
128
|
+
}
|
|
124
129
|
},
|
|
125
130
|
}),
|
|
126
131
|
)
|
package/lib/tools/diagram.js
CHANGED