Files
SpineViewer/release/UNIVERSAL_SpineViewer/viewer/spine.html
T
2026-09-19 16:05:54 +08:00

2085 lines
87 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 data-i18n="viewer.title">Spine 预览</title>
<link rel="stylesheet" href="spine.css">
</head>
<body>
<div id="app">
<div id="toolbar">
<label><span data-i18n="viewer.skin">皮肤</span>
<select id="skinSelect" disabled></select>
</label>
<button id="playBtn" data-i18n-title="viewer.playPauseTitle" title="播放/暂停 (空格)">⏸ 暂停</button>
<label><input type="checkbox" id="loopCheck" checked> <span data-i18n="viewer.loop">循环</span></label>
<label><span data-i18n="viewer.speed">速度</span>
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
<span id="speedVal">1.0x</span>
</label>
<label data-i18n-title="viewer.pmaTitle" title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck" disabled> <span data-i18n="viewer.pma">预乘</span>
</label>
<label data-i18n-title="viewer.backgroundTitle" title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label>
<div class="spacer"></div>
<label data-i18n-title="viewer.suffixTitle" title="文件名尾缀在匹配时会被忽略,使 hero-mesh.skel 与 hero.atlas 归为同一套资源">
<span data-i18n="viewer.suffix">尾缀</span>
<select id="suffixSelect"></select>
</label>
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="左键点击生成静态缩略图,右键点击生成动态缩略图">为关联资源生成缩略图</button>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="左键点击生成静态缩略图,右键点击生成动态缩略图">设为当前素材缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
</div>
<div id="stage">
<aside id="animationPanel" data-i18n-aria-label="viewer.animationList" aria-label="动画列表">
<div id="animationPanelTitle" data-i18n="viewer.animations">动画</div>
<div id="animationList" role="listbox" data-i18n-aria-label="viewer.animations" aria-label="动画"></div>
</aside>
<div id="viewport">
<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" data-i18n="viewer.errorTitle">无法预览</div>
<div class="msg" id="errorMsg"></div>
</div>
</div>
</div>
</div>
<div id="timelineBar">
<div id="timelineLabels">
<span class="tl-label" id="durationLabel" data-i18n="viewer.durationPlaceholder">时长 --</span>
<span id="keyframeLegend" data-i18n="viewer.keyframesLegend" data-i18n-title="viewer.keyframesLegendTitle" 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" data-i18n="viewer.timePlaceholder">0.00 / 0.00 秒 · 0 / 0 帧</span>
</div>
<div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle">
<div class="thumb-dialog-card">
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为关联资源生成缩略图</div>
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。</div>
<div id="thumbDialogList" class="thumb-dialog-list"></div>
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
<div class="thumb-dialog-actions">
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
</div>
</div>
</div>
<div id="suffixDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="suffixDialogTitle">
<div class="thumb-dialog-card">
<div id="suffixDialogTitle" class="thumb-dialog-title"></div>
<div id="suffixDialogNote" class="thumb-dialog-note"></div>
<input type="text" id="suffixDialogInput" class="thumb-dialog-input hidden" maxlength="50" autocomplete="off" spellcheck="false">
<div id="suffixDialogError" class="thumb-dialog-error"></div>
<div class="thumb-dialog-actions">
<button id="suffixDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="suffixDialogConfirm" class="primary"></button>
</div>
</div>
</div>
<div id="animatedThumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedThumbDialogTitle">
<div class="thumb-dialog-card animated-thumb-card">
<div id="animatedThumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.animatedThumbnailTitle">生成动态缩略图</div>
<div class="thumb-dialog-note animated-thumb-warning"><span class="animated-thumb-warning-title" data-i18n="viewer.animatedThumbnailAttention">注意:</span><span data-i18n="viewer.animatedThumbnailNote">生成的缩略图会替换原有缩略图,该操作不可撤销且</span><span class="animated-thumb-danger" data-i18n="viewer.animatedThumbnailDanger">无法恢复原有缩略图</span><span data-i18n="viewer.animatedThumbnailSizeNote">,为避免动态缩略图数据过大,文件尺寸将调整至设定大小之下</span></div>
<div class="animated-thumb-form">
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedFormat">输出格式</span>
<select id="animatedThumbFormat"><option value="webp">AWebP (.webp)</option><option value="apng">APNG (.png)</option></select>
</label>
<div class="animated-thumb-label" data-i18n="viewer.animatedRange">动画范围</div>
<div class="animated-thumb-range">
<label><span data-i18n="viewer.animatedStart">开始</span><input id="animatedThumbStart" type="number" min="0" step="1"></label>
<span>–</span>
<label><span data-i18n="viewer.animatedEnd">结束</span><input id="animatedThumbEnd" type="number" min="0" step="1"></label>
<span data-i18n="viewer.framesUnit">帧</span>
</div>
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedMaxSize">缩略图大小</span>
<span class="animated-thumb-size"><input id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"><span>KB</span></span>
</label>
</div>
<div id="animatedThumbDialogError" class="thumb-dialog-error"></div>
<div class="thumb-dialog-actions">
<button id="animatedThumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="animatedThumbDialogConfirm" class="primary" data-i18n="viewer.animatedConfirm">生成并替换</button>
</div>
</div>
</div>
</div>
<script>
// 使用 Node 集成提供的模块
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
const groupThumbnails = require('../js/spine-group-thumbnails.js');
const previewSettingsModule = require('../js/spine-preview-settings.js');
const apngEncoder = require('../js/apng-encoder.js');
const animatedWebpEncoder = require('../js/animated-webp-encoder.js');
const visibleBounds = require('../js/spine-visible-bounds.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(t('viewer.badgeFailed')));
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)));
}
}
// 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。
function log(msg) {
try {
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
const logDir = path.join(os.tmpdir(), 'SpineViewer');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, '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');
// ---------- 多国语系 ----------
// 文案与 manifest.json 的 languages / fallbackLanguage 共用同一份 _locales/<语言>.json。
// 格式扩展插件的查看器页不会注入 Eagle Plugin API,因此这里直接读取语言文件。
const I18N_LANGUAGES = ['en', 'zh_CN', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE'];
const I18N_FALLBACK = 'zh_CN';
function normalizeLocale(raw) {
if (!raw) return null;
const tag = String(raw).trim().toLowerCase().replace(/-/g, '_');
if (tag === 'zh_tw' || tag === 'zh_hk' || tag === 'zh_hant' || tag === 'zh_mo') return 'zh_TW';
for (const code of I18N_LANGUAGES) {
if (code.toLowerCase() === tag) return code;
}
const base = tag.split('_')[0];
if (base === 'zh') return 'zh_CN';
for (const code of I18N_LANGUAGES) {
if (code.toLowerCase().split('_')[0] === base) return code;
}
return null;
}
function readLocaleFile(code) {
try {
// Eagle 页面中的 __dirname 不一定等于 viewer 目录,使用实际模块路径定位。
const pluginRoot = path.dirname(path.dirname(require.resolve('../js/spine-utils.js')));
const file = path.join(pluginRoot, '_locales', code + '.json');
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return (data && typeof data === 'object') ? data : {};
} catch (e) {
return {};
}
}
// 按 "viewer.skin" 这样的点号路径取字符串,与官方 i18next 的用法一致
function lookupMessage(table, key) {
let node = table;
for (const part of String(key).split('.')) {
if (!node || typeof node !== 'object') return undefined;
node = node[part];
}
return typeof node === 'string' ? node : undefined;
}
const fallbackMessages = readLocaleFile(I18N_FALLBACK);
const locale = normalizeLocale(lang) || I18N_FALLBACK;
const messages = locale === I18N_FALLBACK ? fallbackMessages : readLocaleFile(locale);
document.documentElement.lang = locale.replace('_', '-');
log('界面语言: ' + locale + ' (lang=' + (lang || '未提供') + ')');
// t('viewer.skin', { name: '...' }):缺失的键回退到 fallbackLanguage
function t(key, vars) {
let text = lookupMessage(messages, key);
if (text === undefined) text = lookupMessage(fallbackMessages, key);
if (text === undefined) return key;
if (vars) {
text = text.replace(/\{(\w+)\}/g, (whole, name) => (Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole));
}
return text;
}
// 应用静态文案:data-i18n / data-i18n-title / data-i18n-aria-label
function applyStaticI18n(root) {
const scope = root || document;
for (const el of scope.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of scope.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of scope.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
}
applyStaticI18n();
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 animationPanel = document.getElementById('animationPanel');
const animationList = document.getElementById('animationList');
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 = t('viewer.keyframesHint');
const appEl = document.getElementById('app');
const toolbarEl = document.getElementById('toolbar');
const timelineBarEl = document.getElementById('timelineBar');
function updateFloatingInsets() {
appEl.style.setProperty('--toolbar-height', toolbarEl.offsetHeight + 'px');
appEl.style.setProperty('--timeline-height', timelineBarEl.offsetHeight + 'px');
}
if (typeof ResizeObserver === 'function') {
const floatingResizeObserver = new ResizeObserver(updateFloatingInsets);
floatingResizeObserver.observe(toolbarEl);
floatingResizeObserver.observe(timelineBarEl);
}
requestAnimationFrame(updateFloatingInsets);
// ---------- 全局状态 ----------
let skeleton = null, state = null, stateData = null, skeletonData = null;
let activeAssets = null;
let renderer = null;
let activeRuntimeDir = null;
let activeVersion = null;
const SETUP_POSE_INDEX = -1;
let currentAnim = SETUP_POSE_INDEX, currentSkin = null;
let playing = false, 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;
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 stableFitBounds = null;
let dragging = false; // 是否正在拖拽
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
function showError(title, msg) {
loadingEl.classList.add('hidden');
overlay.classList.remove('text-preview');
overlay.classList.remove('json-preview');
overlay.classList.remove('hidden');
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorMsg').textContent = msg;
}
function showTextPreview(title, text) {
running = false;
toolbarEl.style.display = 'none';
timelineBarEl.style.display = 'none';
animationPanel.style.display = 'none';
statusEl.style.display = 'none';
showError(title, text);
overlay.classList.add('text-preview');
}
// ---------- 普通 JSON:轻量字段树(点击折叠 / 展开) ----------
const JSON_TREE_MAX_NODES = 20000; // 超过此规模退回纯文本,避免构建 DOM 卡顿
const JSON_TREE_OPEN_DEPTH = 1; // 默认展开的层级,更深的折叠
function jsonNodeCount(value, budget) {
let n = 0;
const walk = (node) => {
if (!node || typeof node !== 'object' || n > budget) return;
for (const key of Object.keys(node)) { n++; walk(node[key]); if (n > budget) return; }
};
walk(value);
return n;
}
function jsonKeySpan(key) {
const span = document.createElement('span');
span.className = 'jt-key';
span.textContent = (typeof key === 'number' ? '[' + key + ']: ' : JSON.stringify(key) + ': ');
return span;
}
function buildJsonTree(key, value, depth) {
if (value === null || typeof value !== 'object') {
const leaf = document.createElement('div');
leaf.className = 'jt-leaf';
if (key !== null) leaf.appendChild(jsonKeySpan(key));
const val = document.createElement('span');
val.textContent = typeof value === 'string' ? JSON.stringify(value) : String(value);
if (typeof value === 'string') val.className = 'jt-str';
leaf.appendChild(val);
return leaf;
}
const isArray = Array.isArray(value);
const keys = isArray ? value.map((_, i) => i) : Object.keys(value);
const node = document.createElement('details');
node.className = 'jt-node';
if (depth <= JSON_TREE_OPEN_DEPTH) node.open = true;
const summary = document.createElement('summary');
if (key !== null) summary.appendChild(jsonKeySpan(key));
const open = document.createElement('span');
open.className = 'jt-brace';
open.textContent = isArray ? '[' : '{';
const count = document.createElement('span');
count.className = 'jt-count';
count.textContent = String(keys.length);
const close = document.createElement('span');
close.className = 'jt-brace';
close.textContent = isArray ? ']' : '}';
summary.append(open, count, close);
node.appendChild(summary);
const children = document.createElement('div');
children.className = 'jt-children';
for (const k of keys) children.appendChild(buildJsonTree(k, value[k], depth + 1));
node.appendChild(children);
return node;
}
function showJsonPreview(title, value) {
const box = document.getElementById('errorMsg');
showTextPreview(title, '');
if (jsonNodeCount(value, JSON_TREE_MAX_NODES) > JSON_TREE_MAX_NODES) {
// 超大对象仍以纯文本呈现,保证打开速度与可选择性
box.textContent = JSON.stringify(value, null, 2);
return;
}
overlay.classList.add('json-preview');
box.textContent = '';
box.appendChild(buildJsonTree(null, value, 0));
}
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(t('viewer.runtimeFailed', { file: '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 captureStableFitBounds(bounds = getBounds(skeleton)) {
stableFitBounds = bounds && bounds.width > 0 && bounds.height > 0
? { x: bounds.x, y: bounds.y, width: bounds.width, height: bounds.height }
: null;
return stableFitBounds !== null;
}
function fitToCanvas() {
// 自动适配始终使用加载完成时的 Setup Pose 包围盒。动画播放会改变当前帧
// 的包围盒,但不应改变用户滚轮缩放所依赖的基准比例。
const bounds = stableFitBounds;
const cw = canvas.clientWidth, ch = canvas.clientHeight;
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = zoom; 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 getEagleItemForPath(sourcePath) {
const itemId = eagleItemIdFromPath(sourcePath);
if (!itemId || !/^[A-Za-z0-9_-]+$/.test(itemId)) throw new Error(t('viewer.itemMismatch'));
const currentLibrary = utils.findLibraryRoot(filePath);
const itemLibrary = utils.findLibraryRoot(sourcePath);
if (!currentLibrary || !itemLibrary || path.resolve(currentLibrary) !== path.resolve(itemLibrary)) {
throw new Error(t('viewer.itemMismatch'));
}
if (sourcePath === filePath) {
const urlItemId = urlParams.get('id');
if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch'));
}
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId) + '&_=' + Date.now(), {
cache: 'no-store',
});
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) {
throw new Error(t('viewer.apiHttp', { status: response.status }));
}
if (result.data.name + '.' + result.data.ext !== path.basename(sourcePath)) throw new Error(t('viewer.itemMismatch'));
return { id: itemId, data: result.data, sourcePath };
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
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 !== 'success' || result.data !== true) {
const reason = result && (result.message || result.error);
throw new Error(reason || t('viewer.apiHttp', { status: response.status }));
}
return true;
}
async function waitForCustomThumbnail(sourcePath, attempts = 8, delayMs = 120) {
for (let attempt = 0; attempt < attempts; attempt++) {
const item = await getEagleItemForPath(sourcePath);
if (item.data.customThumbnail === true) return true;
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
}
async function getPreviewSearchRoots(sourcePath) {
const currentDir = path.dirname(sourcePath);
const libraryRoot = utils.findLibraryRoot(sourcePath);
if (!libraryRoot) return null;
const folders = utils.foldersOfItem(sourcePath);
const params = new URLSearchParams({
limit: '1000',
keyword: utils.normalizeStem(path.basename(sourcePath)),
});
if (folders.length) params.set('folders', folders.join(','));
try {
const response = await fetch('http://localhost:41595/api/item/list?' + params.toString() + '&_=' + Date.now(), {
cache: 'no-store',
});
const result = await response.json();
if (!response.ok || !result || result.status !== 'success' || !Array.isArray(result.data)) return null;
const imagesDir = path.join(libraryRoot, 'images');
const roots = [currentDir];
for (const item of result.data) {
if (!item || !/^[A-Za-z0-9_-]+$/.test(item.id || '')) continue;
if (!utils.extOf(String(item.name || '') + '.' + String(item.ext || ''))) continue;
const itemDir = path.join(imagesDir, item.id + '.info');
if (utils.isEagleItemDir(itemDir)) roots.push(itemDir);
}
return [...new Set(roots.map((root) => path.resolve(root)))];
} catch (error) {
log('Eagle 候选素材查询失败,回退到本地发现: ' + error.message);
return null;
}
}
function currentResourcePaths() {
if (!activeAssets) return [];
const groupStems = new Set([
activeAssets.base,
activeAssets.skeletonPath && utils.normalizeStem(path.basename(activeAssets.skeletonPath)),
activeAssets.atlasPath && utils.normalizeStem(path.basename(activeAssets.atlasPath)),
].filter(Boolean));
const relatedDataPaths = [
...(activeAssets.skeletonCandidatePaths || []),
...(activeAssets.atlasCandidatePaths || []),
].filter((file) => {
const name = path.basename(file);
return (utils.isSkeletonFile(name) || utils.isAtlasFile(name)) && groupStems.has(utils.normalizeStem(name));
});
return [...new Set([
activeAssets.skeletonPath,
activeAssets.atlasPath,
...relatedDataPaths,
...Object.values(activeAssets.texturePaths || {}),
].filter(Boolean).map((file) => path.resolve(file)))];
}
async function collectGroupThumbnailItems() {
const rows = [];
for (const sourcePath of currentResourcePaths()) {
try {
const item = await getEagleItemForPath(sourcePath);
rows.push({
...item,
name: path.basename(sourcePath),
state: item.data.customThumbnail === true ? 'custom' : 'ready',
selected: item.data.customThumbnail !== true && !utils.isTextureFile(sourcePath),
allowOverwrite: item.data.customThumbnail === true,
});
} catch (error) {
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
}
}
return rows;
}
function confirmGroupThumbnail(rows) {
const dialog = document.getElementById('thumbDialog');
const list = document.getElementById('thumbDialogList');
const confirm = document.getElementById('thumbDialogConfirm');
const cancel = document.getElementById('thumbDialogCancel');
const summary = document.getElementById('thumbDialogSummary');
const selectable = rows.filter((row) => row.state !== 'unavailable');
list.textContent = '';
for (const row of rows) {
const entry = document.createElement('div');
entry.className = 'thumb-dialog-row' + (row.state === 'unavailable' ? ' skipped' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'thumb-dialog-check';
checkbox.checked = row.state !== 'unavailable' && row.selected === true;
checkbox.disabled = row.state === 'unavailable';
row.checkbox = checkbox;
const name = document.createElement('span');
name.className = 'thumb-dialog-name';
name.textContent = row.name;
name.title = row.sourcePath;
const state = document.createElement('span');
state.className = 'thumb-dialog-state';
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.selected ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupCustomNotSelected'
: row.state === 'ready' ? 'viewer.groupNotSelected' : 'viewer.groupUnavailable');
row.stateElement = state;
entry.append(checkbox, name, state);
list.appendChild(entry);
if (row.state !== 'unavailable') {
checkbox.addEventListener('change', () => {
row.selected = checkbox.checked;
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.selected ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupCustomNotSelected' : 'viewer.groupNotSelected');
updateSelection();
});
entry.addEventListener('click', (event) => {
if (event.target !== checkbox) checkbox.click();
});
}
}
function updateSelection() {
const selected = selectable.filter((row) => row.selected);
summary.textContent = t('viewer.groupDialogSummary', {
generate: selected.length,
skip: rows.length - selected.length,
});
confirm.disabled = selected.length === 0;
}
updateSelection();
dialog.classList.remove('hidden');
return new Promise((resolve) => {
const finish = (accepted) => {
dialog.classList.add('hidden');
confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown);
resolve(accepted ? selectable.filter((row) => row.selected) : []);
};
const accept = () => finish(true);
const reject = () => finish(false);
const keydown = (event) => { if (event.key === 'Escape') reject(); };
confirm.addEventListener('click', accept);
cancel.addEventListener('click', reject);
document.addEventListener('keydown', keydown);
cancel.focus();
});
}
async function renderCurrentFrameThumbnail() {
playing = false;
updatePlayButton();
if (state) state.apply(skeleton);
updateWorld(skeleton);
const size = 400;
const geometryBounds = getBounds(skeleton);
const bounds = await measureVisibleBounds(skeleton, geometryBounds, 1, () => {}) || geometryBounds;
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);
let out = off.toDataURL('image/webp', 0.9);
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
}
function confirmAnimatedThumbnail(animation) {
const dialog = document.getElementById('animatedThumbDialog');
const startInput = document.getElementById('animatedThumbStart');
const endInput = document.getElementById('animatedThumbEnd');
const formatInput = document.getElementById('animatedThumbFormat');
const maxSizeInput = document.getElementById('animatedThumbMaxSize');
const errorEl = document.getElementById('animatedThumbDialogError');
const confirm = document.getElementById('animatedThumbDialogConfirm');
const cancel = document.getElementById('animatedThumbDialogCancel');
const animationLength = Math.max(0, animationDuration(animation));
const totalFrames = Math.max(1, frameOf(animationLength));
startInput.value = '0';
startInput.max = String(totalFrames);
endInput.value = String(totalFrames);
endInput.max = String(totalFrames);
formatInput.value = 'webp';
maxSizeInput.value = '30';
errorEl.textContent = '';
dialog.classList.remove('hidden');
return new Promise((resolve) => {
const finish = (value) => {
dialog.classList.add('hidden');
confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown);
resolve(value);
};
const accept = () => {
const startFrame = Number(startInput.value);
const endFrame = Number(endInput.value);
const maxKilobytes = Number(maxSizeInput.value);
if (!Number.isInteger(startFrame) || !Number.isInteger(endFrame) || startFrame < 0 || endFrame <= startFrame || endFrame > totalFrames) {
errorEl.textContent = t('viewer.animatedInvalidRange', { duration: totalFrames });
return;
}
if (!Number.isInteger(maxKilobytes) || maxKilobytes < 10 || maxKilobytes > 1024) {
errorEl.textContent = t('viewer.animatedInvalidSize');
return;
}
finish({
start: startFrame / FPS,
end: Math.min(endFrame / FPS, animationLength),
format: formatInput.value === 'apng' ? 'apng' : 'webp',
maxKilobytes,
});
};
const reject = () => finish(null);
const keydown = (event) => { if (event.key === 'Escape') reject(); };
confirm.addEventListener('click', accept);
cancel.addEventListener('click', reject);
document.addEventListener('keydown', keydown);
if (document.activeElement && typeof document.activeElement.blur === 'function') document.activeElement.blur();
});
}
function applyAnimationPose(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(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 };
}
async function measureVisibleBounds(targetSkeleton, fallbackBounds, poseCount, applyPose) {
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
const size = 512;
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 probeRenderer = null;
try {
probeRenderer = clippingRenderer.createRenderer(spine, context, activeRuntimeDir);
probeRenderer.premultipliedAlpha = premultipliedAlpha;
for (let index = 0; index < poseCount; index++) {
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);
probeRenderer.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 (probeRenderer && probeRenderer.dispose) probeRenderer.dispose();
}
return measured;
}
function quantizeFrame(data, bits) {
if (bits >= 8) return new Uint8Array(data);
const levels = (1 << bits) - 1;
const lookup = new Uint8Array(256);
for (let value = 0; value < 256; value++) lookup[value] = Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let index = 0; index < data.length; index++) output[index] = lookup[data[index]];
return output;
}
function compressFramePixels(data, size, bits, blockSize) {
if (blockSize <= 1) return quantizeFrame(data, bits);
const levels = (1 << bits) - 1;
const quantize = (value) => Math.round(Math.round(value * levels / 255) * 255 / levels);
const output = new Uint8Array(data.length);
for (let top = 0; top < size; top += blockSize) {
for (let left = 0; left < size; left += blockSize) {
const source = (top * size + left) * 4;
const color = [quantize(data[source]), quantize(data[source + 1]), quantize(data[source + 2]), quantize(data[source + 3])];
for (let y = top; y < Math.min(size, top + blockSize); y++) {
for (let x = left; x < Math.min(size, left + blockSize); x++) output.set(color, (y * size + x) * 4);
}
}
}
return output;
}
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;
}
async function encodeAnimatedThumbnailToLimit(sourceFrames, size, range, maxKilobytes) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, bits: 8, block: 1 },
{ ratio: 0.75, bits: 7, block: 1 },
{ ratio: 0.5, bits: 7, block: 1 },
{ ratio: 0.5, bits: 6, block: 2 },
{ ratio: 0.33, bits: 6, block: 2 },
{ ratio: 0.25, bits: 5, block: 3 },
{ ratio: 0.16, bits: 5, block: 4 },
{ count: 3, bits: 4, block: 8 },
{ count: 2, bits: 3, block: 12 },
{ count: 3, bits: 5, block: 1, size: 320 },
{ count: 3, bits: 5, block: 1, size: 256 },
{ count: 2, bits: 4, block: 1, size: 192 },
{ count: 2, bits: 4, block: 1, size: 128 },
{ count: 2, bits: 3, block: 1, size: 96 },
{ count: 2, bits: 3, block: 1, size: 64 },
{ count: 2, bits: 2, block: 1, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const delayMs = Math.max(1, Math.round(range * 1000 / count));
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
const resized = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
return {
rgba: compressFramePixels(resized, outputSize, profile.bits, profile.block),
delayNumerator: delayMs,
delayDenominator: 1000,
};
};
// 先用三帧估算该档位体积,明显超标时不再为整段动画分配大量内存。
if (count > 3) {
const probeFrames = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
const probe = apngEncoder.encode({ width: outputSize, height: outputSize, frames: probeFrames, plays: 0, compressionLevel: 9 });
if (probe.length * count / probeFrames.length > targetBytes * 1.5) continue;
}
const frames = Array.from({ length: count }, (_, index) => makeFrame(index));
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
await new Promise((resolve) => setTimeout(resolve, 0));
const buffer = apngEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0, compressionLevel: 9 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function encodeAnimatedWebpToLimit(sourceFrames, size, range, maxKilobytes) {
const targetBytes = maxKilobytes * 1024;
const profiles = [
{ ratio: 1, quality: 0.82 },
{ ratio: 0.75, quality: 0.72 },
{ ratio: 0.5, quality: 0.62 },
{ ratio: 0.33, quality: 0.52 },
{ ratio: 0.25, quality: 0.42 },
{ count: 4, quality: 0.35 },
{ count: 3, quality: 0.28 },
{ count: 2, quality: 0.2 },
{ count: 3, quality: 0.3, size: 320 },
{ count: 3, quality: 0.25, size: 256 },
{ count: 2, quality: 0.2, size: 192 },
{ count: 2, quality: 0.15, size: 128 },
{ count: 2, quality: 0.1, size: 96 },
{ count: 2, quality: 0.08, size: 64 },
{ count: 2, quality: 0.05, size: 32 },
];
let smallest = null;
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
const profile = profiles[profileIndex];
const outputSize = profile.size || size;
const count = Math.max(2, Math.min(sourceFrames.length, profile.count || Math.round(sourceFrames.length * profile.ratio)));
const duration = Math.max(1, Math.round(range * 1000 / count));
const canvas = document.createElement('canvas');
canvas.width = outputSize; canvas.height = outputSize;
const context = canvas.getContext('2d');
const cache = new Map();
const makeFrame = (index) => {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
if (cache.has(sourceIndex)) return cache.get(sourceIndex);
const pixels = resizeFramePixels(sourceFrames[sourceIndex], size, outputSize);
const image = context.createImageData(outputSize, outputSize);
image.data.set(pixels);
context.putImageData(image, 0, 0);
const dataUrl = canvas.toDataURL('image/webp', profile.quality);
if (!dataUrl.startsWith('data:image/webp')) throw new Error(t('viewer.animatedWebpUnavailable'));
const webp = Buffer.from(dataUrl.slice(dataUrl.indexOf(',') + 1), 'base64');
cache.set(sourceIndex, webp);
return webp;
};
if (count > 3) {
const probe = [makeFrame(0), makeFrame(Math.floor(count / 2)), makeFrame(count - 1)];
if (probe.reduce((total, frame) => total + frame.length, 0) * count / probe.length > targetBytes * 1.5) continue;
}
const frames = [];
for (let index = 0; index < count; index++) {
frames.push({ webp: makeFrame(index), duration });
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
const buffer = animatedWebpEncoder.encode({ width: outputSize, height: outputSize, frames, plays: 0 });
if (!smallest || buffer.length < smallest.buffer.length) smallest = { buffer, frames: count, size: outputSize };
if (buffer.length <= targetBytes) return { buffer, frames: count, size: outputSize };
}
return smallest;
}
async function renderAnimatedThumbnail(animation, options) {
const size = 400;
const range = options.end - options.start;
// 长动画保持完整时间范围,但自动降低采样帧率,避免内存和输出体积失控。
const frameCount = Math.max(2, Math.min(240, Math.ceil(range * 18)));
const frameDuration = range / frameCount;
const sampleTimes = Array.from({ length: frameCount }, (_, index) => options.start + index * frameDuration);
const previewSkeleton = new spine.Skeleton(skeletonData);
utils.applySkin(previewSkeleton, currentSkin || utils.preferredSkin(skeletonData));
const previewStateData = new spine.AnimationStateData(skeletonData);
previewStateData.defaultMix = 0;
const previewState = new spine.AnimationState(previewStateData);
let bounds = null;
for (const time of sampleTimes) {
applyAnimationPose(previewSkeleton, previewState, animation, time);
bounds = mergeBounds(bounds, getBounds(previewSkeleton));
}
bounds = bounds || stableFitBounds;
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error(t('viewer.emptySkeletonMsg'));
bounds = await measureVisibleBounds(previewSkeleton, bounds, sampleTimes.length, (index) => {
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
}) || bounds;
const off = document.createElement('canvas');
off.width = size; off.height = size;
const octx = off.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;
let offRenderer = null;
const sourceFrames = [];
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
offRenderer.premultipliedAlpha = premultipliedAlpha;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(previewSkeleton, previewState, animation, sampleTimes[index]);
octx.setTransform(1, 0, 0, 1, 0, 0);
octx.clearRect(0, 0, size, size);
octx.fillStyle = '#1e1e1e';
octx.fillRect(0, 0, size, size);
octx.save();
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-centerX, -centerY);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
offRenderer.draw(previewSkeleton);
octx.restore();
await drawSpineBadge(octx, size, size);
spineBadge.drawVersion(octx, size, size, skeletonData.version || activeVersion);
const rgba = new Uint8Array(octx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
sourceFrames.push(rgba);
setStatus(t('viewer.animatedGenerating', { current: index + 1, total: frameCount }), Infinity);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
}
const encoded = options.format === 'apng'
? await encodeAnimatedThumbnailToLimit(sourceFrames, size, range, options.maxKilobytes)
: await encodeAnimatedWebpToLimit(sourceFrames, size, range, options.maxKilobytes);
return { buffer: encoded.buffer, extension: options.format === 'apng' ? '.png' : '.webp', frames: encoded.frames, size: encoded.size };
}
async function generateAnimatedThumbnail() {
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
if (isStaticAnimation || currentAnim === SETUP_POSE_INDEX) {
await generateThumbnail();
return;
}
const animation = (skeletonData.animations || [])[currentAnim];
if (!animation || animationDuration(animation) <= STATIC_EPSILON) {
await generateThumbnail();
return;
}
const options = await confirmAnimatedThumbnail(animation);
if (!options) return;
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
let tempPath = null;
try {
const currentItem = await getEagleItemForPath(filePath);
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
const rendered = await renderAnimatedThumbnail(animation, options);
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.animatedThumbUpdated', {
frames: rendered.frames,
size: Math.ceil(rendered.buffer.length / 1024),
}), 5000);
log('用户主动更新当前素材动画缩略图: item=' + currentItem.id);
} catch (error) {
setStatus(t('viewer.thumbnailFailed', { error: (error && error.message) || String(error) }), 7000);
log('生成当前素材动画缩略图失败: ' + ((error && error.stack) || error));
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
thumbBtn.disabled = false;
}
}
async function generateGroupThumbnails(animated = false) {
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn');
groupThumbBtn.disabled = true;
try {
const rows = await collectGroupThumbnailItems();
const selectedTargets = await confirmGroupThumbnail(rows);
// Updating the item that owns this preview causes Eagle to close/reload the
// viewer. Always do that item last so its teardown cannot abort the batch.
const targets = groupThumbnails.orderCurrentTargetLast(selectedTargets, filePath);
if (targets.length === 0) {
if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000);
return;
}
let rendered;
const animation = (skeletonData.animations || [])[currentAnim];
if (animated && !isStaticAnimation && currentAnim !== SETUP_POSE_INDEX && animation && animationDuration(animation) > STATIC_EPSILON) {
const options = await confirmAnimatedThumbnail(animation);
if (!options) return;
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
rendered = await renderAnimatedThumbnail(animation, options);
} else {
setStatus(t('viewer.generating'), Infinity);
rendered = await renderCurrentFrameThumbnail();
}
const result = await groupThumbnails.applyThumbnailTargets({
targets,
initialSkipped: rows.length - targets.length,
rendered,
getItem: getEagleItemForPath,
setThumbnail: setEagleCustomThumbnail,
waitForApplied: waitForCustomThumbnail,
notConfirmedMessage: t('viewer.groupApplyNotConfirmed'),
onProgress: (current, total) => setStatus(t('viewer.groupGenerating', { current, total }), Infinity),
onSuccess: (fresh) => log('用户确认批量更新关联资源缩略图: item=' + fresh.id),
onError: (error) => log('关联资源缩略图失败: ' + ((error && error.stack) || error)),
});
setStatus(t('viewer.groupComplete', { success: result.success, skip: result.skipped, failed: result.failed }), 6000);
} catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e));
} finally {
groupThumbBtn.disabled = false;
}
}
async function generateThumbnail() {
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
let tempPath = null;
try {
const currentItem = await getEagleItemForPath(filePath);
if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return;
setStatus(t('viewer.generating'), Infinity);
const rendered = await renderCurrentFrameThumbnail();
tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
fs.writeFileSync(tempPath, rendered.buffer);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.thumbUpdated', {
format: rendered.extension === '.webp' ? 'WebP' : 'PNG',
size: (rendered.buffer.length / 1024).toFixed(1),
}), 3500);
log('用户主动更新当前素材缩略图: item=' + currentItem.id);
} catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前素材缩略图失败: ' + ((e && e.stack) || e));
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
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 = currentTrack();
if (entry) {
const currentTime = getTrackAnimationTime(entry);
if (now >= statusHoldUntil) {
setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? t('viewer.staticLabel') : 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 snapTimeToFrame(seconds) {
const lastWholeFrame = Math.max(0, Math.floor((duration + 1e-8) * FPS));
const frame = Math.max(0, Math.min(Math.round((Number(seconds) || 0) * FPS), lastWholeFrame));
return frame / FPS;
}
function formatSeconds(seconds) {
const value = Math.max(0, Number(seconds) || 0);
if (value < 10) return t('viewer.seconds', { value: value.toFixed(2) });
if (value < 60) return t('viewer.seconds', { value: 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 = t('viewer.readoutStatic');
return;
}
const current = Math.max(0, Math.min(Number(time) || 0, duration));
timeLabel.textContent = t('viewer.readout', {
time: formatSeconds(current),
total: formatSeconds(duration),
frame: frameOf(current),
totalFrames: 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 = t('viewer.rulerStatic');
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) {
return name || '';
}
// ---------- 填充 UI ----------
function fillAnimations() {
animationList.innerHTML = '';
const animations = skeletonData.animations || [];
animationPanel.style.display = 'flex';
const setupItem = document.createElement('button');
setupItem.type = 'button';
setupItem.className = 'animation-item active';
setupItem.dataset.index = String(SETUP_POSE_INDEX);
setupItem.textContent = t('viewer.setupPose');
setupItem.title = t('viewer.setupPose');
setupItem.setAttribute('role', 'option');
setupItem.setAttribute('aria-selected', 'true');
animationList.appendChild(setupItem);
animations.forEach((a, i) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'animation-item';
item.dataset.index = String(i);
item.textContent = a.name;
item.title = a.name;
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', 'false');
animationList.appendChild(item);
});
}
function selectAnimationItem(index) {
currentAnim = index;
for (const item of animationList.querySelectorAll('.animation-item')) {
const active = Number(item.dataset.index) === index;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
}
}
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);
}
}
async function setAnimationByName(name, index) {
if (!state) return;
const anim = findAnimByName(name);
if (!anim) return;
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
state.setAnimation(0, name, loop);
playing = true;
updateTimelineDuration(anim);
state.update(0);
state.apply(skeleton);
updateWorld(skeleton);
if (!stableFitBounds) {
const geometryBounds = getBounds(skeleton);
const bounds = await measureVisibleBounds(skeleton, geometryBounds, 1, () => {}) || geometryBounds;
if (currentAnim === resolvedIndex && captureStableFitBounds(bounds)) fitToCanvas();
}
draw();
}
function showSetupPose() {
if (!state || !skeleton) return;
selectAnimationItem(SETUP_POSE_INDEX);
playing = false;
if (typeof state.clearTracks === 'function') state.clearTracks();
else if (typeof state.clearTrack === 'function') state.clearTrack(0);
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
else {
if (typeof skeleton.setBonesToSetupPose === 'function') skeleton.setBonesToSetupPose();
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
}
updateWorld(skeleton);
updateTimelineDuration(null, true);
setStatus(t('viewer.setupPose'));
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, forceStatic = false) {
duration = animationDuration(anim);
isStaticAnimation = forceStatic || (!!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 ? t('viewer.timelineStatic') : t('viewer.timelineDuration', { time: formatSeconds(duration), frames: 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 = t('viewer.staticPoseTitle');
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 keyframeTime = sorted[i];
const span = document.createElement('span');
span.style.left = (duration > 0 ? (keyframeTime / duration) * 100 : 0) + '%';
span.title = t('viewer.keyframeTitle', { time: formatSeconds(keyframeTime), frame: frameOf(keyframeTime) });
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
}
}
// Spine 4.3 起 AnimationState.getCurrent() 被改名为 getTrack(),这里统一做版本兼容。
function currentTrack() {
if (!state) return null;
try {
if (typeof state.getCurrent === 'function') return state.getCurrent(0) || null;
if (typeof state.getTrack === 'function') return state.getTrack(0) || null;
const tracks = state.tracks;
return (tracks && tracks[0]) || null;
} catch (e) {
return null;
}
}
// 跳到指定时间点:暂停播放,重置动画到目标时间
function seekTo(time) {
if (!state || !skeleton) return;
const track = currentTrack();
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(t('viewer.imagePreview'));
};
img.onerror = () => showError(t('viewer.imageLoadFailed'), file);
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
} catch (e) {
showError(t('viewer.imageLoadFailed'), (e && e.message) || String(e));
}
}
// ---------- 加载资源并与运行时交互 ----------
async function loadAssets() {
activeAssets = null;
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.
let message = t('viewer.notSpineText');
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 += t('viewer.truncated256');
} finally { fs.closeSync(fd); }
}
showTextPreview(path.basename(filePath), message);
return;
}
// Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容,
// 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。
if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') {
const maxPreviewBytes = 1024 * 1024;
const stat = fs.statSync(filePath);
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.alloc(Math.min(stat.size, maxPreviewBytes));
let count = 0;
try { count = fs.readSync(fd, buffer, 0, buffer.length, 0); } finally { fs.closeSync(fd); }
let text = buffer.subarray(0, count).toString('utf8');
const name = path.basename(filePath);
let parsed;
if (stat.size <= maxPreviewBytes) {
try { parsed = JSON.parse(text); } catch (e) { /* 保留 JSONC/原始文本 */ }
} else text += t('viewer.truncated1m');
if (parsed !== null && typeof parsed === 'object') showJsonPreview(name, parsed);
else showTextPreview(name, parsed === undefined ? text : JSON.stringify(parsed, null, 2));
return;
}
const previewSearchRoots = await getPreviewSearchRoots(filePath);
const assets = utils.discoverAssets(filePath, previewSearchRoots ? { searchRoots: previewSearchRoots } : undefined);
if (!assets.skeletonPath || !assets.atlasPath) {
if (!assets.skeletonPath) {
showError(t('viewer.noSkeletonFile'), t('viewer.noSkeletonFileMsg'));
} else {
showError(t('viewer.noAtlas'), t('viewer.noAtlasMsg'));
}
return;
}
activeAssets = assets;
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(t('viewer.unsupportedVersion'), t('viewer.unsupportedVersionMsg', {
version: version || t('viewer.unknownVersion'),
list: '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(t('viewer.runtimeLoadFailed'), 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(t('viewer.atlasLoadFailed'), (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(t('viewer.emptySkeleton'), t('viewer.emptySkeletonMsg'));
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);
// 默认显示 Setup Pose 静帧,由用户从列表中主动选择动画。
showSetupPose();
captureStableFitBounds();
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 = t('viewer.static');
btn.classList.remove('active');
return;
}
btn.textContent = playing ? t('viewer.pause') : t('viewer.play');
btn.classList.toggle('active', !playing);
}
document.getElementById('playBtn').addEventListener('click', () => {
if (isStaticAnimation) return;
playing = !playing; updatePlayButton();
});
animationList.addEventListener('click', (e) => {
const item = e.target.closest('.animation-item');
if (!item) return;
const idx = parseInt(item.dataset.index, 10);
if (idx === SETUP_POSE_INDEX) {
showSetupPose();
return;
}
const anims = skeletonData.animations;
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
});
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 = currentTrack();
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(t('viewer.settingsNotReady'));
previewSettings.write(premultipliedAlpha);
setStatus(t('viewer.pmaRemembered', { state: premultipliedAlpha ? t('viewer.pmaOn') : t('viewer.pmaOff') }), 1800);
} catch (err) {
setStatus(t('viewer.pmaSaveFailed', { error: err.message }), 5000);
}
});
// ---------- 文件名尾缀设置(全局偏好,实时落盘) ----------
const SUFFIX_NONE_VALUE = '__none__';
const SUFFIX_ADD_VALUE = '__add__';
const SUFFIX_CUSTOM_PREFIX = 'custom:';
const suffixSelectEl = document.getElementById('suffixSelect');
let suffixSettings = null;
let customSuffixes = [];
// 下拉列表只是入口与状态展示(内置尾缀以禁用条目列出),
// 选择「+ 添加尾缀…」=新增,选择自定义项 =移除,两者都会先弹确认框。
// 静止项必须是独立的值:选中“已经是当前值的项”不会触发 change,
// 若把静止值设成新增项本身,用户点它就会毫无反应。
function renderSuffixSelect() {
suffixSelectEl.textContent = '';
const resting = document.createElement('option');
resting.value = SUFFIX_NONE_VALUE;
resting.textContent = t('viewer.suffixManage');
suffixSelectEl.appendChild(resting);
const add = document.createElement('option');
add.value = SUFFIX_ADD_VALUE;
add.textContent = t('viewer.suffixAdd');
suffixSelectEl.appendChild(add);
const appendGroup = (labelKey, names, removable) => {
if (names.length === 0) return;
const group = document.createElement('option');
group.disabled = true;
group.textContent = '── ' + t(labelKey) + ' ──';
suffixSelectEl.appendChild(group);
for (const name of names) {
const option = document.createElement('option');
option.textContent = removable ? '✕ ' + name : name;
if (removable) option.value = SUFFIX_CUSTOM_PREFIX + name;
else option.disabled = true;
suffixSelectEl.appendChild(option);
}
};
appendGroup('viewer.suffixDefaults', utils.DEFAULT_SUFFIXES, false);
appendGroup('viewer.suffixCustom', customSuffixes, true);
suffixSelectEl.value = SUFFIX_NONE_VALUE;
}
// 与缩略图对话框共用遮罩/卡片样式;withInput 为 false 时是纯确认框。
function openSuffixDialog(options) {
const dialog = document.getElementById('suffixDialog');
const inputEl = document.getElementById('suffixDialogInput');
const errorEl = document.getElementById('suffixDialogError');
const confirmBtn = document.getElementById('suffixDialogConfirm');
const cancelBtn = document.getElementById('suffixDialogCancel');
document.getElementById('suffixDialogTitle').textContent = t(options.titleKey);
document.getElementById('suffixDialogNote').textContent = t(options.noteKey, options.noteVars);
confirmBtn.textContent = t(options.confirmKey);
inputEl.classList.toggle('hidden', !options.withInput);
inputEl.maxLength = utils.SUFFIX_MAX_LENGTH;
inputEl.value = options.withInput ? (options.initialValue || '') : '';
inputEl.placeholder = options.withInput ? t('viewer.suffixPlaceholder') : '';
errorEl.textContent = '';
dialog.classList.remove('hidden');
if (options.withInput) { inputEl.focus(); inputEl.select(); }
return new Promise((resolve) => {
let done = false;
const finish = (accepted) => {
if (done) return;
done = true;
dialog.classList.add('hidden');
confirmBtn.removeEventListener('click', accept);
cancelBtn.removeEventListener('click', reject);
inputEl.removeEventListener('input', clearError);
inputEl.removeEventListener('keydown', onKeydown);
document.removeEventListener('keydown', onKeydown);
resolve(accepted ? (options.withInput ? inputEl.value : true) : null);
};
const accept = () => {
if (options.validate) {
const message = options.validate(inputEl.value);
if (message) { errorEl.textContent = message; return; }
}
finish(true);
};
const reject = () => finish(false);
const clearError = () => { errorEl.textContent = ''; };
const onKeydown = (event) => {
if (event.key === 'Escape') { event.preventDefault(); reject(); }
else if (event.key === 'Enter' && options.withInput) { event.preventDefault(); accept(); }
};
confirmBtn.addEventListener('click', accept);
cancelBtn.addEventListener('click', reject);
inputEl.addEventListener('input', clearError);
inputEl.addEventListener('keydown', onKeydown);
document.addEventListener('keydown', onKeydown);
});
}
function applySuffixes(list) {
if (!suffixSettings) throw new Error(t('viewer.settingsNotReady'));
suffixSettings.write(list);
customSuffixes = utils.setSuffixes(list).filter((name) => utils.DEFAULT_SUFFIXES.indexOf(name) === -1);
}
async function addCustomSuffix() {
const input = await openSuffixDialog({
titleKey: 'viewer.suffixAddTitle',
noteKey: 'viewer.suffixAddNote',
confirmKey: 'viewer.suffixConfirm',
withInput: true,
validate: (value) => {
const name = utils.normalizeSuffixName(value);
if (!name) return t('viewer.suffixInvalid');
if (utils.DEFAULT_SUFFIXES.indexOf(name) !== -1 || customSuffixes.indexOf(name) !== -1) return t('viewer.suffixDuplicate');
return null;
},
});
if (input === null) return;
try {
applySuffixes(customSuffixes.concat([utils.normalizeSuffixName(input)]));
// 尾缀会改变资源配对结果,直接重载查看器最稳。
window.location.reload();
} catch (error) {
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
}
}
async function removeCustomSuffix(name) {
const accepted = await openSuffixDialog({
titleKey: 'viewer.suffixRemoveTitle',
noteKey: 'viewer.suffixRemoveNote',
noteVars: { name },
confirmKey: 'viewer.suffixRemove',
});
if (accepted === null) return;
try {
applySuffixes(customSuffixes.filter((item) => item !== name));
window.location.reload();
} catch (error) {
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
}
}
// 事件回调里没有 await,任何异常都必须在这里兜住,否则会静默失败。
function runSuffixAction(promise) {
Promise.resolve(promise).catch((error) => {
setStatus(t('viewer.suffixSaveFailed', { error: (error && error.message) || String(error) }), 6000);
});
}
suffixSelectEl.addEventListener('change', (event) => {
const value = event.target.value;
// 先复位到静止项,保证下一次选择一定会产生 change。
renderSuffixSelect();
if (value === SUFFIX_ADD_VALUE) runSuffixAction(addCustomSuffix());
else if (value.indexOf(SUFFIX_CUSTOM_PREFIX) === 0) runSuffixAction(removeCustomSuffix(value.slice(SUFFIX_CUSTOM_PREFIX.length)));
});
async function initSuffixSettings() {
try {
const userDataPath = await previewSettingsModule.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
suffixSettings = previewSettingsModule.createSuffixStore(userDataPath);
} catch (error) {
suffixSettings = null;
log('尾缀设置存储不可用: ' + ((error && error.message) || String(error)));
}
try {
if (suffixSettings) {
// 磁盘内容不可信:utils 会丢弃非法、重复和内置重名的尾缀。
customSuffixes = utils.setSuffixes(suffixSettings.read()).filter((name) => utils.DEFAULT_SUFFIXES.indexOf(name) === -1);
} else {
utils.setSuffixes([]);
customSuffixes = [];
}
} catch (error) {
// 配置损坏时回退到内置尾缀,但保留存储对象,下次写入会覆盖它。
customSuffixes = utils.getCustomSuffixes();
log('尾缀配置损坏,已回退到内置尾缀: ' + ((error && error.message) || String(error)));
}
renderSuffixSelect();
}
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();
});
document.getElementById('groupThumbBtn').addEventListener('click', () => generateGroupThumbnails());
document.getElementById('groupThumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault();
generateGroupThumbnails(true);
});
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
document.getElementById('thumbBtn').addEventListener('contextmenu', (event) => {
event.preventDefault();
generateAnimatedThumbnail();
});
function seekTimelineToNearestFrame() {
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
timeline.value = String(snappedTime);
seekTo(snappedTime);
}
// 时间轴:拖动时按 30 FPS 整数帧实时预览。
timeline.addEventListener('input', (e) => {
if (state) seekTimelineToNearestFrame();
});
// 时间轴:开始拖动即暂停,松开后吸附到最近的 30 FPS 整数帧并保持暂停。
timeline.addEventListener('pointerdown', () => {
seeking = true;
playing = false;
updatePlayButton();
});
const endTimelineSeek = () => {
if (!seeking) return;
try {
seekTimelineToNearestFrame();
} finally {
// 无论吸附是否抛错都要复位,否则 seeking 会永久卡住导致时间轴不再同步。
seeking = false;
playing = false;
updatePlayButton();
timeline.blur();
}
};
timeline.addEventListener('pointerup', endTimelineSeek);
// 指针移出滑块后松开时也要完成吸附。
window.addEventListener('pointerup', endTimelineSeek);
timeline.addEventListener('change', () => {
if (seeking || timeline.disabled) return;
seekTimelineToNearestFrame();
timeline.blur();
});
// ---------- 画布交互:左键拖拽平移,右键回中心,滚轮缩放 ----------
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(t('viewer.recentered'));
}
});
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(t('viewer.missingFile'), t('viewer.missingFileMsg'));
} else if (!fs.existsSync(filePath)) {
showError(t('viewer.fileNotExist'), filePath);
} else {
initSuffixSettings().then(() => loadAssets()).catch((err) => {
showError(t('viewer.loadFailed'), (err && err.message) ? err.message : String(err));
});
}
</script>
</body>
</html>