diff --git a/README.md b/README.md index 388764c..9bc89ef 100644 --- a/README.md +++ b/README.md @@ -53,7 +53,10 @@ SpineViewer/ - 播放 / 暂停(空格) - 循环开关 - 播放速度调节 + - 时间轴拖动、动画时长、秒刻度与关键帧标记 + - 零时长动画按单帧静态姿势预览 - 自动适配并居中 + - 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。 --- diff --git a/viewer/spine.html b/viewer/spine.html index a2d9700..946067f 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -58,22 +58,48 @@ font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3); } #timelineBar { - display: flex; align-items: center; gap: 8px; + display: flex; align-items: center; gap: 10px; padding: 6px 12px; background: var(--panel); border-top: 1px solid var(--border); } + #timelineLabels { + display: flex; flex-direction: column; justify-content: space-between; + align-self: stretch; min-width: 136px; padding: 8px 0 1px; + } #timelineBar .tl-label { color: var(--text-dim); font-size: 12px; white-space: nowrap; } - #timelineBar .tl-wrap { position: relative; flex: 1; padding: 8px 0; } + #keyframeLegend { + display: flex; align-items: center; gap: 6px; color: var(--text-dim); + font-size: 11px; white-space: nowrap; cursor: help; + } + #keyframeLegend::before { + content: ''; width: 10px; height: 7px; border-radius: 1px; + background: var(--accent); opacity: .7; + } + #timelineBar .tl-wrap { position: relative; flex: 1; padding-top: 20px; } #timelineBar input[type="range"] { display: block; width: 100%; margin: 0; accent-color: var(--accent); } + #timeRuler { position: absolute; top: 0; left: 7px; right: 7px; height: 19px; } + #timeRuler .tick { + position: absolute; bottom: 0; width: 1px; height: 5px; + background: var(--border); transform: translateX(-.5px); + } + #timeRuler .tick.major { height: 9px; background: var(--text-dim); } + #timeRuler .tick-label { + position: absolute; bottom: 8px; color: var(--text-dim); font-size: 10px; + font-variant-numeric: tabular-nums; transform: translateX(-50%); white-space: nowrap; + } + #timeRuler .tick-label.first { transform: none; } + #timeRuler .tick-label.last { transform: translateX(-100%); } #keyframes { position: relative; height: 8px; margin-top: 2px; } #keyframes span { - position: absolute; top: 0; width: 2px; height: 8px; - background: var(--accent); opacity: .55; transform: translateX(-1px); + position: absolute; top: 0; width: 3px; height: 8px; + background: var(--accent); opacity: .62; transform: translateX(-1.5px); + cursor: help; transition: opacity .12s, transform .12s; } + #keyframes span:hover { opacity: 1; transform: translateX(-1.5px) scaleY(1.35); } #timeLabel { - color: var(--text-dim); font-size: 12px; min-width: 110px; + color: var(--text-dim); font-size: 12px; min-width: 178px; text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; } #overlay { @@ -127,12 +153,16 @@
- 帧 +
+ 时长 -- + 蓝色短线:关键帧 +
+
- 0 / 0 + 0.00 / 0.00 秒 · 0 / 0 帧
@@ -166,7 +196,10 @@ const statusEl = document.getElementById('status'); const timeline = document.getElementById('timeline'); const timeLabel = document.getElementById('timeLabel'); + const durationLabel = document.getElementById('durationLabel'); + const timeRuler = document.getElementById('timeRuler'); const keyframesEl = document.getElementById('keyframes'); + keyframesEl.title = '蓝色短线表示动画关键帧;悬停短线可查看时间和帧号'; // ---------- 全局状态 ---------- let skeleton = null, state = null, stateData = null, skeletonData = null; @@ -176,8 +209,11 @@ let timeKeeper = null, lastTime = 0, running = false; let seeking = false; // 用户正在拖动时间轴 let duration = 0; // 当前动画时长 + let isStaticAnimation = false; let wasPlayingBeforeSeek = false; // 记录拖动前是否在播放 const FPS = 30; + const STATIC_EPSILON = 1e-6; + let statusHoldUntil = 0; // 画布平移 / 缩放状态(左键拖拽平移,滚轮缩放,右键回中心) let panX = 0, panY = 0, zoom = 1; @@ -192,7 +228,10 @@ document.getElementById('errorMsg').textContent = msg; } - function setStatus(msg) { statusEl.textContent = msg; } + function setStatus(msg, holdMs = 0) { + statusEl.textContent = msg; + statusHoldUntil = holdMs === Infinity ? Infinity : (holdMs > 0 ? performance.now() + holdMs : 0); + } // ---------- 动态加载本地 Spine 运行时 ---------- function loadRuntime(dir) { @@ -332,71 +371,97 @@ } // ---------- 生成当前帧缩略图(400×400,自适应骨骼包围盒并居中)---------- - function generateThumbnail() { + function eagleItemIdFromPath(sourcePath) { + const match = path.basename(path.dirname(sourcePath)).match(/^(.+)\.info$/i); + return match ? match[1] : null; + } + + 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 && result.status !== 'success')) { + const reason = result && (result.message || result.error); + throw new Error(reason || ('Eagle API 返回 HTTP ' + response.status)); + } + return result; + } + + async function generateThumbnail() { if (!skeleton || !renderer) { setStatus('尚未加载骨骼,无法生成缩略图'); return; } - // 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼 - playing = false; - updatePlayButton(); - if (state) state.apply(skeleton); - updateWorld(skeleton); - - const size = 400; - const bounds = getBounds(skeleton); - // 离屏 canvas 用于导出 PNG - 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); - // 用独立的 renderer 绑定离屏 ctx - let offRenderer = renderer; - try { - if (spine.canvas && spine.canvas.SkeletonRenderer) offRenderer = new spine.canvas.SkeletonRenderer(octx); - else if (spine.SkeletonRenderer) offRenderer = new spine.SkeletonRenderer(octx); - } catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ } - if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true; - offRenderer.draw(skeleton); - octx.restore(); - } - - // 导出 PNG 并保存到文件旁(覆盖 Eagle 默认缩略图 _thumbnail.png) - // 用 WebP 编码以显著减小体积(Eagle 的缩略图本身也是 WebP 格式,尽管扩展名为 .png) - let out = null; + const thumbBtn = document.getElementById('thumbBtn'); + thumbBtn.disabled = true; + setStatus('正在生成并更新 Eagle 缩略图…', Infinity); try { - out = off.toDataURL('image/webp'); - } catch (e) { - out = off.toDataURL('image/png'); // 回退 PNG - } - const b64 = out.split(',')[1]; - const buf = Buffer.from(b64, 'base64'); + // 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼 + playing = false; + updatePlayButton(); + if (state) state.apply(skeleton); + updateWorld(skeleton); - // 覆盖默认缩略图:用文件基名(stem)+ _thumbnail.png,即 Eagle 管理缩略图的文件名 - const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, ''); - let outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png'); - try { - fs.writeFileSync(outPath, buf); - setStatus('已更新缩略图: ' + path.basename(outPath) + ' (' + (buf.length / 1024).toFixed(1) + 'KB)'); - log('生成缩略图: ' + outPath + ' (' + buf.length + 'B)'); - } catch (e) { - // 若无法写入(无目录写权限),回退为下载 - try { - const a = document.createElement('a'); - a.href = out; a.download = stem + '_thumbnail.png'; - document.body.appendChild(a); a.click(); a.remove(); - setStatus('已通过下载保存缩略图'); - } catch (e2) { - setStatus('生成缩略图失败: ' + ((e && e.message) || String(e))); + 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); + let offRenderer = renderer; + try { + if (spine.canvas && spine.canvas.SkeletonRenderer) offRenderer = new spine.canvas.SkeletonRenderer(octx); + else if (spine.SkeletonRenderer) offRenderer = new spine.SkeletonRenderer(octx); + } catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ } + if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true; + offRenderer.draw(skeleton); + octx.restore(); } + + // Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。 + let out = off.toDataURL('image/webp', 0.9); + const isWebp = out.startsWith('data:image/webp'); + if (!isWebp) out = off.toDataURL('image/png'); + const buf = Buffer.from(out.split(',')[1], 'base64'); + const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, ''); + const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath); + const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png'); + const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png')); + try { + if (!itemId) throw new Error('无法从素材路径识别 Eagle 项目 ID'); + fs.writeFileSync(tempPath, buf); + await setEagleCustomThumbnail(itemId, tempPath); + setStatus('Eagle 缩略图已更新 · ' + (isWebp ? 'WebP' : 'PNG') + ' ' + (buf.length / 1024).toFixed(1) + 'KB', 3500); + log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)'); + } catch (e) { + // 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。 + try { + fs.writeFileSync(outPath, buf); + setStatus('缩略图已写入;当前 Eagle 版本未能自动刷新,请按 Cmd/Ctrl+R', 6000); + log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e))); + } catch (e2) { + setStatus('生成缩略图失败: ' + ((e2 && e2.message) || String(e2)), 6000); + } + } finally { + try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ } + } + } catch (e) { + setStatus('生成缩略图失败: ' + ((e && e.message) || String(e)), 6000); + log('生成当前帧缩略图失败: ' + ((e && e.stack) || e)); + } finally { + thumbBtn.disabled = false; } } @@ -414,17 +479,17 @@ } updateWorld(skeleton); draw(); - const time = state.getCurrent(0); - if (time) { - setStatus(animName(time.animation ? time.animation.name : '') + ' · ' + time.time.toFixed(2) + 's'); - // 同步时间轴(若用户未在拖动),用取模避免循环时滑块越界 + const entry = state.getCurrent(0); + if (entry) { + const currentTime = getTrackAnimationTime(entry); + if (now >= statusHoldUntil) { + setStatus(animName(entry.animation ? entry.animation.name : '') + ' · ' + (isStaticAnimation ? '静帧' : formatSeconds(currentTime))); + } + // 同步时间轴(若用户未在拖动)。兼容 3.x 的 time 与 4.x 的 trackTime/getAnimationTime。 if (!seeking) { - const t = (loop && duration > 0) ? (time.time % duration) : time.time; - // 归一化到 [0, duration],避免越界 - const norm = (duration > 0) ? Math.max(0, Math.min(t, duration)) : 0; - if (timeline.max !== String(Math.max(duration, 0.001))) timeline.max = String(Math.max(duration, 0.001)); + const norm = isStaticAnimation ? 0 : Math.max(0, Math.min(currentTime, duration)); timeline.value = String(norm); - timeLabel.textContent = frameOf(norm) + ' / ' + frameOf(duration) + ' 帧'; + updateTimeReadout(norm); } } } @@ -435,6 +500,123 @@ return Math.round((seconds || 0) * FPS); } + function formatSeconds(seconds) { + const value = Math.max(0, Number(seconds) || 0); + if (value < 10) return value.toFixed(2) + ' 秒'; + if (value < 60) return value.toFixed(1) + ' 秒'; + const minutes = Math.floor(value / 60); + return minutes + ':' + (value % 60).toFixed(1).padStart(4, '0'); + } + + function getTrackAnimationTime(entry) { + if (!entry) return 0; + try { + if (typeof entry.getAnimationTime === 'function') { + const value = entry.getAnimationTime(); + if (Number.isFinite(value)) return Math.max(0, Math.min(value, duration)); + } + } catch (e) { /* fall through to version-specific fields */ } + const raw = Number.isFinite(entry.trackTime) ? entry.trackTime : (Number.isFinite(entry.time) ? entry.time : 0); + if (loop && duration > STATIC_EPSILON) return raw % duration; + return Math.max(0, Math.min(raw, duration)); + } + + function animationDuration(anim) { + const declared = anim && Number(anim.duration); + if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared; + const times = collectKeyframeTimes(anim); + return times.length ? Math.max.apply(null, times) : 0; + } + + function collectKeyframeTimes(anim) { + if (!anim || !anim.timelines) return []; + const times = new Set(); + for (const tl of anim.timelines) { + const frames = tl && tl.frames; + if (!frames || !frames.length) continue; + try { + if (Array.isArray(frames[0])) { + for (const frame of frames) { + if (frame && Number.isFinite(frame[0])) times.add(frame[0]); + } + continue; + } + const frameCount = typeof tl.getFrameCount === 'function' ? tl.getFrameCount() : 0; + const entries = typeof tl.getFrameEntries === 'function' + ? tl.getFrameEntries() + : (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1); + if (frameCount > 0) { + for (let i = 0; i < frameCount; i++) { + const value = frames[i * entries]; + if (Number.isFinite(value)) times.add(value); + } + } else { + // 极旧运行时中没有帧数接口的 timeline,其 frames 通常只保存时间。 + for (const value of frames) if (Number.isFinite(value) && value >= 0) times.add(value); + } + } catch (e) { /* ignore malformed timeline */ } + } + return [...times].filter((time) => time >= 0).sort((a, b) => a - b); + } + + function updateTimeReadout(time) { + if (isStaticAnimation) { + timeLabel.textContent = '静帧 · 1 / 1 帧'; + return; + } + const current = Math.max(0, Math.min(Number(time) || 0, duration)); + timeLabel.textContent = formatSeconds(current) + ' / ' + formatSeconds(duration) + ' · ' + frameOf(current) + ' / ' + frameOf(duration) + ' 帧'; + } + + function niceTickInterval(totalSeconds) { + const target = totalSeconds / 8; + const choices = [1 / 30, 0.1, 0.2, 0.5, 1, 2, 5, 10, 15, 30, 60, 120, 300, 600]; + for (const choice of choices) if (choice >= target) return choice; + return Math.pow(10, Math.ceil(Math.log10(target))); + } + + function renderTimeRuler() { + timeRuler.innerHTML = ''; + if (isStaticAnimation || duration <= STATIC_EPSILON) { + const label = document.createElement('span'); + label.className = 'tick-label first'; + label.style.left = '0%'; + label.textContent = '0 秒(静帧)'; + timeRuler.appendChild(label); + return; + } + const major = niceTickInterval(duration); + const minor = major / 5; + const count = Math.floor(duration / minor); + for (let i = 0; i <= count; i++) { + const value = Math.min(i * minor, duration); + if (value > duration + STATIC_EPSILON) break; + const percent = (value / duration) * 100; + const isMajor = i % 5 === 0 || Math.abs(value - duration) < STATIC_EPSILON; + const tick = document.createElement('span'); + tick.className = 'tick' + (isMajor ? ' major' : ''); + tick.style.left = percent + '%'; + timeRuler.appendChild(tick); + if (isMajor) { + const label = document.createElement('span'); + label.className = 'tick-label' + (i === 0 ? ' first' : (Math.abs(value - duration) < STATIC_EPSILON ? ' last' : '')); + label.style.left = percent + '%'; + label.textContent = value < 10 ? value.toFixed(1) + 's' : Math.round(value) + 's'; + timeRuler.appendChild(label); + } + if (Math.abs(value - duration) < STATIC_EPSILON) break; + } + // 时长不是整刻度时,确保终点始终有明确标签。 + if (Math.abs((duration / minor) - Math.round(duration / minor)) > STATIC_EPSILON) { + const tick = document.createElement('span'); + tick.className = 'tick major'; tick.style.left = '100%'; timeRuler.appendChild(tick); + const label = document.createElement('span'); + label.className = 'tick-label last'; label.style.left = '100%'; + label.textContent = duration < 10 ? duration.toFixed(2) + 's' : duration.toFixed(1) + 's'; + timeRuler.appendChild(label); + } + } + function animName(name) { const sel = document.getElementById('animSelect'); return sel.options[sel.selectedIndex] ? sel.options[sel.selectedIndex].text : ''; @@ -469,6 +651,10 @@ const anim = findAnimByName(name); state.setAnimation(0, name, loop); updateTimelineDuration(anim); + state.update(0); + state.apply(skeleton); + updateWorld(skeleton); + draw(); } // 跨版本地通过名字查找动画(Spine 3.x / 4.x 都有 animations 数组) @@ -482,33 +668,32 @@ // 更新时间轴范围(max = 动画时长),并把滑块归零,同时渲染关键帧标记 function updateTimelineDuration(anim) { - duration = (anim && anim.duration) ? anim.duration : 0; - timeline.max = String(Math.max(duration, 0.001)); + 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 = duration <= 0; - timeLabel.textContent = '0 / ' + frameOf(duration) + ' 帧'; + timeline.disabled = !anim || isStaticAnimation; + durationLabel.textContent = isStaticAnimation ? '静帧 · 1 帧' : ('时长 ' + formatSeconds(duration) + ' · ' + frameOf(duration) + ' 帧'); + updateTimeReadout(0); + renderTimeRuler(); renderKeyframeMarkers(anim); + updatePlayButton(); } // 渲染关键帧标记(收集各 timeline 的关键时间点,用竖线标出) function renderKeyframeMarkers(anim) { keyframesEl.innerHTML = ''; - if (!anim || !anim.timelines || duration <= 0) return; - const times = new Set(); - for (const tl of anim.timelines) { - if (!tl || !tl.frames) continue; - // frames 数组在 3.x/4.x 结构不同:3.x 是 [time, value, time, value...],4.x 是 [[time,value],...] 或带 keyframes - try { - if (Array.isArray(tl.frames[0])) { - for (const f of tl.frames) { if (Array.isArray(f) && typeof f[0] === 'number') times.add(f[0]); } - } else { - for (let i = 0; i < tl.frames.length; i += 2) { - if (typeof tl.frames[i] === 'number') times.add(tl.frames[i]); - } - } - } catch (e) { /* ignore */ } + if (!anim) return; + if (isStaticAnimation) { + const span = document.createElement('span'); + span.style.left = '0%'; + span.title = '静帧姿势 · 第 1 帧'; + span.setAttribute('aria-label', span.title); + keyframesEl.appendChild(span); + return; } - const sorted = [...times].filter((t) => t >= 0 && t <= duration).sort((a, b) => a - b); + const sorted = collectKeyframeTimes(anim).filter((time) => time <= duration); // 限制数量,避免过多标记 const maxMarkers = 200; const step = Math.max(1, Math.ceil(sorted.length / maxMarkers)); @@ -516,6 +701,8 @@ const t = sorted[i]; const span = document.createElement('span'); span.style.left = (duration > 0 ? (t / duration) * 100 : 0) + '%'; + span.title = '关键帧 · ' + formatSeconds(t) + ' · 第 ' + frameOf(t) + ' 帧'; + span.setAttribute('aria-label', span.title); keyframesEl.appendChild(span); } } @@ -535,7 +722,7 @@ state.apply(skeleton); updateWorld(skeleton); draw(); - timeLabel.textContent = frameOf(time) + ' / ' + frameOf(duration) + ' 帧'; + updateTimeReadout(time); } catch (e) { /* ignore */ } } @@ -655,12 +842,9 @@ fillAnimations(); fillSkins(); - // 默认播放第一个“有时长”的动画(避免选中 duration=0 的空动画导致时间轴失效) + // 默认使用导出顺序中的第一个动画;duration=0 的动画按静帧处理。 if (skeletonData.animations.length > 0) { - let defaultAnim = skeletonData.animations[0]; - for (const anim of skeletonData.animations) { - if (anim.duration > 0) { defaultAnim = anim; break; } - } + const defaultAnim = skeletonData.animations[0]; state.setAnimation(0, defaultAnim.name, loop); // 同步下拉框选中项 const sel = document.getElementById('animSelect'); @@ -685,11 +869,18 @@ // ---------- UI 事件绑定 ---------- function updatePlayButton() { const btn = document.getElementById('playBtn'); + btn.disabled = isStaticAnimation; + if (isStaticAnimation) { + btn.textContent = '▮ 静帧'; + btn.classList.remove('active'); + return; + } btn.textContent = playing ? '⏸ 暂停' : '▶ 播放'; btn.classList.toggle('active', !playing); } document.getElementById('playBtn').addEventListener('click', () => { + if (isStaticAnimation) return; playing = !playing; updatePlayButton(); }); document.getElementById('animSelect').addEventListener('change', (e) => { @@ -774,7 +965,7 @@ }, { passive: false }); window.addEventListener('keydown', (e) => { - if (e.code === 'Space') { e.preventDefault(); playing = !playing; updatePlayButton(); } + if (e.code === 'Space' && !isStaticAnimation) { e.preventDefault(); playing = !playing; updatePlayButton(); } }); window.addEventListener('resize', () => { if (!running) return;