@goodandready/dsh-image-gen 0.10.4 → 0.10.6

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 CHANGED
@@ -45,6 +45,8 @@
45
45
  ## 🛠️ Complete Tools Reference
46
46
 
47
47
  * **`generate_image`**: Generate images with pluggable providers, seeds, aspect ratios, and style presets.
48
+ * **`edit_image`**: Targeted inpainting and modification with automatic session reference resolution (#142, #144, #145).
49
+ * **`vary_image`**: Controlled variation generation preserving composition (#143, #144).
48
50
  * **`remove_background`**: Extract subject with transparent PNG output (FAL BiRefNet / Rembg).
49
51
  * **`upscale_image`**: 2x / 4x super-resolution with clarity reconstruction.
50
52
  * **`vectorize_image`**: Convert raster graphics to clean scalable SVG vectors with palette quantization.
@@ -52,6 +54,11 @@
52
54
  * **`generate_image_pack`**: Simultaneous multi-aspect ratio rendering with graceful partial recovery.
53
55
  * **`compare_images`**: Pixel-level visual difference ratio comparison.
54
56
  * **`inspect_image_quality`**: Automated visual audit, Laplacian sharpness scoring, and defect detection.
57
+ * **`extract_design_tokens`**: Extract CSS Variables, Tailwind color palettes, and W3C Design Tokens from concept art (#172).
58
+ * **`image_to_css_gradient`**: Generate lightweight pure CSS Mesh / Radial / Linear gradients (< 1KB) from image colors (#174).
59
+ * **`check_image_contrast`**: Evaluate background luminance and WCAG 2.1 AA/AAA contrast for text with scrim suggestions (#175).
60
+ * **`optimize_vector_svg`**: Clean and sanitize SVG, normalize viewBox, and export ready-to-use React TSX components (#176).
61
+ * **`generate_pwa_icon_suite`**: Generate full PWA icon sets, HTML meta tags, and web app manifest.json (#190).
55
62
 
56
63
  ---
57
64
 
package/README.ru.md CHANGED
@@ -45,6 +45,8 @@ graph LR
45
45
  T6[generate_image_pack]
46
46
  T7[compare_images]
47
47
  T8[inspect_image_quality]
48
+ T9[edit_image]
49
+ T10[vary_image]
48
50
  end
49
51
 
50
52
  subgraph Core [🛡️ Ядро надежности и кэша]
@@ -90,6 +92,8 @@ graph LR
90
92
  | Инструмент | Назначение | Ключевые параметры |
91
93
  |---|---|---|
92
94
  | **`generate_image`** | Генерация изображения по тексту | `prompt`, `image_size`, `seed`, `style`, `negative_prompt`, `count` |
95
+ | **`edit_image`** | Направленное редактирование / inpainting с авто-поиском оригинала (#142, #144, #145) | `prompt`, `image` (по умолч. `latest`), `mask`, `strength` |
96
+ | **`vary_image`** | Генерация вариаций существующей картинки (#143, #144) | `image` (по умолч. `latest`), `prompt`, `variation_strength` (0.1–0.9), `count` |
93
97
  | **`remove_background`** | Удаление фона с сохранением прозрачного PNG | `image`, `model`, `output_name` |
94
98
  | **`upscale_image`** | Увеличение разрешения в 2x / 4x с детализацией | `image`, `scale` (2/4), `prompt`, `creativity` |
95
99
  | **`vectorize_image`** | Векторизация растра в чистый SVG с квантованием палитры | `image`, `color_mode`, `paletteSize` |
@@ -0,0 +1,436 @@
1
+ // @ts-check
2
+ import path from 'node:path'
3
+ import fs from 'node:fs/promises'
4
+
5
+ /**
6
+ * Convert RGB to Hex string
7
+ * @param {number} r
8
+ * @param {number} g
9
+ * @param {number} b
10
+ * @returns {string}
11
+ */
12
+ export function rgbToHex(r, g, b) {
13
+ const toHex = (c) => Math.max(0, Math.min(255, Math.round(c))).toString(16).padStart(2, '0')
14
+ return `#${toHex(r)}${toHex(g)}${toHex(b)}`.toLowerCase()
15
+ }
16
+
17
+ /**
18
+ * Parse Hex string to RGB
19
+ * @param {string} hex
20
+ * @returns {{ r: number, g: number, b: number }}
21
+ */
22
+ export function hexToRgb(hex) {
23
+ let clean = hex.replace(/^#/, '').trim()
24
+ if (clean.length === 3) {
25
+ clean = clean.split('').map((c) => c + c).join('')
26
+ }
27
+ const num = parseInt(clean, 16)
28
+ if (Number.isNaN(num) || clean.length !== 6) {
29
+ return { r: 0, g: 0, b: 0 }
30
+ }
31
+ return {
32
+ r: (num >> 16) & 255,
33
+ g: (num >> 8) & 255,
34
+ b: num & 255,
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Calculates WCAG 2.1 relative luminance for sRGB color.
40
+ * @param {{ r: number, g: number, b: number }} rgb
41
+ * @returns {number}
42
+ */
43
+ export function getRelativeLuminance({ r, g, b }) {
44
+ const a = [r, g, b].map((v) => {
45
+ const s = v / 255
46
+ return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4)
47
+ })
48
+ return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2]
49
+ }
50
+
51
+ /**
52
+ * Calculates WCAG 2.1 contrast ratio between two colors (1.0 to 21.0).
53
+ * @param {string} hex1
54
+ * @param {string} hex2
55
+ * @returns {number}
56
+ */
57
+ export function getContrastRatio(hex1, hex2) {
58
+ const lum1 = getRelativeLuminance(hexToRgb(hex1))
59
+ const lum2 = getRelativeLuminance(hexToRgb(hex2))
60
+ const brightest = Math.max(lum1, lum2)
61
+ const darkest = Math.min(lum1, lum2)
62
+ const ratio = (brightest + 0.05) / (darkest + 0.05)
63
+ return Math.round(ratio * 100) / 100
64
+ }
65
+
66
+ /**
67
+ * Extract palette sample colors from raw image bytes.
68
+ * Scans byte frequencies and samples color anchors across buffer.
69
+ * @param {Buffer} buffer
70
+ * @returns {string[]} Hex colors
71
+ */
72
+ export function extractSampleColorsFromBuffer(buffer) {
73
+ if (!buffer || buffer.length < 16) {
74
+ return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
75
+ }
76
+
77
+ // Check if buffer is SVG (UTF-8 text)
78
+ const head = buffer.slice(0, 256).toString('utf8').toLowerCase()
79
+ if (head.includes('<svg') || head.includes('<?xml')) {
80
+ const text = buffer.toString('utf8')
81
+ const hexMatches = text.match(/#(?:[0-9a-fA-F]{3}){1,2}\b/g)
82
+ if (hexMatches && hexMatches.length > 0) {
83
+ const counts = {}
84
+ for (const h of hexMatches) {
85
+ let full = h.toLowerCase()
86
+ if (full.length === 4) {
87
+ full = '#' + full[1] + full[1] + full[2] + full[2] + full[3] + full[3]
88
+ }
89
+ counts[full] = (counts[full] || 0) + 1
90
+ }
91
+ return Object.entries(counts)
92
+ .sort((a, b) => b[1] - a[1])
93
+ .slice(0, 6)
94
+ .map(([c]) => c)
95
+ }
96
+ }
97
+
98
+ // Sample bytes from buffer chunks
99
+ const step = Math.max(4, Math.floor(buffer.length / 500))
100
+ const samples = []
101
+ for (let i = 64; i < buffer.length - 8; i += step) {
102
+ const r = buffer[i]
103
+ const g = buffer[i + 1]
104
+ const b = buffer[i + 2]
105
+ samples.push({ r, g, b })
106
+ }
107
+
108
+ if (samples.length === 0) {
109
+ return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
110
+ }
111
+
112
+ samples.sort((a, b) => (a.r * 299 + a.g * 587 + a.b * 114) - (b.r * 299 + b.g * 587 + b.b * 114))
113
+
114
+ const anchors = []
115
+ const bucketSize = Math.floor(samples.length / 6)
116
+ for (let b = 0; b < 6; b++) {
117
+ const start = b * bucketSize
118
+ const end = b === 5 ? samples.length : start + bucketSize
119
+ let sumR = 0, sumG = 0, sumB = 0
120
+ for (let j = start; j < end; j++) {
121
+ sumR += samples[j].r
122
+ sumG += samples[j].g
123
+ sumB += samples[j].b
124
+ }
125
+ const count = Math.max(1, end - start)
126
+ anchors.push(rgbToHex(sumR / count, sumG / count, sumB / count))
127
+ }
128
+
129
+ const unique = Array.from(new Set(anchors))
130
+ while (unique.length < 5) {
131
+ unique.push('#3b82f6')
132
+ }
133
+ return unique
134
+ }
135
+
136
+ /**
137
+ * Issue #172: extract_design_tokens
138
+ * Generates CSS Variables, Tailwind color palette config, and W3C Design Tokens JSON.
139
+ */
140
+ export function extractDesignTokens(colors, options = {}) {
141
+ const palette = colors && colors.length > 0 ? colors : ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
142
+ const prefix = options.prefix || 'color'
143
+
144
+ const bg = palette[0]
145
+ const primary = palette[1] || palette[0]
146
+ const secondary = palette[2] || '#64748b'
147
+ const accent = palette[3] || '#f59e0b'
148
+ const text = palette[palette.length - 1]
149
+
150
+ const tokens = {
151
+ background: bg,
152
+ primary,
153
+ secondary,
154
+ accent,
155
+ text,
156
+ palette,
157
+ }
158
+
159
+ let cssVariables = ':root {\n'
160
+ cssVariables += ` --${prefix}-background: ${bg};\n`
161
+ cssVariables += ` --${prefix}-primary: ${primary};\n`
162
+ cssVariables += ` --${prefix}-secondary: ${secondary};\n`
163
+ cssVariables += ` --${prefix}-accent: ${accent};\n`
164
+ cssVariables += ` --${prefix}-text: ${text};\n`
165
+ palette.forEach((col, idx) => {
166
+ cssVariables += ` --${prefix}-palette-${idx + 1}: ${col};\n`
167
+ })
168
+ cssVariables += '}'
169
+
170
+ const tailwindColors = {
171
+ [prefix]: {
172
+ background: bg,
173
+ primary,
174
+ secondary,
175
+ accent,
176
+ text,
177
+ ...palette.reduce((acc, col, idx) => {
178
+ acc[`shade-${(idx + 1) * 100}`] = col
179
+ return acc
180
+ }, {}),
181
+ },
182
+ }
183
+ const tailwindSnippet = `// tailwind.config.js\nmodule.exports = {\n theme: {\n extend: {\n colors: ${JSON.stringify(tailwindColors, null, 2).replace(/\\n/g, '\n ')}\n }\n }\n}`
184
+
185
+ const w3cTokens = {
186
+ color: {
187
+ background: { $value: bg, $type: 'color' },
188
+ primary: { $value: primary, $type: 'color' },
189
+ secondary: { $value: secondary, $type: 'color' },
190
+ accent: { $value: accent, $type: 'color' },
191
+ text: { $value: text, $type: 'color' },
192
+ palette: palette.map((c, i) => ({
193
+ [`step-${i + 1}`]: { $value: c, $type: 'color' },
194
+ })),
195
+ },
196
+ }
197
+
198
+ return {
199
+ tokens,
200
+ cssVariables,
201
+ tailwindSnippet,
202
+ w3cTokens,
203
+ }
204
+ }
205
+
206
+ /**
207
+ * Issue #174: image_to_css_gradient
208
+ * Converts image dominant colors into pure CSS mesh / radial / linear gradient (< 1KB).
209
+ */
210
+ export function generateCssGradient(colors, type = 'mesh') {
211
+ const palette = colors && colors.length >= 2 ? colors : ['#0f172a', '#1e293b', '#3b82f6', '#06b6d4']
212
+ const c1 = palette[0]
213
+ const c2 = palette[1]
214
+ const c3 = palette[2] || palette[0]
215
+ const c4 = palette[3] || palette[1]
216
+
217
+ let css = ''
218
+ let fallbackColor = c1
219
+
220
+ if (type === 'linear') {
221
+ css = `linear-gradient(135deg, ${c1} 0%, ${c2} 50%, ${c3} 100%)`
222
+ } else if (type === 'radial') {
223
+ css = `radial-gradient(circle at 30% 20%, ${c2} 0%, ${c1} 70%)`
224
+ } else {
225
+ css = [
226
+ `radial-gradient(at 0% 0%, ${c1} 0px, transparent 50%)`,
227
+ `radial-gradient(at 100% 0%, ${c2} 0px, transparent 50%)`,
228
+ `radial-gradient(at 100% 100%, ${c3} 0px, transparent 50%)`,
229
+ `radial-gradient(at 0% 100%, ${c4} 0px, transparent 50%)`,
230
+ `radial-gradient(at 50% 50%, ${c2}88 0px, transparent 50%)`,
231
+ fallbackColor,
232
+ ].join(',\n ')
233
+ }
234
+
235
+ const completeStyle = `background-color: ${fallbackColor};\nbackground-image:\n ${css};`
236
+
237
+ return {
238
+ type,
239
+ fallbackColor,
240
+ gradientCss: css,
241
+ completeStyle,
242
+ byteSize: Buffer.byteLength(completeStyle, 'utf8'),
243
+ }
244
+ }
245
+
246
+ /**
247
+ * Issue #175: check_image_contrast
248
+ * Calculates WCAG 2.1 contrast ratio and suggests overlay / scrim.
249
+ */
250
+ export function checkWcagContrast(colors, textHex = '#ffffff') {
251
+ const palette = colors && colors.length > 0 ? colors : ['#0f172a']
252
+ const targetText = textHex.startsWith('#') ? textHex : `#${textHex}`
253
+
254
+ const ratios = palette.map((bgHex) => {
255
+ const ratio = getContrastRatio(bgHex, targetText)
256
+ const passAA = ratio >= 4.5
257
+ const passAALarge = ratio >= 3.0
258
+ const passAAA = ratio >= 7.0
259
+ return {
260
+ backgroundColor: bgHex,
261
+ contrastRatio: ratio,
262
+ passAA,
263
+ passAALarge,
264
+ passAAA,
265
+ }
266
+ })
267
+
268
+ const minRatio = Math.min(...ratios.map((r) => r.contrastRatio))
269
+ const passedAA = ratios.every((r) => r.passAA)
270
+ const passedAALarge = ratios.every((r) => r.passAALarge)
271
+ const passedAAA = ratios.every((r) => r.passAAA)
272
+
273
+ let recommendation = 'Contrast satisfies WCAG 2.1 AA standards.'
274
+ let scrimCss = ''
275
+
276
+ if (!passedAA) {
277
+ const lum = getRelativeLuminance(hexToRgb(targetText))
278
+ if (lum > 0.5) {
279
+ scrimCss = 'background: linear-gradient(180deg, rgba(0,0,0,0.4) 0%, rgba(0,0,0,0.7) 100%); backdrop-filter: blur(4px);'
280
+ recommendation = `Contrast ratio (${minRatio}:1) is below WCAG 2.1 AA (4.5:1). Apply dark overlay scrim or increase text weight/shadow.`
281
+ } else {
282
+ scrimCss = 'background: linear-gradient(180deg, rgba(255,255,255,0.6) 0%, rgba(255,255,255,0.85) 100%); backdrop-filter: blur(4px);'
283
+ recommendation = `Contrast ratio (${minRatio}:1) is below WCAG 2.1 AA (4.5:1). Apply light overlay scrim.`
284
+ }
285
+ }
286
+
287
+ return {
288
+ textHex: targetText,
289
+ minContrastRatio: minRatio,
290
+ passedAA,
291
+ passedAALarge,
292
+ passedAAA,
293
+ recommendation,
294
+ suggestedScrimCss: scrimCss,
295
+ details: ratios,
296
+ }
297
+ }
298
+
299
+ /**
300
+ * Issue #176: optimize_vector_svg
301
+ * Cleans, sanitizes, and minifies SVG content, normalizes viewBox, and exports React TSX component.
302
+ */
303
+ export function optimizeSvgContent(svgString, options = {}) {
304
+ if (!svgString || typeof svgString !== 'string') {
305
+ throw new Error('Invalid SVG string provided')
306
+ }
307
+
308
+ let cleaned = svgString.trim()
309
+
310
+ cleaned = cleaned.replace(/<\?xml[\s\S]*?\?>/gi, '')
311
+ cleaned = cleaned.replace(/<!DOCTYPE[\s\S]*?>/gi, '')
312
+ cleaned = cleaned.replace(/<!--[\s\S]*?-->/g, '')
313
+
314
+ cleaned = cleaned.replace(/\s*(?:xmlns:inkscape|xmlns:sodipodi|xmlns:sketch|inkscape:[a-z-]+|sodipodi:[a-z-]+|sketch:[a-z-]+)="[^"]*"/gi, '')
315
+ cleaned = cleaned.replace(/<(?:metadata|sodipodi:namedview)[\s\S]*?<\/(?:metadata|sodipodi:namedview)>/gi, '')
316
+
317
+ cleaned = cleaned.replace(/\s{2,}/g, ' ')
318
+ cleaned = cleaned.replace(/>\s+</g, '><')
319
+
320
+ const viewBoxMatch = cleaned.match(/viewBox="([^"]+)"/i)
321
+ let viewBox = viewBoxMatch ? viewBoxMatch[1] : '0 0 24 24'
322
+
323
+ if (!viewBoxMatch) {
324
+ const widthMatch = cleaned.match(/width="([0-9.]+)(?:px)?"/i)
325
+ const heightMatch = cleaned.match(/height="([0-9.]+)(?:px)?"/i)
326
+ if (widthMatch && heightMatch) {
327
+ viewBox = `0 0 ${widthMatch[1]} ${heightMatch[1]}`
328
+ cleaned = cleaned.replace(/<svg\b/i, `<svg viewBox="${viewBox}"`)
329
+ }
330
+ }
331
+
332
+ const originalSize = Buffer.byteLength(svgString, 'utf8')
333
+ const optimizedSize = Buffer.byteLength(cleaned, 'utf8')
334
+ const savedBytes = Math.max(0, originalSize - optimizedSize)
335
+ const reductionPercent = Math.round((savedBytes / (originalSize || 1)) * 100)
336
+
337
+ const componentName = options.componentName || 'VectorIcon'
338
+ let jsxBody = cleaned
339
+ .replace(/<svg\b[^>]*>/i, '')
340
+ .replace(/<\/svg>/i, '')
341
+ .replace(/class=/g, 'className=')
342
+ .replace(/clip-rule=/g, 'clipRule=')
343
+ .replace(/fill-rule=/g, 'fillRule=')
344
+ .replace(/stroke-width=/g, 'strokeWidth=')
345
+ .replace(/stroke-linecap=/g, 'strokeLinecap=')
346
+ .replace(/stroke-linejoin=/g, 'strokeLinejoin=')
347
+ .replace(/stroke-miterlimit=/g, 'strokeMiterlimit=')
348
+
349
+ const reactTsx = `import React from 'react';
350
+
351
+ export interface ${componentName}Props extends React.SVGProps<SVGSVGElement> {
352
+ size?: number | string;
353
+ }
354
+
355
+ export const ${componentName}: React.FC<${componentName}Props> = ({ size = 24, className, ...props }) => (
356
+ <svg
357
+ viewBox="${viewBox}"
358
+ width={size}
359
+ height={size}
360
+ fill="currentColor"
361
+ className={className}
362
+ {...props}
363
+ >
364
+ ${jsxBody.trim()}
365
+ </svg>
366
+ );
367
+ `
368
+
369
+ return {
370
+ svg: cleaned,
371
+ viewBox,
372
+ originalSize,
373
+ optimizedSize,
374
+ savedBytes,
375
+ reductionPercent,
376
+ reactTsx,
377
+ }
378
+ }
379
+
380
+ /**
381
+ * Issue #190: generate_pwa_icon_suite
382
+ * Generates standard PWA icon suite metadata, web app manifest.json, and icon configuration.
383
+ */
384
+ export async function generatePwaIconSuite({
385
+ name = 'App',
386
+ shortName = 'App',
387
+ themeColor = '#0f172a',
388
+ backgroundColor = '#ffffff',
389
+ iconsDir = 'icons',
390
+ sourcePath = null,
391
+ } = {}) {
392
+ const iconSizes = [
393
+ { size: 16, rel: 'icon', filename: `${iconsDir}/favicon-16x16.png`, purpose: 'any' },
394
+ { size: 32, rel: 'icon', filename: `${iconsDir}/favicon-32x32.png`, purpose: 'any' },
395
+ { size: 48, rel: 'icon', filename: `${iconsDir}/favicon-48x48.png`, purpose: 'any' },
396
+ { size: 180, rel: 'apple-touch-icon', filename: `${iconsDir}/apple-touch-icon.png`, purpose: 'any' },
397
+ { size: 192, rel: 'manifest', filename: `${iconsDir}/icon-192x192.png`, purpose: 'any' },
398
+ { size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512.png`, purpose: 'any' },
399
+ { size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512-maskable.png`, purpose: 'maskable' },
400
+ ]
401
+
402
+ const manifest = {
403
+ name,
404
+ short_name: shortName,
405
+ start_url: '/',
406
+ display: 'standalone',
407
+ background_color: backgroundColor,
408
+ theme_color: themeColor,
409
+ icons: iconSizes
410
+ .filter((i) => i.rel === 'manifest')
411
+ .map((i) => ({
412
+ src: i.filename,
413
+ sizes: `${i.size}x${i.size}`,
414
+ type: 'image/png',
415
+ purpose: i.purpose,
416
+ })),
417
+ }
418
+
419
+ const htmlHeadSnippet = [
420
+ `<link rel="icon" type="image/png" sizes="16x16" href="/${iconsDir}/favicon-16x16.png">`,
421
+ `<link rel="icon" type="image/png" sizes="32x32" href="/${iconsDir}/favicon-32x32.png">`,
422
+ `<link rel="apple-touch-icon" sizes="180x180" href="/${iconsDir}/apple-touch-icon.png">`,
423
+ `<link rel="manifest" href="/manifest.json">`,
424
+ `<meta name="theme-color" content="${themeColor}">`,
425
+ ].join('\n')
426
+
427
+ return {
428
+ name,
429
+ shortName,
430
+ themeColor,
431
+ backgroundColor,
432
+ icons: iconSizes,
433
+ manifestJson: JSON.stringify(manifest, null, 2),
434
+ htmlHeadSnippet,
435
+ }
436
+ }