@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.
@@ -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 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
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 < 16) {
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
- // 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
- }
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
- 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
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
- const count = Math.max(1, end - start)
126
- anchors.push(rgbToHex(sumR / count, sumG / count, sumB / count))
167
+ } catch (_err) {
168
+ // If sharp cannot decode or buffer is non-image, fallback
127
169
  }
128
170
 
129
- const unique = Array.from(new Set(anchors))
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 metadata, web app manifest.json, and icon configuration.
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: iconSizes
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: iconSizes,
450
- manifestJson: JSON.stringify(manifest, null, 2),
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
+ }
@@ -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
+ }