Author SHA1 Message Date
tianmo 5808e3dfce 按钮颜色 2026-09-19 19:01:49 +08:00
tianmo b258d736fd 悬浮提示 2026-09-19 16:05:54 +08:00
tianmo e60506ef96 修复缩放 2026-09-19 15:02:27 +08:00
tianmo e2df519d2e 显示调整 2026-09-19 11:15:44 +08:00
tianmo 081d06c70e 加入awebp编码支持 2026-09-19 10:36:32 +08:00
tianmo eb2a7f7d91 动态缩略图生成 2026-09-19 10:15:44 +08:00
tianmo 4ff35ce352 动态缩略图修改 2026-09-19 09:48:09 +08:00
Yeomai a429ebbe6b 左下角样式修改 2026-09-18 22:20:20 +08:00
tianmo c113089e2d 动态缩略图生成 2026-09-18 19:23:40 +08:00
tianmo 52bdbc9f3c 默认预览setup pose、修复播放动画时的画布缩放问题 2026-09-18 17:50:54 +08:00
Yeomai a4e86b37fc 文件封面与功能说明 2026-09-18 00:37:38 +08:00
Yeomai 392e71fb22 插件打包 2026-09-18 00:09:22 +08:00
Yeomai ff7ae2cd84 移除死文件 2026-09-18 00:07:38 +08:00
Yeomai 6366635feb 移除死链 2026-09-17 23:58:36 +08:00
Yeomai c21c55b959 eagle插件 2026-09-17 23:55:48 +08:00
Yeomai 94e9304d9e 尾缀识别 2026-09-17 23:38:30 +08:00
tianmo d9f7bd2f89 封面提交 2026-09-17 19:34:12 +08:00
tianmo 2cb70c49d4 预览扫描 2026-09-17 18:47:17 +08:00
tianmo 2a5fe0ce1f 资源组更新缩略图 2026-09-17 18:40:29 +08:00
tianmo 05c4fc4b90 缩略图生成修改 2026-09-17 14:52:18 +08:00
tianmo fe71d2d6ce 移除导入时的缩略图生成 2026-09-17 14:23:33 +08:00
tianmo bb73cc9227 1.1.1版本审核修复
[spine-utils.js (line 35)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-utils.js:35) 统一拦截绝对路径、..、Windows 盘符、UNC、URI、控制字符和符号链接越界。
跨素材贴图只能使用已枚举、验证且位于 Eagle 素材目录内的文件。
[spine-render.js (line 62)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/js/spine-render.js:62) 已删除未经验证的路径备用拼接。
自动缩略图只写当前回调提供的 dest,不再调用接口改写同组素材。
已删除后台整组补刷进程及 relatedDataPaths。
[viewer/spine.html (line 482)](/Users/tianmokeji/Desktop/特效/SpineViewer/SpineViewer/viewer/spine.html:482) 的手动缩略图只针对当前素材,并核对素材 ID、文件名和扩展名。
当前素材已有自定义缩略图时,会提示替换后无法自动恢复并要求确认。
手动生成的中间文件改存系统临时目录,API 失败时不再直接写 Eagle 素材目录。
八种语言文案及 README 已同步。
处理历史日志遗留问题
2026-09-17 13:38:22 +08:00
100 changed files with 4916 additions and 5026 deletions

No files matched your search

-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 lynweklm@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+14 -46
View File
@@ -8,7 +8,7 @@
普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。 普通图片(`png/jpg/jpeg/webp`)仍由 Eagle 原生预览,插件不接管。
Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再检查完整文件名。导入复合后缀时,插件通过缩略图回调将 `lili.atlas.txt` 规范为 `name: "lili"`、`ext: "atlas.txt"`,`skel.bytes` 同理,磁盘文件名保持不变;同时生成匹配新名称的 WebP 缓存。这样可避开 Eagle 对 `txt` 强制显示文本卡片的逻辑。普通 TXT 保留文本内容、正常显示;普通 BYTES 显示文件说明,两者都不扫描资源库。修改注册后需要重新加载插件或重启 Eagle。旧条目重新生成缩略图后,如果列表仍保留旧名称,需要重新打开素材库。 Eagle 按最后一段后缀分发格式扩展,因此插件同时注册 `txt` 和 `bytes`,再由查看器检查完整文件名。普通 JSON/TXT/BYTES 不会进入 Spine 预览。插件不注册导入缩略图处理器,导入阶段由 Eagle 自身处理。
支持的 Spine 版本:**3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。 支持的 Spine 版本:**3.4 / 3.5 / 3.6 / 3.7 / 3.8 / 4.0 / 4.1 / 4.2 / 4.3**。
@@ -24,14 +24,12 @@ SpineViewer/
├─ logo.png # 插件图标 ├─ logo.png # 插件图标
├─ assets/ ├─ assets/
│ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge │ └─ spine-badge.png # 缩略图右下角使用的 Spine 官方 badge
├─ thumbnail/
│ └─ spine.js # 缩略图生成(渲染骨骼首帧并交由 Eagle 缓存)
├─ viewer/ ├─ viewer/
│ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配) │ └─ spine.html # 预览页面(动画播放 / 动画/皮肤切换 / 速度 / 缩放适配)
├─ js/ ├─ js/
│ ├─ spine-utils.js # 资源匹配、版本检测工具 │ ├─ spine-utils.js # 资源匹配、版本检测工具
│ ├─ spine-legacy-binary.js # 3.4–3.7 二进制解析兼容层 │ ├─ spine-legacy-binary.js # 3.4–3.7 二进制解析兼容层
│ └─ spine-render.js # 纯 Node 渲染模块(缩略图用) │ └─ spine-render.js # Node 渲染辅助模块
├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地) ├─ lib/ # 各版本 Spine 官方运行时(已拷贝到本地)
│ ├─ 3.4/ spine-canvas.js │ ├─ 3.4/ spine-canvas.js
│ ├─ 3.5/ spine-canvas.js │ ├─ 3.5/ spine-canvas.js
@@ -42,8 +40,7 @@ SpineViewer/
│ ├─ 4.1/ spine-canvas.js │ ├─ 4.1/ spine-canvas.js
│ ├─ 4.2/ spine-canvas.js │ ├─ 4.2/ spine-canvas.js
│ └─ 4.3/ spine-canvas.js │ └─ 4.3/ spine-canvas.js
├─ LICENSES/ # 第三方运行时及依赖的再分发许可证 └─ LICENSES/ # 第三方运行时的再分发许可证
└─ node_modules/ # 可选:@napi-rs/canvas(用于高质量缩略图渲染)
``` ```
--- ---
@@ -52,7 +49,7 @@ SpineViewer/
1. **跨文件夹资源配对**:Eagle 会把导入的每个文件拆分成独立的元数据文件夹(`images/<id>.info/`,内含资产文件与 `metadata.json`),因此配套文件可能不在同一目录。插件会: 1. **跨文件夹资源配对**:Eagle 会把导入的每个文件拆分成独立的元数据文件夹(`images/<id>.info/`,内含资产文件与 `metadata.json`),因此配套文件可能不在同一目录。插件会:
- 定位到所选文件的 item 文件夹; - 定位到所选文件的 item 文件夹;
- 扫描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma` 等常见后缀)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**; - 涋描其**同级的所有 item 文件夹**,根据文件名(忽略 `-pro`、`-ess`、`-pma`、`-base`、`-atlas`、`-data`、`-mesh` 等常见尾缀,`-` 与 `_` 均可)自动关联出配套的 **骨骼文件** + **atlas** + **贴图**;尾缀表可在预览顶栏的“尾缀”下拉里追加自定义项;
- 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。 - 从 atlas 中解析它引用的贴图页面,再到库中定位实际贴图文件。
2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。 2. **版本检测**:读取骨骼数据里的版本号(`.json` 读 `skeleton.spine`,`.skel` 读二进制头部),自动选择匹配的本地运行时。
3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持: 3. **预览**:查看器通过 Node `fs` 直接读取分离在各 item 文件夹中的骨骼/atlas/贴图文件(无需网络、无 CORS),用 `spine-canvas` 渲染器在 `<canvas>` 上实时播放动画,并支持:
@@ -65,13 +62,9 @@ SpineViewer/
- 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局 - 浮动时间轴支持拖动、动画时长、秒刻度与关键帧标记;顶部控制栏同样采用浮动布局
- 零时长动画按单帧静态姿势预览 - 零时长动画按单帧静态姿势预览
- 自动适配并居中 - 自动适配并居中
- 将当前帧设为自定义缩略图,并通知 Eagle 立即刷新列表缓存 - 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
4. **缩略图**:使用 `@napi-rs/canvas` 在 Node 中跨 item 文件夹读取资源并渲染骨骼首帧。自动生成和预览器中手动点击“生成缩略图”都会在右下角添加 Spine 官方 badge,左下角添加完整导出版本号(例如 `3.4.02`),便于识别数据文件。无法识别版本时不显示版本文字。导入时会等待同批文件写入稳定,再按入库批次配对,避免复用旧库里的同名资源。处理器写入 PNG 工作图,最终缩略图缓存格式由 Eagle 决定,可能是体积更小的 WebP。 - 顶栏“尾缀”下拉列出内置尾缀(`pro` `ess` `pma` `base` `atlas` `data` `mesh`)与用户自定义尾缀;选择“+ 添加尾缀…”可新增(仅字母、数字、`-`、`_`,最长 50 字符,上限由 `js/spine-utils.js` 的 `SUFFIX_MAX_LENGTH` 统一定义),选择自定义项可移除。配置保存在 Eagle userData 的 `SpineViewer/suffix-settings.json`,对全部素材与资源库生效,修改后自动重新加载以重新配对
- 同一前缀的 `atlas/json/skel` 共用资源发现和渲染任务,完成后主动补刷这一组的全部数据条目。 4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,仍可用原有按钮将当前帧设为**当前素材**的自定义缩略图。左侧的“为关联资源生成缩略图”会先列出已验证的同 stem `json / skel / skel.bytes / atlas / atlas.txt` 和 atlas 引用贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户可主动勾选已有自定义缩略图的素材,确认后覆盖,原图无法由插件自动恢复。手动缩略图会添加 Spine badge 和导出版本号。
- JSON 只在 `skeleton.spine` 的值是有效版本号时才按 Spine 骨骼数据处理;普通 JSON、JSONC、空版本字段和仅含 `bones` / `slots` 同名字段的数据会立即放行,不写缩略图,不进入全库资源扫描或补刷队列,以保留 Eagle 或其他 JSON 插件的处理结果。Eagle 查看器不支持内容级转交,所以打开这类文件时会隐藏全部 Spine 控件,仅显示格式化的 JSON 文本。
- 暂时缺少骨骼、atlas 或贴图时,前台最多短暂等待约半秒便显示回退图;后台采用逐步降频的方式观察约两分钟。链路补齐且文件稳定后自动生成 WebP 并通知 Eagle 刷新;更晚补拖的文件会通过新的导入回调重新触发识别。
- 分批拖入时优先在同一个 Eagle 文件夹内补齐;大型文件夹导入会共享短时文件索引,避免每个条目重复扫描整个库。
- Eagle 在缩略图回调结束后会关闭执行窗口,因此补刷使用独立 Node 工作进程和临时文件队列,不依赖窗口内的定时器。每个素材库同时只有一个工作进程;确认资源稳定、素材已经入库后调用 Eagle API 补刷,失败会重试约两分钟,队列完成后退出。不会后台常驻。
--- ---
@@ -85,6 +78,8 @@ SpineViewer/
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。 Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。导入阶段不由插件生成缩略图;手动批量操作只作用于明确列出并得到确认的当前资源组,已有自定义缩略图的素材默认不选中,只有用户明确勾选才会覆盖。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。 - 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。 - 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
@@ -106,7 +101,7 @@ node tests/legacy-binary.test.js tests/.fixtures 3.4
3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。 3.4 JSON 导出对数值采用截断,测试容许对应量化误差;Goblins 二进制还将三个端点相同的阶梯区间优化为线性,测试明确验证此差异。运行时保留二进制原始精度。
- **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。 - **3.8 / 4.x** 的 `.skel` / `.skel.bytes` 二进制文件完整支持。
- **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览、生成缩略图及后台补刷。 - **3.6 / 3.7**:插件已加入基于官方同版本 `spine-libgdx/SkeletonBinary.java` 移植的 JavaScript 解析兼容层,支持 `.skel` / `.skel.bytes`。无需同时导入 JSON;SKEL、atlas 和贴图齐全即可预览。
- 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。 - 旧版数据始终使用匹配的 3.6 或 3.7 运行时,保留网格、权重、变形、约束、裁剪、绘制顺序和事件。3.7 的 IK 压缩/拉伸与音频事件字段单独解析。兼容层不需要编译或联网。
- 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。 - 已验证官方 **3.6.32 / 3.7.90** 的七套示例;损坏、截断或版本不匹配的文件会报告解析错误。
@@ -133,51 +128,24 @@ node tests/legacy-binary.test.js
--- ---
## 可选依赖说明
`@napi-rs/canvas` 仅用于缩略图的高质量渲染。它并非必需:
- 若保留 `node_modules/`(当前平台原生二进制),缩略图会渲染骨骼形象。
- 若删除 `node_modules/`,插件仍可正常预览动画,缩略图会显示一条纯 JS 占位图。
如需在多个平台分发,请安装对应平台的 `@napi-rs/canvas` 原生二进制。
---
## 分享与打包 ## 分享与打包
### 分享源码 ### 分享源码
仓库中的 `node_modules/` 被 `.gitignore` 忽略是正常的。接收者克隆源码后,需要在插件目录执行: 源码不依赖原生 Node 扩展;加载插件后即可使用预览和手动缩略图功能。
```bash
npm ci --omit=dev
```
`@napi-rs/canvas` 会按当前操作系统和 CPU 下载预编译的 N-API 二进制,正常情况下不需要编译,也不需要安装 Rust/C++ 工具链。安装完成后,再从 Eagle 插件面板加载或打包插件。
### 分享可直接安装的插件 ### 分享可直接安装的插件
1. 在准备出包的目标系统上执行 `npm ci --omit=dev`。 1. 在 Eagle 中打开插件面板(快捷键 `P`),右键此插件并选择“打包插件”。
2. 确认插件目录中保留了 `node_modules/`。否则骨骼动画仍可预览,但自动缩略图只能使用占位图。 2. 把生成的 `.eagleplugin` 文件发给对方。安装包不含平台原生依赖,Windows 与 macOS 共用。
3. 在 Eagle 中打开插件面板(快捷键 `P`),右键此插件并选择“打包插件”。
4. 把生成的 `.eagleplugin` 文件发给对方。对方直接安装,不需要再执行 npm,也不需要编译。
当前工作目录安装的是 **macOS Apple 芯片(darwin-arm64)** 的 Canvas 原生二进制。它不能直接作为 Windows 或 Intel Mac 的完整缩略图版本分发。建议分别在以下目标环境执行安装、测试和打包:
- macOS Apple 芯片
- macOS Intel
- Windows x64
如果每个安装包只携带一个平台的原生二进制,出包时还应把 `manifest.json` 的 `platform` / `arch` 改为对应的 `mac` + `arm`、`mac` + `x64` 或 `win` + `x64`。只有安装包确实包含并测试过所有目标平台的原生二进制时,才保留当前的 `all` + `all`。
### 出包文件检查 ### 出包文件检查
完整功能包至少应包含: 完整功能包至少应包含:
- `manifest.json`、`logo.png` - `manifest.json`、`logo.png`
- `viewer/`、`thumbnail/`、`js/`、`lib/`、`assets/` - `viewer/`、`js/`、`lib/`、`assets/`
- `LICENSES/` - `LICENSES/`
- `package.json`、`package-lock.json` - `package.json`、`package-lock.json`
- 与目标平台匹配的 `node_modules/`
无需放入发布包的开发文件包括 `.git/`、日志、`.DS_Store`、`debug*.js`、`test-*.js`、`SpineViewer.code-workspace`,以及未在 `manifest.json` 中使用的 ICNS 测试文件。 无需放入发布包的开发文件包括 `.git/`、日志、`.DS_Store`、`debug*.js`、`test-*.js`、`SpineViewer.code-workspace`,以及未在 `manifest.json` 中使用的 ICNS 测试文件。
+63 -3
View File
@@ -18,12 +18,52 @@
"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.",
"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 +94,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 +116,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)"
} }
} }
+62 -2
View File
@@ -18,12 +18,54 @@
"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.",
"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 +96,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 +118,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)"
} }
} }
+63 -3
View File
@@ -18,12 +18,52 @@
"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.",
"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 +94,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 +116,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)"
} }
} }
+63 -3
View File
@@ -18,12 +18,52 @@
"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)。上限を増やすかフレーム範囲を短くしてください。",
"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 +94,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 +116,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": "カスタムサムネイルあり(未選択)"
} }
} }
+63 -3
View File
@@ -18,12 +18,52 @@
"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). 제한을 높이거나 프레임 범위를 줄이세요.",
"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 +94,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 +116,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": "사용자 지정 썸네일 있음(선택 안 함)"
} }
} }
+63 -3
View File
@@ -18,12 +18,52 @@
"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} КБ). Увеличьте лимит или сократите диапазон кадров.",
"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 +94,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 +116,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": "Есть своя миниатюра (не выбрано)"
} }
} }
+62 -2
View File
@@ -18,12 +18,54 @@
"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),请提高上限或缩短帧范围",
"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 +96,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 +118,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)"
} }
} }
+62 -2
View File
@@ -18,12 +18,54 @@
"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),請提高上限或縮短影格範圍",
"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 +96,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 +118,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)"
} }
} }
+77
View File
@@ -0,0 +1,77 @@
'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) {
const input = Buffer.from(webp);
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
throw new Error('Invalid WebP frame');
}
const parts = [];
let hasAlpha = false;
let offset = 12;
while (offset + 8 <= input.length) {
const type = input.toString('ascii', offset, offset + 4);
const length = input.readUInt32LE(offset + 4);
const end = offset + 8 + length + (length & 1);
if (end > input.length) throw new Error('Truncated WebP frame');
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
if (type === 'ALPH') hasAlpha = true;
offset = end;
}
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
throw new Error('WebP frame has no image data');
}
return { data: Buffer.concat(parts), hasAlpha };
}
function encode(options) {
const width = Number(options && options.width);
const height = Number(options && options.height);
const frames = options && options.frames;
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
throw new RangeError('Animated WebP dimensions are invalid');
}
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
const vp8x = Buffer.alloc(10);
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
writeUint24LE(vp8x, 4, width - 1);
writeUint24LE(vp8x, 7, height - 1);
const anim = Buffer.alloc(6);
const background = options.background || [30, 30, 30, 255];
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
for (const { frame, data } of preparedFrames) {
const header = Buffer.alloc(16);
writeUint24LE(header, 6, width - 1);
writeUint24LE(header, 9, height - 1);
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
header[15] = 0x02;
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
}
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
const output = Buffer.alloc(8 + body.length);
output.write('RIFF', 0, 4, 'ascii');
output.writeUInt32LE(body.length, 4);
body.copy(output, 8);
return output;
}
module.exports = { encode };
+115
View File
@@ -0,0 +1,115 @@
'use strict';
const zlib = require('zlib');
const PNG_SIGNATURE = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const crcTable = new Uint32Array(256);
for (let n = 0; n < 256; n++) {
let value = n;
for (let k = 0; k < 8; k++) value = (value & 1) ? (0xedb88320 ^ (value >>> 1)) : (value >>> 1);
crcTable[n] = value >>> 0;
}
function crc32(buffer) {
let value = 0xffffffff;
for (const byte of buffer) value = crcTable[(value ^ byte) & 0xff] ^ (value >>> 8);
return (value ^ 0xffffffff) >>> 0;
}
function chunk(type, data) {
const name = Buffer.from(type, 'ascii');
const body = Buffer.from(data || []);
const out = Buffer.allocUnsafe(12 + body.length);
out.writeUInt32BE(body.length, 0);
name.copy(out, 4);
body.copy(out, 8);
out.writeUInt32BE(crc32(Buffer.concat([name, body])), 8 + body.length);
return out;
}
function paeth(left, above, upperLeft) {
const estimate = left + above - upperLeft;
const leftDistance = Math.abs(estimate - left);
const aboveDistance = Math.abs(estimate - above);
const cornerDistance = Math.abs(estimate - upperLeft);
return leftDistance <= aboveDistance && leftDistance <= cornerDistance ? left
: aboveDistance <= cornerDistance ? above : upperLeft;
}
function filterFrame(rgba, width, height) {
const stride = width * 4;
const result = Buffer.allocUnsafe((stride + 1) * height);
const candidates = Array.from({ length: 5 }, () => Buffer.allocUnsafe(stride));
for (let y = 0; y < height; y++) {
const rowStart = y * stride;
let bestType = 0;
let bestScore = Infinity;
for (let type = 0; type < 5; type++) {
const candidate = candidates[type];
let score = 0;
for (let x = 0; x < stride; x++) {
const raw = rgba[rowStart + x];
const left = x >= 4 ? rgba[rowStart + x - 4] : 0;
const above = y > 0 ? rgba[rowStart - stride + x] : 0;
const upperLeft = y > 0 && x >= 4 ? rgba[rowStart - stride + x - 4] : 0;
let predicted = 0;
if (type === 1) predicted = left;
else if (type === 2) predicted = above;
else if (type === 3) predicted = Math.floor((left + above) / 2);
else if (type === 4) predicted = paeth(left, above, upperLeft);
const filtered = (raw - predicted + 256) & 0xff;
candidate[x] = filtered;
score += Math.abs(filtered < 128 ? filtered : filtered - 256);
}
if (score < bestScore) { bestScore = score; bestType = type; }
}
const outputStart = y * (stride + 1);
result[outputStart] = bestType;
candidates[bestType].copy(result, outputStart + 1);
}
return result;
}
function encode(options) {
const width = Number(options.width);
const height = Number(options.height);
const frames = options.frames || [];
if (!Number.isInteger(width) || width <= 0 || !Number.isInteger(height) || height <= 0) throw new TypeError('APNG 尺寸无效');
if (!frames.length) throw new TypeError('APNG 至少需要一帧');
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6; // 8-bit RGBA
const animationControl = Buffer.alloc(8);
animationControl.writeUInt32BE(frames.length, 0);
animationControl.writeUInt32BE(Math.max(0, Number(options.plays) || 0), 4);
const chunks = [PNG_SIGNATURE, chunk('IHDR', ihdr), chunk('acTL', animationControl)];
let sequence = 0;
for (let index = 0; index < frames.length; index++) {
const frame = frames[index];
const rgba = Buffer.from(frame.rgba.buffer, frame.rgba.byteOffset, frame.rgba.byteLength);
if (rgba.length !== width * height * 4) throw new TypeError('APNG 帧像素数量无效');
const control = Buffer.alloc(26);
control.writeUInt32BE(sequence++, 0);
control.writeUInt32BE(width, 4); control.writeUInt32BE(height, 8);
control.writeUInt32BE(0, 12); control.writeUInt32BE(0, 16);
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayNumerator) || 1)), 20);
control.writeUInt16BE(Math.max(1, Math.min(65535, Number(frame.delayDenominator) || 1000)), 22);
control[24] = 0; // keep the previous frame
control[25] = 0; // replace the full canvas
chunks.push(chunk('fcTL', control));
const compressed = zlib.deflateSync(filterFrame(rgba, width, height), {
level: Math.max(0, Math.min(9, Number(options.compressionLevel) || 9)),
});
if (index === 0) chunks.push(chunk('IDAT', compressed));
else {
const frameData = Buffer.allocUnsafe(4 + compressed.length);
frameData.writeUInt32BE(sequence++, 0);
compressed.copy(frameData, 4);
chunks.push(chunk('fdAT', frameData));
}
}
chunks.push(chunk('IEND'));
return Buffer.concat(chunks);
}
module.exports = { encode };
-17
View File
@@ -1,17 +0,0 @@
'use strict';
const path = require('path');
// Preserve name + '.' + ext exactly; only split the compound suffix correctly.
// Return through Eagle's thumbnail callback so Eagle persists its own metadata.
function normalizeItem(src, item) {
const filename = path.basename(src);
const match = filename.match(/^(.*)\.(atlas\.txt|skel\.bytes)$/i);
if (!match || !item || !item.ext) return false;
if (item.name + '.' + item.ext !== filename) return false;
if (item.name === match[1] && item.ext === match[2]) return false;
item.name = match[1]; item.ext = match[2];
delete item.text;
return true;
}
module.exports = { normalizeItem };
+75
View File
@@ -0,0 +1,75 @@
'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 || [];
const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs;
const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs;
const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts;
const delay = (ms) => ms > 0 ? new Promise((resolve) => setTimeout(resolve, ms)) : Promise.resolve();
const tempPaths = [];
let success = 0;
let skipped = Number(options.initialSkipped) || 0;
let failed = 0;
for (let index = 0; index < targets.length; index++) {
if (options.onProgress) options.onProgress(index + 1, targets.length);
let itemTempPath = null;
try {
const target = targets[index];
const fresh = await options.getItem(target.sourcePath);
if (fresh.data.customThumbnail === true && target.allowOverwrite !== true) { skipped++; continue; }
itemTempPath = path.join(os.tmpdir(), 'spineviewer-group-' + fresh.id + '-' + crypto.randomBytes(12).toString('hex') + options.rendered.extension);
fs.writeFileSync(itemTempPath, options.rendered.buffer);
tempPaths.push(itemTempPath);
let applied = false;
let lastError = null;
for (let attempt = 0; attempt < retryAttempts && !applied; attempt++) {
try {
const accepted = await options.setThumbnail(fresh.id, itemTempPath);
if (accepted !== true) throw new Error(options.notConfirmedMessage || 'Thumbnail update was rejected');
applied = await options.waitForApplied(target.sourcePath);
if (!applied) throw new Error(options.notConfirmedMessage || 'Thumbnail update was not confirmed');
} catch (error) {
lastError = error;
if (attempt < retryAttempts - 1) await delay(settleDelayMs);
}
}
if (!applied) throw lastError || new Error(options.notConfirmedMessage || 'Thumbnail update was not confirmed');
// The local HTTP API can resolve before Eagle has finished consuming the
// supplied file. Keep every file alive and serialize requests with a short
// settling interval so a later request cannot replace an earlier queued job.
await delay(settleDelayMs);
success++;
if (options.onSuccess) options.onSuccess(fresh);
} catch (error) {
failed++;
if (options.onError) options.onError(error);
} finally { /* temporary inputs are retained until the whole queue settles */ }
}
await delay(cleanupDelayMs);
for (const tempPath of tempPaths) {
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
}
return { success, skipped, failed };
}
module.exports = { applyThumbnailTargets, orderCurrentTargetLast };
+32 -1
View File
@@ -55,4 +55,35 @@ async function resolveDataPath(app) {
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle'); return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
} }
module.exports = { createStore, resolveDataPath }; // 尾缀表是全局偏好(不是每个素材一份):同一台机器上所有素材、所有库共用。
// 查看器在加载资源前读取它,用户的增删都会立即落盘。
function createSuffixStore(userDataPath) {
const directory = path.join(userDataPath, 'SpineViewer');
const file = path.join(directory, 'suffix-settings.json');
return {
file,
read() {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return Array.isArray(data.suffixes) ? data.suffixes : [];
} catch (e) {
if (e.code === 'ENOENT') return [];
throw e;
}
},
// 仅在用户通过下拉列表增删尾缀时调用。
write(suffixes) {
if (!Array.isArray(suffixes)) throw new TypeError('尾缀设置必须为数组');
fs.mkdirSync(directory, { recursive: true });
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try {
fs.writeFileSync(temp, JSON.stringify({ suffixes }, null, 2) + '\n', { flag: 'wx' });
fs.renameSync(temp, file);
} finally {
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
};
}
module.exports = { createStore, createSuffixStore, resolveDataPath };
-85
View File
@@ -1,85 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const utils = require('./spine-utils');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function enqueue(src) {
const library = utils.findLibraryRoot(src);
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
fs.mkdirSync(directory, { recursive: true });
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
fs.renameSync(temp, job);
// Electron's Node mode runs the local worker without opening another Eagle.
const child = spawn(process.execPath, [__filename, directory], {
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
});
child.on('error', error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
});
child.unref();
}
async function run(directory) {
const lock = path.join(directory, 'worker.lock');
let fd;
try { fd = fs.openSync(lock, 'wx'); }
catch (error) {
if (error.code !== 'EEXIST') throw error;
let pid;
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
if (!pid) return; // Another worker is still writing its lock.
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
try { fs.unlinkSync(lock); } catch (_) { return; }
return run(directory);
}
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
const snapshots = new Map();
const nextChecks = new Map();
const thumbnail = require('../thumbnail/spine');
try {
let idle = 0;
while (idle < 2) {
await sleep(1000); // Allow the importing callback/window to finish.
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
if (!jobs.length) { idle++; continue; }
idle = 0;
for (const name of jobs) {
const file = path.join(directory, name);
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
const job = JSON.parse(raw);
if (Date.now() < (nextChecks.get(name) || 0)) continue;
const remaining = job.deadline - Date.now();
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
const result = Date.now() > job.deadline ? { complete: true }
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
snapshots.set(name, result.signature);
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
}
} catch (error) {
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
}
}
}
} finally {
fs.unlinkSync(lock);
// Cover an enqueue racing the final idle check and lock release.
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
}
}
module.exports = { enqueue, run };
if (require.main === module) run(process.argv[2]).catch(error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
process.exitCode = 1;
});
+6 -6
View File
@@ -59,7 +59,7 @@ function loadRuntime(dir) {
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。 /** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。 * texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/ */
function buildTextureAtlas(spine, atlasText, dir, texturePaths) { function buildTextureAtlas(spine, atlasText, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
(async () => { (async () => {
@@ -69,9 +69,10 @@ function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
const textureMap = {}; const textureMap = {};
await Promise.all(pageNames.map(async (name) => { await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录 // 只接受 spine-utils 已完成目录、realpath 与符号链接边界验证的路径。
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name); // 不在这里根据不可信的 atlas 页面名再次拼接文件系统路径。
if (!fs.existsSync(p)) return; const p = texturePaths && texturePaths[name];
if (!p || !fs.existsSync(p)) return;
try { try {
const img = await canvasLib.loadImage(p); const img = await canvasLib.loadImage(p);
textureMap[name] = img; textureMap[name] = img;
@@ -150,12 +151,11 @@ function getBounds(spine, skeleton) {
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) { async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用'); if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath); const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8'); const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true; const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir); const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths); const atlas = await buildTextureAtlas(spine, atlasText, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary); const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) { if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
+168 -37
View File
@@ -19,8 +19,130 @@ const fileIndexCache = new Map();
const skeletonJsonCache = new Map(); const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024; const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符) // Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i; // 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
const SUFFIX_MAX_LENGTH = 50;
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
let activeSuffixes = DEFAULT_SUFFIXES.slice();
let suffixRe = buildSuffixRe(activeSuffixes);
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildSuffixRe(list) {
if (!list || list.length === 0) return null;
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
}
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
function normalizeSuffixName(raw) {
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
}
/**
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
*/
function setSuffixes(list) {
const custom = [];
for (const raw of Array.isArray(list) ? list : []) {
const name = normalizeSuffixName(raw);
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
if (custom.indexOf(name) === -1) custom.push(name);
}
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
suffixRe = buildSuffixRe(activeSuffixes);
return getSuffixes();
}
/** 当前生效的尾缀(内置 + 自定义) */
function getSuffixes() {
return activeSuffixes.slice();
}
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
function getCustomSuffixes() {
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
}
function unsafeAtlasPath(name, reason) {
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
error.code = 'UNSAFE_ATLAS_PATH';
return error;
}
/**
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
*/
function validateAtlasPageName(name) {
const raw = String(name == null ? '' : name).trim();
if (!raw) throw unsafeAtlasPath(name, '路径为空');
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
const portable = raw.replace(/\\/g, '/');
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
throw unsafeAtlasPath(raw, '不允许绝对路径');
}
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
const parts = portable.split('/');
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
const normalized = path.posix.normalize(portable);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw unsafeAtlasPath(raw, '路径无效');
}
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
}
return normalized.replace(/^\.\//, '');
}
function isPathInside(basePath, targetPath) {
const relative = path.relative(basePath, targetPath);
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
}
function realpath(filePath) {
const implementation = fs.realpathSync.native || fs.realpathSync;
return implementation(filePath);
}
/**
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
*/
function resolveFileWithin(baseDir, relativeName, atlasPage) {
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
const base = path.resolve(baseDir);
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
if (!isPathInside(base, candidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
return null;
}
if (!fs.existsSync(candidate)) return null;
let realBase;
let realCandidate;
try {
realBase = realpath(base);
realCandidate = realpath(candidate);
} catch (e) {
return null;
}
if (!isPathInside(realBase, realCandidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
return null;
}
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
return candidate;
}
function resolveAtlasTextureFile(atlasDir, pageName) {
return resolveFileWithin(atlasDir, pageName, true);
}
function extOf(name) { function extOf(name) {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
@@ -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,
+26
View File
@@ -0,0 +1,26 @@
'use strict';
function findAlphaBounds(rgba, width, height, threshold = 4) {
let left = width, top = height, right = 0, bottom = 0;
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
left = Math.min(left, x);
top = Math.min(top, y);
right = Math.max(right, x + 1);
bottom = Math.max(bottom, y + 1);
}
}
return right > left && bottom > top ? { left, top, right, bottom } : null;
}
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
if (!pixels || !isFinite(scale) || scale <= 0) return null;
const left = centerX + (pixels.left - width / 2) / scale;
const right = centerX + (pixels.right - width / 2) / scale;
const top = centerY - (pixels.top - height / 2) / scale;
const bottom = centerY - (pixels.bottom - height / 2) / scale;
return { x: left, y: bottom, width: right - left, height: top - bottom };
}
module.exports = { findAlphaBounds, toWorldBounds };
-5
View File
@@ -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 -201
View File
@@ -6,207 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.1"
"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"
}
} }
} }
} }
+2 -3
View File
@@ -2,10 +2,9 @@
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.1",
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)", "description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
"main": "thumbnail/spine.js",
"private": true, "private": true,
"type": "commonjs", "type": "commonjs",
"dependencies": { "scripts": {
"@napi-rs/canvas": "^0.1.66" "check:release": "node tests/release-package.test.js"
} }
} }
Binary file not shown.

After

Width:  |  Height:  |  Size: 812 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 941 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 859 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 835 KiB

Binary file not shown.
Binary file not shown.
@@ -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.
+12 -21
View File
@@ -1,32 +1,23 @@
# SpineViewer 1.1.1 # SpineViewer 1.1.1
Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、3.7、3.8、4.0、4.1、4.2、4.3。 Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4–4.3,适用于 Windows 和 macOS。
本包为**windows和macos通用包**,同时适用于 **Windows x64** 与 **macOS Apple 芯片**。
## 功能与使用 ## 功能与使用
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内,并保留文件名配对关系。 1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内。
2. 打开骨骼或图集文件即可预览。普通图片仍使用 Eagle 原生预览。 2. 打开骨骼或图集文件即可预览动画,并可切换动画、皮肤、速度、循环、背景和 PMA。
3. 支持动画和皮肤切换、播放/暂停(空格)、循环、速度、时间轴拖动、缩放适配、背景与预乘 Alpha(PMA)切换。 3. 插件不接管导入阶段的缩略图,由 Eagle 自身处理。
4. 自动生成带 Spine 标记和版本号的缩略图,也可在预览器中将当前帧设为缩略图。 4. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为关联资源生成缩略图”会列出同 stem 的骨骼、atlas 及已验证贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户仍可主动选择并覆盖,原图无法由插件自动恢复。
5. 顶栏“尾缀”下拉列出内置尾缀(`pro` `ess` `pma` `base` `atlas` `data` `mesh`)与自定义尾缀:“+ 添加尾缀…”可新增,“选中自定义项”可移除。自定义尾缀保存在本地(Eagle userData),对全部素材生效,用于让 `hero-hover.skel` 这类文件与 `hero.atlas` 配成一套。
## 平台与依赖 ## 安全与隐私
`node_modules/@napi-rs/` 下并排携带三个包,运行时由 `@napi-rs/canvas` 依据 `process.platform` 与 `process.arch` 自动选择,未匹配的平台包不会被加载: - Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。
- 跨素材配对只使用当前 Eagle 素材库中已枚举并验证的文件。
| 平台 | 原生包目录 | - 单素材按钮只作用于当前素材。资源组按钮会先列出全部受影响素材并要求确认,已有自定义缩略图默认不选中,只有用户明确勾选才会覆盖。
| --- | --- |
| Windows x64 | `canvas-win32-x64-msvc` |
| macOS Apple 芯片 | `canvas-darwin-arm64` |
| 公共代码(两平台共用) | `canvas` |
原生包版本均为 **0.1.66**。
清单使用 `platform: all` / `arch: all`。本包**未包含 macOS Intel 与 Windows ARM64 的原生二进制**:这两种环境下动画预览仍可正常使用。
## 多语言 ## 多语言
界面支持简体中文、繁体中文、English、日本語、한국어、Русский、Español、Deutsch,随 Eagle 的界面语言切换,无法识别时回退到简体中文。文案位于 `_locales/`。 支持简体中文、繁体中文、English、日本語、한국어、Русский、Español 和 Deutsch。
第三方许可证见 `LICENSES/`,分发已保留。 第三方许可见 `LICENSES/`。
@@ -18,12 +18,52 @@
"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.",
"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 +94,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 +116,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)"
} }
} }
+62 -2
View File
@@ -18,12 +18,54 @@
"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.",
"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 +96,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 +118,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,52 @@
"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.",
"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 +94,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 +116,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,52 @@
"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)。上限を増やすかフレーム範囲を短くしてください。",
"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 +94,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 +116,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,52 @@
"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). 제한을 높이거나 프레임 범위를 줄이세요.",
"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 +94,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 +116,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,52 @@
"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} КБ). Увеличьте лимит или сократите диапазон кадров.",
"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 +94,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 +116,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,54 @@
"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),请提高上限或缩短帧范围",
"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 +96,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 +118,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,54 @@
"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),請提高上限或縮短影格範圍",
"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 +96,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 +118,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,77 @@
'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) {
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,75 @@
'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 || [];
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;
});
@@ -1,217 +0,0 @@
/**
* SpineViewer - 缩略图渲染模块(仅 Node 环境使用)
* 在纯 Node 中加载本地 Spine 运行时(通过 vm 注入到 global.spine),
* 使用 @napi-rs/canvas 光栅化骨骼的 setup pose / 首帧动画,输出 PNG buffer。
*/
const fs = require('fs');
const vm = require('vm');
const path = require('path');
const utils = require('./spine-utils.js');
const spineBadge = require('./spine-badge.js');
let canvasLib = null;
try {
canvasLib = require('@napi-rs/canvas');
} catch (e) {
canvasLib = null;
}
let spineBadgePromise = null;
/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */
async function drawSpineBadge(ctx, width, height) {
if (!canvasLib || !ctx) return;
try {
if (!spineBadgePromise) {
spineBadgePromise = canvasLib.loadImage(spineBadge.path);
}
const badge = await spineBadgePromise;
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
} catch (e) { /* 缺少 badge 时不影响缩略图主体 */ }
}
/**
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
* 的 BinaryInput、DataView 等),因此不会跨版本混用。
*/
function loadRuntime(dir) {
const code = fs.readFileSync(path.join(__dirname, '..', 'lib', dir, 'spine-canvas.js'), 'utf8');
const sandbox = {
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
performance: { now: () => Date.now() },
};
// 部分运行时通过 window/self/globalThis 访问全局
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
}
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => {
(async () => {
// 解析所有页面贴图名,并预先异步加载
let pageNames = [];
try { pageNames = utils.parseAtlasPages(atlasText); } catch (e) { pageNames = []; }
const textureMap = {};
await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
if (!fs.existsSync(p)) return;
try {
const img = await canvasLib.loadImage(p);
textureMap[name] = img;
} catch (e) { /* ignore */ }
}));
const makeTexture = (name) => {
let img = textureMap[name];
if (!img) {
// 找不到图片时用 2x2 灰色占位纹理
const c = canvasLib.createCanvas(2, 2);
const cx = c.getContext('2d');
cx.fillStyle = '#888888';
cx.fillRect(0, 0, 2, 2);
img = c;
}
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
return new spine.Texture(img);
};
let atlas;
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const tex = makeTexture(page.name);
if (page.setTexture) page.setTexture(tex);
else page.texture = tex;
}
} else {
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
resolve(atlas);
})().catch(reject);
});
}
/** 读取骨骼数据(json 或二进制) */
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buf = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buf));
} else {
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
const text = fs.readFileSync(skeletonPath, 'utf8');
return loader.readSkeletonData(text);
}
}
/** 计算包围盒(跨版本兼容) */
function getBounds(spine, skeleton) {
try {
if (skeleton.getBoundsRect) {
const r = skeleton.getBoundsRect();
if (r && r.width !== undefined) return { x: r.x, y: r.y, width: r.width, height: r.height };
}
if (skeleton.getBounds) {
const offset = new spine.Vector2();
const size = new spine.Vector2();
skeleton.getBounds(offset, size, []);
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
}
} catch (e) { /* ignore */ }
return null;
}
/**
* 渲染首帧动画到 PNG buffer。
* @returns {Promise<Buffer>}
*/
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
const name = path.basename(skeletonPath);
throw new Error('骨骼数据为空:' + name);
}
const skeleton = new spine.Skeleton(skeletonData);
utils.applySkin(skeleton, utils.preferredSkin(skeletonData));
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
let bounds = null;
if (skeleton.setToSetupPose) skeleton.setToSetupPose();
if (skeleton.updateWorldTransform) {
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
else skeleton.updateWorldTransform();
}
bounds = getBounds(spine, skeleton);
const cw = size, ch = size;
const canvas = canvasLib.createCanvas(cw, ch);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, cw, ch);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((cw * (1 - pad * 2)) / bounds.width, (ch * (1 - pad * 2)) / bounds.height);
// Spine 世界坐标 Y 轴向上,而 Canvas 2D Y 轴向下,需翻转 Y。
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
ctx.save();
ctx.translate(cw / 2, ch / 2);
ctx.scale(scale, -scale);
ctx.translate(-cx, -cy);
let renderer = null;
try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); }
catch (e) {
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
}
if (renderer) {
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
renderer.premultipliedAlpha = premultipliedAlpha;
renderer.draw(skeleton);
if (renderer.dispose) renderer.dispose();
}
ctx.restore();
}
await drawSpineBadge(ctx, cw, ch);
spineBadge.drawVersion(ctx, cw, ch, skeletonData.version || version);
const buf = canvas.toBuffer('image/png');
return buf;
}
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib };
+168 -37
View File
@@ -19,8 +19,130 @@ const fileIndexCache = new Map();
const skeletonJsonCache = new Map(); const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024; const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符) // Spine 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i; // 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
const SUFFIX_MAX_LENGTH = 50;
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
let activeSuffixes = DEFAULT_SUFFIXES.slice();
let suffixRe = buildSuffixRe(activeSuffixes);
function escapeRegExp(text) {
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function buildSuffixRe(list) {
if (!list || list.length === 0) return null;
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
}
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
function normalizeSuffixName(raw) {
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
}
/**
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
*/
function setSuffixes(list) {
const custom = [];
for (const raw of Array.isArray(list) ? list : []) {
const name = normalizeSuffixName(raw);
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
if (custom.indexOf(name) === -1) custom.push(name);
}
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
suffixRe = buildSuffixRe(activeSuffixes);
return getSuffixes();
}
/** 当前生效的尾缀(内置 + 自定义) */
function getSuffixes() {
return activeSuffixes.slice();
}
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
function getCustomSuffixes() {
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
}
function unsafeAtlasPath(name, reason) {
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
error.code = 'UNSAFE_ATLAS_PATH';
return error;
}
/**
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
*/
function validateAtlasPageName(name) {
const raw = String(name == null ? '' : name).trim();
if (!raw) throw unsafeAtlasPath(name, '路径为空');
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
const portable = raw.replace(/\\/g, '/');
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
throw unsafeAtlasPath(raw, '不允许绝对路径');
}
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
const parts = portable.split('/');
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
const normalized = path.posix.normalize(portable);
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
throw unsafeAtlasPath(raw, '路径无效');
}
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
}
return normalized.replace(/^\.\//, '');
}
function isPathInside(basePath, targetPath) {
const relative = path.relative(basePath, targetPath);
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
}
function realpath(filePath) {
const implementation = fs.realpathSync.native || fs.realpathSync;
return implementation(filePath);
}
/**
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
*/
function resolveFileWithin(baseDir, relativeName, atlasPage) {
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
const base = path.resolve(baseDir);
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
if (!isPathInside(base, candidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
return null;
}
if (!fs.existsSync(candidate)) return null;
let realBase;
let realCandidate;
try {
realBase = realpath(base);
realCandidate = realpath(candidate);
} catch (e) {
return null;
}
if (!isPathInside(realBase, realCandidate)) {
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
return null;
}
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
return candidate;
}
function resolveAtlasTextureFile(atlasDir, pageName) {
return resolveFileWithin(atlasDir, pageName, true);
}
function extOf(name) { function extOf(name) {
const lower = name.toLowerCase(); const lower = name.toLowerCase();
@@ -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 };
@@ -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,3 +0,0 @@
# `@napi-rs/canvas-darwin-arm64`
This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas`
@@ -1,40 +0,0 @@
{
"name": "@napi-rs/canvas-darwin-arm64",
"version": "0.1.66",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"main": "skia.darwin-arm64.node",
"files": [
"skia.darwin-arm64.node"
],
"description": "Canvas for Node.js with skia backend",
"keywords": [
"napi-rs",
"NAPI",
"N-API",
"Rust",
"node-addon",
"node-addon-api",
"canvas",
"image",
"pdf",
"svg",
"skia"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Brooooooklyn/canvas.git"
}
}
@@ -1,3 +0,0 @@
# `@napi-rs/canvas-win32-x64-msvc`
This is the **x86_64-pc-windows-msvc** binary for `@napi-rs/canvas`
@@ -1,41 +0,0 @@
{
"name": "@napi-rs/canvas-win32-x64-msvc",
"version": "0.1.66",
"os": [
"win32"
],
"cpu": [
"x64"
],
"main": "skia.win32-x64-msvc.node",
"files": [
"skia.win32-x64-msvc.node",
"icudtl.dat"
],
"description": "Canvas for Node.js with skia backend",
"keywords": [
"napi-rs",
"NAPI",
"N-API",
"Rust",
"node-addon",
"node-addon-api",
"canvas",
"image",
"pdf",
"svg",
"skia"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Brooooooklyn/canvas.git"
}
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 lynweklm@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-361
View File
@@ -1,361 +0,0 @@
# `skr canvas`
[![CI](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml/badge.svg)](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml)
![Skia Version](https://img.shields.io/badge/Skia-chrome%2Fm133-hotpink)
[![install size](https://packagephobia.com/badge?p=@napi-rs/canvas)](https://packagephobia.com/result?p=@napi-rs/canvas)
[![Downloads](https://img.shields.io/npm/dm/@napi-rs/canvas.svg?sanitize=true)](https://npmcharts.com/compare/@napi-rs/canvas?minimal=true)
> 🚀 Help me to become a full-time open-source developer by [sponsoring me on Github](https://github.com/sponsors/Brooooooklyn)
Google Skia binding to Node.js via [Node-API](https://napi.rs), **0 System dependencies!**
⚠️ This project is in pre-release stage. And there may be some bugs.<br/>
For details on planned features and future direction please refer to the [Roadmap](https://github.com/Brooooooklyn/canvas/issues/113).
[中文文档](./README-zh.md)
# Install
```bash
yarn add @napi-rs/canvas
npm install @napi-rs/canvas
```
# Support matrix
## System requirement
### `arm64`
[**_cortex-a57_**](https://en.wikipedia.org/wiki/ARM_Cortex-A57) or newer CPU architecture on **Linux**.
All Apple M chips on **macOS**.
### `armv7`
[**_cortex-a7_**](https://en.wikipedia.org/wiki/ARM_Cortex-A7) or newer CPU architecture.
### glibc
Since Skia relies on the [glibc](https://www.gnu.org/software/libc/) 2.18 API, you need to have at least glibc version >= 2.18 on your system.
## AWS Lambda usage
To use this library on Lambda you will need to use a Lambda layer.
You can simply attach a lambda layer by getting an ARN from [Canvas-Lambda-Layer](https://github.com/ShivamJoker/Canvas-Lambda-Layer)
> Make sure to exclude `@napi-rs/canvas` while bundling your Lambda.
# Usage
```js
const { promises } = require('node:fs')
const { join } = require('node:path')
const { createCanvas, loadImage } = require('@napi-rs/canvas')
const canvas = createCanvas(300, 320)
const ctx = canvas.getContext('2d')
ctx.lineWidth = 10
ctx.strokeStyle = '#03a9f4'
ctx.fillStyle = '#03a9f4'
// Wall
ctx.strokeRect(75, 140, 150, 110)
// Door
ctx.fillRect(130, 190, 40, 60)
// Roof
ctx.beginPath()
ctx.moveTo(50, 140)
ctx.lineTo(150, 60)
ctx.lineTo(250, 140)
ctx.closePath()
ctx.stroke()
async function main() {
// load images from disk or from a URL
const catImage = await loadImage('path/to/cat.png')
const dogImage = await loadImage('https://example.com/path/to/dog.jpg')
ctx.drawImage(catImage, 0, 0, catImage.width, catImage.height)
ctx.drawImage(dogImage, canvas.width / 2, canvas.height / 2, dogImage.width, dogImage.height)
// export canvas as image
const pngData = await canvas.encode('png') // JPEG, AVIF and WebP are also supported
// encoding in libuv thread pool, non-blocking
await promises.writeFile(join(__dirname, 'simple.png'), pngData)
}
main()
```
![](./example/simple.png)
## Emoji text
```js
const { writeFileSync } = require('fs')
const { join } = require('path')
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas')
GlobalFonts.registerFromPath(join(__dirname, '..', 'fonts', 'AppleColorEmoji@2x.ttf'), 'Apple Emoji')
GlobalFonts.registerFromPath(join(__dirname, '..', '__test__', 'fonts', 'COLRv1.ttf'), 'COLRv1')
console.info(GlobalFonts.families)
const canvas = createCanvas(760, 360)
const ctx = canvas.getContext('2d')
ctx.font = '50px Apple Emoji'
ctx.strokeText('😀😃😄😁😆😅😂🤣☺️😊😊😇', 50, 150)
ctx.font = '100px COLRv1'
ctx.fillText('abc', 50, 300)
const b = canvas.toBuffer('image/png')
writeFileSync(join(__dirname, 'draw-emoji.png'), b)
```
![](./example/draw-emoji.png)
# Performance
See [benchmark](./benchmark) for benchmark code.
Hardware info:
```
,MMMM. Host - xxxxxxxxxxxxxxxxxxxxxxx
.MMMMMM Machine - Mac15,9
MMMMM, Kernel - 24.0.0
.;MMMMM:' MMMMMMMMMM;. OS - macOS 15.0.1 Sequoia
MMMMMMMMMMMMNWMMMMMMMMMMM: DE - Aqua
.MMMMMMMMMMMMMMMMMMMMMMMMWM. WM - Quartz Compositor
MMMMMMMMMMMMMMMMMMMMMMMMM. Packages - 194 (Homebrew), 32 (cargo)
;MMMMMMMMMMMMMMMMMMMMMMMM: Shell - zsh
:MMMMMMMMMMMMMMMMMMMMMMMM: Terminal - warpterminal (Version v0.2024.10.23.14.49.stable_00)
.MMMMMMMMMMMMMMMMMMMMMMMMM. Resolution - 5120x2880@160fps (as 2560x1440)
MMMMMMMMMMMMMMMMMMMMMMMMMMM. 2992x1934@120fps (as 1496x967)
.MMMMMMMMMMMMMMMMMMMMMMMMMM. 2232x1512@60fps (as 1116x756)
MMMMMMMMMMMMMMMMMMMMMMMM Uptime - 1d 2h 32m
;MMMMMMMMMMMMMMMMMMMM. CPU - Apple M3 Max (16)
.MMMM,. .MMMM,. CPU Load - 16%
Memory - 50.1 GB / 134.2 GB
Battery - 78% & Discharging
Disk Space - 624.0 GB / 994.7 GB
```
```
❯ yarn bench
Draw a House and export to PNG
┌─────────┬─────────────────┬───────────────────────┬──────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
├─────────┼─────────────────┼───────────────────────┼──────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
│ 0 │ '@napi-rs/skia' │ '14676992.14 ± 0.68%' │ '14602333.00' │ '68 ± 0.59%' │ '68' │ 69 │
│ 1 │ 'skia-canvas' │ '21167809.17 ± 2.05%' │ '20960021.00 ± 13646.00' │ '47 ± 1.31%' │ '48' │ 64 │
│ 2 │ 'node-canvas' │ '16552027.42 ± 0.70%' │ '16451291.50 ± 2208.50' │ '60 ± 0.62%' │ '61' │ 64 │
└─────────┴─────────────────┴───────────────────────┴──────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
Draw Gradient and export to PNG
┌─────────┬─────────────────┬───────────────────────┬─────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
├─────────┼─────────────────┼───────────────────────┼─────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
│ 0 │ '@napi-rs/skia' │ '15228495.58 ± 0.53%' │ '15146312.50 ± 1187.50' │ '66 ± 0.48%' │ '66' │ 66 │
│ 1 │ 'skia-canvas' │ '21725564.41 ± 2.20%' │ '21412520.50 ± 2104.50' │ '46 ± 1.39%' │ '47' │ 64 │
│ 2 │ 'node-canvas' │ '17976022.14 ± 1.53%' │ '17563479.50 ± 5104.50' │ '56 ± 1.38%' │ '57' │ 64 │
└─────────┴─────────────────┴───────────────────────┴─────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
```
# Features
## Path2D
```typescript
new Path2D()
new Path2D(path: Path2D)
// new Path2D('M108.956,403.826c0,0,0.178,3.344-1.276,3.311 c-1.455-0.033-30.507-84.917-66.752-80.957C40.928,326.18,72.326,313.197,108.956,403.826z')
new Path2D(path: string)
```
```typescript
export interface DOMMatrix2DInit {
a: number
b: number
c: number
d: number
e: number
f: number
}
export class Path2D {
constructor(path?: Path2D | string)
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
closePath(): void
ellipse(
x: number,
y: number,
radiusX: number,
radiusY: number,
rotation: number,
startAngle: number,
endAngle: number,
anticlockwise?: boolean,
): void
lineTo(x: number, y: number): void
moveTo(x: number, y: number): void
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
rect(x: number, y: number, w: number, h: number): void
// PathKit methods
op(path: Path2D, operation: PathOp): Path2D
toSVGString(): string
getFillType(): FillType
getFillTypeString(): string
setFillType(type: FillType): void
simplify(): Path2D
asWinding(): Path2D
stroke(stroke?: StrokeOptions): Path2D
transform(transform: DOMMatrix2DInit): Path2D
getBounds(): [left: number, top: number, right: number, bottom: number]
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
trim(start: number, end: number, isComplement?: boolean): Path2D
equals(path: Path2D): boolean
}
```
## PathKit
`PathKit` is a toolset for manipulating Path in `Skia`, supporting **_quadratic beziers_**, **_cubic beziers_** and **_conics_**.
The main features are.
### Path Operation
`.op(path, PathOp)`
```js
const pathOne = new Path2D(
'M8 50H92C96.4183 50 100 53.5817 100 58V142C100 146.418 96.4183 150 92 150H8C3.58172 150 0 146.418 0 142V58C0 53.5817 3.58172 50 8 50Z',
)
const pathTwo = new Path2D(
'"M58 0H142C146.418 0 150 3.58172 150 8V92C150 96.4183 146.418 100 142 100H58C53.5817 100 50 96.4183 50 92V8C50 3.58172 53.5817 0 58 0Z',
)
pathOne.op(pathTwo, PathOp.Intersect).toSVGString()
// => "M100 100L58 100C53.5817 100 50 96.4183 50 92L50 50L92 50C96.4183 50 100 53.5817 100 58L100 100Z"
```
- **Union**, subtract the op path from the first path
- **Difference**, intersect the two paths
- **ReverseDifference**, union (inclusive-or) the two paths
- **Intersect**, exclusive-or the two paths
- **XOR**, subtract the first path from the op path
![boolean-operations](./docs/imgs/boolean-operations.svg)
### Covert `FillType` in **_Path_**
`.asWinding()`
You can convert `fill-rule="evenodd"` to `fill-rule="nonzero"` in SVG.
This is useful for **OpenType** font-related tools, as `fill-rule="nonzero"` is only supported in **OpenType** fonts.
![SVG fill-rule](./docs/imgs/asWinding@2x.png)
```js
const pathCircle = new Path2D(
'M24.2979 13.6364H129.394V40.9091H24.2979L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455H70.4673V68.1818H16.073C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727V113.636C7.00728 118.657 11.0661 122.727 16.073 122.727H70.4673V150H84.0658V122.727H128.041C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818H84.0658V54.5455H133.927C138.934 54.5455 142.993 50.4755 142.993 45.4545V9.09091C142.993 4.07014 138.934 0 133.927 0H21.9592ZM125.702 109.091H20.6058V81.8182H125.702L135.372 95.4545L125.702 109.091Z',
)
pathCircle.setFillType(FillType.EvenOdd)
pathCircle.asWinding().toSVGString()
// => "M24.2979 13.6364L129.394 13.6364L129.394 40.9091L24.2979 40.9091L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455L70.4673 54.5455L70.4673 68.1818L16.073 68.1818C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727L7.00728 113.636C7.00728 118.657 11.0661 122.727 16.073 122.727L70.4673 122.727L70.4673 150L84.0658 150L84.0658 122.727L128.041 122.727C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818L84.0658 68.1818L84.0658 54.5455L133.927 54.5455C138.934 54.5455 142.993 50.4755 142.993 45.4545L142.993 9.09091C142.993 4.07014 138.934 0 133.927 0L21.9592 0ZM125.702 109.091L20.6058 109.091L20.6058 81.8182L125.702 81.8182L135.372 95.4545L125.702 109.091Z"
```
### Simplify **_Path_**
`.simplify()`
Set the path to the same non-overlapping contour as the original path area, which means that it can also remove overlapping paths.
<img width="800" src="./docs/imgs/simplify.png" >
[SVG with overlapping paths](./docs/imgs/overlapping-path.svg) (Left)
```js
const path =
'M2.933,89.89 L89.005,3.818 Q90.412,2.411 92.249,1.65 Q94.087,0.889 96.076,0.889 Q98.065,0.889 99.903,1.65 Q101.741,2.411 103.147,3.818 L189.22,89.89 Q190.626,91.296 191.387,93.134 Q192.148,94.972 192.148,96.961 Q192.148,98.95 191.387,100.788 Q190.626,102.625 189.219,104.032 Q187.813,105.439 185.975,106.2 Q184.138,106.961 182.148,106.961 Q180.159,106.961 178.322,106.2 Q176.484,105.439 175.077,104.032 L89.005,17.96 L96.076,10.889 L103.147,17.96 L17.075,104.032 Q15.668,105.439 13.831,106.2 Q11.993,106.961 10.004,106.961 Q8.015,106.961 6.177,106.2 Q4.339,105.439 2.933,104.032 Q1.526,102.625 0.765,100.788 Q0.004,98.95 0.004,96.961 Q0.004,94.972 0.765,93.134 Q1.526,91.296 2.933,89.89 Z'
path.simplify().toSVGString()
// => "M89.005 3.818L2.933 89.89Q1.526 91.296 0.765 93.134Q0.004 94.972 0.004 96.961Q0.004 98.95 0.765 100.788Q1.526 102.625 2.933 104.032Q4.339 105.439 6.177 106.2Q8.015 106.961 10.004 106.961Q11.993 106.961 13.831 106.2Q15.668 105.439 17.075 104.032L96.076 25.031L175.077 104.032Q176.484 105.439 178.322 106.2Q180.159 106.961 182.148 106.961Q184.138 106.961 185.975 106.2Q187.813 105.439 189.219 104.032Q190.626 102.625 191.387 100.788Q192.148 98.95 192.148 96.961Q192.148 94.972 191.387 93.134Q190.626 91.296 189.22 89.89L103.147 3.818Q101.741 2.411 99.903 1.65Q98.065 0.889 96.076 0.889Q94.087 0.889 92.249 1.65Q90.412 2.411 89.005 3.818Z"
```
# [Example](./example/tiger.js)
> The tiger.json was serialized from [gojs/samples/tiger](https://github.com/NorthwoodsSoftware/GoJS/blob/master/samples/tiger.html)
<img width="500" src="example/tiger.png">
```shell
node example/anime-girl.js
```
| SVG | PNG |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img width="500" src="example/anime-girl.svg"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) | <img width="500" src="example/anime-girl.png"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) |
# Building
## Build skia from source
You can build this project from source, the system requirements are here: https://skia.org/docs/user/build
```sh
# Clone the code:
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
$ cd canvas
# Build Skia:
$ node scripts/build-skia.js
# Install NPM packages, build the Node.js addon:
$ npm install -g yarn
$ yarn install --mode=skip-build # Here are modules that are used for benchmarking and are hard to install, you can skip it by specifying `--mode=skip-build`
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
$ yarn build
# All done! Run test cases or examples now:
$ yarn test
$ node example/tiger.js
```
## Pull pre-build skia binary from GitHub
You can pull skia pre-build binaries if you just care the `Rust` part:
```sh
# Clone the code:
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
$ cd canvas
# Download Skia binaries:
# It will pull the binaries match the git hash in `./skia` submodule
$ node scripts/release-skia-binary.mjs --download
# Install NPM packages, build the Node.js addon:
$ npm install -g yarn
$ yarn install --mode=skip-build
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
$ yarn build
# All done! Run test cases or examples now:
$ yarn test
$ node example/tiger.js
```
-866
View File
@@ -1,866 +0,0 @@
const { inspect } = require('util')
/*
* vendored in order to fix its dependence on the window global [cds 2020/08/04]
* otherwise unchanged from https://github.com/jarek-foksa/geometry-polyfill/tree/f36bbc8f4bc43539d980687904ce46c8e915543d
*/
// @info
// DOMPoint polyfill
// @src
// https://drafts.fxtf.org/geometry/#DOMPoint
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_point_read_only.cc
class DOMPoint {
constructor(x = 0, y = 0, z = 0, w = 1) {
this.x = x
this.y = y
this.z = z
this.w = w
}
static fromPoint(otherPoint) {
return new DOMPoint(
otherPoint.x,
otherPoint.y,
otherPoint.z !== undefined ? otherPoint.z : 0,
otherPoint.w !== undefined ? otherPoint.w : 1,
)
}
matrixTransform(matrix) {
if ((matrix.is2D || matrix instanceof SVGMatrix) && this.z === 0 && this.w === 1) {
return new DOMPoint(
this.x * matrix.a + this.y * matrix.c + matrix.e,
this.x * matrix.b + this.y * matrix.d + matrix.f,
0,
1,
)
} else {
return new DOMPoint(
this.x * matrix.m11 + this.y * matrix.m21 + this.z * matrix.m31 + this.w * matrix.m41,
this.x * matrix.m12 + this.y * matrix.m22 + this.z * matrix.m32 + this.w * matrix.m42,
this.x * matrix.m13 + this.y * matrix.m23 + this.z * matrix.m33 + this.w * matrix.m43,
this.x * matrix.m14 + this.y * matrix.m24 + this.z * matrix.m34 + this.w * matrix.m44,
)
}
}
toJSON() {
return {
x: this.x,
y: this.y,
z: this.z,
w: this.w,
}
}
}
// @info
// DOMRect polyfill
// @src
// https://drafts.fxtf.org/geometry/#DOMRect
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_rect_read_only.cc
class DOMRect {
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x
this.y = y
this.width = width
this.height = height
}
static fromRect(otherRect) {
return new DOMRect(otherRect.x, otherRect.y, otherRect.width, otherRect.height)
}
get top() {
return this.y
}
get left() {
return this.x
}
get right() {
return this.x + this.width
}
get bottom() {
return this.y + this.height
}
toJSON() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
top: this.top,
left: this.left,
right: this.right,
bottom: this.bottom,
}
}
}
for (const propertyName of ['top', 'right', 'bottom', 'left']) {
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMRect.prototype, propertyName)
propertyDescriptor.enumerable = true
Object.defineProperty(DOMRect.prototype, propertyName, propertyDescriptor)
}
// @info
// DOMMatrix polyfill (SVG 2)
// @src
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_matrix_read_only.cc
// https://github.com/tocharomera/generativecanvas/blob/master/node-canvas/lib/DOMMatrix.js
const M11 = 0,
M12 = 1,
M13 = 2,
M14 = 3
const M21 = 4,
M22 = 5,
M23 = 6,
M24 = 7
const M31 = 8,
M32 = 9,
M33 = 10,
M34 = 11
const M41 = 12,
M42 = 13,
M43 = 14,
M44 = 15
const A = M11,
B = M12
const C = M21,
D = M22
const E = M41,
F = M42
const DEGREE_PER_RAD = 180 / Math.PI
const RAD_PER_DEGREE = Math.PI / 180
const VALUES = Symbol('values')
const IS_2D = Symbol('is2D')
function parseMatrix(init) {
let parsed = init.replace(/matrix\(/, '').split(/,/, 7)
if (parsed.length !== 6) {
throw new Error(`Failed to parse ${init}`)
}
parsed = parsed.map(parseFloat)
return [parsed[0], parsed[1], 0, 0, parsed[2], parsed[3], 0, 0, 0, 0, 1, 0, parsed[4], parsed[5], 0, 1]
}
function parseMatrix3d(init) {
const parsed = init.replace(/matrix3d\(/, '').split(/,/, 17)
if (parsed.length !== 16) {
throw new Error(`Failed to parse ${init}`)
}
return parsed.map(parseFloat)
}
function parseTransform(tform) {
const type = tform.split(/\(/, 1)[0]
if (type === 'matrix') {
return parseMatrix(tform)
} else if (type === 'matrix3d') {
return parseMatrix3d(tform)
} else {
throw new Error(`${type} parsing not implemented`)
}
}
const setNumber2D = (receiver, index, value) => {
if (typeof value !== 'number') {
throw new TypeError('Expected number')
}
receiver[VALUES][index] = value
}
const setNumber3D = (receiver, index, value) => {
if (typeof value !== 'number') {
throw new TypeError('Expected number')
}
if (index === M33 || index === M44) {
if (value !== 1) {
receiver[IS_2D] = false
}
} else if (value !== 0) {
receiver[IS_2D] = false
}
receiver[VALUES][index] = value
}
const newInstance = (values) => {
const instance = Object.create(DOMMatrix.prototype)
instance.constructor = DOMMatrix
instance[IS_2D] = true
instance[VALUES] = values
return instance
}
const multiply = (first, second) => {
const dest = new Float64Array(16)
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
let sum = 0
for (let k = 0; k < 4; k++) {
sum += first[i * 4 + k] * second[k * 4 + j]
}
dest[i * 4 + j] = sum
}
}
return dest
}
class DOMMatrix {
get m11() {
return this[VALUES][M11]
}
set m11(value) {
setNumber2D(this, M11, value)
}
get m12() {
return this[VALUES][M12]
}
set m12(value) {
setNumber2D(this, M12, value)
}
get m13() {
return this[VALUES][M13]
}
set m13(value) {
setNumber3D(this, M13, value)
}
get m14() {
return this[VALUES][M14]
}
set m14(value) {
setNumber3D(this, M14, value)
}
get m21() {
return this[VALUES][M21]
}
set m21(value) {
setNumber2D(this, M21, value)
}
get m22() {
return this[VALUES][M22]
}
set m22(value) {
setNumber2D(this, M22, value)
}
get m23() {
return this[VALUES][M23]
}
set m23(value) {
setNumber3D(this, M23, value)
}
get m24() {
return this[VALUES][M24]
}
set m24(value) {
setNumber3D(this, M24, value)
}
get m31() {
return this[VALUES][M31]
}
set m31(value) {
setNumber3D(this, M31, value)
}
get m32() {
return this[VALUES][M32]
}
set m32(value) {
setNumber3D(this, M32, value)
}
get m33() {
return this[VALUES][M33]
}
set m33(value) {
setNumber3D(this, M33, value)
}
get m34() {
return this[VALUES][M34]
}
set m34(value) {
setNumber3D(this, M34, value)
}
get m41() {
return this[VALUES][M41]
}
set m41(value) {
setNumber2D(this, M41, value)
}
get m42() {
return this[VALUES][M42]
}
set m42(value) {
setNumber2D(this, M42, value)
}
get m43() {
return this[VALUES][M43]
}
set m43(value) {
setNumber3D(this, M43, value)
}
get m44() {
return this[VALUES][M44]
}
set m44(value) {
setNumber3D(this, M44, value)
}
get a() {
return this[VALUES][A]
}
set a(value) {
setNumber2D(this, A, value)
}
get b() {
return this[VALUES][B]
}
set b(value) {
setNumber2D(this, B, value)
}
get c() {
return this[VALUES][C]
}
set c(value) {
setNumber2D(this, C, value)
}
get d() {
return this[VALUES][D]
}
set d(value) {
setNumber2D(this, D, value)
}
get e() {
return this[VALUES][E]
}
set e(value) {
setNumber2D(this, E, value)
}
get f() {
return this[VALUES][F]
}
set f(value) {
setNumber2D(this, F, value)
}
get is2D() {
return this[IS_2D]
}
get isIdentity() {
const values = this[VALUES]
return (
values[M11] === 1 &&
values[M12] === 0 &&
values[M13] === 0 &&
values[M14] === 0 &&
values[M21] === 0 &&
values[M22] === 1 &&
values[M23] === 0 &&
values[M24] === 0 &&
values[M31] === 0 &&
values[M32] === 0 &&
values[M33] === 1 &&
values[M34] === 0 &&
values[M41] === 0 &&
values[M42] === 0 &&
values[M43] === 0 &&
values[M44] === 1
)
}
static fromMatrix(init) {
if (init instanceof DOMMatrix) {
return new DOMMatrix(init[VALUES])
} else if (init instanceof SVGMatrix) {
return new DOMMatrix([init.a, init.b, init.c, init.d, init.e, init.f])
} else {
throw new TypeError('Expected DOMMatrix')
}
}
static fromFloat32Array(init) {
if (!(init instanceof Float32Array)) throw new TypeError('Expected Float32Array')
return new DOMMatrix(init)
}
static fromFloat64Array(init) {
if (!(init instanceof Float64Array)) throw new TypeError('Expected Float64Array')
return new DOMMatrix(init)
}
// @type
// (Float64Array) => void
constructor(init) {
this[IS_2D] = true
this[VALUES] = new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
// Parse CSS transformList
if (typeof init === 'string') {
if (init === '') {
return
} else {
const tforms = init.split(/\)\s+/, 20).map(parseTransform)
if (tforms.length === 0) {
return
}
init = tforms[0]
for (let i = 1; i < tforms.length; i++) {
init = multiply(tforms[i], init)
}
}
}
let i = 0
if (init && init.length === 6) {
setNumber2D(this, A, init[i++])
setNumber2D(this, B, init[i++])
setNumber2D(this, C, init[i++])
setNumber2D(this, D, init[i++])
setNumber2D(this, E, init[i++])
setNumber2D(this, F, init[i++])
} else if (init && init.length === 16) {
setNumber2D(this, M11, init[i++])
setNumber2D(this, M12, init[i++])
setNumber3D(this, M13, init[i++])
setNumber3D(this, M14, init[i++])
setNumber2D(this, M21, init[i++])
setNumber2D(this, M22, init[i++])
setNumber3D(this, M23, init[i++])
setNumber3D(this, M24, init[i++])
setNumber3D(this, M31, init[i++])
setNumber3D(this, M32, init[i++])
setNumber3D(this, M33, init[i++])
setNumber3D(this, M34, init[i++])
setNumber2D(this, M41, init[i++])
setNumber2D(this, M42, init[i++])
setNumber3D(this, M43, init[i++])
setNumber3D(this, M44, init[i])
} else if (init !== undefined) {
throw new TypeError('Expected string or array.')
}
}
dump() {
const mat = this[VALUES]
console.info([mat.slice(0, 4), mat.slice(4, 8), mat.slice(8, 12), mat.slice(12, 16)])
}
[inspect.custom](depth, options) {
if (depth < 0) return '[DOMMatrix]'
const { a, b, c, d, e, f, is2D, isIdentity } = this
if (this.is2D) {
return `DOMMatrix ${inspect({ a, b, c, d, e, f, is2D, isIdentity }, { colors: true })}`
} else {
const { m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44, is2D, isIdentity } = this
return `DOMMatrix ${inspect(
{
a,
b,
c,
d,
e,
f,
m11,
m12,
m13,
m14,
m21,
m22,
m23,
m24,
m31,
m32,
m33,
m34,
m41,
m42,
m43,
m44,
is2D,
isIdentity,
},
{ colors: true },
)}`
}
}
multiply(other) {
return newInstance(this[VALUES]).multiplySelf(other)
}
multiplySelf(other) {
this[VALUES] = multiply(other[VALUES], this[VALUES])
if (!other.is2D) {
this[IS_2D] = false
}
return this
}
preMultiplySelf(other) {
this[VALUES] = multiply(this[VALUES], other[VALUES])
if (!other.is2D) {
this[IS_2D] = false
}
return this
}
translate(tx, ty, tz) {
return newInstance(this[VALUES]).translateSelf(tx, ty, tz)
}
translateSelf(tx = 0, ty = 0, tz = 0) {
this[VALUES] = multiply([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1], this[VALUES])
if (tz !== 0) {
this[IS_2D] = false
}
return this
}
scale(scaleX, scaleY, scaleZ, originX, originY, originZ) {
return newInstance(this[VALUES]).scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ)
}
scale3d(scale, originX, originY, originZ) {
return newInstance(this[VALUES]).scale3dSelf(scale, originX, originY, originZ)
}
scale3dSelf(scale, originX, originY, originZ) {
return this.scaleSelf(scale, scale, scale, originX, originY, originZ)
}
scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ) {
// Not redundant with translate's checks because we need to negate the values later.
if (typeof originX !== 'number') originX = 0
if (typeof originY !== 'number') originY = 0
if (typeof originZ !== 'number') originZ = 0
this.translateSelf(originX, originY, originZ)
if (typeof scaleX !== 'number') scaleX = 1
if (typeof scaleY !== 'number') scaleY = scaleX
if (typeof scaleZ !== 'number') scaleZ = 1
this[VALUES] = multiply([scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1], this[VALUES])
this.translateSelf(-originX, -originY, -originZ)
if (scaleZ !== 1 || originZ !== 0) {
this[IS_2D] = false
}
return this
}
rotateFromVector(x, y) {
return newInstance(this[VALUES]).rotateFromVectorSelf(x, y)
}
rotateFromVectorSelf(x = 0, y = 0) {
const theta = x === 0 && y === 0 ? 0 : Math.atan2(y, x) * DEGREE_PER_RAD
return this.rotateSelf(theta)
}
rotate(rotX, rotY, rotZ) {
return newInstance(this[VALUES]).rotateSelf(rotX, rotY, rotZ)
}
rotateSelf(rotX, rotY, rotZ) {
if (rotY === undefined && rotZ === undefined) {
rotZ = rotX
rotX = rotY = 0
}
if (typeof rotY !== 'number') rotY = 0
if (typeof rotZ !== 'number') rotZ = 0
if (rotX !== 0 || rotY !== 0) {
this[IS_2D] = false
}
rotX *= RAD_PER_DEGREE
rotY *= RAD_PER_DEGREE
rotZ *= RAD_PER_DEGREE
let c = Math.cos(rotZ)
let s = Math.sin(rotZ)
this[VALUES] = multiply([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
c = Math.cos(rotY)
s = Math.sin(rotY)
this[VALUES] = multiply([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1], this[VALUES])
c = Math.cos(rotX)
s = Math.sin(rotX)
this[VALUES] = multiply([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1], this[VALUES])
return this
}
rotateAxisAngle(x, y, z, angle) {
return newInstance(this[VALUES]).rotateAxisAngleSelf(x, y, z, angle)
}
rotateAxisAngleSelf(x = 0, y = 0, z = 0, angle = 0) {
const length = Math.sqrt(x * x + y * y + z * z)
if (length === 0) {
return this
}
if (length !== 1) {
x /= length
y /= length
z /= length
}
angle *= RAD_PER_DEGREE
const c = Math.cos(angle)
const s = Math.sin(angle)
const t = 1 - c
const tx = t * x
const ty = t * y
this[VALUES] = multiply(
[
tx * x + c,
tx * y + s * z,
tx * z - s * y,
0,
tx * y - s * z,
ty * y + c,
ty * z + s * x,
0,
tx * z + s * y,
ty * z - s * x,
t * z * z + c,
0,
0,
0,
0,
1,
],
this[VALUES],
)
if (x !== 0 || y !== 0) {
this[IS_2D] = false
}
return this
}
skewX(sx) {
return newInstance(this[VALUES]).skewXSelf(sx)
}
skewXSelf(sx) {
if (typeof sx !== 'number') {
return this
}
const t = Math.tan(sx * RAD_PER_DEGREE)
this[VALUES] = multiply([1, 0, 0, 0, t, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
return this
}
skewY(sy) {
return newInstance(this[VALUES]).skewYSelf(sy)
}
skewYSelf(sy) {
if (typeof sy !== 'number') {
return this
}
const t = Math.tan(sy * RAD_PER_DEGREE)
this[VALUES] = multiply([1, t, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
return this
}
flipX() {
return newInstance(multiply([-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
}
flipY() {
return newInstance(multiply([1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
}
inverse() {
return newInstance(this[VALUES]).invertSelf()
}
invertSelf() {
if (this[IS_2D]) {
const det = this[VALUES][A] * this[VALUES][D] - this[VALUES][B] * this[VALUES][C]
// Invertable
if (det !== 0) {
const result = new DOMMatrix()
result.a = this[VALUES][D] / det
result.b = -this[VALUES][B] / det
result.c = -this[VALUES][C] / det
result.d = this[VALUES][A] / det
result.e = (this[VALUES][C] * this[VALUES][F] - this[VALUES][D] * this[VALUES][E]) / det
result.f = (this[VALUES][B] * this[VALUES][E] - this[VALUES][A] * this[VALUES][F]) / det
return result
}
// Not invertable
else {
this[IS_2D] = false
this[VALUES] = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
}
} else {
throw new Error('3D matrix inversion is not implemented.')
}
}
setMatrixValue(transformList) {
const temp = new DOMMatrix(transformList)
this[VALUES] = temp[VALUES]
this[IS_2D] = temp[IS_2D]
return this
}
transformPoint(point) {
const x = point.x || 0
const y = point.y || 0
const z = point.z || 0
const w = point.w || 1
const values = this[VALUES]
const nx = values[M11] * x + values[M21] * y + values[M31] * z + values[M41] * w
const ny = values[M12] * x + values[M22] * y + values[M32] * z + values[M42] * w
const nz = values[M13] * x + values[M23] * y + values[M33] * z + values[M43] * w
const nw = values[M14] * x + values[M24] * y + values[M34] * z + values[M44] * w
return new DOMPoint(nx, ny, nz, nw)
}
toFloat32Array() {
return Float32Array.from(this[VALUES])
}
toFloat64Array() {
return this[VALUES].slice(0)
}
toJSON() {
return {
a: this.a,
b: this.b,
c: this.c,
d: this.d,
e: this.e,
f: this.f,
m11: this.m11,
m12: this.m12,
m13: this.m13,
m14: this.m14,
m21: this.m21,
m22: this.m22,
m23: this.m23,
m24: this.m24,
m31: this.m31,
m32: this.m32,
m33: this.m33,
m34: this.m34,
m41: this.m41,
m42: this.m42,
m43: this.m43,
m44: this.m44,
is2D: this.is2D,
isIdentity: this.isIdentity,
}
}
toString() {
if (this.is2D) {
return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`
} else {
return `matrix3d(${this[VALUES].join(', ')})`
}
}
}
for (const propertyName of [
'a',
'b',
'c',
'd',
'e',
'f',
'm11',
'm12',
'm13',
'm14',
'm21',
'm22',
'm23',
'm24',
'm31',
'm32',
'm33',
'm34',
'm41',
'm42',
'm43',
'm44',
'is2D',
'isIdentity',
]) {
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMMatrix.prototype, propertyName)
propertyDescriptor.enumerable = true
Object.defineProperty(DOMMatrix.prototype, propertyName, propertyDescriptor)
}
module.exports = { DOMPoint, DOMMatrix, DOMRect }
-458
View File
@@ -1,458 +0,0 @@
import { ReadableStream } from 'node:stream/web'
// Clear all type of caches in Skia
export function clearAllCache(): void
export interface DOMMatrix2DInit {
a: number
b: number
c: number
d: number
e: number
f: number
}
interface DOMMatrixReadOnly {
readonly a: number
readonly b: number
readonly c: number
readonly d: number
readonly e: number
readonly f: number
readonly is2D: boolean
readonly isIdentity: boolean
readonly m11: number
readonly m12: number
readonly m13: number
readonly m14: number
readonly m21: number
readonly m22: number
readonly m23: number
readonly m24: number
readonly m31: number
readonly m32: number
readonly m33: number
readonly m34: number
readonly m41: number
readonly m42: number
readonly m43: number
readonly m44: number
flipX(): DOMMatrix
flipY(): DOMMatrix
inverse(): DOMMatrix
multiply(other?: DOMMatrixInit): DOMMatrix
rotate(rotX?: number, rotY?: number, rotZ?: number): DOMMatrix
rotateAxisAngle(x?: number, y?: number, z?: number, angle?: number): DOMMatrix
rotateFromVector(x?: number, y?: number): DOMMatrix
scale(
scaleX?: number,
scaleY?: number,
scaleZ?: number,
originX?: number,
originY?: number,
originZ?: number,
): DOMMatrix
scale3d(scale?: number, originX?: number, originY?: number, originZ?: number): DOMMatrix
skewX(sx?: number): DOMMatrix
skewY(sy?: number): DOMMatrix
toFloat32Array(): Float32Array
toFloat64Array(): Float64Array
transformPoint(point?: DOMPointInit): DOMPoint
translate(tx?: number, ty?: number, tz?: number): DOMMatrix
toString(): string
}
export interface DOMMatrix extends DOMMatrixReadOnly {
a: number
b: number
c: number
d: number
e: number
f: number
m11: number
m12: number
m13: number
m14: number
m21: number
m22: number
m23: number
m24: number
m31: number
m32: number
m33: number
m34: number
m41: number
m42: number
m43: number
m44: number
invertSelf(): DOMMatrix
multiplySelf(other?: DOMMatrixInit): DOMMatrix
preMultiplySelf(other?: DOMMatrixInit): DOMMatrix
rotateAxisAngleSelf(x?: number, y?: number, z?: number, angle?: number): DOMMatrix
rotateFromVectorSelf(x?: number, y?: number): DOMMatrix
rotateSelf(rotX?: number, rotY?: number, rotZ?: number): DOMMatrix
scale3dSelf(scale?: number, originX?: number, originY?: number, originZ?: number): DOMMatrix
scaleSelf(
scaleX?: number,
scaleY?: number,
scaleZ?: number,
originX?: number,
originY?: number,
originZ?: number,
): DOMMatrix
setMatrixValue(transformList: string): DOMMatrix
skewXSelf(sx?: number): DOMMatrix
skewYSelf(sy?: number): DOMMatrix
translateSelf(tx?: number, ty?: number, tz?: number): DOMMatrix
toJSON(): { [K in OmitNeverOfMatrix]: DOMMatrix[K] }
}
type OmitMatrixMethod = {
[K in keyof DOMMatrix]: DOMMatrix[K] extends (...args: any[]) => any ? never : K
}
type OmitNeverOfMatrix = OmitMatrixMethod[keyof OmitMatrixMethod]
export const DOMMatrix: {
prototype: DOMMatrix
new (init?: string | number[]): DOMMatrix
fromFloat32Array(array32: Float32Array): DOMMatrix
fromFloat64Array(array64: Float64Array): DOMMatrix
fromMatrix(other?: DOMMatrixInit): DOMMatrix
}
interface DOMRectReadOnly {
readonly bottom: number
readonly height: number
readonly left: number
readonly right: number
readonly top: number
readonly width: number
readonly x: number
readonly y: number
}
export interface DOMRect extends DOMRectReadOnly {
height: number
width: number
x: number
y: number
toJSON(): Omit<this, 'toJSON' | 'fromRect'>
}
export const DOMRect: {
prototype: DOMRect
new (x?: number, y?: number, width?: number, height?: number): DOMRect
fromRect(other?: DOMRectInit): DOMRect
}
interface DOMPointReadOnly {
readonly w: number
readonly x: number
readonly y: number
readonly z: number
matrixTransform(matrix?: DOMMatrixInit): DOMPoint
}
export interface DOMPoint extends DOMPointReadOnly {
w: number
x: number
y: number
z: number
toJSON(): Omit<DOMPoint, 'matrixTransform' | 'toJSON'>
}
export const DOMPoint: {
prototype: DOMPoint
new (x?: number, y?: number, z?: number, w?: number): DOMPoint
fromPoint(other?: DOMPointInit): DOMPoint
}
export class ImageData {
/**
* Returns the one-dimensional array containing the data in RGBA order, as integers in the range 0 to 255.
*/
readonly data: Uint8ClampedArray
/**
* Returns the actual dimensions of the data in the ImageData object, in pixels.
*/
readonly height: number
/**
* Returns the actual dimensions of the data in the ImageData object, in pixels.
*/
readonly width: number
constructor(sw: number, sh: number, attr?: { colorSpace?: ColorSpace })
constructor(imageData: ImageData, attr?: { colorSpace?: ColorSpace })
constructor(data: Uint8ClampedArray, sw: number, sh?: number)
}
export class Image {
constructor()
// attrs only affects SVG
constructor(width: number, height: number, attrs?: { colorSpace?: ColorSpace })
width: number
height: number
readonly naturalWidth: number
readonly naturalHeight: number
readonly complete: boolean
alt: string
// the src can be a Uint8Array or a string
// if it's a string, it can be a file path, a data URL, a remote URL, or a SVG string
src: Uint8Array | string
onload?(): void
onerror?(err: Error): void
}
export class Path2D {
constructor(path?: Path2D | string)
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
closePath(): void
ellipse(
x: number,
y: number,
radiusX: number,
radiusY: number,
rotation: number,
startAngle: number,
endAngle: number,
anticlockwise?: boolean,
): void
lineTo(x: number, y: number): void
moveTo(x: number, y: number): void
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
rect(x: number, y: number, w: number, h: number): void
roundRect(x: number, y: number, w: number, h: number, radii?: number | number[]): void
// PathKit methods
op(path: Path2D, operation: PathOp): Path2D
toSVGString(): string
getFillType(): FillType
getFillTypeString(): string
setFillType(type: FillType): void
simplify(): Path2D
asWinding(): Path2D
stroke(stroke?: StrokeOptions): Path2D
transform(transform: DOMMatrix2DInit): Path2D
getBounds(): [left: number, top: number, right: number, bottom: number]
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
trim(start: number, end: number, isComplement?: boolean): Path2D
dash(on: number, off: number, phase: number): Path2D
equals(path: Path2D): boolean
}
export interface StrokeOptions {
width?: number
miterLimit?: number
cap?: StrokeCap
join?: StrokeJoin
}
export interface SKRSContext2D
extends Omit<
CanvasRenderingContext2D,
'drawImage' | 'createPattern' | 'getTransform' | 'drawFocusIfNeeded' | 'scrollPathIntoView' | 'canvas'
> {
canvas: Canvas
/**
* @param startAngle The angle at which to begin the gradient, in radians. Angle measurements start vertically above the centre and move around clockwise.
* @param x The x-axis coordinate of the centre of the gradient.
* @param y The y-axis coordinate of the centre of the gradient.
*/
createConicGradient(startAngle: number, x: number, y: number): CanvasGradient
drawImage(image: Image | Canvas, dx: number, dy: number): void
drawImage(image: Image | Canvas, dx: number, dy: number, dw: number, dh: number): void
drawImage(
image: Image | Canvas,
sx: number,
sy: number,
sw: number,
sh: number,
dx: number,
dy: number,
dw: number,
dh: number,
): void
createPattern(
image: Image | ImageData,
repeat: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | null,
): CanvasPattern
getContextAttributes(): { alpha: boolean; desynchronized: boolean }
getTransform(): DOMMatrix
letterSpacing: string
wordSpacing: string
}
export type ColorSpace = 'srgb' | 'display-p3'
export interface ContextAttributes {
alpha?: boolean
colorSpace?: ColorSpace
}
export interface SvgCanvas {
width: number
height: number
getContext(contextType: '2d', contextAttributes?: ContextAttributes): SKRSContext2D
getContent(): Buffer
}
export interface AvifConfig {
/** 0-100 scale, 100 is lossless */
quality?: number
/** 0-100 scale */
alphaQuality?: number
/** rav1e preset 1 (slow) 10 (fast but crappy), default is 4 */
speed?: number
/** How many threads should be used (0 = match core count) */
threads?: number
/** set to '4:2:0' to use chroma subsampling, default '4:4:4' */
chromaSubsampling?: ChromaSubsampling
}
/**
* https://en.wikipedia.org/wiki/Chroma_subsampling#Types_of_sampling_and_subsampling
* https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_concepts
*/
export enum ChromaSubsampling {
/**
* Each of the three Y'CbCr components has the same sample rate, thus there is no chroma subsampling. This scheme is sometimes used in high-end film scanners and cinematic post-production.
* Note that "4:4:4" may instead be wrongly referring to R'G'B' color space, which implicitly also does not have any chroma subsampling (except in JPEG R'G'B' can be subsampled).
* Formats such as HDCAM SR can record 4:4:4 R'G'B' over dual-link HD-SDI.
*/
Yuv444 = 0,
/**
* The two chroma components are sampled at half the horizontal sample rate of luma: the horizontal chroma resolution is halved. This reduces the bandwidth of an uncompressed video signal by one-third.
* Many high-end digital video formats and interfaces use this scheme:
* - [AVC-Intra 100](https://en.wikipedia.org/wiki/AVC-Intra)
* - [Digital Betacam](https://en.wikipedia.org/wiki/Betacam#Digital_Betacam)
* - [Betacam SX](https://en.wikipedia.org/wiki/Betacam#Betacam_SX)
* - [DVCPRO50](https://en.wikipedia.org/wiki/DV#DVCPRO) and [DVCPRO HD](https://en.wikipedia.org/wiki/DV#DVCPRO_HD)
* - [Digital-S](https://en.wikipedia.org/wiki/Digital-S)
* - [CCIR 601](https://en.wikipedia.org/wiki/Rec._601) / [Serial Digital Interface](https://en.wikipedia.org/wiki/Serial_digital_interface) / [D1](https://en.wikipedia.org/wiki/D-1_(Sony))
* - [ProRes (HQ, 422, LT, and Proxy)](https://en.wikipedia.org/wiki/Apple_ProRes)
* - [XDCAM HD422](https://en.wikipedia.org/wiki/XDCAM)
* - [Canon MXF HD422](https://en.wikipedia.org/wiki/Canon_XF-300)
*/
Yuv422 = 1,
/**
* n 4:2:0, the horizontal sampling is doubled compared to 4:1:1,
* but as the **Cb** and **Cr** channels are only sampled on each alternate line in this scheme, the vertical resolution is halved.
* The data rate is thus the same.
* This fits reasonably well with the PAL color encoding system, since this has only half the vertical chrominance resolution of [NTSC](https://en.wikipedia.org/wiki/NTSC).
* It would also fit extremely well with the [SECAM](https://en.wikipedia.org/wiki/SECAM) color encoding system,
* since like that format, 4:2:0 only stores and transmits one color channel per line (the other channel being recovered from the previous line).
* However, little equipment has actually been produced that outputs a SECAM analogue video signal.
* In general, SECAM territories either have to use a PAL-capable display or a [transcoder](https://en.wikipedia.org/wiki/Transcoding) to convert the PAL signal to SECAM for display.
*/
Yuv420 = 2,
/**
* What if the chroma subsampling model is 4:0:0?
* That says to use every pixel of luma data, but that each row has 0 chroma samples applied to it. The resulting image, then, is comprised solely of the luminance data—a greyscale image.
*/
Yuv400 = 3,
}
export class Canvas {
constructor(width: number, height: number, flag?: SvgExportFlag)
width: number
height: number
getContext(contextType: '2d', contextAttributes?: ContextAttributes): SKRSContext2D
encodeSync(format: 'webp' | 'jpeg', quality?: number): Buffer
encodeSync(format: 'png'): Buffer
encodeSync(format: 'avif', cfg?: AvifConfig): Buffer
encode(format: 'webp' | 'jpeg', quality?: number): Promise<Buffer>
encode(format: 'png'): Promise<Buffer>
encode(format: 'avif', cfg?: AvifConfig): Promise<Buffer>
encodeStream(format: 'webp' | 'jpeg', quality?: number): ReadableStream<Buffer>
encodeStream(format: 'png'): ReadableStream<Buffer>
toBuffer(mime: 'image/png'): Buffer
toBuffer(mime: 'image/jpeg' | 'image/webp', quality?: number): Buffer
toBuffer(mime: 'image/avif', cfg?: AvifConfig): Buffer
// raw pixels
data(): Buffer
toDataURL(mime?: 'image/png'): string
toDataURL(mime: 'image/jpeg' | 'image/webp', quality?: number): string
toDataURL(mime?: 'image/jpeg' | 'image/webp' | 'image/png', quality?: number): string
toDataURL(mime?: 'image/avif', cfg?: AvifConfig): string
toDataURLAsync(mime?: 'image/png'): Promise<string>
toDataURLAsync(mime: 'image/jpeg' | 'image/webp', quality?: number): Promise<string>
toDataURLAsync(mime?: 'image/jpeg' | 'image/webp' | 'image/png', quality?: number): Promise<string>
toDataURLAsync(mime?: 'image/avif', cfg?: AvifConfig): Promise<string>
}
export function createCanvas(width: number, height: number): Canvas
export function createCanvas(width: number, height: number, svgExportFlag: SvgExportFlag): SvgCanvas
interface IGlobalFonts {
readonly families: {
family: string
styles: {
weight: number
width: string
style: string
}[]
}[]
// return true if succeeded
register(font: Buffer, nameAlias?: string): boolean
// absolute path
registerFromPath(path: string, nameAlias?: string): boolean
has(name: string): boolean
loadFontsFromDir(path: string): number
}
export const GlobalFonts: IGlobalFonts
export enum PathOp {
Difference = 0, // subtract the op path from the first path
Intersect = 1, // intersect the two paths
Union = 2, // union (inclusive-or) the two paths
Xor = 3, // exclusive-or the two paths
ReverseDifference = 4, // subtract the first path from the op path
}
export enum FillType {
Winding = 0,
EvenOdd = 1,
InverseWinding = 2,
InverseEvenOdd = 3,
}
export enum StrokeJoin {
Miter = 0,
Round = 1,
Bevel = 2,
}
export enum StrokeCap {
Butt = 0,
Round = 1,
Square = 2,
}
export enum SvgExportFlag {
ConvertTextToPaths = 0x01,
NoPrettyXML = 0x02,
RelativePathEncoding = 0x04,
}
export function convertSVGTextToPath(svg: Buffer | string): Buffer
export interface LoadImageOptions {
alt?: string
maxRedirects?: number
requestOptions?: import('http').RequestOptions
}
export function loadImage(
source: string | URL | Buffer | ArrayBufferLike | Uint8Array | Image | import('stream').Readable,
options?: LoadImageOptions,
): Promise<Image>
-116
View File
@@ -1,116 +0,0 @@
const { platform, homedir } = require('os')
const { join } = require('path')
const {
clearAllCache,
CanvasRenderingContext2D,
CanvasElement,
SVGCanvas,
Path: Path2D,
ImageData,
Image,
CanvasPattern,
GlobalFonts,
PathOp,
FillType,
StrokeJoin,
StrokeCap,
convertSVGTextToPath,
} = require('./js-binding')
const { DOMPoint, DOMMatrix, DOMRect } = require('./geometry')
const loadImage = require('./load-image')
const SvgExportFlag = {
ConvertTextToPaths: 0x01,
NoPrettyXML: 0x02,
RelativePathEncoding: 0x04,
}
// eslint-disable-next-line sonarjs/no-unused-collection
const Fonts = []
if (!('families' in GlobalFonts)) {
Object.defineProperty(GlobalFonts, 'families', {
get: function () {
return JSON.parse(GlobalFonts.getFamilies().toString())
},
})
}
if (!('has' in GlobalFonts)) {
Object.defineProperty(GlobalFonts, 'has', {
value: function has(name) {
return !!JSON.parse(GlobalFonts.getFamilies().toString()).find(({ family }) => family === name)
},
configurable: false,
enumerable: false,
writable: false,
})
}
const _getTransform = CanvasRenderingContext2D.prototype.getTransform
CanvasRenderingContext2D.prototype.getTransform = function getTransform() {
const transform = _getTransform.apply(this, arguments)
// monkey patched, skip
if (transform instanceof DOMMatrix) {
return transform
}
const { a, b, c, d, e, f } = transform
return new DOMMatrix([a, b, c, d, e, f])
}
function createCanvas(width, height, flag) {
const isSvgBackend = typeof flag !== 'undefined'
return isSvgBackend ? new SVGCanvas(width, height, flag) : new CanvasElement(width, height)
}
class Canvas {
constructor(width, height, flag) {
return createCanvas(width, height, flag)
}
static [Symbol.hasInstance](instance) {
return instance instanceof CanvasElement || instance instanceof SVGCanvas
}
}
if (!process.env.DISABLE_SYSTEM_FONTS_LOAD) {
GlobalFonts.loadSystemFonts()
const platformName = platform()
const homedirPath = homedir()
switch (platformName) {
case 'win32':
GlobalFonts.loadFontsFromDir(join(homedirPath, 'AppData', 'Local', 'Microsoft', 'Windows', 'Fonts'))
break
case 'darwin':
GlobalFonts.loadFontsFromDir(join(homedirPath, 'Library', 'Fonts'))
break
case 'linux':
GlobalFonts.loadFontsFromDir(join('usr', 'local', 'share', 'fonts'))
GlobalFonts.loadFontsFromDir(join(homedirPath, '.fonts'))
break
}
}
module.exports = {
clearAllCache,
Canvas,
createCanvas,
Path2D,
ImageData,
Image,
PathOp,
FillType,
StrokeCap,
StrokeJoin,
SvgExportFlag,
GlobalFonts: GlobalFonts,
convertSVGTextToPath,
DOMPoint,
DOMMatrix,
DOMRect,
loadImage,
}
@@ -1,269 +0,0 @@
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'skia.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.android-arm64.node')
} else {
nativeBinding = require('@napi-rs/canvas-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'skia.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.android-arm-eabi.node')
} else {
nativeBinding = require('@napi-rs/canvas-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'skia.win32-x64-msvc.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.win32-x64-msvc.node')
} else {
nativeBinding = require('@napi-rs/canvas-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(join(__dirname, 'skia.win32-ia32-msvc.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.win32-ia32-msvc.node')
} else {
nativeBinding = require('@napi-rs/canvas-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'skia.win32-arm64-msvc.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.win32-arm64-msvc.node')
} else {
nativeBinding = require('@napi-rs/canvas-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'skia.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.darwin-x64.node')
} else {
nativeBinding = require('@napi-rs/canvas-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'skia.darwin-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.darwin-arm64.node')
} else {
nativeBinding = require('@napi-rs/canvas-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'skia.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.freebsd-x64.node')
} else {
nativeBinding = require('@napi-rs/canvas-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(join(__dirname, 'skia.linux-x64-musl.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-x64-musl.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(join(__dirname, 'skia.linux-x64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-x64-gnu.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm64-musl.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-arm64-musl.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-arm64-gnu.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm-gnueabihf.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
break
case 'riscv64':
localFileExisted = existsSync(join(__dirname, 'skia.linux-riscv64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-riscv64-gnu.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const {
ChromaSubsampling,
SvgExportFlag,
CanvasRenderingContext2D,
CanvasGradient,
ImageData,
Image,
PathOp,
FillType,
StrokeCap,
StrokeJoin,
Path,
CanvasPattern,
convertSVGTextToPath,
CanvasElement,
SVGCanvas,
clearAllCache,
GlobalFonts,
} = nativeBinding
module.exports.ChromaSubsampling = ChromaSubsampling
module.exports.SvgExportFlag = SvgExportFlag
module.exports.CanvasRenderingContext2D = CanvasRenderingContext2D
module.exports.CanvasGradient = CanvasGradient
module.exports.ImageData = ImageData
module.exports.Image = Image
module.exports.PathOp = PathOp
module.exports.FillType = FillType
module.exports.StrokeCap = StrokeCap
module.exports.StrokeJoin = StrokeJoin
module.exports.Path = Path
module.exports.CanvasPattern = CanvasPattern
module.exports.convertSVGTextToPath = convertSVGTextToPath
module.exports.CanvasElement = CanvasElement
module.exports.SVGCanvas = SVGCanvas
module.exports.clearAllCache = clearAllCache
module.exports.GlobalFonts = GlobalFonts
@@ -1,141 +0,0 @@
const fs = require('fs')
const { Readable } = require('stream')
const { URL } = require('url')
const { Image } = require('./js-binding')
let http, https
const MAX_REDIRECTS = 20
const REDIRECT_STATUSES = new Set([301, 302])
/**
* Loads the given source into canvas Image
* @param {string|URL|Image|Buffer} source The image source to be loaded
* @param {object} options Options passed to the loader
*/
module.exports = async function loadImage(source, options = {}) {
// use the same buffer without copying if the source is a buffer
if (Buffer.isBuffer(source) || source instanceof Uint8Array) return createImage(source, options.alt)
// load readable stream as image
if (source instanceof Readable) return createImage(await consumeStream(source), options.alt)
// construct a Uint8Array if the source is ArrayBuffer or SharedArrayBuffer
if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer)
return createImage(new Uint8Array(source), options.alt)
// construct a buffer if the source is buffer-like
if (isBufferLike(source)) return createImage(Buffer.from(source), options.alt)
// if the source is Image instance, copy the image src to new image
if (source instanceof Image) return createImage(source.src, options.alt)
// if source is string and in data uri format, construct image using data uri
if (typeof source === 'string' && source.trimStart().startsWith('data:')) {
const commaIdx = source.indexOf(',')
const encoding = source.lastIndexOf('base64', commaIdx) < 0 ? 'utf-8' : 'base64'
const data = Buffer.from(source.slice(commaIdx + 1), encoding)
return createImage(data, options.alt)
}
// if source is a string or URL instance
if (typeof source === 'string') {
// if the source exists as a file, construct image from that file
if ((!source.startsWith('http') && !source.startsWith('https')) && await exists(source)) {
return createImage(source, options.alt)
} else {
// the source is a remote url here
source = new URL(source)
// attempt to download the remote source and construct image
const data = await new Promise((resolve, reject) =>
makeRequest(
source,
resolve,
reject,
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
options.requestOptions,
),
)
return createImage(data, options.alt)
}
}
if (source instanceof URL) {
if (source.protocol === 'file:') {
// remove the leading slash on windows
return createImage(process.platform === 'win32' ? source.pathname.substring(1) : source.pathname, options.alt)
} else {
const data = await new Promise((resolve, reject) =>
makeRequest(
source,
resolve,
reject,
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
options.requestOptions,
),
)
return createImage(data, options.alt)
}
}
// throw error as don't support that source
throw new TypeError('unsupported image source')
}
function makeRequest(url, resolve, reject, redirectCount, requestOptions) {
const isHttps = url.protocol === 'https:'
// lazy load the lib
const lib = isHttps ? (!https ? (https = require('https')) : https) : !http ? (http = require('http')) : http
lib
.get(url.toString(), requestOptions || {}, (res) => {
try {
const shouldRedirect = REDIRECT_STATUSES.has(res.statusCode) && typeof res.headers.location === 'string'
if (shouldRedirect && redirectCount > 0)
return makeRequest(
new URL(res.headers.location, url.origin),
resolve,
reject,
redirectCount - 1,
requestOptions,
)
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
return reject(new Error(`remote source rejected with status code ${res.statusCode}`))
}
consumeStream(res).then(resolve, reject)
} catch (err) {
reject(err)
}
})
.on('error', reject)
}
// use stream/consumers in the future?
function consumeStream(res) {
return new Promise((resolve, reject) => {
const chunks = []
res.on('data', (chunk) => chunks.push(chunk))
res.on('end', () => resolve(Buffer.concat(chunks)))
res.on('error', reject)
})
}
function createImage(src, alt) {
return new Promise((resolve, reject) => {
const image = new Image()
if (typeof alt === 'string') image.alt = alt
image.onload = () => resolve(image)
image.onerror = (e) => reject(e)
image.src = src
})
}
function isBufferLike(src) {
return (src && src.type === 'Buffer') || Array.isArray(src)
}
async function exists(path) {
try {
await fs.promises.access(path, fs.constants.F_OK)
return true
} catch {
return false
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"name": "@napi-rs/canvas",
"version": "0.1.66",
"description": "Canvas for Node.js with skia backend",
"main": "index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/Brooooooklyn/canvas.git"
},
"license": "MIT",
"keywords": [
"napi-rs",
"NAPI",
"N-API",
"Rust",
"node-addon",
"node-addon-api",
"canvas",
"image",
"pdf",
"svg",
"skia"
],
"files": [
"index.d.ts",
"index.js",
"geometry.js",
"js-binding.js",
"load-image.js"
],
"napi": {
"binaryName": "skia",
"targets": [
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"armv7-unknown-linux-gnueabihf",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-apple-darwin",
"aarch64-linux-android",
"riscv64-unknown-linux-gnu"
]
},
"engines": {
"node": ">= 10"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"scripts": {
"artifacts": "napi artifacts",
"bench": "node -r @swc-node/register benchmark/bench.ts",
"build": "napi build --platform --release --js js-binding.js",
"build:debug": "napi build --platform --js js-binding.js",
"format": "run-p format:source format:rs format:toml",
"format:rs": "cargo fmt",
"format:source": "prettier . -w",
"format:toml": "taplo format",
"lint": "oxlint",
"prepublishOnly": "pinst --disable && napi prepublish -t npm",
"postpublish": "pinst --enable",
"test:ci": "ava -c 1",
"test": "ava",
"version": "napi version && conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md"
},
"devDependencies": {
"@jimp/core": "^1.0.0",
"@jimp/custom": "^0.22.10",
"@jimp/jpeg": "^0.22.10",
"@jimp/png": "^0.22.10",
"@napi-rs/cli": "^3.0.0-alpha.70",
"@octokit/rest": "^21.0.0",
"@swc-node/register": "^1.8.0",
"@swc/core": "^1.4.0",
"@taplo/cli": "^0.7.0",
"@types/lodash": "^4.14.202",
"@types/node": "^20.11.16",
"@types/semver": "^7",
"ava": "^6.1.1",
"canvas": "^3.0.0",
"canvaskit-wasm": "^0.39.1",
"colorette": "^2.0.20",
"conventional-changelog-cli": "^5.0.0",
"core-js": "^3.38.0",
"echarts": "^5.4.3",
"husky": "^9.0.10",
"lint-staged": "^15.2.1",
"lodash": "^4.17.21",
"npm-run-all2": "^7.0.0",
"oxlint": "^0.15.0",
"pinst": "^3.0.0",
"png.js": "^0.2.1",
"prettier": "^3.2.5",
"pretty-bytes": "^6.1.1",
"semver": "^7.5.4",
"skia-canvas": "^2.0.0",
"table": "^6.8.1",
"tinybench": "^3.0.6",
"typescript": "^5.3.3"
},
"lint-staged": {
"*.@(js|ts|tsx|yml|yaml|md|json|html)": [
"prettier --write"
],
"*.@(js|ts|tsx)": [
"oxlint --fix"
]
},
"ava": {
"require": [
"@swc-node/register",
"core-js/proposals/promise-with-resolvers.js"
],
"extensions": [
"ts"
],
"files": [
"__test__/**/*.spec.ts",
"scripts/__test__/**/*.spec.ts"
],
"workerThreads": false,
"cache": false,
"timeout": "3m",
"environmentVariables": {
"SWC_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "ava"
}
},
"prettier": {
"printWidth": 120,
"semi": false,
"trailingComma": "all",
"singleQuote": true,
"arrowParens": "always"
},
"packageManager": "yarn@4.6.0",
"optionalDependencies": {
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
"@napi-rs/canvas-darwin-x64": "0.1.66",
"@napi-rs/canvas-win32-x64-msvc": "0.1.66",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
"@napi-rs/canvas-darwin-arm64": "0.1.66",
"@napi-rs/canvas-android-arm64": "0.1.66",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66"
}
}
+1 -201
View File
@@ -6,207 +6,7 @@
"packages": { "packages": {
"": { "": {
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.1"
"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 -5
View File
@@ -2,10 +2,6 @@
"name": "spineviewer", "name": "spineviewer",
"version": "1.1.1", "version": "1.1.1",
"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": {
"@napi-rs/canvas": "^0.1.66"
}
} }
@@ -1,585 +0,0 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const discoveryJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v14';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
}
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
function assetSignature(assets) {
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
let atlasText = '';
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
const pages = utils.parseAtlasPages(atlasText);
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
const stats = [];
for (const file of paths) {
try {
const stat = fs.statSync(file);
if (!stat.isFile() || stat.size === 0) return null;
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
} catch (e) {
return null;
}
}
return stats.join('|');
}
/**
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
*/
async function runDiscovery(src, retries, delay) {
let assets = null;
let previousSignature = null;
let stableCount = 0;
for (let i = 0; i < retries; i++) {
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
// 分批拖入的跨批次补齐统一交给后台任务处理。
assets = utils.discoverAssets(src, {
strictStem: true,
allowCrossBatch: false,
refreshIndex: i > 0,
});
const signature = assetSignature(assets);
if (signature && signature === previousSignature) stableCount += 1;
else stableCount = signature ? 1 : 0;
previousSignature = signature;
if (stableCount >= 3) return assets;
if (i < retries - 1) await sleep(delay);
}
return assets;
}
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
async function discoverWithRetry(src, retries = 5, delay = 120) {
const key = coordinationKey(src);
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
const job = runDiscovery(src, retries, delay);
discoveryJobs.set(key, job);
try {
return await job;
} finally {
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
}
}
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
async function classifyJsonBriefly(src) {
let kind = 'pending';
for (let i = 0; i < 3; i++) {
kind = utils.classifySkeletonJson(src);
if (kind !== 'pending') return kind;
if (i < 2) await sleep(80);
}
return kind;
}
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
async function renderThumbnailShared(assets, options) {
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
if (renderJobs.has(key)) {
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
return renderJobs.get(key);
}
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
const job = render.renderThumbnail(options);
renderJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
}
}
/**
* Eagle 格式扩展缩略图处理函数。
* 参数:{ src, dest, item }
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
* - dest : 需要生成的缩略图 PNG 路径
* - item : 当前 item 对象(可更新宽高)
*/
module.exports = async (options) => {
const { src, dest, item } = options;
const changed = require('../js/eagle-compound.js').normalizeItem(src, item);
// Eagle originally chooses the cache name before invoking the plugin.
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
fs.writeFileSync(canonical, await canvas.encode('webp', 90));
} else fs.copyFileSync(dest, canonical);
}
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
}
const ext = utils.extOf(path.basename(src));
if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src);
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
try {
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
} catch (e) { /* ignore */ }
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
if (item && item.isDeleted) {
log(' skip: item.isDeleted=true');
return resolve(item);
}
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
const srcExt = utils.extOf(path.basename(src));
// Eagle dispatches compound suffixes as txt/bytes. Only the complete
// .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit.
if (!srcExt) {
if (/\.txt$/i.test(src)) {
// Eagle's native text card requires item.text, even for an empty file.
item.text = '';
const fd = fs.openSync(src, 'r');
try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); }
finally { fs.closeSync(fd); }
}
await writeNeutralJsonPlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
await copyImageAsThumb(src, dest);
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
return resolve(item);
}
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
// 普通 JSON 不写 dest,不修改尺寸,也不启动资源扫描和后台补刷。
// 这样可以保留 Eagle 或其他 JSON 插件生成的缩略图,避免本插件的回退图覆盖它。
if (srcExt === '.json') {
const jsonKind = await classifyJsonBriefly(src);
if (jsonKind === 'other') {
log('普通 JSON,放行给 Eagle/其他 JSON 插件: ' + src);
return resolve(item);
}
}
let assets = await discoverWithRetry(src);
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
let version = utils.detectVersion(assets.skeletonPath);
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
let png = null;
let lastRenderError = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
png = await renderThumbnailShared(assets, {
skeletonPath: assets.skeletonPath,
atlasPath: assets.atlasPath,
texturePaths: assets.texturePaths,
version,
runtimeDir,
size: 400,
});
break;
} catch (e) {
lastRenderError = e;
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
if (attempt === 2) break;
await sleep(500);
assets = await discoverWithRetry(src, 4, 150);
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
version = utils.detectVersion(assets.skeletonPath);
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
}
}
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
throw lastRenderError || new Error('Spine 资源尚未写入完成');
}
fs.writeFileSync(dest, png);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
item.width = size.width || 400;
item.height = size.height || 400;
} catch (e) { /* ignore */ }
return Promise.resolve(item);
} catch (err) {
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
try {
await writePlaceholder(dest);
item.width = 400;
item.height = 400;
return resolve(item);
} catch (e2) {
// 最后兜底:写入一张最小 1x1 PNG
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
item.width = 400; item.height = 400;
return resolve(item);
}
}
};
function resolve(item) {
return item;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return false;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return true;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
return true;
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
return false;
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
try { require('../js/spine-reconcile-worker.js').enqueue(src); }
catch (error) { log('无法启动后台补刷: ' + String(error)); }
}
// Called by a separate Node worker; Eagle closes the thumbnail window when
// its callback resolves, so timers in that window cannot finish this work.
module.exports.reconcileOnce = async (src, previousSignature) => {
if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true };
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true });
const signature = assetSignature(assets);
if (!signature || signature !== previousSignature) return { signature, complete: false };
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) return { signature, complete: true };
const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png');
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(temp, png);
const complete = await refreshRelatedThumbnails(assets, temp);
return { signature, complete };
} catch (error) {
log('后台补刷重试: ' + String(error));
return { signature, complete: false };
} finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } }
};
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#202126';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#555963';
ctx.beginPath();
ctx.moveTo(112, 72);
ctx.lineTo(244, 72);
ctx.lineTo(288, 116);
ctx.lineTo(288, 328);
ctx.lineTo(112, 328);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#777c87';
ctx.beginPath();
ctx.moveTo(244, 72);
ctx.lineTo(244, 116);
ctx.lineTo(288, 116);
ctx.closePath();
ctx.fill();
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
const pixels = Buffer.alloc(size * size * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
}
fs.writeFileSync(dest, encodePng(size, size, pixels));
}
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
async function copyImageAsThumb(src, dest) {
const ext = path.extname(src).toLowerCase();
if (ext === '.png') {
fs.copyFileSync(src, dest);
return;
}
if (render.canvasLib) {
try {
const img = await render.canvasLib.loadImage(src);
const w = img.width, h = img.height;
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
fs.copyFileSync(src, dest);
}
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
async function writeAtlasFallback(assets, dest) {
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
const texturePath = Object.values(assets.texturePaths).find((file) => {
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
});
if (!texturePath) return false;
try {
const img = await render.canvasLib.loadImage(texturePath);
const size = 400;
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, size, size);
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
await render.drawSpineBadge(ctx, size, size);
if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath));
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return true;
} catch (e) {
log('atlas 贴图回退失败: ' + (e && e.message));
return false;
}
}
/** 读取 PNG 宽高(仅支持 PNG 头部) */
function imageSizeOf(file) {
const buf = fs.readFileSync(file);
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
return { width: 0, height: 0 };
}
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
async function writePlaceholder(dest) {
const w = 400, h = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, w, h);
await render.drawSpineBadge(ctx, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through to pure JS encoder */ }
}
// 深色底
const pixels = Buffer.alloc(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
}
}
// 简单的斜线纹理,让占位图不单调
for (let i = 0; i < w + h; i += 8) {
for (let k = 0; k < 3; k++) {
const x = Math.min(w - 1, i - k);
const y = Math.min(h - 1, k);
const idx = (y * w + x) * 4;
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
}
}
fs.writeFileSync(dest, encodePng(w, h, pixels));
}
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
function encodePng(width, height, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const ihdrChunk = chunk('IHDR', ihdr);
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter type 0
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
const idat = zlib.deflateSync(raw);
const idatChunk = chunk('IDAT', idat);
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function crc32(buf) {
let c, crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
c = (crc ^ buf[i]) & 0xFF;
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
crc = (crc >>> 8) ^ c;
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
+91 -2
View File
@@ -75,6 +75,81 @@
} }
button:hover { background: var(--border); } button:hover { background: var(--border); }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); } button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button:disabled { cursor: default; opacity: .55; }
#groupThumbBtn { position: relative; background: #2e7d32; color: #fff; border-color: #2e7d32; }
#groupThumbBtn:hover { background: #256628; }
#thumbBtn { position: relative; background: #1976d2; color: #fff; border-color: #1976d2; }
#thumbBtn:hover { background: #155fa8; }
#groupThumbBtn::after, #thumbBtn::after {
content: attr(data-tooltip); position: absolute; top: calc(100% + 8px); z-index: 30;
width: max-content; max-width: min(620px, calc(100vw - 24px)); padding: 6px 10px;
color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.4; white-space: normal;
background: var(--panel); border: 1px solid var(--border); border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, .35); pointer-events: none;
opacity: 0; visibility: hidden;
}
#groupThumbBtn::after { left: 0; background: #256628; border-color: #256628; color: #fff; }
#thumbBtn::after { right: 0; background: #155fa8; border-color: #155fa8; color: #fff; }
#groupThumbBtn:hover::after, #thumbBtn:hover::after { opacity: 1; visibility: visible; }
.thumb-dialog {
position: absolute; inset: 0; z-index: 40; display: flex;
align-items: center; justify-content: center; padding: 24px;
background: rgba(0, 0, 0, .58);
}
.thumb-dialog.hidden { display: none; }
.thumb-dialog-card {
width: min(620px, 92vw); max-height: min(680px, 88vh); display: flex;
flex-direction: column; gap: 12px; padding: 18px;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, .45);
}
.thumb-dialog-title { font-size: 16px; font-weight: 600; }
.thumb-dialog-summary, .thumb-dialog-note { color: var(--text-dim); line-height: 1.5; }
.thumb-dialog-list {
min-height: 60px; overflow: auto; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg);
}
.thumb-dialog-row {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.thumb-dialog-row:last-child { border-bottom: 0; }
.thumb-dialog-check { width: 17px; height: 17px; margin: 0; flex: none; accent-color: var(--accent); }
.thumb-dialog-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thumb-dialog-state { flex: none; color: var(--accent); font-size: 12px; }
.thumb-dialog-row.skipped .thumb-dialog-name,
.thumb-dialog-row.skipped .thumb-dialog-state { color: var(--text-dim); }
.thumb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.thumb-dialog-input {
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; padding: 7px 9px;
}
.thumb-dialog-input.hidden { display: none; }
.thumb-dialog-error { color: var(--error); min-height: 18px; }
.animated-thumb-card { width: min(480px, 92vw); gap: 10px; padding: 16px; }
.animated-thumb-card .thumb-dialog-title { text-align: center; }
.animated-thumb-warning { color: #fff; }
.animated-thumb-warning-title { display: block; }
.animated-thumb-danger { color: var(--error); }
.animated-thumb-card .thumb-dialog-note { font-size: 13px; }
.animated-thumb-form { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px 14px; padding: 2px 0; }
.animated-thumb-label { color: var(--text-dim); }
.animated-thumb-range { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
.animated-thumb-range input { width: 88px; }
.animated-thumb-quality { grid-column: 1 / -1; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 14px; color: var(--text-dim); }
.animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
.animated-thumb-size input { width: 88px; }
.animated-thumb-form input[type="number"] {
box-sizing: border-box; height: 32px; padding: 4px 8px;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px;
}
.animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); }
.animated-thumb-card .thumb-dialog-actions { margin-top: 2px; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
#animationPanel { #animationPanel {
position: absolute; position: absolute;
@@ -124,8 +199,22 @@
#viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; } #viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#status { #status {
position: absolute; bottom: calc(22px + var(--timeline-height)); left: 12px; color: var(--text-dim); position: absolute; left: 50%; bottom: calc(22px + var(--timeline-height));
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3); transform: translateX(-50%);
max-width: min(560px, calc(100% - 24px));
padding: 6px 12px;
color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.45;
background: var(--panel-glass);
border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
backdrop-filter: blur(8px);
pointer-events: none;
}
#status:empty { display: none; }
#status::before {
content: ''; display: inline-block; vertical-align: middle;
width: 7px; height: 7px; margin-right: 8px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 6px var(--accent);
} }
#timelineBar { #timelineBar {
position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 10; position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 10;
File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 128 KiB

Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 120 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 631 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 720 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 773 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 738 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 767 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 786 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 740 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 118 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 121 KiB

+52
View File
@@ -0,0 +1,52 @@
'use strict';
const assert = require('node:assert/strict');
const { encode } = require('../js/animated-webp-encoder');
function 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;
}
function stillWebp() {
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;
}
function chunks(buffer) {
const result = [];
for (let offset = 12; offset + 8 <= buffer.length;) {
const size = buffer.readUInt32LE(offset + 4);
result.push({ type: buffer.toString('ascii', offset, offset + 4), payload: buffer.subarray(offset + 8, offset + 8 + size) });
offset += 8 + size + (size & 1);
}
return result;
}
const output = encode({
width: 400,
height: 300,
frames: [{ webp: stillWebp(), duration: 50 }, { webp: stillWebp(), duration: 75 }],
});
assert.equal(output.toString('ascii', 0, 4), 'RIFF');
assert.equal(output.readUInt32LE(4), output.length - 8);
assert.equal(output.toString('ascii', 8, 12), 'WEBP');
const parsed = chunks(output);
assert.deepEqual(parsed.map((entry) => entry.type), ['VP8X', 'ANIM', 'ANMF', 'ANMF']);
assert.equal(parsed[0].payload[0], 0x02, 'VP8X must declare animation without alpha');
assert.deepEqual([...parsed[0].payload.subarray(4, 7)], [143, 1, 0]);
assert.deepEqual([...parsed[0].payload.subarray(7, 10)], [43, 1, 0]);
assert.equal(parsed[1].payload[3], 255, 'animation background must be opaque');
assert.deepEqual([...parsed[2].payload.subarray(12, 15)], [50, 0, 0]);
assert.equal(parsed[2].payload[15], 0x02, 'full frames must replace without blending');
assert.equal(parsed[2].payload.toString('ascii', 16, 20), 'VP8 ');
assert.throws(() => encode({ width: 1, height: 1, frames: [{ webp: Buffer.alloc(0), duration: 50 }] }), /at least two frames/);
console.log('PASS: animated WebP RIFF structure, timing and opaque full-frame mode');
+92
View File
@@ -0,0 +1,92 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const vm = require('node:vm');
const { encode } = require('../js/apng-encoder');
function chunks(buffer) {
const result = [];
let offset = 8;
while (offset < buffer.length) {
const length = buffer.readUInt32BE(offset);
const type = buffer.toString('ascii', offset + 4, offset + 8);
result.push({ type, data: buffer.subarray(offset + 8, offset + 8 + length) });
offset += length + 12;
}
return result;
}
const red = new Uint8Array(16);
const blue = new Uint8Array(16);
for (let index = 0; index < 16; index += 4) {
red.set([255, 0, 0, 255], index);
blue.set([0, 0, 255, 255], index);
}
const png = encode({
width: 2,
height: 2,
frames: [
{ rgba: red, delayNumerator: 125, delayDenominator: 1000 },
{ rgba: blue, delayNumerator: 125, delayDenominator: 1000 },
],
});
assert.deepEqual([...png.subarray(0, 8)], [137, 80, 78, 71, 13, 10, 26, 10]);
const parsed = chunks(png);
assert.deepEqual(parsed.map((entry) => entry.type), ['IHDR', 'acTL', 'fcTL', 'IDAT', 'fcTL', 'fdAT', 'IEND']);
assert.equal(parsed.find((entry) => entry.type === 'acTL').data.readUInt32BE(0), 2);
assert.equal(parsed[2].data.readUInt32BE(0), 0);
assert.equal(parsed[4].data.readUInt32BE(0), 1);
assert.equal(parsed[5].data.readUInt32BE(0), 2);
for (const root of ['.', 'release/UNIVERSAL_SpineViewer']) {
const html = fs.readFileSync(root + '/viewer/spine.html', 'utf8');
const css = fs.readFileSync(root + '/viewer/spine.css', 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
new vm.Script(script);
assert.match(script, /addEventListener\('contextmenu'[\s\S]*generateAnimatedThumbnail\(\)/);
assert.match(script, /groupThumbBtn'[\s\S]*addEventListener\('contextmenu'[\s\S]*generateGroupThumbnails\(true\)/);
assert.match(script, /async function generateGroupThumbnails\(animated = false\)/);
assert.match(script, /if \(animated[\s\S]*renderAnimatedThumbnail\(animation, options\)[\s\S]*renderCurrentFrameThumbnail\(\)/);
assert.match(script, /const size = 400;/, 'animated output must remain fixed at 400x400');
assert.match(script, /async function encodeAnimatedThumbnailToLimit/);
assert.match(script, /const targetBytes = maxKilobytes \* 1024/);
assert.match(script, /if \(buffer\.length <= targetBytes\) return/);
assert.match(script, /count: 2, bits: 3, block: 12/);
assert.match(script, /count: 2, bits: 2, block: 1, size: 32/);
assert.match(script, /const outputSize = profile\.size \|\| size/);
assert.match(script, /return smallest;/);
assert.doesNotMatch(script, /throw new Error\(t\('viewer\.animatedCannotFit'/);
assert.match(script, /require\('\.\.\/js\/animated-webp-encoder\.js'\)/);
assert.match(script, /require\('\.\.\/js\/spine-visible-bounds\.js'\)/);
assert.match(script, /async function measureVisibleBounds/);
assert.match(script, /const bounds = await measureVisibleBounds\(skeleton, geometryBounds, 1/);
assert.match(script, /measureVisibleBounds\(previewSkeleton, bounds, sampleTimes\.length/);
assert.match(script, /async function encodeAnimatedWebpToLimit/);
assert.match(script, /canvas\.toDataURL\('image\/webp', profile\.quality\)/);
assert.match(script, /extension: options\.format === 'apng' \? '\.png' : '\.webp'/);
assert.match(script, /for \(let alpha = 3; alpha < rgba\.length; alpha \+= 4\) rgba\[alpha\] = 255/);
assert.match(script, /const totalFrames = Math\.max\(1, frameOf\(animationLength\)\)/);
assert.match(script, /start: startFrame \/ FPS/);
assert.match(html, /id="animatedThumbStart" type="number" min="0" step="1"/);
assert.match(html, /id="animatedThumbEnd" type="number" min="0" step="1"/);
assert.match(html, /data-i18n="viewer\.framesUnit"/);
assert.match(html, /id="animatedThumbMaxSize" type="number" min="10" max="1024" step="10" value="30"/);
assert.match(html, /class="animated-thumb-danger" data-i18n="viewer\.animatedThumbnailDanger"/);
assert.match(html, /class="animated-thumb-warning-title" data-i18n="viewer\.animatedThumbnailAttention"/);
assert.match(css, /\.animated-thumb-warning-title \{ display: block; \}/);
assert.match(script, /maxSizeInput\.value = '30'/);
assert.match(script, /maxKilobytes < 10 \|\| maxKilobytes > 1024/);
assert.doesNotMatch(script, /startInput\.(?:focus|select)\(\)/);
assert.match(script, /document\.activeElement\.blur\(\)/);
assert.doesNotMatch(html, /id="animatedThumbQuality"/);
assert.doesNotMatch(html, /viewer\.secondsUnit/);
assert.match(html, /id="animatedThumbDialog"/);
assert.match(html, /id="animatedThumbDialogTitle"[^>]*>生成动态缩略图<\/div>/);
assert.match(css, /\.animated-thumb-card \.thumb-dialog-title \{ text-align: center; \}/);
assert.match(html, /id="animatedThumbFormat"><option value="webp">AWebP \(\.webp\)<\/option><option value="apng">APNG \(\.png\)<\/option>/);
assert.match(css, /\.animated-thumb-form \{[^}]*grid-template-columns: 96px minmax\(0, 1fr\)/);
assert.match(css, /\.animated-thumb-form input\[type="number"\]/);
}
console.log('PASS: APNG structure, compact dialog, size fallback and single/group right-click wiring');
+2
View File
@@ -35,6 +35,8 @@ try {
assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true }).skeletonPath, null); assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true }).skeletonPath, null);
const lateSkeleton = path.join(late, 'hero.skel.bytes'); fs.writeFileSync(lateSkeleton, Buffer.from('\0' + '3.8.1\0')); const lateSkeleton = path.join(late, 'hero.skel.bytes'); fs.writeFileSync(lateSkeleton, Buffer.from('\0' + '3.8.1\0'));
assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true, refreshIndex: true }).skeletonPath, lateSkeleton); assert.equal(utils.discoverAssets(lateAtlas, { strictStem: true, refreshIndex: true }).skeletonPath, lateSkeleton);
const scoped = utils.discoverAssets(lateAtlas, { searchRoots: [late] });
assert.equal(scoped.skeletonPath, lateSkeleton);
for (const [name, json, expected] of [ for (const [name, json, expected] of [
['spine.json', { skeleton: { spine: '4.2.43' }, bones: [] }, 'spine'], ['spine.json', { skeleton: { spine: '4.2.43' }, bones: [] }, 'spine'],
['empty-version.json', { skeleton: { spine: '' }, bones: [] }, 'other'], ['empty-version.json', { skeleton: { spine: '' }, bones: [] }, 'other'],
+85
View File
@@ -0,0 +1,85 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const { applyThumbnailTargets, orderCurrentTargetLast } = require('../js/spine-group-thumbnails');
(async () => {
const targets = ['json', 'skel', 'atlas'].map((id) => ({ sourcePath: '/library/' + id }));
assert.deepEqual(
orderCurrentTargetLast(targets, '/library/json').map((target) => target.sourcePath),
['/library/skel', '/library/atlas', '/library/json'],
'the preview-owning item must be processed last because Eagle tears down its viewer after updating it'
);
const applied = new Set();
const inputPaths = [];
const result = await applyThumbnailTargets({
targets,
initialSkipped: 1,
settleDelayMs: 0,
cleanupDelayMs: 0,
rendered: { buffer: Buffer.from('thumbnail'), extension: '.webp' },
getItem: async (sourcePath) => ({ id: sourcePath.split('/').pop(), data: { customThumbnail: false } }),
setThumbnail: async (id, thumbnailPath) => {
assert(fs.existsSync(thumbnailPath));
assert.equal(fs.readFileSync(thumbnailPath, 'utf8'), 'thumbnail');
inputPaths.push(thumbnailPath);
// Simulate Eagle consuming the supplied file. Reusing one file would make
// every call after the first fail, which was the original regression.
fs.unlinkSync(thumbnailPath);
applied.add(id);
return true;
},
waitForApplied: async (sourcePath) => applied.has(sourcePath.split('/').pop()),
});
assert.deepEqual(result, { success: 3, skipped: 1, failed: 0 });
assert.equal(new Set(inputPaths).size, 3);
const overwritten = await applyThumbnailTargets({
targets: [{ sourcePath: '/library/custom', allowOverwrite: true }],
settleDelayMs: 0,
cleanupDelayMs: 0,
rendered: { buffer: Buffer.from('replacement'), extension: '.webp' },
getItem: async () => ({ id: 'custom', data: { customThumbnail: true } }),
setThumbnail: async (_id, thumbnailPath) => { assert(fs.existsSync(thumbnailPath)); return true; },
waitForApplied: async () => true,
});
assert.deepEqual(overwritten, { success: 1, skipped: 0, failed: 0 });
const deferredReads = [];
const deferred = await applyThumbnailTargets({
targets,
settleDelayMs: 15,
cleanupDelayMs: 25,
rendered: { buffer: Buffer.from('deferred-thumbnail'), extension: '.webp' },
getItem: async (sourcePath) => ({ id: sourcePath.split('/').pop(), data: { customThumbnail: false } }),
setThumbnail: async (id, thumbnailPath) => {
setTimeout(() => {
deferredReads.push({ id, content: fs.readFileSync(thumbnailPath, 'utf8') });
}, 10);
return true;
},
// Simulate the Web API exposing customThumbnail before its background file
// copy has completed.
waitForApplied: async () => true,
});
assert.deepEqual(deferred, { success: 3, skipped: 0, failed: 0 });
assert.deepEqual(deferredReads.map((entry) => entry.id), ['json', 'skel', 'atlas']);
assert(deferredReads.every((entry) => entry.content === 'deferred-thumbnail'));
let rejectedCalls = 0;
const retried = await applyThumbnailTargets({
targets: [{ sourcePath: '/library/retry' }],
settleDelayMs: 0,
cleanupDelayMs: 0,
rendered: { buffer: Buffer.from('retry-thumbnail'), extension: '.png' },
getItem: async () => ({ id: 'retry', data: { customThumbnail: false } }),
setThumbnail: async () => ++rejectedCalls === 1 ? false : true,
waitForApplied: async () => true,
});
assert.equal(rejectedCalls, 2);
assert.deepEqual(retried, { success: 1, skipped: 0, failed: 0 });
console.log('PASS: first batch applies every selected item using an independent temporary file');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
+5 -2
View File
@@ -10,8 +10,11 @@ const root=path.resolve(process.argv[2]||path.join(__dirname,'.fixtures'));
const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas'); const dir=path.join(root,version,name),atlasPath=path.join(dir,name+'.atlas');
if(!fs.existsSync(atlasPath))continue; if(!fs.existsSync(atlasPath))continue;
const text=fs.readFileSync(atlasPath,'utf8'); const text=fs.readFileSync(atlasPath,'utf8');
for(const page of utils.parseAtlasPages(text))assert(fs.existsSync(path.join(dir,page)),version+'/'+name+' missing '+page); const texturePaths={};
const atlas=await render.buildTextureAtlas(spine,text,dir); for(const page of utils.parseAtlasPages(text)){
const texture=utils.resolveAtlasTextureFile(dir,page);assert(texture,version+'/'+name+' missing '+page);texturePaths[page]=texture;
}
const atlas=await render.buildTextureAtlas(spine,text,texturePaths);
for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){ for(const file of fs.readdirSync(dir).filter(n=>/\.(json|skel)$/.test(n))){
const source=path.join(dir,file),detected=utils.detectVersion(source); const source=path.join(dir,file),detected=utils.detectVersion(source);
assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version'); assert.equal(utils.chooseRuntimeDir(detected,file.endsWith('.skel')),version,source+' version');
-44
View File
@@ -1,44 +0,0 @@
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { execFile } = require('child_process');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-worker-lifecycle-'));
const library = path.join(root, 'test.library');
const source = path.join(library, 'images', 'test.info', 'hero.atlas.txt');
const marker = path.join(root, 'completed');
const worker = path.resolve(__dirname, '../js/spine-reconcile-worker.js');
const thumbnail = path.resolve(__dirname, '../thumbnail/spine.js');
const key = crypto.createHash('sha256').update(path.dirname(worker) + library).digest('hex');
const queue = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
(async () => {
try {
fs.mkdirSync(path.dirname(source), { recursive: true }); fs.writeFileSync(source, 'atlas');
fs.writeFileSync(path.join(library, 'metadata.json'), '{"folders":[]}');
// Isolate lifecycle behavior from rendering and the user's running Eagle API.
const preload = path.join(root, 'preload.cjs');
fs.writeFileSync(preload, `require.cache[${JSON.stringify(thumbnail)}] = { exports: {
reconcileOnce: async (src, previous) => {
if (previous !== 'stable') return { signature: 'stable', complete: false };
require('fs').appendFileSync(${JSON.stringify(marker)}, String(process.pid) + '\\n');
return { complete: true };
}
}};`);
await new Promise((resolve, reject) => execFile(process.execPath, ['-e', `require(${JSON.stringify(worker)}).enqueue(${JSON.stringify(source)})`],
{ env: { ...process.env, NODE_OPTIONS: '--require=' + preload } }, error => error ? reject(error) : resolve()));
assert(!fs.existsSync(marker), 'launcher returns before reconciliation');
const deadline = Date.now() + 15000;
while ((!fs.existsSync(marker) || fs.existsSync(path.join(queue, 'worker.lock'))) && Date.now() < deadline) await sleep(100);
assert(fs.existsSync(marker), 'worker completes after launching process has exited');
assert(!fs.existsSync(path.join(queue, 'worker.lock')), 'worker releases lock and exits when idle');
assert.equal(fs.readdirSync(queue).filter(name => name.endsWith('.json')).length, 0);
console.log('PASS: detached worker survives callback process exit, completes queued work and releases lock');
} finally {
if (!fs.existsSync(path.join(queue, 'worker.lock'))) {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(queue, { recursive: true, force: true });
}
}
})().catch(error => { console.error(error); process.exitCode = 1; });
+195
View File
@@ -0,0 +1,195 @@
'use strict';
// 出包前体检:release/UNIVERSAL_SpineViewer 必须是「干净 + 与源逐字节一致 + 引用无断链」的目录,
// 因为 Eagle 的“打包插件”是直接把这个目录打成 .eagleplugin,污染会被原样带进安装包。
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const mirror = path.join(root, 'release', 'UNIVERSAL_SpineViewer');
const SHARED_TREES = ['viewer', 'js', 'lib', '_locales', 'assets'];
// package.json 是“发行版专有”文件:不再要求逐字节一致(发行版删掉了指向 tests/ 的 scripts),
// 但 name/version/type 必须与源一致,下面单独校验。
const SHARED_FILES = ['manifest.json', 'package-lock.json', 'logo-spineviewer.png'];
// 只留在源目录、刻意不进入安装包的文件(新增这类文件时必须显式登记,避免漏打包)。
const SOURCE_ONLY = [
'viewer/icns.html',
'js/icns-util.js',
'js/image-size.js',
'js/spine-render.js',
'assets/README.md',
'assets/spineviewer-icon-master.png',
];
// 临时豁免清单:包内暂时无人引用的脚本。历史上曾登记过旧「导入缩略图处理器」的
// js/eagle-compound.js,已随死代码清理删除。新增的无用脚本会被下面的断言拦住。
const UNREFERENCED_SCRIPT_OK = [];
// 语言文件里未被任何代码引用的键(旧的导入缩略图/元素提示文案遗留)。
const UNUSED_MESSAGE_OK = [
'viewer.groupSkipCustom',
'viewer.thumbWrittenNoRefresh',
'viewer.compoundName',
'viewer.txtRegistered',
'viewer.noItemId',
];
const JUNK = /(\.log|\.tmp|\.bak|\.orig|\.swp|~)$|^\.DS_Store$|^Thumbs\.db$|^node_modules$|^\.git$|^\.idea$|^\.vscode$/i;
function walk(directory, prefix = '') {
const out = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const relative = prefix ? prefix + '/' + entry.name : entry.name;
if (entry.isDirectory()) out.push(...walk(path.join(directory, entry.name), relative));
else out.push(relative);
}
return out;
}
assert.ok(fs.existsSync(mirror), '缺少 release/UNIVERSAL_SpineViewer 目录');
// 1) 洁净度:不含日志、临时文件、系统或编辑器垃圾
const junk = walk(mirror).filter((file) => JUNK.test(path.basename(file)) || file.split('/').some((part) => JUNK.test(part)));
assert.deepEqual(junk, [], '镜像内存在垃圾文件:' + junk.join(', '));
// 2) 共享文件必须与源逐字节一致(镜像过时会发出带旧代码的包)
const drift = [];
for (const tree of SHARED_TREES) {
for (const relative of walk(path.join(root, tree))) {
const name = tree + '/' + relative;
const source = path.join(root, tree, relative);
const copy = path.join(mirror, tree, relative);
if (!fs.existsSync(copy)) {
if (!SOURCE_ONLY.includes(name)) drift.push('MISSING ' + name);
} else if (!fs.readFileSync(source).equals(fs.readFileSync(copy))) {
drift.push('DIFFERENT ' + name);
}
}
}
for (const file of SHARED_FILES) {
const source = path.join(root, file);
const copy = path.join(mirror, file);
if (!fs.existsSync(copy)) drift.push('MISSING ' + file);
else if (!fs.readFileSync(source).equals(fs.readFileSync(copy))) drift.push('DIFFERENT ' + file);
}
assert.deepEqual(drift, [], '镜像与源不一致:' + drift.join(', '));
// 3) 镜像内不能有源目录里不存在的文件(排除根级专有文档)
const extra = [];
for (const tree of SHARED_TREES) {
for (const relative of walk(path.join(mirror, tree))) {
if (!fs.existsSync(path.join(root, tree, relative))) extra.push(tree + '/' + relative);
}
}
assert.deepEqual(extra, [], '镜像内存在源目录没有的文件:' + extra.join(', '));
// 4) 相对 require 目标必须存在(缺一个就会在运行时抛错)
const broken = [];
for (const relative of walk(mirror)) {
if (!/\.(js|html)$/.test(relative)) continue;
const text = fs.readFileSync(path.join(mirror, relative), 'utf8');
for (const match of text.matchAll(/require\(\s*['"]([^'"]+)['"]\s*\)/g)) {
if (!match[1].startsWith('.')) continue;
const target = path.posix.normalize(path.posix.join(path.posix.dirname(relative), match[1]));
if (!fs.existsSync(path.join(mirror, target))) broken.push(relative + ' -> ' + match[1]);
}
}
assert.deepEqual(broken, [], '镜像内 require 目标缺失:' + broken.join(', '));
// 5) 不能残留已移除模块的引用
const removed = ['icns-util', 'image-size', 'spine-render'];
const leftovers = [];
for (const relative of walk(mirror)) {
if (!/\.(js|html)$/.test(relative)) continue;
const text = fs.readFileSync(path.join(mirror, relative), 'utf8');
for (const name of removed) {
if (text.includes(name)) leftovers.push(relative + ' 提到 ' + name);
}
}
assert.deepEqual(leftovers, [], '镜像内残留已移除模块的引用:' + leftovers.join(', '));
// 6) manifest 指向的资源必须齐全,语言文件必须是合法 JSON
const manifest = JSON.parse(fs.readFileSync(path.join(mirror, 'manifest.json'), 'utf8'));
assert.ok(fs.existsSync(path.join(mirror, manifest.logo.replace(/^\//, ''))), 'manifest.logo 指向的文件不存在');
for (const entry of Object.values(manifest.preview || {})) {
const viewerPath = entry && entry.viewer && entry.viewer.path;
assert.ok(viewerPath && fs.existsSync(path.join(mirror, viewerPath)), 'manifest.preview 的 viewer 路径不存在');
}
assert.deepEqual(manifest.languages, ['zh_CN', 'en', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE']);
for (const lang of manifest.languages) {
const file = path.join(mirror, '_locales', lang + '.json');
assert.ok(fs.existsSync(file), '缺少语言文件 ' + lang + '.json');
const table = JSON.parse(fs.readFileSync(file, 'utf8'));
assert.equal(typeof table.viewer, 'object', lang + '.json 缺少 viewer 段');
}
// 7) 九个运行时版本齐全(版本检测按 3.4–4.3 选择运行时)
const expectedRuntimes = ['3.4', '3.5', '3.6', '3.7', '3.8', '4.0', '4.1', '4.2', '4.3'];
assert.deepEqual(fs.readdirSync(path.join(mirror, 'lib')).sort(), expectedRuntimes.slice().sort());
for (const runtime of expectedRuntimes) {
assert.ok(fs.existsSync(path.join(mirror, 'lib', runtime, 'spine-canvas.js')), '缺少 lib/' + runtime + '/spine-canvas.js');
}
// 8) 镜像里的 LICENSE 必须齐全
for (const license of fs.readdirSync(path.join(root, 'LICENSES'))) {
assert.ok(fs.existsSync(path.join(mirror, 'LICENSES', license)), '缺少 LICENSES/' + license);
}
// 8) 发行版 package.json:不能指向包内不存在的文件(曾经的 check:release -> tests/ 死链)
const rootPackage = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8'));
const mirrorPackage = JSON.parse(fs.readFileSync(path.join(mirror, 'package.json'), 'utf8'));
for (const field of ['name', 'version', 'type']) {
assert.equal(mirrorPackage[field], rootPackage[field], '发行版 package.json 的 ' + field + ' 与源不一致');
}
const deadScripts = [];
for (const [name, command] of Object.entries(mirrorPackage.scripts || {})) {
for (const token of String(command).split(/\s+/)) {
if (!/^[\w./-]+\.(js|mjs|cjs|json)$/.test(token)) continue;
if (!fs.existsSync(path.join(mirror, token))) deadScripts.push('scripts.' + name + ' -> ' + token);
}
}
assert.deepEqual(deadScripts, [], '发行版 package.json 的 scripts 指向包内不存在的文件:' + deadScripts.join(', '));
// 9) 死脚本:包内每个 js 模块都必须被入口或其他模块引用
const entrySource = fs.readFileSync(path.join(mirror, 'viewer', 'spine.html'), 'utf8');
const scriptNames = fs.readdirSync(path.join(mirror, 'js'));
const unreferenced = [];
for (const file of scriptNames) {
const others = [entrySource]
.concat(scriptNames.filter((name) => name !== file).map((name) => fs.readFileSync(path.join(mirror, 'js', name), 'utf8')))
.join('\n');
if (!others.includes(file)) unreferenced.push('js/' + file);
}
assert.deepEqual(
unreferenced.filter((file) => !UNREFERENCED_SCRIPT_OK.includes(file)),
[],
'包内存在无人引用的脚本:' + unreferenced.join(', '),
);
// 10) 死文案:语言文件里的键必须被入口/脚本/manifest 引用(manifest 的 name 用 {{manifest.app.name}})
const referenced = new Set();
const sources = [entrySource, fs.readFileSync(path.join(mirror, 'manifest.json'), 'utf8')]
.concat(scriptNames.map((name) => fs.readFileSync(path.join(mirror, 'js', name), 'utf8')));
for (const text of sources) {
for (const match of text.matchAll(/[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z0-9_]+)+/g)) referenced.add(match[0]);
}
const unusedMessages = [];
for (const lang of manifest.languages) {
const table = JSON.parse(fs.readFileSync(path.join(mirror, '_locales', lang + '.json'), 'utf8'));
const keys = [];
const collect = (node, prefix) => {
for (const key of Object.keys(node)) {
const value = node[key];
if (value && typeof value === 'object') collect(value, prefix + key + '.');
else keys.push(prefix + key);
}
};
collect(table, '');
for (const key of keys) {
if (!referenced.has(key) && !unusedMessages.includes(key)) unusedMessages.push(key);
}
}
assert.deepEqual(
unusedMessages.filter((key) => !UNUSED_MESSAGE_OK.includes(key)),
[],
'语言文件里存在无人引用的死文案:' + unusedMessages.join(', '),
);
console.log('PASS: 镜像与源一致、无垃圾文件、无断链引用、无死脚本/死文案,manifest/语言/运行时/许可齐全');
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const root = path.resolve(__dirname, '..');
const releaseDir = path.join(root, 'release', 'UNIVERSAL_SpineViewer');
const archive = path.join(root, 'release', 'SpineViewer.eagleplugin');
function walk(directory, prefix = '') {
const files = [];
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
const relative = path.posix.join(prefix, entry.name);
if (entry.isDirectory()) files.push(...walk(path.join(directory, entry.name), relative));
else files.push(relative);
}
return files;
}
function zipEntries(file) {
const data = fs.readFileSync(file);
const entries = [];
for (let offset = 0; offset + 46 <= data.length;) {
const header = data.indexOf(Buffer.from([0x50, 0x4b, 0x01, 0x02]), offset);
if (header < 0) break;
const nameLength = data.readUInt16LE(header + 28);
const extraLength = data.readUInt16LE(header + 30);
const commentLength = data.readUInt16LE(header + 32);
entries.push(data.subarray(header + 46, header + 46 + nameLength).toString('utf8'));
offset = header + 46 + nameLength + extraLength + commentLength;
}
return entries;
}
for (const [label, files] of [
['release directory', walk(releaseDir)],
['eagleplugin archive', zipEntries(archive)],
]) {
assert.equal(files.some((file) => /\.log$/i.test(file)), false, label + ' must not contain runtime or historical logs');
assert.equal(files.some((file) => /(^|\/)\.DS_Store$/i.test(file)), false, label + ' must not contain macOS metadata');
assert.equal(files.includes('thumbnail/spine.js'), false, label + ' must not contain an import-time Spine thumbnail handler');
assert.equal(files.some((file) => /^node_modules\//.test(file)), false, label + ' must not contain unused native thumbnail dependencies');
}
const manifest = JSON.parse(fs.readFileSync(path.join(releaseDir, 'manifest.json'), 'utf8'));
assert.equal(Object.values(manifest.preview || {}).some((entry) => entry && entry.thumbnail), false);
console.log('PASS: release has no logs, metadata, import thumbnail handler, or unused native thumbnail dependencies');
+86
View File
@@ -0,0 +1,86 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const utils = require('../js/spine-utils');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-security-'));
try {
for (const unsafe of [
'../../../secret.png',
'..\\..\\secret.png',
'/tmp/secret.png',
'C:\\secret.png',
'C:secret.png',
'\\\\server\\share\\secret.png',
'file:///tmp/secret.png',
'folder/../secret.png',
'folder\\..\\secret.png',
'bad\0name.png',
]) {
assert.throws(() => utils.validateAtlasPageName(unsafe), (error) => error && error.code === 'UNSAFE_ATLAS_PATH', unsafe);
}
assert.equal(utils.validateAtlasPageName('./textures/page.png'), 'textures/page.png');
const atlasDir = path.join(root, 'atlas');
fs.mkdirSync(path.join(atlasDir, 'textures'), { recursive: true });
const nestedTexture = path.join(atlasDir, 'textures', 'page.png');
fs.writeFileSync(nestedTexture, 'texture');
assert.equal(utils.resolveAtlasTextureFile(atlasDir, 'textures/page.png'), nestedTexture);
const outside = path.join(root, 'outside.png');
const link = path.join(atlasDir, 'linked.png');
fs.writeFileSync(outside, 'private');
try {
fs.symlinkSync(outside, link);
assert.throws(() => utils.resolveAtlasTextureFile(atlasDir, 'linked.png'), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
} catch (error) {
if (!['EPERM', 'EACCES'].includes(error.code)) throw error;
}
const library = path.join(root, 'safe.library');
const images = path.join(library, 'images');
fs.mkdirSync(images, { recursive: true });
fs.writeFileSync(path.join(library, 'metadata.json'), JSON.stringify({ folders: [{ id: 'spine', name: 'Spine', children: [] }] }));
function item(id, name, contents) {
const dir = path.join(images, id + '.info');
fs.mkdirSync(dir);
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({ id, name: path.parse(name).name, ext: path.extname(name).slice(1), folders: ['spine'], isDeleted: false }));
const file = path.join(dir, name);
fs.writeFileSync(file, contents);
return file;
}
const skeleton = item('SKELETON', 'hero.skel', Buffer.from('\0' + '3.8.1\0'));
item('ATLAS', 'hero.atlas', 'textures/hero.png\nsize: 64,64\n');
const texture = item('TEXTURE', 'hero.png', 'texture');
const assets = utils.discoverAssets(skeleton, { strictStem: true, refreshIndex: true });
assert.equal(assets.texturePaths['textures/hero.png'], texture, 'cross-item texture must come from a validated Eagle item');
const maliciousDir = path.join(root, 'malicious');
fs.mkdirSync(maliciousDir);
const maliciousSkeleton = path.join(maliciousDir, 'evil.skel');
fs.writeFileSync(maliciousSkeleton, Buffer.from('\0' + '3.8.1\0'));
fs.writeFileSync(path.join(maliciousDir, 'evil.atlas'), '../../../secret.png\nsize: 64,64\n');
assert.throws(() => utils.discoverAssets(maliciousSkeleton), (error) => error && error.code === 'UNSAFE_ATLAS_PATH');
const manifest = JSON.parse(fs.readFileSync(path.join(__dirname, '../manifest.json'), 'utf8'));
const preview = Object.values(manifest.preview || {});
assert.equal(preview.some((entry) => entry && entry.thumbnail), false, 'import-time thumbnail handler must stay disabled');
assert.equal(fs.existsSync(path.join(__dirname, '../thumbnail/spine.js')), false);
assert.equal(fs.existsSync(path.join(__dirname, '../js/spine-reconcile-worker.js')), false);
const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/);
assert.match(viewerSource, /activeAssets\.skeletonPath[\s\S]*activeAssets\.atlasPath[\s\S]*Object\.values\(activeAssets\.texturePaths/);
assert.match(viewerSource, /activeAssets\.skeletonCandidatePaths[\s\S]*activeAssets\.atlasCandidatePaths[\s\S]*utils\.normalizeStem/);
assert.match(viewerSource, /selected: item\.data\.customThumbnail !== true && !utils\.isTextureFile\(sourcePath\)/);
const groupThumbnailSource = fs.readFileSync(path.join(__dirname, '../js/spine-group-thumbnails.js'), 'utf8');
assert.match(groupThumbnailSource, /fresh\.data\.customThumbnail === true && target\.allowOverwrite !== true\) \{ skipped\+\+; continue; \}[\s\S]*options\.setThumbnail\(fresh\.id/);
assert.match(groupThumbnailSource, /itemTempPath = path\.join\(os\.tmpdir\(\), 'spineviewer-group-' \+ fresh\.id[\s\S]*options\.setThumbnail\(fresh\.id, itemTempPath\)/);
assert.match(viewerSource, /groupThumbBtn[\s\S]*thumbBtn/);
assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/);
assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/);
console.log('PASS: atlas path boundaries, validated Eagle pairing, no import thumbnail handler, and manual current-item confirmation');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
+48
View File
@@ -0,0 +1,48 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const vm = require('node:vm');
const roots = ['.', 'release/UNIVERSAL_SpineViewer'];
function functionBody(source, name, nextName) {
const start = source.indexOf(`\tfunction ${name}(`);
const end = source.indexOf(`\n\tfunction ${nextName}(`, start + 1);
assert.ok(start >= 0 && end > start, `missing function ${name}`);
return source.slice(start, end);
}
for (const root of roots) {
const viewerPath = path.join(root, 'viewer/spine.html');
const html = fs.readFileSync(viewerPath, 'utf8');
const css = fs.readFileSync(path.join(root, 'viewer/spine.css'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
new vm.Script(script);
assert.match(script, /const SETUP_POSE_INDEX = -1;/);
assert.match(script, /let currentAnim = SETUP_POSE_INDEX[\s\S]*let playing = false/);
assert.match(script, /setupItem\.dataset\.index = String\(SETUP_POSE_INDEX\)/);
assert.match(script, /showSetupPose\(\);\s*captureStableFitBounds\(\);\s*fitToCanvas\(\);/);
assert.match(script, /state\.clearTracks[\s\S]*skeleton\.setToSetupPose/);
assert.match(script, /if \(idx === SETUP_POSE_INDEX\)[\s\S]*showSetupPose\(\)/);
const fit = functionBody(script, 'fitToCanvas', 'draw');
assert.match(fit, /const bounds = stableFitBounds;/);
assert.doesNotMatch(fit, /getBounds\(/, 'wheel/resize fitting must not sample an animated frame');
assert.match(fit, /fitScale = zoom;/, 'wheel zoom must still work before a drawable stable bound exists');
const capture = functionBody(script, 'captureStableFitBounds', 'fitToCanvas');
assert.match(capture, /getBounds\(skeleton\)/);
assert.match(script, /async function setAnimationByName[\s\S]*if \(!stableFitBounds\)[\s\S]*measureVisibleBounds\(skeleton, geometryBounds, 1[\s\S]*captureStableFitBounds\(bounds\)/);
assert.match(css, /#status \{\s*position: absolute; left: 50%;[^}]*transform: translateX\(-50%\)/, 'status notice must be horizontally centered');
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'));
for (const language of manifest.languages) {
const locale = JSON.parse(fs.readFileSync(path.join(root, '_locales', language + '.json'), 'utf8'));
assert.equal(locale.viewer.setupPose, 'Setup Pose', `${root}/${language} must keep the Spine term untranslated`);
}
}
console.log('PASS: Setup Pose is the default still frame and zoom uses stable bounds');
+124
View File
@@ -0,0 +1,124 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const utils = require('../js/spine-utils.js');
const settings = require('../js/spine-preview-settings.js');
// --- 内置尾缀(默认值,用户不可删除)---
assert.deepEqual(utils.getSuffixes(), utils.DEFAULT_SUFFIXES);
assert.equal(utils.normalizeStem('hero-mesh.skel'), 'hero');
assert.equal(utils.normalizeStem('hero-data.json'), 'hero');
// --- 自定义尾缀生效后才参与归一化 ---
assert.equal(utils.normalizeStem('hero-hover.skel'), 'hero-hover');
utils.setSuffixes(['hover']);
assert.equal(utils.normalizeStem('hero-hover.skel'), 'hero');
assert.equal(utils.normalizeStem('hero_hover.atlas.txt'), 'hero');
assert.deepEqual(utils.getCustomSuffixes(), ['hover']);
assert.deepEqual(utils.getSuffixes(), utils.DEFAULT_SUFFIXES.concat('hover'));
// --- 长度上限:常量与正则必须一致(改上限时只动一个地方)---
assert.equal(utils.SUFFIX_MAX_LENGTH, 50);
assert.equal(utils.normalizeSuffixName('a'.repeat(utils.SUFFIX_MAX_LENGTH)), 'a'.repeat(utils.SUFFIX_MAX_LENGTH));
assert.equal(utils.normalizeSuffixName('a'.repeat(utils.SUFFIX_MAX_LENGTH + 1)), null);
// --- 非法、重复、内置重名的尾缀一律丢弃(磁盘内容不可信)---
assert.deepEqual(
utils.setSuffixes(['hover', 'HOVER', 'mesh', '', ' ', '-', '_x', 'bad name',
'a'.repeat(utils.SUFFIX_MAX_LENGTH + 1), 'a'.repeat(utils.SUFFIX_MAX_LENGTH)]),
utils.DEFAULT_SUFFIXES.concat(['hover', 'x', 'a'.repeat(utils.SUFFIX_MAX_LENGTH)])
);
assert.equal(utils.normalizeSuffixName(' Hover '), 'hover');
assert.equal(utils.normalizeSuffixName('_outline'), 'outline');
assert.equal(utils.normalizeSuffixName(''), null);
assert.equal(utils.normalizeSuffixName('a.b'), null);
assert.equal(utils.normalizeSuffixName('!'), null);
assert.equal(utils.normalizeSuffixName(null), null);
// --- 尾缀决定配套资源能否配对(strictStem 与导入缩略图共用同一条路径)---
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'spineviewer-suffix-'));
try {
fs.writeFileSync(path.join(dir, 'hero-hover.json'), JSON.stringify({
skeleton: { spine: '4.2.05' }, bones: [{ name: 'root' }], slots: [], skins: [], animations: {},
}));
fs.writeFileSync(path.join(dir, 'hero.atlas'),
'hero.png\nsize: 16,16\nformat: RGBA8888\nfilter: Linear,Linear\nrepeat: none\nbounds: 0,0,16,16\n');
fs.writeFileSync(path.join(dir, 'hero.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
const source = path.join(dir, 'hero-hover.json');
const options = { searchRoots: [dir], strictStem: true };
utils.setSuffixes([]);
assert.equal(utils.discoverAssets(source, options).atlasPath, null);
utils.setSuffixes(['hover']);
const assets = utils.discoverAssets(source, options);
assert.equal(assets.skeletonPath, source);
assert.equal(assets.atlasPath, path.join(dir, 'hero.atlas'));
assert.equal(assets.texturePaths['hero.png'], path.join(dir, 'hero.png'));
assert.equal(assets.base, 'hero');
} finally {
utils.setSuffixes([]);
fs.rmSync(dir, { recursive: true, force: true });
}
// --- 全局尾缀存储:读取、原子写入、损坏文件不拖垮查看器 ---
const userData = fs.mkdtempSync(path.join(os.tmpdir(), 'spineviewer-userdata-'));
try {
const store = settings.createSuffixStore(userData);
assert.deepEqual(store.read(), []);
store.write(['hover', 'outline']);
assert.deepEqual(store.read(), ['hover', 'outline']);
assert.equal(path.dirname(store.file), path.join(userData, 'SpineViewer'));
assert.deepEqual(fs.readdirSync(path.dirname(store.file)).filter((name) => name.endsWith('.tmp')), []);
fs.writeFileSync(store.file, '{ not json');
assert.throws(() => store.read());
store.write([]); // 损坏文件仍可被下一次写入覆盖
assert.deepEqual(store.read(), []);
} finally {
fs.rmSync(userData, { recursive: true, force: true });
}
// --- 查看器接线:启动时先读尾缀配置,再加载资源 ---
const viewer = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
assert.match(viewer, /<select id="suffixSelect"><\/select>/);
assert.match(viewer, /utils\.DEFAULT_SUFFIXES/);
assert.match(viewer, /initSuffixSettings\(\)\.then\(\(\) => loadAssets\(\)\)/);
assert.match(viewer, /suffixSettings\.write\(list\)/);
assert.match(viewer, /utils\.normalizeSuffixName\(value\)/);
// 下拉必须停在独立的静止值:选中“已经是当前值的项”不会触发 change,
// 否则用户点「+ 添加尾缀…」会毫无反应。
assert.match(viewer, /const SUFFIX_NONE_VALUE = '__none__';/);
assert.match(viewer, /resting\.value = SUFFIX_NONE_VALUE;/);
assert.match(viewer, /suffixSelectEl\.value = SUFFIX_NONE_VALUE;/);
assert.doesNotMatch(viewer, /suffixSelectEl\.value = SUFFIX_ADD_VALUE;/);
// 事件回调里的异步动作必须自兜底,否则失败会静默
assert.match(viewer, /runSuffixAction\(addCustomSuffix\(\)\)/);
assert.match(viewer, /runSuffixAction\(removeCustomSuffix\(/);
// 输入框上限来自 utils 常量,不能再出现第二个硬编码的数字
assert.match(viewer, /maxlength="50"/);
assert.match(viewer, /inputEl\.maxLength = utils\.SUFFIX_MAX_LENGTH;/);
// 8 种语言的说明文字都必须提到当前上限
for (const file of fs.readdirSync(path.join(__dirname, '../_locales'))) {
const table = JSON.parse(fs.readFileSync(path.join(__dirname, '../_locales', file), 'utf8'));
assert.ok(table.viewer.suffixAddNote.includes(String(utils.SUFFIX_MAX_LENGTH)), `${file}: suffixAddNote 未提到当前上限`);
}
// 尾缀脚本引用的每个 id 都必须真实存在:缺一个就会在 openSuffixDialog 里抛错,
// 而事件回调里的异常不会冒泡到界面,只会表现为“点了没反应”。
const suffixSection = viewer.slice(viewer.indexOf('// ---------- 文件名尾缀设置'), viewer.indexOf('// ---------- 启动'));
assert.ok(suffixSection.length > 0);
for (const match of suffixSection.matchAll(/getElementById\('([^']+)'\)/g)) {
assert.ok(viewer.includes(`id="${match[1]}"`), `missing element #${match[1]}`);
}
for (const id of ['suffixSelect', 'suffixDialog', 'suffixDialogTitle', 'suffixDialogNote',
'suffixDialogInput', 'suffixDialogError', 'suffixDialogConfirm', 'suffixDialogCancel']) {
assert.ok(viewer.includes(`id="${id}"`), `missing element #${id}`);
}
console.log('PASS: built-in/custom suffixes, input validation, pairing, global store, and viewer wiring');
-53
View File
@@ -1,53 +0,0 @@
'use strict';
const assert = require('assert/strict');
const fs = require('fs');
const os = require('os');
const path = require('path');
const { normalizeItem } = require('../js/eagle-compound');
const generate = require('../thumbnail/spine');
const { canvasLib } = require('../js/spine-render');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-thumbnail-'));
const fixtures = path.resolve(process.argv[2] || path.join(__dirname, '.fixtures/3.4/spineboy'));
(async () => {
try {
for (const [name, ext, full] of [['lili.atlas', 'txt', 'atlas.txt'], ['lili.skel', 'bytes', 'skel.bytes']]) {
const item = { name, ext, text: 'old text' };
const source = path.join(root, name + '.' + ext);
assert(normalizeItem(source, item));
assert.equal(item.name, 'lili'); assert.equal(item.ext, full);
assert.equal(path.basename(source), item.name + '.' + item.ext);
assert.equal(item.text, undefined); assert.equal(normalizeItem(source, item), false);
}
for (const text of ['', 'ordinary text\nsecond line']) {
const src = path.join(root, 'notes.txt'); fs.writeFileSync(src, text);
const result = await generate({ src, dest: path.join(root, 'notes_thumbnail.png'), item: { name: 'notes', ext: 'txt' } });
assert.equal(result.text, text); assert.equal(result.ext, 'txt'); assert.equal(result.name, 'notes');
}
const ordinaryJson = path.join(root, 'config.json');
const ordinaryJsonDest = path.join(root, 'config_thumbnail.png');
fs.writeFileSync(ordinaryJson, JSON.stringify({ skeleton: { spine: null }, enabled: true }));
const ordinaryJsonItem = { name: 'config', ext: 'json' };
assert.equal(await generate({ src: ordinaryJson, dest: ordinaryJsonDest, item: ordinaryJsonItem }), ordinaryJsonItem);
assert.equal(fs.existsSync(ordinaryJsonDest), false, 'non-Spine JSON must leave thumbnail destination untouched');
assert.equal(ordinaryJsonItem.width, undefined); assert.equal(ordinaryJsonItem.height, undefined);
for (const [source, target] of [['spineboy-mesh.skel', 'spineboy.skel.bytes'], ['spineboy.atlas', 'spineboy.atlas.txt'], ['spineboy.png', 'spineboy.png']])
fs.copyFileSync(path.join(fixtures, source), path.join(root, target));
for (const [name, ext] of [['spineboy.atlas', 'txt'], ['spineboy.skel', 'bytes']]) {
const src = path.join(root, name + '.' + ext), before = fs.readFileSync(src);
const dest = path.join(root, name + '_thumbnail.png');
const result = await generate({ src, dest, item: { name, ext } });
assert.equal(result.name, 'spineboy'); assert.equal(result.ext, name.split('.')[1] + '.' + ext);
assert(before.equals(fs.readFileSync(path.join(root, result.name + '.' + result.ext))));
const canonical = path.join(root, 'spineboy_thumbnail.png');
assert(fs.existsSync(dest)); assert(fs.existsSync(canonical));
assert.equal(fs.readFileSync(canonical).subarray(8,12).toString(), 'WEBP');
const image = await canvasLib.loadImage(canonical), canvas = canvasLib.createCanvas(400,400);
const ctx = canvas.getContext('2d'); ctx.drawImage(image,0,0);
const pixels = ctx.getImageData(12,356,110,32).data;
let white = 0; for (let i = 0; i < pixels.length; i += 4) if (pixels[i]>190 && pixels[i+1]>190 && pixels[i+2]>190) white++;
assert(white > 50, 'bottom-left version watermark must be visible');
}
console.log('PASS: ordinary TXT remains visible, compound metadata round-trip, canonical WebP cache and version watermark');
} finally { fs.rmSync(root, { recursive: true, force: true }); }
})().catch(error => { console.error(error); process.exitCode = 1; });
+17 -2
View File
@@ -4,16 +4,31 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const vm = require('node:vm'); const vm = require('node:vm');
const { createRequire } = require('node:module'); const { createRequire } = require('node:module');
const roots = ['.', 'release/MAC_arm64_SpineViewer', 'release/WIN_x64_SpineViewer']; const roots = ['.', 'release/UNIVERSAL_SpineViewer'];
for (const root of roots) { for (const root of roots) {
const html = fs.readFileSync(path.join(root, 'viewer/spine.html'), 'utf8'); const html = fs.readFileSync(path.join(root, 'viewer/spine.html'), 'utf8');
const css = fs.readFileSync(path.join(root, 'viewer/spine.css'), 'utf8');
const script = html.match(/<script>([\s\S]*?)<\/script>/)[1]; const script = html.match(/<script>([\s\S]*?)<\/script>/)[1];
new vm.Script(script); new vm.Script(script);
for (const id of ['groupThumbBtn', 'thumbBtn']) {
const button = html.match(new RegExp(`<button id="${id}"[^>]*>`))[0];
assert.match(button, /data-instant-tooltip/);
assert.match(button, /data-tooltip=/);
assert.doesNotMatch(button, /\stitle=/);
}
assert.match(css, /#groupThumbBtn:hover::after, #thumbBtn:hover::after \{ opacity: 1; visibility: visible; \}/);
assert.match(css, /#groupThumbBtn \{[^}]*background: #2e7d32;/);
assert.match(css, /#thumbBtn \{[^}]*background: #1976d2;/);
assert.match(css, /#groupThumbBtn::after \{[^}]*background: #256628;/);
assert.match(css, /#thumbBtn::after \{[^}]*background: #155fa8;/);
assert.match(script, /if \(el\.hasAttribute\('data-instant-tooltip'\)\) el\.dataset\.tooltip = text;/);
const i18n = script.slice(script.indexOf('\tconst I18N_LANGUAGES'), script.indexOf('\tapplyStaticI18n();')); const i18n = script.slice(script.indexOf('\tconst I18N_LANGUAGES'), script.indexOf('\tapplyStaticI18n();'));
const markers = script.slice(script.indexOf('\tfunction renderKeyframeMarkers('), script.indexOf('\tfunction seekTo(')); const markers = script.slice(script.indexOf('\tfunction renderKeyframeMarkers('), script.indexOf('\tfunction seekTo('));
const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8')); const manifest = JSON.parse(fs.readFileSync(path.join(root, 'manifest.json'), 'utf8'));
for (const lang of manifest.languages) { for (const lang of manifest.languages) {
const table = JSON.parse(fs.readFileSync(path.join(root, '_locales', lang + '.json'), 'utf8')); const table = JSON.parse(fs.readFileSync(path.join(root, '_locales', lang + '.json'), 'utf8'));
assert.equal(table.viewer.thumbnailTitle, table.viewer.groupThumbnailTitle, `${root}/${lang}: thumbnail button hints must match`);
if (lang === 'zh_CN') assert.equal(table.viewer.thumbnailTitle, '左键点击生成静态缩略图,右键点击生成动态缩略图');
for (const match of html.matchAll(/(?:\bt\(['"]|data-i18n(?:-title|-aria-label)?=")([^'"]+)/g)) { for (const match of html.matchAll(/(?:\bt\(['"]|data-i18n(?:-title|-aria-label)?=")([^'"]+)/g)) {
assert.equal(typeof match[1].split('.').reduce((obj, key) => obj && obj[key], table), 'string', `${root}/${lang}: ${match[1]}`); assert.equal(typeof match[1].split('.').reduce((obj, key) => obj && obj[key], table), 'string', `${root}/${lang}: ${match[1]}`);
} }
@@ -40,4 +55,4 @@ for (const root of roots) {
assert.equal(children[0].title, table.viewer.staticPoseTitle); assert.equal(children[0].title, table.viewer.staticPoseTitle);
} }
} }
console.log('PASS: source and both releases; eight languages; animated/static keyframe labels and translation keys'); console.log('PASS: source and universal release; eight languages; animated/static keyframe labels and translation keys');
+19
View File
@@ -0,0 +1,19 @@
'use strict';
const assert = require('node:assert/strict');
const { findAlphaBounds, toWorldBounds } = require('../js/spine-visible-bounds');
const pixels = new Uint8Array(10 * 8 * 4);
for (let y = 2; y < 6; y++) {
for (let x = 3; x < 8; x++) pixels[(y * 10 + x) * 4 + 3] = 255;
}
assert.deepEqual(findAlphaBounds(pixels, 10, 8), { left: 3, top: 2, right: 8, bottom: 6 });
assert.deepEqual(toWorldBounds({ left: 3, top: 2, right: 8, bottom: 6 }, 10, 8, 2, 20, 30), {
x: 19,
y: 29,
width: 2.5,
height: 2,
});
assert.equal(findAlphaBounds(new Uint8Array(10 * 8 * 4), 10, 8), null);
console.log('PASS: visible alpha pixels map back to the correct Spine world bounds');
-585
View File
@@ -1,585 +0,0 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const discoveryJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v14';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
}
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
function assetSignature(assets) {
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
let atlasText = '';
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
const pages = utils.parseAtlasPages(atlasText);
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
const stats = [];
for (const file of paths) {
try {
const stat = fs.statSync(file);
if (!stat.isFile() || stat.size === 0) return null;
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
} catch (e) {
return null;
}
}
return stats.join('|');
}
/**
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
*/
async function runDiscovery(src, retries, delay) {
let assets = null;
let previousSignature = null;
let stableCount = 0;
for (let i = 0; i < retries; i++) {
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
// 分批拖入的跨批次补齐统一交给后台任务处理。
assets = utils.discoverAssets(src, {
strictStem: true,
allowCrossBatch: false,
refreshIndex: i > 0,
});
const signature = assetSignature(assets);
if (signature && signature === previousSignature) stableCount += 1;
else stableCount = signature ? 1 : 0;
previousSignature = signature;
if (stableCount >= 3) return assets;
if (i < retries - 1) await sleep(delay);
}
return assets;
}
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
async function discoverWithRetry(src, retries = 5, delay = 120) {
const key = coordinationKey(src);
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
const job = runDiscovery(src, retries, delay);
discoveryJobs.set(key, job);
try {
return await job;
} finally {
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
}
}
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
async function classifyJsonBriefly(src) {
let kind = 'pending';
for (let i = 0; i < 3; i++) {
kind = utils.classifySkeletonJson(src);
if (kind !== 'pending') return kind;
if (i < 2) await sleep(80);
}
return kind;
}
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
async function renderThumbnailShared(assets, options) {
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
if (renderJobs.has(key)) {
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
return renderJobs.get(key);
}
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
const job = render.renderThumbnail(options);
renderJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
}
}
/**
* Eagle 格式扩展缩略图处理函数。
* 参数:{ src, dest, item }
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
* - dest : 需要生成的缩略图 PNG 路径
* - item : 当前 item 对象(可更新宽高)
*/
module.exports = async (options) => {
const { src, dest, item } = options;
const changed = require('../js/eagle-compound.js').normalizeItem(src, item);
// Eagle originally chooses the cache name before invoking the plugin.
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
fs.writeFileSync(canonical, await canvas.encode('webp', 90));
} else fs.copyFileSync(dest, canonical);
}
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
}
const ext = utils.extOf(path.basename(src));
if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src);
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
try {
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
} catch (e) { /* ignore */ }
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
if (item && item.isDeleted) {
log(' skip: item.isDeleted=true');
return resolve(item);
}
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
const srcExt = utils.extOf(path.basename(src));
// Eagle dispatches compound suffixes as txt/bytes. Only the complete
// .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit.
if (!srcExt) {
if (/\.txt$/i.test(src)) {
// Eagle's native text card requires item.text, even for an empty file.
item.text = '';
const fd = fs.openSync(src, 'r');
try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); }
finally { fs.closeSync(fd); }
}
await writeNeutralJsonPlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
await copyImageAsThumb(src, dest);
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
return resolve(item);
}
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
// 普通 JSON 不写 dest,不修改尺寸,也不启动资源扫描和后台补刷。
// 这样可以保留 Eagle 或其他 JSON 插件生成的缩略图,避免本插件的回退图覆盖它。
if (srcExt === '.json') {
const jsonKind = await classifyJsonBriefly(src);
if (jsonKind === 'other') {
log('普通 JSON,放行给 Eagle/其他 JSON 插件: ' + src);
return resolve(item);
}
}
let assets = await discoverWithRetry(src);
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
let version = utils.detectVersion(assets.skeletonPath);
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
let png = null;
let lastRenderError = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
png = await renderThumbnailShared(assets, {
skeletonPath: assets.skeletonPath,
atlasPath: assets.atlasPath,
texturePaths: assets.texturePaths,
version,
runtimeDir,
size: 400,
});
break;
} catch (e) {
lastRenderError = e;
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
if (attempt === 2) break;
await sleep(500);
assets = await discoverWithRetry(src, 4, 150);
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
version = utils.detectVersion(assets.skeletonPath);
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
}
}
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
throw lastRenderError || new Error('Spine 资源尚未写入完成');
}
fs.writeFileSync(dest, png);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
item.width = size.width || 400;
item.height = size.height || 400;
} catch (e) { /* ignore */ }
return Promise.resolve(item);
} catch (err) {
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
try {
await writePlaceholder(dest);
item.width = 400;
item.height = 400;
return resolve(item);
} catch (e2) {
// 最后兜底:写入一张最小 1x1 PNG
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
item.width = 400; item.height = 400;
return resolve(item);
}
}
};
function resolve(item) {
return item;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return false;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return true;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
return true;
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
return false;
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
try { require('../js/spine-reconcile-worker.js').enqueue(src); }
catch (error) { log('无法启动后台补刷: ' + String(error)); }
}
// Called by a separate Node worker; Eagle closes the thumbnail window when
// its callback resolves, so timers in that window cannot finish this work.
module.exports.reconcileOnce = async (src, previousSignature) => {
if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true };
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true });
const signature = assetSignature(assets);
if (!signature || signature !== previousSignature) return { signature, complete: false };
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) return { signature, complete: true };
const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png');
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(temp, png);
const complete = await refreshRelatedThumbnails(assets, temp);
return { signature, complete };
} catch (error) {
log('后台补刷重试: ' + String(error));
return { signature, complete: false };
} finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } }
};
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#202126';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#555963';
ctx.beginPath();
ctx.moveTo(112, 72);
ctx.lineTo(244, 72);
ctx.lineTo(288, 116);
ctx.lineTo(288, 328);
ctx.lineTo(112, 328);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#777c87';
ctx.beginPath();
ctx.moveTo(244, 72);
ctx.lineTo(244, 116);
ctx.lineTo(288, 116);
ctx.closePath();
ctx.fill();
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
const pixels = Buffer.alloc(size * size * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
}
fs.writeFileSync(dest, encodePng(size, size, pixels));
}
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
async function copyImageAsThumb(src, dest) {
const ext = path.extname(src).toLowerCase();
if (ext === '.png') {
fs.copyFileSync(src, dest);
return;
}
if (render.canvasLib) {
try {
const img = await render.canvasLib.loadImage(src);
const w = img.width, h = img.height;
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
fs.copyFileSync(src, dest);
}
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
async function writeAtlasFallback(assets, dest) {
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
const texturePath = Object.values(assets.texturePaths).find((file) => {
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
});
if (!texturePath) return false;
try {
const img = await render.canvasLib.loadImage(texturePath);
const size = 400;
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, size, size);
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
await render.drawSpineBadge(ctx, size, size);
if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath));
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return true;
} catch (e) {
log('atlas 贴图回退失败: ' + (e && e.message));
return false;
}
}
/** 读取 PNG 宽高(仅支持 PNG 头部) */
function imageSizeOf(file) {
const buf = fs.readFileSync(file);
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
return { width: 0, height: 0 };
}
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
async function writePlaceholder(dest) {
const w = 400, h = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, w, h);
await render.drawSpineBadge(ctx, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through to pure JS encoder */ }
}
// 深色底
const pixels = Buffer.alloc(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
}
}
// 简单的斜线纹理,让占位图不单调
for (let i = 0; i < w + h; i += 8) {
for (let k = 0; k < 3; k++) {
const x = Math.min(w - 1, i - k);
const y = Math.min(h - 1, k);
const idx = (y * w + x) * 4;
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
}
}
fs.writeFileSync(dest, encodePng(w, h, pixels));
}
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
function encodePng(width, height, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const ihdrChunk = chunk('IHDR', ihdr);
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter type 0
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
const idat = zlib.deflateSync(raw);
const idatChunk = chunk('IDAT', idat);
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function crc32(buf) {
let c, crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
c = (crc ^ buf[i]) & 0xFF;
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
crc = (crc >>> 8) ^ c;
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
+91 -2
View File
@@ -75,6 +75,81 @@
} }
button:hover { background: var(--border); } button:hover { background: var(--border); }
button.active { background: var(--accent); color: #fff; border-color: var(--accent); } button.active { background: var(--accent); color: #fff; border-color: var(--accent); }
button.primary { background: var(--accent); color: #fff; border-color: var(--accent); }
button:disabled { cursor: default; opacity: .55; }
#groupThumbBtn { position: relative; background: #2e7d32; color: #fff; border-color: #2e7d32; }
#groupThumbBtn:hover { background: #256628; }
#thumbBtn { position: relative; background: #1976d2; color: #fff; border-color: #1976d2; }
#thumbBtn:hover { background: #155fa8; }
#groupThumbBtn::after, #thumbBtn::after {
content: attr(data-tooltip); position: absolute; top: calc(100% + 8px); z-index: 30;
width: max-content; max-width: min(620px, calc(100vw - 24px)); padding: 6px 10px;
color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.4; white-space: normal;
background: var(--panel); border: 1px solid var(--border); border-radius: 4px;
box-shadow: 0 4px 14px rgba(0, 0, 0, .35); pointer-events: none;
opacity: 0; visibility: hidden;
}
#groupThumbBtn::after { left: 0; background: #256628; border-color: #256628; color: #fff; }
#thumbBtn::after { right: 0; background: #155fa8; border-color: #155fa8; color: #fff; }
#groupThumbBtn:hover::after, #thumbBtn:hover::after { opacity: 1; visibility: visible; }
.thumb-dialog {
position: absolute; inset: 0; z-index: 40; display: flex;
align-items: center; justify-content: center; padding: 24px;
background: rgba(0, 0, 0, .58);
}
.thumb-dialog.hidden { display: none; }
.thumb-dialog-card {
width: min(620px, 92vw); max-height: min(680px, 88vh); display: flex;
flex-direction: column; gap: 12px; padding: 18px;
background: var(--panel); border: 1px solid var(--border); border-radius: 10px;
box-shadow: 0 16px 48px rgba(0, 0, 0, .45);
}
.thumb-dialog-title { font-size: 16px; font-weight: 600; }
.thumb-dialog-summary, .thumb-dialog-note { color: var(--text-dim); line-height: 1.5; }
.thumb-dialog-list {
min-height: 60px; overflow: auto; border: 1px solid var(--border);
border-radius: 6px; background: var(--bg);
}
.thumb-dialog-row {
display: flex; align-items: center; gap: 10px; padding: 8px 10px;
border-bottom: 1px solid var(--border);
}
.thumb-dialog-row:last-child { border-bottom: 0; }
.thumb-dialog-check { width: 17px; height: 17px; margin: 0; flex: none; accent-color: var(--accent); }
.thumb-dialog-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.thumb-dialog-state { flex: none; color: var(--accent); font-size: 12px; }
.thumb-dialog-row.skipped .thumb-dialog-name,
.thumb-dialog-row.skipped .thumb-dialog-state { color: var(--text-dim); }
.thumb-dialog-actions { display: flex; justify-content: flex-end; gap: 8px; }
.thumb-dialog-input {
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px; padding: 7px 9px;
}
.thumb-dialog-input.hidden { display: none; }
.thumb-dialog-error { color: var(--error); min-height: 18px; }
.animated-thumb-card { width: min(480px, 92vw); gap: 10px; padding: 16px; }
.animated-thumb-card .thumb-dialog-title { text-align: center; }
.animated-thumb-warning { color: #fff; }
.animated-thumb-warning-title { display: block; }
.animated-thumb-danger { color: var(--error); }
.animated-thumb-card .thumb-dialog-note { font-size: 13px; }
.animated-thumb-form { display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 10px 14px; padding: 2px 0; }
.animated-thumb-label { color: var(--text-dim); }
.animated-thumb-range { display: flex; align-items: center; flex-wrap: wrap; gap: 8px; }
.animated-thumb-range label { display: flex; align-items: center; gap: 6px; }
.animated-thumb-range input { width: 88px; }
.animated-thumb-quality { grid-column: 1 / -1; display: grid; grid-template-columns: 96px minmax(0, 1fr); align-items: center; gap: 14px; color: var(--text-dim); }
.animated-thumb-size { display: flex; align-items: center; gap: 7px; color: var(--text); }
.animated-thumb-size input { width: 88px; }
.animated-thumb-form input[type="number"] {
box-sizing: border-box; height: 32px; padding: 4px 8px;
background: var(--panel-2); color: var(--text);
border: 1px solid var(--border); border-radius: 4px;
}
.animated-thumb-form input[type="number"]:focus { outline: none; border-color: var(--accent); }
.animated-thumb-card .thumb-dialog-actions { margin-top: 2px; }
/* 收窄下拉,避免在窄窗口下把右侧按钮挤到第二行(展开后的选项不受影响)*/
#toolbar #suffixSelect { max-width: 140px; }
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; } #stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
#animationPanel { #animationPanel {
position: absolute; position: absolute;
@@ -124,8 +199,22 @@
#viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; } #viewport { position: relative; width: 100%; min-width: 0; flex: 1; overflow: hidden; }
canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; } canvas { position: absolute; inset: 0; width: 100%; height: 100%; display: block; }
#status { #status {
position: absolute; bottom: calc(22px + var(--timeline-height)); left: 12px; color: var(--text-dim); position: absolute; left: 50%; bottom: calc(22px + var(--timeline-height));
font-size: 12px; pointer-events: none; text-shadow: 0 1px 2px rgba(0,0,0,.3); transform: translateX(-50%);
max-width: min(560px, calc(100% - 24px));
padding: 6px 12px;
color: var(--text); font-size: 12px; font-weight: 500; line-height: 1.45;
background: var(--panel-glass);
border: 1px solid var(--border); border-radius: 8px;
box-shadow: 0 4px 14px rgba(0, 0, 0, .28);
backdrop-filter: blur(8px);
pointer-events: none;
}
#status:empty { display: none; }
#status::before {
content: ''; display: inline-block; vertical-align: middle;
width: 7px; height: 7px; margin-right: 8px; border-radius: 50%;
background: var(--accent); box-shadow: 0 0 6px var(--accent);
} }
#timelineBar { #timelineBar {
position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 10; position: absolute; left: 12px; right: 12px; bottom: 12px; z-index: 10;
+968 -103
View File
File diff suppressed because it is too large. Load diff