Files
SpineViewer/viewer/spine.html
T
2026-09-12 22:17:52 +08:00

1224 lines
47 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title data-i18n="viewer.title">Spine 预览</title>
<link rel="stylesheet" href="spine.css">
</head>
<body>
<div id="app">
<div id="toolbar">
<label><span data-i18n="viewer.skin">皮肤</span>
<select id="skinSelect" disabled></select>
</label>
<button id="playBtn" data-i18n-title="viewer.playPauseTitle" title="播放/暂停 (空格)">⏸ 暂停</button>
<label><input type="checkbox" id="loopCheck" checked> <span data-i18n="viewer.loop">循环</span></label>
<label><span data-i18n="viewer.speed">速度</span>
<input type="range" id="speedRange" min="0" max="3" step="0.05" value="1">
<span id="speedVal">1.0x</span>
</label>
<label data-i18n-title="viewer.pmaTitle" title="用于以 Premultiplied Alpha(PMA)方式导出的贴图">
<input type="checkbox" id="pmaCheck" disabled> <span data-i18n="viewer.pma">预乘</span>
</label>
<label data-i18n-title="viewer.backgroundTitle" title="50×50 方格背景,随画布移动和缩放,并参与混合模式">
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label>
<div class="spacer"></div>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="使用当前帧生成 400×400 缩略图">生成缩略图</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>
<script>
// 使用 Node 集成提供的模块
const path = require('path');
const fs = require('fs');
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
let spineBadgeImagePromise = null;
function loadSpineBadgeImage() {
if (spineBadgeImagePromise) return spineBadgeImagePromise;
spineBadgeImagePromise = new Promise((resolve, reject) => {
try {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error(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)));
}
}
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
// ---------- 读取 URL 参数 ----------
const urlParams = new URLSearchParams(window.location.search);
const filePath = urlParams.get('path');
const width = urlParams.get('width');
const height = urlParams.get('height');
const theme = urlParams.get('theme');
const lang = urlParams.get('lang');
if (theme === 'light') document.documentElement.setAttribute('data-theme', 'light');
// ---------- 多国语系 ----------
// 文案与 manifest.json 的 languages / fallbackLanguage 共用同一份 _locales/<语言>.json。
// 格式扩展插件的查看器页不会注入 Eagle Plugin API,因此这里直接读取语言文件。
const I18N_LANGUAGES = ['en', 'zh_CN', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE'];
const I18N_FALLBACK = 'zh_CN';
function normalizeLocale(raw) {
if (!raw) return null;
const tag = String(raw).trim().toLowerCase().replace(/-/g, '_');
if (tag === 'zh_tw' || tag === 'zh_hk' || tag === 'zh_hant' || tag === 'zh_mo') return 'zh_TW';
for (const code of I18N_LANGUAGES) {
if (code.toLowerCase() === tag) return code;
}
const base = tag.split('_')[0];
if (base === 'zh') return 'zh_CN';
for (const code of I18N_LANGUAGES) {
if (code.toLowerCase().split('_')[0] === base) return code;
}
return null;
}
function readLocaleFile(code) {
try {
// Eagle 页面中的 __dirname 不一定等于 viewer 目录,使用实际模块路径定位。
const pluginRoot = path.dirname(path.dirname(require.resolve('../js/spine-utils.js')));
const file = path.join(pluginRoot, '_locales', code + '.json');
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return (data && typeof data === 'object') ? data : {};
} catch (e) {
return {};
}
}
// 按 "viewer.skin" 这样的点号路径取字符串,与官方 i18next 的用法一致
function lookupMessage(table, key) {
let node = table;
for (const part of String(key).split('.')) {
if (!node || typeof node !== 'object') return undefined;
node = node[part];
}
return typeof node === 'string' ? node : undefined;
}
const fallbackMessages = readLocaleFile(I18N_FALLBACK);
const locale = normalizeLocale(lang) || I18N_FALLBACK;
const messages = locale === I18N_FALLBACK ? fallbackMessages : readLocaleFile(locale);
document.documentElement.lang = locale.replace('_', '-');
log('界面语言: ' + locale + ' (lang=' + (lang || '未提供') + ')');
// t('viewer.skin', { name: '...' }):缺失的键回退到 fallbackLanguage
function t(key, vars) {
let text = lookupMessage(messages, key);
if (text === undefined) text = lookupMessage(fallbackMessages, key);
if (text === undefined) return key;
if (vars) {
text = text.replace(/\{(\w+)\}/g, (whole, name) => (Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole));
}
return text;
}
// 应用静态文案:data-i18n / data-i18n-title / data-i18n-aria-label
function applyStaticI18n(root) {
const scope = root || document;
for (const el of scope.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of scope.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of scope.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
}
applyStaticI18n();
const canvas = document.getElementById('canvas');
const ctx = canvas.getContext('2d');
const overlay = document.getElementById('overlay');
const loadingEl = document.getElementById('loading');
const statusEl = document.getElementById('status');
const animationPanel = document.getElementById('animationPanel');
const animationList = document.getElementById('animationList');
const timeline = document.getElementById('timeline');
const timeLabel = document.getElementById('timeLabel');
const durationLabel = document.getElementById('durationLabel');
const timeRuler = document.getElementById('timeRuler');
const keyframesEl = document.getElementById('keyframes');
keyframesEl.title = t('viewer.keyframesHint');
const appEl = document.getElementById('app');
const toolbarEl = document.getElementById('toolbar');
const timelineBarEl = document.getElementById('timelineBar');
function updateFloatingInsets() {
appEl.style.setProperty('--toolbar-height', toolbarEl.offsetHeight + 'px');
appEl.style.setProperty('--timeline-height', timelineBarEl.offsetHeight + 'px');
}
if (typeof ResizeObserver === 'function') {
const floatingResizeObserver = new ResizeObserver(updateFloatingInsets);
floatingResizeObserver.observe(toolbarEl);
floatingResizeObserver.observe(timelineBarEl);
}
requestAnimationFrame(updateFloatingInsets);
// ---------- 全局状态 ----------
let skeleton = null, state = null, stateData = null, skeletonData = null;
let renderer = null;
let activeRuntimeDir = null;
let activeVersion = null;
let currentAnim = 0, currentSkin = null;
let playing = true, loop = true, speed = 1;
let premultipliedAlpha = false;
let previewSettings = null;
let timeKeeper = null, lastTime = 0, running = false;
let seeking = false; // 用户正在拖动时间轴
let duration = 0; // 当前动画时长
let isStaticAnimation = false;
const FPS = 30;
const STATIC_EPSILON = 1e-6;
let statusHoldUntil = 0;
// 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心)
let panX = 0, panY = 0, zoom = 1;
let baseFitScale = 1, baseFitCx = 0, baseFitCy = 0;
let dragging = false; // 是否正在拖拽
let dragStartX = 0, dragStartY = 0, dragStartPanX = 0, dragStartPanY = 0;
function showError(title, msg) {
loadingEl.classList.add('hidden');
overlay.classList.remove('text-preview');
overlay.classList.remove('json-preview');
overlay.classList.remove('hidden');
document.getElementById('errorTitle').textContent = title;
document.getElementById('errorMsg').textContent = msg;
}
function showTextPreview(title, text) {
running = false;
toolbarEl.style.display = 'none';
timelineBarEl.style.display = 'none';
animationPanel.style.display = 'none';
statusEl.style.display = 'none';
showError(title, text);
overlay.classList.add('text-preview');
}
// ---------- 普通 JSON:轻量字段树(点击折叠 / 展开) ----------
const JSON_TREE_MAX_NODES = 20000; // 超过此规模退回纯文本,避免构建 DOM 卡顿
const JSON_TREE_OPEN_DEPTH = 1; // 默认展开的层级,更深的折叠
function jsonNodeCount(value, budget) {
let n = 0;
const walk = (node) => {
if (!node || typeof node !== 'object' || n > budget) return;
for (const key of Object.keys(node)) { n++; walk(node[key]); if (n > budget) return; }
};
walk(value);
return n;
}
function jsonKeySpan(key) {
const span = document.createElement('span');
span.className = 'jt-key';
span.textContent = (typeof key === 'number' ? '[' + key + ']: ' : JSON.stringify(key) + ': ');
return span;
}
function buildJsonTree(key, value, depth) {
if (value === null || typeof value !== 'object') {
const leaf = document.createElement('div');
leaf.className = 'jt-leaf';
if (key !== null) leaf.appendChild(jsonKeySpan(key));
const val = document.createElement('span');
val.textContent = typeof value === 'string' ? JSON.stringify(value) : String(value);
if (typeof value === 'string') val.className = 'jt-str';
leaf.appendChild(val);
return leaf;
}
const isArray = Array.isArray(value);
const keys = isArray ? value.map((_, i) => i) : Object.keys(value);
const node = document.createElement('details');
node.className = 'jt-node';
if (depth <= JSON_TREE_OPEN_DEPTH) node.open = true;
const summary = document.createElement('summary');
if (key !== null) summary.appendChild(jsonKeySpan(key));
const open = document.createElement('span');
open.className = 'jt-brace';
open.textContent = isArray ? '[' : '{';
const count = document.createElement('span');
count.className = 'jt-count';
count.textContent = String(keys.length);
const close = document.createElement('span');
close.className = 'jt-brace';
close.textContent = isArray ? ']' : '}';
summary.append(open, count, close);
node.appendChild(summary);
const children = document.createElement('div');
children.className = 'jt-children';
for (const k of keys) children.appendChild(buildJsonTree(k, value[k], depth + 1));
node.appendChild(children);
return node;
}
function showJsonPreview(title, value) {
const box = document.getElementById('errorMsg');
showTextPreview(title, '');
if (jsonNodeCount(value, JSON_TREE_MAX_NODES) > JSON_TREE_MAX_NODES) {
// 超大对象仍以纯文本呈现,保证打开速度与可选择性
box.textContent = JSON.stringify(value, null, 2);
return;
}
overlay.classList.add('json-preview');
box.textContent = '';
box.appendChild(buildJsonTree(null, value, 0));
}
function setStatus(msg, holdMs = 0) {
statusEl.textContent = msg;
statusHoldUntil = holdMs === Infinity ? Infinity : (holdMs > 0 ? performance.now() + holdMs : 0);
}
// ---------- 动态加载本地 Spine 运行时 ----------
function loadRuntime(dir) {
return new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = '../lib/' + dir + '/spine-canvas.js';
s.onload = () => {
try { require('../js/spine-legacy-binary.js').install(spine, dir); resolve(); }
catch (e) { reject(e); }
};
s.onerror = () => reject(new Error(t('viewer.runtimeFailed', { file: 'lib/' + dir + '/spine-canvas.js' })));
document.head.appendChild(s);
});
}
// 1x1 灰色占位贴图 data URI
const PLACEHOLDER_IMG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC';
// ---------- 在浏览器中构造 TextureAtlas(跨 Eagle item 文件夹加载贴图)----------
// assets.texturePaths: { atlas 引用的贴图名 -> 绝对路径 }
function buildBrowserAtlas(spine, atlasText, assets) {
return new Promise((resolve, reject) => {
(async () => {
// 1) 预先读取所有页面贴图,转成 data URI 并用 Image 加载
const pageNames = [];
try { pageNames.push(...utils.parseAtlasPages(atlasText)); } catch (e) { /* ignore */ }
const imgMap = {};
await Promise.all(pageNames.map((name) => {
return new Promise((res) => {
const p = (assets.texturePaths && assets.texturePaths[name]);
if (!p || !fs.existsSync(p)) { res(); return; }
const ext = path.extname(p).toLowerCase();
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
try {
const data = fs.readFileSync(p);
const img = new Image();
img.onload = async () => {
try { await require('../js/spine-webgl-rasterizer.js').prepareTexture(img, new Blob([data], { type: mime })); }
catch (e) { log('无损贴图解码不可用,使用浏览器贴图: ' + e.message); }
imgMap[name] = img; res();
};
img.onerror = () => res();
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
} catch (e) { res(); }
});
}));
// 2) 生成 Textures
const makeTexture = (name) => {
let img = imgMap[name];
if (!img) {
const ph = new Image(); ph.src = PLACEHOLDER_IMG;
img = ph;
}
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
return new spine.Texture(img);
};
// 3) 构建 Atlas(3.x 用构造器回调,4.x 用 setTexture)
let atlas;
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const tex = makeTexture(page.name);
if (page.setTexture) page.setTexture(tex);
else page.texture = tex;
}
} else {
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
resolve(atlas);
})().catch(reject);
});
}
// ---------- 计算骨骼包围盒(跨版本兼容)----------
function getBounds(sk) {
try {
if (typeof sk.getBoundsRect === 'function') {
const r = sk.getBoundsRect();
if (r && (r.width !== undefined)) return { x: r.x, y: r.y, width: r.width, height: r.height };
}
if (typeof sk.getBounds === 'function') {
const offset = new spine.Vector2();
const size = new spine.Vector2();
sk.getBounds(offset, size, []);
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
}
} catch (e) { /* ignore */ }
return null;
}
// ---------- 适配并居中绘制一帧 ----------
// Spine 世界坐标 Y 轴向上,Canvas 2D Y 轴向下,绘制时需翻转 Y。
// baseFitScale 为自动适配用的基准缩放;panX/panY/zoom 为用户平移/缩放叠加。
// fitScale = baseFitScale * zoom;fitCx/fitCy 是骨骼包围盒中心(世界坐标)。
let fitScale = 1, fitCx = 0, fitCy = 0;
function fitToCanvas() {
const bounds = getBounds(skeleton);
const cw = canvas.clientWidth, ch = canvas.clientHeight;
if (!bounds || bounds.width <= 0 || bounds.height <= 0) {
baseFitScale = 1; baseFitCx = 0; baseFitCy = 0; fitScale = 1; fitCx = 0; fitCy = 0; return;
}
const pad = 0.1;
const sx = (cw * (1 - pad * 2)) / bounds.width;
const sy = (ch * (1 - pad * 2)) / bounds.height;
baseFitScale = Math.min(sx, sy);
if (!isFinite(baseFitScale) || baseFitScale <= 0) baseFitScale = 1;
baseFitCx = bounds.x + bounds.width / 2;
baseFitCy = bounds.y + bounds.height / 2;
// 应用用户缩放;骨骼中心保持为默认中心(平移仅作用于屏幕坐标)
fitScale = baseFitScale * zoom;
fitCx = baseFitCx;
fitCy = baseFitCy;
}
function draw() {
const cw = canvas.clientWidth, ch = canvas.clientHeight;
if (canvas.width !== cw * devicePixelRatio || canvas.height !== ch * devicePixelRatio) {
canvas.width = cw * devicePixelRatio;
canvas.height = ch * devicePixelRatio;
}
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, cw, ch);
ctx.fillStyle = '#1e1e1e'; ctx.fillRect(0, 0, cw, ch);
if (!skeleton || !renderer) return;
ctx.save();
// 居中 + 缩放 + Y 翻转,使 Spine 坐标正确映射到 Canvas。
// panX/panY 作为屏幕坐标偏移叠加,支持上下左右拖动画布。
ctx.translate(cw / 2 + panX, ch / 2 + panY);
ctx.scale(fitScale, -fitScale);
ctx.translate(-fitCx, -fitCy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(ctx, canvas.width, canvas.height);
renderer.draw(skeleton);
ctx.restore();
}
// ---------- 更新动画状态 ----------
function updateWorld(sk) {
if (sk.updateWorldTransform) {
if (spine.Physics && spine.Physics.update !== undefined) sk.updateWorldTransform(spine.Physics.update);
else sk.updateWorldTransform();
}
}
// ---------- 生成当前帧缩略图(400×400,自适应骨骼包围盒并居中)----------
function eagleItemIdFromPath(sourcePath) {
const match = path.basename(path.dirname(sourcePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) {
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const info = await response.json();
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
throw new Error(t('viewer.compoundName'));
if (info.data.ext === 'txt') throw new Error(t('viewer.txtRegistered'));
}
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id: itemId, thumbnailPath })
});
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || (result && result.status && result.status !== 'success')) {
const reason = result && (result.message || result.error);
throw new Error(reason || t('viewer.apiHttp', { status: response.status }));
}
return result;
}
async function generateThumbnail() {
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
setStatus(t('viewer.generating'), Infinity);
try {
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
playing = false;
updatePlayButton();
if (state) state.apply(skeleton);
updateWorld(skeleton);
const size = 400;
const bounds = getBounds(skeleton);
const off = document.createElement('canvas');
off.width = size; off.height = size;
const octx = off.getContext('2d');
octx.fillStyle = '#1e1e1e';
octx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
octx.save();
octx.translate(size / 2, size / 2);
octx.scale(scale, -scale);
octx.translate(-cx, -cy);
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
let offRenderer = null;
try {
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
offRenderer.premultipliedAlpha = premultipliedAlpha;
offRenderer.draw(skeleton);
} finally {
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
octx.restore();
}
}
await drawSpineBadge(octx, size, size);
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
let out = off.toDataURL('image/webp', 0.9);
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
const buf = Buffer.from(out.split(',')[1], 'base64');
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath);
const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png'));
try {
if (!itemId) throw new Error(t('viewer.noItemId'));
fs.writeFileSync(tempPath, buf);
await setEagleCustomThumbnail(itemId, tempPath);
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)');
} catch (e) {
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
try {
fs.writeFileSync(outPath, buf);
setStatus(t('viewer.thumbWrittenNoRefresh', { error: (e && e.message) || String(e) }), 6000);
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
} catch (e2) {
setStatus(t('viewer.thumbnailFailed', { error: (e2 && e2.message) || String(e2) }), 6000);
}
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
} catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
} finally {
thumbBtn.disabled = false;
}
}
// ---------- 渲染主循环 ----------
function renderLoop(now) {
if (!running) return;
requestAnimationFrame(renderLoop);
const dt = (now - lastTime) / 1000;
lastTime = now;
if (skeleton && state) {
if (playing) {
// Spine 运行时自身处理循环;持续 update 即可。
state.update(dt * speed);
state.apply(skeleton);
}
updateWorld(skeleton);
draw();
const entry = currentTrack();
if (entry) {
const currentTime = getTrackAnimationTime(entry);
if (now >= statusHoldUntil) {
setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? t('viewer.staticLabel') : formatSeconds(currentTime)));
}
// 同步时间轴(若用户未在拖动)。兼容 3.x 的 time 与 4.x 的 trackTime/getAnimationTime。
if (!seeking) {
const norm = isStaticAnimation ? 0 : Math.max(0, Math.min(currentTime, duration));
timeline.value = String(norm);
updateTimeReadout(norm);
}
}
}
}
// 帧号换算(默认 30fps)
function frameOf(seconds) {
return Math.round((seconds || 0) * FPS);
}
function snapTimeToFrame(seconds) {
const lastWholeFrame = Math.max(0, Math.floor((duration + 1e-8) * FPS));
const frame = Math.max(0, Math.min(Math.round((Number(seconds) || 0) * FPS), lastWholeFrame));
return frame / FPS;
}
function formatSeconds(seconds) {
const value = Math.max(0, Number(seconds) || 0);
if (value < 10) return t('viewer.seconds', { value: value.toFixed(2) });
if (value < 60) return t('viewer.seconds', { value: value.toFixed(1) });
const minutes = Math.floor(value / 60);
return minutes + ':' + (value % 60).toFixed(1).padStart(4, '0');
}
function getTrackAnimationTime(entry) {
if (!entry) return 0;
try {
if (typeof entry.getAnimationTime === 'function') {
const value = entry.getAnimationTime();
if (Number.isFinite(value)) return Math.max(0, Math.min(value, duration));
}
} catch (e) { /* fall through to version-specific fields */ }
const raw = Number.isFinite(entry.trackTime) ? entry.trackTime : (Number.isFinite(entry.time) ? entry.time : 0);
if (loop && duration > STATIC_EPSILON) return raw % duration;
return Math.max(0, Math.min(raw, duration));
}
function animationDuration(anim) {
const declared = anim && Number(anim.duration);
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
const times = collectKeyframeTimes(anim);
return times.length ? Math.max.apply(null, times) : 0;
}
function collectKeyframeTimes(anim) {
if (!anim || !anim.timelines) return [];
const times = new Set();
for (const tl of anim.timelines) {
const frames = tl && tl.frames;
if (!frames || !frames.length) continue;
try {
if (Array.isArray(frames[0])) {
for (const frame of frames) {
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
}
continue;
}
const frameCount = typeof tl.getFrameCount === 'function' ? tl.getFrameCount() : 0;
const entries = typeof tl.getFrameEntries === 'function'
? tl.getFrameEntries()
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
if (frameCount > 0) {
for (let i = 0; i < frameCount; i++) {
const value = frames[i * entries];
if (Number.isFinite(value)) times.add(value);
}
} else {
// 极旧运行时中没有帧数接口的 timeline,其 frames 通常只保存时间。
for (const value of frames) if (Number.isFinite(value) && value >= 0) times.add(value);
}
} catch (e) { /* ignore malformed timeline */ }
}
return [...times].filter((time) => time >= 0).sort((a, b) => a - b);
}
function updateTimeReadout(time) {
if (isStaticAnimation) {
timeLabel.textContent = t('viewer.readoutStatic');
return;
}
const current = Math.max(0, Math.min(Number(time) || 0, duration));
timeLabel.textContent = t('viewer.readout', {
time: formatSeconds(current),
total: formatSeconds(duration),
frame: frameOf(current),
totalFrames: frameOf(duration)
});
}
function niceTickInterval(totalSeconds) {
const target = totalSeconds / 8;
const choices = [1 / 30, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600];
for (const choice of choices) if (choice >= target) return choice;
return Math.pow(10, Math.ceil(Math.log10(target)));
}
function renderTimeRuler() {
timeRuler.innerHTML = '';
if (isStaticAnimation || duration <= STATIC_EPSILON) {
const label = document.createElement('span');
label.className = 'tick-label first';
label.style.left = '0%';
label.textContent = t('viewer.rulerStatic');
timeRuler.appendChild(label);
return;
}
const major = niceTickInterval(duration);
const minor = major / 5;
const count = Math.floor(duration / minor);
for (let i = 0; i <= count; i++) {
const value = Math.min(i * minor, duration);
if (value > duration + STATIC_EPSILON) break;
const percent = (value / duration) * 100;
const isMajor = i % 5 === 0 || Math.abs(value - duration) < STATIC_EPSILON;
const tick = document.createElement('span');
tick.className = 'tick' + (isMajor ? ' major' : '');
tick.style.left = percent + '%';
timeRuler.appendChild(tick);
if (isMajor) {
const label = document.createElement('span');
label.className = 'tick-label' + (i === 0 ? ' first' : (Math.abs(value - duration) < STATIC_EPSILON ? ' last' : ''));
label.style.left = percent + '%';
label.textContent = value < 10 ? value.toFixed(1) + 's' : Math.round(value) + 's';
timeRuler.appendChild(label);
}
if (Math.abs(value - duration) < STATIC_EPSILON) break;
}
// 时长不是整刻度时,确保终点始终有明确标签。
if (Math.abs((duration / minor) - Math.round(duration / minor)) > STATIC_EPSILON) {
const tick = document.createElement('span');
tick.className = 'tick major'; tick.style.left = '100%'; timeRuler.appendChild(tick);
const label = document.createElement('span');
label.className = 'tick-label last'; label.style.left = '100%';
label.textContent = duration < 10 ? duration.toFixed(2) + 's' : duration.toFixed(1) + 's';
timeRuler.appendChild(label);
}
}
function animName(name) {
return name || '';
}
// ---------- 填充 UI ----------
function fillAnimations() {
animationList.innerHTML = '';
const animations = skeletonData.animations || [];
animationPanel.style.display = animations.length ? 'flex' : 'none';
animations.forEach((a, i) => {
const item = document.createElement('button');
item.type = 'button';
item.className = 'animation-item' + (i === 0 ? ' active' : '');
item.dataset.index = String(i);
item.textContent = a.name;
item.title = a.name;
item.setAttribute('role', 'option');
item.setAttribute('aria-selected', i === 0 ? 'true' : 'false');
animationList.appendChild(item);
});
}
function selectAnimationItem(index) {
currentAnim = index;
for (const item of animationList.querySelectorAll('.animation-item')) {
const active = Number(item.dataset.index) === index;
item.classList.toggle('active', active);
item.setAttribute('aria-selected', active ? 'true' : 'false');
}
}
function fillSkins(selectedSkin) {
const sel = document.getElementById('skinSelect');
sel.innerHTML = '';
(skeletonData.skins || []).forEach((s, i) => {
const opt = document.createElement('option');
opt.value = i; opt.textContent = s.name;
sel.appendChild(opt);
});
sel.disabled = (skeletonData.skins || []).length <= 1;
if (selectedSkin) {
const index = (skeletonData.skins || []).indexOf(selectedSkin);
if (index >= 0) sel.value = String(index);
}
}
function setAnimationByName(name, index) {
if (!state) return;
const anim = findAnimByName(name);
if (!anim) return;
const resolvedIndex = Number.isInteger(index) ? index : (skeletonData.animations || []).indexOf(anim);
if (resolvedIndex >= 0) selectAnimationItem(resolvedIndex);
state.setAnimation(0, name, loop);
updateTimelineDuration(anim);
state.update(0);
state.apply(skeleton);
updateWorld(skeleton);
draw();
}
// 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组)
function findAnimByName(name) {
const arr = (skeletonData && skeletonData.animations) || [];
for (let i = 0; i < arr.length; i++) {
if (arr[i] && arr[i].name === name) return arr[i];
}
return null;
}
// 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记
function updateTimelineDuration(anim) {
duration = animationDuration(anim);
isStaticAnimation = !!anim && duration <= STATIC_EPSILON;
timeline.max = String(isStaticAnimation ? 1 : Math.max(duration, 0.001));
timeline.step = String(isStaticAnimation ? 1 : Math.min(1 / FPS, Math.max(duration / 1000, 0.001)));
timeline.value = '0';
timeline.disabled = !anim || isStaticAnimation;
durationLabel.textContent = isStaticAnimation ? t('viewer.timelineStatic') : t('viewer.timelineDuration', { time: formatSeconds(duration), frames: frameOf(duration) });
updateTimeReadout(0);
renderTimeRuler();
renderKeyframeMarkers(anim);
updatePlayButton();
}
// 渲染关键帧标记(收集各 timeline 的关键时间点,用竖线标出)
function renderKeyframeMarkers(anim) {
keyframesEl.innerHTML = '';
if (!anim) return;
if (isStaticAnimation) {
const span = document.createElement('span');
span.style.left = '0%';
span.title = t('viewer.staticPoseTitle');
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
return;
}
const sorted = collectKeyframeTimes(anim).filter((time) => time <= duration);
// 限制数量,避免过多标记
const maxMarkers = 200;
const step = Math.max(1, Math.ceil(sorted.length / maxMarkers));
for (let i = 0; i < sorted.length; i += step) {
const keyframeTime = sorted[i];
const span = document.createElement('span');
span.style.left = (duration > 0 ? (keyframeTime / duration) * 100 : 0) + '%';
span.title = t('viewer.keyframeTitle', { time: formatSeconds(keyframeTime), frame: frameOf(keyframeTime) });
span.setAttribute('aria-label', span.title);
keyframesEl.appendChild(span);
}
}
// Spine 4.3 起 AnimationState.getCurrent() 被改名为 getTrack(),这里统一做版本兼容。
function currentTrack() {
if (!state) return null;
try {
if (typeof state.getCurrent === 'function') return state.getCurrent(0) || null;
if (typeof state.getTrack === 'function') return state.getTrack(0) || null;
const tracks = state.tracks;
return (tracks && tracks[0]) || null;
} catch (e) {
return null;
}
}
// 跳到指定时间点:暂停播放,重置动画到目标时间
function seekTo(time) {
if (!state || !skeleton) return;
const track = currentTrack();
const anim = track ? track.animation : null;
playing = false;
updatePlayButton();
try {
if (anim) {
state.setAnimation(0, anim.name, loop);
}
state.update(Math.max(0, time));
state.apply(skeleton);
updateWorld(skeleton);
draw();
updateTimeReadout(time);
} catch (e) { /* ignore */ }
}
function applySkinByName(name) {
if (!skeleton) return;
const skin = skeleton.data && skeleton.data.findSkin ? skeleton.data.findSkin(name)
: (skeletonData.skins || []).find((item) => item.name === name);
if (!utils.applySkin(skeleton, skin)) return;
currentSkin = skin;
if (state) state.apply(skeleton);
updateWorld(skeleton);
draw();
}
// ---------- 非 Spine 图片回退:直接显示图片 ----------
function showImageFallback(file) {
try {
const ext = path.extname(file).toLowerCase();
const mime = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.webp': 'image/webp' }[ext] || 'application/octet-stream';
const data = fs.readFileSync(file);
const img = new Image();
img.onload = () => {
loadingEl.classList.add('hidden');
running = false;
const cw = canvas.clientWidth, ch = canvas.clientHeight;
canvas.width = cw * devicePixelRatio; canvas.height = ch * devicePixelRatio;
ctx.setTransform(devicePixelRatio, 0, 0, devicePixelRatio, 0, 0);
ctx.clearRect(0, 0, cw, ch);
const scale = Math.min(cw / img.width, ch / img.height);
const w = img.width * scale, h = img.height * scale;
ctx.drawImage(img, (cw - w) / 2, (ch - h) / 2, w, h);
setStatus(t('viewer.imagePreview'));
};
img.onerror = () => showError(t('viewer.imageLoadFailed'), file);
img.src = 'data:' + mime + ';base64,' + data.toString('base64');
} catch (e) {
showError(t('viewer.imageLoadFailed'), (e && e.message) || String(e));
}
}
// ---------- 加载资源并与运行时交互 ----------
async function loadAssets() {
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 assets = utils.discoverAssets(filePath);
if (!assets.skeletonPath || !assets.atlasPath) {
if (!assets.skeletonPath) {
showError(t('viewer.noSkeletonFile'), t('viewer.noSkeletonFileMsg'));
} else {
showError(t('viewer.noAtlas'), t('viewer.noAtlasMsg'));
}
return;
}
let savedPma;
try {
const settings = require('../js/spine-preview-settings.js');
const userData = await settings.resolveDataPath(typeof eagle !== 'undefined' ? eagle.app : null);
previewSettings = settings.createStore(userData, filePath, urlParams.get('id'));
savedPma = previewSettings.read();
} catch (e) { log('读取预乘设置失败: ' + e.message); }
// 检测版本并选择运行时
const version = utils.detectVersion(assets.skeletonPath);
activeVersion = version;
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
showError(t('viewer.unsupportedVersion'), t('viewer.unsupportedVersionMsg', {
version: version || t('viewer.unknownVersion'),
list: '3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3'
}));
return;
}
activeRuntimeDir = runtimeDir;
document.getElementById('verLabel').textContent = 'Spine ' + (version || runtimeDir);
try {
await loadRuntime(runtimeDir);
} catch (e) {
showError(t('viewer.runtimeLoadFailed'), e.message);
return;
}
// 读取骨骼数据(直接用 Node fs 读取,兼容跨文件夹)
let skelData;
if (assets.isBinary) {
skelData = new Uint8Array(fs.readFileSync(assets.skeletonPath));
} else {
skelData = fs.readFileSync(assets.skeletonPath, 'utf8');
}
// 读取 atlas 文本并构造 TextureAtlas(跨 item 文件夹加载贴图)
let atlas;
try {
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
const atlasPma = utils.parseAtlasPma(atlasText);
// 用户明确切换过的设置用于下次预览;没有用户设置时,以 Atlas 为准。
premultipliedAlpha = savedPma !== undefined ? savedPma : (atlasPma !== undefined ? atlasPma : false);
document.getElementById('pmaCheck').checked = premultipliedAlpha;
atlas = await buildBrowserAtlas(spine, atlasText, assets);
} catch (e) {
showError(t('viewer.atlasLoadFailed'), (e && e.message) || String(e));
return;
}
// 创建骨骼加载器
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
const loader = assets.isBinary ? new spine.SkeletonBinary(atlasLoader) : new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
let skeletonData2;
if (assets.isBinary) {
skeletonData2 = loader.readSkeletonData(skelData);
} else {
skeletonData2 = loader.readSkeletonData(skelData);
}
if (!skeletonData2 || !skeletonData2.bones || skeletonData2.bones.length === 0) {
showError(t('viewer.emptySkeleton'), t('viewer.emptySkeletonMsg'));
return;
}
skeletonData = skeletonData2;
// 创建骨架与动画状态
skeleton = new spine.Skeleton(skeletonData);
currentSkin = utils.preferredSkin(skeletonData);
utils.applySkin(skeleton, currentSkin);
stateData = new spine.AnimationStateData(skeletonData);
stateData.defaultMix = 0.2;
state = new spine.AnimationState(stateData);
renderer = clippingRenderer.createRenderer(spine, ctx, runtimeDir);
renderer.premultipliedAlpha = premultipliedAlpha;
document.getElementById('pmaCheck').disabled = false;
fillAnimations();
fillSkins(currentSkin);
// 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。
if (skeletonData.animations.length > 0) {
const defaultAnim = skeletonData.animations[0];
state.setAnimation(0, defaultAnim.name, loop);
const idx = skeletonData.animations.indexOf(defaultAnim);
if (idx >= 0) selectAnimationItem(idx);
updateTimelineDuration(defaultAnim);
} else {
updateTimelineDuration(null);
}
updateWorld(skeleton);
fitToCanvas();
draw();
loadingEl.classList.add('hidden');
running = true;
lastTime = performance.now();
requestAnimationFrame(renderLoop);
updatePlayButton();
}
// ---------- UI 事件绑定 ----------
function updatePlayButton() {
const btn = document.getElementById('playBtn');
btn.disabled = isStaticAnimation;
if (isStaticAnimation) {
btn.textContent = t('viewer.static');
btn.classList.remove('active');
return;
}
btn.textContent = playing ? t('viewer.pause') : t('viewer.play');
btn.classList.toggle('active', !playing);
}
document.getElementById('playBtn').addEventListener('click', () => {
if (isStaticAnimation) return;
playing = !playing; updatePlayButton();
});
animationList.addEventListener('click', (e) => {
const item = e.target.closest('.animation-item');
if (!item) return;
const idx = parseInt(item.dataset.index, 10);
const anims = skeletonData.animations;
if (anims && anims[idx]) setAnimationByName(anims[idx].name, idx);
});
document.getElementById('skinSelect').addEventListener('change', (e) => {
const idx = parseInt(e.target.value, 10);
const skins = skeletonData.skins;
if (skins && skins[idx]) applySkinByName(skins[idx].name);
});
document.getElementById('loopCheck').addEventListener('change', (e) => {
loop = e.target.checked;
const cur = currentTrack();
if (cur) cur.loop = loop;
});
document.getElementById('speedRange').addEventListener('input', (e) => {
speed = parseFloat(e.target.value);
document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x';
});
document.getElementById('backgroundCheck').addEventListener('change', () => draw());
document.getElementById('pmaCheck').addEventListener('change', (e) => {
premultipliedAlpha = e.target.checked;
if (renderer) renderer.premultipliedAlpha = premultipliedAlpha;
draw();
try {
if (!previewSettings) throw new Error(t('viewer.settingsNotReady'));
previewSettings.write(premultipliedAlpha);
setStatus(t('viewer.pmaRemembered', { state: premultipliedAlpha ? t('viewer.pmaOn') : t('viewer.pmaOff') }), 1800);
} catch (err) {
setStatus(t('viewer.pmaSaveFailed', { error: err.message }), 5000);
}
});
document.getElementById('reloadBtn').addEventListener('click', () => {
window.location.reload();
});
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
function seekTimelineToNearestFrame() {
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
timeline.value = String(snappedTime);
seekTo(snappedTime);
}
// 时间轴:拖动时按 30 FPS 整数帧实时预览。
timeline.addEventListener('input', (e) => {
if (state) seekTimelineToNearestFrame();
});
// 时间轴:开始拖动即暂停,松开后吸附到最近的 30 FPS 整数帧并保持暂停。
timeline.addEventListener('pointerdown', () => {
seeking = true;
playing = false;
updatePlayButton();
});
const endTimelineSeek = () => {
if (!seeking) return;
try {
seekTimelineToNearestFrame();
} finally {
// 无论吸附是否抛错都要复位,否则 seeking 会永久卡住导致时间轴不再同步。
seeking = false;
playing = false;
updatePlayButton();
timeline.blur();
}
};
timeline.addEventListener('pointerup', endTimelineSeek);
// 指针移出滑块后松开时也要完成吸附。
window.addEventListener('pointerup', endTimelineSeek);
timeline.addEventListener('change', () => {
if (seeking || timeline.disabled) return;
seekTimelineToNearestFrame();
timeline.blur();
});
// ---------- 画布交互:左键拖拽平移,右键回中心,滚轮缩放 ----------
canvas.addEventListener('pointerdown', (e) => {
if (e.button === 0) { // 左键
dragging = true;
dragStartX = e.clientX; dragStartY = e.clientY;
dragStartPanX = panX; dragStartPanY = panY;
canvas.setPointerCapture(e.pointerId);
} else if (e.button === 2) { // 右键:回到画布中心
panX = 0; panY = 0; zoom = 1;
fitToCanvas();
draw();
setStatus(t('viewer.recentered'));
}
});
canvas.addEventListener('pointermove', (e) => {
if (!dragging) return;
// 直接按屏幕像素增量平移(clientX/Y 差值即像素位移,无需换算)
panX = dragStartPanX + (e.clientX - dragStartX);
panY = dragStartPanY + (e.clientY - dragStartY);
draw();
});
canvas.addEventListener('pointerup', () => { dragging = false; });
canvas.addEventListener('pointercancel', () => { dragging = false; });
// 右键菜单拦截
canvas.addEventListener('contextmenu', (e) => e.preventDefault());
// 滚轮缩放(以画布中心为锚点,更快更跟手)
canvas.addEventListener('wheel', (e) => {
e.preventDefault();
const factor = e.deltaY < 0 ? 1.15 : 1 / 1.15; // 每格缩放 15%
zoom = Math.min(50, Math.max(0.02, zoom * factor));
fitToCanvas();
draw();
}, { passive: false });
window.addEventListener('keydown', (e) => {
if (e.code === 'Space' && !isStaticAnimation) { e.preventDefault(); playing = !playing; updatePlayButton(); }
});
window.addEventListener('resize', () => {
if (!running) return;
fitToCanvas();
draw();
});
// ---------- 启动 ----------
if (!filePath) {
showError(t('viewer.missingFile'), t('viewer.missingFileMsg'));
} else if (!fs.existsSync(filePath)) {
showError(t('viewer.fileNotExist'), filePath);
} else {
loadAssets().catch((err) => {
showError(t('viewer.loadFailed'), (err && err.message) ? err.message : String(err));
});
}
</script>
</body>
</html>