@goodandready/dsh-image-gen 0.11.22 → 0.11.23

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.
@@ -186,9 +186,9 @@ export const Config = z.object({
186
186
  .default('GEMINI_API_KEY')
187
187
  .volatile(),
188
188
  localKind: z
189
- .union([z.const('automatic1111'), z.const('a1111'), z.const('comfyui')])
190
- .description('Local generation backend kind: "automatic1111" / "a1111" (WebUI) or "comfyui".')
191
- .default('automatic1111')
189
+ .union([z.const('a1111'), z.const('comfyui'), z.const('automatic1111')])
190
+ .description('Local generation backend kind: "a1111" (WebUI) or "comfyui".')
191
+ .default('a1111')
192
192
  .volatile(),
193
193
  localBaseURL: z
194
194
  .string()
@@ -351,6 +351,9 @@ export function sanitizeRaw(raw) {
351
351
  }
352
352
  cleaned[k] = v
353
353
  }
354
+ if (cleaned.localKind === 'automatic1111') {
355
+ cleaned.localKind = 'a1111'
356
+ }
354
357
  return cleaned
355
358
  }
356
359
 
@@ -23,16 +23,27 @@ export function createCustomGenerator(deps, job) {
23
23
 
24
24
  // Empty key reference indicates unauthenticated gateway.
25
25
  const key = cfg.customKeyEnv ? await resolveKey(cfg.customKeyEnv) : ''
26
- const headers = { 'Content-Type': 'application/json' }
26
+ const isEdit = Boolean(source && source.bytes)
27
+ const headers = {}
28
+ if (!isEdit) {
29
+ headers['Content-Type'] = 'application/json'
30
+ }
27
31
  if (key) headers.Authorization = `Bearer ${key}`
28
32
 
29
33
  // Omit response_format; modern models return either base64 or URL.
30
- const endpoint = buildEndpointUrl(base, source ? 'images/edits' : 'images/generations')
34
+ const endpoint = buildEndpointUrl(base, isEdit ? 'images/edits' : 'images/generations')
31
35
  const res = await fetchImpl(endpoint, {
32
36
  method: 'POST',
33
37
  headers,
34
- body: source
35
- ? buildEditForm({ source, mask, prompt: promptArg, size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size, strength })
38
+ body: isEdit
39
+ ? buildEditForm({
40
+ model: cfg.customModel,
41
+ source,
42
+ mask,
43
+ prompt: promptArg,
44
+ size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
45
+ strength,
46
+ })
36
47
  : JSON.stringify({
37
48
  model: cfg.customModel,
38
49
  prompt: promptArg,
@@ -24,7 +24,14 @@ export function createLocalGenerator(deps, job) {
24
24
  const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
25
25
  if (!base) throw new Error('Local image provider: server address is not configured (Settings → Image generation)')
26
26
  const [width, height] = pxSize(aspectPixels, size)
27
- const kind = (cfg.localKind === 'a1111' || cfg.localKind === 'automatic1111') ? 'a1111' : 'comfyui'
27
+ const rawKind = cfg.localKind || 'a1111'
28
+ const kind = (rawKind === 'a1111' || rawKind === 'automatic1111')
29
+ ? 'a1111'
30
+ : (rawKind === 'comfyui' ? 'comfyui' : null)
31
+
32
+ if (!kind) {
33
+ throw new Error(`Unknown local generation backend kind "${rawKind}". Expected "a1111" or "comfyui".`)
34
+ }
28
35
 
29
36
  if (kind === 'a1111') {
30
37
  const isImg2Img = Boolean(source && source.bytes)
@@ -86,8 +86,9 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
86
86
  }
87
87
  }
88
88
 
89
- export function buildEditForm({ source, mask, prompt, size, strength }) {
89
+ export function buildEditForm({ source, mask, prompt, size, strength, model }) {
90
90
  const form = new FormData()
91
+ if (model) form.append('model', model)
91
92
  form.append('image', new Blob([source.bytes], { type: source.mediaType || 'image/png' }), 'source.png')
92
93
  if (mask) form.append('mask', new Blob([mask.bytes], { type: mask.mediaType || 'image/png' }), 'mask.png')
93
94
  form.append('prompt', prompt)
@@ -1,5 +1,5 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // character-sheet.js — generate_character_sheet tool (#181).
1
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
2
+ // character-sheet.js — generate_character_sheet tool (#181, #358).
3
3
  // Generates multi-angle character model sheets and turnarounds with visual consistency.
4
4
 
5
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -7,10 +7,12 @@ import path from 'node:path'
7
7
  import { mkdir, writeFile } from 'node:fs/promises'
8
8
  import {
9
9
  ASPECT_RATIOS,
10
+ PROVIDER_KEYS,
10
11
  buildSidecar,
11
12
  makeProviders,
12
13
  toLosslessJson,
13
14
  saveAttachmentSafe,
15
+
14
16
  } from '../providers.js'
15
17
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
16
18
  import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
@@ -24,7 +26,7 @@ import {
24
26
  export function registerCharacterSheetTools(ctx, deps) {
25
27
  const {
26
28
  live,
27
- slugify,
29
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
28
30
  resolveApiKey,
29
31
  } = deps
30
32
 
@@ -78,6 +80,8 @@ export function registerCharacterSheetTools(ctx, deps) {
78
80
  },
79
81
  seed: { type: 'integer' },
80
82
  provider: { type: 'string' },
83
+ path: { type: 'string' },
84
+ url: { type: 'string' },
81
85
  attachment: {
82
86
  type: 'object',
83
87
  additionalProperties: true,
@@ -89,13 +93,24 @@ export function registerCharacterSheetTools(ctx, deps) {
89
93
  byteSize: { type: 'integer' },
90
94
  },
91
95
  },
96
+ _fallback: {
97
+ type: 'object',
98
+ additionalProperties: true,
99
+ },
92
100
  },
93
101
  },
94
102
  render: (_args, value) => renderToolOutput(value),
95
103
  },
96
104
  async execute(args, execCtx) {
97
- const cfg = live.value
98
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
105
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
106
+ if (cfg.enabled === false) {
107
+ throw new Error('Image generation is disabled in settings.')
108
+ }
109
+
110
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
111
+ ? (args.provider || cfg.provider)
112
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
113
+
99
114
  const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
100
115
 
101
116
  const sheetPlan = buildCharacterSheetPrompt({
@@ -105,73 +120,98 @@ export function registerCharacterSheetTools(ctx, deps) {
105
120
  negativePrompt: args.negativePrompt,
106
121
  })
107
122
 
108
- const providers = makeProviders({
109
- fetchImpl: fetch,
110
- resolveKey: (id) => resolveApiKey(cfg, id),
111
- cfg,
112
- })
123
+ const aspectPixels = ASPECT_RATIOS[sheetPlan.aspectRatio] || ASPECT_RATIOS['16:9']
113
124
 
114
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
125
+ let subscriptionImages
126
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
127
+
128
+ const providers = makeProviders(
129
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
130
+ {
131
+ prompt: sheetPlan.prompt,
132
+ negativePrompt: sheetPlan.negativePrompt,
133
+ size: 'custom',
134
+ format: 'png',
135
+ seed,
136
+ signal: execCtx?.signal,
137
+ aspectPixels,
138
+ aspectRatio: sheetPlan.aspectRatio,
139
+ quality: 'high',
140
+ },
141
+ )
115
142
 
116
- const aspectPixels = ASPECT_RATIOS[sheetPlan.aspectRatio] || ASPECT_RATIOS['16:9']
143
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
117
144
 
118
145
  const estimatedCost = calculateGenerationCost({
119
- provider: activeProvider,
146
+ provider,
120
147
  model: cfg.model || cfg.customModel || 'default',
121
148
  size: cfg.defaultSize || '1024x1024',
122
149
  count: 1,
123
150
  })
124
151
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
125
152
 
126
- const jobPayload = {
127
- prompt: sheetPlan.prompt,
128
- negativePrompt: sheetPlan.negativePrompt,
129
- aspectRatio: sheetPlan.aspectRatio,
130
- aspectPixels,
131
- format: 'png',
132
- seed,
133
- quality: 'high',
134
- }
135
-
136
- const { result: genResult, provider: usedProvider } = await executeWithFallback(
137
- fallbackChain,
153
+ const gen = await executeWithFallback(
138
154
  providers,
139
- jobPayload,
140
- { signal: execCtx?.signal }
155
+ fallbackChain,
156
+ seed,
157
+ sheetPlan.prompt,
158
+ { logger: ctx.logger },
141
159
  )
142
160
 
161
+ const usedProvider = gen._fallback?.providerUsed || provider
162
+ const finalBytes = gen.bytes
143
163
  const timestamp = Date.now()
144
164
  const safeSlug = slugify(args.prompt.slice(0, 30)) || 'character-sheet'
145
165
  const filename = `character-sheet-${sheetPlan.layout}-${safeSlug}-${timestamp}.png`
146
166
 
147
167
  let attachment = null
148
- if (genResult.bytes) {
149
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
168
+ let localUrl = ''
169
+ let diskPath = ''
170
+ if (finalBytes) {
171
+ const saveRes = await saveAttachmentSafe(ctx, {
172
+ bytes: finalBytes,
173
+ mediaType: 'image/png',
174
+ name: filename,
175
+ })
176
+ attachment = saveRes?.attachment || null
177
+ localUrl = saveRes?.localUrl || ''
178
+
179
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
180
+ const targetDir = cfg.outputDir || 'generated/images'
181
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
150
182
  await mkdir(outDir, { recursive: true })
151
- const diskPath = path.join(outDir, filename)
152
- await writeFile(diskPath, Buffer.from(genResult.bytes))
153
-
154
- attachment = await saveAttachmentSafe(
155
- ctx,
156
- Buffer.from(genResult.bytes),
157
- 'image/png',
158
- filename,
159
- diskPath
160
- )
161
- } else if (genResult.url) {
183
+ diskPath = path.join(outDir, filename)
184
+ await writeFile(diskPath, Buffer.from(finalBytes))
185
+ } else if (gen.url) {
162
186
  attachment = {
163
- url: genResult.url,
187
+ url: gen.url,
164
188
  mediaType: 'image/png',
165
189
  filename,
166
190
  }
191
+ localUrl = gen.url
167
192
  }
168
193
 
169
- recordSpend(Number(genResult.cost) || estimatedCost, {
194
+ const effectiveCost = Number(gen.cost) || estimatedCost
195
+ recordSpend(effectiveCost, {
170
196
  ctx,
171
197
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: sheetPlan.prompt, seed },
172
198
  })
173
199
 
200
+ const dualOutput = buildDualOutputMarkdown({
201
+ action: 'generated',
202
+ filePath: diskPath || filename,
203
+ width: aspectPixels[0],
204
+ height: aspectPixels[1],
205
+ mediaType: 'image/png',
206
+ seed,
207
+ provider: usedProvider,
208
+ model: cfg.model || cfg.customModel || 'character-sheet',
209
+ cost: gen.cost,
210
+ attachmentId: attachment?.attachmentId,
211
+ })
212
+
174
213
  const response = {
214
+ summary: dualOutput,
175
215
  prompt: sheetPlan.prompt,
176
216
  characterDescription: args.prompt,
177
217
  layout: sheetPlan.layout,
@@ -181,16 +221,13 @@ export function registerCharacterSheetTools(ctx, deps) {
181
221
  seed,
182
222
  provider: usedProvider,
183
223
  attachment,
224
+ path: diskPath,
225
+ url: localUrl,
226
+ attachmentId: attachment?.attachmentId,
227
+ _fallback: gen._fallback,
184
228
  }
185
229
 
186
- if (attachment?.attachmentId) {
187
- response.attachmentId = attachment.attachmentId
188
- }
189
- if (attachment?.url) {
190
- response.url = attachment.url
191
- }
192
-
193
- return response
230
+ return toLosslessJson(response)
194
231
  },
195
232
  })
196
233
  )
@@ -1,5 +1,4 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // diagram.js — beautify_diagram tool (#185).
1
+ // diagram.js — beautify_diagram tool (#185, #359).
3
2
  // Transforms raw Mermaid / Graphviz diagrams into high-end 3D isometric architectural illustrations.
4
3
 
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -7,9 +6,12 @@ import path from 'node:path'
7
6
  import { mkdir, writeFile } from 'node:fs/promises'
8
7
  import {
9
8
  ASPECT_RATIOS,
9
+ PROVIDER_KEYS,
10
10
  makeProviders,
11
+ toLosslessJson,
11
12
  saveAttachmentSafe,
12
13
  } from '../providers.js'
14
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
13
15
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
14
16
  import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
15
17
  import { renderToolOutput } from '../attachment-helper.js'
@@ -21,7 +23,7 @@ import {
21
23
  export function registerDiagramTools(ctx, deps) {
22
24
  const {
23
25
  live,
24
- slugify,
26
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
25
27
  resolveApiKey,
26
28
  } = deps
27
29
 
@@ -75,6 +77,8 @@ export function registerDiagramTools(ctx, deps) {
75
77
  prompt: { type: 'string' },
76
78
  seed: { type: 'integer' },
77
79
  provider: { type: 'string' },
80
+ path: { type: 'string' },
81
+ url: { type: 'string' },
78
82
  attachment: {
79
83
  type: 'object',
80
84
  additionalProperties: true,
@@ -85,13 +89,24 @@ export function registerDiagramTools(ctx, deps) {
85
89
  filename: { type: 'string' },
86
90
  },
87
91
  },
92
+ _fallback: {
93
+ type: 'object',
94
+ additionalProperties: true,
95
+ },
88
96
  },
89
97
  },
90
98
  render: (_args, value) => renderToolOutput(value),
91
99
  },
92
100
  async execute(args, execCtx) {
93
- const cfg = live?.value || (typeof live === 'function' ? live() : {})
94
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
101
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
102
+ if (cfg.enabled === false) {
103
+ throw new Error('Image generation is disabled in settings.')
104
+ }
105
+
106
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
107
+ ? (args.provider || cfg.provider)
108
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
109
+
95
110
  const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
96
111
 
97
112
  const plan = buildDiagramIllustrationPrompt({
@@ -101,70 +116,98 @@ export function registerDiagramTools(ctx, deps) {
101
116
  aspectRatio: args.aspect_ratio || '16:9',
102
117
  })
103
118
 
104
- const providers = makeProviders({
105
- fetchImpl: fetch,
106
- resolveKey: (id) => resolveApiKey(cfg, id),
107
- cfg,
108
- })
109
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
110
119
  const aspectPixels = ASPECT_RATIOS[plan.aspectRatio] || ASPECT_RATIOS['16:9']
111
120
 
121
+ let subscriptionImages
122
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
123
+
124
+ const providers = makeProviders(
125
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
126
+ {
127
+ prompt: plan.prompt,
128
+ negativePrompt: plan.negativePrompt,
129
+ size: 'custom',
130
+ format: 'png',
131
+ seed,
132
+ signal: execCtx?.signal,
133
+ aspectPixels,
134
+ aspectRatio: plan.aspectRatio,
135
+ quality: 'high',
136
+ },
137
+ )
138
+
139
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
140
+
112
141
  const estimatedCost = calculateGenerationCost({
113
- provider: activeProvider,
142
+ provider,
114
143
  model: cfg.model || cfg.customModel || 'default',
115
144
  size: cfg.defaultSize || '1024x1024',
116
145
  count: 1,
117
146
  })
118
147
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
119
148
 
120
- const jobPayload = {
121
- prompt: plan.prompt,
122
- negativePrompt: plan.negativePrompt,
123
- aspectRatio: plan.aspectRatio,
124
- aspectPixels,
125
- format: 'png',
126
- seed,
127
- quality: 'high',
128
- }
129
-
130
- const { result: genResult, provider: usedProvider } = await executeWithFallback(
131
- fallbackChain,
149
+ const gen = await executeWithFallback(
132
150
  providers,
133
- jobPayload,
134
- { signal: execCtx?.signal }
151
+ fallbackChain,
152
+ seed,
153
+ plan.prompt,
154
+ { logger: ctx.logger },
135
155
  )
136
156
 
137
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
138
- await mkdir(outDir, { recursive: true })
157
+ const usedProvider = gen._fallback?.providerUsed || provider
158
+ const finalBytes = gen.bytes
139
159
  const timestamp = Date.now()
140
160
  const safeSlug = slugify(args.title || 'architecture-diagram') || 'diagram'
141
161
  const filename = `diagram-3d-${safeSlug}-${timestamp}.png`
142
162
 
143
163
  let attachment = null
144
- if (genResult.bytes) {
145
- const diskPath = path.join(outDir, filename)
146
- await writeFile(diskPath, Buffer.from(genResult.bytes))
147
- attachment = await saveAttachmentSafe(
148
- ctx,
149
- Buffer.from(genResult.bytes),
150
- 'image/png',
151
- filename,
152
- diskPath
153
- )
154
- } else if (genResult.url) {
164
+ let localUrl = ''
165
+ let diskPath = ''
166
+ if (finalBytes) {
167
+ const saveRes = await saveAttachmentSafe(ctx, {
168
+ bytes: finalBytes,
169
+ mediaType: 'image/png',
170
+ name: filename,
171
+ })
172
+ attachment = saveRes?.attachment || null
173
+ localUrl = saveRes?.localUrl || ''
174
+
175
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
176
+ const targetDir = cfg.outputDir || 'generated/images'
177
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
178
+ await mkdir(outDir, { recursive: true })
179
+ diskPath = path.join(outDir, filename)
180
+ await writeFile(diskPath, Buffer.from(finalBytes))
181
+ } else if (gen.url) {
155
182
  attachment = {
156
- url: genResult.url,
183
+ url: gen.url,
157
184
  mediaType: 'image/png',
158
185
  filename,
159
186
  }
187
+ localUrl = gen.url
160
188
  }
161
189
 
162
- recordSpend(Number(genResult.cost) || estimatedCost, {
190
+ const effectiveCost = Number(gen.cost) || estimatedCost
191
+ recordSpend(effectiveCost, {
163
192
  ctx,
164
193
  meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: plan.prompt, seed },
165
194
  })
166
195
 
167
- return {
196
+ const dualOutput = buildDualOutputMarkdown({
197
+ action: 'generated',
198
+ filePath: diskPath || filename,
199
+ width: aspectPixels[0],
200
+ height: aspectPixels[1],
201
+ mediaType: 'image/png',
202
+ seed,
203
+ provider: usedProvider,
204
+ model: cfg.model || cfg.customModel || 'beautify-diagram',
205
+ cost: gen.cost,
206
+ attachmentId: attachment?.attachmentId,
207
+ })
208
+
209
+ const response = {
210
+ summary: dualOutput,
168
211
  title: args.title || 'System Architecture',
169
212
  style: plan.style,
170
213
  aspectRatio: plan.aspectRatio,
@@ -173,9 +216,13 @@ export function registerDiagramTools(ctx, deps) {
173
216
  seed,
174
217
  provider: usedProvider,
175
218
  attachment,
176
- url: attachment?.url,
219
+ path: diskPath,
220
+ url: localUrl,
177
221
  attachmentId: attachment?.attachmentId,
222
+ _fallback: gen._fallback,
178
223
  }
224
+
225
+ return toLosslessJson(response)
179
226
  },
180
227
  })
181
228
  )
@@ -1,12 +1,12 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // theme-pair.js — generate_theme_pair tool (#189).
3
- // Synchronized dual generation of light and dark theme visuals with adaptive CSS snippets.
1
+ // theme-pair.js — generate_theme_pair tool (#189, #361).
2
+ // Generates complementary Light & Dark mode asset variants with synchronized compositions.
4
3
 
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
5
  import path from 'node:path'
7
6
  import { mkdir, writeFile } from 'node:fs/promises'
8
7
  import {
9
8
  ASPECT_RATIOS,
9
+ PROVIDER_KEYS,
10
10
  buildSidecar,
11
11
  makeProviders,
12
12
  toLosslessJson,
@@ -24,7 +24,7 @@ import {
24
24
  export function registerThemePairTools(ctx, deps) {
25
25
  const {
26
26
  live,
27
- slugify,
27
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
28
28
  resolveApiKey,
29
29
  } = deps
30
30
 
@@ -33,23 +33,23 @@ export function registerThemePairTools(ctx, deps) {
33
33
  defineTool({
34
34
  name: 'generate_theme_pair',
35
35
  description:
36
- 'Generate synchronized dual illustrations or UI graphics designed specifically for light and dark application themes. '
37
- + 'Produces both light (#FFFFFF clean backdrop) and dark (#0F172A obsidian backdrop with luminous accents) variants with matched composition, plus ready-to-use HTML/CSS responsive markup.',
36
+ 'Generate matching Light and Dark mode UI illustration variants with synchronized seeds, '
37
+ + 'consistent composition, and inverted contrast palettes. Outputs a responsive <picture> tag with prefers-color-scheme.',
38
38
  parameters: {
39
39
  prompt: {
40
40
  type: 'string',
41
+ description: 'Core visual concept description (e.g. "cloud database topology architecture").',
41
42
  required: true,
42
- description: 'Core subject or illustration motif, e.g. "cloud server network topology" or "productivity dashboard banner".',
43
- },
44
- aspect_ratio: {
45
- type: 'string',
46
- enum: Object.keys(ASPECT_RATIOS),
47
- description: 'Aspect ratio for both variants: "1:1", "16:9", "4:3", etc. (default: "16:9").',
48
43
  },
49
44
  style: {
50
45
  type: 'string',
51
46
  enum: THEME_STYLES,
52
- description: 'Visual style aesthetic (default: "minimalist").',
47
+ description: 'Aesthetic visual direction: minimalist, neon_cyberpunk, editorial, gradient_glass, 3d_clay.',
48
+ },
49
+ aspect_ratio: {
50
+ type: 'string',
51
+ enum: ['16:9', '1:1', '4:3', '21:9'],
52
+ description: 'Asset aspect ratio (default: 16:9).',
53
53
  },
54
54
  dark_contrast_boost: {
55
55
  type: 'number',
@@ -63,6 +63,10 @@ export function registerThemePairTools(ctx, deps) {
63
63
  type: 'integer',
64
64
  description: 'Base random seed to synchronize composition between variants.',
65
65
  },
66
+ provider: {
67
+ type: 'string',
68
+ description: 'Optional provider override. Defaults to active provider.',
69
+ },
66
70
  },
67
71
  output: {
68
72
  schema: {
@@ -100,7 +104,7 @@ export function registerThemePairTools(ctx, deps) {
100
104
  },
101
105
  isConcurrencySafe: () => false,
102
106
  async execute(args, exec) {
103
- const cfg = live()
107
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
104
108
  if (cfg.enabled === false) {
105
109
  throw new Error('Image generation is disabled in settings.')
106
110
  }
@@ -121,7 +125,10 @@ export function registerThemePairTools(ctx, deps) {
121
125
  darkContrastBoost,
122
126
  })
123
127
 
124
- const primaryProvider = cfg.provider || 'fal'
128
+ const primaryProvider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
129
+ ? (args.provider || cfg.provider)
130
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
131
+
125
132
  const estimatedCost = calculateGenerationCost({
126
133
  provider: primaryProvider,
127
134
  model: cfg.model || cfg.customModel || 'default',
@@ -130,7 +137,7 @@ export function registerThemePairTools(ctx, deps) {
130
137
  })
131
138
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
132
139
 
133
- const chain = resolveFallbackChain(primaryProvider, cfg.fallbackChain)
140
+ const chain = resolveFallbackChain(primaryProvider, cfg.fallbackProviders, PROVIDER_KEYS)
134
141
  const baseSeed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 1000000)
135
142
 
136
143
  const sessionCwd = exec.agent?.session?.header?.cwd
@@ -141,6 +148,9 @@ export function registerThemePairTools(ctx, deps) {
141
148
  const slug = args.output_name ? slugify(args.output_name) : slugify(prompt).slice(0, 36)
142
149
  const ts = Date.now().toString(36)
143
150
 
151
+ let subscriptionImages
152
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
153
+
144
154
  // 1. Generate Light theme variant
145
155
  const lightJob = {
146
156
  prompt: lightPrompt,
@@ -150,22 +160,26 @@ export function registerThemePairTools(ctx, deps) {
150
160
  seed: baseSeed,
151
161
  signal: exec.signal,
152
162
  }
153
- const lightGen = await executeWithFallback({
163
+ const lightProviders = makeProviders(
164
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
165
+ lightJob,
166
+ )
167
+ const lightGen = await executeWithFallback(
168
+ lightProviders,
154
169
  chain,
155
- liveConfig: cfg,
156
- makeProvidersFn: (deps, job) => makeProviders(deps, job),
157
- resolveApiKeyFn: resolveApiKey,
158
- job: lightJob,
159
- })
170
+ baseSeed,
171
+ lightPrompt,
172
+ { logger: ctx.logger },
173
+ )
160
174
 
161
175
  const lightFilename = `${slug}-light-${ts}.png`
162
176
  const lightPath = path.join(outDir, lightFilename)
163
- await writeFile(lightPath, lightGen.bytes)
177
+ await writeFile(lightPath, Buffer.from(lightGen.bytes))
164
178
 
165
- const lightAttachment = await saveAttachmentSafe(ctx, {
179
+ const { attachment: lightAttachment, localUrl: lightUrl } = await saveAttachmentSafe(ctx, {
166
180
  bytes: lightGen.bytes,
167
181
  mediaType: 'image/png',
168
- filename: lightFilename,
182
+ name: lightFilename,
169
183
  width: lightGen.width || aspectPixels[0],
170
184
  height: lightGen.height || aspectPixels[1],
171
185
  })
@@ -174,7 +188,6 @@ export function registerThemePairTools(ctx, deps) {
174
188
  ctx,
175
189
  meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: lightPrompt },
176
190
  })
177
- const lightUrl = lightAttachment.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(lightAttachment.attachmentId)}` : ''
178
191
 
179
192
  // 2. Generate Dark theme variant (using matched seed)
180
193
  const darkJob = {
@@ -185,22 +198,32 @@ export function registerThemePairTools(ctx, deps) {
185
198
  seed: baseSeed,
186
199
  signal: exec.signal,
187
200
  }
188
- const darkGen = await executeWithFallback({
189
- chain,
190
- liveConfig: cfg,
191
- makeProvidersFn: (deps, job) => makeProviders(deps, job),
192
- resolveApiKeyFn: resolveApiKey,
193
- job: darkJob,
194
- })
201
+ const darkProviders = makeProviders(
202
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
203
+ darkJob,
204
+ )
205
+
206
+ let darkGen
207
+ try {
208
+ darkGen = await executeWithFallback(
209
+ darkProviders,
210
+ chain,
211
+ baseSeed,
212
+ darkPrompt,
213
+ { logger: ctx.logger },
214
+ )
215
+ } catch (darkErr) {
216
+ throw new Error(`Failed to generate dark theme variant: ${darkErr.message}`)
217
+ }
195
218
 
196
219
  const darkFilename = `${slug}-dark-${ts}.png`
197
220
  const darkPath = path.join(outDir, darkFilename)
198
- await writeFile(darkPath, darkGen.bytes)
221
+ await writeFile(darkPath, Buffer.from(darkGen.bytes))
199
222
 
200
- const darkAttachment = await saveAttachmentSafe(ctx, {
223
+ const { attachment: darkAttachment, localUrl: darkUrl } = await saveAttachmentSafe(ctx, {
201
224
  bytes: darkGen.bytes,
202
225
  mediaType: 'image/png',
203
- filename: darkFilename,
226
+ name: darkFilename,
204
227
  width: darkGen.width || aspectPixels[0],
205
228
  height: darkGen.height || aspectPixels[1],
206
229
  })
@@ -208,7 +231,6 @@ export function registerThemePairTools(ctx, deps) {
208
231
  ctx,
209
232
  meta: { provider: primaryProvider, model: cfg.model || cfg.customModel, prompt: darkPrompt },
210
233
  })
211
- const darkUrl = darkAttachment.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(darkAttachment.attachmentId)}` : ''
212
234
 
213
235
  // Sidecars
214
236
  await writeFile(
@@ -220,8 +242,8 @@ export function registerThemePairTools(ctx, deps) {
220
242
  size: `${aspectPixels[0]}x${aspectPixels[1]}`,
221
243
  format: 'png',
222
244
  seed: baseSeed,
223
- light: { path: lightPath, url: lightUrl, attachmentId: lightAttachment.attachmentId },
224
- dark: { path: darkPath, url: darkUrl, attachmentId: darkAttachment.attachmentId },
245
+ light: { path: lightPath, url: lightUrl, attachmentId: lightAttachment?.attachmentId },
246
+ dark: { path: darkPath, url: darkUrl, attachmentId: darkAttachment?.attachmentId },
225
247
  cost: (lightGen.cost || 0) + (darkGen.cost || 0),
226
248
  }), null, 2),
227
249
  )
@@ -266,4 +288,4 @@ export function registerThemePairTools(ctx, deps) {
266
288
  }),
267
289
  )
268
290
  }, 'dsh-image-gen: tool generate_theme_pair')
269
- }
291
+ }
@@ -1,16 +1,18 @@
1
- import { sanitizeErrorAndLogs } from '../security.js'
2
- // vision-ocr.js — replace_image_text tool (#180).
3
- // Seamless text replacement and OCR localization via dsh-vision-bridge & inpainting.
1
+ // vision-ocr.js — replace_image_text tool (#180, #360).
2
+ // Grounding OCR, background inpainting, and typography re-rendering for visual localization.
4
3
 
5
4
  import { defineTool } from '@deepseek-ai/dsh-tools'
6
5
  import path from 'node:path'
7
6
  import { mkdir, writeFile } from 'node:fs/promises'
8
7
  import {
9
- ASPECT_RATIOS,
8
+ PROVIDER_KEYS,
10
9
  makeProviders,
10
+ toLosslessJson,
11
11
  saveAttachmentSafe,
12
12
  } from '../providers.js'
13
+ import { buildDualOutputMarkdown } from '../resolve-image.js'
13
14
  import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
15
+ import { assertBudgetAvailable, calculateGenerationCost, recordSpend } from '../cost-meter.js'
14
16
  import { renderToolOutput } from '../attachment-helper.js'
15
17
  import {
16
18
  normalizeBbox,
@@ -22,7 +24,7 @@ export function registerVisionOcrTools(ctx, deps) {
22
24
  const {
23
25
  live,
24
26
  resolveSource,
25
- slugify,
27
+ slugify = (s) => String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-'),
26
28
  resolveApiKey,
27
29
  } = deps
28
30
 
@@ -31,22 +33,28 @@ export function registerVisionOcrTools(ctx, deps) {
31
33
  defineTool({
32
34
  name: 'replace_image_text',
33
35
  description:
34
- 'Locate text inscriptions on an image via vision OCR, erase them seamlessly via inpainting, '
35
- + 'and render translated or replaced typography precisely at the original coordinates.',
36
+ 'Locate text elements in an image using OCR/vision grounding, inpaint the background to erase them, '
37
+ + 'and render crisp translated/replacement typography over the cleaned regions for visual localization.',
36
38
  parameters: {
37
39
  image: {
38
40
  type: 'string',
39
- description: 'Source image path, URL, or attachment id (sha256:...) containing text to replace.',
41
+ description: 'Source image path, attachment ID, or URL containing text to replace.',
40
42
  },
41
43
  replacements: {
42
44
  type: 'array',
43
- description: 'Array of text replacements: [{ find: "Old Text", replace: "New Text", bbox: [x1,y1,x2,y2] }].',
45
+ description: 'List of text replacement instructions.',
44
46
  items: {
45
47
  type: 'object',
46
48
  additionalProperties: true,
47
49
  properties: {
48
- find: { type: 'string', description: 'Original text to locate' },
49
- replace: { type: 'string', description: 'New replacement text to render' },
50
+ find: {
51
+ type: 'string',
52
+ description: 'Original text string to search for and erase.',
53
+ },
54
+ replace: {
55
+ type: 'string',
56
+ description: 'Replacement text to render in place of the original.',
57
+ },
50
58
  bbox: {
51
59
  type: 'array',
52
60
  items: { type: 'number' },
@@ -75,6 +83,10 @@ export function registerVisionOcrTools(ctx, deps) {
75
83
  type: 'string',
76
84
  description: 'Optional provider override for inpainting. Defaults to active provider.',
77
85
  },
86
+ seed: {
87
+ type: 'integer',
88
+ description: 'Optional seed for reproducible inpainting.',
89
+ },
78
90
  },
79
91
  output: {
80
92
  schema: {
@@ -90,6 +102,9 @@ export function registerVisionOcrTools(ctx, deps) {
90
102
  },
91
103
  },
92
104
  inpaintPrompt: { type: 'string' },
105
+ targetLanguage: { type: 'string' },
106
+ path: { type: 'string' },
107
+ url: { type: 'string' },
93
108
  attachment: {
94
109
  type: 'object',
95
110
  additionalProperties: true,
@@ -108,13 +123,23 @@ export function registerVisionOcrTools(ctx, deps) {
108
123
  type: 'object',
109
124
  additionalProperties: true,
110
125
  },
126
+ _fallback: {
127
+ type: 'object',
128
+ additionalProperties: true,
129
+ },
111
130
  },
112
131
  },
113
132
  render: (_args, value) => renderToolOutput(value),
114
133
  },
115
134
  async execute(args, execCtx) {
116
- const cfg = live?.value || (typeof live === 'function' ? live() : {})
117
- const activeProvider = args.provider || cfg.activeProvider || 'fal'
135
+ const cfg = typeof live === 'function' ? live() : (live?.value || {})
136
+ if (cfg.enabled === false) {
137
+ throw new Error('Image generation is disabled in settings.')
138
+ }
139
+
140
+ const provider = PROVIDER_KEYS.includes(args.provider || cfg.provider)
141
+ ? (args.provider || cfg.provider)
142
+ : (PROVIDER_KEYS.includes(cfg.provider) ? cfg.provider : 'fal')
118
143
 
119
144
  const source = await resolveSource(ctx, execCtx, args.image)
120
145
  if (!source || !source.bytes) {
@@ -148,7 +173,6 @@ export function registerVisionOcrTools(ctx, deps) {
148
173
  }
149
174
  }
150
175
 
151
- // Fallback default bounding box if detection didn't locate exact coordinates
152
176
  const normalizedBox = normalizeBbox(bbox || [200, 400, 800, 500], imgWidth, imgHeight)
153
177
  if (normalizedBox) {
154
178
  bboxes.push(normalizedBox)
@@ -160,12 +184,11 @@ export function registerVisionOcrTools(ctx, deps) {
160
184
  }
161
185
  }
162
186
 
163
- // If no replacements specified, check default center title box
164
187
  if (bboxes.length === 0) {
165
188
  const defaultBox = [150, 420, 850, 520]
166
189
  bboxes.push(defaultBox)
167
190
  overlayItems.push({
168
- text: 'Localized Text',
191
+ text: args.target_language ? `[${args.target_language}] Localized Text` : 'Localized Text',
169
192
  bbox: defaultBox,
170
193
  color: args.font_color || '#FFFFFF',
171
194
  })
@@ -181,12 +204,10 @@ export function registerVisionOcrTools(ctx, deps) {
181
204
 
182
205
  // 4. Inpaint to erase old text
183
206
  const inpaintPrompt = args.inpaint_prompt || 'clean seamless surface, erase text artifacts, coherent background texture'
184
- const providers = makeProviders({
185
- fetchImpl: fetch,
186
- resolveKey: (id) => resolveApiKey(cfg, id),
187
- cfg,
188
- })
189
- const fallbackChain = resolveFallbackChain(activeProvider, cfg)
207
+ const seed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 2147483647)
208
+
209
+ let subscriptionImages
210
+ try { subscriptionImages = ctx.get && ctx.get('subscriptionImages') } catch (_err) { subscriptionImages = undefined }
190
211
 
191
212
  const inpaintJob = {
192
213
  prompt: inpaintPrompt,
@@ -200,77 +221,119 @@ export function registerVisionOcrTools(ctx, deps) {
200
221
  },
201
222
  strength: 0.95,
202
223
  format: 'png',
224
+ seed,
225
+ signal: execCtx?.signal,
226
+ }
227
+
228
+ const providers = makeProviders(
229
+ { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
230
+ inpaintJob,
231
+ )
232
+ const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
233
+
234
+ const estimatedCost = calculateGenerationCost({
235
+ provider,
236
+ model: cfg.model || cfg.customModel || 'default',
237
+ size: `${imgWidth}x${imgHeight}`,
238
+ count: 1,
239
+ })
240
+ assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
241
+
242
+ const gen = await executeWithFallback(
243
+ providers,
244
+ fallbackChain,
245
+ seed,
246
+ inpaintPrompt,
247
+ { logger: ctx.logger },
248
+ )
249
+
250
+ const inpaintedBytes = gen.bytes
251
+ if (!inpaintedBytes) {
252
+ throw new Error('Inpainting failed to return image bytes for text replacement')
203
253
  }
204
254
 
205
- let inpaintedBytes = null
206
- let inpaintedUrl = null
255
+ // 5. Composite typography overlay on inpainted background
256
+ let finalBytes = inpaintedBytes
207
257
  try {
208
- const { result: inpaintResult } = await executeWithFallback(
209
- fallbackChain,
210
- providers,
211
- inpaintJob,
212
- { signal: execCtx?.signal }
213
- )
214
- inpaintedBytes = inpaintResult.bytes
215
- inpaintedUrl = inpaintResult.url
216
- } catch (_err) {
217
- /* non-fatal: inpaint provider unavailable; use source bytes */
218
- inpaintedBytes = source.bytes
258
+ const sharpMod = await import('sharp')
259
+ const sharp = sharpMod.default || sharpMod
260
+ finalBytes = await sharp(inpaintedBytes)
261
+ .composite([{ input: overlayBuffer, top: 0, left: 0 }])
262
+ .png()
263
+ .toBuffer()
264
+ } catch (sharpErr) {
265
+ if (ctx.logger?.warn) ctx.logger.warn(`Sharp compositing skipped: ${sharpErr.message}`)
219
266
  }
220
267
 
221
- const outDir = path.resolve(process.cwd(), cfg.outputDir || 'output')
268
+ const usedProvider = gen._fallback?.providerUsed || provider
269
+ const sessionCwd = execCtx?.agent?.session?.header?.cwd
270
+ const targetDir = cfg.outputDir || 'generated/images'
271
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
222
272
  await mkdir(outDir, { recursive: true })
223
273
  const timestamp = Date.now()
224
- const safeSlug = slugify(args.image.slice(0, 20)) || 'localized-image'
274
+ const safeSlug = slugify(String(args.image || '').slice(0, 20)) || 'localized-image'
225
275
 
226
- let attachment = null
227
- if (inpaintedBytes) {
228
- const filename = `text-replaced-${safeSlug}-${timestamp}.png`
229
- const diskPath = path.join(outDir, filename)
230
- await writeFile(diskPath, Buffer.from(inpaintedBytes))
231
- attachment = await saveAttachmentSafe(
232
- ctx,
233
- Buffer.from(inpaintedBytes),
234
- 'image/png',
235
- filename,
236
- diskPath
237
- )
238
- } else if (inpaintedUrl) {
239
- attachment = {
240
- url: inpaintedUrl,
241
- mediaType: 'image/png',
242
- filename: `text-replaced-${safeSlug}-${timestamp}.png`,
243
- }
244
- }
276
+ const filename = `text-replaced-${safeSlug}-${timestamp}.png`
277
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
278
+ bytes: finalBytes,
279
+ mediaType: 'image/png',
280
+ name: filename,
281
+ })
282
+
283
+ const diskPath = path.join(outDir, filename)
284
+ await writeFile(diskPath, Buffer.from(finalBytes))
245
285
 
246
286
  const maskFilename = `mask-text-${safeSlug}-${timestamp}.svg`
247
- const maskAttachment = await saveAttachmentSafe(
248
- ctx,
249
- maskBuffer,
250
- 'image/svg+xml',
251
- maskFilename,
252
- path.join(outDir, maskFilename)
253
- )
287
+ const { attachment: maskAttachment } = await saveAttachmentSafe(ctx, {
288
+ bytes: maskBuffer,
289
+ mediaType: 'image/svg+xml',
290
+ name: maskFilename,
291
+ })
254
292
 
255
293
  const overlayFilename = `overlay-text-${safeSlug}-${timestamp}.svg`
256
- const overlayAttachment = await saveAttachmentSafe(
294
+ const { attachment: overlayAttachment } = await saveAttachmentSafe(ctx, {
295
+ bytes: overlayBuffer,
296
+ mediaType: 'image/svg+xml',
297
+ name: overlayFilename,
298
+ })
299
+
300
+ const effectiveCost = Number(gen.cost) || estimatedCost
301
+ recordSpend(effectiveCost, {
257
302
  ctx,
258
- overlayBuffer,
259
- 'image/svg+xml',
260
- overlayFilename,
261
- path.join(outDir, overlayFilename)
262
- )
303
+ meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: inpaintPrompt, seed },
304
+ })
263
305
 
264
- return {
306
+ const dualOutput = buildDualOutputMarkdown({
307
+ action: 'generated',
308
+ filePath: diskPath,
309
+ width: imgWidth,
310
+ height: imgHeight,
311
+ mediaType: 'image/png',
312
+ seed,
313
+ provider: usedProvider,
314
+ model: cfg.model || cfg.customModel || 'replace-image-text',
315
+ cost: gen.cost,
316
+ attachmentId: attachment?.attachmentId,
317
+ })
318
+
319
+ const response = {
320
+ summary: dualOutput,
265
321
  replacedCount: overlayItems.length,
266
322
  bboxes,
267
323
  inpaintPrompt,
324
+ targetLanguage: args.target_language || '',
325
+ seed,
326
+ provider: usedProvider,
327
+ path: diskPath,
328
+ url: localUrl,
268
329
  attachment,
269
330
  maskAttachment,
270
331
  overlayAttachment,
271
- url: attachment?.url,
272
332
  attachmentId: attachment?.attachmentId,
333
+ _fallback: gen._fallback,
273
334
  }
335
+
336
+ return toLosslessJson(response)
274
337
  },
275
338
  })
276
339
  )
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.22",
3
+ "version": "0.11.23",
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",