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:
1 parent
473a270510
commit
bb73cc9227
36 files changed
+499
-652
No files matched your search
Binary file not shown.
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 */ }
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user