@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/README.md +7 -0
- package/README.ru.md +7 -0
- package/README.zh.md +7 -0
- package/lib/client.js +21 -21
- package/lib/cost-meter.js +4 -2
- package/lib/generation-cache.js +6 -3
- package/lib/index.js +2 -0
- package/lib/loop-guard.js +2 -0
- package/lib/prompt-polisher.js +0 -7
- package/lib/provider-utils.js +302 -0
- package/lib/providers.js +49 -294
- package/lib/register-tools.js +3 -1
- package/lib/tools/{processing.js → processing-advanced.js} +4 -269
- package/lib/tools/processing-basic.js +327 -0
- package/package.json +2 -2
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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,
|
package/lib/register-tools.js
CHANGED
|
@@ -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
|
-
|
|
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(
|