feat(stage-ui-spine): add Spine 2D runtime integration (#1810)

This commit is contained in:
nyueki
2026-05-19 19:13:35 +08:00
committed by GitHub
parent c0ced4ed17
commit 4542a9d290
42 changed files with 2801 additions and 47 deletions
+1
View File
@@ -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: >-
+1
View File
@@ -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:",
+53
View File
@@ -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.
+25
View File
@@ -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.
+53
View File
@@ -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,
}
+8
View File
@@ -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'
+151
View File
@@ -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')
}
+36
View File
@@ -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"
]
}
+1
View File
@@ -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'
}
+4
View File
@@ -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([
+172 -41
View File
@@ -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