Files
SpineViewer/release/UNIVERSAL_SpineThumbnailGenerateServer/js/renderer.js
T
2026-10-28 19:19:04 +08:00

724 lines
29 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 在 service 插件页面里渲染缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出图片。
// 两条产物,互不影响,由插件面板选择:
// 静帧(setup pose)—— 原来的那条路,导出 PNG,一直保留;
// 动态(animated WebP)—— 参数与主插件默认值一致:画布 200×200、分辨率优先档位表、
// 目标体积由动画时长决定(每秒 25 KB,夹在 25–100 KB),取骨骼里的第一个动画。
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const runtimeCache = new Map();
// 长动画不截断时间轴,只降低采样密度:18 fps 采样,最多 240 帧。
const SOURCE_SAMPLE_FPS = 18;
const MAX_SOURCE_FRAMES = 240;
// 时长小于这个值的动画按静帧处理。
const STATIC_EPSILON = 1e-6;
// 逐帧编码时轮转使用的离屏画布数量:成批提交比逐帧等待快得多。
const FRAME_POOL_SIZE = 4;
// 生成策略:
// 'size' = 十档全部生成,取实际体积最小的那一档(最省体积,也就是主插件画廊默认打勾的);
// 'speed' = 只用粗估挑出体积最小的两档真正生成(最快,其余开销跟着一起砍)。
const DEFAULT_STRATEGY = 'size';
const SPEED_PROBE_SIZE = 256;
const SPEED_PROBE_POSES = 12;
const SPEED_ESTIMATE_POINTS = 8;
const SPEED_CANDIDATES = 2;
/**
* 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
*
* 不能在主页面上用 <script> 标签:插件页面存在 Node 的 module 对象,3.4–3.7 的 UMD
* 会因此走 CommonJS 分支,把导出丢进 module.exports 而不写 window.spine——结果
* window.spine 永远停在第一个加载的版本上,后续每个版本都拿到同一个对象,于是出现
* 「SkeletonBinary 只有 getter」或者「用 4.0 运行时去读 3.6 数据导致 worldX 未定义」。
* iframe 里没有 Node 注入,UMD 会走浏览器分支,各版本天然隔离。
*/
function loadRuntime(runtimeDir) {
if (runtimeCache.has(runtimeDir)) return runtimeCache.get(runtimeDir);
const job = new Promise((resolve, reject) => {
const frame = document.createElement('iframe');
frame.style.display = 'none';
frame.onload = () => {
try {
const win = frame.contentWindow;
const script = win.document.createElement('script');
script.src = new URL('lib/' + runtimeDir + '/spine-canvas.js', location.href).href;
script.onload = () => {
try {
const spine = win.spine;
if (!spine) throw new Error('运行时没有导出 spine:' + runtimeDir);
if (window.SpineLegacyBinary) window.SpineLegacyBinary.install(spine, runtimeDir);
resolve(spine);
} catch (error) {
reject(error);
}
};
script.onerror = () => reject(new Error('加载运行时失败:' + runtimeDir));
win.document.head.appendChild(script);
} catch (error) {
reject(error);
}
};
frame.onerror = () => reject(new Error('创建运行时隔离环境失败:' + runtimeDir));
document.body.appendChild(frame);
});
runtimeCache.set(runtimeDir, job);
return job;
}
/** 本地绝对路径 → file:// URL。交给 Node 的 pathToFileURL,Windows / macOS 都正确。 */
function fileUrl(filePath) {
return pathToFileURL(filePath).href;
}
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 Promise 堵死。 */
function withTimeout(promise, timeoutMs, label) {
let timer = null;
return Promise.race([
promise.finally(() => { if (timer) clearTimeout(timer); }),
new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error((label || '操作') + ' 超时(' + timeoutMs + 'ms)')), timeoutMs);
}),
]);
}
function loadImage(filePath) {
return withTimeout(new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取贴图失败:' + filePath));
image.src = fileUrl(filePath);
}), 10000, '读取贴图 ' + path.basename(filePath));
}
/** 按 atlas 里引用的页面名加载贴图,构造 TextureAtlas。 */
async function buildTextureAtlas(spine, atlasText, texturePaths) {
const images = new Map();
await Promise.all(Object.keys(texturePaths).map(async (pageName) => {
const filePath = texturePaths[pageName];
if (!filePath) return;
images.set(pageName, await loadImage(filePath));
}));
const makeTexture = (name) => {
const image = images.get(name);
if (!image) throw new Error('atlas 引用的贴图没找到:' + name);
if (spine.CanvasTexture) return new spine.CanvasTexture(image);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(image);
return new spine.Texture(image);
};
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
const atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const texture = makeTexture(page.name);
if (page.setTexture) page.setTexture(texture);
else page.texture = texture;
}
return atlas;
}
return new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buffer = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buffer));
}
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8'));
}
function updateWorld(spine, skeleton, delta = 0, resetPhysics = false) {
if (spine.Physics && spine.Physics.update !== undefined) {
if (delta > 0 && typeof skeleton.update === 'function') skeleton.update(delta);
const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update;
skeleton.updateWorldTransform(mode);
}
else skeleton.updateWorldTransform();
}
/** 载入运行时、atlas 与骨骼数据。静态和动态两条路共用。 */
async function loadSkeleton(options) {
const spine = await loadRuntime(options.runtimeDir);
const atlasText = fs.readFileSync(options.atlasPath, 'utf8');
const atlas = await buildTextureAtlas(spine, atlasText, options.texturePaths);
const data = readSkeletonData(spine, options.skeletonPath, atlas, !!options.isBinary);
if (!data || !data.bones || !data.bones.length) throw new Error('骨骼数据为空');
return {
spine,
data,
atlasText,
runtimeDir: options.runtimeDir,
premultipliedAlpha: window.SpineUtils.parseAtlasPma(atlasText) === true,
};
}
/** 骨骼当前姿态的包围盒(世界坐标)。 */
function boundsOf(spine, skeleton) {
try {
if (typeof skeleton.getBoundsRect === 'function') {
const rect = skeleton.getBoundsRect();
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
if (typeof skeleton.getBounds === 'function') {
const offset = new spine.Vector2();
const dimensions = new spine.Vector2();
skeleton.getBounds(offset, dimensions, []);
if (dimensions.x > 0 || dimensions.y > 0) {
return { x: offset.x, y: offset.y, width: dimensions.x, height: dimensions.y };
}
}
} catch (error) { /* ignore */ }
return null;
}
/**
* 渲染 setup pose 的缩略图。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
* 返回 { blob, width, height, version }
*/
async function renderThumbnail(options) {
const utils = window.SpineUtils;
const size = options.size || 400;
const loaded = await loadSkeleton(options);
const { spine, data } = loaded;
const skeleton = new spine.Skeleton(data);
utils.applySkin(skeleton, utils.preferredSkin(data));
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
updateWorld(spine, skeleton, 0, true);
const bounds = boundsOf(spine, skeleton);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
renderer.draw(skeleton);
} finally {
if (renderer && renderer.dispose) renderer.dispose();
ctx.restore();
}
}
if (options.drawBadge !== false) await drawBadge(ctx, size);
if (options.drawVersion !== false) {
window.SpineBadge.drawVersion(ctx, size, size, data.version || options.version);
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('导出缩略图失败');
return { blob, width: size, height: size, version: data.version || options.version || '' };
}
// ---------- 动态缩略图(animated WebP) ----------
// 参数与主插件的默认值一致:画布 200×200、分辨率优先档位表、目标体积 = 动画时长 × 25 KB
// (夹在 25–100 KB)。区别是没有候选画廊,所以直接采用主插件画廊里默认打勾的那一档
// (十档里实际体积最小的那个),目标体积只用来判断有没有达标并在日志里提示。
function collectKeyframeTimes(animation) {
if (!animation || !animation.timelines) return [];
const times = new Set();
for (const timeline of animation.timelines) {
const frames = timeline && timeline.frames;
if (!frames || !frames.length) continue;
try {
if (Array.isArray(frames[0])) {
for (const frame of frames) {
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
}
continue;
}
const frameCount = typeof timeline.getFrameCount === 'function' ? timeline.getFrameCount() : 0;
const entries = typeof timeline.getFrameEntries === 'function'
? timeline.getFrameEntries()
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
if (frameCount > 0) {
for (let index = 0; index < frameCount; index++) {
const value = frames[index * entries];
if (Number.isFinite(value)) times.add(value);
}
}
} catch (error) { /* 读数失败就跳过这条时间轴 */ }
}
return [...times];
}
function animationDurationOf(animation) {
const declared = animation && Number(animation.duration);
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
const times = collectKeyframeTimes(animation);
return times.length ? Math.max.apply(null, times) : 0;
}
/** 取骨骼里的第一个可用动画(没有动画或时长为 0 就返回 null,由上层回退静帧)。 */
function firstAnimation(data) {
for (const animation of (data && data.animations) || []) {
if (animation && animationDurationOf(animation) > STATIC_EPSILON) return animation;
}
return null;
}
function applyAnimationPose(spine, targetSkeleton, targetState, animation, time, previousTime) {
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
const entry = targetState.setAnimation(0, animation.name, false);
if (entry) entry.loop = false;
targetState.update(Math.max(0, time));
targetState.apply(targetSkeleton);
updateWorld(spine, targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined);
}
function mergeBounds(total, next) {
if (!next || next.width <= 0 || next.height <= 0) return total;
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
const left = Math.min(total.x, next.x);
const bottom = Math.min(total.y, next.y);
const right = Math.max(total.x + total.width, next.x + next.width);
const top = Math.max(total.y + total.height, next.y + next.height);
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
/**
* 采样若干姿态,用像素 alpha 求真实可见范围,避免取景跟着骨骼包围盒留大片空白。
* 最快策略下采样点变少、探测画布变小,取景精度略降但省掉一大截渲染 + 回读。
*/
async function measureVisibleBounds(loaded, targetSkeleton, fallbackBounds, poseCount, applyPose, options) {
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
const probeOptions = options || {};
const visibleBounds = window.SpineVisibleBounds;
const size = probeOptions.probeSize || 512;
const maxPoses = probeOptions.maxPoses || poseCount;
const step = Math.max(1, Math.ceil(poseCount / maxPoses));
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
let measured = null;
let probe = null;
try {
probe = window.SpineCanvasClipping.createRenderer(loaded.spine, context, loaded.runtimeDir);
probe.premultipliedAlpha = loaded.premultipliedAlpha;
let previousIndex = -1;
for (let index = 0; index < poseCount; index += step) {
applyPose(index, previousIndex);
previousIndex = index;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, size, size);
context.save();
context.translate(size / 2, size / 2);
context.scale(scale, -scale);
context.translate(-centerX, -centerY);
probe.draw(targetSkeleton);
context.restore();
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (probe && probe.dispose) probe.dispose();
}
return measured;
}
function profileFrameCount(sourceFrameCount, range, fps) {
return Math.max(2, Math.min(sourceFrameCount, Math.ceil(range * fps)));
}
function resizeFramePixels(data, sourceSize, targetSize) {
if (sourceSize === targetSize) return data;
const output = new Uint8Array(targetSize * targetSize * 4);
for (let y = 0; y < targetSize; y++) {
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
for (let x = 0; x < targetSize; x++) {
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
const source = (sourceY * sourceSize + sourceX) * 4;
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
}
}
return output;
}
// 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
// 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
function scaledFramePixels(data, size, detailSize) {
return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
}
let badgeImagePromise = null;
function badgeImage() {
if (!badgeImagePromise) {
badgeImagePromise = new Promise((resolve) => {
try {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = window.SpineBadge.dataUrl();
} catch (error) {
resolve(null);
}
});
}
return badgeImagePromise;
}
/** 一次把所有采样帧画到 size×size 上(含角标),后续每档只从这些像素重新编码。 */
async function renderAnimatedSourceFrames(loaded, animation, options) {
const utils = window.SpineUtils;
const spine = loaded.spine;
const size = options.canvasSize;
const range = options.range;
const frameCount = Math.max(2, Math.min(MAX_SOURCE_FRAMES, Math.ceil(range * SOURCE_SAMPLE_FPS)));
const frameDuration = range / frameCount;
const sampleTimes = Array.from({ length: frameCount }, (_, index) => index * frameDuration);
const skeleton = new spine.Skeleton(loaded.data);
utils.applySkin(skeleton, utils.preferredSkin(loaded.data));
const stateData = new spine.AnimationStateData(loaded.data);
stateData.defaultMix = 0;
const state = new spine.AnimationState(stateData);
let bounds = null;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
bounds = mergeBounds(bounds, boundsOf(spine, skeleton));
}
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error('骨骼数据没有可绘制的范围');
bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index, previousIndex) => {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined);
}, options.probe) || bounds;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const badge = await badgeImage();
const version = loaded.data.version || options.version || '';
const frames = [];
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, loaded.runtimeDir);
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-centerX, -centerY);
renderer.draw(skeleton);
ctx.restore();
if (badge) {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(badge, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
}
window.SpineBadge.drawVersion(ctx, size, size, version);
// 动态 WebP 用全帧替换,透明区域直接压成背景色,省掉 alpha 通道的体积。
const rgba = new Uint8Array(ctx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
frames.push(rgba);
if (options.onProgress) options.onProgress(index + 1, frameCount);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (renderer && renderer.dispose) renderer.dispose();
}
return { frames, size, range };
}
/** 轮转 4 张离屏画布,把 RGBA 像素编成 WebP 静帧。 */
function createFrameEncoder(size) {
const pool = Array.from({ length: FRAME_POOL_SIZE }, () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
return { canvas, context, image: context.createImageData(size, size) };
});
return (slot, pixels, quality) => new Promise((resolve, reject) => {
const unit = pool[slot % pool.length];
unit.image.data.set(pixels);
unit.context.putImageData(unit.image, 0, 0);
unit.canvas.toBlob((blob) => {
if (!blob || blob.type !== 'image/webp') {
reject(new Error('当前环境不支持 WebP 编码'));
return;
}
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
}, 'image/webp', quality);
});
}
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
async function estimateAnimatedBytes(profile, sourceFrames, size, range, encodeFrame) {
const count = profileFrameCount(sourceFrames.length, range, profile.fps);
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
let total = 0;
for (let slot = 0; slot < indexes.length; slot++) {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
total += (await encodeFrame(slot, pixels, profile.quality)).length;
}
return (total / indexes.length) * count;
}
// 沿整条搜索路径粗估体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」取候选。
// 返回顺序就是从预计体积最小到最大,最快策略只需要前面几档。
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, targetBytes, count, encodeFrame, samplePoints) {
const stride = Math.max(1, Math.ceil(searchPath.length / (samplePoints || 24)));
const samples = [];
for (let index = 0; index < searchPath.length; index += stride) {
samples.push({
index,
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, encodeFrame),
});
}
const lastIndex = searchPath.length - 1;
if (samples[samples.length - 1].index !== lastIndex) {
samples.push({
index: lastIndex,
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, encodeFrame),
});
}
// 先按体积从小到大排:配置允许分段,排序后再取点才能保证候选体积递增。
samples.sort((left, right) => left.bytes - right.bytes);
// 体积 → 参数:在「体积相邻」的两个采样点之间插值,候选因此可以落在配置表之外。
const profileForBytes = (wantedBytes) => {
let upper = samples.length - 1;
for (let index = 0; index < samples.length; index++) {
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
}
const lower = Math.max(0, upper - 1);
const before = searchPath[samples[lower].index];
const after = searchPath[samples[upper].index];
const gap = samples[upper].bytes - samples[lower].bytes;
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
const mix = (start, end) => start + (end - start) * ratio;
return {
size: Math.max(1, Math.round(mix(before.size, after.size))),
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
};
};
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
const span = Math.max(1, count - 1);
const picked = [];
for (let slot = 0; slot < count; slot++) {
picked.push(profileForBytes(Math.exp(low + (high - low) * slot / span)));
}
return picked;
}
/** 按一档参数把采样帧全部编码并封装成动态 WebP。 */
async function encodeAnimatedProfile(profile, source, encodeFrame) {
const count = profileFrameCount(source.frames.length, source.range, profile.fps);
const duration = Math.max(1, Math.round(source.range * 1000 / count));
const frames = [];
// 帧编码是异步的,一批 4 帧(正好对上画布池)一起提交,比逐帧 await 快得多。
for (let start = 0; start < count; start += FRAME_POOL_SIZE) {
const batch = [];
for (let index = start; index < Math.min(count, start + FRAME_POOL_SIZE); index++) {
const sourceIndex = Math.min(source.frames.length - 1, Math.floor(index * source.frames.length / count));
batch.push({
slot: index % FRAME_POOL_SIZE,
pixels: scaledFramePixels(source.frames[sourceIndex], source.size, profile.size),
});
}
const buffers = await Promise.all(batch.map(({ slot, pixels }) => encodeFrame(slot, pixels, profile.quality)));
for (const webp of buffers) frames.push({ webp, duration });
await new Promise((resolve) => setTimeout(resolve, 0));
}
return {
buffer: window.SpineAnimatedWebpEncoder.encode({
width: source.size,
height: source.size,
frames,
plays: 0,
}),
frames: count,
detail: profile.size,
fps: profile.fps,
quality: profile.quality,
};
}
/** 所有候选里体积最小的那一档(主插件画廊默认打勾选中的就是它)。 */
function smallestCandidate(candidates) {
return candidates.reduce(
(best, candidate) => (!best || candidate.buffer.length < best.buffer.length ? candidate : best),
null,
);
}
async function pickAnimatedThumbnail(source, options) {
const config = window.SpineAnimatedProfiles;
const fast = options.strategy === 'speed';
const encodeFrame = createFrameEncoder(source.size);
const targetBytes = options.maxKilobytes * 1024;
const searchPath = config.expandAnimatedPath(options.priority, options.canvasSize);
const candidateCount = config.animatedProfiles(options.priority, options.canvasSize).length;
const profiles = await pickAnimatedCandidates(
searchPath, source.frames, source.size, source.range, targetBytes, candidateCount, encodeFrame,
fast ? SPEED_ESTIMATE_POINTS : undefined,
);
// 用户看不到候选画廊,就完全照主插件的默认选择来,取实际体积最小的那一档。
// 最优体积:十档全生成再比;最优速度:只生成粗估最小的那几档(粗估已经按体积排过序)。
const wanted = fast ? profiles.slice(0, SPEED_CANDIDATES) : profiles;
const candidates = [];
for (const profile of wanted) {
candidates.push(await encodeAnimatedProfile(profile, source, encodeFrame));
}
const best = smallestCandidate(candidates);
return {
...best,
// 最小档仍然超预算时告诉用户,让他考虑改配置里的目标体积。
fits: best.buffer.length <= targetBytes,
};
}
/**
* 生成动态缩略图。骨骼里没有可用动画时返回 null,交给调用方回退到 setup pose 静帧。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, version,
* canvasSize, priority, maxKilobytes, onProgress }
*/
async function renderAnimatedThumbnail(options) {
const config = window.SpineAnimatedProfiles;
const loaded = await loadSkeleton(options);
const animation = firstAnimation(loaded.data);
if (!animation) return null;
const canvasSize = options.canvasSize || config.DEFAULT_OUTPUT_SIZE;
const range = animationDurationOf(animation);
const maxKilobytes = options.maxKilobytes || config.suggestSize(range);
const fast = (options.strategy || DEFAULT_STRATEGY) === 'speed';
const source = await renderAnimatedSourceFrames(loaded, animation, {
canvasSize,
range,
version: options.version,
onProgress: options.onProgress,
// 最快策略用小画布、少采样点做可见范围探测。
probe: fast ? { probeSize: SPEED_PROBE_SIZE, maxPoses: SPEED_PROBE_POSES } : null,
});
const picked = await pickAnimatedThumbnail(source, {
priority: options.priority || config.DEFAULT_PRIORITY,
maxKilobytes,
canvasSize,
strategy: options.strategy || DEFAULT_STRATEGY,
});
return {
buffer: picked.buffer,
extension: '.webp',
width: source.size,
height: source.size,
mode: 'animated',
animation: animation.name,
version: loaded.data.version || options.version || '',
frames: picked.frames,
detail: picked.detail,
fps: picked.fps,
quality: picked.quality,
duration: range,
targetKilobytes: maxKilobytes,
strategy: options.strategy || DEFAULT_STRATEGY,
fits: picked.fits,
};
}
/** 静帧产物统一成和动态一样的返回结构,调用方只认 buffer + extension。 */
async function renderStillBuffer(options) {
const still = await renderThumbnail(options);
return {
buffer: Buffer.from(await still.blob.arrayBuffer()),
extension: '.png',
width: still.width,
height: still.height,
mode: 'still',
version: still.version,
};
}
/**
* 入口:mode === 'static' 一律出 setup pose 静帧;否则有动画就出动态 WebP,
* 没有动画回退静帧(reason 里说明原因,日志会带出来)。
*/
async function renderThumbnailAuto(options) {
if (options.mode === 'static') return renderStillBuffer(options);
try {
const animated = await renderAnimatedThumbnail(options);
if (animated) return animated;
return { ...(await renderStillBuffer(options)), fallback: '骨骼里没有动画' };
} catch (error) {
const still = await renderStillBuffer(options);
return { ...still, fallback: '动态生成失败:' + ((error && error.message) || error) };
}
}
// badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
function drawBadge(ctx, size) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(image, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
resolve();
};
image.onerror = () => resolve();
image.src = window.SpineBadge.dataUrl();
});
}
const api = {
renderThumbnail,
renderThumbnailAuto,
renderAnimatedThumbnail,
loadRuntime,
loadSkeleton,
firstAnimation,
animationDurationOf,
profileFrameCount,
resizeFramePixels,
scaledFramePixels,
pickAnimatedCandidates,
smallestCandidate,
withTimeout,
};
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();