diff --git a/plugins/aseprite_support/about.md b/plugins/aseprite_support/about.md new file mode 100644 index 00000000..3deb192b --- /dev/null +++ b/plugins/aseprite_support/about.md @@ -0,0 +1,29 @@ +# Aseprite Import/Export + +Import and export `.aseprite` / `.ase` in Blockbench. + +## Import +- `.aseprite` / `.ase` -> Blockbench texture. +- Preserves layers, opacity, blend modes. +- Multiple frames -> flipbook animation. +- Supports RGBA, grayscale, indexed, zlib. + +## Export +- Blockbench texture -> `.aseprite`. +- Preserves layers and animation. +- Merges identical adjacent slots into frames. + +## Usage +- `File -> Import -> Import Aseprite…` +- `File -> Export -> Export as Aseprite…` +- Drag and drop file into window. +- Right-click texture in TEXTURES panel. + +## File link +- Imported texture remembers source `.aseprite`. +- `Save` overwrites source file. +- Saving to another format unlinks. + +## Limitations +- Tilemap layers and groups are skipped. +- Blend modes not in Aseprite become Normal. \ No newline at end of file diff --git a/plugins/aseprite_support/aseprite_support.js b/plugins/aseprite_support/aseprite_support.js new file mode 100644 index 00000000..791b8fac --- /dev/null +++ b/plugins/aseprite_support/aseprite_support.js @@ -0,0 +1,1496 @@ +// aseprite_support.js +// Aseprite Import/Export for Blockbench — .aseprite textures with layers and animation support. +// +// Import: .aseprite / .ase -> Blockbench texture with layers (RGBA / grayscale / indexed, +// raw / linked / zlib-compressed cels). Multiple frames -> flipbook strip: layers and +// the composite become vertical strips, frame durations are matched by duplicating +// frames under a single FPS (dialog), fps + frame_time (50 ms ticks) are written to the texture. +// Export: Blockbench texture (including layered and flipbook strip textures) -> .aseprite 32bpp RGBA, +// cels compressed with zlib, the strip is sliced into slots, identical adjacent slots +// are merged into a single frame with "duplicates x slot" duration (dialog), +// palette from unique colors. +// +// Specification: https://github.com/aseprite/aseprite/blob/main/docs/ase-file-specs.md +// CRITICAL FORMAT RULE: the chunk "size" field includes both its own 4 bytes and the 2 type bytes +// (next chunk = chunkStart + size); the frame "bytes" field includes the whole frame. All little-endian. +// +// The core (parseAseprite / buildAseprite / timing helpers) does not depend on Blockbench and is +// tested in Node (analysis/test_plugin.mjs); the module.exports branch exists only for those tests. +(function () { + 'use strict'; + + // ============================================================ + // Format constants + // ============================================================ + const ASE_MAGIC = 0xA5E0; + const FRAME_MAGIC = 0xF1FA; + const CHUNK = { + LAYER: 0x2004, + CEL: 0x2005, + COLOR_PROFILE: 0x2007, + PALETTE: 0x2019, + }; + + // Aseprite blend modes (0-18) -> Blockbench 5.x layer blend_mode enum values. + // Blockbench layers have no color dodge/burn, hard/soft light, exclusion, hue/saturation/luminosity, + // subtract/divide — they fall back to 'default' (source-over). + const ASE_BLEND_TO_BB = { + 0: 'default', 1: 'multiply', 2: 'screen', 3: 'overlay', + 4: 'darken', 5: 'lighten', 10: 'difference', + 12: 'color', 13: 'color', 14: 'color', 16: 'add', + }; + // Reverse mapping for export. + const BB_BLEND_TO_ASE = { + default: 0, set_opacity: 0, alpha_mask: 0, behind: 0, + multiply: 1, screen: 2, overlay: 3, darken: 4, lighten: 5, + difference: 10, color: 14, add: 16, + }; + // canvas globalCompositeOperation for Blockbench layer blend modes + // (same as Painter.getBlendModeCompositeOperation) — used to build the composite on import. + const BB_BLEND_TO_COMPOSITE = { + default: 'source-over', set_opacity: 'source-atop', color: 'color', + behind: 'destination-over', multiply: 'multiply', add: 'lighter', + darken: 'darken', lighten: 'lighten', screen: 'screen', + overlay: 'overlay', difference: 'difference', + }; + + // Aseprite blend modes (0-18): display names for layer warnings. + const ASE_BLEND_NAMES = { + 0: 'Normal', 1: 'Multiply', 2: 'Screen', 3: 'Overlay', 4: 'Darken', 5: 'Lighten', + 6: 'Color Dodge', 7: 'Color Burn', 8: 'Hard Light', 9: 'Soft Light', 10: 'Difference', + 11: 'Exclusion', 12: 'Hue', 13: 'Saturation', 14: 'Color', 15: 'Luminosity', + 16: 'Addition', 17: 'Subtract', 18: 'Divide', + }; + // Aseprite blend modes Blockbench reproduces without loss (the rest are lossy). + const ASE_BB_EQUIVALENT = [0, 1, 2, 3, 4, 5, 10, 14, 16]; + // Blockbench blend modes that do not exist in Aseprite. + const ASE_UNSUPPORTED_BB_MODES = ['set_opacity', 'alpha_mask', 'behind']; + + const aseOpacityToBB = (a) => Math.max(0, Math.min(100, Math.round((a / 255) * 100))); + const bbOpacityToAse = (o) => Math.max(0, Math.min(255, Math.round((o / 100) * 255))); + + // Originals of Blockbench methods (restored on plugin unload) + let BB_TEXTURE_SAVE = null; + let BB_TEXTURE_FROM_PATH = null; + let BB_LAYER_SELECT = null; + let BB_LAYER_PROPS_DIALOG = null; + let BB_LBM_ONCHANGE = null; + + // ============================================================ + // Blend mode warnings and Aseprite-linked selection restrictions + // ============================================================ + function aseSelectedTextureLinked() { + try { + const l = TextureLayer.selected; + return !!(l && l.texture && l.texture.ase_path); + } catch (e) { return false; } + } + + function findLayerByUuid(uuid) { + try { + for (const tex of Texture.all) { + const l = (tex.layers || []).find(x => x.uuid === uuid); + if (l) return l; + } + } catch (e) { /* no project */ } + return null; + } + + let layerWarningsScheduled = false; + function scheduleLayerWarningsUpdate() { + if (layerWarningsScheduled) return; + layerWarningsScheduled = true; + setTimeout(() => { + layerWarningsScheduled = false; + updateLayerWarningsAndIndicator(); + }, 60); + } + + // Warning icons on layer rows + the restriction indicator next to the blend mode select. + function updateLayerWarningsAndIndicator() { + try { + const list = document.getElementById('layers_list'); + if (list) { + for (const row of list.querySelectorAll('.texture_layer[layer_id]')) { + const layer = findLayerByUuid(row.getAttribute('layer_id')); + let show = false; + let tip = ''; + if (layer) { + if (layer.ase_blend_warning && layer.blend_mode !== 'default') { + layer.ase_blend_warning = ''; // user picked a supported mode + } + if (layer.ase_blend_warning && layer.blend_mode === 'default') { + show = true; + tip = 'The original Aseprite blend mode — "' + layer.ase_blend_warning + + '" — is not supported by Blockbench; the layer renders as Normal. ' + + 'This warning disappears once you pick a different blend mode.'; + } + } + let icon = row.querySelector(':scope > .ase_blend_warning_icon'); + if (show && !icon) { + icon = document.createElement('div'); + icon.className = 'ase_blend_warning_icon'; + icon.appendChild(Blockbench.getIconNode('warning')); + icon.style.cssText = 'display:inline-flex;align-items:center;color:#ffbe3d;margin-left:6px;cursor:help;flex-shrink:0;'; + row.appendChild(icon); + } + if (icon) icon.title = tip; + if (!show && icon) icon.remove(); + } + } + } catch (e) { /* layers panel unavailable */ } + // "This is an Aseprite texture" badge on TEXTURES panel rows + try { + const list = document.getElementById('texture_list'); + if (list) { + for (const row of list.querySelectorAll('li.texture[texid]')) { + const tex = (typeof Project !== 'undefined' && Project.textures || []).find(t => t.uuid === row.getAttribute('texid')); + const linked = !!(tex && tex.ase_path); + let badge = row.querySelector(':scope > .ase_texture_badge'); + if (linked && !badge) { + badge = aseIconNode('logo', '#D9D9D9'); + badge.classList.add('ase_texture_badge'); + badge.style.cssText = 'position:absolute;bottom:2px;width:16px;height:16px;z-index:1;' + + 'filter:drop-shadow(0 0 2px rgba(0,0,0,0.9));'; + badge.title = 'Texture linked to Aseprite: saving writes to the source .aseprite'; + row.appendChild(badge); + } + if (badge) { + const wrapper = row.querySelector('.texture_icon_wrapper'); + if (wrapper) badge.style.left = (wrapper.offsetLeft + wrapper.offsetWidth - 18) + 'px'; + badge.title = 'Texture linked to Aseprite: saving writes to the source .aseprite'; + } + if (!linked && badge) badge.remove(); + } + } + } catch (e) { /* textures panel unavailable */ } + try { + const lbm = BarItems.layer_blend_mode; + const linked = aseSelectedTextureLinked(); + let lock = lbm.node.querySelector(':scope > .ase_mode_lock'); + if (linked && !lock) { + lock = document.createElement('div'); + lock.className = 'ase_mode_lock'; + lock.appendChild(Blockbench.getIconNode('warning')); + lock.style.cssText = 'display:inline-flex;align-items:center;color:#ffbe3d;margin-left:5px;cursor:help;'; + lock.title = 'This texture is saved as .aseprite, so only Aseprite-supported modes are available: ' + + 'Normal, Multiply, Screen, Overlay, Darken, Lighten, Difference, Color, Addition. ' + + 'The set_opacity and alpha_mask modes become available once the file is saved as an image.'; + lbm.node.appendChild(lock); + } else if (!linked && lock) { + lock.remove(); + } + } catch (e) { /* widget unavailable */ } + } + + // ============================================================ + // Icons (user SVG art, adapted: square viewBox with padding — + // fits Blockbench's square 26px icon slots; fill #D9D9D9 = dark theme text color) + // ============================================================ + const ASE_ICON_ART = { + logo: '', + import: '', + export: '', + }; + // Square viewBox with padding around the artwork (content -> center) + const ASE_ICON_VIEWBOX = { + logo: '-20.5 -16 400 400', + import: '-32 -76 520 520', + export: '-36.5 -106 580 580', + }; + function aseIconDataUrl(kind) { + const svg = '' + ASE_ICON_ART[kind] + ''; + return 'data:image/svg+xml,' + encodeURIComponent(svg); + } + // Inline SVG node: with fill="currentColor" the icon inherits the exact CSS color + // that native Blockbench icons use (white in menus, theme color in toolbars) — never darkens. + // IMPORTANT: returns a div wrapper — getIconNode checks `instanceof HTMLElement`, + // and SVGElement is not one (otherwise that branch falls into icon.match and breaks plugin loading). + function aseIconNode(kind, fixedFill) { + const NS = 'http://www.w3.org/2000/svg'; + const wrap = document.createElement('div'); + const svg = document.createElementNS(NS, 'svg'); + svg.setAttribute('viewBox', ASE_ICON_VIEWBOX[kind]); + svg.setAttribute('fill', fixedFill || 'currentColor'); + svg.innerHTML = ASE_ICON_ART[kind]; + wrap.appendChild(svg); + wrap.classList.add('ase_icon'); + return wrap; + } + + // ============================================================ + // Binary writer + // ============================================================ + class BinaryWriter { + constructor(initial = 4096) { + this.buffer = new ArrayBuffer(initial); + this.view = new DataView(this.buffer); + this.bytes = new Uint8Array(this.buffer); + this.offset = 0; + } + _ensure(n) { + if (this.offset + n <= this.buffer.byteLength) return; + let size = this.buffer.byteLength * 2; + while (size < this.offset + n) size *= 2; + const nb = new ArrayBuffer(size); + new Uint8Array(nb).set(this.bytes); + this.buffer = nb; + this.view = new DataView(nb); + this.bytes = new Uint8Array(nb); + } + writeBYTE(x) { this._ensure(1); this.view.setUint8(this.offset, x & 0xFF); this.offset += 1; } + writeWORD(x) { this._ensure(2); this.view.setUint16(this.offset, x & 0xFFFF, true); this.offset += 2; } + writeSHORT(x) { this._ensure(2); this.view.setInt16(this.offset, x, true); this.offset += 2; } + writeDWORD(x) { this._ensure(4); this.view.setUint32(this.offset, x >>> 0, true); this.offset += 4; } + writeBytes(arr) { + this._ensure(arr.length); + this.bytes.set(arr, this.offset); + this.offset += arr.length; + } + writeZeros(n) { this._ensure(n); this.bytes.fill(0, this.offset, this.offset + n); this.offset += n; } + writeString(str) { + const b = new TextEncoder().encode(String(str)); + this.writeWORD(b.length); + this.writeBytes(b); + } + patchDWORD(absoluteOffset, value) { this.view.setUint32(absoluteOffset, value >>> 0, true); } + patchWORD(absoluteOffset, value) { this.view.setUint16(absoluteOffset, value & 0xFFFF, true); } + length() { return this.offset; } + getBuffer() { return this.buffer.slice(0, this.offset); } + } + + // ============================================================ + // zlib (cel type 2). 'deflate' in the Streams API = RFC1950 = zlib, as the spec requires + // ============================================================ + async function inflateZlib(bytes) { + if (typeof DecompressionStream === 'undefined') { + throw new Error('DecompressionStream is not supported in this environment'); + } + const stream = new DecompressionStream('deflate'); + const writer = stream.writable.getWriter(); + writer.write(bytes); + writer.close(); + const reader = stream.readable.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + const out = new Uint8Array(total); + let p = 0; + for (const c of chunks) { out.set(c, p); p += c.length; } + return out; + } + + async function deflateZlib(bytes) { + if (typeof CompressionStream === 'undefined') { + throw new Error('CompressionStream is not supported in this environment'); + } + const stream = new CompressionStream('deflate'); + const writer = stream.writable.getWriter(); + writer.write(bytes); + writer.close(); + const reader = stream.readable.getReader(); + const chunks = []; + let total = 0; + for (;;) { + const { done, value } = await reader.read(); + if (done) break; + chunks.push(value); + total += value.length; + } + const out = new Uint8Array(total); + let p = 0; + for (const c of chunks) { out.set(c, p); p += c.length; } + return out; + } + + // ============================================================ + // .aseprite parser -> { width, height, frames, layers: [{name, visible, opacity(0-100), blend_mode, pixels}] } + // pixels — Uint8ClampedArray width*height*4 (RGBA), the full canvas. + // ============================================================ + async function parseAseprite(input) { + let ab; + if (input instanceof ArrayBuffer) ab = input; + else if (ArrayBuffer.isView(input)) { + ab = input.buffer.slice(input.byteOffset, input.byteOffset + input.byteLength); + } else throw new Error('parseAseprite: expected ArrayBuffer or Uint8Array'); + + const view = new DataView(ab); + const u8 = new Uint8Array(ab); + + if (ab.byteLength < 128) throw new Error('File is smaller than 128 bytes — not an .aseprite file'); + const magic = view.getUint16(4, true); + if (magic !== ASE_MAGIC) throw new Error('Not an Aseprite file (magic 0x' + magic.toString(16) + ')'); + + const numFrames = view.getUint16(6, true) || 1; + const width = view.getUint16(8, true); + const height = view.getUint16(10, true); + const bpp = view.getUint16(12, true); + if (!width || !height) throw new Error('Invalid sprite size'); + if (bpp !== 32 && bpp !== 16 && bpp !== 8) throw new Error('Unsupported color depth: ' + bpp + ' bpp'); + const transparentIndex = view.getUint8(28); + + const allLayers = []; // all LAYER chunks in order (index = cel layerIndex) + const cels = new Map(); // "frame:layerIndex" -> {type, x, y, opacity, dataOffset, ...} + const palette = []; // index -> [r,g,b,a] + const frameDur = []; // frame duration, ms + + let off = 128; + for (let frame = 0; frame < numFrames; frame++) { + if (off + 16 > ab.byteLength) break; + const frameSize = view.getUint32(off, true); + if (view.getUint16(off + 4, true) !== FRAME_MAGIC) { + throw new Error('Frame ' + frame + ': invalid frame magic'); + } + frameDur.push(view.getUint16(off + 8, true)); + const oldCount = view.getUint16(off + 6, true); + const chunkCount = oldCount === 0xFFFF ? view.getUint32(off + 12, true) : (oldCount || view.getUint32(off + 12, true)); + + let co = off + 16; + for (let c = 0; c < chunkCount; c++) { + if (co + 6 > ab.byteLength) break; + const size = view.getUint32(co, true); + const type = view.getUint16(co + 4, true); + if (size < 6 || co + size > ab.byteLength) { + throw new Error('Frame ' + frame + ': chunk extends past end of file (offset ' + co + ', size ' + size + ')'); + } + const d = co + 6; + + if (type === CHUNK.LAYER) { + allLayers.push(readLayerChunk(view, u8, d)); + } else if (type === CHUNK.CEL) { + const cel = { + type: view.getUint16(d + 7, true), + layerIndex: view.getUint16(d, true), + x: view.getInt16(d + 2, true), + y: view.getInt16(d + 4, true), + opacity: view.getUint8(d + 6), + dataOffset: d, + chunkEnd: co + size, + frame, + }; + if (cel.type === 1) cel.linkFrame = view.getUint16(d + 16, true); + cels.set(cel.frame + ':' + cel.layerIndex, cel); + } else if (type === CHUNK.PALETTE) { + readPaletteChunk(view, u8, d, co + size, palette); + } + // Other chunks (color profile, tags, user data, slices, ...) are skipped. + co += size; + } + if (frameSize < 16 || off + frameSize > ab.byteLength) break; + off += frameSize; + } + + // Blockbench layers — only regular pixel layers (groups are flattened, tilemap layers skipped). + const pixelLayers = []; + allLayers.forEach((entry, index) => { + if (entry.type === 1) return; // group — imported flat for now + if (entry.type === 2) { + console.warn('[aseprite_support] tilemap layer "' + entry.name + '" skipped'); + return; + } + pixelLayers.push({ entry, aseIndex: index }); + }); + + // Pixels of every frame per layer: framesByLayer[frame][layer] -> full RGBA canvas. + const framesByLayer = []; + for (let f = 0; f < numFrames; f++) { + const perLayer = []; + for (const pl of pixelLayers) { + const pixels = new Uint8ClampedArray(width * height * 4); + const cel = resolveCel(cels, f, pl.aseIndex); + if (cel) { + const decoded = await decodeCel(cel, view, u8, ab, width, height, bpp, palette, transparentIndex); + blitCel(pixels, decoded, width, height); + } + perLayer.push(pixels); + } + framesByLayer.push(perLayer); + } + + // layers — layer metadata + frame 1 pixels (backwards compatible with the old API). + const layers = pixelLayers.map((pl, j) => ({ + name: pl.entry.name, + visible: pl.entry.visible, + opacity: aseOpacityToBB(pl.entry.opacity), + blend_mode: ASE_BLEND_TO_BB[pl.entry.blend] || 'default', + aseBlend: pl.entry.blend, + aseBlendName: ASE_BLEND_NAMES[pl.entry.blend] || 'Normal', + pixels: framesByLayer[0][j], + })); + + return { + width, height, + frames: numFrames, + frameDurations: frameDur.length === numFrames ? frameDur : frameDur.concat(new Array(numFrames - frameDur.length).fill(100)), + bpp, + layers, + framesByLayer, + }; + } + + function readLayerChunk(view, u8, d) { + const flags = view.getUint16(d, true); + return { + flags, + visible: (flags & 1) !== 0, + type: view.getUint16(d + 2, true), + childLevel: view.getUint16(d + 4, true), + blend: view.getUint16(d + 10, true), + opacity: view.getUint8(d + 12), + name: readString(view, u8, d + 16) || 'Layer', + }; + } + + function readString(view, u8, d) { + const len = view.getUint16(d, true); + if (d + 2 + len > u8.length) return ''; + return new TextDecoder().decode(u8.subarray(d + 2, d + 2 + len)); + } + + function readPaletteChunk(view, u8, d, end, palette) { + const from = view.getUint32(d + 4, true); + const to = view.getUint32(d + 8, true); + let p = d + 20; + for (let i = from; i <= to && p + 6 <= end; i++) { + const flags = view.getUint16(p, true); + const entry = [view.getUint8(p + 2), view.getUint8(p + 3), view.getUint8(p + 4), view.getUint8(p + 5)]; + palette[i] = entry; + p += 6; + if (flags & 1) { + if (p + 2 > end) break; + p += 2 + view.getUint16(p, true); + } + } + } + + // Resolving a linked cel (type 1) to a cel of another frame of the same layer. + function resolveCel(cels, frame, layerIndex, seen = new Set()) { + const key = frame + ':' + layerIndex; + if (seen.has(key)) return null; + seen.add(key); + const cel = cels.get(key); + if (!cel) return null; + if (cel.type === 1) return resolveCel(cels, cel.linkFrame, layerIndex, seen); + if (cel.type !== 0 && cel.type !== 2) return null; + return cel; + } + + async function decodeCel(cel, view, u8, ab, spriteW, spriteH, bpp, palette, transparentIndex) { + const bytesPP = bpp / 8; + let cw, ch, raw; + if (cel.type === 0) { + cw = spriteW; ch = spriteH; + const need = cw * ch * bytesPP; + const avail = Math.max(0, Math.min(need, cel.chunkEnd - (cel.dataOffset + 16))); + raw = decodePixels(u8.subarray(cel.dataOffset + 16, cel.dataOffset + 16 + avail), avail, bpp, palette, transparentIndex); + } else { + cw = view.getUint16(cel.dataOffset + 16, true); + ch = view.getUint16(cel.dataOffset + 18, true); + const compStart = cel.dataOffset + 20; + const compEnd = cel.chunkEnd; + if (cw === 0 || ch === 0 || compEnd <= compStart) { + return { x: cel.x, y: cel.y, w: cw, h: ch, pixels: new Uint8ClampedArray(0) }; + } + const comp = u8.subarray(compStart, compEnd); + const inflated = await inflateZlib(comp); + const expected = cw * ch * bytesPP; + raw = decodePixels(inflated, Math.min(inflated.length, expected), bpp, palette, transparentIndex); + } + // cel opacity is baked into the alpha channel (Aseprite effect = layer.opacity * cel.opacity) + if (cel.opacity < 255) { + const k = cel.opacity / 255; + for (let i = 3; i < raw.length; i += 4) raw[i] = Math.round(raw[i] * k); + } + return { x: cel.x, y: cel.y, w: cw, h: ch, pixels: raw }; + } + + function decodePixels(bytes, byteLength, bpp, palette, transparentIndex) { + const out = new Uint8ClampedArray(byteLength && (byteLength / (bpp / 8)) * 4); + if (bpp === 32) { + for (let p = 0; p < out.length / 4; p++) { + out[p * 4] = bytes[p * 4]; + out[p * 4 + 1] = bytes[p * 4 + 1]; + out[p * 4 + 2] = bytes[p * 4 + 2]; + out[p * 4 + 3] = bytes[p * 4 + 3]; + } + } else if (bpp === 16) { + // grayscale: value byte + alpha byte + for (let p = 0; p < out.length / 4; p++) { + const v = bytes[p * 2]; + out[p * 4] = v; out[p * 4 + 1] = v; out[p * 4 + 2] = v; + out[p * 4 + 3] = bytes[p * 2 + 1]; + } + } else { + // indexed: palette index; transparentIndex = fully transparent pixel + for (let p = 0; p < out.length / 4; p++) { + const idx = bytes[p]; + const entry = (idx === transparentIndex) ? null : (palette[idx] || null); + if (!entry) { out[p * 4 + 3] = 0; continue; } + out[p * 4] = entry[0]; + out[p * 4 + 1] = entry[1]; + out[p * 4 + 2] = entry[2]; + out[p * 4 + 3] = entry[3]; + } + } + return out; + } + + function blitCel(target, cel, targetW, targetH) { + for (let y = 0; y < cel.h; y++) { + const dy = cel.y + y; + if (dy < 0 || dy >= targetH) continue; + for (let x = 0; x < cel.w; x++) { + const dx = cel.x + x; + if (dx < 0 || dx >= targetW) continue; + const si = (y * cel.w + x) * 4; + const di = (dy * targetW + dx) * 4; + target[di] = cel.pixels[si]; + target[di + 1] = cel.pixels[si + 1]; + target[di + 2] = cel.pixels[si + 2]; + target[di + 3] = cel.pixels[si + 3]; + } + } + } + + // ============================================================ + // .aseprite builder: { width, height, layers: [{name, visible, opacity(0-100), blend_mode, pixels}] } + // pixels — Uint8ClampedArray width*height*4. Returns ArrayBuffer. + // ============================================================ + async function buildAseprite(sprite) { + const width = sprite.width, height = sprite.height; + if (!width || !height) throw new Error('buildAseprite: size is not set'); + const frameList = (Array.isArray(sprite.frames) && sprite.frames.length) + ? sprite.frames + : [{ duration: 100, layers: sprite.layers || [] }]; + const metaLayers = frameList[0].layers || []; + + const w = new BinaryWriter(64 * 1024); + + // ---- File header (128 bytes) ---- + w.writeDWORD(0); // 0 file size (patched) + w.writeWORD(ASE_MAGIC); // 4 + w.writeWORD(frameList.length); // 6 frames + w.writeWORD(width); // 8 + w.writeWORD(height); // 10 + w.writeWORD(32); // 12 RGBA + w.writeDWORD(1); // 14 flags: layer opacity is valid + w.writeWORD(100); // 18 speed (deprecated) + w.writeDWORD(0); // 20 + w.writeDWORD(0); // 24 + w.writeBYTE(0); // 28 transparent palette entry + w.writeZeros(3); // 29 + w.writeWORD(0); // 32 color count (patched from palette) + w.writeBYTE(1); w.writeBYTE(1); // 34 pixel ratio + w.writeSHORT(0); // 36 grid x + w.writeSHORT(0); // 38 grid y + w.writeWORD(16); // 40 grid w + w.writeWORD(16); // 42 grid h + w.writeZeros(84); // 44 + + // ---- Palette from unique colors across all frames ---- + const colors = collectColors(frameList.reduce((acc, fr) => acc.concat(fr.layers || []), [])); + + // ---- Frames ---- + const frameStarts = []; + const frameChunkCounts = []; + for (let f = 0; f < frameList.length; f++) { + const frameStart = w.length(); + frameStarts.push(frameStart); + + // Frame header (16 bytes) + w.writeDWORD(0); // frame bytes (patched; includes the whole frame) + w.writeWORD(FRAME_MAGIC); + w.writeWORD(0); // old chunk count (patched) + w.writeWORD(Math.max(0, Math.min(65535, Math.round(frameList[f].duration ?? 100)))); // duration, ms + w.writeZeros(2); + w.writeDWORD(0); // new chunk count (patched) + + let chunkCount = 0; + if (f === 0) { + // Color profile (sRGB, same as Aseprite itself writes) + w.writeDWORD(22); + w.writeWORD(CHUNK.COLOR_PROFILE); + w.writeWORD(1); w.writeWORD(0); w.writeDWORD(0); w.writeZeros(8); + chunkCount++; + + // Palette (not required for RGBA, but improves compatibility) + if (colors.length > 0 && colors.length <= 256) { + const start = w.length(); + w.writeDWORD(0); + w.writeWORD(CHUNK.PALETTE); + w.writeDWORD(colors.length); + w.writeDWORD(0); + w.writeDWORD(colors.length - 1); + w.writeZeros(8); + for (const c of colors) { + w.writeWORD(0); + w.writeBYTE(c[0]); w.writeBYTE(c[1]); w.writeBYTE(c[2]); w.writeBYTE(c[3]); + } + w.patchDWORD(start, w.length() - start); + w.patchWORD(32, colors.length); + chunkCount++; + } + + // Layer chunks (bottom -> top, same as the array order) + for (const l of metaLayers) { + const start = w.length(); + w.writeDWORD(0); + w.writeWORD(CHUNK.LAYER); + w.writeWORD(((l.visible === false) ? 0 : 1) | 2); // visible + editable + w.writeWORD(0); // type: normal + w.writeWORD(0); // child level + w.writeWORD(0); w.writeWORD(0); // default w/h + w.writeWORD(BB_BLEND_TO_ASE[l.blend_mode] ?? 0); + w.writeBYTE(bbOpacityToAse(l.opacity)); + w.writeZeros(3); + w.writeString(l.name || 'Layer'); + // The chunk size includes its own 4 size bytes and the 2 type bytes — the critical v2 fix. + w.patchDWORD(start, w.length() - start); + chunkCount++; + } + } + + // Cel chunks (type 2, zlib; an empty layer gets no cel in this frame) + const layersF = frameList[f].layers || metaLayers; + for (let i = 0; i < layersF.length; i++) { + const l = layersF[i]; + const bbox = alphaBBox(l.pixels, width, height); + if (!bbox) continue; + const cw = bbox.x1 - bbox.x0 + 1; + const ch = bbox.y1 - bbox.y0 + 1; + const raw = new Uint8Array(cw * ch * 4); + for (let y = 0; y < ch; y++) { + for (let x = 0; x < cw; x++) { + const si = ((bbox.y0 + y) * width + (bbox.x0 + x)) * 4; + const di = (y * cw + x) * 4; + raw[di] = l.pixels[si]; + raw[di + 1] = l.pixels[si + 1]; + raw[di + 2] = l.pixels[si + 2]; + raw[di + 3] = l.pixels[si + 3]; + } + } + const comp = await deflateZlib(raw); + const start = w.length(); + w.writeDWORD(0); + w.writeWORD(CHUNK.CEL); + w.writeWORD(i); // layer index + w.writeSHORT(bbox.x0); + w.writeSHORT(bbox.y0); + w.writeBYTE(255); // cel opacity + w.writeWORD(2); // type: compressed image + w.writeSHORT(0); // z-index + w.writeZeros(5); + w.writeWORD(cw); w.writeWORD(ch); + w.writeBytes(comp); + w.patchDWORD(start, w.length() - start); + chunkCount++; + } + + frameChunkCounts.push(chunkCount); + } + + // ---- Size patches (every size includes its own field) ---- + for (let f = 0; f < frameStarts.length; f++) { + const end = (f + 1 < frameStarts.length) ? frameStarts[f + 1] : w.length(); + w.patchDWORD(frameStarts[f], end - frameStarts[f]); + const count = Math.min(frameChunkCounts[f], 0xFFFF); + w.patchWORD(frameStarts[f] + 6, count); + w.patchDWORD(frameStarts[f] + 12, count); + } + w.patchDWORD(0, w.length()); + + return w.getBuffer(); + } + + function collectColors(layers) { + const counts = new Map(); + for (const l of layers) { + const px = l.pixels; + if (!px) continue; + for (let p = 0; p < px.length; p += 4) { + if (px[p + 3] === 0) continue; + const key = px[p] + ',' + px[p + 1] + ',' + px[p + 2] + ',' + px[p + 3]; + counts.set(key, (counts.get(key) || 0) + 1); + } + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 256) + .map(([key]) => key.split(',').map(Number)); + } + + // Bounding box of non-transparent area; null if the layer is empty. + function alphaBBox(pixels, width, height) { + let x0 = width, y0 = height, x1 = -1, y1 = -1; + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + if (pixels[(y * width + x) * 4 + 3] !== 0) { + if (x < x0) x0 = x; + if (x > x1) x1 = x; + if (y < y0) y0 = y; + if (y > y1) y1 = y; + } + } + } + return x1 < 0 ? null : { x0, y0, x1, y1 }; + } + + // ============================================================ + // Aseprite <-> flipbook timing conversion + // ============================================================ + function gcd2(a, b) { return b === 0 ? a : gcd2(b, a % b); } + + // The FPS at which every frame duration is a multiple of the slot (GCD of durations). + function suggestedFps(durations) { + if (!Array.isArray(durations) || !durations.length) return 10; + let g = Math.max(1, Math.round(durations[0])); + for (const d of durations) g = gcd2(g, Math.max(1, Math.round(d))); + if (!g || g < 10) return 10; + return Math.max(1, Math.min(60, Math.round(1000 / g))); + } + + // How many flipbook slots each Aseprite frame takes at the given FPS. + function computeFrameCopies(durations, fps) { + const slotMs = 1000 / Math.max(1, fps); + return durations.map(d => Math.max(1, Math.round(d / slotMs))); + } + + // Whether two slots are identical (arrays of layer pixel arrays). + function slotsAreIdentical(a, b) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + const x = a[i], y = b[i]; + if (x === y) continue; + if (!x || !y || x.length !== y.length) return false; + for (let p = 0; p < x.length; p++) if (x[p] !== y[p]) return false; + } + return true; + } + + // Slots -> runs of identical neighbors: [{start, count}] (for merging duplicates into .aseprite frames). + function mergeConsecutiveSlots(slots) { + const runs = []; + for (let s = 0; s < slots.length; s++) { + const last = runs[runs.length - 1]; + if (last && slotsAreIdentical(slots[last.start], slots[s])) last.count++; + else runs.push({ start: s, count: 1 }); + } + return runs; + } + + // ============================================================ + // Blockbench: import + // ============================================================ + function importIntoBlockbench(parsed, suggestedName, sourcePath) { + const { width, height, layers } = parsed; + if (!width || !height || !layers.length) { + Blockbench.showQuickMessage('Aseprite: the file has no pixel layers', 4000); + return; + } + + // Composite for source: same operations as Blockbench's updateLayerChanges. + const composite = document.createElement('canvas'); + composite.width = width; + composite.height = height; + const cctx = composite.getContext('2d'); + const scratch = document.createElement('canvas'); + scratch.width = width; + scratch.height = height; + const sctx = scratch.getContext('2d'); + for (const l of layers) { + if (l.visible === false || l.opacity === 0) continue; + sctx.clearRect(0, 0, width, height); + sctx.putImageData(new ImageData(new Uint8ClampedArray(l.pixels), width, height), 0, 0); + cctx.save(); + cctx.globalAlpha = l.opacity / 100; + cctx.globalCompositeOperation = BB_BLEND_TO_COMPOSITE[l.blend_mode] || 'source-over'; + cctx.drawImage(scratch, 0, 0); + cctx.restore(); + } + + const texture = new Texture({ + name: suggestedName, + width, + height, + source: composite.toDataURL('image/png'), + }); + texture.layers_enabled = true; + if (sourcePath) texture.ase_path = sourcePath; // remember the source .aseprite for the save button + + for (const l of layers) { + const layer = new TextureLayer({ + name: l.name, + visible: l.visible !== false, + opacity: l.opacity, + blend_mode: l.blend_mode, + }, texture); + if (!texture.layers.includes(layer)) texture.layers.push(layer); + if (l.aseBlend !== undefined && !ASE_BB_EQUIVALENT.includes(l.aseBlend)) { + layer.ase_blend_warning = l.aseBlendName || 'Normal'; + } + if (typeof layer.setSize === 'function') layer.setSize(width, height); + else { layer.canvas.width = width; layer.canvas.height = height; } + layer.ctx.putImageData(new ImageData(new Uint8ClampedArray(l.pixels), width, height), 0, 0); + } + + texture.add(false, true); + try { texture.load(); } catch (e) { /* source is already a data URL */ } + texture.updateLayerChanges(true); + if (typeof texture.select === 'function') texture.select(); + if (typeof Canvas !== 'undefined') { + if (typeof Canvas.updateAllUVs === 'function') Canvas.updateAllUVs(); + if (typeof Canvas.updateAllFaces === 'function') Canvas.updateAllFaces(texture); + } + + Blockbench.showQuickMessage( + 'Aseprite: imported ' + width + '×' + height + + ', layers: ' + layers.length + + (parsed.frames > 1 ? ' (frame 1 of ' + parsed.frames + ')' : ''), 4000); + } + + // Timing adaptation dialog: Aseprite frames -> flipbook slots. + function showImportAnimationDialog(parsed, suggestedName, sourcePath) { + const durations = parsed.frameDurations; + const min = Math.min.apply(null, durations); + const max = Math.max.apply(null, durations); + const total = durations.reduce((a, b) => a + b, 0); + new Dialog({ + id: 'aseprite_support_import_anim', + title: 'Aseprite: animation, ' + parsed.frames + ' frames', + width: 470, + form: { + info: { type: 'info', text: 'Frame durations: ' + min + '–' + max + ' ms, total ' + total + ' ms. ' + + 'The Blockbench flipbook uses a single FPS for the whole animation — durations are matched by duplicating frames. ' + + 'The default FPS is chosen so the timing matches exactly.' }, + mode: { type: 'select', label: 'Frame duration matching', value: 'duplicate', options: { + duplicate: 'Duplicate frames to match the FPS (exact timing)', + uniform: 'Each frame = 1 slot, ignore durations', + }}, + fps: { type: 'number', label: 'Flipbook FPS (1 sec = 20 ticks)', value: suggestedFps(durations), min: 1, max: 60, step: 1 }, + }, + onConfirm(result) { + buildAnimatedTexture(parsed, suggestedName, Math.max(1, Math.round(result.fps || 10)), result.mode === 'duplicate', sourcePath); + }, + }).show(); + } + + // Animated import: every layer becomes a vertical frame strip, + // texture.fps/frame_time set the flipbook speed (java-format preview runs on frame_time in 50 ms ticks). + function buildAnimatedTexture(parsed, suggestedName, fps, useDurations, sourcePath) { + const W = parsed.width, H = parsed.height; + let copies = useDurations + ? computeFrameCopies(parsed.frameDurations, fps) + : new Array(parsed.frames).fill(1); + let slots = copies.reduce((a, b) => a + b, 0); + if (H * slots > 16384) { // canvas height limit + copies = new Array(parsed.frames).fill(1); + slots = parsed.frames; + if (H * slots > 16384) { + Blockbench.showQuickMessage('Animation is too long for a flipbook (' + slots + ' slots) — import cancelled', 5000); + return; + } + Blockbench.showQuickMessage('Strip would be too tall — frames were not duplicated', 4000); + } + + // Strip of every layer: frames stacked vertically (as Blockbench stores them) + const strips = parsed.layers.map(() => { + const c = document.createElement('canvas'); + c.width = W; c.height = H * slots; + return c; + }); + let slot = 0; + for (let f = 0; f < parsed.frames; f++) { + for (let c = 0; c < copies[f]; c++) { + for (let j = 0; j < parsed.layers.length; j++) { + strips[j].getContext('2d').putImageData( + new ImageData(new Uint8ClampedArray(parsed.framesByLayer[f][j]), W, H), 0, slot * H); + } + slot++; + } + } + + // Composite strip for source (same operations as updateLayerChanges) + const composite = document.createElement('canvas'); + composite.width = W; composite.height = H * slots; + const cctx = composite.getContext('2d'); + for (let j = 0; j < parsed.layers.length; j++) { + const l = parsed.layers[j]; + if (l.visible === false || l.opacity === 0) continue; + cctx.save(); + cctx.globalAlpha = l.opacity / 100; + cctx.globalCompositeOperation = BB_BLEND_TO_COMPOSITE[l.blend_mode] || 'source-over'; + cctx.drawImage(strips[j], 0, 0); + cctx.restore(); + } + + const texture = new Texture({ + name: suggestedName, + width: W, height: H * slots, + uv_width: W, uv_height: H, + source: composite.toDataURL('image/png'), + fps: fps, + frame_time: Math.max(1, Math.round(20 / fps)), // 50 ms ticks + frame_order_type: 'loop', + }); + texture.layers_enabled = true; + if (sourcePath) texture.ase_path = sourcePath; // remember the source .aseprite for the save button + for (let j = 0; j < parsed.layers.length; j++) { + const l = parsed.layers[j]; + const layer = new TextureLayer({ + name: l.name, visible: l.visible !== false, opacity: l.opacity, blend_mode: l.blend_mode, + }, texture); + if (!texture.layers.includes(layer)) texture.layers.push(layer); + if (l.aseBlend !== undefined && !ASE_BB_EQUIVALENT.includes(l.aseBlend)) { + layer.ase_blend_warning = l.aseBlendName || 'Normal'; + } + if (typeof layer.setSize === 'function') layer.setSize(W, H * slots); + else { layer.canvas.width = W; layer.canvas.height = H * slots; } + layer.ctx.drawImage(strips[j], 0, 0); + } + texture.add(false, true); + try { texture.load(); } catch (e) { /* source is already a data URL */ } + texture.updateLayerChanges(true); + if (typeof texture.select === 'function') texture.select(); + if (typeof Canvas !== 'undefined') { + if (typeof Canvas.updateAllUVs === 'function') Canvas.updateAllUVs(); + if (typeof Canvas.updateAllFaces === 'function') Canvas.updateAllFaces(texture); + } + Blockbench.showQuickMessage('Aseprite: ' + parsed.frames + ' frames → ' + slots + ' slots @ ' + fps + ' fps, layers: ' + parsed.layers.length, 4500); + } + + async function importFiles(files) { + for (const file of files) { + try { + const parsed = await parseAseprite(file.buffer || file.content); + const name = (file.name || 'aseprite').replace(/\.(aseprite|ase)$/i, ''); + const sourcePath = file.path || ''; + if (parsed.frames > 1) showImportAnimationDialog(parsed, name, sourcePath); + else importIntoBlockbench(parsed, name, sourcePath); + } catch (err) { + console.error('[aseprite_support] import error:', err); + Blockbench.showQuickMessage('Aseprite import error: ' + err.message, 5000); + } + } + } + + // ============================================================ + // Blockbench: export + // ============================================================ + // Collect texture data: size, flipbook slots, layer metadata and baked canvases. + function collectTextureData(texture) { + const width = texture.width || texture.canvas?.width; + const fullH = texture.height || texture.canvas?.height; + if (!width || !fullH) return { error: 'Texture size is unknown' }; + // Flipbook: frame height = uv_height, slot count = strip height / frame height + const uvH = texture.uv_height; + const frameH = (uvH && uvH > 0 && uvH < fullH && fullH % uvH === 0) ? uvH : fullH; + const slots = fullH / frameH; + + const layersMeta = []; + const bakedCanvases = []; + const unsupportedModes = []; + const texCtx = texture.ctx || texture.canvas?.getContext('2d'); + + if (texture.layers_enabled && Array.isArray(texture.layers) && texture.layers.length) { + for (const l of texture.layers) { + if (l.type && l.type !== 'pixel_layer') continue; // groups are not exported + if (ASE_UNSUPPORTED_BB_MODES.includes(l.blend_mode) && !unsupportedModes.includes(l.blend_mode)) { + unsupportedModes.push(l.blend_mode); + } + const c = document.createElement('canvas'); + c.width = width; c.height = fullH; + const ctx = c.getContext('2d'); + const lw = Math.round((l.canvas?.width || 0) * (l.scale ? l.scale[0] : 1)); + const lh = Math.round((l.canvas?.height || 0) * (l.scale ? l.scale[1] : 1)); + if (l.canvas && lw > 0 && lh > 0) { + ctx.drawImage(l.canvas, (l.offset ? l.offset[0] : 0), (l.offset ? l.offset[1] : 0), lw, lh); + } + layersMeta.push({ + name: l.name || 'Layer', + visible: l.visible !== false, + opacity: Math.max(0, Math.min(100, l.opacity ?? 100)), + blend_mode: l.blend_mode || 'default', + }); + bakedCanvases.push(c); + } + } else if (texCtx && texture.canvas) { + const c = document.createElement('canvas'); + c.width = width; c.height = fullH; + c.getContext('2d').drawImage(texture.canvas, 0, 0); + layersMeta.push({ name: texture.name || 'Background', visible: true, opacity: 100, blend_mode: 'default' }); + bakedCanvases.push(c); + } + if (!layersMeta.length) return { error: 'Texture has no pixels to export' }; + return { width, fullH, frameH, slots, layersMeta, bakedCanvases, unsupportedModes }; + } + + // Build the .aseprite ArrayBuffer from a texture. mergeDuplicates — merge identical adjacent slots. + async function buildAsepriteFromTexture(texture, mergeDuplicates) { + const data = collectTextureData(texture); + if (data.error) throw new Error(data.error); + if (data.slots > 1) { + const ft = Math.max(1, Math.round(texture.frame_time || 1)); + const fpsDef = Math.max(1, Math.round(texture.fps || 10)); + const slotMs = (typeof Format !== 'undefined' && Format && Format.texture_mcmeta && (texture.frame_time || 1) > 1) + ? 50 * ft + : 1000 / fpsDef; + const slotPixels = []; + for (let s = 0; s < data.slots; s++) { + const perLayer = []; + for (let j = 0; j < data.bakedCanvases.length; j++) { + perLayer.push(data.bakedCanvases[j].getContext('2d').getImageData(0, s * data.frameH, data.width, data.frameH).data); + } + slotPixels.push(perLayer); + } + const runs = (mergeDuplicates !== false) + ? mergeConsecutiveSlots(slotPixels) + : slotPixels.map((_, s) => ({ start: s, count: 1 })); + const frames = runs.map(r => ({ + duration: Math.max(1, Math.round(r.count * slotMs)), + layers: data.layersMeta.map((m, j) => Object.assign({}, m, { pixels: slotPixels[r.start][j] })), + })); + const buffer = await buildAseprite({ width: data.width, height: data.frameH, frames }); + return { buffer, data, frames: frames.length, slotMs }; + } + const layers = data.layersMeta.map((m, j) => Object.assign({}, m, { + pixels: data.bakedCanvases[j].getContext('2d').getImageData(0, 0, data.width, data.fullH).data, + })); + const buffer = await buildAseprite({ width: data.width, height: data.fullH, layers }); + return { buffer, data, frames: 1, slotMs: 100 }; + } + + async function exportTexture(texture) { + texture = texture || Texture.selected; + if (!texture) { + Blockbench.showQuickMessage('Select a texture first', 2500); + return; + } + try { + const data = collectTextureData(texture); + if (data.error) { + Blockbench.showQuickMessage(data.error, 3000); + return; + } + if (data.slots > 1) { + // Slot speed: java format (mcmeta) runs on frame_time in 50 ms ticks, others on fps + const ft = Math.max(1, Math.round(texture.frame_time || 1)); + const fpsDef = Math.max(1, Math.round(texture.fps || 10)); + const slotMs = (typeof Format !== 'undefined' && Format && Format.texture_mcmeta && (texture.frame_time || 1) > 1) + ? 50 * ft + : 1000 / fpsDef; + showExportAnimationDialog({ + texture, width: data.width, frameH: data.frameH, slots: data.slots, + layersMeta: data.layersMeta, bakedCanvases: data.bakedCanvases, + slotMs, fps: fpsDef, unsupportedModes: data.unsupportedModes, + }); + } else { + const doExport = async () => { + const { buffer } = await buildAsepriteFromTexture(texture, false); + Blockbench.export({ + resource_id: 'aseprite_file', + name: (texture.name || 'texture') + '.aseprite', + extensions: ['aseprite'], + type: 'Aseprite File', + savetype: 'binary', + content: buffer, + }, (path) => { + if (path && /\.aseprite$/i.test(path)) { + texture.ase_path = path; // explicit export (re)links the texture to the file + texture.saved = true; + } + }); + if (data.unsupportedModes.length) { + Blockbench.showQuickMessage('Blend modes not supported by Aseprite (' + data.unsupportedModes.join(', ') + ') were saved as Normal', 4000); + } + }; + if (data.unsupportedModes.length) { + showUnsupportedBlendDialog(data.unsupportedModes, doExport); + } else { + doExport(); + } + } + } catch (err) { + console.error('[aseprite_support] export error:', err); + Blockbench.showQuickMessage('Aseprite export error: ' + err.message, 5000); + } + } + + // Warning before export: Blockbench blend modes missing in Aseprite become Normal. + function showUnsupportedBlendDialog(modes, onContinue) { + new Dialog({ + id: 'aseprite_support_blend_warning', + title: 'Aseprite: unsupported blend modes', + width: 480, + form: { + info: { type: 'info', text: 'These blend modes exist only in Blockbench and are not supported by Aseprite: **' + + modes.join('**, **') + '**. On export they will be replaced with Normal — layers, positions and opacity are preserved.' }, + }, + onConfirm() { onContinue(); }, + }).show(); + } + + function showExportAnimationDialog(ctx) { + const byFrameTime = ctx.slotMs !== 1000 / Math.max(1, ctx.fps); + new Dialog({ + id: 'aseprite_support_export_anim', + title: 'Aseprite: animation export', + width: 470, + form: { + info: { type: 'info', text: 'The texture is a flipbook: ' + ctx.slots + ' slots of ' + ctx.width + '×' + ctx.frameH + + ', slot ≈ ' + Math.round(ctx.slotMs) + ' ms' + + (byFrameTime + ? ' (by frame_time: ' + Math.max(1, Math.round(ctx.texture.frame_time || 1)) + ' ticks = 50 ms)' + : ' (by ' + Math.round(ctx.fps) + ' fps)') + '. ' + + 'Identical adjacent slots can be merged into one .aseprite frame with a "duplicates × slot" duration.' + + (ctx.unsupportedModes && ctx.unsupportedModes.length + ? '\\n\\n⚠ Blend modes not supported by Aseprite (' + ctx.unsupportedModes.join(', ') + ') will be replaced with Normal.' + : '') }, + mode: { type: 'select', label: '.aseprite frames', value: 'merge', options: { + merge: 'Merge identical adjacent slots (recommended)', + all: 'Every slot = a separate frame', + }}, + }, + onConfirm(result) { + exportAnimated(ctx, result.mode === 'merge'); + }, + }).show(); + } + + async function exportAnimated(ctx, merge) { + try { + const { buffer, frames, slotMs } = await buildAsepriteFromTexture(ctx.texture, merge); + Blockbench.export({ + resource_id: 'aseprite_file', + name: (ctx.texture.name || 'texture') + '.aseprite', + extensions: ['aseprite'], + type: 'Aseprite File', + savetype: 'binary', + content: buffer, + }, (path) => { + if (path && /\.aseprite$/i.test(path)) { + ctx.texture.ase_path = path; // explicit export (re)links the texture to the file + ctx.texture.saved = true; + } + }); + if (ctx.unsupportedModes && ctx.unsupportedModes.length) { + Blockbench.showQuickMessage('Blend modes not supported by Aseprite (' + ctx.unsupportedModes.join(', ') + ') were saved as Normal', 4000); + } + Blockbench.showQuickMessage('Aseprite: ' + ctx.slots + ' slots → ' + frames + ' frames (' + Math.round(slotMs) + ' ms per slot)', 3500); + } catch (err) { + console.error('[aseprite_support] export error:', err); + Blockbench.showQuickMessage('Aseprite export error: ' + err.message, 5000); + } + } + + // Direct write to the linked .aseprite (texture save button / Save Textures). + async function writeAsepriteFile(texture, path) { + try { + const { buffer, data, frames, slots } = await buildAsepriteFromTexture(texture, true); + const direct = (typeof isApp !== 'undefined' && isApp && typeof Filesystem !== 'undefined' && typeof Filesystem.writeFile === 'function'); + if (direct) { + Filesystem.writeFile(path, { content: buffer, savetype: 'binary' }); + } else { + // Web version: download the file + Blockbench.export({ + name: String(path).split(/[\\/]/).pop() || (texture.name || 'texture') + '.aseprite', + extensions: ['aseprite'], + type: 'Aseprite File', + savetype: 'binary', + content: buffer, + }); + } + texture.saved = true; + Blockbench.showQuickMessage('Saved (.aseprite): ' + path + + (slots > 1 ? ' — ' + slots + ' slots → ' + frames + ' frames' : '') + + (data.unsupportedModes.length ? '. Blend modes (' + data.unsupportedModes.join(', ') + ') replaced with Normal' : ''), 3500); + } catch (err) { + console.error('[aseprite_support] save error:', err); + Blockbench.showQuickMessage('Aseprite save error: ' + err.message, 5000); + } + } + + // "Save As" for a texture linked to .aseprite: stay in Aseprite or switch to another format. + function showAsepriteSaveAsDialog(texture) { + const base = String(texture.ase_path || '').split(/[\\/]/).pop(); + new Dialog({ + id: 'aseprite_support_save_as', + title: 'Save texture', + width: 480, + form: { + info: { type: 'info', text: 'This texture is linked to an Aseprite file: `' + base + '`. ' + + 'Saving as Aseprite updates the source file (with layers and animation). ' + + 'Saving in another format unlinks the texture from Aseprite.' }, + mode: { type: 'select', label: 'Format', value: 'aseprite', options: { + aseprite: 'Aseprite — update the source file', + other: 'Another format (PNG etc.) — unlink from Aseprite', + }}, + }, + onConfirm(result) { + if (result.mode === 'aseprite') { + writeAsepriteFile(texture, texture.ase_path); + } else if (BB_TEXTURE_SAVE) { + // native Blockbench "Save As" dialog; unlinking happens in the fromPath wrapper + BB_TEXTURE_SAVE.call(texture, true); + } + }, + }).show(); + } + + // ============================================================ + // Plugin registration + // ============================================================ + if (typeof Plugin !== 'undefined') { + + Plugin.register('aseprite_support', { + title: 'Aseprite Support', + author: 'Animun4ik', + description: 'Import .aseprite textures with layers and export Blockbench textures to .aseprite with layers.', + // Inline about text: shown on the plugin page without any CDN fetch. + // (fetchAbout only reads a sibling about.md for installed new-repository-format plugins.) + about: [ + '# Aseprite Support', + '', + 'Import and export `.aseprite` / `.ase` textures in Blockbench **with layers and animation**.', + '', + '## Import', + '- `.aseprite` / `.ase` → Blockbench texture with layers, opacity and blend modes.', + '- Multiple frames → flipbook animation (frame durations are matched by duplicating frames under a single FPS).', + '- Supports RGBA, grayscale and indexed color; raw, linked and zlib-compressed cels.', + '', + '## Export', + '- Blockbench texture → `.aseprite`.', + '- Preserves layers and animation; identical adjacent flipbook slots are merged into single frames.', + '- Textures without layers are exported as a single layer.', + '', + '## File link', + '- An imported texture remembers its source `.aseprite` file.', + '- The texture save button overwrites the source file; "Save As" can switch formats (which unlinks the texture).', + '', + '## Limitations', + '- Aseprite layer groups are flattened; tilemap layers are skipped.', + '- Blend modes missing in Aseprite (set_opacity, alpha_mask) are exported as Normal.', + '- Custom frame order and interpolation are not transferred.', + ].join('\n'), + icon: aseIconDataUrl('logo'), + version: '3.4.1', + variant: 'both', + // Do NOT set 4.8+: min_version >= 4.8 force-enables new_repository_format, + // and Blockbench starts requesting about.md from the CDN (404 spam in the console). + min_version: '4.0.0', + onload() { + const importAction = new Action('aseprite_import', { + name: 'Import Aseprite…', + description: 'Open a .aseprite / .ase file as a Blockbench texture with layers', + icon: () => aseIconNode('import'), + click() { + Blockbench.import({ + resource_id: 'aseprite_file', + extensions: ['aseprite', 'ase'], + type: 'Aseprite File', + readtype: 'buffer', + multiple: true, + }, importFiles); + }, + }); + const exportAction = new Action('aseprite_export', { + name: 'Export as Aseprite…', + description: 'Save the selected texture as .aseprite with layers', + icon: () => aseIconNode('export'), + click() { exportTexture(Texture.selected); }, + }); + + try { MenuBar.addAction(importAction, 'file.import'); } + catch (e) { MenuBar.addAction(importAction, 'file'); } + try { MenuBar.addAction(exportAction, 'file.export'); } + catch (e) { MenuBar.addAction(exportAction, 'file'); } + + // Texture context menu in the textures panel + try { + if (Texture.prototype.menu && typeof Texture.prototype.menu.addAction === 'function') { + Texture.prototype.menu.addAction(exportAction); + } + } catch (e) { console.warn('[aseprite_support] texture menu:', e); } + + // TEXTURES panel context menu (right-click on empty space in the list): + // the item is inserted right after the built-in "Import Texture". + const attachPanelMenu = () => { + try { + const panel = (typeof Panels !== 'undefined' && Panels.textures) || + (typeof Interface !== 'undefined' && Interface.Panels && Interface.Panels.textures); + const menu = panel && panel.menu; + if (!menu) return false; + if (Array.isArray(menu.items) && menu.items.includes('import_texture')) { + menu.items.splice(menu.items.indexOf('import_texture') + 1, 0, importAction); + } else if (typeof menu.addAction === 'function') { + menu.addAction(importAction); + } else { + return false; + } + this.__panelMenu = menu; + this.__panelMenuAction = importAction; + return true; + } catch (e) { + console.warn('[aseprite_support] textures panel menu:', e); + return false; + } + }; + if (!attachPanelMenu()) setTimeout(attachPanelMenu, 1500); + + // Texture <-> source .aseprite link property (saved into .bbmodel) + try { new Property(Texture, 'string', 'ase_path', { default: '' }); } + catch (e) { console.warn('[aseprite_support] property:', e); } + + // Property marking "the layer's original blend mode does not transfer to Blockbench" + try { new Property(TextureLayer, 'string', 'ase_blend_warning', { default: '' }); } + catch (e) { console.warn('[aseprite_support] property:', e); } + + // While a texture is linked to .aseprite — hide blend modes missing in Aseprite + // from the layer select (options with a condition are hidden from the menu and skipped by the wheel). + try { + const lbm = BarItems.layer_blend_mode; + this.__lbmOriginalOptions = {}; + for (const key of ['set_opacity', 'alpha_mask']) { + if (lbm.options[key] !== undefined) { + this.__lbmOriginalOptions[key] = lbm.options[key]; + lbm.options[key] = { + name: 'action.blend_mode.' + key, + condition: () => !aseSelectedTextureLinked(), + }; + } + } + BB_LBM_ONCHANGE = lbm.onChange; + lbm.onChange = function (sel, event) { + const r = BB_LBM_ONCHANGE ? BB_LBM_ONCHANGE.call(this, sel, event) : undefined; + try { + if (TextureLayer.selected && TextureLayer.selected.ase_blend_warning) { + TextureLayer.selected.ase_blend_warning = ''; + } + } catch (e) { /* no layer */ } + scheduleLayerWarningsUpdate(); + return r; + }; + } catch (e) { console.warn('[aseprite_support] blend select:', e); } + + // Update warning icons and the indicator on layer selection / panel changes + BB_LAYER_SELECT = TextureLayer.prototype.select; + TextureLayer.prototype.select = function (m) { + const r = BB_LAYER_SELECT.call(this, m); + scheduleLayerWarningsUpdate(); + return r; + }; + // In the layer properties dialog — also remove modes unavailable for .aseprite + BB_LAYER_PROPS_DIALOG = TextureLayer.prototype.propertiesDialog; + TextureLayer.prototype.propertiesDialog = function (...args) { + const removed = []; + if (this.texture && this.texture.ase_path && TextureLayer.properties.blend_mode.enum_values) { + const ev = TextureLayer.properties.blend_mode.enum_values; + for (const key of ['set_opacity', 'alpha_mask']) { + const i = ev.indexOf(key); + if (i !== -1) { removed.push([key, i]); ev.splice(i, 1); } + } + } + try { return BB_LAYER_PROPS_DIALOG.apply(this, args); } + finally { + const ev = TextureLayer.properties.blend_mode.enum_values; + for (let k = removed.length - 1; k >= 0; k--) ev.splice(removed[k][1], 0, removed[k][0]); + } + }; + this.__layersObserver = new MutationObserver(() => scheduleLayerWarningsUpdate()); + this.__layersObserver.observe(document.body, { childList: true, subtree: true }); + setTimeout(() => scheduleLayerWarningsUpdate(), 400); + + // Inline SVG icon sizes — mirroring Blockbench's img.icon rules + this.__iconStyle = document.createElement('style'); + this.__iconStyle.textContent = + 'div.ase_icon { width: 26px; height: 26px; display: block; flex-shrink: 0; }' + + 'div.ase_icon > svg { width: 100%; height: 100%; display: block; }' + + 'header .tool > div.ase_icon { margin-top: 2px; }' + + '.contextMenu li > div.ase_icon { width: 20px; height: 20px; margin-bottom: -3px; margin-left: -27px; margin-right: 5px; margin-top: 1px; }' + + '.ase_texture_badge > svg { width: 100%; height: 100%; display: block; }'; + document.head.appendChild(this.__iconStyle); + + // Texture save button: .aseprite-linked textures are written to the source file + // until the user saves them in another format — then the link is removed. + BB_TEXTURE_SAVE = Texture.prototype.save; + BB_TEXTURE_FROM_PATH = Texture.prototype.fromPath; + Texture.prototype.save = function (as) { + if (this.ase_path && !(this.saved && !as)) { + if (!as) return writeAsepriteFile(this, this.ase_path); + return showAsepriteSaveAsDialog(this); + } + return BB_TEXTURE_SAVE.apply(this, arguments); + }; + Texture.prototype.fromPath = function (path) { + const result = BB_TEXTURE_FROM_PATH.apply(this, arguments); + try { + if (typeof path === 'string' && path && /\.aseprite$/i.test(path)) { + this.ase_path = path; // an .aseprite file became the source again + } else if (this.ase_path && path && path.toLowerCase() !== String(this.ase_path).toLowerCase()) { + this.ase_path = ''; // saving in another format unlinks the texture + } + } catch (e) { /* not in this version */ } + scheduleLayerWarningsUpdate(); + return result; + }; + + this.__actions = [importAction, exportAction]; + }, + onunload() { + for (const a of (this.__actions || [])) { try { a.delete(); } catch (e) { /* already deleted */ } } + this.__actions = null; + try { + if (BB_TEXTURE_SAVE) Texture.prototype.save = BB_TEXTURE_SAVE; + if (BB_TEXTURE_FROM_PATH) Texture.prototype.fromPath = BB_TEXTURE_FROM_PATH; + } catch (e) { /* not in this version */ } + BB_TEXTURE_SAVE = null; + BB_TEXTURE_FROM_PATH = null; + try { + const lbm = BarItems.layer_blend_mode; + for (const key in (this.__lbmOriginalOptions || {})) lbm.options[key] = this.__lbmOriginalOptions[key]; + if (BB_LBM_ONCHANGE) lbm.onChange = BB_LBM_ONCHANGE; + if (BB_LAYER_SELECT) TextureLayer.prototype.select = BB_LAYER_SELECT; + if (BB_LAYER_PROPS_DIALOG) TextureLayer.prototype.propertiesDialog = BB_LAYER_PROPS_DIALOG; + } catch (e) { /* not in this version */ } + BB_LBM_ONCHANGE = null; + BB_LAYER_SELECT = null; + BB_LAYER_PROPS_DIALOG = null; + try { if (this.__layersObserver) this.__layersObserver.disconnect(); } catch (e) { /* none */ } + this.__layersObserver = null; + try { if (this.__iconStyle) this.__iconStyle.remove(); } catch (e) { /* none */ } + this.__iconStyle = null; + try { + document.querySelectorAll('.ase_mode_lock, .ase_blend_warning_icon').forEach(n => n.remove()); + } catch (e) { /* none */ } + try { + const menu = this.__panelMenu; + const action = this.__panelMenuAction; + if (menu && action) { + if (Array.isArray(menu.items) && menu.items.includes(action)) { + menu.items.splice(menu.items.indexOf(action), 1); + } else if (typeof menu.removeAction === 'function') { + menu.removeAction(action); + } + } + } catch (e) { /* not in this version */ } + this.__panelMenu = null; + this.__panelMenuAction = null; + try { + if (typeof Blockbench.removeDragHandler === 'function') Blockbench.removeDragHandler('aseprite_support'); + } catch (e) { /* not in this version */ } + }, + }); + + } else if (typeof module !== 'undefined' && module.exports !== undefined) { + // Only for Node core tests (analysis/test_plugin.mjs) + module.exports = { + parseAseprite, buildAseprite, inflateZlib, deflateZlib, + suggestedFps, computeFrameCopies, mergeConsecutiveSlots, + }; + } +})(); diff --git a/plugins/aseprite_support/new icons/export.svg b/plugins/aseprite_support/new icons/export.svg new file mode 100644 index 00000000..8682653f --- /dev/null +++ b/plugins/aseprite_support/new icons/export.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/aseprite_support/new icons/icon.svg b/plugins/aseprite_support/new icons/icon.svg new file mode 100644 index 00000000..f19ddd0a --- /dev/null +++ b/plugins/aseprite_support/new icons/icon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/plugins/aseprite_support/new icons/import.svg b/plugins/aseprite_support/new icons/import.svg new file mode 100644 index 00000000..226b55d1 --- /dev/null +++ b/plugins/aseprite_support/new icons/import.svg @@ -0,0 +1,5 @@ + + + + +