@goodandready/dsh-image-gen 0.11.29 → 0.11.31
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/client.js +2 -2
- package/lib/config-schema.js +1 -1
- package/lib/cost-meter.js +74 -7
- package/lib/frontend-assets.js +129 -67
- package/lib/generation-helpers.js +9 -5
- package/lib/history.js +31 -5
- package/lib/live-progress.js +5 -3
- package/lib/loop-guard.js +20 -8
- package/lib/providers/backends/fal.js +10 -0
- package/lib/providers/backends/gemini.js +17 -9
- package/lib/providers/backends/local.js +18 -0
- package/lib/providers/shared-helpers.js +0 -3
- package/lib/providers.js +3 -14
- package/lib/quality-gate.js +19 -2
- package/lib/tools/character-sheet.js +16 -11
- package/lib/tools/diagram.js +16 -11
- package/lib/tools/editing.js +6 -3
- package/lib/tools/generation.js +27 -10
- package/lib/tools/pattern.js +2 -1
- package/lib/tools/processing-basic.js +9 -8
- package/lib/tools/responsive.js +2 -1
- package/lib/tools/sketch.js +2 -1
- package/lib/tools/spritesheet.js +2 -1
- package/lib/tools/style-matrix.js +3 -3
- package/lib/tools/theme-pair.js +4 -4
- package/lib/tools/ui-asset.js +12 -7
- package/lib/tools/vision-ocr.js +44 -18
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -2698,7 +2698,7 @@ window.__ModuleLoader__.load({
|
|
|
2698
2698
|
|
|
2699
2699
|
'f.geminiModel': 'Gemini Model ID',
|
|
2700
2700
|
'f.geminiModelHint': 'Google Gemini image generation model identifier.',
|
|
2701
|
-
'p.geminiModel': 'gemini-
|
|
2701
|
+
'p.geminiModel': 'gemini-3.1-flash-image',
|
|
2702
2702
|
'f.geminiKeyEnv': 'Gemini Key Reference',
|
|
2703
2703
|
'f.geminiKeyEnvHint': 'Credential reference holding Google Gemini API key.',
|
|
2704
2704
|
'p.geminiKeyEnv': 'GEMINI_API_KEY',
|
|
@@ -2977,7 +2977,7 @@ window.__ModuleLoader__.load({
|
|
|
2977
2977
|
|
|
2978
2978
|
'f.geminiModel': 'Gemini 模型 ID',
|
|
2979
2979
|
'f.geminiModelHint': 'Google Gemini 图像生成模型标识符。',
|
|
2980
|
-
'p.geminiModel': 'gemini-
|
|
2980
|
+
'p.geminiModel': 'gemini-3.1-flash-image',
|
|
2981
2981
|
'f.geminiKeyEnv': 'Gemini 凭证名称',
|
|
2982
2982
|
'f.geminiKeyEnvHint': '存储 Google Gemini 密钥的凭证名称。',
|
|
2983
2983
|
'p.geminiKeyEnv': 'GEMINI_API_KEY',
|
package/lib/config-schema.js
CHANGED
|
@@ -177,7 +177,7 @@ export const Config = z.object({
|
|
|
177
177
|
geminiModel: z
|
|
178
178
|
.string()
|
|
179
179
|
.description('Google Gemini image generation model identifier.')
|
|
180
|
-
.default('gemini-
|
|
180
|
+
.default('gemini-3.1-flash-image')
|
|
181
181
|
.volatile(),
|
|
182
182
|
geminiKeyEnv: z
|
|
183
183
|
.string()
|
package/lib/cost-meter.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
// lib/cost-meter.js
|
|
2
|
-
// Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167)
|
|
2
|
+
// Integration with dsh-cost-meter, rate card, and daily budget enforcer (#167, #353, #355)
|
|
3
3
|
|
|
4
4
|
import { homedir } from 'node:os'
|
|
5
5
|
import { join } from 'node:path'
|
|
6
|
-
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
|
|
6
|
+
import { readFileSync, writeFileSync, mkdirSync, renameSync, unlinkSync } from 'node:fs'
|
|
7
|
+
import { randomBytes } from 'node:crypto'
|
|
7
8
|
import { enforceSecurePermissions } from './security.js'
|
|
8
9
|
|
|
9
10
|
/**
|
|
@@ -53,6 +54,27 @@ export const RATE_CARD = {
|
|
|
53
54
|
},
|
|
54
55
|
}
|
|
55
56
|
|
|
57
|
+
/** Active in-flight budget reservations for serialized concurrency (#355) */
|
|
58
|
+
const activeReservations = new Map()
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Returns total currently active in-flight spend reservations in USD.
|
|
62
|
+
*/
|
|
63
|
+
export function getTotalActiveReservations() {
|
|
64
|
+
let total = 0
|
|
65
|
+
for (const amount of activeReservations.values()) {
|
|
66
|
+
total += Number(amount) || 0
|
|
67
|
+
}
|
|
68
|
+
return +total.toFixed(4)
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Clears all active in-flight reservations (for testing / session resets).
|
|
73
|
+
*/
|
|
74
|
+
export function clearSpendReservations() {
|
|
75
|
+
activeReservations.clear()
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
/**
|
|
57
79
|
* Calculates estimated USD cost for a generation request.
|
|
58
80
|
*/
|
|
@@ -94,6 +116,7 @@ export function loadDailySpend() {
|
|
|
94
116
|
|
|
95
117
|
/**
|
|
96
118
|
* Records an image generation expense.
|
|
119
|
+
* Uses atomic sibling write + rename to guarantee crash-safe persistence (#355).
|
|
97
120
|
*/
|
|
98
121
|
export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
99
122
|
const amount = Number(usdAmount) || 0
|
|
@@ -106,8 +129,11 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
|
106
129
|
current.lastUpdated = new Date().toISOString()
|
|
107
130
|
|
|
108
131
|
try {
|
|
109
|
-
|
|
110
|
-
|
|
132
|
+
const dir = join(filePath, '..')
|
|
133
|
+
mkdirSync(dir, { recursive: true })
|
|
134
|
+
const tmpFile = `${filePath}.${randomBytes(6).toString('hex')}.tmp`
|
|
135
|
+
writeFileSync(tmpFile, JSON.stringify(current, null, 2), { encoding: 'utf8', mode: 0o600 })
|
|
136
|
+
renameSync(tmpFile, filePath)
|
|
111
137
|
enforceSecurePermissions(filePath)
|
|
112
138
|
} catch (err) {
|
|
113
139
|
// Non-fatal write failure
|
|
@@ -130,7 +156,7 @@ export function recordSpend(usdAmount, { ctx, meta = {} } = {}) {
|
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
/**
|
|
133
|
-
* Asserts that the generation does not exceed daily budget.
|
|
159
|
+
* Asserts that the generation does not exceed daily budget, accounting for active in-flight reservations (#353, #355).
|
|
134
160
|
* @throws {Error} If daily budget is exceeded.
|
|
135
161
|
*/
|
|
136
162
|
export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
|
|
@@ -139,11 +165,52 @@ export function assertBudgetAvailable(estimatedCost, dailyBudgetUsd) {
|
|
|
139
165
|
|
|
140
166
|
const cost = Number(estimatedCost) || 0
|
|
141
167
|
const current = loadDailySpend()
|
|
142
|
-
const
|
|
168
|
+
const reserved = getTotalActiveReservations()
|
|
169
|
+
const projected = +(current.totalSpendUsd + reserved + cost).toFixed(4)
|
|
143
170
|
if (projected > budget) {
|
|
144
171
|
throw new Error(
|
|
145
|
-
`Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
|
|
172
|
+
`Daily image generation budget exceeded: current spend $${current.totalSpendUsd.toFixed(2)} + reserved $${reserved.toFixed(2)} + estimated $${cost.toFixed(2)} exceeds limit $${budget.toFixed(2)} (Settings → Image generation → dailyBudgetUsd)`
|
|
146
173
|
)
|
|
147
174
|
}
|
|
148
175
|
return true
|
|
149
176
|
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Reserves estimated budget for an in-flight generation attempt (#353, #355).
|
|
180
|
+
* Returns a settlement handle with .commit(actualCost, opts) and .release().
|
|
181
|
+
*
|
|
182
|
+
* @param {number} estimatedCost
|
|
183
|
+
* @param {number} dailyBudgetUsd
|
|
184
|
+
* @returns {{ id: string, cost: number, commit: Function, release: Function }}
|
|
185
|
+
*/
|
|
186
|
+
export function reserveSpend(estimatedCost, dailyBudgetUsd) {
|
|
187
|
+
const budget = Number(dailyBudgetUsd) || 0
|
|
188
|
+
const cost = Number(estimatedCost) || 0
|
|
189
|
+
|
|
190
|
+
if (budget > 0 && cost > 0) {
|
|
191
|
+
assertBudgetAvailable(cost, budget)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const id = `res_${Date.now()}_${randomBytes(4).toString('hex')}`
|
|
195
|
+
if (budget > 0 && cost > 0) {
|
|
196
|
+
activeReservations.set(id, cost)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let settled = false
|
|
200
|
+
return {
|
|
201
|
+
id,
|
|
202
|
+
cost,
|
|
203
|
+
commit(actualCost, opts) {
|
|
204
|
+
if (settled) return
|
|
205
|
+
settled = true
|
|
206
|
+
activeReservations.delete(id)
|
|
207
|
+
const effective = actualCost !== undefined ? Number(actualCost) : cost
|
|
208
|
+
return recordSpend(effective, opts)
|
|
209
|
+
},
|
|
210
|
+
release() {
|
|
211
|
+
if (settled) return
|
|
212
|
+
settled = true
|
|
213
|
+
activeReservations.delete(id)
|
|
214
|
+
},
|
|
215
|
+
}
|
|
216
|
+
}
|
package/lib/frontend-assets.js
CHANGED
|
@@ -70,7 +70,7 @@ export function getContrastRatio(hex1, hex2) {
|
|
|
70
70
|
* @returns {Promise<string[]>} Hex colors
|
|
71
71
|
*/
|
|
72
72
|
export async function extractSampleColorsFromBuffer(buffer) {
|
|
73
|
-
if (!buffer || buffer.length
|
|
73
|
+
if (!buffer || buffer.length === 0) {
|
|
74
74
|
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -96,76 +96,79 @@ export async function extractSampleColorsFromBuffer(buffer) {
|
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
// Decode raster container to raw RGBA pixels via Sharp
|
|
99
|
+
let data, info
|
|
99
100
|
try {
|
|
100
|
-
const
|
|
101
|
+
const res = await sharp(buffer)
|
|
101
102
|
.raw()
|
|
102
103
|
.ensureAlpha()
|
|
103
104
|
.toBuffer({ resolveWithObject: true })
|
|
105
|
+
data = res.data
|
|
106
|
+
info = res.info
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new Error(`Failed to decode image buffer for color extraction: ${err.message}`)
|
|
109
|
+
}
|
|
104
110
|
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
}
|
|
130
|
-
bucket.count++
|
|
131
|
-
bucket.rSum += r
|
|
132
|
-
bucket.gSum += g
|
|
133
|
-
bucket.bSum += b
|
|
111
|
+
const totalPixels = info.width * info.height
|
|
112
|
+
const step = Math.max(1, Math.floor(totalPixels / 2048))
|
|
113
|
+
const colorBuckets = new Map()
|
|
114
|
+
|
|
115
|
+
for (let i = 0; i < totalPixels; i += step) {
|
|
116
|
+
const off = i * 4
|
|
117
|
+
const r = data[off]
|
|
118
|
+
const g = data[off + 1]
|
|
119
|
+
const b = data[off + 2]
|
|
120
|
+
const a = data[off + 3]
|
|
121
|
+
|
|
122
|
+
// Ignore fully or mostly transparent pixels
|
|
123
|
+
if (a < 128) continue
|
|
124
|
+
|
|
125
|
+
// Quantize to 16-level RGB bins to cluster nearby nuances
|
|
126
|
+
const qr = Math.round(r / 16) * 16
|
|
127
|
+
const qg = Math.round(g / 16) * 16
|
|
128
|
+
const qb = Math.round(b / 16) * 16
|
|
129
|
+
const key = `${qr},${qg},${qb}`
|
|
130
|
+
|
|
131
|
+
let bucket = colorBuckets.get(key)
|
|
132
|
+
if (!bucket) {
|
|
133
|
+
bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
|
|
134
|
+
colorBuckets.set(key, bucket)
|
|
134
135
|
}
|
|
136
|
+
bucket.count++
|
|
137
|
+
bucket.rSum += r
|
|
138
|
+
bucket.gSum += g
|
|
139
|
+
bucket.bSum += b
|
|
140
|
+
}
|
|
135
141
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
142
|
+
if (colorBuckets.size > 0) {
|
|
143
|
+
const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
|
|
144
|
+
const dominant = sorted.slice(0, 6).map((b) => {
|
|
145
|
+
return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
|
|
146
|
+
})
|
|
141
147
|
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
}
|
|
148
|
+
const result = [...dominant]
|
|
149
|
+
if (result.length < 5) {
|
|
150
|
+
const primaryRgb = hexToRgb(result[0])
|
|
151
|
+
const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
|
|
152
|
+
if (isNearBlack) {
|
|
153
|
+
const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
|
|
154
|
+
for (const s of darkShades) {
|
|
155
|
+
if (result.length >= 6) break
|
|
156
|
+
if (!result.includes(s)) result.push(s)
|
|
157
|
+
}
|
|
158
|
+
} else {
|
|
159
|
+
const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
|
|
160
|
+
for (const factor of factors) {
|
|
161
|
+
if (result.length >= 6) break
|
|
162
|
+
const shade = rgbToHex(
|
|
163
|
+
Math.min(255, Math.round(primaryRgb.r * factor)),
|
|
164
|
+
Math.min(255, Math.round(primaryRgb.g * factor)),
|
|
165
|
+
Math.min(255, Math.round(primaryRgb.b * factor)),
|
|
166
|
+
)
|
|
167
|
+
if (!result.includes(shade)) result.push(shade)
|
|
163
168
|
}
|
|
164
169
|
}
|
|
165
|
-
return result.slice(0, 6)
|
|
166
170
|
}
|
|
167
|
-
|
|
168
|
-
// If sharp cannot decode or buffer is non-image, fallback
|
|
171
|
+
return result.slice(0, 6)
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
@@ -334,6 +337,28 @@ export function checkWcagContrast(colors, textHex = '#ffffff') {
|
|
|
334
337
|
}
|
|
335
338
|
}
|
|
336
339
|
|
|
340
|
+
/**
|
|
341
|
+
* Decode common HTML numeric and named entities in SVG attribute values (#374)
|
|
342
|
+
* @param {string} str
|
|
343
|
+
* @returns {string}
|
|
344
|
+
*/
|
|
345
|
+
export function decodeHtmlEntities(str) {
|
|
346
|
+
if (!str || typeof str !== 'string') return ''
|
|
347
|
+
return str
|
|
348
|
+
.replace(/&#x([0-9a-fA-F]+);?/gi, (_, hex) => {
|
|
349
|
+
try { return String.fromCharCode(parseInt(hex, 16)) } catch (_e) { return '' }
|
|
350
|
+
})
|
|
351
|
+
.replace(/&#([0-9]+);?/g, (_, dec) => {
|
|
352
|
+
try { return String.fromCharCode(parseInt(dec, 10)) } catch (_e) { return '' }
|
|
353
|
+
})
|
|
354
|
+
.replace(/:/gi, ':')
|
|
355
|
+
.replace(/&tab;/gi, '\t')
|
|
356
|
+
.replace(/&newline;/gi, '\n')
|
|
357
|
+
.replace(/"/gi, '"')
|
|
358
|
+
.replace(/'/gi, "'")
|
|
359
|
+
.replace(/&/gi, '&')
|
|
360
|
+
}
|
|
361
|
+
|
|
337
362
|
/**
|
|
338
363
|
* Issue #176: optimize_vector_svg
|
|
339
364
|
* Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
|
|
@@ -360,14 +385,51 @@ export function optimizeSvgContent(svgString, options = {}) {
|
|
|
360
385
|
cleaned = cleaned.replace(/<script[^>]*\/?>/gi, '')
|
|
361
386
|
cleaned = cleaned.replace(/<foreignObject[\s\S]*?<\/foreignObject>/gi, '')
|
|
362
387
|
cleaned = cleaned.replace(/<foreignObject[^>]*\/?>/gi, '')
|
|
363
|
-
cleaned = cleaned.replace(/<(?:iframe|embed|object|applet)[\s\S]*?<\/(?:iframe|embed|object|applet)>/gi, '')
|
|
364
|
-
cleaned = cleaned.replace(/<(?:iframe|embed|object|applet)[^>]*\/?>/gi, '')
|
|
365
|
-
|
|
366
|
-
// Strip
|
|
367
|
-
cleaned = cleaned.replace(
|
|
388
|
+
cleaned = cleaned.replace(/<(?:iframe|embed|object|applet|handler|listener)[\s\S]*?<\/(?:iframe|embed|object|applet|handler|listener)>/gi, '')
|
|
389
|
+
cleaned = cleaned.replace(/<(?:iframe|embed|object|applet|handler|listener)[^>]*\/?>/gi, '')
|
|
390
|
+
|
|
391
|
+
// Security (#374): Strip dangerous SMIL animation elements (<animate>, <set>, etc.) targeting href/xlink:href/on* or containing script schemes
|
|
392
|
+
cleaned = cleaned.replace(/<(animate|set|animateTransform|animateMotion|discard)\b[^>]*>[\s\S]*?<\/\1>/gi, (block) => {
|
|
393
|
+
const norm = decodeHtmlEntities(block).replace(/[\s\x00-\x20]+/g, ' ').toLowerCase()
|
|
394
|
+
if (
|
|
395
|
+
/attributename\s*=\s*['"]?(?:href|xlink:href|src|action|formaction|on[a-z0-9_-]+)/.test(norm) ||
|
|
396
|
+
/(?:javascript|vbscript|data\s*:\s*text\/html)/.test(norm)
|
|
397
|
+
) {
|
|
398
|
+
return ''
|
|
399
|
+
}
|
|
400
|
+
return block
|
|
401
|
+
})
|
|
402
|
+
cleaned = cleaned.replace(/<(?:animate|set|animateTransform|animateMotion|discard)\b[^>]*\/?>/gi, (tag) => {
|
|
403
|
+
const norm = decodeHtmlEntities(tag).replace(/[\s\x00-\x20]+/g, ' ').toLowerCase()
|
|
404
|
+
if (
|
|
405
|
+
/attributename\s*=\s*['"]?(?:href|xlink:href|src|action|formaction|on[a-z0-9_-]+)/.test(norm) ||
|
|
406
|
+
/(?:javascript|vbscript|data\s*:\s*text\/html)/.test(norm)
|
|
407
|
+
) {
|
|
408
|
+
return ''
|
|
409
|
+
}
|
|
410
|
+
return tag
|
|
411
|
+
})
|
|
368
412
|
|
|
369
|
-
// Strip
|
|
370
|
-
cleaned = cleaned.replace(/\s+
|
|
413
|
+
// Strip event handler attributes (onload, onerror, onclick, etc., quoted or unquoted)
|
|
414
|
+
cleaned = cleaned.replace(/\s+on[a-z0-9_-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
|
|
415
|
+
|
|
416
|
+
// Security (#374): Strip dangerous URI schemes (javascript:, vbscript:, data:text/html, etc.) in href/xlink:href/src/action/formaction
|
|
417
|
+
// Handles HTML entity encoding (s), control chars, and unquoted attributes
|
|
418
|
+
cleaned = cleaned.replace(/\s+(?:href|xlink:href|src|action|formaction)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>]+))/gi, (match, q1, q2, unquoted) => {
|
|
419
|
+
const val = q1 !== undefined ? q1 : (q2 !== undefined ? q2 : unquoted)
|
|
420
|
+
const decoded = decodeHtmlEntities(val)
|
|
421
|
+
const normalized = decoded.replace(/[\x00-\x20\s]+/g, '').toLowerCase()
|
|
422
|
+
if (
|
|
423
|
+
normalized.startsWith('javascript:') ||
|
|
424
|
+
normalized.startsWith('vbscript:') ||
|
|
425
|
+
normalized.startsWith('data:text/html') ||
|
|
426
|
+
normalized.startsWith('data:text/javascript') ||
|
|
427
|
+
normalized.startsWith('data:image/svg+xml')
|
|
428
|
+
) {
|
|
429
|
+
return ''
|
|
430
|
+
}
|
|
431
|
+
return match
|
|
432
|
+
})
|
|
371
433
|
|
|
372
434
|
cleaned = cleaned.replace(/\s{2,}/g, ' ')
|
|
373
435
|
cleaned = cleaned.replace(/>\s+</g, '><')
|
|
@@ -152,6 +152,7 @@ export async function saveGeneratedImageArtifact({
|
|
|
152
152
|
slugify,
|
|
153
153
|
estimatedCost,
|
|
154
154
|
totalCount,
|
|
155
|
+
recordSpend: shouldRecordSpend = true,
|
|
155
156
|
}) {
|
|
156
157
|
const targetFormat = format || (args.output_format || cfg.defaultFormat)
|
|
157
158
|
const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
|
|
@@ -244,6 +245,7 @@ export async function saveGeneratedImageArtifact({
|
|
|
244
245
|
model: cfg.model,
|
|
245
246
|
size,
|
|
246
247
|
format,
|
|
248
|
+
aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
|
|
247
249
|
seed: gen.seed,
|
|
248
250
|
cacheHash,
|
|
249
251
|
thumbnailUrl: localUrl,
|
|
@@ -261,11 +263,13 @@ export async function saveGeneratedImageArtifact({
|
|
|
261
263
|
await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
|
|
262
264
|
}
|
|
263
265
|
|
|
264
|
-
// #167: Record spend in cost meter
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
266
|
+
// #167: Record spend in cost meter (unless caller already committed attempt spend)
|
|
267
|
+
if (shouldRecordSpend !== false) {
|
|
268
|
+
recordSpend(gen.cost || (estimatedCost / totalCount), {
|
|
269
|
+
ctx,
|
|
270
|
+
meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
|
|
271
|
+
})
|
|
272
|
+
}
|
|
269
273
|
|
|
270
274
|
return {
|
|
271
275
|
path: filePath,
|
package/lib/history.js
CHANGED
|
@@ -37,13 +37,38 @@ export function findCachedGeneration(entries, hash) {
|
|
|
37
37
|
return entries.find((e) => e.cacheHash === hash)
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
-
export async function findCachedByPrompt(entries, prompt) {
|
|
41
|
-
|
|
40
|
+
export async function findCachedByPrompt(entries, prompt, criteria = {}) {
|
|
41
|
+
if (!prompt || !Array.isArray(entries)) return undefined
|
|
42
|
+
return entries.find((e) => {
|
|
43
|
+
if (e.prompt !== prompt) return false
|
|
44
|
+
if (criteria.format) {
|
|
45
|
+
const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
|
|
46
|
+
const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
|
|
47
|
+
if (entryFmt && targetFmt && entryFmt !== targetFmt) return false
|
|
48
|
+
}
|
|
49
|
+
if (criteria.aspectRatio && e.aspectRatio && e.aspectRatio !== criteria.aspectRatio) return false
|
|
50
|
+
if (criteria.width && e.width && criteria.width !== e.width) return false
|
|
51
|
+
if (criteria.height && e.height && criteria.height !== e.height) return false
|
|
52
|
+
if (criteria.size && e.size && criteria.size !== criteria.size) return false
|
|
53
|
+
return true
|
|
54
|
+
})
|
|
42
55
|
}
|
|
43
56
|
|
|
44
|
-
export async function findCached(entries, seed, prompt) {
|
|
45
|
-
if (seed === undefined) return undefined
|
|
46
|
-
return entries.find((e) =>
|
|
57
|
+
export async function findCached(entries, seed, prompt, criteria = {}) {
|
|
58
|
+
if (seed === undefined || !Array.isArray(entries)) return undefined
|
|
59
|
+
return entries.find((e) => {
|
|
60
|
+
if (e.seed !== seed || e.prompt !== prompt) return false
|
|
61
|
+
if (criteria.format) {
|
|
62
|
+
const entryFmt = (e.format || (e.mediaType || '').replace('image/', '')).toLowerCase().replace('jpeg', 'jpg')
|
|
63
|
+
const targetFmt = String(criteria.format).toLowerCase().replace('jpeg', 'jpg')
|
|
64
|
+
if (entryFmt && targetFmt && entryFmt !== targetFmt) return false
|
|
65
|
+
}
|
|
66
|
+
if (criteria.aspectRatio && e.aspectRatio && e.aspectRatio !== criteria.aspectRatio) return false
|
|
67
|
+
if (criteria.width && e.width && criteria.width !== e.width) return false
|
|
68
|
+
if (criteria.height && e.height && criteria.height !== e.height) return false
|
|
69
|
+
if (criteria.size && e.size && criteria.size !== criteria.size) return false
|
|
70
|
+
return true
|
|
71
|
+
})
|
|
47
72
|
}
|
|
48
73
|
|
|
49
74
|
/** Return cached entry if file exists on disk, otherwise undefined. */
|
|
@@ -61,6 +86,7 @@ export async function cachedResult(entry) {
|
|
|
61
86
|
cost: 0,
|
|
62
87
|
format: (entry.mediaType || 'image/png').replace('image/', ''),
|
|
63
88
|
cached: true,
|
|
89
|
+
fromCache: true,
|
|
64
90
|
attachment: entry.attachmentId ? {
|
|
65
91
|
attachmentId: entry.attachmentId,
|
|
66
92
|
mediaType: entry.mediaType || 'image/png',
|
package/lib/live-progress.js
CHANGED
|
@@ -14,8 +14,10 @@ const activeSessions = new Map()
|
|
|
14
14
|
export function createLiveProgressSession(callId, options = {}) {
|
|
15
15
|
if (!callId) return null
|
|
16
16
|
const existing = activeSessions.get(callId)
|
|
17
|
-
|
|
18
|
-
|
|
17
|
+
const listeners = existing?.listeners ? existing.listeners : new Set()
|
|
18
|
+
|
|
19
|
+
if (typeof options.onProgress === 'function') {
|
|
20
|
+
listeners.add(options.onProgress)
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
const session = {
|
|
@@ -27,7 +29,7 @@ export function createLiveProgressSession(callId, options = {}) {
|
|
|
27
29
|
done: false,
|
|
28
30
|
error: undefined,
|
|
29
31
|
timestamp: Date.now(),
|
|
30
|
-
listeners
|
|
32
|
+
listeners,
|
|
31
33
|
}
|
|
32
34
|
|
|
33
35
|
if (options.signal) {
|
package/lib/loop-guard.js
CHANGED
|
@@ -41,15 +41,27 @@ export function trackAndAssertLoopGuard(sessionId, {
|
|
|
41
41
|
const current = sessionStates.get(sid) || { count: 0, lastPrompt: '', lastTimestamp: 0, lastTurnId: null }
|
|
42
42
|
const maxLimit = Math.max(1, Number(limit) || DEFAULT_MAX_CONSECUTIVE_GENERATIONS)
|
|
43
43
|
|
|
44
|
-
// Derive turn identifier from turnId or exec context
|
|
44
|
+
// Derive turn identifier from turnId or exec context (#370)
|
|
45
45
|
const session = exec?.agent?.session
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
let derivedTurnId = turnId ?? exec?.turnId ?? exec?.agent?.turnId
|
|
47
|
+
|
|
48
|
+
if (derivedTurnId === null || derivedTurnId === undefined) {
|
|
49
|
+
const rawMessages = typeof session?.deriveMessages === 'function'
|
|
50
|
+
? session.deriveMessages()
|
|
51
|
+
: (Array.isArray(session?.messages) ? session.messages : null)
|
|
52
|
+
|
|
53
|
+
if (Array.isArray(rawMessages)) {
|
|
54
|
+
const userMessages = rawMessages.filter(m => m && (m.role === 'user' || m.type === 'user'))
|
|
55
|
+
if (userMessages.length > 0) {
|
|
56
|
+
const lastUser = userMessages[userMessages.length - 1]
|
|
57
|
+
derivedTurnId = lastUser.id ?? lastUser.turnId ?? `user_turn_${userMessages.length}`
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (derivedTurnId === null || derivedTurnId === undefined) {
|
|
63
|
+
derivedTurnId = session?.lastUserMessageId ?? session?.lastMessageId ?? session?.turnId ?? null
|
|
64
|
+
}
|
|
53
65
|
|
|
54
66
|
// If turn identifier changed, reset count for the new user turn
|
|
55
67
|
if (derivedTurnId !== null && derivedTurnId !== undefined) {
|
|
@@ -35,6 +35,11 @@ export function createFalGenerator(deps, job) {
|
|
|
35
35
|
body.strength = strength
|
|
36
36
|
}
|
|
37
37
|
} else if (job.faceReference && (job.faceReference.bytes || job.faceReference.url)) {
|
|
38
|
+
// #375: Enforce model capability check before dispatching face identity anchors
|
|
39
|
+
const isFaceCapable = /instant-id|face-to-many|pulid|ip-adapter|face/i.test(targetModel)
|
|
40
|
+
if (!isFaceCapable) {
|
|
41
|
+
throw new Error(`Model "${targetModel}" on FAL does not support face identity anchors. Use a face-capable model (e.g. fal-ai/flux-lora/face-to-many, fal-ai/instant-id) or ComfyUI with FaceID workflow.`)
|
|
42
|
+
}
|
|
38
43
|
const refDataUri = job.faceReference.bytes
|
|
39
44
|
? `data:${job.faceReference.mediaType || 'image/png'};base64,${Buffer.from(job.faceReference.bytes).toString('base64')}`
|
|
40
45
|
: job.faceReference.url
|
|
@@ -45,6 +50,11 @@ export function createFalGenerator(deps, job) {
|
|
|
45
50
|
body.reference_weight = job.faceStrength
|
|
46
51
|
}
|
|
47
52
|
} else if (job.referenceImage && (job.referenceImage.bytes || job.referenceImage.url)) {
|
|
53
|
+
// #375: Enforce model capability check before dispatching style reference anchors
|
|
54
|
+
const isRefCapable = /instant-id|face-to-many|pulid|ip-adapter|reference|redux/i.test(targetModel)
|
|
55
|
+
if (!isRefCapable) {
|
|
56
|
+
throw new Error(`Model "${targetModel}" on FAL does not support reference image anchors. Use a reference-capable model (e.g. fal-ai/flux/dev/redux, fal-ai/ip-adapter) or ComfyUI.`)
|
|
57
|
+
}
|
|
48
58
|
const refDataUri = job.referenceImage.bytes
|
|
49
59
|
? `data:${job.referenceImage.mediaType || 'image/png'};base64,${Buffer.from(job.referenceImage.bytes).toString('base64')}`
|
|
50
60
|
: job.referenceImage.url
|
|
@@ -15,10 +15,10 @@ export function createGeminiGenerator(deps, job) {
|
|
|
15
15
|
|
|
16
16
|
async function gemini(seedArg = seed, promptArg = prompt) {
|
|
17
17
|
const key = await resolveKey(cfg.geminiKeyEnv)
|
|
18
|
-
let model = cfg.geminiModel || 'gemini-
|
|
19
|
-
// #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
|
|
20
|
-
if (model.startsWith('imagen-') || model
|
|
21
|
-
model = 'gemini-
|
|
18
|
+
let model = cfg.geminiModel || 'gemini-3.1-flash-image'
|
|
19
|
+
// #364: Transparently migrate deprecated Imagen 3/4 IDs and preview 2.0 to current Gemini multimodal model
|
|
20
|
+
if (model.startsWith('imagen-') || model.startsWith('gemini-2.0-flash-exp')) {
|
|
21
|
+
model = 'gemini-3.1-flash-image'
|
|
22
22
|
}
|
|
23
23
|
|
|
24
24
|
const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
|
|
@@ -53,14 +53,22 @@ export function createGeminiGenerator(deps, job) {
|
|
|
53
53
|
}
|
|
54
54
|
parts.push({ text: promptArg })
|
|
55
55
|
|
|
56
|
-
//
|
|
56
|
+
// #364: Google Gemini ImageConfig only accepts aspectRatio and imageSize.
|
|
57
|
+
// Unofficial/deprecated fields like imageQuality or imageStyle must not be passed.
|
|
57
58
|
const imageConfig = {}
|
|
58
|
-
if (aspectRatio)
|
|
59
|
-
|
|
60
|
-
|
|
59
|
+
if (aspectRatio) {
|
|
60
|
+
const ratioMap = {
|
|
61
|
+
square: '1:1',
|
|
62
|
+
portrait_4_3: '3:4',
|
|
63
|
+
landscape_4_3: '4:3',
|
|
64
|
+
portrait_16_9: '9:16',
|
|
65
|
+
landscape_16_9: '16:9',
|
|
66
|
+
}
|
|
67
|
+
imageConfig.aspectRatio = ratioMap[aspectRatio] || aspectRatio
|
|
68
|
+
}
|
|
61
69
|
|
|
62
70
|
const generationConfig = {
|
|
63
|
-
responseModalities: ['IMAGE'],
|
|
71
|
+
responseModalities: ['TEXT', 'IMAGE'],
|
|
64
72
|
...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
|
|
65
73
|
}
|
|
66
74
|
|
|
@@ -43,6 +43,13 @@ export function createLocalGenerator(deps, job) {
|
|
|
43
43
|
}
|
|
44
44
|
|
|
45
45
|
if (kind === 'a1111') {
|
|
46
|
+
// #375: Local A1111 txt2img/img2img lacks identity adapters
|
|
47
|
+
if (job.faceReference) {
|
|
48
|
+
throw new Error('Local A1111 does not support facial identity references (FaceID) — use ComfyUI (IP-Adapter/InstantID) or FAL')
|
|
49
|
+
}
|
|
50
|
+
if (job.referenceImage) {
|
|
51
|
+
throw new Error('Local A1111 does not support visual style reference anchors — use ComfyUI (IP-Adapter) or FAL')
|
|
52
|
+
}
|
|
46
53
|
if (typeof onProgress === 'function') {
|
|
47
54
|
onProgress({ stage: 'Generating...', progress: undefined })
|
|
48
55
|
}
|
|
@@ -88,6 +95,17 @@ export function createLocalGenerator(deps, job) {
|
|
|
88
95
|
}
|
|
89
96
|
|
|
90
97
|
// ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
|
|
98
|
+
if (job.faceReference || job.referenceImage) {
|
|
99
|
+
const wf = cfg.comfyWorkflowJson || ''
|
|
100
|
+
const hasFace = wf.includes('face_image') || wf.includes('ApplyIPAdapter') || wf.includes('InstantID') || wf.includes('FaceID')
|
|
101
|
+
const hasRef = wf.includes('reference_image') || wf.includes('ApplyIPAdapter') || wf.includes('StyleModelApply')
|
|
102
|
+
if (job.faceReference && !hasFace) {
|
|
103
|
+
throw new Error('ComfyUI requires a custom workflow with FaceID or IP-Adapter nodes to use facial identity anchors.')
|
|
104
|
+
}
|
|
105
|
+
if (job.referenceImage && !hasRef) {
|
|
106
|
+
throw new Error('ComfyUI requires a custom workflow with reference or IP-Adapter nodes to use style reference anchors.')
|
|
107
|
+
}
|
|
108
|
+
}
|
|
91
109
|
const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
|
|
92
110
|
let workflowGraph
|
|
93
111
|
if (cfg.comfyWorkflowJson) {
|
|
@@ -90,9 +90,6 @@ export async function ensureImageFormat(bytes, targetFormat, { fallbackMime } =
|
|
|
90
90
|
const verified = detectImageMediaType(transcoded) || targetMime
|
|
91
91
|
return { bytes: transcoded, mediaType: verified }
|
|
92
92
|
} catch (err) {
|
|
93
|
-
if (buf.length < 64) {
|
|
94
|
-
return { bytes: buf, mediaType: fallbackMime || targetMime || actualMime || 'image/png' }
|
|
95
|
-
}
|
|
96
93
|
throw new Error(`Failed to encode image to ${canonical}: ${err.message}`)
|
|
97
94
|
}
|
|
98
95
|
}
|