fix(stage-ui,stage-web,stage-tamagotchi): capture imported Live2D models in Polaroid (#2320)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
})),
|
||||
],
|
||||
},
|
||||
})
|
||||
|
||||
@@ -204,7 +204,15 @@ async function loadModel() {
|
||||
await until(modelLoading).not.toBeTruthy()
|
||||
|
||||
await modelLoadMutex.acquire()
|
||||
try {
|
||||
await performModelLoad()
|
||||
}
|
||||
finally {
|
||||
modelLoadMutex.release()
|
||||
}
|
||||
}
|
||||
|
||||
async function performModelLoad() {
|
||||
modelLoading.value = true
|
||||
componentState.value = 'loading'
|
||||
|
||||
@@ -447,7 +455,6 @@ async function loadModel() {
|
||||
await initExpressionController(internalModelRef.value).catch((err) => {
|
||||
console.warn('[Model.vue] Expression controller initialization failed:', err)
|
||||
})
|
||||
modelLoadMutex.release()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@
|
||||
"./tools/mcp": "./src/tools/mcp.ts",
|
||||
"./stores/character": "./src/stores/character/index.ts",
|
||||
"./stores/settings/analytics": "./src/stores/settings/analytics.ts",
|
||||
"./stores/settings/stage-model": "./src/stores/settings/stage-model.ts",
|
||||
"./stores/settings": "./src/stores/settings/index.ts",
|
||||
"./stores/modules/vision": "./src/stores/modules/vision/index.ts",
|
||||
"./stores/mcp-tool-bridge": "./src/stores/mcp-tool-bridge.ts",
|
||||
|
||||
Generated
+3
@@ -1493,6 +1493,9 @@ importers:
|
||||
'@proj-airi/stage-ui':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui
|
||||
'@proj-airi/stage-ui-live2d':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-live2d
|
||||
'@proj-airi/stage-ui-three':
|
||||
specifier: workspace:^
|
||||
version: link:../../packages/stage-ui-three
|
||||
|
||||
Reference in New Issue
Block a user