fix(stage-ui,stage-web,stage-tamagotchi): capture imported Live2D models in Polaroid (#2320)

This commit is contained in:
Neko
2026-08-19 14:17:03 +08:00
committed by GitHub
parent 5c0916baa3
commit 5e95e56d62
10 changed files with 196 additions and 12 deletions
+1
View File
@@ -44,6 +44,7 @@
"@proj-airi/stage-layouts": "workspace:^",
"@proj-airi/stage-shared": "workspace:^",
"@proj-airi/stage-ui": "workspace:^",
"@proj-airi/stage-ui-live2d": "workspace:^",
"@proj-airi/stage-ui-three": "workspace:^",
"@proj-airi/stream-kit": "workspace:^",
"@proj-airi/ui": "workspace:^",
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui-live2d/components/scenes/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Screen } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
@@ -8,8 +8,8 @@ import { ref, watch } from 'vue'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const live2dModelRef = ref<InstanceType<typeof Live2DModel>>()
const settingsStore = useSettings()
const { stageModelSelectedUrl } = storeToRefs(settingsStore)
const settingsStore = useSettingsStageModel()
const { stageModelSelected, stageModelSelectedUrl } = storeToRefs(settingsStore)
const motion = ref<string>('idle')
const motionGroupsList = ref<{
motionName: string
@@ -65,6 +65,7 @@ function handleShot() {
>
<Live2DModel
ref="live2dModelRef"
:model-id="stageModelSelected"
:model-src="stageModelSelectedUrl"
:app="app"
:width="width"
+2
View File
@@ -15,6 +15,8 @@
"dev:https": "vite --host -- --mkcert",
"lint": "eslint .",
"preview": "vite preview",
"test": "vitest",
"test:run": "vitest run",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
@@ -0,0 +1,129 @@
import type { Component } from 'vue'
import { DisplayModelFormat, useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { createPinia } from 'pinia'
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest'
import { createApp } from 'vue'
import PocketPolaroid from '../../../../stage-pocket/src/pages/devtools/polaroid.vue'
import WebPolaroid from './polaroid.vue'
import 'virtual:uno.css'
let presetArchive: Blob
const layoutStyle = document.createElement('style')
beforeAll(async () => {
layoutStyle.textContent = `
.polaroid-browser-test {
height: 700px;
overflow: hidden;
width: 800px;
}
.polaroid-browser-test > div {
height: 700px !important;
width: 800px !important;
}
.polaroid-browser-test > div > div:first-child {
flex: none !important;
height: 600px !important;
width: 800px !important;
}
`
document.head.appendChild(layoutStyle)
await import('@proj-airi/stage-ui-live2d/utils/live2d-zip-loader')
await import('@proj-airi/stage-ui-live2d/utils/live2d-opfs-registration')
const pinia = createPinia()
const displayModels = useDisplayModelsStore(pinia)
const preset = await displayModels.getDisplayModel('preset-live2d-2')
if (preset?.type !== 'url')
throw new Error('The Live2D test preset is unavailable.')
const response = await fetch(preset.url)
if (!response.ok)
throw new Error(`Failed to load the Live2D test preset: ${response.status}`)
presetArchive = await response.blob()
})
beforeEach(() => {
localStorage.removeItem('settings/stage/model')
})
afterEach(() => {
localStorage.removeItem('settings/stage/model')
})
afterAll(() => {
layoutStyle.remove()
})
async function renderPolaroid(component: Component, modelId: string) {
const pinia = createPinia()
const displayModels = useDisplayModelsStore(pinia)
const settings = useSettingsStageModel(pinia)
const file = new File([presetArchive], 'imported-live2d.zip', { type: 'application/zip' })
const container = document.createElement('div')
container.className = 'polaroid-browser-test'
document.body.appendChild(container)
// NOTICE:
// Keep each 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 config used by this browser test.
// Removal condition: Vue DevTools supports component-test app teardown.
const app = createApp(component)
app.use(pinia)
app.mount(container)
displayModels.displayModels.unshift({
id: modelId,
format: DisplayModelFormat.Live2dZip,
type: 'file',
file,
name: file.name,
importedAt: Date.now(),
})
settings.stageModelSelected = modelId
await settings.updateStageModel()
await expect.poll(() => settings.stageModelSelectedUrl).toMatch(/^blob:/)
return container
}
async function expectImportedModelPhoto(component: Component, modelId: string) {
const container = await renderPolaroid(component, modelId)
await expect.poll(() => container.querySelector('option'), { timeout: 20_000 }).toBeInstanceOf(HTMLOptionElement)
const canvas = container.querySelector('canvas')
if (!(canvas instanceof HTMLCanvasElement))
throw new TypeError('Polaroid did not render a canvas.')
expect(canvas.toDataURL('image/png')).toMatch(/^data:image\/png;base64,./)
}
describe('polaroid imported Live2D model', () => {
it('loads and captures an imported model on the web page', async () => {
// ROOT CAUSE:
//
// Polaroid passed only the object URL to the Live2D loader. An imported
// model uses a blob URL without the .zip suffix, so the loader also needs
// the selected display model ID to resolve the archive from OPFS.
//
// The loader also kept its mutex locked when the first render had no model
// source. We fixed both paths by passing the selected ID and releasing the
// mutex across every early return.
await expectImportedModelPhoto(WebPolaroid, 'display-model-polaroid-web')
})
it('loads and captures an imported model on the pocket page', async () => {
await expectImportedModelPhoto(PocketPolaroid, 'display-model-polaroid-pocket')
})
})
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui-live2d/components/scenes/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { Screen } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
@@ -8,8 +8,8 @@ import { ref, watch } from 'vue'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const live2dModelRef = ref<InstanceType<typeof Live2DModel>>()
const settingsStore = useSettings()
const { stageModelSelectedUrl } = storeToRefs(settingsStore)
const settingsStore = useSettingsStageModel()
const { stageModelSelected, stageModelSelectedUrl } = storeToRefs(settingsStore)
const motion = ref<string>('idle')
const motionGroupsList = ref<{
motionName: string
@@ -65,6 +65,7 @@ function handleShot() {
>
<Live2DModel
ref="live2dModelRef"
:model-id="stageModelSelected"
:model-src="stageModelSelectedUrl"
:app="app"
:width="width"
@@ -0,0 +1,12 @@
if (!('Live2DCubismCore' in window)) {
// eslint-disable-next-line antfu/no-top-level-await
await new Promise<void>((resolve, reject) => {
const script = document.createElement('script')
script.src = '/assets/js/CubismSdkForWeb-5-r.3/Core/live2dcubismcore.min.js'
script.addEventListener('load', () => resolve(), { once: true })
script.addEventListener('error', () => reject(new Error('Failed to load the Live2D Cubism runtime.')), { once: true })
document.head.appendChild(script)
})
}
export {}
+30 -3
View File
@@ -1,11 +1,38 @@
import Vue from '@vitejs/plugin-vue'
import { defineConfig } from 'vitest/config'
import { playwright } from '@vitest/browser-playwright'
import { defineConfig, mergeConfig } from 'vitest/config'
import stageWebConfig from './vite.config'
export default defineConfig({
plugins: [Vue()],
test: {
environment: 'jsdom',
include: ['src/**/*.test.ts'],
projects: [
{
extends: true,
test: {
name: 'unit',
environment: 'jsdom',
include: ['src/**/*.test.ts'],
exclude: ['src/**/*.browser.test.ts'],
},
},
mergeConfig(stageWebConfig, defineConfig({
test: {
name: 'browser',
include: ['src/**/*.browser.test.ts'],
setupFiles: ['./src/test/setup-live2d.browser.ts'],
browser: {
enabled: true,
headless: true,
provider: playwright(),
instances: [
{ browser: 'chromium' },
],
},
},
})),
],
},
})