新增3.4版本支持

This commit is contained in:
tianmo committed 2026-09-10 13:53:43 +08:00
1 parent 4c5033d2f9
commit 9635428f78
21 files changed
+5425 -51

No files matched your search

+33
View File
@@ -0,0 +1,33 @@
Source revision: ef5013143178918ddfc7cabfbddeae7aec7cf84e (official 3.4.02 tag)
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.4.02/spine-ts/build/spine-canvas.js
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.4.02/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
+21 -3
View File
@@ -8,7 +8,9 @@
普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。
支持的 Spine 版本:**3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再检查完整文件名。导入复合后缀时,插件通过缩略图回调将 `lili.atlas.txt` 规范为 `name: "lili"`、`ext: "atlas.txt"`,`skel.bytes` 同理,磁盘文件名保持不变;同时生成匹配新名称的 WebP 缓存。这样可避开 Eagle 对 `txt` 强制显示文本卡片的逻辑。普通 TXT 保留文本内容、正常显示;普通 BYTES 显示文件说明,两者都不扫描资源库。修改注册后需要重新加载插件或重启 Eagle。旧条目重新生成缩略图后,如果列表仍保留旧名称,需要重新打开素材库。
支持的 Spine 版本:**3.4 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
> 关键特性:无需网络、不走 CDN。所有 Spine 官方运行时代码都已拷贝到插件本地 `lib/` 目录,不含 git 子模块。
@@ -31,6 +33,7 @@ SpineViewer/
│ ├─ spine-legacy-binary.js # 3.6/3.7 二进制解析兼容层
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用)
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
│ ├─ 3.4/ spine-canvas.js
│ ├─ 3.6/ spine-canvas.js
│ ├─ 3.7/ spine-canvas.js
│ ├─ 3.8/ spine-canvas.js
@@ -57,12 +60,12 @@ SpineViewer/
- 播放 / 暂停(空格)
- 循环开关
- 播放速度调节
- 预乘 Alpha(PMA)贴图渲染开关
- 预乘 Alpha(PMA)贴图渲染开关:仅手动切换时保存当前素材的设置,重新打开自动恢复;默认导入和预览不写入设置
- 时间轴拖动、动画时长、秒刻度与关键帧标记
- 零时长动画按单帧静态姿势预览
- 自动适配并居中
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,便于识别数据文件。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。
- 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。
- JSON 只在存在 Spine 官方导出的 `skeleton.spine` 版本字段时才按骨骼数据处理;普通 JSON、JSONC 和仅含 `bones` / `slots` 同名字段的数据会立即生成中性文件缩略图,不进入全库资源扫描或补刷队列。
- 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。
@@ -72,6 +75,10 @@ SpineViewer/
## 使用说明
预览与手动缩略图使用 WebGL 绘制网格三角形,避免 Canvas 逐三角形裁切造成的细线接缝,覆盖全部八个运行时版本。WebGL 不可用时回退到 Canvas。
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
@@ -79,6 +86,17 @@ SpineViewer/
## 关于新版/旧版二进制
- **3.4**:使用官方 3.4.02 运行时与匹配的二进制解析分支,支持 JSON、SKEL、SKEL.BYTES、ATLAS、ATLAS.TXT,包含网格、变形、多皮肤、约束、时间轴、预乘及缩略图。3.4 本身没有裁剪附件。已验证九组官方 JSON/SKEL 示例;本地路径长度修正见 `lib/3.4/README.md`。
3.4 测试资源与回归命令(下载包括配套 PNG,可直接导入 Eagle):
```sh
node tests/fetch-legacy-fixtures.js tests/.fixtures 3.4
node tests/legacy-binary.test.js tests/.fixtures 3.4
```
3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览、生成缩略图及后台补刷。
- 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
+17
View File
@@ -0,0 +1,17 @@
'use strict';
const path = require('path');
// Preserve name + '.' + ext exactly; only split the compound suffix correctly.
// Return through Eagle's thumbnail callback so Eagle persists its own metadata.
function normalizeItem(src, item) {
const filename = path.basename(src);
const match = filename.match(/^(.*)\.(atlas\.txt|skel\.bytes)$/i);
if (!match || !item || !item.ext) return false;
if (item.name + '.' + item.ext !== filename) return false;
if (item.name === match[1] && item.ext === match[2]) return false;
item.name = match[1]; item.ext = match[2];
delete item.text;
return true;
}
module.exports = { normalizeItem };
+17 -1
View File
@@ -11,4 +11,20 @@ function dataUrl() {
return cachedDataUrl;
}
module.exports = { path: badgePath, dataUrl };
function drawVersion(ctx, width, height, version) {
if (!/^\d+\.\d+(?:\.\d+)?$/.test(String(version || ''))) return;
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055));
const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03));
ctx.save();
try {
ctx.font = '600 ' + fontSize + 'px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const boxHeight = fontSize + 12;
const y = height - margin - boxHeight;
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(margin, y, ctx.measureText(version).width + 16, boxHeight);
ctx.fillStyle = '#ffffff'; ctx.fillText(version, margin + 8, y + boxHeight / 2);
} finally { ctx.restore(); }
}
module.exports = { path: badgePath, dataUrl, drawVersion };
+25 -1
View File
@@ -152,6 +152,27 @@ function createLegacyRenderer(spine, context) {
return base;
}
// 3.4 predates clipping attachments and computes packed vertices on attachments.
function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
base.draw = skeleton => {
try {
for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment();
if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue;
const vertices = attachment.updateWorldVertices(slot, false);
const image = imageForCanvas(imageFromAttachment(attachment), context, base.premultipliedAlpha);
if (!image) continue;
context.globalAlpha = vertices[5];
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
}
} finally { context.globalAlpha = 1; }
};
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
@@ -190,9 +211,12 @@ function create43Renderer(spine, context) {
}
function createRenderer(spine, context, runtimeDir) {
return runtimeDir === '4.3'
const renderer = runtimeDir === '3.4' ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime.
renderer.debugRendering = false;
return require('./spine-webgl-rasterizer.js').attach(renderer, context);
}
module.exports = { createRenderer };
+16 -10
View File
@@ -1,8 +1,9 @@
'use strict';
// Adapted from Esoteric Software's 3.6/3.7 spine-libgdx SkeletonBinary.java.
// Adapted from Esoteric Software's 3.4/3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// 3.4 source and license: LICENSES/Spine-3.4-License.txt.
// Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once.
@@ -60,13 +61,14 @@ class Input {
function decode(bytes, runtimeDir) {
const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[67]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.6 / 3.7');
if (!/^3\.[467]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7';
const v34 = version === '3.4';
skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool();
if (nonessential) { skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
const boneName = () => r.ref(root.bones).name;
const slotName = () => r.ref(root.slots).name;
@@ -75,24 +77,26 @@ function decode(bytes, runtimeDir) {
for (let i = 0, n = r.count(); i < n; i++) {
const b = { name: r.string() }; if (i) b.parent = boneName();
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (v34) { b.inheritRotation = r.bool(); b.inheritScale = r.bool(); }
else b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (nonessential) b.color = r.color(); root.bones.push(b);
}
for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() };
const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2);
if (!v34) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); }
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: r.varint(), bones: boneList(), target: boneName(), local: r.bool(), relative: r.bool() };
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
if (!v34) { c.local = r.bool(); c.relative = r.bool(); }
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: r.varint(), bones: boneList(), target: slotName(),
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: slotName(),
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
}
@@ -108,6 +112,7 @@ function decode(bytes, runtimeDir) {
function attachment(key) {
const a = { name: r.string() || key };
const type = r.byte();
if (v34 && type > 4) r.fail('3.4 不支持此附件类型:' + type);
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) {
@@ -167,7 +172,7 @@ function decode(bytes, runtimeDir) {
const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
else if (type === 2) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else if (type === 2 && !v34) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线');
}
}
@@ -228,7 +233,8 @@ function decode(bytes, runtimeDir) {
}
function install(spine, runtimeDir) {
if (runtimeDir !== '3.6' && runtimeDir !== '3.7') return spine;
if (!['3.4', '3.6', '3.7'].includes(runtimeDir)) return spine;
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
readSkeletonData(bytes) {
+58
View File
@@ -0,0 +1,58 @@
'use strict';
// Eagle's item API has no arbitrary custom fields. Keep plugin metadata in
// userData, keyed by library + item ID, without editing Eagle metadata.json.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function identity(sourcePath, itemId) {
const directory = path.dirname(path.resolve(sourcePath));
const match = path.basename(directory).match(/^(.+)\.info$/i);
return match
? path.dirname(directory) + ':' + (itemId || match[1])
: path.resolve(sourcePath);
}
function createStore(userDataPath, sourcePath, itemId) {
const key = crypto.createHash('sha256').update(identity(sourcePath, itemId)).digest('hex');
const directory = path.join(userDataPath, 'SpineViewer', 'preview-settings');
const file = path.join(directory, key + '.json');
return {
file,
read() {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return typeof data.premultipliedAlpha === 'boolean' ? data.premultipliedAlpha : undefined;
} catch (e) {
if (e.code === 'ENOENT') return undefined;
throw e;
}
},
// This method is called only by the user's checkbox change event.
write(premultipliedAlpha) {
if (typeof premultipliedAlpha !== 'boolean') throw new TypeError('预乘设置必须为布尔值');
fs.mkdirSync(directory, { recursive: true });
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try {
fs.writeFileSync(temp, JSON.stringify({ premultipliedAlpha }, null, 2) + '\n', { flag: 'wx' });
fs.renameSync(temp, file);
} finally {
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
};
}
async function resolveDataPath(app) {
try {
const value = app && (app.userDataPath || (typeof app.getPath === 'function' && await app.getPath('userData')));
if (typeof value === 'string' && value) return value;
} catch (e) { /* Some Eagle format-view windows reject app.getPath IPC. */ }
const home = require('os').homedir();
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Eagle');
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Eagle');
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
}
module.exports = { createStore, resolveDataPath };
+3 -1
View File
@@ -191,7 +191,8 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
ctx.translate(-cx, -cy);
let renderer = null;
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
if (runtimeDir === '3.4') renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir);
else if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
if (renderer) {
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
@@ -202,6 +203,7 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version,
}
await drawSpineBadge(ctx, cw, ch);
spineBadge.drawVersion(ctx, cw, ch, skeletonData.version || version);
const buf = canvas.toBuffer('image/png');
return buf;
}
+4 -1
View File
@@ -104,6 +104,7 @@ function chooseRuntimeDir(version, isBinary) {
const major = parseInt(version.split('.')[0], 10);
if (major === 3) {
const minor = parseFloat(version);
if (minor === 3.4) return '3.4';
if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8';
@@ -360,7 +361,9 @@ function collectSpineFiles(roots, validFolders) {
*/
function collectSpineFilesCached(filePath, roots) {
const libRoot = findLibraryRoot(filePath);
const key = libRoot || path.dirname(path.dirname(filePath));
// Outside an Eagle library, sibling asset folders are independent search
// scopes. Sharing their parent as a cache key can return another skeleton.
const key = libRoot || JSON.stringify([...roots].sort());
const now = Date.now();
const cached = fileIndexCache.get(key);
if (cached && now - cached.time < 1500) return cached.files;
+129
View File
@@ -0,0 +1,129 @@
'use strict';
// Rasterize adjacent mesh triangles together, with GPU edge ownership rules.
// Canvas clip()+drawImage() anti-aliases each triangle separately and exposes
// the background along shared edges, which looks like a wireframe overlay.
function createRasterizer(context) {
const document = context.canvas && context.canvas.ownerDocument;
if (!document || typeof context.getTransform !== 'function') return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', {
alpha: true, premultipliedAlpha: true, antialias: true,
preserveDrawingBuffer: true, depth: false, stencil: false
});
if (!gl) return null;
function shader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
const vertex = shader(gl.VERTEX_SHADER, `
attribute vec2 position;
attribute vec2 uv;
uniform vec2 resolution;
varying vec2 textureUV;
void main() {
vec2 p = position / resolution * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
textureUV = uv;
}`);
const fragment = shader(gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D image;
uniform float opacity;
varying vec2 textureUV;
void main() {
vec4 color = texture2D(image, textureUV);
float alpha = color.a * opacity;
gl_FragColor = vec4(color.rgb * opacity, alpha);
}`);
const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
gl.deleteShader(vertex); gl.deleteShader(fragment);
const buffer = gl.createBuffer();
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution'), opacity = gl.getUniformLocation(program, 'opacity');
const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, transform;
let disposed = false, lost = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) {
if (textures.has(image)) return textures.get(image);
const t = gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, true);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
textures.set(image, t); return t;
}
function flush() {
if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
gl.uniform1f(opacity, currentAlpha);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 4);
coordinates.length = 0;
}
return {
begin() {
if (disposed || lost || gl.isContextLost()) return false;
const target = context.canvas;
if (canvas.width !== target.width) canvas.width = target.width;
if (canvas.height !== target.height) canvas.height = target.height;
transform = context.getTransform(); coordinates.length = 0; currentImage = null;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 16, 0);
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 16, 8);
gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
return true;
},
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2) {
const alpha = context.globalAlpha;
if (image !== currentImage || alpha !== currentAlpha) { flush(); currentImage = image; currentAlpha = alpha; }
const t = transform;
coordinates.push(
t.a * x0 + t.c * y0 + t.e, t.b * x0 + t.d * y0 + t.f, u0, v0,
t.a * x1 + t.c * y1 + t.e, t.b * x1 + t.d * y1 + t.f, u1, v1,
t.a * x2 + t.c * y2 + t.e, t.b * x2 + t.d * y2 + t.f, u2, v2);
},
end() {
flush();
context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.drawImage(canvas, 0, 0); }
finally { context.restore(); }
},
dispose() {
if (disposed) return; disposed = true;
for (const value of textures.values()) gl.deleteTexture(value);
textures.clear(); gl.deleteBuffer(buffer); gl.deleteProgram(program);
const extension = gl.getExtension('WEBGL_lose_context'); if (extension) extension.loseContext();
}
};
}
function attach(renderer, context) {
let rasterizer;
try { rasterizer = createRasterizer(context); } catch (e) { return renderer; }
if (!rasterizer) return renderer;
const draw = renderer.draw.bind(renderer), triangle = renderer.drawTriangle.bind(renderer);
renderer.draw = skeleton => {
if (!rasterizer.begin()) return draw(skeleton);
renderer.drawTriangle = rasterizer.triangle;
try { draw(skeleton); rasterizer.end(); }
finally { renderer.drawTriangle = triangle; }
};
renderer.dispose = rasterizer.dispose;
renderer.rasterization = 'webgl';
return renderer;
}
module.exports = { createRasterizer, attach };
+9
View File
@@ -0,0 +1,9 @@
# Spine 3.4 runtime
Bundled from the official `3.4.02` tag, commit `ef5013143178918ddfc7cabfbddeae7aec7cf84e`, `spine-ts/build/spine-canvas.js`.
Local correction: `SkeletonJson` path-length loop writes `lengths[i]`, not `lengths[i++]`. The original increments twice and reads beyond the source array, producing missing/NaN lengths.
The plugin provides binary decoding and the `AtlasAttachmentLoader` name alias in `js/spine-legacy-binary.js`. Rendering uses the 3.4 attachment vertex API in `js/spine-canvas-clipping.js`. 3.4 has no clipping attachments; no newer attachment types are synthesized.
License: `LICENSES/Spine-3.4-License.txt`.
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -14,7 +14,7 @@
],
"devTools": false,
"preview": {
"skel,skel.bytes,json,atlas,atlas.txt": {
"skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": {
"thumbnail": {
"path": "thumbnail/spine.js",
"size": 400,
+34
View File
@@ -0,0 +1,34 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const utils = require('../js/spine-utils');
const manifest = require('../manifest.json');
const registered = Object.keys(manifest.preview).flatMap(key => key.split(','));
// Eagle uses path.extname for item.ext, then dispatches by that exact key.
for (const name of ['hero.atlas.txt', 'hero.skel.bytes', 'HERO.ATLAS.TXT', 'HERO.SKEL.BYTES']) {
assert(registered.includes(path.extname(name).slice(1).toLowerCase()));
assert.equal(utils.stemOf(name).toLowerCase(), 'hero');
}
for (const name of ['notes.txt', 'payload.bytes', 'hero.atlas.txt.bak']) assert.equal(utils.extOf(name), null);
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-extensions-test-'));
try {
// Sibling directories with identical filenames must not share stale indexes.
for (const version of ['3.4', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3']) {
const directory = path.join(root, version); fs.mkdirSync(directory);
const skeleton = path.join(directory, 'hero.skel.bytes');
const atlas = path.join(directory, 'hero.atlas.txt');
// These tests cover routing/discovery; real binary decoding is tested by
// legacy-binary.test.js and the all-version official sample smoke tests.
fs.writeFileSync(skeleton, Buffer.from('\0' + version + '.1\0'));
fs.writeFileSync(atlas, 'hero.png\nsize: 64,64\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nregion\n rotate: false\n xy: 0,0\n size: 64,64\n');
fs.writeFileSync(path.join(directory, 'hero.png'), 'texture');
for (const selected of [skeleton, atlas]) for (const strictStem of [false, true]) {
const assets = utils.discoverAssets(selected, { strictStem });
assert.equal(assets.skeletonPath, skeleton); assert.equal(assets.atlasPath, atlas);
assert.equal(assets.isBinary, true); assert.equal(utils.chooseRuntimeDir(utils.detectVersion(skeleton), true), version);
}
}
console.log('PASS: Eagle last-extension routing, case handling, ordinary-file exclusion and sibling cache isolation');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
+14 -1
View File
@@ -4,6 +4,20 @@ const fs = require('fs');
const path = require('path');
const { execFileSync } = require('child_process');
const destination = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures'));
if (process.argv[3] === '3.4') {
const revision = 'ef5013143178918ddfc7cabfbddeae7aec7cf84e';
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'raptor']) {
const directory = path.join(destination, '3.4', name);
fs.mkdirSync(directory, { recursive: true });
const variants = name === 'spineboy' ? [name, name + '-mesh', name + '-hover'] : name === 'goblins' ? [name, name + '-mesh'] : [name];
for (const file of [...variants.flatMap(v => [v + '.json', v + '.skel']), name + '.atlas', name + '.png']) {
const url = `https://raw.githubusercontent.com/EsotericSoftware/spine-runtimes/${revision}/examples/${name}/export/${file}`;
execFileSync('curl', ['--http1.1', '-LfsS', '--retry', '2', '--max-time', '60', url, '-o', path.join(directory, file)]);
}
console.log('3.4 / ' + name);
}
process.exit(0);
}
const revisions = { '3.6': '654c20e5b0e523040b6366bbd1042510d2645134', '3.7': '9639bcc81722d7178fd9d1cdc1a3d55a4c91f989' };
for (const [version, revision] of Object.entries(revisions)) {
for (const name of ['spineboy', 'goblins', 'vine', 'stretchyman', 'tank', 'coin', 'raptor']) {
@@ -16,4 +30,3 @@ for (const [version, revision] of Object.entries(revisions)) {
console.log(version + ' / ' + name);
}
}
+40 -15
View File
@@ -24,22 +24,38 @@ function runtime(version) {
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'lib', version, 'spine-canvas.js'), 'utf8'), context);
return install(context.spine, version);
}
for (const version of ['3.6', '3.7']) {
const versions = process.argv[3] === '3.4' ? ['3.4'] : ['3.6', '3.7'];
let pairs = 0;
for (const version of versions) {
const spine = runtime(version);
// 3.4 JSON export truncates to two decimal places instead of rounding.
const quantization = version === '3.4' ? 0.0102 : 0.0052;
assert.equal(utils.chooseRuntimeDir(version + '.32', true), version);
for (const name of names) {
const directory = path.join(fixtures, version, name);
const bytes = fs.readFileSync(path.join(directory, name + '-pro.skel'));
const json = JSON.parse(fs.readFileSync(path.join(directory, name + '-pro.json'), 'utf8'));
for (const name of version === '3.4' ? ['spineboy', 'spineboy-mesh', 'spineboy-hover', 'goblins', 'goblins-mesh', 'vine', 'stretchyman', 'tank', 'raptor'] : names) {
const directory = path.join(fixtures, version, version === '3.4' ? name.split('-')[0] : name);
const file = name + (version === '3.4' ? '' : '-pro');
const bytes = fs.readFileSync(path.join(directory, file + '.skel'));
const json = JSON.parse(fs.readFileSync(path.join(directory, file + '.json'), 'utf8'));
const decoded = decode(bytes, version);
assert.equal(decoded.skeleton.hash, json.skeleton.hash);
assert.equal(decoded.skeleton.spine, json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, name + '-pro.skel')), json.skeleton.spine);
assert.equal(utils.detectVersion(path.join(directory, file + '.skel')), json.skeleton.spine);
// Region geometry uses a real official atlas; no native Canvas dependency
// is needed to verify animation poses and renderer triangle output.
const texture = { getImage: () => ({ width: 1024, height: 1024 }), setFilters() {}, setWraps() {} };
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, name + '.atlas'), 'utf8'), () => texture);
const atlas = new spine.TextureAtlas(fs.readFileSync(path.join(directory, (version === '3.4' ? name.split('-')[0] : name) + '.atlas'), 'utf8'), () => texture);
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas);
// 3.4 Goblins binary optimizes three constant segments to linear;
// JSON keeps stepped. Their equal endpoints produce identical poses.
if (version === '3.4' && name.startsWith('goblins')) {
for (const [bone, type, index] of [['left lower leg', 'translate', 2], ['right lower leg', 'translate', 0], ['hip', 'rotate', 0]]) {
const timeline = json.animations.walk.bones[bone][type];
assert.equal(timeline[index].curve, 'stepped');
assert.equal(decoded.animations.walk.bones[bone][type][index].curve, undefined);
for (const field of type === 'rotate' ? ['angle'] : ['x', 'y']) assert.equal(timeline[index][field], timeline[index + 1][field]);
delete timeline[index].curve;
}
}
// In the pinned 3.6 raptor exports, the same-hash JSON puts the stepped
// IK curve on frame 0, while the SKEL stores it on frame 1. Assert the
// exact discrepancy, then align only this JSON comparison oracle.
@@ -60,7 +76,7 @@ for (const version of ['3.6', '3.7']) {
assert.deepEqual(Array.from(data.animations, a => a.name), Array.from(expected.animations, a => a.name));
for (let i = 0; i < data.bones.length; i++) {
for (const k of ['x', 'y', 'rotation', 'scaleX', 'scaleY', 'shearX', 'shearY', 'length'])
near(data.bones[i][k], expected.bones[i][k], 0.0052, name + '/bone/' + k);
near(data.bones[i][k], expected.bones[i][k], quantization, name + '/bone/' + k);
}
for (const skin of data.skins) {
const reference = expected.findSkin(skin.name);
@@ -69,20 +85,24 @@ for (const version of ['3.6', '3.7']) {
const b = reference.getAttachment(Number(slot), key);
assert(b, key); assert.equal(a.constructor.name, b.constructor.name);
for (const field of ['vertices', 'regionUVs', 'triangles', 'bones', 'lengths']) {
if (a[field]) arrayNear(a[field], b[field], field === 'triangles' || field === 'bones' ? 0 : 0.0052, name + '/' + key + '/' + field);
if (a[field]) arrayNear(a[field], b[field], field === 'triangles' || field === 'bones' ? 0 : quantization, name + '/' + key + '/' + field);
}
}
}
}
for (const animation of data.animations) {
const refAnimation = expected.findAnimation(animation.name);
near(animation.duration, refAnimation.duration, 0.00006, 'duration');
near(animation.duration, refAnimation.duration, version === '3.4' ? 0.00011 : 0.00006, 'duration');
assert.equal(animation.timelines.length, refAnimation.timelines.length);
// Older JSON and binary exports order independent slot timelines differently.
const timelineKey = t => [t.constructor.name, t.slotIndex, t.boneIndex, t.ikConstraintIndex, t.transformConstraintIndex, t.pathConstraintIndex, t.attachment && t.attachment.name].join('/');
const references = new Map(refAnimation.timelines.map(t => [timelineKey(t), t]));
for (let i = 0; i < animation.timelines.length; i++) {
const a = animation.timelines[i], b = refAnimation.timelines[i];
const a = animation.timelines[i], b = references.get(timelineKey(a));
assert(b, timelineKey(a));
assert.equal(a.constructor.name, b.constructor.name);
arrayNear(a.frames, b.frames, 0.0052, name + '/' + animation.name + '/frames');
if (a.curves) arrayNear(a.curves, b.curves, 0.0052, name + '/' + animation.name + '/curves');
arrayNear(a.frames, b.frames, quantization, name + '/' + animation.name + '/frames');
if (a.curves) arrayNear(a.curves, b.curves, quantization, name + '/' + animation.name + '/curves');
if (a.attachmentNames) assert.deepEqual(Array.from(a.attachmentNames), Array.from(b.attachmentNames));
if (a.frameVertices) for (let f = 0; f < a.frameVertices.length; f++) arrayNear(a.frameVertices[f], b.frameVertices[f], 0.0104, 'deform');
}
@@ -100,7 +120,11 @@ for (const version of ['3.6', '3.7']) {
assert.deepEqual(Array.from(pair[0].slots, s => s.getAttachment()?.name), Array.from(pair[1].slots, s => s.getAttachment()?.name));
for (let i = 0; i < pair[0].bones.length; i++) for (const field of ['a', 'b', 'c', 'd', 'worldX', 'worldY']) {
// Quantized JSON weights/curves compound along bone chains.
near(pair[0].bones[i][field], pair[1].bones[i][field], 2.1, name + '/' + animation.name + '/' + field);
// 3.4 truncates weights/scales by up to 0.01. Bound accumulated
// world-position error by 1% of export size; local data is checked above.
const tolerance = version === '3.4' && field.startsWith('world')
? Math.max(2.1, Math.max(json.skeleton.width, json.skeleton.height) * scale * 0.01) : 2.1;
near(pair[0].bones[i][field], pair[1].bones[i][field], tolerance, name + '/' + animation.name + '/' + field);
}
const renderer = clipping.createRenderer(spine, { globalAlpha: 1 }, version);
let triangles = 0;
@@ -116,6 +140,7 @@ for (const version of ['3.6', '3.7']) {
const wrapped = Buffer.concat([Buffer.from([9, 8, 7]), bytes, Buffer.from([6])]);
assert.equal(decode(wrapped.subarray(3, -1), version).skeleton.hash, json.skeleton.hash);
console.log('PASS ' + version + ' / ' + name);
pairs++;
}
}
for (const version of ['3.8', '4.0', '4.1', '4.2', '4.3']) assert.equal(utils.chooseRuntimeDir(version + '.1', true), version);
@@ -174,4 +199,4 @@ for (const version of ['3.6', '3.7']) for (const nonessential of [false, true])
assert.equal(data.bones[0].name, '根😀'); assert.equal(data.events[0].intValue, -123);
assert.equal(data.animations[0].duration, 0.5);
}
console.log(`PASS: 14 official JSON/SKEL pairs, ${animations} animation/scale checks, ${poses} pose/render checks`);
console.log(`PASS: ${pairs} official JSON/SKEL pairs, ${animations} animation/scale checks, ${poses} pose/render checks`);
+26
View File
@@ -0,0 +1,26 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const { createStore } = require('../js/spine-preview-settings');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-settings-test-'));
try {
const userData = path.join(root, 'settings');
const source = path.join(root, 'A.library', 'images', 'ITEM.info', 'hero.skel.bytes');
const store = createStore(userData, source);
assert.equal(store.read(), undefined);
assert.equal(fs.existsSync(userData), false, 'opening an item must not create metadata');
store.write(true);
assert.deepEqual(JSON.parse(fs.readFileSync(store.file)), { premultipliedAlpha: true });
assert.equal(createStore(userData, source).read(), true);
// Asset rename preserves the Eagle item identity.
assert.equal(createStore(userData, path.join(path.dirname(source), 'renamed.skel.bytes')).read(), true);
store.write(false);
assert.equal(createStore(userData, source).read(), false, 'false must not be treated as unset');
assert.equal(createStore(userData, source.replace('ITEM.info', 'OTHER.info')).read(), undefined);
assert.equal(createStore(userData, source.replace('A.library', 'B.library')).read(), undefined);
assert.throws(() => store.write('false'), TypeError);
assert.equal(fs.existsSync(path.dirname(source)), false, 'never modify Eagle metadata');
console.log('PASS: no default writes, explicit true/false, reopening, rename and library isolation');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
+46
View File
@@ -0,0 +1,46 @@
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { normalizeItem } = require('../js/eagle-compound');
const generate = require('../thumbnail/spine');
const { canvasLib } = require('../js/spine-render');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-'));
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures/3.4/spineboy'));
(async () => {
try {
for (const [name, ext, full] of [['lili.atlas', 'txt', 'atlas.txt'], ['lili.skel', 'bytes', 'skel.bytes']]) {
const item = { name, ext, text: 'old text' };
const source = path.join(root, name + '.' + ext);
assert(normalizeItem(source, item));
assert.equal(item.name, 'lili'); assert.equal(item.ext, full);
assert.equal(path.basename(source), item.name + '.' + item.ext);
assert.equal(item.text, undefined); assert.equal(normalizeItem(source, item), false);
}
for (const text of ['', 'ordinary text\nsecond line']) {
const src = path.join(root, 'notes.txt'); fs.writeFileSync(src, text);
const result = await generate({ src, dest: path.join(root, 'notes_thumbnail.png'), item: { name: 'notes', ext: 'txt' } });
assert.equal(result.text, text); assert.equal(result.ext, 'txt'); assert.equal(result.name, 'notes');
}
for (const [source, target] of [['spineboy-mesh.skel', 'spineboy.skel.bytes'], ['spineboy.atlas', 'spineboy.atlas.txt'], ['spineboy.png', 'spineboy.png']])
fs.copyFileSync(path.join(fixtures, source), path.join(root, target));
for (const [name, ext] of [['spineboy.atlas', 'txt'], ['spineboy.skel', 'bytes']]) {
const src = path.join(root, name + '.' + ext), before = fs.readFileSync(src);
const dest = path.join(root, name + '_thumbnail.png');
const result = await generate({ src, dest, item: { name, ext } });
assert.equal(result.name, 'spineboy'); assert.equal(result.ext, name.split('.')[1] + '.' + ext);
assert(before.equals(fs.readFileSync(path.join(root, result.name + '.' + result.ext))));
const canonical = path.join(root, 'spineboy_thumbnail.png');
assert(fs.existsSync(dest)); assert(fs.existsSync(canonical));
assert.equal(fs.readFileSync(canonical).subarray(8,12).toString(), 'WEBP');
const image = await canvasLib.loadImage(canonical), canvas = canvasLib.createCanvas(400,400);
const ctx = canvas.getContext('2d'); ctx.drawImage(image,0,0);
const pixels = ctx.getImageData(12,356,110,32).data;
let white = 0; for (let i = 0; i < pixels.length; i += 4) if (pixels[i]>190 && pixels[i+1]>190 && pixels[i+2]>190) white++;
assert(white > 50, 'bottom-left version watermark must be visible');
}
console.log('PASS: ordinary TXT remains visible, compound metadata round-trip, canonical WebP cache and version watermark');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+36
View File
@@ -0,0 +1,36 @@
<!doctype html>
<meta charset="utf-8">
<title>Spine mesh seam regression</title>
<pre id="result">Running</pre>
<script>var module = { exports: {} };</script>
<script src="../js/spine-webgl-rasterizer.js"></script>
<script>
// Open locally in Chromium. A translucent quad must have neither cracks nor
// double-blended pixels along its shared diagonal, including after scaling.
try {
for (const scale of [1, 1.25]) {
const target = document.createElement('canvas');
target.width = target.height = 96;
const ctx = target.getContext('2d');
ctx.translate(2, 3); ctx.scale(scale, scale);
const image = document.createElement('canvas');
image.width = image.height = 8;
const paint = image.getContext('2d');
paint.fillStyle = '#80c040'; paint.fillRect(0, 0, 8, 8);
const rasterizer = module.exports.createRasterizer(ctx);
if (!rasterizer || !rasterizer.begin()) throw new Error('WebGL unavailable');
ctx.globalAlpha = 0.5;
rasterizer.triangle(image, 8,8,0,0, 56,8,1,0, 56,56,1,1);
rasterizer.triangle(image, 8,8,0,0, 56,56,1,1, 8,56,0,1);
rasterizer.end();
const pixels = ctx.getImageData(0,0,96,96).data;
for (let y = 15; y < 50; y++) for (let x = 15; x < 50; x++) {
const offset = (y * 96 + x) * 4;
if (Math.abs(pixels[offset+3]-128)>1 || Math.abs(pixels[offset]-128)>2)
throw new Error(`Seam/overlap at ${x},${y}, scale ${scale}: ${pixels.slice(offset,offset+4)}`);
}
rasterizer.dispose();
}
document.getElementById('result').textContent = 'PASS: shared edges, opacity, transformed geometry';
} catch (error) { document.getElementById('result').textContent = 'FAIL: ' + error.stack; }
</script>
+71 -7
View File
@@ -22,7 +22,7 @@ const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v11';
const VERSION = 'v13';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
@@ -125,7 +125,37 @@ async function renderThumbnailShared(assets, options) {
* - dest : 需要生成的缩略图 PNG 路径
* - item : 当前 item 对象(可更新宽高)
*/
module.exports = async ({ src, dest, item }) => {
module.exports = async (options) => {
const { src, dest, item } = options;
const changed = require('../js/eagle-compound.js').normalizeItem(src, item);
// Eagle originally chooses the cache name before invoking the plugin.
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
fs.writeFileSync(canonical, await canvas.encode('webp', 90));
} else fs.copyFileSync(dest, canonical);
}
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
// The item does not enter Eagle's item map until this callback returns.
// Reconcile afterwards, using the correctly named persistent cache.
setTimeout(() => {
try {
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true });
if (assetSignature(assets)) refreshRelatedThumbnails(assets, canonical, item.id).catch(e => log(String(e)));
} catch (e) { log('复合后缀补刷失败: ' + String(e)); }
}, 1500);
}
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
@@ -143,6 +173,20 @@ module.exports = async ({ src, dest, item }) => {
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
const srcExt = utils.extOf(path.basename(src));
// Eagle dispatches compound suffixes as txt/bytes. Only the complete
// .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit.
if (!srcExt) {
if (/\.txt$/i.test(src)) {
// Eagle's native text card requires item.text, even for an empty file.
item.text = '';
const fd = fs.openSync(src, 'r');
try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); }
finally { fs.closeSync(fd); }
}
await writeNeutralJsonPlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
await copyImageAsThumb(src, dest);
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
@@ -181,7 +225,7 @@ module.exports = async ({ src, dest, item }) => {
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
await refreshRelatedThumbnails(assets, dest, item && item.id);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -219,7 +263,7 @@ module.exports = async ({ src, dest, item }) => {
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
await refreshRelatedThumbnails(assets, dest, item && item.id);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -227,7 +271,7 @@ module.exports = async ({ src, dest, item }) => {
}
fs.writeFileSync(dest, png);
await refreshRelatedThumbnails(assets, dest, item && item.id);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
@@ -262,7 +306,22 @@ function itemIdFromAssetPath(filePath) {
return match ? match[1] : null;
}
function setEagleCustomThumbnail(itemId, thumbnailPath) {
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
@@ -280,6 +339,7 @@ function setEagleCustomThumbnail(itemId, thumbnailPath) {
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
@@ -307,7 +367,10 @@ async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => setEagleCustomThumbnail(id, apiPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
@@ -458,6 +521,7 @@ async function writeAtlasFallback(assets, dest) {
const h = Math.max(1, Math.round(img.height * scale));
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
await render.drawSpineBadge(ctx, size, size);
if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath));
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return true;
} catch (e) {
+58 -10
View File
@@ -137,7 +137,7 @@
<span id="speedVal">1.0x</span>
</label>
<label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck"> 预乘
<input type="checkbox" id="pmaCheck" disabled> 预乘
</label>
<div class="spacer"></div>
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
@@ -237,9 +237,11 @@
let skeleton = null, state = null, stateData = null, skeletonData = null;
let renderer = null;
let activeRuntimeDir = null;
let activeVersion = null;
let currentAnim = 0, currentSkin = null;
let playing = true, loop = true, speed = 1;
let premultipliedAlpha = false;
let previewSettings = null;
let timeKeeper = null, lastTime = 0, running = false;
let seeking = false; // 用户正在拖动时间轴
let duration = 0; // 当前动画时长
@@ -414,6 +416,13 @@
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) {
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const info = await response.json();
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库');
if (info.data.ext === 'txt') throw new Error('此条目仍按 TXT 登记,请在 Eagle 重新生成缩略图并重新打开素材库');
}
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -457,16 +466,18 @@
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-cx, -cy);
let offRenderer = renderer;
let offRenderer = null;
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
} catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ }
offRenderer.premultipliedAlpha = premultipliedAlpha;
if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true;
offRenderer.draw(skeleton);
octx.restore();
offRenderer.premultipliedAlpha = premultipliedAlpha;
offRenderer.draw(skeleton);
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
octx.restore();
}
}
await drawSpineBadge(octx, size, size);
require('../js/spine-badge.js').drawVersion(octx, size, size, activeVersion);
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
let out = off.toDataURL('image/webp', 0.9);
@@ -487,7 +498,7 @@
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
try {
fs.writeFileSync(outPath, buf);
setStatus('缩略图已写入;当前 Eagle 版本未能自动刷新,请按 Cmd/Ctrl+R', 6000);
setStatus('缩略图已写入;自动刷新失败:' + ((e && e.message) || String(e)), 6000);
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
} catch (e2) {
setStatus('生成缩略图失败: ' + ((e2 && e2.message) || String(e2)), 6000);
@@ -798,6 +809,27 @@
// ---------- 加载资源并与运行时交互 ----------
async function loadAssets() {
if (!utils.extOf(path.basename(filePath))) {
// Registering txt/bytes is required by Eagle's extension dispatch.
// Ordinary text remains readable and never starts Spine discovery.
document.getElementById('toolbar').style.display = 'none';
document.getElementById('timelineBar').style.display = 'none';
let message = '此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。';
if (/\.txt$/i.test(filePath)) {
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(256 * 1024);
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
message = buffer.subarray(0, count).toString('utf8');
if (fs.fstatSync(fd).size > count) message += '\n\n(仅显示前 256 KB)';
} finally { fs.closeSync(fd); }
}
showError(path.basename(filePath), message);
const box = overlay.querySelector('.box');
box.style.cssText = 'max-width:none;width:100%;height:100%;overflow:auto;text-align:left';
document.getElementById('errorTitle').style.color = 'var(--text)';
return;
}
// Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容,
// 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。
if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') {
@@ -814,12 +846,21 @@
}
return;
}
try {
const settings = require('../js/spine-preview-settings.js');
const userData = await settings.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
previewSettings = settings.createStore(userData, filePath, urlParams.get('id'));
const saved = previewSettings.read();
if (saved !== undefined) premultipliedAlpha = saved;
document.getElementById('pmaCheck').checked = premultipliedAlpha;
} catch (e) { log('读取预乘设置失败: ' + e.message); }
// 检测版本并选择运行时
const version = utils.detectVersion(assets.skeletonPath);
activeVersion = version;
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.4 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
return;
}
activeRuntimeDir = runtimeDir;
@@ -877,6 +918,7 @@
renderer = clippingRenderer.createRenderer(spine, ctx, runtimeDir);
renderer.premultipliedAlpha = premultipliedAlpha;
document.getElementById('pmaCheck').disabled = false;
fillAnimations();
fillSkins();
@@ -945,7 +987,13 @@
premultipliedAlpha = e.target.checked;
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;
draw();
setStatus(premultipliedAlpha ? '已开启预乘 Alpha 渲染' : '已关闭预乘 Alpha 渲染', 1800);
try {
if (!previewSettings) throw new Error('素材设置尚未就绪');
previewSettings.write(premultipliedAlpha);
setStatus((premultipliedAlpha ? '预乘已开启' : '预乘已关闭') + ' · 已记住此素材的设置', 1800);
} catch (err) {
setStatus('预乘已切换,但设置保存失败:' + err.message, 5000);
}
});
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();