资源组更新缩略图

This commit is contained in:
tianmo committed 2026-09-17 18:40:29 +08:00
1 parent 05c4fc4b90
commit 2a5fe0ce1f
27 files changed
+562 -161

No files matched your search

+2 -2
View File
@@ -63,7 +63,7 @@ SpineViewer/
- 零时长动画按单帧静态姿势预览 - 零时长动画按单帧静态姿势预览
- 自动适配并居中 - 自动适配并居中
- 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认 - 用户可主动将当前帧设为当前素材的自定义缩略图;若已有自定义缩略图,替换前会明确确认
4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,仍可用原有按钮将当前帧设为**当前素材**的自定义缩略图。左侧的“为当前资源组生成缩略图…”会先列出已验证的骨骼、atlas 和贴图,明确确认后才批量处理,并始终跳过已有自定义缩略图的素材。手动缩略图会添加 Spine badge 和导出版本号。 4. **缩略图**:导入时不运行插件缩略图生成器,由 Eagle 自身处理。用户打开 Spine 预览后,仍可用原有按钮将当前帧设为**当前素材**的自定义缩略图。左侧的“为关联资源生成缩略图”会先列出已验证的同 stem `json / skel / skel.bytes / atlas / atlas.txt` 和 atlas 引用贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户可主动勾选已有自定义缩略图的素材,确认后覆盖,原图无法由插件自动恢复。手动缩略图会添加 Spine badge 和导出版本号。
--- ---
@@ -77,7 +77,7 @@ 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` 时仅写系统临时目录。 Atlas 页面路径按不可信输入处理:插件拒绝绝对路径、`..`、盘符、UNC、URI 以及越出允许目录的符号链接;跨素材贴图只从当前 Eagle 素材库中已经枚举并验证的素材选择。导入阶段不由插件生成缩略图;手动批量操作只作用于明确列出并得到确认的当前资源组,已有自定义缩略图的素材默认不选中,只有用户明确勾选才会覆盖。诊断日志默认关闭;显式设置 `SPINEVIEWER_DEBUG_LOG=1` 时仅写系统临时目录。
- 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。 - 将 Spine 导出的一整套文件(`.skel`/`.json` + `.atlas` + `.png` 等)放在**同一个文件夹**并导入 Eagle。
- 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。 - 点击其中任何一个文件即可预览动画;点击配套的贴图或 atlas 也会自动找到整套资源。
+9 -5
View File
@@ -20,18 +20,20 @@
"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": "Als aktuelles Vorschaubild setzen", "thumbnail": "Als aktuelles Vorschaubild setzen",
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen", "thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
"groupThumbnail": "Vorschaubilder für aktuelle Ressourcengruppe erstellen…", "groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen",
"groupThumbnailTitle": "Aktuelle Spine-Ressourcengruppe prüfen und Vorschaubilder gesammelt erstellen", "groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen",
"groupDialogTitle": "Vorschaubilder für aktuelle Ressourcengruppe erstellen", "groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen",
"groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.", "groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.",
"groupDialogNote": "Nur die unten aufgeführten Elemente werden aktualisiert. Elemente mit eigenen Vorschaubildern werden immer ü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", "cancel": "Abbrechen",
"groupConfirm": "Erstellen", "groupConfirm": "Erstellen",
"groupWillGenerate": "Wird erstellt", "groupWillGenerate": "Wird erstellt",
"groupNotSelected": "Nicht ausgewählt",
"groupSkipCustom": "Übersprungen: eigenes Vorschaubild vorhanden", "groupSkipCustom": "Übersprungen: eigenes Vorschaubild vorhanden",
"groupUnavailable": "Übersprungen: nicht verifizierbar", "groupUnavailable": "Übersprungen: nicht verifizierbar",
"groupNoTargets": "In der aktuellen Ressourcengruppe gibt es keine geeigneten Vorschaubilder.", "groupNoTargets": "In der aktuellen Ressourcengruppe gibt es keine geeigneten Vorschaubilder.",
"groupGenerating": "Vorschaubilder werden erstellt {current}/{total}…", "groupGenerating": "Vorschaubilder werden erstellt {current}/{total}…",
"groupApplyNotConfirmed": "Eagle hat die Anwendung des Vorschaubilds nicht bestätigt",
"groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.", "groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.",
"versionTitle": "Spine-Version", "versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden", "reloadTitle": "Ordner neu laden",
@@ -89,6 +91,8 @@
"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)" "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)"
} }
} }
+8 -4
View File
@@ -20,18 +20,22 @@
"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 current item thumbnail", "thumbnail": "Set current item thumbnail",
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
"groupThumbnail": "Generate thumbnails for current asset group…", "groupThumbnail": "Generate thumbnails for related assets",
"groupThumbnailTitle": "Review the current Spine asset group, then generate thumbnails in a batch", "groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch",
"groupDialogTitle": "Generate thumbnails for current asset group", "groupDialogTitle": "Generate thumbnails for related assets",
"groupDialogSummary": "Generate {generate} item(s) and skip {skip} item(s).", "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.", "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", "cancel": "Cancel",
"groupConfirm": "Generate", "groupConfirm": "Generate",
"groupWillGenerate": "Will generate", "groupWillGenerate": "Will generate",
"groupWillReplaceCustom": "Will replace existing custom thumbnail",
"groupCustomNotSelected": "Custom thumbnail exists (not selected)",
"groupNotSelected": "Not selected",
"groupSkipCustom": "Skipped: custom thumbnail exists", "groupSkipCustom": "Skipped: custom thumbnail exists",
"groupUnavailable": "Skipped: could not verify", "groupUnavailable": "Skipped: could not verify",
"groupNoTargets": "There are no eligible thumbnails in the current asset group.", "groupNoTargets": "There are no eligible thumbnails in the current asset group.",
"groupGenerating": "Generating asset-group thumbnails {current}/{total}…", "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.", "groupComplete": "Asset-group thumbnails complete: {success} succeeded, {skip} skipped, {failed} failed.",
"versionTitle": "Spine version", "versionTitle": "Spine version",
"reloadTitle": "Reload folder", "reloadTitle": "Reload folder",
+9 -5
View File
@@ -20,18 +20,20 @@
"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": "Usar como miniatura actual", "thumbnail": "Usar como miniatura actual",
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento", "thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
"groupThumbnail": "Generar miniaturas del grupo actual…", "groupThumbnail": "Generar miniaturas de recursos relacionados",
"groupThumbnailTitle": "Revisar el grupo de recursos Spine actual y generar miniaturas por lotes", "groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes",
"groupDialogTitle": "Generar miniaturas del grupo actual", "groupDialogTitle": "Generar miniaturas de recursos relacionados",
"groupDialogSummary": "Se generarán {generate} y se omitirán {skip}.", "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.", "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", "cancel": "Cancelar",
"groupConfirm": "Generar", "groupConfirm": "Generar",
"groupWillGenerate": "Se generará", "groupWillGenerate": "Se generará",
"groupNotSelected": "No seleccionado",
"groupSkipCustom": "Omitido: ya tiene miniatura personalizada", "groupSkipCustom": "Omitido: ya tiene miniatura personalizada",
"groupUnavailable": "Omitido: no se pudo verificar", "groupUnavailable": "Omitido: no se pudo verificar",
"groupNoTargets": "No hay miniaturas disponibles para generar en el grupo actual.", "groupNoTargets": "No hay miniaturas disponibles para generar en el grupo actual.",
"groupGenerating": "Generando miniaturas {current}/{total}…", "groupGenerating": "Generando miniaturas {current}/{total}…",
"groupApplyNotConfirmed": "Eagle no confirmó que se aplicara la miniatura",
"groupComplete": "Completado: {success} correctas, {skip} omitidas y {failed} fallidas.", "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",
@@ -89,6 +91,8 @@
"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)" "truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)",
"groupWillReplaceCustom": "Reemplazará la miniatura personalizada",
"groupCustomNotSelected": "Tiene miniatura personalizada (no seleccionado)"
} }
} }
+9 -5
View File
@@ -20,18 +20,20 @@
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "現在の項目のサムネイルに設定", "thumbnail": "現在の項目のサムネイルに設定",
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
"groupThumbnail": "現在のアセットグループのサムネイルを生成…", "groupThumbnail": "関連アセットのサムネイルを生成",
"groupThumbnailTitle": "現在の Spine アセットグループを確認して一括生成", "groupThumbnailTitle": "関連する Spine アセットを確認して一括生成",
"groupDialogTitle": "現在のアセットグループのサムネイルを生成", "groupDialogTitle": "関連アセットのサムネイルを生成",
"groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。", "groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。",
"groupDialogNote": "下に明示された項目だけを更新します。カスタムサムネイルがある項目は必ずスキップします。", "groupDialogNote": "チェックした関連アセットだけにサムネイルを生成します。画像とカスタムサムネイルがあるアセットは初期状態で未選択です。後者を選ぶと既存のサムネイルが置き換えられ、プラグインでは自動復元できません。",
"cancel": "キャンセル", "cancel": "キャンセル",
"groupConfirm": "生成", "groupConfirm": "生成",
"groupWillGenerate": "生成対象", "groupWillGenerate": "生成対象",
"groupNotSelected": "未選択",
"groupSkipCustom": "スキップ:カスタムサムネイルあり", "groupSkipCustom": "スキップ:カスタムサムネイルあり",
"groupUnavailable": "スキップ:検証できません", "groupUnavailable": "スキップ:検証できません",
"groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。", "groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。",
"groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…", "groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle でサムネイルの適用を確認できませんでした",
"groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。", "groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。",
"versionTitle": "Spine バージョン", "versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み", "reloadTitle": "フォルダーを再読み込み",
@@ -89,6 +91,8 @@
"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 のみ表示)" "truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)",
"groupWillReplaceCustom": "既存のカスタムサムネイルを置き換えます",
"groupCustomNotSelected": "カスタムサムネイルあり(未選択)"
} }
} }
+9 -5
View File
@@ -20,18 +20,20 @@
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "현재 항목 썸네일로 설정", "thumbnail": "현재 항목 썸네일로 설정",
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
"groupThumbnail": "현재 자산 그룹 썸네일 생성…", "groupThumbnail": "관련 자산 썸네일 생성",
"groupThumbnailTitle": "현재 Spine 자산 그룹을 확인한 후 일괄 생성", "groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성",
"groupDialogTitle": "현재 자산 그룹 썸네일 생성", "groupDialogTitle": "관련 자산 썸네일 생성",
"groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.", "groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.",
"groupDialogNote": "아래에 명시된 항목만 업데이트합니다. 기존 사용자 지정 썸네일이 있는 항목은 항상 건너뜁니다.", "groupDialogNote": "선택한 관련 자산에만 썸네일을 생성합니다. 이미지와 사용자 지정 썸네일이 있는 자산은 기본적으로 선택하지 않습니다. 후자를 선택하면 기존 썸네일이 교체되며 플러그인에서 자동 복원할 수 없습니다.",
"cancel": "취소", "cancel": "취소",
"groupConfirm": "생성", "groupConfirm": "생성",
"groupWillGenerate": "생성 예정", "groupWillGenerate": "생성 예정",
"groupNotSelected": "선택하지 않음",
"groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음", "groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음",
"groupUnavailable": "건너뛰: 확인할 수 없음", "groupUnavailable": "건너뛰: 확인할 수 없음",
"groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.", "groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.",
"groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…", "groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle에서 썸네일 적용을 확인하지 못했습니다",
"groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.", "groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.",
"versionTitle": "Spine 버전", "versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기", "reloadTitle": "폴더 다시 불러오기",
@@ -89,6 +91,8 @@
"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만 표시)" "truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)",
"groupWillReplaceCustom": "기존 사용자 지정 썸네일 교체",
"groupCustomNotSelected": "사용자 지정 썸네일 있음(선택 안 함)"
} }
} }
+9 -5
View File
@@ -20,18 +20,20 @@
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "Сделать миниатюрой элемента", "thumbnail": "Сделать миниатюрой элемента",
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
"groupThumbnail": "Создать миниатюры для текущей группы…", "groupThumbnail": "Создать миниатюры для связанных ресурсов",
"groupThumbnailTitle": "Проверить группу Spine и создать миниатюры пакетно", "groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры",
"groupDialogTitle": "Создать миниатюры для текущей группы", "groupDialogTitle": "Создать миниатюры для связанных ресурсов",
"groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.", "groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.",
"groupDialogNote": "Будут обновлены только явно указанные ниже элементы. Элементы с пользовательскими миниатюрами всегда пропускаются.", "groupDialogNote": "Миниатюры будут созданы только для отмеченных связанных ресурсов. Изображения и ресурсы со своими миниатюрами по умолчанию не отмечены. Выбор последних заменит существующую миниатюру, и плагин не сможет восстановить её автоматически.",
"cancel": "Отмена", "cancel": "Отмена",
"groupConfirm": "Создать", "groupConfirm": "Создать",
"groupWillGenerate": "Будет создано", "groupWillGenerate": "Будет создано",
"groupNotSelected": "Не выбрано",
"groupSkipCustom": "Пропущено: есть своя миниатюра", "groupSkipCustom": "Пропущено: есть своя миниатюра",
"groupUnavailable": "Пропущено: не удалось проверить", "groupUnavailable": "Пропущено: не удалось проверить",
"groupNoTargets": "В текущей группе нет подходящих миниатюр.", "groupNoTargets": "В текущей группе нет подходящих миниатюр.",
"groupGenerating": "Создание миниатюр {current}/{total}…", "groupGenerating": "Создание миниатюр {current}/{total}…",
"groupApplyNotConfirmed": "Eagle не подтвердил применение миниатюры",
"groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.", "groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.",
"versionTitle": "Версия Spine", "versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку", "reloadTitle": "Перезагрузить папку",
@@ -89,6 +91,8 @@
"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 МБ)" "truncated1m": "\n\n(файл большой, показан только первый 1 МБ)",
"groupWillReplaceCustom": "Заменит существующую миниатюру",
"groupCustomNotSelected": "Есть своя миниатюра (не выбрано)"
} }
} }
+8 -4
View File
@@ -20,18 +20,22 @@
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "设为当前素材缩略图", "thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"groupThumbnail": "为当前资源组生成缩略图…", "groupThumbnail": "为关联资源生成缩略图",
"groupThumbnailTitle": "列出当前 Spine 资源组,确认后批量生成缩略图", "groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图",
"groupDialogTitle": "为当前资源组生成缩略图", "groupDialogTitle": "为关联资源生成缩略图",
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。", "groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
"groupDialogNote": "只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。", "groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。",
"cancel": "取消", "cancel": "取消",
"groupConfirm": "确认生成", "groupConfirm": "确认生成",
"groupWillGenerate": "将生成", "groupWillGenerate": "将生成",
"groupWillReplaceCustom": "将替换已有自定义缩略图",
"groupCustomNotSelected": "已有自定义缩略图(未选择)",
"groupNotSelected": "未选择",
"groupSkipCustom": "跳过:已有自定义缩略图", "groupSkipCustom": "跳过:已有自定义缩略图",
"groupUnavailable": "跳过:无法验证", "groupUnavailable": "跳过:无法验证",
"groupNoTargets": "当前资源组没有可生成的缩略图。", "groupNoTargets": "当前资源组没有可生成的缩略图。",
"groupGenerating": "正在生成资源组缩略图 {current}/{total}…", "groupGenerating": "正在生成资源组缩略图 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle 未确认缩略图已应用",
"groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。", "groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。",
"versionTitle": "Spine 版本", "versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录", "reloadTitle": "重新加载目录",
+8 -4
View File
@@ -20,18 +20,22 @@
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "設為目前素材縮圖", "thumbnail": "設為目前素材縮圖",
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
"groupThumbnail": "為目前資源組產生縮圖…", "groupThumbnail": "為關聯資源產生縮圖",
"groupThumbnailTitle": "列出目前 Spine 資源組,確認後批次產生縮圖", "groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖",
"groupDialogTitle": "為目前資源組產生縮圖", "groupDialogTitle": "為關聯資源產生縮圖",
"groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。", "groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。",
"groupDialogNote": "只會更新下列明確列出的素材;已有自訂縮圖的素材會自動跳過。", "groupDialogNote": "只會為勾選的關聯資源產生縮圖。圖片和已有自訂縮圖的資源預設不勾選;若勾選後者,原縮圖將被取代且外掛無法自動復原。",
"cancel": "取消", "cancel": "取消",
"groupConfirm": "確認產生", "groupConfirm": "確認產生",
"groupWillGenerate": "將產生", "groupWillGenerate": "將產生",
"groupWillReplaceCustom": "將取代已有自訂縮圖",
"groupCustomNotSelected": "已有自訂縮圖(未選擇)",
"groupNotSelected": "未選擇",
"groupSkipCustom": "跳過:已有自訂縮圖", "groupSkipCustom": "跳過:已有自訂縮圖",
"groupUnavailable": "跳過:無法驗證", "groupUnavailable": "跳過:無法驗證",
"groupNoTargets": "目前資源組沒有可產生的縮圖。", "groupNoTargets": "目前資源組沒有可產生的縮圖。",
"groupGenerating": "正在產生資源組縮圖 {current}/{total}…", "groupGenerating": "正在產生資源組縮圖 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle 未確認縮圖已套用",
"groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。", "groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。",
"versionTitle": "Spine 版本", "versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄", "reloadTitle": "重新載入目錄",
+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 };
Binary file not shown.
+2 -2
View File
@@ -7,13 +7,13 @@ Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4–4.3,适用
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内。 1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内。
2. 打开骨骼或图集文件即可预览动画,并可切换动画、皮肤、速度、循环、背景和 PMA。 2. 打开骨骼或图集文件即可预览动画,并可切换动画、皮肤、速度、循环、背景和 PMA。
3. 插件不接管导入阶段的缩略图,由 Eagle 自身处理。 3. 插件不接管导入阶段的缩略图,由 Eagle 自身处理。
4. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为当前资源组生成缩略图…”会先明确列出受影响的骨骼、atlas 和贴图,得到确认后才批量生成,并始终跳过已有自定义缩略图的素材。 4. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为关联资源生成缩略图”会列出同 stem 的骨骼、atlas 及已验证贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户仍可主动选择并覆盖,原图无法由插件自动恢复。
## 安全与隐私 ## 安全与隐私
- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。 - Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。
- 跨素材配对只使用当前 Eagle 素材库中已枚举并验证的文件。 - 跨素材配对只使用当前 Eagle 素材库中已枚举并验证的文件。
- 单素材按钮只作用于当前素材。资源组按钮会先列出全部受影响素材并要求确认,且不会覆盖已有自定义缩略图。 - 单素材按钮只作用于当前素材。资源组按钮会先列出全部受影响素材并要求确认,已有自定义缩略图默认不选中,只有用户明确勾选才会覆盖。
## 多语言 ## 多语言
@@ -20,18 +20,20 @@
"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": "Als aktuelles Vorschaubild setzen", "thumbnail": "Als aktuelles Vorschaubild setzen",
"thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen", "thumbnailTitle": "Aktuellen Frame als benutzerdefiniertes 400×400-Vorschaubild dieses Elements setzen",
"groupThumbnail": "Vorschaubilder für aktuelle Ressourcengruppe erstellen…", "groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen",
"groupThumbnailTitle": "Aktuelle Spine-Ressourcengruppe prüfen und Vorschaubilder gesammelt erstellen", "groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen",
"groupDialogTitle": "Vorschaubilder für aktuelle Ressourcengruppe erstellen", "groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen",
"groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.", "groupDialogSummary": "{generate} werden erstellt, {skip} werden übersprungen.",
"groupDialogNote": "Nur die unten aufgeführten Elemente werden aktualisiert. Elemente mit eigenen Vorschaubildern werden immer ü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", "cancel": "Abbrechen",
"groupConfirm": "Erstellen", "groupConfirm": "Erstellen",
"groupWillGenerate": "Wird erstellt", "groupWillGenerate": "Wird erstellt",
"groupNotSelected": "Nicht ausgewählt",
"groupSkipCustom": "Übersprungen: eigenes Vorschaubild vorhanden", "groupSkipCustom": "Übersprungen: eigenes Vorschaubild vorhanden",
"groupUnavailable": "Übersprungen: nicht verifizierbar", "groupUnavailable": "Übersprungen: nicht verifizierbar",
"groupNoTargets": "In der aktuellen Ressourcengruppe gibt es keine geeigneten Vorschaubilder.", "groupNoTargets": "In der aktuellen Ressourcengruppe gibt es keine geeigneten Vorschaubilder.",
"groupGenerating": "Vorschaubilder werden erstellt {current}/{total}…", "groupGenerating": "Vorschaubilder werden erstellt {current}/{total}…",
"groupApplyNotConfirmed": "Eagle hat die Anwendung des Vorschaubilds nicht bestätigt",
"groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.", "groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.",
"versionTitle": "Spine-Version", "versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden", "reloadTitle": "Ordner neu laden",
@@ -89,6 +91,8 @@
"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)" "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)"
} }
} }
@@ -20,18 +20,22 @@
"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 current item thumbnail", "thumbnail": "Set current item thumbnail",
"thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail", "thumbnailTitle": "Set the current frame as this item's 400×400 custom thumbnail",
"groupThumbnail": "Generate thumbnails for current asset group…", "groupThumbnail": "Generate thumbnails for related assets",
"groupThumbnailTitle": "Review the current Spine asset group, then generate thumbnails in a batch", "groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch",
"groupDialogTitle": "Generate thumbnails for current asset group", "groupDialogTitle": "Generate thumbnails for related assets",
"groupDialogSummary": "Generate {generate} item(s) and skip {skip} item(s).", "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.", "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", "cancel": "Cancel",
"groupConfirm": "Generate", "groupConfirm": "Generate",
"groupWillGenerate": "Will generate", "groupWillGenerate": "Will generate",
"groupWillReplaceCustom": "Will replace existing custom thumbnail",
"groupCustomNotSelected": "Custom thumbnail exists (not selected)",
"groupNotSelected": "Not selected",
"groupSkipCustom": "Skipped: custom thumbnail exists", "groupSkipCustom": "Skipped: custom thumbnail exists",
"groupUnavailable": "Skipped: could not verify", "groupUnavailable": "Skipped: could not verify",
"groupNoTargets": "There are no eligible thumbnails in the current asset group.", "groupNoTargets": "There are no eligible thumbnails in the current asset group.",
"groupGenerating": "Generating asset-group thumbnails {current}/{total}…", "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.", "groupComplete": "Asset-group thumbnails complete: {success} succeeded, {skip} skipped, {failed} failed.",
"versionTitle": "Spine version", "versionTitle": "Spine version",
"reloadTitle": "Reload folder", "reloadTitle": "Reload folder",
@@ -20,18 +20,20 @@
"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": "Usar como miniatura actual", "thumbnail": "Usar como miniatura actual",
"thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento", "thumbnailTitle": "Usar el fotograma actual como miniatura personalizada de 400×400 de este elemento",
"groupThumbnail": "Generar miniaturas del grupo actual…", "groupThumbnail": "Generar miniaturas de recursos relacionados",
"groupThumbnailTitle": "Revisar el grupo de recursos Spine actual y generar miniaturas por lotes", "groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes",
"groupDialogTitle": "Generar miniaturas del grupo actual", "groupDialogTitle": "Generar miniaturas de recursos relacionados",
"groupDialogSummary": "Se generarán {generate} y se omitirán {skip}.", "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.", "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", "cancel": "Cancelar",
"groupConfirm": "Generar", "groupConfirm": "Generar",
"groupWillGenerate": "Se generará", "groupWillGenerate": "Se generará",
"groupNotSelected": "No seleccionado",
"groupSkipCustom": "Omitido: ya tiene miniatura personalizada", "groupSkipCustom": "Omitido: ya tiene miniatura personalizada",
"groupUnavailable": "Omitido: no se pudo verificar", "groupUnavailable": "Omitido: no se pudo verificar",
"groupNoTargets": "No hay miniaturas disponibles para generar en el grupo actual.", "groupNoTargets": "No hay miniaturas disponibles para generar en el grupo actual.",
"groupGenerating": "Generando miniaturas {current}/{total}…", "groupGenerating": "Generando miniaturas {current}/{total}…",
"groupApplyNotConfirmed": "Eagle no confirmó que se aplicara la miniatura",
"groupComplete": "Completado: {success} correctas, {skip} omitidas y {failed} fallidas.", "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",
@@ -89,6 +91,8 @@
"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)" "truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)",
"groupWillReplaceCustom": "Reemplazará la miniatura personalizada",
"groupCustomNotSelected": "Tiene miniatura personalizada (no seleccionado)"
} }
} }
@@ -20,18 +20,20 @@
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します", "backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "現在の項目のサムネイルに設定", "thumbnail": "現在の項目のサムネイルに設定",
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定", "thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
"groupThumbnail": "現在のアセットグループのサムネイルを生成…", "groupThumbnail": "関連アセットのサムネイルを生成",
"groupThumbnailTitle": "現在の Spine アセットグループを確認して一括生成", "groupThumbnailTitle": "関連する Spine アセットを確認して一括生成",
"groupDialogTitle": "現在のアセットグループのサムネイルを生成", "groupDialogTitle": "関連アセットのサムネイルを生成",
"groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。", "groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。",
"groupDialogNote": "下に明示された項目だけを更新します。カスタムサムネイルがある項目は必ずスキップします。", "groupDialogNote": "チェックした関連アセットだけにサムネイルを生成します。画像とカスタムサムネイルがあるアセットは初期状態で未選択です。後者を選ぶと既存のサムネイルが置き換えられ、プラグインでは自動復元できません。",
"cancel": "キャンセル", "cancel": "キャンセル",
"groupConfirm": "生成", "groupConfirm": "生成",
"groupWillGenerate": "生成対象", "groupWillGenerate": "生成対象",
"groupNotSelected": "未選択",
"groupSkipCustom": "スキップ:カスタムサムネイルあり", "groupSkipCustom": "スキップ:カスタムサムネイルあり",
"groupUnavailable": "スキップ:検証できません", "groupUnavailable": "スキップ:検証できません",
"groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。", "groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。",
"groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…", "groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle でサムネイルの適用を確認できませんでした",
"groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。", "groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。",
"versionTitle": "Spine バージョン", "versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み", "reloadTitle": "フォルダーを再読み込み",
@@ -89,6 +91,8 @@
"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 のみ表示)" "truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)",
"groupWillReplaceCustom": "既存のカスタムサムネイルを置き換えます",
"groupCustomNotSelected": "カスタムサムネイルあり(未選択)"
} }
} }
@@ -20,18 +20,20 @@
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다", "backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "현재 항목 썸네일로 설정", "thumbnail": "현재 항목 썸네일로 설정",
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정", "thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
"groupThumbnail": "현재 자산 그룹 썸네일 생성…", "groupThumbnail": "관련 자산 썸네일 생성",
"groupThumbnailTitle": "현재 Spine 자산 그룹을 확인한 후 일괄 생성", "groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성",
"groupDialogTitle": "현재 자산 그룹 썸네일 생성", "groupDialogTitle": "관련 자산 썸네일 생성",
"groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.", "groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.",
"groupDialogNote": "아래에 명시된 항목만 업데이트합니다. 기존 사용자 지정 썸네일이 있는 항목은 항상 건너뜁니다.", "groupDialogNote": "선택한 관련 자산에만 썸네일을 생성합니다. 이미지와 사용자 지정 썸네일이 있는 자산은 기본적으로 선택하지 않습니다. 후자를 선택하면 기존 썸네일이 교체되며 플러그인에서 자동 복원할 수 없습니다.",
"cancel": "취소", "cancel": "취소",
"groupConfirm": "생성", "groupConfirm": "생성",
"groupWillGenerate": "생성 예정", "groupWillGenerate": "생성 예정",
"groupNotSelected": "선택하지 않음",
"groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음", "groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음",
"groupUnavailable": "건너뛰: 확인할 수 없음", "groupUnavailable": "건너뛰: 확인할 수 없음",
"groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.", "groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.",
"groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…", "groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle에서 썸네일 적용을 확인하지 못했습니다",
"groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.", "groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.",
"versionTitle": "Spine 버전", "versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기", "reloadTitle": "폴더 다시 불러오기",
@@ -89,6 +91,8 @@
"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만 표시)" "truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)",
"groupWillReplaceCustom": "기존 사용자 지정 썸네일 교체",
"groupCustomNotSelected": "사용자 지정 썸네일 있음(선택 안 함)"
} }
} }
@@ -20,18 +20,20 @@
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания", "backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "Сделать миниатюрой элемента", "thumbnail": "Сделать миниатюрой элемента",
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента", "thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
"groupThumbnail": "Создать миниатюры для текущей группы…", "groupThumbnail": "Создать миниатюры для связанных ресурсов",
"groupThumbnailTitle": "Проверить группу Spine и создать миниатюры пакетно", "groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры",
"groupDialogTitle": "Создать миниатюры для текущей группы", "groupDialogTitle": "Создать миниатюры для связанных ресурсов",
"groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.", "groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.",
"groupDialogNote": "Будут обновлены только явно указанные ниже элементы. Элементы с пользовательскими миниатюрами всегда пропускаются.", "groupDialogNote": "Миниатюры будут созданы только для отмеченных связанных ресурсов. Изображения и ресурсы со своими миниатюрами по умолчанию не отмечены. Выбор последних заменит существующую миниатюру, и плагин не сможет восстановить её автоматически.",
"cancel": "Отмена", "cancel": "Отмена",
"groupConfirm": "Создать", "groupConfirm": "Создать",
"groupWillGenerate": "Будет создано", "groupWillGenerate": "Будет создано",
"groupNotSelected": "Не выбрано",
"groupSkipCustom": "Пропущено: есть своя миниатюра", "groupSkipCustom": "Пропущено: есть своя миниатюра",
"groupUnavailable": "Пропущено: не удалось проверить", "groupUnavailable": "Пропущено: не удалось проверить",
"groupNoTargets": "В текущей группе нет подходящих миниатюр.", "groupNoTargets": "В текущей группе нет подходящих миниатюр.",
"groupGenerating": "Создание миниатюр {current}/{total}…", "groupGenerating": "Создание миниатюр {current}/{total}…",
"groupApplyNotConfirmed": "Eagle не подтвердил применение миниатюры",
"groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.", "groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.",
"versionTitle": "Версия Spine", "versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку", "reloadTitle": "Перезагрузить папку",
@@ -89,6 +91,8 @@
"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 МБ)" "truncated1m": "\n\n(файл большой, показан только первый 1 МБ)",
"groupWillReplaceCustom": "Заменит существующую миниатюру",
"groupCustomNotSelected": "Есть своя миниатюра (не выбрано)"
} }
} }
@@ -20,18 +20,22 @@
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式", "backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "设为当前素材缩略图", "thumbnail": "设为当前素材缩略图",
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图", "thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
"groupThumbnail": "为当前资源组生成缩略图…", "groupThumbnail": "为关联资源生成缩略图",
"groupThumbnailTitle": "列出当前 Spine 资源组,确认后批量生成缩略图", "groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图",
"groupDialogTitle": "为当前资源组生成缩略图", "groupDialogTitle": "为关联资源生成缩略图",
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。", "groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
"groupDialogNote": "只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。", "groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。",
"cancel": "取消", "cancel": "取消",
"groupConfirm": "确认生成", "groupConfirm": "确认生成",
"groupWillGenerate": "将生成", "groupWillGenerate": "将生成",
"groupWillReplaceCustom": "将替换已有自定义缩略图",
"groupCustomNotSelected": "已有自定义缩略图(未选择)",
"groupNotSelected": "未选择",
"groupSkipCustom": "跳过:已有自定义缩略图", "groupSkipCustom": "跳过:已有自定义缩略图",
"groupUnavailable": "跳过:无法验证", "groupUnavailable": "跳过:无法验证",
"groupNoTargets": "当前资源组没有可生成的缩略图。", "groupNoTargets": "当前资源组没有可生成的缩略图。",
"groupGenerating": "正在生成资源组缩略图 {current}/{total}…", "groupGenerating": "正在生成资源组缩略图 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle 未确认缩略图已应用",
"groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。", "groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。",
"versionTitle": "Spine 版本", "versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录", "reloadTitle": "重新加载目录",
@@ -20,18 +20,22 @@
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式", "backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "設為目前素材縮圖", "thumbnail": "設為目前素材縮圖",
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖", "thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
"groupThumbnail": "為目前資源組產生縮圖…", "groupThumbnail": "為關聯資源產生縮圖",
"groupThumbnailTitle": "列出目前 Spine 資源組,確認後批次產生縮圖", "groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖",
"groupDialogTitle": "為目前資源組產生縮圖", "groupDialogTitle": "為關聯資源產生縮圖",
"groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。", "groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。",
"groupDialogNote": "只會更新下列明確列出的素材;已有自訂縮圖的素材會自動跳過。", "groupDialogNote": "只會為勾選的關聯資源產生縮圖。圖片和已有自訂縮圖的資源預設不勾選;若勾選後者,原縮圖將被取代且外掛無法自動復原。",
"cancel": "取消", "cancel": "取消",
"groupConfirm": "確認產生", "groupConfirm": "確認產生",
"groupWillGenerate": "將產生", "groupWillGenerate": "將產生",
"groupWillReplaceCustom": "將取代已有自訂縮圖",
"groupCustomNotSelected": "已有自訂縮圖(未選擇)",
"groupNotSelected": "未選擇",
"groupSkipCustom": "跳過:已有自訂縮圖", "groupSkipCustom": "跳過:已有自訂縮圖",
"groupUnavailable": "跳過:無法驗證", "groupUnavailable": "跳過:無法驗證",
"groupNoTargets": "目前資源組沒有可產生的縮圖。", "groupNoTargets": "目前資源組沒有可產生的縮圖。",
"groupGenerating": "正在產生資源組縮圖 {current}/{total}…", "groupGenerating": "正在產生資源組縮圖 {current}/{total}…",
"groupApplyNotConfirmed": "Eagle 未確認縮圖已套用",
"groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。", "groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。",
"versionTitle": "Spine 版本", "versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄", "reloadTitle": "重新載入目錄",
@@ -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 };
@@ -100,6 +100,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.thumb-dialog-row:last-child { border-bottom: 0; } .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-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-state { flex: none; color: var(--accent); font-size: 12px; }
.thumb-dialog-row.skipped .thumb-dialog-name, .thumb-dialog-row.skipped .thumb-dialog-name,
+89 -41
View File
@@ -25,7 +25,7 @@
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span> <input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label> </label>
<div class="spacer"></div> <div class="spacer"></div>
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为当前资源组生成缩略图">为当前资源组生成缩略图…</button> <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> <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> <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> <button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
@@ -61,10 +61,10 @@
</div> </div>
<div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle"> <div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle">
<div class="thumb-dialog-card"> <div class="thumb-dialog-card">
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为当前资源组生成缩略图</div> <div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为关联资源生成缩略图</div>
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div> <div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。</div>
<div id="thumbDialogList" class="thumb-dialog-list"></div> <div id="thumbDialogList" class="thumb-dialog-list"></div>
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。</div> <div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
<div class="thumb-dialog-actions"> <div class="thumb-dialog-actions">
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button> <button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button> <button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
@@ -82,6 +82,7 @@
const utils = require('../js/spine-utils.js'); const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js'); const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js'); const clippingRenderer = require('../js/spine-canvas-clipping.js');
const groupThumbnails = require('../js/spine-group-thumbnails.js');
let spineBadgeImagePromise = null; let spineBadgeImagePromise = null;
function loadSpineBadgeImage() { function loadSpineBadgeImage() {
@@ -508,7 +509,9 @@
const urlItemId = urlParams.get('id'); const urlItemId = urlParams.get('id');
if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch')); if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch'));
} }
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId)); const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId) + '&_=' + Date.now(), {
cache: 'no-store',
});
let result = null; let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ } try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) { if (!response.ok || !result || result.status !== 'success' || !result.data) {
@@ -526,18 +529,40 @@
}); });
let result = null; let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ } try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || (result && result.status && result.status !== 'success')) { if (!response.ok || !result || result.status !== 'success' || result.data !== true) {
const reason = result && (result.message || result.error); const reason = result && (result.message || result.error);
throw new Error(reason || t('viewer.apiHttp', { status: response.status })); throw new Error(reason || t('viewer.apiHttp', { status: response.status }));
} }
return result; return true;
}
async function waitForCustomThumbnail(sourcePath, attempts = 8, delayMs = 120) {
for (let attempt = 0; attempt < attempts; attempt++) {
const item = await getEagleItemForPath(sourcePath);
if (item.data.customThumbnail === true) return true;
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
} }
function currentResourcePaths() { function currentResourcePaths() {
if (!activeAssets) return []; if (!activeAssets) return [];
const groupStems = new Set([
activeAssets.base,
activeAssets.skeletonPath && utils.normalizeStem(path.basename(activeAssets.skeletonPath)),
activeAssets.atlasPath && utils.normalizeStem(path.basename(activeAssets.atlasPath)),
].filter(Boolean));
const relatedDataPaths = [
...(activeAssets.skeletonCandidatePaths || []),
...(activeAssets.atlasCandidatePaths || []),
].filter((file) => {
const name = path.basename(file);
return (utils.isSkeletonFile(name) || utils.isAtlasFile(name)) && groupStems.has(utils.normalizeStem(name));
});
return [...new Set([ return [...new Set([
activeAssets.skeletonPath, activeAssets.skeletonPath,
activeAssets.atlasPath, activeAssets.atlasPath,
...relatedDataPaths,
...Object.values(activeAssets.texturePaths || {}), ...Object.values(activeAssets.texturePaths || {}),
].filter(Boolean).map((file) => path.resolve(file)))]; ].filter(Boolean).map((file) => path.resolve(file)))];
} }
@@ -551,6 +576,8 @@
...item, ...item,
name: path.basename(sourcePath), name: path.basename(sourcePath),
state: item.data.customThumbnail === true ? 'custom' : 'ready', state: item.data.customThumbnail === true ? 'custom' : 'ready',
selected: item.data.customThumbnail !== true && !utils.isTextureFile(sourcePath),
allowOverwrite: item.data.customThumbnail === true,
}); });
} catch (error) { } catch (error) {
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error }); rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
@@ -564,28 +591,53 @@
const list = document.getElementById('thumbDialogList'); const list = document.getElementById('thumbDialogList');
const confirm = document.getElementById('thumbDialogConfirm'); const confirm = document.getElementById('thumbDialogConfirm');
const cancel = document.getElementById('thumbDialogCancel'); const cancel = document.getElementById('thumbDialogCancel');
const ready = rows.filter((row) => row.state === 'ready'); const summary = document.getElementById('thumbDialogSummary');
const skipped = rows.length - ready.length; const selectable = rows.filter((row) => row.state !== 'unavailable');
document.getElementById('thumbDialogSummary').textContent = t('viewer.groupDialogSummary', {
generate: ready.length,
skip: skipped,
});
list.textContent = ''; list.textContent = '';
for (const row of rows) { for (const row of rows) {
const entry = document.createElement('div'); const entry = document.createElement('div');
entry.className = 'thumb-dialog-row' + (row.state === 'ready' ? '' : ' skipped'); entry.className = 'thumb-dialog-row' + (row.state === 'unavailable' ? ' skipped' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'thumb-dialog-check';
checkbox.checked = row.state !== 'unavailable' && row.selected === true;
checkbox.disabled = row.state === 'unavailable';
row.checkbox = checkbox;
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'thumb-dialog-name'; name.className = 'thumb-dialog-name';
name.textContent = row.name; name.textContent = row.name;
name.title = row.sourcePath; name.title = row.sourcePath;
const state = document.createElement('span'); const state = document.createElement('span');
state.className = 'thumb-dialog-state'; state.className = 'thumb-dialog-state';
state.textContent = t(row.state === 'ready' ? 'viewer.groupWillGenerate' state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.state === 'custom' ? 'viewer.groupSkipCustom' : 'viewer.groupUnavailable'); : row.selected ? 'viewer.groupWillGenerate'
entry.append(name, state); : row.state === 'custom' ? 'viewer.groupCustomNotSelected'
: row.state === 'ready' ? 'viewer.groupNotSelected' : 'viewer.groupUnavailable');
row.stateElement = state;
entry.append(checkbox, name, state);
list.appendChild(entry); list.appendChild(entry);
if (row.state !== 'unavailable') {
checkbox.addEventListener('change', () => {
row.selected = checkbox.checked;
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.selected ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupCustomNotSelected' : 'viewer.groupNotSelected');
updateSelection();
});
entry.addEventListener('click', (event) => {
if (event.target !== checkbox) checkbox.click();
});
}
} }
confirm.disabled = ready.length === 0; function updateSelection() {
const selected = selectable.filter((row) => row.selected);
summary.textContent = t('viewer.groupDialogSummary', {
generate: selected.length,
skip: rows.length - selected.length,
});
confirm.disabled = selected.length === 0;
}
updateSelection();
dialog.classList.remove('hidden'); dialog.classList.remove('hidden');
return new Promise((resolve) => { return new Promise((resolve) => {
const finish = (accepted) => { const finish = (accepted) => {
@@ -593,7 +645,7 @@
confirm.removeEventListener('click', accept); confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject); cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown); document.removeEventListener('keydown', keydown);
resolve(accepted ? ready : []); resolve(accepted ? selectable.filter((row) => row.selected) : []);
}; };
const accept = () => finish(true); const accept = () => finish(true);
const reject = () => finish(false); const reject = () => finish(false);
@@ -650,39 +702,35 @@
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; } if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn'); const groupThumbBtn = document.getElementById('groupThumbBtn');
groupThumbBtn.disabled = true; groupThumbBtn.disabled = true;
let tempPath = null;
try { try {
const rows = await collectGroupThumbnailItems(); const rows = await collectGroupThumbnailItems();
const targets = await confirmGroupThumbnail(rows); const selectedTargets = await confirmGroupThumbnail(rows);
// Updating the item that owns this preview causes Eagle to close/reload the
// viewer. Always do that item last so its teardown cannot abort the batch.
const targets = groupThumbnails.orderCurrentTargetLast(selectedTargets, filePath);
if (targets.length === 0) { if (targets.length === 0) {
if (rows.length && rows.every((row) => row.state !== 'ready')) setStatus(t('viewer.groupNoTargets'), 4000); if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000);
return; return;
} }
setStatus(t('viewer.generating'), Infinity); setStatus(t('viewer.generating'), Infinity);
const rendered = await renderCurrentFrameThumbnail(); const rendered = await renderCurrentFrameThumbnail();
tempPath = path.join(os.tmpdir(), 'spineviewer-group-' + crypto.randomBytes(12).toString('hex') + rendered.extension); const result = await groupThumbnails.applyThumbnailTargets({
fs.writeFileSync(tempPath, rendered.buffer); targets,
let success = 0, skipped = rows.filter((row) => row.state !== 'ready').length, failed = 0; initialSkipped: rows.length - targets.length,
for (let index = 0; index < targets.length; index++) { rendered,
setStatus(t('viewer.groupGenerating', { current: index + 1, total: targets.length }), Infinity); getItem: getEagleItemForPath,
try { setThumbnail: setEagleCustomThumbnail,
// 确认后再读一次,避免在对话框打开期间覆盖用户新设的缩略图。 waitForApplied: waitForCustomThumbnail,
const fresh = await getEagleItemForPath(targets[index].sourcePath); notConfirmedMessage: t('viewer.groupApplyNotConfirmed'),
if (fresh.data.customThumbnail === true) { skipped++; continue; } onProgress: (current, total) => setStatus(t('viewer.groupGenerating', { current, total }), Infinity),
await setEagleCustomThumbnail(fresh.id, tempPath); onSuccess: (fresh) => log('用户确认批量更新关联资源缩略图: item=' + fresh.id),
success++; onError: (error) => log('关联资源缩略图失败: ' + ((error && error.stack) || error)),
log('用户确认批量更新资源组缩略图: item=' + fresh.id); });
} catch (error) { setStatus(t('viewer.groupComplete', { success: result.success, skip: result.skipped, failed: result.failed }), 6000);
failed++;
log('资源组缩略图失败: ' + ((error && error.stack) || error));
}
}
setStatus(t('viewer.groupComplete', { success, skip: skipped, failed }), 6000);
} catch (e) { } catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000); setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e)); log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e));
} finally { } finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
groupThumbBtn.disabled = false; groupThumbBtn.disabled = false;
} }
} }
+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 -1
View File
@@ -72,7 +72,11 @@ try {
const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8'); const viewerSource = fs.readFileSync(path.join(__dirname, '../viewer/spine.html'), 'utf8');
assert.match(viewerSource, /currentItem\.data\.customThumbnail === true && !window\.confirm/); 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\.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, /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, /groupThumbBtn[\s\S]*thumbBtn/);
assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/); assert.match(viewerSource, /path\.join\(os\.tmpdir\(\), 'spineviewer-current-'/);
assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/); assert.doesNotMatch(viewerSource, /fs\.writeFileSync\(outPath/);
+1
View File
@@ -100,6 +100,7 @@
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.thumb-dialog-row:last-child { border-bottom: 0; } .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-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-state { flex: none; color: var(--accent); font-size: 12px; }
.thumb-dialog-row.skipped .thumb-dialog-name, .thumb-dialog-row.skipped .thumb-dialog-name,
+89 -41
View File
@@ -25,7 +25,7 @@
<input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span> <input type="checkbox" id="backgroundCheck"> <span data-i18n="viewer.background">背景</span>
</label> </label>
<div class="spacer"></div> <div class="spacer"></div>
<button id="groupThumbBtn" data-i18n="viewer.groupThumbnail" data-i18n-title="viewer.groupThumbnailTitle" title="为当前资源组生成缩略图">为当前资源组生成缩略图…</button> <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> <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> <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> <button id="reloadBtn" title="重新加载目录" data-i18n-title="viewer.reloadTitle">↻</button>
@@ -61,10 +61,10 @@
</div> </div>
<div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle"> <div id="thumbDialog" class="thumb-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="thumbDialogTitle">
<div class="thumb-dialog-card"> <div class="thumb-dialog-card">
<div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为当前资源组生成缩略图</div> <div id="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为关联资源生成缩略图</div>
<div id="thumbDialogSummary" class="thumb-dialog-summary"></div> <div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。</div>
<div id="thumbDialogList" class="thumb-dialog-list"></div> <div id="thumbDialogList" class="thumb-dialog-list"></div>
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。</div> <div id="thumbDialogSummary" class="thumb-dialog-summary"></div>
<div class="thumb-dialog-actions"> <div class="thumb-dialog-actions">
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button> <button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button> <button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
@@ -82,6 +82,7 @@
const utils = require('../js/spine-utils.js'); const utils = require('../js/spine-utils.js');
const spineBadge = require('../js/spine-badge.js'); const spineBadge = require('../js/spine-badge.js');
const clippingRenderer = require('../js/spine-canvas-clipping.js'); const clippingRenderer = require('../js/spine-canvas-clipping.js');
const groupThumbnails = require('../js/spine-group-thumbnails.js');
let spineBadgeImagePromise = null; let spineBadgeImagePromise = null;
function loadSpineBadgeImage() { function loadSpineBadgeImage() {
@@ -508,7 +509,9 @@
const urlItemId = urlParams.get('id'); const urlItemId = urlParams.get('id');
if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch')); if (urlItemId && urlItemId !== itemId) throw new Error(t('viewer.itemMismatch'));
} }
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId)); const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId) + '&_=' + Date.now(), {
cache: 'no-store',
});
let result = null; let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ } try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || !result || result.status !== 'success' || !result.data) { if (!response.ok || !result || result.status !== 'success' || !result.data) {
@@ -526,18 +529,40 @@
}); });
let result = null; let result = null;
try { result = await response.json(); } catch (e) { /* use HTTP status below */ } try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
if (!response.ok || (result && result.status && result.status !== 'success')) { if (!response.ok || !result || result.status !== 'success' || result.data !== true) {
const reason = result && (result.message || result.error); const reason = result && (result.message || result.error);
throw new Error(reason || t('viewer.apiHttp', { status: response.status })); throw new Error(reason || t('viewer.apiHttp', { status: response.status }));
} }
return result; return true;
}
async function waitForCustomThumbnail(sourcePath, attempts = 8, delayMs = 120) {
for (let attempt = 0; attempt < attempts; attempt++) {
const item = await getEagleItemForPath(sourcePath);
if (item.data.customThumbnail === true) return true;
if (attempt < attempts - 1) await new Promise((resolve) => setTimeout(resolve, delayMs));
}
return false;
} }
function currentResourcePaths() { function currentResourcePaths() {
if (!activeAssets) return []; if (!activeAssets) return [];
const groupStems = new Set([
activeAssets.base,
activeAssets.skeletonPath && utils.normalizeStem(path.basename(activeAssets.skeletonPath)),
activeAssets.atlasPath && utils.normalizeStem(path.basename(activeAssets.atlasPath)),
].filter(Boolean));
const relatedDataPaths = [
...(activeAssets.skeletonCandidatePaths || []),
...(activeAssets.atlasCandidatePaths || []),
].filter((file) => {
const name = path.basename(file);
return (utils.isSkeletonFile(name) || utils.isAtlasFile(name)) && groupStems.has(utils.normalizeStem(name));
});
return [...new Set([ return [...new Set([
activeAssets.skeletonPath, activeAssets.skeletonPath,
activeAssets.atlasPath, activeAssets.atlasPath,
...relatedDataPaths,
...Object.values(activeAssets.texturePaths || {}), ...Object.values(activeAssets.texturePaths || {}),
].filter(Boolean).map((file) => path.resolve(file)))]; ].filter(Boolean).map((file) => path.resolve(file)))];
} }
@@ -551,6 +576,8 @@
...item, ...item,
name: path.basename(sourcePath), name: path.basename(sourcePath),
state: item.data.customThumbnail === true ? 'custom' : 'ready', state: item.data.customThumbnail === true ? 'custom' : 'ready',
selected: item.data.customThumbnail !== true && !utils.isTextureFile(sourcePath),
allowOverwrite: item.data.customThumbnail === true,
}); });
} catch (error) { } catch (error) {
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error }); rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
@@ -564,28 +591,53 @@
const list = document.getElementById('thumbDialogList'); const list = document.getElementById('thumbDialogList');
const confirm = document.getElementById('thumbDialogConfirm'); const confirm = document.getElementById('thumbDialogConfirm');
const cancel = document.getElementById('thumbDialogCancel'); const cancel = document.getElementById('thumbDialogCancel');
const ready = rows.filter((row) => row.state === 'ready'); const summary = document.getElementById('thumbDialogSummary');
const skipped = rows.length - ready.length; const selectable = rows.filter((row) => row.state !== 'unavailable');
document.getElementById('thumbDialogSummary').textContent = t('viewer.groupDialogSummary', {
generate: ready.length,
skip: skipped,
});
list.textContent = ''; list.textContent = '';
for (const row of rows) { for (const row of rows) {
const entry = document.createElement('div'); const entry = document.createElement('div');
entry.className = 'thumb-dialog-row' + (row.state === 'ready' ? '' : ' skipped'); entry.className = 'thumb-dialog-row' + (row.state === 'unavailable' ? ' skipped' : '');
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.className = 'thumb-dialog-check';
checkbox.checked = row.state !== 'unavailable' && row.selected === true;
checkbox.disabled = row.state === 'unavailable';
row.checkbox = checkbox;
const name = document.createElement('span'); const name = document.createElement('span');
name.className = 'thumb-dialog-name'; name.className = 'thumb-dialog-name';
name.textContent = row.name; name.textContent = row.name;
name.title = row.sourcePath; name.title = row.sourcePath;
const state = document.createElement('span'); const state = document.createElement('span');
state.className = 'thumb-dialog-state'; state.className = 'thumb-dialog-state';
state.textContent = t(row.state === 'ready' ? 'viewer.groupWillGenerate' state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.state === 'custom' ? 'viewer.groupSkipCustom' : 'viewer.groupUnavailable'); : row.selected ? 'viewer.groupWillGenerate'
entry.append(name, state); : row.state === 'custom' ? 'viewer.groupCustomNotSelected'
: row.state === 'ready' ? 'viewer.groupNotSelected' : 'viewer.groupUnavailable');
row.stateElement = state;
entry.append(checkbox, name, state);
list.appendChild(entry); list.appendChild(entry);
if (row.state !== 'unavailable') {
checkbox.addEventListener('change', () => {
row.selected = checkbox.checked;
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
: row.selected ? 'viewer.groupWillGenerate'
: row.state === 'custom' ? 'viewer.groupCustomNotSelected' : 'viewer.groupNotSelected');
updateSelection();
});
entry.addEventListener('click', (event) => {
if (event.target !== checkbox) checkbox.click();
});
}
} }
confirm.disabled = ready.length === 0; function updateSelection() {
const selected = selectable.filter((row) => row.selected);
summary.textContent = t('viewer.groupDialogSummary', {
generate: selected.length,
skip: rows.length - selected.length,
});
confirm.disabled = selected.length === 0;
}
updateSelection();
dialog.classList.remove('hidden'); dialog.classList.remove('hidden');
return new Promise((resolve) => { return new Promise((resolve) => {
const finish = (accepted) => { const finish = (accepted) => {
@@ -593,7 +645,7 @@
confirm.removeEventListener('click', accept); confirm.removeEventListener('click', accept);
cancel.removeEventListener('click', reject); cancel.removeEventListener('click', reject);
document.removeEventListener('keydown', keydown); document.removeEventListener('keydown', keydown);
resolve(accepted ? ready : []); resolve(accepted ? selectable.filter((row) => row.selected) : []);
}; };
const accept = () => finish(true); const accept = () => finish(true);
const reject = () => finish(false); const reject = () => finish(false);
@@ -650,39 +702,35 @@
if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; } if (!skeleton || !renderer || !activeAssets) { setStatus(t('viewer.noSkeletonForThumb')); return; }
const groupThumbBtn = document.getElementById('groupThumbBtn'); const groupThumbBtn = document.getElementById('groupThumbBtn');
groupThumbBtn.disabled = true; groupThumbBtn.disabled = true;
let tempPath = null;
try { try {
const rows = await collectGroupThumbnailItems(); const rows = await collectGroupThumbnailItems();
const targets = await confirmGroupThumbnail(rows); const selectedTargets = await confirmGroupThumbnail(rows);
// Updating the item that owns this preview causes Eagle to close/reload the
// viewer. Always do that item last so its teardown cannot abort the batch.
const targets = groupThumbnails.orderCurrentTargetLast(selectedTargets, filePath);
if (targets.length === 0) { if (targets.length === 0) {
if (rows.length && rows.every((row) => row.state !== 'ready')) setStatus(t('viewer.groupNoTargets'), 4000); if (rows.length && rows.every((row) => row.state === 'unavailable')) setStatus(t('viewer.groupNoTargets'), 4000);
return; return;
} }
setStatus(t('viewer.generating'), Infinity); setStatus(t('viewer.generating'), Infinity);
const rendered = await renderCurrentFrameThumbnail(); const rendered = await renderCurrentFrameThumbnail();
tempPath = path.join(os.tmpdir(), 'spineviewer-group-' + crypto.randomBytes(12).toString('hex') + rendered.extension); const result = await groupThumbnails.applyThumbnailTargets({
fs.writeFileSync(tempPath, rendered.buffer); targets,
let success = 0, skipped = rows.filter((row) => row.state !== 'ready').length, failed = 0; initialSkipped: rows.length - targets.length,
for (let index = 0; index < targets.length; index++) { rendered,
setStatus(t('viewer.groupGenerating', { current: index + 1, total: targets.length }), Infinity); getItem: getEagleItemForPath,
try { setThumbnail: setEagleCustomThumbnail,
// 确认后再读一次,避免在对话框打开期间覆盖用户新设的缩略图。 waitForApplied: waitForCustomThumbnail,
const fresh = await getEagleItemForPath(targets[index].sourcePath); notConfirmedMessage: t('viewer.groupApplyNotConfirmed'),
if (fresh.data.customThumbnail === true) { skipped++; continue; } onProgress: (current, total) => setStatus(t('viewer.groupGenerating', { current, total }), Infinity),
await setEagleCustomThumbnail(fresh.id, tempPath); onSuccess: (fresh) => log('用户确认批量更新关联资源缩略图: item=' + fresh.id),
success++; onError: (error) => log('关联资源缩略图失败: ' + ((error && error.stack) || error)),
log('用户确认批量更新资源组缩略图: item=' + fresh.id); });
} catch (error) { setStatus(t('viewer.groupComplete', { success: result.success, skip: result.skipped, failed: result.failed }), 6000);
failed++;
log('资源组缩略图失败: ' + ((error && error.stack) || error));
}
}
setStatus(t('viewer.groupComplete', { success, skip: skipped, failed }), 6000);
} catch (e) { } catch (e) {
setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000); setStatus(t('viewer.thumbnailFailed', { error: (e && e.message) || String(e) }), 6000);
log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e)); log('生成当前资源组缩略图失败: ' + ((e && e.stack) || e));
} finally { } finally {
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (e) { /* ignore */ }
groupThumbBtn.disabled = false; groupThumbBtn.disabled = false;
} }
} }