干净发布副本
This commit is contained in:
1 parent
c29de7a26b
commit
acc93d5404
35 files changed
+82427
No files matched your search
@@ -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;
|
||||
|
||||
})();
|
||||
Reference in new issue
Block a user