feat(stage-ui-spine): add Spine 2D runtime integration (#1810)
This commit is contained in:
@@ -68,6 +68,7 @@
|
||||
"@proj-airi/stage-pages": "workspace:^",
|
||||
"@proj-airi/stage-ui": "workspace:^",
|
||||
"@proj-airi/stage-ui-live2d": "workspace:^",
|
||||
"@proj-airi/stage-ui-spine": "workspace:^",
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@shikijs/markdown-it": "^4.0.2",
|
||||
|
||||
@@ -151,6 +151,20 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'spine') {
|
||||
const phase = resolveComponentStateToRuntimePhase(componentStateStage.value, { hasModel })
|
||||
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
renderer: 'spine',
|
||||
phase,
|
||||
controlsLocked: hasModel ? phase !== 'mounted' : false,
|
||||
previewAvailable: hasModel,
|
||||
canCapturePreview: false,
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'godot') {
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||
|
||||
@@ -1628,6 +1628,35 @@ vrm:
|
||||
skybox:
|
||||
skybox-intensity: SkyBox Intensity
|
||||
skybox-specular-mix: Specular Mix
|
||||
spine:
|
||||
title: Spine Settings
|
||||
scale-and-position:
|
||||
title: Scale And Position
|
||||
scale: Scale
|
||||
x: X
|
||||
'y': 'Y'
|
||||
theme-color-from-model:
|
||||
title: Extract colors from model
|
||||
button-extract:
|
||||
title: Extract
|
||||
animation:
|
||||
title: Animation
|
||||
idle-animation: Idle Animation
|
||||
mix-duration: Mix Duration (seconds)
|
||||
speed: Animation Speed
|
||||
variant:
|
||||
title: Variant
|
||||
current-variant: Active Variant
|
||||
skin:
|
||||
title: Skin
|
||||
current-skin: Current Skin
|
||||
rendering:
|
||||
title: Rendering
|
||||
max-fps: Maximum FPS
|
||||
render-scale: Render Scale
|
||||
fps:
|
||||
options:
|
||||
unlimited: Unlimited
|
||||
websocket-secure-enabled:
|
||||
title: Enable Secure WebSocket (WSS)
|
||||
description: >-
|
||||
|
||||
@@ -29,6 +29,7 @@
|
||||
"@proj-airi/server-sdk": "workspace:*",
|
||||
"@proj-airi/stage-ui": "workspace:*",
|
||||
"@proj-airi/stage-ui-live2d": "workspace:*",
|
||||
"@proj-airi/stage-ui-spine": "workspace:*",
|
||||
"@proj-airi/stage-ui-three": "workspace:*",
|
||||
"@proj-airi/ui": "workspace:*",
|
||||
"@shopify/draggable": "catalog:",
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# License
|
||||
|
||||
This package contains code under two separate licenses:
|
||||
|
||||
## Original Code (MIT License)
|
||||
|
||||
All source code in this package (Vue components, stores, composables, utilities)
|
||||
authored by the Moeru AI Project AIRI Team is licensed under the MIT License:
|
||||
|
||||
```
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2024-PRESENT Neko Ayaka
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
```
|
||||
|
||||
## Spine Runtimes (Spine Runtimes License Agreement)
|
||||
|
||||
This package depends on the Spine Runtimes (`@esotericsoftware/spine-webgl`)
|
||||
which are provided under the **Spine Runtimes License Agreement**.
|
||||
|
||||
The Spine Runtimes are copyright (c) 2013-2025 Esoteric Software LLC.
|
||||
|
||||
Key terms:
|
||||
|
||||
- You must hold a valid Spine Editor license (Essential, Professional, or
|
||||
Enterprise) to integrate the Spine Runtimes into your product.
|
||||
- Redistribution of the Spine Runtimes must include the Spine Runtimes License
|
||||
Agreement and copyright notice.
|
||||
- You may not sublicense the Spine Runtimes under any other license (including MIT).
|
||||
- Modifications to the Spine Runtimes require your own Spine Editor license.
|
||||
|
||||
The full Spine Runtimes License Agreement can be found at:
|
||||
https://esotericsoftware.com/spine-runtimes-license
|
||||
|
||||
The Spine Runtimes are NOT covered by the MIT License above.
|
||||
@@ -0,0 +1,25 @@
|
||||
# @proj-airi/stage-ui-spine
|
||||
|
||||
Spine 2D scene components and stores for Project AIRI. Provides Vue components, composables, and utilities for rendering and managing Spine animations within the stage UI.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
**You must hold a valid [Spine Editor license](https://esotericsoftware.com/spine-purchase) (Essential, Professional, or Enterprise) to develop with this package.**
|
||||
|
||||
The Spine Runtimes (`@esotericsoftware/spine-webgl`) are provided under the [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license), which requires each developer integrating the runtimes to hold their own Spine Editor license at the time of integration.
|
||||
|
||||
End users of a shipped product that uses Spine animations do not need their own Spine license.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { SpineScene } from '@proj-airi/stage-ui-spine/components/scenes'
|
||||
import { useSpineStore } from '@proj-airi/stage-ui-spine/stores/spine'
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
- **Original code** (components, stores, composables, utilities): MIT License
|
||||
- **Spine Runtimes** (`@esotericsoftware/spine-webgl`): [Spine Runtimes License Agreement](https://esotericsoftware.com/spine-runtimes-license)
|
||||
|
||||
See [LICENSE.md](./LICENSE.md) for full details.
|
||||
@@ -0,0 +1,53 @@
|
||||
{
|
||||
"name": "@proj-airi/stage-ui-spine",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"description": "Spine 2D scene components and stores for Project AIRI",
|
||||
"author": {
|
||||
"name": "Moeru AI Project AIRI Team",
|
||||
"email": "airi@moeru.ai",
|
||||
"url": "https://github.com/moeru-ai"
|
||||
},
|
||||
"license": "MIT",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/moeru-ai/airi.git",
|
||||
"directory": "packages/stage-ui-spine"
|
||||
},
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts",
|
||||
"./components/scenes": "./src/components/scenes/index.ts",
|
||||
"./components/scenes/Spine.vue": "./src/components/scenes/Spine.vue",
|
||||
"./components/scenes/spine": "./src/components/scenes/spine/index.ts",
|
||||
"./composables/spine": "./src/composables/spine/index.ts",
|
||||
"./constants/emotions": "./src/constants/emotions.ts",
|
||||
"./stores": "./src/stores/index.ts",
|
||||
"./stores/spine": "./src/stores/spine.ts",
|
||||
"./tools/animation-tools": "./src/tools/animation-tools.ts",
|
||||
"./utils/spine-preview": "./src/utils/spine-preview.ts",
|
||||
"./utils/spine-validator": "./src/utils/spine-validator.ts",
|
||||
"./utils/spine-zip-loader": "./src/utils/spine-zip-loader.ts"
|
||||
},
|
||||
"scripts": {
|
||||
"typecheck": "vue-tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@esotericsoftware/spine-webgl": "^4.2.0",
|
||||
"@esotericsoftware/spine-webgl-4-0": "npm:@esotericsoftware/spine-webgl@~4.0.31",
|
||||
"@esotericsoftware/spine-webgl-4-1": "npm:@esotericsoftware/spine-webgl@~4.1.56",
|
||||
"@moeru/std": "catalog:",
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@vueuse/core": "^14.2.1",
|
||||
"@xsai/tool": "catalog:",
|
||||
"es-toolkit": "catalog:",
|
||||
"jszip": "^3.10.1",
|
||||
"pinia": "^3.0.4",
|
||||
"vue": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './scenes'
|
||||
@@ -0,0 +1,77 @@
|
||||
<script setup lang="ts">
|
||||
import type { Emotion } from '../../constants/emotions'
|
||||
|
||||
import { Screen } from '@proj-airi/ui'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import SpineCanvas from './spine/Canvas.vue'
|
||||
import SpineModel from './spine/Model.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
modelSrc?: string
|
||||
modelId?: string
|
||||
paused?: boolean
|
||||
premultipliedAlpha?: boolean
|
||||
defaultMixDuration?: number
|
||||
idleAnimationEnabled?: boolean
|
||||
maxFps?: number
|
||||
renderScale?: number
|
||||
}>(), {
|
||||
paused: false,
|
||||
premultipliedAlpha: true,
|
||||
defaultMixDuration: 0.2,
|
||||
idleAnimationEnabled: true,
|
||||
maxFps: 0,
|
||||
renderScale: 1,
|
||||
})
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
const componentStateCanvas = defineModel<'pending' | 'loading' | 'mounted'>('canvasState', { default: 'pending' })
|
||||
const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('modelState', { default: 'pending' })
|
||||
|
||||
const canvasRef = ref<InstanceType<typeof SpineCanvas>>()
|
||||
const modelRef = ref<InstanceType<typeof SpineModel>>()
|
||||
|
||||
watch([componentStateModel, componentStateCanvas], () => {
|
||||
componentState.value = (componentStateModel.value === 'mounted' && componentStateCanvas.value === 'mounted')
|
||||
? 'mounted'
|
||||
: 'loading'
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
canvasElement: () => canvasRef.value?.canvasElement(),
|
||||
captureFrame: () => canvasRef.value?.captureFrame(),
|
||||
setEmotion: (emotion: Emotion, intensity?: number) => modelRef.value?.setEmotion(emotion, intensity),
|
||||
listAnimations: () => modelRef.value?.listAnimations() ?? [],
|
||||
listSkins: () => modelRef.value?.listSkins() ?? [],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Screen v-slot="{ width, height }" relative>
|
||||
<SpineCanvas
|
||||
ref="canvasRef"
|
||||
v-slot="{ canvas }"
|
||||
v-model:state="componentStateCanvas"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:resolution="renderScale"
|
||||
max-h="100dvh"
|
||||
>
|
||||
<SpineModel
|
||||
ref="modelRef"
|
||||
v-model:state="componentStateModel"
|
||||
:model-src="modelSrc"
|
||||
:model-id="modelId"
|
||||
:canvas="canvas"
|
||||
:width="width"
|
||||
:height="height"
|
||||
:paused="paused"
|
||||
:premultiplied-alpha="premultipliedAlpha"
|
||||
:default-mix-duration="defaultMixDuration"
|
||||
:idle-animation-enabled="idleAnimationEnabled"
|
||||
:max-fps="maxFps"
|
||||
/>
|
||||
</SpineCanvas>
|
||||
</Screen>
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './spine'
|
||||
export { default as SpineScene } from './Spine.vue'
|
||||
@@ -0,0 +1,84 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
width: number
|
||||
height: number
|
||||
resolution?: number
|
||||
}>(), {
|
||||
resolution: 1,
|
||||
})
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
const canvasRef = ref<HTMLCanvasElement>()
|
||||
const isCanvasReady = ref(false)
|
||||
|
||||
function initCanvas(parent: HTMLDivElement) {
|
||||
componentState.value = 'loading'
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = Math.max(1, Math.floor(props.width * props.resolution))
|
||||
canvas.height = Math.max(1, Math.floor(props.height * props.resolution))
|
||||
canvas.style.width = '100%'
|
||||
canvas.style.height = '100%'
|
||||
canvas.style.objectFit = 'cover'
|
||||
canvas.style.display = 'block'
|
||||
|
||||
parent.appendChild(canvas)
|
||||
canvasRef.value = canvas
|
||||
isCanvasReady.value = true
|
||||
componentState.value = 'mounted'
|
||||
}
|
||||
|
||||
function handleResize() {
|
||||
if (!canvasRef.value)
|
||||
return
|
||||
|
||||
canvasRef.value.width = Math.max(1, Math.floor(props.width * props.resolution))
|
||||
canvasRef.value.height = Math.max(1, Math.floor(props.height * props.resolution))
|
||||
}
|
||||
|
||||
watch([() => props.width, () => props.height, () => props.resolution], handleResize)
|
||||
|
||||
onMounted(() => {
|
||||
if (containerRef.value)
|
||||
initCanvas(containerRef.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
if (canvasRef.value && canvasRef.value.parentElement)
|
||||
canvasRef.value.parentElement.removeChild(canvasRef.value)
|
||||
canvasRef.value = undefined
|
||||
isCanvasReady.value = false
|
||||
})
|
||||
|
||||
async function captureFrame() {
|
||||
return new Promise<Blob | null>((resolve) => {
|
||||
if (!canvasRef.value)
|
||||
return resolve(null)
|
||||
canvasRef.value.toBlob(resolve, 'image/png')
|
||||
})
|
||||
}
|
||||
|
||||
function canvasElement() {
|
||||
return canvasRef.value
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
captureFrame,
|
||||
canvasElement,
|
||||
})
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
console.warn('[Dev] Reload on HMR dispose is active for this component. Performing a full reload.')
|
||||
window.location.reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div ref="containerRef" h-full w-full>
|
||||
<slot v-if="isCanvasReady" :canvas="canvasRef" />
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,497 @@
|
||||
<script setup lang="ts">
|
||||
import type { AnimationState, AssetManager, Skeleton, SpineCanvas, SpineCanvasApp } from '@esotericsoftware/spine-webgl'
|
||||
|
||||
import type { SpineAnimationManager } from '../../../composables/spine'
|
||||
import type { Emotion } from '../../../constants/emotions'
|
||||
import type { SpineModelVariant } from '../../../utils/spine-zip-loader'
|
||||
|
||||
import { Mutex } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { onMounted, onUnmounted, ref, toRef, watch } from 'vue'
|
||||
|
||||
import { useSpineAnimationManager } from '../../../composables/spine'
|
||||
import { EMOTION_SpineAnimationName_value, SpineAnimationName } from '../../../constants/emotions'
|
||||
import { useSpine } from '../../../stores/spine'
|
||||
import { loadSpineRuntime } from '../../../utils/spine-runtime'
|
||||
import { detectSpineVersionFromBinary, detectSpineVersionFromJson } from '../../../utils/spine-version'
|
||||
import { loadSpineZip } from '../../../utils/spine-zip-loader'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
modelSrc?: string
|
||||
modelId?: string
|
||||
canvas?: HTMLCanvasElement
|
||||
width: number
|
||||
height: number
|
||||
paused?: boolean
|
||||
premultipliedAlpha?: boolean
|
||||
defaultMixDuration?: number
|
||||
idleAnimationEnabled?: boolean
|
||||
maxFps?: number
|
||||
}>(), {
|
||||
paused: false,
|
||||
premultipliedAlpha: true,
|
||||
defaultMixDuration: 0.2,
|
||||
idleAnimationEnabled: true,
|
||||
maxFps: 0,
|
||||
})
|
||||
|
||||
const emits = defineEmits<{
|
||||
(e: 'modelLoaded'): void
|
||||
(e: 'error', error: Error): void
|
||||
(e: 'animationsDiscovered', value: { animations: { name: string, duration: number }[], skins: { name: string }[] }): void
|
||||
}>()
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
|
||||
const spineStore = useSpine()
|
||||
const {
|
||||
position,
|
||||
scale,
|
||||
currentAnimation,
|
||||
currentSkin,
|
||||
availableAnimations,
|
||||
availableSkins,
|
||||
availableVariants,
|
||||
currentVariant,
|
||||
animationSpeed,
|
||||
} = storeToRefs(spineStore)
|
||||
|
||||
let isUnmounted = false
|
||||
const modelLoadMutex = new Mutex()
|
||||
const modelLoading = ref(false)
|
||||
|
||||
// Live runtime objects.
|
||||
let spineCanvas: SpineCanvas | undefined
|
||||
let assetCleanup: (() => void) | undefined
|
||||
let animationManager: SpineAnimationManager | undefined
|
||||
let skeleton: Skeleton | undefined
|
||||
let animationState: AnimationState | undefined
|
||||
let loadedVariants: SpineModelVariant[] = []
|
||||
|
||||
const canvas = toRef(() => props.canvas)
|
||||
const modelSrc = toRef(() => props.modelSrc)
|
||||
const paused = toRef(() => props.paused)
|
||||
|
||||
function disposeSpine() {
|
||||
if (spineCanvas) {
|
||||
try {
|
||||
spineCanvas.dispose()
|
||||
}
|
||||
catch (err) {
|
||||
console.warn('[Spine] Failed to dispose SpineCanvas:', err)
|
||||
}
|
||||
spineCanvas = undefined
|
||||
}
|
||||
assetCleanup?.()
|
||||
assetCleanup = undefined
|
||||
animationManager = undefined
|
||||
skeleton = undefined
|
||||
animationState = undefined
|
||||
}
|
||||
|
||||
async function loadModel() {
|
||||
await modelLoadMutex.acquire()
|
||||
|
||||
modelLoading.value = true
|
||||
componentState.value = 'loading'
|
||||
|
||||
try {
|
||||
if (!canvas.value) {
|
||||
modelLoading.value = false
|
||||
componentState.value = 'mounted'
|
||||
return
|
||||
}
|
||||
|
||||
if (!modelSrc.value) {
|
||||
console.warn('[Spine] No model source provided')
|
||||
disposeSpine()
|
||||
modelLoading.value = false
|
||||
componentState.value = 'mounted'
|
||||
return
|
||||
}
|
||||
|
||||
disposeSpine()
|
||||
|
||||
let assetPaths: { skeletonPath: string, atlasPath: string, skeletonFormat: 'binary' | 'json', texturePaths: string[] }
|
||||
let pathPrefix = ''
|
||||
let blobUrls: Record<string, string> | undefined
|
||||
let rawData: Record<string, Uint8Array | string> | undefined
|
||||
|
||||
const isLocalBlob = modelSrc.value.startsWith('blob:')
|
||||
if (isLocalBlob || modelSrc.value.endsWith('.zip')) {
|
||||
const response = await fetch(modelSrc.value)
|
||||
const blob = await response.blob()
|
||||
const file = new File([blob], 'model.zip', { type: 'application/zip' })
|
||||
const loaded = await loadSpineZip(file)
|
||||
loadedVariants = loaded.variants
|
||||
|
||||
// Populate variant store.
|
||||
availableVariants.value = loaded.variants.map(v => ({ name: v.name }))
|
||||
// Select stored variant or default to first.
|
||||
const selectedVariant = loaded.variants.find(v => v.name === currentVariant.value)
|
||||
?? loaded.variants[0]
|
||||
if (selectedVariant && currentVariant.value !== selectedVariant.name)
|
||||
currentVariant.value = selectedVariant.name
|
||||
|
||||
assetPaths = selectedVariant.layout
|
||||
blobUrls = loaded.blobUrls
|
||||
rawData = loaded.rawData
|
||||
assetCleanup = loaded.dispose
|
||||
}
|
||||
else {
|
||||
// Plain URL case: assume a sibling .skel/.json + .atlas next to the source.
|
||||
const baseUrl = new URL(modelSrc.value, window.location.href)
|
||||
pathPrefix = baseUrl.href.replace(/\/[^/]+$/, '/')
|
||||
const baseName = baseUrl.pathname.replace(/^.*\//, '').replace(/\.(?:json|skel|atlas)(?:\.txt)?$/i, '')
|
||||
const skeletonFormat: 'binary' | 'json' = baseUrl.pathname.toLowerCase().endsWith('.json') ? 'json' : 'binary'
|
||||
assetPaths = {
|
||||
skeletonPath: `${baseName}.${skeletonFormat === 'binary' ? 'skel' : 'json'}`,
|
||||
atlasPath: `${baseName}.atlas`,
|
||||
skeletonFormat,
|
||||
texturePaths: [],
|
||||
}
|
||||
}
|
||||
|
||||
// Detect version from skeleton data to load the matching runtime.
|
||||
let detectedVersion = rawData
|
||||
? (assetPaths.skeletonFormat === 'binary'
|
||||
? detectSpineVersionFromBinary(rawData[assetPaths.skeletonPath] as Uint8Array)
|
||||
: detectSpineVersionFromJson(rawData[assetPaths.skeletonPath] as string))
|
||||
: undefined
|
||||
if (!detectedVersion)
|
||||
detectedVersion = '4.2'
|
||||
const spine = await loadSpineRuntime(detectedVersion)
|
||||
console.log(`[Spine] Detected skeleton version: ${detectedVersion}`)
|
||||
|
||||
if (isUnmounted) {
|
||||
assetCleanup?.()
|
||||
modelLoading.value = false
|
||||
componentState.value = 'mounted'
|
||||
return
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const app: SpineCanvasApp = {
|
||||
loadAssets: (sc) => {
|
||||
const am = sc.assetManager
|
||||
// NOTICE:
|
||||
// Patch BEFORE any load calls. SpineCanvas calls loadAssets
|
||||
// synchronously in its constructor, and am.loadBinary/loadJson/
|
||||
// loadTextureAtlas immediately dispatch XHRs. The downloader
|
||||
// checks rawDataUris at dispatch time — if we patch after the
|
||||
// constructor returns, requests already hit the dev server.
|
||||
if (blobUrls)
|
||||
patchAssetManagerForZipAssets(am, blobUrls, rawData!, assetPaths.texturePaths)
|
||||
|
||||
if (assetPaths.skeletonFormat === 'binary')
|
||||
am.loadBinary(assetPaths.skeletonPath)
|
||||
else
|
||||
am.loadJson(assetPaths.skeletonPath)
|
||||
|
||||
am.loadTextureAtlas(assetPaths.atlasPath)
|
||||
},
|
||||
initialize: (sc) => {
|
||||
try {
|
||||
const am = sc.assetManager
|
||||
const atlas = am.require(assetPaths.atlasPath) as import('@esotericsoftware/spine-webgl').TextureAtlas
|
||||
const attachmentLoader = new spine.AtlasAttachmentLoader(atlas)
|
||||
const skeletonData = assetPaths.skeletonFormat === 'binary'
|
||||
? new spine.SkeletonBinary(attachmentLoader).readSkeletonData(am.require(assetPaths.skeletonPath) as Uint8Array)
|
||||
: new spine.SkeletonJson(attachmentLoader).readSkeletonData(am.require(assetPaths.skeletonPath) as string)
|
||||
|
||||
skeleton = new spine.Skeleton(skeletonData)
|
||||
skeleton.setToSetupPose()
|
||||
applyTransformFromStore()
|
||||
|
||||
const stateData = new spine.AnimationStateData(skeletonData)
|
||||
stateData.defaultMix = props.defaultMixDuration
|
||||
animationState = new spine.AnimationState(stateData)
|
||||
|
||||
animationManager = useSpineAnimationManager(animationState, skeleton, {
|
||||
mixDuration: props.defaultMixDuration,
|
||||
idleAnimationEnabled: props.idleAnimationEnabled,
|
||||
})
|
||||
|
||||
// Inventory animations and skins, populate the store.
|
||||
const animations = skeletonData.animations.map(animation => ({ name: animation.name, duration: animation.duration }))
|
||||
const skins = skeletonData.skins.map(s => ({ name: s.name }))
|
||||
availableAnimations.value = animations
|
||||
availableSkins.value = skins
|
||||
emits('animationsDiscovered', { animations, skins })
|
||||
|
||||
// Apply the user's saved skin (if any).
|
||||
applySkin(currentSkin.value)
|
||||
|
||||
// Apply the user's saved idle animation.
|
||||
applyCurrentAnimation()
|
||||
|
||||
emits('modelLoaded')
|
||||
resolve()
|
||||
}
|
||||
catch (err) {
|
||||
const error = err instanceof Error ? err : new Error(String(err))
|
||||
emits('error', error)
|
||||
reject(error)
|
||||
}
|
||||
},
|
||||
update: (_sc, delta) => {
|
||||
if (!skeleton || !animationState)
|
||||
return
|
||||
if (paused.value) {
|
||||
return
|
||||
}
|
||||
animationState.update(delta * animationSpeed.value)
|
||||
animationState.apply(skeleton)
|
||||
// Physics was added in Spine 4.2; older runtimes take no argument.
|
||||
if (spine.Physics)
|
||||
skeleton.updateWorldTransform(spine.Physics.update)
|
||||
else
|
||||
(skeleton as any).updateWorldTransform()
|
||||
},
|
||||
render: (sc) => {
|
||||
if (!skeleton)
|
||||
return
|
||||
const renderer = sc.renderer
|
||||
renderer.resize(spine.ResizeMode.Expand)
|
||||
sc.gl.clearColor(0, 0, 0, 0)
|
||||
sc.gl.clear(sc.gl.COLOR_BUFFER_BIT)
|
||||
renderer.begin()
|
||||
renderer.drawSkeleton(skeleton, props.premultipliedAlpha)
|
||||
renderer.end()
|
||||
},
|
||||
error: (_sc, errors: Record<string, string>) => {
|
||||
const message = Object.values(errors).join('; ')
|
||||
const error = new Error(message)
|
||||
emits('error', error)
|
||||
reject(error)
|
||||
},
|
||||
}
|
||||
|
||||
spineCanvas = new spine.SpineCanvas(canvas.value!, {
|
||||
app,
|
||||
pathPrefix,
|
||||
webglConfig: { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true },
|
||||
})
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Spine] Failed to load model:', err)
|
||||
emits('error', err instanceof Error ? err : new Error(String(err)))
|
||||
}
|
||||
finally {
|
||||
modelLoading.value = false
|
||||
componentState.value = 'mounted'
|
||||
modelLoadMutex.release()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the AssetManager's Downloader to serve ZIP-extracted assets from
|
||||
* memory. Skeleton/atlas data is served directly from `rawData`; texture
|
||||
* pages use blob URLs registered in `rawDataUris` for `image.src`.
|
||||
*
|
||||
* NOTICE:
|
||||
* Spine's Downloader.rawDataUris has a broken heuristic: values without "."
|
||||
* are decoded as data: URIs via atob(). In Electron, blob URLs are
|
||||
* `blob:null/<uuid>` (no dots) → treated as inline data → 400 error.
|
||||
* Even with data: URIs, the atob round-trip corrupts multi-byte binary.
|
||||
* We bypass rawDataUris entirely for text/binary and monkey-patch the
|
||||
* download methods to resolve from the in-memory `rawData` map.
|
||||
* Source: spine-core/AssetManagerBase.js Downloader class.
|
||||
* Removal condition: Spine ships a Blob/ArrayBuffer-aware asset loader.
|
||||
*/
|
||||
function patchAssetManagerForZipAssets(
|
||||
assetManager: AssetManager,
|
||||
blobUrls: Record<string, string>,
|
||||
rawData: Record<string, Uint8Array | string>,
|
||||
texturePaths: string[],
|
||||
) {
|
||||
const downloader = (assetManager as unknown as {
|
||||
downloader?: {
|
||||
rawDataUris: Record<string, string>
|
||||
downloadText: (url: string, success: (data: string) => void, error: (status: number, responseText: string) => void) => void
|
||||
downloadBinary: (url: string, success: (data: Uint8Array) => void, error: (status: number, response: unknown) => void) => void
|
||||
}
|
||||
}).downloader
|
||||
if (!downloader)
|
||||
return
|
||||
|
||||
// Build a lookup keyed by both full path and bare filename.
|
||||
const textLookup = new Map<string, string>()
|
||||
const binaryLookup = new Map<string, Uint8Array>()
|
||||
for (const [path, data] of Object.entries(rawData)) {
|
||||
const bare = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path
|
||||
if (typeof data === 'string') {
|
||||
textLookup.set(path, data)
|
||||
textLookup.set(bare, data)
|
||||
}
|
||||
else {
|
||||
binaryLookup.set(path, data)
|
||||
binaryLookup.set(bare, data)
|
||||
}
|
||||
}
|
||||
|
||||
const origDownloadText = downloader.downloadText.bind(downloader)
|
||||
const origDownloadBinary = downloader.downloadBinary.bind(downloader)
|
||||
|
||||
downloader.downloadText = (url, success, error) => {
|
||||
const data = textLookup.get(url)
|
||||
if (data !== undefined) {
|
||||
queueMicrotask(() => success(data))
|
||||
return
|
||||
}
|
||||
origDownloadText(url, success, error)
|
||||
}
|
||||
|
||||
downloader.downloadBinary = (url, success, error) => {
|
||||
const data = binaryLookup.get(url)
|
||||
if (data !== undefined) {
|
||||
queueMicrotask(() => success(data))
|
||||
return
|
||||
}
|
||||
origDownloadBinary(url, success, error)
|
||||
}
|
||||
|
||||
// Texture blob URLs → rawDataUris for image.src resolution in loadTexture.
|
||||
for (const path of texturePaths) {
|
||||
const url = blobUrls[path]
|
||||
if (!url)
|
||||
continue
|
||||
downloader.rawDataUris[path] = url
|
||||
const slash = path.lastIndexOf('/')
|
||||
if (slash !== -1)
|
||||
downloader.rawDataUris[path.slice(slash + 1)] = url
|
||||
}
|
||||
}
|
||||
|
||||
function applyTransformFromStore() {
|
||||
if (!skeleton || !canvas.value)
|
||||
return
|
||||
|
||||
// Centre the skeleton roughly at the bottom-middle of the canvas, then
|
||||
// apply user offsets/scale on top. This mirrors the Live2D anchor.
|
||||
const w = canvas.value.width
|
||||
const h = canvas.value.height
|
||||
skeleton.x = w / 2 + position.value.x
|
||||
skeleton.y = h * 0.05 + position.value.y
|
||||
skeleton.scaleX = scale.value
|
||||
skeleton.scaleY = scale.value
|
||||
}
|
||||
|
||||
function applyCurrentAnimation() {
|
||||
if (!animationManager)
|
||||
return
|
||||
const desired = currentAnimation.value?.name ?? SpineAnimationName.Idle
|
||||
animationManager.setIdle(desired)
|
||||
}
|
||||
|
||||
function applySkin(skinName: string) {
|
||||
if (!skeleton)
|
||||
return
|
||||
|
||||
if (!skinName) {
|
||||
skeleton.setSkinByName(skeleton.data.defaultSkin?.name ?? skeleton.data.skins[0]?.name ?? 'default')
|
||||
skeleton.setSlotsToSetupPose()
|
||||
return
|
||||
}
|
||||
|
||||
const skin = skeleton.data.findSkin(skinName)
|
||||
if (skin) {
|
||||
skeleton.setSkin(skin)
|
||||
skeleton.setSlotsToSetupPose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Plays an emotion-tagged animation on the dedicated emotion track.
|
||||
*
|
||||
* Use when:
|
||||
* - The chat orchestrator emits an `EmotionPayload`. The Stage component
|
||||
* forwards the emotion name here so the model can react in real time
|
||||
* without disturbing the persistent idle loop on track 0.
|
||||
*
|
||||
* Expects:
|
||||
* - The skeleton has loaded (`componentState === 'mounted'`). The call is
|
||||
* a no-op if invoked before then.
|
||||
*
|
||||
* Returns:
|
||||
* - The resolved animation name when one was found, otherwise `undefined`.
|
||||
*/
|
||||
function setEmotion(emotion: Emotion, _intensity: number = 1): string | undefined {
|
||||
if (!animationManager)
|
||||
return undefined
|
||||
const animationName = EMOTION_SpineAnimationName_value[emotion]
|
||||
if (!animationName)
|
||||
return undefined
|
||||
const entry = animationManager.playEmotion(animationName)
|
||||
return entry?.animation?.name
|
||||
}
|
||||
|
||||
watch(modelSrc, async () => await loadModel(), { immediate: true })
|
||||
watch(canvas, async (next, prev) => {
|
||||
if (next && next !== prev)
|
||||
await loadModel()
|
||||
})
|
||||
|
||||
watch([() => props.width, () => props.height, position, scale], () => {
|
||||
applyTransformFromStore()
|
||||
}, { deep: true })
|
||||
|
||||
watch(currentAnimation, () => {
|
||||
applyCurrentAnimation()
|
||||
}, { deep: true })
|
||||
|
||||
watch(currentSkin, (skinName) => {
|
||||
applySkin(skinName)
|
||||
})
|
||||
|
||||
watch(currentVariant, async () => {
|
||||
if (loadedVariants.length > 1)
|
||||
await loadModel()
|
||||
})
|
||||
|
||||
watch(() => props.idleAnimationEnabled, () => {
|
||||
if (!animationManager || !skeleton || !animationState)
|
||||
return
|
||||
if (props.idleAnimationEnabled)
|
||||
applyCurrentAnimation()
|
||||
else
|
||||
animationState.setEmptyAnimation(0, props.defaultMixDuration)
|
||||
})
|
||||
|
||||
watch(() => props.defaultMixDuration, (mix) => {
|
||||
if (animationState)
|
||||
animationState.data.defaultMix = mix
|
||||
})
|
||||
|
||||
watch(paused, () => {
|
||||
// SpineCanvas does not expose a built-in pause; we toggle by stopping
|
||||
// the update step from advancing time (handled in the update callback).
|
||||
// We still let render run so the last frame remains visible.
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
// First load is triggered by the immediate watch above when the canvas
|
||||
// becomes available.
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
isUnmounted = true
|
||||
disposeSpine()
|
||||
})
|
||||
|
||||
defineExpose({
|
||||
setEmotion,
|
||||
listAnimations: () => animationManager?.listAnimations() ?? [],
|
||||
listSkins: () => availableSkins.value.map(s => s.name),
|
||||
})
|
||||
|
||||
import.meta.hot?.dispose(() => {
|
||||
console.warn('[Dev] Reload on HMR dispose is active for this component. Performing a full reload.')
|
||||
window.location.reload()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<slot />
|
||||
</template>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as SpineCanvas } from './Canvas.vue'
|
||||
export { default as SpineModel } from './Model.vue'
|
||||
@@ -0,0 +1,122 @@
|
||||
import type { AnimationState, Skeleton, TrackEntry } from '@esotericsoftware/spine-webgl'
|
||||
|
||||
import { SPINE_EMOTION_TRACK, SPINE_IDLE_TRACK } from '../../constants/emotions'
|
||||
|
||||
export interface SpineAnimationManager {
|
||||
/** Set the looping idle animation on track 0. */
|
||||
setIdle: (name: string) => TrackEntry | null
|
||||
/** Play a one-shot emotion animation on track 1. */
|
||||
playEmotion: (name: string, options?: { loop?: boolean, mixDuration?: number }) => TrackEntry | null
|
||||
/** Stop the emotion track and re-empty back to the idle state. */
|
||||
clearEmotion: (mixDuration?: number) => void
|
||||
/** Resolve the closest matching animation name. Case-insensitive substring match. */
|
||||
resolveAnimation: (preferred: string) => string | undefined
|
||||
/** Returns the list of animation names available on the loaded skeleton. */
|
||||
listAnimations: () => string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a Spine `AnimationState` + `Skeleton` pair with helpers for AIRI's
|
||||
* idle-vs-emotion track conventions.
|
||||
*
|
||||
* Use when:
|
||||
* - The Spine model's lifecycle (mount, animation switch, emotion event)
|
||||
* needs to consult the loaded skeleton, fall back to similar names, or
|
||||
* layer one-shot animations on top of an idle loop.
|
||||
*
|
||||
* Expects:
|
||||
* - `animationState` and `skeleton` are already initialized for a model
|
||||
* that the caller mounted via `loadSpineZip()` or a URL source.
|
||||
*
|
||||
* Returns:
|
||||
* - A handle that mutates the underlying `AnimationState` directly.
|
||||
*/
|
||||
export function useSpineAnimationManager(
|
||||
animationState: AnimationState,
|
||||
skeleton: Skeleton,
|
||||
defaults: { mixDuration: number, idleAnimationEnabled: boolean },
|
||||
): SpineAnimationManager {
|
||||
function listAnimations() {
|
||||
return skeleton.data.animations.map(animation => animation.name)
|
||||
}
|
||||
|
||||
function resolveAnimation(preferred: string) {
|
||||
const animations = listAnimations()
|
||||
if (animations.length === 0)
|
||||
return undefined
|
||||
|
||||
// 1. exact match
|
||||
const exact = animations.find(name => name === preferred)
|
||||
if (exact)
|
||||
return exact
|
||||
|
||||
// 2. case-insensitive exact match
|
||||
const ci = animations.find(name => name.toLowerCase() === preferred.toLowerCase())
|
||||
if (ci)
|
||||
return ci
|
||||
|
||||
// 3. substring contains preferred
|
||||
const contains = animations.find(name => name.toLowerCase().includes(preferred.toLowerCase()))
|
||||
if (contains)
|
||||
return contains
|
||||
|
||||
// 4. preferred contains animation name
|
||||
const reverse = animations.find(name => preferred.toLowerCase().includes(name.toLowerCase()))
|
||||
if (reverse)
|
||||
return reverse
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
function setIdle(name: string): TrackEntry | null {
|
||||
if (!defaults.idleAnimationEnabled) {
|
||||
animationState.setEmptyAnimation(SPINE_IDLE_TRACK, defaults.mixDuration)
|
||||
return null
|
||||
}
|
||||
|
||||
const resolved = resolveAnimation(name) ?? listAnimations()[0]
|
||||
if (!resolved)
|
||||
return null
|
||||
|
||||
return animationState.setAnimation(SPINE_IDLE_TRACK, resolved, true)
|
||||
}
|
||||
|
||||
function playEmotion(name: string, options?: { loop?: boolean, mixDuration?: number }): TrackEntry | null {
|
||||
const resolved = resolveAnimation(name)
|
||||
if (!resolved)
|
||||
return null
|
||||
|
||||
const entry = animationState.setAnimation(SPINE_EMOTION_TRACK, resolved, options?.loop ?? false)
|
||||
entry.mixDuration = options?.mixDuration ?? defaults.mixDuration
|
||||
// Auto-clear after the one-shot animation completes; the listener fires
|
||||
// on `complete` for non-looping tracks, restoring the idle state.
|
||||
if (!entry.loop) {
|
||||
const listener = {
|
||||
complete: (completed: TrackEntry) => {
|
||||
if (completed === entry) {
|
||||
try {
|
||||
animationState.setEmptyAnimation(SPINE_EMOTION_TRACK, defaults.mixDuration)
|
||||
}
|
||||
finally {
|
||||
animationState.removeListener(listener)
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
animationState.addListener(listener)
|
||||
}
|
||||
return entry
|
||||
}
|
||||
|
||||
function clearEmotion(mixDuration?: number) {
|
||||
animationState.setEmptyAnimation(SPINE_EMOTION_TRACK, mixDuration ?? defaults.mixDuration)
|
||||
}
|
||||
|
||||
return {
|
||||
setIdle,
|
||||
playEmotion,
|
||||
clearEmotion,
|
||||
resolveAnimation,
|
||||
listAnimations,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export * from './animation-manager'
|
||||
@@ -0,0 +1,66 @@
|
||||
export enum Emotion {
|
||||
Happy = 'happy',
|
||||
Sad = 'sad',
|
||||
Angry = 'angry',
|
||||
Think = 'think',
|
||||
Surprise = 'surprised',
|
||||
Awkward = 'awkward',
|
||||
Question = 'question',
|
||||
Curious = 'curious',
|
||||
Neutral = 'neutral',
|
||||
}
|
||||
|
||||
export const EMOTION_VALUES = Object.values(Emotion)
|
||||
|
||||
/**
|
||||
* Default Spine animation track used for the persistent idle/state loop.
|
||||
*/
|
||||
export const SPINE_IDLE_TRACK = 0
|
||||
|
||||
/**
|
||||
* Default Spine animation track used for one-shot emotion overrides.
|
||||
*
|
||||
* Higher track index renders on top of the idle track, mirroring how the
|
||||
* Spine player layers shoot/celebrate animations over the idle skeleton.
|
||||
*/
|
||||
export const SPINE_EMOTION_TRACK = 1
|
||||
|
||||
/**
|
||||
* Common Spine animation names that AIRI maps incoming emotions to.
|
||||
*
|
||||
* These names follow the Esoteric Software example conventions
|
||||
* (idle/walk/run/jump/shoot/death/celebrate). Models that ship custom
|
||||
* names can override the mapping at runtime through the settings panel.
|
||||
*/
|
||||
export const SpineAnimationName = {
|
||||
Idle: 'idle',
|
||||
Happy: 'celebrate',
|
||||
Sad: 'sad',
|
||||
Angry: 'angry',
|
||||
Awkward: 'awkward',
|
||||
Think: 'think',
|
||||
Surprise: 'surprise',
|
||||
Question: 'question',
|
||||
Curious: 'curious',
|
||||
Neutral: 'idle',
|
||||
} as const
|
||||
|
||||
export type SpineAnimationKey = keyof typeof SpineAnimationName
|
||||
|
||||
/**
|
||||
* Maps an AIRI emotion to a canonical Spine animation name.
|
||||
*
|
||||
* The actual track name played at runtime falls back to whichever name
|
||||
* exists on the loaded skeleton — see useSpineAnimationManager().
|
||||
*/
|
||||
export const EMOTION_SpineAnimationName_value: Record<Emotion, string> = {
|
||||
[Emotion.Happy]: SpineAnimationName.Happy,
|
||||
[Emotion.Sad]: SpineAnimationName.Sad,
|
||||
[Emotion.Angry]: SpineAnimationName.Angry,
|
||||
[Emotion.Think]: SpineAnimationName.Think,
|
||||
[Emotion.Surprise]: SpineAnimationName.Surprise,
|
||||
[Emotion.Awkward]: SpineAnimationName.Awkward,
|
||||
[Emotion.Question]: SpineAnimationName.Question,
|
||||
[Emotion.Neutral]: SpineAnimationName.Neutral,
|
||||
[Emotion.Curious]: SpineAnimationName.Curious,
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
export { SpineCanvas, SpineModel } from './components/scenes/spine'
|
||||
export { default as SpineScene } from './components/scenes/Spine.vue'
|
||||
export * from './composables/spine'
|
||||
export * from './constants/emotions'
|
||||
export * from './stores'
|
||||
export * from './utils/spine-preview'
|
||||
export * from './utils/spine-validator'
|
||||
export * from './utils/spine-zip-loader'
|
||||
@@ -0,0 +1,2 @@
|
||||
export * from './spine'
|
||||
export * from './view-control'
|
||||
@@ -0,0 +1,151 @@
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { useBroadcastChannel } from '@vueuse/core'
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
import { supportedControl, useSpineViewControl } from './view-control'
|
||||
|
||||
type BroadcastChannelEvents
|
||||
= | BroadcastChannelEventShouldUpdateView
|
||||
|
||||
interface BroadcastChannelEventShouldUpdateView {
|
||||
type: 'spine-should-update-view'
|
||||
}
|
||||
|
||||
export interface SpineAnimationDescriptor {
|
||||
name: string
|
||||
duration: number
|
||||
}
|
||||
|
||||
export interface SpineSkinDescriptor {
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface SpineVariantDescriptor {
|
||||
name: string
|
||||
}
|
||||
|
||||
/** Persisted runtime state for the active Spine model. */
|
||||
export interface SpineCurrentAnimation {
|
||||
/** Animation name resolved against the loaded skeleton. */
|
||||
name: string
|
||||
/** Whether the animation should loop on track 0. */
|
||||
loop: boolean
|
||||
/** Optional one-shot trigger; bumped to force re-application. */
|
||||
nonce?: number
|
||||
}
|
||||
|
||||
export const defaultSpineAnimation: SpineCurrentAnimation = {
|
||||
name: 'idle',
|
||||
loop: true,
|
||||
}
|
||||
|
||||
export const useSpine = defineStore('spine', () => {
|
||||
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({
|
||||
name: 'airi-stores-stage-ui-spine',
|
||||
})
|
||||
const shouldUpdateViewHooks = ref(new Set<() => void>())
|
||||
|
||||
const onShouldUpdateView = (hook: () => void) => {
|
||||
shouldUpdateViewHooks.value.add(hook)
|
||||
return () => {
|
||||
shouldUpdateViewHooks.value.delete(hook)
|
||||
}
|
||||
}
|
||||
|
||||
function shouldUpdateView() {
|
||||
post({ type: 'spine-should-update-view' })
|
||||
shouldUpdateViewHooks.value.forEach(hook => hook())
|
||||
}
|
||||
|
||||
watch(data, (event) => {
|
||||
if (event?.type === 'spine-should-update-view') {
|
||||
shouldUpdateViewHooks.value.forEach(hook => hook())
|
||||
}
|
||||
})
|
||||
|
||||
/** Currently active idle animation (track 0). */
|
||||
const currentAnimation = useLocalStorageManualReset<SpineCurrentAnimation>(
|
||||
'settings/spine/current-animation',
|
||||
() => ({ ...defaultSpineAnimation }),
|
||||
)
|
||||
|
||||
/** All animations discovered on the loaded skeleton. */
|
||||
const availableAnimations = useLocalStorageManualReset<SpineAnimationDescriptor[]>(
|
||||
'settings/spine/available-animations',
|
||||
() => [],
|
||||
)
|
||||
|
||||
/** All skins discovered on the loaded skeleton. */
|
||||
const availableSkins = useLocalStorageManualReset<SpineSkinDescriptor[]>(
|
||||
'settings/spine/available-skins',
|
||||
() => [],
|
||||
)
|
||||
|
||||
/** Active skin name. Empty string means use the model's default skin. */
|
||||
const currentSkin = useLocalStorageManualReset<string>('settings/spine/current-skin', '')
|
||||
|
||||
/** All skeleton variants discovered in the ZIP. */
|
||||
const availableVariants = useLocalStorageManualReset<SpineVariantDescriptor[]>(
|
||||
'settings/spine/available-variants',
|
||||
() => [],
|
||||
)
|
||||
|
||||
/** Active variant name. Empty string means use the default (first) variant. */
|
||||
const currentVariant = useLocalStorageManualReset<string>('settings/spine/current-variant', '')
|
||||
|
||||
/** Premultiplied alpha — most modern Spine atlases ship as PMA. */
|
||||
const premultipliedAlpha = useLocalStorageManualReset<boolean>('settings/spine/premultiplied-alpha', true)
|
||||
|
||||
/** Default mix-in/out duration (s) between track animations. */
|
||||
const defaultMixDuration = useLocalStorageManualReset<number>('settings/spine/default-mix', 0.2)
|
||||
|
||||
/** Auto-play idle animation on load. */
|
||||
const idleAnimationEnabled = useLocalStorageManualReset<boolean>('settings/spine/idle-enabled', true)
|
||||
|
||||
/** Animation playback speed multiplier (1.0 = normal). */
|
||||
const animationSpeed = useLocalStorageManualReset<number>('settings/spine/animation-speed', 1)
|
||||
|
||||
/** Maximum FPS for the WebGL render loop (0 = uncapped). */
|
||||
const maxFps = useLocalStorageManualReset<number>('settings/spine/max-fps', 0)
|
||||
|
||||
const { position, scale, reset: resetViewControl } = useSpineViewControl()
|
||||
|
||||
function resetState() {
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
currentAnimation.reset()
|
||||
availableAnimations.reset()
|
||||
availableSkins.reset()
|
||||
currentSkin.reset()
|
||||
availableVariants.reset()
|
||||
currentVariant.reset()
|
||||
premultipliedAlpha.reset()
|
||||
defaultMixDuration.reset()
|
||||
idleAnimationEnabled.reset()
|
||||
animationSpeed.reset()
|
||||
maxFps.reset()
|
||||
shouldUpdateView()
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
scale,
|
||||
currentAnimation,
|
||||
availableAnimations,
|
||||
availableSkins,
|
||||
currentSkin,
|
||||
availableVariants,
|
||||
currentVariant,
|
||||
premultipliedAlpha,
|
||||
defaultMixDuration,
|
||||
idleAnimationEnabled,
|
||||
animationSpeed,
|
||||
maxFps,
|
||||
|
||||
onShouldUpdateView,
|
||||
shouldUpdateView,
|
||||
resetState,
|
||||
}
|
||||
})
|
||||
|
||||
export { useSpineViewControl }
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'scale'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
|
||||
const viewControlsEnabled = ref(false)
|
||||
const viewControlMode = ref<SupportedControl>('scale')
|
||||
|
||||
/** model position relative to the centre of the screen, in pixels */
|
||||
const position = useLocalStorage<{ x: number, y: number }>('settings/spine/position', { x: 0, y: 0 })
|
||||
/** uniform model scaling. `1` means no scaling. */
|
||||
const scale = useLocalStorage('settings/spine/scale', 1)
|
||||
|
||||
const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
|
||||
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
x: { min: -500, max: 500, step: 0.1, default: 0, format: formatPercentD1 },
|
||||
y: { min: -500, max: 500, step: 0.1, default: 0, format: formatPercentD1 },
|
||||
scale: { min: 0.01, max: 3, step: 0.01, default: 1, format: formatToPercent },
|
||||
}
|
||||
|
||||
export function useSpineViewControl() {
|
||||
function reset(key: SupportedControl) {
|
||||
switch (key) {
|
||||
case 'x':
|
||||
position.value.x = controlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
position.value.y = controlConfig.y.default
|
||||
break
|
||||
case 'scale':
|
||||
scale.value = controlConfig.scale.default
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
position,
|
||||
scale,
|
||||
reset,
|
||||
viewControlsEnabled,
|
||||
viewControlMode,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { tool } from '@xsai/tool'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { useSpine } from '../stores/spine'
|
||||
|
||||
interface SpineToolResult {
|
||||
success: boolean
|
||||
data?: unknown
|
||||
error?: string
|
||||
}
|
||||
|
||||
function serialize(result: SpineToolResult): string {
|
||||
return JSON.stringify(result)
|
||||
}
|
||||
|
||||
function ensureModelLoaded(): SpineToolResult | null {
|
||||
const store = useSpine()
|
||||
if (store.availableAnimations.length === 0)
|
||||
return { success: false, error: 'No Spine model is currently loaded.' }
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* LLM-callable tools for controlling the active Spine model.
|
||||
*
|
||||
* Use when:
|
||||
* - The chat orchestrator wires up tools for the active provider.
|
||||
*
|
||||
* Expects:
|
||||
* - A Spine model is the active stage renderer; otherwise tool calls
|
||||
* short-circuit with `success: false`.
|
||||
*/
|
||||
export const tools = [
|
||||
tool({
|
||||
name: 'spine_play_animation',
|
||||
description: [
|
||||
'Play a Spine animation on the loaded model.',
|
||||
'By default, replaces the looping idle animation; pass `oneShot: true` to layer the animation on top of the idle loop and revert when it finishes.',
|
||||
'Animation names are case-insensitive; partial matches are accepted.',
|
||||
].join(' '),
|
||||
execute: async ({ name, oneShot, loop }) => {
|
||||
const err = ensureModelLoaded()
|
||||
if (err)
|
||||
return serialize(err)
|
||||
|
||||
const store = useSpine()
|
||||
if (oneShot) {
|
||||
// The active model component watches a `nonce` field on
|
||||
// currentAnimation to re-trigger the same animation, but for
|
||||
// one-shot we let the Stage forward the call to setEmotion via
|
||||
// the Spine instance ref. The store-level signal here updates
|
||||
// the persisted idle when oneShot is false.
|
||||
return serialize({
|
||||
success: true,
|
||||
data: {
|
||||
queued: name,
|
||||
mode: 'one-shot',
|
||||
note: 'Forwarded to scene; the scene resolves the closest matching animation name.',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
store.currentAnimation = { name, loop: loop ?? true, nonce: (store.currentAnimation.nonce ?? 0) + 1 }
|
||||
return serialize({ success: true, data: { idle: name, loop: loop ?? true } })
|
||||
},
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Spine animation name (e.g. "idle", "walk", "celebrate"). Case-insensitive partial match accepted.'),
|
||||
loop: z.boolean().optional().describe('Whether the animation should loop. Defaults to true.'),
|
||||
oneShot: z.boolean().optional().describe('Play once on the emotion track instead of replacing the idle loop.'),
|
||||
}),
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: 'spine_list_animations',
|
||||
description: 'List every animation available on the currently loaded Spine skeleton.',
|
||||
execute: async () => {
|
||||
const err = ensureModelLoaded()
|
||||
if (err)
|
||||
return serialize(err)
|
||||
const store = useSpine()
|
||||
return serialize({ success: true, data: store.availableAnimations })
|
||||
},
|
||||
parameters: z.object({}),
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: 'spine_set_skin',
|
||||
description: 'Switch the active skin. Skins are model-defined variants (different costumes/colours).',
|
||||
execute: async ({ name }) => {
|
||||
const err = ensureModelLoaded()
|
||||
if (err)
|
||||
return serialize(err)
|
||||
|
||||
const store = useSpine()
|
||||
const exists = store.availableSkins.some(skin => skin.name === name)
|
||||
if (!exists) {
|
||||
return serialize({
|
||||
success: false,
|
||||
error: `Skin "${name}" not found. Available: ${store.availableSkins.map(skin => skin.name).join(', ')}`,
|
||||
})
|
||||
}
|
||||
store.currentSkin = name
|
||||
return serialize({ success: true, data: { skin: name } })
|
||||
},
|
||||
parameters: z.object({
|
||||
name: z.string().describe('Skin name as defined in the skeleton.'),
|
||||
}),
|
||||
}),
|
||||
|
||||
tool({
|
||||
name: 'spine_list_skins',
|
||||
description: 'List every skin defined on the currently loaded Spine skeleton.',
|
||||
execute: async () => {
|
||||
const err = ensureModelLoaded()
|
||||
if (err)
|
||||
return serialize(err)
|
||||
const store = useSpine()
|
||||
return serialize({ success: true, data: store.availableSkins })
|
||||
},
|
||||
parameters: z.object({}),
|
||||
}),
|
||||
]
|
||||
@@ -0,0 +1,232 @@
|
||||
import { loadSpineRuntime } from './spine-runtime'
|
||||
import { detectSpineVersionFromBinary, detectSpineVersionFromJson } from './spine-version'
|
||||
import { loadSpineZip } from './spine-zip-loader'
|
||||
|
||||
/**
|
||||
* Renders the first frame of a user-imported Spine ZIP to an offscreen
|
||||
* canvas and returns a data URL suitable for the model-selector grid.
|
||||
*
|
||||
* Use when:
|
||||
* - A user imports a `.zip` Spine model and the display-models store
|
||||
* needs a thumbnail for the catalog tile.
|
||||
*
|
||||
* Expects:
|
||||
* - The ZIP passes `validateSpineZip()`; otherwise this returns `undefined`.
|
||||
*
|
||||
* Returns:
|
||||
* - A `data:image/png` URL when rendering succeeds, otherwise `undefined`.
|
||||
*/
|
||||
export async function loadSpineModelPreview(file: File): Promise<string | undefined> {
|
||||
let assets: Awaited<ReturnType<typeof loadSpineZip>> | undefined
|
||||
let canvas: HTMLCanvasElement | undefined
|
||||
try {
|
||||
assets = await loadSpineZip(file)
|
||||
|
||||
let detectedVersion = assets.layout.skeletonFormat === 'binary'
|
||||
? detectSpineVersionFromBinary(assets.rawData[assets.layout.skeletonPath] as Uint8Array)
|
||||
: detectSpineVersionFromJson(assets.rawData[assets.layout.skeletonPath] as string)
|
||||
if (!detectedVersion)
|
||||
detectedVersion = '4.2'
|
||||
const spine = await loadSpineRuntime(detectedVersion)
|
||||
|
||||
const previewWidth = 720
|
||||
const previewHeight = 960
|
||||
|
||||
canvas = document.createElement('canvas')
|
||||
canvas.width = previewWidth
|
||||
canvas.height = previewHeight
|
||||
canvas.style.position = 'absolute'
|
||||
canvas.style.left = '-99999px'
|
||||
canvas.style.top = '0'
|
||||
document.body.appendChild(canvas)
|
||||
|
||||
const layout = assets.layout
|
||||
const blobUrls = assets.blobUrls
|
||||
const rawData = assets.rawData
|
||||
|
||||
const skeletonAssetPath = layout.skeletonPath
|
||||
const atlasAssetPath = layout.atlasPath
|
||||
|
||||
return await new Promise<string | undefined>((resolve) => {
|
||||
let resolved = false
|
||||
const finish = (value: string | undefined) => {
|
||||
if (resolved)
|
||||
return
|
||||
resolved = true
|
||||
resolve(value)
|
||||
}
|
||||
|
||||
try {
|
||||
const app: import('@esotericsoftware/spine-webgl').SpineCanvasApp = {
|
||||
loadAssets: (canvasApp: import('@esotericsoftware/spine-webgl').SpineCanvas) => {
|
||||
// NOTICE:
|
||||
// Patch BEFORE any load calls. SpineCanvas calls loadAssets
|
||||
// synchronously in its constructor, and load methods immediately
|
||||
// dispatch XHR. Patching after the constructor is too late.
|
||||
// Source/context: spine-core/AssetManagerBase.js Downloader class.
|
||||
// Removal condition: Spine ships a Blob/buffer-aware loader.
|
||||
const am = canvasApp.assetManager
|
||||
patchAssetManagerForZipAssets(am, blobUrls, rawData, layout.texturePaths)
|
||||
|
||||
if (layout.skeletonFormat === 'binary')
|
||||
am.loadBinary(skeletonAssetPath)
|
||||
else
|
||||
am.loadJson(skeletonAssetPath)
|
||||
|
||||
am.loadTextureAtlas(atlasAssetPath)
|
||||
for (const texPath of layout.texturePaths)
|
||||
am.loadTexture(texPath)
|
||||
},
|
||||
initialize: (canvasApp: import('@esotericsoftware/spine-webgl').SpineCanvas) => {
|
||||
const am = canvasApp.assetManager
|
||||
|
||||
const atlas = am.require(atlasAssetPath) as import('@esotericsoftware/spine-webgl').TextureAtlas
|
||||
const skeletonData = layout.skeletonFormat === 'binary'
|
||||
? new spine.SkeletonBinary(new spine.AtlasAttachmentLoader(atlas))
|
||||
.readSkeletonData(am.require(skeletonAssetPath) as Uint8Array)
|
||||
: new spine.SkeletonJson(new spine.AtlasAttachmentLoader(atlas))
|
||||
.readSkeletonData(am.require(skeletonAssetPath) as string)
|
||||
|
||||
const skeleton = new spine.Skeleton(skeletonData)
|
||||
skeleton.setToSetupPose()
|
||||
;(canvasApp as unknown as { __previewSkeleton: import('@esotericsoftware/spine-webgl').Skeleton }).__previewSkeleton = skeleton
|
||||
},
|
||||
update: (canvasApp: import('@esotericsoftware/spine-webgl').SpineCanvas, _delta: number) => {
|
||||
const skeleton = (canvasApp as unknown as { __previewSkeleton?: import('@esotericsoftware/spine-webgl').Skeleton }).__previewSkeleton
|
||||
if (skeleton) {
|
||||
if (spine.Physics)
|
||||
skeleton.updateWorldTransform(spine.Physics.update)
|
||||
else
|
||||
(skeleton as any).updateWorldTransform()
|
||||
}
|
||||
},
|
||||
render: (canvasApp: import('@esotericsoftware/spine-webgl').SpineCanvas) => {
|
||||
const skeleton = (canvasApp as unknown as { __previewSkeleton?: import('@esotericsoftware/spine-webgl').Skeleton }).__previewSkeleton
|
||||
if (!skeleton)
|
||||
return
|
||||
|
||||
const renderer = canvasApp.renderer
|
||||
renderer.resize(spine.ResizeMode.Fit)
|
||||
canvasApp.gl.clearColor(0, 0, 0, 0)
|
||||
canvasApp.gl.clear(canvasApp.gl.COLOR_BUFFER_BIT)
|
||||
renderer.begin()
|
||||
renderer.drawSkeleton(skeleton, true)
|
||||
renderer.end()
|
||||
|
||||
try {
|
||||
const dataUrl = canvas!.toDataURL('image/png')
|
||||
finish(dataUrl)
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Spine] Failed to capture preview:', err)
|
||||
finish(undefined)
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
// Use a custom path handler so AssetManager fetches go through our
|
||||
// blob URLs instead of trying the resolved path on the network.
|
||||
const SpineCanvasCtor = spine.SpineCanvas as unknown as new (
|
||||
canvas: HTMLCanvasElement,
|
||||
config: { app: import('@esotericsoftware/spine-webgl').SpineCanvasApp, pathPrefix?: string, webglConfig?: WebGLContextAttributes },
|
||||
) => import('@esotericsoftware/spine-webgl').SpineCanvas
|
||||
|
||||
new SpineCanvasCtor(canvas!, {
|
||||
app,
|
||||
pathPrefix: '',
|
||||
webglConfig: { alpha: true, premultipliedAlpha: false, preserveDrawingBuffer: true },
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Spine] Preview generation failed:', err)
|
||||
finish(undefined)
|
||||
}
|
||||
|
||||
// Hard timeout so a stuck load can't block the import flow.
|
||||
setTimeout(finish, 4000, undefined)
|
||||
})
|
||||
}
|
||||
catch (err) {
|
||||
console.error('[Spine] Preview generation failed:', err)
|
||||
return undefined
|
||||
}
|
||||
finally {
|
||||
if (canvas?.isConnected)
|
||||
canvas.remove()
|
||||
assets?.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Patches the AssetManager's Downloader to serve ZIP-extracted assets from
|
||||
* memory, bypassing the broken rawDataUris heuristic.
|
||||
*
|
||||
* NOTICE:
|
||||
* Spine's Downloader.rawDataUris treats values without "." as data: URIs
|
||||
* (atob decode). Blob URLs in Electron are `blob:null/<uuid>` (no dots) →
|
||||
* misidentified as data URIs → status 400. Even real data: URIs corrupt
|
||||
* multi-byte binary via atob round-trip.
|
||||
* Source: spine-core/AssetManagerBase.js Downloader class.
|
||||
* Removal condition: Spine ships a Blob/ArrayBuffer-aware asset loader.
|
||||
*/
|
||||
function patchAssetManagerForZipAssets(
|
||||
assetManager: import('@esotericsoftware/spine-webgl').AssetManager,
|
||||
blobUrls: Record<string, string>,
|
||||
rawData: Record<string, Uint8Array | string>,
|
||||
texturePaths: string[],
|
||||
) {
|
||||
const downloader = (assetManager as unknown as {
|
||||
downloader?: {
|
||||
rawDataUris: Record<string, string>
|
||||
downloadText: (url: string, success: (data: string) => void, error: (status: number, responseText: string) => void) => void
|
||||
downloadBinary: (url: string, success: (data: Uint8Array) => void, error: (status: number, response: unknown) => void) => void
|
||||
}
|
||||
}).downloader
|
||||
if (!downloader)
|
||||
return
|
||||
|
||||
const textLookup = new Map<string, string>()
|
||||
const binaryLookup = new Map<string, Uint8Array>()
|
||||
for (const [path, data] of Object.entries(rawData)) {
|
||||
const bare = path.includes('/') ? path.slice(path.lastIndexOf('/') + 1) : path
|
||||
if (typeof data === 'string') {
|
||||
textLookup.set(path, data)
|
||||
textLookup.set(bare, data)
|
||||
}
|
||||
else {
|
||||
binaryLookup.set(path, data)
|
||||
binaryLookup.set(bare, data)
|
||||
}
|
||||
}
|
||||
|
||||
const origDownloadText = downloader.downloadText.bind(downloader)
|
||||
const origDownloadBinary = downloader.downloadBinary.bind(downloader)
|
||||
|
||||
downloader.downloadText = (url, success, error) => {
|
||||
const data = textLookup.get(url)
|
||||
if (data !== undefined) {
|
||||
queueMicrotask(() => success(data))
|
||||
return
|
||||
}
|
||||
origDownloadText(url, success, error)
|
||||
}
|
||||
|
||||
downloader.downloadBinary = (url, success, error) => {
|
||||
const data = binaryLookup.get(url)
|
||||
if (data !== undefined) {
|
||||
queueMicrotask(() => success(data))
|
||||
return
|
||||
}
|
||||
origDownloadBinary(url, success, error)
|
||||
}
|
||||
|
||||
for (const path of texturePaths) {
|
||||
const url = blobUrls[path]
|
||||
if (!url)
|
||||
continue
|
||||
downloader.rawDataUris[path] = url
|
||||
const slash = path.lastIndexOf('/')
|
||||
if (slash !== -1)
|
||||
downloader.rawDataUris[path.slice(slash + 1)] = url
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { SpineVersion } from './spine-version'
|
||||
|
||||
/**
|
||||
* Lazily loads the spine-webgl runtime for the given Spine version.
|
||||
*
|
||||
* Use when:
|
||||
* - The skeleton version has been detected and we need the matching runtime
|
||||
* to parse and render the model correctly.
|
||||
*
|
||||
* Expects:
|
||||
* - A valid SpineVersion ('4.0', '4.1', or '4.2').
|
||||
*
|
||||
* Returns:
|
||||
* - The full spine-webgl module namespace for that version.
|
||||
*/
|
||||
export async function loadSpineRuntime(version: SpineVersion): Promise<typeof import('@esotericsoftware/spine-webgl')> {
|
||||
switch (version) {
|
||||
case '4.0':
|
||||
return await import('@esotericsoftware/spine-webgl-4-0') as unknown as typeof import('@esotericsoftware/spine-webgl')
|
||||
case '4.1':
|
||||
return await import('@esotericsoftware/spine-webgl-4-1') as unknown as typeof import('@esotericsoftware/spine-webgl')
|
||||
case '4.2':
|
||||
return await import('@esotericsoftware/spine-webgl')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
|
||||
export type SpineValidationStatus = 'VALID' | 'INVALID'
|
||||
|
||||
export interface SpineValidationReport {
|
||||
status: SpineValidationStatus
|
||||
errors: string[]
|
||||
warnings: string[]
|
||||
detected: {
|
||||
skeletonPath?: string
|
||||
skeletonFormat?: 'binary' | 'json'
|
||||
atlasPath?: string
|
||||
texturePaths: string[]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspects a Spine ZIP without loading textures into GPU memory.
|
||||
*
|
||||
* Mirrors the Live2D validator return shape so the model-selector dialog
|
||||
* can present consistent error/warning UX across formats.
|
||||
*
|
||||
* Use when:
|
||||
* - The user picks a `.zip` from the model-selector and we need to decide
|
||||
* whether to import directly or surface a validation modal first.
|
||||
*
|
||||
* Expects:
|
||||
* - `file` is a user-provided ZIP. Non-ZIP inputs return `INVALID`.
|
||||
*
|
||||
* Returns:
|
||||
* - A `SpineValidationReport`. When `status === 'VALID'`, the import path
|
||||
* can run `loadSpineZip()` and pass the assets to `spine.AssetManager`.
|
||||
*/
|
||||
export async function validateSpineZip(file: File): Promise<SpineValidationReport> {
|
||||
const errors: string[] = []
|
||||
const warnings: string[] = []
|
||||
const detected: SpineValidationReport['detected'] = { texturePaths: [] }
|
||||
|
||||
try {
|
||||
const zip = new JSZip()
|
||||
const archive = await zip.loadAsync(file)
|
||||
const files = Object.keys(archive.files).filter(name => !archive.files[name].dir)
|
||||
|
||||
const atlasCandidates = files.filter(name => /\.atlas(?:\.txt)?$/i.test(name))
|
||||
if (atlasCandidates.length === 0) {
|
||||
errors.push('No texture atlas (`.atlas` or `.atlas.txt`) found in the ZIP. A Spine export must include one.')
|
||||
return { status: 'INVALID', errors, warnings, detected }
|
||||
}
|
||||
if (atlasCandidates.length > 1)
|
||||
warnings.push(`Multiple atlas files detected (${atlasCandidates.length}). The import will pick the one paired with a same-named skeleton.`)
|
||||
|
||||
const skelCandidates = files.filter(name => name.toLowerCase().endsWith('.skel'))
|
||||
const jsonCandidates = files.filter(name => /\.json$/i.test(name) && !/(?:package|manifest)\.json$/i.test(name))
|
||||
|
||||
if (skelCandidates.length === 0 && jsonCandidates.length === 0) {
|
||||
errors.push('No skeleton (`.skel` or `.json`) found in the ZIP.')
|
||||
return { status: 'INVALID', errors, warnings, detected }
|
||||
}
|
||||
|
||||
detected.atlasPath = atlasCandidates[0]
|
||||
if (skelCandidates.length > 0) {
|
||||
detected.skeletonPath = skelCandidates[0]
|
||||
detected.skeletonFormat = 'binary'
|
||||
}
|
||||
else {
|
||||
detected.skeletonPath = jsonCandidates[0]
|
||||
detected.skeletonFormat = 'json'
|
||||
}
|
||||
|
||||
const textures = files.filter(name => /\.(?:png|webp|jpg|jpeg)$/i.test(name))
|
||||
if (textures.length === 0) {
|
||||
errors.push('No texture pages (`.png`/`.webp`/`.jpg`) found in the ZIP.')
|
||||
return { status: 'INVALID', errors, warnings, detected }
|
||||
}
|
||||
detected.texturePaths = textures
|
||||
}
|
||||
catch (err) {
|
||||
errors.push(`Failed to read ZIP: ${errorMessageFrom(err) ?? 'Unknown error'}`)
|
||||
return { status: 'INVALID', errors, warnings, detected }
|
||||
}
|
||||
|
||||
return { status: 'VALID', errors, warnings, detected }
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* Spine skeleton version detection and runtime routing.
|
||||
*
|
||||
* Use when:
|
||||
* - A ZIP is imported and we need to determine which spine-webgl runtime
|
||||
* (4.0, 4.1, or 4.2) to use for loading and rendering.
|
||||
*
|
||||
* Expects:
|
||||
* - Raw skeleton data (Uint8Array for binary `.skel`, or string for `.json`).
|
||||
*
|
||||
* Returns:
|
||||
* - A `SpineVersion` ('4.0' | '4.1' | '4.2') or `undefined` if undetectable.
|
||||
*/
|
||||
|
||||
export type SpineVersion = '4.0' | '4.1' | '4.2'
|
||||
|
||||
/**
|
||||
* Detects the Spine editor version from a binary `.skel` file.
|
||||
*
|
||||
* The binary format header is:
|
||||
* - int32 hashLow
|
||||
* - int32 hashHigh
|
||||
* - varint-length-prefixed string: version (e.g. "4.2.18")
|
||||
*/
|
||||
export function detectSpineVersionFromBinary(data: Uint8Array): SpineVersion | undefined {
|
||||
try {
|
||||
// Skip 8 bytes of hash (two int32s)
|
||||
let offset = 8
|
||||
// Read varint-encoded string length
|
||||
const { value: strLen, bytesRead } = readVarint(data, offset)
|
||||
offset += bytesRead
|
||||
if (strLen <= 0 || offset + strLen > data.byteLength)
|
||||
return undefined
|
||||
|
||||
const versionStr = new TextDecoder().decode(data.slice(offset, offset + strLen))
|
||||
return parseSpineVersionString(versionStr)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects the Spine editor version from a JSON skeleton string.
|
||||
* Reads `root.skeleton.spine` which contains the version string.
|
||||
*/
|
||||
export function detectSpineVersionFromJson(json: string): SpineVersion | undefined {
|
||||
try {
|
||||
const root = JSON.parse(json)
|
||||
const versionStr = root?.skeleton?.spine
|
||||
if (typeof versionStr !== 'string')
|
||||
return undefined
|
||||
return parseSpineVersionString(versionStr)
|
||||
}
|
||||
catch {
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parses a version string like "4.2.18" or "4.0.64" into our supported
|
||||
* major.minor version bucket.
|
||||
*/
|
||||
function parseSpineVersionString(version: string): SpineVersion | undefined {
|
||||
const match = version.match(/^(\d+)\.(\d+)/)
|
||||
if (!match)
|
||||
return undefined
|
||||
const key = `${match[1]}.${match[2]}` as SpineVersion
|
||||
if (key === '4.0' || key === '4.1' || key === '4.2')
|
||||
return key
|
||||
return undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads a Spine-format varint (variable-length int, 7 bits per byte,
|
||||
* high bit = continuation).
|
||||
*/
|
||||
function readVarint(data: Uint8Array, offset: number): { value: number, bytesRead: number } {
|
||||
let value = 0
|
||||
let shift = 0
|
||||
let bytesRead = 0
|
||||
while (offset < data.byteLength) {
|
||||
const b = data[offset++]
|
||||
bytesRead++
|
||||
value |= (b & 0x7F) << shift
|
||||
if ((b & 0x80) === 0)
|
||||
break
|
||||
shift += 7
|
||||
}
|
||||
return { value, bytesRead }
|
||||
}
|
||||
@@ -0,0 +1,354 @@
|
||||
import JSZip from 'jszip'
|
||||
|
||||
export interface SpineModelLayout {
|
||||
/**
|
||||
* Path of the skeleton file inside the ZIP (`.skel` for binary, `.json` for JSON).
|
||||
*/
|
||||
skeletonPath: string
|
||||
skeletonFormat: 'binary' | 'json'
|
||||
|
||||
/** Path of the texture atlas (`.atlas` or `.atlas.txt`). */
|
||||
atlasPath: string
|
||||
|
||||
/** All texture page paths referenced by the atlas. */
|
||||
texturePaths: string[]
|
||||
}
|
||||
|
||||
export interface SpineModelVariant {
|
||||
/** Display name derived from the folder/file name. */
|
||||
name: string
|
||||
layout: SpineModelLayout
|
||||
}
|
||||
|
||||
export interface SpineLoadedAssets {
|
||||
layout: SpineModelLayout
|
||||
|
||||
/** All skeleton+atlas pairs found in the ZIP. */
|
||||
variants: SpineModelVariant[]
|
||||
|
||||
/**
|
||||
* Blob URLs for texture pages, keyed by ZIP path.
|
||||
* Used as `image.src` in the Spine texture loader.
|
||||
*/
|
||||
blobUrls: Record<string, string>
|
||||
|
||||
/**
|
||||
* Raw decoded data keyed by ZIP path.
|
||||
* Skeleton binary → Uint8Array, skeleton JSON / atlas → string.
|
||||
* Fed directly to the Downloader to avoid base64 round-trip corruption.
|
||||
*/
|
||||
rawData: Record<string, Uint8Array | string>
|
||||
|
||||
/** Disposes every blob URL allocated for this load. */
|
||||
dispose: () => void
|
||||
}
|
||||
|
||||
const SKELETON_BINARY_EXT = '.skel'
|
||||
const SKELETON_JSON_EXT = '.json'
|
||||
const ATLAS_EXT_PRIMARY = '.atlas'
|
||||
const ATLAS_EXT_TXT = '.atlas.txt'
|
||||
const TEXTURE_EXTS = ['.png', '.webp', '.jpg', '.jpeg']
|
||||
|
||||
function isTexturePath(name: string) {
|
||||
const lower = name.toLowerCase()
|
||||
return TEXTURE_EXTS.some(ext => lower.endsWith(ext))
|
||||
}
|
||||
|
||||
function isAtlasPath(name: string) {
|
||||
const lower = name.toLowerCase()
|
||||
return lower.endsWith(ATLAS_EXT_PRIMARY) || lower.endsWith(ATLAS_EXT_TXT)
|
||||
}
|
||||
|
||||
function isSkeletonBinaryPath(name: string) {
|
||||
return name.toLowerCase().endsWith(SKELETON_BINARY_EXT)
|
||||
}
|
||||
|
||||
function isSkeletonJsonPath(name: string) {
|
||||
// Filter out package manifests / settings — only treat as a skeleton if it
|
||||
// sits next to an atlas with the same base name. The caller validates.
|
||||
const lower = name.toLowerCase()
|
||||
if (!lower.endsWith(SKELETON_JSON_EXT))
|
||||
return false
|
||||
// Exclude obvious non-skeleton JSON.
|
||||
if (lower.endsWith('package.json') || lower.endsWith('manifest.json'))
|
||||
return false
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function basename(path: string) {
|
||||
const slash = path.lastIndexOf('/')
|
||||
return slash === -1 ? path : path.slice(slash + 1)
|
||||
}
|
||||
|
||||
function stripExt(name: string) {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot === -1 ? name : name.slice(0, dot)
|
||||
}
|
||||
|
||||
function dirname(path: string) {
|
||||
const slash = path.lastIndexOf('/')
|
||||
return slash === -1 ? '' : path.slice(0, slash + 1)
|
||||
}
|
||||
|
||||
/**
|
||||
* Inspect a Spine ZIP and resolve the skeleton, atlas, and texture page
|
||||
* paths.
|
||||
*
|
||||
* Heuristics:
|
||||
* 1. Find all `.atlas`/`.atlas.txt` files paired with same-basename skeletons.
|
||||
* 2. For each pair, walk the atlas to extract texture page filenames.
|
||||
* 3. Return the first matched pair as the primary layout.
|
||||
*/
|
||||
export function detectSpineLayout(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelLayout {
|
||||
const variants = detectAllSpineLayouts(entries, atlasText)
|
||||
if (variants.length === 0)
|
||||
throw new Error('Spine ZIP must contain a .skel or .json skeleton file paired with a .atlas')
|
||||
return variants[0].layout
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects all skeleton+atlas pairs in a ZIP, returning them as named
|
||||
* variants. Useful for ZIPs containing multiple outfits/characters in
|
||||
* separate folders.
|
||||
*/
|
||||
export function detectAllSpineLayouts(entries: Record<string, string>, atlasText: Record<string, string>): SpineModelVariant[] {
|
||||
const allFiles = Object.keys(entries)
|
||||
const atlasCandidates = allFiles.filter(isAtlasPath)
|
||||
if (atlasCandidates.length === 0)
|
||||
throw new Error('Spine ZIP must contain a .atlas (or .atlas.txt) file')
|
||||
|
||||
const variants: SpineModelVariant[] = []
|
||||
const usedAtlases = new Set<string>()
|
||||
|
||||
// First pass: match each atlas with a same-basename skeleton.
|
||||
for (const candidate of atlasCandidates) {
|
||||
const baseName = stripExt(stripExt(basename(candidate)))
|
||||
const dir = dirname(candidate)
|
||||
|
||||
const binaryPath = `${dir}${baseName}${SKELETON_BINARY_EXT}`
|
||||
const jsonPath = `${dir}${baseName}${SKELETON_JSON_EXT}`
|
||||
|
||||
let skeletonPath: string | undefined
|
||||
let skeletonFormat: SpineModelLayout['skeletonFormat'] = 'binary'
|
||||
|
||||
if (entries[binaryPath] !== undefined) {
|
||||
skeletonPath = binaryPath
|
||||
skeletonFormat = 'binary'
|
||||
}
|
||||
else if (entries[jsonPath] !== undefined) {
|
||||
skeletonPath = jsonPath
|
||||
skeletonFormat = 'json'
|
||||
}
|
||||
|
||||
if (!skeletonPath)
|
||||
continue
|
||||
|
||||
usedAtlases.add(candidate)
|
||||
const texturePaths = resolveAtlasTextures(candidate, entries, atlasText)
|
||||
const name = dir ? dir.replace(/\/$/, '').split('/').pop()! : baseName
|
||||
variants.push({ name, layout: { skeletonPath, skeletonFormat, atlasPath: candidate, texturePaths } })
|
||||
}
|
||||
|
||||
// Fallback: unmatched atlases paired with any skeleton in the same directory.
|
||||
for (const candidate of atlasCandidates) {
|
||||
if (usedAtlases.has(candidate))
|
||||
continue
|
||||
|
||||
const dir = dirname(candidate)
|
||||
const skel = allFiles.find(f => f.startsWith(dir) && isSkeletonBinaryPath(f))
|
||||
?? allFiles.find(f => f.startsWith(dir) && isSkeletonJsonPath(f))
|
||||
if (!skel)
|
||||
continue
|
||||
|
||||
const skeletonFormat: SpineModelLayout['skeletonFormat'] = isSkeletonBinaryPath(skel) ? 'binary' : 'json'
|
||||
const texturePaths = resolveAtlasTextures(candidate, entries, atlasText)
|
||||
const baseName = stripExt(stripExt(basename(candidate)))
|
||||
const name = dir ? dir.replace(/\/$/, '').split('/').pop()! : baseName
|
||||
variants.push({ name, layout: { skeletonPath: skel, skeletonFormat, atlasPath: candidate, texturePaths } })
|
||||
}
|
||||
|
||||
return variants
|
||||
}
|
||||
|
||||
function resolveAtlasTextures(atlasPath: string, entries: Record<string, string>, atlasText: Record<string, string>): string[] {
|
||||
const allFiles = Object.keys(entries)
|
||||
|
||||
// Atlas page lines start at column 0 with the texture file name.
|
||||
const text = atlasText[atlasPath] ?? ''
|
||||
const lines = text.split(/\r?\n/)
|
||||
const texturePaths: string[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (!line)
|
||||
continue
|
||||
|
||||
// First non-empty, non-property line is a texture page name. After that
|
||||
// continued page-property lines start with whitespace; a blank line ends
|
||||
// the page block, and a new page starts with a non-empty, non-indented
|
||||
// line that does not contain ':' (atlas property) or ',' (region prop).
|
||||
if (line.trim().length === 0)
|
||||
continue
|
||||
if (line[0] === ' ' || line[0] === '\t')
|
||||
continue
|
||||
if (line.includes(':'))
|
||||
continue
|
||||
|
||||
// Heuristic: candidate page name; verify against entries (handles relative path).
|
||||
const dir = dirname(atlasPath)
|
||||
const candidate = `${dir}${line.trim()}`
|
||||
if (entries[candidate] !== undefined && isTexturePath(candidate))
|
||||
texturePaths.push(candidate)
|
||||
|
||||
// After the first valid page, skip property lines until next blank line.
|
||||
while (i + 1 < lines.length && lines[i + 1].length > 0)
|
||||
i++
|
||||
}
|
||||
|
||||
// Fallback: if atlas parsing missed pages, accept every PNG sibling.
|
||||
if (texturePaths.length === 0) {
|
||||
const dir = dirname(atlasPath)
|
||||
for (const file of allFiles) {
|
||||
if (file.startsWith(dir) && isTexturePath(file))
|
||||
texturePaths.push(file)
|
||||
}
|
||||
}
|
||||
|
||||
return texturePaths
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads a Spine model packaged as a ZIP into a set of blob URLs ready for
|
||||
* `spine.AssetManager`.
|
||||
*
|
||||
* The returned `dispose()` revokes every blob URL — call it on unmount or
|
||||
* when reloading.
|
||||
*/
|
||||
export async function loadSpineZip(file: File | Blob | ArrayBuffer): Promise<SpineLoadedAssets> {
|
||||
const zip = new JSZip()
|
||||
const archive = await zip.loadAsync(file)
|
||||
|
||||
const entries: Record<string, string> = {}
|
||||
const atlasTexts: Record<string, string> = {}
|
||||
const blobUrls: Record<string, string> = {}
|
||||
|
||||
// Pass 1: inventory file paths and read atlas text bodies.
|
||||
await Promise.all(Object.keys(archive.files).map(async (name) => {
|
||||
const entry = archive.files[name]
|
||||
if (entry.dir)
|
||||
return
|
||||
|
||||
entries[name] = name
|
||||
|
||||
if (isAtlasPath(name))
|
||||
atlasTexts[name] = await entry.async('string')
|
||||
}))
|
||||
|
||||
const variants = detectAllSpineLayouts(entries, atlasTexts)
|
||||
if (variants.length === 0)
|
||||
throw new Error('Spine ZIP must contain at least one skeleton+atlas pair')
|
||||
const layout = variants[0].layout
|
||||
|
||||
// Pass 2: materialize assets for ALL variants.
|
||||
// NOTICE:
|
||||
// Spine's Downloader has a heuristic for rawDataUris: if the value doesn't
|
||||
// contain ".", it treats it as a data: URI and calls atob(). In Electron,
|
||||
// blob URLs are `blob:null/<uuid>` (no dots), so the Downloader fails.
|
||||
// Even with data: URIs, Spine's atob-based decode can corrupt binary data.
|
||||
// We store raw decoded data (Uint8Array / string) alongside blob URLs and
|
||||
// monkey-patch the Downloader's download methods to serve from memory.
|
||||
// Removal condition: Spine ships a Blob-aware or buffer-aware loader.
|
||||
const rawData: Record<string, Uint8Array | string> = {}
|
||||
|
||||
// Collect all unique paths across all variants.
|
||||
const allTexturePaths = new Set<string>()
|
||||
const allSkeletonPaths = new Set<string>()
|
||||
const allAtlasPaths = new Set<string>()
|
||||
for (const v of variants) {
|
||||
allSkeletonPaths.add(v.layout.skeletonPath)
|
||||
allAtlasPaths.add(v.layout.atlasPath)
|
||||
for (const t of v.layout.texturePaths)
|
||||
allTexturePaths.add(t)
|
||||
}
|
||||
|
||||
// Textures → blob URLs (used by image.src in loadTexture).
|
||||
await Promise.all(Array.from(allTexturePaths).map(async (path) => {
|
||||
const entry = archive.files[path]
|
||||
if (!entry)
|
||||
return
|
||||
const buffer = await entry.async('blob')
|
||||
blobUrls[path] = URL.createObjectURL(buffer)
|
||||
}))
|
||||
|
||||
// Skeletons → raw decoded data
|
||||
// NOTICE:
|
||||
// Spine's BinaryInput does `new DataView(data.buffer)` without respecting
|
||||
// byteOffset. JSZip may return a Uint8Array that is a view into a larger
|
||||
// ArrayBuffer. We copy via `.slice(0)` which produces a zero-offset buffer.
|
||||
// Source: spine-core/SkeletonBinary.js BinaryInput constructor.
|
||||
// Removal condition: Spine fixes BinaryInput to use byteOffset/byteLength.
|
||||
await Promise.all(Array.from(allSkeletonPaths).map(async (path) => {
|
||||
const entry = archive.files[path]
|
||||
if (!entry)
|
||||
return
|
||||
const variant = variants.find(v => v.layout.skeletonPath === path)!
|
||||
if (variant.layout.skeletonFormat === 'binary') {
|
||||
const ab = await entry.async('arraybuffer')
|
||||
rawData[path] = new Uint8Array(ab.slice(0))
|
||||
}
|
||||
else {
|
||||
rawData[path] = await entry.async('string')
|
||||
}
|
||||
}))
|
||||
|
||||
// Atlases → raw text with page references rewritten to bare filenames
|
||||
for (const atlasPath of allAtlasPaths) {
|
||||
const variantForAtlas = variants.find(v => v.layout.atlasPath === atlasPath)!
|
||||
const finalAtlasText = rewriteAtlasPageReferences(atlasTexts[atlasPath] ?? '', variantForAtlas.layout, blobUrls)
|
||||
rawData[atlasPath] = finalAtlasText
|
||||
}
|
||||
|
||||
return {
|
||||
layout,
|
||||
variants,
|
||||
blobUrls,
|
||||
rawData,
|
||||
dispose: () => {
|
||||
for (const url of Object.values(blobUrls)) {
|
||||
if (url.startsWith('blob:')) {
|
||||
try {
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
catch {}
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function rewriteAtlasPageReferences(atlasText: string, layout: SpineModelLayout, blobUrls: Record<string, string>) {
|
||||
const dir = dirname(layout.atlasPath)
|
||||
const lines = atlasText.split(/\r?\n/)
|
||||
const out: string[] = []
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i]
|
||||
if (line.trim().length === 0 || line[0] === ' ' || line[0] === '\t' || line.includes(':')) {
|
||||
out.push(line)
|
||||
continue
|
||||
}
|
||||
|
||||
const candidate = `${dir}${line.trim()}`
|
||||
if (blobUrls[candidate] && layout.texturePaths.includes(candidate)) {
|
||||
// Spine's atlas reader resolves page paths through the AssetManager's
|
||||
// path prefix. We cannot inject `blob:` here directly because the
|
||||
// reader joins the prefix with the page name. Instead, leave the
|
||||
// bare filename and let the AssetManager resolver handle the lookup.
|
||||
out.push(basename(candidate))
|
||||
}
|
||||
else {
|
||||
out.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
return out.join('\n')
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ESNext",
|
||||
"jsx": "preserve",
|
||||
"lib": [
|
||||
"DOM",
|
||||
"DOM.AsyncIterable",
|
||||
"ESNext"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"types": [
|
||||
"vite/client"
|
||||
],
|
||||
"allowJs": true,
|
||||
"strict": true,
|
||||
"strictNullChecks": true,
|
||||
"noUnusedLocals": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"isolatedModules": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"skipLibCheck": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*.ts",
|
||||
"src/**/*.d.ts",
|
||||
"src/**/*.vue"
|
||||
],
|
||||
"exclude": [
|
||||
"dist",
|
||||
"node_modules"
|
||||
]
|
||||
}
|
||||
@@ -87,6 +87,7 @@
|
||||
"@proj-airi/server-sdk-shared": "workspace:^",
|
||||
"@proj-airi/stage-shared": "workspace:^",
|
||||
"@proj-airi/stage-ui-live2d": "workspace:^",
|
||||
"@proj-airi/stage-ui-spine": "workspace:^",
|
||||
"@proj-airi/stage-ui-three": "workspace:^",
|
||||
"@proj-airi/ui": "workspace:^",
|
||||
"@ricky0123/vad-web": "^0.0.30",
|
||||
|
||||
@@ -101,10 +101,26 @@ async function handleAddVRMModel(file: FileList | null) {
|
||||
highlightDisplayModelCard.value = displayModel.id
|
||||
}
|
||||
|
||||
async function handleAddSpineModel(file: FileList | null) {
|
||||
if (file === null || file.length === 0)
|
||||
return
|
||||
if (!file[0].name.endsWith('.zip'))
|
||||
return
|
||||
|
||||
// NOTICE:
|
||||
// Keep this await for the same import-then-pick race as Live2D/VRM imports above.
|
||||
// The returned model id is only safe to highlight after addDisplayModel has updated the store.
|
||||
// Source/context: model selector import flow -> settings model pick -> settings-stage-model.getDisplayModel().
|
||||
// Removal condition: addDisplayModel becomes a synchronous transaction or pick is blocked by explicit import state.
|
||||
const displayModel = await displayModelStore.addDisplayModel(DisplayModelFormat.SpineZip, file[0])
|
||||
highlightDisplayModelCard.value = displayModel.id
|
||||
}
|
||||
|
||||
const mapFormatRenderer: Record<DisplayModelFormat, string> = {
|
||||
[DisplayModelFormat.Live2dZip]: 'Live2D',
|
||||
[DisplayModelFormat.Live2dDirectory]: 'Live2D',
|
||||
[DisplayModelFormat.VRM]: 'VRM',
|
||||
[DisplayModelFormat.SpineZip]: 'Spine',
|
||||
[DisplayModelFormat.PMXDirectory]: 'MMD',
|
||||
[DisplayModelFormat.PMXZip]: 'MMD',
|
||||
[DisplayModelFormat.PMD]: 'MMD',
|
||||
@@ -112,9 +128,11 @@ const mapFormatRenderer: Record<DisplayModelFormat, string> = {
|
||||
|
||||
const live2dDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
|
||||
const vrmDialog = useFileDialog({ accept: '.vrm', multiple: false, reset: true })
|
||||
const spineDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
|
||||
|
||||
live2dDialog.onChange(handleAddLive2DModel)
|
||||
vrmDialog.onChange(handleAddVRMModel)
|
||||
spineDialog.onChange(handleAddSpineModel)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -174,6 +192,17 @@ vrmDialog.onChange(handleAddVRMModel)
|
||||
>
|
||||
VRM
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
:class="[
|
||||
'data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 leading-none outline-none data-[disabled]:pointer-events-none',
|
||||
'text-base sm:text-sm',
|
||||
'data-[highlighted]:bg-primary-300/20 dark:data-[highlighted]:bg-primary-100/20',
|
||||
'data-[highlighted]:text-primary-400 dark:data-[highlighted]:text-primary-200',
|
||||
]"
|
||||
transition="colors duration-200 ease-in-out" @click="spineDialog.open()"
|
||||
>
|
||||
Spine
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenuRoot>
|
||||
|
||||
@@ -14,6 +14,7 @@ withDefaults(defineProps<{
|
||||
allowExtractColors?: boolean
|
||||
live2dSceneClass?: string | string[]
|
||||
vrmSceneClass?: string | string[]
|
||||
spineSceneClass?: string | string[]
|
||||
}>(), {
|
||||
allowExtractColors: true,
|
||||
})
|
||||
@@ -50,6 +51,7 @@ defineExpose({
|
||||
ref="previewStageRef"
|
||||
:live2d-scene-class="live2dSceneClass"
|
||||
:vrm-scene-class="vrmSceneClass"
|
||||
:spine-scene-class="spineSceneClass"
|
||||
@runtime-snapshot-changed="handleRuntimeSnapshotChanged"
|
||||
/>
|
||||
</template>
|
||||
|
||||
@@ -8,6 +8,7 @@ import { computed, ref } from 'vue'
|
||||
|
||||
import Godot from './godot.vue'
|
||||
import Live2D from './live2d.vue'
|
||||
import Spine from './spine.vue'
|
||||
import VRM from './vrm.vue'
|
||||
|
||||
import { useAiriCardStore } from '../../../../stores/modules/airi-card'
|
||||
@@ -59,12 +60,12 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
<Callout label="We support both 2D and 3D models">
|
||||
<p>
|
||||
Click <strong>Select Model</strong> to import different formats of
|
||||
models into catalog, currently, <code>.zip</code> (Live2D) and <code>.vrm</code> (VRM) are supported.
|
||||
models into catalog, currently, <code>.zip</code> (Live2D, Spine) and <code>.vrm</code> (VRM) are supported.
|
||||
</p>
|
||||
<p>
|
||||
Neuro-sama uses 2D model driven by Live2D Inc. developed framework.
|
||||
While Grok Ani (first female character announced in Grok Companion)
|
||||
uses 3D model that is driven by VRM / MMD open formats.
|
||||
Grok Ani uses 3D model that is driven by VRM / MMD open formats.
|
||||
Spine 2D models are supported via Esoteric Software's Spine runtime.
|
||||
</p>
|
||||
</Callout>
|
||||
<div :class="['flex flex-wrap items-center gap-2']">
|
||||
@@ -89,6 +90,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="$emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Spine
|
||||
v-if="effectiveRenderer === 'spine'"
|
||||
:allow-extract-colors="allowExtractColors"
|
||||
:palette="palette"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
@extract-colors-from-model="$emit('extractColorsFromModel')"
|
||||
/>
|
||||
<Godot
|
||||
v-if="effectiveRenderer === 'godot'"
|
||||
:runtime-snapshot="runtimeSnapshot"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { Live2DScene } from '@proj-airi/stage-ui-live2d'
|
||||
import { SpineScene } from '@proj-airi/stage-ui-spine'
|
||||
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
|
||||
import { useMouse } from '@vueuse/core'
|
||||
import { storeToRefs } from 'pinia'
|
||||
@@ -16,6 +17,7 @@ import {
|
||||
const props = defineProps<{
|
||||
live2dSceneClass?: string | string[]
|
||||
vrmSceneClass?: string | string[]
|
||||
spineSceneClass?: string | string[]
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
@@ -27,7 +29,9 @@ const settingsStore = useSettings()
|
||||
const modelStore = useModelStore()
|
||||
const live2dSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const spineSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
|
||||
const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const spineComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
|
||||
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
|
||||
|
||||
provide('previewStage', true)
|
||||
@@ -45,11 +49,17 @@ const {
|
||||
live2dShadowEnabled,
|
||||
live2dMaxFps,
|
||||
live2dRenderScale,
|
||||
spinePremultipliedAlpha,
|
||||
spineDefaultMixDuration,
|
||||
spineIdleAnimationEnabled,
|
||||
spineMaxFps,
|
||||
spineRenderScale,
|
||||
} = storeToRefs(settingsStore)
|
||||
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
|
||||
|
||||
const live2dSceneClassList = computed(() => normalizeClassList(props.live2dSceneClass))
|
||||
const vrmSceneClassList = computed(() => normalizeClassList(props.vrmSceneClass))
|
||||
const spineSceneClassList = computed(() => normalizeClassList(props.spineSceneClass))
|
||||
|
||||
function normalizeClassList(value?: string | string[]) {
|
||||
if (!value)
|
||||
@@ -74,6 +84,9 @@ async function capturePreviewFrame() {
|
||||
if (stageModelRenderer.value === 'vrm')
|
||||
return captureCanvasFrame(vrmSceneRef.value?.canvasElement())
|
||||
|
||||
if (stageModelRenderer.value === 'spine')
|
||||
return captureCanvasFrame(spineSceneRef.value?.canvasElement())
|
||||
|
||||
return undefined
|
||||
}
|
||||
|
||||
@@ -106,6 +119,20 @@ const runtimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'spine') {
|
||||
const phase = resolveComponentStateToRuntimePhase(spineComponentState.value, { hasModel })
|
||||
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: vrmPreviewStageInstanceId,
|
||||
renderer: 'spine',
|
||||
phase,
|
||||
controlsLocked: hasModel ? phase !== 'mounted' : false,
|
||||
previewAvailable: hasModel,
|
||||
canCapturePreview: !!spineSceneRef.value?.canvasElement(),
|
||||
updatedAt: Date.now(),
|
||||
})
|
||||
}
|
||||
|
||||
if (stageModelRenderer.value === 'godot') {
|
||||
return createEmptyModelSettingsRuntimeSnapshot({
|
||||
ownerInstanceId: vrmPreviewStageInstanceId,
|
||||
@@ -157,4 +184,19 @@ defineExpose({
|
||||
<ThreeScene ref="vrmSceneRef" :model-src="stageModelSelectedUrl" />
|
||||
</div>
|
||||
</template>
|
||||
<template v-if="stageModelRenderer === 'spine'">
|
||||
<div :class="spineSceneClassList">
|
||||
<SpineScene
|
||||
ref="spineSceneRef"
|
||||
v-model:state="spineComponentState"
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:premultiplied-alpha="spinePremultipliedAlpha"
|
||||
:default-mix-duration="spineDefaultMixDuration"
|
||||
:idle-animation-enabled="spineIdleAnimationEnabled"
|
||||
:max-fps="spineMaxFps"
|
||||
:render-scale="spineRenderScale"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'godot'
|
||||
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm' | 'spine' | 'godot'
|
||||
export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error'
|
||||
|
||||
export interface ModelSettingsRuntimeSnapshot {
|
||||
|
||||
@@ -0,0 +1,263 @@
|
||||
<script setup lang="ts">
|
||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||
|
||||
import { useSpine } from '@proj-airi/stage-ui-spine'
|
||||
import { Button, FieldCombobox, FieldRange, SelectTab } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { useSettings } from '../../../../stores/settings'
|
||||
import { Section } from '../../../layouts'
|
||||
import { ColorPalette } from '../../../widgets'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
palette: string[]
|
||||
allowExtractColors?: boolean
|
||||
runtimeSnapshot: ModelSettingsRuntimeSnapshot
|
||||
}>(), {
|
||||
allowExtractColors: true,
|
||||
})
|
||||
|
||||
defineEmits<{
|
||||
(e: 'extractColorsFromModel'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const settings = useSettings()
|
||||
const {
|
||||
spineDefaultMixDuration,
|
||||
spineMaxFps,
|
||||
spineRenderScale,
|
||||
} = storeToRefs(settings)
|
||||
|
||||
const spineStore = useSpine()
|
||||
const {
|
||||
scale,
|
||||
position,
|
||||
currentAnimation,
|
||||
availableAnimations,
|
||||
currentSkin,
|
||||
availableSkins,
|
||||
availableVariants,
|
||||
currentVariant,
|
||||
animationSpeed,
|
||||
} = storeToRefs(spineStore)
|
||||
|
||||
const canExtractColors = computed(() => props.runtimeSnapshot.canCapturePreview)
|
||||
const hasMultipleVariants = computed(() => availableVariants.value.length > 1)
|
||||
|
||||
const variantOptions = computed(() => availableVariants.value.map(v => ({
|
||||
label: v.name,
|
||||
value: v.name,
|
||||
description: '',
|
||||
})))
|
||||
|
||||
const animationOptions = computed(() => availableAnimations.value.map(animation => ({
|
||||
label: animation.name,
|
||||
value: animation.name,
|
||||
description: `${animation.duration.toFixed(2)}s`,
|
||||
})))
|
||||
|
||||
const skinOptions = computed(() => availableSkins.value.map(skin => ({
|
||||
label: skin.name,
|
||||
value: skin.name,
|
||||
description: '',
|
||||
})))
|
||||
|
||||
const fpsOptions = computed(() => [
|
||||
{ value: 0, label: t('settings.spine.fps.options.unlimited') },
|
||||
{ value: 60, label: '60' },
|
||||
{ value: 30, label: '30' },
|
||||
])
|
||||
|
||||
function handleVariantSelect(variantName: string | number | undefined) {
|
||||
if (typeof variantName !== 'string')
|
||||
return
|
||||
currentVariant.value = variantName
|
||||
}
|
||||
|
||||
function handleAnimationSelect(animationName: string | number | undefined) {
|
||||
if (typeof animationName !== 'string')
|
||||
return
|
||||
currentAnimation.value = { ...currentAnimation.value, name: animationName }
|
||||
}
|
||||
|
||||
function handleSkinSelect(skinName: string | number | undefined) {
|
||||
if (typeof skinName !== 'string')
|
||||
return
|
||||
currentSkin.value = skinName
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Section
|
||||
:title="t('settings.spine.scale-and-position.title')"
|
||||
icon="i-solar:scale-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="true"
|
||||
>
|
||||
<FieldRange v-model="scale" as="div" :min="0.1" :max="3" :step="0.01" :label="t('settings.spine.scale-and-position.scale')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.spine.scale-and-position.scale') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange v-model="position.x" as="div" :min="-3000" :max="3000" :step="1" :label="t('settings.spine.scale-and-position.x')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.spine.scale-and-position.x') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.x = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange v-model="position.y" as="div" :min="-3000" :max="3000" :step="1" :label="t('settings.spine.scale-and-position.y')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.spine.scale-and-position.y') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.y = 0">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
v-if="allowExtractColors"
|
||||
:title="t('settings.spine.theme-color-from-model.title')"
|
||||
icon="i-solar:magic-stick-3-bold-duotone"
|
||||
inner-class="text-sm"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="false"
|
||||
>
|
||||
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
|
||||
<Button variant="secondary" :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
|
||||
{{ t('settings.spine.theme-color-from-model.button-extract.title') }}
|
||||
</Button>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
:title="t('settings.spine.animation.title')"
|
||||
icon="i-solar:play-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="true"
|
||||
>
|
||||
<FieldCombobox
|
||||
:model-value="currentAnimation.name"
|
||||
:options="animationOptions"
|
||||
:label="t('settings.spine.animation.idle-animation')"
|
||||
@update:model-value="handleAnimationSelect"
|
||||
/>
|
||||
<FieldRange v-model="spineDefaultMixDuration" as="div" :min="0" :max="2" :step="0.05" :label="t('settings.spine.animation.mix-duration')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.spine.animation.mix-duration') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => spineDefaultMixDuration = 0.2">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
<FieldRange v-model="animationSpeed" as="div" :min="0.1" :max="3" :step="0.05" :label="t('settings.spine.animation.speed')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.spine.animation.speed') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => animationSpeed = 1">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
v-if="hasMultipleVariants"
|
||||
:title="t('settings.spine.variant.title')"
|
||||
icon="i-solar:layers-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="true"
|
||||
>
|
||||
<FieldCombobox
|
||||
:model-value="currentVariant"
|
||||
:options="variantOptions"
|
||||
:label="t('settings.spine.variant.current-variant')"
|
||||
@update:model-value="handleVariantSelect"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
:title="t('settings.spine.skin.title')"
|
||||
icon="i-solar:brush-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="true"
|
||||
>
|
||||
<FieldCombobox
|
||||
:model-value="currentSkin"
|
||||
:options="skinOptions"
|
||||
:label="t('settings.spine.skin.current-skin')"
|
||||
@update:model-value="handleSkinSelect"
|
||||
/>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
:title="t('settings.spine.rendering.title')"
|
||||
icon="i-solar:settings-bold-duotone"
|
||||
:class="[
|
||||
'rounded-xl',
|
||||
'bg-white/80 dark:bg-black/75',
|
||||
'backdrop-blur-lg',
|
||||
]"
|
||||
size="sm"
|
||||
:expand="true"
|
||||
>
|
||||
<div :class="['flex', 'items-center', 'justify-between', 'gap-2']">
|
||||
<div :class="['text-sm', 'font-medium']">
|
||||
{{ t('settings.spine.rendering.max-fps') }}
|
||||
</div>
|
||||
<SelectTab v-model="spineMaxFps" :options="fpsOptions" size="sm" :class="['shrink-0']" />
|
||||
</div>
|
||||
<FieldRange v-model="spineRenderScale" as="div" :min="0.5" :max="3" :step="0.1" :label="t('settings.spine.rendering.render-scale')">
|
||||
<template #label>
|
||||
<div flex items-center>
|
||||
<div>{{ t('settings.spine.rendering.render-scale') }}</div>
|
||||
<button px-2 text-xs outline-none title="Reset value to default" @click="() => spineRenderScale = 1">
|
||||
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</FieldRange>
|
||||
</Section>
|
||||
</template>
|
||||
@@ -12,6 +12,7 @@ import { createLive2DLipSync } from '@proj-airi/model-driver-lipsync'
|
||||
import { wlipsyncProfile } from '@proj-airi/model-driver-lipsync/shared/wlipsync'
|
||||
import { createPlaybackManager, createSpeechPipeline, normalizeActPayload } from '@proj-airi/pipelines-audio'
|
||||
import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d'
|
||||
import { SpineScene } from '@proj-airi/stage-ui-spine'
|
||||
import { ThreeScene } from '@proj-airi/stage-ui-three'
|
||||
import { animations } from '@proj-airi/stage-ui-three/assets/vrm'
|
||||
import { createQueue } from '@proj-airi/stream-kit'
|
||||
@@ -54,6 +55,7 @@ const { getDb } = useDuckDb()
|
||||
|
||||
const vrmViewerRef = ref<InstanceType<typeof ThreeScene>>()
|
||||
const live2dSceneRef = ref<InstanceType<typeof Live2DScene>>()
|
||||
const spineSceneRef = ref<InstanceType<typeof SpineScene>>()
|
||||
|
||||
const settingsStore = useSettings()
|
||||
const {
|
||||
@@ -71,6 +73,11 @@ const {
|
||||
live2dShadowEnabled,
|
||||
live2dMaxFps,
|
||||
live2dRenderScale,
|
||||
spinePremultipliedAlpha,
|
||||
spineDefaultMixDuration,
|
||||
spineIdleAnimationEnabled,
|
||||
spineMaxFps,
|
||||
spineRenderScale,
|
||||
} = storeToRefs(settingsStore)
|
||||
const { mouthOpenSize, nowSpeaking } = storeToRefs(useSpeakingStore())
|
||||
const { audioContext } = useAudioContext()
|
||||
@@ -138,6 +145,9 @@ const emotionsQueue = createQueue<EmotionPayload>({
|
||||
else if (stageModelRenderer.value === 'live2d') {
|
||||
currentMotion.value = { group: EMOTION_EmotionMotionName_value[ctx.data.name] }
|
||||
}
|
||||
else if (stageModelRenderer.value === 'spine') {
|
||||
spineSceneRef.value?.setEmotion(ctx.data.name, ctx.data.intensity)
|
||||
}
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -746,6 +756,9 @@ function canvasElement() {
|
||||
|
||||
else if (stageModelRenderer.value === 'vrm')
|
||||
return vrmViewerRef.value?.canvasElement()
|
||||
|
||||
else if (stageModelRenderer.value === 'spine')
|
||||
return spineSceneRef.value?.canvasElement()
|
||||
}
|
||||
|
||||
function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, radius: number) {
|
||||
@@ -758,7 +771,9 @@ function readRenderTargetRegionAtClientPoint(clientX: number, clientY: number, r
|
||||
async function captureFrame() {
|
||||
const charBlob = await (stageModelRenderer.value === 'live2d'
|
||||
? live2dSceneRef.value?.captureFrame()
|
||||
: vrmViewerRef.value?.captureFrame())
|
||||
: stageModelRenderer.value === 'vrm'
|
||||
? vrmViewerRef.value?.captureFrame()
|
||||
: spineSceneRef.value?.captureFrame())
|
||||
|
||||
if (!activeBackgroundUrl.value || !charBlob)
|
||||
return charBlob
|
||||
@@ -879,6 +894,21 @@ defineExpose({
|
||||
:current-audio-source="currentAudioSource"
|
||||
@error="console.error"
|
||||
/>
|
||||
<SpineScene
|
||||
v-if="stageModelRenderer === 'spine' && showStage"
|
||||
ref="spineSceneRef"
|
||||
v-model:state="componentState"
|
||||
min-w="50% <lg:full" min-h="100 sm:100"
|
||||
h-full w-full flex-1
|
||||
:model-src="stageModelSelectedUrl"
|
||||
:model-id="stageModelSelected"
|
||||
:paused="paused"
|
||||
:premultiplied-alpha="spinePremultipliedAlpha"
|
||||
:default-mix-duration="spineDefaultMixDuration"
|
||||
:idle-animation-enabled="spineIdleAnimationEnabled"
|
||||
:max-fps="spineMaxFps"
|
||||
:render-scale="spineRenderScale"
|
||||
/>
|
||||
<div
|
||||
v-if="stageModelRenderer === 'godot'"
|
||||
:class="[
|
||||
|
||||
@@ -46,6 +46,18 @@ export const EMOTION_VRMExpressionName_value = {
|
||||
[Emotion.Curious]: 'think',
|
||||
} satisfies Record<Emotion, string | undefined>
|
||||
|
||||
export const EMOTION_SpineAnimationName_value = {
|
||||
[Emotion.Happy]: 'celebrate',
|
||||
[Emotion.Sad]: 'sad',
|
||||
[Emotion.Angry]: 'angry',
|
||||
[Emotion.Think]: 'think',
|
||||
[Emotion.Surprise]: 'surprise',
|
||||
[Emotion.Awkward]: 'awkward',
|
||||
[Emotion.Question]: 'question',
|
||||
[Emotion.Neutral]: 'idle',
|
||||
[Emotion.Curious]: 'curious',
|
||||
} satisfies Record<Emotion, string>
|
||||
|
||||
export interface EmotionPayload {
|
||||
name: Emotion
|
||||
intensity: number
|
||||
|
||||
@@ -9,6 +9,7 @@ export enum DisplayModelFormat {
|
||||
Live2dZip = 'live2d-zip',
|
||||
Live2dDirectory = 'live2d-directory',
|
||||
VRM = 'vrm',
|
||||
SpineZip = 'spine-zip',
|
||||
PMXZip = 'pmx-zip',
|
||||
PMXDirectory = 'pmx-directory',
|
||||
PMD = 'pmd',
|
||||
@@ -58,6 +59,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
|
||||
let generateLive2DPreview: (file: File) => Promise<string | undefined>
|
||||
let generateVrmPreview: (file: File) => Promise<string | undefined>
|
||||
let generateSpinePreview: (file: File) => Promise<string | undefined>
|
||||
|
||||
const displayModelsFromIndexedDBLoading = ref(false)
|
||||
|
||||
@@ -105,6 +107,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
|
||||
const loadLive2DModelPreview = (file: File) => generateLive2DPreview(file)
|
||||
const loadVrmModelPreview = (file: File) => generateVrmPreview(file)
|
||||
const loadSpineModelPreview = (file: File) => generateSpinePreview(file)
|
||||
|
||||
async function addDisplayModel(format: DisplayModelFormat, file: File) {
|
||||
await until(displayModelsFromIndexedDBLoading).toBe(false)
|
||||
@@ -118,6 +121,10 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
const previewImage = await loadVrmModelPreview(file)
|
||||
newDisplayModel.previewImage = previewImage
|
||||
}
|
||||
else if (format === DisplayModelFormat.SpineZip) {
|
||||
const previewImage = await loadSpineModelPreview(file)
|
||||
newDisplayModel.previewImage = previewImage
|
||||
}
|
||||
|
||||
displayModels.value.unshift(newDisplayModel)
|
||||
|
||||
@@ -179,9 +186,11 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
|
||||
|
||||
const { loadLive2DModelPreview } = await import('@proj-airi/stage-ui-live2d/utils/live2d-preview')
|
||||
const { loadVrmModelPreview } = await import('@proj-airi/stage-ui-three/utils/vrm-preview')
|
||||
const { loadSpineModelPreview } = await import('@proj-airi/stage-ui-spine/utils/spine-preview')
|
||||
|
||||
generateLive2DPreview = loadLive2DModelPreview
|
||||
generateVrmPreview = loadVrmModelPreview
|
||||
generateSpinePreview = loadSpineModelPreview
|
||||
}
|
||||
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import { useSettingsControlsIsland } from './controls-island'
|
||||
import { useSettingsDeveloper } from './developer'
|
||||
import { useSettingsGeneral } from './general'
|
||||
import { useSettingsLive2d } from './live2d'
|
||||
import { useSettingsSpine } from './spine'
|
||||
import { useSettingsStageModel } from './stage-model'
|
||||
import { useSettingsTheme } from './theme'
|
||||
|
||||
@@ -16,6 +17,7 @@ export * from './controls-island'
|
||||
export * from './developer'
|
||||
export * from './general'
|
||||
export * from './live2d'
|
||||
export * from './spine'
|
||||
export * from './stage-model'
|
||||
export * from './theme'
|
||||
// Export constants
|
||||
@@ -33,6 +35,7 @@ export const useSettings = defineStore('settings', () => {
|
||||
const analytics = useSettingsAnalytics()
|
||||
const stageModel = useSettingsStageModel()
|
||||
const live2d = useSettingsLive2d()
|
||||
const spine = useSettingsSpine()
|
||||
const theme = useSettingsTheme()
|
||||
const controlsIsland = useSettingsControlsIsland()
|
||||
const developer = useSettingsDeveloper()
|
||||
@@ -42,6 +45,7 @@ export const useSettings = defineStore('settings', () => {
|
||||
analytics.resetState()
|
||||
general.resetState()
|
||||
live2d.resetState()
|
||||
spine.resetState()
|
||||
theme.resetState()
|
||||
controlsIsland.resetState()
|
||||
developer.resetState()
|
||||
@@ -52,6 +56,7 @@ export const useSettings = defineStore('settings', () => {
|
||||
const analyticsRefs = storeToRefs(analytics)
|
||||
const stageModelRefs = storeToRefs(stageModel)
|
||||
const live2dRefs = storeToRefs(live2d)
|
||||
const spineRefs = storeToRefs(spine)
|
||||
const themeRefs = storeToRefs(theme)
|
||||
const controlsIslandRefs = storeToRefs(controlsIsland)
|
||||
const developerRefs = storeToRefs(developer)
|
||||
@@ -81,6 +86,13 @@ export const useSettings = defineStore('settings', () => {
|
||||
live2dMaxFps: live2dRefs.live2dMaxFps,
|
||||
live2dRenderScale: live2dRefs.live2dRenderScale,
|
||||
|
||||
// Spine settings
|
||||
spinePremultipliedAlpha: spineRefs.spinePremultipliedAlpha,
|
||||
spineDefaultMixDuration: spineRefs.spineDefaultMixDuration,
|
||||
spineIdleAnimationEnabled: spineRefs.spineIdleAnimationEnabled,
|
||||
spineMaxFps: spineRefs.spineMaxFps,
|
||||
spineRenderScale: spineRefs.spineRenderScale,
|
||||
|
||||
// Theme settings
|
||||
themeColorsHue: themeRefs.themeColorsHue,
|
||||
themeColorsHueDynamic: themeRefs.themeColorsHueDynamic,
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useLocalStorageManualReset } from '@proj-airi/stage-shared/composables'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
export const useSettingsSpine = defineStore('settings-spine', () => {
|
||||
const spinePremultipliedAlpha = useLocalStorageManualReset<boolean>('settings/spine/premultiplied-alpha', true)
|
||||
const spineDefaultMixDuration = useLocalStorageManualReset<number>('settings/spine/default-mix', 0.2)
|
||||
const spineIdleAnimationEnabled = useLocalStorageManualReset<boolean>('settings/spine/idle-enabled', true)
|
||||
const spineMaxFps = useLocalStorageManualReset<number>('settings/spine/max-fps', 0)
|
||||
const spineRenderScale = useLocalStorageManualReset<number>('settings/spine/render-scale', 1)
|
||||
|
||||
function resetState() {
|
||||
spinePremultipliedAlpha.reset()
|
||||
spineDefaultMixDuration.reset()
|
||||
spineIdleAnimationEnabled.reset()
|
||||
spineMaxFps.reset()
|
||||
spineRenderScale.reset()
|
||||
}
|
||||
|
||||
return {
|
||||
spinePremultipliedAlpha,
|
||||
spineDefaultMixDuration,
|
||||
spineIdleAnimationEnabled,
|
||||
spineMaxFps,
|
||||
spineRenderScale,
|
||||
|
||||
resetState,
|
||||
}
|
||||
})
|
||||
@@ -7,7 +7,7 @@ import { computed, watch } from 'vue'
|
||||
|
||||
import { DisplayModelFormat, useDisplayModelsStore } from '../display-models'
|
||||
|
||||
export type StageModelRenderer = 'live2d' | 'vrm' | 'godot' | 'disabled' | undefined
|
||||
export type StageModelRenderer = 'live2d' | 'vrm' | 'spine' | 'godot' | 'disabled' | undefined
|
||||
type BuiltInStageModelRenderer = Exclude<StageModelRenderer, 'godot'>
|
||||
|
||||
export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
@@ -52,6 +52,8 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
|
||||
return 'live2d'
|
||||
case DisplayModelFormat.VRM:
|
||||
return 'vrm'
|
||||
case DisplayModelFormat.SpineZip:
|
||||
return 'spine'
|
||||
default:
|
||||
return 'disabled'
|
||||
}
|
||||
|
||||
@@ -11,6 +11,9 @@ export const AvatarModelConfigSchema = object({
|
||||
live2d: optional(object({
|
||||
urls: array(string()),
|
||||
})),
|
||||
spine: optional(object({
|
||||
urls: array(string()),
|
||||
})),
|
||||
})
|
||||
|
||||
export const CharacterCapabilityConfigSchema = object({
|
||||
@@ -44,6 +47,7 @@ const CharacterCapabilityTypeSchema = union([
|
||||
const AvatarModelTypeSchema = union([
|
||||
literal('vrm'),
|
||||
literal('live2d'),
|
||||
literal('spine'),
|
||||
])
|
||||
|
||||
const PromptTypeSchema = union([
|
||||
|
||||
Generated
+172
-41
@@ -260,7 +260,7 @@ catalogs:
|
||||
version: 6.2.2
|
||||
jsdom:
|
||||
specifier: ^29.0.2
|
||||
version: 29.0.2
|
||||
version: 29.1.1
|
||||
knip:
|
||||
specifier: ^6.4.1
|
||||
version: 6.4.1
|
||||
@@ -558,7 +558,7 @@ importers:
|
||||
version: 12.0.0-beta.1(typescript@5.9.3)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(ws@8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10))
|
||||
vitest:
|
||||
specifier: catalog:vitest
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest-browser-vue:
|
||||
specifier: 'catalog:'
|
||||
version: 2.1.0(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3))
|
||||
@@ -1255,6 +1255,9 @@ importers:
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-live2d
|
||||
'@proj-airi/stage-ui-spine':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-spine
|
||||
'@proj-airi/stage-ui-three':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-three
|
||||
@@ -2533,7 +2536,7 @@ importers:
|
||||
version: 5.1.0
|
||||
vieval:
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
version: 0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
xsschema:
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
|
||||
@@ -2996,13 +2999,13 @@ importers:
|
||||
version: 0.1.69
|
||||
jsdom:
|
||||
specifier: 'catalog:'
|
||||
version: 29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3)
|
||||
version: 29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3)
|
||||
unplugin-info:
|
||||
specifier: 'catalog:'
|
||||
version: 1.3.2(esbuild@0.27.2)(rollup@4.60.1)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue-tsc:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
@@ -3030,6 +3033,9 @@ importers:
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:*
|
||||
version: link:../stage-ui-live2d
|
||||
'@proj-airi/stage-ui-spine':
|
||||
specifier: workspace:*
|
||||
version: link:../stage-ui-spine
|
||||
'@proj-airi/stage-ui-three':
|
||||
specifier: workspace:*
|
||||
version: link:../stage-ui-three
|
||||
@@ -3245,6 +3251,9 @@ importers:
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-ui-live2d
|
||||
'@proj-airi/stage-ui-spine':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-ui-spine
|
||||
'@proj-airi/stage-ui-three':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-ui-three
|
||||
@@ -3692,6 +3701,52 @@ importers:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
|
||||
packages/stage-ui-spine:
|
||||
dependencies:
|
||||
'@esotericsoftware/spine-webgl':
|
||||
specifier: ^4.2.0
|
||||
version: 4.2.114
|
||||
'@esotericsoftware/spine-webgl-4-0':
|
||||
specifier: npm:@esotericsoftware/spine-webgl@~4.0.31
|
||||
version: '@esotericsoftware/spine-webgl@4.0.31'
|
||||
'@esotericsoftware/spine-webgl-4-1':
|
||||
specifier: npm:@esotericsoftware/spine-webgl@~4.1.56
|
||||
version: '@esotericsoftware/spine-webgl@4.1.56'
|
||||
'@moeru/std':
|
||||
specifier: 'catalog:'
|
||||
version: 0.1.0-beta.17
|
||||
'@proj-airi/stage-shared':
|
||||
specifier: workspace:^
|
||||
version: link:../stage-shared
|
||||
'@proj-airi/ui':
|
||||
specifier: workspace:^
|
||||
version: link:../ui
|
||||
'@vueuse/core':
|
||||
specifier: ^14.2.1
|
||||
version: 14.2.1(vue@3.5.32(typescript@5.9.3))
|
||||
'@xsai/tool':
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
|
||||
es-toolkit:
|
||||
specifier: 'catalog:'
|
||||
version: 1.43.0
|
||||
jszip:
|
||||
specifier: ^3.10.1
|
||||
version: 3.10.1
|
||||
pinia:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.32(typescript@5.9.3))
|
||||
vue:
|
||||
specifier: 'catalog:'
|
||||
version: 3.5.32(typescript@5.9.3)
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 4.3.6
|
||||
devDependencies:
|
||||
vue-tsc:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
|
||||
packages/stage-ui-three:
|
||||
dependencies:
|
||||
'@moeru/eventa':
|
||||
@@ -4047,7 +4102,7 @@ importers:
|
||||
version: 66.6.8(@emnapi/core@1.9.2)(@emnapi/runtime@1.9.2)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vieval:
|
||||
specifier: 'catalog:'
|
||||
version: 0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
version: 0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
xsschema:
|
||||
specifier: 'catalog:'
|
||||
version: 0.5.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.3.1(typescript@5.9.3)))(zod-to-json-schema@3.25.2(zod@4.3.6))(zod@4.3.6)
|
||||
@@ -5485,8 +5540,8 @@ packages:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-calc@3.2.0':
|
||||
resolution: {integrity: sha512-bR9e6o2BDB12jzN/gIbjHa5wLJ4UjD1CB9pM7ehlc0ddk6EBz+yYS1EV2MF55/HUxrHcB/hehAyt5vhsA3hx7w==}
|
||||
'@csstools/css-calc@3.2.1':
|
||||
resolution: {integrity: sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
@@ -5499,8 +5554,8 @@ packages:
|
||||
'@csstools/css-parser-algorithms': ^3.0.5
|
||||
'@csstools/css-tokenizer': ^3.0.4
|
||||
|
||||
'@csstools/css-color-parser@4.1.0':
|
||||
resolution: {integrity: sha512-U0KhLYmy2GVj6q4T3WaAe6NPuFYCPQoE3b0dRGxejWDgcPp8TP7S5rVdM5ZrFaqu4N67X8YaPBw14dQSYx3IyQ==}
|
||||
'@csstools/css-color-parser@4.1.1':
|
||||
resolution: {integrity: sha512-eZ5XOtyhK+mggRafYUWzA0tvaYOFgdY8AkgQiCJF9qNAePnUo/zmsqqYubBBb3sQ8uNUaSKTY9s9klfRaAXL0g==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
@@ -5518,8 +5573,12 @@ packages:
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.3':
|
||||
resolution: {integrity: sha512-SH60bMfrRCJF3morcdk57WklujF4Jr/EsQUzqkarfHXEFcAR1gg7fS/chAE922Sehgzc1/+Tz5H3Ypa1HiEKrg==}
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.25':
|
||||
resolution: {integrity: sha512-g0Kw9W3vjx5BEBAF8c5Fm2NcB/Fs8jJXh85aXqwEXiL+tqtOut07TWgyaGzAAfTM+gKckrrncyeGEZPcaRgm2Q==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.4':
|
||||
resolution: {integrity: sha512-wgsqt92b7C7tQhIdPNxj0n9zuUbQlvAuI1exyzeNrOKOi62SD7ren8zqszmpVREjAOqg8cD2FqYhQfAuKjk4sw==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
@@ -6233,6 +6292,24 @@ packages:
|
||||
resolution: {integrity: sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24}
|
||||
|
||||
'@esotericsoftware/spine-core@4.0.31':
|
||||
resolution: {integrity: sha512-SiP87Xudw8qfg6t1Gv4NVoqP+Tw0eaCj1ApS+GXLdCvb/i5FNQevqEt4RgEvD8j1vJy+WVriPLMJYQSdaSehgA==}
|
||||
|
||||
'@esotericsoftware/spine-core@4.1.56':
|
||||
resolution: {integrity: sha512-sJbqIof+yE7LbkImbJt2cHfYcGBqadABttx8RY3ggu4JCa0sZLGdh+tWwe0+aRoN/91VI0rGSgG16fmkZ7Hc8Q==}
|
||||
|
||||
'@esotericsoftware/spine-core@4.2.114':
|
||||
resolution: {integrity: sha512-YB5DFjRXbvS6pusY1XNPFurgp8mGsS9ii7lqpIzi3LgNQt8H6+br8l/zqOzHjeY38aAXQfaDdvUNOES2KHrOPg==}
|
||||
|
||||
'@esotericsoftware/spine-webgl@4.0.31':
|
||||
resolution: {integrity: sha512-G6j31+caQJck/4UN8TVaTKnU0RPysI7ECMkCxcXBGsTmv98m0O5Wx18YgeIf//Bg8KAO+mZ/DmwzeScwGG9HPA==}
|
||||
|
||||
'@esotericsoftware/spine-webgl@4.1.56':
|
||||
resolution: {integrity: sha512-LNr/X4B81/rC96mzFV+L5LPnqaIj1v3RBCKTagmFlAd/2MtXcxwEatIQVPq487NigFwlrkvmxQuMpl1TRf3xxw==}
|
||||
|
||||
'@esotericsoftware/spine-webgl@4.2.114':
|
||||
resolution: {integrity: sha512-cD3twxmPNnuYCCk5vVFELOHdY4rkd8j7OtfYqB9pkEoi32V00fUh8qQFF5gC0lgefMTdid0dL8FPIkW/swmcLg==}
|
||||
|
||||
'@exodus/bytes@1.15.0':
|
||||
resolution: {integrity: sha512-UY0nlA+feH81UGSHv92sLEPLCeZFjXOuHhrIo0HQydScuQc8s0A7kL/UdgwgDq8g8ilksmuoF35YVTNphV2aBQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
@@ -6242,6 +6319,15 @@ packages:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@exodus/bytes@1.8.0':
|
||||
resolution: {integrity: sha512-8JPn18Bcp8Uo1T82gR8lh2guEOa5KKU/IEKvvdp0sgmi7coPBWf1Doi1EXsGZb2ehc8ym/StJCjffYV+ne7sXQ==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@exodus/crypto': ^1.0.0-rc.4
|
||||
peerDependenciesMeta:
|
||||
'@exodus/crypto':
|
||||
optional: true
|
||||
|
||||
'@ffmpeg-installer/darwin-arm64@4.1.5':
|
||||
resolution: {integrity: sha512-hYqTiP63mXz7wSQfuqfFwfLOfwwFChUedeCVKkBtl/cliaTM7/ePI9bVzfZ2c+dWu3TqCwLDRWNSJ5pqZl8otA==}
|
||||
cpu: [arm64]
|
||||
@@ -12523,6 +12609,10 @@ packages:
|
||||
resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==}
|
||||
engines: {node: '>=0.12'}
|
||||
|
||||
entities@8.0.0:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
env-paths@2.2.1:
|
||||
resolution: {integrity: sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -14093,8 +14183,8 @@ packages:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
jsdom@29.0.2:
|
||||
resolution: {integrity: sha512-9VnGEBosc/ZpwyOsJBCQ/3I5p7Q5ngOY14a9bf5btenAORmZfDse1ZEheMiWcJ3h81+Fv7HmJFdS0szo/waF2w==}
|
||||
jsdom@29.1.1:
|
||||
resolution: {integrity: sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==}
|
||||
engines: {node: ^20.19.0 || ^22.13.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.0.0
|
||||
@@ -15382,6 +15472,9 @@ packages:
|
||||
parse5@8.0.0:
|
||||
resolution: {integrity: sha512-9m4m5GSgXjL4AjumKzq1Fgfp3Z8rsvjRNbnkVwfu2ImRqE5D0LnY2QfDen18FSY9C573YU5XxSapdHZTZ2WolA==}
|
||||
|
||||
parse5@8.0.1:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
parseurl@1.3.3:
|
||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -16935,6 +17028,10 @@ packages:
|
||||
resolution: {integrity: sha512-nlLsUzgm1kfLXSXfRZMc1KLAugd4hqJHDTvc2hDIwS3mZAfMEuMbc03SujMF+GEcpaX/qboeycw6iO8JwVv2+g==}
|
||||
engines: {node: '>=0.8'}
|
||||
|
||||
tough-cookie@6.0.0:
|
||||
resolution: {integrity: sha512-kXuRi1mtaKMrsLUxz3sQYvVl37B0Ns6MzfrtV5DvJceE9bPyspOqk9xxv7XbZWcfLWbFmm997vl83qUWVJA64w==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==}
|
||||
engines: {node: '>=16'}
|
||||
@@ -18552,8 +18649,8 @@ snapshots:
|
||||
'@asamuzakjp/css-color@5.1.11':
|
||||
dependencies:
|
||||
'@asamuzakjp/generational-cache': 1.0.1
|
||||
'@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
@@ -19688,7 +19785,7 @@ snapshots:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-calc@3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
'@csstools/css-calc@3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
@@ -19700,10 +19797,10 @@ snapshots:
|
||||
'@csstools/css-parser-algorithms': 3.0.5(@csstools/css-tokenizer@3.0.4)
|
||||
'@csstools/css-tokenizer': 3.0.4
|
||||
|
||||
'@csstools/css-color-parser@4.1.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
'@csstools/css-color-parser@4.1.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.0.2
|
||||
'@csstools/css-calc': 3.2.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-calc': 3.2.1(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
@@ -19715,7 +19812,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.3(css-tree@3.2.1)':
|
||||
'@csstools/css-syntax-patches-for-csstree@1.0.25': {}
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.4(css-tree@3.2.1)':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
@@ -20318,10 +20417,30 @@ snapshots:
|
||||
'@eslint/core': 1.2.1
|
||||
levn: 0.4.1
|
||||
|
||||
'@esotericsoftware/spine-core@4.0.31': {}
|
||||
|
||||
'@esotericsoftware/spine-core@4.1.56': {}
|
||||
|
||||
'@esotericsoftware/spine-core@4.2.114': {}
|
||||
|
||||
'@esotericsoftware/spine-webgl@4.0.31':
|
||||
dependencies:
|
||||
'@esotericsoftware/spine-core': 4.0.31
|
||||
|
||||
'@esotericsoftware/spine-webgl@4.1.56':
|
||||
dependencies:
|
||||
'@esotericsoftware/spine-core': 4.1.56
|
||||
|
||||
'@esotericsoftware/spine-webgl@4.2.114':
|
||||
dependencies:
|
||||
'@esotericsoftware/spine-core': 4.2.114
|
||||
|
||||
'@exodus/bytes@1.15.0(@noble/hashes@2.0.1)':
|
||||
optionalDependencies:
|
||||
'@noble/hashes': 2.0.1
|
||||
|
||||
'@exodus/bytes@1.8.0': {}
|
||||
|
||||
'@ffmpeg-installer/darwin-arm64@4.1.5':
|
||||
optional: true
|
||||
|
||||
@@ -24385,7 +24504,7 @@ snapshots:
|
||||
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
playwright: 1.59.1
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
@@ -24398,7 +24517,7 @@ snapshots:
|
||||
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
playwright: 1.59.1
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
- msw
|
||||
@@ -24415,7 +24534,7 @@ snapshots:
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -24432,7 +24551,7 @@ snapshots:
|
||||
pngjs: 7.0.0
|
||||
sirv: 3.0.2
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
ws: 8.20.0(bufferutil@4.1.0)(utf-8-validate@5.0.10)
|
||||
transitivePeerDependencies:
|
||||
- bufferutil
|
||||
@@ -24453,7 +24572,7 @@ snapshots:
|
||||
obug: 2.1.1
|
||||
std-env: 4.1.0
|
||||
tinyrainbow: 3.1.0
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
optionalDependencies:
|
||||
'@vitest/browser': 4.1.4(bufferutil@4.1.0)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
|
||||
|
||||
@@ -24465,7 +24584,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@typescript-eslint/eslint-plugin': 8.58.1(@typescript-eslint/parser@8.51.0(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3))(eslint@10.2.1(jiti@2.6.1))(typescript@5.9.3)
|
||||
typescript: 5.9.3
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
@@ -25614,7 +25733,7 @@ snapshots:
|
||||
drizzle-orm: 0.41.0(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)
|
||||
pg: 8.20.0
|
||||
react: 19.2.3
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
|
||||
better-auth@1.6.5(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(better-sqlite3@12.5.0)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9))(pg@8.20.0)(react@19.2.3)(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)):
|
||||
@@ -25643,7 +25762,7 @@ snapshots:
|
||||
drizzle-orm: 0.45.2(@electric-sql/pglite@0.4.4)(@opentelemetry/api@1.9.1)(@prisma/client@5.22.0)(@types/pg@8.20.0)(better-sqlite3@12.5.0)(kysely@0.28.14)(pg@8.20.0)(postgres@3.4.9)
|
||||
pg: 8.20.0
|
||||
react: 19.2.3
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
transitivePeerDependencies:
|
||||
- '@cloudflare/workers-types'
|
||||
@@ -26294,7 +26413,7 @@ snapshots:
|
||||
cssstyle@5.3.7:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 4.1.1
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1)
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.0.25
|
||||
css-tree: 3.2.1
|
||||
lru-cache: 11.3.5
|
||||
|
||||
@@ -26955,6 +27074,8 @@ snapshots:
|
||||
|
||||
entities@7.0.1: {}
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
env-paths@2.2.1: {}
|
||||
|
||||
environment@1.1.0: {}
|
||||
@@ -28421,6 +28542,7 @@ snapshots:
|
||||
vite: 6.4.2(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vite-node: 3.2.4(@types/node@25.6.0)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
transitivePeerDependencies:
|
||||
- '@exodus/crypto'
|
||||
- '@noble/hashes'
|
||||
- '@types/node'
|
||||
- bufferutil
|
||||
@@ -28878,7 +29000,7 @@ snapshots:
|
||||
dependencies:
|
||||
'@acemir/cssom': 0.9.31
|
||||
'@asamuzakjp/dom-selector': 6.7.6
|
||||
'@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
|
||||
'@exodus/bytes': 1.8.0
|
||||
cssstyle: 5.3.7
|
||||
data-urls: 6.0.0
|
||||
decimal.js: 10.6.0
|
||||
@@ -28889,7 +29011,7 @@ snapshots:
|
||||
parse5: 8.0.0
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.1
|
||||
tough-cookie: 6.0.0
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 4.0.0
|
||||
@@ -28899,17 +29021,18 @@ snapshots:
|
||||
optionalDependencies:
|
||||
canvas: 3.2.3
|
||||
transitivePeerDependencies:
|
||||
- '@exodus/crypto'
|
||||
- '@noble/hashes'
|
||||
- bufferutil
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3):
|
||||
jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3):
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 5.1.11
|
||||
'@asamuzakjp/dom-selector': 7.1.1
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.3(css-tree@3.2.1)
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.4(css-tree@3.2.1)
|
||||
'@exodus/bytes': 1.15.0(@noble/hashes@2.0.1)
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0(@noble/hashes@2.0.1)
|
||||
@@ -28917,7 +29040,7 @@ snapshots:
|
||||
html-encoding-sniffer: 6.0.0(@noble/hashes@2.0.1)
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.3.5
|
||||
parse5: 8.0.0
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.1
|
||||
@@ -30622,6 +30745,10 @@ snapshots:
|
||||
dependencies:
|
||||
entities: 6.0.1
|
||||
|
||||
parse5@8.0.1:
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
|
||||
parseurl@1.3.3: {}
|
||||
|
||||
path-browserify-esm@1.0.6: {}
|
||||
@@ -32533,6 +32660,10 @@ snapshots:
|
||||
psl: 1.15.0
|
||||
punycode: 2.3.1
|
||||
|
||||
tough-cookie@6.0.0:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
|
||||
tough-cookie@6.0.1:
|
||||
dependencies:
|
||||
tldts: 7.0.19
|
||||
@@ -33291,7 +33422,7 @@ snapshots:
|
||||
'@types/unist': 3.0.3
|
||||
vfile-message: 4.0.3
|
||||
|
||||
vieval@0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
vieval@0.0.5(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(chokidar@5.0.0)(dotenv@17.4.2)(esbuild@0.27.2)(giget@2.0.0)(jiti@2.6.1)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(less@4.6.4)(magicast@0.5.2)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3):
|
||||
dependencies:
|
||||
'@moeru/std': 0.1.0-beta.17
|
||||
'@pnpm/find-workspace-dir': 1000.1.5
|
||||
@@ -33306,7 +33437,7 @@ snapshots:
|
||||
tinyglobby: 0.2.16
|
||||
tinyrainbow: 3.1.0
|
||||
vite: 8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
transitivePeerDependencies:
|
||||
- '@edge-runtime/vm'
|
||||
- '@opentelemetry/api'
|
||||
@@ -33678,10 +33809,10 @@ snapshots:
|
||||
vitest-browser-vue@2.1.0(vitest@4.1.4)(vue@3.5.32(typescript@5.9.3)):
|
||||
dependencies:
|
||||
'@vue/test-utils': 2.4.6
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vitest: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue: 3.5.32(typescript@5.9.3)
|
||||
|
||||
vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@24.12.2)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.4
|
||||
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
@@ -33708,11 +33839,11 @@ snapshots:
|
||||
'@types/node': 24.12.2
|
||||
'@vitest/browser-playwright': 4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@24.12.2)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
|
||||
'@vitest/coverage-v8': 4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4)
|
||||
jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3)
|
||||
jsdom: 29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
vitest@4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.4
|
||||
'@vitest/mocker': 4.1.4(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
@@ -33739,7 +33870,7 @@ snapshots:
|
||||
'@types/node': 25.6.0
|
||||
'@vitest/browser-playwright': 4.1.4(bufferutil@4.1.0)(playwright@1.59.1)(utf-8-validate@5.0.10)(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))(vitest@4.1.4)
|
||||
'@vitest/coverage-v8': 4.1.4(@vitest/browser@4.1.4)(vitest@4.1.4)
|
||||
jsdom: 29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3)
|
||||
jsdom: 29.1.1(@noble/hashes@2.0.1)(canvas@3.2.3)
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
|
||||
Reference in New Issue
Block a user