缩略图插件

This commit is contained in:
Yeomai committed 2026-09-20 21:22:03 +08:00
1 parent 065b6466f1
commit 96a0db09d8
9 files changed
+984 -59

No files matched your search

+35 -7
View File
@@ -13,18 +13,25 @@
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; } border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.notice b { color: var(--text); font-weight: 500; } .notice b { color: var(--text); font-weight: 500; }
.notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; } .notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; }
.toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer; .toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
flex: 1 1 240px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; } background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); } .toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
.toolbar { display: flex; gap: 8px; } /* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
.toolbar .toggle-row { flex: 1; } .toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
.toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border); #clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; } border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
#clear:hover { border-color: var(--accent); color: var(--accent); } #clear:hover { border-color: var(--accent); color: var(--accent); }
.mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
flex: 0 1 auto;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.mode-row select { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 6px; font: inherit; max-width: 200px; }
.row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); } .row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
.row b { color: var(--text); font-weight: 500; word-break: break-all; } .row b { color: var(--text); font-weight: 500; word-break: break-all; }
#stats { color: var(--accent); } #stats { color: var(--accent); }
#log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none; #log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none; min-height: 140px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; } font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; }
#log li { white-space: pre-wrap; word-break: break-all; } #log li { white-space: pre-wrap; word-break: break-all; }
@@ -50,11 +57,29 @@
导入 Spine 资产(<code>.skel</code> / <code>.json</code> + <code>.atlas</code> + 贴图)后, 导入 Spine 资产(<code>.skel</code> / <code>.json</code> + <code>.atlas</code> + 贴图)后,
本插件会为还没设置缩略图的素材生成一张骨骼预览图。 本插件会为还没设置缩略图的素材生成一张骨骼预览图。
<b>它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</b> <b>它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</b>
生成方式可切换:<b>动态</b>取骨骼里的第一个动画压成 WebP(200×200,体积按动画时长自动定,
参数与主插件默认值一致),<b>静帧</b>始终用 setup pose。
自动生成默认关闭,需要你在下面手动启用。 自动生成默认关闭,需要你在下面手动启用。
</div> </div>
<div class="toolbar"> <div class="toolbar">
<label class="toggle-row"><input type="checkbox" id="toggle"><span>导入时自动生成缩略图</span></label> <div class="toolbar-group">
<button id="clear" type="button">清空日志</button> <label class="toggle-row"><input type="checkbox" id="toggle"><span>导入时自动生成缩略图</span></label>
<button id="clear" type="button">清空日志</button>
</div>
<div class="toolbar-group">
<label class="mode-row"><span>生成方式</span>
<select id="mode">
<option value="animated">动态(第一个动画)</option>
<option value="static">静帧(setup pose)</option>
</select>
</label>
<label class="mode-row"><span>生成策略</span>
<select id="strategy">
<option value="size">最优体积(慢)</option>
<option value="speed">最优速度(快)</option>
</select>
</label>
</div>
</div> </div>
<div class="row"><span>状态</span><b id="status">启动中…</b></div> <div class="row"><span>状态</span><b id="status">启动中…</b></div>
<div class="row"><span>素材库</span><b id="library">—</b></div> <div class="row"><span>素材库</span><b id="library">—</b></div>
@@ -92,6 +117,9 @@
<script src="js/spine-webgl-rasterizer.js"></script> <script src="js/spine-webgl-rasterizer.js"></script>
<script src="js/spine-canvas-clipping.js"></script> <script src="js/spine-canvas-clipping.js"></script>
<script src="js/spine-background.js"></script> <script src="js/spine-background.js"></script>
<script src="js/spine-visible-bounds.js"></script>
<script src="js/animated-profiles.js"></script>
<script src="js/animated-webp-encoder.js"></script>
<script src="js/spine-utils.js"></script> <script src="js/spine-utils.js"></script>
<script src="js/spine-legacy-binary.js"></script> <script src="js/spine-legacy-binary.js"></script>
<script src="js/spine-badge.js"></script> <script src="js/spine-badge.js"></script>
@@ -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;
})();
+81 -23
View File
@@ -2,14 +2,17 @@
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件 // 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。 // 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v26'; const VERSION = 'v32';
const path = require('path'); const path = require('path');
const os = require('os'); const os = require('os');
const fs = require('fs'); const fs = require('fs');
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。 // 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
const REQUIRED = ['SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings', 'SpineUtils', 'SpineBadge']; const REQUIRED = [
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
];
const missing = REQUIRED.filter((name) => !window[name]); const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) { if (missing.length) {
document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', '); document.getElementById('status').textContent = '本地模块未加载:' + missing.join(', ');
@@ -17,7 +20,7 @@ if (missing.length) {
} }
const { createLibraryWatcher } = window.SpineThumbLibraryWatcher; const { createLibraryWatcher } = window.SpineThumbLibraryWatcher;
const { scanSpineItems } = window.SpineThumbSpineAssets; const { scanSpineItems } = window.SpineThumbSpineAssets;
const { renderThumbnail, withTimeout } = window.SpineThumbRenderer; const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings; const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils; const utils = window.SpineUtils;
@@ -28,6 +31,8 @@ const logEl = document.getElementById('log');
const toggleEl = document.getElementById('toggle'); const toggleEl = document.getElementById('toggle');
const clearEl = document.getElementById('clear'); const clearEl = document.getElementById('clear');
const filtersEl = document.getElementById('filters'); 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 }; const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
let settings = { ...settingsStore.DEFAULTS }; let settings = { ...settingsStore.DEFAULTS };
@@ -70,27 +75,45 @@ function itemIdOf(dirName) {
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id // Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。 // 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
// 文件夹树只在切换素材库时重新读,避免每个素材都解析一次库 metadata。 // 文件夹树每批重读一次(见 handleBatchAsync):导入会新建文件夹、同一会话里也可能重建
// 素材库,跨批次复用旧 id 的话日志会整片变成「未分类」。
let folderPaths = null; 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) { function folderPathOf(folderIds) {
if (!folderPaths) { if (!folderPaths) 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) { /* 读不到就只显示未分类 */ }
}
const id = (Array.isArray(folderIds) && folderIds[0]) || null; const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '未分类'; 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 + ')' : '');
}
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */ /** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
async function generateOne(item) { async function generateOne(item) {
let tempPath = null; let tempPath = null;
@@ -128,7 +151,9 @@ async function generateOne(item) {
} }
const version = utils.detectVersion(item.skeletonPath); const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary); const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
const rendered = await withTimeout(renderThumbnail({ // 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
const animated = settings.mode !== 'static';
const rendered = await withTimeout(renderThumbnailAuto({
skeletonPath: item.skeletonPath, skeletonPath: item.skeletonPath,
atlasPath: item.atlasPath, atlasPath: item.atlasPath,
texturePaths: item.texturePaths, texturePaths: item.texturePaths,
@@ -136,13 +161,18 @@ async function generateOne(item) {
isBinary: item.isBinary, isBinary: item.isBinary,
version, version,
size: 400, size: 400,
}), 60000, '渲染 ' + item.dirName); mode: settings.mode,
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + '.png'); strategy: settings.strategy,
fs.writeFileSync(tempPath, Buffer.from(await rendered.blob.arrayBuffer())); 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); const applied = await withTimeout(eagleItem.setCustomThumbnail(tempPath), 20000, '设置缩略图 ' + item.dirName);
if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图'); if (applied !== true) throw new Error('Eagle 拒绝了这个缩略图');
stats.generated += 1; stats.generated += 1;
log('已生成 ' + label + ' | Spine ' + version, 'ok'); log('已生成 ' + label + ' | ' + describeThumbnail(rendered) + ' | Spine ' + version, 'ok');
} catch (error) { } catch (error) {
stats.failed += 1; stats.failed += 1;
log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail'); log('生成失败 ' + label + ':' + ((error && error.message) || error), 'fail');
@@ -182,6 +212,8 @@ async function handleBatchAsync({ added, total, reason }) {
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。 // Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。 // 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
if (!added.length) return; if (!added.length) return;
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
loadFolderPaths();
stats.batches += 1; stats.batches += 1;
stats.added += added.length; stats.added += added.length;
log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')'); log('这批结束:新增 ' + added.length + ' 个,库内共 ' + total + ' 个(' + reason + ')');
@@ -278,6 +310,8 @@ async function loadSettings() {
settings = { ...settingsStore.DEFAULTS }; settings = { ...settingsStore.DEFAULTS };
} }
toggleEl.checked = settings.enabled === true; toggleEl.checked = settings.enabled === true;
modeEl.value = settings.mode === 'static' ? 'static' : 'animated';
strategyEl.value = settings.strategy === 'speed' ? 'speed' : 'size';
} }
toggleEl.addEventListener('change', async () => { toggleEl.addEventListener('change', async () => {
@@ -302,6 +336,30 @@ clearEl.addEventListener('click', () => {
logEl.textContent = ''; 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 和按钮高亮。 // 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
filtersEl.addEventListener('click', (event) => { filtersEl.addEventListener('click', (event) => {
const button = event.target.closest('button[data-filter]'); const button = event.target.closest('button[data-filter]');
+526 -25
View File
@@ -1,4 +1,8 @@
// 在 service 插件页面里渲染一张缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出 PNG。 // 在 service 插件页面里渲染缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出图片。
// 两条产物,互不影响,由插件面板选择:
// 静帧(setup pose)—— 原来的那条路,导出 PNG,一直保留;
// 动态(animated WebP)—— 参数与主插件默认值一致:画布 200×200、分辨率优先档位表、
// 目标体积由动画时长决定(每秒 25 KB,夹在 25–100 KB),取骨骼里的第一个动画。
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。 // 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
(function () { (function () {
'use strict'; 'use strict';
@@ -7,6 +11,21 @@ const fs = require('fs');
const path = require('path'); const path = require('path');
const runtimeCache = new Map(); 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 命名空间。 * 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
@@ -124,6 +143,41 @@ function updateWorld(spine, skeleton) {
else skeleton.updateWorldTransform(); 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 的缩略图。 * 渲染 setup pose 的缩略图。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion } * options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
@@ -132,11 +186,8 @@ function updateWorld(spine, skeleton) {
async function renderThumbnail(options) { async function renderThumbnail(options) {
const utils = window.SpineUtils; const utils = window.SpineUtils;
const size = options.size || 400; const size = options.size || 400;
const spine = await loadRuntime(options.runtimeDir); const loaded = await loadSkeleton(options);
const atlasText = fs.readFileSync(options.atlasPath, 'utf8'); const { spine, data } = loaded;
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('骨骼数据为空');
const skeleton = new spine.Skeleton(data); const skeleton = new spine.Skeleton(data);
utils.applySkin(skeleton, utils.preferredSkin(data)); utils.applySkin(skeleton, utils.preferredSkin(data));
@@ -144,23 +195,7 @@ async function renderThumbnail(options) {
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose(); else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
updateWorld(spine, skeleton); updateWorld(spine, skeleton);
const bounds = (function boundsOf(sk) { const bounds = boundsOf(spine, skeleton);
try {
if (typeof sk.getBoundsRect === 'function') {
const rect = sk.getBoundsRect();
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
if (typeof sk.getBounds === 'function') {
const offset = new spine.Vector2();
const dimensions = new spine.Vector2();
sk.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;
})(skeleton);
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
canvas.width = size; canvas.width = size;
@@ -178,7 +213,7 @@ async function renderThumbnail(options) {
let renderer = null; let renderer = null;
try { try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir); renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
renderer.premultipliedAlpha = utils.parseAtlasPma(atlasText) === true; renderer.premultipliedAlpha = loaded.premultipliedAlpha;
renderer.draw(skeleton); renderer.draw(skeleton);
} finally { } finally {
if (renderer && renderer.dispose) renderer.dispose(); if (renderer && renderer.dispose) renderer.dispose();
@@ -194,6 +229,458 @@ async function renderThumbnail(options) {
return { blob, width: size, height: size, version: data.version || options.version || '' }; 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) {
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);
}
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;
for (let index = 0; index < poseCount; index += step) {
applyPose(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 (const time of sampleTimes) {
applyAnimationPose(spine, skeleton, state, animation, time);
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) => {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index]);
}, 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]);
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 缓存过,这里直接取用。 // badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
function drawBadge(ctx, size) { function drawBadge(ctx, size) {
return new Promise((resolve) => { return new Promise((resolve) => {
@@ -211,7 +698,21 @@ function drawBadge(ctx, size) {
}); });
} }
const api = { renderThumbnail, loadRuntime, withTimeout }; const api = {
renderThumbnail,
renderThumbnailAuto,
renderAnimatedThumbnail,
loadRuntime,
loadSkeleton,
firstAnimation,
animationDurationOf,
profileFrameCount,
resizeFramePixels,
scaledFramePixels,
pickAnimatedCandidates,
smallestCandidate,
withTimeout,
};
if (typeof window !== 'undefined') window.SpineThumbRenderer = api; if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api; else if (typeof module !== 'undefined' && module.exports) module.exports = api;
+3 -1
View File
@@ -6,7 +6,9 @@
const fs = require('fs'); const fs = require('fs');
const path = require('path'); const path = require('path');
const DEFAULTS = { enabled: false }; // mode: 'animated' = 有动画就出动态 WebP(没有动画回退静帧),'static' = 一律 setup pose 静帧。
// strategy: 'size' = 十档全生成取体积最小的(慢、最省体积),'speed' = 只生成粗估最小的那几档。
const DEFAULTS = { enabled: false, mode: 'animated', strategy: 'size' };
/** 存放在 Eagle 的 userData 目录,不写进素材库,卸载时可直接删掉。 */ /** 存放在 Eagle 的 userData 目录,不写进素材库,卸载时可直接删掉。 */
async function resolveFile() { async function resolveFile() {
@@ -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;
})();
+3 -3
View File
@@ -10,11 +10,11 @@
"thumbnail", "thumbnail",
"缩略图" "缩略图"
], ],
"devTools": true, "devTools": false,
"main": { "main": {
"serviceMode": true, "serviceMode": true,
"url": "index.html", "url": "index.html",
"width": 560, "width": 780,
"height": 460 "height": 620
} }
} }
@@ -0,0 +1,116 @@
'use strict';
// 动态缩略图的回归测试。渲染本身要 DOM canvas,这里锁住所有能脱离浏览器验证的部分:
// 档位配置、目标体积默认值、帧数/尺寸换算、可见范围算法、WebP 封装、自动定档策略。
const assert = require('node:assert/strict');
const profiles = require('../js/animated-profiles.js');
const encoder = require('../js/animated-webp-encoder.js');
const visibleBounds = require('../js/spine-visible-bounds.js');
const renderer = require('../js/renderer.js');
// —— 目标体积:每秒 25 KB,夹在 25–100 KB(跟主插件的默认值一致)——
assert.equal(profiles.SIZE_PER_SECOND, 25);
assert.equal(profiles.DEFAULT_OUTPUT_SIZE, 200);
assert.equal(profiles.DEFAULT_PRIORITY, 'resolution');
assert.equal(profiles.suggestSize(0.2), 25);
assert.equal(profiles.suggestSize(1), 25);
assert.equal(profiles.suggestSize(2), 50);
assert.equal(profiles.suggestSize(10), 100);
// —— 档位表:细节分辨率永远不超过输出画布边长 ——
const ladder = profiles.animatedProfiles('resolution', 200);
assert.equal(ladder.length, 10);
assert.ok(ladder.every((profile) => profile.size <= 200), '细节分辨率不能超过输出画布');
assert.ok(ladder.every((profile) => profile.fps >= 1 && profile.quality >= 0 && profile.quality <= 1));
assert.deepEqual(profiles.animatedProfiles('resolution', 400).slice(0, 5).map((p) => p.size), [400, 400, 400, 400, 400]);
// —— 搜索路径:首尾保留锚点,中间是插值 ——
const searchPath = profiles.expandAnimatedPath('resolution', 200);
assert.deepEqual(searchPath[0], { size: 200, fps: 18, quality: 1 });
assert.equal(searchPath[searchPath.length - 1].fps, 6);
assert.ok(searchPath.every((point) => point.size <= 200));
// —— 帧数换算:至少 2 帧、不超过采样帧数、按 ceil(range × fps) ——
assert.equal(renderer.profileFrameCount(100, 1, 12), 12);
assert.equal(renderer.profileFrameCount(100, 1, 200), 100, '帧率再高也不能超过采样帧数');
assert.equal(renderer.profileFrameCount(100, 0.001, 1), 2);
// —— 细节分辨率:等于画布时原样返回,低于画布时先降后升、尺寸不变 ——
const pixels = new Uint8Array(4 * 4 * 4).fill(7);
assert.equal(renderer.scaledFramePixels(pixels, 4, 4), pixels);
const scaled = renderer.scaledFramePixels(pixels, 4, 2);
assert.equal(scaled.length, 16 * 4);
assert.equal(scaled[0], 7);
// —— 可见范围:全透明不算范围,单个像素能定位 ——
assert.equal(visibleBounds.findAlphaBounds(new Uint8Array(4 * 4 * 4), 4, 4), null);
const box = new Uint8Array(4 * 4 * 4);
box[(1 * 4 + 1) * 4 + 3] = 255;
assert.deepEqual(visibleBounds.findAlphaBounds(box, 4, 4), { left: 1, top: 1, right: 2, bottom: 2 });
assert.deepEqual(visibleBounds.toWorldBounds({ left: 0, top: 0, right: 8, bottom: 8 }, 8, 8, 2, 0, 0), {
x: -2, y: -2, width: 4, height: 4,
});
// —— 取骨骼里的第一个有效动画;时长为 0 的动画要跳过 ——
assert.equal(renderer.animationDurationOf({ duration: 1.5 }), 1.5);
assert.equal(renderer.animationDurationOf({ duration: 0, timelines: [{ frames: [[0.5], [1]] }] }), 1);
assert.equal(
renderer.firstAnimation({ animations: [{ name: 'empty', duration: 0 }, { name: 'idle', duration: 1 }] }).name,
'idle',
'时长为 0 的动画不能当成动态缩略图的素材',
);
assert.equal(renderer.firstAnimation({ animations: [] }), null);
// —— 自动定档:和主插件画廊默认打勾的一致,取实际体积最小的那一档 ——
(async () => {
const candidates = [60, 10, 120, 30].map((kb) => ({ buffer: Buffer.alloc(kb * 1024), kb }));
assert.equal(renderer.smallestCandidate(candidates).kb, 10, '要挑体积最小的那一档,跟顺序无关');
assert.equal(renderer.smallestCandidate([]), null);
// —— 候选插值:给定搜索路径应返回指定数量的合法档位 ——
const size = 8;
const frames = Array.from({ length: 3 }, () => new Uint8Array(size * size * 4).fill(9));
const fakeEncode = async (_slot, framePixels, quality) => Buffer.alloc(Math.max(1, Math.round(framePixels.length * quality / 16)));
const picked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode);
assert.equal(picked.length, 10);
// 最快策略只是少采样(第 8 个参数),候选数量不变,顺序仍是从预计体积最小到最大。
const fastPicked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode, 4);
assert.equal(fastPicked.length, 10, '最快策略只是少采样,候选数量不变');
for (const profile of picked) {
// 这条搜索路径是按 200 的画布展开的,细节分辨率必须落在 [1, 200]。
assert.ok(profile.size >= 1 && profile.size <= 200, '细节分辨率要落在画布范围内');
assert.ok(profile.fps >= 1);
assert.ok(profile.quality >= 0 && profile.quality <= 1);
}
// —— WebP 封装:两帧以上才合法,结构必须是 RIFF/WEBP + ANMF ——
const still = (() => {
const chunk = (type, payload) => {
const output = Buffer.alloc(8 + payload.length + (payload.length & 1));
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
};
const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8 ', Buffer.from([1, 2, 3, 4]))]);
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 animated = encoder.encode({
width: 200,
height: 200,
frames: [{ webp: still, duration: 55 }, { webp: still, duration: 55 }],
});
assert.equal(animated.toString('ascii', 0, 4), 'RIFF');
assert.equal(animated.toString('ascii', 8, 12), 'WEBP');
assert.equal(animated.readUInt32LE(4), animated.length - 8);
assert.throws(() => encoder.encode({ width: 200, height: 200, frames: [{ webp: still, duration: 55 }] }), /at least two frames/);
console.log('PASS: 动态缩略图档位、目标体积、帧数换算、可见范围、自动定档、WebP 封装');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});