Files
SpineViewer/viewer/spine.html
T
2026-09-11 00:33:43 +08:00

1107 lines
43 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Spine 预览</title>
<style>
:root {
--bg: #1e1e1e;
--panel: #252526;
--panel-2: #2d2d30;
--text: #e6e6e6;
--text-dim: #9d9d9d;
--accent: #4fc1ff;
--border: #3c3c3c;
--error: #f14c4c;
}
[data-theme="light"] {
--bg: #f3f4f6;
--panel: #ffffff;
--panel-2: #f0f0f0;
--text: #1f1f1f;
--text-dim: #6b6b6b;
--accent: #0078d4;
--border: #ddd;
--error: #d13438;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0; height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
background: var(--bg); color: var(--text);
font-size: 13px;
}
#app { display: flex; flex-direction: column; height: 100%; }
#toolbar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
padding: 8px 12px; background: var(--panel);
border-bottom: 1px solid var(--border); z-index: 10;
}
#toolbar .spacer { flex: 1; }
#toolbar label { color: var(--text-dim); display: flex; align-items: center; gap: 4px; }
select, input[type="range"] {
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; padding: 4px 6px;
}
button {
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; padding: 5px 10px;
cursor: pointer; white-space: nowrap;
}
button:hover { background: var(--border); }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
#stage { position: relative; flex: 1; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#status {
position: absolute; bottom: 10px; left: 12px; color: var(--text-dim);
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3);
}
#timelineBar {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; background: var(--panel);
border-top: 1px solid var(--border);
}
#timelineLabels {
display: flex; flex-direction: column; justify-content: space-between;
align-self: stretch; min-width: 136px; padding: 8px 0 1px;
}
#timelineBar .tl-label { color: var(--text-dim); font-size: 12px; white-space: nowrap; }
#keyframeLegend {
display: flex; align-items: center; gap: 6px; color: var(--text-dim);
font-size: 11px; white-space: nowrap; cursor: help;
}
#keyframeLegend::before {
content: ''; width: 10px; height: 7px; border-radius: 1px;
background: var(--accent); opacity: .7;
}
#timelineBar .tl-wrap { position: relative; flex: 1; padding-top: 20px; }
#timelineBar input[type="range"] {
display: block; width: 100%; margin: 0; accent-color: var(--accent);
}
#timeRuler { position: absolute; top: 0; left: 7px; right: 7px; height: 19px; }
#timeRuler .tick {
position: absolute; bottom: 0; width: 1px; height: 5px;
background: var(--border); transform: translateX(-.5px);
}
#timeRuler .tick.major { height: 9px; background: var(--text-dim); }
#timeRuler .tick-label {
position: absolute; bottom: 8px; color: var(--text-dim); font-size: 10px;
font-variant-numeric: tabular-nums; transform: translateX(-50%); white-space: nowrap;
}
#timeRuler .tick-label.first { transform: none; }
#timeRuler .tick-label.last { transform: translateX(-100%); }
#keyframes { position: relative; height: 8px; margin-top: 2px; }
#keyframes span {
position: absolute; top: 0; width: 3px; height: 8px;
background: var(--accent); opacity: .62; transform: translateX(-1.5px);
cursor: help; transition: opacity .12s, transform .12s;
}
#keyframes span:hover { opacity: 1; transform: translateX(-1.5px) scaleY(1.35); }
#timeLabel {
color: var(--text-dim); font-size: 12px; min-width: 178px;
text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap;
}
#overlay {
position: absolute; inset: 0; display: flex; align-items: center; justify-content: center;
background: var(--bg); z-index: 5; text-align: center;
}
#overlay .box { max-width: 520px; padding: 20px; }
#overlay .title { font-size: 16px; margin-bottom: 8px; color: var(--error); }
#overlay .msg { color: var(--text-dim); white-space: pre-wrap; word-break: break-all; }
#overlay.hidden { display: none; }
#loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 4; background: var(--bg); }
#loading.hidden { display: none; }
.spinner {
width: 34px; height: 34px; border: 3px solid var(--border);
border-top-color: var(--accent); border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
#hideOthers { display: none; }
</style>
</head>
<body>
<div id="app">
<div id="toolbar">
<label>动画
<select id="animSelect" disabled></select>
</label>
<label>皮肤
<select id="skinSelect" disabled></select>
</label>
<button id="playBtn" title="播放/暂停 (空格)">⏸ 暂停</button>
<label><input type="checkbox" id="loopCheck" checked> 循环</label>
<label>速度
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
<span id="speedVal">1.0x</span>
</label>
<label title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck" disabled> 预乘
</label>
<label title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
<input type="checkbox" id="backgroundCheck" checked> 背景
</label>
<div class="spacer"></div>
<button id="thumbBtn" title="使用当前帧生成 400×400 缩略图">🖼 生成缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录">↻</button>
</div>
<div id="stage">
<canvas id="canvas"></canvas>
<div id="status"></div>
<div id="loading"><div class="spinner"></div></div>
<div id="overlay" class="hidden">
<div class="box">
<div class="title" id="errorTitle">无法预览</div>
<div class="msg" id="errorMsg"></div>
</div>
</div>
</div>
<div id="timelineBar">
<div id="timelineLabels">
<span class="tl-label" id="durationLabel">时长 --</span>
<span id="keyframeLegend" title="蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧">蓝色短线:关键帧</span>
</div>
<div class="tl-wrap">
<div id="timeRuler"></div>
<input type="range" id="timeline" min="0" max="1" step="0.001" value="0" disabled>
<div id="keyframes"></div>
</div>
<span id="timeLabel">0.00 / 0.00 秒 · 0 / 0 帧</span>
</div>
</div>
<script>
// 使用 Node 集成提供的模块
const path = require('path');
const fs = require('fs');
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
if (spineBadgeImagePromise) return spineBadgeImagePromise;
spineBadgeImagePromise = new Promise((resolve, reject) => {
try {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('Spine badge 加载失败'));
image.src = spineBadge.dataUrl();
} catch (e) {
reject(e);
}
});
return spineBadgeImagePromise;
}
async function drawSpineBadge(targetCtx, width, height) {
try {
const badge = await loadSpineBadgeImage();
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
targetCtx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
} catch (e) {
log('绘制 Spine badge 失败: ' + ((e && e.message) || String(e)));
}
}
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
// ---------- 读取 URL 参数 ----------
const urlParams = new URLSearchParams(window.location.search);
const filePath = urlParams.get('path');
const width = urlParams.get('width');
const height = urlParams.get('height');
const theme = urlParams.get('theme');
const lang = urlParams.get('lang');
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const loadingEl = document.getElementById('loading');
const statusEl = document.getElementById('status');
const timeline = document.getElementById('timeline');
const timeLabel = document.getElementById('timeLabel');
const durationLabel = document.getElementById('durationLabel');
const timeRuler = document.getElementById('timeRuler');
const keyframesEl = document.getElementById('keyframes');
keyframesEl.title = '蓝色短线表示动画关键帧;悬停短线可查看时间和帧号';
// ---------- 全局状态 ----------
let skeleton = null, state = null, stateData = null, skeletonData = null;
let renderer = null;
let activeRuntimeDir = null;
let activeVersion = null;
let currentAnim = 0, currentSkin = null;
let playing = true, loop = true, speed = 1;
let premultipliedAlpha = false;
let previewSettings = null;
let timeKeeper = null, lastTime = 0, running = false;
let seeking = false; // 用户正在拖动时间轴
let duration = 0; // 当前动画时长
let isStaticAnimation = false;
let wasPlayingBeforeSeek = false; // 记录拖动前是否在播放
const FPS = 30;
const STATIC_EPSILON = 1e-6;
let statusHoldUntil = 0;
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
let panX = 0, panY = 0, zoom = 1;
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
let dragging = false; // 是否正在拖拽
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
function showError(title, msg) {
loadingEl.classList.add('hidden');
overlay.classList.remove('hidden');
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorMsg').textContent = msg;
}
function setStatus(msg, holdMs = 0) {
statusEl.textContent = msg;
statusHoldUntil = holdMs === Infinity ? Infinity : (holdMs > 0 ? performance.now() + holdMs : 0);
}
// ---------- 动态加载本地 Spine 运行时 ----------
function loadRuntime(dir) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '../lib/' + dir + '/spine-canvas.js';
s.onload = () => {
try { require('../js/spine-legacy-binary.js').install(spine, dir); resolve(); }
catch (e) { reject(e); }
};
s.onerror = () => reject(new Error('无法加载 Spine 运行时:lib/' + dir + '/spine-canvas.js'));
document.head.appendChild(s);
});
}
// 1x1 灰色占位贴图 data URI
const PLACEHOLDER_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
// ---------- 在浏览器中构造 TextureAtlas(跨 Eagle item 文件夹加载贴图)----------
// assets.texturePaths: { atlas 引用的贴图名 -> 绝对路径 }
function buildBrowserAtlas(spine, atlasText, assets) {
return new Promise((resolve, reject) => {
(async () => {
// 1) 预先读取所有页面贴图,转成 data URI 并用 Image 加载
const pageNames = [];
try { pageNames.push(...utils.parseAtlasPages(atlasText)); } catch (e) { /* ignore */ }
const imgMap = {};
await Promise.all(pageNames.map((name) => {
return new Promise((res) => {
const p = (assets.texturePaths && assets.texturePaths[name]);
if (!p || !fs.existsSync(p)) { res(); return; }
const ext = path.extname(p).toLowerCase();
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
try {
const data = fs.readFileSync(p);
const img = new Image();
img.onload = async () => {
try { await require('../js/spine-webgl-rasterizer.js').prepareTexture(img, new Blob([data], { type: mime })); }
catch (e) { log('无损贴图解码不可用,使用浏览器贴图: ' + e.message); }
imgMap[name] = img; res();
};
img.onerror = () => res();
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
} catch (e) { res(); }
});
}));
// 2) 生成 Textures
const makeTexture = (name) => {
let img = imgMap[name];
if (!img) {
const ph = new Image(); ph.src = PLACEHOLDER_IMG;
img = ph;
}
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
return new spine.Texture(img);
};
// 3) 构建 Atlas(3.x 用构造器回调,4.x 用 setTexture)
let atlas;
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const tex = makeTexture(page.name);
if (page.setTexture) page.setTexture(tex);
else page.texture = tex;
}
} else {
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
resolve(atlas);
})().catch(reject);
});
}
// ---------- 计算骨骼包围盒(跨版本兼容)----------
function getBounds(sk) {
try {
if (typeof sk.getBoundsRect === 'function') {
const r = sk.getBoundsRect();
if (r && (r.width !== undefined)) return { x: r.x, y: r.y, width: r.width, height: r.height };
}
if (typeof sk.getBounds === 'function') {
const offset = new spine.Vector2();
const size = new spine.Vector2();
sk.getBounds(offset, size, []);
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
}
} catch (e) { /* ignore */ }
return null;
}
// ---------- 适配并居中绘制一帧 ----------
// Spine 世界坐标 Y 轴向上,Canvas 2D Y 轴向下,绘制时需翻转 Y。
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
let fitScale = 1, fitCx = 0, fitCy = 0;
function fitToCanvas() {
const bounds = getBounds(skeleton);
const cw = canvas.clientWidth, ch = canvas.clientHeight;
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
}
const pad = 0.1;
const sx = (cw * (1 - pad * 2)) / bounds.width;
const sy = (ch * (1 - pad * 2)) / bounds.height;
baseFitScale = Math.min(sx, sy);
if (!isFinite(baseFitScale) || baseFitScale <= 0) baseFitScale = 1;
baseFitCx = bounds.x + bounds.width / 2;
baseFitCy = bounds.y + bounds.height / 2;
// 应用用户缩放;骨骼中心保持为默认中心(平移仅作用于屏幕坐标)
fitScale = baseFitScale * zoom;
fitCx = baseFitCx;
fitCy = baseFitCy;
}
function draw() {
const cw = canvas.clientWidth, ch = canvas.clientHeight;
if (canvas.width !== cw * devicePixelRatio || canvas.height !== ch * devicePixelRatio) {
canvas.width = cw * devicePixelRatio;
canvas.height = ch * devicePixelRatio;
}
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, cw, ch);
if (!skeleton || !renderer) return;
ctx.save();
// 居中 + 缩放 + Y 翻转,使 Spine 坐标正确映射到 Canvas。
// panX/panY 作为屏幕坐标偏移叠加,支持上下左右拖动画布。
ctx.translate(cw / 2 + panX, ch / 2 + panY);
ctx.scale(fitScale, -fitScale);
ctx.translate(-fitCx, -fitCy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(ctx, canvas.width, canvas.height);
renderer.draw(skeleton);
ctx.restore();
}
// ---------- 更新动画状态 ----------
function updateWorld(sk) {
if (sk.updateWorldTransform) {
if (spine.Physics && spine.Physics.update !== undefined) sk.updateWorldTransform(spine.Physics.update);
else sk.updateWorldTransform();
}
}
// ---------- 生成当前帧缩略图(400×400,自适应骨骼包围盒并居中)----------
function eagleItemIdFromPath(sourcePath) {
const match = path.basename(path.dirname(sourcePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) {
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const info = await response.json();
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库');
if (info.data.ext === 'txt') throw new Error('此条目仍按 TXT 登记,请在 Eagle 重新生成缩略图并重新打开素材库');
}
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: itemId, thumbnailPath })
});
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || (result && result.status && result.status !== 'success')) {
const reason = result && (result.message || result.error);
throw new Error(reason || ('Eagle API 返回 HTTP ' + response.status));
}
return result;
}
async function generateThumbnail() {
if (!skeleton || !renderer) { setStatus('尚未加载骨骼,无法生成缩略图'); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
setStatus('正在生成并更新 Eagle 缩略图…', Infinity);
try {
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
playing = false;
updatePlayButton();
if (state) state.apply(skeleton);
updateWorld(skeleton);
const size = 400;
const bounds = getBounds(skeleton);
const off = document.createElement('canvas');
off.width = size; off.height = size;
const octx = off.getContext('2d');
octx.fillStyle = '#1e1e1e';
octx.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);
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
octx.save();
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-cx, -cy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
let offRenderer = null;
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
offRenderer.premultipliedAlpha = premultipliedAlpha;
offRenderer.draw(skeleton);
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
octx.restore();
}
}
await drawSpineBadge(octx, size, size);
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
let out = off.toDataURL('image/webp', 0.9);
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
const buf = Buffer.from(out.split(',')[1], 'base64');
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath);
const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png'));
try {
if (!itemId) throw new Error('无法从素材路径识别 Eagle 项目 ID');
fs.writeFileSync(tempPath, buf);
await setEagleCustomThumbnail(itemId, tempPath);
setStatus('Eagle 缩略图已更新 · ' + (isWebp ? 'WebP' : 'PNG') + ' ' + (buf.length / 1024).toFixed(1) + 'KB', 3500);
log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)');
} catch (e) {
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
try {
fs.writeFileSync(outPath, buf);
setStatus('缩略图已写入;自动刷新失败:' + ((e && e.message) || String(e)), 6000);
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
} catch (e2) {
setStatus('生成缩略图失败: ' + ((e2 && e2.message) || String(e2)), 6000);
}
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
} catch (e) {
setStatus('生成缩略图失败: ' + ((e && e.message) || String(e)), 6000);
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
} finally {
thumbBtn.disabled = false;
}
}
// ---------- 渲染主循环 ----------
function renderLoop(now) {
if (!running) return;
requestAnimationFrame(renderLoop);
const dt = (now - lastTime) / 1000;
lastTime = now;
if (skeleton && state) {
if (playing) {
// Spine 运行时自身处理循环;持续 update 即可。
state.update(dt * speed);
state.apply(skeleton);
}
updateWorld(skeleton);
draw();
const entry = state.getCurrent(0);
if (entry) {
const currentTime = getTrackAnimationTime(entry);
if (now >= statusHoldUntil) {
setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? '静帧' : formatSeconds(currentTime)));
}
// 同步时间轴(若用户未在拖动)。兼容 3.x 的 time 与 4.x 的 trackTime/getAnimationTime。
if (!seeking) {
const norm = isStaticAnimation ? 0 : Math.max(0, Math.min(currentTime, duration));
timeline.value = String(norm);
updateTimeReadout(norm);
}
}
}
}
// 帧号换算(默认 30fps)
function frameOf(seconds) {
return Math.round((seconds || 0) * FPS);
}
function formatSeconds(seconds) {
const value = Math.max(0, Number(seconds) || 0);
if (value < 10) return value.toFixed(2) + ' 秒';
if (value < 60) return value.toFixed(1) + ' 秒';
const minutes = Math.floor(value / 60);
return minutes + ':' + (value % 60).toFixed(1).padStart(4, '0');
}
function getTrackAnimationTime(entry) {
if (!entry) return 0;
try {
if (typeof entry.getAnimationTime === 'function') {
const value = entry.getAnimationTime();
if (Number.isFinite(value)) return Math.max(0, Math.min(value, duration));
}
} catch (e) { /* fall through to version-specific fields */ }
const raw = Number.isFinite(entry.trackTime) ? entry.trackTime : (Number.isFinite(entry.time) ? entry.time : 0);
if (loop && duration > STATIC_EPSILON) return raw % duration;
return Math.max(0, Math.min(raw, duration));
}
function animationDuration(anim) {
const declared = anim && Number(anim.duration);
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
const times = collectKeyframeTimes(anim);
return times.length ? Math.max.apply(null, times) : 0;
}
function collectKeyframeTimes(anim) {
if (!anim || !anim.timelines) return [];
const times = new Set();
for (const tl of anim.timelines) {
const frames = tl && tl.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 tl.getFrameCount === 'function' ? tl.getFrameCount() : 0;
const entries = typeof tl.getFrameEntries === 'function'
? tl.getFrameEntries()
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
if (frameCount > 0) {
for (let i = 0; i < frameCount; i++) {
const value = frames[i * entries];
if (Number.isFinite(value)) times.add(value);
}
} else {
// 极旧运行时中没有帧数接口的 timeline,其 frames 通常只保存时间。
for (const value of frames) if (Number.isFinite(value) && value >= 0) times.add(value);
}
} catch (e) { /* ignore malformed timeline */ }
}
return [...times].filter((time) => time >= 0).sort((a, b) => a - b);
}
function updateTimeReadout(time) {
if (isStaticAnimation) {
timeLabel.textContent = '静帧 · 1 / 1 帧';
return;
}
const current = Math.max(0, Math.min(Number(time) || 0, duration));
timeLabel.textContent = formatSeconds(current) + ' / ' + formatSeconds(duration) + ' · ' + frameOf(current) + ' / ' + frameOf(duration) + ' 帧';
}
function niceTickInterval(totalSeconds) {
const target = totalSeconds / 8;
const choices = [1 / 30, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
for (const choice of choices) if (choice >= target) return choice;
return Math.pow(10, Math.ceil(Math.log10(target)));
}
function renderTimeRuler() {
timeRuler.innerHTML = '';
if (isStaticAnimation || duration <= STATIC_EPSILON) {
const label = document.createElement('span');
label.className = 'tick-label first';
label.style.left = '0%';
label.textContent = '0 秒(静帧)';
timeRuler.appendChild(label);
return;
}
const major = niceTickInterval(duration);
const minor = major / 5;
const count = Math.floor(duration / minor);
for (let i = 0; i <= count; i++) {
const value = Math.min(i * minor, duration);
if (value > duration + STATIC_EPSILON) break;
const percent = (value / duration) * 100;
const isMajor = i % 5 === 0 || Math.abs(value - duration) < STATIC_EPSILON;
const tick = document.createElement('span');
tick.className = 'tick' + (isMajor ? ' major' : '');
tick.style.left = percent + '%';
timeRuler.appendChild(tick);
if (isMajor) {
const label = document.createElement('span');
label.className = 'tick-label' + (i === 0 ? ' first' : (Math.abs(value - duration) < STATIC_EPSILON ? ' last' : ''));
label.style.left = percent + '%';
label.textContent = value < 10 ? value.toFixed(1) + 's' : Math.round(value) + 's';
timeRuler.appendChild(label);
}
if (Math.abs(value - duration) < STATIC_EPSILON) break;
}
// 时长不是整刻度时,确保终点始终有明确标签。
if (Math.abs((duration / minor) - Math.round(duration / minor)) > STATIC_EPSILON) {
const tick = document.createElement('span');
tick.className = 'tick major'; tick.style.left = '100%'; timeRuler.appendChild(tick);
const label = document.createElement('span');
label.className = 'tick-label last'; label.style.left = '100%';
label.textContent = duration < 10 ? duration.toFixed(2) + 's' : duration.toFixed(1) + 's';
timeRuler.appendChild(label);
}
}
function animName(name) {
const sel = document.getElementById('animSelect');
return sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : '';
}
// ---------- 填充 UI ----------
function fillAnimations() {
const sel = document.getElementById('animSelect');
sel.innerHTML = '';
(skeletonData.animations || []).forEach((a, i) => {
const opt = document.createElement('option');
opt.value = i; opt.textContent = a.name;
sel.appendChild(opt);
});
sel.disabled = skeletonData.animations.length <= 1;
if (skeletonData.animations.length > 0) sel.value = '0';
}
function fillSkins(selectedSkin) {
const sel = document.getElementById('skinSelect');
sel.innerHTML = '';
(skeletonData.skins || []).forEach((s, i) => {
const opt = document.createElement('option');
opt.value = i; opt.textContent = s.name;
sel.appendChild(opt);
});
sel.disabled = (skeletonData.skins || []).length <= 1;
if (selectedSkin) {
const index = (skeletonData.skins || []).indexOf(selectedSkin);
if (index >= 0) sel.value = String(index);
}
}
function setAnimationByName(name) {
if (!state) return;
const anim = findAnimByName(name);
state.setAnimation(0, name, loop);
updateTimelineDuration(anim);
state.update(0);
state.apply(skeleton);
updateWorld(skeleton);
draw();
}
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
function findAnimByName(name) {
const arr = (skeletonData && skeletonData.animations) || [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] && arr[i].name === name) return arr[i];
}
return null;
}
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
function updateTimelineDuration(anim) {
duration = animationDuration(anim);
isStaticAnimation = !!anim && duration <= STATIC_EPSILON;
timeline.max = String(isStaticAnimation ? 1 : Math.max(duration, 0.001));
timeline.step = String(isStaticAnimation ? 1 : Math.min(1 / FPS, Math.max(duration / 1000, 0.001)));
timeline.value = '0';
timeline.disabled = !anim || isStaticAnimation;
durationLabel.textContent = isStaticAnimation ? '静帧 · 1 帧' : ('时长 ' + formatSeconds(duration) + ' · ' + frameOf(duration) + ' 帧');
updateTimeReadout(0);
renderTimeRuler();
renderKeyframeMarkers(anim);
updatePlayButton();
}
// 渲染关键帧标记(收集各 timeline 的关键时间点,用竖线标出)
function renderKeyframeMarkers(anim) {
keyframesEl.innerHTML = '';
if (!anim) return;
if (isStaticAnimation) {
const span = document.createElement('span');
span.style.left = '0%';
span.title = '静帧姿势 · 第 1 帧';
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
return;
}
const sorted = collectKeyframeTimes(anim).filter((time) => time <= duration);
// 限制数量,避免过多标记
const maxMarkers = 200;
const step = Math.max(1, Math.ceil(sorted.length / maxMarkers));
for (let i = 0; i < sorted.length; i += step) {
const t = sorted[i];
const span = document.createElement('span');
span.style.left = (duration > 0 ? (t / duration) * 100 : 0) + '%';
span.title = '关键帧 · ' + formatSeconds(t) + ' · 第 ' + frameOf(t) + ' 帧';
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
}
}
// 跳到指定时间点:暂停播放,重置动画到目标时间
function seekTo(time) {
if (!state || !skeleton) return;
const track = state.getCurrent(0);
const anim = track ? track.animation : null;
playing = false;
updatePlayButton();
try {
if (anim) {
state.setAnimation(0, anim.name, loop);
}
state.update(Math.max(0, time));
state.apply(skeleton);
updateWorld(skeleton);
draw();
updateTimeReadout(time);
} catch (e) { /* ignore */ }
}
function applySkinByName(name) {
if (!skeleton) return;
const skin = skeleton.data && skeleton.data.findSkin ? skeleton.data.findSkin(name)
: (skeletonData.skins || []).find((item) => item.name === name);
if (!utils.applySkin(skeleton, skin)) return;
currentSkin = skin;
if (state) state.apply(skeleton);
updateWorld(skeleton);
draw();
}
// ---------- 非 Spine 图片回退:直接显示图片 ----------
function showImageFallback(file) {
try {
const ext = path.extname(file).toLowerCase();
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
const data = fs.readFileSync(file);
const img = new Image();
img.onload = () => {
loadingEl.classList.add('hidden');
running = false;
const cw = canvas.clientWidth, ch = canvas.clientHeight;
canvas.width = cw * devicePixelRatio; canvas.height = ch * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, cw, ch);
const scale = Math.min(cw / img.width, ch / img.height);
const w = img.width * scale, h = img.height * scale;
ctx.drawImage(img, (cw - w) / 2, (ch - h) / 2, w, h);
setStatus('图片预览(未检测到配套 Spine 资源)');
};
img.onerror = () => showError('图片加载失败', file);
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
} catch (e) {
showError('图片加载失败', (e && e.message) || String(e));
}
}
// ---------- 加载资源并与运行时交互 ----------
async function loadAssets() {
if (!utils.extOf(path.basename(filePath))) {
// Registering txt/bytes is required by Eagle's extension dispatch.
// Ordinary text remains readable and never starts Spine discovery.
document.getElementById('toolbar').style.display = 'none';
document.getElementById('timelineBar').style.display = 'none';
let message = '此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。';
if (/\.txt$/i.test(filePath)) {
const fd = fs.openSync(filePath, 'r');
try {
const buffer = Buffer.alloc(256 * 1024);
const count = fs.readSync(fd, buffer, 0, buffer.length, 0);
message = buffer.subarray(0, count).toString('utf8');
if (fs.fstatSync(fd).size > count) message += '\n\n(仅显示前 256 KB)';
} finally { fs.closeSync(fd); }
}
showError(path.basename(filePath), message);
const box = overlay.querySelector('.box');
box.style.cssText = 'max-width:none;width:100%;height:100%;overflow:auto;text-align:left';
document.getElementById('errorTitle').style.color = 'var(--text)';
return;
}
// Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容,
// 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。
if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') {
showError('普通 JSON 文件', '该文件不包含 Spine 导出的 skeleton.spine 版本字段,因此不会作为 Spine 骨骼数据加载。');
return;
}
const assets = utils.discoverAssets(filePath);
if (!assets.skeletonPath || !assets.atlasPath) {
if (!assets.skeletonPath) {
showError('未找到骨骼文件', '未能定位到与所选文件配套的 Spine 骨骼文件(.skel / .skel.bytes / .json)。\n\n请将同一套 Spine 导出文件导入 Eagle。');
} else {
showError('未找到 Atlas', '未能定位到配套的 .atlas / .atlas.txt 文件。');
}
return;
}
let savedPma;
try {
const settings = require('../js/spine-preview-settings.js');
const userData = await settings.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
previewSettings = settings.createStore(userData, filePath, urlParams.get('id'));
savedPma = previewSettings.read();
} catch (e) { log('读取预乘设置失败: ' + e.message); }
// 检测版本并选择运行时
const version = utils.detectVersion(assets.skeletonPath);
activeVersion = version;
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本:3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3');
return;
}
activeRuntimeDir = runtimeDir;
document.getElementById('verLabel').textContent = 'Spine ' + (version || runtimeDir);
try {
await loadRuntime(runtimeDir);
} catch (e) {
showError('运行时加载失败', e.message);
return;
}
// 读取骨骼数据(直接用 Node fs 读取,兼容跨文件夹)
let skelData;
if (assets.isBinary) {
skelData = new Uint8Array(fs.readFileSync(assets.skeletonPath));
} else {
skelData = fs.readFileSync(assets.skeletonPath, 'utf8');
}
// 读取 atlas 文本并构造 TextureAtlas(跨 item 文件夹加载贴图)
let atlas;
try {
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
const atlasPma = utils.parseAtlasPma(atlasText);
// 用户明确切换过的设置用于下次预览;没有用户设置时,以 Atlas 为准。
premultipliedAlpha = savedPma !== undefined ? savedPma : (atlasPma !== undefined ? atlasPma : false);
document.getElementById('pmaCheck').checked = premultipliedAlpha;
atlas = await buildBrowserAtlas(spine, atlasText, assets);
} catch (e) {
showError('Atlas 加载失败', (e && e.message) || String(e));
return;
}
// 创建骨骼加载器
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
const loader = assets.isBinary ? new spine.SkeletonBinary(atlasLoader) : new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
let skeletonData2;
if (assets.isBinary) {
skeletonData2 = loader.readSkeletonData(skelData);
} else {
skeletonData2 = loader.readSkeletonData(skelData);
}
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
showError('骨骼数据为空', '未能读取到有效的骨骼数据。');
return;
}
skeletonData = skeletonData2;
// 创建骨架与动画状态
skeleton = new spine.Skeleton(skeletonData);
currentSkin = utils.preferredSkin(skeletonData);
utils.applySkin(skeleton, currentSkin);
stateData = new spine.AnimationStateData(skeletonData);
stateData.defaultMix = 0.2;
state = new spine.AnimationState(stateData);
renderer = clippingRenderer.createRenderer(spine, ctx, runtimeDir);
renderer.premultipliedAlpha = premultipliedAlpha;
document.getElementById('pmaCheck').disabled = false;
fillAnimations();
fillSkins(currentSkin);
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
if (skeletonData.animations.length > 0) {
const defaultAnim = skeletonData.animations[0];
state.setAnimation(0, defaultAnim.name, loop);
// 同步下拉框选中项
const sel = document.getElementById('animSelect');
const idx = skeletonData.animations.indexOf(defaultAnim);
if (idx >= 0) sel.value = String(idx);
updateTimelineDuration(defaultAnim);
} else {
updateTimelineDuration(null);
}
updateWorld(skeleton);
fitToCanvas();
draw();
loadingEl.classList.add('hidden');
running = true;
lastTime = performance.now();
requestAnimationFrame(renderLoop);
updatePlayButton();
}
// ---------- UI 事件绑定 ----------
function updatePlayButton() {
const btn = document.getElementById('playBtn');
btn.disabled = isStaticAnimation;
if (isStaticAnimation) {
btn.textContent = '▮ 静帧';
btn.classList.remove('active');
return;
}
btn.textContent = playing ? '⏸ 暂停' : '▶ 播放';
btn.classList.toggle('active', !playing);
}
document.getElementById('playBtn').addEventListener('click', () => {
if (isStaticAnimation) return;
playing = !playing; updatePlayButton();
});
document.getElementById('animSelect').addEventListener('change', (e) => {
const idx = parseInt(e.target.value, 10);
const anims = skeletonData.animations;
if (anims && anims[idx]) setAnimationByName(anims[idx].name);
});
document.getElementById('skinSelect').addEventListener('change', (e) => {
const idx = parseInt(e.target.value, 10);
const skins = skeletonData.skins;
if (skins && skins[idx]) applySkinByName(skins[idx].name);
});
document.getElementById('loopCheck').addEventListener('change', (e) => {
loop = e.target.checked;
const cur = state.getCurrent(0);
if (cur) cur.loop = loop;
});
document.getElementById('speedRange').addEventListener('input', (e) => {
speed = parseFloat(e.target.value);
document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x';
});
document.getElementById('backgroundCheck').addEventListener('change', () => draw());
document.getElementById('pmaCheck').addEventListener('change', (e) => {
premultipliedAlpha = e.target.checked;
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;
draw();
try {
if (!previewSettings) throw new Error('素材设置尚未就绪');
previewSettings.write(premultipliedAlpha);
setStatus((premultipliedAlpha ? '预乘已开启' : '预乘已关闭') + ' · 已记住此素材的设置', 1800);
} catch (err) {
setStatus('预乘已切换,但设置保存失败:' + err.message, 5000);
}
});
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();
});
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
// 时间轴:拖动时实时跳帧
timeline.addEventListener('input', (e) => {
const t = parseFloat(e.target.value);
if (state) seekTo(t);
});
// 时间轴:按下时暂停动画,拖动结束(松开)恢复播放(若之前是播放状态)
timeline.addEventListener('pointerdown', () => {
seeking = true;
wasPlayingBeforeSeek = playing;
playing = false;
updatePlayButton();
});
const endTimelineSeek = () => {
if (!seeking) return;
seeking = false;
if (wasPlayingBeforeSeek) {
playing = true;
updatePlayButton();
}
};
timeline.addEventListener('pointerup', endTimelineSeek);
// 即便指针移出滑块松开,也能恢复
window.addEventListener('pointerup', endTimelineSeek);
// ---------- 画布交互:左键拖拽平移,右键回中心,滚轮缩放 ----------
canvas.addEventListener('pointerdown', (e) => {
if (e.button === 0) { // 左键
dragging = true;
dragStartX = e.clientX; dragStartY = e.clientY;
dragStartPanX = panX; dragStartPanY = panY;
canvas.setPointerCapture(e.pointerId);
} else if (e.button === 2) { // 右键:回到画布中心
panX = 0; panY = 0; zoom = 1;
fitToCanvas();
draw();
setStatus('已回到画布中心');
}
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
// 直接按屏幕像素增量平移(clientX/Y 差值即像素位移,无需换算)
panX = dragStartPanX + (e.clientX - dragStartX);
panY = dragStartPanY + (e.clientY - dragStartY);
draw();
});
canvas.addEventListener('pointerup', () => { dragging = false; });
canvas.addEventListener('pointercancel', () => { dragging = false; });
// 右键菜单拦截
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// 滚轮缩放(以画布中心为锚点,更快更跟手)
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // 每格缩放 15%
zoom = Math.min(50, Math.max(0.02, zoom * factor));
fitToCanvas();
draw();
}, { passive: false });
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' && !isStaticAnimation) { e.preventDefault(); playing = !playing; updatePlayButton(); }
});
window.addEventListener('resize', () => {
if (!running) return;
fitToCanvas();
draw();
});
// ---------- 启动 ----------
if (!filePath) {
showError('缺少文件', '未传入文件路径参数。');
} else if (!fs.existsSync(filePath)) {
showError('文件不存在', filePath);
} else {
loadAssets().catch((err) => {
showError('加载失败', (err && err.message) ? err.message : String(err));
});
}
</script>
</body>
</html>