1.1.1版本审核修复

[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。
跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。
[spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。
自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。
已删除后台整组补刷进程及 relatedDataPaths。
[viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。
当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。
手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。
八种语言文案及 README 已同步。
处理历史日志遗留问题
This commit is contained in:
tianmo committed 2026-09-17 13:38:22 +08:00
1 parent 473a270510
commit bb73cc9227
36 files changed
+499 -652

No files matched your search

+8 -8
View File
@@ -65,13 +65,11 @@ SpineViewer/
- 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局
- 零时长动画按单帧静态姿势预览
- 自动适配并居中
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。
- 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。
- JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,不进入全库资源扫描或补刷队列,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。
- 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
- Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。
- 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中的手动设置都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。自动流程只写入 Eagle 为当前素材提供的缩略图目标,不会改写同组其他素材的自定义缩略图。
- JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,也不进入资源扫描,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。
- 暂时缺少骨骼、atlas 或贴图时会显示回退图。资源补齐后,请使用 Eagle 的“刷新缩略图”重新生成当前素材的缩略图。
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
---
@@ -85,6 +83,8 @@ SpineViewer/
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。自动缩略图只写 Eagle 为当前素材提供的目标,不会隐式改写同组其他素材。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
@@ -106,7 +106,7 @@ node tests/legacy-binary.test.js tests/.fixtures 3.4
3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览、生成缩略图及后台补刷。
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览和生成缩略图。
- 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
- 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden",
"background": "Hintergrund",
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
"thumbnail": "Vorschaubild",
"thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen",
"thumbnail": "Als aktuelles Vorschaubild setzen",
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
"versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden",
"animationList": "Animationsliste",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle-API meldete HTTP {status}",
"noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden",
"noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln",
"itemMismatch": "Die Vorschaudatei stimmt nicht mit dem Eagle-Element überein; die Aktualisierung wurde abgebrochen",
"replaceCustomThumbnailConfirm": "Dieses Element hat bereits ein benutzerdefiniertes Vorschaubild. Beim Fortfahren wird es ersetzt; das Plugin kann das vorherige Bild nicht automatisch wiederherstellen. Fortfahren?",
"thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}",
"imageLoadFailed": "Bild konnte nicht geladen werden",
"settingsNotReady": "Asset-Einstellungen sind noch nicht bereit",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "For textures exported with Premultiplied Alpha (PMA)",
"background": "Background",
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
"thumbnail": "Set thumbnail",
"thumbnailTitle": "Generate a 400×400 thumbnail from the current frame",
"thumbnail": "Set current item thumbnail",
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
"versionTitle": "Spine version",
"reloadTitle": "Reload folder",
"animationList": "Animation list",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API returned HTTP {status}",
"noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail",
"noItemId": "Cannot determine the Eagle item ID from the asset path",
"itemMismatch": "The preview file does not match the Eagle item; the thumbnail update was cancelled",
"replaceCustomThumbnailConfirm": "This item already has a custom thumbnail. Continuing will replace it, and the plugin cannot automatically restore the previous image. Continue?",
"thumbnailFailed": "Failed to generate the thumbnail: {error}",
"imageLoadFailed": "Failed to load the image",
"settingsNotReady": "Asset settings are not ready",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)",
"background": "Fondo",
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
"thumbnail": "Crear miniatura",
"thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual",
"thumbnail": "Usar como miniatura actual",
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
"versionTitle": "Versión de Spine",
"reloadTitle": "Recargar carpeta",
"animationList": "Lista de animaciones",
@@ -54,6 +54,8 @@
"apiHttp": "La API de Eagle devolvió HTTP {status}",
"noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura",
"noItemId": "No se puede determinar el ID del elemento de Eagle a partir de la ruta",
"itemMismatch": "El archivo de vista previa no coincide con el elemento de Eagle; se canceló la actualización de la miniatura",
"replaceCustomThumbnailConfirm": "Este elemento ya tiene una miniatura personalizada. Si continúas, se reemplazará y el complemento no podrá restaurar automáticamente la imagen anterior. ¿Continuar?",
"thumbnailFailed": "No se pudo generar la miniatura: {error}",
"imageLoadFailed": "No se pudo cargar la imagen",
"settingsNotReady": "La configuración del recurso aún no está lista",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用",
"background": "背景",
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "サムネイル生成",
"thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成",
"thumbnail": "現在の項目のサムネイルに設定",
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
"versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み",
"animationList": "アニメーション一覧",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API が HTTP {status} を返しました",
"noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません",
"noItemId": "素材パスから Eagle の項目 ID を特定できません",
"itemMismatch": "プレビューファイルと Eagle の項目情報が一致しないため、サムネイルの更新を中止しました",
"replaceCustomThumbnailConfirm": "この項目には既にカスタムサムネイルがあります。続行すると置き換えられ、以前の画像をプラグインで自動復元できません。続行しますか?",
"thumbnailFailed": "サムネイルの生成に失敗しました:{error}",
"imageLoadFailed": "画像の読み込みに失敗しました",
"settingsNotReady": "素材の設定がまだ準備できていません",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용",
"background": "배경",
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "썸네일 생성",
"thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성",
"thumbnail": "현재 항목 썸네일로 설정",
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
"versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기",
"animationList": "애니메이션 목록",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API가 HTTP {status}를 반환했습니다",
"noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다",
"noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다",
"itemMismatch": "미리보기 파일과 Eagle 항목 정보가 일치하지 않아 썸네일 업데이트를 취소했습니다",
"replaceCustomThumbnailConfirm": "이 항목에는 이미 사용자 지정 썸네일이 있습니다. 계속하면 교체되며 플러그인에서 이전 이미지를 자동 복원할 수 없습니다. 계속하시겠습니까?",
"thumbnailFailed": "썸네일 생성 실패: {error}",
"imageLoadFailed": "이미지를 불러오지 못했습니다",
"settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)",
"background": "Фон",
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "Создать миниатюру",
"thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра",
"thumbnail": "Сделать миниатюрой элемента",
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
"versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку",
"animationList": "Список анимаций",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API вернул HTTP {status}",
"noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру",
"noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу",
"itemMismatch": "Файл предпросмотра не соответствует элементу Eagle; обновление миниатюры отменено",
"replaceCustomThumbnailConfirm": "У этого элемента уже есть пользовательская миниатюра. При продолжении она будет заменена, и плагин не сможет автоматически восстановить прежнее изображение. Продолжить?",
"thumbnailFailed": "Не удалось создать миниатюру: {error}",
"imageLoadFailed": "Не удалось загрузить изображение",
"settingsNotReady": "Настройки ресурса ещё не готовы",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图",
"background": "背景",
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "生成缩略图",
"thumbnailTitle": "使用当前帧生成 400×400 缩略图",
"thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录",
"animationList": "动画列表",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API 返回 HTTP {status}",
"noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图",
"noItemId": "无法从素材路径识别 Eagle 项目 ID",
"itemMismatch": "当前预览文件与 Eagle 素材信息不一致,已取消缩略图更新",
"replaceCustomThumbnailConfirm": "当前素材已有自定义缩略图。继续后将替换它,且插件无法自动恢复原图。是否继续?",
"thumbnailFailed": "生成缩略图失败: {error}",
"imageLoadFailed": "图片加载失败",
"settingsNotReady": "素材设置尚未就绪",
+4 -2
View File
@@ -18,8 +18,8 @@
"pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖",
"background": "背景",
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "產生縮圖",
"thumbnailTitle": "使用目前影格產生 400×400 縮圖",
"thumbnail": "設為目前素材縮圖",
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
"versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄",
"animationList": "動畫清單",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API 回傳 HTTP {status}",
"noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖",
"noItemId": "無法從素材路徑辨識 Eagle 項目 ID",
"itemMismatch": "目前預覽檔案與 Eagle 素材資訊不一致,已取消縮圖更新",
"replaceCustomThumbnailConfirm": "目前素材已有自訂縮圖。繼續後將取代它,且外掛無法自動還原原圖。是否繼續?",
"thumbnailFailed": "產生縮圖失敗:{error}",
"imageLoadFailed": "圖片載入失敗",
"settingsNotReady": "素材設定尚未就緒",
-85
View File
@@ -1,85 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const utils = require('./spine-utils');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function enqueue(src) {
const library = utils.findLibraryRoot(src);
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
fs.mkdirSync(directory, { recursive: true });
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
fs.renameSync(temp, job);
// Electron's Node mode runs the local worker without opening another Eagle.
const child = spawn(process.execPath, [__filename, directory], {
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
});
child.on('error', error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
});
child.unref();
}
async function run(directory) {
const lock = path.join(directory, 'worker.lock');
let fd;
try { fd = fs.openSync(lock, 'wx'); }
catch (error) {
if (error.code !== 'EEXIST') throw error;
let pid;
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
if (!pid) return; // Another worker is still writing its lock.
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
try { fs.unlinkSync(lock); } catch (_) { return; }
return run(directory);
}
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
const snapshots = new Map();
const nextChecks = new Map();
const thumbnail = require('../thumbnail/spine');
try {
let idle = 0;
while (idle < 2) {
await sleep(1000); // Allow the importing callback/window to finish.
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
if (!jobs.length) { idle++; continue; }
idle = 0;
for (const name of jobs) {
const file = path.join(directory, name);
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
const job = JSON.parse(raw);
if (Date.now() < (nextChecks.get(name) || 0)) continue;
const remaining = job.deadline - Date.now();
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
const result = Date.now() > job.deadline ? { complete: true }
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
snapshots.set(name, result.signature);
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
}
} catch (error) {
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
}
}
}
} finally {
fs.unlinkSync(lock);
// Cover an enqueue racing the final idle check and lock release.
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
}
}
module.exports = { enqueue, run };
if (require.main === module) run(process.argv[2]).catch(error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
process.exitCode = 1;
});
+6 -6
View File
@@ -59,7 +59,7 @@ function loadRuntime(dir) {
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
function buildTextureAtlas(spine, atlasText, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => {
(async () => {
@@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
const textureMap = {};
await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
if (!fs.existsSync(p)) return;
// 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
// 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
const p = texturePaths && texturePaths[name];
if (!p || !fs.existsSync(p)) return;
try {
const img = await canvasLib.loadImage(p);
textureMap[name] = img;
@@ -150,12 +151,11 @@ function getBounds(spine, skeleton) {
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
const atlas = await buildTextureAtlas(spine, atlasText, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
+100 -29
View File
@@ -22,6 +22,80 @@ const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i;
function unsafeAtlasPath(name, reason) {
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
error.code = 'UNSAFE_ATLAS_PATH';
return error;
}
/**
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
*/
function validateAtlasPageName(name) {
const raw = String(name == null ? '' : name).trim();
if (!raw) throw unsafeAtlasPath(name, '路径为空');
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
const portable = raw.replace(/\\/g, '/');
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
throw unsafeAtlasPath(raw, '不允许绝对路径');
}
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
const parts = portable.split('/');
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
const normalized = path.posix.normalize(portable);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw unsafeAtlasPath(raw, '路径无效');
}
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
}
return normalized.replace(/^\.\//, '');
}
function isPathInside(basePath, targetPath) {
const relative = path.relative(basePath, targetPath);
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
}
function realpath(filePath) {
const implementation = fs.realpathSync.native || fs.realpathSync;
return implementation(filePath);
}
/**
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
*/
function resolveFileWithin(baseDir, relativeName, atlasPage) {
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
const base = path.resolve(baseDir);
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
if (!isPathInside(base, candidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
return null;
}
if (!fs.existsSync(candidate)) return null;
let realBase;
let realCandidate;
try {
realBase = realpath(base);
realCandidate = realpath(candidate);
} catch (e) {
return null;
}
if (!isPathInside(realBase, realCandidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
return null;
}
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
return candidate;
}
function resolveAtlasTextureFile(atlasDir, pageName) {
return resolveFileWithin(atlasDir, pageName, true);
}
function extOf(name) {
const lower = name.toLowerCase();
for (let i = 0; i < SKELETON_EXTS.length; i++) {
@@ -335,12 +409,24 @@ function collectSpineFiles(roots, validFolders) {
const seen = new Set();
const out = [];
for (const root of roots) {
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
const probe = path.join(root, '__spineviewer_probe__');
const libRoot = findLibraryRoot(probe);
if (libRoot) {
try {
const imagesReal = realpath(path.join(libRoot, 'images'));
const rootReal = realpath(root);
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
} catch (e) { continue; }
}
let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) {
const full = path.join(root, name);
try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; }
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
const full = resolveFileWithin(root, name, false);
if (!full) continue;
// 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) {
@@ -576,15 +662,16 @@ function discoverAssets(filePath, options) {
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠)
const inAtlasDir = path.join(atlasDir, pn);
if (fs.existsSync(inAtlasDir)) {
// 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
if (inAtlasDir) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 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));
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
@@ -594,22 +681,6 @@ function discoverAssets(filePath, options) {
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null);
const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0;
const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0;
const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfSkel(f) === chosenFamily &&
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) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))]
.filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp));
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
@@ -621,7 +692,6 @@ function discoverAssets(filePath, options) {
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
relatedDataPaths,
};
}
@@ -633,15 +703,14 @@ function parseAtlasPages(atlasText) {
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) {
pages.push(line);
if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
pages.push(validateAtlasPageName(line));
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等)
return pages.filter((p) => !/[=,:]/.test(p));
return [...new Set(pages)];
}
/**
@@ -694,6 +763,8 @@ module.exports = {
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
validateAtlasPageName,
resolveAtlasTextureFile,
parseAtlasPma,
preferredSkin,
applySkin,
+3
View File
@@ -5,6 +5,9 @@
"main": "thumbnail/spine.js",
"private": true,
"type": "commonjs",
"scripts": {
"check:release": "node tests/release-package.test.js"
},
"dependencies": {
"@napi-rs/canvas": "^0.1.66"
}
Binary file not shown.
+8 -1
View File
@@ -9,7 +9,14 @@ Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内,并保留文件名配对关系。
2. 打开骨骼或图集文件即可预览。普通图片仍使用 Eagle 原生预览。
3. 支持动画和皮肤切换、播放/暂停(空格)、循环、速度、时间轴拖动、缩放适配、背景与预乘 Alpha(PMA)切换。
4. 自动生成带 Spine 标记和版本号的缩略图,也可在预览器中将当前帧设为缩略图。
4. 自动生成带 Spine 标记和版本号的当前素材缩略图,也可在预览器中主动将当前帧设为当前素材的自定义缩略图。已有自定义缩略图时会先确认;插件不会自动改写同组其他素材。
5. 若导入时资源尚未配齐,会先显示回退图;补齐后请使用 Eagle 的“刷新缩略图”重新生成。
## 安全与隐私
- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。跨素材配对只使用当前 Eagle 素材库中已枚举并验证的素材。
- 自动缩略图只写 Eagle 为当前素材提供的目标,不会改写同组其他素材。手动设置也只作用于当前素材;已有自定义缩略图时会先确认。
- 诊断日志默认关闭。显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时,日志只写系统临时目录,不写插件目录,也不会进入安装包。
## 平台与依赖
@@ -18,8 +18,8 @@
"pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden",
"background": "Hintergrund",
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
"thumbnail": "Vorschaubild",
"thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen",
"thumbnail": "Als aktuelles Vorschaubild setzen",
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
"versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden",
"animationList": "Animationsliste",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle-API meldete HTTP {status}",
"noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden",
"noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln",
"itemMismatch": "Die Vorschaudatei stimmt nicht mit dem Eagle-Element überein; die Aktualisierung wurde abgebrochen",
"replaceCustomThumbnailConfirm": "Dieses Element hat bereits ein benutzerdefiniertes Vorschaubild. Beim Fortfahren wird es ersetzt; das Plugin kann das vorherige Bild nicht automatisch wiederherstellen. Fortfahren?",
"thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}",
"imageLoadFailed": "Bild konnte nicht geladen werden",
"settingsNotReady": "Asset-Einstellungen sind noch nicht bereit",
@@ -18,8 +18,8 @@
"pmaTitle": "For textures exported with Premultiplied Alpha (PMA)",
"background": "Background",
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
"thumbnail": "Set thumbnail",
"thumbnailTitle": "Generate a 400×400 thumbnail from the current frame",
"thumbnail": "Set current item thumbnail",
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
"versionTitle": "Spine version",
"reloadTitle": "Reload folder",
"animationList": "Animation list",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API returned HTTP {status}",
"noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail",
"noItemId": "Cannot determine the Eagle item ID from the asset path",
"itemMismatch": "The preview file does not match the Eagle item; the thumbnail update was cancelled",
"replaceCustomThumbnailConfirm": "This item already has a custom thumbnail. Continuing will replace it, and the plugin cannot automatically restore the previous image. Continue?",
"thumbnailFailed": "Failed to generate the thumbnail: {error}",
"imageLoadFailed": "Failed to load the image",
"settingsNotReady": "Asset settings are not ready",
@@ -18,8 +18,8 @@
"pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)",
"background": "Fondo",
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
"thumbnail": "Crear miniatura",
"thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual",
"thumbnail": "Usar como miniatura actual",
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
"versionTitle": "Versión de Spine",
"reloadTitle": "Recargar carpeta",
"animationList": "Lista de animaciones",
@@ -54,6 +54,8 @@
"apiHttp": "La API de Eagle devolvió HTTP {status}",
"noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura",
"noItemId": "No se puede determinar el ID del elemento de Eagle a partir de la ruta",
"itemMismatch": "El archivo de vista previa no coincide con el elemento de Eagle; se canceló la actualización de la miniatura",
"replaceCustomThumbnailConfirm": "Este elemento ya tiene una miniatura personalizada. Si continúas, se reemplazará y el complemento no podrá restaurar automáticamente la imagen anterior. ¿Continuar?",
"thumbnailFailed": "No se pudo generar la miniatura: {error}",
"imageLoadFailed": "No se pudo cargar la imagen",
"settingsNotReady": "La configuración del recurso aún no está lista",
@@ -18,8 +18,8 @@
"pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用",
"background": "背景",
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "サムネイル生成",
"thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成",
"thumbnail": "現在の項目のサムネイルに設定",
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
"versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み",
"animationList": "アニメーション一覧",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API が HTTP {status} を返しました",
"noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません",
"noItemId": "素材パスから Eagle の項目 ID を特定できません",
"itemMismatch": "プレビューファイルと Eagle の項目情報が一致しないため、サムネイルの更新を中止しました",
"replaceCustomThumbnailConfirm": "この項目には既にカスタムサムネイルがあります。続行すると置き換えられ、以前の画像をプラグインで自動復元できません。続行しますか?",
"thumbnailFailed": "サムネイルの生成に失敗しました:{error}",
"imageLoadFailed": "画像の読み込みに失敗しました",
"settingsNotReady": "素材の設定がまだ準備できていません",
@@ -18,8 +18,8 @@
"pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용",
"background": "배경",
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "썸네일 생성",
"thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성",
"thumbnail": "현재 항목 썸네일로 설정",
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
"versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기",
"animationList": "애니메이션 목록",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API가 HTTP {status}를 반환했습니다",
"noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다",
"noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다",
"itemMismatch": "미리보기 파일과 Eagle 항목 정보가 일치하지 않아 썸네일 업데이트를 취소했습니다",
"replaceCustomThumbnailConfirm": "이 항목에는 이미 사용자 지정 썸네일이 있습니다. 계속하면 교체되며 플러그인에서 이전 이미지를 자동 복원할 수 없습니다. 계속하시겠습니까?",
"thumbnailFailed": "썸네일 생성 실패: {error}",
"imageLoadFailed": "이미지를 불러오지 못했습니다",
"settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다",
@@ -18,8 +18,8 @@
"pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)",
"background": "Фон",
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "Создать миниатюру",
"thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра",
"thumbnail": "Сделать миниатюрой элемента",
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
"versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку",
"animationList": "Список анимаций",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API вернул HTTP {status}",
"noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру",
"noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу",
"itemMismatch": "Файл предпросмотра не соответствует элементу Eagle; обновление миниатюры отменено",
"replaceCustomThumbnailConfirm": "У этого элемента уже есть пользовательская миниатюра. При продолжении она будет заменена, и плагин не сможет автоматически восстановить прежнее изображение. Продолжить?",
"thumbnailFailed": "Не удалось создать миниатюру: {error}",
"imageLoadFailed": "Не удалось загрузить изображение",
"settingsNotReady": "Настройки ресурса ещё не готовы",
@@ -18,8 +18,8 @@
"pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图",
"background": "背景",
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "生成缩略图",
"thumbnailTitle": "使用当前帧生成 400×400 缩略图",
"thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录",
"animationList": "动画列表",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API 返回 HTTP {status}",
"noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图",
"noItemId": "无法从素材路径识别 Eagle 项目 ID",
"itemMismatch": "当前预览文件与 Eagle 素材信息不一致,已取消缩略图更新",
"replaceCustomThumbnailConfirm": "当前素材已有自定义缩略图。继续后将替换它,且插件无法自动恢复原图。是否继续?",
"thumbnailFailed": "生成缩略图失败: {error}",
"imageLoadFailed": "图片加载失败",
"settingsNotReady": "素材设置尚未就绪",
@@ -18,8 +18,8 @@
"pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖",
"background": "背景",
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "產生縮圖",
"thumbnailTitle": "使用目前影格產生 400×400 縮圖",
"thumbnail": "設為目前素材縮圖",
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
"versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄",
"animationList": "動畫清單",
@@ -54,6 +54,8 @@
"apiHttp": "Eagle API 回傳 HTTP {status}",
"noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖",
"noItemId": "無法從素材路徑辨識 Eagle 項目 ID",
"itemMismatch": "目前預覽檔案與 Eagle 素材資訊不一致,已取消縮圖更新",
"replaceCustomThumbnailConfirm": "目前素材已有自訂縮圖。繼續後將取代它,且外掛無法自動還原原圖。是否繼續?",
"thumbnailFailed": "產生縮圖失敗:{error}",
"imageLoadFailed": "圖片載入失敗",
"settingsNotReady": "素材設定尚未就緒",
@@ -1,85 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const utils = require('./spine-utils');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function enqueue(src) {
const library = utils.findLibraryRoot(src);
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
fs.mkdirSync(directory, { recursive: true });
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
fs.renameSync(temp, job);
// Electron's Node mode runs the local worker without opening another Eagle.
const child = spawn(process.execPath, [__filename, directory], {
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
});
child.on('error', error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
});
child.unref();
}
async function run(directory) {
const lock = path.join(directory, 'worker.lock');
let fd;
try { fd = fs.openSync(lock, 'wx'); }
catch (error) {
if (error.code !== 'EEXIST') throw error;
let pid;
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
if (!pid) return; // Another worker is still writing its lock.
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
try { fs.unlinkSync(lock); } catch (_) { return; }
return run(directory);
}
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
const snapshots = new Map();
const nextChecks = new Map();
const thumbnail = require('../thumbnail/spine');
try {
let idle = 0;
while (idle < 2) {
await sleep(1000); // Allow the importing callback/window to finish.
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
if (!jobs.length) { idle++; continue; }
idle = 0;
for (const name of jobs) {
const file = path.join(directory, name);
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
const job = JSON.parse(raw);
if (Date.now() < (nextChecks.get(name) || 0)) continue;
const remaining = job.deadline - Date.now();
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
const result = Date.now() > job.deadline ? { complete: true }
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
snapshots.set(name, result.signature);
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
}
} catch (error) {
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
}
}
}
} finally {
fs.unlinkSync(lock);
// Cover an enqueue racing the final idle check and lock release.
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
}
}
module.exports = { enqueue, run };
if (require.main === module) run(process.argv[2]).catch(error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
process.exitCode = 1;
});
@@ -59,7 +59,7 @@ function loadRuntime(dir) {
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
function buildTextureAtlas(spine, atlasText, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => {
(async () => {
@@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
const textureMap = {};
await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
if (!fs.existsSync(p)) return;
// 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
// 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
const p = texturePaths && texturePaths[name];
if (!p || !fs.existsSync(p)) return;
try {
const img = await canvasLib.loadImage(p);
textureMap[name] = img;
@@ -150,12 +151,11 @@ function getBounds(spine, skeleton) {
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
const atlas = await buildTextureAtlas(spine, atlasText, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
+100 -29
View File
@@ -22,6 +22,80 @@ const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i;
function unsafeAtlasPath(name, reason) {
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
error.code = 'UNSAFE_ATLAS_PATH';
return error;
}
/**
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
*/
function validateAtlasPageName(name) {
const raw = String(name == null ? '' : name).trim();
if (!raw) throw unsafeAtlasPath(name, '路径为空');
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
const portable = raw.replace(/\\/g, '/');
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
throw unsafeAtlasPath(raw, '不允许绝对路径');
}
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
const parts = portable.split('/');
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
const normalized = path.posix.normalize(portable);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw unsafeAtlasPath(raw, '路径无效');
}
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
}
return normalized.replace(/^\.\//, '');
}
function isPathInside(basePath, targetPath) {
const relative = path.relative(basePath, targetPath);
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
}
function realpath(filePath) {
const implementation = fs.realpathSync.native || fs.realpathSync;
return implementation(filePath);
}
/**
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
*/
function resolveFileWithin(baseDir, relativeName, atlasPage) {
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
const base = path.resolve(baseDir);
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
if (!isPathInside(base, candidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
return null;
}
if (!fs.existsSync(candidate)) return null;
let realBase;
let realCandidate;
try {
realBase = realpath(base);
realCandidate = realpath(candidate);
} catch (e) {
return null;
}
if (!isPathInside(realBase, realCandidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
return null;
}
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
return candidate;
}
function resolveAtlasTextureFile(atlasDir, pageName) {
return resolveFileWithin(atlasDir, pageName, true);
}
function extOf(name) {
const lower = name.toLowerCase();
for (let i = 0; i < SKELETON_EXTS.length; i++) {
@@ -335,12 +409,24 @@ function collectSpineFiles(roots, validFolders) {
const seen = new Set();
const out = [];
for (const root of roots) {
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
const probe = path.join(root, '__spineviewer_probe__');
const libRoot = findLibraryRoot(probe);
if (libRoot) {
try {
const imagesReal = realpath(path.join(libRoot, 'images'));
const rootReal = realpath(root);
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
} catch (e) { continue; }
}
let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) {
const full = path.join(root, name);
try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; }
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
const full = resolveFileWithin(root, name, false);
if (!full) continue;
// 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) {
@@ -576,15 +662,16 @@ function discoverAssets(filePath, options) {
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠)
const inAtlasDir = path.join(atlasDir, pn);
if (fs.existsSync(inAtlasDir)) {
// 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
if (inAtlasDir) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 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));
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
@@ -594,22 +681,6 @@ function discoverAssets(filePath, options) {
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null);
const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0;
const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0;
const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfSkel(f) === chosenFamily &&
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) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))]
.filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp));
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
@@ -621,7 +692,6 @@ function discoverAssets(filePath, options) {
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
relatedDataPaths,
};
}
@@ -633,15 +703,14 @@ function parseAtlasPages(atlasText) {
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) {
pages.push(line);
if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
pages.push(validateAtlasPageName(line));
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等)
return pages.filter((p) => !/[=,:]/.test(p));
return [...new Set(pages)];
}
/**
@@ -694,6 +763,8 @@ module.exports = {
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
validateAtlasPageName,
resolveAtlasTextureFile,
parseAtlasPma,
preferredSkin,
applySkin,
@@ -1,16 +1,17 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
/** 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
const logDir = path.join(os.tmpdir(), 'SpineViewer');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
@@ -18,10 +19,9 @@ function log(msg) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const discoveryJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v14';
const VERSION = 'v15';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
@@ -132,7 +132,7 @@ module.exports = async (options) => {
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
const result = await generate(options);
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
@@ -144,12 +144,10 @@ module.exports = async (options) => {
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
}
const ext = utils.extOf(path.basename(src));
if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src);
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
async function generate({ src, dest, item }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
@@ -217,7 +215,6 @@ async function generate({ src, dest, item, deferRefresh }) {
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -255,7 +252,6 @@ async function generate({ src, dest, item, deferRefresh }) {
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -263,7 +259,6 @@ async function generate({ src, dest, item, deferRefresh }) {
}
fs.writeFileSync(dest, png);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
@@ -293,128 +288,6 @@ function resolve(item) {
return item;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return false;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return true;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
return true;
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
return false;
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
try { require('../js/spine-reconcile-worker.js').enqueue(src); }
catch (error) { log('无法启动后台补刷: ' + String(error)); }
}
// Called by a separate Node worker; Eagle closes the thumbnail window when
// its callback resolves, so timers in that window cannot finish this work.
module.exports.reconcileOnce = async (src, previousSignature) => {
if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true };
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true });
const signature = assetSignature(assets);
if (!signature || signature !== previousSignature) return { signature, complete: false };
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) return { signature, complete: true };
const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png');
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(temp, png);
const complete = await refreshRelatedThumbnails(assets, temp);
return { signature, complete };
} catch (error) {
log('后台补刷重试: ' + String(error));
return { signature, complete: false };
} finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } }
};
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;
+30 -26
View File
@@ -25,7 +25,7 @@
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label>
<div class="spacer"></div>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="使用当前帧生成 400×400 缩略图">生成缩略图</button>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
</div>
@@ -64,6 +64,8 @@
// 使用 Node 集成提供的模块
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
@@ -95,10 +97,13 @@
}
}
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
// 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
const logDir = path.join(os.tmpdir(), 'SpineViewer');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
@@ -477,14 +482,22 @@
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) {
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const info = await response.json();
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
throw new Error(t('viewer.compoundName'));
if (info.data.ext === 'txt') throw new Error(t('viewer.txtRegistered'));
async function getCurrentEagleItem() {
const pathItemId = eagleItemIdFromPath(filePath);
const urlItemId = urlParams.get('id');
if (!pathItemId || (urlItemId && urlItemId !== pathItemId)) throw new Error(t('viewer.itemMismatch'));
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(pathItemId));
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) {
throw new Error(t('viewer.apiHttp', { status: response.status }));
}
if (result.data.name + '.' + result.data.ext !== path.basename(filePath)) throw new Error(t('viewer.itemMismatch'));
if (result.data.ext === 'txt' && /\.atlas\.txt$/i.test(filePath)) throw new Error(t('viewer.txtRegistered'));
return { id: pathItemId, data: result.data };
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -503,8 +516,10 @@
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
setStatus(t('viewer.generating'), Infinity);
try {
const currentItem = await getCurrentEagleItem();
if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return;
setStatus(t('viewer.generating'), Infinity);
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
playing = false;
updatePlayButton();
@@ -547,25 +562,14 @@
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
const buf = Buffer.from(out.split(',')[1], 'base64');
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath);
const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png'));
const tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + (isWebp ? '.webp' : '.png'));
try {
if (!itemId) throw new Error(t('viewer.noItemId'));
fs.writeFileSync(tempPath, buf);
await setEagleCustomThumbnail(itemId, tempPath);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)');
log('用户主动更新当前素材缩略图: item=' + currentItem.id + ' (' + buf.length + 'B)');
} catch (e) {
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
try {
fs.writeFileSync(outPath, buf);
setStatus(t('viewer.thumbWrittenNoRefresh', { error: (e && e.message) || String(e) }), 6000);
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
} catch (e2) {
setStatus(t('viewer.thumbnailFailed', { error: (e2 && e2.message) || String(e2) }), 6000);
}
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
+5 -2
View File
@@ -10,8 +10,11 @@ const root=path.resolve(process.argv[2]||path.join(__dirname,'.fixtures'));
const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas');
if(!fs.existsSync(atlasPath))continue;
const text=fs.readFileSync(atlasPath,'utf8');
for(const page of utils.parseAtlasPages(text))assert(fs.existsSync(path.join(dir,page)),version+'/'+name+' missing '+page);
const atlas=await render.buildTextureAtlas(spine,text,dir);
const texturePaths={};
for(const page of utils.parseAtlasPages(text)){
const texture=utils.resolveAtlasTextureFile(dir,page);assert(texture,version+'/'+name+' missing '+page);texturePaths[page]=texture;
}
const atlas=await render.buildTextureAtlas(spine,text,texturePaths);
for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){
const source=path.join(dir,file),detected=utils.detectVersion(source);
assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version');
-44
View File
@@ -1,44 +0,0 @@
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execFile } = require('child_process');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-worker-lifecycle-'));
const library = path.join(root, 'test.library');
const source = path.join(library, 'images', 'test.info', 'hero.atlas.txt');
const marker = path.join(root, 'completed');
const worker = path.resolve(__dirname, '../js/spine-reconcile-worker.js');
const thumbnail = path.resolve(__dirname, '../thumbnail/spine.js');
const key = crypto.createHash('sha256').update(path.dirname(worker) + library).digest('hex');
const queue = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
(async () => {
try {
fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync(source, 'atlas');
fs.writeFileSync(path.join(library, 'metadata.json'), '{"folders":[]}');
// Isolate lifecycle behavior from rendering and the user's running Eagle API.
const preload = path.join(root, 'preload.cjs');
fs.writeFileSync(preload, `require.cache[${JSON.stringify(thumbnail)}] = { exports: {
reconcileOnce: async (src, previous) => {
if (previous !== 'stable') return { signature: 'stable', complete: false };
require('fs').appendFileSync(${JSON.stringify(marker)}, String(process.pid) + '\\n');
return { complete: true };
}
}};`);
await new Promise((resolve, reject) => execFile(process.execPath, ['-e', `require(${JSON.stringify(worker)}).enqueue(${JSON.stringify(source)})`],
{ env: { ...process.env, NODE_OPTIONS: '--require=' + preload } }, error => error ? reject(error) : resolve()));
assert(!fs.existsSync(marker), 'launcher returns before reconciliation');
const deadline = Date.now() + 15000;
while ((!fs.existsSync(marker) || fs.existsSync(path.join(queue, 'worker.lock'))) && Date.now() < deadline) await sleep(100);
assert(fs.existsSync(marker), 'worker completes after launching process has exited');
assert(!fs.existsSync(path.join(queue, 'worker.lock')), 'worker releases lock and exits when idle');
assert.equal(fs.readdirSync(queue).filter(name => name.endsWith('.json')).length, 0);
console.log('PASS: detached worker survives callback process exit, completes queued work and releases lock');
} finally {
if (!fs.existsSync(path.join(queue, 'worker.lock'))) {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(queue, { recursive: true, force: true });
}
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+43
View File
@@ -0,0 +1,43 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const releaseDir = path.join(root, 'release', 'UNIVERSAL_SpineViewer');
const archive = path.join(root, 'release', 'SpineViewer.eagleplugin');
function walk(directory, prefix = '') {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const relative = path.posix.join(prefix, entry.name);
if (entry.isDirectory()) files.push(...walk(path.join(directory, entry.name), relative));
else files.push(relative);
}
return files;
}
function zipEntries(file) {
const data = fs.readFileSync(file);
const entries = [];
for (let offset = 0; offset + 46 <= data.length;) {
const header = data.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]), offset);
if (header < 0) break;
const nameLength = data.readUInt16LE(header + 28);
const extraLength = data.readUInt16LE(header + 30);
const commentLength = data.readUInt16LE(header + 32);
entries.push(data.subarray(header + 46, header + 46 + nameLength).toString('utf8'));
offset = header + 46 + nameLength + extraLength + commentLength;
}
return entries;
}
for (const [label, files] of [
['release directory', walk(releaseDir)],
['eagleplugin archive', zipEntries(archive)],
]) {
assert.equal(files.some((file) => /\.log$/i.test(file)), false, label + ' must not contain runtime or historical logs');
assert.equal(files.some((file) => /(^|\/)\.DS_Store$/i.test(file)), false, label + ' must not contain macOS metadata');
}
console.log('PASS: release directory and .eagleplugin contain no logs or .DS_Store files');
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const utils = require('../js/spine-utils');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-security-'));
try {
for (const unsafe of [
'../../../secret.png',
'..\\..\\secret.png',
'/tmp/secret.png',
'C:\\secret.png',
'C:secret.png',
'\\\\server\\share\\secret.png',
'file:///tmp/secret.png',
'folder/../secret.png',
'folder\\..\\secret.png',
'bad\0name.png',
]) {
assert.throws(() => utils.validateAtlasPageName(unsafe), (error) => error && error.code === 'UNSAFE_ATLAS_PATH', unsafe);
}
assert.equal(utils.validateAtlasPageName('./textures/page.png'), 'textures/page.png');
const atlasDir = path.join(root, 'atlas');
fs.mkdirSync(path.join(atlasDir, 'textures'), { recursive: true });
const nestedTexture = path.join(atlasDir, 'textures', 'page.png');
fs.writeFileSync(nestedTexture, 'texture');
assert.equal(utils.resolveAtlasTextureFile(atlasDir, 'textures/page.png'), nestedTexture);
const outside = path.join(root, 'outside.png');
const link = path.join(atlasDir, 'linked.png');
fs.writeFileSync(outside, 'private');
try {
fs.symlinkSync(outside, link);
assert.throws(() => utils.resolveAtlasTextureFile(atlasDir, 'linked.png'), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
} catch (error) {
if (!['EPERM', 'EACCES'].includes(error.code)) throw error;
}
const library = path.join(root, 'safe.library');
const images = path.join(library, 'images');
fs.mkdirSync(images, { recursive: true });
fs.writeFileSync(path.join(library, 'metadata.json'), JSON.stringify({ folders: [{ id: 'spine', name: 'Spine', children: [] }] }));
function item(id, name, contents) {
const dir = path.join(images, id + '.info');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({ id, name: path.parse(name).name, ext: path.extname(name).slice(1), folders: ['spine'], isDeleted: false }));
const file = path.join(dir, name);
fs.writeFileSync(file, contents);
return file;
}
const skeleton = item('SKELETON', 'hero.skel', Buffer.from('\0' + '3.8.1\0'));
item('ATLAS', 'hero.atlas', 'textures/hero.png\nsize: 64,64\n');
const texture = item('TEXTURE', 'hero.png', 'texture');
const assets = utils.discoverAssets(skeleton, { strictStem: true, refreshIndex: true });
assert.equal(assets.texturePaths['textures/hero.png'], texture, 'cross-item texture must come from a validated Eagle item');
const maliciousDir = path.join(root, 'malicious');
fs.mkdirSync(maliciousDir);
const maliciousSkeleton = path.join(maliciousDir, 'evil.skel');
fs.writeFileSync(maliciousSkeleton, Buffer.from('\0' + '3.8.1\0'));
fs.writeFileSync(path.join(maliciousDir, 'evil.atlas'), '../../../secret.png\nsize: 64,64\n');
assert.throws(() => utils.discoverAssets(maliciousSkeleton), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
const thumbnailSource = fs.readFileSync(path.join(__dirname, '../thumbnail/spine.js'), 'utf8');
assert.doesNotMatch(thumbnailSource, /setCustomThumbnail|refreshRelatedThumbnails|spine-reconcile-worker|relatedDataPaths/);
assert.equal(fs.existsSync(path.join(__dirname, '../js/spine-reconcile-worker.js')), false);
const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/);
assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/);
assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/);
console.log('PASS: atlas traversal, absolute paths, symlink escape, validated Eagle pairing, and no automatic cross-item thumbnail writes');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+1 -1
View File
@@ -7,7 +7,7 @@ const { normalizeItem } = require('../js/eagle-compound');
const generate = require('../thumbnail/spine');
const { canvasLib } = require('../js/spine-render');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-'));
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures/3.4/spineboy'));
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '34-43/3.4/spineboy'));
(async () => {
try {
+2 -2
View File
@@ -4,7 +4,7 @@ const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const { createRequire } = require('node:module');
const roots = ['.', 'release/MAC_arm64_SpineViewer', 'release/WIN_x64_SpineViewer'];
const roots = ['.', 'release/UNIVERSAL_SpineViewer'];
for (const root of roots) {
const html = fs.readFileSync(path.join(root, 'viewer/spine.html'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
@@ -40,4 +40,4 @@ for (const root of roots) {
assert.equal(children[0].title, table.viewer.staticPoseTitle);
}
}
console.log('PASS: source and both releases; eight languages; animated/static keyframe labels and translation keys');
console.log('PASS: source and universal release; eight languages; animated/static keyframe labels and translation keys');
+8 -135
View File
@@ -1,16 +1,17 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
/** 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。 */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
const logDir = path.join(os.tmpdir(), 'SpineViewer');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
@@ -18,10 +19,9 @@ function log(msg) {
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const discoveryJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v14';
const VERSION = 'v15';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
@@ -132,7 +132,7 @@ module.exports = async (options) => {
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
const result = await generate(options);
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
@@ -144,12 +144,10 @@ module.exports = async (options) => {
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
}
const ext = utils.extOf(path.basename(src));
if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src);
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
async function generate({ src, dest, item }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
@@ -217,7 +215,6 @@ async function generate({ src, dest, item, deferRefresh }) {
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -255,7 +252,6 @@ async function generate({ src, dest, item, deferRefresh }) {
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
@@ -263,7 +259,6 @@ async function generate({ src, dest, item, deferRefresh }) {
}
fs.writeFileSync(dest, png);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
@@ -293,128 +288,6 @@ function resolve(item) {
return item;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return false;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return true;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
return true;
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
return false;
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
try { require('../js/spine-reconcile-worker.js').enqueue(src); }
catch (error) { log('无法启动后台补刷: ' + String(error)); }
}
// Called by a separate Node worker; Eagle closes the thumbnail window when
// its callback resolves, so timers in that window cannot finish this work.
module.exports.reconcileOnce = async (src, previousSignature) => {
if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true };
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true });
const signature = assetSignature(assets);
if (!signature || signature !== previousSignature) return { signature, complete: false };
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) return { signature, complete: true };
const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png');
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(temp, png);
const complete = await refreshRelatedThumbnails(assets, temp);
return { signature, complete };
} catch (error) {
log('后台补刷重试: ' + String(error));
return { signature, complete: false };
} finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } }
};
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;
+30 -26
View File
@@ -25,7 +25,7 @@
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label>
<div class="spacer"></div>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="使用当前帧生成 400×400 缩略图">生成缩略图</button>
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
</div>
@@ -64,6 +64,8 @@
// 使用 Node 集成提供的模块
const path = require('path');
const fs = require('fs');
const os = require('os');
const crypto = require('crypto');
const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js');
@@ -95,10 +97,13 @@
}
}
// 简易日志(写入插件目录 thumbnails.log,便于诊断)
// 诊断日志默认关闭;显式开启时只写系统临时目录,绝不写入插件包。
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
if (process.env.SPINEVIEWER_DEBUG_LOG !== '1') return;
const logDir = path.join(os.tmpdir(), 'SpineViewer');
fs.mkdirSync(logDir, { recursive: true });
const logPath = path.join(logDir, 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
@@ -477,14 +482,22 @@
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
if (/\.(atlas\.txt|skel\.bytes)$/i.test(filePath)) {
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
const info = await response.json();
if (!response.ok || info.status !== 'success' || info.data.name + '.' + info.data.ext !== path.basename(filePath))
throw new Error(t('viewer.compoundName'));
if (info.data.ext === 'txt') throw new Error(t('viewer.txtRegistered'));
async function getCurrentEagleItem() {
const pathItemId = eagleItemIdFromPath(filePath);
const urlItemId = urlParams.get('id');
if (!pathItemId || (urlItemId && urlItemId !== pathItemId)) throw new Error(t('viewer.itemMismatch'));
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(pathItemId));
let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) {
throw new Error(t('viewer.apiHttp', { status: response.status }));
}
if (result.data.name + '.' + result.data.ext !== path.basename(filePath)) throw new Error(t('viewer.itemMismatch'));
if (result.data.ext === 'txt' && /\.atlas\.txt$/i.test(filePath)) throw new Error(t('viewer.txtRegistered'));
return { id: pathItemId, data: result.data };
}
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
const response = await fetch('http://localhost:41595/api/item/setCustomThumbnail', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
@@ -503,8 +516,10 @@
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const thumbBtn = document.getElementById('thumbBtn');
thumbBtn.disabled = true;
setStatus(t('viewer.generating'), Infinity);
try {
const currentItem = await getCurrentEagleItem();
if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return;
setStatus(t('viewer.generating'), Infinity);
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
playing = false;
updatePlayButton();
@@ -547,25 +562,14 @@
const isWebp = out.startsWith('data:image/webp');
if (!isWebp) out = off.toDataURL('image/png');
const buf = Buffer.from(out.split(',')[1], 'base64');
const stem = (utils.stemOf && utils.stemOf(path.basename(filePath))) || path.basename(filePath).replace(/\.[^.]*$/, '');
const itemId = urlParams.get('id') || eagleItemIdFromPath(filePath);
const outPath = path.join(path.dirname(filePath), stem + '_thumbnail.png');
const tempPath = path.join(path.dirname(filePath), '.' + stem + '-thumbnail-' + Date.now() + (isWebp ? '.webp' : '.png'));
const tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + (isWebp ? '.webp' : '.png'));
try {
if (!itemId) throw new Error(t('viewer.noItemId'));
fs.writeFileSync(tempPath, buf);
await setEagleCustomThumbnail(itemId, tempPath);
await setEagleCustomThumbnail(currentItem.id, tempPath);
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
log('通过 Eagle API 更新缩略图: item=' + itemId + ' (' + buf.length + 'B)');
log('用户主动更新当前素材缩略图: item=' + currentItem.id + ' (' + buf.length + 'B)');
} catch (e) {
// 兼容旧版 Eagle:保留原先的磁盘写入方案,但明确提示可能需要手动刷新列表。
try {
fs.writeFileSync(outPath, buf);
setStatus(t('viewer.thumbWrittenNoRefresh', { error: (e && e.message) || String(e) }), 6000);
log('Eagle API 更新失败,已回退写入: ' + ((e && e.message) || String(e)));
} catch (e2) {
setStatus(t('viewer.thumbnailFailed', { error: (e2 && e2.message) || String(e2) }), 6000);
}
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
} finally {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}