@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/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, cfg.model, key, body, signal)
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(`${base}/sdapi/v1/txt2img`, {
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.4",
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",