@goodandready/dsh-image-gen 0.11.40 → 0.11.41
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/batch-queue.js +283 -0
- package/lib/index.js +72 -0
- package/lib/inpaint-staging.js +236 -0
- package/lib/register-tools.js +8 -0
- package/lib/tools/batch-queue.js +134 -0
- package/lib/tools/inpaint-staging.js +116 -0
- package/package.json +1 -1
|
@@ -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,8 @@ 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'
|
|
55
57
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
56
58
|
import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
|
|
57
59
|
import { resolveSource } from './resolve-source.js'
|
|
@@ -511,6 +513,76 @@ export function apply(ctx, rawConfig) {
|
|
|
511
513
|
}), 'dsh-image-gen: project profiles route')
|
|
512
514
|
|
|
513
515
|
// Live events route (#379)
|
|
516
|
+
// Reconcile batch queues on startup (#437)
|
|
517
|
+
try {
|
|
518
|
+
reconcileQueueOnStartup()
|
|
519
|
+
} catch (err) {
|
|
520
|
+
// Non-fatal if queue storage is empty or initial
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// Inpaint staging route (#436)
|
|
524
|
+
ctx.effect(() => ctx.webServer.register({
|
|
525
|
+
kind: 'exact',
|
|
526
|
+
path: '/dsh-image-gen/staging',
|
|
527
|
+
handler: async (req, res) => {
|
|
528
|
+
const url = new URL(req.url, 'http://localhost')
|
|
529
|
+
if (req.method === 'GET') {
|
|
530
|
+
const stageId = url.searchParams.get('stageId')
|
|
531
|
+
const session = stageId ? getStagingSession(stageId) : null
|
|
532
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
533
|
+
res.end(JSON.stringify({ ok: Boolean(session), session }))
|
|
534
|
+
return
|
|
535
|
+
}
|
|
536
|
+
if (req.method === 'POST') {
|
|
537
|
+
try {
|
|
538
|
+
const body = await readBoundedRequestBody(req, 1024 * 1024)
|
|
539
|
+
const data = JSON.parse(body.toString('utf8'))
|
|
540
|
+
const review = reviewStagedEdit(data.stageId, data.action, data)
|
|
541
|
+
res.writeHead(review.ok ? 200 : 400, { 'Content-Type': 'application/json' })
|
|
542
|
+
res.end(JSON.stringify(review))
|
|
543
|
+
} catch (err) {
|
|
544
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
545
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
546
|
+
}
|
|
547
|
+
return
|
|
548
|
+
}
|
|
549
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
550
|
+
res.end(JSON.stringify({ error: 'GET or POST only' }))
|
|
551
|
+
},
|
|
552
|
+
}), 'dsh-image-gen: staging route')
|
|
553
|
+
|
|
554
|
+
// Batch queue route (#437)
|
|
555
|
+
ctx.effect(() => ctx.webServer.register({
|
|
556
|
+
kind: 'exact',
|
|
557
|
+
path: '/dsh-image-gen/queue',
|
|
558
|
+
handler: async (req, res) => {
|
|
559
|
+
const url = new URL(req.url, 'http://localhost')
|
|
560
|
+
if (req.method === 'GET') {
|
|
561
|
+
const batchId = url.searchParams.get('batchId')
|
|
562
|
+
const batch = batchId ? getBatchQueue(batchId) : null
|
|
563
|
+
const progress = batch ? getBatchProgress(batch) : null
|
|
564
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
565
|
+
res.end(JSON.stringify({ ok: Boolean(batch), batch, progress }))
|
|
566
|
+
return
|
|
567
|
+
}
|
|
568
|
+
if (req.method === 'POST') {
|
|
569
|
+
try {
|
|
570
|
+
const body = await readBoundedRequestBody(req, 1024 * 1024)
|
|
571
|
+
const data = JSON.parse(body.toString('utf8'))
|
|
572
|
+
const batch = enqueueBatch(data)
|
|
573
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
574
|
+
res.end(JSON.stringify({ ok: true, batch }))
|
|
575
|
+
} catch (err) {
|
|
576
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
577
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
578
|
+
}
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
582
|
+
res.end(JSON.stringify({ error: 'GET or POST only' }))
|
|
583
|
+
},
|
|
584
|
+
}), 'dsh-image-gen: batch queue route')
|
|
585
|
+
|
|
514
586
|
registerLiveProgressRoutes(ctx)
|
|
515
587
|
|
|
516
588
|
// 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
|
+
}
|
package/lib/register-tools.js
CHANGED
|
@@ -22,6 +22,8 @@ import { registerDiagramTools } from './tools/diagram.js'
|
|
|
22
22
|
import { registerRecipeTools } from './tools/recipes.js'
|
|
23
23
|
import { registerCapabilityTools } from './tools/capabilities.js'
|
|
24
24
|
import { registerProjectProfileTools } from './tools/project-profiles.js'
|
|
25
|
+
import { registerInpaintStagingTools } from './tools/inpaint-staging.js'
|
|
26
|
+
import { registerBatchQueueTools } from './tools/batch-queue.js'
|
|
25
27
|
import {
|
|
26
28
|
createLiveProgressSession,
|
|
27
29
|
publishLiveProgress,
|
|
@@ -157,6 +159,12 @@ export function registerAllTools(ctx, deps) {
|
|
|
157
159
|
if (Boolean(sets.profiles)) {
|
|
158
160
|
registerProjectProfileTools(targetCtx, deps)
|
|
159
161
|
}
|
|
162
|
+
if (Boolean(sets.staging)) {
|
|
163
|
+
registerInpaintStagingTools(targetCtx, deps)
|
|
164
|
+
}
|
|
165
|
+
if (Boolean(sets.queue)) {
|
|
166
|
+
registerBatchQueueTools(targetCtx, deps)
|
|
167
|
+
}
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
function dispose() {
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import {
|
|
2
|
+
enqueueBatch,
|
|
3
|
+
getBatchQueue,
|
|
4
|
+
getBatchProgress,
|
|
5
|
+
cancelBatch,
|
|
6
|
+
retryFailedBatchItems,
|
|
7
|
+
} from '../batch-queue.js'
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Registers batch queue management tools (#437).
|
|
11
|
+
*/
|
|
12
|
+
export function registerBatchQueueTools(ctx, deps = {}) {
|
|
13
|
+
ctx.tools.register({
|
|
14
|
+
name: 'enqueue_batch_jobs',
|
|
15
|
+
description: 'Enqueues a series of generation or processing jobs with budget pre-flight check and concurrency control.',
|
|
16
|
+
parameters: {
|
|
17
|
+
type: 'object',
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
properties: {
|
|
20
|
+
name: {
|
|
21
|
+
type: 'string',
|
|
22
|
+
description: 'Descriptive title for the batch.',
|
|
23
|
+
},
|
|
24
|
+
items: {
|
|
25
|
+
type: 'array',
|
|
26
|
+
items: {
|
|
27
|
+
type: 'object',
|
|
28
|
+
additionalProperties: true,
|
|
29
|
+
properties: {
|
|
30
|
+
tool: { type: 'string' },
|
|
31
|
+
params: { type: 'object', additionalProperties: true },
|
|
32
|
+
estimatedCost: { type: 'number' },
|
|
33
|
+
},
|
|
34
|
+
},
|
|
35
|
+
description: 'Array of jobs to execute.',
|
|
36
|
+
},
|
|
37
|
+
concurrency: {
|
|
38
|
+
type: 'number',
|
|
39
|
+
description: 'Maximum concurrent active workers (default 2, max 10).',
|
|
40
|
+
},
|
|
41
|
+
maxBudgetUSD: {
|
|
42
|
+
type: 'number',
|
|
43
|
+
description: 'Maximum total allowed budget in USD for this entire batch.',
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
required: ['items'],
|
|
47
|
+
},
|
|
48
|
+
output: {
|
|
49
|
+
render(args, value) {
|
|
50
|
+
return [
|
|
51
|
+
{
|
|
52
|
+
type: 'text',
|
|
53
|
+
text: `Enqueued Batch: [${value?.batchId}] "${value?.name}"\n` +
|
|
54
|
+
`Total Jobs: ${value?.items?.length} | Estimated Cost: $${value?.totalEstimatedCost?.toFixed(3)}\n` +
|
|
55
|
+
`Status: ${value?.status} | Concurrency: ${value?.concurrency}`,
|
|
56
|
+
},
|
|
57
|
+
]
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
async execute(args) {
|
|
61
|
+
const storagePath = deps.queueStoragePath || null
|
|
62
|
+
return enqueueBatch(
|
|
63
|
+
{
|
|
64
|
+
name: args.name,
|
|
65
|
+
items: args.items,
|
|
66
|
+
concurrency: args.concurrency || 2,
|
|
67
|
+
maxBudget: args.maxBudgetUSD || Infinity,
|
|
68
|
+
},
|
|
69
|
+
storagePath
|
|
70
|
+
)
|
|
71
|
+
},
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
ctx.tools.register({
|
|
75
|
+
name: 'manage_batch_queue',
|
|
76
|
+
description: 'Manages batch queues: status inquiry, cancellation, or retrying failed items.',
|
|
77
|
+
parameters: {
|
|
78
|
+
type: 'object',
|
|
79
|
+
additionalProperties: false,
|
|
80
|
+
properties: {
|
|
81
|
+
batchId: {
|
|
82
|
+
type: 'string',
|
|
83
|
+
description: 'Target batch queue identifier.',
|
|
84
|
+
},
|
|
85
|
+
action: {
|
|
86
|
+
type: 'string',
|
|
87
|
+
enum: ['status', 'cancel', 'retry_failed'],
|
|
88
|
+
description: 'Action to perform: check status, cancel, or retry failed items.',
|
|
89
|
+
},
|
|
90
|
+
itemId: {
|
|
91
|
+
type: 'string',
|
|
92
|
+
description: 'Optional single item ID to cancel.',
|
|
93
|
+
},
|
|
94
|
+
},
|
|
95
|
+
required: ['batchId', 'action'],
|
|
96
|
+
},
|
|
97
|
+
output: {
|
|
98
|
+
render(args, value) {
|
|
99
|
+
if (args.action === 'status') {
|
|
100
|
+
const p = value?.progress || {}
|
|
101
|
+
return [
|
|
102
|
+
{
|
|
103
|
+
type: 'text',
|
|
104
|
+
text: `Batch [${args.batchId}] Status: ${value?.status} (${p.percent}%)\n` +
|
|
105
|
+
`Total: ${p.total} | Completed: ${p.completed} | Failed: ${p.failed} | Queued: ${p.queued} | Running: ${p.running} | Interrupted: ${p.interrupted || 0}`,
|
|
106
|
+
},
|
|
107
|
+
]
|
|
108
|
+
}
|
|
109
|
+
if (args.action === 'cancel') {
|
|
110
|
+
return [{ type: 'text', text: `Cancelled batch [${args.batchId}]. Queued jobs discarded.` }]
|
|
111
|
+
}
|
|
112
|
+
if (args.action === 'retry_failed') {
|
|
113
|
+
return [{ type: 'text', text: `Reset ${value?.resetCount || 0} failed items for retry in batch [${args.batchId}].` }]
|
|
114
|
+
}
|
|
115
|
+
return [{ type: 'text', text: JSON.stringify(value) }]
|
|
116
|
+
},
|
|
117
|
+
},
|
|
118
|
+
async execute(args) {
|
|
119
|
+
const storagePath = deps.queueStoragePath || null
|
|
120
|
+
if (args.action === 'status') {
|
|
121
|
+
const batch = getBatchQueue(args.batchId, storagePath)
|
|
122
|
+
if (!batch) return { ok: false, error: `Batch "${args.batchId}" not found` }
|
|
123
|
+
return { ok: true, batchId: batch.batchId, status: batch.status, progress: getBatchProgress(batch), items: batch.items }
|
|
124
|
+
}
|
|
125
|
+
if (args.action === 'cancel') {
|
|
126
|
+
return cancelBatch(args.batchId, args.itemId, storagePath)
|
|
127
|
+
}
|
|
128
|
+
if (args.action === 'retry_failed') {
|
|
129
|
+
return retryFailedBatchItems(args.batchId, args.itemId ? [args.itemId] : null, storagePath)
|
|
130
|
+
}
|
|
131
|
+
throw new Error(`Unknown action: ${args.action}`)
|
|
132
|
+
},
|
|
133
|
+
})
|
|
134
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import {
|
|
2
|
+
createStagingSession,
|
|
3
|
+
reviewStagedEdit,
|
|
4
|
+
getStagingSession,
|
|
5
|
+
} from '../inpaint-staging.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Registers staging & review workflow tools (#436).
|
|
9
|
+
*/
|
|
10
|
+
export function registerInpaintStagingTools(ctx, deps = {}) {
|
|
11
|
+
ctx.tools.register({
|
|
12
|
+
name: 'stage_image_edit',
|
|
13
|
+
description: 'Stages a localized inpaint or image modification in preview state for user review before committing.',
|
|
14
|
+
parameters: {
|
|
15
|
+
type: 'object',
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
image: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
description: 'Original image path or URL to edit.',
|
|
21
|
+
},
|
|
22
|
+
prompt: {
|
|
23
|
+
type: 'string',
|
|
24
|
+
description: 'Edit instruction or prompt for the localized modification.',
|
|
25
|
+
},
|
|
26
|
+
mask: {
|
|
27
|
+
type: 'string',
|
|
28
|
+
description: 'Optional inpainting mask path, base64, or SVG string.',
|
|
29
|
+
},
|
|
30
|
+
bbox: {
|
|
31
|
+
type: 'array',
|
|
32
|
+
items: { type: 'number' },
|
|
33
|
+
description: 'Optional bounding box [x1, y1, x2, y2] (normalized 0-1000).',
|
|
34
|
+
},
|
|
35
|
+
previewImage: {
|
|
36
|
+
type: 'string',
|
|
37
|
+
description: 'Initial preview image path if already produced.',
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
required: ['image', 'prompt'],
|
|
41
|
+
},
|
|
42
|
+
output: {
|
|
43
|
+
render(args, value) {
|
|
44
|
+
return [
|
|
45
|
+
{
|
|
46
|
+
type: 'text',
|
|
47
|
+
text: `Staging Session Created: [${value?.stageId}]\n` +
|
|
48
|
+
`Status: ${value?.status} (preview only, original preserved)\n` +
|
|
49
|
+
`Instruction: "${value?.prompt}"\n` +
|
|
50
|
+
`Preview: ${value?.previewImage || 'Generating in background...'}`,
|
|
51
|
+
},
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
async execute(args) {
|
|
56
|
+
const storagePath = deps.stagingStoragePath || null
|
|
57
|
+
return createStagingSession(
|
|
58
|
+
{
|
|
59
|
+
originalImage: args.image,
|
|
60
|
+
prompt: args.prompt,
|
|
61
|
+
mask: args.mask,
|
|
62
|
+
bbox: args.bbox,
|
|
63
|
+
previewImage: args.previewImage,
|
|
64
|
+
provider: deps.cfg?.provider || 'fal',
|
|
65
|
+
},
|
|
66
|
+
storagePath
|
|
67
|
+
)
|
|
68
|
+
},
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
ctx.tools.register({
|
|
72
|
+
name: 'review_staged_edit',
|
|
73
|
+
description: 'Reviews a staged image edit: accept (commits change), reject (restores original), retry (new prompt), or cancel.',
|
|
74
|
+
parameters: {
|
|
75
|
+
type: 'object',
|
|
76
|
+
additionalProperties: false,
|
|
77
|
+
properties: {
|
|
78
|
+
stageId: {
|
|
79
|
+
type: 'string',
|
|
80
|
+
description: 'ID of the staging session to review.',
|
|
81
|
+
},
|
|
82
|
+
action: {
|
|
83
|
+
type: 'string',
|
|
84
|
+
enum: ['accept', 'reject', 'retry', 'cancel'],
|
|
85
|
+
description: 'Review decision: accept, reject, retry, or cancel.',
|
|
86
|
+
},
|
|
87
|
+
prompt: {
|
|
88
|
+
type: 'string',
|
|
89
|
+
description: 'New instruction if retrying with updated prompt.',
|
|
90
|
+
},
|
|
91
|
+
reason: {
|
|
92
|
+
type: 'string',
|
|
93
|
+
description: 'Optional reason when rejecting or cancelling.',
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
required: ['stageId', 'action'],
|
|
97
|
+
},
|
|
98
|
+
output: {
|
|
99
|
+
render(args, value) {
|
|
100
|
+
if (!value?.ok) {
|
|
101
|
+
return [{ type: 'text', text: `Review failed: ${value?.error || 'Unknown error'}` }]
|
|
102
|
+
}
|
|
103
|
+
return [
|
|
104
|
+
{
|
|
105
|
+
type: 'text',
|
|
106
|
+
text: `Review Result: ${value.message}\nStatus: ${value.status}\nStage ID: ${args.stageId}`,
|
|
107
|
+
},
|
|
108
|
+
]
|
|
109
|
+
},
|
|
110
|
+
},
|
|
111
|
+
async execute(args) {
|
|
112
|
+
const storagePath = deps.stagingStoragePath || null
|
|
113
|
+
return reviewStagedEdit(args.stageId, args.action, { prompt: args.prompt, reason: args.reason }, storagePath)
|
|
114
|
+
},
|
|
115
|
+
})
|
|
116
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@goodandready/dsh-image-gen",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.41",
|
|
4
4
|
"description": "Image generation for DeepSeek Harness: a generate_image tool with pluggable providers — 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",
|