缩略图生成修改
This commit is contained in:
1 parent
fe71d2d6ce
commit
05c4fc4b90
24 files changed
+673
-130
No files matched your search
@@ -63,7 +63,7 @@ SpineViewer/
|
||||
- 零时长动画按单帧静态姿势预览
|
||||
- 自动适配并居中
|
||||
- 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
|
||||
4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,可主动将当前帧设为**当前素材**的自定义缩略图;若已有自定义缩略图,替换前会明确确认。手动缩略图会添加 Spine badge 和导出版本号。
|
||||
4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,仍可用原有按钮将当前帧设为**当前素材**的自定义缩略图。左侧的“为当前资源组生成缩略图…”会先列出已验证的骨骼、atlas 和贴图,明确确认后才批量处理,并始终跳过已有自定义缩略图的素材。手动缩略图会添加 Spine badge 和导出版本号。
|
||||
|
||||
---
|
||||
|
||||
@@ -77,7 +77,7 @@ SpineViewer/
|
||||
|
||||
Eagle 的公开素材 API 不提供任意自定义元数据字段。预乘设置保存在 Eagle 用户数据目录的 `SpineViewer/preview-settings/` 下,以素材库和素材 ID 区分;不修改 Eagle 的 `metadata.json`、标签或注释,也不会随素材导出到其他电脑。
|
||||
|
||||
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。导入阶段不由插件生成缩略图;手动操作只能设置当前素材。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
|
||||
Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。导入阶段不由插件生成缩略图;手动批量操作只作用于明确列出并得到确认的当前资源组,且跳过已有自定义缩略图的素材。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
|
||||
|
||||
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
|
||||
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
|
||||
"thumbnail": "Als aktuelles Vorschaubild setzen",
|
||||
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
|
||||
"groupThumbnail": "Vorschaubilder für aktuelle Ressourcengruppe erstellen…",
|
||||
"groupThumbnailTitle": "Aktuelle Spine-Ressourcengruppe prüfen und Vorschaubilder gesammelt erstellen",
|
||||
"groupDialogTitle": "Vorschaubilder für aktuelle Ressourcengruppe erstellen",
|
||||
"groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.",
|
||||
"groupDialogNote": "Nur die unten aufgeführten Elemente werden aktualisiert. Elemente mit eigenen Vorschaubildern werden immer übersprungen.",
|
||||
"cancel": "Abbrechen",
|
||||
"groupConfirm": "Erstellen",
|
||||
"groupWillGenerate": "Wird erstellt",
|
||||
"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}…",
|
||||
"groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.",
|
||||
"versionTitle": "Spine-Version",
|
||||
"reloadTitle": "Ordner neu laden",
|
||||
"animationList": "Animationsliste",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
|
||||
"thumbnail": "Set current item thumbnail",
|
||||
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
|
||||
"groupThumbnail": "Generate thumbnails for current asset group…",
|
||||
"groupThumbnailTitle": "Review the current Spine asset group, then generate thumbnails in a batch",
|
||||
"groupDialogTitle": "Generate thumbnails for current asset group",
|
||||
"groupDialogSummary": "Generate {generate} item(s) and skip {skip} item(s).",
|
||||
"groupDialogNote": "Only the items explicitly listed below will be updated. Items with existing custom thumbnails are always skipped.",
|
||||
"cancel": "Cancel",
|
||||
"groupConfirm": "Generate",
|
||||
"groupWillGenerate": "Will generate",
|
||||
"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}…",
|
||||
"groupComplete": "Asset-group thumbnails complete: {success} succeeded, {skip} skipped, {failed} failed.",
|
||||
"versionTitle": "Spine version",
|
||||
"reloadTitle": "Reload folder",
|
||||
"animationList": "Animation list",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
|
||||
"thumbnail": "Usar como miniatura actual",
|
||||
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
|
||||
"groupThumbnail": "Generar miniaturas del grupo actual…",
|
||||
"groupThumbnailTitle": "Revisar el grupo de recursos Spine actual y generar miniaturas por lotes",
|
||||
"groupDialogTitle": "Generar miniaturas del grupo actual",
|
||||
"groupDialogSummary": "Se generarán {generate} y se omitirán {skip}.",
|
||||
"groupDialogNote": "Solo se actualizarán los elementos indicados abajo. Los elementos con miniaturas personalizadas siempre se omiten.",
|
||||
"cancel": "Cancelar",
|
||||
"groupConfirm": "Generar",
|
||||
"groupWillGenerate": "Se generará",
|
||||
"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}…",
|
||||
"groupComplete": "Completado: {success} correctas, {skip} omitidas y {failed} fallidas.",
|
||||
"versionTitle": "Versión de Spine",
|
||||
"reloadTitle": "Recargar carpeta",
|
||||
"animationList": "Lista de animaciones",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"groupThumbnail": "現在のアセットグループのサムネイルを生成…",
|
||||
"groupThumbnailTitle": "現在の Spine アセットグループを確認して一括生成",
|
||||
"groupDialogTitle": "現在のアセットグループのサムネイルを生成",
|
||||
"groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。",
|
||||
"groupDialogNote": "下に明示された項目だけを更新します。カスタムサムネイルがある項目は必ずスキップします。",
|
||||
"cancel": "キャンセル",
|
||||
"groupConfirm": "生成",
|
||||
"groupWillGenerate": "生成対象",
|
||||
"groupSkipCustom": "スキップ:カスタムサムネイルあり",
|
||||
"groupUnavailable": "スキップ:検証できません",
|
||||
"groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。",
|
||||
"groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…",
|
||||
"groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。",
|
||||
"versionTitle": "Spine バージョン",
|
||||
"reloadTitle": "フォルダーを再読み込み",
|
||||
"animationList": "アニメーション一覧",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"groupThumbnail": "현재 자산 그룹 썸네일 생성…",
|
||||
"groupThumbnailTitle": "현재 Spine 자산 그룹을 확인한 후 일괄 생성",
|
||||
"groupDialogTitle": "현재 자산 그룹 썸네일 생성",
|
||||
"groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.",
|
||||
"groupDialogNote": "아래에 명시된 항목만 업데이트합니다. 기존 사용자 지정 썸네일이 있는 항목은 항상 건너뜁니다.",
|
||||
"cancel": "취소",
|
||||
"groupConfirm": "생성",
|
||||
"groupWillGenerate": "생성 예정",
|
||||
"groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음",
|
||||
"groupUnavailable": "건너뛰: 확인할 수 없음",
|
||||
"groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.",
|
||||
"groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…",
|
||||
"groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.",
|
||||
"versionTitle": "Spine 버전",
|
||||
"reloadTitle": "폴더 다시 불러오기",
|
||||
"animationList": "애니메이션 목록",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"groupThumbnail": "Создать миниатюры для текущей группы…",
|
||||
"groupThumbnailTitle": "Проверить группу Spine и создать миниатюры пакетно",
|
||||
"groupDialogTitle": "Создать миниатюры для текущей группы",
|
||||
"groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.",
|
||||
"groupDialogNote": "Будут обновлены только явно указанные ниже элементы. Элементы с пользовательскими миниатюрами всегда пропускаются.",
|
||||
"cancel": "Отмена",
|
||||
"groupConfirm": "Создать",
|
||||
"groupWillGenerate": "Будет создано",
|
||||
"groupSkipCustom": "Пропущено: есть своя миниатюра",
|
||||
"groupUnavailable": "Пропущено: не удалось проверить",
|
||||
"groupNoTargets": "В текущей группе нет подходящих миниатюр.",
|
||||
"groupGenerating": "Создание миниатюр {current}/{total}…",
|
||||
"groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.",
|
||||
"versionTitle": "Версия Spine",
|
||||
"reloadTitle": "Перезагрузить папку",
|
||||
"animationList": "Список анимаций",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"groupThumbnail": "为当前资源组生成缩略图…",
|
||||
"groupThumbnailTitle": "列出当前 Spine 资源组,确认后批量生成缩略图",
|
||||
"groupDialogTitle": "为当前资源组生成缩略图",
|
||||
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
|
||||
"groupDialogNote": "只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。",
|
||||
"cancel": "取消",
|
||||
"groupConfirm": "确认生成",
|
||||
"groupWillGenerate": "将生成",
|
||||
"groupSkipCustom": "跳过:已有自定义缩略图",
|
||||
"groupUnavailable": "跳过:无法验证",
|
||||
"groupNoTargets": "当前资源组没有可生成的缩略图。",
|
||||
"groupGenerating": "正在生成资源组缩略图 {current}/{total}…",
|
||||
"groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。",
|
||||
"versionTitle": "Spine 版本",
|
||||
"reloadTitle": "重新加载目录",
|
||||
"animationList": "动画列表",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"groupThumbnail": "為目前資源組產生縮圖…",
|
||||
"groupThumbnailTitle": "列出目前 Spine 資源組,確認後批次產生縮圖",
|
||||
"groupDialogTitle": "為目前資源組產生縮圖",
|
||||
"groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。",
|
||||
"groupDialogNote": "只會更新下列明確列出的素材;已有自訂縮圖的素材會自動跳過。",
|
||||
"cancel": "取消",
|
||||
"groupConfirm": "確認產生",
|
||||
"groupWillGenerate": "將產生",
|
||||
"groupSkipCustom": "跳過:已有自訂縮圖",
|
||||
"groupUnavailable": "跳過:無法驗證",
|
||||
"groupNoTargets": "目前資源組沒有可產生的縮圖。",
|
||||
"groupGenerating": "正在產生資源組縮圖 {current}/{total}…",
|
||||
"groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。",
|
||||
"versionTitle": "Spine 版本",
|
||||
"reloadTitle": "重新載入目錄",
|
||||
"animationList": "動畫清單",
|
||||
|
||||
Binary file not shown.
@@ -7,13 +7,13 @@ Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4–4.3,适用
|
||||
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内。
|
||||
2. 打开骨骼或图集文件即可预览动画,并可切换动画、皮肤、速度、循环、背景和 PMA。
|
||||
3. 插件不接管导入阶段的缩略图,由 Eagle 自身处理。
|
||||
4. 打开预览后,可点击“设为当前素材缩略图”,把当前帧手动设为当前素材的自定义缩略图。已有自定义缩略图时会先询问是否替换。
|
||||
4. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为当前资源组生成缩略图…”会先明确列出受影响的骨骼、atlas 和贴图,得到确认后才批量生成,并始终跳过已有自定义缩略图的素材。
|
||||
|
||||
## 安全与隐私
|
||||
|
||||
- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。
|
||||
- 跨素材配对只使用当前 Eagle 素材库中已枚举并验证的文件。
|
||||
- 手动设置缩略图只作用于当前素材,不会自动改写其他素材。
|
||||
- 单素材按钮只作用于当前素材。资源组按钮会先列出全部受影响素材并要求确认,且不会覆盖已有自定义缩略图。
|
||||
|
||||
## 多语言
|
||||
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
|
||||
"thumbnail": "Als aktuelles Vorschaubild setzen",
|
||||
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
|
||||
"groupThumbnail": "Vorschaubilder für aktuelle Ressourcengruppe erstellen…",
|
||||
"groupThumbnailTitle": "Aktuelle Spine-Ressourcengruppe prüfen und Vorschaubilder gesammelt erstellen",
|
||||
"groupDialogTitle": "Vorschaubilder für aktuelle Ressourcengruppe erstellen",
|
||||
"groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.",
|
||||
"groupDialogNote": "Nur die unten aufgeführten Elemente werden aktualisiert. Elemente mit eigenen Vorschaubildern werden immer übersprungen.",
|
||||
"cancel": "Abbrechen",
|
||||
"groupConfirm": "Erstellen",
|
||||
"groupWillGenerate": "Wird erstellt",
|
||||
"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}…",
|
||||
"groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.",
|
||||
"versionTitle": "Spine-Version",
|
||||
"reloadTitle": "Ordner neu laden",
|
||||
"animationList": "Animationsliste",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
|
||||
"thumbnail": "Set current item thumbnail",
|
||||
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
|
||||
"groupThumbnail": "Generate thumbnails for current asset group…",
|
||||
"groupThumbnailTitle": "Review the current Spine asset group, then generate thumbnails in a batch",
|
||||
"groupDialogTitle": "Generate thumbnails for current asset group",
|
||||
"groupDialogSummary": "Generate {generate} item(s) and skip {skip} item(s).",
|
||||
"groupDialogNote": "Only the items explicitly listed below will be updated. Items with existing custom thumbnails are always skipped.",
|
||||
"cancel": "Cancel",
|
||||
"groupConfirm": "Generate",
|
||||
"groupWillGenerate": "Will generate",
|
||||
"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}…",
|
||||
"groupComplete": "Asset-group thumbnails complete: {success} succeeded, {skip} skipped, {failed} failed.",
|
||||
"versionTitle": "Spine version",
|
||||
"reloadTitle": "Reload folder",
|
||||
"animationList": "Animation list",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
|
||||
"thumbnail": "Usar como miniatura actual",
|
||||
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
|
||||
"groupThumbnail": "Generar miniaturas del grupo actual…",
|
||||
"groupThumbnailTitle": "Revisar el grupo de recursos Spine actual y generar miniaturas por lotes",
|
||||
"groupDialogTitle": "Generar miniaturas del grupo actual",
|
||||
"groupDialogSummary": "Se generarán {generate} y se omitirán {skip}.",
|
||||
"groupDialogNote": "Solo se actualizarán los elementos indicados abajo. Los elementos con miniaturas personalizadas siempre se omiten.",
|
||||
"cancel": "Cancelar",
|
||||
"groupConfirm": "Generar",
|
||||
"groupWillGenerate": "Se generará",
|
||||
"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}…",
|
||||
"groupComplete": "Completado: {success} correctas, {skip} omitidas y {failed} fallidas.",
|
||||
"versionTitle": "Versión de Spine",
|
||||
"reloadTitle": "Recargar carpeta",
|
||||
"animationList": "Lista de animaciones",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"groupThumbnail": "現在のアセットグループのサムネイルを生成…",
|
||||
"groupThumbnailTitle": "現在の Spine アセットグループを確認して一括生成",
|
||||
"groupDialogTitle": "現在のアセットグループのサムネイルを生成",
|
||||
"groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。",
|
||||
"groupDialogNote": "下に明示された項目だけを更新します。カスタムサムネイルがある項目は必ずスキップします。",
|
||||
"cancel": "キャンセル",
|
||||
"groupConfirm": "生成",
|
||||
"groupWillGenerate": "生成対象",
|
||||
"groupSkipCustom": "スキップ:カスタムサムネイルあり",
|
||||
"groupUnavailable": "スキップ:検証できません",
|
||||
"groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。",
|
||||
"groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…",
|
||||
"groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。",
|
||||
"versionTitle": "Spine バージョン",
|
||||
"reloadTitle": "フォルダーを再読み込み",
|
||||
"animationList": "アニメーション一覧",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"groupThumbnail": "현재 자산 그룹 썸네일 생성…",
|
||||
"groupThumbnailTitle": "현재 Spine 자산 그룹을 확인한 후 일괄 생성",
|
||||
"groupDialogTitle": "현재 자산 그룹 썸네일 생성",
|
||||
"groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.",
|
||||
"groupDialogNote": "아래에 명시된 항목만 업데이트합니다. 기존 사용자 지정 썸네일이 있는 항목은 항상 건너뜁니다.",
|
||||
"cancel": "취소",
|
||||
"groupConfirm": "생성",
|
||||
"groupWillGenerate": "생성 예정",
|
||||
"groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음",
|
||||
"groupUnavailable": "건너뛰: 확인할 수 없음",
|
||||
"groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.",
|
||||
"groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…",
|
||||
"groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.",
|
||||
"versionTitle": "Spine 버전",
|
||||
"reloadTitle": "폴더 다시 불러오기",
|
||||
"animationList": "애니메이션 목록",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"groupThumbnail": "Создать миниатюры для текущей группы…",
|
||||
"groupThumbnailTitle": "Проверить группу Spine и создать миниатюры пакетно",
|
||||
"groupDialogTitle": "Создать миниатюры для текущей группы",
|
||||
"groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.",
|
||||
"groupDialogNote": "Будут обновлены только явно указанные ниже элементы. Элементы с пользовательскими миниатюрами всегда пропускаются.",
|
||||
"cancel": "Отмена",
|
||||
"groupConfirm": "Создать",
|
||||
"groupWillGenerate": "Будет создано",
|
||||
"groupSkipCustom": "Пропущено: есть своя миниатюра",
|
||||
"groupUnavailable": "Пропущено: не удалось проверить",
|
||||
"groupNoTargets": "В текущей группе нет подходящих миниатюр.",
|
||||
"groupGenerating": "Создание миниатюр {current}/{total}…",
|
||||
"groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.",
|
||||
"versionTitle": "Версия Spine",
|
||||
"reloadTitle": "Перезагрузить папку",
|
||||
"animationList": "Список анимаций",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"groupThumbnail": "为当前资源组生成缩略图…",
|
||||
"groupThumbnailTitle": "列出当前 Spine 资源组,确认后批量生成缩略图",
|
||||
"groupDialogTitle": "为当前资源组生成缩略图",
|
||||
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
|
||||
"groupDialogNote": "只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。",
|
||||
"cancel": "取消",
|
||||
"groupConfirm": "确认生成",
|
||||
"groupWillGenerate": "将生成",
|
||||
"groupSkipCustom": "跳过:已有自定义缩略图",
|
||||
"groupUnavailable": "跳过:无法验证",
|
||||
"groupNoTargets": "当前资源组没有可生成的缩略图。",
|
||||
"groupGenerating": "正在生成资源组缩略图 {current}/{total}…",
|
||||
"groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。",
|
||||
"versionTitle": "Spine 版本",
|
||||
"reloadTitle": "重新加载目录",
|
||||
"animationList": "动画列表",
|
||||
|
||||
@@ -20,6 +20,19 @@
|
||||
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"groupThumbnail": "為目前資源組產生縮圖…",
|
||||
"groupThumbnailTitle": "列出目前 Spine 資源組,確認後批次產生縮圖",
|
||||
"groupDialogTitle": "為目前資源組產生縮圖",
|
||||
"groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。",
|
||||
"groupDialogNote": "只會更新下列明確列出的素材;已有自訂縮圖的素材會自動跳過。",
|
||||
"cancel": "取消",
|
||||
"groupConfirm": "確認產生",
|
||||
"groupWillGenerate": "將產生",
|
||||
"groupSkipCustom": "跳過:已有自訂縮圖",
|
||||
"groupUnavailable": "跳過:無法驗證",
|
||||
"groupNoTargets": "目前資源組沒有可產生的縮圖。",
|
||||
"groupGenerating": "正在產生資源組縮圖 {current}/{total}…",
|
||||
"groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。",
|
||||
"versionTitle": "Spine 版本",
|
||||
"reloadTitle": "重新載入目錄",
|
||||
"animationList": "動畫清單",
|
||||
|
||||
@@ -75,6 +75,36 @@
|
||||
}
|
||||
button:hover { background: var(--border); }
|
||||
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; }
|
||||
.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-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; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
#animationPanel {
|
||||
position: absolute;
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
|
||||
</label>
|
||||
<div class="spacer"></div>
|
||||
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为当前资源组生成缩略图">为当前资源组生成缩略图…</button>
|
||||
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
|
||||
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
|
||||
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
|
||||
@@ -58,6 +59,18 @@
|
||||
</div>
|
||||
<span id="timeLabel" data-i18n="viewer.timePlaceholder">0.00 / 0.00 秒 · 0 / 0 帧</span>
|
||||
</div>
|
||||
<div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle">
|
||||
<div class="thumb-dialog-card">
|
||||
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为当前资源组生成缩略图</div>
|
||||
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
|
||||
<div id="thumbDialogList" class="thumb-dialog-list"></div>
|
||||
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。</div>
|
||||
<div class="thumb-dialog-actions">
|
||||
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -215,6 +228,7 @@
|
||||
|
||||
// ---------- 全局状态 ----------
|
||||
let skeleton = null, state = null, stateData = null, skeletonData = null;
|
||||
let activeAssets = null;
|
||||
let renderer = null;
|
||||
let activeRuntimeDir = null;
|
||||
let activeVersion = null;
|
||||
@@ -482,19 +496,26 @@
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function getCurrentEagleItem() {
|
||||
const pathItemId = eagleItemIdFromPath(filePath);
|
||||
const urlItemId = urlParams.get('id');
|
||||
if (!pathItemId || (urlItemId && urlItemId !== pathItemId)) throw new Error(t('viewer.itemMismatch'));
|
||||
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(pathItemId));
|
||||
async function getEagleItemForPath(sourcePath) {
|
||||
const itemId = eagleItemIdFromPath(sourcePath);
|
||||
if (!itemId || !/^[A-Za-z0-9_-]+$/.test(itemId)) throw new Error(t('viewer.itemMismatch'));
|
||||
const currentLibrary = utils.findLibraryRoot(filePath);
|
||||
const itemLibrary = utils.findLibraryRoot(sourcePath);
|
||||
if (!currentLibrary || !itemLibrary || path.resolve(currentLibrary) !== path.resolve(itemLibrary)) {
|
||||
throw new Error(t('viewer.itemMismatch'));
|
||||
}
|
||||
if (sourcePath === filePath) {
|
||||
const urlItemId = urlParams.get('id');
|
||||
if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch'));
|
||||
}
|
||||
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
|
||||
let result = null;
|
||||
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
|
||||
if (!response.ok || !result || result.status !== 'success' || !result.data) {
|
||||
throw new Error(t('viewer.apiHttp', { status: response.status }));
|
||||
}
|
||||
if (result.data.name + '.' + result.data.ext !== path.basename(filePath)) throw new Error(t('viewer.itemMismatch'));
|
||||
if (result.data.ext === 'txt' && /\.atlas\.txt$/i.test(filePath)) throw new Error(t('viewer.txtRegistered'));
|
||||
return { id: pathItemId, data: result.data };
|
||||
if (result.data.name + '.' + result.data.ext !== path.basename(sourcePath)) throw new Error(t('viewer.itemMismatch'));
|
||||
return { id: itemId, data: result.data, sourcePath };
|
||||
}
|
||||
|
||||
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
|
||||
@@ -512,71 +533,183 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
function currentResourcePaths() {
|
||||
if (!activeAssets) return [];
|
||||
return [...new Set([
|
||||
activeAssets.skeletonPath,
|
||||
activeAssets.atlasPath,
|
||||
...Object.values(activeAssets.texturePaths || {}),
|
||||
].filter(Boolean).map((file) => path.resolve(file)))];
|
||||
}
|
||||
|
||||
async function collectGroupThumbnailItems() {
|
||||
const rows = [];
|
||||
for (const sourcePath of currentResourcePaths()) {
|
||||
try {
|
||||
const item = await getEagleItemForPath(sourcePath);
|
||||
rows.push({
|
||||
...item,
|
||||
name: path.basename(sourcePath),
|
||||
state: item.data.customThumbnail === true ? 'custom' : 'ready',
|
||||
});
|
||||
} catch (error) {
|
||||
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function confirmGroupThumbnail(rows) {
|
||||
const dialog = document.getElementById('thumbDialog');
|
||||
const list = document.getElementById('thumbDialogList');
|
||||
const confirm = document.getElementById('thumbDialogConfirm');
|
||||
const cancel = document.getElementById('thumbDialogCancel');
|
||||
const ready = rows.filter((row) => row.state === 'ready');
|
||||
const skipped = rows.length - ready.length;
|
||||
document.getElementById('thumbDialogSummary').textContent = t('viewer.groupDialogSummary', {
|
||||
generate: ready.length,
|
||||
skip: skipped,
|
||||
});
|
||||
list.textContent = '';
|
||||
for (const row of rows) {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'thumb-dialog-row' + (row.state === 'ready' ? '' : ' skipped');
|
||||
const name = document.createElement('span');
|
||||
name.className = 'thumb-dialog-name';
|
||||
name.textContent = row.name;
|
||||
name.title = row.sourcePath;
|
||||
const state = document.createElement('span');
|
||||
state.className = 'thumb-dialog-state';
|
||||
state.textContent = t(row.state === 'ready' ? 'viewer.groupWillGenerate'
|
||||
: row.state === 'custom' ? 'viewer.groupSkipCustom' : 'viewer.groupUnavailable');
|
||||
entry.append(name, state);
|
||||
list.appendChild(entry);
|
||||
}
|
||||
confirm.disabled = ready.length === 0;
|
||||
dialog.classList.remove('hidden');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (accepted) => {
|
||||
dialog.classList.add('hidden');
|
||||
confirm.removeEventListener('click', accept);
|
||||
cancel.removeEventListener('click', reject);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(accepted ? ready : []);
|
||||
};
|
||||
const accept = () => finish(true);
|
||||
const reject = () => finish(false);
|
||||
const keydown = (event) => { if (event.key === 'Escape') reject(); };
|
||||
confirm.addEventListener('click', accept);
|
||||
cancel.addEventListener('click', reject);
|
||||
document.addEventListener('keydown', keydown);
|
||||
cancel.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCurrentFrameThumbnail() {
|
||||
playing = false;
|
||||
updatePlayButton();
|
||||
if (state) state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-cx, -cy);
|
||||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||||
let offRenderer = null;
|
||||
try {
|
||||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
offRenderer.draw(skeleton);
|
||||
} finally {
|
||||
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
|
||||
octx.restore();
|
||||
}
|
||||
}
|
||||
await drawSpineBadge(octx, size, size);
|
||||
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
|
||||
let out = off.toDataURL('image/webp', 0.9);
|
||||
const isWebp = out.startsWith('data:image/webp');
|
||||
if (!isWebp) out = off.toDataURL('image/png');
|
||||
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
|
||||
}
|
||||
|
||||
async function generateGroupThumbnails() {
|
||||
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
const groupThumbBtn = document.getElementById('groupThumbBtn');
|
||||
groupThumbBtn.disabled = true;
|
||||
let tempPath = null;
|
||||
try {
|
||||
const rows = await collectGroupThumbnailItems();
|
||||
const targets = await confirmGroupThumbnail(rows);
|
||||
if (targets.length === 0) {
|
||||
if (rows.length && rows.every((row) => row.state !== 'ready')) setStatus(t('viewer.groupNoTargets'), 4000);
|
||||
return;
|
||||
}
|
||||
setStatus(t('viewer.generating'), Infinity);
|
||||
const rendered = await renderCurrentFrameThumbnail();
|
||||
tempPath = path.join(os.tmpdir(), 'spineviewer-group-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
let success = 0, skipped = rows.filter((row) => row.state !== 'ready').length, failed = 0;
|
||||
for (let index = 0; index < targets.length; index++) {
|
||||
setStatus(t('viewer.groupGenerating', { current: index + 1, total: targets.length }), Infinity);
|
||||
try {
|
||||
// 确认后再读一次,避免在对话框打开期间覆盖用户新设的缩略图。
|
||||
const fresh = await getEagleItemForPath(targets[index].sourcePath);
|
||||
if (fresh.data.customThumbnail === true) { skipped++; continue; }
|
||||
await setEagleCustomThumbnail(fresh.id, tempPath);
|
||||
success++;
|
||||
log('用户确认批量更新资源组缩略图: item=' + fresh.id);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
log('资源组缩略图失败: ' + ((error && error.stack) || error));
|
||||
}
|
||||
}
|
||||
setStatus(t('viewer.groupComplete', { success, skip: skipped, failed }), 6000);
|
||||
} catch (e) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
|
||||
log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e));
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
groupThumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail() {
|
||||
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
const thumbBtn = document.getElementById('thumbBtn');
|
||||
thumbBtn.disabled = true;
|
||||
let tempPath = null;
|
||||
try {
|
||||
const currentItem = await getCurrentEagleItem();
|
||||
const currentItem = await getEagleItemForPath(filePath);
|
||||
if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return;
|
||||
setStatus(t('viewer.generating'), Infinity);
|
||||
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
|
||||
playing = false;
|
||||
updatePlayButton();
|
||||
if (state) state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-cx, -cy);
|
||||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||||
let offRenderer = null;
|
||||
try {
|
||||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
offRenderer.draw(skeleton);
|
||||
} finally {
|
||||
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
|
||||
octx.restore();
|
||||
}
|
||||
}
|
||||
await drawSpineBadge(octx, size, size);
|
||||
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
|
||||
|
||||
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
|
||||
let out = off.toDataURL('image/webp', 0.9);
|
||||
const isWebp = out.startsWith('data:image/webp');
|
||||
if (!isWebp) out = off.toDataURL('image/png');
|
||||
const buf = Buffer.from(out.split(',')[1], 'base64');
|
||||
const tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + (isWebp ? '.webp' : '.png'));
|
||||
try {
|
||||
fs.writeFileSync(tempPath, buf);
|
||||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||||
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
|
||||
log('用户主动更新当前素材缩略图: item=' + currentItem.id + ' (' + buf.length + 'B)');
|
||||
} catch (e) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
|
||||
} finally {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
}
|
||||
const rendered = await renderCurrentFrameThumbnail();
|
||||
tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||||
setStatus(t('viewer.thumbUpdated', {
|
||||
format: rendered.extension === '.webp' ? 'WebP' : 'PNG',
|
||||
size: (rendered.buffer.length / 1024).toFixed(1),
|
||||
}), 3500);
|
||||
log('用户主动更新当前素材缩略图: item=' + currentItem.id);
|
||||
} catch (e) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
|
||||
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
|
||||
log('生成当前素材缩略图失败: ' + ((e && e.stack) || e));
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
thumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -925,6 +1058,7 @@
|
||||
|
||||
// ---------- 加载资源并与运行时交互 ----------
|
||||
async function loadAssets() {
|
||||
activeAssets = null;
|
||||
if (!utils.extOf(path.basename(filePath))) {
|
||||
// Registering txt/bytes is required by Eagle's extension dispatch.
|
||||
// Ordinary text remains readable and never starts Spine discovery.
|
||||
@@ -970,6 +1104,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeAssets = assets;
|
||||
let savedPma;
|
||||
try {
|
||||
const settings = require('../js/spine-preview-settings.js');
|
||||
@@ -1131,6 +1266,7 @@
|
||||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
|
||||
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||||
function seekTimelineToNearestFrame() {
|
||||
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
|
||||
|
||||
@@ -71,6 +71,9 @@ try {
|
||||
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, /fresh\.data\.customThumbnail === true\) \{ skipped\+\+; continue; \}[\s\S]*setEagleCustomThumbnail\(fresh\.id/);
|
||||
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');
|
||||
|
||||
@@ -75,6 +75,36 @@
|
||||
}
|
||||
button:hover { background: var(--border); }
|
||||
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; }
|
||||
.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-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; }
|
||||
#stage { position: absolute; inset: 0; display: flex; overflow: hidden; }
|
||||
#animationPanel {
|
||||
position: absolute;
|
||||
|
||||
+199
-63
@@ -25,6 +25,7 @@
|
||||
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
|
||||
</label>
|
||||
<div class="spacer"></div>
|
||||
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为当前资源组生成缩略图">为当前资源组生成缩略图…</button>
|
||||
<button id="thumbBtn" data-i18n="viewer.thumbnail" data-i18n-title="viewer.thumbnailTitle" title="将当前帧设为当前素材的缩略图">设为当前素材缩略图</button>
|
||||
<label id="verLabel" style="color: var(--text-dim);" data-i18n-title="viewer.versionTitle" title="Spine 版本"></label>
|
||||
<button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
|
||||
@@ -58,6 +59,18 @@
|
||||
</div>
|
||||
<span id="timeLabel" data-i18n="viewer.timePlaceholder">0.00 / 0.00 秒 · 0 / 0 帧</span>
|
||||
</div>
|
||||
<div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle">
|
||||
<div class="thumb-dialog-card">
|
||||
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为当前资源组生成缩略图</div>
|
||||
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
|
||||
<div id="thumbDialogList" class="thumb-dialog-list"></div>
|
||||
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。</div>
|
||||
<div class="thumb-dialog-actions">
|
||||
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
@@ -215,6 +228,7 @@
|
||||
|
||||
// ---------- 全局状态 ----------
|
||||
let skeleton = null, state = null, stateData = null, skeletonData = null;
|
||||
let activeAssets = null;
|
||||
let renderer = null;
|
||||
let activeRuntimeDir = null;
|
||||
let activeVersion = null;
|
||||
@@ -482,19 +496,26 @@
|
||||
return match ? match[1] : null;
|
||||
}
|
||||
|
||||
async function getCurrentEagleItem() {
|
||||
const pathItemId = eagleItemIdFromPath(filePath);
|
||||
const urlItemId = urlParams.get('id');
|
||||
if (!pathItemId || (urlItemId && urlItemId !== pathItemId)) throw new Error(t('viewer.itemMismatch'));
|
||||
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(pathItemId));
|
||||
async function getEagleItemForPath(sourcePath) {
|
||||
const itemId = eagleItemIdFromPath(sourcePath);
|
||||
if (!itemId || !/^[A-Za-z0-9_-]+$/.test(itemId)) throw new Error(t('viewer.itemMismatch'));
|
||||
const currentLibrary = utils.findLibraryRoot(filePath);
|
||||
const itemLibrary = utils.findLibraryRoot(sourcePath);
|
||||
if (!currentLibrary || !itemLibrary || path.resolve(currentLibrary) !== path.resolve(itemLibrary)) {
|
||||
throw new Error(t('viewer.itemMismatch'));
|
||||
}
|
||||
if (sourcePath === filePath) {
|
||||
const urlItemId = urlParams.get('id');
|
||||
if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch'));
|
||||
}
|
||||
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId));
|
||||
let result = null;
|
||||
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
|
||||
if (!response.ok || !result || result.status !== 'success' || !result.data) {
|
||||
throw new Error(t('viewer.apiHttp', { status: response.status }));
|
||||
}
|
||||
if (result.data.name + '.' + result.data.ext !== path.basename(filePath)) throw new Error(t('viewer.itemMismatch'));
|
||||
if (result.data.ext === 'txt' && /\.atlas\.txt$/i.test(filePath)) throw new Error(t('viewer.txtRegistered'));
|
||||
return { id: pathItemId, data: result.data };
|
||||
if (result.data.name + '.' + result.data.ext !== path.basename(sourcePath)) throw new Error(t('viewer.itemMismatch'));
|
||||
return { id: itemId, data: result.data, sourcePath };
|
||||
}
|
||||
|
||||
async function setEagleCustomThumbnail(itemId, thumbnailPath) {
|
||||
@@ -512,71 +533,183 @@
|
||||
return result;
|
||||
}
|
||||
|
||||
function currentResourcePaths() {
|
||||
if (!activeAssets) return [];
|
||||
return [...new Set([
|
||||
activeAssets.skeletonPath,
|
||||
activeAssets.atlasPath,
|
||||
...Object.values(activeAssets.texturePaths || {}),
|
||||
].filter(Boolean).map((file) => path.resolve(file)))];
|
||||
}
|
||||
|
||||
async function collectGroupThumbnailItems() {
|
||||
const rows = [];
|
||||
for (const sourcePath of currentResourcePaths()) {
|
||||
try {
|
||||
const item = await getEagleItemForPath(sourcePath);
|
||||
rows.push({
|
||||
...item,
|
||||
name: path.basename(sourcePath),
|
||||
state: item.data.customThumbnail === true ? 'custom' : 'ready',
|
||||
});
|
||||
} catch (error) {
|
||||
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function confirmGroupThumbnail(rows) {
|
||||
const dialog = document.getElementById('thumbDialog');
|
||||
const list = document.getElementById('thumbDialogList');
|
||||
const confirm = document.getElementById('thumbDialogConfirm');
|
||||
const cancel = document.getElementById('thumbDialogCancel');
|
||||
const ready = rows.filter((row) => row.state === 'ready');
|
||||
const skipped = rows.length - ready.length;
|
||||
document.getElementById('thumbDialogSummary').textContent = t('viewer.groupDialogSummary', {
|
||||
generate: ready.length,
|
||||
skip: skipped,
|
||||
});
|
||||
list.textContent = '';
|
||||
for (const row of rows) {
|
||||
const entry = document.createElement('div');
|
||||
entry.className = 'thumb-dialog-row' + (row.state === 'ready' ? '' : ' skipped');
|
||||
const name = document.createElement('span');
|
||||
name.className = 'thumb-dialog-name';
|
||||
name.textContent = row.name;
|
||||
name.title = row.sourcePath;
|
||||
const state = document.createElement('span');
|
||||
state.className = 'thumb-dialog-state';
|
||||
state.textContent = t(row.state === 'ready' ? 'viewer.groupWillGenerate'
|
||||
: row.state === 'custom' ? 'viewer.groupSkipCustom' : 'viewer.groupUnavailable');
|
||||
entry.append(name, state);
|
||||
list.appendChild(entry);
|
||||
}
|
||||
confirm.disabled = ready.length === 0;
|
||||
dialog.classList.remove('hidden');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (accepted) => {
|
||||
dialog.classList.add('hidden');
|
||||
confirm.removeEventListener('click', accept);
|
||||
cancel.removeEventListener('click', reject);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(accepted ? ready : []);
|
||||
};
|
||||
const accept = () => finish(true);
|
||||
const reject = () => finish(false);
|
||||
const keydown = (event) => { if (event.key === 'Escape') reject(); };
|
||||
confirm.addEventListener('click', accept);
|
||||
cancel.addEventListener('click', reject);
|
||||
document.addEventListener('keydown', keydown);
|
||||
cancel.focus();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderCurrentFrameThumbnail() {
|
||||
playing = false;
|
||||
updatePlayButton();
|
||||
if (state) state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-cx, -cy);
|
||||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||||
let offRenderer = null;
|
||||
try {
|
||||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
offRenderer.draw(skeleton);
|
||||
} finally {
|
||||
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
|
||||
octx.restore();
|
||||
}
|
||||
}
|
||||
await drawSpineBadge(octx, size, size);
|
||||
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
|
||||
let out = off.toDataURL('image/webp', 0.9);
|
||||
const isWebp = out.startsWith('data:image/webp');
|
||||
if (!isWebp) out = off.toDataURL('image/png');
|
||||
return { buffer: Buffer.from(out.split(',')[1], 'base64'), extension: isWebp ? '.webp' : '.png' };
|
||||
}
|
||||
|
||||
async function generateGroupThumbnails() {
|
||||
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
const groupThumbBtn = document.getElementById('groupThumbBtn');
|
||||
groupThumbBtn.disabled = true;
|
||||
let tempPath = null;
|
||||
try {
|
||||
const rows = await collectGroupThumbnailItems();
|
||||
const targets = await confirmGroupThumbnail(rows);
|
||||
if (targets.length === 0) {
|
||||
if (rows.length && rows.every((row) => row.state !== 'ready')) setStatus(t('viewer.groupNoTargets'), 4000);
|
||||
return;
|
||||
}
|
||||
setStatus(t('viewer.generating'), Infinity);
|
||||
const rendered = await renderCurrentFrameThumbnail();
|
||||
tempPath = path.join(os.tmpdir(), 'spineviewer-group-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
let success = 0, skipped = rows.filter((row) => row.state !== 'ready').length, failed = 0;
|
||||
for (let index = 0; index < targets.length; index++) {
|
||||
setStatus(t('viewer.groupGenerating', { current: index + 1, total: targets.length }), Infinity);
|
||||
try {
|
||||
// 确认后再读一次,避免在对话框打开期间覆盖用户新设的缩略图。
|
||||
const fresh = await getEagleItemForPath(targets[index].sourcePath);
|
||||
if (fresh.data.customThumbnail === true) { skipped++; continue; }
|
||||
await setEagleCustomThumbnail(fresh.id, tempPath);
|
||||
success++;
|
||||
log('用户确认批量更新资源组缩略图: item=' + fresh.id);
|
||||
} catch (error) {
|
||||
failed++;
|
||||
log('资源组缩略图失败: ' + ((error && error.stack) || error));
|
||||
}
|
||||
}
|
||||
setStatus(t('viewer.groupComplete', { success, skip: skipped, failed }), 6000);
|
||||
} catch (e) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
|
||||
log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e));
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
groupThumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function generateThumbnail() {
|
||||
if (!skeleton || !renderer) { setStatus(t('viewer.noSkeletonForThumb')); return; }
|
||||
const thumbBtn = document.getElementById('thumbBtn');
|
||||
thumbBtn.disabled = true;
|
||||
let tempPath = null;
|
||||
try {
|
||||
const currentItem = await getCurrentEagleItem();
|
||||
const currentItem = await getEagleItemForPath(filePath);
|
||||
if (currentItem.data.customThumbnail === true && !window.confirm(t('viewer.replaceCustomThumbnailConfirm'))) return;
|
||||
setStatus(t('viewer.generating'), Infinity);
|
||||
// 暂停动画,冻结当前帧:先确保当前动画时间已被应用到骨骼
|
||||
playing = false;
|
||||
updatePlayButton();
|
||||
if (state) state.apply(skeleton);
|
||||
updateWorld(skeleton);
|
||||
|
||||
const size = 400;
|
||||
const bounds = getBounds(skeleton);
|
||||
const off = document.createElement('canvas');
|
||||
off.width = size; off.height = size;
|
||||
const octx = off.getContext('2d');
|
||||
octx.fillStyle = '#1e1e1e';
|
||||
octx.fillRect(0, 0, size, size);
|
||||
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
const cx = bounds.x + bounds.width / 2;
|
||||
const cy = bounds.y + bounds.height / 2;
|
||||
octx.save();
|
||||
octx.translate(size / 2, size / 2);
|
||||
octx.scale(scale, -scale);
|
||||
octx.translate(-cx, -cy);
|
||||
if (document.getElementById('backgroundCheck').checked) require('../js/spine-background.js').drawCheckerboard(octx, size, size);
|
||||
let offRenderer = null;
|
||||
try {
|
||||
offRenderer = clippingRenderer.createRenderer(spine, octx, activeRuntimeDir);
|
||||
offRenderer.premultipliedAlpha = premultipliedAlpha;
|
||||
offRenderer.draw(skeleton);
|
||||
} finally {
|
||||
if (offRenderer && offRenderer.dispose) offRenderer.dispose();
|
||||
octx.restore();
|
||||
}
|
||||
}
|
||||
await drawSpineBadge(octx, size, size);
|
||||
require('../js/spine-badge.js').drawVersion(octx, size, size, skeletonData.version || activeVersion);
|
||||
|
||||
// Eagle 的 *_thumbnail.png 通常实际保存 WebP;优先使用 WebP 显著减小体积。
|
||||
let out = off.toDataURL('image/webp', 0.9);
|
||||
const isWebp = out.startsWith('data:image/webp');
|
||||
if (!isWebp) out = off.toDataURL('image/png');
|
||||
const buf = Buffer.from(out.split(',')[1], 'base64');
|
||||
const tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + (isWebp ? '.webp' : '.png'));
|
||||
try {
|
||||
fs.writeFileSync(tempPath, buf);
|
||||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||||
setStatus(t('viewer.thumbUpdated', { format: isWebp ? 'WebP' : 'PNG', size: (buf.length / 1024).toFixed(1) }), 3500);
|
||||
log('用户主动更新当前素材缩略图: item=' + currentItem.id + ' (' + buf.length + 'B)');
|
||||
} catch (e) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
|
||||
} finally {
|
||||
try { if (fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
}
|
||||
const rendered = await renderCurrentFrameThumbnail();
|
||||
tempPath = path.join(os.tmpdir(), 'spineviewer-current-' + crypto.randomBytes(12).toString('hex') + rendered.extension);
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
await setEagleCustomThumbnail(currentItem.id, tempPath);
|
||||
setStatus(t('viewer.thumbUpdated', {
|
||||
format: rendered.extension === '.webp' ? 'WebP' : 'PNG',
|
||||
size: (rendered.buffer.length / 1024).toFixed(1),
|
||||
}), 3500);
|
||||
log('用户主动更新当前素材缩略图: item=' + currentItem.id);
|
||||
} catch (e) {
|
||||
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
|
||||
log('生成当前帧缩略图失败: ' + ((e && e.stack) || e));
|
||||
log('生成当前素材缩略图失败: ' + ((e && e.stack) || e));
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
|
||||
thumbBtn.disabled = false;
|
||||
}
|
||||
}
|
||||
@@ -925,6 +1058,7 @@
|
||||
|
||||
// ---------- 加载资源并与运行时交互 ----------
|
||||
async function loadAssets() {
|
||||
activeAssets = null;
|
||||
if (!utils.extOf(path.basename(filePath))) {
|
||||
// Registering txt/bytes is required by Eagle's extension dispatch.
|
||||
// Ordinary text remains readable and never starts Spine discovery.
|
||||
@@ -970,6 +1104,7 @@
|
||||
}
|
||||
return;
|
||||
}
|
||||
activeAssets = assets;
|
||||
let savedPma;
|
||||
try {
|
||||
const settings = require('../js/spine-preview-settings.js');
|
||||
@@ -1131,6 +1266,7 @@
|
||||
document.getElementById('reloadBtn').addEventListener('click', () => {
|
||||
window.location.reload();
|
||||
});
|
||||
document.getElementById('groupThumbBtn').addEventListener('click', generateGroupThumbnails);
|
||||
document.getElementById('thumbBtn').addEventListener('click', generateThumbnail);
|
||||
function seekTimelineToNearestFrame() {
|
||||
const snappedTime = snapTimeToFrame(parseFloat(timeline.value));
|
||||
|
||||
Reference in new issue
Block a user