缩略图插件

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

+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 那类原生模块。
(function () {
'use strict';
@@ -7,6 +11,21 @@ const fs = require('fs');
const path = require('path');
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 命名空间。
@@ -124,6 +143,41 @@ function updateWorld(spine, skeleton) {
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 }
@@ -132,11 +186,8 @@ function updateWorld(spine, skeleton) {
async function renderThumbnail(options) {
const utils = window.SpineUtils;
const size = options.size || 400;
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('骨骼数据为空');
const loaded = await loadSkeleton(options);
const { spine, data } = loaded;
const skeleton = new spine.Skeleton(data);
utils.applySkin(skeleton, utils.preferredSkin(data));
@@ -144,23 +195,7 @@ async function renderThumbnail(options) {
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
updateWorld(spine, skeleton);
const bounds = (function boundsOf(sk) {
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 bounds = boundsOf(spine, skeleton);
const canvas = document.createElement('canvas');
canvas.width = size;
@@ -178,7 +213,7 @@ async function renderThumbnail(options) {
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
renderer.premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
renderer.draw(skeleton);
} finally {
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 || '' };
}
// ---------- 动态缩略图(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 缓存过,这里直接取用。
function drawBadge(ctx, size) {
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;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;