@goodandready/dsh-image-gen 0.11.17 → 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/provider-utils.js +4 -2
- 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/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/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