Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d33302858 | ||
|
|
4a34d0c46a | ||
|
|
5fb210e5a8 | ||
|
|
224ba24fab | ||
|
|
21a8b5e24e | ||
|
|
3bd3ba7404 | ||
|
|
acc93d5404 | ||
|
|
c29de7a26b | ||
|
|
650d9c0398 | ||
|
|
be058c6c80 | ||
|
|
08456f26cf | ||
|
|
07c21f5954 | ||
|
|
96a0db09d8 | ||
|
|
065b6466f1 | ||
|
|
c28adf9fae | ||
|
|
fe4997f913 | ||
|
|
081520dbd9 | ||
|
|
e94dfb9e35 | ||
|
|
5808e3dfce | ||
|
|
b258d736fd | ||
|
|
e60506ef96 | ||
|
|
e2df519d2e | ||
|
|
081d06c70e | ||
|
|
eb2a7f7d91 | ||
|
|
4ff35ce352 | ||
|
|
a429ebbe6b | ||
|
|
c113089e2d | ||
|
|
52bdbc9f3c | ||
|
|
a4e86b37fc | ||
|
|
392e71fb22 | ||
|
|
ff7ae2cd84 | ||
|
|
6366635feb | ||
|
|
c21c55b959 | ||
|
|
94e9304d9e | ||
|
|
d9f7bd2f89 | ||
|
|
2cb70c49d4 | ||
|
|
2a5fe0ce1f | ||
|
|
05c4fc4b90 | ||
|
|
fe71d2d6ce | ||
|
|
bb73cc9227 |
No files matched your search
@@ -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.
|
|
||||||
@@ -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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Spine-Vorschaubilder"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Spine-Vorschaubilder",
|
||||||
|
"noticeIntro": "Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente ohne eigenes Vorschaubild.",
|
||||||
|
"noticeSafety": "Nur neu importierte Elemente werden verarbeitet. Vorhandene Elemente werden weder durchsucht noch geändert, und eigene Vorschaubilder werden nie überschrieben.",
|
||||||
|
"noticeModes": "Animiert verwendet die erste Animation und exportiert WebP. Statisch verwendet immer die Setup-Pose.",
|
||||||
|
"noticeDefault": "Die automatische Erstellung ist standardmäßig ausgeschaltet. Aktiviere sie bei Bedarf unten.",
|
||||||
|
"autoToggle": "Beim Import Vorschaubilder erstellen",
|
||||||
|
"clear": "Protokoll leeren",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Spine Thumbnails"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Spine Thumbnails",
|
||||||
|
"noticeIntro": "After importing Spine assets (.skel / .json + .atlas + textures), this plugin creates a skeleton preview for items that do not yet have a custom thumbnail.",
|
||||||
|
"noticeSafety": "Only newly imported items are processed. Existing items are not scanned or changed, and custom thumbnails are never overwritten.",
|
||||||
|
"noticeModes": "Animated uses the first animation and exports WebP. Still always uses the setup pose.",
|
||||||
|
"noticeDefault": "Automatic generation is off by default. Enable it below when needed.",
|
||||||
|
"autoToggle": "Generate thumbnails on import",
|
||||||
|
"clear": "Clear log",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Miniaturas de Spine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Miniaturas de Spine",
|
||||||
|
"noticeIntro": "Después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento crea una vista previa del esqueleto para los elementos que aún no tienen una miniatura personalizada.",
|
||||||
|
"noticeSafety": "Solo se procesan los elementos recién importados. No se buscan ni modifican elementos existentes y nunca se sobrescriben las miniaturas personalizadas.",
|
||||||
|
"noticeModes": "Animado usa la primera animación y exporta WebP. Estático siempre usa la pose de configuración.",
|
||||||
|
"noticeDefault": "La generación automática está desactivada de forma predeterminada. Actívala abajo cuando la necesites.",
|
||||||
|
"autoToggle": "Generar miniaturas al importar",
|
||||||
|
"clear": "Borrar registro",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Spineサムネイル"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Spineサムネイル",
|
||||||
|
"noticeIntro": "Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込んだ後、カスタムサムネイルがないアイテムにスケルトンプレビューを生成します。",
|
||||||
|
"noticeSafety": "今回新しく読み込んだアイテムだけを処理し、既存アイテムの走査や変更、カスタムサムネイルの上書きは行いません。",
|
||||||
|
"noticeModes": "アニメーションは最初のアニメーションを WebP に変換します。静止画は常に setup pose を使用します。",
|
||||||
|
"noticeDefault": "自動生成は既定でオフです。必要なときに下で有効にしてください。",
|
||||||
|
"autoToggle": "読み込み時にサムネイルを自動生成",
|
||||||
|
"clear": "ログを消去",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Spine 썸네일"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Spine 썸네일",
|
||||||
|
"noticeIntro": "Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져온 뒤 사용자 지정 썸네일이 없는 항목에 스켈레톤 미리보기를 생성합니다.",
|
||||||
|
"noticeSafety": "이번에 새로 가져온 항목만 처리합니다. 기존 항목을 검색하거나 수정하지 않으며 사용자 지정 썸네일을 덮어쓰지 않습니다.",
|
||||||
|
"noticeModes": "애니메이션은 첫 번째 애니메이션을 WebP로 만듭니다. 정지 이미지는 항상 setup pose를 사용합니다.",
|
||||||
|
"noticeDefault": "자동 생성은 기본적으로 꺼져 있습니다. 필요할 때 아래에서 켜세요.",
|
||||||
|
"autoToggle": "가져올 때 썸네일 자동 생성",
|
||||||
|
"clear": "로그 지우기",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Миниатюры Spine"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Миниатюры Spine",
|
||||||
|
"noticeIntro": "После импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов без пользовательской миниатюры.",
|
||||||
|
"noticeSafety": "Обрабатываются только новые элементы. Существующие элементы не сканируются и не изменяются, пользовательские миниатюры никогда не перезаписываются.",
|
||||||
|
"noticeModes": "Анимированный режим использует первую анимацию и экспортирует WebP. Статический всегда использует setup pose.",
|
||||||
|
"noticeDefault": "Автоматическое создание по умолчанию отключено. Включите его ниже при необходимости.",
|
||||||
|
"autoToggle": "Создавать миниатюры при импорте",
|
||||||
|
"clear": "Очистить журнал",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Spine缩略图"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Spine缩略图",
|
||||||
|
"noticeIntro": "导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
|
||||||
|
"noticeSafety": "它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。",
|
||||||
|
"noticeModes": "动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。",
|
||||||
|
"noticeDefault": "自动生成默认关闭,需要你在下面手动启用。",
|
||||||
|
"autoToggle": "导入时自动生成缩略图",
|
||||||
|
"clear": "清空日志",
|
||||||
|
"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,117 @@
|
|||||||
|
{
|
||||||
|
"manifest": {
|
||||||
|
"app": {
|
||||||
|
"name": "Spine縮圖"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"thumb": {
|
||||||
|
"title": "Spine縮圖",
|
||||||
|
"noticeIntro": "匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生骨骼預覽圖。",
|
||||||
|
"noticeSafety": "只處理本次新匯入的素材,不掃描或修改歷史素材,也不會覆寫你已設定的自訂縮圖。",
|
||||||
|
"noticeModes": "動態會取第一個動畫並壓成 WebP;靜態一律使用 setup pose。",
|
||||||
|
"noticeDefault": "自動產生預設為關閉,需要時請在下方手動啟用。",
|
||||||
|
"autoToggle": "匯入時自動產生縮圖",
|
||||||
|
"clear": "清除記錄",
|
||||||
|
"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,141 @@
|
|||||||
|
<!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; }
|
||||||
|
h1 { font-size: 15px; margin: 0; font-weight: 600; text-align: center; }
|
||||||
|
.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; }
|
||||||
|
#clear { 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 { 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); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1 data-i18n="thumb.title">Spine缩略图</h1>
|
||||||
|
<div class="notice">
|
||||||
|
<p data-i18n="thumb.noticeIntro">导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
|
||||||
|
<p data-i18n="thumb.noticeSafety">它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</p>
|
||||||
|
<p data-i18n="thumb.noticeModes">动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。</p>
|
||||||
|
<p data-i18n="thumb.noticeDefault">自动生成默认关闭,需要你在下面手动启用。</p>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="toolbar-group">
|
||||||
|
<label class="toggle-row"><input type="checkbox" id="toggle"><span data-i18n="thumb.autoToggle">导入时自动生成缩略图</span></label>
|
||||||
|
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
|
||||||
|
</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>
|
||||||
|
<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/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,111 @@
|
|||||||
|
// 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 fromUrl = new URLSearchParams(location.search).get('lang');
|
||||||
|
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,122 @@
|
|||||||
|
// 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();
|
||||||
|
onLog(t('thumb.watching', { path: imagesDir, count: known.size }));
|
||||||
|
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;
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -0,0 +1,558 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
|
||||||
|
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
|
||||||
|
const VERSION = 'v66';
|
||||||
|
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',
|
||||||
|
];
|
||||||
|
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 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 filtersEl = document.getElementById('filters');
|
||||||
|
const modeEl = document.getElementById('mode');
|
||||||
|
const strategyEl = document.getElementById('strategy');
|
||||||
|
|
||||||
|
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 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 folderPathOf(folderIds) {
|
||||||
|
if (!folderPaths) loadFolderPaths();
|
||||||
|
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
|
||||||
|
return (id && folderPaths.get(id)) || t('thumb.uncategorized');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 日志里的渲染结果描述。 */
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
|
||||||
|
async function generateOne(item) {
|
||||||
|
let tempPath = 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;
|
||||||
|
}
|
||||||
|
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
|
||||||
|
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
|
||||||
|
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
|
||||||
|
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
|
||||||
|
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
|
||||||
|
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
|
||||||
|
try {
|
||||||
|
eagleItem.name = parts.name;
|
||||||
|
eagleItem.ext = parts.ext;
|
||||||
|
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
|
||||||
|
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
|
||||||
|
} catch (error) {
|
||||||
|
log(t('thumb.suffixFailed', { label, error: (error && error.message) || error }), 'warn');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
|
||||||
|
if (eagleItem.customThumbnail === true) {
|
||||||
|
stats.skipped += 1;
|
||||||
|
countRun('skipped', false, item.entryName);
|
||||||
|
log(t('thumb.existingThumbnail', { label }), 'dim');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
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) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
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 = resolved;
|
||||||
|
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function startSafely() {
|
||||||
|
start().catch((error) => {
|
||||||
|
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
|
||||||
|
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
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();
|
||||||
|
log(t('thumb.pluginCreated', {
|
||||||
|
name: (plugin && plugin.manifest && plugin.manifest.name) || t('thumb.title'),
|
||||||
|
}));
|
||||||
|
try {
|
||||||
|
await loadSettings();
|
||||||
|
} catch (error) {
|
||||||
|
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
|
||||||
|
}
|
||||||
|
startSafely();
|
||||||
|
});
|
||||||
|
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');
|
||||||
|
loadSettings().catch(() => {}).then(startSafely);
|
||||||
|
}
|
||||||
|
}, 800);
|
||||||
|
}
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
// 进度弹窗:用 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,
|
||||||
|
url: 'progress.html?lang=' + encodeURIComponent(i18n ? i18n.locale : 'zh_CN'),
|
||||||
|
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;
|
||||||
|
|
||||||
|
})();
|
||||||
@@ -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,874 @@
|
|||||||
|
// 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 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: suffix.slice(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));
|
||||||
|
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,
|
||||||
|
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`.
|
||||||
@@ -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`.
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
{
|
||||||
|
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,17 @@
|
|||||||
|
'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 windowSource = fs.readFileSync(path.join(root, 'js', 'progress-window.js'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(windowSource, /minimizable: true/);
|
||||||
|
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;
|
||||||
|
});
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "カスタムサムネイルあり(未選択)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "사용자 지정 썸네일 있음(선택 안 함)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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": "Есть своя миниатюра (не выбрано)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
};
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
|
||||||
@@ -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 };
|
||||||
@@ -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 };
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
'use strict';
|
|
||||||
const fs = require('fs');
|
|
||||||
const path = require('path');
|
|
||||||
const os = require('os');
|
|
||||||
const crypto = require('crypto');
|
|
||||||
const { spawn } = require('child_process');
|
|
||||||
const utils = require('./spine-utils');
|
|
||||||
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
||||||
|
|
||||||
function enqueue(src) {
|
|
||||||
const library = utils.findLibraryRoot(src);
|
|
||||||
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
|
|
||||||
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
|
|
||||||
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
|
|
||||||
fs.mkdirSync(directory, { recursive: true });
|
|
||||||
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
|
|
||||||
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
|
|
||||||
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
|
|
||||||
fs.renameSync(temp, job);
|
|
||||||
// Electron's Node mode runs the local worker without opening another Eagle.
|
|
||||||
const child = spawn(process.execPath, [__filename, directory], {
|
|
||||||
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
|
|
||||||
});
|
|
||||||
child.on('error', error => {
|
|
||||||
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
|
|
||||||
});
|
|
||||||
child.unref();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function run(directory) {
|
|
||||||
const lock = path.join(directory, 'worker.lock');
|
|
||||||
let fd;
|
|
||||||
try { fd = fs.openSync(lock, 'wx'); }
|
|
||||||
catch (error) {
|
|
||||||
if (error.code !== 'EEXIST') throw error;
|
|
||||||
let pid;
|
|
||||||
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
|
|
||||||
if (!pid) return; // Another worker is still writing its lock.
|
|
||||||
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
|
|
||||||
try { fs.unlinkSync(lock); } catch (_) { return; }
|
|
||||||
return run(directory);
|
|
||||||
}
|
|
||||||
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
|
|
||||||
const snapshots = new Map();
|
|
||||||
const nextChecks = new Map();
|
|
||||||
const thumbnail = require('../thumbnail/spine');
|
|
||||||
try {
|
|
||||||
let idle = 0;
|
|
||||||
while (idle < 2) {
|
|
||||||
await sleep(1000); // Allow the importing callback/window to finish.
|
|
||||||
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
|
|
||||||
if (!jobs.length) { idle++; continue; }
|
|
||||||
idle = 0;
|
|
||||||
for (const name of jobs) {
|
|
||||||
const file = path.join(directory, name);
|
|
||||||
let raw;
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(file, 'utf8');
|
|
||||||
const job = JSON.parse(raw);
|
|
||||||
if (Date.now() < (nextChecks.get(name) || 0)) continue;
|
|
||||||
const remaining = job.deadline - Date.now();
|
|
||||||
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
|
|
||||||
const result = Date.now() > job.deadline ? { complete: true }
|
|
||||||
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
|
|
||||||
snapshots.set(name, result.signature);
|
|
||||||
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
|
|
||||||
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
fs.unlinkSync(lock);
|
|
||||||
// Cover an enqueue racing the final idle check and lock release.
|
|
||||||
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
module.exports = { enqueue, run };
|
|
||||||
if (require.main === module) run(process.argv[2]).catch(error => {
|
|
||||||
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
|
|
||||||
process.exitCode = 1;
|
|
||||||
});
|
|
||||||
@@ -59,7 +59,7 @@ function loadRuntime(dir) {
|
|||||||
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
|
/** 构造 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) {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -58,9 +180,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 +458,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 +556,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)))];
|
||||||
buildRootsWithLibraryRoot(filePath, roots);
|
} else {
|
||||||
|
roots = getSearchRoots(filePath);
|
||||||
|
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
|
||||||
|
// 明确列举的候选 item 目录,避免每次预览扫描整库。
|
||||||
|
buildRootsWithLibraryRoot(filePath, roots);
|
||||||
|
}
|
||||||
|
|
||||||
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
|
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
|
||||||
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
|
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
|
||||||
@@ -576,15 +716,16 @@ 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));
|
||||||
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 +735,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 +746,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 +757,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));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -686,6 +809,12 @@ module.exports = {
|
|||||||
isTextureFile,
|
isTextureFile,
|
||||||
stemOf,
|
stemOf,
|
||||||
normalizeStem,
|
normalizeStem,
|
||||||
|
DEFAULT_SUFFIXES,
|
||||||
|
SUFFIX_MAX_LENGTH,
|
||||||
|
normalizeSuffixName,
|
||||||
|
setSuffixes,
|
||||||
|
getSuffixes,
|
||||||
|
getCustomSuffixes,
|
||||||
detectVersion,
|
detectVersion,
|
||||||
detectVersionFromJson,
|
detectVersionFromJson,
|
||||||
detectVersionFromBuffer,
|
detectVersionFromBuffer,
|
||||||
@@ -694,6 +823,8 @@ module.exports = {
|
|||||||
looksLikeSkeletonJson,
|
looksLikeSkeletonJson,
|
||||||
discoverAssets,
|
discoverAssets,
|
||||||
parseAtlasPages,
|
parseAtlasPages,
|
||||||
|
validateAtlasPageName,
|
||||||
|
resolveAtlasTextureFile,
|
||||||
parseAtlasPma,
|
parseAtlasPma,
|
||||||
preferredSkin,
|
preferredSkin,
|
||||||
applySkin,
|
applySkin,
|
||||||
|
|||||||
@@ -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 +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"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,66 @@
|
|||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var root = document.documentElement;
|
||||||
|
var themeToggle = document.getElementById('themeToggle');
|
||||||
|
|
||||||
|
if (themeToggle) {
|
||||||
|
themeToggle.addEventListener('click', function () {
|
||||||
|
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||||
|
root.dataset.theme = next;
|
||||||
|
try { localStorage.setItem('stg-site-theme', next); } catch (error) {}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
var revealEls = Array.prototype.slice.call(document.querySelectorAll('.reveal'));
|
||||||
|
var revealTick = 0;
|
||||||
|
|
||||||
|
function revealInViewport() {
|
||||||
|
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||||
|
revealEls.forEach(function (el) {
|
||||||
|
if (el.classList.contains('visible')) return;
|
||||||
|
var rect = el.getBoundingClientRect();
|
||||||
|
if (rect.top < viewportHeight * 0.94 && rect.bottom > 0) el.classList.add('visible');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReveal() {
|
||||||
|
if (revealTick) return;
|
||||||
|
revealTick = window.requestAnimationFrame(function () {
|
||||||
|
revealTick = 0;
|
||||||
|
revealInViewport();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
revealInViewport();
|
||||||
|
window.addEventListener('scroll', scheduleReveal, { passive: true });
|
||||||
|
window.addEventListener('resize', scheduleReveal);
|
||||||
|
setTimeout(function () {
|
||||||
|
revealEls.forEach(function (el) { el.classList.add('visible'); });
|
||||||
|
}, 2000);
|
||||||
|
|
||||||
|
var lightbox = document.getElementById('lightbox');
|
||||||
|
var lightboxImg = lightbox ? lightbox.querySelector('img') : null;
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
if (!lightbox || lightbox.hidden) return;
|
||||||
|
lightbox.hidden = true;
|
||||||
|
lightboxImg.removeAttribute('src');
|
||||||
|
document.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (lightbox) {
|
||||||
|
document.querySelectorAll('img.zoomable').forEach(function (img) {
|
||||||
|
img.addEventListener('click', function () {
|
||||||
|
lightboxImg.src = img.currentSrc || img.src;
|
||||||
|
lightboxImg.alt = img.alt || '';
|
||||||
|
lightbox.hidden = false;
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
});
|
||||||
|
});
|
||||||
|
lightbox.addEventListener('click', closeLightbox);
|
||||||
|
window.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Escape') closeLightbox();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 208 KiB |
@@ -0,0 +1,384 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="zh-CN" data-theme="dark">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>SpineThumbnails — Eagle 插件 · Spine 导出资源缩略图自动生成</title>
|
||||||
|
<meta name="description" content="SpineThumbnails 是一款用于 Eagle 的 Spine 缩略图自动生成插件。自动识别并配对 Spine 骨骼、Atlas 与贴图,支持 3.4–4.3、静态 PNG、动态 WebP、进度控制与八种界面语言。">
|
||||||
|
<meta property="og:title" content="SpineThumbnails — Eagle 插件">
|
||||||
|
<meta property="og:description" content="识别、配对、渲染一次完成。为 Eagle 中的 Spine 导出资源自动生成静态或动态缩略图。">
|
||||||
|
<meta property="og:type" content="website">
|
||||||
|
<meta property="og:image" content="assets/promo-banner.jpg">
|
||||||
|
<meta name="theme-color" content="#16161b">
|
||||||
|
<link rel="icon" href="assets/logo.png">
|
||||||
|
<link rel="stylesheet" href="style.css">
|
||||||
|
<noscript><style>.reveal { opacity: 1; transform: none; }</style></noscript>
|
||||||
|
<script>
|
||||||
|
try {
|
||||||
|
var savedTheme = localStorage.getItem('stg-site-theme');
|
||||||
|
document.documentElement.dataset.theme = savedTheme || 'dark';
|
||||||
|
} catch (error) {}
|
||||||
|
</script>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header class="topbar">
|
||||||
|
<div class="wrap topbar-inner">
|
||||||
|
<a class="brand" href="#top">
|
||||||
|
<img src="assets/logo.png" alt="SpineThumbnails 图标" width="30" height="30">
|
||||||
|
<span class="brand-name">SpineThumbnails</span>
|
||||||
|
<span class="brand-ver">v1.0.0</span>
|
||||||
|
</a>
|
||||||
|
<nav class="nav" aria-label="页面导航">
|
||||||
|
<a href="#features">功能特性</a>
|
||||||
|
<a href="#safety">安全机制</a>
|
||||||
|
<a href="#workflow">生成流程</a>
|
||||||
|
<a href="#versions">支持版本</a>
|
||||||
|
<a href="#install">安装使用</a>
|
||||||
|
</nav>
|
||||||
|
<div class="topbar-actions">
|
||||||
|
<button class="icon-btn" id="themeToggle" type="button" aria-label="切换深浅主题" title="切换深浅主题">
|
||||||
|
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/></svg>
|
||||||
|
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2z"/></svg>
|
||||||
|
</button>
|
||||||
|
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">获取插件</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main id="top">
|
||||||
|
<section class="hero">
|
||||||
|
<div class="hero-glow" aria-hidden="true"></div>
|
||||||
|
<div class="wrap hero-grid">
|
||||||
|
<div class="hero-copy">
|
||||||
|
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件 · 自动资源配对</span>
|
||||||
|
<h1>导入完成,<br><span class="grad">缩略图自动就位</span></h1>
|
||||||
|
<p class="hero-sub">
|
||||||
|
SpineThumbnails 识别 Eagle 中导入的 Spine 骨骼、Atlas 与贴图,
|
||||||
|
自动跨 item 文件夹完成配对,再按你的设置生成静态 PNG 或动态 WebP 缩略图。
|
||||||
|
从版本识别到渲染、写入、进度反馈,一次完成。
|
||||||
|
</p>
|
||||||
|
<div class="hero-cta">
|
||||||
|
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">前往插件页</a>
|
||||||
|
<a class="link-arrow" href="#workflow">了解工作流程 →</a>
|
||||||
|
</div>
|
||||||
|
<ul class="stats">
|
||||||
|
<li><b>9</b><span>Spine 版本<br>3.4 – 4.3</span></li>
|
||||||
|
<li><b>2</b><span>缩略图模式<br>静态 / 动态</span></li>
|
||||||
|
<li><b>8</b><span>界面语言<br>简繁英日韩俄西德</span></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="hero-visual">
|
||||||
|
<figure class="shot shot-hero reveal">
|
||||||
|
<img class="zoomable" src="assets/ui-main.jpg" alt="SpineThumbnails 插件主界面:自动生成开关、生成方式、策略、状态、统计与分类日志" width="1560" height="1296">
|
||||||
|
<figcaption>SpineThumbnails 1.0.0 · 插件主界面</figcaption>
|
||||||
|
</figure>
|
||||||
|
<div class="badge-chip reveal">
|
||||||
|
<img src="assets/logo.png" alt="" width="24" height="24">
|
||||||
|
<span>首次使用默认关闭自动生成,只有主动开启后才会处理新导入的素材。</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wrap">
|
||||||
|
<ul class="file-chips" aria-label="可识别的 Spine 资源类型">
|
||||||
|
<li class="chip chip-on">json</li>
|
||||||
|
<li class="chip chip-on">skel</li>
|
||||||
|
<li class="chip chip-on">skel.bytes</li>
|
||||||
|
<li class="chip chip-on">atlas</li>
|
||||||
|
<li class="chip chip-on">atlas.txt</li>
|
||||||
|
<li class="chip chip-off">png / jpg / webp · 用于 atlas 贴图配对</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="safety-band" id="safety">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="safety-head reveal">
|
||||||
|
<span class="kicker">SAFE BY DEFAULT</span>
|
||||||
|
<h2>默认关闭,只处理新导入</h2>
|
||||||
|
<p>缩略图会写入 Eagle 素材卡片,因此在首次使用时需要明确打开自动生成开关。</p>
|
||||||
|
</div>
|
||||||
|
<ul class="safety-grid">
|
||||||
|
<li class="reveal">
|
||||||
|
<span class="safety-mark">01</span>
|
||||||
|
<strong>不扫描历史素材</strong>
|
||||||
|
<p>插件从启用后的新导入批次开始监听,不会回头批量修改已有素材。</p>
|
||||||
|
</li>
|
||||||
|
<li class="reveal">
|
||||||
|
<span class="safety-mark">02</span>
|
||||||
|
<strong>不覆盖自定义缩略图</strong>
|
||||||
|
<p>检测到素材已有自定义缩略图时直接跳过,并把结果记录在分类日志中。</p>
|
||||||
|
</li>
|
||||||
|
<li class="reveal">
|
||||||
|
<span class="safety-mark">03</span>
|
||||||
|
<strong>配置独立保存</strong>
|
||||||
|
<p>设置写入 Eagle 用户数据目录,不修改素材 <code>metadata.json</code>、标签或注释。</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="features">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head reveal">
|
||||||
|
<span class="kicker">FEATURES</span>
|
||||||
|
<h2>导入之后,剩下交给插件</h2>
|
||||||
|
<p class="section-lead">不是只会渲染一张图:资源发现、跨目录配对、版本切换、编码搜索和写入反馈都在同一个后台流程里完成。</p>
|
||||||
|
</div>
|
||||||
|
<div class="features">
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 15l6-6"/><path d="M10.5 6.6L12.1 5a4 4 0 0 1 5.7 5.7l-1.6 1.6"/><path d="M13.5 17.4L11.9 19a4 4 0 0 1-5.7-5.7l1.6-1.6"/></svg></div>
|
||||||
|
<h3>跨文件夹自动配对</h3>
|
||||||
|
<p>Eagle 会把每个导入文件拆成独立 item 文件夹。插件按文件名与尾缀规则关联骨骼、atlas 和贴图,即使它们分散在不同目录也能组成一套资源。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M7 9h10M7 13h6"/><circle cx="17" cy="15" r="2.2"/></svg></div>
|
||||||
|
<h3>静态与动态两种输出</h3>
|
||||||
|
<p>静态模式固定使用 setup pose 输出 PNG;动态模式读取骨骼中的第一个动画,逐帧渲染后封装为轻量 WebP。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 18V9M10 18V5M16 18v-7M22 18V3"/><path d="M2 18h22"/></svg></div>
|
||||||
|
<h3>体积与速度双策略</h3>
|
||||||
|
<p>“最优体积”生成十个候选档位并保留最小结果;“最优速度”先粗估再只渲染最有希望的一两档,适合大批量导入。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 3.5v11"/><circle cx="18" cy="6" r="2.6"/><circle cx="6" cy="17.5" r="2.6"/><path d="M18 8.6a9 9 0 0 1-9 8.9"/></svg></div>
|
||||||
|
<h3>9 个运行版本</h3>
|
||||||
|
<p>从 JSON 版本字段或 SKEL 二进制头部识别 Spine 版本,自动匹配 3.4–4.3 对应的本地运行时。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/><path d="M16 15l2 2 3-4"/></svg></div>
|
||||||
|
<h3>批次串行处理</h3>
|
||||||
|
<p>一次只渲染一个素材,避免批量导入时把 Eagle 卡住。后续批次自动排队,进度窗口会同步总数与剩余数量。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M10 8.4l6 3.6-6 3.6z"/></svg></div>
|
||||||
|
<h3>暂停、继续与取消</h3>
|
||||||
|
<p>大批量动态生成时自动显示进度窗口。可暂停当前队列、继续执行,或取消剩余任务并保留已经完成的结果。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5h14v14H5z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg></div>
|
||||||
|
<h3>分类日志</h3>
|
||||||
|
<p>成功、失败、警告和错误可分别筛选。每条记录包含素材名、Eagle 文件夹、骨架、atlas 与贴图齐全情况。</p>
|
||||||
|
</article>
|
||||||
|
<article class="feature-card reveal">
|
||||||
|
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg></div>
|
||||||
|
<h3>八种界面语言</h3>
|
||||||
|
<p>跟随 Eagle 当前语言显示简体中文、繁体中文、英文、日文、韩文、俄文、西班牙文或德文界面。</p>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section alt" id="workflow">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head reveal">
|
||||||
|
<span class="kicker">WORKFLOW</span>
|
||||||
|
<h2>从导入到缩略图</h2>
|
||||||
|
<p class="section-lead">首次打开自动生成后,后续新导入的 Spine 素材会沿同一条串行流程处理。</p>
|
||||||
|
</div>
|
||||||
|
<ol class="steps">
|
||||||
|
<li class="step reveal">
|
||||||
|
<span class="step-num">01</span>
|
||||||
|
<h3>发现新素材</h3>
|
||||||
|
<p>监听 Eagle 素材库目录,在导入批次稳定后读取新增 item,历史素材不会进入队列。</p>
|
||||||
|
</li>
|
||||||
|
<li class="step reveal">
|
||||||
|
<span class="step-num">02</span>
|
||||||
|
<h3>配对与检查</h3>
|
||||||
|
<p>关联骨骼、atlas 和贴图,检查版本、复合后缀、贴图数量以及是否已有自定义缩略图。</p>
|
||||||
|
</li>
|
||||||
|
<li class="step reveal">
|
||||||
|
<span class="step-num">03</span>
|
||||||
|
<h3>渲染缩略图</h3>
|
||||||
|
<p>加载匹配运行时,按静态或动态模式绘制画布。动态模式继续搜索满足目标体积的编码参数。</p>
|
||||||
|
</li>
|
||||||
|
<li class="step reveal">
|
||||||
|
<span class="step-num">04</span>
|
||||||
|
<h3>写入与反馈</h3>
|
||||||
|
<p>通过 Eagle 官方接口写入自定义缩略图,并同步更新统计、分类日志与进度窗口。</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section alt" id="modes">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head reveal">
|
||||||
|
<span class="kicker">OUTPUT</span>
|
||||||
|
<h2>按素材用途选择输出</h2>
|
||||||
|
<p class="section-lead">两种模式共用同一套资源识别与安全检查,只在最终渲染阶段不同。</p>
|
||||||
|
</div>
|
||||||
|
<div class="mode-grid">
|
||||||
|
<article class="mode-card reveal">
|
||||||
|
<div class="mode-visual static" aria-hidden="true">
|
||||||
|
<span class="pose-ring"></span>
|
||||||
|
<span class="pose-bone b1"></span>
|
||||||
|
<span class="pose-bone b2"></span>
|
||||||
|
<span class="pose-bone b3"></span>
|
||||||
|
<span class="mode-file">PNG</span>
|
||||||
|
</div>
|
||||||
|
<div class="mode-copy">
|
||||||
|
<span class="mode-label">STATIC</span>
|
||||||
|
<h3>静帧 · setup pose</h3>
|
||||||
|
<p>始终生成一个稳定的 default 姿势,适合需要明确默认形象、动画首帧不适合作为封面的素材。</p>
|
||||||
|
<ul>
|
||||||
|
<li>输出格式:PNG</li>
|
||||||
|
<li>渲染内容:setup pose</li>
|
||||||
|
<li>速度:更快,结果稳定</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
<article class="mode-card reveal">
|
||||||
|
<div class="mode-visual animated" aria-hidden="true">
|
||||||
|
<span class="motion-dot d1"></span>
|
||||||
|
<span class="motion-dot d2"></span>
|
||||||
|
<span class="motion-dot d3"></span>
|
||||||
|
<span class="motion-line"></span>
|
||||||
|
<span class="mode-file">WEBP</span>
|
||||||
|
</div>
|
||||||
|
<div class="mode-copy">
|
||||||
|
<span class="mode-label">ANIMATED</span>
|
||||||
|
<h3>动态 · 首个动画</h3>
|
||||||
|
<p>读取骨骼中的第一个动画,采样帧后封装为动态 WebP,让素材卡片直接呈现动作状态。</p>
|
||||||
|
<ul>
|
||||||
|
<li>输出格式:动画 WebP</li>
|
||||||
|
<li>渲染内容:第一个动画</li>
|
||||||
|
<li>速度:更慢,需搜索编码档位</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="versions">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head reveal">
|
||||||
|
<span class="kicker">SUPPORTED</span>
|
||||||
|
<h2>覆盖 Spine 3.4 – 4.3</h2>
|
||||||
|
<p class="section-lead">支持 JSON、SKEL、SKEL.BYTES、ATLAS 与 ATLAS.TXT,使用与皮肤和约束相匹配的本地运行时渲染。</p>
|
||||||
|
</div>
|
||||||
|
<ul class="v-chips reveal" aria-label="支持的 Spine 版本">
|
||||||
|
<li>3.4</li><li>3.5</li><li>3.6</li><li>3.7</li><li class="sep" aria-hidden="true">·</li>
|
||||||
|
<li>3.8</li><li>4.0</li><li>4.1</li><li>4.2</li><li>4.3</li>
|
||||||
|
</ul>
|
||||||
|
<div class="v-table-wrap reveal">
|
||||||
|
<table class="v-table">
|
||||||
|
<thead>
|
||||||
|
<tr><th>版本</th><th>骨骼输入</th><th>渲染说明</th></tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<td><code>3.4</code> / <code>3.5</code></td>
|
||||||
|
<td>JSON · SKEL · SKEL.BYTES</td>
|
||||||
|
<td>使用独立版本运行时和匹配的二进制解析分支,保留网格、权重、变形、约束与绘制顺序。</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>3.6</code> / <code>3.7</code></td>
|
||||||
|
<td>JSON · SKEL · SKEL.BYTES</td>
|
||||||
|
<td>通过官方同版本 <code>SkeletonBinary</code> 兼容层解析,无需额外导入 JSON。</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<td><code>3.8</code> – <code>4.3</code></td>
|
||||||
|
<td>JSON · SKEL · SKEL.BYTES</td>
|
||||||
|
<td>读取数据版本并加载对应运行时;4.x 的物理约束、裁剪和染色能力由运行时处理。</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<ul class="chips reveal" aria-label="附加支持">
|
||||||
|
<li>复合后缀 <code>skel.bytes</code></li>
|
||||||
|
<li>复合后缀 <code>atlas.txt</code></li>
|
||||||
|
<li>PMA 贴图</li>
|
||||||
|
<li>多皮肤骨骼</li>
|
||||||
|
<li>动态 WebP</li>
|
||||||
|
<li>进度暂停与取消</li>
|
||||||
|
<li>八种界面语言</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section alt" id="install">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head reveal">
|
||||||
|
<span class="kicker">GET STARTED</span>
|
||||||
|
<h2>安装后只需打开一次开关</h2>
|
||||||
|
<p class="section-lead">插件不会在默认状态下自动写入缩略图。先在控制面板开启“导入时自动生成缩略图”,再导入新的 Spine 资源。</p>
|
||||||
|
</div>
|
||||||
|
<a class="dl-cta reveal" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">
|
||||||
|
<span class="dl-cta-info">
|
||||||
|
<span class="dl-os">EAGLE PLUGIN COMMUNITY</span>
|
||||||
|
<strong>在 Eagle 插件社区获取 SpineThumbnails</strong>
|
||||||
|
<span class="dl-meta">搜索“Spine缩略图”,或直接打开 Eagle 插件社区。</span>
|
||||||
|
</span>
|
||||||
|
<span class="btn btn-primary">前往插件页 →</span>
|
||||||
|
</a>
|
||||||
|
<ol class="install-steps reveal">
|
||||||
|
<li>
|
||||||
|
<h3>安装并打开插件</h3>
|
||||||
|
<p>在 Eagle 中安装插件,打开 SpineThumbnails 控制面板。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>启用自动生成</h3>
|
||||||
|
<p>打开“导入时自动生成缩略图”,选择动态或静态模式,以及最优体积或最优速度策略。</p>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<h3>导入整套资源</h3>
|
||||||
|
<p>把骨骼、atlas 和贴图导入 Eagle。插件会只针对本次新导入的素材自动处理。</p>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<aside class="callout reveal">
|
||||||
|
<strong>第一次使用为什么没有自动生成?</strong>
|
||||||
|
<p>自动生成默认关闭,避免未经确认就批量修改缩略图。开启后只处理之后新导入的素材,不会追溯历史内容。</p>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="section" id="faq">
|
||||||
|
<div class="wrap">
|
||||||
|
<div class="section-head reveal">
|
||||||
|
<span class="kicker">FAQ</span>
|
||||||
|
<h2>常见问题</h2>
|
||||||
|
</div>
|
||||||
|
<div class="faq">
|
||||||
|
<details class="reveal">
|
||||||
|
<summary>插件会覆盖我已经设置好的缩略图吗?</summary>
|
||||||
|
<p>不会。检测到素材已有自定义缩略图时会跳过并记录日志。插件只通过 Eagle 官方接口设置当前新素材的缩略图。</p>
|
||||||
|
</details>
|
||||||
|
<details class="reveal">
|
||||||
|
<summary>为什么历史素材没有自动处理?</summary>
|
||||||
|
<p>这是默认安全行为。插件只处理启用后新导入的素材,不扫描、不修改历史素材,避免一次开关造成全素材库批量变更。</p>
|
||||||
|
</details>
|
||||||
|
<details class="reveal">
|
||||||
|
<summary>资源没有同时导入会怎样?</summary>
|
||||||
|
<p>配套不全的素材会记为待观察。后续批次补齐 atlas 或贴图后,插件会重新检查并继续处理。</p>
|
||||||
|
</details>
|
||||||
|
<details class="reveal">
|
||||||
|
<summary>动态缩略图为什么比静态慢?</summary>
|
||||||
|
<p>动态模式需要渲染多个动画帧,还要在分辨率、帧率、质量和目标体积之间寻找合适档位。选择“最优速度”会减少实际生成的候选数量。</p>
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="wrap footer-inner">
|
||||||
|
<div class="foot-brand">
|
||||||
|
<img src="assets/logo.png" alt="" width="34" height="34">
|
||||||
|
<div>
|
||||||
|
<strong>SpineThumbnails</strong>
|
||||||
|
<span>Eagle 插件 · v1.0.0</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<div class="lightbox" id="lightbox" hidden>
|
||||||
|
<img alt="放大查看插件界面截图">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
After Width: | Height: | Size: 812 KiB |
|
After Width: | Height: | Size: 941 KiB |
|
After Width: | Height: | Size: 859 KiB |
|
After Width: | Height: | Size: 835 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 120 KiB |