1444 lines
57 KiB
HTML
1444 lines
57 KiB
HTML
<!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>
|
||
<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>
|
||
|
||
<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');
|
||
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;
|
||
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;
|
||
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('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 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 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 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);
|
||
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' };
|
||
}
|
||
|
||
async function generateGroupThumbnails() {
|
||
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;
|
||
}
|
||
setStatus(t('viewer.generating'), Infinity);
|
||
const 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 = animations.length ? 'flex' : 'none';
|
||
animations.forEach((a, i) => {
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'animation-item' + (i === 0 ? ' active' : '');
|
||
item.dataset.index = String(i);
|
||
item.textContent = a.name;
|
||
item.title = a.name;
|
||
item.setAttribute('role', 'option');
|
||
item.setAttribute('aria-selected', i === 0 ? 'true' : '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);
|
||
}
|
||
}
|
||
|
||
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);
|
||
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 ? 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);
|
||
|
||
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
|
||
if (skeletonData.animations.length > 0) {
|
||
const defaultAnim = skeletonData.animations[0];
|
||
state.setAnimation(0, defaultAnim.name, loop);
|
||
const idx = skeletonData.animations.indexOf(defaultAnim);
|
||
if (idx >= 0) selectAnimationItem(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 = 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);
|
||
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);
|
||
}
|
||
});
|
||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||
window.location.reload();
|
||
});
|
||
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
|
||
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||
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 {
|
||
loadAssets().catch((err) => {
|
||
showError(t('viewer.loadFailed'), (err && err.message) ? err.message : String(err));
|
||
});
|
||
}
|
||
</script>
|
||
</body>
|
||
</html>
|