diff --git a/README.md b/README.md index 9bc89ef..7893b7f 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,10 @@ SpineViewer/ ├─ manifest.json # 插件清单(预览扩展注册) ├─ logo.png # 插件图标 +├─ assets/ +│ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge ├─ thumbnail/ -│ └─ spine.js # 缩略图生成(渲染骨骼首帧为 PNG) +│ └─ spine.js # 缩略图生成(渲染骨骼首帧并交由 Eagle 缓存) ├─ viewer/ │ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配) ├─ js/ @@ -57,7 +59,7 @@ SpineViewer/ - 零时长动画按单帧静态姿势预览 - 自动适配并居中 - 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 -4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧生成 PNG。若该依赖不可用,会自动回退为一条纯 JS 生成的占位 PNG。 +4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,便于识别数据文件。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。 --- @@ -71,7 +73,7 @@ SpineViewer/ ## 关于新版/旧版二进制 - **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。 -- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时加载。插件会检测到这种情况并给出提示(缩略图显示占位图),建议改用 **JSON** 导出即可正常预览。 +- **3.6 / 3.7** 的官方 JS 运行时 **不包含二进制解析器**,且其二进制格式与 3.8 不兼容。因此这两版的 `.skel` / `.skel.bytes` 无法由官方 JS 运行时直接加载。同批存在 **JSON** 时,插件会用 JSON 为 `atlas/json/skel` 三个条目生成相同的骨骼缩略图;没有 JSON 时则显示实际 atlas 贴图预览。 --- diff --git a/assets/README.md b/assets/README.md new file mode 100644 index 0000000..9dd147e --- /dev/null +++ b/assets/README.md @@ -0,0 +1,5 @@ +# Spine badge + +`spine-badge.png` 下载自 Esoteric Software 官方 [Spine Brand assets](https://esotericsoftware.com/branding) 页面。 + +插件只在缩略图右下角等比缩放显示该 badge,用于标明条目是 Spine 数据文件;图形本身未做拉伸、变色或其他修改。 diff --git a/assets/spine-badge.png b/assets/spine-badge.png new file mode 100644 index 0000000..422f11a Binary files /dev/null and b/assets/spine-badge.png differ diff --git a/js/spine-badge.js b/js/spine-badge.js new file mode 100644 index 0000000..655addd --- /dev/null +++ b/js/spine-badge.js @@ -0,0 +1,14 @@ +const fs = require('fs'); +const path = require('path'); + +const badgePath = path.join(__dirname, '..', 'assets', 'spine-badge.png'); +let cachedDataUrl = null; + +function dataUrl() { + if (!cachedDataUrl) { + cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgePath).toString('base64'); + } + return cachedDataUrl; +} + +module.exports = { path: badgePath, dataUrl }; diff --git a/js/spine-render.js b/js/spine-render.js index 06d5d26..e3ac196 100644 --- a/js/spine-render.js +++ b/js/spine-render.js @@ -7,6 +7,7 @@ const fs = require('fs'); const vm = require('vm'); const path = require('path'); const utils = require('./spine-utils.js'); +const spineBadge = require('./spine-badge.js'); let canvasLib = null; try { @@ -15,6 +16,22 @@ try { canvasLib = null; } +let spineBadgePromise = null; + +/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */ +async function drawSpineBadge(ctx, width, height) { + if (!canvasLib || !ctx) return; + try { + if (!spineBadgePromise) { + spineBadgePromise = canvasLib.loadImage(spineBadge.path); + } + const badge = await spineBadgePromise; + const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15)); + const margin = Math.max(10, Math.round(badgeSize * 0.2)); + ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize); + } catch (e) { /* 缺少 badge 时不影响缩略图主体 */ } +} + /** * 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。 * 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x @@ -188,8 +205,9 @@ async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, ctx.restore(); } + await drawSpineBadge(ctx, cw, ch); const buf = canvas.toBuffer('image/png'); return buf; } -module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, canvasLib }; +module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib }; diff --git a/js/spine-utils.js b/js/spine-utils.js index 0af3e2e..760a25a 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -142,6 +142,21 @@ function btimeOf(fp) { return (d && d.btime) || 0; } +/** + * Eagle item 的实际入库时间。 + * metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的 + * btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。 + */ +function importTimeOf(fp) { + try { + const metaPath = path.join(path.dirname(fp), 'metadata.json'); + const stat = fs.statSync(metaPath); + return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0; + } catch (e) { + return btimeOf(fp); + } +} + /** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */ function versionFamily(version) { if (!version) return null; @@ -343,7 +358,10 @@ function resolveRealSource(filePath) { * 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... } * - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 } */ -function discoverAssets(filePath) { +function discoverAssets(filePath, options) { + options = options || {}; + const strictStem = !!options.strictStem; + const preferCompatibleJson = !!options.preferCompatibleJson; filePath = resolveRealSource(filePath); const selected = path.basename(filePath); const selectedStem = normalizeStem(selected); @@ -353,9 +371,11 @@ function discoverAssets(filePath) { // 则扩展搜索到整个库的 images/ 目录(全库兜底,靠“同家族 + 同文件夹 + 最近 btime”配对)。 buildRootsWithLibraryRoot(filePath, roots); - // 只匹配选中文件所在的 Eagle 文件夹(及子文件夹)内的资源,避免串到其他项目 + // 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入 + // folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。 const validFolders = getValidFolders(filePath); - const files = collectSpineFiles(roots, validFolders); + let files = collectSpineFiles(roots, validFolders); + if (strictStem) files = collectSpineFiles(roots, null); const skeletonFiles = files.filter((f) => isSkeletonFile(f.name)); const atlasFiles = files.filter((f) => isAtlasFile(f.name)); @@ -367,22 +387,29 @@ function discoverAssets(filePath) { return true; }); - // 工具:优先选择与 refFile 位于同一个 Eagle 逻辑文件夹的候选; - // 同文件夹内有多个时,再按“创建时间 btime 最接近”选取(视为同一批导入)。 + // 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。 + // 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。 const selectBest = (cands, refFile) => { if (!cands || cands.length === 0) return null; const refFolders = foldersOfItem(refFile); + const refImportTime = importTimeOf(refFile); const refBtime = btimeOf(refFile); - const scored = cands.map((c) => { - let score = 0; + const ranked = cands.map((c) => { const cFolders = foldersOfItem(c.fullPath); - if (refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1)) score += 1e6; - // btime 越接近越好(差值作为负加成) - score += -Math.abs(btimeOf(c.fullPath) - refBtime); - return { c, score }; + return { + c, + importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER, + sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0, + btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER, + }; }); - scored.sort((a, b) => b.score - a.score); - return scored[0].c; + ranked.sort((a, b) => + a.importDelta - b.importDelta || + b.sameFolder - a.sameFolder || + a.btimeDelta - b.btimeDelta || + a.c.fullPath.localeCompare(b.c.fullPath) + ); + return ranked[0].c; }; // 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族 @@ -398,7 +425,8 @@ function discoverAssets(filePath) { let skeleton = null; // 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本) if (isSkeletonFile(selected)) { - skeleton = realSkeletonFiles.find((f) => f.name === selected) || null; + skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) || + realSkeletonFiles.find((f) => f.name === selected) || null; } if (!skeleton) { // 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入 @@ -410,19 +438,43 @@ function discoverAssets(filePath) { skeleton = cands.length > 0 ? selectBest(cands, filePath) : null; } if (!skeleton && realSkeletonFiles.length > 0) { - // 忽略 stem:优先与选中 atlas 同家族,再按同 Eagle 文件夹 + 最近 btime 选取 - let cands = realSkeletonFiles; - if (selectedAtlasFamily) { - const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily); - if (same.length > 0) cands = same; + if (strictStem) { + skeleton = null; + } else { + // 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取 + let cands = realSkeletonFiles; + if (selectedAtlasFamily) { + const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily); + if (same.length > 0) cands = same; + } + skeleton = selectBest(cands, filePath); + } + } + + // Spine 3.6/3.7 的官方 JS 运行时不能读取对应二进制格式。若同批导入中 + // 同时带有 JSON,则 atlas 和 skel 条目都用该 JSON 渲染真实缩略图。 + if (preferCompatibleJson && skeleton && isSkeletonFile(skeleton.name)) { + let selectedVersion = null; + try { selectedVersion = detectVersion(skeleton.fullPath); } catch (e) { /* ignore */ } + const isOldBinary = /\.skel(?:\.bytes)?$/i.test(skeleton.name) && selectedVersion && parseFloat(selectedVersion) < 3.8; + if (isOldBinary) { + const stem = normalizeStem(skeleton.name); + const jsonCands = realSkeletonFiles.filter((f) => + f.name.toLowerCase().endsWith('.json') && + normalizeStem(f.name) === stem && + familyOfSkel(f) === familyOfSkel(skeleton) && + (!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= 30000) + ); + const jsonFallback = selectBest(jsonCands, skeleton.fullPath); + if (jsonFallback) skeleton = jsonFallback; } - skeleton = selectBest(cands, filePath); } // 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas let atlas = null; if (isAtlasFile(selected)) { - atlas = atlasFiles.find((f) => f.name === selected) || null; + atlas = atlasFiles.find((f) => f.fullPath === filePath) || + atlasFiles.find((f) => f.name === selected) || null; } // 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。 // 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。 @@ -446,10 +498,24 @@ function discoverAssets(filePath) { atlas = pickAtlas(cands, skeleton.fullPath); } if (!atlas && skeleton) { - // 忽略 stem:用与骨骼同 Eagle 文件夹 + 同家族(3x/4x) + 最近 btime 的 atlas - atlas = pickAtlas(atlasFiles, skeleton.fullPath); + if (strictStem) { + atlas = null; + } else { + // 忽略 stem:用与骨骼同家族且同批入库的 atlas + atlas = pickAtlas(atlasFiles, skeleton.fullPath); + } + } + if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0]; + + // 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差 + // 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。 + if (strictStem && skeleton && atlas) { + const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath)); + if (pairDelta > 30000) { + if (isAtlasFile(selected)) skeleton = null; + else atlas = null; + } } - if (!atlas && atlasFiles.length === 1) atlas = atlasFiles[0]; // 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的) let texturePaths = {}; @@ -468,7 +534,8 @@ function discoverAssets(filePath) { // 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选 let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase()); if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn)); - const hit = selectBest(cands, atlas.fullPath); + let hit = selectBest(cands, atlas.fullPath); + if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > 30000) hit = null; if (hit) texturePaths[pn] = hit.fullPath; } } @@ -484,7 +551,9 @@ function discoverAssets(filePath) { base, isBinary: skeletonIsBinary, skeletonCandidates: realSkeletonFiles.map((f) => f.name), + skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath), atlasCandidates: atlasFiles.map((f) => f.name), + atlasCandidatePaths: atlasFiles.map((f) => f.fullPath), textureCandidates: textureFiles.map((f) => f.name), }; } @@ -535,6 +604,7 @@ module.exports = { getValidFolders, mtimeOf, btimeOf, + importTimeOf, versionFamily, atlasFamily, itemMeta, diff --git a/manifest.json b/manifest.json index 6c7a7b7..12f28e1 100644 --- a/manifest.json +++ b/manifest.json @@ -1,6 +1,6 @@ { "id": "f6602400-1327-485c-98e0-78ae9fded460", - "version": "1.0.0", + "version": "1.0.3", "platform": "all", "arch": "all", "name": "SpineViewer", @@ -25,4 +25,4 @@ } } } -} \ No newline at end of file +} diff --git a/thumbnail/spine.js b/thumbnail/spine.js index 9263951..4d208db 100644 --- a/thumbnail/spine.js +++ b/thumbnail/spine.js @@ -15,33 +15,50 @@ function log(msg) { const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); /** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */ -const VERSION = 'v4'; +const VERSION = 'v6'; -/** 发现配套资源;带重试,以应对 Eagle 导入时“多文件分批写入库”的时序问题。 - * 同伴文件(骨骼/图集)可能在 Atlas 的缩略图生成后才落盘,故需等待。 - * 一旦发现即立即返回(不会傻等满窗口),窗口仅作为最坏情况的上限。 - * 采用 10 次 × 150ms ≈ 1.5 秒;间隔更密、最快感知同伴落盘。 */ -async function discoverWithRetry(src, retries = 10, delay = 150) { +/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */ +function assetSignature(assets) { + if (!assets || !assets.skeletonPath || !assets.atlasPath) return null; + let atlasText = ''; + try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; } + const pages = utils.parseAtlasPages(atlasText); + if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null; + const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name])); + const stats = []; + for (const file of paths) { + try { + const stat = fs.statSync(file); + if (!stat.isFile() || stat.size === 0) return null; + stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs)); + } catch (e) { + return null; + } + } + return stats.join('|'); +} + +/** + * Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和 + * folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把 + * 半截 atlas 或旧库中的同名骨骼渲染成占位图。 + */ +async function discoverWithRetry(src, retries = 48, delay = 250) { let assets = null; + let previousSignature = null; + let stableCount = 0; for (let i = 0; i < retries; i++) { - assets = utils.discoverAssets(src); - if (assets.skeletonPath && assets.atlasPath) return assets; + assets = utils.discoverAssets(src, { strictStem: true, preferCompatibleJson: true }); + const signature = assetSignature(assets); + if (signature && signature === previousSignature) stableCount += 1; + else stableCount = signature ? 1 : 0; + previousSignature = signature; + if (stableCount >= 3) return assets; await sleep(delay); } return assets; } -/** 渲染缩略图;失败时重试一次(应对偶发渲染错误) */ -async function renderWithRetry(opts) { - try { - return await render.renderThumbnail(opts); - } catch (e) { - log('renderThumbnail 首次失败: ' + (e && e.message) + ',重试...'); - await sleep(150); - return await render.renderThumbnail(opts); - } -} - /** * Eagle 格式扩展缩略图处理函数。 * 参数:{ src, dest, item } @@ -73,34 +90,77 @@ module.exports = async ({ src, dest, item }) => { return resolve(item); } - const assets = await discoverWithRetry(src); + let assets = await discoverWithRetry(src); // 找不到配套 Spine 资源 if (!assets.skeletonPath || !assets.atlasPath) { log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')'); - await writePlaceholder(dest, path.basename(src, utils.extOf(path.basename(src)) || '')); + if (assets.atlasPath && await writeAtlasFallback(assets, dest)) { + item.width = 400; item.height = 400; + return resolve(item); + } + await writePlaceholder(dest); item.width = 400; item.height = 400; return resolve(item); } - const version = utils.detectVersion(assets.skeletonPath); - const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary); + let version = utils.detectVersion(assets.skeletonPath); + let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary); + + // 3.6/3.7 二进制没有对应解析器。若有同批 JSON,资源发现阶段已自动切换; + // 走到这里说明没有 JSON,直接显示 atlas 实际贴图,避免无意义地重复解析。 + if (assets.isBinary && version && parseFloat(version) < 3.8) { + if (await writeAtlasFallback(assets, dest)) { + log('旧版二进制无 JSON,已使用 atlas 贴图缩略图: ' + src); + item.width = 400; item.height = 400; + return resolve(item); + } + } if (!runtimeDir) { - await writePlaceholder(dest, path.basename(assets.skeletonPath, path.extname(assets.skeletonPath))); + if (await writeAtlasFallback(assets, dest)) { + item.width = 400; item.height = 400; + return resolve(item); + } + await writePlaceholder(dest); item.width = 400; item.height = 400; return resolve(item); } - // 渲染首帧到 PNG - const png = await renderWithRetry({ - skeletonPath: assets.skeletonPath, - atlasPath: assets.atlasPath, - texturePaths: assets.texturePaths, - version, - runtimeDir, - size: 400, - }); + // 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试, + // 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。 + let png = null; + let lastRenderError = null; + for (let attempt = 0; attempt < 3; attempt++) { + try { + png = await render.renderThumbnail({ + skeletonPath: assets.skeletonPath, + atlasPath: assets.atlasPath, + texturePaths: assets.texturePaths, + version, + runtimeDir, + size: 400, + }); + break; + } catch (e) { + lastRenderError = e; + log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...'); + if (attempt === 2) break; + await sleep(500); + assets = await discoverWithRetry(src, 16, 250); + if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue; + version = utils.detectVersion(assets.skeletonPath); + runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary); + } + } + if (!png) { + if (await writeAtlasFallback(assets, dest)) { + log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src); + item.width = 400; item.height = 400; + return resolve(item); + } + throw lastRenderError || new Error('Spine 资源尚未写入完成'); + } fs.writeFileSync(dest, png); @@ -115,7 +175,7 @@ module.exports = async ({ src, dest, item }) => { // 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标 log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || '')); try { - await writePlaceholder(dest, 'Spine'); + await writePlaceholder(dest); item.width = 400; item.height = 400; return resolve(item); @@ -153,6 +213,33 @@ async function copyImageAsThumb(src, dest) { fs.copyFileSync(src, dest); } +/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */ +async function writeAtlasFallback(assets, dest) { + if (!render.canvasLib || !assets || !assets.texturePaths) return false; + const texturePath = Object.values(assets.texturePaths).find((file) => { + try { return fs.statSync(file).size > 0; } catch (e) { return false; } + }); + if (!texturePath) return false; + try { + const img = await render.canvasLib.loadImage(texturePath); + const size = 400; + const canvas = render.canvasLib.createCanvas(size, size); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#1e1e22'; + ctx.fillRect(0, 0, size, size); + const scale = Math.min((size - 24) / img.width, (size - 24) / img.height); + const w = Math.max(1, Math.round(img.width * scale)); + const h = Math.max(1, Math.round(img.height * scale)); + ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h); + await render.drawSpineBadge(ctx, size, size); + fs.writeFileSync(dest, canvas.toBuffer('image/png')); + return true; + } catch (e) { + log('atlas 贴图回退失败: ' + (e && e.message)); + return false; + } +} + /** 读取 PNG 宽高(仅支持 PNG 头部) */ function imageSizeOf(file) { const buf = fs.readFileSync(file); @@ -163,8 +250,19 @@ function imageSizeOf(file) { } /** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */ -async function writePlaceholder(dest, text) { +async function writePlaceholder(dest) { const w = 400, h = 400; + if (render.canvasLib) { + try { + const canvas = render.canvasLib.createCanvas(w, h); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#1e1e22'; + ctx.fillRect(0, 0, w, h); + await render.drawSpineBadge(ctx, w, h); + fs.writeFileSync(dest, canvas.toBuffer('image/png')); + return; + } catch (e) { /* fall through to pure JS encoder */ } + } // 深色底 const pixels = Buffer.alloc(w * h * 4); for (let y = 0; y < h; y++) { diff --git a/viewer/spine.html b/viewer/spine.html index 946067f..ce04df5 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -171,6 +171,34 @@ const path = require('path'); const fs = require('fs'); const utils = require('../js/spine-utils.js'); + const spineBadge = require('../js/spine-badge.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('Spine badge 加载失败')); + 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) { @@ -429,6 +457,7 @@ offRenderer.draw(skeleton); octx.restore(); } + await drawSpineBadge(octx, size, size); // Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。 let out = off.toDataURL('image/webp', 0.9);