@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.
- package/lib/config-schema.js +6 -3
- package/lib/providers/backends/custom.js +15 -4
- package/lib/providers/backends/local.js +8 -1
- package/lib/providers/shared-helpers.js +2 -1
- package/lib/tools/character-sheet.js +87 -50
- package/lib/tools/diagram.js +90 -43
- package/lib/tools/theme-pair.js +62 -40
- package/lib/tools/vision-ocr.js +134 -71
- package/package.json +1 -1
package/lib/config-schema.js
CHANGED
|
@@ -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('
|
|
190
|
-
.description('Local generation backend kind: "
|
|
191
|
-
.default('
|
|
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
|
|
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,
|
|
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:
|
|
35
|
-
? buildEditForm({
|
|
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
|
|
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 {
|
|
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
|
|
98
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
143
|
+
const fallbackChain = resolveFallbackChain(provider, cfg.fallbackProviders, PROVIDER_KEYS)
|
|
117
144
|
|
|
118
145
|
const estimatedCost = calculateGenerationCost({
|
|
119
|
-
provider
|
|
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
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
149
|
-
|
|
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
|
-
|
|
152
|
-
await writeFile(diskPath, Buffer.from(
|
|
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:
|
|
187
|
+
url: gen.url,
|
|
164
188
|
mediaType: 'image/png',
|
|
165
189
|
filename,
|
|
166
190
|
}
|
|
191
|
+
localUrl = gen.url
|
|
167
192
|
}
|
|
168
193
|
|
|
169
|
-
|
|
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
|
-
|
|
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
|
)
|
package/lib/tools/diagram.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
|
-
|
|
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 =
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
151
|
+
fallbackChain,
|
|
152
|
+
seed,
|
|
153
|
+
plan.prompt,
|
|
154
|
+
{ logger: ctx.logger },
|
|
135
155
|
)
|
|
136
156
|
|
|
137
|
-
const
|
|
138
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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:
|
|
183
|
+
url: gen.url,
|
|
157
184
|
mediaType: 'image/png',
|
|
158
185
|
filename,
|
|
159
186
|
}
|
|
187
|
+
localUrl = gen.url
|
|
160
188
|
}
|
|
161
189
|
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
)
|
package/lib/tools/theme-pair.js
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
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
|
|
37
|
-
+ '
|
|
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: '
|
|
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 =
|
|
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.
|
|
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
|
|
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
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
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
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
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
|
-
|
|
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
|
|
224
|
-
dark: { path: darkPath, url: darkUrl, attachmentId: darkAttachment
|
|
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
|
+
}
|
package/lib/tools/vision-ocr.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
|
|
2
|
-
//
|
|
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
|
-
|
|
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
|
|
35
|
-
+ 'and render translated
|
|
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,
|
|
41
|
+
description: 'Source image path, attachment ID, or URL containing text to replace.',
|
|
40
42
|
},
|
|
41
43
|
replacements: {
|
|
42
44
|
type: 'array',
|
|
43
|
-
description: '
|
|
45
|
+
description: 'List of text replacement instructions.',
|
|
44
46
|
items: {
|
|
45
47
|
type: 'object',
|
|
46
48
|
additionalProperties: true,
|
|
47
49
|
properties: {
|
|
48
|
-
find: {
|
|
49
|
-
|
|
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 =
|
|
117
|
-
|
|
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
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
-
|
|
206
|
-
let
|
|
255
|
+
// 5. Composite typography overlay on inpainted background
|
|
256
|
+
let finalBytes = inpaintedBytes
|
|
207
257
|
try {
|
|
208
|
-
const
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
overlayFilename,
|
|
261
|
-
path.join(outDir, overlayFilename)
|
|
262
|
-
)
|
|
303
|
+
meta: { provider: usedProvider, model: cfg.model || cfg.customModel, prompt: inpaintPrompt, seed },
|
|
304
|
+
})
|
|
263
305
|
|
|
264
|
-
|
|
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.
|
|
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",
|