From ba0f48c5433a9de671307155c367fa0fc70f383a Mon Sep 17 00:00:00 2001 From: tianmo Date: Fri, 11 Sep 2026 14:02:48 +0800 Subject: [PATCH] =?UTF-8?q?json=E6=94=BE=E8=A1=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- js/spine-utils.js | 4 ++-- tests/compound-extensions.test.js | 10 +++++++++ tests/thumbnail-compound.test.js | 7 ++++++ thumbnail/spine.js | 7 +++--- viewer/spine.html | 37 ++++++++++++++++++++++++------- 6 files changed, 52 insertions(+), 15 deletions(-) diff --git a/README.md b/README.md index 46190bd..f45f363 100644 --- a/README.md +++ b/README.md @@ -68,7 +68,7 @@ SpineViewer/ - 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。 - 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。 - - JSON 只在存在 Spine 官方导出的 `skeleton.spine` 版本字段时才按骨骼数据处理;普通 JSON、JSONC 和仅含 `bones` / `slots` 同名字段的数据会立即生成中性文件缩略图,不进入全库资源扫描或补刷队列。 + - JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,不进入全库资源扫描或补刷队列,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。 - 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。 - 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。 - Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。 diff --git a/js/spine-utils.js b/js/spine-utils.js index 8332942..c2d2cee 100644 --- a/js/spine-utils.js +++ b/js/spine-utils.js @@ -138,9 +138,9 @@ function classifySkeletonJson(filePath) { 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); + const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i); let kind; - if (hasSkeletonVersion) { + if (versionMatch) { kind = 'spine'; } else if (stat.size > JSON_PROBE_BYTES) { kind = 'other'; diff --git a/tests/compound-extensions.test.js b/tests/compound-extensions.test.js index e2375d3..85493ff 100644 --- a/tests/compound-extensions.test.js +++ b/tests/compound-extensions.test.js @@ -35,5 +35,15 @@ try { assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true }).skeletonPath, null); const lateSkeleton = path.join(late, 'hero.skel.bytes'); fs.writeFileSync(lateSkeleton, Buffer.from('\0' + '3.8.1\0')); assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true, refreshIndex: true }).skeletonPath, lateSkeleton); + for (const [name, json, expected] of [ + ['spine.json', { skeleton: { spine: '4.2.43' }, bones: [] }, 'spine'], + ['empty-version.json', { skeleton: { spine: '' }, bones: [] }, 'other'], + ['null-version.json', { skeleton: { spine: null }, bones: [] }, 'other'], + ['fake-version.json', { skeleton: { spine: 'latest' }, bones: [] }, 'other'], + ['ordinary.json', { bones: [], slots: [] }, 'other'], + ]) { + const file = path.join(root, name); fs.writeFileSync(file, JSON.stringify(json)); + assert.equal(utils.classifySkeletonJson(file), expected, name); + } console.log('PASS: Eagle routing, case handling, ordinary-file exclusion, cache isolation and late-arriving skeleton'); } finally { fs.rmSync(root, { recursive: true, force: true }); } diff --git a/tests/thumbnail-compound.test.js b/tests/thumbnail-compound.test.js index 7db4798..90065cf 100644 --- a/tests/thumbnail-compound.test.js +++ b/tests/thumbnail-compound.test.js @@ -24,6 +24,13 @@ const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures const result = await generate({ src, dest: path.join(root, 'notes_thumbnail.png'), item: { name: 'notes', ext: 'txt' } }); assert.equal(result.text, text); assert.equal(result.ext, 'txt'); assert.equal(result.name, 'notes'); } + const ordinaryJson = path.join(root, 'config.json'); + const ordinaryJsonDest = path.join(root, 'config_thumbnail.png'); + fs.writeFileSync(ordinaryJson, JSON.stringify({ skeleton: { spine: null }, enabled: true })); + const ordinaryJsonItem = { name: 'config', ext: 'json' }; + assert.equal(await generate({ src: ordinaryJson, dest: ordinaryJsonDest, item: ordinaryJsonItem }), ordinaryJsonItem); + assert.equal(fs.existsSync(ordinaryJsonDest), false, 'non-Spine JSON must leave thumbnail destination untouched'); + assert.equal(ordinaryJsonItem.width, undefined); assert.equal(ordinaryJsonItem.height, undefined); for (const [source, target] of [['spineboy-mesh.skel', 'spineboy.skel.bytes'], ['spineboy.atlas', 'spineboy.atlas.txt'], ['spineboy.png', 'spineboy.png']]) fs.copyFileSync(path.join(fixtures, source), path.join(root, target)); for (const [name, ext] of [['spineboy.atlas', 'txt'], ['spineboy.skel', 'bytes']]) { diff --git a/thumbnail/spine.js b/thumbnail/spine.js index ad4cbee..03a717c 100644 --- a/thumbnail/spine.js +++ b/thumbnail/spine.js @@ -188,13 +188,12 @@ async function generate({ src, dest, item, deferRefresh }) { } // manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定: - // 普通 JSON 立即生成中性缩略图,不扫描资源库,也不启动后台补刷。 + // 普通 JSON 不写 dest,不修改尺寸,也不启动资源扫描和后台补刷。 + // 这样可以保留 Eagle 或其他 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; + log('普通 JSON,放行给 Eagle/其他 JSON 插件: ' + src); return resolve(item); } } diff --git a/viewer/spine.html b/viewer/spine.html index c8e1913..f9a58a1 100644 --- a/viewer/spine.html +++ b/viewer/spine.html @@ -175,6 +175,13 @@ #overlay .box { max-width: 520px; padding: 20px; } #overlay .title { font-size: 16px; margin-bottom: 8px; color: var(--error); } #overlay .msg { color: var(--text-dim); white-space: pre-wrap; word-break: break-all; } + #overlay.text-preview { align-items: stretch; justify-content: stretch; text-align: left; } + #overlay.text-preview .box { width: 100%; max-width: none; padding: 24px; overflow: auto; } + #overlay.text-preview .title { color: var(--text); font-size: 13px; font-weight: 600; } + #overlay.text-preview .msg { + font: 12px/1.6 "SFMono-Regular", Consolas, "Liberation Mono", monospace; + color: var(--text-dim); white-space: pre; word-break: normal; + } #overlay.hidden { display: none; } #loading { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; z-index: 4; background: var(--bg); } #loading.hidden { display: none; } @@ -350,6 +357,16 @@ document.getElementById('errorMsg').textContent = msg; } + function showTextPreview(title, text) { + running = false; + toolbarEl.style.display = 'none'; + timelineBarEl.style.display = 'none'; + animationPanel.style.display = 'none'; + statusEl.style.display = 'none'; + overlay.classList.add('text-preview'); + showError(title, text); + } + function setStatus(msg, holdMs = 0) { statusEl.textContent = msg; statusHoldUntil = holdMs === Infinity ? Infinity : (holdMs > 0 ? performance.now() + holdMs : 0); @@ -936,9 +953,6 @@ if (!utils.extOf(path.basename(filePath))) { // Registering txt/bytes is required by Eagle's extension dispatch. // Ordinary text remains readable and never starts Spine discovery. - document.getElementById('toolbar').style.display = 'none'; - document.getElementById('timelineBar').style.display = 'none'; - document.getElementById('animationPanel').style.display = 'none'; let message = '此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。'; if (/\.txt$/i.test(filePath)) { const fd = fs.openSync(filePath, 'r'); @@ -949,16 +963,23 @@ if (fs.fstatSync(fd).size > count) message += '\n\n(仅显示前 256 KB)'; } finally { fs.closeSync(fd); } } - showError(path.basename(filePath), message); - const box = overlay.querySelector('.box'); - box.style.cssText = 'max-width:none;width:100%;height:100%;overflow:auto;text-align:left'; - document.getElementById('errorTitle').style.color = 'var(--text)'; + showTextPreview(path.basename(filePath), message); return; } // Eagle 只能按 .json 扩展名把文件交给格式扩展。查看器必须再次检查内容, // 防止普通 JSON 被拿去匹配库中其他 Spine 骨骼资源。 if (utils.extOf(path.basename(filePath)) === '.json' && utils.classifySkeletonJson(filePath) !== 'spine') { - showError('普通 JSON 文件', '该文件不包含 Spine 导出的 skeleton.spine 版本字段,因此不会作为 Spine 骨骼数据加载。'); + const maxPreviewBytes = 1024 * 1024; + const stat = fs.statSync(filePath); + const fd = fs.openSync(filePath, 'r'); + const buffer = Buffer.alloc(Math.min(stat.size, maxPreviewBytes)); + let count = 0; + try { count = fs.readSync(fd, buffer, 0, buffer.length, 0); } finally { fs.closeSync(fd); } + let text = buffer.subarray(0, count).toString('utf8'); + if (stat.size <= maxPreviewBytes) { + try { text = JSON.stringify(JSON.parse(text), null, 2); } catch (e) { /* 保留 JSONC/原始文本 */ } + } else text += '\n\n(文件较大,仅显示前 1 MB)'; + showTextPreview(path.basename(filePath), text); return; } const assets = utils.discoverAssets(filePath);