@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 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-2.0-flash-exp-image-generation',
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-2.0-flash-exp-image-generation',
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',
@@ -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-2.0-flash-exp-image-generation')
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
- mkdirSync(join(filePath, '..'), { recursive: true })
110
- writeFileSync(filePath, JSON.stringify(current, null, 2), 'utf8')
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 projected = +(current.totalSpendUsd + cost).toFixed(4)
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
+ }
@@ -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 < 8) {
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 { data, info } = await sharp(buffer)
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
- const totalPixels = info.width * info.height
106
- const step = Math.max(1, Math.floor(totalPixels / 2048))
107
- const colorBuckets = new Map()
108
-
109
- for (let i = 0; i < totalPixels; i += step) {
110
- const off = i * 4
111
- const r = data[off]
112
- const g = data[off + 1]
113
- const b = data[off + 2]
114
- const a = data[off + 3]
115
-
116
- // Ignore fully or mostly transparent pixels
117
- if (a < 128) continue
118
-
119
- // Quantize to 16-level RGB bins to cluster nearby nuances
120
- const qr = Math.round(r / 16) * 16
121
- const qg = Math.round(g / 16) * 16
122
- const qb = Math.round(b / 16) * 16
123
- const key = `${qr},${qg},${qb}`
124
-
125
- let bucket = colorBuckets.get(key)
126
- if (!bucket) {
127
- bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
128
- colorBuckets.set(key, bucket)
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
- if (colorBuckets.size > 0) {
137
- const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
138
- const dominant = sorted.slice(0, 6).map((b) => {
139
- return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
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
- const result = [...dominant]
143
- if (result.length < 5) {
144
- const primaryRgb = hexToRgb(result[0])
145
- const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
146
- if (isNearBlack) {
147
- const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
148
- for (const s of darkShades) {
149
- if (result.length >= 6) break
150
- if (!result.includes(s)) result.push(s)
151
- }
152
- } else {
153
- const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
154
- for (const factor of factors) {
155
- if (result.length >= 6) break
156
- const shade = rgbToHex(
157
- Math.min(255, Math.round(primaryRgb.r * factor)),
158
- Math.min(255, Math.round(primaryRgb.g * factor)),
159
- Math.min(255, Math.round(primaryRgb.b * factor)),
160
- )
161
- if (!result.includes(shade)) result.push(shade)
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
- } catch (_err) {
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(/&colon;/gi, ':')
355
+ .replace(/&tab;/gi, '\t')
356
+ .replace(/&newline;/gi, '\n')
357
+ .replace(/&quot;/gi, '"')
358
+ .replace(/&apos;/gi, "'")
359
+ .replace(/&amp;/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 event handler attributes (onload, onerror, onclick, etc.)
367
- cleaned = cleaned.replace(/\s+on[a-z]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>]+)/gi, '')
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 dangerous URI schemes (javascript:, data:text/html, vbscript:) in href/xlink:href/src/action
370
- cleaned = cleaned.replace(/\s+(?:href|xlink:href|src|action)\s*=\s*(?:"\s*(?:javascript|data:text\/html|vbscript):[^"]*"|'\s*(?:javascript|data:text\/html|vbscript):[^']*')/gi, '')
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 (&#x73;), 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
- recordSpend(gen.cost || (estimatedCost / totalCount), {
266
- ctx,
267
- meta: { provider, model: cfg.model || cfg.customModel, prompt: promptArg, seed: gen.seed },
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
- return entries.find((e) => e.prompt === prompt)
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) => e.seed === seed && e.prompt === prompt)
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',
@@ -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
- if (existing) {
18
- existing.listeners.clear()
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: new Set(),
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
- const derivedTurnId = turnId ??
47
- exec?.turnId ??
48
- exec?.agent?.turnId ??
49
- exec?.message?.id ??
50
- session?.lastMessageId ??
51
- session?.turnId ??
52
- (Array.isArray(session?.messages) ? session.messages.length : null)
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-2.0-flash-exp-image-generation'
19
- // #364: Transparently migrate deprecated Imagen 3/4 IDs to Gemini multimodal model
20
- if (model.startsWith('imagen-') || model === 'imagen-3.0-generate-002') {
21
- model = 'gemini-2.0-flash-exp-image-generation'
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
- // Build unified imageConfig without quality/style overwrite collisions (#364)
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) imageConfig.aspectRatio = aspectRatio
59
- if (quality && quality !== 'auto') imageConfig.imageQuality = quality
60
- if (style && style !== 'none') imageConfig.imageStyle = style
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
  }