@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.
- package/lib/client.js +901 -37
- package/lib/comfy-workflow-helpers.js +161 -0
- package/lib/index.js +4 -0
- package/lib/providers/backends/local.js +33 -11
- package/lib/register-tools.js +3 -1
- package/lib/theme-pair-helpers.js +83 -0
- package/lib/tools/theme-pair.js +251 -0
- package/lib/vault.js +183 -0
- package/package.json +1 -1
|
@@ -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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
+
}
|
package/lib/register-tools.js
CHANGED
|
@@ -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
|
+
}
|