51 Commits
Author SHA1 Message Date
tianmo 725f7d5e24 eagle包更新 2026-11-06 17:09:21 +08:00
tianmo d0112e0016 修正 2026-11-06 14:11:49 +08:00
tianmo e7f800aa8d 修正识别问题 2026-11-06 10:44:34 +08:00
tianmo d02370d6bd 自动缩略图插件审核修改 2026-11-05 19:35:14 +08:00
tianmo f4e69707c3 网页优化 2026-10-31 15:26:22 +08:00
tianmo 49dd3b9472 物料修正 2026-10-31 10:38:08 +08:00
tianmo 0aea133888 进度条弹窗重新修复 2026-10-30 16:49:52 +08:00
tianmo 55dc52be37 进度修复 2026-10-30 16:31:27 +08:00
tianmo 63663f3bb7 进度修复 2026-10-30 16:26:18 +08:00
tianmo 5799d5d049 进度bug修复 2026-10-30 16:08:36 +08:00
tianmo 406bf21b2a logo修正 2026-10-29 15:46:31 +08:00
tianmo 5d33302858 1.1.2插件包 2026-10-29 15:14:52 +08:00
tianmo 4a34d0c46a 版本升级到1.1.2 2026-10-29 15:08:24 +08:00
tianmo 5fb210e5a8 站点 2026-10-29 11:57:42 +08:00
tianmo 224ba24fab 文件移动 2026-10-29 11:41:26 +08:00
tianmo 21a8b5e24e 宣传物料 2026-10-29 11:34:33 +08:00
tianmo 3bd3ba7404 八国语言+eagle图片 2026-10-29 11:02:01 +08:00
tianmo acc93d5404 干净发布副本 2026-10-28 19:19:04 +08:00
tianmo c29de7a26b 增加物理结算 2026-09-22 15:59:22 +08:00
tianmo 650d9c0398 弹窗优化 2026-09-21 10:30:33 +08:00
tianmo be058c6c80 临时文件兜底 2026-09-21 10:06:53 +08:00
Yeomai 08456f26cf win mac双平台修正 2026-09-20 23:33:36 +08:00
Yeomai 07c21f5954 自动缩略图插件 2026-09-20 23:29:13 +08:00
Yeomai 96a0db09d8 缩略图插件 2026-09-20 21:22:03 +08:00
Yeomai 065b6466f1 缩略图自动生成背景服务插件 2026-09-20 20:34:17 +08:00
Yeomai c28adf9fae 优化缩略图 2026-09-20 02:31:07 +08:00
Yeomai fe4997f913 优化缩略图 2026-09-20 02:15:46 +08:00
Yeomai 081520dbd9 优化缩略图 2026-09-20 01:14:46 +08:00
Yeomai e94dfb9e35 缩略图二分定位 + 邻域采样
B. 二分定位 + 邻域采样(我推荐)
把配置的语义从"这就是要生成的 10 档"改成"允许搜索的参数梯度"(比如分辨率梯度、帧率梯度、质量梯度三张表)。运行时:
1. 用每个组合只编 2 帧的方式粗估体积(很便宜);
2. 靠单调性做二分,几轮就找到"体积刚好不超过目标"的参数点;
3. 在这个点周围取 10 个组合(略高、略低各几个),完整生成。
成本大概是十几次估算编码 + 10 档完整编码,和现在基本持平甚至更省(估算只编 2 帧)。效果是候选真正围绕你输入的目标分布——比如你填 30 KB,出来的就是 22、26、29、31、35 KB 这种,而不是 5 KB 和 300 KB 各一个。
代价是你现在这套配置格式要推翻重做——从"精确档位表"变成"参数范围表"
2026-09-20 00:39:38 +08:00
tianmo 5808e3dfce 按钮颜色 2026-09-19 19:01:49 +08:00
tianmo b258d736fd 悬浮提示 2026-09-19 16:05:54 +08:00
tianmo e60506ef96 修复缩放 2026-09-19 15:02:27 +08:00
tianmo e2df519d2e 显示调整 2026-09-19 11:15:44 +08:00
tianmo 081d06c70e 加入awebp编码支持 2026-09-19 10:36:32 +08:00
tianmo eb2a7f7d91 动态缩略图生成 2026-09-19 10:15:44 +08:00
tianmo 4ff35ce352 动态缩略图修改 2026-09-19 09:48:09 +08:00
Yeomai a429ebbe6b 左下角样式修改 2026-09-18 22:20:20 +08:00
tianmo c113089e2d 动态缩略图生成 2026-09-18 19:23:40 +08:00
tianmo 52bdbc9f3c 默认预览setup pose、修复播放动画时的画布缩放问题 2026-09-18 17:50:54 +08:00
Yeomai a4e86b37fc 文件封面与功能说明 2026-09-18 00:37:38 +08:00
Yeomai 392e71fb22 插件打包 2026-09-18 00:09:22 +08:00
Yeomai ff7ae2cd84 移除死文件 2026-09-18 00:07:38 +08:00
Yeomai 6366635feb 移除死链 2026-09-17 23:58:36 +08:00
Yeomai c21c55b959 eagle插件 2026-09-17 23:55:48 +08:00
Yeomai 94e9304d9e 尾缀识别 2026-09-17 23:38:30 +08:00
tianmo d9f7bd2f89 封面提交 2026-09-17 19:34:12 +08:00
tianmo 2cb70c49d4 预览扫描 2026-09-17 18:47:17 +08:00
tianmo 2a5fe0ce1f 资源组更新缩略图 2026-09-17 18:40:29 +08:00
tianmo 05c4fc4b90 缩略图生成修改 2026-09-17 14:52:18 +08:00
tianmo fe71d2d6ce 移除导入时的缩略图生成 2026-09-17 14:23:33 +08:00
tianmo bb73cc9227 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 已同步。
处理历史日志遗留问题
2026-09-17 13:38:22 +08:00
256 changed files with 177282 additions and 5061 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 lynweklm@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -46
View File
@@ -8,7 +8,7 @@
普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。 普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。
Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再检查完整文件名。导入复合后缀时,插件通过缩略图回调将 `lili.atlas.txt` 规范为 `name: "lili"`、`ext: "atlas.txt"`,`skel.bytes` 同理,磁盘文件名保持不变;同时生成匹配新名称的 WebP 缓存。这样可避开 Eagle 对 `txt` 强制显示文本卡片的逻辑。普通 TXT 保留文本内容、正常显示;普通 BYTES 显示文件说明,两者都不扫描资源库。修改注册后需要重新加载插件或重启 Eagle。旧条目重新生成缩略图后,如果列表仍保留旧名称,需要重新打开素材库。 Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再由查看器检查完整文件名。普通 JSON/TXT/BYTES 不会进入 Spine 预览。插件不注册导入缩略图处理器,导入阶段由 Eagle 自身处理。
支持的 Spine 版本:**3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。 支持的 Spine 版本:**3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
@@ -24,14 +24,12 @@ SpineViewer/
├─ logo.png # 插件图标 ├─ logo.png # 插件图标
├─ assets/ ├─ assets/
│ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge │ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge
├─ thumbnail/
│ └─ spine.js # 缩略图生成(渲染骨骼首帧并交由 Eagle 缓存)
├─ viewer/ ├─ viewer/
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配) │ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
├─ js/ ├─ js/
│ ├─ spine-utils.js # 资源匹配、版本检测工具 │ ├─ spine-utils.js # 资源匹配、版本检测工具
│ ├─ spine-legacy-binary.js # 3.4–3.7 二进制解析兼容层 │ ├─ spine-legacy-binary.js # 3.4–3.7 二进制解析兼容层
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用) │ └─ spine-render.js # Node 渲染辅助模块
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地) ├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
│ ├─ 3.4/ spine-canvas.js │ ├─ 3.4/ spine-canvas.js
│ ├─ 3.5/ spine-canvas.js │ ├─ 3.5/ spine-canvas.js
@@ -42,8 +40,7 @@ SpineViewer/
│ ├─ 4.1/ spine-canvas.js │ ├─ 4.1/ spine-canvas.js
│ ├─ 4.2/ spine-canvas.js │ ├─ 4.2/ spine-canvas.js
│ └─ 4.3/ spine-canvas.js │ └─ 4.3/ spine-canvas.js
├─ LICENSES/ # 第三方运行时及依赖的再分发许可证 └─ LICENSES/ # 第三方运行时的再分发许可证
└─ node_modules/ # 可选:@napi-rs/canvas(用于高质量缩略图渲染)
``` ```
--- ---
@@ -52,7 +49,7 @@ SpineViewer/
1. **跨文件夹资源配对**:Eagle 会把导入的每个文件拆分成独立的元数据文件夹(`images/<id>.info/`,内含资产文件与 `metadata.json`),因此配套文件可能不在同一目录。插件会: 1. **跨文件夹资源配对**:Eagle 会把导入的每个文件拆分成独立的元数据文件夹(`images/<id>.info/`,内含资产文件与 `metadata.json`),因此配套文件可能不在同一目录。插件会:
- 定位到所选文件的 item 文件夹; - 定位到所选文件的 item 文件夹;
- 扫描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma` 等常见后缀)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**; - 涋描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma`、`-base`、`-atlas`、`-data`、`-mesh` 等常见尾缀,`-` 与 `_` 均可)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**;尾缀表可在预览顶栏的“尾缀”下拉里追加自定义项;
- 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。 - 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。
2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。 2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。
3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持: 3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持:
@@ -65,13 +62,9 @@ SpineViewer/
- 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局 - 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局
- 零时长动画按单帧静态姿势预览 - 零时长动画按单帧静态姿势预览
- 自动适配并居中 - 自动适配并居中
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 - 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。 - 顶栏“尾缀”下拉列出内置尾缀(`pro` `ess` `pma` `base` `atlas` `data` `mesh`)与用户自定义尾缀;选择“+ 添加尾缀…”可新增(仅字母、数字、`-`、`_`,最长 50 字符,上限由 `js/spine-utils.js` 的 `SUFFIX_MAX_LENGTH` 统一定义),选择自定义项可移除。配置保存在 Eagle userData 的 `SpineViewer/suffix-settings.json`,对全部素材与资源库生效,修改后自动重新加载以重新配对
- 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。 4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,仍可用原有按钮将当前帧设为**当前素材**的自定义缩略图。左侧的“为关联资源生成缩略图”会先列出已验证的同 stem `json / skel / skel.bytes / atlas / atlas.txt` 和 atlas 引用贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户可主动勾选已有自定义缩略图的素材,确认后覆盖,原图无法由插件自动恢复。手动缩略图会添加 Spine badge 和导出版本号。
- JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,不进入全库资源扫描或补刷队列,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。
- 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
- Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。
--- ---
@@ -85,6 +78,8 @@ SpineViewer/
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。 Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。导入阶段不由插件生成缩略图;手动批量操作只作用于明确列出并得到确认的当前资源组,已有自定义缩略图的素材默认不选中,只有用户明确勾选才会覆盖。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。 - 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。 - 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
@@ -106,7 +101,7 @@ node tests/legacy-binary.test.js tests/.fixtures 3.4
3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。 3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。 - **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 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
- 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。 - 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。
@@ -133,51 +128,24 @@ node tests/legacy-binary.test.js
--- ---
## 可选依赖说明
`@napi-rs/canvas` 仅用于缩略图的高质量渲染。它并非必需:
- 若保留 `node_modules/`(当前平台原生二进制),缩略图会渲染骨骼形象。
- 若删除 `node_modules/`,插件仍可正常预览动画,缩略图会显示一条纯 JS 占位图。
如需在多个平台分发,请安装对应平台的 `@napi-rs/canvas` 原生二进制。
---
## 分享与打包 ## 分享与打包
### 分享源码 ### 分享源码
仓库中的 `node_modules/` 被 `.gitignore` 忽略是正常的。接收者克隆源码后,需要在插件目录执行: 源码不依赖原生 Node 扩展;加载插件后即可使用预览和手动缩略图功能。
```bash
npm ci --omit=dev
```
`@napi-rs/canvas` 会按当前操作系统和 CPU 下载预编译的 N-API 二进制,正常情况下不需要编译,也不需要安装 Rust/C++ 工具链。安装完成后,再从 Eagle 插件面板加载或打包插件。
### 分享可直接安装的插件 ### 分享可直接安装的插件
1. 在准备出包的目标系统上执行 `npm ci --omit=dev`。 1. 在 Eagle 中打开插件面板(快捷键 `P`),右键此插件并选择“打包插件”。
2. 确认插件目录中保留了 `node_modules/`。否则骨骼动画仍可预览,但自动缩略图只能使用占位图。 2. 把生成的 `.eagleplugin` 文件发给对方。安装包不含平台原生依赖,Windows 与 macOS 共用。
3. 在 Eagle 中打开插件面板(快捷键 `P`),右键此插件并选择“打包插件”。
4. 把生成的 `.eagleplugin` 文件发给对方。对方直接安装,不需要再执行 npm,也不需要编译。
当前工作目录安装的是 **macOS Apple 芯片(darwin-arm64)** 的 Canvas 原生二进制。它不能直接作为 Windows 或 Intel Mac 的完整缩略图版本分发。建议分别在以下目标环境执行安装、测试和打包:
- macOS Apple 芯片
- macOS Intel
- Windows x64
如果每个安装包只携带一个平台的原生二进制,出包时还应把 `manifest.json` 的 `platform` / `arch` 改为对应的 `mac` + `arm`、`mac` + `x64` 或 `win` + `x64`。只有安装包确实包含并测试过所有目标平台的原生二进制时,才保留当前的 `all` + `all`。
### 出包文件检查 ### 出包文件检查
完整功能包至少应包含: 完整功能包至少应包含:
- `manifest.json`、`logo.png` - `manifest.json`、`logo.png`
- `viewer/`、`thumbnail/`、`js/`、`lib/`、`assets/` - `viewer/`、`js/`、`lib/`、`assets/`
- `LICENSES/` - `LICENSES/`
- `package.json`、`package-lock.json` - `package.json`、`package-lock.json`
- 与目标平台匹配的 `node_modules/`
无需放入发布包的开发文件包括 `.git/`、日志、`.DS_Store`、`debug*.js`、`test-*.js`、`SpineViewer.code-workspace`,以及未在 `manifest.json` 中使用的 ICNS 测试文件。 无需放入发布包的开发文件包括 `.git/`、日志、`.DS_Store`、`debug*.js`、`test-*.js`、`SpineViewer.code-workspace`,以及未在 `manifest.json` 中使用的 ICNS 测试文件。
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Spine-Vorschaubilder"
}
},
"thumb": {
"title": "Spine-Vorschaubilder",
"noticeIntro": "Funktion: Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente, die noch kein Vorschaubild haben.",
"noticeSafety": "1. Die automatische Erstellung ist standardmäßig ausgeschaltet und muss unten manuell aktiviert werden. Nur in diesem Lauf neu importierte Elemente werden verarbeitet; bereits festgelegte eigene Vorschaubilder werden nie überschrieben.",
"noticeCompound": "2. Bei Elementen mit zusammengesetzter Endung werden die Felder Name und Typ in Eagle erst nach Bestätigung der aktuellen und neuen Werte geändert. Die Originalwerte werden gespeichert und können wiederhergestellt werden.",
"autoToggle": "Beim Import Vorschaubilder erstellen",
"clear": "Protokoll leeren",
"restoreNames": "Namen/Typen wiederherstellen",
"compoundConfirmTitle": "Änderung zusammengesetzter Endungen bestätigen",
"compoundConfirmIntro": "Aufgrund der Besonderheit von Dateien mit zusammengesetzter Endung muss Eagle bei diesen Elementen die Felder Name und Typ ändern, damit die erzeugten Vorschaubilder angezeigt werden können. Die Originalwerte werden gespeichert und können in diesem Plugin wiederhergestellt werden.",
"compoundOldValue": "Aktuell",
"compoundNewValue": "Neu",
"compoundSkip": "Überspringen und fortfahren",
"compoundConfirm": "Änderungen anwenden",
"compoundConfirmSelected": "Auswahl anwenden ({count})",
"compoundSkipped": "{label} übersprungen: Änderung der zusammengesetzten Endung nicht bestätigt",
"compoundSourceMismatch": "{label} übersprungen: Eagle-Name {current} stimmt nicht mit Quelldatei {source} überein",
"compoundCheckFailed": "Zusammengesetzte Endung für {label} konnte nicht geprüft werden: {error}",
"compoundNeedsConfirmation": "{label} übersprungen: Felder wurden nach der Bestätigung geändert; bitte erneut prüfen",
"compoundBackupUnavailable": "Zusammengesetzte Endungen können nicht geändert werden, weil die Originalwerte nicht gespeichert werden konnten",
"compoundRolledBack": "Vorschaubilderstellung fehlgeschlagen; Name und Typ für {label} wurden wiederhergestellt",
"compoundRollbackFailed": "Vorschaubilderstellung fehlgeschlagen und {label} konnte nicht automatisch wiederhergestellt werden: {error}",
"restoreEmpty": "Keine gespeicherten Namens-/Typänderungen zum Wiederherstellen",
"restoreTitle": "Elemente zum Wiederherstellen auswählen",
"restoreIntro": "Für diese Elemente sind Originalwerte gespeichert. Wiederherstellbare Elemente sind standardmäßig alle ausgewählt; vom Benutzer geänderte oder nicht lesbare Elemente können nicht ausgewählt werden.",
"restoreCurrent": "Aktuell",
"restoreTarget": "Wiederherstellen zu",
"restoreStatusReady": "Das Element wurde geändert zu {fields}",
"restoreStatusAlreadyOriginal": "Hat bereits den Originalwert; die Bestätigung entfernt nur die Sicherung",
"restoreStatusChanged": "Die aktuellen Felder wurden vom Benutzer geändert; wird übersprungen",
"restoreStatusMissing": "Element nicht gefunden und kann nicht wiederhergestellt werden",
"restoreStatusError": "Aktuelle Werte konnten nicht gelesen werden; Wiederherstellung nicht möglich",
"restoreCancel": "Abbrechen",
"restoreSelected": "Auswahl wiederherstellen ({count})",
"restoreComplete": "Wiederherstellung abgeschlossen: {success} erfolgreich, {skipped} übersprungen, {failed} fehlgeschlagen.",
"restoreFailed": "Namen/Typen konnten nicht wiederhergestellt werden: {error}",
"modeLabel": "Erstellungsmodus",
"modeAnimated": "Animiert (erste Animation)",
"modeStatic": "Statisch (Setup-Pose)",
"strategyLabel": "Strategie",
"strategySize": "Kleinste Datei (langsam)",
"strategySpeed": "Schnellste Methode (schnell)",
"statusLabel": "Status",
"statusStarting": "Wird gestartet…",
"libraryLabel": "Bibliothek",
"statsLabel": "Statistik",
"filterAll": "Alle",
"filterOk": "Erfolg",
"filterFail": "Fehlgeschlagen",
"filterWarn": "Warnung",
"filterError": "Fehler",
"unknownError": "Unbekannter Fehler",
"scriptError": "Skriptfehler: {error}",
"promiseError": "Unbehandelter Promise-Fehler: {error}",
"uncategorized": "Ohne Kategorie",
"reasonQuiet": "Ruhephase",
"reasonFallback": "Ersatzprüfung",
"none": "keine",
"complete": "vollständig",
"incomplete": "unvollständig",
"statusLoaded": "Skript {version} geladen; warte auf Eagle-Ereignisse…",
"statusEagleMissing": "Eagle-API nicht verfügbar",
"statusListening": "Überwachung aktiv",
"statusListenFailed": "Überwachung fehlgeschlagen",
"statusAutoOff": "Überwachung aktiv (automatische Erstellung aus)",
"statusNoLibrary": "Bibliothekspfad nicht verfügbar",
"statusPairing": "Neue Assets werden zugeordnet…",
"statusPairingProgress": "Zuordnung {current}/{total}",
"statusRendering": "Frame wird gerendert {current}/{total} ({queued} in Warteschlange)",
"statusGenerating": "Vorschaubilder werden erstellt… noch {queued}",
"statusPaused": "Pausiert (im Fortschrittsfenster fortsetzen oder abbrechen)",
"statusAutoSkipped": "Automatische Erstellung ist aus; dieser Stapel wurde übersprungen",
"statusStartFailed": "Start fehlgeschlagen: {error}",
"stats": "Stapel {batches} · Hinzugefügt {added} · Spine {spine} · Vollständig {complete} · Erstellt {generated} · Übersprungen {skipped} · Fehlgeschlagen {failed}",
"modulesMissing": "Lokale Module nicht geladen: {modules}",
"moduleLoadError": "Lokale Module wurden nicht geladen. Prüfe die Skriptreihenfolge in index.html: {modules}",
"version": "Plugin-Codeversion {version}",
"pluginCreated": "Plugin erstellt: {name}",
"settingsReadFailed": "Einstellungen konnten nicht gelesen werden; Standardwerte werden verwendet (automatische Erstellung aus): {error}",
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden: {error}",
"autoEnabled": "Automatische Erstellung aktiviert. Neu importierte Spine-Assets erhalten Vorschaubilder.",
"autoDisabled": "Automatische Erstellung deaktiviert; die Warteschlange wurde geleert.",
"modeStaticLog": "Erstellungsmodus: statisch (immer Setup-Pose, PNG)",
"modeAnimatedLog": "Erstellungsmodus: animiert (erste Animation als WebP; Setup-Pose ohne Animation)",
"strategySpeedLog": "Strategie: schnellste Methode (grobe Auswahl, nur die kleinsten ein oder zwei Profile)",
"strategySizeLog": "Strategie: kleinste Datei (alle zehn Profile erstellen und das kleinste Ergebnis behalten)",
"missingItem": "{label} übersprungen: Element nicht in Eagle gefunden",
"suffixFixed": "Zusammengesetzte Endung für {label} korrigiert → {name}",
"suffixFailed": "Zusammengesetzte Endung für {label} konnte nicht korrigiert werden: {error}",
"existingThumbnail": "{label} übersprungen: eigenes Vorschaubild vorhanden",
"thumbnailRejected": "Eagle hat dieses Vorschaubild abgelehnt",
"generated": "{label} erstellt | {details} | Spine {version}",
"generateFailed": "Erstellung für {label} fehlgeschlagen: {error}",
"cancelled": "Erstellung abgebrochen: {dropped} ausstehende Elemente verworfen. {done} abgeschlossen (Erfolg {generated}, Fehler {failed}, übersprungen {skipped})",
"batchFailed": "Importstapel konnte nicht verarbeitet werden: {error}",
"batchFinished": "Stapel abgeschlossen: {added} hinzugefügt, {total} in der Bibliothek ({reason})",
"recheck": "{count} früher unvollständige Assets werden erneut geprüft",
"noSpine": "Keine Spine-Assets in diesem Stapel",
"incompletePending": "{count} Assets sind unvollständig und werden nach späteren Importen erneut geprüft",
"incompleteIgnored": "{count} Assets sind unvollständig und wurden in diesem Stapel nicht verarbeitet",
"itemSummary": " {name} | {path} | {state} | Skelett {skeleton} | Atlas {atlas} | Seiten {pages} (fehlend {missing})",
"autoSkipped": "Automatische Erstellung ist aus; {count} Assets in diesem Stapel übersprungen",
"queued": "{added} zur Warteschlange hinzugefügt; {queued} warten{state}",
"queuedRunning": " (wird verarbeitet)",
"libraryInfoNoPath": "library.info() hat keinen Pfad zurückgegeben: {info}",
"libraryInfoFailed": "library.info() fehlgeschlagen: {error}",
"switchingLibrary": "Bibliothek wird gewechselt: {path}",
"noCreateEvent": "Kein Plugin-Erstellungsereignis erhalten; direkter Start wird versucht",
"watching": "{path} wird überwacht ({count} vorhandene Elemente gelten als Verlauf)",
"watchReadFailed": "Asset-Verzeichnis konnte nicht gelesen werden: {error}",
"watchMissingDir": "Asset-Verzeichnis fehlt: {path}",
"watchFailed": "Überwachung konnte nicht gestartet werden: {error}",
"fixSuffixAction": "Zusammengesetzte Endung für {label} korrigieren",
"renderAction": "{label} rendern",
"setThumbnailAction": "Vorschaubild für {label} setzen",
"thumbnailDetailsAnimated": "Animiert {size}@{fps}fps/q{quality}/{kb}KB (Ziel {target}KB)/{frames} Frames/Animation {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "Statisch {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "schnellste Methode",
"bestSize": "kleinste Datei",
"fallbackNote": " ({text})",
"fallbackNoAnimation": "das Skelett hat keine Animationen",
"fallbackAnimatedFailed": "animierte Erstellung fehlgeschlagen: {error}",
"limitNote": " (auch das kleinste Profil überschreitet das Ziel; erhöhe gegebenenfalls die Vorschaubildgröße)",
"runSummary": "{generated} erfolgreich, {failed} fehlgeschlagen, {skipped} übersprungen in {seconds} Sekunden."
},
"progress": {
"title": "Spine-Vorschaubilder",
"generating": "Animierte Vorschaubilder werden erstellt…",
"waiting": "Warte auf Start…",
"confirmPrompt": "Diesen Lauf abbrechen?",
"resume": "Fortsetzen",
"cancel": "Abbruch bestätigen",
"stop": "Stoppen",
"confirm": "Fertig",
"generatedConfirm": "{done} Vorschaubilder erstellt. Lauf abbrechen?",
"complete": "Abgeschlossen",
"completed": "Abgeschlossen {done} / {total} ({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Spine Thumbnails"
}
},
"thumb": {
"title": "Spine Thumbnails",
"noticeIntro": "Feature: After importing Spine assets (.skel / .json + .atlas + textures), this plugin generates a skeleton preview for items that do not yet have a thumbnail.",
"noticeSafety": "1. Automatic generation is off by default and must be enabled below. It only processes items newly imported in this run and never overwrites custom thumbnails that are already set.",
"noticeCompound": "2. For items with compound suffixes, Eagle name/type fields are adjusted only after the current and new values are confirmed. Original values are saved and can be restored.",
"autoToggle": "Generate thumbnails on import",
"clear": "Clear log",
"restoreNames": "Restore names/types",
"compoundConfirmTitle": "Confirm compound suffix change",
"compoundConfirmIntro": "Because compound-suffix files require special handling, Eagle must adjust the name and type fields for these items so the generated thumbnails can be displayed. Original values will be saved and can be restored from this plugin.",
"compoundOldValue": "Current",
"compoundNewValue": "New",
"compoundSkip": "Skip and continue",
"compoundConfirm": "Apply changes",
"compoundConfirmSelected": "Apply selected ({count})",
"compoundSkipped": "Skipped {label}: compound suffix change was not approved",
"compoundSourceMismatch": "Skipped {label}: Eagle name {current} does not match source file {source}",
"compoundCheckFailed": "Could not check the compound suffix for {label}: {error}",
"compoundNeedsConfirmation": "Skipped {label}: fields changed after confirmation; select it again for a new review",
"compoundBackupUnavailable": "Compound suffixes cannot be changed because the original values could not be saved",
"compoundRolledBack": "Thumbnail generation failed; restored the name and type for {label}",
"compoundRollbackFailed": "Thumbnail generation failed and {label} could not be restored automatically: {error}",
"restoreEmpty": "There are no saved name/type changes to restore",
"restoreTitle": "Select items to restore",
"restoreIntro": "These items have saved original values. All restorable items are selected by default; items changed by the user or unavailable for reading cannot be selected.",
"restoreCurrent": "Current",
"restoreTarget": "Restore to",
"restoreStatusReady": "Item was modified to {fields}",
"restoreStatusAlreadyOriginal": "Already has the original value; confirming will only remove the backup",
"restoreStatusChanged": "Current fields were changed by the user; this item will be skipped",
"restoreStatusMissing": "Item not found and cannot be restored",
"restoreStatusError": "Current values could not be read and cannot be restored",
"restoreCancel": "Cancel",
"restoreSelected": "Restore selected ({count})",
"restoreComplete": "Restore complete: {success} succeeded, {skipped} skipped, {failed} failed.",
"restoreFailed": "Failed to restore names/types: {error}",
"modeLabel": "Generation mode",
"modeAnimated": "Animated (first animation)",
"modeStatic": "Still (setup pose)",
"strategyLabel": "Strategy",
"strategySize": "Smallest file (slow)",
"strategySpeed": "Fastest (quick)",
"statusLabel": "Status",
"statusStarting": "Starting…",
"libraryLabel": "Library",
"statsLabel": "Statistics",
"filterAll": "All",
"filterOk": "Success",
"filterFail": "Failed",
"filterWarn": "Warning",
"filterError": "Error",
"unknownError": "Unknown error",
"scriptError": "Script error: {error}",
"promiseError": "Unhandled Promise error: {error}",
"uncategorized": "Uncategorized",
"reasonQuiet": "quiet",
"reasonFallback": "fallback scan",
"none": "none",
"complete": "complete",
"incomplete": "incomplete",
"statusLoaded": "Script {version} loaded; waiting for Eagle events…",
"statusEagleMissing": "Eagle API not available",
"statusListening": "Watching",
"statusListenFailed": "Watch failed",
"statusAutoOff": "Watching (automatic generation off)",
"statusNoLibrary": "Library path unavailable",
"statusPairing": "Pairing new assets…",
"statusPairingProgress": "Pairing {current}/{total}",
"statusRendering": "Rendering frame {current}/{total} ({queued} queued)",
"statusGenerating": "Generating thumbnails… {queued} remaining",
"statusPaused": "Paused (resume or cancel in the progress window)",
"statusAutoSkipped": "Automatic generation is off; this batch was skipped",
"statusStartFailed": "Startup failed: {error}",
"stats": "Batches {batches} · Added {added} · Spine {spine} · Complete {complete} · Generated {generated} · Skipped {skipped} · Failed {failed}",
"modulesMissing": "Local modules not loaded: {modules}",
"moduleLoadError": "Local modules were not loaded. Check the script order in index.html: {modules}",
"version": "Plugin code version {version}",
"pluginCreated": "Plugin created: {name}",
"settingsReadFailed": "Failed to read settings; using defaults (automatic generation off): {error}",
"settingsSaveFailed": "Failed to save settings: {error}",
"autoEnabled": "Automatic generation enabled. Newly imported Spine assets will receive thumbnails.",
"autoDisabled": "Automatic generation disabled; the pending queue was cleared.",
"modeStaticLog": "Generation mode: still (setup pose, PNG)",
"modeAnimatedLog": "Generation mode: animated (first animation as WebP; setup pose if no animation exists)",
"strategySpeedLog": "Strategy: fastest (estimate profiles and render only the smallest one or two)",
"strategySizeLog": "Strategy: smallest file (render all ten profiles and keep the smallest result)",
"missingItem": "Skipped {label}: item not found in Eagle",
"suffixFixed": "Fixed compound suffix for {label} → {name}",
"suffixFailed": "Failed to fix compound suffix for {label}: {error}",
"existingThumbnail": "Skipped {label}: a custom thumbnail already exists",
"thumbnailRejected": "Eagle rejected this thumbnail",
"generated": "Generated {label} | {details} | Spine {version}",
"generateFailed": "Generation failed for {label}: {error}",
"cancelled": "Generation cancelled: discarded {dropped} pending items. Completed {done} (success {generated}, failed {failed}, skipped {skipped})",
"batchFailed": "Failed to process import batch: {error}",
"batchFinished": "Batch finished: {added} added, {total} in library ({reason})",
"recheck": "Rechecking {count} previously incomplete assets",
"noSpine": "No Spine assets in this batch",
"incompletePending": "{count} assets are incomplete and will be rechecked after later imports",
"incompleteIgnored": "{count} assets are incomplete and were not processed",
"itemSummary": " {name} | {path} | {state} | skeleton {skeleton} | atlas {atlas} | pages {pages} (missing {missing})",
"autoSkipped": "Automatic generation is off; skipped {count} assets in this batch",
"queued": "Queued {added}; {queued} waiting{state}",
"queuedRunning": " (processing)",
"libraryInfoNoPath": "library.info() did not return a path: {info}",
"libraryInfoFailed": "library.info() failed: {error}",
"switchingLibrary": "Switching library: {path}",
"noCreateEvent": "No plugin-create event received; trying to start directly",
"watching": "Watching {path} ({count} existing items treated as history)",
"watchReadFailed": "Failed to read the asset directory: {error}",
"watchMissingDir": "Asset directory does not exist: {path}",
"watchFailed": "Failed to start watching: {error}",
"fixSuffixAction": "Fixing compound suffix for {label}",
"renderAction": "Rendering {label}",
"setThumbnailAction": "Setting thumbnail for {label}",
"thumbnailDetailsAnimated": "Animated {size}@{fps}fps/q{quality}/{kb}KB (target {target}KB)/{frames} frames/animation {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "Still {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "fastest",
"bestSize": "smallest file",
"fallbackNote": " ({text})",
"fallbackNoAnimation": "the skeleton has no animations",
"fallbackAnimatedFailed": "animated generation failed: {error}",
"limitNote": " (the smallest profile still exceeds the target; consider a larger thumbnail size)",
"runSummary": "{generated} succeeded, {failed} failed, {skipped} skipped in {seconds} seconds."
},
"progress": {
"title": "Spine Thumbnails",
"generating": "Generating animated thumbnails…",
"waiting": "Waiting to start…",
"confirmPrompt": "Cancel this run?",
"resume": "Continue",
"cancel": "Confirm cancel",
"stop": "Stop",
"confirm": "Done",
"generatedConfirm": "{done} thumbnails generated. Cancel this run?",
"complete": "Complete",
"completed": "Completed {done} / {total} ({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Miniaturas de Spine"
}
},
"thumb": {
"title": "Miniaturas de Spine",
"noticeIntro": "Función: después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento genera una vista previa del esqueleto para los elementos que aún no tienen una miniatura.",
"noticeSafety": "1. La generación automática está desactivada de forma predeterminada y debe activarse abajo manualmente. Solo procesa los elementos recién importados en esta ejecución y nunca sobrescribe miniaturas personalizadas ya configuradas.",
"noticeCompound": "2. En los elementos con extensión compuesta, los campos de nombre y tipo de Eagle solo se ajustan después de confirmar los valores actuales y nuevos. Los valores originales se guardan y pueden restaurarse.",
"autoToggle": "Generar miniaturas al importar",
"clear": "Borrar registro",
"restoreNames": "Restaurar nombres/tipos",
"compoundConfirmTitle": "Confirmar cambio de extensión compuesta",
"compoundConfirmIntro": "Debido a la naturaleza especial de los archivos con extensión compuesta, Eagle debe ajustar los campos de nombre y tipo de estos elementos para mostrar las miniaturas generadas. Los valores originales se guardarán y podrán restaurarse desde este complemento.",
"compoundOldValue": "Actual",
"compoundNewValue": "Nuevo",
"compoundSkip": "Omitir y continuar",
"compoundConfirm": "Aplicar cambios",
"compoundConfirmSelected": "Aplicar seleccionados ({count})",
"compoundSkipped": "Se omitió {label}: no se aprobó el cambio de extensión compuesta",
"compoundSourceMismatch": "Se omitió {label}: el nombre de Eagle {current} no coincide con el archivo de origen {source}",
"compoundCheckFailed": "No se pudo comprobar la extensión compuesta de {label}: {error}",
"compoundNeedsConfirmation": "Se omitió {label}: los campos cambiaron después de confirmar; vuelve a revisarlo",
"compoundBackupUnavailable": "No se pueden cambiar extensiones compuestas porque no se pudieron guardar los valores originales",
"compoundRolledBack": "La generación falló; se restauraron el nombre y el tipo de {label}",
"compoundRollbackFailed": "La generación falló y no se pudo restaurar {label} automáticamente: {error}",
"restoreEmpty": "No hay cambios de nombre/tipo guardados para restaurar",
"restoreTitle": "Seleccionar elementos para restaurar",
"restoreIntro": "Estos elementos tienen valores originales guardados. Los elementos restaurables se seleccionan todos de forma predeterminada; los modificados por el usuario o ilegibles no se pueden seleccionar.",
"restoreCurrent": "Actual",
"restoreTarget": "Restaurar a",
"restoreStatusReady": "El elemento se modificó a {fields}",
"restoreStatusAlreadyOriginal": "Ya tiene el valor original; al confirmar solo se eliminará la copia de seguridad",
"restoreStatusChanged": "Los campos actuales fueron modificados por el usuario y se omitirán",
"restoreStatusMissing": "El elemento no existe y no se puede restaurar",
"restoreStatusError": "No se pudieron leer los valores actuales y no se puede restaurar",
"restoreCancel": "Cancelar",
"restoreSelected": "Restaurar seleccionados ({count})",
"restoreComplete": "Restauración completada: {success} correctos, {skipped} omitidos, {failed} fallidos.",
"restoreFailed": "No se pudieron restaurar nombres/tipos: {error}",
"modeLabel": "Modo de generación",
"modeAnimated": "Animado (primera animación)",
"modeStatic": "Estático (pose de configuración)",
"strategyLabel": "Estrategia",
"strategySize": "Archivo más pequeño (lento)",
"strategySpeed": "Más rápido (rápido)",
"statusLabel": "Estado",
"statusStarting": "Iniciando…",
"libraryLabel": "Biblioteca",
"statsLabel": "Estadísticas",
"filterAll": "Todo",
"filterOk": "Correcto",
"filterFail": "Fallido",
"filterWarn": "Aviso",
"filterError": "Error",
"unknownError": "Error desconocido",
"scriptError": "Error de script: {error}",
"promiseError": "Error de Promise no controlado: {error}",
"uncategorized": "Sin categoría",
"reasonQuiet": "fin de actividad",
"reasonFallback": "exploración de respaldo",
"none": "ninguno",
"complete": "completo",
"incomplete": "incompleto",
"statusLoaded": "Script {version} cargado; esperando eventos de Eagle…",
"statusEagleMissing": "API de Eagle no disponible",
"statusListening": "Supervisando",
"statusListenFailed": "Error de supervisión",
"statusAutoOff": "Supervisando (generación automática desactivada)",
"statusNoLibrary": "Ruta de la biblioteca no disponible",
"statusPairing": "Emparejando recursos nuevos…",
"statusPairingProgress": "Emparejando {current}/{total}",
"statusRendering": "Renderizando fotograma {current}/{total} ({queued} en cola)",
"statusGenerating": "Generando miniaturas… quedan {queued}",
"statusPaused": "En pausa (continúa o cancela en la ventana de progreso)",
"statusAutoSkipped": "La generación automática está desactivada; se omitió este lote",
"statusStartFailed": "Error al iniciar: {error}",
"stats": "Lotes {batches} · Añadidos {added} · Spine {spine} · Completos {complete} · Generados {generated} · Omitidos {skipped} · Fallidos {failed}",
"modulesMissing": "Módulos locales no cargados: {modules}",
"moduleLoadError": "No se cargaron los módulos locales. Revisa el orden de scripts en index.html: {modules}",
"version": "Versión del código del complemento {version}",
"pluginCreated": "Complemento creado: {name}",
"settingsReadFailed": "No se pudieron leer los ajustes; se usarán los valores predeterminados (generación automática desactivada): {error}",
"settingsSaveFailed": "No se pudieron guardar los ajustes: {error}",
"autoEnabled": "Generación automática activada. Los nuevos recursos de Spine importados recibirán miniaturas.",
"autoDisabled": "Generación automática desactivada; se vació la cola pendiente.",
"modeStaticLog": "Modo de generación: estático (siempre pose de configuración, PNG)",
"modeAnimatedLog": "Modo de generación: animado (primera animación como WebP; pose de configuración si no hay animación)",
"strategySpeedLog": "Estrategia: más rápida (estimación y solo los perfiles más pequeños)",
"strategySizeLog": "Estrategia: archivo más pequeño (genera los diez perfiles y conserva el menor)",
"missingItem": "Se omitió {label}: elemento no encontrado en Eagle",
"suffixFixed": "Extensión compuesta corregida para {label} → {name}",
"suffixFailed": "No se pudo corregir la extensión compuesta de {label}: {error}",
"existingThumbnail": "Se omitió {label}: ya existe una miniatura personalizada",
"thumbnailRejected": "Eagle rechazó esta miniatura",
"generated": "{label} generado | {details} | Spine {version}",
"generateFailed": "Error al generar {label}: {error}",
"cancelled": "Generación cancelada: se descartaron {dropped} pendientes. Completados {done} (correctos {generated}, fallidos {failed}, omitidos {skipped})",
"batchFailed": "Error al procesar el lote importado: {error}",
"batchFinished": "Lote finalizado: {added} añadidos, {total} en la biblioteca ({reason})",
"recheck": "Revisando de nuevo {count} recursos previamente incompletos",
"noSpine": "No hay recursos de Spine en este lote",
"incompletePending": "{count} recursos están incompletos y se revisarán tras importaciones posteriores",
"incompleteIgnored": "{count} recursos están incompletos y no se procesaron en este lote",
"itemSummary": " {name} | {path} | {state} | esqueleto {skeleton} | atlas {atlas} | páginas {pages} (faltan {missing})",
"autoSkipped": "La generación automática está desactivada; se omitieron {count} recursos de este lote",
"queued": "{added} añadidos a la cola; {queued} esperando{state}",
"queuedRunning": " (procesando)",
"libraryInfoNoPath": "library.info() no devolvió una ruta: {info}",
"libraryInfoFailed": "Error de library.info(): {error}",
"switchingLibrary": "Cambiando de biblioteca: {path}",
"noCreateEvent": "No se recibió el evento de creación; intentando iniciar directamente",
"watching": "Supervisando {path} ({count} elementos existentes tratados como historial)",
"watchReadFailed": "No se pudo leer el directorio de recursos: {error}",
"watchMissingDir": "No existe el directorio de recursos: {path}",
"watchFailed": "No se pudo iniciar la supervisión: {error}",
"fixSuffixAction": "Corrigiendo extensión compuesta de {label}",
"renderAction": "Renderizando {label}",
"setThumbnailAction": "Estableciendo miniatura de {label}",
"thumbnailDetailsAnimated": "Animado {size}@{fps}fps/q{quality}/{kb}KB (objetivo {target}KB)/{frames} fotogramas/animación {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "Estático {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "más rápido",
"bestSize": "archivo más pequeño",
"fallbackNote": " ({text})",
"fallbackNoAnimation": "el esqueleto no tiene animaciones",
"fallbackAnimatedFailed": "falló la generación animada: {error}",
"limitNote": " (incluso el perfil más pequeño supera el objetivo; considera aumentar el tamaño de la miniatura)",
"runSummary": "{generated} correctos, {failed} fallidos, {skipped} omitidos en {seconds} segundos."
},
"progress": {
"title": "Miniaturas de Spine",
"generating": "Generando miniaturas animadas…",
"waiting": "Esperando para iniciar…",
"confirmPrompt": "¿Cancelar esta ejecución?",
"resume": "Continuar",
"cancel": "Confirmar cancelación",
"stop": "Detener",
"confirm": "Listo",
"generatedConfirm": "Se generaron {done} miniaturas. ¿Cancelar esta ejecución?",
"complete": "Completado",
"completed": "Completado {done} / {total} ({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Spineサムネイル"
}
},
"thumb": {
"title": "Spineサムネイル",
"noticeIntro": "機能:Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込むと、サムネイル未設定のアイテムにスケルトンプレビューを生成します。",
"noticeSafety": "1. 自動生成は既定でオフで、下で手動で有効にする必要があります。今回新しく読み込んだアイテムだけを処理し、既に設定されたカスタムサムネイルは上書きしません。",
"noticeCompound": "2. 複合拡張子の項目は、現在値と新しい値を確認した場合にのみ Eagle の名前/種類フィールドを変更します。元の値は保存され、復元できます。",
"autoToggle": "読み込み時にサムネイルを自動生成",
"clear": "ログを消去",
"restoreNames": "名前/種類を復元",
"compoundConfirmTitle": "複合拡張子の変更を確認",
"compoundConfirmIntro": "複合拡張子ファイルの特性上、生成したサムネイルを表示するためには Eagle でこれらの素材の名前と種類フィールドを変更する必要があります。元の値は保存され、このプラグインから復元できます。",
"compoundOldValue": "変更前",
"compoundNewValue": "変更後",
"compoundSkip": "スキップして続行",
"compoundConfirm": "変更を適用",
"compoundConfirmSelected": "選択項目を適用({count})",
"compoundSkipped": "{label} をスキップ:複合拡張子の変更が承認されていません",
"compoundSourceMismatch": "{label} をスキップ:Eagle の名前 {current} が元ファイル {source} と一致しません",
"compoundCheckFailed": "{label} の複合拡張子を確認できませんでした:{error}",
"compoundNeedsConfirmation": "{label} をスキップ:確認後にフィールドが変更されたため、次回もう一度確認してください",
"compoundBackupUnavailable": "元の値を保存できないため、複合拡張子を変更できません",
"compoundRolledBack": "サムネイル生成に失敗したため、{label} の名前と種類を復元しました",
"compoundRollbackFailed": "サムネイル生成に失敗し、{label} を自動復元できませんでした:{error}",
"restoreEmpty": "復元できる名前/種類の変更はありません",
"restoreTitle": "復元する素材を選択",
"restoreIntro": "元の値が保存されている素材です。復元可能な項目は既定ですべて選択されます。後からユーザーが変更した素材や読み取れない素材は選択できません。",
"restoreCurrent": "現在",
"restoreTarget": "復元後",
"restoreStatusReady": "素材は {fields} に変更されています",
"restoreStatusAlreadyOriginal": "すでに元の値です。確認するとバックアップのみ削除します",
"restoreStatusChanged": "現在のフィールドはユーザーが変更済みのためスキップします",
"restoreStatusMissing": "素材が見つからないため復元できません",
"restoreStatusError": "現在値を読み取れないため復元できません",
"restoreCancel": "キャンセル",
"restoreSelected": "選択項目を復元({count})",
"restoreComplete": "復元完了:成功 {success}、スキップ {skipped}、失敗 {failed}。",
"restoreFailed": "名前/種類の復元に失敗しました:{error}",
"modeLabel": "生成方式",
"modeAnimated": "アニメーション(最初のアニメーション)",
"modeStatic": "静止画(setup pose)",
"strategyLabel": "生成戦略",
"strategySize": "最小サイズ(低速)",
"strategySpeed": "最速(高速)",
"statusLabel": "状態",
"statusStarting": "起動中…",
"libraryLabel": "ライブラリ",
"statsLabel": "統計",
"filterAll": "すべて",
"filterOk": "成功",
"filterFail": "失敗",
"filterWarn": "警告",
"filterError": "エラー",
"unknownError": "不明なエラー",
"scriptError": "スクリプトエラー:{error}",
"promiseError": "未処理の Promise エラー:{error}",
"uncategorized": "未分類",
"reasonQuiet": "静止検出",
"reasonFallback": "フォールバック走査",
"none": "なし",
"complete": "完全",
"incomplete": "不完全",
"statusLoaded": "スクリプト {version} を読み込みました。Eagle イベントを待機しています…",
"statusEagleMissing": "Eagle API が利用できません",
"statusListening": "監視中",
"statusListenFailed": "監視に失敗",
"statusAutoOff": "監視中(自動生成はオフ)",
"statusNoLibrary": "ライブラリのパスを取得できません",
"statusPairing": "新しいアセットを照合中…",
"statusPairingProgress": "照合中 {current}/{total}",
"statusRendering": "フレームをレンダリング中 {current}/{total}(待機 {queued})",
"statusGenerating": "サムネイルを生成中…残り {queued}",
"statusPaused": "一時停止中(進捗ウィンドウで再開またはキャンセル)",
"statusAutoSkipped": "自動生成がオフのため、このバッチをスキップしました",
"statusStartFailed": "起動に失敗:{error}",
"stats": "バッチ {batches} · 追加 {added} · Spine {spine} · 完全 {complete} · 生成済み {generated} · スキップ {skipped} · 失敗 {failed}",
"modulesMissing": "ローカルモジュールが未読み込みです:{modules}",
"moduleLoadError": "ローカルモジュールが読み込まれていません。index.html の script 順を確認してください:{modules}",
"version": "プラグインコード {version}",
"pluginCreated": "プラグインを初期化しました:{name}",
"settingsReadFailed": "設定を読み込めないため既定値を使用します(自動生成オフ):{error}",
"settingsSaveFailed": "設定を保存できません:{error}",
"autoEnabled": "自動生成を有効にしました。今後読み込む Spine アセットにサムネイルを生成します。",
"autoDisabled": "自動生成を無効にし、待機中のキューを消去しました。",
"modeStaticLog": "生成方式:静止画(常に setup pose、PNG)",
"modeAnimatedLog": "生成方式:アニメーション(最初のアニメーションを WebP 化し、なければ setup pose)",
"strategySpeedLog": "生成戦略:最速(概算で選び、最小の 1~2 プロファイルのみ生成)",
"strategySizeLog": "生成戦略:最小サイズ(全 10 プロファイルを生成し最小を採用)",
"missingItem": "{label} をスキップ:Eagle にアイテムが見つかりません",
"suffixFixed": "{label} の複合拡張子を修正 → {name}",
"suffixFailed": "{label} の複合拡張子を修正できません:{error}",
"existingThumbnail": "{label} をスキップ:カスタムサムネイルが既にあります",
"thumbnailRejected": "Eagle がこのサムネイルを拒否しました",
"generated": "{label} を生成 | {details} | Spine {version}",
"generateFailed": "{label} の生成に失敗:{error}",
"cancelled": "生成をキャンセル:残り {dropped} 件を破棄、完了 {done} 件(成功 {generated}、失敗 {failed}、スキップ {skipped})",
"batchFailed": "読み込みバッチの処理に失敗:{error}",
"batchFinished": "バッチ完了:{added} 件追加、ライブラリ内 {total} 件({reason})",
"recheck": "以前の不完全なアセット {count} 件を再確認",
"noSpine": "このバッチに Spine アセットはありません",
"incompletePending": "不完全なアセットが {count} 件あります。後の読み込み後に再確認します",
"incompleteIgnored": "不完全なアセットが {count} 件あり、このバッチでは処理しません",
"itemSummary": " {name} | {path} | {state} | スケルトン {skeleton} | atlas {atlas} | ページ {pages}(不足 {missing})",
"autoSkipped": "自動生成がオフのため、このバッチの {count} 件をスキップしました",
"queued": "{added} 件をキューに追加、待機中 {queued} 件{state}",
"queuedRunning": "(処理中)",
"libraryInfoNoPath": "library.info() がパスを返しません:{info}",
"libraryInfoFailed": "library.info() の呼び出しに失敗:{error}",
"switchingLibrary": "ライブラリを切り替え:{path}",
"noCreateEvent": "作成イベントを受信できないため、直接起動します",
"watching": "{path} を監視中(既存 {count} 件は履歴として扱い、処理しません)",
"watchReadFailed": "アセットディレクトリを読み込めません:{error}",
"watchMissingDir": "アセットディレクトリがありません:{path}",
"watchFailed": "監視を開始できません:{error}",
"fixSuffixAction": "{label} の複合拡張子を修正",
"renderAction": "{label} をレンダリング",
"setThumbnailAction": "{label} のサムネイルを設定",
"thumbnailDetailsAnimated": "アニメーション {size}@{fps}fps/q{quality}/{kb}KB(目標 {target}KB)/ {frames} フレーム / アニメーション {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "静止画 {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "最速",
"bestSize": "最小サイズ",
"fallbackNote": "({text})",
"fallbackNoAnimation": "スケルトンにアニメーションがありません",
"fallbackAnimatedFailed": "アニメーション生成に失敗:{error}",
"limitNote": "(最小プロファイルでも目標サイズを超えています。サムネイルサイズを上げてください)",
"runSummary": "成功 {generated}、失敗 {failed}、スキップ {skipped}、所要 {seconds} 秒。"
},
"progress": {
"title": "Spineサムネイル",
"generating": "アニメーションサムネイルを生成中……",
"waiting": "開始待ち…",
"confirmPrompt": "キャンセルしますか?",
"resume": "続行",
"cancel": "キャンセル確定",
"stop": "停止",
"confirm": "確認",
"generatedConfirm": "{done} 枚生成しました。キャンセルしますか?",
"complete": "完了",
"completed": "完了 {done} / {total}({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Spine 썸네일"
}
},
"thumb": {
"title": "Spine 썸네일",
"noticeIntro": "기능: Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져오면 썸네일이 아직 설정되지 않은 항목에 스켈레톤 미리보기를 생성합니다.",
"noticeSafety": "1. 자동 생성은 기본적으로 꺼져 있으며 아래에서 직접 켜야 합니다. 이번에 새로 가져온 항목만 처리하고 이미 설정된 사용자 지정 썸네일은 덮어쓰지 않습니다.",
"noticeCompound": "2. 복합 확장자 항목은 현재 값과 새 값을 확인한 경우에만 Eagle 이름/유형 필드를 변경합니다. 원래 값은 저장되며 복원할 수 있습니다.",
"autoToggle": "가져올 때 썸네일 자동 생성",
"clear": "로그 지우기",
"restoreNames": "이름/유형 복원",
"compoundConfirmTitle": "복합 확장자 변경 확인",
"compoundConfirmIntro": "복합 확장자 파일의 특성상 생성된 썸네일을 표시하려면 Eagle에서 이 항목들의 이름과 유형 필드를 조정해야 합니다. 원래 값은 저장되며 이 플러그인에서 복원할 수 있습니다.",
"compoundOldValue": "현재 값",
"compoundNewValue": "새 값",
"compoundSkip": "건너뛰고 계속",
"compoundConfirm": "변경 적용",
"compoundConfirmSelected": "선택 항목 적용({count})",
"compoundSkipped": "{label} 건너뜀: 복합 확장자 변경이 승인되지 않았습니다",
"compoundSourceMismatch": "{label} 건너뜀: Eagle 이름 {current}이(가) 실제 원본 파일 {source}과(와) 일치하지 않습니다",
"compoundCheckFailed": "{label}의 복합 확장자를 확인할 수 없습니다: {error}",
"compoundNeedsConfirmation": "{label} 건너뜀: 확인 후 필드가 변경되었습니다. 다음에 다시 확인하세요",
"compoundBackupUnavailable": "원래 값을 저장할 수 없어 복합 확장자를 변경할 수 없습니다",
"compoundRolledBack": "썸네일 생성에 실패하여 {label}의 이름과 유형을 복원했습니다",
"compoundRollbackFailed": "썸네일 생성에 실패했고 {label}을(를) 자동 복원할 수 없습니다: {error}",
"restoreEmpty": "복원할 이름/유형 변경이 없습니다",
"restoreTitle": "복원할 항목 선택",
"restoreIntro": "원래 값이 저장된 항목입니다. 복원 가능한 항목은 기본적으로 모두 선택됩니다. 이후 사용자가 변경했거나 읽을 수 없는 항목은 선택할 수 없습니다.",
"restoreCurrent": "현재",
"restoreTarget": "복원 값",
"restoreStatusReady": "항목이 {fields} 값으로 변경되었습니다",
"restoreStatusAlreadyOriginal": "이미 원래 값입니다. 확인하면 백업만 정리합니다",
"restoreStatusChanged": "현재 필드가 사용자에 의해 변경되어 건너뜁니다",
"restoreStatusMissing": "항목을 찾을 수 없어 복원할 수 없습니다",
"restoreStatusError": "현재 값을 읽을 수 없어 복원할 수 없습니다",
"restoreCancel": "취소",
"restoreSelected": "선택 항목 복원({count})",
"restoreComplete": "복원 완료: 성공 {success}, 건너뜀 {skipped}, 실패 {failed}.",
"restoreFailed": "이름/유형 복원 실패: {error}",
"modeLabel": "생성 방식",
"modeAnimated": "애니메이션(첫 번째 애니메이션)",
"modeStatic": "정지 이미지(setup pose)",
"strategyLabel": "생성 전략",
"strategySize": "최소 파일(느림)",
"strategySpeed": "최고 속도(빠름)",
"statusLabel": "상태",
"statusStarting": "시작 중…",
"libraryLabel": "라이브러리",
"statsLabel": "통계",
"filterAll": "전체",
"filterOk": "성공",
"filterFail": "실패",
"filterWarn": "경고",
"filterError": "오류",
"unknownError": "알 수 없는 오류",
"scriptError": "스크립트 오류: {error}",
"promiseError": "처리되지 않은 Promise 오류: {error}",
"uncategorized": "미분류",
"reasonQuiet": "정적 감지",
"reasonFallback": "대체 검색",
"none": "없음",
"complete": "완전",
"incomplete": "불완전",
"statusLoaded": "스크립트 {version}을(를) 불러왔습니다. Eagle 이벤트를 기다리는 중…",
"statusEagleMissing": "Eagle API를 사용할 수 없음",
"statusListening": "감시 중",
"statusListenFailed": "감시 실패",
"statusAutoOff": "감시 중(자동 생성 꺼짐)",
"statusNoLibrary": "라이브러리 경로를 가져올 수 없음",
"statusPairing": "새 에셋 연결 중…",
"statusPairingProgress": "연결 중 {current}/{total}",
"statusRendering": "프레임 렌더링 중 {current}/{total}(대기 {queued})",
"statusGenerating": "썸네일 생성 중… 남은 항목 {queued}",
"statusPaused": "일시 정지됨(진행 창에서 계속하거나 취소하세요)",
"statusAutoSkipped": "자동 생성이 꺼져 있어 이번 묶음을 건너뛰었습니다",
"statusStartFailed": "시작 실패: {error}",
"stats": "묶음 {batches} · 추가 {added} · Spine {spine} · 완전 {complete} · 생성 {generated} · 건너뜀 {skipped} · 실패 {failed}",
"modulesMissing": "로컬 모듈이 로드되지 않음: {modules}",
"moduleLoadError": "로컬 모듈이 로드되지 않았습니다. index.html의 script 순서를 확인하세요: {modules}",
"version": "플러그인 코드 버전 {version}",
"pluginCreated": "플러그인 생성됨: {name}",
"settingsReadFailed": "설정을 읽지 못해 기본값을 사용합니다(자동 생성 꺼짐): {error}",
"settingsSaveFailed": "설정 저장 실패: {error}",
"autoEnabled": "자동 생성을 켰습니다. 새로 가져오는 Spine 에셋에 썸네일을 생성합니다.",
"autoDisabled": "자동 생성을 껐으며 대기 큐를 비웠습니다.",
"modeStaticLog": "생성 방식: 정지 이미지(항상 setup pose, PNG)",
"modeAnimatedLog": "생성 방식: 애니메이션(첫 애니메이션을 WebP로 만들고 없으면 setup pose 사용)",
"strategySpeedLog": "생성 전략: 최고 속도(대략 선택 후 가장 작은 1~2개 프로필만 생성)",
"strategySizeLog": "생성 전략: 최소 파일(10개 프로필을 모두 생성하고 가장 작은 결과 선택)",
"missingItem": "{label} 건너뜀: Eagle에서 항목을 찾을 수 없음",
"suffixFixed": "{label}의 복합 확장자 수정 → {name}",
"suffixFailed": "{label}의 복합 확장자 수정 실패: {error}",
"existingThumbnail": "{label} 건너뜀: 사용자 지정 썸네일이 이미 있음",
"thumbnailRejected": "Eagle이 이 썸네일을 거부했습니다",
"generated": "{label} 생성됨 | {details} | Spine {version}",
"generateFailed": "{label} 생성 실패: {error}",
"cancelled": "생성 취소: 남은 {dropped}개를 버렸습니다. 완료 {done}개(성공 {generated}, 실패 {failed}, 건너뜀 {skipped})",
"batchFailed": "가져오기 묶음 처리 실패: {error}",
"batchFinished": "묶음 완료: {added}개 추가, 라이브러리 전체 {total}개({reason})",
"recheck": "이전 불완전 에셋 {count}개 다시 확인",
"noSpine": "이번 묶음에 Spine 에셋이 없습니다",
"incompletePending": "불완전한 에셋 {count}개를 이후 가져오기 후 다시 확인합니다",
"incompleteIgnored": "불완전한 에셋 {count}개가 있으며 이번 묶음에서는 처리하지 않습니다",
"itemSummary": " {name} | {path} | {state} | 스켈레톤 {skeleton} | atlas {atlas} | 페이지 {pages}(누락 {missing})",
"autoSkipped": "자동 생성이 꺼져 있어 이번 묶음의 에셋 {count}개를 건너뛰었습니다",
"queued": "{added}개를 큐에 추가했습니다. 현재 대기 {queued}개{state}",
"queuedRunning": "(처리 중)",
"libraryInfoNoPath": "library.info()가 경로를 반환하지 않음: {info}",
"libraryInfoFailed": "library.info() 호출 실패: {error}",
"switchingLibrary": "라이브러리 전환: {path}",
"noCreateEvent": "생성 이벤트를 받지 못해 직접 시작합니다",
"watching": "{path} 감시 중(기존 항목 {count}개는 기록으로 간주하여 처리하지 않음)",
"watchReadFailed": "에셋 디렉터리를 읽지 못함: {error}",
"watchMissingDir": "에셋 디렉터리가 없음: {path}",
"watchFailed": "감시 시작 실패: {error}",
"fixSuffixAction": "{label}의 복합 확장자 수정",
"renderAction": "{label} 렌더링",
"setThumbnailAction": "{label} 썸네일 설정",
"thumbnailDetailsAnimated": "애니메이션 {size}@{fps}fps/q{quality}/{kb}KB(목표 {target}KB)/ {frames}프레임 / 애니메이션 {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "정지 이미지 {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "최고 속도",
"bestSize": "최소 파일",
"fallbackNote": "({text})",
"fallbackNoAnimation": "스켈레톤에 애니메이션이 없습니다",
"fallbackAnimatedFailed": "애니메이션 생성 실패: {error}",
"limitNote": "(가장 작은 프로필도 목표 크기를 초과합니다. 썸네일 크기를 늘리세요)",
"runSummary": "성공 {generated}, 실패 {failed}, 건너뜀 {skipped}, {seconds}초 소요."
},
"progress": {
"title": "Spine 썸네일",
"generating": "애니메이션 썸네일 생성 중……",
"waiting": "시작 대기 중…",
"confirmPrompt": "이번 작업을 취소할까요?",
"resume": "계속",
"cancel": "취소 확인",
"stop": "중지",
"confirm": "확인",
"generatedConfirm": "썸네일 {done}개를 생성했습니다. 취소할까요?",
"complete": "완료",
"completed": "완료 {done} / {total}({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Миниатюры Spine"
}
},
"thumb": {
"title": "Миниатюры Spine",
"noticeIntro": "Функция: после импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов, у которых ещё нет миниатюры.",
"noticeSafety": "1. Автоматическое создание по умолчанию отключено и должно быть включено вручную ниже. Обрабатываются только элементы, импортированные в текущем запуске; уже заданные пользовательские миниатюры не перезаписываются.",
"noticeCompound": "2. Поля имени и типа Eagle для элементов с составными расширениями изменяются только после подтверждения текущих и новых значений. Исходные значения сохраняются и могут быть восстановлены.",
"autoToggle": "Создавать миниатюры при импорте",
"clear": "Очистить журнал",
"restoreNames": "Восстановить имена/типы",
"compoundConfirmTitle": "Подтвердить изменение составного расширения",
"compoundConfirmIntro": "Из-за особенностей файлов с составными расширениями Eagle должен изменить поля имени и типа этих элементов, чтобы показывать созданные миниатюры. Исходные значения будут сохранены и доступны для восстановления в плагине.",
"compoundOldValue": "Сейчас",
"compoundNewValue": "Новое",
"compoundSkip": "Пропустить и продолжить",
"compoundConfirm": "Применить изменения",
"compoundConfirmSelected": "Применить выбранные ({count})",
"compoundSkipped": "{label} пропущен: изменение составного расширения не подтверждено",
"compoundSourceMismatch": "{label} пропущен: имя Eagle {current} не совпадает с исходным файлом {source}",
"compoundCheckFailed": "Не удалось проверить составное расширение {label}: {error}",
"compoundNeedsConfirmation": "{label} пропущен: поля изменились после подтверждения; проверьте элемент заново",
"compoundBackupUnavailable": "Нельзя изменить составные расширения: исходные значения не удалось сохранить",
"compoundRolledBack": "Создание миниатюры не удалось; имя и тип {label} восстановлены",
"compoundRollbackFailed": "Создание миниатюры не удалось, а {label} не удалось восстановить автоматически: {error}",
"restoreEmpty": "Нет сохранённых изменений имён/типов для восстановления",
"restoreTitle": "Выберите элементы для восстановления",
"restoreIntro": "Для этих элементов сохранены исходные значения. Все доступные элементы выбраны по умолчанию; изменённые пользователем или недоступные элементы выбрать нельзя.",
"restoreCurrent": "Сейчас",
"restoreTarget": "Восстановить до",
"restoreStatusReady": "Элемент изменён на {fields}",
"restoreStatusAlreadyOriginal": "Уже имеет исходное значение; подтверждение только удалит резервную копию",
"restoreStatusChanged": "Текущие поля изменены пользователем; элемент будет пропущен",
"restoreStatusMissing": "Элемент не найден, восстановление невозможно",
"restoreStatusError": "Не удалось прочитать текущие значения, восстановление невозможно",
"restoreCancel": "Отмена",
"restoreSelected": "Восстановить выбранные ({count})",
"restoreComplete": "Восстановление завершено: успешно {success}, пропущено {skipped}, ошибок {failed}.",
"restoreFailed": "Не удалось восстановить имена/типы: {error}",
"modeLabel": "Режим создания",
"modeAnimated": "Анимированный (первая анимация)",
"modeStatic": "Статический (setup pose)",
"strategyLabel": "Стратегия",
"strategySize": "Минимальный файл (медленно)",
"strategySpeed": "Максимальная скорость (быстро)",
"statusLabel": "Состояние",
"statusStarting": "Запуск…",
"libraryLabel": "Библиотека",
"statsLabel": "Статистика",
"filterAll": "Все",
"filterOk": "Успешно",
"filterFail": "Ошибки",
"filterWarn": "Предупреждения",
"filterError": "Ошибка",
"unknownError": "Неизвестная ошибка",
"scriptError": "Ошибка скрипта: {error}",
"promiseError": "Необработанная ошибка Promise: {error}",
"uncategorized": "Без категории",
"reasonQuiet": "после завершения импорта",
"reasonFallback": "резервное сканирование",
"none": "нет",
"complete": "полный",
"incomplete": "неполный",
"statusLoaded": "Скрипт {version} загружен; ожидание событий Eagle…",
"statusEagleMissing": "API Eagle недоступен",
"statusListening": "Наблюдение",
"statusListenFailed": "Ошибка наблюдения",
"statusAutoOff": "Наблюдение (автосоздание выключено)",
"statusNoLibrary": "Путь к библиотеке недоступен",
"statusPairing": "Сопоставление новых ресурсов…",
"statusPairingProgress": "Сопоставление {current}/{total}",
"statusRendering": "Рендеринг кадра {current}/{total} (в очереди {queued})",
"statusGenerating": "Создание миниатюр… осталось {queued}",
"statusPaused": "Пауза (продолжите или отмените в окне прогресса)",
"statusAutoSkipped": "Автосоздание выключено; этот пакет пропущен",
"statusStartFailed": "Ошибка запуска: {error}",
"stats": "Пакеты {batches} · Добавлено {added} · Spine {spine} · Полные {complete} · Создано {generated} · Пропущено {skipped} · Ошибок {failed}",
"modulesMissing": "Локальные модули не загружены: {modules}",
"moduleLoadError": "Локальные модули не загружены. Проверьте порядок скриптов в index.html: {modules}",
"version": "Версия кода плагина {version}",
"pluginCreated": "Плагин создан: {name}",
"settingsReadFailed": "Не удалось прочитать настройки; используются значения по умолчанию (автосоздание выключено): {error}",
"settingsSaveFailed": "Не удалось сохранить настройки: {error}",
"autoEnabled": "Автосоздание включено. Для новых импортированных ресурсов Spine будут созданы миниатюры.",
"autoDisabled": "Автосоздание выключено; очередь ожидания очищена.",
"modeStaticLog": "Режим создания: статический (всегда setup pose, PNG)",
"modeAnimatedLog": "Режим создания: анимированный (первая анимация в WebP; setup pose без анимации)",
"strategySpeedLog": "Стратегия: максимальная скорость (примерный выбор, только один-два наименьших профиля)",
"strategySizeLog": "Стратегия: минимальный файл (все десять профилей и выбор наименьшего)",
"missingItem": "{label} пропущен: элемент не найден в Eagle",
"suffixFixed": "Составное расширение исправлено для {label} → {name}",
"suffixFailed": "Не удалось исправить составное расширение {label}: {error}",
"existingThumbnail": "{label} пропущен: уже есть пользовательская миниатюра",
"thumbnailRejected": "Eagle отклонил эту миниатюру",
"generated": "{label} создан | {details} | Spine {version}",
"generateFailed": "Ошибка создания {label}: {error}",
"cancelled": "Создание отменено: отброшено ожидающих {dropped}. Завершено {done} (успешно {generated}, ошибок {failed}, пропущено {skipped})",
"batchFailed": "Ошибка обработки пакета импорта: {error}",
"batchFinished": "Пакет завершён: добавлено {added}, в библиотеке {total} ({reason})",
"recheck": "Повторная проверка ранее неполных ресурсов: {count}",
"noSpine": "В этом пакете нет ресурсов Spine",
"incompletePending": "Неполных ресурсов: {count}; они будут проверены после следующих импортов",
"incompleteIgnored": "Неполных ресурсов: {count}; в этом пакете они не обрабатываются",
"itemSummary": " {name} | {path} | {state} | скелет {skeleton} | atlas {atlas} | страниц {pages} (не хватает {missing})",
"autoSkipped": "Автосоздание выключено; пропущено ресурсов в пакете: {count}",
"queued": "Добавлено в очередь: {added}; ожидают {queued}{state}",
"queuedRunning": " (обработка)",
"libraryInfoNoPath": "library.info() не вернул путь: {info}",
"libraryInfoFailed": "Ошибка вызова library.info(): {error}",
"switchingLibrary": "Переключение библиотеки: {path}",
"noCreateEvent": "Событие создания не получено; выполняется прямой запуск",
"watching": "Наблюдение за {path} (существующие элементы {count} считаются историей)",
"watchReadFailed": "Не удалось прочитать каталог ресурсов: {error}",
"watchMissingDir": "Каталог ресурсов не существует: {path}",
"watchFailed": "Не удалось начать наблюдение: {error}",
"fixSuffixAction": "Исправление составного расширения {label}",
"renderAction": "Рендеринг {label}",
"setThumbnailAction": "Установка миниатюры {label}",
"thumbnailDetailsAnimated": "Анимация {size}@{fps}fps/q{quality}/{kb}KB (цель {target}KB)/{frames} кадров/анимация {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "Статическая {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "максимальная скорость",
"bestSize": "минимальный файл",
"fallbackNote": " ({text})",
"fallbackNoAnimation": "в скелете нет анимаций",
"fallbackAnimatedFailed": "ошибка анимированного создания: {error}",
"limitNote": " (даже минимальный профиль превышает целевой размер; увеличьте размер миниатюры)",
"runSummary": "Успешно {generated}, ошибок {failed}, пропущено {skipped}, время {seconds} с."
},
"progress": {
"title": "Миниатюры Spine",
"generating": "Создание анимированных миниатюр…",
"waiting": "Ожидание запуска…",
"confirmPrompt": "Отменить этот запуск?",
"resume": "Продолжить",
"cancel": "Подтвердить отмену",
"stop": "Остановить",
"confirm": "Готово",
"generatedConfirm": "Создано миниатюр: {done}. Отменить запуск?",
"complete": "Завершено",
"completed": "Завершено {done} / {total} ({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Spine缩略图"
}
},
"thumb": {
"title": "Spine缩略图",
"noticeIntro": "功能:导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
"noticeSafety": "1. 自动生成默认关闭,需要你在下面手动启用。它只处理本次新导入的素材,不会覆盖已经设置过的自定义缩略图。",
"noticeCompound": "2. 复合后缀素材只有在确认原值和新值后,才会调整 Eagle 的名称/类型字段;原值会保存并可恢复。",
"autoToggle": "导入时自动生成缩略图",
"clear": "清空日志",
"restoreNames": "恢复名称/类型",
"compoundConfirmTitle": "确认修改复合后缀",
"compoundConfirmIntro": "由于复合后缀文件的特殊性,为了让这些素材显示生成的缩略图,Eagle 需要调整名称和类型字段。原值会保存,可随时在插件面板恢复。",
"compoundOldValue": "原值",
"compoundNewValue": "新值",
"compoundSkip": "跳过并继续",
"compoundConfirm": "确认并应用",
"compoundConfirmSelected": "应用选中({count})",
"compoundSkipped": "已跳过 {label}:未确认修改复合后缀",
"compoundSourceMismatch": "已跳过 {label}:Eagle 名称 {current} 与真实来源文件 {source} 不一致",
"compoundCheckFailed": "检查 {label} 的复合后缀失败:{error}",
"compoundNeedsConfirmation": "已跳过 {label}:确认后字段又发生变化,请下次重新确认",
"compoundBackupUnavailable": "无法修改复合后缀,因为原值备份不可用",
"compoundRolledBack": "缩略图生成失败,已恢复 {label} 的名称和类型",
"compoundRollbackFailed": "缩略图生成失败且无法自动恢复 {label}:{error}",
"restoreEmpty": "没有可恢复的名称/类型修改",
"restoreTitle": "选择要恢复的素材",
"restoreIntro": "以下是插件保存了原值的素材。可恢复项默认全部勾选;后来被用户修改或无法读取的素材不可选择。",
"restoreCurrent": "当前",
"restoreTarget": "恢复为",
"restoreStatusReady": "素材已被修改为 {fields}",
"restoreStatusAlreadyOriginal": "已是原值;确认后仅清理备份",
"restoreStatusChanged": "当前字段已被用户修改,将跳过",
"restoreStatusMissing": "素材不存在,无法恢复",
"restoreStatusError": "无法读取当前值,无法恢复",
"restoreCancel": "取消",
"restoreSelected": "恢复选中({count})",
"restoreComplete": "恢复完成:成功 {success},跳过 {skipped},失败 {failed}。",
"restoreFailed": "恢复名称/类型失败:{error}",
"modeLabel": "生成方式",
"modeAnimated": "动态(第一个动画)",
"modeStatic": "静帧(setup pose)",
"strategyLabel": "生成策略",
"strategySize": "最优体积(慢)",
"strategySpeed": "最优速度(快)",
"statusLabel": "状态",
"statusStarting": "启动中…",
"libraryLabel": "素材库",
"statsLabel": "统计",
"filterAll": "全部",
"filterOk": "成功",
"filterFail": "失败",
"filterWarn": "警告",
"filterError": "错误",
"unknownError": "未知错误",
"scriptError": "脚本错误:{error}",
"promiseError": "未处理的 Promise 错误:{error}",
"uncategorized": "未分类",
"reasonQuiet": "静默",
"reasonFallback": "兜底扫描",
"none": "无",
"complete": "完整",
"incomplete": "不完整",
"statusLoaded": "脚本已加载 {version},等待 Eagle 事件…",
"statusEagleMissing": "Eagle API 未注入",
"statusListening": "监听中",
"statusListenFailed": "监听失败",
"statusAutoOff": "监听中(自动生成已关闭)",
"statusNoLibrary": "没有拿到素材库路径",
"statusPairing": "正在配对新增素材…",
"statusPairingProgress": "正在配对 {current}/{total}",
"statusRendering": "正在渲染动画帧 {current}/{total}(排队 {queued} 个)",
"statusGenerating": "正在生成缩略图…剩余 {queued} 个",
"statusPaused": "已暂停(在进度弹窗里继续或取消)",
"statusAutoSkipped": "自动生成未启用,本批已跳过",
"statusStartFailed": "启动失败:{error}",
"stats": "批次 {batches} · 新增 {added} · Spine {spine} · 完整 {complete} · 已生成 {generated} · 跳过 {skipped} · 失败 {failed}",
"modulesMissing": "本地模块未加载:{modules}",
"moduleLoadError": "本地模块未加载,请检查 index.html 里 script 的顺序:{modules}",
"version": "插件代码版本 {version}",
"pluginCreated": "插件已创建:{name}",
"settingsReadFailed": "读取设置失败,按默认值处理(关闭):{error}",
"settingsSaveFailed": "保存设置失败:{error}",
"autoEnabled": "自动生成已启用。之后导入的 Spine 资产会自动生成缩略图。",
"autoDisabled": "自动生成已关闭,待处理队列已清空。",
"modeStaticLog": "生成方式:静帧(始终 setup pose,PNG)",
"modeAnimatedLog": "生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)",
"strategySpeedLog": "生成策略:最优速度(粗估挑档,只生成最小的一两档)",
"strategySizeLog": "生成策略:最优体积(十档全生成,取实际体积最小的那一档)",
"missingItem": "跳过 {label}:Eagle 里找不到这个素材",
"suffixFixed": "修正复合后缀 {label} → {name}",
"suffixFailed": "修正复合后缀失败 {label}:{error}",
"existingThumbnail": "跳过 {label}:已有自定义缩略图",
"thumbnailRejected": "Eagle 拒绝了这个缩略图",
"generated": "已生成 {label} | {details} | Spine {version}",
"generateFailed": "生成失败 {label}:{error}",
"cancelled": "已取消本轮生成:丢弃剩余 {dropped} 个素材,已完成 {done} 张(成功 {generated} 张,失败 {failed} 张,跳过 {skipped} 张)",
"batchFailed": "处理批次失败:{error}",
"batchFinished": "这批结束:新增 {added} 个,库内共 {total} 个({reason})",
"recheck": "连带重新检查之前 {count} 个配套不全的资产",
"noSpine": "这批里没有 Spine 资产",
"incompletePending": "有 {count} 个资产配套不全,先记为待观察,等后续批次补齐",
"incompleteIgnored": "有 {count} 个资产配套不全,本批不处理",
"itemSummary": " {name} | {path} | {state} | 骨架 {skeleton} | atlas {atlas} | 贴图页 {pages}(缺 {missing})",
"autoSkipped": "自动生成已关闭,本批 {count} 个资产已跳过",
"queued": "已加入队列 {added} 个,当前排队 {queued} 个{state}",
"queuedRunning": "(正在处理中)",
"libraryInfoNoPath": "library.info() 没有返回路径字段:{info}",
"libraryInfoFailed": "library.info() 调用失败:{error}",
"switchingLibrary": "切换素材库:{path}",
"noCreateEvent": "没有收到创建事件,直接尝试启动",
"watching": "监听 {path}(现有 {count} 个素材视为历史,不处理)",
"watchReadFailed": "读取素材目录失败:{error}",
"watchMissingDir": "素材目录不存在:{path}",
"watchFailed": "建立监听失败:{error}",
"fixSuffixAction": "修正复合后缀 {label}",
"renderAction": "渲染 {label}",
"setThumbnailAction": "设置缩略图 {label}",
"thumbnailDetailsAnimated": "动态 {size}@{fps}fps/q{quality}/{kb}kb(目标 {target}kb)/{frames} 帧/动画 {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "静帧 {width}*{height}/{kb}kb{fallback}",
"bestSpeed": "最优速度",
"bestSize": "最优体积",
"fallbackNote": "({text})",
"fallbackNoAnimation": "骨骼里没有动画",
"fallbackAnimatedFailed": "动态生成失败:{error}",
"limitNote": "(最小档仍超出目标体积,可以考虑提高缩略图大小)",
"runSummary": "成功 {generated} 张,失败 {failed} 张,跳过 {skipped} 张,用时 {seconds} 秒。"
},
"progress": {
"title": "Spine缩略图",
"generating": "正在生成动态缩略图……",
"waiting": "等待开始…",
"confirmPrompt": "是否确认取消?",
"resume": "继续",
"cancel": "确认取消",
"stop": "停止",
"confirm": "确认",
"generatedConfirm": "已生成 {done} 张缩略图,是否确认取消?",
"complete": "已完成",
"completed": "已完成 {done} / {total}({percent}%)"
}
}
@@ -0,0 +1,145 @@
{
"manifest": {
"app": {
"name": "Spine縮圖"
}
},
"thumb": {
"title": "Spine縮圖",
"noticeIntro": "功能:匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生一張骨骼預覽圖。",
"noticeSafety": "1. 自動產生預設為關閉,需要時請在下方手動啟用。只處理本次新匯入的素材,不會覆寫已設定的自訂縮圖。",
"noticeCompound": "2. 複合副檔名素材只有在確認原始值和新值後,才會調整 Eagle 的名稱/類型欄位;原始值會保存並可還原。",
"autoToggle": "匯入時自動產生縮圖",
"clear": "清除記錄",
"restoreNames": "還原名稱/類型",
"compoundConfirmTitle": "確認修改複合副檔名",
"compoundConfirmIntro": "由於複合副檔名檔案的特殊性,為了讓這些素材顯示產生的縮圖,Eagle 需要調整名稱和類型欄位。原始值會保存,可隨時在外掛面板還原。",
"compoundOldValue": "原始值",
"compoundNewValue": "新值",
"compoundSkip": "略過並繼續",
"compoundConfirm": "確認並套用",
"compoundConfirmSelected": "套用選取項目({count})",
"compoundSkipped": "已略過 {label}:未確認修改複合副檔名",
"compoundSourceMismatch": "已略過 {label}:Eagle 名稱 {current} 與實際來源檔案 {source} 不一致",
"compoundCheckFailed": "檢查 {label} 的複合副檔名失敗:{error}",
"compoundNeedsConfirmation": "已略過 {label}:確認後欄位又發生變化,請下次重新確認",
"compoundBackupUnavailable": "無法修改複合副檔名,因為原始值備份不可用",
"compoundRolledBack": "縮圖產生失敗,已還原 {label} 的名稱和類型",
"compoundRollbackFailed": "縮圖產生失敗且無法自動還原 {label}:{error}",
"restoreEmpty": "沒有可還原的名稱/類型修改",
"restoreTitle": "選擇要還原的素材",
"restoreIntro": "以下是外掛保存了原始值的素材。可還原項目預設全部勾選;後來被使用者修改或無法讀取的素材不可選擇。",
"restoreCurrent": "目前",
"restoreTarget": "還原為",
"restoreStatusReady": "素材已被修改為 {fields}",
"restoreStatusAlreadyOriginal": "已是原始值;確認後僅清除備份",
"restoreStatusChanged": "目前欄位已被使用者修改,將略過",
"restoreStatusMissing": "素材不存在,無法還原",
"restoreStatusError": "無法讀取目前值,無法還原",
"restoreCancel": "取消",
"restoreSelected": "還原選取項目({count})",
"restoreComplete": "還原完成:成功 {success},略過 {skipped},失敗 {failed}。",
"restoreFailed": "還原名稱/類型失敗:{error}",
"modeLabel": "產生方式",
"modeAnimated": "動態(第一個動畫)",
"modeStatic": "靜態(setup pose)",
"strategyLabel": "產生策略",
"strategySize": "最小檔案(慢)",
"strategySpeed": "最快速度(快速)",
"statusLabel": "狀態",
"statusStarting": "啟動中…",
"libraryLabel": "素材庫",
"statsLabel": "統計",
"filterAll": "全部",
"filterOk": "成功",
"filterFail": "失敗",
"filterWarn": "警告",
"filterError": "錯誤",
"unknownError": "未知錯誤",
"scriptError": "指令碼錯誤:{error}",
"promiseError": "未處理的 Promise 錯誤:{error}",
"uncategorized": "未分類",
"reasonQuiet": "靜默",
"reasonFallback": "備援掃描",
"none": "無",
"complete": "完整",
"incomplete": "不完整",
"statusLoaded": "指令碼已載入 {version},等待 Eagle 事件…",
"statusEagleMissing": "未注入 Eagle API",
"statusListening": "監聽中",
"statusListenFailed": "監聽失敗",
"statusAutoOff": "監聽中(自動產生已關閉)",
"statusNoLibrary": "無法取得素材庫路徑",
"statusPairing": "正在配對新增素材…",
"statusPairingProgress": "正在配對 {current}/{total}",
"statusRendering": "正在算繪動畫影格 {current}/{total}(佇列 {queued} 個)",
"statusGenerating": "正在產生縮圖…剩餘 {queued} 個",
"statusPaused": "已暫停(請在進度視窗繼續或取消)",
"statusAutoSkipped": "未啟用自動產生,已略過本批素材",
"statusStartFailed": "啟動失敗:{error}",
"stats": "批次 {batches} · 新增 {added} · Spine {spine} · 完整 {complete} · 已產生 {generated} · 略過 {skipped} · 失敗 {failed}",
"modulesMissing": "本機模組未載入:{modules}",
"moduleLoadError": "本機模組未載入,請檢查 index.html 的 script 順序:{modules}",
"version": "外掛程式碼版本 {version}",
"pluginCreated": "已建立外掛:{name}",
"settingsReadFailed": "讀取設定失敗,改用預設值(關閉):{error}",
"settingsSaveFailed": "儲存設定失敗:{error}",
"autoEnabled": "已啟用自動產生。之後匯入的 Spine 資產會自動產生縮圖。",
"autoDisabled": "已關閉自動產生,待處理佇列已清除。",
"modeStaticLog": "產生方式:靜態(一律 setup pose,PNG)",
"modeAnimatedLog": "產生方式:動態(第一個動畫轉為 WebP,沒有動畫時使用 setup pose)",
"strategySpeedLog": "產生策略:最快速度(粗估後只產生最小的一到兩檔)",
"strategySizeLog": "產生策略:最小檔案(產生全部十檔並保留最小結果)",
"missingItem": "略過 {label}:Eagle 中找不到這個素材",
"suffixFixed": "已修正複合副檔名 {label} → {name}",
"suffixFailed": "修正複合副檔名失敗 {label}:{error}",
"existingThumbnail": "略過 {label}:已有自訂縮圖",
"thumbnailRejected": "Eagle 拒絕了這個縮圖",
"generated": "已產生 {label} | {details} | Spine {version}",
"generateFailed": "產生失敗 {label}:{error}",
"cancelled": "已取消本輪產生:丟棄剩餘 {dropped} 個素材,已完成 {done} 張(成功 {generated} 張,失敗 {failed} 張,略過 {skipped} 張)",
"batchFailed": "處理批次失敗:{error}",
"batchFinished": "本批結束:新增 {added} 個,素材庫共 {total} 個({reason})",
"recheck": "重新檢查先前 {count} 個不完整的資產",
"noSpine": "本批沒有 Spine 資產",
"incompletePending": "有 {count} 個資產不完整,將在後續批次重新檢查",
"incompleteIgnored": "有 {count} 個資產不完整,本批未處理",
"itemSummary": " {name} | {path} | {state} | 骨架 {skeleton} | atlas {atlas} | 貼圖頁 {pages}(缺少 {missing})",
"autoSkipped": "自動產生已關閉,本批已略過 {count} 個資產",
"queued": "已加入佇列 {added} 個,目前等待 {queued} 個{state}",
"queuedRunning": "(處理中)",
"libraryInfoNoPath": "library.info() 未傳回路徑:{info}",
"libraryInfoFailed": "library.info() 呼叫失敗:{error}",
"switchingLibrary": "切換素材庫:{path}",
"noCreateEvent": "未收到建立事件,直接嘗試啟動",
"watching": "監聽 {path}(現有 {count} 個素材視為歷史資料,不處理)",
"watchReadFailed": "讀取素材目錄失敗:{error}",
"watchMissingDir": "素材目錄不存在:{path}",
"watchFailed": "建立監聽失敗:{error}",
"fixSuffixAction": "修正複合副檔名 {label}",
"renderAction": "算繪 {label}",
"setThumbnailAction": "設定縮圖 {label}",
"thumbnailDetailsAnimated": "動態 {size}@{fps}fps/q{quality}/{kb}KB(目標 {target}KB)/ {frames} 幀 / 動畫 {animation}/{strategy}{limit}",
"thumbnailDetailsStatic": "靜態 {width}×{height}/{kb}KB{fallback}",
"bestSpeed": "最快速度",
"bestSize": "最小檔案",
"fallbackNote": "({text})",
"fallbackNoAnimation": "骨骼沒有動畫",
"fallbackAnimatedFailed": "動態產生失敗:{error}",
"limitNote": "(最小檔仍超過目標大小,可考慮提高縮圖尺寸)",
"runSummary": "成功 {generated} 張,失敗 {failed} 張,略過 {skipped} 張,耗時 {seconds} 秒。"
},
"progress": {
"title": "Spine縮圖",
"generating": "正在產生動態縮圖……",
"waiting": "等待開始…",
"confirmPrompt": "確定要取消嗎?",
"resume": "繼續",
"cancel": "確認取消",
"stop": "停止",
"confirm": "確認",
"generatedConfirm": "已產生 {done} 張縮圖,確定要取消嗎?",
"complete": "已完成",
"completed": "已完成 {done} / {total}({percent}%)"
}
}
+203
View File
@@ -0,0 +1,203 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title data-i18n="thumb.title">Spine缩略图</title>
<style>
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; --warn: #e3b341; }
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
body { display: flex; flex-direction: column; gap: 9px; padding: 14px; box-sizing: border-box; }
.notice { color: var(--dim); line-height: 1.6; background: var(--panel);
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.notice p { margin: 0 0 6px; }
.notice p:last-child { margin-bottom: 0; }
.notice b { color: var(--text); font-weight: 500; }
.notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; }
.toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
flex: 1 1 240px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
/* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
.toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
.toolbar-group.primary { flex: 1 1 auto; flex-wrap: nowrap; min-width: 0; }
.toolbar-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.toolbar-group.primary .toggle-row { min-width: 220px; }
#clear, #restoreNames { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
#clear:hover, #restoreNames:hover { border-color: var(--accent); color: var(--accent); }
.mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
flex: 0 1 auto;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.mode-row select { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 3px 6px; font: inherit; max-width: 200px; }
.row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
.row b { color: var(--text); font-weight: 500; word-break: break-all; }
#stats { color: var(--accent); }
#log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none; min-height: 140px;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; }
#log li { white-space: pre-wrap; word-break: break-all; }
#log li.dim { color: var(--dim); }
#log li.ok { color: var(--accent); }
#log li.fail { color: #e06c75; }
#log li.warn { color: var(--warn); }
#log li.error { color: #ff5f56; }
/* 过滤直接用 CSS:给 #log 一个 data-filter,非该类别的行就不显示,不用遍历 DOM。 */
#log[data-filter="ok"] li:not(.ok),
#log[data-filter="fail"] li:not(.fail),
#log[data-filter="warn"] li:not(.warn),
#log[data-filter="error"] li:not(.error) { display: none; }
.filters { display: flex; gap: 6px; }
.filters button { background: var(--panel); color: var(--dim); border: 1px solid var(--border);
border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; }
.filters button.active { color: var(--accent); border-color: var(--accent); }
.compat-dialog { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
padding: 18px; background: rgba(0, 0, 0, .62); }
.compat-dialog.hidden { display: none; }
.compat-card { width: min(640px, 100%); max-height: calc(100vh - 36px); display: flex;
flex-direction: column; gap: 12px; padding: 18px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 18px 54px rgba(0, 0, 0, .48); }
.compat-card h2 { margin: 0; font-size: 16px; }
.compat-card p { margin: 0; color: var(--dim); line-height: 1.65; }
.compat-list { overflow: auto; border: 1px solid var(--border); border-radius: 6px; }
.compat-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer; }
.compat-row:last-child { border-bottom: 0; }
.compat-row input { margin: 3px 0 0; accent-color: var(--accent); }
.compat-row-content { min-width: 0; }
.compat-file { color: var(--text); font-weight: 500; word-break: break-all; }
.compat-path { color: var(--dim); font-size: 12px; word-break: break-all; }
.compat-change { margin-top: 4px; font-family: ui-monospace, Consolas, monospace; font-size: 12px; word-break: break-all; }
.compat-change code { color: var(--accent); background: transparent; border: 0; padding: 0; }
.compat-actions { display: flex; justify-content: flex-end; gap: 8px; }
.compat-actions button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 14px; font: inherit; cursor: pointer; }
.compat-actions button.primary { color: #fff; background: #1976d2; border-color: #1976d2; }
.compat-actions button:disabled { opacity: .45; cursor: default; }
.restore-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer; }
.restore-row:last-child { border-bottom: 0; }
.restore-row.disabled { opacity: .55; cursor: default; }
.restore-row input { margin: 3px 0 0; accent-color: var(--accent); }
.restore-current, .restore-target { margin-top: 4px; font-family: ui-monospace, Consolas, monospace;
font-size: 12px; word-break: break-all; }
.restore-current code, .restore-target code { color: var(--accent); background: transparent; border: 0; padding: 0; }
.restore-status { color: var(--dim); font-size: 12px; margin-top: 3px; }
.restore-status.applied { color: #3fb950; }
.restore-row.disabled .restore-status { color: var(--warn); }
.restore-row.disabled .restore-status.applied { color: #3fb950; }
</style>
</head>
<body>
<div class="notice">
<p data-i18n="thumb.noticeIntro">功能:导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
<p data-i18n="thumb.noticeSafety">1. 自动生成默认关闭,需要你在下面手动启用。它只处理本次新导入的素材,不会覆盖已经设置过的自定义缩略图。</p>
<p data-i18n="thumb.noticeCompound">2. 复合后缀素材只有在确认原值和新值后,才会调整 Eagle 的名称/类型字段;原值会保存并可恢复。</p>
</div>
<div class="toolbar">
<div class="toolbar-group primary">
<label class="toggle-row"><input type="checkbox" id="toggle"><span data-i18n="thumb.autoToggle">导入时自动生成缩略图</span></label>
<div class="toolbar-actions">
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
<button id="restoreNames" type="button" data-i18n="thumb.restoreNames">恢复名称/类型</button>
</div>
</div>
<div class="toolbar-group">
<label class="mode-row"><span data-i18n="thumb.modeLabel">生成方式</span>
<select id="mode">
<option value="animated" data-i18n="thumb.modeAnimated">动态(第一个动画)</option>
<option value="static" data-i18n="thumb.modeStatic">静帧(setup pose)</option>
</select>
</label>
<label class="mode-row"><span data-i18n="thumb.strategyLabel">生成策略</span>
<select id="strategy">
<option value="size" data-i18n="thumb.strategySize">最优体积(慢)</option>
<option value="speed" data-i18n="thumb.strategySpeed">最优速度(快)</option>
</select>
</label>
</div>
</div>
<div class="row"><span data-i18n="thumb.statusLabel">状态</span><b id="status" data-i18n="thumb.statusStarting">启动中…</b></div>
<div class="row"><span data-i18n="thumb.libraryLabel">素材库</span><b id="library">—</b></div>
<div class="row"><span data-i18n="thumb.statsLabel">统计</span><b id="stats">—</b></div>
<div class="filters" id="filters">
<button type="button" data-filter="all" class="active" data-i18n="thumb.filterAll">全部</button>
<button type="button" data-filter="ok" data-i18n="thumb.filterOk">成功</button>
<button type="button" data-filter="fail" data-i18n="thumb.filterFail">失败</button>
<button type="button" data-filter="warn" data-i18n="thumb.filterWarn">警告</button>
<button type="button" data-filter="error" data-i18n="thumb.filterError">错误</button>
</div>
<ul id="log"></ul>
<div id="compatDialog" class="compat-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="compatTitle">
<div class="compat-card">
<h2 id="compatTitle" data-i18n="thumb.compoundConfirmTitle">确认修改复合后缀</h2>
<p data-i18n="thumb.compoundConfirmIntro">由于复合后缀文件的特殊性,为了让这些素材显示生成的缩略图,Eagle 需要调整名称和类型字段。原值会保存,可随时在插件面板恢复。</p>
<div id="compatList" class="compat-list"></div>
<div class="compat-actions">
<button id="compatSkip" type="button" data-i18n="thumb.compoundSkip">跳过并继续</button>
<button id="compatConfirm" class="primary" type="button" data-i18n="thumb.compoundConfirm">确认并应用</button>
</div>
</div>
</div>
<div id="restoreDialog" class="compat-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="restoreTitle">
<div class="compat-card">
<h2 id="restoreTitle" data-i18n="thumb.restoreTitle">选择要恢复的素材</h2>
<p data-i18n="thumb.restoreIntro">以下是插件保存了原值的素材。可恢复项默认全部勾选;后来被用户修改或无法读取的素材不可选择。</p>
<div id="restoreList" class="compat-list"></div>
<div class="compat-actions">
<button id="restoreCancel" type="button" data-i18n="thumb.restoreCancel">取消</button>
<button id="restoreApply" class="primary" type="button" disabled>恢复选中</button>
</div>
</div>
</div>
<script>
// service 页面里的 require 不以插件目录为基准,本地资源要用绝对路径,这里先把根目录算出来。
(function () {
try {
// Windows 上 pathname 形如 /C:/...,要去掉盘符前那个多余的斜杠;
// macOS/Linux 是 /Users/...,前导斜杠必须留着,否则会变成相对路径。
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
})();
</script>
<script src="js/i18n.js"></script>
<script>
// 诊断用:脚本一旦抛错,直接把原因写在状态栏,避免停在「启动中…」看不出问题。
window.addEventListener('error', (event) => {
const el = document.getElementById('status');
if (el) el.textContent = SpineThumbI18n.t('thumb.scriptError', {
error: event.message || event.error || SpineThumbI18n.t('thumb.unknownError'),
});
});
window.addEventListener('unhandledrejection', (event) => {
const el = document.getElementById('status');
if (el) el.textContent = SpineThumbI18n.t('thumb.promiseError', {
error: event.reason && event.reason.message || event.reason,
});
});
</script>
<!-- 顺序有依赖:rasterizer 先于 clipping,utils/badge 先于 renderer,plugin.js 最后。 -->
<script src="js/spine-webgl-rasterizer.js"></script>
<script src="js/spine-canvas-clipping.js"></script>
<script src="js/spine-background.js"></script>
<script src="js/spine-visible-bounds.js"></script>
<script src="js/animated-profiles.js"></script>
<script src="js/animated-webp-encoder.js"></script>
<script src="js/spine-utils.js"></script>
<script src="js/spine-legacy-binary.js"></script>
<script src="js/spine-badge.js"></script>
<script src="js/settings.js"></script>
<script src="js/compound-compat.js"></script>
<script src="js/instance-coordinator.js"></script>
<script src="js/progress-window.js"></script>
<script src="js/renderer.js"></script>
<script src="js/library-watcher.js"></script>
<script src="js/spine-assets.js"></script>
<script src="js/plugin.js"></script>
</body>
</html>
@@ -0,0 +1,102 @@
// 动态缩略图的档位配置。数值与主插件 js/animated-profiles.js 保持一致,
// 后台插件没有参数面板,所以这里只当默认值来源,改这里就等于改后台插件的默认渲染参数。
(function () {
'use strict';
/**
* 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
* - 细节分辨率决定画面保留多少细节(输出画布始终是画布边长,小分辨率会被放大填满);
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
* - 编码质量是 0–1 的 WebP 质量。
*
* 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
* 路径,再按目标体积在路径上定位,取命中位置附近若干档完整生成,挑出符合体积的那一档。
*/
const ANIMATED_PRIORITIES = {
// 分辨率优先:先把帧率压到底,再降分辨率。
resolution: [
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
],
// 帧率优先:先把分辨率压到底,再降帧率。
framerate: [
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
],
// 交替进行:分辨率和帧率交替下降。
balanced: [
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
],
};
const DEFAULT_PRIORITY = 'resolution';
/** 可选的输出画布边长(正方形)。后台插件默认用 200。 */
const ANIMATED_OUTPUT_SIZES = [200, 400];
const DEFAULT_OUTPUT_SIZE = 200;
/** 目标体积的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。 */
const SIZE_PER_SECOND = 25;
const SIZE_SUGGESTION_RANGE = [25, 100];
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,粗估阶段耗时略增。
const PATH_STEPS_PER_SEGMENT = 6;
function suggestSize(seconds) {
const [min, max] = SIZE_SUGGESTION_RANGE;
return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
}
function animatedProfiles(priority, outputSize) {
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
return plan.map(([size, fps, quality]) => ({
size: Math.min(size, limit),
fps,
quality,
}));
}
/**
* 把锚点路径插值成更密的采样路径,供按目标体积定位使用。
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
*/
function expandAnimatedPath(priority, outputSize) {
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
const lerp = (from, to, t) => from + (to - from) * t;
const path = [];
for (let index = 0; index < anchors.length - 1; index++) {
const [fromSize, fromFps, fromQuality] = anchors[index];
const [toSize, toFps, toQuality] = anchors[index + 1];
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
const t = step / PATH_STEPS_PER_SEGMENT;
path.push({
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
});
}
}
const [size, fps, quality] = anchors[anchors.length - 1];
path.push({ size: Math.min(size, limit), fps, quality });
return path;
}
const api = {
ANIMATED_PRIORITIES,
DEFAULT_PRIORITY,
ANIMATED_OUTPUT_SIZES,
DEFAULT_OUTPUT_SIZE,
SIZE_PER_SECOND,
SIZE_SUGGESTION_RANGE,
PATH_STEPS_PER_SEGMENT,
animatedProfiles,
expandAnimatedPath,
suggestSize,
};
if (typeof window !== 'undefined') window.SpineAnimatedProfiles = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,85 @@
// 把若干张 WebP 静帧封装成一个动态 WebP(ANMF 全帧、不混合)。
// 与主插件 js/animated-webp-encoder.js 是同一份实现,只是出口兼容 <script> 加载。
(function () {
'use strict';
function writeUint24LE(buffer, offset, value) {
buffer[offset] = value & 0xff;
buffer[offset + 1] = (value >>> 8) & 0xff;
buffer[offset + 2] = (value >>> 16) & 0xff;
}
function chunk(type, payload) {
const padding = payload.length & 1;
const output = Buffer.alloc(8 + payload.length + padding);
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
}
function frameData(webp) {
if (!webp) throw new Error('WebP 帧数据缺失');
const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame');
}
const parts = [];
let hasAlpha = false;
let offset = 12;
while (offset + 8 <= input.length) {
const type = input.toString('ascii', offset, offset + 4);
const length = input.readUInt32LE(offset + 4);
const end = offset + 8 + length + (length & 1);
if (end > input.length) throw new Error('Truncated WebP frame');
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
if (type === 'ALPH') hasAlpha = true;
offset = end;
}
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
throw new Error('WebP frame has no image data');
}
return { data: Buffer.concat(parts), hasAlpha };
}
function encode(options) {
const width = Number(options && options.width);
const height = Number(options && options.height);
const frames = options && options.frames;
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
throw new RangeError('Animated WebP dimensions are invalid');
}
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
const vp8x = Buffer.alloc(10);
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
writeUint24LE(vp8x, 4, width - 1);
writeUint24LE(vp8x, 7, height - 1);
const anim = Buffer.alloc(6);
const background = options.background || [30, 30, 30, 255];
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
for (const { frame, data } of preparedFrames) {
const header = Buffer.alloc(16);
writeUint24LE(header, 6, width - 1);
writeUint24LE(header, 9, height - 1);
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
header[15] = 0x02;
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
}
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
const api = { encode };
if (typeof window !== 'undefined') window.SpineAnimatedWebpEncoder = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,197 @@
// 复合后缀兼容修改:只生成计划并持久化原值;真正修改 Eagle 字段由插件在用户确认后执行。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const STORE_VERSION = 1;
function fullName(name, ext) {
return String(name || '') + (ext ? '.' + ext : '');
}
/** 返回需要展示给用户的字段变更;不需要修改或被安全条件阻断时返回 null / blocked。 */
function planChange(item, eagleItem, splitCompoundName) {
const sourceName = String((item && item.entryName) || '');
const currentName = String((eagleItem && eagleItem.name) || '');
const currentExt = String((eagleItem && eagleItem.ext) || '');
const currentFileName = fullName(currentName, currentExt);
if (!sourceName || currentFileName !== sourceName) {
return {
blocked: true,
reason: 'sourceMismatch',
sourceName,
currentName,
currentExt,
currentFileName,
};
}
const parts = splitCompoundName(sourceName);
if (!parts || (currentName === parts.name && currentExt === parts.ext)) return null;
if (fullName(parts.name, parts.ext) !== sourceName) {
return {
blocked: true,
reason: 'sourceMismatch',
sourceName,
currentName,
currentExt,
currentFileName,
};
}
return {
sourceName,
currentName,
currentExt,
currentFileName,
newName: parts.name,
newExt: parts.ext,
newFileName: fullName(parts.name, parts.ext),
};
}
function emptyStore() {
return { version: STORE_VERSION, entries: {} };
}
function readStore(file) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!data || data.version !== STORE_VERSION || !data.entries || typeof data.entries !== 'object') {
throw new Error('备份文件格式不正确');
}
return data;
} catch (error) {
if (error && error.code === 'ENOENT') return emptyStore();
throw error;
}
}
function writeStore(file, data) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = file + '.' + process.pid + '.tmp';
fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.renameSync(temp, file);
}
function createStore(userDataPath) {
const file = path.join(userDataPath, 'SpineThumbnailGenerateServer', 'compound-name-backups.json');
return {
file,
list() {
return Object.values(readStore(file).entries)
.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
},
remember(change) {
const data = readStore(file);
if (!data.entries[change.itemId]) {
data.entries[change.itemId] = {
itemId: change.itemId,
sourceName: change.sourceName,
folderPath: change.folderPath || '',
originalName: change.currentName,
originalExt: change.currentExt,
appliedName: change.newName,
appliedExt: change.newExt,
updatedAt: Date.now(),
};
writeStore(file, data);
}
return data.entries[change.itemId];
},
remove(itemId) {
const data = readStore(file);
if (!data.entries[itemId]) return;
delete data.entries[itemId];
writeStore(file, data);
},
};
}
/**
* 为恢复对话框生成可审计列表,并标出哪些条目允许恢复。
* 用户后来亲自改过的字段不会被覆盖。
*/
async function inspectBackups(store, getItem, sourceExists) {
const entries = store.list();
const rows = [];
for (const entry of entries) {
let item = null;
try {
item = await getItem(entry.itemId);
if (!item) {
let sourcePresent = false;
try {
sourcePresent = sourceExists ? await sourceExists(entry) : false;
} catch (error) { /* 检查失败时保守按缺失处理 */ }
if (sourcePresent) {
rows.push({
entry,
item: null,
currentName: entry.appliedName,
currentExt: entry.appliedExt,
status: 'pluginApplied',
});
} else {
rows.push({ entry, item: null, status: 'missing' });
}
continue;
}
const currentName = String(item.name || '');
const currentExt = String(item.ext || '');
if (currentName === entry.originalName && currentExt === entry.originalExt) {
rows.push({ entry, item, currentName, currentExt, status: 'alreadyOriginal' });
continue;
}
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
rows.push({ entry, item, currentName, currentExt, status: 'changed' });
continue;
}
rows.push({ entry, item, currentName, currentExt, status: 'ready' });
} catch (error) {
rows.push({ entry, item, status: 'error' });
}
}
return rows;
}
async function restoreBackups(store, selectedItemIds, getItem, saveItem) {
const selected = new Set(selectedItemIds || []);
const entries = store.list().filter((entry) => selected.has(entry.itemId));
const result = { total: entries.length, success: 0, skipped: 0, failed: 0 };
for (const entry of entries) {
let item = null;
try {
item = await getItem(entry.itemId);
if (!item) {
result.skipped += 1;
continue;
}
const currentName = String(item.name || '');
const currentExt = String(item.ext || '');
if (currentName === entry.originalName && currentExt === entry.originalExt) {
store.remove(entry.itemId);
result.success += 1;
continue;
}
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
result.skipped += 1;
continue;
}
item.name = entry.originalName;
item.ext = entry.originalExt;
await saveItem(item);
store.remove(entry.itemId);
result.success += 1;
} catch (error) {
result.failed += 1;
}
}
return result;
}
const api = { fullName, planChange, createStore, inspectBackups, restoreBackups, STORE_VERSION };
if (typeof window !== 'undefined') window.SpineThumbCompoundCompat = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
+112
View File
@@ -0,0 +1,112 @@
// Shared localization for the service panel and progress window.
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const LANGUAGES = ['en', 'zh_CN', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE'];
const FALLBACK = 'zh_CN';
function normalizeLocale(raw) {
if (!raw) return null;
const tag = String(raw).trim().toLowerCase().replace(/-/g, '_');
if (tag === 'zh_tw' || tag === 'zh_hk' || tag === 'zh_hant' || tag === 'zh_mo') return 'zh_TW';
for (const code of LANGUAGES) {
if (code.toLowerCase() === tag) return code;
}
const base = tag.split('_')[0];
if (base === 'zh') return 'zh_CN';
for (const code of LANGUAGES) {
if (code.toLowerCase().split('_')[0] === base) return code;
}
return null;
}
function requestedLocale() {
try {
const params = new URLSearchParams(location.search);
const fromUrl = params.get('lang') || params.get('locale');
if (fromUrl) return fromUrl;
} catch (error) { /* No browser location. */ }
try {
if (typeof eagle !== 'undefined' && eagle.app && eagle.app.locale) return eagle.app.locale;
} catch (error) { /* Eagle API is injected asynchronously. */ }
try {
return navigator.language;
} catch (error) {
return null;
}
}
function pluginRoot() {
if (window.SpinePluginPath) return window.SpinePluginPath;
if (window.SpinePluginRoot) return window.SpinePluginRoot;
return typeof __dirname !== 'undefined' ? __dirname : null;
}
function readLocale(code) {
try {
const root = pluginRoot();
if (!root) return {};
return JSON.parse(fs.readFileSync(path.join(root, '_locales', code + '.json'), 'utf8'));
} catch (error) {
return {};
}
}
function lookup(table, key) {
let node = table;
for (const part of String(key).split('.')) {
if (!node || typeof node !== 'object') return undefined;
node = node[part];
}
return typeof node === 'string' ? node : undefined;
}
function apply(root) {
const scope = root || document;
for (const el of scope.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
for (const el of scope.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
for (const el of scope.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
}
let fallbackMessages = readLocale(FALLBACK);
let locale = FALLBACK;
let messages = fallbackMessages;
function t(key, vars) {
let text = lookup(messages, key);
if (text === undefined) text = lookup(fallbackMessages, key);
if (text === undefined) return key;
if (vars) {
text = text.replace(/\{(\w+)\}/g, (whole, name) =>
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole);
}
return text;
}
function setLocale(raw) {
locale = normalizeLocale(raw) || FALLBACK;
fallbackMessages = readLocale(FALLBACK);
messages = locale === FALLBACK ? fallbackMessages : readLocale(locale);
document.documentElement.lang = locale.replace('_', '-');
apply(document);
return locale;
}
const api = {
languages: LANGUAGES,
fallback: FALLBACK,
get locale() { return locale; },
normalizeLocale,
requestedLocale,
setLocale,
t,
apply,
};
window.SpineThumbI18n = api;
setLocale(requestedLocale());
})();
@@ -0,0 +1,127 @@
// 多个 Eagle 插件窗口共用 localStorage,用它协调唯一的活动监听实例。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const KEY = 'spine-thumbnail-active-instance';
const STALE_MS = 3500;
function createFileStorage(file) {
return {
file,
getItem(key) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return typeof data[key] === 'string' ? data[key] : null;
} catch (error) {
return null;
}
},
setItem(key, value) {
let data = {};
try {
const existing = JSON.parse(fs.readFileSync(file, 'utf8'));
if (existing && typeof existing === 'object') data = existing;
} catch (error) { /* first write or damaged file */ }
data[key] = String(value);
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = file + '.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(16).slice(2) + '.tmp';
fs.writeFileSync(temp, JSON.stringify(data), 'utf8');
fs.renameSync(temp, file);
},
};
}
function readState(storage) {
try {
const state = JSON.parse(storage.getItem(KEY));
if (!state || typeof state.id !== 'string' || !Number.isFinite(state.updatedAt)) return null;
return state;
} catch (error) {
return null;
}
}
function writeState(storage, id, visible, now) {
const state = { id, visible, updatedAt: now };
try { storage.setItem(KEY, JSON.stringify(state)); } catch (error) { /* 无本地存储时仍可运行 */ }
return state;
}
function createCoordinator(options) {
const storage = options.storage;
const id = options.id;
const visible = options.isVisible || (() => true);
const hide = options.hide || (() => {});
const now = options.now || Date.now;
const staleMs = options.staleMs || STALE_MS;
const intervalMs = options.intervalMs || 1000;
let active = false;
let started = false;
let hiddenForDuplicate = false;
let timer = null;
function claim() {
writeState(storage, id, visible(), now());
hiddenForDuplicate = false;
if (!active) {
active = true;
if (options.onActiveChanged) options.onActiveChanged(true);
}
}
function evaluate() {
const state = readState(storage);
const currentVisible = visible();
const canClaim = !state ||
state.id === id ||
now() - state.updatedAt > staleMs;
const nextActive = canClaim;
if (nextActive) {
claim();
} else if (currentVisible && !hiddenForDuplicate) {
hiddenForDuplicate = true;
try { hide(); } catch (error) { /* 隐藏失败不影响主窗口 */ }
}
if (nextActive !== active) {
active = nextActive;
if (options.onActiveChanged) options.onActiveChanged(active);
}
return active;
}
function handleVisibility() {
if (visible()) claim();
else evaluate();
}
function start() {
if (started) return active;
started = true;
const state = readState(storage);
const canStart = visible() ||
!state ||
state.id === id ||
now() - state.updatedAt > staleMs;
if (canStart) claim();
else evaluate();
timer = setInterval(evaluate, intervalMs);
return active;
}
function stop() {
if (timer) clearInterval(timer);
timer = null;
started = false;
}
return { start, stop, evaluate, handleVisibility, isActive: () => active };
}
const api = { createCoordinator, createFileStorage, KEY, STALE_MS };
if (typeof window !== 'undefined') window.SpineThumbInstanceCoordinator = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,121 @@
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载;包成 IIFE 是为了
// 让多个页面脚本各自的 const 不互相冲突。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
// Eagle 没有暴露「素材导入完成」事件,所以用「目录静默检测」代替:
// 监听素材目录,任何变化都重置防抖计时器,安静 QUIET_MS 之后才认为这一批拖入结束。
//
// 1 秒:足够合并连续写入。Eagle 的「重复添加提示」是主窗口里的 Angular 模态框,
// 没有独立窗口句柄可检测,插件只能靠静默判断——那期间素材库是静止的,但重复文件
// 还没入库,扫到的只是已入库的非重复素材。
const QUIET_MS = 1000;
// fs.watch 在网络盘/外置盘上可能漏事件,用低频兜底扫描补上。
const FALLBACK_MS = 30000;
/** 删除指定目录中超过保留时间的本插件临时文件。 */
function cleanupStaleFiles(directory, prefix, maxAgeMs, now = Date.now()) {
let names;
try { names = fs.readdirSync(directory); } catch (error) { return 0; }
let removed = 0;
for (const name of names) {
if (!name.startsWith(prefix)) continue;
const file = path.join(directory, name);
try {
const stat = fs.lstatSync(file);
if (!stat.isFile() || now - stat.mtimeMs < maxAgeMs) continue;
fs.unlinkSync(file);
removed += 1;
} catch (error) { /* 单个文件失败不影响其余清理和监听 */ }
}
return removed;
}
function createLibraryWatcher(options) {
const onBatch = options.onBatch || (() => {});
const onLog = options.onLog || (() => {});
const t = options.t || ((key) => key);
let imagesDir = null;
let watcher = null;
let quietTimer = null;
let fallbackTimer = null;
let known = null;
function listItemDirs() {
try {
return new Set(fs.readdirSync(imagesDir).filter((name) => name.endsWith('.info')));
} catch (error) {
onLog(t('thumb.watchReadFailed', { error: (error && error.message) || error }));
return new Set();
}
}
/** 与上次快照比对,把新增的素材目录交给上层处理。 */
function flush(reason) {
if (!imagesDir || !known) return;
const current = listItemDirs();
const added = [...current].filter((name) => !known.has(name));
known = current;
onBatch({ added, total: current.size, reason });
}
function scheduleFlush() {
if (quietTimer) clearTimeout(quietTimer);
quietTimer = setTimeout(() => {
quietTimer = null;
flush('quiet');
}, QUIET_MS);
}
function stop() {
if (watcher) { watcher.close(); watcher = null; }
if (quietTimer) { clearTimeout(quietTimer); quietTimer = null; }
if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null; }
imagesDir = null;
known = null;
}
/** 监听指定素材库。首次快照里的素材视为历史数据,不处理。 */
function start(libraryPath) {
stop();
imagesDir = path.join(libraryPath, 'images');
if (!fs.existsSync(imagesDir)) {
onLog(t('thumb.watchMissingDir', { path: imagesDir }));
return false;
}
known = listItemDirs();
try {
watcher = fs.watch(imagesDir, { persistent: true }, () => scheduleFlush());
} catch (error) {
onLog(t('thumb.watchFailed', { error: (error && error.message) || error }));
return false;
}
fallbackTimer = setInterval(() => {
const current = listItemDirs();
// 不能只比数量:同一次导里如果有增有删,数量可能刚好抵消。
let changed = current.size !== known.size;
if (!changed) {
for (const name of current) {
if (!known.has(name)) { changed = true; break; }
}
}
if (changed) flush('fallback');
}, FALLBACK_MS);
return true;
}
return { start, stop, flush, isRunning: () => !!watcher };
}
// 同时支持两种加载方式:Node 里用 require(跑测试),插件页面里用 <script> 标签。
// service 插件页面的 require 解析基准不是插件目录,所以页面里必须走 script 标签。
const libraryWatcherApi = { createLibraryWatcher, cleanupStaleFiles, QUIET_MS, FALLBACK_MS };
// 优先挂 window:Eagle 插件页面里也存在 module 对象(它有 Node 模块系统),
// 先判断 module 会让页面拿到模块却取不到 window 上的名字。Node 里没有 window,自然走 require 分支。
if (typeof window !== 'undefined') window.SpineThumbLibraryWatcher = libraryWatcherApi;
else if (typeof module !== 'undefined' && module.exports) module.exports = libraryWatcherApi;
})();
+931
View File
@@ -0,0 +1,931 @@
'use strict';
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v79';
const SHOW_DEV_VERSION = true;
const path = require('path');
const os = require('os');
const fs = require('fs');
const i18n = window.SpineThumbI18n;
const t = i18n ? i18n.t : (key) => key;
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
const REQUIRED = [
'SpineThumbI18n',
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
'SpineThumbProgress', 'SpineThumbCompoundCompat', 'SpineThumbInstanceCoordinator',
];
const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) {
const modules = missing.join(', ');
document.getElementById('status').textContent = t('thumb.modulesMissing', { modules });
throw new Error(t('thumb.moduleLoadError', { modules }));
}
const { createLibraryWatcher, cleanupStaleFiles } = window.SpineThumbLibraryWatcher;
const { scanSpineItems } = window.SpineThumbSpineAssets;
const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils;
const progress = window.SpineThumbProgress;
const compoundCompat = window.SpineThumbCompoundCompat;
const instanceCoordinatorModule = window.SpineThumbInstanceCoordinator;
const statusEl = document.getElementById('status');
const libraryEl = document.getElementById('library');
const statsEl = document.getElementById('stats');
const logEl = document.getElementById('log');
const toggleEl = document.getElementById('toggle');
const clearEl = document.getElementById('clear');
const restoreNamesEl = document.getElementById('restoreNames');
const filtersEl = document.getElementById('filters');
const modeEl = document.getElementById('mode');
const strategyEl = document.getElementById('strategy');
const compatDialogEl = document.getElementById('compatDialog');
const compatListEl = document.getElementById('compatList');
const compatConfirmEl = document.getElementById('compatConfirm');
const compatSkipEl = document.getElementById('compatSkip');
const restoreDialogEl = document.getElementById('restoreDialog');
const restoreListEl = document.getElementById('restoreList');
const restoreCancelEl = document.getElementById('restoreCancel');
const restoreApplyEl = document.getElementById('restoreApply');
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
// 判定用排队数,不要求最终真的都产出动态 WebP。
const PROGRESS_MIN_DYNAMIC = 10;
// 弹窗上的「停止」按钮会把动作写进这个文件,宿主读回来执行暂停/继续/取消。
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
let settings = { ...settingsStore.DEFAULTS };
let compatStore = null;
let instanceCoordinator = null;
let pluginUserDataPath = null;
let libraryPath = null;
let imagesDir = null;
const queue = [];
let running = false;
// 本轮(一次队列排空)的统计和是否要开进度弹窗的标记;排空后一起清掉。
let runStats = null;
let runShowsProgress = false;
// 弹窗暂停期间,队列原地等待,不再取出新的素材。
let paused = false;
let lastControlAt = 0;
let versionLogged = false;
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
// Eagle 的重复添加提示会把一次导入拆成几批,靠它才能等齐配套文件。
const pending = new Set();
function log(message, kind) {
const line = document.createElement('li');
if (kind) line.className = kind;
line.textContent = '[' + new Date().toTimeString().slice(0, 8) + '] ' + message;
logEl.appendChild(line);
while (logEl.childElementCount > 400) logEl.removeChild(logEl.firstElementChild);
logEl.scrollTop = logEl.scrollHeight;
console.log('[SpineThumbnailGenerateServer] ' + message);
}
function logVersion() {
if (!SHOW_DEV_VERSION || versionLogged) return;
versionLogged = true;
log(t('thumb.version', { version: VERSION }));
}
function setStatus(text) {
statusEl.textContent = text;
}
function refreshStats() {
statsEl.textContent = t('thumb.stats', stats);
}
function itemIdOf(dirName) {
return String(dirName).replace(/\.info$/i, '');
}
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
// 文件夹树每批重读一次(见 handleBatchAsync):导入会新建文件夹、同一会话里也可能重建
// 素材库,跨批次复用旧 id 的话日志会整片变成「未分类」。
let folderPaths = null;
function loadFolderPaths() {
folderPaths = new Map();
try {
const tree = JSON.parse(fs.readFileSync(path.join(libraryPath, 'metadata.json'), 'utf8')).folders || [];
const walk = (list, prefix) => {
for (const f of list || []) {
const full = prefix + (f.name || f.id);
folderPaths.set(f.id, full);
walk(f.children, full + '/');
}
};
walk(tree, '');
} catch (error) { /* 读不到就只显示未分类 */ }
}
function optionalFolderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || '';
}
function folderPathOf(folderIds) {
return optionalFolderPathOf(folderIds) || t('thumb.uncategorized');
}
function backupSourceExists(entry) {
if (!imagesDir || !entry.sourceName) return false;
return fs.existsSync(path.join(imagesDir, entry.itemId + '.info', entry.sourceName));
}
/** 日志里的渲染结果描述。 */
function describeThumbnail(rendered) {
const kb = Math.max(1, Math.round(rendered.buffer.length / 1024));
if (rendered.mode === 'animated') {
return t('thumb.thumbnailDetailsAnimated', {
size: rendered.detail + '*' + rendered.detail,
fps: rendered.fps,
quality: rendered.quality,
kb,
target: rendered.targetKilobytes,
frames: rendered.frames,
animation: rendered.animation,
strategy: t(rendered.strategy === 'speed' ? 'thumb.bestSpeed' : 'thumb.bestSize'),
limit: rendered.fits === false ? t('thumb.limitNote') : '',
});
}
return t('thumb.thumbnailDetailsStatic', {
width: rendered.width,
height: rendered.height,
kb,
fallback: rendered.fallback ? t('thumb.fallbackNote', { text: rendered.fallback }) : '',
});
}
/**
* 记本轮(一次队列排空)的产出,顺手把进度推给弹窗。
* animated 只统计真正做出动态 WebP 的数量;name 用来在弹窗里显示最近完成的文件。
*/
function countRun(outcome, animated, name) {
if (!runStats) return;
// 弹窗的暂停/取消随时生效:万一用户在最后一个素材渲染期间点了取消,这里也能记上。
applyControl();
runStats[outcome] += 1;
if (animated) runStats.animated += 1;
runStats.done += 1;
runStats.name = name || '';
if (runShowsProgress) {
progress.update({ done: runStats.done, total: runStats.total, name: runStats.name });
}
}
function compoundMatchesApproval(plan, approved) {
return plan && !plan.blocked &&
plan.currentName === approved.currentName &&
plan.currentExt === approved.currentExt &&
plan.newName === approved.newName &&
plan.newExt === approved.newExt;
}
function showCompoundConfirmation(changes) {
compatListEl.textContent = '';
const checkboxes = [];
const updateConfirm = () => {
const count = checkboxes.filter((input) => input.checked).length;
compatConfirmEl.disabled = count === 0;
compatConfirmEl.textContent = t('thumb.compoundConfirmSelected', { count });
};
for (const change of changes) {
const plan = change.plan;
const row = document.createElement('label');
row.className = 'compat-row';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true;
input.dataset.itemId = itemIdOf(change.item.dirName);
input.addEventListener('change', updateConfirm);
checkboxes.push(input);
const content = document.createElement('div');
content.className = 'compat-row-content';
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = plan.sourceName;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = optionalFolderPathOf(change.item.folders);
const values = document.createElement('div');
values.className = 'compat-change';
const oldValue = document.createElement('code');
oldValue.textContent = 'name=' + JSON.stringify(plan.currentName) + '; ext=' + JSON.stringify(plan.currentExt);
const newValue = document.createElement('code');
newValue.textContent = 'name=' + JSON.stringify(plan.newName) + '; ext=' + JSON.stringify(plan.newExt);
values.append(
t('thumb.compoundOldValue') + ' ', oldValue,
' → ', t('thumb.compoundNewValue') + ' ', newValue,
);
content.append(file);
if (location.textContent) content.append(location);
content.append(values);
row.append(input, content);
compatListEl.appendChild(row);
}
updateConfirm();
compatDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 窗口显示失败时仍可处理后续批次 */ }
return new Promise((resolve) => {
const finish = (selected) => {
compatDialogEl.classList.add('hidden');
compatConfirmEl.removeEventListener('click', confirm);
compatSkipEl.removeEventListener('click', skip);
document.removeEventListener('keydown', keydown);
resolve(selected);
};
const confirm = () => finish(checkboxes
.filter((input) => input.checked)
.map((input) => input.dataset.itemId));
const skip = () => finish([]);
const keydown = (event) => {
if (event.key === 'Escape') skip();
};
compatConfirmEl.addEventListener('click', confirm);
compatSkipEl.addEventListener('click', skip);
document.addEventListener('keydown', keydown);
checkboxes[0].focus();
});
}
function fieldsText(name, ext) {
return 'name=' + JSON.stringify(name) + '; ext=' + JSON.stringify(ext);
}
function showRestoreSelection(rows) {
restoreListEl.textContent = '';
const checkboxes = [];
const statusKeys = {
ready: 'restoreStatusReady',
pluginApplied: 'restoreStatusReady',
alreadyOriginal: 'restoreStatusAlreadyOriginal',
changed: 'restoreStatusChanged',
missing: 'restoreStatusMissing',
error: 'restoreStatusError',
};
const updateApply = () => {
const count = checkboxes.filter((input) => !input.disabled && input.checked).length;
restoreApplyEl.disabled = count === 0;
restoreApplyEl.textContent = t('thumb.restoreSelected', { count });
};
for (const row of rows) {
const entry = row.entry;
const canRestore = row.status === 'ready' || row.status === 'alreadyOriginal';
const label = document.createElement('label');
label.className = 'restore-row' + (canRestore ? '' : ' disabled');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = canRestore;
input.disabled = !canRestore;
input.dataset.itemId = entry.itemId;
input.addEventListener('change', updateApply);
checkboxes.push(input);
const details = document.createElement('div');
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = entry.sourceName || entry.itemId;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = entry.folderPath || '';
const current = document.createElement('div');
current.className = 'restore-current';
current.textContent = t('thumb.restoreCurrent') + ' ' + fieldsText(
row.currentName === undefined ? '' : row.currentName,
row.currentExt === undefined ? '' : row.currentExt,
);
const target = document.createElement('div');
target.className = 'restore-target';
target.textContent = t('thumb.restoreTarget') + ' ' + fieldsText(entry.originalName, entry.originalExt);
const status = document.createElement('div');
status.className = 'restore-status';
if (row.status === 'ready' || row.status === 'pluginApplied') {
status.classList.add('applied');
status.textContent = t('thumb.restoreStatusReady', {
fields: fieldsText(row.currentName, row.currentExt),
});
} else {
status.textContent = t('thumb.' + statusKeys[row.status]);
}
details.append(file);
if (location.textContent) details.append(location);
details.append(current, target, status);
label.append(input, details);
restoreListEl.appendChild(label);
}
updateApply();
restoreDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 列表仍可通过已打开的面板操作 */ }
return new Promise((resolve) => {
const finish = (value) => {
restoreDialogEl.classList.add('hidden');
restoreCancelEl.removeEventListener('click', cancel);
restoreApplyEl.removeEventListener('click', apply);
document.removeEventListener('keydown', keydown);
resolve(value);
};
const cancel = () => finish(null);
const apply = () => finish(checkboxes
.filter((input) => !input.disabled && input.checked)
.map((input) => input.dataset.itemId));
const keydown = (event) => {
if (event.key === 'Escape') cancel();
};
restoreCancelEl.addEventListener('click', cancel);
restoreApplyEl.addEventListener('click', apply);
document.addEventListener('keydown', keydown);
const first = checkboxes.find((input) => !input.disabled);
(first || restoreCancelEl).focus();
});
}
/** 修改前统一检查已有缩略图、真实文件名和用户确认,避免任何静默元数据变更。 */
async function prepareCompoundCompatibility(items) {
const changes = [];
for (const item of items) {
try {
const eagleItem = await eagle.item.getById(itemIdOf(item.dirName));
if (!eagleItem || eagleItem.customThumbnail === true) continue;
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (!plan) continue;
const label = item.entryName + ' | ' + folderPathOf(item.folders);
if (plan.blocked) {
item.compoundBlocked = plan;
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
continue;
}
changes.push({ item, plan });
} catch (error) {
item.compoundBlocked = { error };
log(t('thumb.compoundCheckFailed', {
label: item.entryName,
error: (error && error.message) || error,
}), 'warn');
}
}
if (!changes.length) return;
if (!compatStore) {
for (const change of changes) change.item.compoundBlocked = { error: 'backup unavailable' };
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
const selectedIds = new Set(await showCompoundConfirmation(changes));
for (const change of changes) {
if (selectedIds.has(itemIdOf(change.item.dirName))) change.item.compoundApproval = change.plan;
else change.item.compoundDeclined = true;
}
}
async function rollbackCompoundChange(eagleItem, backup, label) {
if (!backup) return false;
try {
eagleItem.name = backup.originalName;
eagleItem.ext = backup.originalExt;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
compatStore.remove(backup.itemId);
log(t('thumb.compoundRolledBack', { label }), 'warn');
return true;
} catch (error) {
log(t('thumb.compoundRollbackFailed', {
label,
error: (error && error.message) || error,
}), 'error');
return false;
}
}
/** 一个素材的缩略图生成流程:查重 → 确认兼容变更 → 渲染 → 写临时文件 → 设置 → 清理。 */
async function generateOne(item) {
let tempPath = null;
let compatibilityAttempted = false;
let compatibilityBackup = null;
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
const label = item.entryName + ' | ' + folderPathOf(item.folders);
try {
const itemId = itemIdOf(item.dirName);
const eagleItem = await eagle.item.getById(itemId);
if (!eagleItem) {
stats.failed += 1;
countRun('failed', false, item.entryName);
log(t('thumb.missingItem', { label }), 'warn');
return;
}
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.existingThumbnail', { label }), 'dim');
return;
}
if (item.compoundDeclined || item.compoundBlocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSkipped', { label }), 'warn');
return;
}
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (plan && plan.blocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
return;
}
if (plan) {
const approved = item.compoundApproval;
if (!approved || !compoundMatchesApproval(plan, approved)) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundNeedsConfirmation', { label }), 'warn');
return;
}
if (!compatStore) throw new Error(t('thumb.compoundBackupUnavailable'));
compatibilityBackup = compatStore.remember({
itemId,
sourceName: plan.sourceName,
folderPath: folderPathOf(item.folders),
currentName: plan.currentName,
currentExt: plan.currentExt,
newName: plan.newName,
newExt: plan.newExt,
});
eagleItem.name = plan.newName;
eagleItem.ext = plan.newExt;
compatibilityAttempted = true;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: plan.newFileName }), 'dim');
}
const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
const animated = settings.mode !== 'static';
const rendered = await withTimeout(renderThumbnailAuto({
skeletonPath: item.skeletonPath,
atlasPath: item.atlasPath,
texturePaths: item.texturePaths,
runtimeDir,
isBinary: item.isBinary,
version,
size: 400,
mode: settings.mode,
strategy: settings.strategy,
onProgress: (current, total) => {
setStatus(t('thumb.statusRendering', { current, total, queued: queue.length }));
},
}), animated ? 300000 : 60000, t('thumb.renderAction', { label }));
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + (rendered.extension || '.png'));
fs.writeFileSync(tempPath, rendered.buffer);
const applied = await withTimeout(
eagleItem.setCustomThumbnail(tempPath),
20000,
t('thumb.setThumbnailAction', { label: item.dirName }),
);
if (applied !== true) throw new Error(t('thumb.thumbnailRejected'));
stats.generated += 1;
countRun('generated', rendered.mode === 'animated', item.entryName);
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
} catch (error) {
if (compatibilityAttempted) {
await rollbackCompoundChange(eagleItem, compatibilityBackup, label);
}
stats.failed += 1;
countRun('failed', false, item.entryName);
log(t('thumb.generateFailed', { label, error: (error && error.message) || error }), 'fail');
} finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
refreshStats();
}
}
/** 读弹窗写下的控制指令:{ action: 'pause' | 'resume' | 'cancel', at: 时间戳 }。 */
function readControl() {
try {
const data = JSON.parse(fs.readFileSync(CONTROL_FILE, 'utf8'));
if (data && typeof data.action === 'string' && Number.isFinite(data.at)) return data;
} catch (error) { /* 没有文件或内容不完整 */ }
return null;
}
/** 执行弹窗按钮的意图:暂停、继续、取消(取消 = 丢掉剩下的队列,已生成的保留)。 */
function applyControl() {
const control = readControl();
if (!control || control.at === lastControlAt) return;
lastControlAt = control.at;
if (control.action === 'pause') {
paused = true;
setStatus(t('thumb.statusPaused'));
} else if (control.action === 'resume') {
paused = false;
} else if (control.action === 'cancel') {
paused = false;
const dropped = queue.length;
queue.length = 0;
log(t('thumb.cancelled', {
dropped,
done: runStats ? runStats.done : 0,
generated: runStats ? runStats.generated : 0,
failed: runStats ? runStats.failed : 0,
skipped: runStats ? runStats.skipped : 0,
}), 'warn');
}
}
/** 串行处理队列:一次只渲染一个,避免批量导入时把 Eagle 卡住。 */
async function processQueue() {
if (running) return;
running = true;
// 一次队列排空算一轮,从零开始记;排空后再决定要不要弹通知。
if (!runStats) {
runStats = {
startedAt: Date.now(),
total: queue.length,
done: 0,
name: '',
animated: 0,
generated: 0,
failed: 0,
skipped: 0,
};
}
paused = false;
// 记下开跑那一刻控制文件的时间戳:上一轮遗留的暂停/取消指令不能再生效。
const stale = readControl();
lastControlAt = stale ? stale.at : 0;
try {
while (queue.length) {
applyControl();
if (paused) {
await new Promise((resolve) => setTimeout(resolve, 300));
continue;
}
const item = queue.shift();
// 取消是在上面 applyControl 里生效的,队列可能刚好被清空。
if (!item) break;
setStatus(t('thumb.statusGenerating', { queued: queue.length }));
await generateOne(item);
}
setStatus(t('thumb.statusListening'));
} finally {
running = false;
paused = false;
// 收尾放进 finally:哪怕中途出异常,进度弹窗也必须收到"本轮结束",不能一直挂着。
const run = runStats;
const showsProgress = runShowsProgress;
runStats = null;
runShowsProgress = false;
if (run && showsProgress) finishRun(run);
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
}
}
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
function finishRun(run) {
const seconds = Math.max(1, Math.round((Date.now() - run.startedAt) / 1000));
const summary = t('thumb.runSummary', {
generated: run.generated,
failed: run.failed,
skipped: run.skipped,
seconds,
});
log(summary, 'ok');
progress.finish(summary);
}
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
let batchChain = Promise.resolve();
function handleBatch(batch) {
batchChain = batchChain
.then(() => handleBatchAsync(batch))
.catch((error) => log(t('thumb.batchFailed', { error: (error && error.message) || error }), 'error'));
}
/** 一批静默结束后触发:只分析本次新增的目录,避免每次全库扫描。 */
async function handleBatchAsync({ added, total, reason }) {
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
if (!added.length) return;
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
loadFolderPaths();
stats.batches += 1;
stats.added += added.length;
const reasonText = t(reason === 'fallback' ? 'thumb.reasonFallback' : 'thumb.reasonQuiet');
log(t('thumb.batchFinished', { added: added.length, total, reason: reasonText }));
// 本次新增 + 之前配套不全的,一起重新检查。
const candidates = [...new Set([...added, ...pending])];
if (pending.size) log(t('thumb.recheck', { count: pending.size }), 'dim');
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
setStatus(t('thumb.statusPairing'));
await new Promise((resolve) => setTimeout(resolve, 0));
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
setStatus(t('thumb.statusPairingProgress', { current, total: all }));
});
// 统计只算本次新增的:待观察的重查会重复计入,数字会虚高。
const fresh = items.filter((item) => added.includes(item.dirName));
stats.spine += fresh.length;
stats.complete += fresh.filter((item) => item.complete).length;
if (!items.length) log(t('thumb.noSpine'), 'dim');
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
const incomplete = items.filter((item) => !item.complete);
pending.clear();
// 「待观察」只在自动生成开着的时候记:关着的时候导入的素材属于"没赶上",不该在
// 开关重新打开后被回头处理——这个插件只对导入当时开着开关的素材生效。
if (settings.enabled) {
for (const item of incomplete) pending.add(item.dirName);
}
if (incomplete.length) {
log(t(settings.enabled ? 'thumb.incompletePending' : 'thumb.incompleteIgnored', { count: incomplete.length }),
settings.enabled ? 'warn' : 'dim');
}
for (const item of items) {
const name = (filePath) => (filePath ? path.basename(filePath) : t('thumb.none'));
log(t('thumb.itemSummary', {
name: item.entryName,
path: folderPathOf(item.folders),
state: t(item.complete ? 'thumb.complete' : 'thumb.incomplete'),
skeleton: name(item.skeletonPath),
atlas: name(item.atlasPath),
pages: item.pages,
missing: item.missingTextures,
}),
item.complete ? 'ok' : 'warn');
}
refreshStats();
const ready = items.filter((item) => item.complete);
if (!ready.length) return;
if (!settings.enabled) {
log(t('thumb.autoSkipped', { count: ready.length }), 'warn');
setStatus(t('thumb.statusAutoSkipped'));
return;
}
await prepareCompoundCompatibility(ready);
queue.push(...ready);
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
if (runStats) runStats.total += ready.length;
// 硬性条件:动态模式且这一次排队超过 10 个,才开进度弹窗,跑完在里面给汇总。
if (settings.mode !== 'static' && ready.length > PROGRESS_MIN_DYNAMIC) {
runShowsProgress = true;
progress.open();
progress.update({
done: runStats ? runStats.done : 0,
total: runStats ? runStats.total : queue.length,
});
}
log(t('thumb.queued', {
added: ready.length,
queued: queue.length,
state: running ? t('thumb.queuedRunning') : '',
}), 'dim');
processQueue();
}
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log, t });
async function resolveLibraryPath() {
const direct = eagle.library && eagle.library.path;
if (direct) return direct;
try {
const info = await eagle.library.info();
const fromApi = info && (info.path || info.libraryPath || info.dir);
if (fromApi) return fromApi;
log(t('thumb.libraryInfoNoPath', { info: JSON.stringify(info) }), 'dim');
} catch (error) {
log(t('thumb.libraryInfoFailed', { error: (error && error.message) || error }), 'error');
}
return null;
}
async function start() {
const resolved = await resolveLibraryPath();
if (!resolved) {
setStatus(t('thumb.statusNoLibrary'));
return;
}
libraryPath = resolved;
imagesDir = path.join(resolved, 'images');
folderPaths = null; // 换库了,文件夹树重新读
libraryEl.textContent = path.basename(resolved);
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
}
function startSafely() {
if (instanceCoordinator && !instanceCoordinator.isActive()) return;
start().catch((error) => {
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
});
}
async function initializeInstanceCoordinator(plugin) {
if (instanceCoordinator) {
instanceCoordinator.evaluate();
return;
}
await initializeCompatStore();
const stateFile = pluginUserDataPath
? path.join(pluginUserDataPath, 'SpineThumbnailGenerateServer', 'active-instance.json')
: path.join(os.tmpdir(), 'spine-thumbnail-active-instance.json');
const storage = instanceCoordinatorModule.createFileStorage(stateFile);
const id = String(window.windowID || (plugin && plugin.webContentsID) || Date.now());
instanceCoordinator = instanceCoordinatorModule.createCoordinator({
storage,
id,
isVisible: () => document.visibilityState === 'visible',
hide: () => eagle.window && eagle.window.hide && eagle.window.hide(),
onActiveChanged(active) {
if (active) {
startSafely();
return;
}
libraryWatcher.stop();
queue.length = 0;
try { if (eagle.window && eagle.window.hide) eagle.window.hide(); } catch (error) { /* already hidden */ }
},
intervalMs: 250,
});
document.addEventListener('visibilitychange', () => instanceCoordinator.handleVisibility());
window.addEventListener('beforeunload', () => instanceCoordinator.stop());
instanceCoordinator.start();
}
async function initializeCompatStore() {
if (compatStore) return true;
try {
pluginUserDataPath = await eagle.app.getPath('userData');
compatStore = compoundCompat.createStore(pluginUserDataPath);
return true;
} catch (error) {
log(t('thumb.compoundBackupUnavailable') + ': ' + ((error && error.message) || error), 'warn');
return false;
}
}
async function loadSettings() {
try {
settings = await settingsStore.read();
} catch (error) {
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
settings = { ...settingsStore.DEFAULTS };
}
toggleEl.checked = settings.enabled === true;
modeEl.value = settings.mode === 'static' ? 'static' : 'animated';
strategyEl.value = settings.strategy === 'speed' ? 'speed' : 'size';
}
toggleEl.addEventListener('change', async () => {
settings = { ...settings, enabled: toggleEl.checked };
try {
await settingsStore.write(settings);
} catch (error) {
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
}
if (settings.enabled) {
log(t('thumb.autoEnabled'), 'ok');
setStatus(t('thumb.statusListening'));
} else {
queue.length = 0;
log(t('thumb.autoDisabled'), 'dim');
setStatus(t('thumb.statusAutoOff'));
}
});
// 只清显示,不动统计和 queue:Eagle 的 devtools 控制台里仍然留着完整历史。
clearEl.addEventListener('click', () => {
logEl.textContent = '';
});
restoreNamesEl.addEventListener('click', async () => {
if (!compatStore) {
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
let rows;
try {
rows = await compoundCompat.inspectBackups(
compatStore,
(itemId) => eagle.item.getById(itemId),
backupSourceExists,
);
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
return;
}
if (!rows.length) {
log(t('thumb.restoreEmpty'), 'dim');
return;
}
const selected = await showRestoreSelection(rows);
if (!selected || !selected.length) return;
restoreNamesEl.disabled = true;
try {
const result = await compoundCompat.restoreBackups(
compatStore,
selected,
(itemId) => eagle.item.getById(itemId),
(item) => withTimeout(item.save(), 20000, t('thumb.restoreNames')),
);
log(t('thumb.restoreComplete', result), result.failed ? 'warn' : 'ok');
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
} finally {
restoreNamesEl.disabled = false;
}
});
modeEl.addEventListener('change', async () => {
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
try {
await settingsStore.write(settings);
} catch (error) {
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
}
log(t(settings.mode === 'static' ? 'thumb.modeStaticLog' : 'thumb.modeAnimatedLog'), 'dim');
});
strategyEl.addEventListener('change', async () => {
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
try {
await settingsStore.write(settings);
} catch (error) {
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
}
log(t(settings.strategy === 'speed' ? 'thumb.strategySpeedLog' : 'thumb.strategySizeLog'), 'dim');
});
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
filtersEl.addEventListener('click', (event) => {
const button = event.target.closest('button[data-filter]');
if (!button) return;
logEl.dataset.filter = button.dataset.filter;
for (const item of filtersEl.children) item.classList.toggle('active', item === button);
});
// 先确认脚本本身执行到了这里,再谈 Eagle 事件有没有来。
document.getElementById('status').textContent = t('thumb.statusLoaded', { version: VERSION });
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
if (typeof eagle === 'undefined') {
setStatus(t('thumb.statusEagleMissing'));
log(t('thumb.statusEagleMissing'), 'error');
} else {
eagle.onPluginCreate(async (plugin) => {
// 官方文档:onPluginCreate 用来初始化插件所需的模块,plugin.path 就是插件所在目录。
// app.getPath 这类 API 必须在这个事件之后才能调用,所以设置读取也放在这里。
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
if (i18n) i18n.setLocale(eagle.app && eagle.app.locale);
logVersion();
await initializeCompatStore();
try {
await loadSettings();
} catch (error) {
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
}
initializeInstanceCoordinator(plugin);
});
eagle.onLibraryChanged((changedPath) => {
log(t('thumb.switchingLibrary', { path: changedPath }));
startSafely();
});
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
setTimeout(() => {
if (libraryEl.textContent === '—') {
if (i18n) i18n.setLocale(i18n.requestedLocale());
logVersion();
log(t('thumb.noCreateEvent'), 'dim');
Promise.all([initializeCompatStore(), loadSettings().catch(() => {})])
.then(() => initializeInstanceCoordinator());
}
}, 800);
}
@@ -0,0 +1,143 @@
// 进度弹窗:用 Eagle 自己的 create-plugin-window 通道开一个正规插件窗口。
//
// 为什么不用 window.open:那样开出来的是 Electron 默认的裸窗口——没有 Node、没有插件 API、
// 还带着 Eagle 的应用菜单栏(Eagle 只对自己创建的插件窗口调 setMenuBarVisibility(false))。
// create-plugin-window 正是 Eagle 前端开插件窗口用的通道,manifest 由调用方传进来,
// 所以我们可以指定 url / 尺寸 / 置顶,拿到的就是"正规的插件窗口"。
//
// 两个窗口之间没有 opener 关系,进度用临时目录里的状态文件传:宿主写,弹窗轮询。
// 弹窗反过来写一个心跳文件,宿主靠它判断窗口是不是被用户关掉了。
(function () {
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const i18n = window.SpineThumbI18n;
const t = i18n ? i18n.t : ((key) => key);
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
// 高度只是初值,弹窗加载后会按内容自己调(eagle.window.setSize)。
const WINDOW_SIZE = { width: 380, height: 160 };
// 心跳超过这个时间就认为弹窗已经被关掉,下次需要时重新开。
const ALIVE_WINDOW_MS = 3000;
// 刚创建完窗口要给它一点加载时间,这段时间内不重复创建。
const CREATE_COOLDOWN_MS = 4000;
let state = { done: 0, total: 0, name: '', finished: false, text: '' };
let lastCreateAt = 0;
let ipcRenderer = null;
/** 插件目录的绝对路径。装了 eagleplugin:// 协议后 location.href 不是文件路径,只能靠 Eagle 给的。 */
function pluginPath() {
if (window.SpinePluginPath) return window.SpinePluginPath;
if (typeof eagle !== 'undefined' && eagle.plugin && eagle.plugin.path) return eagle.plugin.path;
return typeof __dirname !== 'undefined' ? __dirname : null;
}
function getIpcRenderer() {
if (!ipcRenderer) {
// Eagle 的 preload 把 require('electron') 换成了 {},另起一个 require 才能拿到真模块。
// createRequire 只接受绝对路径或 file:// URL,所以基准用插件目录拼出来,不能直接用 location.href。
const base = path.join(pluginPath() || process.cwd(), 'index.html');
ipcRenderer = require('module').createRequire(base)('electron').ipcRenderer;
}
return ipcRenderer;
}
/** 弹窗还开着吗?靠它写的心跳判断,窗口关掉后心跳自然就停了。 */
function popupAlive() {
try {
return Date.now() - Number(fs.readFileSync(ALIVE_FILE, 'utf8')) < ALIVE_WINDOW_MS;
} catch (error) {
return false;
}
}
function writeState() {
try {
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...state, updatedAt: Date.now() }), 'utf8');
} catch (error) { /* 写不进去也不影响生成,面板里一样有进度 */ }
}
/** 用 Eagle 的通道创建插件窗口;manifest 覆盖成进度页面的配置。 */
function createPopup() {
const plugin = (typeof eagle !== 'undefined' && eagle.plugin) || {};
const dir = pluginPath();
let manifest = plugin.manifest;
if (!manifest && dir) {
// 正常情况下 eagle.plugin 里就带 manifest;万一还没注入就自己从插件目录读一份。
manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
}
if (!manifest) return Promise.reject(new Error(t('thumb.modulesMissing', { modules: 'manifest' })));
return getIpcRenderer().invoke('create-plugin-window', {
plugin: {
...plugin,
path: dir,
manifest: {
...manifest,
// 这个窗口的标题栏文字(只影响这扇窗,插件本身的名字不变)。
name: t('progress.title'),
main: {
...manifest.main,
// 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。
serviceMode: false,
// Eagle 会把 url 交给 pathToFileURL,查询参数必须由 Eagle 追加,否则 ? 会被编码成文件名。
url: 'progress.html',
width: WINDOW_SIZE.width,
height: WINDOW_SIZE.height,
show: true,
// 挂在 Eagle 主窗口上:子窗口天然压在父窗口之上,而且跟着 Eagle 一起显示/隐藏。
// 这里显式写 false,免得主 manifest 哪天又开了 alwaysOnTop 被 ...manifest.main 带进来,
// 那会让弹窗盖住所有程序,而不是只待在 Eagle 上面。
alwaysOnTop: false,
childWindow: true,
// 保留系统标题栏:最小化/关闭按钮都在上面(manifest.name 决定标题文字)。
frame: true,
resizable: false,
minimizable: true,
maximizable: false,
fullscreenable: false,
},
},
},
args: {},
});
}
/** 需要时开窗(或复用已开着的);被环境挡住时返回 false,调用方照常继续跑。 */
function open() {
try {
if (popupAlive()) return true;
if (Date.now() - lastCreateAt < CREATE_COOLDOWN_MS) return false;
lastCreateAt = Date.now();
writeState();
createPopup().catch(() => { /* 开不出来就只在面板里看进度 */ });
return true;
} catch (error) {
// 弹窗只是附加显示,任何问题都不能影响缩略图生成本身。
return false;
}
}
function update(next) {
try {
state = { ...state, ...next, finished: false };
writeState();
} catch (error) { /* 同上:弹窗不能影响生成流程 */ }
}
function finish(text) {
try {
state = { ...state, finished: true, text };
writeState();
} catch (error) { /* ignore */ }
}
const api = { open, update, finish };
if (typeof window !== 'undefined') window.SpineThumbProgress = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
+724
View File
@@ -0,0 +1,724 @@
// 在 service 插件页面里渲染缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出图片。
// 两条产物,互不影响,由插件面板选择:
// 静帧(setup pose)—— 原来的那条路,导出 PNG,一直保留;
// 动态(animated WebP)—— 参数与主插件默认值一致:画布 200×200、分辨率优先档位表、
// 目标体积由动画时长决定(每秒 25 KB,夹在 25–100 KB),取骨骼里的第一个动画。
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const { pathToFileURL } = require('url');
const t = (key, vars) => window.SpineThumbI18n ? window.SpineThumbI18n.t(key, vars) : key;
const runtimeCache = new Map();
// 长动画不截断时间轴,只降低采样密度:18 fps 采样,最多 240 帧。
const SOURCE_SAMPLE_FPS = 18;
const MAX_SOURCE_FRAMES = 240;
// 时长小于这个值的动画按静帧处理。
const STATIC_EPSILON = 1e-6;
// 逐帧编码时轮转使用的离屏画布数量:成批提交比逐帧等待快得多。
const FRAME_POOL_SIZE = 4;
// 生成策略:
// 'size' = 十档全部生成,取实际体积最小的那一档(最省体积,也就是主插件画廊默认打勾的);
// 'speed' = 只用粗估挑出体积最小的两档真正生成(最快,其余开销跟着一起砍)。
const DEFAULT_STRATEGY = 'size';
const SPEED_PROBE_SIZE = 256;
const SPEED_PROBE_POSES = 12;
const SPEED_ESTIMATE_POINTS = 8;
const SPEED_CANDIDATES = 2;
/**
* 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
*
* 不能在主页面上用 <script> 标签:插件页面存在 Node 的 module 对象,3.4–3.7 的 UMD
* 会因此走 CommonJS 分支,把导出丢进 module.exports 而不写 window.spine——结果
* window.spine 永远停在第一个加载的版本上,后续每个版本都拿到同一个对象,于是出现
* 「SkeletonBinary 只有 getter」或者「用 4.0 运行时去读 3.6 数据导致 worldX 未定义」。
* iframe 里没有 Node 注入,UMD 会走浏览器分支,各版本天然隔离。
*/
function loadRuntime(runtimeDir) {
if (runtimeCache.has(runtimeDir)) return runtimeCache.get(runtimeDir);
const job = new Promise((resolve, reject) => {
const frame = document.createElement('iframe');
frame.style.display = 'none';
frame.onload = () => {
try {
const win = frame.contentWindow;
const script = win.document.createElement('script');
script.src = new URL('lib/' + runtimeDir + '/spine-canvas.js', location.href).href;
script.onload = () => {
try {
const spine = win.spine;
if (!spine) throw new Error('运行时没有导出 spine:' + runtimeDir);
if (window.SpineLegacyBinary) window.SpineLegacyBinary.install(spine, runtimeDir);
resolve(spine);
} catch (error) {
reject(error);
}
};
script.onerror = () => reject(new Error('加载运行时失败:' + runtimeDir));
win.document.head.appendChild(script);
} catch (error) {
reject(error);
}
};
frame.onerror = () => reject(new Error('创建运行时隔离环境失败:' + runtimeDir));
document.body.appendChild(frame);
});
runtimeCache.set(runtimeDir, job);
return job;
}
/** 本地绝对路径 → file:// URL。交给 Node 的 pathToFileURL,Windows / macOS 都正确。 */
function fileUrl(filePath) {
return pathToFileURL(filePath).href;
}
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 Promise 堵死。 */
function withTimeout(promise, timeoutMs, label) {
let timer = null;
return Promise.race([
promise.finally(() => { if (timer) clearTimeout(timer); }),
new Promise((resolve, reject) => {
timer = setTimeout(() => reject(new Error((label || '操作') + ' 超时(' + timeoutMs + 'ms)')), timeoutMs);
}),
]);
}
function loadImage(filePath) {
return withTimeout(new Promise((resolve, reject) => {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => reject(new Error('读取贴图失败:' + filePath));
image.src = fileUrl(filePath);
}), 10000, '读取贴图 ' + path.basename(filePath));
}
/** 按 atlas 里引用的页面名加载贴图,构造 TextureAtlas。 */
async function buildTextureAtlas(spine, atlasText, texturePaths) {
const images = new Map();
await Promise.all(Object.keys(texturePaths).map(async (pageName) => {
const filePath = texturePaths[pageName];
if (!filePath) return;
images.set(pageName, await loadImage(filePath));
}));
const makeTexture = (name) => {
const image = images.get(name);
if (!image) throw new Error('atlas 引用的贴图没找到:' + name);
if (spine.CanvasTexture) return new spine.CanvasTexture(image);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(image);
return new spine.Texture(image);
};
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
const atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const texture = makeTexture(page.name);
if (page.setTexture) page.setTexture(texture);
else page.texture = texture;
}
return atlas;
}
return new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buffer = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buffer));
}
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8'));
}
function updateWorld(spine, skeleton, delta = 0, resetPhysics = false) {
if (spine.Physics && spine.Physics.update !== undefined) {
if (delta > 0 && typeof skeleton.update === 'function') skeleton.update(delta);
const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update;
skeleton.updateWorldTransform(mode);
}
else skeleton.updateWorldTransform();
}
/** 载入运行时、atlas 与骨骼数据。静态和动态两条路共用。 */
async function loadSkeleton(options) {
const spine = await loadRuntime(options.runtimeDir);
const atlasText = fs.readFileSync(options.atlasPath, 'utf8');
const atlas = await buildTextureAtlas(spine, atlasText, options.texturePaths);
const data = readSkeletonData(spine, options.skeletonPath, atlas, !!options.isBinary);
if (!data || !data.bones || !data.bones.length) throw new Error('骨骼数据为空');
return {
spine,
data,
atlasText,
runtimeDir: options.runtimeDir,
premultipliedAlpha: window.SpineUtils.parseAtlasPma(atlasText) === true,
};
}
/** 骨骼当前姿态的包围盒(世界坐标)。 */
function boundsOf(spine, skeleton) {
try {
if (typeof skeleton.getBoundsRect === 'function') {
const rect = skeleton.getBoundsRect();
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
}
if (typeof skeleton.getBounds === 'function') {
const offset = new spine.Vector2();
const dimensions = new spine.Vector2();
skeleton.getBounds(offset, dimensions, []);
if (dimensions.x > 0 || dimensions.y > 0) {
return { x: offset.x, y: offset.y, width: dimensions.x, height: dimensions.y };
}
}
} catch (error) { /* ignore */ }
return null;
}
/**
* 渲染 setup pose 的缩略图。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
* 返回 { blob, width, height, version }
*/
async function renderThumbnail(options) {
const utils = window.SpineUtils;
const size = options.size || 400;
const loaded = await loadSkeleton(options);
const { spine, data } = loaded;
const skeleton = new spine.Skeleton(data);
utils.applySkin(skeleton, utils.preferredSkin(data));
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
updateWorld(spine, skeleton, 0, true);
const bounds = boundsOf(spine, skeleton);
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
renderer.draw(skeleton);
} finally {
if (renderer && renderer.dispose) renderer.dispose();
ctx.restore();
}
}
if (options.drawBadge !== false) await drawBadge(ctx, size);
if (options.drawVersion !== false) {
window.SpineBadge.drawVersion(ctx, size, size, data.version || options.version);
}
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
if (!blob) throw new Error('导出缩略图失败');
return { blob, width: size, height: size, version: data.version || options.version || '' };
}
// ---------- 动态缩略图(animated WebP) ----------
// 参数与主插件的默认值一致:画布 200×200、分辨率优先档位表、目标体积 = 动画时长 × 25 KB
// (夹在 25–100 KB)。区别是没有候选画廊,所以直接采用主插件画廊里默认打勾的那一档
// (十档里实际体积最小的那个),目标体积只用来判断有没有达标并在日志里提示。
function collectKeyframeTimes(animation) {
if (!animation || !animation.timelines) return [];
const times = new Set();
for (const timeline of animation.timelines) {
const frames = timeline && timeline.frames;
if (!frames || !frames.length) continue;
try {
if (Array.isArray(frames[0])) {
for (const frame of frames) {
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
}
continue;
}
const frameCount = typeof timeline.getFrameCount === 'function' ? timeline.getFrameCount() : 0;
const entries = typeof timeline.getFrameEntries === 'function'
? timeline.getFrameEntries()
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
if (frameCount > 0) {
for (let index = 0; index < frameCount; index++) {
const value = frames[index * entries];
if (Number.isFinite(value)) times.add(value);
}
}
} catch (error) { /* 读数失败就跳过这条时间轴 */ }
}
return [...times];
}
function animationDurationOf(animation) {
const declared = animation && Number(animation.duration);
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
const times = collectKeyframeTimes(animation);
return times.length ? Math.max.apply(null, times) : 0;
}
/** 取骨骼里的第一个可用动画(没有动画或时长为 0 就返回 null,由上层回退静帧)。 */
function firstAnimation(data) {
for (const animation of (data && data.animations) || []) {
if (animation && animationDurationOf(animation) > STATIC_EPSILON) return animation;
}
return null;
}
function applyAnimationPose(spine, targetSkeleton, targetState, animation, time, previousTime) {
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
const entry = targetState.setAnimation(0, animation.name, false);
if (entry) entry.loop = false;
targetState.update(Math.max(0, time));
targetState.apply(targetSkeleton);
updateWorld(spine, targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined);
}
function mergeBounds(total, next) {
if (!next || next.width <= 0 || next.height <= 0) return total;
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
const left = Math.min(total.x, next.x);
const bottom = Math.min(total.y, next.y);
const right = Math.max(total.x + total.width, next.x + next.width);
const top = Math.max(total.y + total.height, next.y + next.height);
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
/**
* 采样若干姿态,用像素 alpha 求真实可见范围,避免取景跟着骨骼包围盒留大片空白。
* 最快策略下采样点变少、探测画布变小,取景精度略降但省掉一大截渲染 + 回读。
*/
async function measureVisibleBounds(loaded, targetSkeleton, fallbackBounds, poseCount, applyPose, options) {
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
const probeOptions = options || {};
const visibleBounds = window.SpineVisibleBounds;
const size = probeOptions.probeSize || 512;
const maxPoses = probeOptions.maxPoses || poseCount;
const step = Math.max(1, Math.ceil(poseCount / maxPoses));
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
let measured = null;
let probe = null;
try {
probe = window.SpineCanvasClipping.createRenderer(loaded.spine, context, loaded.runtimeDir);
probe.premultipliedAlpha = loaded.premultipliedAlpha;
let previousIndex = -1;
for (let index = 0; index < poseCount; index += step) {
applyPose(index, previousIndex);
previousIndex = index;
context.setTransform(1, 0, 0, 1, 0, 0);
context.clearRect(0, 0, size, size);
context.save();
context.translate(size / 2, size / 2);
context.scale(scale, -scale);
context.translate(-centerX, -centerY);
probe.draw(targetSkeleton);
context.restore();
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (probe && probe.dispose) probe.dispose();
}
return measured;
}
function profileFrameCount(sourceFrameCount, range, fps) {
return Math.max(2, Math.min(sourceFrameCount, Math.ceil(range * fps)));
}
function resizeFramePixels(data, sourceSize, targetSize) {
if (sourceSize === targetSize) return data;
const output = new Uint8Array(targetSize * targetSize * 4);
for (let y = 0; y < targetSize; y++) {
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
for (let x = 0; x < targetSize; x++) {
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
const source = (sourceY * sourceSize + sourceX) * 4;
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
}
}
return output;
}
// 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
// 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
function scaledFramePixels(data, size, detailSize) {
return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
}
let badgeImagePromise = null;
function badgeImage() {
if (!badgeImagePromise) {
badgeImagePromise = new Promise((resolve) => {
try {
const image = new Image();
image.onload = () => resolve(image);
image.onerror = () => resolve(null);
image.src = window.SpineBadge.dataUrl();
} catch (error) {
resolve(null);
}
});
}
return badgeImagePromise;
}
/** 一次把所有采样帧画到 size×size 上(含角标),后续每档只从这些像素重新编码。 */
async function renderAnimatedSourceFrames(loaded, animation, options) {
const utils = window.SpineUtils;
const spine = loaded.spine;
const size = options.canvasSize;
const range = options.range;
const frameCount = Math.max(2, Math.min(MAX_SOURCE_FRAMES, Math.ceil(range * SOURCE_SAMPLE_FPS)));
const frameDuration = range / frameCount;
const sampleTimes = Array.from({ length: frameCount }, (_, index) => index * frameDuration);
const skeleton = new spine.Skeleton(loaded.data);
utils.applySkin(skeleton, utils.preferredSkin(loaded.data));
const stateData = new spine.AnimationStateData(loaded.data);
stateData.defaultMix = 0;
const state = new spine.AnimationState(stateData);
let bounds = null;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
bounds = mergeBounds(bounds, boundsOf(spine, skeleton));
}
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error('骨骼数据没有可绘制的范围');
bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index, previousIndex) => {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined);
}, options.probe) || bounds;
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
const centerX = bounds.x + bounds.width / 2;
const centerY = bounds.y + bounds.height / 2;
const badge = await badgeImage();
const version = loaded.data.version || options.version || '';
const frames = [];
let renderer = null;
try {
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, loaded.runtimeDir);
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
for (let index = 0; index < sampleTimes.length; index++) {
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, size, size);
ctx.save();
ctx.translate(size / 2, size / 2);
ctx.scale(scale, -scale);
ctx.translate(-centerX, -centerY);
renderer.draw(skeleton);
ctx.restore();
if (badge) {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(badge, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
}
window.SpineBadge.drawVersion(ctx, size, size, version);
// 动态 WebP 用全帧替换,透明区域直接压成背景色,省掉 alpha 通道的体积。
const rgba = new Uint8Array(ctx.getImageData(0, 0, size, size).data);
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
frames.push(rgba);
if (options.onProgress) options.onProgress(index + 1, frameCount);
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
}
} finally {
if (renderer && renderer.dispose) renderer.dispose();
}
return { frames, size, range };
}
/** 轮转 4 张离屏画布,把 RGBA 像素编成 WebP 静帧。 */
function createFrameEncoder(size) {
const pool = Array.from({ length: FRAME_POOL_SIZE }, () => {
const canvas = document.createElement('canvas');
canvas.width = size;
canvas.height = size;
const context = canvas.getContext('2d');
return { canvas, context, image: context.createImageData(size, size) };
});
return (slot, pixels, quality) => new Promise((resolve, reject) => {
const unit = pool[slot % pool.length];
unit.image.data.set(pixels);
unit.context.putImageData(unit.image, 0, 0);
unit.canvas.toBlob((blob) => {
if (!blob || blob.type !== 'image/webp') {
reject(new Error('当前环境不支持 WebP 编码'));
return;
}
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
}, 'image/webp', quality);
});
}
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
async function estimateAnimatedBytes(profile, sourceFrames, size, range, encodeFrame) {
const count = profileFrameCount(sourceFrames.length, range, profile.fps);
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
let total = 0;
for (let slot = 0; slot < indexes.length; slot++) {
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
total += (await encodeFrame(slot, pixels, profile.quality)).length;
}
return (total / indexes.length) * count;
}
// 沿整条搜索路径粗估体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」取候选。
// 返回顺序就是从预计体积最小到最大,最快策略只需要前面几档。
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, targetBytes, count, encodeFrame, samplePoints) {
const stride = Math.max(1, Math.ceil(searchPath.length / (samplePoints || 24)));
const samples = [];
for (let index = 0; index < searchPath.length; index += stride) {
samples.push({
index,
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, encodeFrame),
});
}
const lastIndex = searchPath.length - 1;
if (samples[samples.length - 1].index !== lastIndex) {
samples.push({
index: lastIndex,
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, encodeFrame),
});
}
// 先按体积从小到大排:配置允许分段,排序后再取点才能保证候选体积递增。
samples.sort((left, right) => left.bytes - right.bytes);
// 体积 → 参数:在「体积相邻」的两个采样点之间插值,候选因此可以落在配置表之外。
const profileForBytes = (wantedBytes) => {
let upper = samples.length - 1;
for (let index = 0; index < samples.length; index++) {
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
}
const lower = Math.max(0, upper - 1);
const before = searchPath[samples[lower].index];
const after = searchPath[samples[upper].index];
const gap = samples[upper].bytes - samples[lower].bytes;
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
const mix = (start, end) => start + (end - start) * ratio;
return {
size: Math.max(1, Math.round(mix(before.size, after.size))),
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
};
};
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
const span = Math.max(1, count - 1);
const picked = [];
for (let slot = 0; slot < count; slot++) {
picked.push(profileForBytes(Math.exp(low + (high - low) * slot / span)));
}
return picked;
}
/** 按一档参数把采样帧全部编码并封装成动态 WebP。 */
async function encodeAnimatedProfile(profile, source, encodeFrame) {
const count = profileFrameCount(source.frames.length, source.range, profile.fps);
const duration = Math.max(1, Math.round(source.range * 1000 / count));
const frames = [];
// 帧编码是异步的,一批 4 帧(正好对上画布池)一起提交,比逐帧 await 快得多。
for (let start = 0; start < count; start += FRAME_POOL_SIZE) {
const batch = [];
for (let index = start; index < Math.min(count, start + FRAME_POOL_SIZE); index++) {
const sourceIndex = Math.min(source.frames.length - 1, Math.floor(index * source.frames.length / count));
batch.push({
slot: index % FRAME_POOL_SIZE,
pixels: scaledFramePixels(source.frames[sourceIndex], source.size, profile.size),
});
}
const buffers = await Promise.all(batch.map(({ slot, pixels }) => encodeFrame(slot, pixels, profile.quality)));
for (const webp of buffers) frames.push({ webp, duration });
await new Promise((resolve) => setTimeout(resolve, 0));
}
return {
buffer: window.SpineAnimatedWebpEncoder.encode({
width: source.size,
height: source.size,
frames,
plays: 0,
}),
frames: count,
detail: profile.size,
fps: profile.fps,
quality: profile.quality,
};
}
/** 所有候选里体积最小的那一档(主插件画廊默认打勾选中的就是它)。 */
function smallestCandidate(candidates) {
return candidates.reduce(
(best, candidate) => (!best || candidate.buffer.length < best.buffer.length ? candidate : best),
null,
);
}
async function pickAnimatedThumbnail(source, options) {
const config = window.SpineAnimatedProfiles;
const fast = options.strategy === 'speed';
const encodeFrame = createFrameEncoder(source.size);
const targetBytes = options.maxKilobytes * 1024;
const searchPath = config.expandAnimatedPath(options.priority, options.canvasSize);
const candidateCount = config.animatedProfiles(options.priority, options.canvasSize).length;
const profiles = await pickAnimatedCandidates(
searchPath, source.frames, source.size, source.range, targetBytes, candidateCount, encodeFrame,
fast ? SPEED_ESTIMATE_POINTS : undefined,
);
// 用户看不到候选画廊,就完全照主插件的默认选择来,取实际体积最小的那一档。
// 最优体积:十档全生成再比;最优速度:只生成粗估最小的那几档(粗估已经按体积排过序)。
const wanted = fast ? profiles.slice(0, SPEED_CANDIDATES) : profiles;
const candidates = [];
for (const profile of wanted) {
candidates.push(await encodeAnimatedProfile(profile, source, encodeFrame));
}
const best = smallestCandidate(candidates);
return {
...best,
// 最小档仍然超预算时告诉用户,让他考虑改配置里的目标体积。
fits: best.buffer.length <= targetBytes,
};
}
/**
* 生成动态缩略图。骨骼里没有可用动画时返回 null,交给调用方回退到 setup pose 静帧。
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, version,
* canvasSize, priority, maxKilobytes, onProgress }
*/
async function renderAnimatedThumbnail(options) {
const config = window.SpineAnimatedProfiles;
const loaded = await loadSkeleton(options);
const animation = firstAnimation(loaded.data);
if (!animation) return null;
const canvasSize = options.canvasSize || config.DEFAULT_OUTPUT_SIZE;
const range = animationDurationOf(animation);
const maxKilobytes = options.maxKilobytes || config.suggestSize(range);
const fast = (options.strategy || DEFAULT_STRATEGY) === 'speed';
const source = await renderAnimatedSourceFrames(loaded, animation, {
canvasSize,
range,
version: options.version,
onProgress: options.onProgress,
// 最快策略用小画布、少采样点做可见范围探测。
probe: fast ? { probeSize: SPEED_PROBE_SIZE, maxPoses: SPEED_PROBE_POSES } : null,
});
const picked = await pickAnimatedThumbnail(source, {
priority: options.priority || config.DEFAULT_PRIORITY,
maxKilobytes,
canvasSize,
strategy: options.strategy || DEFAULT_STRATEGY,
});
return {
buffer: picked.buffer,
extension: '.webp',
width: source.size,
height: source.size,
mode: 'animated',
animation: animation.name,
version: loaded.data.version || options.version || '',
frames: picked.frames,
detail: picked.detail,
fps: picked.fps,
quality: picked.quality,
duration: range,
targetKilobytes: maxKilobytes,
strategy: options.strategy || DEFAULT_STRATEGY,
fits: picked.fits,
};
}
/** 静帧产物统一成和动态一样的返回结构,调用方只认 buffer + extension。 */
async function renderStillBuffer(options) {
const still = await renderThumbnail(options);
return {
buffer: Buffer.from(await still.blob.arrayBuffer()),
extension: '.png',
width: still.width,
height: still.height,
mode: 'still',
version: still.version,
};
}
/**
* 入口:mode === 'static' 一律出 setup pose 静帧;否则有动画就出动态 WebP,
* 没有动画回退静帧(reason 里说明原因,日志会带出来)。
*/
async function renderThumbnailAuto(options) {
if (options.mode === 'static') return renderStillBuffer(options);
try {
const animated = await renderAnimatedThumbnail(options);
if (animated) return animated;
return { ...(await renderStillBuffer(options)), fallback: t('thumb.fallbackNoAnimation') };
} catch (error) {
const still = await renderStillBuffer(options);
return { ...still, fallback: t('thumb.fallbackAnimatedFailed', { error: (error && error.message) || error }) };
}
}
// badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
function drawBadge(ctx, size) {
return new Promise((resolve) => {
const image = new Image();
image.onload = () => {
const badgeSize = Math.max(44, Math.round(size * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
try {
ctx.drawImage(image, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
} catch (error) { /* 缺少 badge 不影响主体 */ }
resolve();
};
image.onerror = () => resolve();
image.src = window.SpineBadge.dataUrl();
});
}
const api = {
renderThumbnail,
renderThumbnailAuto,
renderAnimatedThumbnail,
loadRuntime,
loadSkeleton,
firstAnimation,
animationDurationOf,
profileFrameCount,
resizeFramePixels,
scaledFramePixels,
pickAnimatedCandidates,
smallestCandidate,
withTimeout,
};
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,43 @@
// 插件设置持久化。默认关闭自动生成——Eagle 审核标准要求「批量或不可逆的变更
// 必须事先明确说明并获得确认」,所以必须由用户主动打开这个开关。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
// mode: 'animated' = 有动画就出动态 WebP(没有动画回退静帧),'static' = 一律 setup pose 静帧。
// strategy: 'size' = 十档全生成取体积最小的(慢、最省体积),'speed' = 只生成粗估最小的那几档。
const DEFAULTS = { enabled: false, mode: 'animated', strategy: 'size' };
/** 存放在 Eagle 的 userData 目录,不写进素材库,卸载时可直接删掉。 */
async function resolveFile() {
const userData = await eagle.app.getPath('userData');
return path.join(userData, 'SpineThumbnailGenerateServer', 'settings.json');
}
async function read() {
const file = await resolveFile();
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return { ...DEFAULTS, ...data };
} catch (error) {
if (error && error.code === 'ENOENT') return { ...DEFAULTS };
throw error;
}
}
async function write(next) {
const file = await resolveFile();
fs.mkdirSync(path.dirname(file), { recursive: true });
const body = JSON.stringify({ ...DEFAULTS, ...next }, null, 2) + '\n';
const temp = file + '.' + process.pid + '.tmp';
fs.writeFileSync(temp, body, 'utf8');
fs.renameSync(temp, file);
}
const api = { read, write, resolveFile, DEFAULTS };
if (typeof window !== 'undefined') window.SpineThumbSettings = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,140 @@
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载后挂到 window。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const SKELETON_PATTERN = /\.(skel|skel\.bytes|json)$/i;
const ATLAS_PATTERN = /\.(atlas|atlas\.txt)$/i;
const TEXTURE_PATTERN = /\.(png|jpe?g|webp)$/i;
const THUMBNAIL_PATTERN = /_thumbnail\.(png|webp)$/i;
/**
* 分析一个 Eagle item 目录。
*
* Eagle 会把导入的每个文件拆成独立的 <id>.info 目录,所以一套 Spine 资产的骨架、
* atlas、贴图通常分散在多个目录里,必须跨目录配对。配对逻辑不重写,直接复用主插件的
* discoverAssets(后缀表、同名筛选、路径校验都在里面)。
*/
function analyzeItemDir(itemDir, dirName, utils, pairCache, searchRoots, refreshIndex) {
let files;
try {
files = fs.readdirSync(itemDir);
} catch (error) {
return null;
}
// metadata.json 是 Eagle 的 item 元数据,不是骨骼文件,必须排除。
const skeletons = files.filter((name) => SKELETON_PATTERN.test(name) && name.toLowerCase() !== 'metadata.json');
const atlases = files.filter((name) => ATLAS_PATTERN.test(name));
const textures = files.filter((name) => TEXTURE_PATTERN.test(name) && !THUMBNAIL_PATTERN.test(name));
// .skel / .skel.bytes 的后缀够特定,直接信任;.json 必须确认内容真的是 Spine 骨骼,
// 否则任何普通 JSON(甚至改了后缀的其它数据)都会被误当成骨骼文件。
const usableSkeletons = skeletons.filter((name) => {
if (!/\.json$/i.test(name)) return true;
try {
// classifySkeletonJson 的肯定标识是 'spine',普通 JSON 是 'other',仍在写入是 'pending'。
return utils.classifySkeletonJson(path.join(itemDir, name)) === 'spine';
} catch (error) {
return false;
}
});
// 一个目录里既没有可用骨架也没有 atlas,说明它不是 Spine 资产的入口。
if (!usableSkeletons.length && !atlases.length) return null;
const entryName = usableSkeletons[0] || atlases[0];
// Eagle 删除素材只是把 item 的 metadata.json 标记成 isDeleted,目录和文件都留在
// 磁盘上。这种目录仍然会出现在「新增目录」里,必须在配对前挡掉,否则会给回收站
// 里的素材渲染一次缩略图(白做,还可能算成失败)。
if (utils.isItemDeleted(path.join(itemDir, entryName))) return null;
let assets = null;
// 一套资产的多个入口(atlas / skel / json)配对结果完全一样,缓存下来避免重复扫描。
// 缓存键必须带上 Eagle 文件夹:同名素材可能分属不同文件夹(同一示例的不同版本就是这样),
// 只用 stem 会把文件夹 A 的配对结果复用给文件夹 B。
let folders = [];
try {
folders = utils.foldersOfItem(path.join(itemDir, entryName));
} catch (error) { /* 读不到就按未分类处理 */ }
const folderKey = folders.slice().sort().join(',') || 'root';
const cacheKey = utils.normalizeStem(entryName) + '|' + folderKey;
if (pairCache && pairCache.has(cacheKey)) {
assets = pairCache.get(cacheKey);
} else {
try {
// searchRoots 限定在本次新增的目录里。不传的话 Utils 会把搜索范围扩到整个库,
// 几百上千个素材时一次配对就要一秒以上——Eagle 的「文件夹」只是逻辑分组,
// 磁盘上所有素材都平铺在 images/ 下,所以「扫同级」等于「扫全库」。
assets = utils.discoverAssets(path.join(itemDir, entryName), {
strictStem: true,
searchRoots,
// 文件索引有 1.5 秒缓存,刚导入的目录可能还没进索引,
// 所以每批的第一个目录强制刷新,其余复用。
refreshIndex,
});
} catch (error) {
assets = null;
}
if (pairCache) pairCache.set(cacheKey, assets);
}
// 贴图是否齐全要看 atlas 里声明了哪些页面,再看这些页面能不能在库里找到。
let pages = 0;
let missingTextures = 0;
if (assets && assets.atlasPath) {
try {
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
const pageNames = utils.parseAtlasPages(atlasText);
pages = pageNames.length;
missingTextures = pageNames.filter((name) => !assets.texturePaths[name]).length;
} catch (error) { /* atlas 读不了就当作不完整 */ }
}
// 骨架路径必须是骨骼文件后缀,防止配对结果指到贴图或其它文件上。
const skeletonPath = (assets && assets.skeletonPath) || null;
const skeletonOk = !!skeletonPath && /\.(skel|skel\.bytes|json)$/i.test(skeletonPath);
return {
dirName,
// 日志里要显示 Eagle 界面上的文件名和文件夹路径,而不是 MU9RVSxxxx.info 这种 id。
entryName,
folders,
skeletonPath,
atlasPath: (assets && assets.atlasPath) || null,
texturePaths: (assets && assets.texturePaths) || {},
// 必须按「实际要读的那个骨架文件」判断是否二进制:配对的骨架可能是同名的 .skel,
// 而当前目录里放的是 .json,用目录里的文件名判断就会拿 JSON 解析器去读二进制。
isBinary: /\.(skel|skel\.bytes)$/i.test(skeletonPath || entryName),
hasSkeletonInDir: usableSkeletons.length > 0,
hasAtlasInDir: atlases.length > 0,
hasTextureInDir: textures.length > 0,
pages,
missingTextures,
complete: !!(skeletonOk && assets.atlasPath && pages > 0 && missingTextures === 0),
};
}
/**
* 扫描指定的 item 目录,返回其中的 Spine 资产(其他素材会被过滤掉)。
* 每处理完一个目录就让出一次主线程:discoverAssets 是同步扫全库的重活,
* 连续跑几十个会把界面彻底卡住,日志写了也刷不出来。
*/
async function scanSpineItems(imagesDir, dirNames, utils, onProgress) {
const found = [];
// 本次新增的目录就是配对范围:配套的骨架 / atlas / 贴图在同一次导入里,
// 因此不需要、也不应该去翻整个素材库。
const searchRoots = dirNames.map((dirName) => path.resolve(imagesDir, dirName));
const pairCache = new Map();
for (let index = 0; index < dirNames.length; index++) {
const dirName = dirNames[index];
const info = analyzeItemDir(path.join(imagesDir, dirName), dirName, utils, pairCache, searchRoots, index === 0);
if (info) found.push(info);
if (onProgress) onProgress(index + 1, dirNames.length);
await new Promise((resolve) => setTimeout(resolve, 0));
}
return found;
}
const api = { analyzeItemDir, scanSpineItems, SKELETON_PATTERN, ATLAS_PATTERN, TEXTURE_PATTERN };
if (typeof window !== 'undefined') window.SpineThumbSpineAssets = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,31 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
const patterns = new WeakMap();
// Called with the same world-to-canvas transform used to draw the skeleton.
function drawCheckerboard(ctx, width, height) {
let pattern = patterns.get(ctx);
if (!pattern) {
const tile = ctx.canvas.ownerDocument.createElement('canvas');
tile.width = tile.height = 100;
const paint = tile.getContext('2d');
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
}
const m = ctx.getTransform().inverse();
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
if (!Number.isFinite(x + y)) return;
ctx.save();
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
finally { ctx.restore(); }
}
const api = { drawCheckerboard };
if (typeof window !== 'undefined') window.SpineBackground = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,49 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
const fs = require('fs');
const path = require('path');
// 插件根目录必须延迟计算:Eagle 在 onPluginCreate 里才给出 plugin.path,
// 那时代码已经加载完了,所以这里用函数而不是常量。
function pluginRootPath() {
if (typeof window !== 'undefined') {
const fromEagle = window.SpinePluginPath || window.SpinePluginRoot;
if (fromEagle) return fromEagle;
}
return typeof __dirname !== 'undefined' ? path.join(__dirname, '..') : '.';
}
function badgeFile() {
return path.join(pluginRootPath(), 'assets', 'spine-badge.png');
}
let cachedDataUrl = null;
function dataUrl() {
if (!cachedDataUrl) {
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgeFile()).toString('base64');
}
return cachedDataUrl;
}
function drawVersion(ctx, width, height, version) {
version = String(version || '');
if (version.length > 40 || !/^\d+\.\d+(?:\.\d+)?(?:[-+][a-z0-9][a-z0-9.-]*)?$/i.test(version)) return;
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055));
const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03));
ctx.save();
try {
ctx.font = '600 ' + fontSize + 'px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const boxHeight = fontSize + 12;
const y = height - margin - boxHeight;
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(margin, y, ctx.measureText(version).width + 16, boxHeight);
ctx.fillStyle = '#ffffff'; ctx.fillText(version, margin + 8, y + boxHeight / 2);
} finally { ctx.restore(); }
}
const api = { get path() { return badgeFile(); }, dataUrl, drawVersion };
if (typeof window !== 'undefined') window.SpineBadge = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,253 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
// The official spine-canvas SkeletonRenderer does not process clipping
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) {
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
}
function imageFromAttachment(attachment) {
const region = attachment && attachment.region;
// 4.0 stores the texture on the atlas page, while the other bundled
// runtimes expose it directly on the attachment region.
const renderRegion = region && region.renderObject;
const texture = region && (region.texture
|| (region.page && region.page.texture)
|| (renderRegion && renderRegion.texture)
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
}
// Canvas 2D always applies source-alpha blending. A Spine texture exported
// with PMA already has RGB multiplied by alpha, so drawing it directly would
// multiply translucent edge pixels a second time. Convert it once to straight
// alpha and cache the result; Canvas can then blend it correctly.
function imageForCanvas(image, context, premultipliedAlpha) {
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
const cached = unpremultipliedImages.get(image);
if (cached) return cached;
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!image.width || !image.height) return image;
let canvas;
if (doc) canvas = doc.createElement('canvas');
else {
try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); }
catch (e) { return image; }
}
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0 && alpha < 255) {
const factor = 255 / alpha;
data[i] = Math.min(255, Math.round(data[i] * factor));
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
}
}
ctx.putImageData(pixels, 0, 0);
unpremultipliedImages.set(image, canvas);
return canvas;
} catch (e) {
// If pixel access is unavailable, retain the original texture so preview
// remains usable instead of failing the whole frame.
return image;
}
}
function unpackVertices(vertices, count) {
const positions = new Array(count * 2);
const uvs = new Array(count * 2);
for (let i = 0, p = 0; i < count; i++, p += 2) {
const source = i * 8;
positions[p] = vertices[source];
positions[p + 1] = vertices[source + 1];
uvs[p] = vertices[source + 6];
uvs[p + 1] = vertices[source + 7];
}
return { positions, uvs };
}
function drawInterleaved(base, image, vertices, triangles, darkColor) {
for (let i = 0; i < triangles.length; i += 3) {
const a = triangles[i] * 8;
const b = triangles[i + 1] * 8;
const c = triangles[i + 2] * 8;
base.drawTriangle(image,
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7],
[...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)],
darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined);
}
}
function createLegacyRenderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const clipper = new spine.SkeletonClipping();
const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) {
const composite = context.globalCompositeOperation;
const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha;
try {
for (let i = 0; i < drawOrder.length; i++) {
const slot = drawOrder[i];
if (slot.bone && slot.bone.active === false) {
clipper.clipEndWithSlot(slot);
continue;
}
const attachment = slot.getAttachment();
if (attachment instanceof spine.ClippingAttachment) {
clipper.clipStart(slot, attachment);
continue;
}
try {
if (!attachment) continue;
let vertices;
let triangles;
let vertexCount;
if (attachment instanceof spine.RegionAttachment) {
vertices = base.computeRegionVertices(slot, attachment, false);
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
vertexCount = 4;
} else if (attachment instanceof spine.MeshAttachment) {
vertices = base.computeMeshVertices(slot, attachment, false);
triangles = attachment.triangles;
vertexCount = attachment.worldVerticesLength >> 1;
} else {
continue;
}
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = base.tempColor.a;
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor);
} else {
drawInterleaved(base, image, vertices, triangles, slot.darkColor);
}
} finally {
clipper.clipEndWithSlot(slot);
}
}
} finally {
clipper.clipEnd();
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = clipper;
return base;
}
// 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
base.draw = skeleton => {
const composite = context.globalCompositeOperation;
try {
for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment();
if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue;
const vertices = attachment.updateWorldVertices(slot, false);
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha);
if (!image) continue;
context.globalAlpha = vertices[5];
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
}
} finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
};
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) {
const composite = context.globalCompositeOperation;
try {
const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, false); command; command = command.next) {
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
const positions = command.positions;
const uvs = command.uvs;
const indices = command.indices;
for (let i = 0; i < command.numIndices; i += 3) {
const a = indices[i] * 2;
const b = indices[i + 1] * 2;
const c = indices[i + 2] * 2;
const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255];
const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2]));
const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2]));
base.drawTriangle(image,
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors);
}
}
} finally {
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = core.clipping;
return base;
}
function createRenderer(spine, context, runtimeDir) {
const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime.
renderer.debugRendering = false;
// 页面里 rasterizer 已经由 <script> 提前加载并挂在 window 上,Node 里才走 require。
const rasterizer = typeof window !== 'undefined' && window.SpineWebglRasterizer
? window.SpineWebglRasterizer
: require('./spine-webgl-rasterizer.js');
return rasterizer.attach(renderer, context);
}
const api = { createRenderer };
if (typeof window !== 'undefined') window.SpineCanvasClipping = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,263 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
// Adapted from Esoteric Software's 3.4/3.5/3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// 3.4 source and license: LICENSES/Spine-3.4-License.txt.
// 3.5 source and license: LICENSES/Spine-3.5-License.txt.
// Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once.
const dict = () => Object.create(null);
class Input {
constructor(bytes) {
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.offset = 0;
}
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
byte() { this.need(1); return this.view.getUint8(this.offset++); }
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
varint(positive = true) {
let value = 0;
for (let shift = 0; shift <= 28; shift += 7) {
const b = this.byte();
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
value |= (b & 127) << shift;
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
}
this.fail('无效变长整数');
}
count() { const n = this.varint(); this.need(n); return n; }
string() {
const size = this.varint();
if (size === 0) return null;
this.need(size - 1);
const end = this.offset + size - 1;
let result = '';
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
// code units (including surrogate pairs), rather than string references.
while (this.offset < end) {
const b = this.byte();
if (b < 128) result += String.fromCharCode(b);
else {
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
let code = b & (extra === 1 ? 31 : 15);
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
result += String.fromCharCode(code);
}
}
return result;
}
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
}
function decode(bytes, runtimeDir) {
const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[4567]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.5 / 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7';
const v34 = version === '3.4';
const before36 = v34 || version === '3.5';
skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool();
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
const boneName = () => r.ref(root.bones).name;
const slotName = () => r.ref(root.slots).name;
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
const boneList = () => Array.from({ length: r.count() }, boneName);
for (let i = 0, n = r.count(); i < n; i++) {
const b = { name: r.string() }; if (i) b.parent = boneName();
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
if (v34) { b.inheritRotation = r.bool(); b.inheritScale = r.bool(); }
else b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (nonessential) b.color = r.color(); root.bones.push(b);
}
for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() };
if (!before36) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); }
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
if (!before36) { c.local = r.bool(); c.relative = r.bool(); }
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: slotName(),
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
}
function vertices(n) {
if (!r.bool()) return r.floats(n * 2);
const out = [];
for (let i = 0; i < n; i++) {
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
}
return out;
}
function attachment(key) {
const a = { name: r.string() || key };
const type = r.byte();
if (before36 && type > 4) r.fail(version + ' 不支持此附件类型:' + type);
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) {
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
} else if (type === 2) {
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
a.vertices = vertices(count); a.hull = r.varint();
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
} else if (type === 3) {
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
} else if (type === 5) {
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
} else {
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
if (type === 6) a.end = slotName();
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
if (nonessential) a.color = r.color();
}
return a;
}
const skinNames = [];
function skin(name, isDefault = false) {
const count = r.count(); if (!count && isDefault) return;
const data = root.skins[name] = dict(); skinNames.push(name);
for (let i = 0; i < count; i++) {
const attachments = data[slotName()] = dict();
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
}
}
skin('default', true);
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
const eventNames = [];
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.string(); eventNames.push(name);
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
}
function curve(frame) {
const type = r.byte();
if (type === 1) frame.curve = 'stepped';
else if (type === 2) frame.curve = r.floats(4);
else if (type !== 0) r.fail('未知曲线类型:' + type);
}
function frames(read, curved = true) {
const count = r.count(); if (!count) r.fail('空时间线');
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
}
function animation() {
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.slots[slotName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
else if (type === 2 && !before36) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线');
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.bones[boneName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
if (!name) r.fail('未知骨骼时间线');
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.ik).name;
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.paths[r.ref(root.path).name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
for (let k = 0, q = r.count(); k < q; k++) {
const key = r.string();
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
}
}
}
const drawCount = r.count();
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
const f = { time: r.float(), offsets: [] }; let previous = -1;
for (let i = 0, n = r.count(); i < n; i++) {
const index = r.varint(); const offset = r.varint();
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
}
return f;
});
const eventCount = r.count();
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
});
return a;
}
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
return root;
}
function install(spine, runtimeDir) {
if (!['3.4', '3.5', '3.6', '3.7'].includes(runtimeDir)) return spine;
// 4.x 的运行时用 Object.defineProperty 导出,同名属性是只读 getter,写入会抛
// "which has only a getter"。这里的替换只对确实缺少 SkeletonBinary 的老版本有意义,
// 失败时保留运行时自带实现,绝不能让一次赋值把整个渲染流程打断。
try {
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
readSkeletonData(bytes) {
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
return loader.readSkeletonData(decode(bytes, runtimeDir));
}
};
} catch (error) {
// 只读属性:沿用运行时自带实现。
}
return spine;
}
const api = { install, decode };
if (typeof window !== 'undefined') window.SpineLegacyBinary = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,882 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
// service 插件页面的 require 不以插件目录为基准,所以页面里必须走 <script>。
(function () {
/**
* SpineViewer - 工具模块
* 该模块为纯 Node.js CommonJS,供 viewer/thumbnail 复用。
* 职责:
* - 识别 Spine 导出文件的类型(skel/skel.bytes/json/atlas/atlas.txt/png/jpg/jpeg/webp)
* - 在同一个目录内自动关联配套的骨骼文件、atlas、贴图
* - 从骨骼数据中检测 Spine 版本
* - 根据版本选择合适的本地运行时
*/
const fs = require('fs');
const path = require('path');
const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json'];
const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
const fileIndexCache = new Map();
const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
const SUFFIX_MAX_LENGTH = 50;
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
let activeSuffixes = DEFAULT_SUFFIXES.slice();
let suffixRe = buildSuffixRe(activeSuffixes);
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildSuffixRe(list) {
if (!list || list.length === 0) return null;
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
}
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
function normalizeSuffixName(raw) {
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
}
/**
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
*/
function setSuffixes(list) {
const custom = [];
for (const raw of Array.isArray(list) ? list : []) {
const name = normalizeSuffixName(raw);
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
if (custom.indexOf(name) === -1) custom.push(name);
}
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
suffixRe = buildSuffixRe(activeSuffixes);
return getSuffixes();
}
/** 当前生效的尾缀(内置 + 自定义) */
function getSuffixes() {
return activeSuffixes.slice();
}
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
function getCustomSuffixes() {
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
}
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++) {
if (lower.endsWith(SKELETON_EXTS[i])) return SKELETON_EXTS[i];
}
for (let i = 0; i < ATLAS_EXTS.length; i++) {
if (lower.endsWith(ATLAS_EXTS[i])) return ATLAS_EXTS[i];
}
for (let i = 0; i < TEXTURE_EXTS.length; i++) {
if (lower.endsWith(TEXTURE_EXTS[i])) return TEXTURE_EXTS[i];
}
return null;
}
function isSkeletonFile(name) {
const ext = extOf(name);
return ext === '.skel' || ext === '.skel.bytes' || ext === '.json';
}
function isAtlasFile(name) {
const ext = extOf(name);
return ext === '.atlas' || ext === '.atlas.txt';
}
function isTextureFile(name) {
const ext = extOf(name);
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
function textureFormatOf(name) {
const ext = extOf(name);
return ext === '.jpg' || ext === '.jpeg' ? 'jpeg' : ext;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
if (!ext) return name;
return name.slice(0, name.length - ext.length);
}
/** 归一化 stem,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
function normalizeStem(name) {
const lower = stemOf(name).toLowerCase();
return suffixRe ? lower.replace(suffixRe, '') : lower;
}
// 复合后缀:Eagle 按「最后一个点」拆 name / ext,于是 EmoticonSpine.atlas.txt 变成
// name=EmoticonSpine.atlas + ext=txt。而 Eagle 的卡片把 ext === 'txt' 写死成文本预览,
// 会整个盖掉自定义缩略图。把整段复合后缀塞进 ext(name=EmoticonSpine + ext=atlas.txt),
// Eagle 认不出这个类型,就会退回「用自定义缩略图当封面」,而 name + '.' + ext 拼出来
// 仍是磁盘上的真实文件名,显示和路径都不变。
const COMPOUND_SUFFIXES = ['.skel.bytes', '.atlas.txt'];
/** 返回 { name, ext };不是复合后缀(或没有主干名)时返回 null。 */
function splitCompoundName(fileName) {
const lower = String(fileName || '').toLowerCase();
const suffix = COMPOUND_SUFFIXES.find((s) => lower.endsWith(s));
if (!suffix) return null;
const name = String(fileName).slice(0, fileName.length - suffix.length);
if (!name) return null;
return { name, ext: String(fileName).slice(-suffix.length + 1) };
}
/** 从 JSON 文本中读取 spine 版本 */
function detectVersionFromJson(text) {
try {
const json = JSON.parse(text);
if (json && json.skeleton && json.skeleton.spine) {
return String(json.skeleton.spine);
}
} catch (e) { /* ignore */ }
return null;
}
/** 从二进制数据中读取 spine 版本(扫描前若干字节中的版本号) */
function detectVersionFromBuffer(buf) {
let s = '';
const bytes = new Uint8Array(buf);
const len = Math.min(bytes.length, 160);
for (let i = 0; i < len; i++) {
s += String.fromCharCode(bytes[i]);
}
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
return m ? m[1] : null;
}
/** 从文件读取 spine 版本 */
function detectVersion(filePath) {
const lower = filePath.toLowerCase();
if (lower.endsWith('.json')) {
return detectVersionFromJson(fs.readFileSync(filePath, 'utf8'));
}
return detectVersionFromBuffer(fs.readFileSync(filePath));
}
/**
* 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
*/
function chooseRuntimeDir(version, isBinary) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
if (major === 3) {
const minor = parseFloat(version);
if (minor === 3.4) return '3.4';
if (minor === 3.5) return '3.5';
if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8';
} else if (major === 4) {
const minor = parseFloat(version);
if (minor <= 4.0 + 0.0001) return '4.0';
if (minor <= 4.1 + 0.0001) return '4.1';
if (minor <= 4.2 + 0.0001) return '4.2';
return '4.3';
}
return null;
}
/**
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
*/
function classifySkeletonJson(filePath) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size === 0) return 'pending';
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
const cached = skeletonJsonCache.get(filePath);
if (cached && cached.signature === signature) return cached.kind;
const length = Math.min(stat.size, JSON_PROBE_BYTES);
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(length);
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
const text = buffer.toString('utf8');
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i);
let kind;
if (versionMatch) {
kind = 'spine';
} else if (stat.size > JSON_PROBE_BYTES) {
kind = 'other';
} else {
try {
JSON.parse(text);
kind = 'other';
} catch (e) {
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
kind = hasSkeletonKey ? 'pending' : 'other';
}
}
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
skeletonJsonCache.set(filePath, { signature, kind });
return kind;
} catch (e) {
return 'pending';
}
}
function looksLikeSkeletonJson(filePath) {
return classifySkeletonJson(filePath) === 'spine';
}
/** 文件修改时间(毫秒) */
function mtimeOf(fp) {
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
}
/** 读取某 item 元数据里的创建时间 btime(毫秒),用于按“同一批导入”配对 */
function btimeOf(fp) {
const d = itemMeta(path.dirname(fp));
return (d && d.btime) || 0;
}
/**
* Eagle item 的实际入库时间。
* metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的
* btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。
*/
function importTimeOf(fp) {
try {
const metaPath = path.join(path.dirname(fp), 'metadata.json');
const stat = fs.statSync(metaPath);
return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0;
} catch (e) {
return btimeOf(fp);
}
}
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
function versionFamily(version) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return major <= 3 ? '3x' : '4x';
}
/** 根据 atlas 文本判断其格式家族(3x / 4x) */
function atlasFamily(atlasText) {
if (!atlasText) return null;
// 4.x atlas 用 `bounds:` 定义区域;3.x 用 `xy:`/`size:`/`orig:`/`offset:`。
// 注意:`scale:` 在 3.x 也会出现(如字体缩放),不能单独作为 4x 依据。
// 因此以 `bounds:` 作为最可靠的 4.x 标志;若整体含 `xy:` 判定为 3x。
if (/\bbounds\s*:/.test(atlasText)) return '4x';
if (/\bxy\s*:/.test(atlasText)) return '3x';
return '3x';
}
/** 读取某 item 文件夹的 metadata.json(含 folders、isDeleted、btime 等) */
function itemMeta(itemDir) {
if (!itemDir) return null;
try {
return JSON.parse(fs.readFileSync(path.join(itemDir, 'metadata.json'), 'utf8'));
} catch (e) {
return null;
}
}
/** 读取某个资产文件所属 item 文件夹的 Eagle 逻辑文件夹 id 列表 */
function foldersOfItem(filePath) {
const d = itemMeta(path.dirname(filePath));
return d && Array.isArray(d.folders) ? d.folders : [];
}
/** 判断某 item 是否已被放入 Eagle 回收站(isDeleted) */
function isItemDeleted(filePath) {
const d = itemMeta(path.dirname(filePath));
return !!(d && d.isDeleted);
}
/** 从任意 item 路径向上定位 Eagle 库根目录 */
function findLibraryRoot(filePath) {
let dir = path.dirname(filePath); // item 文件夹
for (let i = 0; i < 8; i++) {
const parent = path.dirname(dir);
if (parent === dir) break;
try {
if (fs.existsSync(path.join(parent, 'metadata.json')) && fs.existsSync(path.join(parent, 'images'))) {
return parent;
}
} catch (e) { /* ignore */ }
dir = parent;
}
return null;
}
/** 解析库根 metadata.json,返回 { folderId -> children[] } 及文件夹名 */
function readFolderInfo(libRoot) {
const map = {};
const names = {};
if (!libRoot) return { map, names };
try {
const d = JSON.parse(fs.readFileSync(path.join(libRoot, 'metadata.json'), 'utf8'));
const folders = d.folders || [];
for (const f of folders) {
if (!f) continue;
map[f.id] = Array.isArray(f.children) ? f.children : [];
names[f.id] = f.name || '';
}
} catch (e) { /* ignore */ }
return { map, names };
}
/**
* 计算允许匹配的 Eagle 文件夹 id 集合:
* 仅包含「选中文件所在的文件夹」本身,不下钻子文件夹,也不匹配父级/兄弟文件夹。
*/
function getValidFolders(filePath) {
const itemFolders = foldersOfItem(filePath);
if (itemFolders.length === 0) return null; // 未归入任何 Eagle 文件夹,则不限定
return new Set(itemFolders);
}
/** 判断目录是否为 Eagle 的 item 元数据文件夹(包含 metadata.json) */
function isEagleItemDir(dir) {
try { return fs.existsSync(path.join(dir, 'metadata.json')); } catch (e) { return false; }
}
/**
* 计算需要搜索的目录列表。
* Eagle 会把导入的每个文件拆分成独立的元数据文件夹(如 `images/<id>.info/`),
* 因此配套的骨骼/atlas/贴图可能位于不同的 item 文件夹中。
* 返回:当前 item 文件夹 + 其同级的所有 item 文件夹。
*/
function getSearchRoots(filePath) {
const roots = [];
const itemDir = path.dirname(filePath);
roots.push(itemDir);
if (isEagleItemDir(itemDir)) {
const parent = path.dirname(itemDir);
try {
for (const name of fs.readdirSync(parent)) {
const sub = path.join(parent, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
return [...new Set(roots)];
}
/** 把库根目录 images/ 下所有 item 文件夹加入搜索根(去重)。
* 用于覆盖同伴位于不同 .info / 跨批次的情况(全库兜底)。 */
function buildRootsWithLibraryRoot(filePath, roots) {
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return;
for (const name of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
if (roots.indexOf(sub) === -1) roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
/**
* 在多个目录中收集所有 Spine 相关文件,返回 [{ name, fullPath }]。
* 若传入 validFolders(Eagle 逻辑文件夹 id 集合),则只保留位于这些文件夹内的 item,
* 从而把匹配范围限定在「选中文件所在的 Eagle 文件夹及其子文件夹」内。
*/
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) {
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) {
const itemFolders = foldersOfItem(full);
if (!itemFolders.some((f) => validFolders.has(f))) continue; // 不在允许的文件夹内
}
if (seen.has(full)) continue;
seen.add(full);
out.push({ name, fullPath: full });
}
}
return out;
}
/**
* 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个
* 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉
* 正在落盘的新文件。
*/
function collectSpineFilesCached(filePath, roots, refresh) {
const libRoot = findLibraryRoot(filePath);
// Outside an Eagle library, sibling asset folders are independent search
// scopes. Sharing their parent as a cache key can return another skeleton.
const key = libRoot || JSON.stringify([...roots].sort());
const now = Date.now();
const cached = fileIndexCache.get(key);
if (!refresh && cached && now - cached.time < 1500) return cached.files;
const files = collectSpineFiles(roots, null);
fileIndexCache.set(key, { time: now, files });
return files;
}
/**
* 修正不可靠的源路径。
* Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录
* (例如其内部临时工作目录)。此时按文件名在整个库 images/ 下找到真实存在的
* 源文件,作为后续资源发现的基准。
*/
function resolveRealSource(filePath) {
// 源目录存在且是有效 item 目录 → 直接用
const dir = path.dirname(filePath);
if (isEagleItemDir(dir)) return filePath;
const name = path.basename(filePath);
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return filePath;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return filePath;
// 优先找“文件名 + 类型”匹配的真实文件。若存在同名文件,返回它所在目录,
// 以便后续 getSearchRoots/selectBest 能借助其 folders/btime 精确匹配。
let fallback = null;
for (const d of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, d);
if (!isEagleItemDir(sub)) continue;
const cand = path.join(sub, name);
if (fs.existsSync(cand)) {
// 优先选择未被删除的项
if (!isItemDeleted(cand)) return cand;
if (!fallback) fallback = cand;
}
}
if (fallback) return fallback;
} catch (e) { /* ignore */ }
return filePath;
}
/**
* 发现与所选文件 related 的 Spine 资源(跨 Eagle 多个 item 文件夹)。
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
*/
function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const requestedCrossBatch = !!options.allowCrossBatch;
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
let roots;
if (Array.isArray(options.searchRoots) && options.searchRoots.length) {
roots = [...new Set(options.searchRoots.map((root) => path.resolve(root)))];
} else {
roots = getSearchRoots(filePath);
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
// 明确列举的候选 item 目录,避免每次预览扫描整库。
buildRootsWithLibraryRoot(filePath, roots);
}
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
const validFolders = getValidFolders(filePath);
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
let files;
if (strictStem) {
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
// 无论是否允许跨批次,都必须限定在同一个 Eagle 文件夹内。同名素材很常见
//(同一套资源的 3.6 与 4.0 导出、同一角色的不同批次),只按文件名 stem
// 匹配会配到别的版本,渲染时要么区域找不到,要么贴图坐标整个错位。
files = validFolders && validFolders.size
? allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)))
: allFiles;
} else {
files = collectSpineFiles(roots, validFolders);
}
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
const textureFiles = files.filter((f) => isTextureFile(f.name));
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
// 避免每个回调都解析库中所有普通 JSON。
if (strictStem) {
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
}
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
const realSkeletonFiles = skeletonFiles.filter((f) => {
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
return true;
});
// 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。
// 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。
const selectBest = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const refFolders = foldersOfItem(refFile);
const refImportTime = importTimeOf(refFile);
const refBtime = btimeOf(refFile);
const ranked = cands.map((c) => {
const cFolders = foldersOfItem(c.fullPath);
return {
c,
importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER,
sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0,
btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER,
};
});
ranked.sort((a, b) => allowCrossBatch
? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
: (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
);
return ranked[0].c;
};
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
const familyOfAtlas = (f) => { try { return atlasFamily(fs.readFileSync(f.fullPath, 'utf8')); } catch (e) { return null; } };
const familyOfSkel = (f) => { try { return versionFamily(detectVersion(f.fullPath)); } catch (e) { return null; } };
// 选中的文件若是 atlas,则读取其格式家族,用于匹配同家族的骨骼
const selectedAtlasFamily = isAtlasFile(selected)
? (() => { try { return atlasFamily(fs.readFileSync(filePath, 'utf8')); } catch (e) { return null; } })()
: null;
// 1) 骨骼文件
let skeleton = null;
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
if (isSkeletonFile(selected)) {
skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) ||
realSkeletonFiles.find((f) => f.name === selected) || null;
}
if (!skeleton) {
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
let cands = realSkeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
if (selectedAtlasFamily && cands.length > 1) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = cands.length > 0 ? selectBest(cands, filePath) : null;
}
if (!skeleton && realSkeletonFiles.length > 0) {
if (strictStem) {
skeleton = null;
} else {
// 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取
let cands = realSkeletonFiles;
if (selectedAtlasFamily) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = selectBest(cands, filePath);
}
}
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
let atlas = null;
if (isAtlasFile(selected)) {
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
atlasFiles.find((f) => f.name === selected) || null;
}
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
const pickAtlas = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const skelFamily = skeleton ? familyOfSkel(skeleton) : null;
if (skelFamily) {
const same = cands.filter((f) => familyOfAtlas(f) === skelFamily);
if (same.length > 0) cands = same;
}
return selectBest(cands, refFile);
};
if (!atlas) {
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === selectedStem);
atlas = pickAtlas(cands, filePath);
}
if (!atlas && skeleton) {
const skelStem = normalizeStem(skeleton.name);
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === skelStem);
atlas = pickAtlas(cands, skeleton.fullPath);
}
if (!atlas && skeleton) {
if (strictStem) {
atlas = null;
} else {
// 忽略 stem:用与骨骼同家族且同批入库的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
}
}
if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0];
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
if (strictStem && skeleton && atlas) {
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
if (pairDelta > allowedImportDelta) {
if (isAtlasFile(selected)) skeleton = null;
else atlas = null;
}
}
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
let texturePaths = {};
if (atlas) {
let text = '';
try { text = fs.readFileSync(atlas.fullPath, 'utf8'); } catch (e) { /* ignore */ }
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
if (inAtlasDir) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 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));
const sameFormat = cands.filter((f) => textureFormatOf(f.name) === textureFormatOf(pageFileName));
if (sameFormat.length > 0) cands = sameFormat;
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;
}
}
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');
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
texturePaths,
base,
isBinary: skeletonIsBinary,
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
};
}
/** 从 atlas 文本中解析出引用的贴图页面文件名 */
function parseAtlasPages(atlasText) {
const pages = [];
if (!atlasText) return pages;
const lines = atlasText.split(/\r?\n/);
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
pages.push(validateAtlasPageName(line));
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
return [...new Set(pages)];
}
/**
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
*/
function parseAtlasPma(atlasText) {
if (typeof atlasText !== 'string') return undefined;
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
}
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
function preferredSkin(skeletonData) {
if (!skeletonData) return null;
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
const skins = skeletonData.skins || [];
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
}
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
function applySkin(skeleton, skin) {
if (!skeleton || !skin) return false;
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
else return false;
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
return true;
}
const spineUtilsApi = {
SKELETON_EXTS,
ATLAS_EXTS,
TEXTURE_EXTS,
ALL_EXTS,
extOf,
isSkeletonFile,
isAtlasFile,
isTextureFile,
textureFormatOf,
stemOf,
normalizeStem,
DEFAULT_SUFFIXES,
SUFFIX_MAX_LENGTH,
normalizeSuffixName,
setSuffixes,
getSuffixes,
getCustomSuffixes,
detectVersion,
detectVersionFromJson,
detectVersionFromBuffer,
chooseRuntimeDir,
classifySkeletonJson,
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
validateAtlasPageName,
resolveAtlasTextureFile,
parseAtlasPma,
preferredSkin,
applySkin,
isEagleItemDir,
getSearchRoots,
collectSpineFiles,
foldersOfItem,
findLibraryRoot,
readFolderInfo,
getValidFolders,
splitCompoundName,
mtimeOf,
btimeOf,
importTimeOf,
versionFamily,
atlasFamily,
itemMeta,
isItemDeleted,
};
if (typeof window !== 'undefined') window.SpineUtils = spineUtilsApi;
else if (typeof module !== 'undefined' && module.exports) module.exports = spineUtilsApi;
})();
@@ -0,0 +1,33 @@
// 从画布像素里找实际有内容的包围盒(动态缩略图按整个动画的可见范围取景用)。
// 与主插件 js/spine-visible-bounds.js 是同一份实现,只是出口兼容 <script> 加载。
(function () {
'use strict';
function findAlphaBounds(rgba, width, height, threshold = 4) {
let left = width, top = height, right = 0, bottom = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x + 1);
bottom = Math.max(bottom, y + 1);
}
}
return right > left && bottom > top ? { left, top, right, bottom } : null;
}
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
if (!pixels || !isFinite(scale) || scale <= 0) return null;
const left = centerX + (pixels.left - width / 2) / scale;
const right = centerX + (pixels.right - width / 2) / scale;
const top = centerY - (pixels.top - height / 2) / scale;
const bottom = centerY - (pixels.bottom - height / 2) / scale;
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
const api = { findAlphaBounds, toWorldBounds };
if (typeof window !== 'undefined') window.SpineVisibleBounds = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,183 @@
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
(function () {
'use strict';
// Decode file bytes without the Canvas 2D premultiply/unpremultiply round trip.
// In low-alpha PMA pixels that round trip destroys RGB precision before blending.
const preparedTextures = new WeakMap();
async function prepareTexture(image, blob) {
if (typeof createImageBitmap !== 'function') return;
const options = { colorSpaceConversion: 'none' };
const [pma, straight] = await Promise.all([
createImageBitmap(blob, { ...options, premultiplyAlpha: 'none' }),
createImageBitmap(blob, { ...options, premultiplyAlpha: 'premultiply' })
]);
preparedTextures.set(image, { pma, straight });
}
// Rasterize adjacent mesh triangles together, with GPU edge ownership rules.
// Canvas clip()+drawImage() anti-aliases each triangle separately and exposes
// the background along shared edges, which looks like a wireframe overlay.
function createRasterizer(context) {
const document = context.canvas && context.canvas.ownerDocument;
if (!document || typeof context.getTransform !== 'function') return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', {
alpha: true, premultipliedAlpha: true, antialias: true,
preserveDrawingBuffer: true, depth: false, stencil: false
});
if (!gl) return null;
function shader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
const vertex = shader(gl.VERTEX_SHADER, `
attribute vec2 position;
attribute vec2 uv;
attribute vec4 light;
attribute vec4 dark;
uniform vec2 resolution;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec2 p = position / resolution * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
textureUV = uv;
lightColor = light; darkColor = dark;
}`);
const fragment = shader(gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D image;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec4 color = texture2D(image, textureUV);
float alpha = color.a * lightColor.a;
// Textures are PMA in GPU memory. Light/dark colors arrive straight.
vec3 rgb = (color.rgb * lightColor.rgb + (color.a - color.rgb) * darkColor.rgb) * lightColor.a;
gl_FragColor = vec4(rgb, alpha);
}`);
const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
gl.deleteShader(vertex); gl.deleteShader(fragment);
const buffer = gl.createBuffer();
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution');
const light = gl.getAttribLocation(program, 'light'), dark = gl.getAttribLocation(program, 'dark');
const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
let disposed = false, lost = false;
let premultipliedAlpha = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) {
const pma = image !== context.canvas && premultipliedAlpha;
let pair = textures.get(image);
if (!pair) { pair = {}; textures.set(image, pair); }
const key = pma ? 'pma' : 'straight';
if (pair[key] && image !== context.canvas) return pair[key];
const t = pair[key] || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !pma);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const prepared = preparedTextures.get(image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, prepared ? prepared[key] : image);
pair[key] = t; return t;
}
function flush() {
if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
// Premultiplied RGB factors; keep source-over alpha independently.
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 12);
coordinates.length = 0;
}
return {
begin(pma = false) {
if (disposed || lost || gl.isContextLost()) return false;
premultipliedAlpha = pma;
const target = context.canvas;
if (canvas.width !== target.width) canvas.width = target.width;
if (canvas.height !== target.height) canvas.height = target.height;
transform = context.getTransform(); coordinates.length = 0; currentImage = null;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 48, 0);
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 48, 8);
gl.enableVertexAttribArray(light); gl.vertexAttribPointer(light, 4, gl.FLOAT, false, 48, 16);
gl.enableVertexAttribArray(dark); gl.vertexAttribPointer(dark, 4, gl.FLOAT, false, 48, 32);
gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Seed the same framebuffer with the actual Canvas pixels (solid or
// checkerboard background). CSS backgrounds cannot participate in blending.
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
const w = canvas.width, h = canvas.height;
for (const vertex of [[0,0,0,0],[w,0,1,0],[w,h,1,1],[0,0,0,0],[w,h,1,1],[0,h,0,1]])
coordinates.push(...vertex, 1,1,1,1, 0,0,0,0);
flush(); currentImage = null;
return true;
},
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, colors, darkColors) {
const alpha = context.globalAlpha;
const mode = context.globalCompositeOperation || 'source-over';
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
const t = transform;
const points = [[x0,y0,u0,v0],[x1,y1,u1,v1],[x2,y2,u2,v2]];
for (let i=0; i<3; i++) {
const [x,y,u,v] = points[i], j=i*4;
coordinates.push(t.a*x+t.c*y+t.e, t.b*x+t.d*y+t.f, u,v,
...(colors ? colors.slice(j,j+4) : [1,1,1,alpha]),
...(darkColors ? darkColors.slice(j,j+4) : [0,0,0,0]));
}
},
end() {
flush();
context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
finally { context.restore(); }
},
dispose() {
if (disposed) return; disposed = true;
for (const pair of textures.values()) for (const value of Object.values(pair)) gl.deleteTexture(value);
textures.clear(); gl.deleteBuffer(buffer); gl.deleteProgram(program);
const extension = gl.getExtension('WEBGL_lose_context'); if (extension) extension.loseContext();
}
};
}
function attach(renderer, context) {
let rasterizer;
try { rasterizer = createRasterizer(context); } catch (e) { return renderer; }
if (!rasterizer) return renderer;
const draw = renderer.draw.bind(renderer), triangle = renderer.drawTriangle.bind(renderer);
renderer.draw = skeleton => {
if (!rasterizer.begin(!!renderer.premultipliedAlpha)) return draw(skeleton);
renderer.gpuDrawing = true;
renderer.drawTriangle = rasterizer.triangle;
try { draw(skeleton); rasterizer.end(); }
finally { renderer.drawTriangle = triangle; renderer.gpuDrawing = false; }
};
renderer.dispose = rasterizer.dispose;
renderer.rasterization = 'webgl';
return renderer;
}
const api = { createRasterizer, attach, prepareTexture };
if (typeof window !== 'undefined') window.SpineWebglRasterizer = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -0,0 +1,9 @@
# Spine 3.4 runtime
Bundled from the official `3.4.02` tag, commit `ef5013143178918ddfc7cabfbddeae7aec7cf84e`, `spine-ts/build/spine-canvas.js`.
Local correction: `SkeletonJson` path-length loop writes `lengths[i]`, not `lengths[i++]`. The original increments twice and reads beyond the source array, producing missing/NaN lengths.
The plugin provides binary decoding and the `AtlasAttachmentLoader` name alias in `js/spine-legacy-binary.js`. Rendering uses the 3.4 attachment vertex API in `js/spine-canvas-clipping.js`. 3.4 has no clipping attachments; no newer attachment types are synthesized.
License: `LICENSES/Spine-3.4-License.txt`.
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,11 @@
# Spine 3.5
Official `3.5.51` tag, commit `2cd9467d2736b7f3a0b81382f117c73823bfb44b`, `spine-ts/build/spine-canvas.js`. The branch named `3.5` contains later 3.6 beta data; this bundle deliberately uses the tag.
Local correction: the path-length loop uses `lengths[i]` instead of `lengths[i++]`, avoiding a double increment and NaN values.
The binary compatibility layer reads 3.5 transform modes and constraint order, but does not read 3.6 dark-slot color or local/relative transform fields. Rendering uses the older attachment vertex interface. The version predates clipping attachments.
The official tag includes matching 3.5.03-beta JSON/SKEL pairs for seven variants. Tank and Raptor have 3.5.49 JSON but 3.5.03-beta SKEL; these are tested independently, not compared as identical exports. No version strings or data are rewritten.
License: `LICENSES/Spine-3.5-License.txt`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

@@ -0,0 +1,32 @@
{
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
"version": "1.0.1",
"platform": "all",
"arch": "all",
"name": "{{manifest.app.name}}",
"logo": "/logo.png",
"keywords": [
"spine",
"thumbnail",
"缩略图"
],
"fallbackLanguage": "zh_CN",
"languages": [
"zh_CN",
"en",
"zh_TW",
"ja_JP",
"ko_KR",
"ru_RU",
"es_ES",
"de_DE"
],
"devTools": false,
"main": {
"serviceMode": true,
"url": "index.html",
"width": 780,
"height": 620,
"childWindow": true
}
}
+234
View File
@@ -0,0 +1,234 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<title data-i18n="progress.title">Spine缩略图</title>
<style>
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; }
html, body { margin: 0; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
body { display: flex; flex-direction: column; gap: 8px; padding: 14px; box-sizing: border-box; }
h1 { font-size: 13px; margin: 0; font-weight: 600; text-align: center; }
.bar { height: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
/* 不加过渡:窗口被遮挡时 CSS 过渡会停在中途,进度条就不跟着走了。 */
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); }
#count { color: var(--text); }
#name { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 5px 24px; font: inherit; cursor: pointer; }
button:hover { border-color: var(--accent); color: var(--accent); }
#action { align-self: center; }
#confirm { display: none; flex-direction: column; gap: 8px; }
#confirmText { text-align: center; color: var(--text); }
#confirmButtons { display: flex; gap: 10px; justify-content: center; }
/* 继续 = 默认项(绿色、自动获得焦点);确认取消 = 危险操作(红色)。 */
#resume { background: #2ea043; border-color: #2ea043; color: #fff; }
#resume:hover { background: #3fb950; border-color: #3fb950; color: #fff; }
#cancel { background: #d64545; border-color: #d64545; color: #fff; }
#cancel:hover { background: #e5534b; border-color: #e5534b; color: #fff; }
</style>
</head>
<body>
<h1 data-i18n="progress.generating">正在生成动态缩略图……</h1>
<div class="bar"><i id="bar"></i></div>
<div id="count" data-i18n="progress.waiting">等待开始…</div>
<div id="name"></div>
<div id="confirm">
<div id="confirmText" data-i18n="progress.confirmPrompt">是否确认取消?</div>
<div id="confirmButtons">
<button id="resume" type="button" data-i18n="progress.resume">继续</button>
<button id="cancel" type="button" data-i18n="progress.cancel">确认取消</button>
</div>
</div>
<button id="action" type="button" data-i18n="progress.stop">停止</button>
<script>
(function () {
try {
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
})();
</script>
<script src="js/i18n.js"></script>
<script>
// 这个页面是宿主用 Eagle 的 create-plugin-window 打开的插件窗口:
// 无标题栏、置顶、挂在 Eagle 主窗口上;进度从临时目录的状态文件轮询。
// 按钮往回写控制文件:生成中 = 停止(暂停并就地询问),生成完 = 确认(关窗)。
const fs = require('fs');
const os = require('os');
const path = require('path');
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const WIDTH = 380;
const t = SpineThumbI18n.t;
const bar = document.getElementById('bar');
const countEl = document.getElementById('count');
const nameEl = document.getElementById('name');
const actionEl = document.getElementById('action');
const confirmEl = document.getElementById('confirm');
const confirmTextEl = document.getElementById('confirmText');
const resumeEl = document.getElementById('resume');
let finished = false;
let paused = false;
let doneCount = 0;
let lastHeight = 0;
let placedAt = null; // 上次由我们设置的位置,用来判断用户是不是手动拖过
let allowClose = false;
/** Eagle 的 userData 目录;eagle.app.userDataPath 注入得比较晚,所以留一个兜底。 */
function userDataPath() {
if (eagle.app && eagle.app.userDataPath) return eagle.app.userDataPath;
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Eagle');
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Eagle');
}
/** 算出"Eagle 主窗口正中心"对应的弹窗位置;读不到就返回 null。 */
function centerTarget(width, height) {
let state = null;
try {
state = JSON.parse(fs.readFileSync(path.join(userDataPath(), 'window-state.json'), 'utf8'));
} catch (error) { /* 读不到就用不了 */ }
if (!state || !Number.isFinite(state.x)) return null;
// 主窗口最大化/全屏时记录的是还原后的位置,拿它当基准会偏,改用所在显示器居中。
const area = (state.isMaximized || state.isFullScreen) && state.displayBounds ? state.displayBounds : state;
return [
Math.round(area.x + (area.width - width) / 2),
Math.round(area.y + (area.height - height) / 2),
];
}
/** 跟着 Eagle 主窗口居中;一旦用户手动拖过窗口,就只改尺寸、不再动位置。 */
async function reCenter(width, height) {
try {
const current = await eagle.window.getPosition();
const movedByUser = placedAt && (Math.abs(current[0] - placedAt[0]) > 4 || Math.abs(current[1] - placedAt[1]) > 4);
if (movedByUser) return;
} catch (error) { /* 拿不到位置就照常居中 */ }
const target = centerTarget(width, height);
if (!target) {
await eagle.window.center();
return;
}
placedAt = target;
await eagle.window.setPosition(target[0], target[1]);
}
/** 窗口高度跟着内容走。 */
function fitHeight() {
// setSize 设置的是包含系统标题栏的外部高度,内容高度要补回窗口边框占用。
const height = Math.ceil(document.body.scrollHeight + Math.max(0, window.outerHeight - window.innerHeight));
if (Math.abs(height - lastHeight) < 2) return;
lastHeight = height;
eagle.window.setSize(WIDTH, height)
.then(() => reCenter(WIDTH, height))
.catch(() => { /* 调整失败不影响显示 */ });
}
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
function syncButtons() {
actionEl.textContent = finished ? t('progress.confirm') : t('progress.stop');
actionEl.style.display = paused ? 'none' : '';
confirmEl.style.display = paused ? 'flex' : 'none';
fitHeight();
}
function writeControl(action) {
try {
fs.writeFileSync(CONTROL_FILE, JSON.stringify({ action, at: Date.now() }), 'utf8');
} catch (error) { /* 写不进去就等于没按 */ }
}
function requestStop() {
if (paused) return;
paused = true;
writeControl('pause');
syncButtons();
resumeEl.focus();
}
// 系统关闭按钮与页面里的「停止」走同一条暂停确认流程。
window.addEventListener('beforeunload', (event) => {
if (allowClose || finished) return;
event.preventDefault();
event.returnValue = false;
requestStop();
});
function render(state) {
const total = Math.max(1, state.total || 0);
const done = state.done || 0;
finished = state.finished === true;
if (finished) paused = false;
doneCount = done;
confirmTextEl.textContent = t('progress.generatedConfirm', { done: doneCount });
if (finished) {
bar.style.width = '100%';
countEl.textContent = state.text || t('progress.complete');
nameEl.textContent = '';
} else {
bar.style.width = Math.min(100, done / total * 100) + '%';
countEl.textContent = t('progress.completed', {
done,
total: state.total || 0,
percent: Math.round(done / total * 100),
});
nameEl.textContent = state.name || '';
}
syncButtons();
}
actionEl.addEventListener('click', () => {
if (finished) {
allowClose = true;
window.close();
return;
}
requestStop();
});
resumeEl.addEventListener('click', () => {
paused = false;
writeControl('resume');
syncButtons();
});
document.getElementById('cancel').addEventListener('click', () => {
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
writeControl('cancel');
allowClose = true;
window.close();
});
// 窗口尺寸/置顶/居中:Eagle 的 window-state.json 会覆盖 manifest 里的位置和宽高,这里再对齐一次。
(async () => {
try {
await eagle.window.setResizable(false);
} catch (error) { /* ignore */ }
syncButtons();
})();
// 心跳:让宿主知道这个窗口还开着;窗口关掉后心跳就停了,下次需要时会重新开。
const beat = () => {
try { fs.writeFileSync(ALIVE_FILE, String(Date.now()), 'utf8'); } catch (error) { /* ignore */ }
};
beat();
setInterval(beat, 1000);
let lastStamp = 0;
setInterval(() => {
let state;
try {
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
} catch (error) {
return; // 状态文件还没写出来
}
if (state.updatedAt === lastStamp) return;
lastStamp = state.updatedAt;
render(state);
}, 400);
</script>
</body>
</html>
@@ -0,0 +1,116 @@
'use strict';
// 动态缩略图的回归测试。渲染本身要 DOM canvas,这里锁住所有能脱离浏览器验证的部分:
// 档位配置、目标体积默认值、帧数/尺寸换算、可见范围算法、WebP 封装、自动定档策略。
const assert = require('node:assert/strict');
const profiles = require('../js/animated-profiles.js');
const encoder = require('../js/animated-webp-encoder.js');
const visibleBounds = require('../js/spine-visible-bounds.js');
const renderer = require('../js/renderer.js');
// —— 目标体积:每秒 25 KB,夹在 25–100 KB(跟主插件的默认值一致)——
assert.equal(profiles.SIZE_PER_SECOND, 25);
assert.equal(profiles.DEFAULT_OUTPUT_SIZE, 200);
assert.equal(profiles.DEFAULT_PRIORITY, 'resolution');
assert.equal(profiles.suggestSize(0.2), 25);
assert.equal(profiles.suggestSize(1), 25);
assert.equal(profiles.suggestSize(2), 50);
assert.equal(profiles.suggestSize(10), 100);
// —— 档位表:细节分辨率永远不超过输出画布边长 ——
const ladder = profiles.animatedProfiles('resolution', 200);
assert.equal(ladder.length, 10);
assert.ok(ladder.every((profile) => profile.size <= 200), '细节分辨率不能超过输出画布');
assert.ok(ladder.every((profile) => profile.fps >= 1 && profile.quality >= 0 && profile.quality <= 1));
assert.deepEqual(profiles.animatedProfiles('resolution', 400).slice(0, 5).map((p) => p.size), [400, 400, 400, 400, 400]);
// —— 搜索路径:首尾保留锚点,中间是插值 ——
const searchPath = profiles.expandAnimatedPath('resolution', 200);
assert.deepEqual(searchPath[0], { size: 200, fps: 18, quality: 1 });
assert.equal(searchPath[searchPath.length - 1].fps, 6);
assert.ok(searchPath.every((point) => point.size <= 200));
// —— 帧数换算:至少 2 帧、不超过采样帧数、按 ceil(range × fps) ——
assert.equal(renderer.profileFrameCount(100, 1, 12), 12);
assert.equal(renderer.profileFrameCount(100, 1, 200), 100, '帧率再高也不能超过采样帧数');
assert.equal(renderer.profileFrameCount(100, 0.001, 1), 2);
// —— 细节分辨率:等于画布时原样返回,低于画布时先降后升、尺寸不变 ——
const pixels = new Uint8Array(4 * 4 * 4).fill(7);
assert.equal(renderer.scaledFramePixels(pixels, 4, 4), pixels);
const scaled = renderer.scaledFramePixels(pixels, 4, 2);
assert.equal(scaled.length, 16 * 4);
assert.equal(scaled[0], 7);
// —— 可见范围:全透明不算范围,单个像素能定位 ——
assert.equal(visibleBounds.findAlphaBounds(new Uint8Array(4 * 4 * 4), 4, 4), null);
const box = new Uint8Array(4 * 4 * 4);
box[(1 * 4 + 1) * 4 + 3] = 255;
assert.deepEqual(visibleBounds.findAlphaBounds(box, 4, 4), { left: 1, top: 1, right: 2, bottom: 2 });
assert.deepEqual(visibleBounds.toWorldBounds({ left: 0, top: 0, right: 8, bottom: 8 }, 8, 8, 2, 0, 0), {
x: -2, y: -2, width: 4, height: 4,
});
// —— 取骨骼里的第一个有效动画;时长为 0 的动画要跳过 ——
assert.equal(renderer.animationDurationOf({ duration: 1.5 }), 1.5);
assert.equal(renderer.animationDurationOf({ duration: 0, timelines: [{ frames: [[0.5], [1]] }] }), 1);
assert.equal(
renderer.firstAnimation({ animations: [{ name: 'empty', duration: 0 }, { name: 'idle', duration: 1 }] }).name,
'idle',
'时长为 0 的动画不能当成动态缩略图的素材',
);
assert.equal(renderer.firstAnimation({ animations: [] }), null);
// —— 自动定档:和主插件画廊默认打勾的一致,取实际体积最小的那一档 ——
(async () => {
const candidates = [60, 10, 120, 30].map((kb) => ({ buffer: Buffer.alloc(kb * 1024), kb }));
assert.equal(renderer.smallestCandidate(candidates).kb, 10, '要挑体积最小的那一档,跟顺序无关');
assert.equal(renderer.smallestCandidate([]), null);
// —— 候选插值:给定搜索路径应返回指定数量的合法档位 ——
const size = 8;
const frames = Array.from({ length: 3 }, () => new Uint8Array(size * size * 4).fill(9));
const fakeEncode = async (_slot, framePixels, quality) => Buffer.alloc(Math.max(1, Math.round(framePixels.length * quality / 16)));
const picked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode);
assert.equal(picked.length, 10);
// 最快策略只是少采样(第 8 个参数),候选数量不变,顺序仍是从预计体积最小到最大。
const fastPicked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode, 4);
assert.equal(fastPicked.length, 10, '最快策略只是少采样,候选数量不变');
for (const profile of picked) {
// 这条搜索路径是按 200 的画布展开的,细节分辨率必须落在 [1, 200]。
assert.ok(profile.size >= 1 && profile.size <= 200, '细节分辨率要落在画布范围内');
assert.ok(profile.fps >= 1);
assert.ok(profile.quality >= 0 && profile.quality <= 1);
}
// —— WebP 封装:两帧以上才合法,结构必须是 RIFF/WEBP + ANMF ——
const still = (() => {
const chunk = (type, payload) => {
const output = Buffer.alloc(8 + payload.length + (payload.length & 1));
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
};
const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8 ', Buffer.from([1, 2, 3, 4]))]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
})();
const animated = encoder.encode({
width: 200,
height: 200,
frames: [{ webp: still, duration: 55 }, { webp: still, duration: 55 }],
});
assert.equal(animated.toString('ascii', 0, 4), 'RIFF');
assert.equal(animated.toString('ascii', 8, 12), 'WEBP');
assert.equal(animated.readUInt32LE(4), animated.length - 8);
assert.throws(() => encoder.encode({ width: 200, height: 200, frames: [{ webp: still, duration: 55 }] }), /at least two frames/);
console.log('PASS: 动态缩略图档位、目标体积、帧数换算、可见范围、自动定档、WebP 封装');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,192 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const compat = require('../js/compound-compat');
const utils = require('../js/spine-utils');
const pluginPath = path.join(__dirname, '..', 'js', 'plugin.js');
const pluginSource = fs.readFileSync(pluginPath, 'utf8');
const pageSource = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
const releaseRoot = path.join(__dirname, '..', '..', 'release', 'UNIVERSAL_SpineThumbnailGenerateServer');
const planned = compat.planChange(
{ entryName: 'hero.atlas.txt' },
{ name: 'hero.atlas', ext: 'txt' },
utils.splitCompoundName,
);
assert.deepEqual(planned, {
sourceName: 'hero.atlas.txt',
currentName: 'hero.atlas',
currentExt: 'txt',
currentFileName: 'hero.atlas.txt',
newName: 'hero',
newExt: 'atlas.txt',
newFileName: 'hero.atlas.txt',
});
assert.deepEqual(compat.planChange(
{ entryName: 'hero.skel.bytes' },
{ name: 'hero.skel', ext: 'bytes' },
utils.splitCompoundName,
), {
sourceName: 'hero.skel.bytes',
currentName: 'hero.skel',
currentExt: 'bytes',
currentFileName: 'hero.skel.bytes',
newName: 'hero',
newExt: 'skel.bytes',
newFileName: 'hero.skel.bytes',
});
assert.equal(compat.planChange(
{ entryName: 'hero.atlas.txt' },
{ name: 'hero', ext: 'atlas.txt' },
utils.splitCompoundName,
), null);
assert.equal(compat.planChange(
{ entryName: 'hero.atlas' },
{ name: 'hero', ext: 'atlas' },
utils.splitCompoundName,
), null);
assert.equal(compat.planChange(
{ entryName: 'hero.atlas.txt' },
{ name: 'renamed', ext: 'txt' },
utils.splitCompoundName,
).reason, 'sourceMismatch');
assert.deepEqual(utils.splitCompoundName('Hero.ATLAS.TXT'), {
name: 'Hero',
ext: 'ATLAS.TXT',
});
assert.ok(
pluginSource.indexOf('eagleItem.customThumbnail === true') <
pluginSource.indexOf('const plan = compoundCompat.planChange'),
'custom thumbnails must be skipped before compatibility changes',
);
assert.match(pluginSource, /await prepareCompoundCompatibility\(ready\)[\s\S]*queue\.push/);
assert.match(pluginSource, /rollbackCompoundChange/);
assert.match(pluginSource, /name=.*ext=/);
assert.match(pluginSource, /function showCompoundConfirmation[\s\S]*input\.checked = true[\s\S]*function fieldsText/);
assert.match(pluginSource, /const selectedIds = new Set\(await showCompoundConfirmation\(changes\)\)/);
assert.match(pluginSource, /if \(location\.textContent\) content\.append\(location\)/);
assert.match(pluginSource, /if \(location\.textContent\) details\.append\(location\)/);
assert.match(pageSource, /id="compatDialog"/);
assert.match(pageSource, /id="compatConfirm"/);
assert.match(pageSource, /id="compatSkip"/);
assert.match(pageSource, /id="restoreNames"/);
assert.match(pageSource, /class="toolbar-actions"[\s\S]*id="clear"[\s\S]*id="restoreNames"/);
assert.match(pageSource, /id="restoreDialog"/);
assert.doesNotMatch(pageSource, /<h1\b/);
assert.match(pageSource, /id="restoreList"/);
assert.match(pageSource, /id="restoreApply"[^>]*disabled/);
assert.match(pluginSource, /input\.checked = canRestore/);
assert.match(pluginSource, /compoundCompat\.inspectBackups/);
assert.match(pluginSource, /compoundCompat\.restoreBackups\([\s\S]*selected,/);
assert.doesNotMatch(pluginSource, /window\.confirm\(t\('thumb\.restoreConfirm'/);
for (const relative of [
'index.html',
'js/compound-compat.js',
'js/instance-coordinator.js',
'js/spine-utils.js',
]) {
assert.equal(
fs.readFileSync(path.join(__dirname, '..', relative), 'utf8'),
fs.readFileSync(path.join(releaseRoot, relative), 'utf8'),
relative + ' must match the release copy',
);
}
for (const file of fs.readdirSync(path.join(__dirname, '..', '_locales'))) {
const relative = path.join('_locales', file);
assert.equal(
fs.readFileSync(path.join(__dirname, '..', relative), 'utf8'),
fs.readFileSync(path.join(releaseRoot, relative), 'utf8'),
relative + ' must match the release copy',
);
}
assert.equal(
pluginSource.replace('const SHOW_DEV_VERSION = true;', 'const SHOW_DEV_VERSION = false;'),
fs.readFileSync(path.join(releaseRoot, 'js', 'plugin.js'), 'utf8'),
'plugin.js may differ only by the development-version switch',
);
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-compat-'));
try {
const userData = path.join(root, '用户 data');
fs.mkdirSync(userData);
const store = compat.createStore(userData);
assert.equal(
store.file,
path.join(userData, 'SpineThumbnailGenerateServer', 'compound-name-backups.json'),
);
const original = {
itemId: 'one',
sourceName: 'hero.atlas.txt',
folderPath: 'Assets/Heroes',
currentName: 'hero.atlas',
currentExt: 'txt',
newName: 'hero',
newExt: 'atlas.txt',
};
store.remember(original);
store.remember({ ...original, newName: 'changed-again', newExt: 'txt' });
assert.equal(store.list().length, 1);
assert.equal(store.list()[0].originalName, 'hero.atlas');
const items = new Map([
['one', { name: 'hero', ext: 'atlas.txt' }],
['changed', { name: 'user-renamed', ext: 'atlas.txt' }],
['failed', { id: 'failed', name: 'failed', ext: 'atlas.txt' }],
]);
for (const itemId of ['changed', 'failed']) {
store.remember({
itemId,
sourceName: itemId + '.atlas.txt',
currentName: itemId + '.atlas',
currentExt: 'txt',
newName: itemId,
newExt: 'atlas.txt',
});
}
for (const itemId of ['ondisk', 'gone']) {
store.remember({
itemId,
sourceName: itemId + '.skel.bytes',
currentName: itemId + '.skel',
currentExt: 'bytes',
newName: itemId,
newExt: 'skel.bytes',
});
}
const inspected = await compat.inspectBackups(
store,
async (itemId) => items.get(itemId),
async (entry) => entry.itemId === 'ondisk',
);
assert.deepEqual(Object.fromEntries(inspected.map((row) => [row.entry.itemId, row.status])), {
one: 'ready',
changed: 'changed',
failed: 'ready',
ondisk: 'pluginApplied',
gone: 'missing',
});
const restored = await compat.restoreBackups(
store,
['one', 'changed', 'failed'],
async (itemId) => items.get(itemId),
async (item) => {
if (item.id === 'failed') throw new Error('save failed');
},
);
assert.deepEqual(restored, { total: 3, success: 1, skipped: 1, failed: 1 });
assert.deepEqual(items.get('one'), { name: 'hero.atlas', ext: 'txt' });
assert.deepEqual(items.get('changed'), { name: 'user-renamed', ext: 'atlas.txt' });
assert.equal(store.list().length, 4);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
console.log('PASS: compound suffix changes require reviewable plans and preserve restorable originals');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,66 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const coordinatorModule = require('../js/instance-coordinator');
const coordinators = [];
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-instance-coordinator-'));
const storage = coordinatorModule.createFileStorage(path.join(root, '用户 data', 'instance-state.json'));
let timestamp = 1000;
let hidden = 0;
const changes = { a: [], b: [], c: [], d: [] };
const make = (id, visible) => {
const coordinator = coordinatorModule.createCoordinator({
storage,
id,
now: () => timestamp,
hide: () => { hidden += 1; },
isVisible: () => visible,
onActiveChanged: (active) => changes[id].push(active),
});
coordinators.push(coordinator);
return coordinator;
};
const hiddenService = make('a', false);
const visibleUi = make('b', true);
const duplicateUi = make('c', true);
hiddenService.start();
assert.equal(hiddenService.isActive(), true);
visibleUi.start();
assert.equal(hiddenService.evaluate(), false);
assert.equal(visibleUi.isActive(), true);
duplicateUi.start();
assert.equal(duplicateUi.isActive(), true, 'the later window takes ownership');
assert.equal(visibleUi.evaluate(), false);
assert.equal(hidden, 1, 'the previous visible window is hidden');
const laterWindow = make('d', false);
laterWindow.start();
assert.equal(laterWindow.isActive(), false, 'a hidden background window must not steal visible UI');
assert.equal(duplicateUi.isActive(), true);
assert.equal(hidden, 1);
timestamp += coordinatorModule.STALE_MS + 1;
assert.equal(laterWindow.evaluate(), true, 'stale owner allows background takeover');
assert.equal(duplicateUi.evaluate(), false);
assert.equal(hidden, 2);
hiddenService.stop();
visibleUi.stop();
duplicateUi.stop();
laterWindow.stop();
console.log('PASS: opened UI takes ownership and background instances respect it');
fs.rmSync(root, { recursive: true, force: true });
})().catch((error) => {
for (const coordinator of coordinators) coordinator.stop();
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,47 @@
'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-mixed-atlas-'));
try {
const images = path.join(root, 'images');
fs.mkdirSync(images);
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
const makeItem = (id, name, body) => {
const directory = path.join(images, id + '.info');
fs.mkdirSync(directory);
fs.writeFileSync(path.join(directory, name), body);
fs.writeFileSync(path.join(directory, 'metadata.json'), JSON.stringify({ folders: [] }));
return path.join(directory, name);
};
const png = makeItem('png', 'hero.png', 'png');
const atlas = makeItem('atlas', 'hero.atlas', [
'hero.png',
'size: 64,64',
'format: RGBA8888',
'filter: Linear,Linear',
'repeat: none',
'',
'hero.jpeg',
'size: 32,32',
'format: RGBA8888',
'filter: Linear,Linear',
'repeat: none',
'',
].join('\n'));
const jpeg = makeItem('jpg', 'hero.jpg', 'jpg');
const assets = utils.discoverAssets(atlas, {
strictStem: true,
searchRoots: [path.dirname(png), path.dirname(atlas), path.dirname(jpeg)],
});
assert.equal(assets.texturePaths['hero.png'], png);
assert.equal(assets.texturePaths['hero.jpeg'], jpeg, 'JPEG atlas page must prefer .jpg over same-stem .png');
assert.equal(utils.textureFormatOf('photo.JPEG'), utils.textureFormatOf('photo.jpg'));
console.log('PASS: mixed PNG/JPEG atlas pages resolve to matching texture formats');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
@@ -0,0 +1,28 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'progress.html'), 'utf8');
const indexHtml = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
const windowSource = fs.readFileSync(path.join(root, 'js', 'progress-window.js'), 'utf8');
const i18nSource = fs.readFileSync(path.join(root, 'js', 'i18n.js'), 'utf8');
const normalizePluginRoot = (pathname) => pathname.replace(/^\/+([A-Za-z]:)/, '$1');
assert.match(windowSource, /minimizable: true/);
assert.match(windowSource, /url: 'progress\.html',/);
assert.doesNotMatch(windowSource, /url: 'progress\.html\?/);
assert.match(i18nSource, /params\.get\('lang'\) \|\| params\.get\('locale'\)/);
for (const source of [html, indexHtml]) {
assert.match(source, /pathname\.replace\(\/\^\\\/\+\(\[A-Za-z\]:\)\/, '\$1'\)/);
}
assert.equal(normalizePluginRoot('//C:/Users/demo/plugin/'), 'C:/Users/demo/plugin/');
assert.equal(normalizePluginRoot('/Users/demo/plugin/'), '/Users/demo/plugin/');
assert.match(html, /addEventListener\('beforeunload'/);
assert.match(html, /function requestStop\(\)[\s\S]*writeControl\('pause'\)[\s\S]*resumeEl\.focus\(\)/);
assert.match(html, /if \(allowClose \|\| finished\) return;[\s\S]*event\.returnValue = false;[\s\S]*requestStop\(\)/);
assert.equal((html.match(/requestStop\(\);/g) || []).length, 2);
assert.equal((html.match(/allowClose = true;[\s\S]{0,80}window\.close\(\)/g) || []).length, 2);
console.log('PASS: 系统关闭与停止按钮共用暂停确认流程');
@@ -0,0 +1,28 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const { cleanupStaleFiles } = require('../js/library-watcher.js');
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-cleanup-'));
const now = Date.now();
const old = path.join(directory, 'spine-thumb-old.webp');
const fresh = path.join(directory, 'spine-thumb-fresh.webp');
const unrelated = path.join(directory, 'other-old.webp');
try {
for (const file of [old, fresh, unrelated]) fs.writeFileSync(file, 'x');
const oldTime = new Date(now - 25 * 60 * 60 * 1000);
fs.utimesSync(old, oldTime, oldTime);
fs.utimesSync(unrelated, oldTime, oldTime);
assert.equal(cleanupStaleFiles(directory, 'spine-thumb-', 24 * 60 * 60 * 1000, now), 1);
assert.equal(fs.existsSync(old), false);
assert.equal(fs.existsSync(fresh), true);
assert.equal(fs.existsSync(unrelated), true);
console.log('PASS: 只清理超过 24 小时的本插件临时文件');
} finally {
fs.rmSync(directory, { recursive: true, force: true });
}
@@ -0,0 +1,211 @@
'use strict';
// 不需要 Eagle 的回归测试。用临时素材库模拟「拖入一批文件」,覆盖:
// 1. 历史素材被忽略、静默之后才触发、只报增量;
// 2. **跨目录配对**——Eagle 会把每个文件拆成独立的 <id>.info 目录,
// 一套资产的骨架 / atlas / 贴图分散在不同目录里,必须能关联起来;
// 3. 本地模块在「页面同时有 window 和 module」时仍然挂到 window 上。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const vm = require('node:vm');
const { createLibraryWatcher, QUIET_MS } = require('../js/library-watcher.js');
const { scanSpineItems } = require('../js/spine-assets.js');
const utils = require('../js/spine-utils.js');
const { withTimeout } = require('../js/renderer.js');
// —— 超时保护:卡住的 Promise 必须被拒绝,否则队列会被永久堵死 ——
(async () => {
assert.equal(await withTimeout(Promise.resolve('ok'), 1000, '测试'), 'ok', '正常 Promise 应原样返回');
await assert.rejects(
() => withTimeout(new Promise(() => {}), 30, '测试'),
/超时/,
'永不 resolve 的 Promise 必须被超时拒绝',
);
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
// —— 回归:Eagle 插件页面里也存在 module 对象,本地模块必须优先挂 window ——
const pageSandbox = {
require, console, setTimeout, clearTimeout, setInterval, clearInterval,
window: {}, module: { exports: {} },
document: { getElementById: () => null, createElement: () => ({}) },
};
pageSandbox.self = pageSandbox;
vm.createContext(pageSandbox);
for (const file of ['library-watcher.js', 'spine-assets.js', 'spine-utils.js']) {
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', file), 'utf8'), pageSandbox, { filename: file });
}
assert.ok(pageSandbox.window.SpineThumbLibraryWatcher, '存在 module 对象时也必须挂到 window');
assert.ok(pageSandbox.window.SpineThumbSpineAssets, '存在 module 对象时也必须挂到 window');
assert.ok(pageSandbox.window.SpineUtils, '存在 module 对象时也必须挂到 window');
// —— 复合后缀:Eagle 只按最后一个点拆 name/ext,atlas.txt 会被当成纯文本素材 ——
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.atlas.txt'), { name: 'EmoticonSpine', ext: 'atlas.txt' });
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.skel.bytes'), { name: 'EmoticonSpine', ext: 'skel.bytes' });
assert.equal(
utils.splitCompoundName('hero.skel'), null,
'普通后缀不动(否则会把 Eagle 里显式支持的类型也拆坏)',
);
assert.equal(utils.splitCompoundName('notes.txt'), null, '普通 txt 不是 Spine 素材,不碰');
assert.equal(utils.splitCompoundName('.atlas.txt'), null, '没有主干名就不改');
// —— 搭一个仿真素材库:<root>/images/<id>.info/<file> ——
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-server-'));
const imagesDir = path.join(root, 'images');
fs.mkdirSync(imagesDir, { recursive: true });
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
const MINIMAL_ATLAS = ['hero.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
// 真实 Spine JSON 的最小形态:必须有 skeleton.spine 版本字段才会被判为骨骼数据。
const MINIMAL_SPINE_JSON = JSON.stringify({ skeleton: { spine: '3.8.55' }, bones: [{ name: 'root' }] });
const LEGACY_ATLAS = ['legacy.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
const ATLAS_FOR_DUP = ['dup.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
const ATLAS_FOLD_A = ['dup2.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
function makeItem(id, files, folders, deleted) {
const dir = path.join(imagesDir, id + '.info');
fs.mkdirSync(dir, { recursive: true });
// Eagle 的每个 item 目录都必须有 metadata.json,spine-utils 靠它判断这是不是一个 item。
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({
id, name: id, folders: folders || [], isDeleted: !!deleted,
}));
for (const [name, content] of Object.entries(files)) {
fs.writeFileSync(path.join(dir, name), content === true ? 'x' : content);
}
return dir;
}
// 兜底超时,避免配对逻辑异常时测试挂住不退出。
setTimeout(() => {
console.error('测试超时(60 秒)');
process.exit(1);
}, 60000).unref();
// 历史素材:监听建立之前就存在,不能处理。
makeItem('old-skel', { 'spineboy.skel': true });
makeItem('old-atlas', { 'spineboy.atlas': MINIMAL_ATLAS });
makeItem('old-tex', { 'spineboy.png': true });
// 历史里还留着一套 legacy 资产的 atlas + 贴图,用来验证新增素材不能借用它们。
makeItem('old-legacy-atlas', { 'legacy.atlas': LEGACY_ATLAS });
makeItem('old-legacy-tex', { 'legacy.png': true });
const batches = [];
const watcher = createLibraryWatcher({ onBatch: (batch) => batches.push(batch), onLog: () => {} });
assert.equal(watcher.start(root), true, '监听应当建立成功');
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
(async () => {
// 模拟一次拖入:一套分散在三处的完整资产 + 一张普通图片 + 只有骨架的残缺资产。
makeItem('new-skel', { 'hero.skel': true });
makeItem('new-atlas', { 'hero.atlas': MINIMAL_ATLAS });
makeItem('new-tex', { 'hero.png': true });
makeItem('new-photo', { 'photo.png': true });
makeItem('new-broken', { 'lonely.skel': true });
makeItem('new-json', { 'notes.json': '{"hello":"world"}' });
makeItem('new-skeljson', { 'beast.json': MINIMAL_SPINE_JSON });
// 只有骨架,配套的 legacy.atlas / legacy.png 都在历史素材里 → 不该跨批次配上
makeItem('new-orphan', { 'legacy.skel': true });
await wait(QUIET_MS + 1200);
assert.equal(batches.length, 1, '静默之后应当只触发一批');
assert.equal(batches[0].reason, 'quiet');
assert.deepEqual(
batches[0].added.slice().sort(),
['new-atlas.info', 'new-broken.info', 'new-json.info', 'new-orphan.info', 'new-photo.info', 'new-skel.info', 'new-skeljson.info', 'new-tex.info'],
);
const spine = await scanSpineItems(imagesDir, batches[0].added, utils);
const names = spine.map((item) => item.dirName).sort();
assert.deepEqual(
names,
['new-atlas.info', 'new-broken.info', 'new-orphan.info', 'new-skel.info', 'new-skeljson.info'],
'普通图片和普通 JSON 不算 Spine 资产,真实的 Spine JSON 要算',
);
// 关键:骨架在 new-skel.info 里,atlas 在 new-atlas.info 里,贴图在 new-tex.info 里,
// 三者必须被判成同一套完整资产。
const hero = spine.find((item) => item.dirName === 'new-skel.info');
assert.equal(hero.complete, true, '跨目录的三件套必须配成完整资产');
// 日志要显示 Eagle 里的文件名和文件夹路径,扫描结果必须带上这两项。
assert.equal(hero.entryName, 'hero.skel', '要带上 item 目录里的入口文件名');
assert.deepEqual(hero.folders, [], '未归入文件夹的素材 folders 为空数组');
assert.equal(path.basename(hero.atlasPath || ''), 'hero.atlas', '要找到另一个目录里的 atlas');
assert.equal(path.basename(hero.texturePaths['hero.png'] || ''), 'hero.png', '要找到 atlas 引用的贴图');
assert.equal(hero.pages, 1);
assert.equal(hero.missingTextures, 0);
const broken = spine.find((item) => item.dirName === 'new-broken.info');
assert.equal(broken.complete, false, '只有骨架不算完整');
assert.equal(broken.missingTextures, 0, '没有 atlas 时缺图计数为 0');
// 配对范围只限本次新增:历史素材里有 legacy.atlas 和 legacy.png,
// 但新导入的 legacy.skel 不能去借用它们。
const orphan = spine.find((item) => item.dirName === 'new-orphan.info');
assert.equal(orphan.complete, false, '新增素材不能借用历史素材的配套文件');
assert.equal(orphan.atlasPath, null, '不应该跨批次找到历史 atlas');
// 第二批:只报新增的那个。
makeItem('new-skel2', { 'boss.skel': true });
await wait(QUIET_MS + 1200);
assert.equal(batches.length, 2, '第二批应当再触发一次');
assert.deepEqual(batches[1].added, ['new-skel2.info']);
// 分批导入(模拟 Eagle 的「重复添加提示」把一次导入拆成两段):
// 先只写入骨架,配套的 atlas / 贴图过一会儿才写入。
makeItem('dup-skel', { 'dup.skel': true });
await wait(QUIET_MS + 1200);
const firstPass = await scanSpineItems(imagesDir, ['dup-skel.info'], utils);
assert.equal(firstPass[0].complete, false, '只有骨架时不算完整');
makeItem('dup-atlas', { 'dup.atlas': ATLAS_FOR_DUP });
makeItem('dup-tex', { 'dup.png': true });
await wait(QUIET_MS + 1200);
// plugin.js 的「待观察」会把上一批的目录和新一批一起送进来,这里模拟同样的调用。
const secondPass = await scanSpineItems(imagesDir, ['dup-skel.info', 'dup-atlas.info', 'dup-tex.info'], utils);
const dup = secondPass.find((item) => item.dirName === 'dup-skel.info');
assert.equal(dup.complete, true, '配套补齐后应当能配成完整资产');
assert.equal(dup.missingTextures, 0);
// 同名素材分属不同 Eagle 文件夹时不能互相配对:文件夹 A 里有完整三件套,
// 文件夹 B 里只有一个同名骨架,B 不应该借用 A 的 atlas。
makeItem('fold-a-skel', { 'dup2.skel': true }, ['folderA']);
makeItem('fold-a-atlas', { 'dup2.atlas': ATLAS_FOLD_A }, ['folderA']);
makeItem('fold-a-tex', { 'dup2.png': true }, ['folderA']);
makeItem('fold-b-skel', { 'dup2.skel': true }, ['folderB']);
const crossFolder = await scanSpineItems(
imagesDir,
['fold-a-skel.info', 'fold-a-atlas.info', 'fold-a-tex.info', 'fold-b-skel.info'],
utils,
);
const inA = crossFolder.find((item) => item.dirName === 'fold-a-skel.info');
assert.equal(inA.complete, true, '同一 Eagle 文件夹里的三件套要能配上');
assert.deepEqual(inA.folders, ['folderA'], '要带上 Eagle 文件夹 id,日志才能还原路径');
assert.equal(path.basename(inA.atlasPath), 'dup2.atlas');
const inB = crossFolder.find((item) => item.dirName === 'fold-b-skel.info');
assert.equal(inB.complete, false, '不能借用别的 Eagle 文件夹里的同名 atlas');
assert.equal(inB.atlasPath, null);
// 回收站素材:Eagle 删除只是把 metadata 标成 isDeleted,目录和文件都还在磁盘上。
// 三件套齐全也不能进结果,否则会给回收站里的素材渲染缩略图。
makeItem('trash-skel', { 'gone.skel': true }, [], true);
makeItem('trash-atlas', { 'gone.atlas': ATLAS_FOR_DUP }, [], true);
makeItem('trash-tex', { 'gone.png': true }, [], true);
const trashed = await scanSpineItems(
imagesDir,
['trash-skel.info', 'trash-atlas.info', 'trash-tex.info'],
utils,
);
assert.deepEqual(trashed, [], '回收站里的素材不能被当成待生成资产');
watcher.stop();
fs.rmSync(root, { recursive: true, force: true });
console.log('PASS: 静默检测、历史忽略、增量识别、跨目录配对、回收站过滤、浏览器加载方式');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+71 -3
View File
@@ -18,12 +18,60 @@
"pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden", "pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden",
"background": "Hintergrund", "background": "Hintergrund",
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt", "backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
"thumbnail": "Vorschaubild", "thumbnail": "Als aktuelles Vorschaubild setzen",
"thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen", "thumbnailTitle": "Linksklick erstellt ein statisches Vorschaubild, Rechtsklick ein animiertes Vorschaubild",
"animatedThumbnailTitle": "Animiertes Vorschaubild erzeugen",
"animatedThumbnailAttention": "Achtung:",
"animatedThumbnailNote": "Das erzeugte Vorschaubild ersetzt das vorhandene. Dieser Vorgang kann nicht rückgängig gemacht werden und ",
"animatedThumbnailDanger": "das ursprüngliche Vorschaubild kann nicht wiederhergestellt werden",
"animatedThumbnailSizeNote": ". Damit Animationsdaten nicht zu groß werden, wird die Datei unter die eingestellte Größe reduziert.",
"animatedFormat": "Ausgabeformat",
"animatedRange": "Animationsbereich",
"animatedStart": "Start",
"animatedEnd": "Ende",
"framesUnit": "Frames",
"animatedQuality": "Vorschaubildqualität",
"animatedMaxSize": "Vorschaubildgröße",
"animatedInvalidSize": "Geben Sie eine ganze Zahl zwischen 10 und 1024 KB ein",
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
"animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.",
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
"animatedPickTitle": "Miniaturbild auswählen",
"animatedPickHint": "Alle Ergebnisse behalten eine {size}-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
"animatedPickConfirm": "Bestätigen",
"animatedPriority": "Priorität",
"animatedCanvas": "Ausgabegröße",
"priorityResolution": "Auflösung zuerst",
"priorityFramerate": "Bildrate zuerst",
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
"qualityLow": "Niedrig",
"qualityMedium": "Mittel",
"qualityHigh": "Hoch",
"animatedConfirm": "Erzeugen und ersetzen",
"animatedInvalidRange": "Geben Sie einen gültigen ganzzahligen Framebereich zwischen 0 und {duration} ein",
"animatedGenerating": "Animiertes Vorschaubild wird erzeugt {current}/{total}…",
"animatedTooLarge": "Das animierte Vorschaubild ist größer als 50 MB. Verkürzen Sie den Bereich oder wählen Sie eine niedrigere Qualität.",
"animatedThumbUpdated": "Animiertes Vorschaubild aktualisiert · {frames} Frames · {size} KB",
"groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen",
"groupThumbnailTitle": "Linksklick erstellt ein statisches Vorschaubild, Rechtsklick ein animiertes Vorschaubild",
"groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen",
"groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.",
"groupDialogNote": "Nur markierte verknüpfte Ressourcen erhalten Vorschaubilder. Bilder und Ressourcen mit eigenen Vorschaubildern sind standardmäßig abgewählt. Werden letztere ausgewählt, wird das vorhandene Vorschaubild ersetzt und kann vom Plugin nicht automatisch wiederhergestellt werden.",
"cancel": "Abbrechen",
"groupConfirm": "Erstellen",
"groupWillGenerate": "Wird erstellt",
"groupNotSelected": "Nicht ausgewählt",
"groupSkipCustom": "Übersprungen: eigenes Vorschaubild vorhanden",
"groupUnavailable": "Übersprungen: nicht verifizierbar",
"groupNoTargets": "In der aktuellen Ressourcengruppe gibt es keine geeigneten Vorschaubilder.",
"groupGenerating": "Vorschaubilder werden erstellt {current}/{total}…",
"groupApplyNotConfirmed": "Eagle hat die Anwendung des Vorschaubilds nicht bestätigt",
"groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.",
"versionTitle": "Spine-Version", "versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden", "reloadTitle": "Ordner neu laden",
"animationList": "Animationsliste", "animationList": "Animationsliste",
"animations": "Animationen", "animations": "Animationen",
"setupPose": "Setup Pose",
"errorTitle": "Vorschau nicht möglich", "errorTitle": "Vorschau nicht möglich",
"durationPlaceholder": "Dauer --", "durationPlaceholder": "Dauer --",
"keyframesLegend": "Blaue Marken: Keyframes", "keyframesLegend": "Blaue Marken: Keyframes",
@@ -54,6 +102,8 @@
"apiHttp": "Eagle-API meldete HTTP {status}", "apiHttp": "Eagle-API meldete HTTP {status}",
"noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden", "noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden",
"noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln", "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}", "thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}",
"imageLoadFailed": "Bild konnte nicht geladen werden", "imageLoadFailed": "Bild konnte nicht geladen werden",
"settingsNotReady": "Asset-Einstellungen sind noch nicht bereit", "settingsNotReady": "Asset-Einstellungen sind noch nicht bereit",
@@ -74,6 +124,24 @@
"loadFailed": "Laden fehlgeschlagen", "loadFailed": "Laden fehlgeschlagen",
"notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.", "notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.",
"truncated256": "\n\n(nur die ersten 256 KB werden angezeigt)", "truncated256": "\n\n(nur die ersten 256 KB werden angezeigt)",
"truncated1m": "\n\n(große Datei; nur das erste 1 MB wird angezeigt)" "suffix": "Suffix",
"suffixTitle": "Dateinamens-Suffixe werden beim Abgleich ignoriert, daher wird hero-mesh.skel mit hero.atlas verknüpft",
"suffixManage": "Suffixe verwalten…",
"suffixAdd": "+ Suffix hinzufügen…",
"suffixDefaults": "Integrierte Suffixe",
"suffixCustom": "Eigene Suffixe (Auswahl entfernt)",
"suffixAddTitle": "Suffix hinzufügen",
"suffixAddNote": "Suffixe werden beim Dateiabgleich ignoriert: Nach dem Hinzufügen von hover wird hero-hover.skel mit hero.atlas verknüpft. Nur Buchstaben, Ziffern, - und _, maximal 50 Zeichen.",
"suffixPlaceholder": "z. B. hover",
"suffixInvalid": "Nur Buchstaben, Ziffern, - und _, beginnend mit Buchstabe oder Ziffer",
"suffixDuplicate": "Dieses Suffix existiert bereits",
"suffixConfirm": "Hinzufügen",
"suffixRemoveTitle": "Suffix entfernen",
"suffixRemoveNote": "Nach dem Entfernen von „{name}“ werden Dateien mit diesem Suffix nicht mehr automatisch verknüpft.",
"suffixRemove": "Entfernen",
"suffixSaveFailed": "Speichern der Suffix-Liste fehlgeschlagen: {error}",
"truncated1m": "\n\n(große Datei; nur das erste 1 MB wird angezeigt)",
"groupWillReplaceCustom": "Ersetzt vorhandenes eigenes Vorschaubild",
"groupCustomNotSelected": "Eigenes Vorschaubild vorhanden (nicht ausgewählt)"
} }
} }
+70 -2
View File
@@ -18,12 +18,62 @@
"pmaTitle": "For textures exported with Premultiplied Alpha (PMA)", "pmaTitle": "For textures exported with Premultiplied Alpha (PMA)",
"background": "Background", "background": "Background",
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes", "backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
"thumbnail": "Set thumbnail", "thumbnail": "Set current item thumbnail",
"thumbnailTitle": "Generate a 400×400 thumbnail from the current frame", "thumbnailTitle": "Left-click to generate a static thumbnail; right-click to generate an animated thumbnail",
"animatedThumbnailTitle": "Generate animated thumbnail",
"animatedThumbnailAttention": "Warning:",
"animatedThumbnailNote": "The generated thumbnail replaces the existing thumbnail. This cannot be undone and ",
"animatedThumbnailDanger": "the original thumbnail cannot be restored",
"animatedThumbnailSizeNote": ". To prevent animated thumbnail data from becoming too large, the file will be reduced below the configured size.",
"animatedFormat": "Output format",
"animatedRange": "Animation range",
"animatedStart": "Start",
"animatedEnd": "End",
"framesUnit": "frames",
"animatedQuality": "Thumbnail quality",
"animatedMaxSize": "Thumbnail size",
"animatedInvalidSize": "Enter a whole number between 10 and 1024 KB",
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
"animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.",
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
"animatedPickTitle": "Pick a thumbnail",
"animatedPickHint": "Every result keeps a {size} canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
"animatedPickConfirm": "Confirm",
"animatedPriority": "Priority",
"animatedCanvas": "Canvas size",
"priorityResolution": "Resolution first",
"priorityFramerate": "Frame rate first",
"priorityBalanced": "Alternate resolution and frame rate",
"qualityLow": "Low",
"qualityMedium": "Medium",
"qualityHigh": "High",
"animatedConfirm": "Generate and replace",
"animatedInvalidRange": "Enter a valid whole-frame range between 0 and {duration}",
"animatedGenerating": "Generating animated thumbnail {current}/{total}…",
"animatedTooLarge": "The animated thumbnail exceeds 50 MB. Shorten the range or select a lower quality.",
"animatedThumbUpdated": "Animated thumbnail updated · {frames} frames · {size} KB",
"groupThumbnail": "Generate thumbnails for related assets",
"groupThumbnailTitle": "Left-click to generate a static thumbnail; right-click to generate an animated thumbnail",
"groupDialogTitle": "Generate thumbnails for related assets",
"groupDialogSummary": "Generate {generate} item(s) and skip {skip} item(s).",
"groupDialogNote": "Only checked related assets will receive thumbnails. Images and assets with custom thumbnails are unchecked by default. Selecting the latter replaces the existing thumbnail, which the plugin cannot automatically restore.",
"cancel": "Cancel",
"groupConfirm": "Generate",
"groupWillGenerate": "Will generate",
"groupWillReplaceCustom": "Will replace existing custom thumbnail",
"groupCustomNotSelected": "Custom thumbnail exists (not selected)",
"groupNotSelected": "Not selected",
"groupSkipCustom": "Skipped: custom thumbnail exists",
"groupUnavailable": "Skipped: could not verify",
"groupNoTargets": "There are no eligible thumbnails in the current asset group.",
"groupGenerating": "Generating asset-group thumbnails {current}/{total}…",
"groupApplyNotConfirmed": "Eagle did not confirm that the thumbnail was applied",
"groupComplete": "Asset-group thumbnails complete: {success} succeeded, {skip} skipped, {failed} failed.",
"versionTitle": "Spine version", "versionTitle": "Spine version",
"reloadTitle": "Reload folder", "reloadTitle": "Reload folder",
"animationList": "Animation list", "animationList": "Animation list",
"animations": "Animations", "animations": "Animations",
"setupPose": "Setup Pose",
"errorTitle": "Cannot preview", "errorTitle": "Cannot preview",
"durationPlaceholder": "Duration --", "durationPlaceholder": "Duration --",
"keyframesLegend": "Blue ticks: keyframes", "keyframesLegend": "Blue ticks: keyframes",
@@ -54,6 +104,8 @@
"apiHttp": "Eagle API returned HTTP {status}", "apiHttp": "Eagle API returned HTTP {status}",
"noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail", "noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail",
"noItemId": "Cannot determine the Eagle item ID from the asset path", "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}", "thumbnailFailed": "Failed to generate the thumbnail: {error}",
"imageLoadFailed": "Failed to load the image", "imageLoadFailed": "Failed to load the image",
"settingsNotReady": "Asset settings are not ready", "settingsNotReady": "Asset settings are not ready",
@@ -74,6 +126,22 @@
"loadFailed": "Failed to load", "loadFailed": "Failed to load",
"notSpineText": "This file is not a .atlas.txt or .skel.bytes Spine asset.", "notSpineText": "This file is not a .atlas.txt or .skel.bytes Spine asset.",
"truncated256": "\n\n(only the first 256 KB is shown)", "truncated256": "\n\n(only the first 256 KB is shown)",
"suffix": "Suffix",
"suffixTitle": "Filename suffixes are ignored when matching, so hero-mesh.skel pairs with hero.atlas",
"suffixManage": "Manage suffixes…",
"suffixAdd": "+ Add suffix…",
"suffixDefaults": "Built-in suffixes",
"suffixCustom": "Custom suffixes (select to remove)",
"suffixAddTitle": "Add a suffix",
"suffixAddNote": "Suffixes are ignored when matching filenames: after adding hover, hero-hover.skel pairs with hero.atlas. Letters, digits, - and _ only, up to 50 characters.",
"suffixPlaceholder": "e.g. hover",
"suffixInvalid": "Use letters, digits, - and _ only, and start with a letter or digit",
"suffixDuplicate": "That suffix already exists",
"suffixConfirm": "Add",
"suffixRemoveTitle": "Remove a suffix",
"suffixRemoveNote": "After removing “{name}”, files carrying that suffix will no longer be paired automatically.",
"suffixRemove": "Remove",
"suffixSaveFailed": "Saving the suffix list failed: {error}",
"truncated1m": "\n\n(large file, only the first 1 MB is shown)" "truncated1m": "\n\n(large file, only the first 1 MB is shown)"
} }
} }
+71 -3
View File
@@ -18,12 +18,60 @@
"pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)", "pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)",
"background": "Fondo", "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", "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", "thumbnail": "Usar como miniatura actual",
"thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual", "thumbnailTitle": "Clic izquierdo para generar una miniatura estática; clic derecho para generar una miniatura animada",
"animatedThumbnailTitle": "Generar miniatura animada",
"animatedThumbnailAttention": "Atención:",
"animatedThumbnailNote": "La miniatura generada reemplaza la existente. Esta acción no se puede deshacer y ",
"animatedThumbnailDanger": "la miniatura original no se puede restaurar",
"animatedThumbnailSizeNote": ". Para evitar datos de animación demasiado grandes, el archivo se ajustará por debajo del tamaño configurado.",
"animatedFormat": "Formato de salida",
"animatedRange": "Rango de animación",
"animatedStart": "Inicio",
"animatedEnd": "Fin",
"framesUnit": "fotogramas",
"animatedQuality": "Calidad de miniatura",
"animatedMaxSize": "Tamaño de miniatura",
"animatedInvalidSize": "Introduce un número entero entre 10 y 1024 KB",
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
"animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.",
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
"animatedPickTitle": "Elige una miniatura",
"animatedPickHint": "Todos los resultados mantienen un lienzo de {size}; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
"animatedPickConfirm": "Confirmar",
"animatedPriority": "Prioridad",
"animatedCanvas": "Tamaño del lienzo",
"priorityResolution": "Resolución primero",
"priorityFramerate": "Frecuencia primero",
"priorityBalanced": "Alternar resolución y frecuencia",
"qualityLow": "Baja",
"qualityMedium": "Media",
"qualityHigh": "Alta",
"animatedConfirm": "Generar y reemplazar",
"animatedInvalidRange": "Introduce un rango de fotogramas enteros entre 0 y {duration}",
"animatedGenerating": "Generando miniatura animada {current}/{total}…",
"animatedTooLarge": "La miniatura animada supera los 50 MB. Acorta el rango o elige una calidad inferior.",
"animatedThumbUpdated": "Miniatura animada actualizada · {frames} fotogramas · {size} KB",
"groupThumbnail": "Generar miniaturas de recursos relacionados",
"groupThumbnailTitle": "Clic izquierdo para generar una miniatura estática; clic derecho para generar una miniatura animada",
"groupDialogTitle": "Generar miniaturas de recursos relacionados",
"groupDialogSummary": "Se generarán {generate} y se omitirán {skip}.",
"groupDialogNote": "Solo se generarán miniaturas para los recursos relacionados marcados. Las imágenes y los recursos con miniaturas personalizadas no están marcados por defecto. Si seleccionas estos últimos, se reemplazará la miniatura existente y el complemento no podrá restaurarla automáticamente.",
"cancel": "Cancelar",
"groupConfirm": "Generar",
"groupWillGenerate": "Se generará",
"groupNotSelected": "No seleccionado",
"groupSkipCustom": "Omitido: ya tiene miniatura personalizada",
"groupUnavailable": "Omitido: no se pudo verificar",
"groupNoTargets": "No hay miniaturas disponibles para generar en el grupo actual.",
"groupGenerating": "Generando miniaturas {current}/{total}…",
"groupApplyNotConfirmed": "Eagle no confirmó que se aplicara la miniatura",
"groupComplete": "Completado: {success} correctas, {skip} omitidas y {failed} fallidas.",
"versionTitle": "Versión de Spine", "versionTitle": "Versión de Spine",
"reloadTitle": "Recargar carpeta", "reloadTitle": "Recargar carpeta",
"animationList": "Lista de animaciones", "animationList": "Lista de animaciones",
"animations": "Animaciones", "animations": "Animaciones",
"setupPose": "Setup Pose",
"errorTitle": "No se puede previsualizar", "errorTitle": "No se puede previsualizar",
"durationPlaceholder": "Duración --", "durationPlaceholder": "Duración --",
"keyframesLegend": "Marcas azules: fotogramas clave", "keyframesLegend": "Marcas azules: fotogramas clave",
@@ -54,6 +102,8 @@
"apiHttp": "La API de Eagle devolvió HTTP {status}", "apiHttp": "La API de Eagle devolvió HTTP {status}",
"noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura", "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", "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}", "thumbnailFailed": "No se pudo generar la miniatura: {error}",
"imageLoadFailed": "No se pudo cargar la imagen", "imageLoadFailed": "No se pudo cargar la imagen",
"settingsNotReady": "La configuración del recurso aún no está lista", "settingsNotReady": "La configuración del recurso aún no está lista",
@@ -74,6 +124,24 @@
"loadFailed": "Error al cargar", "loadFailed": "Error al cargar",
"notSpineText": "Este archivo no es un recurso Spine en formato .atlas.txt o .skel.bytes.", "notSpineText": "Este archivo no es un recurso Spine en formato .atlas.txt o .skel.bytes.",
"truncated256": "\n\n(solo se muestran los primeros 256 KB)", "truncated256": "\n\n(solo se muestran los primeros 256 KB)",
"truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)" "suffix": "Sufijo",
"suffixTitle": "Los sufijos del nombre se ignoran al emparejar, de modo que hero-mesh.skel se une con hero.atlas",
"suffixManage": "Gestionar sufijos…",
"suffixAdd": "+ Añadir sufijo…",
"suffixDefaults": "Sufijos integrados",
"suffixCustom": "Sufijos personalizados (selecciona para quitar)",
"suffixAddTitle": "Añadir un sufijo",
"suffixAddNote": "Los sufijos se ignoran al emparejar nombres: al añadir hover, hero-hover.skel se empareja con hero.atlas. Solo letras, dígitos, - y _, hasta 50 caracteres.",
"suffixPlaceholder": "p. ej. hover",
"suffixInvalid": "Usa solo letras, dígitos, - y _, y empieza por letra o dígito",
"suffixDuplicate": "Ese sufijo ya existe",
"suffixConfirm": "Añadir",
"suffixRemoveTitle": "Quitar un sufijo",
"suffixRemoveNote": "Tras quitar «{name}», los archivos con ese sufijo dejarán de emparejarse automáticamente.",
"suffixRemove": "Quitar",
"suffixSaveFailed": "No se pudo guardar la lista de sufijos: {error}",
"truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)",
"groupWillReplaceCustom": "Reemplazará la miniatura personalizada",
"groupCustomNotSelected": "Tiene miniatura personalizada (no seleccionado)"
} }
} }
+71 -3
View File
@@ -18,12 +18,60 @@
"pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用", "pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用",
"background": "背景", "background": "背景",
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "サムネイル生成", "thumbnail": "現在の項目のサムネイルに設定",
"thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成", "thumbnailTitle": "左クリックで静止サムネイルを生成、右クリックで動的サムネイルを生成",
"animatedThumbnailTitle": "アニメーションサムネイルを生成",
"animatedThumbnailAttention": "注意:",
"animatedThumbnailNote": "生成されたサムネイルは既存のサムネイルを置き換えます。この操作は元に戻せず、",
"animatedThumbnailDanger": "元のサムネイルは復元できません",
"animatedThumbnailSizeNote": "。データが大きくなりすぎないよう、ファイルは設定サイズ以下に調整されます。",
"animatedFormat": "出力形式",
"animatedRange": "アニメーション範囲",
"animatedStart": "開始",
"animatedEnd": "終了",
"framesUnit": "フレーム",
"animatedQuality": "サムネイル品質",
"animatedMaxSize": "サムネイルサイズ",
"animatedInvalidSize": "10 から 1024 KB までの整数を入力してください",
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
"animatedPickTitle": "サムネイルを選択",
"animatedPickHint": "すべての結果は {size} のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
"animatedPickConfirm": "確認",
"animatedPriority": "優先度",
"animatedCanvas": "出力サイズ",
"priorityResolution": "解像度優先",
"priorityFramerate": "フレームレート優先",
"priorityBalanced": "解像度とフレームレートを交互に",
"qualityLow": "低",
"qualityMedium": "中",
"qualityHigh": "高",
"animatedConfirm": "生成して置き換え",
"animatedInvalidRange": "0 から {duration} フレームまでの有効な整数フレーム範囲を入力してください",
"animatedGenerating": "アニメーションサムネイルを生成中 {current}/{total}…",
"animatedTooLarge": "アニメーションサムネイルが 50 MB を超えます。範囲を短くするか、低い品質を選択してください。",
"animatedThumbUpdated": "アニメーションサムネイルを更新 · {frames} フレーム · {size} KB",
"groupThumbnail": "関連アセットのサムネイルを生成",
"groupThumbnailTitle": "左クリックで静止サムネイルを生成、右クリックで動的サムネイルを生成",
"groupDialogTitle": "関連アセットのサムネイルを生成",
"groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。",
"groupDialogNote": "チェックした関連アセットだけにサムネイルを生成します。画像とカスタムサムネイルがあるアセットは初期状態で未選択です。後者を選ぶと既存のサムネイルが置き換えられ、プラグインでは自動復元できません。",
"cancel": "キャンセル",
"groupConfirm": "生成",
"groupWillGenerate": "生成対象",
"groupNotSelected": "未選択",
"groupSkipCustom": "スキップ:カスタムサムネイルあり",
"groupUnavailable": "スキップ:検証できません",
"groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。",
"groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle でサムネイルの適用を確認できませんでした",
"groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。",
"versionTitle": "Spine バージョン", "versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み", "reloadTitle": "フォルダーを再読み込み",
"animationList": "アニメーション一覧", "animationList": "アニメーション一覧",
"animations": "アニメーション", "animations": "アニメーション",
"setupPose": "Setup Pose",
"errorTitle": "プレビューできません", "errorTitle": "プレビューできません",
"durationPlaceholder": "長さ --", "durationPlaceholder": "長さ --",
"keyframesLegend": "青い線:キーフレーム", "keyframesLegend": "青い線:キーフレーム",
@@ -54,6 +102,8 @@
"apiHttp": "Eagle API が HTTP {status} を返しました", "apiHttp": "Eagle API が HTTP {status} を返しました",
"noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません", "noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません",
"noItemId": "素材パスから Eagle の項目 ID を特定できません", "noItemId": "素材パスから Eagle の項目 ID を特定できません",
"itemMismatch": "プレビューファイルと Eagle の項目情報が一致しないため、サムネイルの更新を中止しました",
"replaceCustomThumbnailConfirm": "この項目には既にカスタムサムネイルがあります。続行すると置き換えられ、以前の画像をプラグインで自動復元できません。続行しますか?",
"thumbnailFailed": "サムネイルの生成に失敗しました:{error}", "thumbnailFailed": "サムネイルの生成に失敗しました:{error}",
"imageLoadFailed": "画像の読み込みに失敗しました", "imageLoadFailed": "画像の読み込みに失敗しました",
"settingsNotReady": "素材の設定がまだ準備できていません", "settingsNotReady": "素材の設定がまだ準備できていません",
@@ -74,6 +124,24 @@
"loadFailed": "読み込みに失敗しました", "loadFailed": "読み込みに失敗しました",
"notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。", "notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。",
"truncated256": "\n\n(先頭 256 KB のみ表示)", "truncated256": "\n\n(先頭 256 KB のみ表示)",
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)" "suffix": "接尾辞",
"suffixTitle": "ファイル名の接尾辞は照合時に無視され、hero-mesh.skel と hero.atlas が同じセットとして扱われます",
"suffixManage": "接尾辞を管理…",
"suffixAdd": "+ 接尾辞を追加…",
"suffixDefaults": "組み込みの接尾辞",
"suffixCustom": "カスタム接尾辞(選択で削除)",
"suffixAddTitle": "接尾辞を追加",
"suffixAddNote": "接尾辞はファイル名の照合時に無視されます。hover を追加すると hero-hover.skel は hero.atlas と同じセットになります。使用できるのは英数字と - _ のみ、最大 50 文字です。",
"suffixPlaceholder": "例: hover",
"suffixInvalid": "英数字と - _ のみ使用でき、先頭は英数字である必要があります",
"suffixDuplicate": "その接尾辞は既に存在します",
"suffixConfirm": "追加",
"suffixRemoveTitle": "接尾辞を削除",
"suffixRemoveNote": "「{name}」を削除すると、その接尾辞を含むファイルは自動照合されなくなります。",
"suffixRemove": "削除",
"suffixSaveFailed": "接尾辞設定の保存に失敗しました: {error}",
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)",
"groupWillReplaceCustom": "既存のカスタムサムネイルを置き換えます",
"groupCustomNotSelected": "カスタムサムネイルあり(未選択)"
} }
} }
+71 -3
View File
@@ -18,12 +18,60 @@
"pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용", "pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용",
"background": "배경", "background": "배경",
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "썸네일 생성", "thumbnail": "현재 항목 썸네일로 설정",
"thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성", "thumbnailTitle": "왼쪽 클릭으로 정적 썸네일 생성, 오른쪽 클릭으로 동적 썸네일 생성",
"animatedThumbnailTitle": "애니메이션 썸네일 생성",
"animatedThumbnailAttention": "주의:",
"animatedThumbnailNote": "생성된 썸네일은 기존 썸네일을 교체합니다. 이 작업은 취소할 수 없으며 ",
"animatedThumbnailDanger": "기존 썸네일을 복원할 수 없습니다",
"animatedThumbnailSizeNote": ". 데이터가 너무 커지지 않도록 파일은 설정한 크기 이하로 조정됩니다.",
"animatedFormat": "출력 형식",
"animatedRange": "애니메이션 범위",
"animatedStart": "시작",
"animatedEnd": "종료",
"framesUnit": "프레임",
"animatedQuality": "썸네일 품질",
"animatedMaxSize": "썸네일 크기",
"animatedInvalidSize": "10~1024KB 사이의 정수를 입력하세요",
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
"animatedPickTitle": "썸네일 선택",
"animatedPickHint": "모든 결과의 캔버스는 {size}이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
"animatedPickConfirm": "확인",
"animatedPriority": "우선 순위",
"animatedCanvas": "출력 크기",
"priorityResolution": "해상도 우선",
"priorityFramerate": "프레임률 우선",
"priorityBalanced": "해상도와 프레임률 교차",
"qualityLow": "낮음",
"qualityMedium": "보통",
"qualityHigh": "높음",
"animatedConfirm": "생성 및 교체",
"animatedInvalidRange": "0에서 {duration}프레임 사이의 올바른 정수 프레임 범위를 입력하세요",
"animatedGenerating": "애니메이션 썸네일 생성 중 {current}/{total}…",
"animatedTooLarge": "애니메이션 썸네일이 50MB를 초과합니다. 범위를 줄이거나 더 낮은 품질을 선택하세요.",
"animatedThumbUpdated": "애니메이션 썸네일 업데이트 · {frames}프레임 · {size}KB",
"groupThumbnail": "관련 자산 썸네일 생성",
"groupThumbnailTitle": "왼쪽 클릭으로 정적 썸네일 생성, 오른쪽 클릭으로 동적 썸네일 생성",
"groupDialogTitle": "관련 자산 썸네일 생성",
"groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.",
"groupDialogNote": "선택한 관련 자산에만 썸네일을 생성합니다. 이미지와 사용자 지정 썸네일이 있는 자산은 기본적으로 선택하지 않습니다. 후자를 선택하면 기존 썸네일이 교체되며 플러그인에서 자동 복원할 수 없습니다.",
"cancel": "취소",
"groupConfirm": "생성",
"groupWillGenerate": "생성 예정",
"groupNotSelected": "선택하지 않음",
"groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음",
"groupUnavailable": "건너뛰: 확인할 수 없음",
"groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.",
"groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle에서 썸네일 적용을 확인하지 못했습니다",
"groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.",
"versionTitle": "Spine 버전", "versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기", "reloadTitle": "폴더 다시 불러오기",
"animationList": "애니메이션 목록", "animationList": "애니메이션 목록",
"animations": "애니메이션", "animations": "애니메이션",
"setupPose": "Setup Pose",
"errorTitle": "미리볼 수 없음", "errorTitle": "미리볼 수 없음",
"durationPlaceholder": "길이 --", "durationPlaceholder": "길이 --",
"keyframesLegend": "파란 선: 키프레임", "keyframesLegend": "파란 선: 키프레임",
@@ -54,6 +102,8 @@
"apiHttp": "Eagle API가 HTTP {status}를 반환했습니다", "apiHttp": "Eagle API가 HTTP {status}를 반환했습니다",
"noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다", "noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다",
"noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다", "noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다",
"itemMismatch": "미리보기 파일과 Eagle 항목 정보가 일치하지 않아 썸네일 업데이트를 취소했습니다",
"replaceCustomThumbnailConfirm": "이 항목에는 이미 사용자 지정 썸네일이 있습니다. 계속하면 교체되며 플러그인에서 이전 이미지를 자동 복원할 수 없습니다. 계속하시겠습니까?",
"thumbnailFailed": "썸네일 생성 실패: {error}", "thumbnailFailed": "썸네일 생성 실패: {error}",
"imageLoadFailed": "이미지를 불러오지 못했습니다", "imageLoadFailed": "이미지를 불러오지 못했습니다",
"settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다", "settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다",
@@ -74,6 +124,24 @@
"loadFailed": "불러오기 실패", "loadFailed": "불러오기 실패",
"notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.", "notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.",
"truncated256": "\n\n(앞부분 256 KB만 표시)", "truncated256": "\n\n(앞부분 256 KB만 표시)",
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)" "suffix": "접미사",
"suffixTitle": "파일 이름 접미사는 매칭 시 무시되어 hero-mesh.skel 이 hero.atlas 와 같은 세트로 묶입니다",
"suffixManage": "접미사 관리…",
"suffixAdd": "+ 접미사 추가…",
"suffixDefaults": "기본 접미사",
"suffixCustom": "사용자 접미사(선택하면 제거)",
"suffixAddTitle": "접미사 추가",
"suffixAddNote": "접미사는 파일 이름 매칭 시 무시됩니다. hover 를 추가하면 hero-hover.skel 이 hero.atlas 와 같은 세트가 됩니다. 영문, 숫자, - 와 _ 만 사용할 수 있으며 최대 50자입니다.",
"suffixPlaceholder": "예: hover",
"suffixInvalid": "영문, 숫자, - 와 _ 만 사용할 수 있으며 첫 글자는 영문이나 숫자여야 합니다",
"suffixDuplicate": "이미 존재하는 접미사입니다",
"suffixConfirm": "추가",
"suffixRemoveTitle": "접미사 제거",
"suffixRemoveNote": "“{name}”을(를) 제거하면 해당 접미사가 붙은 파일은 더 이상 자동으로 매칭되지 않습니다.",
"suffixRemove": "제거",
"suffixSaveFailed": "접미사 설정을 저장하지 못했습니다: {error}",
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)",
"groupWillReplaceCustom": "기존 사용자 지정 썸네일 교체",
"groupCustomNotSelected": "사용자 지정 썸네일 있음(선택 안 함)"
} }
} }
+71 -3
View File
@@ -18,12 +18,60 @@
"pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)", "pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)",
"background": "Фон", "background": "Фон",
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "Создать миниатюру", "thumbnail": "Сделать миниатюрой элемента",
"thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра", "thumbnailTitle": "Левый щелчок создаёт статическую миниатюру, правый — анимированную",
"animatedThumbnailTitle": "Создать анимированную миниатюру",
"animatedThumbnailAttention": "Внимание:",
"animatedThumbnailNote": "Созданная миниатюра заменит существующую. Это действие нельзя отменить, и ",
"animatedThumbnailDanger": "исходную миниатюру нельзя восстановить",
"animatedThumbnailSizeNote": ". Чтобы данные анимации не были слишком большими, файл будет уменьшен до размера ниже заданного.",
"animatedFormat": "Формат вывода",
"animatedRange": "Диапазон анимации",
"animatedStart": "Начало",
"animatedEnd": "Конец",
"framesUnit": "кадров",
"animatedQuality": "Качество миниатюры",
"animatedMaxSize": "Размер миниатюры",
"animatedInvalidSize": "Введите целое число от 10 до 1024 КБ",
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
"animatedPickTitle": "Выберите миниатюру",
"animatedPickHint": "У всех результатов холст {size}; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
"animatedPickConfirm": "Подтвердить",
"animatedPriority": "Приоритет",
"animatedCanvas": "Размер холста",
"priorityResolution": "Сначала разрешение",
"priorityFramerate": "Сначала частота кадров",
"priorityBalanced": "Чередовать разрешение и частоту",
"qualityLow": "Низкое",
"qualityMedium": "Среднее",
"qualityHigh": "Высокое",
"animatedConfirm": "Создать и заменить",
"animatedInvalidRange": "Введите целый диапазон кадров от 0 до {duration}",
"animatedGenerating": "Создание анимированной миниатюры {current}/{total}…",
"animatedTooLarge": "Миниатюра превышает 50 МБ. Сократите диапазон или выберите более низкое качество.",
"animatedThumbUpdated": "Миниатюра обновлена · {frames} кадров · {size} КБ",
"groupThumbnail": "Создать миниатюры для связанных ресурсов",
"groupThumbnailTitle": "Левый щелчок создаёт статическую миниатюру, правый — анимированную",
"groupDialogTitle": "Создать миниатюры для связанных ресурсов",
"groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.",
"groupDialogNote": "Миниатюры будут созданы только для отмеченных связанных ресурсов. Изображения и ресурсы со своими миниатюрами по умолчанию не отмечены. Выбор последних заменит существующую миниатюру, и плагин не сможет восстановить её автоматически.",
"cancel": "Отмена",
"groupConfirm": "Создать",
"groupWillGenerate": "Будет создано",
"groupNotSelected": "Не выбрано",
"groupSkipCustom": "Пропущено: есть своя миниатюра",
"groupUnavailable": "Пропущено: не удалось проверить",
"groupNoTargets": "В текущей группе нет подходящих миниатюр.",
"groupGenerating": "Создание миниатюр {current}/{total}…",
"groupApplyNotConfirmed": "Eagle не подтвердил применение миниатюры",
"groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.",
"versionTitle": "Версия Spine", "versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку", "reloadTitle": "Перезагрузить папку",
"animationList": "Список анимаций", "animationList": "Список анимаций",
"animations": "Анимации", "animations": "Анимации",
"setupPose": "Setup Pose",
"errorTitle": "Просмотр невозможен", "errorTitle": "Просмотр невозможен",
"durationPlaceholder": "Длительность --", "durationPlaceholder": "Длительность --",
"keyframesLegend": "Синие метки: ключевые кадры", "keyframesLegend": "Синие метки: ключевые кадры",
@@ -54,6 +102,8 @@
"apiHttp": "Eagle API вернул HTTP {status}", "apiHttp": "Eagle API вернул HTTP {status}",
"noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру", "noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру",
"noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу", "noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу",
"itemMismatch": "Файл предпросмотра не соответствует элементу Eagle; обновление миниатюры отменено",
"replaceCustomThumbnailConfirm": "У этого элемента уже есть пользовательская миниатюра. При продолжении она будет заменена, и плагин не сможет автоматически восстановить прежнее изображение. Продолжить?",
"thumbnailFailed": "Не удалось создать миниатюру: {error}", "thumbnailFailed": "Не удалось создать миниатюру: {error}",
"imageLoadFailed": "Не удалось загрузить изображение", "imageLoadFailed": "Не удалось загрузить изображение",
"settingsNotReady": "Настройки ресурса ещё не готовы", "settingsNotReady": "Настройки ресурса ещё не готовы",
@@ -74,6 +124,24 @@
"loadFailed": "Не удалось загрузить", "loadFailed": "Не удалось загрузить",
"notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.", "notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.",
"truncated256": "\n\n(показаны только первые 256 КБ)", "truncated256": "\n\n(показаны только первые 256 КБ)",
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)" "suffix": "Суффикс",
"suffixTitle": "Суффиксы имён файлов игнорируются при сопоставлении, поэтому hero-mesh.skel объединяется с hero.atlas",
"suffixManage": "Управление суффиксами…",
"suffixAdd": "+ Добавить суффикс…",
"suffixDefaults": "Встроенные суффиксы",
"suffixCustom": "Пользовательские суффиксы (выбор — удалить)",
"suffixAddTitle": "Добавить суффикс",
"suffixAddNote": "Суффиксы игнорируются при сопоставлении имён: добавив hover, файл hero-hover.skel объединится с hero.atlas. Допустимы буквы, цифры, - и _, не более 50 символов.",
"suffixPlaceholder": "например hover",
"suffixInvalid": "Допустимы только буквы, цифры, - и _; первый символ — буква или цифра",
"suffixDuplicate": "Такой суффикс уже есть",
"suffixConfirm": "Добавить",
"suffixRemoveTitle": "Удалить суффикс",
"suffixRemoveNote": "После удаления «{name}» файлы с этим суффиксом больше не будут сопоставляться автоматически.",
"suffixRemove": "Удалить",
"suffixSaveFailed": "Не удалось сохранить список суффиксов: {error}",
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)",
"groupWillReplaceCustom": "Заменит существующую миниатюру",
"groupCustomNotSelected": "Есть своя миниатюра (не выбрано)"
} }
} }
+70 -2
View File
@@ -18,12 +18,62 @@
"pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图", "pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图",
"background": "背景", "background": "背景",
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "生成缩略图", "thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "使用当前帧生成 400×400 缩略图", "thumbnailTitle": "左键点击生成静态缩略图,右键点击生成动态缩略图",
"animatedThumbnailTitle": "生成动态缩略图",
"animatedThumbnailAttention": "注意:",
"animatedThumbnailNote": "生成的缩略图会替换原有缩略图,该操作不可撤销且",
"animatedThumbnailDanger": "无法恢复原有缩略图",
"animatedThumbnailSizeNote": ",为避免动态缩略图数据过大,文件尺寸将调整至设定大小之下",
"animatedFormat": "输出格式",
"animatedRange": "动画范围",
"animatedStart": "开始",
"animatedEnd": "结束",
"framesUnit": "帧",
"animatedQuality": "缩略图质量",
"animatedMaxSize": "缩略图大小",
"animatedInvalidSize": "请输入 10 到 1024 KB 之间的整数",
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
"animatedPickTitle": "选择一个缩略图",
"animatedPickHint": "所有结果画布都是 {size},标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
"animatedPickConfirm": "确认",
"animatedPriority": "优先级别",
"animatedCanvas": "输出尺寸",
"priorityResolution": "分辨率优先",
"priorityFramerate": "帧率优先",
"priorityBalanced": "分辨率和帧率交替进行",
"qualityLow": "低",
"qualityMedium": "中",
"qualityHigh": "高",
"animatedConfirm": "生成并替换",
"animatedInvalidRange": "请输入 0 到 {duration} 帧之间的有效整数帧范围",
"animatedGenerating": "正在生成动画缩略图 {current}/{total}…",
"animatedTooLarge": "动画缩略图超过 50 MB,请缩短范围或选择更低质量",
"animatedThumbUpdated": "动画缩略图已更新 · {frames} 帧 · {size} KB",
"groupThumbnail": "为关联资源生成缩略图",
"groupThumbnailTitle": "左键点击生成静态缩略图,右键点击生成动态缩略图",
"groupDialogTitle": "为关联资源生成缩略图",
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
"groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。",
"cancel": "取消",
"groupConfirm": "确认生成",
"groupWillGenerate": "将生成",
"groupWillReplaceCustom": "将替换已有自定义缩略图",
"groupCustomNotSelected": "已有自定义缩略图(未选择)",
"groupNotSelected": "未选择",
"groupSkipCustom": "跳过:已有自定义缩略图",
"groupUnavailable": "跳过:无法验证",
"groupNoTargets": "当前资源组没有可生成的缩略图。",
"groupGenerating": "正在生成资源组缩略图 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle 未确认缩略图已应用",
"groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。",
"versionTitle": "Spine 版本", "versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录", "reloadTitle": "重新加载目录",
"animationList": "动画列表", "animationList": "动画列表",
"animations": "动画", "animations": "动画",
"setupPose": "Setup Pose",
"errorTitle": "无法预览", "errorTitle": "无法预览",
"durationPlaceholder": "时长 --", "durationPlaceholder": "时长 --",
"keyframesLegend": "蓝色短线:关键帧", "keyframesLegend": "蓝色短线:关键帧",
@@ -54,6 +104,8 @@
"apiHttp": "Eagle API 返回 HTTP {status}", "apiHttp": "Eagle API 返回 HTTP {status}",
"noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图", "noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图",
"noItemId": "无法从素材路径识别 Eagle 项目 ID", "noItemId": "无法从素材路径识别 Eagle 项目 ID",
"itemMismatch": "当前预览文件与 Eagle 素材信息不一致,已取消缩略图更新",
"replaceCustomThumbnailConfirm": "当前素材已有自定义缩略图。继续后将替换它,且插件无法自动恢复原图。是否继续?",
"thumbnailFailed": "生成缩略图失败: {error}", "thumbnailFailed": "生成缩略图失败: {error}",
"imageLoadFailed": "图片加载失败", "imageLoadFailed": "图片加载失败",
"settingsNotReady": "素材设置尚未就绪", "settingsNotReady": "素材设置尚未就绪",
@@ -74,6 +126,22 @@
"loadFailed": "加载失败", "loadFailed": "加载失败",
"notSpineText": "此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。", "notSpineText": "此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。",
"truncated256": "\n\n(仅显示前 256 KB)", "truncated256": "\n\n(仅显示前 256 KB)",
"suffix": "尾缀",
"suffixTitle": "文件名尾缀在匹配时会被忽略,使 hero-mesh.skel 与 hero.atlas 归为同一套资源",
"suffixManage": "管理尾缀…",
"suffixAdd": "+ 添加尾缀…",
"suffixDefaults": "内置尾缀",
"suffixCustom": "自定义尾缀(选中即移除)",
"suffixAddTitle": "添加尾缀",
"suffixAddNote": "尾缀会在文件名匹配时被忽略:添加 hover 后,hero-hover.skel 会与 hero.atlas 归为同一套资源。只能使用字母、数字、- 和 _,最长 50 个字符。",
"suffixPlaceholder": "例如 hover",
"suffixInvalid": "只能使用字母、数字、- 和 _,且需以字母或数字开头",
"suffixDuplicate": "该尾缀已存在",
"suffixConfirm": "添加",
"suffixRemoveTitle": "移除尾缀",
"suffixRemoveNote": "移除“{name}”后,带该尾缀的文件将不再自动配对。",
"suffixRemove": "移除",
"suffixSaveFailed": "保存尾缀设置失败:{error}",
"truncated1m": "\n\n(文件较大,仅显示前 1 MB)" "truncated1m": "\n\n(文件较大,仅显示前 1 MB)"
} }
} }
+70 -2
View File
@@ -18,12 +18,62 @@
"pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖", "pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖",
"background": "背景", "background": "背景",
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "產生縮圖", "thumbnail": "設為目前素材縮圖",
"thumbnailTitle": "使用目前影格產生 400×400 縮圖", "thumbnailTitle": "左鍵點擊產生靜態縮圖,右鍵點擊產生動態縮圖",
"animatedThumbnailTitle": "產生動畫縮圖",
"animatedThumbnailAttention": "注意:",
"animatedThumbnailNote": "產生的縮圖會取代原有縮圖,該操作無法復原且",
"animatedThumbnailDanger": "無法恢復原有縮圖",
"animatedThumbnailSizeNote": ",為避免動態縮圖資料過大,檔案尺寸將調整至設定大小以下",
"animatedFormat": "輸出格式",
"animatedRange": "動畫範圍",
"animatedStart": "開始",
"animatedEnd": "結束",
"framesUnit": "影格",
"animatedQuality": "縮圖品質",
"animatedMaxSize": "縮圖大小",
"animatedInvalidSize": "請輸入 10 到 1024 KB 之間的整數",
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
"animatedPickTitle": "選擇一個縮圖",
"animatedPickHint": "所有結果畫布都是 {size},標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
"animatedPickConfirm": "確認",
"animatedPriority": "優先級別",
"animatedCanvas": "輸出尺寸",
"priorityResolution": "解析度優先",
"priorityFramerate": "影格率優先",
"priorityBalanced": "解析度與影格率交替進行",
"qualityLow": "低",
"qualityMedium": "中",
"qualityHigh": "高",
"animatedConfirm": "產生並取代",
"animatedInvalidRange": "請輸入 0 到 {duration} 影格之間的有效整數影格範圍",
"animatedGenerating": "正在產生動畫縮圖 {current}/{total}…",
"animatedTooLarge": "動畫縮圖超過 50 MB,請縮短範圍或選擇較低品質",
"animatedThumbUpdated": "動畫縮圖已更新 · {frames} 影格 · {size} KB",
"groupThumbnail": "為關聯資源產生縮圖",
"groupThumbnailTitle": "左鍵點擊產生靜態縮圖,右鍵點擊產生動態縮圖",
"groupDialogTitle": "為關聯資源產生縮圖",
"groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。",
"groupDialogNote": "只會為勾選的關聯資源產生縮圖。圖片和已有自訂縮圖的資源預設不勾選;若勾選後者,原縮圖將被取代且外掛無法自動復原。",
"cancel": "取消",
"groupConfirm": "確認產生",
"groupWillGenerate": "將產生",
"groupWillReplaceCustom": "將取代已有自訂縮圖",
"groupCustomNotSelected": "已有自訂縮圖(未選擇)",
"groupNotSelected": "未選擇",
"groupSkipCustom": "跳過:已有自訂縮圖",
"groupUnavailable": "跳過:無法驗證",
"groupNoTargets": "目前資源組沒有可產生的縮圖。",
"groupGenerating": "正在產生資源組縮圖 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle 未確認縮圖已套用",
"groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。",
"versionTitle": "Spine 版本", "versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄", "reloadTitle": "重新載入目錄",
"animationList": "動畫清單", "animationList": "動畫清單",
"animations": "動畫", "animations": "動畫",
"setupPose": "Setup Pose",
"errorTitle": "無法預覽", "errorTitle": "無法預覽",
"durationPlaceholder": "時長 --", "durationPlaceholder": "時長 --",
"keyframesLegend": "藍色短線:關鍵影格", "keyframesLegend": "藍色短線:關鍵影格",
@@ -54,6 +104,8 @@
"apiHttp": "Eagle API 回傳 HTTP {status}", "apiHttp": "Eagle API 回傳 HTTP {status}",
"noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖", "noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖",
"noItemId": "無法從素材路徑辨識 Eagle 項目 ID", "noItemId": "無法從素材路徑辨識 Eagle 項目 ID",
"itemMismatch": "目前預覽檔案與 Eagle 素材資訊不一致,已取消縮圖更新",
"replaceCustomThumbnailConfirm": "目前素材已有自訂縮圖。繼續後將取代它,且外掛無法自動還原原圖。是否繼續?",
"thumbnailFailed": "產生縮圖失敗:{error}", "thumbnailFailed": "產生縮圖失敗:{error}",
"imageLoadFailed": "圖片載入失敗", "imageLoadFailed": "圖片載入失敗",
"settingsNotReady": "素材設定尚未就緒", "settingsNotReady": "素材設定尚未就緒",
@@ -74,6 +126,22 @@
"loadFailed": "載入失敗", "loadFailed": "載入失敗",
"notSpineText": "此檔案不是 .atlas.txt 或 .skel.bytes 格式的 Spine 資源。", "notSpineText": "此檔案不是 .atlas.txt 或 .skel.bytes 格式的 Spine 資源。",
"truncated256": "\n\n(僅顯示前 256 KB)", "truncated256": "\n\n(僅顯示前 256 KB)",
"suffix": "尾綴",
"suffixTitle": "檔名尾綴在比對時會被忽略,讓 hero-mesh.skel 與 hero.atlas 歸為同一套資源",
"suffixManage": "管理尾綴…",
"suffixAdd": "+ 新增尾綴…",
"suffixDefaults": "內建尾綴",
"suffixCustom": "自訂尾綴(選取即移除)",
"suffixAddTitle": "新增尾綴",
"suffixAddNote": "尾綴會在檔名比對時被忽略:新增 hover 後,hero-hover.skel 會與 hero.atlas 歸為同一套資源。僅能使用字母、數字、- 和 _,最長 50 個字元。",
"suffixPlaceholder": "例如 hover",
"suffixInvalid": "僅能使用字母、數字、- 和 _,且需以字母或數字開頭",
"suffixDuplicate": "該尾綴已存在",
"suffixConfirm": "新增",
"suffixRemoveTitle": "移除尾綴",
"suffixRemoveNote": "移除「{name}」後,帶該尾綴的檔案將不再自動配對。",
"suffixRemove": "移除",
"suffixSaveFailed": "儲存尾綴設定失敗:{error}",
"truncated1m": "\n\n(檔案較大,僅顯示前 1 MB)" "truncated1m": "\n\n(檔案較大,僅顯示前 1 MB)"
} }
} }
+107
View File
@@ -0,0 +1,107 @@
'use strict';
/**
* 动态缩略图的档位配置。
*
* 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
* - 细节分辨率决定画面保留多少细节(输出画布始终 400×400,小分辨率会被放大填满);
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
* - 编码质量是 0–1 的 WebP 质量,APNG 是无损编码会忽略这一项。
*
* 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
* 路径,再按用户填写的目标体积在路径上二分定位,取命中位置附近若干档完整生成,
* 所以锚点的疏密决定可搜索的粒度,锚点数量决定画廊里的候选数量。
*/
const ANIMATED_PRIORITIES = {
// 分辨率优先:先把帧率压到底,再降分辨率。
resolution: [
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
],
// 帧率优先:先把分辨率压到底,再降帧率。
framerate: [
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
],
// 交替进行:分辨率和帧率交替下降。
balanced: [
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
],
};
const DEFAULT_PRIORITY = 'resolution';
/**
* 可选的输出画布边长(正方形),界面上的下拉就是这两项。
*
* Eagle 缩略图网格里每格实际只显示 100–240px 左右,200 与 400 在这个尺寸下几乎看不出差别,
* 但像素数只有四分之一,体积能降约 4 倍。配置里写的细节分辨率超过所选边长时会被压下来,
* 所以同一份档位表在两种尺寸下都能用。
*/
const ANIMATED_OUTPUT_SIZES = [200, 400];
const DEFAULT_OUTPUT_SIZE = 200;
/**
* 「缩略图大小」输入框的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。
* 只是打开面板时的初始值,用户随时可以手动改成 10–1024 之间的任意数。
*/
const SIZE_PER_SECOND = 25;
const SIZE_SUGGESTION_RANGE = [25, 100];
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。
const PATH_STEPS_PER_SEGMENT = 6;
function suggestSize(seconds) {
const [min, max] = SIZE_SUGGESTION_RANGE;
return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
}
function animatedProfiles(priority, outputSize) {
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
return plan.map(([size, fps, quality]) => ({
size: Math.min(size, limit),
fps,
quality,
}));
}
/**
* 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
*/
function expandAnimatedPath(priority, outputSize) {
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
const lerp = (from, to, t) => from + (to - from) * t;
const path = [];
for (let index = 0; index < anchors.length - 1; index++) {
const [fromSize, fromFps, fromQuality] = anchors[index];
const [toSize, toFps, toQuality] = anchors[index + 1];
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
const t = step / PATH_STEPS_PER_SEGMENT;
path.push({
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
});
}
}
const [size, fps, quality] = anchors[anchors.length - 1];
path.push({ size: Math.min(size, limit), fps, quality });
return path;
}
module.exports = {
ANIMATED_PRIORITIES,
DEFAULT_PRIORITY,
ANIMATED_OUTPUT_SIZES,
DEFAULT_OUTPUT_SIZE,
SIZE_PER_SECOND,
SIZE_SUGGESTION_RANGE,
PATH_STEPS_PER_SEGMENT,
animatedProfiles,
expandAnimatedPath,
suggestSize,
};
+78
View File
@@ -0,0 +1,78 @@
'use strict';
function writeUint24LE(buffer, offset, value) {
buffer[offset] = value & 0xff;
buffer[offset + 1] = (value >>> 8) & 0xff;
buffer[offset + 2] = (value >>> 16) & 0xff;
}
function chunk(type, payload) {
const padding = payload.length & 1;
const output = Buffer.alloc(8 + payload.length + padding);
output.write(type, 0, 4, 'ascii');
output.writeUInt32LE(payload.length, 4);
payload.copy(output, 8);
return output;
}
function frameData(webp) {
if (!webp) throw new Error('WebP 帧数据缺失');
const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame');
}
const parts = [];
let hasAlpha = false;
let offset = 12;
while (offset + 8 <= input.length) {
const type = input.toString('ascii', offset, offset + 4);
const length = input.readUInt32LE(offset + 4);
const end = offset + 8 + length + (length & 1);
if (end > input.length) throw new Error('Truncated WebP frame');
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
if (type === 'ALPH') hasAlpha = true;
offset = end;
}
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
throw new Error('WebP frame has no image data');
}
return { data: Buffer.concat(parts), hasAlpha };
}
function encode(options) {
const width = Number(options && options.width);
const height = Number(options && options.height);
const frames = options && options.frames;
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
throw new RangeError('Animated WebP dimensions are invalid');
}
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
const vp8x = Buffer.alloc(10);
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
writeUint24LE(vp8x, 4, width - 1);
writeUint24LE(vp8x, 7, height - 1);
const anim = Buffer.alloc(6);
const background = options.background || [30, 30, 30, 255];
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
for (const { frame, data } of preparedFrames) {
const header = Buffer.alloc(16);
writeUint24LE(header, 6, width - 1);
writeUint24LE(header, 9, height - 1);
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
header[15] = 0x02;
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
}
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
module.exports = { encode };
+115
View File
@@ -0,0 +1,115 @@
'use strict';
const zlib = require('zlib');
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let value = n;
for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
crcTable[n] = value >>> 0;
}
function crc32(buffer) {
let value = 0xffffffff;
for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
return (value ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const name = Buffer.from(type, 'ascii');
const body = Buffer.from(data || []);
const out = Buffer.allocUnsafe(12 + body.length);
out.writeUInt32BE(body.length, 0);
name.copy(out, 4);
body.copy(out, 8);
out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length);
return out;
}
function paeth(left, above, upperLeft) {
const estimate = left + above - upperLeft;
const leftDistance = Math.abs(estimate - left);
const aboveDistance = Math.abs(estimate - above);
const cornerDistance = Math.abs(estimate - upperLeft);
return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left
: aboveDistance <= cornerDistance ? above : upperLeft;
}
function filterFrame(rgba, width, height) {
const stride = width * 4;
const result = Buffer.allocUnsafe((stride + 1) * height);
const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride));
for (let y = 0; y < height; y++) {
const rowStart = y * stride;
let bestType = 0;
let bestScore = Infinity;
for (let type = 0; type < 5; type++) {
const candidate = candidates[type];
let score = 0;
for (let x = 0; x < stride; x++) {
const raw = rgba[rowStart + x];
const left = x >= 4 ? rgba[rowStart + x - 4] : 0;
const above = y > 0 ? rgba[rowStart - stride + x] : 0;
const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0;
let predicted = 0;
if (type === 1) predicted = left;
else if (type === 2) predicted = above;
else if (type === 3) predicted = Math.floor((left + above) / 2);
else if (type === 4) predicted = paeth(left, above, upperLeft);
const filtered = (raw - predicted + 256) & 0xff;
candidate[x] = filtered;
score += Math.abs(filtered < 128 ? filtered : filtered - 256);
}
if (score < bestScore) { bestScore = score; bestType = type; }
}
const outputStart = y * (stride + 1);
result[outputStart] = bestType;
candidates[bestType].copy(result, outputStart + 1);
}
return result;
}
function encode(options) {
const width = Number(options.width);
const height = Number(options.height);
const frames = options.frames || [];
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效');
if (!frames.length) throw new TypeError('APNG 至少需要一帧');
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
const animationControl = Buffer.alloc(8);
animationControl.writeUInt32BE(frames.length, 0);
animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4);
const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)];
let sequence = 0;
for (let index = 0; index < frames.length; index++) {
const frame = frames[index];
const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength);
if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效');
const control = Buffer.alloc(26);
control.writeUInt32BE(sequence++, 0);
control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8);
control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16);
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20);
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22);
control[24] = 0; // keep the previous frame
control[25] = 0; // replace the full canvas
chunks.push(chunk('fcTL', control));
const compressed = zlib.deflateSync(filterFrame(rgba, width, height), {
level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)),
});
if (index === 0) chunks.push(chunk('IDAT', compressed));
else {
const frameData = Buffer.allocUnsafe(4 + compressed.length);
frameData.writeUInt32BE(sequence++, 0);
compressed.copy(frameData, 4);
chunks.push(chunk('fdAT', frameData));
}
}
chunks.push(chunk('IEND'));
return Buffer.concat(chunks);
}
module.exports = { encode };
-17
View File
@@ -1,17 +0,0 @@
'use strict';
const path = require('path');
// Preserve name + '.' + ext exactly; only split the compound suffix correctly.
// Return through Eagle's thumbnail callback so Eagle persists its own metadata.
function normalizeItem(src, item) {
const filename = path.basename(src);
const match = filename.match(/^(.*)\.(atlas\.txt|skel\.bytes)$/i);
if (!match || !item || !item.ext) return false;
if (item.name + '.' + item.ext !== filename) return false;
if (item.name === match[1] && item.ext === match[2]) return false;
item.name = match[1]; item.ext = match[2];
delete item.text;
return true;
}
module.exports = { normalizeItem };
+77
View File
@@ -0,0 +1,77 @@
'use strict';
const crypto = require('crypto');
const fs = require('fs');
const os = require('os');
const path = require('path');
function orderCurrentTargetLast(targets, currentSourcePath) {
if (!currentSourcePath) return [...targets];
const current = path.resolve(currentSourcePath);
return [...targets].sort((left, right) => {
const leftIsCurrent = path.resolve(left.sourcePath) === current;
const rightIsCurrent = path.resolve(right.sourcePath) === current;
return Number(leftIsCurrent) - Number(rightIsCurrent);
});
}
/**
* Apply one rendered frame to explicitly selected Eagle items.
* Each item gets an independent temporary file because Eagle may consume or
* defer reading the file passed to setCustomThumbnail.
*/
async function applyThumbnailTargets(options) {
const targets = options.targets || [];
// 渲染结果为空时立刻报清楚的原因,否则会在写临时文件时才抛出难懂的参数类型错误。
if (!options.rendered || !Buffer.isBuffer(options.rendered.buffer)) throw new Error('缩略图渲染结果为空');
const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs;
const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs;
const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts;
const delay = (ms) => ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
const tempPaths = [];
let success = 0;
let skipped = Number(options.initialSkipped) || 0;
let failed = 0;
for (let index = 0; index < targets.length; index++) {
if (options.onProgress) options.onProgress(index + 1, targets.length);
let itemTempPath = null;
try {
const target = targets[index];
const fresh = await options.getItem(target.sourcePath);
if (fresh.data.customThumbnail === true && target.allowOverwrite !== true) { skipped++; continue; }
itemTempPath = path.join(os.tmpdir(), 'spineviewer-group-' + fresh.id + '-' + crypto.randomBytes(12).toString('hex') + options.rendered.extension);
fs.writeFileSync(itemTempPath, options.rendered.buffer);
tempPaths.push(itemTempPath);
let applied = false;
let lastError = null;
for (let attempt = 0; attempt < retryAttempts && !applied; attempt++) {
try {
const accepted = await options.setThumbnail(fresh.id, itemTempPath);
if (accepted !== true) throw new Error(options.notConfirmedMessage || 'Thumbnail update was rejected');
applied = await options.waitForApplied(target.sourcePath);
if (!applied) throw new Error(options.notConfirmedMessage || 'Thumbnail update was not confirmed');
} catch (error) {
lastError = error;
if (attempt < retryAttempts - 1) await delay(settleDelayMs);
}
}
if (!applied) throw lastError || new Error(options.notConfirmedMessage || 'Thumbnail update was not confirmed');
// The local HTTP API can resolve before Eagle has finished consuming the
// supplied file. Keep every file alive and serialize requests with a short
// settling interval so a later request cannot replace an earlier queued job.
await delay(settleDelayMs);
success++;
if (options.onSuccess) options.onSuccess(fresh);
} catch (error) {
failed++;
if (options.onError) options.onError(error);
} finally { /* temporary inputs are retained until the whole queue settles */ }
}
await delay(cleanupDelayMs);
for (const tempPath of tempPaths) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
return { success, skipped, failed };
}
module.exports = { applyThumbnailTargets, orderCurrentTargetLast };
+32 -1
View File
@@ -55,4 +55,35 @@ async function resolveDataPath(app) {
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle'); return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
} }
module.exports = { createStore, resolveDataPath }; // 尾缀表是全局偏好(不是每个素材一份):同一台机器上所有素材、所有库共用。
// 查看器在加载资源前读取它,用户的增删都会立即落盘。
function createSuffixStore(userDataPath) {
const directory = path.join(userDataPath, 'SpineViewer');
const file = path.join(directory, 'suffix-settings.json');
return {
file,
read() {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return Array.isArray(data.suffixes) ? data.suffixes : [];
} catch (e) {
if (e.code === 'ENOENT') return [];
throw e;
}
},
// 仅在用户通过下拉列表增删尾缀时调用。
write(suffixes) {
if (!Array.isArray(suffixes)) throw new TypeError('尾缀设置必须为数组');
fs.mkdirSync(directory, { recursive: true });
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try {
fs.writeFileSync(temp, JSON.stringify({ suffixes }, null, 2) + '\n', { flag: 'wx' });
fs.renameSync(temp, file);
} finally {
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
};
}
module.exports = { createStore, createSuffixStore, resolveDataPath };
-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 并挂载贴图(跨版本兼容)。 /** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。 * texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/ */
function buildTextureAtlas(spine, atlasText, dir, texturePaths) { function buildTextureAtlas(spine, atlasText, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
(async () => { (async () => {
@@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
const textureMap = {}; const textureMap = {};
await Promise.all(pageNames.map(async (name) => { await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录 // 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name); // 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
if (!fs.existsSync(p)) return; const p = texturePaths && texturePaths[name];
if (!p || !fs.existsSync(p)) return;
try { try {
const img = await canvasLib.loadImage(p); const img = await canvasLib.loadImage(p);
textureMap[name] = img; textureMap[name] = img;
@@ -150,12 +151,11 @@ function getBounds(spine, skeleton) {
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) { async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath); const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8'); const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true; const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir); 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); const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) { if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
+175 -36
View File
@@ -19,8 +19,130 @@ const fileIndexCache = new Map();
const skeletonJsonCache = new Map(); const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024; const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符) // Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i; // 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
const SUFFIX_MAX_LENGTH = 50;
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
let activeSuffixes = DEFAULT_SUFFIXES.slice();
let suffixRe = buildSuffixRe(activeSuffixes);
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildSuffixRe(list) {
if (!list || list.length === 0) return null;
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
}
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
function normalizeSuffixName(raw) {
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
}
/**
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
*/
function setSuffixes(list) {
const custom = [];
for (const raw of Array.isArray(list) ? list : []) {
const name = normalizeSuffixName(raw);
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
if (custom.indexOf(name) === -1) custom.push(name);
}
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
suffixRe = buildSuffixRe(activeSuffixes);
return getSuffixes();
}
/** 当前生效的尾缀(内置 + 自定义) */
function getSuffixes() {
return activeSuffixes.slice();
}
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
function getCustomSuffixes() {
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
}
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) { function extOf(name) {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
@@ -51,6 +173,11 @@ function isTextureFile(name) {
return TEXTURE_EXTS.indexOf(ext) !== -1; return TEXTURE_EXTS.indexOf(ext) !== -1;
} }
function textureFormatOf(name) {
const ext = extOf(name);
return ext === '.jpg' || ext === '.jpeg' ? 'jpeg' : ext;
}
/** 去掉文件名中的扩展名 */ /** 去掉文件名中的扩展名 */
function stemOf(name) { function stemOf(name) {
const ext = extOf(name); const ext = extOf(name);
@@ -58,9 +185,10 @@ function stemOf(name) {
return name.slice(0, name.length - ext.length); return name.slice(0, name.length - ext.length);
} }
/** 归一化 stem,去掉 Spine 常见后缀,用于跨文件匹配 */ /** 归一化 stem,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
function normalizeStem(name) { function normalizeStem(name) {
return stemOf(name).toLowerCase().replace(SUFFIX_RE, ''); const lower = stemOf(name).toLowerCase();
return suffixRe ? lower.replace(suffixRe, '') : lower;
} }
/** 从 JSON 文本中读取 spine 版本 */ /** 从 JSON 文本中读取 spine 版本 */
@@ -335,12 +463,24 @@ function collectSpineFiles(roots, validFolders) {
const seen = new Set(); const seen = new Set();
const out = []; const out = [];
for (const root of roots) { 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; let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; } try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) { 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 相关扩展 if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
const full = resolveFileWithin(root, name, false);
if (!full) continue;
// 排除 Eagle 回收站中已删除的项 // 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue; if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) { if (validFolders && validFolders.size) {
@@ -421,10 +561,15 @@ function discoverAssets(filePath, options) {
const selected = path.basename(filePath); const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected); const selectedStem = normalizeStem(selected);
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。 // 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
const roots = getSearchRoots(filePath); let roots;
// 若同级范围无法凑齐骨架+图集(常见于 Eagle 导入瞬间,同伴落在别的 .info), if (Array.isArray(options.searchRoots) && options.searchRoots.length) {
// 则扩展搜索到整个库的 images/ 目录(全库兜底,靠“同家族 + 同文件夹 + 最近 btime”配对)。 roots = [...new Set(options.searchRoots.map((root) => path.resolve(root)))];
} else {
roots = getSearchRoots(filePath);
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
// 明确列举的候选 item 目录,避免每次预览扫描整库。
buildRootsWithLibraryRoot(filePath, roots); buildRootsWithLibraryRoot(filePath, roots);
}
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入 // 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。 // folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
@@ -576,15 +721,18 @@ function discoverAssets(filePath, options) {
const pageNames = parseAtlasPages(text); const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录 const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) { for (const pn of pageNames) {
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠) // 1) 允许 atlas 所在 .info 目录内的安全相对路径;realpath 必须仍在目录内。
const inAtlasDir = path.join(atlasDir, pn); const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
if (fs.existsSync(inAtlasDir)) { if (inAtlasDir) {
texturePaths[pn] = inAtlasDir; texturePaths[pn] = inAtlasDir;
continue; continue;
} }
// 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选 // 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase()); const pageFileName = path.posix.basename(pn);
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn)); let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
const sameFormat = cands.filter((f) => textureFormatOf(f.name) === textureFormatOf(pageFileName));
if (sameFormat.length > 0) cands = sameFormat;
let hit = selectBest(cands, atlas.fullPath); let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null; if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath; if (hit) texturePaths[pn] = hit.fullPath;
@@ -594,22 +742,6 @@ function discoverAssets(filePath, options) {
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem); const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : ''; const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes'); 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 { return {
skeletonPath: skeleton ? skeleton.fullPath : null, skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null, atlasPath: atlas ? atlas.fullPath : null,
@@ -621,7 +753,6 @@ function discoverAssets(filePath, options) {
atlasCandidates: atlasFiles.map((f) => f.name), atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath), atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name), textureCandidates: textureFiles.map((f) => f.name),
relatedDataPaths,
}; };
} }
@@ -633,15 +764,14 @@ function parseAtlasPages(atlasText) {
// atlas 中每个页面以一行“xxx.png”之类的文件名开始 // atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) { for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim(); const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) { if (/\.(png|jpg|jpeg|webp)$/i.test(line) && !/[=,:]/.test(line)) {
pages.push(line); pages.push(validateAtlasPageName(line));
} else if (line === '') { } else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名 // 空白行之后的下一个非空行可能是下一页文件名
continue; continue;
} }
} }
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等) return [...new Set(pages)];
return pages.filter((p) => !/[=,:]/.test(p));
} }
/** /**
@@ -684,8 +814,15 @@ module.exports = {
isSkeletonFile, isSkeletonFile,
isAtlasFile, isAtlasFile,
isTextureFile, isTextureFile,
textureFormatOf,
stemOf, stemOf,
normalizeStem, normalizeStem,
DEFAULT_SUFFIXES,
SUFFIX_MAX_LENGTH,
normalizeSuffixName,
setSuffixes,
getSuffixes,
getCustomSuffixes,
detectVersion, detectVersion,
detectVersionFromJson, detectVersionFromJson,
detectVersionFromBuffer, detectVersionFromBuffer,
@@ -694,6 +831,8 @@ module.exports = {
looksLikeSkeletonJson, looksLikeSkeletonJson,
discoverAssets, discoverAssets,
parseAtlasPages, parseAtlasPages,
validateAtlasPageName,
resolveAtlasTextureFile,
parseAtlasPma, parseAtlasPma,
preferredSkin, preferredSkin,
applySkin, applySkin,
+26
View File
@@ -0,0 +1,26 @@
'use strict';
function findAlphaBounds(rgba, width, height, threshold = 4) {
let left = width, top = height, right = 0, bottom = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x + 1);
bottom = Math.max(bottom, y + 1);
}
}
return right > left && bottom > top ? { left, top, right, bottom } : null;
}
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
if (!pixels || !isFinite(scale) || scale <= 0) return null;
const left = centerX + (pixels.left - width / 2) / scale;
const right = centerX + (pixels.right - width / 2) / scale;
const top = centerY - (pixels.top - height / 2) / scale;
const bottom = centerY - (pixels.bottom - height / 2) / scale;
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
module.exports = { findAlphaBounds, toWorldBounds };
+1 -6
View File
@@ -1,6 +1,6 @@
{ {
"id": "ddf1c576-cfbc-4813-83a6-2e029cc08745", "id": "ddf1c576-cfbc-4813-83a6-2e029cc08745",
"version": "1.1.1", "version": "1.1.2",
"platform": "all", "platform": "all",
"arch": "all", "arch": "all",
"name": "{{manifest.app.name}}", "name": "{{manifest.app.name}}",
@@ -26,11 +26,6 @@
"devTools": false, "devTools": false,
"preview": { "preview": {
"skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": { "skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": {
"thumbnail": {
"path": "thumbnail/spine.js",
"size": 400,
"allowZoom": false
},
"viewer": { "viewer": {
"path": "viewer/spine.html" "path": "viewer/spine.html"
} }
+2 -202
View File
@@ -1,212 +1,12 @@
{ {
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.2",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.2"
"license": "ISC",
"dependencies": {
"@napi-rs/canvas": "^0.1.66"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.66.tgz",
"integrity": "sha512-NE/eQKLbUS+LCbMHRa5HnR7cc1Q4ibg/qfLUN4Ukl3CC0lq6LfHE0YbvFm/l4i5RyyS+aUjL+8IuZDD9EH3amg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.66",
"@napi-rs/canvas-darwin-arm64": "0.1.66",
"@napi-rs/canvas-darwin-x64": "0.1.66",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66",
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
"@napi-rs/canvas-win32-x64-msvc": "0.1.66"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.66.tgz",
"integrity": "sha512-77Yq9yaUYN90zCovYOpw7LhidJiswU9wLIWWBGF6iiEJyQdt6tkiXpGRZpOMJVO70afkcdc4T7532cxMIBhk0Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.66.tgz",
"integrity": "sha512-cz3aJ06b8BZGtwRxKTiE0OVUlB17MH8j+BnE4A5+wD9aD1guCCqECsz+k7tpXdAdTAYKRIz2pq6ZuiJ76NyUbQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.66.tgz",
"integrity": "sha512-szIWqJgFm2OTyGzM+hSiJOaOtjI73VYRC2KN30zZTt7i1+0sgpm5exK5ltDBPOmCdnLt7SbUfpInLj8VvxYlKA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.66.tgz",
"integrity": "sha512-h/TZJFc6JLvp8FwbA5mu+yXiblN0iKqshU7xzd6L+ks5uNYgjS7XWLkNiyPQkMaXQgVczOJfZy7r4NSPK3V8Hg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.66.tgz",
"integrity": "sha512-RGFUdBdi0Xmf+TfwZcB89Ap6hDYh4nzyJhXhNJIgve6ELrIPFhf7sDHvUHxjgW0YzczGoo+ophyCm03cJggu+w==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.66.tgz",
"integrity": "sha512-2cFViDIZ0xQlAHyJmyym+rj3p04V16vgAiz64sCAfwOOiW6e19agv1HQWHUsro3G2lF3PaHGAnp0WRPXGqLOfg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.66.tgz",
"integrity": "sha512-Vm5ZWS2RDPeBpnfx83eJpZfJT07xl0jqp8d83PklKqiDNa3BmDZZ/uuI40/ICgejGLymXXYo5N21b7oAxhRTSA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.66.tgz",
"integrity": "sha512-/ptGBhErNBCgWff3khtuEjhiiYWf70oWvBPRj8y5EMB0nLYpve7RxxFnavVvxN49kJ0MQHRIwgfyd47RSOOKPw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.66.tgz",
"integrity": "sha512-XunvXisTkIG+bpq6BcXmsUstoLX3RLS6N9Uz9Pg9RpWIMeM6ObR5shr3NgpGRJq93769I1hS4mJW0DX2Au3WBw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.66.tgz",
"integrity": "sha512-3n34watNFqpwACDA+pt4jfQD6zR8PzfK86FBajdsgDVVZhSp6ohgbbJv+eUrXM08VUtjxTq7+U4sWspTu9+4Ug==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
} }
} }
} }
+3 -4
View File
@@ -1,11 +1,10 @@
{ {
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.2",
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)", "description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
"main": "thumbnail/spine.js",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"dependencies": { "scripts": {
"@napi-rs/canvas": "^0.1.66" "check:release": "node tests/release-package.test.js"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 208 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 227 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Loaded 100 of 256 files, more files were not shown because too many files have changed in this diff. Show more