@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,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
|
+
}
|