fix(stage-ui-three): preserve imported VRM view settings (#2423)

This commit is contained in:
leafyy
2026-09-01 10:31:12 +08:00
committed by GitHub
parent ffad10a716
commit 55ef3d416e
12 changed files with 343 additions and 64 deletions
@@ -278,6 +278,7 @@ onUnmounted(() => {
<ThreeScene
v-if="stageModelRenderer === 'vrm'"
ref="sceneRef"
:model-id="stageModelSelected"
:model-src="stageModelSelectedUrl"
:idle-animation="animations.idleLoop.toString()"
:current-audio-source="currentAudioSource"
@@ -465,6 +465,7 @@ onUnmounted(() => {
<ThreeScene
v-if="stageModelRenderer === 'vrm'"
ref="sceneRef"
:model-id="stageModelSelected"
:model-src="stageModelSelectedUrl"
:idle-animation="animations.idleLoop.toString()"
:show-axes="stageViewControlsEnabled"
@@ -278,6 +278,7 @@ onUnmounted(() => {
<ThreeScene
v-if="stageModelRenderer === 'vrm'"
ref="sceneRef"
:model-id="stageModelSelected"
:model-src="stageModelSelectedUrl"
:idle-animation="animations.idleLoop.toString()"
:current-audio-source="currentAudioSource"
@@ -0,0 +1,145 @@
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { createPinia } from 'pinia'
import { beforeAll, describe, expect, it } from 'vitest'
import { createApp, defineComponent, h, nextTick, shallowRef } from 'vue'
import 'virtual:uno.css'
let vrmModel: Blob
let vrmModelUrl: string
beforeAll(async () => {
const pinia = createPinia()
const displayModels = useDisplayModelsStore(pinia)
const preset = await displayModels.getDisplayModel('preset-vrm-1')
if (preset?.type !== 'url')
throw new Error('The VRM test preset is unavailable.')
vrmModelUrl = preset.url
const response = await fetch(preset.url)
if (!response.ok)
throw new Error(`Failed to load the VRM test preset: ${response.status}`)
vrmModel = await response.blob()
})
describe('imported VRM view settings', () => {
// https://github.com/moeru-ai/airi/issues/1806
it('resets the legacy view once and preserves later reloads for Issue #1806', async () => {
// ROOT CAUSE:
//
// An imported VRM receives a new Blob URL when AIRI reloads the selected model.
// The scene treats that URL change as a model switch and replaces the saved view settings.
//
// The selected display model ID is stable across reloads. The scene must use that ID
// for model identity and keep the Blob URL only for resource loading.
// A new model ID can arrive before its URL. The old load must not commit the new ID.
// Existing installations only have the old runtime URL identity key. AIRI cannot
// safely map that URL back to a persisted file after restart. The first new-version
// load must reset once and establish a stable model ID for later reloads.
// A reload also creates a new VRM group. The saved offset must be applied to that
// group even when the store value does not change and its watcher does not run.
// The old group must leave the scene before AIRI commits the replacement group.
const pinia = createPinia()
const modelStore = useModelStore(pinia)
modelStore.resetModelStore()
localStorage.setItem('settings/stage-ui-three/lastModelSrc', vrmModelUrl)
modelStore.modelOffset = { x: 0.25, y: 0.25, z: 0 }
const modelId = shallowRef('display-model-issue-1806')
const modelSrc = shallowRef(vrmModelUrl)
const sceneComponent = shallowRef<InstanceType<typeof ThreeScene>>()
modelStore.resetLegacyModelIdentity()
const container = document.createElement('div')
container.style.height = '600px'
container.style.width = '800px'
document.body.appendChild(container)
const TestHarness = defineComponent(() => () => h(ThreeScene, {
ref: sceneComponent,
modelId: modelId.value,
modelSrc: modelSrc.value,
style: { height: '600px', width: '800px' },
}))
function renderedModelOffset() {
const position = sceneComponent.value?.scene()?.parent?.position
if (!position)
return undefined
return { x: position.x, y: position.y, z: position.z }
}
const app = createApp(TestHarness)
app.use(pinia)
app.mount(container)
await expect.poll(() => {
const bounds = container.firstElementChild?.getBoundingClientRect()
return { height: bounds?.height, width: bounds?.width }
}).toEqual({ height: 600, width: 800 })
// NOTICE:
// Keep the app mounted until Vitest closes the browser page.
// Vue DevTools schedules inspector work after app.unmount(), which rejects after teardown.
// Source/context: the Stage Web Vite configuration used by this browser test.
// Removal condition: Vue DevTools supports component-test app teardown.
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted')
expect(modelStore.lastCommittedModelId).toBe('display-model-issue-1806')
expect(localStorage.getItem('settings/stage-ui-three/lastModelId')).toBe('display-model-issue-1806')
expect(modelStore.modelOffset).toEqual({ x: 0, y: 0, z: 0 })
expect(localStorage.getItem('settings/stage-ui-three/lastModelSrc')).toBeNull()
modelStore.modelOffset = { x: 0.35, y: 0.35, z: 0 }
modelStore.cameraDistance = 1.75
await expect.poll(renderedModelOffset).toEqual({ x: 0.35, y: 0.35, z: 0 })
const previousModelGroup = sceneComponent.value?.scene()?.parent
expect(previousModelGroup).toBeTruthy()
const previousModelSrc = modelSrc.value
modelSrc.value = URL.createObjectURL(vrmModel)
await nextTick()
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading')
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted')
expect(modelSrc.value).not.toBe(previousModelSrc)
expect(modelStore.modelOffset).toEqual({ x: 0.35, y: 0.35, z: 0 })
expect(renderedModelOffset()).toEqual({ x: 0.35, y: 0.35, z: 0 })
expect(modelStore.cameraDistance).toBe(1.75)
expect(sceneComponent.value?.scene()?.parent === previousModelGroup).toBe(false)
expect(previousModelGroup?.parent === null).toBe(true)
modelId.value = 'display-model-other'
modelSrc.value = URL.createObjectURL(vrmModel)
await nextTick()
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading')
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted')
expect(modelStore.lastCommittedModelId).toBe('display-model-other')
expect(modelStore.modelOffset).toEqual({ x: 0, y: 0, z: 0 })
modelStore.modelOffset = { x: 0.45, y: 0.45, z: 0 }
const outgoingModelId = modelId.value
modelSrc.value = URL.createObjectURL(vrmModel)
await nextTick()
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading')
modelId.value = 'display-model-incoming'
await nextTick()
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted')
expect(modelStore.lastCommittedModelId).toBe(outgoingModelId)
expect(modelStore.modelOffset).toEqual({ x: 0.45, y: 0.45, z: 0 })
modelSrc.value = URL.createObjectURL(vrmModel)
await nextTick()
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('loading')
await expect.poll(() => modelStore.scenePhase, { timeout: 20_000 }).toBe('mounted')
expect(modelStore.lastCommittedModelId).toBe('display-model-incoming')
expect(modelStore.modelOffset).toEqual({ x: 0, y: 0, z: 0 })
}, 45_000)
})
+2 -1
View File
@@ -34,7 +34,8 @@
"build:packages": "turbo run build -F=\"./packages/*\"",
"build:engines": "turbo run build -F=\"./engines/*\"",
"test": "vitest --coverage",
"test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-ui:run",
"test:run": "vitest run && pnpm run test-stage-tamagotchi:run && pnpm run test-stage-web:run && pnpm run test-ui:run",
"test-stage-web:run": "pnpm -F @proj-airi/stage-web exec vitest run --project browser src/pages/devtools/vrm-model-position.browser.test.ts",
"test-stage-tamagotchi:run": "vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser",
"test-ui:run": "vitest run --config packages/stage-ui/vitest.config.ts",
"lint": "moeru-lint .",
@@ -98,6 +98,7 @@ import {
/*
* Props:
* - modelId: stable display model identity
* - modelSrc: model src string to load model asset
* - idleAnimation: animation src for model
* - loadAnimations: TBC
@@ -112,7 +113,10 @@ const props = withDefaults(defineProps<{
audioContext?: AudioContext
currentAudioSource?: AudioBufferSourceNode
cursorPosition?: { x: number, y: number }
lastCommittedModelSrc?: string
/** The stable identity of the last model that completed scene binding. */
lastCommittedModelId?: string
/** Stable display model identity. Runtime resource URLs can change across reloads. */
modelId: string
modelSrc?: string
idleAnimation: string
// loadAnimations?: string[]
@@ -152,7 +156,8 @@ const emit = defineEmits<{
const {
audioContext,
currentAudioSource,
lastCommittedModelSrc,
lastCommittedModelId,
modelId,
modelSrc,
idleAnimation,
// loadAnimations, // TBC
@@ -221,6 +226,13 @@ function getRendererInstance() {
return renderer?.instance as WebGLRenderer | undefined
}
function updateIblProbe(mode = normalizeEnvMode(envSelect.value)) {
if (!airiIblProbe && scene.value)
airiIblProbe = createIblProbeController(scene.value)
airiIblProbe?.update(mode, skyBoxIntensity.value, nprIrrSH.value ?? null)
}
function toErrorMessage(error: unknown) {
if (error instanceof Error)
return error.message
@@ -303,7 +315,19 @@ function clearActiveManagedVrmRefs() {
interactionColliders.value = undefined
}
function applyModelTransform(group: Group) {
group.position.set(
modelOffset.value.x,
modelOffset.value.y,
modelOffset.value.z,
)
group.rotation.y = MathUtils.degToRad(modelRotationY.value)
}
function applyManagedVrmInstance(instance: ManagedVrmInstance) {
// A reload creates a new group while the saved transform can stay unchanged.
// Apply it during every commit because the value watchers will not run again.
applyModelTransform(instance.group)
vrm.value = instance.vrm
vrmGroup.value = instance.group
vrmAnimationMixer.value = instance.mixer
@@ -493,7 +517,16 @@ function bindManagedVrmInstanceRenderLoop() {
}).off
}
function commitManagedVrmInstance(instance: ManagedVrmInstance) {
function commitManagedVrmInstance(
instance: ManagedVrmInstance,
reason: 'initial-load' | 'model-reload' | 'model-switch',
) {
// Keep the active model visible until its replacement is ready. No asynchronous
// work occurs between this cleanup and the replacement scene commit.
if (reason !== 'initial-load')
componentCleanUp(reason, { invalidate: false })
updateIblProbe()
scene.value?.add(instance.group)
applyManagedVrmInstance(instance)
bindManagedVrmInstanceRenderLoop()
@@ -640,10 +673,10 @@ function buildSceneBootstrap(activeVrm: VRM, cacheHit: boolean): SceneBootstrap
}
function resolveVrmLoadReason(): 'initial-load' | 'model-reload' | 'model-switch' {
if (!lastCommittedModelSrc.value)
if (!lastCommittedModelId.value)
return 'initial-load'
if (lastCommittedModelSrc.value !== modelSrc.value)
if (lastCommittedModelId.value !== modelId.value)
return 'model-switch'
return 'model-reload'
@@ -699,13 +732,6 @@ async function loadModel() {
nextVrmAnimationMixer = reusableInstance.mixer
nextVrmEmote = reusableInstance.emote
if (!airiIblProbe && scene.value)
airiIblProbe = createIblProbeController(scene.value)
if (currentLoadReason === 'model-switch') {
componentCleanUp('model-switch', { invalidate: false })
}
runVrmLoadHooks({
cacheHit: true,
camera: camera.value,
@@ -714,7 +740,7 @@ async function loadModel() {
vrmGroup: reusableInstance.group,
})
emit('sceneBootstrap', buildSceneBootstrap(reusableInstance.vrm, true))
commitManagedVrmInstance(reusableInstance)
commitManagedVrmInstance(reusableInstance, currentLoadReason)
didCommitLoad = true
if (isStageThreeRuntimeTraceEnabled()) {
@@ -817,10 +843,6 @@ async function loadModel() {
injectDiffuseIBL(mat)
}
// MToon material sky box lightProbe setting
if (!airiIblProbe && scene.value)
airiIblProbe = createIblProbeController(scene.value)
// Material traverse setting
_vrm.scene.traverse((child) => {
if (child instanceof Mesh && child.material) {
@@ -858,10 +880,6 @@ async function loadModel() {
}
})
if (currentLoadReason === 'model-switch') {
componentCleanUp('model-switch', { invalidate: false })
}
emit('sceneBootstrap', buildSceneBootstrap(_vrm, false))
const nextInteractionColliders = createVrmInteractionColliders(_vrm)
@@ -872,7 +890,7 @@ async function loadModel() {
interactionColliders: nextInteractionColliders,
mixer: nextVrmAnimationMixer,
vrm: _vrm,
}))
}), currentLoadReason)
didCommitLoad = true
if (isStageThreeRuntimeTraceEnabled()) {
@@ -951,19 +969,13 @@ onMounted(async () => {
}, { immediate: true })
// update model position
watch(modelOffset, () => {
if (vrmGroup.value) {
vrmGroup.value.position.set(
modelOffset.value.x,
modelOffset.value.y,
modelOffset.value.z,
)
}
if (vrmGroup.value)
applyModelTransform(vrmGroup.value)
}, { immediate: true, deep: true })
// update model rotation
watch(modelRotationY, (newRotationY) => {
if (vrmGroup.value) {
vrmGroup.value.rotation.y = MathUtils.degToRad(newRotationY)
}
watch(modelRotationY, () => {
if (vrmGroup.value)
applyModelTransform(vrmGroup.value)
}, { immediate: true })
// update NPR sky box
watch([envSelect, skyBoxIntensity, nprIrrSH], async () => {
@@ -1003,7 +1015,7 @@ onMounted(async () => {
intensity: skyBoxIntensity.value,
sh: nprIrrSH.value ?? null,
})
airiIblProbe?.update(mode, skyBoxIntensity.value, nprIrrSH.value ?? null)
updateIblProbe(mode)
}, { immediate: true })
watch(focusPos, (newPos) => {
idleEyeSaccades.instantUpdate(vrm.value, newPos)
@@ -56,6 +56,8 @@ const props = withDefaults(defineProps<{
audioContext?: AudioContext
currentAudioSource?: AudioBufferSourceNode
cursorPosition?: { x: number, y: number }
/** Stable display model identity. Runtime resource URLs can change across reloads. */
modelId: string
modelSrc?: string
skyBoxSrc?: string
/**
@@ -90,6 +92,10 @@ const emit = defineEmits<{
}>()
type ModelPhase = 'no-model' | 'loading' | 'ready' | 'error'
interface ModelLoadIdentity {
modelId: string
modelSrc: string
}
type SceneTracePhaseCause
= | 'binding:complete'
| 'binding:start'
@@ -118,7 +124,7 @@ const {
scenePhase,
sceneTransactionDepth,
lastCommittedModelSrc,
lastCommittedModelId,
modelSize,
modelOrigin,
modelOffset,
@@ -167,7 +173,11 @@ const latestScenePhaseTraceCause = ref<SceneTracePhaseCause>('props:model-src')
const latestSceneTransactionReason = ref<SceneTraceTransactionReason>('unknown')
const activeModelSrc = ref<string>()
const bindingRevision = ref(0)
const pendingCommittedModelSrc = ref<string>()
// A selection ID can change while its URL is still resolving. The URL change owns
// the request snapshot, so an in-flight load keeps the ID that requested its URL.
const requestedModelIdentity = shallowRef<ModelLoadIdentity>()
const loadingModelIdentity = shallowRef<ModelLoadIdentity>()
const pendingCommittedModelIdentity = shallowRef<ModelLoadIdentity>()
const pendingCommittedModelRevision = ref<number>()
const pendingSceneBootstrap = shallowRef<SceneBootstrap>()
@@ -255,12 +265,13 @@ function toVec3(value: Vector3): Vec3 {
}
function clearPendingCommittedModel() {
pendingCommittedModelSrc.value = undefined
pendingCommittedModelIdentity.value = undefined
pendingCommittedModelRevision.value = undefined
}
function invalidateBindingRevision() {
bindingRevision.value += 1
loadingModelIdentity.value = undefined
clearPendingCommittedModel()
}
@@ -372,23 +383,25 @@ function setScenePhaseWithTrace(phase: ScenePhase, cause: SceneTracePhaseCause)
setScenePhase(phase)
}
function commitLastCommittedModelSrc(expectedRevision: number, nextPhase: ScenePhase) {
function commitLastCommittedModelId(expectedRevision: number, nextPhase: ScenePhase) {
if (nextPhase !== 'mounted')
return
if (expectedRevision !== bindingRevision.value)
return
if (!pendingCommittedModelSrc.value || pendingCommittedModelRevision.value !== expectedRevision)
const completedModel = pendingCommittedModelIdentity.value
if (!completedModel || pendingCommittedModelRevision.value !== expectedRevision)
return
if (!activeModelSrc.value || pendingCommittedModelSrc.value !== activeModelSrc.value)
if (!activeModelSrc.value || completedModel.modelSrc !== activeModelSrc.value)
return
if (props.modelSrc !== activeModelSrc.value)
const activeRequest = requestedModelIdentity.value
if (activeRequest?.modelId !== completedModel.modelId || activeRequest.modelSrc !== completedModel.modelSrc)
return
lastCommittedModelSrc.value = pendingCommittedModelSrc.value
lastCommittedModelId.value = completedModel.modelId
clearPendingCommittedModel()
}
@@ -446,7 +459,7 @@ async function completeSceneBinding(expectedRevision = bindingRevision.value) {
const nextPhase = resolveScenePhaseAfterBinding()
setScenePhaseWithTrace(nextPhase, 'binding:complete')
commitLastCommittedModelSrc(expectedRevision, nextPhase)
commitLastCommittedModelId(expectedRevision, nextPhase)
}
finally {
isCompletingBinding.value = false
@@ -470,6 +483,7 @@ function onVRMModelLoadStart(reason: VrmLifecycleReason) {
modelPhase.value = 'loading'
pendingSceneBootstrap.value = undefined
beginSceneBindingCycle(toSceneLoadTransactionReason(reason))
loadingModelIdentity.value = requestedModelIdentity.value
}
function onVRMSceneBootstrap(value: SceneBootstrap) {
@@ -478,8 +492,12 @@ function onVRMSceneBootstrap(value: SceneBootstrap) {
function onVRMModelLoaded(value: string) {
activeModelSrc.value = value
pendingCommittedModelSrc.value = value
const completedModel = loadingModelIdentity.value
pendingCommittedModelIdentity.value = completedModel?.modelSrc === value
? completedModel
: undefined
pendingCommittedModelRevision.value = bindingRevision.value
loadingModelIdentity.value = undefined
modelPhase.value = 'ready'
void completeSceneBinding(bindingRevision.value)
}
@@ -620,6 +638,12 @@ function applyVrmFrameRuntimeHook() {
modelRef.value?.setVrmFrameHook(vrmFrameRuntimeHook.value)
}
watch(() => props.modelSrc, (modelSrc) => {
requestedModelIdentity.value = modelSrc
? { modelId: props.modelId, modelSrc }
: undefined
}, { flush: 'sync', immediate: true })
watch(() => props.modelSrc, (modelSrc) => {
modelPhase.value = modelSrc ? 'loading' : 'no-model'
@@ -848,8 +872,9 @@ defineExpose({
:audio-context="props.audioContext"
:current-audio-source="props.currentAudioSource"
:cursor-position="props.cursorPosition"
:last-committed-model-src="lastCommittedModelSrc"
:model-src="props.modelSrc"
:last-committed-model-id="lastCommittedModelId"
:model-id="requestedModelIdentity?.modelId ?? props.modelId"
:model-src="requestedModelIdentity?.modelSrc"
:idle-animation="props.idleAnimation"
:paused="props.paused"
:env-select="envSelect"
@@ -138,8 +138,26 @@ export const useModelStore = defineStore('modelStore', () => {
sceneTransactionDepth.value = 0
}
// === Legacy / shared controls ===
const lastCommittedModelSrc = useLocalStorage('settings/stage-ui-three/lastModelSrc', '')
// === Model identity ===
// The display model ID is stable across application restarts. Runtime URLs are not.
const lastCommittedModelId = useLocalStorage('settings/stage-ui-three/lastModelId', '')
/** The storage key from releases that used runtime URLs as model identity. */
const legacyLastCommittedModelSrcStorageKey = 'settings/stage-ui-three/lastModelSrc'
/**
* Clears model identity from releases that stored only a runtime URL.
*
* A regenerated Blob URL cannot identify the persisted file that owns the saved view.
* The first VRM load after this reset establishes the stable model ID for later starts.
*/
function resetLegacyModelIdentity() {
const legacyModelSrc = window.localStorage.getItem(legacyLastCommittedModelSrcStorageKey)
if (!legacyModelSrc)
return
lastCommittedModelId.value = ''
window.localStorage.removeItem(legacyLastCommittedModelSrcStorageKey)
}
// === Model lifecycle / bootstrap ===
// These values are recalculated from the currently bound model instance whenever
@@ -156,7 +174,8 @@ export const useModelStore = defineStore('modelStore', () => {
scenePhase.value = 'pending'
sceneTransactionDepth.value = 0
lastCommittedModelSrc.value = ''
lastCommittedModelId.value = ''
window.localStorage.removeItem(legacyLastCommittedModelSrcStorageKey)
modelSize.value = { x: 0, y: 0, z: 0 }
modelOrigin.value = { x: 0, y: 0, z: 0 }
modelRotationY.value = 0
@@ -210,7 +229,7 @@ export const useModelStore = defineStore('modelStore', () => {
sceneTransactionDepth,
sceneMutationLocked,
lastCommittedModelSrc,
lastCommittedModelId,
modelSize,
modelOrigin,
@@ -250,6 +269,7 @@ export const useModelStore = defineStore('modelStore', () => {
beginSceneBindingTransaction,
endSceneBindingTransaction,
resetSceneBindingTransactions,
resetLegacyModelIdentity,
resetModelStore,
}
@@ -217,7 +217,7 @@ const cursorPosition = computed(() => ({
</template>
<template v-if="stageModelRenderer === 'vrm'">
<div :class="vrmSceneClassList">
<ThreeScene ref="vrmSceneRef" :cursor-position="cursorPosition" :model-src="stageModelSelectedUrl" />
<ThreeScene ref="vrmSceneRef" :cursor-position="cursorPosition" :model-id="stageModelSelected" :model-src="stageModelSelectedUrl" />
</div>
</template>
<template v-if="stageModelRenderer === 'spine'">
@@ -1108,6 +1108,7 @@ defineExpose({
ref="vrmViewerRef"
v-model:state="componentState"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:model-id="stageModelSelected"
:model-src="stageModelSelectedUrl"
:cursor-position="cursorPosition"
:idle-animation="animations.idleLoop.toString()"
@@ -1,16 +1,27 @@
import type { DisplayModelURL } from '../display-models'
import { createPinia, setActivePinia } from 'pinia'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { DisplayModelFormat, useDisplayModelsStore } from '../display-models'
import { useSettingsStageModel } from './stage-model'
const { initialStageModelId, resetLegacyModelIdentity } = vi.hoisted(() => ({
initialStageModelId: { value: 'preset-live2d-1' },
resetLegacyModelIdentity: vi.fn(),
}))
vi.mock('@proj-airi/stage-ui-three', () => ({
useModelStore: () => ({ resetLegacyModelIdentity }),
}))
vi.mock('@proj-airi/stage-shared/composables', async () => {
const { refManualReset } = await import('@vueuse/core')
return {
useLocalStorageManualReset: (_key: string, value: string) => refManualReset(value),
useLocalStorageManualReset: (key: string, value: string) => refManualReset(
key === 'settings/stage/model' ? initialStageModelId.value : value,
),
}
})
@@ -26,6 +37,12 @@ vi.mock('@vueuse/core', async (importOriginal) => {
describe('settings stage model store', () => {
beforeEach(() => {
setActivePinia(createPinia())
initialStageModelId.value = 'preset-live2d-1'
resetLegacyModelIdentity.mockReset()
})
afterEach(() => {
vi.unstubAllGlobals()
})
// https://github.com/moeru-ai/airi/issues/1984
@@ -59,6 +76,7 @@ describe('settings stage model store', () => {
expect(store.stageModelRenderer).toBe('live2d')
expect(getDisplayModelSpy).toHaveBeenCalledWith('display-model-missing')
expect(getDisplayModelSpy).toHaveBeenCalledWith(fallbackModel.id)
expect(resetLegacyModelIdentity).not.toHaveBeenCalled()
})
it('routes Tachie archives to the Tachie renderer', async () => {
@@ -73,13 +91,44 @@ describe('settings stage model store', () => {
const displayModelsStore = useDisplayModelsStore()
vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue(tachieModel)
vi.stubGlobal('window', {})
initialStageModelId.value = tachieModel.id
const store = useSettingsStageModel()
store.stageModelSelected = tachieModel.id
await store.initializeStageModel()
expect(store.stageModelSelectedDisplayModel).toEqual(tachieModel)
expect(store.stageModelSelectedUrl).toBe(tachieModel.url)
expect(store.stageModelRenderer).toBe('tachie')
expect(resetLegacyModelIdentity).toHaveBeenCalledOnce()
expect(resetLegacyModelIdentity).toHaveBeenCalledWith()
})
it('resets the legacy model identity before publishing the startup model', async () => {
const vrmModel: DisplayModelURL = {
id: 'vrm-model',
format: DisplayModelFormat.VRM,
type: 'url',
url: 'https://example.com/character.vrm',
name: 'VRM character',
importedAt: 1,
}
const displayModelsStore = useDisplayModelsStore()
vi.spyOn(displayModelsStore, 'getDisplayModel').mockResolvedValue(vrmModel)
vi.stubGlobal('window', {})
initialStageModelId.value = vrmModel.id
const store = useSettingsStageModel()
resetLegacyModelIdentity.mockImplementationOnce(() => {
expect(store.stageModelRenderer).toBeUndefined()
expect(store.stageModelSelectedUrl).toBeUndefined()
})
await store.initializeStageModel()
expect(resetLegacyModelIdentity).toHaveBeenCalledWith()
expect(store.stageModelRenderer).toBe('vrm')
expect(store.stageModelSelectedUrl).toBe(vrmModel.url)
})
})
@@ -38,6 +38,7 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
const stageModelSelectionStore = useStageModelSelectionStore()
const { selected: stageModelSelectedState } = storeToRefs(stageModelSelectionStore)
let stageModelUpdateSequence = 0
let legacyModelIdentityResetPromise: Promise<void> | undefined
const defaultStageModelId = 'preset-live2d-1'
const stageModelSelected = computed<string>({
get: () => stageModelSelectedState.value,
@@ -88,10 +89,30 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
}
}
function resetLegacyModelIdentity() {
if (typeof window === 'undefined')
return undefined
// The Three.js store is browser-only. Load it only during browser startup so
// Node consumers of the shared settings store do not evaluate rendering APIs.
legacyModelIdentityResetPromise ??= import('@proj-airi/stage-ui-three').then(({ useModelStore }) => {
useModelStore().resetLegacyModelIdentity()
})
return legacyModelIdentityResetPromise
}
async function updateStageModel() {
const requestId = ++stageModelUpdateSequence
const selectedModelId = stageModelSelectedState.value
const legacyModelIdentityReset = resetLegacyModelIdentity()
if (legacyModelIdentityReset) {
await legacyModelIdentityReset
if (requestId !== stageModelUpdateSequence)
return
}
if (!selectedModelId) {
replaceStageModelUrl(undefined)
stageModelSelectedDisplayModel.value = undefined
@@ -120,24 +141,26 @@ export const useSettingsStageModel = defineStore('settings-stage-model', () => {
return
}
const builtInRenderer = resolveBuiltInStageModelRenderer(model)
stageModelBuiltInRenderer.value = builtInRenderer
if (stageModelRenderer.value !== 'godot')
stageModelRenderer.value = builtInRenderer
let nextUrl: string
if (model.type === 'file') {
const nextUrl = URL.createObjectURL(model.file)
nextUrl = URL.createObjectURL(model.file)
if (requestId !== stageModelUpdateSequence) {
URL.revokeObjectURL(nextUrl)
return
}
replaceStageModelUrl(nextUrl)
}
else {
replaceStageModelUrl(model.url)
nextUrl = model.url
}
const builtInRenderer = resolveBuiltInStageModelRenderer(model)
// Browser startup consumes the one-time legacy reset before these refs publish.
// Direct ThreeScene routes mount from the refs and cannot start with stale identity state.
stageModelBuiltInRenderer.value = builtInRenderer
if (stageModelRenderer.value !== 'godot')
stageModelRenderer.value = builtInRenderer
replaceStageModelUrl(nextUrl)
stageModelSelectedDisplayModel.value = model
}