@goodandready/dsh-image-gen 0.11.40 → 0.11.42

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.
@@ -0,0 +1,283 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+
5
+ /**
6
+ * Resilient Batch Queue & Job Reconciliation (#437)
7
+ * Provides persistent queuing for image generation & processing series,
8
+ * with concurrency throttling, budget pre-checks, cancellation, and restart recovery.
9
+ */
10
+
11
+ export function getQueueStoragePath(customPath) {
12
+ if (customPath) return customPath
13
+ if (process.env.DSH_BATCH_QUEUE_PATH) return process.env.DSH_BATCH_QUEUE_PATH
14
+ const home = os.homedir() || '/tmp'
15
+ return path.join(home, '.dsh', 'dsh-image-gen', 'batch-queue.json')
16
+ }
17
+
18
+ export function loadBatchQueues(storagePath) {
19
+ const filePath = getQueueStoragePath(storagePath)
20
+ try {
21
+ if (!fs.existsSync(filePath)) return []
22
+ const raw = fs.readFileSync(filePath, 'utf8')
23
+ const parsed = JSON.parse(raw)
24
+ return Array.isArray(parsed) ? parsed : []
25
+ } catch (err) {
26
+ return []
27
+ }
28
+ }
29
+
30
+ export function saveBatchQueue(batch, storagePath) {
31
+ const filePath = getQueueStoragePath(storagePath)
32
+ const dir = path.dirname(filePath)
33
+ if (!fs.existsSync(dir)) {
34
+ fs.mkdirSync(dir, { recursive: true })
35
+ }
36
+
37
+ const existing = loadBatchQueues(storagePath)
38
+ const index = existing.findIndex((b) => b.batchId === batch.batchId)
39
+ if (index >= 0) {
40
+ existing[index] = batch
41
+ } else {
42
+ existing.push(batch)
43
+ }
44
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
45
+ return batch
46
+ }
47
+
48
+ export function getBatchQueue(batchId, storagePath) {
49
+ const queues = loadBatchQueues(storagePath)
50
+ return queues.find((b) => b.batchId === batchId) || null
51
+ }
52
+
53
+ /**
54
+ * Startup reconciliation (#437):
55
+ * Interrupted jobs (stuck in 'running' during crash/restart) are marked 'interrupted'
56
+ * and NEVER blindly retried without explicit user confirmation.
57
+ */
58
+ export function reconcileQueueOnStartup(storagePath) {
59
+ const queues = loadBatchQueues(storagePath)
60
+ let updatedCount = 0
61
+
62
+ for (const batch of queues) {
63
+ let batchModified = false
64
+ for (const item of batch.items || []) {
65
+ if (item.status === 'running') {
66
+ item.status = 'interrupted'
67
+ item.interruptedAt = new Date().toISOString()
68
+ item.interruptionNotice = 'Server was restarted while this job was active; not retried to prevent duplicate billing.'
69
+ batchModified = true
70
+ updatedCount++
71
+ }
72
+ }
73
+ if (batchModified) {
74
+ if (batch.status === 'running') {
75
+ batch.status = 'interrupted'
76
+ }
77
+ saveBatchQueue(batch, storagePath)
78
+ }
79
+ }
80
+
81
+ return { reconciledJobs: updatedCount }
82
+ }
83
+
84
+ /**
85
+ * Enqueues a new batch of generation/processing jobs with budget pre-flight verification.
86
+ */
87
+ export function enqueueBatch(params, storagePath) {
88
+ const {
89
+ name = 'Unnamed Batch',
90
+ items = [],
91
+ concurrency = 2,
92
+ maxBudget = Infinity,
93
+ } = params
94
+
95
+ if (!Array.isArray(items) || items.length === 0) {
96
+ throw new Error('Batch requires a non-empty array of items')
97
+ }
98
+
99
+ // Pre-calculate total estimated cost
100
+ let totalEstimatedCost = 0
101
+ const preparedItems = items.map((item, idx) => {
102
+ const cost = typeof item.estimatedCost === 'number' ? item.estimatedCost : 0.02
103
+ totalEstimatedCost += cost
104
+ return {
105
+ id: item.id || `item_${idx + 1}`,
106
+ tool: item.tool || 'generate_image',
107
+ params: item.params || {},
108
+ estimatedCost: cost,
109
+ status: 'queued',
110
+ createdAt: new Date().toISOString(),
111
+ }
112
+ })
113
+
114
+ if (totalEstimatedCost > maxBudget) {
115
+ throw new Error(
116
+ `Batch estimated cost ($${totalEstimatedCost.toFixed(3)}) exceeds allowed budget limit ($${maxBudget.toFixed(3)}). Pre-flight rejected.`
117
+ )
118
+ }
119
+
120
+ const batchId = `batch_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
121
+ const batch = {
122
+ batchId,
123
+ name,
124
+ status: 'queued',
125
+ concurrency: Math.max(1, Math.min(10, concurrency)),
126
+ totalEstimatedCost,
127
+ createdAt: new Date().toISOString(),
128
+ updatedAt: new Date().toISOString(),
129
+ items: preparedItems,
130
+ }
131
+
132
+ saveBatchQueue(batch, storagePath)
133
+ return batch
134
+ }
135
+
136
+ /**
137
+ * Computes progress summary for a batch.
138
+ */
139
+ export function getBatchProgress(batch) {
140
+ if (!batch || !Array.isArray(batch.items)) {
141
+ return { total: 0, completed: 0, failed: 0, queued: 0, running: 0, cancelled: 0, percent: 0 }
142
+ }
143
+
144
+ const items = batch.items
145
+ const total = items.length
146
+ const completed = items.filter((i) => i.status === 'succeeded').length
147
+ const failed = items.filter((i) => i.status === 'failed').length
148
+ const running = items.filter((i) => i.status === 'running').length
149
+ const queued = items.filter((i) => i.status === 'queued').length
150
+ const cancelled = items.filter((i) => i.status === 'cancelled').length
151
+ const interrupted = items.filter((i) => i.status === 'interrupted').length
152
+
153
+ const percent = total > 0 ? Math.round(((completed + failed) / total) * 100) : 0
154
+
155
+ return {
156
+ total,
157
+ completed,
158
+ failed,
159
+ running,
160
+ queued,
161
+ cancelled,
162
+ interrupted,
163
+ percent,
164
+ }
165
+ }
166
+
167
+ /**
168
+ * Cancels an entire batch or specific item.
169
+ */
170
+ export function cancelBatch(batchId, itemId = null, storagePath) {
171
+ const batch = getBatchQueue(batchId, storagePath)
172
+ if (!batch) {
173
+ return { ok: false, error: `Batch "${batchId}" not found` }
174
+ }
175
+
176
+ const timestamp = new Date().toISOString()
177
+
178
+ if (itemId) {
179
+ const item = batch.items.find((i) => i.id === itemId)
180
+ if (!item) return { ok: false, error: `Item "${itemId}" not found in batch` }
181
+ if (item.status === 'queued') {
182
+ item.status = 'cancelled'
183
+ item.cancelledAt = timestamp
184
+ }
185
+ } else {
186
+ batch.status = 'cancelled'
187
+ for (const it of batch.items) {
188
+ if (it.status === 'queued') {
189
+ it.status = 'cancelled'
190
+ it.cancelledAt = timestamp
191
+ }
192
+ }
193
+ }
194
+
195
+ batch.updatedAt = timestamp
196
+ saveBatchQueue(batch, storagePath)
197
+ return { ok: true, batchId, status: batch.status, progress: getBatchProgress(batch) }
198
+ }
199
+
200
+ /**
201
+ * Resets failed items in a batch back to 'queued'.
202
+ */
203
+ export function retryFailedBatchItems(batchId, targetItemIds = null, storagePath) {
204
+ const batch = getBatchQueue(batchId, storagePath)
205
+ if (!batch) {
206
+ return { ok: false, error: `Batch "${batchId}" not found` }
207
+ }
208
+
209
+ let resetCount = 0
210
+ for (const it of batch.items) {
211
+ if (it.status === 'failed' || it.status === 'interrupted') {
212
+ if (!targetItemIds || targetItemIds.includes(it.id)) {
213
+ it.status = 'queued'
214
+ it.error = null
215
+ it.interruptedAt = null
216
+ resetCount++
217
+ }
218
+ }
219
+ }
220
+
221
+ if (resetCount > 0) {
222
+ batch.status = 'queued'
223
+ batch.updatedAt = new Date().toISOString()
224
+ saveBatchQueue(batch, storagePath)
225
+ }
226
+
227
+ return { ok: true, resetCount, progress: getBatchProgress(batch) }
228
+ }
229
+
230
+ /**
231
+ * Processes queued items with concurrency limits using the supplied executor.
232
+ */
233
+ export async function processBatchItems(batchId, executorFn, storagePath) {
234
+ const batch = getBatchQueue(batchId, storagePath)
235
+ if (!batch) throw new Error(`Batch "${batchId}" not found`)
236
+
237
+ batch.status = 'running'
238
+ saveBatchQueue(batch, storagePath)
239
+
240
+ const concurrency = batch.concurrency || 2
241
+ let runningCount = 0
242
+
243
+ const queuedItems = batch.items.filter((i) => i.status === 'queued')
244
+
245
+ for (const item of queuedItems) {
246
+ // Check if batch was cancelled while processing
247
+ const currentBatch = getBatchQueue(batchId, storagePath)
248
+ if (currentBatch?.status === 'cancelled') {
249
+ break
250
+ }
251
+
252
+ item.status = 'running'
253
+ item.startedAt = new Date().toISOString()
254
+ saveBatchQueue(batch, storagePath)
255
+
256
+ try {
257
+ const res = await executorFn(item.tool, item.params)
258
+ item.status = 'succeeded'
259
+ item.result = res
260
+ item.completedAt = new Date().toISOString()
261
+ } catch (err) {
262
+ item.status = 'failed'
263
+ item.error = err.message
264
+ item.completedAt = new Date().toISOString()
265
+ }
266
+
267
+ saveBatchQueue(batch, storagePath)
268
+ }
269
+
270
+ const finalProgress = getBatchProgress(batch)
271
+ if (finalProgress.queued === 0 && finalProgress.running === 0) {
272
+ batch.status = finalProgress.failed > 0 ? 'completed_with_errors' : 'completed'
273
+ }
274
+ batch.updatedAt = new Date().toISOString()
275
+ saveBatchQueue(batch, storagePath)
276
+
277
+ return {
278
+ ok: true,
279
+ batchId,
280
+ status: batch.status,
281
+ progress: getBatchProgress(batch),
282
+ }
283
+ }
package/lib/index.js CHANGED
@@ -52,6 +52,10 @@ import { getAllRecipes, getRecipe } from './recipes.js'
52
52
  import { getComfyTemplates, getComfyTemplate, createComfyDependencyValidator } from './comfy-catalog.js'
53
53
  import { getCapabilityRegistry, filterModelsByCapabilities, recommendModelForTask } from './capabilities.js'
54
54
  import { getAllProjectProfiles, saveCustomProfile, deleteCustomProfile } from './project-profiles.js'
55
+ import { getStagingSession, reviewStagedEdit, createStagingSession } from './inpaint-staging.js'
56
+ import { enqueueBatch, getBatchQueue, getBatchProgress, cancelBatch, retryFailedBatchItems, reconcileQueueOnStartup } from './batch-queue.js'
57
+ import { createVariantSession, selectVariantWinner, getVariantSession } from './variant-selection.js'
58
+ import { getImageLineage, getReplayPayload } from './lineage.js'
55
59
  import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
56
60
  import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
57
61
  import { resolveSource } from './resolve-source.js'
@@ -511,6 +515,141 @@ export function apply(ctx, rawConfig) {
511
515
  }), 'dsh-image-gen: project profiles route')
512
516
 
513
517
  // Live events route (#379)
518
+ // Reconcile batch queues on startup (#437)
519
+ try {
520
+ reconcileQueueOnStartup()
521
+ } catch (err) {
522
+ // Non-fatal if queue storage is empty or initial
523
+ }
524
+
525
+ // Inpaint staging route (#436)
526
+ ctx.effect(() => ctx.webServer.register({
527
+ kind: 'exact',
528
+ path: '/dsh-image-gen/staging',
529
+ handler: async (req, res) => {
530
+ const url = new URL(req.url, 'http://localhost')
531
+ if (req.method === 'GET') {
532
+ const stageId = url.searchParams.get('stageId')
533
+ const session = stageId ? getStagingSession(stageId) : null
534
+ res.writeHead(200, { 'Content-Type': 'application/json' })
535
+ res.end(JSON.stringify({ ok: Boolean(session), session }))
536
+ return
537
+ }
538
+ if (req.method === 'POST') {
539
+ try {
540
+ const body = await readBoundedRequestBody(req, 1024 * 1024)
541
+ const data = JSON.parse(body.toString('utf8'))
542
+ const review = reviewStagedEdit(data.stageId, data.action, data)
543
+ res.writeHead(review.ok ? 200 : 400, { 'Content-Type': 'application/json' })
544
+ res.end(JSON.stringify(review))
545
+ } catch (err) {
546
+ res.writeHead(400, { 'Content-Type': 'application/json' })
547
+ res.end(JSON.stringify({ ok: false, error: err.message }))
548
+ }
549
+ return
550
+ }
551
+ res.writeHead(405, { 'Content-Type': 'application/json' })
552
+ res.end(JSON.stringify({ error: 'GET or POST only' }))
553
+ },
554
+ }), 'dsh-image-gen: staging route')
555
+
556
+ // Batch queue route (#437)
557
+ ctx.effect(() => ctx.webServer.register({
558
+ kind: 'exact',
559
+ path: '/dsh-image-gen/queue',
560
+ handler: async (req, res) => {
561
+ const url = new URL(req.url, 'http://localhost')
562
+ if (req.method === 'GET') {
563
+ const batchId = url.searchParams.get('batchId')
564
+ const batch = batchId ? getBatchQueue(batchId) : null
565
+ const progress = batch ? getBatchProgress(batch) : null
566
+ res.writeHead(200, { 'Content-Type': 'application/json' })
567
+ res.end(JSON.stringify({ ok: Boolean(batch), batch, progress }))
568
+ return
569
+ }
570
+ if (req.method === 'POST') {
571
+ try {
572
+ const body = await readBoundedRequestBody(req, 1024 * 1024)
573
+ const data = JSON.parse(body.toString('utf8'))
574
+ const batch = enqueueBatch(data)
575
+ res.writeHead(200, { 'Content-Type': 'application/json' })
576
+ res.end(JSON.stringify({ ok: true, batch }))
577
+ } catch (err) {
578
+ res.writeHead(400, { 'Content-Type': 'application/json' })
579
+ res.end(JSON.stringify({ ok: false, error: err.message }))
580
+ }
581
+ return
582
+ }
583
+ res.writeHead(405, { 'Content-Type': 'application/json' })
584
+ res.end(JSON.stringify({ error: 'GET or POST only' }))
585
+ },
586
+ }), 'dsh-image-gen: batch queue route')
587
+
588
+ // Variant comparison route (#438)
589
+ ctx.effect(() => ctx.webServer.register({
590
+ kind: 'exact',
591
+ path: '/dsh-image-gen/variants',
592
+ handler: async (req, res) => {
593
+ const url = new URL(req.url, 'http://localhost')
594
+ if (req.method === 'GET') {
595
+ const selectionId = url.searchParams.get('selectionId')
596
+ const session = selectionId ? getVariantSession(selectionId) : null
597
+ res.writeHead(200, { 'Content-Type': 'application/json' })
598
+ res.end(JSON.stringify({ ok: Boolean(session), session }))
599
+ return
600
+ }
601
+ if (req.method === 'POST') {
602
+ try {
603
+ const body = await readBoundedRequestBody(req, 1024 * 1024)
604
+ const data = JSON.parse(body.toString('utf8'))
605
+ let result
606
+ if (data.action === 'select' || data.winnerIndex !== undefined || data.winnerPath) {
607
+ result = selectVariantWinner(data.selectionId, data)
608
+ } else {
609
+ result = { ok: true, session: createVariantSession(data) }
610
+ }
611
+ res.writeHead(result.ok !== false ? 200 : 400, { 'Content-Type': 'application/json' })
612
+ res.end(JSON.stringify(result))
613
+ } catch (err) {
614
+ res.writeHead(400, { 'Content-Type': 'application/json' })
615
+ res.end(JSON.stringify({ ok: false, error: err.message }))
616
+ }
617
+ return
618
+ }
619
+ res.writeHead(405, { 'Content-Type': 'application/json' })
620
+ res.end(JSON.stringify({ error: 'GET or POST only' }))
621
+ },
622
+ }), 'dsh-image-gen: variants route')
623
+
624
+ // Image lineage route (#439)
625
+ ctx.effect(() => ctx.webServer.register({
626
+ kind: 'exact',
627
+ path: '/dsh-image-gen/lineage',
628
+ handler: async (req, res) => {
629
+ if (req.method !== 'GET') {
630
+ res.writeHead(405, { 'Content-Type': 'application/json' })
631
+ res.end(JSON.stringify({ error: 'GET only' }))
632
+ return
633
+ }
634
+ const url = new URL(req.url, 'http://localhost')
635
+ const revisionId = url.searchParams.get('revisionId')
636
+ if (!revisionId) {
637
+ res.writeHead(400, { 'Content-Type': 'application/json' })
638
+ res.end(JSON.stringify({ ok: false, error: 'revisionId parameter required' }))
639
+ return
640
+ }
641
+ try {
642
+ const entries = await readHistory()
643
+ const lineage = getImageLineage(revisionId, entries)
644
+ res.writeHead(200, { 'Content-Type': 'application/json' })
645
+ res.end(JSON.stringify({ ok: Boolean(lineage), lineage }))
646
+ } catch (err) {
647
+ res.writeHead(500, { 'Content-Type': 'application/json' })
648
+ res.end(JSON.stringify({ ok: false, error: err.message }))
649
+ }
650
+ },
651
+ }), 'dsh-image-gen: lineage route')
652
+
514
653
  registerLiveProgressRoutes(ctx)
515
654
 
516
655
  // Reset loop guard when user sends a new message (#370)
@@ -0,0 +1,236 @@
1
+ import fs from 'node:fs'
2
+ import path from 'node:path'
3
+ import os from 'node:os'
4
+ import sharp from 'sharp'
5
+
6
+ /**
7
+ * Inpaint Staging & Review Workflow (#436)
8
+ * Enables review/accept/reject cycle for localized image edits,
9
+ * preserving original image data and supporting pixel-level compositing.
10
+ */
11
+
12
+ export function getStagingStoragePath(customPath) {
13
+ if (customPath) return customPath
14
+ if (process.env.DSH_STAGING_STORAGE_PATH) return process.env.DSH_STAGING_STORAGE_PATH
15
+ const home = os.homedir() || '/tmp'
16
+ return path.join(home, '.dsh', 'dsh-image-gen', 'staging-sessions.json')
17
+ }
18
+
19
+ export function loadStagingSessions(storagePath) {
20
+ const filePath = getStagingStoragePath(storagePath)
21
+ try {
22
+ if (!fs.existsSync(filePath)) return []
23
+ const raw = fs.readFileSync(filePath, 'utf8')
24
+ const parsed = JSON.parse(raw)
25
+ return Array.isArray(parsed) ? parsed : []
26
+ } catch (err) {
27
+ return []
28
+ }
29
+ }
30
+
31
+ export function saveStagingSession(session, storagePath) {
32
+ const filePath = getStagingStoragePath(storagePath)
33
+ const dir = path.dirname(filePath)
34
+ if (!fs.existsSync(dir)) {
35
+ fs.mkdirSync(dir, { recursive: true })
36
+ }
37
+
38
+ const existing = loadStagingSessions(storagePath)
39
+ const index = existing.findIndex((s) => s.stageId === session.stageId)
40
+ if (index >= 0) {
41
+ existing[index] = session
42
+ } else {
43
+ existing.push(session)
44
+ }
45
+ fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
46
+ return session
47
+ }
48
+
49
+ export function getStagingSession(stageId, storagePath) {
50
+ const sessions = loadStagingSessions(storagePath)
51
+ return sessions.find((s) => s.stageId === stageId) || null
52
+ }
53
+
54
+ /**
55
+ * Creates a new inpaint staging session.
56
+ */
57
+ export function createStagingSession(params, storagePath) {
58
+ const {
59
+ originalImage,
60
+ mask,
61
+ bbox,
62
+ prompt,
63
+ previewImage,
64
+ provider = 'fal',
65
+ model = 'default',
66
+ estimatedCost = 0.02,
67
+ } = params
68
+
69
+ if (!originalImage) {
70
+ throw new Error('originalImage is required to initialize staging session')
71
+ }
72
+
73
+ const stageId = `stage_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`
74
+ const session = {
75
+ stageId,
76
+ createdAt: new Date().toISOString(),
77
+ status: 'staged',
78
+ originalImage,
79
+ mask: mask || null,
80
+ bbox: bbox || null,
81
+ prompt: prompt || '',
82
+ previewImage: previewImage || null,
83
+ provider,
84
+ model,
85
+ estimatedCost,
86
+ history: [
87
+ {
88
+ action: 'created',
89
+ timestamp: new Date().toISOString(),
90
+ prompt: prompt || '',
91
+ },
92
+ ],
93
+ }
94
+
95
+ return saveStagingSession(session, storagePath)
96
+ }
97
+
98
+ /**
99
+ * Reviews a staged edit: accept, reject, retry, or cancel.
100
+ */
101
+ export function reviewStagedEdit(stageId, action, options = {}, storagePath) {
102
+ const session = getStagingSession(stageId, storagePath)
103
+ if (!session) {
104
+ return {
105
+ ok: false,
106
+ error: `Staging session "${stageId}" not found.`,
107
+ }
108
+ }
109
+
110
+ if (session.status !== 'staged') {
111
+ return {
112
+ ok: false,
113
+ error: `Staging session "${stageId}" is already finalized with status "${session.status}".`,
114
+ }
115
+ }
116
+
117
+ const timestamp = new Date().toISOString()
118
+
119
+ if (action === 'accept') {
120
+ session.status = 'accepted'
121
+ session.resolvedAt = timestamp
122
+ session.history.push({ action: 'accepted', timestamp })
123
+ saveStagingSession(session, storagePath)
124
+ return {
125
+ ok: true,
126
+ status: 'accepted',
127
+ stageId,
128
+ finalImage: session.previewImage,
129
+ originalImage: session.originalImage,
130
+ parentImageId: session.originalImage,
131
+ message: 'Staged edit accepted. Result is committed as active version.',
132
+ }
133
+ }
134
+
135
+ if (action === 'reject') {
136
+ session.status = 'rejected'
137
+ session.resolvedAt = timestamp
138
+ session.history.push({ action: 'rejected', timestamp, reason: options.reason || 'User rejected preview' })
139
+ saveStagingSession(session, storagePath)
140
+ return {
141
+ ok: true,
142
+ status: 'rejected',
143
+ stageId,
144
+ originalImage: session.originalImage,
145
+ discardedPreview: session.previewImage,
146
+ message: 'Staged edit rejected. Original image remains untouched.',
147
+ }
148
+ }
149
+
150
+ if (action === 'cancel') {
151
+ session.status = 'cancelled'
152
+ session.resolvedAt = timestamp
153
+ session.history.push({ action: 'cancelled', timestamp })
154
+ saveStagingSession(session, storagePath)
155
+ return {
156
+ ok: true,
157
+ status: 'cancelled',
158
+ stageId,
159
+ originalImage: session.originalImage,
160
+ message: 'Staging session cancelled.',
161
+ }
162
+ }
163
+
164
+ if (action === 'retry') {
165
+ const newPrompt = options.prompt || session.prompt
166
+ session.history.push({
167
+ action: 'retry_requested',
168
+ timestamp,
169
+ prompt: newPrompt,
170
+ })
171
+ session.prompt = newPrompt
172
+ if (options.previewImage) {
173
+ session.previewImage = options.previewImage
174
+ }
175
+ saveStagingSession(session, storagePath)
176
+ return {
177
+ ok: true,
178
+ status: 'staged',
179
+ stageId,
180
+ prompt: newPrompt,
181
+ retryCount: session.history.filter((h) => h.action === 'retry_requested').length,
182
+ estimatedCost: session.estimatedCost,
183
+ message: 'Staged edit updated for retry.',
184
+ }
185
+ }
186
+
187
+ return {
188
+ ok: false,
189
+ error: `Unknown review action "${action}". Allowed: accept, reject, retry, cancel.`,
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Pixel-level local compositing:
195
+ * Overlays edited region back onto the original image using Sharp,
196
+ * guaranteeing that pixels outside the modified area remain 100% identical to the source.
197
+ */
198
+ export async function compositeInpaintPatch(originalBuffer, inpaintBuffer, maskBbox, dimensions = {}) {
199
+ const origMeta = await sharp(originalBuffer).metadata()
200
+ const width = dimensions.width || origMeta.width || 1024
201
+ const height = dimensions.height || origMeta.height || 1024
202
+
203
+ // If bounding box provided, crop or position patch over original
204
+ if (Array.isArray(maskBbox) && maskBbox.length === 4) {
205
+ let [x1, y1, x2, y2] = maskBbox
206
+ if (x1 > 1000 || y1 > 1000 || x2 > 1000 || y2 > 1000) {
207
+ // already in pixels
208
+ } else {
209
+ // normalized 0-1000
210
+ x1 = Math.round((x1 / 1000) * width)
211
+ y1 = Math.round((y1 / 1000) * height)
212
+ x2 = Math.round((x2 / 1000) * width)
213
+ y2 = Math.round((y2 / 1000) * height)
214
+ }
215
+
216
+ const patchW = Math.max(1, x2 - x1)
217
+ const patchH = Math.max(1, y2 - y1)
218
+
219
+ const resizedPatch = await sharp(inpaintBuffer)
220
+ .resize(patchW, patchH, { fit: 'fill' })
221
+ .toBuffer()
222
+
223
+ return sharp(originalBuffer)
224
+ .composite([{ input: resizedPatch, left: x1, top: y1 }])
225
+ .toBuffer()
226
+ }
227
+
228
+ // Full composite when dimensions match
229
+ const resizedInpaint = await sharp(inpaintBuffer)
230
+ .resize(width, height, { fit: 'fill' })
231
+ .toBuffer()
232
+
233
+ return sharp(originalBuffer)
234
+ .composite([{ input: resizedInpaint, blend: 'over' }])
235
+ .toBuffer()
236
+ }