From 5cb2e7647130c574b5422d32aaff8dbdbad67c5b Mon Sep 17 00:00:00 2001 From: tianmo Date: Wed, 9 Sep 2026 00:27:25 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=AF=86=E5=88=AB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- assets/README.md | 5 ++ assets/spine-badge.png | Bin 0 -> 4567 bytes js/spine-badge.js | 14 ++++ js/spine-render.js | 20 ++++- js/spine-utils.js | 120 +++++++++++++++++++++++------ manifest.json | 4 +- thumbnail/spine.js | 168 ++++++++++++++++++++++++++++++++--------- viewer/spine.html | 29 +++++++ 9 files changed, 302 insertions(+), 66 deletions(-) create mode 100644 assets/README.md create mode 100644 assets/spine-badge.png create mode 100644 js/spine-badge.js 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 0000000000000000000000000000000000000000..422f11a8a54bad9410806524611f221501baacb8 GIT binary patch literal 4567 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 绝对路径 } */ -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);