@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,209 @@
1
+ /**
2
+ * Minimal PNG support for .comp projects, pure Node (zlib only).
3
+ *
4
+ * encodePngRgba / encodePngGray write 8-bit PNGs exactly as the Compositor
5
+ * format expects (layer images RGBA, masks 8-bit grayscale). readPngHeader
6
+ * decodes just the IHDR so validation can check bit depth and color type
7
+ * without a full decode. Layer PNG generation (solid / linear / radial) is
8
+ * built on the encoders.
9
+ */
10
+ import { deflateSync } from 'node:zlib'
11
+
12
+ const PNG_SIGNATURE = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
13
+
14
+ const CRC_TABLE = (() => {
15
+ const table = new Int32Array(256)
16
+ for (let n = 0; n < 256; n++) {
17
+ let c = n
18
+ for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
19
+ table[n] = c
20
+ }
21
+ return table
22
+ })()
23
+
24
+ function crc32(...buffers) {
25
+ let c = 0xffffffff
26
+ for (const buffer of buffers) for (const byte of buffer) c = CRC_TABLE[(c ^ byte) & 0xff] ^ (c >>> 8)
27
+ return (c ^ 0xffffffff) >>> 0
28
+ }
29
+
30
+ function chunk(type, data) {
31
+ const length = Buffer.alloc(4)
32
+ length.writeUInt32BE(data.length)
33
+ const typeBuffer = Buffer.from(type, 'ascii')
34
+ const crc = Buffer.alloc(4)
35
+ crc.writeUInt32BE(crc32(typeBuffer, data))
36
+ return Buffer.concat([length, typeBuffer, data, crc])
37
+ }
38
+
39
+ function ihdr(width, height, bitDepth, colorType) {
40
+ const data = Buffer.alloc(13)
41
+ data.writeUInt32BE(width, 0)
42
+ data.writeUInt32BE(height, 4)
43
+ data.writeUInt8(bitDepth, 8)
44
+ data.writeUInt8(colorType, 9)
45
+ // compression 0 (deflate), filter 0, interlace 0 (progressive not written)
46
+ return data
47
+ }
48
+
49
+ function encode(width, height, rawRows, bitDepth, colorType) {
50
+ // rawRows already includes the per-scanline filter byte (0) where needed
51
+ const idat = deflateSync(rawRows, { level: 6 })
52
+ return Buffer.concat([
53
+ PNG_SIGNATURE,
54
+ chunk('IHDR', ihdr(width, height, bitDepth, colorType)),
55
+ chunk('IDAT', idat),
56
+ chunk('IEND', Buffer.alloc(0)),
57
+ ])
58
+ }
59
+
60
+ /** Encode 8-bit RGBA pixels (Buffer of width*height*4) as a PNG layer image. */
61
+ export function encodePngRgba(width, height, pixels) {
62
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new Error(`invalid PNG dimensions ${width}x${height}`)
63
+ if (pixels.length !== width * height * 4) throw new Error(`pixel buffer is ${pixels.length} bytes, expected ${width * height * 4}`)
64
+ const stride = width * 4
65
+ const rows = Buffer.alloc((stride + 1) * height)
66
+ for (let y = 0; y < height; y++) {
67
+ rows[y * (stride + 1)] = 0
68
+ pixels.copy(rows, y * (stride + 1) + 1, y * stride, (y + 1) * stride)
69
+ }
70
+ return encode(width, height, rows, 8, 6)
71
+ }
72
+
73
+ /** Encode 8-bit grayscale pixels (Buffer of width*height) as a PNG mask. */
74
+ export function encodePngGray(width, height, pixels) {
75
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new Error(`invalid PNG dimensions ${width}x${height}`)
76
+ if (pixels.length !== width * height) throw new Error(`pixel buffer is ${pixels.length} bytes, expected ${width * height}`)
77
+ const rows = Buffer.alloc((width + 1) * height)
78
+ for (let y = 0; y < height; y++) {
79
+ rows[y * (width + 1)] = 0
80
+ pixels.copy(rows, y * (width + 1) + 1, y * width, (y + 1) * width)
81
+ }
82
+ return encode(width, height, rows, 8, 0)
83
+ }
84
+
85
+ /** Decode the IHDR of a PNG buffer. Returns null when it is not a PNG. */
86
+ export function readPngHeader(buffer) {
87
+ if (!Buffer.isBuffer(buffer) || buffer.length < 33 || !buffer.subarray(0, 8).equals(PNG_SIGNATURE)) return null
88
+ if (buffer.readUInt32BE(8) !== 13 || buffer.toString('ascii', 12, 16) !== 'IHDR') return null
89
+ return {
90
+ width: buffer.readUInt32BE(16),
91
+ height: buffer.readUInt32BE(20),
92
+ bitDepth: buffer.readUInt8(24),
93
+ colorType: buffer.readUInt8(25),
94
+ compression: buffer.readUInt8(26),
95
+ filter: buffer.readUInt8(27),
96
+ interlace: buffer.readUInt8(28),
97
+ }
98
+ }
99
+
100
+ export function isPngBuffer(buffer) {
101
+ return Buffer.isBuffer(buffer) && buffer.length >= 8 && buffer.subarray(0, 8).equals(PNG_SIGNATURE)
102
+ }
103
+
104
+ /** OpenEXR magic: 0x76 0x2f 0x31 0x01 followed by a version dword. */
105
+ export function isExrBuffer(buffer) {
106
+ return Buffer.isBuffer(buffer) && buffer.length >= 8 && buffer[0] === 0x76 && buffer[1] === 0x2f && buffer[2] === 0x31 && buffer[3] === 0x01
107
+ }
108
+
109
+ export const PNG_COLOR_TYPES = { 0: 'grayscale', 2: 'truecolor', 3: 'palette', 4: 'grayscale+alpha', 6: 'truecolor+alpha' }
110
+
111
+ export function describeImage(buffer, expected) {
112
+ if (isExrBuffer(buffer)) return { format: 'exr', note: 'OpenEXR (HDR) image' }
113
+ const header = readPngHeader(buffer)
114
+ if (!header) return { format: 'unknown', note: 'neither PNG nor EXR' }
115
+ const color = PNG_COLOR_TYPES[header.colorType] ?? `color type ${header.colorType}`
116
+ return {
117
+ format: 'png',
118
+ width: header.width,
119
+ height: header.height,
120
+ bitDepth: header.bitDepth,
121
+ colorType: header.colorType,
122
+ interlace: header.interlace,
123
+ note: `${header.bitDepth}-bit ${color}${header.interlace ? ' interlaced' : ''} PNG`,
124
+ ...(expected === 'mask' && (header.colorType !== 0 || header.bitDepth !== 8) ? { maskWarning: 'masks must be 8-bit grayscale' } : {}),
125
+ }
126
+ }
127
+
128
+
129
+ function clamp255(value) {
130
+ return value < 0 ? 0 : value > 255 ? 255 : Math.round(value)
131
+ }
132
+
133
+ function parseColor(input, fallback) {
134
+ if (Array.isArray(input)) {
135
+ const [r, g, b, a] = input
136
+ return {
137
+ r: clamp255(Number(r ?? fallback[0])),
138
+ g: clamp255(Number(g ?? fallback[1])),
139
+ b: clamp255(Number(b ?? fallback[2])),
140
+ a: clamp255(Number(a ?? 255)),
141
+ }
142
+ }
143
+ if (typeof input === 'number') return { r: clamp255(input), g: clamp255(input), b: clamp255(input), a: 255 }
144
+ if (typeof input === 'string') {
145
+ const hex = input.trim().replace(/^#/, '')
146
+ if (/^[0-9a-fA-F]{6}$/.test(hex) || /^[0-9a-fA-F]{8}$/.test(hex)) {
147
+ return {
148
+ r: parseInt(hex.slice(0, 2), 16),
149
+ g: parseInt(hex.slice(2, 4), 16),
150
+ b: parseInt(hex.slice(4, 6), 16),
151
+ a: hex.length === 8 ? parseInt(hex.slice(6, 8), 16) : 255,
152
+ }
153
+ }
154
+ }
155
+ return { r: fallback[0], g: fallback[1], b: fallback[2], a: 255 }
156
+ }
157
+
158
+ function lerp(a, b, t) {
159
+ return a + (b - a) * t
160
+ }
161
+
162
+ /**
163
+ * Generate a layer image or mask buffer. mode: 'solid' | 'linear' | 'radial'.
164
+ * For kind 'image' colors are [r,g,b,(a)] 0-255; for kind 'mask' from/to are
165
+ * gray levels 0-255 (white reveals, black hides).
166
+ */
167
+ export function generatePixels({ width, height, mode = 'solid', color, from, to, angle = 90, center, radius }) {
168
+ if (!Number.isInteger(width) || !Number.isInteger(height) || width < 1 || height < 1) throw new Error('width and height must be positive integers')
169
+ if (width * height > 100_000_000) throw new Error('refusing to generate more than 100 million pixels')
170
+ const start = parseColor(from ?? color, [0, 0, 0])
171
+ const end = parseColor(to ?? color, [255, 255, 255])
172
+ const pixels = Buffer.alloc(width * height * 4)
173
+ // the gradient travels along (cos, sin) of `angle` in screen coordinates
174
+ // (y grows downward): 0° = left→right, 90° = top→bottom
175
+ const radians = ((Number(angle) || 0) * Math.PI) / 180
176
+ const dx = Math.cos(radians)
177
+ const dy = Math.sin(radians)
178
+ const extent = Math.abs((width / 2) * dx) + Math.abs((height / 2) * dy)
179
+ const span = 2 * extent || 1
180
+ const cx = Number(center?.[0]) || width / 2
181
+ const cy = Number(center?.[1]) || height / 2
182
+ const maxR = Number(radius) || Math.hypot(Math.max(cx, width - cx), Math.max(cy, height - cy))
183
+ let i = 0
184
+ for (let y = 0; y < height; y++) {
185
+ for (let x = 0; x < width; x++) {
186
+ let t = 0
187
+ if (mode === 'radial') t = Math.min(1, Math.max(0, Math.hypot(x + 0.5 - cx, y + 0.5 - cy) / maxR))
188
+ else if (mode === 'linear') {
189
+ const px = x + 0.5 - width / 2
190
+ const py = y + 0.5 - height / 2
191
+ t = Math.min(1, Math.max(0, (px * dx + py * dy + extent) / span))
192
+ }
193
+ pixels[i] = clamp255(lerp(start.r, end.r, t))
194
+ pixels[i + 1] = clamp255(lerp(start.g, end.g, t))
195
+ pixels[i + 2] = clamp255(lerp(start.b, end.b, t))
196
+ pixels[i + 3] = clamp255(lerp(start.a, end.a, t))
197
+ i += 4
198
+ }
199
+ }
200
+ return pixels
201
+ }
202
+
203
+ /** 8-bit grayscale variant used for masks (from/to are gray levels). */
204
+ export function generateGrayPixels({ width, height, mode = 'solid', color, from, to, angle = 90, center, radius }) {
205
+ const rgba = generatePixels({ width, height, mode, color, from: from ?? color, to: to ?? color, angle, center, radius })
206
+ const gray = Buffer.alloc(width * height)
207
+ for (let p = 0, g = 0; p < rgba.length; p += 4, g++) gray[g] = rgba[p] // use the red channel
208
+ return gray
209
+ }
@@ -0,0 +1,276 @@
1
+ /**
2
+ * Best-effort flattened preview of a .comp project.
3
+ *
4
+ * Decodes layer images with sharp (an optional dependency — when it is not
5
+ * installed the tool reports that instead of failing) and composites the
6
+ * stack in JavaScript: transforms (origin/size/rotation/flips/sampling),
7
+ * opacity with folder pass-through, raster masks, unlinked maskPlacement,
8
+ * folder masks, clipping masks (maskSourceID) and all 24 blend modes.
9
+ * Adjustment layers and layer effects are NOT rendered — they are listed in
10
+ * `skipped` so nobody mistakes the preview for the app's output.
11
+ */
12
+ import { readFile } from 'node:fs/promises'
13
+ import { join } from 'node:path'
14
+ import { compositePixel } from './comp-blend.mjs'
15
+ import { isExrBuffer } from './comp-png.mjs'
16
+
17
+ const MAX_PLACED_PIXELS = 96_000_000
18
+
19
+ async function loadSharp() {
20
+ try {
21
+ const module = await import('sharp')
22
+ return module.default ?? module
23
+ } catch {
24
+ return null
25
+ }
26
+ }
27
+
28
+ async function decodeRgba(sharp, buffer) {
29
+ // → { width, height, data: Buffer RGBA 8-bit } or null (unsupported)
30
+ const image = sharp(buffer, { failOn: 'none' }).ensureAlpha().toColourspace('srgb')
31
+ const { data, info } = await image.raw().toBuffer({ resolveWithObject: true })
32
+ if (info.channels === 4 && info.depth === 'uchar') return { width: info.width, height: info.height, data }
33
+ // normalize 16-bit or odd channel counts to 8-bit RGBA
34
+ const out = Buffer.alloc(info.width * info.height * 4)
35
+ const depthMax = info.depth === 'ushort' ? 65535 : 255
36
+ for (let p = 0, q = 0; p < info.width * info.height; p++, q += info.channels) {
37
+ out[p * 4] = Math.round((data[q] / depthMax) * 255)
38
+ out[p * 4 + 1] = Math.round((data[q + 1] / depthMax) * 255)
39
+ out[p * 4 + 2] = Math.round((data[q + 2] / depthMax) * 255)
40
+ out[p * 4 + 3] = Math.round(((data[q + 3] ?? 255) / depthMax) * 255)
41
+ }
42
+ return { width: info.width, height: info.height, data: out }
43
+ }
44
+
45
+ function bilinear(image, x, y) {
46
+ if (x < 0 || y < 0 || x >= image.width - 1 || y >= image.height - 1) {
47
+ const px = Math.min(image.width - 1, Math.max(0, Math.round(x)))
48
+ const py = Math.min(image.height - 1, Math.max(0, Math.round(y)))
49
+ const i = (py * image.width + px) * 4
50
+ return [image.data[i], image.data[i + 1], image.data[i + 2], image.data[i + 3]]
51
+ }
52
+ const x0 = Math.floor(x), y0 = Math.floor(y)
53
+ const fx = x - x0, fy = y - y0
54
+ const at = (px, py) => (py * image.width + px) * 4
55
+ const mix4 = (channel) => {
56
+ const top = image.data[at(x0, y0) + channel] * (1 - fx) + image.data[at(x0 + 1, y0) + channel] * fx
57
+ const bottom = image.data[at(x0, y0 + 1) + channel] * (1 - fx) + image.data[at(x0 + 1, y0 + 1) + channel] * fx
58
+ return top * (1 - fy) + bottom * fy
59
+ }
60
+ return [mix4(0), mix4(1), mix4(2), mix4(3)]
61
+ }
62
+
63
+ function rotatedBounds(transform) {
64
+ const [ox, oy] = transform.origin ?? [0, 0]
65
+ const [w, h] = transform.size
66
+ const theta = ((transform.rotation ?? 0) * Math.PI) / 180
67
+ const cx = ox + w / 2, cy = oy + h / 2
68
+ const cos = Math.abs(Math.cos(theta)), sin = Math.abs(Math.sin(theta))
69
+ const bw = w * cos + h * sin, bh = w * sin + h * cos
70
+ return { x0: Math.floor(cx - bw / 2), y0: Math.floor(cy - bh / 2), x1: Math.ceil(cx + bw / 2), y1: Math.ceil(cy + bh / 2) }
71
+ }
72
+
73
+ /**
74
+ * Sample a mask (grayscale PNG decoded to RGBA; red channel used) at
75
+ * document coordinates through an optional document-space rectangle.
76
+ */
77
+ function sampleMask(maskImage, docX, docY, placement) {
78
+ let u = docX, v = docY
79
+ if (placement) {
80
+ const [px, py, pw, ph] = placement
81
+ u = (docX - px) / (pw || 1)
82
+ v = (docY - py) / (ph || 1)
83
+ } else {
84
+ // linked mask: reuse the caller's layer UV
85
+ return null
86
+ }
87
+ if (u < 0 || v < 0 || u >= 1 || v >= 1) return 0
88
+ const [r] = bilinear(maskImage, u * (maskImage.width - 1), v * (maskImage.height - 1))
89
+ return r
90
+ }
91
+
92
+ /**
93
+ * Render the flattened stack. manifest/target from resolveCompPath + read.
94
+ * Returns { ok, png: Buffer, width, height, skipped: [...], warnings: [...] }
95
+ * or { ok: false, reason }.
96
+ */
97
+ export async function renderPreview(target, manifest, { maxWidth = 1600, onlyLayerIds = null, signal } = {}) {
98
+ const sharp = await loadSharp()
99
+ if (!sharp) return { ok: false, reason: 'sharp is not installed in this profile; the preview needs it for PNG decode/encode (pnpm add sharp in the profile, or install the plugin with its optionalDependencies)' }
100
+ const canvasW = manifest.width, canvasH = manifest.height
101
+ if (!Number.isInteger(canvasW) || !Number.isInteger(canvasH) || canvasW <= 0 || canvasH <= 0) return { ok: false, reason: 'manifest.width/height are not usable' }
102
+ const layers = Array.isArray(manifest.layers) ? manifest.layers : []
103
+ const byId = new Map(layers.filter(layer => layer?.id).map(layer => [layer.id, layer]))
104
+ const ancestors = id => {
105
+ const chain = []
106
+ let cursor = byId.get(id)
107
+ const seen = new Set([id])
108
+ while (cursor?.parentID && !seen.has(cursor.parentID)) { seen.add(cursor.parentID); chain.push(byId.get(cursor.parentID)); cursor = byId.get(cursor.parentID) }
109
+ return chain.filter(Boolean)
110
+ }
111
+ const isVisible = layer => {
112
+ if (layer?.isVisible === false) return false
113
+ return ancestors(layer.id).every(group => group.isVisible !== false)
114
+ }
115
+ const groupOpacity = layer => ancestors(layer.id).reduce((product, group) => product * (typeof group.opacity === 'number' ? group.opacity : 1), 1)
116
+
117
+ const warnings = []
118
+ const skipped = []
119
+ const imageCache = new Map()
120
+ const alphaFieldCache = new Map()
121
+ let placedPixels = 0
122
+
123
+ const loadImage = async layer => {
124
+ if (!layer?.imageFile) return null
125
+ if (imageCache.has(layer.id)) return imageCache.get(layer.id)
126
+ let entry = null
127
+ try {
128
+ const buffer = await readFile(join(target.imagesDir, layer.imageFile))
129
+ if (isExrBuffer(buffer)) { skipped.push(`adjustment/EXR layer "${layer.name ?? layer.id}" image not decoded (EXR needs the app)`); entry = { unsupported: true } }
130
+ else {
131
+ const decoded = await decodeRgba(sharp, buffer)
132
+ entry = decoded
133
+ placedPixels += decoded.width * decoded.height
134
+ }
135
+ } catch (error) {
136
+ warnings.push(`cannot decode ${layer.imageFile}: ${error.message}`)
137
+ entry = { unsupported: true }
138
+ }
139
+ imageCache.set(layer.id, entry)
140
+ return entry
141
+ }
142
+
143
+ const loadMask = async layer => {
144
+ if (!layer?.maskFile || layer.maskEnabled === false) return null
145
+ if (imageCache.has(`mask:${layer.id}`)) return imageCache.get(`mask:${layer.id}`)
146
+ let entry = null
147
+ try {
148
+ entry = await decodeRgba(sharp, await readFile(join(target.imagesDir, layer.maskFile)))
149
+ placedPixels += entry.width * entry.height
150
+ } catch (error) {
151
+ warnings.push(`cannot decode mask ${layer.maskFile}: ${error.message}`)
152
+ entry = null
153
+ }
154
+ imageCache.set(`mask:${layer.id}`, entry)
155
+ return entry
156
+ }
157
+
158
+ /** Alpha field (coverage) of a layer in document space, for clipping. */
159
+ const alphaField = async layer => {
160
+ if (alphaFieldCache.has(layer.id)) return alphaFieldCache.get(layer.id)
161
+ const field = new Uint8Array(canvasW * canvasH)
162
+ const image = await loadImage(layer)
163
+ const mask = await loadMask(layer)
164
+ if (image && !image.unsupported && layer.transform?.size) {
165
+ const transform = layer.transform
166
+ const bounds = rotatedBounds(transform)
167
+ const theta = ((transform.rotation ?? 0) * Math.PI) / 180
168
+ const cos = Math.cos(theta), sin = Math.sin(theta)
169
+ const [ox, oy] = transform.origin ?? [0, 0]
170
+ const [sw, sh] = transform.size
171
+ const opacity = (typeof layer.opacity === 'number' ? layer.opacity : 1)
172
+ for (let y = Math.max(0, bounds.y0); y < Math.min(canvasH, bounds.y1); y++) {
173
+ for (let x = Math.max(0, bounds.x0); x < Math.min(canvasW, bounds.x1); x++) {
174
+ const lx = x + 0.5 - ox - sw / 2, ly = y + 0.5 - oy - sh / 2
175
+ const rx = lx * cos + ly * sin, ry = -lx * sin + ly * cos
176
+ let u = (rx + sw / 2) / sw, v = (ry + sh / 2) / sh
177
+ if (transform.flipX) u = 1 - u
178
+ if (transform.flipY) v = 1 - v
179
+ if (u < 0 || v < 0 || u >= 1 || v >= 1) continue
180
+ let coverage = bilinear(image, u * (image.width - 1), v * (image.height - 1))[3] / 255
181
+ coverage *= opacity
182
+ if (mask && layer.maskLinked !== false) coverage *= bilinear(mask, u * (mask.width - 1), v * (mask.height - 1))[0] / 255
183
+ else if (mask && layer.maskPlacement) coverage *= sampleMask(mask, x + 0.5, y + 0.5, layer.maskPlacement) / 255
184
+ field[y * canvasW + x] = Math.round(Math.min(1, Math.max(0, coverage)) * 255)
185
+ }
186
+ }
187
+ }
188
+ // clipping chain: the source's own maskSourceID contributes too
189
+ if (layer.maskSourceID && byId.has(layer.maskSourceID)) {
190
+ const upstream = await alphaField(byId.get(layer.maskSourceID))
191
+ for (let p = 0; p < field.length; p++) field[p] = Math.round((field[p] / 255) * (upstream[p] / 255) * 255)
192
+ }
193
+ alphaFieldCache.set(layer.id, field)
194
+ return field
195
+ }
196
+
197
+ const canvas = Buffer.alloc(canvasW * canvasH * 4)
198
+ const groupMasks = new Map()
199
+ const loadGroupMask = async group => {
200
+ if (!group.maskFile || group.maskEnabled === false) return null
201
+ if (groupMasks.has(group.id)) return groupMasks.get(group.id)
202
+ let entry = null
203
+ try {
204
+ entry = { image: await decodeRgba(sharp, await readFile(join(target.imagesDir, group.maskFile))), placement: group.transform?.size ? group.transform.origin ?? [0, 0] : [0, 0], size: group.transform?.size ?? [canvasW, canvasH] }
205
+ } catch (error) { warnings.push(`cannot decode group mask ${group.maskFile}: ${error.message}`) }
206
+ groupMasks.set(group.id, entry)
207
+ return entry
208
+ }
209
+
210
+ for (const layer of layers) {
211
+ if (signal?.aborted) return { ok: false, reason: 'aborted' }
212
+ if (onlyLayerIds && !onlyLayerIds.includes(layer.id)) continue
213
+ if (layer.adjustment) { skipped.push(`adjustment layer "${layer.name ?? layer.id}" (${layer.adjustment.kind}) not rendered`); continue }
214
+ if (layer.isGroup === true) continue // folders contribute opacity/masks via their children
215
+ if (layer.effects && Object.keys(layer.effects).length) skipped.push(`layer effects on "${layer.name ?? layer.id}" not rendered`)
216
+ if (!isVisible(layer)) continue
217
+ const image = await loadImage(layer)
218
+ if (!image) continue // blank layer
219
+ if (image.unsupported) continue
220
+ const transform = layer.transform
221
+ if (!transform?.size) { warnings.push(`layer "${layer.name ?? layer.id}" has no usable transform; skipped`); continue }
222
+ if (placedPixels > MAX_PLACED_PIXELS) { warnings.push('pixel budget reached; remaining layers skipped'); break }
223
+
224
+ const bounds = rotatedBounds(transform)
225
+ const theta = ((transform.rotation ?? 0) * Math.PI) / 180
226
+ const cos = Math.cos(theta), sin = Math.sin(theta)
227
+ const [ox, oy] = transform.origin ?? [0, 0]
228
+ const [sw, sh] = transform.size
229
+ const opacity = (typeof layer.opacity === 'number' ? layer.opacity : 1) * groupOpacity(layer)
230
+ const blendMode = layer.blendMode ?? 'Normal'
231
+ const mask = await loadMask(layer)
232
+ const ancestorMasks = []
233
+ for (const group of ancestors(layer.id)) {
234
+ const groupMask = await loadGroupMask(group)
235
+ if (groupMask) ancestorMasks.push(groupMask)
236
+ }
237
+ let clipField = null
238
+ if (layer.maskSourceID && byId.has(layer.maskSourceID)) clipField = await alphaField(byId.get(layer.maskSourceID))
239
+
240
+ for (let y = Math.max(0, bounds.y0); y < Math.min(canvasH, bounds.y1); y++) {
241
+ for (let x = Math.max(0, bounds.x0); x < Math.min(canvasW, bounds.x1); x++) {
242
+ const lx = x + 0.5 - ox - sw / 2, ly = y + 0.5 - oy - sh / 2
243
+ const rx = lx * cos + ly * sin, ry = -lx * sin + ly * cos
244
+ let u = (rx + sw / 2) / sw, v = (ry + sh / 2) / sh
245
+ if (u < 0 || v < 0 || u >= 1 || v >= 1) continue
246
+ if (transform.flipX) u = 1 - u
247
+ if (transform.flipY) v = 1 - v
248
+ const nearest = transform.sampling === 'Nearest'
249
+ const px = nearest ? Math.min(image.width - 1, Math.max(0, Math.round(u * (image.width - 1)))) : u * (image.width - 1)
250
+ const py = nearest ? Math.min(image.height - 1, Math.max(0, Math.round(v * (image.height - 1)))) : v * (image.height - 1)
251
+ const rgba = bilinear(image, px, py)
252
+ if (rgba[3] <= 0) continue
253
+ let coverage = opacity
254
+ if (mask && layer.maskLinked !== false) coverage *= bilinear(mask, u * (mask.width - 1), v * (mask.height - 1))[0] / 255
255
+ else if (mask && layer.maskPlacement) coverage *= sampleMask(mask, x + 0.5, y + 0.5, layer.maskPlacement) / 255
256
+ for (const groupMask of ancestorMasks) {
257
+ const [gx, gy, gw, gh] = [groupMask.placement[0], groupMask.placement[1], groupMask.size[0], groupMask.size[1]]
258
+ coverage *= sampleMask(groupMask.image, x + 0.5, y + 0.5, [gx, gy, gw, gh]) / 255
259
+ }
260
+ if (clipField) coverage *= clipField[y * canvasW + x] / 255
261
+ if (coverage <= 0) continue
262
+ // compositePixel applies the source pixel's own alpha to `coverage`
263
+ scratch[0] = Math.round(rgba[0]); scratch[1] = Math.round(rgba[1]); scratch[2] = Math.round(rgba[2]); scratch[3] = Math.round(rgba[3])
264
+ compositePixel(canvas, (y * canvasW + x) * 4, scratch, 0, blendMode, Math.min(1, coverage))
265
+ }
266
+ }
267
+ }
268
+
269
+ let png = sharp(canvas, { raw: { width: canvasW, height: canvasH, channels: 4 } })
270
+ if (maxWidth && canvasW > maxWidth) png = png.resize({ width: maxWidth })
271
+ const output = await png.png().toBuffer()
272
+ const meta = await sharp(output).metadata()
273
+ return { ok: true, png: output, width: meta.width, height: meta.height, canvas: [canvasW, canvasH], skipped, warnings }
274
+ }
275
+
276
+ const scratch = Buffer.alloc(4)
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Reading, atomically writing and cleaning .comp packages.
3
+ *
4
+ * The write path follows the rule the Compositor docs prescribe for editing
5
+ * a project while it is open: PNGs land in images/ first (the agent's job),
6
+ * then the manifest is written to a temporary file inside the package and
7
+ * renamed over manifest.json — a rename is atomic, so the open app only ever
8
+ * sees a complete manifest.
9
+ */
10
+ import { randomUUID } from 'node:crypto'
11
+ import { mkdir, readdir, readFile, rename, rm, writeFile, stat } from 'node:fs/promises'
12
+ import { join } from 'node:path'
13
+ import { resolveCompPath } from './comp-paths.mjs'
14
+
15
+ const MANIFEST_READ_LIMIT = 128 * 1024
16
+
17
+ export { resolveCompPath }
18
+
19
+ export function newLayerId() {
20
+ return randomUUID().toUpperCase()
21
+ }
22
+
23
+ export async function readManifest(target) {
24
+ const text = await readFile(target.manifestPath, 'utf8')
25
+ return { manifest: JSON.parse(text), text, bytes: Buffer.byteLength(text) }
26
+ }
27
+
28
+ function layerOneLiner(layer, index) {
29
+ const bits = [`#${index}`]
30
+ if (layer.isGroup === true) bits.push('group')
31
+ if (layer.adjustment) bits.push(`adjustment:${layer.adjustment.kind ?? '?'}`)
32
+ if (layer.text) bits.push('text')
33
+ bits.push(layer.name ?? layer.id)
34
+ if (layer.isVisible === false) bits.push('hidden')
35
+ if (typeof layer.opacity === 'number' && layer.opacity !== 1) bits.push(`opacity ${layer.opacity}`)
36
+ if (layer.blendMode && layer.blendMode !== 'Normal') bits.push(layer.blendMode)
37
+ if (layer.parentID) bits.push(`in ${layer.parentID}`)
38
+ if (layer.maskFile) bits.push('masked')
39
+ if (layer.maskSourceID) bits.push(`clips ${layer.maskSourceID}`)
40
+ const transform = layer.transform
41
+ if (transform?.size) bits.push(`at ${transform.origin ?? [0, 0]} size ${transform.size}`)
42
+ if (layer.imageFile) bits.push(layer.imageFile)
43
+ return bits.join(' | ')
44
+ }
45
+
46
+ /** Summarize a manifest for token-efficient reading; full only when small. */
47
+ export function summarizeManifest(manifest, text, { layerIndex = null } = {}) {
48
+ const layers = Array.isArray(manifest.layers) ? manifest.layers : []
49
+ if (layerIndex !== null) {
50
+ const layer = layers[layerIndex]
51
+ return { layer: layer ?? null, layerCount: layers.length }
52
+ }
53
+ const full = text.length <= MANIFEST_READ_LIMIT
54
+ return {
55
+ canvas: { width: manifest.width, height: manifest.height, resolution: manifest.resolution, colorSpace: manifest.colorSpace },
56
+ version: manifest.version,
57
+ documentID: manifest.documentID,
58
+ activeLayerID: manifest.activeLayerID ?? null,
59
+ layerCount: layers.length,
60
+ guides: Array.isArray(manifest.guides) ? manifest.guides.length : 0,
61
+ stack: 'bottom to top — layers[0] paints first, the last layer paints on top',
62
+ layerLines: layers.map((layer, index) => layerOneLiner(layer, index)),
63
+ ...(full ? { manifest } : { note: `manifest is ${text.length} bytes; ask with layerIndex for one layer's full record` }),
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Fill in what an agent usually forgets, without changing intent:
69
+ * missing layer ids get fresh UUIDs; imageFile/maskFile are normalized to
70
+ * `<id>.png` / `<id>.mask.png`; a few top-level defaults are added when absent.
71
+ * Returns { manifest, fixes: [...] }.
72
+ */
73
+ export function autoFixManifest(manifest, { fixIds = true, fixFilenames = true, defaultVersion = 11 } = {}) {
74
+ const fixes = []
75
+ const out = structuredClone(manifest)
76
+ if (out.format === undefined) { out.format = 'com.compositor.project'; fixes.push('set format = "com.compositor.project"') }
77
+ if (out.version === undefined) { out.version = defaultVersion; fixes.push(`set version = ${defaultVersion} (newest conservative version; raise it only for 16-bit/EXR layers)`) }
78
+ if (out.colorSpace === undefined) { out.colorSpace = 'sRGB'; fixes.push('set colorSpace = "sRGB"') }
79
+ if (out.resolution === undefined) { out.resolution = 72; fixes.push('set resolution = 72') }
80
+ if (!Array.isArray(out.layers)) out.layers = []
81
+ for (const layer of out.layers) {
82
+ if (!layer || typeof layer !== 'object') continue
83
+ if (fixIds && !layer.id) {
84
+ layer.id = newLayerId()
85
+ fixes.push(`gave layer "${layer.name ?? '(unnamed)'}" the id ${layer.id}`)
86
+ }
87
+ if (!layer.id) continue
88
+ if (fixFilenames && layer.imageFile !== undefined && layer.imageFile !== `${layer.id}.png` && layer.imageFile !== `${layer.id}.exr`) {
89
+ const extension = /\.(png|exr)$/i.exec(layer.imageFile)?.[1]?.toLowerCase() ?? 'png'
90
+ fixes.push(`layers "${layer.name ?? layer.id}": imageFile "${layer.imageFile}" → "${layer.id}.${extension}" — image files must be named after their layer id`)
91
+ layer.imageFile = `${layer.id}.${extension}`
92
+ }
93
+ if (fixFilenames && layer.maskFile !== undefined && layer.maskFile !== `${layer.id}.mask.png`) {
94
+ fixes.push(`layer "${layer.name ?? layer.id}": maskFile "${layer.maskFile}" → "${layer.id}.mask.png"`)
95
+ layer.maskFile = `${layer.id}.mask.png`
96
+ }
97
+ }
98
+ if (out.activeLayerID === undefined && out.layers.length) {
99
+ out.activeLayerID = out.layers[out.layers.length - 1]?.id ?? null
100
+ if (out.activeLayerID) fixes.push(`set activeLayerID = ${out.activeLayerID} (top layer)`)
101
+ }
102
+ return { manifest: out, fixes }
103
+ }
104
+
105
+ /**
106
+ * Write the manifest atomically: temp file inside the package, then rename.
107
+ * `docs`-validated manifests only — pass skipValidation to override.
108
+ */
109
+ export async function writeManifestAtomic(target, manifest, { validate, tempPrefix = '.manifest' } = {}) {
110
+ const text = JSON.stringify(manifest, null, 2) + '\n'
111
+ const tempPath = join(target.compDir, `${tempPrefix}.json.tmp-${process.pid}-${Date.now()}`)
112
+ await mkdir(target.imagesDir, { recursive: true })
113
+ await writeFile(tempPath, text, 'utf8')
114
+ // prove the round-trip parses before swapping it in
115
+ JSON.parse(await readFile(tempPath, 'utf8'))
116
+ await rename(tempPath, target.manifestPath)
117
+ return { manifestPath: target.manifestPath, bytes: Buffer.byteLength(text) }
118
+ }
119
+
120
+ /**
121
+ * Remove what the project no longer needs: image files in images/ the
122
+ * manifest does not reference, and the QuickLook folder (the docs ask for
123
+ * this after every change so Finder does not preview a stale picture).
124
+ */
125
+ export async function cleanPackage(target, manifest, { apply = false } = {}) {
126
+ const referenced = new Set()
127
+ for (const layer of Array.isArray(manifest.layers) ? manifest.layers : []) {
128
+ if (typeof layer?.imageFile === 'string') referenced.add(layer.imageFile)
129
+ if (typeof layer?.maskFile === 'string') referenced.add(layer.maskFile)
130
+ }
131
+ const unusedImages = []
132
+ const entries = await readdir(target.imagesDir).catch(() => [])
133
+ for (const name of entries) {
134
+ if (referenced.has(name) || !/\.(png|exr)$/i.test(name)) continue
135
+ const fileStat = await stat(join(target.imagesDir, name)).catch(() => null)
136
+ if (fileStat?.isFile()) unusedImages.push({ name, bytes: fileStat.size })
137
+ }
138
+ const quickLook = await stat(join(target.compDir, 'QuickLook')).catch(() => null)
139
+ const hasQuickLook = quickLook?.isDirectory() === true
140
+ const result = { unusedImages, quickLook: hasQuickLook, applied: apply, removed: [] }
141
+ if (apply) {
142
+ for (const image of unusedImages) {
143
+ await rm(join(target.imagesDir, image.name), { force: true })
144
+ result.removed.push(`images/${image.name}`)
145
+ }
146
+ if (hasQuickLook) {
147
+ await rm(join(target.compDir, 'QuickLook'), { recursive: true, force: true })
148
+ result.removed.push('QuickLook/')
149
+ }
150
+ }
151
+ return result
152
+ }