@goodandready/dsh-image-gen 0.11.26 → 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.
@@ -112,3 +112,28 @@ export function renderToolOutput(value) {
112
112
  }
113
113
  return blocks
114
114
  }
115
+
116
+ export function buildPresentationMeta(value) {
117
+ if (!value || typeof value !== 'object') return null
118
+ const meta = {
119
+ url: value.url,
120
+ path: value.path,
121
+ width: value.width,
122
+ height: value.height,
123
+ seed: value.seed,
124
+ prompt: value.prompt,
125
+ provider: value.provider,
126
+ model: value.model,
127
+ attachment: value.attachment,
128
+ attachmentId: value.attachment?.attachmentId || value.attachmentId,
129
+ }
130
+ if (value.images) meta.images = value.images
131
+ if (value.cells) meta.cells = value.cells
132
+ if (value.base_prompt) meta.base_prompt = value.base_prompt
133
+ if (value.blind_mode !== undefined) meta.blind_mode = value.blind_mode
134
+ if (value.light) meta.light = value.light
135
+ if (value.dark) meta.dark = value.dark
136
+ if (value.html_snippet) meta.html_snippet = value.html_snippet
137
+ if (value.css_snippet) meta.css_snippet = value.css_snippet
138
+ return meta
139
+ }
package/lib/client.js CHANGED
@@ -145,11 +145,15 @@ window.__ModuleLoader__.load({
145
145
  .ig-progress-meta{display:flex;justify-content:space-between;align-items:center;font-size:11px;color:var(--dsw-alias-label-secondary);font-weight:500}
146
146
  .ig-progress-track{height:6px;width:100%;background:var(--dsw-alias-bg-layer-1);border-radius:3px;overflow:hidden;position:relative}
147
147
  .ig-progress-fill{height:100%;background:var(--dsw-alias-state-brand);border-radius:3px;transition:width 0.25s ease-in-out}
148
+ .ig-progress-indeterminate{width:40%;animation:ig-indeterminate-slide 1.5s infinite ease-in-out}
149
+ @keyframes ig-indeterminate-slide{0%{transform:translateX(-100%)}50%{transform:translateX(150%)}100%{transform:translateX(300%)}}
148
150
 
149
151
  .ig-draft-overlay{position:absolute;bottom:8px;left:8px;padding:3px 8px;border-radius:4px;background:color-mix(in srgb, var(--dsw-alias-bg-layer-1) 80%, transparent);font-size:11px;font-weight:600;color:var(--dsw-alias-label-primary);display:flex;align-items:center;gap:6px;backdrop-filter:blur(4px)}
150
152
 
151
153
  @media (prefers-reduced-motion: reduce){
152
154
  .ig-draft-img,.ig-final-img{transition:none !important}
155
+ .ig-progress-fill{transition:none !important;animation:none !important}
156
+ .ig-progress-indeterminate{animation:none !important;width:100%;opacity:0.6}
153
157
  }
154
158
 
155
159
  /* Asset Vault & Studio (#158, #159) */
@@ -1369,17 +1373,53 @@ window.__ModuleLoader__.load({
1369
1373
  }
1370
1374
 
1371
1375
  // -------------------------------------------------------------- Toolview Component
1372
- const IMAGE_URL_RE = /https?:\/\/[^\s)]+\.(png|jpg|jpeg|webp|gif)(\?[^\s)]*)?/i
1376
+ const HTTP_IMAGE_URL_RE = /https?:\/\/[^\s)]+\.(png|jpg|jpeg|webp|gif)(\?[^\s)]*)?/i
1377
+ const RELATIVE_IMAGE_URL_RE = /(\/dsh-image-gen\/image\?[^\s)"'<>]+)/i
1378
+ const MARKDOWN_IMAGE_RE = /!\[.*?\]\(((\/|https?:\/\/)[^\s)]+)\)/i
1379
+ const MARKDOWN_LINK_RE = /\[.*?\]\(((\/|https?:\/\/)[^\s)]+)\)/i
1373
1380
 
1374
1381
  function readResult(block) {
1375
1382
  if (!block) return { text: '', url: '', attachment: null }
1376
1383
  const text = block.output || block.text || ''
1377
- const attachment = block.attachment || (block.attachments && block.attachments[0]) || null
1378
- const linked = text.match(IMAGE_URL_RE)
1384
+ const meta = block.presentationMeta || block.meta || block.data || block.result || null
1385
+
1386
+ let attachment = block.attachment
1387
+ || (Array.isArray(block.attachments) && block.attachments[0])
1388
+ || meta?.attachment
1389
+ || null
1390
+
1391
+ let url = meta?.url || block.url || ''
1392
+
1393
+ if (!url && attachment) {
1394
+ url = attachmentImageUrl(attachment)
1395
+ }
1396
+
1397
+ let cleanText = text
1398
+ if (!url && text) {
1399
+ const mdImg = text.match(MARKDOWN_IMAGE_RE)
1400
+ const relUrl = text.match(RELATIVE_IMAGE_URL_RE)
1401
+ const httpUrl = text.match(HTTP_IMAGE_URL_RE)
1402
+ const mdLink = text.match(MARKDOWN_LINK_RE)
1403
+
1404
+ if (mdImg) {
1405
+ url = mdImg[1]
1406
+ cleanText = text.replace(mdImg[0], '').trim()
1407
+ } else if (relUrl) {
1408
+ url = relUrl[1]
1409
+ cleanText = text.replace(relUrl[0], '').trim()
1410
+ } else if (httpUrl) {
1411
+ url = httpUrl[0]
1412
+ cleanText = text.replace(httpUrl[0], '').trim()
1413
+ } else if (mdLink) {
1414
+ url = mdLink[1]
1415
+ cleanText = text.replace(mdLink[0], '').trim()
1416
+ }
1417
+ }
1418
+
1379
1419
  return {
1380
1420
  attachment,
1381
- url: linked ? linked[0] : '',
1382
- text: linked ? text.replace(linked[0], '').trim() : text,
1421
+ url,
1422
+ text: cleanText,
1383
1423
  }
1384
1424
  }
1385
1425
 
@@ -1586,6 +1626,7 @@ window.__ModuleLoader__.load({
1586
1626
  const draftSrc = block && (block.draftUrl || block.previewUrl || '')
1587
1627
  body.push(react.createElement(ProgressiveImagePreview, {
1588
1628
  key: 'prog-img',
1629
+ callId: block && (block.id || block.callId || block.toolCallId || ''),
1589
1630
  src: displaySrc,
1590
1631
  draftSrc,
1591
1632
  isRunning: running,
@@ -1694,8 +1735,9 @@ window.__ModuleLoader__.load({
1694
1735
  react,
1695
1736
  t,
1696
1737
  parsed,
1697
- imgUrl: parsed.imageUrl || parsed.url || parsed.attachment?.url,
1698
- targetRef: parsed.attachmentId || parsed.attachment?.attachmentId || parsed.imageUrl || parsed.url,
1738
+ imgUrl: parsed.url || (parsed.attachment ? attachmentImageUrl(parsed.attachment) : ''),
1739
+ targetRef: (parsed.attachment && (parsed.attachment.attachmentId || parsed.attachment.id)) || parsed.url || 'latest',
1740
+ sendActionPrompt,
1699
1741
  onClose: () => setInpaintOpen(false),
1700
1742
  }) : null
1701
1743
 
@@ -1761,23 +1803,76 @@ window.__ModuleLoader__.load({
1761
1803
  current.seed !== undefined ? `seed: ${current.seed}` : (current.timestamp ? new Date(current.timestamp).toLocaleTimeString() : '')
1762
1804
  )
1763
1805
  )
1764
- } // -------------------------------------------------------------- Progressive Draft & Live Progress Preview (#147, #329)
1806
+ } // -------------------------------------------------------------- Progressive Draft & Live Progress Preview (#147, #329, #379)
1765
1807
  function ProgressiveImagePreview(props) {
1766
1808
  const src = props.src
1767
- const draftSrc = props.draftSrc
1768
1809
  const isRunning = props.isRunning
1769
- const progress = typeof props.progress === 'number' ? Math.min(100, Math.max(0, props.progress)) : undefined
1770
- const step = props.step
1771
- const stage = props.stage || (progress !== undefined && progress < 100 ? 'Rendering...' : 'Complete')
1810
+ const callId = props.callId
1772
1811
  const alt = props.alt || 'Generated image'
1773
1812
  const t = props.t || ((k) => k)
1774
1813
 
1775
1814
  const [loaded, setLoaded] = react.useState(false)
1815
+ const [liveState, setLiveState] = react.useState({
1816
+ progress: undefined,
1817
+ step: undefined,
1818
+ stage: undefined,
1819
+ draftSrc: '',
1820
+ })
1776
1821
 
1777
1822
  react.useEffect(() => {
1778
1823
  setLoaded(false)
1779
1824
  }, [src])
1780
1825
 
1826
+ // Subscribe to live events transport when running and callId is present (#379)
1827
+ react.useEffect(() => {
1828
+ const EventSourceImpl = typeof window !== 'undefined' ? window.EventSource : undefined
1829
+ if (!isRunning || !callId || !EventSourceImpl) {
1830
+ return
1831
+ }
1832
+ let es
1833
+ try {
1834
+ es = new EventSourceImpl('/dsh-image-gen/live-events?callId=' + encodeURIComponent(callId))
1835
+ es.onmessage = (e) => {
1836
+ try {
1837
+ const data = JSON.parse(e.data)
1838
+ if (!data) return
1839
+ setLiveState((prev) => ({
1840
+ progress: typeof data.progress === 'number' ? Math.min(100, Math.max(0, data.progress)) : prev.progress,
1841
+ step: data.step !== undefined ? data.step : prev.step,
1842
+ stage: data.stage !== undefined ? data.stage : prev.stage,
1843
+ draftSrc: data.draftUrl || prev.draftSrc,
1844
+ }))
1845
+ } catch (_err) {
1846
+ // Ignore event parsing errors
1847
+ }
1848
+ }
1849
+ es.onerror = () => {
1850
+ // EventSource handles retries or terminates on close
1851
+ }
1852
+ } catch (_err) {
1853
+ // Ignore EventSource creation errors
1854
+ }
1855
+
1856
+ return () => {
1857
+ if (es) {
1858
+ try {
1859
+ es.close()
1860
+ } catch (_err) {
1861
+ // Ignore EventSource close errors
1862
+ }
1863
+ }
1864
+ }
1865
+ }, [isRunning, callId])
1866
+
1867
+ const draftSrc = liveState.draftSrc || props.draftSrc
1868
+ const progress = liveState.progress !== undefined
1869
+ ? liveState.progress
1870
+ : (typeof props.progress === 'number' ? Math.min(100, Math.max(0, props.progress)) : undefined)
1871
+ const step = liveState.step !== undefined ? liveState.step : props.step
1872
+ const stage = liveState.stage
1873
+ || props.stage
1874
+ || (progress !== undefined && progress < 100 ? (t('card.rendering') || 'Rendering...') : (isRunning ? (t('card.generating') || 'Generating...') : 'Complete'))
1875
+
1781
1876
  const showProgress = isRunning || (progress !== undefined && progress < 100)
1782
1877
 
1783
1878
  const progressElement = showProgress ? react.createElement(
@@ -1793,8 +1888,8 @@ window.__ModuleLoader__.load({
1793
1888
  'div',
1794
1889
  { className: 'ig-progress-track' },
1795
1890
  react.createElement('div', {
1796
- className: 'ig-progress-fill',
1797
- style: { width: (progress !== undefined ? progress : 45) + '%' },
1891
+ className: 'ig-progress-fill' + (progress === undefined ? ' ig-progress-indeterminate' : ''),
1892
+ style: progress !== undefined ? { width: progress + '%' } : undefined,
1798
1893
  })
1799
1894
  )
1800
1895
  ) : null
@@ -1842,10 +1937,17 @@ window.__ModuleLoader__.load({
1842
1937
  progressElement
1843
1938
  )
1844
1939
  }
1845
- // 105-inpaint-canvas.js — Interactive in-chat canvas drawing overlay for inpainting (#285, #301, #328).
1940
+ // 105-inpaint-canvas.js — Interactive in-chat canvas drawing overlay for inpainting (#285, #301, #328, #380).
1941
+
1942
+ function buildEditInstruction(opts) {
1943
+ const p = (opts && opts.prompt ? opts.prompt.trim() : '') || 'inpaint masked region'
1944
+ const img = (opts && opts.image) || 'latest'
1945
+ const m = (opts && opts.mask) || ''
1946
+ return 'Use edit_image with prompt: "' + p + '", image: "' + img + '", mask: "' + m + '"'
1947
+ }
1846
1948
 
1847
1949
  function InpaintCanvas(props) {
1848
- const { imgUrl, targetRef, parsed, onClose, t } = props
1950
+ const { imgUrl, targetRef, parsed, onClose, sendActionPrompt, t } = props
1849
1951
  const canvasRef = react.useRef(null)
1850
1952
  const isDrawingRef = react.useRef(false)
1851
1953
  const undoStackRef = react.useRef([])
@@ -1856,8 +1958,12 @@ window.__ModuleLoader__.load({
1856
1958
  const [inpaintPrompt, setInpaintPrompt] = react.useState('')
1857
1959
  const [canvasDims, setCanvasDims] = react.useState({ width: 512, height: 512, ratio: '1/1' })
1858
1960
 
1859
- const effectiveImgUrl = imgUrl || parsed?.imageUrl || parsed?.url || parsed?.attachment?.url || ''
1860
- const effectiveTargetRef = targetRef || parsed?.attachmentId || parsed?.attachment?.attachmentId || parsed?.imageUrl || parsed?.url || 'current image'
1961
+ const effectiveImgUrl = imgUrl || (parsed?.attachment ? attachmentImageUrl(parsed.attachment) : (parsed?.url || ''))
1962
+ const effectiveTargetRef = targetRef
1963
+ || (parsed?.attachment && (parsed.attachment.attachmentId || parsed.attachment.id))
1964
+ || parsed?.attachmentId
1965
+ || parsed?.url
1966
+ || 'latest'
1861
1967
 
1862
1968
  const initCanvas = react.useCallback(() => {
1863
1969
  const canvas = canvasRef.current
@@ -1877,11 +1983,8 @@ window.__ModuleLoader__.load({
1877
1983
  if (e.target && e.target.naturalWidth && e.target.naturalHeight) {
1878
1984
  const nw = e.target.naturalWidth
1879
1985
  const nh = e.target.naturalHeight
1880
- // Scale dimensions down to manageable working canvas size (max 768)
1881
- const scale = Math.min(1, 768 / Math.max(nw, nh))
1882
- const cw = Math.round(nw * scale)
1883
- const ch = Math.round(nh * scale)
1884
- setCanvasDims({ width: cw, height: ch, ratio: `${cw}/${ch}` })
1986
+ // Preserve 1:1 intrinsic source dimensions for accurate mask generation (#380)
1987
+ setCanvasDims({ width: nw, height: nh, ratio: `${nw}/${nh}` })
1885
1988
  }
1886
1989
  }
1887
1990
 
@@ -1889,11 +1992,12 @@ window.__ModuleLoader__.load({
1889
1992
  const rect = canvas.getBoundingClientRect()
1890
1993
  const clientX = e.touches ? e.touches[0].clientX : e.clientX
1891
1994
  const clientY = e.touches ? e.touches[0].clientY : e.clientY
1892
- const scaleX = canvas.width / rect.width
1893
- const scaleY = canvas.height / rect.height
1995
+ const scaleX = canvas.width / (rect.width || canvas.width)
1996
+ const scaleY = canvas.height / (rect.height || canvas.height)
1894
1997
  return {
1895
1998
  x: (clientX - rect.left) * scaleX,
1896
1999
  y: (clientY - rect.top) * scaleY,
2000
+ scale: (scaleX + scaleY) / 2,
1897
2001
  }
1898
2002
  }
1899
2003
 
@@ -1905,16 +2009,17 @@ window.__ModuleLoader__.load({
1905
2009
  if (!ctx) return
1906
2010
  isDrawingRef.current = true
1907
2011
  const pos = getPos(e, canvas)
2012
+ const actualBrush = brushSize * (pos.scale || 1)
1908
2013
  ctx.beginPath()
1909
2014
  ctx.moveTo(pos.x, pos.y)
1910
- ctx.lineWidth = brushSize
2015
+ ctx.lineWidth = actualBrush
1911
2016
  ctx.lineCap = 'round'
1912
2017
  ctx.lineJoin = 'round'
1913
2018
  const color = toolMode === 'eraser' ? 'black' : 'white'
1914
2019
  ctx.strokeStyle = color
1915
2020
  ctx.fillStyle = color
1916
2021
 
1917
- ctx.arc(pos.x, pos.y, brushSize / 2, 0, Math.PI * 2)
2022
+ ctx.arc(pos.x, pos.y, actualBrush / 2, 0, Math.PI * 2)
1918
2023
  ctx.fill()
1919
2024
  ctx.beginPath()
1920
2025
  ctx.moveTo(pos.x, pos.y)
@@ -1929,25 +2034,26 @@ window.__ModuleLoader__.load({
1929
2034
  const ctx = canvas.getContext('2d')
1930
2035
  if (!ctx) return
1931
2036
  const pos = getPos(e, canvas)
1932
- ctx.lineWidth = brushSize
2037
+ const actualBrush = brushSize * (pos.scale || 1)
2038
+ ctx.lineWidth = actualBrush
1933
2039
  ctx.lineCap = 'round'
1934
2040
  ctx.lineJoin = 'round'
1935
2041
  const color = toolMode === 'eraser' ? 'black' : 'white'
1936
2042
  ctx.strokeStyle = color
1937
2043
  ctx.lineTo(pos.x, pos.y)
1938
2044
  ctx.stroke()
1939
- setHasStrokes(true)
2045
+ ctx.beginPath()
2046
+ ctx.moveTo(pos.x, pos.y)
1940
2047
  }
1941
2048
 
1942
- function stopDraw(e) {
2049
+ function stopDraw() {
1943
2050
  if (!isDrawingRef.current) return
1944
2051
  isDrawingRef.current = false
1945
2052
  const canvas = canvasRef.current
1946
2053
  if (!canvas) return
1947
2054
  const ctx = canvas.getContext('2d')
1948
2055
  if (!ctx) return
1949
- ctx.closePath()
1950
-
2056
+ ctx.beginPath()
1951
2057
  if (undoStackRef.current.length >= 15) {
1952
2058
  undoStackRef.current.shift()
1953
2059
  }
@@ -2000,7 +2106,15 @@ window.__ModuleLoader__.load({
2000
2106
  if (!canvas) return
2001
2107
  const maskDataUrl = canvas.toDataURL('image/png')
2002
2108
  const promptText = inpaintPrompt.trim() || 'inpaint masked region'
2003
- const instruction = 'Use edit_image with prompt: "' + promptText + '", source_image: "' + effectiveTargetRef + '", mask: "' + maskDataUrl + '"'
2109
+ const instruction = buildEditInstruction({
2110
+ prompt: promptText,
2111
+ image: effectiveTargetRef,
2112
+ mask: maskDataUrl,
2113
+ })
2114
+
2115
+ if (typeof sendActionPrompt === 'function') {
2116
+ sendActionPrompt(instruction)
2117
+ }
2004
2118
 
2005
2119
  if (typeof navigator !== 'undefined' && navigator.clipboard) {
2006
2120
  navigator.clipboard.writeText(instruction)
@@ -2188,7 +2302,18 @@ window.__ModuleLoader__.load({
2188
2302
  const t = props.t || ((k) => k)
2189
2303
  const block = props.block || {}
2190
2304
  const parsed = react.useMemo(() => {
2305
+ const meta = block.presentationMeta || block.meta || block.data || block.result
2306
+ if (meta && Array.isArray(meta.cells) && meta.cells.length) {
2307
+ return meta
2308
+ }
2191
2309
  const raw = block.output || block.text || ''
2310
+ const tagMatch = raw.match(/<dsh-image-gen-matrix>([\s\S]*?)<\/dsh-image-gen-matrix>/)
2311
+ if (tagMatch) {
2312
+ const fromTag = tryParseJsonObject(tagMatch[1])
2313
+ if (fromTag && Array.isArray(fromTag.cells) && fromTag.cells.length) {
2314
+ return fromTag
2315
+ }
2316
+ }
2192
2317
  const obj = tryParseJsonObject(raw)
2193
2318
  return obj || { base_prompt: '', blind_mode: false, cells: [] }
2194
2319
  }, [block])
@@ -2307,12 +2432,27 @@ window.__ModuleLoader__.load({
2307
2432
  let args = {}
2308
2433
  try { if (raw) args = JSON.parse(raw) } catch (_) { /* invalid json */ }
2309
2434
 
2310
- let data = {}
2311
- try {
2312
- const out = block && (block.output || block.result || block.text || '')
2313
- if (typeof out === 'object') data = out
2314
- else if (typeof out === 'string') data = JSON.parse(out)
2315
- } catch (_) { /* invalid json */ }
2435
+ let data = block && (block.presentationMeta || block.meta || block.data || block.result)
2436
+ if (!data || (!data.light && !data.dark)) {
2437
+ const out = block && (block.output || block.text || '')
2438
+ const tagMatch = typeof out === 'string' ? out.match(/<dsh-image-gen-theme-pair>([\s\S]*?)<\/dsh-image-gen-theme-pair>/) : null
2439
+ if (tagMatch) {
2440
+ try {
2441
+ data = JSON.parse(tagMatch[1])
2442
+ } catch (_err) {
2443
+ // Malformed XML payload
2444
+ }
2445
+ }
2446
+ if (!data || (!data.light && !data.dark)) {
2447
+ try {
2448
+ if (typeof out === 'object') data = out
2449
+ else if (typeof out === 'string') data = JSON.parse(out)
2450
+ } catch (_err) {
2451
+ // Malformed fallback output
2452
+ }
2453
+ }
2454
+ }
2455
+ data = data || {}
2316
2456
 
2317
2457
  const light = data.light || {}
2318
2458
  const dark = data.dark || {}
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,6 +1,7 @@
1
1
  import {
2
2
  normalizeMediaType,
3
3
  buildEditForm,
4
+ normalizeMaskAlpha,
4
5
  SIZE_PIXELS,
5
6
  formatErrorMessage,
6
7
  buildEndpointUrl,
@@ -32,6 +33,11 @@ export function createCustomGenerator(deps, job) {
32
33
  if (key) headers.Authorization = `Bearer ${key}`
33
34
 
34
35
  // Omit response_format; modern models return either base64 or URL.
36
+ let effectiveMask = mask
37
+ if (isEdit && mask && mask.bytes) {
38
+ const normBytes = await normalizeMaskAlpha(mask.bytes)
39
+ effectiveMask = { ...mask, bytes: normBytes, mediaType: 'image/png' }
40
+ }
35
41
  const endpoint = buildEndpointUrl(base, isEdit ? 'images/edits' : 'images/generations')
36
42
  const res = await fetchImpl(endpoint, {
37
43
  method: 'POST',
@@ -40,7 +46,7 @@ export function createCustomGenerator(deps, job) {
40
46
  ? buildEditForm({
41
47
  model: cfg.customModel,
42
48
  source,
43
- mask,
49
+ mask: effectiveMask,
44
50
  prompt: promptArg,
45
51
  size: cfg.customSize || (aspectPixels ? aspectPixels.join('x') : SIZE_PIXELS[size]) || size,
46
52
  strength,
@@ -9,11 +9,11 @@ import {
9
9
  * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
10
10
  * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
11
11
  * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
12
- * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
12
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string, onProgress?: Function, callId?: string} job
13
13
  */
14
14
  export function createFalGenerator(deps, job) {
15
15
  const { fetchImpl, resolveKey, cfg } = deps
16
- const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
16
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio, onProgress } = job
17
17
 
18
18
  async function fal(seedArg = seed, promptArg = prompt) {
19
19
  const key = await resolveKey(cfg.apiKeyEnv)
@@ -36,9 +36,13 @@ export function createFalGenerator(deps, job) {
36
36
  }
37
37
  }
38
38
 
39
+ if (typeof onProgress === 'function') {
40
+ onProgress({ stage: 'Submitting job...', progress: undefined })
41
+ }
42
+
39
43
  const submit = await submitJob(fetchImpl, cfg.baseURL, targetModel, key, body, signal)
40
44
  const statusUrl = submit.status_url || `${cfg.baseURL}/${cfg.model}/requests/${submit.request_id}/status`
41
- const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs)
45
+ const statusBody = await pollStatus(fetchImpl, statusUrl, key, signal, cfg.pollIntervalMs, cfg.timeoutMs, onProgress)
42
46
 
43
47
  const resultRes = await fetchImpl(statusBody.response_url, {
44
48
  headers: { Authorization: falAuthHeader(key) },
@@ -10,15 +10,24 @@ import {
10
10
  buildDefaultComfyWorkflow,
11
11
  } from '../../comfy-workflow-helpers.js'
12
12
 
13
+ function resolveWebSocket() {
14
+ if (typeof window !== 'undefined' && window.WebSocket) return window.WebSocket
15
+ try {
16
+ return (new Function('return typeof WebSocket !== "undefined" ? WebSocket : undefined'))()
17
+ } catch (_) {
18
+ return undefined
19
+ }
20
+ }
21
+
13
22
  /**
14
23
  * @param {fetchImpl: Function, resolveKey: Function, cfg: object} deps
15
24
  * @param {prompt?: string, size?: string, format?: string, seed?: number, signal?: AbortSignal,
16
25
  * negativePrompt?: string, guidanceScale?: number, source?: object, mask?: object, strength?: number,
17
- * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string} job
26
+ * quality?: string, style?: string, aspectPixels?: number[], aspectRatio?: string, onProgress?: Function, callId?: string} job
18
27
  */
19
28
  export function createLocalGenerator(deps, job) {
20
29
  const { fetchImpl, resolveKey, cfg } = deps
21
- const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio } = job
30
+ const { prompt, size, format, seed, signal, negativePrompt, guidanceScale, source, mask, strength, quality, style, aspectPixels, aspectRatio, onProgress } = job
22
31
 
23
32
  async function local(seedArg = seed, promptArg = prompt) {
24
33
  const base = String(cfg.localBaseURL || '').replace(/\/+$/, '')
@@ -34,6 +43,9 @@ export function createLocalGenerator(deps, job) {
34
43
  }
35
44
 
36
45
  if (kind === 'a1111') {
46
+ if (typeof onProgress === 'function') {
47
+ onProgress({ stage: 'Generating...', progress: undefined })
48
+ }
37
49
  const isImg2Img = Boolean(source && source.bytes)
38
50
  const endpoint = `${base}${isImg2Img ? '/sdapi/v1/img2img' : '/sdapi/v1/txt2img'}`
39
51
  const body = {
@@ -114,61 +126,138 @@ export function createLocalGenerator(deps, job) {
114
126
  }
115
127
  }
116
128
 
117
- const submit = await fetchImpl(`${base}/prompt`, {
118
- method: 'POST',
119
- headers: { 'Content-Type': 'application/json' },
120
- body: JSON.stringify({ prompt: workflowGraph, client_id: promptId }),
121
- signal,
122
- })
123
- if (!submit.ok) {
124
- throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
129
+ let ws
130
+ let wsClosed = false
131
+ const closeWs = () => {
132
+ if (!wsClosed && ws) {
133
+ wsClosed = true
134
+ try {
135
+ ws.close()
136
+ } catch (_err) {
137
+ // Socket close failure ignored
138
+ }
139
+ }
125
140
  }
126
- const submitData = await submit.json().catch(() => ({}))
127
- const pid = submitData.prompt_id
128
- if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
129
141
 
130
- const deadline = Date.now() + cfg.timeoutMs
131
- let attempt = 0
132
- for (;;) {
133
- if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
134
- if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
135
- if (attempt > 0) {
136
- const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
137
- await new Promise((resolve) => {
138
- const timer = setTimeout(resolve, cDelay)
142
+ try {
143
+ const WebSocketClass = resolveWebSocket()
144
+ if (WebSocketClass) {
145
+ const wsUrl = `${base.replace(/^http/, 'ws')}/ws?clientId=${encodeURIComponent(promptId)}`
146
+ try {
147
+ ws = new WebSocketClass(wsUrl)
139
148
  if (signal) {
140
- signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
149
+ signal.addEventListener('abort', closeWs, { once: true })
141
150
  }
142
- })
143
- }
144
- attempt++
145
- const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
146
- if (!hist.ok) continue
147
- const histData = await hist.json().catch(() => ({}))
148
- const entry = histData[pid]
149
- if (entry) {
150
- const comfyErr = extractComfyNodeErrors(entry)
151
- if (comfyErr) {
152
- throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
151
+ ws.onmessage = (event) => {
152
+ if (wsClosed) return
153
+ try {
154
+ if (typeof event.data === 'string') {
155
+ const msg = JSON.parse(event.data)
156
+ if (msg.type === 'progress') {
157
+ const { value, max } = msg.data || {}
158
+ const progress = (max > 0) ? Math.min(100, Math.max(0, Math.round((value / max) * 100))) : undefined
159
+ if (typeof onProgress === 'function') {
160
+ onProgress({ stage: 'Rendering...', step: `${value}/${max}`, progress })
161
+ }
162
+ } else if (msg.type === 'executing') {
163
+ const node = msg.data?.node
164
+ if (typeof onProgress === 'function') {
165
+ onProgress({ stage: node ? `Executing node ${node}` : 'Finishing...' })
166
+ }
167
+ } else if (msg.type === 'status') {
168
+ const q = msg.data?.status?.exec_info?.queue_remaining
169
+ if (q !== undefined && q > 0 && typeof onProgress === 'function') {
170
+ onProgress({ stage: `In queue (${q} remaining)` })
171
+ }
172
+ }
173
+ } else if (event.data) {
174
+ const rawBuf = Buffer.isBuffer(event.data) ? event.data : Buffer.from(event.data)
175
+ let imgBuf = rawBuf
176
+ if (rawBuf.length > 8 && (rawBuf[0] === 0 || rawBuf[0] === 1 || rawBuf[0] === 2)) {
177
+ imgBuf = rawBuf.subarray(8)
178
+ }
179
+ if (imgBuf.length > 4) {
180
+ const isPng = imgBuf[0] === 0x89 && imgBuf[1] === 0x50
181
+ const mime = isPng ? 'image/png' : 'image/jpeg'
182
+ const draftUrl = `data:${mime};base64,${imgBuf.toString('base64')}`
183
+ if (typeof onProgress === 'function') {
184
+ onProgress({ draftUrl })
185
+ }
186
+ }
187
+ }
188
+ } catch (_err) {
189
+ // Frame parse failure ignored
190
+ }
191
+ }
192
+ ws.onerror = () => {}
193
+ ws.onclose = () => {}
194
+ } catch (_err) {
195
+ // Websocket handshake failure ignored
153
196
  }
154
197
  }
155
- if (entry && entry.outputs) {
156
- const outputs = entry.outputs
157
- const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
158
- if (img) {
159
- const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
160
- if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
161
- return {
162
- bytes: Buffer.from(await dl.arrayBuffer()),
163
- mediaType: normalizeMediaType('image/png', format),
164
- width,
165
- height,
166
- seed: seedArg ?? 0,
167
- sourceUrl: '',
198
+
199
+ if (typeof onProgress === 'function') {
200
+ onProgress({ stage: 'Submitting prompt...', progress: undefined })
201
+ }
202
+
203
+ const submit = await fetchImpl(`${base}/prompt`, {
204
+ method: 'POST',
205
+ headers: { 'Content-Type': 'application/json' },
206
+ body: JSON.stringify({ prompt: workflowGraph, client_id: promptId }),
207
+ signal,
208
+ })
209
+ if (!submit.ok) {
210
+ throw new Error(`Local ComfyUI submit failed (HTTP ${submit.status}): ${String(await submit.text().catch(() => '')).slice(0, 300)}`)
211
+ }
212
+ const submitData = await submit.json().catch(() => ({}))
213
+ const pid = submitData.prompt_id
214
+ if (!pid) throw new Error('Local ComfyUI did not return a prompt_id')
215
+
216
+ const deadline = Date.now() + cfg.timeoutMs
217
+ let attempt = 0
218
+ for (;;) {
219
+ if (signal?.aborted) throw new Error('Local ComfyUI generation cancelled')
220
+ if (Date.now() > deadline) throw new Error(`Local ComfyUI timed out after ${cfg.timeoutMs} ms`)
221
+ if (attempt > 0) {
222
+ const cDelay = calculateBackoff(attempt - 1, cfg.pollIntervalMs || 1000, 5000)
223
+ await new Promise((resolve) => {
224
+ const timer = setTimeout(resolve, cDelay)
225
+ if (signal) {
226
+ signal.addEventListener('abort', () => { clearTimeout(timer); resolve() }, { once: true })
227
+ }
228
+ })
229
+ }
230
+ attempt++
231
+ const hist = await fetchImpl(`${base}/history/${pid}`, { signal })
232
+ if (!hist.ok) continue
233
+ const histData = await hist.json().catch(() => ({}))
234
+ const entry = histData[pid]
235
+ if (entry) {
236
+ const comfyErr = extractComfyNodeErrors(entry)
237
+ if (comfyErr) {
238
+ throw new Error(`Local ComfyUI execution failed: ${comfyErr}`)
239
+ }
240
+ }
241
+ if (entry && entry.outputs) {
242
+ const outputs = entry.outputs
243
+ const img = Object.values(outputs).flatMap((o) => o.images || []).find((i) => i && i.filename)
244
+ if (img) {
245
+ const dl = await fetchImpl(`${base}/view?filename=${encodeURIComponent(img.filename)}&subfolder=${encodeURIComponent(img.subfolder || '')}&type=${encodeURIComponent(img.type || 'output')}`, { signal })
246
+ if (!dl.ok) throw new Error(`Local ComfyUI download failed (HTTP ${dl.status})`)
247
+ return {
248
+ bytes: Buffer.from(await dl.arrayBuffer()),
249
+ mediaType: normalizeMediaType('image/png', format),
250
+ width,
251
+ height,
252
+ seed: seedArg ?? 0,
253
+ sourceUrl: '',
254
+ }
168
255
  }
169
256
  }
170
257
  }
258
+ } finally {
259
+ closeWs()
171
260
  }
172
261
  }
173
262
  return local
174
- }
263
+ }
@@ -115,7 +115,7 @@ export async function submitJob(fetchImpl, baseURL, model, key, body, signal) {
115
115
  return data
116
116
  }
117
117
 
118
- export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000) {
118
+ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollIntervalMs = 500, timeoutMs = 180000, onProgress) {
119
119
  const deadline = Date.now() + timeoutMs
120
120
  let attempt = 0
121
121
  for (;;) {
@@ -138,6 +138,21 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
138
138
  const res = await fetchImpl(statusUrl, { headers: { Authorization: falAuthHeader(key) }, signal })
139
139
  const data = await res.json().catch(() => ({}))
140
140
  const status = data.status
141
+ if (typeof onProgress === 'function') {
142
+ if (status === 'IN_QUEUE') {
143
+ const pos = data.queue_position
144
+ onProgress({
145
+ stage: pos !== undefined ? `In queue (#${pos})` : 'In queue',
146
+ progress: undefined, // honest status: do NOT simulate fake percentage
147
+ })
148
+ } else if (status === 'IN_PROGRESS') {
149
+ const rawProg = typeof data.progress === 'number' ? Math.round(data.progress * 100) : undefined
150
+ onProgress({
151
+ stage: 'Generating...',
152
+ progress: rawProg,
153
+ })
154
+ }
155
+ }
141
156
  if (status === 'COMPLETED') return data
142
157
  if (status === 'ERROR' || data.error || data.detail) {
143
158
  throw new Error(`FAL generation failed: ${JSON.stringify(data).slice(0, 600)}`)
@@ -148,6 +163,53 @@ export async function pollStatus(fetchImpl, statusUrl, key, signal, pollInterval
148
163
  }
149
164
  }
150
165
 
166
+ /**
167
+ * Normalizes mask bytes for OpenAI/Custom inpainting where transparent alpha (0) indicates edit region.
168
+ * If the incoming mask has an alpha channel with transparent areas, it is preserved.
169
+ * If the incoming mask is opaque black & white (e.g. from Canvas), white pixels are converted to transparent (alpha 0)
170
+ * and black pixels to opaque (alpha 255).
171
+ *
172
+ * @param {Buffer|Uint8Array} maskBytes
173
+ * @returns {Promise<Buffer>}
174
+ */
175
+ export async function normalizeMaskAlpha(maskBytes) {
176
+ if (!maskBytes || maskBytes.length === 0) return maskBytes
177
+ try {
178
+ const img = sharp(maskBytes)
179
+ const { data, info } = await img.raw().ensureAlpha().toBuffer({ resolveWithObject: true })
180
+ const { width, height, channels } = info
181
+
182
+ let hasTransparency = false
183
+ for (let i = 3; i < data.length; i += channels) {
184
+ if (data[i] < 250) {
185
+ hasTransparency = true
186
+ break
187
+ }
188
+ }
189
+
190
+ if (hasTransparency) {
191
+ return await sharp(data, { raw: { width, height, channels } }).png().toBuffer()
192
+ }
193
+
194
+ const out = Buffer.from(data)
195
+ for (let i = 0; i < out.length; i += channels) {
196
+ const r = out[i]
197
+ const g = out[i + 1]
198
+ const b = out[i + 2]
199
+ const lum = 0.299 * r + 0.587 * g + 0.114 * b
200
+ if (lum > 128) {
201
+ out[i + 3] = 0
202
+ } else {
203
+ out[i + 3] = 255
204
+ }
205
+ }
206
+
207
+ return await sharp(out, { raw: { width, height, channels } }).png().toBuffer()
208
+ } catch (_) {
209
+ return maskBytes
210
+ }
211
+ }
212
+
151
213
  export function buildEditForm({ source, mask, prompt, size, strength, model }) {
152
214
  const form = new FormData()
153
215
  if (model) form.append('model', model)
@@ -19,6 +19,12 @@ import { registerThemePairTools } from './tools/theme-pair.js'
19
19
  import { registerCharacterSheetTools } from './tools/character-sheet.js'
20
20
  import { registerVisionOcrTools } from './tools/vision-ocr.js'
21
21
  import { registerDiagramTools } from './tools/diagram.js'
22
+ import {
23
+ createLiveProgressSession,
24
+ publishLiveProgress,
25
+ completeLiveProgress,
26
+ abortLiveProgress,
27
+ } from './live-progress.js'
22
28
 
23
29
  /**
24
30
  * Registers every image-gen tool on the host tools service according to toolsetProfile (#337, #384).
@@ -43,7 +49,19 @@ export function registerAllTools(ctx, deps) {
43
49
  if (currentCfg && currentCfg.enabled === false) {
44
50
  throw new Error('Image generation is disabled in settings (dsh-image-gen.enabled is false); enable it in Settings → Image generation.')
45
51
  }
46
- return originalExecute(args, exec)
52
+ const callId = exec?.callId || exec?.rootCallId
53
+ if (callId) {
54
+ createLiveProgressSession(callId, { signal: exec.signal })
55
+ exec.onProgress = (update) => publishLiveProgress(callId, update)
56
+ }
57
+ try {
58
+ const res = await originalExecute(args, exec)
59
+ if (callId) completeLiveProgress(callId)
60
+ return res
61
+ } catch (err) {
62
+ if (callId) abortLiveProgress(callId, err.message)
63
+ throw err
64
+ }
47
65
  },
48
66
  }
49
67
  const res = originalToolsRegister(wrappedDef)
@@ -98,6 +98,21 @@ export function registerEditingTools(ctx, deps, options = {}) {
98
98
  const source = await resolveConversationImage(ctx, exec, args.image)
99
99
  const maskRef = args.mask_image || args.mask
100
100
  const mask = maskRef ? await resolveSource(ctx, exec, maskRef) : undefined
101
+ if (mask && mask.bytes && source && source.bytes) {
102
+ try {
103
+ const sharpMod = await import('sharp')
104
+ const sharp = sharpMod.default || sharpMod
105
+ const srcMeta = await sharp(source.bytes).metadata()
106
+ const maskMeta = await sharp(mask.bytes).metadata()
107
+ if (srcMeta.width && srcMeta.height && (maskMeta.width !== srcMeta.width || maskMeta.height !== srcMeta.height)) {
108
+ const resized = await sharp(mask.bytes).resize(srcMeta.width, srcMeta.height, { fit: 'fill' }).png().toBuffer()
109
+ mask.bytes = resized
110
+ mask.mediaType = 'image/png'
111
+ }
112
+ } catch (_err) {
113
+ // Mask dimension normalization failure ignored
114
+ }
115
+ }
101
116
  const vision = await analyzeImageWithVision(ctx, exec, source)
102
117
 
103
118
  let effectivePrompt = args.prompt
@@ -127,6 +142,8 @@ export function registerEditingTools(ctx, deps, options = {}) {
127
142
  strength,
128
143
  provider,
129
144
  signal: exec?.signal,
145
+ onProgress: exec?.onProgress,
146
+ callId: exec?.callId,
130
147
  }
131
148
 
132
149
  const gen = await editImageDirect(deps, job)
@@ -1,3 +1,4 @@
1
+ import { buildPresentationMeta } from '../attachment-helper.js'
1
2
  import { registerGenerationPackTool } from './generation-pack.js'
2
3
  import { defineTool } from '@deepseek-ai/dsh-tools'
3
4
  import path from 'node:path'
@@ -282,12 +283,16 @@ export function registerGenerationTools(ctx, deps, options = {}) {
282
283
  const summary = lines.join('\n')
283
284
  if (live().deliverAs !== 'image') {
284
285
  const urls = list.map((img) => img.url).filter(Boolean)
285
- return [{ type: 'text', text: urls.length ? `${summary}\n${urls.join('\n')}` : summary }]
286
+ const mdLinks = list.map((img) => `![Generated Image](${img.url})`).join('\n')
287
+ return [{ type: 'text', text: urls.length ? `${summary}\n${mdLinks}` : summary }]
286
288
  }
287
289
  const blocks = [{ type: 'text', text: summary }]
288
290
  for (const img of list) if (img.attachment) blocks.push({ type: 'image', attachment: img.attachment })
289
291
  return blocks
290
292
  },
293
+ presentationMeta(args, value) {
294
+ return buildPresentationMeta(value)
295
+ },
291
296
  },
292
297
  isConcurrencySafe: () => false,
293
298
  timeoutMs: (config?.timeoutMs || 180000) + 30000,
@@ -334,7 +339,7 @@ export function registerGenerationTools(ctx, deps, options = {}) {
334
339
  assertBudgetAvailable(estimatedCost, cfg.dailyBudgetUsd)
335
340
  const providers = makeProviders(
336
341
  { fetchImpl: fetch, resolveKey: (ref) => resolveApiKey(ctx, ref), cfg, subscriptionImages },
337
- { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef },
342
+ { prompt: effectivePrompt, size, format, seed: args.seed, signal: exec.signal, negativePrompt: effectiveNegative, guidanceScale: effectiveGuidance, source, mask, strength: args.strength, quality: args.quality, style: args.style, aspectPixels, aspectRatio: args.aspect_ratio, faceReference: resolvedFaceRef, onProgress: exec?.onProgress, callId: exec?.callId },
338
343
  )
339
344
  // Subscription providers return {ok:false, reason} instead of throwing:
340
345
  // rejection reaches the model as text. Without this guard,
@@ -107,7 +107,15 @@ export function registerStyleMatrixTools(ctx, deps) {
107
107
  blindMode: value.blind_mode,
108
108
  cells: value.cells || [],
109
109
  })
110
- return [{ type: 'text', text: summary }]
110
+ const metaTag = `<dsh-image-gen-matrix>${JSON.stringify({ cells: value.cells, base_prompt: value.base_prompt, blind_mode: value.blind_mode })}</dsh-image-gen-matrix>`
111
+ return [{ type: 'text', text: `${summary}\n\n${metaTag}` }]
112
+ },
113
+ presentationMeta(args, value) {
114
+ return {
115
+ base_prompt: value.base_prompt,
116
+ blind_mode: value.blind_mode,
117
+ cells: value.cells || [],
118
+ }
111
119
  },
112
120
  },
113
121
  isConcurrencySafe: () => false,
@@ -99,7 +99,25 @@ export function registerThemePairTools(ctx, deps) {
99
99
  },
100
100
  },
101
101
  render(_args, value) {
102
- return renderToolOutput(value)
102
+ const blocks = renderToolOutput(value)
103
+ const metaTag = `<dsh-image-gen-theme-pair>${JSON.stringify({
104
+ light: value.light,
105
+ dark: value.dark,
106
+ html_snippet: value.html_snippet,
107
+ css_snippet: value.css_snippet,
108
+ })}</dsh-image-gen-theme-pair>`
109
+ if (blocks[0] && blocks[0].type === 'text') {
110
+ blocks[0].text += `\n\n${metaTag}`
111
+ }
112
+ return blocks
113
+ },
114
+ presentationMeta(_args, value) {
115
+ return {
116
+ light: value.light,
117
+ dark: value.dark,
118
+ html_snippet: value.html_snippet,
119
+ css_snippet: value.css_snippet,
120
+ }
103
121
  },
104
122
  },
105
123
  isConcurrencySafe: () => false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-image-gen",
3
- "version": "0.11.26",
3
+ "version": "0.11.27",
4
4
  "description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers \u2014 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",