@goodandready/dsh-image-gen 0.10.17 → 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.
@@ -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.17",
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",