2222 lines
95 KiB
HTML
2222 lines
95 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>
|
||
<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" data-instant-tooltip data-tooltip="左键点击生成静态缩略图,右键点击生成动态缩略图">为关联资源生成缩略图</button>
|
||
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" data-instant-tooltip data-tooltip="左键点击生成静态缩略图,右键点击生成动态缩略图">设为当前素材缩略图</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>
|
||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedCanvas">输出尺寸</span>
|
||
<select id="animatedThumbCanvas">
|
||
<option value="200" selected>200 × 200</option>
|
||
<option value="400">400 × 400</option>
|
||
</select>
|
||
</label>
|
||
<label class="animated-thumb-quality"><span data-i18n="viewer.animatedPriority">优先级别</span>
|
||
<select id="animatedThumbPriority">
|
||
<option value="resolution" data-i18n="viewer.priorityResolution">分辨率优先</option>
|
||
<option value="framerate" data-i18n="viewer.priorityFramerate">帧率优先</option>
|
||
<option value="balanced" data-i18n="viewer.priorityBalanced">分辨率和帧率交替进行</option>
|
||
</select>
|
||
</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 id="animatedPickDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="animatedPickTitle">
|
||
<div class="thumb-dialog-card animated-pick-card">
|
||
<div id="animatedPickTitle" class="thumb-dialog-title" data-i18n="viewer.animatedPickTitle">选择一个缩略图</div>
|
||
<div class="thumb-dialog-note" data-i18n="viewer.animatedPickHint">所有结果画布都是 400×400,标签为实际细节分辨率@帧率/质量/文件大小。点击一个结果即替换缩略图,关闭则全部放弃。</div>
|
||
<div id="animatedPickGrid" class="animated-pick-grid"></div>
|
||
<div id="animatedPickNotice" class="animated-pick-notice"></div>
|
||
<div class="thumb-dialog-actions">
|
||
<button id="animatedPickCancel" data-i18n="viewer.cancel">取消</button>
|
||
<button id="animatedPickConfirm" class="primary" data-i18n="viewer.animatedPickConfirm" disabled>确认</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 animatedProfileConfig = require('../js/animated-profiles.js');
|
||
const animatedProfiles = animatedProfileConfig.animatedProfiles;
|
||
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]')) {
|
||
const text = t(el.dataset.i18nTitle);
|
||
if (el.hasAttribute('data-instant-tooltip')) el.dataset.tooltip = text;
|
||
else el.title = text;
|
||
}
|
||
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 priorityInput = document.getElementById('animatedThumbPriority');
|
||
const canvasInput = document.getElementById('animatedThumbCanvas');
|
||
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';
|
||
priorityInput.value = animatedProfileConfig.DEFAULT_PRIORITY;
|
||
canvasInput.value = String(animatedProfileConfig.DEFAULT_OUTPUT_SIZE);
|
||
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',
|
||
priority: priorityInput.value,
|
||
canvasSize: Number(canvasInput.value) || animatedProfileConfig.DEFAULT_OUTPUT_SIZE,
|
||
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 profileFrameCount(sourceFrames, range, fps) {
|
||
return Math.max(2, Math.min(sourceFrames.length, Math.ceil(range * fps)));
|
||
}
|
||
|
||
function resizeFramePixels(data, sourceSize, targetSize) {
|
||
if (sourceSize === targetSize) return data;
|
||
const output = new Uint8Array(targetSize * targetSize * 4);
|
||
for (let y = 0; y < targetSize; y++) {
|
||
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
|
||
for (let x = 0; x < targetSize; x++) {
|
||
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
|
||
const source = (sourceY * sourceSize + sourceX) * 4;
|
||
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
|
||
}
|
||
}
|
||
return output;
|
||
}
|
||
|
||
// 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
|
||
// 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
|
||
function scaledFramePixels(data, size, detailSize) {
|
||
return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
|
||
}
|
||
|
||
async function renderAnimatedSourceFrames(animation, options) {
|
||
const size = options.canvasSize || animatedProfileConfig.DEFAULT_OUTPUT_SIZE;
|
||
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();
|
||
}
|
||
return { sourceFrames, size, range };
|
||
}
|
||
|
||
// 缩略图参数标签,不走本地化:细节分辨率*细节分辨率@帧率/质量/文件大小。
|
||
function candidateParams(candidate) {
|
||
const quality = candidate.quality === null ? 'lossless' : 'q' + candidate.quality;
|
||
return candidate.detail + '*' + candidate.detail + '@' + candidate.fps + 'fps/' + quality
|
||
+ '/' + Math.max(1, Math.round(candidate.buffer.length / 1024)) + 'kb';
|
||
}
|
||
|
||
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
|
||
async function estimateAnimatedBytes(profile, sourceFrames, size, range, options, encodeWebpFrame) {
|
||
const count = profileFrameCount(sourceFrames, range, profile.fps);
|
||
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
|
||
let total = 0;
|
||
for (let slot = 0; slot < indexes.length; slot++) {
|
||
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
|
||
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
|
||
if (options.format === 'apng') {
|
||
total += apngEncoder.encode({
|
||
width: size, height: size, plays: 0, compressionLevel: 9,
|
||
frames: [{ rgba: pixels, delayNumerator: 100, delayDenominator: 1000 }],
|
||
}).length;
|
||
} else {
|
||
total += (await encodeWebpFrame(slot, pixels, profile.quality)).length;
|
||
}
|
||
}
|
||
return (total / indexes.length) * count;
|
||
}
|
||
|
||
// 沿整条路径估算体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」挑候选。
|
||
// 对数取点能让画廊里的档位从明显清晰一路铺到明显省体积,而不是全挤在目标附近;
|
||
// 配置允许分段(分辨率优先就是「400 系列 + 200 系列」),所以整条路径不假设单调。
|
||
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, options, targetBytes, count, encodeWebpFrame) {
|
||
// 估算点按步长抽样,避免整条路径都各编两帧;路径首尾一定纳入。
|
||
const stride = Math.max(1, Math.ceil(searchPath.length / 24));
|
||
const samples = [];
|
||
for (let index = 0; index < searchPath.length; index += stride) {
|
||
samples.push({
|
||
index,
|
||
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, options, encodeWebpFrame),
|
||
});
|
||
}
|
||
const lastIndex = searchPath.length - 1;
|
||
if (samples[samples.length - 1].index !== lastIndex) {
|
||
samples.push({
|
||
index: lastIndex,
|
||
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, options, encodeWebpFrame),
|
||
});
|
||
}
|
||
// 先按体积从小到大排:配置允许分段(分辨率优先的 400 / 200 两段体积曲线会重叠),
|
||
// 排序后再取点才能保证候选体积单调递增、也不会挑出两张几乎一样的结果。
|
||
samples.sort((left, right) => left.bytes - right.bytes);
|
||
// 体积 → 参数:在「体积相邻」的两个采样点之间插值。候选因此可以落在配置表之外,
|
||
// 出现 234×234@13fps/q0.22 这类值;按体积相邻插值也能跨过分段,保证候选体积递增。
|
||
const profileForBytes = (wantedBytes) => {
|
||
let upper = samples.length - 1;
|
||
for (let index = 0; index < samples.length; index++) {
|
||
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
|
||
}
|
||
const lower = Math.max(0, upper - 1);
|
||
const before = searchPath[samples[lower].index];
|
||
const after = searchPath[samples[upper].index];
|
||
const gap = samples[upper].bytes - samples[lower].bytes;
|
||
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
|
||
const mix = (start, end) => start + (end - start) * ratio;
|
||
return {
|
||
size: Math.max(1, Math.round(mix(before.size, after.size))),
|
||
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
|
||
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
|
||
};
|
||
};
|
||
// 目标区间夹在样本的实际体积范围内,免得目标远小于最小候选时空转。
|
||
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
|
||
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
|
||
const span = Math.max(1, count - 1);
|
||
const picked = [];
|
||
for (let slot = 0; slot < count; slot++) {
|
||
const wanted = Math.exp(low + (high - low) * slot / span);
|
||
picked.push(profileForBytes(wanted));
|
||
}
|
||
return picked;
|
||
}
|
||
|
||
// 结果画廊:先按目标体积定位,再逐档生成,每档一出来就填进两行五列的格子,
|
||
// 点哪个用哪个,关掉就全部丢弃。帧编码走 canvas.toBlob(异步),界面不会被卡住。
|
||
async function pickAnimatedThumbnail(sourceFrames, size, range, options) {
|
||
const dialog = document.getElementById('animatedPickDialog');
|
||
const grid = document.getElementById('animatedPickGrid');
|
||
const notice = document.getElementById('animatedPickNotice');
|
||
const cancelButton = document.getElementById('animatedPickCancel');
|
||
const confirmButton = document.getElementById('animatedPickConfirm');
|
||
const targetBytes = options.maxKilobytes * 1024;
|
||
// 锚点插值成更密的搜索路径,候选数量仍按锚点数量给。
|
||
const searchPath = animatedProfileConfig.expandAnimatedPath(options.priority, options.canvasSize);
|
||
const candidateCount = animatedProfiles(options.priority, options.canvasSize).length;
|
||
// 4 个离屏画布轮转:每帧编码互相独立,成批提交比逐帧等待快得多。
|
||
const pool = Array.from({ length: 4 }, () => {
|
||
const off = document.createElement('canvas');
|
||
off.width = size; off.height = size;
|
||
const context = off.getContext('2d');
|
||
return { canvas: off, context, image: context.createImageData(size, size) };
|
||
});
|
||
const previewUrls = [];
|
||
grid.textContent = '';
|
||
notice.textContent = '';
|
||
confirmButton.disabled = true;
|
||
let selected = null;
|
||
let defaultPick = null;
|
||
let userPicked = false;
|
||
let hasFit = false;
|
||
let smallest = Infinity;
|
||
let settled = false;
|
||
const encodeFrame = (slot, pixels, quality) => new Promise((resolve, reject) => {
|
||
const unit = pool[slot];
|
||
unit.image.data.set(pixels);
|
||
unit.context.putImageData(unit.image, 0, 0);
|
||
unit.canvas.toBlob((blob) => {
|
||
if (!blob || blob.type !== 'image/webp') { reject(new Error(t('viewer.animatedWebpUnavailable'))); return; }
|
||
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
|
||
}, 'image/webp', quality);
|
||
});
|
||
return new Promise((resolve, reject) => {
|
||
const cards = new Map();
|
||
const close = (value) => {
|
||
if (settled) return;
|
||
settled = true;
|
||
dialog.classList.add('hidden');
|
||
cancelButton.removeEventListener('click', cancel);
|
||
confirmButton.removeEventListener('click', confirm);
|
||
document.removeEventListener('keydown', keydown);
|
||
for (const url of previewUrls) URL.revokeObjectURL(url);
|
||
previewUrls.length = 0;
|
||
resolve(value);
|
||
};
|
||
const cancel = () => close(null);
|
||
const confirm = () => { if (selected) close(selected); };
|
||
const keydown = (event) => { if (event.key === 'Escape') cancel(); };
|
||
// 点卡片只是选中(右上角打勾),真正替换缩略图要再按确认。
|
||
const select = (candidate, manual) => {
|
||
if (manual) userPicked = true;
|
||
selected = candidate;
|
||
for (const [other, node] of cards) node.classList.toggle('selected', other === candidate);
|
||
confirmButton.disabled = false;
|
||
};
|
||
const addCard = (candidate) => {
|
||
const url = URL.createObjectURL(new Blob([candidate.buffer], {
|
||
type: options.format === 'apng' ? 'image/png' : 'image/webp',
|
||
}));
|
||
previewUrls.push(url);
|
||
const item = document.createElement('button');
|
||
item.type = 'button';
|
||
item.className = 'animated-pick-item';
|
||
const preview = document.createElement('img');
|
||
preview.src = url;
|
||
preview.alt = '';
|
||
const params = document.createElement('span');
|
||
params.className = 'animated-pick-params';
|
||
params.textContent = candidateParams(candidate);
|
||
item.append(preview, params);
|
||
item.addEventListener('click', () => select(candidate, true));
|
||
cards.set(candidate, item);
|
||
grid.appendChild(item);
|
||
};
|
||
cancelButton.addEventListener('click', cancel);
|
||
confirmButton.addEventListener('click', confirm);
|
||
document.addEventListener('keydown', keydown);
|
||
dialog.classList.remove('hidden');
|
||
(async () => {
|
||
// 先沿搜索路径估算体积,再按对数间隔挑出候选,最后完整生成选中的那几档,
|
||
// 这样画廊里的候选会围绕用户填写的体积铺开,而不是固定的那几张。
|
||
setStatus(t('viewer.animatedGenerating', { current: 0, total: '…' }), Infinity);
|
||
const profiles = await pickAnimatedCandidates(
|
||
searchPath, sourceFrames, size, range, options, targetBytes, candidateCount, encodeFrame,
|
||
);
|
||
for (let profileIndex = 0; profileIndex < profiles.length; profileIndex++) {
|
||
if (settled) return;
|
||
const profile = profiles[profileIndex];
|
||
const count = profileFrameCount(sourceFrames, range, profile.fps);
|
||
const duration = Math.max(1, Math.round(range * 1000 / count));
|
||
const frames = [];
|
||
for (let start = 0; start < count; start += pool.length) {
|
||
if (settled) return;
|
||
const batch = [];
|
||
for (let index = start; index < Math.min(count, start + pool.length); index++) {
|
||
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(index * sourceFrames.length / count));
|
||
batch.push(scaledFramePixels(sourceFrames[sourceIndex], size, profile.size));
|
||
}
|
||
if (options.format === 'apng') {
|
||
for (const pixels of batch) frames.push({ rgba: pixels, delayNumerator: duration, delayDenominator: 1000 });
|
||
} else {
|
||
const buffers = await Promise.all(batch.map((pixels, offset) =>
|
||
encodeFrame((start + offset) % pool.length, pixels, profile.quality)));
|
||
for (const webp of buffers) frames.push({ webp, duration });
|
||
}
|
||
setStatus(t('viewer.animatedCompressing', { current: profileIndex + 1, total: profiles.length }), Infinity);
|
||
await new Promise((next) => setTimeout(next, 0));
|
||
}
|
||
if (settled) return;
|
||
const buffer = options.format === 'apng'
|
||
? apngEncoder.encode({ width: size, height: size, frames, plays: 0, compressionLevel: 9 })
|
||
: animatedWebpEncoder.encode({ width: size, height: size, frames, plays: 0 });
|
||
const candidate = {
|
||
detail: profile.size,
|
||
fps: profile.fps,
|
||
quality: options.format === 'apng' ? null : profile.quality,
|
||
frames: count,
|
||
buffer,
|
||
};
|
||
smallest = Math.min(smallest, buffer.length);
|
||
if (buffer.length <= targetBytes) hasFit = true;
|
||
addCard(candidate);
|
||
// 用户没自己挑过时,默认选中当前体积最小的档位,省得在十个结果里纠结。
|
||
if (!userPicked && (!defaultPick || buffer.length < defaultPick.buffer.length)) {
|
||
defaultPick = candidate;
|
||
select(candidate, false);
|
||
}
|
||
}
|
||
if (!hasFit) notice.textContent = t('viewer.animatedCannotFit', { target: options.maxKilobytes, actual: Math.ceil(smallest / 1024) });
|
||
})().catch((error) => { close(null); reject(error); });
|
||
});
|
||
}
|
||
|
||
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 source = await renderAnimatedSourceFrames(animation, options);
|
||
const picked = await pickAnimatedThumbnail(source.sourceFrames, source.size, source.range, options);
|
||
// 用户没挑就直接结束:不替换缩略图,已生成的预览对象在上面统一释放。
|
||
if (!picked) { setStatus(''); return; }
|
||
const extension = options.format === 'apng' ? '.png' : '.webp';
|
||
tempPath = path.join(os.tmpdir(), 'spineviewer-current-animated-' + crypto.randomBytes(12).toString('hex') + extension);
|
||
fs.writeFileSync(tempPath, picked.buffer);
|
||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||
setStatus(t('viewer.animatedThumbUpdated', {
|
||
frames: picked.frames,
|
||
size: Math.ceil(picked.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);
|
||
const source = await renderAnimatedSourceFrames(animation, options);
|
||
const picked = await pickAnimatedThumbnail(source.sourceFrames, source.size, source.range, options);
|
||
if (!picked) { setStatus(''); return; }
|
||
rendered = {
|
||
buffer: picked.buffer,
|
||
extension: options.format === 'apng' ? '.png' : '.webp',
|
||
};
|
||
} 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>
|