@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/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']
@@ -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
+ }
@@ -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() {