@goodandready/dsh-image-gen 0.10.23 → 0.10.24

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
@@ -1,239 +1,54 @@
1
1
  /**
2
2
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
3
3
  */
4
- export function resolveApiKeyCandidates(ref) {
5
- if (!ref) return []
6
- const candidates = [ref]
7
- if (ref === 'FAL_API_KEY') candidates.push('FAL_KEY')
8
- else if (ref === 'FAL_KEY') candidates.push('FAL_API_KEY')
9
- return candidates
10
- }
11
-
12
-
13
- export const PROVIDER_MAX_COUNTS = {
14
- fal: 4,
15
- replicate: 4,
16
- custom: 10,
17
- seedream: 10,
18
- gemini: 4,
19
- codex: 1,
20
- grok: 1,
21
- local: 4,
22
- }
23
-
24
- export function clampProviderCount(provider, count) {
25
- const max = PROVIDER_MAX_COUNTS[provider] || 4
26
- return normalizeCount(count, max)
27
- }
28
-
29
- export function buildEndpointUrl(baseURL, pathSuffix) {
30
- const base = String(baseURL || '').trim().replace(/\/+$/, '')
31
- const suffix = String(pathSuffix || '').trim().replace(/^\/+/, '')
32
- return `${base}/${suffix}`
33
- }
34
-
35
- export function createAbortError(message = 'Image generation cancelled') {
36
- const err = new Error(message)
37
- err.name = 'AbortError'
38
- return err
39
- }
40
-
41
-
42
- /** Fast Laplacian-like sharpness and entropy estimator across image scanlines. */
43
- export function estimateSharpnessAndVariance(bytes) {
44
- const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes || [])
45
- if (!buf || buf.length < 64) {
46
- return { score: 0, passed: false, isBlank: true, reason: 'Empty or corrupt image buffer' }
47
- }
48
- let sum = 0
49
- let diffSum = 0
50
- const sampleStep = Math.max(1, Math.floor(buf.length / 4096))
51
- let count = 0
52
- for (let i = 8; i < buf.length - sampleStep; i += sampleStep) {
53
- const v = buf[i]
54
- const nextV = buf[i + sampleStep]
55
- sum += v
56
- diffSum += Math.abs(v - nextV)
57
- count++
58
- }
59
- const avgDiff = count > 0 ? diffSum / count : 0
60
- if (avgDiff < 2) {
61
- return { score: 0.1, passed: false, isBlank: true, reason: 'Image appears blank or solid monochrome' }
62
- }
63
- const score = Math.min(0.98, Math.max(0.3, +(0.5 + (avgDiff / 255) * 0.5).toFixed(2)))
64
- return { score, passed: score >= 0.5, isBlank: false, avgDiff: +avgDiff.toFixed(2) }
65
- }
66
-
67
- /** Adaptive color palette quantizer for SVG vectorization. */
68
- export function quantizePalette(colorMode = 'color', paletteSize = 16) {
69
- if (colorMode === 'binary') return ['#000000', '#ffffff']
70
- if (colorMode === 'grayscale') return ['#000000', '#444444', '#888888', '#cccccc', '#ffffff']
71
- // Standard vibrant UI vector palette
72
- return [
73
- '#000000', '#ffffff', '#e11d48', '#2563eb',
74
- '#16a34a', '#ca8a04', '#9333ea', '#0891b2',
75
- '#475569', '#64748b', '#94a3b8', '#cbd5e1'
76
- ].slice(0, Math.max(2, paletteSize))
77
- }
78
-
79
-
80
- /** Format parameters in standard Automatic1111 / ComfyUI text format for drag-and-drop support. */
81
- export function formatA1111Parameters(metadata = {}) {
82
- if (typeof metadata === 'string') return metadata
83
- const prompt = metadata.prompt || ''
84
- const neg = metadata.negative_prompt || metadata.negativePrompt || ''
85
- const seed = metadata.seed ?? ''
86
- const size = metadata.size || (metadata.width && metadata.height ? `${metadata.width}x${metadata.height}` : '1024x1024')
87
- const model = metadata.model || metadata.provider || ''
88
- const steps = metadata.steps || 20
89
- const cfg = metadata.cfg_scale || metadata.guidance_scale || 7
90
-
91
- let out = prompt
92
- if (neg) out += `\nNegative prompt: ${neg}`
93
- out += `\nSteps: ${steps}, Sampler: Euler, CFG scale: ${cfg}, Seed: ${seed}, Size: ${size}, Model: ${model}`
94
- return out
95
- }
96
-
97
-
98
- import { createHash } from 'node:crypto'
99
-
100
- /** Deterministic sha256 hash for image generation caching. */
101
- export function computeGenerationHash({ provider, model, prompt, seed, size, style }) {
102
- const norm = [
103
- String(provider || '').trim().toLowerCase(),
104
- String(model || '').trim().toLowerCase(),
105
- String(prompt || '').trim(),
106
- String(seed ?? ''),
107
- String(size || '').trim().toLowerCase(),
108
- String(style || '').trim().toLowerCase(),
109
- ].join('|')
110
- return createHash('sha256').update(norm).digest('hex')
111
- }
112
-
113
- // Image generation provider implementations.
114
- //
115
- // A provider receives a generation job and returns finished image bytes. Everything
116
- // that follows (attachments, workspace files, links, card rendering) is shared
117
- // across all providers and lives in index.js.
118
- //
119
- // Network is injected via fetchImpl and keys via resolveKey, enabling fully isolated unit tests.
120
-
121
- export const PROVIDER_KEYS = ['fal', 'custom', 'codex', 'grok', 'local', 'seedream', 'gemini', 'replicate']
122
-
123
- /** Clamp count from tool arguments to range 1..4. */
124
- /** Provider fallback ordering: primary provider first, followed by PROVIDER_KEYS. */
125
- export function fallbackOrder(primary) {
126
- return [primary, ...PROVIDER_KEYS.filter((k) => k !== primary)]
127
- }
128
-
129
- /**
130
- * Iterates through candidate generators, returning the first successful result.
131
- * Throws an aggregate error if all candidates fail.
132
- * @param generators - array of (key, seed) => Promise<generated> functions.
133
- * @param order - candidate key evaluation order.
134
- */
135
-
136
- /** Calculate exponential backoff delay with jitter. */
137
- export function calculateBackoff(attempt, baseInterval = 500, maxInterval = 5000, jitterFactor = 0.2) {
138
- const exp = Math.min(maxInterval, baseInterval * Math.pow(1.3, attempt))
139
- const jitter = exp * jitterFactor * (Math.random() * 2 - 1)
140
- return Math.max(100, Math.floor(exp + jitter))
141
- }
142
-
143
- /** Check if an error is a fatal client error that should NOT be cascaded to other providers. */
144
- export function isFatalClientError(error) {
145
- const msg = (error?.message || String(error || '')).toLowerCase()
146
- return (
147
- msg.includes('content policy') ||
148
- msg.includes('safety system') ||
149
- msg.includes('nsfw') ||
150
- msg.includes('moderation') ||
151
- msg.includes('bad request (http 400') ||
152
- msg.includes('invalid_prompt') ||
153
- msg.includes('prompt is required') ||
154
- msg.includes('unsupported image format') ||
155
- msg.includes('402 payment required') ||
156
- msg.includes('insufficient_quota') ||
157
- msg.includes('insufficient credits') ||
158
- msg.includes('exceeded your current quota') ||
159
- msg.includes('balance is insufficient')
160
- )
161
- }
162
4
 
163
- /**
164
- * Safely extract a human-readable message from any error, object, or response.
165
- * Completely eliminates "[object Object]" and prevents duplicate provider prefixes (e.g. "codex: codex: ...").
166
- */
167
- export function formatErrorMessage(e, providerKey = '') {
168
- if (e === null || e === undefined) {
169
- return providerKey ? `${providerKey}: unknown error` : 'unknown error'
170
- }
171
-
172
- let msg = ''
173
- if (typeof e === 'string') {
174
- msg = e
175
- } else if (typeof e === 'object') {
176
- if (typeof e.message === 'string' && e.message && e.message !== '[object Object]') {
177
- msg = e.message
178
- } else if (typeof e.detail === 'string' && e.detail) {
179
- msg = e.detail
180
- } else if (e.error) {
181
- if (typeof e.error === 'string') msg = e.error
182
- else if (typeof e.error.message === 'string') msg = e.error.message
183
- else if (typeof e.error.detail === 'string') msg = e.error.detail
184
- else {
185
- try { msg = JSON.stringify(e.error) } catch {}
186
- }
187
- } else if (typeof e.statusText === 'string' && e.statusText) {
188
- msg = `HTTP ${e.status || ''} ${e.statusText}`.trim()
189
- } else if (typeof e.cause === 'string') {
190
- msg = e.cause
191
- } else if (e.cause && typeof e.cause.message === 'string') {
192
- msg = e.cause.message
193
- }
194
-
195
- if (!msg || msg === '[object Object]') {
196
- try {
197
- const json = JSON.stringify(e)
198
- if (json && json !== '{}') {
199
- msg = json.slice(0, 500)
200
- }
201
- } catch {}
202
- }
203
-
204
- if (!msg || msg === '[object Object]') {
205
- const keys = Object.getOwnPropertyNames(e)
206
- if (keys.length) {
207
- msg = `error [${keys.join(', ')}]`
208
- } else {
209
- msg = String(e)
210
- }
211
- }
212
- } else {
213
- msg = String(e)
214
- }
215
-
216
- msg = String(msg || 'unknown error').trim()
217
-
218
- if (providerKey) {
219
- const prefixRegex = new RegExp(`^${providerKey}\\s*:\\s*`, 'i')
220
- while (prefixRegex.test(msg)) {
221
- msg = msg.replace(prefixRegex, '').trim()
222
- }
223
- }
224
-
225
- if (!msg || msg === '[object Object]') {
226
- msg = 'unknown error object'
227
- } else if (msg.includes('[object Object]')) {
228
- msg = msg.replace(/\[object Object\]/g, 'unknown error object').trim()
229
- }
230
-
231
- if (providerKey) {
232
- return `${providerKey}: ${msg}`
233
- }
234
-
235
- return msg
236
- }
5
+ // Utilities and constants extracted to provider-utils.js (#239)
6
+ import {
7
+ resolveApiKeyCandidates,
8
+ PROVIDER_MAX_COUNTS,
9
+ clampProviderCount,
10
+ buildEndpointUrl,
11
+ createAbortError,
12
+ estimateSharpnessAndVariance,
13
+ quantizePalette,
14
+ formatA1111Parameters,
15
+ computeGenerationHash,
16
+ PROVIDER_KEYS,
17
+ fallbackOrder,
18
+ calculateBackoff,
19
+ isFatalClientError,
20
+ formatErrorMessage,
21
+ resolveSubscriptionSize,
22
+ SUBSCRIPTION_SIZES,
23
+ IMAGE_SIZES,
24
+ OUTPUT_FORMATS,
25
+ SIZE_PIXELS,
26
+ normalizeCount,
27
+ } from './provider-utils.js'
28
+
29
+ // Re-export utilities so existing imports from './providers.js' continue to work
30
+ export {
31
+ resolveApiKeyCandidates,
32
+ PROVIDER_MAX_COUNTS,
33
+ clampProviderCount,
34
+ buildEndpointUrl,
35
+ createAbortError,
36
+ estimateSharpnessAndVariance,
37
+ quantizePalette,
38
+ formatA1111Parameters,
39
+ computeGenerationHash,
40
+ PROVIDER_KEYS,
41
+ fallbackOrder,
42
+ calculateBackoff,
43
+ isFatalClientError,
44
+ formatErrorMessage,
45
+ resolveSubscriptionSize,
46
+ SUBSCRIPTION_SIZES,
47
+ IMAGE_SIZES,
48
+ OUTPUT_FORMATS,
49
+ SIZE_PIXELS,
50
+ normalizeCount,
51
+ } from './provider-utils.js'
237
52
 
238
53
  export async function tryGenerate(generators, order, seed, promptArg) {
239
54
  const refusals = []
@@ -251,11 +66,6 @@ export async function tryGenerate(generators, order, seed, promptArg) {
251
66
  throw new Error(`Image generation failed on all providers. ${refusals.join('; ')}`)
252
67
  }
253
68
 
254
- export function normalizeCount(count, max = 4) {
255
- const n = Math.floor(Number(count))
256
- if (!Number.isFinite(n)) return 1
257
- return Math.max(1, Math.min(max, n))
258
- }
259
69
 
260
70
  // Subscription services use their own aspect ratio taxonomy.
261
71
  // Normalize named sizes to standard square, landscape, and portrait equivalents.
@@ -264,63 +74,6 @@ export function normalizeCount(count, max = 4) {
264
74
  * Supports named sizes (landscape_16_9, etc.), aspect ratios (16:9, 3:2, 9:16),
265
75
  * and pixel dimensions [width, height], preserving requested proportions.
266
76
  */
267
- export function resolveSubscriptionSize(size, aspectPixels, aspectRatio) {
268
- if (size && SUBSCRIPTION_SIZES[size]) {
269
- return SUBSCRIPTION_SIZES[size]
270
- }
271
- const ratio = String(aspectRatio || '').trim()
272
- if (ratio === '16:9' || ratio === '3:2' || ratio === '4:3') {
273
- return '1536x1024'
274
- }
275
- if (ratio === '9:16' || ratio === '2:3' || ratio === '3:4') {
276
- return '1024x1536'
277
- }
278
- if (ratio === '1:1') {
279
- return '1024x1024'
280
- }
281
- if (Array.isArray(aspectPixels) && aspectPixels.length === 2) {
282
- const [w, h] = aspectPixels
283
- if (w > h) return '1536x1024'
284
- if (h > w) return '1024x1536'
285
- return '1024x1024'
286
- }
287
- return '1024x1024'
288
- }
289
-
290
- export const SUBSCRIPTION_SIZES = {
291
- square_hd: '1024x1024',
292
- square: '1024x1024',
293
- portrait_4_3: '1024x1536',
294
- portrait_16_9: '1024x1536',
295
- landscape_4_3: '1536x1024',
296
- landscape_16_9: '1536x1024',
297
- }
298
-
299
- /** Image sizes accepted by fal-ai/flux-2/klein (and most FAL flux models). */
300
- export const IMAGE_SIZES = [
301
- 'square_hd',
302
- 'square',
303
- 'portrait_4_3',
304
- 'portrait_16_9',
305
- 'landscape_4_3',
306
- 'landscape_16_9',
307
- ]
308
-
309
- /** Output formats accepted by the model. */
310
- export const OUTPUT_FORMATS = ['png', 'jpeg', 'webp']
311
-
312
- // Named sizes are a unified abstraction across all providers.
313
- // FAL accepts named identifiers; OpenAI-compatible gateways require WxH resolution.
314
- export const SIZE_PIXELS = {
315
- square_hd: '1024x1024',
316
- square: '512x512',
317
- portrait_4_3: '768x1024',
318
- portrait_16_9: '576x1024',
319
- landscape_4_3: '1024x768',
320
- landscape_16_9: '1024x576',
321
- }
322
-
323
- /** Normalize a raw key into a FAL `Authorization: Key <key>` value. */
324
77
  export function falAuthHeader(key) {
325
78
  const trimmed = String(key ?? '').trim()
326
79
  if (!trimmed) return ''
@@ -1143,6 +896,7 @@ export async function blendImagesFal({ fetchImpl, resolveKey, cfg }, { images, w
1143
896
  }
1144
897
 
1145
898
  /** Direct image editing (inpainting / img2img) via selected provider. */
899
+ /** @internal — direct provider call path for integration tests */
1146
900
  export async function editImageDirect(deps, job) {
1147
901
  const providers = makeProviders(deps, job)
1148
902
  const providerKey = job.provider || deps.cfg.defaultProvider || 'fal'
@@ -1152,6 +906,7 @@ export async function editImageDirect(deps, job) {
1152
906
  }
1153
907
 
1154
908
  /** Direct image variation via selected provider. */
909
+ /** @internal — direct provider call path for integration tests */
1155
910
  export async function varyImageDirect(deps, job) {
1156
911
  const providers = makeProviders(deps, {
1157
912
  ...job,
@@ -2,7 +2,8 @@
2
2
  // Each tool lives in lib/tools/* and is registered inside a labeled ctx.effect.
3
3
 
4
4
  import { registerGenerationTools } from './tools/generation.js'
5
- import { registerProcessingTools } from './tools/processing.js'
5
+ import { registerProcessingTools } from './tools/processing-basic.js'
6
+ import { registerProcessingAdvancedTools } from './tools/processing-advanced.js'
6
7
  import { registerEditingTools } from './tools/editing.js'
7
8
  import { registerInspectTools } from './tools/inspect.js'
8
9
  import { registerFrontendTools } from './tools/frontend.js'
@@ -16,6 +17,7 @@ import { registerFrontendTools } from './tools/frontend.js'
16
17
  export function registerAllTools(ctx, deps) {
17
18
  registerGenerationTools(ctx, deps)
18
19
  registerProcessingTools(ctx, deps)
20
+ registerProcessingAdvancedTools(ctx, deps)
19
21
  registerEditingTools(ctx, deps)
20
22
  registerInspectTools(ctx, deps)
21
23
  registerFrontendTools(ctx, deps)
@@ -1,3 +1,5 @@
1
+ // processing-basic — core image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Split from processing.js (#239).
2
+
1
3
  // processing — image-gen tools (remove_background, upscale_image, vectorize_image, blend_images). Extracted from apply() (#216).
2
4
 
3
5
  import { defineTool } from '@deepseek-ai/dsh-tools'
@@ -44,7 +46,8 @@ import {
44
46
  } from '../frontend-assets.js'
45
47
 
46
48
 
47
- export function registerProcessingTools(ctx, deps) {
49
+
50
+ export function registerProcessingAdvancedTools(ctx, deps) {
48
51
  const {
49
52
  config,
50
53
  live,
@@ -53,274 +56,6 @@ export function registerProcessingTools(ctx, deps) {
53
56
  slugify,
54
57
  resolveApiKey,
55
58
  } = deps
56
- ctx.effect(() => {
57
- ctx.tools.register(
58
- defineTool({
59
- name: 'remove_background',
60
- description: 'Remove the background of an image (path or attachment id) and return a transparent PNG.',
61
- parameters: {
62
- image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
63
- model: { type: 'string', description: 'FAL model for background removal (default: fal-ai/birefnet).' },
64
- output_name: { type: 'string', description: 'Custom output file name without extension.' },
65
- },
66
- output: {
67
- schema: {
68
- type: 'object',
69
- additionalProperties: true,
70
- properties: {
71
- path: { type: 'string' },
72
- url: { type: 'string' },
73
- width: { type: 'number' },
74
- height: { type: 'number' },
75
- format: { type: 'string' },
76
- },
77
- },
78
- },
79
- async execute(args, exec) {
80
- try {
81
- const source = await resolveSource(ctx, exec, args.image)
82
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
83
- const cfg = live()
84
- const res = await removeBackgroundFal(
85
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
86
- { imageBytes: source.bytes, mediaType: source.mediaType, model: args.model, signal: exec.signal }
87
- )
88
- const stem = `${slugify(args.output_name || 'nobg')}-${Date.now().toString(36)}`
89
- const name = `${stem}.png`
90
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
91
- const sessionCwd = exec.agent?.session?.header?.cwd
92
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
93
- await mkdir(outDir, { recursive: true })
94
- const filePath = path.join(outDir, name)
95
- await writeFile(filePath, res.bytes)
96
- const summary = buildDualOutputMarkdown({
97
- action: 'background removed',
98
- filePath,
99
- width: res.width || attachment.width,
100
- height: res.height || attachment.height,
101
- mediaType: 'image/png',
102
- provider: 'fal',
103
- model: args.model || 'fal-ai/birefnet',
104
- attachmentId: attachment.attachmentId,
105
- })
106
- return toLosslessJson({
107
- summary,
108
- path: filePath,
109
- url: localUrl,
110
- width: res.width || attachment.width,
111
- height: res.height || attachment.height,
112
- format: 'png',
113
- attachment,
114
- })
115
- } catch (err) {
116
- throw new Error(sanitizeErrorAndLogs(err.message || err))
117
- }
118
- },
119
- }),
120
- )
121
- }, 'dsh-image-gen: tool remove_background')
122
- ctx.effect(() => {
123
- ctx.tools.register(
124
- defineTool({
125
- name: 'upscale_image',
126
- description: 'Upscale an image 2x or 4x with enhanced clarity and detail.',
127
- parameters: {
128
- image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
129
- scale: { type: 'number', description: 'Upscale factor: 2 or 4 (default: 2).' },
130
- prompt: { type: 'string', description: 'Optional guiding prompt for detail reconstruction.' },
131
- creativity: { type: 'number', description: 'Creativity / hallucination level from 0 to 1.' },
132
- output_name: { type: 'string', description: 'Custom output file name.' },
133
- },
134
- output: {
135
- schema: {
136
- type: 'object',
137
- additionalProperties: true,
138
- properties: {
139
- path: { type: 'string' },
140
- url: { type: 'string' },
141
- scale: { type: 'number' },
142
- width: { type: 'number' },
143
- height: { type: 'number' },
144
- },
145
- },
146
- },
147
- async execute(args, exec) {
148
- try {
149
- const source = await resolveSource(ctx, exec, args.image)
150
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
151
- const cfg = live()
152
- const res = await upscaleImageFal(
153
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
154
- { imageBytes: source.bytes, mediaType: source.mediaType, scale: args.scale, prompt: args.prompt, creativity: args.creativity, signal: exec.signal }
155
- )
156
- const stem = `${slugify(args.output_name || 'upscaled')}-${Date.now().toString(36)}`
157
- const name = `${stem}.png`
158
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
159
- const sessionCwd = exec.agent?.session?.header?.cwd
160
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
161
- await mkdir(outDir, { recursive: true })
162
- const filePath = path.join(outDir, name)
163
- await writeFile(filePath, res.bytes)
164
- const summary = buildDualOutputMarkdown({
165
- action: `upscaled (${args.scale || 2}x)`,
166
- filePath,
167
- width: res.width || attachment.width,
168
- height: res.height || attachment.height,
169
- mediaType: 'image/png',
170
- provider: 'fal',
171
- model: 'fal-ai/clarity-upscaler',
172
- attachmentId: attachment.attachmentId,
173
- })
174
- return toLosslessJson({
175
- summary,
176
- path: filePath,
177
- url: localUrl,
178
- scale: args.scale || 2,
179
- width: res.width || attachment.width,
180
- height: res.height || attachment.height,
181
- attachment,
182
- })
183
- } catch (err) {
184
- throw new Error(sanitizeErrorAndLogs(err.message || err))
185
- }
186
- },
187
- }),
188
- )
189
- }, 'dsh-image-gen: tool upscale_image')
190
- ctx.effect(() => {
191
- ctx.tools.register(
192
- defineTool({
193
- name: 'vectorize_image',
194
- description: 'Convert a raster image or icon to SVG vector format.',
195
- parameters: {
196
- image: { type: 'string', required: true, description: 'Path or attachment id of the source image.' },
197
- color_mode: { type: 'string', description: '"color" or "binary" (default: "color").' },
198
- output_name: { type: 'string', description: 'Custom output file name.' },
199
- },
200
- output: {
201
- schema: {
202
- type: 'object',
203
- additionalProperties: true,
204
- properties: {
205
- path: { type: 'string' },
206
- svg: { type: 'string' },
207
- format: { type: 'string' },
208
- },
209
- },
210
- },
211
- async execute(args, exec) {
212
- try {
213
- const source = await resolveSource(ctx, exec, args.image)
214
- if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
215
- const cfg = live()
216
- const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
217
- const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
218
- const name = `${stem}.svg`
219
- const sessionCwd = exec.agent?.session?.header?.cwd
220
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
221
- await mkdir(outDir, { recursive: true })
222
- const filePath = path.join(outDir, name)
223
- await writeFile(filePath, res.bytes)
224
-
225
- let attachment = null
226
- let localUrl = ''
227
- if (ctx.attachments?.saveImage) {
228
- attachment = await ctx.attachments.saveImage({
229
- data: new Uint8Array(res.bytes),
230
- mediaType: 'image/svg+xml',
231
- name,
232
- })
233
- localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
234
- }
235
-
236
- const summary = `### Vectorized SVG Export\n- **File**: ${filePath}\n- **Format**: SVG (XML vector)\n- **Color mode**: ${args.color_mode || 'color'}\n- **Palette size**: ${res.palette ? res.palette.length : 16} colors\n`
237
- return toLosslessJson({
238
- summary,
239
- path: filePath,
240
- url: localUrl,
241
- svg: res.svg,
242
- format: 'svg',
243
- attachment,
244
- })
245
- } catch (err) {
246
- throw new Error(sanitizeErrorAndLogs(err.message || err))
247
- }
248
- },
249
- }),
250
- )
251
- }, 'dsh-image-gen: tool vectorize_image')
252
- ctx.effect(() => {
253
- ctx.tools.register(
254
- defineTool({
255
- name: 'blend_images',
256
- description: 'Blend multiple images (paths or attachment ids) into a coherent new composition.',
257
- parameters: {
258
- images: { type: 'array', items: { type: 'string' }, required: true, description: 'List of image paths or attachment ids to blend.' },
259
- weights: { type: 'array', items: { type: 'number' }, description: 'Weights for each image (optional).' },
260
- prompt: { type: 'string', description: 'Guiding prompt for the blend.' },
261
- output_name: { type: 'string', description: 'Custom output file name.' },
262
- },
263
- output: {
264
- schema: {
265
- type: 'object',
266
- additionalProperties: true,
267
- properties: {
268
- path: { type: 'string' },
269
- url: { type: 'string' },
270
- width: { type: 'number' },
271
- height: { type: 'number' },
272
- },
273
- },
274
- },
275
- async execute(args, exec) {
276
- try {
277
- const cfg = live()
278
- trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
279
- assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
280
- const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
281
- const sources = []
282
- for (const r of refs) {
283
- const s = await resolveSource(ctx, exec, r)
284
- if (s && s.bytes) sources.push(s)
285
- }
286
- if (sources.length === 0) throw new Error('No valid images found to blend')
287
- const res = await blendImagesFal(
288
- { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
289
- { images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
290
- )
291
- const stem = `${slugify(args.output_name || 'blended')}-${Date.now().toString(36)}`
292
- const name = `${stem}.png`
293
- const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: res.bytes, mediaType: 'image/png', name })
294
- const sessionCwd = exec.agent?.session?.header?.cwd
295
- const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
296
- await mkdir(outDir, { recursive: true })
297
- const filePath = path.join(outDir, name)
298
- await writeFile(filePath, res.bytes)
299
- const summary = buildDualOutputMarkdown({
300
- action: 'blended composition',
301
- filePath,
302
- width: res.width || attachment.width,
303
- height: res.height || attachment.height,
304
- mediaType: 'image/png',
305
- provider: 'fal',
306
- model: 'fal-ai/image-blend',
307
- attachmentId: attachment.attachmentId,
308
- })
309
- return toLosslessJson({
310
- summary,
311
- path: filePath,
312
- url: localUrl,
313
- width: res.width || attachment.width,
314
- height: res.height || attachment.height,
315
- attachment,
316
- })
317
- } catch (err) {
318
- throw new Error(sanitizeErrorAndLogs(err.message || err))
319
- }
320
- },
321
- }),
322
- )
323
- }, 'dsh-image-gen: tool blend_images')
324
59
 
325
60
  ctx.effect(() => {
326
61
  ctx.tools.register(