Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d33302858 | ||
|
|
4a34d0c46a | ||
|
|
5fb210e5a8 | ||
|
|
224ba24fab | ||
|
|
21a8b5e24e | ||
|
|
3bd3ba7404 | ||
|
|
acc93d5404 | ||
|
|
c29de7a26b | ||
|
|
650d9c0398 | ||
|
|
be058c6c80 | ||
|
|
08456f26cf | ||
|
|
07c21f5954 | ||
|
|
96a0db09d8 | ||
|
|
065b6466f1 | ||
|
|
c28adf9fae | ||
|
|
fe4997f913 | ||
|
|
081520dbd9 | ||
|
|
e94dfb9e35 |
No files matched your search
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Spine-Vorschaubilder"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Spine-Vorschaubilder",
|
||||
"noticeIntro": "Nach dem Import von Spine-Assets (.skel / .json + .atlas + Texturen) erstellt dieses Plugin eine Skelettvorschau für Elemente ohne eigenes Vorschaubild.",
|
||||
"noticeSafety": "Nur neu importierte Elemente werden verarbeitet. Vorhandene Elemente werden weder durchsucht noch geändert, und eigene Vorschaubilder werden nie überschrieben.",
|
||||
"noticeModes": "Animiert verwendet die erste Animation und exportiert WebP. Statisch verwendet immer die Setup-Pose.",
|
||||
"noticeDefault": "Die automatische Erstellung ist standardmäßig ausgeschaltet. Aktiviere sie bei Bedarf unten.",
|
||||
"autoToggle": "Beim Import Vorschaubilder erstellen",
|
||||
"clear": "Protokoll leeren",
|
||||
"modeLabel": "Erstellungsmodus",
|
||||
"modeAnimated": "Animiert (erste Animation)",
|
||||
"modeStatic": "Statisch (Setup-Pose)",
|
||||
"strategyLabel": "Strategie",
|
||||
"strategySize": "Kleinste Datei (langsam)",
|
||||
"strategySpeed": "Schnellste Methode (schnell)",
|
||||
"statusLabel": "Status",
|
||||
"statusStarting": "Wird gestartet…",
|
||||
"libraryLabel": "Bibliothek",
|
||||
"statsLabel": "Statistik",
|
||||
"filterAll": "Alle",
|
||||
"filterOk": "Erfolg",
|
||||
"filterFail": "Fehlgeschlagen",
|
||||
"filterWarn": "Warnung",
|
||||
"filterError": "Fehler",
|
||||
"unknownError": "Unbekannter Fehler",
|
||||
"scriptError": "Skriptfehler: {error}",
|
||||
"promiseError": "Unbehandelter Promise-Fehler: {error}",
|
||||
"uncategorized": "Ohne Kategorie",
|
||||
"reasonQuiet": "Ruhephase",
|
||||
"reasonFallback": "Ersatzprüfung",
|
||||
"none": "keine",
|
||||
"complete": "vollständig",
|
||||
"incomplete": "unvollständig",
|
||||
"statusLoaded": "Skript {version} geladen; warte auf Eagle-Ereignisse…",
|
||||
"statusEagleMissing": "Eagle-API nicht verfügbar",
|
||||
"statusListening": "Überwachung aktiv",
|
||||
"statusListenFailed": "Überwachung fehlgeschlagen",
|
||||
"statusAutoOff": "Überwachung aktiv (automatische Erstellung aus)",
|
||||
"statusNoLibrary": "Bibliothekspfad nicht verfügbar",
|
||||
"statusPairing": "Neue Assets werden zugeordnet…",
|
||||
"statusPairingProgress": "Zuordnung {current}/{total}",
|
||||
"statusRendering": "Frame wird gerendert {current}/{total} ({queued} in Warteschlange)",
|
||||
"statusGenerating": "Vorschaubilder werden erstellt… noch {queued}",
|
||||
"statusPaused": "Pausiert (im Fortschrittsfenster fortsetzen oder abbrechen)",
|
||||
"statusAutoSkipped": "Automatische Erstellung ist aus; dieser Stapel wurde übersprungen",
|
||||
"statusStartFailed": "Start fehlgeschlagen: {error}",
|
||||
"stats": "Stapel {batches} · Hinzugefügt {added} · Spine {spine} · Vollständig {complete} · Erstellt {generated} · Übersprungen {skipped} · Fehlgeschlagen {failed}",
|
||||
"modulesMissing": "Lokale Module nicht geladen: {modules}",
|
||||
"moduleLoadError": "Lokale Module wurden nicht geladen. Prüfe die Skriptreihenfolge in index.html: {modules}",
|
||||
"version": "Plugin-Codeversion {version}",
|
||||
"pluginCreated": "Plugin erstellt: {name}",
|
||||
"settingsReadFailed": "Einstellungen konnten nicht gelesen werden; Standardwerte werden verwendet (automatische Erstellung aus): {error}",
|
||||
"settingsSaveFailed": "Einstellungen konnten nicht gespeichert werden: {error}",
|
||||
"autoEnabled": "Automatische Erstellung aktiviert. Neu importierte Spine-Assets erhalten Vorschaubilder.",
|
||||
"autoDisabled": "Automatische Erstellung deaktiviert; die Warteschlange wurde geleert.",
|
||||
"modeStaticLog": "Erstellungsmodus: statisch (immer Setup-Pose, PNG)",
|
||||
"modeAnimatedLog": "Erstellungsmodus: animiert (erste Animation als WebP; Setup-Pose ohne Animation)",
|
||||
"strategySpeedLog": "Strategie: schnellste Methode (grobe Auswahl, nur die kleinsten ein oder zwei Profile)",
|
||||
"strategySizeLog": "Strategie: kleinste Datei (alle zehn Profile erstellen und das kleinste Ergebnis behalten)",
|
||||
"missingItem": "{label} übersprungen: Element nicht in Eagle gefunden",
|
||||
"suffixFixed": "Zusammengesetzte Endung für {label} korrigiert → {name}",
|
||||
"suffixFailed": "Zusammengesetzte Endung für {label} konnte nicht korrigiert werden: {error}",
|
||||
"existingThumbnail": "{label} übersprungen: eigenes Vorschaubild vorhanden",
|
||||
"thumbnailRejected": "Eagle hat dieses Vorschaubild abgelehnt",
|
||||
"generated": "{label} erstellt | {details} | Spine {version}",
|
||||
"generateFailed": "Erstellung für {label} fehlgeschlagen: {error}",
|
||||
"cancelled": "Erstellung abgebrochen: {dropped} ausstehende Elemente verworfen. {done} abgeschlossen (Erfolg {generated}, Fehler {failed}, übersprungen {skipped})",
|
||||
"batchFailed": "Importstapel konnte nicht verarbeitet werden: {error}",
|
||||
"batchFinished": "Stapel abgeschlossen: {added} hinzugefügt, {total} in der Bibliothek ({reason})",
|
||||
"recheck": "{count} früher unvollständige Assets werden erneut geprüft",
|
||||
"noSpine": "Keine Spine-Assets in diesem Stapel",
|
||||
"incompletePending": "{count} Assets sind unvollständig und werden nach späteren Importen erneut geprüft",
|
||||
"incompleteIgnored": "{count} Assets sind unvollständig und wurden in diesem Stapel nicht verarbeitet",
|
||||
"itemSummary": " {name} | {path} | {state} | Skelett {skeleton} | Atlas {atlas} | Seiten {pages} (fehlend {missing})",
|
||||
"autoSkipped": "Automatische Erstellung ist aus; {count} Assets in diesem Stapel übersprungen",
|
||||
"queued": "{added} zur Warteschlange hinzugefügt; {queued} warten{state}",
|
||||
"queuedRunning": " (wird verarbeitet)",
|
||||
"libraryInfoNoPath": "library.info() hat keinen Pfad zurückgegeben: {info}",
|
||||
"libraryInfoFailed": "library.info() fehlgeschlagen: {error}",
|
||||
"switchingLibrary": "Bibliothek wird gewechselt: {path}",
|
||||
"noCreateEvent": "Kein Plugin-Erstellungsereignis erhalten; direkter Start wird versucht",
|
||||
"watching": "{path} wird überwacht ({count} vorhandene Elemente gelten als Verlauf)",
|
||||
"watchReadFailed": "Asset-Verzeichnis konnte nicht gelesen werden: {error}",
|
||||
"watchMissingDir": "Asset-Verzeichnis fehlt: {path}",
|
||||
"watchFailed": "Überwachung konnte nicht gestartet werden: {error}",
|
||||
"fixSuffixAction": "Zusammengesetzte Endung für {label} korrigieren",
|
||||
"renderAction": "{label} rendern",
|
||||
"setThumbnailAction": "Vorschaubild für {label} setzen",
|
||||
"thumbnailDetailsAnimated": "Animiert {size}@{fps}fps/q{quality}/{kb}KB (Ziel {target}KB)/{frames} Frames/Animation {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "Statisch {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "schnellste Methode",
|
||||
"bestSize": "kleinste Datei",
|
||||
"fallbackNote": " ({text})",
|
||||
"fallbackNoAnimation": "das Skelett hat keine Animationen",
|
||||
"fallbackAnimatedFailed": "animierte Erstellung fehlgeschlagen: {error}",
|
||||
"limitNote": " (auch das kleinste Profil überschreitet das Ziel; erhöhe gegebenenfalls die Vorschaubildgröße)",
|
||||
"runSummary": "{generated} erfolgreich, {failed} fehlgeschlagen, {skipped} übersprungen in {seconds} Sekunden."
|
||||
},
|
||||
"progress": {
|
||||
"title": "Spine-Vorschaubilder",
|
||||
"generating": "Animierte Vorschaubilder werden erstellt…",
|
||||
"waiting": "Warte auf Start…",
|
||||
"confirmPrompt": "Diesen Lauf abbrechen?",
|
||||
"resume": "Fortsetzen",
|
||||
"cancel": "Abbruch bestätigen",
|
||||
"stop": "Stoppen",
|
||||
"confirm": "Fertig",
|
||||
"generatedConfirm": "{done} Vorschaubilder erstellt. Lauf abbrechen?",
|
||||
"complete": "Abgeschlossen",
|
||||
"completed": "Abgeschlossen {done} / {total} ({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Spine Thumbnails"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Spine Thumbnails",
|
||||
"noticeIntro": "After importing Spine assets (.skel / .json + .atlas + textures), this plugin creates a skeleton preview for items that do not yet have a custom thumbnail.",
|
||||
"noticeSafety": "Only newly imported items are processed. Existing items are not scanned or changed, and custom thumbnails are never overwritten.",
|
||||
"noticeModes": "Animated uses the first animation and exports WebP. Still always uses the setup pose.",
|
||||
"noticeDefault": "Automatic generation is off by default. Enable it below when needed.",
|
||||
"autoToggle": "Generate thumbnails on import",
|
||||
"clear": "Clear log",
|
||||
"modeLabel": "Generation mode",
|
||||
"modeAnimated": "Animated (first animation)",
|
||||
"modeStatic": "Still (setup pose)",
|
||||
"strategyLabel": "Strategy",
|
||||
"strategySize": "Smallest file (slow)",
|
||||
"strategySpeed": "Fastest (quick)",
|
||||
"statusLabel": "Status",
|
||||
"statusStarting": "Starting…",
|
||||
"libraryLabel": "Library",
|
||||
"statsLabel": "Statistics",
|
||||
"filterAll": "All",
|
||||
"filterOk": "Success",
|
||||
"filterFail": "Failed",
|
||||
"filterWarn": "Warning",
|
||||
"filterError": "Error",
|
||||
"unknownError": "Unknown error",
|
||||
"scriptError": "Script error: {error}",
|
||||
"promiseError": "Unhandled Promise error: {error}",
|
||||
"uncategorized": "Uncategorized",
|
||||
"reasonQuiet": "quiet",
|
||||
"reasonFallback": "fallback scan",
|
||||
"none": "none",
|
||||
"complete": "complete",
|
||||
"incomplete": "incomplete",
|
||||
"statusLoaded": "Script {version} loaded; waiting for Eagle events…",
|
||||
"statusEagleMissing": "Eagle API not available",
|
||||
"statusListening": "Watching",
|
||||
"statusListenFailed": "Watch failed",
|
||||
"statusAutoOff": "Watching (automatic generation off)",
|
||||
"statusNoLibrary": "Library path unavailable",
|
||||
"statusPairing": "Pairing new assets…",
|
||||
"statusPairingProgress": "Pairing {current}/{total}",
|
||||
"statusRendering": "Rendering frame {current}/{total} ({queued} queued)",
|
||||
"statusGenerating": "Generating thumbnails… {queued} remaining",
|
||||
"statusPaused": "Paused (resume or cancel in the progress window)",
|
||||
"statusAutoSkipped": "Automatic generation is off; this batch was skipped",
|
||||
"statusStartFailed": "Startup failed: {error}",
|
||||
"stats": "Batches {batches} · Added {added} · Spine {spine} · Complete {complete} · Generated {generated} · Skipped {skipped} · Failed {failed}",
|
||||
"modulesMissing": "Local modules not loaded: {modules}",
|
||||
"moduleLoadError": "Local modules were not loaded. Check the script order in index.html: {modules}",
|
||||
"version": "Plugin code version {version}",
|
||||
"pluginCreated": "Plugin created: {name}",
|
||||
"settingsReadFailed": "Failed to read settings; using defaults (automatic generation off): {error}",
|
||||
"settingsSaveFailed": "Failed to save settings: {error}",
|
||||
"autoEnabled": "Automatic generation enabled. Newly imported Spine assets will receive thumbnails.",
|
||||
"autoDisabled": "Automatic generation disabled; the pending queue was cleared.",
|
||||
"modeStaticLog": "Generation mode: still (setup pose, PNG)",
|
||||
"modeAnimatedLog": "Generation mode: animated (first animation as WebP; setup pose if no animation exists)",
|
||||
"strategySpeedLog": "Strategy: fastest (estimate profiles and render only the smallest one or two)",
|
||||
"strategySizeLog": "Strategy: smallest file (render all ten profiles and keep the smallest result)",
|
||||
"missingItem": "Skipped {label}: item not found in Eagle",
|
||||
"suffixFixed": "Fixed compound suffix for {label} → {name}",
|
||||
"suffixFailed": "Failed to fix compound suffix for {label}: {error}",
|
||||
"existingThumbnail": "Skipped {label}: a custom thumbnail already exists",
|
||||
"thumbnailRejected": "Eagle rejected this thumbnail",
|
||||
"generated": "Generated {label} | {details} | Spine {version}",
|
||||
"generateFailed": "Generation failed for {label}: {error}",
|
||||
"cancelled": "Generation cancelled: discarded {dropped} pending items. Completed {done} (success {generated}, failed {failed}, skipped {skipped})",
|
||||
"batchFailed": "Failed to process import batch: {error}",
|
||||
"batchFinished": "Batch finished: {added} added, {total} in library ({reason})",
|
||||
"recheck": "Rechecking {count} previously incomplete assets",
|
||||
"noSpine": "No Spine assets in this batch",
|
||||
"incompletePending": "{count} assets are incomplete and will be rechecked after later imports",
|
||||
"incompleteIgnored": "{count} assets are incomplete and were not processed",
|
||||
"itemSummary": " {name} | {path} | {state} | skeleton {skeleton} | atlas {atlas} | pages {pages} (missing {missing})",
|
||||
"autoSkipped": "Automatic generation is off; skipped {count} assets in this batch",
|
||||
"queued": "Queued {added}; {queued} waiting{state}",
|
||||
"queuedRunning": " (processing)",
|
||||
"libraryInfoNoPath": "library.info() did not return a path: {info}",
|
||||
"libraryInfoFailed": "library.info() failed: {error}",
|
||||
"switchingLibrary": "Switching library: {path}",
|
||||
"noCreateEvent": "No plugin-create event received; trying to start directly",
|
||||
"watching": "Watching {path} ({count} existing items treated as history)",
|
||||
"watchReadFailed": "Failed to read the asset directory: {error}",
|
||||
"watchMissingDir": "Asset directory does not exist: {path}",
|
||||
"watchFailed": "Failed to start watching: {error}",
|
||||
"fixSuffixAction": "Fixing compound suffix for {label}",
|
||||
"renderAction": "Rendering {label}",
|
||||
"setThumbnailAction": "Setting thumbnail for {label}",
|
||||
"thumbnailDetailsAnimated": "Animated {size}@{fps}fps/q{quality}/{kb}KB (target {target}KB)/{frames} frames/animation {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "Still {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "fastest",
|
||||
"bestSize": "smallest file",
|
||||
"fallbackNote": " ({text})",
|
||||
"fallbackNoAnimation": "the skeleton has no animations",
|
||||
"fallbackAnimatedFailed": "animated generation failed: {error}",
|
||||
"limitNote": " (the smallest profile still exceeds the target; consider a larger thumbnail size)",
|
||||
"runSummary": "{generated} succeeded, {failed} failed, {skipped} skipped in {seconds} seconds."
|
||||
},
|
||||
"progress": {
|
||||
"title": "Spine Thumbnails",
|
||||
"generating": "Generating animated thumbnails…",
|
||||
"waiting": "Waiting to start…",
|
||||
"confirmPrompt": "Cancel this run?",
|
||||
"resume": "Continue",
|
||||
"cancel": "Confirm cancel",
|
||||
"stop": "Stop",
|
||||
"confirm": "Done",
|
||||
"generatedConfirm": "{done} thumbnails generated. Cancel this run?",
|
||||
"complete": "Complete",
|
||||
"completed": "Completed {done} / {total} ({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Miniaturas de Spine"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Miniaturas de Spine",
|
||||
"noticeIntro": "Después de importar recursos de Spine (.skel / .json + .atlas + texturas), este complemento crea una vista previa del esqueleto para los elementos que aún no tienen una miniatura personalizada.",
|
||||
"noticeSafety": "Solo se procesan los elementos recién importados. No se buscan ni modifican elementos existentes y nunca se sobrescriben las miniaturas personalizadas.",
|
||||
"noticeModes": "Animado usa la primera animación y exporta WebP. Estático siempre usa la pose de configuración.",
|
||||
"noticeDefault": "La generación automática está desactivada de forma predeterminada. Actívala abajo cuando la necesites.",
|
||||
"autoToggle": "Generar miniaturas al importar",
|
||||
"clear": "Borrar registro",
|
||||
"modeLabel": "Modo de generación",
|
||||
"modeAnimated": "Animado (primera animación)",
|
||||
"modeStatic": "Estático (pose de configuración)",
|
||||
"strategyLabel": "Estrategia",
|
||||
"strategySize": "Archivo más pequeño (lento)",
|
||||
"strategySpeed": "Más rápido (rápido)",
|
||||
"statusLabel": "Estado",
|
||||
"statusStarting": "Iniciando…",
|
||||
"libraryLabel": "Biblioteca",
|
||||
"statsLabel": "Estadísticas",
|
||||
"filterAll": "Todo",
|
||||
"filterOk": "Correcto",
|
||||
"filterFail": "Fallido",
|
||||
"filterWarn": "Aviso",
|
||||
"filterError": "Error",
|
||||
"unknownError": "Error desconocido",
|
||||
"scriptError": "Error de script: {error}",
|
||||
"promiseError": "Error de Promise no controlado: {error}",
|
||||
"uncategorized": "Sin categoría",
|
||||
"reasonQuiet": "fin de actividad",
|
||||
"reasonFallback": "exploración de respaldo",
|
||||
"none": "ninguno",
|
||||
"complete": "completo",
|
||||
"incomplete": "incompleto",
|
||||
"statusLoaded": "Script {version} cargado; esperando eventos de Eagle…",
|
||||
"statusEagleMissing": "API de Eagle no disponible",
|
||||
"statusListening": "Supervisando",
|
||||
"statusListenFailed": "Error de supervisión",
|
||||
"statusAutoOff": "Supervisando (generación automática desactivada)",
|
||||
"statusNoLibrary": "Ruta de la biblioteca no disponible",
|
||||
"statusPairing": "Emparejando recursos nuevos…",
|
||||
"statusPairingProgress": "Emparejando {current}/{total}",
|
||||
"statusRendering": "Renderizando fotograma {current}/{total} ({queued} en cola)",
|
||||
"statusGenerating": "Generando miniaturas… quedan {queued}",
|
||||
"statusPaused": "En pausa (continúa o cancela en la ventana de progreso)",
|
||||
"statusAutoSkipped": "La generación automática está desactivada; se omitió este lote",
|
||||
"statusStartFailed": "Error al iniciar: {error}",
|
||||
"stats": "Lotes {batches} · Añadidos {added} · Spine {spine} · Completos {complete} · Generados {generated} · Omitidos {skipped} · Fallidos {failed}",
|
||||
"modulesMissing": "Módulos locales no cargados: {modules}",
|
||||
"moduleLoadError": "No se cargaron los módulos locales. Revisa el orden de scripts en index.html: {modules}",
|
||||
"version": "Versión del código del complemento {version}",
|
||||
"pluginCreated": "Complemento creado: {name}",
|
||||
"settingsReadFailed": "No se pudieron leer los ajustes; se usarán los valores predeterminados (generación automática desactivada): {error}",
|
||||
"settingsSaveFailed": "No se pudieron guardar los ajustes: {error}",
|
||||
"autoEnabled": "Generación automática activada. Los nuevos recursos de Spine importados recibirán miniaturas.",
|
||||
"autoDisabled": "Generación automática desactivada; se vació la cola pendiente.",
|
||||
"modeStaticLog": "Modo de generación: estático (siempre pose de configuración, PNG)",
|
||||
"modeAnimatedLog": "Modo de generación: animado (primera animación como WebP; pose de configuración si no hay animación)",
|
||||
"strategySpeedLog": "Estrategia: más rápida (estimación y solo los perfiles más pequeños)",
|
||||
"strategySizeLog": "Estrategia: archivo más pequeño (genera los diez perfiles y conserva el menor)",
|
||||
"missingItem": "Se omitió {label}: elemento no encontrado en Eagle",
|
||||
"suffixFixed": "Extensión compuesta corregida para {label} → {name}",
|
||||
"suffixFailed": "No se pudo corregir la extensión compuesta de {label}: {error}",
|
||||
"existingThumbnail": "Se omitió {label}: ya existe una miniatura personalizada",
|
||||
"thumbnailRejected": "Eagle rechazó esta miniatura",
|
||||
"generated": "{label} generado | {details} | Spine {version}",
|
||||
"generateFailed": "Error al generar {label}: {error}",
|
||||
"cancelled": "Generación cancelada: se descartaron {dropped} pendientes. Completados {done} (correctos {generated}, fallidos {failed}, omitidos {skipped})",
|
||||
"batchFailed": "Error al procesar el lote importado: {error}",
|
||||
"batchFinished": "Lote finalizado: {added} añadidos, {total} en la biblioteca ({reason})",
|
||||
"recheck": "Revisando de nuevo {count} recursos previamente incompletos",
|
||||
"noSpine": "No hay recursos de Spine en este lote",
|
||||
"incompletePending": "{count} recursos están incompletos y se revisarán tras importaciones posteriores",
|
||||
"incompleteIgnored": "{count} recursos están incompletos y no se procesaron en este lote",
|
||||
"itemSummary": " {name} | {path} | {state} | esqueleto {skeleton} | atlas {atlas} | páginas {pages} (faltan {missing})",
|
||||
"autoSkipped": "La generación automática está desactivada; se omitieron {count} recursos de este lote",
|
||||
"queued": "{added} añadidos a la cola; {queued} esperando{state}",
|
||||
"queuedRunning": " (procesando)",
|
||||
"libraryInfoNoPath": "library.info() no devolvió una ruta: {info}",
|
||||
"libraryInfoFailed": "Error de library.info(): {error}",
|
||||
"switchingLibrary": "Cambiando de biblioteca: {path}",
|
||||
"noCreateEvent": "No se recibió el evento de creación; intentando iniciar directamente",
|
||||
"watching": "Supervisando {path} ({count} elementos existentes tratados como historial)",
|
||||
"watchReadFailed": "No se pudo leer el directorio de recursos: {error}",
|
||||
"watchMissingDir": "No existe el directorio de recursos: {path}",
|
||||
"watchFailed": "No se pudo iniciar la supervisión: {error}",
|
||||
"fixSuffixAction": "Corrigiendo extensión compuesta de {label}",
|
||||
"renderAction": "Renderizando {label}",
|
||||
"setThumbnailAction": "Estableciendo miniatura de {label}",
|
||||
"thumbnailDetailsAnimated": "Animado {size}@{fps}fps/q{quality}/{kb}KB (objetivo {target}KB)/{frames} fotogramas/animación {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "Estático {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "más rápido",
|
||||
"bestSize": "archivo más pequeño",
|
||||
"fallbackNote": " ({text})",
|
||||
"fallbackNoAnimation": "el esqueleto no tiene animaciones",
|
||||
"fallbackAnimatedFailed": "falló la generación animada: {error}",
|
||||
"limitNote": " (incluso el perfil más pequeño supera el objetivo; considera aumentar el tamaño de la miniatura)",
|
||||
"runSummary": "{generated} correctos, {failed} fallidos, {skipped} omitidos en {seconds} segundos."
|
||||
},
|
||||
"progress": {
|
||||
"title": "Miniaturas de Spine",
|
||||
"generating": "Generando miniaturas animadas…",
|
||||
"waiting": "Esperando para iniciar…",
|
||||
"confirmPrompt": "¿Cancelar esta ejecución?",
|
||||
"resume": "Continuar",
|
||||
"cancel": "Confirmar cancelación",
|
||||
"stop": "Detener",
|
||||
"confirm": "Listo",
|
||||
"generatedConfirm": "Se generaron {done} miniaturas. ¿Cancelar esta ejecución?",
|
||||
"complete": "Completado",
|
||||
"completed": "Completado {done} / {total} ({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Spineサムネイル"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Spineサムネイル",
|
||||
"noticeIntro": "Spine アセット(.skel / .json + .atlas + テクスチャ)を読み込んだ後、カスタムサムネイルがないアイテムにスケルトンプレビューを生成します。",
|
||||
"noticeSafety": "今回新しく読み込んだアイテムだけを処理し、既存アイテムの走査や変更、カスタムサムネイルの上書きは行いません。",
|
||||
"noticeModes": "アニメーションは最初のアニメーションを WebP に変換します。静止画は常に setup pose を使用します。",
|
||||
"noticeDefault": "自動生成は既定でオフです。必要なときに下で有効にしてください。",
|
||||
"autoToggle": "読み込み時にサムネイルを自動生成",
|
||||
"clear": "ログを消去",
|
||||
"modeLabel": "生成方式",
|
||||
"modeAnimated": "アニメーション(最初のアニメーション)",
|
||||
"modeStatic": "静止画(setup pose)",
|
||||
"strategyLabel": "生成戦略",
|
||||
"strategySize": "最小サイズ(低速)",
|
||||
"strategySpeed": "最速(高速)",
|
||||
"statusLabel": "状態",
|
||||
"statusStarting": "起動中…",
|
||||
"libraryLabel": "ライブラリ",
|
||||
"statsLabel": "統計",
|
||||
"filterAll": "すべて",
|
||||
"filterOk": "成功",
|
||||
"filterFail": "失敗",
|
||||
"filterWarn": "警告",
|
||||
"filterError": "エラー",
|
||||
"unknownError": "不明なエラー",
|
||||
"scriptError": "スクリプトエラー:{error}",
|
||||
"promiseError": "未処理の Promise エラー:{error}",
|
||||
"uncategorized": "未分類",
|
||||
"reasonQuiet": "静止検出",
|
||||
"reasonFallback": "フォールバック走査",
|
||||
"none": "なし",
|
||||
"complete": "完全",
|
||||
"incomplete": "不完全",
|
||||
"statusLoaded": "スクリプト {version} を読み込みました。Eagle イベントを待機しています…",
|
||||
"statusEagleMissing": "Eagle API が利用できません",
|
||||
"statusListening": "監視中",
|
||||
"statusListenFailed": "監視に失敗",
|
||||
"statusAutoOff": "監視中(自動生成はオフ)",
|
||||
"statusNoLibrary": "ライブラリのパスを取得できません",
|
||||
"statusPairing": "新しいアセットを照合中…",
|
||||
"statusPairingProgress": "照合中 {current}/{total}",
|
||||
"statusRendering": "フレームをレンダリング中 {current}/{total}(待機 {queued})",
|
||||
"statusGenerating": "サムネイルを生成中…残り {queued}",
|
||||
"statusPaused": "一時停止中(進捗ウィンドウで再開またはキャンセル)",
|
||||
"statusAutoSkipped": "自動生成がオフのため、このバッチをスキップしました",
|
||||
"statusStartFailed": "起動に失敗:{error}",
|
||||
"stats": "バッチ {batches} · 追加 {added} · Spine {spine} · 完全 {complete} · 生成済み {generated} · スキップ {skipped} · 失敗 {failed}",
|
||||
"modulesMissing": "ローカルモジュールが未読み込みです:{modules}",
|
||||
"moduleLoadError": "ローカルモジュールが読み込まれていません。index.html の script 順を確認してください:{modules}",
|
||||
"version": "プラグインコード {version}",
|
||||
"pluginCreated": "プラグインを初期化しました:{name}",
|
||||
"settingsReadFailed": "設定を読み込めないため既定値を使用します(自動生成オフ):{error}",
|
||||
"settingsSaveFailed": "設定を保存できません:{error}",
|
||||
"autoEnabled": "自動生成を有効にしました。今後読み込む Spine アセットにサムネイルを生成します。",
|
||||
"autoDisabled": "自動生成を無効にし、待機中のキューを消去しました。",
|
||||
"modeStaticLog": "生成方式:静止画(常に setup pose、PNG)",
|
||||
"modeAnimatedLog": "生成方式:アニメーション(最初のアニメーションを WebP 化し、なければ setup pose)",
|
||||
"strategySpeedLog": "生成戦略:最速(概算で選び、最小の 1~2 プロファイルのみ生成)",
|
||||
"strategySizeLog": "生成戦略:最小サイズ(全 10 プロファイルを生成し最小を採用)",
|
||||
"missingItem": "{label} をスキップ:Eagle にアイテムが見つかりません",
|
||||
"suffixFixed": "{label} の複合拡張子を修正 → {name}",
|
||||
"suffixFailed": "{label} の複合拡張子を修正できません:{error}",
|
||||
"existingThumbnail": "{label} をスキップ:カスタムサムネイルが既にあります",
|
||||
"thumbnailRejected": "Eagle がこのサムネイルを拒否しました",
|
||||
"generated": "{label} を生成 | {details} | Spine {version}",
|
||||
"generateFailed": "{label} の生成に失敗:{error}",
|
||||
"cancelled": "生成をキャンセル:残り {dropped} 件を破棄、完了 {done} 件(成功 {generated}、失敗 {failed}、スキップ {skipped})",
|
||||
"batchFailed": "読み込みバッチの処理に失敗:{error}",
|
||||
"batchFinished": "バッチ完了:{added} 件追加、ライブラリ内 {total} 件({reason})",
|
||||
"recheck": "以前の不完全なアセット {count} 件を再確認",
|
||||
"noSpine": "このバッチに Spine アセットはありません",
|
||||
"incompletePending": "不完全なアセットが {count} 件あります。後の読み込み後に再確認します",
|
||||
"incompleteIgnored": "不完全なアセットが {count} 件あり、このバッチでは処理しません",
|
||||
"itemSummary": " {name} | {path} | {state} | スケルトン {skeleton} | atlas {atlas} | ページ {pages}(不足 {missing})",
|
||||
"autoSkipped": "自動生成がオフのため、このバッチの {count} 件をスキップしました",
|
||||
"queued": "{added} 件をキューに追加、待機中 {queued} 件{state}",
|
||||
"queuedRunning": "(処理中)",
|
||||
"libraryInfoNoPath": "library.info() がパスを返しません:{info}",
|
||||
"libraryInfoFailed": "library.info() の呼び出しに失敗:{error}",
|
||||
"switchingLibrary": "ライブラリを切り替え:{path}",
|
||||
"noCreateEvent": "作成イベントを受信できないため、直接起動します",
|
||||
"watching": "{path} を監視中(既存 {count} 件は履歴として扱い、処理しません)",
|
||||
"watchReadFailed": "アセットディレクトリを読み込めません:{error}",
|
||||
"watchMissingDir": "アセットディレクトリがありません:{path}",
|
||||
"watchFailed": "監視を開始できません:{error}",
|
||||
"fixSuffixAction": "{label} の複合拡張子を修正",
|
||||
"renderAction": "{label} をレンダリング",
|
||||
"setThumbnailAction": "{label} のサムネイルを設定",
|
||||
"thumbnailDetailsAnimated": "アニメーション {size}@{fps}fps/q{quality}/{kb}KB(目標 {target}KB)/ {frames} フレーム / アニメーション {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "静止画 {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "最速",
|
||||
"bestSize": "最小サイズ",
|
||||
"fallbackNote": "({text})",
|
||||
"fallbackNoAnimation": "スケルトンにアニメーションがありません",
|
||||
"fallbackAnimatedFailed": "アニメーション生成に失敗:{error}",
|
||||
"limitNote": "(最小プロファイルでも目標サイズを超えています。サムネイルサイズを上げてください)",
|
||||
"runSummary": "成功 {generated}、失敗 {failed}、スキップ {skipped}、所要 {seconds} 秒。"
|
||||
},
|
||||
"progress": {
|
||||
"title": "Spineサムネイル",
|
||||
"generating": "アニメーションサムネイルを生成中……",
|
||||
"waiting": "開始待ち…",
|
||||
"confirmPrompt": "キャンセルしますか?",
|
||||
"resume": "続行",
|
||||
"cancel": "キャンセル確定",
|
||||
"stop": "停止",
|
||||
"confirm": "確認",
|
||||
"generatedConfirm": "{done} 枚生成しました。キャンセルしますか?",
|
||||
"complete": "完了",
|
||||
"completed": "完了 {done} / {total}({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Spine 썸네일"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Spine 썸네일",
|
||||
"noticeIntro": "Spine 에셋(.skel / .json + .atlas + 텍스처)을 가져온 뒤 사용자 지정 썸네일이 없는 항목에 스켈레톤 미리보기를 생성합니다.",
|
||||
"noticeSafety": "이번에 새로 가져온 항목만 처리합니다. 기존 항목을 검색하거나 수정하지 않으며 사용자 지정 썸네일을 덮어쓰지 않습니다.",
|
||||
"noticeModes": "애니메이션은 첫 번째 애니메이션을 WebP로 만듭니다. 정지 이미지는 항상 setup pose를 사용합니다.",
|
||||
"noticeDefault": "자동 생성은 기본적으로 꺼져 있습니다. 필요할 때 아래에서 켜세요.",
|
||||
"autoToggle": "가져올 때 썸네일 자동 생성",
|
||||
"clear": "로그 지우기",
|
||||
"modeLabel": "생성 방식",
|
||||
"modeAnimated": "애니메이션(첫 번째 애니메이션)",
|
||||
"modeStatic": "정지 이미지(setup pose)",
|
||||
"strategyLabel": "생성 전략",
|
||||
"strategySize": "최소 파일(느림)",
|
||||
"strategySpeed": "최고 속도(빠름)",
|
||||
"statusLabel": "상태",
|
||||
"statusStarting": "시작 중…",
|
||||
"libraryLabel": "라이브러리",
|
||||
"statsLabel": "통계",
|
||||
"filterAll": "전체",
|
||||
"filterOk": "성공",
|
||||
"filterFail": "실패",
|
||||
"filterWarn": "경고",
|
||||
"filterError": "오류",
|
||||
"unknownError": "알 수 없는 오류",
|
||||
"scriptError": "스크립트 오류: {error}",
|
||||
"promiseError": "처리되지 않은 Promise 오류: {error}",
|
||||
"uncategorized": "미분류",
|
||||
"reasonQuiet": "정적 감지",
|
||||
"reasonFallback": "대체 검색",
|
||||
"none": "없음",
|
||||
"complete": "완전",
|
||||
"incomplete": "불완전",
|
||||
"statusLoaded": "스크립트 {version}을(를) 불러왔습니다. Eagle 이벤트를 기다리는 중…",
|
||||
"statusEagleMissing": "Eagle API를 사용할 수 없음",
|
||||
"statusListening": "감시 중",
|
||||
"statusListenFailed": "감시 실패",
|
||||
"statusAutoOff": "감시 중(자동 생성 꺼짐)",
|
||||
"statusNoLibrary": "라이브러리 경로를 가져올 수 없음",
|
||||
"statusPairing": "새 에셋 연결 중…",
|
||||
"statusPairingProgress": "연결 중 {current}/{total}",
|
||||
"statusRendering": "프레임 렌더링 중 {current}/{total}(대기 {queued})",
|
||||
"statusGenerating": "썸네일 생성 중… 남은 항목 {queued}",
|
||||
"statusPaused": "일시 정지됨(진행 창에서 계속하거나 취소하세요)",
|
||||
"statusAutoSkipped": "자동 생성이 꺼져 있어 이번 묶음을 건너뛰었습니다",
|
||||
"statusStartFailed": "시작 실패: {error}",
|
||||
"stats": "묶음 {batches} · 추가 {added} · Spine {spine} · 완전 {complete} · 생성 {generated} · 건너뜀 {skipped} · 실패 {failed}",
|
||||
"modulesMissing": "로컬 모듈이 로드되지 않음: {modules}",
|
||||
"moduleLoadError": "로컬 모듈이 로드되지 않았습니다. index.html의 script 순서를 확인하세요: {modules}",
|
||||
"version": "플러그인 코드 버전 {version}",
|
||||
"pluginCreated": "플러그인 생성됨: {name}",
|
||||
"settingsReadFailed": "설정을 읽지 못해 기본값을 사용합니다(자동 생성 꺼짐): {error}",
|
||||
"settingsSaveFailed": "설정 저장 실패: {error}",
|
||||
"autoEnabled": "자동 생성을 켰습니다. 새로 가져오는 Spine 에셋에 썸네일을 생성합니다.",
|
||||
"autoDisabled": "자동 생성을 껐으며 대기 큐를 비웠습니다.",
|
||||
"modeStaticLog": "생성 방식: 정지 이미지(항상 setup pose, PNG)",
|
||||
"modeAnimatedLog": "생성 방식: 애니메이션(첫 애니메이션을 WebP로 만들고 없으면 setup pose 사용)",
|
||||
"strategySpeedLog": "생성 전략: 최고 속도(대략 선택 후 가장 작은 1~2개 프로필만 생성)",
|
||||
"strategySizeLog": "생성 전략: 최소 파일(10개 프로필을 모두 생성하고 가장 작은 결과 선택)",
|
||||
"missingItem": "{label} 건너뜀: Eagle에서 항목을 찾을 수 없음",
|
||||
"suffixFixed": "{label}의 복합 확장자 수정 → {name}",
|
||||
"suffixFailed": "{label}의 복합 확장자 수정 실패: {error}",
|
||||
"existingThumbnail": "{label} 건너뜀: 사용자 지정 썸네일이 이미 있음",
|
||||
"thumbnailRejected": "Eagle이 이 썸네일을 거부했습니다",
|
||||
"generated": "{label} 생성됨 | {details} | Spine {version}",
|
||||
"generateFailed": "{label} 생성 실패: {error}",
|
||||
"cancelled": "생성 취소: 남은 {dropped}개를 버렸습니다. 완료 {done}개(성공 {generated}, 실패 {failed}, 건너뜀 {skipped})",
|
||||
"batchFailed": "가져오기 묶음 처리 실패: {error}",
|
||||
"batchFinished": "묶음 완료: {added}개 추가, 라이브러리 전체 {total}개({reason})",
|
||||
"recheck": "이전 불완전 에셋 {count}개 다시 확인",
|
||||
"noSpine": "이번 묶음에 Spine 에셋이 없습니다",
|
||||
"incompletePending": "불완전한 에셋 {count}개를 이후 가져오기 후 다시 확인합니다",
|
||||
"incompleteIgnored": "불완전한 에셋 {count}개가 있으며 이번 묶음에서는 처리하지 않습니다",
|
||||
"itemSummary": " {name} | {path} | {state} | 스켈레톤 {skeleton} | atlas {atlas} | 페이지 {pages}(누락 {missing})",
|
||||
"autoSkipped": "자동 생성이 꺼져 있어 이번 묶음의 에셋 {count}개를 건너뛰었습니다",
|
||||
"queued": "{added}개를 큐에 추가했습니다. 현재 대기 {queued}개{state}",
|
||||
"queuedRunning": "(처리 중)",
|
||||
"libraryInfoNoPath": "library.info()가 경로를 반환하지 않음: {info}",
|
||||
"libraryInfoFailed": "library.info() 호출 실패: {error}",
|
||||
"switchingLibrary": "라이브러리 전환: {path}",
|
||||
"noCreateEvent": "생성 이벤트를 받지 못해 직접 시작합니다",
|
||||
"watching": "{path} 감시 중(기존 항목 {count}개는 기록으로 간주하여 처리하지 않음)",
|
||||
"watchReadFailed": "에셋 디렉터리를 읽지 못함: {error}",
|
||||
"watchMissingDir": "에셋 디렉터리가 없음: {path}",
|
||||
"watchFailed": "감시 시작 실패: {error}",
|
||||
"fixSuffixAction": "{label}의 복합 확장자 수정",
|
||||
"renderAction": "{label} 렌더링",
|
||||
"setThumbnailAction": "{label} 썸네일 설정",
|
||||
"thumbnailDetailsAnimated": "애니메이션 {size}@{fps}fps/q{quality}/{kb}KB(목표 {target}KB)/ {frames}프레임 / 애니메이션 {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "정지 이미지 {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "최고 속도",
|
||||
"bestSize": "최소 파일",
|
||||
"fallbackNote": "({text})",
|
||||
"fallbackNoAnimation": "스켈레톤에 애니메이션이 없습니다",
|
||||
"fallbackAnimatedFailed": "애니메이션 생성 실패: {error}",
|
||||
"limitNote": "(가장 작은 프로필도 목표 크기를 초과합니다. 썸네일 크기를 늘리세요)",
|
||||
"runSummary": "성공 {generated}, 실패 {failed}, 건너뜀 {skipped}, {seconds}초 소요."
|
||||
},
|
||||
"progress": {
|
||||
"title": "Spine 썸네일",
|
||||
"generating": "애니메이션 썸네일 생성 중……",
|
||||
"waiting": "시작 대기 중…",
|
||||
"confirmPrompt": "이번 작업을 취소할까요?",
|
||||
"resume": "계속",
|
||||
"cancel": "취소 확인",
|
||||
"stop": "중지",
|
||||
"confirm": "확인",
|
||||
"generatedConfirm": "썸네일 {done}개를 생성했습니다. 취소할까요?",
|
||||
"complete": "완료",
|
||||
"completed": "완료 {done} / {total}({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Миниатюры Spine"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Миниатюры Spine",
|
||||
"noticeIntro": "После импорта ресурсов Spine (.skel / .json + .atlas + текстуры) плагин создаёт предпросмотр скелета для элементов без пользовательской миниатюры.",
|
||||
"noticeSafety": "Обрабатываются только новые элементы. Существующие элементы не сканируются и не изменяются, пользовательские миниатюры никогда не перезаписываются.",
|
||||
"noticeModes": "Анимированный режим использует первую анимацию и экспортирует WebP. Статический всегда использует setup pose.",
|
||||
"noticeDefault": "Автоматическое создание по умолчанию отключено. Включите его ниже при необходимости.",
|
||||
"autoToggle": "Создавать миниатюры при импорте",
|
||||
"clear": "Очистить журнал",
|
||||
"modeLabel": "Режим создания",
|
||||
"modeAnimated": "Анимированный (первая анимация)",
|
||||
"modeStatic": "Статический (setup pose)",
|
||||
"strategyLabel": "Стратегия",
|
||||
"strategySize": "Минимальный файл (медленно)",
|
||||
"strategySpeed": "Максимальная скорость (быстро)",
|
||||
"statusLabel": "Состояние",
|
||||
"statusStarting": "Запуск…",
|
||||
"libraryLabel": "Библиотека",
|
||||
"statsLabel": "Статистика",
|
||||
"filterAll": "Все",
|
||||
"filterOk": "Успешно",
|
||||
"filterFail": "Ошибки",
|
||||
"filterWarn": "Предупреждения",
|
||||
"filterError": "Ошибка",
|
||||
"unknownError": "Неизвестная ошибка",
|
||||
"scriptError": "Ошибка скрипта: {error}",
|
||||
"promiseError": "Необработанная ошибка Promise: {error}",
|
||||
"uncategorized": "Без категории",
|
||||
"reasonQuiet": "после завершения импорта",
|
||||
"reasonFallback": "резервное сканирование",
|
||||
"none": "нет",
|
||||
"complete": "полный",
|
||||
"incomplete": "неполный",
|
||||
"statusLoaded": "Скрипт {version} загружен; ожидание событий Eagle…",
|
||||
"statusEagleMissing": "API Eagle недоступен",
|
||||
"statusListening": "Наблюдение",
|
||||
"statusListenFailed": "Ошибка наблюдения",
|
||||
"statusAutoOff": "Наблюдение (автосоздание выключено)",
|
||||
"statusNoLibrary": "Путь к библиотеке недоступен",
|
||||
"statusPairing": "Сопоставление новых ресурсов…",
|
||||
"statusPairingProgress": "Сопоставление {current}/{total}",
|
||||
"statusRendering": "Рендеринг кадра {current}/{total} (в очереди {queued})",
|
||||
"statusGenerating": "Создание миниатюр… осталось {queued}",
|
||||
"statusPaused": "Пауза (продолжите или отмените в окне прогресса)",
|
||||
"statusAutoSkipped": "Автосоздание выключено; этот пакет пропущен",
|
||||
"statusStartFailed": "Ошибка запуска: {error}",
|
||||
"stats": "Пакеты {batches} · Добавлено {added} · Spine {spine} · Полные {complete} · Создано {generated} · Пропущено {skipped} · Ошибок {failed}",
|
||||
"modulesMissing": "Локальные модули не загружены: {modules}",
|
||||
"moduleLoadError": "Локальные модули не загружены. Проверьте порядок скриптов в index.html: {modules}",
|
||||
"version": "Версия кода плагина {version}",
|
||||
"pluginCreated": "Плагин создан: {name}",
|
||||
"settingsReadFailed": "Не удалось прочитать настройки; используются значения по умолчанию (автосоздание выключено): {error}",
|
||||
"settingsSaveFailed": "Не удалось сохранить настройки: {error}",
|
||||
"autoEnabled": "Автосоздание включено. Для новых импортированных ресурсов Spine будут созданы миниатюры.",
|
||||
"autoDisabled": "Автосоздание выключено; очередь ожидания очищена.",
|
||||
"modeStaticLog": "Режим создания: статический (всегда setup pose, PNG)",
|
||||
"modeAnimatedLog": "Режим создания: анимированный (первая анимация в WebP; setup pose без анимации)",
|
||||
"strategySpeedLog": "Стратегия: максимальная скорость (примерный выбор, только один-два наименьших профиля)",
|
||||
"strategySizeLog": "Стратегия: минимальный файл (все десять профилей и выбор наименьшего)",
|
||||
"missingItem": "{label} пропущен: элемент не найден в Eagle",
|
||||
"suffixFixed": "Составное расширение исправлено для {label} → {name}",
|
||||
"suffixFailed": "Не удалось исправить составное расширение {label}: {error}",
|
||||
"existingThumbnail": "{label} пропущен: уже есть пользовательская миниатюра",
|
||||
"thumbnailRejected": "Eagle отклонил эту миниатюру",
|
||||
"generated": "{label} создан | {details} | Spine {version}",
|
||||
"generateFailed": "Ошибка создания {label}: {error}",
|
||||
"cancelled": "Создание отменено: отброшено ожидающих {dropped}. Завершено {done} (успешно {generated}, ошибок {failed}, пропущено {skipped})",
|
||||
"batchFailed": "Ошибка обработки пакета импорта: {error}",
|
||||
"batchFinished": "Пакет завершён: добавлено {added}, в библиотеке {total} ({reason})",
|
||||
"recheck": "Повторная проверка ранее неполных ресурсов: {count}",
|
||||
"noSpine": "В этом пакете нет ресурсов Spine",
|
||||
"incompletePending": "Неполных ресурсов: {count}; они будут проверены после следующих импортов",
|
||||
"incompleteIgnored": "Неполных ресурсов: {count}; в этом пакете они не обрабатываются",
|
||||
"itemSummary": " {name} | {path} | {state} | скелет {skeleton} | atlas {atlas} | страниц {pages} (не хватает {missing})",
|
||||
"autoSkipped": "Автосоздание выключено; пропущено ресурсов в пакете: {count}",
|
||||
"queued": "Добавлено в очередь: {added}; ожидают {queued}{state}",
|
||||
"queuedRunning": " (обработка)",
|
||||
"libraryInfoNoPath": "library.info() не вернул путь: {info}",
|
||||
"libraryInfoFailed": "Ошибка вызова library.info(): {error}",
|
||||
"switchingLibrary": "Переключение библиотеки: {path}",
|
||||
"noCreateEvent": "Событие создания не получено; выполняется прямой запуск",
|
||||
"watching": "Наблюдение за {path} (существующие элементы {count} считаются историей)",
|
||||
"watchReadFailed": "Не удалось прочитать каталог ресурсов: {error}",
|
||||
"watchMissingDir": "Каталог ресурсов не существует: {path}",
|
||||
"watchFailed": "Не удалось начать наблюдение: {error}",
|
||||
"fixSuffixAction": "Исправление составного расширения {label}",
|
||||
"renderAction": "Рендеринг {label}",
|
||||
"setThumbnailAction": "Установка миниатюры {label}",
|
||||
"thumbnailDetailsAnimated": "Анимация {size}@{fps}fps/q{quality}/{kb}KB (цель {target}KB)/{frames} кадров/анимация {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "Статическая {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "максимальная скорость",
|
||||
"bestSize": "минимальный файл",
|
||||
"fallbackNote": " ({text})",
|
||||
"fallbackNoAnimation": "в скелете нет анимаций",
|
||||
"fallbackAnimatedFailed": "ошибка анимированного создания: {error}",
|
||||
"limitNote": " (даже минимальный профиль превышает целевой размер; увеличьте размер миниатюры)",
|
||||
"runSummary": "Успешно {generated}, ошибок {failed}, пропущено {skipped}, время {seconds} с."
|
||||
},
|
||||
"progress": {
|
||||
"title": "Миниатюры Spine",
|
||||
"generating": "Создание анимированных миниатюр…",
|
||||
"waiting": "Ожидание запуска…",
|
||||
"confirmPrompt": "Отменить этот запуск?",
|
||||
"resume": "Продолжить",
|
||||
"cancel": "Подтвердить отмену",
|
||||
"stop": "Остановить",
|
||||
"confirm": "Готово",
|
||||
"generatedConfirm": "Создано миниатюр: {done}. Отменить запуск?",
|
||||
"complete": "Завершено",
|
||||
"completed": "Завершено {done} / {total} ({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Spine缩略图"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Spine缩略图",
|
||||
"noticeIntro": "导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。",
|
||||
"noticeSafety": "它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。",
|
||||
"noticeModes": "动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。",
|
||||
"noticeDefault": "自动生成默认关闭,需要你在下面手动启用。",
|
||||
"autoToggle": "导入时自动生成缩略图",
|
||||
"clear": "清空日志",
|
||||
"modeLabel": "生成方式",
|
||||
"modeAnimated": "动态(第一个动画)",
|
||||
"modeStatic": "静帧(setup pose)",
|
||||
"strategyLabel": "生成策略",
|
||||
"strategySize": "最优体积(慢)",
|
||||
"strategySpeed": "最优速度(快)",
|
||||
"statusLabel": "状态",
|
||||
"statusStarting": "启动中…",
|
||||
"libraryLabel": "素材库",
|
||||
"statsLabel": "统计",
|
||||
"filterAll": "全部",
|
||||
"filterOk": "成功",
|
||||
"filterFail": "失败",
|
||||
"filterWarn": "警告",
|
||||
"filterError": "错误",
|
||||
"unknownError": "未知错误",
|
||||
"scriptError": "脚本错误:{error}",
|
||||
"promiseError": "未处理的 Promise 错误:{error}",
|
||||
"uncategorized": "未分类",
|
||||
"reasonQuiet": "静默",
|
||||
"reasonFallback": "兜底扫描",
|
||||
"none": "无",
|
||||
"complete": "完整",
|
||||
"incomplete": "不完整",
|
||||
"statusLoaded": "脚本已加载 {version},等待 Eagle 事件…",
|
||||
"statusEagleMissing": "Eagle API 未注入",
|
||||
"statusListening": "监听中",
|
||||
"statusListenFailed": "监听失败",
|
||||
"statusAutoOff": "监听中(自动生成已关闭)",
|
||||
"statusNoLibrary": "没有拿到素材库路径",
|
||||
"statusPairing": "正在配对新增素材…",
|
||||
"statusPairingProgress": "正在配对 {current}/{total}",
|
||||
"statusRendering": "正在渲染动画帧 {current}/{total}(排队 {queued} 个)",
|
||||
"statusGenerating": "正在生成缩略图…剩余 {queued} 个",
|
||||
"statusPaused": "已暂停(在进度弹窗里继续或取消)",
|
||||
"statusAutoSkipped": "自动生成未启用,本批已跳过",
|
||||
"statusStartFailed": "启动失败:{error}",
|
||||
"stats": "批次 {batches} · 新增 {added} · Spine {spine} · 完整 {complete} · 已生成 {generated} · 跳过 {skipped} · 失败 {failed}",
|
||||
"modulesMissing": "本地模块未加载:{modules}",
|
||||
"moduleLoadError": "本地模块未加载,请检查 index.html 里 script 的顺序:{modules}",
|
||||
"version": "插件代码版本 {version}",
|
||||
"pluginCreated": "插件已创建:{name}",
|
||||
"settingsReadFailed": "读取设置失败,按默认值处理(关闭):{error}",
|
||||
"settingsSaveFailed": "保存设置失败:{error}",
|
||||
"autoEnabled": "自动生成已启用。之后导入的 Spine 资产会自动生成缩略图。",
|
||||
"autoDisabled": "自动生成已关闭,待处理队列已清空。",
|
||||
"modeStaticLog": "生成方式:静帧(始终 setup pose,PNG)",
|
||||
"modeAnimatedLog": "生成方式:动态(取第一个动画压成 WebP,没有动画时回退 setup pose)",
|
||||
"strategySpeedLog": "生成策略:最优速度(粗估挑档,只生成最小的一两档)",
|
||||
"strategySizeLog": "生成策略:最优体积(十档全生成,取实际体积最小的那一档)",
|
||||
"missingItem": "跳过 {label}:Eagle 里找不到这个素材",
|
||||
"suffixFixed": "修正复合后缀 {label} → {name}",
|
||||
"suffixFailed": "修正复合后缀失败 {label}:{error}",
|
||||
"existingThumbnail": "跳过 {label}:已有自定义缩略图",
|
||||
"thumbnailRejected": "Eagle 拒绝了这个缩略图",
|
||||
"generated": "已生成 {label} | {details} | Spine {version}",
|
||||
"generateFailed": "生成失败 {label}:{error}",
|
||||
"cancelled": "已取消本轮生成:丢弃剩余 {dropped} 个素材,已完成 {done} 张(成功 {generated} 张,失败 {failed} 张,跳过 {skipped} 张)",
|
||||
"batchFailed": "处理批次失败:{error}",
|
||||
"batchFinished": "这批结束:新增 {added} 个,库内共 {total} 个({reason})",
|
||||
"recheck": "连带重新检查之前 {count} 个配套不全的资产",
|
||||
"noSpine": "这批里没有 Spine 资产",
|
||||
"incompletePending": "有 {count} 个资产配套不全,先记为待观察,等后续批次补齐",
|
||||
"incompleteIgnored": "有 {count} 个资产配套不全,本批不处理",
|
||||
"itemSummary": " {name} | {path} | {state} | 骨架 {skeleton} | atlas {atlas} | 贴图页 {pages}(缺 {missing})",
|
||||
"autoSkipped": "自动生成已关闭,本批 {count} 个资产已跳过",
|
||||
"queued": "已加入队列 {added} 个,当前排队 {queued} 个{state}",
|
||||
"queuedRunning": "(正在处理中)",
|
||||
"libraryInfoNoPath": "library.info() 没有返回路径字段:{info}",
|
||||
"libraryInfoFailed": "library.info() 调用失败:{error}",
|
||||
"switchingLibrary": "切换素材库:{path}",
|
||||
"noCreateEvent": "没有收到创建事件,直接尝试启动",
|
||||
"watching": "监听 {path}(现有 {count} 个素材视为历史,不处理)",
|
||||
"watchReadFailed": "读取素材目录失败:{error}",
|
||||
"watchMissingDir": "素材目录不存在:{path}",
|
||||
"watchFailed": "建立监听失败:{error}",
|
||||
"fixSuffixAction": "修正复合后缀 {label}",
|
||||
"renderAction": "渲染 {label}",
|
||||
"setThumbnailAction": "设置缩略图 {label}",
|
||||
"thumbnailDetailsAnimated": "动态 {size}@{fps}fps/q{quality}/{kb}kb(目标 {target}kb)/{frames} 帧/动画 {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "静帧 {width}*{height}/{kb}kb{fallback}",
|
||||
"bestSpeed": "最优速度",
|
||||
"bestSize": "最优体积",
|
||||
"fallbackNote": "({text})",
|
||||
"fallbackNoAnimation": "骨骼里没有动画",
|
||||
"fallbackAnimatedFailed": "动态生成失败:{error}",
|
||||
"limitNote": "(最小档仍超出目标体积,可以考虑提高缩略图大小)",
|
||||
"runSummary": "成功 {generated} 张,失败 {failed} 张,跳过 {skipped} 张,用时 {seconds} 秒。"
|
||||
},
|
||||
"progress": {
|
||||
"title": "Spine缩略图",
|
||||
"generating": "正在生成动态缩略图……",
|
||||
"waiting": "等待开始…",
|
||||
"confirmPrompt": "是否确认取消?",
|
||||
"resume": "继续",
|
||||
"cancel": "确认取消",
|
||||
"stop": "停止",
|
||||
"confirm": "确认",
|
||||
"generatedConfirm": "已生成 {done} 张缩略图,是否确认取消?",
|
||||
"complete": "已完成",
|
||||
"completed": "已完成 {done} / {total}({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
{
|
||||
"manifest": {
|
||||
"app": {
|
||||
"name": "Spine縮圖"
|
||||
}
|
||||
},
|
||||
"thumb": {
|
||||
"title": "Spine縮圖",
|
||||
"noticeIntro": "匯入 Spine 資產(.skel / .json + .atlas + 貼圖)後,此外掛會為尚未設定縮圖的素材產生骨骼預覽圖。",
|
||||
"noticeSafety": "只處理本次新匯入的素材,不掃描或修改歷史素材,也不會覆寫你已設定的自訂縮圖。",
|
||||
"noticeModes": "動態會取第一個動畫並壓成 WebP;靜態一律使用 setup pose。",
|
||||
"noticeDefault": "自動產生預設為關閉,需要時請在下方手動啟用。",
|
||||
"autoToggle": "匯入時自動產生縮圖",
|
||||
"clear": "清除記錄",
|
||||
"modeLabel": "產生方式",
|
||||
"modeAnimated": "動態(第一個動畫)",
|
||||
"modeStatic": "靜態(setup pose)",
|
||||
"strategyLabel": "產生策略",
|
||||
"strategySize": "最小檔案(慢)",
|
||||
"strategySpeed": "最快速度(快速)",
|
||||
"statusLabel": "狀態",
|
||||
"statusStarting": "啟動中…",
|
||||
"libraryLabel": "素材庫",
|
||||
"statsLabel": "統計",
|
||||
"filterAll": "全部",
|
||||
"filterOk": "成功",
|
||||
"filterFail": "失敗",
|
||||
"filterWarn": "警告",
|
||||
"filterError": "錯誤",
|
||||
"unknownError": "未知錯誤",
|
||||
"scriptError": "指令碼錯誤:{error}",
|
||||
"promiseError": "未處理的 Promise 錯誤:{error}",
|
||||
"uncategorized": "未分類",
|
||||
"reasonQuiet": "靜默",
|
||||
"reasonFallback": "備援掃描",
|
||||
"none": "無",
|
||||
"complete": "完整",
|
||||
"incomplete": "不完整",
|
||||
"statusLoaded": "指令碼已載入 {version},等待 Eagle 事件…",
|
||||
"statusEagleMissing": "未注入 Eagle API",
|
||||
"statusListening": "監聽中",
|
||||
"statusListenFailed": "監聽失敗",
|
||||
"statusAutoOff": "監聽中(自動產生已關閉)",
|
||||
"statusNoLibrary": "無法取得素材庫路徑",
|
||||
"statusPairing": "正在配對新增素材…",
|
||||
"statusPairingProgress": "正在配對 {current}/{total}",
|
||||
"statusRendering": "正在算繪動畫影格 {current}/{total}(佇列 {queued} 個)",
|
||||
"statusGenerating": "正在產生縮圖…剩餘 {queued} 個",
|
||||
"statusPaused": "已暫停(請在進度視窗繼續或取消)",
|
||||
"statusAutoSkipped": "未啟用自動產生,已略過本批素材",
|
||||
"statusStartFailed": "啟動失敗:{error}",
|
||||
"stats": "批次 {batches} · 新增 {added} · Spine {spine} · 完整 {complete} · 已產生 {generated} · 略過 {skipped} · 失敗 {failed}",
|
||||
"modulesMissing": "本機模組未載入:{modules}",
|
||||
"moduleLoadError": "本機模組未載入,請檢查 index.html 的 script 順序:{modules}",
|
||||
"version": "外掛程式碼版本 {version}",
|
||||
"pluginCreated": "已建立外掛:{name}",
|
||||
"settingsReadFailed": "讀取設定失敗,改用預設值(關閉):{error}",
|
||||
"settingsSaveFailed": "儲存設定失敗:{error}",
|
||||
"autoEnabled": "已啟用自動產生。之後匯入的 Spine 資產會自動產生縮圖。",
|
||||
"autoDisabled": "已關閉自動產生,待處理佇列已清除。",
|
||||
"modeStaticLog": "產生方式:靜態(一律 setup pose,PNG)",
|
||||
"modeAnimatedLog": "產生方式:動態(第一個動畫轉為 WebP,沒有動畫時使用 setup pose)",
|
||||
"strategySpeedLog": "產生策略:最快速度(粗估後只產生最小的一到兩檔)",
|
||||
"strategySizeLog": "產生策略:最小檔案(產生全部十檔並保留最小結果)",
|
||||
"missingItem": "略過 {label}:Eagle 中找不到這個素材",
|
||||
"suffixFixed": "已修正複合副檔名 {label} → {name}",
|
||||
"suffixFailed": "修正複合副檔名失敗 {label}:{error}",
|
||||
"existingThumbnail": "略過 {label}:已有自訂縮圖",
|
||||
"thumbnailRejected": "Eagle 拒絕了這個縮圖",
|
||||
"generated": "已產生 {label} | {details} | Spine {version}",
|
||||
"generateFailed": "產生失敗 {label}:{error}",
|
||||
"cancelled": "已取消本輪產生:丟棄剩餘 {dropped} 個素材,已完成 {done} 張(成功 {generated} 張,失敗 {failed} 張,略過 {skipped} 張)",
|
||||
"batchFailed": "處理批次失敗:{error}",
|
||||
"batchFinished": "本批結束:新增 {added} 個,素材庫共 {total} 個({reason})",
|
||||
"recheck": "重新檢查先前 {count} 個不完整的資產",
|
||||
"noSpine": "本批沒有 Spine 資產",
|
||||
"incompletePending": "有 {count} 個資產不完整,將在後續批次重新檢查",
|
||||
"incompleteIgnored": "有 {count} 個資產不完整,本批未處理",
|
||||
"itemSummary": " {name} | {path} | {state} | 骨架 {skeleton} | atlas {atlas} | 貼圖頁 {pages}(缺少 {missing})",
|
||||
"autoSkipped": "自動產生已關閉,本批已略過 {count} 個資產",
|
||||
"queued": "已加入佇列 {added} 個,目前等待 {queued} 個{state}",
|
||||
"queuedRunning": "(處理中)",
|
||||
"libraryInfoNoPath": "library.info() 未傳回路徑:{info}",
|
||||
"libraryInfoFailed": "library.info() 呼叫失敗:{error}",
|
||||
"switchingLibrary": "切換素材庫:{path}",
|
||||
"noCreateEvent": "未收到建立事件,直接嘗試啟動",
|
||||
"watching": "監聽 {path}(現有 {count} 個素材視為歷史資料,不處理)",
|
||||
"watchReadFailed": "讀取素材目錄失敗:{error}",
|
||||
"watchMissingDir": "素材目錄不存在:{path}",
|
||||
"watchFailed": "建立監聽失敗:{error}",
|
||||
"fixSuffixAction": "修正複合副檔名 {label}",
|
||||
"renderAction": "算繪 {label}",
|
||||
"setThumbnailAction": "設定縮圖 {label}",
|
||||
"thumbnailDetailsAnimated": "動態 {size}@{fps}fps/q{quality}/{kb}KB(目標 {target}KB)/ {frames} 幀 / 動畫 {animation}/{strategy}{limit}",
|
||||
"thumbnailDetailsStatic": "靜態 {width}×{height}/{kb}KB{fallback}",
|
||||
"bestSpeed": "最快速度",
|
||||
"bestSize": "最小檔案",
|
||||
"fallbackNote": "({text})",
|
||||
"fallbackNoAnimation": "骨骼沒有動畫",
|
||||
"fallbackAnimatedFailed": "動態產生失敗:{error}",
|
||||
"limitNote": "(最小檔仍超過目標大小,可考慮提高縮圖尺寸)",
|
||||
"runSummary": "成功 {generated} 張,失敗 {failed} 張,略過 {skipped} 張,耗時 {seconds} 秒。"
|
||||
},
|
||||
"progress": {
|
||||
"title": "Spine縮圖",
|
||||
"generating": "正在產生動態縮圖……",
|
||||
"waiting": "等待開始…",
|
||||
"confirmPrompt": "確定要取消嗎?",
|
||||
"resume": "繼續",
|
||||
"cancel": "確認取消",
|
||||
"stop": "停止",
|
||||
"confirm": "確認",
|
||||
"generatedConfirm": "已產生 {done} 張縮圖,確定要取消嗎?",
|
||||
"complete": "已完成",
|
||||
"completed": "已完成 {done} / {total}({percent}%)"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title data-i18n="thumb.title">Spine缩略图</title>
|
||||
<style>
|
||||
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; --warn: #e3b341; }
|
||||
html, body { margin: 0; height: 100%; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
|
||||
body { display: flex; flex-direction: column; gap: 9px; padding: 14px; box-sizing: border-box; }
|
||||
h1 { font-size: 15px; margin: 0; font-weight: 600; text-align: center; }
|
||||
.notice { color: var(--dim); line-height: 1.6; background: var(--panel);
|
||||
border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
|
||||
.notice p { margin: 0 0 6px; }
|
||||
.notice p:last-child { margin-bottom: 0; }
|
||||
.notice b { color: var(--text); font-weight: 500; }
|
||||
.notice code { color: var(--accent); font-family: ui-monospace, Consolas, monospace; }
|
||||
.toggle-row { display: flex; align-items: center; gap: 8px; cursor: pointer; white-space: nowrap;
|
||||
flex: 1 1 240px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
|
||||
.toggle-row input { width: 16px; height: 16px; margin: 0; accent-color: var(--accent); }
|
||||
/* 窗口窄的时候整组换行,而不是把标签压成一列一个字符。 */
|
||||
.toolbar { display: flex; flex-wrap: wrap; gap: 8px; align-items: flex-start; }
|
||||
.toolbar-group { display: flex; flex-wrap: wrap; gap: 8px; flex: 0 0 auto; }
|
||||
#clear { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 9px 12px; font: inherit; cursor: pointer; white-space: nowrap; }
|
||||
#clear:hover { border-color: var(--accent); color: var(--accent); }
|
||||
.mode-row { display: flex; align-items: center; gap: 8px; color: var(--dim); white-space: nowrap;
|
||||
flex: 0 1 auto;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px; padding: 9px 11px; }
|
||||
.mode-row select { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 3px 6px; font: inherit; max-width: 200px; }
|
||||
.row { display: flex; gap: 8px; align-items: baseline; color: var(--dim); }
|
||||
.row b { color: var(--text); font-weight: 500; word-break: break-all; }
|
||||
#stats { color: var(--accent); }
|
||||
#log { flex: 1; overflow: auto; margin: 0; padding: 10px; list-style: none; min-height: 140px;
|
||||
background: var(--panel); border: 1px solid var(--border); border-radius: 6px;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 12px; line-height: 1.55; }
|
||||
#log li { white-space: pre-wrap; word-break: break-all; }
|
||||
#log li.dim { color: var(--dim); }
|
||||
#log li.ok { color: var(--accent); }
|
||||
#log li.fail { color: #e06c75; }
|
||||
#log li.warn { color: var(--warn); }
|
||||
#log li.error { color: #ff5f56; }
|
||||
/* 过滤直接用 CSS:给 #log 一个 data-filter,非该类别的行就不显示,不用遍历 DOM。 */
|
||||
#log[data-filter="ok"] li:not(.ok),
|
||||
#log[data-filter="fail"] li:not(.fail),
|
||||
#log[data-filter="warn"] li:not(.warn),
|
||||
#log[data-filter="error"] li:not(.error) { display: none; }
|
||||
.filters { display: flex; gap: 6px; }
|
||||
.filters button { background: var(--panel); color: var(--dim); border: 1px solid var(--border);
|
||||
border-radius: 999px; padding: 3px 12px; font: inherit; font-size: 12px; cursor: pointer; }
|
||||
.filters button.active { color: var(--accent); border-color: var(--accent); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 data-i18n="thumb.title">Spine缩略图</h1>
|
||||
<div class="notice">
|
||||
<p data-i18n="thumb.noticeIntro">导入 Spine 资产(.skel / .json + .atlas + 贴图)后,本插件会为还没设置缩略图的素材生成一张骨骼预览图。</p>
|
||||
<p data-i18n="thumb.noticeSafety">它只处理本次新导入的素材,不扫描、不修改历史素材,也不会覆盖你已经设置过的自定义缩略图。</p>
|
||||
<p data-i18n="thumb.noticeModes">动态取骨骼里的第一个动画压成 WebP;静帧始终使用 setup pose。</p>
|
||||
<p data-i18n="thumb.noticeDefault">自动生成默认关闭,需要你在下面手动启用。</p>
|
||||
</div>
|
||||
<div class="toolbar">
|
||||
<div class="toolbar-group">
|
||||
<label class="toggle-row"><input type="checkbox" id="toggle"><span data-i18n="thumb.autoToggle">导入时自动生成缩略图</span></label>
|
||||
<button id="clear" type="button" data-i18n="thumb.clear">清空日志</button>
|
||||
</div>
|
||||
<div class="toolbar-group">
|
||||
<label class="mode-row"><span data-i18n="thumb.modeLabel">生成方式</span>
|
||||
<select id="mode">
|
||||
<option value="animated" data-i18n="thumb.modeAnimated">动态(第一个动画)</option>
|
||||
<option value="static" data-i18n="thumb.modeStatic">静帧(setup pose)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label class="mode-row"><span data-i18n="thumb.strategyLabel">生成策略</span>
|
||||
<select id="strategy">
|
||||
<option value="size" data-i18n="thumb.strategySize">最优体积(慢)</option>
|
||||
<option value="speed" data-i18n="thumb.strategySpeed">最优速度(快)</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row"><span data-i18n="thumb.statusLabel">状态</span><b id="status" data-i18n="thumb.statusStarting">启动中…</b></div>
|
||||
<div class="row"><span data-i18n="thumb.libraryLabel">素材库</span><b id="library">—</b></div>
|
||||
<div class="row"><span data-i18n="thumb.statsLabel">统计</span><b id="stats">—</b></div>
|
||||
<div class="filters" id="filters">
|
||||
<button type="button" data-filter="all" class="active" data-i18n="thumb.filterAll">全部</button>
|
||||
<button type="button" data-filter="ok" data-i18n="thumb.filterOk">成功</button>
|
||||
<button type="button" data-filter="fail" data-i18n="thumb.filterFail">失败</button>
|
||||
<button type="button" data-filter="warn" data-i18n="thumb.filterWarn">警告</button>
|
||||
<button type="button" data-filter="error" data-i18n="thumb.filterError">错误</button>
|
||||
</div>
|
||||
<ul id="log"></ul>
|
||||
<script>
|
||||
// service 页面里的 require 不以插件目录为基准,本地资源要用绝对路径,这里先把根目录算出来。
|
||||
(function () {
|
||||
try {
|
||||
// Windows 上 pathname 形如 /C:/...,要去掉盘符前那个多余的斜杠;
|
||||
// macOS/Linux 是 /Users/...,前导斜杠必须留着,否则会变成相对路径。
|
||||
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
|
||||
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||
} catch (error) {
|
||||
window.SpinePluginRoot = '';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script>
|
||||
// 诊断用:脚本一旦抛错,直接把原因写在状态栏,避免停在「启动中…」看不出问题。
|
||||
window.addEventListener('error', (event) => {
|
||||
const el = document.getElementById('status');
|
||||
if (el) el.textContent = SpineThumbI18n.t('thumb.scriptError', {
|
||||
error: event.message || event.error || SpineThumbI18n.t('thumb.unknownError'),
|
||||
});
|
||||
});
|
||||
window.addEventListener('unhandledrejection', (event) => {
|
||||
const el = document.getElementById('status');
|
||||
if (el) el.textContent = SpineThumbI18n.t('thumb.promiseError', {
|
||||
error: event.reason && event.reason.message || event.reason,
|
||||
});
|
||||
});
|
||||
</script>
|
||||
<!-- 顺序有依赖:rasterizer 先于 clipping,utils/badge 先于 renderer,plugin.js 最后。 -->
|
||||
<script src="js/spine-webgl-rasterizer.js"></script>
|
||||
<script src="js/spine-canvas-clipping.js"></script>
|
||||
<script src="js/spine-background.js"></script>
|
||||
<script src="js/spine-visible-bounds.js"></script>
|
||||
<script src="js/animated-profiles.js"></script>
|
||||
<script src="js/animated-webp-encoder.js"></script>
|
||||
<script src="js/spine-utils.js"></script>
|
||||
<script src="js/spine-legacy-binary.js"></script>
|
||||
<script src="js/spine-badge.js"></script>
|
||||
<script src="js/settings.js"></script>
|
||||
<script src="js/progress-window.js"></script>
|
||||
<script src="js/renderer.js"></script>
|
||||
<script src="js/library-watcher.js"></script>
|
||||
<script src="js/spine-assets.js"></script>
|
||||
<script src="js/plugin.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,102 @@
|
||||
// 动态缩略图的档位配置。数值与主插件 js/animated-profiles.js 保持一致,
|
||||
// 后台插件没有参数面板,所以这里只当默认值来源,改这里就等于改后台插件的默认渲染参数。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
|
||||
* - 细节分辨率决定画面保留多少细节(输出画布始终是画布边长,小分辨率会被放大填满);
|
||||
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
|
||||
* - 编码质量是 0–1 的 WebP 质量。
|
||||
*
|
||||
* 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
|
||||
* 路径,再按目标体积在路径上定位,取命中位置附近若干档完整生成,挑出符合体积的那一档。
|
||||
*/
|
||||
const ANIMATED_PRIORITIES = {
|
||||
// 分辨率优先:先把帧率压到底,再降分辨率。
|
||||
resolution: [
|
||||
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
|
||||
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
|
||||
],
|
||||
// 帧率优先:先把分辨率压到底,再降帧率。
|
||||
framerate: [
|
||||
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
|
||||
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
|
||||
],
|
||||
// 交替进行:分辨率和帧率交替下降。
|
||||
balanced: [
|
||||
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
|
||||
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_PRIORITY = 'resolution';
|
||||
|
||||
/** 可选的输出画布边长(正方形)。后台插件默认用 200。 */
|
||||
const ANIMATED_OUTPUT_SIZES = [200, 400];
|
||||
const DEFAULT_OUTPUT_SIZE = 200;
|
||||
|
||||
/** 目标体积的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。 */
|
||||
const SIZE_PER_SECOND = 25;
|
||||
const SIZE_SUGGESTION_RANGE = [25, 100];
|
||||
|
||||
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,粗估阶段耗时略增。
|
||||
const PATH_STEPS_PER_SEGMENT = 6;
|
||||
|
||||
function suggestSize(seconds) {
|
||||
const [min, max] = SIZE_SUGGESTION_RANGE;
|
||||
return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
|
||||
}
|
||||
|
||||
function animatedProfiles(priority, outputSize) {
|
||||
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||
return plan.map(([size, fps, quality]) => ({
|
||||
size: Math.min(size, limit),
|
||||
fps,
|
||||
quality,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把锚点路径插值成更密的采样路径,供按目标体积定位使用。
|
||||
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
||||
*/
|
||||
function expandAnimatedPath(priority, outputSize) {
|
||||
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||
const lerp = (from, to, t) => from + (to - from) * t;
|
||||
const path = [];
|
||||
for (let index = 0; index < anchors.length - 1; index++) {
|
||||
const [fromSize, fromFps, fromQuality] = anchors[index];
|
||||
const [toSize, toFps, toQuality] = anchors[index + 1];
|
||||
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
||||
const t = step / PATH_STEPS_PER_SEGMENT;
|
||||
path.push({
|
||||
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
|
||||
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
||||
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
const [size, fps, quality] = anchors[anchors.length - 1];
|
||||
path.push({ size: Math.min(size, limit), fps, quality });
|
||||
return path;
|
||||
}
|
||||
|
||||
const api = {
|
||||
ANIMATED_PRIORITIES,
|
||||
DEFAULT_PRIORITY,
|
||||
ANIMATED_OUTPUT_SIZES,
|
||||
DEFAULT_OUTPUT_SIZE,
|
||||
SIZE_PER_SECOND,
|
||||
SIZE_SUGGESTION_RANGE,
|
||||
PATH_STEPS_PER_SEGMENT,
|
||||
animatedProfiles,
|
||||
expandAnimatedPath,
|
||||
suggestSize,
|
||||
};
|
||||
if (typeof window !== 'undefined') window.SpineAnimatedProfiles = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,85 @@
|
||||
// 把若干张 WebP 静帧封装成一个动态 WebP(ANMF 全帧、不混合)。
|
||||
// 与主插件 js/animated-webp-encoder.js 是同一份实现,只是出口兼容 <script> 加载。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function writeUint24LE(buffer, offset, value) {
|
||||
buffer[offset] = value & 0xff;
|
||||
buffer[offset + 1] = (value >>> 8) & 0xff;
|
||||
buffer[offset + 2] = (value >>> 16) & 0xff;
|
||||
}
|
||||
|
||||
function chunk(type, payload) {
|
||||
const padding = payload.length & 1;
|
||||
const output = Buffer.alloc(8 + payload.length + padding);
|
||||
output.write(type, 0, 4, 'ascii');
|
||||
output.writeUInt32LE(payload.length, 4);
|
||||
payload.copy(output, 8);
|
||||
return output;
|
||||
}
|
||||
|
||||
function frameData(webp) {
|
||||
if (!webp) throw new Error('WebP 帧数据缺失');
|
||||
const input = Buffer.from(webp);
|
||||
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
throw new Error('Invalid WebP frame');
|
||||
}
|
||||
const parts = [];
|
||||
let hasAlpha = false;
|
||||
let offset = 12;
|
||||
while (offset + 8 <= input.length) {
|
||||
const type = input.toString('ascii', offset, offset + 4);
|
||||
const length = input.readUInt32LE(offset + 4);
|
||||
const end = offset + 8 + length + (length & 1);
|
||||
if (end > input.length) throw new Error('Truncated WebP frame');
|
||||
if (type === 'ALPH' || type === 'VP8 ' || type === 'VP8L') parts.push(input.subarray(offset, end));
|
||||
if (type === 'ALPH') hasAlpha = true;
|
||||
offset = end;
|
||||
}
|
||||
if (!parts.some((part) => part.toString('ascii', 0, 4) === 'VP8 ' || part.toString('ascii', 0, 4) === 'VP8L')) {
|
||||
throw new Error('WebP frame has no image data');
|
||||
}
|
||||
return { data: Buffer.concat(parts), hasAlpha };
|
||||
}
|
||||
|
||||
function encode(options) {
|
||||
const width = Number(options && options.width);
|
||||
const height = Number(options && options.height);
|
||||
const frames = options && options.frames;
|
||||
if (!Number.isInteger(width) || width < 1 || width > 0x1000000 || !Number.isInteger(height) || height < 1 || height > 0x1000000) {
|
||||
throw new RangeError('Animated WebP dimensions are invalid');
|
||||
}
|
||||
if (!Array.isArray(frames) || frames.length < 2) throw new RangeError('Animated WebP requires at least two frames');
|
||||
const preparedFrames = frames.map((frame) => ({ frame, ...frameData(frame.webp) }));
|
||||
|
||||
const vp8x = Buffer.alloc(10);
|
||||
vp8x[0] = 0x02 | (preparedFrames.some(({ hasAlpha }) => hasAlpha) ? 0x10 : 0);
|
||||
writeUint24LE(vp8x, 4, width - 1);
|
||||
writeUint24LE(vp8x, 7, height - 1);
|
||||
const anim = Buffer.alloc(6);
|
||||
const background = options.background || [30, 30, 30, 255];
|
||||
anim.set([background[2] || 0, background[1] || 0, background[0] || 0, background[3] === undefined ? 255 : background[3]], 0);
|
||||
anim.writeUInt16LE(Math.max(0, Math.min(0xffff, options.plays || 0)), 4);
|
||||
|
||||
const chunks = [chunk('VP8X', vp8x), chunk('ANIM', anim)];
|
||||
for (const { frame, data } of preparedFrames) {
|
||||
const header = Buffer.alloc(16);
|
||||
writeUint24LE(header, 6, width - 1);
|
||||
writeUint24LE(header, 9, height - 1);
|
||||
writeUint24LE(header, 12, Math.max(1, Math.min(0xffffff, Math.round(frame.duration))));
|
||||
header[15] = 0x02;
|
||||
chunks.push(chunk('ANMF', Buffer.concat([header, data])));
|
||||
}
|
||||
const body = Buffer.concat([Buffer.from('WEBP'), ...chunks]);
|
||||
const output = Buffer.alloc(8 + body.length);
|
||||
output.write('RIFF', 0, 4, 'ascii');
|
||||
output.writeUInt32LE(body.length, 4);
|
||||
body.copy(output, 8);
|
||||
return output;
|
||||
}
|
||||
|
||||
const api = { encode };
|
||||
if (typeof window !== 'undefined') window.SpineAnimatedWebpEncoder = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,111 @@
|
||||
// Shared localization for the service panel and progress window.
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const LANGUAGES = ['en', 'zh_CN', 'zh_TW', 'ja_JP', 'ko_KR', 'ru_RU', 'es_ES', 'de_DE'];
|
||||
const FALLBACK = 'zh_CN';
|
||||
|
||||
function normalizeLocale(raw) {
|
||||
if (!raw) return null;
|
||||
const tag = String(raw).trim().toLowerCase().replace(/-/g, '_');
|
||||
if (tag === 'zh_tw' || tag === 'zh_hk' || tag === 'zh_hant' || tag === 'zh_mo') return 'zh_TW';
|
||||
for (const code of LANGUAGES) {
|
||||
if (code.toLowerCase() === tag) return code;
|
||||
}
|
||||
const base = tag.split('_')[0];
|
||||
if (base === 'zh') return 'zh_CN';
|
||||
for (const code of LANGUAGES) {
|
||||
if (code.toLowerCase().split('_')[0] === base) return code;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function requestedLocale() {
|
||||
try {
|
||||
const fromUrl = new URLSearchParams(location.search).get('lang');
|
||||
if (fromUrl) return fromUrl;
|
||||
} catch (error) { /* No browser location. */ }
|
||||
try {
|
||||
if (typeof eagle !== 'undefined' && eagle.app && eagle.app.locale) return eagle.app.locale;
|
||||
} catch (error) { /* Eagle API is injected asynchronously. */ }
|
||||
try {
|
||||
return navigator.language;
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function pluginRoot() {
|
||||
if (window.SpinePluginPath) return window.SpinePluginPath;
|
||||
if (window.SpinePluginRoot) return window.SpinePluginRoot;
|
||||
return typeof __dirname !== 'undefined' ? __dirname : null;
|
||||
}
|
||||
|
||||
function readLocale(code) {
|
||||
try {
|
||||
const root = pluginRoot();
|
||||
if (!root) return {};
|
||||
return JSON.parse(fs.readFileSync(path.join(root, '_locales', code + '.json'), 'utf8'));
|
||||
} catch (error) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function lookup(table, key) {
|
||||
let node = table;
|
||||
for (const part of String(key).split('.')) {
|
||||
if (!node || typeof node !== 'object') return undefined;
|
||||
node = node[part];
|
||||
}
|
||||
return typeof node === 'string' ? node : undefined;
|
||||
}
|
||||
|
||||
function apply(root) {
|
||||
const scope = root || document;
|
||||
for (const el of scope.querySelectorAll('[data-i18n]')) el.textContent = t(el.dataset.i18n);
|
||||
for (const el of scope.querySelectorAll('[data-i18n-title]')) el.title = t(el.dataset.i18nTitle);
|
||||
for (const el of scope.querySelectorAll('[data-i18n-aria-label]')) el.setAttribute('aria-label', t(el.dataset.i18nAriaLabel));
|
||||
}
|
||||
|
||||
let fallbackMessages = readLocale(FALLBACK);
|
||||
let locale = FALLBACK;
|
||||
let messages = fallbackMessages;
|
||||
|
||||
function t(key, vars) {
|
||||
let text = lookup(messages, key);
|
||||
if (text === undefined) text = lookup(fallbackMessages, key);
|
||||
if (text === undefined) return key;
|
||||
if (vars) {
|
||||
text = text.replace(/\{(\w+)\}/g, (whole, name) =>
|
||||
Object.prototype.hasOwnProperty.call(vars, name) ? String(vars[name]) : whole);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function setLocale(raw) {
|
||||
locale = normalizeLocale(raw) || FALLBACK;
|
||||
fallbackMessages = readLocale(FALLBACK);
|
||||
messages = locale === FALLBACK ? fallbackMessages : readLocale(locale);
|
||||
document.documentElement.lang = locale.replace('_', '-');
|
||||
apply(document);
|
||||
return locale;
|
||||
}
|
||||
|
||||
const api = {
|
||||
languages: LANGUAGES,
|
||||
fallback: FALLBACK,
|
||||
get locale() { return locale; },
|
||||
normalizeLocale,
|
||||
requestedLocale,
|
||||
setLocale,
|
||||
t,
|
||||
apply,
|
||||
};
|
||||
|
||||
window.SpineThumbI18n = api;
|
||||
setLocale(requestedLocale());
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,122 @@
|
||||
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载;包成 IIFE 是为了
|
||||
// 让多个页面脚本各自的 const 不互相冲突。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// Eagle 没有暴露「素材导入完成」事件,所以用「目录静默检测」代替:
|
||||
// 监听素材目录,任何变化都重置防抖计时器,安静 QUIET_MS 之后才认为这一批拖入结束。
|
||||
//
|
||||
// 1 秒:足够合并连续写入。Eagle 的「重复添加提示」是主窗口里的 Angular 模态框,
|
||||
// 没有独立窗口句柄可检测,插件只能靠静默判断——那期间素材库是静止的,但重复文件
|
||||
// 还没入库,扫到的只是已入库的非重复素材。
|
||||
const QUIET_MS = 1000;
|
||||
// fs.watch 在网络盘/外置盘上可能漏事件,用低频兜底扫描补上。
|
||||
const FALLBACK_MS = 30000;
|
||||
|
||||
/** 删除指定目录中超过保留时间的本插件临时文件。 */
|
||||
function cleanupStaleFiles(directory, prefix, maxAgeMs, now = Date.now()) {
|
||||
let names;
|
||||
try { names = fs.readdirSync(directory); } catch (error) { return 0; }
|
||||
let removed = 0;
|
||||
for (const name of names) {
|
||||
if (!name.startsWith(prefix)) continue;
|
||||
const file = path.join(directory, name);
|
||||
try {
|
||||
const stat = fs.lstatSync(file);
|
||||
if (!stat.isFile() || now - stat.mtimeMs < maxAgeMs) continue;
|
||||
fs.unlinkSync(file);
|
||||
removed += 1;
|
||||
} catch (error) { /* 单个文件失败不影响其余清理和监听 */ }
|
||||
}
|
||||
return removed;
|
||||
}
|
||||
|
||||
function createLibraryWatcher(options) {
|
||||
const onBatch = options.onBatch || (() => {});
|
||||
const onLog = options.onLog || (() => {});
|
||||
const t = options.t || ((key) => key);
|
||||
let imagesDir = null;
|
||||
let watcher = null;
|
||||
let quietTimer = null;
|
||||
let fallbackTimer = null;
|
||||
let known = null;
|
||||
|
||||
function listItemDirs() {
|
||||
try {
|
||||
return new Set(fs.readdirSync(imagesDir).filter((name) => name.endsWith('.info')));
|
||||
} catch (error) {
|
||||
onLog(t('thumb.watchReadFailed', { error: (error && error.message) || error }));
|
||||
return new Set();
|
||||
}
|
||||
}
|
||||
|
||||
/** 与上次快照比对,把新增的素材目录交给上层处理。 */
|
||||
function flush(reason) {
|
||||
if (!imagesDir || !known) return;
|
||||
const current = listItemDirs();
|
||||
const added = [...current].filter((name) => !known.has(name));
|
||||
known = current;
|
||||
onBatch({ added, total: current.size, reason });
|
||||
}
|
||||
|
||||
function scheduleFlush() {
|
||||
if (quietTimer) clearTimeout(quietTimer);
|
||||
quietTimer = setTimeout(() => {
|
||||
quietTimer = null;
|
||||
flush('quiet');
|
||||
}, QUIET_MS);
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (watcher) { watcher.close(); watcher = null; }
|
||||
if (quietTimer) { clearTimeout(quietTimer); quietTimer = null; }
|
||||
if (fallbackTimer) { clearInterval(fallbackTimer); fallbackTimer = null; }
|
||||
imagesDir = null;
|
||||
known = null;
|
||||
}
|
||||
|
||||
/** 监听指定素材库。首次快照里的素材视为历史数据,不处理。 */
|
||||
function start(libraryPath) {
|
||||
stop();
|
||||
imagesDir = path.join(libraryPath, 'images');
|
||||
if (!fs.existsSync(imagesDir)) {
|
||||
onLog(t('thumb.watchMissingDir', { path: imagesDir }));
|
||||
return false;
|
||||
}
|
||||
known = listItemDirs();
|
||||
onLog(t('thumb.watching', { path: imagesDir, count: known.size }));
|
||||
try {
|
||||
watcher = fs.watch(imagesDir, { persistent: true }, () => scheduleFlush());
|
||||
} catch (error) {
|
||||
onLog(t('thumb.watchFailed', { error: (error && error.message) || error }));
|
||||
return false;
|
||||
}
|
||||
fallbackTimer = setInterval(() => {
|
||||
const current = listItemDirs();
|
||||
// 不能只比数量:同一次导里如果有增有删,数量可能刚好抵消。
|
||||
let changed = current.size !== known.size;
|
||||
if (!changed) {
|
||||
for (const name of current) {
|
||||
if (!known.has(name)) { changed = true; break; }
|
||||
}
|
||||
}
|
||||
if (changed) flush('fallback');
|
||||
}, FALLBACK_MS);
|
||||
return true;
|
||||
}
|
||||
|
||||
return { start, stop, flush, isRunning: () => !!watcher };
|
||||
}
|
||||
|
||||
// 同时支持两种加载方式:Node 里用 require(跑测试),插件页面里用 <script> 标签。
|
||||
// service 插件页面的 require 解析基准不是插件目录,所以页面里必须走 script 标签。
|
||||
const libraryWatcherApi = { createLibraryWatcher, cleanupStaleFiles, QUIET_MS, FALLBACK_MS };
|
||||
// 优先挂 window:Eagle 插件页面里也存在 module 对象(它有 Node 模块系统),
|
||||
// 先判断 module 会让页面拿到模块却取不到 window 上的名字。Node 里没有 window,自然走 require 分支。
|
||||
if (typeof window !== 'undefined') window.SpineThumbLibraryWatcher = libraryWatcherApi;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = libraryWatcherApi;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,558 @@
|
||||
'use strict';
|
||||
|
||||
// 每次改动递增。Eagle 里的 service 插件是启动时加载并常驻的,改完代码必须重新加载插件
|
||||
// 才会生效;状态栏会显示这个版本号,方便确认加载的到底是不是最新代码。
|
||||
const VERSION = 'v66';
|
||||
const SHOW_DEV_VERSION = true;
|
||||
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const fs = require('fs');
|
||||
const i18n = window.SpineThumbI18n;
|
||||
const t = i18n ? i18n.t : (key) => key;
|
||||
|
||||
// 本地模块由 index.html 用 <script> 标签按顺序加载后挂在 window 上。
|
||||
const REQUIRED = [
|
||||
'SpineThumbI18n',
|
||||
'SpineThumbLibraryWatcher', 'SpineThumbSpineAssets', 'SpineThumbRenderer', 'SpineThumbSettings',
|
||||
'SpineUtils', 'SpineBadge', 'SpineVisibleBounds', 'SpineAnimatedProfiles', 'SpineAnimatedWebpEncoder',
|
||||
'SpineThumbProgress',
|
||||
];
|
||||
const missing = REQUIRED.filter((name) => !window[name]);
|
||||
if (missing.length) {
|
||||
const modules = missing.join(', ');
|
||||
document.getElementById('status').textContent = t('thumb.modulesMissing', { modules });
|
||||
throw new Error(t('thumb.moduleLoadError', { modules }));
|
||||
}
|
||||
const { createLibraryWatcher, cleanupStaleFiles } = window.SpineThumbLibraryWatcher;
|
||||
const { scanSpineItems } = window.SpineThumbSpineAssets;
|
||||
const { renderThumbnailAuto, withTimeout } = window.SpineThumbRenderer;
|
||||
const settingsStore = window.SpineThumbSettings;
|
||||
const utils = window.SpineUtils;
|
||||
const progress = window.SpineThumbProgress;
|
||||
|
||||
const statusEl = document.getElementById('status');
|
||||
const libraryEl = document.getElementById('library');
|
||||
const statsEl = document.getElementById('stats');
|
||||
const logEl = document.getElementById('log');
|
||||
const toggleEl = document.getElementById('toggle');
|
||||
const clearEl = document.getElementById('clear');
|
||||
const filtersEl = document.getElementById('filters');
|
||||
const modeEl = document.getElementById('mode');
|
||||
const strategyEl = document.getElementById('strategy');
|
||||
|
||||
const stats = { batches: 0, added: 0, spine: 0, complete: 0, generated: 0, skipped: 0, failed: 0 };
|
||||
// 一次排队超过这个数量(动态模式)才开进度弹窗(这种批量通常要等很久)。
|
||||
// 判定用排队数,不要求最终真的都产出动态 WebP。
|
||||
const PROGRESS_MIN_DYNAMIC = 10;
|
||||
// 弹窗上的「停止」按钮会把动作写进这个文件,宿主读回来执行暂停/继续/取消。
|
||||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||||
const STALE_TEMP_AGE_MS = 24 * 60 * 60 * 1000;
|
||||
let settings = { ...settingsStore.DEFAULTS };
|
||||
let libraryPath = null;
|
||||
let imagesDir = null;
|
||||
const queue = [];
|
||||
let running = false;
|
||||
// 本轮(一次队列排空)的统计和是否要开进度弹窗的标记;排空后一起清掉。
|
||||
let runStats = null;
|
||||
let runShowsProgress = false;
|
||||
// 弹窗暂停期间,队列原地等待,不再取出新的素材。
|
||||
let paused = false;
|
||||
let lastControlAt = 0;
|
||||
let versionLogged = false;
|
||||
// 「待观察」:本次配套不全的资产记在这里,下一批导入时连带重新检查。
|
||||
// Eagle 的重复添加提示会把一次导入拆成几批,靠它才能等齐配套文件。
|
||||
const pending = new Set();
|
||||
|
||||
|
||||
function log(message, kind) {
|
||||
const line = document.createElement('li');
|
||||
if (kind) line.className = kind;
|
||||
line.textContent = '[' + new Date().toTimeString().slice(0, 8) + '] ' + message;
|
||||
logEl.appendChild(line);
|
||||
while (logEl.childElementCount > 400) logEl.removeChild(logEl.firstElementChild);
|
||||
logEl.scrollTop = logEl.scrollHeight;
|
||||
console.log('[SpineThumbnailGenerateServer] ' + message);
|
||||
}
|
||||
|
||||
function logVersion() {
|
||||
if (!SHOW_DEV_VERSION || versionLogged) return;
|
||||
versionLogged = true;
|
||||
log(t('thumb.version', { version: VERSION }));
|
||||
}
|
||||
|
||||
function setStatus(text) {
|
||||
statusEl.textContent = text;
|
||||
}
|
||||
|
||||
function refreshStats() {
|
||||
statsEl.textContent = t('thumb.stats', stats);
|
||||
}
|
||||
|
||||
function itemIdOf(dirName) {
|
||||
return String(dirName).replace(/\.info$/i, '');
|
||||
}
|
||||
|
||||
// Eagle 里同一个文件可能同时存在于多个文件夹(34-43/3.7/tank 这种),只写 item id
|
||||
// 用户看不出是哪一份。这里把文件夹 id 还原成 Eagle 界面上显示的路径。
|
||||
// 文件夹树每批重读一次(见 handleBatchAsync):导入会新建文件夹、同一会话里也可能重建
|
||||
// 素材库,跨批次复用旧 id 的话日志会整片变成「未分类」。
|
||||
let folderPaths = null;
|
||||
|
||||
function loadFolderPaths() {
|
||||
folderPaths = new Map();
|
||||
try {
|
||||
const tree = JSON.parse(fs.readFileSync(path.join(libraryPath, 'metadata.json'), 'utf8')).folders || [];
|
||||
const walk = (list, prefix) => {
|
||||
for (const f of list || []) {
|
||||
const full = prefix + (f.name || f.id);
|
||||
folderPaths.set(f.id, full);
|
||||
walk(f.children, full + '/');
|
||||
}
|
||||
};
|
||||
walk(tree, '');
|
||||
} catch (error) { /* 读不到就只显示未分类 */ }
|
||||
}
|
||||
|
||||
function folderPathOf(folderIds) {
|
||||
if (!folderPaths) loadFolderPaths();
|
||||
const id = (Array.isArray(folderIds) && folderIds[0]) || null;
|
||||
return (id && folderPaths.get(id)) || t('thumb.uncategorized');
|
||||
}
|
||||
|
||||
/** 日志里的渲染结果描述。 */
|
||||
function describeThumbnail(rendered) {
|
||||
const kb = Math.max(1, Math.round(rendered.buffer.length / 1024));
|
||||
if (rendered.mode === 'animated') {
|
||||
return t('thumb.thumbnailDetailsAnimated', {
|
||||
size: rendered.detail + '*' + rendered.detail,
|
||||
fps: rendered.fps,
|
||||
quality: rendered.quality,
|
||||
kb,
|
||||
target: rendered.targetKilobytes,
|
||||
frames: rendered.frames,
|
||||
animation: rendered.animation,
|
||||
strategy: t(rendered.strategy === 'speed' ? 'thumb.bestSpeed' : 'thumb.bestSize'),
|
||||
limit: rendered.fits === false ? t('thumb.limitNote') : '',
|
||||
});
|
||||
}
|
||||
return t('thumb.thumbnailDetailsStatic', {
|
||||
width: rendered.width,
|
||||
height: rendered.height,
|
||||
kb,
|
||||
fallback: rendered.fallback ? t('thumb.fallbackNote', { text: rendered.fallback }) : '',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 记本轮(一次队列排空)的产出,顺手把进度推给弹窗。
|
||||
* animated 只统计真正做出动态 WebP 的数量;name 用来在弹窗里显示最近完成的文件。
|
||||
*/
|
||||
function countRun(outcome, animated, name) {
|
||||
if (!runStats) return;
|
||||
// 弹窗的暂停/取消随时生效:万一用户在最后一个素材渲染期间点了取消,这里也能记上。
|
||||
applyControl();
|
||||
runStats[outcome] += 1;
|
||||
if (animated) runStats.animated += 1;
|
||||
runStats.done += 1;
|
||||
runStats.name = name || '';
|
||||
if (runShowsProgress) {
|
||||
progress.update({ done: runStats.done, total: runStats.total, name: runStats.name });
|
||||
}
|
||||
}
|
||||
|
||||
/** 一个素材的缩略图生成流程:查重 → 渲染 → 写临时文件 → 走官方接口设置 → 清理。 */
|
||||
async function generateOne(item) {
|
||||
let tempPath = null;
|
||||
// 日志统一用「文件名 | Eagle 文件夹路径」,比 MU9RVSxxxx.info 好认;
|
||||
// 这些信息配对阶段就读到了,Eagle API 拿不到素材时也能照样显示。
|
||||
const label = item.entryName + ' | ' + folderPathOf(item.folders);
|
||||
try {
|
||||
const itemId = itemIdOf(item.dirName);
|
||||
const eagleItem = await eagle.item.getById(itemId);
|
||||
if (!eagleItem) {
|
||||
stats.failed += 1;
|
||||
countRun('failed', false, item.entryName);
|
||||
log(t('thumb.missingItem', { label }), 'warn');
|
||||
return;
|
||||
}
|
||||
// 复合后缀(.skel.bytes / .atlas.txt):Eagle 只按最后一个点拆 name/ext,atlas.txt
|
||||
// 会被当成纯文本,卡片写死成文本预览,自定义缩略图根本显示不出来。把整段后缀塞进
|
||||
// ext 后 Eagle 认不出这个类型,就会用我们生成的缩略图当封面。
|
||||
// 只在「name + '.' + ext 就是磁盘上的真实文件名」时才改,避免覆盖用户自己改过的名字。
|
||||
const parts = utils.splitCompoundName(eagleItem.name + (eagleItem.ext ? '.' + eagleItem.ext : ''));
|
||||
if (parts && (eagleItem.name !== parts.name || eagleItem.ext !== parts.ext)) {
|
||||
try {
|
||||
eagleItem.name = parts.name;
|
||||
eagleItem.ext = parts.ext;
|
||||
await withTimeout(eagleItem.save(), 20000, t('thumb.fixSuffixAction', { label }));
|
||||
log(t('thumb.suffixFixed', { label, name: parts.name + '.' + parts.ext }), 'dim');
|
||||
} catch (error) {
|
||||
log(t('thumb.suffixFailed', { label, error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
}
|
||||
// 不覆盖用户已有的自定义缩略图(Eagle 审核要求避免静默覆盖用户数据)。
|
||||
if (eagleItem.customThumbnail === true) {
|
||||
stats.skipped += 1;
|
||||
countRun('skipped', false, item.entryName);
|
||||
log(t('thumb.existingThumbnail', { label }), 'dim');
|
||||
return;
|
||||
}
|
||||
const version = utils.detectVersion(item.skeletonPath);
|
||||
const runtimeDir = utils.chooseRuntimeDir(version, item.isBinary);
|
||||
// 动态缩略图要逐帧渲染再逐档试体积,比一张静帧慢得多,超时时间分开给。
|
||||
const animated = settings.mode !== 'static';
|
||||
const rendered = await withTimeout(renderThumbnailAuto({
|
||||
skeletonPath: item.skeletonPath,
|
||||
atlasPath: item.atlasPath,
|
||||
texturePaths: item.texturePaths,
|
||||
runtimeDir,
|
||||
isBinary: item.isBinary,
|
||||
version,
|
||||
size: 400,
|
||||
mode: settings.mode,
|
||||
strategy: settings.strategy,
|
||||
onProgress: (current, total) => {
|
||||
setStatus(t('thumb.statusRendering', { current, total, queued: queue.length }));
|
||||
},
|
||||
}), animated ? 300000 : 60000, t('thumb.renderAction', { label }));
|
||||
tempPath = path.join(os.tmpdir(), 'spine-thumb-' + itemId + '-' + Date.now() + (rendered.extension || '.png'));
|
||||
fs.writeFileSync(tempPath, rendered.buffer);
|
||||
const applied = await withTimeout(
|
||||
eagleItem.setCustomThumbnail(tempPath),
|
||||
20000,
|
||||
t('thumb.setThumbnailAction', { label: item.dirName }),
|
||||
);
|
||||
if (applied !== true) throw new Error(t('thumb.thumbnailRejected'));
|
||||
stats.generated += 1;
|
||||
countRun('generated', rendered.mode === 'animated', item.entryName);
|
||||
log(t('thumb.generated', { label, details: describeThumbnail(rendered), version }), 'ok');
|
||||
} catch (error) {
|
||||
stats.failed += 1;
|
||||
countRun('failed', false, item.entryName);
|
||||
log(t('thumb.generateFailed', { label, error: (error && error.message) || error }), 'fail');
|
||||
} finally {
|
||||
try { if (tempPath && fs.existsSync(tempPath)) fs.unlinkSync(tempPath); } catch (error) { /* ignore */ }
|
||||
refreshStats();
|
||||
}
|
||||
}
|
||||
|
||||
/** 读弹窗写下的控制指令:{ action: 'pause' | 'resume' | 'cancel', at: 时间戳 }。 */
|
||||
function readControl() {
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(CONTROL_FILE, 'utf8'));
|
||||
if (data && typeof data.action === 'string' && Number.isFinite(data.at)) return data;
|
||||
} catch (error) { /* 没有文件或内容不完整 */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 执行弹窗按钮的意图:暂停、继续、取消(取消 = 丢掉剩下的队列,已生成的保留)。 */
|
||||
function applyControl() {
|
||||
const control = readControl();
|
||||
if (!control || control.at === lastControlAt) return;
|
||||
lastControlAt = control.at;
|
||||
if (control.action === 'pause') {
|
||||
paused = true;
|
||||
setStatus(t('thumb.statusPaused'));
|
||||
} else if (control.action === 'resume') {
|
||||
paused = false;
|
||||
} else if (control.action === 'cancel') {
|
||||
paused = false;
|
||||
const dropped = queue.length;
|
||||
queue.length = 0;
|
||||
log(t('thumb.cancelled', {
|
||||
dropped,
|
||||
done: runStats ? runStats.done : 0,
|
||||
generated: runStats ? runStats.generated : 0,
|
||||
failed: runStats ? runStats.failed : 0,
|
||||
skipped: runStats ? runStats.skipped : 0,
|
||||
}), 'warn');
|
||||
}
|
||||
}
|
||||
|
||||
/** 串行处理队列:一次只渲染一个,避免批量导入时把 Eagle 卡住。 */
|
||||
async function processQueue() {
|
||||
if (running) return;
|
||||
running = true;
|
||||
// 一次队列排空算一轮,从零开始记;排空后再决定要不要弹通知。
|
||||
if (!runStats) {
|
||||
runStats = {
|
||||
startedAt: Date.now(),
|
||||
total: queue.length,
|
||||
done: 0,
|
||||
name: '',
|
||||
animated: 0,
|
||||
generated: 0,
|
||||
failed: 0,
|
||||
skipped: 0,
|
||||
};
|
||||
}
|
||||
paused = false;
|
||||
// 记下开跑那一刻控制文件的时间戳:上一轮遗留的暂停/取消指令不能再生效。
|
||||
const stale = readControl();
|
||||
lastControlAt = stale ? stale.at : 0;
|
||||
try {
|
||||
while (queue.length) {
|
||||
applyControl();
|
||||
if (paused) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
continue;
|
||||
}
|
||||
const item = queue.shift();
|
||||
// 取消是在上面 applyControl 里生效的,队列可能刚好被清空。
|
||||
if (!item) break;
|
||||
setStatus(t('thumb.statusGenerating', { queued: queue.length }));
|
||||
await generateOne(item);
|
||||
}
|
||||
setStatus(t('thumb.statusListening'));
|
||||
} finally {
|
||||
running = false;
|
||||
paused = false;
|
||||
// 收尾放进 finally:哪怕中途出异常,进度弹窗也必须收到"本轮结束",不能一直挂着。
|
||||
const run = runStats;
|
||||
const showsProgress = runShowsProgress;
|
||||
runStats = null;
|
||||
runShowsProgress = false;
|
||||
if (run && showsProgress) finishRun(run);
|
||||
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/** 在这轮结束时把结果同时写进面板日志和进度弹窗。 */
|
||||
function finishRun(run) {
|
||||
const seconds = Math.max(1, Math.round((Date.now() - run.startedAt) / 1000));
|
||||
const summary = t('thumb.runSummary', {
|
||||
generated: run.generated,
|
||||
failed: run.failed,
|
||||
skipped: run.skipped,
|
||||
seconds,
|
||||
});
|
||||
log(summary, 'ok');
|
||||
progress.finish(summary);
|
||||
}
|
||||
|
||||
// 批次之间串行执行:配对大目录是同步重活,上一批没跑完就让下一批等着。
|
||||
let batchChain = Promise.resolve();
|
||||
|
||||
function handleBatch(batch) {
|
||||
batchChain = batchChain
|
||||
.then(() => handleBatchAsync(batch))
|
||||
.catch((error) => log(t('thumb.batchFailed', { error: (error && error.message) || error }), 'error'));
|
||||
}
|
||||
|
||||
/** 一批静默结束后触发:只分析本次新增的目录,避免每次全库扫描。 */
|
||||
async function handleBatchAsync({ added, total, reason }) {
|
||||
// Eagle 后台写缩略图、写 metadata 也会让 images/ 产生目录事件,静默后同样走到这里。
|
||||
// 没有新增目录就等于什么都没发生:不计数、不写日志,否则「批次」会被噪音刷成几十上百。
|
||||
if (!added.length) return;
|
||||
// 每批都重读库的文件夹树:这批导入可能刚建了新文件夹,沿用上一批的 id 会全部查不到。
|
||||
loadFolderPaths();
|
||||
stats.batches += 1;
|
||||
stats.added += added.length;
|
||||
const reasonText = t(reason === 'fallback' ? 'thumb.reasonFallback' : 'thumb.reasonQuiet');
|
||||
log(t('thumb.batchFinished', { added: added.length, total, reason: reasonText }));
|
||||
// 本次新增 + 之前配套不全的,一起重新检查。
|
||||
const candidates = [...new Set([...added, ...pending])];
|
||||
if (pending.size) log(t('thumb.recheck', { count: pending.size }), 'dim');
|
||||
// 先把上面这行日志真正刷到屏幕上,再开始配对——配对是同步扫全库的重活,
|
||||
// 一旦开始浏览器就没机会重绘,日志会「迟到」到全部结束才一次性出现。
|
||||
setStatus(t('thumb.statusPairing'));
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
const items = await scanSpineItems(imagesDir, candidates, utils, (current, all) => {
|
||||
setStatus(t('thumb.statusPairingProgress', { current, total: all }));
|
||||
});
|
||||
// 统计只算本次新增的:待观察的重查会重复计入,数字会虚高。
|
||||
const fresh = items.filter((item) => added.includes(item.dirName));
|
||||
stats.spine += fresh.length;
|
||||
stats.complete += fresh.filter((item) => item.complete).length;
|
||||
if (!items.length) log(t('thumb.noSpine'), 'dim');
|
||||
// 配套不全的先记下来,等下一批补齐时再处理;补齐了就从待观察里移除。
|
||||
const incomplete = items.filter((item) => !item.complete);
|
||||
pending.clear();
|
||||
// 「待观察」只在自动生成开着的时候记:关着的时候导入的素材属于"没赶上",不该在
|
||||
// 开关重新打开后被回头处理——这个插件只对导入当时开着开关的素材生效。
|
||||
if (settings.enabled) {
|
||||
for (const item of incomplete) pending.add(item.dirName);
|
||||
}
|
||||
if (incomplete.length) {
|
||||
log(t(settings.enabled ? 'thumb.incompletePending' : 'thumb.incompleteIgnored', { count: incomplete.length }),
|
||||
settings.enabled ? 'warn' : 'dim');
|
||||
}
|
||||
for (const item of items) {
|
||||
const name = (filePath) => (filePath ? path.basename(filePath) : t('thumb.none'));
|
||||
log(t('thumb.itemSummary', {
|
||||
name: item.entryName,
|
||||
path: folderPathOf(item.folders),
|
||||
state: t(item.complete ? 'thumb.complete' : 'thumb.incomplete'),
|
||||
skeleton: name(item.skeletonPath),
|
||||
atlas: name(item.atlasPath),
|
||||
pages: item.pages,
|
||||
missing: item.missingTextures,
|
||||
}),
|
||||
item.complete ? 'ok' : 'warn');
|
||||
}
|
||||
refreshStats();
|
||||
|
||||
const ready = items.filter((item) => item.complete);
|
||||
if (!ready.length) return;
|
||||
if (!settings.enabled) {
|
||||
log(t('thumb.autoSkipped', { count: ready.length }), 'warn');
|
||||
setStatus(t('thumb.statusAutoSkipped'));
|
||||
return;
|
||||
}
|
||||
queue.push(...ready);
|
||||
// 已经在跑的一轮又来新素材,把总数补上,进度条才不会一直显示满。
|
||||
if (runStats) runStats.total += ready.length;
|
||||
// 硬性条件:动态模式且这一次排队超过 10 个,才开进度弹窗,跑完在里面给汇总。
|
||||
if (settings.mode !== 'static' && ready.length > PROGRESS_MIN_DYNAMIC) {
|
||||
runShowsProgress = true;
|
||||
progress.open();
|
||||
progress.update({
|
||||
done: runStats ? runStats.done : 0,
|
||||
total: runStats ? runStats.total : queue.length,
|
||||
});
|
||||
}
|
||||
log(t('thumb.queued', {
|
||||
added: ready.length,
|
||||
queued: queue.length,
|
||||
state: running ? t('thumb.queuedRunning') : '',
|
||||
}), 'dim');
|
||||
processQueue();
|
||||
}
|
||||
|
||||
const libraryWatcher = createLibraryWatcher({ onBatch: handleBatch, onLog: log, t });
|
||||
|
||||
async function resolveLibraryPath() {
|
||||
const direct = eagle.library && eagle.library.path;
|
||||
if (direct) return direct;
|
||||
try {
|
||||
const info = await eagle.library.info();
|
||||
const fromApi = info && (info.path || info.libraryPath || info.dir);
|
||||
if (fromApi) return fromApi;
|
||||
log(t('thumb.libraryInfoNoPath', { info: JSON.stringify(info) }), 'dim');
|
||||
} catch (error) {
|
||||
log(t('thumb.libraryInfoFailed', { error: (error && error.message) || error }), 'error');
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function start() {
|
||||
const resolved = await resolveLibraryPath();
|
||||
if (!resolved) {
|
||||
setStatus(t('thumb.statusNoLibrary'));
|
||||
return;
|
||||
}
|
||||
libraryPath = resolved;
|
||||
imagesDir = path.join(resolved, 'images');
|
||||
folderPaths = null; // 换库了,文件夹树重新读
|
||||
libraryEl.textContent = resolved;
|
||||
setStatus(libraryWatcher.start(resolved) ? t('thumb.statusListening') : t('thumb.statusListenFailed'));
|
||||
}
|
||||
|
||||
function startSafely() {
|
||||
start().catch((error) => {
|
||||
setStatus(t('thumb.statusStartFailed', { error: (error && error.message) || error }));
|
||||
log(t('thumb.statusStartFailed', { error: (error && error.stack) || error }), 'error');
|
||||
});
|
||||
}
|
||||
|
||||
async function loadSettings() {
|
||||
try {
|
||||
settings = await settingsStore.read();
|
||||
} catch (error) {
|
||||
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
|
||||
settings = { ...settingsStore.DEFAULTS };
|
||||
}
|
||||
toggleEl.checked = settings.enabled === true;
|
||||
modeEl.value = settings.mode === 'static' ? 'static' : 'animated';
|
||||
strategyEl.value = settings.strategy === 'speed' ? 'speed' : 'size';
|
||||
}
|
||||
|
||||
toggleEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, enabled: toggleEl.checked };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
if (settings.enabled) {
|
||||
log(t('thumb.autoEnabled'), 'ok');
|
||||
setStatus(t('thumb.statusListening'));
|
||||
} else {
|
||||
queue.length = 0;
|
||||
log(t('thumb.autoDisabled'), 'dim');
|
||||
setStatus(t('thumb.statusAutoOff'));
|
||||
}
|
||||
});
|
||||
|
||||
// 只清显示,不动统计和 queue:Eagle 的 devtools 控制台里仍然留着完整历史。
|
||||
clearEl.addEventListener('click', () => {
|
||||
logEl.textContent = '';
|
||||
});
|
||||
|
||||
modeEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, mode: modeEl.value === 'static' ? 'static' : 'animated' };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
log(t(settings.mode === 'static' ? 'thumb.modeStaticLog' : 'thumb.modeAnimatedLog'), 'dim');
|
||||
});
|
||||
|
||||
strategyEl.addEventListener('change', async () => {
|
||||
settings = { ...settings, strategy: strategyEl.value === 'speed' ? 'speed' : 'size' };
|
||||
try {
|
||||
await settingsStore.write(settings);
|
||||
} catch (error) {
|
||||
log(t('thumb.settingsSaveFailed', { error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
log(t(settings.strategy === 'speed' ? 'thumb.strategySpeedLog' : 'thumb.strategySizeLog'), 'dim');
|
||||
});
|
||||
|
||||
// 日志分类过滤交给 CSS:这里只切 #log 的 data-filter 和按钮高亮。
|
||||
filtersEl.addEventListener('click', (event) => {
|
||||
const button = event.target.closest('button[data-filter]');
|
||||
if (!button) return;
|
||||
logEl.dataset.filter = button.dataset.filter;
|
||||
for (const item of filtersEl.children) item.classList.toggle('active', item === button);
|
||||
});
|
||||
|
||||
// 先确认脚本本身执行到了这里,再谈 Eagle 事件有没有来。
|
||||
document.getElementById('status').textContent = t('thumb.statusLoaded', { version: VERSION });
|
||||
cleanupStaleFiles(os.tmpdir(), 'spine-thumb-', STALE_TEMP_AGE_MS);
|
||||
|
||||
if (typeof eagle === 'undefined') {
|
||||
setStatus(t('thumb.statusEagleMissing'));
|
||||
log(t('thumb.statusEagleMissing'), 'error');
|
||||
} else {
|
||||
eagle.onPluginCreate(async (plugin) => {
|
||||
// 官方文档:onPluginCreate 用来初始化插件所需的模块,plugin.path 就是插件所在目录。
|
||||
// app.getPath 这类 API 必须在这个事件之后才能调用,所以设置读取也放在这里。
|
||||
window.SpinePluginPath = (plugin && plugin.path) || window.SpinePluginRoot;
|
||||
if (i18n) i18n.setLocale(eagle.app && eagle.app.locale);
|
||||
logVersion();
|
||||
log(t('thumb.pluginCreated', {
|
||||
name: (plugin && plugin.manifest && plugin.manifest.name) || t('thumb.title'),
|
||||
}));
|
||||
try {
|
||||
await loadSettings();
|
||||
} catch (error) {
|
||||
log(t('thumb.settingsReadFailed', { error: (error && error.message) || error }), 'warn');
|
||||
}
|
||||
startSafely();
|
||||
});
|
||||
eagle.onLibraryChanged((changedPath) => {
|
||||
log(t('thumb.switchingLibrary', { path: changedPath }));
|
||||
startSafely();
|
||||
});
|
||||
// 兜底:service 插件可能在窗口打开前就已创建完,创建事件错过了就自己启动一次。
|
||||
setTimeout(() => {
|
||||
if (libraryEl.textContent === '—') {
|
||||
if (i18n) i18n.setLocale(i18n.requestedLocale());
|
||||
logVersion();
|
||||
log(t('thumb.noCreateEvent'), 'dim');
|
||||
loadSettings().catch(() => {}).then(startSafely);
|
||||
}
|
||||
}, 800);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
// 进度弹窗:用 Eagle 自己的 create-plugin-window 通道开一个正规插件窗口。
|
||||
//
|
||||
// 为什么不用 window.open:那样开出来的是 Electron 默认的裸窗口——没有 Node、没有插件 API、
|
||||
// 还带着 Eagle 的应用菜单栏(Eagle 只对自己创建的插件窗口调 setMenuBarVisibility(false))。
|
||||
// create-plugin-window 正是 Eagle 前端开插件窗口用的通道,manifest 由调用方传进来,
|
||||
// 所以我们可以指定 url / 尺寸 / 置顶,拿到的就是"正规的插件窗口"。
|
||||
//
|
||||
// 两个窗口之间没有 opener 关系,进度用临时目录里的状态文件传:宿主写,弹窗轮询。
|
||||
// 弹窗反过来写一个心跳文件,宿主靠它判断窗口是不是被用户关掉了。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const i18n = window.SpineThumbI18n;
|
||||
const t = i18n ? i18n.t : ((key) => key);
|
||||
|
||||
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
|
||||
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
|
||||
// 高度只是初值,弹窗加载后会按内容自己调(eagle.window.setSize)。
|
||||
const WINDOW_SIZE = { width: 380, height: 160 };
|
||||
// 心跳超过这个时间就认为弹窗已经被关掉,下次需要时重新开。
|
||||
const ALIVE_WINDOW_MS = 3000;
|
||||
// 刚创建完窗口要给它一点加载时间,这段时间内不重复创建。
|
||||
const CREATE_COOLDOWN_MS = 4000;
|
||||
|
||||
let state = { done: 0, total: 0, name: '', finished: false, text: '' };
|
||||
let lastCreateAt = 0;
|
||||
let ipcRenderer = null;
|
||||
|
||||
/** 插件目录的绝对路径。装了 eagleplugin:// 协议后 location.href 不是文件路径,只能靠 Eagle 给的。 */
|
||||
function pluginPath() {
|
||||
if (window.SpinePluginPath) return window.SpinePluginPath;
|
||||
if (typeof eagle !== 'undefined' && eagle.plugin && eagle.plugin.path) return eagle.plugin.path;
|
||||
return typeof __dirname !== 'undefined' ? __dirname : null;
|
||||
}
|
||||
|
||||
function getIpcRenderer() {
|
||||
if (!ipcRenderer) {
|
||||
// Eagle 的 preload 把 require('electron') 换成了 {},另起一个 require 才能拿到真模块。
|
||||
// createRequire 只接受绝对路径或 file:// URL,所以基准用插件目录拼出来,不能直接用 location.href。
|
||||
const base = path.join(pluginPath() || process.cwd(), 'index.html');
|
||||
ipcRenderer = require('module').createRequire(base)('electron').ipcRenderer;
|
||||
}
|
||||
return ipcRenderer;
|
||||
}
|
||||
|
||||
/** 弹窗还开着吗?靠它写的心跳判断,窗口关掉后心跳自然就停了。 */
|
||||
function popupAlive() {
|
||||
try {
|
||||
return Date.now() - Number(fs.readFileSync(ALIVE_FILE, 'utf8')) < ALIVE_WINDOW_MS;
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function writeState() {
|
||||
try {
|
||||
fs.writeFileSync(STATE_FILE, JSON.stringify({ ...state, updatedAt: Date.now() }), 'utf8');
|
||||
} catch (error) { /* 写不进去也不影响生成,面板里一样有进度 */ }
|
||||
}
|
||||
|
||||
/** 用 Eagle 的通道创建插件窗口;manifest 覆盖成进度页面的配置。 */
|
||||
function createPopup() {
|
||||
const plugin = (typeof eagle !== 'undefined' && eagle.plugin) || {};
|
||||
const dir = pluginPath();
|
||||
let manifest = plugin.manifest;
|
||||
if (!manifest && dir) {
|
||||
// 正常情况下 eagle.plugin 里就带 manifest;万一还没注入就自己从插件目录读一份。
|
||||
manifest = JSON.parse(fs.readFileSync(path.join(dir, 'manifest.json'), 'utf8'));
|
||||
}
|
||||
if (!manifest) return Promise.reject(new Error(t('thumb.modulesMissing', { modules: 'manifest' })));
|
||||
return getIpcRenderer().invoke('create-plugin-window', {
|
||||
plugin: {
|
||||
...plugin,
|
||||
path: dir,
|
||||
manifest: {
|
||||
...manifest,
|
||||
// 这个窗口的标题栏文字(只影响这扇窗,插件本身的名字不变)。
|
||||
name: t('progress.title'),
|
||||
main: {
|
||||
...manifest.main,
|
||||
// 必须关掉 serviceMode,否则 Eagle 会按 show:false 创建这个窗口。
|
||||
serviceMode: false,
|
||||
url: 'progress.html?lang=' + encodeURIComponent(i18n ? i18n.locale : 'zh_CN'),
|
||||
width: WINDOW_SIZE.width,
|
||||
height: WINDOW_SIZE.height,
|
||||
show: true,
|
||||
// 挂在 Eagle 主窗口上:子窗口天然压在父窗口之上,而且跟着 Eagle 一起显示/隐藏。
|
||||
// 这里显式写 false,免得主 manifest 哪天又开了 alwaysOnTop 被 ...manifest.main 带进来,
|
||||
// 那会让弹窗盖住所有程序,而不是只待在 Eagle 上面。
|
||||
alwaysOnTop: false,
|
||||
childWindow: true,
|
||||
// 保留系统标题栏:最小化/关闭按钮都在上面(manifest.name 决定标题文字)。
|
||||
frame: true,
|
||||
resizable: false,
|
||||
minimizable: true,
|
||||
maximizable: false,
|
||||
fullscreenable: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
args: {},
|
||||
});
|
||||
}
|
||||
|
||||
/** 需要时开窗(或复用已开着的);被环境挡住时返回 false,调用方照常继续跑。 */
|
||||
function open() {
|
||||
try {
|
||||
if (popupAlive()) return true;
|
||||
if (Date.now() - lastCreateAt < CREATE_COOLDOWN_MS) return false;
|
||||
lastCreateAt = Date.now();
|
||||
writeState();
|
||||
createPopup().catch(() => { /* 开不出来就只在面板里看进度 */ });
|
||||
return true;
|
||||
} catch (error) {
|
||||
// 弹窗只是附加显示,任何问题都不能影响缩略图生成本身。
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function update(next) {
|
||||
try {
|
||||
state = { ...state, ...next, finished: false };
|
||||
writeState();
|
||||
} catch (error) { /* 同上:弹窗不能影响生成流程 */ }
|
||||
}
|
||||
|
||||
function finish(text) {
|
||||
try {
|
||||
state = { ...state, finished: true, text };
|
||||
writeState();
|
||||
} catch (error) { /* ignore */ }
|
||||
}
|
||||
|
||||
const api = { open, update, finish };
|
||||
|
||||
if (typeof window !== 'undefined') window.SpineThumbProgress = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,724 @@
|
||||
// 在 service 插件页面里渲染缩略图:加载对应版本运行时 → 解析骨骼 → 绘制 → 导出图片。
|
||||
// 两条产物,互不影响,由插件面板选择:
|
||||
// 静帧(setup pose)—— 原来的那条路,导出 PNG,一直保留;
|
||||
// 动态(animated WebP)—— 参数与主插件默认值一致:画布 200×200、分辨率优先档位表、
|
||||
// 目标体积由动画时长决定(每秒 25 KB,夹在 25–100 KB),取骨骼里的第一个动画。
|
||||
// 这里用页面自己的 DOM canvas,不需要 @napi-rs/canvas 那类原生模块。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { pathToFileURL } = require('url');
|
||||
const t = (key, vars) => window.SpineThumbI18n ? window.SpineThumbI18n.t(key, vars) : key;
|
||||
|
||||
const runtimeCache = new Map();
|
||||
// 长动画不截断时间轴,只降低采样密度:18 fps 采样,最多 240 帧。
|
||||
const SOURCE_SAMPLE_FPS = 18;
|
||||
const MAX_SOURCE_FRAMES = 240;
|
||||
// 时长小于这个值的动画按静帧处理。
|
||||
const STATIC_EPSILON = 1e-6;
|
||||
// 逐帧编码时轮转使用的离屏画布数量:成批提交比逐帧等待快得多。
|
||||
const FRAME_POOL_SIZE = 4;
|
||||
// 生成策略:
|
||||
// 'size' = 十档全部生成,取实际体积最小的那一档(最省体积,也就是主插件画廊默认打勾的);
|
||||
// 'speed' = 只用粗估挑出体积最小的两档真正生成(最快,其余开销跟着一起砍)。
|
||||
const DEFAULT_STRATEGY = 'size';
|
||||
const SPEED_PROBE_SIZE = 256;
|
||||
const SPEED_PROBE_POSES = 12;
|
||||
const SPEED_ESTIMATE_POINTS = 8;
|
||||
const SPEED_CANDIDATES = 2;
|
||||
|
||||
/**
|
||||
* 让每个版本在自己的 iframe 里加载,返回它的 spine 命名空间。
|
||||
*
|
||||
* 不能在主页面上用 <script> 标签:插件页面存在 Node 的 module 对象,3.4–3.7 的 UMD
|
||||
* 会因此走 CommonJS 分支,把导出丢进 module.exports 而不写 window.spine——结果
|
||||
* window.spine 永远停在第一个加载的版本上,后续每个版本都拿到同一个对象,于是出现
|
||||
* 「SkeletonBinary 只有 getter」或者「用 4.0 运行时去读 3.6 数据导致 worldX 未定义」。
|
||||
* iframe 里没有 Node 注入,UMD 会走浏览器分支,各版本天然隔离。
|
||||
*/
|
||||
function loadRuntime(runtimeDir) {
|
||||
if (runtimeCache.has(runtimeDir)) return runtimeCache.get(runtimeDir);
|
||||
const job = new Promise((resolve, reject) => {
|
||||
const frame = document.createElement('iframe');
|
||||
frame.style.display = 'none';
|
||||
frame.onload = () => {
|
||||
try {
|
||||
const win = frame.contentWindow;
|
||||
const script = win.document.createElement('script');
|
||||
script.src = new URL('lib/' + runtimeDir + '/spine-canvas.js', location.href).href;
|
||||
script.onload = () => {
|
||||
try {
|
||||
const spine = win.spine;
|
||||
if (!spine) throw new Error('运行时没有导出 spine:' + runtimeDir);
|
||||
if (window.SpineLegacyBinary) window.SpineLegacyBinary.install(spine, runtimeDir);
|
||||
resolve(spine);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
script.onerror = () => reject(new Error('加载运行时失败:' + runtimeDir));
|
||||
win.document.head.appendChild(script);
|
||||
} catch (error) {
|
||||
reject(error);
|
||||
}
|
||||
};
|
||||
frame.onerror = () => reject(new Error('创建运行时隔离环境失败:' + runtimeDir));
|
||||
document.body.appendChild(frame);
|
||||
});
|
||||
runtimeCache.set(runtimeDir, job);
|
||||
return job;
|
||||
}
|
||||
|
||||
/** 本地绝对路径 → file:// URL。交给 Node 的 pathToFileURL,Windows / macOS 都正确。 */
|
||||
function fileUrl(filePath) {
|
||||
return pathToFileURL(filePath).href;
|
||||
}
|
||||
|
||||
/** 给可能挂起的异步操作套一层超时,避免队列被一个卡住的 Promise 堵死。 */
|
||||
function withTimeout(promise, timeoutMs, label) {
|
||||
let timer = null;
|
||||
return Promise.race([
|
||||
promise.finally(() => { if (timer) clearTimeout(timer); }),
|
||||
new Promise((resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error((label || '操作') + ' 超时(' + timeoutMs + 'ms)')), timeoutMs);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
function loadImage(filePath) {
|
||||
return withTimeout(new Promise((resolve, reject) => {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => reject(new Error('读取贴图失败:' + filePath));
|
||||
image.src = fileUrl(filePath);
|
||||
}), 10000, '读取贴图 ' + path.basename(filePath));
|
||||
}
|
||||
|
||||
/** 按 atlas 里引用的页面名加载贴图,构造 TextureAtlas。 */
|
||||
async function buildTextureAtlas(spine, atlasText, texturePaths) {
|
||||
const images = new Map();
|
||||
await Promise.all(Object.keys(texturePaths).map(async (pageName) => {
|
||||
const filePath = texturePaths[pageName];
|
||||
if (!filePath) return;
|
||||
images.set(pageName, await loadImage(filePath));
|
||||
}));
|
||||
const makeTexture = (name) => {
|
||||
const image = images.get(name);
|
||||
if (!image) throw new Error('atlas 引用的贴图没找到:' + name);
|
||||
if (spine.CanvasTexture) return new spine.CanvasTexture(image);
|
||||
if (spine.canvas && spine.canvas.CanvasTexture) return new spine.canvas.CanvasTexture(image);
|
||||
return new spine.Texture(image);
|
||||
};
|
||||
const is4x = typeof spine.TextureAtlas === 'function' && spine.TextureAtlas.length <= 1;
|
||||
if (is4x) {
|
||||
const atlas = new spine.TextureAtlas(atlasText);
|
||||
for (const page of atlas.pages) {
|
||||
const texture = makeTexture(page.name);
|
||||
if (page.setTexture) page.setTexture(texture);
|
||||
else page.texture = texture;
|
||||
}
|
||||
return atlas;
|
||||
}
|
||||
return new spine.TextureAtlas(atlasText, (name) => makeTexture(name));
|
||||
}
|
||||
|
||||
function readSkeletonData(spine, skeletonPath, atlas, isBinary) {
|
||||
const atlasLoader = new spine.AtlasAttachmentLoader(atlas);
|
||||
if (isBinary) {
|
||||
const loader = new spine.SkeletonBinary(atlasLoader);
|
||||
loader.scale = 1;
|
||||
const buffer = fs.readFileSync(skeletonPath);
|
||||
return loader.readSkeletonData(new Uint8Array(buffer));
|
||||
}
|
||||
const loader = new spine.SkeletonJson(atlasLoader);
|
||||
loader.scale = 1;
|
||||
return loader.readSkeletonData(fs.readFileSync(skeletonPath, 'utf8'));
|
||||
}
|
||||
|
||||
function updateWorld(spine, skeleton, delta = 0, resetPhysics = false) {
|
||||
if (spine.Physics && spine.Physics.update !== undefined) {
|
||||
if (delta > 0 && typeof skeleton.update === 'function') skeleton.update(delta);
|
||||
const mode = resetPhysics && spine.Physics.reset !== undefined ? spine.Physics.reset : spine.Physics.update;
|
||||
skeleton.updateWorldTransform(mode);
|
||||
}
|
||||
else skeleton.updateWorldTransform();
|
||||
}
|
||||
|
||||
/** 载入运行时、atlas 与骨骼数据。静态和动态两条路共用。 */
|
||||
async function loadSkeleton(options) {
|
||||
const spine = await loadRuntime(options.runtimeDir);
|
||||
const atlasText = fs.readFileSync(options.atlasPath, 'utf8');
|
||||
const atlas = await buildTextureAtlas(spine, atlasText, options.texturePaths);
|
||||
const data = readSkeletonData(spine, options.skeletonPath, atlas, !!options.isBinary);
|
||||
if (!data || !data.bones || !data.bones.length) throw new Error('骨骼数据为空');
|
||||
return {
|
||||
spine,
|
||||
data,
|
||||
atlasText,
|
||||
runtimeDir: options.runtimeDir,
|
||||
premultipliedAlpha: window.SpineUtils.parseAtlasPma(atlasText) === true,
|
||||
};
|
||||
}
|
||||
|
||||
/** 骨骼当前姿态的包围盒(世界坐标)。 */
|
||||
function boundsOf(spine, skeleton) {
|
||||
try {
|
||||
if (typeof skeleton.getBoundsRect === 'function') {
|
||||
const rect = skeleton.getBoundsRect();
|
||||
if (rect && rect.width !== undefined) return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
|
||||
}
|
||||
if (typeof skeleton.getBounds === 'function') {
|
||||
const offset = new spine.Vector2();
|
||||
const dimensions = new spine.Vector2();
|
||||
skeleton.getBounds(offset, dimensions, []);
|
||||
if (dimensions.x > 0 || dimensions.y > 0) {
|
||||
return { x: offset.x, y: offset.y, width: dimensions.x, height: dimensions.y };
|
||||
}
|
||||
}
|
||||
} catch (error) { /* ignore */ }
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 渲染 setup pose 的缩略图。
|
||||
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, size, drawBadge, drawVersion }
|
||||
* 返回 { blob, width, height, version }
|
||||
*/
|
||||
async function renderThumbnail(options) {
|
||||
const utils = window.SpineUtils;
|
||||
const size = options.size || 400;
|
||||
const loaded = await loadSkeleton(options);
|
||||
const { spine, data } = loaded;
|
||||
|
||||
const skeleton = new spine.Skeleton(data);
|
||||
utils.applySkin(skeleton, utils.preferredSkin(data));
|
||||
if (typeof skeleton.setToSetupPose === 'function') skeleton.setToSetupPose();
|
||||
else if (typeof skeleton.setupPose === 'function') skeleton.setupPose();
|
||||
updateWorld(spine, skeleton, 0, true);
|
||||
|
||||
const bounds = boundsOf(spine, skeleton);
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
if (bounds && bounds.width > 0 && bounds.height > 0) {
|
||||
const pad = 0.1;
|
||||
const scale = Math.min((size * (1 - pad * 2)) / bounds.width, (size * (1 - pad * 2)) / bounds.height);
|
||||
ctx.save();
|
||||
ctx.translate(size / 2, size / 2);
|
||||
ctx.scale(scale, -scale);
|
||||
ctx.translate(-(bounds.x + bounds.width / 2), -(bounds.y + bounds.height / 2));
|
||||
let renderer = null;
|
||||
try {
|
||||
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, options.runtimeDir);
|
||||
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
|
||||
renderer.draw(skeleton);
|
||||
} finally {
|
||||
if (renderer && renderer.dispose) renderer.dispose();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
if (options.drawBadge !== false) await drawBadge(ctx, size);
|
||||
if (options.drawVersion !== false) {
|
||||
window.SpineBadge.drawVersion(ctx, size, size, data.version || options.version);
|
||||
}
|
||||
const blob = await new Promise((resolve) => canvas.toBlob(resolve, 'image/png'));
|
||||
if (!blob) throw new Error('导出缩略图失败');
|
||||
return { blob, width: size, height: size, version: data.version || options.version || '' };
|
||||
}
|
||||
|
||||
// ---------- 动态缩略图(animated WebP) ----------
|
||||
// 参数与主插件的默认值一致:画布 200×200、分辨率优先档位表、目标体积 = 动画时长 × 25 KB
|
||||
// (夹在 25–100 KB)。区别是没有候选画廊,所以直接采用主插件画廊里默认打勾的那一档
|
||||
// (十档里实际体积最小的那个),目标体积只用来判断有没有达标并在日志里提示。
|
||||
|
||||
function collectKeyframeTimes(animation) {
|
||||
if (!animation || !animation.timelines) return [];
|
||||
const times = new Set();
|
||||
for (const timeline of animation.timelines) {
|
||||
const frames = timeline && timeline.frames;
|
||||
if (!frames || !frames.length) continue;
|
||||
try {
|
||||
if (Array.isArray(frames[0])) {
|
||||
for (const frame of frames) {
|
||||
if (frame && Number.isFinite(frame[0])) times.add(frame[0]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const frameCount = typeof timeline.getFrameCount === 'function' ? timeline.getFrameCount() : 0;
|
||||
const entries = typeof timeline.getFrameEntries === 'function'
|
||||
? timeline.getFrameEntries()
|
||||
: (frameCount > 0 ? Math.max(1, Math.round(frames.length / frameCount)) : 1);
|
||||
if (frameCount > 0) {
|
||||
for (let index = 0; index < frameCount; index++) {
|
||||
const value = frames[index * entries];
|
||||
if (Number.isFinite(value)) times.add(value);
|
||||
}
|
||||
}
|
||||
} catch (error) { /* 读数失败就跳过这条时间轴 */ }
|
||||
}
|
||||
return [...times];
|
||||
}
|
||||
|
||||
function animationDurationOf(animation) {
|
||||
const declared = animation && Number(animation.duration);
|
||||
if (Number.isFinite(declared) && declared > STATIC_EPSILON) return declared;
|
||||
const times = collectKeyframeTimes(animation);
|
||||
return times.length ? Math.max.apply(null, times) : 0;
|
||||
}
|
||||
|
||||
/** 取骨骼里的第一个可用动画(没有动画或时长为 0 就返回 null,由上层回退静帧)。 */
|
||||
function firstAnimation(data) {
|
||||
for (const animation of (data && data.animations) || []) {
|
||||
if (animation && animationDurationOf(animation) > STATIC_EPSILON) return animation;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyAnimationPose(spine, targetSkeleton, targetState, animation, time, previousTime) {
|
||||
if (typeof targetState.clearTracks === 'function') targetState.clearTracks();
|
||||
else if (typeof targetState.clearTrack === 'function') targetState.clearTrack(0);
|
||||
if (typeof targetSkeleton.setToSetupPose === 'function') targetSkeleton.setToSetupPose();
|
||||
const entry = targetState.setAnimation(0, animation.name, false);
|
||||
if (entry) entry.loop = false;
|
||||
targetState.update(Math.max(0, time));
|
||||
targetState.apply(targetSkeleton);
|
||||
updateWorld(spine, targetSkeleton, previousTime === undefined ? 0 : Math.max(0, time - previousTime), previousTime === undefined);
|
||||
}
|
||||
|
||||
function mergeBounds(total, next) {
|
||||
if (!next || next.width <= 0 || next.height <= 0) return total;
|
||||
if (!total) return { x: next.x, y: next.y, width: next.width, height: next.height };
|
||||
const left = Math.min(total.x, next.x);
|
||||
const bottom = Math.min(total.y, next.y);
|
||||
const right = Math.max(total.x + total.width, next.x + next.width);
|
||||
const top = Math.max(total.y + total.height, next.y + next.height);
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
/**
|
||||
* 采样若干姿态,用像素 alpha 求真实可见范围,避免取景跟着骨骼包围盒留大片空白。
|
||||
* 最快策略下采样点变少、探测画布变小,取景精度略降但省掉一大截渲染 + 回读。
|
||||
*/
|
||||
async function measureVisibleBounds(loaded, targetSkeleton, fallbackBounds, poseCount, applyPose, options) {
|
||||
if (!fallbackBounds || fallbackBounds.width <= 0 || fallbackBounds.height <= 0) return null;
|
||||
const probeOptions = options || {};
|
||||
const visibleBounds = window.SpineVisibleBounds;
|
||||
const size = probeOptions.probeSize || 512;
|
||||
const maxPoses = probeOptions.maxPoses || poseCount;
|
||||
const step = Math.max(1, Math.ceil(poseCount / maxPoses));
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.9 / fallbackBounds.width, size * 0.9 / fallbackBounds.height);
|
||||
const centerX = fallbackBounds.x + fallbackBounds.width / 2;
|
||||
const centerY = fallbackBounds.y + fallbackBounds.height / 2;
|
||||
let measured = null;
|
||||
let probe = null;
|
||||
try {
|
||||
probe = window.SpineCanvasClipping.createRenderer(loaded.spine, context, loaded.runtimeDir);
|
||||
probe.premultipliedAlpha = loaded.premultipliedAlpha;
|
||||
let previousIndex = -1;
|
||||
for (let index = 0; index < poseCount; index += step) {
|
||||
applyPose(index, previousIndex);
|
||||
previousIndex = index;
|
||||
context.setTransform(1, 0, 0, 1, 0, 0);
|
||||
context.clearRect(0, 0, size, size);
|
||||
context.save();
|
||||
context.translate(size / 2, size / 2);
|
||||
context.scale(scale, -scale);
|
||||
context.translate(-centerX, -centerY);
|
||||
probe.draw(targetSkeleton);
|
||||
context.restore();
|
||||
const pixels = visibleBounds.findAlphaBounds(context.getImageData(0, 0, size, size).data, size, size);
|
||||
measured = mergeBounds(measured, visibleBounds.toWorldBounds(pixels, size, size, scale, centerX, centerY));
|
||||
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (probe && probe.dispose) probe.dispose();
|
||||
}
|
||||
return measured;
|
||||
}
|
||||
|
||||
function profileFrameCount(sourceFrameCount, range, fps) {
|
||||
return Math.max(2, Math.min(sourceFrameCount, Math.ceil(range * fps)));
|
||||
}
|
||||
|
||||
function resizeFramePixels(data, sourceSize, targetSize) {
|
||||
if (sourceSize === targetSize) return data;
|
||||
const output = new Uint8Array(targetSize * targetSize * 4);
|
||||
for (let y = 0; y < targetSize; y++) {
|
||||
const sourceY = Math.min(sourceSize - 1, Math.floor((y + 0.5) * sourceSize / targetSize));
|
||||
for (let x = 0; x < targetSize; x++) {
|
||||
const sourceX = Math.min(sourceSize - 1, Math.floor((x + 0.5) * sourceSize / targetSize));
|
||||
const source = (sourceY * sourceSize + sourceX) * 4;
|
||||
output.set(data.subarray(source, source + 4), (y * targetSize + x) * 4);
|
||||
}
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
// 输出画布始终是 size×size:先把画面降到 detailSize,再放大填满。
|
||||
// 缩略图尺寸保持稳定,体积则随 detailSize 档位下降。
|
||||
function scaledFramePixels(data, size, detailSize) {
|
||||
return resizeFramePixels(resizeFramePixels(data, size, detailSize), detailSize, size);
|
||||
}
|
||||
|
||||
let badgeImagePromise = null;
|
||||
|
||||
function badgeImage() {
|
||||
if (!badgeImagePromise) {
|
||||
badgeImagePromise = new Promise((resolve) => {
|
||||
try {
|
||||
const image = new Image();
|
||||
image.onload = () => resolve(image);
|
||||
image.onerror = () => resolve(null);
|
||||
image.src = window.SpineBadge.dataUrl();
|
||||
} catch (error) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
}
|
||||
return badgeImagePromise;
|
||||
}
|
||||
|
||||
/** 一次把所有采样帧画到 size×size 上(含角标),后续每档只从这些像素重新编码。 */
|
||||
async function renderAnimatedSourceFrames(loaded, animation, options) {
|
||||
const utils = window.SpineUtils;
|
||||
const spine = loaded.spine;
|
||||
const size = options.canvasSize;
|
||||
const range = options.range;
|
||||
const frameCount = Math.max(2, Math.min(MAX_SOURCE_FRAMES, Math.ceil(range * SOURCE_SAMPLE_FPS)));
|
||||
const frameDuration = range / frameCount;
|
||||
const sampleTimes = Array.from({ length: frameCount }, (_, index) => index * frameDuration);
|
||||
const skeleton = new spine.Skeleton(loaded.data);
|
||||
utils.applySkin(skeleton, utils.preferredSkin(loaded.data));
|
||||
const stateData = new spine.AnimationStateData(loaded.data);
|
||||
stateData.defaultMix = 0;
|
||||
const state = new spine.AnimationState(stateData);
|
||||
|
||||
let bounds = null;
|
||||
for (let index = 0; index < sampleTimes.length; index++) {
|
||||
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
|
||||
bounds = mergeBounds(bounds, boundsOf(spine, skeleton));
|
||||
}
|
||||
if (!bounds || bounds.width <= 0 || bounds.height <= 0) throw new Error('骨骼数据没有可绘制的范围');
|
||||
bounds = await measureVisibleBounds(loaded, skeleton, bounds, sampleTimes.length, (index, previousIndex) => {
|
||||
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], previousIndex >= 0 ? sampleTimes[previousIndex] : undefined);
|
||||
}, options.probe) || bounds;
|
||||
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const ctx = canvas.getContext('2d', { willReadFrequently: true });
|
||||
const scale = Math.min(size * 0.8 / bounds.width, size * 0.8 / bounds.height);
|
||||
const centerX = bounds.x + bounds.width / 2;
|
||||
const centerY = bounds.y + bounds.height / 2;
|
||||
const badge = await badgeImage();
|
||||
const version = loaded.data.version || options.version || '';
|
||||
const frames = [];
|
||||
let renderer = null;
|
||||
try {
|
||||
renderer = window.SpineCanvasClipping.createRenderer(spine, ctx, loaded.runtimeDir);
|
||||
renderer.premultipliedAlpha = loaded.premultipliedAlpha;
|
||||
for (let index = 0; index < sampleTimes.length; index++) {
|
||||
applyAnimationPose(spine, skeleton, state, animation, sampleTimes[index], index ? sampleTimes[index - 1] : undefined);
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0);
|
||||
ctx.fillStyle = '#1e1e1e';
|
||||
ctx.fillRect(0, 0, size, size);
|
||||
ctx.save();
|
||||
ctx.translate(size / 2, size / 2);
|
||||
ctx.scale(scale, -scale);
|
||||
ctx.translate(-centerX, -centerY);
|
||||
renderer.draw(skeleton);
|
||||
ctx.restore();
|
||||
if (badge) {
|
||||
const badgeSize = Math.max(44, Math.round(size * 0.15));
|
||||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||||
try {
|
||||
ctx.drawImage(badge, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
|
||||
} catch (error) { /* 缺少 badge 不影响主体 */ }
|
||||
}
|
||||
window.SpineBadge.drawVersion(ctx, size, size, version);
|
||||
// 动态 WebP 用全帧替换,透明区域直接压成背景色,省掉 alpha 通道的体积。
|
||||
const rgba = new Uint8Array(ctx.getImageData(0, 0, size, size).data);
|
||||
for (let alpha = 3; alpha < rgba.length; alpha += 4) rgba[alpha] = 255;
|
||||
frames.push(rgba);
|
||||
if (options.onProgress) options.onProgress(index + 1, frameCount);
|
||||
if (index % 6 === 5) await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
} finally {
|
||||
if (renderer && renderer.dispose) renderer.dispose();
|
||||
}
|
||||
return { frames, size, range };
|
||||
}
|
||||
|
||||
/** 轮转 4 张离屏画布,把 RGBA 像素编成 WebP 静帧。 */
|
||||
function createFrameEncoder(size) {
|
||||
const pool = Array.from({ length: FRAME_POOL_SIZE }, () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = size;
|
||||
canvas.height = size;
|
||||
const context = canvas.getContext('2d');
|
||||
return { canvas, context, image: context.createImageData(size, size) };
|
||||
});
|
||||
return (slot, pixels, quality) => new Promise((resolve, reject) => {
|
||||
const unit = pool[slot % pool.length];
|
||||
unit.image.data.set(pixels);
|
||||
unit.context.putImageData(unit.image, 0, 0);
|
||||
unit.canvas.toBlob((blob) => {
|
||||
if (!blob || blob.type !== 'image/webp') {
|
||||
reject(new Error('当前环境不支持 WebP 编码'));
|
||||
return;
|
||||
}
|
||||
blob.arrayBuffer().then((data) => resolve(Buffer.from(data))).catch(reject);
|
||||
}, 'image/webp', quality);
|
||||
});
|
||||
}
|
||||
|
||||
// 用动画 1/3 和 2/3 处的两帧估算整档体积:避开首尾信息不全的帧,按整数帧对齐。
|
||||
async function estimateAnimatedBytes(profile, sourceFrames, size, range, encodeFrame) {
|
||||
const count = profileFrameCount(sourceFrames.length, range, profile.fps);
|
||||
const indexes = [Math.round(count / 3), Math.round(count * 2 / 3)];
|
||||
let total = 0;
|
||||
for (let slot = 0; slot < indexes.length; slot++) {
|
||||
const sourceIndex = Math.min(sourceFrames.length - 1, Math.floor(indexes[slot] * sourceFrames.length / count));
|
||||
const pixels = scaledFramePixels(sourceFrames[sourceIndex], size, profile.size);
|
||||
total += (await encodeFrame(slot, pixels, profile.quality)).length;
|
||||
}
|
||||
return (total / indexes.length) * count;
|
||||
}
|
||||
|
||||
// 沿整条搜索路径粗估体积(每点只编两帧),再按「目标体积的 0.5× ~ 2× 对数间隔」取候选。
|
||||
// 返回顺序就是从预计体积最小到最大,最快策略只需要前面几档。
|
||||
async function pickAnimatedCandidates(searchPath, sourceFrames, size, range, targetBytes, count, encodeFrame, samplePoints) {
|
||||
const stride = Math.max(1, Math.ceil(searchPath.length / (samplePoints || 24)));
|
||||
const samples = [];
|
||||
for (let index = 0; index < searchPath.length; index += stride) {
|
||||
samples.push({
|
||||
index,
|
||||
bytes: await estimateAnimatedBytes(searchPath[index], sourceFrames, size, range, encodeFrame),
|
||||
});
|
||||
}
|
||||
const lastIndex = searchPath.length - 1;
|
||||
if (samples[samples.length - 1].index !== lastIndex) {
|
||||
samples.push({
|
||||
index: lastIndex,
|
||||
bytes: await estimateAnimatedBytes(searchPath[lastIndex], sourceFrames, size, range, encodeFrame),
|
||||
});
|
||||
}
|
||||
// 先按体积从小到大排:配置允许分段,排序后再取点才能保证候选体积递增。
|
||||
samples.sort((left, right) => left.bytes - right.bytes);
|
||||
// 体积 → 参数:在「体积相邻」的两个采样点之间插值,候选因此可以落在配置表之外。
|
||||
const profileForBytes = (wantedBytes) => {
|
||||
let upper = samples.length - 1;
|
||||
for (let index = 0; index < samples.length; index++) {
|
||||
if (samples[index].bytes >= wantedBytes) { upper = index; break; }
|
||||
}
|
||||
const lower = Math.max(0, upper - 1);
|
||||
const before = searchPath[samples[lower].index];
|
||||
const after = searchPath[samples[upper].index];
|
||||
const gap = samples[upper].bytes - samples[lower].bytes;
|
||||
const ratio = gap > 0 ? Math.max(0, Math.min(1, (wantedBytes - samples[lower].bytes) / gap)) : 0;
|
||||
const mix = (start, end) => start + (end - start) * ratio;
|
||||
return {
|
||||
size: Math.max(1, Math.round(mix(before.size, after.size))),
|
||||
fps: Math.max(1, Math.round(mix(before.fps, after.fps))),
|
||||
quality: Math.round(mix(before.quality, after.quality) * 100) / 100,
|
||||
};
|
||||
};
|
||||
const low = Math.log(Math.max(samples[0].bytes, targetBytes * 0.5));
|
||||
const high = Math.log(Math.min(samples[samples.length - 1].bytes, targetBytes * 2));
|
||||
const span = Math.max(1, count - 1);
|
||||
const picked = [];
|
||||
for (let slot = 0; slot < count; slot++) {
|
||||
picked.push(profileForBytes(Math.exp(low + (high - low) * slot / span)));
|
||||
}
|
||||
return picked;
|
||||
}
|
||||
|
||||
/** 按一档参数把采样帧全部编码并封装成动态 WebP。 */
|
||||
async function encodeAnimatedProfile(profile, source, encodeFrame) {
|
||||
const count = profileFrameCount(source.frames.length, source.range, profile.fps);
|
||||
const duration = Math.max(1, Math.round(source.range * 1000 / count));
|
||||
const frames = [];
|
||||
// 帧编码是异步的,一批 4 帧(正好对上画布池)一起提交,比逐帧 await 快得多。
|
||||
for (let start = 0; start < count; start += FRAME_POOL_SIZE) {
|
||||
const batch = [];
|
||||
for (let index = start; index < Math.min(count, start + FRAME_POOL_SIZE); index++) {
|
||||
const sourceIndex = Math.min(source.frames.length - 1, Math.floor(index * source.frames.length / count));
|
||||
batch.push({
|
||||
slot: index % FRAME_POOL_SIZE,
|
||||
pixels: scaledFramePixels(source.frames[sourceIndex], source.size, profile.size),
|
||||
});
|
||||
}
|
||||
const buffers = await Promise.all(batch.map(({ slot, pixels }) => encodeFrame(slot, pixels, profile.quality)));
|
||||
for (const webp of buffers) frames.push({ webp, duration });
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return {
|
||||
buffer: window.SpineAnimatedWebpEncoder.encode({
|
||||
width: source.size,
|
||||
height: source.size,
|
||||
frames,
|
||||
plays: 0,
|
||||
}),
|
||||
frames: count,
|
||||
detail: profile.size,
|
||||
fps: profile.fps,
|
||||
quality: profile.quality,
|
||||
};
|
||||
}
|
||||
|
||||
/** 所有候选里体积最小的那一档(主插件画廊默认打勾选中的就是它)。 */
|
||||
function smallestCandidate(candidates) {
|
||||
return candidates.reduce(
|
||||
(best, candidate) => (!best || candidate.buffer.length < best.buffer.length ? candidate : best),
|
||||
null,
|
||||
);
|
||||
}
|
||||
|
||||
async function pickAnimatedThumbnail(source, options) {
|
||||
const config = window.SpineAnimatedProfiles;
|
||||
const fast = options.strategy === 'speed';
|
||||
const encodeFrame = createFrameEncoder(source.size);
|
||||
const targetBytes = options.maxKilobytes * 1024;
|
||||
const searchPath = config.expandAnimatedPath(options.priority, options.canvasSize);
|
||||
const candidateCount = config.animatedProfiles(options.priority, options.canvasSize).length;
|
||||
const profiles = await pickAnimatedCandidates(
|
||||
searchPath, source.frames, source.size, source.range, targetBytes, candidateCount, encodeFrame,
|
||||
fast ? SPEED_ESTIMATE_POINTS : undefined,
|
||||
);
|
||||
// 用户看不到候选画廊,就完全照主插件的默认选择来,取实际体积最小的那一档。
|
||||
// 最优体积:十档全生成再比;最优速度:只生成粗估最小的那几档(粗估已经按体积排过序)。
|
||||
const wanted = fast ? profiles.slice(0, SPEED_CANDIDATES) : profiles;
|
||||
const candidates = [];
|
||||
for (const profile of wanted) {
|
||||
candidates.push(await encodeAnimatedProfile(profile, source, encodeFrame));
|
||||
}
|
||||
const best = smallestCandidate(candidates);
|
||||
return {
|
||||
...best,
|
||||
// 最小档仍然超预算时告诉用户,让他考虑改配置里的目标体积。
|
||||
fits: best.buffer.length <= targetBytes,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成动态缩略图。骨骼里没有可用动画时返回 null,交给调用方回退到 setup pose 静帧。
|
||||
* options: { skeletonPath, atlasPath, texturePaths, runtimeDir, isBinary, version,
|
||||
* canvasSize, priority, maxKilobytes, onProgress }
|
||||
*/
|
||||
async function renderAnimatedThumbnail(options) {
|
||||
const config = window.SpineAnimatedProfiles;
|
||||
const loaded = await loadSkeleton(options);
|
||||
const animation = firstAnimation(loaded.data);
|
||||
if (!animation) return null;
|
||||
const canvasSize = options.canvasSize || config.DEFAULT_OUTPUT_SIZE;
|
||||
const range = animationDurationOf(animation);
|
||||
const maxKilobytes = options.maxKilobytes || config.suggestSize(range);
|
||||
const fast = (options.strategy || DEFAULT_STRATEGY) === 'speed';
|
||||
const source = await renderAnimatedSourceFrames(loaded, animation, {
|
||||
canvasSize,
|
||||
range,
|
||||
version: options.version,
|
||||
onProgress: options.onProgress,
|
||||
// 最快策略用小画布、少采样点做可见范围探测。
|
||||
probe: fast ? { probeSize: SPEED_PROBE_SIZE, maxPoses: SPEED_PROBE_POSES } : null,
|
||||
});
|
||||
const picked = await pickAnimatedThumbnail(source, {
|
||||
priority: options.priority || config.DEFAULT_PRIORITY,
|
||||
maxKilobytes,
|
||||
canvasSize,
|
||||
strategy: options.strategy || DEFAULT_STRATEGY,
|
||||
});
|
||||
return {
|
||||
buffer: picked.buffer,
|
||||
extension: '.webp',
|
||||
width: source.size,
|
||||
height: source.size,
|
||||
mode: 'animated',
|
||||
animation: animation.name,
|
||||
version: loaded.data.version || options.version || '',
|
||||
frames: picked.frames,
|
||||
detail: picked.detail,
|
||||
fps: picked.fps,
|
||||
quality: picked.quality,
|
||||
duration: range,
|
||||
targetKilobytes: maxKilobytes,
|
||||
strategy: options.strategy || DEFAULT_STRATEGY,
|
||||
fits: picked.fits,
|
||||
};
|
||||
}
|
||||
|
||||
/** 静帧产物统一成和动态一样的返回结构,调用方只认 buffer + extension。 */
|
||||
async function renderStillBuffer(options) {
|
||||
const still = await renderThumbnail(options);
|
||||
return {
|
||||
buffer: Buffer.from(await still.blob.arrayBuffer()),
|
||||
extension: '.png',
|
||||
width: still.width,
|
||||
height: still.height,
|
||||
mode: 'still',
|
||||
version: still.version,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 入口:mode === 'static' 一律出 setup pose 静帧;否则有动画就出动态 WebP,
|
||||
* 没有动画回退静帧(reason 里说明原因,日志会带出来)。
|
||||
*/
|
||||
async function renderThumbnailAuto(options) {
|
||||
if (options.mode === 'static') return renderStillBuffer(options);
|
||||
try {
|
||||
const animated = await renderAnimatedThumbnail(options);
|
||||
if (animated) return animated;
|
||||
return { ...(await renderStillBuffer(options)), fallback: t('thumb.fallbackNoAnimation') };
|
||||
} catch (error) {
|
||||
const still = await renderStillBuffer(options);
|
||||
return { ...still, fallback: t('thumb.fallbackAnimatedFailed', { error: (error && error.message) || error }) };
|
||||
}
|
||||
}
|
||||
|
||||
// badge 已经在 spine-badge.js 里以 data URL 缓存过,这里直接取用。
|
||||
function drawBadge(ctx, size) {
|
||||
return new Promise((resolve) => {
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
const badgeSize = Math.max(44, Math.round(size * 0.15));
|
||||
const margin = Math.max(10, Math.round(badgeSize * 0.2));
|
||||
try {
|
||||
ctx.drawImage(image, size - badgeSize - margin, size - badgeSize - margin, badgeSize, badgeSize);
|
||||
} catch (error) { /* 缺少 badge 不影响主体 */ }
|
||||
resolve();
|
||||
};
|
||||
image.onerror = () => resolve();
|
||||
image.src = window.SpineBadge.dataUrl();
|
||||
});
|
||||
}
|
||||
|
||||
const api = {
|
||||
renderThumbnail,
|
||||
renderThumbnailAuto,
|
||||
renderAnimatedThumbnail,
|
||||
loadRuntime,
|
||||
loadSkeleton,
|
||||
firstAnimation,
|
||||
animationDurationOf,
|
||||
profileFrameCount,
|
||||
resizeFramePixels,
|
||||
scaledFramePixels,
|
||||
pickAnimatedCandidates,
|
||||
smallestCandidate,
|
||||
withTimeout,
|
||||
};
|
||||
if (typeof window !== 'undefined') window.SpineThumbRenderer = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,43 @@
|
||||
// 插件设置持久化。默认关闭自动生成——Eagle 审核标准要求「批量或不可逆的变更
|
||||
// 必须事先明确说明并获得确认」,所以必须由用户主动打开这个开关。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// mode: 'animated' = 有动画就出动态 WebP(没有动画回退静帧),'static' = 一律 setup pose 静帧。
|
||||
// strategy: 'size' = 十档全生成取体积最小的(慢、最省体积),'speed' = 只生成粗估最小的那几档。
|
||||
const DEFAULTS = { enabled: false, mode: 'animated', strategy: 'size' };
|
||||
|
||||
/** 存放在 Eagle 的 userData 目录,不写进素材库,卸载时可直接删掉。 */
|
||||
async function resolveFile() {
|
||||
const userData = await eagle.app.getPath('userData');
|
||||
return path.join(userData, 'SpineThumbnailGenerateServer', 'settings.json');
|
||||
}
|
||||
|
||||
async function read() {
|
||||
const file = await resolveFile();
|
||||
try {
|
||||
const data = JSON.parse(fs.readFileSync(file, 'utf8'));
|
||||
return { ...DEFAULTS, ...data };
|
||||
} catch (error) {
|
||||
if (error && error.code === 'ENOENT') return { ...DEFAULTS };
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async function write(next) {
|
||||
const file = await resolveFile();
|
||||
fs.mkdirSync(path.dirname(file), { recursive: true });
|
||||
const body = JSON.stringify({ ...DEFAULTS, ...next }, null, 2) + '\n';
|
||||
const temp = file + '.' + process.pid + '.tmp';
|
||||
fs.writeFileSync(temp, body, 'utf8');
|
||||
fs.renameSync(temp, file);
|
||||
}
|
||||
|
||||
const api = { read, write, resolveFile, DEFAULTS };
|
||||
if (typeof window !== 'undefined') window.SpineThumbSettings = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,140 @@
|
||||
// Node 里用 require(跑测试),插件页面里用 <script> 标签加载后挂到 window。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
const SKELETON_PATTERN = /\.(skel|skel\.bytes|json)$/i;
|
||||
const ATLAS_PATTERN = /\.(atlas|atlas\.txt)$/i;
|
||||
const TEXTURE_PATTERN = /\.(png|jpe?g|webp)$/i;
|
||||
const THUMBNAIL_PATTERN = /_thumbnail\.(png|webp)$/i;
|
||||
|
||||
/**
|
||||
* 分析一个 Eagle item 目录。
|
||||
*
|
||||
* Eagle 会把导入的每个文件拆成独立的 <id>.info 目录,所以一套 Spine 资产的骨架、
|
||||
* atlas、贴图通常分散在多个目录里,必须跨目录配对。配对逻辑不重写,直接复用主插件的
|
||||
* discoverAssets(后缀表、同名筛选、路径校验都在里面)。
|
||||
*/
|
||||
function analyzeItemDir(itemDir, dirName, utils, pairCache, searchRoots, refreshIndex) {
|
||||
let files;
|
||||
try {
|
||||
files = fs.readdirSync(itemDir);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
// metadata.json 是 Eagle 的 item 元数据,不是骨骼文件,必须排除。
|
||||
const skeletons = files.filter((name) => SKELETON_PATTERN.test(name) && name.toLowerCase() !== 'metadata.json');
|
||||
const atlases = files.filter((name) => ATLAS_PATTERN.test(name));
|
||||
const textures = files.filter((name) => TEXTURE_PATTERN.test(name) && !THUMBNAIL_PATTERN.test(name));
|
||||
// .skel / .skel.bytes 的后缀够特定,直接信任;.json 必须确认内容真的是 Spine 骨骼,
|
||||
// 否则任何普通 JSON(甚至改了后缀的其它数据)都会被误当成骨骼文件。
|
||||
const usableSkeletons = skeletons.filter((name) => {
|
||||
if (!/\.json$/i.test(name)) return true;
|
||||
try {
|
||||
// classifySkeletonJson 的肯定标识是 'spine',普通 JSON 是 'other',仍在写入是 'pending'。
|
||||
return utils.classifySkeletonJson(path.join(itemDir, name)) === 'spine';
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
});
|
||||
// 一个目录里既没有可用骨架也没有 atlas,说明它不是 Spine 资产的入口。
|
||||
if (!usableSkeletons.length && !atlases.length) return null;
|
||||
|
||||
const entryName = usableSkeletons[0] || atlases[0];
|
||||
// Eagle 删除素材只是把 item 的 metadata.json 标记成 isDeleted,目录和文件都留在
|
||||
// 磁盘上。这种目录仍然会出现在「新增目录」里,必须在配对前挡掉,否则会给回收站
|
||||
// 里的素材渲染一次缩略图(白做,还可能算成失败)。
|
||||
if (utils.isItemDeleted(path.join(itemDir, entryName))) return null;
|
||||
let assets = null;
|
||||
// 一套资产的多个入口(atlas / skel / json)配对结果完全一样,缓存下来避免重复扫描。
|
||||
// 缓存键必须带上 Eagle 文件夹:同名素材可能分属不同文件夹(同一示例的不同版本就是这样),
|
||||
// 只用 stem 会把文件夹 A 的配对结果复用给文件夹 B。
|
||||
let folders = [];
|
||||
try {
|
||||
folders = utils.foldersOfItem(path.join(itemDir, entryName));
|
||||
} catch (error) { /* 读不到就按未分类处理 */ }
|
||||
const folderKey = folders.slice().sort().join(',') || 'root';
|
||||
const cacheKey = utils.normalizeStem(entryName) + '|' + folderKey;
|
||||
if (pairCache && pairCache.has(cacheKey)) {
|
||||
assets = pairCache.get(cacheKey);
|
||||
} else {
|
||||
try {
|
||||
// searchRoots 限定在本次新增的目录里。不传的话 Utils 会把搜索范围扩到整个库,
|
||||
// 几百上千个素材时一次配对就要一秒以上——Eagle 的「文件夹」只是逻辑分组,
|
||||
// 磁盘上所有素材都平铺在 images/ 下,所以「扫同级」等于「扫全库」。
|
||||
assets = utils.discoverAssets(path.join(itemDir, entryName), {
|
||||
strictStem: true,
|
||||
searchRoots,
|
||||
// 文件索引有 1.5 秒缓存,刚导入的目录可能还没进索引,
|
||||
// 所以每批的第一个目录强制刷新,其余复用。
|
||||
refreshIndex,
|
||||
});
|
||||
} catch (error) {
|
||||
assets = null;
|
||||
}
|
||||
if (pairCache) pairCache.set(cacheKey, assets);
|
||||
}
|
||||
|
||||
// 贴图是否齐全要看 atlas 里声明了哪些页面,再看这些页面能不能在库里找到。
|
||||
let pages = 0;
|
||||
let missingTextures = 0;
|
||||
if (assets && assets.atlasPath) {
|
||||
try {
|
||||
const atlasText = fs.readFileSync(assets.atlasPath, 'utf8');
|
||||
const pageNames = utils.parseAtlasPages(atlasText);
|
||||
pages = pageNames.length;
|
||||
missingTextures = pageNames.filter((name) => !assets.texturePaths[name]).length;
|
||||
} catch (error) { /* atlas 读不了就当作不完整 */ }
|
||||
}
|
||||
|
||||
// 骨架路径必须是骨骼文件后缀,防止配对结果指到贴图或其它文件上。
|
||||
const skeletonPath = (assets && assets.skeletonPath) || null;
|
||||
const skeletonOk = !!skeletonPath && /\.(skel|skel\.bytes|json)$/i.test(skeletonPath);
|
||||
return {
|
||||
dirName,
|
||||
// 日志里要显示 Eagle 界面上的文件名和文件夹路径,而不是 MU9RVSxxxx.info 这种 id。
|
||||
entryName,
|
||||
folders,
|
||||
skeletonPath,
|
||||
atlasPath: (assets && assets.atlasPath) || null,
|
||||
texturePaths: (assets && assets.texturePaths) || {},
|
||||
// 必须按「实际要读的那个骨架文件」判断是否二进制:配对的骨架可能是同名的 .skel,
|
||||
// 而当前目录里放的是 .json,用目录里的文件名判断就会拿 JSON 解析器去读二进制。
|
||||
isBinary: /\.(skel|skel\.bytes)$/i.test(skeletonPath || entryName),
|
||||
hasSkeletonInDir: usableSkeletons.length > 0,
|
||||
hasAtlasInDir: atlases.length > 0,
|
||||
hasTextureInDir: textures.length > 0,
|
||||
pages,
|
||||
missingTextures,
|
||||
complete: !!(skeletonOk && assets.atlasPath && pages > 0 && missingTextures === 0),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 扫描指定的 item 目录,返回其中的 Spine 资产(其他素材会被过滤掉)。
|
||||
* 每处理完一个目录就让出一次主线程:discoverAssets 是同步扫全库的重活,
|
||||
* 连续跑几十个会把界面彻底卡住,日志写了也刷不出来。
|
||||
*/
|
||||
async function scanSpineItems(imagesDir, dirNames, utils, onProgress) {
|
||||
const found = [];
|
||||
// 本次新增的目录就是配对范围:配套的骨架 / atlas / 贴图在同一次导入里,
|
||||
// 因此不需要、也不应该去翻整个素材库。
|
||||
const searchRoots = dirNames.map((dirName) => path.resolve(imagesDir, dirName));
|
||||
const pairCache = new Map();
|
||||
for (let index = 0; index < dirNames.length; index++) {
|
||||
const dirName = dirNames[index];
|
||||
const info = analyzeItemDir(path.join(imagesDir, dirName), dirName, utils, pairCache, searchRoots, index === 0);
|
||||
if (info) found.push(info);
|
||||
if (onProgress) onProgress(index + 1, dirNames.length);
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
const api = { analyzeItemDir, scanSpineItems, SKELETON_PATTERN, ATLAS_PATTERN, TEXTURE_PATTERN };
|
||||
if (typeof window !== 'undefined') window.SpineThumbSpineAssets = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,31 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'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(); }
|
||||
}
|
||||
|
||||
const api = { drawCheckerboard };
|
||||
if (typeof window !== 'undefined') window.SpineBackground = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,49 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
// 插件根目录必须延迟计算:Eagle 在 onPluginCreate 里才给出 plugin.path,
|
||||
// 那时代码已经加载完了,所以这里用函数而不是常量。
|
||||
function pluginRootPath() {
|
||||
if (typeof window !== 'undefined') {
|
||||
const fromEagle = window.SpinePluginPath || window.SpinePluginRoot;
|
||||
if (fromEagle) return fromEagle;
|
||||
}
|
||||
return typeof __dirname !== 'undefined' ? path.join(__dirname, '..') : '.';
|
||||
}
|
||||
|
||||
function badgeFile() {
|
||||
return path.join(pluginRootPath(), 'assets', 'spine-badge.png');
|
||||
}
|
||||
let cachedDataUrl = null;
|
||||
|
||||
function dataUrl() {
|
||||
if (!cachedDataUrl) {
|
||||
cachedDataUrl = 'data:image/png;base64,' + fs.readFileSync(badgeFile()).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(); }
|
||||
}
|
||||
|
||||
const api = { get path() { return badgeFile(); }, dataUrl, drawVersion };
|
||||
if (typeof window !== 'undefined') window.SpineBadge = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,253 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'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;
|
||||
// 页面里 rasterizer 已经由 <script> 提前加载并挂在 window 上,Node 里才走 require。
|
||||
const rasterizer = typeof window !== 'undefined' && window.SpineWebglRasterizer
|
||||
? window.SpineWebglRasterizer
|
||||
: require('./spine-webgl-rasterizer.js');
|
||||
return rasterizer.attach(renderer, context);
|
||||
}
|
||||
|
||||
const api = { createRenderer };
|
||||
if (typeof window !== 'undefined') window.SpineCanvasClipping = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,263 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'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;
|
||||
// 4.x 的运行时用 Object.defineProperty 导出,同名属性是只读 getter,写入会抛
|
||||
// "which has only a getter"。这里的替换只对确实缺少 SkeletonBinary 的老版本有意义,
|
||||
// 失败时保留运行时自带实现,绝不能让一次赋值把整个渲染流程打断。
|
||||
try {
|
||||
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));
|
||||
}
|
||||
};
|
||||
} catch (error) {
|
||||
// 只读属性:沿用运行时自带实现。
|
||||
}
|
||||
return spine;
|
||||
}
|
||||
|
||||
const api = { install, decode };
|
||||
if (typeof window !== 'undefined') window.SpineLegacyBinary = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,874 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
// service 插件页面的 require 不以插件目录为基准,所以页面里必须走 <script>。
|
||||
(function () {
|
||||
/**
|
||||
* 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 导出常见的尾缀,匹配时归一化掉(支持 - 与 _ 两种分隔符)
|
||||
// 例:hero-mesh.skel / hero-data.json / hero-pma.atlas 都归一为 hero
|
||||
const DEFAULT_SUFFIXES = ['pro', 'ess', 'pma', 'base', 'atlas', 'data', 'mesh'];
|
||||
// 用户可在查看器里添加自己的尾缀;为避免正则注入,只允许字母、数字、- 与 _,
|
||||
// 且必须以字母或数字开头。SUFFIX_MAX_LENGTH 必须与下面的正则保持一致(有测试守着)。
|
||||
const SUFFIX_MAX_LENGTH = 50;
|
||||
const SUFFIX_NAME_RE = /^[a-z0-9](?:[a-z0-9_-]{0,49})$/i;
|
||||
let activeSuffixes = DEFAULT_SUFFIXES.slice();
|
||||
let suffixRe = buildSuffixRe(activeSuffixes);
|
||||
|
||||
function escapeRegExp(text) {
|
||||
return String(text).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function buildSuffixRe(list) {
|
||||
if (!list || list.length === 0) return null;
|
||||
return new RegExp('[-_](' + list.map(escapeRegExp).join('|') + ')$', 'i');
|
||||
}
|
||||
|
||||
/** 校验并归一化单个尾缀名(去掉前导分隔符并转小写);非法返回 null */
|
||||
function normalizeSuffixName(raw) {
|
||||
const name = String(raw == null ? '' : raw).trim().replace(/^[-_]+/, '');
|
||||
return SUFFIX_NAME_RE.test(name) ? name.toLowerCase() : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* 设置用户自定义尾缀(内置尾缀始终生效),返回当前生效的完整列表。
|
||||
* 非法或重复的名字会被安静地忽略,不信任磁盘上的内容。
|
||||
*/
|
||||
function setSuffixes(list) {
|
||||
const custom = [];
|
||||
for (const raw of Array.isArray(list) ? list : []) {
|
||||
const name = normalizeSuffixName(raw);
|
||||
if (!name || DEFAULT_SUFFIXES.indexOf(name) !== -1) continue;
|
||||
if (custom.indexOf(name) === -1) custom.push(name);
|
||||
}
|
||||
activeSuffixes = DEFAULT_SUFFIXES.concat(custom);
|
||||
suffixRe = buildSuffixRe(activeSuffixes);
|
||||
return getSuffixes();
|
||||
}
|
||||
|
||||
/** 当前生效的尾缀(内置 + 自定义) */
|
||||
function getSuffixes() {
|
||||
return activeSuffixes.slice();
|
||||
}
|
||||
|
||||
/** 仅用户自定义(非内置)的尾缀,用于持久化 */
|
||||
function getCustomSuffixes() {
|
||||
return activeSuffixes.filter((name) => DEFAULT_SUFFIXES.indexOf(name) === -1);
|
||||
}
|
||||
|
||||
function unsafeAtlasPath(name, reason) {
|
||||
const error = new Error('Atlas 贴图路径不安全:' + JSON.stringify(String(name)) + '(' + reason + ')');
|
||||
error.code = 'UNSAFE_ATLAS_PATH';
|
||||
return error;
|
||||
}
|
||||
|
||||
/**
|
||||
* Atlas 页面名来自不可信文本。先用同时覆盖 POSIX/Windows 的规则验证,
|
||||
* 再交给本机 path API,避免在 macOS 上漏掉 C:\\、UNC 或反斜杠形式的越界路径。
|
||||
*/
|
||||
function validateAtlasPageName(name) {
|
||||
const raw = String(name == null ? '' : name).trim();
|
||||
if (!raw) throw unsafeAtlasPath(name, '路径为空');
|
||||
if (/[\0-\x1f\x7f]/.test(raw)) throw unsafeAtlasPath(raw, '包含控制字符');
|
||||
const portable = raw.replace(/\\/g, '/');
|
||||
if (path.posix.isAbsolute(portable) || path.win32.isAbsolute(raw) || path.win32.isAbsolute(portable)) {
|
||||
throw unsafeAtlasPath(raw, '不允许绝对路径');
|
||||
}
|
||||
if (portable.includes(':')) throw unsafeAtlasPath(raw, '不允许盘符、URI 或替代数据流');
|
||||
const parts = portable.split('/');
|
||||
if (parts.some((part) => part === '..')) throw unsafeAtlasPath(raw, '不允许 .. 路径段');
|
||||
const normalized = path.posix.normalize(portable);
|
||||
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
|
||||
throw unsafeAtlasPath(raw, '路径无效');
|
||||
}
|
||||
if (!TEXTURE_EXTS.includes(path.posix.extname(normalized).toLowerCase())) {
|
||||
throw unsafeAtlasPath(raw, '不是支持的贴图格式');
|
||||
}
|
||||
return normalized.replace(/^\.\//, '');
|
||||
}
|
||||
|
||||
function isPathInside(basePath, targetPath) {
|
||||
const relative = path.relative(basePath, targetPath);
|
||||
return relative === '' || (!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith('..' + path.sep));
|
||||
}
|
||||
|
||||
function realpath(filePath) {
|
||||
const implementation = fs.realpathSync.native || fs.realpathSync;
|
||||
return implementation(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析已存在的普通文件,并同时检查词法路径和 realpath 边界。
|
||||
* realpath 检查会阻止目录内的符号链接跳到允许目录之外。
|
||||
*/
|
||||
function resolveFileWithin(baseDir, relativeName, atlasPage) {
|
||||
const safeName = atlasPage ? validateAtlasPageName(relativeName) : String(relativeName);
|
||||
const base = path.resolve(baseDir);
|
||||
const candidate = path.resolve(base, ...safeName.replace(/\\/g, '/').split('/'));
|
||||
if (!isPathInside(base, candidate)) {
|
||||
if (atlasPage) throw unsafeAtlasPath(relativeName, '路径越出允许目录');
|
||||
return null;
|
||||
}
|
||||
if (!fs.existsSync(candidate)) return null;
|
||||
let realBase;
|
||||
let realCandidate;
|
||||
try {
|
||||
realBase = realpath(base);
|
||||
realCandidate = realpath(candidate);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
if (!isPathInside(realBase, realCandidate)) {
|
||||
if (atlasPage) throw unsafeAtlasPath(relativeName, '符号链接越出允许目录');
|
||||
return null;
|
||||
}
|
||||
try { if (!fs.statSync(realCandidate).isFile()) return null; } catch (e) { return null; }
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function resolveAtlasTextureFile(atlasDir, pageName) {
|
||||
return resolveFileWithin(atlasDir, pageName, true);
|
||||
}
|
||||
|
||||
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,去掉内置与用户自定义的尾缀,用于跨文件匹配 */
|
||||
function normalizeStem(name) {
|
||||
const lower = stemOf(name).toLowerCase();
|
||||
return suffixRe ? lower.replace(suffixRe, '') : lower;
|
||||
}
|
||||
|
||||
// 复合后缀:Eagle 按「最后一个点」拆 name / ext,于是 EmoticonSpine.atlas.txt 变成
|
||||
// name=EmoticonSpine.atlas + ext=txt。而 Eagle 的卡片把 ext === 'txt' 写死成文本预览,
|
||||
// 会整个盖掉自定义缩略图。把整段复合后缀塞进 ext(name=EmoticonSpine + ext=atlas.txt),
|
||||
// Eagle 认不出这个类型,就会退回「用自定义缩略图当封面」,而 name + '.' + ext 拼出来
|
||||
// 仍是磁盘上的真实文件名,显示和路径都不变。
|
||||
const COMPOUND_SUFFIXES = ['.skel.bytes', '.atlas.txt'];
|
||||
|
||||
/** 返回 { name, ext };不是复合后缀(或没有主干名)时返回 null。 */
|
||||
function splitCompoundName(fileName) {
|
||||
const lower = String(fileName || '').toLowerCase();
|
||||
const suffix = COMPOUND_SUFFIXES.find((s) => lower.endsWith(s));
|
||||
if (!suffix) return null;
|
||||
const name = String(fileName).slice(0, fileName.length - suffix.length);
|
||||
if (!name) return null;
|
||||
return { name, ext: suffix.slice(1) };
|
||||
}
|
||||
|
||||
/** 从 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) {
|
||||
// Eagle 库内只允许真实位置仍处于 images/ 下的普通 .info 目录。
|
||||
// 库外仅用于本地测试,仍把当前目录作为唯一边界。
|
||||
const probe = path.join(root, '__spineviewer_probe__');
|
||||
const libRoot = findLibraryRoot(probe);
|
||||
if (libRoot) {
|
||||
try {
|
||||
const imagesReal = realpath(path.join(libRoot, 'images'));
|
||||
const rootReal = realpath(root);
|
||||
if (!isPathInside(imagesReal, rootReal) || !isEagleItemDir(root)) continue;
|
||||
} catch (e) { continue; }
|
||||
}
|
||||
let entries;
|
||||
try { entries = fs.readdirSync(root); } catch (e) { continue; }
|
||||
for (const name of entries) {
|
||||
if (!extOf(name)) continue; // 只关心 Spine 相关扩展
|
||||
// readdir 的结果也必须通过 realpath 边界校验,不能信任 .info 中的符号链接。
|
||||
const full = resolveFileWithin(root, name, false);
|
||||
if (!full) continue;
|
||||
// 排除 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”(精确、不跨批次)。
|
||||
let roots;
|
||||
if (Array.isArray(options.searchRoots) && options.searchRoots.length) {
|
||||
roots = [...new Set(options.searchRoots.map((root) => path.resolve(root)))];
|
||||
} else {
|
||||
roots = getSearchRoots(filePath);
|
||||
// 兼容旧的非查询调用:扩展到整个库。查看器会传入 Eagle API
|
||||
// 明确列举的候选 item 目录,避免每次预览扫描整库。
|
||||
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;
|
||||
if (strictStem) {
|
||||
const allFiles = collectSpineFilesCached(filePath, roots, options.refreshIndex);
|
||||
// 无论是否允许跨批次,都必须限定在同一个 Eagle 文件夹内。同名素材很常见
|
||||
//(同一套资源的 3.6 与 4.0 导出、同一角色的不同批次),只按文件名 stem
|
||||
// 匹配会配到别的版本,渲染时要么区域找不到,要么贴图坐标整个错位。
|
||||
files = validFolders && validFolders.size
|
||||
? allFiles.filter((f) => foldersOfItem(f.fullPath).some((id) => validFolders.has(id)))
|
||||
: allFiles;
|
||||
} else {
|
||||
files = collectSpineFiles(roots, validFolders);
|
||||
}
|
||||
|
||||
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 目录内的安全相对路径;realpath 必须仍在目录内。
|
||||
const inAtlasDir = resolveAtlasTextureFile(atlasDir, pn);
|
||||
if (inAtlasDir) {
|
||||
texturePaths[pn] = inAtlasDir;
|
||||
continue;
|
||||
}
|
||||
// 2) 跨素材时只能使用上面已枚举并验证的 Eagle 素材,不能由 atlas 拼接路径。
|
||||
const pageFileName = path.posix.basename(pn);
|
||||
let cands = textureFiles.filter((f) => f.name.toLowerCase() === pageFileName.toLowerCase());
|
||||
if (cands.length === 0) cands = textureFiles.filter((f) => normalizeStem(f.name) === normalizeStem(pageFileName));
|
||||
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');
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
/** 从 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) && !/[=,:]/.test(line)) {
|
||||
pages.push(validateAtlasPageName(line));
|
||||
} else if (line === '') {
|
||||
// 空白行之后的下一个非空行可能是下一页文件名
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return [...new Set(pages)];
|
||||
}
|
||||
|
||||
/**
|
||||
* 读取 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;
|
||||
}
|
||||
|
||||
const spineUtilsApi = {
|
||||
SKELETON_EXTS,
|
||||
ATLAS_EXTS,
|
||||
TEXTURE_EXTS,
|
||||
ALL_EXTS,
|
||||
extOf,
|
||||
isSkeletonFile,
|
||||
isAtlasFile,
|
||||
isTextureFile,
|
||||
stemOf,
|
||||
normalizeStem,
|
||||
DEFAULT_SUFFIXES,
|
||||
SUFFIX_MAX_LENGTH,
|
||||
normalizeSuffixName,
|
||||
setSuffixes,
|
||||
getSuffixes,
|
||||
getCustomSuffixes,
|
||||
detectVersion,
|
||||
detectVersionFromJson,
|
||||
detectVersionFromBuffer,
|
||||
chooseRuntimeDir,
|
||||
classifySkeletonJson,
|
||||
looksLikeSkeletonJson,
|
||||
discoverAssets,
|
||||
parseAtlasPages,
|
||||
validateAtlasPageName,
|
||||
resolveAtlasTextureFile,
|
||||
parseAtlasPma,
|
||||
preferredSkin,
|
||||
applySkin,
|
||||
isEagleItemDir,
|
||||
getSearchRoots,
|
||||
collectSpineFiles,
|
||||
foldersOfItem,
|
||||
findLibraryRoot,
|
||||
readFolderInfo,
|
||||
getValidFolders,
|
||||
splitCompoundName,
|
||||
mtimeOf,
|
||||
btimeOf,
|
||||
importTimeOf,
|
||||
versionFamily,
|
||||
atlasFamily,
|
||||
itemMeta,
|
||||
isItemDeleted,
|
||||
};
|
||||
|
||||
if (typeof window !== 'undefined') window.SpineUtils = spineUtilsApi;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = spineUtilsApi;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,33 @@
|
||||
// 从画布像素里找实际有内容的包围盒(动态缩略图按整个动画的可见范围取景用)。
|
||||
// 与主插件 js/spine-visible-bounds.js 是同一份实现,只是出口兼容 <script> 加载。
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
function findAlphaBounds(rgba, width, height, threshold = 4) {
|
||||
let left = width, top = height, right = 0, bottom = 0;
|
||||
for (let y = 0; y < height; y++) {
|
||||
for (let x = 0; x < width; x++) {
|
||||
if (rgba[(y * width + x) * 4 + 3] <= threshold) continue;
|
||||
left = Math.min(left, x);
|
||||
top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1);
|
||||
bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
}
|
||||
return right > left && bottom > top ? { left, top, right, bottom } : null;
|
||||
}
|
||||
|
||||
function toWorldBounds(pixels, width, height, scale, centerX, centerY) {
|
||||
if (!pixels || !isFinite(scale) || scale <= 0) return null;
|
||||
const left = centerX + (pixels.left - width / 2) / scale;
|
||||
const right = centerX + (pixels.right - width / 2) / scale;
|
||||
const top = centerY - (pixels.top - height / 2) / scale;
|
||||
const bottom = centerY - (pixels.bottom - height / 2) / scale;
|
||||
return { x: left, y: bottom, width: right - left, height: top - bottom };
|
||||
}
|
||||
|
||||
const api = { findAlphaBounds, toWorldBounds };
|
||||
if (typeof window !== 'undefined') window.SpineVisibleBounds = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,183 @@
|
||||
// Node 里用 require,插件页面里用 <script> 标签挂到 window。
|
||||
(function () {
|
||||
'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;
|
||||
}
|
||||
|
||||
const api = { createRasterizer, attach, prepareTexture };
|
||||
if (typeof window !== 'undefined') window.SpineWebglRasterizer = api;
|
||||
else if (typeof module !== 'undefined' && module.exports) module.exports = api;
|
||||
|
||||
})();
|
||||
@@ -0,0 +1,9 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,11 @@
|
||||
# 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`.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"id": "8f3a2c14-7d5e-4b91-a4c8-2e6f0b7d1935",
|
||||
"version": "1.0.0",
|
||||
"platform": "all",
|
||||
"arch": "all",
|
||||
"name": "{{manifest.app.name}}",
|
||||
"logo": "/logo.png",
|
||||
"keywords": [
|
||||
"spine",
|
||||
"thumbnail",
|
||||
"缩略图"
|
||||
],
|
||||
"fallbackLanguage": "zh_CN",
|
||||
"languages": [
|
||||
"zh_CN",
|
||||
"en",
|
||||
"zh_TW",
|
||||
"ja_JP",
|
||||
"ko_KR",
|
||||
"ru_RU",
|
||||
"es_ES",
|
||||
"de_DE"
|
||||
],
|
||||
"devTools": false,
|
||||
"main": {
|
||||
"serviceMode": true,
|
||||
"url": "index.html",
|
||||
"width": 780,
|
||||
"height": 620,
|
||||
"childWindow": true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title data-i18n="progress.title">Spine缩略图</title>
|
||||
<style>
|
||||
:root { --bg: #1e1e1e; --panel: #252526; --text: #e6e6e6; --dim: #9d9d9d; --accent: #4fc1ff; --border: #3c3c3c; }
|
||||
html, body { margin: 0; background: var(--bg); color: var(--text);
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Microsoft YaHei", sans-serif; font-size: 13px; }
|
||||
body { display: flex; flex-direction: column; gap: 8px; padding: 14px; box-sizing: border-box; }
|
||||
h1 { font-size: 13px; margin: 0; font-weight: 600; text-align: center; }
|
||||
.bar { height: 10px; background: var(--panel); border: 1px solid var(--border); border-radius: 999px; overflow: hidden; }
|
||||
/* 不加过渡:窗口被遮挡时 CSS 过渡会停在中途,进度条就不跟着走了。 */
|
||||
.bar > i { display: block; height: 100%; width: 0; background: var(--accent); }
|
||||
#count { color: var(--text); }
|
||||
#name { color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
button { background: var(--panel); color: var(--text); border: 1px solid var(--border);
|
||||
border-radius: 6px; padding: 5px 24px; font: inherit; cursor: pointer; }
|
||||
button:hover { border-color: var(--accent); color: var(--accent); }
|
||||
#action { align-self: center; }
|
||||
#confirm { display: none; flex-direction: column; gap: 8px; }
|
||||
#confirmText { text-align: center; color: var(--text); }
|
||||
#confirmButtons { display: flex; gap: 10px; justify-content: center; }
|
||||
/* 继续 = 默认项(绿色、自动获得焦点);确认取消 = 危险操作(红色)。 */
|
||||
#resume { background: #2ea043; border-color: #2ea043; color: #fff; }
|
||||
#resume:hover { background: #3fb950; border-color: #3fb950; color: #fff; }
|
||||
#cancel { background: #d64545; border-color: #d64545; color: #fff; }
|
||||
#cancel:hover { background: #e5534b; border-color: #e5534b; color: #fff; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1 data-i18n="progress.generating">正在生成动态缩略图……</h1>
|
||||
<div class="bar"><i id="bar"></i></div>
|
||||
<div id="count" data-i18n="progress.waiting">等待开始…</div>
|
||||
<div id="name"></div>
|
||||
<div id="confirm">
|
||||
<div id="confirmText" data-i18n="progress.confirmPrompt">是否确认取消?</div>
|
||||
<div id="confirmButtons">
|
||||
<button id="resume" type="button" data-i18n="progress.resume">继续</button>
|
||||
<button id="cancel" type="button" data-i18n="progress.cancel">确认取消</button>
|
||||
</div>
|
||||
</div>
|
||||
<button id="action" type="button" data-i18n="progress.stop">停止</button>
|
||||
<script>
|
||||
(function () {
|
||||
try {
|
||||
const pathname = decodeURIComponent(new URL('.', location.href).pathname).replace(/\\/g, '/');
|
||||
window.SpinePluginRoot = pathname.replace(/^\/([A-Za-z]:)/, '$1');
|
||||
} catch (error) {
|
||||
window.SpinePluginRoot = '';
|
||||
}
|
||||
})();
|
||||
</script>
|
||||
<script src="js/i18n.js"></script>
|
||||
<script>
|
||||
// 这个页面是宿主用 Eagle 的 create-plugin-window 打开的插件窗口:
|
||||
// 无标题栏、置顶、挂在 Eagle 主窗口上;进度从临时目录的状态文件轮询。
|
||||
// 按钮往回写控制文件:生成中 = 停止(暂停并就地询问),生成完 = 确认(关窗)。
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
const STATE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress.json');
|
||||
const ALIVE_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-alive');
|
||||
const CONTROL_FILE = path.join(os.tmpdir(), 'spine-thumbnail-progress-control.json');
|
||||
const WIDTH = 380;
|
||||
const t = SpineThumbI18n.t;
|
||||
const bar = document.getElementById('bar');
|
||||
const countEl = document.getElementById('count');
|
||||
const nameEl = document.getElementById('name');
|
||||
const actionEl = document.getElementById('action');
|
||||
const confirmEl = document.getElementById('confirm');
|
||||
const confirmTextEl = document.getElementById('confirmText');
|
||||
const resumeEl = document.getElementById('resume');
|
||||
let finished = false;
|
||||
let paused = false;
|
||||
let doneCount = 0;
|
||||
let lastHeight = 0;
|
||||
let placedAt = null; // 上次由我们设置的位置,用来判断用户是不是手动拖过
|
||||
let allowClose = false;
|
||||
|
||||
/** Eagle 的 userData 目录;eagle.app.userDataPath 注入得比较晚,所以留一个兜底。 */
|
||||
function userDataPath() {
|
||||
if (eagle.app && eagle.app.userDataPath) return eagle.app.userDataPath;
|
||||
if (process.platform === 'darwin') return path.join(os.homedir(), 'Library', 'Application Support', 'Eagle');
|
||||
return path.join(process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming'), 'Eagle');
|
||||
}
|
||||
|
||||
/** 算出"Eagle 主窗口正中心"对应的弹窗位置;读不到就返回 null。 */
|
||||
function centerTarget(width, height) {
|
||||
let state = null;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(path.join(userDataPath(), 'window-state.json'), 'utf8'));
|
||||
} catch (error) { /* 读不到就用不了 */ }
|
||||
if (!state || !Number.isFinite(state.x)) return null;
|
||||
// 主窗口最大化/全屏时记录的是还原后的位置,拿它当基准会偏,改用所在显示器居中。
|
||||
const area = (state.isMaximized || state.isFullScreen) && state.displayBounds ? state.displayBounds : state;
|
||||
return [
|
||||
Math.round(area.x + (area.width - width) / 2),
|
||||
Math.round(area.y + (area.height - height) / 2),
|
||||
];
|
||||
}
|
||||
|
||||
/** 跟着 Eagle 主窗口居中;一旦用户手动拖过窗口,就只改尺寸、不再动位置。 */
|
||||
async function reCenter(width, height) {
|
||||
try {
|
||||
const current = await eagle.window.getPosition();
|
||||
const movedByUser = placedAt && (Math.abs(current[0] - placedAt[0]) > 4 || Math.abs(current[1] - placedAt[1]) > 4);
|
||||
if (movedByUser) return;
|
||||
} catch (error) { /* 拿不到位置就照常居中 */ }
|
||||
const target = centerTarget(width, height);
|
||||
if (!target) {
|
||||
await eagle.window.center();
|
||||
return;
|
||||
}
|
||||
placedAt = target;
|
||||
await eagle.window.setPosition(target[0], target[1]);
|
||||
}
|
||||
|
||||
/** 窗口高度跟着内容走。 */
|
||||
function fitHeight() {
|
||||
// setSize 设置的是包含系统标题栏的外部高度,内容高度要补回窗口边框占用。
|
||||
const height = Math.ceil(document.body.scrollHeight + Math.max(0, window.outerHeight - window.innerHeight));
|
||||
if (Math.abs(height - lastHeight) < 2) return;
|
||||
lastHeight = height;
|
||||
eagle.window.setSize(WIDTH, height)
|
||||
.then(() => reCenter(WIDTH, height))
|
||||
.catch(() => { /* 调整失败不影响显示 */ });
|
||||
}
|
||||
|
||||
/** 三种状态:生成中显示「停止」,暂停询问时显示「继续 / 取消」,跑完显示「确认」。 */
|
||||
function syncButtons() {
|
||||
actionEl.textContent = finished ? t('progress.confirm') : t('progress.stop');
|
||||
actionEl.style.display = paused ? 'none' : '';
|
||||
confirmEl.style.display = paused ? 'flex' : 'none';
|
||||
fitHeight();
|
||||
}
|
||||
|
||||
function writeControl(action) {
|
||||
try {
|
||||
fs.writeFileSync(CONTROL_FILE, JSON.stringify({ action, at: Date.now() }), 'utf8');
|
||||
} catch (error) { /* 写不进去就等于没按 */ }
|
||||
}
|
||||
|
||||
function requestStop() {
|
||||
if (paused) return;
|
||||
paused = true;
|
||||
writeControl('pause');
|
||||
syncButtons();
|
||||
resumeEl.focus();
|
||||
}
|
||||
|
||||
// 系统关闭按钮与页面里的「停止」走同一条暂停确认流程。
|
||||
window.addEventListener('beforeunload', (event) => {
|
||||
if (allowClose || finished) return;
|
||||
event.preventDefault();
|
||||
event.returnValue = false;
|
||||
requestStop();
|
||||
});
|
||||
|
||||
function render(state) {
|
||||
const total = Math.max(1, state.total || 0);
|
||||
const done = state.done || 0;
|
||||
finished = state.finished === true;
|
||||
if (finished) paused = false;
|
||||
doneCount = done;
|
||||
confirmTextEl.textContent = t('progress.generatedConfirm', { done: doneCount });
|
||||
if (finished) {
|
||||
bar.style.width = '100%';
|
||||
countEl.textContent = state.text || t('progress.complete');
|
||||
nameEl.textContent = '';
|
||||
} else {
|
||||
bar.style.width = Math.min(100, done / total * 100) + '%';
|
||||
countEl.textContent = t('progress.completed', {
|
||||
done,
|
||||
total: state.total || 0,
|
||||
percent: Math.round(done / total * 100),
|
||||
});
|
||||
nameEl.textContent = state.name || '';
|
||||
}
|
||||
syncButtons();
|
||||
}
|
||||
|
||||
actionEl.addEventListener('click', () => {
|
||||
if (finished) {
|
||||
allowClose = true;
|
||||
window.close();
|
||||
return;
|
||||
}
|
||||
requestStop();
|
||||
});
|
||||
|
||||
resumeEl.addEventListener('click', () => {
|
||||
paused = false;
|
||||
writeControl('resume');
|
||||
syncButtons();
|
||||
});
|
||||
|
||||
document.getElementById('cancel').addEventListener('click', () => {
|
||||
// 同步写控制文件再关窗:窗口没了宿主也能读到 cancel,把剩余队列丢掉。
|
||||
writeControl('cancel');
|
||||
allowClose = true;
|
||||
window.close();
|
||||
});
|
||||
|
||||
// 窗口尺寸/置顶/居中:Eagle 的 window-state.json 会覆盖 manifest 里的位置和宽高,这里再对齐一次。
|
||||
(async () => {
|
||||
try {
|
||||
await eagle.window.setResizable(false);
|
||||
} catch (error) { /* ignore */ }
|
||||
syncButtons();
|
||||
})();
|
||||
|
||||
// 心跳:让宿主知道这个窗口还开着;窗口关掉后心跳就停了,下次需要时会重新开。
|
||||
const beat = () => {
|
||||
try { fs.writeFileSync(ALIVE_FILE, String(Date.now()), 'utf8'); } catch (error) { /* ignore */ }
|
||||
};
|
||||
beat();
|
||||
setInterval(beat, 1000);
|
||||
|
||||
let lastStamp = 0;
|
||||
setInterval(() => {
|
||||
let state;
|
||||
try {
|
||||
state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
||||
} catch (error) {
|
||||
return; // 状态文件还没写出来
|
||||
}
|
||||
if (state.updatedAt === lastStamp) return;
|
||||
lastStamp = state.updatedAt;
|
||||
render(state);
|
||||
}, 400);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,116 @@
|
||||
'use strict';
|
||||
|
||||
// 动态缩略图的回归测试。渲染本身要 DOM canvas,这里锁住所有能脱离浏览器验证的部分:
|
||||
// 档位配置、目标体积默认值、帧数/尺寸换算、可见范围算法、WebP 封装、自动定档策略。
|
||||
const assert = require('node:assert/strict');
|
||||
const profiles = require('../js/animated-profiles.js');
|
||||
const encoder = require('../js/animated-webp-encoder.js');
|
||||
const visibleBounds = require('../js/spine-visible-bounds.js');
|
||||
const renderer = require('../js/renderer.js');
|
||||
|
||||
// —— 目标体积:每秒 25 KB,夹在 25–100 KB(跟主插件的默认值一致)——
|
||||
assert.equal(profiles.SIZE_PER_SECOND, 25);
|
||||
assert.equal(profiles.DEFAULT_OUTPUT_SIZE, 200);
|
||||
assert.equal(profiles.DEFAULT_PRIORITY, 'resolution');
|
||||
assert.equal(profiles.suggestSize(0.2), 25);
|
||||
assert.equal(profiles.suggestSize(1), 25);
|
||||
assert.equal(profiles.suggestSize(2), 50);
|
||||
assert.equal(profiles.suggestSize(10), 100);
|
||||
|
||||
// —— 档位表:细节分辨率永远不超过输出画布边长 ——
|
||||
const ladder = profiles.animatedProfiles('resolution', 200);
|
||||
assert.equal(ladder.length, 10);
|
||||
assert.ok(ladder.every((profile) => profile.size <= 200), '细节分辨率不能超过输出画布');
|
||||
assert.ok(ladder.every((profile) => profile.fps >= 1 && profile.quality >= 0 && profile.quality <= 1));
|
||||
assert.deepEqual(profiles.animatedProfiles('resolution', 400).slice(0, 5).map((p) => p.size), [400, 400, 400, 400, 400]);
|
||||
|
||||
// —— 搜索路径:首尾保留锚点,中间是插值 ——
|
||||
const searchPath = profiles.expandAnimatedPath('resolution', 200);
|
||||
assert.deepEqual(searchPath[0], { size: 200, fps: 18, quality: 1 });
|
||||
assert.equal(searchPath[searchPath.length - 1].fps, 6);
|
||||
assert.ok(searchPath.every((point) => point.size <= 200));
|
||||
|
||||
// —— 帧数换算:至少 2 帧、不超过采样帧数、按 ceil(range × fps) ——
|
||||
assert.equal(renderer.profileFrameCount(100, 1, 12), 12);
|
||||
assert.equal(renderer.profileFrameCount(100, 1, 200), 100, '帧率再高也不能超过采样帧数');
|
||||
assert.equal(renderer.profileFrameCount(100, 0.001, 1), 2);
|
||||
|
||||
// —— 细节分辨率:等于画布时原样返回,低于画布时先降后升、尺寸不变 ——
|
||||
const pixels = new Uint8Array(4 * 4 * 4).fill(7);
|
||||
assert.equal(renderer.scaledFramePixels(pixels, 4, 4), pixels);
|
||||
const scaled = renderer.scaledFramePixels(pixels, 4, 2);
|
||||
assert.equal(scaled.length, 16 * 4);
|
||||
assert.equal(scaled[0], 7);
|
||||
|
||||
// —— 可见范围:全透明不算范围,单个像素能定位 ——
|
||||
assert.equal(visibleBounds.findAlphaBounds(new Uint8Array(4 * 4 * 4), 4, 4), null);
|
||||
const box = new Uint8Array(4 * 4 * 4);
|
||||
box[(1 * 4 + 1) * 4 + 3] = 255;
|
||||
assert.deepEqual(visibleBounds.findAlphaBounds(box, 4, 4), { left: 1, top: 1, right: 2, bottom: 2 });
|
||||
assert.deepEqual(visibleBounds.toWorldBounds({ left: 0, top: 0, right: 8, bottom: 8 }, 8, 8, 2, 0, 0), {
|
||||
x: -2, y: -2, width: 4, height: 4,
|
||||
});
|
||||
|
||||
// —— 取骨骼里的第一个有效动画;时长为 0 的动画要跳过 ——
|
||||
assert.equal(renderer.animationDurationOf({ duration: 1.5 }), 1.5);
|
||||
assert.equal(renderer.animationDurationOf({ duration: 0, timelines: [{ frames: [[0.5], [1]] }] }), 1);
|
||||
assert.equal(
|
||||
renderer.firstAnimation({ animations: [{ name: 'empty', duration: 0 }, { name: 'idle', duration: 1 }] }).name,
|
||||
'idle',
|
||||
'时长为 0 的动画不能当成动态缩略图的素材',
|
||||
);
|
||||
assert.equal(renderer.firstAnimation({ animations: [] }), null);
|
||||
|
||||
// —— 自动定档:和主插件画廊默认打勾的一致,取实际体积最小的那一档 ——
|
||||
(async () => {
|
||||
const candidates = [60, 10, 120, 30].map((kb) => ({ buffer: Buffer.alloc(kb * 1024), kb }));
|
||||
assert.equal(renderer.smallestCandidate(candidates).kb, 10, '要挑体积最小的那一档,跟顺序无关');
|
||||
assert.equal(renderer.smallestCandidate([]), null);
|
||||
|
||||
// —— 候选插值:给定搜索路径应返回指定数量的合法档位 ——
|
||||
const size = 8;
|
||||
const frames = Array.from({ length: 3 }, () => new Uint8Array(size * size * 4).fill(9));
|
||||
const fakeEncode = async (_slot, framePixels, quality) => Buffer.alloc(Math.max(1, Math.round(framePixels.length * quality / 16)));
|
||||
const picked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode);
|
||||
assert.equal(picked.length, 10);
|
||||
// 最快策略只是少采样(第 8 个参数),候选数量不变,顺序仍是从预计体积最小到最大。
|
||||
const fastPicked = await renderer.pickAnimatedCandidates(searchPath, frames, size, 1, 20 * 1024, 10, fakeEncode, 4);
|
||||
assert.equal(fastPicked.length, 10, '最快策略只是少采样,候选数量不变');
|
||||
for (const profile of picked) {
|
||||
// 这条搜索路径是按 200 的画布展开的,细节分辨率必须落在 [1, 200]。
|
||||
assert.ok(profile.size >= 1 && profile.size <= 200, '细节分辨率要落在画布范围内');
|
||||
assert.ok(profile.fps >= 1);
|
||||
assert.ok(profile.quality >= 0 && profile.quality <= 1);
|
||||
}
|
||||
|
||||
// —— WebP 封装:两帧以上才合法,结构必须是 RIFF/WEBP + ANMF ——
|
||||
const still = (() => {
|
||||
const chunk = (type, payload) => {
|
||||
const output = Buffer.alloc(8 + payload.length + (payload.length & 1));
|
||||
output.write(type, 0, 4, 'ascii');
|
||||
output.writeUInt32LE(payload.length, 4);
|
||||
payload.copy(output, 8);
|
||||
return output;
|
||||
};
|
||||
const body = Buffer.concat([Buffer.from('WEBP'), chunk('VP8 ', Buffer.from([1, 2, 3, 4]))]);
|
||||
const output = Buffer.alloc(8 + body.length);
|
||||
output.write('RIFF', 0, 4, 'ascii');
|
||||
output.writeUInt32LE(body.length, 4);
|
||||
body.copy(output, 8);
|
||||
return output;
|
||||
})();
|
||||
const animated = encoder.encode({
|
||||
width: 200,
|
||||
height: 200,
|
||||
frames: [{ webp: still, duration: 55 }, { webp: still, duration: 55 }],
|
||||
});
|
||||
assert.equal(animated.toString('ascii', 0, 4), 'RIFF');
|
||||
assert.equal(animated.toString('ascii', 8, 12), 'WEBP');
|
||||
assert.equal(animated.readUInt32LE(4), animated.length - 8);
|
||||
assert.throws(() => encoder.encode({ width: 200, height: 200, frames: [{ webp: still, duration: 55 }] }), /at least two frames/);
|
||||
|
||||
console.log('PASS: 动态缩略图档位、目标体积、帧数换算、可见范围、自动定档、WebP 封装');
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
const root = path.join(__dirname, '..');
|
||||
const html = fs.readFileSync(path.join(root, 'progress.html'), 'utf8');
|
||||
const windowSource = fs.readFileSync(path.join(root, 'js', 'progress-window.js'), 'utf8');
|
||||
|
||||
assert.match(windowSource, /minimizable: true/);
|
||||
assert.match(html, /addEventListener\('beforeunload'/);
|
||||
assert.match(html, /function requestStop\(\)[\s\S]*writeControl\('pause'\)[\s\S]*resumeEl\.focus\(\)/);
|
||||
assert.match(html, /if \(allowClose \|\| finished\) return;[\s\S]*event\.returnValue = false;[\s\S]*requestStop\(\)/);
|
||||
assert.equal((html.match(/requestStop\(\);/g) || []).length, 2);
|
||||
assert.equal((html.match(/allowClose = true;[\s\S]{0,80}window\.close\(\)/g) || []).length, 2);
|
||||
console.log('PASS: 系统关闭与停止按钮共用暂停确认流程');
|
||||
@@ -0,0 +1,28 @@
|
||||
'use strict';
|
||||
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const { cleanupStaleFiles } = require('../js/library-watcher.js');
|
||||
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-cleanup-'));
|
||||
const now = Date.now();
|
||||
const old = path.join(directory, 'spine-thumb-old.webp');
|
||||
const fresh = path.join(directory, 'spine-thumb-fresh.webp');
|
||||
const unrelated = path.join(directory, 'other-old.webp');
|
||||
|
||||
try {
|
||||
for (const file of [old, fresh, unrelated]) fs.writeFileSync(file, 'x');
|
||||
const oldTime = new Date(now - 25 * 60 * 60 * 1000);
|
||||
fs.utimesSync(old, oldTime, oldTime);
|
||||
fs.utimesSync(unrelated, oldTime, oldTime);
|
||||
|
||||
assert.equal(cleanupStaleFiles(directory, 'spine-thumb-', 24 * 60 * 60 * 1000, now), 1);
|
||||
assert.equal(fs.existsSync(old), false);
|
||||
assert.equal(fs.existsSync(fresh), true);
|
||||
assert.equal(fs.existsSync(unrelated), true);
|
||||
console.log('PASS: 只清理超过 24 小时的本插件临时文件');
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
'use strict';
|
||||
|
||||
// 不需要 Eagle 的回归测试。用临时素材库模拟「拖入一批文件」,覆盖:
|
||||
// 1. 历史素材被忽略、静默之后才触发、只报增量;
|
||||
// 2. **跨目录配对**——Eagle 会把每个文件拆成独立的 <id>.info 目录,
|
||||
// 一套资产的骨架 / atlas / 贴图分散在不同目录里,必须能关联起来;
|
||||
// 3. 本地模块在「页面同时有 window 和 module」时仍然挂到 window 上。
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const os = require('node:os');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
const { createLibraryWatcher, QUIET_MS } = require('../js/library-watcher.js');
|
||||
const { scanSpineItems } = require('../js/spine-assets.js');
|
||||
const utils = require('../js/spine-utils.js');
|
||||
const { withTimeout } = require('../js/renderer.js');
|
||||
|
||||
// —— 超时保护:卡住的 Promise 必须被拒绝,否则队列会被永久堵死 ——
|
||||
(async () => {
|
||||
assert.equal(await withTimeout(Promise.resolve('ok'), 1000, '测试'), 'ok', '正常 Promise 应原样返回');
|
||||
await assert.rejects(
|
||||
() => withTimeout(new Promise(() => {}), 30, '测试'),
|
||||
/超时/,
|
||||
'永不 resolve 的 Promise 必须被超时拒绝',
|
||||
);
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
|
||||
// —— 回归:Eagle 插件页面里也存在 module 对象,本地模块必须优先挂 window ——
|
||||
const pageSandbox = {
|
||||
require, console, setTimeout, clearTimeout, setInterval, clearInterval,
|
||||
window: {}, module: { exports: {} },
|
||||
document: { getElementById: () => null, createElement: () => ({}) },
|
||||
};
|
||||
pageSandbox.self = pageSandbox;
|
||||
vm.createContext(pageSandbox);
|
||||
for (const file of ['library-watcher.js', 'spine-assets.js', 'spine-utils.js']) {
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'js', file), 'utf8'), pageSandbox, { filename: file });
|
||||
}
|
||||
assert.ok(pageSandbox.window.SpineThumbLibraryWatcher, '存在 module 对象时也必须挂到 window');
|
||||
assert.ok(pageSandbox.window.SpineThumbSpineAssets, '存在 module 对象时也必须挂到 window');
|
||||
assert.ok(pageSandbox.window.SpineUtils, '存在 module 对象时也必须挂到 window');
|
||||
|
||||
// —— 复合后缀:Eagle 只按最后一个点拆 name/ext,atlas.txt 会被当成纯文本素材 ——
|
||||
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.atlas.txt'), { name: 'EmoticonSpine', ext: 'atlas.txt' });
|
||||
assert.deepEqual(utils.splitCompoundName('EmoticonSpine.skel.bytes'), { name: 'EmoticonSpine', ext: 'skel.bytes' });
|
||||
assert.equal(
|
||||
utils.splitCompoundName('hero.skel'), null,
|
||||
'普通后缀不动(否则会把 Eagle 里显式支持的类型也拆坏)',
|
||||
);
|
||||
assert.equal(utils.splitCompoundName('notes.txt'), null, '普通 txt 不是 Spine 素材,不碰');
|
||||
assert.equal(utils.splitCompoundName('.atlas.txt'), null, '没有主干名就不改');
|
||||
|
||||
// —— 搭一个仿真素材库:<root>/images/<id>.info/<file> ——
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'spine-thumb-server-'));
|
||||
const imagesDir = path.join(root, 'images');
|
||||
fs.mkdirSync(imagesDir, { recursive: true });
|
||||
fs.writeFileSync(path.join(root, 'metadata.json'), JSON.stringify({ folders: [] }));
|
||||
|
||||
const MINIMAL_ATLAS = ['hero.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||||
// 真实 Spine JSON 的最小形态:必须有 skeleton.spine 版本字段才会被判为骨骼数据。
|
||||
const MINIMAL_SPINE_JSON = JSON.stringify({ skeleton: { spine: '3.8.55' }, bones: [{ name: 'root' }] });
|
||||
const LEGACY_ATLAS = ['legacy.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||||
const ATLAS_FOR_DUP = ['dup.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||||
const ATLAS_FOLD_A = ['dup2.png', 'size: 64,64', 'format: RGBA8888', 'filter: Linear,Linear', 'repeat: none', ''].join('\n');
|
||||
|
||||
function makeItem(id, files, folders, deleted) {
|
||||
const dir = path.join(imagesDir, id + '.info');
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
// Eagle 的每个 item 目录都必须有 metadata.json,spine-utils 靠它判断这是不是一个 item。
|
||||
fs.writeFileSync(path.join(dir, 'metadata.json'), JSON.stringify({
|
||||
id, name: id, folders: folders || [], isDeleted: !!deleted,
|
||||
}));
|
||||
for (const [name, content] of Object.entries(files)) {
|
||||
fs.writeFileSync(path.join(dir, name), content === true ? 'x' : content);
|
||||
}
|
||||
return dir;
|
||||
}
|
||||
|
||||
// 兜底超时,避免配对逻辑异常时测试挂住不退出。
|
||||
setTimeout(() => {
|
||||
console.error('测试超时(60 秒)');
|
||||
process.exit(1);
|
||||
}, 60000).unref();
|
||||
|
||||
// 历史素材:监听建立之前就存在,不能处理。
|
||||
makeItem('old-skel', { 'spineboy.skel': true });
|
||||
makeItem('old-atlas', { 'spineboy.atlas': MINIMAL_ATLAS });
|
||||
makeItem('old-tex', { 'spineboy.png': true });
|
||||
// 历史里还留着一套 legacy 资产的 atlas + 贴图,用来验证新增素材不能借用它们。
|
||||
makeItem('old-legacy-atlas', { 'legacy.atlas': LEGACY_ATLAS });
|
||||
makeItem('old-legacy-tex', { 'legacy.png': true });
|
||||
|
||||
const batches = [];
|
||||
const watcher = createLibraryWatcher({ onBatch: (batch) => batches.push(batch), onLog: () => {} });
|
||||
assert.equal(watcher.start(root), true, '监听应当建立成功');
|
||||
|
||||
const wait = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
||||
|
||||
(async () => {
|
||||
// 模拟一次拖入:一套分散在三处的完整资产 + 一张普通图片 + 只有骨架的残缺资产。
|
||||
makeItem('new-skel', { 'hero.skel': true });
|
||||
makeItem('new-atlas', { 'hero.atlas': MINIMAL_ATLAS });
|
||||
makeItem('new-tex', { 'hero.png': true });
|
||||
makeItem('new-photo', { 'photo.png': true });
|
||||
makeItem('new-broken', { 'lonely.skel': true });
|
||||
makeItem('new-json', { 'notes.json': '{"hello":"world"}' });
|
||||
makeItem('new-skeljson', { 'beast.json': MINIMAL_SPINE_JSON });
|
||||
// 只有骨架,配套的 legacy.atlas / legacy.png 都在历史素材里 → 不该跨批次配上
|
||||
makeItem('new-orphan', { 'legacy.skel': true });
|
||||
await wait(QUIET_MS + 1200);
|
||||
|
||||
assert.equal(batches.length, 1, '静默之后应当只触发一批');
|
||||
assert.equal(batches[0].reason, 'quiet');
|
||||
assert.deepEqual(
|
||||
batches[0].added.slice().sort(),
|
||||
['new-atlas.info', 'new-broken.info', 'new-json.info', 'new-orphan.info', 'new-photo.info', 'new-skel.info', 'new-skeljson.info', 'new-tex.info'],
|
||||
);
|
||||
|
||||
const spine = await scanSpineItems(imagesDir, batches[0].added, utils);
|
||||
const names = spine.map((item) => item.dirName).sort();
|
||||
assert.deepEqual(
|
||||
names,
|
||||
['new-atlas.info', 'new-broken.info', 'new-orphan.info', 'new-skel.info', 'new-skeljson.info'],
|
||||
'普通图片和普通 JSON 不算 Spine 资产,真实的 Spine JSON 要算',
|
||||
);
|
||||
|
||||
// 关键:骨架在 new-skel.info 里,atlas 在 new-atlas.info 里,贴图在 new-tex.info 里,
|
||||
// 三者必须被判成同一套完整资产。
|
||||
const hero = spine.find((item) => item.dirName === 'new-skel.info');
|
||||
assert.equal(hero.complete, true, '跨目录的三件套必须配成完整资产');
|
||||
// 日志要显示 Eagle 里的文件名和文件夹路径,扫描结果必须带上这两项。
|
||||
assert.equal(hero.entryName, 'hero.skel', '要带上 item 目录里的入口文件名');
|
||||
assert.deepEqual(hero.folders, [], '未归入文件夹的素材 folders 为空数组');
|
||||
assert.equal(path.basename(hero.atlasPath || ''), 'hero.atlas', '要找到另一个目录里的 atlas');
|
||||
assert.equal(path.basename(hero.texturePaths['hero.png'] || ''), 'hero.png', '要找到 atlas 引用的贴图');
|
||||
assert.equal(hero.pages, 1);
|
||||
assert.equal(hero.missingTextures, 0);
|
||||
|
||||
const broken = spine.find((item) => item.dirName === 'new-broken.info');
|
||||
assert.equal(broken.complete, false, '只有骨架不算完整');
|
||||
assert.equal(broken.missingTextures, 0, '没有 atlas 时缺图计数为 0');
|
||||
|
||||
// 配对范围只限本次新增:历史素材里有 legacy.atlas 和 legacy.png,
|
||||
// 但新导入的 legacy.skel 不能去借用它们。
|
||||
const orphan = spine.find((item) => item.dirName === 'new-orphan.info');
|
||||
assert.equal(orphan.complete, false, '新增素材不能借用历史素材的配套文件');
|
||||
assert.equal(orphan.atlasPath, null, '不应该跨批次找到历史 atlas');
|
||||
|
||||
// 第二批:只报新增的那个。
|
||||
makeItem('new-skel2', { 'boss.skel': true });
|
||||
await wait(QUIET_MS + 1200);
|
||||
assert.equal(batches.length, 2, '第二批应当再触发一次');
|
||||
assert.deepEqual(batches[1].added, ['new-skel2.info']);
|
||||
|
||||
// 分批导入(模拟 Eagle 的「重复添加提示」把一次导入拆成两段):
|
||||
// 先只写入骨架,配套的 atlas / 贴图过一会儿才写入。
|
||||
makeItem('dup-skel', { 'dup.skel': true });
|
||||
await wait(QUIET_MS + 1200);
|
||||
const firstPass = await scanSpineItems(imagesDir, ['dup-skel.info'], utils);
|
||||
assert.equal(firstPass[0].complete, false, '只有骨架时不算完整');
|
||||
|
||||
makeItem('dup-atlas', { 'dup.atlas': ATLAS_FOR_DUP });
|
||||
makeItem('dup-tex', { 'dup.png': true });
|
||||
await wait(QUIET_MS + 1200);
|
||||
// plugin.js 的「待观察」会把上一批的目录和新一批一起送进来,这里模拟同样的调用。
|
||||
const secondPass = await scanSpineItems(imagesDir, ['dup-skel.info', 'dup-atlas.info', 'dup-tex.info'], utils);
|
||||
const dup = secondPass.find((item) => item.dirName === 'dup-skel.info');
|
||||
assert.equal(dup.complete, true, '配套补齐后应当能配成完整资产');
|
||||
assert.equal(dup.missingTextures, 0);
|
||||
|
||||
// 同名素材分属不同 Eagle 文件夹时不能互相配对:文件夹 A 里有完整三件套,
|
||||
// 文件夹 B 里只有一个同名骨架,B 不应该借用 A 的 atlas。
|
||||
makeItem('fold-a-skel', { 'dup2.skel': true }, ['folderA']);
|
||||
makeItem('fold-a-atlas', { 'dup2.atlas': ATLAS_FOLD_A }, ['folderA']);
|
||||
makeItem('fold-a-tex', { 'dup2.png': true }, ['folderA']);
|
||||
makeItem('fold-b-skel', { 'dup2.skel': true }, ['folderB']);
|
||||
const crossFolder = await scanSpineItems(
|
||||
imagesDir,
|
||||
['fold-a-skel.info', 'fold-a-atlas.info', 'fold-a-tex.info', 'fold-b-skel.info'],
|
||||
utils,
|
||||
);
|
||||
const inA = crossFolder.find((item) => item.dirName === 'fold-a-skel.info');
|
||||
assert.equal(inA.complete, true, '同一 Eagle 文件夹里的三件套要能配上');
|
||||
assert.deepEqual(inA.folders, ['folderA'], '要带上 Eagle 文件夹 id,日志才能还原路径');
|
||||
assert.equal(path.basename(inA.atlasPath), 'dup2.atlas');
|
||||
const inB = crossFolder.find((item) => item.dirName === 'fold-b-skel.info');
|
||||
assert.equal(inB.complete, false, '不能借用别的 Eagle 文件夹里的同名 atlas');
|
||||
assert.equal(inB.atlasPath, null);
|
||||
|
||||
// 回收站素材:Eagle 删除只是把 metadata 标成 isDeleted,目录和文件都还在磁盘上。
|
||||
// 三件套齐全也不能进结果,否则会给回收站里的素材渲染缩略图。
|
||||
makeItem('trash-skel', { 'gone.skel': true }, [], true);
|
||||
makeItem('trash-atlas', { 'gone.atlas': ATLAS_FOR_DUP }, [], true);
|
||||
makeItem('trash-tex', { 'gone.png': true }, [], true);
|
||||
const trashed = await scanSpineItems(
|
||||
imagesDir,
|
||||
['trash-skel.info', 'trash-atlas.info', 'trash-tex.info'],
|
||||
utils,
|
||||
);
|
||||
assert.deepEqual(trashed, [], '回收站里的素材不能被当成待生成资产');
|
||||
|
||||
watcher.stop();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
console.log('PASS: 静默检测、历史忽略、增量识别、跨目录配对、回收站过滤、浏览器加载方式');
|
||||
})().catch((error) => {
|
||||
console.error(error);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "Animiertes Vorschaubild wird komprimiert {current}/{total}…",
|
||||
"animatedWebpUnavailable": "Diese Umgebung kann WebP nicht kodieren. Bitte APNG auswählen.",
|
||||
"animatedCannotFit": "Die Animation kann nicht auf {target} KB reduziert werden (mindestens etwa {actual} KB). Erhöhen Sie das Limit oder verkürzen Sie den Framebereich.",
|
||||
"animatedPickTitle": "Miniaturbild auswählen",
|
||||
"animatedPickHint": "Alle Ergebnisse behalten eine {size}-Arbeitsfläche; das Label zeigt Detailauflösung@fps/Qualität/Dateigröße. Wählen Sie eines und drücken Sie Bestätigen, um es zu übernehmen, oder brechen Sie ab, um alle zu verwerfen.",
|
||||
"animatedPickConfirm": "Bestätigen",
|
||||
"animatedPriority": "Priorität",
|
||||
"animatedCanvas": "Ausgabegröße",
|
||||
"priorityResolution": "Auflösung zuerst",
|
||||
"priorityFramerate": "Bildrate zuerst",
|
||||
"priorityBalanced": "Auflösung und Bildrate abwechselnd",
|
||||
"qualityLow": "Niedrig",
|
||||
"qualityMedium": "Mittel",
|
||||
"qualityHigh": "Hoch",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "Compressing animated thumbnail {current}/{total}…",
|
||||
"animatedWebpUnavailable": "This environment cannot encode WebP. Choose APNG instead.",
|
||||
"animatedCannotFit": "The animation cannot be reduced to {target} KB (minimum about {actual} KB). Increase the limit or shorten the frame range.",
|
||||
"animatedPickTitle": "Pick a thumbnail",
|
||||
"animatedPickHint": "Every result keeps a {size} canvas; the label shows detail resolution@fps/quality/file size. Select one and press Confirm to replace the thumbnail, or cancel to discard them all.",
|
||||
"animatedPickConfirm": "Confirm",
|
||||
"animatedPriority": "Priority",
|
||||
"animatedCanvas": "Canvas size",
|
||||
"priorityResolution": "Resolution first",
|
||||
"priorityFramerate": "Frame rate first",
|
||||
"priorityBalanced": "Alternate resolution and frame rate",
|
||||
"qualityLow": "Low",
|
||||
"qualityMedium": "Medium",
|
||||
"qualityHigh": "High",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "Comprimiendo miniatura animada {current}/{total}…",
|
||||
"animatedWebpUnavailable": "Este entorno no puede codificar WebP. Selecciona APNG.",
|
||||
"animatedCannotFit": "No se puede reducir la animación a {target} KB (mínimo aproximado: {actual} KB). Aumenta el límite o acorta el rango.",
|
||||
"animatedPickTitle": "Elige una miniatura",
|
||||
"animatedPickHint": "Todos los resultados mantienen un lienzo de {size}; la etiqueta indica la resolución de detalle@fps/calidad/tamaño de archivo. Elige uno y pulsa Confirmar para aplicarlo, o cancela para descartarlos todos.",
|
||||
"animatedPickConfirm": "Confirmar",
|
||||
"animatedPriority": "Prioridad",
|
||||
"animatedCanvas": "Tamaño del lienzo",
|
||||
"priorityResolution": "Resolución primero",
|
||||
"priorityFramerate": "Frecuencia primero",
|
||||
"priorityBalanced": "Alternar resolución y frecuencia",
|
||||
"qualityLow": "Baja",
|
||||
"qualityMedium": "Media",
|
||||
"qualityHigh": "Alta",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "アニメーションサムネイルを圧縮中 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "この環境では WebP をエンコードできません。APNG を選択してください。",
|
||||
"animatedCannotFit": "アニメーションを {target} KB まで圧縮できません(最小約 {actual} KB)。上限を増やすかフレーム範囲を短くしてください。",
|
||||
"animatedPickTitle": "サムネイルを選択",
|
||||
"animatedPickHint": "すべての結果は {size} のキャンバスです。ラベルは実際のディテール解像度@fps/品質/ファイルサイズを示します。1つ選んで確認を押すと置き換え、キャンセルですべて破棄します。",
|
||||
"animatedPickConfirm": "確認",
|
||||
"animatedPriority": "優先度",
|
||||
"animatedCanvas": "出力サイズ",
|
||||
"priorityResolution": "解像度優先",
|
||||
"priorityFramerate": "フレームレート優先",
|
||||
"priorityBalanced": "解像度とフレームレートを交互に",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "애니메이션 썸네일 압축 중 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "현재 환경에서 WebP를 인코딩할 수 없습니다. APNG를 선택하세요.",
|
||||
"animatedCannotFit": "애니메이션을 {target}KB로 줄일 수 없습니다(최소 약 {actual}KB). 제한을 높이거나 프레임 범위를 줄이세요.",
|
||||
"animatedPickTitle": "썸네일 선택",
|
||||
"animatedPickHint": "모든 결과의 캔버스는 {size}이며, 라벨은 실제 디테일 해상도@fps/품질/파일 크기입니다. 하나를 선택하고 확인을 누르면 적용되고, 취소하면 모두 버려집니다.",
|
||||
"animatedPickConfirm": "확인",
|
||||
"animatedPriority": "우선 순위",
|
||||
"animatedCanvas": "출력 크기",
|
||||
"priorityResolution": "해상도 우선",
|
||||
"priorityFramerate": "프레임률 우선",
|
||||
"priorityBalanced": "해상도와 프레임률 교차",
|
||||
"qualityLow": "낮음",
|
||||
"qualityMedium": "보통",
|
||||
"qualityHigh": "높음",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "Сжатие анимированной миниатюры {current}/{total}…",
|
||||
"animatedWebpUnavailable": "Эта среда не поддерживает кодирование WebP. Выберите APNG.",
|
||||
"animatedCannotFit": "Анимацию нельзя уменьшить до {target} КБ (минимум около {actual} КБ). Увеличьте лимит или сократите диапазон кадров.",
|
||||
"animatedPickTitle": "Выберите миниатюру",
|
||||
"animatedPickHint": "У всех результатов холст {size}; в подписи указаны реальное разрешение детализации@fps/качество/размер файла. Выберите результат и нажмите «Подтвердить», чтобы применить его, или отмените, чтобы всё отбросить.",
|
||||
"animatedPickConfirm": "Подтвердить",
|
||||
"animatedPriority": "Приоритет",
|
||||
"animatedCanvas": "Размер холста",
|
||||
"priorityResolution": "Сначала разрешение",
|
||||
"priorityFramerate": "Сначала частота кадров",
|
||||
"priorityBalanced": "Чередовать разрешение и частоту",
|
||||
"qualityLow": "Низкое",
|
||||
"qualityMedium": "Среднее",
|
||||
"qualityHigh": "Высокое",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "正在压缩动画缩略图 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "当前环境不支持 WebP 编码,请选择 APNG",
|
||||
"animatedCannotFit": "无法在保留动画的前提下压缩到 {target} KB(最小约 {actual} KB),请提高上限或缩短帧范围",
|
||||
"animatedPickTitle": "选择一个缩略图",
|
||||
"animatedPickHint": "所有结果画布都是 {size},标签为实际细节分辨率@帧率/质量/文件大小。点选一个结果后按确认替换缩略图,取消则全部放弃。",
|
||||
"animatedPickConfirm": "确认",
|
||||
"animatedPriority": "优先级别",
|
||||
"animatedCanvas": "输出尺寸",
|
||||
"priorityResolution": "分辨率优先",
|
||||
"priorityFramerate": "帧率优先",
|
||||
"priorityBalanced": "分辨率和帧率交替进行",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
|
||||
@@ -36,6 +36,14 @@
|
||||
"animatedCompressing": "正在壓縮動畫縮圖 {current}/{total}…",
|
||||
"animatedWebpUnavailable": "目前環境不支援 WebP 編碼,請選擇 APNG",
|
||||
"animatedCannotFit": "無法在保留動畫的情況下壓縮到 {target} KB(最小約 {actual} KB),請提高上限或縮短影格範圍",
|
||||
"animatedPickTitle": "選擇一個縮圖",
|
||||
"animatedPickHint": "所有結果畫布都是 {size},標籤為實際細節解析度@影格率/品質/檔案大小。點選一個結果後按確認替換縮圖,取消則全部放棄。",
|
||||
"animatedPickConfirm": "確認",
|
||||
"animatedPriority": "優先級別",
|
||||
"animatedCanvas": "輸出尺寸",
|
||||
"priorityResolution": "解析度優先",
|
||||
"priorityFramerate": "影格率優先",
|
||||
"priorityBalanced": "解析度與影格率交替進行",
|
||||
"qualityLow": "低",
|
||||
"qualityMedium": "中",
|
||||
"qualityHigh": "高",
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 动态缩略图的档位配置。
|
||||
*
|
||||
* 每项是 [细节分辨率, 帧率, 编码质量],作为搜索路径上的锚点:
|
||||
* - 细节分辨率决定画面保留多少细节(输出画布始终 400×400,小分辨率会被放大填满);
|
||||
* - 帧率决定每秒采样多少帧,只改采样密度不改时间轴,每档的帧延迟会自动重算;
|
||||
* - 编码质量是 0–1 的 WebP 质量,APNG 是无损编码会忽略这一项。
|
||||
*
|
||||
* 数组顺序是从最高画质到最低画质的搜索方向。运行时会在相邻锚点之间插值成一条更密的
|
||||
* 路径,再按用户填写的目标体积在路径上二分定位,取命中位置附近若干档完整生成,
|
||||
* 所以锚点的疏密决定可搜索的粒度,锚点数量决定画廊里的候选数量。
|
||||
*/
|
||||
const ANIMATED_PRIORITIES = {
|
||||
// 分辨率优先:先把帧率压到底,再降分辨率。
|
||||
resolution: [
|
||||
[400, 18, 1.0], [400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2],
|
||||
[200, 18, 1.0], [200, 15, 0.8], [200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2],
|
||||
],
|
||||
// 帧率优先:先把分辨率压到底,再降帧率。
|
||||
framerate: [
|
||||
[400, 18, 1.0], [400, 18, 0.5], [350, 15, 1.0], [350, 15, 0.5], [300, 9, 1.0],
|
||||
[300, 9, 0.5], [250, 6, 1.0], [250, 6, 0.5], [200, 9, 1.0], [200, 6, 0.5],
|
||||
],
|
||||
// 交替进行:分辨率和帧率交替下降。
|
||||
balanced: [
|
||||
[400, 15, 0.8], [400, 12, 0.6], [400, 9, 0.4], [400, 6, 0.2], [200, 15, 0.8],
|
||||
[200, 12, 0.6], [200, 9, 0.4], [200, 6, 0.2], [100, 3, 0.5], [100, 3, 0.1],
|
||||
],
|
||||
};
|
||||
|
||||
const DEFAULT_PRIORITY = 'resolution';
|
||||
|
||||
/**
|
||||
* 可选的输出画布边长(正方形),界面上的下拉就是这两项。
|
||||
*
|
||||
* Eagle 缩略图网格里每格实际只显示 100–240px 左右,200 与 400 在这个尺寸下几乎看不出差别,
|
||||
* 但像素数只有四分之一,体积能降约 4 倍。配置里写的细节分辨率超过所选边长时会被压下来,
|
||||
* 所以同一份档位表在两种尺寸下都能用。
|
||||
*/
|
||||
const ANIMATED_OUTPUT_SIZES = [200, 400];
|
||||
const DEFAULT_OUTPUT_SIZE = 200;
|
||||
|
||||
/**
|
||||
* 「缩略图大小」输入框的默认建议值:每秒动画配 25 KB,夹在 25–100 KB 之间。
|
||||
* 只是打开面板时的初始值,用户随时可以手动改成 10–1024 之间的任意数。
|
||||
*/
|
||||
const SIZE_PER_SECOND = 25;
|
||||
const SIZE_SUGGESTION_RANGE = [25, 100];
|
||||
|
||||
// 相邻锚点之间插多少个采样点。数值越大,越容易压在目标体积附近,但粗估阶段耗时略增。
|
||||
const PATH_STEPS_PER_SEGMENT = 6;
|
||||
|
||||
function suggestSize(seconds) {
|
||||
const [min, max] = SIZE_SUGGESTION_RANGE;
|
||||
return Math.max(min, Math.min(max, Math.round((Number(seconds) || 0) * SIZE_PER_SECOND)));
|
||||
}
|
||||
|
||||
function animatedProfiles(priority, outputSize) {
|
||||
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||
const plan = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||
return plan.map(([size, fps, quality]) => ({
|
||||
size: Math.min(size, limit),
|
||||
fps,
|
||||
quality,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* 把锚点路径插值成更密的采样路径,供按目标体积二分定位使用。
|
||||
* 分辨率取整、帧率至少 1、质量保留两位小数;首尾锚点原样保留。
|
||||
*/
|
||||
function expandAnimatedPath(priority, outputSize) {
|
||||
const limit = outputSize || DEFAULT_OUTPUT_SIZE;
|
||||
const anchors = ANIMATED_PRIORITIES[priority] || ANIMATED_PRIORITIES[DEFAULT_PRIORITY];
|
||||
const lerp = (from, to, t) => from + (to - from) * t;
|
||||
const path = [];
|
||||
for (let index = 0; index < anchors.length - 1; index++) {
|
||||
const [fromSize, fromFps, fromQuality] = anchors[index];
|
||||
const [toSize, toFps, toQuality] = anchors[index + 1];
|
||||
for (let step = 0; step < PATH_STEPS_PER_SEGMENT; step++) {
|
||||
const t = step / PATH_STEPS_PER_SEGMENT;
|
||||
path.push({
|
||||
size: Math.min(limit, Math.round(lerp(fromSize, toSize, t))),
|
||||
fps: Math.max(1, Math.round(lerp(fromFps, toFps, t))),
|
||||
quality: Math.round(lerp(fromQuality, toQuality, t) * 100) / 100,
|
||||
});
|
||||
}
|
||||
}
|
||||
const [size, fps, quality] = anchors[anchors.length - 1];
|
||||
path.push({ size: Math.min(size, limit), fps, quality });
|
||||
return path;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
ANIMATED_PRIORITIES,
|
||||
DEFAULT_PRIORITY,
|
||||
ANIMATED_OUTPUT_SIZES,
|
||||
DEFAULT_OUTPUT_SIZE,
|
||||
SIZE_PER_SECOND,
|
||||
SIZE_SUGGESTION_RANGE,
|
||||
PATH_STEPS_PER_SEGMENT,
|
||||
animatedProfiles,
|
||||
expandAnimatedPath,
|
||||
suggestSize,
|
||||
};
|
||||
@@ -16,6 +16,7 @@ function chunk(type, payload) {
|
||||
}
|
||||
|
||||
function frameData(webp) {
|
||||
if (!webp) throw new Error('WebP 帧数据缺失');
|
||||
const input = Buffer.from(webp);
|
||||
if (input.length < 20 || input.toString('ascii', 0, 4) !== 'RIFF' || input.toString('ascii', 8, 12) !== 'WEBP') {
|
||||
throw new Error('Invalid WebP frame');
|
||||
|
||||
@@ -22,6 +22,8 @@ function orderCurrentTargetLast(targets, currentSourcePath) {
|
||||
*/
|
||||
async function applyThumbnailTargets(options) {
|
||||
const targets = options.targets || [];
|
||||
// 渲染结果为空时立刻报清楚的原因,否则会在写临时文件时才抛出难懂的参数类型错误。
|
||||
if (!options.rendered || !Buffer.isBuffer(options.rendered.buffer)) throw new Error('缩略图渲染结果为空');
|
||||
const settleDelayMs = options.settleDelayMs === undefined ? 800 : options.settleDelayMs;
|
||||
const cleanupDelayMs = options.cleanupDelayMs === undefined ? 1200 : options.cleanupDelayMs;
|
||||
const retryAttempts = options.retryAttempts === undefined ? 3 : options.retryAttempts;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "ddf1c576-cfbc-4813-83a6-2e029cc08745",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"platform": "all",
|
||||
"arch": "all",
|
||||
"name": "{{manifest.app.name}}",
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1"
|
||||
"version": "1.1.2"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "spineviewer",
|
||||
"version": "1.1.1",
|
||||
"version": "1.1.2",
|
||||
"description": "Eagle 插件:离线预览 Spine 导出的骨骼动画(skel / skel.bytes / json / atlas / atlas.txt / png / jpg / jpeg / webp)",
|
||||
"private": true,
|
||||
"type": "commonjs",
|
||||
|
||||
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 208 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 286 KiB |
|
After Width: | Height: | Size: 227 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 261 KiB |
|
After Width: | Height: | Size: 202 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
|
After Width: | Height: | Size: 298 KiB |
|
After Width: | Height: | Size: 223 KiB |
|
After Width: | Height: | Size: 2.8 MiB |
@@ -0,0 +1,66 @@
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
var root = document.documentElement;
|
||||
var themeToggle = document.getElementById('themeToggle');
|
||||
|
||||
if (themeToggle) {
|
||||
themeToggle.addEventListener('click', function () {
|
||||
var next = root.dataset.theme === 'dark' ? 'light' : 'dark';
|
||||
root.dataset.theme = next;
|
||||
try { localStorage.setItem('stg-site-theme', next); } catch (error) {}
|
||||
});
|
||||
}
|
||||
|
||||
var revealEls = Array.prototype.slice.call(document.querySelectorAll('.reveal'));
|
||||
var revealTick = 0;
|
||||
|
||||
function revealInViewport() {
|
||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight;
|
||||
revealEls.forEach(function (el) {
|
||||
if (el.classList.contains('visible')) return;
|
||||
var rect = el.getBoundingClientRect();
|
||||
if (rect.top < viewportHeight * 0.94 && rect.bottom > 0) el.classList.add('visible');
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleReveal() {
|
||||
if (revealTick) return;
|
||||
revealTick = window.requestAnimationFrame(function () {
|
||||
revealTick = 0;
|
||||
revealInViewport();
|
||||
});
|
||||
}
|
||||
|
||||
revealInViewport();
|
||||
window.addEventListener('scroll', scheduleReveal, { passive: true });
|
||||
window.addEventListener('resize', scheduleReveal);
|
||||
setTimeout(function () {
|
||||
revealEls.forEach(function (el) { el.classList.add('visible'); });
|
||||
}, 2000);
|
||||
|
||||
var lightbox = document.getElementById('lightbox');
|
||||
var lightboxImg = lightbox ? lightbox.querySelector('img') : null;
|
||||
|
||||
function closeLightbox() {
|
||||
if (!lightbox || lightbox.hidden) return;
|
||||
lightbox.hidden = true;
|
||||
lightboxImg.removeAttribute('src');
|
||||
document.body.style.overflow = '';
|
||||
}
|
||||
|
||||
if (lightbox) {
|
||||
document.querySelectorAll('img.zoomable').forEach(function (img) {
|
||||
img.addEventListener('click', function () {
|
||||
lightboxImg.src = img.currentSrc || img.src;
|
||||
lightboxImg.alt = img.alt || '';
|
||||
lightbox.hidden = false;
|
||||
document.body.style.overflow = 'hidden';
|
||||
});
|
||||
});
|
||||
lightbox.addEventListener('click', closeLightbox);
|
||||
window.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') closeLightbox();
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
After Width: | Height: | Size: 69 KiB |
|
After Width: | Height: | Size: 181 KiB |
|
After Width: | Height: | Size: 268 KiB |
|
After Width: | Height: | Size: 208 KiB |
@@ -0,0 +1,384 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="zh-CN" data-theme="dark">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>SpineThumbnails — Eagle 插件 · Spine 导出资源缩略图自动生成</title>
|
||||
<meta name="description" content="SpineThumbnails 是一款用于 Eagle 的 Spine 缩略图自动生成插件。自动识别并配对 Spine 骨骼、Atlas 与贴图,支持 3.4–4.3、静态 PNG、动态 WebP、进度控制与八种界面语言。">
|
||||
<meta property="og:title" content="SpineThumbnails — Eagle 插件">
|
||||
<meta property="og:description" content="识别、配对、渲染一次完成。为 Eagle 中的 Spine 导出资源自动生成静态或动态缩略图。">
|
||||
<meta property="og:type" content="website">
|
||||
<meta property="og:image" content="assets/promo-banner.jpg">
|
||||
<meta name="theme-color" content="#16161b">
|
||||
<link rel="icon" href="assets/logo.png">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<noscript><style>.reveal { opacity: 1; transform: none; }</style></noscript>
|
||||
<script>
|
||||
try {
|
||||
var savedTheme = localStorage.getItem('stg-site-theme');
|
||||
document.documentElement.dataset.theme = savedTheme || 'dark';
|
||||
} catch (error) {}
|
||||
</script>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<header class="topbar">
|
||||
<div class="wrap topbar-inner">
|
||||
<a class="brand" href="#top">
|
||||
<img src="assets/logo.png" alt="SpineThumbnails 图标" width="30" height="30">
|
||||
<span class="brand-name">SpineThumbnails</span>
|
||||
<span class="brand-ver">v1.0.0</span>
|
||||
</a>
|
||||
<nav class="nav" aria-label="页面导航">
|
||||
<a href="#features">功能特性</a>
|
||||
<a href="#safety">安全机制</a>
|
||||
<a href="#workflow">生成流程</a>
|
||||
<a href="#versions">支持版本</a>
|
||||
<a href="#install">安装使用</a>
|
||||
</nav>
|
||||
<div class="topbar-actions">
|
||||
<button class="icon-btn" id="themeToggle" type="button" aria-label="切换深浅主题" title="切换深浅主题">
|
||||
<svg class="icon-sun" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="4.2"/><path d="M12 2.5v2.4M12 19.1v2.4M2.5 12h2.4M19.1 12h2.4M5.2 5.2l1.7 1.7M17.1 17.1l1.7 1.7M18.8 5.2l-1.7 1.7M6.9 17.1l-1.7 1.7"/></svg>
|
||||
<svg class="icon-moon" viewBox="0 0 24 24" aria-hidden="true"><path d="M20 14.2A8.2 8.2 0 0 1 9.8 4a8.4 8.4 0 1 0 10.2 10.2z"/></svg>
|
||||
</button>
|
||||
<a class="btn btn-primary btn-sm" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">获取插件</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<main id="top">
|
||||
<section class="hero">
|
||||
<div class="hero-glow" aria-hidden="true"></div>
|
||||
<div class="wrap hero-grid">
|
||||
<div class="hero-copy">
|
||||
<span class="pill"><i class="dot" aria-hidden="true"></i>Eagle 插件 · 自动资源配对</span>
|
||||
<h1>导入完成,<br><span class="grad">缩略图自动就位</span></h1>
|
||||
<p class="hero-sub">
|
||||
SpineThumbnails 识别 Eagle 中导入的 Spine 骨骼、Atlas 与贴图,
|
||||
自动跨 item 文件夹完成配对,再按你的设置生成静态 PNG 或动态 WebP 缩略图。
|
||||
从版本识别到渲染、写入、进度反馈,一次完成。
|
||||
</p>
|
||||
<div class="hero-cta">
|
||||
<a class="btn btn-primary" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">前往插件页</a>
|
||||
<a class="link-arrow" href="#workflow">了解工作流程 →</a>
|
||||
</div>
|
||||
<ul class="stats">
|
||||
<li><b>9</b><span>Spine 版本<br>3.4 – 4.3</span></li>
|
||||
<li><b>2</b><span>缩略图模式<br>静态 / 动态</span></li>
|
||||
<li><b>8</b><span>界面语言<br>简繁英日韩俄西德</span></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="hero-visual">
|
||||
<figure class="shot shot-hero reveal">
|
||||
<img class="zoomable" src="assets/ui-main.jpg" alt="SpineThumbnails 插件主界面:自动生成开关、生成方式、策略、状态、统计与分类日志" width="1560" height="1296">
|
||||
<figcaption>SpineThumbnails 1.0.0 · 插件主界面</figcaption>
|
||||
</figure>
|
||||
<div class="badge-chip reveal">
|
||||
<img src="assets/logo.png" alt="" width="24" height="24">
|
||||
<span>首次使用默认关闭自动生成,只有主动开启后才会处理新导入的素材。</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="wrap">
|
||||
<ul class="file-chips" aria-label="可识别的 Spine 资源类型">
|
||||
<li class="chip chip-on">json</li>
|
||||
<li class="chip chip-on">skel</li>
|
||||
<li class="chip chip-on">skel.bytes</li>
|
||||
<li class="chip chip-on">atlas</li>
|
||||
<li class="chip chip-on">atlas.txt</li>
|
||||
<li class="chip chip-off">png / jpg / webp · 用于 atlas 贴图配对</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="safety-band" id="safety">
|
||||
<div class="wrap">
|
||||
<div class="safety-head reveal">
|
||||
<span class="kicker">SAFE BY DEFAULT</span>
|
||||
<h2>默认关闭,只处理新导入</h2>
|
||||
<p>缩略图会写入 Eagle 素材卡片,因此在首次使用时需要明确打开自动生成开关。</p>
|
||||
</div>
|
||||
<ul class="safety-grid">
|
||||
<li class="reveal">
|
||||
<span class="safety-mark">01</span>
|
||||
<strong>不扫描历史素材</strong>
|
||||
<p>插件从启用后的新导入批次开始监听,不会回头批量修改已有素材。</p>
|
||||
</li>
|
||||
<li class="reveal">
|
||||
<span class="safety-mark">02</span>
|
||||
<strong>不覆盖自定义缩略图</strong>
|
||||
<p>检测到素材已有自定义缩略图时直接跳过,并把结果记录在分类日志中。</p>
|
||||
</li>
|
||||
<li class="reveal">
|
||||
<span class="safety-mark">03</span>
|
||||
<strong>配置独立保存</strong>
|
||||
<p>设置写入 Eagle 用户数据目录,不修改素材 <code>metadata.json</code>、标签或注释。</p>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="features">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="kicker">FEATURES</span>
|
||||
<h2>导入之后,剩下交给插件</h2>
|
||||
<p class="section-lead">不是只会渲染一张图:资源发现、跨目录配对、版本切换、编码搜索和写入反馈都在同一个后台流程里完成。</p>
|
||||
</div>
|
||||
<div class="features">
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M9 15l6-6"/><path d="M10.5 6.6L12.1 5a4 4 0 0 1 5.7 5.7l-1.6 1.6"/><path d="M13.5 17.4L11.9 19a4 4 0 0 1-5.7-5.7l1.6-1.6"/></svg></div>
|
||||
<h3>跨文件夹自动配对</h3>
|
||||
<p>Eagle 会把每个导入文件拆成独立 item 文件夹。插件按文件名与尾缀规则关联骨骼、atlas 和贴图,即使它们分散在不同目录也能组成一套资源。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><rect x="3" y="4" width="18" height="16" rx="3"/><path d="M7 9h10M7 13h6"/><circle cx="17" cy="15" r="2.2"/></svg></div>
|
||||
<h3>静态与动态两种输出</h3>
|
||||
<p>静态模式固定使用 setup pose 输出 PNG;动态模式读取骨骼中的第一个动画,逐帧渲染后封装为轻量 WebP。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 18V9M10 18V5M16 18v-7M22 18V3"/><path d="M2 18h22"/></svg></div>
|
||||
<h3>体积与速度双策略</h3>
|
||||
<p>“最优体积”生成十个候选档位并保留最小结果;“最优速度”先粗估再只渲染最有希望的一两档,适合大批量导入。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M6 3.5v11"/><circle cx="18" cy="6" r="2.6"/><circle cx="6" cy="17.5" r="2.6"/><path d="M18 8.6a9 9 0 0 1-9 8.9"/></svg></div>
|
||||
<h3>9 个运行版本</h3>
|
||||
<p>从 JSON 版本字段或 SKEL 二进制头部识别 Spine 版本,自动匹配 3.4–4.3 对应的本地运行时。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M4 5h16v14H4z"/><path d="M8 9h8M8 13h5"/><path d="M16 15l2 2 3-4"/></svg></div>
|
||||
<h3>批次串行处理</h3>
|
||||
<p>一次只渲染一个素材,避免批量导入时把 Eagle 卡住。后续批次自动排队,进度窗口会同步总数与剩余数量。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M10 8.4l6 3.6-6 3.6z"/></svg></div>
|
||||
<h3>暂停、继续与取消</h3>
|
||||
<p>大批量动态生成时自动显示进度窗口。可暂停当前队列、继续执行,或取消剩余任务并保留已经完成的结果。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><path d="M5 5h14v14H5z"/><path d="M8 9h8M8 13h8M8 17h5"/></svg></div>
|
||||
<h3>分类日志</h3>
|
||||
<p>成功、失败、警告和错误可分别筛选。每条记录包含素材名、Eagle 文件夹、骨架、atlas 与贴图齐全情况。</p>
|
||||
</article>
|
||||
<article class="feature-card reveal">
|
||||
<div class="f-icon" aria-hidden="true"><svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M3 12h18M12 3a15 15 0 0 1 0 18M12 3a15 15 0 0 0 0 18"/></svg></div>
|
||||
<h3>八种界面语言</h3>
|
||||
<p>跟随 Eagle 当前语言显示简体中文、繁体中文、英文、日文、韩文、俄文、西班牙文或德文界面。</p>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section alt" id="workflow">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="kicker">WORKFLOW</span>
|
||||
<h2>从导入到缩略图</h2>
|
||||
<p class="section-lead">首次打开自动生成后,后续新导入的 Spine 素材会沿同一条串行流程处理。</p>
|
||||
</div>
|
||||
<ol class="steps">
|
||||
<li class="step reveal">
|
||||
<span class="step-num">01</span>
|
||||
<h3>发现新素材</h3>
|
||||
<p>监听 Eagle 素材库目录,在导入批次稳定后读取新增 item,历史素材不会进入队列。</p>
|
||||
</li>
|
||||
<li class="step reveal">
|
||||
<span class="step-num">02</span>
|
||||
<h3>配对与检查</h3>
|
||||
<p>关联骨骼、atlas 和贴图,检查版本、复合后缀、贴图数量以及是否已有自定义缩略图。</p>
|
||||
</li>
|
||||
<li class="step reveal">
|
||||
<span class="step-num">03</span>
|
||||
<h3>渲染缩略图</h3>
|
||||
<p>加载匹配运行时,按静态或动态模式绘制画布。动态模式继续搜索满足目标体积的编码参数。</p>
|
||||
</li>
|
||||
<li class="step reveal">
|
||||
<span class="step-num">04</span>
|
||||
<h3>写入与反馈</h3>
|
||||
<p>通过 Eagle 官方接口写入自定义缩略图,并同步更新统计、分类日志与进度窗口。</p>
|
||||
</li>
|
||||
</ol>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section alt" id="modes">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="kicker">OUTPUT</span>
|
||||
<h2>按素材用途选择输出</h2>
|
||||
<p class="section-lead">两种模式共用同一套资源识别与安全检查,只在最终渲染阶段不同。</p>
|
||||
</div>
|
||||
<div class="mode-grid">
|
||||
<article class="mode-card reveal">
|
||||
<div class="mode-visual static" aria-hidden="true">
|
||||
<span class="pose-ring"></span>
|
||||
<span class="pose-bone b1"></span>
|
||||
<span class="pose-bone b2"></span>
|
||||
<span class="pose-bone b3"></span>
|
||||
<span class="mode-file">PNG</span>
|
||||
</div>
|
||||
<div class="mode-copy">
|
||||
<span class="mode-label">STATIC</span>
|
||||
<h3>静帧 · setup pose</h3>
|
||||
<p>始终生成一个稳定的 default 姿势,适合需要明确默认形象、动画首帧不适合作为封面的素材。</p>
|
||||
<ul>
|
||||
<li>输出格式:PNG</li>
|
||||
<li>渲染内容:setup pose</li>
|
||||
<li>速度:更快,结果稳定</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
<article class="mode-card reveal">
|
||||
<div class="mode-visual animated" aria-hidden="true">
|
||||
<span class="motion-dot d1"></span>
|
||||
<span class="motion-dot d2"></span>
|
||||
<span class="motion-dot d3"></span>
|
||||
<span class="motion-line"></span>
|
||||
<span class="mode-file">WEBP</span>
|
||||
</div>
|
||||
<div class="mode-copy">
|
||||
<span class="mode-label">ANIMATED</span>
|
||||
<h3>动态 · 首个动画</h3>
|
||||
<p>读取骨骼中的第一个动画,采样帧后封装为动态 WebP,让素材卡片直接呈现动作状态。</p>
|
||||
<ul>
|
||||
<li>输出格式:动画 WebP</li>
|
||||
<li>渲染内容:第一个动画</li>
|
||||
<li>速度:更慢,需搜索编码档位</li>
|
||||
</ul>
|
||||
</div>
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="versions">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="kicker">SUPPORTED</span>
|
||||
<h2>覆盖 Spine 3.4 – 4.3</h2>
|
||||
<p class="section-lead">支持 JSON、SKEL、SKEL.BYTES、ATLAS 与 ATLAS.TXT,使用与皮肤和约束相匹配的本地运行时渲染。</p>
|
||||
</div>
|
||||
<ul class="v-chips reveal" aria-label="支持的 Spine 版本">
|
||||
<li>3.4</li><li>3.5</li><li>3.6</li><li>3.7</li><li class="sep" aria-hidden="true">·</li>
|
||||
<li>3.8</li><li>4.0</li><li>4.1</li><li>4.2</li><li>4.3</li>
|
||||
</ul>
|
||||
<div class="v-table-wrap reveal">
|
||||
<table class="v-table">
|
||||
<thead>
|
||||
<tr><th>版本</th><th>骨骼输入</th><th>渲染说明</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr>
|
||||
<td><code>3.4</code> / <code>3.5</code></td>
|
||||
<td>JSON · SKEL · SKEL.BYTES</td>
|
||||
<td>使用独立版本运行时和匹配的二进制解析分支,保留网格、权重、变形、约束与绘制顺序。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>3.6</code> / <code>3.7</code></td>
|
||||
<td>JSON · SKEL · SKEL.BYTES</td>
|
||||
<td>通过官方同版本 <code>SkeletonBinary</code> 兼容层解析,无需额外导入 JSON。</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><code>3.8</code> – <code>4.3</code></td>
|
||||
<td>JSON · SKEL · SKEL.BYTES</td>
|
||||
<td>读取数据版本并加载对应运行时;4.x 的物理约束、裁剪和染色能力由运行时处理。</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ul class="chips reveal" aria-label="附加支持">
|
||||
<li>复合后缀 <code>skel.bytes</code></li>
|
||||
<li>复合后缀 <code>atlas.txt</code></li>
|
||||
<li>PMA 贴图</li>
|
||||
<li>多皮肤骨骼</li>
|
||||
<li>动态 WebP</li>
|
||||
<li>进度暂停与取消</li>
|
||||
<li>八种界面语言</li>
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section alt" id="install">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="kicker">GET STARTED</span>
|
||||
<h2>安装后只需打开一次开关</h2>
|
||||
<p class="section-lead">插件不会在默认状态下自动写入缩略图。先在控制面板开启“导入时自动生成缩略图”,再导入新的 Spine 资源。</p>
|
||||
</div>
|
||||
<a class="dl-cta reveal" href="https://community-cn.eagle.cool/plugins?search=Spine%E7%BC%A9%E7%95%A5%E5%9B%BE" target="_blank" rel="noopener">
|
||||
<span class="dl-cta-info">
|
||||
<span class="dl-os">EAGLE PLUGIN COMMUNITY</span>
|
||||
<strong>在 Eagle 插件社区获取 SpineThumbnails</strong>
|
||||
<span class="dl-meta">搜索“Spine缩略图”,或直接打开 Eagle 插件社区。</span>
|
||||
</span>
|
||||
<span class="btn btn-primary">前往插件页 →</span>
|
||||
</a>
|
||||
<ol class="install-steps reveal">
|
||||
<li>
|
||||
<h3>安装并打开插件</h3>
|
||||
<p>在 Eagle 中安装插件,打开 SpineThumbnails 控制面板。</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>启用自动生成</h3>
|
||||
<p>打开“导入时自动生成缩略图”,选择动态或静态模式,以及最优体积或最优速度策略。</p>
|
||||
</li>
|
||||
<li>
|
||||
<h3>导入整套资源</h3>
|
||||
<p>把骨骼、atlas 和贴图导入 Eagle。插件会只针对本次新导入的素材自动处理。</p>
|
||||
</li>
|
||||
</ol>
|
||||
<aside class="callout reveal">
|
||||
<strong>第一次使用为什么没有自动生成?</strong>
|
||||
<p>自动生成默认关闭,避免未经确认就批量修改缩略图。开启后只处理之后新导入的素材,不会追溯历史内容。</p>
|
||||
</aside>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="section" id="faq">
|
||||
<div class="wrap">
|
||||
<div class="section-head reveal">
|
||||
<span class="kicker">FAQ</span>
|
||||
<h2>常见问题</h2>
|
||||
</div>
|
||||
<div class="faq">
|
||||
<details class="reveal">
|
||||
<summary>插件会覆盖我已经设置好的缩略图吗?</summary>
|
||||
<p>不会。检测到素材已有自定义缩略图时会跳过并记录日志。插件只通过 Eagle 官方接口设置当前新素材的缩略图。</p>
|
||||
</details>
|
||||
<details class="reveal">
|
||||
<summary>为什么历史素材没有自动处理?</summary>
|
||||
<p>这是默认安全行为。插件只处理启用后新导入的素材,不扫描、不修改历史素材,避免一次开关造成全素材库批量变更。</p>
|
||||
</details>
|
||||
<details class="reveal">
|
||||
<summary>资源没有同时导入会怎样?</summary>
|
||||
<p>配套不全的素材会记为待观察。后续批次补齐 atlas 或贴图后,插件会重新检查并继续处理。</p>
|
||||
</details>
|
||||
<details class="reveal">
|
||||
<summary>动态缩略图为什么比静态慢?</summary>
|
||||
<p>动态模式需要渲染多个动画帧,还要在分辨率、帧率、质量和目标体积之间寻找合适档位。选择“最优速度”会减少实际生成的候选数量。</p>
|
||||
</details>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<div class="wrap footer-inner">
|
||||
<div class="foot-brand">
|
||||
<img src="assets/logo.png" alt="" width="34" height="34">
|
||||
<div>
|
||||
<strong>SpineThumbnails</strong>
|
||||
<span>Eagle 插件 · v1.0.0</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
|
||||
<div class="lightbox" id="lightbox" hidden>
|
||||
<img alt="放大查看插件界面截图">
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||