@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.
- package/lib/attachment-helper.js +5 -1
- package/lib/commands.js +9 -3
- package/lib/config-schema.js +2 -2
- package/lib/generation-helpers.js +16 -10
- package/lib/history.js +55 -5
- package/lib/provider-utils.js +2 -0
- package/lib/providers/backends/custom.js +17 -4
- package/lib/providers/backends/gemini.js +45 -4
- package/lib/providers/shared-helpers.js +64 -2
- package/lib/providers.js +21 -29
- package/lib/tools/generation.js +52 -28
- package/lib/vault.js +19 -17
- package/package.json +1 -1
package/lib/attachment-helper.js
CHANGED
|
@@ -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
|
|
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(
|
|
92
|
-
const effectivePrompt =
|
|
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 = {
|
package/lib/config-schema.js
CHANGED
|
@@ -176,8 +176,8 @@ export const Config = z.object({
|
|
|
176
176
|
.volatile(),
|
|
177
177
|
geminiModel: z
|
|
178
178
|
.string()
|
|
179
|
-
.description('Google Gemini
|
|
180
|
-
.default('
|
|
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
|
|
66
|
-
|
|
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
|
|
136
|
-
|
|
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
|
|
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
|
-
|
|
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,
|
|
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 (
|
|
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
|
|
140
|
+
await writeFileAtomic(file, JSON.stringify(entries, null, 2), {
|
|
141
|
+
mode: 0o600,
|
|
142
|
+
dirMode: 0o700,
|
|
143
|
+
})
|
|
120
144
|
enforceSecurePermissions(file)
|
|
121
|
-
} catch (
|
|
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. */
|
package/lib/provider-utils.js
CHANGED
|
@@ -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:
|
|
77
|
-
mediaType:
|
|
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:
|
|
96
|
-
mediaType:
|
|
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
|
-
|
|
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
|
|
29
|
-
generationConfig
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
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
|
|
331
|
-
|
|
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 :
|
|
333
|
+
promptSuffix: typeof preset === 'string' ? preset : raw,
|
|
342
334
|
negativePrompt: existingNegative,
|
|
343
335
|
guidanceScale: existingGuidance,
|
|
344
336
|
}
|
package/lib/tools/generation.js
CHANGED
|
@@ -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
|
-
|
|
376
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
153
|
-
|
|
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.
|
|
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",
|