@goodandready/dsh-image-gen 0.10.13 → 0.10.15
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 +18 -0
- package/README.ru.md +18 -0
- package/lib/client.js +1028 -1019
- package/lib/cost-meter.js +3 -2
- package/lib/frontend-assets.js +3 -0
- package/lib/generation-cache.js +7 -3
- package/lib/index.js +87 -28
- package/lib/loop-guard.js +6 -1
- package/lib/providers.js +1 -1
- package/lib/resolve-image.js +12 -1
- package/lib/security.js +10 -0
- package/package.json +1 -1
package/lib/cost-meter.js
CHANGED
|
@@ -135,11 +135,12 @@ export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
|
|
|
135
135
|
const budget = Number(dailyBudgetUsd) || 0
|
|
136
136
|
if (budget <= 0) return true // Budget 0 = unlimited
|
|
137
137
|
|
|
138
|
+
const cost = Number(estimatedCost) || 0
|
|
138
139
|
const current = loadDailySpend()
|
|
139
|
-
const projected = +(current.totalSpendUsd +
|
|
140
|
+
const projected = +(current.totalSpendUsd + cost).toFixed(4)
|
|
140
141
|
if (projected > budget) {
|
|
141
142
|
throw new Error(
|
|
142
|
-
`Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${
|
|
143
|
+
`Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
|
|
143
144
|
)
|
|
144
145
|
}
|
|
145
146
|
return true
|
package/lib/frontend-assets.js
CHANGED
|
@@ -306,6 +306,9 @@ export function optimizeSvgContent(svgString, options = {}) {
|
|
|
306
306
|
}
|
|
307
307
|
|
|
308
308
|
let cleaned = svgString.trim()
|
|
309
|
+
if (!cleaned.includes('<svg')) {
|
|
310
|
+
throw new Error('Content does not contain valid SVG markup (<svg ...>)')
|
|
311
|
+
}
|
|
309
312
|
|
|
310
313
|
cleaned = cleaned.replace(/<\?xml[\s\S]*?\?>/gi, '')
|
|
311
314
|
cleaned = cleaned.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
|
package/lib/generation-cache.js
CHANGED
|
@@ -33,9 +33,13 @@ export function getCachedGeneration(hash, { force = false } = {}) {
|
|
|
33
33
|
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
34
34
|
const bytes = readFileSync(dataPath)
|
|
35
35
|
|
|
36
|
-
// Touch meta file for LRU update
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Touch meta file for LRU update in isolated block
|
|
37
|
+
try {
|
|
38
|
+
meta.lastAccessed = Date.now()
|
|
39
|
+
writeFileSync(metaPath, JSON.stringify(meta, null, 2), 'utf8')
|
|
40
|
+
} catch {
|
|
41
|
+
// Non-fatal touch failure: still return valid cache entry
|
|
42
|
+
}
|
|
39
43
|
|
|
40
44
|
return {
|
|
41
45
|
bytes,
|
package/lib/index.js
CHANGED
|
@@ -274,6 +274,10 @@ export const Config = z.object({
|
|
|
274
274
|
.number()
|
|
275
275
|
.description('provider=local: CFG scale.')
|
|
276
276
|
.default(7),
|
|
277
|
+
seedreamBaseURL: z
|
|
278
|
+
.string()
|
|
279
|
+
.description('provider=seedream: base URL, e.g. https://api.bytedanceapi.com/v1 or Volcengine Ark.')
|
|
280
|
+
.default('https://api.bytedanceapi.com/v1'),
|
|
277
281
|
seedreamKeyEnv: z
|
|
278
282
|
.string()
|
|
279
283
|
.role('credential-ref')
|
|
@@ -1197,8 +1201,9 @@ export function apply(ctx, config) {
|
|
|
1197
1201
|
},
|
|
1198
1202
|
},
|
|
1199
1203
|
async execute(args, exec) {
|
|
1200
|
-
|
|
1201
|
-
|
|
1204
|
+
try {
|
|
1205
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
1206
|
+
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
1202
1207
|
const cfg = live()
|
|
1203
1208
|
const res = await removeBackgroundFal(
|
|
1204
1209
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
@@ -1236,6 +1241,9 @@ export function apply(ctx, config) {
|
|
|
1236
1241
|
format: 'png',
|
|
1237
1242
|
attachment,
|
|
1238
1243
|
})
|
|
1244
|
+
} catch (err) {
|
|
1245
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1246
|
+
}
|
|
1239
1247
|
},
|
|
1240
1248
|
}),
|
|
1241
1249
|
)
|
|
@@ -1266,8 +1274,9 @@ export function apply(ctx, config) {
|
|
|
1266
1274
|
},
|
|
1267
1275
|
},
|
|
1268
1276
|
async execute(args, exec) {
|
|
1269
|
-
|
|
1270
|
-
|
|
1277
|
+
try {
|
|
1278
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
1279
|
+
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
1271
1280
|
const cfg = live()
|
|
1272
1281
|
const res = await upscaleImageFal(
|
|
1273
1282
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
@@ -1305,6 +1314,9 @@ export function apply(ctx, config) {
|
|
|
1305
1314
|
height: res.height || attachment.height,
|
|
1306
1315
|
attachment,
|
|
1307
1316
|
})
|
|
1317
|
+
} catch (err) {
|
|
1318
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1319
|
+
}
|
|
1308
1320
|
},
|
|
1309
1321
|
}),
|
|
1310
1322
|
)
|
|
@@ -1331,24 +1343,42 @@ export function apply(ctx, config) {
|
|
|
1331
1343
|
},
|
|
1332
1344
|
},
|
|
1333
1345
|
async execute(args, exec) {
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1346
|
+
try {
|
|
1347
|
+
const source = await resolveSource(ctx, exec, args.image)
|
|
1348
|
+
if (!source || !source.bytes) throw new Error('Source image not found: ' + args.image)
|
|
1349
|
+
const cfg = live()
|
|
1350
|
+
const res = traceToSvg(source.bytes, { colorMode: args.color_mode })
|
|
1351
|
+
const stem = `${slugify(args.output_name || 'vector')}-${Date.now().toString(36)}`
|
|
1352
|
+
const name = `${stem}.svg`
|
|
1353
|
+
const sessionCwd = exec.agent?.session?.header?.cwd
|
|
1354
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
|
|
1355
|
+
await mkdir(outDir, { recursive: true })
|
|
1356
|
+
const filePath = path.join(outDir, name)
|
|
1357
|
+
await writeFile(filePath, res.bytes)
|
|
1358
|
+
|
|
1359
|
+
let attachment = null
|
|
1360
|
+
let localUrl = ''
|
|
1361
|
+
if (ctx.attachments?.saveImage) {
|
|
1362
|
+
attachment = await ctx.attachments.saveImage({
|
|
1363
|
+
data: new Uint8Array(res.bytes),
|
|
1364
|
+
mediaType: 'image/svg+xml',
|
|
1365
|
+
name,
|
|
1366
|
+
})
|
|
1367
|
+
localUrl = '/dsh-image-gen/image?id=' + encodeURIComponent(attachment.attachmentId)
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
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`
|
|
1371
|
+
return toLosslessJson({
|
|
1372
|
+
summary,
|
|
1373
|
+
path: filePath,
|
|
1374
|
+
url: localUrl,
|
|
1375
|
+
svg: res.svg,
|
|
1376
|
+
format: 'svg',
|
|
1377
|
+
attachment,
|
|
1378
|
+
})
|
|
1379
|
+
} catch (err) {
|
|
1380
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1381
|
+
}
|
|
1352
1382
|
},
|
|
1353
1383
|
}),
|
|
1354
1384
|
)
|
|
@@ -1378,14 +1408,17 @@ export function apply(ctx, config) {
|
|
|
1378
1408
|
},
|
|
1379
1409
|
},
|
|
1380
1410
|
async execute(args, exec) {
|
|
1381
|
-
|
|
1411
|
+
try {
|
|
1412
|
+
const cfg = live()
|
|
1413
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
1414
|
+
assertBudgetAvailable(0.05, cfg.dailyBudgetUsd)
|
|
1415
|
+
const refs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
|
|
1382
1416
|
const sources = []
|
|
1383
1417
|
for (const r of refs) {
|
|
1384
1418
|
const s = await resolveSource(ctx, exec, r)
|
|
1385
1419
|
if (s && s.bytes) sources.push(s)
|
|
1386
1420
|
}
|
|
1387
1421
|
if (sources.length === 0) throw new Error('No valid images found to blend')
|
|
1388
|
-
const cfg = live()
|
|
1389
1422
|
const res = await blendImagesFal(
|
|
1390
1423
|
{ fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg },
|
|
1391
1424
|
{ images: sources, weights: args.weights, prompt: args.prompt, signal: exec.signal }
|
|
@@ -1421,6 +1454,9 @@ export function apply(ctx, config) {
|
|
|
1421
1454
|
height: res.height || attachment.height,
|
|
1422
1455
|
attachment,
|
|
1423
1456
|
})
|
|
1457
|
+
} catch (err) {
|
|
1458
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1459
|
+
}
|
|
1424
1460
|
},
|
|
1425
1461
|
}),
|
|
1426
1462
|
)
|
|
@@ -1542,7 +1578,16 @@ export function apply(ctx, config) {
|
|
|
1542
1578
|
},
|
|
1543
1579
|
},
|
|
1544
1580
|
async execute(args, exec) {
|
|
1545
|
-
|
|
1581
|
+
try {
|
|
1582
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
1583
|
+
const estimatedCostEdit = calculateGenerationCost({
|
|
1584
|
+
provider: cfg.provider || 'fal',
|
|
1585
|
+
model: cfg.model,
|
|
1586
|
+
size: cfg.defaultSize || 'square_hd',
|
|
1587
|
+
count: 1,
|
|
1588
|
+
})
|
|
1589
|
+
assertBudgetAvailable(estimatedCostEdit, cfg.dailyBudgetUsd)
|
|
1590
|
+
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
1546
1591
|
const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
|
|
1547
1592
|
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
1548
1593
|
|
|
@@ -1603,6 +1648,9 @@ export function apply(ctx, config) {
|
|
|
1603
1648
|
result.visionAnalysis = vision.summary
|
|
1604
1649
|
}
|
|
1605
1650
|
return toLosslessJson(result)
|
|
1651
|
+
} catch (err) {
|
|
1652
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1653
|
+
}
|
|
1606
1654
|
},
|
|
1607
1655
|
}),
|
|
1608
1656
|
)
|
|
@@ -1662,7 +1710,17 @@ export function apply(ctx, config) {
|
|
|
1662
1710
|
},
|
|
1663
1711
|
},
|
|
1664
1712
|
async execute(args, exec) {
|
|
1665
|
-
|
|
1713
|
+
try {
|
|
1714
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
1715
|
+
const count = normalizeCount(args.count || 1)
|
|
1716
|
+
const estimatedCostVary = calculateGenerationCost({
|
|
1717
|
+
provider: cfg.provider || 'fal',
|
|
1718
|
+
model: cfg.model,
|
|
1719
|
+
size: cfg.defaultSize || 'square_hd',
|
|
1720
|
+
count,
|
|
1721
|
+
})
|
|
1722
|
+
assertBudgetAvailable(estimatedCostVary, cfg.dailyBudgetUsd)
|
|
1723
|
+
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
1666
1724
|
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
1667
1725
|
|
|
1668
1726
|
const provider = cfg.provider || 'fal'
|
|
@@ -1671,8 +1729,6 @@ export function apply(ctx, config) {
|
|
|
1671
1729
|
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
1672
1730
|
cfg,
|
|
1673
1731
|
}
|
|
1674
|
-
|
|
1675
|
-
const count = normalizeCount(args.count || 1)
|
|
1676
1732
|
const variationStrength = Math.max(0.05, Math.min(0.95, args.variation_strength ?? 0.35))
|
|
1677
1733
|
const baseSeed = Math.floor(Math.random() * 100000)
|
|
1678
1734
|
const format = cfg.defaultFormat || 'png'
|
|
@@ -1727,6 +1783,9 @@ export function apply(ctx, config) {
|
|
|
1727
1783
|
...first,
|
|
1728
1784
|
variations: results.map((r) => ({ path: r.path, attachmentId: r.attachment?.attachmentId, seed: r.seed })),
|
|
1729
1785
|
})
|
|
1786
|
+
} catch (err) {
|
|
1787
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
1788
|
+
}
|
|
1730
1789
|
},
|
|
1731
1790
|
}),
|
|
1732
1791
|
)
|
package/lib/loop-guard.js
CHANGED
|
@@ -29,8 +29,13 @@ export function trackAndAssertLoopGuard(sessionId, {
|
|
|
29
29
|
return { count: 0, allowed: true }
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
+
// A limit of 0 explicitly disables loop guard protection
|
|
33
|
+
if (limit === 0 || limit === '0') {
|
|
34
|
+
return { count: 0, remaining: Infinity, allowed: true }
|
|
35
|
+
}
|
|
36
|
+
|
|
32
37
|
const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0 }
|
|
33
|
-
const maxLimit = Math.max(1, limit)
|
|
38
|
+
const maxLimit = Math.max(1, Number(limit) || DEFAULT_MAX_CONSECUTIVE_GENERATIONS)
|
|
34
39
|
|
|
35
40
|
if (current.count >= maxLimit) {
|
|
36
41
|
throw new Error(
|
package/lib/providers.js
CHANGED
|
@@ -749,7 +749,7 @@ export function makeProviders(deps, job) {
|
|
|
749
749
|
// Seedream (ByteDance): OpenAI-совместимый API картинок.
|
|
750
750
|
async function seedream(seedArg = seed, promptArg = prompt) {
|
|
751
751
|
const key = await resolveKey(cfg.seedreamKeyEnv)
|
|
752
|
-
const base = 'https://api.bytedanceapi.com/v1'
|
|
752
|
+
const base = (cfg.seedreamBaseURL || 'https://api.bytedanceapi.com/v1').replace(/\/+$/, '')
|
|
753
753
|
const res = await fetchImpl(`${base}/images/generations`, {
|
|
754
754
|
method: 'POST',
|
|
755
755
|
headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${key}` },
|
package/lib/resolve-image.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFile } from 'node:fs/promises'
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { estimateSharpnessAndVariance } from './providers.js'
|
|
4
4
|
|
|
@@ -158,6 +158,17 @@ export async function resolveConversationImage(ctx, exec, targetRef) {
|
|
|
158
158
|
? effectiveRef
|
|
159
159
|
: path.resolve(sessionCwd, effectiveRef)
|
|
160
160
|
|
|
161
|
+
const fileStat = await stat(absPath).catch(() => null)
|
|
162
|
+
if (!fileStat) {
|
|
163
|
+
throw new Error(`Image file not found: ${effectiveRef}`)
|
|
164
|
+
}
|
|
165
|
+
if (fileStat.size === 0) {
|
|
166
|
+
throw new Error(`Image file is empty (0 bytes): ${effectiveRef}`)
|
|
167
|
+
}
|
|
168
|
+
if (fileStat.size > 50 * 1024 * 1024) {
|
|
169
|
+
throw new Error(`Image file too large (${(fileStat.size / (1024 * 1024)).toFixed(1)} MB). Maximum allowed size is 50 MB.`)
|
|
170
|
+
}
|
|
171
|
+
|
|
161
172
|
const bytes = await readFile(absPath)
|
|
162
173
|
const mediaType = detectMediaType(absPath, bytes)
|
|
163
174
|
return {
|
package/lib/security.js
CHANGED
|
@@ -42,6 +42,16 @@ export function sanitizeErrorAndLogs(input) {
|
|
|
42
42
|
return maskApiKey(token)
|
|
43
43
|
})
|
|
44
44
|
|
|
45
|
+
// Mask Replicate tokens r8_...
|
|
46
|
+
text = text.replace(/(r8_[a-zA-Z0-9_\-]{8,})/gi, (match, token) => {
|
|
47
|
+
return maskApiKey(token)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
// Mask Google Gemini API keys (starts with AIza and ~35-45 chars)
|
|
51
|
+
text = text.replace(/(AIza[0-9A-Za-z\-_]{30,45})/g, (match, token) => {
|
|
52
|
+
return maskApiKey(token)
|
|
53
|
+
})
|
|
54
|
+
|
|
45
55
|
return text
|
|
46
56
|
}
|
|
47
57
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.15",
|
|
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",
|