@goodandready/dsh-image-gen 0.10.35 → 0.11.0

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.
@@ -0,0 +1,161 @@
1
+ // comfy-workflow-helpers.js — ComfyUI workflow JSON parser and placeholder interpolator (#157)
2
+
3
+ /**
4
+ * Validates whether an input is a valid ComfyUI API workflow object.
5
+ * An API workflow is either { [nodeId]: { class_type, inputs } } or { prompt: { [nodeId]: ... } }.
6
+ *
7
+ * @param {any} raw
8
+ * @returns {object} normalized prompt graph { [nodeId]: { class_type, inputs } }
9
+ */
10
+ export function validateComfyWorkflow(raw) {
11
+ let graph = raw
12
+ if (typeof raw === 'string') {
13
+ const trimmed = raw.trim()
14
+ if (!trimmed) throw new Error('ComfyUI workflow JSON is empty')
15
+ try {
16
+ graph = JSON.parse(trimmed)
17
+ } catch (e) {
18
+ throw new Error(`ComfyUI workflow is not valid JSON: ${e.message}`)
19
+ }
20
+ }
21
+
22
+ if (!graph || typeof graph !== 'object' || Array.isArray(graph)) {
23
+ throw new Error('ComfyUI workflow must be a JSON object')
24
+ }
25
+
26
+ // Handle nested { prompt: { ... } } wrapper
27
+ if (graph.prompt && typeof graph.prompt === 'object' && !Array.isArray(graph.prompt)) {
28
+ graph = graph.prompt
29
+ }
30
+
31
+ const nodeIds = Object.keys(graph)
32
+ if (nodeIds.length === 0) {
33
+ throw new Error('ComfyUI workflow contains no nodes')
34
+ }
35
+
36
+ for (const id of nodeIds) {
37
+ const node = graph[id]
38
+ if (!node || typeof node !== 'object' || Array.isArray(node)) {
39
+ throw new Error(`ComfyUI node "${id}" must be an object`)
40
+ }
41
+ if (!node.class_type || typeof node.class_type !== 'string') {
42
+ throw new Error(`ComfyUI node "${id}" missing required "class_type" property`)
43
+ }
44
+ }
45
+
46
+ return graph
47
+ }
48
+
49
+ /**
50
+ * Recursively replaces placeholders in a ComfyUI workflow template.
51
+ * Supported placeholders:
52
+ * - {{prompt}}
53
+ * - {{negativePrompt}}
54
+ * - {{seed}} (converted to number when exact match)
55
+ * - {{width}} (converted to number when exact match)
56
+ * - {{height}} (converted to number when exact match)
57
+ * - {{steps}} (converted to number when exact match)
58
+ * - {{cfg}} (converted to number when exact match)
59
+ * - {{model}}
60
+ * - {{image}}
61
+ *
62
+ * @param {object} workflowGraph
63
+ * @param {object} ctx
64
+ * @returns {object} interpolated workflow clone
65
+ */
66
+ export function interpolateComfyWorkflow(workflowGraph, ctx) {
67
+ const promptVal = String(ctx.prompt ?? '')
68
+ const negVal = String(ctx.negativePrompt ?? '')
69
+ const seedVal = Number.isFinite(ctx.seed) ? ctx.seed : 0
70
+ const widthVal = Number.isFinite(ctx.width) ? ctx.width : 1024
71
+ const heightVal = Number.isFinite(ctx.height) ? ctx.height : 1024
72
+ const stepsVal = Number.isFinite(ctx.steps) ? ctx.steps : 20
73
+ const cfgVal = Number.isFinite(ctx.cfg) ? ctx.cfg : 7
74
+ const modelVal = String(ctx.model ?? 'v1-5-pruned-emaonly.safetensors')
75
+ const imageVal = String(ctx.image ?? '')
76
+
77
+ function transformValue(val) {
78
+ if (typeof val === 'string') {
79
+ const trimmed = val.trim()
80
+ // Numeric exact replacements
81
+ if (trimmed === '{{seed}}') return seedVal
82
+ if (trimmed === '{{width}}') return widthVal
83
+ if (trimmed === '{{height}}') return heightVal
84
+ if (trimmed === '{{steps}}') return stepsVal
85
+ if (trimmed === '{{cfg}}') return cfgVal
86
+
87
+ // General string replacements
88
+ return val
89
+ .replace(/\{\{prompt\}\}/g, promptVal)
90
+ .replace(/\{\{negativePrompt\}\}/g, negVal)
91
+ .replace(/\{\{negative_prompt\}\}/g, negVal)
92
+ .replace(/\{\{seed\}\}/g, String(seedVal))
93
+ .replace(/\{\{width\}\}/g, String(widthVal))
94
+ .replace(/\{\{height\}\}/g, String(heightVal))
95
+ .replace(/\{\{steps\}\}/g, String(stepsVal))
96
+ .replace(/\{\{cfg\}\}/g, String(cfgVal))
97
+ .replace(/\{\{model\}\}/g, modelVal)
98
+ .replace(/\{\{image\}\}/g, imageVal)
99
+ }
100
+ if (Array.isArray(val)) {
101
+ return val.map(transformValue)
102
+ }
103
+ if (val && typeof val === 'object') {
104
+ const out = {}
105
+ for (const [k, v] of Object.entries(val)) {
106
+ out[k] = transformValue(v)
107
+ }
108
+ return out
109
+ }
110
+ return val
111
+ }
112
+
113
+ return transformValue(workflowGraph)
114
+ }
115
+
116
+ /**
117
+ * Builds the default 7-node standard ComfyUI workflow graph.
118
+ */
119
+ export function buildDefaultComfyWorkflow({ prompt, negativePrompt, seed, width, height, model, steps, cfg }) {
120
+ return {
121
+ '3': {
122
+ class_type: 'KSampler',
123
+ inputs: {
124
+ seed: seed ?? 0,
125
+ steps: steps ?? 20,
126
+ cfg: cfg ?? 7,
127
+ sampler_name: 'euler',
128
+ scheduler: 'normal',
129
+ denoise: 1,
130
+ model: ['4', 0],
131
+ positive: ['6', 0],
132
+ negative: ['7', 0],
133
+ latent_image: ['5', 0],
134
+ },
135
+ },
136
+ '4': {
137
+ class_type: 'CheckpointLoaderSimple',
138
+ inputs: { ckpt_name: model || 'v1-5-pruned-emaonly.safetensors' },
139
+ },
140
+ '5': {
141
+ class_type: 'EmptyLatentImage',
142
+ inputs: { width, height, batch_size: 1 },
143
+ },
144
+ '6': {
145
+ class_type: 'CLIPTextEncode',
146
+ inputs: { text: prompt || '', clip: ['4', 1] },
147
+ },
148
+ '7': {
149
+ class_type: 'CLIPTextEncode',
150
+ inputs: { text: negativePrompt || '', clip: ['4', 1] },
151
+ },
152
+ '8': {
153
+ class_type: 'VAEDecode',
154
+ inputs: { samples: ['3', 0], vae: ['4', 2] },
155
+ },
156
+ '9': {
157
+ class_type: 'SaveImage',
158
+ inputs: { filename_prefix: 'dsh', images: ['8', 0] },
159
+ },
160
+ }
161
+ }
package/lib/index.js CHANGED
@@ -46,6 +46,7 @@ import {
46
46
  import { buildDualOutputMarkdown, resolveConversationImage, analyzeImageWithVision } from './resolve-image.js'
47
47
  import { registerAllTools } from './register-tools.js'
48
48
  import { registerPluginUpdater } from './updater.js'
49
+ import { registerVaultRoutes } from './vault.js'
49
50
 
50
51
 
51
52
  export { IMAGE_SIZES, OUTPUT_FORMATS, PROVIDER_KEYS, buildSidecar, normalizeMediaType, resolveConversationImage, analyzeImageWithVision }
@@ -566,6 +567,9 @@ export function apply(ctx, config) {
566
567
  },
567
568
  }), 'dsh-image-gen: history route')
568
569
 
570
+ // Vault route (#159)
571
+ registerVaultRoutes(ctx)
572
+
569
573
  // Tool registrations live in register-tools.js; each tool is a labeled ctx.effect (#216).
570
574
  registerAllTools(ctx, {
571
575
  config,
@@ -4,6 +4,11 @@ import {
4
4
  calculateBackoff,
5
5
  extractComfyNodeErrors,
6
6
  } from '../shared-helpers.js'
7
+ import {
8
+ validateComfyWorkflow,
9
+ interpolateComfyWorkflow,
10
+ buildDefaultComfyWorkflow,
11
+ } from '../../comfy-workflow-helpers.js'
7
12
 
8
13
  /**
9
14
  * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
@@ -65,17 +70,34 @@ export function createLocalGenerator(deps, job) {
65
70
 
66
71
  // ComfyUI: submit via /prompt, poll /history/{prompt_id} until completed.
67
72
  const promptId = `dsh-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 8)}`
68
- const workflow = {
69
- prompt: {
70
- '3': { class_type: 'KSampler', inputs: { seed: seedArg ?? 0, steps: cfg.localSteps ?? 20, cfg: cfg.localCfg ?? 7, sampler_name: 'euler', scheduler: 'normal', denoise: 1, model: ['4', 0], positive: ['6', 0], negative: ['7', 0], latent_image: ['5', 0] } },
71
- '4': { class_type: 'CheckpointLoaderSimple', inputs: { ckpt_name: cfg.localModel || 'v1-5-pruned-emaonly.safetensors' } },
72
- '5': { class_type: 'EmptyLatentImage', inputs: { width, height, batch_size: 1 } },
73
- '6': { class_type: 'CLIPTextEncode', inputs: { text: promptArg, clip: ['4', 1] } },
74
- '7': { class_type: 'CLIPTextEncode', inputs: { text: negativePrompt || '', clip: ['4', 1] } },
75
- '8': { class_type: 'VAEDecode', inputs: { samples: ['3', 0], vae: ['4', 2] } },
76
- '9': { class_type: 'SaveImage', inputs: { filename_prefix: 'dsh', images: ['8', 0] } },
77
- },
73
+ let workflowGraph
74
+ if (cfg.comfyWorkflowJson) {
75
+ const template = validateComfyWorkflow(cfg.comfyWorkflowJson)
76
+ workflowGraph = interpolateComfyWorkflow(template, {
77
+ prompt: promptArg,
78
+ negativePrompt: negativePrompt || '',
79
+ seed: seedArg ?? 0,
80
+ width,
81
+ height,
82
+ steps: cfg.localSteps ?? 20,
83
+ cfg: cfg.localCfg ?? 7,
84
+ model: cfg.localModel,
85
+ image: source && source.bytes ? Buffer.from(source.bytes).toString('base64') : '',
86
+ })
87
+ } else {
88
+ workflowGraph = buildDefaultComfyWorkflow({
89
+ prompt: promptArg,
90
+ negativePrompt,
91
+ seed: seedArg,
92
+ width,
93
+ height,
94
+ model: cfg.localModel,
95
+ steps: cfg.localSteps,
96
+ cfg: cfg.localCfg,
97
+ })
78
98
  }
99
+
100
+ const workflow = { prompt: workflowGraph }
79
101
  const submit = await fetchImpl(`${base}/prompt`, {
80
102
  method: 'POST',
81
103
  headers: { 'Content-Type': 'application/json' },
@@ -133,4 +155,4 @@ export function createLocalGenerator(deps, job) {
133
155
  }
134
156
  }
135
157
  return local
136
- }
158
+ }
@@ -14,6 +14,7 @@ import { registerResponsiveTools } from './tools/responsive.js'
14
14
  import { registerAnchorTools } from './tools/anchor.js'
15
15
  import { registerUiAssetTools } from './tools/ui-asset.js'
16
16
  import { registerStyleMatrixTools } from './tools/style-matrix.js'
17
+ import { registerThemePairTools } from './tools/theme-pair.js'
17
18
 
18
19
  /**
19
20
  * Registers every image-gen tool on the host tools service.
@@ -35,4 +36,5 @@ export function registerAllTools(ctx, deps) {
35
36
  registerAnchorTools(ctx, deps)
36
37
  registerUiAssetTools(ctx, deps)
37
38
  registerStyleMatrixTools(ctx, deps)
38
- }
39
+ registerThemePairTools(ctx, deps)
40
+ }
@@ -0,0 +1,83 @@
1
+ // theme-pair-helpers.js — helper functions for generate_theme_pair (#189)
2
+
3
+ export const THEME_STYLES = [
4
+ 'minimalist',
5
+ 'isometric',
6
+ 'flat',
7
+ '3d_render',
8
+ 'lineart',
9
+ 'cyberpunk',
10
+ 'claymorphism',
11
+ 'glassmorphism',
12
+ ]
13
+
14
+ /**
15
+ * Synthesizes prompt directives for light and dark theme variants.
16
+ * @param {object} opts
17
+ * @param {string} opts.prompt - Core user prompt
18
+ * @param {string} [opts.style] - Aesthetic style
19
+ * @param {number} [opts.darkContrastBoost=1.2] - Contrast boost multiplier for dark theme
20
+ * @returns {{ lightPrompt: string, darkPrompt: string }}
21
+ */
22
+ export function buildThemePairPrompts({ prompt, style, darkContrastBoost = 1.2 }) {
23
+ const cleanPrompt = String(prompt || '').trim()
24
+ const styleDirective = style ? `, in ${style} aesthetic style` : ''
25
+
26
+ const lightDirectives = [
27
+ 'light mode color palette',
28
+ 'clean pure white background (#ffffff)',
29
+ 'soft ambient occlusion shadows',
30
+ 'balanced crisp natural daylight illumination',
31
+ 'subtle pastel secondary accents',
32
+ 'high daytime readability',
33
+ ].join(', ')
34
+
35
+ const contrastNotes = (darkContrastBoost && darkContrastBoost > 1.0)
36
+ ? `enhanced ${Math.round(darkContrastBoost * 100)}% dynamic range highlights, luminous neon edge glow`
37
+ : 'luminous subtle edge glow'
38
+
39
+ const darkDirectives = [
40
+ 'dark mode color palette',
41
+ 'deep dark slate or obsidian background (#0f172a, #18181b)',
42
+ contrastNotes,
43
+ 'vibrant high-contrast focal points',
44
+ 'sleek night UI aesthetics',
45
+ 'eye-comfort calibrated dark surface tones',
46
+ ].join(', ')
47
+
48
+ const lightPrompt = `${cleanPrompt}${styleDirective}, ${lightDirectives}`
49
+ const darkPrompt = `${cleanPrompt}${styleDirective}, ${darkDirectives}`
50
+
51
+ return { lightPrompt, darkPrompt }
52
+ }
53
+
54
+ /**
55
+ * Builds responsive HTML/CSS snippets for adaptive theme switching.
56
+ * @param {object} opts
57
+ * @param {string} opts.lightSrc - Light image path or url
58
+ * @param {string} opts.darkSrc - Dark image path or url
59
+ * @param {string} [opts.alt] - Alt text
60
+ * @returns {{ html: string, css: string }}
61
+ */
62
+ export function buildThemePairSnippet({ lightSrc, darkSrc, alt = 'Theme adaptive graphic' }) {
63
+ const html = `<picture class="dsh-theme-pair">
64
+ <source srcset="${darkSrc}" media="(prefers-color-scheme: dark)">
65
+ <img src="${lightSrc}" alt="${alt}" loading="lazy">
66
+ </picture>`
67
+
68
+ const css = `/* CSS media-query & class-based dark theme switching */
69
+ .dsh-theme-pair img {
70
+ max-width: 100%;
71
+ height: auto;
72
+ display: block;
73
+ border-radius: 8px;
74
+ transition: opacity 0.3s ease;
75
+ }
76
+
77
+ /* Optional manual class override: <body class="dark"> or <html class="dark"> */
78
+ :is(.dark, [data-theme="dark"]) .dsh-theme-pair img {
79
+ content: url("${darkSrc}");
80
+ }`
81
+
82
+ return { html, css }
83
+ }
@@ -0,0 +1,251 @@
1
+ // theme-pair.js — generate_theme_pair tool (#189).
2
+ // Synchronized dual generation of light and dark theme visuals with adaptive CSS snippets.
3
+
4
+ import { defineTool } from '@deepseek-ai/dsh-tools'
5
+ import path from 'node:path'
6
+ import { mkdir, writeFile } from 'node:fs/promises'
7
+ import {
8
+ ASPECT_RATIOS,
9
+ PROVIDER_KEYS,
10
+ buildSidecar,
11
+ makeProviders,
12
+ toLosslessJson,
13
+ saveAttachmentSafe,
14
+ } from '../providers.js'
15
+ import { resolveFallbackChain, executeWithFallback } from '../fallback-router.js'
16
+ import { renderToolOutput } from '../attachment-helper.js'
17
+ import {
18
+ THEME_STYLES,
19
+ buildThemePairPrompts,
20
+ buildThemePairSnippet,
21
+ } from '../theme-pair-helpers.js'
22
+
23
+ export function registerThemePairTools(ctx, deps) {
24
+ const {
25
+ live,
26
+ slugify,
27
+ resolveApiKey,
28
+ } = deps
29
+
30
+ ctx.effect(() => {
31
+ ctx.tools.register(
32
+ defineTool({
33
+ name: 'generate_theme_pair',
34
+ description:
35
+ 'Generate synchronized dual illustrations or UI graphics designed specifically for light and dark application themes. '
36
+ + 'Produces both light (#FFFFFF clean backdrop) and dark (#0F172A obsidian backdrop with luminous accents) variants with matched composition, plus ready-to-use HTML/CSS responsive markup.',
37
+ parameters: {
38
+ prompt: {
39
+ type: 'string',
40
+ required: true,
41
+ description: 'Core subject or illustration motif, e.g. "cloud server network topology" or "productivity dashboard banner".',
42
+ },
43
+ aspect_ratio: {
44
+ type: 'string',
45
+ enum: Object.keys(ASPECT_RATIOS),
46
+ description: 'Aspect ratio for both variants: "1:1", "16:9", "4:3", etc. (default: "16:9").',
47
+ },
48
+ style: {
49
+ type: 'string',
50
+ enum: THEME_STYLES,
51
+ description: 'Visual style aesthetic (default: "minimalist").',
52
+ },
53
+ dark_contrast_boost: {
54
+ type: 'number',
55
+ description: 'Multiplier for glow and highlight contrast in the dark variant (default: 1.2).',
56
+ },
57
+ output_name: {
58
+ type: 'string',
59
+ description: 'Base filename prefix for the pair (default: auto-slugified from prompt).',
60
+ },
61
+ seed: {
62
+ type: 'integer',
63
+ description: 'Base random seed to synchronize composition between variants.',
64
+ },
65
+ },
66
+ output: {
67
+ schema: {
68
+ type: 'object',
69
+ additionalProperties: true,
70
+ properties: {
71
+ summary: { type: 'string' },
72
+ prompt: { type: 'string' },
73
+ style: { type: 'string' },
74
+ light: {
75
+ type: 'object',
76
+ additionalProperties: true,
77
+ properties: {
78
+ path: { type: 'string' },
79
+ url: { type: 'string' },
80
+ attachment: { type: 'object', additionalProperties: true },
81
+ },
82
+ },
83
+ dark: {
84
+ type: 'object',
85
+ additionalProperties: true,
86
+ properties: {
87
+ path: { type: 'string' },
88
+ url: { type: 'string' },
89
+ attachment: { type: 'object', additionalProperties: true },
90
+ },
91
+ },
92
+ html_snippet: { type: 'string' },
93
+ css_snippet: { type: 'string' },
94
+ },
95
+ },
96
+ render(_args, value) {
97
+ return renderToolOutput(value)
98
+ },
99
+ },
100
+ isConcurrencySafe: () => false,
101
+ async execute(args, exec) {
102
+ const cfg = live()
103
+ if (cfg.enabled === false) {
104
+ throw new Error('Image generation is disabled in settings.')
105
+ }
106
+
107
+ const prompt = String(args.prompt || '').trim()
108
+ if (!prompt) {
109
+ throw new Error('Prompt is required for generate_theme_pair')
110
+ }
111
+
112
+ const style = THEME_STYLES.includes(args.style) ? args.style : 'minimalist'
113
+ const aspectRatio = args.aspect_ratio || '16:9'
114
+ const aspectPixels = ASPECT_RATIOS[aspectRatio] || [1344, 768]
115
+ const darkContrastBoost = typeof args.dark_contrast_boost === 'number' ? args.dark_contrast_boost : 1.2
116
+
117
+ const { lightPrompt, darkPrompt } = buildThemePairPrompts({
118
+ prompt,
119
+ style,
120
+ darkContrastBoost,
121
+ })
122
+
123
+ const primaryProvider = cfg.provider || 'fal'
124
+ const chain = resolveFallbackChain(primaryProvider, cfg.fallbackChain)
125
+ const baseSeed = typeof args.seed === 'number' ? args.seed : Math.floor(Math.random() * 1000000)
126
+
127
+ const sessionCwd = exec.agent?.session?.header?.cwd
128
+ const targetDir = cfg.outputDir || 'generated/images'
129
+ const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
130
+ await mkdir(outDir, { recursive: true })
131
+
132
+ const slug = args.output_name ? slugify(args.output_name) : slugify(prompt).slice(0, 36)
133
+ const ts = Date.now().toString(36)
134
+
135
+ // 1. Generate Light theme variant
136
+ const lightJob = {
137
+ prompt: lightPrompt,
138
+ format: 'png',
139
+ aspectPixels,
140
+ aspectRatio,
141
+ seed: baseSeed,
142
+ signal: exec.signal,
143
+ }
144
+ const lightGen = await executeWithFallback({
145
+ chain,
146
+ liveConfig: cfg,
147
+ makeProvidersFn: (deps, job) => makeProviders(deps, job),
148
+ resolveApiKeyFn: resolveApiKey,
149
+ job: lightJob,
150
+ })
151
+
152
+ const lightFilename = `${slug}-light-${ts}.png`
153
+ const lightPath = path.join(outDir, lightFilename)
154
+ await writeFile(lightPath, lightGen.bytes)
155
+
156
+ const lightAttachment = await saveAttachmentSafe(ctx, {
157
+ bytes: lightGen.bytes,
158
+ mediaType: 'image/png',
159
+ filename: lightFilename,
160
+ width: lightGen.width || aspectPixels[0],
161
+ height: lightGen.height || aspectPixels[1],
162
+ })
163
+ const lightUrl = lightAttachment.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(lightAttachment.attachmentId)}` : ''
164
+
165
+ // 2. Generate Dark theme variant (using matched seed)
166
+ const darkJob = {
167
+ prompt: darkPrompt,
168
+ format: 'png',
169
+ aspectPixels,
170
+ aspectRatio,
171
+ seed: baseSeed,
172
+ signal: exec.signal,
173
+ }
174
+ const darkGen = await executeWithFallback({
175
+ chain,
176
+ liveConfig: cfg,
177
+ makeProvidersFn: (deps, job) => makeProviders(deps, job),
178
+ resolveApiKeyFn: resolveApiKey,
179
+ job: darkJob,
180
+ })
181
+
182
+ const darkFilename = `${slug}-dark-${ts}.png`
183
+ const darkPath = path.join(outDir, darkFilename)
184
+ await writeFile(darkPath, darkGen.bytes)
185
+
186
+ const darkAttachment = await saveAttachmentSafe(ctx, {
187
+ bytes: darkGen.bytes,
188
+ mediaType: 'image/png',
189
+ filename: darkFilename,
190
+ width: darkGen.width || aspectPixels[0],
191
+ height: darkGen.height || aspectPixels[1],
192
+ })
193
+ const darkUrl = darkAttachment.attachmentId ? `/dsh-image-gen/image?id=${encodeURIComponent(darkAttachment.attachmentId)}` : ''
194
+
195
+ // Sidecars
196
+ await writeFile(
197
+ path.join(outDir, `${slug}-pair-${ts}.json`),
198
+ JSON.stringify(buildSidecar({
199
+ prompt,
200
+ style,
201
+ aspectRatio,
202
+ size: `${aspectPixels[0]}x${aspectPixels[1]}`,
203
+ format: 'png',
204
+ seed: baseSeed,
205
+ light: { path: lightPath, url: lightUrl, attachmentId: lightAttachment.attachmentId },
206
+ dark: { path: darkPath, url: darkUrl, attachmentId: darkAttachment.attachmentId },
207
+ cost: (lightGen.cost || 0) + (darkGen.cost || 0),
208
+ }), null, 2),
209
+ )
210
+
211
+ const { html, css } = buildThemePairSnippet({
212
+ lightSrc: lightFilename,
213
+ darkSrc: darkFilename,
214
+ alt: prompt,
215
+ })
216
+
217
+ const summary = `Generated Theme Pair (${style}, ${aspectRatio}):\n`
218
+ + `- Light: ${lightFilename} (seed: ${baseSeed})\n`
219
+ + `- Dark: ${darkFilename} (seed: ${baseSeed})\n\n`
220
+ + `\`\`\`html\n${html}\n\`\`\``
221
+
222
+ return toLosslessJson({
223
+ summary,
224
+ prompt,
225
+ style,
226
+ aspect_ratio: aspectRatio,
227
+ light: {
228
+ path: lightPath,
229
+ url: lightUrl,
230
+ filename: lightFilename,
231
+ width: lightGen.width || aspectPixels[0],
232
+ height: lightGen.height || aspectPixels[1],
233
+ attachment: lightAttachment,
234
+ },
235
+ dark: {
236
+ path: darkPath,
237
+ url: darkUrl,
238
+ filename: darkFilename,
239
+ width: darkGen.width || aspectPixels[0],
240
+ height: darkGen.height || aspectPixels[1],
241
+ attachment: darkAttachment,
242
+ },
243
+ html_snippet: html,
244
+ css_snippet: css,
245
+ attachment: lightAttachment,
246
+ })
247
+ },
248
+ }),
249
+ )
250
+ }, 'dsh-image-gen: tool generate_theme_pair')
251
+ }