干净发布副本
This commit is contained in:
1 parent
c29de7a26b
commit
acc93d5404
35 files changed
+82427
No files matched your search
@@ -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.
|
||||
*****************************************************************************/
|
||||
@@ -0,0 +1,33 @@
|
||||
Source revision: 2cd9467d2736b7f3a0b81382f117c73823bfb44b (official 3.5.51 tag)
|
||||
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/spine-ts/build/spine-canvas.js
|
||||
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/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.
|
||||
*****************************************************************************/
|
||||
+62
@@ -0,0 +1,62 @@
|
||||
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.6/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.
|
||||
*****************************************************************************/
|
||||
|
||||
|
||||
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.7/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
|
||||
/******************************************************************************
|
||||
* Spine Runtimes License Agreement
|
||||
* Last updated May 1, 2019. Replaces all prior versions.
|
||||
*
|
||||
* Copyright (c) 2013-2019, Esoteric Software LLC
|
||||
*
|
||||
* Integration of the Spine Runtimes into software or otherwise creating
|
||||
* derivative works of the Spine Runtimes is permitted under the terms and
|
||||
* conditions of Section 2 of the Spine Editor License Agreement:
|
||||
* http://esotericsoftware.com/spine-editor-license
|
||||
*
|
||||
* Otherwise, it is permitted to integrate the Spine Runtimes into software
|
||||
* or otherwise create derivative works of the Spine Runtimes (collectively,
|
||||
* "Products"), provided that each user of the Products must obtain their own
|
||||
* Spine Editor license and redistribution of the Products in any form must
|
||||
* include this license and copyright notice.
|
||||
*
|
||||
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE LLC "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 LLC 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.
|
||||
*****************************************************************************/
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
Spine Runtimes License Agreement
|
||||
Last updated April 5, 2025. Replaces all prior versions.
|
||||
|
||||
Copyright (c) 2013-2025, Esoteric Software LLC
|
||||
|
||||
Integration of the Spine Runtimes into software or otherwise creating
|
||||
derivative works of the Spine Runtimes is permitted under the terms and
|
||||
conditions of Section 2 of the Spine Editor License Agreement:
|
||||
https://esotericsoftware.com/spine-editor-license
|
||||
|
||||
Otherwise, it is permitted to integrate the Spine Runtimes into software
|
||||
or otherwise create derivative works of the Spine Runtimes (collectively,
|
||||
"Products"), provided that each user of the Products must obtain their own
|
||||
Spine Editor license and redistribution of the Products in any form must
|
||||
include this license and copyright notice.
|
||||
|
||||
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "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 LLC 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
|
||||
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.5 KiB |
@@ -0,0 +1,134 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>SpineThumbnailGenerateServer</title>
|
||||
<style>
|
||||
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; --warn: #e3b341; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
|
||||
body { display: flex; flex-direction: column; gap: 9px; padding: 14px; box-sizing: border-box; }
|
||||
h1 { font-size: 15px; margin: 0; font-weight: 600; text-align: center; }
|
||||
.notice { color: var(--dim); line-height: 1.6; background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
|
||||
.notice b { color: var(--text); font-weight: 500; }
|
||||
.notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; }
|
||||
.toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
|
||||
flex: 1 1 240px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
|
||||
.toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
||||
/* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
|
||||
.toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
|
||||
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
|
||||
#clear:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
|
||||
.mode-row select { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 3px 6px; font: inherit; max-width: 200px; }
|
||||
.row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
|
||||
.row b { color: var(--text); font-weight: 500; word-break: break-all; }
|
||||
#stats { color: var(--accent); }
|
||||
#log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none; min-height: 140px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; }
|
||||
#log li { white-space: pre-wrap; word-break: break-all; }
|
||||
#log li.dim { color: var(--dim); }
|
||||
#log li.ok { color: var(--accent); }
|
||||
#log li.fail { color: #e06c75; }
|
||||
#log li.warn { color: var(--warn); }
|
||||
#log li.error { color: #ff5f56; }
|
||||
/* 过滤直接用 CSS:给 #log 一个 data-filter,非该类别的行就不显示,不用遍历 DOM。 */
|
||||
#log[data-filter="ok"] li:not(.ok),
|
||||
#log[data-filter="fail"] li:not(.fail),
|
||||
#log[data-filter="warn"] li:not(.warn),
|
||||
#log[data-filter="error"] li:not(.error) { display: none; }
|
||||
.filters { display: flex; gap: 6px; }
|
||||
.filters button { background: var(--panel); color: var(--dim); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.filters button.active { color: var(--accent); border-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Spine缩略图</h1>
|
||||
<div class="notice">
|
||||
导入 Spine 资产(<code>.skel</code> / <code>.json</code> + <code>.atlas</code> + 贴图)后,
|
||||
本插件会为还没设置缩略图的素材生成一张骨骼预览图。
|
||||
<b>它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</b>
|
||||
生成方式可切换:<b>动态</b>取骨骼里的第一个动画压成 WebP(200×200,体积按动画时长自动定,
|
||||
参数与主插件默认值一致),<b>静帧</b>始终用 setup pose。
|
||||
自动生成默认关闭,需要你在下面手动启用。
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<label class="toggle-row"><input type="checkbox" id="toggle"><span>导入时自动生成缩略图</span></label>
|
||||
<button id="clear" type="button">清空日志</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<label class="mode-row"><span>生成方式</span>
|
||||
<select id="mode">
|
||||
<option value="animated">动态(第一个动画)</option>
|
||||
<option value="static">静帧(setup pose)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="mode-row"><span>生成策略</span>
|
||||
<select id="strategy">
|
||||
<option value="size">最优体积(慢)</option>
|
||||
<option value="speed">最优速度(快)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row"><span>状态</span><b id="status">启动中…</b></div>
|
||||
<div class="row"><span>素材库</span><b id="library">—</b></div>
|
||||
<div class="row"><span>统计</span><b id="stats">—</b></div>
|
||||
<div class="filters" id="filters">
|
||||
<button type="button" data-filter="all" class="active">全部</button>
|
||||
<button type="button" data-filter="ok">成功</button>
|
||||
<button type="button" data-filter="fail">失败</button>
|
||||
<button type="button" data-filter="warn">警告</button>
|
||||
<button type="button" data-filter="error">错误</button>
|
||||
</div>
|
||||
<ul id="log"></ul>
|
||||
<script>
|
||||
// 诊断用:脚本一旦抛错,直接把原因写在状态栏,避免停在「启动中…」看不出问题。
|
||||
window.addEventListener('error', (event) => {
|
||||
const el = document.getElementById('status');
|
||||
if (el) el.textContent = '脚本错误:' + (event.message || event.error || '未知错误');
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const el = document.getElementById('status');
|
||||
if (el) el.textContent = '未处理的 Promise 错误:' + (event.reason && event.reason.message || event.reason);
|
||||
});
|
||||
// service 页面里的 require 不以插件目录为基准,本地资源要用绝对路径,这里先把根目录算出来。
|
||||
(function () {
|
||||
try {
|
||||
// Windows 上 pathname 形如 /C:/...,要去掉盘符前那个多余的斜杠;
|
||||
// macOS/Linux 是 /Users/...,前导斜杠必须留着,否则会变成相对路径。
|
||||
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
|
||||
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||
} catch (error) {
|
||||
window.SpinePluginRoot = '';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<!-- 顺序有依赖:rasterizer 先于 clipping,utils/badge 先于 renderer,plugin.js 最后。 -->
|
||||
<script src="js/spine-webgl-rasterizer.js"></script>
|
||||
<script src="js/spine-canvas-clipping.js"></script>
|
||||
<script src="js/spine-background.js"></script>
|
||||
<script src="js/spine-visible-bounds.js"></script>
|
||||
<script src="js/animated-profiles.js"></script>
|
||||
<script src="js/animated-webp-encoder.js"></script>
|
||||
<script src="js/spine-utils.js"></script>
|
||||
<script src="js/spine-legacy-binary.js"></script>
|
||||
<script src="js/spine-badge.js"></script>
|
||||
<script src="js/settings.js"></script>
|
||||
<script src="js/progress-window.js"></script>
|
||||
<script src="js/renderer.js"></script>
|
||||
<script src="js/library-watcher.js"></script>
|
||||
<script src="js/spine-assets.js"></script>
|
||||
<script src="js/plugin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
// 动态缩略图的档位配置。数值与主插件 js/animated-profiles.js 保持一致,
|
||||
// 后台插件没有参数面板,所以这里只当默认值来源,改这里就等于改后台插件的默认渲染参数。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
|
||||
* - 细节分辨率决定画面保留多少细节(输出画布始终是画布边长,小分辨率会被放大填满);
|
||||
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
|
||||
* - 编码质量是 0–1 的 WebP 质量。
|
||||
*
|
||||
* 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
|
||||
* 路径,再按目标体积在路径上定位,取命中位置附近若干档完整生成,挑出符合体积的那一档。
|
||||
*/
|
||||
const ANIMATED_PRIORITIES = {
|
||||
// 分辨率优先:先把帧率压到底,再降分辨率。
|
||||
resolution: [
|
||||
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
|
||||
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
|
||||
],
|
||||
// 帧率优先:先把分辨率压到底,再降帧率。
|
||||
framerate: [
|
||||
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
|
||||
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
|
||||
],
|
||||
// 交替进行:分辨率和帧率交替下降。
|
||||
balanced: [
|
||||
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
|
||||
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_PRIORITY = 'resolution';
|
||||
|
||||
/** 可选的输出画布边长(正方形)。后台插件默认用 200。 */
|
||||
const ANIMATED_OUTPUT_SIZES = [200, 400];
|
||||
const DEFAULT_OUTPUT_SIZE = 200;
|
||||
|
||||
/** 目标体积的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。 */
|
||||
const SIZE_PER_SECOND = 25;
|
||||
const SIZE_SUGGESTION_RANGE = [25, 100];
|
||||
|
||||
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,粗估阶段耗时略增。
|
||||
const PATH_STEPS_PER_SEGMENT = 6;
|
||||
|
||||
function suggestSize(seconds) {
|
||||
const [min, max] = SIZE_SUGGESTION_RANGE;
|
||||
return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
|
||||
}
|
||||
|
||||
function animatedProfiles(priority, outputSize) {
|
||||
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||
return plan.map(([size, fps, quality]) => ({
|
||||
size: Math.min(size, limit),
|
||||
fps,
|
||||
quality,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把锚点路径插值成更密的采样路径,供按目标体积定位使用。
|
||||
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
||||
*/
|
||||
function expandAnimatedPath(priority, outputSize) {
|
||||
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||
const lerp = (from, to, t) => from + (to - from) * t;
|
||||
const path = [];
|
||||
for (let index = 0; index < anchors.length - 1; index++) {
|
||||
const [fromSize, fromFps, fromQuality] = anchors[index];
|
||||
const [toSize, toFps, toQuality] = anchors[index + 1];
|
||||
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
||||
const t = step / PATH_STEPS_PER_SEGMENT;
|
||||
path.push({
|
||||
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
|
||||
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
||||
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
const [size, fps, quality] = anchors[anchors.length - 1];
|
||||
path.push({ size: Math.min(size, limit), fps, quality });
|
||||
return path;
|
||||
}
|
||||
|
||||
const api = {
|
||||
ANIMATED_PRIORITIES,
|
||||
DEFAULT_PRIORITY,
|
||||
ANIMATED_OUTPUT_SIZES,
|
||||
DEFAULT_OUTPUT_SIZE,
|
||||
SIZE_PER_SECOND,
|
||||
SIZE_SUGGESTION_RANGE,
|
||||
PATH_STEPS_PER_SEGMENT,
|
||||
animatedProfiles,
|
||||
expandAnimatedPath,
|
||||
suggestSize,
|
||||
};
|
||||
if (typeof window !== 'undefined') window.SpineAnimatedProfiles = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
// 把若干张 WebP 静帧封装成一个动态 WebP(ANMF 全帧、不混合)。
|
||||
// 与主插件 js/animated-webp-encoder.js 是同一份实现,只是出口兼容 <script> 加载。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function writeUint24LE(buffer, offset, value) {
|
||||
buffer[offset] = value & 0xff;
|
||||
buffer[offset + 1] = (value >>> 8) & 0xff;
|
||||
buffer[offset + 2] = (value >>> 16) & 0xff;
|
||||
}
|
||||
|
||||
function chunk(type, payload) {
|
||||
const padding = payload.length & 1;
|
||||
const output = Buffer.alloc(8 + payload.length + padding);
|
||||
output.write(type, 0, 4, 'ascii');
|
||||
output.writeUInt32LE(payload.length, 4);
|
||||
payload.copy(output, 8);
|
||||
return output;
|
||||
}
|
||||
|
||||
function frameData(webp) {
|
||||
if (!webp) throw new Error('WebP 帧数据缺失');
|
||||
const input = Buffer.from(webp);
|
||||
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
throw new Error('Invalid WebP frame');
|
||||
}
|
||||
const parts = [];
|
||||
let hasAlpha = false;
|
||||
let offset = 12;
|
||||
while (offset + 8 <= input.length) {
|
||||
const type = input.toString('ascii', offset, offset + 4);
|
||||
const length = input.readUInt32LE(offset + 4);
|
||||
const end = offset + 8 + length + (length & 1);
|
||||
if (end > input.length) throw new Error('Truncated WebP frame');
|
||||
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
|
||||
if (type === 'ALPH') hasAlpha = true;
|
||||
offset = end;
|
||||
}
|
||||
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
|
||||
throw new Error('WebP frame has no image data');
|
||||
}
|
||||
return { data: Buffer.concat(parts), hasAlpha };
|
||||
}
|
||||
|
||||
function encode(options) {
|
||||
const width = Number(options && options.width);
|
||||
const height = Number(options && options.height);
|
||||
const frames = options && options.frames;
|
||||
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
|
||||
throw new RangeError('Animated WebP dimensions are invalid');
|
||||
}
|
||||
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
|
||||
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
|
||||
|
||||
const vp8x = Buffer.alloc(10);
|
||||
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
|
||||
writeUint24LE(vp8x, 4, width - 1);
|
||||
writeUint24LE(vp8x, 7, height - 1);
|
||||
const anim = Buffer.alloc(6);
|
||||
const background = options.background || [30, 30, 30, 255];
|
||||
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
|
||||
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
|
||||
|
||||
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
|
||||
for (const { frame, data } of preparedFrames) {
|
||||
const header = Buffer.alloc(16);
|
||||
writeUint24LE(header, 6, width - 1);
|
||||
writeUint24LE(header, 9, height - 1);
|
||||
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
|
||||
header[15] = 0x02;
|
||||
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
|
||||
}
|
||||
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
|
||||
const output = Buffer.alloc(8 + body.length);
|
||||
output.write('RIFF', 0, 4, 'ascii');
|
||||
output.writeUInt32LE(body.length, 4);
|
||||
body.copy(output, 8);
|
||||
return output;
|
||||
}
|
||||
|
||||
const api = { encode };
|
||||
if (typeof window !== 'undefined') window.SpineAnimatedWebpEncoder = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,121 @@
|
||||
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载;包成 IIFE 是为了
|
||||
// 让多个页面脚本各自的 const 不互相冲突。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Eagle 没有暴露「素材导入完成」事件,所以用「目录静默检测」代替:
|
||||
// 监听素材目录,任何变化都重置防抖计时器,安静 QUIET_MS 之后才认为这一批拖入结束。
|
||||
//
|
||||
// 1 秒:足够合并连续写入。Eagle 的「重复添加提示」是主窗口里的 Angular 模态框,
|
||||
// 没有独立窗口句柄可检测,插件只能靠静默判断——那期间素材库是静止的,但重复文件
|
||||
// 还没入库,扫到的只是已入库的非重复素材。
|
||||
const QUIET_MS = 1000;
|
||||
// fs.watch 在网络盘/外置盘上可能漏事件,用低频兜底扫描补上。
|
||||
const FALLBACK_MS = 30000;
|
||||
|
||||
/** 删除指定目录中超过保留时间的本插件临时文件。 */
|
||||
function cleanupStaleFiles(directory, prefix, maxAgeMs, now = Date.now()) {
|
||||
let names;
|
||||
try { names = fs.readdirSync(directory); } catch (error) { return 0; }
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
const file = path.join(directory, name);
|
||||
try {
|
||||
const stat = fs.lstatSync(file);
|
||||
if (!stat.isFile() || now - stat.mtimeMs < maxAgeMs) continue;
|
||||
fs.unlinkSync(file);
|
||||
removed += 1;
|
||||
} catch (error) { /* 单个文件失败不影响其余清理和监听 */ }
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function createLibraryWatcher(options) {
|
||||
const onBatch = options.onBatch || (() => {});
|
||||
const onLog = options.onLog || (() => {});
|
||||
let imagesDir = null;
|
||||
let watcher = null;
|
||||
let quietTimer = null;
|
||||
let fallbackTimer = null;
|
||||
let known = null;
|
||||
|
||||
function listItemDirs() {
|
||||
try {
|
||||
return new Set(fs.readdirSync(imagesDir).filter((name) => name.endsWith('.info')));
|
||||
} catch (error) {
|
||||
onLog('读取素材目录失败:' + ((error && error.message) || error));
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/** 与上次快照比对,把新增的素材目录交给上层处理。 */
|
||||
function flush(reason) {
|
||||
if (!imagesDir || !known) return;
|
||||
const current = listItemDirs();
|
||||
const added = [...current].filter((name) => !known.has(name));
|
||||
known = current;
|
||||
onBatch({ added, total: current.size, reason });
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (quietTimer) clearTimeout(quietTimer);
|
||||
quietTimer = setTimeout(() => {
|
||||
quietTimer = null;
|
||||
flush('quiet');
|
||||
}, QUIET_MS);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (watcher) { watcher.close(); watcher = null; }
|
||||
if (quietTimer) { clearTimeout(quietTimer); quietTimer = null; }
|
||||
if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null; }
|
||||
imagesDir = null;
|
||||
known = null;
|
||||
}
|
||||
|
||||
/** 监听指定素材库。首次快照里的素材视为历史数据,不处理。 */
|
||||
function start(libraryPath) {
|
||||
stop();
|
||||
imagesDir = path.join(libraryPath, 'images');
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
onLog('素材目录不存在:' + imagesDir);
|
||||
return false;
|
||||
}
|
||||
known = listItemDirs();
|
||||
onLog('监听 ' + imagesDir + '(现有 ' + known.size + ' 个素材视为历史,不处理)');
|
||||
try {
|
||||
watcher = fs.watch(imagesDir, { persistent: true }, () => scheduleFlush());
|
||||
} catch (error) {
|
||||
onLog('建立监听失败:' + ((error && error.message) || error));
|
||||
return false;
|
||||
}
|
||||
fallbackTimer = setInterval(() => {
|
||||
const current = listItemDirs();
|
||||
// 不能只比数量:同一次导里如果有增有删,数量可能刚好抵消。
|
||||
let changed = current.size !== known.size;
|
||||
if (!changed) {
|
||||
for (const name of current) {
|
||||
if (!known.has(name)) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
if (changed) flush('fallback');
|
||||
}, FALLBACK_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { start, stop, flush, isRunning: () => !!watcher };
|
||||
}
|
||||
|
||||
// 同时支持两种加载方式:Node 里用 require(跑测试),插件页面里用 <script> 标签。
|
||||
// service 插件页面的 require 解析基准不是插件目录,所以页面里必须走 script 标签。
|
||||
const libraryWatcherApi = { createLibraryWatcher, cleanupStaleFiles, QUIET_MS, FALLBACK_MS };
|
||||
// 优先挂 window:Eagle 插件页面里也存在 module 对象(它有 Node 模块系统),
|
||||
// 先判断 module 会让页面拿到模块却取不到 window 上的名字。Node 里没有 window,自然走 require 分支。
|
||||
if (typeof window !== 'undefined') window.SpineThumbLibraryWatcher = libraryWatcherApi;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = libraryWatcherApi;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,526 @@
|
||||
'use strict';
|
||||
|
||||
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
|
||||
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
|
||||
const VERSION = 'v65';
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
|
||||
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
|
||||
const REQUIRED = [
|
||||
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
|
||||
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
|
||||
'SpineThumbProgress',
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !window[name]);
|
||||
if (missing.length) {
|
||||
document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', ');
|
||||
throw new Error('本地模块未加载,请检查 index.html 里 script 的顺序:' + missing.join(', '));
|
||||
}
|
||||
const { createLibraryWatcher, cleanupStaleFiles } = window.SpineThumbLibraryWatcher;
|
||||
const { scanSpineItems } = window.SpineThumbSpineAssets;
|
||||
const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
|
||||
const settingsStore = window.SpineThumbSettings;
|
||||
const utils = window.SpineUtils;
|
||||
const progress = window.SpineThumbProgress;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const libraryEl = document.getElementById('library');
|
||||
const statsEl = document.getElementById('stats');
|
||||
const logEl = document.getElementById('log');
|
||||
const toggleEl = document.getElementById('toggle');
|
||||
const clearEl = document.getElementById('clear');
|
||||
const filtersEl = document.getElementById('filters');
|
||||
const modeEl = document.getElementById('mode');
|
||||
const strategyEl = document.getElementById('strategy');
|
||||
|
||||
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
|
||||
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
|
||||
// 判定用排队数,不要求最终真的都产出动态 WebP。
|
||||
const PROGRESS_MIN_DYNAMIC = 10;
|
||||
// 弹窗上的「停止」按钮会把动作写进这个文件,宿主读回来执行暂停/继续/取消。
|
||||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||||
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
let settings = { ...settingsStore.DEFAULTS };
|
||||
let libraryPath = null;
|
||||
let imagesDir = null;
|
||||
const queue = [];
|
||||
let running = false;
|
||||
// 本轮(一次队列排空)的统计和是否要开进度弹窗的标记;排空后一起清掉。
|
||||
let runStats = null;
|
||||
let runShowsProgress = false;
|
||||
// 弹窗暂停期间,队列原地等待,不再取出新的素材。
|
||||
let paused = false;
|
||||
let lastControlAt = 0;
|
||||
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
|
||||
// Eagle 的重复添加提示会把一次导入拆成几批,靠它才能等齐配套文件。
|
||||
const pending = new Set();
|
||||
|
||||
|
||||
function log(message, kind) {
|
||||
const line = document.createElement('li');
|
||||
if (kind) line.className = kind;
|
||||
line.textContent = '[' + new Date().toTimeString().slice(0, 8) + '] ' + message;
|
||||
logEl.appendChild(line);
|
||||
while (logEl.childElementCount > 400) logEl.removeChild(logEl.firstElementChild);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
console.log('[SpineThumbnailGenerateServer] ' + message);
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
function refreshStats() {
|
||||
statsEl.textContent = '批次 ' + stats.batches +
|
||||
' · 新增 ' + stats.added +
|
||||
' · Spine ' + stats.spine +
|
||||
' · 完整 ' + stats.complete +
|
||||
' · 已生成 ' + stats.generated +
|
||||
' · 跳过 ' + stats.skipped +
|
||||
' · 失败 ' + stats.failed;
|
||||
}
|
||||
|
||||
function itemIdOf(dirName) {
|
||||
return String(dirName).replace(/\.info$/i, '');
|
||||
}
|
||||
|
||||
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
|
||||
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
|
||||
// 文件夹树每批重读一次(见 handleBatchAsync):导入会新建文件夹、同一会话里也可能重建
|
||||
// 素材库,跨批次复用旧 id 的话日志会整片变成「未分类」。
|
||||
let folderPaths = null;
|
||||
|
||||
function loadFolderPaths() {
|
||||
folderPaths = new Map();
|
||||
try {
|
||||
const tree = JSON.parse(fs.readFileSync(path.join(libraryPath, 'metadata.json'), 'utf8')).folders || [];
|
||||
const walk = (list, prefix) => {
|
||||
for (const f of list || []) {
|
||||
const full = prefix + (f.name || f.id);
|
||||
folderPaths.set(f.id, full);
|
||||
walk(f.children, full + '/');
|
||||
}
|
||||
};
|
||||
walk(tree, '');
|
||||
} catch (error) { /* 读不到就只显示未分类 */ }
|
||||
}
|
||||
|
||||
function folderPathOf(folderIds) {
|
||||
if (!folderPaths) loadFolderPaths();
|
||||
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
|
||||
return (id && folderPaths.get(id)) || '未分类';
|
||||
}
|
||||
|
||||
/** 日志里的渲染结果描述。参数不走本地化,和主插件的候选标签是同一套写法。 */
|
||||
function describeThumbnail(rendered) {
|
||||
const kb = Math.max(1, Math.round(rendered.buffer.length / 1024));
|
||||
if (rendered.mode === 'animated') {
|
||||
return '动态 ' + rendered.detail + '*' + rendered.detail + '@' + rendered.fps + 'fps/q' +
|
||||
rendered.quality + '/' + kb + 'kb(目标 ' + rendered.targetKilobytes + 'kb)/' +
|
||||
rendered.frames + ' 帧/动画 ' + rendered.animation +
|
||||
'/' + (rendered.strategy === 'speed' ? '最优速度' : '最优体积') +
|
||||
(rendered.fits === false ? '(最小档仍超出目标体积,可以考虑提高缩略图大小)' : '');
|
||||
}
|
||||
return '静帧 ' + rendered.width + '*' + rendered.height + '/' + kb + 'kb' +
|
||||
(rendered.fallback ? '(' + rendered.fallback + ')' : '');
|
||||
}
|
||||
|
||||
/**
|
||||
* 记本轮(一次队列排空)的产出,顺手把进度推给弹窗。
|
||||
* animated 只统计真正做出动态 WebP 的数量;name 用来在弹窗里显示最近完成的文件。
|
||||
*/
|
||||
function countRun(outcome, animated, name) {
|
||||
if (!runStats) return;
|
||||
// 弹窗的暂停/取消随时生效:万一用户在最后一个素材渲染期间点了取消,这里也能记上。
|
||||
applyControl();
|
||||
runStats[outcome] += 1;
|
||||
if (animated) runStats.animated += 1;
|
||||
runStats.done += 1;
|
||||
runStats.name = name || '';
|
||||
if (runShowsProgress) {
|
||||
progress.update({ done: runStats.done, total: runStats.total, name: runStats.name });
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
|
||||
async function generateOne(item) {
|
||||
let tempPath = null;
|
||||
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
|
||||
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
|
||||
const label = item.entryName + ' | ' + folderPathOf(item.folders);
|
||||
try {
|
||||
const itemId = itemIdOf(item.dirName);
|
||||
const eagleItem = await eagle.item.getById(itemId);
|
||||
if (!eagleItem) {
|
||||
stats.failed += 1;
|
||||
countRun('failed', false, item.entryName);
|
||||
log('跳过 ' + label + ':Eagle 里找不到这个素材', 'warn');
|
||||
return;
|
||||
}
|
||||
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
|
||||
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
|
||||
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
|
||||
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
|
||||
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
|
||||
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
|
||||
try {
|
||||
eagleItem.name = parts.name;
|
||||
eagleItem.ext = parts.ext;
|
||||
await withTimeout(eagleItem.save(), 20000, '修正复合后缀 ' + label);
|
||||
log('修正复合后缀 ' + label + ' → ' + parts.name + '.' + parts.ext, 'dim');
|
||||
} catch (error) {
|
||||
log('修正复合后缀失败 ' + label + ':' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
}
|
||||
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
|
||||
if (eagleItem.customThumbnail === true) {
|
||||
stats.skipped += 1;
|
||||
countRun('skipped', false, item.entryName);
|
||||
log('跳过 ' + label + ':已有自定义缩略图', 'dim');
|
||||
return;
|
||||
}
|
||||
const version = utils.detectVersion(item.skeletonPath);
|
||||
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
|
||||
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
|
||||
const animated = settings.mode !== 'static';
|
||||
const rendered = await withTimeout(renderThumbnailAuto({
|
||||
skeletonPath: item.skeletonPath,
|
||||
atlasPath: item.atlasPath,
|
||||
texturePaths: item.texturePaths,
|
||||
runtimeDir,
|
||||
isBinary: item.isBinary,
|
||||
version,
|
||||
size: 400,
|
||||
mode: settings.mode,
|
||||
strategy: settings.strategy,
|
||||
onProgress: (current, total) => {
|
||||
setStatus('正在渲染动画帧 ' + current + '/' + total + '(排队 ' + queue.length + ' 个)');
|
||||
},
|
||||
}), animated ? 300000 : 60000, '渲染 ' + label);
|
||||
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + (rendered.extension || '.png'));
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
const applied = await withTimeout(eagleItem.setCustomThumbnail(tempPath), 20000, '设置缩略图 ' + item.dirName);
|
||||
if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图');
|
||||
stats.generated += 1;
|
||||
countRun('generated', rendered.mode === 'animated', item.entryName);
|
||||
log('已生成 ' + label + ' | ' + describeThumbnail(rendered) + ' | Spine ' + version, 'ok');
|
||||
} catch (error) {
|
||||
stats.failed += 1;
|
||||
countRun('failed', false, item.entryName);
|
||||
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
|
||||
refreshStats();
|
||||
}
|
||||
}
|
||||
|
||||
/** 读弹窗写下的控制指令:{ action: 'pause' | 'resume' | 'cancel', at: 时间戳 }。 */
|
||||
function readControl() {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(CONTROL_FILE, 'utf8'));
|
||||
if (data && typeof data.action === 'string' && Number.isFinite(data.at)) return data;
|
||||
} catch (error) { /* 没有文件或内容不完整 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 执行弹窗按钮的意图:暂停、继续、取消(取消 = 丢掉剩下的队列,已生成的保留)。 */
|
||||
function applyControl() {
|
||||
const control = readControl();
|
||||
if (!control || control.at === lastControlAt) return;
|
||||
lastControlAt = control.at;
|
||||
if (control.action === 'pause') {
|
||||
paused = true;
|
||||
setStatus('已暂停(在进度弹窗里继续或取消)');
|
||||
} else if (control.action === 'resume') {
|
||||
paused = false;
|
||||
} else if (control.action === 'cancel') {
|
||||
paused = false;
|
||||
const dropped = queue.length;
|
||||
queue.length = 0;
|
||||
log('已取消本轮生成:丢弃剩余 ' + dropped + ' 个素材,已完成 ' +
|
||||
(runStats ? runStats.done : 0) + ' 张(成功 ' + (runStats ? runStats.generated : 0) +
|
||||
' 张,失败 ' + (runStats ? runStats.failed : 0) +
|
||||
' 张,跳过 ' + (runStats ? runStats.skipped : 0) + ' 张)', 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
/** 串行处理队列:一次只渲染一个,避免批量导入时把 Eagle 卡住。 */
|
||||
async function processQueue() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
// 一次队列排空算一轮,从零开始记;排空后再决定要不要弹通知。
|
||||
if (!runStats) {
|
||||
runStats = {
|
||||
startedAt: Date.now(),
|
||||
total: queue.length,
|
||||
done: 0,
|
||||
name: '',
|
||||
animated: 0,
|
||||
generated: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
}
|
||||
paused = false;
|
||||
// 记下开跑那一刻控制文件的时间戳:上一轮遗留的暂停/取消指令不能再生效。
|
||||
const stale = readControl();
|
||||
lastControlAt = stale ? stale.at : 0;
|
||||
try {
|
||||
while (queue.length) {
|
||||
applyControl();
|
||||
if (paused) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
continue;
|
||||
}
|
||||
const item = queue.shift();
|
||||
// 取消是在上面 applyControl 里生效的,队列可能刚好被清空。
|
||||
if (!item) break;
|
||||
setStatus('正在生成缩略图…剩余 ' + queue.length + ' 个');
|
||||
await generateOne(item);
|
||||
}
|
||||
setStatus('监听中');
|
||||
} finally {
|
||||
running = false;
|
||||
paused = false;
|
||||
// 收尾放进 finally:哪怕中途出异常,进度弹窗也必须收到"本轮结束",不能一直挂着。
|
||||
const run = runStats;
|
||||
const showsProgress = runShowsProgress;
|
||||
runStats = null;
|
||||
runShowsProgress = false;
|
||||
if (run && showsProgress) finishRun(run);
|
||||
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
|
||||
function finishRun(run) {
|
||||
const seconds = Math.max(1, Math.round((Date.now() - run.startedAt) / 1000));
|
||||
const summary = '成功 ' + run.generated + ' 张,失败 ' + run.failed + ' 张,跳过 ' +
|
||||
run.skipped + ' 张,用时 ' + seconds + ' 秒。';
|
||||
log('本轮完成:' + summary, 'ok');
|
||||
progress.finish(summary);
|
||||
}
|
||||
|
||||
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
|
||||
let batchChain = Promise.resolve();
|
||||
|
||||
function handleBatch(batch) {
|
||||
batchChain = batchChain
|
||||
.then(() => handleBatchAsync(batch))
|
||||
.catch((error) => log('处理批次失败:' + ((error && error.message) || error), 'error'));
|
||||
}
|
||||
|
||||
/** 一批静默结束后触发:只分析本次新增的目录,避免每次全库扫描。 */
|
||||
async function handleBatchAsync({ added, total, reason }) {
|
||||
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
|
||||
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
|
||||
if (!added.length) return;
|
||||
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
|
||||
loadFolderPaths();
|
||||
stats.batches += 1;
|
||||
stats.added += added.length;
|
||||
log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')');
|
||||
// 本次新增 + 之前配套不全的,一起重新检查。
|
||||
const candidates = [...new Set([...added, ...pending])];
|
||||
if (pending.size) log('连带重新检查之前 ' + pending.size + ' 个配套不全的资产', 'dim');
|
||||
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
|
||||
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
|
||||
setStatus('正在配对新增素材…');
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
|
||||
setStatus('正在配对 ' + current + '/' + all);
|
||||
});
|
||||
// 统计只算本次新增的:待观察的重查会重复计入,数字会虚高。
|
||||
const fresh = items.filter((item) => added.includes(item.dirName));
|
||||
stats.spine += fresh.length;
|
||||
stats.complete += fresh.filter((item) => item.complete).length;
|
||||
if (!items.length) log('这批里没有 Spine 资产', 'dim');
|
||||
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
|
||||
const incomplete = items.filter((item) => !item.complete);
|
||||
pending.clear();
|
||||
// 「待观察」只在自动生成开着的时候记:关着的时候导入的素材属于"没赶上",不该在
|
||||
// 开关重新打开后被回头处理——这个插件只对导入当时开着开关的素材生效。
|
||||
if (settings.enabled) {
|
||||
for (const item of incomplete) pending.add(item.dirName);
|
||||
}
|
||||
if (incomplete.length) {
|
||||
log(settings.enabled
|
||||
? '有 ' + incomplete.length + ' 个资产配套不全,先记为待观察,等后续批次补齐'
|
||||
: '有 ' + incomplete.length + ' 个资产配套不全,本批不处理',
|
||||
settings.enabled ? 'warn' : 'dim');
|
||||
}
|
||||
for (const item of items) {
|
||||
const name = (filePath) => (filePath ? path.basename(filePath) : '无');
|
||||
log(' ' + item.entryName + ' | ' + folderPathOf(item.folders) + ' | ' +
|
||||
(item.complete ? '完整' : '不完整') +
|
||||
'|骨架 ' + name(item.skeletonPath) +
|
||||
'|atlas ' + name(item.atlasPath) +
|
||||
'|贴图页 ' + item.pages + '(缺 ' + item.missingTextures + ')',
|
||||
item.complete ? 'ok' : 'warn');
|
||||
}
|
||||
refreshStats();
|
||||
|
||||
const ready = items.filter((item) => item.complete);
|
||||
if (!ready.length) return;
|
||||
if (!settings.enabled) {
|
||||
log('自动生成已关闭,本批 ' + ready.length + ' 个资产已跳过', 'warn');
|
||||
setStatus('自动生成未启用,本批已跳过');
|
||||
return;
|
||||
}
|
||||
queue.push(...ready);
|
||||
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
|
||||
if (runStats) runStats.total += ready.length;
|
||||
// 硬性条件:动态模式且这一次排队超过 10 个,才开进度弹窗,跑完在里面给汇总。
|
||||
if (settings.mode !== 'static' && ready.length > PROGRESS_MIN_DYNAMIC) {
|
||||
runShowsProgress = true;
|
||||
progress.open();
|
||||
progress.update({
|
||||
done: runStats ? runStats.done : 0,
|
||||
total: runStats ? runStats.total : queue.length,
|
||||
});
|
||||
}
|
||||
log('已加入队列 ' + ready.length + ' 个,当前排队 ' + queue.length + ' 个' +
|
||||
(running ? '(正在处理中)' : ''), 'dim');
|
||||
processQueue();
|
||||
}
|
||||
|
||||
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log });
|
||||
|
||||
log('插件代码版本 ' + VERSION);
|
||||
|
||||
async function resolveLibraryPath() {
|
||||
const direct = eagle.library && eagle.library.path;
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const info = await eagle.library.info();
|
||||
const fromApi = info && (info.path || info.libraryPath || info.dir);
|
||||
if (fromApi) return fromApi;
|
||||
log('library.info() 没有返回路径字段:' + JSON.stringify(info), 'dim');
|
||||
} catch (error) {
|
||||
log('library.info() 调用失败:' + ((error && error.message) || error), 'error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const resolved = await resolveLibraryPath();
|
||||
if (!resolved) {
|
||||
setStatus('没有拿到素材库路径');
|
||||
log('切换素材库时会重试', 'warn');
|
||||
return;
|
||||
}
|
||||
libraryPath = resolved;
|
||||
imagesDir = path.join(resolved, 'images');
|
||||
folderPaths = null; // 换库了,文件夹树重新读
|
||||
libraryEl.textContent = resolved;
|
||||
setStatus(libraryWatcher.start(resolved) ? '监听中' : '监听失败');
|
||||
}
|
||||
|
||||
function startSafely() {
|
||||
start().catch((error) => {
|
||||
setStatus('启动失败:' + ((error && error.message) || error));
|
||||
log('启动失败:' + ((error && error.stack) || error), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await settingsStore.read();
|
||||
} catch (error) {
|
||||
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
|
||||
settings = { ...settingsStore.DEFAULTS };
|
||||
}
|
||||
toggleEl.checked = settings.enabled === true;
|
||||
modeEl.value = settings.mode === 'static' ? 'static' : 'animated';
|
||||
strategyEl.value = settings.strategy === 'speed' ? 'speed' : 'size';
|
||||
}
|
||||
|
||||
toggleEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, enabled: toggleEl.checked };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log('保存设置失败:' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
if (settings.enabled) {
|
||||
log('自动生成已启用。之后导入的 Spine 资产会自动生成缩略图。', 'ok');
|
||||
setStatus('监听中');
|
||||
} else {
|
||||
queue.length = 0;
|
||||
log('自动生成已关闭,待处理队列已清空。', 'dim');
|
||||
setStatus('监听中(自动生成已关闭)');
|
||||
}
|
||||
});
|
||||
|
||||
// 只清显示,不动统计和 queue:Eagle 的 devtools 控制台里仍然留着完整历史。
|
||||
clearEl.addEventListener('click', () => {
|
||||
logEl.textContent = '';
|
||||
});
|
||||
|
||||
modeEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log('保存设置失败:' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
log(settings.mode === 'static'
|
||||
? '生成方式:静帧(始终 setup pose,PNG)'
|
||||
: '生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)', 'dim');
|
||||
});
|
||||
|
||||
strategyEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log('保存设置失败:' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
log(settings.strategy === 'speed'
|
||||
? '生成策略:最优速度(粗估挑档,只生成最小的一两档)'
|
||||
: '生成策略:最优体积(十档全生成,取实际体积最小的那一档)', 'dim');
|
||||
});
|
||||
|
||||
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
|
||||
filtersEl.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-filter]');
|
||||
if (!button) return;
|
||||
logEl.dataset.filter = button.dataset.filter;
|
||||
for (const item of filtersEl.children) item.classList.toggle('active', item === button);
|
||||
});
|
||||
|
||||
// 先确认脚本本身执行到了这里,再谈 Eagle 事件有没有来。
|
||||
document.getElementById('status').textContent = '脚本已加载 ' + VERSION + ',等待 Eagle 事件…';
|
||||
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
|
||||
|
||||
if (typeof eagle === 'undefined') {
|
||||
setStatus('eagle API 未注入');
|
||||
log('页面上没有 eagle 对象,插件无法与 Eagle 通信', 'error');
|
||||
} else {
|
||||
eagle.onPluginCreate(async (plugin) => {
|
||||
// 官方文档:onPluginCreate 用来初始化插件所需的模块,plugin.path 就是插件所在目录。
|
||||
// app.getPath 这类 API 必须在这个事件之后才能调用,所以设置读取也放在这里。
|
||||
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
|
||||
log('插件已创建:' + ((plugin && plugin.manifest && plugin.manifest.name) || 'SpineThumbnailGenerateServer'));
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch (error) {
|
||||
log('读取设置失败,按默认值处理(关闭):' + ((error && error.message) || error), 'warn');
|
||||
}
|
||||
startSafely();
|
||||
});
|
||||
eagle.onLibraryChanged((changedPath) => {
|
||||
log('切换素材库:' + changedPath);
|
||||
startSafely();
|
||||
});
|
||||
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
|
||||
setTimeout(() => {
|
||||
if (libraryEl.textContent === '—') {
|
||||
log('没有收到创建事件,直接尝试启动', 'dim');
|
||||
loadSettings().catch(() => {}).then(startSafely);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
// 进度弹窗:用 Eagle 自己的 create-plugin-window 通道开一个正规插件窗口。
|
||||
//
|
||||
// 为什么不用 window.open:那样开出来的是 Electron 默认的裸窗口——没有 Node、没有插件 API、
|
||||
// 还带着 Eagle 的应用菜单栏(Eagle 只对自己创建的插件窗口调 setMenuBarVisibility(false))。
|
||||
// create-plugin-window 正是 Eagle 前端开插件窗口用的通道,manifest 由调用方传进来,
|
||||
// 所以我们可以指定 url / 尺寸 / 置顶,拿到的就是"正规的插件窗口"。
|
||||
//
|
||||
// 两个窗口之间没有 opener 关系,进度用临时目录里的状态文件传:宿主写,弹窗轮询。
|
||||
// 弹窗反过来写一个心跳文件,宿主靠它判断窗口是不是被用户关掉了。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
|
||||
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
|
||||
// 高度只是初值,弹窗加载后会按内容自己调(eagle.window.setSize)。
|
||||
const WINDOW_SIZE = { width: 380, height: 160 };
|
||||
// 心跳超过这个时间就认为弹窗已经被关掉,下次需要时重新开。
|
||||
const ALIVE_WINDOW_MS = 3000;
|
||||
// 刚创建完窗口要给它一点加载时间,这段时间内不重复创建。
|
||||
const CREATE_COOLDOWN_MS = 4000;
|
||||
|
||||
let state = { done: 0, total: 0, name: '', finished: false, text: '' };
|
||||
let lastCreateAt = 0;
|
||||
let ipcRenderer = null;
|
||||
|
||||
/** 插件目录的绝对路径。装了 eagleplugin:// 协议后 location.href 不是文件路径,只能靠 Eagle 给的。 */
|
||||
function pluginPath() {
|
||||
if (window.SpinePluginPath) return window.SpinePluginPath;
|
||||
if (typeof eagle !== 'undefined' && eagle.plugin && eagle.plugin.path) return eagle.plugin.path;
|
||||
return typeof __dirname !== 'undefined' ? __dirname : null;
|
||||
}
|
||||
|
||||
function getIpcRenderer() {
|
||||
if (!ipcRenderer) {
|
||||
// Eagle 的 preload 把 require('electron') 换成了 {},另起一个 require 才能拿到真模块。
|
||||
// createRequire 只接受绝对路径或 file:// URL,所以基准用插件目录拼出来,不能直接用 location.href。
|
||||
const base = path.join(pluginPath() || process.cwd(), 'index.html');
|
||||
ipcRenderer = require('module').createRequire(base)('electron').ipcRenderer;
|
||||
}
|
||||
return ipcRenderer;
|
||||
}
|
||||
|
||||
/** 弹窗还开着吗?靠它写的心跳判断,窗口关掉后心跳自然就停了。 */
|
||||
function popupAlive() {
|
||||
try {
|
||||
return Date.now() - Number(fs.readFileSync(ALIVE_FILE, 'utf8')) < ALIVE_WINDOW_MS;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState() {
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...state, updatedAt: Date.now() }), 'utf8');
|
||||
} catch (error) { /* 写不进去也不影响生成,面板里一样有进度 */ }
|
||||
}
|
||||
|
||||
/** 用 Eagle 的通道创建插件窗口;manifest 覆盖成进度页面的配置。 */
|
||||
function createPopup() {
|
||||
const plugin = (typeof eagle !== 'undefined' && eagle.plugin) || {};
|
||||
const dir = pluginPath();
|
||||
let manifest = plugin.manifest;
|
||||
if (!manifest && dir) {
|
||||
// 正常情况下 eagle.plugin 里就带 manifest;万一还没注入就自己从插件目录读一份。
|
||||
manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
|
||||
}
|
||||
if (!manifest) return Promise.reject(new Error('拿不到插件 manifest'));
|
||||
return getIpcRenderer().invoke('create-plugin-window', {
|
||||
plugin: {
|
||||
...plugin,
|
||||
path: dir,
|
||||
manifest: {
|
||||
...manifest,
|
||||
// 这个窗口的标题栏文字(只影响这扇窗,插件本身的名字不变)。
|
||||
name: 'Spine缩略图',
|
||||
main: {
|
||||
...manifest.main,
|
||||
// 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。
|
||||
serviceMode: false,
|
||||
url: 'progress.html',
|
||||
width: WINDOW_SIZE.width,
|
||||
height: WINDOW_SIZE.height,
|
||||
show: true,
|
||||
// 挂在 Eagle 主窗口上:子窗口天然压在父窗口之上,而且跟着 Eagle 一起显示/隐藏。
|
||||
// 这里显式写 false,免得主 manifest 哪天又开了 alwaysOnTop 被 ...manifest.main 带进来,
|
||||
// 那会让弹窗盖住所有程序,而不是只待在 Eagle 上面。
|
||||
alwaysOnTop: false,
|
||||
childWindow: true,
|
||||
// 保留系统标题栏:最小化/关闭按钮都在上面(manifest.name 决定标题文字)。
|
||||
frame: true,
|
||||
resizable: false,
|
||||
minimizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 需要时开窗(或复用已开着的);被环境挡住时返回 false,调用方照常继续跑。 */
|
||||
function open() {
|
||||
try {
|
||||
if (popupAlive()) return true;
|
||||
if (Date.now() - lastCreateAt < CREATE_COOLDOWN_MS) return false;
|
||||
lastCreateAt = Date.now();
|
||||
writeState();
|
||||
createPopup().catch(() => { /* 开不出来就只在面板里看进度 */ });
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 弹窗只是附加显示,任何问题都不能影响缩略图生成本身。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function update(next) {
|
||||
try {
|
||||
state = { ...state, ...next, finished: false };
|
||||
writeState();
|
||||
} catch (error) { /* 同上:弹窗不能影响生成流程 */ }
|
||||
}
|
||||
|
||||
function finish(text) {
|
||||
try {
|
||||
state = { ...state, finished: true, text };
|
||||
writeState();
|
||||
} catch (error) { /* ignore */ }
|
||||
}
|
||||
|
||||
const api = { open, update, finish };
|
||||
|
||||
if (typeof window !== 'undefined') window.SpineThumbProgress = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,723 @@
|
||||
// 在 service 插件页面里渲染缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出图片。
|
||||
// 两条产物,互不影响,由插件面板选择:
|
||||
// 静帧(setup pose)—— 原来的那条路,导出 PNG,一直保留;
|
||||
// 动态(animated WebP)—— 参数与主插件默认值一致:画布 200×200、分辨率优先档位表、
|
||||
// 目标体积由动画时长决定(每秒 25 KB,夹在 25–100 KB),取骨骼里的第一个动画。
|
||||
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
|
||||
const runtimeCache = new Map();
|
||||
// 长动画不截断时间轴,只降低采样密度:18 fps 采样,最多 240 帧。
|
||||
const SOURCE_SAMPLE_FPS = 18;
|
||||
const MAX_SOURCE_FRAMES = 240;
|
||||
// 时长小于这个值的动画按静帧处理。
|
||||
const STATIC_EPSILON = 1e-6;
|
||||
// 逐帧编码时轮转使用的离屏画布数量:成批提交比逐帧等待快得多。
|
||||
const FRAME_POOL_SIZE = 4;
|
||||
// 生成策略:
|
||||
// 'size' = 十档全部生成,取实际体积最小的那一档(最省体积,也就是主插件画廊默认打勾的);
|
||||
// 'speed' = 只用粗估挑出体积最小的两档真正生成(最快,其余开销跟着一起砍)。
|
||||
const DEFAULT_STRATEGY = 'size';
|
||||
const SPEED_PROBE_SIZE = 256;
|
||||
const SPEED_PROBE_POSES = 12;
|
||||
const SPEED_ESTIMATE_POINTS = 8;
|
||||
const SPEED_CANDIDATES = 2;
|
||||
|
||||
/**
|
||||
* 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
|
||||
*
|
||||
* 不能在主页面上用 <script> 标签:插件页面存在 Node 的 module 对象,3.4–3.7 的 UMD
|
||||
* 会因此走 CommonJS 分支,把导出丢进 module.exports 而不写 window.spine——结果
|
||||
* window.spine 永远停在第一个加载的版本上,后续每个版本都拿到同一个对象,于是出现
|
||||
* 「SkeletonBinary 只有 getter」或者「用 4.0 运行时去读 3.6 数据导致 worldX 未定义」。
|
||||
* iframe 里没有 Node 注入,UMD 会走浏览器分支,各版本天然隔离。
|
||||
*/
|
||||
function loadRuntime(runtimeDir) {
|
||||
if (runtimeCache.has(runtimeDir)) return runtimeCache.get(runtimeDir);
|
||||
const job = new Promise((resolve, reject) => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.style.display = 'none';
|
||||
frame.onload = () => {
|
||||
try {
|
||||
const win = frame.contentWindow;
|
||||
const script = win.document.createElement('script');
|
||||
script.src = new URL('lib/' + runtimeDir + '/spine-canvas.js', location.href).href;
|
||||
script.onload = () => {
|
||||
try {
|
||||
const spine = win.spine;
|
||||
if (!spine) throw new Error('运行时没有导出 spine:' + runtimeDir);
|
||||
if (window.SpineLegacyBinary) window.SpineLegacyBinary.install(spine, runtimeDir);
|
||||
resolve(spine);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
script.onerror = () => reject(new Error('加载运行时失败:' + runtimeDir));
|
||||
win.document.head.appendChild(script);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
frame.onerror = () => reject(new Error('创建运行时隔离环境失败:' + runtimeDir));
|
||||
document.body.appendChild(frame);
|
||||
});
|
||||
runtimeCache.set(runtimeDir, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 本地绝对路径 → file:// URL。交给 Node 的 pathToFileURL,Windows / macOS 都正确。 */
|
||||
function fileUrl(filePath) {
|
||||
return pathToFileURL(filePath).href;
|
||||
}
|
||||
|
||||
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 Promise 堵死。 */
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
let timer = null;
|
||||
return Promise.race([
|
||||
promise.finally(() => { if (timer) clearTimeout(timer); }),
|
||||
new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error((label || '操作') + ' 超时(' + timeoutMs + 'ms)')), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function loadImage(filePath) {
|
||||
return withTimeout(new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('读取贴图失败:' + filePath));
|
||||
image.src = fileUrl(filePath);
|
||||
}), 10000, '读取贴图 ' + path.basename(filePath));
|
||||
}
|
||||
|
||||
/** 按 atlas 里引用的页面名加载贴图,构造 TextureAtlas。 */
|
||||
async function buildTextureAtlas(spine, atlasText, texturePaths) {
|
||||
const images = new Map();
|
||||
await Promise.all(Object.keys(texturePaths).map(async (pageName) => {
|
||||
const filePath = texturePaths[pageName];
|
||||
if (!filePath) return;
|
||||
images.set(pageName, await loadImage(filePath));
|
||||
}));
|
||||
const makeTexture = (name) => {
|
||||
const image = images.get(name);
|
||||
if (!image) throw new Error('atlas 引用的贴图没找到:' + name);
|
||||
if (spine.CanvasTexture) return new spine.CanvasTexture(image);
|
||||
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(image);
|
||||
return new spine.Texture(image);
|
||||
};
|
||||
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
|
||||
if (is4x) {
|
||||
const atlas = new spine.TextureAtlas(atlasText);
|
||||
for (const page of atlas.pages) {
|
||||
const texture = makeTexture(page.name);
|
||||
if (page.setTexture) page.setTexture(texture);
|
||||
else page.texture = texture;
|
||||
}
|
||||
return atlas;
|
||||
}
|
||||
return new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
|
||||
}
|
||||
|
||||
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
|
||||
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
||||
if (isBinary) {
|
||||
const loader = new spine.SkeletonBinary(atlasLoader);
|
||||
loader.scale = 1;
|
||||
const buffer = fs.readFileSync(skeletonPath);
|
||||
return loader.readSkeletonData(new Uint8Array(buffer));
|
||||
}
|
||||
const loader = new spine.SkeletonJson(atlasLoader);
|
||||
loader.scale = 1;
|
||||
return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8'));
|
||||
}
|
||||
|
||||
function updateWorld(spine, skeleton, delta = 0, resetPhysics = false) {
|
||||
if (spine.Physics && spine.Physics.update !== undefined) {
|
||||
if (delta > 0 && typeof skeleton.update === 'function') skeleton.update(delta);
|
||||
const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update;
|
||||
skeleton.updateWorldTransform(mode);
|
||||
}
|
||||
else skeleton.updateWorldTransform();
|
||||
}
|
||||
|
||||
/** 载入运行时、atlas 与骨骼数据。静态和动态两条路共用。 */
|
||||
async function loadSkeleton(options) {
|
||||
const spine = await loadRuntime(options.runtimeDir);
|
||||
const atlasText = fs.readFileSync(options.atlasPath, 'utf8');
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, options.texturePaths);
|
||||
const data = readSkeletonData(spine, options.skeletonPath, atlas, !!options.isBinary);
|
||||
if (!data || !data.bones || !data.bones.length) throw new Error('骨骼数据为空');
|
||||
return {
|
||||
spine,
|
||||
data,
|
||||
atlasText,
|
||||
runtimeDir: options.runtimeDir,
|
||||
premultipliedAlpha: window.SpineUtils.parseAtlasPma(atlasText) === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 骨骼当前姿态的包围盒(世界坐标)。 */
|
||||
function boundsOf(spine, skeleton) {
|
||||
try {
|
||||
if (typeof skeleton.getBoundsRect === 'function') {
|
||||
const rect = skeleton.getBoundsRect();
|
||||
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
if (typeof skeleton.getBounds === 'function') {
|
||||
const offset = new spine.Vector2();
|
||||
const dimensions = new spine.Vector2();
|
||||
skeleton.getBounds(offset, dimensions, []);
|
||||
if (dimensions.x > 0 || dimensions.y > 0) {
|
||||
return { x: offset.x, y: offset.y, width: dimensions.x, height: dimensions.y };
|
||||
}
|
||||
}
|
||||
} catch (error) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 setup pose 的缩略图。
|
||||
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
|
||||
* 返回 { blob, width, height, version }
|
||||
*/
|
||||
async function renderThumbnail(options) {
|
||||
const utils = window.SpineUtils;
|
||||
const size = options.size || 400;
|
||||
const loaded = await loadSkeleton(options);
|
||||
const { spine, data } = loaded;
|
||||
|
||||
const skeleton = new spine.Skeleton(data);
|
||||
utils.applySkin(skeleton, utils.preferredSkin(data));
|
||||
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
|
||||
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
|
||||
updateWorld(spine, skeleton, 0, true);
|
||||
|
||||
const bounds = boundsOf(spine, skeleton);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
ctx.save();
|
||||
ctx.translate(size / 2, size / 2);
|
||||
ctx.scale(scale, -scale);
|
||||
ctx.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
|
||||
let renderer = null;
|
||||
try {
|
||||
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
|
||||
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
|
||||
renderer.draw(skeleton);
|
||||
} finally {
|
||||
if (renderer && renderer.dispose) renderer.dispose();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (options.drawBadge !== false) await drawBadge(ctx, size);
|
||||
if (options.drawVersion !== false) {
|
||||
window.SpineBadge.drawVersion(ctx, size, size, data.version || options.version);
|
||||
}
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) throw new Error('导出缩略图失败');
|
||||
return { blob, width: size, height: size, version: data.version || options.version || '' };
|
||||
}
|
||||
|
||||
// ---------- 动态缩略图(animated WebP) ----------
|
||||
// 参数与主插件的默认值一致:画布 200×200、分辨率优先档位表、目标体积 = 动画时长 × 25 KB
|
||||
// (夹在 25–100 KB)。区别是没有候选画廊,所以直接采用主插件画廊里默认打勾的那一档
|
||||
// (十档里实际体积最小的那个),目标体积只用来判断有没有达标并在日志里提示。
|
||||
|
||||
function collectKeyframeTimes(animation) {
|
||||
if (!animation || !animation.timelines) return [];
|
||||
const times = new Set();
|
||||
for (const timeline of animation.timelines) {
|
||||
const frames = timeline && timeline.frames;
|
||||
if (!frames || !frames.length) continue;
|
||||
try {
|
||||
if (Array.isArray(frames[0])) {
|
||||
for (const frame of frames) {
|
||||
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const frameCount = typeof timeline.getFrameCount === 'function' ? timeline.getFrameCount() : 0;
|
||||
const entries = typeof timeline.getFrameEntries === 'function'
|
||||
? timeline.getFrameEntries()
|
||||
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
|
||||
if (frameCount > 0) {
|
||||
for (let index = 0; index < frameCount; index++) {
|
||||
const value = frames[index * entries];
|
||||
if (Number.isFinite(value)) times.add(value);
|
||||
}
|
||||
}
|
||||
} catch (error) { /* 读数失败就跳过这条时间轴 */ }
|
||||
}
|
||||
return [...times];
|
||||
}
|
||||
|
||||
function animationDurationOf(animation) {
|
||||
const declared = animation && Number(animation.duration);
|
||||
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
|
||||
const times = collectKeyframeTimes(animation);
|
||||
return times.length ? Math.max.apply(null, times) : 0;
|
||||
}
|
||||
|
||||
/** 取骨骼里的第一个可用动画(没有动画或时长为 0 就返回 null,由上层回退静帧)。 */
|
||||
function firstAnimation(data) {
|
||||
for (const animation of (data && data.animations) || []) {
|
||||
if (animation && animationDurationOf(animation) > STATIC_EPSILON) return animation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAnimationPose(spine, targetSkeleton, targetState, animation, time, previousTime) {
|
||||
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
|
||||
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
|
||||
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
|
||||
const entry = targetState.setAnimation(0, animation.name, false);
|
||||
if (entry) entry.loop = false;
|
||||
targetState.update(Math.max(0, time));
|
||||
targetState.apply(targetSkeleton);
|
||||
updateWorld(spine, targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined);
|
||||
}
|
||||
|
||||
function mergeBounds(total, next) {
|
||||
if (!next || next.width <= 0 || next.height <= 0) return total;
|
||||
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
|
||||
const left = Math.min(total.x, next.x);
|
||||
const bottom = Math.min(total.y, next.y);
|
||||
const right = Math.max(total.x + total.width, next.x + next.width);
|
||||
const top = Math.max(total.y + total.height, next.y + next.height);
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
/**
|
||||
* 采样若干姿态,用像素 alpha 求真实可见范围,避免取景跟着骨骼包围盒留大片空白。
|
||||
* 最快策略下采样点变少、探测画布变小,取景精度略降但省掉一大截渲染 + 回读。
|
||||
*/
|
||||
async function measureVisibleBounds(loaded, targetSkeleton, fallbackBounds, poseCount, applyPose, options) {
|
||||
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
|
||||
const probeOptions = options || {};
|
||||
const visibleBounds = window.SpineVisibleBounds;
|
||||
const size = probeOptions.probeSize || 512;
|
||||
const maxPoses = probeOptions.maxPoses || poseCount;
|
||||
const step = Math.max(1, Math.ceil(poseCount / maxPoses));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
|
||||
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
|
||||
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
|
||||
let measured = null;
|
||||
let probe = null;
|
||||
try {
|
||||
probe = window.SpineCanvasClipping.createRenderer(loaded.spine, context, loaded.runtimeDir);
|
||||
probe.premultipliedAlpha = loaded.premultipliedAlpha;
|
||||
let previousIndex = -1;
|
||||
for (let index = 0; index < poseCount; index += step) {
|
||||
applyPose(index, previousIndex);
|
||||
previousIndex = index;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, size, size);
|
||||
context.save();
|
||||
context.translate(size / 2, size / 2);
|
||||
context.scale(scale, -scale);
|
||||
context.translate(-centerX, -centerY);
|
||||
probe.draw(targetSkeleton);
|
||||
context.restore();
|
||||
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
|
||||
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
|
||||
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (probe && probe.dispose) probe.dispose();
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
function profileFrameCount(sourceFrameCount, range, fps) {
|
||||
return Math.max(2, Math.min(sourceFrameCount, Math.ceil(range * fps)));
|
||||
}
|
||||
|
||||
function resizeFramePixels(data, sourceSize, targetSize) {
|
||||
if (sourceSize === targetSize) return data;
|
||||
const output = new Uint8Array(targetSize * targetSize * 4);
|
||||
for (let y = 0; y < targetSize; y++) {
|
||||
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
|
||||
for (let x = 0; x < targetSize; x++) {
|
||||
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
|
||||
const source = (sourceY * sourceSize + sourceX) * 4;
|
||||
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
|
||||
// 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
|
||||
function scaledFramePixels(data, size, detailSize) {
|
||||
return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
|
||||
}
|
||||
|
||||
let badgeImagePromise = null;
|
||||
|
||||
function badgeImage() {
|
||||
if (!badgeImagePromise) {
|
||||
badgeImagePromise = new Promise((resolve) => {
|
||||
try {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(null);
|
||||
image.src = window.SpineBadge.dataUrl();
|
||||
} catch (error) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return badgeImagePromise;
|
||||
}
|
||||
|
||||
/** 一次把所有采样帧画到 size×size 上(含角标),后续每档只从这些像素重新编码。 */
|
||||
async function renderAnimatedSourceFrames(loaded, animation, options) {
|
||||
const utils = window.SpineUtils;
|
||||
const spine = loaded.spine;
|
||||
const size = options.canvasSize;
|
||||
const range = options.range;
|
||||
const frameCount = Math.max(2, Math.min(MAX_SOURCE_FRAMES, Math.ceil(range * SOURCE_SAMPLE_FPS)));
|
||||
const frameDuration = range / frameCount;
|
||||
const sampleTimes = Array.from({ length: frameCount }, (_, index) => index * frameDuration);
|
||||
const skeleton = new spine.Skeleton(loaded.data);
|
||||
utils.applySkin(skeleton, utils.preferredSkin(loaded.data));
|
||||
const stateData = new spine.AnimationStateData(loaded.data);
|
||||
stateData.defaultMix = 0;
|
||||
const state = new spine.AnimationState(stateData);
|
||||
|
||||
let bounds = null;
|
||||
for (let index = 0; index < sampleTimes.length; index++) {
|
||||
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
|
||||
bounds = mergeBounds(bounds, boundsOf(spine, skeleton));
|
||||
}
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error('骨骼数据没有可绘制的范围');
|
||||
bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index, previousIndex) => {
|
||||
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined);
|
||||
}, options.probe) || bounds;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
const badge = await badgeImage();
|
||||
const version = loaded.data.version || options.version || '';
|
||||
const frames = [];
|
||||
let renderer = null;
|
||||
try {
|
||||
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, loaded.runtimeDir);
|
||||
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
|
||||
for (let index = 0; index < sampleTimes.length; index++) {
|
||||
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.translate(size / 2, size / 2);
|
||||
ctx.scale(scale, -scale);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
renderer.draw(skeleton);
|
||||
ctx.restore();
|
||||
if (badge) {
|
||||
const badgeSize = Math.max(44, Math.round(size * 0.15));
|
||||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||||
try {
|
||||
ctx.drawImage(badge, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
|
||||
} catch (error) { /* 缺少 badge 不影响主体 */ }
|
||||
}
|
||||
window.SpineBadge.drawVersion(ctx, size, size, version);
|
||||
// 动态 WebP 用全帧替换,透明区域直接压成背景色,省掉 alpha 通道的体积。
|
||||
const rgba = new Uint8Array(ctx.getImageData(0, 0, size, size).data);
|
||||
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
|
||||
frames.push(rgba);
|
||||
if (options.onProgress) options.onProgress(index + 1, frameCount);
|
||||
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (renderer && renderer.dispose) renderer.dispose();
|
||||
}
|
||||
return { frames, size, range };
|
||||
}
|
||||
|
||||
/** 轮转 4 张离屏画布,把 RGBA 像素编成 WebP 静帧。 */
|
||||
function createFrameEncoder(size) {
|
||||
const pool = Array.from({ length: FRAME_POOL_SIZE }, () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
return { canvas, context, image: context.createImageData(size, size) };
|
||||
});
|
||||
return (slot, pixels, quality) => new Promise((resolve, reject) => {
|
||||
const unit = pool[slot % pool.length];
|
||||
unit.image.data.set(pixels);
|
||||
unit.context.putImageData(unit.image, 0, 0);
|
||||
unit.canvas.toBlob((blob) => {
|
||||
if (!blob || blob.type !== 'image/webp') {
|
||||
reject(new Error('当前环境不支持 WebP 编码'));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
|
||||
}, 'image/webp', quality);
|
||||
});
|
||||
}
|
||||
|
||||
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
|
||||
async function estimateAnimatedBytes(profile, sourceFrames, size, range, encodeFrame) {
|
||||
const count = profileFrameCount(sourceFrames.length, range, profile.fps);
|
||||
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
|
||||
let total = 0;
|
||||
for (let slot = 0; slot < indexes.length; slot++) {
|
||||
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
|
||||
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
|
||||
total += (await encodeFrame(slot, pixels, profile.quality)).length;
|
||||
}
|
||||
return (total / indexes.length) * count;
|
||||
}
|
||||
|
||||
// 沿整条搜索路径粗估体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」取候选。
|
||||
// 返回顺序就是从预计体积最小到最大,最快策略只需要前面几档。
|
||||
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, targetBytes, count, encodeFrame, samplePoints) {
|
||||
const stride = Math.max(1, Math.ceil(searchPath.length / (samplePoints || 24)));
|
||||
const samples = [];
|
||||
for (let index = 0; index < searchPath.length; index += stride) {
|
||||
samples.push({
|
||||
index,
|
||||
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, encodeFrame),
|
||||
});
|
||||
}
|
||||
const lastIndex = searchPath.length - 1;
|
||||
if (samples[samples.length - 1].index !== lastIndex) {
|
||||
samples.push({
|
||||
index: lastIndex,
|
||||
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, encodeFrame),
|
||||
});
|
||||
}
|
||||
// 先按体积从小到大排:配置允许分段,排序后再取点才能保证候选体积递增。
|
||||
samples.sort((left, right) => left.bytes - right.bytes);
|
||||
// 体积 → 参数:在「体积相邻」的两个采样点之间插值,候选因此可以落在配置表之外。
|
||||
const profileForBytes = (wantedBytes) => {
|
||||
let upper = samples.length - 1;
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
|
||||
}
|
||||
const lower = Math.max(0, upper - 1);
|
||||
const before = searchPath[samples[lower].index];
|
||||
const after = searchPath[samples[upper].index];
|
||||
const gap = samples[upper].bytes - samples[lower].bytes;
|
||||
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
|
||||
const mix = (start, end) => start + (end - start) * ratio;
|
||||
return {
|
||||
size: Math.max(1, Math.round(mix(before.size, after.size))),
|
||||
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
|
||||
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
|
||||
};
|
||||
};
|
||||
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
|
||||
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
|
||||
const span = Math.max(1, count - 1);
|
||||
const picked = [];
|
||||
for (let slot = 0; slot < count; slot++) {
|
||||
picked.push(profileForBytes(Math.exp(low + (high - low) * slot / span)));
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
/** 按一档参数把采样帧全部编码并封装成动态 WebP。 */
|
||||
async function encodeAnimatedProfile(profile, source, encodeFrame) {
|
||||
const count = profileFrameCount(source.frames.length, source.range, profile.fps);
|
||||
const duration = Math.max(1, Math.round(source.range * 1000 / count));
|
||||
const frames = [];
|
||||
// 帧编码是异步的,一批 4 帧(正好对上画布池)一起提交,比逐帧 await 快得多。
|
||||
for (let start = 0; start < count; start += FRAME_POOL_SIZE) {
|
||||
const batch = [];
|
||||
for (let index = start; index < Math.min(count, start + FRAME_POOL_SIZE); index++) {
|
||||
const sourceIndex = Math.min(source.frames.length - 1, Math.floor(index * source.frames.length / count));
|
||||
batch.push({
|
||||
slot: index % FRAME_POOL_SIZE,
|
||||
pixels: scaledFramePixels(source.frames[sourceIndex], source.size, profile.size),
|
||||
});
|
||||
}
|
||||
const buffers = await Promise.all(batch.map(({ slot, pixels }) => encodeFrame(slot, pixels, profile.quality)));
|
||||
for (const webp of buffers) frames.push({ webp, duration });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return {
|
||||
buffer: window.SpineAnimatedWebpEncoder.encode({
|
||||
width: source.size,
|
||||
height: source.size,
|
||||
frames,
|
||||
plays: 0,
|
||||
}),
|
||||
frames: count,
|
||||
detail: profile.size,
|
||||
fps: profile.fps,
|
||||
quality: profile.quality,
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有候选里体积最小的那一档(主插件画廊默认打勾选中的就是它)。 */
|
||||
function smallestCandidate(candidates) {
|
||||
return candidates.reduce(
|
||||
(best, candidate) => (!best || candidate.buffer.length < best.buffer.length ? candidate : best),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async function pickAnimatedThumbnail(source, options) {
|
||||
const config = window.SpineAnimatedProfiles;
|
||||
const fast = options.strategy === 'speed';
|
||||
const encodeFrame = createFrameEncoder(source.size);
|
||||
const targetBytes = options.maxKilobytes * 1024;
|
||||
const searchPath = config.expandAnimatedPath(options.priority, options.canvasSize);
|
||||
const candidateCount = config.animatedProfiles(options.priority, options.canvasSize).length;
|
||||
const profiles = await pickAnimatedCandidates(
|
||||
searchPath, source.frames, source.size, source.range, targetBytes, candidateCount, encodeFrame,
|
||||
fast ? SPEED_ESTIMATE_POINTS : undefined,
|
||||
);
|
||||
// 用户看不到候选画廊,就完全照主插件的默认选择来,取实际体积最小的那一档。
|
||||
// 最优体积:十档全生成再比;最优速度:只生成粗估最小的那几档(粗估已经按体积排过序)。
|
||||
const wanted = fast ? profiles.slice(0, SPEED_CANDIDATES) : profiles;
|
||||
const candidates = [];
|
||||
for (const profile of wanted) {
|
||||
candidates.push(await encodeAnimatedProfile(profile, source, encodeFrame));
|
||||
}
|
||||
const best = smallestCandidate(candidates);
|
||||
return {
|
||||
...best,
|
||||
// 最小档仍然超预算时告诉用户,让他考虑改配置里的目标体积。
|
||||
fits: best.buffer.length <= targetBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成动态缩略图。骨骼里没有可用动画时返回 null,交给调用方回退到 setup pose 静帧。
|
||||
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, version,
|
||||
* canvasSize, priority, maxKilobytes, onProgress }
|
||||
*/
|
||||
async function renderAnimatedThumbnail(options) {
|
||||
const config = window.SpineAnimatedProfiles;
|
||||
const loaded = await loadSkeleton(options);
|
||||
const animation = firstAnimation(loaded.data);
|
||||
if (!animation) return null;
|
||||
const canvasSize = options.canvasSize || config.DEFAULT_OUTPUT_SIZE;
|
||||
const range = animationDurationOf(animation);
|
||||
const maxKilobytes = options.maxKilobytes || config.suggestSize(range);
|
||||
const fast = (options.strategy || DEFAULT_STRATEGY) === 'speed';
|
||||
const source = await renderAnimatedSourceFrames(loaded, animation, {
|
||||
canvasSize,
|
||||
range,
|
||||
version: options.version,
|
||||
onProgress: options.onProgress,
|
||||
// 最快策略用小画布、少采样点做可见范围探测。
|
||||
probe: fast ? { probeSize: SPEED_PROBE_SIZE, maxPoses: SPEED_PROBE_POSES } : null,
|
||||
});
|
||||
const picked = await pickAnimatedThumbnail(source, {
|
||||
priority: options.priority || config.DEFAULT_PRIORITY,
|
||||
maxKilobytes,
|
||||
canvasSize,
|
||||
strategy: options.strategy || DEFAULT_STRATEGY,
|
||||
});
|
||||
return {
|
||||
buffer: picked.buffer,
|
||||
extension: '.webp',
|
||||
width: source.size,
|
||||
height: source.size,
|
||||
mode: 'animated',
|
||||
animation: animation.name,
|
||||
version: loaded.data.version || options.version || '',
|
||||
frames: picked.frames,
|
||||
detail: picked.detail,
|
||||
fps: picked.fps,
|
||||
quality: picked.quality,
|
||||
duration: range,
|
||||
targetKilobytes: maxKilobytes,
|
||||
strategy: options.strategy || DEFAULT_STRATEGY,
|
||||
fits: picked.fits,
|
||||
};
|
||||
}
|
||||
|
||||
/** 静帧产物统一成和动态一样的返回结构,调用方只认 buffer + extension。 */
|
||||
async function renderStillBuffer(options) {
|
||||
const still = await renderThumbnail(options);
|
||||
return {
|
||||
buffer: Buffer.from(await still.blob.arrayBuffer()),
|
||||
extension: '.png',
|
||||
width: still.width,
|
||||
height: still.height,
|
||||
mode: 'still',
|
||||
version: still.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口:mode === 'static' 一律出 setup pose 静帧;否则有动画就出动态 WebP,
|
||||
* 没有动画回退静帧(reason 里说明原因,日志会带出来)。
|
||||
*/
|
||||
async function renderThumbnailAuto(options) {
|
||||
if (options.mode === 'static') return renderStillBuffer(options);
|
||||
try {
|
||||
const animated = await renderAnimatedThumbnail(options);
|
||||
if (animated) return animated;
|
||||
return { ...(await renderStillBuffer(options)), fallback: '骨骼里没有动画' };
|
||||
} catch (error) {
|
||||
const still = await renderStillBuffer(options);
|
||||
return { ...still, fallback: '动态生成失败:' + ((error && error.message) || error) };
|
||||
}
|
||||
}
|
||||
|
||||
// badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
|
||||
function drawBadge(ctx, size) {
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const badgeSize = Math.max(44, Math.round(size * 0.15));
|
||||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||||
try {
|
||||
ctx.drawImage(image, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
|
||||
} catch (error) { /* 缺少 badge 不影响主体 */ }
|
||||
resolve();
|
||||
};
|
||||
image.onerror = () => resolve();
|
||||
image.src = window.SpineBadge.dataUrl();
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
renderThumbnail,
|
||||
renderThumbnailAuto,
|
||||
renderAnimatedThumbnail,
|
||||
loadRuntime,
|
||||
loadSkeleton,
|
||||
firstAnimation,
|
||||
animationDurationOf,
|
||||
profileFrameCount,
|
||||
resizeFramePixels,
|
||||
scaledFramePixels,
|
||||
pickAnimatedCandidates,
|
||||
smallestCandidate,
|
||||
withTimeout,
|
||||
};
|
||||
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
// 插件设置持久化。默认关闭自动生成——Eagle 审核标准要求「批量或不可逆的变更
|
||||
// 必须事先明确说明并获得确认」,所以必须由用户主动打开这个开关。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// mode: 'animated' = 有动画就出动态 WebP(没有动画回退静帧),'static' = 一律 setup pose 静帧。
|
||||
// strategy: 'size' = 十档全生成取体积最小的(慢、最省体积),'speed' = 只生成粗估最小的那几档。
|
||||
const DEFAULTS = { enabled: false, mode: 'animated', strategy: 'size' };
|
||||
|
||||
/** 存放在 Eagle 的 userData 目录,不写进素材库,卸载时可直接删掉。 */
|
||||
async function resolveFile() {
|
||||
const userData = await eagle.app.getPath('userData');
|
||||
return path.join(userData, 'SpineThumbnailGenerateServer', 'settings.json');
|
||||
}
|
||||
|
||||
async function read() {
|
||||
const file = await resolveFile();
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return { ...DEFAULTS, ...data };
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return { ...DEFAULTS };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function write(next) {
|
||||
const file = await resolveFile();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const body = JSON.stringify({ ...DEFAULTS, ...next }, null, 2) + '\n';
|
||||
const temp = file + '.' + process.pid + '.tmp';
|
||||
fs.writeFileSync(temp, body, 'utf8');
|
||||
fs.renameSync(temp, file);
|
||||
}
|
||||
|
||||
const api = { read, write, resolveFile, DEFAULTS };
|
||||
if (typeof window !== 'undefined') window.SpineThumbSettings = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,140 @@
|
||||
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载后挂到 window。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SKELETON_PATTERN = /\.(skel|skel\.bytes|json)$/i;
|
||||
const ATLAS_PATTERN = /\.(atlas|atlas\.txt)$/i;
|
||||
const TEXTURE_PATTERN = /\.(png|jpe?g|webp)$/i;
|
||||
const THUMBNAIL_PATTERN = /_thumbnail\.(png|webp)$/i;
|
||||
|
||||
/**
|
||||
* 分析一个 Eagle item 目录。
|
||||
*
|
||||
* Eagle 会把导入的每个文件拆成独立的 <id>.info 目录,所以一套 Spine 资产的骨架、
|
||||
* atlas、贴图通常分散在多个目录里,必须跨目录配对。配对逻辑不重写,直接复用主插件的
|
||||
* discoverAssets(后缀表、同名筛选、路径校验都在里面)。
|
||||
*/
|
||||
function analyzeItemDir(itemDir, dirName, utils, pairCache, searchRoots, refreshIndex) {
|
||||
let files;
|
||||
try {
|
||||
files = fs.readdirSync(itemDir);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
// metadata.json 是 Eagle 的 item 元数据,不是骨骼文件,必须排除。
|
||||
const skeletons = files.filter((name) => SKELETON_PATTERN.test(name) && name.toLowerCase() !== 'metadata.json');
|
||||
const atlases = files.filter((name) => ATLAS_PATTERN.test(name));
|
||||
const textures = files.filter((name) => TEXTURE_PATTERN.test(name) && !THUMBNAIL_PATTERN.test(name));
|
||||
// .skel / .skel.bytes 的后缀够特定,直接信任;.json 必须确认内容真的是 Spine 骨骼,
|
||||
// 否则任何普通 JSON(甚至改了后缀的其它数据)都会被误当成骨骼文件。
|
||||
const usableSkeletons = skeletons.filter((name) => {
|
||||
if (!/\.json$/i.test(name)) return true;
|
||||
try {
|
||||
// classifySkeletonJson 的肯定标识是 'spine',普通 JSON 是 'other',仍在写入是 'pending'。
|
||||
return utils.classifySkeletonJson(path.join(itemDir, name)) === 'spine';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// 一个目录里既没有可用骨架也没有 atlas,说明它不是 Spine 资产的入口。
|
||||
if (!usableSkeletons.length && !atlases.length) return null;
|
||||
|
||||
const entryName = usableSkeletons[0] || atlases[0];
|
||||
// Eagle 删除素材只是把 item 的 metadata.json 标记成 isDeleted,目录和文件都留在
|
||||
// 磁盘上。这种目录仍然会出现在「新增目录」里,必须在配对前挡掉,否则会给回收站
|
||||
// 里的素材渲染一次缩略图(白做,还可能算成失败)。
|
||||
if (utils.isItemDeleted(path.join(itemDir, entryName))) return null;
|
||||
let assets = null;
|
||||
// 一套资产的多个入口(atlas / skel / json)配对结果完全一样,缓存下来避免重复扫描。
|
||||
// 缓存键必须带上 Eagle 文件夹:同名素材可能分属不同文件夹(同一示例的不同版本就是这样),
|
||||
// 只用 stem 会把文件夹 A 的配对结果复用给文件夹 B。
|
||||
let folders = [];
|
||||
try {
|
||||
folders = utils.foldersOfItem(path.join(itemDir, entryName));
|
||||
} catch (error) { /* 读不到就按未分类处理 */ }
|
||||
const folderKey = folders.slice().sort().join(',') || 'root';
|
||||
const cacheKey = utils.normalizeStem(entryName) + '|' + folderKey;
|
||||
if (pairCache && pairCache.has(cacheKey)) {
|
||||
assets = pairCache.get(cacheKey);
|
||||
} else {
|
||||
try {
|
||||
// searchRoots 限定在本次新增的目录里。不传的话 Utils 会把搜索范围扩到整个库,
|
||||
// 几百上千个素材时一次配对就要一秒以上——Eagle 的「文件夹」只是逻辑分组,
|
||||
// 磁盘上所有素材都平铺在 images/ 下,所以「扫同级」等于「扫全库」。
|
||||
assets = utils.discoverAssets(path.join(itemDir, entryName), {
|
||||
strictStem: true,
|
||||
searchRoots,
|
||||
// 文件索引有 1.5 秒缓存,刚导入的目录可能还没进索引,
|
||||
// 所以每批的第一个目录强制刷新,其余复用。
|
||||
refreshIndex,
|
||||
});
|
||||
} catch (error) {
|
||||
assets = null;
|
||||
}
|
||||
if (pairCache) pairCache.set(cacheKey, assets);
|
||||
}
|
||||
|
||||
// 贴图是否齐全要看 atlas 里声明了哪些页面,再看这些页面能不能在库里找到。
|
||||
let pages = 0;
|
||||
let missingTextures = 0;
|
||||
if (assets && assets.atlasPath) {
|
||||
try {
|
||||
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
|
||||
const pageNames = utils.parseAtlasPages(atlasText);
|
||||
pages = pageNames.length;
|
||||
missingTextures = pageNames.filter((name) => !assets.texturePaths[name]).length;
|
||||
} catch (error) { /* atlas 读不了就当作不完整 */ }
|
||||
}
|
||||
|
||||
// 骨架路径必须是骨骼文件后缀,防止配对结果指到贴图或其它文件上。
|
||||
const skeletonPath = (assets && assets.skeletonPath) || null;
|
||||
const skeletonOk = !!skeletonPath && /\.(skel|skel\.bytes|json)$/i.test(skeletonPath);
|
||||
return {
|
||||
dirName,
|
||||
// 日志里要显示 Eagle 界面上的文件名和文件夹路径,而不是 MU9RVSxxxx.info 这种 id。
|
||||
entryName,
|
||||
folders,
|
||||
skeletonPath,
|
||||
atlasPath: (assets && assets.atlasPath) || null,
|
||||
texturePaths: (assets && assets.texturePaths) || {},
|
||||
// 必须按「实际要读的那个骨架文件」判断是否二进制:配对的骨架可能是同名的 .skel,
|
||||
// 而当前目录里放的是 .json,用目录里的文件名判断就会拿 JSON 解析器去读二进制。
|
||||
isBinary: /\.(skel|skel\.bytes)$/i.test(skeletonPath || entryName),
|
||||
hasSkeletonInDir: usableSkeletons.length > 0,
|
||||
hasAtlasInDir: atlases.length > 0,
|
||||
hasTextureInDir: textures.length > 0,
|
||||
pages,
|
||||
missingTextures,
|
||||
complete: !!(skeletonOk && assets.atlasPath && pages > 0 && missingTextures === 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描指定的 item 目录,返回其中的 Spine 资产(其他素材会被过滤掉)。
|
||||
* 每处理完一个目录就让出一次主线程:discoverAssets 是同步扫全库的重活,
|
||||
* 连续跑几十个会把界面彻底卡住,日志写了也刷不出来。
|
||||
*/
|
||||
async function scanSpineItems(imagesDir, dirNames, utils, onProgress) {
|
||||
const found = [];
|
||||
// 本次新增的目录就是配对范围:配套的骨架 / atlas / 贴图在同一次导入里,
|
||||
// 因此不需要、也不应该去翻整个素材库。
|
||||
const searchRoots = dirNames.map((dirName) => path.resolve(imagesDir, dirName));
|
||||
const pairCache = new Map();
|
||||
for (let index = 0; index < dirNames.length; index++) {
|
||||
const dirName = dirNames[index];
|
||||
const info = analyzeItemDir(path.join(imagesDir, dirName), dirName, utils, pairCache, searchRoots, index === 0);
|
||||
if (info) found.push(info);
|
||||
if (onProgress) onProgress(index + 1, dirNames.length);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const api = { analyzeItemDir, scanSpineItems, SKELETON_PATTERN, ATLAS_PATTERN, TEXTURE_PATTERN };
|
||||
if (typeof window !== 'undefined') window.SpineThumbSpineAssets = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'use strict';
|
||||
const patterns = new WeakMap();
|
||||
|
||||
// Called with the same world-to-canvas transform used to draw the skeleton.
|
||||
function drawCheckerboard(ctx, width, height) {
|
||||
let pattern = patterns.get(ctx);
|
||||
if (!pattern) {
|
||||
const tile = ctx.canvas.ownerDocument.createElement('canvas');
|
||||
tile.width = tile.height = 100;
|
||||
const paint = tile.getContext('2d');
|
||||
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
|
||||
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
|
||||
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
|
||||
}
|
||||
const m = ctx.getTransform().inverse();
|
||||
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
|
||||
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
|
||||
const x = Math.min(...xs), y = Math.min(...ys);
|
||||
if (!Number.isFinite(x + y)) return;
|
||||
ctx.save();
|
||||
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
|
||||
finally { ctx.restore(); }
|
||||
}
|
||||
|
||||
const api = { drawCheckerboard };
|
||||
if (typeof window !== 'undefined') window.SpineBackground = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 插件根目录必须延迟计算:Eagle 在 onPluginCreate 里才给出 plugin.path,
|
||||
// 那时代码已经加载完了,所以这里用函数而不是常量。
|
||||
function pluginRootPath() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const fromEagle = window.SpinePluginPath || window.SpinePluginRoot;
|
||||
if (fromEagle) return fromEagle;
|
||||
}
|
||||
return typeof __dirname !== 'undefined' ? path.join(__dirname, '..') : '.';
|
||||
}
|
||||
|
||||
function badgeFile() {
|
||||
return path.join(pluginRootPath(), 'assets', 'spine-badge.png');
|
||||
}
|
||||
let cachedDataUrl = null;
|
||||
|
||||
function dataUrl() {
|
||||
if (!cachedDataUrl) {
|
||||
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgeFile()).toString('base64');
|
||||
}
|
||||
return cachedDataUrl;
|
||||
}
|
||||
|
||||
function drawVersion(ctx, width, height, version) {
|
||||
version = String(version || '');
|
||||
if (version.length > 40 || !/^\d+\.\d+(?:\.\d+)?(?:[-+][a-z0-9][a-z0-9.-]*)?$/i.test(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(); }
|
||||
}
|
||||
|
||||
const api = { get path() { return badgeFile(); }, dataUrl, drawVersion };
|
||||
if (typeof window !== 'undefined') window.SpineBadge = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,253 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// The official spine-canvas SkeletonRenderer does not process clipping
|
||||
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
|
||||
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
|
||||
|
||||
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
|
||||
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
|
||||
const unpremultipliedImages = new WeakMap();
|
||||
|
||||
function rendererConstructor(spine) {
|
||||
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
|
||||
}
|
||||
|
||||
function imageFromAttachment(attachment) {
|
||||
const region = attachment && attachment.region;
|
||||
// 4.0 stores the texture on the atlas page, while the other bundled
|
||||
// runtimes expose it directly on the attachment region.
|
||||
const renderRegion = region && region.renderObject;
|
||||
const texture = region && (region.texture
|
||||
|| (region.page && region.page.texture)
|
||||
|| (renderRegion && renderRegion.texture)
|
||||
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
|
||||
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
|
||||
}
|
||||
|
||||
// Canvas 2D always applies source-alpha blending. A Spine texture exported
|
||||
// with PMA already has RGB multiplied by alpha, so drawing it directly would
|
||||
// multiply translucent edge pixels a second time. Convert it once to straight
|
||||
// alpha and cache the result; Canvas can then blend it correctly.
|
||||
function imageForCanvas(image, context, premultipliedAlpha) {
|
||||
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
|
||||
const cached = unpremultipliedImages.get(image);
|
||||
if (cached) return cached;
|
||||
try {
|
||||
const ownerDocument = context.canvas && context.canvas.ownerDocument;
|
||||
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
|
||||
if (!image.width || !image.height) return image;
|
||||
let canvas;
|
||||
if (doc) canvas = doc.createElement('canvas');
|
||||
else {
|
||||
try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); }
|
||||
catch (e) { return image; }
|
||||
}
|
||||
canvas.width = image.width;
|
||||
canvas.height = image.height;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
ctx.drawImage(image, 0, 0);
|
||||
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = pixels.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const alpha = data[i + 3];
|
||||
if (alpha > 0 && alpha < 255) {
|
||||
const factor = 255 / alpha;
|
||||
data[i] = Math.min(255, Math.round(data[i] * factor));
|
||||
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
|
||||
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
|
||||
}
|
||||
}
|
||||
ctx.putImageData(pixels, 0, 0);
|
||||
unpremultipliedImages.set(image, canvas);
|
||||
return canvas;
|
||||
} catch (e) {
|
||||
// If pixel access is unavailable, retain the original texture so preview
|
||||
// remains usable instead of failing the whole frame.
|
||||
return image;
|
||||
}
|
||||
}
|
||||
|
||||
function unpackVertices(vertices, count) {
|
||||
const positions = new Array(count * 2);
|
||||
const uvs = new Array(count * 2);
|
||||
for (let i = 0, p = 0; i < count; i++, p += 2) {
|
||||
const source = i * 8;
|
||||
positions[p] = vertices[source];
|
||||
positions[p + 1] = vertices[source + 1];
|
||||
uvs[p] = vertices[source + 6];
|
||||
uvs[p + 1] = vertices[source + 7];
|
||||
}
|
||||
return { positions, uvs };
|
||||
}
|
||||
|
||||
function drawInterleaved(base, image, vertices, triangles, darkColor) {
|
||||
for (let i = 0; i < triangles.length; i += 3) {
|
||||
const a = triangles[i] * 8;
|
||||
const b = triangles[i + 1] * 8;
|
||||
const c = triangles[i + 2] * 8;
|
||||
base.drawTriangle(image,
|
||||
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
|
||||
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
|
||||
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7],
|
||||
[...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)],
|
||||
darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined);
|
||||
}
|
||||
}
|
||||
|
||||
function createLegacyRenderer(spine, context) {
|
||||
const Renderer = rendererConstructor(spine);
|
||||
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
|
||||
const base = new Renderer(context);
|
||||
base.triangleRendering = true;
|
||||
base.premultipliedAlpha = false;
|
||||
const clipper = new spine.SkeletonClipping();
|
||||
const dark = new spine.Color(0, 0, 0, 0);
|
||||
|
||||
base.draw = function drawWithClipping(skeleton) {
|
||||
const composite = context.globalCompositeOperation;
|
||||
const drawOrder = skeleton.drawOrder;
|
||||
const pma = !!base.premultipliedAlpha;
|
||||
try {
|
||||
for (let i = 0; i < drawOrder.length; i++) {
|
||||
const slot = drawOrder[i];
|
||||
if (slot.bone && slot.bone.active === false) {
|
||||
clipper.clipEndWithSlot(slot);
|
||||
continue;
|
||||
}
|
||||
const attachment = slot.getAttachment();
|
||||
if (attachment instanceof spine.ClippingAttachment) {
|
||||
clipper.clipStart(slot, attachment);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
if (!attachment) continue;
|
||||
let vertices;
|
||||
let triangles;
|
||||
let vertexCount;
|
||||
if (attachment instanceof spine.RegionAttachment) {
|
||||
vertices = base.computeRegionVertices(slot, attachment, false);
|
||||
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
|
||||
vertexCount = 4;
|
||||
} else if (attachment instanceof spine.MeshAttachment) {
|
||||
vertices = base.computeMeshVertices(slot, attachment, false);
|
||||
triangles = attachment.triangles;
|
||||
vertexCount = attachment.worldVerticesLength >> 1;
|
||||
} else {
|
||||
continue;
|
||||
}
|
||||
|
||||
const sourceImage = imageFromAttachment(attachment);
|
||||
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
|
||||
if (!image) continue;
|
||||
context.globalAlpha = base.tempColor.a;
|
||||
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
|
||||
if (clipper.isClipping()) {
|
||||
const unpacked = unpackVertices(vertices, vertexCount);
|
||||
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
|
||||
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
|
||||
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor);
|
||||
} else {
|
||||
drawInterleaved(base, image, vertices, triangles, slot.darkColor);
|
||||
}
|
||||
} finally {
|
||||
clipper.clipEndWithSlot(slot);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
clipper.clipEnd();
|
||||
context.globalAlpha = 1;
|
||||
context.globalCompositeOperation = composite;
|
||||
}
|
||||
};
|
||||
base.clipper = clipper;
|
||||
return base;
|
||||
}
|
||||
|
||||
// 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
|
||||
function create34Renderer(spine, context) {
|
||||
const base = new (rendererConstructor(spine))(context);
|
||||
base.triangleRendering = true;
|
||||
base.premultipliedAlpha = false;
|
||||
base.draw = skeleton => {
|
||||
const composite = context.globalCompositeOperation;
|
||||
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 sourceImage = imageFromAttachment(attachment);
|
||||
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha);
|
||||
if (!image) continue;
|
||||
context.globalAlpha = vertices[5];
|
||||
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
|
||||
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
|
||||
}
|
||||
} finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
|
||||
};
|
||||
return base;
|
||||
}
|
||||
|
||||
function create43Renderer(spine, context) {
|
||||
const Renderer = rendererConstructor(spine);
|
||||
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
|
||||
const base = new Renderer(context);
|
||||
base.triangleRendering = true;
|
||||
base.premultipliedAlpha = false;
|
||||
const core = new spine.SkeletonRendererCore();
|
||||
|
||||
base.draw = function drawWithCoreClipping(skeleton) {
|
||||
const composite = context.globalCompositeOperation;
|
||||
try {
|
||||
const pma = !!base.premultipliedAlpha;
|
||||
for (let command = core.render(skeleton, false); command; command = command.next) {
|
||||
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
|
||||
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
|
||||
if (!image) continue;
|
||||
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
|
||||
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
|
||||
const positions = command.positions;
|
||||
const uvs = command.uvs;
|
||||
const indices = command.indices;
|
||||
for (let i = 0; i < command.numIndices; i += 3) {
|
||||
const a = indices[i] * 2;
|
||||
const b = indices[i + 1] * 2;
|
||||
const c = indices[i + 2] * 2;
|
||||
const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255];
|
||||
const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2]));
|
||||
const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2]));
|
||||
base.drawTriangle(image,
|
||||
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
|
||||
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
|
||||
positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
context.globalAlpha = 1;
|
||||
context.globalCompositeOperation = composite;
|
||||
}
|
||||
};
|
||||
base.clipper = core.clipping;
|
||||
return base;
|
||||
}
|
||||
|
||||
function createRenderer(spine, context, runtimeDir) {
|
||||
const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
|
||||
? create43Renderer(spine, context)
|
||||
: createLegacyRenderer(spine, context);
|
||||
// Explicitly disable debug drawing in every supported runtime.
|
||||
renderer.debugRendering = false;
|
||||
// 页面里 rasterizer 已经由 <script> 提前加载并挂在 window 上,Node 里才走 require。
|
||||
const rasterizer = typeof window !== 'undefined' && window.SpineWebglRasterizer
|
||||
? window.SpineWebglRasterizer
|
||||
: require('./spine-webgl-rasterizer.js');
|
||||
return rasterizer.attach(renderer, context);
|
||||
}
|
||||
|
||||
const api = { createRenderer };
|
||||
if (typeof window !== 'undefined') window.SpineCanvasClipping = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,263 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Adapted from Esoteric Software's 3.4/3.5/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.
|
||||
// 3.5 source and license: LICENSES/Spine-3.5-License.txt.
|
||||
// Decode the versioned wire format to an in-memory SkeletonJson object. The
|
||||
// matching runtime builds attachments/timelines and applies loader.scale once.
|
||||
|
||||
const dict = () => Object.create(null);
|
||||
class Input {
|
||||
constructor(bytes) {
|
||||
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
|
||||
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
|
||||
this.offset = 0;
|
||||
}
|
||||
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
|
||||
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
|
||||
byte() { this.need(1); return this.view.getUint8(this.offset++); }
|
||||
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
|
||||
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
|
||||
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
|
||||
varint(positive = true) {
|
||||
let value = 0;
|
||||
for (let shift = 0; shift <= 28; shift += 7) {
|
||||
const b = this.byte();
|
||||
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
|
||||
value |= (b & 127) << shift;
|
||||
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
|
||||
}
|
||||
this.fail('无效变长整数');
|
||||
}
|
||||
count() { const n = this.varint(); this.need(n); return n; }
|
||||
string() {
|
||||
const size = this.varint();
|
||||
if (size === 0) return null;
|
||||
this.need(size - 1);
|
||||
const end = this.offset + size - 1;
|
||||
let result = '';
|
||||
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
|
||||
// code units (including surrogate pairs), rather than string references.
|
||||
while (this.offset < end) {
|
||||
const b = this.byte();
|
||||
if (b < 128) result += String.fromCharCode(b);
|
||||
else {
|
||||
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
|
||||
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
|
||||
let code = b & (extra === 1 ? 31 : 15);
|
||||
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
|
||||
result += String.fromCharCode(code);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
|
||||
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
|
||||
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
|
||||
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
|
||||
}
|
||||
|
||||
function decode(bytes, runtimeDir) {
|
||||
const r = new Input(bytes);
|
||||
const skeleton = { hash: r.string(), spine: r.string() };
|
||||
if (!/^3\.[4567]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.5 / 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';
|
||||
const before36 = v34 || version === '3.5';
|
||||
skeleton.width = r.float(); skeleton.height = r.float();
|
||||
const nonessential = r.bool();
|
||||
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;
|
||||
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
|
||||
const boneList = () => Array.from({ length: r.count() }, boneName);
|
||||
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');
|
||||
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() };
|
||||
if (!before36) { 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: 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: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
|
||||
if (!before36) { 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: 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);
|
||||
}
|
||||
function vertices(n) {
|
||||
if (!r.bool()) return r.floats(n * 2);
|
||||
const out = [];
|
||||
for (let i = 0; i < n; i++) {
|
||||
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
|
||||
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function attachment(key) {
|
||||
const a = { name: r.string() || key };
|
||||
const type = r.byte();
|
||||
if (before36 && type > 4) r.fail(version + ' 不支持此附件类型:' + type);
|
||||
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
|
||||
if (!a.type) r.fail('未知附件类型:' + type);
|
||||
if (type === 0) {
|
||||
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
|
||||
} else if (type === 2) {
|
||||
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
|
||||
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
|
||||
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
|
||||
a.vertices = vertices(count); a.hull = r.varint();
|
||||
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
|
||||
} else if (type === 3) {
|
||||
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
|
||||
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
|
||||
} else if (type === 5) {
|
||||
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
|
||||
} else {
|
||||
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
|
||||
if (type === 6) a.end = slotName();
|
||||
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
|
||||
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
|
||||
if (nonessential) a.color = r.color();
|
||||
}
|
||||
return a;
|
||||
}
|
||||
const skinNames = [];
|
||||
function skin(name, isDefault = false) {
|
||||
const count = r.count(); if (!count && isDefault) return;
|
||||
const data = root.skins[name] = dict(); skinNames.push(name);
|
||||
for (let i = 0; i < count; i++) {
|
||||
const attachments = data[slotName()] = dict();
|
||||
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
|
||||
}
|
||||
}
|
||||
skin('default', true);
|
||||
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
|
||||
const eventNames = [];
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const name = r.string(); eventNames.push(name);
|
||||
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
|
||||
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
|
||||
}
|
||||
function curve(frame) {
|
||||
const type = r.byte();
|
||||
if (type === 1) frame.curve = 'stepped';
|
||||
else if (type === 2) frame.curve = r.floats(4);
|
||||
else if (type !== 0) r.fail('未知曲线类型:' + type);
|
||||
}
|
||||
function frames(read, curved = true) {
|
||||
const count = r.count(); if (!count) r.fail('空时间线');
|
||||
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
|
||||
}
|
||||
function animation() {
|
||||
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const timelines = a.slots[slotName()] = dict();
|
||||
for (let j = 0, m = r.count(); j < m; j++) {
|
||||
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 && !before36) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
|
||||
else r.fail('未知插槽时间线');
|
||||
}
|
||||
}
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const timelines = a.bones[boneName()] = dict();
|
||||
for (let j = 0, m = r.count(); j < m; j++) {
|
||||
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
|
||||
if (!name) r.fail('未知骨骼时间线');
|
||||
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
|
||||
}
|
||||
}
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const name = r.ref(root.ik).name;
|
||||
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
|
||||
}
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
|
||||
}
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const timelines = a.paths[r.ref(root.path).name] = dict();
|
||||
for (let j = 0, m = r.count(); j < m; j++) {
|
||||
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
|
||||
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
|
||||
}
|
||||
}
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
|
||||
for (let j = 0, m = r.count(); j < m; j++) {
|
||||
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
|
||||
for (let k = 0, q = r.count(); k < q; k++) {
|
||||
const key = r.string();
|
||||
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
|
||||
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
|
||||
}
|
||||
}
|
||||
}
|
||||
const drawCount = r.count();
|
||||
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
|
||||
const f = { time: r.float(), offsets: [] }; let previous = -1;
|
||||
for (let i = 0, n = r.count(); i < n; i++) {
|
||||
const index = r.varint(); const offset = r.varint();
|
||||
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
|
||||
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
|
||||
}
|
||||
return f;
|
||||
});
|
||||
const eventCount = r.count();
|
||||
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
|
||||
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
|
||||
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
|
||||
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
|
||||
});
|
||||
return a;
|
||||
}
|
||||
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
|
||||
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
|
||||
return root;
|
||||
}
|
||||
|
||||
function install(spine, runtimeDir) {
|
||||
if (!['3.4', '3.5', '3.6', '3.7'].includes(runtimeDir)) return spine;
|
||||
// 4.x 的运行时用 Object.defineProperty 导出,同名属性是只读 getter,写入会抛
|
||||
// "which has only a getter"。这里的替换只对确实缺少 SkeletonBinary 的老版本有意义,
|
||||
// 失败时保留运行时自带实现,绝不能让一次赋值把整个渲染流程打断。
|
||||
try {
|
||||
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
|
||||
spine.SkeletonBinary = class SkeletonBinary {
|
||||
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
|
||||
readSkeletonData(bytes) {
|
||||
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
|
||||
return loader.readSkeletonData(decode(bytes, runtimeDir));
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// 只读属性:沿用运行时自带实现。
|
||||
}
|
||||
return spine;
|
||||
}
|
||||
|
||||
const api = { install, decode };
|
||||
if (typeof window !== 'undefined') window.SpineLegacyBinary = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,874 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
// service 插件页面的 require 不以插件目录为基准,所以页面里必须走 <script>。
|
||||
(function () {
|
||||
/**
|
||||
* SpineViewer - 工具模块
|
||||
* 该模块为纯 Node.js CommonJS,供 viewer/thumbnail 复用。
|
||||
* 职责:
|
||||
* - 识别 Spine 导出文件的类型(skel/skel.bytes/json/atlas/atlas.txt/png/jpg/jpeg/webp)
|
||||
* - 在同一个目录内自动关联配套的骨骼文件、atlas、贴图
|
||||
* - 从骨骼数据中检测 Spine 版本
|
||||
* - 根据版本选择合适的本地运行时
|
||||
*/
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json'];
|
||||
const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
|
||||
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
|
||||
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
|
||||
const fileIndexCache = new Map();
|
||||
const skeletonJsonCache = new Map();
|
||||
const JSON_PROBE_BYTES = 256 * 1024;
|
||||
|
||||
// Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
|
||||
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
|
||||
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
|
||||
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
|
||||
const SUFFIX_MAX_LENGTH = 50;
|
||||
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
|
||||
let activeSuffixes = DEFAULT_SUFFIXES.slice();
|
||||
let suffixRe = buildSuffixRe(activeSuffixes);
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function buildSuffixRe(list) {
|
||||
if (!list || list.length === 0) return null;
|
||||
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
|
||||
}
|
||||
|
||||
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
|
||||
function normalizeSuffixName(raw) {
|
||||
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
|
||||
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
|
||||
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
|
||||
*/
|
||||
function setSuffixes(list) {
|
||||
const custom = [];
|
||||
for (const raw of Array.isArray(list) ? list : []) {
|
||||
const name = normalizeSuffixName(raw);
|
||||
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
|
||||
if (custom.indexOf(name) === -1) custom.push(name);
|
||||
}
|
||||
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
|
||||
suffixRe = buildSuffixRe(activeSuffixes);
|
||||
return getSuffixes();
|
||||
}
|
||||
|
||||
/** 当前生效的尾缀(内置 + 自定义) */
|
||||
function getSuffixes() {
|
||||
return activeSuffixes.slice();
|
||||
}
|
||||
|
||||
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
|
||||
function getCustomSuffixes() {
|
||||
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
}
|
||||
|
||||
function unsafeAtlasPath(name, reason) {
|
||||
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
|
||||
error.code = 'UNSAFE_ATLAS_PATH';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
|
||||
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
|
||||
*/
|
||||
function validateAtlasPageName(name) {
|
||||
const raw = String(name == null ? '' : name).trim();
|
||||
if (!raw) throw unsafeAtlasPath(name, '路径为空');
|
||||
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
|
||||
const portable = raw.replace(/\\/g, '/');
|
||||
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
|
||||
throw unsafeAtlasPath(raw, '不允许绝对路径');
|
||||
}
|
||||
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
|
||||
const parts = portable.split('/');
|
||||
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
|
||||
const normalized = path.posix.normalize(portable);
|
||||
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
||||
throw unsafeAtlasPath(raw, '路径无效');
|
||||
}
|
||||
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
|
||||
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
|
||||
}
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function isPathInside(basePath, targetPath) {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
||||
}
|
||||
|
||||
function realpath(filePath) {
|
||||
const implementation = fs.realpathSync.native || fs.realpathSync;
|
||||
return implementation(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
|
||||
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
|
||||
*/
|
||||
function resolveFileWithin(baseDir, relativeName, atlasPage) {
|
||||
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
|
||||
const base = path.resolve(baseDir);
|
||||
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
|
||||
if (!isPathInside(base, candidate)) {
|
||||
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
|
||||
return null;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) return null;
|
||||
let realBase;
|
||||
let realCandidate;
|
||||
try {
|
||||
realBase = realpath(base);
|
||||
realCandidate = realpath(candidate);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInside(realBase, realCandidate)) {
|
||||
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
|
||||
return null;
|
||||
}
|
||||
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function resolveAtlasTextureFile(atlasDir, pageName) {
|
||||
return resolveFileWithin(atlasDir, pageName, true);
|
||||
}
|
||||
|
||||
function extOf(name) {
|
||||
const lower = name.toLowerCase();
|
||||
for (let i = 0; i < SKELETON_EXTS.length; i++) {
|
||||
if (lower.endsWith(SKELETON_EXTS[i])) return SKELETON_EXTS[i];
|
||||
}
|
||||
for (let i = 0; i < ATLAS_EXTS.length; i++) {
|
||||
if (lower.endsWith(ATLAS_EXTS[i])) return ATLAS_EXTS[i];
|
||||
}
|
||||
for (let i = 0; i < TEXTURE_EXTS.length; i++) {
|
||||
if (lower.endsWith(TEXTURE_EXTS[i])) return TEXTURE_EXTS[i];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function isSkeletonFile(name) {
|
||||
const ext = extOf(name);
|
||||
return ext === '.skel' || ext === '.skel.bytes' || ext === '.json';
|
||||
}
|
||||
|
||||
function isAtlasFile(name) {
|
||||
const ext = extOf(name);
|
||||
return ext === '.atlas' || ext === '.atlas.txt';
|
||||
}
|
||||
|
||||
function isTextureFile(name) {
|
||||
const ext = extOf(name);
|
||||
return TEXTURE_EXTS.indexOf(ext) !== -1;
|
||||
}
|
||||
|
||||
/** 去掉文件名中的扩展名 */
|
||||
function stemOf(name) {
|
||||
const ext = extOf(name);
|
||||
if (!ext) return name;
|
||||
return name.slice(0, name.length - ext.length);
|
||||
}
|
||||
|
||||
/** 归一化 stem,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
|
||||
function normalizeStem(name) {
|
||||
const lower = stemOf(name).toLowerCase();
|
||||
return suffixRe ? lower.replace(suffixRe, '') : lower;
|
||||
}
|
||||
|
||||
// 复合后缀:Eagle 按「最后一个点」拆 name / ext,于是 EmoticonSpine.atlas.txt 变成
|
||||
// name=EmoticonSpine.atlas + ext=txt。而 Eagle 的卡片把 ext === 'txt' 写死成文本预览,
|
||||
// 会整个盖掉自定义缩略图。把整段复合后缀塞进 ext(name=EmoticonSpine + ext=atlas.txt),
|
||||
// Eagle 认不出这个类型,就会退回「用自定义缩略图当封面」,而 name + '.' + ext 拼出来
|
||||
// 仍是磁盘上的真实文件名,显示和路径都不变。
|
||||
const COMPOUND_SUFFIXES = ['.skel.bytes', '.atlas.txt'];
|
||||
|
||||
/** 返回 { name, ext };不是复合后缀(或没有主干名)时返回 null。 */
|
||||
function splitCompoundName(fileName) {
|
||||
const lower = String(fileName || '').toLowerCase();
|
||||
const suffix = COMPOUND_SUFFIXES.find((s) => lower.endsWith(s));
|
||||
if (!suffix) return null;
|
||||
const name = String(fileName).slice(0, fileName.length - suffix.length);
|
||||
if (!name) return null;
|
||||
return { name, ext: suffix.slice(1) };
|
||||
}
|
||||
|
||||
/** 从 JSON 文本中读取 spine 版本 */
|
||||
function detectVersionFromJson(text) {
|
||||
try {
|
||||
const json = JSON.parse(text);
|
||||
if (json && json.skeleton && json.skeleton.spine) {
|
||||
return String(json.skeleton.spine);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 从二进制数据中读取 spine 版本(扫描前若干字节中的版本号) */
|
||||
function detectVersionFromBuffer(buf) {
|
||||
let s = '';
|
||||
const bytes = new Uint8Array(buf);
|
||||
const len = Math.min(bytes.length, 160);
|
||||
for (let i = 0; i < len; i++) {
|
||||
s += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
/** 从文件读取 spine 版本 */
|
||||
function detectVersion(filePath) {
|
||||
const lower = filePath.toLowerCase();
|
||||
if (lower.endsWith('.json')) {
|
||||
return detectVersionFromJson(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
return detectVersionFromBuffer(fs.readFileSync(filePath));
|
||||
}
|
||||
|
||||
/**
|
||||
* 根据检测到的版本与文件类型,选择本地运行时目录。
|
||||
* - 3.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
|
||||
*/
|
||||
function chooseRuntimeDir(version, isBinary) {
|
||||
if (!version) return null;
|
||||
const major = parseInt(version.split('.')[0], 10);
|
||||
if (major === 3) {
|
||||
const minor = parseFloat(version);
|
||||
if (minor === 3.4) return '3.4';
|
||||
if (minor === 3.5) return '3.5';
|
||||
if (minor <= 3.6) return '3.6';
|
||||
if (minor <= 3.7 + 0.0001) return '3.7';
|
||||
return '3.8';
|
||||
} else if (major === 4) {
|
||||
const minor = parseFloat(version);
|
||||
if (minor <= 4.0 + 0.0001) return '4.0';
|
||||
if (minor <= 4.1 + 0.0001) return '4.1';
|
||||
if (minor <= 4.2 + 0.0001) return '4.2';
|
||||
return '4.3';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
|
||||
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
|
||||
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
|
||||
*/
|
||||
function classifySkeletonJson(filePath) {
|
||||
try {
|
||||
const stat = fs.statSync(filePath);
|
||||
if (!stat.isFile() || stat.size === 0) return 'pending';
|
||||
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
|
||||
const cached = skeletonJsonCache.get(filePath);
|
||||
if (cached && cached.signature === signature) return cached.kind;
|
||||
|
||||
const length = Math.min(stat.size, JSON_PROBE_BYTES);
|
||||
const fd = fs.openSync(filePath, 'r');
|
||||
const buffer = Buffer.allocUnsafe(length);
|
||||
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
|
||||
const text = buffer.toString('utf8');
|
||||
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
|
||||
const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i);
|
||||
let kind;
|
||||
if (versionMatch) {
|
||||
kind = 'spine';
|
||||
} else if (stat.size > JSON_PROBE_BYTES) {
|
||||
kind = 'other';
|
||||
} else {
|
||||
try {
|
||||
JSON.parse(text);
|
||||
kind = 'other';
|
||||
} catch (e) {
|
||||
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
|
||||
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
|
||||
kind = hasSkeletonKey ? 'pending' : 'other';
|
||||
}
|
||||
}
|
||||
|
||||
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
|
||||
skeletonJsonCache.set(filePath, { signature, kind });
|
||||
return kind;
|
||||
} catch (e) {
|
||||
return 'pending';
|
||||
}
|
||||
}
|
||||
|
||||
function looksLikeSkeletonJson(filePath) {
|
||||
return classifySkeletonJson(filePath) === 'spine';
|
||||
}
|
||||
|
||||
/** 文件修改时间(毫秒) */
|
||||
function mtimeOf(fp) {
|
||||
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
|
||||
}
|
||||
|
||||
/** 读取某 item 元数据里的创建时间 btime(毫秒),用于按“同一批导入”配对 */
|
||||
function btimeOf(fp) {
|
||||
const d = itemMeta(path.dirname(fp));
|
||||
return (d && d.btime) || 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Eagle item 的实际入库时间。
|
||||
* metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的
|
||||
* btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。
|
||||
*/
|
||||
function importTimeOf(fp) {
|
||||
try {
|
||||
const metaPath = path.join(path.dirname(fp), 'metadata.json');
|
||||
const stat = fs.statSync(metaPath);
|
||||
return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0;
|
||||
} catch (e) {
|
||||
return btimeOf(fp);
|
||||
}
|
||||
}
|
||||
|
||||
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
|
||||
function versionFamily(version) {
|
||||
if (!version) return null;
|
||||
const major = parseInt(version.split('.')[0], 10);
|
||||
return major <= 3 ? '3x' : '4x';
|
||||
}
|
||||
|
||||
/** 根据 atlas 文本判断其格式家族(3x / 4x) */
|
||||
function atlasFamily(atlasText) {
|
||||
if (!atlasText) return null;
|
||||
// 4.x atlas 用 `bounds:` 定义区域;3.x 用 `xy:`/`size:`/`orig:`/`offset:`。
|
||||
// 注意:`scale:` 在 3.x 也会出现(如字体缩放),不能单独作为 4x 依据。
|
||||
// 因此以 `bounds:` 作为最可靠的 4.x 标志;若整体含 `xy:` 判定为 3x。
|
||||
if (/\bbounds\s*:/.test(atlasText)) return '4x';
|
||||
if (/\bxy\s*:/.test(atlasText)) return '3x';
|
||||
return '3x';
|
||||
}
|
||||
|
||||
/** 读取某 item 文件夹的 metadata.json(含 folders、isDeleted、btime 等) */
|
||||
function itemMeta(itemDir) {
|
||||
if (!itemDir) return null;
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(path.join(itemDir, 'metadata.json'), 'utf8'));
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 读取某个资产文件所属 item 文件夹的 Eagle 逻辑文件夹 id 列表 */
|
||||
function foldersOfItem(filePath) {
|
||||
const d = itemMeta(path.dirname(filePath));
|
||||
return d && Array.isArray(d.folders) ? d.folders : [];
|
||||
}
|
||||
|
||||
/** 判断某 item 是否已被放入 Eagle 回收站(isDeleted) */
|
||||
function isItemDeleted(filePath) {
|
||||
const d = itemMeta(path.dirname(filePath));
|
||||
return !!(d && d.isDeleted);
|
||||
}
|
||||
|
||||
/** 从任意 item 路径向上定位 Eagle 库根目录 */
|
||||
function findLibraryRoot(filePath) {
|
||||
let dir = path.dirname(filePath); // item 文件夹
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const parent = path.dirname(dir);
|
||||
if (parent === dir) break;
|
||||
try {
|
||||
if (fs.existsSync(path.join(parent, 'metadata.json')) && fs.existsSync(path.join(parent, 'images'))) {
|
||||
return parent;
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
dir = parent;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 解析库根 metadata.json,返回 { folderId -> children[] } 及文件夹名 */
|
||||
function readFolderInfo(libRoot) {
|
||||
const map = {};
|
||||
const names = {};
|
||||
if (!libRoot) return { map, names };
|
||||
try {
|
||||
const d = JSON.parse(fs.readFileSync(path.join(libRoot, 'metadata.json'), 'utf8'));
|
||||
const folders = d.folders || [];
|
||||
for (const f of folders) {
|
||||
if (!f) continue;
|
||||
map[f.id] = Array.isArray(f.children) ? f.children : [];
|
||||
names[f.id] = f.name || '';
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
return { map, names };
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算允许匹配的 Eagle 文件夹 id 集合:
|
||||
* 仅包含「选中文件所在的文件夹」本身,不下钻子文件夹,也不匹配父级/兄弟文件夹。
|
||||
*/
|
||||
function getValidFolders(filePath) {
|
||||
const itemFolders = foldersOfItem(filePath);
|
||||
if (itemFolders.length === 0) return null; // 未归入任何 Eagle 文件夹,则不限定
|
||||
return new Set(itemFolders);
|
||||
}
|
||||
|
||||
/** 判断目录是否为 Eagle 的 item 元数据文件夹(包含 metadata.json) */
|
||||
function isEagleItemDir(dir) {
|
||||
try { return fs.existsSync(path.join(dir, 'metadata.json')); } catch (e) { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* 计算需要搜索的目录列表。
|
||||
* Eagle 会把导入的每个文件拆分成独立的元数据文件夹(如 `images/<id>.info/`),
|
||||
* 因此配套的骨骼/atlas/贴图可能位于不同的 item 文件夹中。
|
||||
* 返回:当前 item 文件夹 + 其同级的所有 item 文件夹。
|
||||
*/
|
||||
function getSearchRoots(filePath) {
|
||||
const roots = [];
|
||||
const itemDir = path.dirname(filePath);
|
||||
roots.push(itemDir);
|
||||
if (isEagleItemDir(itemDir)) {
|
||||
const parent = path.dirname(itemDir);
|
||||
try {
|
||||
for (const name of fs.readdirSync(parent)) {
|
||||
const sub = path.join(parent, name);
|
||||
try {
|
||||
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
|
||||
roots.push(sub);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
return [...new Set(roots)];
|
||||
}
|
||||
|
||||
/** 把库根目录 images/ 下所有 item 文件夹加入搜索根(去重)。
|
||||
* 用于覆盖同伴位于不同 .info / 跨批次的情况(全库兜底)。 */
|
||||
function buildRootsWithLibraryRoot(filePath, roots) {
|
||||
const libRoot = findLibraryRoot(filePath);
|
||||
if (!libRoot) return;
|
||||
const imagesDir = path.join(libRoot, 'images');
|
||||
try {
|
||||
if (!fs.existsSync(imagesDir)) return;
|
||||
for (const name of fs.readdirSync(imagesDir)) {
|
||||
const sub = path.join(imagesDir, name);
|
||||
try {
|
||||
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
|
||||
if (roots.indexOf(sub) === -1) roots.push(sub);
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
} catch (e) { /* ignore */ }
|
||||
}
|
||||
|
||||
/**
|
||||
* 在多个目录中收集所有 Spine 相关文件,返回 [{ name, fullPath }]。
|
||||
* 若传入 validFolders(Eagle 逻辑文件夹 id 集合),则只保留位于这些文件夹内的 item,
|
||||
* 从而把匹配范围限定在「选中文件所在的 Eagle 文件夹及其子文件夹」内。
|
||||
*/
|
||||
function collectSpineFiles(roots, validFolders) {
|
||||
const seen = new Set();
|
||||
const out = [];
|
||||
for (const root of roots) {
|
||||
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
|
||||
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
|
||||
const probe = path.join(root, '__spineviewer_probe__');
|
||||
const libRoot = findLibraryRoot(probe);
|
||||
if (libRoot) {
|
||||
try {
|
||||
const imagesReal = realpath(path.join(libRoot, 'images'));
|
||||
const rootReal = realpath(root);
|
||||
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
|
||||
} catch (e) { continue; }
|
||||
}
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(root); } catch (e) { continue; }
|
||||
for (const name of entries) {
|
||||
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
|
||||
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
|
||||
const full = resolveFileWithin(root, name, false);
|
||||
if (!full) continue;
|
||||
// 排除 Eagle 回收站中已删除的项
|
||||
if (isItemDeleted(full)) continue;
|
||||
if (validFolders && validFolders.size) {
|
||||
const itemFolders = foldersOfItem(full);
|
||||
if (!itemFolders.some((f) => validFolders.has(f))) continue; // 不在允许的文件夹内
|
||||
}
|
||||
if (seen.has(full)) continue;
|
||||
seen.add(full);
|
||||
out.push({ name, fullPath: full });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个
|
||||
* 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉
|
||||
* 正在落盘的新文件。
|
||||
*/
|
||||
function collectSpineFilesCached(filePath, roots, refresh) {
|
||||
const libRoot = findLibraryRoot(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 (!refresh && cached && now - cached.time < 1500) return cached.files;
|
||||
const files = collectSpineFiles(roots, null);
|
||||
fileIndexCache.set(key, { time: now, files });
|
||||
return files;
|
||||
}
|
||||
|
||||
/**
|
||||
* 修正不可靠的源路径。
|
||||
* Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录
|
||||
* (例如其内部临时工作目录)。此时按文件名在整个库 images/ 下找到真实存在的
|
||||
* 源文件,作为后续资源发现的基准。
|
||||
*/
|
||||
function resolveRealSource(filePath) {
|
||||
// 源目录存在且是有效 item 目录 → 直接用
|
||||
const dir = path.dirname(filePath);
|
||||
if (isEagleItemDir(dir)) return filePath;
|
||||
const name = path.basename(filePath);
|
||||
const libRoot = findLibraryRoot(filePath);
|
||||
if (!libRoot) return filePath;
|
||||
const imagesDir = path.join(libRoot, 'images');
|
||||
try {
|
||||
if (!fs.existsSync(imagesDir)) return filePath;
|
||||
// 优先找“文件名 + 类型”匹配的真实文件。若存在同名文件,返回它所在目录,
|
||||
// 以便后续 getSearchRoots/selectBest 能借助其 folders/btime 精确匹配。
|
||||
let fallback = null;
|
||||
for (const d of fs.readdirSync(imagesDir)) {
|
||||
const sub = path.join(imagesDir, d);
|
||||
if (!isEagleItemDir(sub)) continue;
|
||||
const cand = path.join(sub, name);
|
||||
if (fs.existsSync(cand)) {
|
||||
// 优先选择未被删除的项
|
||||
if (!isItemDeleted(cand)) return cand;
|
||||
if (!fallback) fallback = cand;
|
||||
}
|
||||
}
|
||||
if (fallback) return fallback;
|
||||
} catch (e) { /* ignore */ }
|
||||
return filePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发现与所选文件 related 的 Spine 资源(跨 Eagle 多个 item 文件夹)。
|
||||
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
|
||||
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
|
||||
*/
|
||||
function discoverAssets(filePath, options) {
|
||||
options = options || {};
|
||||
const strictStem = !!options.strictStem;
|
||||
const requestedCrossBatch = !!options.allowCrossBatch;
|
||||
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
|
||||
filePath = resolveRealSource(filePath);
|
||||
const selected = path.basename(filePath);
|
||||
const selectedStem = normalizeStem(selected);
|
||||
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
|
||||
let roots;
|
||||
if (Array.isArray(options.searchRoots) && options.searchRoots.length) {
|
||||
roots = [...new Set(options.searchRoots.map((root) => path.resolve(root)))];
|
||||
} else {
|
||||
roots = getSearchRoots(filePath);
|
||||
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
|
||||
// 明确列举的候选 item 目录,避免每次预览扫描整库。
|
||||
buildRootsWithLibraryRoot(filePath, roots);
|
||||
}
|
||||
|
||||
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
|
||||
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
|
||||
const validFolders = getValidFolders(filePath);
|
||||
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
|
||||
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
|
||||
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
|
||||
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
|
||||
let files;
|
||||
if (strictStem) {
|
||||
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
|
||||
// 无论是否允许跨批次,都必须限定在同一个 Eagle 文件夹内。同名素材很常见
|
||||
//(同一套资源的 3.6 与 4.0 导出、同一角色的不同批次),只按文件名 stem
|
||||
// 匹配会配到别的版本,渲染时要么区域找不到,要么贴图坐标整个错位。
|
||||
files = validFolders && validFolders.size
|
||||
? allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)))
|
||||
: allFiles;
|
||||
} else {
|
||||
files = collectSpineFiles(roots, validFolders);
|
||||
}
|
||||
|
||||
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
|
||||
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
|
||||
const textureFiles = files.filter((f) => isTextureFile(f.name));
|
||||
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
|
||||
// 避免每个回调都解析库中所有普通 JSON。
|
||||
if (strictStem) {
|
||||
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
|
||||
}
|
||||
|
||||
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
|
||||
const realSkeletonFiles = skeletonFiles.filter((f) => {
|
||||
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
|
||||
return true;
|
||||
});
|
||||
|
||||
// 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。
|
||||
// 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。
|
||||
const selectBest = (cands, refFile) => {
|
||||
if (!cands || cands.length === 0) return null;
|
||||
const refFolders = foldersOfItem(refFile);
|
||||
const refImportTime = importTimeOf(refFile);
|
||||
const refBtime = btimeOf(refFile);
|
||||
const ranked = cands.map((c) => {
|
||||
const cFolders = foldersOfItem(c.fullPath);
|
||||
return {
|
||||
c,
|
||||
importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER,
|
||||
sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0,
|
||||
btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER,
|
||||
};
|
||||
});
|
||||
ranked.sort((a, b) => allowCrossBatch
|
||||
? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
|
||||
: (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
|
||||
);
|
||||
return ranked[0].c;
|
||||
};
|
||||
|
||||
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
|
||||
const familyOfAtlas = (f) => { try { return atlasFamily(fs.readFileSync(f.fullPath, 'utf8')); } catch (e) { return null; } };
|
||||
const familyOfSkel = (f) => { try { return versionFamily(detectVersion(f.fullPath)); } catch (e) { return null; } };
|
||||
|
||||
// 选中的文件若是 atlas,则读取其格式家族,用于匹配同家族的骨骼
|
||||
const selectedAtlasFamily = isAtlasFile(selected)
|
||||
? (() => { try { return atlasFamily(fs.readFileSync(filePath, 'utf8')); } catch (e) { return null; } })()
|
||||
: null;
|
||||
|
||||
// 1) 骨骼文件
|
||||
let skeleton = null;
|
||||
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
|
||||
if (isSkeletonFile(selected)) {
|
||||
skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) ||
|
||||
realSkeletonFiles.find((f) => f.name === selected) || null;
|
||||
}
|
||||
if (!skeleton) {
|
||||
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
|
||||
let cands = realSkeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
|
||||
if (selectedAtlasFamily && cands.length > 1) {
|
||||
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
|
||||
if (same.length > 0) cands = same;
|
||||
}
|
||||
skeleton = cands.length > 0 ? selectBest(cands, filePath) : null;
|
||||
}
|
||||
if (!skeleton && realSkeletonFiles.length > 0) {
|
||||
if (strictStem) {
|
||||
skeleton = null;
|
||||
} else {
|
||||
// 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取
|
||||
let cands = realSkeletonFiles;
|
||||
if (selectedAtlasFamily) {
|
||||
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
|
||||
if (same.length > 0) cands = same;
|
||||
}
|
||||
skeleton = selectBest(cands, filePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
|
||||
let atlas = null;
|
||||
if (isAtlasFile(selected)) {
|
||||
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
|
||||
atlasFiles.find((f) => f.name === selected) || null;
|
||||
}
|
||||
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
|
||||
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
|
||||
const pickAtlas = (cands, refFile) => {
|
||||
if (!cands || cands.length === 0) return null;
|
||||
const skelFamily = skeleton ? familyOfSkel(skeleton) : null;
|
||||
if (skelFamily) {
|
||||
const same = cands.filter((f) => familyOfAtlas(f) === skelFamily);
|
||||
if (same.length > 0) cands = same;
|
||||
}
|
||||
return selectBest(cands, refFile);
|
||||
};
|
||||
|
||||
if (!atlas) {
|
||||
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === selectedStem);
|
||||
atlas = pickAtlas(cands, filePath);
|
||||
}
|
||||
if (!atlas && skeleton) {
|
||||
const skelStem = normalizeStem(skeleton.name);
|
||||
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === skelStem);
|
||||
atlas = pickAtlas(cands, skeleton.fullPath);
|
||||
}
|
||||
if (!atlas && skeleton) {
|
||||
if (strictStem) {
|
||||
atlas = null;
|
||||
} else {
|
||||
// 忽略 stem:用与骨骼同家族且同批入库的 atlas
|
||||
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
|
||||
}
|
||||
}
|
||||
if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0];
|
||||
|
||||
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
|
||||
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
|
||||
if (strictStem && skeleton && atlas) {
|
||||
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
|
||||
if (pairDelta > allowedImportDelta) {
|
||||
if (isAtlasFile(selected)) skeleton = null;
|
||||
else atlas = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
|
||||
let texturePaths = {};
|
||||
if (atlas) {
|
||||
let text = '';
|
||||
try { text = fs.readFileSync(atlas.fullPath, 'utf8'); } catch (e) { /* ignore */ }
|
||||
const pageNames = parseAtlasPages(text);
|
||||
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
|
||||
for (const pn of pageNames) {
|
||||
// 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
|
||||
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
|
||||
if (inAtlasDir) {
|
||||
texturePaths[pn] = inAtlasDir;
|
||||
continue;
|
||||
}
|
||||
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
|
||||
const pageFileName = path.posix.basename(pn);
|
||||
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
|
||||
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
|
||||
let hit = selectBest(cands, atlas.fullPath);
|
||||
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
|
||||
if (hit) texturePaths[pn] = hit.fullPath;
|
||||
}
|
||||
}
|
||||
|
||||
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
|
||||
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
|
||||
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
|
||||
return {
|
||||
skeletonPath: skeleton ? skeleton.fullPath : null,
|
||||
atlasPath: atlas ? atlas.fullPath : null,
|
||||
texturePaths,
|
||||
base,
|
||||
isBinary: skeletonIsBinary,
|
||||
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
|
||||
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
|
||||
atlasCandidates: atlasFiles.map((f) => f.name),
|
||||
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
|
||||
textureCandidates: textureFiles.map((f) => f.name),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 atlas 文本中解析出引用的贴图页面文件名 */
|
||||
function parseAtlasPages(atlasText) {
|
||||
const pages = [];
|
||||
if (!atlasText) return pages;
|
||||
const lines = atlasText.split(/\r?\n/);
|
||||
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i].trim();
|
||||
if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
|
||||
pages.push(validateAtlasPageName(line));
|
||||
} else if (line === '') {
|
||||
// 空白行之后的下一个非空行可能是下一页文件名
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return [...new Set(pages)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
|
||||
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
|
||||
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
|
||||
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
|
||||
*/
|
||||
function parseAtlasPma(atlasText) {
|
||||
if (typeof atlasText !== 'string') return undefined;
|
||||
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
|
||||
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
|
||||
}
|
||||
|
||||
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
|
||||
function preferredSkin(skeletonData) {
|
||||
if (!skeletonData) return null;
|
||||
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
|
||||
const skins = skeletonData.skins || [];
|
||||
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
|
||||
}
|
||||
|
||||
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
|
||||
function applySkin(skeleton, skin) {
|
||||
if (!skeleton || !skin) return false;
|
||||
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
|
||||
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
|
||||
else return false;
|
||||
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
|
||||
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
|
||||
return true;
|
||||
}
|
||||
|
||||
const spineUtilsApi = {
|
||||
SKELETON_EXTS,
|
||||
ATLAS_EXTS,
|
||||
TEXTURE_EXTS,
|
||||
ALL_EXTS,
|
||||
extOf,
|
||||
isSkeletonFile,
|
||||
isAtlasFile,
|
||||
isTextureFile,
|
||||
stemOf,
|
||||
normalizeStem,
|
||||
DEFAULT_SUFFIXES,
|
||||
SUFFIX_MAX_LENGTH,
|
||||
normalizeSuffixName,
|
||||
setSuffixes,
|
||||
getSuffixes,
|
||||
getCustomSuffixes,
|
||||
detectVersion,
|
||||
detectVersionFromJson,
|
||||
detectVersionFromBuffer,
|
||||
chooseRuntimeDir,
|
||||
classifySkeletonJson,
|
||||
looksLikeSkeletonJson,
|
||||
discoverAssets,
|
||||
parseAtlasPages,
|
||||
validateAtlasPageName,
|
||||
resolveAtlasTextureFile,
|
||||
parseAtlasPma,
|
||||
preferredSkin,
|
||||
applySkin,
|
||||
isEagleItemDir,
|
||||
getSearchRoots,
|
||||
collectSpineFiles,
|
||||
foldersOfItem,
|
||||
findLibraryRoot,
|
||||
readFolderInfo,
|
||||
getValidFolders,
|
||||
splitCompoundName,
|
||||
mtimeOf,
|
||||
btimeOf,
|
||||
importTimeOf,
|
||||
versionFamily,
|
||||
atlasFamily,
|
||||
itemMeta,
|
||||
isItemDeleted,
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') window.SpineUtils = spineUtilsApi;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = spineUtilsApi;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,33 @@
|
||||
// 从画布像素里找实际有内容的包围盒(动态缩略图按整个动画的可见范围取景用)。
|
||||
// 与主插件 js/spine-visible-bounds.js 是同一份实现,只是出口兼容 <script> 加载。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function findAlphaBounds(rgba, width, height, threshold = 4) {
|
||||
let left = width, top = height, right = 0, bottom = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1);
|
||||
bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
}
|
||||
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
||||
}
|
||||
|
||||
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
|
||||
if (!pixels || !isFinite(scale) || scale <= 0) return null;
|
||||
const left = centerX + (pixels.left - width / 2) / scale;
|
||||
const right = centerX + (pixels.right - width / 2) / scale;
|
||||
const top = centerY - (pixels.top - height / 2) / scale;
|
||||
const bottom = centerY - (pixels.bottom - height / 2) / scale;
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
const api = { findAlphaBounds, toWorldBounds };
|
||||
if (typeof window !== 'undefined') window.SpineVisibleBounds = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,183 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
// Decode file bytes without the Canvas 2D premultiply/unpremultiply round trip.
|
||||
// In low-alpha PMA pixels that round trip destroys RGB precision before blending.
|
||||
const preparedTextures = new WeakMap();
|
||||
async function prepareTexture(image, blob) {
|
||||
if (typeof createImageBitmap !== 'function') return;
|
||||
const options = { colorSpaceConversion: 'none' };
|
||||
const [pma, straight] = await Promise.all([
|
||||
createImageBitmap(blob, { ...options, premultiplyAlpha: 'none' }),
|
||||
createImageBitmap(blob, { ...options, premultiplyAlpha: 'premultiply' })
|
||||
]);
|
||||
preparedTextures.set(image, { pma, straight });
|
||||
}
|
||||
|
||||
// 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;
|
||||
attribute vec4 light;
|
||||
attribute vec4 dark;
|
||||
uniform vec2 resolution;
|
||||
varying vec2 textureUV;
|
||||
varying vec4 lightColor;
|
||||
varying vec4 darkColor;
|
||||
void main() {
|
||||
vec2 p = position / resolution * 2.0 - 1.0;
|
||||
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
|
||||
textureUV = uv;
|
||||
lightColor = light; darkColor = dark;
|
||||
}`);
|
||||
const fragment = shader(gl.FRAGMENT_SHADER, `
|
||||
precision mediump float;
|
||||
uniform sampler2D image;
|
||||
varying vec2 textureUV;
|
||||
varying vec4 lightColor;
|
||||
varying vec4 darkColor;
|
||||
void main() {
|
||||
vec4 color = texture2D(image, textureUV);
|
||||
float alpha = color.a * lightColor.a;
|
||||
// Textures are PMA in GPU memory. Light/dark colors arrive straight.
|
||||
vec3 rgb = (color.rgb * lightColor.rgb + (color.a - color.rgb) * darkColor.rgb) * lightColor.a;
|
||||
gl_FragColor = vec4(rgb, 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');
|
||||
const light = gl.getAttribLocation(program, 'light'), dark = gl.getAttribLocation(program, 'dark');
|
||||
const textures = new Map();
|
||||
let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
|
||||
let disposed = false, lost = false;
|
||||
let premultipliedAlpha = false;
|
||||
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
|
||||
function texture(image) {
|
||||
const pma = image !== context.canvas && premultipliedAlpha;
|
||||
let pair = textures.get(image);
|
||||
if (!pair) { pair = {}; textures.set(image, pair); }
|
||||
const key = pma ? 'pma' : 'straight';
|
||||
if (pair[key] && image !== context.canvas) return pair[key];
|
||||
const t = pair[key] || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
|
||||
// Premultiply before interpolation so translucent edges do not darken.
|
||||
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !pma);
|
||||
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);
|
||||
const prepared = preparedTextures.get(image);
|
||||
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, prepared ? prepared[key] : image);
|
||||
pair[key] = t; return t;
|
||||
}
|
||||
function flush() {
|
||||
if (!coordinates.length) return;
|
||||
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
|
||||
// Premultiplied RGB factors; keep source-over alpha independently.
|
||||
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
|
||||
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
|
||||
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
|
||||
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
|
||||
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
|
||||
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
|
||||
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 12);
|
||||
coordinates.length = 0;
|
||||
}
|
||||
return {
|
||||
begin(pma = false) {
|
||||
if (disposed || lost || gl.isContextLost()) return false;
|
||||
premultipliedAlpha = pma;
|
||||
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, 48, 0);
|
||||
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 48, 8);
|
||||
gl.enableVertexAttribArray(light); gl.vertexAttribPointer(light, 4, gl.FLOAT, false, 48, 16);
|
||||
gl.enableVertexAttribArray(dark); gl.vertexAttribPointer(dark, 4, gl.FLOAT, false, 48, 32);
|
||||
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);
|
||||
// Seed the same framebuffer with the actual Canvas pixels (solid or
|
||||
// checkerboard background). CSS backgrounds cannot participate in blending.
|
||||
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
|
||||
const w = canvas.width, h = canvas.height;
|
||||
for (const vertex of [[0,0,0,0],[w,0,1,0],[w,h,1,1],[0,0,0,0],[w,h,1,1],[0,h,0,1]])
|
||||
coordinates.push(...vertex, 1,1,1,1, 0,0,0,0);
|
||||
flush(); currentImage = null;
|
||||
return true;
|
||||
},
|
||||
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, colors, darkColors) {
|
||||
const alpha = context.globalAlpha;
|
||||
const mode = context.globalCompositeOperation || 'source-over';
|
||||
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
|
||||
const t = transform;
|
||||
const points = [[x0,y0,u0,v0],[x1,y1,u1,v1],[x2,y2,u2,v2]];
|
||||
for (let i=0; i<3; i++) {
|
||||
const [x,y,u,v] = points[i], j=i*4;
|
||||
coordinates.push(t.a*x+t.c*y+t.e, t.b*x+t.d*y+t.f, u,v,
|
||||
...(colors ? colors.slice(j,j+4) : [1,1,1,alpha]),
|
||||
...(darkColors ? darkColors.slice(j,j+4) : [0,0,0,0]));
|
||||
}
|
||||
},
|
||||
end() {
|
||||
flush();
|
||||
context.save();
|
||||
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
|
||||
finally { context.restore(); }
|
||||
},
|
||||
dispose() {
|
||||
if (disposed) return; disposed = true;
|
||||
for (const pair of textures.values()) for (const value of Object.values(pair)) 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(!!renderer.premultipliedAlpha)) return draw(skeleton);
|
||||
renderer.gpuDrawing = true;
|
||||
renderer.drawTriangle = rasterizer.triangle;
|
||||
try { draw(skeleton); rasterizer.end(); }
|
||||
finally { renderer.drawTriangle = triangle; renderer.gpuDrawing = false; }
|
||||
};
|
||||
renderer.dispose = rasterizer.dispose;
|
||||
renderer.rasterization = 'webgl';
|
||||
return renderer;
|
||||
}
|
||||
|
||||
const api = { createRasterizer, attach, prepareTexture };
|
||||
if (typeof window !== 'undefined') window.SpineWebglRasterizer = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -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
@@ -0,0 +1,11 @@
|
||||
# Spine 3.5
|
||||
|
||||
Official `3.5.51` tag, commit `2cd9467d2736b7f3a0b81382f117c73823bfb44b`, `spine-ts/build/spine-canvas.js`. The branch named `3.5` contains later 3.6 beta data; this bundle deliberately uses the tag.
|
||||
|
||||
Local correction: the path-length loop uses `lengths[i]` instead of `lengths[i++]`, avoiding a double increment and NaN values.
|
||||
|
||||
The binary compatibility layer reads 3.5 transform modes and constraint order, but does not read 3.6 dark-slot color or local/relative transform fields. Rendering uses the older attachment vertex interface. The version predates clipping attachments.
|
||||
|
||||
The official tag includes matching 3.5.03-beta JSON/SKEL pairs for seven variants. Tank and Raptor have 3.5.49 JSON but 3.5.03-beta SKEL; these are tested independently, not compared as identical exports. No version strings or data are rewritten.
|
||||
|
||||
License: `LICENSES/Spine-3.5-License.txt`.
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
|
||||
"version": "0.1.0",
|
||||
"platform": "all",
|
||||
"arch": "all",
|
||||
"name": "SpineThumbnailGenerateServer",
|
||||
"logo": "/logo.png",
|
||||
"keywords": [
|
||||
"spine",
|
||||
"thumbnail",
|
||||
"缩略图"
|
||||
],
|
||||
"devTools": false,
|
||||
"main": {
|
||||
"serviceMode": true,
|
||||
"url": "index.html",
|
||||
"width": 780,
|
||||
"height": 620,
|
||||
"childWindow": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Spine缩略图</title>
|
||||
<style>
|
||||
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; }
|
||||
html, body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
|
||||
body { display: flex; flex-direction: column; gap: 8px; padding: 14px; box-sizing: border-box; }
|
||||
h1 { font-size: 13px; margin: 0; font-weight: 600; text-align: center; }
|
||||
.bar { height: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||||
/* 不加过渡:窗口被遮挡时 CSS 过渡会停在中途,进度条就不跟着走了。 */
|
||||
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); }
|
||||
#count { color: var(--text); }
|
||||
#name { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 24px; font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#action { align-self: center; }
|
||||
#confirm { display: none; flex-direction: column; gap: 8px; }
|
||||
#confirmText { text-align: center; color: var(--text); }
|
||||
#confirmButtons { display: flex; gap: 10px; justify-content: center; }
|
||||
/* 继续 = 默认项(绿色、自动获得焦点);确认取消 = 危险操作(红色)。 */
|
||||
#resume { background: #2ea043; border-color: #2ea043; color: #fff; }
|
||||
#resume:hover { background: #3fb950; border-color: #3fb950; color: #fff; }
|
||||
#cancel { background: #d64545; border-color: #d64545; color: #fff; }
|
||||
#cancel:hover { background: #e5534b; border-color: #e5534b; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>正在生成动态缩略图……</h1>
|
||||
<div class="bar"><i id="bar"></i></div>
|
||||
<div id="count">等待开始…</div>
|
||||
<div id="name"></div>
|
||||
<div id="confirm">
|
||||
<div id="confirmText">是否确认取消?</div>
|
||||
<div id="confirmButtons">
|
||||
<button id="resume" type="button">继续</button>
|
||||
<button id="cancel" type="button">确认取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="action" type="button">停止</button>
|
||||
<script>
|
||||
// 这个页面是宿主用 Eagle 的 create-plugin-window 打开的插件窗口:
|
||||
// 无标题栏、置顶、挂在 Eagle 主窗口上;进度从临时目录的状态文件轮询。
|
||||
// 按钮往回写控制文件:生成中 = 停止(暂停并就地询问),生成完 = 确认(关窗)。
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
|
||||
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
|
||||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||||
const WIDTH = 380;
|
||||
const bar = document.getElementById('bar');
|
||||
const countEl = document.getElementById('count');
|
||||
const nameEl = document.getElementById('name');
|
||||
const actionEl = document.getElementById('action');
|
||||
const confirmEl = document.getElementById('confirm');
|
||||
const confirmTextEl = document.getElementById('confirmText');
|
||||
const resumeEl = document.getElementById('resume');
|
||||
let finished = false;
|
||||
let paused = false;
|
||||
let doneCount = 0;
|
||||
let lastHeight = 0;
|
||||
let placedAt = null; // 上次由我们设置的位置,用来判断用户是不是手动拖过
|
||||
let allowClose = false;
|
||||
|
||||
/** Eagle 的 userData 目录;eagle.app.userDataPath 注入得比较晚,所以留一个兜底。 */
|
||||
function userDataPath() {
|
||||
if (eagle.app && eagle.app.userDataPath) return eagle.app.userDataPath;
|
||||
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Eagle');
|
||||
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Eagle');
|
||||
}
|
||||
|
||||
/** 算出"Eagle 主窗口正中心"对应的弹窗位置;读不到就返回 null。 */
|
||||
function centerTarget(width, height) {
|
||||
let state = null;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(path.join(userDataPath(), 'window-state.json'), 'utf8'));
|
||||
} catch (error) { /* 读不到就用不了 */ }
|
||||
if (!state || !Number.isFinite(state.x)) return null;
|
||||
// 主窗口最大化/全屏时记录的是还原后的位置,拿它当基准会偏,改用所在显示器居中。
|
||||
const area = (state.isMaximized || state.isFullScreen) && state.displayBounds ? state.displayBounds : state;
|
||||
return [
|
||||
Math.round(area.x + (area.width - width) / 2),
|
||||
Math.round(area.y + (area.height - height) / 2),
|
||||
];
|
||||
}
|
||||
|
||||
/** 跟着 Eagle 主窗口居中;一旦用户手动拖过窗口,就只改尺寸、不再动位置。 */
|
||||
async function reCenter(width, height) {
|
||||
try {
|
||||
const current = await eagle.window.getPosition();
|
||||
const movedByUser = placedAt && (Math.abs(current[0] - placedAt[0]) > 4 || Math.abs(current[1] - placedAt[1]) > 4);
|
||||
if (movedByUser) return;
|
||||
} catch (error) { /* 拿不到位置就照常居中 */ }
|
||||
const target = centerTarget(width, height);
|
||||
if (!target) {
|
||||
await eagle.window.center();
|
||||
return;
|
||||
}
|
||||
placedAt = target;
|
||||
await eagle.window.setPosition(target[0], target[1]);
|
||||
}
|
||||
|
||||
/** 窗口高度跟着内容走。 */
|
||||
function fitHeight() {
|
||||
// setSize 设置的是包含系统标题栏的外部高度,内容高度要补回窗口边框占用。
|
||||
const height = Math.ceil(document.body.scrollHeight + Math.max(0, window.outerHeight - window.innerHeight));
|
||||
if (Math.abs(height - lastHeight) < 2) return;
|
||||
lastHeight = height;
|
||||
eagle.window.setSize(WIDTH, height)
|
||||
.then(() => reCenter(WIDTH, height))
|
||||
.catch(() => { /* 调整失败不影响显示 */ });
|
||||
}
|
||||
|
||||
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
|
||||
function syncButtons() {
|
||||
actionEl.textContent = finished ? '确认' : '停止';
|
||||
actionEl.style.display = paused ? 'none' : '';
|
||||
confirmEl.style.display = paused ? 'flex' : 'none';
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
function writeControl(action) {
|
||||
try {
|
||||
fs.writeFileSync(CONTROL_FILE, JSON.stringify({ action, at: Date.now() }), 'utf8');
|
||||
} catch (error) { /* 写不进去就等于没按 */ }
|
||||
}
|
||||
|
||||
function requestStop() {
|
||||
if (paused) return;
|
||||
paused = true;
|
||||
writeControl('pause');
|
||||
syncButtons();
|
||||
resumeEl.focus();
|
||||
}
|
||||
|
||||
// 系统关闭按钮与页面里的「停止」走同一条暂停确认流程。
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (allowClose || finished) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = false;
|
||||
requestStop();
|
||||
});
|
||||
|
||||
function render(state) {
|
||||
const total = Math.max(1, state.total || 0);
|
||||
const done = state.done || 0;
|
||||
finished = state.finished === true;
|
||||
if (finished) paused = false;
|
||||
doneCount = done;
|
||||
confirmTextEl.textContent = '已生成 ' + doneCount + ' 张缩略图,是否确认取消?';
|
||||
if (finished) {
|
||||
bar.style.width = '100%';
|
||||
countEl.textContent = state.text || '已完成';
|
||||
nameEl.textContent = '';
|
||||
} else {
|
||||
bar.style.width = Math.min(100, done / total * 100) + '%';
|
||||
countEl.textContent = '已完成 ' + done + ' / ' + (state.total || 0) +
|
||||
'(' + Math.round(done / total * 100) + '%)';
|
||||
nameEl.textContent = state.name || '';
|
||||
}
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
actionEl.addEventListener('click', () => {
|
||||
if (finished) {
|
||||
allowClose = true;
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
requestStop();
|
||||
});
|
||||
|
||||
resumeEl.addEventListener('click', () => {
|
||||
paused = false;
|
||||
writeControl('resume');
|
||||
syncButtons();
|
||||
});
|
||||
|
||||
document.getElementById('cancel').addEventListener('click', () => {
|
||||
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
|
||||
writeControl('cancel');
|
||||
allowClose = true;
|
||||
window.close();
|
||||
});
|
||||
|
||||
// 窗口尺寸/置顶/居中:Eagle 的 window-state.json 会覆盖 manifest 里的位置和宽高,这里再对齐一次。
|
||||
(async () => {
|
||||
try {
|
||||
await eagle.window.setResizable(false);
|
||||
} catch (error) { /* ignore */ }
|
||||
syncButtons();
|
||||
})();
|
||||
|
||||
// 心跳:让宿主知道这个窗口还开着;窗口关掉后心跳就停了,下次需要时会重新开。
|
||||
const beat = () => {
|
||||
try { fs.writeFileSync(ALIVE_FILE, String(Date.now()), 'utf8'); } catch (error) { /* ignore */ }
|
||||
};
|
||||
beat();
|
||||
setInterval(beat, 1000);
|
||||
|
||||
let lastStamp = 0;
|
||||
setInterval(() => {
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
} catch (error) {
|
||||
return; // 状态文件还没写出来
|
||||
}
|
||||
if (state.updatedAt === lastStamp) return;
|
||||
lastStamp = state.updatedAt;
|
||||
render(state);
|
||||
}, 400);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in new issue
Block a user