@goodandready/dsh-image-gen 0.10.24 → 0.10.26
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/README.md +14 -0
- package/README.ru.md +14 -0
- package/README.zh.md +14 -0
- package/lib/attachment-helper.js +82 -0
- package/lib/client.js +149 -21
- package/lib/history.js +93 -0
- package/lib/index.js +25 -167
- package/lib/pattern-helpers.js +87 -0
- package/lib/providers/backends/custom.js +93 -0
- package/lib/providers/backends/fal.js +67 -0
- package/lib/providers/backends/gemini.js +37 -0
- package/lib/providers/backends/local.js +136 -0
- package/lib/providers/backends/replicate.js +83 -0
- package/lib/providers/backends/seedream.js +49 -0
- package/lib/providers/backends/subscription.js +58 -0
- package/lib/providers/shared-helpers.js +164 -0
- package/lib/providers.js +46 -553
- package/lib/register-tools.js +8 -0
- package/lib/responsive-helpers.js +94 -0
- package/lib/spritesheet-helpers.js +65 -0
- package/lib/tools/editing.js +2 -0
- package/lib/tools/generation-pack.js +74 -0
- package/lib/tools/generation.js +2 -66
- package/lib/tools/pattern.js +125 -0
- package/lib/tools/processing-advanced.js +7 -1
- package/lib/tools/responsive.js +267 -0
- package/lib/tools/sketch.js +167 -0
- package/lib/tools/spritesheet.js +151 -0
- package/package.json +7 -4
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// responsive — generate_responsive_mockups tool (#173)
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import {
|
|
7
|
+
makeProviders,
|
|
8
|
+
toLosslessJson,
|
|
9
|
+
saveAttachmentSafe,
|
|
10
|
+
tryGenerate,
|
|
11
|
+
fallbackOrder,
|
|
12
|
+
PROVIDER_KEYS,
|
|
13
|
+
} from '../providers.js'
|
|
14
|
+
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
15
|
+
import { sanitizeErrorAndLogs } from '../security.js'
|
|
16
|
+
import { buildDualOutputMarkdown } from '../resolve-image.js'
|
|
17
|
+
import {
|
|
18
|
+
buildResponsivePrompt,
|
|
19
|
+
deviceMeta,
|
|
20
|
+
mockupFileName,
|
|
21
|
+
normalizeDevices,
|
|
22
|
+
resolveDevicePreset,
|
|
23
|
+
} from '../responsive-helpers.js'
|
|
24
|
+
|
|
25
|
+
async function asyncPool(tasks, concurrency = 3) {
|
|
26
|
+
const results = new Array(tasks.length)
|
|
27
|
+
let nextIdx = 0
|
|
28
|
+
async function worker() {
|
|
29
|
+
while (nextIdx < tasks.length) {
|
|
30
|
+
const idx = nextIdx++
|
|
31
|
+
results[idx] = await tasks[idx]()
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
await Promise.all(
|
|
35
|
+
Array.from({ length: Math.min(concurrency, tasks.length) }, () => worker()),
|
|
36
|
+
)
|
|
37
|
+
return results
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function registerResponsiveTools(ctx, deps) {
|
|
41
|
+
const { live, resolveApiKey } = deps
|
|
42
|
+
|
|
43
|
+
ctx.effect(() => {
|
|
44
|
+
ctx.tools.register(
|
|
45
|
+
defineTool({
|
|
46
|
+
name: 'generate_responsive_mockups',
|
|
47
|
+
description:
|
|
48
|
+
'Generate one product UI mockup across Mobile (9:16), Tablet (3:4) and Desktop (16:9) viewports in one call (#173). '
|
|
49
|
+
+ 'Keeps prompt, style and brand palette synchronized so the three screens look like the same product family. '
|
|
50
|
+
+ 'Saves mockup-mobile.png, mockup-tablet.png and mockup-desktop.png and shows a tabbed preview in chat.',
|
|
51
|
+
parameters: {
|
|
52
|
+
prompt: {
|
|
53
|
+
type: 'string',
|
|
54
|
+
required: true,
|
|
55
|
+
description: 'Shared product/screen description used for all viewports (e.g. "settings dashboard for a smart home app").',
|
|
56
|
+
},
|
|
57
|
+
devices: {
|
|
58
|
+
type: 'array',
|
|
59
|
+
items: { type: 'string' },
|
|
60
|
+
description: 'Subset of viewports: mobile, tablet, desktop. Default: all three.',
|
|
61
|
+
},
|
|
62
|
+
style_preset: {
|
|
63
|
+
type: 'string',
|
|
64
|
+
description: 'Optional style preset shared by all viewports (same catalog as generate_image).',
|
|
65
|
+
},
|
|
66
|
+
palette_colors: {
|
|
67
|
+
type: 'array',
|
|
68
|
+
items: { type: 'string' },
|
|
69
|
+
description: 'Optional brand color hex list enforced across every viewport for visual consistency.',
|
|
70
|
+
},
|
|
71
|
+
seed: {
|
|
72
|
+
type: 'integer',
|
|
73
|
+
description: 'Optional base seed for reproducible responsive sets (each device uses seed, seed+1, seed+2…).',
|
|
74
|
+
},
|
|
75
|
+
output_name: {
|
|
76
|
+
type: 'string',
|
|
77
|
+
description: 'Optional file stem. Default produces mockup-mobile.png, mockup-tablet.png, mockup-desktop.png.',
|
|
78
|
+
},
|
|
79
|
+
output_dir: {
|
|
80
|
+
type: 'string',
|
|
81
|
+
description: 'Optional directory relative to session cwd (defaults to settings outputDir).',
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
output: {
|
|
85
|
+
schema: {
|
|
86
|
+
type: 'object',
|
|
87
|
+
additionalProperties: true,
|
|
88
|
+
properties: {
|
|
89
|
+
summary: { type: 'string' },
|
|
90
|
+
images: { type: 'array', items: { type: 'object', additionalProperties: true } },
|
|
91
|
+
devices: { type: 'array', items: { type: 'string' } },
|
|
92
|
+
count: { type: 'number' },
|
|
93
|
+
seedBase: { type: 'number' },
|
|
94
|
+
provider: { type: 'string' },
|
|
95
|
+
warnings: { type: 'array', items: { type: 'string' } },
|
|
96
|
+
},
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
async execute(args, exec) {
|
|
100
|
+
try {
|
|
101
|
+
const cfg = live()
|
|
102
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
103
|
+
if (!args.prompt || !String(args.prompt).trim()) {
|
|
104
|
+
throw new Error('prompt is required for generate_responsive_mockups')
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const devices = normalizeDevices(args.devices)
|
|
108
|
+
if (!devices.length) {
|
|
109
|
+
throw new Error('devices must include at least one of: mobile, tablet, desktop')
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const provider = cfg.provider || 'fal'
|
|
113
|
+
const format = cfg.defaultFormat || 'png'
|
|
114
|
+
const seedBase = Number.isInteger(args.seed) ? args.seed : Math.floor(Math.random() * 100000)
|
|
115
|
+
const stylePreset = args.style_preset || cfg.stylePreset
|
|
116
|
+
const paletteColors = Array.isArray(args.palette_colors) ? args.palette_colors : undefined
|
|
117
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
118
|
+
const targetDir = (args.output_dir && String(args.output_dir).trim()) || cfg.outputDir || 'generated/images'
|
|
119
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), targetDir)
|
|
120
|
+
await mkdir(outDir, { recursive: true })
|
|
121
|
+
|
|
122
|
+
const pdeps = {
|
|
123
|
+
fetchImpl: fetch,
|
|
124
|
+
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
125
|
+
cfg,
|
|
126
|
+
}
|
|
127
|
+
const order = fallbackOrder(provider)
|
|
128
|
+
const warnings = []
|
|
129
|
+
|
|
130
|
+
const tasks = devices.map((device, index) => async () => {
|
|
131
|
+
const preset = resolveDevicePreset(device)
|
|
132
|
+
const devicePrompt = buildResponsivePrompt(args.prompt, {
|
|
133
|
+
device,
|
|
134
|
+
stylePreset,
|
|
135
|
+
paletteColors,
|
|
136
|
+
})
|
|
137
|
+
const seed = seedBase + index
|
|
138
|
+
const job = {
|
|
139
|
+
prompt: devicePrompt,
|
|
140
|
+
size: preset.size,
|
|
141
|
+
format,
|
|
142
|
+
seed,
|
|
143
|
+
provider,
|
|
144
|
+
...(stylePreset ? { style: stylePreset, style_preset: stylePreset } : {}),
|
|
145
|
+
...(paletteColors ? { palette_colors: paletteColors } : {}),
|
|
146
|
+
}
|
|
147
|
+
const providers = makeProviders(pdeps, job)
|
|
148
|
+
const generators = Object.fromEntries(PROVIDER_KEYS.map((k) => [
|
|
149
|
+
k,
|
|
150
|
+
async (s, p) => {
|
|
151
|
+
const fn = providers[k]
|
|
152
|
+
if (typeof fn !== 'function') throw new Error(`provider ${k} unavailable`)
|
|
153
|
+
return fn(s, p)
|
|
154
|
+
},
|
|
155
|
+
]))
|
|
156
|
+
|
|
157
|
+
try {
|
|
158
|
+
const gen = await tryGenerate(generators, order, seed, devicePrompt)
|
|
159
|
+
let bytes = gen.bytes || gen.image || gen.buffer
|
|
160
|
+
if (bytes && !Buffer.isBuffer(bytes)) bytes = Buffer.from(bytes)
|
|
161
|
+
if (!bytes || !bytes.length) throw new Error('provider returned empty image bytes')
|
|
162
|
+
|
|
163
|
+
const name = mockupFileName(device, {
|
|
164
|
+
outputName: String(args.output_name || '').trim() || undefined,
|
|
165
|
+
})
|
|
166
|
+
const mediaType = gen.mediaType
|
|
167
|
+
|| `image/${String(gen.format || format).replace('jpg', 'jpeg')}`
|
|
168
|
+
const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
|
|
169
|
+
bytes,
|
|
170
|
+
mediaType,
|
|
171
|
+
name,
|
|
172
|
+
})
|
|
173
|
+
const filePath = path.join(outDir, name)
|
|
174
|
+
await writeFile(filePath, bytes)
|
|
175
|
+
const meta = deviceMeta(device)
|
|
176
|
+
return {
|
|
177
|
+
success: true,
|
|
178
|
+
...meta,
|
|
179
|
+
seed,
|
|
180
|
+
path: filePath,
|
|
181
|
+
url: localUrl,
|
|
182
|
+
width: gen.width || attachment.width,
|
|
183
|
+
height: gen.height || attachment.height,
|
|
184
|
+
cost: gen.cost || 0,
|
|
185
|
+
prompt: devicePrompt,
|
|
186
|
+
format: String(gen.format || format).replace('jpg', 'jpeg'),
|
|
187
|
+
attachment: {
|
|
188
|
+
attachmentId: attachment.attachmentId,
|
|
189
|
+
mediaType: attachment.mediaType,
|
|
190
|
+
bytes: attachment.bytes,
|
|
191
|
+
width: attachment.width,
|
|
192
|
+
height: attachment.height,
|
|
193
|
+
name: attachment.name,
|
|
194
|
+
},
|
|
195
|
+
}
|
|
196
|
+
} catch (err) {
|
|
197
|
+
warnings.push(`${preset.id}: ${err?.message || err}`)
|
|
198
|
+
return { device: preset.id, success: false, error: err?.message || String(err) }
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
const settled = await asyncPool(tasks, 3)
|
|
203
|
+
const images = settled.filter((r) => r && r.success === true)
|
|
204
|
+
if (!images.length) {
|
|
205
|
+
throw new Error(`All responsive mockup generations failed: ${warnings.join('; ') || 'unknown error'}`)
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
const first = images[0]
|
|
209
|
+
const totalCost = images.reduce((s, i) => s + (i.cost || 0), 0)
|
|
210
|
+
const summaryBody = buildDualOutputMarkdown({
|
|
211
|
+
action: 'generated',
|
|
212
|
+
filePath: first.path,
|
|
213
|
+
width: first.width,
|
|
214
|
+
height: first.height,
|
|
215
|
+
mediaType: first.mediaType || `image/${format}`,
|
|
216
|
+
seed: seedBase,
|
|
217
|
+
provider,
|
|
218
|
+
model: cfg.model || cfg.customModel || 'default',
|
|
219
|
+
cost: totalCost,
|
|
220
|
+
attachmentId: first.attachment?.attachmentId || 'N/A',
|
|
221
|
+
})
|
|
222
|
+
const extraLines = images
|
|
223
|
+
.map((i) => `- **${i.device}** \`${i.path}\` (${i.width}×${i.height})`)
|
|
224
|
+
.join('\n')
|
|
225
|
+
// Machine-readable payload for the toolview tab strip (#173).
|
|
226
|
+
const tabPayload = JSON.stringify({
|
|
227
|
+
kind: 'generate_responsive_mockups',
|
|
228
|
+
images: images.map((i) => ({
|
|
229
|
+
device: i.device,
|
|
230
|
+
label: i.label,
|
|
231
|
+
aspect: i.aspect,
|
|
232
|
+
width: i.width,
|
|
233
|
+
height: i.height,
|
|
234
|
+
path: i.path,
|
|
235
|
+
seed: i.seed,
|
|
236
|
+
url: i.url,
|
|
237
|
+
attachment: i.attachment,
|
|
238
|
+
})),
|
|
239
|
+
})
|
|
240
|
+
const summary = `${summaryBody}\n\n**Responsive set (${images.length} viewports)**\n${extraLines}\n\n<dsh-image-gen-responsive>${tabPayload}</dsh-image-gen-responsive>`
|
|
241
|
+
|
|
242
|
+
return toLosslessJson({
|
|
243
|
+
summary,
|
|
244
|
+
images,
|
|
245
|
+
devices: images.map((i) => i.device),
|
|
246
|
+
count: images.length,
|
|
247
|
+
seedBase,
|
|
248
|
+
provider,
|
|
249
|
+
path: first.path,
|
|
250
|
+
url: first.url,
|
|
251
|
+
attachment: first.attachment,
|
|
252
|
+
width: first.width,
|
|
253
|
+
height: first.height,
|
|
254
|
+
seed: seedBase,
|
|
255
|
+
prompt: args.prompt,
|
|
256
|
+
cost: totalCost,
|
|
257
|
+
format,
|
|
258
|
+
...(warnings.length ? { warnings } : {}),
|
|
259
|
+
})
|
|
260
|
+
} catch (err) {
|
|
261
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
}),
|
|
265
|
+
)
|
|
266
|
+
}, 'dsh-image-gen: tool generate_responsive_mockups')
|
|
267
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
// sketch — sketch_to_image tool (#146)
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import {
|
|
5
|
+
makeProviders,
|
|
6
|
+
toLosslessJson,
|
|
7
|
+
} from '../providers.js'
|
|
8
|
+
import { resolveConversationImage, analyzeImageWithVision } from '../resolve-image.js'
|
|
9
|
+
import { calculateGenerationCost, assertBudgetAvailable } from '../cost-meter.js'
|
|
10
|
+
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
11
|
+
import { sanitizeErrorAndLogs } from '../security.js'
|
|
12
|
+
|
|
13
|
+
const SKETCH_PROMPT_SUFFIX =
|
|
14
|
+
'Detailed finished artwork derived from the provided line sketch. '
|
|
15
|
+
+ 'Preserve the original composition, geometry, and proportions of the sketch. '
|
|
16
|
+
+ 'Fill with coherent materials, lighting, and color while keeping the silhouette faithful.'
|
|
17
|
+
|
|
18
|
+
function isSvgSource(source) {
|
|
19
|
+
const mt = String(source?.mediaType || '').toLowerCase()
|
|
20
|
+
if (mt.includes('svg')) return true
|
|
21
|
+
const head = source?.bytes ? Buffer.from(source.bytes).subarray(0, 256).toString('utf8') : ''
|
|
22
|
+
return head.includes('<svg')
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function registerSketchTools(ctx, deps) {
|
|
26
|
+
const {
|
|
27
|
+
live,
|
|
28
|
+
saveAndAttachResult,
|
|
29
|
+
slugify,
|
|
30
|
+
resolveApiKey,
|
|
31
|
+
} = deps
|
|
32
|
+
|
|
33
|
+
ctx.effect(() => {
|
|
34
|
+
ctx.tools.register(
|
|
35
|
+
defineTool({
|
|
36
|
+
name: 'sketch_to_image',
|
|
37
|
+
description:
|
|
38
|
+
'Turn a rough sketch, wireframe, or line drawing into a finished illustration (#146). '
|
|
39
|
+
+ 'Accepts a raster sketch (PNG/JPEG) or SVG outline plus a text prompt. '
|
|
40
|
+
+ 'Uses image-to-image with lower denoise strength so composition and proportions of the sketch are preserved. '
|
|
41
|
+
+ 'If image is omitted or "latest", resolves the most recent image from the conversation session.',
|
|
42
|
+
parameters: {
|
|
43
|
+
prompt: {
|
|
44
|
+
type: 'string',
|
|
45
|
+
required: true,
|
|
46
|
+
description: 'What the finished image should look like (style, materials, lighting, mood).',
|
|
47
|
+
},
|
|
48
|
+
image: {
|
|
49
|
+
type: 'string',
|
|
50
|
+
description: 'Path or attachment id of the sketch. Defaults to "latest" to auto-resolve from session history.',
|
|
51
|
+
},
|
|
52
|
+
strength: {
|
|
53
|
+
type: 'number',
|
|
54
|
+
description: 'Denoise strength 0.1-1.0. Lower keeps more of the sketch geometry (default 0.45).',
|
|
55
|
+
},
|
|
56
|
+
output_name: {
|
|
57
|
+
type: 'string',
|
|
58
|
+
description: 'Optional file name stem for the saved image.',
|
|
59
|
+
},
|
|
60
|
+
output_dir: {
|
|
61
|
+
type: 'string',
|
|
62
|
+
description: 'Optional custom directory to write the image into.',
|
|
63
|
+
},
|
|
64
|
+
},
|
|
65
|
+
output: {
|
|
66
|
+
schema: {
|
|
67
|
+
type: 'object',
|
|
68
|
+
additionalProperties: true,
|
|
69
|
+
properties: {
|
|
70
|
+
summary: { type: 'string' },
|
|
71
|
+
path: { type: 'string' },
|
|
72
|
+
url: { type: 'string' },
|
|
73
|
+
width: { type: 'number' },
|
|
74
|
+
height: { type: 'number' },
|
|
75
|
+
seed: { type: 'number' },
|
|
76
|
+
provider: { type: 'string' },
|
|
77
|
+
cost: { type: 'number' },
|
|
78
|
+
format: { type: 'string' },
|
|
79
|
+
attachment: { type: 'object', additionalProperties: true },
|
|
80
|
+
inputFormat: { type: 'string' },
|
|
81
|
+
strength: { type: 'number' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
async execute(args, exec) {
|
|
86
|
+
try {
|
|
87
|
+
const cfg = live()
|
|
88
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
89
|
+
const estimated = calculateGenerationCost({
|
|
90
|
+
provider: cfg.provider || 'fal',
|
|
91
|
+
model: cfg.model,
|
|
92
|
+
size: cfg.defaultSize || 'square_hd',
|
|
93
|
+
count: 1,
|
|
94
|
+
})
|
|
95
|
+
assertBudgetAvailable(estimated, cfg.dailyBudgetUsd)
|
|
96
|
+
|
|
97
|
+
const source = await resolveConversationImage(ctx, exec, args.image)
|
|
98
|
+
if (!source?.bytes) {
|
|
99
|
+
throw new Error('sketch_to_image: no sketch image resolved. Provide image path/attachment id or attach a sketch in the conversation.')
|
|
100
|
+
}
|
|
101
|
+
const svg = isSvgSource(source)
|
|
102
|
+
if (svg) {
|
|
103
|
+
// Normalize media type so providers that accept SVG get a correct data URL.
|
|
104
|
+
source.mediaType = 'image/svg+xml'
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const vision = await analyzeImageWithVision(ctx, exec, source)
|
|
108
|
+
let effectivePrompt = `${args.prompt}\n\n${SKETCH_PROMPT_SUFFIX}`
|
|
109
|
+
if (vision.available && vision.summary) {
|
|
110
|
+
effectivePrompt += `\nSketch content: ${vision.summary}`
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const provider = cfg.provider || 'fal'
|
|
114
|
+
const pdeps = {
|
|
115
|
+
fetchImpl: fetch,
|
|
116
|
+
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
117
|
+
cfg,
|
|
118
|
+
}
|
|
119
|
+
const seed = Math.floor(Math.random() * 100000)
|
|
120
|
+
const strength = args.strength ?? 0.45
|
|
121
|
+
const format = cfg.defaultFormat || 'png'
|
|
122
|
+
const size = cfg.defaultSize || 'square_hd'
|
|
123
|
+
|
|
124
|
+
const job = {
|
|
125
|
+
prompt: effectivePrompt,
|
|
126
|
+
size,
|
|
127
|
+
format,
|
|
128
|
+
seed,
|
|
129
|
+
source,
|
|
130
|
+
strength,
|
|
131
|
+
provider,
|
|
132
|
+
}
|
|
133
|
+
const providers = makeProviders(pdeps, job)
|
|
134
|
+
const fn = providers[provider] || providers.fal || providers.custom
|
|
135
|
+
const gen = await fn(seed, effectivePrompt)
|
|
136
|
+
|
|
137
|
+
const stem = args.output_name ? slugify(args.output_name) : `sketch-${seed}`
|
|
138
|
+
const name = `${stem}.${format}`
|
|
139
|
+
const result = await saveAndAttachResult(ctx, exec, cfg, {
|
|
140
|
+
bytes: gen.bytes,
|
|
141
|
+
mediaType: gen.mediaType || 'image/png',
|
|
142
|
+
name,
|
|
143
|
+
stem,
|
|
144
|
+
prompt: args.prompt,
|
|
145
|
+
size,
|
|
146
|
+
format,
|
|
147
|
+
seed: gen.seed ?? seed,
|
|
148
|
+
provider,
|
|
149
|
+
model: cfg.model,
|
|
150
|
+
cost: gen.cost,
|
|
151
|
+
sourceUrl: gen.sourceUrl,
|
|
152
|
+
deliverAs: cfg.deliverAs || 'both',
|
|
153
|
+
args,
|
|
154
|
+
action: 'sketch_to_image',
|
|
155
|
+
})
|
|
156
|
+
result.inputFormat = svg ? 'svg' : String(source.mediaType || 'image/png')
|
|
157
|
+
result.strength = strength
|
|
158
|
+
if (vision.summary) result.visionAnalysis = vision.summary
|
|
159
|
+
return toLosslessJson(result)
|
|
160
|
+
} catch (err) {
|
|
161
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
}),
|
|
165
|
+
)
|
|
166
|
+
}, 'dsh-image-gen: tool sketch_to_image')
|
|
167
|
+
}
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// spritesheet — generate_spritesheet tool (#177)
|
|
2
|
+
|
|
3
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
4
|
+
import path from 'node:path'
|
|
5
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
6
|
+
import { makeProviders, toLosslessJson, saveAttachmentSafe } from '../providers.js'
|
|
7
|
+
import { trackAndAssertLoopGuard } from '../loop-guard.js'
|
|
8
|
+
import { sanitizeErrorAndLogs } from '../security.js'
|
|
9
|
+
import {
|
|
10
|
+
ANIMATION_PRESETS,
|
|
11
|
+
clampFrames,
|
|
12
|
+
buildSpritesheetCss,
|
|
13
|
+
buildSpritesheetSvg,
|
|
14
|
+
buildFramePrompts,
|
|
15
|
+
} from '../spritesheet-helpers.js'
|
|
16
|
+
import { sizeToPixels } from '../providers.js'
|
|
17
|
+
|
|
18
|
+
export function registerSpritesheetTools(ctx, deps) {
|
|
19
|
+
const { live, slugify, resolveApiKey } = deps
|
|
20
|
+
|
|
21
|
+
ctx.effect(() => {
|
|
22
|
+
ctx.tools.register(
|
|
23
|
+
defineTool({
|
|
24
|
+
name: 'generate_spritesheet',
|
|
25
|
+
description:
|
|
26
|
+
'Generate a 2D animation sprite sheet (#177): N consistent frames of a character/UI element in one horizontal strip, '
|
|
27
|
+
+ 'plus CSS @keyframes with steps(N) for seamless playback. Supports animation types idle, walk, attack, pulse, hover.',
|
|
28
|
+
parameters: {
|
|
29
|
+
subject: {
|
|
30
|
+
type: 'string',
|
|
31
|
+
required: true,
|
|
32
|
+
description: 'Character or UI element description (e.g. "pixel knight with blue armor").',
|
|
33
|
+
},
|
|
34
|
+
animation: {
|
|
35
|
+
type: 'string',
|
|
36
|
+
description: 'Animation type: idle, walk, attack, pulse, or hover (default idle).',
|
|
37
|
+
},
|
|
38
|
+
frames: {
|
|
39
|
+
type: 'number',
|
|
40
|
+
description: 'Frame count: 4, 8, or 12 (default 8; walk uses 4 or 8).',
|
|
41
|
+
},
|
|
42
|
+
frame_size: {
|
|
43
|
+
type: 'string',
|
|
44
|
+
description: 'Named size for each frame (default square = 512x512, matches sheet tiles).',
|
|
45
|
+
},
|
|
46
|
+
duration: {
|
|
47
|
+
type: 'number',
|
|
48
|
+
description: 'Full loop duration in seconds (default 0.8).',
|
|
49
|
+
},
|
|
50
|
+
output_name: {
|
|
51
|
+
type: 'string',
|
|
52
|
+
description: 'File name stem / CSS class name (default spritesheet).',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
output: {
|
|
56
|
+
schema: {
|
|
57
|
+
type: 'object',
|
|
58
|
+
additionalProperties: true,
|
|
59
|
+
properties: {
|
|
60
|
+
summary: { type: 'string' },
|
|
61
|
+
path: { type: 'string' },
|
|
62
|
+
url: { type: 'string' },
|
|
63
|
+
css: { type: 'string' },
|
|
64
|
+
cssPath: { type: 'string' },
|
|
65
|
+
frameCount: { type: 'number' },
|
|
66
|
+
animation: { type: 'string' },
|
|
67
|
+
format: { type: 'string' },
|
|
68
|
+
attachment: { type: 'object', additionalProperties: true },
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
async execute(args, exec) {
|
|
73
|
+
try {
|
|
74
|
+
const cfg = live()
|
|
75
|
+
trackAndAssertLoopGuard(exec?.agent?.session?.id || 'default', { limit: cfg.loopGuardLimit })
|
|
76
|
+
const animation = String(args.animation || 'idle').toLowerCase()
|
|
77
|
+
const animKey = ANIMATION_PRESETS[animation] ? animation : 'idle'
|
|
78
|
+
const frameCount = clampFrames(args.frames, animKey)
|
|
79
|
+
const provider = cfg.provider || 'fal'
|
|
80
|
+
const format = cfg.defaultFormat || 'png'
|
|
81
|
+
// square (512) matches tile slot; square_hd would overspend then downscale
|
|
82
|
+
const size = args.frame_size || 'square'
|
|
83
|
+
const seed = Math.floor(Math.random() * 100000)
|
|
84
|
+
const prompts = buildFramePrompts(args.subject, animKey, frameCount)
|
|
85
|
+
|
|
86
|
+
const frames = []
|
|
87
|
+
for (let i = 0; i < prompts.length; i++) {
|
|
88
|
+
const pdeps = {
|
|
89
|
+
fetchImpl: fetch,
|
|
90
|
+
resolveKey: (ref) => resolveApiKey(ctx, ref),
|
|
91
|
+
cfg,
|
|
92
|
+
}
|
|
93
|
+
const job = { prompt: prompts[i], size, format, seed: seed + i, provider }
|
|
94
|
+
const providers = makeProviders(pdeps, job)
|
|
95
|
+
const fn = providers[provider] || providers.fal || providers.custom
|
|
96
|
+
const gen = await fn(seed + i, prompts[i])
|
|
97
|
+
frames.push(gen)
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const [frameW, frameH] = sizeToPixels(size)
|
|
101
|
+
const svg = buildSpritesheetSvg(frames, { frameW, frameH })
|
|
102
|
+
const svgBuffer = Buffer.from(svg, 'utf8')
|
|
103
|
+
const stem = slugify(args.output_name || 'spritesheet')
|
|
104
|
+
const sheetName = `${stem}-sheet.svg`
|
|
105
|
+
const { attachment, localUrl } = await saveAttachmentSafe(ctx, {
|
|
106
|
+
bytes: svgBuffer,
|
|
107
|
+
mediaType: 'image/svg+xml',
|
|
108
|
+
name: sheetName,
|
|
109
|
+
})
|
|
110
|
+
|
|
111
|
+
const sessionCwd = exec?.agent?.session?.header?.cwd
|
|
112
|
+
const outDir = path.resolve(sessionCwd || process.cwd(), cfg.outputDir || 'generated/images')
|
|
113
|
+
await mkdir(outDir, { recursive: true })
|
|
114
|
+
const filePath = path.join(outDir, sheetName)
|
|
115
|
+
await writeFile(filePath, svgBuffer)
|
|
116
|
+
|
|
117
|
+
const durationSec = Number(args.duration) > 0 ? Number(args.duration) : 0.8
|
|
118
|
+
const css = buildSpritesheetCss({
|
|
119
|
+
frameCount,
|
|
120
|
+
frameW,
|
|
121
|
+
frameH,
|
|
122
|
+
durationSec,
|
|
123
|
+
name: stem,
|
|
124
|
+
})
|
|
125
|
+
const cssPath = path.join(outDir, `${stem}.css`)
|
|
126
|
+
await writeFile(cssPath, css, 'utf8')
|
|
127
|
+
|
|
128
|
+
const summary = `### Sprite sheet (${animKey}, ${frameCount} frames)\n`
|
|
129
|
+
+ `- **Sheet**: \`${filePath}\` (${frameW * frameCount}×${frameH})\n`
|
|
130
|
+
+ `- **CSS**: \`${cssPath}\` — \`animation: ${stem} ${durationSec}s steps(${frameCount}) infinite\`\n\n`
|
|
131
|
+
+ ``
|
|
132
|
+
|
|
133
|
+
return toLosslessJson({
|
|
134
|
+
summary,
|
|
135
|
+
path: filePath,
|
|
136
|
+
url: localUrl,
|
|
137
|
+
css,
|
|
138
|
+
cssPath,
|
|
139
|
+
frameCount,
|
|
140
|
+
animation: animKey,
|
|
141
|
+
format: 'svg',
|
|
142
|
+
attachment,
|
|
143
|
+
})
|
|
144
|
+
} catch (err) {
|
|
145
|
+
throw new Error(sanitizeErrorAndLogs(err.message || err))
|
|
146
|
+
}
|
|
147
|
+
},
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
}, 'dsh-image-gen: tool generate_spritesheet')
|
|
151
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.10.
|
|
4
|
-
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers
|
|
3
|
+
"version": "0.10.26",
|
|
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",
|
|
7
7
|
"dsh",
|
|
@@ -36,7 +36,9 @@
|
|
|
36
36
|
"lib/",
|
|
37
37
|
"cordis.patch.yml",
|
|
38
38
|
"README.md",
|
|
39
|
-
"LICENSE"
|
|
39
|
+
"LICENSE",
|
|
40
|
+
"README.ru.md",
|
|
41
|
+
"README.zh.md"
|
|
40
42
|
],
|
|
41
43
|
"license": "MIT",
|
|
42
44
|
"engines": {
|
|
@@ -66,6 +68,7 @@
|
|
|
66
68
|
"@deepseek-ai/schemastery": "^3.18.1"
|
|
67
69
|
},
|
|
68
70
|
"scripts": {
|
|
69
|
-
"test": "node --check lib/client.js && node --test test/*.test.mjs"
|
|
71
|
+
"test": "node scripts/build-client.mjs && node --check lib/client.js && node --test test/*.test.mjs",
|
|
72
|
+
"build:client": "node scripts/build-client.mjs"
|
|
70
73
|
}
|
|
71
74
|
}
|