@goodandready/dsh-image-gen 0.11.25 → 0.11.27
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/attachment-helper.js +25 -0
- package/lib/client.js +179 -39
- package/lib/frontend-assets.js +218 -43
- package/lib/index.js +4 -0
- package/lib/live-progress.js +278 -0
- package/lib/pattern-helpers.js +17 -0
- package/lib/provider-utils.js +77 -15
- package/lib/providers/backends/custom.js +7 -1
- package/lib/providers/backends/fal.js +7 -3
- package/lib/providers/backends/local.js +137 -48
- package/lib/providers/shared-helpers.js +63 -1
- package/lib/providers.js +235 -13
- package/lib/quality-gate.js +6 -5
- package/lib/register-tools.js +19 -1
- package/lib/resolve-image.js +1 -1
- package/lib/tools/editing.js +17 -0
- package/lib/tools/frontend.js +29 -12
- package/lib/tools/generation.js +7 -2
- package/lib/tools/inspect.js +2 -2
- package/lib/tools/pattern.js +9 -5
- package/lib/tools/processing-basic.js +7 -2
- package/lib/tools/style-matrix.js +9 -1
- package/lib/tools/theme-pair.js +19 -1
- package/package.json +2 -2
package/lib/frontend-assets.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
1
2
|
// @ts-check
|
|
2
3
|
import path from 'node:path'
|
|
3
4
|
import fs from 'node:fs/promises'
|
|
@@ -64,13 +65,12 @@ export function getContrastRatio(hex1, hex2) {
|
|
|
64
65
|
}
|
|
65
66
|
|
|
66
67
|
/**
|
|
67
|
-
* Extract palette
|
|
68
|
-
*
|
|
69
|
-
* @
|
|
70
|
-
* @returns {string[]} Hex colors
|
|
68
|
+
* Extract dominant palette colors from decoded image pixels with alpha awareness (#372).
|
|
69
|
+
* @param {Buffer|Uint8Array} buffer
|
|
70
|
+
* @returns {Promise<string[]>} Hex colors
|
|
71
71
|
*/
|
|
72
|
-
export function extractSampleColorsFromBuffer(buffer) {
|
|
73
|
-
if (!buffer || buffer.length <
|
|
72
|
+
export async function extractSampleColorsFromBuffer(buffer) {
|
|
73
|
+
if (!buffer || buffer.length < 8) {
|
|
74
74
|
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
75
75
|
}
|
|
76
76
|
|
|
@@ -95,42 +95,80 @@ export function extractSampleColorsFromBuffer(buffer) {
|
|
|
95
95
|
}
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
98
|
+
// Decode raster container to raw RGBA pixels via Sharp
|
|
99
|
+
try {
|
|
100
|
+
const { data, info } = await sharp(buffer)
|
|
101
|
+
.raw()
|
|
102
|
+
.ensureAlpha()
|
|
103
|
+
.toBuffer({ resolveWithObject: true })
|
|
104
|
+
|
|
105
|
+
const totalPixels = info.width * info.height
|
|
106
|
+
const step = Math.max(1, Math.floor(totalPixels / 2048))
|
|
107
|
+
const colorBuckets = new Map()
|
|
108
|
+
|
|
109
|
+
for (let i = 0; i < totalPixels; i += step) {
|
|
110
|
+
const off = i * 4
|
|
111
|
+
const r = data[off]
|
|
112
|
+
const g = data[off + 1]
|
|
113
|
+
const b = data[off + 2]
|
|
114
|
+
const a = data[off + 3]
|
|
115
|
+
|
|
116
|
+
// Ignore fully or mostly transparent pixels
|
|
117
|
+
if (a < 128) continue
|
|
118
|
+
|
|
119
|
+
// Quantize to 16-level RGB bins to cluster nearby nuances
|
|
120
|
+
const qr = Math.round(r / 16) * 16
|
|
121
|
+
const qg = Math.round(g / 16) * 16
|
|
122
|
+
const qb = Math.round(b / 16) * 16
|
|
123
|
+
const key = `${qr},${qg},${qb}`
|
|
124
|
+
|
|
125
|
+
let bucket = colorBuckets.get(key)
|
|
126
|
+
if (!bucket) {
|
|
127
|
+
bucket = { count: 0, rSum: 0, gSum: 0, bSum: 0 }
|
|
128
|
+
colorBuckets.set(key, bucket)
|
|
129
|
+
}
|
|
130
|
+
bucket.count++
|
|
131
|
+
bucket.rSum += r
|
|
132
|
+
bucket.gSum += g
|
|
133
|
+
bucket.bSum += b
|
|
134
|
+
}
|
|
111
135
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
136
|
+
if (colorBuckets.size > 0) {
|
|
137
|
+
const sorted = Array.from(colorBuckets.values()).sort((a, b) => b.count - a.count)
|
|
138
|
+
const dominant = sorted.slice(0, 6).map((b) => {
|
|
139
|
+
return rgbToHex(b.rSum / b.count, b.gSum / b.count, b.bSum / b.count)
|
|
140
|
+
})
|
|
141
|
+
|
|
142
|
+
const result = [...dominant]
|
|
143
|
+
if (result.length < 5) {
|
|
144
|
+
const primaryRgb = hexToRgb(result[0])
|
|
145
|
+
const isNearBlack = primaryRgb.r === 0 && primaryRgb.g === 0 && primaryRgb.b === 0
|
|
146
|
+
if (isNearBlack) {
|
|
147
|
+
const darkShades = ['#1a1a1a', '#333333', '#4d4d4d', '#666666', '#808080']
|
|
148
|
+
for (const s of darkShades) {
|
|
149
|
+
if (result.length >= 6) break
|
|
150
|
+
if (!result.includes(s)) result.push(s)
|
|
151
|
+
}
|
|
152
|
+
} else {
|
|
153
|
+
const factors = [0.2, 0.4, 0.6, 0.8, 1.2, 1.4]
|
|
154
|
+
for (const factor of factors) {
|
|
155
|
+
if (result.length >= 6) break
|
|
156
|
+
const shade = rgbToHex(
|
|
157
|
+
Math.min(255, Math.round(primaryRgb.r * factor)),
|
|
158
|
+
Math.min(255, Math.round(primaryRgb.g * factor)),
|
|
159
|
+
Math.min(255, Math.round(primaryRgb.b * factor)),
|
|
160
|
+
)
|
|
161
|
+
if (!result.includes(shade)) result.push(shade)
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
return result.slice(0, 6)
|
|
124
166
|
}
|
|
125
|
-
|
|
126
|
-
|
|
167
|
+
} catch (_err) {
|
|
168
|
+
// If sharp cannot decode or buffer is non-image, fallback
|
|
127
169
|
}
|
|
128
170
|
|
|
129
|
-
|
|
130
|
-
while (unique.length < 5) {
|
|
131
|
-
unique.push('#3b82f6')
|
|
132
|
-
}
|
|
133
|
-
return unique
|
|
171
|
+
return ['#0f172a', '#3b82f6', '#10b981', '#f59e0b', '#ef4444', '#f8fafc']
|
|
134
172
|
}
|
|
135
173
|
|
|
136
174
|
/**
|
|
@@ -394,9 +432,57 @@ export const ${componentName}: React.FC<${componentName}Props> = ({ size = 24, c
|
|
|
394
432
|
}
|
|
395
433
|
}
|
|
396
434
|
|
|
435
|
+
export function buildIcoBuffer(pngFrames) {
|
|
436
|
+
const count = pngFrames.length
|
|
437
|
+
const headerLen = 6 + count * 16
|
|
438
|
+
let currentOffset = headerLen
|
|
439
|
+
|
|
440
|
+
const entries = []
|
|
441
|
+
for (const item of pngFrames) {
|
|
442
|
+
entries.push({
|
|
443
|
+
width: item.width >= 256 ? 0 : item.width,
|
|
444
|
+
height: item.height >= 256 ? 0 : item.height,
|
|
445
|
+
size: item.buffer.length,
|
|
446
|
+
offset: currentOffset,
|
|
447
|
+
buffer: item.buffer,
|
|
448
|
+
})
|
|
449
|
+
currentOffset += item.buffer.length
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const out = Buffer.alloc(currentOffset)
|
|
453
|
+
// Header: reserved 0, type 1 (icon), count
|
|
454
|
+
out.writeUInt16LE(0, 0)
|
|
455
|
+
out.writeUInt16LE(1, 2)
|
|
456
|
+
out.writeUInt16LE(count, 4)
|
|
457
|
+
|
|
458
|
+
let entryPos = 6
|
|
459
|
+
for (const entry of entries) {
|
|
460
|
+
out.writeUInt8(entry.width, entryPos)
|
|
461
|
+
out.writeUInt8(entry.height, entryPos + 1)
|
|
462
|
+
out.writeUInt8(0, entryPos + 2) // color count
|
|
463
|
+
out.writeUInt8(0, entryPos + 3) // reserved
|
|
464
|
+
out.writeUInt16LE(1, entryPos + 4) // planes
|
|
465
|
+
out.writeUInt16LE(32, entryPos + 6) // bpp
|
|
466
|
+
out.writeUInt32LE(entry.size, entryPos + 8) // size
|
|
467
|
+
out.writeUInt32LE(entry.offset, entryPos + 12) // offset
|
|
468
|
+
entry.buffer.copy(out, entry.offset)
|
|
469
|
+
entryPos += 16
|
|
470
|
+
}
|
|
471
|
+
return out
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function getDefaultIconBytes(name, themeColor) {
|
|
475
|
+
const initial = (name || 'A').trim().charAt(0).toUpperCase() || 'A'
|
|
476
|
+
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
|
477
|
+
<rect width="512" height="512" rx="100" fill="${themeColor || '#0f172a'}"/>
|
|
478
|
+
<text x="50%" y="54%" font-family="system-ui, -apple-system, sans-serif" font-size="280" font-weight="bold" fill="#ffffff" dominant-baseline="middle" text-anchor="middle">${initial}</text>
|
|
479
|
+
</svg>`
|
|
480
|
+
return sharp(Buffer.from(svg)).png().toBuffer()
|
|
481
|
+
}
|
|
482
|
+
|
|
397
483
|
/**
|
|
398
|
-
* Issue #190: generate_pwa_icon_suite
|
|
399
|
-
* Generates standard PWA icon suite
|
|
484
|
+
* Issue #190 & #387: generate_pwa_icon_suite
|
|
485
|
+
* Generates standard PWA icon suite, web app manifest.json, multi-resolution favicon.ico, and HTML snippet.
|
|
400
486
|
*/
|
|
401
487
|
export async function generatePwaIconSuite({
|
|
402
488
|
name = 'App',
|
|
@@ -405,7 +491,20 @@ export async function generatePwaIconSuite({
|
|
|
405
491
|
backgroundColor = '#ffffff',
|
|
406
492
|
iconsDir = 'icons',
|
|
407
493
|
sourcePath = null,
|
|
494
|
+
imageBytes = null,
|
|
495
|
+
outDir = null,
|
|
408
496
|
} = {}) {
|
|
497
|
+
// 1. Resolve source image buffer
|
|
498
|
+
let source = imageBytes
|
|
499
|
+
if (!source && sourcePath) {
|
|
500
|
+
try {
|
|
501
|
+
source = await fs.readFile(sourcePath)
|
|
502
|
+
} catch (_err) { source = null }
|
|
503
|
+
}
|
|
504
|
+
if (!source) {
|
|
505
|
+
source = await getDefaultIconBytes(name, themeColor)
|
|
506
|
+
}
|
|
507
|
+
|
|
409
508
|
const iconSizes = [
|
|
410
509
|
{ size: 16, rel: 'icon', filename: `${iconsDir}/favicon-16x16.png`, purpose: 'any' },
|
|
411
510
|
{ size: 32, rel: 'icon', filename: `${iconsDir}/favicon-32x32.png`, purpose: 'any' },
|
|
@@ -416,6 +515,53 @@ export async function generatePwaIconSuite({
|
|
|
416
515
|
{ size: 512, rel: 'manifest', filename: `${iconsDir}/icon-512x512-maskable.png`, purpose: 'maskable' },
|
|
417
516
|
]
|
|
418
517
|
|
|
518
|
+
// 2. Render all PNG icons via Sharp
|
|
519
|
+
const generatedIcons = []
|
|
520
|
+
const icoFrames = []
|
|
521
|
+
|
|
522
|
+
for (const item of iconSizes) {
|
|
523
|
+
let pngBuf
|
|
524
|
+
if (item.purpose === 'maskable') {
|
|
525
|
+
// Safe zone requires 10% padding on each side -> inner icon is 80% of canvas (#387)
|
|
526
|
+
const innerSize = Math.max(1, Math.round(item.size * 0.8))
|
|
527
|
+
const inner = await sharp(source)
|
|
528
|
+
.resize(innerSize, innerSize, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
529
|
+
.png()
|
|
530
|
+
.toBuffer()
|
|
531
|
+
|
|
532
|
+
pngBuf = await sharp({
|
|
533
|
+
create: {
|
|
534
|
+
width: item.size,
|
|
535
|
+
height: item.size,
|
|
536
|
+
channels: 4,
|
|
537
|
+
background: backgroundColor || '#ffffff',
|
|
538
|
+
},
|
|
539
|
+
})
|
|
540
|
+
.composite([{ input: inner, gravity: 'center' }])
|
|
541
|
+
.png()
|
|
542
|
+
.toBuffer()
|
|
543
|
+
} else {
|
|
544
|
+
pngBuf = await sharp(source)
|
|
545
|
+
.resize(item.size, item.size, { fit: 'contain', background: { r: 0, g: 0, b: 0, alpha: 0 } })
|
|
546
|
+
.png()
|
|
547
|
+
.toBuffer()
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
generatedIcons.push({
|
|
551
|
+
...item,
|
|
552
|
+
bytes: pngBuf,
|
|
553
|
+
})
|
|
554
|
+
|
|
555
|
+
if ([16, 32, 48].includes(item.size) && item.rel === 'icon') {
|
|
556
|
+
icoFrames.push({ width: item.size, height: item.size, buffer: pngBuf })
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// 3. Multi-resolution favicon.ico containing 16, 32, 48 px PNG frames (#387)
|
|
561
|
+
const faviconIcoBuffer = buildIcoBuffer(icoFrames)
|
|
562
|
+
const faviconIcoFilename = `${iconsDir}/favicon.ico`
|
|
563
|
+
|
|
564
|
+
// 4. Manifest JSON
|
|
419
565
|
const manifest = {
|
|
420
566
|
name,
|
|
421
567
|
short_name: shortName,
|
|
@@ -423,7 +569,7 @@ export async function generatePwaIconSuite({
|
|
|
423
569
|
display: 'standalone',
|
|
424
570
|
background_color: backgroundColor,
|
|
425
571
|
theme_color: themeColor,
|
|
426
|
-
icons:
|
|
572
|
+
icons: generatedIcons
|
|
427
573
|
.filter((i) => i.rel === 'manifest')
|
|
428
574
|
.map((i) => ({
|
|
429
575
|
src: i.filename,
|
|
@@ -432,8 +578,11 @@ export async function generatePwaIconSuite({
|
|
|
432
578
|
purpose: i.purpose,
|
|
433
579
|
})),
|
|
434
580
|
}
|
|
581
|
+
const manifestJson = JSON.stringify(manifest, null, 2)
|
|
435
582
|
|
|
583
|
+
// 5. HTML snippet
|
|
436
584
|
const htmlHeadSnippet = [
|
|
585
|
+
`<link rel="icon" href="/${faviconIcoFilename}" sizes="any">`,
|
|
437
586
|
`<link rel="icon" type="image/png" sizes="16x16" href="/${iconsDir}/favicon-16x16.png">`,
|
|
438
587
|
`<link rel="icon" type="image/png" sizes="32x32" href="/${iconsDir}/favicon-32x32.png">`,
|
|
439
588
|
`<link rel="apple-touch-icon" sizes="180x180" href="/${iconsDir}/apple-touch-icon.png">`,
|
|
@@ -441,13 +590,39 @@ export async function generatePwaIconSuite({
|
|
|
441
590
|
`<meta name="theme-color" content="${themeColor}">`,
|
|
442
591
|
].join('\n')
|
|
443
592
|
|
|
593
|
+
// 6. Write files to outDir if provided
|
|
594
|
+
const exportedFiles = []
|
|
595
|
+
if (outDir) {
|
|
596
|
+
const fullIconsDir = path.resolve(outDir, iconsDir)
|
|
597
|
+
await fs.mkdir(fullIconsDir, { recursive: true })
|
|
598
|
+
|
|
599
|
+
for (const item of generatedIcons) {
|
|
600
|
+
const filePath = path.resolve(outDir, item.filename)
|
|
601
|
+
await fs.writeFile(filePath, item.bytes)
|
|
602
|
+
exportedFiles.push(filePath)
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
const icoPath = path.resolve(outDir, faviconIcoFilename)
|
|
606
|
+
await fs.writeFile(icoPath, faviconIcoBuffer)
|
|
607
|
+
exportedFiles.push(icoPath)
|
|
608
|
+
|
|
609
|
+
const manifestPath = path.resolve(outDir, 'manifest.json')
|
|
610
|
+
await fs.writeFile(manifestPath, manifestJson)
|
|
611
|
+
exportedFiles.push(manifestPath)
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Strip internal bytes property from public icons list to avoid huge JSON serialization
|
|
615
|
+
const cleanIcons = generatedIcons.map(({ bytes, ...rest }) => rest)
|
|
616
|
+
|
|
444
617
|
return {
|
|
445
618
|
name,
|
|
446
619
|
shortName,
|
|
447
620
|
themeColor,
|
|
448
621
|
backgroundColor,
|
|
449
|
-
icons:
|
|
450
|
-
|
|
622
|
+
icons: cleanIcons,
|
|
623
|
+
faviconIco: faviconIcoFilename,
|
|
624
|
+
manifestJson,
|
|
451
625
|
htmlHeadSnippet,
|
|
626
|
+
exportedFiles,
|
|
452
627
|
}
|
|
453
628
|
}
|
package/lib/index.js
CHANGED
|
@@ -44,6 +44,7 @@ import { registerImageCommand } from './commands.js'
|
|
|
44
44
|
import { registerAllTools } from './register-tools.js'
|
|
45
45
|
import { registerPluginUpdater } from './updater.js'
|
|
46
46
|
import { registerVaultRoutes } from './vault.js'
|
|
47
|
+
import { registerLiveProgressRoutes } from './live-progress.js'
|
|
47
48
|
import { registerSettingsRoutes } from './settings-route.js'
|
|
48
49
|
import { clearAllAnchors } from './anchor-helpers.js'
|
|
49
50
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
@@ -418,6 +419,9 @@ export function apply(ctx, rawConfig) {
|
|
|
418
419
|
// Vault route (#159)
|
|
419
420
|
registerVaultRoutes(ctx)
|
|
420
421
|
|
|
422
|
+
// Live events route (#379)
|
|
423
|
+
registerLiveProgressRoutes(ctx)
|
|
424
|
+
|
|
421
425
|
// Reset loop guard when user sends a new message (#370)
|
|
422
426
|
if (typeof ctx.on === 'function') {
|
|
423
427
|
ctx.on('chat/message', (session) => {
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
// lib/live-progress.js — Live progress event hub & presentation transport (#147, #329, #379)
|
|
2
|
+
|
|
3
|
+
import { isTrustedLocalRequest } from './security.js'
|
|
4
|
+
|
|
5
|
+
const activeSessions = new Map()
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Creates or resets a live progress session for a tool call.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} callId
|
|
11
|
+
* @param {{ signal?: AbortSignal, onProgress?: Function }} [options]
|
|
12
|
+
* @returns {object}
|
|
13
|
+
*/
|
|
14
|
+
export function createLiveProgressSession(callId, options = {}) {
|
|
15
|
+
if (!callId) return null
|
|
16
|
+
const existing = activeSessions.get(callId)
|
|
17
|
+
if (existing) {
|
|
18
|
+
existing.listeners.clear()
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const session = {
|
|
22
|
+
callId,
|
|
23
|
+
stage: 'Starting...',
|
|
24
|
+
step: undefined,
|
|
25
|
+
progress: undefined,
|
|
26
|
+
draftUrl: undefined,
|
|
27
|
+
done: false,
|
|
28
|
+
error: undefined,
|
|
29
|
+
timestamp: Date.now(),
|
|
30
|
+
listeners: new Set(),
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
if (options.signal) {
|
|
34
|
+
options.signal.addEventListener(
|
|
35
|
+
'abort',
|
|
36
|
+
() => {
|
|
37
|
+
abortLiveProgress(callId, 'Execution aborted')
|
|
38
|
+
},
|
|
39
|
+
{ once: true },
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
activeSessions.set(callId, session)
|
|
44
|
+
return session
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Publishes an incremental update to a live progress session.
|
|
49
|
+
*
|
|
50
|
+
* @param {string} callId
|
|
51
|
+
* @param {{ stage?: string, step?: string, progress?: number, draftUrl?: string }} update
|
|
52
|
+
*/
|
|
53
|
+
export function publishLiveProgress(callId, update = {}) {
|
|
54
|
+
if (!callId) return
|
|
55
|
+
let session = activeSessions.get(callId)
|
|
56
|
+
if (!session) {
|
|
57
|
+
session = createLiveProgressSession(callId)
|
|
58
|
+
}
|
|
59
|
+
if (session.done) return
|
|
60
|
+
|
|
61
|
+
if (update.stage !== undefined) session.stage = update.stage
|
|
62
|
+
if (update.step !== undefined) session.step = update.step
|
|
63
|
+
if (update.progress !== undefined) {
|
|
64
|
+
session.progress = typeof update.progress === 'number'
|
|
65
|
+
? Math.min(100, Math.max(0, Math.round(update.progress)))
|
|
66
|
+
: undefined
|
|
67
|
+
}
|
|
68
|
+
if (update.draftUrl !== undefined) session.draftUrl = update.draftUrl
|
|
69
|
+
session.timestamp = Date.now()
|
|
70
|
+
|
|
71
|
+
const payload = {
|
|
72
|
+
callId,
|
|
73
|
+
stage: session.stage,
|
|
74
|
+
step: session.step,
|
|
75
|
+
progress: session.progress,
|
|
76
|
+
draftUrl: session.draftUrl,
|
|
77
|
+
done: false,
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const listener of session.listeners) {
|
|
81
|
+
try {
|
|
82
|
+
listener(payload)
|
|
83
|
+
} catch (_err) {
|
|
84
|
+
// Listener notification failure ignored
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Marks a session as complete and notifies subscribers.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} callId
|
|
93
|
+
* @param {object} [finalData]
|
|
94
|
+
*/
|
|
95
|
+
export function completeLiveProgress(callId, finalData = {}) {
|
|
96
|
+
if (!callId) return
|
|
97
|
+
const session = activeSessions.get(callId)
|
|
98
|
+
if (!session || session.done) return
|
|
99
|
+
|
|
100
|
+
session.done = true
|
|
101
|
+
session.stage = 'Complete'
|
|
102
|
+
session.progress = 100
|
|
103
|
+
session.timestamp = Date.now()
|
|
104
|
+
|
|
105
|
+
const payload = {
|
|
106
|
+
callId,
|
|
107
|
+
stage: 'Complete',
|
|
108
|
+
progress: 100,
|
|
109
|
+
draftUrl: session.draftUrl,
|
|
110
|
+
done: true,
|
|
111
|
+
...finalData,
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
for (const listener of session.listeners) {
|
|
115
|
+
try {
|
|
116
|
+
listener(payload)
|
|
117
|
+
} catch (_err) {
|
|
118
|
+
// Listener notification failure ignored
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
session.listeners.clear()
|
|
122
|
+
|
|
123
|
+
// Retain briefly for trailing SSE disconnects, then purge
|
|
124
|
+
setTimeout(() => {
|
|
125
|
+
activeSessions.delete(callId)
|
|
126
|
+
}, 15000)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Marks a session as aborted or errored.
|
|
131
|
+
*
|
|
132
|
+
* @param {string} callId
|
|
133
|
+
* @param {string} [reason]
|
|
134
|
+
*/
|
|
135
|
+
export function abortLiveProgress(callId, reason = 'Cancelled') {
|
|
136
|
+
if (!callId) return
|
|
137
|
+
const session = activeSessions.get(callId)
|
|
138
|
+
if (!session || session.done) return
|
|
139
|
+
|
|
140
|
+
session.done = true
|
|
141
|
+
session.error = reason
|
|
142
|
+
session.timestamp = Date.now()
|
|
143
|
+
|
|
144
|
+
const payload = {
|
|
145
|
+
callId,
|
|
146
|
+
error: reason,
|
|
147
|
+
done: true,
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
for (const listener of session.listeners) {
|
|
151
|
+
try {
|
|
152
|
+
listener(payload)
|
|
153
|
+
} catch (_err) {
|
|
154
|
+
// Listener notification failure ignored
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
session.listeners.clear()
|
|
158
|
+
|
|
159
|
+
setTimeout(() => {
|
|
160
|
+
activeSessions.delete(callId)
|
|
161
|
+
}, 10000)
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Returns current snapshot of a live progress session.
|
|
166
|
+
*
|
|
167
|
+
* @param {string} callId
|
|
168
|
+
* @returns {object|null}
|
|
169
|
+
*/
|
|
170
|
+
export function getLiveProgress(callId) {
|
|
171
|
+
if (!callId) return null
|
|
172
|
+
const session = activeSessions.get(callId)
|
|
173
|
+
if (!session) return null
|
|
174
|
+
return {
|
|
175
|
+
callId: session.callId,
|
|
176
|
+
stage: session.stage,
|
|
177
|
+
step: session.step,
|
|
178
|
+
progress: session.progress,
|
|
179
|
+
draftUrl: session.draftUrl,
|
|
180
|
+
done: session.done,
|
|
181
|
+
error: session.error,
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Subscribes a listener to progress events for a callId.
|
|
187
|
+
* Calls listener with current state immediately if present.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} callId
|
|
190
|
+
* @param {Function} listener
|
|
191
|
+
* @returns {Function} unsubscribe
|
|
192
|
+
*/
|
|
193
|
+
export function subscribeLiveProgress(callId, listener) {
|
|
194
|
+
if (!callId || typeof listener !== 'function') return () => {}
|
|
195
|
+
let session = activeSessions.get(callId)
|
|
196
|
+
if (!session) {
|
|
197
|
+
session = createLiveProgressSession(callId)
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
session.listeners.add(listener)
|
|
201
|
+
|
|
202
|
+
// Initial event replay
|
|
203
|
+
listener({
|
|
204
|
+
callId: session.callId,
|
|
205
|
+
stage: session.stage,
|
|
206
|
+
step: session.step,
|
|
207
|
+
progress: session.progress,
|
|
208
|
+
draftUrl: session.draftUrl,
|
|
209
|
+
done: session.done,
|
|
210
|
+
error: session.error,
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
return () => {
|
|
214
|
+
session.listeners.delete(listener)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Registers the /dsh-image-gen/live-events SSE route on DSH web server.
|
|
220
|
+
*
|
|
221
|
+
* @param {object} ctx cordis context
|
|
222
|
+
*/
|
|
223
|
+
export function registerLiveProgressRoutes(ctx) {
|
|
224
|
+
ctx.effect(() => ctx.webServer.register({
|
|
225
|
+
kind: 'exact',
|
|
226
|
+
path: '/dsh-image-gen/live-events',
|
|
227
|
+
handler: async (req, res) => {
|
|
228
|
+
if (req.method !== 'GET' && req.method !== 'HEAD') {
|
|
229
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
230
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
if (!isTrustedLocalRequest(req)) {
|
|
234
|
+
res.writeHead(403, { 'Content-Type': 'application/json' })
|
|
235
|
+
res.end(JSON.stringify({ error: 'forbidden' }))
|
|
236
|
+
return
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const u = new URL(req.url, 'http://localhost')
|
|
240
|
+
const callId = u.searchParams.get('callId') || ''
|
|
241
|
+
if (!callId) {
|
|
242
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
243
|
+
res.end(JSON.stringify({ error: 'callId query parameter required' }))
|
|
244
|
+
return
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
res.writeHead(200, {
|
|
248
|
+
'Content-Type': 'text/event-stream',
|
|
249
|
+
'Cache-Control': 'no-cache, no-transform',
|
|
250
|
+
'Connection': 'keep-alive',
|
|
251
|
+
'Access-Control-Allow-Origin': '*',
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
res.write(': connected\n\n')
|
|
255
|
+
|
|
256
|
+
const unsubscribe = subscribeLiveProgress(callId, (data) => {
|
|
257
|
+
try {
|
|
258
|
+
res.write(`data: ${JSON.stringify(data)}\n\n`)
|
|
259
|
+
if (data.done) {
|
|
260
|
+
setTimeout(() => {
|
|
261
|
+
try {
|
|
262
|
+
res.end()
|
|
263
|
+
} catch (_err) {
|
|
264
|
+
// Response end failure ignored
|
|
265
|
+
}
|
|
266
|
+
}, 300)
|
|
267
|
+
}
|
|
268
|
+
} catch (_err) {
|
|
269
|
+
// SSE write failure ignored
|
|
270
|
+
}
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
req.on('close', () => {
|
|
274
|
+
unsubscribe()
|
|
275
|
+
})
|
|
276
|
+
},
|
|
277
|
+
}), 'dsh-image-gen: live events route')
|
|
278
|
+
}
|
package/lib/pattern-helpers.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import sharp from 'sharp'
|
|
1
2
|
// Pure helpers for generate_seamless_pattern (#178)
|
|
2
3
|
|
|
3
4
|
export function buildSeamlessPrompt(motif, style, density) {
|
|
@@ -85,3 +86,19 @@ export function tilePixels(pixels, width, height, channels, cols, rows) {
|
|
|
85
86
|
}
|
|
86
87
|
return { pixels: out, width: outW, height: outH }
|
|
87
88
|
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Decodes image container to raw RGBA pixels and scores edge wrap continuity (#371).
|
|
92
|
+
* Returns 0..1 (1 = identical opposite edges).
|
|
93
|
+
* @param {Buffer|Uint8Array} imageBytes
|
|
94
|
+
* @returns {Promise<number>}
|
|
95
|
+
*/
|
|
96
|
+
export async function scoreImageEdgeWrap(imageBytes) {
|
|
97
|
+
if (!imageBytes || imageBytes.length === 0) return 0
|
|
98
|
+
try {
|
|
99
|
+
const { data, info } = await sharp(imageBytes).raw().ensureAlpha().toBuffer({ resolveWithObject: true })
|
|
100
|
+
return scoreEdgeWrap(data, info.width, info.height, info.channels)
|
|
101
|
+
} catch {
|
|
102
|
+
return 0
|
|
103
|
+
}
|
|
104
|
+
}
|