@goodandready/dsh-image-gen 0.11.39 → 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/capabilities.js +433 -0
- package/lib/client.js +5 -0
- package/lib/config-schema.js +5 -0
- package/lib/index.js +117 -0
- package/lib/inpaint-staging.js +236 -0
- package/lib/project-profiles.js +234 -0
- package/lib/register-tools.js +16 -0
- package/lib/tools/batch-queue.js +134 -0
- package/lib/tools/capabilities.js +139 -0
- package/lib/tools/inpaint-staging.js +116 -0
- package/lib/tools/project-profiles.js +139 -0
- package/package.json +1 -1
package/lib/client.js
CHANGED
|
@@ -360,6 +360,7 @@ window.__ModuleLoader__.load({
|
|
|
360
360
|
{ field: 'toolsetProcessing', spec: booleanField('toolsetProcessing'), kind: 'select', options: ['true', 'false'], labelKey: 'f.toolsetProcessing', hintKey: 'f.toolsetProcessingHint', tab: 'general' },
|
|
361
361
|
{ field: 'toolsetFrontend', spec: booleanField('toolsetFrontend'), kind: 'select', options: ['true', 'false'], labelKey: 'f.toolsetFrontend', hintKey: 'f.toolsetFrontendHint', tab: 'general' },
|
|
362
362
|
{ field: 'toolsetCreative', spec: booleanField('toolsetCreative'), kind: 'select', options: ['true', 'false'], labelKey: 'f.toolsetCreative', hintKey: 'f.toolsetCreativeHint', tab: 'general' },
|
|
363
|
+
{ field: 'activeProjectProfile', spec: selectField('activeProjectProfile', ['', 'cyberpunk_neon', 'minimal_nordic', 'corporate_editorial']), kind: 'select', options: ['', 'cyberpunk_neon', 'minimal_nordic', 'corporate_editorial'], labelKey: 'f.activeProjectProfile', hintKey: 'f.activeProjectProfileHint', tab: 'general' },
|
|
363
364
|
|
|
364
365
|
// Provider: FAL
|
|
365
366
|
{ field: 'model', spec: textField('model'), kind: 'text', labelKey: 'f.model', hintKey: 'f.modelHint', placeholderKey: 'p.model', when: ['fal'], tab: 'provider' },
|
|
@@ -2878,6 +2879,8 @@ window.__ModuleLoader__.load({
|
|
|
2878
2879
|
'vault.title': 'Asset Browser & Vault',
|
|
2879
2880
|
'f.comfyTemplate': 'ComfyUI Workflow Template',
|
|
2880
2881
|
'f.comfyTemplateHint': 'Pre-vetted workflow template (e.g. SDXL txt2img, img2img, InstantID face transfer, RealESRGAN upscaler).',
|
|
2882
|
+
'f.activeProjectProfile': 'Active Project Profile',
|
|
2883
|
+
'f.activeProjectProfileHint': 'Select a saved visual profile (palette, style, format) to consistently apply across generations (#435).',
|
|
2881
2884
|
}
|
|
2882
2885
|
const zh = {
|
|
2883
2886
|
'card.actionRemoveBg': '移除背景',
|
|
@@ -3187,6 +3190,8 @@ window.__ModuleLoader__.load({
|
|
|
3187
3190
|
'vault.title': '图像素材库与浏览器',
|
|
3188
3191
|
'f.comfyTemplate': 'ComfyUI 工作流模板',
|
|
3189
3192
|
'f.comfyTemplateHint': '经过验证的预设工作流模板(例如 SDXL 文生图、图生图、InstantID 人脸迁移、RealESRGAN 放大器)。',
|
|
3193
|
+
'f.activeProjectProfile': '活动项目视觉配置',
|
|
3194
|
+
'f.activeProjectProfileHint': '选择已保存的项目视觉配置(调色板、风格、格式),跨多次生成统一应用 (#435)。',
|
|
3190
3195
|
}
|
|
3191
3196
|
// Dual compatibility with DSH 0.1.5-rc.3 (settingsScope) and 0.1.6+ (configForms) (#291, #295)
|
|
3192
3197
|
const inject = ['slots', 'locale', 'sessions']
|
package/lib/config-schema.js
CHANGED
|
@@ -220,6 +220,11 @@ export const Config = z.object({
|
|
|
220
220
|
.description('Vetted ComfyUI workflow template identifier (e.g. "txt2img_sdxl", "img2img_sdxl", "face_instantid", "upscale_esrgan").')
|
|
221
221
|
.default('')
|
|
222
222
|
.volatile(),
|
|
223
|
+
activeProjectProfile: z
|
|
224
|
+
.string()
|
|
225
|
+
.description('Active project visual profile for continuous multi-image styling (#435). One of: "", "cyberpunk_neon", "minimal_nordic", "corporate_editorial".')
|
|
226
|
+
.default('')
|
|
227
|
+
.volatile(),
|
|
223
228
|
comfyWorkflowJson: z
|
|
224
229
|
.string()
|
|
225
230
|
.description('Custom ComfyUI API workflow JSON template with {{prompt}}, {{seed}}, etc. placeholders.')
|
package/lib/index.js
CHANGED
|
@@ -50,6 +50,10 @@ import { clearAllAnchors } from './anchor-helpers.js'
|
|
|
50
50
|
import { clearSpendReservations } from './cost-meter.js'
|
|
51
51
|
import { getAllRecipes, getRecipe } from './recipes.js'
|
|
52
52
|
import { getComfyTemplates, getComfyTemplate, createComfyDependencyValidator } from './comfy-catalog.js'
|
|
53
|
+
import { getCapabilityRegistry, filterModelsByCapabilities, recommendModelForTask } from './capabilities.js'
|
|
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'
|
|
53
57
|
import { resetLoopGuard, getLoopGuardState } from './loop-guard.js'
|
|
54
58
|
import { Config, plainConfig, volatileConfig, publicConfig, ensureConfig } from './config-schema.js'
|
|
55
59
|
import { resolveSource } from './resolve-source.js'
|
|
@@ -465,7 +469,120 @@ export function apply(ctx, rawConfig) {
|
|
|
465
469
|
},
|
|
466
470
|
}), 'dsh-image-gen: comfy templates route')
|
|
467
471
|
|
|
472
|
+
// Capabilities route (#434)
|
|
473
|
+
ctx.effect(() => ctx.webServer.register({
|
|
474
|
+
kind: 'exact',
|
|
475
|
+
path: '/dsh-image-gen/capabilities',
|
|
476
|
+
handler: async (req, res) => {
|
|
477
|
+
if (req.method !== 'GET') {
|
|
478
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
479
|
+
res.end(JSON.stringify({ error: 'GET only' }))
|
|
480
|
+
return
|
|
481
|
+
}
|
|
482
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
483
|
+
res.end(JSON.stringify({ ok: true, registry: getCapabilityRegistry() }))
|
|
484
|
+
},
|
|
485
|
+
}), 'dsh-image-gen: capabilities route')
|
|
486
|
+
|
|
487
|
+
// Project profiles route (#435)
|
|
488
|
+
ctx.effect(() => ctx.webServer.register({
|
|
489
|
+
kind: 'exact',
|
|
490
|
+
path: '/dsh-image-gen/profiles',
|
|
491
|
+
handler: async (req, res) => {
|
|
492
|
+
if (req.method === 'GET') {
|
|
493
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
494
|
+
res.end(JSON.stringify({ ok: true, profiles: getAllProjectProfiles() }))
|
|
495
|
+
return
|
|
496
|
+
}
|
|
497
|
+
if (req.method === 'POST') {
|
|
498
|
+
try {
|
|
499
|
+
const body = await readBoundedRequestBody(req, 1024 * 1024)
|
|
500
|
+
const data = JSON.parse(body.toString('utf8'))
|
|
501
|
+
const saved = saveCustomProfile(data)
|
|
502
|
+
res.writeHead(200, { 'Content-Type': 'application/json' })
|
|
503
|
+
res.end(JSON.stringify({ ok: true, profile: saved }))
|
|
504
|
+
} catch (err) {
|
|
505
|
+
res.writeHead(400, { 'Content-Type': 'application/json' })
|
|
506
|
+
res.end(JSON.stringify({ ok: false, error: err.message }))
|
|
507
|
+
}
|
|
508
|
+
return
|
|
509
|
+
}
|
|
510
|
+
res.writeHead(405, { 'Content-Type': 'application/json' })
|
|
511
|
+
res.end(JSON.stringify({ error: 'GET or POST only' }))
|
|
512
|
+
},
|
|
513
|
+
}), 'dsh-image-gen: project profiles route')
|
|
514
|
+
|
|
468
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
|
+
|
|
469
586
|
registerLiveProgressRoutes(ctx)
|
|
470
587
|
|
|
471
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
|
+
}
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import os from 'node:os'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Visual Project & Series Profiles (#435)
|
|
7
|
+
* Manages persistent project visual profiles with explicit scope,
|
|
8
|
+
* parameter cascade (defaults -> profile -> request), and immutable history tagging.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const BUILTIN_PROJECT_PROFILES = [
|
|
12
|
+
{
|
|
13
|
+
id: 'cyberpunk_neon',
|
|
14
|
+
name: 'Cyberpunk Neon',
|
|
15
|
+
version: '1.0.0',
|
|
16
|
+
description: 'High contrast neon lighting and dark futuristic styling.',
|
|
17
|
+
palette: ['#00f0ff', '#ff003c', '#ffe600', '#0a0a12'],
|
|
18
|
+
stylePrompt: 'cyberpunk aesthetic, volumetric atmospheric lighting, raytracing, sharp neon accents, highly detailed 8k',
|
|
19
|
+
negativePrompt: 'washed out, vintage, pastel, desaturated, noisy',
|
|
20
|
+
format: { size: '1024x1024', aspect: '1:1', format: 'webp' },
|
|
21
|
+
rules: {
|
|
22
|
+
background: 'dark cityscapes or moody interior',
|
|
23
|
+
typography: 'clean monospace or futuristic geometric',
|
|
24
|
+
},
|
|
25
|
+
allowedModels: ['fal-ai/flux/dev', 'sdxl_base'],
|
|
26
|
+
builtin: true,
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
id: 'minimal_nordic',
|
|
30
|
+
name: 'Scandinavian Minimalist',
|
|
31
|
+
version: '1.0.0',
|
|
32
|
+
description: 'Warm daylight, matte textures, and understated earthy tones.',
|
|
33
|
+
palette: ['#f4f1de', '#e07a5f', '#3d405b', '#81b29a'],
|
|
34
|
+
stylePrompt: 'scandinavian minimalism, clean composition, soft natural daylight, matte surfaces, earthy organic elegance',
|
|
35
|
+
negativePrompt: 'cluttered, neon, heavy shadows, oversaturated, glossy plastic',
|
|
36
|
+
format: { size: '1024x1024', aspect: '1:1', format: 'png' },
|
|
37
|
+
rules: {
|
|
38
|
+
background: 'plain textured warm neutral',
|
|
39
|
+
typography: 'light grotesque sans',
|
|
40
|
+
},
|
|
41
|
+
allowedModels: ['fal-ai/flux/dev', 'imagen-3.0-generate-002'],
|
|
42
|
+
builtin: true,
|
|
43
|
+
},
|
|
44
|
+
{
|
|
45
|
+
id: 'corporate_editorial',
|
|
46
|
+
name: 'Corporate Editorial',
|
|
47
|
+
version: '1.0.0',
|
|
48
|
+
description: 'High-end business magazine photography with crisp studio lighting.',
|
|
49
|
+
palette: ['#0f172a', '#3b82f6', '#64748b', '#f8fafc'],
|
|
50
|
+
stylePrompt: 'high-end corporate magazine editorial, crisp balanced studio lighting, premium commercial photography',
|
|
51
|
+
negativePrompt: 'anime, cartoon, oversaturated, exaggerated distortions, amateur',
|
|
52
|
+
format: { size: '1024x1024', aspect: '16:9', format: 'webp' },
|
|
53
|
+
rules: {
|
|
54
|
+
background: 'architectural soft blur or pristine studio',
|
|
55
|
+
typography: 'editorial serif / humanistic sans',
|
|
56
|
+
},
|
|
57
|
+
allowedModels: ['fal-ai/flux/dev', 'black-forest-labs/flux-1.1-pro'],
|
|
58
|
+
builtin: true,
|
|
59
|
+
},
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
export function getProfilesStoragePath(customPath) {
|
|
63
|
+
if (customPath) return customPath
|
|
64
|
+
if (process.env.DSH_PROJECT_PROFILES_PATH) return process.env.DSH_PROJECT_PROFILES_PATH
|
|
65
|
+
const home = os.homedir() || '/tmp'
|
|
66
|
+
return path.join(home, '.dsh', 'dsh-image-gen', 'project-profiles.json')
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function loadCustomProfiles(storagePath) {
|
|
70
|
+
const filePath = getProfilesStoragePath(storagePath)
|
|
71
|
+
try {
|
|
72
|
+
if (!fs.existsSync(filePath)) return []
|
|
73
|
+
const content = fs.readFileSync(filePath, 'utf8')
|
|
74
|
+
const parsed = JSON.parse(content)
|
|
75
|
+
return Array.isArray(parsed) ? parsed : []
|
|
76
|
+
} catch (err) {
|
|
77
|
+
return []
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function saveCustomProfile(profile, storagePath) {
|
|
82
|
+
if (!profile || typeof profile !== 'object') {
|
|
83
|
+
throw new Error('Invalid project profile: object required')
|
|
84
|
+
}
|
|
85
|
+
if (!profile.id || typeof profile.id !== 'string') {
|
|
86
|
+
throw new Error('Project profile requires a string "id"')
|
|
87
|
+
}
|
|
88
|
+
if (!profile.name || typeof profile.name !== 'string') {
|
|
89
|
+
throw new Error('Project profile requires a string "name"')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const filePath = getProfilesStoragePath(storagePath)
|
|
93
|
+
const dir = path.dirname(filePath)
|
|
94
|
+
if (!fs.existsSync(dir)) {
|
|
95
|
+
fs.mkdirSync(dir, { recursive: true })
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const existing = loadCustomProfiles(storagePath)
|
|
99
|
+
const index = existing.findIndex((p) => p.id === profile.id)
|
|
100
|
+
const prepared = {
|
|
101
|
+
...profile,
|
|
102
|
+
version: profile.version || '1.0.0',
|
|
103
|
+
builtin: false,
|
|
104
|
+
updatedAt: new Date().toISOString(),
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (index >= 0) {
|
|
108
|
+
existing[index] = prepared
|
|
109
|
+
} else {
|
|
110
|
+
existing.push(prepared)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), 'utf8')
|
|
114
|
+
return prepared
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function deleteCustomProfile(id, storagePath) {
|
|
118
|
+
const filePath = getProfilesStoragePath(storagePath)
|
|
119
|
+
const existing = loadCustomProfiles(storagePath)
|
|
120
|
+
const filtered = existing.filter((p) => p.id !== id)
|
|
121
|
+
if (filtered.length === existing.length) {
|
|
122
|
+
return false
|
|
123
|
+
}
|
|
124
|
+
fs.writeFileSync(filePath, JSON.stringify(filtered, null, 2), 'utf8')
|
|
125
|
+
return true
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export function getAllProjectProfiles(storagePath) {
|
|
129
|
+
const custom = loadCustomProfiles(storagePath)
|
|
130
|
+
const customIds = new Set(custom.map((c) => c.id))
|
|
131
|
+
const builtins = BUILTIN_PROJECT_PROFILES.filter((b) => !customIds.has(b.id))
|
|
132
|
+
return [...builtins, ...custom]
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function getProjectProfileById(id, storagePath) {
|
|
136
|
+
if (!id) return null
|
|
137
|
+
const all = getAllProjectProfiles(storagePath)
|
|
138
|
+
return all.find((p) => p.id === id) || null
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Parameter resolution cascade (#435):
|
|
143
|
+
* Priority: Request parameters > Project Profile > System Defaults.
|
|
144
|
+
* Returns effective params, provenance mapping, and immutable profile tag for history.
|
|
145
|
+
*/
|
|
146
|
+
export function resolveEffectiveParams(requestParams = {}, profileId = null, storagePath = null) {
|
|
147
|
+
const profile = profileId ? getProjectProfileById(profileId, storagePath) : null
|
|
148
|
+
const provenance = {}
|
|
149
|
+
const effective = {}
|
|
150
|
+
|
|
151
|
+
// 1. Defaults
|
|
152
|
+
const defaults = {
|
|
153
|
+
size: '1024x1024',
|
|
154
|
+
format: 'webp',
|
|
155
|
+
negativePrompt: '',
|
|
156
|
+
steps: 25,
|
|
157
|
+
cfg: 7,
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
for (const [key, val] of Object.entries(defaults)) {
|
|
161
|
+
effective[key] = val
|
|
162
|
+
provenance[key] = 'default'
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// 2. Profile layer
|
|
166
|
+
if (profile) {
|
|
167
|
+
if (profile.format?.size) {
|
|
168
|
+
effective.size = profile.format.size
|
|
169
|
+
provenance.size = 'profile'
|
|
170
|
+
}
|
|
171
|
+
if (profile.format?.format) {
|
|
172
|
+
effective.format = profile.format.format
|
|
173
|
+
provenance.format = 'profile'
|
|
174
|
+
}
|
|
175
|
+
if (profile.negativePrompt) {
|
|
176
|
+
effective.negativePrompt = profile.negativePrompt
|
|
177
|
+
provenance.negativePrompt = 'profile'
|
|
178
|
+
}
|
|
179
|
+
if (profile.stylePrompt) {
|
|
180
|
+
effective.stylePrompt = profile.stylePrompt
|
|
181
|
+
provenance.stylePrompt = 'profile'
|
|
182
|
+
}
|
|
183
|
+
if (Array.isArray(profile.palette) && profile.palette.length > 0) {
|
|
184
|
+
effective.palette = [...profile.palette]
|
|
185
|
+
provenance.palette = 'profile'
|
|
186
|
+
}
|
|
187
|
+
if (profile.rules) {
|
|
188
|
+
effective.rules = { ...profile.rules }
|
|
189
|
+
provenance.rules = 'profile'
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// 3. Request layer (highest precedence)
|
|
194
|
+
for (const [key, val] of Object.entries(requestParams)) {
|
|
195
|
+
if (val !== undefined && val !== null) {
|
|
196
|
+
if (key === 'prompt') {
|
|
197
|
+
// Synthesize prompt with profile style if available
|
|
198
|
+
if (effective.stylePrompt) {
|
|
199
|
+
effective.prompt = `${val}, ${effective.stylePrompt}`
|
|
200
|
+
provenance.prompt = 'request+profile'
|
|
201
|
+
} else {
|
|
202
|
+
effective.prompt = val
|
|
203
|
+
provenance.prompt = 'request'
|
|
204
|
+
}
|
|
205
|
+
} else if (key === 'negativePrompt') {
|
|
206
|
+
if (effective.negativePrompt && val) {
|
|
207
|
+
effective.negativePrompt = `${effective.negativePrompt}, ${val}`
|
|
208
|
+
provenance.negativePrompt = 'request+profile'
|
|
209
|
+
} else {
|
|
210
|
+
effective.negativePrompt = val || effective.negativePrompt
|
|
211
|
+
provenance.negativePrompt = 'request'
|
|
212
|
+
}
|
|
213
|
+
} else {
|
|
214
|
+
effective[key] = val
|
|
215
|
+
provenance[key] = 'request'
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Immutable applied profile metadata snapshot for history
|
|
221
|
+
const appliedProfile = profile
|
|
222
|
+
? {
|
|
223
|
+
id: profile.id,
|
|
224
|
+
name: profile.name,
|
|
225
|
+
version: profile.version || '1.0.0',
|
|
226
|
+
}
|
|
227
|
+
: null
|
|
228
|
+
|
|
229
|
+
return {
|
|
230
|
+
effectiveParams: effective,
|
|
231
|
+
provenance,
|
|
232
|
+
appliedProfile,
|
|
233
|
+
}
|
|
234
|
+
}
|
package/lib/register-tools.js
CHANGED
|
@@ -20,6 +20,10 @@ 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
22
|
import { registerRecipeTools } from './tools/recipes.js'
|
|
23
|
+
import { registerCapabilityTools } from './tools/capabilities.js'
|
|
24
|
+
import { registerProjectProfileTools } from './tools/project-profiles.js'
|
|
25
|
+
import { registerInpaintStagingTools } from './tools/inpaint-staging.js'
|
|
26
|
+
import { registerBatchQueueTools } from './tools/batch-queue.js'
|
|
23
27
|
import {
|
|
24
28
|
createLiveProgressSession,
|
|
25
29
|
publishLiveProgress,
|
|
@@ -149,6 +153,18 @@ export function registerAllTools(ctx, deps) {
|
|
|
149
153
|
if (Boolean(sets.recipes)) {
|
|
150
154
|
registerRecipeTools(targetCtx, { ...deps, toolDispatcher })
|
|
151
155
|
}
|
|
156
|
+
if (Boolean(sets.capabilities)) {
|
|
157
|
+
registerCapabilityTools(targetCtx, deps)
|
|
158
|
+
}
|
|
159
|
+
if (Boolean(sets.profiles)) {
|
|
160
|
+
registerProjectProfileTools(targetCtx, deps)
|
|
161
|
+
}
|
|
162
|
+
if (Boolean(sets.staging)) {
|
|
163
|
+
registerInpaintStagingTools(targetCtx, deps)
|
|
164
|
+
}
|
|
165
|
+
if (Boolean(sets.queue)) {
|
|
166
|
+
registerBatchQueueTools(targetCtx, deps)
|
|
167
|
+
}
|
|
152
168
|
}
|
|
153
169
|
|
|
154
170
|
function dispose() {
|