资源组更新缩略图
This commit is contained in:
1 parent
05c4fc4b90
commit
2a5fe0ce1f
27 files changed
+562
-161
No files matched your search
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. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为当前资源组生成缩略图…”会先明确列出受影响的骨骼、atlas 和贴图,得到确认后才批量生成,并始终跳过已有自定义缩略图的素材。
|
||||
4. 打开预览后,原有按钮可把当前帧设为当前素材的自定义缩略图。其左侧的“为关联资源生成缩略图”会列出同 stem 的骨骼、atlas 及已验证贴图;骨骼与 atlas 默认勾选,图片和已有自定义缩略图的素材默认不勾选;用户仍可主动选择并覆盖,原图无法由插件自动恢复。
|
||||
|
||||
## 安全与隐私
|
||||
|
||||
- Atlas 贴图只允许安全的相对路径;绝对路径、`..`、盘符、UNC、URI 和越界符号链接会被拒绝。
|
||||
- 跨素材配对只使用当前 Eagle 素材库中已枚举并验证的文件。
|
||||
- 单素材按钮只作用于当前素材。资源组按钮会先列出全部受影响素材并要求确认,且不会覆盖已有自定义缩略图。
|
||||
- 单素材按钮只作用于当前素材。资源组按钮会先列出全部受影响素材并要求确认,已有自定义缩略图默认不选中,只有用户明确勾选才会覆盖。
|
||||
|
||||
## 多语言
|
||||
|
||||
|
||||
@@ -20,18 +20,20 @@
|
||||
"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",
|
||||
"groupThumbnail": "Vorschaubilder für verknüpfte Ressourcen erstellen",
|
||||
"groupThumbnailTitle": "Verknüpfte Spine-Ressourcen prüfen und Vorschaubilder gesammelt erstellen",
|
||||
"groupDialogTitle": "Vorschaubilder für verknüpfte Ressourcen erstellen",
|
||||
"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",
|
||||
"groupConfirm": "Erstellen",
|
||||
"groupWillGenerate": "Wird erstellt",
|
||||
"groupNotSelected": "Nicht ausgewählt",
|
||||
"groupSkipCustom": "Übersprungen: eigenes Vorschaubild vorhanden",
|
||||
"groupUnavailable": "Übersprungen: nicht verifizierbar",
|
||||
"groupNoTargets": "In der aktuellen Ressourcengruppe gibt es keine geeigneten Vorschaubilder.",
|
||||
"groupGenerating": "Vorschaubilder werden erstellt {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle hat die Anwendung des Vorschaubilds nicht bestätigt",
|
||||
"groupComplete": "Fertig: {success} erfolgreich, {skip} übersprungen, {failed} fehlgeschlagen.",
|
||||
"versionTitle": "Spine-Version",
|
||||
"reloadTitle": "Ordner neu laden",
|
||||
@@ -89,6 +91,8 @@
|
||||
"loadFailed": "Laden fehlgeschlagen",
|
||||
"notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.",
|
||||
"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",
|
||||
"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",
|
||||
"groupThumbnail": "Generate thumbnails for related assets",
|
||||
"groupThumbnailTitle": "Review related Spine assets, then generate thumbnails in a batch",
|
||||
"groupDialogTitle": "Generate thumbnails for related assets",
|
||||
"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",
|
||||
"groupConfirm": "Generate",
|
||||
"groupWillGenerate": "Will generate",
|
||||
"groupWillReplaceCustom": "Will replace existing custom thumbnail",
|
||||
"groupCustomNotSelected": "Custom thumbnail exists (not selected)",
|
||||
"groupNotSelected": "Not selected",
|
||||
"groupSkipCustom": "Skipped: custom thumbnail exists",
|
||||
"groupUnavailable": "Skipped: could not verify",
|
||||
"groupNoTargets": "There are no eligible thumbnails in the current asset group.",
|
||||
"groupGenerating": "Generating asset-group thumbnails {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle did not confirm that the thumbnail was applied",
|
||||
"groupComplete": "Asset-group thumbnails complete: {success} succeeded, {skip} skipped, {failed} failed.",
|
||||
"versionTitle": "Spine version",
|
||||
"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",
|
||||
"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",
|
||||
"groupThumbnail": "Generar miniaturas de recursos relacionados",
|
||||
"groupThumbnailTitle": "Revisar los recursos Spine relacionados y generar miniaturas por lotes",
|
||||
"groupDialogTitle": "Generar miniaturas de recursos relacionados",
|
||||
"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",
|
||||
"groupConfirm": "Generar",
|
||||
"groupWillGenerate": "Se generará",
|
||||
"groupNotSelected": "No seleccionado",
|
||||
"groupSkipCustom": "Omitido: ya tiene miniatura personalizada",
|
||||
"groupUnavailable": "Omitido: no se pudo verificar",
|
||||
"groupNoTargets": "No hay miniaturas disponibles para generar en el grupo actual.",
|
||||
"groupGenerating": "Generando miniaturas {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle no confirmó que se aplicara la miniatura",
|
||||
"groupComplete": "Completado: {success} correctas, {skip} omitidas y {failed} fallidas.",
|
||||
"versionTitle": "Versión de Spine",
|
||||
"reloadTitle": "Recargar carpeta",
|
||||
@@ -89,6 +91,8 @@
|
||||
"loadFailed": "Error al cargar",
|
||||
"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)",
|
||||
"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 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
|
||||
"thumbnail": "現在の項目のサムネイルに設定",
|
||||
"thumbnailTitle": "現在のフレームをこの項目の 400×400 カスタムサムネイルに設定",
|
||||
"groupThumbnail": "現在のアセットグループのサムネイルを生成…",
|
||||
"groupThumbnailTitle": "現在の Spine アセットグループを確認して一括生成",
|
||||
"groupDialogTitle": "現在のアセットグループのサムネイルを生成",
|
||||
"groupThumbnail": "関連アセットのサムネイルを生成",
|
||||
"groupThumbnailTitle": "関連する Spine アセットを確認して一括生成",
|
||||
"groupDialogTitle": "関連アセットのサムネイルを生成",
|
||||
"groupDialogSummary": "{generate} 件を生成し、{skip} 件をスキップします。",
|
||||
"groupDialogNote": "下に明示された項目だけを更新します。カスタムサムネイルがある項目は必ずスキップします。",
|
||||
"groupDialogNote": "チェックした関連アセットだけにサムネイルを生成します。画像とカスタムサムネイルがあるアセットは初期状態で未選択です。後者を選ぶと既存のサムネイルが置き換えられ、プラグインでは自動復元できません。",
|
||||
"cancel": "キャンセル",
|
||||
"groupConfirm": "生成",
|
||||
"groupWillGenerate": "生成対象",
|
||||
"groupNotSelected": "未選択",
|
||||
"groupSkipCustom": "スキップ:カスタムサムネイルあり",
|
||||
"groupUnavailable": "スキップ:検証できません",
|
||||
"groupNoTargets": "現在のアセットグループに生成可能なサムネイルはありません。",
|
||||
"groupGenerating": "アセットグループのサムネイルを生成中 {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle でサムネイルの適用を確認できませんでした",
|
||||
"groupComplete": "完了:成功 {success}、スキップ {skip}、失敗 {failed}。",
|
||||
"versionTitle": "Spine バージョン",
|
||||
"reloadTitle": "フォルダーを再読み込み",
|
||||
@@ -89,6 +91,8 @@
|
||||
"loadFailed": "読み込みに失敗しました",
|
||||
"notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。",
|
||||
"truncated256": "\n\n(先頭 256 KB のみ表示)",
|
||||
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)"
|
||||
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)",
|
||||
"groupWillReplaceCustom": "既存のカスタムサムネイルを置き換えます",
|
||||
"groupCustomNotSelected": "カスタムサムネイルあり(未選択)"
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,20 @@
|
||||
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
|
||||
"thumbnail": "현재 항목 썸네일로 설정",
|
||||
"thumbnailTitle": "현재 프레임을 이 항목의 400×400 사용자 지정 썸네일로 설정",
|
||||
"groupThumbnail": "현재 자산 그룹 썸네일 생성…",
|
||||
"groupThumbnailTitle": "현재 Spine 자산 그룹을 확인한 후 일괄 생성",
|
||||
"groupDialogTitle": "현재 자산 그룹 썸네일 생성",
|
||||
"groupThumbnail": "관련 자산 썸네일 생성",
|
||||
"groupThumbnailTitle": "관련 Spine 자산을 확인한 후 일괄 생성",
|
||||
"groupDialogTitle": "관련 자산 썸네일 생성",
|
||||
"groupDialogSummary": "{generate}개를 생성하고 {skip}개를 건너뜁니다.",
|
||||
"groupDialogNote": "아래에 명시된 항목만 업데이트합니다. 기존 사용자 지정 썸네일이 있는 항목은 항상 건너뜁니다.",
|
||||
"groupDialogNote": "선택한 관련 자산에만 썸네일을 생성합니다. 이미지와 사용자 지정 썸네일이 있는 자산은 기본적으로 선택하지 않습니다. 후자를 선택하면 기존 썸네일이 교체되며 플러그인에서 자동 복원할 수 없습니다.",
|
||||
"cancel": "취소",
|
||||
"groupConfirm": "생성",
|
||||
"groupWillGenerate": "생성 예정",
|
||||
"groupNotSelected": "선택하지 않음",
|
||||
"groupSkipCustom": "건너뛰: 사용자 지정 썸네일 있음",
|
||||
"groupUnavailable": "건너뛰: 확인할 수 없음",
|
||||
"groupNoTargets": "현재 자산 그룹에 생성할 수 있는 썸네일이 없습니다.",
|
||||
"groupGenerating": "자산 그룹 썸네일 생성 중 {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle에서 썸네일 적용을 확인하지 못했습니다",
|
||||
"groupComplete": "완료: 성공 {success}, 건너뛰 {skip}, 실패 {failed}.",
|
||||
"versionTitle": "Spine 버전",
|
||||
"reloadTitle": "폴더 다시 불러오기",
|
||||
@@ -89,6 +91,8 @@
|
||||
"loadFailed": "불러오기 실패",
|
||||
"notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.",
|
||||
"truncated256": "\n\n(앞부분 256 KB만 표시)",
|
||||
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)"
|
||||
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)",
|
||||
"groupWillReplaceCustom": "기존 사용자 지정 썸네일 교체",
|
||||
"groupCustomNotSelected": "사용자 지정 썸네일 있음(선택 안 함)"
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,20 @@
|
||||
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
|
||||
"thumbnail": "Сделать миниатюрой элемента",
|
||||
"thumbnailTitle": "Установить текущий кадр как пользовательскую миниатюру 400×400 для этого элемента",
|
||||
"groupThumbnail": "Создать миниатюры для текущей группы…",
|
||||
"groupThumbnailTitle": "Проверить группу Spine и создать миниатюры пакетно",
|
||||
"groupDialogTitle": "Создать миниатюры для текущей группы",
|
||||
"groupThumbnail": "Создать миниатюры для связанных ресурсов",
|
||||
"groupThumbnailTitle": "Проверить связанные ресурсы Spine и создать миниатюры",
|
||||
"groupDialogTitle": "Создать миниатюры для связанных ресурсов",
|
||||
"groupDialogSummary": "Будет создано: {generate}; пропущено: {skip}.",
|
||||
"groupDialogNote": "Будут обновлены только явно указанные ниже элементы. Элементы с пользовательскими миниатюрами всегда пропускаются.",
|
||||
"groupDialogNote": "Миниатюры будут созданы только для отмеченных связанных ресурсов. Изображения и ресурсы со своими миниатюрами по умолчанию не отмечены. Выбор последних заменит существующую миниатюру, и плагин не сможет восстановить её автоматически.",
|
||||
"cancel": "Отмена",
|
||||
"groupConfirm": "Создать",
|
||||
"groupWillGenerate": "Будет создано",
|
||||
"groupNotSelected": "Не выбрано",
|
||||
"groupSkipCustom": "Пропущено: есть своя миниатюра",
|
||||
"groupUnavailable": "Пропущено: не удалось проверить",
|
||||
"groupNoTargets": "В текущей группе нет подходящих миниатюр.",
|
||||
"groupGenerating": "Создание миниатюр {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle не подтвердил применение миниатюры",
|
||||
"groupComplete": "Готово: {success} успешно, {skip} пропущено, {failed} с ошибкой.",
|
||||
"versionTitle": "Версия Spine",
|
||||
"reloadTitle": "Перезагрузить папку",
|
||||
@@ -89,6 +91,8 @@
|
||||
"loadFailed": "Не удалось загрузить",
|
||||
"notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.",
|
||||
"truncated256": "\n\n(показаны только первые 256 КБ)",
|
||||
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)"
|
||||
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)",
|
||||
"groupWillReplaceCustom": "Заменит существующую миниатюру",
|
||||
"groupCustomNotSelected": "Есть своя миниатюра (не выбрано)"
|
||||
}
|
||||
}
|
||||
@@ -20,18 +20,22 @@
|
||||
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
|
||||
"thumbnail": "设为当前素材缩略图",
|
||||
"thumbnailTitle": "将当前帧设为当前素材的 400×400 自定义缩略图",
|
||||
"groupThumbnail": "为当前资源组生成缩略图…",
|
||||
"groupThumbnailTitle": "列出当前 Spine 资源组,确认后批量生成缩略图",
|
||||
"groupDialogTitle": "为当前资源组生成缩略图",
|
||||
"groupThumbnail": "为关联资源生成缩略图",
|
||||
"groupThumbnailTitle": "列出当前 Spine 关联资源,确认后批量生成缩略图",
|
||||
"groupDialogTitle": "为关联资源生成缩略图",
|
||||
"groupDialogSummary": "将生成 {generate} 项,跳过 {skip} 项。",
|
||||
"groupDialogNote": "只会更新下列明确列出的素材;已有自定义缩略图的素材会自动跳过。",
|
||||
"groupDialogNote": "只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。",
|
||||
"cancel": "取消",
|
||||
"groupConfirm": "确认生成",
|
||||
"groupWillGenerate": "将生成",
|
||||
"groupWillReplaceCustom": "将替换已有自定义缩略图",
|
||||
"groupCustomNotSelected": "已有自定义缩略图(未选择)",
|
||||
"groupNotSelected": "未选择",
|
||||
"groupSkipCustom": "跳过:已有自定义缩略图",
|
||||
"groupUnavailable": "跳过:无法验证",
|
||||
"groupNoTargets": "当前资源组没有可生成的缩略图。",
|
||||
"groupGenerating": "正在生成资源组缩略图 {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle 未确认缩略图已应用",
|
||||
"groupComplete": "资源组缩略图已完成:成功 {success},跳过 {skip},失败 {failed}。",
|
||||
"versionTitle": "Spine 版本",
|
||||
"reloadTitle": "重新加载目录",
|
||||
|
||||
@@ -20,18 +20,22 @@
|
||||
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
|
||||
"thumbnail": "設為目前素材縮圖",
|
||||
"thumbnailTitle": "將目前影格設為目前素材的 400×400 自訂縮圖",
|
||||
"groupThumbnail": "為目前資源組產生縮圖…",
|
||||
"groupThumbnailTitle": "列出目前 Spine 資源組,確認後批次產生縮圖",
|
||||
"groupDialogTitle": "為目前資源組產生縮圖",
|
||||
"groupThumbnail": "為關聯資源產生縮圖",
|
||||
"groupThumbnailTitle": "列出目前 Spine 關聯資源,確認後批次產生縮圖",
|
||||
"groupDialogTitle": "為關聯資源產生縮圖",
|
||||
"groupDialogSummary": "將產生 {generate} 項,跳過 {skip} 項。",
|
||||
"groupDialogNote": "只會更新下列明確列出的素材;已有自訂縮圖的素材會自動跳過。",
|
||||
"groupDialogNote": "只會為勾選的關聯資源產生縮圖。圖片和已有自訂縮圖的資源預設不勾選;若勾選後者,原縮圖將被取代且外掛無法自動復原。",
|
||||
"cancel": "取消",
|
||||
"groupConfirm": "確認產生",
|
||||
"groupWillGenerate": "將產生",
|
||||
"groupWillReplaceCustom": "將取代已有自訂縮圖",
|
||||
"groupCustomNotSelected": "已有自訂縮圖(未選擇)",
|
||||
"groupNotSelected": "未選擇",
|
||||
"groupSkipCustom": "跳過:已有自訂縮圖",
|
||||
"groupUnavailable": "跳過:無法驗證",
|
||||
"groupNoTargets": "目前資源組沒有可產生的縮圖。",
|
||||
"groupGenerating": "正在產生資源組縮圖 {current}/{total}…",
|
||||
"groupApplyNotConfirmed": "Eagle 未確認縮圖已套用",
|
||||
"groupComplete": "資源組縮圖已完成:成功 {success},跳過 {skip},失敗 {failed}。",
|
||||
"versionTitle": "Spine 版本",
|
||||
"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);
|
||||
}
|
||||
.thumb-dialog-row:last-child { border-bottom: 0; }
|
||||
.thumb-dialog-check { width: 17px; height: 17px; margin: 0; flex: none; accent-color: var(--accent); }
|
||||
.thumb-dialog-name { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.thumb-dialog-state { flex: none; color: var(--accent); font-size: 12px; }
|
||||
.thumb-dialog-row.skipped .thumb-dialog-name,
|
||||
|
||||
@@ -25,7 +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="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>
|
||||
@@ -61,10 +61,10 @@
|
||||
</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="thumbDialogTitle" class="thumb-dialog-title" data-i18n="viewer.groupDialogTitle">为关联资源生成缩略图</div>
|
||||
<div class="thumb-dialog-note" data-i18n="viewer.groupDialogNote">只会为勾选的关联资源生成缩略图。图片和已有自定义缩略图的资源默认不勾选;若勾选后者,原缩略图将被替换且插件无法自动恢复。</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">
|
||||
<button id="thumbDialogCancel" data-i18n="viewer.cancel">取消</button>
|
||||
<button id="thumbDialogConfirm" class="primary" data-i18n="viewer.groupConfirm">确认生成</button>
|
||||
@@ -82,6 +82,7 @@
|
||||
const utils = require('../js/spine-utils.js');
|
||||
const spineBadge = require('../js/spine-badge.js');
|
||||
const clippingRenderer = require('../js/spine-canvas-clipping.js');
|
||||
const groupThumbnails = require('../js/spine-group-thumbnails.js');
|
||||
let spineBadgeImagePromise = null;
|
||||
|
||||
function loadSpineBadgeImage() {
|
||||
@@ -508,7 +509,9 @@
|
||||
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));
|
||||
const response = await fetch('http://localhost:41595/api/item/info?id=' + encodeURIComponent(itemId) + '&_=' + Date.now(), {
|
||||
cache: 'no-store',
|
||||
});
|
||||
let result = null;
|
||||
try { result = await response.json(); } catch (e) { /* use HTTP status below */ }
|
||||
if (!response.ok || !result || result.status !== 'success' || !result.data) {
|
||||
@@ -526,18 +529,40 @@
|
||||
});
|
||||
let result = null;
|
||||
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);
|
||||
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() {
|
||||
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([
|
||||
activeAssets.skeletonPath,
|
||||
activeAssets.atlasPath,
|
||||
...relatedDataPaths,
|
||||
...Object.values(activeAssets.texturePaths || {}),
|
||||
].filter(Boolean).map((file) => path.resolve(file)))];
|
||||
}
|
||||
@@ -551,6 +576,8 @@
|
||||
...item,
|
||||
name: path.basename(sourcePath),
|
||||
state: item.data.customThumbnail === true ? 'custom' : 'ready',
|
||||
selected: item.data.customThumbnail !== true && !utils.isTextureFile(sourcePath),
|
||||
allowOverwrite: item.data.customThumbnail === true,
|
||||
});
|
||||
} catch (error) {
|
||||
rows.push({ sourcePath, name: path.basename(sourcePath), state: 'unavailable', error });
|
||||
@@ -564,28 +591,53 @@
|
||||
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,
|
||||
});
|
||||
const summary = document.getElementById('thumbDialogSummary');
|
||||
const selectable = rows.filter((row) => row.state !== 'unavailable');
|
||||
list.textContent = '';
|
||||
for (const row of rows) {
|
||||
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');
|
||||
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);
|
||||
state.textContent = t(row.selected && row.state === 'custom' ? 'viewer.groupWillReplaceCustom'
|
||||
: row.selected ? 'viewer.groupWillGenerate'
|
||||
: row.state === 'custom' ? 'viewer.groupCustomNotSelected'
|
||||
: row.state === 'ready' ? 'viewer.groupNotSelected' : 'viewer.groupUnavailable');
|
||||
row.stateElement = state;
|
||||
entry.append(checkbox, name, state);
|
||||
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');
|
||||
return new Promise((resolve) => {
|
||||
const finish = (accepted) => {
|
||||
@@ -593,7 +645,7 @@
|
||||
confirm.removeEventListener('click', accept);
|
||||
cancel.removeEventListener('click', reject);
|
||||
document.removeEventListener('keydown', keydown);
|
||||
resolve(accepted ? ready : []);
|
||||
resolve(accepted ? selectable.filter((row) => row.selected) : []);
|
||||
};
|
||||
const accept = () => finish(true);
|
||||
const reject = () => finish(false);
|
||||
@@ -650,39 +702,35 @@
|
||||
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);
|
||||
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 (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;
|
||||
}
|
||||
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);
|
||||
const result = await groupThumbnails.applyThumbnailTargets({
|
||||
targets,
|
||||
initialSkipped: rows.length - targets.length,
|
||||
rendered,
|
||||
getItem: getEagleItemForPath,
|
||||
setThumbnail: setEagleCustomThumbnail,
|
||||
waitForApplied: waitForCustomThumbnail,
|
||||
notConfirmedMessage: t('viewer.groupApplyNotConfirmed'),
|
||||
onProgress: (current, total) => setStatus(t('viewer.groupGenerating', { current, total }), Infinity),
|
||||
onSuccess: (fresh) => log('用户确认批量更新关联资源缩略图: item=' + fresh.id),
|
||||
onError: (error) => log('关联资源缩略图失败: ' + ((error && error.stack) || error)),
|
||||
});
|
||||
setStatus(t('viewer.groupComplete', { success: result.success, skip: result.skipped, failed: result.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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user