11 Commits
Author SHA1 Message Date
tianmo 725f7d5e24 eagle包更新 2026-11-06 17:09:21 +08:00
tianmo d0112e0016 修正 2026-11-06 14:11:49 +08:00
tianmo e7f800aa8d 修正识别问题 2026-11-06 10:44:34 +08:00
tianmo d02370d6bd 自动缩略图插件审核修改 2026-11-05 19:35:14 +08:00
tianmo f4e69707c3 网页优化 2026-10-31 15:26:22 +08:00
tianmo 49dd3b9472 物料修正 2026-10-31 10:38:08 +08:00
tianmo 0aea133888 进度条弹窗重新修复 2026-10-30 16:49:52 +08:00
tianmo 55dc52be37 进度修复 2026-10-30 16:31:27 +08:00
tianmo 63663f3bb7 进度修复 2026-10-30 16:26:18 +08:00
tianmo 5799d5d049 进度bug修复 2026-10-30 16:08:36 +08:00
tianmo 406bf21b2a logo修正 2026-10-29 15:46:31 +08:00
79 changed files with 2639 additions and 388 deletions

No files matched your search

@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine-Vorschaubilder",
"noticeIntro": "Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente ohne eigenes Vorschaubild.",
"noticeSafety": "Nur neu importierte Elemente werden verarbeitet. Vorhandene Elemente werden weder durchsucht noch geändert, und eigene Vorschaubilder werden nie überschrieben.",
"noticeModes": "Animiert verwendet die erste Animation und exportiert WebP. Statisch verwendet immer die Setup-Pose.",
"noticeDefault": "Die automatische Erstellung ist standardmäßig ausgeschaltet. Aktiviere sie bei Bedarf unten.",
"noticeIntro": "Funktion: Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente, die noch kein Vorschaubild haben.",
"noticeSafety": "1. Die automatische Erstellung ist standardmäßig ausgeschaltet und muss unten manuell aktiviert werden. Nur in diesem Lauf neu importierte Elemente werden verarbeitet; bereits festgelegte eigene Vorschaubilder werden nie überschrieben.",
"noticeCompound": "2. Bei Elementen mit zusammengesetzter Endung werden die Felder Name und Typ in Eagle erst nach Bestätigung der aktuellen und neuen Werte geändert. Die Originalwerte werden gespeichert und können wiederhergestellt werden.",
"autoToggle": "Beim Import Vorschaubilder erstellen",
"clear": "Protokoll leeren",
"restoreNames": "Namen/Typen wiederherstellen",
"compoundConfirmTitle": "Änderung zusammengesetzter Endungen bestätigen",
"compoundConfirmIntro": "Aufgrund der Besonderheit von Dateien mit zusammengesetzter Endung muss Eagle bei diesen Elementen die Felder Name und Typ ändern, damit die erzeugten Vorschaubilder angezeigt werden können. Die Originalwerte werden gespeichert und können in diesem Plugin wiederhergestellt werden.",
"compoundOldValue": "Aktuell",
"compoundNewValue": "Neu",
"compoundSkip": "Überspringen und fortfahren",
"compoundConfirm": "Änderungen anwenden",
"compoundConfirmSelected": "Auswahl anwenden ({count})",
"compoundSkipped": "{label} übersprungen: Änderung der zusammengesetzten Endung nicht bestätigt",
"compoundSourceMismatch": "{label} übersprungen: Eagle-Name {current} stimmt nicht mit Quelldatei {source} überein",
"compoundCheckFailed": "Zusammengesetzte Endung für {label} konnte nicht geprüft werden: {error}",
"compoundNeedsConfirmation": "{label} übersprungen: Felder wurden nach der Bestätigung geändert; bitte erneut prüfen",
"compoundBackupUnavailable": "Zusammengesetzte Endungen können nicht geändert werden, weil die Originalwerte nicht gespeichert werden konnten",
"compoundRolledBack": "Vorschaubilderstellung fehlgeschlagen; Name und Typ für {label} wurden wiederhergestellt",
"compoundRollbackFailed": "Vorschaubilderstellung fehlgeschlagen und {label} konnte nicht automatisch wiederhergestellt werden: {error}",
"restoreEmpty": "Keine gespeicherten Namens-/Typänderungen zum Wiederherstellen",
"restoreTitle": "Elemente zum Wiederherstellen auswählen",
"restoreIntro": "Für diese Elemente sind Originalwerte gespeichert. Wiederherstellbare Elemente sind standardmäßig alle ausgewählt; vom Benutzer geänderte oder nicht lesbare Elemente können nicht ausgewählt werden.",
"restoreCurrent": "Aktuell",
"restoreTarget": "Wiederherstellen zu",
"restoreStatusReady": "Das Element wurde geändert zu {fields}",
"restoreStatusAlreadyOriginal": "Hat bereits den Originalwert; die Bestätigung entfernt nur die Sicherung",
"restoreStatusChanged": "Die aktuellen Felder wurden vom Benutzer geändert; wird übersprungen",
"restoreStatusMissing": "Element nicht gefunden und kann nicht wiederhergestellt werden",
"restoreStatusError": "Aktuelle Werte konnten nicht gelesen werden; Wiederherstellung nicht möglich",
"restoreCancel": "Abbrechen",
"restoreSelected": "Auswahl wiederherstellen ({count})",
"restoreComplete": "Wiederherstellung abgeschlossen: {success} erfolgreich, {skipped} übersprungen, {failed} fehlgeschlagen.",
"restoreFailed": "Namen/Typen konnten nicht wiederhergestellt werden: {error}",
"modeLabel": "Erstellungsmodus",
"modeAnimated": "Animiert (erste Animation)",
"modeStatic": "Statisch (Setup-Pose)",
+32 -4
View File
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine Thumbnails",
"noticeIntro": "After importing Spine assets (.skel / .json + .atlas + textures), this plugin creates a skeleton preview for items that do not yet have a custom thumbnail.",
"noticeSafety": "Only newly imported items are processed. Existing items are not scanned or changed, and custom thumbnails are never overwritten.",
"noticeModes": "Animated uses the first animation and exports WebP. Still always uses the setup pose.",
"noticeDefault": "Automatic generation is off by default. Enable it below when needed.",
"noticeIntro": "Feature: After importing Spine assets (.skel / .json + .atlas + textures), this plugin generates a skeleton preview for items that do not yet have a thumbnail.",
"noticeSafety": "1. Automatic generation is off by default and must be enabled below. It only processes items newly imported in this run and never overwrites custom thumbnails that are already set.",
"noticeCompound": "2. For items with compound suffixes, Eagle name/type fields are adjusted only after the current and new values are confirmed. Original values are saved and can be restored.",
"autoToggle": "Generate thumbnails on import",
"clear": "Clear log",
"restoreNames": "Restore names/types",
"compoundConfirmTitle": "Confirm compound suffix change",
"compoundConfirmIntro": "Because compound-suffix files require special handling, Eagle must adjust the name and type fields for these items so the generated thumbnails can be displayed. Original values will be saved and can be restored from this plugin.",
"compoundOldValue": "Current",
"compoundNewValue": "New",
"compoundSkip": "Skip and continue",
"compoundConfirm": "Apply changes",
"compoundConfirmSelected": "Apply selected ({count})",
"compoundSkipped": "Skipped {label}: compound suffix change was not approved",
"compoundSourceMismatch": "Skipped {label}: Eagle name {current} does not match source file {source}",
"compoundCheckFailed": "Could not check the compound suffix for {label}: {error}",
"compoundNeedsConfirmation": "Skipped {label}: fields changed after confirmation; select it again for a new review",
"compoundBackupUnavailable": "Compound suffixes cannot be changed because the original values could not be saved",
"compoundRolledBack": "Thumbnail generation failed; restored the name and type for {label}",
"compoundRollbackFailed": "Thumbnail generation failed and {label} could not be restored automatically: {error}",
"restoreEmpty": "There are no saved name/type changes to restore",
"restoreTitle": "Select items to restore",
"restoreIntro": "These items have saved original values. All restorable items are selected by default; items changed by the user or unavailable for reading cannot be selected.",
"restoreCurrent": "Current",
"restoreTarget": "Restore to",
"restoreStatusReady": "Item was modified to {fields}",
"restoreStatusAlreadyOriginal": "Already has the original value; confirming will only remove the backup",
"restoreStatusChanged": "Current fields were changed by the user; this item will be skipped",
"restoreStatusMissing": "Item not found and cannot be restored",
"restoreStatusError": "Current values could not be read and cannot be restored",
"restoreCancel": "Cancel",
"restoreSelected": "Restore selected ({count})",
"restoreComplete": "Restore complete: {success} succeeded, {skipped} skipped, {failed} failed.",
"restoreFailed": "Failed to restore names/types: {error}",
"modeLabel": "Generation mode",
"modeAnimated": "Animated (first animation)",
"modeStatic": "Still (setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Miniaturas de Spine",
"noticeIntro": "Después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento crea una vista previa del esqueleto para los elementos que aún no tienen una miniatura personalizada.",
"noticeSafety": "Solo se procesan los elementos recién importados. No se buscan ni modifican elementos existentes y nunca se sobrescriben las miniaturas personalizadas.",
"noticeModes": "Animado usa la primera animación y exporta WebP. Estático siempre usa la pose de configuración.",
"noticeDefault": "La generación automática está desactivada de forma predeterminada. Actívala abajo cuando la necesites.",
"noticeIntro": "Función: después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento genera una vista previa del esqueleto para los elementos que aún no tienen una miniatura.",
"noticeSafety": "1. La generación automática está desactivada de forma predeterminada y debe activarse abajo manualmente. Solo procesa los elementos recién importados en esta ejecución y nunca sobrescribe miniaturas personalizadas ya configuradas.",
"noticeCompound": "2. En los elementos con extensión compuesta, los campos de nombre y tipo de Eagle solo se ajustan después de confirmar los valores actuales y nuevos. Los valores originales se guardan y pueden restaurarse.",
"autoToggle": "Generar miniaturas al importar",
"clear": "Borrar registro",
"restoreNames": "Restaurar nombres/tipos",
"compoundConfirmTitle": "Confirmar cambio de extensión compuesta",
"compoundConfirmIntro": "Debido a la naturaleza especial de los archivos con extensión compuesta, Eagle debe ajustar los campos de nombre y tipo de estos elementos para mostrar las miniaturas generadas. Los valores originales se guardarán y podrán restaurarse desde este complemento.",
"compoundOldValue": "Actual",
"compoundNewValue": "Nuevo",
"compoundSkip": "Omitir y continuar",
"compoundConfirm": "Aplicar cambios",
"compoundConfirmSelected": "Aplicar seleccionados ({count})",
"compoundSkipped": "Se omitió {label}: no se aprobó el cambio de extensión compuesta",
"compoundSourceMismatch": "Se omitió {label}: el nombre de Eagle {current} no coincide con el archivo de origen {source}",
"compoundCheckFailed": "No se pudo comprobar la extensión compuesta de {label}: {error}",
"compoundNeedsConfirmation": "Se omitió {label}: los campos cambiaron después de confirmar; vuelve a revisarlo",
"compoundBackupUnavailable": "No se pueden cambiar extensiones compuestas porque no se pudieron guardar los valores originales",
"compoundRolledBack": "La generación falló; se restauraron el nombre y el tipo de {label}",
"compoundRollbackFailed": "La generación falló y no se pudo restaurar {label} automáticamente: {error}",
"restoreEmpty": "No hay cambios de nombre/tipo guardados para restaurar",
"restoreTitle": "Seleccionar elementos para restaurar",
"restoreIntro": "Estos elementos tienen valores originales guardados. Los elementos restaurables se seleccionan todos de forma predeterminada; los modificados por el usuario o ilegibles no se pueden seleccionar.",
"restoreCurrent": "Actual",
"restoreTarget": "Restaurar a",
"restoreStatusReady": "El elemento se modificó a {fields}",
"restoreStatusAlreadyOriginal": "Ya tiene el valor original; al confirmar solo se eliminará la copia de seguridad",
"restoreStatusChanged": "Los campos actuales fueron modificados por el usuario y se omitirán",
"restoreStatusMissing": "El elemento no existe y no se puede restaurar",
"restoreStatusError": "No se pudieron leer los valores actuales y no se puede restaurar",
"restoreCancel": "Cancelar",
"restoreSelected": "Restaurar seleccionados ({count})",
"restoreComplete": "Restauración completada: {success} correctos, {skipped} omitidos, {failed} fallidos.",
"restoreFailed": "No se pudieron restaurar nombres/tipos: {error}",
"modeLabel": "Modo de generación",
"modeAnimated": "Animado (primera animación)",
"modeStatic": "Estático (pose de configuración)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spineサムネイル",
"noticeIntro": "Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込んだ後、カスタムサムネイルがないアイテムにスケルトンプレビューを生成します。",
"noticeSafety": "今回新しく読み込んだアイテムだけを処理し、既存アイテムの走査や変更、カスタムサムネイルの上書きは行いません。",
"noticeModes": "アニメーションは最初のアニメーションを WebP に変換します。静止画は常に setup pose を使用します。",
"noticeDefault": "自動生成は既定でオフです。必要なときに下で有効にしてください。",
"noticeIntro": "機能:Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込むと、サムネイル未設定のアイテムにスケルトンプレビューを生成します。",
"noticeSafety": "1. 自動生成は既定でオフで、下で手動で有効にする必要があります。今回新しく読み込んだアイテムだけを処理し、既に設定されたカスタムサムネイルは上書きしません。",
"noticeCompound": "2. 複合拡張子の項目は、現在値と新しい値を確認した場合にのみ Eagle の名前/種類フィールドを変更します。元の値は保存され、復元できます。",
"autoToggle": "読み込み時にサムネイルを自動生成",
"clear": "ログを消去",
"restoreNames": "名前/種類を復元",
"compoundConfirmTitle": "複合拡張子の変更を確認",
"compoundConfirmIntro": "複合拡張子ファイルの特性上、生成したサムネイルを表示するためには Eagle でこれらの素材の名前と種類フィールドを変更する必要があります。元の値は保存され、このプラグインから復元できます。",
"compoundOldValue": "変更前",
"compoundNewValue": "変更後",
"compoundSkip": "スキップして続行",
"compoundConfirm": "変更を適用",
"compoundConfirmSelected": "選択項目を適用({count})",
"compoundSkipped": "{label} をスキップ:複合拡張子の変更が承認されていません",
"compoundSourceMismatch": "{label} をスキップ:Eagle の名前 {current} が元ファイル {source} と一致しません",
"compoundCheckFailed": "{label} の複合拡張子を確認できませんでした:{error}",
"compoundNeedsConfirmation": "{label} をスキップ:確認後にフィールドが変更されたため、次回もう一度確認してください",
"compoundBackupUnavailable": "元の値を保存できないため、複合拡張子を変更できません",
"compoundRolledBack": "サムネイル生成に失敗したため、{label} の名前と種類を復元しました",
"compoundRollbackFailed": "サムネイル生成に失敗し、{label} を自動復元できませんでした:{error}",
"restoreEmpty": "復元できる名前/種類の変更はありません",
"restoreTitle": "復元する素材を選択",
"restoreIntro": "元の値が保存されている素材です。復元可能な項目は既定ですべて選択されます。後からユーザーが変更した素材や読み取れない素材は選択できません。",
"restoreCurrent": "現在",
"restoreTarget": "復元後",
"restoreStatusReady": "素材は {fields} に変更されています",
"restoreStatusAlreadyOriginal": "すでに元の値です。確認するとバックアップのみ削除します",
"restoreStatusChanged": "現在のフィールドはユーザーが変更済みのためスキップします",
"restoreStatusMissing": "素材が見つからないため復元できません",
"restoreStatusError": "現在値を読み取れないため復元できません",
"restoreCancel": "キャンセル",
"restoreSelected": "選択項目を復元({count})",
"restoreComplete": "復元完了:成功 {success}、スキップ {skipped}、失敗 {failed}。",
"restoreFailed": "名前/種類の復元に失敗しました:{error}",
"modeLabel": "生成方式",
"modeAnimated": "アニメーション(最初のアニメーション)",
"modeStatic": "静止画(setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine 썸네일",
"noticeIntro": "Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져온 뒤 사용자 지정 썸네일이 없는 항목에 스켈레톤 미리보기를 생성합니다.",
"noticeSafety": "이번에 새로 가져온 항목만 처리합니다. 기존 항목을 검색하거나 수정하지 않으며 사용자 지정 썸네일을 덮어쓰지 않습니다.",
"noticeModes": "애니메이션은 첫 번째 애니메이션을 WebP로 만듭니다. 정지 이미지는 항상 setup pose를 사용합니다.",
"noticeDefault": "자동 생성은 기본적으로 꺼져 있습니다. 필요할 때 아래에서 켜세요.",
"noticeIntro": "기능: Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져오면 썸네일이 아직 설정되지 않은 항목에 스켈레톤 미리보기를 생성합니다.",
"noticeSafety": "1. 자동 생성은 기본적으로 꺼져 있으며 아래에서 직접 켜야 합니다. 이번에 새로 가져온 항목만 처리하고 이미 설정된 사용자 지정 썸네일은 덮어쓰지 않습니다.",
"noticeCompound": "2. 복합 확장자 항목은 현재 값과 새 값을 확인한 경우에만 Eagle 이름/유형 필드를 변경합니다. 원래 값은 저장되며 복원할 수 있습니다.",
"autoToggle": "가져올 때 썸네일 자동 생성",
"clear": "로그 지우기",
"restoreNames": "이름/유형 복원",
"compoundConfirmTitle": "복합 확장자 변경 확인",
"compoundConfirmIntro": "복합 확장자 파일의 특성상 생성된 썸네일을 표시하려면 Eagle에서 이 항목들의 이름과 유형 필드를 조정해야 합니다. 원래 값은 저장되며 이 플러그인에서 복원할 수 있습니다.",
"compoundOldValue": "현재 값",
"compoundNewValue": "새 값",
"compoundSkip": "건너뛰고 계속",
"compoundConfirm": "변경 적용",
"compoundConfirmSelected": "선택 항목 적용({count})",
"compoundSkipped": "{label} 건너뜀: 복합 확장자 변경이 승인되지 않았습니다",
"compoundSourceMismatch": "{label} 건너뜀: Eagle 이름 {current}이(가) 실제 원본 파일 {source}과(와) 일치하지 않습니다",
"compoundCheckFailed": "{label}의 복합 확장자를 확인할 수 없습니다: {error}",
"compoundNeedsConfirmation": "{label} 건너뜀: 확인 후 필드가 변경되었습니다. 다음에 다시 확인하세요",
"compoundBackupUnavailable": "원래 값을 저장할 수 없어 복합 확장자를 변경할 수 없습니다",
"compoundRolledBack": "썸네일 생성에 실패하여 {label}의 이름과 유형을 복원했습니다",
"compoundRollbackFailed": "썸네일 생성에 실패했고 {label}을(를) 자동 복원할 수 없습니다: {error}",
"restoreEmpty": "복원할 이름/유형 변경이 없습니다",
"restoreTitle": "복원할 항목 선택",
"restoreIntro": "원래 값이 저장된 항목입니다. 복원 가능한 항목은 기본적으로 모두 선택됩니다. 이후 사용자가 변경했거나 읽을 수 없는 항목은 선택할 수 없습니다.",
"restoreCurrent": "현재",
"restoreTarget": "복원 값",
"restoreStatusReady": "항목이 {fields} 값으로 변경되었습니다",
"restoreStatusAlreadyOriginal": "이미 원래 값입니다. 확인하면 백업만 정리합니다",
"restoreStatusChanged": "현재 필드가 사용자에 의해 변경되어 건너뜁니다",
"restoreStatusMissing": "항목을 찾을 수 없어 복원할 수 없습니다",
"restoreStatusError": "현재 값을 읽을 수 없어 복원할 수 없습니다",
"restoreCancel": "취소",
"restoreSelected": "선택 항목 복원({count})",
"restoreComplete": "복원 완료: 성공 {success}, 건너뜀 {skipped}, 실패 {failed}.",
"restoreFailed": "이름/유형 복원 실패: {error}",
"modeLabel": "생성 방식",
"modeAnimated": "애니메이션(첫 번째 애니메이션)",
"modeStatic": "정지 이미지(setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Миниатюры Spine",
"noticeIntro": "После импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов без пользовательской миниатюры.",
"noticeSafety": "Обрабатываются только новые элементы. Существующие элементы не сканируются и не изменяются, пользовательские миниатюры никогда не перезаписываются.",
"noticeModes": "Анимированный режим использует первую анимацию и экспортирует WebP. Статический всегда использует setup pose.",
"noticeDefault": "Автоматическое создание по умолчанию отключено. Включите его ниже при необходимости.",
"noticeIntro": "Функция: после импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов, у которых ещё нет миниатюры.",
"noticeSafety": "1. Автоматическое создание по умолчанию отключено и должно быть включено вручную ниже. Обрабатываются только элементы, импортированные в текущем запуске; уже заданные пользовательские миниатюры не перезаписываются.",
"noticeCompound": "2. Поля имени и типа Eagle для элементов с составными расширениями изменяются только после подтверждения текущих и новых значений. Исходные значения сохраняются и могут быть восстановлены.",
"autoToggle": "Создавать миниатюры при импорте",
"clear": "Очистить журнал",
"restoreNames": "Восстановить имена/типы",
"compoundConfirmTitle": "Подтвердить изменение составного расширения",
"compoundConfirmIntro": "Из-за особенностей файлов с составными расширениями Eagle должен изменить поля имени и типа этих элементов, чтобы показывать созданные миниатюры. Исходные значения будут сохранены и доступны для восстановления в плагине.",
"compoundOldValue": "Сейчас",
"compoundNewValue": "Новое",
"compoundSkip": "Пропустить и продолжить",
"compoundConfirm": "Применить изменения",
"compoundConfirmSelected": "Применить выбранные ({count})",
"compoundSkipped": "{label} пропущен: изменение составного расширения не подтверждено",
"compoundSourceMismatch": "{label} пропущен: имя Eagle {current} не совпадает с исходным файлом {source}",
"compoundCheckFailed": "Не удалось проверить составное расширение {label}: {error}",
"compoundNeedsConfirmation": "{label} пропущен: поля изменились после подтверждения; проверьте элемент заново",
"compoundBackupUnavailable": "Нельзя изменить составные расширения: исходные значения не удалось сохранить",
"compoundRolledBack": "Создание миниатюры не удалось; имя и тип {label} восстановлены",
"compoundRollbackFailed": "Создание миниатюры не удалось, а {label} не удалось восстановить автоматически: {error}",
"restoreEmpty": "Нет сохранённых изменений имён/типов для восстановления",
"restoreTitle": "Выберите элементы для восстановления",
"restoreIntro": "Для этих элементов сохранены исходные значения. Все доступные элементы выбраны по умолчанию; изменённые пользователем или недоступные элементы выбрать нельзя.",
"restoreCurrent": "Сейчас",
"restoreTarget": "Восстановить до",
"restoreStatusReady": "Элемент изменён на {fields}",
"restoreStatusAlreadyOriginal": "Уже имеет исходное значение; подтверждение только удалит резервную копию",
"restoreStatusChanged": "Текущие поля изменены пользователем; элемент будет пропущен",
"restoreStatusMissing": "Элемент не найден, восстановление невозможно",
"restoreStatusError": "Не удалось прочитать текущие значения, восстановление невозможно",
"restoreCancel": "Отмена",
"restoreSelected": "Восстановить выбранные ({count})",
"restoreComplete": "Восстановление завершено: успешно {success}, пропущено {skipped}, ошибок {failed}.",
"restoreFailed": "Не удалось восстановить имена/типы: {error}",
"modeLabel": "Режим создания",
"modeAnimated": "Анимированный (первая анимация)",
"modeStatic": "Статический (setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine缩略图",
"noticeIntro": "导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
"noticeSafety": "它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。",
"noticeModes": "动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。",
"noticeDefault": "自动生成默认关闭,需要你在下面手动启用。",
"noticeIntro": "功能:导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
"noticeSafety": "1. 自动生成默认关闭,需要你在下面手动启用。它只处理本次新导入的素材,不会覆盖已经设置过的自定义缩略图。",
"noticeCompound": "2. 复合后缀素材只有在确认原值和新值后,才会调整 Eagle 的名称/类型字段;原值会保存并可恢复。",
"autoToggle": "导入时自动生成缩略图",
"clear": "清空日志",
"restoreNames": "恢复名称/类型",
"compoundConfirmTitle": "确认修改复合后缀",
"compoundConfirmIntro": "由于复合后缀文件的特殊性,为了让这些素材显示生成的缩略图,Eagle 需要调整名称和类型字段。原值会保存,可随时在插件面板恢复。",
"compoundOldValue": "原值",
"compoundNewValue": "新值",
"compoundSkip": "跳过并继续",
"compoundConfirm": "确认并应用",
"compoundConfirmSelected": "应用选中({count})",
"compoundSkipped": "已跳过 {label}:未确认修改复合后缀",
"compoundSourceMismatch": "已跳过 {label}:Eagle 名称 {current} 与真实来源文件 {source} 不一致",
"compoundCheckFailed": "检查 {label} 的复合后缀失败:{error}",
"compoundNeedsConfirmation": "已跳过 {label}:确认后字段又发生变化,请下次重新确认",
"compoundBackupUnavailable": "无法修改复合后缀,因为原值备份不可用",
"compoundRolledBack": "缩略图生成失败,已恢复 {label} 的名称和类型",
"compoundRollbackFailed": "缩略图生成失败且无法自动恢复 {label}:{error}",
"restoreEmpty": "没有可恢复的名称/类型修改",
"restoreTitle": "选择要恢复的素材",
"restoreIntro": "以下是插件保存了原值的素材。可恢复项默认全部勾选;后来被用户修改或无法读取的素材不可选择。",
"restoreCurrent": "当前",
"restoreTarget": "恢复为",
"restoreStatusReady": "素材已被修改为 {fields}",
"restoreStatusAlreadyOriginal": "已是原值;确认后仅清理备份",
"restoreStatusChanged": "当前字段已被用户修改,将跳过",
"restoreStatusMissing": "素材不存在,无法恢复",
"restoreStatusError": "无法读取当前值,无法恢复",
"restoreCancel": "取消",
"restoreSelected": "恢复选中({count})",
"restoreComplete": "恢复完成:成功 {success},跳过 {skipped},失败 {failed}。",
"restoreFailed": "恢复名称/类型失败:{error}",
"modeLabel": "生成方式",
"modeAnimated": "动态(第一个动画)",
"modeStatic": "静帧(setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine縮圖",
"noticeIntro": "匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生骨骼預覽圖。",
"noticeSafety": "只處理本次新匯入的素材,不掃描或修改歷史素材,也不會覆寫你已設定的自訂縮圖。",
"noticeModes": "動態會取第一個動畫並壓成 WebP;靜態一律使用 setup pose。",
"noticeDefault": "自動產生預設為關閉,需要時請在下方手動啟用。",
"noticeIntro": "功能:匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生一張骨骼預覽圖。",
"noticeSafety": "1. 自動產生預設為關閉,需要時請在下方手動啟用。只處理本次新匯入的素材,不會覆寫已設定的自訂縮圖。",
"noticeCompound": "2. 複合副檔名素材只有在確認原始值和新值後,才會調整 Eagle 的名稱/類型欄位;原始值會保存並可還原。",
"autoToggle": "匯入時自動產生縮圖",
"clear": "清除記錄",
"restoreNames": "還原名稱/類型",
"compoundConfirmTitle": "確認修改複合副檔名",
"compoundConfirmIntro": "由於複合副檔名檔案的特殊性,為了讓這些素材顯示產生的縮圖,Eagle 需要調整名稱和類型欄位。原始值會保存,可隨時在外掛面板還原。",
"compoundOldValue": "原始值",
"compoundNewValue": "新值",
"compoundSkip": "略過並繼續",
"compoundConfirm": "確認並套用",
"compoundConfirmSelected": "套用選取項目({count})",
"compoundSkipped": "已略過 {label}:未確認修改複合副檔名",
"compoundSourceMismatch": "已略過 {label}:Eagle 名稱 {current} 與實際來源檔案 {source} 不一致",
"compoundCheckFailed": "檢查 {label} 的複合副檔名失敗:{error}",
"compoundNeedsConfirmation": "已略過 {label}:確認後欄位又發生變化,請下次重新確認",
"compoundBackupUnavailable": "無法修改複合副檔名,因為原始值備份不可用",
"compoundRolledBack": "縮圖產生失敗,已還原 {label} 的名稱和類型",
"compoundRollbackFailed": "縮圖產生失敗且無法自動還原 {label}:{error}",
"restoreEmpty": "沒有可還原的名稱/類型修改",
"restoreTitle": "選擇要還原的素材",
"restoreIntro": "以下是外掛保存了原始值的素材。可還原項目預設全部勾選;後來被使用者修改或無法讀取的素材不可選擇。",
"restoreCurrent": "目前",
"restoreTarget": "還原為",
"restoreStatusReady": "素材已被修改為 {fields}",
"restoreStatusAlreadyOriginal": "已是原始值;確認後僅清除備份",
"restoreStatusChanged": "目前欄位已被使用者修改,將略過",
"restoreStatusMissing": "素材不存在,無法還原",
"restoreStatusError": "無法讀取目前值,無法還原",
"restoreCancel": "取消",
"restoreSelected": "還原選取項目({count})",
"restoreComplete": "還原完成:成功 {success},略過 {skipped},失敗 {failed}。",
"restoreFailed": "還原名稱/類型失敗:{error}",
"modeLabel": "產生方式",
"modeAnimated": "動態(第一個動畫)",
"modeStatic": "靜態(setup pose)",
+73 -11
View File
@@ -8,7 +8,6 @@
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
body { display: flex; flex-direction: column; gap: 9px; padding: 14px; box-sizing: border-box; }
h1 { font-size: 15px; margin: 0; font-weight: 600; text-align: center; }
.notice { color: var(--dim); line-height: 1.6; background: var(--panel);
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.notice p { margin: 0 0 6px; }
@@ -22,9 +21,12 @@
/* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
.toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
.toolbar-group.primary { flex: 1 1 auto; flex-wrap: nowrap; min-width: 0; }
.toolbar-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.toolbar-group.primary .toggle-row { min-width: 220px; }
#clear, #restoreNames { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
#clear:hover { border-color: var(--accent); color: var(--accent); }
#clear:hover, #restoreNames:hover { border-color: var(--accent); color: var(--accent); }
.mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
flex: 0 1 auto;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
@@ -51,20 +53,56 @@
.filters button { background: var(--panel); color: var(--dim); border: 1px solid var(--border);
border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; }
.filters button.active { color: var(--accent); border-color: var(--accent); }
.compat-dialog { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
padding: 18px; background: rgba(0, 0, 0, .62); }
.compat-dialog.hidden { display: none; }
.compat-card { width: min(640px, 100%); max-height: calc(100vh - 36px); display: flex;
flex-direction: column; gap: 12px; padding: 18px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 18px 54px rgba(0, 0, 0, .48); }
.compat-card h2 { margin: 0; font-size: 16px; }
.compat-card p { margin: 0; color: var(--dim); line-height: 1.65; }
.compat-list { overflow: auto; border: 1px solid var(--border); border-radius: 6px; }
.compat-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer; }
.compat-row:last-child { border-bottom: 0; }
.compat-row input { margin: 3px 0 0; accent-color: var(--accent); }
.compat-row-content { min-width: 0; }
.compat-file { color: var(--text); font-weight: 500; word-break: break-all; }
.compat-path { color: var(--dim); font-size: 12px; word-break: break-all; }
.compat-change { margin-top: 4px; font-family: ui-monospace, Consolas, monospace; font-size: 12px; word-break: break-all; }
.compat-change code { color: var(--accent); background: transparent; border: 0; padding: 0; }
.compat-actions { display: flex; justify-content: flex-end; gap: 8px; }
.compat-actions button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 14px; font: inherit; cursor: pointer; }
.compat-actions button.primary { color: #fff; background: #1976d2; border-color: #1976d2; }
.compat-actions button:disabled { opacity: .45; cursor: default; }
.restore-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer; }
.restore-row:last-child { border-bottom: 0; }
.restore-row.disabled { opacity: .55; cursor: default; }
.restore-row input { margin: 3px 0 0; accent-color: var(--accent); }
.restore-current, .restore-target { margin-top: 4px; font-family: ui-monospace, Consolas, monospace;
font-size: 12px; word-break: break-all; }
.restore-current code, .restore-target code { color: var(--accent); background: transparent; border: 0; padding: 0; }
.restore-status { color: var(--dim); font-size: 12px; margin-top: 3px; }
.restore-status.applied { color: #3fb950; }
.restore-row.disabled .restore-status { color: var(--warn); }
.restore-row.disabled .restore-status.applied { color: #3fb950; }
</style>
</head>
<body>
<h1 data-i18n="thumb.title">Spine缩略图</h1>
<div class="notice">
<p data-i18n="thumb.noticeIntro">导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
<p data-i18n="thumb.noticeSafety">它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</p>
<p data-i18n="thumb.noticeModes">动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。</p>
<p data-i18n="thumb.noticeDefault">自动生成默认关闭,需要你在下面手动启用。</p>
<p data-i18n="thumb.noticeIntro">功能:导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
<p data-i18n="thumb.noticeSafety">1. 自动生成默认关闭,需要你在下面手动启用。它只处理本次新导入的素材,不会覆盖已经设置过的自定义缩略图。</p>
<p data-i18n="thumb.noticeCompound">2. 复合后缀素材只有在确认原值和新值后,才会调整 Eagle 的名称/类型字段;原值会保存并可恢复。</p>
</div>
<div class="toolbar">
<div class="toolbar-group">
<div class="toolbar-group primary">
<label class="toggle-row"><input type="checkbox" id="toggle"><span data-i18n="thumb.autoToggle">导入时自动生成缩略图</span></label>
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
<div class="toolbar-actions">
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
<button id="restoreNames" type="button" data-i18n="thumb.restoreNames">恢复名称/类型</button>
</div>
</div>
<div class="toolbar-group">
<label class="mode-row"><span data-i18n="thumb.modeLabel">生成方式</span>
@@ -92,6 +130,28 @@
<button type="button" data-filter="error" data-i18n="thumb.filterError">错误</button>
</div>
<ul id="log"></ul>
<div id="compatDialog" class="compat-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="compatTitle">
<div class="compat-card">
<h2 id="compatTitle" data-i18n="thumb.compoundConfirmTitle">确认修改复合后缀</h2>
<p data-i18n="thumb.compoundConfirmIntro">由于复合后缀文件的特殊性,为了让这些素材显示生成的缩略图,Eagle 需要调整名称和类型字段。原值会保存,可随时在插件面板恢复。</p>
<div id="compatList" class="compat-list"></div>
<div class="compat-actions">
<button id="compatSkip" type="button" data-i18n="thumb.compoundSkip">跳过并继续</button>
<button id="compatConfirm" class="primary" type="button" data-i18n="thumb.compoundConfirm">确认并应用</button>
</div>
</div>
</div>
<div id="restoreDialog" class="compat-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="restoreTitle">
<div class="compat-card">
<h2 id="restoreTitle" data-i18n="thumb.restoreTitle">选择要恢复的素材</h2>
<p data-i18n="thumb.restoreIntro">以下是插件保存了原值的素材。可恢复项默认全部勾选;后来被用户修改或无法读取的素材不可选择。</p>
<div id="restoreList" class="compat-list"></div>
<div class="compat-actions">
<button id="restoreCancel" type="button" data-i18n="thumb.restoreCancel">取消</button>
<button id="restoreApply" class="primary" type="button" disabled>恢复选中</button>
</div>
</div>
</div>
<script>
// service 页面里的 require 不以插件目录为基准,本地资源要用绝对路径,这里先把根目录算出来。
(function () {
@@ -99,7 +159,7 @@
// Windows 上 pathname 形如 /C:/...,要去掉盘符前那个多余的斜杠;
// macOS/Linux 是 /Users/...,前导斜杠必须留着,否则会变成相对路径。
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
@@ -132,6 +192,8 @@
<script src="js/spine-legacy-binary.js"></script>
<script src="js/spine-badge.js"></script>
<script src="js/settings.js"></script>
<script src="js/compound-compat.js"></script>
<script src="js/instance-coordinator.js"></script>
<script src="js/progress-window.js"></script>
<script src="js/renderer.js"></script>
<script src="js/library-watcher.js"></script>
@@ -0,0 +1,197 @@
// 复合后缀兼容修改:只生成计划并持久化原值;真正修改 Eagle 字段由插件在用户确认后执行。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const STORE_VERSION = 1;
function fullName(name, ext) {
return String(name || '') + (ext ? '.' + ext : '');
}
/** 返回需要展示给用户的字段变更;不需要修改或被安全条件阻断时返回 null / blocked。 */
function planChange(item, eagleItem, splitCompoundName) {
const sourceName = String((item && item.entryName) || '');
const currentName = String((eagleItem && eagleItem.name) || '');
const currentExt = String((eagleItem && eagleItem.ext) || '');
const currentFileName = fullName(currentName, currentExt);
if (!sourceName || currentFileName !== sourceName) {
return {
blocked: true,
reason: 'sourceMismatch',
sourceName,
currentName,
currentExt,
currentFileName,
};
}
const parts = splitCompoundName(sourceName);
if (!parts || (currentName === parts.name && currentExt === parts.ext)) return null;
if (fullName(parts.name, parts.ext) !== sourceName) {
return {
blocked: true,
reason: 'sourceMismatch',
sourceName,
currentName,
currentExt,
currentFileName,
};
}
return {
sourceName,
currentName,
currentExt,
currentFileName,
newName: parts.name,
newExt: parts.ext,
newFileName: fullName(parts.name, parts.ext),
};
}
function emptyStore() {
return { version: STORE_VERSION, entries: {} };
}
function readStore(file) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!data || data.version !== STORE_VERSION || !data.entries || typeof data.entries !== 'object') {
throw new Error('备份文件格式不正确');
}
return data;
} catch (error) {
if (error && error.code === 'ENOENT') return emptyStore();
throw error;
}
}
function writeStore(file, data) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = file + '.' + process.pid + '.tmp';
fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.renameSync(temp, file);
}
function createStore(userDataPath) {
const file = path.join(userDataPath, 'SpineThumbnailGenerateServer', 'compound-name-backups.json');
return {
file,
list() {
return Object.values(readStore(file).entries)
.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
},
remember(change) {
const data = readStore(file);
if (!data.entries[change.itemId]) {
data.entries[change.itemId] = {
itemId: change.itemId,
sourceName: change.sourceName,
folderPath: change.folderPath || '',
originalName: change.currentName,
originalExt: change.currentExt,
appliedName: change.newName,
appliedExt: change.newExt,
updatedAt: Date.now(),
};
writeStore(file, data);
}
return data.entries[change.itemId];
},
remove(itemId) {
const data = readStore(file);
if (!data.entries[itemId]) return;
delete data.entries[itemId];
writeStore(file, data);
},
};
}
/**
* 为恢复对话框生成可审计列表,并标出哪些条目允许恢复。
* 用户后来亲自改过的字段不会被覆盖。
*/
async function inspectBackups(store, getItem, sourceExists) {
const entries = store.list();
const rows = [];
for (const entry of entries) {
let item = null;
try {
item = await getItem(entry.itemId);
if (!item) {
let sourcePresent = false;
try {
sourcePresent = sourceExists ? await sourceExists(entry) : false;
} catch (error) { /* 检查失败时保守按缺失处理 */ }
if (sourcePresent) {
rows.push({
entry,
item: null,
currentName: entry.appliedName,
currentExt: entry.appliedExt,
status: 'pluginApplied',
});
} else {
rows.push({ entry, item: null, status: 'missing' });
}
continue;
}
const currentName = String(item.name || '');
const currentExt = String(item.ext || '');
if (currentName === entry.originalName && currentExt === entry.originalExt) {
rows.push({ entry, item, currentName, currentExt, status: 'alreadyOriginal' });
continue;
}
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
rows.push({ entry, item, currentName, currentExt, status: 'changed' });
continue;
}
rows.push({ entry, item, currentName, currentExt, status: 'ready' });
} catch (error) {
rows.push({ entry, item, status: 'error' });
}
}
return rows;
}
async function restoreBackups(store, selectedItemIds, getItem, saveItem) {
const selected = new Set(selectedItemIds || []);
const entries = store.list().filter((entry) => selected.has(entry.itemId));
const result = { total: entries.length, success: 0, skipped: 0, failed: 0 };
for (const entry of entries) {
let item = null;
try {
item = await getItem(entry.itemId);
if (!item) {
result.skipped += 1;
continue;
}
const currentName = String(item.name || '');
const currentExt = String(item.ext || '');
if (currentName === entry.originalName && currentExt === entry.originalExt) {
store.remove(entry.itemId);
result.success += 1;
continue;
}
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
result.skipped += 1;
continue;
}
item.name = entry.originalName;
item.ext = entry.originalExt;
await saveItem(item);
store.remove(entry.itemId);
result.success += 1;
} catch (error) {
result.failed += 1;
}
}
return result;
}
const api = { fullName, planChange, createStore, inspectBackups, restoreBackups, STORE_VERSION };
if (typeof window !== 'undefined') window.SpineThumbCompoundCompat = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
+2 -1
View File
@@ -25,7 +25,8 @@ function normalizeLocale(raw) {
function requestedLocale() {
try {
const fromUrl = new URLSearchParams(location.search).get('lang');
const params = new URLSearchParams(location.search);
const fromUrl = params.get('lang') || params.get('locale');
if (fromUrl) return fromUrl;
} catch (error) { /* No browser location. */ }
try {
@@ -0,0 +1,127 @@
// 多个 Eagle 插件窗口共用 localStorage,用它协调唯一的活动监听实例。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const KEY = 'spine-thumbnail-active-instance';
const STALE_MS = 3500;
function createFileStorage(file) {
return {
file,
getItem(key) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return typeof data[key] === 'string' ? data[key] : null;
} catch (error) {
return null;
}
},
setItem(key, value) {
let data = {};
try {
const existing = JSON.parse(fs.readFileSync(file, 'utf8'));
if (existing && typeof existing === 'object') data = existing;
} catch (error) { /* first write or damaged file */ }
data[key] = String(value);
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = file + '.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(16).slice(2) + '.tmp';
fs.writeFileSync(temp, JSON.stringify(data), 'utf8');
fs.renameSync(temp, file);
},
};
}
function readState(storage) {
try {
const state = JSON.parse(storage.getItem(KEY));
if (!state || typeof state.id !== 'string' || !Number.isFinite(state.updatedAt)) return null;
return state;
} catch (error) {
return null;
}
}
function writeState(storage, id, visible, now) {
const state = { id, visible, updatedAt: now };
try { storage.setItem(KEY, JSON.stringify(state)); } catch (error) { /* 无本地存储时仍可运行 */ }
return state;
}
function createCoordinator(options) {
const storage = options.storage;
const id = options.id;
const visible = options.isVisible || (() => true);
const hide = options.hide || (() => {});
const now = options.now || Date.now;
const staleMs = options.staleMs || STALE_MS;
const intervalMs = options.intervalMs || 1000;
let active = false;
let started = false;
let hiddenForDuplicate = false;
let timer = null;
function claim() {
writeState(storage, id, visible(), now());
hiddenForDuplicate = false;
if (!active) {
active = true;
if (options.onActiveChanged) options.onActiveChanged(true);
}
}
function evaluate() {
const state = readState(storage);
const currentVisible = visible();
const canClaim = !state ||
state.id === id ||
now() - state.updatedAt > staleMs;
const nextActive = canClaim;
if (nextActive) {
claim();
} else if (currentVisible && !hiddenForDuplicate) {
hiddenForDuplicate = true;
try { hide(); } catch (error) { /* 隐藏失败不影响主窗口 */ }
}
if (nextActive !== active) {
active = nextActive;
if (options.onActiveChanged) options.onActiveChanged(active);
}
return active;
}
function handleVisibility() {
if (visible()) claim();
else evaluate();
}
function start() {
if (started) return active;
started = true;
const state = readState(storage);
const canStart = visible() ||
!state ||
state.id === id ||
now() - state.updatedAt > staleMs;
if (canStart) claim();
else evaluate();
timer = setInterval(evaluate, intervalMs);
return active;
}
function stop() {
if (timer) clearInterval(timer);
timer = null;
started = false;
}
return { start, stop, evaluate, handleVisibility, isActive: () => active };
}
const api = { createCoordinator, createFileStorage, KEY, STALE_MS };
if (typeof window !== 'undefined') window.SpineThumbInstanceCoordinator = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -87,7 +87,6 @@ function createLibraryWatcher(options) {
return false;
}
known = listItemDirs();
onLog(t('thumb.watching', { path: imagesDir, count: known.size }));
try {
watcher = fs.watch(imagesDir, { persistent: true }, () => scheduleFlush());
} catch (error) {
+399 -26
View File
@@ -2,7 +2,7 @@
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v66';
const VERSION = 'v79';
const SHOW_DEV_VERSION = true;
const path = require('path');
@@ -16,7 +16,7 @@ const REQUIRED = [
'SpineThumbI18n',
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
'SpineThumbProgress',
'SpineThumbProgress', 'SpineThumbCompoundCompat', 'SpineThumbInstanceCoordinator',
];
const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) {
@@ -30,6 +30,8 @@ const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils;
const progress = window.SpineThumbProgress;
const compoundCompat = window.SpineThumbCompoundCompat;
const instanceCoordinatorModule = window.SpineThumbInstanceCoordinator;
const statusEl = document.getElementById('status');
const libraryEl = document.getElementById('library');
@@ -37,9 +39,18 @@ const statsEl = document.getElementById('stats');
const logEl = document.getElementById('log');
const toggleEl = document.getElementById('toggle');
const clearEl = document.getElementById('clear');
const restoreNamesEl = document.getElementById('restoreNames');
const filtersEl = document.getElementById('filters');
const modeEl = document.getElementById('mode');
const strategyEl = document.getElementById('strategy');
const compatDialogEl = document.getElementById('compatDialog');
const compatListEl = document.getElementById('compatList');
const compatConfirmEl = document.getElementById('compatConfirm');
const compatSkipEl = document.getElementById('compatSkip');
const restoreDialogEl = document.getElementById('restoreDialog');
const restoreListEl = document.getElementById('restoreList');
const restoreCancelEl = document.getElementById('restoreCancel');
const restoreApplyEl = document.getElementById('restoreApply');
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
@@ -49,6 +60,9 @@ const PROGRESS_MIN_DYNAMIC = 10;
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
let settings = { ...settingsStore.DEFAULTS };
let compatStore = null;
let instanceCoordinator = null;
let pluginUserDataPath = null;
let libraryPath = null;
let imagesDir = null;
const queue = [];
@@ -114,10 +128,19 @@ function loadFolderPaths() {
} catch (error) { /* 读不到就只显示未分类 */ }
}
function folderPathOf(folderIds) {
function optionalFolderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || t('thumb.uncategorized');
return (id && folderPaths.get(id)) || '';
}
function folderPathOf(folderIds) {
return optionalFolderPathOf(folderIds) || t('thumb.uncategorized');
}
function backupSourceExists(entry) {
if (!imagesDir || !entry.sourceName) return false;
return fs.existsSync(path.join(imagesDir, entry.itemId + '.info', entry.sourceName));
}
/** 日志里的渲染结果描述。 */
@@ -161,9 +184,247 @@ function countRun(outcome, animated, name) {
}
}
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
function compoundMatchesApproval(plan, approved) {
return plan && !plan.blocked &&
plan.currentName === approved.currentName &&
plan.currentExt === approved.currentExt &&
plan.newName === approved.newName &&
plan.newExt === approved.newExt;
}
function showCompoundConfirmation(changes) {
compatListEl.textContent = '';
const checkboxes = [];
const updateConfirm = () => {
const count = checkboxes.filter((input) => input.checked).length;
compatConfirmEl.disabled = count === 0;
compatConfirmEl.textContent = t('thumb.compoundConfirmSelected', { count });
};
for (const change of changes) {
const plan = change.plan;
const row = document.createElement('label');
row.className = 'compat-row';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true;
input.dataset.itemId = itemIdOf(change.item.dirName);
input.addEventListener('change', updateConfirm);
checkboxes.push(input);
const content = document.createElement('div');
content.className = 'compat-row-content';
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = plan.sourceName;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = optionalFolderPathOf(change.item.folders);
const values = document.createElement('div');
values.className = 'compat-change';
const oldValue = document.createElement('code');
oldValue.textContent = 'name=' + JSON.stringify(plan.currentName) + '; ext=' + JSON.stringify(plan.currentExt);
const newValue = document.createElement('code');
newValue.textContent = 'name=' + JSON.stringify(plan.newName) + '; ext=' + JSON.stringify(plan.newExt);
values.append(
t('thumb.compoundOldValue') + ' ', oldValue,
' → ', t('thumb.compoundNewValue') + ' ', newValue,
);
content.append(file);
if (location.textContent) content.append(location);
content.append(values);
row.append(input, content);
compatListEl.appendChild(row);
}
updateConfirm();
compatDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 窗口显示失败时仍可处理后续批次 */ }
return new Promise((resolve) => {
const finish = (selected) => {
compatDialogEl.classList.add('hidden');
compatConfirmEl.removeEventListener('click', confirm);
compatSkipEl.removeEventListener('click', skip);
document.removeEventListener('keydown', keydown);
resolve(selected);
};
const confirm = () => finish(checkboxes
.filter((input) => input.checked)
.map((input) => input.dataset.itemId));
const skip = () => finish([]);
const keydown = (event) => {
if (event.key === 'Escape') skip();
};
compatConfirmEl.addEventListener('click', confirm);
compatSkipEl.addEventListener('click', skip);
document.addEventListener('keydown', keydown);
checkboxes[0].focus();
});
}
function fieldsText(name, ext) {
return 'name=' + JSON.stringify(name) + '; ext=' + JSON.stringify(ext);
}
function showRestoreSelection(rows) {
restoreListEl.textContent = '';
const checkboxes = [];
const statusKeys = {
ready: 'restoreStatusReady',
pluginApplied: 'restoreStatusReady',
alreadyOriginal: 'restoreStatusAlreadyOriginal',
changed: 'restoreStatusChanged',
missing: 'restoreStatusMissing',
error: 'restoreStatusError',
};
const updateApply = () => {
const count = checkboxes.filter((input) => !input.disabled && input.checked).length;
restoreApplyEl.disabled = count === 0;
restoreApplyEl.textContent = t('thumb.restoreSelected', { count });
};
for (const row of rows) {
const entry = row.entry;
const canRestore = row.status === 'ready' || row.status === 'alreadyOriginal';
const label = document.createElement('label');
label.className = 'restore-row' + (canRestore ? '' : ' disabled');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = canRestore;
input.disabled = !canRestore;
input.dataset.itemId = entry.itemId;
input.addEventListener('change', updateApply);
checkboxes.push(input);
const details = document.createElement('div');
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = entry.sourceName || entry.itemId;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = entry.folderPath || '';
const current = document.createElement('div');
current.className = 'restore-current';
current.textContent = t('thumb.restoreCurrent') + ' ' + fieldsText(
row.currentName === undefined ? '' : row.currentName,
row.currentExt === undefined ? '' : row.currentExt,
);
const target = document.createElement('div');
target.className = 'restore-target';
target.textContent = t('thumb.restoreTarget') + ' ' + fieldsText(entry.originalName, entry.originalExt);
const status = document.createElement('div');
status.className = 'restore-status';
if (row.status === 'ready' || row.status === 'pluginApplied') {
status.classList.add('applied');
status.textContent = t('thumb.restoreStatusReady', {
fields: fieldsText(row.currentName, row.currentExt),
});
} else {
status.textContent = t('thumb.' + statusKeys[row.status]);
}
details.append(file);
if (location.textContent) details.append(location);
details.append(current, target, status);
label.append(input, details);
restoreListEl.appendChild(label);
}
updateApply();
restoreDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 列表仍可通过已打开的面板操作 */ }
return new Promise((resolve) => {
const finish = (value) => {
restoreDialogEl.classList.add('hidden');
restoreCancelEl.removeEventListener('click', cancel);
restoreApplyEl.removeEventListener('click', apply);
document.removeEventListener('keydown', keydown);
resolve(value);
};
const cancel = () => finish(null);
const apply = () => finish(checkboxes
.filter((input) => !input.disabled && input.checked)
.map((input) => input.dataset.itemId));
const keydown = (event) => {
if (event.key === 'Escape') cancel();
};
restoreCancelEl.addEventListener('click', cancel);
restoreApplyEl.addEventListener('click', apply);
document.addEventListener('keydown', keydown);
const first = checkboxes.find((input) => !input.disabled);
(first || restoreCancelEl).focus();
});
}
/** 修改前统一检查已有缩略图、真实文件名和用户确认,避免任何静默元数据变更。 */
async function prepareCompoundCompatibility(items) {
const changes = [];
for (const item of items) {
try {
const eagleItem = await eagle.item.getById(itemIdOf(item.dirName));
if (!eagleItem || eagleItem.customThumbnail === true) continue;
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (!plan) continue;
const label = item.entryName + ' | ' + folderPathOf(item.folders);
if (plan.blocked) {
item.compoundBlocked = plan;
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
continue;
}
changes.push({ item, plan });
} catch (error) {
item.compoundBlocked = { error };
log(t('thumb.compoundCheckFailed', {
label: item.entryName,
error: (error && error.message) || error,
}), 'warn');
}
}
if (!changes.length) return;
if (!compatStore) {
for (const change of changes) change.item.compoundBlocked = { error: 'backup unavailable' };
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
const selectedIds = new Set(await showCompoundConfirmation(changes));
for (const change of changes) {
if (selectedIds.has(itemIdOf(change.item.dirName))) change.item.compoundApproval = change.plan;
else change.item.compoundDeclined = true;
}
}
async function rollbackCompoundChange(eagleItem, backup, label) {
if (!backup) return false;
try {
eagleItem.name = backup.originalName;
eagleItem.ext = backup.originalExt;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
compatStore.remove(backup.itemId);
log(t('thumb.compoundRolledBack', { label }), 'warn');
return true;
} catch (error) {
log(t('thumb.compoundRollbackFailed', {
label,
error: (error && error.message) || error,
}), 'error');
return false;
}
}
/** 一个素材的缩略图生成流程:查重 → 确认兼容变更 → 渲染 → 写临时文件 → 设置 → 清理。 */
async function generateOne(item) {
let tempPath = null;
let compatibilityAttempted = false;
let compatibilityBackup = null;
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
const label = item.entryName + ' | ' + folderPathOf(item.folders);
@@ -176,21 +437,6 @@ async function generateOne(item) {
log(t('thumb.missingItem', { label }), 'warn');
return;
}
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
try {
eagleItem.name = parts.name;
eagleItem.ext = parts.ext;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
} catch (error) {
log(t('thumb.suffixFailed', { label, error: (error && error.message) || error }), 'warn');
}
}
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
@@ -198,6 +444,47 @@ async function generateOne(item) {
log(t('thumb.existingThumbnail', { label }), 'dim');
return;
}
if (item.compoundDeclined || item.compoundBlocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSkipped', { label }), 'warn');
return;
}
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (plan && plan.blocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
return;
}
if (plan) {
const approved = item.compoundApproval;
if (!approved || !compoundMatchesApproval(plan, approved)) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundNeedsConfirmation', { label }), 'warn');
return;
}
if (!compatStore) throw new Error(t('thumb.compoundBackupUnavailable'));
compatibilityBackup = compatStore.remember({
itemId,
sourceName: plan.sourceName,
folderPath: folderPathOf(item.folders),
currentName: plan.currentName,
currentExt: plan.currentExt,
newName: plan.newName,
newExt: plan.newExt,
});
eagleItem.name = plan.newName;
eagleItem.ext = plan.newExt;
compatibilityAttempted = true;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: plan.newFileName }), 'dim');
}
const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
@@ -228,6 +515,9 @@ async function generateOne(item) {
countRun('generated', rendered.mode === 'animated', item.entryName);
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
} catch (error) {
if (compatibilityAttempted) {
await rollbackCompoundChange(eagleItem, compatibilityBackup, label);
}
stats.failed += 1;
countRun('failed', false, item.entryName);
log(t('thumb.generateFailed', { label, error: (error && error.message) || error }), 'fail');
@@ -400,6 +690,7 @@ async function handleBatchAsync({ added, total, reason }) {
setStatus(t('thumb.statusAutoSkipped'));
return;
}
await prepareCompoundCompatibility(ready);
queue.push(...ready);
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
if (runStats) runStats.total += ready.length;
@@ -445,17 +736,62 @@ async function start() {
libraryPath = resolved;
imagesDir = path.join(resolved, 'images');
folderPaths = null; // 换库了,文件夹树重新读
libraryEl.textContent = resolved;
libraryEl.textContent = path.basename(resolved);
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
}
function startSafely() {
if (instanceCoordinator && !instanceCoordinator.isActive()) return;
start().catch((error) => {
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
});
}
async function initializeInstanceCoordinator(plugin) {
if (instanceCoordinator) {
instanceCoordinator.evaluate();
return;
}
await initializeCompatStore();
const stateFile = pluginUserDataPath
? path.join(pluginUserDataPath, 'SpineThumbnailGenerateServer', 'active-instance.json')
: path.join(os.tmpdir(), 'spine-thumbnail-active-instance.json');
const storage = instanceCoordinatorModule.createFileStorage(stateFile);
const id = String(window.windowID || (plugin && plugin.webContentsID) || Date.now());
instanceCoordinator = instanceCoordinatorModule.createCoordinator({
storage,
id,
isVisible: () => document.visibilityState === 'visible',
hide: () => eagle.window && eagle.window.hide && eagle.window.hide(),
onActiveChanged(active) {
if (active) {
startSafely();
return;
}
libraryWatcher.stop();
queue.length = 0;
try { if (eagle.window && eagle.window.hide) eagle.window.hide(); } catch (error) { /* already hidden */ }
},
intervalMs: 250,
});
document.addEventListener('visibilitychange', () => instanceCoordinator.handleVisibility());
window.addEventListener('beforeunload', () => instanceCoordinator.stop());
instanceCoordinator.start();
}
async function initializeCompatStore() {
if (compatStore) return true;
try {
pluginUserDataPath = await eagle.app.getPath('userData');
compatStore = compoundCompat.createStore(pluginUserDataPath);
return true;
} catch (error) {
log(t('thumb.compoundBackupUnavailable') + ': ' + ((error && error.message) || error), 'warn');
return false;
}
}
async function loadSettings() {
try {
settings = await settingsStore.read();
@@ -490,6 +826,44 @@ clearEl.addEventListener('click', () => {
logEl.textContent = '';
});
restoreNamesEl.addEventListener('click', async () => {
if (!compatStore) {
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
let rows;
try {
rows = await compoundCompat.inspectBackups(
compatStore,
(itemId) => eagle.item.getById(itemId),
backupSourceExists,
);
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
return;
}
if (!rows.length) {
log(t('thumb.restoreEmpty'), 'dim');
return;
}
const selected = await showRestoreSelection(rows);
if (!selected || !selected.length) return;
restoreNamesEl.disabled = true;
try {
const result = await compoundCompat.restoreBackups(
compatStore,
selected,
(itemId) => eagle.item.getById(itemId),
(item) => withTimeout(item.save(), 20000, t('thumb.restoreNames')),
);
log(t('thumb.restoreComplete', result), result.failed ? 'warn' : 'ok');
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
} finally {
restoreNamesEl.disabled = false;
}
});
modeEl.addEventListener('change', async () => {
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
try {
@@ -532,15 +906,13 @@ if (typeof eagle === 'undefined') {
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
if (i18n) i18n.setLocale(eagle.app && eagle.app.locale);
logVersion();
log(t('thumb.pluginCreated', {
name: (plugin && plugin.manifest && plugin.manifest.name) || t('thumb.title'),
}));
await initializeCompatStore();
try {
await loadSettings();
} catch (error) {
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
}
startSafely();
initializeInstanceCoordinator(plugin);
});
eagle.onLibraryChanged((changedPath) => {
log(t('thumb.switchingLibrary', { path: changedPath }));
@@ -552,7 +924,8 @@ if (typeof eagle === 'undefined') {
if (i18n) i18n.setLocale(i18n.requestedLocale());
logVersion();
log(t('thumb.noCreateEvent'), 'dim');
loadSettings().catch(() => {}).then(startSafely);
Promise.all([initializeCompatStore(), loadSettings().catch(() => {})])
.then(() => initializeInstanceCoordinator());
}
}, 800);
}
@@ -83,7 +83,8 @@ function createPopup() {
...manifest.main,
// 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。
serviceMode: false,
url: 'progress.html?lang=' + encodeURIComponent(i18n ? i18n.locale : 'zh_CN'),
// Eagle 会把 url 交给 pathToFileURL,查询参数必须由 Eagle 追加,否则 ? 会被编码成文件名。
url: 'progress.html',
width: WINDOW_SIZE.width,
height: WINDOW_SIZE.height,
show: true,
@@ -176,6 +176,11 @@ function isTextureFile(name) {
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
function textureFormatOf(name) {
const ext = extOf(name);
return ext === '.jpg' || ext === '.jpeg' ? 'jpeg' : ext;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
@@ -203,7 +208,7 @@ function splitCompoundName(fileName) {
if (!suffix) return null;
const name = String(fileName).slice(0, fileName.length - suffix.length);
if (!name) return null;
return { name, ext: suffix.slice(1) };
return { name, ext: String(fileName).slice(-suffix.length + 1) };
}
/** 从 JSON 文本中读取 spine 版本 */
@@ -749,6 +754,8 @@ function discoverAssets(filePath, options) {
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
const sameFormat = cands.filter((f) => textureFormatOf(f.name) === textureFormatOf(pageFileName));
if (sameFormat.length > 0) cands = sameFormat;
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
@@ -830,6 +837,7 @@ const spineUtilsApi = {
isSkeletonFile,
isAtlasFile,
isTextureFile,
textureFormatOf,
stemOf,
normalizeStem,
DEFAULT_SUFFIXES,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 53 KiB

+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
"version": "1.0.0",
"version": "1.0.1",
"platform": "all",
"arch": "all",
"name": "{{manifest.app.name}}",
+1 -1
View File
@@ -45,7 +45,7 @@
(function () {
try {
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
@@ -0,0 +1,192 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const compat = require('../js/compound-compat');
const utils = require('../js/spine-utils');
const pluginPath = path.join(__dirname, '..', 'js', 'plugin.js');
const pluginSource = fs.readFileSync(pluginPath, 'utf8');
const pageSource = fs.readFileSync(path.join(__dirname, '..', 'index.html'), 'utf8');
const releaseRoot = path.join(__dirname, '..', '..', 'release', 'UNIVERSAL_SpineThumbnailGenerateServer');
const planned = compat.planChange(
{ entryName: 'hero.atlas.txt' },
{ name: 'hero.atlas', ext: 'txt' },
utils.splitCompoundName,
);
assert.deepEqual(planned, {
sourceName: 'hero.atlas.txt',
currentName: 'hero.atlas',
currentExt: 'txt',
currentFileName: 'hero.atlas.txt',
newName: 'hero',
newExt: 'atlas.txt',
newFileName: 'hero.atlas.txt',
});
assert.deepEqual(compat.planChange(
{ entryName: 'hero.skel.bytes' },
{ name: 'hero.skel', ext: 'bytes' },
utils.splitCompoundName,
), {
sourceName: 'hero.skel.bytes',
currentName: 'hero.skel',
currentExt: 'bytes',
currentFileName: 'hero.skel.bytes',
newName: 'hero',
newExt: 'skel.bytes',
newFileName: 'hero.skel.bytes',
});
assert.equal(compat.planChange(
{ entryName: 'hero.atlas.txt' },
{ name: 'hero', ext: 'atlas.txt' },
utils.splitCompoundName,
), null);
assert.equal(compat.planChange(
{ entryName: 'hero.atlas' },
{ name: 'hero', ext: 'atlas' },
utils.splitCompoundName,
), null);
assert.equal(compat.planChange(
{ entryName: 'hero.atlas.txt' },
{ name: 'renamed', ext: 'txt' },
utils.splitCompoundName,
).reason, 'sourceMismatch');
assert.deepEqual(utils.splitCompoundName('Hero.ATLAS.TXT'), {
name: 'Hero',
ext: 'ATLAS.TXT',
});
assert.ok(
pluginSource.indexOf('eagleItem.customThumbnail === true') <
pluginSource.indexOf('const plan = compoundCompat.planChange'),
'custom thumbnails must be skipped before compatibility changes',
);
assert.match(pluginSource, /await prepareCompoundCompatibility\(ready\)[\s\S]*queue\.push/);
assert.match(pluginSource, /rollbackCompoundChange/);
assert.match(pluginSource, /name=.*ext=/);
assert.match(pluginSource, /function showCompoundConfirmation[\s\S]*input\.checked = true[\s\S]*function fieldsText/);
assert.match(pluginSource, /const selectedIds = new Set\(await showCompoundConfirmation\(changes\)\)/);
assert.match(pluginSource, /if \(location\.textContent\) content\.append\(location\)/);
assert.match(pluginSource, /if \(location\.textContent\) details\.append\(location\)/);
assert.match(pageSource, /id="compatDialog"/);
assert.match(pageSource, /id="compatConfirm"/);
assert.match(pageSource, /id="compatSkip"/);
assert.match(pageSource, /id="restoreNames"/);
assert.match(pageSource, /class="toolbar-actions"[\s\S]*id="clear"[\s\S]*id="restoreNames"/);
assert.match(pageSource, /id="restoreDialog"/);
assert.doesNotMatch(pageSource, /<h1\b/);
assert.match(pageSource, /id="restoreList"/);
assert.match(pageSource, /id="restoreApply"[^>]*disabled/);
assert.match(pluginSource, /input\.checked = canRestore/);
assert.match(pluginSource, /compoundCompat\.inspectBackups/);
assert.match(pluginSource, /compoundCompat\.restoreBackups\([\s\S]*selected,/);
assert.doesNotMatch(pluginSource, /window\.confirm\(t\('thumb\.restoreConfirm'/);
for (const relative of [
'index.html',
'js/compound-compat.js',
'js/instance-coordinator.js',
'js/spine-utils.js',
]) {
assert.equal(
fs.readFileSync(path.join(__dirname, '..', relative), 'utf8'),
fs.readFileSync(path.join(releaseRoot, relative), 'utf8'),
relative + ' must match the release copy',
);
}
for (const file of fs.readdirSync(path.join(__dirname, '..', '_locales'))) {
const relative = path.join('_locales', file);
assert.equal(
fs.readFileSync(path.join(__dirname, '..', relative), 'utf8'),
fs.readFileSync(path.join(releaseRoot, relative), 'utf8'),
relative + ' must match the release copy',
);
}
assert.equal(
pluginSource.replace('const SHOW_DEV_VERSION = true;', 'const SHOW_DEV_VERSION = false;'),
fs.readFileSync(path.join(releaseRoot, 'js', 'plugin.js'), 'utf8'),
'plugin.js may differ only by the development-version switch',
);
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-compound-compat-'));
try {
const userData = path.join(root, '用户 data');
fs.mkdirSync(userData);
const store = compat.createStore(userData);
assert.equal(
store.file,
path.join(userData, 'SpineThumbnailGenerateServer', 'compound-name-backups.json'),
);
const original = {
itemId: 'one',
sourceName: 'hero.atlas.txt',
folderPath: 'Assets/Heroes',
currentName: 'hero.atlas',
currentExt: 'txt',
newName: 'hero',
newExt: 'atlas.txt',
};
store.remember(original);
store.remember({ ...original, newName: 'changed-again', newExt: 'txt' });
assert.equal(store.list().length, 1);
assert.equal(store.list()[0].originalName, 'hero.atlas');
const items = new Map([
['one', { name: 'hero', ext: 'atlas.txt' }],
['changed', { name: 'user-renamed', ext: 'atlas.txt' }],
['failed', { id: 'failed', name: 'failed', ext: 'atlas.txt' }],
]);
for (const itemId of ['changed', 'failed']) {
store.remember({
itemId,
sourceName: itemId + '.atlas.txt',
currentName: itemId + '.atlas',
currentExt: 'txt',
newName: itemId,
newExt: 'atlas.txt',
});
}
for (const itemId of ['ondisk', 'gone']) {
store.remember({
itemId,
sourceName: itemId + '.skel.bytes',
currentName: itemId + '.skel',
currentExt: 'bytes',
newName: itemId,
newExt: 'skel.bytes',
});
}
const inspected = await compat.inspectBackups(
store,
async (itemId) => items.get(itemId),
async (entry) => entry.itemId === 'ondisk',
);
assert.deepEqual(Object.fromEntries(inspected.map((row) => [row.entry.itemId, row.status])), {
one: 'ready',
changed: 'changed',
failed: 'ready',
ondisk: 'pluginApplied',
gone: 'missing',
});
const restored = await compat.restoreBackups(
store,
['one', 'changed', 'failed'],
async (itemId) => items.get(itemId),
async (item) => {
if (item.id === 'failed') throw new Error('save failed');
},
);
assert.deepEqual(restored, { total: 3, success: 1, skipped: 1, failed: 1 });
assert.deepEqual(items.get('one'), { name: 'hero.atlas', ext: 'txt' });
assert.deepEqual(items.get('changed'), { name: 'user-renamed', ext: 'atlas.txt' });
assert.equal(store.list().length, 4);
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
console.log('PASS: compound suffix changes require reviewable plans and preserve restorable originals');
})().catch((error) => {
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,66 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const coordinatorModule = require('../js/instance-coordinator');
const coordinators = [];
(async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-instance-coordinator-'));
const storage = coordinatorModule.createFileStorage(path.join(root, '用户 data', 'instance-state.json'));
let timestamp = 1000;
let hidden = 0;
const changes = { a: [], b: [], c: [], d: [] };
const make = (id, visible) => {
const coordinator = coordinatorModule.createCoordinator({
storage,
id,
now: () => timestamp,
hide: () => { hidden += 1; },
isVisible: () => visible,
onActiveChanged: (active) => changes[id].push(active),
});
coordinators.push(coordinator);
return coordinator;
};
const hiddenService = make('a', false);
const visibleUi = make('b', true);
const duplicateUi = make('c', true);
hiddenService.start();
assert.equal(hiddenService.isActive(), true);
visibleUi.start();
assert.equal(hiddenService.evaluate(), false);
assert.equal(visibleUi.isActive(), true);
duplicateUi.start();
assert.equal(duplicateUi.isActive(), true, 'the later window takes ownership');
assert.equal(visibleUi.evaluate(), false);
assert.equal(hidden, 1, 'the previous visible window is hidden');
const laterWindow = make('d', false);
laterWindow.start();
assert.equal(laterWindow.isActive(), false, 'a hidden background window must not steal visible UI');
assert.equal(duplicateUi.isActive(), true);
assert.equal(hidden, 1);
timestamp += coordinatorModule.STALE_MS + 1;
assert.equal(laterWindow.evaluate(), true, 'stale owner allows background takeover');
assert.equal(duplicateUi.evaluate(), false);
assert.equal(hidden, 2);
hiddenService.stop();
visibleUi.stop();
duplicateUi.stop();
laterWindow.stop();
console.log('PASS: opened UI takes ownership and background instances respect it');
fs.rmSync(root, { recursive: true, force: true });
})().catch((error) => {
for (const coordinator of coordinators) coordinator.stop();
console.error(error);
process.exitCode = 1;
});
@@ -0,0 +1,47 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const utils = require('../js/spine-utils');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-mixed-atlas-'));
try {
const images = path.join(root, 'images');
fs.mkdirSync(images);
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
const makeItem = (id, name, body) => {
const directory = path.join(images, id + '.info');
fs.mkdirSync(directory);
fs.writeFileSync(path.join(directory, name), body);
fs.writeFileSync(path.join(directory, 'metadata.json'), JSON.stringify({ folders: [] }));
return path.join(directory, name);
};
const png = makeItem('png', 'hero.png', 'png');
const atlas = makeItem('atlas', 'hero.atlas', [
'hero.png',
'size: 64,64',
'format: RGBA8888',
'filter: Linear,Linear',
'repeat: none',
'',
'hero.jpeg',
'size: 32,32',
'format: RGBA8888',
'filter: Linear,Linear',
'repeat: none',
'',
].join('\n'));
const jpeg = makeItem('jpg', 'hero.jpg', 'jpg');
const assets = utils.discoverAssets(atlas, {
strictStem: true,
searchRoots: [path.dirname(png), path.dirname(atlas), path.dirname(jpeg)],
});
assert.equal(assets.texturePaths['hero.png'], png);
assert.equal(assets.texturePaths['hero.jpeg'], jpeg, 'JPEG atlas page must prefer .jpg over same-stem .png');
assert.equal(utils.textureFormatOf('photo.JPEG'), utils.textureFormatOf('photo.jpg'));
console.log('PASS: mixed PNG/JPEG atlas pages resolve to matching texture formats');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}
@@ -6,9 +6,20 @@ const path = require('node:path');
const root = path.join(__dirname, '..');
const html = fs.readFileSync(path.join(root, 'progress.html'), 'utf8');
const indexHtml = fs.readFileSync(path.join(root, 'index.html'), 'utf8');
const windowSource = fs.readFileSync(path.join(root, 'js', 'progress-window.js'), 'utf8');
const i18nSource = fs.readFileSync(path.join(root, 'js', 'i18n.js'), 'utf8');
const normalizePluginRoot = (pathname) => pathname.replace(/^\/+([A-Za-z]:)/, '$1');
assert.match(windowSource, /minimizable: true/);
assert.match(windowSource, /url: 'progress\.html',/);
assert.doesNotMatch(windowSource, /url: 'progress\.html\?/);
assert.match(i18nSource, /params\.get\('lang'\) \|\| params\.get\('locale'\)/);
for (const source of [html, indexHtml]) {
assert.match(source, /pathname\.replace\(\/\^\\\/\+\(\[A-Za-z\]:\)\/, '\$1'\)/);
}
assert.equal(normalizePluginRoot('//C:/Users/demo/plugin/'), 'C:/Users/demo/plugin/');
assert.equal(normalizePluginRoot('/Users/demo/plugin/'), '/Users/demo/plugin/');
assert.match(html, /addEventListener\('beforeunload'/);
assert.match(html, /function requestStop\(\)[\s\S]*writeControl\('pause'\)[\s\S]*resumeEl\.focus\(\)/);
assert.match(html, /if \(allowClose \|\| finished\) return;[\s\S]*event\.returnValue = false;[\s\S]*requestStop\(\)/);
+8
View File
@@ -173,6 +173,11 @@ function isTextureFile(name) {
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
function textureFormatOf(name) {
const ext = extOf(name);
return ext === '.jpg' || ext === '.jpeg' ? 'jpeg' : ext;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
@@ -726,6 +731,8 @@ function discoverAssets(filePath, options) {
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
const sameFormat = cands.filter((f) => textureFormatOf(f.name) === textureFormatOf(pageFileName));
if (sameFormat.length > 0) cands = sameFormat;
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
@@ -807,6 +814,7 @@ module.exports = {
isSkeletonFile,
isAtlasFile,
isTextureFile,
textureFormatOf,
stemOf,
normalizeStem,
DEFAULT_SUFFIXES,
Binary file not shown.

After

Width:  |  Height:  |  Size: 567 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 552 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 525 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 529 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 555 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 266 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 528 KiB

Binary file not shown.
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 268 KiB

After

Width:  |  Height:  |  Size: 265 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 286 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 261 KiB

After

Width:  |  Height:  |  Size: 267 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 298 KiB

After

Width:  |  Height:  |  Size: 296 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 MiB

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 181 KiB

After

Width:  |  Height:  |  Size: 228 KiB

@@ -8,10 +8,10 @@
<meta property="og:title" content="SpineThumbnails — Eagle 插件">
<meta property="og:description" content="识别、配对、渲染一次完成。为 Eagle 中的 Spine 导出资源自动生成静态或动态缩略图。">
<meta property="og:type" content="website">
<meta property="og:image" content="assets/promo-banner.jpg">
<meta property="og:image" content="assets/promo-banner.jpg?v=2">
<meta name="theme-color" content="#16161b">
<link rel="icon" href="assets/logo.png">
<link rel="stylesheet" href="style.css">
<link rel="icon" href="assets/logo.png?v=2">
<link rel="stylesheet" href="style.css?v=4">
<noscript><style>.reveal { opacity: 1; transform: none; }</style></noscript>
<script>
try {
@@ -19,15 +19,49 @@
document.documentElement.dataset.theme = savedTheme || 'dark';
} catch (error) {}
</script>
<!-- Matomo -->
<script>
(function() {
var host = location.hostname.toLowerCase();
var localHost = location.protocol === 'file:' ||
host === 'localhost' ||
host === '127.0.0.1' ||
host === '0.0.0.0' ||
host === '::1' ||
host === '[::1]' ||
host.endsWith('.localhost') ||
host.endsWith('.local') ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
if (localHost) return;
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
var u = "//web-analysis.yeomai.site/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '3']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
document.addEventListener('click', function(event) {
var link = event.target.closest && event.target.closest('a[download]');
if (!link) return;
var file = (link.getAttribute('href') || '').split('/').pop();
_paq.push(['trackEvent', 'Download', 'direct', file]);
});
})();
</script>
<!-- End Matomo Code -->
</head>
<body>
<header class="topbar">
<div class="wrap topbar-inner">
<a class="brand" href="#top">
<img src="assets/logo.png" alt="SpineThumbnails 图标" width="30" height="30">
<img src="assets/logo.png?v=2" alt="SpineThumbnails 图标" width="30" height="30">
<span class="brand-name">SpineThumbnails</span>
<span class="brand-ver">v1.0.0</span>
<span class="brand-ver">v1.0.1</span>
</a>
<nav class="nav" aria-label="页面导航">
<a href="#features">功能特性</a>
@@ -41,7 +75,7 @@
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2z"/></svg>
</button>
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">获取插件</a>
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">前往Eagle社区获取插件</a>
</div>
</div>
</header>
@@ -51,7 +85,7 @@
<div class="hero-glow" aria-hidden="true"></div>
<div class="wrap hero-grid">
<div class="hero-copy">
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件 · 自动资源配对</span>
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件</span>
<h1>导入完成,<br><span class="grad">缩略图自动就位</span></h1>
<p class="hero-sub">
SpineThumbnails 识别 Eagle 中导入的 Spine 骨骼、Atlas 与贴图,
@@ -59,8 +93,9 @@
从版本识别到渲染、写入、进度反馈,一次完成。
</p>
<div class="hero-cta">
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">前往插件页</a>
<a class="link-arrow" href="#workflow">了解工作流程 →</a>
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">前往Eagle社区获取插件</a>
<a class="btn btn-download" href="assets/download/SpineThumbnailGenerateServer.eagleplugin" download>直接下载</a>
<a class="btn btn-ghost" href="#workflow">了解工作流程</a>
</div>
<ul class="stats">
<li><b>9</b><span>Spine 版本<br>3.4 – 4.3</span></li>
@@ -71,10 +106,10 @@
<div class="hero-visual">
<figure class="shot shot-hero reveal">
<img class="zoomable" src="assets/ui-main.jpg" alt="SpineThumbnails 插件主界面:自动生成开关、生成方式、策略、状态、统计与分类日志" width="1560" height="1296">
<figcaption>SpineThumbnails 1.0.0 · 插件主界面</figcaption>
<figcaption>SpineThumbnails 1.0.1 · 插件主界面</figcaption>
</figure>
<div class="badge-chip reveal">
<img src="assets/logo.png" alt="" width="24" height="24">
<img src="assets/logo.png?v=2" alt="" width="24" height="24">
<span>首次使用默认关闭自动生成,只有主动开启后才会处理新导入的素材。</span>
</div>
</div>
@@ -144,7 +179,7 @@
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 3.5v11"/><circle cx="18" cy="6" r="2.6"/><circle cx="6" cy="17.5" r="2.6"/><path d="M18 8.6a9 9 0 0 1-9 8.9"/></svg></div>
<h3>9 个运行版本</h3>
<p>从 JSON 版本字段或 SKEL 二进制头部识别 Spine 版本,自动匹配 3.4–4.3 对应的本地运行时。</p>
<p>从 JSON 版本字段或 SKEL 二进制头部识别 Spine 版本,自动匹配 3.4–4.3 对应的运行时。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/><path d="M16 15l2 2 3-4"/></svg></div>
@@ -211,13 +246,6 @@
</div>
<div class="mode-grid">
<article class="mode-card reveal">
<div class="mode-visual static" aria-hidden="true">
<span class="pose-ring"></span>
<span class="pose-bone b1"></span>
<span class="pose-bone b2"></span>
<span class="pose-bone b3"></span>
<span class="mode-file">PNG</span>
</div>
<div class="mode-copy">
<span class="mode-label">STATIC</span>
<h3>静帧 · setup pose</h3>
@@ -230,13 +258,6 @@
</div>
</article>
<article class="mode-card reveal">
<div class="mode-visual animated" aria-hidden="true">
<span class="motion-dot d1"></span>
<span class="motion-dot d2"></span>
<span class="motion-dot d3"></span>
<span class="motion-line"></span>
<span class="mode-file">WEBP</span>
</div>
<div class="mode-copy">
<span class="mode-label">ANIMATED</span>
<h3>动态 · 首个动画</h3>
@@ -257,7 +278,7 @@
<div class="section-head reveal">
<span class="kicker">SUPPORTED</span>
<h2>覆盖 Spine 3.4 – 4.3</h2>
<p class="section-lead">支持 JSON、SKEL、SKEL.BYTES、ATLAS 与 ATLAS.TXT,使用与皮肤和约束相匹配的本地运行时渲染。</p>
<p class="section-lead">支持 JSON、SKEL、SKEL.BYTES、ATLAS 与 ATLAS.TXT,使用与皮肤和约束相匹配的运行时渲染。</p>
</div>
<ul class="v-chips reveal" aria-label="支持的 Spine 版本">
<li>3.4</li><li>3.5</li><li>3.6</li><li>3.7</li><li class="sep" aria-hidden="true">·</li>
@@ -312,7 +333,7 @@
<strong>在 Eagle 插件社区获取 SpineThumbnails</strong>
<span class="dl-meta">搜索“Spine缩略图”,或直接打开 Eagle 插件社区。</span>
</span>
<span class="btn btn-primary">前往插件页 →</span>
<span class="btn btn-primary">前往Eagle社区获取插件</span>
</a>
<ol class="install-steps reveal">
<li>
@@ -366,10 +387,10 @@
<footer class="footer">
<div class="wrap footer-inner">
<div class="foot-brand">
<img src="assets/logo.png" alt="" width="34" height="34">
<img src="assets/logo.png?v=2" alt="" width="34" height="34">
<div>
<strong>SpineThumbnails</strong>
<span>Eagle 插件 · v1.0.0</span>
<span>Eagle 插件 · v1.0.1</span>
</div>
</div>
</div>
@@ -211,6 +211,14 @@ html[data-theme="dark"] .icon-moon { display: none; }
.btn-ghost:hover { border-color: color-mix(in srgb, var(--cyan) 45%, var(--border)); transform: translateY(-1px); }
.btn-download {
background: linear-gradient(135deg, #1976d2, #4fc1ff);
color: #fff;
box-shadow: 0 10px 26px rgba(25, 118, 210, .28);
}
.btn-download:hover { transform: translateY(-1px); box-shadow: 0 14px 30px rgba(25, 118, 210, .36); }
.btn-sm { padding: 8px 15px; font-size: 13px; border-radius: 9px; }
.link-arrow {
@@ -1282,123 +1290,6 @@ html[data-theme="light"] .viewer {
box-shadow: var(--card-shadow);
}
.mode-visual {
position: relative;
height: 190px;
overflow: hidden;
border-bottom: 1px solid var(--border);
background:
linear-gradient(130deg, color-mix(in srgb, var(--accent) 15%, var(--panel-2)), transparent 62%),
radial-gradient(240px 180px at 78% 22%, color-mix(in srgb, var(--cyan) 15%, transparent), transparent 70%),
var(--panel-2);
}
.mode-visual::before {
content: "";
position: absolute;
inset: 0;
opacity: .32;
background-image:
linear-gradient(color-mix(in srgb, var(--text-dim) 12%, transparent) 1px, transparent 1px),
linear-gradient(90deg, color-mix(in srgb, var(--text-dim) 12%, transparent) 1px, transparent 1px);
background-size: 28px 28px;
mask-image: linear-gradient(to bottom right, #000, transparent 78%);
}
.pose-ring {
position: absolute;
left: 50%;
top: 50%;
width: 112px;
height: 112px;
border: 1px solid color-mix(in srgb, var(--accent) 55%, transparent);
border-radius: 50%;
transform: translate(-50%, -50%);
box-shadow: 0 0 44px color-mix(in srgb, var(--accent) 18%, transparent);
}
.pose-bone {
position: absolute;
left: 50%;
top: 50%;
height: 7px;
width: 138px;
border-radius: 999px;
background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent-2) 75%, #fff));
transform-origin: 12% 50%;
box-shadow: 0 0 16px color-mix(in srgb, var(--accent) 35%, transparent);
}
.pose-bone::before,
.pose-bone::after {
content: "";
position: absolute;
top: 50%;
width: 18px;
height: 18px;
border-radius: 50%;
border: 5px solid var(--panel);
background: #fff;
transform: translateY(-50%);
box-shadow: 0 2px 10px rgba(0, 0, 0, .28);
}
.pose-bone::before { left: -8px; }
.pose-bone::after { right: -8px; }
.pose-bone.b1 { transform: translate(-46px, -48px) rotate(24deg); }
.pose-bone.b2 { transform: translate(-18px, 3px) rotate(-28deg); }
.pose-bone.b3 { transform: translate(-56px, -21px) rotate(67deg); }
.motion-line {
position: absolute;
left: 18%;
right: 18%;
top: 50%;
height: 2px;
transform: rotate(-9deg);
background: linear-gradient(90deg, transparent, var(--cyan), transparent);
opacity: .7;
}
.motion-dot {
position: absolute;
width: 58px;
height: 58px;
border-radius: 17px;
border: 1px solid color-mix(in srgb, var(--cyan) 45%, var(--border));
background: color-mix(in srgb, var(--panel) 86%, transparent);
box-shadow: 0 12px 30px rgba(0, 0, 0, .28);
}
.motion-dot::after {
content: "";
position: absolute;
left: 13px;
top: 14px;
width: 28px;
height: 28px;
border-radius: 8px;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
box-shadow: inset 0 0 0 6px rgba(255, 255, 255, .12);
}
.motion-dot.d1 { left: 18%; top: 24px; transform: rotate(-14deg) scale(.72); opacity: .45; }
.motion-dot.d2 { left: 42%; top: 64px; transform: rotate(6deg); }
.motion-dot.d3 { right: 18%; top: 34px; transform: rotate(16deg) scale(.78); opacity: .55; }
.mode-file {
position: absolute;
right: 16px;
bottom: 14px;
padding: 5px 10px;
border: 1px solid color-mix(in srgb, var(--cyan) 42%, var(--border));
border-radius: 999px;
background: color-mix(in srgb, var(--panel) 86%, transparent);
color: var(--cyan);
font: 700 10.5px/1.4 ui-monospace, "SFMono-Regular", Consolas, monospace;
letter-spacing: .12em;
}
.mode-copy { padding: 24px; }
.mode-label {
@@ -1458,6 +1349,5 @@ html[data-theme="light"] .viewer {
@media (max-width: 520px) {
.safety-band { padding: 42px 0; }
.safety-grid li { padding: 18px 18px 18px 54px; }
.mode-visual { height: 170px; }
.mode-copy { padding: 20px; }
}
Binary file not shown.
@@ -1,6 +1,6 @@
// 官方 Raptor 示例(Spine 4.3.75-beta 导出)的 JS 内嵌副本。
// 用途:以 file:// 直接打开页面时,浏览器不允许 fetch 本地文本,本地图片也会污染画布,
// 这里预先内嵌 JSON、Atlas 与贴图的 data URL,保证离线与 file:// 场景下同样可以渲染。
// 这里预先内嵌 JSON、Atlas 与贴图的 data URL,保证 file:// 场景下同样可以渲染。
// 数据来源:Esoteric Software spine-runtimes 仓库 examples/raptor/export
// 固定提交:ba17cf88b198c6531c6c51d8d4e1e4b3c3ab47dd(Spine Runtimes License,见 LICENSE.txt)。
window.SPINE_DEMO_DATA = {
+54 -51
View File
@@ -3,10 +3,10 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SpineViewer — Eagle 插件 · 离线预览 Spine 骨骼动画</title>
<meta name="description" content="SpineViewer 是一个完全离线的 Eagle 插件,在素材库中直接预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt),内置 3.4–4.3 全部官方运行时,支持动画与皮肤切换、关键帧时间轴、PMA 预乘与自动缩略图。">
<title>SpineViewer — Eagle 插件 · Spine 骨骼动画预览</title>
<meta name="description" content="SpineViewer 是一款 Eagle 插件,在素材库中直接预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt),内置 3.4–4.3 全部官方运行时,支持动画与皮肤切换、关键帧时间轴、PMA 预乘与自动缩略图。">
<link rel="icon" href="assets/logo-spineviewer.png">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css?v=4">
<noscript><style>.reveal { opacity: 1; transform: none; }</style></noscript>
<script>
// 提前应用主题,避免首屏闪白
@@ -15,6 +15,40 @@
document.documentElement.dataset.theme = saved || 'dark';
} catch (e) {}
</script>
<!-- Matomo -->
<script>
(function() {
var host = location.hostname.toLowerCase();
var localHost = location.protocol === 'file:' ||
host === 'localhost' ||
host === '127.0.0.1' ||
host === '0.0.0.0' ||
host === '::1' ||
host === '[::1]' ||
host.endsWith('.localhost') ||
host.endsWith('.local') ||
/^10\./.test(host) ||
/^192\.168\./.test(host) ||
/^172\.(1[6-9]|2\d|3[01])\./.test(host);
if (localHost) return;
var _paq = window._paq = window._paq || [];
_paq.push(['trackPageView']);
_paq.push(['enableLinkTracking']);
var u = "//web-analysis.yeomai.site/";
_paq.push(['setTrackerUrl', u + 'matomo.php']);
_paq.push(['setSiteId', '3']);
var d = document, g = d.createElement('script'), s = d.getElementsByTagName('script')[0];
g.async = true; g.src = u + 'matomo.js'; s.parentNode.insertBefore(g, s);
document.addEventListener('click', function(event) {
var link = event.target.closest && event.target.closest('a[download]');
if (!link) return;
var file = (link.getAttribute('href') || '').split('/').pop();
_paq.push(['trackEvent', 'Download', 'direct', file]);
});
})();
</script>
<!-- End Matomo Code -->
</head>
<body>
@@ -27,7 +61,7 @@
</a>
<nav class="nav" aria-label="页面导航">
<a href="#features">功能特性</a>
<a href="#ui">实际界面</a>
<a href="#demo">交互演示</a>
<a href="#versions">支持版本</a>
<a href="#install">安装使用</a>
<a href="#faq">常见问题</a>
@@ -37,7 +71,7 @@
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/></svg>
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2z"/></svg>
</button>
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">下载插件</a>
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">前往Eagle社区获取插件</a>
</div>
</div>
</header>
@@ -49,22 +83,22 @@
<div class="hero-glow" aria-hidden="true"></div>
<div class="wrap hero-grid">
<div class="hero-copy">
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件 · 完全离线 · 无 CDN</span>
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件</span>
<h1>在 Eagle 里直接预览<br><span class="grad">Spine 骨骼动画</span></h1>
<p class="hero-sub">
SpineViewer 接管 <code>skel</code> / <code>skel.bytes</code> / <code>json</code> / <code>atlas</code> / <code>atlas.txt</code>,
自动在素材库中配对骨骼、图集与贴图,识别版本并加载本地运行时。
自动在素材库中配对骨骼、图集与贴图,识别版本并加载匹配的运行时。
点开任意一个文件,就能播放动画、切换皮肤、导出缩略图。
</p>
<div class="hero-cta">
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">下载插件</a>
<a class="btn btn-ghost" href="#ui">体验界面</a>
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=SpineViewer" target="_blank" rel="noopener">前往Eagle社区获取插件</a>
<a class="btn btn-download" href="assets/download/SpineViewer.eagleplugin" download>直接下载</a>
<a class="btn btn-ghost" href="#demo">显示交互演示</a>
<a class="link-arrow" href="#features">功能特性 →</a>
</div>
<ul class="stats">
<li><b>9</b><span>内置运行时版本<br>3.4 – 4.3</span></li>
<li><b>8</b><span>种界面语言<br>简繁英日韩俄西德</span></li>
<li><b>0</b><span>网络请求<br>运行时全部本地化</span></li>
<li><b>400px</b><span>缩略图自动生成<br>可手动更新</span></li>
</ul>
</div>
@@ -100,11 +134,6 @@
<p class="section-lead">下面每一项都对应插件的真实行为:跨 item 文件夹配对、旧版二进制解析、WebGL 渲染、缩略图补刷……不只是“能看动画”。</p>
</div>
<div class="features">
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M12 20h.01"/><path d="M5 12.6a10 10 0 0 1 14 0"/><path d="M8.5 16.1a5 5 0 0 1 7 0"/><path d="M2.5 2.5l19 19"/></svg></div>
<h3>完全离线运行</h3>
<p>官方运行时全部拷贝在插件本地 <code>lib/</code> 目录,无 CDN、无网络请求。断网、内网环境同样可用。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 15l6-6"/><path d="M10.5 6.6L12.1 5a4 4 0 0 1 5.7 5.7l-1.6 1.6"/><path d="M13.5 17.4L11.9 19a4 4 0 0 1-5.7-5.7l1.6-1.6"/></svg></div>
<h3>跨文件夹自动配对</h3>
@@ -118,7 +147,7 @@
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="3" width="18" height="18" rx="3"/><path d="M8.5 12.2l2.5 2.5 4.7-5"/></svg></div>
<h3>旧版二进制兼容</h3>
<p>3.4–3.7 的 <code>.skel</code> / <code>.skel.bytes</code> 由移植自官方 <code>SkeletonBinary</code> 的解析层处理,保留网格、权重、变形、约束、裁剪与绘制顺序,无需编译或联网。</p>
<p>3.4–3.7 的 <code>.skel</code> / <code>.skel.bytes</code> 由移植自官方 <code>SkeletonBinary</code> 的解析层处理,保留网格、权重、变形、约束、裁剪与绘制顺序。</p>
</article>
<article class="feature-card reveal">
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M10 8.4l6 3.6-6 3.6z"/></svg></div>
@@ -159,40 +188,14 @@
</div>
</section>
<!-- ======== 实际界面 ======== -->
<section class="section alt" id="ui">
<!-- ======== 交互演示 ======== -->
<section class="section alt" id="interaction">
<div class="wrap">
<div class="section-head reveal">
<span class="kicker">REAL UI</span>
<h2>实际界面</h2>
<p class="section-lead">截图取自下方的交互演示,与插件查看器的控件布局、文案完全一致。浮动控制栏、动画列表与时间轴全部悬浮在画布上方,不挤占预览区域。</p>
</div>
<div class="gallery">
<figure class="shot shot-main reveal">
<img class="zoomable" src="assets/ui-window.png" alt="查看器完整窗口截图" width="1600" height="818">
<figcaption>查看器窗口 · 点击可放大</figcaption>
</figure>
<div class="crop-grid">
<figure class="shot crop toolbar reveal">
<img class="zoomable" src="assets/ui-toolbar.png" alt="浮动控制栏截图" width="1600" height="72">
<figcaption>控制栏:皮肤、播放/暂停、循环、速度、背景、版本号、重载</figcaption>
</figure>
<figure class="shot crop animlist reveal">
<img class="zoomable" src="assets/ui-animlist.png" alt="动画列表截图" width="256" height="382">
<figcaption>动画列表:自适应宽度,条目多时独立滚动</figcaption>
</figure>
<figure class="shot crop timeline reveal">
<img class="zoomable" src="assets/ui-timeline.png" alt="时间轴截图" width="1600" height="90">
<figcaption>时间轴:时长与帧号、秒刻度、蓝色关键帧短线</figcaption>
</figure>
</div>
</div>
<div class="demo-block">
<div class="demo-head reveal">
<h3>交互演示</h3>
<p>复刻查看器的控件布局与文案,直接在页面上操作:切换动画、拖动时间轴、缩放画布试试。画面使用官方示例 <strong>Raptor</strong>(Spine 4.3.75-beta 导出,5 个动画),由 4.3 官方运行时 + 插件同款渲染模块在本地实时渲染,断网或直接双击打开同样可用。</p>
<div class="section-head reveal" id="demo">
<span class="kicker">INTERACTIVE DEMO</span>
<h2>交互演示</h2>
<p class="section-lead">复刻查看器的控件布局与文案,直接在页面上操作:切换动画、拖动时间轴、缩放画布试试。画面使用官方示例 <strong>Raptor</strong>(Spine 4.3.75-beta 导出,5 个动画),由 4.3 官方运行时 + 插件同款渲染模块实时渲染。</p>
</div>
<div class="viewer reveal" id="viewerDemo" tabindex="0" aria-label="SpineViewer 查看器界面交互演示">
@@ -306,7 +309,7 @@
<li class="step reveal">
<span class="step-num">03</span>
<h3>识别版本</h3>
<p>读取骨骼数据中的版本号(JSON 读 <code>skeleton.spine</code>,SKEL 读二进制头部),加载匹配的本地运行时。</p>
<p>读取骨骼数据中的版本号(JSON 读 <code>skeleton.spine</code>,SKEL 读二进制头部),加载匹配的运行时。</p>
</li>
<li class="step reveal">
<span class="step-num">04</span>
@@ -331,7 +334,7 @@
<strong>在 Eagle 插件社区获取 SpineViewer</strong>
<span class="dl-meta">插件页提供下载与安装说明;本页面不提供任何文件下载。</span>
</span>
<span class="btn btn-primary">前往插件页 →</span>
<span class="btn btn-primary">前往Eagle社区获取插件</span>
</a>
<p class="dl-note reveal">原生二进制需要与目标平台匹配;如需自行构建,可在源码目录执行 <code>npm ci --omit=dev</code> 后从 Eagle 插件面板打包。</p>
@@ -412,7 +415,7 @@
<img alt="放大查看界面截图">
</div>
<!-- 官方 Spine 4.3 运行时、插件同款渲染模块与演示示例(全部本地打包,无网络请求) -->
<!-- 官方 Spine 4.3 运行时、插件同款渲染模块与演示示例 -->
<script>
// 插件模块是 CommonJS 文件,这里用最小的 module / require 垫片在浏览器中加载它们。
window.__svLibs = {};
+12 -38
View File
@@ -1,7 +1,6 @@
/* ==========================================================================
SpineViewer 插件介绍网站样式
- 品牌色取自插件图标(红),查看器演示区颜色取自 viewer/spine.html
- 零外部依赖,可离线打开
========================================================================== */
:root {
@@ -212,6 +211,14 @@ html[data-theme="dark"] .icon-moon { display: none; }
.btn-ghost:hover { border-color: color-mix(in srgb, var(--cyan) 45%, var(--border)); transform: translateY(-1px); }
.btn-download {
background: linear-gradient(135deg, #1976d2, #4fc1ff);
color: #fff;
box-shadow: 0 10px 26px rgba(25, 118, 210, .28);
}
.btn-download:hover { transform: translateY(-1px); box-shadow: 0 14px 30px rgba(25, 118, 210, .36); }
.btn-sm { padding: 8px 15px; font-size: 13px; border-radius: 9px; }
.link-arrow {
@@ -309,7 +316,7 @@ html[data-theme="dark"] .icon-moon { display: none; }
.stats {
list-style: none;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-columns: repeat(3, 1fr);
gap: 12px;
padding: 0;
margin: 0;
@@ -516,39 +523,9 @@ html[data-theme="dark"] .f-icon { color: color-mix(in srgb, var(--accent) 80%, #
font-size: 12.5px;
}
/* ---------- 实际界面 ---------- */
/* ---------- 交互演示 ---------- */
.gallery { display: grid; gap: 16px; }
.crop-grid {
display: grid;
grid-template-columns: minmax(170px, 215px) 1fr;
gap: 16px;
align-items: start;
}
.crop.toolbar { grid-column: 1 / -1; }
.crop.animlist { grid-column: 1; }
.crop.timeline { grid-column: 2; }
.crop.animlist img {
display: block;
width: 100%;
max-width: 128px;
margin: 18px auto;
box-shadow: 0 6px 18px rgba(0, 0, 0, .35);
border-radius: 6px;
}
.crop.toolbar img, .crop.timeline img { border-radius: 6px; }
.demo-block { margin-top: 58px; }
.demo-head h3 { margin: 0 0 8px; font-size: 19px; letter-spacing: -0.01em; }
.demo-head p { margin: 0 0 20px; color: var(--text-dim); font-size: 14.5px; line-height: 1.75; max-width: 780px; }
#interaction { padding-top: 48px; }
/* ---------- 查看器界面复刻(配色与 viewer/spine.html 一致) ---------- */
@@ -1153,9 +1130,6 @@ html[data-theme="light"] .viewer {
@media (max-width: 1020px) {
.hero-grid { grid-template-columns: 1fr; gap: 36px; }
.stats { grid-template-columns: repeat(2, 1fr); }
.crop-grid { grid-template-columns: 1fr; }
.crop.toolbar, .crop.animlist, .crop.timeline { grid-column: 1; }
.crop.animlist img { max-width: 132px; }
}
@media (max-width: 880px) {
@@ -1181,7 +1155,7 @@ html[data-theme="light"] .viewer {
@media (max-width: 520px) {
.wrap { padding: 0 16px; }
.stats { grid-template-columns: 1fr 1fr; gap: 10px; }
.stats { grid-template-columns: 1fr; gap: 10px; }
.brand-ver { display: none; }
.viewer { height: 520px; }
.v-tl-labels { min-width: 96px; }
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine-Vorschaubilder",
"noticeIntro": "Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente ohne eigenes Vorschaubild.",
"noticeSafety": "Nur neu importierte Elemente werden verarbeitet. Vorhandene Elemente werden weder durchsucht noch geändert, und eigene Vorschaubilder werden nie überschrieben.",
"noticeModes": "Animiert verwendet die erste Animation und exportiert WebP. Statisch verwendet immer die Setup-Pose.",
"noticeDefault": "Die automatische Erstellung ist standardmäßig ausgeschaltet. Aktiviere sie bei Bedarf unten.",
"noticeIntro": "Funktion: Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente, die noch kein Vorschaubild haben.",
"noticeSafety": "1. Die automatische Erstellung ist standardmäßig ausgeschaltet und muss unten manuell aktiviert werden. Nur in diesem Lauf neu importierte Elemente werden verarbeitet; bereits festgelegte eigene Vorschaubilder werden nie überschrieben.",
"noticeCompound": "2. Bei Elementen mit zusammengesetzter Endung werden die Felder Name und Typ in Eagle erst nach Bestätigung der aktuellen und neuen Werte geändert. Die Originalwerte werden gespeichert und können wiederhergestellt werden.",
"autoToggle": "Beim Import Vorschaubilder erstellen",
"clear": "Protokoll leeren",
"restoreNames": "Namen/Typen wiederherstellen",
"compoundConfirmTitle": "Änderung zusammengesetzter Endungen bestätigen",
"compoundConfirmIntro": "Aufgrund der Besonderheit von Dateien mit zusammengesetzter Endung muss Eagle bei diesen Elementen die Felder Name und Typ ändern, damit die erzeugten Vorschaubilder angezeigt werden können. Die Originalwerte werden gespeichert und können in diesem Plugin wiederhergestellt werden.",
"compoundOldValue": "Aktuell",
"compoundNewValue": "Neu",
"compoundSkip": "Überspringen und fortfahren",
"compoundConfirm": "Änderungen anwenden",
"compoundConfirmSelected": "Auswahl anwenden ({count})",
"compoundSkipped": "{label} übersprungen: Änderung der zusammengesetzten Endung nicht bestätigt",
"compoundSourceMismatch": "{label} übersprungen: Eagle-Name {current} stimmt nicht mit Quelldatei {source} überein",
"compoundCheckFailed": "Zusammengesetzte Endung für {label} konnte nicht geprüft werden: {error}",
"compoundNeedsConfirmation": "{label} übersprungen: Felder wurden nach der Bestätigung geändert; bitte erneut prüfen",
"compoundBackupUnavailable": "Zusammengesetzte Endungen können nicht geändert werden, weil die Originalwerte nicht gespeichert werden konnten",
"compoundRolledBack": "Vorschaubilderstellung fehlgeschlagen; Name und Typ für {label} wurden wiederhergestellt",
"compoundRollbackFailed": "Vorschaubilderstellung fehlgeschlagen und {label} konnte nicht automatisch wiederhergestellt werden: {error}",
"restoreEmpty": "Keine gespeicherten Namens-/Typänderungen zum Wiederherstellen",
"restoreTitle": "Elemente zum Wiederherstellen auswählen",
"restoreIntro": "Für diese Elemente sind Originalwerte gespeichert. Wiederherstellbare Elemente sind standardmäßig alle ausgewählt; vom Benutzer geänderte oder nicht lesbare Elemente können nicht ausgewählt werden.",
"restoreCurrent": "Aktuell",
"restoreTarget": "Wiederherstellen zu",
"restoreStatusReady": "Das Element wurde geändert zu {fields}",
"restoreStatusAlreadyOriginal": "Hat bereits den Originalwert; die Bestätigung entfernt nur die Sicherung",
"restoreStatusChanged": "Die aktuellen Felder wurden vom Benutzer geändert; wird übersprungen",
"restoreStatusMissing": "Element nicht gefunden und kann nicht wiederhergestellt werden",
"restoreStatusError": "Aktuelle Werte konnten nicht gelesen werden; Wiederherstellung nicht möglich",
"restoreCancel": "Abbrechen",
"restoreSelected": "Auswahl wiederherstellen ({count})",
"restoreComplete": "Wiederherstellung abgeschlossen: {success} erfolgreich, {skipped} übersprungen, {failed} fehlgeschlagen.",
"restoreFailed": "Namen/Typen konnten nicht wiederhergestellt werden: {error}",
"modeLabel": "Erstellungsmodus",
"modeAnimated": "Animiert (erste Animation)",
"modeStatic": "Statisch (Setup-Pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine Thumbnails",
"noticeIntro": "After importing Spine assets (.skel / .json + .atlas + textures), this plugin creates a skeleton preview for items that do not yet have a custom thumbnail.",
"noticeSafety": "Only newly imported items are processed. Existing items are not scanned or changed, and custom thumbnails are never overwritten.",
"noticeModes": "Animated uses the first animation and exports WebP. Still always uses the setup pose.",
"noticeDefault": "Automatic generation is off by default. Enable it below when needed.",
"noticeIntro": "Feature: After importing Spine assets (.skel / .json + .atlas + textures), this plugin generates a skeleton preview for items that do not yet have a thumbnail.",
"noticeSafety": "1. Automatic generation is off by default and must be enabled below. It only processes items newly imported in this run and never overwrites custom thumbnails that are already set.",
"noticeCompound": "2. For items with compound suffixes, Eagle name/type fields are adjusted only after the current and new values are confirmed. Original values are saved and can be restored.",
"autoToggle": "Generate thumbnails on import",
"clear": "Clear log",
"restoreNames": "Restore names/types",
"compoundConfirmTitle": "Confirm compound suffix change",
"compoundConfirmIntro": "Because compound-suffix files require special handling, Eagle must adjust the name and type fields for these items so the generated thumbnails can be displayed. Original values will be saved and can be restored from this plugin.",
"compoundOldValue": "Current",
"compoundNewValue": "New",
"compoundSkip": "Skip and continue",
"compoundConfirm": "Apply changes",
"compoundConfirmSelected": "Apply selected ({count})",
"compoundSkipped": "Skipped {label}: compound suffix change was not approved",
"compoundSourceMismatch": "Skipped {label}: Eagle name {current} does not match source file {source}",
"compoundCheckFailed": "Could not check the compound suffix for {label}: {error}",
"compoundNeedsConfirmation": "Skipped {label}: fields changed after confirmation; select it again for a new review",
"compoundBackupUnavailable": "Compound suffixes cannot be changed because the original values could not be saved",
"compoundRolledBack": "Thumbnail generation failed; restored the name and type for {label}",
"compoundRollbackFailed": "Thumbnail generation failed and {label} could not be restored automatically: {error}",
"restoreEmpty": "There are no saved name/type changes to restore",
"restoreTitle": "Select items to restore",
"restoreIntro": "These items have saved original values. All restorable items are selected by default; items changed by the user or unavailable for reading cannot be selected.",
"restoreCurrent": "Current",
"restoreTarget": "Restore to",
"restoreStatusReady": "Item was modified to {fields}",
"restoreStatusAlreadyOriginal": "Already has the original value; confirming will only remove the backup",
"restoreStatusChanged": "Current fields were changed by the user; this item will be skipped",
"restoreStatusMissing": "Item not found and cannot be restored",
"restoreStatusError": "Current values could not be read and cannot be restored",
"restoreCancel": "Cancel",
"restoreSelected": "Restore selected ({count})",
"restoreComplete": "Restore complete: {success} succeeded, {skipped} skipped, {failed} failed.",
"restoreFailed": "Failed to restore names/types: {error}",
"modeLabel": "Generation mode",
"modeAnimated": "Animated (first animation)",
"modeStatic": "Still (setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Miniaturas de Spine",
"noticeIntro": "Después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento crea una vista previa del esqueleto para los elementos que aún no tienen una miniatura personalizada.",
"noticeSafety": "Solo se procesan los elementos recién importados. No se buscan ni modifican elementos existentes y nunca se sobrescriben las miniaturas personalizadas.",
"noticeModes": "Animado usa la primera animación y exporta WebP. Estático siempre usa la pose de configuración.",
"noticeDefault": "La generación automática está desactivada de forma predeterminada. Actívala abajo cuando la necesites.",
"noticeIntro": "Función: después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento genera una vista previa del esqueleto para los elementos que aún no tienen una miniatura.",
"noticeSafety": "1. La generación automática está desactivada de forma predeterminada y debe activarse abajo manualmente. Solo procesa los elementos recién importados en esta ejecución y nunca sobrescribe miniaturas personalizadas ya configuradas.",
"noticeCompound": "2. En los elementos con extensión compuesta, los campos de nombre y tipo de Eagle solo se ajustan después de confirmar los valores actuales y nuevos. Los valores originales se guardan y pueden restaurarse.",
"autoToggle": "Generar miniaturas al importar",
"clear": "Borrar registro",
"restoreNames": "Restaurar nombres/tipos",
"compoundConfirmTitle": "Confirmar cambio de extensión compuesta",
"compoundConfirmIntro": "Debido a la naturaleza especial de los archivos con extensión compuesta, Eagle debe ajustar los campos de nombre y tipo de estos elementos para mostrar las miniaturas generadas. Los valores originales se guardarán y podrán restaurarse desde este complemento.",
"compoundOldValue": "Actual",
"compoundNewValue": "Nuevo",
"compoundSkip": "Omitir y continuar",
"compoundConfirm": "Aplicar cambios",
"compoundConfirmSelected": "Aplicar seleccionados ({count})",
"compoundSkipped": "Se omitió {label}: no se aprobó el cambio de extensión compuesta",
"compoundSourceMismatch": "Se omitió {label}: el nombre de Eagle {current} no coincide con el archivo de origen {source}",
"compoundCheckFailed": "No se pudo comprobar la extensión compuesta de {label}: {error}",
"compoundNeedsConfirmation": "Se omitió {label}: los campos cambiaron después de confirmar; vuelve a revisarlo",
"compoundBackupUnavailable": "No se pueden cambiar extensiones compuestas porque no se pudieron guardar los valores originales",
"compoundRolledBack": "La generación falló; se restauraron el nombre y el tipo de {label}",
"compoundRollbackFailed": "La generación falló y no se pudo restaurar {label} automáticamente: {error}",
"restoreEmpty": "No hay cambios de nombre/tipo guardados para restaurar",
"restoreTitle": "Seleccionar elementos para restaurar",
"restoreIntro": "Estos elementos tienen valores originales guardados. Los elementos restaurables se seleccionan todos de forma predeterminada; los modificados por el usuario o ilegibles no se pueden seleccionar.",
"restoreCurrent": "Actual",
"restoreTarget": "Restaurar a",
"restoreStatusReady": "El elemento se modificó a {fields}",
"restoreStatusAlreadyOriginal": "Ya tiene el valor original; al confirmar solo se eliminará la copia de seguridad",
"restoreStatusChanged": "Los campos actuales fueron modificados por el usuario y se omitirán",
"restoreStatusMissing": "El elemento no existe y no se puede restaurar",
"restoreStatusError": "No se pudieron leer los valores actuales y no se puede restaurar",
"restoreCancel": "Cancelar",
"restoreSelected": "Restaurar seleccionados ({count})",
"restoreComplete": "Restauración completada: {success} correctos, {skipped} omitidos, {failed} fallidos.",
"restoreFailed": "No se pudieron restaurar nombres/tipos: {error}",
"modeLabel": "Modo de generación",
"modeAnimated": "Animado (primera animación)",
"modeStatic": "Estático (pose de configuración)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spineサムネイル",
"noticeIntro": "Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込んだ後、カスタムサムネイルがないアイテムにスケルトンプレビューを生成します。",
"noticeSafety": "今回新しく読み込んだアイテムだけを処理し、既存アイテムの走査や変更、カスタムサムネイルの上書きは行いません。",
"noticeModes": "アニメーションは最初のアニメーションを WebP に変換します。静止画は常に setup pose を使用します。",
"noticeDefault": "自動生成は既定でオフです。必要なときに下で有効にしてください。",
"noticeIntro": "機能:Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込むと、サムネイル未設定のアイテムにスケルトンプレビューを生成します。",
"noticeSafety": "1. 自動生成は既定でオフで、下で手動で有効にする必要があります。今回新しく読み込んだアイテムだけを処理し、既に設定されたカスタムサムネイルは上書きしません。",
"noticeCompound": "2. 複合拡張子の項目は、現在値と新しい値を確認した場合にのみ Eagle の名前/種類フィールドを変更します。元の値は保存され、復元できます。",
"autoToggle": "読み込み時にサムネイルを自動生成",
"clear": "ログを消去",
"restoreNames": "名前/種類を復元",
"compoundConfirmTitle": "複合拡張子の変更を確認",
"compoundConfirmIntro": "複合拡張子ファイルの特性上、生成したサムネイルを表示するためには Eagle でこれらの素材の名前と種類フィールドを変更する必要があります。元の値は保存され、このプラグインから復元できます。",
"compoundOldValue": "変更前",
"compoundNewValue": "変更後",
"compoundSkip": "スキップして続行",
"compoundConfirm": "変更を適用",
"compoundConfirmSelected": "選択項目を適用({count})",
"compoundSkipped": "{label} をスキップ:複合拡張子の変更が承認されていません",
"compoundSourceMismatch": "{label} をスキップ:Eagle の名前 {current} が元ファイル {source} と一致しません",
"compoundCheckFailed": "{label} の複合拡張子を確認できませんでした:{error}",
"compoundNeedsConfirmation": "{label} をスキップ:確認後にフィールドが変更されたため、次回もう一度確認してください",
"compoundBackupUnavailable": "元の値を保存できないため、複合拡張子を変更できません",
"compoundRolledBack": "サムネイル生成に失敗したため、{label} の名前と種類を復元しました",
"compoundRollbackFailed": "サムネイル生成に失敗し、{label} を自動復元できませんでした:{error}",
"restoreEmpty": "復元できる名前/種類の変更はありません",
"restoreTitle": "復元する素材を選択",
"restoreIntro": "元の値が保存されている素材です。復元可能な項目は既定ですべて選択されます。後からユーザーが変更した素材や読み取れない素材は選択できません。",
"restoreCurrent": "現在",
"restoreTarget": "復元後",
"restoreStatusReady": "素材は {fields} に変更されています",
"restoreStatusAlreadyOriginal": "すでに元の値です。確認するとバックアップのみ削除します",
"restoreStatusChanged": "現在のフィールドはユーザーが変更済みのためスキップします",
"restoreStatusMissing": "素材が見つからないため復元できません",
"restoreStatusError": "現在値を読み取れないため復元できません",
"restoreCancel": "キャンセル",
"restoreSelected": "選択項目を復元({count})",
"restoreComplete": "復元完了:成功 {success}、スキップ {skipped}、失敗 {failed}。",
"restoreFailed": "名前/種類の復元に失敗しました:{error}",
"modeLabel": "生成方式",
"modeAnimated": "アニメーション(最初のアニメーション)",
"modeStatic": "静止画(setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine 썸네일",
"noticeIntro": "Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져온 뒤 사용자 지정 썸네일이 없는 항목에 스켈레톤 미리보기를 생성합니다.",
"noticeSafety": "이번에 새로 가져온 항목만 처리합니다. 기존 항목을 검색하거나 수정하지 않으며 사용자 지정 썸네일을 덮어쓰지 않습니다.",
"noticeModes": "애니메이션은 첫 번째 애니메이션을 WebP로 만듭니다. 정지 이미지는 항상 setup pose를 사용합니다.",
"noticeDefault": "자동 생성은 기본적으로 꺼져 있습니다. 필요할 때 아래에서 켜세요.",
"noticeIntro": "기능: Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져오면 썸네일이 아직 설정되지 않은 항목에 스켈레톤 미리보기를 생성합니다.",
"noticeSafety": "1. 자동 생성은 기본적으로 꺼져 있으며 아래에서 직접 켜야 합니다. 이번에 새로 가져온 항목만 처리하고 이미 설정된 사용자 지정 썸네일은 덮어쓰지 않습니다.",
"noticeCompound": "2. 복합 확장자 항목은 현재 값과 새 값을 확인한 경우에만 Eagle 이름/유형 필드를 변경합니다. 원래 값은 저장되며 복원할 수 있습니다.",
"autoToggle": "가져올 때 썸네일 자동 생성",
"clear": "로그 지우기",
"restoreNames": "이름/유형 복원",
"compoundConfirmTitle": "복합 확장자 변경 확인",
"compoundConfirmIntro": "복합 확장자 파일의 특성상 생성된 썸네일을 표시하려면 Eagle에서 이 항목들의 이름과 유형 필드를 조정해야 합니다. 원래 값은 저장되며 이 플러그인에서 복원할 수 있습니다.",
"compoundOldValue": "현재 값",
"compoundNewValue": "새 값",
"compoundSkip": "건너뛰고 계속",
"compoundConfirm": "변경 적용",
"compoundConfirmSelected": "선택 항목 적용({count})",
"compoundSkipped": "{label} 건너뜀: 복합 확장자 변경이 승인되지 않았습니다",
"compoundSourceMismatch": "{label} 건너뜀: Eagle 이름 {current}이(가) 실제 원본 파일 {source}과(와) 일치하지 않습니다",
"compoundCheckFailed": "{label}의 복합 확장자를 확인할 수 없습니다: {error}",
"compoundNeedsConfirmation": "{label} 건너뜀: 확인 후 필드가 변경되었습니다. 다음에 다시 확인하세요",
"compoundBackupUnavailable": "원래 값을 저장할 수 없어 복합 확장자를 변경할 수 없습니다",
"compoundRolledBack": "썸네일 생성에 실패하여 {label}의 이름과 유형을 복원했습니다",
"compoundRollbackFailed": "썸네일 생성에 실패했고 {label}을(를) 자동 복원할 수 없습니다: {error}",
"restoreEmpty": "복원할 이름/유형 변경이 없습니다",
"restoreTitle": "복원할 항목 선택",
"restoreIntro": "원래 값이 저장된 항목입니다. 복원 가능한 항목은 기본적으로 모두 선택됩니다. 이후 사용자가 변경했거나 읽을 수 없는 항목은 선택할 수 없습니다.",
"restoreCurrent": "현재",
"restoreTarget": "복원 값",
"restoreStatusReady": "항목이 {fields} 값으로 변경되었습니다",
"restoreStatusAlreadyOriginal": "이미 원래 값입니다. 확인하면 백업만 정리합니다",
"restoreStatusChanged": "현재 필드가 사용자에 의해 변경되어 건너뜁니다",
"restoreStatusMissing": "항목을 찾을 수 없어 복원할 수 없습니다",
"restoreStatusError": "현재 값을 읽을 수 없어 복원할 수 없습니다",
"restoreCancel": "취소",
"restoreSelected": "선택 항목 복원({count})",
"restoreComplete": "복원 완료: 성공 {success}, 건너뜀 {skipped}, 실패 {failed}.",
"restoreFailed": "이름/유형 복원 실패: {error}",
"modeLabel": "생성 방식",
"modeAnimated": "애니메이션(첫 번째 애니메이션)",
"modeStatic": "정지 이미지(setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Миниатюры Spine",
"noticeIntro": "После импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов без пользовательской миниатюры.",
"noticeSafety": "Обрабатываются только новые элементы. Существующие элементы не сканируются и не изменяются, пользовательские миниатюры никогда не перезаписываются.",
"noticeModes": "Анимированный режим использует первую анимацию и экспортирует WebP. Статический всегда использует setup pose.",
"noticeDefault": "Автоматическое создание по умолчанию отключено. Включите его ниже при необходимости.",
"noticeIntro": "Функция: после импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов, у которых ещё нет миниатюры.",
"noticeSafety": "1. Автоматическое создание по умолчанию отключено и должно быть включено вручную ниже. Обрабатываются только элементы, импортированные в текущем запуске; уже заданные пользовательские миниатюры не перезаписываются.",
"noticeCompound": "2. Поля имени и типа Eagle для элементов с составными расширениями изменяются только после подтверждения текущих и новых значений. Исходные значения сохраняются и могут быть восстановлены.",
"autoToggle": "Создавать миниатюры при импорте",
"clear": "Очистить журнал",
"restoreNames": "Восстановить имена/типы",
"compoundConfirmTitle": "Подтвердить изменение составного расширения",
"compoundConfirmIntro": "Из-за особенностей файлов с составными расширениями Eagle должен изменить поля имени и типа этих элементов, чтобы показывать созданные миниатюры. Исходные значения будут сохранены и доступны для восстановления в плагине.",
"compoundOldValue": "Сейчас",
"compoundNewValue": "Новое",
"compoundSkip": "Пропустить и продолжить",
"compoundConfirm": "Применить изменения",
"compoundConfirmSelected": "Применить выбранные ({count})",
"compoundSkipped": "{label} пропущен: изменение составного расширения не подтверждено",
"compoundSourceMismatch": "{label} пропущен: имя Eagle {current} не совпадает с исходным файлом {source}",
"compoundCheckFailed": "Не удалось проверить составное расширение {label}: {error}",
"compoundNeedsConfirmation": "{label} пропущен: поля изменились после подтверждения; проверьте элемент заново",
"compoundBackupUnavailable": "Нельзя изменить составные расширения: исходные значения не удалось сохранить",
"compoundRolledBack": "Создание миниатюры не удалось; имя и тип {label} восстановлены",
"compoundRollbackFailed": "Создание миниатюры не удалось, а {label} не удалось восстановить автоматически: {error}",
"restoreEmpty": "Нет сохранённых изменений имён/типов для восстановления",
"restoreTitle": "Выберите элементы для восстановления",
"restoreIntro": "Для этих элементов сохранены исходные значения. Все доступные элементы выбраны по умолчанию; изменённые пользователем или недоступные элементы выбрать нельзя.",
"restoreCurrent": "Сейчас",
"restoreTarget": "Восстановить до",
"restoreStatusReady": "Элемент изменён на {fields}",
"restoreStatusAlreadyOriginal": "Уже имеет исходное значение; подтверждение только удалит резервную копию",
"restoreStatusChanged": "Текущие поля изменены пользователем; элемент будет пропущен",
"restoreStatusMissing": "Элемент не найден, восстановление невозможно",
"restoreStatusError": "Не удалось прочитать текущие значения, восстановление невозможно",
"restoreCancel": "Отмена",
"restoreSelected": "Восстановить выбранные ({count})",
"restoreComplete": "Восстановление завершено: успешно {success}, пропущено {skipped}, ошибок {failed}.",
"restoreFailed": "Не удалось восстановить имена/типы: {error}",
"modeLabel": "Режим создания",
"modeAnimated": "Анимированный (первая анимация)",
"modeStatic": "Статический (setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine缩略图",
"noticeIntro": "导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
"noticeSafety": "它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。",
"noticeModes": "动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。",
"noticeDefault": "自动生成默认关闭,需要你在下面手动启用。",
"noticeIntro": "功能:导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
"noticeSafety": "1. 自动生成默认关闭,需要你在下面手动启用。它只处理本次新导入的素材,不会覆盖已经设置过的自定义缩略图。",
"noticeCompound": "2. 复合后缀素材只有在确认原值和新值后,才会调整 Eagle 的名称/类型字段;原值会保存并可恢复。",
"autoToggle": "导入时自动生成缩略图",
"clear": "清空日志",
"restoreNames": "恢复名称/类型",
"compoundConfirmTitle": "确认修改复合后缀",
"compoundConfirmIntro": "由于复合后缀文件的特殊性,为了让这些素材显示生成的缩略图,Eagle 需要调整名称和类型字段。原值会保存,可随时在插件面板恢复。",
"compoundOldValue": "原值",
"compoundNewValue": "新值",
"compoundSkip": "跳过并继续",
"compoundConfirm": "确认并应用",
"compoundConfirmSelected": "应用选中({count})",
"compoundSkipped": "已跳过 {label}:未确认修改复合后缀",
"compoundSourceMismatch": "已跳过 {label}:Eagle 名称 {current} 与真实来源文件 {source} 不一致",
"compoundCheckFailed": "检查 {label} 的复合后缀失败:{error}",
"compoundNeedsConfirmation": "已跳过 {label}:确认后字段又发生变化,请下次重新确认",
"compoundBackupUnavailable": "无法修改复合后缀,因为原值备份不可用",
"compoundRolledBack": "缩略图生成失败,已恢复 {label} 的名称和类型",
"compoundRollbackFailed": "缩略图生成失败且无法自动恢复 {label}:{error}",
"restoreEmpty": "没有可恢复的名称/类型修改",
"restoreTitle": "选择要恢复的素材",
"restoreIntro": "以下是插件保存了原值的素材。可恢复项默认全部勾选;后来被用户修改或无法读取的素材不可选择。",
"restoreCurrent": "当前",
"restoreTarget": "恢复为",
"restoreStatusReady": "素材已被修改为 {fields}",
"restoreStatusAlreadyOriginal": "已是原值;确认后仅清理备份",
"restoreStatusChanged": "当前字段已被用户修改,将跳过",
"restoreStatusMissing": "素材不存在,无法恢复",
"restoreStatusError": "无法读取当前值,无法恢复",
"restoreCancel": "取消",
"restoreSelected": "恢复选中({count})",
"restoreComplete": "恢复完成:成功 {success},跳过 {skipped},失败 {failed}。",
"restoreFailed": "恢复名称/类型失败:{error}",
"modeLabel": "生成方式",
"modeAnimated": "动态(第一个动画)",
"modeStatic": "静帧(setup pose)",
@@ -6,12 +6,40 @@
},
"thumb": {
"title": "Spine縮圖",
"noticeIntro": "匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生骨骼預覽圖。",
"noticeSafety": "只處理本次新匯入的素材,不掃描或修改歷史素材,也不會覆寫你已設定的自訂縮圖。",
"noticeModes": "動態會取第一個動畫並壓成 WebP;靜態一律使用 setup pose。",
"noticeDefault": "自動產生預設為關閉,需要時請在下方手動啟用。",
"noticeIntro": "功能:匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生一張骨骼預覽圖。",
"noticeSafety": "1. 自動產生預設為關閉,需要時請在下方手動啟用。只處理本次新匯入的素材,不會覆寫已設定的自訂縮圖。",
"noticeCompound": "2. 複合副檔名素材只有在確認原始值和新值後,才會調整 Eagle 的名稱/類型欄位;原始值會保存並可還原。",
"autoToggle": "匯入時自動產生縮圖",
"clear": "清除記錄",
"restoreNames": "還原名稱/類型",
"compoundConfirmTitle": "確認修改複合副檔名",
"compoundConfirmIntro": "由於複合副檔名檔案的特殊性,為了讓這些素材顯示產生的縮圖,Eagle 需要調整名稱和類型欄位。原始值會保存,可隨時在外掛面板還原。",
"compoundOldValue": "原始值",
"compoundNewValue": "新值",
"compoundSkip": "略過並繼續",
"compoundConfirm": "確認並套用",
"compoundConfirmSelected": "套用選取項目({count})",
"compoundSkipped": "已略過 {label}:未確認修改複合副檔名",
"compoundSourceMismatch": "已略過 {label}:Eagle 名稱 {current} 與實際來源檔案 {source} 不一致",
"compoundCheckFailed": "檢查 {label} 的複合副檔名失敗:{error}",
"compoundNeedsConfirmation": "已略過 {label}:確認後欄位又發生變化,請下次重新確認",
"compoundBackupUnavailable": "無法修改複合副檔名,因為原始值備份不可用",
"compoundRolledBack": "縮圖產生失敗,已還原 {label} 的名稱和類型",
"compoundRollbackFailed": "縮圖產生失敗且無法自動還原 {label}:{error}",
"restoreEmpty": "沒有可還原的名稱/類型修改",
"restoreTitle": "選擇要還原的素材",
"restoreIntro": "以下是外掛保存了原始值的素材。可還原項目預設全部勾選;後來被使用者修改或無法讀取的素材不可選擇。",
"restoreCurrent": "目前",
"restoreTarget": "還原為",
"restoreStatusReady": "素材已被修改為 {fields}",
"restoreStatusAlreadyOriginal": "已是原始值;確認後僅清除備份",
"restoreStatusChanged": "目前欄位已被使用者修改,將略過",
"restoreStatusMissing": "素材不存在,無法還原",
"restoreStatusError": "無法讀取目前值,無法還原",
"restoreCancel": "取消",
"restoreSelected": "還原選取項目({count})",
"restoreComplete": "還原完成:成功 {success},略過 {skipped},失敗 {failed}。",
"restoreFailed": "還原名稱/類型失敗:{error}",
"modeLabel": "產生方式",
"modeAnimated": "動態(第一個動畫)",
"modeStatic": "靜態(setup pose)",
@@ -8,7 +8,6 @@
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
body { display: flex; flex-direction: column; gap: 9px; padding: 14px; box-sizing: border-box; }
h1 { font-size: 15px; margin: 0; font-weight: 600; text-align: center; }
.notice { color: var(--dim); line-height: 1.6; background: var(--panel);
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
.notice p { margin: 0 0 6px; }
@@ -22,9 +21,12 @@
/* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
.toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
.toolbar-group.primary { flex: 1 1 auto; flex-wrap: nowrap; min-width: 0; }
.toolbar-actions { display: flex; flex: 0 0 auto; gap: 8px; }
.toolbar-group.primary .toggle-row { min-width: 220px; }
#clear, #restoreNames { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
#clear:hover { border-color: var(--accent); color: var(--accent); }
#clear:hover, #restoreNames:hover { border-color: var(--accent); color: var(--accent); }
.mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
flex: 0 1 auto;
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
@@ -51,20 +53,56 @@
.filters button { background: var(--panel); color: var(--dim); border: 1px solid var(--border);
border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; }
.filters button.active { color: var(--accent); border-color: var(--accent); }
.compat-dialog { position: fixed; inset: 0; z-index: 100; display: grid; place-items: center;
padding: 18px; background: rgba(0, 0, 0, .62); }
.compat-dialog.hidden { display: none; }
.compat-card { width: min(640px, 100%); max-height: calc(100vh - 36px); display: flex;
flex-direction: column; gap: 12px; padding: 18px; background: var(--panel);
border: 1px solid var(--border); border-radius: 10px; box-shadow: 0 18px 54px rgba(0, 0, 0, .48); }
.compat-card h2 { margin: 0; font-size: 16px; }
.compat-card p { margin: 0; color: var(--dim); line-height: 1.65; }
.compat-list { overflow: auto; border: 1px solid var(--border); border-radius: 6px; }
.compat-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer; }
.compat-row:last-child { border-bottom: 0; }
.compat-row input { margin: 3px 0 0; accent-color: var(--accent); }
.compat-row-content { min-width: 0; }
.compat-file { color: var(--text); font-weight: 500; word-break: break-all; }
.compat-path { color: var(--dim); font-size: 12px; word-break: break-all; }
.compat-change { margin-top: 4px; font-family: ui-monospace, Consolas, monospace; font-size: 12px; word-break: break-all; }
.compat-change code { color: var(--accent); background: transparent; border: 0; padding: 0; }
.compat-actions { display: flex; justify-content: flex-end; gap: 8px; }
.compat-actions button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
border-radius: 6px; padding: 7px 14px; font: inherit; cursor: pointer; }
.compat-actions button.primary { color: #fff; background: #1976d2; border-color: #1976d2; }
.compat-actions button:disabled { opacity: .45; cursor: default; }
.restore-row { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: 9px;
padding: 9px 11px; border-bottom: 1px solid var(--border); cursor: pointer; }
.restore-row:last-child { border-bottom: 0; }
.restore-row.disabled { opacity: .55; cursor: default; }
.restore-row input { margin: 3px 0 0; accent-color: var(--accent); }
.restore-current, .restore-target { margin-top: 4px; font-family: ui-monospace, Consolas, monospace;
font-size: 12px; word-break: break-all; }
.restore-current code, .restore-target code { color: var(--accent); background: transparent; border: 0; padding: 0; }
.restore-status { color: var(--dim); font-size: 12px; margin-top: 3px; }
.restore-status.applied { color: #3fb950; }
.restore-row.disabled .restore-status { color: var(--warn); }
.restore-row.disabled .restore-status.applied { color: #3fb950; }
</style>
</head>
<body>
<h1 data-i18n="thumb.title">Spine缩略图</h1>
<div class="notice">
<p data-i18n="thumb.noticeIntro">导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
<p data-i18n="thumb.noticeSafety">它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</p>
<p data-i18n="thumb.noticeModes">动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。</p>
<p data-i18n="thumb.noticeDefault">自动生成默认关闭,需要你在下面手动启用。</p>
<p data-i18n="thumb.noticeIntro">功能:导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
<p data-i18n="thumb.noticeSafety">1. 自动生成默认关闭,需要你在下面手动启用。它只处理本次新导入的素材,不会覆盖已经设置过的自定义缩略图。</p>
<p data-i18n="thumb.noticeCompound">2. 复合后缀素材只有在确认原值和新值后,才会调整 Eagle 的名称/类型字段;原值会保存并可恢复。</p>
</div>
<div class="toolbar">
<div class="toolbar-group">
<div class="toolbar-group primary">
<label class="toggle-row"><input type="checkbox" id="toggle"><span data-i18n="thumb.autoToggle">导入时自动生成缩略图</span></label>
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
<div class="toolbar-actions">
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
<button id="restoreNames" type="button" data-i18n="thumb.restoreNames">恢复名称/类型</button>
</div>
</div>
<div class="toolbar-group">
<label class="mode-row"><span data-i18n="thumb.modeLabel">生成方式</span>
@@ -92,6 +130,28 @@
<button type="button" data-filter="error" data-i18n="thumb.filterError">错误</button>
</div>
<ul id="log"></ul>
<div id="compatDialog" class="compat-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="compatTitle">
<div class="compat-card">
<h2 id="compatTitle" data-i18n="thumb.compoundConfirmTitle">确认修改复合后缀</h2>
<p data-i18n="thumb.compoundConfirmIntro">由于复合后缀文件的特殊性,为了让这些素材显示生成的缩略图,Eagle 需要调整名称和类型字段。原值会保存,可随时在插件面板恢复。</p>
<div id="compatList" class="compat-list"></div>
<div class="compat-actions">
<button id="compatSkip" type="button" data-i18n="thumb.compoundSkip">跳过并继续</button>
<button id="compatConfirm" class="primary" type="button" data-i18n="thumb.compoundConfirm">确认并应用</button>
</div>
</div>
</div>
<div id="restoreDialog" class="compat-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="restoreTitle">
<div class="compat-card">
<h2 id="restoreTitle" data-i18n="thumb.restoreTitle">选择要恢复的素材</h2>
<p data-i18n="thumb.restoreIntro">以下是插件保存了原值的素材。可恢复项默认全部勾选;后来被用户修改或无法读取的素材不可选择。</p>
<div id="restoreList" class="compat-list"></div>
<div class="compat-actions">
<button id="restoreCancel" type="button" data-i18n="thumb.restoreCancel">取消</button>
<button id="restoreApply" class="primary" type="button" disabled>恢复选中</button>
</div>
</div>
</div>
<script>
// service 页面里的 require 不以插件目录为基准,本地资源要用绝对路径,这里先把根目录算出来。
(function () {
@@ -99,7 +159,7 @@
// Windows 上 pathname 形如 /C:/...,要去掉盘符前那个多余的斜杠;
// macOS/Linux 是 /Users/...,前导斜杠必须留着,否则会变成相对路径。
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
@@ -132,6 +192,8 @@
<script src="js/spine-legacy-binary.js"></script>
<script src="js/spine-badge.js"></script>
<script src="js/settings.js"></script>
<script src="js/compound-compat.js"></script>
<script src="js/instance-coordinator.js"></script>
<script src="js/progress-window.js"></script>
<script src="js/renderer.js"></script>
<script src="js/library-watcher.js"></script>
@@ -0,0 +1,197 @@
// 复合后缀兼容修改:只生成计划并持久化原值;真正修改 Eagle 字段由插件在用户确认后执行。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const STORE_VERSION = 1;
function fullName(name, ext) {
return String(name || '') + (ext ? '.' + ext : '');
}
/** 返回需要展示给用户的字段变更;不需要修改或被安全条件阻断时返回 null / blocked。 */
function planChange(item, eagleItem, splitCompoundName) {
const sourceName = String((item && item.entryName) || '');
const currentName = String((eagleItem && eagleItem.name) || '');
const currentExt = String((eagleItem && eagleItem.ext) || '');
const currentFileName = fullName(currentName, currentExt);
if (!sourceName || currentFileName !== sourceName) {
return {
blocked: true,
reason: 'sourceMismatch',
sourceName,
currentName,
currentExt,
currentFileName,
};
}
const parts = splitCompoundName(sourceName);
if (!parts || (currentName === parts.name && currentExt === parts.ext)) return null;
if (fullName(parts.name, parts.ext) !== sourceName) {
return {
blocked: true,
reason: 'sourceMismatch',
sourceName,
currentName,
currentExt,
currentFileName,
};
}
return {
sourceName,
currentName,
currentExt,
currentFileName,
newName: parts.name,
newExt: parts.ext,
newFileName: fullName(parts.name, parts.ext),
};
}
function emptyStore() {
return { version: STORE_VERSION, entries: {} };
}
function readStore(file) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
if (!data || data.version !== STORE_VERSION || !data.entries || typeof data.entries !== 'object') {
throw new Error('备份文件格式不正确');
}
return data;
} catch (error) {
if (error && error.code === 'ENOENT') return emptyStore();
throw error;
}
}
function writeStore(file, data) {
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = file + '.' + process.pid + '.tmp';
fs.writeFileSync(temp, JSON.stringify(data, null, 2) + '\n', 'utf8');
fs.renameSync(temp, file);
}
function createStore(userDataPath) {
const file = path.join(userDataPath, 'SpineThumbnailGenerateServer', 'compound-name-backups.json');
return {
file,
list() {
return Object.values(readStore(file).entries)
.sort((left, right) => (right.updatedAt || 0) - (left.updatedAt || 0));
},
remember(change) {
const data = readStore(file);
if (!data.entries[change.itemId]) {
data.entries[change.itemId] = {
itemId: change.itemId,
sourceName: change.sourceName,
folderPath: change.folderPath || '',
originalName: change.currentName,
originalExt: change.currentExt,
appliedName: change.newName,
appliedExt: change.newExt,
updatedAt: Date.now(),
};
writeStore(file, data);
}
return data.entries[change.itemId];
},
remove(itemId) {
const data = readStore(file);
if (!data.entries[itemId]) return;
delete data.entries[itemId];
writeStore(file, data);
},
};
}
/**
* 为恢复对话框生成可审计列表,并标出哪些条目允许恢复。
* 用户后来亲自改过的字段不会被覆盖。
*/
async function inspectBackups(store, getItem, sourceExists) {
const entries = store.list();
const rows = [];
for (const entry of entries) {
let item = null;
try {
item = await getItem(entry.itemId);
if (!item) {
let sourcePresent = false;
try {
sourcePresent = sourceExists ? await sourceExists(entry) : false;
} catch (error) { /* 检查失败时保守按缺失处理 */ }
if (sourcePresent) {
rows.push({
entry,
item: null,
currentName: entry.appliedName,
currentExt: entry.appliedExt,
status: 'pluginApplied',
});
} else {
rows.push({ entry, item: null, status: 'missing' });
}
continue;
}
const currentName = String(item.name || '');
const currentExt = String(item.ext || '');
if (currentName === entry.originalName && currentExt === entry.originalExt) {
rows.push({ entry, item, currentName, currentExt, status: 'alreadyOriginal' });
continue;
}
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
rows.push({ entry, item, currentName, currentExt, status: 'changed' });
continue;
}
rows.push({ entry, item, currentName, currentExt, status: 'ready' });
} catch (error) {
rows.push({ entry, item, status: 'error' });
}
}
return rows;
}
async function restoreBackups(store, selectedItemIds, getItem, saveItem) {
const selected = new Set(selectedItemIds || []);
const entries = store.list().filter((entry) => selected.has(entry.itemId));
const result = { total: entries.length, success: 0, skipped: 0, failed: 0 };
for (const entry of entries) {
let item = null;
try {
item = await getItem(entry.itemId);
if (!item) {
result.skipped += 1;
continue;
}
const currentName = String(item.name || '');
const currentExt = String(item.ext || '');
if (currentName === entry.originalName && currentExt === entry.originalExt) {
store.remove(entry.itemId);
result.success += 1;
continue;
}
if (currentName !== entry.appliedName || currentExt !== entry.appliedExt) {
result.skipped += 1;
continue;
}
item.name = entry.originalName;
item.ext = entry.originalExt;
await saveItem(item);
store.remove(entry.itemId);
result.success += 1;
} catch (error) {
result.failed += 1;
}
}
return result;
}
const api = { fullName, planChange, createStore, inspectBackups, restoreBackups, STORE_VERSION };
if (typeof window !== 'undefined') window.SpineThumbCompoundCompat = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -25,7 +25,8 @@ function normalizeLocale(raw) {
function requestedLocale() {
try {
const fromUrl = new URLSearchParams(location.search).get('lang');
const params = new URLSearchParams(location.search);
const fromUrl = params.get('lang') || params.get('locale');
if (fromUrl) return fromUrl;
} catch (error) { /* No browser location. */ }
try {
@@ -0,0 +1,127 @@
// 多个 Eagle 插件窗口共用 localStorage,用它协调唯一的活动监听实例。
(function () {
'use strict';
const fs = require('fs');
const path = require('path');
const KEY = 'spine-thumbnail-active-instance';
const STALE_MS = 3500;
function createFileStorage(file) {
return {
file,
getItem(key) {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return typeof data[key] === 'string' ? data[key] : null;
} catch (error) {
return null;
}
},
setItem(key, value) {
let data = {};
try {
const existing = JSON.parse(fs.readFileSync(file, 'utf8'));
if (existing && typeof existing === 'object') data = existing;
} catch (error) { /* first write or damaged file */ }
data[key] = String(value);
fs.mkdirSync(path.dirname(file), { recursive: true });
const temp = file + '.' + process.pid + '.' + Date.now() + '.' + Math.random().toString(16).slice(2) + '.tmp';
fs.writeFileSync(temp, JSON.stringify(data), 'utf8');
fs.renameSync(temp, file);
},
};
}
function readState(storage) {
try {
const state = JSON.parse(storage.getItem(KEY));
if (!state || typeof state.id !== 'string' || !Number.isFinite(state.updatedAt)) return null;
return state;
} catch (error) {
return null;
}
}
function writeState(storage, id, visible, now) {
const state = { id, visible, updatedAt: now };
try { storage.setItem(KEY, JSON.stringify(state)); } catch (error) { /* 无本地存储时仍可运行 */ }
return state;
}
function createCoordinator(options) {
const storage = options.storage;
const id = options.id;
const visible = options.isVisible || (() => true);
const hide = options.hide || (() => {});
const now = options.now || Date.now;
const staleMs = options.staleMs || STALE_MS;
const intervalMs = options.intervalMs || 1000;
let active = false;
let started = false;
let hiddenForDuplicate = false;
let timer = null;
function claim() {
writeState(storage, id, visible(), now());
hiddenForDuplicate = false;
if (!active) {
active = true;
if (options.onActiveChanged) options.onActiveChanged(true);
}
}
function evaluate() {
const state = readState(storage);
const currentVisible = visible();
const canClaim = !state ||
state.id === id ||
now() - state.updatedAt > staleMs;
const nextActive = canClaim;
if (nextActive) {
claim();
} else if (currentVisible && !hiddenForDuplicate) {
hiddenForDuplicate = true;
try { hide(); } catch (error) { /* 隐藏失败不影响主窗口 */ }
}
if (nextActive !== active) {
active = nextActive;
if (options.onActiveChanged) options.onActiveChanged(active);
}
return active;
}
function handleVisibility() {
if (visible()) claim();
else evaluate();
}
function start() {
if (started) return active;
started = true;
const state = readState(storage);
const canStart = visible() ||
!state ||
state.id === id ||
now() - state.updatedAt > staleMs;
if (canStart) claim();
else evaluate();
timer = setInterval(evaluate, intervalMs);
return active;
}
function stop() {
if (timer) clearInterval(timer);
timer = null;
started = false;
}
return { start, stop, evaluate, handleVisibility, isActive: () => active };
}
const api = { createCoordinator, createFileStorage, KEY, STALE_MS };
if (typeof window !== 'undefined') window.SpineThumbInstanceCoordinator = api;
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
})();
@@ -87,7 +87,6 @@ function createLibraryWatcher(options) {
return false;
}
known = listItemDirs();
onLog(t('thumb.watching', { path: imagesDir, count: known.size }));
try {
watcher = fs.watch(imagesDir, { persistent: true }, () => scheduleFlush());
} catch (error) {
@@ -2,7 +2,7 @@
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
const VERSION = 'v66';
const VERSION = 'v79';
const SHOW_DEV_VERSION = false;
const path = require('path');
@@ -16,7 +16,7 @@ const REQUIRED = [
'SpineThumbI18n',
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
'SpineThumbProgress',
'SpineThumbProgress', 'SpineThumbCompoundCompat', 'SpineThumbInstanceCoordinator',
];
const missing = REQUIRED.filter((name) => !window[name]);
if (missing.length) {
@@ -30,6 +30,8 @@ const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
const settingsStore = window.SpineThumbSettings;
const utils = window.SpineUtils;
const progress = window.SpineThumbProgress;
const compoundCompat = window.SpineThumbCompoundCompat;
const instanceCoordinatorModule = window.SpineThumbInstanceCoordinator;
const statusEl = document.getElementById('status');
const libraryEl = document.getElementById('library');
@@ -37,9 +39,18 @@ const statsEl = document.getElementById('stats');
const logEl = document.getElementById('log');
const toggleEl = document.getElementById('toggle');
const clearEl = document.getElementById('clear');
const restoreNamesEl = document.getElementById('restoreNames');
const filtersEl = document.getElementById('filters');
const modeEl = document.getElementById('mode');
const strategyEl = document.getElementById('strategy');
const compatDialogEl = document.getElementById('compatDialog');
const compatListEl = document.getElementById('compatList');
const compatConfirmEl = document.getElementById('compatConfirm');
const compatSkipEl = document.getElementById('compatSkip');
const restoreDialogEl = document.getElementById('restoreDialog');
const restoreListEl = document.getElementById('restoreList');
const restoreCancelEl = document.getElementById('restoreCancel');
const restoreApplyEl = document.getElementById('restoreApply');
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
@@ -49,6 +60,9 @@ const PROGRESS_MIN_DYNAMIC = 10;
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
let settings = { ...settingsStore.DEFAULTS };
let compatStore = null;
let instanceCoordinator = null;
let pluginUserDataPath = null;
let libraryPath = null;
let imagesDir = null;
const queue = [];
@@ -114,10 +128,19 @@ function loadFolderPaths() {
} catch (error) { /* 读不到就只显示未分类 */ }
}
function folderPathOf(folderIds) {
function optionalFolderPathOf(folderIds) {
if (!folderPaths) loadFolderPaths();
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
return (id && folderPaths.get(id)) || t('thumb.uncategorized');
return (id && folderPaths.get(id)) || '';
}
function folderPathOf(folderIds) {
return optionalFolderPathOf(folderIds) || t('thumb.uncategorized');
}
function backupSourceExists(entry) {
if (!imagesDir || !entry.sourceName) return false;
return fs.existsSync(path.join(imagesDir, entry.itemId + '.info', entry.sourceName));
}
/** 日志里的渲染结果描述。 */
@@ -161,9 +184,247 @@ function countRun(outcome, animated, name) {
}
}
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
function compoundMatchesApproval(plan, approved) {
return plan && !plan.blocked &&
plan.currentName === approved.currentName &&
plan.currentExt === approved.currentExt &&
plan.newName === approved.newName &&
plan.newExt === approved.newExt;
}
function showCompoundConfirmation(changes) {
compatListEl.textContent = '';
const checkboxes = [];
const updateConfirm = () => {
const count = checkboxes.filter((input) => input.checked).length;
compatConfirmEl.disabled = count === 0;
compatConfirmEl.textContent = t('thumb.compoundConfirmSelected', { count });
};
for (const change of changes) {
const plan = change.plan;
const row = document.createElement('label');
row.className = 'compat-row';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = true;
input.dataset.itemId = itemIdOf(change.item.dirName);
input.addEventListener('change', updateConfirm);
checkboxes.push(input);
const content = document.createElement('div');
content.className = 'compat-row-content';
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = plan.sourceName;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = optionalFolderPathOf(change.item.folders);
const values = document.createElement('div');
values.className = 'compat-change';
const oldValue = document.createElement('code');
oldValue.textContent = 'name=' + JSON.stringify(plan.currentName) + '; ext=' + JSON.stringify(plan.currentExt);
const newValue = document.createElement('code');
newValue.textContent = 'name=' + JSON.stringify(plan.newName) + '; ext=' + JSON.stringify(plan.newExt);
values.append(
t('thumb.compoundOldValue') + ' ', oldValue,
' → ', t('thumb.compoundNewValue') + ' ', newValue,
);
content.append(file);
if (location.textContent) content.append(location);
content.append(values);
row.append(input, content);
compatListEl.appendChild(row);
}
updateConfirm();
compatDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 窗口显示失败时仍可处理后续批次 */ }
return new Promise((resolve) => {
const finish = (selected) => {
compatDialogEl.classList.add('hidden');
compatConfirmEl.removeEventListener('click', confirm);
compatSkipEl.removeEventListener('click', skip);
document.removeEventListener('keydown', keydown);
resolve(selected);
};
const confirm = () => finish(checkboxes
.filter((input) => input.checked)
.map((input) => input.dataset.itemId));
const skip = () => finish([]);
const keydown = (event) => {
if (event.key === 'Escape') skip();
};
compatConfirmEl.addEventListener('click', confirm);
compatSkipEl.addEventListener('click', skip);
document.addEventListener('keydown', keydown);
checkboxes[0].focus();
});
}
function fieldsText(name, ext) {
return 'name=' + JSON.stringify(name) + '; ext=' + JSON.stringify(ext);
}
function showRestoreSelection(rows) {
restoreListEl.textContent = '';
const checkboxes = [];
const statusKeys = {
ready: 'restoreStatusReady',
pluginApplied: 'restoreStatusReady',
alreadyOriginal: 'restoreStatusAlreadyOriginal',
changed: 'restoreStatusChanged',
missing: 'restoreStatusMissing',
error: 'restoreStatusError',
};
const updateApply = () => {
const count = checkboxes.filter((input) => !input.disabled && input.checked).length;
restoreApplyEl.disabled = count === 0;
restoreApplyEl.textContent = t('thumb.restoreSelected', { count });
};
for (const row of rows) {
const entry = row.entry;
const canRestore = row.status === 'ready' || row.status === 'alreadyOriginal';
const label = document.createElement('label');
label.className = 'restore-row' + (canRestore ? '' : ' disabled');
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = canRestore;
input.disabled = !canRestore;
input.dataset.itemId = entry.itemId;
input.addEventListener('change', updateApply);
checkboxes.push(input);
const details = document.createElement('div');
const file = document.createElement('div');
file.className = 'compat-file';
file.textContent = entry.sourceName || entry.itemId;
const location = document.createElement('div');
location.className = 'compat-path';
location.textContent = entry.folderPath || '';
const current = document.createElement('div');
current.className = 'restore-current';
current.textContent = t('thumb.restoreCurrent') + ' ' + fieldsText(
row.currentName === undefined ? '' : row.currentName,
row.currentExt === undefined ? '' : row.currentExt,
);
const target = document.createElement('div');
target.className = 'restore-target';
target.textContent = t('thumb.restoreTarget') + ' ' + fieldsText(entry.originalName, entry.originalExt);
const status = document.createElement('div');
status.className = 'restore-status';
if (row.status === 'ready' || row.status === 'pluginApplied') {
status.classList.add('applied');
status.textContent = t('thumb.restoreStatusReady', {
fields: fieldsText(row.currentName, row.currentExt),
});
} else {
status.textContent = t('thumb.' + statusKeys[row.status]);
}
details.append(file);
if (location.textContent) details.append(location);
details.append(current, target, status);
label.append(input, details);
restoreListEl.appendChild(label);
}
updateApply();
restoreDialogEl.classList.remove('hidden');
try {
if (eagle.window && eagle.window.show) {
Promise.resolve(eagle.window.show())
.then(() => eagle.window.focus && eagle.window.focus())
.catch(() => {});
}
} catch (error) { /* 列表仍可通过已打开的面板操作 */ }
return new Promise((resolve) => {
const finish = (value) => {
restoreDialogEl.classList.add('hidden');
restoreCancelEl.removeEventListener('click', cancel);
restoreApplyEl.removeEventListener('click', apply);
document.removeEventListener('keydown', keydown);
resolve(value);
};
const cancel = () => finish(null);
const apply = () => finish(checkboxes
.filter((input) => !input.disabled && input.checked)
.map((input) => input.dataset.itemId));
const keydown = (event) => {
if (event.key === 'Escape') cancel();
};
restoreCancelEl.addEventListener('click', cancel);
restoreApplyEl.addEventListener('click', apply);
document.addEventListener('keydown', keydown);
const first = checkboxes.find((input) => !input.disabled);
(first || restoreCancelEl).focus();
});
}
/** 修改前统一检查已有缩略图、真实文件名和用户确认,避免任何静默元数据变更。 */
async function prepareCompoundCompatibility(items) {
const changes = [];
for (const item of items) {
try {
const eagleItem = await eagle.item.getById(itemIdOf(item.dirName));
if (!eagleItem || eagleItem.customThumbnail === true) continue;
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (!plan) continue;
const label = item.entryName + ' | ' + folderPathOf(item.folders);
if (plan.blocked) {
item.compoundBlocked = plan;
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
continue;
}
changes.push({ item, plan });
} catch (error) {
item.compoundBlocked = { error };
log(t('thumb.compoundCheckFailed', {
label: item.entryName,
error: (error && error.message) || error,
}), 'warn');
}
}
if (!changes.length) return;
if (!compatStore) {
for (const change of changes) change.item.compoundBlocked = { error: 'backup unavailable' };
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
const selectedIds = new Set(await showCompoundConfirmation(changes));
for (const change of changes) {
if (selectedIds.has(itemIdOf(change.item.dirName))) change.item.compoundApproval = change.plan;
else change.item.compoundDeclined = true;
}
}
async function rollbackCompoundChange(eagleItem, backup, label) {
if (!backup) return false;
try {
eagleItem.name = backup.originalName;
eagleItem.ext = backup.originalExt;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
compatStore.remove(backup.itemId);
log(t('thumb.compoundRolledBack', { label }), 'warn');
return true;
} catch (error) {
log(t('thumb.compoundRollbackFailed', {
label,
error: (error && error.message) || error,
}), 'error');
return false;
}
}
/** 一个素材的缩略图生成流程:查重 → 确认兼容变更 → 渲染 → 写临时文件 → 设置 → 清理。 */
async function generateOne(item) {
let tempPath = null;
let compatibilityAttempted = false;
let compatibilityBackup = null;
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
const label = item.entryName + ' | ' + folderPathOf(item.folders);
@@ -176,21 +437,6 @@ async function generateOne(item) {
log(t('thumb.missingItem', { label }), 'warn');
return;
}
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
try {
eagleItem.name = parts.name;
eagleItem.ext = parts.ext;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
} catch (error) {
log(t('thumb.suffixFailed', { label, error: (error && error.message) || error }), 'warn');
}
}
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
if (eagleItem.customThumbnail === true) {
stats.skipped += 1;
@@ -198,6 +444,47 @@ async function generateOne(item) {
log(t('thumb.existingThumbnail', { label }), 'dim');
return;
}
if (item.compoundDeclined || item.compoundBlocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSkipped', { label }), 'warn');
return;
}
const plan = compoundCompat.planChange(item, eagleItem, utils.splitCompoundName);
if (plan && plan.blocked) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundSourceMismatch', {
label,
current: plan.currentFileName,
source: plan.sourceName,
}), 'warn');
return;
}
if (plan) {
const approved = item.compoundApproval;
if (!approved || !compoundMatchesApproval(plan, approved)) {
stats.skipped += 1;
countRun('skipped', false, item.entryName);
log(t('thumb.compoundNeedsConfirmation', { label }), 'warn');
return;
}
if (!compatStore) throw new Error(t('thumb.compoundBackupUnavailable'));
compatibilityBackup = compatStore.remember({
itemId,
sourceName: plan.sourceName,
folderPath: folderPathOf(item.folders),
currentName: plan.currentName,
currentExt: plan.currentExt,
newName: plan.newName,
newExt: plan.newExt,
});
eagleItem.name = plan.newName;
eagleItem.ext = plan.newExt;
compatibilityAttempted = true;
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
log(t('thumb.suffixFixed', { label, name: plan.newFileName }), 'dim');
}
const version = utils.detectVersion(item.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
@@ -228,6 +515,9 @@ async function generateOne(item) {
countRun('generated', rendered.mode === 'animated', item.entryName);
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
} catch (error) {
if (compatibilityAttempted) {
await rollbackCompoundChange(eagleItem, compatibilityBackup, label);
}
stats.failed += 1;
countRun('failed', false, item.entryName);
log(t('thumb.generateFailed', { label, error: (error && error.message) || error }), 'fail');
@@ -400,6 +690,7 @@ async function handleBatchAsync({ added, total, reason }) {
setStatus(t('thumb.statusAutoSkipped'));
return;
}
await prepareCompoundCompatibility(ready);
queue.push(...ready);
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
if (runStats) runStats.total += ready.length;
@@ -445,17 +736,62 @@ async function start() {
libraryPath = resolved;
imagesDir = path.join(resolved, 'images');
folderPaths = null; // 换库了,文件夹树重新读
libraryEl.textContent = resolved;
libraryEl.textContent = path.basename(resolved);
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
}
function startSafely() {
if (instanceCoordinator && !instanceCoordinator.isActive()) return;
start().catch((error) => {
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
});
}
async function initializeInstanceCoordinator(plugin) {
if (instanceCoordinator) {
instanceCoordinator.evaluate();
return;
}
await initializeCompatStore();
const stateFile = pluginUserDataPath
? path.join(pluginUserDataPath, 'SpineThumbnailGenerateServer', 'active-instance.json')
: path.join(os.tmpdir(), 'spine-thumbnail-active-instance.json');
const storage = instanceCoordinatorModule.createFileStorage(stateFile);
const id = String(window.windowID || (plugin && plugin.webContentsID) || Date.now());
instanceCoordinator = instanceCoordinatorModule.createCoordinator({
storage,
id,
isVisible: () => document.visibilityState === 'visible',
hide: () => eagle.window && eagle.window.hide && eagle.window.hide(),
onActiveChanged(active) {
if (active) {
startSafely();
return;
}
libraryWatcher.stop();
queue.length = 0;
try { if (eagle.window && eagle.window.hide) eagle.window.hide(); } catch (error) { /* already hidden */ }
},
intervalMs: 250,
});
document.addEventListener('visibilitychange', () => instanceCoordinator.handleVisibility());
window.addEventListener('beforeunload', () => instanceCoordinator.stop());
instanceCoordinator.start();
}
async function initializeCompatStore() {
if (compatStore) return true;
try {
pluginUserDataPath = await eagle.app.getPath('userData');
compatStore = compoundCompat.createStore(pluginUserDataPath);
return true;
} catch (error) {
log(t('thumb.compoundBackupUnavailable') + ': ' + ((error && error.message) || error), 'warn');
return false;
}
}
async function loadSettings() {
try {
settings = await settingsStore.read();
@@ -490,6 +826,44 @@ clearEl.addEventListener('click', () => {
logEl.textContent = '';
});
restoreNamesEl.addEventListener('click', async () => {
if (!compatStore) {
log(t('thumb.compoundBackupUnavailable'), 'warn');
return;
}
let rows;
try {
rows = await compoundCompat.inspectBackups(
compatStore,
(itemId) => eagle.item.getById(itemId),
backupSourceExists,
);
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
return;
}
if (!rows.length) {
log(t('thumb.restoreEmpty'), 'dim');
return;
}
const selected = await showRestoreSelection(rows);
if (!selected || !selected.length) return;
restoreNamesEl.disabled = true;
try {
const result = await compoundCompat.restoreBackups(
compatStore,
selected,
(itemId) => eagle.item.getById(itemId),
(item) => withTimeout(item.save(), 20000, t('thumb.restoreNames')),
);
log(t('thumb.restoreComplete', result), result.failed ? 'warn' : 'ok');
} catch (error) {
log(t('thumb.restoreFailed', { error: (error && error.message) || error }), 'error');
} finally {
restoreNamesEl.disabled = false;
}
});
modeEl.addEventListener('change', async () => {
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
try {
@@ -532,15 +906,13 @@ if (typeof eagle === 'undefined') {
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
if (i18n) i18n.setLocale(eagle.app && eagle.app.locale);
logVersion();
log(t('thumb.pluginCreated', {
name: (plugin && plugin.manifest && plugin.manifest.name) || t('thumb.title'),
}));
await initializeCompatStore();
try {
await loadSettings();
} catch (error) {
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
}
startSafely();
initializeInstanceCoordinator(plugin);
});
eagle.onLibraryChanged((changedPath) => {
log(t('thumb.switchingLibrary', { path: changedPath }));
@@ -552,7 +924,8 @@ if (typeof eagle === 'undefined') {
if (i18n) i18n.setLocale(i18n.requestedLocale());
logVersion();
log(t('thumb.noCreateEvent'), 'dim');
loadSettings().catch(() => {}).then(startSafely);
Promise.all([initializeCompatStore(), loadSettings().catch(() => {})])
.then(() => initializeInstanceCoordinator());
}
}, 800);
}
@@ -83,7 +83,8 @@ function createPopup() {
...manifest.main,
// 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。
serviceMode: false,
url: 'progress.html?lang=' + encodeURIComponent(i18n ? i18n.locale : 'zh_CN'),
// Eagle 会把 url 交给 pathToFileURL,查询参数必须由 Eagle 追加,否则 ? 会被编码成文件名。
url: 'progress.html',
width: WINDOW_SIZE.width,
height: WINDOW_SIZE.height,
show: true,
@@ -176,6 +176,11 @@ function isTextureFile(name) {
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
function textureFormatOf(name) {
const ext = extOf(name);
return ext === '.jpg' || ext === '.jpeg' ? 'jpeg' : ext;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
@@ -203,7 +208,7 @@ function splitCompoundName(fileName) {
if (!suffix) return null;
const name = String(fileName).slice(0, fileName.length - suffix.length);
if (!name) return null;
return { name, ext: suffix.slice(1) };
return { name, ext: String(fileName).slice(-suffix.length + 1) };
}
/** 从 JSON 文本中读取 spine 版本 */
@@ -749,6 +754,8 @@ function discoverAssets(filePath, options) {
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
const sameFormat = cands.filter((f) => textureFormatOf(f.name) === textureFormatOf(pageFileName));
if (sameFormat.length > 0) cands = sameFormat;
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
@@ -830,6 +837,7 @@ const spineUtilsApi = {
isSkeletonFile,
isAtlasFile,
isTextureFile,
textureFormatOf,
stemOf,
normalizeStem,
DEFAULT_SUFFIXES,
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

After

Width:  |  Height:  |  Size: 53 KiB

@@ -1,6 +1,6 @@
{
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
"version": "1.0.0",
"version": "1.0.1",
"platform": "all",
"arch": "all",
"name": "{{manifest.app.name}}",
@@ -45,7 +45,7 @@
(function () {
try {
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
window.SpinePluginRoot = pathname.replace(/^\/+([A-Za-z]:)/, '$1');
} catch (error) {
window.SpinePluginRoot = '';
}
@@ -173,6 +173,11 @@ function isTextureFile(name) {
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
function textureFormatOf(name) {
const ext = extOf(name);
return ext === '.jpg' || ext === '.jpeg' ? 'jpeg' : ext;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
@@ -726,6 +731,8 @@ function discoverAssets(filePath, options) {
const pageFileName = path.posix.basename(pn);
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
const sameFormat = cands.filter((f) => textureFormatOf(f.name) === textureFormatOf(pageFileName));
if (sameFormat.length > 0) cands = sameFormat;
let hit = selectBest(cands, atlas.fullPath);
if (strictStem && hit && Math.abs(importTimeOf(hit.fullPath) - importTimeOf(atlas.fullPath)) > allowedImportDelta) hit = null;
if (hit) texturePaths[pn] = hit.fullPath;
@@ -807,6 +814,7 @@ module.exports = {
isSkeletonFile,
isAtlasFile,
isTextureFile,
textureFormatOf,
stemOf,
normalizeStem,
DEFAULT_SUFFIXES,
+47
View File
@@ -0,0 +1,47 @@
'use strict';
const assert = require('node:assert/strict');
const fs = require('node:fs');
const os = require('node:os');
const path = require('node:path');
const utils = require('../js/spine-utils');
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spineviewer-mixed-atlas-'));
try {
const images = path.join(root, 'images');
fs.mkdirSync(images);
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
const makeItem = (id, name, body) => {
const directory = path.join(images, id + '.info');
fs.mkdirSync(directory);
fs.writeFileSync(path.join(directory, name), body);
fs.writeFileSync(path.join(directory, 'metadata.json'), JSON.stringify({ folders: [] }));
return path.join(directory, name);
};
const png = makeItem('png', 'hero.png', 'png');
const atlas = makeItem('atlas', 'hero.atlas', [
'hero.png',
'size: 64,64',
'format: RGBA8888',
'filter: Linear,Linear',
'repeat: none',
'',
'hero.jpeg',
'size: 32,32',
'format: RGBA8888',
'filter: Linear,Linear',
'repeat: none',
'',
].join('\n'));
const jpeg = makeItem('jpg', 'hero.jpg', 'jpg');
const assets = utils.discoverAssets(atlas, {
strictStem: true,
searchRoots: [path.dirname(png), path.dirname(atlas), path.dirname(jpeg)],
});
assert.equal(assets.texturePaths['hero.png'], png);
assert.equal(assets.texturePaths['hero.jpeg'], jpeg, 'JPEG atlas page must prefer .jpg over same-stem .png');
assert.equal(utils.textureFormatOf('photo.JPEG'), utils.textureFormatOf('photo.jpg'));
console.log('PASS: SpineViewer resolves mixed PNG/JPEG atlas pages by texture format');
} finally {
fs.rmSync(root, { recursive: true, force: true });
}