diff --git a/apps/stage-tamagotchi/package.json b/apps/stage-tamagotchi/package.json index 852517de0..f1c6ccfed 100644 --- a/apps/stage-tamagotchi/package.json +++ b/apps/stage-tamagotchi/package.json @@ -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", diff --git a/apps/stage-tamagotchi/src/renderer/pages/index.vue b/apps/stage-tamagotchi/src/renderer/pages/index.vue index cbfdb50a2..adbaab4e0 100644 --- a/apps/stage-tamagotchi/src/renderer/pages/index.vue +++ b/apps/stage-tamagotchi/src/renderer/pages/index.vue @@ -151,6 +151,20 @@ const modelSettingsRuntimeSnapshot = computed(() = }) } + 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, diff --git a/packages/i18n/src/locales/en/settings.yaml b/packages/i18n/src/locales/en/settings.yaml index e8f420bed..a6b8cd5c4 100644 --- a/packages/i18n/src/locales/en/settings.yaml +++ b/packages/i18n/src/locales/en/settings.yaml @@ -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: >- diff --git a/packages/stage-pages/package.json b/packages/stage-pages/package.json index 93a2b34db..9f96cc8b0 100644 --- a/packages/stage-pages/package.json +++ b/packages/stage-pages/package.json @@ -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:", diff --git a/packages/stage-ui-spine/LICENSE.md b/packages/stage-ui-spine/LICENSE.md new file mode 100644 index 000000000..c471b1125 --- /dev/null +++ b/packages/stage-ui-spine/LICENSE.md @@ -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. diff --git a/packages/stage-ui-spine/README.md b/packages/stage-ui-spine/README.md new file mode 100644 index 000000000..286aa6ac5 --- /dev/null +++ b/packages/stage-ui-spine/README.md @@ -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. diff --git a/packages/stage-ui-spine/package.json b/packages/stage-ui-spine/package.json new file mode 100644 index 000000000..bf4e4efa4 --- /dev/null +++ b/packages/stage-ui-spine/package.json @@ -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" + } +} diff --git a/packages/stage-ui-spine/src/components/index.ts b/packages/stage-ui-spine/src/components/index.ts new file mode 100644 index 000000000..ab6b53306 --- /dev/null +++ b/packages/stage-ui-spine/src/components/index.ts @@ -0,0 +1 @@ +export * from './scenes' diff --git a/packages/stage-ui-spine/src/components/scenes/Spine.vue b/packages/stage-ui-spine/src/components/scenes/Spine.vue new file mode 100644 index 000000000..21952029f --- /dev/null +++ b/packages/stage-ui-spine/src/components/scenes/Spine.vue @@ -0,0 +1,77 @@ + + + diff --git a/packages/stage-ui-spine/src/components/scenes/index.ts b/packages/stage-ui-spine/src/components/scenes/index.ts new file mode 100644 index 000000000..e80d49e90 --- /dev/null +++ b/packages/stage-ui-spine/src/components/scenes/index.ts @@ -0,0 +1,2 @@ +export * from './spine' +export { default as SpineScene } from './Spine.vue' diff --git a/packages/stage-ui-spine/src/components/scenes/spine/Canvas.vue b/packages/stage-ui-spine/src/components/scenes/spine/Canvas.vue new file mode 100644 index 000000000..9be6c4270 --- /dev/null +++ b/packages/stage-ui-spine/src/components/scenes/spine/Canvas.vue @@ -0,0 +1,84 @@ + + + diff --git a/packages/stage-ui-spine/src/components/scenes/spine/Model.vue b/packages/stage-ui-spine/src/components/scenes/spine/Model.vue new file mode 100644 index 000000000..55c03fb99 --- /dev/null +++ b/packages/stage-ui-spine/src/components/scenes/spine/Model.vue @@ -0,0 +1,497 @@ + + + diff --git a/packages/stage-ui-spine/src/components/scenes/spine/index.ts b/packages/stage-ui-spine/src/components/scenes/spine/index.ts new file mode 100644 index 000000000..ca1415284 --- /dev/null +++ b/packages/stage-ui-spine/src/components/scenes/spine/index.ts @@ -0,0 +1,2 @@ +export { default as SpineCanvas } from './Canvas.vue' +export { default as SpineModel } from './Model.vue' diff --git a/packages/stage-ui-spine/src/composables/spine/animation-manager.ts b/packages/stage-ui-spine/src/composables/spine/animation-manager.ts new file mode 100644 index 000000000..3148add20 --- /dev/null +++ b/packages/stage-ui-spine/src/composables/spine/animation-manager.ts @@ -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, + } +} diff --git a/packages/stage-ui-spine/src/composables/spine/index.ts b/packages/stage-ui-spine/src/composables/spine/index.ts new file mode 100644 index 000000000..47e383b88 --- /dev/null +++ b/packages/stage-ui-spine/src/composables/spine/index.ts @@ -0,0 +1 @@ +export * from './animation-manager' diff --git a/packages/stage-ui-spine/src/constants/emotions.ts b/packages/stage-ui-spine/src/constants/emotions.ts new file mode 100644 index 000000000..2ed7a5219 --- /dev/null +++ b/packages/stage-ui-spine/src/constants/emotions.ts @@ -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.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, +} diff --git a/packages/stage-ui-spine/src/index.ts b/packages/stage-ui-spine/src/index.ts new file mode 100644 index 000000000..b6bc7abeb --- /dev/null +++ b/packages/stage-ui-spine/src/index.ts @@ -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' diff --git a/packages/stage-ui-spine/src/stores/index.ts b/packages/stage-ui-spine/src/stores/index.ts new file mode 100644 index 000000000..515ec1742 --- /dev/null +++ b/packages/stage-ui-spine/src/stores/index.ts @@ -0,0 +1,2 @@ +export * from './spine' +export * from './view-control' diff --git a/packages/stage-ui-spine/src/stores/spine.ts b/packages/stage-ui-spine/src/stores/spine.ts new file mode 100644 index 000000000..74e6eb1b0 --- /dev/null +++ b/packages/stage-ui-spine/src/stores/spine.ts @@ -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({ + 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( + 'settings/spine/current-animation', + () => ({ ...defaultSpineAnimation }), + ) + + /** All animations discovered on the loaded skeleton. */ + const availableAnimations = useLocalStorageManualReset( + 'settings/spine/available-animations', + () => [], + ) + + /** All skins discovered on the loaded skeleton. */ + const availableSkins = useLocalStorageManualReset( + 'settings/spine/available-skins', + () => [], + ) + + /** Active skin name. Empty string means use the model's default skin. */ + const currentSkin = useLocalStorageManualReset('settings/spine/current-skin', '') + + /** All skeleton variants discovered in the ZIP. */ + const availableVariants = useLocalStorageManualReset( + 'settings/spine/available-variants', + () => [], + ) + + /** Active variant name. Empty string means use the default (first) variant. */ + const currentVariant = useLocalStorageManualReset('settings/spine/current-variant', '') + + /** Premultiplied alpha — most modern Spine atlases ship as PMA. */ + const premultipliedAlpha = useLocalStorageManualReset('settings/spine/premultiplied-alpha', true) + + /** Default mix-in/out duration (s) between track animations. */ + const defaultMixDuration = useLocalStorageManualReset('settings/spine/default-mix', 0.2) + + /** Auto-play idle animation on load. */ + const idleAnimationEnabled = useLocalStorageManualReset('settings/spine/idle-enabled', true) + + /** Animation playback speed multiplier (1.0 = normal). */ + const animationSpeed = useLocalStorageManualReset('settings/spine/animation-speed', 1) + + /** Maximum FPS for the WebGL render loop (0 = uncapped). */ + const maxFps = useLocalStorageManualReset('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 } diff --git a/packages/stage-ui-spine/src/stores/view-control.ts b/packages/stage-ui-spine/src/stores/view-control.ts new file mode 100644 index 000000000..59e305a36 --- /dev/null +++ b/packages/stage-ui-spine/src/stores/view-control.ts @@ -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('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 = { + 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, + } +} diff --git a/packages/stage-ui-spine/src/tools/animation-tools.ts b/packages/stage-ui-spine/src/tools/animation-tools.ts new file mode 100644 index 000000000..7eb502eed --- /dev/null +++ b/packages/stage-ui-spine/src/tools/animation-tools.ts @@ -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({}), + }), +] diff --git a/packages/stage-ui-spine/src/utils/spine-preview.ts b/packages/stage-ui-spine/src/utils/spine-preview.ts new file mode 100644 index 000000000..876108023 --- /dev/null +++ b/packages/stage-ui-spine/src/utils/spine-preview.ts @@ -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 { + let assets: Awaited> | 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((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/` (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, + rawData: Record, + texturePaths: string[], +) { + const downloader = (assetManager as unknown as { + downloader?: { + rawDataUris: Record + 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() + const binaryLookup = new Map() + 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 + } +} diff --git a/packages/stage-ui-spine/src/utils/spine-runtime.ts b/packages/stage-ui-spine/src/utils/spine-runtime.ts new file mode 100644 index 000000000..4ce845fb9 --- /dev/null +++ b/packages/stage-ui-spine/src/utils/spine-runtime.ts @@ -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 { + 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') + } +} diff --git a/packages/stage-ui-spine/src/utils/spine-validator.ts b/packages/stage-ui-spine/src/utils/spine-validator.ts new file mode 100644 index 000000000..1ddfe6705 --- /dev/null +++ b/packages/stage-ui-spine/src/utils/spine-validator.ts @@ -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 { + 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 } +} diff --git a/packages/stage-ui-spine/src/utils/spine-version.ts b/packages/stage-ui-spine/src/utils/spine-version.ts new file mode 100644 index 000000000..0bf263b7c --- /dev/null +++ b/packages/stage-ui-spine/src/utils/spine-version.ts @@ -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 } +} diff --git a/packages/stage-ui-spine/src/utils/spine-zip-loader.ts b/packages/stage-ui-spine/src/utils/spine-zip-loader.ts new file mode 100644 index 000000000..4660ed6d3 --- /dev/null +++ b/packages/stage-ui-spine/src/utils/spine-zip-loader.ts @@ -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 + + /** + * 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 + + /** 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, atlasText: Record): 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, atlasText: Record): 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() + + // 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, atlasText: Record): 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 { + const zip = new JSZip() + const archive = await zip.loadAsync(file) + + const entries: Record = {} + const atlasTexts: Record = {} + const blobUrls: Record = {} + + // 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/` (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 = {} + + // Collect all unique paths across all variants. + const allTexturePaths = new Set() + const allSkeletonPaths = new Set() + const allAtlasPaths = new Set() + 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) { + 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') +} diff --git a/packages/stage-ui-spine/tsconfig.json b/packages/stage-ui-spine/tsconfig.json new file mode 100644 index 000000000..2365daacf --- /dev/null +++ b/packages/stage-ui-spine/tsconfig.json @@ -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" + ] +} diff --git a/packages/stage-ui/package.json b/packages/stage-ui/package.json index b832c9f57..f37bcce53 100644 --- a/packages/stage-ui/package.json +++ b/packages/stage-ui/package.json @@ -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", diff --git a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue index 76fa929dd..cd839db79 100644 --- a/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue +++ b/packages/stage-ui/src/components/scenarios/dialogs/model-selector/model-selector.vue @@ -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.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 = { 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) diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue index 25dcdaf8d..d408c0a7d 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue @@ -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) {

Click Select Model to import different formats of - models into catalog, currently, .zip (Live2D) and .vrm (VRM) are supported. + models into catalog, currently, .zip (Live2D, Spine) and .vrm (VRM) are supported.

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.

@@ -89,6 +90,13 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) { :runtime-snapshot="runtimeSnapshot" @extract-colors-from-model="$emit('extractColorsFromModel')" /> + () 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(() => { }) } + 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({
+ diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts index e15ef82f2..cb19f46e9 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/runtime.ts @@ -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 { diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue new file mode 100644 index 000000000..cf02e5edd --- /dev/null +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/spine.vue @@ -0,0 +1,263 @@ + + + diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 8b0142ef9..c8dade118 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -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>() const live2dSceneRef = ref>() +const spineSceneRef = ref>() 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({ 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" /> +