缩略图自动生成背景服务插件

This commit is contained in:
Yeomai committed 2026-09-20 20:34:17 +08:00
1 parent c28adf9fae
commit 065b6466f1
27 files changed
+81166

No files matched your search

@@ -0,0 +1,211 @@
'use strict';
// 不需要 Eagle 的回归测试。用临时素材库模拟「拖入一批文件」,覆盖:
// 1. 历史素材被忽略、静默之后才触发、只报增量;
// 2. **跨目录配对**——Eagle 会把每个文件拆成独立的 <id>.info 目录,
// 一套资产的骨架 / atlas / 贴图分散在不同目录里,必须能关联起来;
// 3. 本地模块在「页面同时有 window 和 module」时仍然挂到 window 上。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { createLibraryWatcher, QUIET_MS } = require('../js/library-watcher.js');
const { scanSpineItems } = require('../js/spine-assets.js');
const utils = require('../js/spine-utils.js');
const { withTimeout } = require('../js/renderer.js');
// —— 超时保护:卡住的 Promise 必须被拒绝,否则队列会被永久堵死 ——
(async () => {
assert.equal(await withTimeout(Promise.resolve('ok'), 1000, '测试'), 'ok', '正常 Promise 应原样返回');
await assert.rejects(
() => withTimeout(new Promise(() => {}), 30, '测试'),
/超时/,
'永不 resolve 的 Promise 必须被超时拒绝',
);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
// —— 回归:Eagle 插件页面里也存在 module 对象,本地模块必须优先挂 window ——
const pageSandbox = {
require, console, setTimeout, clearTimeout, setInterval, clearInterval,
window: {}, module: { exports: {} },
document: { getElementById: () => null, createElement: () => ({}) },
};
pageSandbox.self = pageSandbox;
vm.createContext(pageSandbox);
for (const file of ['library-watcher.js', 'spine-assets.js', 'spine-utils.js']) {
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', file), 'utf8'), pageSandbox, { filename: file });
}
assert.ok(pageSandbox.window.SpineThumbLibraryWatcher, '存在 module 对象时也必须挂到 window');
assert.ok(pageSandbox.window.SpineThumbSpineAssets, '存在 module 对象时也必须挂到 window');
assert.ok(pageSandbox.window.SpineUtils, '存在 module 对象时也必须挂到 window');
// —— 复合后缀:Eagle 只按最后一个点拆 name/ext,atlas.txt 会被当成纯文本素材 ——
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.atlas.txt'), { name: 'EmoticonSpine', ext: 'atlas.txt' });
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.skel.bytes'), { name: 'EmoticonSpine', ext: 'skel.bytes' });
assert.equal(
utils.splitCompoundName('hero.skel'), null,
'普通后缀不动(否则会把 Eagle 里显式支持的类型也拆坏)',
);
assert.equal(utils.splitCompoundName('notes.txt'), null, '普通 txt 不是 Spine 素材,不碰');
assert.equal(utils.splitCompoundName('.atlas.txt'), null, '没有主干名就不改');
// —— 搭一个仿真素材库:<root>/images/<id>.info/<file> ——
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-server-'));
const imagesDir = path.join(root, 'images');
fs.mkdirSync(imagesDir, { recursive: true });
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
const MINIMAL_ATLAS = ['hero.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
// 真实 Spine JSON 的最小形态:必须有 skeleton.spine 版本字段才会被判为骨骼数据。
const MINIMAL_SPINE_JSON = JSON.stringify({ skeleton: { spine: '3.8.55' }, bones: [{ name: 'root' }] });
const LEGACY_ATLAS = ['legacy.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
const ATLAS_FOR_DUP = ['dup.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
const ATLAS_FOLD_A = ['dup2.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
function makeItem(id, files, folders, deleted) {
const dir = path.join(imagesDir, id + '.info');
fs.mkdirSync(dir, { recursive: true });
// Eagle 的每个 item 目录都必须有 metadata.json,spine-utils 靠它判断这是不是一个 item。
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({
id, name: id, folders: folders || [], isDeleted: !!deleted,
}));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content === true ? 'x' : content);
}
return dir;
}
// 兜底超时,避免配对逻辑异常时测试挂住不退出。
setTimeout(() => {
console.error('测试超时(60 秒)');
process.exit(1);
}, 60000).unref();
// 历史素材:监听建立之前就存在,不能处理。
makeItem('old-skel', { 'spineboy.skel': true });
makeItem('old-atlas', { 'spineboy.atlas': MINIMAL_ATLAS });
makeItem('old-tex', { 'spineboy.png': true });
// 历史里还留着一套 legacy 资产的 atlas + 贴图,用来验证新增素材不能借用它们。
makeItem('old-legacy-atlas', { 'legacy.atlas': LEGACY_ATLAS });
makeItem('old-legacy-tex', { 'legacy.png': true });
const batches = [];
const watcher = createLibraryWatcher({ onBatch: (batch) => batches.push(batch), onLog: () => {} });
assert.equal(watcher.start(root), true, '监听应当建立成功');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
(async () => {
// 模拟一次拖入:一套分散在三处的完整资产 + 一张普通图片 + 只有骨架的残缺资产。
makeItem('new-skel', { 'hero.skel': true });
makeItem('new-atlas', { 'hero.atlas': MINIMAL_ATLAS });
makeItem('new-tex', { 'hero.png': true });
makeItem('new-photo', { 'photo.png': true });
makeItem('new-broken', { 'lonely.skel': true });
makeItem('new-json', { 'notes.json': '{"hello":"world"}' });
makeItem('new-skeljson', { 'beast.json': MINIMAL_SPINE_JSON });
// 只有骨架,配套的 legacy.atlas / legacy.png 都在历史素材里 → 不该跨批次配上
makeItem('new-orphan', { 'legacy.skel': true });
await wait(QUIET_MS + 1200);
assert.equal(batches.length, 1, '静默之后应当只触发一批');
assert.equal(batches[0].reason, 'quiet');
assert.deepEqual(
batches[0].added.slice().sort(),
['new-atlas.info', 'new-broken.info', 'new-json.info', 'new-orphan.info', 'new-photo.info', 'new-skel.info', 'new-skeljson.info', 'new-tex.info'],
);
const spine = await scanSpineItems(imagesDir, batches[0].added, utils);
const names = spine.map((item) => item.dirName).sort();
assert.deepEqual(
names,
['new-atlas.info', 'new-broken.info', 'new-orphan.info', 'new-skel.info', 'new-skeljson.info'],
'普通图片和普通 JSON 不算 Spine 资产,真实的 Spine JSON 要算',
);
// 关键:骨架在 new-skel.info 里,atlas 在 new-atlas.info 里,贴图在 new-tex.info 里,
// 三者必须被判成同一套完整资产。
const hero = spine.find((item) => item.dirName === 'new-skel.info');
assert.equal(hero.complete, true, '跨目录的三件套必须配成完整资产');
// 日志要显示 Eagle 里的文件名和文件夹路径,扫描结果必须带上这两项。
assert.equal(hero.entryName, 'hero.skel', '要带上 item 目录里的入口文件名');
assert.deepEqual(hero.folders, [], '未归入文件夹的素材 folders 为空数组');
assert.equal(path.basename(hero.atlasPath || ''), 'hero.atlas', '要找到另一个目录里的 atlas');
assert.equal(path.basename(hero.texturePaths['hero.png'] || ''), 'hero.png', '要找到 atlas 引用的贴图');
assert.equal(hero.pages, 1);
assert.equal(hero.missingTextures, 0);
const broken = spine.find((item) => item.dirName === 'new-broken.info');
assert.equal(broken.complete, false, '只有骨架不算完整');
assert.equal(broken.missingTextures, 0, '没有 atlas 时缺图计数为 0');
// 配对范围只限本次新增:历史素材里有 legacy.atlas 和 legacy.png,
// 但新导入的 legacy.skel 不能去借用它们。
const orphan = spine.find((item) => item.dirName === 'new-orphan.info');
assert.equal(orphan.complete, false, '新增素材不能借用历史素材的配套文件');
assert.equal(orphan.atlasPath, null, '不应该跨批次找到历史 atlas');
// 第二批:只报新增的那个。
makeItem('new-skel2', { 'boss.skel': true });
await wait(QUIET_MS + 1200);
assert.equal(batches.length, 2, '第二批应当再触发一次');
assert.deepEqual(batches[1].added, ['new-skel2.info']);
// 分批导入(模拟 Eagle 的「重复添加提示」把一次导入拆成两段):
// 先只写入骨架,配套的 atlas / 贴图过一会儿才写入。
makeItem('dup-skel', { 'dup.skel': true });
await wait(QUIET_MS + 1200);
const firstPass = await scanSpineItems(imagesDir, ['dup-skel.info'], utils);
assert.equal(firstPass[0].complete, false, '只有骨架时不算完整');
makeItem('dup-atlas', { 'dup.atlas': ATLAS_FOR_DUP });
makeItem('dup-tex', { 'dup.png': true });
await wait(QUIET_MS + 1200);
// plugin.js 的「待观察」会把上一批的目录和新一批一起送进来,这里模拟同样的调用。
const secondPass = await scanSpineItems(imagesDir, ['dup-skel.info', 'dup-atlas.info', 'dup-tex.info'], utils);
const dup = secondPass.find((item) => item.dirName === 'dup-skel.info');
assert.equal(dup.complete, true, '配套补齐后应当能配成完整资产');
assert.equal(dup.missingTextures, 0);
// 同名素材分属不同 Eagle 文件夹时不能互相配对:文件夹 A 里有完整三件套,
// 文件夹 B 里只有一个同名骨架,B 不应该借用 A 的 atlas。
makeItem('fold-a-skel', { 'dup2.skel': true }, ['folderA']);
makeItem('fold-a-atlas', { 'dup2.atlas': ATLAS_FOLD_A }, ['folderA']);
makeItem('fold-a-tex', { 'dup2.png': true }, ['folderA']);
makeItem('fold-b-skel', { 'dup2.skel': true }, ['folderB']);
const crossFolder = await scanSpineItems(
imagesDir,
['fold-a-skel.info', 'fold-a-atlas.info', 'fold-a-tex.info', 'fold-b-skel.info'],
utils,
);
const inA = crossFolder.find((item) => item.dirName === 'fold-a-skel.info');
assert.equal(inA.complete, true, '同一 Eagle 文件夹里的三件套要能配上');
assert.deepEqual(inA.folders, ['folderA'], '要带上 Eagle 文件夹 id,日志才能还原路径');
assert.equal(path.basename(inA.atlasPath), 'dup2.atlas');
const inB = crossFolder.find((item) => item.dirName === 'fold-b-skel.info');
assert.equal(inB.complete, false, '不能借用别的 Eagle 文件夹里的同名 atlas');
assert.equal(inB.atlasPath, null);
// 回收站素材:Eagle 删除只是把 metadata 标成 isDeleted,目录和文件都还在磁盘上。
// 三件套齐全也不能进结果,否则会给回收站里的素材渲染缩略图。
makeItem('trash-skel', { 'gone.skel': true }, [], true);
makeItem('trash-atlas', { 'gone.atlas': ATLAS_FOR_DUP }, [], true);
makeItem('trash-tex', { 'gone.png': true }, [], true);
const trashed = await scanSpineItems(
imagesDir,
['trash-skel.info', 'trash-atlas.info', 'trash-tex.info'],
utils,
);
assert.deepEqual(trashed, [], '回收站里的素材不能被当成待生成资产');
watcher.stop();
fs.rmSync(root, { recursive: true, force: true });
console.log('PASS: 静默检测、历史忽略、增量识别、跨目录配对、回收站过滤、浏览器加载方式');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});