From 1b67933caca3be65ac0c896d1ba800dfddeaf391 Mon Sep 17 00:00:00 2001 From: tianmo Date: Wed, 9 Sep 2026 15:58:56 +0800 Subject: [PATCH] =?UTF-8?q?=E5=89=AA=E5=88=87=E4=BF=AE=E6=94=B9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 +- js/spine-canvas-clipping.js | 198 ++++++++++++++++++++++++++++++++++++ js/spine-utils.js | 81 +++++++++++---- thumbnail/spine.js | 80 +++++++++++++-- viewer/spine.html | 28 ++++- 5 files changed, 358 insertions(+), 33 deletions(-) create mode 100644 js/spine-canvas-clipping.js diff --git a/README.md b/README.md index 7d7fab2..2c91d48 100644 --- a/README.md +++ b/README.md @@ -56,13 +56,15 @@ SpineViewer/ - 播放 / 暂停(空格) - 循环开关 - 播放速度调节 + - 预乘 Alpha(PMA)贴图渲染开关 - 时间轴拖动、动画时长、秒刻度与关键帧标记 - 零时长动画按单帧静态姿势预览 - 自动适配并居中 - 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,便于识别数据文件。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。 - 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。 - - 暂时缺少骨骼、atlas 或贴图时先显示回退图,并在后台继续观察约 10 分钟;链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新。 + - JSON 只在存在 Spine 官方导出的 `skeleton.spine` 版本字段时才按骨骼数据处理;普通 JSON、JSONC 和仅含 `bones` / `slots` 同名字段的数据会立即生成中性文件缩略图,不进入全库资源扫描或补刷队列。 + - 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。 - 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。 --- diff --git a/js/spine-canvas-clipping.js b/js/spine-canvas-clipping.js new file mode 100644 index 0000000..6ff1084 --- /dev/null +++ b/js/spine-canvas-clipping.js @@ -0,0 +1,198 @@ +'use strict'; + +// The official spine-canvas SkeletonRenderer does not process clipping +// attachments. Keep its triangle rasterizer, but feed it geometry produced by +// SkeletonClipping so the viewer matches Spine's draw order and clipping rules. + +const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0]; +const unpremultipliedImages = new WeakMap(); + +function rendererConstructor(spine) { + return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer; +} + +function imageFromAttachment(attachment) { + const region = attachment && attachment.region; + // 4.0 stores the texture on the atlas page, while the other bundled + // runtimes expose it directly on the attachment region. + const renderRegion = region && region.renderObject; + const texture = region && (region.texture + || (region.page && region.page.texture) + || (renderRegion && renderRegion.texture) + || (renderRegion && renderRegion.page && renderRegion.page.texture)); + return texture && typeof texture.getImage === 'function' ? texture.getImage() : null; +} + +// Canvas 2D always applies source-alpha blending. A Spine texture exported +// with PMA already has RGB multiplied by alpha, so drawing it directly would +// multiply translucent edge pixels a second time. Convert it once to straight +// alpha and cache the result; Canvas can then blend it correctly. +function imageForCanvas(image, context, premultipliedAlpha) { + if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image; + const cached = unpremultipliedImages.get(image); + if (cached) return cached; + try { + const ownerDocument = context.canvas && context.canvas.ownerDocument; + const doc = ownerDocument || (typeof document !== 'undefined' ? document : null); + if (!doc || !image.width || !image.height) return image; + const canvas = doc.createElement('canvas'); + canvas.width = image.width; + canvas.height = image.height; + const ctx = canvas.getContext('2d', { willReadFrequently: true }); + ctx.drawImage(image, 0, 0); + const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height); + const data = pixels.data; + for (let i = 0; i < data.length; i += 4) { + const alpha = data[i + 3]; + if (alpha > 0 && alpha < 255) { + const factor = 255 / alpha; + data[i] = Math.min(255, Math.round(data[i] * factor)); + data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor)); + data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor)); + } + } + ctx.putImageData(pixels, 0, 0); + unpremultipliedImages.set(image, canvas); + return canvas; + } catch (e) { + // If pixel access is unavailable, retain the original texture so preview + // remains usable instead of failing the whole frame. + return image; + } +} + +function unpackVertices(vertices, count) { + const positions = new Array(count * 2); + const uvs = new Array(count * 2); + for (let i = 0, p = 0; i < count; i++, p += 2) { + const source = i * 8; + positions[p] = vertices[source]; + positions[p + 1] = vertices[source + 1]; + uvs[p] = vertices[source + 6]; + uvs[p + 1] = vertices[source + 7]; + } + return { positions, uvs }; +} + +function drawInterleaved(base, image, vertices, triangles) { + for (let i = 0; i < triangles.length; i += 3) { + const a = triangles[i] * 8; + const b = triangles[i + 1] * 8; + const c = triangles[i + 2] * 8; + base.drawTriangle(image, + vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7], + vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7], + vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7]); + } +} + +function createLegacyRenderer(spine, context) { + const Renderer = rendererConstructor(spine); + if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整'); + const base = new Renderer(context); + base.triangleRendering = true; + base.premultipliedAlpha = false; + const clipper = new spine.SkeletonClipping(); + const dark = new spine.Color(0, 0, 0, 0); + + base.draw = function drawWithClipping(skeleton) { + const drawOrder = skeleton.drawOrder; + const pma = !!base.premultipliedAlpha; + try { + for (let i = 0; i < drawOrder.length; i++) { + const slot = drawOrder[i]; + if (slot.bone && slot.bone.active === false) { + clipper.clipEndWithSlot(slot); + continue; + } + const attachment = slot.getAttachment(); + if (attachment instanceof spine.ClippingAttachment) { + clipper.clipStart(slot, attachment); + continue; + } + + try { + if (!attachment) continue; + let vertices; + let triangles; + let vertexCount; + if (attachment instanceof spine.RegionAttachment) { + vertices = base.computeRegionVertices(slot, attachment, pma); + triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES; + vertexCount = 4; + } else if (attachment instanceof spine.MeshAttachment) { + vertices = base.computeMeshVertices(slot, attachment, pma); + triangles = attachment.triangles; + vertexCount = attachment.worldVerticesLength >> 1; + } else { + continue; + } + + const image = imageForCanvas(imageFromAttachment(attachment), context, pma); + if (!image) continue; + context.globalAlpha = base.tempColor.a; + if (clipper.isClipping()) { + const unpacked = unpackVertices(vertices, vertexCount); + clipper.clipTriangles(unpacked.positions, unpacked.positions.length, + triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false); + drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles); + } else { + drawInterleaved(base, image, vertices, triangles); + } + } finally { + clipper.clipEndWithSlot(slot); + } + } + } finally { + clipper.clipEnd(); + context.globalAlpha = 1; + } + }; + base.clipper = clipper; + return base; +} + +function create43Renderer(spine, context) { + const Renderer = rendererConstructor(spine); + if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整'); + const base = new Renderer(context); + base.triangleRendering = true; + base.premultipliedAlpha = false; + const core = new spine.SkeletonRendererCore(); + + base.draw = function drawWithCoreClipping(skeleton) { + try { + const pma = !!base.premultipliedAlpha; + for (let command = core.render(skeleton, pma); command; command = command.next) { + const sourceImage = command.texture && command.texture.getImage && command.texture.getImage(); + const image = imageForCanvas(sourceImage, context, pma); + if (!image) continue; + context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255; + const positions = command.positions; + const uvs = command.uvs; + const indices = command.indices; + for (let i = 0; i < command.numIndices; i += 3) { + const a = indices[i] * 2; + const b = indices[i + 1] * 2; + const c = indices[i + 2] * 2; + base.drawTriangle(image, + positions[a], positions[a + 1], uvs[a], uvs[a + 1], + positions[b], positions[b + 1], uvs[b], uvs[b + 1], + positions[c], positions[c + 1], uvs[c], uvs[c + 1]); + } + } + } finally { + context.globalAlpha = 1; + } + }; + base.clipper = core.clipping; + return base; +} + +function createRenderer(spine, context, runtimeDir) { + return runtimeDir === '4.3' + ? create43Renderer(spine, context) + : createLegacyRenderer(spine, context); +} + +module.exports = { createRenderer }; diff --git a/js/spine-utils.js b/js/spine-utils.js index 660aa27..11a4d2e 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -16,6 +16,8 @@ const ATLAS_EXTS = ['.atlas', '.atlas.txt']; const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp']; const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS); const fileIndexCache = new Map(); +const skeletonJsonCache = new Map(); +const JSON_PROBE_BYTES = 256 * 1024; // Spine 导出常见的后缀,匹配时归一化掉 const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i; @@ -117,21 +119,53 @@ function chooseRuntimeDir(version, isBinary) { } /** - * 判断给定的 JSON 文件是否为 Spine 骨骼 JSON,避免把普通 JSON 误判为骨骼数据。 - * 判定条件:存在 `skeleton` 及其 `spine` 版本字段,或同时存在 `bones` + `slots` 数组。 + * 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。 + * 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等 + * 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。 */ -function looksLikeSkeletonJson(filePath) { +function classifySkeletonJson(filePath) { try { - const json = JSON.parse(fs.readFileSync(filePath, 'utf8')); - if (!json || typeof json !== 'object') return false; - if (json.skeleton && json.skeleton.spine) return true; - if (Array.isArray(json.bones) && json.bones.length > 0 && Array.isArray(json.slots)) return true; - return false; + const stat = fs.statSync(filePath); + if (!stat.isFile() || stat.size === 0) return 'pending'; + const signature = stat.size + ':' + Math.round(stat.mtimeMs); + const cached = skeletonJsonCache.get(filePath); + if (cached && cached.signature === signature) return cached.kind; + + const length = Math.min(stat.size, JSON_PROBE_BYTES); + const fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.allocUnsafe(length); + try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); } + const text = buffer.toString('utf8'); + const hasSkeletonKey = /"skeleton"\s*:/i.test(text); + const hasSkeletonVersion = /"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:/i.test(text); + let kind; + if (hasSkeletonVersion) { + kind = 'spine'; + } else if (stat.size > JSON_PROBE_BYTES) { + kind = 'other'; + } else { + try { + JSON.parse(text); + kind = 'other'; + } catch (e) { + // 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。 + // JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。 + kind = hasSkeletonKey ? 'pending' : 'other'; + } + } + + if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear(); + skeletonJsonCache.set(filePath, { signature, kind }); + return kind; } catch (e) { - return false; + return 'pending'; } } +function looksLikeSkeletonJson(filePath) { + return classifySkeletonJson(filePath) === 'spine'; +} + /** 文件修改时间(毫秒) */ function mtimeOf(fp) { try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; } @@ -330,7 +364,7 @@ function collectSpineFilesCached(filePath, roots) { const key = libRoot || path.dirname(path.dirname(filePath)); const now = Date.now(); const cached = fileIndexCache.get(key); - if (cached && now - cached.time < 180) return cached.files; + if (cached && now - cached.time < 1500) return cached.files; const files = collectSpineFiles(roots, null); fileIndexCache.set(key, { time: now, files }); return files; @@ -379,7 +413,8 @@ function discoverAssets(filePath, options) { options = options || {}; const strictStem = !!options.strictStem; const preferCompatibleJson = !!options.preferCompatibleJson; - const allowCrossBatch = !!options.allowCrossBatch; + const requestedCrossBatch = !!options.allowCrossBatch; + const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000); filePath = resolveRealSource(filePath); const selected = path.basename(filePath); const selectedStem = normalizeStem(selected); @@ -392,6 +427,10 @@ function discoverAssets(filePath, options) { // 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入 // folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。 const validFolders = getValidFolders(filePath); + // 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次, + // 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。 + const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size); + const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000; let files = collectSpineFiles(roots, validFolders); if (strictStem) { const allFiles = collectSpineFilesCached(filePath, roots); @@ -402,9 +441,14 @@ function discoverAssets(filePath, options) { } } - const skeletonFiles = files.filter((f) => isSkeletonFile(f.name)); + let skeletonFiles = files.filter((f) => isSkeletonFile(f.name)); const atlasFiles = files.filter((f) => isAtlasFile(f.name)); const textureFiles = files.filter((f) => isTextureFile(f.name)); + // 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容, + // 避免每个回调都解析库中所有普通 JSON。 + if (strictStem) { + skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem); + } // 区分“骨架 json”和“普通 json”:只保留形似骨架的 json const realSkeletonFiles = skeletonFiles.filter((f) => { @@ -486,7 +530,7 @@ function discoverAssets(filePath, options) { f.name.toLowerCase().endsWith('.json') && normalizeStem(f.name) === stem && familyOfSkel(f) === familyOfSkel(skeleton) && - (!strictStem || allowCrossBatch || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= 30000) + (!strictStem || Math.abs(importTimeOf(f.fullPath) - importTimeOf(skeleton.fullPath)) <= allowedImportDelta) ); const jsonFallback = selectBest(jsonCands, skeleton.fullPath); if (jsonFallback) skeleton = jsonFallback; @@ -532,9 +576,9 @@ function discoverAssets(filePath, options) { // 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差 // 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。 - if (strictStem && !allowCrossBatch && skeleton && atlas) { + if (strictStem && skeleton && atlas) { const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath)); - if (pairDelta > 30000) { + if (pairDelta > allowedImportDelta) { if (isAtlasFile(selected)) skeleton = null; else atlas = null; } @@ -558,7 +602,7 @@ function discoverAssets(filePath, options) { let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase()); if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn)); let hit = selectBest(cands, atlas.fullPath); - if (strictStem && !allowCrossBatch && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > 30000) hit = null; + if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null; if (hit) texturePaths[pn] = hit.fullPath; } } @@ -572,12 +616,12 @@ function discoverAssets(filePath, options) { const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) => normalizeStem(f.name) === base && familyOfSkel(f) === chosenFamily && - Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= 30000 + Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta ).map((f) => f.fullPath) : []; const relatedAtlasPaths = atlas ? atlasFiles.filter((f) => normalizeStem(f.name) === base && familyOfAtlas(f) === chosenFamily && - Math.abs(importTimeOf(f.fullPath) - atlasTime) <= 30000 + Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta ).map((f) => f.fullPath) : []; const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))] .filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp)); @@ -631,6 +675,7 @@ module.exports = { detectVersionFromJson, detectVersionFromBuffer, chooseRuntimeDir, + classifySkeletonJson, looksLikeSkeletonJson, discoverAssets, parseAtlasPages, diff --git a/thumbnail/spine.js b/thumbnail/spine.js index cad89f8..da8091e 100644 --- a/thumbnail/spine.js +++ b/thumbnail/spine.js @@ -22,7 +22,7 @@ const renderJobs = new Map(); const refreshJobs = new Map(); /** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */ -const VERSION = 'v7'; +const VERSION = 'v10'; function coordinationKey(src) { const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src)); @@ -61,24 +61,25 @@ async function runDiscovery(src, retries, delay) { let previousSignature = null; let stableCount = 0; for (let i = 0; i < retries; i++) { - // 前 5 秒只接受同批资源;之后允许在同一个 Eagle 文件夹内补齐分批拖入的链路。 + // 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。 + // 分批拖入的跨批次补齐统一交给后台任务处理。 assets = utils.discoverAssets(src, { strictStem: true, preferCompatibleJson: true, - allowCrossBatch: i >= Math.min(20, retries - 1), + allowCrossBatch: false, }); 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); + if (i < retries - 1) await sleep(delay); } return assets; } /** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */ -async function discoverWithRetry(src, retries = 48, delay = 250) { +async function discoverWithRetry(src, retries = 5, delay = 120) { const key = coordinationKey(src); if (discoveryJobs.has(key)) return discoveryJobs.get(key); const job = runDiscovery(src, retries, delay); @@ -90,6 +91,17 @@ async function discoverWithRetry(src, retries = 48, delay = 250) { } } +/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */ +async function classifyJsonBriefly(src) { + let kind = 'pending'; + for (let i = 0; i < 3; i++) { + kind = utils.classifySkeletonJson(src); + if (kind !== 'pending') return kind; + if (i < 2) await sleep(80); + } + return kind; +} + /** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */ async function renderThumbnailShared(assets, options) { const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size; @@ -138,6 +150,18 @@ module.exports = async ({ src, dest, item }) => { return resolve(item); } + // manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定: + // 普通 JSON 立即生成中性缩略图,不扫描资源库,也不启动后台补刷。 + if (srcExt === '.json') { + const jsonKind = await classifyJsonBriefly(src); + if (jsonKind === 'other') { + log('普通 JSON,跳过 Spine 资源发现: ' + src); + await writeNeutralJsonPlaceholder(dest); + item.width = 400; item.height = 400; + return resolve(item); + } + } + let assets = await discoverWithRetry(src); // 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图) @@ -198,7 +222,7 @@ module.exports = async ({ src, dest, item }) => { log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...'); if (attempt === 2) break; await sleep(500); - assets = await discoverWithRetry(src, 16, 250); + assets = await discoverWithRetry(src, 4, 150); if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue; version = utils.detectVersion(assets.skeletonPath); runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary); @@ -325,9 +349,11 @@ function scheduleBackgroundReconcile(src) { const job = (async () => { let previousSignature = null; let stableCount = 0; - // 最长观察约 10 分钟,覆盖大型文件夹分批落盘;每个资源组只有一个任务。 - for (let i = 0; i < 300; i++) { - await sleep(2000); + // 前几秒快速观察,随后降低频率;总计约两分钟。若更晚补拖第三个文件, + // 新文件自己的缩略图回调会再次触发同一资源链发现,无需常驻轮询十分钟。 + for (let i = 0; i < 36; i++) { + const delay = i < 8 ? 1000 : (i < 20 ? 2500 : 5000); + await sleep(delay); const assets = utils.discoverAssets(src, { strictStem: true, preferCompatibleJson: true, @@ -371,6 +397,42 @@ function scheduleBackgroundReconcile(src) { .finally(() => { if (backgroundJobs.get(key) === job) backgroundJobs.delete(key); }); } +/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */ +async function writeNeutralJsonPlaceholder(dest) { + const size = 400; + if (render.canvasLib) { + try { + const canvas = render.canvasLib.createCanvas(size, size); + const ctx = canvas.getContext('2d'); + ctx.fillStyle = '#202126'; + ctx.fillRect(0, 0, size, size); + ctx.fillStyle = '#555963'; + ctx.beginPath(); + ctx.moveTo(112, 72); + ctx.lineTo(244, 72); + ctx.lineTo(288, 116); + ctx.lineTo(288, 328); + ctx.lineTo(112, 328); + ctx.closePath(); + ctx.fill(); + ctx.fillStyle = '#777c87'; + ctx.beginPath(); + ctx.moveTo(244, 72); + ctx.lineTo(244, 116); + ctx.lineTo(288, 116); + ctx.closePath(); + ctx.fill(); + fs.writeFileSync(dest, canvas.toBuffer('image/png')); + return; + } catch (e) { /* fall through */ } + } + const pixels = Buffer.alloc(size * size * 4); + for (let i = 0; i < pixels.length; i += 4) { + pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255; + } + fs.writeFileSync(dest, encodePng(size, size, pixels)); +} + /** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */ async function copyImageAsThumb(src, dest) { const ext = path.extname(src).toLowerCase(); diff --git a/viewer/spine.html b/viewer/spine.html index ce04df5..539505e 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -136,6 +136,9 @@ 1.0x +
@@ -172,6 +175,7 @@ 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() { @@ -232,8 +236,10 @@ // ---------- 全局状态 ---------- let skeleton = null, state = null, stateData = null, skeletonData = null; let renderer = null; + let activeRuntimeDir = null; let currentAnim = 0, currentSkin = null; let playing = true, loop = true, speed = 1; + let premultipliedAlpha = false; let timeKeeper = null, lastTime = 0, running = false; let seeking = false; // 用户正在拖动时间轴 let duration = 0; // 当前动画时长 @@ -450,9 +456,9 @@ 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); + offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir); } catch (e) { /* 沿用原 renderer,可能无法正确绑定离屏 ctx */ } + offRenderer.premultipliedAlpha = premultipliedAlpha; if (offRenderer && 'triangleRendering' in offRenderer) offRenderer.triangleRendering = true; offRenderer.draw(skeleton); octx.restore(); @@ -789,6 +795,12 @@ // ---------- 加载资源并与运行时交互 ---------- async function loadAssets() { + // Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容, + // 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。 + if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') { + showError('普通 JSON 文件', '该文件不包含 Spine 导出的 skeleton.spine 版本字段,因此不会作为 Spine 骨骼数据加载。'); + return; + } const assets = utils.discoverAssets(filePath); if (!assets.skeletonPath || !assets.atlasPath) { @@ -807,6 +819,7 @@ showError('版本不支持', '无法识别的 Spine 版本:' + (version || '未知') + '\n支持的版本: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 { @@ -864,9 +877,8 @@ stateData.defaultMix = 0.2; state = new spine.AnimationState(stateData); - renderer = (spine.canvas && spine.canvas.SkeletonRenderer) ? new spine.canvas.SkeletonRenderer(ctx) : new spine.SkeletonRenderer(ctx); - // 使用三角渲染,正确处理 mesh 附件(如角色身体的网格) - if (renderer && 'triangleRendering' in renderer) renderer.triangleRendering = true; + renderer = clippingRenderer.createRenderer(spine, ctx, runtimeDir); + renderer.premultipliedAlpha = premultipliedAlpha; fillAnimations(); fillSkins(); @@ -931,6 +943,12 @@ speed = parseFloat(e.target.value); document.getElementById('speedVal').textContent = speed.toFixed(2) + 'x'; }); + document.getElementById('pmaCheck').addEventListener('change', (e) => { + premultipliedAlpha = e.target.checked; + if (renderer) renderer.premultipliedAlpha = premultipliedAlpha; + draw(); + setStatus(premultipliedAlpha ? '已开启预乘 Alpha 渲染' : '已关闭预乘 Alpha 渲染', 1800); + }); document.getElementById('reloadBtn').addEventListener('click', () => { window.location.reload(); });