fix(stage-ui-three): preserve imported VRM view settings (#2423)
This commit is contained in:
@@ -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
@@ -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,
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user