@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.
- package/.dsh-plugin/client.js +3 -0
- package/.dsh-plugin/index.mjs +76 -0
- package/.dsh-plugin/shared/comp-blend.mjs +142 -0
- package/.dsh-plugin/shared/comp-docs.mjs +186 -0
- package/.dsh-plugin/shared/comp-paths.mjs +102 -0
- package/.dsh-plugin/shared/comp-png.mjs +209 -0
- package/.dsh-plugin/shared/comp-preview.mjs +276 -0
- package/.dsh-plugin/shared/comp-project.mjs +152 -0
- package/.dsh-plugin/shared/comp-tools.mjs +298 -0
- package/.dsh-plugin/shared/comp-validate.mjs +446 -0
- package/CHANGELOG.md +10 -0
- package/LICENSE +21 -0
- package/README.md +81 -2
- package/README.zh.md +82 -0
- package/cordis.patch.yml +5 -0
- package/package.json +65 -4
|
@@ -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
|
-
#
|
|
1
|
+
# @ljwei-stak/dsh-compositor
|
|
2
2
|
|
|
3
|
-
|
|
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)。
|