@goodandready/dsh-image-gen 0.10.18 → 0.10.19

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/index.js CHANGED
@@ -27,6 +27,7 @@ import {
27
27
  resolveApiKeyCandidates,
28
28
  toLosslessJson,
29
29
  saveAttachmentSafe,
30
+ testProviderConnection,
30
31
  } from './providers.js'
31
32
 
32
33
  import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
@@ -276,6 +277,14 @@ export const Config = z.object({
276
277
  .string()
277
278
  .description('Model used to enhance the prompt. Empty means the same model that leads the conversation.')
278
279
  .default(''),
280
+ autoEnhancePrompt: z
281
+ .boolean()
282
+ .description('Enrich prompts with photography, lighting, and composition quality tokens.')
283
+ .default(false),
284
+ defaultStylePreset: z
285
+ .string()
286
+ .description('Default style preset applied to generations (e.g. cinematic, anime, photorealistic).')
287
+ .default('none'),
279
288
  enhanceBelowChars: z
280
289
  .number()
281
290
  .description('Only enhance prompts shorter than this many characters.')
@@ -620,7 +629,36 @@ export function apply(ctx, config) {
620
629
  }), `dsh-image-gen: image route ${path}`)
621
630
  }
622
631
 
623
- // История генераций: in-memory список, отфильтрованный по существованию файлов.
632
+ // Диагностика подключения к провайдерам
633
+ ctx.effect(() => ctx.webServer.register({
634
+ kind: 'exact',
635
+ path: '/dsh-image-gen/diagnostics/test',
636
+ handler: async (req, res) => {
637
+ if (req.method !== 'GET') {
638
+ res.writeHead(405, { 'Content-Type': 'application/json' })
639
+ res.end(JSON.stringify({ error: 'GET only' }))
640
+ return
641
+ }
642
+ try {
643
+ const u = new URL(req.url, 'http://localhost')
644
+ const provider = u.searchParams.get('provider') || config.provider || 'fal'
645
+ const deps = {
646
+ fetchImpl: fetch,
647
+ resolveKey: (ref) => resolveApiKey(ctx, ref),
648
+ cfg: (typeof getConfig === "function") ? getConfig() : config,
649
+ ctx,
650
+ }
651
+ const result = await testProviderConnection(deps, provider)
652
+ res.writeHead(200, { 'Content-Type': 'application/json' })
653
+ res.end(JSON.stringify({ provider, ...result }))
654
+ } catch (err) {
655
+ res.writeHead(500, { 'Content-Type': 'application/json' })
656
+ res.end(JSON.stringify({ ok: false, error: err.message }))
657
+ }
658
+ },
659
+ }), 'dsh-image-gen: diagnostics test route')
660
+
661
+ // История генераций: in-memory список, отфильтрованный по существованию файлов.
624
662
  ctx.effect(() => ctx.webServer.register({
625
663
  kind: 'exact',
626
664
  path: '/dsh-image-gen/history',
@@ -0,0 +1,194 @@
1
+ /**
2
+ * @file lib/prompt-polisher.js
3
+ * Curated artistic/photographic style presets, smart prompt enrichment, and palette harmonization.
4
+ */
5
+
6
+ export const CURATED_STYLES = {
7
+ cinematic: {
8
+ id: 'cinematic',
9
+ label: 'Cinematic Film',
10
+ promptSuffix: 'cinematic 35mm film still, anamorphic lighting, shallow depth of field, color graded, photorealistic details',
11
+ negativePrompt: 'cartoon, illustration, 3d render, oversaturated, blurry, distorted, amateur',
12
+ guidanceScale: 6.5,
13
+ },
14
+ photorealistic: {
15
+ id: 'photorealistic',
16
+ label: 'Photorealistic Studio',
17
+ promptSuffix: 'photorealistic portraiture, 85mm lens, natural studio lighting, micro-textures, 8k resolution, award-winning photography',
18
+ negativePrompt: 'illustration, drawing, anime, 3d render, plastic skin, CGI, doll-like',
19
+ guidanceScale: 7.0,
20
+ },
21
+ anime: {
22
+ id: 'anime',
23
+ label: 'Anime Masterpiece',
24
+ promptSuffix: 'clean anime aesthetic, sharp clean lineart, vibrant cel shading, detailed background scenery, Makoto Shinkai style',
25
+ negativePrompt: 'photorealistic, realistic photo, noisy, deformed, 3d model, lowres',
26
+ guidanceScale: 7.0,
27
+ },
28
+ minimalist_vector: {
29
+ id: 'minimalist_vector',
30
+ label: 'Minimalist Vector',
31
+ promptSuffix: 'minimalist vector art, clean sharp outlines, flat color fills, modern graphic design, Bauhaus aesthetics, SVG vector style',
32
+ negativePrompt: 'photorealistic, heavy gradients, 3d render, noisy texture, clutter, photorealism',
33
+ guidanceScale: 7.5,
34
+ },
35
+ isometric_3d: {
36
+ id: 'isometric_3d',
37
+ label: 'Isometric 3D',
38
+ promptSuffix: 'isometric 3D render, ambient occlusion, miniature diorama, Octane render, smooth clay surface, soft global illumination',
39
+ negativePrompt: 'flat, 2d, hand-drawn sketch, chaotic background, realistic photo',
40
+ guidanceScale: 7.0,
41
+ },
42
+ analog_film: {
43
+ id: 'analog_film',
44
+ label: 'Analog 35mm Film',
45
+ promptSuffix: 'vintage 35mm photograph, Kodak Portra 400 color science, authentic film grain, subtle halation, nostalgic muted tones',
46
+ negativePrompt: 'digital CGI, harsh sharpening, plastic, 3d render, oversaturated digital look',
47
+ guidanceScale: 6.5,
48
+ },
49
+ cyberpunk: {
50
+ id: 'cyberpunk',
51
+ label: 'Cyberpunk Neon',
52
+ promptSuffix: 'cyberpunk aesthetic, high-tech dark city, neon volumetric lighting, rain-slicked reflective surfaces, moody atmosphere',
53
+ negativePrompt: 'daylight, sunshine, pastoral, cartoon, pastel colors, washed out',
54
+ guidanceScale: 7.5,
55
+ },
56
+ pixel_art: {
57
+ id: 'pixel_art',
58
+ label: '16-bit Retro Pixel Art',
59
+ promptSuffix: '16-bit retro pixel art, crisp pixel grid, authentic dithered shading, nostalgic arcade palette, master pixel artwork',
60
+ negativePrompt: 'smooth vector, 3d render, blurry, modern photography, anti-aliased gradients',
61
+ guidanceScale: 8.0,
62
+ },
63
+ oil_painting: {
64
+ id: 'oil_painting',
65
+ label: 'Classical Oil Painting',
66
+ promptSuffix: 'classical fine art oil painting, impasto brushstrokes, textured canvas, chiaroscuro lighting, museum masterpiece',
67
+ negativePrompt: 'photograph, digital vector, 3d CGI, plastic, flat anime',
68
+ guidanceScale: 7.0,
69
+ },
70
+ claymation: {
71
+ id: 'claymation',
72
+ label: 'Claymation Stop-Motion',
73
+ promptSuffix: 'handcrafted claymation stop-motion aesthetic, tactile plasticine clay texture, studio macro softbox lighting, Aardman inspired',
74
+ negativePrompt: 'digital CGI, flat vector, photographic humans, harsh synthetic gloss',
75
+ guidanceScale: 7.5,
76
+ },
77
+ }
78
+
79
+ /**
80
+ * Normalizes a style identifier or alias into a canonical style key.
81
+ */
82
+ export function normalizeStyleKey(key) {
83
+ if (!key) return null
84
+ const cleaned = String(key).trim().toLowerCase().replace(/[-\s]/g, '_')
85
+ if (CURATED_STYLES[cleaned]) return cleaned
86
+ // Alias mapping
87
+ if (cleaned === 'isometric') return 'isometric_3d'
88
+ if (cleaned === 'vector' || cleaned === 'minimalist') return 'minimalist_vector'
89
+ if (cleaned === 'film' || cleaned === 'analog') return 'analog_film'
90
+ if (cleaned === 'photo' || cleaned === 'realism') return 'photorealistic'
91
+ return cleaned
92
+ }
93
+
94
+ /**
95
+ * Smart prompt enrichment: adds photographic & lighting cues if requested.
96
+ */
97
+ export function enrichWithQualityCues(prompt, styleKey = null) {
98
+ if (!prompt || typeof prompt !== 'string') return prompt
99
+ let p = prompt.trim()
100
+ const lower = p.toLowerCase()
101
+
102
+ // Only append if not already containing lighting/detail tokens
103
+ const hasLighting = lower.includes('lighting') || lower.includes('light') || lower.includes('illumination')
104
+ const hasDetail = lower.includes('detailed') || lower.includes('highres') || lower.includes('sharp') || lower.includes('resolution')
105
+
106
+ const additions = []
107
+ if (!hasLighting) additions.push('balanced natural lighting')
108
+ if (!hasDetail && styleKey !== 'minimalist_vector' && styleKey !== 'pixel_art') additions.push('high visual clarity')
109
+
110
+ if (additions.length > 0) {
111
+ p = p.endsWith('.') ? `${p} ${additions.join(', ')}.` : `${p}, ${additions.join(', ')}`
112
+ }
113
+ return p
114
+ }
115
+
116
+ /**
117
+ * Polish and enrich a prompt with styles, auto-enhancements, and optional palette constraints.
118
+ *
119
+ * @param {string} basePrompt
120
+ * @param {string|null} stylePreset
121
+ * @param {object} [options]
122
+ * @param {boolean} [options.autoEnhance=false]
123
+ * @param {string[]} [options.paletteColors=null]
124
+ * @param {string} [options.existingNegative]
125
+ * @param {number} [options.existingGuidance]
126
+ * @returns {{ prompt: string, negativePrompt?: string, guidanceScale?: number, styleApplied?: string }}
127
+ */
128
+ export function polishPrompt(basePrompt, stylePreset = null, {
129
+ autoEnhance = false,
130
+ paletteColors = null,
131
+ existingNegative = '',
132
+ existingGuidance = undefined,
133
+ } = {}) {
134
+ let prompt = String(basePrompt || '').trim()
135
+ let negative = existingNegative ? String(existingNegative).trim() : ''
136
+ let guidance = existingGuidance
137
+ let styleApplied = null
138
+
139
+ const key = normalizeStyleKey(stylePreset)
140
+ const preset = key ? CURATED_STYLES[key] : null
141
+
142
+ if (preset) {
143
+ styleApplied = preset.id
144
+ if (preset.promptSuffix && !prompt.toLowerCase().includes(preset.promptSuffix.toLowerCase())) {
145
+ prompt = prompt.endsWith('.') ? `${prompt} ${preset.promptSuffix}.` : `${prompt}, ${preset.promptSuffix}`
146
+ }
147
+ if (preset.negativePrompt) {
148
+ negative = [negative, preset.negativePrompt].filter(Boolean).join(', ')
149
+ }
150
+ if (guidance === undefined && preset.guidanceScale !== undefined) {
151
+ guidance = preset.guidanceScale
152
+ }
153
+ } else if (stylePreset && typeof stylePreset === 'string' && stylePreset.trim() && stylePreset !== 'none') {
154
+ // Custom style suffix string
155
+ const customSuffix = stylePreset.trim()
156
+ if (!prompt.toLowerCase().includes(customSuffix.toLowerCase())) {
157
+ prompt = `${prompt}, in ${customSuffix} style`
158
+ }
159
+ }
160
+
161
+ // Auto-enhance prompt if toggled
162
+ if (autoEnhance) {
163
+ prompt = enrichWithQualityCues(prompt, styleApplied)
164
+ }
165
+
166
+ // Palette constraint injection
167
+ if (Array.isArray(paletteColors) && paletteColors.length > 0) {
168
+ const validHex = paletteColors
169
+ .map(c => String(c).trim().toUpperCase())
170
+ .filter(c => /^#([0-9A-F]{3}|[0-9A-F]{6})$/i.test(c))
171
+
172
+ if (validHex.length > 0) {
173
+ const paletteDirective = `chromatic palette strictly dominated by ${validHex.join(', ')}, harmonious color grading`
174
+ prompt = `${prompt}, ${paletteDirective}`
175
+ const paletteNeg = 'clashing foreign colors, dissonant hues, out-of-palette tints'
176
+ negative = [negative, paletteNeg].filter(Boolean).join(', ')
177
+ }
178
+ }
179
+
180
+ return {
181
+ prompt,
182
+ negativePrompt: negative || undefined,
183
+ guidanceScale: guidance,
184
+ styleApplied: styleApplied || undefined,
185
+ }
186
+ }
187
+
188
+ export function listCuratedStyles() {
189
+ return Object.values(CURATED_STYLES).map(s => ({
190
+ id: s.id,
191
+ label: s.label,
192
+ guidanceScale: s.guidanceScale,
193
+ }))
194
+ }
package/lib/providers.js CHANGED
@@ -1191,3 +1191,87 @@ export function toLosslessJson(val) {
1191
1191
  }
1192
1192
  return null
1193
1193
  }
1194
+
1195
+
1196
+ /**
1197
+ * Diagnostic ping/connection test for a given provider.
1198
+ * Returns { ok: boolean, latencyMs: number, message: string }.
1199
+ */
1200
+ export async function testProviderConnection(deps, providerId) {
1201
+ const { fetchImpl = fetch, resolveKey, cfg } = deps
1202
+ const start = Date.now()
1203
+ const p = (providerId || cfg.defaultProvider || 'fal').toLowerCase()
1204
+
1205
+ try {
1206
+ if (p === 'fal') {
1207
+ const key = await resolveKey(cfg.apiKeyEnv || 'FAL_KEY')
1208
+ if (!key) return { ok: false, latencyMs: 0, message: 'FAL API key not configured' }
1209
+ const res = await fetchImpl('https://queue.fal.run/fal-ai/fast-sdxl/status', {
1210
+ headers: { Authorization: falAuthHeader(key) },
1211
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
1212
+ })
1213
+ const latencyMs = Date.now() - start
1214
+ if (res.status === 401 || res.status === 403) {
1215
+ return { ok: false, latencyMs, message: `Fal auth failed (HTTP ${res.status})` }
1216
+ }
1217
+ return { ok: true, latencyMs, message: 'Fal.ai queue reachable' }
1218
+ }
1219
+
1220
+ if (p === 'comfyui') {
1221
+ const url = cfg.comfyuiUrl || cfg.localBaseURL || 'http://127.0.0.1:8188'
1222
+ const res = await fetchImpl(`${url.replace(/\/+$/, '')}/system_stats`, {
1223
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
1224
+ })
1225
+ const latencyMs = Date.now() - start
1226
+ if (res.ok) {
1227
+ const data = await res.json().catch(() => ({}))
1228
+ const vram = data?.devices?.[0]?.vram_free ? ` (${Math.round(data.devices[0].vram_free / 1048576)} MB free VRAM)` : ''
1229
+ return { ok: true, latencyMs, message: `ComfyUI operational${vram}` }
1230
+ }
1231
+ return { ok: false, latencyMs, message: `ComfyUI returned HTTP ${res.status}` }
1232
+ }
1233
+
1234
+ if (p === 'a1111') {
1235
+ const url = cfg.a1111Url || cfg.localBaseURL || 'http://127.0.0.1:7860'
1236
+ const res = await fetchImpl(`${url.replace(/\/+$/, '')}/sdapi/v1/samplers`, {
1237
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(5000) : undefined,
1238
+ })
1239
+ const latencyMs = Date.now() - start
1240
+ if (res.ok) {
1241
+ return { ok: true, latencyMs, message: 'Automatic1111 operational' }
1242
+ }
1243
+ return { ok: false, latencyMs, message: `A1111 returned HTTP ${res.status}` }
1244
+ }
1245
+
1246
+ if (p === 'custom') {
1247
+ const url = cfg.customEndpoint || cfg.customBaseURL || 'https://api.openai.com/v1'
1248
+ const key = await resolveKey(cfg.customApiKeyEnv || cfg.customKeyEnv || 'OPENAI_API_KEY')
1249
+ const headers = key ? { Authorization: `Bearer ${key}` } : {}
1250
+ const res = await fetchImpl(`${url.replace(/\/+$/, '')}/models`, {
1251
+ headers,
1252
+ signal: typeof AbortSignal !== 'undefined' && AbortSignal.timeout ? AbortSignal.timeout(6000) : undefined,
1253
+ })
1254
+ const latencyMs = Date.now() - start
1255
+ if (res.status === 401 || res.status === 403) {
1256
+ return { ok: false, latencyMs, message: `Custom endpoint auth error (HTTP ${res.status})` }
1257
+ }
1258
+ if (res.ok || res.status === 404 || res.status === 405) {
1259
+ return { ok: true, latencyMs, message: `Custom gateway reachable (HTTP ${res.status})` }
1260
+ }
1261
+ return { ok: false, latencyMs, message: `Custom endpoint returned HTTP ${res.status}` }
1262
+ }
1263
+
1264
+ if (p === 'subscription' || p === 'chatgpt' || p === 'grok' || p === 'codex') {
1265
+ const hasSub = Boolean(deps.ctx?.subscriptions)
1266
+ const latencyMs = Date.now() - start
1267
+ if (hasSub) {
1268
+ return { ok: true, latencyMs, message: 'Subscription bridge available' }
1269
+ }
1270
+ return { ok: false, latencyMs, message: 'dsh-subscriptions plugin not installed' }
1271
+ }
1272
+
1273
+ return { ok: true, latencyMs: Date.now() - start, message: `Provider "${p}" configured` }
1274
+ } catch (err) {
1275
+ return { ok: false, latencyMs: Date.now() - start, message: err.message || 'Connection timeout' }
1276
+ }
1277
+ }
@@ -75,6 +75,10 @@ export function registerEditingTools(ctx, deps) {
75
75
  },
76
76
  mask: {
77
77
  type: 'string',
78
+ },
79
+ mask_image: {
80
+ type: 'string',
81
+ description: 'Optional path or attachment id of the inpaint mask image (alias for mask).',
78
82
  description: 'Optional path or attachment id of the inpaint mask image.',
79
83
  },
80
84
  strength: {
@@ -120,7 +124,8 @@ export function registerEditingTools(ctx, deps) {
120
124
  })
121
125
  assertBudgetAvailable(estimatedCostEdit, cfg.dailyBudgetUsd)
122
126
  const source = await resolveConversationImage(ctx, exec, args.image)
123
- const mask = args.mask ? await resolveSource(ctx, exec, args.mask) : undefined
127
+ const maskRef = args.mask_image || args.mask
128
+ const mask = maskRef ? await resolveSource(ctx, exec, maskRef) : undefined
124
129
  const vision = await analyzeImageWithVision(ctx, exec, source)
125
130
 
126
131
  let effectivePrompt = args.prompt
@@ -130,7 +135,7 @@ export function registerEditingTools(ctx, deps) {
130
135
 
131
136
  const provider = cfg.provider || 'fal'
132
137
  const deps = {
133
- fetchImpl: ctx.http ? (url, opt) => ctx.http(url, opt) : fetch,
138
+ fetchImpl: fetch,
134
139
  resolveKey: (ref) => resolveApiKey(ctx, ref),
135
140
  cfg,
136
141
  }
@@ -257,7 +262,7 @@ export function registerEditingTools(ctx, deps) {
257
262
 
258
263
  const provider = cfg.provider || 'fal'
259
264
  const deps = {
260
- fetchImpl: ctx.http ? (url, opt) => ctx.http(url, opt) : fetch,
265
+ fetchImpl: fetch,
261
266
  resolveKey: (ref) => resolveApiKey(ctx, ref),
262
267
  cfg,
263
268
  }
@@ -1,3 +1,4 @@
1
+ import { polishPrompt } from '../prompt-polisher.js'
1
2
 
2
3
  /** Run an array of async task functions with a concurrency cap */
3
4
  async function asyncPool(tasks, concurrency = 3) {
@@ -111,6 +112,15 @@ export function registerGenerationTools(ctx, deps) {
111
112
  type: 'string',
112
113
  description: 'Optional custom directory to write images into (relative to session cwd or absolute). Defaults to outputDir from settings.',
113
114
  },
115
+ style_preset: {
116
+ type: 'string',
117
+ description: 'Optional style preset: cinematic, photorealistic, anime, minimalist_vector, isometric_3d, analog_film, cyberpunk, claymation.',
118
+ },
119
+ palette_colors: {
120
+ type: 'array',
121
+ items: { type: 'string' },
122
+ description: 'Optional array of hex color codes (#1e1b4b) to constrain and harmonize the color palette.',
123
+ },
114
124
  count: {
115
125
  type: 'integer',
116
126
  description: 'Number of variations to generate in one call, 1-4 (default 1). Cost scales with count.',
@@ -279,7 +289,7 @@ export function registerGenerationTools(ctx, deps) {
279
289
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
280
290
  const providers = makeProviders(
281
291
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
282
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
292
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectivePolishedNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio },
283
293
  )
284
294
  // Подписочные провайдеры (codex/grok) отдают {ok:false, reason} вместо исключения:
285
295
  // отказ должен дойти до модели текстом. Без проверки execute шёл дальше с пустыми
@@ -321,4 +321,176 @@ export function registerProcessingTools(ctx, deps) {
321
321
  }),
322
322
  )
323
323
  }, 'dsh-image-gen: tool blend_images')
324
+
325
+ ctx.effect(() => {
326
+ ctx.tools.register(
327
+ defineTool({
328
+ name: 'assemble_image_grid',
329
+ description:
330
+ 'Assemble 2 to 4 images into a clean side-by-side, 2x2 grid, or vertical collage comparison. '
331
+ + 'Generates a composite visual with labels, optimal spacing, and lossless fidelity. '
332
+ + 'Returns safe dual-output with an inline preview and downloadable image asset.',
333
+ parameters: {
334
+ images: {
335
+ type: 'array',
336
+ items: { type: 'string' },
337
+ required: true,
338
+ description: 'List of 2 to 4 image paths or attachment ids to assemble into a grid.',
339
+ },
340
+ layout: {
341
+ type: 'string',
342
+ enum: ['side_by_side', 'grid_2x2', 'vertical'],
343
+ description: 'Grid layout arrangement: "side_by_side" (default), "grid_2x2", or "vertical".',
344
+ },
345
+ title: {
346
+ type: 'string',
347
+ description: 'Optional headline title displayed above the assembled collage.',
348
+ },
349
+ output_name: {
350
+ type: 'string',
351
+ description: 'Optional custom file name stem for the output grid image.',
352
+ },
353
+ },
354
+ output: {
355
+ schema: {
356
+ type: 'object',
357
+ additionalProperties: true,
358
+ properties: {
359
+ summary: { type: 'string' },
360
+ path: { type: 'string' },
361
+ url: { type: 'string' },
362
+ layout: { type: 'string' },
363
+ imageCount: { type: 'number' },
364
+ format: { type: 'string' },
365
+ attachment: { type: 'object', additionalProperties: true },
366
+ },
367
+ },
368
+ },
369
+ async execute(args, exec) {
370
+ try {
371
+ const rawRefs = Array.isArray(args.images) ? args.images : [args.images].filter(Boolean)
372
+ if (rawRefs.length < 2) throw new Error('assemble_image_grid requires at least 2 images (received ' + rawRefs.length + ')')
373
+ const limitRefs = rawRefs.slice(0, 4)
374
+
375
+ const sources = []
376
+ for (const r of limitRefs) {
377
+ const s = await resolveSource(ctx, exec, r)
378
+ if (s && s.bytes) sources.push(s)
379
+ }
380
+ if (sources.length < 2) throw new Error('Could not resolve at least 2 valid image sources from provided references')
381
+
382
+ const layout = args.layout || 'side_by_side'
383
+ const title = args.title ? String(args.title).trim() : ''
384
+
385
+ const tileW = 512
386
+ const tileH = 512
387
+ const gap = 16
388
+ const pad = 16
389
+ const headerH = title ? 48 : 0
390
+
391
+ let cols = 2
392
+ let rows = 1
393
+ if (layout === 'side_by_side') {
394
+ cols = sources.length
395
+ rows = 1
396
+ } else if (layout === 'vertical') {
397
+ cols = 1
398
+ rows = sources.length
399
+ } else if (layout === 'grid_2x2') {
400
+ cols = 2
401
+ rows = Math.ceil(sources.length / 2)
402
+ }
403
+
404
+ const totalW = pad * 2 + cols * tileW + (cols - 1) * gap
405
+ const totalH = pad * 2 + headerH + rows * tileH + (rows - 1) * gap
406
+
407
+ const labels = ['A', 'B', 'C', 'D']
408
+ let tilesSvg = ''
409
+
410
+ sources.forEach((src, idx) => {
411
+ let c = idx
412
+ let r = 0
413
+ if (layout === 'side_by_side') {
414
+ c = idx
415
+ r = 0
416
+ } else if (layout === 'vertical') {
417
+ c = 0
418
+ r = idx
419
+ } else if (layout === 'grid_2x2') {
420
+ c = idx % 2
421
+ r = Math.floor(idx / 2)
422
+ }
423
+
424
+ const x = pad + c * (tileW + gap)
425
+ const y = pad + headerH + r * (tileH + gap)
426
+ const b64 = Buffer.isBuffer(src.bytes) ? src.bytes.toString('base64') : Buffer.from(src.bytes).toString('base64')
427
+ const mime = src.mediaType || 'image/png'
428
+ const dataUri = `data:${mime};base64,${b64}`
429
+ const label = labels[idx] || String(idx + 1)
430
+
431
+ tilesSvg += `
432
+ <g transform="translate(${x}, ${y})">
433
+ <clipPath id="clip-${idx}"><rect width="${tileW}" height="${tileH}" rx="8" ry="8"/></clipPath>
434
+ <rect width="${tileW}" height="${tileH}" rx="8" fill="#18191c" stroke="#2c2e33" stroke-width="1"/>
435
+ <image href="${dataUri}" width="${tileW}" height="${tileH}" preserveAspectRatio="xMidYMid slice" clip-path="url(#clip-${idx})"/>
436
+ <rect x="12" y="12" width="28" height="24" rx="4" fill="rgba(0,0,0,0.65)"/>
437
+ <text x="26" y="29" fill="#ffffff" font-family="-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif" font-size="13" font-weight="700" text-anchor="middle">${label}</text>
438
+ </g>`
439
+ })
440
+
441
+ const escapedTitle = title
442
+ .replace(/&/g, '&amp;')
443
+ .replace(/</g, '&lt;')
444
+ .replace(/>/g, '&gt;')
445
+
446
+ const svgContent = `<?xml version="1.0" encoding="UTF-8"?>
447
+ <svg xmlns="http://www.w3.org/2000/svg" width="${totalW}" height="${totalH}" viewBox="0 0 ${totalW} ${totalH}">
448
+ <defs>
449
+ <style>
450
+ .bg { fill: #0f1012; }
451
+ .title { fill: #f3f4f6; font-family: -apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif; font-size: 16px; font-weight: 600; }
452
+ </style>
453
+ </defs>
454
+ <rect width="100%" height="100%" rx="12" class="bg"/>
455
+ ${title ? `<text x="${pad}" y="${pad + 24}" class="title">${escapedTitle}</text>` : ''}
456
+ ${tilesSvg}
457
+ </svg>`
458
+
459
+ const svgBuffer = Buffer.from(svgContent, 'utf8')
460
+ const cfg = live()
461
+ const stem = `${slugify(args.output_name || 'grid-collage')}-${Date.now().toString(36)}`
462
+ const name = `${stem}.svg`
463
+ const { attachment, localUrl } = await saveAttachmentSafe(ctx, { bytes: svgBuffer, mediaType: 'image/svg+xml', name })
464
+
465
+ const sessionCwd = exec?.agent?.session?.header?.cwd
466
+ const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
467
+ await mkdir(outDir, { recursive: true })
468
+ const filePath = path.join(outDir, name)
469
+ await writeFile(filePath, svgBuffer)
470
+
471
+ const summary = `### Assembled Image Grid (${layout})\n`
472
+ + `- **Images**: ${sources.length} sources combined (${layout})\n`
473
+ + `- **Canvas Size**: ${totalW} × ${totalH} px\n`
474
+ + `- **File**: \`${filePath}\`\n\n`
475
+ + `![${title || 'Assembled Grid'}](${localUrl || filePath})`
476
+
477
+ return toLosslessJson({
478
+ summary,
479
+ path: filePath,
480
+ url: localUrl,
481
+ layout,
482
+ imageCount: sources.length,
483
+ width: totalW,
484
+ height: totalH,
485
+ format: 'svg',
486
+ attachment,
487
+ })
488
+ } catch (err) {
489
+ throw new Error(sanitizeErrorAndLogs(err.message || err))
490
+ }
491
+ },
492
+ }),
493
+ )
494
+ }, 'dsh-image-gen: tool assemble_image_grid')
495
+
324
496
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.10.18",
3
+ "version": "0.10.19",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 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",