插件优化

This commit is contained in:
tianmo committed 2026-09-08 19:21:52 +08:00
1 parent 91815a355b
commit 7263a841cb
2 files changed
+298 -104

No files matched your search

+3
View File
@@ -53,7 +53,10 @@ SpineViewer/
- 播放 / 暂停(空格)
- 循环开关
- 播放速度调节
- 时间轴拖动、动画时长、秒刻度与关键帧标记
- 零时长动画按单帧静态姿势预览
- 自动适配并居中
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。
---
+295 -104
View File
@@ -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 @@
</div>
</div>
<div id="timelineBar">
<span class="tl-label">帧</span>
<div id="timelineLabels">
<span class="tl-label" id="durationLabel">时长 --</span>
<span id="keyframeLegend" 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">0 / 0</span>
<span id="timeLabel">0.00 / 0.00 秒 · 0 / 0 帧</span>
</div>
</div>
@@ -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 默认缩略图 <stem>_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;