725 lines
30 KiB
JavaScript
725 lines
30 KiB
JavaScript
// 在 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 t = (key, vars) => window.SpineThumbI18n ? window.SpineThumbI18n.t(key, vars) : key;
|
||
|
||
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: t('thumb.fallbackNoAnimation') };
|
||
} catch (error) {
|
||
const still = await renderStillBuffer(options);
|
||
return { ...still, fallback: t('thumb.fallbackAnimatedFailed', { error: (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;
|
||
|
||
})();
|