增加-data虚幻引擎数据匹配过滤,版本号升级到1.1.1

This commit is contained in:
tianmo committed 2026-09-12 18:51:00 +08:00
1 parent f0470410df
commit ef23e33ad0
125 files changed
+15 -171295

No files matched your search

Binary file not shown.
@@ -1,33 +0,0 @@
Source revision: ef5013143178918ddfc7cabfbddeae7aec7cf84e (official 3.4.02 tag)
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.4.02/spine-ts/build/spine-canvas.js
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.4.02/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
@@ -1,33 +0,0 @@
Source revision: 2cd9467d2736b7f3a0b81382f117c73823bfb44b (official 3.5.51 tag)
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/spine-ts/build/spine-canvas.js
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
@@ -1,62 +0,0 @@
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.6/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.7/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated May 1, 2019. Replaces all prior versions.
*
* Copyright (c) 2013-2019, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY EXPRESS
* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
* NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS
* INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
@@ -1,26 +0,0 @@
Spine Runtimes License Agreement
Last updated April 5, 2025. Replaces all prior versions.
Copyright (c) 2013-2025, Esoteric Software LLC
Integration of the Spine Runtimes into software or otherwise creating
derivative works of the Spine Runtimes is permitted under the terms and
conditions of Section 2 of the Spine Editor License Agreement:
https://esotericsoftware.com/spine-editor-license
Otherwise, it is permitted to integrate the Spine Runtimes into software
or otherwise create derivative works of the Spine Runtimes (collectively,
"Products"), provided that each user of the Products must obtain their own
Spine Editor license and redistribution of the Products in any form must
include this license and copyright notice.
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 lynweklm@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,12 +0,0 @@
# SpineViewer 1.1.0
Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、3.7、3.8、4.0、4.1、4.2、4.3。
## 功能与使用
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内,并保留文件名配对关系。
2. 打开骨骼或图集文件即可预览。普通图片仍使用 Eagle 原生预览。
3. 支持动画和皮肤切换、播放/暂停(空格)、循环、速度、时间轴拖动、缩放适配、背景与预乘 Alpha(PMA)切换。
4. 自动生成带 Spine 标记和版本号的缩略图,也可在预览器中将当前帧设为缩略图。
第三方许可证见 `LICENSES/`,分发时已保留。
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine-Vorschau",
"skin": "Skin",
"play": "▶ Abspielen",
"pause": "⏸ Pause",
"static": "▮ Statisch",
"staticLabel": "Statisch",
"playPauseTitle": "Abspielen / Pause (Leertaste)",
"loop": "Schleife",
"speed": "Geschwindigkeit",
"pma": "Prämultipliziert",
"pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden",
"background": "Hintergrund",
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
"thumbnail": "🖼 Vorschaubild",
"thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen",
"versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden",
"animationList": "Animationsliste",
"animations": "Animationen",
"errorTitle": "Vorschau nicht möglich",
"durationPlaceholder": "Dauer --",
"keyframesLegend": "Blaue Marken: Keyframes",
"keyframesLegendTitle": "Eine blaue Marke zeigt eine Zeit, zu der mindestens eine Bone-, Slot- oder Constraint-Eigenschaft einen Keyframe hat",
"keyframesHint": "Blaue Marken kennzeichnen Keyframes; zum Anzeigen von Zeit und Frame den Zeiger darüber halten",
"timePlaceholder": "0.00 / 0.00 s · 0 / 0 Frames",
"staticPoseTitle": "Statische Pose · Frame 1",
"keyframeTitle": "Keyframe · {time} · Frame {frame}",
"timelineStatic": "Statisch · 1 Frame",
"timelineDuration": "Dauer {time} · {frames} Frames",
"readoutStatic": "Statisch · 1 / 1 Frames",
"readout": "{time} / {total} · {frame} / {totalFrames} Frames",
"rulerStatic": "0 s (statisch)",
"seconds": "{value} s",
"imagePreview": "Bildvorschau (keine passenden Spine-Ressourcen gefunden)",
"generating": "Eagle-Vorschaubild wird erzeugt und aktualisiert…",
"thumbUpdated": "Eagle-Vorschaubild aktualisiert · {format} {size}KB",
"thumbWrittenNoRefresh": "Vorschaubild geschrieben, automatische Aktualisierung fehlgeschlagen: {error}",
"recentered": "Leinwand wieder zentriert",
"pmaOn": "Prämultipliziertes Alpha an",
"pmaOff": "Prämultipliziertes Alpha aus",
"pmaRemembered": "{state} · für dieses Asset gespeichert",
"pmaSaveFailed": "Umgeschaltet, aber Speichern der Einstellung fehlgeschlagen: {error}",
"badgeFailed": "Spine-Badge konnte nicht geladen werden",
"runtimeFailed": "Spine-Laufzeit kann nicht geladen werden: {file}",
"compoundName": "Eagle hat die zusammengesetzte Dateiendung noch nicht synchronisiert; Bibliothek erneut öffnen",
"txtRegistered": "Dieses Element ist noch als TXT registriert. Vorschaubild in Eagle neu erzeugen und Bibliothek erneut öffnen",
"apiHttp": "Eagle-API meldete HTTP {status}",
"noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden",
"noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln",
"thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}",
"imageLoadFailed": "Bild konnte nicht geladen werden",
"settingsNotReady": "Asset-Einstellungen sind noch nicht bereit",
"noSkeletonFile": "Skelettdatei nicht gefunden",
"noSkeletonFileMsg": "Zur ausgewählten Datei wurde keine passende Spine-Skelettdatei (.skel / .skel.bytes / .json) gefunden.\n\nBitte den kompletten Spine-Export in Eagle importieren.",
"noAtlas": "Atlas nicht gefunden",
"noAtlasMsg": "Die passende .atlas- / .atlas.txt-Datei wurde nicht gefunden.",
"unsupportedVersion": "Nicht unterstützte Version",
"unsupportedVersionMsg": "Unbekannte Spine-Version: {version}\nUnterstützte Versionen: {list}",
"unknownVersion": "unbekannt",
"runtimeLoadFailed": "Laufzeit konnte nicht geladen werden",
"atlasLoadFailed": "Atlas konnte nicht geladen werden",
"emptySkeleton": "Leere Skelettdaten",
"emptySkeletonMsg": "Es konnten keine gültigen Skelettdaten gelesen werden.",
"missingFile": "Datei fehlt",
"missingFileMsg": "Es wurde kein Dateipfad-Parameter übergeben.",
"fileNotExist": "Datei existiert nicht",
"loadFailed": "Laden fehlgeschlagen",
"notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.",
"truncated256": "\n\n(nur die ersten 256 KB werden angezeigt)",
"truncated1m": "\n\n(große Datei; nur das erste 1 MB wird angezeigt)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine Preview",
"skin": "Skin",
"play": "▶ Play",
"pause": "⏸ Pause",
"static": "▮ Still",
"staticLabel": "Still",
"playPauseTitle": "Play / pause (Space)",
"loop": "Loop",
"speed": "Speed",
"pma": "Premultiplied",
"pmaTitle": "For textures exported with Premultiplied Alpha (PMA)",
"background": "Background",
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
"thumbnail": "🖼 Set thumbnail",
"thumbnailTitle": "Generate a 400×400 thumbnail from the current frame",
"versionTitle": "Spine version",
"reloadTitle": "Reload folder",
"animationList": "Animation list",
"animations": "Animations",
"errorTitle": "Cannot preview",
"durationPlaceholder": "Duration --",
"keyframesLegend": "Blue ticks: keyframes",
"keyframesLegendTitle": "A blue tick marks a time where at least one bone, slot or constraint property has a keyframe",
"keyframesHint": "Blue ticks mark animation keyframes; hover a tick to see its time and frame",
"timePlaceholder": "0.00 / 0.00 s · 0 / 0 frames",
"staticPoseTitle": "Still pose · frame 1",
"keyframeTitle": "Keyframe · {time} · frame {frame}",
"timelineStatic": "Still · 1 frame",
"timelineDuration": "Duration {time} · {frames} frames",
"readoutStatic": "Still · 1 / 1 frames",
"readout": "{time} / {total} · {frame} / {totalFrames} frames",
"rulerStatic": "0 s (still)",
"seconds": "{value} s",
"imagePreview": "Image preview (no matching Spine assets found)",
"generating": "Generating and updating the Eagle thumbnail…",
"thumbUpdated": "Eagle thumbnail updated · {format} {size}KB",
"thumbWrittenNoRefresh": "Thumbnail written, but auto-refresh failed: {error}",
"recentered": "Canvas re-centered",
"pmaOn": "Premultiplied alpha on",
"pmaOff": "Premultiplied alpha off",
"pmaRemembered": "{state} · saved for this asset",
"pmaSaveFailed": "Toggled, but saving the setting failed: {error}",
"badgeFailed": "Failed to load the Spine badge",
"runtimeFailed": "Cannot load the Spine runtime: {file}",
"compoundName": "Eagle has not synced the compound extension yet; please reopen the library",
"txtRegistered": "This item is still registered as TXT. Regenerate the thumbnail in Eagle and reopen the library",
"apiHttp": "Eagle API returned HTTP {status}",
"noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail",
"noItemId": "Cannot determine the Eagle item ID from the asset path",
"thumbnailFailed": "Failed to generate the thumbnail: {error}",
"imageLoadFailed": "Failed to load the image",
"settingsNotReady": "Asset settings are not ready",
"noSkeletonFile": "Skeleton file not found",
"noSkeletonFileMsg": "Could not locate the Spine skeleton file (.skel / .skel.bytes / .json) that matches the selected file.\n\nPlease import the whole Spine export into Eagle.",
"noAtlas": "Atlas not found",
"noAtlasMsg": "Could not locate the matching .atlas / .atlas.txt file.",
"unsupportedVersion": "Unsupported version",
"unsupportedVersionMsg": "Unrecognized Spine version: {version}\nSupported versions: {list}",
"unknownVersion": "unknown",
"runtimeLoadFailed": "Failed to load the runtime",
"atlasLoadFailed": "Failed to load the atlas",
"emptySkeleton": "Empty skeleton data",
"emptySkeletonMsg": "No valid skeleton data could be read.",
"missingFile": "Missing file",
"missingFileMsg": "No file path parameter was provided.",
"fileNotExist": "File not found",
"loadFailed": "Failed to load",
"notSpineText": "This file is not a .atlas.txt or .skel.bytes Spine asset.",
"truncated256": "\n\n(only the first 256 KB is shown)",
"truncated1m": "\n\n(large file, only the first 1 MB is shown)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Vista previa de Spine",
"skin": "Piel",
"play": "▶ Reproducir",
"pause": "⏸ Pausa",
"static": "▮ Estático",
"staticLabel": "Estático",
"playPauseTitle": "Reproducir / pausar (espacio)",
"loop": "Bucle",
"speed": "Velocidad",
"pma": "Premultiplicado",
"pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)",
"background": "Fondo",
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
"thumbnail": "🖼 Crear miniatura",
"thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual",
"versionTitle": "Versión de Spine",
"reloadTitle": "Recargar carpeta",
"animationList": "Lista de animaciones",
"animations": "Animaciones",
"errorTitle": "No se puede previsualizar",
"durationPlaceholder": "Duración --",
"keyframesLegend": "Marcas azules: fotogramas clave",
"keyframesLegendTitle": "Una marca azul señala un instante en el que al menos una propiedad de hueso, ranura o restricción tiene un fotograma clave",
"keyframesHint": "Las marcas azules indican fotogramas clave; pasa el cursor para ver el tiempo y el fotograma",
"timePlaceholder": "0.00 / 0.00 s · 0 / 0 fotogramas",
"staticPoseTitle": "Pose estática · fotograma 1",
"keyframeTitle": "Fotograma clave · {time} · fotograma {frame}",
"timelineStatic": "Estático · 1 fotograma",
"timelineDuration": "Duración {time} · {frames} fotogramas",
"readoutStatic": "Estático · 1 / 1 fotogramas",
"readout": "{time} / {total} · {frame} / {totalFrames} fotogramas",
"rulerStatic": "0 s (estático)",
"seconds": "{value} s",
"imagePreview": "Vista previa de imagen (no se encontraron recursos Spine correspondientes)",
"generating": "Generando y actualizando la miniatura de Eagle…",
"thumbUpdated": "Miniatura de Eagle actualizada · {format} {size}KB",
"thumbWrittenNoRefresh": "Miniatura escrita, pero falló la actualización automática: {error}",
"recentered": "Lienzo centrado de nuevo",
"pmaOn": "Alfa premultiplicado activado",
"pmaOff": "Alfa premultiplicado desactivado",
"pmaRemembered": "{state} · guardado para este recurso",
"pmaSaveFailed": "Se cambió, pero no se pudo guardar la configuración: {error}",
"badgeFailed": "No se pudo cargar el distintivo de Spine",
"runtimeFailed": "No se puede cargar el entorno de ejecución de Spine: {file}",
"compoundName": "Eagle aún no ha sincronizado la extensión compuesta; vuelve a abrir la biblioteca",
"txtRegistered": "Este elemento sigue registrado como TXT. Vuelve a generar la miniatura en Eagle y reabre la biblioteca",
"apiHttp": "La API de Eagle devolvió HTTP {status}",
"noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura",
"noItemId": "No se puede determinar el ID del elemento de Eagle a partir de la ruta",
"thumbnailFailed": "No se pudo generar la miniatura: {error}",
"imageLoadFailed": "No se pudo cargar la imagen",
"settingsNotReady": "La configuración del recurso aún no está lista",
"noSkeletonFile": "No se encontró el archivo de esqueleto",
"noSkeletonFileMsg": "No se encontró el archivo de esqueleto de Spine (.skel / .skel.bytes / .json) correspondiente al archivo seleccionado.\n\nImporta el conjunto completo de exportación de Spine en Eagle.",
"noAtlas": "No se encontró el Atlas",
"noAtlasMsg": "No se encontró el archivo .atlas / .atlas.txt correspondiente.",
"unsupportedVersion": "Versión no compatible",
"unsupportedVersionMsg": "Versión de Spine no reconocida: {version}\nVersiones compatibles: {list}",
"unknownVersion": "desconocida",
"runtimeLoadFailed": "Error al cargar el entorno de ejecución",
"atlasLoadFailed": "Error al cargar el atlas",
"emptySkeleton": "Datos de esqueleto vacíos",
"emptySkeletonMsg": "No se pudieron leer datos de esqueleto válidos.",
"missingFile": "Falta el archivo",
"missingFileMsg": "No se proporcionó el parámetro de ruta del archivo.",
"fileNotExist": "El archivo no existe",
"loadFailed": "Error al cargar",
"notSpineText": "Este archivo no es un recurso Spine en formato .atlas.txt o .skel.bytes.",
"truncated256": "\n\n(solo se muestran los primeros 256 KB)",
"truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine プレビュー",
"skin": "スキン",
"play": "▶ 再生",
"pause": "⏸ 一時停止",
"static": "▮ 静止",
"staticLabel": "静止",
"playPauseTitle": "再生 / 一時停止(スペース)",
"loop": "ループ",
"speed": "速度",
"pma": "乗算済み",
"pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用",
"background": "背景",
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "🖼 サムネイル生成",
"thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成",
"versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み",
"animationList": "アニメーション一覧",
"animations": "アニメーション",
"errorTitle": "プレビューできません",
"durationPlaceholder": "長さ --",
"keyframesLegend": "青い線:キーフレーム",
"keyframesLegendTitle": "青い線は、いずれかのボーン・スロット・コンストレイントのプロパティにキーフレームがある位置を示します",
"keyframesHint": "青い線はアニメーションのキーフレームです。線にカーソルを合わせると時間とフレームが表示されます",
"timePlaceholder": "0.00 / 0.00 秒 · 0 / 0 フレーム",
"staticPoseTitle": "静止ポーズ · 1 フレーム目",
"keyframeTitle": "キーフレーム · {time} · {frame} フレーム目",
"timelineStatic": "静止 · 1 フレーム",
"timelineDuration": "長さ {time} · {frames} フレーム",
"readoutStatic": "静止 · 1 / 1 フレーム",
"readout": "{time} / {total} · {frame} / {totalFrames} フレーム",
"rulerStatic": "0 秒(静止)",
"seconds": "{value} 秒",
"imagePreview": "画像プレビュー(対応する Spine リソースが見つかりません)",
"generating": "Eagle のサムネイルを生成・更新しています…",
"thumbUpdated": "Eagle のサムネイルを更新しました · {format} {size}KB",
"thumbWrittenNoRefresh": "サムネイルを書き込みましたが、自動更新に失敗しました:{error}",
"recentered": "キャンバスを中央に戻しました",
"pmaOn": "乗算済みアルファ:オン",
"pmaOff": "乗算済みアルファ:オフ",
"pmaRemembered": "{state} · この素材の設定として保存しました",
"pmaSaveFailed": "切り替えましたが、設定の保存に失敗しました:{error}",
"badgeFailed": "Spine バッジの読み込みに失敗しました",
"runtimeFailed": "Spine ランタイムを読み込めません:{file}",
"compoundName": "Eagle が複合拡張子を同期していません。ライブラリを開き直してください",
"txtRegistered": "この項目はまだ TXT として登録されています。Eagle でサムネイルを再生成し、ライブラリを開き直してください",
"apiHttp": "Eagle API が HTTP {status} を返しました",
"noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません",
"noItemId": "素材パスから Eagle の項目 ID を特定できません",
"thumbnailFailed": "サムネイルの生成に失敗しました:{error}",
"imageLoadFailed": "画像の読み込みに失敗しました",
"settingsNotReady": "素材の設定がまだ準備できていません",
"noSkeletonFile": "骨格ファイルが見つかりません",
"noSkeletonFileMsg": "選択したファイルに対応する Spine 骨格ファイル(.skel / .skel.bytes / .json)が見つかりませんでした。\n\n同じ Spine 書き出し一式を Eagle に読み込んでください。",
"noAtlas": "Atlas が見つかりません",
"noAtlasMsg": "対応する .atlas / .atlas.txt ファイルが見つかりませんでした。",
"unsupportedVersion": "未対応のバージョン",
"unsupportedVersionMsg": "認識できない Spine バージョン:{version}\n対応バージョン:{list}",
"unknownVersion": "不明",
"runtimeLoadFailed": "ランタイムの読み込みに失敗しました",
"atlasLoadFailed": "Atlas の読み込みに失敗しました",
"emptySkeleton": "骨格データが空です",
"emptySkeletonMsg": "有効な骨格データを読み取れませんでした。",
"missingFile": "ファイルがありません",
"missingFileMsg": "ファイルパスのパラメーターが指定されていません。",
"fileNotExist": "ファイルが存在しません",
"loadFailed": "読み込みに失敗しました",
"notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。",
"truncated256": "\n\n(先頭 256 KB のみ表示)",
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine 미리보기",
"skin": "스킨",
"play": "▶ 재생",
"pause": "⏸ 일시정지",
"static": "▮ 정지",
"staticLabel": "정지",
"playPauseTitle": "재생 / 일시정지(스페이스)",
"loop": "반복",
"speed": "속도",
"pma": "미리 곱함",
"pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용",
"background": "배경",
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "🖼 썸네일 생성",
"thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성",
"versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기",
"animationList": "애니메이션 목록",
"animations": "애니메이션",
"errorTitle": "미리볼 수 없음",
"durationPlaceholder": "길이 --",
"keyframesLegend": "파란 선: 키프레임",
"keyframesLegendTitle": "파란 선은 하나 이상의 본·슬롯·컨스트레인트 속성에 키프레임이 있는 시점을 나타냅니다",
"keyframesHint": "파란 선은 애니메이션 키프레임입니다. 선에 마우스를 올리면 시간과 프레임이 표시됩니다",
"timePlaceholder": "0.00 / 0.00 초 · 0 / 0 프레임",
"staticPoseTitle": "정지 자세 · 1 프레임",
"keyframeTitle": "키프레임 · {time} · {frame} 프레임",
"timelineStatic": "정지 · 1 프레임",
"timelineDuration": "길이 {time} · {frames} 프레임",
"readoutStatic": "정지 · 1 / 1 프레임",
"readout": "{time} / {total} · {frame} / {totalFrames} 프레임",
"rulerStatic": "0초 (정지)",
"seconds": "{value}초",
"imagePreview": "이미지 미리보기 (함께 있는 Spine 리소스를 찾지 못했습니다)",
"generating": "Eagle 썸네일을 생성하고 갱신하는 중…",
"thumbUpdated": "Eagle 썸네일을 갱신했습니다 · {format} {size}KB",
"thumbWrittenNoRefresh": "썸네일을 저장했지만 자동 새로고침에 실패했습니다: {error}",
"recentered": "캔버스를 가운데로 되돌렸습니다",
"pmaOn": "미리 곱한 알파 켜짐",
"pmaOff": "미리 곱한 알파 꺼짐",
"pmaRemembered": "{state} · 이 에셋의 설정으로 저장했습니다",
"pmaSaveFailed": "전환했지만 설정 저장에 실패했습니다: {error}",
"badgeFailed": "Spine 배지를 불러오지 못했습니다",
"runtimeFailed": "Spine 런타임을 불러올 수 없습니다: {file}",
"compoundName": "Eagle이 복합 확장자를 아직 동기화하지 않았습니다. 라이브러리를 다시 열어 주세요",
"txtRegistered": "이 항목은 아직 TXT로 등록되어 있습니다. Eagle에서 썸네일을 다시 생성하고 라이브러리를 다시 열어 주세요",
"apiHttp": "Eagle API가 HTTP {status}를 반환했습니다",
"noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다",
"noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다",
"thumbnailFailed": "썸네일 생성 실패: {error}",
"imageLoadFailed": "이미지를 불러오지 못했습니다",
"settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다",
"noSkeletonFile": "스켈레톤 파일을 찾을 수 없음",
"noSkeletonFileMsg": "선택한 파일과 짝을 이루는 Spine 스켈레톤 파일(.skel / .skel.bytes / .json)을 찾지 못했습니다.\n\n같은 Spine 내보내기 세트를 Eagle에 함께 가져와 주세요.",
"noAtlas": "Atlas를 찾을 수 없음",
"noAtlasMsg": "짝을 이루는 .atlas / .atlas.txt 파일을 찾지 못했습니다.",
"unsupportedVersion": "지원하지 않는 버전",
"unsupportedVersionMsg": "인식할 수 없는 Spine 버전: {version}\n지원 버전: {list}",
"unknownVersion": "알 수 없음",
"runtimeLoadFailed": "런타임 로드 실패",
"atlasLoadFailed": "Atlas 로드 실패",
"emptySkeleton": "스켈레톤 데이터가 비어 있음",
"emptySkeletonMsg": "유효한 스켈레톤 데이터를 읽지 못했습니다.",
"missingFile": "파일 없음",
"missingFileMsg": "파일 경로 매개변수가 전달되지 않았습니다.",
"fileNotExist": "파일이 존재하지 않음",
"loadFailed": "불러오기 실패",
"notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.",
"truncated256": "\n\n(앞부분 256 KB만 표시)",
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Просмотр Spine",
"skin": "Скин",
"play": "▶ Воспроизвести",
"pause": "⏸ Пауза",
"static": "▮ Статично",
"staticLabel": "Статично",
"playPauseTitle": "Воспроизведение / пауза (пробел)",
"loop": "Повтор",
"speed": "Скорость",
"pma": "Премультипликация",
"pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)",
"background": "Фон",
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "🖼 Создать миниатюру",
"thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра",
"versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку",
"animationList": "Список анимаций",
"animations": "Анимации",
"errorTitle": "Просмотр невозможен",
"durationPlaceholder": "Длительность --",
"keyframesLegend": "Синие метки: ключевые кадры",
"keyframesLegendTitle": "Синяя метка отмечает момент, в котором есть ключевой кадр хотя бы у одного свойства кости, слота или ограничения",
"keyframesHint": "Синие метки обозначают ключевые кадры; наведите курсор, чтобы увидеть время и кадр",
"timePlaceholder": "0.00 / 0.00 с · 0 / 0 кадров",
"staticPoseTitle": "Статичная поза · кадр 1",
"keyframeTitle": "Ключевой кадр · {time} · кадр {frame}",
"timelineStatic": "Статично · 1 кадр",
"timelineDuration": "Длительность {time} · {frames} кадров",
"readoutStatic": "Статично · 1 / 1 кадров",
"readout": "{time} / {total} · {frame} / {totalFrames} кадров",
"rulerStatic": "0 с (статично)",
"seconds": "{value} с",
"imagePreview": "Просмотр изображения (подходящие ресурсы Spine не найдены)",
"generating": "Создание и обновление миниатюры Eagle…",
"thumbUpdated": "Миниатюра Eagle обновлена · {format} {size}KB",
"thumbWrittenNoRefresh": "Миниатюра записана, но автообновление не удалось: {error}",
"recentered": "Холст возвращён в центр",
"pmaOn": "Премультипликация включена",
"pmaOff": "Премультипликация выключена",
"pmaRemembered": "{state} · сохранено для этого ресурса",
"pmaSaveFailed": "Переключено, но сохранить настройку не удалось: {error}",
"badgeFailed": "Не удалось загрузить значок Spine",
"runtimeFailed": "Не удалось загрузить среду выполнения Spine: {file}",
"compoundName": "Eagle ещё не синхронизировал составное расширение. Откройте библиотеку заново",
"txtRegistered": "Этот элемент всё ещё зарегистрирован как TXT. Пересоздайте миниатюру в Eagle и откройте библиотеку заново",
"apiHttp": "Eagle API вернул HTTP {status}",
"noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру",
"noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу",
"thumbnailFailed": "Не удалось создать миниатюру: {error}",
"imageLoadFailed": "Не удалось загрузить изображение",
"settingsNotReady": "Настройки ресурса ещё не готовы",
"noSkeletonFile": "Файл скелета не найден",
"noSkeletonFileMsg": "Не удалось найти файл скелета Spine (.skel / .skel.bytes / .json), соответствующий выбранному файлу.\n\nИмпортируйте весь набор экспорта Spine в Eagle.",
"noAtlas": "Atlas не найден",
"noAtlasMsg": "Не удалось найти соответствующий файл .atlas / .atlas.txt.",
"unsupportedVersion": "Неподдерживаемая версия",
"unsupportedVersionMsg": "Неизвестная версия Spine: {version}\nПоддерживаемые версии: {list}",
"unknownVersion": "неизвестно",
"runtimeLoadFailed": "Не удалось загрузить среду выполнения",
"atlasLoadFailed": "Не удалось загрузить atlas",
"emptySkeleton": "Пустые данные скелета",
"emptySkeletonMsg": "Не удалось прочитать корректные данные скелета.",
"missingFile": "Файл отсутствует",
"missingFileMsg": "Параметр пути к файлу не передан.",
"fileNotExist": "Файл не существует",
"loadFailed": "Не удалось загрузить",
"notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.",
"truncated256": "\n\n(показаны только первые 256 КБ)",
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine 预览",
"skin": "皮肤",
"play": "▶ 播放",
"pause": "⏸ 暂停",
"static": "▮ 静帧",
"staticLabel": "静帧",
"playPauseTitle": "播放/暂停 (空格)",
"loop": "循环",
"speed": "速度",
"pma": "预乘",
"pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图",
"background": "背景",
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "🖼 生成缩略图",
"thumbnailTitle": "使用当前帧生成 400×400 缩略图",
"versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录",
"animationList": "动画列表",
"animations": "动画",
"errorTitle": "无法预览",
"durationPlaceholder": "时长 --",
"keyframesLegend": "蓝色短线:关键帧",
"keyframesLegendTitle": "蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧",
"keyframesHint": "蓝色短线表示动画关键帧;悬停短线可查看时间和帧号",
"timePlaceholder": "0.00 / 0.00 秒 · 0 / 0 帧",
"staticPoseTitle": "静帧姿势 · 第 1 帧",
"keyframeTitle": "关键帧 · {time} · 第 {frame} 帧",
"timelineStatic": "静帧 · 1 帧",
"timelineDuration": "时长 {time} · {frames} 帧",
"readoutStatic": "静帧 · 1 / 1 帧",
"readout": "{time} / {total} · {frame} / {totalFrames} 帧",
"rulerStatic": "0 秒(静帧)",
"seconds": "{value} 秒",
"imagePreview": "图片预览(未检测到配套 Spine 资源)",
"generating": "正在生成并更新 Eagle 缩略图…",
"thumbUpdated": "Eagle 缩略图已更新 · {format} {size}KB",
"thumbWrittenNoRefresh": "缩略图已写入;自动刷新失败:{error}",
"recentered": "已回到画布中心",
"pmaOn": "预乘已开启",
"pmaOff": "预乘已关闭",
"pmaRemembered": "{state} · 已记住此素材的设置",
"pmaSaveFailed": "预乘已切换,但设置保存失败:{error}",
"badgeFailed": "Spine badge 加载失败",
"runtimeFailed": "无法加载 Spine 运行时:{file}",
"compoundName": "Eagle 尚未同步复合后缀名称,请重新打开素材库",
"txtRegistered": "此条目仍按 TXT 登记,请在 Eagle 重新生成缩略图并重新打开素材库",
"apiHttp": "Eagle API 返回 HTTP {status}",
"noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图",
"noItemId": "无法从素材路径识别 Eagle 项目 ID",
"thumbnailFailed": "生成缩略图失败: {error}",
"imageLoadFailed": "图片加载失败",
"settingsNotReady": "素材设置尚未就绪",
"noSkeletonFile": "未找到骨骼文件",
"noSkeletonFileMsg": "未能定位到与所选文件配套的 Spine 骨骼文件(.skel / .skel.bytes / .json)。\n\n请将同一套 Spine 导出文件导入 Eagle。",
"noAtlas": "未找到 Atlas",
"noAtlasMsg": "未能定位到配套的 .atlas / .atlas.txt 文件。",
"unsupportedVersion": "版本不支持",
"unsupportedVersionMsg": "无法识别的 Spine 版本:{version}\n支持的版本:{list}",
"unknownVersion": "未知",
"runtimeLoadFailed": "运行时加载失败",
"atlasLoadFailed": "Atlas 加载失败",
"emptySkeleton": "骨骼数据为空",
"emptySkeletonMsg": "未能读取到有效的骨骼数据。",
"missingFile": "缺少文件",
"missingFileMsg": "未传入文件路径参数。",
"fileNotExist": "文件不存在",
"loadFailed": "加载失败",
"notSpineText": "此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。",
"truncated256": "\n\n(仅显示前 256 KB)",
"truncated1m": "\n\n(文件较大,仅显示前 1 MB)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine 預覽",
"skin": "外觀",
"play": "▶ 播放",
"pause": "⏸ 暫停",
"static": "▮ 靜態",
"staticLabel": "靜態",
"playPauseTitle": "播放/暫停(空白鍵)",
"loop": "循環",
"speed": "速度",
"pma": "預乘",
"pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖",
"background": "背景",
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "🖼 產生縮圖",
"thumbnailTitle": "使用目前影格產生 400×400 縮圖",
"versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄",
"animationList": "動畫清單",
"animations": "動畫",
"errorTitle": "無法預覽",
"durationPlaceholder": "時長 --",
"keyframesLegend": "藍色短線:關鍵影格",
"keyframesLegendTitle": "藍色短線表示動畫中至少一個骨骼、插槽或約束屬性在該時刻有關鍵影格",
"keyframesHint": "藍色短線表示動畫關鍵影格;將游標停在短線上可查看時間與影格",
"timePlaceholder": "0.00 / 0.00 秒 · 0 / 0 影格",
"staticPoseTitle": "靜態姿勢 · 第 1 影格",
"keyframeTitle": "關鍵影格 · {time} · 第 {frame} 影格",
"timelineStatic": "靜態 · 1 影格",
"timelineDuration": "時長 {time} · {frames} 影格",
"readoutStatic": "靜態 · 1 / 1 影格",
"readout": "{time} / {total} · {frame} / {totalFrames} 影格",
"rulerStatic": "0 秒(靜態)",
"seconds": "{value} 秒",
"imagePreview": "圖片預覽(未偵測到配套的 Spine 資源)",
"generating": "正在產生並更新 Eagle 縮圖…",
"thumbUpdated": "Eagle 縮圖已更新 · {format} {size}KB",
"thumbWrittenNoRefresh": "縮圖已寫入;自動重新整理失敗:{error}",
"recentered": "已回到畫布中心",
"pmaOn": "預乘已開啟",
"pmaOff": "預乘已關閉",
"pmaRemembered": "{state} · 已記住此素材的設定",
"pmaSaveFailed": "預乘已切換,但設定儲存失敗:{error}",
"badgeFailed": "Spine badge 載入失敗",
"runtimeFailed": "無法載入 Spine 執行環境:{file}",
"compoundName": "Eagle 尚未同步複合副檔名,請重新開啟素材庫",
"txtRegistered": "此項目仍以 TXT 登錄,請在 Eagle 重新產生縮圖並重新開啟素材庫",
"apiHttp": "Eagle API 回傳 HTTP {status}",
"noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖",
"noItemId": "無法從素材路徑辨識 Eagle 項目 ID",
"thumbnailFailed": "產生縮圖失敗:{error}",
"imageLoadFailed": "圖片載入失敗",
"settingsNotReady": "素材設定尚未就緒",
"noSkeletonFile": "找不到骨骼檔案",
"noSkeletonFileMsg": "找不到與所選檔案搭配的 Spine 骨骼檔案(.skel / .skel.bytes / .json)。\n\n請將同一套 Spine 匯出檔案匯入 Eagle。",
"noAtlas": "找不到 Atlas",
"noAtlasMsg": "找不到搭配的 .atlas / .atlas.txt 檔案。",
"unsupportedVersion": "不支援的版本",
"unsupportedVersionMsg": "無法辨識的 Spine 版本:{version}\n支援的版本:{list}",
"unknownVersion": "未知",
"runtimeLoadFailed": "執行環境載入失敗",
"atlasLoadFailed": "Atlas 載入失敗",
"emptySkeleton": "骨骼資料為空",
"emptySkeletonMsg": "無法讀取到有效的骨骼資料。",
"missingFile": "缺少檔案",
"missingFileMsg": "未傳入檔案路徑參數。",
"fileNotExist": "檔案不存在",
"loadFailed": "載入失敗",
"notSpineText": "此檔案不是 .atlas.txt 或 .skel.bytes 格式的 Spine 資源。",
"truncated256": "\n\n(僅顯示前 256 KB)",
"truncated1m": "\n\n(檔案較大,僅顯示前 1 MB)"
}
}
@@ -1,5 +0,0 @@
# Spine badge
`spine-badge.png` 下载自 Esoteric Software 官方 [Spine Brand assets](https://esotericsoftware.com/branding) 页面。
插件只在缩略图右下角等比缩放显示该 badge,用于标明条目是 Spine 数据文件;图形本身未做拉伸、变色或其他修改。
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

@@ -1,17 +0,0 @@
'use strict';
const path = require('path');
// Preserve name + '.' + ext exactly; only split the compound suffix correctly.
// Return through Eagle's thumbnail callback so Eagle persists its own metadata.
function normalizeItem(src, item) {
const filename = path.basename(src);
const match = filename.match(/^(.*)\.(atlas\.txt|skel\.bytes)$/i);
if (!match || !item || !item.ext) return false;
if (item.name + '.' + item.ext !== filename) return false;
if (item.name === match[1] && item.ext === match[2]) return false;
item.name = match[1]; item.ext = match[2];
delete item.text;
return true;
}
module.exports = { normalizeItem };
@@ -1,25 +0,0 @@
'use strict';
const patterns = new WeakMap();
// Called with the same world-to-canvas transform used to draw the skeleton.
function drawCheckerboard(ctx, width, height) {
let pattern = patterns.get(ctx);
if (!pattern) {
const tile = ctx.canvas.ownerDocument.createElement('canvas');
tile.width = tile.height = 100;
const paint = tile.getContext('2d');
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
}
const m = ctx.getTransform().inverse();
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
if (!Number.isFinite(x + y)) return;
ctx.save();
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
finally { ctx.restore(); }
}
module.exports = { drawCheckerboard };
@@ -1,31 +0,0 @@
const fs = require('fs');
const path = require('path');
const badgePath = path.join(__dirname, '..', 'assets', 'spine-badge.png');
let cachedDataUrl = null;
function dataUrl() {
if (!cachedDataUrl) {
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgePath).toString('base64');
}
return cachedDataUrl;
}
function drawVersion(ctx, width, height, version) {
version = String(version || '');
if (version.length > 40 || !/^\d+\.\d+(?:\.\d+)?(?:[-+][a-z0-9][a-z0-9.-]*)?$/i.test(version)) return;
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055));
const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03));
ctx.save();
try {
ctx.font = '600 ' + fontSize + 'px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const boxHeight = fontSize + 12;
const y = height - margin - boxHeight;
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(margin, y, ctx.measureText(version).width + 16, boxHeight);
ctx.fillStyle = '#ffffff'; ctx.fillText(version, margin + 8, y + boxHeight / 2);
} finally { ctx.restore(); }
}
module.exports = { path: badgePath, dataUrl, drawVersion };
@@ -1,243 +0,0 @@
'use strict';
// The official spine-canvas SkeletonRenderer does not process clipping
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) {
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
}
function imageFromAttachment(attachment) {
const region = attachment && attachment.region;
// 4.0 stores the texture on the atlas page, while the other bundled
// runtimes expose it directly on the attachment region.
const renderRegion = region && region.renderObject;
const texture = region && (region.texture
|| (region.page && region.page.texture)
|| (renderRegion && renderRegion.texture)
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
}
// Canvas 2D always applies source-alpha blending. A Spine texture exported
// with PMA already has RGB multiplied by alpha, so drawing it directly would
// multiply translucent edge pixels a second time. Convert it once to straight
// alpha and cache the result; Canvas can then blend it correctly.
function imageForCanvas(image, context, premultipliedAlpha) {
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
const cached = unpremultipliedImages.get(image);
if (cached) return cached;
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!image.width || !image.height) return image;
let canvas;
if (doc) canvas = doc.createElement('canvas');
else {
try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); }
catch (e) { return image; }
}
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0 && alpha < 255) {
const factor = 255 / alpha;
data[i] = Math.min(255, Math.round(data[i] * factor));
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
}
}
ctx.putImageData(pixels, 0, 0);
unpremultipliedImages.set(image, canvas);
return canvas;
} catch (e) {
// If pixel access is unavailable, retain the original texture so preview
// remains usable instead of failing the whole frame.
return image;
}
}
function unpackVertices(vertices, count) {
const positions = new Array(count * 2);
const uvs = new Array(count * 2);
for (let i = 0, p = 0; i < count; i++, p += 2) {
const source = i * 8;
positions[p] = vertices[source];
positions[p + 1] = vertices[source + 1];
uvs[p] = vertices[source + 6];
uvs[p + 1] = vertices[source + 7];
}
return { positions, uvs };
}
function drawInterleaved(base, image, vertices, triangles, darkColor) {
for (let i = 0; i < triangles.length; i += 3) {
const a = triangles[i] * 8;
const b = triangles[i + 1] * 8;
const c = triangles[i + 2] * 8;
base.drawTriangle(image,
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7],
[...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)],
darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined);
}
}
function createLegacyRenderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const clipper = new spine.SkeletonClipping();
const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) {
const composite = context.globalCompositeOperation;
const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha;
try {
for (let i = 0; i < drawOrder.length; i++) {
const slot = drawOrder[i];
if (slot.bone && slot.bone.active === false) {
clipper.clipEndWithSlot(slot);
continue;
}
const attachment = slot.getAttachment();
if (attachment instanceof spine.ClippingAttachment) {
clipper.clipStart(slot, attachment);
continue;
}
try {
if (!attachment) continue;
let vertices;
let triangles;
let vertexCount;
if (attachment instanceof spine.RegionAttachment) {
vertices = base.computeRegionVertices(slot, attachment, false);
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
vertexCount = 4;
} else if (attachment instanceof spine.MeshAttachment) {
vertices = base.computeMeshVertices(slot, attachment, false);
triangles = attachment.triangles;
vertexCount = attachment.worldVerticesLength >> 1;
} else {
continue;
}
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = base.tempColor.a;
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor);
} else {
drawInterleaved(base, image, vertices, triangles, slot.darkColor);
}
} finally {
clipper.clipEndWithSlot(slot);
}
}
} finally {
clipper.clipEnd();
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = clipper;
return base;
}
// 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
base.draw = skeleton => {
const composite = context.globalCompositeOperation;
try {
for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment();
if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue;
const vertices = attachment.updateWorldVertices(slot, false);
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha);
if (!image) continue;
context.globalAlpha = vertices[5];
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
}
} finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
};
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) {
const composite = context.globalCompositeOperation;
try {
const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, false); command; command = command.next) {
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
const positions = command.positions;
const uvs = command.uvs;
const indices = command.indices;
for (let i = 0; i < command.numIndices; i += 3) {
const a = indices[i] * 2;
const b = indices[i + 1] * 2;
const c = indices[i + 2] * 2;
const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255];
const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2]));
const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2]));
base.drawTriangle(image,
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors);
}
}
} finally {
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = core.clipping;
return base;
}
function createRenderer(spine, context, runtimeDir) {
const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime.
renderer.debugRendering = false;
return require('./spine-webgl-rasterizer.js').attach(renderer, context);
}
module.exports = { createRenderer };
@@ -1,250 +0,0 @@
'use strict';
// Adapted from Esoteric Software's 3.4/3.5/3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// 3.4 source and license: LICENSES/Spine-3.4-License.txt.
// 3.5 source and license: LICENSES/Spine-3.5-License.txt.
// Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once.
const dict = () => Object.create(null);
class Input {
constructor(bytes) {
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.offset = 0;
}
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
byte() { this.need(1); return this.view.getUint8(this.offset++); }
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
varint(positive = true) {
let value = 0;
for (let shift = 0; shift <= 28; shift += 7) {
const b = this.byte();
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
value |= (b & 127) << shift;
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
}
this.fail('无效变长整数');
}
count() { const n = this.varint(); this.need(n); return n; }
string() {
const size = this.varint();
if (size === 0) return null;
this.need(size - 1);
const end = this.offset + size - 1;
let result = '';
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
// code units (including surrogate pairs), rather than string references.
while (this.offset < end) {
const b = this.byte();
if (b < 128) result += String.fromCharCode(b);
else {
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
let code = b & (extra === 1 ? 31 : 15);
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
result += String.fromCharCode(code);
}
}
return result;
}
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
}
function decode(bytes, runtimeDir) {
const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[4567]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.5 / 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7';
const v34 = version === '3.4';
const before36 = v34 || version === '3.5';
skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool();
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
const boneName = () => r.ref(root.bones).name;
const slotName = () => r.ref(root.slots).name;
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
const boneList = () => Array.from({ length: r.count() }, boneName);
for (let i = 0, n = r.count(); i < n; i++) {
const b = { name: r.string() }; if (i) b.parent = boneName();
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
if (v34) { b.inheritRotation = r.bool(); b.inheritScale = r.bool(); }
else b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (nonessential) b.color = r.color(); root.bones.push(b);
}
for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() };
if (!before36) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); }
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
if (!before36) { c.local = r.bool(); c.relative = r.bool(); }
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: slotName(),
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
}
function vertices(n) {
if (!r.bool()) return r.floats(n * 2);
const out = [];
for (let i = 0; i < n; i++) {
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
}
return out;
}
function attachment(key) {
const a = { name: r.string() || key };
const type = r.byte();
if (before36 && type > 4) r.fail(version + ' 不支持此附件类型:' + type);
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) {
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
} else if (type === 2) {
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
a.vertices = vertices(count); a.hull = r.varint();
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
} else if (type === 3) {
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
} else if (type === 5) {
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
} else {
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
if (type === 6) a.end = slotName();
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
if (nonessential) a.color = r.color();
}
return a;
}
const skinNames = [];
function skin(name, isDefault = false) {
const count = r.count(); if (!count && isDefault) return;
const data = root.skins[name] = dict(); skinNames.push(name);
for (let i = 0; i < count; i++) {
const attachments = data[slotName()] = dict();
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
}
}
skin('default', true);
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
const eventNames = [];
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.string(); eventNames.push(name);
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
}
function curve(frame) {
const type = r.byte();
if (type === 1) frame.curve = 'stepped';
else if (type === 2) frame.curve = r.floats(4);
else if (type !== 0) r.fail('未知曲线类型:' + type);
}
function frames(read, curved = true) {
const count = r.count(); if (!count) r.fail('空时间线');
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
}
function animation() {
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.slots[slotName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
else if (type === 2 && !before36) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线');
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.bones[boneName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
if (!name) r.fail('未知骨骼时间线');
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.ik).name;
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.paths[r.ref(root.path).name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
for (let k = 0, q = r.count(); k < q; k++) {
const key = r.string();
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
}
}
}
const drawCount = r.count();
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
const f = { time: r.float(), offsets: [] }; let previous = -1;
for (let i = 0, n = r.count(); i < n; i++) {
const index = r.varint(); const offset = r.varint();
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
}
return f;
});
const eventCount = r.count();
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
});
return a;
}
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
return root;
}
function install(spine, runtimeDir) {
if (!['3.4', '3.5', '3.6', '3.7'].includes(runtimeDir)) return spine;
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
readSkeletonData(bytes) {
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
return loader.readSkeletonData(decode(bytes, runtimeDir));
}
};
return spine;
}
module.exports = { install, decode };
@@ -1,58 +0,0 @@
'use strict';
// Eagle's item API has no arbitrary custom fields. Keep plugin metadata in
// userData, keyed by library + item ID, without editing Eagle metadata.json.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function identity(sourcePath, itemId) {
const directory = path.dirname(path.resolve(sourcePath));
const match = path.basename(directory).match(/^(.+)\.info$/i);
return match
? path.dirname(directory) + ':' + (itemId || match[1])
: path.resolve(sourcePath);
}
function createStore(userDataPath, sourcePath, itemId) {
const key = crypto.createHash('sha256').update(identity(sourcePath, itemId)).digest('hex');
const directory = path.join(userDataPath, 'SpineViewer', 'preview-settings');
const file = path.join(directory, key + '.json');
return {
file,
read() {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return typeof data.premultipliedAlpha === 'boolean' ? data.premultipliedAlpha : undefined;
} catch (e) {
if (e.code === 'ENOENT') return undefined;
throw e;
}
},
// This method is called only by the user's checkbox change event.
write(premultipliedAlpha) {
if (typeof premultipliedAlpha !== 'boolean') throw new TypeError('预乘设置必须为布尔值');
fs.mkdirSync(directory, { recursive: true });
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try {
fs.writeFileSync(temp, JSON.stringify({ premultipliedAlpha }, null, 2) + '\n', { flag: 'wx' });
fs.renameSync(temp, file);
} finally {
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
};
}
async function resolveDataPath(app) {
try {
const value = app && (app.userDataPath || (typeof app.getPath === 'function' && await app.getPath('userData')));
if (typeof value === 'string' && value) return value;
} catch (e) { /* Some Eagle format-view windows reject app.getPath IPC. */ }
const home = require('os').homedir();
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Eagle');
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Eagle');
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
}
module.exports = { createStore, resolveDataPath };
@@ -1,85 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const utils = require('./spine-utils');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function enqueue(src) {
const library = utils.findLibraryRoot(src);
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
fs.mkdirSync(directory, { recursive: true });
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
fs.renameSync(temp, job);
// Electron's Node mode runs the local worker without opening another Eagle.
const child = spawn(process.execPath, [__filename, directory], {
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
});
child.on('error', error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
});
child.unref();
}
async function run(directory) {
const lock = path.join(directory, 'worker.lock');
let fd;
try { fd = fs.openSync(lock, 'wx'); }
catch (error) {
if (error.code !== 'EEXIST') throw error;
let pid;
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
if (!pid) return; // Another worker is still writing its lock.
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
try { fs.unlinkSync(lock); } catch (_) { return; }
return run(directory);
}
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
const snapshots = new Map();
const nextChecks = new Map();
const thumbnail = require('../thumbnail/spine');
try {
let idle = 0;
while (idle < 2) {
await sleep(1000); // Allow the importing callback/window to finish.
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
if (!jobs.length) { idle++; continue; }
idle = 0;
for (const name of jobs) {
const file = path.join(directory, name);
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
const job = JSON.parse(raw);
if (Date.now() < (nextChecks.get(name) || 0)) continue;
const remaining = job.deadline - Date.now();
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
const result = Date.now() > job.deadline ? { complete: true }
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
snapshots.set(name, result.signature);
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
}
} catch (error) {
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
}
}
}
} finally {
fs.unlinkSync(lock);
// Cover an enqueue racing the final idle check and lock release.
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
}
}
module.exports = { enqueue, run };
if (require.main === module) run(process.argv[2]).catch(error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
process.exitCode = 1;
});
@@ -1,217 +0,0 @@
/**
* SpineViewer - 缩略图渲染模块(仅 Node 环境使用)
* 在纯 Node 中加载本地 Spine 运行时(通过 vm 注入到 global.spine),
* 使用 @napi-rs/canvas 光栅化骨骼的 setup pose / 首帧动画,输出 PNG buffer。
*/
const fs = require('fs');
const vm = require('vm');
const path = require('path');
const utils = require('./spine-utils.js');
const spineBadge = require('./spine-badge.js');
let canvasLib = null;
try {
canvasLib = require('@napi-rs/canvas');
} catch (e) {
canvasLib = null;
}
let spineBadgePromise = null;
/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */
async function drawSpineBadge(ctx, width, height) {
if (!canvasLib || !ctx) return;
try {
if (!spineBadgePromise) {
spineBadgePromise = canvasLib.loadImage(spineBadge.path);
}
const badge = await spineBadgePromise;
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
} catch (e) { /* 缺少 badge 时不影响缩略图主体 */ }
}
/**
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
* 的 BinaryInput、DataView 等),因此不会跨版本混用。
*/
function loadRuntime(dir) {
const code = fs.readFileSync(path.join(__dirname, '..', 'lib', dir, 'spine-canvas.js'), 'utf8');
const sandbox = {
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
performance: { now: () => Date.now() },
};
// 部分运行时通过 window/self/globalThis 访问全局
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
}
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => {
(async () => {
// 解析所有页面贴图名,并预先异步加载
let pageNames = [];
try { pageNames = utils.parseAtlasPages(atlasText); } catch (e) { pageNames = []; }
const textureMap = {};
await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
if (!fs.existsSync(p)) return;
try {
const img = await canvasLib.loadImage(p);
textureMap[name] = img;
} catch (e) { /* ignore */ }
}));
const makeTexture = (name) => {
let img = textureMap[name];
if (!img) {
// 找不到图片时用 2x2 灰色占位纹理
const c = canvasLib.createCanvas(2, 2);
const cx = c.getContext('2d');
cx.fillStyle = '#888888';
cx.fillRect(0, 0, 2, 2);
img = c;
}
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
return new spine.Texture(img);
};
let atlas;
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const tex = makeTexture(page.name);
if (page.setTexture) page.setTexture(tex);
else page.texture = tex;
}
} else {
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
resolve(atlas);
})().catch(reject);
});
}
/** 读取骨骼数据(json 或二进制) */
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buf = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buf));
} else {
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
const text = fs.readFileSync(skeletonPath, 'utf8');
return loader.readSkeletonData(text);
}
}
/** 计算包围盒(跨版本兼容) */
function getBounds(spine, skeleton) {
try {
if (skeleton.getBoundsRect) {
const r = skeleton.getBoundsRect();
if (r && r.width !== undefined) return { x: r.x, y: r.y, width: r.width, height: r.height };
}
if (skeleton.getBounds) {
const offset = new spine.Vector2();
const size = new spine.Vector2();
skeleton.getBounds(offset, size, []);
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
}
} catch (e) { /* ignore */ }
return null;
}
/**
* 渲染首帧动画到 PNG buffer。
* @returns {Promise<Buffer>}
*/
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
const name = path.basename(skeletonPath);
throw new Error('骨骼数据为空:' + name);
}
const skeleton = new spine.Skeleton(skeletonData);
utils.applySkin(skeleton, utils.preferredSkin(skeletonData));
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
let bounds = null;
if (skeleton.setToSetupPose) skeleton.setToSetupPose();
if (skeleton.updateWorldTransform) {
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
else skeleton.updateWorldTransform();
}
bounds = getBounds(spine, skeleton);
const cw = size, ch = size;
const canvas = canvasLib.createCanvas(cw, ch);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, cw, ch);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((cw * (1 - pad * 2)) / bounds.width, (ch * (1 - pad * 2)) / bounds.height);
// Spine 世界坐标 Y 轴向上,而 Canvas 2D Y 轴向下,需翻转 Y。
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
ctx.save();
ctx.translate(cw / 2, ch / 2);
ctx.scale(scale, -scale);
ctx.translate(-cx, -cy);
let renderer = null;
try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); }
catch (e) {
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
}
if (renderer) {
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
renderer.premultipliedAlpha = premultipliedAlpha;
renderer.draw(skeleton);
if (renderer.dispose) renderer.dispose();
}
ctx.restore();
}
await drawSpineBadge(ctx, cw, ch);
spineBadge.drawVersion(ctx, cw, ch, skeletonData.version || version);
const buf = canvas.toBuffer('image/png');
return buf;
}
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib };
@@ -1,714 +0,0 @@
/**
* SpineViewer - 工具模块
* 该模块为纯 Node.js CommonJS,供 viewer/thumbnail 复用。
* 职责:
* - 识别 Spine 导出文件的类型(skel/skel.bytes/json/atlas/atlas.txt/png/jpg/jpeg/webp)
* - 在同一个目录内自动关联配套的骨骼文件、atlas、贴图
* - 从骨骼数据中检测 Spine 版本
* - 根据版本选择合适的本地运行时
*/
const fs = require('fs');
const path = require('path');
const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json'];
const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
const fileIndexCache = new Map();
const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉
const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i;
function extOf(name) {
const lower = name.toLowerCase();
for (let i = 0; i < SKELETON_EXTS.length; i++) {
if (lower.endsWith(SKELETON_EXTS[i])) return SKELETON_EXTS[i];
}
for (let i = 0; i < ATLAS_EXTS.length; i++) {
if (lower.endsWith(ATLAS_EXTS[i])) return ATLAS_EXTS[i];
}
for (let i = 0; i < TEXTURE_EXTS.length; i++) {
if (lower.endsWith(TEXTURE_EXTS[i])) return TEXTURE_EXTS[i];
}
return null;
}
function isSkeletonFile(name) {
const ext = extOf(name);
return ext === '.skel' || ext === '.skel.bytes' || ext === '.json';
}
function isAtlasFile(name) {
const ext = extOf(name);
return ext === '.atlas' || ext === '.atlas.txt';
}
function isTextureFile(name) {
const ext = extOf(name);
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
if (!ext) return name;
return name.slice(0, name.length - ext.length);
}
/** 归一化 stem,去掉 Spine 常见后缀,用于跨文件匹配 */
function normalizeStem(name) {
return stemOf(name).toLowerCase().replace(SUFFIX_RE, '');
}
/** 从 JSON 文本中读取 spine 版本 */
function detectVersionFromJson(text) {
try {
const json = JSON.parse(text);
if (json && json.skeleton && json.skeleton.spine) {
return String(json.skeleton.spine);
}
} catch (e) { /* ignore */ }
return null;
}
/** 从二进制数据中读取 spine 版本(扫描前若干字节中的版本号) */
function detectVersionFromBuffer(buf) {
let s = '';
const bytes = new Uint8Array(buf);
const len = Math.min(bytes.length, 160);
for (let i = 0; i < len; i++) {
s += String.fromCharCode(bytes[i]);
}
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
return m ? m[1] : null;
}
/** 从文件读取 spine 版本 */
function detectVersion(filePath) {
const lower = filePath.toLowerCase();
if (lower.endsWith('.json')) {
return detectVersionFromJson(fs.readFileSync(filePath, 'utf8'));
}
return detectVersionFromBuffer(fs.readFileSync(filePath));
}
/**
* 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
*/
function chooseRuntimeDir(version, isBinary) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
if (major === 3) {
const minor = parseFloat(version);
if (minor === 3.4) return '3.4';
if (minor === 3.5) return '3.5';
if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8';
} else if (major === 4) {
const minor = parseFloat(version);
if (minor <= 4.0 + 0.0001) return '4.0';
if (minor <= 4.1 + 0.0001) return '4.1';
if (minor <= 4.2 + 0.0001) return '4.2';
return '4.3';
}
return null;
}
/**
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
*/
function classifySkeletonJson(filePath) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size === 0) return 'pending';
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
const cached = skeletonJsonCache.get(filePath);
if (cached && cached.signature === signature) return cached.kind;
const length = Math.min(stat.size, JSON_PROBE_BYTES);
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(length);
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
const text = buffer.toString('utf8');
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i);
let kind;
if (versionMatch) {
kind = 'spine';
} else if (stat.size > JSON_PROBE_BYTES) {
kind = 'other';
} else {
try {
JSON.parse(text);
kind = 'other';
} catch (e) {
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
kind = hasSkeletonKey ? 'pending' : 'other';
}
}
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
skeletonJsonCache.set(filePath, { signature, kind });
return kind;
} catch (e) {
return 'pending';
}
}
function looksLikeSkeletonJson(filePath) {
return classifySkeletonJson(filePath) === 'spine';
}
/** 文件修改时间(毫秒) */
function mtimeOf(fp) {
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
}
/** 读取某 item 元数据里的创建时间 btime(毫秒),用于按“同一批导入”配对 */
function btimeOf(fp) {
const d = itemMeta(path.dirname(fp));
return (d && d.btime) || 0;
}
/**
* Eagle item 的实际入库时间。
* metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的
* btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。
*/
function importTimeOf(fp) {
try {
const metaPath = path.join(path.dirname(fp), 'metadata.json');
const stat = fs.statSync(metaPath);
return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0;
} catch (e) {
return btimeOf(fp);
}
}
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
function versionFamily(version) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return major <= 3 ? '3x' : '4x';
}
/** 根据 atlas 文本判断其格式家族(3x / 4x) */
function atlasFamily(atlasText) {
if (!atlasText) return null;
// 4.x atlas 用 `bounds:` 定义区域;3.x 用 `xy:`/`size:`/`orig:`/`offset:`。
// 注意:`scale:` 在 3.x 也会出现(如字体缩放),不能单独作为 4x 依据。
// 因此以 `bounds:` 作为最可靠的 4.x 标志;若整体含 `xy:` 判定为 3x。
if (/\bbounds\s*:/.test(atlasText)) return '4x';
if (/\bxy\s*:/.test(atlasText)) return '3x';
return '3x';
}
/** 读取某 item 文件夹的 metadata.json(含 folders、isDeleted、btime 等) */
function itemMeta(itemDir) {
if (!itemDir) return null;
try {
return JSON.parse(fs.readFileSync(path.join(itemDir, 'metadata.json'), 'utf8'));
} catch (e) {
return null;
}
}
/** 读取某个资产文件所属 item 文件夹的 Eagle 逻辑文件夹 id 列表 */
function foldersOfItem(filePath) {
const d = itemMeta(path.dirname(filePath));
return d && Array.isArray(d.folders) ? d.folders : [];
}
/** 判断某 item 是否已被放入 Eagle 回收站(isDeleted) */
function isItemDeleted(filePath) {
const d = itemMeta(path.dirname(filePath));
return !!(d && d.isDeleted);
}
/** 从任意 item 路径向上定位 Eagle 库根目录 */
function findLibraryRoot(filePath) {
let dir = path.dirname(filePath); // item 文件夹
for (let i = 0; i < 8; i++) {
const parent = path.dirname(dir);
if (parent === dir) break;
try {
if (fs.existsSync(path.join(parent, 'metadata.json')) && fs.existsSync(path.join(parent, 'images'))) {
return parent;
}
} catch (e) { /* ignore */ }
dir = parent;
}
return null;
}
/** 解析库根 metadata.json,返回 { folderId -> children[] } 及文件夹名 */
function readFolderInfo(libRoot) {
const map = {};
const names = {};
if (!libRoot) return { map, names };
try {
const d = JSON.parse(fs.readFileSync(path.join(libRoot, 'metadata.json'), 'utf8'));
const folders = d.folders || [];
for (const f of folders) {
if (!f) continue;
map[f.id] = Array.isArray(f.children) ? f.children : [];
names[f.id] = f.name || '';
}
} catch (e) { /* ignore */ }
return { map, names };
}
/**
* 计算允许匹配的 Eagle 文件夹 id 集合:
* 仅包含「选中文件所在的文件夹」本身,不下钻子文件夹,也不匹配父级/兄弟文件夹。
*/
function getValidFolders(filePath) {
const itemFolders = foldersOfItem(filePath);
if (itemFolders.length === 0) return null; // 未归入任何 Eagle 文件夹,则不限定
return new Set(itemFolders);
}
/** 判断目录是否为 Eagle 的 item 元数据文件夹(包含 metadata.json) */
function isEagleItemDir(dir) {
try { return fs.existsSync(path.join(dir, 'metadata.json')); } catch (e) { return false; }
}
/**
* 计算需要搜索的目录列表。
* Eagle 会把导入的每个文件拆分成独立的元数据文件夹(如 `images/<id>.info/`),
* 因此配套的骨骼/atlas/贴图可能位于不同的 item 文件夹中。
* 返回:当前 item 文件夹 + 其同级的所有 item 文件夹。
*/
function getSearchRoots(filePath) {
const roots = [];
const itemDir = path.dirname(filePath);
roots.push(itemDir);
if (isEagleItemDir(itemDir)) {
const parent = path.dirname(itemDir);
try {
for (const name of fs.readdirSync(parent)) {
const sub = path.join(parent, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
return [...new Set(roots)];
}
/** 把库根目录 images/ 下所有 item 文件夹加入搜索根(去重)。
* 用于覆盖同伴位于不同 .info / 跨批次的情况(全库兜底)。 */
function buildRootsWithLibraryRoot(filePath, roots) {
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return;
for (const name of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
if (roots.indexOf(sub) === -1) roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
/**
* 在多个目录中收集所有 Spine 相关文件,返回 [{ name, fullPath }]。
* 若传入 validFolders(Eagle 逻辑文件夹 id 集合),则只保留位于这些文件夹内的 item,
* 从而把匹配范围限定在「选中文件所在的 Eagle 文件夹及其子文件夹」内。
*/
function collectSpineFiles(roots, validFolders) {
const seen = new Set();
const out = [];
for (const root of roots) {
let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) {
const full = path.join(root, name);
try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; }
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) {
const itemFolders = foldersOfItem(full);
if (!itemFolders.some((f) => validFolders.has(f))) continue; // 不在允许的文件夹内
}
if (seen.has(full)) continue;
seen.add(full);
out.push({ name, fullPath: full });
}
}
return out;
}
/**
* 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个
* 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉
* 正在落盘的新文件。
*/
function collectSpineFilesCached(filePath, roots, refresh) {
const libRoot = findLibraryRoot(filePath);
// Outside an Eagle library, sibling asset folders are independent search
// scopes. Sharing their parent as a cache key can return another skeleton.
const key = libRoot || JSON.stringify([...roots].sort());
const now = Date.now();
const cached = fileIndexCache.get(key);
if (!refresh && cached && now - cached.time < 1500) return cached.files;
const files = collectSpineFiles(roots, null);
fileIndexCache.set(key, { time: now, files });
return files;
}
/**
* 修正不可靠的源路径。
* Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录
* (例如其内部临时工作目录)。此时按文件名在整个库 images/ 下找到真实存在的
* 源文件,作为后续资源发现的基准。
*/
function resolveRealSource(filePath) {
// 源目录存在且是有效 item 目录 → 直接用
const dir = path.dirname(filePath);
if (isEagleItemDir(dir)) return filePath;
const name = path.basename(filePath);
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return filePath;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return filePath;
// 优先找“文件名 + 类型”匹配的真实文件。若存在同名文件,返回它所在目录,
// 以便后续 getSearchRoots/selectBest 能借助其 folders/btime 精确匹配。
let fallback = null;
for (const d of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, d);
if (!isEagleItemDir(sub)) continue;
const cand = path.join(sub, name);
if (fs.existsSync(cand)) {
// 优先选择未被删除的项
if (!isItemDeleted(cand)) return cand;
if (!fallback) fallback = cand;
}
}
if (fallback) return fallback;
} catch (e) { /* ignore */ }
return filePath;
}
/**
* 发现与所选文件 related 的 Spine 资源(跨 Eagle 多个 item 文件夹)。
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
*/
function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const requestedCrossBatch = !!options.allowCrossBatch;
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
const roots = getSearchRoots(filePath);
// 若同级范围无法凑齐骨架+图集(常见于 Eagle 导入瞬间,同伴落在别的 .info),
// 则扩展搜索到整个库的 images/ 目录(全库兜底,靠“同家族 + 同文件夹 + 最近 btime”配对)。
buildRootsWithLibraryRoot(filePath, roots);
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
const validFolders = getValidFolders(filePath);
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
let files = collectSpineFiles(roots, validFolders);
if (strictStem) {
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
if (allowCrossBatch && validFolders && validFolders.size) {
files = allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)));
} else {
files = allFiles;
}
}
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
const textureFiles = files.filter((f) => isTextureFile(f.name));
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
// 避免每个回调都解析库中所有普通 JSON。
if (strictStem) {
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
}
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
const realSkeletonFiles = skeletonFiles.filter((f) => {
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
return true;
});
// 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。
// 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。
const selectBest = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const refFolders = foldersOfItem(refFile);
const refImportTime = importTimeOf(refFile);
const refBtime = btimeOf(refFile);
const ranked = cands.map((c) => {
const cFolders = foldersOfItem(c.fullPath);
return {
c,
importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER,
sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0,
btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER,
};
});
ranked.sort((a, b) => allowCrossBatch
? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
: (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
);
return ranked[0].c;
};
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
const familyOfAtlas = (f) => { try { return atlasFamily(fs.readFileSync(f.fullPath, 'utf8')); } catch (e) { return null; } };
const familyOfSkel = (f) => { try { return versionFamily(detectVersion(f.fullPath)); } catch (e) { return null; } };
// 选中的文件若是 atlas,则读取其格式家族,用于匹配同家族的骨骼
const selectedAtlasFamily = isAtlasFile(selected)
? (() => { try { return atlasFamily(fs.readFileSync(filePath, 'utf8')); } catch (e) { return null; } })()
: null;
// 1) 骨骼文件
let skeleton = null;
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
if (isSkeletonFile(selected)) {
skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) ||
realSkeletonFiles.find((f) => f.name === selected) || null;
}
if (!skeleton) {
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
let cands = realSkeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
if (selectedAtlasFamily && cands.length > 1) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = cands.length > 0 ? selectBest(cands, filePath) : null;
}
if (!skeleton && realSkeletonFiles.length > 0) {
if (strictStem) {
skeleton = null;
} else {
// 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取
let cands = realSkeletonFiles;
if (selectedAtlasFamily) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = selectBest(cands, filePath);
}
}
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
let atlas = null;
if (isAtlasFile(selected)) {
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
atlasFiles.find((f) => f.name === selected) || null;
}
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
const pickAtlas = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const skelFamily = skeleton ? familyOfSkel(skeleton) : null;
if (skelFamily) {
const same = cands.filter((f) => familyOfAtlas(f) === skelFamily);
if (same.length > 0) cands = same;
}
return selectBest(cands, refFile);
};
if (!atlas) {
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === selectedStem);
atlas = pickAtlas(cands, filePath);
}
if (!atlas && skeleton) {
const skelStem = normalizeStem(skeleton.name);
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === skelStem);
atlas = pickAtlas(cands, skeleton.fullPath);
}
if (!atlas && skeleton) {
if (strictStem) {
atlas = null;
} else {
// 忽略 stem:用与骨骼同家族且同批入库的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
}
}
if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0];
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
if (strictStem && skeleton && atlas) {
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
if (pairDelta > allowedImportDelta) {
if (isAtlasFile(selected)) skeleton = null;
else atlas = null;
}
}
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
let texturePaths = {};
if (atlas) {
let text = '';
try { text = fs.readFileSync(atlas.fullPath, 'utf8'); } catch (e) { /* ignore */ }
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠)
const inAtlasDir = path.join(atlasDir, pn);
if (fs.existsSync(inAtlasDir)) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn));
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;
}
}
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null);
const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0;
const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0;
const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfSkel(f) === chosenFamily &&
Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedAtlasPaths = atlas ? atlasFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfAtlas(f) === chosenFamily &&
Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))]
.filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp));
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
texturePaths,
base,
isBinary: skeletonIsBinary,
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
relatedDataPaths,
};
}
/** 从 atlas 文本中解析出引用的贴图页面文件名 */
function parseAtlasPages(atlasText) {
const pages = [];
if (!atlasText) return pages;
const lines = atlasText.split(/\r?\n/);
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) {
pages.push(line);
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等)
return pages.filter((p) => !/[=,:]/.test(p));
}
/**
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
*/
function parseAtlasPma(atlasText) {
if (typeof atlasText !== 'string') return undefined;
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
}
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
function preferredSkin(skeletonData) {
if (!skeletonData) return null;
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
const skins = skeletonData.skins || [];
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
}
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
function applySkin(skeleton, skin) {
if (!skeleton || !skin) return false;
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
else return false;
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
return true;
}
module.exports = {
SKELETON_EXTS,
ATLAS_EXTS,
TEXTURE_EXTS,
ALL_EXTS,
extOf,
isSkeletonFile,
isAtlasFile,
isTextureFile,
stemOf,
normalizeStem,
detectVersion,
detectVersionFromJson,
detectVersionFromBuffer,
chooseRuntimeDir,
classifySkeletonJson,
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
parseAtlasPma,
preferredSkin,
applySkin,
isEagleItemDir,
getSearchRoots,
collectSpineFiles,
foldersOfItem,
findLibraryRoot,
readFolderInfo,
getValidFolders,
mtimeOf,
btimeOf,
importTimeOf,
versionFamily,
atlasFamily,
itemMeta,
isItemDeleted,
};
@@ -1,177 +0,0 @@
'use strict';
// Decode file bytes without the Canvas 2D premultiply/unpremultiply round trip.
// In low-alpha PMA pixels that round trip destroys RGB precision before blending.
const preparedTextures = new WeakMap();
async function prepareTexture(image, blob) {
if (typeof createImageBitmap !== 'function') return;
const options = { colorSpaceConversion: 'none' };
const [pma, straight] = await Promise.all([
createImageBitmap(blob, { ...options, premultiplyAlpha: 'none' }),
createImageBitmap(blob, { ...options, premultiplyAlpha: 'premultiply' })
]);
preparedTextures.set(image, { pma, straight });
}
// Rasterize adjacent mesh triangles together, with GPU edge ownership rules.
// Canvas clip()+drawImage() anti-aliases each triangle separately and exposes
// the background along shared edges, which looks like a wireframe overlay.
function createRasterizer(context) {
const document = context.canvas && context.canvas.ownerDocument;
if (!document || typeof context.getTransform !== 'function') return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', {
alpha: true, premultipliedAlpha: true, antialias: true,
preserveDrawingBuffer: true, depth: false, stencil: false
});
if (!gl) return null;
function shader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
const vertex = shader(gl.VERTEX_SHADER, `
attribute vec2 position;
attribute vec2 uv;
attribute vec4 light;
attribute vec4 dark;
uniform vec2 resolution;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec2 p = position / resolution * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
textureUV = uv;
lightColor = light; darkColor = dark;
}`);
const fragment = shader(gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D image;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec4 color = texture2D(image, textureUV);
float alpha = color.a * lightColor.a;
// Textures are PMA in GPU memory. Light/dark colors arrive straight.
vec3 rgb = (color.rgb * lightColor.rgb + (color.a - color.rgb) * darkColor.rgb) * lightColor.a;
gl_FragColor = vec4(rgb, alpha);
}`);
const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
gl.deleteShader(vertex); gl.deleteShader(fragment);
const buffer = gl.createBuffer();
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution');
const light = gl.getAttribLocation(program, 'light'), dark = gl.getAttribLocation(program, 'dark');
const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
let disposed = false, lost = false;
let premultipliedAlpha = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) {
const pma = image !== context.canvas && premultipliedAlpha;
let pair = textures.get(image);
if (!pair) { pair = {}; textures.set(image, pair); }
const key = pma ? 'pma' : 'straight';
if (pair[key] && image !== context.canvas) return pair[key];
const t = pair[key] || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !pma);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const prepared = preparedTextures.get(image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, prepared ? prepared[key] : image);
pair[key] = t; return t;
}
function flush() {
if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
// Premultiplied RGB factors; keep source-over alpha independently.
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 12);
coordinates.length = 0;
}
return {
begin(pma = false) {
if (disposed || lost || gl.isContextLost()) return false;
premultipliedAlpha = pma;
const target = context.canvas;
if (canvas.width !== target.width) canvas.width = target.width;
if (canvas.height !== target.height) canvas.height = target.height;
transform = context.getTransform(); coordinates.length = 0; currentImage = null;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 48, 0);
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 48, 8);
gl.enableVertexAttribArray(light); gl.vertexAttribPointer(light, 4, gl.FLOAT, false, 48, 16);
gl.enableVertexAttribArray(dark); gl.vertexAttribPointer(dark, 4, gl.FLOAT, false, 48, 32);
gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Seed the same framebuffer with the actual Canvas pixels (solid or
// checkerboard background). CSS backgrounds cannot participate in blending.
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
const w = canvas.width, h = canvas.height;
for (const vertex of [[0,0,0,0],[w,0,1,0],[w,h,1,1],[0,0,0,0],[w,h,1,1],[0,h,0,1]])
coordinates.push(...vertex, 1,1,1,1, 0,0,0,0);
flush(); currentImage = null;
return true;
},
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, colors, darkColors) {
const alpha = context.globalAlpha;
const mode = context.globalCompositeOperation || 'source-over';
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
const t = transform;
const points = [[x0,y0,u0,v0],[x1,y1,u1,v1],[x2,y2,u2,v2]];
for (let i=0; i<3; i++) {
const [x,y,u,v] = points[i], j=i*4;
coordinates.push(t.a*x+t.c*y+t.e, t.b*x+t.d*y+t.f, u,v,
...(colors ? colors.slice(j,j+4) : [1,1,1,alpha]),
...(darkColors ? darkColors.slice(j,j+4) : [0,0,0,0]));
}
},
end() {
flush();
context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
finally { context.restore(); }
},
dispose() {
if (disposed) return; disposed = true;
for (const pair of textures.values()) for (const value of Object.values(pair)) gl.deleteTexture(value);
textures.clear(); gl.deleteBuffer(buffer); gl.deleteProgram(program);
const extension = gl.getExtension('WEBGL_lose_context'); if (extension) extension.loseContext();
}
};
}
function attach(renderer, context) {
let rasterizer;
try { rasterizer = createRasterizer(context); } catch (e) { return renderer; }
if (!rasterizer) return renderer;
const draw = renderer.draw.bind(renderer), triangle = renderer.drawTriangle.bind(renderer);
renderer.draw = skeleton => {
if (!rasterizer.begin(!!renderer.premultipliedAlpha)) return draw(skeleton);
renderer.gpuDrawing = true;
renderer.drawTriangle = rasterizer.triangle;
try { draw(skeleton); rasterizer.end(); }
finally { renderer.drawTriangle = triangle; renderer.gpuDrawing = false; }
};
renderer.dispose = rasterizer.dispose;
renderer.rasterization = 'webgl';
return renderer;
}
module.exports = { createRasterizer, attach, prepareTexture };
@@ -1,9 +0,0 @@
# Spine 3.4 runtime
Bundled from the official `3.4.02` tag, commit `ef5013143178918ddfc7cabfbddeae7aec7cf84e`, `spine-ts/build/spine-canvas.js`.
Local correction: `SkeletonJson` path-length loop writes `lengths[i]`, not `lengths[i++]`. The original increments twice and reads beyond the source array, producing missing/NaN lengths.
The plugin provides binary decoding and the `AtlasAttachmentLoader` name alias in `js/spine-legacy-binary.js`. Rendering uses the 3.4 attachment vertex API in `js/spine-canvas-clipping.js`. 3.4 has no clipping attachments; no newer attachment types are synthesized.
License: `LICENSES/Spine-3.4-License.txt`.
File diff suppressed because it is too large. Load diff
@@ -1,11 +0,0 @@
# Spine 3.5
Official `3.5.51` tag, commit `2cd9467d2736b7f3a0b81382f117c73823bfb44b`, `spine-ts/build/spine-canvas.js`. The branch named `3.5` contains later 3.6 beta data; this bundle deliberately uses the tag.
Local correction: the path-length loop uses `lengths[i]` instead of `lengths[i++]`, avoiding a double increment and NaN values.
The binary compatibility layer reads 3.5 transform modes and constraint order, but does not read 3.6 dark-slot color or local/relative transform fields. Rendering uses the older attachment vertex interface. The version predates clipping attachments.
The official tag includes matching 3.5.03-beta JSON/SKEL pairs for seven variants. Tank and Raptor have 3.5.49 JSON but 3.5.03-beta SKEL; these are tested independently, not compared as identical exports. No version strings or data are rewritten.
License: `LICENSES/Spine-3.5-License.txt`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

@@ -1,39 +0,0 @@
{
"id": "ddf1c576-cfbc-4813-83a6-2e029cc08745",
"version": "1.1.0",
"platform": "all",
"arch": "all",
"name": "{{manifest.app.name}}",
"logo": "/logo-spineviewer.png",
"keywords": [
"spine",
"骨骼动画",
"动画预览",
"skel",
"atlas"
],
"fallbackLanguage": "zh_CN",
"languages": [
"zh_CN",
"en",
"zh_TW",
"ja_JP",
"ko_KR",
"ru_RU",
"es_ES",
"de_DE"
],
"devTools": false,
"preview": {
"skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": {
"thumbnail": {
"path": "thumbnail/spine.js",
"size": 400,
"allowZoom": false
},
"viewer": {
"path": "viewer/spine.html"
}
}
}
}
@@ -1,3 +0,0 @@
# `@napi-rs/canvas-darwin-arm64`
This is the **aarch64-apple-darwin** binary for `@napi-rs/canvas`
@@ -1,40 +0,0 @@
{
"name": "@napi-rs/canvas-darwin-arm64",
"version": "0.1.66",
"os": [
"darwin"
],
"cpu": [
"arm64"
],
"main": "skia.darwin-arm64.node",
"files": [
"skia.darwin-arm64.node"
],
"description": "Canvas for Node.js with skia backend",
"keywords": [
"napi-rs",
"NAPI",
"N-API",
"Rust",
"node-addon",
"node-addon-api",
"canvas",
"image",
"pdf",
"svg",
"skia"
],
"license": "MIT",
"engines": {
"node": ">= 10"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"repository": {
"type": "git",
"url": "git+https://github.com/Brooooooklyn/canvas.git"
}
}
-21
View File
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 lynweklm@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
-361
View File
@@ -1,361 +0,0 @@
# `skr canvas`
[![CI](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml/badge.svg)](https://github.com/Brooooooklyn/canvas/actions/workflows/CI.yaml)
![Skia Version](https://img.shields.io/badge/Skia-chrome%2Fm133-hotpink)
[![install size](https://packagephobia.com/badge?p=@napi-rs/canvas)](https://packagephobia.com/result?p=@napi-rs/canvas)
[![Downloads](https://img.shields.io/npm/dm/@napi-rs/canvas.svg?sanitize=true)](https://npmcharts.com/compare/@napi-rs/canvas?minimal=true)
> 🚀 Help me to become a full-time open-source developer by [sponsoring me on Github](https://github.com/sponsors/Brooooooklyn)
Google Skia binding to Node.js via [Node-API](https://napi.rs), **0 System dependencies!**
⚠️ This project is in pre-release stage. And there may be some bugs.<br/>
For details on planned features and future direction please refer to the [Roadmap](https://github.com/Brooooooklyn/canvas/issues/113).
[中文文档](./README-zh.md)
# Install
```bash
yarn add @napi-rs/canvas
npm install @napi-rs/canvas
```
# Support matrix
## System requirement
### `arm64`
[**_cortex-a57_**](https://en.wikipedia.org/wiki/ARM_Cortex-A57) or newer CPU architecture on **Linux**.
All Apple M chips on **macOS**.
### `armv7`
[**_cortex-a7_**](https://en.wikipedia.org/wiki/ARM_Cortex-A7) or newer CPU architecture.
### glibc
Since Skia relies on the [glibc](https://www.gnu.org/software/libc/) 2.18 API, you need to have at least glibc version >= 2.18 on your system.
## AWS Lambda usage
To use this library on Lambda you will need to use a Lambda layer.
You can simply attach a lambda layer by getting an ARN from [Canvas-Lambda-Layer](https://github.com/ShivamJoker/Canvas-Lambda-Layer)
> Make sure to exclude `@napi-rs/canvas` while bundling your Lambda.
# Usage
```js
const { promises } = require('node:fs')
const { join } = require('node:path')
const { createCanvas, loadImage } = require('@napi-rs/canvas')
const canvas = createCanvas(300, 320)
const ctx = canvas.getContext('2d')
ctx.lineWidth = 10
ctx.strokeStyle = '#03a9f4'
ctx.fillStyle = '#03a9f4'
// Wall
ctx.strokeRect(75, 140, 150, 110)
// Door
ctx.fillRect(130, 190, 40, 60)
// Roof
ctx.beginPath()
ctx.moveTo(50, 140)
ctx.lineTo(150, 60)
ctx.lineTo(250, 140)
ctx.closePath()
ctx.stroke()
async function main() {
// load images from disk or from a URL
const catImage = await loadImage('path/to/cat.png')
const dogImage = await loadImage('https://example.com/path/to/dog.jpg')
ctx.drawImage(catImage, 0, 0, catImage.width, catImage.height)
ctx.drawImage(dogImage, canvas.width / 2, canvas.height / 2, dogImage.width, dogImage.height)
// export canvas as image
const pngData = await canvas.encode('png') // JPEG, AVIF and WebP are also supported
// encoding in libuv thread pool, non-blocking
await promises.writeFile(join(__dirname, 'simple.png'), pngData)
}
main()
```
![](./example/simple.png)
## Emoji text
```js
const { writeFileSync } = require('fs')
const { join } = require('path')
const { createCanvas, GlobalFonts } = require('@napi-rs/canvas')
GlobalFonts.registerFromPath(join(__dirname, '..', 'fonts', 'AppleColorEmoji@2x.ttf'), 'Apple Emoji')
GlobalFonts.registerFromPath(join(__dirname, '..', '__test__', 'fonts', 'COLRv1.ttf'), 'COLRv1')
console.info(GlobalFonts.families)
const canvas = createCanvas(760, 360)
const ctx = canvas.getContext('2d')
ctx.font = '50px Apple Emoji'
ctx.strokeText('😀😃😄😁😆😅😂🤣☺️😊😊😇', 50, 150)
ctx.font = '100px COLRv1'
ctx.fillText('abc', 50, 300)
const b = canvas.toBuffer('image/png')
writeFileSync(join(__dirname, 'draw-emoji.png'), b)
```
![](./example/draw-emoji.png)
# Performance
See [benchmark](./benchmark) for benchmark code.
Hardware info:
```
,MMMM. Host - xxxxxxxxxxxxxxxxxxxxxxx
.MMMMMM Machine - Mac15,9
MMMMM, Kernel - 24.0.0
.;MMMMM:' MMMMMMMMMM;. OS - macOS 15.0.1 Sequoia
MMMMMMMMMMMMNWMMMMMMMMMMM: DE - Aqua
.MMMMMMMMMMMMMMMMMMMMMMMMWM. WM - Quartz Compositor
MMMMMMMMMMMMMMMMMMMMMMMMM. Packages - 194 (Homebrew), 32 (cargo)
;MMMMMMMMMMMMMMMMMMMMMMMM: Shell - zsh
:MMMMMMMMMMMMMMMMMMMMMMMM: Terminal - warpterminal (Version v0.2024.10.23.14.49.stable_00)
.MMMMMMMMMMMMMMMMMMMMMMMMM. Resolution - 5120x2880@160fps (as 2560x1440)
MMMMMMMMMMMMMMMMMMMMMMMMMMM. 2992x1934@120fps (as 1496x967)
.MMMMMMMMMMMMMMMMMMMMMMMMMM. 2232x1512@60fps (as 1116x756)
MMMMMMMMMMMMMMMMMMMMMMMM Uptime - 1d 2h 32m
;MMMMMMMMMMMMMMMMMMMM. CPU - Apple M3 Max (16)
.MMMM,. .MMMM,. CPU Load - 16%
Memory - 50.1 GB / 134.2 GB
Battery - 78% & Discharging
Disk Space - 624.0 GB / 994.7 GB
```
```
❯ yarn bench
Draw a House and export to PNG
┌─────────┬─────────────────┬───────────────────────┬──────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
├─────────┼─────────────────┼───────────────────────┼──────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
│ 0 │ '@napi-rs/skia' │ '14676992.14 ± 0.68%' │ '14602333.00' │ '68 ± 0.59%' │ '68' │ 69 │
│ 1 │ 'skia-canvas' │ '21167809.17 ± 2.05%' │ '20960021.00 ± 13646.00' │ '47 ± 1.31%' │ '48' │ 64 │
│ 2 │ 'node-canvas' │ '16552027.42 ± 0.70%' │ '16451291.50 ± 2208.50' │ '60 ± 0.62%' │ '61' │ 64 │
└─────────┴─────────────────┴───────────────────────┴──────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
Draw Gradient and export to PNG
┌─────────┬─────────────────┬───────────────────────┬─────────────────────────┬────────────────────────────┬───────────────────────────┬─────────┐
│ (index) │ Task name │ Latency average (ns) │ Latency median (ns) │ Throughput average (ops/s) │ Throughput median (ops/s) │ Samples │
├─────────┼─────────────────┼───────────────────────┼─────────────────────────┼────────────────────────────┼───────────────────────────┼─────────┤
│ 0 │ '@napi-rs/skia' │ '15228495.58 ± 0.53%' │ '15146312.50 ± 1187.50' │ '66 ± 0.48%' │ '66' │ 66 │
│ 1 │ 'skia-canvas' │ '21725564.41 ± 2.20%' │ '21412520.50 ± 2104.50' │ '46 ± 1.39%' │ '47' │ 64 │
│ 2 │ 'node-canvas' │ '17976022.14 ± 1.53%' │ '17563479.50 ± 5104.50' │ '56 ± 1.38%' │ '57' │ 64 │
└─────────┴─────────────────┴───────────────────────┴─────────────────────────┴────────────────────────────┴───────────────────────────┴─────────┘
```
# Features
## Path2D
```typescript
new Path2D()
new Path2D(path: Path2D)
// new Path2D('M108.956,403.826c0,0,0.178,3.344-1.276,3.311 c-1.455-0.033-30.507-84.917-66.752-80.957C40.928,326.18,72.326,313.197,108.956,403.826z')
new Path2D(path: string)
```
```typescript
export interface DOMMatrix2DInit {
a: number
b: number
c: number
d: number
e: number
f: number
}
export class Path2D {
constructor(path?: Path2D | string)
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
closePath(): void
ellipse(
x: number,
y: number,
radiusX: number,
radiusY: number,
rotation: number,
startAngle: number,
endAngle: number,
anticlockwise?: boolean,
): void
lineTo(x: number, y: number): void
moveTo(x: number, y: number): void
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
rect(x: number, y: number, w: number, h: number): void
// PathKit methods
op(path: Path2D, operation: PathOp): Path2D
toSVGString(): string
getFillType(): FillType
getFillTypeString(): string
setFillType(type: FillType): void
simplify(): Path2D
asWinding(): Path2D
stroke(stroke?: StrokeOptions): Path2D
transform(transform: DOMMatrix2DInit): Path2D
getBounds(): [left: number, top: number, right: number, bottom: number]
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
trim(start: number, end: number, isComplement?: boolean): Path2D
equals(path: Path2D): boolean
}
```
## PathKit
`PathKit` is a toolset for manipulating Path in `Skia`, supporting **_quadratic beziers_**, **_cubic beziers_** and **_conics_**.
The main features are.
### Path Operation
`.op(path, PathOp)`
```js
const pathOne = new Path2D(
'M8 50H92C96.4183 50 100 53.5817 100 58V142C100 146.418 96.4183 150 92 150H8C3.58172 150 0 146.418 0 142V58C0 53.5817 3.58172 50 8 50Z',
)
const pathTwo = new Path2D(
'"M58 0H142C146.418 0 150 3.58172 150 8V92C150 96.4183 146.418 100 142 100H58C53.5817 100 50 96.4183 50 92V8C50 3.58172 53.5817 0 58 0Z',
)
pathOne.op(pathTwo, PathOp.Intersect).toSVGString()
// => "M100 100L58 100C53.5817 100 50 96.4183 50 92L50 50L92 50C96.4183 50 100 53.5817 100 58L100 100Z"
```
- **Union**, subtract the op path from the first path
- **Difference**, intersect the two paths
- **ReverseDifference**, union (inclusive-or) the two paths
- **Intersect**, exclusive-or the two paths
- **XOR**, subtract the first path from the op path
![boolean-operations](./docs/imgs/boolean-operations.svg)
### Covert `FillType` in **_Path_**
`.asWinding()`
You can convert `fill-rule="evenodd"` to `fill-rule="nonzero"` in SVG.
This is useful for **OpenType** font-related tools, as `fill-rule="nonzero"` is only supported in **OpenType** fonts.
![SVG fill-rule](./docs/imgs/asWinding@2x.png)
```js
const pathCircle = new Path2D(
'M24.2979 13.6364H129.394V40.9091H24.2979L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455H70.4673V68.1818H16.073C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727V113.636C7.00728 118.657 11.0661 122.727 16.073 122.727H70.4673V150H84.0658V122.727H128.041C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818H84.0658V54.5455H133.927C138.934 54.5455 142.993 50.4755 142.993 45.4545V9.09091C142.993 4.07014 138.934 0 133.927 0H21.9592ZM125.702 109.091H20.6058V81.8182H125.702L135.372 95.4545L125.702 109.091Z',
)
pathCircle.setFillType(FillType.EvenOdd)
pathCircle.asWinding().toSVGString()
// => "M24.2979 13.6364L129.394 13.6364L129.394 40.9091L24.2979 40.9091L14.6278 27.2727L24.2979 13.6364ZM21.9592 0C19.0246 0 16.2716 1.42436 14.571 3.82251L1.67756 22.0043C-0.559186 25.1585 -0.559186 29.387 1.67756 32.5411L14.571 50.7227C16.2716 53.1209 19.0246 54.5455 21.9592 54.5455L70.4673 54.5455L70.4673 68.1818L16.073 68.1818C11.0661 68.1818 7.00728 72.2518 7.00728 77.2727L7.00728 113.636C7.00728 118.657 11.0661 122.727 16.073 122.727L70.4673 122.727L70.4673 150L84.0658 150L84.0658 122.727L128.041 122.727C130.975 122.727 133.729 121.303 135.429 118.905L148.323 100.723C150.559 97.5686 150.559 93.3405 148.323 90.1864L135.429 72.0045C133.729 69.6064 130.975 68.1818 128.041 68.1818L84.0658 68.1818L84.0658 54.5455L133.927 54.5455C138.934 54.5455 142.993 50.4755 142.993 45.4545L142.993 9.09091C142.993 4.07014 138.934 0 133.927 0L21.9592 0ZM125.702 109.091L20.6058 109.091L20.6058 81.8182L125.702 81.8182L135.372 95.4545L125.702 109.091Z"
```
### Simplify **_Path_**
`.simplify()`
Set the path to the same non-overlapping contour as the original path area, which means that it can also remove overlapping paths.
<img width="800" src="./docs/imgs/simplify.png" >
[SVG with overlapping paths](./docs/imgs/overlapping-path.svg) (Left)
```js
const path =
'M2.933,89.89 L89.005,3.818 Q90.412,2.411 92.249,1.65 Q94.087,0.889 96.076,0.889 Q98.065,0.889 99.903,1.65 Q101.741,2.411 103.147,3.818 L189.22,89.89 Q190.626,91.296 191.387,93.134 Q192.148,94.972 192.148,96.961 Q192.148,98.95 191.387,100.788 Q190.626,102.625 189.219,104.032 Q187.813,105.439 185.975,106.2 Q184.138,106.961 182.148,106.961 Q180.159,106.961 178.322,106.2 Q176.484,105.439 175.077,104.032 L89.005,17.96 L96.076,10.889 L103.147,17.96 L17.075,104.032 Q15.668,105.439 13.831,106.2 Q11.993,106.961 10.004,106.961 Q8.015,106.961 6.177,106.2 Q4.339,105.439 2.933,104.032 Q1.526,102.625 0.765,100.788 Q0.004,98.95 0.004,96.961 Q0.004,94.972 0.765,93.134 Q1.526,91.296 2.933,89.89 Z'
path.simplify().toSVGString()
// => "M89.005 3.818L2.933 89.89Q1.526 91.296 0.765 93.134Q0.004 94.972 0.004 96.961Q0.004 98.95 0.765 100.788Q1.526 102.625 2.933 104.032Q4.339 105.439 6.177 106.2Q8.015 106.961 10.004 106.961Q11.993 106.961 13.831 106.2Q15.668 105.439 17.075 104.032L96.076 25.031L175.077 104.032Q176.484 105.439 178.322 106.2Q180.159 106.961 182.148 106.961Q184.138 106.961 185.975 106.2Q187.813 105.439 189.219 104.032Q190.626 102.625 191.387 100.788Q192.148 98.95 192.148 96.961Q192.148 94.972 191.387 93.134Q190.626 91.296 189.22 89.89L103.147 3.818Q101.741 2.411 99.903 1.65Q98.065 0.889 96.076 0.889Q94.087 0.889 92.249 1.65Q90.412 2.411 89.005 3.818Z"
```
# [Example](./example/tiger.js)
> The tiger.json was serialized from [gojs/samples/tiger](https://github.com/NorthwoodsSoftware/GoJS/blob/master/samples/tiger.html)
<img width="500" src="example/tiger.png">
```shell
node example/anime-girl.js
```
| SVG | PNG |
| -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| <img width="500" src="example/anime-girl.svg"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) | <img width="500" src="example/anime-girl.png"><br/>[CC-BY-SA 3.0](https://creativecommons.org/licenses/by/3.0) by [Niabot](https://commons.wikimedia.org/wiki/User:Niabot) |
# Building
## Build skia from source
You can build this project from source, the system requirements are here: https://skia.org/docs/user/build
```sh
# Clone the code:
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
$ cd canvas
# Build Skia:
$ node scripts/build-skia.js
# Install NPM packages, build the Node.js addon:
$ npm install -g yarn
$ yarn install --mode=skip-build # Here are modules that are used for benchmarking and are hard to install, you can skip it by specifying `--mode=skip-build`
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
$ yarn build
# All done! Run test cases or examples now:
$ yarn test
$ node example/tiger.js
```
## Pull pre-build skia binary from GitHub
You can pull skia pre-build binaries if you just care the `Rust` part:
```sh
# Clone the code:
$ git clone --recurse-submodules https://github.com/Brooooooklyn/canvas.git
$ cd canvas
# Download Skia binaries:
# It will pull the binaries match the git hash in `./skia` submodule
$ node scripts/release-skia-binary.mjs --download
# Install NPM packages, build the Node.js addon:
$ npm install -g yarn
$ yarn install --mode=skip-build
$ sudo dnf install clang # https://fedora.pkgs.org/34/fedora-x86_64/clang-12.0.0-0.3.rc1.fc34.x86_64.rpm.html
$ yarn build
# All done! Run test cases or examples now:
$ yarn test
$ node example/tiger.js
```
-866
View File
@@ -1,866 +0,0 @@
const { inspect } = require('util')
/*
* vendored in order to fix its dependence on the window global [cds 2020/08/04]
* otherwise unchanged from https://github.com/jarek-foksa/geometry-polyfill/tree/f36bbc8f4bc43539d980687904ce46c8e915543d
*/
// @info
// DOMPoint polyfill
// @src
// https://drafts.fxtf.org/geometry/#DOMPoint
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_point_read_only.cc
class DOMPoint {
constructor(x = 0, y = 0, z = 0, w = 1) {
this.x = x
this.y = y
this.z = z
this.w = w
}
static fromPoint(otherPoint) {
return new DOMPoint(
otherPoint.x,
otherPoint.y,
otherPoint.z !== undefined ? otherPoint.z : 0,
otherPoint.w !== undefined ? otherPoint.w : 1,
)
}
matrixTransform(matrix) {
if ((matrix.is2D || matrix instanceof SVGMatrix) && this.z === 0 && this.w === 1) {
return new DOMPoint(
this.x * matrix.a + this.y * matrix.c + matrix.e,
this.x * matrix.b + this.y * matrix.d + matrix.f,
0,
1,
)
} else {
return new DOMPoint(
this.x * matrix.m11 + this.y * matrix.m21 + this.z * matrix.m31 + this.w * matrix.m41,
this.x * matrix.m12 + this.y * matrix.m22 + this.z * matrix.m32 + this.w * matrix.m42,
this.x * matrix.m13 + this.y * matrix.m23 + this.z * matrix.m33 + this.w * matrix.m43,
this.x * matrix.m14 + this.y * matrix.m24 + this.z * matrix.m34 + this.w * matrix.m44,
)
}
}
toJSON() {
return {
x: this.x,
y: this.y,
z: this.z,
w: this.w,
}
}
}
// @info
// DOMRect polyfill
// @src
// https://drafts.fxtf.org/geometry/#DOMRect
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_rect_read_only.cc
class DOMRect {
constructor(x = 0, y = 0, width = 0, height = 0) {
this.x = x
this.y = y
this.width = width
this.height = height
}
static fromRect(otherRect) {
return new DOMRect(otherRect.x, otherRect.y, otherRect.width, otherRect.height)
}
get top() {
return this.y
}
get left() {
return this.x
}
get right() {
return this.x + this.width
}
get bottom() {
return this.y + this.height
}
toJSON() {
return {
x: this.x,
y: this.y,
width: this.width,
height: this.height,
top: this.top,
left: this.left,
right: this.right,
bottom: this.bottom,
}
}
}
for (const propertyName of ['top', 'right', 'bottom', 'left']) {
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMRect.prototype, propertyName)
propertyDescriptor.enumerable = true
Object.defineProperty(DOMRect.prototype, propertyName, propertyDescriptor)
}
// @info
// DOMMatrix polyfill (SVG 2)
// @src
// https://github.com/chromium/chromium/blob/master/third_party/blink/renderer/core/geometry/dom_matrix_read_only.cc
// https://github.com/tocharomera/generativecanvas/blob/master/node-canvas/lib/DOMMatrix.js
const M11 = 0,
M12 = 1,
M13 = 2,
M14 = 3
const M21 = 4,
M22 = 5,
M23 = 6,
M24 = 7
const M31 = 8,
M32 = 9,
M33 = 10,
M34 = 11
const M41 = 12,
M42 = 13,
M43 = 14,
M44 = 15
const A = M11,
B = M12
const C = M21,
D = M22
const E = M41,
F = M42
const DEGREE_PER_RAD = 180 / Math.PI
const RAD_PER_DEGREE = Math.PI / 180
const VALUES = Symbol('values')
const IS_2D = Symbol('is2D')
function parseMatrix(init) {
let parsed = init.replace(/matrix\(/, '').split(/,/, 7)
if (parsed.length !== 6) {
throw new Error(`Failed to parse ${init}`)
}
parsed = parsed.map(parseFloat)
return [parsed[0], parsed[1], 0, 0, parsed[2], parsed[3], 0, 0, 0, 0, 1, 0, parsed[4], parsed[5], 0, 1]
}
function parseMatrix3d(init) {
const parsed = init.replace(/matrix3d\(/, '').split(/,/, 17)
if (parsed.length !== 16) {
throw new Error(`Failed to parse ${init}`)
}
return parsed.map(parseFloat)
}
function parseTransform(tform) {
const type = tform.split(/\(/, 1)[0]
if (type === 'matrix') {
return parseMatrix(tform)
} else if (type === 'matrix3d') {
return parseMatrix3d(tform)
} else {
throw new Error(`${type} parsing not implemented`)
}
}
const setNumber2D = (receiver, index, value) => {
if (typeof value !== 'number') {
throw new TypeError('Expected number')
}
receiver[VALUES][index] = value
}
const setNumber3D = (receiver, index, value) => {
if (typeof value !== 'number') {
throw new TypeError('Expected number')
}
if (index === M33 || index === M44) {
if (value !== 1) {
receiver[IS_2D] = false
}
} else if (value !== 0) {
receiver[IS_2D] = false
}
receiver[VALUES][index] = value
}
const newInstance = (values) => {
const instance = Object.create(DOMMatrix.prototype)
instance.constructor = DOMMatrix
instance[IS_2D] = true
instance[VALUES] = values
return instance
}
const multiply = (first, second) => {
const dest = new Float64Array(16)
for (let i = 0; i < 4; i++) {
for (let j = 0; j < 4; j++) {
let sum = 0
for (let k = 0; k < 4; k++) {
sum += first[i * 4 + k] * second[k * 4 + j]
}
dest[i * 4 + j] = sum
}
}
return dest
}
class DOMMatrix {
get m11() {
return this[VALUES][M11]
}
set m11(value) {
setNumber2D(this, M11, value)
}
get m12() {
return this[VALUES][M12]
}
set m12(value) {
setNumber2D(this, M12, value)
}
get m13() {
return this[VALUES][M13]
}
set m13(value) {
setNumber3D(this, M13, value)
}
get m14() {
return this[VALUES][M14]
}
set m14(value) {
setNumber3D(this, M14, value)
}
get m21() {
return this[VALUES][M21]
}
set m21(value) {
setNumber2D(this, M21, value)
}
get m22() {
return this[VALUES][M22]
}
set m22(value) {
setNumber2D(this, M22, value)
}
get m23() {
return this[VALUES][M23]
}
set m23(value) {
setNumber3D(this, M23, value)
}
get m24() {
return this[VALUES][M24]
}
set m24(value) {
setNumber3D(this, M24, value)
}
get m31() {
return this[VALUES][M31]
}
set m31(value) {
setNumber3D(this, M31, value)
}
get m32() {
return this[VALUES][M32]
}
set m32(value) {
setNumber3D(this, M32, value)
}
get m33() {
return this[VALUES][M33]
}
set m33(value) {
setNumber3D(this, M33, value)
}
get m34() {
return this[VALUES][M34]
}
set m34(value) {
setNumber3D(this, M34, value)
}
get m41() {
return this[VALUES][M41]
}
set m41(value) {
setNumber2D(this, M41, value)
}
get m42() {
return this[VALUES][M42]
}
set m42(value) {
setNumber2D(this, M42, value)
}
get m43() {
return this[VALUES][M43]
}
set m43(value) {
setNumber3D(this, M43, value)
}
get m44() {
return this[VALUES][M44]
}
set m44(value) {
setNumber3D(this, M44, value)
}
get a() {
return this[VALUES][A]
}
set a(value) {
setNumber2D(this, A, value)
}
get b() {
return this[VALUES][B]
}
set b(value) {
setNumber2D(this, B, value)
}
get c() {
return this[VALUES][C]
}
set c(value) {
setNumber2D(this, C, value)
}
get d() {
return this[VALUES][D]
}
set d(value) {
setNumber2D(this, D, value)
}
get e() {
return this[VALUES][E]
}
set e(value) {
setNumber2D(this, E, value)
}
get f() {
return this[VALUES][F]
}
set f(value) {
setNumber2D(this, F, value)
}
get is2D() {
return this[IS_2D]
}
get isIdentity() {
const values = this[VALUES]
return (
values[M11] === 1 &&
values[M12] === 0 &&
values[M13] === 0 &&
values[M14] === 0 &&
values[M21] === 0 &&
values[M22] === 1 &&
values[M23] === 0 &&
values[M24] === 0 &&
values[M31] === 0 &&
values[M32] === 0 &&
values[M33] === 1 &&
values[M34] === 0 &&
values[M41] === 0 &&
values[M42] === 0 &&
values[M43] === 0 &&
values[M44] === 1
)
}
static fromMatrix(init) {
if (init instanceof DOMMatrix) {
return new DOMMatrix(init[VALUES])
} else if (init instanceof SVGMatrix) {
return new DOMMatrix([init.a, init.b, init.c, init.d, init.e, init.f])
} else {
throw new TypeError('Expected DOMMatrix')
}
}
static fromFloat32Array(init) {
if (!(init instanceof Float32Array)) throw new TypeError('Expected Float32Array')
return new DOMMatrix(init)
}
static fromFloat64Array(init) {
if (!(init instanceof Float64Array)) throw new TypeError('Expected Float64Array')
return new DOMMatrix(init)
}
// @type
// (Float64Array) => void
constructor(init) {
this[IS_2D] = true
this[VALUES] = new Float64Array([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1])
// Parse CSS transformList
if (typeof init === 'string') {
if (init === '') {
return
} else {
const tforms = init.split(/\)\s+/, 20).map(parseTransform)
if (tforms.length === 0) {
return
}
init = tforms[0]
for (let i = 1; i < tforms.length; i++) {
init = multiply(tforms[i], init)
}
}
}
let i = 0
if (init && init.length === 6) {
setNumber2D(this, A, init[i++])
setNumber2D(this, B, init[i++])
setNumber2D(this, C, init[i++])
setNumber2D(this, D, init[i++])
setNumber2D(this, E, init[i++])
setNumber2D(this, F, init[i++])
} else if (init && init.length === 16) {
setNumber2D(this, M11, init[i++])
setNumber2D(this, M12, init[i++])
setNumber3D(this, M13, init[i++])
setNumber3D(this, M14, init[i++])
setNumber2D(this, M21, init[i++])
setNumber2D(this, M22, init[i++])
setNumber3D(this, M23, init[i++])
setNumber3D(this, M24, init[i++])
setNumber3D(this, M31, init[i++])
setNumber3D(this, M32, init[i++])
setNumber3D(this, M33, init[i++])
setNumber3D(this, M34, init[i++])
setNumber2D(this, M41, init[i++])
setNumber2D(this, M42, init[i++])
setNumber3D(this, M43, init[i++])
setNumber3D(this, M44, init[i])
} else if (init !== undefined) {
throw new TypeError('Expected string or array.')
}
}
dump() {
const mat = this[VALUES]
console.info([mat.slice(0, 4), mat.slice(4, 8), mat.slice(8, 12), mat.slice(12, 16)])
}
[inspect.custom](depth, options) {
if (depth < 0) return '[DOMMatrix]'
const { a, b, c, d, e, f, is2D, isIdentity } = this
if (this.is2D) {
return `DOMMatrix ${inspect({ a, b, c, d, e, f, is2D, isIdentity }, { colors: true })}`
} else {
const { m11, m12, m13, m14, m21, m22, m23, m24, m31, m32, m33, m34, m41, m42, m43, m44, is2D, isIdentity } = this
return `DOMMatrix ${inspect(
{
a,
b,
c,
d,
e,
f,
m11,
m12,
m13,
m14,
m21,
m22,
m23,
m24,
m31,
m32,
m33,
m34,
m41,
m42,
m43,
m44,
is2D,
isIdentity,
},
{ colors: true },
)}`
}
}
multiply(other) {
return newInstance(this[VALUES]).multiplySelf(other)
}
multiplySelf(other) {
this[VALUES] = multiply(other[VALUES], this[VALUES])
if (!other.is2D) {
this[IS_2D] = false
}
return this
}
preMultiplySelf(other) {
this[VALUES] = multiply(this[VALUES], other[VALUES])
if (!other.is2D) {
this[IS_2D] = false
}
return this
}
translate(tx, ty, tz) {
return newInstance(this[VALUES]).translateSelf(tx, ty, tz)
}
translateSelf(tx = 0, ty = 0, tz = 0) {
this[VALUES] = multiply([1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, tx, ty, tz, 1], this[VALUES])
if (tz !== 0) {
this[IS_2D] = false
}
return this
}
scale(scaleX, scaleY, scaleZ, originX, originY, originZ) {
return newInstance(this[VALUES]).scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ)
}
scale3d(scale, originX, originY, originZ) {
return newInstance(this[VALUES]).scale3dSelf(scale, originX, originY, originZ)
}
scale3dSelf(scale, originX, originY, originZ) {
return this.scaleSelf(scale, scale, scale, originX, originY, originZ)
}
scaleSelf(scaleX, scaleY, scaleZ, originX, originY, originZ) {
// Not redundant with translate's checks because we need to negate the values later.
if (typeof originX !== 'number') originX = 0
if (typeof originY !== 'number') originY = 0
if (typeof originZ !== 'number') originZ = 0
this.translateSelf(originX, originY, originZ)
if (typeof scaleX !== 'number') scaleX = 1
if (typeof scaleY !== 'number') scaleY = scaleX
if (typeof scaleZ !== 'number') scaleZ = 1
this[VALUES] = multiply([scaleX, 0, 0, 0, 0, scaleY, 0, 0, 0, 0, scaleZ, 0, 0, 0, 0, 1], this[VALUES])
this.translateSelf(-originX, -originY, -originZ)
if (scaleZ !== 1 || originZ !== 0) {
this[IS_2D] = false
}
return this
}
rotateFromVector(x, y) {
return newInstance(this[VALUES]).rotateFromVectorSelf(x, y)
}
rotateFromVectorSelf(x = 0, y = 0) {
const theta = x === 0 && y === 0 ? 0 : Math.atan2(y, x) * DEGREE_PER_RAD
return this.rotateSelf(theta)
}
rotate(rotX, rotY, rotZ) {
return newInstance(this[VALUES]).rotateSelf(rotX, rotY, rotZ)
}
rotateSelf(rotX, rotY, rotZ) {
if (rotY === undefined && rotZ === undefined) {
rotZ = rotX
rotX = rotY = 0
}
if (typeof rotY !== 'number') rotY = 0
if (typeof rotZ !== 'number') rotZ = 0
if (rotX !== 0 || rotY !== 0) {
this[IS_2D] = false
}
rotX *= RAD_PER_DEGREE
rotY *= RAD_PER_DEGREE
rotZ *= RAD_PER_DEGREE
let c = Math.cos(rotZ)
let s = Math.sin(rotZ)
this[VALUES] = multiply([c, s, 0, 0, -s, c, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
c = Math.cos(rotY)
s = Math.sin(rotY)
this[VALUES] = multiply([c, 0, -s, 0, 0, 1, 0, 0, s, 0, c, 0, 0, 0, 0, 1], this[VALUES])
c = Math.cos(rotX)
s = Math.sin(rotX)
this[VALUES] = multiply([1, 0, 0, 0, 0, c, s, 0, 0, -s, c, 0, 0, 0, 0, 1], this[VALUES])
return this
}
rotateAxisAngle(x, y, z, angle) {
return newInstance(this[VALUES]).rotateAxisAngleSelf(x, y, z, angle)
}
rotateAxisAngleSelf(x = 0, y = 0, z = 0, angle = 0) {
const length = Math.sqrt(x * x + y * y + z * z)
if (length === 0) {
return this
}
if (length !== 1) {
x /= length
y /= length
z /= length
}
angle *= RAD_PER_DEGREE
const c = Math.cos(angle)
const s = Math.sin(angle)
const t = 1 - c
const tx = t * x
const ty = t * y
this[VALUES] = multiply(
[
tx * x + c,
tx * y + s * z,
tx * z - s * y,
0,
tx * y - s * z,
ty * y + c,
ty * z + s * x,
0,
tx * z + s * y,
ty * z - s * x,
t * z * z + c,
0,
0,
0,
0,
1,
],
this[VALUES],
)
if (x !== 0 || y !== 0) {
this[IS_2D] = false
}
return this
}
skewX(sx) {
return newInstance(this[VALUES]).skewXSelf(sx)
}
skewXSelf(sx) {
if (typeof sx !== 'number') {
return this
}
const t = Math.tan(sx * RAD_PER_DEGREE)
this[VALUES] = multiply([1, 0, 0, 0, t, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
return this
}
skewY(sy) {
return newInstance(this[VALUES]).skewYSelf(sy)
}
skewYSelf(sy) {
if (typeof sy !== 'number') {
return this
}
const t = Math.tan(sy * RAD_PER_DEGREE)
this[VALUES] = multiply([1, t, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES])
return this
}
flipX() {
return newInstance(multiply([-1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
}
flipY() {
return newInstance(multiply([1, 0, 0, 0, 0, -1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1], this[VALUES]))
}
inverse() {
return newInstance(this[VALUES]).invertSelf()
}
invertSelf() {
if (this[IS_2D]) {
const det = this[VALUES][A] * this[VALUES][D] - this[VALUES][B] * this[VALUES][C]
// Invertable
if (det !== 0) {
const result = new DOMMatrix()
result.a = this[VALUES][D] / det
result.b = -this[VALUES][B] / det
result.c = -this[VALUES][C] / det
result.d = this[VALUES][A] / det
result.e = (this[VALUES][C] * this[VALUES][F] - this[VALUES][D] * this[VALUES][E]) / det
result.f = (this[VALUES][B] * this[VALUES][E] - this[VALUES][A] * this[VALUES][F]) / det
return result
}
// Not invertable
else {
this[IS_2D] = false
this[VALUES] = [NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN, NaN]
}
} else {
throw new Error('3D matrix inversion is not implemented.')
}
}
setMatrixValue(transformList) {
const temp = new DOMMatrix(transformList)
this[VALUES] = temp[VALUES]
this[IS_2D] = temp[IS_2D]
return this
}
transformPoint(point) {
const x = point.x || 0
const y = point.y || 0
const z = point.z || 0
const w = point.w || 1
const values = this[VALUES]
const nx = values[M11] * x + values[M21] * y + values[M31] * z + values[M41] * w
const ny = values[M12] * x + values[M22] * y + values[M32] * z + values[M42] * w
const nz = values[M13] * x + values[M23] * y + values[M33] * z + values[M43] * w
const nw = values[M14] * x + values[M24] * y + values[M34] * z + values[M44] * w
return new DOMPoint(nx, ny, nz, nw)
}
toFloat32Array() {
return Float32Array.from(this[VALUES])
}
toFloat64Array() {
return this[VALUES].slice(0)
}
toJSON() {
return {
a: this.a,
b: this.b,
c: this.c,
d: this.d,
e: this.e,
f: this.f,
m11: this.m11,
m12: this.m12,
m13: this.m13,
m14: this.m14,
m21: this.m21,
m22: this.m22,
m23: this.m23,
m24: this.m24,
m31: this.m31,
m32: this.m32,
m33: this.m33,
m34: this.m34,
m41: this.m41,
m42: this.m42,
m43: this.m43,
m44: this.m44,
is2D: this.is2D,
isIdentity: this.isIdentity,
}
}
toString() {
if (this.is2D) {
return `matrix(${this.a}, ${this.b}, ${this.c}, ${this.d}, ${this.e}, ${this.f})`
} else {
return `matrix3d(${this[VALUES].join(', ')})`
}
}
}
for (const propertyName of [
'a',
'b',
'c',
'd',
'e',
'f',
'm11',
'm12',
'm13',
'm14',
'm21',
'm22',
'm23',
'm24',
'm31',
'm32',
'm33',
'm34',
'm41',
'm42',
'm43',
'm44',
'is2D',
'isIdentity',
]) {
const propertyDescriptor = Object.getOwnPropertyDescriptor(DOMMatrix.prototype, propertyName)
propertyDescriptor.enumerable = true
Object.defineProperty(DOMMatrix.prototype, propertyName, propertyDescriptor)
}
module.exports = { DOMPoint, DOMMatrix, DOMRect }
-458
View File
@@ -1,458 +0,0 @@
import { ReadableStream } from 'node:stream/web'
// Clear all type of caches in Skia
export function clearAllCache(): void
export interface DOMMatrix2DInit {
a: number
b: number
c: number
d: number
e: number
f: number
}
interface DOMMatrixReadOnly {
readonly a: number
readonly b: number
readonly c: number
readonly d: number
readonly e: number
readonly f: number
readonly is2D: boolean
readonly isIdentity: boolean
readonly m11: number
readonly m12: number
readonly m13: number
readonly m14: number
readonly m21: number
readonly m22: number
readonly m23: number
readonly m24: number
readonly m31: number
readonly m32: number
readonly m33: number
readonly m34: number
readonly m41: number
readonly m42: number
readonly m43: number
readonly m44: number
flipX(): DOMMatrix
flipY(): DOMMatrix
inverse(): DOMMatrix
multiply(other?: DOMMatrixInit): DOMMatrix
rotate(rotX?: number, rotY?: number, rotZ?: number): DOMMatrix
rotateAxisAngle(x?: number, y?: number, z?: number, angle?: number): DOMMatrix
rotateFromVector(x?: number, y?: number): DOMMatrix
scale(
scaleX?: number,
scaleY?: number,
scaleZ?: number,
originX?: number,
originY?: number,
originZ?: number,
): DOMMatrix
scale3d(scale?: number, originX?: number, originY?: number, originZ?: number): DOMMatrix
skewX(sx?: number): DOMMatrix
skewY(sy?: number): DOMMatrix
toFloat32Array(): Float32Array
toFloat64Array(): Float64Array
transformPoint(point?: DOMPointInit): DOMPoint
translate(tx?: number, ty?: number, tz?: number): DOMMatrix
toString(): string
}
export interface DOMMatrix extends DOMMatrixReadOnly {
a: number
b: number
c: number
d: number
e: number
f: number
m11: number
m12: number
m13: number
m14: number
m21: number
m22: number
m23: number
m24: number
m31: number
m32: number
m33: number
m34: number
m41: number
m42: number
m43: number
m44: number
invertSelf(): DOMMatrix
multiplySelf(other?: DOMMatrixInit): DOMMatrix
preMultiplySelf(other?: DOMMatrixInit): DOMMatrix
rotateAxisAngleSelf(x?: number, y?: number, z?: number, angle?: number): DOMMatrix
rotateFromVectorSelf(x?: number, y?: number): DOMMatrix
rotateSelf(rotX?: number, rotY?: number, rotZ?: number): DOMMatrix
scale3dSelf(scale?: number, originX?: number, originY?: number, originZ?: number): DOMMatrix
scaleSelf(
scaleX?: number,
scaleY?: number,
scaleZ?: number,
originX?: number,
originY?: number,
originZ?: number,
): DOMMatrix
setMatrixValue(transformList: string): DOMMatrix
skewXSelf(sx?: number): DOMMatrix
skewYSelf(sy?: number): DOMMatrix
translateSelf(tx?: number, ty?: number, tz?: number): DOMMatrix
toJSON(): { [K in OmitNeverOfMatrix]: DOMMatrix[K] }
}
type OmitMatrixMethod = {
[K in keyof DOMMatrix]: DOMMatrix[K] extends (...args: any[]) => any ? never : K
}
type OmitNeverOfMatrix = OmitMatrixMethod[keyof OmitMatrixMethod]
export const DOMMatrix: {
prototype: DOMMatrix
new (init?: string | number[]): DOMMatrix
fromFloat32Array(array32: Float32Array): DOMMatrix
fromFloat64Array(array64: Float64Array): DOMMatrix
fromMatrix(other?: DOMMatrixInit): DOMMatrix
}
interface DOMRectReadOnly {
readonly bottom: number
readonly height: number
readonly left: number
readonly right: number
readonly top: number
readonly width: number
readonly x: number
readonly y: number
}
export interface DOMRect extends DOMRectReadOnly {
height: number
width: number
x: number
y: number
toJSON(): Omit<this, 'toJSON' | 'fromRect'>
}
export const DOMRect: {
prototype: DOMRect
new (x?: number, y?: number, width?: number, height?: number): DOMRect
fromRect(other?: DOMRectInit): DOMRect
}
interface DOMPointReadOnly {
readonly w: number
readonly x: number
readonly y: number
readonly z: number
matrixTransform(matrix?: DOMMatrixInit): DOMPoint
}
export interface DOMPoint extends DOMPointReadOnly {
w: number
x: number
y: number
z: number
toJSON(): Omit<DOMPoint, 'matrixTransform' | 'toJSON'>
}
export const DOMPoint: {
prototype: DOMPoint
new (x?: number, y?: number, z?: number, w?: number): DOMPoint
fromPoint(other?: DOMPointInit): DOMPoint
}
export class ImageData {
/**
* Returns the one-dimensional array containing the data in RGBA order, as integers in the range 0 to 255.
*/
readonly data: Uint8ClampedArray
/**
* Returns the actual dimensions of the data in the ImageData object, in pixels.
*/
readonly height: number
/**
* Returns the actual dimensions of the data in the ImageData object, in pixels.
*/
readonly width: number
constructor(sw: number, sh: number, attr?: { colorSpace?: ColorSpace })
constructor(imageData: ImageData, attr?: { colorSpace?: ColorSpace })
constructor(data: Uint8ClampedArray, sw: number, sh?: number)
}
export class Image {
constructor()
// attrs only affects SVG
constructor(width: number, height: number, attrs?: { colorSpace?: ColorSpace })
width: number
height: number
readonly naturalWidth: number
readonly naturalHeight: number
readonly complete: boolean
alt: string
// the src can be a Uint8Array or a string
// if it's a string, it can be a file path, a data URL, a remote URL, or a SVG string
src: Uint8Array | string
onload?(): void
onerror?(err: Error): void
}
export class Path2D {
constructor(path?: Path2D | string)
addPath(path: Path2D, transform?: DOMMatrix2DInit): void
arc(x: number, y: number, radius: number, startAngle: number, endAngle: number, anticlockwise?: boolean): void
arcTo(x1: number, y1: number, x2: number, y2: number, radius: number): void
bezierCurveTo(cp1x: number, cp1y: number, cp2x: number, cp2y: number, x: number, y: number): void
closePath(): void
ellipse(
x: number,
y: number,
radiusX: number,
radiusY: number,
rotation: number,
startAngle: number,
endAngle: number,
anticlockwise?: boolean,
): void
lineTo(x: number, y: number): void
moveTo(x: number, y: number): void
quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void
rect(x: number, y: number, w: number, h: number): void
roundRect(x: number, y: number, w: number, h: number, radii?: number | number[]): void
// PathKit methods
op(path: Path2D, operation: PathOp): Path2D
toSVGString(): string
getFillType(): FillType
getFillTypeString(): string
setFillType(type: FillType): void
simplify(): Path2D
asWinding(): Path2D
stroke(stroke?: StrokeOptions): Path2D
transform(transform: DOMMatrix2DInit): Path2D
getBounds(): [left: number, top: number, right: number, bottom: number]
computeTightBounds(): [left: number, top: number, right: number, bottom: number]
trim(start: number, end: number, isComplement?: boolean): Path2D
dash(on: number, off: number, phase: number): Path2D
equals(path: Path2D): boolean
}
export interface StrokeOptions {
width?: number
miterLimit?: number
cap?: StrokeCap
join?: StrokeJoin
}
export interface SKRSContext2D
extends Omit<
CanvasRenderingContext2D,
'drawImage' | 'createPattern' | 'getTransform' | 'drawFocusIfNeeded' | 'scrollPathIntoView' | 'canvas'
> {
canvas: Canvas
/**
* @param startAngle The angle at which to begin the gradient, in radians. Angle measurements start vertically above the centre and move around clockwise.
* @param x The x-axis coordinate of the centre of the gradient.
* @param y The y-axis coordinate of the centre of the gradient.
*/
createConicGradient(startAngle: number, x: number, y: number): CanvasGradient
drawImage(image: Image | Canvas, dx: number, dy: number): void
drawImage(image: Image | Canvas, dx: number, dy: number, dw: number, dh: number): void
drawImage(
image: Image | Canvas,
sx: number,
sy: number,
sw: number,
sh: number,
dx: number,
dy: number,
dw: number,
dh: number,
): void
createPattern(
image: Image | ImageData,
repeat: 'repeat' | 'repeat-x' | 'repeat-y' | 'no-repeat' | null,
): CanvasPattern
getContextAttributes(): { alpha: boolean; desynchronized: boolean }
getTransform(): DOMMatrix
letterSpacing: string
wordSpacing: string
}
export type ColorSpace = 'srgb' | 'display-p3'
export interface ContextAttributes {
alpha?: boolean
colorSpace?: ColorSpace
}
export interface SvgCanvas {
width: number
height: number
getContext(contextType: '2d', contextAttributes?: ContextAttributes): SKRSContext2D
getContent(): Buffer
}
export interface AvifConfig {
/** 0-100 scale, 100 is lossless */
quality?: number
/** 0-100 scale */
alphaQuality?: number
/** rav1e preset 1 (slow) 10 (fast but crappy), default is 4 */
speed?: number
/** How many threads should be used (0 = match core count) */
threads?: number
/** set to '4:2:0' to use chroma subsampling, default '4:4:4' */
chromaSubsampling?: ChromaSubsampling
}
/**
* https://en.wikipedia.org/wiki/Chroma_subsampling#Types_of_sampling_and_subsampling
* https://developer.mozilla.org/en-US/docs/Web/Media/Formats/Video_concepts
*/
export enum ChromaSubsampling {
/**
* Each of the three Y'CbCr components has the same sample rate, thus there is no chroma subsampling. This scheme is sometimes used in high-end film scanners and cinematic post-production.
* Note that "4:4:4" may instead be wrongly referring to R'G'B' color space, which implicitly also does not have any chroma subsampling (except in JPEG R'G'B' can be subsampled).
* Formats such as HDCAM SR can record 4:4:4 R'G'B' over dual-link HD-SDI.
*/
Yuv444 = 0,
/**
* The two chroma components are sampled at half the horizontal sample rate of luma: the horizontal chroma resolution is halved. This reduces the bandwidth of an uncompressed video signal by one-third.
* Many high-end digital video formats and interfaces use this scheme:
* - [AVC-Intra 100](https://en.wikipedia.org/wiki/AVC-Intra)
* - [Digital Betacam](https://en.wikipedia.org/wiki/Betacam#Digital_Betacam)
* - [Betacam SX](https://en.wikipedia.org/wiki/Betacam#Betacam_SX)
* - [DVCPRO50](https://en.wikipedia.org/wiki/DV#DVCPRO) and [DVCPRO HD](https://en.wikipedia.org/wiki/DV#DVCPRO_HD)
* - [Digital-S](https://en.wikipedia.org/wiki/Digital-S)
* - [CCIR 601](https://en.wikipedia.org/wiki/Rec._601) / [Serial Digital Interface](https://en.wikipedia.org/wiki/Serial_digital_interface) / [D1](https://en.wikipedia.org/wiki/D-1_(Sony))
* - [ProRes (HQ, 422, LT, and Proxy)](https://en.wikipedia.org/wiki/Apple_ProRes)
* - [XDCAM HD422](https://en.wikipedia.org/wiki/XDCAM)
* - [Canon MXF HD422](https://en.wikipedia.org/wiki/Canon_XF-300)
*/
Yuv422 = 1,
/**
* n 4:2:0, the horizontal sampling is doubled compared to 4:1:1,
* but as the **Cb** and **Cr** channels are only sampled on each alternate line in this scheme, the vertical resolution is halved.
* The data rate is thus the same.
* This fits reasonably well with the PAL color encoding system, since this has only half the vertical chrominance resolution of [NTSC](https://en.wikipedia.org/wiki/NTSC).
* It would also fit extremely well with the [SECAM](https://en.wikipedia.org/wiki/SECAM) color encoding system,
* since like that format, 4:2:0 only stores and transmits one color channel per line (the other channel being recovered from the previous line).
* However, little equipment has actually been produced that outputs a SECAM analogue video signal.
* In general, SECAM territories either have to use a PAL-capable display or a [transcoder](https://en.wikipedia.org/wiki/Transcoding) to convert the PAL signal to SECAM for display.
*/
Yuv420 = 2,
/**
* What if the chroma subsampling model is 4:0:0?
* That says to use every pixel of luma data, but that each row has 0 chroma samples applied to it. The resulting image, then, is comprised solely of the luminance data—a greyscale image.
*/
Yuv400 = 3,
}
export class Canvas {
constructor(width: number, height: number, flag?: SvgExportFlag)
width: number
height: number
getContext(contextType: '2d', contextAttributes?: ContextAttributes): SKRSContext2D
encodeSync(format: 'webp' | 'jpeg', quality?: number): Buffer
encodeSync(format: 'png'): Buffer
encodeSync(format: 'avif', cfg?: AvifConfig): Buffer
encode(format: 'webp' | 'jpeg', quality?: number): Promise<Buffer>
encode(format: 'png'): Promise<Buffer>
encode(format: 'avif', cfg?: AvifConfig): Promise<Buffer>
encodeStream(format: 'webp' | 'jpeg', quality?: number): ReadableStream<Buffer>
encodeStream(format: 'png'): ReadableStream<Buffer>
toBuffer(mime: 'image/png'): Buffer
toBuffer(mime: 'image/jpeg' | 'image/webp', quality?: number): Buffer
toBuffer(mime: 'image/avif', cfg?: AvifConfig): Buffer
// raw pixels
data(): Buffer
toDataURL(mime?: 'image/png'): string
toDataURL(mime: 'image/jpeg' | 'image/webp', quality?: number): string
toDataURL(mime?: 'image/jpeg' | 'image/webp' | 'image/png', quality?: number): string
toDataURL(mime?: 'image/avif', cfg?: AvifConfig): string
toDataURLAsync(mime?: 'image/png'): Promise<string>
toDataURLAsync(mime: 'image/jpeg' | 'image/webp', quality?: number): Promise<string>
toDataURLAsync(mime?: 'image/jpeg' | 'image/webp' | 'image/png', quality?: number): Promise<string>
toDataURLAsync(mime?: 'image/avif', cfg?: AvifConfig): Promise<string>
}
export function createCanvas(width: number, height: number): Canvas
export function createCanvas(width: number, height: number, svgExportFlag: SvgExportFlag): SvgCanvas
interface IGlobalFonts {
readonly families: {
family: string
styles: {
weight: number
width: string
style: string
}[]
}[]
// return true if succeeded
register(font: Buffer, nameAlias?: string): boolean
// absolute path
registerFromPath(path: string, nameAlias?: string): boolean
has(name: string): boolean
loadFontsFromDir(path: string): number
}
export const GlobalFonts: IGlobalFonts
export enum PathOp {
Difference = 0, // subtract the op path from the first path
Intersect = 1, // intersect the two paths
Union = 2, // union (inclusive-or) the two paths
Xor = 3, // exclusive-or the two paths
ReverseDifference = 4, // subtract the first path from the op path
}
export enum FillType {
Winding = 0,
EvenOdd = 1,
InverseWinding = 2,
InverseEvenOdd = 3,
}
export enum StrokeJoin {
Miter = 0,
Round = 1,
Bevel = 2,
}
export enum StrokeCap {
Butt = 0,
Round = 1,
Square = 2,
}
export enum SvgExportFlag {
ConvertTextToPaths = 0x01,
NoPrettyXML = 0x02,
RelativePathEncoding = 0x04,
}
export function convertSVGTextToPath(svg: Buffer | string): Buffer
export interface LoadImageOptions {
alt?: string
maxRedirects?: number
requestOptions?: import('http').RequestOptions
}
export function loadImage(
source: string | URL | Buffer | ArrayBufferLike | Uint8Array | Image | import('stream').Readable,
options?: LoadImageOptions,
): Promise<Image>
-116
View File
@@ -1,116 +0,0 @@
const { platform, homedir } = require('os')
const { join } = require('path')
const {
clearAllCache,
CanvasRenderingContext2D,
CanvasElement,
SVGCanvas,
Path: Path2D,
ImageData,
Image,
CanvasPattern,
GlobalFonts,
PathOp,
FillType,
StrokeJoin,
StrokeCap,
convertSVGTextToPath,
} = require('./js-binding')
const { DOMPoint, DOMMatrix, DOMRect } = require('./geometry')
const loadImage = require('./load-image')
const SvgExportFlag = {
ConvertTextToPaths: 0x01,
NoPrettyXML: 0x02,
RelativePathEncoding: 0x04,
}
// eslint-disable-next-line sonarjs/no-unused-collection
const Fonts = []
if (!('families' in GlobalFonts)) {
Object.defineProperty(GlobalFonts, 'families', {
get: function () {
return JSON.parse(GlobalFonts.getFamilies().toString())
},
})
}
if (!('has' in GlobalFonts)) {
Object.defineProperty(GlobalFonts, 'has', {
value: function has(name) {
return !!JSON.parse(GlobalFonts.getFamilies().toString()).find(({ family }) => family === name)
},
configurable: false,
enumerable: false,
writable: false,
})
}
const _getTransform = CanvasRenderingContext2D.prototype.getTransform
CanvasRenderingContext2D.prototype.getTransform = function getTransform() {
const transform = _getTransform.apply(this, arguments)
// monkey patched, skip
if (transform instanceof DOMMatrix) {
return transform
}
const { a, b, c, d, e, f } = transform
return new DOMMatrix([a, b, c, d, e, f])
}
function createCanvas(width, height, flag) {
const isSvgBackend = typeof flag !== 'undefined'
return isSvgBackend ? new SVGCanvas(width, height, flag) : new CanvasElement(width, height)
}
class Canvas {
constructor(width, height, flag) {
return createCanvas(width, height, flag)
}
static [Symbol.hasInstance](instance) {
return instance instanceof CanvasElement || instance instanceof SVGCanvas
}
}
if (!process.env.DISABLE_SYSTEM_FONTS_LOAD) {
GlobalFonts.loadSystemFonts()
const platformName = platform()
const homedirPath = homedir()
switch (platformName) {
case 'win32':
GlobalFonts.loadFontsFromDir(join(homedirPath, 'AppData', 'Local', 'Microsoft', 'Windows', 'Fonts'))
break
case 'darwin':
GlobalFonts.loadFontsFromDir(join(homedirPath, 'Library', 'Fonts'))
break
case 'linux':
GlobalFonts.loadFontsFromDir(join('usr', 'local', 'share', 'fonts'))
GlobalFonts.loadFontsFromDir(join(homedirPath, '.fonts'))
break
}
}
module.exports = {
clearAllCache,
Canvas,
createCanvas,
Path2D,
ImageData,
Image,
PathOp,
FillType,
StrokeCap,
StrokeJoin,
SvgExportFlag,
GlobalFonts: GlobalFonts,
convertSVGTextToPath,
DOMPoint,
DOMMatrix,
DOMRect,
loadImage,
}
@@ -1,269 +0,0 @@
const { existsSync, readFileSync } = require('fs')
const { join } = require('path')
const { platform, arch } = process
let nativeBinding = null
let localFileExisted = false
let loadError = null
function isMusl() {
// For Node 10
if (!process.report || typeof process.report.getReport !== 'function') {
try {
return readFileSync('/usr/bin/ldd', 'utf8').includes('musl')
} catch (e) {
return true
}
} else {
const { glibcVersionRuntime } = process.report.getReport().header
return !glibcVersionRuntime
}
}
switch (platform) {
case 'android':
switch (arch) {
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'skia.android-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.android-arm64.node')
} else {
nativeBinding = require('@napi-rs/canvas-android-arm64')
}
} catch (e) {
loadError = e
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'skia.android-arm-eabi.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.android-arm-eabi.node')
} else {
nativeBinding = require('@napi-rs/canvas-android-arm-eabi')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Android ${arch}`)
}
break
case 'win32':
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'skia.win32-x64-msvc.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.win32-x64-msvc.node')
} else {
nativeBinding = require('@napi-rs/canvas-win32-x64-msvc')
}
} catch (e) {
loadError = e
}
break
case 'ia32':
localFileExisted = existsSync(join(__dirname, 'skia.win32-ia32-msvc.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.win32-ia32-msvc.node')
} else {
nativeBinding = require('@napi-rs/canvas-win32-ia32-msvc')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'skia.win32-arm64-msvc.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.win32-arm64-msvc.node')
} else {
nativeBinding = require('@napi-rs/canvas-win32-arm64-msvc')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Windows: ${arch}`)
}
break
case 'darwin':
switch (arch) {
case 'x64':
localFileExisted = existsSync(join(__dirname, 'skia.darwin-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.darwin-x64.node')
} else {
nativeBinding = require('@napi-rs/canvas-darwin-x64')
}
} catch (e) {
loadError = e
}
break
case 'arm64':
localFileExisted = existsSync(join(__dirname, 'skia.darwin-arm64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.darwin-arm64.node')
} else {
nativeBinding = require('@napi-rs/canvas-darwin-arm64')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on macOS: ${arch}`)
}
break
case 'freebsd':
if (arch !== 'x64') {
throw new Error(`Unsupported architecture on FreeBSD: ${arch}`)
}
localFileExisted = existsSync(join(__dirname, 'skia.freebsd-x64.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.freebsd-x64.node')
} else {
nativeBinding = require('@napi-rs/canvas-freebsd-x64')
}
} catch (e) {
loadError = e
}
break
case 'linux':
switch (arch) {
case 'x64':
if (isMusl()) {
localFileExisted = existsSync(join(__dirname, 'skia.linux-x64-musl.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-x64-musl.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-x64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(join(__dirname, 'skia.linux-x64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-x64-gnu.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-x64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm64':
if (isMusl()) {
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm64-musl.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-arm64-musl.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-arm64-musl')
}
} catch (e) {
loadError = e
}
} else {
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-arm64-gnu.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-arm64-gnu')
}
} catch (e) {
loadError = e
}
}
break
case 'arm':
localFileExisted = existsSync(join(__dirname, 'skia.linux-arm-gnueabihf.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-arm-gnueabihf.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-arm-gnueabihf')
}
} catch (e) {
loadError = e
}
break
case 'riscv64':
localFileExisted = existsSync(join(__dirname, 'skia.linux-riscv64-gnu.node'))
try {
if (localFileExisted) {
nativeBinding = require('./skia.linux-riscv64-gnu.node')
} else {
nativeBinding = require('@napi-rs/canvas-linux-riscv64-gnu')
}
} catch (e) {
loadError = e
}
break
default:
throw new Error(`Unsupported architecture on Linux: ${arch}`)
}
break
default:
throw new Error(`Unsupported OS: ${platform}, architecture: ${arch}`)
}
if (!nativeBinding) {
if (loadError) {
throw loadError
}
throw new Error(`Failed to load native binding`)
}
const {
ChromaSubsampling,
SvgExportFlag,
CanvasRenderingContext2D,
CanvasGradient,
ImageData,
Image,
PathOp,
FillType,
StrokeCap,
StrokeJoin,
Path,
CanvasPattern,
convertSVGTextToPath,
CanvasElement,
SVGCanvas,
clearAllCache,
GlobalFonts,
} = nativeBinding
module.exports.ChromaSubsampling = ChromaSubsampling
module.exports.SvgExportFlag = SvgExportFlag
module.exports.CanvasRenderingContext2D = CanvasRenderingContext2D
module.exports.CanvasGradient = CanvasGradient
module.exports.ImageData = ImageData
module.exports.Image = Image
module.exports.PathOp = PathOp
module.exports.FillType = FillType
module.exports.StrokeCap = StrokeCap
module.exports.StrokeJoin = StrokeJoin
module.exports.Path = Path
module.exports.CanvasPattern = CanvasPattern
module.exports.convertSVGTextToPath = convertSVGTextToPath
module.exports.CanvasElement = CanvasElement
module.exports.SVGCanvas = SVGCanvas
module.exports.clearAllCache = clearAllCache
module.exports.GlobalFonts = GlobalFonts
@@ -1,141 +0,0 @@
const fs = require('fs')
const { Readable } = require('stream')
const { URL } = require('url')
const { Image } = require('./js-binding')
let http, https
const MAX_REDIRECTS = 20
const REDIRECT_STATUSES = new Set([301, 302])
/**
* Loads the given source into canvas Image
* @param {string|URL|Image|Buffer} source The image source to be loaded
* @param {object} options Options passed to the loader
*/
module.exports = async function loadImage(source, options = {}) {
// use the same buffer without copying if the source is a buffer
if (Buffer.isBuffer(source) || source instanceof Uint8Array) return createImage(source, options.alt)
// load readable stream as image
if (source instanceof Readable) return createImage(await consumeStream(source), options.alt)
// construct a Uint8Array if the source is ArrayBuffer or SharedArrayBuffer
if (source instanceof ArrayBuffer || source instanceof SharedArrayBuffer)
return createImage(new Uint8Array(source), options.alt)
// construct a buffer if the source is buffer-like
if (isBufferLike(source)) return createImage(Buffer.from(source), options.alt)
// if the source is Image instance, copy the image src to new image
if (source instanceof Image) return createImage(source.src, options.alt)
// if source is string and in data uri format, construct image using data uri
if (typeof source === 'string' && source.trimStart().startsWith('data:')) {
const commaIdx = source.indexOf(',')
const encoding = source.lastIndexOf('base64', commaIdx) < 0 ? 'utf-8' : 'base64'
const data = Buffer.from(source.slice(commaIdx + 1), encoding)
return createImage(data, options.alt)
}
// if source is a string or URL instance
if (typeof source === 'string') {
// if the source exists as a file, construct image from that file
if ((!source.startsWith('http') && !source.startsWith('https')) && await exists(source)) {
return createImage(source, options.alt)
} else {
// the source is a remote url here
source = new URL(source)
// attempt to download the remote source and construct image
const data = await new Promise((resolve, reject) =>
makeRequest(
source,
resolve,
reject,
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
options.requestOptions,
),
)
return createImage(data, options.alt)
}
}
if (source instanceof URL) {
if (source.protocol === 'file:') {
// remove the leading slash on windows
return createImage(process.platform === 'win32' ? source.pathname.substring(1) : source.pathname, options.alt)
} else {
const data = await new Promise((resolve, reject) =>
makeRequest(
source,
resolve,
reject,
typeof options.maxRedirects === 'number' && options.maxRedirects >= 0 ? options.maxRedirects : MAX_REDIRECTS,
options.requestOptions,
),
)
return createImage(data, options.alt)
}
}
// throw error as don't support that source
throw new TypeError('unsupported image source')
}
function makeRequest(url, resolve, reject, redirectCount, requestOptions) {
const isHttps = url.protocol === 'https:'
// lazy load the lib
const lib = isHttps ? (!https ? (https = require('https')) : https) : !http ? (http = require('http')) : http
lib
.get(url.toString(), requestOptions || {}, (res) => {
try {
const shouldRedirect = REDIRECT_STATUSES.has(res.statusCode) && typeof res.headers.location === 'string'
if (shouldRedirect && redirectCount > 0)
return makeRequest(
new URL(res.headers.location, url.origin),
resolve,
reject,
redirectCount - 1,
requestOptions,
)
if (typeof res.statusCode === 'number' && (res.statusCode < 200 || res.statusCode >= 300)) {
return reject(new Error(`remote source rejected with status code ${res.statusCode}`))
}
consumeStream(res).then(resolve, reject)
} catch (err) {
reject(err)
}
})
.on('error', reject)
}
// use stream/consumers in the future?
function consumeStream(res) {
return new Promise((resolve, reject) => {
const chunks = []
res.on('data', (chunk) => chunks.push(chunk))
res.on('end', () => resolve(Buffer.concat(chunks)))
res.on('error', reject)
})
}
function createImage(src, alt) {
return new Promise((resolve, reject) => {
const image = new Image()
if (typeof alt === 'string') image.alt = alt
image.onload = () => resolve(image)
image.onerror = (e) => reject(e)
image.src = src
})
}
function isBufferLike(src) {
return (src && src.type === 'Buffer') || Array.isArray(src)
}
async function exists(path) {
try {
await fs.promises.access(path, fs.constants.F_OK)
return true
} catch {
return false
}
}
-152
View File
@@ -1,152 +0,0 @@
{
"name": "@napi-rs/canvas",
"version": "0.1.66",
"description": "Canvas for Node.js with skia backend",
"main": "index.js",
"repository": {
"type": "git",
"url": "git+https://github.com/Brooooooklyn/canvas.git"
},
"license": "MIT",
"keywords": [
"napi-rs",
"NAPI",
"N-API",
"Rust",
"node-addon",
"node-addon-api",
"canvas",
"image",
"pdf",
"svg",
"skia"
],
"files": [
"index.d.ts",
"index.js",
"geometry.js",
"js-binding.js",
"load-image.js"
],
"napi": {
"binaryName": "skia",
"targets": [
"x86_64-unknown-linux-gnu",
"x86_64-apple-darwin",
"x86_64-pc-windows-msvc",
"armv7-unknown-linux-gnueabihf",
"x86_64-unknown-linux-musl",
"aarch64-unknown-linux-gnu",
"aarch64-unknown-linux-musl",
"aarch64-apple-darwin",
"aarch64-linux-android",
"riscv64-unknown-linux-gnu"
]
},
"engines": {
"node": ">= 10"
},
"publishConfig": {
"registry": "https://registry.npmjs.org/",
"access": "public"
},
"scripts": {
"artifacts": "napi artifacts",
"bench": "node -r @swc-node/register benchmark/bench.ts",
"build": "napi build --platform --release --js js-binding.js",
"build:debug": "napi build --platform --js js-binding.js",
"format": "run-p format:source format:rs format:toml",
"format:rs": "cargo fmt",
"format:source": "prettier . -w",
"format:toml": "taplo format",
"lint": "oxlint",
"prepublishOnly": "pinst --disable && napi prepublish -t npm",
"postpublish": "pinst --enable",
"test:ci": "ava -c 1",
"test": "ava",
"version": "napi version && conventional-changelog -p angular -i CHANGELOG.md -s && git add CHANGELOG.md"
},
"devDependencies": {
"@jimp/core": "^1.0.0",
"@jimp/custom": "^0.22.10",
"@jimp/jpeg": "^0.22.10",
"@jimp/png": "^0.22.10",
"@napi-rs/cli": "^3.0.0-alpha.70",
"@octokit/rest": "^21.0.0",
"@swc-node/register": "^1.8.0",
"@swc/core": "^1.4.0",
"@taplo/cli": "^0.7.0",
"@types/lodash": "^4.14.202",
"@types/node": "^20.11.16",
"@types/semver": "^7",
"ava": "^6.1.1",
"canvas": "^3.0.0",
"canvaskit-wasm": "^0.39.1",
"colorette": "^2.0.20",
"conventional-changelog-cli": "^5.0.0",
"core-js": "^3.38.0",
"echarts": "^5.4.3",
"husky": "^9.0.10",
"lint-staged": "^15.2.1",
"lodash": "^4.17.21",
"npm-run-all2": "^7.0.0",
"oxlint": "^0.15.0",
"pinst": "^3.0.0",
"png.js": "^0.2.1",
"prettier": "^3.2.5",
"pretty-bytes": "^6.1.1",
"semver": "^7.5.4",
"skia-canvas": "^2.0.0",
"table": "^6.8.1",
"tinybench": "^3.0.6",
"typescript": "^5.3.3"
},
"lint-staged": {
"*.@(js|ts|tsx|yml|yaml|md|json|html)": [
"prettier --write"
],
"*.@(js|ts|tsx)": [
"oxlint --fix"
]
},
"ava": {
"require": [
"@swc-node/register",
"core-js/proposals/promise-with-resolvers.js"
],
"extensions": [
"ts"
],
"files": [
"__test__/**/*.spec.ts",
"scripts/__test__/**/*.spec.ts"
],
"workerThreads": false,
"cache": false,
"timeout": "3m",
"environmentVariables": {
"SWC_NODE_PROJECT": "./tsconfig.json",
"NODE_ENV": "ava"
}
},
"prettier": {
"printWidth": 120,
"semi": false,
"trailingComma": "all",
"singleQuote": true,
"arrowParens": "always"
},
"packageManager": "yarn@4.6.0",
"optionalDependencies": {
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
"@napi-rs/canvas-darwin-x64": "0.1.66",
"@napi-rs/canvas-win32-x64-msvc": "0.1.66",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
"@napi-rs/canvas-darwin-arm64": "0.1.66",
"@napi-rs/canvas-android-arm64": "0.1.66",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66"
}
}
-212
View File
@@ -1,212 +0,0 @@
{
"name": "spineviewer",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spineviewer",
"version": "1.1.0",
"license": "ISC",
"dependencies": {
"@napi-rs/canvas": "^0.1.66"
}
},
"node_modules/@napi-rs/canvas": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.66.tgz",
"integrity": "sha512-NE/eQKLbUS+LCbMHRa5HnR7cc1Q4ibg/qfLUN4Ukl3CC0lq6LfHE0YbvFm/l4i5RyyS+aUjL+8IuZDD9EH3amg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.66",
"@napi-rs/canvas-darwin-arm64": "0.1.66",
"@napi-rs/canvas-darwin-x64": "0.1.66",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66",
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
"@napi-rs/canvas-win32-x64-msvc": "0.1.66"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-0.1.66.tgz",
"integrity": "sha512-77Yq9yaUYN90zCovYOpw7LhidJiswU9wLIWWBGF6iiEJyQdt6tkiXpGRZpOMJVO70afkcdc4T7532cxMIBhk0Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-0.1.66.tgz",
"integrity": "sha512-cz3aJ06b8BZGtwRxKTiE0OVUlB17MH8j+BnE4A5+wD9aD1guCCqECsz+k7tpXdAdTAYKRIz2pq6ZuiJ76NyUbQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-0.1.66.tgz",
"integrity": "sha512-szIWqJgFm2OTyGzM+hSiJOaOtjI73VYRC2KN30zZTt7i1+0sgpm5exK5ltDBPOmCdnLt7SbUfpInLj8VvxYlKA==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-0.1.66.tgz",
"integrity": "sha512-h/TZJFc6JLvp8FwbA5mu+yXiblN0iKqshU7xzd6L+ks5uNYgjS7XWLkNiyPQkMaXQgVczOJfZy7r4NSPK3V8Hg==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-0.1.66.tgz",
"integrity": "sha512-RGFUdBdi0Xmf+TfwZcB89Ap6hDYh4nzyJhXhNJIgve6ELrIPFhf7sDHvUHxjgW0YzczGoo+ophyCm03cJggu+w==",
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-0.1.66.tgz",
"integrity": "sha512-2cFViDIZ0xQlAHyJmyym+rj3p04V16vgAiz64sCAfwOOiW6e19agv1HQWHUsro3G2lF3PaHGAnp0WRPXGqLOfg==",
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-0.1.66.tgz",
"integrity": "sha512-Vm5ZWS2RDPeBpnfx83eJpZfJT07xl0jqp8d83PklKqiDNa3BmDZZ/uuI40/ICgejGLymXXYo5N21b7oAxhRTSA==",
"cpu": [
"riscv64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-0.1.66.tgz",
"integrity": "sha512-/ptGBhErNBCgWff3khtuEjhiiYWf70oWvBPRj8y5EMB0nLYpve7RxxFnavVvxN49kJ0MQHRIwgfyd47RSOOKPw==",
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-0.1.66.tgz",
"integrity": "sha512-XunvXisTkIG+bpq6BcXmsUstoLX3RLS6N9Uz9Pg9RpWIMeM6ObR5shr3NgpGRJq93769I1hS4mJW0DX2Au3WBw==",
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.66.tgz",
"integrity": "sha512-3n34watNFqpwACDA+pt4jfQD6zR8PzfK86FBajdsgDVVZhSp6ohgbbJv+eUrXM08VUtjxTq7+U4sWspTu9+4Ug==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
@@ -1,11 +0,0 @@
{
"name": "spineviewer",
"version": "1.1.0",
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
"main": "thumbnail/spine.js",
"private": true,
"type": "commonjs",
"dependencies": {
"@napi-rs/canvas": "^0.1.66"
}
}
@@ -1,585 +0,0 @@
const fs = require('fs');
const path = require('path');
const os = require('os');
const http = require('http');
const crypto = require('crypto');
const zlib = require('zlib');
const utils = require('./../js/spine-utils.js');
const render = require('./../js/spine-render.js');
/** 简易日志(写入插件目录 thumbnails.log,便于诊断 Eagle 中的间歇失败) */
function log(msg) {
try {
const logPath = path.join(__dirname, '..', 'thumbnails.log');
fs.appendFileSync(logPath, '[' + new Date().toISOString() + '] ' + msg + '\n');
} catch (e) { /* ignore */ }
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const discoveryJobs = new Map();
const renderJobs = new Map();
const refreshJobs = new Map();
/** 插件代码版本:每次改动递增,用于确认 Eagle 是否加载到最新代码 */
const VERSION = 'v14';
function coordinationKey(src) {
const root = utils.findLibraryRoot(src) || path.dirname(path.dirname(src));
const folders = utils.foldersOfItem(src).slice().sort().join(',') || 'unfiled';
return root + '|' + folders + '|' + utils.normalizeStem(path.basename(src));
}
/** 返回资源当前的尺寸/修改时间签名,同时确认 atlas 引用的全部贴图均已落盘。 */
function assetSignature(assets) {
if (!assets || !assets.skeletonPath || !assets.atlasPath) return null;
let atlasText = '';
try { atlasText = fs.readFileSync(assets.atlasPath, 'utf8'); } catch (e) { return null; }
const pages = utils.parseAtlasPages(atlasText);
if (pages.length === 0 || pages.some((name) => !assets.texturePaths[name])) return null;
const paths = [assets.skeletonPath, assets.atlasPath].concat(pages.map((name) => assets.texturePaths[name]));
const stats = [];
for (const file of paths) {
try {
const stat = fs.statSync(file);
if (!stat.isFile() || stat.size === 0) return null;
stats.push(file + ':' + stat.size + ':' + Math.round(stat.mtimeMs));
} catch (e) {
return null;
}
}
return stats.join('|');
}
/**
* Eagle 拖入一组文件时会并发触发 atlas/json/skel 的缩略图回调,而文件内容和
* folders 元数据仍可能继续写入。等同批资源连续三次保持不变后再渲染,避免把
* 半截 atlas 或旧库中的同名骨骼渲染成占位图。
*/
async function runDiscovery(src, retries, delay) {
let assets = null;
let previousSignature = null;
let stableCount = 0;
for (let i = 0; i < retries; i++) {
// 前台缩略图回调只接受同批资源,绝不借用库里旧的同名 atlas/贴图。
// 分批拖入的跨批次补齐统一交给后台任务处理。
assets = utils.discoverAssets(src, {
strictStem: true,
allowCrossBatch: false,
refreshIndex: i > 0,
});
const signature = assetSignature(assets);
if (signature && signature === previousSignature) stableCount += 1;
else stableCount = signature ? 1 : 0;
previousSignature = signature;
if (stableCount >= 3) return assets;
if (i < retries - 1) await sleep(delay);
}
return assets;
}
/** 同一资源组的 atlas/json/skel 回调共享发现任务,避免大批量导入时重复全库扫描。 */
async function discoverWithRetry(src, retries = 5, delay = 120) {
const key = coordinationKey(src);
if (discoveryJobs.has(key)) return discoveryJobs.get(key);
const job = runDiscovery(src, retries, delay);
discoveryJobs.set(key, job);
try {
return await job;
} finally {
if (discoveryJobs.get(key) === job) discoveryJobs.delete(key);
}
}
/** JSON 仍在落盘时只做极短等待;普通 JSON 一经确认便立即退出 Spine 流程。 */
async function classifyJsonBriefly(src) {
let kind = 'pending';
for (let i = 0; i < 3; i++) {
kind = utils.classifySkeletonJson(src);
if (kind !== 'pending') return kind;
if (i < 2) await sleep(80);
}
return kind;
}
/** 同一份稳定资源只渲染一次,atlas/json/skel 并发回调共享输出 Buffer。 */
async function renderThumbnailShared(assets, options) {
const key = assetSignature(assets) + '|' + options.version + '|' + options.runtimeDir + '|' + options.size;
if (renderJobs.has(key)) {
log('复用同组渲染结果: ' + (assets.base || path.basename(options.skeletonPath)));
return renderJobs.get(key);
}
log('开始同组统一渲染: ' + (assets.base || path.basename(options.skeletonPath)));
const job = render.renderThumbnail(options);
renderJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (renderJobs.get(key) === job) renderJobs.delete(key); }, 2000);
}
}
/**
* Eagle 格式扩展缩略图处理函数。
* 参数:{ src, dest, item }
* - src : 原始文件路径(skel/skel.bytes/json/atlas/atlas.txt)
* - dest : 需要生成的缩略图 PNG 路径
* - item : 当前 item 对象(可更新宽高)
*/
module.exports = async (options) => {
const { src, dest, item } = options;
const changed = require('../js/eagle-compound.js').normalizeItem(src, item);
// Eagle originally chooses the cache name before invoking the plugin.
// Supply that requested output as well as the corrected compound-name cache.
const canonical = changed && path.basename(dest).endsWith('_thumbnail.png')
? path.join(path.dirname(dest), item.name + '_thumbnail.png') : dest;
const result = await generate({ ...options, deferRefresh: changed });
if (canonical !== dest && fs.existsSync(dest)) {
if (render.canvasLib) {
const image = await render.canvasLib.loadImage(dest);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
fs.writeFileSync(canonical, await canvas.encode('webp', 90));
} else fs.copyFileSync(dest, canonical);
}
if (changed && item.id) {
log('复合后缀元数据已规范化: name=' + item.name + ' ext=' + item.ext);
}
const ext = utils.extOf(path.basename(src));
if (!item.isDeleted && (utils.isAtlasFile(src) || (utils.isSkeletonFile(src) && (ext !== '.json' || utils.classifySkeletonJson(src) === 'spine')))) scheduleBackgroundReconcile(src);
return result;
};
async function generate({ src, dest, item, deferRefresh }) {
try {
// 记录 Eagle 传入的关键信息,便于诊断 src/item 的实际形态
log('HIT ' + VERSION + ' src=' + src + ' (isTexture=' + utils.isTextureFile(src) + ')');
try {
log(' item keys=' + Object.keys(item || {}).join(',') + ' | name=' + (item && item.name) + ' | id=' + (item && item.id) + ' | ext=' + (item && item.ext) + ' | isDeleted=' + (item && item.isDeleted));
} catch (e) { /* ignore */ }
// —— 守护:若 src 文件在库中不存在(可能是回收站 isDeleted 残留 / 临时 id),
// 且 item 标记为已删除,则跳过,避免生成破碎/占位缩略图,交由 Eagle 自身处理。
if (item && item.isDeleted) {
log(' skip: item.isDeleted=true');
return resolve(item);
}
// —— 防御:若 src 是图片(png/jpg/jpeg/webp),不生成占位图,
// 而是把原始图片复制/转换为缩略图,交由系统展示真实内容。
const srcExt = utils.extOf(path.basename(src));
// Eagle dispatches compound suffixes as txt/bytes. Only the complete
// .atlas.txt/.skel.bytes names enter Spine discovery; ordinary files exit.
if (!srcExt) {
if (/\.txt$/i.test(src)) {
// Eagle's native text card requires item.text, even for an empty file.
item.text = '';
const fd = fs.openSync(src, 'r');
try { const buffer = Buffer.alloc(32768); item.text = buffer.subarray(0, fs.readSync(fd, buffer, 0, buffer.length, 0)).toString('utf8'); }
finally { fs.closeSync(fd); }
}
await writeNeutralJsonPlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
if (utils.isTextureFile(src) || srcExt === '.png' || srcExt === '.jpg' || srcExt === '.jpeg' || srcExt === '.webp') {
await copyImageAsThumb(src, dest);
try { const s = imageSizeOf(dest); item.width = s.width || item.width; item.height = s.height || item.height; } catch (e) {}
return resolve(item);
}
// manifest 只能按扩展名接管 JSON,不能按文件内容过滤。因此先做低成本内容判定:
// 普通 JSON 不写 dest,不修改尺寸,也不启动资源扫描和后台补刷。
// 这样可以保留 Eagle 或其他 JSON 插件生成的缩略图,避免本插件的回退图覆盖它。
if (srcExt === '.json') {
const jsonKind = await classifyJsonBriefly(src);
if (jsonKind === 'other') {
log('普通 JSON,放行给 Eagle/其他 JSON 插件: ' + src);
return resolve(item);
}
}
let assets = await discoverWithRetry(src);
// 找不到完整 Spine 资源链(骨骼 + atlas + atlas 引用的全部贴图)
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) {
log('未找到配套资源: ' + src + ' (skeleton=' + !!assets.skeletonPath + ', atlas=' + !!assets.atlasPath + ')');
if (assets.atlasPath && await writeAtlasFallback(assets, dest)) {
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
let version = utils.detectVersion(assets.skeletonPath);
let runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) {
if (await writeAtlasFallback(assets, dest)) {
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
await writePlaceholder(dest);
item.width = 400; item.height = 400;
return resolve(item);
}
// 渲染首帧到 PNG。若导入过程恰好仍在写大文件,重新发现稳定资源再试,
// 每次都会重新匹配同批 atlas/json/skel,而不是重复读取原来的错误组合。
let png = null;
let lastRenderError = null;
for (let attempt = 0; attempt < 3; attempt++) {
try {
png = await renderThumbnailShared(assets, {
skeletonPath: assets.skeletonPath,
atlasPath: assets.atlasPath,
texturePaths: assets.texturePaths,
version,
runtimeDir,
size: 400,
});
break;
} catch (e) {
lastRenderError = e;
log('renderThumbnail 第 ' + (attempt + 1) + ' 次失败: ' + (e && e.message) + ',重新等待同批资源...');
if (attempt === 2) break;
await sleep(500);
assets = await discoverWithRetry(src, 4, 150);
if (!assets.skeletonPath || !assets.atlasPath || !assetSignature(assets)) continue;
version = utils.detectVersion(assets.skeletonPath);
runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
}
}
if (!png) {
if (await writeAtlasFallback(assets, dest)) {
log('骨骼无法解析,已使用 atlas 贴图缩略图: ' + src);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
item.width = 400; item.height = 400;
return resolve(item);
}
throw lastRenderError || new Error('Spine 资源尚未写入完成');
}
fs.writeFileSync(dest, png);
if (!deferRefresh) await refreshRelatedThumbnails(assets, dest, item && item.id);
try {
const size = imageSizeOf(dest);
item.width = size.width || 400;
item.height = size.height || 400;
} catch (e) { /* ignore */ }
return Promise.resolve(item);
} catch (err) {
// 兜底:总是生成一张有效 PNG,绝不 reject,避免 Eagle 显示默认图标
log('缩略图处理异常: ' + (err && err.message) + '\n' + ((err && err.stack) || ''));
try {
await writePlaceholder(dest);
item.width = 400;
item.height = 400;
return resolve(item);
} catch (e2) {
// 最后兜底:写入一张最小 1x1 PNG
try { fs.writeFileSync(dest, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+M8AAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64')); } catch (e3) {}
item.width = 400; item.height = 400;
return resolve(item);
}
}
};
function resolve(item) {
return item;
}
function itemIdFromAssetPath(filePath) {
const match = path.basename(path.dirname(filePath)).match(/^(.+)\.info$/i);
return match ? match[1] : null;
}
async function setEagleCustomThumbnail(itemId, thumbnailPath, expectedFilename) {
if (expectedFilename) {
// Eagle's foreground may still hold the old name after a type change.
// Do not let setCustomThumbnail persist that stale name back to metadata.
const info = await new Promise((resolveInfo, rejectInfo) => {
const req = http.get('http://127.0.0.1:41595/api/item/info?id=' + encodeURIComponent(itemId), response => {
let body = ''; response.on('data', chunk => { body += chunk; });
response.on('end', () => {
try { const result = JSON.parse(body); if (result.status !== 'success') throw new Error('Eagle 素材尚未入库'); resolveInfo(result.data); }
catch (error) { rejectInfo(error); }
});
});
req.setTimeout(3000, () => req.destroy(new Error('Eagle 素材信息请求超时'))); req.on('error', rejectInfo);
});
if (!info || info.name + '.' + info.ext !== expectedFilename) throw new Error('Eagle 尚未同步复合后缀名称,请重新打开素材库后刷新缩略图');
}
return new Promise((resolveRequest, rejectRequest) => {
const body = JSON.stringify({ id: itemId, thumbnailPath });
const request = http.request({
hostname: '127.0.0.1',
port: 41595,
path: '/api/item/setCustomThumbnail',
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body) },
}, (response) => {
let data = '';
response.on('data', (chunk) => { data += chunk; });
response.on('end', () => {
if (response.statusCode >= 200 && response.statusCode < 300) resolveRequest(data);
else rejectRequest(new Error('Eagle API HTTP ' + response.statusCode + ': ' + data));
});
});
request.on('error', rejectRequest);
request.setTimeout(12000, () => request.destroy(new Error('Eagle 缩略图请求超时')));
request.end(body);
});
}
async function createWebpCopy(sourcePath, key) {
if (!render.canvasLib) return sourcePath;
const image = await render.canvasLib.loadImage(sourcePath);
const canvas = render.canvasLib.createCanvas(image.width, image.height);
canvas.getContext('2d').drawImage(image, 0, 0);
const unique = crypto.randomBytes(8).toString('hex');
const output = path.join(os.tmpdir(), 'spineviewer-' + process.pid + '-' + Date.now() + '-' + unique + '-' + key.replace(/[^a-z0-9]/gi, '') + '.webp');
fs.writeFileSync(output, await canvas.encode('webp', 90));
return output;
}
/** 将一份已验证的缩略图主动应用到同一资源链的 atlas/json/skel 条目。 */
async function refreshRelatedThumbnails(assets, thumbnailPath, currentItemId) {
const signature = assetSignature(assets);
if (!signature) return false;
const ids = [...new Set((assets.relatedDataPaths || []).map(itemIdFromAssetPath).concat(currentItemId || []).filter(Boolean))];
if (ids.length === 0) return true;
const key = signature + '|' + ids.slice().sort().join(',');
if (refreshJobs.has(key)) return refreshJobs.get(key);
const job = (async () => {
let apiPath = thumbnailPath;
try {
apiPath = await createWebpCopy(thumbnailPath, utils.normalizeStem(path.basename(assets.skeletonPath || assets.atlasPath)));
const results = await Promise.allSettled(ids.map((id) => {
const source = (assets.relatedDataPaths || []).find(file => itemIdFromAssetPath(file) === id);
return setEagleCustomThumbnail(id, apiPath, source && path.basename(source));
}));
const failed = results.filter((result) => result.status === 'rejected');
if (failed.length) throw failed[0].reason;
log('统一补刷完成: ' + ids.length + ' 个条目 [' + ids.join(',') + ']');
return true;
} catch (e) {
log('统一补刷失败: ' + ((e && e.message) || String(e)));
return false;
} finally {
if (apiPath !== thumbnailPath) {
try { fs.unlinkSync(apiPath); } catch (e) { /* ignore */ }
}
}
})();
refreshJobs.set(key, job);
try {
return await job;
} finally {
setTimeout(() => { if (refreshJobs.get(key) === job) refreshJobs.delete(key); }, 2000);
}
}
/**
* 资源暂不完整时在后台继续观察。贴图最后落盘不会触发格式扩展回调,因此这里负责
* 在链路补齐后渲染一次,并统一刷新此前已生成占位图的所有数据条目。
*/
function scheduleBackgroundReconcile(src) {
try { require('../js/spine-reconcile-worker.js').enqueue(src); }
catch (error) { log('无法启动后台补刷: ' + String(error)); }
}
// Called by a separate Node worker; Eagle closes the thumbnail window when
// its callback resolves, so timers in that window cannot finish this work.
module.exports.reconcileOnce = async (src, previousSignature) => {
if (!fs.existsSync(src) || utils.isItemDeleted(src)) return { complete: true };
const assets = utils.discoverAssets(src, { strictStem: true, allowCrossBatch: true, refreshIndex: true });
const signature = assetSignature(assets);
if (!signature || signature !== previousSignature) return { signature, complete: false };
const version = utils.detectVersion(assets.skeletonPath);
const runtimeDir = utils.chooseRuntimeDir(version, assets.isBinary);
if (!runtimeDir) return { signature, complete: true };
const temp = path.join(os.tmpdir(), 'spineviewer-reconcile-' + crypto.randomBytes(12).toString('hex') + '.png');
try {
const png = await renderThumbnailShared(assets, { ...assets, version, runtimeDir, size: 400 });
fs.writeFileSync(temp, png);
const complete = await refreshRelatedThumbnails(assets, temp);
return { signature, complete };
} catch (error) {
log('后台补刷重试: ' + String(error));
return { signature, complete: false };
} finally { try { fs.unlinkSync(temp); } catch (error) { if (error.code !== 'ENOENT') log(String(error)); } }
};
/** 普通 JSON 使用中性文件图形,避免错误显示 Spine badge。 */
async function writeNeutralJsonPlaceholder(dest) {
const size = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#202126';
ctx.fillRect(0, 0, size, size);
ctx.fillStyle = '#555963';
ctx.beginPath();
ctx.moveTo(112, 72);
ctx.lineTo(244, 72);
ctx.lineTo(288, 116);
ctx.lineTo(288, 328);
ctx.lineTo(112, 328);
ctx.closePath();
ctx.fill();
ctx.fillStyle = '#777c87';
ctx.beginPath();
ctx.moveTo(244, 72);
ctx.lineTo(244, 116);
ctx.lineTo(288, 116);
ctx.closePath();
ctx.fill();
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
const pixels = Buffer.alloc(size * size * 4);
for (let i = 0; i < pixels.length; i += 4) {
pixels[i] = 32; pixels[i + 1] = 33; pixels[i + 2] = 38; pixels[i + 3] = 255;
}
fs.writeFileSync(dest, encodePng(size, size, pixels));
}
/** 将任意图片作为缩略图复制/转换为 PNG(PNG 直接拷贝,JPG/WebP 用 @napi-rs/canvas 转换) */
async function copyImageAsThumb(src, dest) {
const ext = path.extname(src).toLowerCase();
if (ext === '.png') {
fs.copyFileSync(src, dest);
return;
}
if (render.canvasLib) {
try {
const img = await render.canvasLib.loadImage(src);
const w = img.width, h = img.height;
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through */ }
}
fs.copyFileSync(src, dest);
}
/** 骨骼二进制无法解析时,用真实 atlas 贴图生成可辨认的回退缩略图。 */
async function writeAtlasFallback(assets, dest) {
if (!render.canvasLib || !assets || !assets.texturePaths) return false;
const texturePath = Object.values(assets.texturePaths).find((file) => {
try { return fs.statSync(file).size > 0; } catch (e) { return false; }
});
if (!texturePath) return false;
try {
const img = await render.canvasLib.loadImage(texturePath);
const size = 400;
const canvas = render.canvasLib.createCanvas(size, size);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, size, size);
const scale = Math.min((size - 24) / img.width, (size - 24) / img.height);
const w = Math.max(1, Math.round(img.width * scale));
const h = Math.max(1, Math.round(img.height * scale));
ctx.drawImage(img, Math.round((size - w) / 2), Math.round((size - h) / 2), w, h);
await render.drawSpineBadge(ctx, size, size);
if (assets.skeletonPath) require('../js/spine-badge.js').drawVersion(ctx, size, size, utils.detectVersion(assets.skeletonPath));
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return true;
} catch (e) {
log('atlas 贴图回退失败: ' + (e && e.message));
return false;
}
}
/** 读取 PNG 宽高(仅支持 PNG 头部) */
function imageSizeOf(file) {
const buf = fs.readFileSync(file);
if (buf.slice(0, 8).toString('hex') === '89504e470d0a1a0a') {
return { width: buf.readUInt32BE(16), height: buf.readUInt32BE(20) };
}
return { width: 0, height: 0 };
}
/** 生成一个纯 JS 占位 PNG(无需外部依赖),作为兜底 */
async function writePlaceholder(dest) {
const w = 400, h = 400;
if (render.canvasLib) {
try {
const canvas = render.canvasLib.createCanvas(w, h);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e22';
ctx.fillRect(0, 0, w, h);
await render.drawSpineBadge(ctx, w, h);
fs.writeFileSync(dest, canvas.toBuffer('image/png'));
return;
} catch (e) { /* fall through to pure JS encoder */ }
}
// 深色底
const pixels = Buffer.alloc(w * h * 4);
for (let y = 0; y < h; y++) {
for (let x = 0; x < w; x++) {
const i = (y * w + x) * 4;
pixels[i] = 30; pixels[i + 1] = 30; pixels[i + 2] = 34; pixels[i + 3] = 255;
}
}
// 简单的斜线纹理,让占位图不单调
for (let i = 0; i < w + h; i += 8) {
for (let k = 0; k < 3; k++) {
const x = Math.min(w - 1, i - k);
const y = Math.min(h - 1, k);
const idx = (y * w + x) * 4;
pixels[idx] = 60; pixels[idx + 1] = 62; pixels[idx + 2] = 68;
}
}
fs.writeFileSync(dest, encodePng(w, h, pixels));
}
/** 最小 PNG 编码器(纯 JS,使用 zlib deflate) */
function encodePng(width, height, rgba) {
const sig = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0);
ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; // bit depth
ihdr[9] = 6; // color type RGBA
ihdr[10] = 0; ihdr[11] = 0; ihdr[12] = 0;
const ihdrChunk = chunk('IHDR', ihdr);
const raw = Buffer.alloc((width * 4 + 1) * height);
for (let y = 0; y < height; y++) {
raw[y * (width * 4 + 1)] = 0; // filter type 0
rgba.copy(raw, y * (width * 4 + 1) + 1, y * width * 4, (y + 1) * width * 4);
}
const idat = zlib.deflateSync(raw);
const idatChunk = chunk('IDAT', idat);
const iend = chunk('IEND', Buffer.alloc(0));
return Buffer.concat([sig, ihdrChunk, idatChunk, iend]);
}
function chunk(type, data) {
const len = Buffer.alloc(4);
len.writeUInt32BE(data.length, 0);
const typeBuf = Buffer.from(type, 'ascii');
const crcBuf = Buffer.alloc(4);
crcBuf.writeUInt32BE(crc32(Buffer.concat([typeBuf, data])), 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function crc32(buf) {
let c, crc = 0xFFFFFFFF;
for (let i = 0; i < buf.length; i++) {
c = (crc ^ buf[i]) & 0xFF;
for (let k = 0; k < 8; k++) c = c & 1 ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
crc = (crc >>> 8) ^ c;
}
return (crc ^ 0xFFFFFFFF) >>> 0;
}
File diff suppressed because it is too large. Load diff
+1 -1
View File
@@ -1,4 +1,4 @@
# SpineViewer 1.1.0
# SpineViewer 1.1.1
Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、3.7、3.8、4.0、4.1、4.2、4.3。
@@ -19,8 +19,8 @@ const fileIndexCache = new Map();
const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉
const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i;
// Spine 导出常见的后缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
const SUFFIX_RE = /[-_](pro|ess|pma|base|atlas|data)$/i;
function extOf(name) {
const lower = name.toLowerCase();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"id": "ddf1c576-cfbc-4813-83a6-2e029cc08745",
"version": "1.1.0",
"version": "1.1.1",
"platform": "all",
"arch": "all",
"name": "{{manifest.app.name}}",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "spineviewer",
"version": "1.1.0",
"version": "1.1.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "spineviewer",
"version": "1.1.0",
"version": "1.1.1",
"license": "ISC",
"dependencies": {
"@napi-rs/canvas": "^0.1.66"
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "spineviewer",
"version": "1.1.0",
"version": "1.1.1",
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
"main": "thumbnail/spine.js",
"private": true,
Binary file not shown.
@@ -1,33 +0,0 @@
Source revision: ef5013143178918ddfc7cabfbddeae7aec7cf84e (official 3.4.02 tag)
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.4.02/spine-ts/build/spine-canvas.js
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.4.02/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
@@ -1,33 +0,0 @@
Source revision: 2cd9467d2736b7f3a0b81382f117c73823bfb44b (official 3.5.51 tag)
Runtime: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/spine-ts/build/spine-canvas.js
Binary parser source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.5.51/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
@@ -1,62 +0,0 @@
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.6/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes Software License v2.5
*
* Copyright (c) 2013-2016, Esoteric Software
* All rights reserved.
*
* You are granted a perpetual, non-exclusive, non-sublicensable, and
* non-transferable license to use, install, execute, and perform the Spine
* Runtimes software and derivative works solely for personal or internal
* use. Without the written permission of Esoteric Software (see Section 2 of
* the Spine Software License Agreement), you may not (a) modify, translate,
* adapt, or develop new applications using the Spine Runtimes or otherwise
* create derivative works or improvements of the Spine Runtimes or (b) remove,
* delete, alter, or obscure any trademarks or any copyright, trademark, patent,
* or other intellectual property or proprietary rights notices on or in the
* Software, including any copy thereof. Redistributions in binary or source
* form must include this license and terms.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE "AS IS" AND ANY EXPRESS OR
* IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF
* MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO
* EVENT SHALL ESOTERIC SOFTWARE BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
* SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
* PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS INTERRUPTION, OR LOSS OF
* USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER
* IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
* ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
* POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
Source: https://github.com/EsotericSoftware/spine-runtimes/blob/3.7/spine-libgdx/spine-libgdx/src/com/esotericsoftware/spine/SkeletonBinary.java
/******************************************************************************
* Spine Runtimes License Agreement
* Last updated May 1, 2019. Replaces all prior versions.
*
* Copyright (c) 2013-2019, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software
* or otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THIS SOFTWARE IS PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY EXPRESS
* OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES
* OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN
* NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY DIRECT, INDIRECT,
* INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES, BUSINESS
* INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND ON ANY
* THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
* NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE,
* EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
@@ -1,26 +0,0 @@
Spine Runtimes License Agreement
Last updated April 5, 2025. Replaces all prior versions.
Copyright (c) 2013-2025, Esoteric Software LLC
Integration of the Spine Runtimes into software or otherwise creating
derivative works of the Spine Runtimes is permitted under the terms and
conditions of Section 2 of the Spine Editor License Agreement:
https://esotericsoftware.com/spine-editor-license
Otherwise, it is permitted to integrate the Spine Runtimes into software
or otherwise create derivative works of the Spine Runtimes (collectively,
"Products"), provided that each user of the Products must obtain their own
Spine Editor license and redistribution of the Products in any form must
include this license and copyright notice.
THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
THE SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -1,21 +0,0 @@
MIT License
Copyright (c) 2020 lynweklm@gmail.com
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,12 +0,0 @@
# SpineViewer 1.1.0
Eagle 的离线 Spine 骨骼动画预览插件,支持 Spine 3.4、3.5、3.6、3.7、3.8、4.0、4.1、4.2、4.3。
## 功能与使用
1. 将同一套骨骼文件(`.skel` / `.skel.bytes` / Spine `.json`)、图集(`.atlas` / `.atlas.txt`)和配套贴图一起导入 Eagle,建议放在同一个 Eagle 文件夹内,并保留文件名配对关系。
2. 打开骨骼或图集文件即可预览。普通图片仍使用 Eagle 原生预览。
3. 支持动画和皮肤切换、播放/暂停(空格)、循环、速度、时间轴拖动、缩放适配、背景与预乘 Alpha(PMA)切换。
4. 自动生成带 Spine 标记和版本号的缩略图,也可在预览器中将当前帧设为缩略图。
第三方许可证见 `LICENSES/`,分发时已保留。
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine-Vorschau",
"skin": "Skin",
"play": "▶ Abspielen",
"pause": "⏸ Pause",
"static": "▮ Statisch",
"staticLabel": "Statisch",
"playPauseTitle": "Abspielen / Pause (Leertaste)",
"loop": "Schleife",
"speed": "Geschwindigkeit",
"pma": "Prämultipliziert",
"pmaTitle": "Für Texturen, die mit Premultiplied Alpha (PMA) exportiert wurden",
"background": "Hintergrund",
"backgroundTitle": "50×50-Schachbrett, das mit der Leinwand verschoben und skaliert wird und an Blend-Modi teilnimmt",
"thumbnail": "🖼 Vorschaubild",
"thumbnailTitle": "Aus dem aktuellen Frame ein 400×400-Vorschaubild erzeugen",
"versionTitle": "Spine-Version",
"reloadTitle": "Ordner neu laden",
"animationList": "Animationsliste",
"animations": "Animationen",
"errorTitle": "Vorschau nicht möglich",
"durationPlaceholder": "Dauer --",
"keyframesLegend": "Blaue Marken: Keyframes",
"keyframesLegendTitle": "Eine blaue Marke zeigt eine Zeit, zu der mindestens eine Bone-, Slot- oder Constraint-Eigenschaft einen Keyframe hat",
"keyframesHint": "Blaue Marken kennzeichnen Keyframes; zum Anzeigen von Zeit und Frame den Zeiger darüber halten",
"timePlaceholder": "0.00 / 0.00 s · 0 / 0 Frames",
"staticPoseTitle": "Statische Pose · Frame 1",
"keyframeTitle": "Keyframe · {time} · Frame {frame}",
"timelineStatic": "Statisch · 1 Frame",
"timelineDuration": "Dauer {time} · {frames} Frames",
"readoutStatic": "Statisch · 1 / 1 Frames",
"readout": "{time} / {total} · {frame} / {totalFrames} Frames",
"rulerStatic": "0 s (statisch)",
"seconds": "{value} s",
"imagePreview": "Bildvorschau (keine passenden Spine-Ressourcen gefunden)",
"generating": "Eagle-Vorschaubild wird erzeugt und aktualisiert…",
"thumbUpdated": "Eagle-Vorschaubild aktualisiert · {format} {size}KB",
"thumbWrittenNoRefresh": "Vorschaubild geschrieben, automatische Aktualisierung fehlgeschlagen: {error}",
"recentered": "Leinwand wieder zentriert",
"pmaOn": "Prämultipliziertes Alpha an",
"pmaOff": "Prämultipliziertes Alpha aus",
"pmaRemembered": "{state} · für dieses Asset gespeichert",
"pmaSaveFailed": "Umgeschaltet, aber Speichern der Einstellung fehlgeschlagen: {error}",
"badgeFailed": "Spine-Badge konnte nicht geladen werden",
"runtimeFailed": "Spine-Laufzeit kann nicht geladen werden: {file}",
"compoundName": "Eagle hat die zusammengesetzte Dateiendung noch nicht synchronisiert; Bibliothek erneut öffnen",
"txtRegistered": "Dieses Element ist noch als TXT registriert. Vorschaubild in Eagle neu erzeugen und Bibliothek erneut öffnen",
"apiHttp": "Eagle-API meldete HTTP {status}",
"noSkeletonForThumb": "Kein Skelett geladen; Vorschaubild kann nicht erzeugt werden",
"noItemId": "Eagle-Item-ID lässt sich aus dem Pfad nicht ermitteln",
"thumbnailFailed": "Vorschaubild konnte nicht erzeugt werden: {error}",
"imageLoadFailed": "Bild konnte nicht geladen werden",
"settingsNotReady": "Asset-Einstellungen sind noch nicht bereit",
"noSkeletonFile": "Skelettdatei nicht gefunden",
"noSkeletonFileMsg": "Zur ausgewählten Datei wurde keine passende Spine-Skelettdatei (.skel / .skel.bytes / .json) gefunden.\n\nBitte den kompletten Spine-Export in Eagle importieren.",
"noAtlas": "Atlas nicht gefunden",
"noAtlasMsg": "Die passende .atlas- / .atlas.txt-Datei wurde nicht gefunden.",
"unsupportedVersion": "Nicht unterstützte Version",
"unsupportedVersionMsg": "Unbekannte Spine-Version: {version}\nUnterstützte Versionen: {list}",
"unknownVersion": "unbekannt",
"runtimeLoadFailed": "Laufzeit konnte nicht geladen werden",
"atlasLoadFailed": "Atlas konnte nicht geladen werden",
"emptySkeleton": "Leere Skelettdaten",
"emptySkeletonMsg": "Es konnten keine gültigen Skelettdaten gelesen werden.",
"missingFile": "Datei fehlt",
"missingFileMsg": "Es wurde kein Dateipfad-Parameter übergeben.",
"fileNotExist": "Datei existiert nicht",
"loadFailed": "Laden fehlgeschlagen",
"notSpineText": "Diese Datei ist keine Spine-Ressource im Format .atlas.txt oder .skel.bytes.",
"truncated256": "\n\n(nur die ersten 256 KB werden angezeigt)",
"truncated1m": "\n\n(große Datei; nur das erste 1 MB wird angezeigt)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine Preview",
"skin": "Skin",
"play": "▶ Play",
"pause": "⏸ Pause",
"static": "▮ Still",
"staticLabel": "Still",
"playPauseTitle": "Play / pause (Space)",
"loop": "Loop",
"speed": "Speed",
"pma": "Premultiplied",
"pmaTitle": "For textures exported with Premultiplied Alpha (PMA)",
"background": "Background",
"backgroundTitle": "50×50 checkerboard that pans and zooms with the canvas and participates in blend modes",
"thumbnail": "🖼 Set thumbnail",
"thumbnailTitle": "Generate a 400×400 thumbnail from the current frame",
"versionTitle": "Spine version",
"reloadTitle": "Reload folder",
"animationList": "Animation list",
"animations": "Animations",
"errorTitle": "Cannot preview",
"durationPlaceholder": "Duration --",
"keyframesLegend": "Blue ticks: keyframes",
"keyframesLegendTitle": "A blue tick marks a time where at least one bone, slot or constraint property has a keyframe",
"keyframesHint": "Blue ticks mark animation keyframes; hover a tick to see its time and frame",
"timePlaceholder": "0.00 / 0.00 s · 0 / 0 frames",
"staticPoseTitle": "Still pose · frame 1",
"keyframeTitle": "Keyframe · {time} · frame {frame}",
"timelineStatic": "Still · 1 frame",
"timelineDuration": "Duration {time} · {frames} frames",
"readoutStatic": "Still · 1 / 1 frames",
"readout": "{time} / {total} · {frame} / {totalFrames} frames",
"rulerStatic": "0 s (still)",
"seconds": "{value} s",
"imagePreview": "Image preview (no matching Spine assets found)",
"generating": "Generating and updating the Eagle thumbnail…",
"thumbUpdated": "Eagle thumbnail updated · {format} {size}KB",
"thumbWrittenNoRefresh": "Thumbnail written, but auto-refresh failed: {error}",
"recentered": "Canvas re-centered",
"pmaOn": "Premultiplied alpha on",
"pmaOff": "Premultiplied alpha off",
"pmaRemembered": "{state} · saved for this asset",
"pmaSaveFailed": "Toggled, but saving the setting failed: {error}",
"badgeFailed": "Failed to load the Spine badge",
"runtimeFailed": "Cannot load the Spine runtime: {file}",
"compoundName": "Eagle has not synced the compound extension yet; please reopen the library",
"txtRegistered": "This item is still registered as TXT. Regenerate the thumbnail in Eagle and reopen the library",
"apiHttp": "Eagle API returned HTTP {status}",
"noSkeletonForThumb": "No skeleton loaded; cannot generate a thumbnail",
"noItemId": "Cannot determine the Eagle item ID from the asset path",
"thumbnailFailed": "Failed to generate the thumbnail: {error}",
"imageLoadFailed": "Failed to load the image",
"settingsNotReady": "Asset settings are not ready",
"noSkeletonFile": "Skeleton file not found",
"noSkeletonFileMsg": "Could not locate the Spine skeleton file (.skel / .skel.bytes / .json) that matches the selected file.\n\nPlease import the whole Spine export into Eagle.",
"noAtlas": "Atlas not found",
"noAtlasMsg": "Could not locate the matching .atlas / .atlas.txt file.",
"unsupportedVersion": "Unsupported version",
"unsupportedVersionMsg": "Unrecognized Spine version: {version}\nSupported versions: {list}",
"unknownVersion": "unknown",
"runtimeLoadFailed": "Failed to load the runtime",
"atlasLoadFailed": "Failed to load the atlas",
"emptySkeleton": "Empty skeleton data",
"emptySkeletonMsg": "No valid skeleton data could be read.",
"missingFile": "Missing file",
"missingFileMsg": "No file path parameter was provided.",
"fileNotExist": "File not found",
"loadFailed": "Failed to load",
"notSpineText": "This file is not a .atlas.txt or .skel.bytes Spine asset.",
"truncated256": "\n\n(only the first 256 KB is shown)",
"truncated1m": "\n\n(large file, only the first 1 MB is shown)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Vista previa de Spine",
"skin": "Piel",
"play": "▶ Reproducir",
"pause": "⏸ Pausa",
"static": "▮ Estático",
"staticLabel": "Estático",
"playPauseTitle": "Reproducir / pausar (espacio)",
"loop": "Bucle",
"speed": "Velocidad",
"pma": "Premultiplicado",
"pmaTitle": "Para texturas exportadas con Premultiplied Alpha (PMA)",
"background": "Fondo",
"backgroundTitle": "Fondo de cuadros de 50×50 que se desplaza y escala con el lienzo y participa en los modos de fusión",
"thumbnail": "🖼 Crear miniatura",
"thumbnailTitle": "Crear una miniatura de 400×400 con el fotograma actual",
"versionTitle": "Versión de Spine",
"reloadTitle": "Recargar carpeta",
"animationList": "Lista de animaciones",
"animations": "Animaciones",
"errorTitle": "No se puede previsualizar",
"durationPlaceholder": "Duración --",
"keyframesLegend": "Marcas azules: fotogramas clave",
"keyframesLegendTitle": "Una marca azul señala un instante en el que al menos una propiedad de hueso, ranura o restricción tiene un fotograma clave",
"keyframesHint": "Las marcas azules indican fotogramas clave; pasa el cursor para ver el tiempo y el fotograma",
"timePlaceholder": "0.00 / 0.00 s · 0 / 0 fotogramas",
"staticPoseTitle": "Pose estática · fotograma 1",
"keyframeTitle": "Fotograma clave · {time} · fotograma {frame}",
"timelineStatic": "Estático · 1 fotograma",
"timelineDuration": "Duración {time} · {frames} fotogramas",
"readoutStatic": "Estático · 1 / 1 fotogramas",
"readout": "{time} / {total} · {frame} / {totalFrames} fotogramas",
"rulerStatic": "0 s (estático)",
"seconds": "{value} s",
"imagePreview": "Vista previa de imagen (no se encontraron recursos Spine correspondientes)",
"generating": "Generando y actualizando la miniatura de Eagle…",
"thumbUpdated": "Miniatura de Eagle actualizada · {format} {size}KB",
"thumbWrittenNoRefresh": "Miniatura escrita, pero falló la actualización automática: {error}",
"recentered": "Lienzo centrado de nuevo",
"pmaOn": "Alfa premultiplicado activado",
"pmaOff": "Alfa premultiplicado desactivado",
"pmaRemembered": "{state} · guardado para este recurso",
"pmaSaveFailed": "Se cambió, pero no se pudo guardar la configuración: {error}",
"badgeFailed": "No se pudo cargar el distintivo de Spine",
"runtimeFailed": "No se puede cargar el entorno de ejecución de Spine: {file}",
"compoundName": "Eagle aún no ha sincronizado la extensión compuesta; vuelve a abrir la biblioteca",
"txtRegistered": "Este elemento sigue registrado como TXT. Vuelve a generar la miniatura en Eagle y reabre la biblioteca",
"apiHttp": "La API de Eagle devolvió HTTP {status}",
"noSkeletonForThumb": "No hay esqueleto cargado; no se puede generar la miniatura",
"noItemId": "No se puede determinar el ID del elemento de Eagle a partir de la ruta",
"thumbnailFailed": "No se pudo generar la miniatura: {error}",
"imageLoadFailed": "No se pudo cargar la imagen",
"settingsNotReady": "La configuración del recurso aún no está lista",
"noSkeletonFile": "No se encontró el archivo de esqueleto",
"noSkeletonFileMsg": "No se encontró el archivo de esqueleto de Spine (.skel / .skel.bytes / .json) correspondiente al archivo seleccionado.\n\nImporta el conjunto completo de exportación de Spine en Eagle.",
"noAtlas": "No se encontró el Atlas",
"noAtlasMsg": "No se encontró el archivo .atlas / .atlas.txt correspondiente.",
"unsupportedVersion": "Versión no compatible",
"unsupportedVersionMsg": "Versión de Spine no reconocida: {version}\nVersiones compatibles: {list}",
"unknownVersion": "desconocida",
"runtimeLoadFailed": "Error al cargar el entorno de ejecución",
"atlasLoadFailed": "Error al cargar el atlas",
"emptySkeleton": "Datos de esqueleto vacíos",
"emptySkeletonMsg": "No se pudieron leer datos de esqueleto válidos.",
"missingFile": "Falta el archivo",
"missingFileMsg": "No se proporcionó el parámetro de ruta del archivo.",
"fileNotExist": "El archivo no existe",
"loadFailed": "Error al cargar",
"notSpineText": "Este archivo no es un recurso Spine en formato .atlas.txt o .skel.bytes.",
"truncated256": "\n\n(solo se muestran los primeros 256 KB)",
"truncated1m": "\n\n(archivo grande; solo se muestra el primer 1 MB)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine プレビュー",
"skin": "スキン",
"play": "▶ 再生",
"pause": "⏸ 一時停止",
"static": "▮ 静止",
"staticLabel": "静止",
"playPauseTitle": "再生 / 一時停止(スペース)",
"loop": "ループ",
"speed": "速度",
"pma": "乗算済み",
"pmaTitle": "Premultiplied Alpha(PMA)で書き出したテクスチャ用",
"background": "背景",
"backgroundTitle": "50×50 のチェッカー背景。キャンバスの移動・拡大に追従し、ブレンドモードにも参加します",
"thumbnail": "🖼 サムネイル生成",
"thumbnailTitle": "現在のフレームから 400×400 のサムネイルを生成",
"versionTitle": "Spine バージョン",
"reloadTitle": "フォルダーを再読み込み",
"animationList": "アニメーション一覧",
"animations": "アニメーション",
"errorTitle": "プレビューできません",
"durationPlaceholder": "長さ --",
"keyframesLegend": "青い線:キーフレーム",
"keyframesLegendTitle": "青い線は、いずれかのボーン・スロット・コンストレイントのプロパティにキーフレームがある位置を示します",
"keyframesHint": "青い線はアニメーションのキーフレームです。線にカーソルを合わせると時間とフレームが表示されます",
"timePlaceholder": "0.00 / 0.00 秒 · 0 / 0 フレーム",
"staticPoseTitle": "静止ポーズ · 1 フレーム目",
"keyframeTitle": "キーフレーム · {time} · {frame} フレーム目",
"timelineStatic": "静止 · 1 フレーム",
"timelineDuration": "長さ {time} · {frames} フレーム",
"readoutStatic": "静止 · 1 / 1 フレーム",
"readout": "{time} / {total} · {frame} / {totalFrames} フレーム",
"rulerStatic": "0 秒(静止)",
"seconds": "{value} 秒",
"imagePreview": "画像プレビュー(対応する Spine リソースが見つかりません)",
"generating": "Eagle のサムネイルを生成・更新しています…",
"thumbUpdated": "Eagle のサムネイルを更新しました · {format} {size}KB",
"thumbWrittenNoRefresh": "サムネイルを書き込みましたが、自動更新に失敗しました:{error}",
"recentered": "キャンバスを中央に戻しました",
"pmaOn": "乗算済みアルファ:オン",
"pmaOff": "乗算済みアルファ:オフ",
"pmaRemembered": "{state} · この素材の設定として保存しました",
"pmaSaveFailed": "切り替えましたが、設定の保存に失敗しました:{error}",
"badgeFailed": "Spine バッジの読み込みに失敗しました",
"runtimeFailed": "Spine ランタイムを読み込めません:{file}",
"compoundName": "Eagle が複合拡張子を同期していません。ライブラリを開き直してください",
"txtRegistered": "この項目はまだ TXT として登録されています。Eagle でサムネイルを再生成し、ライブラリを開き直してください",
"apiHttp": "Eagle API が HTTP {status} を返しました",
"noSkeletonForThumb": "骨格が読み込まれていないため、サムネイルを生成できません",
"noItemId": "素材パスから Eagle の項目 ID を特定できません",
"thumbnailFailed": "サムネイルの生成に失敗しました:{error}",
"imageLoadFailed": "画像の読み込みに失敗しました",
"settingsNotReady": "素材の設定がまだ準備できていません",
"noSkeletonFile": "骨格ファイルが見つかりません",
"noSkeletonFileMsg": "選択したファイルに対応する Spine 骨格ファイル(.skel / .skel.bytes / .json)が見つかりませんでした。\n\n同じ Spine 書き出し一式を Eagle に読み込んでください。",
"noAtlas": "Atlas が見つかりません",
"noAtlasMsg": "対応する .atlas / .atlas.txt ファイルが見つかりませんでした。",
"unsupportedVersion": "未対応のバージョン",
"unsupportedVersionMsg": "認識できない Spine バージョン:{version}\n対応バージョン:{list}",
"unknownVersion": "不明",
"runtimeLoadFailed": "ランタイムの読み込みに失敗しました",
"atlasLoadFailed": "Atlas の読み込みに失敗しました",
"emptySkeleton": "骨格データが空です",
"emptySkeletonMsg": "有効な骨格データを読み取れませんでした。",
"missingFile": "ファイルがありません",
"missingFileMsg": "ファイルパスのパラメーターが指定されていません。",
"fileNotExist": "ファイルが存在しません",
"loadFailed": "読み込みに失敗しました",
"notSpineText": "このファイルは .atlas.txt または .skel.bytes 形式の Spine リソースではありません。",
"truncated256": "\n\n(先頭 256 KB のみ表示)",
"truncated1m": "\n\n(ファイルが大きいため、先頭 1 MB のみ表示)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine 미리보기",
"skin": "스킨",
"play": "▶ 재생",
"pause": "⏸ 일시정지",
"static": "▮ 정지",
"staticLabel": "정지",
"playPauseTitle": "재생 / 일시정지(스페이스)",
"loop": "반복",
"speed": "속도",
"pma": "미리 곱함",
"pmaTitle": "Premultiplied Alpha(PMA)로 내보낸 텍스처용",
"background": "배경",
"backgroundTitle": "50×50 체커 배경. 캔버스 이동·확대를 따라가며 블렌드 모드에도 참여합니다",
"thumbnail": "🖼 썸네일 생성",
"thumbnailTitle": "현재 프레임으로 400×400 썸네일 생성",
"versionTitle": "Spine 버전",
"reloadTitle": "폴더 다시 불러오기",
"animationList": "애니메이션 목록",
"animations": "애니메이션",
"errorTitle": "미리볼 수 없음",
"durationPlaceholder": "길이 --",
"keyframesLegend": "파란 선: 키프레임",
"keyframesLegendTitle": "파란 선은 하나 이상의 본·슬롯·컨스트레인트 속성에 키프레임이 있는 시점을 나타냅니다",
"keyframesHint": "파란 선은 애니메이션 키프레임입니다. 선에 마우스를 올리면 시간과 프레임이 표시됩니다",
"timePlaceholder": "0.00 / 0.00 초 · 0 / 0 프레임",
"staticPoseTitle": "정지 자세 · 1 프레임",
"keyframeTitle": "키프레임 · {time} · {frame} 프레임",
"timelineStatic": "정지 · 1 프레임",
"timelineDuration": "길이 {time} · {frames} 프레임",
"readoutStatic": "정지 · 1 / 1 프레임",
"readout": "{time} / {total} · {frame} / {totalFrames} 프레임",
"rulerStatic": "0초 (정지)",
"seconds": "{value}초",
"imagePreview": "이미지 미리보기 (함께 있는 Spine 리소스를 찾지 못했습니다)",
"generating": "Eagle 썸네일을 생성하고 갱신하는 중…",
"thumbUpdated": "Eagle 썸네일을 갱신했습니다 · {format} {size}KB",
"thumbWrittenNoRefresh": "썸네일을 저장했지만 자동 새로고침에 실패했습니다: {error}",
"recentered": "캔버스를 가운데로 되돌렸습니다",
"pmaOn": "미리 곱한 알파 켜짐",
"pmaOff": "미리 곱한 알파 꺼짐",
"pmaRemembered": "{state} · 이 에셋의 설정으로 저장했습니다",
"pmaSaveFailed": "전환했지만 설정 저장에 실패했습니다: {error}",
"badgeFailed": "Spine 배지를 불러오지 못했습니다",
"runtimeFailed": "Spine 런타임을 불러올 수 없습니다: {file}",
"compoundName": "Eagle이 복합 확장자를 아직 동기화하지 않았습니다. 라이브러리를 다시 열어 주세요",
"txtRegistered": "이 항목은 아직 TXT로 등록되어 있습니다. Eagle에서 썸네일을 다시 생성하고 라이브러리를 다시 열어 주세요",
"apiHttp": "Eagle API가 HTTP {status}를 반환했습니다",
"noSkeletonForThumb": "스켈레톤이 로드되지 않아 썸네일을 생성할 수 없습니다",
"noItemId": "에셋 경로에서 Eagle 항목 ID를 찾을 수 없습니다",
"thumbnailFailed": "썸네일 생성 실패: {error}",
"imageLoadFailed": "이미지를 불러오지 못했습니다",
"settingsNotReady": "에셋 설정이 아직 준비되지 않았습니다",
"noSkeletonFile": "스켈레톤 파일을 찾을 수 없음",
"noSkeletonFileMsg": "선택한 파일과 짝을 이루는 Spine 스켈레톤 파일(.skel / .skel.bytes / .json)을 찾지 못했습니다.\n\n같은 Spine 내보내기 세트를 Eagle에 함께 가져와 주세요.",
"noAtlas": "Atlas를 찾을 수 없음",
"noAtlasMsg": "짝을 이루는 .atlas / .atlas.txt 파일을 찾지 못했습니다.",
"unsupportedVersion": "지원하지 않는 버전",
"unsupportedVersionMsg": "인식할 수 없는 Spine 버전: {version}\n지원 버전: {list}",
"unknownVersion": "알 수 없음",
"runtimeLoadFailed": "런타임 로드 실패",
"atlasLoadFailed": "Atlas 로드 실패",
"emptySkeleton": "스켈레톤 데이터가 비어 있음",
"emptySkeletonMsg": "유효한 스켈레톤 데이터를 읽지 못했습니다.",
"missingFile": "파일 없음",
"missingFileMsg": "파일 경로 매개변수가 전달되지 않았습니다.",
"fileNotExist": "파일이 존재하지 않음",
"loadFailed": "불러오기 실패",
"notSpineText": "이 파일은 .atlas.txt 또는 .skel.bytes 형식의 Spine 리소스가 아닙니다.",
"truncated256": "\n\n(앞부분 256 KB만 표시)",
"truncated1m": "\n\n(파일이 커서 앞부분 1 MB만 표시)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Просмотр Spine",
"skin": "Скин",
"play": "▶ Воспроизвести",
"pause": "⏸ Пауза",
"static": "▮ Статично",
"staticLabel": "Статично",
"playPauseTitle": "Воспроизведение / пауза (пробел)",
"loop": "Повтор",
"speed": "Скорость",
"pma": "Премультипликация",
"pmaTitle": "Для текстур, экспортированных с Premultiplied Alpha (PMA)",
"background": "Фон",
"backgroundTitle": "Шахматный фон 50×50: следует за сдвигом и масштабом холста и участвует в режимах смешивания",
"thumbnail": "🖼 Создать миниатюру",
"thumbnailTitle": "Создать миниатюру 400×400 из текущего кадра",
"versionTitle": "Версия Spine",
"reloadTitle": "Перезагрузить папку",
"animationList": "Список анимаций",
"animations": "Анимации",
"errorTitle": "Просмотр невозможен",
"durationPlaceholder": "Длительность --",
"keyframesLegend": "Синие метки: ключевые кадры",
"keyframesLegendTitle": "Синяя метка отмечает момент, в котором есть ключевой кадр хотя бы у одного свойства кости, слота или ограничения",
"keyframesHint": "Синие метки обозначают ключевые кадры; наведите курсор, чтобы увидеть время и кадр",
"timePlaceholder": "0.00 / 0.00 с · 0 / 0 кадров",
"staticPoseTitle": "Статичная поза · кадр 1",
"keyframeTitle": "Ключевой кадр · {time} · кадр {frame}",
"timelineStatic": "Статично · 1 кадр",
"timelineDuration": "Длительность {time} · {frames} кадров",
"readoutStatic": "Статично · 1 / 1 кадров",
"readout": "{time} / {total} · {frame} / {totalFrames} кадров",
"rulerStatic": "0 с (статично)",
"seconds": "{value} с",
"imagePreview": "Просмотр изображения (подходящие ресурсы Spine не найдены)",
"generating": "Создание и обновление миниатюры Eagle…",
"thumbUpdated": "Миниатюра Eagle обновлена · {format} {size}KB",
"thumbWrittenNoRefresh": "Миниатюра записана, но автообновление не удалось: {error}",
"recentered": "Холст возвращён в центр",
"pmaOn": "Премультипликация включена",
"pmaOff": "Премультипликация выключена",
"pmaRemembered": "{state} · сохранено для этого ресурса",
"pmaSaveFailed": "Переключено, но сохранить настройку не удалось: {error}",
"badgeFailed": "Не удалось загрузить значок Spine",
"runtimeFailed": "Не удалось загрузить среду выполнения Spine: {file}",
"compoundName": "Eagle ещё не синхронизировал составное расширение. Откройте библиотеку заново",
"txtRegistered": "Этот элемент всё ещё зарегистрирован как TXT. Пересоздайте миниатюру в Eagle и откройте библиотеку заново",
"apiHttp": "Eagle API вернул HTTP {status}",
"noSkeletonForThumb": "Скелет не загружен — невозможно создать миниатюру",
"noItemId": "Не удалось определить ID элемента Eagle по пути к ресурсу",
"thumbnailFailed": "Не удалось создать миниатюру: {error}",
"imageLoadFailed": "Не удалось загрузить изображение",
"settingsNotReady": "Настройки ресурса ещё не готовы",
"noSkeletonFile": "Файл скелета не найден",
"noSkeletonFileMsg": "Не удалось найти файл скелета Spine (.skel / .skel.bytes / .json), соответствующий выбранному файлу.\n\nИмпортируйте весь набор экспорта Spine в Eagle.",
"noAtlas": "Atlas не найден",
"noAtlasMsg": "Не удалось найти соответствующий файл .atlas / .atlas.txt.",
"unsupportedVersion": "Неподдерживаемая версия",
"unsupportedVersionMsg": "Неизвестная версия Spine: {version}\nПоддерживаемые версии: {list}",
"unknownVersion": "неизвестно",
"runtimeLoadFailed": "Не удалось загрузить среду выполнения",
"atlasLoadFailed": "Не удалось загрузить atlas",
"emptySkeleton": "Пустые данные скелета",
"emptySkeletonMsg": "Не удалось прочитать корректные данные скелета.",
"missingFile": "Файл отсутствует",
"missingFileMsg": "Параметр пути к файлу не передан.",
"fileNotExist": "Файл не существует",
"loadFailed": "Не удалось загрузить",
"notSpineText": "Этот файл не является ресурсом Spine в формате .atlas.txt или .skel.bytes.",
"truncated256": "\n\n(показаны только первые 256 КБ)",
"truncated1m": "\n\n(файл большой, показан только первый 1 МБ)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine 预览",
"skin": "皮肤",
"play": "▶ 播放",
"pause": "⏸ 暂停",
"static": "▮ 静帧",
"staticLabel": "静帧",
"playPauseTitle": "播放/暂停 (空格)",
"loop": "循环",
"speed": "速度",
"pma": "预乘",
"pmaTitle": "用于以 Premultiplied Alpha(PMA)方式导出的贴图",
"background": "背景",
"backgroundTitle": "50×50 方格背景,随画布移动和缩放,并参与混合模式",
"thumbnail": "🖼 生成缩略图",
"thumbnailTitle": "使用当前帧生成 400×400 缩略图",
"versionTitle": "Spine 版本",
"reloadTitle": "重新加载目录",
"animationList": "动画列表",
"animations": "动画",
"errorTitle": "无法预览",
"durationPlaceholder": "时长 --",
"keyframesLegend": "蓝色短线:关键帧",
"keyframesLegendTitle": "蓝色短线表示动画中至少一个骨骼、插槽或约束属性在该时刻有关键帧",
"keyframesHint": "蓝色短线表示动画关键帧;悬停短线可查看时间和帧号",
"timePlaceholder": "0.00 / 0.00 秒 · 0 / 0 帧",
"staticPoseTitle": "静帧姿势 · 第 1 帧",
"keyframeTitle": "关键帧 · {time} · 第 {frame} 帧",
"timelineStatic": "静帧 · 1 帧",
"timelineDuration": "时长 {time} · {frames} 帧",
"readoutStatic": "静帧 · 1 / 1 帧",
"readout": "{time} / {total} · {frame} / {totalFrames} 帧",
"rulerStatic": "0 秒(静帧)",
"seconds": "{value} 秒",
"imagePreview": "图片预览(未检测到配套 Spine 资源)",
"generating": "正在生成并更新 Eagle 缩略图…",
"thumbUpdated": "Eagle 缩略图已更新 · {format} {size}KB",
"thumbWrittenNoRefresh": "缩略图已写入;自动刷新失败:{error}",
"recentered": "已回到画布中心",
"pmaOn": "预乘已开启",
"pmaOff": "预乘已关闭",
"pmaRemembered": "{state} · 已记住此素材的设置",
"pmaSaveFailed": "预乘已切换,但设置保存失败:{error}",
"badgeFailed": "Spine badge 加载失败",
"runtimeFailed": "无法加载 Spine 运行时:{file}",
"compoundName": "Eagle 尚未同步复合后缀名称,请重新打开素材库",
"txtRegistered": "此条目仍按 TXT 登记,请在 Eagle 重新生成缩略图并重新打开素材库",
"apiHttp": "Eagle API 返回 HTTP {status}",
"noSkeletonForThumb": "尚未加载骨骼,无法生成缩略图",
"noItemId": "无法从素材路径识别 Eagle 项目 ID",
"thumbnailFailed": "生成缩略图失败: {error}",
"imageLoadFailed": "图片加载失败",
"settingsNotReady": "素材设置尚未就绪",
"noSkeletonFile": "未找到骨骼文件",
"noSkeletonFileMsg": "未能定位到与所选文件配套的 Spine 骨骼文件(.skel / .skel.bytes / .json)。\n\n请将同一套 Spine 导出文件导入 Eagle。",
"noAtlas": "未找到 Atlas",
"noAtlasMsg": "未能定位到配套的 .atlas / .atlas.txt 文件。",
"unsupportedVersion": "版本不支持",
"unsupportedVersionMsg": "无法识别的 Spine 版本:{version}\n支持的版本:{list}",
"unknownVersion": "未知",
"runtimeLoadFailed": "运行时加载失败",
"atlasLoadFailed": "Atlas 加载失败",
"emptySkeleton": "骨骼数据为空",
"emptySkeletonMsg": "未能读取到有效的骨骼数据。",
"missingFile": "缺少文件",
"missingFileMsg": "未传入文件路径参数。",
"fileNotExist": "文件不存在",
"loadFailed": "加载失败",
"notSpineText": "此文件不是 .atlas.txt 或 .skel.bytes 格式的 Spine 资源。",
"truncated256": "\n\n(仅显示前 256 KB)",
"truncated1m": "\n\n(文件较大,仅显示前 1 MB)"
}
}
@@ -1,79 +0,0 @@
{
"manifest": {
"app": {
"name": "SpineViewer"
}
},
"viewer": {
"title": "Spine 預覽",
"skin": "外觀",
"play": "▶ 播放",
"pause": "⏸ 暫停",
"static": "▮ 靜態",
"staticLabel": "靜態",
"playPauseTitle": "播放/暫停(空白鍵)",
"loop": "循環",
"speed": "速度",
"pma": "預乘",
"pmaTitle": "用於以 Premultiplied Alpha(PMA)方式匯出的貼圖",
"background": "背景",
"backgroundTitle": "50×50 方格背景,隨畫布移動和縮放,並參與混合模式",
"thumbnail": "🖼 產生縮圖",
"thumbnailTitle": "使用目前影格產生 400×400 縮圖",
"versionTitle": "Spine 版本",
"reloadTitle": "重新載入目錄",
"animationList": "動畫清單",
"animations": "動畫",
"errorTitle": "無法預覽",
"durationPlaceholder": "時長 --",
"keyframesLegend": "藍色短線:關鍵影格",
"keyframesLegendTitle": "藍色短線表示動畫中至少一個骨骼、插槽或約束屬性在該時刻有關鍵影格",
"keyframesHint": "藍色短線表示動畫關鍵影格;將游標停在短線上可查看時間與影格",
"timePlaceholder": "0.00 / 0.00 秒 · 0 / 0 影格",
"staticPoseTitle": "靜態姿勢 · 第 1 影格",
"keyframeTitle": "關鍵影格 · {time} · 第 {frame} 影格",
"timelineStatic": "靜態 · 1 影格",
"timelineDuration": "時長 {time} · {frames} 影格",
"readoutStatic": "靜態 · 1 / 1 影格",
"readout": "{time} / {total} · {frame} / {totalFrames} 影格",
"rulerStatic": "0 秒(靜態)",
"seconds": "{value} 秒",
"imagePreview": "圖片預覽(未偵測到配套的 Spine 資源)",
"generating": "正在產生並更新 Eagle 縮圖…",
"thumbUpdated": "Eagle 縮圖已更新 · {format} {size}KB",
"thumbWrittenNoRefresh": "縮圖已寫入;自動重新整理失敗:{error}",
"recentered": "已回到畫布中心",
"pmaOn": "預乘已開啟",
"pmaOff": "預乘已關閉",
"pmaRemembered": "{state} · 已記住此素材的設定",
"pmaSaveFailed": "預乘已切換,但設定儲存失敗:{error}",
"badgeFailed": "Spine badge 載入失敗",
"runtimeFailed": "無法載入 Spine 執行環境:{file}",
"compoundName": "Eagle 尚未同步複合副檔名,請重新開啟素材庫",
"txtRegistered": "此項目仍以 TXT 登錄,請在 Eagle 重新產生縮圖並重新開啟素材庫",
"apiHttp": "Eagle API 回傳 HTTP {status}",
"noSkeletonForThumb": "尚未載入骨骼,無法產生縮圖",
"noItemId": "無法從素材路徑辨識 Eagle 項目 ID",
"thumbnailFailed": "產生縮圖失敗:{error}",
"imageLoadFailed": "圖片載入失敗",
"settingsNotReady": "素材設定尚未就緒",
"noSkeletonFile": "找不到骨骼檔案",
"noSkeletonFileMsg": "找不到與所選檔案搭配的 Spine 骨骼檔案(.skel / .skel.bytes / .json)。\n\n請將同一套 Spine 匯出檔案匯入 Eagle。",
"noAtlas": "找不到 Atlas",
"noAtlasMsg": "找不到搭配的 .atlas / .atlas.txt 檔案。",
"unsupportedVersion": "不支援的版本",
"unsupportedVersionMsg": "無法辨識的 Spine 版本:{version}\n支援的版本:{list}",
"unknownVersion": "未知",
"runtimeLoadFailed": "執行環境載入失敗",
"atlasLoadFailed": "Atlas 載入失敗",
"emptySkeleton": "骨骼資料為空",
"emptySkeletonMsg": "無法讀取到有效的骨骼資料。",
"missingFile": "缺少檔案",
"missingFileMsg": "未傳入檔案路徑參數。",
"fileNotExist": "檔案不存在",
"loadFailed": "載入失敗",
"notSpineText": "此檔案不是 .atlas.txt 或 .skel.bytes 格式的 Spine 資源。",
"truncated256": "\n\n(僅顯示前 256 KB)",
"truncated1m": "\n\n(檔案較大,僅顯示前 1 MB)"
}
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

@@ -1,17 +0,0 @@
'use strict';
const path = require('path');
// Preserve name + '.' + ext exactly; only split the compound suffix correctly.
// Return through Eagle's thumbnail callback so Eagle persists its own metadata.
function normalizeItem(src, item) {
const filename = path.basename(src);
const match = filename.match(/^(.*)\.(atlas\.txt|skel\.bytes)$/i);
if (!match || !item || !item.ext) return false;
if (item.name + '.' + item.ext !== filename) return false;
if (item.name === match[1] && item.ext === match[2]) return false;
item.name = match[1]; item.ext = match[2];
delete item.text;
return true;
}
module.exports = { normalizeItem };
@@ -1,25 +0,0 @@
'use strict';
const patterns = new WeakMap();
// Called with the same world-to-canvas transform used to draw the skeleton.
function drawCheckerboard(ctx, width, height) {
let pattern = patterns.get(ctx);
if (!pattern) {
const tile = ctx.canvas.ownerDocument.createElement('canvas');
tile.width = tile.height = 100;
const paint = tile.getContext('2d');
paint.fillStyle = '#505052'; paint.fillRect(0, 0, 100, 100);
paint.fillStyle = '#5a5a5c'; paint.fillRect(0, 0, 50, 50); paint.fillRect(50, 50, 50, 50);
pattern = ctx.createPattern(tile, 'repeat'); patterns.set(ctx, pattern);
}
const m = ctx.getTransform().inverse();
const corners = [[0,0], [width,0], [width,height], [0,height]].map(([x,y]) => [m.a*x+m.c*y+m.e, m.b*x+m.d*y+m.f]);
const xs = corners.map(p => p[0]), ys = corners.map(p => p[1]);
const x = Math.min(...xs), y = Math.min(...ys);
if (!Number.isFinite(x + y)) return;
ctx.save();
try { ctx.imageSmoothingEnabled = false; ctx.fillStyle = pattern; ctx.fillRect(x, y, Math.max(...xs)-x, Math.max(...ys)-y); }
finally { ctx.restore(); }
}
module.exports = { drawCheckerboard };
@@ -1,31 +0,0 @@
const fs = require('fs');
const path = require('path');
const badgePath = path.join(__dirname, '..', 'assets', 'spine-badge.png');
let cachedDataUrl = null;
function dataUrl() {
if (!cachedDataUrl) {
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgePath).toString('base64');
}
return cachedDataUrl;
}
function drawVersion(ctx, width, height, version) {
version = String(version || '');
if (version.length > 40 || !/^\d+\.\d+(?:\.\d+)?(?:[-+][a-z0-9][a-z0-9.-]*)?$/i.test(version)) return;
const fontSize = Math.max(12, Math.round(Math.min(width, height) * 0.055));
const margin = Math.max(10, Math.round(Math.min(width, height) * 0.03));
ctx.save();
try {
ctx.font = '600 ' + fontSize + 'px sans-serif';
ctx.textAlign = 'left'; ctx.textBaseline = 'middle';
const boxHeight = fontSize + 12;
const y = height - margin - boxHeight;
ctx.fillStyle = 'rgba(0,0,0,0.65)';
ctx.fillRect(margin, y, ctx.measureText(version).width + 16, boxHeight);
ctx.fillStyle = '#ffffff'; ctx.fillText(version, margin + 8, y + boxHeight / 2);
} finally { ctx.restore(); }
}
module.exports = { path: badgePath, dataUrl, drawVersion };
@@ -1,243 +0,0 @@
'use strict';
// The official spine-canvas SkeletonRenderer does not process clipping
// attachments. Keep its triangle rasterizer, but feed it geometry produced by
// SkeletonClipping so the viewer matches Spine's draw order and clipping rules.
const QUAD_TRIANGLES = [0, 1, 2, 2, 3, 0];
const BLEND_MODES = ['source-over', 'lighter', 'multiply', 'screen'];
const unpremultipliedImages = new WeakMap();
function rendererConstructor(spine) {
return (spine.canvas && spine.canvas.SkeletonRenderer) || spine.SkeletonRenderer;
}
function imageFromAttachment(attachment) {
const region = attachment && attachment.region;
// 4.0 stores the texture on the atlas page, while the other bundled
// runtimes expose it directly on the attachment region.
const renderRegion = region && region.renderObject;
const texture = region && (region.texture
|| (region.page && region.page.texture)
|| (renderRegion && renderRegion.texture)
|| (renderRegion && renderRegion.page && renderRegion.page.texture));
return texture && typeof texture.getImage === 'function' ? texture.getImage() : null;
}
// Canvas 2D always applies source-alpha blending. A Spine texture exported
// with PMA already has RGB multiplied by alpha, so drawing it directly would
// multiply translucent edge pixels a second time. Convert it once to straight
// alpha and cache the result; Canvas can then blend it correctly.
function imageForCanvas(image, context, premultipliedAlpha) {
if (!premultipliedAlpha || !image || (typeof image !== 'object' && typeof image !== 'function')) return image;
const cached = unpremultipliedImages.get(image);
if (cached) return cached;
try {
const ownerDocument = context.canvas && context.canvas.ownerDocument;
const doc = ownerDocument || (typeof document !== 'undefined' ? document : null);
if (!image.width || !image.height) return image;
let canvas;
if (doc) canvas = doc.createElement('canvas');
else {
try { canvas = require('@napi-rs/canvas').createCanvas(image.width, image.height); }
catch (e) { return image; }
}
canvas.width = image.width;
canvas.height = image.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(image, 0, 0);
const pixels = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = pixels.data;
for (let i = 0; i < data.length; i += 4) {
const alpha = data[i + 3];
if (alpha > 0 && alpha < 255) {
const factor = 255 / alpha;
data[i] = Math.min(255, Math.round(data[i] * factor));
data[i + 1] = Math.min(255, Math.round(data[i + 1] * factor));
data[i + 2] = Math.min(255, Math.round(data[i + 2] * factor));
}
}
ctx.putImageData(pixels, 0, 0);
unpremultipliedImages.set(image, canvas);
return canvas;
} catch (e) {
// If pixel access is unavailable, retain the original texture so preview
// remains usable instead of failing the whole frame.
return image;
}
}
function unpackVertices(vertices, count) {
const positions = new Array(count * 2);
const uvs = new Array(count * 2);
for (let i = 0, p = 0; i < count; i++, p += 2) {
const source = i * 8;
positions[p] = vertices[source];
positions[p + 1] = vertices[source + 1];
uvs[p] = vertices[source + 6];
uvs[p + 1] = vertices[source + 7];
}
return { positions, uvs };
}
function drawInterleaved(base, image, vertices, triangles, darkColor) {
for (let i = 0; i < triangles.length; i += 3) {
const a = triangles[i] * 8;
const b = triangles[i + 1] * 8;
const c = triangles[i + 2] * 8;
base.drawTriangle(image,
vertices[a], vertices[a + 1], vertices[a + 6], vertices[a + 7],
vertices[b], vertices[b + 1], vertices[b + 6], vertices[b + 7],
vertices[c], vertices[c + 1], vertices[c + 6], vertices[c + 7],
[...vertices.slice(a+2,a+6), ...vertices.slice(b+2,b+6), ...vertices.slice(c+2,c+6)],
darkColor ? Array.from({length:3}, () => [darkColor.r,darkColor.g,darkColor.b,0]).flat() : undefined);
}
}
function createLegacyRenderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonClipping) throw new Error('Spine Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const clipper = new spine.SkeletonClipping();
const dark = new spine.Color(0, 0, 0, 0);
base.draw = function drawWithClipping(skeleton) {
const composite = context.globalCompositeOperation;
const drawOrder = skeleton.drawOrder;
const pma = !!base.premultipliedAlpha;
try {
for (let i = 0; i < drawOrder.length; i++) {
const slot = drawOrder[i];
if (slot.bone && slot.bone.active === false) {
clipper.clipEndWithSlot(slot);
continue;
}
const attachment = slot.getAttachment();
if (attachment instanceof spine.ClippingAttachment) {
clipper.clipStart(slot, attachment);
continue;
}
try {
if (!attachment) continue;
let vertices;
let triangles;
let vertexCount;
if (attachment instanceof spine.RegionAttachment) {
vertices = base.computeRegionVertices(slot, attachment, false);
triangles = Renderer.QUAD_TRIANGLES || QUAD_TRIANGLES;
vertexCount = 4;
} else if (attachment instanceof spine.MeshAttachment) {
vertices = base.computeMeshVertices(slot, attachment, false);
triangles = attachment.triangles;
vertexCount = attachment.worldVerticesLength >> 1;
} else {
continue;
}
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = base.tempColor.a;
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
if (clipper.isClipping()) {
const unpacked = unpackVertices(vertices, vertexCount);
clipper.clipTriangles(unpacked.positions, unpacked.positions.length,
triangles, triangles.length, unpacked.uvs, base.tempColor, dark, false);
drawInterleaved(base, image, clipper.clippedVertices, clipper.clippedTriangles, slot.darkColor);
} else {
drawInterleaved(base, image, vertices, triangles, slot.darkColor);
}
} finally {
clipper.clipEndWithSlot(slot);
}
}
} finally {
clipper.clipEnd();
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = clipper;
return base;
}
// 3.4/3.5 predate clipping attachments and compute packed vertices on attachments.
function create34Renderer(spine, context) {
const base = new (rendererConstructor(spine))(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
base.draw = skeleton => {
const composite = context.globalCompositeOperation;
try {
for (const slot of skeleton.drawOrder) {
const attachment = slot.getAttachment();
if (!(attachment instanceof spine.RegionAttachment) && !(attachment instanceof spine.MeshAttachment)) continue;
const vertices = attachment.updateWorldVertices(slot, false);
const sourceImage = imageFromAttachment(attachment);
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, base.premultipliedAlpha);
if (!image) continue;
context.globalAlpha = vertices[5];
context.globalCompositeOperation = BLEND_MODES[slot.data.blendMode] || 'source-over';
drawInterleaved(base, image, vertices, attachment instanceof spine.MeshAttachment ? attachment.triangles : QUAD_TRIANGLES);
}
} finally { context.globalAlpha = 1; context.globalCompositeOperation = composite; }
};
return base;
}
function create43Renderer(spine, context) {
const Renderer = rendererConstructor(spine);
if (!Renderer || !spine.SkeletonRendererCore) throw new Error('Spine 4.3 Canvas 渲染器不完整');
const base = new Renderer(context);
base.triangleRendering = true;
base.premultipliedAlpha = false;
const core = new spine.SkeletonRendererCore();
base.draw = function drawWithCoreClipping(skeleton) {
const composite = context.globalCompositeOperation;
try {
const pma = !!base.premultipliedAlpha;
for (let command = core.render(skeleton, false); command; command = command.next) {
const sourceImage = command.texture && command.texture.getImage && command.texture.getImage();
const image = base.gpuDrawing ? sourceImage : imageForCanvas(sourceImage, context, pma);
if (!image) continue;
context.globalAlpha = ((command.colors[0] >>> 24) & 255) / 255;
context.globalCompositeOperation = BLEND_MODES[command.blendMode] || 'source-over';
const positions = command.positions;
const uvs = command.uvs;
const indices = command.indices;
for (let i = 0; i < command.numIndices; i += 3) {
const a = indices[i] * 2;
const b = indices[i + 1] * 2;
const c = indices[i + 2] * 2;
const unpack = packed => [((packed>>>16)&255)/255,((packed>>>8)&255)/255,(packed&255)/255,((packed>>>24)&255)/255];
const colors = [a,b,c].flatMap(index => unpack(command.colors[index/2]));
const darkColors = [a,b,c].flatMap(index => unpack(command.darkColors[index/2]));
base.drawTriangle(image,
positions[a], positions[a + 1], uvs[a], uvs[a + 1],
positions[b], positions[b + 1], uvs[b], uvs[b + 1],
positions[c], positions[c + 1], uvs[c], uvs[c + 1], colors, darkColors);
}
}
} finally {
context.globalAlpha = 1;
context.globalCompositeOperation = composite;
}
};
base.clipper = core.clipping;
return base;
}
function createRenderer(spine, context, runtimeDir) {
const renderer = (runtimeDir === '3.4' || runtimeDir === '3.5') ? create34Renderer(spine, context) : runtimeDir === '4.3'
? create43Renderer(spine, context)
: createLegacyRenderer(spine, context);
// Explicitly disable debug drawing in every supported runtime.
renderer.debugRendering = false;
return require('./spine-webgl-rasterizer.js').attach(renderer, context);
}
module.exports = { createRenderer };
@@ -1,250 +0,0 @@
'use strict';
// Adapted from Esoteric Software's 3.4/3.5/3.6/3.7 spine-libgdx SkeletonBinary.java.
// Copyright (c) 2013-2019, Esoteric Software. See
// LICENSES/Spine-Legacy-Binary-License.txt for the original license notices.
// 3.4 source and license: LICENSES/Spine-3.4-License.txt.
// 3.5 source and license: LICENSES/Spine-3.5-License.txt.
// Decode the versioned wire format to an in-memory SkeletonJson object. The
// matching runtime builds attachments/timelines and applies loader.scale once.
const dict = () => Object.create(null);
class Input {
constructor(bytes) {
if (!ArrayBuffer.isView(bytes)) throw new Error('骨骼二进制数据必须为字节数组');
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
this.offset = 0;
}
fail(message) { throw new Error('Spine 二进制解析失败(字节 ' + this.offset + '):' + message); }
need(n) { if (n < 0 || n > this.view.byteLength - this.offset) this.fail('文件截断或长度无效'); }
byte() { this.need(1); return this.view.getUint8(this.offset++); }
bool() { const b = this.byte(); if (b > 1) this.fail('无效布尔值'); return b === 1; }
float() { this.need(4); const v = this.view.getFloat32(this.offset); this.offset += 4; if (!Number.isFinite(v)) this.fail('无效浮点数'); return v; }
int() { this.need(4); const v = this.view.getInt32(this.offset); this.offset += 4; return v; }
varint(positive = true) {
let value = 0;
for (let shift = 0; shift <= 28; shift += 7) {
const b = this.byte();
if (shift === 28 && (b & 240)) this.fail('无效变长整数');
value |= (b & 127) << shift;
if (!(b & 128)) return positive ? value : ((value >>> 1) ^ -(value & 1));
}
this.fail('无效变长整数');
}
count() { const n = this.varint(); this.need(n); return n; }
string() {
const size = this.varint();
if (size === 0) return null;
this.need(size - 1);
const end = this.offset + size - 1;
let result = '';
// Old Spine exports use Java's 1/2/3-byte UTF-8 encoding of UTF-16
// code units (including surrogate pairs), rather than string references.
while (this.offset < end) {
const b = this.byte();
if (b < 128) result += String.fromCharCode(b);
else {
const extra = (b & 224) === 192 ? 1 : (b & 240) === 224 ? 2 : -1;
if (extra < 0 || this.offset + extra > end) this.fail('无效字符串');
let code = b & (extra === 1 ? 31 : 15);
for (let i = 0; i < extra; i++) { const c = this.byte(); if ((c & 192) !== 128) this.fail('无效 UTF-8'); code = (code << 6) | (c & 63); }
result += String.fromCharCode(code);
}
}
return result;
}
color() { return (this.int() >>> 0).toString(16).padStart(8, '0'); }
floats(n) { this.need(n * 4); return Array.from({ length: n }, () => this.float()); }
shorts() { const n = this.count(); this.need(n * 2); return Array.from({ length: n }, () => { const v = this.view.getUint16(this.offset); this.offset += 2; return v; }); }
ref(list) { const i = this.varint(); if (i < 0 || i >= list.length) this.fail('索引越界:' + i); return list[i]; }
}
function decode(bytes, runtimeDir) {
const r = new Input(bytes);
const skeleton = { hash: r.string(), spine: r.string() };
if (!/^3\.[4567]\./.test(skeleton.spine || '')) r.fail('仅支持 Spine 3.4 / 3.5 / 3.6 / 3.7');
const version = skeleton.spine.slice(0, 3);
if (runtimeDir && version !== runtimeDir) r.fail('骨骼版本与运行时不一致');
const v37 = version === '3.7';
const v34 = version === '3.4';
const before36 = v34 || version === '3.5';
skeleton.width = r.float(); skeleton.height = r.float();
const nonessential = r.bool();
if (nonessential) { if (!v34) skeleton.fps = r.float(); skeleton.images = r.string(); if (v37) skeleton.audio = r.string(); }
const root = { skeleton, bones: [], slots: [], ik: [], transform: [], path: [], skins: dict(), events: dict(), animations: dict() };
const boneName = () => r.ref(root.bones).name;
const slotName = () => r.ref(root.slots).name;
const fields = (obj, names) => { for (const name of names.split(' ')) obj[name] = r.float(); return obj; };
const boneList = () => Array.from({ length: r.count() }, boneName);
for (let i = 0, n = r.count(); i < n; i++) {
const b = { name: r.string() }; if (i) b.parent = boneName();
fields(b, 'rotation x y scaleX scaleY shearX shearY length');
if (v34) { b.inheritRotation = r.bool(); b.inheritScale = r.bool(); }
else b.transform = r.ref(['normal', 'onlyTranslation', 'noRotationOrReflection', 'noScale', 'noScaleOrReflection']);
if (nonessential) b.color = r.color(); root.bones.push(b);
}
for (let i = 0, n = r.count(); i < n; i++) {
const s = { name: r.string(), bone: boneName(), color: r.color() };
if (!before36) { const dark = r.color(); if (dark !== 'ffffffff') s.dark = dark.slice(2); }
s.attachment = r.string(); s.blend = r.ref(['normal', 'additive', 'multiply', 'screen']); root.slots.push(s);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName(), mix: r.float(), bendPositive: r.byte() === 1 };
if (v37) { c.compress = r.bool(); c.stretch = r.bool(); c.uniform = r.bool(); } root.ik.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: boneName() };
if (!before36) { c.local = r.bool(); c.relative = r.bool(); }
fields(c, 'rotation x y scaleX scaleY shearY rotateMix translateMix scaleMix shearMix'); root.transform.push(c);
}
for (let i = 0, n = r.count(); i < n; i++) {
const c = { name: r.string(), order: v34 ? 0 : r.varint(), bones: boneList(), target: slotName(),
positionMode: r.ref(['fixed', 'percent']), spacingMode: r.ref(['length', 'fixed', 'percent']), rotateMode: r.ref(['tangent', 'chain', 'chainScale']) };
fields(c, 'rotation position spacing rotateMix translateMix'); root.path.push(c);
}
function vertices(n) {
if (!r.bool()) return r.floats(n * 2);
const out = [];
for (let i = 0; i < n; i++) {
const count = r.count(); if (!count) r.fail('加权顶点没有骨骼'); out.push(count);
for (let j = 0; j < count; j++) { const index = r.varint(); if (index < 0 || index >= root.bones.length) r.fail('顶点骨骼索引越界'); out.push(index, r.float(), r.float(), r.float()); }
}
return out;
}
function attachment(key) {
const a = { name: r.string() || key };
const type = r.byte();
if (before36 && type > 4) r.fail(version + ' 不支持此附件类型:' + type);
a.type = ['region', 'boundingbox', 'mesh', 'linkedmesh', 'path', 'point', 'clipping'][type];
if (!a.type) r.fail('未知附件类型:' + type);
if (type === 0) {
a.path = r.string() || a.name; fields(a, 'rotation x y scaleX scaleY width height'); a.color = r.color();
} else if (type === 2) {
a.path = r.string() || a.name; a.color = r.color(); const count = r.count();
a.uvs = r.floats(count * 2); a.triangles = r.shorts();
if (a.triangles.some(i => i >= count)) r.fail('网格三角形索引越界');
a.vertices = vertices(count); a.hull = r.varint();
if (nonessential) { a.edges = r.shorts(); fields(a, 'width height'); }
} else if (type === 3) {
a.path = r.string() || a.name; a.color = r.color(); const skin = r.string(); if (skin !== null) a.skin = skin;
a.parent = r.string(); a.deform = r.bool(); if (nonessential) fields(a, 'width height');
} else if (type === 5) {
fields(a, 'rotation x y'); if (nonessential) a.color = r.color();
} else {
if (type === 4) { a.closed = r.bool(); a.constantSpeed = r.bool(); }
if (type === 6) a.end = slotName();
a.vertexCount = r.count(); a.vertices = vertices(a.vertexCount);
if (type === 4) a.lengths = r.floats(Math.floor(a.vertexCount / 3));
if (nonessential) a.color = r.color();
}
return a;
}
const skinNames = [];
function skin(name, isDefault = false) {
const count = r.count(); if (!count && isDefault) return;
const data = root.skins[name] = dict(); skinNames.push(name);
for (let i = 0; i < count; i++) {
const attachments = data[slotName()] = dict();
for (let j = 0, n = r.count(); j < n; j++) { const key = r.string(); attachments[key] = attachment(key); }
}
}
skin('default', true);
for (let i = 0, n = r.count(); i < n; i++) skin(r.string());
const eventNames = [];
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.string(); eventNames.push(name);
const e = root.events[name] = { int: r.varint(false), float: r.float(), string: r.string() };
if (v37) { e.audio = r.string(); if (e.audio !== null) fields(e, 'volume balance'); }
}
function curve(frame) {
const type = r.byte();
if (type === 1) frame.curve = 'stepped';
else if (type === 2) frame.curve = r.floats(4);
else if (type !== 0) r.fail('未知曲线类型:' + type);
}
function frames(read, curved = true) {
const count = r.count(); if (!count) r.fail('空时间线');
return Array.from({ length: count }, (_, i) => { const frame = { time: r.float() }; read(frame); if (curved && i < count - 1) curve(frame); return frame; });
}
function animation() {
const a = { slots: dict(), bones: dict(), ik: dict(), transform: dict(), paths: dict(), deform: dict() };
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.slots[slotName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte();
if (type === 0) timelines.attachment = frames(f => { f.name = r.string(); }, false);
else if (type === 1) timelines.color = frames(f => { f.color = r.color(); });
else if (type === 2 && !before36) timelines.twoColor = frames(f => { f.light = r.color(); f.dark = r.color().slice(2); });
else r.fail('未知插槽时间线');
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.bones[boneName()] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['rotate', 'translate', 'scale', 'shear'][type];
if (!name) r.fail('未知骨骼时间线');
timelines[name] = frames(f => fields(f, type === 0 ? 'angle' : 'x y'));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.ik).name;
a.ik[name] = frames(f => { f.mix = r.float(); f.bendPositive = r.byte() === 1; if (v37) { f.compress = r.bool(); f.stretch = r.bool(); } });
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(root.transform).name; a.transform[name] = frames(f => fields(f, 'rotateMix translateMix scaleMix shearMix'));
}
for (let i = 0, n = r.count(); i < n; i++) {
const timelines = a.paths[r.ref(root.path).name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const type = r.byte(), name = ['position', 'spacing', 'mix'][type]; if (!name) r.fail('未知路径时间线');
timelines[name] = frames(f => fields(f, type === 2 ? 'rotateMix translateMix' : name));
}
}
for (let i = 0, n = r.count(); i < n; i++) {
const name = r.ref(skinNames), skinDeform = a.deform[name] = dict();
for (let j = 0, m = r.count(); j < m; j++) {
const slot = slotName(), slotDeform = skinDeform[slot] = dict();
for (let k = 0, q = r.count(); k < q; k++) {
const key = r.string();
if (!root.skins[name][slot] || !root.skins[name][slot][key]) r.fail('变形附件不存在');
slotDeform[key] = frames(f => { const length = r.count(); if (length) { f.offset = r.varint(); if (f.offset < 0 || f.offset > bytes.byteLength) r.fail('变形偏移越界'); f.vertices = r.floats(length); } });
}
}
}
const drawCount = r.count();
if (drawCount) a.drawOrder = Array.from({ length: drawCount }, () => {
const f = { time: r.float(), offsets: [] }; let previous = -1;
for (let i = 0, n = r.count(); i < n; i++) {
const index = r.varint(); const offset = r.varint();
if (index <= previous || index >= root.slots.length || index + offset < 0 || index + offset >= root.slots.length) r.fail('绘制顺序索引越界');
previous = index; f.offsets.push({ slot: root.slots[index].name, offset });
}
return f;
});
const eventCount = r.count();
if (eventCount) a.events = Array.from({ length: eventCount }, () => {
const e = { time: r.float(), name: r.ref(eventNames), int: r.varint(false), float: r.float() };
const data = root.events[e.name]; e.string = r.bool() ? r.string() : data.string;
if (v37 && data.audio !== null) fields(e, 'volume balance'); return e;
});
return a;
}
for (let i = 0, n = r.count(); i < n; i++) { const name = r.string(); root.animations[name] = animation(); }
if (r.offset !== r.view.byteLength) r.fail('文件含未识别的尾部数据');
return root;
}
function install(spine, runtimeDir) {
if (!['3.4', '3.5', '3.6', '3.7'].includes(runtimeDir)) return spine;
if (runtimeDir === '3.4') spine.AtlasAttachmentLoader = spine.TextureAtlasAttachmentLoader;
spine.SkeletonBinary = class SkeletonBinary {
constructor(attachmentLoader) { this.attachmentLoader = attachmentLoader; this.scale = 1; }
readSkeletonData(bytes) {
const loader = new spine.SkeletonJson(this.attachmentLoader); loader.scale = this.scale;
return loader.readSkeletonData(decode(bytes, runtimeDir));
}
};
return spine;
}
module.exports = { install, decode };
@@ -1,58 +0,0 @@
'use strict';
// Eagle's item API has no arbitrary custom fields. Keep plugin metadata in
// userData, keyed by library + item ID, without editing Eagle metadata.json.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
function identity(sourcePath, itemId) {
const directory = path.dirname(path.resolve(sourcePath));
const match = path.basename(directory).match(/^(.+)\.info$/i);
return match
? path.dirname(directory) + ':' + (itemId || match[1])
: path.resolve(sourcePath);
}
function createStore(userDataPath, sourcePath, itemId) {
const key = crypto.createHash('sha256').update(identity(sourcePath, itemId)).digest('hex');
const directory = path.join(userDataPath, 'SpineViewer', 'preview-settings');
const file = path.join(directory, key + '.json');
return {
file,
read() {
try {
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
return typeof data.premultipliedAlpha === 'boolean' ? data.premultipliedAlpha : undefined;
} catch (e) {
if (e.code === 'ENOENT') return undefined;
throw e;
}
},
// This method is called only by the user's checkbox change event.
write(premultipliedAlpha) {
if (typeof premultipliedAlpha !== 'boolean') throw new TypeError('预乘设置必须为布尔值');
fs.mkdirSync(directory, { recursive: true });
const temp = file + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
try {
fs.writeFileSync(temp, JSON.stringify({ premultipliedAlpha }, null, 2) + '\n', { flag: 'wx' });
fs.renameSync(temp, file);
} finally {
try { fs.unlinkSync(temp); } catch (e) { if (e.code !== 'ENOENT') throw e; }
}
}
};
}
async function resolveDataPath(app) {
try {
const value = app && (app.userDataPath || (typeof app.getPath === 'function' && await app.getPath('userData')));
if (typeof value === 'string' && value) return value;
} catch (e) { /* Some Eagle format-view windows reject app.getPath IPC. */ }
const home = require('os').homedir();
if (process.platform === 'darwin') return path.join(home, 'Library', 'Application Support', 'Eagle');
if (process.platform === 'win32') return path.join(process.env.APPDATA || path.join(home, 'AppData', 'Roaming'), 'Eagle');
return path.join(process.env.XDG_CONFIG_HOME || path.join(home, '.config'), 'Eagle');
}
module.exports = { createStore, resolveDataPath };
@@ -1,85 +0,0 @@
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
const crypto = require('crypto');
const { spawn } = require('child_process');
const utils = require('./spine-utils');
const sleep = ms => new Promise(resolve => setTimeout(resolve, ms));
function enqueue(src) {
const library = utils.findLibraryRoot(src);
if (!library) return; // Standalone tests/previews do not need Eagle refresh.
const key = crypto.createHash('sha256').update(__dirname + library).digest('hex');
const directory = path.join(os.tmpdir(), 'spineviewer-reconcile', key);
fs.mkdirSync(directory, { recursive: true });
const job = path.join(directory, crypto.createHash('sha256').update(src).digest('hex') + '.json');
const temp = job + '.' + crypto.randomBytes(8).toString('hex') + '.tmp';
fs.writeFileSync(temp, JSON.stringify({ src, deadline: Date.now() + 120000 }));
fs.renameSync(temp, job);
// Electron's Node mode runs the local worker without opening another Eagle.
const child = spawn(process.execPath, [__filename, directory], {
detached: true, stdio: 'ignore', env: { ...process.env, ELECTRON_RUN_AS_NODE: '1' }
});
child.on('error', error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
});
child.unref();
}
async function run(directory) {
const lock = path.join(directory, 'worker.lock');
let fd;
try { fd = fs.openSync(lock, 'wx'); }
catch (error) {
if (error.code !== 'EEXIST') throw error;
let pid;
try { pid = Number(fs.readFileSync(lock, 'utf8')); } catch (_) { return; }
if (!pid) return; // Another worker is still writing its lock.
try { process.kill(pid, 0); return; } catch (e) { if (e.code !== 'ESRCH') return; }
try { fs.unlinkSync(lock); } catch (_) { return; }
return run(directory);
}
fs.writeFileSync(fd, String(process.pid)); fs.closeSync(fd);
const snapshots = new Map();
const nextChecks = new Map();
const thumbnail = require('../thumbnail/spine');
try {
let idle = 0;
while (idle < 2) {
await sleep(1000); // Allow the importing callback/window to finish.
const jobs = fs.readdirSync(directory).filter(name => name.endsWith('.json'));
if (!jobs.length) { idle++; continue; }
idle = 0;
for (const name of jobs) {
const file = path.join(directory, name);
let raw;
try {
raw = fs.readFileSync(file, 'utf8');
const job = JSON.parse(raw);
if (Date.now() < (nextChecks.get(name) || 0)) continue;
const remaining = job.deadline - Date.now();
nextChecks.set(name, Date.now() + (remaining > 110000 ? 1000 : remaining > 80000 ? 2500 : 5000));
const result = Date.now() > job.deadline ? { complete: true }
: await thumbnail.reconcileOnce(job.src, snapshots.get(name));
snapshots.set(name, result.signature);
if (result.complete && fs.readFileSync(file, 'utf8') === raw) {
fs.unlinkSync(file); snapshots.delete(name); nextChecks.delete(name);
}
} catch (error) {
if (error.code !== 'ENOENT') fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n');
}
}
}
} finally {
fs.unlinkSync(lock);
// Cover an enqueue racing the final idle check and lock release.
if (fs.readdirSync(directory).some(name => name.endsWith('.json'))) await run(directory);
}
}
module.exports = { enqueue, run };
if (require.main === module) run(process.argv[2]).catch(error => {
try { fs.appendFileSync(path.join(__dirname, '../thumbnails.log'), '[worker] ' + String(error) + '\n'); } catch (_) {}
process.exitCode = 1;
});
@@ -1,217 +0,0 @@
/**
* SpineViewer - 缩略图渲染模块(仅 Node 环境使用)
* 在纯 Node 中加载本地 Spine 运行时(通过 vm 注入到 global.spine),
* 使用 @napi-rs/canvas 光栅化骨骼的 setup pose / 首帧动画,输出 PNG buffer。
*/
const fs = require('fs');
const vm = require('vm');
const path = require('path');
const utils = require('./spine-utils.js');
const spineBadge = require('./spine-badge.js');
let canvasLib = null;
try {
canvasLib = require('@napi-rs/canvas');
} catch (e) {
canvasLib = null;
}
let spineBadgePromise = null;
/** 在缩略图右下角绘制官方 Spine badge,仅等比缩放,不改变官方图形。 */
async function drawSpineBadge(ctx, width, height) {
if (!canvasLib || !ctx) return;
try {
if (!spineBadgePromise) {
spineBadgePromise = canvasLib.loadImage(spineBadge.path);
}
const badge = await spineBadgePromise;
const badgeSize = Math.max(44, Math.round(Math.min(width, height) * 0.15));
const margin = Math.max(10, Math.round(badgeSize * 0.2));
ctx.drawImage(badge, width - badgeSize - margin, height - badgeSize - margin, badgeSize, badgeSize);
} catch (e) { /* 缺少 badge 时不影响缩略图主体 */ }
}
/**
* 在隔离的 vm 上下文中加载本地 Spine 运行时,返回 spine 命名空间对象。
* 使用独立上下文可避免不同版本运行时的全局变量/类相互污染(例如 4.x 与 3.x
* 的 BinaryInput、DataView 等),因此不会跨版本混用。
*/
function loadRuntime(dir) {
const code = fs.readFileSync(path.join(__dirname, '..', 'lib', dir, 'spine-canvas.js'), 'utf8');
const sandbox = {
console,
setTimeout,
clearTimeout,
setInterval,
clearInterval,
performance: { now: () => Date.now() },
};
// 部分运行时通过 window/self/globalThis 访问全局
sandbox.window = sandbox;
sandbox.self = sandbox;
sandbox.globalThis = sandbox;
vm.createContext(sandbox);
vm.runInContext(code, sandbox, { filename: 'lib/' + dir + '/spine-canvas.js' });
return require('./spine-legacy-binary.js').install(sandbox.spine, dir);
}
/** 构造 TextureAtlas 并挂载贴图(跨版本兼容)。
* texturePaths: { atlas 中引用的贴图名 -> 绝对路径 },用于跨 Eagle item 文件夹解析贴图。
*/
function buildTextureAtlas(spine, atlasText, dir, texturePaths) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
return new Promise((resolve, reject) => {
(async () => {
// 解析所有页面贴图名,并预先异步加载
let pageNames = [];
try { pageNames = utils.parseAtlasPages(atlasText); } catch (e) { pageNames = []; }
const textureMap = {};
await Promise.all(pageNames.map(async (name) => {
// 优先使用跨文件夹解析出的绝对路径,否则回退到 atlas 同目录
const p = (texturePaths && texturePaths[name]) ? texturePaths[name] : path.join(dir, name);
if (!fs.existsSync(p)) return;
try {
const img = await canvasLib.loadImage(p);
textureMap[name] = img;
} catch (e) { /* ignore */ }
}));
const makeTexture = (name) => {
let img = textureMap[name];
if (!img) {
// 找不到图片时用 2x2 灰色占位纹理
const c = canvasLib.createCanvas(2, 2);
const cx = c.getContext('2d');
cx.fillStyle = '#888888';
cx.fillRect(0, 0, 2, 2);
img = c;
}
if (spine.CanvasTexture) return new spine.CanvasTexture(img);
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(img);
return new spine.Texture(img);
};
let atlas;
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
if (is4x) {
atlas = new spine.TextureAtlas(atlasText);
for (const page of atlas.pages) {
const tex = makeTexture(page.name);
if (page.setTexture) page.setTexture(tex);
else page.texture = tex;
}
} else {
atlas = new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
}
resolve(atlas);
})().catch(reject);
});
}
/** 读取骨骼数据(json 或二进制) */
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
if (isBinary) {
const loader = new spine.SkeletonBinary(atlasLoader);
loader.scale = 1;
const buf = fs.readFileSync(skeletonPath);
return loader.readSkeletonData(new Uint8Array(buf));
} else {
const loader = new spine.SkeletonJson(atlasLoader);
loader.scale = 1;
const text = fs.readFileSync(skeletonPath, 'utf8');
return loader.readSkeletonData(text);
}
}
/** 计算包围盒(跨版本兼容) */
function getBounds(spine, skeleton) {
try {
if (skeleton.getBoundsRect) {
const r = skeleton.getBoundsRect();
if (r && r.width !== undefined) return { x: r.x, y: r.y, width: r.width, height: r.height };
}
if (skeleton.getBounds) {
const offset = new spine.Vector2();
const size = new spine.Vector2();
skeleton.getBounds(offset, size, []);
if (size.x > 0 || size.y > 0) return { x: offset.x, y: offset.y, width: size.x, height: size.y };
}
} catch (e) { /* ignore */ }
return null;
}
/**
* 渲染首帧动画到 PNG buffer。
* @returns {Promise<Buffer>}
*/
async function renderThumbnail({ skeletonPath, atlasPath, texturePaths, version, runtimeDir, size = 400 }) {
if (!canvasLib) throw new Error('@napi-rs/canvas 不可用');
const isBinary = /\.skel(\.bytes)?$/i.test(skeletonPath);
const dir = path.dirname(skeletonPath);
const atlasText = fs.readFileSync(atlasPath, 'utf8');
const premultipliedAlpha = utils.parseAtlasPma(atlasText) === true;
const spine = loadRuntime(runtimeDir);
const atlas = await buildTextureAtlas(spine, atlasText, dir, texturePaths);
const skeletonData = readSkeletonData(spine, skeletonPath, atlas, isBinary);
if (!skeletonData || !skeletonData.bones || skeletonData.bones.length === 0) {
const name = path.basename(skeletonPath);
throw new Error('骨骼数据为空:' + name);
}
const skeleton = new spine.Skeleton(skeletonData);
utils.applySkin(skeleton, utils.preferredSkin(skeletonData));
// 计算所有动画的包围盒,取最合适的姿态用于缩略图。
// 默认使用 setup pose(编辑器内的标准站姿),保证缩略图稳定、接近编辑视图。
let bounds = null;
if (skeleton.setToSetupPose) skeleton.setToSetupPose();
if (skeleton.updateWorldTransform) {
if (spine.Physics && spine.Physics.update !== undefined) skeleton.updateWorldTransform(spine.Physics.update);
else skeleton.updateWorldTransform();
}
bounds = getBounds(spine, skeleton);
const cw = size, ch = size;
const canvas = canvasLib.createCanvas(cw, ch);
const ctx = canvas.getContext('2d');
ctx.fillStyle = '#1e1e1e';
ctx.fillRect(0, 0, cw, ch);
if (bounds && bounds.width > 0 && bounds.height > 0) {
const pad = 0.1;
const scale = Math.min((cw * (1 - pad * 2)) / bounds.width, (ch * (1 - pad * 2)) / bounds.height);
// Spine 世界坐标 Y 轴向上,而 Canvas 2D Y 轴向下,需翻转 Y。
const cx = bounds.x + bounds.width / 2;
const cy = bounds.y + bounds.height / 2;
ctx.save();
ctx.translate(cw / 2, ch / 2);
ctx.scale(scale, -scale);
ctx.translate(-cx, -cy);
let renderer = null;
try { renderer = require('./spine-canvas-clipping.js').createRenderer(spine, ctx, runtimeDir); }
catch (e) {
if (spine.canvas && spine.canvas.SkeletonRenderer) renderer = new spine.canvas.SkeletonRenderer(ctx);
else if (spine.SkeletonRenderer) renderer = new spine.SkeletonRenderer(ctx);
}
if (renderer) {
// 使用三角渲染,正确处理 mesh 附件(如角色身体的网格)
if ('triangleRendering' in renderer) renderer.triangleRendering = true;
renderer.premultipliedAlpha = premultipliedAlpha;
renderer.draw(skeleton);
if (renderer.dispose) renderer.dispose();
}
ctx.restore();
}
await drawSpineBadge(ctx, cw, ch);
spineBadge.drawVersion(ctx, cw, ch, skeletonData.version || version);
const buf = canvas.toBuffer('image/png');
return buf;
}
module.exports = { renderThumbnail, loadRuntime, buildTextureAtlas, readSkeletonData, getBounds, drawSpineBadge, canvasLib };
@@ -1,714 +0,0 @@
/**
* SpineViewer - 工具模块
* 该模块为纯 Node.js CommonJS,供 viewer/thumbnail 复用。
* 职责:
* - 识别 Spine 导出文件的类型(skel/skel.bytes/json/atlas/atlas.txt/png/jpg/jpeg/webp)
* - 在同一个目录内自动关联配套的骨骼文件、atlas、贴图
* - 从骨骼数据中检测 Spine 版本
* - 根据版本选择合适的本地运行时
*/
const fs = require('fs');
const path = require('path');
const SKELETON_EXTS = ['.skel', '.skel.bytes', '.json'];
const ATLAS_EXTS = ['.atlas', '.atlas.txt'];
const TEXTURE_EXTS = ['.png', '.jpg', '.jpeg', '.webp'];
const ALL_EXTS = SKELETON_EXTS.concat(ATLAS_EXTS, TEXTURE_EXTS);
const fileIndexCache = new Map();
const skeletonJsonCache = new Map();
const JSON_PROBE_BYTES = 256 * 1024;
// Spine 导出常见的后缀,匹配时归一化掉
const SUFFIX_RE = /-(pro|ess|pma|base|atlas|_pma|_pro|_ess)$/i;
function extOf(name) {
const lower = name.toLowerCase();
for (let i = 0; i < SKELETON_EXTS.length; i++) {
if (lower.endsWith(SKELETON_EXTS[i])) return SKELETON_EXTS[i];
}
for (let i = 0; i < ATLAS_EXTS.length; i++) {
if (lower.endsWith(ATLAS_EXTS[i])) return ATLAS_EXTS[i];
}
for (let i = 0; i < TEXTURE_EXTS.length; i++) {
if (lower.endsWith(TEXTURE_EXTS[i])) return TEXTURE_EXTS[i];
}
return null;
}
function isSkeletonFile(name) {
const ext = extOf(name);
return ext === '.skel' || ext === '.skel.bytes' || ext === '.json';
}
function isAtlasFile(name) {
const ext = extOf(name);
return ext === '.atlas' || ext === '.atlas.txt';
}
function isTextureFile(name) {
const ext = extOf(name);
return TEXTURE_EXTS.indexOf(ext) !== -1;
}
/** 去掉文件名中的扩展名 */
function stemOf(name) {
const ext = extOf(name);
if (!ext) return name;
return name.slice(0, name.length - ext.length);
}
/** 归一化 stem,去掉 Spine 常见后缀,用于跨文件匹配 */
function normalizeStem(name) {
return stemOf(name).toLowerCase().replace(SUFFIX_RE, '');
}
/** 从 JSON 文本中读取 spine 版本 */
function detectVersionFromJson(text) {
try {
const json = JSON.parse(text);
if (json && json.skeleton && json.skeleton.spine) {
return String(json.skeleton.spine);
}
} catch (e) { /* ignore */ }
return null;
}
/** 从二进制数据中读取 spine 版本(扫描前若干字节中的版本号) */
function detectVersionFromBuffer(buf) {
let s = '';
const bytes = new Uint8Array(buf);
const len = Math.min(bytes.length, 160);
for (let i = 0; i < len; i++) {
s += String.fromCharCode(bytes[i]);
}
const m = s.match(/(\d+\.\d+(?:\.\d+)?)/);
return m ? m[1] : null;
}
/** 从文件读取 spine 版本 */
function detectVersion(filePath) {
const lower = filePath.toLowerCase();
if (lower.endsWith('.json')) {
return detectVersionFromJson(fs.readFileSync(filePath, 'utf8'));
}
return detectVersionFromBuffer(fs.readFileSync(filePath));
}
/**
* 根据检测到的版本与文件类型,选择本地运行时目录。
* - 3.4–3.7 二进制通过本地兼容解析器加载,始终使用对应版本运行时
*/
function chooseRuntimeDir(version, isBinary) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
if (major === 3) {
const minor = parseFloat(version);
if (minor === 3.4) return '3.4';
if (minor === 3.5) return '3.5';
if (minor <= 3.6) return '3.6';
if (minor <= 3.7 + 0.0001) return '3.7';
return '3.8';
} else if (major === 4) {
const minor = parseFloat(version);
if (minor <= 4.0 + 0.0001) return '4.0';
if (minor <= 4.1 + 0.0001) return '4.1';
if (minor <= 4.2 + 0.0001) return '4.2';
return '4.3';
}
return null;
}
/**
* 快速区分 Spine JSON、普通 JSON 和仍在写入的 JSON。
* 仅以 Spine 官方导出的 `skeleton.spine` 版本字段作为肯定标识;bones/slots 等
* 通用字段不能证明文件来自 Spine。大文件只探测前 256KB。
*/
function classifySkeletonJson(filePath) {
try {
const stat = fs.statSync(filePath);
if (!stat.isFile() || stat.size === 0) return 'pending';
const signature = stat.size + ':' + Math.round(stat.mtimeMs);
const cached = skeletonJsonCache.get(filePath);
if (cached && cached.signature === signature) return cached.kind;
const length = Math.min(stat.size, JSON_PROBE_BYTES);
const fd = fs.openSync(filePath, 'r');
const buffer = Buffer.allocUnsafe(length);
try { fs.readSync(fd, buffer, 0, length, 0); } finally { fs.closeSync(fd); }
const text = buffer.toString('utf8');
const hasSkeletonKey = /"skeleton"\s*:/i.test(text);
const versionMatch = text.match(/"skeleton"\s*:\s*\{[\s\S]{0,8192}?"spine"\s*:\s*"(\d+\.\d+(?:\.\d+)?(?:[^"\\]|\\.)*)"/i);
let kind;
if (versionMatch) {
kind = 'spine';
} else if (stat.size > JSON_PROBE_BYTES) {
kind = 'other';
} else {
try {
JSON.parse(text);
kind = 'other';
} catch (e) {
// 只有已经出现 skeleton 键但文件尚未完整写入时才继续短暂等待。
// JSONC、配置文件或损坏的普通 JSON 不应进入 Spine 补刷流程。
kind = hasSkeletonKey ? 'pending' : 'other';
}
}
if (skeletonJsonCache.size > 2048) skeletonJsonCache.clear();
skeletonJsonCache.set(filePath, { signature, kind });
return kind;
} catch (e) {
return 'pending';
}
}
function looksLikeSkeletonJson(filePath) {
return classifySkeletonJson(filePath) === 'spine';
}
/** 文件修改时间(毫秒) */
function mtimeOf(fp) {
try { return fs.statSync(fp).mtimeMs; } catch (e) { return 0; }
}
/** 读取某 item 元数据里的创建时间 btime(毫秒),用于按“同一批导入”配对 */
function btimeOf(fp) {
const d = itemMeta(path.dirname(fp));
return (d && d.btime) || 0;
}
/**
* Eagle item 的实际入库时间。
* metadata.json 的 birthtime 在生成缩略图、修改标签后也不会改变,比素材自身的
* btime/mtime 更适合判断多个独立 .info 项是否来自同一次拖入。
*/
function importTimeOf(fp) {
try {
const metaPath = path.join(path.dirname(fp), 'metadata.json');
const stat = fs.statSync(metaPath);
return stat.birthtimeMs || stat.ctimeMs || stat.mtimeMs || 0;
} catch (e) {
return btimeOf(fp);
}
}
/** 骨骼版本所属家族(3x / 4x),atlas 格式按版本家族区分 */
function versionFamily(version) {
if (!version) return null;
const major = parseInt(version.split('.')[0], 10);
return major <= 3 ? '3x' : '4x';
}
/** 根据 atlas 文本判断其格式家族(3x / 4x) */
function atlasFamily(atlasText) {
if (!atlasText) return null;
// 4.x atlas 用 `bounds:` 定义区域;3.x 用 `xy:`/`size:`/`orig:`/`offset:`。
// 注意:`scale:` 在 3.x 也会出现(如字体缩放),不能单独作为 4x 依据。
// 因此以 `bounds:` 作为最可靠的 4.x 标志;若整体含 `xy:` 判定为 3x。
if (/\bbounds\s*:/.test(atlasText)) return '4x';
if (/\bxy\s*:/.test(atlasText)) return '3x';
return '3x';
}
/** 读取某 item 文件夹的 metadata.json(含 folders、isDeleted、btime 等) */
function itemMeta(itemDir) {
if (!itemDir) return null;
try {
return JSON.parse(fs.readFileSync(path.join(itemDir, 'metadata.json'), 'utf8'));
} catch (e) {
return null;
}
}
/** 读取某个资产文件所属 item 文件夹的 Eagle 逻辑文件夹 id 列表 */
function foldersOfItem(filePath) {
const d = itemMeta(path.dirname(filePath));
return d && Array.isArray(d.folders) ? d.folders : [];
}
/** 判断某 item 是否已被放入 Eagle 回收站(isDeleted) */
function isItemDeleted(filePath) {
const d = itemMeta(path.dirname(filePath));
return !!(d && d.isDeleted);
}
/** 从任意 item 路径向上定位 Eagle 库根目录 */
function findLibraryRoot(filePath) {
let dir = path.dirname(filePath); // item 文件夹
for (let i = 0; i < 8; i++) {
const parent = path.dirname(dir);
if (parent === dir) break;
try {
if (fs.existsSync(path.join(parent, 'metadata.json')) && fs.existsSync(path.join(parent, 'images'))) {
return parent;
}
} catch (e) { /* ignore */ }
dir = parent;
}
return null;
}
/** 解析库根 metadata.json,返回 { folderId -> children[] } 及文件夹名 */
function readFolderInfo(libRoot) {
const map = {};
const names = {};
if (!libRoot) return { map, names };
try {
const d = JSON.parse(fs.readFileSync(path.join(libRoot, 'metadata.json'), 'utf8'));
const folders = d.folders || [];
for (const f of folders) {
if (!f) continue;
map[f.id] = Array.isArray(f.children) ? f.children : [];
names[f.id] = f.name || '';
}
} catch (e) { /* ignore */ }
return { map, names };
}
/**
* 计算允许匹配的 Eagle 文件夹 id 集合:
* 仅包含「选中文件所在的文件夹」本身,不下钻子文件夹,也不匹配父级/兄弟文件夹。
*/
function getValidFolders(filePath) {
const itemFolders = foldersOfItem(filePath);
if (itemFolders.length === 0) return null; // 未归入任何 Eagle 文件夹,则不限定
return new Set(itemFolders);
}
/** 判断目录是否为 Eagle 的 item 元数据文件夹(包含 metadata.json) */
function isEagleItemDir(dir) {
try { return fs.existsSync(path.join(dir, 'metadata.json')); } catch (e) { return false; }
}
/**
* 计算需要搜索的目录列表。
* Eagle 会把导入的每个文件拆分成独立的元数据文件夹(如 `images/<id>.info/`),
* 因此配套的骨骼/atlas/贴图可能位于不同的 item 文件夹中。
* 返回:当前 item 文件夹 + 其同级的所有 item 文件夹。
*/
function getSearchRoots(filePath) {
const roots = [];
const itemDir = path.dirname(filePath);
roots.push(itemDir);
if (isEagleItemDir(itemDir)) {
const parent = path.dirname(itemDir);
try {
for (const name of fs.readdirSync(parent)) {
const sub = path.join(parent, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
return [...new Set(roots)];
}
/** 把库根目录 images/ 下所有 item 文件夹加入搜索根(去重)。
* 用于覆盖同伴位于不同 .info / 跨批次的情况(全库兜底)。 */
function buildRootsWithLibraryRoot(filePath, roots) {
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return;
for (const name of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, name);
try {
if (fs.statSync(sub).isDirectory() && isEagleItemDir(sub)) {
if (roots.indexOf(sub) === -1) roots.push(sub);
}
} catch (e) { /* ignore */ }
}
} catch (e) { /* ignore */ }
}
/**
* 在多个目录中收集所有 Spine 相关文件,返回 [{ name, fullPath }]。
* 若传入 validFolders(Eagle 逻辑文件夹 id 集合),则只保留位于这些文件夹内的 item,
* 从而把匹配范围限定在「选中文件所在的 Eagle 文件夹及其子文件夹」内。
*/
function collectSpineFiles(roots, validFolders) {
const seen = new Set();
const out = [];
for (const root of roots) {
let entries;
try { entries = fs.readdirSync(root); } catch (e) { continue; }
for (const name of entries) {
const full = path.join(root, name);
try { if (!fs.statSync(full).isFile()) continue; } catch (e) { continue; }
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
// 排除 Eagle 回收站中已删除的项
if (isItemDeleted(full)) continue;
if (validFolders && validFolders.size) {
const itemFolders = foldersOfItem(full);
if (!itemFolders.some((f) => validFolders.has(f))) continue; // 不在允许的文件夹内
}
if (seen.has(full)) continue;
seen.add(full);
out.push({ name, fullPath: full });
}
}
return out;
}
/**
* 缩略图批量生成时,同一时刻会有很多 atlas/json/skel 回调。对全库文件清单做一个
* 很短的缓存,下一轮轮询仍会重新扫描,因此既降低大文件夹导入开销,也不会漏掉
* 正在落盘的新文件。
*/
function collectSpineFilesCached(filePath, roots, refresh) {
const libRoot = findLibraryRoot(filePath);
// Outside an Eagle library, sibling asset folders are independent search
// scopes. Sharing their parent as a cache key can return another skeleton.
const key = libRoot || JSON.stringify([...roots].sort());
const now = Date.now();
const cached = fileIndexCache.get(key);
if (!refresh && cached && now - cached.time < 1500) return cached.files;
const files = collectSpineFiles(roots, null);
fileIndexCache.set(key, { time: now, files });
return files;
}
/**
* 修正不可靠的源路径。
* Eagle 传给缩略图 handler 的 src 有时指向一个已清理/不存在的 `.info` 目录
* (例如其内部临时工作目录)。此时按文件名在整个库 images/ 下找到真实存在的
* 源文件,作为后续资源发现的基准。
*/
function resolveRealSource(filePath) {
// 源目录存在且是有效 item 目录 → 直接用
const dir = path.dirname(filePath);
if (isEagleItemDir(dir)) return filePath;
const name = path.basename(filePath);
const libRoot = findLibraryRoot(filePath);
if (!libRoot) return filePath;
const imagesDir = path.join(libRoot, 'images');
try {
if (!fs.existsSync(imagesDir)) return filePath;
// 优先找“文件名 + 类型”匹配的真实文件。若存在同名文件,返回它所在目录,
// 以便后续 getSearchRoots/selectBest 能借助其 folders/btime 精确匹配。
let fallback = null;
for (const d of fs.readdirSync(imagesDir)) {
const sub = path.join(imagesDir, d);
if (!isEagleItemDir(sub)) continue;
const cand = path.join(sub, name);
if (fs.existsSync(cand)) {
// 优先选择未被删除的项
if (!isItemDeleted(cand)) return cand;
if (!fallback) fallback = cand;
}
}
if (fallback) return fallback;
} catch (e) { /* ignore */ }
return filePath;
}
/**
* 发现与所选文件 related 的 Spine 资源(跨 Eagle 多个 item 文件夹)。
* 返回 { skeletonPath, atlasPath, texturePaths, base, isBinary, ... }
* - texturePaths: { atlas 中引用的贴图名 -> 绝对路径 }
*/
function discoverAssets(filePath, options) {
options = options || {};
const strictStem = !!options.strictStem;
const requestedCrossBatch = !!options.allowCrossBatch;
const maxCrossBatchDeltaMs = Math.max(30000, Number(options.maxCrossBatchDeltaMs) || 5 * 60 * 1000);
filePath = resolveRealSource(filePath);
const selected = path.basename(filePath);
const selectedStem = normalizeStem(selected);
// 先扫码“选中 item 文件夹 + 同级所有 .info”(精确、不跨批次)。
const roots = getSearchRoots(filePath);
// 若同级范围无法凑齐骨架+图集(常见于 Eagle 导入瞬间,同伴落在别的 .info),
// 则扩展搜索到整个库的 images/ 目录(全库兜底,靠“同家族 + 同文件夹 + 最近 btime”配对)。
buildRootsWithLibraryRoot(filePath, roots);
// 导入时 Eagle 会先创建各个 .info,再异步写 folders。严格模式必须把尚未写入
// folders 的同批资源也纳入候选,之后用 metadata.json 的 birthtime 精确配对。
const validFolders = getValidFolders(filePath);
// 导入瞬间 folders 可能尚未写入。没有明确 Eagle 文件夹 ID 时禁止跨批次,
// 否则单独导入的同名 JSON 会借用库中任意旧 atlas 生成错误缩略图。
const allowCrossBatch = requestedCrossBatch && !!(validFolders && validFolders.size);
const allowedImportDelta = allowCrossBatch ? maxCrossBatchDeltaMs : 30000;
let files = collectSpineFiles(roots, validFolders);
if (strictStem) {
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
if (allowCrossBatch && validFolders && validFolders.size) {
files = allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)));
} else {
files = allFiles;
}
}
let skeletonFiles = files.filter((f) => isSkeletonFile(f.name));
const atlasFiles = files.filter((f) => isAtlasFile(f.name));
const textureFiles = files.filter((f) => isTextureFile(f.name));
// 导入缩略图始终按同名资源链匹配。先按 stem 缩小候选,再检查 JSON 内容,
// 避免每个回调都解析库中所有普通 JSON。
if (strictStem) {
skeletonFiles = skeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
}
// 区分“骨架 json”和“普通 json”:只保留形似骨架的 json
const realSkeletonFiles = skeletonFiles.filter((f) => {
if (f.name.toLowerCase().endsWith('.json')) return looksLikeSkeletonJson(f.fullPath);
return true;
});
// 工具:优先选择与 refFile 同一批入库的候选,再看 Eagle 逻辑文件夹及素材时间。
// 这可区分同一文件夹中多次导入的同名 Spine 导出,又不受素材原始时间影响。
const selectBest = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const refFolders = foldersOfItem(refFile);
const refImportTime = importTimeOf(refFile);
const refBtime = btimeOf(refFile);
const ranked = cands.map((c) => {
const cFolders = foldersOfItem(c.fullPath);
return {
c,
importDelta: refImportTime ? Math.abs(importTimeOf(c.fullPath) - refImportTime) : Number.MAX_SAFE_INTEGER,
sameFolder: refFolders.length && cFolders.some((f) => refFolders.indexOf(f) !== -1) ? 1 : 0,
btimeDelta: refBtime ? Math.abs(btimeOf(c.fullPath) - refBtime) : Number.MAX_SAFE_INTEGER,
};
});
ranked.sort((a, b) => allowCrossBatch
? (b.sameFolder - a.sameFolder || a.importDelta - b.importDelta || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
: (a.importDelta - b.importDelta || b.sameFolder - a.sameFolder || a.btimeDelta - b.btimeDelta || a.c.fullPath.localeCompare(b.c.fullPath))
);
return ranked[0].c;
};
// 辅助:读取候选 atlas 的格式家族,以及候选骨骼的版本家族
const familyOfAtlas = (f) => { try { return atlasFamily(fs.readFileSync(f.fullPath, 'utf8')); } catch (e) { return null; } };
const familyOfSkel = (f) => { try { return versionFamily(detectVersion(f.fullPath)); } catch (e) { return null; } };
// 选中的文件若是 atlas,则读取其格式家族,用于匹配同家族的骨骼
const selectedAtlasFamily = isAtlasFile(selected)
? (() => { try { return atlasFamily(fs.readFileSync(filePath, 'utf8')); } catch (e) { return null; } })()
: null;
// 1) 骨骼文件
let skeleton = null;
// 若选中的就是骨骼文件,务必使用它本身(它的版本即最终版本)
if (isSkeletonFile(selected)) {
skeleton = realSkeletonFiles.find((f) => f.fullPath === filePath) ||
realSkeletonFiles.find((f) => f.name === selected) || null;
}
if (!skeleton) {
// 依选中 stem 匹配;优先与选中 atlas 同家族(3x/4x),再按同文件夹/同批导入
let cands = realSkeletonFiles.filter((f) => normalizeStem(f.name) === selectedStem);
if (selectedAtlasFamily && cands.length > 1) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = cands.length > 0 ? selectBest(cands, filePath) : null;
}
if (!skeleton && realSkeletonFiles.length > 0) {
if (strictStem) {
skeleton = null;
} else {
// 忽略 stem:优先与选中 atlas 同家族,再按入库批次选取
let cands = realSkeletonFiles;
if (selectedAtlasFamily) {
const same = cands.filter((f) => familyOfSkel(f) === selectedAtlasFamily);
if (same.length > 0) cands = same;
}
skeleton = selectBest(cands, filePath);
}
}
// 2) atlas 文件 —— 优先与骨骼版本家族一致(3x/4x),避免 3.8 骨骼配 4x atlas
let atlas = null;
if (isAtlasFile(selected)) {
atlas = atlasFiles.find((f) => f.fullPath === filePath) ||
atlasFiles.find((f) => f.name === selected) || null;
}
// 统一的候选选择:优先“与骨骼同家族(3x/4x)”的 atlas,再按“同文件夹 + 最近 btime”挑选。
// 家族过滤无条件优先,避免 4x 骨骼配到 3x 图集(会导致 UV/坐标错乱)。
const pickAtlas = (cands, refFile) => {
if (!cands || cands.length === 0) return null;
const skelFamily = skeleton ? familyOfSkel(skeleton) : null;
if (skelFamily) {
const same = cands.filter((f) => familyOfAtlas(f) === skelFamily);
if (same.length > 0) cands = same;
}
return selectBest(cands, refFile);
};
if (!atlas) {
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === selectedStem);
atlas = pickAtlas(cands, filePath);
}
if (!atlas && skeleton) {
const skelStem = normalizeStem(skeleton.name);
let cands = atlasFiles.filter((f) => normalizeStem(f.name) === skelStem);
atlas = pickAtlas(cands, skeleton.fullPath);
}
if (!atlas && skeleton) {
if (strictStem) {
atlas = null;
} else {
// 忽略 stem:用与骨骼同家族且同批入库的 atlas
atlas = pickAtlas(atlasFiles, skeleton.fullPath);
}
}
if (!atlas && !strictStem && atlasFiles.length === 1) atlas = atlasFiles[0];
// 严格模式用于导入缩略图:配套骨骼和图集应由同一次拖入创建。若时间差
// 过大,说明新同伴还没落盘,返回缺失让调用方继续等待,不能借用旧同名资源。
if (strictStem && skeleton && atlas) {
const pairDelta = Math.abs(importTimeOf(skeleton.fullPath) - importTimeOf(atlas.fullPath));
if (pairDelta > allowedImportDelta) {
if (isAtlasFile(selected)) skeleton = null;
else atlas = null;
}
}
// 3) 解析 atlas 引用的贴图路径(多个同名贴图时,优先与 atlas 同文件夹/同批导入的)
let texturePaths = {};
if (atlas) {
let text = '';
try { text = fs.readFileSync(atlas.fullPath, 'utf8'); } catch (e) { /* ignore */ }
const pageNames = parseAtlasPages(text);
const atlasDir = path.dirname(atlas.fullPath); // atlas 所在 .info 目录
for (const pn of pageNames) {
// 1) 优先使用 atlas 所在 .info 目录里的同名贴图(最可靠)
const inAtlasDir = path.join(atlasDir, pn);
if (fs.existsSync(inAtlasDir)) {
texturePaths[pn] = inAtlasDir;
continue;
}
// 2) 否则在候选里按“同 Eagle 文件夹 + 最近 btime”挑选
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pn.toLowerCase());
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pn));
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;
}
}
const base = skeleton ? normalizeStem(skeleton.name) : (atlas ? normalizeStem(atlas.name) : selectedStem);
const skelLower = skeleton ? skeleton.name.toLowerCase() : '';
const skeletonIsBinary = skelLower.endsWith('.skel') || skelLower.endsWith('.skel.bytes');
const chosenFamily = skeleton ? familyOfSkel(skeleton) : (atlas ? familyOfAtlas(atlas) : null);
const skeletonTime = skeleton ? importTimeOf(skeleton.fullPath) : 0;
const atlasTime = atlas ? importTimeOf(atlas.fullPath) : 0;
const relatedSkeletonPaths = skeleton ? realSkeletonFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfSkel(f) === chosenFamily &&
Math.abs(importTimeOf(f.fullPath) - skeletonTime) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedAtlasPaths = atlas ? atlasFiles.filter((f) =>
normalizeStem(f.name) === base &&
familyOfAtlas(f) === chosenFamily &&
Math.abs(importTimeOf(f.fullPath) - atlasTime) <= allowedImportDelta
).map((f) => f.fullPath) : [];
const relatedDataPaths = [...new Set(relatedSkeletonPaths.concat(relatedAtlasPaths, [filePath]))]
.filter((fp) => isSkeletonFile(fp) || isAtlasFile(fp));
return {
skeletonPath: skeleton ? skeleton.fullPath : null,
atlasPath: atlas ? atlas.fullPath : null,
texturePaths,
base,
isBinary: skeletonIsBinary,
skeletonCandidates: realSkeletonFiles.map((f) => f.name),
skeletonCandidatePaths: realSkeletonFiles.map((f) => f.fullPath),
atlasCandidates: atlasFiles.map((f) => f.name),
atlasCandidatePaths: atlasFiles.map((f) => f.fullPath),
textureCandidates: textureFiles.map((f) => f.name),
relatedDataPaths,
};
}
/** 从 atlas 文本中解析出引用的贴图页面文件名 */
function parseAtlasPages(atlasText) {
const pages = [];
if (!atlasText) return pages;
const lines = atlasText.split(/\r?\n/);
// atlas 中每个页面以一行“xxx.png”之类的文件名开始
for (let i = 0; i < lines.length; i++) {
const line = lines[i].trim();
if (/\.(png|jpg|jpeg|webp)$/i.test(line)) {
pages.push(line);
} else if (line === '') {
// 空白行之后的下一个非空行可能是下一页文件名
continue;
}
}
// 过滤掉显然是属性的行(例如 "size: 512,512"、"filter: " 等)
return pages.filter((p) => !/[=,:]/.test(p));
}
/**
* 读取 Atlas 页面声明的预乘设置。Spine 4.x 会为每个页面写入
* `pma:true/false`;旧版 Atlas 通常没有该字段,此时返回 undefined。
* Spine 的一次导出会让所有页面使用同一设置。若遇到手工拼接且声明冲突
* 的 Atlas,采用第一个页面的声明,保持与页面/区域读取顺序一致。
*/
function parseAtlasPma(atlasText) {
if (typeof atlasText !== 'string') return undefined;
const matches = Array.from(atlasText.matchAll(/^\s*pma\s*:\s*(true|false)\s*$/gim));
return matches.length ? matches[0][1].toLowerCase() === 'true' : undefined;
}
/** 找到应在首次预览时使用的皮肤:defaultSkin、名为 default 的皮肤、第一个皮肤。 */
function preferredSkin(skeletonData) {
if (!skeletonData) return null;
if (skeletonData.defaultSkin) return skeletonData.defaultSkin;
const skins = skeletonData.skins || [];
return skins.find((skin) => skin && skin.name === 'default') || skins[0] || null;
}
/** 应用皮肤并按 setup pose 重新解析槽位附件,跨 Spine 3.4–4.3 使用。 */
function applySkin(skeleton, skin) {
if (!skeleton || !skin) return false;
if (typeof skeleton.setSkin === 'function') skeleton.setSkin(skin);
else if (typeof skeleton.setSkinByName === 'function') skeleton.setSkinByName(skin.name);
else return false;
if (typeof skeleton.setSlotsToSetupPose === 'function') skeleton.setSlotsToSetupPose();
else if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
return true;
}
module.exports = {
SKELETON_EXTS,
ATLAS_EXTS,
TEXTURE_EXTS,
ALL_EXTS,
extOf,
isSkeletonFile,
isAtlasFile,
isTextureFile,
stemOf,
normalizeStem,
detectVersion,
detectVersionFromJson,
detectVersionFromBuffer,
chooseRuntimeDir,
classifySkeletonJson,
looksLikeSkeletonJson,
discoverAssets,
parseAtlasPages,
parseAtlasPma,
preferredSkin,
applySkin,
isEagleItemDir,
getSearchRoots,
collectSpineFiles,
foldersOfItem,
findLibraryRoot,
readFolderInfo,
getValidFolders,
mtimeOf,
btimeOf,
importTimeOf,
versionFamily,
atlasFamily,
itemMeta,
isItemDeleted,
};
@@ -1,177 +0,0 @@
'use strict';
// Decode file bytes without the Canvas 2D premultiply/unpremultiply round trip.
// In low-alpha PMA pixels that round trip destroys RGB precision before blending.
const preparedTextures = new WeakMap();
async function prepareTexture(image, blob) {
if (typeof createImageBitmap !== 'function') return;
const options = { colorSpaceConversion: 'none' };
const [pma, straight] = await Promise.all([
createImageBitmap(blob, { ...options, premultiplyAlpha: 'none' }),
createImageBitmap(blob, { ...options, premultiplyAlpha: 'premultiply' })
]);
preparedTextures.set(image, { pma, straight });
}
// Rasterize adjacent mesh triangles together, with GPU edge ownership rules.
// Canvas clip()+drawImage() anti-aliases each triangle separately and exposes
// the background along shared edges, which looks like a wireframe overlay.
function createRasterizer(context) {
const document = context.canvas && context.canvas.ownerDocument;
if (!document || typeof context.getTransform !== 'function') return null;
const canvas = document.createElement('canvas');
const gl = canvas.getContext('webgl', {
alpha: true, premultipliedAlpha: true, antialias: true,
preserveDrawingBuffer: true, depth: false, stencil: false
});
if (!gl) return null;
function shader(type, source) {
const shader = gl.createShader(type);
gl.shaderSource(shader, source); gl.compileShader(shader);
if (!gl.getShaderParameter(shader, gl.COMPILE_STATUS)) throw new Error(gl.getShaderInfoLog(shader));
return shader;
}
const vertex = shader(gl.VERTEX_SHADER, `
attribute vec2 position;
attribute vec2 uv;
attribute vec4 light;
attribute vec4 dark;
uniform vec2 resolution;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec2 p = position / resolution * 2.0 - 1.0;
gl_Position = vec4(p.x, -p.y, 0.0, 1.0);
textureUV = uv;
lightColor = light; darkColor = dark;
}`);
const fragment = shader(gl.FRAGMENT_SHADER, `
precision mediump float;
uniform sampler2D image;
varying vec2 textureUV;
varying vec4 lightColor;
varying vec4 darkColor;
void main() {
vec4 color = texture2D(image, textureUV);
float alpha = color.a * lightColor.a;
// Textures are PMA in GPU memory. Light/dark colors arrive straight.
vec3 rgb = (color.rgb * lightColor.rgb + (color.a - color.rgb) * darkColor.rgb) * lightColor.a;
gl_FragColor = vec4(rgb, alpha);
}`);
const program = gl.createProgram(); gl.attachShader(program, vertex); gl.attachShader(program, fragment); gl.linkProgram(program);
if (!gl.getProgramParameter(program, gl.LINK_STATUS)) throw new Error(gl.getProgramInfoLog(program));
gl.deleteShader(vertex); gl.deleteShader(fragment);
const buffer = gl.createBuffer();
const position = gl.getAttribLocation(program, 'position'), uv = gl.getAttribLocation(program, 'uv');
const resolution = gl.getUniformLocation(program, 'resolution');
const light = gl.getAttribLocation(program, 'light'), dark = gl.getAttribLocation(program, 'dark');
const textures = new Map();
let coordinates = [], currentImage = null, currentAlpha = 1, currentMode = 'source-over', transform;
let disposed = false, lost = false;
let premultipliedAlpha = false;
canvas.addEventListener('webglcontextlost', event => { event.preventDefault(); lost = true; });
function texture(image) {
const pma = image !== context.canvas && premultipliedAlpha;
let pair = textures.get(image);
if (!pair) { pair = {}; textures.set(image, pair); }
const key = pma ? 'pma' : 'straight';
if (pair[key] && image !== context.canvas) return pair[key];
const t = pair[key] || gl.createTexture(); gl.bindTexture(gl.TEXTURE_2D, t);
// Premultiply before interpolation so translucent edges do not darken.
gl.pixelStorei(gl.UNPACK_PREMULTIPLY_ALPHA_WEBGL, !pma);
gl.pixelStorei(gl.UNPACK_FLIP_Y_WEBGL, false);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.LINEAR);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_S, gl.CLAMP_TO_EDGE);
gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_WRAP_T, gl.CLAMP_TO_EDGE);
const prepared = preparedTextures.get(image);
gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, prepared ? prepared[key] : image);
pair[key] = t; return t;
}
function flush() {
if (!coordinates.length) return;
gl.bindTexture(gl.TEXTURE_2D, texture(currentImage));
// Premultiplied RGB factors; keep source-over alpha independently.
const factors = currentMode === 'lighter' ? [gl.ONE, gl.ONE]
: currentMode === 'multiply' ? [gl.DST_COLOR, gl.ONE_MINUS_SRC_ALPHA]
: currentMode === 'screen' ? [gl.ONE, gl.ONE_MINUS_SRC_COLOR]
: [gl.ONE, gl.ONE_MINUS_SRC_ALPHA];
gl.blendFuncSeparate(factors[0], factors[1], gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
gl.bufferData(gl.ARRAY_BUFFER, new Float32Array(coordinates), gl.STREAM_DRAW);
gl.drawArrays(gl.TRIANGLES, 0, coordinates.length / 12);
coordinates.length = 0;
}
return {
begin(pma = false) {
if (disposed || lost || gl.isContextLost()) return false;
premultipliedAlpha = pma;
const target = context.canvas;
if (canvas.width !== target.width) canvas.width = target.width;
if (canvas.height !== target.height) canvas.height = target.height;
transform = context.getTransform(); coordinates.length = 0; currentImage = null;
gl.viewport(0, 0, canvas.width, canvas.height);
gl.clearColor(0, 0, 0, 0); gl.clear(gl.COLOR_BUFFER_BIT);
gl.useProgram(program); gl.bindBuffer(gl.ARRAY_BUFFER, buffer);
gl.enableVertexAttribArray(position); gl.vertexAttribPointer(position, 2, gl.FLOAT, false, 48, 0);
gl.enableVertexAttribArray(uv); gl.vertexAttribPointer(uv, 2, gl.FLOAT, false, 48, 8);
gl.enableVertexAttribArray(light); gl.vertexAttribPointer(light, 4, gl.FLOAT, false, 48, 16);
gl.enableVertexAttribArray(dark); gl.vertexAttribPointer(dark, 4, gl.FLOAT, false, 48, 32);
gl.uniform2f(resolution, canvas.width, canvas.height);
gl.activeTexture(gl.TEXTURE0); gl.uniform1i(gl.getUniformLocation(program, 'image'), 0);
gl.enable(gl.BLEND); gl.blendFunc(gl.ONE, gl.ONE_MINUS_SRC_ALPHA);
// Seed the same framebuffer with the actual Canvas pixels (solid or
// checkerboard background). CSS backgrounds cannot participate in blending.
currentImage = target; currentAlpha = 1; currentMode = 'source-over';
const w = canvas.width, h = canvas.height;
for (const vertex of [[0,0,0,0],[w,0,1,0],[w,h,1,1],[0,0,0,0],[w,h,1,1],[0,h,0,1]])
coordinates.push(...vertex, 1,1,1,1, 0,0,0,0);
flush(); currentImage = null;
return true;
},
triangle(image, x0, y0, u0, v0, x1, y1, u1, v1, x2, y2, u2, v2, colors, darkColors) {
const alpha = context.globalAlpha;
const mode = context.globalCompositeOperation || 'source-over';
if (image !== currentImage || alpha !== currentAlpha || mode !== currentMode) { flush(); currentImage = image; currentAlpha = alpha; currentMode = mode; }
const t = transform;
const points = [[x0,y0,u0,v0],[x1,y1,u1,v1],[x2,y2,u2,v2]];
for (let i=0; i<3; i++) {
const [x,y,u,v] = points[i], j=i*4;
coordinates.push(t.a*x+t.c*y+t.e, t.b*x+t.d*y+t.f, u,v,
...(colors ? colors.slice(j,j+4) : [1,1,1,alpha]),
...(darkColors ? darkColors.slice(j,j+4) : [0,0,0,0]));
}
},
end() {
flush();
context.save();
try { context.setTransform(1, 0, 0, 1, 0, 0); context.globalAlpha = 1; context.globalCompositeOperation = 'copy'; context.drawImage(canvas, 0, 0); }
finally { context.restore(); }
},
dispose() {
if (disposed) return; disposed = true;
for (const pair of textures.values()) for (const value of Object.values(pair)) gl.deleteTexture(value);
textures.clear(); gl.deleteBuffer(buffer); gl.deleteProgram(program);
const extension = gl.getExtension('WEBGL_lose_context'); if (extension) extension.loseContext();
}
};
}
function attach(renderer, context) {
let rasterizer;
try { rasterizer = createRasterizer(context); } catch (e) { return renderer; }
if (!rasterizer) return renderer;
const draw = renderer.draw.bind(renderer), triangle = renderer.drawTriangle.bind(renderer);
renderer.draw = skeleton => {
if (!rasterizer.begin(!!renderer.premultipliedAlpha)) return draw(skeleton);
renderer.gpuDrawing = true;
renderer.drawTriangle = rasterizer.triangle;
try { draw(skeleton); rasterizer.end(); }
finally { renderer.drawTriangle = triangle; renderer.gpuDrawing = false; }
};
renderer.dispose = rasterizer.dispose;
renderer.rasterization = 'webgl';
return renderer;
}
module.exports = { createRasterizer, attach, prepareTexture };
@@ -1,9 +0,0 @@
# Spine 3.4 runtime
Bundled from the official `3.4.02` tag, commit `ef5013143178918ddfc7cabfbddeae7aec7cf84e`, `spine-ts/build/spine-canvas.js`.
Local correction: `SkeletonJson` path-length loop writes `lengths[i]`, not `lengths[i++]`. The original increments twice and reads beyond the source array, producing missing/NaN lengths.
The plugin provides binary decoding and the `AtlasAttachmentLoader` name alias in `js/spine-legacy-binary.js`. Rendering uses the 3.4 attachment vertex API in `js/spine-canvas-clipping.js`. 3.4 has no clipping attachments; no newer attachment types are synthesized.
License: `LICENSES/Spine-3.4-License.txt`.
File diff suppressed because it is too large. Load diff
@@ -1,11 +0,0 @@
# Spine 3.5
Official `3.5.51` tag, commit `2cd9467d2736b7f3a0b81382f117c73823bfb44b`, `spine-ts/build/spine-canvas.js`. The branch named `3.5` contains later 3.6 beta data; this bundle deliberately uses the tag.
Local correction: the path-length loop uses `lengths[i]` instead of `lengths[i++]`, avoiding a double increment and NaN values.
The binary compatibility layer reads 3.5 transform modes and constraint order, but does not read 3.6 dark-slot color or local/relative transform fields. Rendering uses the older attachment vertex interface. The version predates clipping attachments.
The official tag includes matching 3.5.03-beta JSON/SKEL pairs for seven variants. Tank and Raptor have 3.5.49 JSON but 3.5.03-beta SKEL; these are tested independently, not compared as identical exports. No version strings or data are rewritten.
License: `LICENSES/Spine-3.5-License.txt`.
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
Binary file not shown.

Before

Width:  |  Height:  |  Size: 69 KiB

-39
View File
@@ -1,39 +0,0 @@
{
"id": "ddf1c576-cfbc-4813-83a6-2e029cc08745",
"version": "1.1.0",
"platform": "win",
"arch": "x64",
"name": "{{manifest.app.name}}",
"logo": "/logo-spineviewer.png",
"keywords": [
"spine",
"骨骼动画",
"动画预览",
"skel",
"atlas"
],
"fallbackLanguage": "zh_CN",
"languages": [
"zh_CN",
"en",
"zh_TW",
"ja_JP",
"ko_KR",
"ru_RU",
"es_ES",
"de_DE"
],
"devTools": false,
"preview": {
"skel,skel.bytes,bytes,json,atlas,atlas.txt,txt": {
"thumbnail": {
"path": "thumbnail/spine.js",
"size": 400,
"allowZoom": false
},
"viewer": {
"path": "viewer/spine.html"
}
}
}
}
-45
View File
@@ -1,45 +0,0 @@
{
"name": "spineviewer",
"version": "1.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"node_modules/@napi-rs/canvas": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-0.1.66.tgz",
"integrity": "sha512-NE/eQKLbUS+LCbMHRa5HnR7cc1Q4ibg/qfLUN4Ukl3CC0lq6LfHE0YbvFm/l4i5RyyS+aUjL+8IuZDD9EH3amg==",
"license": "MIT",
"engines": {
"node": ">= 10"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "0.1.66",
"@napi-rs/canvas-darwin-arm64": "0.1.66",
"@napi-rs/canvas-darwin-x64": "0.1.66",
"@napi-rs/canvas-linux-arm-gnueabihf": "0.1.66",
"@napi-rs/canvas-linux-arm64-gnu": "0.1.66",
"@napi-rs/canvas-linux-arm64-musl": "0.1.66",
"@napi-rs/canvas-linux-riscv64-gnu": "0.1.66",
"@napi-rs/canvas-linux-x64-gnu": "0.1.66",
"@napi-rs/canvas-linux-x64-musl": "0.1.66",
"@napi-rs/canvas-win32-x64-msvc": "0.1.66"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "0.1.66",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-0.1.66.tgz",
"integrity": "sha512-3n34watNFqpwACDA+pt4jfQD6zR8PzfK86FBajdsgDVVZhSp6ohgbbJv+eUrXM08VUtjxTq7+U4sWspTu9+4Ug==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
}
}
}
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff. Show more