@goodandready/dsh-image-gen 0.10.4 → 0.10.6
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 +7 -0
- package/README.ru.md +4 -0
- package/lib/frontend-assets.js +436 -0
- package/lib/index.js +566 -5
- package/lib/providers.js +73 -2
- package/lib/resolve-image.js +243 -0
- package/package.json +1 -1
package/lib/providers.js
CHANGED
|
@@ -353,13 +353,26 @@ export function makeProviders(deps, job) {
|
|
|
353
353
|
|
|
354
354
|
async function fal(seedArg = seed, promptArg = prompt) {
|
|
355
355
|
const key = await resolveKey(cfg.apiKeyEnv)
|
|
356
|
+
const isEdit = Boolean(source && source.bytes)
|
|
357
|
+
const targetModel = isEdit
|
|
358
|
+
? (mask && mask.bytes ? 'fal-ai/flux-pro/v1/inpaint' : 'fal-ai/flux/dev/image-to-image')
|
|
359
|
+
: cfg.model
|
|
356
360
|
const body = { prompt: promptArg, image_size: size, num_images: 1 }
|
|
357
361
|
if (seedArg !== undefined) body.seed = seedArg
|
|
358
362
|
if (negativePrompt !== undefined) body.negative_prompt = negativePrompt
|
|
359
363
|
if (guidanceScale !== undefined) body.guidance_scale = guidanceScale
|
|
360
364
|
if (format !== 'png') body.output_format = format
|
|
365
|
+
if (isEdit) {
|
|
366
|
+
body.image_url = `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`
|
|
367
|
+
if (mask && mask.bytes) {
|
|
368
|
+
body.mask_url = `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}`
|
|
369
|
+
}
|
|
370
|
+
if (strength !== undefined) {
|
|
371
|
+
body.strength = strength
|
|
372
|
+
}
|
|
373
|
+
}
|
|
361
374
|
|
|
362
|
-
const submit = await submitJob(fetchImpl, cfg.baseURL,
|
|
375
|
+
const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
|
|
363
376
|
const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
|
|
364
377
|
const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
|
|
365
378
|
|
|
@@ -517,6 +530,8 @@ export function makeProviders(deps, job) {
|
|
|
517
530
|
const kind = cfg.localKind === 'a1111' ? 'a1111' : 'comfyui'
|
|
518
531
|
|
|
519
532
|
if (kind === 'a1111') {
|
|
533
|
+
const isImg2Img = Boolean(source && source.bytes)
|
|
534
|
+
const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
|
|
520
535
|
const body = {
|
|
521
536
|
prompt: promptArg,
|
|
522
537
|
negative_prompt: negativePrompt,
|
|
@@ -526,8 +541,15 @@ export function makeProviders(deps, job) {
|
|
|
526
541
|
cfg_scale: cfg.localCfg ?? 7,
|
|
527
542
|
seed: seedArg ?? -1,
|
|
528
543
|
}
|
|
544
|
+
if (isImg2Img) {
|
|
545
|
+
body.init_images = [Buffer.from(source.bytes).toString('base64')]
|
|
546
|
+
body.denoising_strength = strength ?? (mask ? 0.75 : 0.35)
|
|
547
|
+
if (mask && mask.bytes) {
|
|
548
|
+
body.mask = Buffer.from(mask.bytes).toString('base64')
|
|
549
|
+
}
|
|
550
|
+
}
|
|
529
551
|
if (cfg.localModel) body.override_settings = { sd_model_checkpoint: cfg.localModel }
|
|
530
|
-
const res = await fetchImpl(
|
|
552
|
+
const res = await fetchImpl(endpoint, {
|
|
531
553
|
method: 'POST',
|
|
532
554
|
headers: { 'Content-Type': 'application/json' },
|
|
533
555
|
body: JSON.stringify(body),
|
|
@@ -680,6 +702,11 @@ export function makeProviders(deps, job) {
|
|
|
680
702
|
aspect_ratio: aspectPixels ? `${aspectPixels[0]}:${aspectPixels[1]}` : (ASPECT_RATIOS[size] || '1:1'),
|
|
681
703
|
seed: seedArg,
|
|
682
704
|
...(negativePrompt ? { negative_prompt: negativePrompt } : {}),
|
|
705
|
+
...(source && source.bytes ? {
|
|
706
|
+
image: `data:${source.mediaType || 'image/png'};base64,${Buffer.from(source.bytes).toString('base64')}`,
|
|
707
|
+
...(mask && mask.bytes ? { mask: `data:${mask.mediaType || 'image/png'};base64,${Buffer.from(mask.bytes).toString('base64')}` } : {}),
|
|
708
|
+
...(strength !== undefined ? { prompt_strength: strength } : {}),
|
|
709
|
+
} : {}),
|
|
683
710
|
},
|
|
684
711
|
}
|
|
685
712
|
const res = await fetchImpl(`https://api.replicate.com/v1/models/${model}/predictions`, {
|
|
@@ -935,3 +962,47 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
|
|
|
935
962
|
const bytes = Buffer.from(await dl.arrayBuffer())
|
|
936
963
|
return { bytes, mediaType: 'image/png', width: img.width || 0, height: img.height || 0, sourceUrl: img.url }
|
|
937
964
|
}
|
|
965
|
+
|
|
966
|
+
/** Прямой вызов редактирования (inpainting / img2img) через выбранного провайдера. */
|
|
967
|
+
export async function editImageDirect(deps, job) {
|
|
968
|
+
const providers = makeProviders(deps, job)
|
|
969
|
+
const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
|
|
970
|
+
const fn = providers[providerKey] || providers.fal || providers.custom
|
|
971
|
+
if (!fn) throw new Error(`Provider "${providerKey}" does not support image editing`)
|
|
972
|
+
return fn(job.seed, job.prompt)
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** Прямой вызов вариаций изображения через выбранного провайдера. */
|
|
976
|
+
export async function varyImageDirect(deps, job) {
|
|
977
|
+
const providers = makeProviders(deps, {
|
|
978
|
+
...job,
|
|
979
|
+
strength: job.variationStrength ?? job.strength ?? 0.35,
|
|
980
|
+
})
|
|
981
|
+
const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
|
|
982
|
+
const fn = providers[providerKey] || providers.fal || providers.custom
|
|
983
|
+
if (!fn) throw new Error(`Provider "${providerKey}" does not support image variations`)
|
|
984
|
+
return fn(job.seed, job.prompt)
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* Recursively removes any property with an undefined value so the result satisfies
|
|
989
|
+
* DSH lossless JSON requirements (dsh-util-values isJsonValue).
|
|
990
|
+
*
|
|
991
|
+
* @param {any} val
|
|
992
|
+
* @returns {any}
|
|
993
|
+
*/
|
|
994
|
+
export function toLosslessJson(val) {
|
|
995
|
+
if (val === null || typeof val === 'boolean' || typeof val === 'string') return val
|
|
996
|
+
if (typeof val === 'number') return Number.isFinite(val) && !Object.is(val, -0) ? val : null
|
|
997
|
+
if (Array.isArray(val)) return val.map(toLosslessJson)
|
|
998
|
+
if (typeof val === 'object' && val !== null) {
|
|
999
|
+
const res = {}
|
|
1000
|
+
for (const [k, v] of Object.entries(val)) {
|
|
1001
|
+
if (v !== undefined) {
|
|
1002
|
+
res[k] = toLosslessJson(v)
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
return res
|
|
1006
|
+
}
|
|
1007
|
+
return null
|
|
1008
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { readFile } from 'node:fs/promises'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import { estimateSharpnessAndVariance } from './providers.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Scan active session messages in reverse order to find the latest image attachment or file ref.
|
|
7
|
+
*
|
|
8
|
+
* @param {object} exec - Tool execution context provided by DSH
|
|
9
|
+
* @returns {{ ref: string, name?: string } | undefined}
|
|
10
|
+
*/
|
|
11
|
+
export function findLastConversationImage(exec) {
|
|
12
|
+
if (!exec || !exec.agent || !exec.agent.session) return undefined
|
|
13
|
+
const session = exec.agent.session
|
|
14
|
+
const messages = typeof session.deriveMessages === 'function'
|
|
15
|
+
? session.deriveMessages()
|
|
16
|
+
: Array.isArray(session.messages) ? session.messages : []
|
|
17
|
+
|
|
18
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
19
|
+
const msg = messages[i]
|
|
20
|
+
if (!msg) continue
|
|
21
|
+
|
|
22
|
+
// 1. Check direct attachments array on message
|
|
23
|
+
if (Array.isArray(msg.attachments)) {
|
|
24
|
+
for (const att of msg.attachments) {
|
|
25
|
+
if (att && (att.mediaType?.startsWith('image/') || att.attachmentId?.startsWith('sha256:') || att.id?.startsWith('sha256:'))) {
|
|
26
|
+
return {
|
|
27
|
+
ref: att.attachmentId || att.id || att.ref,
|
|
28
|
+
name: att.name || att.filename || 'previous-image',
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// 2. Check content blocks
|
|
35
|
+
if (Array.isArray(msg.content)) {
|
|
36
|
+
for (const block of msg.content) {
|
|
37
|
+
if (!block) continue
|
|
38
|
+
if (block.type === 'image' && block.source) {
|
|
39
|
+
const ref = block.source.attachmentId || block.source.id || block.source.data || block.source.url
|
|
40
|
+
if (ref) return { ref, name: 'conversation-image' }
|
|
41
|
+
}
|
|
42
|
+
if (block.type === 'tool_result' && block.content) {
|
|
43
|
+
// Look for file path or attachment reference in tool results
|
|
44
|
+
const str = typeof block.content === 'string' ? block.content : JSON.stringify(block.content)
|
|
45
|
+
const shaMatch = str.match(/sha256:[a-f0-9]{64}/i)
|
|
46
|
+
if (shaMatch) return { ref: shaMatch[0], name: 'tool-image' }
|
|
47
|
+
const fileMatch = str.match(/(?:File|Path|Saved to):\s*[`"']?([^\r\n`"']+\.(?:png|jpe?g|webp|svg))[`"']?/i)
|
|
48
|
+
if (fileMatch) return { ref: fileMatch[1].trim(), name: path.basename(fileMatch[1].trim()) }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// 3. Check metadata fields
|
|
54
|
+
if (msg.metadata) {
|
|
55
|
+
const metaImg = msg.metadata.attachmentId || msg.metadata.image || msg.metadata.outputFile
|
|
56
|
+
if (metaImg && typeof metaImg === 'string') {
|
|
57
|
+
return { ref: metaImg, name: 'metadata-image' }
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return undefined
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Determine MIME type by extension or signature.
|
|
67
|
+
*/
|
|
68
|
+
export function detectMediaType(filePath, buffer) {
|
|
69
|
+
if (buffer && buffer.length >= 8) {
|
|
70
|
+
// PNG signature: 89 50 4E 47 0D 0A 1A 0A
|
|
71
|
+
if (buffer[0] === 0x89 && buffer[1] === 0x50 && buffer[2] === 0x4E && buffer[3] === 0x47) return 'image/png'
|
|
72
|
+
// JPEG signature: FF D8 FF
|
|
73
|
+
if (buffer[0] === 0xFF && buffer[1] === 0xD8 && buffer[2] === 0xFF) return 'image/jpeg'
|
|
74
|
+
// WebP signature: RIFF ... WEBP
|
|
75
|
+
if (buffer[0] === 0x52 && buffer[1] === 0x49 && buffer[2] === 0x46 && buffer[3] === 0x46 &&
|
|
76
|
+
buffer[8] === 0x57 && buffer[9] === 0x45 && buffer[10] === 0x42 && buffer[11] === 0x50) return 'image/webp'
|
|
77
|
+
// SVG xml/svg check
|
|
78
|
+
const head = buffer.slice(0, 100).toString('utf8').toLowerCase()
|
|
79
|
+
if (head.includes('<svg') || head.includes('<?xml')) return 'image/svg+xml'
|
|
80
|
+
}
|
|
81
|
+
const ext = path.extname(String(filePath || '')).toLowerCase()
|
|
82
|
+
if (ext === '.jpg' || ext === '.jpeg') return 'image/jpeg'
|
|
83
|
+
if (ext === '.webp') return 'image/webp'
|
|
84
|
+
if (ext === '.svg') return 'image/svg+xml'
|
|
85
|
+
return 'image/png'
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve target image bytes and mediaType, automatically looking up session history if omitted.
|
|
90
|
+
*
|
|
91
|
+
* @param {object} ctx - Cordis context
|
|
92
|
+
* @param {object} exec - Tool execution context
|
|
93
|
+
* @param {string} [targetRef] - User provided path, attachment ID, or 'latest'
|
|
94
|
+
* @returns {Promise<{ bytes: Buffer, mediaType: string, ref: string, name: string }>}
|
|
95
|
+
*/
|
|
96
|
+
export async function resolveConversationImage(ctx, exec, targetRef) {
|
|
97
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd || process.cwd()
|
|
98
|
+
let effectiveRef = typeof targetRef === 'string' ? targetRef.trim() : ''
|
|
99
|
+
|
|
100
|
+
const isImplicit = !effectiveRef ||
|
|
101
|
+
effectiveRef.toLowerCase() === 'latest' ||
|
|
102
|
+
effectiveRef.toLowerCase() === 'last' ||
|
|
103
|
+
effectiveRef.toLowerCase() === 'previous' ||
|
|
104
|
+
effectiveRef.toLowerCase() === 'current'
|
|
105
|
+
|
|
106
|
+
if (isImplicit) {
|
|
107
|
+
const found = findLastConversationImage(exec)
|
|
108
|
+
if (!found || !found.ref) {
|
|
109
|
+
throw new Error(
|
|
110
|
+
'No previous image found in the active conversation session. ' +
|
|
111
|
+
'Please provide an explicit image path or attachment ID.'
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
effectiveRef = found.ref
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// 1. Attachment store resolution
|
|
118
|
+
if (effectiveRef.startsWith('sha256:')) {
|
|
119
|
+
if (!ctx?.attachments?.readImage) {
|
|
120
|
+
throw new Error(`Attachments service unavailable to read ${effectiveRef}`)
|
|
121
|
+
}
|
|
122
|
+
const stored = await ctx.attachments.readImage({
|
|
123
|
+
attachmentId: effectiveRef,
|
|
124
|
+
mediaType: 'image/png',
|
|
125
|
+
bytes: 0,
|
|
126
|
+
width: 0,
|
|
127
|
+
height: 0,
|
|
128
|
+
})
|
|
129
|
+
const bytes = Buffer.from(stored.data || stored.bytes || [])
|
|
130
|
+
return {
|
|
131
|
+
bytes,
|
|
132
|
+
mediaType: stored.ref?.mediaType || detectMediaType('', bytes),
|
|
133
|
+
ref: effectiveRef,
|
|
134
|
+
name: `attachment-${effectiveRef.slice(7, 15)}`,
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 2. Data URI
|
|
139
|
+
if (effectiveRef.startsWith('data:')) {
|
|
140
|
+
const commaIndex = effectiveRef.indexOf(',')
|
|
141
|
+
if (commaIndex > 0) {
|
|
142
|
+
const meta = effectiveRef.slice(0, commaIndex)
|
|
143
|
+
const data = effectiveRef.slice(commaIndex + 1)
|
|
144
|
+
const mediaMatch = meta.match(/data:([^;]+)/)
|
|
145
|
+
const mediaType = mediaMatch ? mediaMatch[1] : 'image/png'
|
|
146
|
+
const bytes = Buffer.from(data, 'base64')
|
|
147
|
+
return {
|
|
148
|
+
bytes,
|
|
149
|
+
mediaType,
|
|
150
|
+
ref: effectiveRef.slice(0, 32) + '...',
|
|
151
|
+
name: 'inline-image',
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// 3. Local filesystem resolution
|
|
157
|
+
const absPath = path.isAbsolute(effectiveRef)
|
|
158
|
+
? effectiveRef
|
|
159
|
+
: path.resolve(sessionCwd, effectiveRef)
|
|
160
|
+
|
|
161
|
+
const bytes = await readFile(absPath)
|
|
162
|
+
const mediaType = detectMediaType(absPath, bytes)
|
|
163
|
+
return {
|
|
164
|
+
bytes,
|
|
165
|
+
mediaType,
|
|
166
|
+
ref: effectiveRef,
|
|
167
|
+
name: path.basename(absPath, path.extname(absPath)),
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Query @goodandready/dsh-vision-bridge or Cordis tools to extract structural scene cues.
|
|
173
|
+
*
|
|
174
|
+
* @param {object} ctx - Cordis context
|
|
175
|
+
* @param {object} exec - Tool execution context
|
|
176
|
+
* @param {{ bytes: Buffer, mediaType: string, ref: string }} source - Source image
|
|
177
|
+
* @returns {Promise<{ available: boolean, summary: string, subjects?: string[], palette?: string }>}
|
|
178
|
+
*/
|
|
179
|
+
export async function analyzeImageWithVision(ctx, exec, source) {
|
|
180
|
+
if (!source || !source.bytes) {
|
|
181
|
+
return { available: false, summary: 'No image data to analyze' }
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Check if dsh-vision-bridge tools exist in runtime
|
|
185
|
+
const inspectTool = ctx?.tools?.get?.('inspect_image') || ctx?.tools?.get?.('describe_image')
|
|
186
|
+
if (inspectTool && typeof inspectTool.execute === 'function') {
|
|
187
|
+
try {
|
|
188
|
+
const res = await inspectTool.execute(
|
|
189
|
+
{
|
|
190
|
+
source: source.ref,
|
|
191
|
+
attachmentIds: source.ref.startsWith('sha256:') ? [source.ref] : undefined,
|
|
192
|
+
paths: !source.ref.startsWith('sha256:') ? [source.ref] : undefined,
|
|
193
|
+
question: 'Summarize in 1 sentence: key subjects, visual style, and dominant colors for image editing.',
|
|
194
|
+
detail: 'low',
|
|
195
|
+
},
|
|
196
|
+
exec,
|
|
197
|
+
)
|
|
198
|
+
const text = res?.description || res?.summary || (typeof res === 'string' ? res : '')
|
|
199
|
+
if (text && text.trim()) {
|
|
200
|
+
return {
|
|
201
|
+
available: true,
|
|
202
|
+
summary: text.trim(),
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
} catch {
|
|
206
|
+
// Vision bridge execution failed or timed out; proceed with local fallback
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Local fallback: analyze sharpness and dimensions without external model call
|
|
211
|
+
const analysis = estimateSharpnessAndVariance(source.bytes)
|
|
212
|
+
const fallbackSummary = analysis.isBlank
|
|
213
|
+
? 'Low-contrast or uniform canvas'
|
|
214
|
+
: `Detected visual asset (${source.mediaType}, sharpness: ${analysis.score})`
|
|
215
|
+
|
|
216
|
+
return {
|
|
217
|
+
available: false,
|
|
218
|
+
summary: fallbackSummary,
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
/**
|
|
222
|
+
* Форматирует структурированный текстовый отчёт для безопасного возврата в LLM (#150 Dual-Output).
|
|
223
|
+
*/
|
|
224
|
+
export function buildDualOutputMarkdown({
|
|
225
|
+
action = 'generated',
|
|
226
|
+
filePath,
|
|
227
|
+
width,
|
|
228
|
+
height,
|
|
229
|
+
mediaType,
|
|
230
|
+
seed,
|
|
231
|
+
provider,
|
|
232
|
+
model,
|
|
233
|
+
cost,
|
|
234
|
+
attachmentId,
|
|
235
|
+
}) {
|
|
236
|
+
return `✅ **Image ${action} successfully**\n`
|
|
237
|
+
+ `- **File:** \`${filePath}\`\n`
|
|
238
|
+
+ `- **Dimensions:** ${width}x${height} (${mediaType || 'image/png'})\n`
|
|
239
|
+
+ `- **Seed:** ${seed ?? 'N/A'} | **Provider:** ${provider} (${model})\n`
|
|
240
|
+
+ (cost !== undefined ? `- **Cost:** $${Number(cost).toFixed(4)}\n` : '')
|
|
241
|
+
+ `- **Attachment ID:** \`${attachmentId}\`\n\n`
|
|
242
|
+
+ `*(Interactive visual card rendered in conversation UI)*`
|
|
243
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.6",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — the FAL queue, any OpenAI-compatible images API, or a ChatGPT/Grok subscription with no API key at all. The picture is shown inline in the conversation; the model receives either a link (works with any chat model) or the image itself (needs dsh-vision-bridge or a vision-capable model).",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"deepseek-harness",
|