@goodandready/dsh-image-gen 0.11.24 → 0.11.25

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.
@@ -1,6 +1,6 @@
1
1
  import path from 'node:path'
2
2
  import { mkdir, writeFile } from 'node:fs/promises'
3
- import { saveAttachmentSafe, buildSidecar, toLosslessJson } from './providers.js'
3
+ import { saveAttachmentSafe, buildSidecar, toLosslessJson, ensureImageFormat } from './providers.js'
4
4
  import { recordSpend } from './cost-meter.js'
5
5
  import { buildDualOutputMarkdown } from './resolve-image.js'
6
6
 
@@ -21,6 +21,10 @@ export async function saveAndAttachResult(ctx, exec, cfg, {
21
21
  args = {},
22
22
  action = 'generated',
23
23
  }) {
24
+ const targetFormat = format || (mediaType ? mediaType.replace('image/', '') : undefined)
25
+ const formatted = await ensureImageFormat(bytes, targetFormat, { fallbackMime: mediaType })
26
+ bytes = formatted.bytes
27
+ mediaType = formatted.mediaType
24
28
  const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes, mediaType, name })
25
29
 
26
30
  const sessionCwd = exec?.agent?.session?.header?.cwd
package/lib/commands.js CHANGED
@@ -85,11 +85,14 @@ export function registerImageCommand(ctx, deps) {
85
85
  const size = flags.size || cfg.defaultSize || 'square'
86
86
  const format = flags.format || cfg.defaultFormat || 'png'
87
87
  const aspectRatio = flags.aspect || flags.aspect_ratio || cfg.defaultAspectRatio
88
- const effectiveStyle = flags.style || cfg.stylePreset
88
+ const effectivePreset = (flags.style_preset !== undefined && flags.style_preset !== null && flags.style_preset !== '')
89
+ ? flags.style_preset
90
+ : (flags.style || cfg.stylePreset || cfg.defaultStylePreset)
89
91
  const seed = flags.seed ? Number(flags.seed) : Math.floor(Math.random() * 2147483647)
90
92
 
91
- const styleInfo = resolveStylePreset(effectiveStyle, flags.negative_prompt, flags.guidance_scale)
92
- const effectivePrompt = applyStylePreset(prompt, effectiveStyle)
93
+ const styleInfo = resolveStylePreset(effectivePreset, flags.negative_prompt, flags.guidance_scale)
94
+ const effectivePrompt = styleInfo.promptSuffix ? `${prompt}, ${styleInfo.promptSuffix}` : prompt
95
+ const effectiveNegative = styleInfo.negativePrompt || flags.negative_prompt || cfg.negativePrompt
93
96
 
94
97
  const job = {
95
98
  prompt: effectivePrompt,
@@ -99,6 +102,9 @@ export function registerImageCommand(ctx, deps) {
99
102
  aspectPixels: aspectRatio ? ASPECT_RATIOS[aspectRatio] : undefined,
100
103
  seed,
101
104
  signal: invocation?.signal,
105
+ negativePrompt: effectiveNegative,
106
+ guidanceScale: styleInfo.guidanceScale ?? flags.guidance_scale,
107
+ style: flags.style,
102
108
  }
103
109
 
104
110
  const pdeps = {
@@ -176,8 +176,8 @@ export const Config = z.object({
176
176
  .volatile(),
177
177
  geminiModel: z
178
178
  .string()
179
- .description('Google Gemini Imagen model identifier.')
180
- .default('imagen-3.0-generate-002')
179
+ .description('Google Gemini image generation model identifier.')
180
+ .default('gemini-2.0-flash-exp-image-generation')
181
181
  .volatile(),
182
182
  geminiKeyEnv: z
183
183
  .string()
@@ -13,6 +13,7 @@ import {
13
13
  embedPngMetadata,
14
14
  resolveStylePreset,
15
15
  saveAttachmentSafe,
16
+ ensureImageFormat,
16
17
  } from './providers.js'
17
18
  import { enhancePrompt } from './prompt-enhancer.js'
18
19
  import { buildFaceConsistencyPrompt, getSessionAnchor, applyAnchorPromptHints } from './anchor-helpers.js'
@@ -23,6 +24,7 @@ import {
23
24
  readHistory,
24
25
  writeHistory,
25
26
  pruneHistory,
27
+ appendHistoryEntry,
26
28
  } from './history.js'
27
29
 
28
30
  /** Run an array of async task functions with a concurrency cap */
@@ -62,8 +64,10 @@ export function validateGenerationParams(cfg, args) {
62
64
  /** Prepares enhanced prompt, style hints, facial identity, anchors, and sanitized negative prompt */
63
65
  export async function prepareGenerationPrompt({ ctx, cfg, args, exec, sessionId, provider, resolveSource }) {
64
66
  const enhanced = await enhancePrompt(ctx, cfg, args.prompt, exec.signal, provider)
65
- const effectiveStyle = args.style || cfg.stylePreset
66
- const styleInfo = resolveStylePreset(effectiveStyle, args.negative_prompt, args.guidance_scale)
67
+ const candidatePreset = (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
68
+ ? args.style_preset
69
+ : (cfg.stylePreset || cfg.defaultStylePreset)
70
+ const styleInfo = resolveStylePreset(candidatePreset, args.negative_prompt, args.guidance_scale)
67
71
  let effectivePrompt = styleInfo.promptSuffix ? `${enhanced.prompt}, ${styleInfo.promptSuffix}` : enhanced.prompt
68
72
  let effectiveNegative = styleInfo.negativePrompt
69
73
  const activeAnchor = getSessionAnchor(sessionId)
@@ -132,8 +136,10 @@ export async function saveGeneratedImageArtifact({
132
136
  estimatedCost,
133
137
  totalCount,
134
138
  }) {
135
- const mediaType = gen.mediaType || 'image/png'
136
- let bytes = gen.bytes
139
+ const targetFormat = format || (args.output_format || cfg.defaultFormat)
140
+ const formatted = await ensureImageFormat(gen.bytes, targetFormat, { fallbackMime: gen.mediaType })
141
+ let bytes = formatted.bytes
142
+ const mediaType = formatted.mediaType || 'image/png'
137
143
  if (mediaType === 'image/png') {
138
144
  bytes = embedPngMetadata(bytes, {
139
145
  prompt: promptArg,
@@ -189,7 +195,10 @@ export async function saveGeneratedImageArtifact({
189
195
  prompt: promptArg,
190
196
  seed: gen.seed,
191
197
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
192
- style: args.style || cfg.stylePreset,
198
+ style: args.style,
199
+ stylePreset: (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
200
+ ? args.style_preset
201
+ : (cfg.stylePreset || cfg.defaultStylePreset),
193
202
  format: format || args.output_format || cfg.defaultFormat || 'png',
194
203
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
195
204
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
@@ -229,13 +238,10 @@ export async function saveGeneratedImageArtifact({
229
238
  attachmentId: attachment.attachmentId,
230
239
  }
231
240
  // Save to history (#385: historyLimit=0 skips history retention; bounds guard against negative values)
241
+ // #365: Atomic transaction with file lock prevents read-modify-write race during concurrent batches
232
242
  const historyLimit = typeof cfg.historyLimit === 'number' ? Math.max(0, Math.floor(cfg.historyLimit)) : 50
233
243
  if (historyLimit > 0) {
234
- let current = await readHistory()
235
- current = await pruneHistory(current, cfg.pruneDays)
236
- current.unshift(entry)
237
- if (current.length > historyLimit) current.length = historyLimit
238
- await writeHistory(current)
244
+ await appendHistoryEntry(entry, { pruneDays: cfg.pruneDays, historyLimit })
239
245
  }
240
246
 
241
247
  // #167: Record spend in cost meter
package/lib/history.js CHANGED
@@ -1,9 +1,30 @@
1
1
  import path from 'node:path'
2
2
  import os from 'node:os'
3
3
  import { existsSync, unlinkSync, chmodSync, readdirSync, statSync } from 'node:fs'
4
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
4
+ import { readFile, mkdir } from 'node:fs/promises'
5
+ import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
5
6
  import { enforceSecurePermissions } from './security.js'
6
7
 
8
+ let inMemoryLockQueue = Promise.resolve()
9
+
10
+ /** Execute an operation with in-process queue + cross-process file lock (#365) */
11
+ export async function withHistoryLock(operation, options = { waitMs: 5000 }) {
12
+ const file = historyFile()
13
+ const dir = path.dirname(file)
14
+ try {
15
+ await mkdir(dir, { recursive: true, mode: 0o700 })
16
+ try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
17
+ } catch (_err) { /* non-fatal mkdir */ }
18
+
19
+ const runWithLocks = async () => {
20
+ return await withFileLock(file, operation, options)
21
+ }
22
+
23
+ const next = inMemoryLockQueue.then(runWithLocks, runWithLocks)
24
+ inMemoryLockQueue = next.catch(() => {})
25
+ return await next
26
+ }
27
+
7
28
  export function historyFile() {
8
29
  return path.join(process.env.DSH_HOME || path.join(os.homedir(), '.dsh'), 'dsh-image-gen', 'history.json')
9
30
  }
@@ -104,21 +125,50 @@ export async function readHistory() {
104
125
  const raw = await readFile(historyFile(), 'utf-8')
105
126
  const parsed = JSON.parse(raw)
106
127
  return Array.isArray(parsed) ? parsed : []
107
- } catch (e) {
128
+ } catch (_e) {
108
129
  return []
109
130
  }
110
131
  }
111
132
 
112
- /** Save history to disk file (atomic overwrite). */
133
+ /** Save history to disk file (atomic overwrite with 0600 mode via dsh-atomic-write). */
113
134
  export async function writeHistory(entries) {
114
135
  try {
115
136
  const file = historyFile()
116
137
  const dir = path.dirname(file)
117
138
  await mkdir(dir, { recursive: true, mode: 0o700 })
118
139
  try { chmodSync(dir, 0o700) } catch (_err) { /* non-fatal permission repair */ }
119
- await writeFile(file, JSON.stringify(entries, null, 2), { mode: 0o600 })
140
+ await writeFileAtomic(file, JSON.stringify(entries, null, 2), {
141
+ mode: 0o600,
142
+ dirMode: 0o700,
143
+ })
120
144
  enforceSecurePermissions(file)
121
- } catch (e) { /* history write failure is non-fatal */ }
145
+ } catch (_e) { /* history write failure is non-fatal */ }
146
+ }
147
+
148
+ /**
149
+ * Atomically append an entry to history, running read -> prune -> unshift -> write
150
+ * under an exclusive lock transaction (#365).
151
+ */
152
+ export async function appendHistoryEntry(entry, { pruneDays = 0, historyLimit = 50 } = {}) {
153
+ if (!entry || historyLimit <= 0) return []
154
+ return await withHistoryLock(async () => {
155
+ let current = await readHistory()
156
+ if (pruneDays > 0) {
157
+ current = await pruneHistory(current, pruneDays)
158
+ }
159
+ const existingIdx = current.findIndex(
160
+ (e) => (entry.attachmentId && e.attachmentId === entry.attachmentId) || (entry.path && e.path === entry.path)
161
+ )
162
+ if (existingIdx !== -1) {
163
+ current.splice(existingIdx, 1)
164
+ }
165
+ current.unshift(entry)
166
+ if (current.length > historyLimit) {
167
+ current.length = historyLimit
168
+ }
169
+ await writeHistory(current)
170
+ return current
171
+ })
122
172
  }
123
173
 
124
174
  /** Filter entries whose files exist on disk; newest first. */
@@ -117,6 +117,7 @@ export function computeGenerationHash({
117
117
  seed,
118
118
  size,
119
119
  style,
120
+ stylePreset,
120
121
  format,
121
122
  aspectRatio,
122
123
  negativePrompt,
@@ -144,6 +145,7 @@ export function computeGenerationHash({
144
145
  String(seed ?? ''),
145
146
  String(size || '').trim().toLowerCase(),
146
147
  String(style || '').trim().toLowerCase(),
148
+ String(stylePreset || '').trim().toLowerCase(),
147
149
  String(format || '').trim().toLowerCase(),
148
150
  String(aspectRatio || '').trim().toLowerCase(),
149
151
  String(negativePrompt || '').trim(),
@@ -4,6 +4,7 @@ import {
4
4
  SIZE_PIXELS,
5
5
  formatErrorMessage,
6
6
  buildEndpointUrl,
7
+ ensureImageFormat,
7
8
  } from '../shared-helpers.js'
8
9
 
9
10
  /**
@@ -49,6 +50,7 @@ export function createCustomGenerator(deps, job) {
49
50
  prompt: promptArg,
50
51
  n: 1,
51
52
  size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
53
+ ...(format ? { output_format: format } : {}),
52
54
  ...(negativePrompt !== undefined ? { negative_prompt: negativePrompt } : {}),
53
55
  ...(guidanceScale !== undefined ? { guidance_scale: guidanceScale } : {}),
54
56
  ...(quality && quality !== 'auto' ? { quality } : {}),
@@ -72,9 +74,14 @@ export function createCustomGenerator(deps, job) {
72
74
  }
73
75
 
74
76
  if (item.b64_json) {
77
+ const rawBytes = Buffer.from(item.b64_json, 'base64')
78
+ const targetFormat = format || data.output_format
79
+ const formatted = await ensureImageFormat(rawBytes, targetFormat, {
80
+ fallbackMime: normalizeMediaType(data.output_format || '', format, rawBytes),
81
+ })
75
82
  return {
76
- bytes: Buffer.from(item.b64_json, 'base64'),
77
- mediaType: normalizeMediaType(data.output_format || '', format),
83
+ bytes: formatted.bytes,
84
+ mediaType: formatted.mediaType,
78
85
  width: 0,
79
86
  height: 0,
80
87
  seed: seedArg ?? 0,
@@ -91,9 +98,15 @@ export function createCustomGenerator(deps, job) {
91
98
  const contentType = download.headers && typeof download.headers.get === 'function'
92
99
  ? download.headers.get('content-type')
93
100
  : ''
101
+ const rawBytes = Buffer.from(await download.arrayBuffer())
102
+ const downloadFormat = contentType ? contentType.replace('image/', '').trim().toLowerCase() : undefined
103
+ const targetFormat = (format && format !== 'png') ? format : (downloadFormat || format)
104
+ const formatted = await ensureImageFormat(rawBytes, targetFormat, {
105
+ fallbackMime: normalizeMediaType(contentType, format),
106
+ })
94
107
  return {
95
- bytes: Buffer.from(await download.arrayBuffer()),
96
- mediaType: normalizeMediaType(contentType, format),
108
+ bytes: formatted.bytes,
109
+ mediaType: formatted.mediaType,
97
110
  width: 0,
98
111
  height: 0,
99
112
  seed: seedArg ?? 0,
@@ -1,5 +1,6 @@
1
1
  import {
2
2
  normalizeMediaType,
3
+ ensureImageFormat,
3
4
  } from '../shared-helpers.js'
4
5
 
5
6
  /**
@@ -14,19 +15,49 @@ export function createGeminiGenerator(deps, job) {
14
15
 
15
16
  async function gemini(seedArg = seed, promptArg = prompt) {
16
17
  const key = await resolveKey(cfg.geminiKeyEnv)
17
- const model = cfg.geminiModel || 'gemini-2.0-flash-exp-image-generation'
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'
22
+ }
23
+
18
24
  const timeoutSignal = typeof AbortSignal !== 'undefined' && AbortSignal.timeout
19
25
  ? AbortSignal.timeout(cfg.timeoutMs || 120000)
20
26
  : undefined
21
27
  const effectiveSignal = signal && timeoutSignal && AbortSignal.any
22
28
  ? AbortSignal.any([signal, timeoutSignal])
23
29
  : (signal || timeoutSignal)
30
+
31
+ const parts = []
32
+ if (source && source.bytes) {
33
+ const sourceMime = source.mediaType || 'image/png'
34
+ const sourceBase64 = Buffer.isBuffer(source.bytes) ? source.bytes.toString('base64') : Buffer.from(source.bytes).toString('base64')
35
+ parts.push({
36
+ inlineData: {
37
+ mimeType: sourceMime,
38
+ data: sourceBase64,
39
+ },
40
+ })
41
+ }
42
+ parts.push({ text: promptArg })
43
+
44
+ // Build unified imageConfig without quality/style overwrite collisions (#364)
45
+ const imageConfig = {}
46
+ if (aspectRatio) imageConfig.aspectRatio = aspectRatio
47
+ if (quality && quality !== 'auto') imageConfig.imageQuality = quality
48
+ if (style && style !== 'none') imageConfig.imageStyle = style
49
+
50
+ const generationConfig = {
51
+ responseModalities: ['IMAGE'],
52
+ ...(Object.keys(imageConfig).length > 0 ? { imageConfig } : {}),
53
+ }
54
+
24
55
  const res = await fetchImpl(`https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent?key=${encodeURIComponent(key)}`, {
25
56
  method: 'POST',
26
57
  headers: { 'Content-Type': 'application/json' },
27
58
  body: JSON.stringify({
28
- contents: [{ parts: [{ text: promptArg }] }],
29
- generationConfig: { responseModalities: ['IMAGE'], ...(quality !== undefined ? { imageConfig: { imageQuality: quality } } : {}), ...(style !== undefined ? { imageConfig: { imageStyle: style } } : {}) },
59
+ contents: [{ parts }],
60
+ generationConfig,
30
61
  }),
31
62
  signal: effectiveSignal,
32
63
  })
@@ -37,7 +68,17 @@ export function createGeminiGenerator(deps, job) {
37
68
  }
38
69
  const part = data?.candidates?.[0]?.content?.parts?.find((p) => p.inlineData?.data)
39
70
  if (!part) throw new Error('Gemini returned no image')
40
- return { bytes: Buffer.from(part.inlineData.data, 'base64'), mediaType: normalizeMediaType(part.inlineData.mimeType || 'image/png', format), width: 0, height: 0, seed: seedArg ?? 0, sourceUrl: '' }
71
+
72
+ const rawBytes = Buffer.from(part.inlineData.data, 'base64')
73
+ const formatted = await ensureImageFormat(rawBytes, format || part.inlineData.mimeType)
74
+ return {
75
+ bytes: formatted.bytes,
76
+ mediaType: formatted.mediaType,
77
+ width: 0,
78
+ height: 0,
79
+ seed: seedArg ?? 0,
80
+ sourceUrl: '',
81
+ }
41
82
  }
42
83
  return gemini
43
84
  }
@@ -1,3 +1,4 @@
1
+ import sharp from 'sharp'
1
2
  /**
2
3
  * Shared provider HTTP/auth helpers used by per-backend factories.
3
4
  * Extracted from providers.js for #218 modularization.
@@ -25,16 +26,77 @@ export function falAuthHeader(key) {
25
26
  : `Key ${trimmed}`
26
27
  }
27
28
 
28
- export function normalizeMediaType(contentType, fallbackFormat) {
29
+ export function detectImageMediaType(buf) {
30
+ if (!buf || (!Buffer.isBuffer(buf) && !(buf instanceof Uint8Array))) return undefined
31
+ const b = Buffer.isBuffer(buf) ? buf : Buffer.from(buf)
32
+ if (b.length < 4) return undefined
33
+ if (b[0] === 0x89 && b[1] === 0x50 && b[2] === 0x4e && b[3] === 0x47) return 'image/png'
34
+ if (b[0] === 0xff && b[1] === 0xd8 && b[2] === 0xff) return 'image/jpeg'
35
+ if (b.length >= 12 && b[0] === 0x52 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x46 &&
36
+ b[8] === 0x57 && b[9] === 0x45 && b[10] === 0x42 && b[11] === 0x50) {
37
+ return 'image/webp'
38
+ }
39
+ if (b[0] === 0x47 && b[1] === 0x49 && b[2] === 0x46 && b[3] === 0x38) return 'image/gif'
40
+ if (b.length > 5) {
41
+ const head = b.subarray(0, Math.min(256, b.length)).toString('utf8').trimStart()
42
+ if (head.startsWith('<svg') || (head.startsWith('<?xml') && head.includes('<svg'))) {
43
+ return 'image/svg+xml'
44
+ }
45
+ }
46
+ return undefined
47
+ }
48
+
49
+ export function normalizeMediaType(contentType, fallbackFormat, bytes) {
50
+ if (bytes) {
51
+ const detected = detectImageMediaType(bytes)
52
+ if (detected) return detected
53
+ }
29
54
  const raw = String(contentType ?? '').toLowerCase()
30
55
  if (raw.includes('jpeg') || raw.includes('jpg')) return 'image/jpeg'
31
56
  if (raw.includes('webp')) return 'image/webp'
32
57
  if (raw.includes('png')) return 'image/png'
33
- if (fallbackFormat === 'jpeg') return 'image/jpeg'
58
+ if (fallbackFormat === 'jpeg' || fallbackFormat === 'jpg') return 'image/jpeg'
34
59
  if (fallbackFormat === 'webp') return 'image/webp'
60
+ if (fallbackFormat === 'png') return 'image/png'
35
61
  return 'image/png'
36
62
  }
37
63
 
64
+ /**
65
+ * Ensures image bytes match the requested target format ('png', 'jpeg', 'webp').
66
+ * If bytes already match target format, returns them with truthful mediaType.
67
+ * If bytes differ (e.g. provider returned PNG for a webp request), transcodes via Sharp (#386).
68
+ */
69
+ export async function ensureImageFormat(bytes, targetFormat, { fallbackMime } = {}) {
70
+ if (!bytes) return { bytes: Buffer.alloc(0), mediaType: fallbackMime || 'image/png' }
71
+ const buf = Buffer.isBuffer(bytes) ? bytes : Buffer.from(bytes)
72
+ const actualMime = detectImageMediaType(buf)
73
+ if (!targetFormat && !fallbackMime) return { bytes: buf, mediaType: actualMime || 'image/png' }
74
+
75
+ const rawFormat = String(targetFormat || '').trim().toLowerCase()
76
+ const canonical = rawFormat === 'jpg' ? 'jpeg' : rawFormat
77
+
78
+ if (canonical && !['png', 'jpeg', 'webp'].includes(canonical)) {
79
+ throw new Error(`Unsupported output format "${targetFormat}". Supported formats: png, jpeg, webp`)
80
+ }
81
+
82
+ const targetMime = canonical ? `image/${canonical}` : fallbackMime
83
+ if (actualMime === targetMime) {
84
+ return { bytes: buf, mediaType: targetMime }
85
+ }
86
+
87
+ try {
88
+ if (!canonical) return { bytes: buf, mediaType: fallbackMime || actualMime || 'image/png' }
89
+ const transcoded = await sharp(buf).toFormat(canonical).toBuffer()
90
+ const verified = detectImageMediaType(transcoded) || targetMime
91
+ return { bytes: transcoded, mediaType: verified }
92
+ } catch (err) {
93
+ if (buf.length < 64) {
94
+ return { bytes: buf, mediaType: fallbackMime || targetMime || actualMime || 'image/png' }
95
+ }
96
+ throw new Error(`Failed to encode image to ${canonical}: ${err.message}`)
97
+ }
98
+ }
99
+
38
100
  export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
39
101
  const res = await fetchImpl(`${baseURL}/${model}`, {
40
102
  method: 'POST',
package/lib/providers.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { CURATED_STYLES, normalizeStyleKey } from './prompt-polisher.js'
1
2
  /**
2
3
  * Returns candidate API key names considering known aliases (e.g. FAL_API_KEY <-> FAL_KEY).
3
4
  */
@@ -27,6 +28,8 @@ import { createReplicateGenerator } from './providers/backends/replicate.js'
27
28
  export {
28
29
  falAuthHeader,
29
30
  normalizeMediaType,
31
+ detectImageMediaType,
32
+ ensureImageFormat,
30
33
  submitJob,
31
34
  pollStatus,
32
35
  buildEditForm,
@@ -288,47 +291,36 @@ export function traceToSvg(imageBytes, { colorMode = 'color', paletteSize = 16,
288
291
 
289
292
 
290
293
  export const STYLE_PRESETS = {
294
+ ...CURATED_STYLES,
291
295
  cinematic: {
296
+ ...CURATED_STYLES.cinematic,
292
297
  promptSuffix: 'cinematic film still, 35mm photograph, dramatic lighting, highly detailed',
293
298
  negativePrompt: 'cartoon, illustration, 3d render, oversaturated, blurry, distorted',
294
299
  guidanceScale: 6.5,
295
300
  },
296
- anime: {
297
- promptSuffix: 'anime aesthetic, Makoto Shinkai style, vibrant colors, detailed lineart, masterpiece',
298
- negativePrompt: 'photorealistic, 3d, realistic photo, noisy, deformed, lowres',
299
- guidanceScale: 7.0,
300
- },
301
- isometric: {
301
+ isometric: CURATED_STYLES.isometric_3d || {
302
302
  promptSuffix: 'isometric 3D render, clay style, soft studio lighting, clean ambient occlusion',
303
303
  negativePrompt: 'flat, 2d, sketch, photograph, noisy background',
304
304
  guidanceScale: 7.0,
305
305
  },
306
- cyberpunk: {
307
- promptSuffix: 'cyberpunk aesthetic, neon reflections, volumetric fog, moody dark atmosphere',
308
- negativePrompt: 'daylight, cartoon, pastel, monochrome, blurry',
309
- guidanceScale: 7.5,
310
- },
311
- pixel_art: {
312
- promptSuffix: '16-bit retro pixel art, crisp pixels, dithered shading, nostalgic game palette',
313
- negativePrompt: 'smooth, vector, 3d render, blurry, photorealistic',
314
- guidanceScale: 8.0,
315
- },
316
- oil_painting: {
317
- promptSuffix: 'classical oil painting, visible canvas texture, rich brushstrokes, fine art',
318
- negativePrompt: 'photo, digital render, 3d, plastic, anime, flat vector',
319
- guidanceScale: 7.0,
320
- },
321
- minimalist: {
322
- promptSuffix: 'minimalist graphic design, clean lines, flat vector illustration, elegant geometry',
323
- negativePrompt: 'busy, cluttered, photorealistic, 3d, noisy texture, gradient overload',
324
- guidanceScale: 7.0,
325
- },
306
+ vector: CURATED_STYLES.minimalist_vector,
307
+ minimalist: CURATED_STYLES.minimalist_vector,
308
+ film: CURATED_STYLES.analog_film,
309
+ analog: CURATED_STYLES.analog_film,
310
+ photo: CURATED_STYLES.photorealistic,
311
+ realism: CURATED_STYLES.photorealistic,
326
312
  }
327
313
 
328
314
  export function resolveStylePreset(styleKeyOrText, existingNegative, existingGuidance) {
329
315
  if (!styleKeyOrText) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
330
- const key = String(styleKeyOrText).toLowerCase().replace(/[-\s]/g, '_')
331
- const preset = STYLE_PRESETS[key]
316
+ const raw = String(styleKeyOrText).trim()
317
+ if (!raw) return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
318
+ const key = raw.toLowerCase().replace(/[-\s]/g, '_')
319
+ if (key === 'none' || key === 'default' || key === 'off') {
320
+ return { promptSuffix: '', negativePrompt: existingNegative, guidanceScale: existingGuidance }
321
+ }
322
+ const canonicalKey = normalizeStyleKey(key) || key
323
+ const preset = STYLE_PRESETS[canonicalKey] || STYLE_PRESETS[key]
332
324
  if (preset && typeof preset === 'object') {
333
325
  const combinedNeg = [existingNegative, preset.negativePrompt].filter(Boolean).join(', ')
334
326
  return {
@@ -338,7 +330,7 @@ export function resolveStylePreset(styleKeyOrText, existingNegative, existingGui
338
330
  }
339
331
  }
340
332
  return {
341
- promptSuffix: typeof preset === 'string' ? preset : String(styleKeyOrText),
333
+ promptSuffix: typeof preset === 'string' ? preset : raw,
342
334
  negativePrompt: existingNegative,
343
335
  guidanceScale: existingGuidance,
344
336
  }
@@ -340,29 +340,6 @@ export function registerGenerationTools(ctx, deps, options = {}) {
340
340
  // rejection reaches the model as text. Without this guard,
341
341
  // execution would proceed with empty bytes.
342
342
  const guard = (generated) => { if (generated && generated.ok === false) throw new Error(generated.reason) }
343
- const one = async (jobSeed, providerKey, promptArg = effectivePrompt) => {
344
- const gen = await providers[providerKey](jobSeed, promptArg)
345
- guard(gen)
346
- return await saveGeneratedImageArtifact({
347
- ctx,
348
- exec,
349
- cfg,
350
- args,
351
- gen,
352
- promptArg,
353
- jobSeed,
354
- provider,
355
- deliverAs,
356
- size,
357
- format,
358
- effectiveNegative,
359
- effectiveGuidance,
360
- enhanced,
361
- slugify,
362
- estimatedCost,
363
- totalCount,
364
- })
365
- }
366
343
 
367
344
  const seedBase = args.seed ?? Math.floor(Math.random() * 100000)
368
345
  // Fallback cascade: evaluate configured provider, falling back to alternates
@@ -372,8 +349,20 @@ export function registerGenerationTools(ctx, deps, options = {}) {
372
349
  const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
373
350
  k,
374
351
  async (s, p) => {
375
- const { generated, qualityReport } = await executeWithQualityGate(
376
- (seedVal) => one(seedVal, k, p),
352
+ let totalAttempts = 0
353
+ let accumulatedCost = 0
354
+ // #353: Pipeline evaluates raw gen.bytes first; saves artifact ONCE after acceptance
355
+ const rawGenerate = async (seedVal) => {
356
+ totalAttempts++
357
+ const gen = await providers[k](seedVal, p)
358
+ guard(gen)
359
+ const attemptCost = Number(gen.cost) || estimatedCost
360
+ accumulatedCost += attemptCost
361
+ return gen
362
+ }
363
+
364
+ const { generated: acceptedGen, qualityReport } = await executeWithQualityGate(
365
+ rawGenerate,
377
366
  {
378
367
  initialSeed: s,
379
368
  enabled: qgEnabled,
@@ -381,7 +370,36 @@ export function registerGenerationTools(ctx, deps, options = {}) {
381
370
  prompt: p,
382
371
  }
383
372
  )
384
- return { ...generated, qualityReport }
373
+
374
+ // Save the accepted artifact ONCE
375
+ const saved = await saveGeneratedImageArtifact({
376
+ ctx,
377
+ exec,
378
+ cfg,
379
+ args,
380
+ gen: acceptedGen,
381
+ promptArg: p,
382
+ jobSeed: qualityReport.seedUsed ?? s,
383
+ provider,
384
+ deliverAs,
385
+ size,
386
+ format,
387
+ effectiveNegative,
388
+ effectiveGuidance,
389
+ enhanced,
390
+ slugify,
391
+ estimatedCost: accumulatedCost || estimatedCost,
392
+ totalCount,
393
+ })
394
+
395
+ return {
396
+ ...saved,
397
+ qualityReport: {
398
+ ...qualityReport,
399
+ attempts: totalAttempts,
400
+ totalCost: accumulatedCost,
401
+ },
402
+ }
385
403
  }
386
404
  ]))
387
405
  const images = []
@@ -400,7 +418,10 @@ export function registerGenerationTools(ctx, deps, options = {}) {
400
418
  prompt: promptVal,
401
419
  seed: seedVal,
402
420
  size: args.aspect_ratio ? 'custom' : (args.image_size || cfg.defaultSize),
403
- style: args.style || cfg.stylePreset,
421
+ style: args.style,
422
+ stylePreset: (args.style_preset !== undefined && args.style_preset !== null && args.style_preset !== '')
423
+ ? args.style_preset
424
+ : (cfg.stylePreset || cfg.defaultStylePreset),
404
425
  format: args.output_format || cfg.defaultFormat || 'png',
405
426
  aspectRatio: args.aspect_ratio || cfg.defaultAspectRatio,
406
427
  negativePrompt: args.negative_prompt || cfg.negativePrompt,
@@ -482,7 +503,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
482
503
  images.push(...parallelResults)
483
504
  }
484
505
  const first = images[0]
485
- const dualOutputSummary = buildDualOutputMarkdown({
506
+ let dualOutputSummary = buildDualOutputMarkdown({
486
507
  action: 'generated',
487
508
  filePath: first.path,
488
509
  width: first.width,
@@ -494,6 +515,9 @@ export function registerGenerationTools(ctx, deps, options = {}) {
494
515
  cost: first.cost,
495
516
  attachmentId: first.attachment?.attachmentId || 'N/A',
496
517
  })
518
+ if (first.qualityReport?.exhausted) {
519
+ dualOutputSummary += `\n\n> ⚠️ **Quality Gate Warning**: Visual quality checks failed after ${first.qualityReport.attempts || 3} attempts (${first.qualityReport.defect || 'defect detected'}). Image retained with potential defects.`
520
+ }
497
521
  return toLosslessJson({ summary: dualOutputSummary, channel: provider, provider, model: cfg.model || cfg.customModel, _fallback: first._fallback, anchor: activeAnchor ? { label: activeAnchor.label, mode: activeAnchor.mode } : undefined, ...first, images })
498
522
  },
499
523
  }),
package/lib/vault.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { existsSync, unlinkSync } from 'node:fs'
2
- import { readHistory, writeHistory } from './history.js'
2
+ import { readHistory, writeHistory, withHistoryLock } from './history.js'
3
3
  import { isTrustedLocalRequest } from './security.js'
4
4
 
5
5
  /**
@@ -133,24 +133,26 @@ export async function readBoundedRequestBody(req, maxBytes = MAX_VAULT_BODY_BYTE
133
133
 
134
134
  export async function deleteVaultEntry(id) {
135
135
  if (!id) return { success: false, error: 'Missing entry id' }
136
- const entries = await readHistory()
137
- const idx = entries.findIndex((e) => e.id === id || e.attachmentId === id)
138
- if (idx === -1) {
139
- return { success: false, error: 'Vault entry not found' }
140
- }
136
+ return await withHistoryLock(async () => {
137
+ const entries = await readHistory()
138
+ const idx = entries.findIndex((e) => e.id === id || e.attachmentId === id)
139
+ if (idx === -1) {
140
+ return { success: false, error: 'Vault entry not found' }
141
+ }
141
142
 
142
- const [entry] = entries.splice(idx, 1)
143
- if (entry && entry.path) {
144
- try {
145
- unlinkSync(entry.path)
146
- } catch { /* ignore missing file */ }
147
- try {
148
- unlinkSync(entry.path.replace(/\.[^.]+$/, '.json'))
149
- } catch { /* ignore missing sidecar */ }
150
- }
143
+ const [entry] = entries.splice(idx, 1)
144
+ if (entry && entry.path) {
145
+ try {
146
+ unlinkSync(entry.path)
147
+ } catch (_err) { /* ignore missing file */ }
148
+ try {
149
+ unlinkSync(entry.path.replace(/\.[^.]+$/, '.json'))
150
+ } catch (_err) { /* ignore missing sidecar */ }
151
+ }
151
152
 
152
- await writeHistory(entries)
153
- return { success: true, id }
153
+ await writeHistory(entries)
154
+ return { success: true, id }
155
+ })
154
156
  }
155
157
 
156
158
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.24",
3
+ "version": "0.11.25",
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",