mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 03:42:20 +02:00
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
498 lines
19 KiB
JavaScript
498 lines
19 KiB
JavaScript
/** Minimal Adobe Photoshop (PSD) reader/writer.
|
|
*
|
|
* Exports a flattened 8-bit RGB composite as a valid `8BPS` document with
|
|
* no layer records, and parses the same shape back. Also decodes RLE
|
|
* (PackBits) image data, which is what Photoshop normally writes.
|
|
*/
|
|
|
|
const SIGNATURE = 0x38425053; // '8BPS'
|
|
const COLOR_MODE_RGB = 3;
|
|
const COLOR_MODE_GRAYSCALE = 1;
|
|
const COMPRESSION_RAW = 0;
|
|
const COMPRESSION_RLE = 1;
|
|
|
|
function writeUint16(view, offset, value) {
|
|
view.setUint16(offset, value & 0xffff, false);
|
|
}
|
|
|
|
function writeUint32(view, offset, value) {
|
|
view.setUint32(offset, value >>> 0, false);
|
|
}
|
|
|
|
function writeUint64(view, offset, value) {
|
|
view.setBigUint64(offset, BigInt(value), false);
|
|
}
|
|
|
|
/** PackBits (RLE) encode a byte array. */
|
|
export function packBits(input) {
|
|
const out = [];
|
|
const length = input.length;
|
|
let i = 0;
|
|
while (i < length) {
|
|
let runLength = 1;
|
|
while (i + runLength < length && runLength < 128 && input[i + runLength] === input[i]) runLength++;
|
|
if (runLength >= 2) {
|
|
out.push((256 - (runLength - 1)) & 0xff, input[i]);
|
|
i += runLength;
|
|
continue;
|
|
}
|
|
let literalStart = i;
|
|
let literalLength = 0;
|
|
while (i < length && literalLength < 128) {
|
|
let nextRun = 1;
|
|
while (i + nextRun < length && nextRun < 128 && input[i + nextRun] === input[i]) nextRun++;
|
|
if (nextRun >= 2 && literalLength > 0) break;
|
|
i += 1;
|
|
literalLength += 1;
|
|
if (i >= length) break;
|
|
}
|
|
if (i === literalStart) i += 1;
|
|
const count = i - literalStart;
|
|
out.push(count - 1, ...input.slice(literalStart, i));
|
|
}
|
|
return Uint8Array.from(out);
|
|
}
|
|
|
|
/** PackBits (RLE) decode `length` bytes starting at `offset`. */
|
|
export function unpackBits(data, offset, length) {
|
|
const out = new Uint8Array(length);
|
|
let outPos = 0;
|
|
let pos = offset;
|
|
while (outPos < length && pos < data.length) {
|
|
const header = data[pos] < 128 ? data[pos] : data[pos] - 256;
|
|
pos += 1;
|
|
if (header >= 0) {
|
|
const count = header + 1;
|
|
for (let i = 0; i < count && outPos < length; i++) out[outPos++] = data[pos + i];
|
|
pos += count;
|
|
} else if (header !== -128) {
|
|
const count = 1 - header;
|
|
const value = data[pos++];
|
|
for (let i = 0; i < count && outPos < length; i++) out[outPos++] = value;
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/** Serialize a canvas as a flattened 8-bit RGB PSD. */
|
|
export function exportPsd(sourceCanvas) {
|
|
const width = sourceCanvas.width;
|
|
const height = sourceCanvas.height;
|
|
const pixels = sourceCanvas.getContext('2d').getImageData(0, 0, width, height).data;
|
|
const pixelCount = width * height;
|
|
const red = new Uint8Array(pixelCount);
|
|
const green = new Uint8Array(pixelCount);
|
|
const blue = new Uint8Array(pixelCount);
|
|
for (let i = 0; i < pixelCount; i++) {
|
|
red[i] = pixels[i * 4];
|
|
green[i] = pixels[i * 4 + 1];
|
|
blue[i] = pixels[i * 4 + 2];
|
|
}
|
|
const channels = [red, green, blue];
|
|
const rowLengths = [];
|
|
const rows = [];
|
|
for (const channel of channels) {
|
|
let channelLength = 0;
|
|
for (let y = 0; y < height; y++) {
|
|
const row = packBits(channel.subarray(y * width, (y + 1) * width));
|
|
rowLengths.push(row.length);
|
|
rows.push(row);
|
|
channelLength += row.length;
|
|
}
|
|
}
|
|
const rleTableBytes = rowLengths.length * 2;
|
|
const rleBodyBytes = rows.reduce((sum, row) => sum + row.length, 0);
|
|
const headerBytes = 26;
|
|
const imageDataBytes = 2 + rleTableBytes + rleBodyBytes;
|
|
const total = headerBytes + 4 * 3 + imageDataBytes;
|
|
const buffer = new ArrayBuffer(total);
|
|
const bytes = new Uint8Array(buffer);
|
|
const view = new DataView(buffer);
|
|
let offset = 0;
|
|
writeUint32(view, 0, SIGNATURE);
|
|
writeUint16(view, 4, 1);
|
|
offset = 10; // reserved 6 bytes
|
|
writeUint16(view, offset, 3); offset += 2;
|
|
writeUint32(view, offset, height); offset += 4;
|
|
writeUint32(view, offset, width); offset += 4;
|
|
writeUint16(view, offset, 8); offset += 2;
|
|
writeUint16(view, offset, COLOR_MODE_RGB); offset += 2;
|
|
// Color mode data + image resources + layer/mask sections are empty.
|
|
writeUint32(view, offset, 0); offset += 4;
|
|
writeUint32(view, offset, 0); offset += 4;
|
|
writeUint32(view, offset, 0); offset += 4;
|
|
writeUint16(view, offset, COMPRESSION_RLE); offset += 2;
|
|
for (const length of rowLengths) {
|
|
writeUint16(view, offset, length); offset += 2;
|
|
}
|
|
for (const row of rows) {
|
|
bytes.set(row, offset);
|
|
offset += row.length;
|
|
}
|
|
return bytes;
|
|
}
|
|
|
|
function readSection(view, bytes, offset, long = false) {
|
|
if (long) {
|
|
const length = Number(view.getBigUint64(offset, false));
|
|
return offset + 8 + length;
|
|
}
|
|
const length = view.getUint32(offset, false);
|
|
return offset + 4 + length;
|
|
}
|
|
|
|
/** Parse an 8BPS buffer into `{ width, height, canvas }`. */
|
|
export function parsePsd(arrayBuffer) {
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
const view = new DataView(arrayBuffer);
|
|
if (view.getUint32(0, false) !== SIGNATURE) throw new Error('Not a PSD file');
|
|
const version = view.getUint16(4, false);
|
|
if (version !== 1 && version !== 2) throw new Error('Unsupported PSD version');
|
|
const psb = version === 2;
|
|
const channels = view.getUint16(12, false);
|
|
const height = view.getUint32(14, false);
|
|
const width = view.getUint32(18, false);
|
|
const depth = view.getUint16(22, false);
|
|
const colorMode = view.getUint16(24, false);
|
|
if (depth !== 8) throw new Error('Unsupported PSD bit depth');
|
|
if (colorMode !== COLOR_MODE_RGB && colorMode !== COLOR_MODE_GRAYSCALE) {
|
|
throw new Error('Unsupported PSD color mode');
|
|
}
|
|
let offset = 26;
|
|
offset = readSection(view, bytes, offset); // color mode data (4-byte length)
|
|
offset = readSection(view, bytes, offset); // image resources (4-byte length)
|
|
offset = readSection(view, bytes, offset, psb); // layer & mask info (8-byte length in PSB)
|
|
const compression = view.getUint16(offset, false);
|
|
offset += 2;
|
|
const pixelCount = width * height;
|
|
const planes = [];
|
|
if (compression === COMPRESSION_RAW) {
|
|
for (let c = 0; c < channels; c++) {
|
|
planes.push(bytes.subarray(offset, offset + pixelCount));
|
|
offset += pixelCount;
|
|
}
|
|
} else if (compression === COMPRESSION_RLE) {
|
|
const rowCount = channels * height;
|
|
const rowLengths = [];
|
|
for (let i = 0; i < rowCount; i++) {
|
|
rowLengths.push(psb ? view.getUint32(offset, false) : view.getUint16(offset, false));
|
|
offset += psb ? 4 : 2;
|
|
}
|
|
for (let c = 0; c < channels; c++) {
|
|
const plane = new Uint8Array(pixelCount);
|
|
for (let y = 0; y < height; y++) {
|
|
const rowIndex = c * height + y;
|
|
const decoded = unpackBits(bytes, offset, rowLengths[rowIndex]);
|
|
plane.set(decoded.subarray(0, width), y * width);
|
|
offset += rowLengths[rowIndex];
|
|
}
|
|
planes.push(plane);
|
|
}
|
|
} else {
|
|
throw new Error('Unsupported PSD compression');
|
|
}
|
|
const canvas = document.createElement('canvas');
|
|
canvas.width = width;
|
|
canvas.height = height;
|
|
const ctx = canvas.getContext('2d');
|
|
const image = ctx.createImageData(width, height);
|
|
const isGray = colorMode === COLOR_MODE_GRAYSCALE;
|
|
for (let i = 0; i < pixelCount; i++) {
|
|
const r = planes[0] ? planes[0][i] : 0;
|
|
const g = isGray ? r : (planes[1] ? planes[1][i] : 0);
|
|
const b = isGray ? r : (planes[2] ? planes[2][i] : 0);
|
|
image.data[i * 4] = r;
|
|
image.data[i * 4 + 1] = g;
|
|
image.data[i * 4 + 2] = b;
|
|
image.data[i * 4 + 3] = 255;
|
|
}
|
|
ctx.putImageData(image, 0, 0);
|
|
return { width, height, canvas };
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Layered PSD (8BPS v1, 8-bit RGB) — layer records + raw channel planes.
|
|
// Encoder/decoder operate on plain RGBA arrays (no canvas) so they are
|
|
// usable and testable outside the browser.
|
|
// ---------------------------------------------------------------------------
|
|
|
|
function rgbaToPlanes(data, width, height) {
|
|
const count = width * height;
|
|
const r = new Uint8Array(count);
|
|
const g = new Uint8Array(count);
|
|
const b = new Uint8Array(count);
|
|
const a = new Uint8Array(count);
|
|
for (let i = 0; i < count; i++) {
|
|
r[i] = data[i * 4];
|
|
g[i] = data[i * 4 + 1];
|
|
b[i] = data[i * 4 + 2];
|
|
a[i] = data[i * 4 + 3];
|
|
}
|
|
return [r, g, b, a];
|
|
}
|
|
|
|
function blendRgbaInto(target, layer, docWidth, docHeight) {
|
|
const lw = layer.width;
|
|
const lh = layer.height;
|
|
const ox = layer.x | 0;
|
|
const oy = layer.y | 0;
|
|
const alpha = layer.opacity == null ? 1 : layer.opacity;
|
|
const data = layer.data;
|
|
for (let y = 0; y < lh; y++) {
|
|
const ty = oy + y;
|
|
if (ty < 0 || ty >= docHeight) continue;
|
|
for (let x = 0; x < lw; x++) {
|
|
const tx = ox + x;
|
|
if (tx < 0 || tx >= docWidth) continue;
|
|
const si = (y * lw + x) * 4;
|
|
const ti = (ty * docWidth + tx) * 4;
|
|
const sa = (data[si + 3] / 255) * alpha;
|
|
if (sa <= 0) continue;
|
|
const da = target[ti + 3] / 255;
|
|
const outA = sa + da * (1 - sa);
|
|
for (let c = 0; c < 3; c++) {
|
|
const sc = data[si + c];
|
|
const dc = target[ti + c];
|
|
target[ti + c] = outA ? Math.round((sc * sa + dc * da * (1 - sa)) / outA) : 0;
|
|
}
|
|
target[ti + 3] = Math.round(outA * 255);
|
|
}
|
|
}
|
|
}
|
|
|
|
function latin1Bytes(text) {
|
|
const out = [];
|
|
for (const char of String(text)) out.push(char.charCodeAt(0) & 0xff);
|
|
return out;
|
|
}
|
|
|
|
/** Serialize layered RGBA layers + a flattened composite as an 8BPS PSD
|
|
* (version 1) or PSB (version 2, "large document format", 8-byte lengths). */
|
|
export function encodeLayeredPsd({ width, height, layers = [], psb = false } = {}) {
|
|
const longLen = psb ? 8 : 4;
|
|
const composite = new Uint8ClampedArray(width * height * 4);
|
|
const ordered = layers.slice().reverse(); // PSD stores bottom-first
|
|
for (const layer of ordered) {
|
|
if (layer.visible === false) continue;
|
|
blendRgbaInto(composite, layer, width, height);
|
|
}
|
|
|
|
const records = [];
|
|
const channelBlocks = [];
|
|
for (const layer of ordered) {
|
|
const lw = layer.width;
|
|
const lh = layer.height;
|
|
const planes = rgbaToPlanes(layer.data, lw, lh);
|
|
const channelIds = [0, 1, 2, -1];
|
|
const channelLength = 2 + lw * lh;
|
|
const name = latin1Bytes(layer.name || 'Layer');
|
|
const namePadded = 1 + name.length + ((4 - ((1 + name.length) % 4)) % 4);
|
|
const extraLength = 4 + 4 + namePadded;
|
|
|
|
const record = new Uint8Array(4 * 4 + 2 + channelIds.length * (2 + longLen) + 4 + 4 + 4 + 4 + extraLength);
|
|
const rv = new DataView(record.buffer);
|
|
let o = 0;
|
|
rv.setInt32(o, layer.y | 0, false); o += 4;
|
|
rv.setInt32(o, layer.x | 0, false); o += 4;
|
|
rv.setInt32(o, (layer.y | 0) + lh, false); o += 4;
|
|
rv.setInt32(o, (layer.x | 0) + lw, false); o += 4;
|
|
rv.setUint16(o, channelIds.length, false); o += 2;
|
|
for (const id of channelIds) {
|
|
rv.setInt16(o, id, false); o += 2;
|
|
if (psb) { rv.setBigUint64(o, BigInt(channelLength), false); o += 8; }
|
|
else { rv.setUint32(o, channelLength, false); o += 4; }
|
|
}
|
|
record.set([0x38, 0x42, 0x49, 0x4d], o); o += 4; // '8BIM'
|
|
record.set(latin1Bytes((layer.blendKey || 'norm').padEnd(4, ' ').slice(0, 4)), o); o += 4;
|
|
record[o++] = Math.round((layer.opacity == null ? 1 : layer.opacity) * 255);
|
|
record[o++] = 0; // clipping
|
|
record[o++] = layer.visible === false ? 2 : 0; // flags (bit1 = hidden)
|
|
record[o++] = 0; // filler
|
|
rv.setUint32(o, extraLength, false); o += 4;
|
|
rv.setUint32(o, 0, false); o += 4; // layer mask data length
|
|
rv.setUint32(o, 0, false); o += 4; // layer blending ranges length
|
|
record[o++] = name.length;
|
|
record.set(name, o); o += name.length;
|
|
while (o < record.length) record[o++] = 0; // name padding
|
|
records.push(record);
|
|
|
|
for (const plane of planes) {
|
|
const block = new Uint8Array(2 + plane.length);
|
|
block[0] = 0;
|
|
block[1] = COMPRESSION_RAW;
|
|
block.set(plane, 2);
|
|
channelBlocks.push(block);
|
|
}
|
|
}
|
|
|
|
const recordBytes = records.reduce((sum, record) => sum + record.length, 0);
|
|
const channelBytes = channelBlocks.reduce((sum, block) => sum + block.length, 0);
|
|
const layerInfoLength = 2 + recordBytes + channelBytes;
|
|
|
|
const layerInfo = new Uint8Array(longLen + layerInfoLength);
|
|
const liv = new DataView(layerInfo.buffer);
|
|
if (psb) liv.setBigUint64(0, BigInt(layerInfoLength), false); else liv.setUint32(0, layerInfoLength, false);
|
|
liv.setInt16(longLen, ordered.length, false);
|
|
let p = longLen + 2;
|
|
for (const record of records) { layerInfo.set(record, p); p += record.length; }
|
|
for (const block of channelBlocks) { layerInfo.set(block, p); p += block.length; }
|
|
|
|
const sectionDataLength = layerInfo.length + 4; // layerInfo blob + global mask (0)
|
|
const section = new Uint8Array(longLen + sectionDataLength);
|
|
const sv = new DataView(section.buffer);
|
|
if (psb) sv.setBigUint64(0, BigInt(sectionDataLength), false); else sv.setUint32(0, sectionDataLength, false);
|
|
section.set(layerInfo, longLen);
|
|
sv.setUint32(longLen + layerInfo.length, 0, false); // global layer mask info length
|
|
|
|
// Composite image data (RLE), matching exportPsd.
|
|
const compositePlanes = rgbaToPlanes(composite, width, height);
|
|
const rowLengths = [];
|
|
const rows = [];
|
|
for (const plane of compositePlanes) {
|
|
for (let y = 0; y < height; y++) {
|
|
const row = packBits(plane.subarray(y * width, (y + 1) * width));
|
|
rowLengths.push(row.length);
|
|
rows.push(row);
|
|
}
|
|
}
|
|
const rleTableBytes = rowLengths.length * (psb ? 4 : 2);
|
|
const rleBodyBytes = rows.reduce((sum, row) => sum + row.length, 0);
|
|
const imageDataBytes = 2 + rleTableBytes + rleBodyBytes;
|
|
|
|
const headerBytes = 26;
|
|
const total = headerBytes + 4 + 4 + section.length + imageDataBytes;
|
|
const buffer = new ArrayBuffer(total);
|
|
const bytes = new Uint8Array(buffer);
|
|
const view = new DataView(buffer);
|
|
writeUint32(view, 0, SIGNATURE);
|
|
writeUint16(view, 4, psb ? 2 : 1);
|
|
writeUint16(view, 12, 3); // channels
|
|
writeUint32(view, 14, height);
|
|
writeUint32(view, 18, width);
|
|
writeUint16(view, 22, 8);
|
|
writeUint16(view, 24, COLOR_MODE_RGB);
|
|
writeUint32(view, 26, 0); // color mode data
|
|
writeUint32(view, 30, 0); // image resources
|
|
let offset = 34;
|
|
bytes.set(section, offset); offset += section.length;
|
|
writeUint16(view, offset, COMPRESSION_RLE); offset += 2;
|
|
for (const length of rowLengths) {
|
|
if (psb) { writeUint32(view, offset, length); offset += 4; }
|
|
else { writeUint16(view, offset, length); offset += 2; }
|
|
}
|
|
for (const row of rows) { bytes.set(row, offset); offset += row.length; }
|
|
return bytes;
|
|
}
|
|
|
|
/** Decode a layered PSD into `{ width, height, layers }` (RGBA planes, no canvas). */
|
|
export function decodeLayeredPsd(arrayBuffer) {
|
|
const bytes = new Uint8Array(arrayBuffer);
|
|
const view = new DataView(arrayBuffer);
|
|
if (view.getUint32(0, false) !== SIGNATURE) throw new Error('Not a PSD file');
|
|
const version = view.getUint16(4, false);
|
|
if (version !== 1 && version !== 2) throw new Error('Unsupported PSD version');
|
|
const psb = version === 2;
|
|
const longLen = psb ? 8 : 4;
|
|
const height = view.getUint32(14, false);
|
|
const width = view.getUint32(18, false);
|
|
if (view.getUint16(22, false) !== 8) throw new Error('Unsupported PSD bit depth');
|
|
let offset = 26;
|
|
offset = readSection(view, bytes, offset); // color mode data (4-byte length)
|
|
offset = readSection(view, bytes, offset); // image resources (4-byte length)
|
|
const sectionLength = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
|
|
offset += longLen;
|
|
if (!sectionLength) return { width, height, layers: [] };
|
|
const sectionEnd = offset + sectionLength;
|
|
const layerInfoLength = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
|
|
offset += longLen;
|
|
const layerInfoEnd = offset + layerInfoLength;
|
|
const rawCount = view.getInt16(offset, false);
|
|
offset += 2;
|
|
const layerCount = Math.abs(rawCount);
|
|
const metas = [];
|
|
for (let l = 0; l < layerCount; l++) {
|
|
const top = view.getInt32(offset, false); offset += 4;
|
|
const left = view.getInt32(offset, false); offset += 4;
|
|
const bottom = view.getInt32(offset, false); offset += 4;
|
|
const right = view.getInt32(offset, false); offset += 4;
|
|
const channelCount = view.getUint16(offset, false); offset += 2;
|
|
const channels = [];
|
|
for (let c = 0; c < channelCount; c++) {
|
|
const id = view.getInt16(offset, false); offset += 2;
|
|
const length = psb ? Number(view.getBigUint64(offset, false)) : view.getUint32(offset, false);
|
|
offset += longLen;
|
|
channels.push({ id, length });
|
|
}
|
|
offset += 4; // '8BIM'
|
|
const blendKey = String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]); offset += 4;
|
|
const opacity = bytes[offset++] / 255;
|
|
offset += 1; // clipping
|
|
const flags = bytes[offset++];
|
|
offset += 1; // filler
|
|
const extraLength = view.getUint32(offset, false); offset += 4;
|
|
const extraEnd = offset + extraLength;
|
|
const maskLength = view.getUint32(offset, false); offset += 4 + maskLength;
|
|
const rangesLength = view.getUint32(offset, false); offset += 4 + rangesLength;
|
|
const nameLength = bytes[offset++];
|
|
let name = '';
|
|
for (let i = 0; i < nameLength; i++) name += String.fromCharCode(bytes[offset + i]);
|
|
offset = extraEnd;
|
|
metas.push({ top, left, bottom, right, channelCount, channels, blendKey, opacity, visible: (flags & 2) === 0, name });
|
|
}
|
|
// Channel image data follows the records; the metas loop above already
|
|
// left `offset` at the start of the channel data.
|
|
const layers = [];
|
|
for (const meta of metas) {
|
|
const lw = meta.right - meta.left;
|
|
const lh = meta.bottom - meta.top;
|
|
const count = lw * lh;
|
|
const planes = { 0: new Uint8Array(count), 1: new Uint8Array(count), 2: new Uint8Array(count), '-1': new Uint8Array(count) };
|
|
for (let c = 0; c < meta.channelCount; c++) {
|
|
const channel = meta.channels[c];
|
|
const compression = view.getUint16(offset, false);
|
|
offset += 2;
|
|
if (compression === COMPRESSION_RAW) {
|
|
const plane = bytes.subarray(offset, offset + count);
|
|
if (planes[channel.id]) planes[channel.id].set(plane);
|
|
offset += count;
|
|
} else if (compression === COMPRESSION_RLE) {
|
|
const rowLengths = [];
|
|
for (let y = 0; y < lh; y++) {
|
|
rowLengths.push(psb ? view.getUint32(offset, false) : view.getUint16(offset, false));
|
|
offset += psb ? 4 : 2;
|
|
}
|
|
const plane = planes[channel.id] || new Uint8Array(count);
|
|
for (let y = 0; y < lh; y++) {
|
|
const decoded = unpackBits(bytes, offset, rowLengths[y]);
|
|
plane.set(decoded.subarray(0, lw), y * lw);
|
|
offset += rowLengths[y];
|
|
}
|
|
planes[channel.id] = plane;
|
|
} else {
|
|
throw new Error('Unsupported PSD layer compression');
|
|
}
|
|
}
|
|
const data = new Uint8ClampedArray(count * 4);
|
|
const alpha = planes['-1'];
|
|
for (let i = 0; i < count; i++) {
|
|
data[i * 4] = planes[0][i];
|
|
data[i * 4 + 1] = planes[1][i];
|
|
data[i * 4 + 2] = planes[2][i];
|
|
data[i * 4 + 3] = alpha ? alpha[i] : 255;
|
|
}
|
|
layers.push({
|
|
name: meta.name,
|
|
x: meta.left,
|
|
y: meta.top,
|
|
width: lw,
|
|
height: lh,
|
|
opacity: meta.opacity,
|
|
visible: meta.visible,
|
|
blendKey: meta.blendKey,
|
|
data,
|
|
});
|
|
}
|
|
return { width, height, layers: layers.reverse() };
|
|
}
|
|
|