@ljwei-stak/dsh-compositor 0.0.0-stage → 0.1.0

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,446 @@
1
+ /**
2
+ * Forward-compatible validator for .comp manifests and packages.
3
+ *
4
+ * The rules implemented here are the invariants the Compositor docs state
5
+ * (IDs name their files, exact blend-mode spellings, group tree shape,
6
+ * numeric ranges, version gates, package limits) — the stable parts of a
7
+ * format whose history is purely additive. Vocabulary and limits come from
8
+ * loadRepoDocs(), so a newer checkout loosens them automatically, and a
9
+ * manifest whose version is newer than the checkout's docs only ever gets
10
+ * warnings, never a hard error: new upstream features must not be blocked by
11
+ * an older plugin.
12
+ */
13
+ import { readFile, readdir, stat } from 'node:fs/promises'
14
+ import { join } from 'node:path'
15
+ import { isExrBuffer, readPngHeader } from './comp-png.mjs'
16
+
17
+ const UUID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/
18
+ const SAMPLINGS = new Set(['High quality', 'Smooth', 'Nearest'])
19
+ const IMAGE_EXTENSIONS = /\.(png|exr)$/i
20
+
21
+ const isNum = value => typeof value === 'number' && Number.isFinite(value)
22
+ const isInt = value => typeof value === 'number' && Number.isInteger(value)
23
+ const isBool = value => typeof value === 'boolean'
24
+ const isStr = value => typeof value === 'string' && value.length > 0
25
+
26
+ function colorRunsProblems(runs, contentLength, label) {
27
+ const problems = []
28
+ if (!Array.isArray(runs)) return [`${label} must be an array`]
29
+ let cursor = -1
30
+ for (const run of runs) {
31
+ if (!run || typeof run !== 'object') { problems.push(`${label} entries must be objects`); continue }
32
+ const { location, length } = run
33
+ if (!isInt(location) || !isInt(length) || length <= 0) { problems.push(`${label} entries need integer location and positive length`); continue }
34
+ if (location < 0 || location + length > contentLength) problems.push(`${label} run [${location}, ${location + length}) exceeds the content length ${contentLength} (UTF-16 units)`)
35
+ if (location < cursor) problems.push(`${label} runs must be sorted and not overlap`)
36
+ cursor = Math.max(cursor, location + length)
37
+ }
38
+ return problems
39
+ }
40
+
41
+ function effectRangeProblems(effects) {
42
+ const problems = []
43
+ const size = (record, key, field = 'size') => {
44
+ if (record && isNum(record[field]) && (record[field] < 0 || record[field] > 500)) problems.push(`effects.${key}.${field} must be 0–500 layer pixels`)
45
+ if (record && isNum(record.opacity) && (record.opacity < 0 || record.opacity > 1)) problems.push(`effects.${key}.opacity must be 0–1`)
46
+ }
47
+ if (!effects || typeof effects !== 'object') return problems
48
+ size(effects.stroke, 'stroke')
49
+ size(effects.shadow, 'shadow')
50
+ size(effects.innerShadow, 'innerShadow')
51
+ size(effects.outerGlow, 'outerGlow')
52
+ size(effects.innerGlow, 'innerGlow')
53
+ if (effects.colorOverlay && isNum(effects.colorOverlay.opacity) && (effects.colorOverlay.opacity < 0 || effects.colorOverlay.opacity > 1)) problems.push('effects.colorOverlay.opacity must be 0–1')
54
+ return problems
55
+ }
56
+
57
+ function adjustmentProblems(adjustment, kinds) {
58
+ const problems = []
59
+ if (!adjustment || typeof adjustment !== 'object') return ['adjustment must be an object']
60
+ if (!isStr(adjustment.kind)) problems.push('adjustment.kind is required')
61
+ else if (!kinds.includes(adjustment.kind)) problems.push(`adjustment.kind "${adjustment.kind}" is not one of: ${kinds.join(', ')}`)
62
+ const range = (key, min, max) => {
63
+ if (adjustment[key] !== undefined && (!isNum(adjustment[key]) || adjustment[key] < min || adjustment[key] > max)) problems.push(`adjustment.${key} must be a finite number ${min}–${max}`)
64
+ }
65
+ range('hue', -360, 360)
66
+ range('saturation', -100, 100)
67
+ range('lightness', -100, 100)
68
+ if (adjustment.levels) {
69
+ const { ranges } = adjustment.levels
70
+ if (!Array.isArray(ranges) || ranges.length !== 4) problems.push('adjustment.levels.ranges must have 4 channels (RGB, red, green, blue)')
71
+ else for (const [index, r] of ranges.entries()) {
72
+ for (const key of ['black', 'white', 'outputBlack', 'outputWhite']) if (!isNum(r[key])) problems.push(`levels.ranges[${index}].${key} must be a number`)
73
+ if (isNum(r.black) && isNum(r.white) && r.black > r.white) problems.push(`levels.ranges[${index}].black must be ≤ white`)
74
+ if (!isNum(r.gamma) || r.gamma <= 0) problems.push(`levels.ranges[${index}].gamma must be > 0`)
75
+ }
76
+ }
77
+ if (adjustment.curves) {
78
+ const { channels } = adjustment.curves
79
+ if (!Array.isArray(channels) || channels.length !== 4) problems.push('adjustment.curves.channels must have 4 channels (RGB, red, green, blue)')
80
+ else for (const [index, points] of channels.entries()) {
81
+ if (!Array.isArray(points) || points.length < 2) { problems.push(`curves.channels[${index}] needs at least 2 points`); continue }
82
+ let lastX = -1
83
+ for (const point of points) {
84
+ if (!isNum(point.x) || !isNum(point.y) || point.x < 0 || point.x > 255 || point.y < 0 || point.y > 255) { problems.push(`curves.channels[${index}] points must be numbers in 0–255`); break }
85
+ if (point.x <= lastX) { problems.push(`curves.channels[${index}] x values must be strictly increasing from 0 to 255`); break }
86
+ lastX = point.x
87
+ }
88
+ }
89
+ }
90
+ range('blurRadius', 0.1, 250)
91
+ range('motionAngle', -90, 90)
92
+ range('motionDistance', 1, 2000)
93
+ range('noiseAmount', 0.1, 400)
94
+ if (adjustment.colorBalanceSettings) {
95
+ const settings = adjustment.colorBalanceSettings
96
+ for (const key of ['shadowCyanRed', 'shadowMagentaGreen', 'shadowYellowBlue', 'midCyanRed', 'midMagentaGreen', 'midYellowBlue', 'highlightCyanRed', 'highlightMagentaGreen', 'highlightYellowBlue']) {
97
+ if (settings[key] !== undefined && (!isNum(settings[key]) || Math.abs(settings[key]) > 100)) problems.push(`colorBalanceSettings.${key} must be −100–100`)
98
+ }
99
+ }
100
+ return problems
101
+ }
102
+
103
+ /**
104
+ * Validate a .comp package. `docs` is the loadRepoDocs() result.
105
+ * Returns { ok, problems, warnings, info }.
106
+ */
107
+ export async function validateComp(target, docs, { readImages = true } = {}) {
108
+ const problems = []
109
+ const warnings = []
110
+ const { compDir, manifestPath, imagesDir } = target
111
+ const vocab = docs.vocab
112
+
113
+ const manifestStat = await stat(manifestPath).catch(() => null)
114
+ if (!manifestStat) return { ok: false, problems: [`manifest.json missing at ${manifestPath}`], warnings, info: { compDir } }
115
+ if (manifestStat.size > vocab.limits.manifestBytes) problems.push(`manifest.json is ${manifestStat.size} bytes, over the ${vocab.limits.manifestBytes} byte limit`)
116
+
117
+ const manifestText = await readFile(manifestPath, 'utf8').catch(error => null)
118
+ if (manifestText === null) return { ok: false, problems: [`manifest.json cannot be read: ${errorNote(manifestPath)}`], warnings, info: { compDir } }
119
+ let manifest
120
+ try {
121
+ manifest = JSON.parse(manifestText)
122
+ } catch (error) {
123
+ return { ok: false, problems: [`manifest.json is not valid JSON: ${error.message}`], warnings, info: { compDir } }
124
+ }
125
+ if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return { ok: false, problems: ['manifest.json must contain a JSON object'], warnings, info: { compDir } }
126
+
127
+ const version = manifest.version
128
+ const knownMax = vocab.maxVersion
129
+ const relaxed = isInt(version) && version > knownMax
130
+ if (!isInt(version)) problems.push('manifest.version must be an integer')
131
+ else if (relaxed) warnings.push(`manifest version ${version} is newer than the checkout docs (1–${knownMax}); version-gated checks are warnings only — features unknown to this plugin may be present`)
132
+ else if (version < 1) problems.push('manifest.version must be ≥ 1')
133
+ // "gated": hard error while the version is known, warning once upstream runs ahead
134
+ const gate = (min, message) => {
135
+ if (!isInt(version)) return
136
+ if (version < min) (relaxed ? warnings : problems).push(message)
137
+ }
138
+
139
+ if (manifest.format !== 'com.compositor.project') problems.push(`manifest.format must be "com.compositor.project", got ${JSON.stringify(manifest.format)}`)
140
+ if (manifest.colorSpace !== undefined && manifest.colorSpace !== 'sRGB') warnings.push(`colorSpace is ${JSON.stringify(manifest.colorSpace)}; the documented working space is sRGB`)
141
+ if (!isStr(manifest.documentID)) warnings.push('documentID is missing (keep it when editing an existing project)')
142
+ else if (!UUID_RE.test(manifest.documentID)) warnings.push('documentID is not a UUID')
143
+ for (const key of ['width', 'height']) {
144
+ const value = manifest[key]
145
+ if (!isInt(value) || value < 1) problems.push(`manifest.${key} must be a positive integer`)
146
+ else if (value > vocab.limits.canvasSide) problems.push(`manifest.${key} is ${value}, over the ${vocab.limits.canvasSide} pixel per-side limit`)
147
+ }
148
+ if (manifest.resolution !== undefined && (!isInt(manifest.resolution) || manifest.resolution < 1 || manifest.resolution > 9600)) problems.push('manifest.resolution must be an integer 1–9600 pixels/inch')
149
+
150
+ const layers = manifest.layers
151
+ if (!Array.isArray(layers)) return { ok: false, problems: [...problems, 'manifest.layers must be an array'], warnings, info: { compDir, version } }
152
+ if (layers.length > vocab.limits.layers) problems.push(`${layers.length} layers, over the ${vocab.limits.layers} limit`)
153
+ if (!layers.length) warnings.push('the layer list is empty')
154
+
155
+ const byId = new Map()
156
+ for (const [index, layer] of layers.entries()) {
157
+ if (!layer || typeof layer !== 'object') { problems.push(`layers[${index}] must be an object`); continue }
158
+ if (!isStr(layer.id)) { problems.push(`layers[${index}] is missing a string id`); continue }
159
+ if (byId.has(layer.id)) problems.push(`duplicate layer id ${layer.id}`)
160
+ byId.set(layer.id, layer)
161
+ if (!UUID_RE.test(layer.id)) warnings.push(`layer id ${layer.id} is not a UUID`)
162
+ else if (layer.id !== layer.id.toUpperCase()) warnings.push(`layer id ${layer.id} has lowercase hex; the docs write IDs in uppercase`)
163
+ if (layer.name === undefined) warnings.push(`layer ${layer.id} has no name`)
164
+ }
165
+
166
+ // active layer reference
167
+ if (manifest.activeLayerID !== undefined && manifest.activeLayerID !== null) {
168
+ if (!byId.has(manifest.activeLayerID)) warnings.push(`activeLayerID ${manifest.activeLayerID} does not match any layer`)
169
+ }
170
+
171
+ // ---- group tree: parents exist, are groups, no cycles, depth, contiguity
172
+ const parentOf = id => byId.get(id)?.parentID ?? null
173
+ for (const layer of byId.values()) {
174
+ if (layer.parentID === undefined) continue
175
+ if (!isStr(layer.parentID)) { problems.push(`layer ${layer.id}: parentID must be a string`); continue }
176
+ const parent = byId.get(layer.parentID)
177
+ if (!parent) { problems.push(`layer ${layer.id}: parentID ${layer.parentID} does not exist`); continue }
178
+ if (parent.isGroup !== true) { problems.push(`layer ${layer.id}: parent ${layer.parentID} is not a group`); continue }
179
+ // cycle + depth by walking ancestors
180
+ const seen = new Set([layer.id])
181
+ let depth = 0
182
+ let cursor = layer.parentID
183
+ let cyclic = false
184
+ while (cursor) {
185
+ if (seen.has(cursor)) { cyclic = true; break }
186
+ seen.add(cursor)
187
+ depth++
188
+ if (depth > vocab.limits.nestDepth) break
189
+ cursor = parentOf(cursor)
190
+ }
191
+ if (cyclic) problems.push(`layer ${layer.id}: parentID chain contains a cycle`)
192
+ else if (depth > vocab.limits.nestDepth) problems.push(`layer ${layer.id}: nested ${depth} group levels deep, over the ${vocab.limits.nestDepth} limit`)
193
+ }
194
+ // contiguity: renderers traverse each group as a contiguous subtree, so the
195
+ // array must read parent followed by its children (stack discipline)
196
+ {
197
+ const stack = []
198
+ for (const layer of layers) {
199
+ if (!layer || typeof layer !== 'object' || !isStr(layer.id)) continue
200
+ const parentID = layer.parentID ?? null
201
+ if (parentID === null) {
202
+ stack.length = 0
203
+ } else if (stack.includes(parentID)) {
204
+ while (stack.length && stack[stack.length - 1] !== parentID) stack.pop()
205
+ } else {
206
+ problems.push(`layer ${layer.id}: group ${parentID} is not contiguous — each group's children must follow it directly in the array`)
207
+ stack.length = 0
208
+ }
209
+ if (layer.isGroup === true) stack.push(layer.id)
210
+ }
211
+ }
212
+
213
+ // ---- per-layer appearance and assets
214
+ let imageCount = 0, groupCount = 0, adjustmentCount = 0, textCount = 0, blankCount = 0
215
+ let layerPixels = 0, maskPixels = 0
216
+ const referenced = new Set()
217
+ const imagesDirStat = await stat(imagesDir).catch(() => null)
218
+ const readAsset = async name => {
219
+ const path = join(imagesDir, name)
220
+ const fileStat = await stat(path).catch(() => null)
221
+ if (!fileStat) return { exists: false, path }
222
+ if (fileStat.size > vocab.limits.assetBytes) problems.push(`${name} is ${fileStat.size} bytes, over the ${vocab.limits.assetBytes} byte per-asset limit`)
223
+ if (!readImages) return { exists: true, path, header: null, bytes: fileStat.size }
224
+ const handle = await readFile(path).catch(() => null)
225
+ if (handle === null) return { exists: true, path, unreadable: true, bytes: fileStat.size }
226
+ return { exists: true, path, buffer: handle, header: readPngHeader(handle), exr: isExrBuffer(handle), bytes: fileStat.size }
227
+ }
228
+
229
+ for (const [index, layer] of layers.entries()) {
230
+ if (!layer || typeof layer !== 'object' || !isStr(layer.id)) continue
231
+ const where = `layers[${index}] (${layer.name ?? layer.id})`
232
+ const isGroup = layer.isGroup === true
233
+
234
+ if (isGroup) {
235
+ groupCount++
236
+ if (isStr(layer.imageFile)) problems.push(`${where}: a group cannot carry an imageFile`)
237
+ if (layer.adjustment) problems.push(`${where}: an adjustment layer cannot be a group`)
238
+ if (layer.opacity !== undefined && layer.opacity !== 1) gate(8, `${where}: folders carry their own opacity only from version 8`)
239
+ if (layer.blendMode !== undefined && layer.blendMode !== 'Normal') warnings.push(`${where}: folders are pass-through; blend modes other than Normal are ignored`)
240
+ }
241
+
242
+ if (layer.opacity !== undefined) {
243
+ if (!isNum(layer.opacity) || layer.opacity < 0 || layer.opacity > 1) problems.push(`${where}: opacity must be 0–1`)
244
+ }
245
+ if (layer.blendMode !== undefined) {
246
+ if (!vocab.blendModes.includes(layer.blendMode)) {
247
+ const message = `${where}: blendMode "${layer.blendMode}" is not one of: ${vocab.blendModes.join(', ')}`
248
+ // hard error only when the list came from the current checkout docs
249
+ // and the manifest version is within the documented range
250
+ if (vocab.blendModesSource === 'docs' && !relaxed) problems.push(message)
251
+ else warnings.push(message)
252
+ }
253
+ }
254
+ if (layer.isVisible !== undefined && !isBool(layer.isVisible)) problems.push(`${where}: isVisible must be a boolean`)
255
+
256
+ // transform
257
+ const transform = layer.transform
258
+ if (transform !== undefined) {
259
+ if (!transform || typeof transform !== 'object') problems.push(`${where}: transform must be an object`)
260
+ else {
261
+ const point = (key, axis) => {
262
+ const value = transform[key]
263
+ if (!Array.isArray(value) || value.length !== 2 || !isNum(value[0]) || !isNum(value[1])) problems.push(`${where}: transform.${key} must be two finite numbers (x, y${axis ?? ''})`)
264
+ }
265
+ point('origin')
266
+ if (Array.isArray(transform.size) && transform.size.length === 2 && isNum(transform.size[0]) && isNum(transform.size[1])) {
267
+ if (transform.size[0] <= 0 || transform.size[1] <= 0) problems.push(`${where}: transform.size must be positive`)
268
+ } else problems.push(`${where}: transform.size must be two positive numbers (width, height)`)
269
+ if (!isNum(transform.rotation)) problems.push(`${where}: transform.rotation must be a number`)
270
+ if (transform.sampling !== undefined && !SAMPLINGS.has(transform.sampling)) warnings.push(`${where}: sampling "${transform.sampling}" is not one of ${[...SAMPLINGS].join(', ')}`)
271
+ if (transform.flipX !== undefined && !isBool(transform.flipX)) problems.push(`${where}: transform.flipX must be a boolean`)
272
+ if (transform.flipY !== undefined && !isBool(transform.flipY)) problems.push(`${where}: transform.flipY must be a boolean`)
273
+ }
274
+ }
275
+
276
+ // adjustment
277
+ if (layer.adjustment !== undefined) {
278
+ if (isStr(layer.imageFile)) problems.push(`${where}: an adjustment layer has no imageFile`)
279
+ adjustmentCount++
280
+ for (const message of adjustmentProblems(layer.adjustment, vocab.adjustmentKinds)) problems.push(`${where}: ${message}`)
281
+ gate(7, `${where}: adjustment layers exist only from version 7`)
282
+ }
283
+
284
+ // text metadata
285
+ if (layer.text !== undefined) {
286
+ textCount++
287
+ if (!isStr(layer.text.content)) problems.push(`${where}: text.content must be a string`)
288
+ else {
289
+ if (layer.text.colorRuns !== undefined) {
290
+ for (const message of colorRunsProblems(layer.text.colorRuns, layer.text.content.length, 'colorRuns')) problems.push(`${where}: ${message}`)
291
+ gate(10, `${where}: colorRuns exist only from version 10`)
292
+ }
293
+ if (layer.text.fontRuns !== undefined) {
294
+ for (const message of colorRunsProblems(layer.text.fontRuns, layer.text.content.length, 'fontRuns')) problems.push(`${where}: ${message}`)
295
+ for (const run of Array.isArray(layer.text.fontRuns) ? layer.text.fontRuns : []) if (run && !isStr(run.fontName)) problems.push(`${where}: fontRuns entries need fontName`)
296
+ gate(11, `${where}: fontRuns exist only from version 11`)
297
+ }
298
+ }
299
+ }
300
+
301
+ // effects
302
+ if (layer.effects !== undefined) {
303
+ if (!layer.effects || typeof layer.effects !== 'object') problems.push(`${where}: effects must be an object`)
304
+ else for (const message of effectRangeProblems(layer.effects)) problems.push(`${where}: ${message}`)
305
+ }
306
+
307
+ // shape metadata
308
+ if (layer.shape !== undefined && layer.shape && typeof layer.shape === 'object') {
309
+ for (const key of ['red', 'green', 'blue']) {
310
+ const value = layer.shape[key]
311
+ if (value !== undefined && (!isNum(value) || value < 0 || value > 1)) problems.push(`${where}: shape.${key} must be 0–1`)
312
+ }
313
+ if (layer.shape.cornerRadius !== undefined && !isNum(layer.shape.cornerRadius) && layer.shape.cornerRadius < 0) problems.push(`${where}: shape.cornerRadius must be ≥ 0`)
314
+ }
315
+
316
+ // mask
317
+ const hasMask = isStr(layer.maskFile)
318
+ if (layer.maskEnabled !== undefined && !isBool(layer.maskEnabled)) problems.push(`${where}: maskEnabled must be a boolean`)
319
+ if (hasMask) {
320
+ const expectedMask = `${layer.id}.mask.png`
321
+ if (layer.maskFile !== expectedMask) problems.push(`${where}: maskFile must be "${expectedMask}", got "${layer.maskFile}"`)
322
+ if (isGroup) gate(6, `${where}: groups can carry masks only from version 6`)
323
+ else gate(4, `${where}: layer masks exist only from version 4`)
324
+ if (imagesDirStat) {
325
+ const asset = await readAsset(layer.maskFile)
326
+ if (!asset.exists) problems.push(`${where}: mask file ${layer.maskFile} is missing from images/`)
327
+ else if (asset.header) {
328
+ if (asset.header.colorType !== 0 || asset.header.bitDepth !== 8) problems.push(`${where}: mask ${layer.maskFile} must be 8-bit grayscale (got ${asset.header.bitDepth}-bit color type ${asset.header.colorType})`)
329
+ maskPixels += asset.header.width * asset.header.height
330
+ }
331
+ referenced.add(layer.maskFile)
332
+ }
333
+ }
334
+ if (layer.maskPlacement !== undefined || layer.maskLinked !== undefined) {
335
+ if (!hasMask) problems.push(`${where}: maskPlacement/maskLinked require a maskFile`)
336
+ if (layer.maskPlacement !== undefined && (!Array.isArray(layer.maskPlacement) || layer.maskPlacement.length !== 4 || layer.maskPlacement.some(v => !isNum(v)))) problems.push(`${where}: maskPlacement must be four numbers (x, y, width, height)`)
337
+ if (layer.maskLinked !== undefined && !isBool(layer.maskLinked)) problems.push(`${where}: maskLinked must be a boolean`)
338
+ }
339
+
340
+ // live mask (clipping)
341
+ if (layer.maskSourceID !== undefined) {
342
+ gate(5, `${where}: maskSourceID (clipping masks) exists only from version 5`)
343
+ const source = byId.get(layer.maskSourceID)
344
+ if (!source) problems.push(`${where}: maskSourceID ${layer.maskSourceID} does not exist`)
345
+ else if (source.isGroup === true) problems.push(`${where}: maskSourceID cannot point at a group`)
346
+ else if (layer.maskSourceID === layer.id) problems.push(`${where}: maskSourceID cannot reference itself`)
347
+ }
348
+
349
+ // image
350
+ if (isStr(layer.imageFile)) {
351
+ imageCount++
352
+ const expectedPng = `${layer.id}.png`
353
+ const expectedExr = `${layer.id}.exr`
354
+ if (layer.imageFile !== expectedPng && layer.imageFile !== expectedExr) {
355
+ problems.push(`${where}: imageFile must be "${expectedPng}"${isInt(version) && version >= 13 ? ` or "${expectedExr}"` : ''}, got "${layer.imageFile}"`)
356
+ } else if (layer.imageFile === expectedExr) {
357
+ gate(13, `${where}: EXR images exist only from version 13`)
358
+ }
359
+ if (imagesDirStat) {
360
+ const asset = await readAsset(layer.imageFile)
361
+ if (!asset.exists) problems.push(`${where}: image file ${layer.imageFile} is missing from images/`)
362
+ else if (asset.exr) { /* EXR magic verified */ }
363
+ else if (asset.unreadable) warnings.push(`${where}: ${layer.imageFile} could not be read`)
364
+ else if (!asset.header) problems.push(`${where}: ${layer.imageFile} is not a PNG`)
365
+ else {
366
+ if (asset.header.bitDepth === 16) gate(12, `${where}: 16-bit PNG images exist only from version 12`)
367
+ if (asset.header.colorType !== 6) warnings.push(`${where}: ${layer.imageFile} is ${asset.header.bitDepth}-bit color type ${asset.header.colorType}; layer images are documented as RGBA (color type 6)`)
368
+ if (asset.header.interlace) warnings.push(`${where}: ${layer.imageFile} is interlaced`)
369
+ if (isInt(asset.header.width) && isInt(asset.header.height)) {
370
+ const pixels = asset.header.width * asset.header.height
371
+ layerPixels += pixels
372
+ if (asset.header.width > vocab.limits.canvasSide || asset.header.height > vocab.limits.canvasSide) problems.push(`${where}: ${layer.imageFile} is ${asset.header.width}x${asset.header.height}, over the ${vocab.limits.canvasSide} pixel per-side limit`)
373
+ }
374
+ }
375
+ referenced.add(layer.imageFile)
376
+ }
377
+ } else if (!isGroup && !layer.adjustment) {
378
+ blankCount++
379
+ }
380
+ }
381
+
382
+ // maskSourceID cycles and chain length (functional graph)
383
+ for (const layer of byId.values()) {
384
+ if (layer.maskSourceID === undefined || !isStr(layer.maskSourceID)) continue
385
+ const seen = new Set([layer.id])
386
+ let cursor = layer.maskSourceID
387
+ let chain = 1
388
+ while (cursor) {
389
+ if (seen.has(cursor)) { problems.push(`layer ${cursor}: maskSourceID chain contains a cycle`); break }
390
+ seen.add(cursor)
391
+ if (++chain > vocab.limits.maskSourceChain) { problems.push(`layer ${layer.id}: maskSourceID chain is longer than ${vocab.limits.maskSourceChain} nodes`); break }
392
+ cursor = byId.get(cursor)?.maskSourceID ?? null
393
+ }
394
+ }
395
+
396
+ // guides
397
+ if (manifest.guides !== undefined) {
398
+ gate(8, 'guides exist only from version 8')
399
+ if (!Array.isArray(manifest.guides)) problems.push('guides must be an array')
400
+ else {
401
+ if (manifest.guides.length > vocab.limits.guideCount) problems.push(`${manifest.guides.length} guides, over the ${vocab.limits.guideCount} limit`)
402
+ for (const [index, guide] of manifest.guides.entries()) {
403
+ if (!guide || !isStr(guide.id) || !isStr(guide.axis) || !['horizontal', 'vertical'].includes(guide.axis) || !isNum(guide.position)) { problems.push(`guides[${index}] needs id, axis (horizontal|vertical) and a numeric position`); continue }
404
+ if (Math.abs(guide.position) > vocab.limits.guidePosition) problems.push(`guides[${index}].position is out of range (±${vocab.limits.guidePosition})`)
405
+ }
406
+ }
407
+ }
408
+
409
+ // pixel budgets
410
+ if (layerPixels > vocab.limits.totalLayerPixels) problems.push(`layers total ${layerPixels.toLocaleString()} pixels, over the ${vocab.limits.totalLayerPixels.toLocaleString()} budget`)
411
+ if (maskPixels > vocab.limits.totalMaskPixels) problems.push(`masks total ${maskPixels.toLocaleString()} pixels, over the ${vocab.limits.totalMaskPixels.toLocaleString()} budget`)
412
+
413
+ // package leftovers
414
+ if (imagesDirStat) {
415
+ const entries = await readdir(imagesDir).catch(() => [])
416
+ const orphaned = []
417
+ for (const name of entries) {
418
+ if (referenced.has(name)) continue
419
+ if (!IMAGE_EXTENSIONS.test(name)) continue
420
+ const fileStat = await stat(join(imagesDir, name)).catch(() => null)
421
+ orphaned.push({ name, bytes: fileStat?.size ?? 0 })
422
+ }
423
+ if (orphaned.length) warnings.push(`${orphaned.length} image file(s) in images/ are not referenced by the manifest (run compositor_clean): ${orphaned.slice(0, 5).map(o => o.name).join(', ')}${orphaned.length > 5 ? ' …' : ''}`)
424
+ }
425
+ const quickLook = await stat(join(compDir, 'QuickLook')).catch(() => null)
426
+ if (quickLook?.isDirectory()) warnings.push('a QuickLook folder exists; delete it after changing the project so Finder does not preview a stale picture (compositor_clean does)')
427
+
428
+ const info = {
429
+ compDir,
430
+ manifestPath,
431
+ version: isInt(version) ? version : null,
432
+ knownMaxVersion: knownMax,
433
+ relaxed,
434
+ canvas: isInt(manifest.width) && isInt(manifest.height) ? [manifest.width, manifest.height] : null,
435
+ layers: { total: layers.length, images: imageCount, groups: groupCount, adjustments: adjustmentCount, text: textCount, blank: blankCount },
436
+ layerPixels, maskPixels,
437
+ manifestBytes: manifestStat.size,
438
+ stackOrder: 'layers[0] is the BOTTOM of the stack; the last layer draws on top',
439
+ }
440
+
441
+ return { ok: problems.length === 0, problems, warnings, info }
442
+ }
443
+
444
+ function errorNote(path) {
445
+ return `could not read ${path}`
446
+ }
package/CHANGELOG.md ADDED
@@ -0,0 +1,10 @@
1
+ # Changelog
2
+
3
+ ## 0.1.0 (2026-10-13)
4
+
5
+ Initial release.
6
+
7
+ - Live format knowledge: blend modes, adjustment kinds, max manifest version and limits parsed at runtime from the synced Compositor checkout's docs, with built-in fallbacks; cache invalidates by mtime so `git pull` is enough to follow upstream.
8
+ - Forward-compatible validator: version-gated checks downgrade to warnings for manifests newer than the checkout docs; unknown additive fields pass.
9
+ - 8 agent tools: `compositor_repo`, `compositor_docs`, `compositor_validate`, `compositor_manifest`, `compositor_write_manifest` (validate-first, atomic temp+rename write, works on brand-new projects), `compositor_layer_png` (pure-Node solid/gradient layer and mask PNGs), `compositor_preview` (optional sharp; transforms, opacity, masks, clipping, all 24 blend modes; adjustments/effects reported as skipped), `compositor_clean` (unreferenced images + stale QuickLook, dry-run by default).
10
+ - 41 unit tests including live parsing against the real checkout and an end-to-end fresh-project flow.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 ljwei-stak
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,82 @@
1
- # Temporary Holding Version
1
+ # @ljwei-stak/dsh-compositor
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ A DeepSeek Harness (DSH Desktop / dsh CLI) plugin that lets Harness agents build and edit [Compositor](https://github.com/robbietilton/Compositor) image projects (`.comp`) directly.
4
+
5
+ Compositor is an open-source macOS image editor; a `.comp` project is a folder of PNG layers plus a `manifest.json` that anything which can write files — including AI agents — can construct, with the open canvas refreshing live as files change. This plugin hands that workflow to Harness agents and **freezes no format knowledge**: every format rule is parsed at runtime from the locally synced Compositor git checkout, so after upstream updates, a `git pull` is all it takes.
6
+
7
+ ## Why a `git pull` is enough (compatibility design)
8
+
9
+ - **Docs are the source of truth**: the blend-mode list, adjustment kinds, max manifest version and all numeric limits are parsed live from `docs/writing-comp-files.md` / `docs/project-format.md` in the checkout, with a built-in fallback copy used only when parsing fails (and every value reports whether it came from `docs` or `fallback`).
10
+ - **Forward compatible**: a manifest version newer than the checkout's docs (upstream shipped, you haven't pulled) automatically downgrades every version-gated check to a warning — a legitimate new-format project is never blocked.
11
+ - **Additive fields ignored**: the format's history (v1–13) is purely additive; the validator only enforces rules the docs state and lets unknown new fields through.
12
+ - **Cache invalidates by mtime**: the doc cache re-reads immediately after a pull.
13
+
14
+ ## Agent tools (8)
15
+
16
+ | Tool | Purpose |
17
+ |---|---|
18
+ | `compositor_repo` | Checkout status: git commit, doc parse results, blend modes / adjustment kinds / max version / limits |
19
+ | `compositor_docs` | Read the format docs verbatim from the checkout (writing / format / both) |
20
+ | `compositor_validate` | Full package validation per the docs: naming rules, blend-mode spellings, group tree (cycles/depth/contiguity), clipping chains, adjustment ranges, version gates, image formats, package limits, unreferenced files |
21
+ | `compositor_manifest` | Token-efficient manifest read (one line per layer; full object when small; layerIndex for one record) |
22
+ | `compositor_write_manifest` | Safe writes: auto layer ids (UUIDs), filename normalization, validate-then-**atomic write** (temp + rename, so a watching Compositor never sees half a manifest); refuses to write on validation failure |
23
+ | `compositor_layer_png` | Generate layer/mask PNGs (solid, linear/radial gradients) named exactly `<id>.png` / `<id>.mask.png`; pure Node, no native deps |
24
+ | `compositor_preview` | No Mac needed: flatten the stack to a PNG (transforms, opacity, layer/folder masks, clipping masks, all 24 blend modes); adjustment layers and effects are not rendered and are listed in `skipped`. Needs the optional sharp package |
25
+ | `compositor_clean` | Delete unreferenced images and the stale QuickLook folder (the docs ask for this after every change); dry-run by default |
26
+
27
+ ## Install
28
+
29
+ ### From a local pack
30
+
31
+ ```sh
32
+ cd F:\everyAI\dsh-Compositor\dsh-compositor
33
+ npm pack --pack-destination dist
34
+ ```
35
+
36
+ The `desktop` profile is reserved for the Electron app (the CLI rejects plugin management for it), so install by hand:
37
+
38
+ ```sh
39
+ cd %USERPROFILE%\.dsh\profiles\desktop
40
+ pnpm add F:\everyAI\dsh-Compositor\dsh-compositor\dist\ljwei-stak-dsh-compositor-0.1.0.tgz
41
+ ```
42
+
43
+ Then add `"@ljwei-stak/dsh-compositor"` to the `dsh.profile.bundles` array in `%USERPROFILE%\.dsh\profiles\desktop\package.json` (the profile runs `patchReload: live`, so it hot-reloads; restart DSH Desktop if not).
44
+
45
+ ### After publishing to npm
46
+
47
+ Install from the DSH Desktop plugin manager, or for non-desktop profiles:
48
+
49
+ ```sh
50
+ dsh plugin --profile <name> add @ljwei-stak/dsh-compositor
51
+ ```
52
+
53
+ ## Settings
54
+
55
+ | Key | Default | Meaning |
56
+ |---|---|---|
57
+ | `repoDir` | `F:\everyAI\dsh-Compositor\Compositor` | Local Compositor git checkout (the single source of format knowledge) |
58
+ | `agentTools` | `true` | Register the compositor_* tools |
59
+ | `previewEnabled` | `true` | Enable compositor_preview |
60
+ | `docsMaxChars` | `200000` | Character cap for compositor_docs |
61
+
62
+ When sharp is missing, `compositor_preview` says so clearly; every other tool works with zero dependencies.
63
+
64
+ ## The agent workflow (also written into the tool descriptions)
65
+
66
+ 1. Read `compositor_docs` (file=writing) once;
67
+ 2. `compositor_layer_png` for flat/gradient layers and masks (painterly content: generate PNGs with your own image tools and drop them into `images/`);
68
+ 3. `compositor_write_manifest` for the atomic manifest write (refused when validation fails);
69
+ 4. `compositor_preview` to eyeball the result, `compositor_validate` to confirm;
70
+ 5. Whoever has the project open in Compositor on a Mac sees the canvas refresh within ~0.3s.
71
+
72
+ ## Development
73
+
74
+ ```sh
75
+ npm test # node --test tests/
76
+ ```
77
+
78
+ Plugin code is MIT. The `.comp` format knowledge belongs to the Compositor repository (MIT, Robbie Tilton); this plugin reads it at runtime and does not redistribute it.
79
+
80
+ ## License
81
+
82
+ MIT — see [LICENSE](LICENSE).
package/README.zh.md ADDED
@@ -0,0 +1,82 @@
1
+ # @ljwei-stak/dsh-compositor
2
+
3
+ DeepSeek Harness(DSH Desktop / dsh CLI)插件:让 Harness 的 Agent 直接创建和编辑 [Compositor](https://github.com/robbietilton/Compositor) 图像项目(`.comp`)。
4
+
5
+ Compositor 是 macOS 上的开源图像编辑器;`.comp` 项目是「PNG 图层文件夹 + `manifest.json`」,任何能写文件的东西(包括 AI agent)都可以直接构建,Compositor 打开时画布会随写入实时刷新。本插件把这套能力交给 Harness 的 Agent,并且**不冻结任何格式知识**——所有格式规则都在运行时从本地同步的 Compositor git 仓库解析,上游更新后 `git pull` 即可,插件自动跟上。
6
+
7
+ ## 兼容性设计(为什么 git pull 就够了)
8
+
9
+ - **文档即真相**:blend mode 列表、adjustment kind 列表、最高 manifest 版本、各类数值限制,全部实时解析自仓库 `docs/writing-comp-files.md` 和 `docs/project-format.md`;解析失败才退回内置副本,且结果里标注来源(`docs` / `fallback`)。
10
+ - **前向兼容**:manifest 版本比仓库文档新时(上游刚发新版本、你还没 pull),所有版本门槛检查自动降级为警告,绝不阻塞新格式的合法项目。
11
+ - **附加字段忽略**:格式历史(v1–v13)是纯增量演进的,校验器只检查文档声明过的规则,未知新字段一律放行。
12
+ - **缓存随 mtime 失效**:文档缓存按修改时间判断,`git pull` 后的下一次调用立即读到新文档。
13
+
14
+ ## Agent 工具(8 个)
15
+
16
+ | 工具 | 作用 |
17
+ |---|---|
18
+ | `compositor_repo` | 报告本地 checkout 状态:git commit、文档解析结果、blend mode / adjustment kind / 版本上限 / 限制值 |
19
+ | `compositor_docs` | 读取 checkout 里的两份格式文档原文(writing / format / both) |
20
+ | `compositor_validate` | 按照文档规则全面校验一个 .comp:命名规则、blend mode 拼写、组树(环/深度/连续性)、剪贴蒙版链、adjustment 数值范围、版本门槛、图片格式、包限制、未引用文件 |
21
+ | `compositor_manifest` | 紧凑读取 manifest(每层一行的栈摘要;小文件返回全量;layerIndex 取单层) |
22
+ | `compositor_write_manifest` | 安全写入:自动补 layer id(UUID)、规范 imageFile/maskFile 命名、先校验后**原子写入**(临时文件 + rename,正在打开的 Compositor 永远不会读到半个 manifest);校验失败拒绝写入并返回问题清单 |
23
+ | `compositor_layer_png` | 生成图层 PNG / 蒙版 PNG(纯色、线性/径向渐变),命名严格遵循 `<id>.png` / `<id>.mask.png`;纯 Node 实现,无原生依赖 |
24
+ | `compositor_preview` | 无需 Mac:把图层栈拍平成 PNG(变换、不透明度、图层/文件夹蒙版、剪贴蒙版、全部 24 种混合模式);adjustment 图层与图层效果不渲染、明确列入 `skipped`。需要可选依赖 sharp |
25
+ | `compositor_clean` | 删除 manifest 不再引用的图片和过期的 QuickLook 文件夹(文档要求每次改动后删除,否则 Finder 预览是旧图);默认 dry-run |
26
+
27
+ ## 安装
28
+
29
+ ### 方式一:本地打包安装(推荐先这样验证)
30
+
31
+ ```sh
32
+ cd F:\everyAI\dsh-Compositor\dsh-compositor
33
+ npm pack --pack-destination dist
34
+ ```
35
+
36
+ `desktop` profile 被 dsh CLI 保留(插件管理要走 Desktop 的插件管理器),所以手工安装:
37
+
38
+ ```sh
39
+ cd %USERPROFILE%\.dsh\profiles\desktop
40
+ pnpm add F:\everyAI\dsh-Compositor\dsh-compositor\dist\ljwei-stak-dsh-compositor-0.1.0.tgz
41
+ ```
42
+
43
+ 然后在 `%USERPROFILE%\.dsh\profiles\desktop\package.json` 的 `dsh.profile.bundles` 数组里加一项 `"@ljwei-stak/dsh-compositor"`(profile 开了 `patchReload: live`,保存即热加载;没生效就重启 DSH Desktop)。
44
+
45
+ ### 方式二:发布到 npm 后
46
+
47
+ 在 DSH Desktop 的插件管理器里搜索安装,或对非 desktop profile:
48
+
49
+ ```sh
50
+ dsh plugin --profile <name> add @ljwei-stak/dsh-compositor
51
+ ```
52
+
53
+ ## 设置
54
+
55
+ | 键 | 默认 | 说明 |
56
+ |---|---|---|
57
+ | `repoDir` | `F:\everyAI\dsh-Compositor\Compositor` | Compositor git 仓库本地路径(格式知识的唯一来源) |
58
+ | `agentTools` | `true` | 注册 compositor_* 工具 |
59
+ | `previewEnabled` | `true` | 启用 compositor_preview |
60
+ | `docsMaxChars` | `200000` | compositor_docs 单次返回字符上限 |
61
+
62
+ sharp 未安装时 `compositor_preview` 会明确报告,其余工具全部可用(它们零依赖)。
63
+
64
+ ## Agent 的标准工作流(工具描述里也写了)
65
+
66
+ 1. `compositor_docs`(file=writing)先读一遍怎么写;
67
+ 2. `compositor_layer_png` 生成/覆盖图层 PNG(画笔画的内容用你自己的图像工具生成 PNG 再放入 `images/`);
68
+ 3. `compositor_write_manifest` 原子写入 manifest(校验不过会被拒);
69
+ 4. `compositor_preview` 自检视觉结果,`compositor_validate` 再确认一次;
70
+ 5. 在 Mac 上打开该项目的人会看到画布约 0.3 秒内刷新。
71
+
72
+ ## 开发
73
+
74
+ ```sh
75
+ npm test # node --test tests/
76
+ ```
77
+
78
+ 插件代码 MIT。`.comp` 格式知识属于 Compositor 仓库(MIT,Robbie Tilton),本插件运行时读取、不复制分发。
79
+
80
+ ## 许可
81
+
82
+ MIT — 见 [LICENSE](LICENSE)。
@@ -0,0 +1,5 @@
1
+ # DeepSeek Harness bundle entry. `id` is the Cordis plugin name and the
2
+ # settings namespace; `name` is the module the loader imports (this package).
3
+ - insert:
4
+ - id: dsh-compositor
5
+ name: '@ljwei-stak/dsh-compositor'