fix(stage-ui-live2d): recover from model render failures (#2225)
This commit is contained in:
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Synced to cloud
|
||||
new-chat-fallback: New chat
|
||||
delete: Delete conversation
|
||||
render-error:
|
||||
title: '{renderer} could not render this model'
|
||||
description: The model is selected, but the Stage could not display it.
|
||||
report-description: |-
|
||||
{renderer} failed to render the selected model.
|
||||
Model ID: {modelId}
|
||||
Error: {error}
|
||||
message: Say something...
|
||||
send-mode:
|
||||
title: Send key
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Sincronizado en la nube
|
||||
new-chat-fallback: Nuevo Chat
|
||||
delete: Eliminar conversación
|
||||
render-error:
|
||||
title: '{renderer} no pudo renderizar este modelo'
|
||||
description: El modelo está seleccionado, pero el escenario no pudo mostrarlo.
|
||||
report-description: |-
|
||||
{renderer} no pudo renderizar el modelo seleccionado.
|
||||
ID del modelo: {modelId}
|
||||
Error: {error}
|
||||
message: Dile algo...
|
||||
send-mode:
|
||||
title: Enviar tecla
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Synchronisé dans le cloud
|
||||
new-chat-fallback: Nouvelle conversation
|
||||
delete: Supprimer conversation
|
||||
render-error:
|
||||
title: '{renderer} n’a pas pu afficher ce modèle'
|
||||
description: Le modèle est sélectionné, mais la scène n’a pas pu l’afficher.
|
||||
report-description: |-
|
||||
{renderer} n’a pas pu afficher le modèle sélectionné.
|
||||
ID du modèle : {modelId}
|
||||
Erreur : {error}
|
||||
message: Dites quelque chose...
|
||||
send-mode:
|
||||
title: Envoyer la clef
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Synced to cloud
|
||||
new-chat-fallback: New chat
|
||||
delete: Delete conversation
|
||||
render-error:
|
||||
title: '{renderer} でこのモデルを描画できませんでした'
|
||||
description: モデルは選択されていますが、ステージに表示できませんでした。
|
||||
report-description: |-
|
||||
{renderer} で選択したモデルを描画できませんでした。
|
||||
モデル ID: {modelId}
|
||||
エラー: {error}
|
||||
message: 何か話しかけてみましょう…
|
||||
send-mode:
|
||||
title: キーを送信
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Synced to cloud
|
||||
new-chat-fallback: New chat
|
||||
delete: Delete conversation
|
||||
render-error:
|
||||
title: '{renderer}에서 이 모델을 렌더링할 수 없습니다'
|
||||
description: 모델이 선택되었지만 스테이지에 표시할 수 없습니다.
|
||||
report-description: |-
|
||||
{renderer}에서 선택한 모델을 렌더링하지 못했습니다.
|
||||
모델 ID: {modelId}
|
||||
오류: {error}
|
||||
message: 듣고 있어요...
|
||||
send-mode:
|
||||
title: Send key
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Синхронизировано с облаком
|
||||
new-chat-fallback: Новый чат
|
||||
delete: Удалить диалог
|
||||
render-error:
|
||||
title: '{renderer} не удалось отобразить эту модель'
|
||||
description: Модель выбрана, но сцена не смогла её отобразить.
|
||||
report-description: |-
|
||||
{renderer} не удалось отобразить выбранную модель.
|
||||
ID модели: {modelId}
|
||||
Ошибка: {error}
|
||||
message: Спроси что-нибудь
|
||||
send-mode:
|
||||
title: Отправить ключ
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: Đã đồng bộ đám mây
|
||||
new-chat-fallback: Cuộc trò chuyện mới
|
||||
delete: Xóa đoạn hội thoại
|
||||
render-error:
|
||||
title: '{renderer} không thể hiển thị mô hình này'
|
||||
description: Mô hình đã được chọn nhưng sân khấu không thể hiển thị mô hình đó.
|
||||
report-description: |-
|
||||
{renderer} không thể hiển thị mô hình đã chọn.
|
||||
ID mô hình: {modelId}
|
||||
Lỗi: {error}
|
||||
message: Nói gì đó...
|
||||
send-mode:
|
||||
title: Gửi key
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: 已同步到云
|
||||
new-chat-fallback: 新建对话
|
||||
delete: 删除对话
|
||||
render-error:
|
||||
title: '{renderer} 无法渲染这个模型'
|
||||
description: 模型已经选中,但舞台无法显示它。
|
||||
report-description: |-
|
||||
{renderer} 无法渲染选中的模型。
|
||||
模型 ID:{modelId}
|
||||
错误:{error}
|
||||
message: 说点什么...
|
||||
send-mode:
|
||||
title: 发送键值
|
||||
|
||||
@@ -14,6 +14,13 @@ chat:
|
||||
cloud-badge: 已同步至雲端
|
||||
new-chat-fallback: 新對話
|
||||
delete: 刪除對話
|
||||
render-error:
|
||||
title: '{renderer} 無法算繪這個模型'
|
||||
description: 模型已經選取,但舞台無法顯示它。
|
||||
report-description: |-
|
||||
{renderer} 無法算繪選取的模型。
|
||||
模型 ID:{modelId}
|
||||
錯誤:{error}
|
||||
message: 說點什麼...
|
||||
send-mode:
|
||||
title: 傳送按鍵
|
||||
|
||||
@@ -31,6 +31,10 @@ const props = withDefaults(defineProps<{
|
||||
themeColorsHueDynamic: false,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
const componentStateCanvas = defineModel<'pending' | 'loading' | 'mounted'>('canvasState', { default: 'pending' })
|
||||
const componentStateModel = defineModel<'pending' | 'loading' | 'mounted'>('modelState', { default: 'pending' })
|
||||
@@ -102,6 +106,7 @@ defineExpose({
|
||||
:resolution="live2dRenderScale"
|
||||
:max-fps="live2dMaxFps"
|
||||
max-h="100dvh"
|
||||
@error="emit('error', $event)"
|
||||
>
|
||||
<Live2DModel
|
||||
ref="live2dModelRef"
|
||||
@@ -125,6 +130,7 @@ defineExpose({
|
||||
:live2d-force-auto-blink-enabled="live2dForceAutoBlinkEnabled"
|
||||
:live2d-expression-enabled="live2dExpressionEnabled"
|
||||
:live2d-shadow-enabled="live2dShadowEnabled"
|
||||
@error="emit('error', $event)"
|
||||
/>
|
||||
</Live2DCanvas>
|
||||
</Screen>
|
||||
|
||||
@@ -15,6 +15,10 @@ const props = withDefaults(defineProps<{
|
||||
maxFps: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
error: [error: Error]
|
||||
}>()
|
||||
|
||||
const componentState = defineModel<'pending' | 'loading' | 'mounted'>('state', { default: 'pending' })
|
||||
|
||||
const containerRef = ref<HTMLDivElement>()
|
||||
@@ -37,6 +41,7 @@ function installRenderGuard(app: Application) {
|
||||
catch (error) {
|
||||
console.error('[Live2D] Pixi render error.', error)
|
||||
app.ticker.stop()
|
||||
emit('error', error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,7 +102,18 @@ watch(() => props.maxFps, (limit) => {
|
||||
pixiApp.value.ticker.maxFPS = resolveMaxFps(limit)
|
||||
})
|
||||
|
||||
onMounted(async () => containerRef.value && await initLive2DPixiStage(containerRef.value))
|
||||
onMounted(async () => {
|
||||
if (!containerRef.value)
|
||||
return
|
||||
|
||||
try {
|
||||
await initLive2DPixiStage(containerRef.value)
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Live2D] Failed to initialize Pixi stage.', error)
|
||||
emit('error', error instanceof Error ? error : new Error(String(error)))
|
||||
}
|
||||
})
|
||||
onUnmounted(() => pixiApp.value?.destroy())
|
||||
|
||||
async function captureFrame() {
|
||||
@@ -110,6 +126,7 @@ async function captureFrame() {
|
||||
}
|
||||
catch (error) {
|
||||
console.error('[Live2D] Pixi render error during capture.', error)
|
||||
emit('error', error instanceof Error ? error : new Error(String(error)))
|
||||
return resolve(null)
|
||||
}
|
||||
|
||||
|
||||
@@ -58,6 +58,17 @@ function createCjkPathSettingsText(): string {
|
||||
})
|
||||
}
|
||||
|
||||
function createSpacePathSettingsText(): string {
|
||||
return JSON.stringify({
|
||||
Version: 3,
|
||||
FileReferences: {
|
||||
Moc: 'Avatar Model.moc3',
|
||||
Textures: ['Avatar Model.4096/texture 00.png'],
|
||||
},
|
||||
Groups: [],
|
||||
})
|
||||
}
|
||||
|
||||
const appleDoubleHeader = new Uint8Array([0, 5, 22, 7, 0, 2, 0, 0, 77, 97, 99, 32, 79, 83, 32, 88])
|
||||
|
||||
describe('live2d zip loader settings sanitization', () => {
|
||||
@@ -134,6 +145,47 @@ describe('live2d zip loader settings sanitization', () => {
|
||||
}
|
||||
})
|
||||
|
||||
it('loads a zip model whose settings and resources contain spaces', async () => {
|
||||
await import('./live2d-zip-loader')
|
||||
const { FileLoader, Live2DModel, ZipLoader } = await import('pixi-live2d-display/cubism4')
|
||||
|
||||
const zip = new JSZip()
|
||||
zip.file('Model Package/Avatar Model.model3.json', createSpacePathSettingsText())
|
||||
zip.file('Model Package/Avatar Model.moc3', new Uint8Array([77, 79, 67, 51]))
|
||||
zip.file('Model Package/Avatar Model.4096/texture 00.png', new Uint8Array([1, 2, 3]))
|
||||
|
||||
const zipBytes = await zip.generateAsync({ type: 'uint8array' })
|
||||
const reader = await JSZip.loadAsync(await blobFromBytes(zipBytes).arrayBuffer())
|
||||
const settings = await ZipLoader.createSettings(reader)
|
||||
const files = Object.assign(await ZipLoader.unzip(reader, settings), { settings })
|
||||
const objectUrl = `zip://test/${settings.url}`
|
||||
Object.assign(settings, { _objectURL: objectUrl })
|
||||
|
||||
// ROOT CAUSE:
|
||||
//
|
||||
// ModelSettings.resolveURL percent-encodes spaces while ZipLoader and OPFS preserve
|
||||
// decoded archive paths. FileLoader therefore rejects resources that are present.
|
||||
//
|
||||
// Model Package/Avatar Model.moc3
|
||||
// !== Model%20Package/Avatar%20Model.moc3
|
||||
//
|
||||
// We fixed this by comparing canonical decoded archive paths at the loader boundary.
|
||||
const context = {
|
||||
source: files,
|
||||
options: {},
|
||||
live2dModel: new Live2DModel(),
|
||||
}
|
||||
|
||||
try {
|
||||
await expect(FileLoader.factory(context, async () => {})).resolves.toBeUndefined()
|
||||
}
|
||||
finally {
|
||||
for (const resourceUrl of Object.values(FileLoader.filesMap[objectUrl] ?? {}))
|
||||
URL.revokeObjectURL(resourceUrl)
|
||||
delete FileLoader.filesMap[objectUrl]
|
||||
}
|
||||
})
|
||||
|
||||
it('loads a zip model when a macOS AppleDouble settings sidecar is present before the real settings file', async () => {
|
||||
await import('./live2d-zip-loader')
|
||||
const { ZipLoader } = await import('pixi-live2d-display/cubism4')
|
||||
|
||||
@@ -102,6 +102,29 @@ function sanitizeModelSettingsText(text: string): string {
|
||||
return JSON.stringify(json)
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalizes a resolved Live2D resource path to the decoded archive representation.
|
||||
*
|
||||
* @example
|
||||
* normalizeLive2DArchivePath('Model%20Package/Avatar%20Model.moc3')
|
||||
* // => 'Model Package/Avatar Model.moc3'
|
||||
*/
|
||||
function normalizeLive2DArchivePath(path: string): string {
|
||||
try {
|
||||
return decodeURI(path)
|
||||
}
|
||||
catch {
|
||||
// Malformed percent escapes cannot be URI-decoded and therefore represent a literal archive path.
|
||||
return path
|
||||
}
|
||||
}
|
||||
|
||||
function useArchivePathResolution(settings: ModelSettings): ModelSettings {
|
||||
const resolveURL = settings.resolveURL.bind(settings)
|
||||
settings.resolveURL = path => normalizeLive2DArchivePath(resolveURL(path))
|
||||
return settings
|
||||
}
|
||||
|
||||
function createModelSettings(text: string, url: string): ModelSettings {
|
||||
if (!text) {
|
||||
throw new Error(`Empty settings file: ${url}`)
|
||||
@@ -115,7 +138,7 @@ function createModelSettings(text: string, url: string): ModelSettings {
|
||||
throw new Error('Unknown settings JSON')
|
||||
}
|
||||
|
||||
return runtime.createModelSettings(settingsJSON)
|
||||
return useArchivePathResolution(runtime.createModelSettings(settingsJSON))
|
||||
}
|
||||
|
||||
export function isSettingsFile(file: string) {
|
||||
|
||||
@@ -27,7 +27,9 @@ import { useBroadcastChannel } from '@vueuse/core'
|
||||
// import { embed } from '@xsai/embed'
|
||||
import { generateSpeech } from '@xsai/generate-speech'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { computed, nextTick, onMounted, onUnmounted, ref, shallowRef, watch } from 'vue'
|
||||
|
||||
import StageRenderError from './stage-render-error.vue'
|
||||
|
||||
import { useSettingsLive2d } from '../../../../stage-ui-live2d/src/composables/live2d/live2d'
|
||||
import { useAnalytics } from '../../composables/use-analytics'
|
||||
@@ -133,8 +135,24 @@ const chatHookCleanups: Array<() => void> = []
|
||||
const providersStore = useProvidersStore()
|
||||
const live2dStore = useLive2dParams()
|
||||
const showStage = ref(true)
|
||||
const stageRenderError = shallowRef<Error>()
|
||||
const viewUpdateCleanups: Array<() => void> = []
|
||||
|
||||
function handleStageRenderError(error: Error) {
|
||||
stageRenderError.value = error
|
||||
}
|
||||
|
||||
async function retryStageRenderer() {
|
||||
stageRenderError.value = undefined
|
||||
showStage.value = false
|
||||
await nextTick()
|
||||
showStage.value = true
|
||||
}
|
||||
|
||||
watch([stageModelRenderer, stageModelSelected, stageModelSelectedUrl], () => {
|
||||
stageRenderError.value = undefined
|
||||
})
|
||||
|
||||
// Caption + Presentation broadcast channels
|
||||
type CaptionChannelEvent
|
||||
= | { type: 'caption-speaker', text: string }
|
||||
@@ -1075,6 +1093,7 @@ defineExpose({
|
||||
:live2d-shadow-enabled="live2dShadowEnabled"
|
||||
:live2d-max-fps="live2dMaxFps"
|
||||
:live2d-render-scale="live2dRenderScale"
|
||||
@error="handleStageRenderError"
|
||||
/>
|
||||
<ThreeScene
|
||||
v-if="stageModelRenderer === 'vrm' && showStage"
|
||||
@@ -1153,6 +1172,14 @@ defineExpose({
|
||||
</Callout>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<StageRenderError
|
||||
v-if="stageRenderError"
|
||||
:error="stageRenderError"
|
||||
renderer="Live2D"
|
||||
:model-id="stageModelSelected"
|
||||
@retry="retryStageRenderer"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
<script setup lang="ts">
|
||||
import type { BugReportDialogSubmitPayload } from '../scenarios/dialogs/bug-report/types'
|
||||
|
||||
import { errorMessageFrom } from '@moeru/std'
|
||||
import { Button, ContainerError } from '@proj-airi/ui'
|
||||
import { useClipboard } from '@vueuse/core'
|
||||
import { shallowRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import BugReportDialog from '../scenarios/dialogs/bug-report/bug-report-dialog.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
error: Error
|
||||
renderer: string
|
||||
modelId?: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
retry: []
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
const { copy, isSupported: isClipboardSupported } = useClipboard({ legacy: true })
|
||||
const showBugReportDialog = shallowRef(false)
|
||||
const bugReportDescription = shallowRef('')
|
||||
const bugReportSending = shallowRef(false)
|
||||
const bugReportSubmitError = shallowRef<unknown>()
|
||||
|
||||
function openBugReportDialog() {
|
||||
bugReportDescription.value = t('stage.render-error.report-description', {
|
||||
renderer: props.renderer,
|
||||
modelId: props.modelId ?? 'unknown',
|
||||
error: errorMessageFrom(props.error) ?? props.error.message,
|
||||
})
|
||||
bugReportSubmitError.value = undefined
|
||||
showBugReportDialog.value = true
|
||||
}
|
||||
|
||||
async function submitBugReport(payload: BugReportDialogSubmitPayload) {
|
||||
bugReportSending.value = true
|
||||
bugReportSubmitError.value = undefined
|
||||
|
||||
try {
|
||||
if (!isClipboardSupported.value)
|
||||
throw new Error('Clipboard API is unavailable')
|
||||
|
||||
await copy(payload.formattedReport)
|
||||
showBugReportDialog.value = false
|
||||
}
|
||||
catch (error) {
|
||||
bugReportSubmitError.value = error
|
||||
}
|
||||
finally {
|
||||
bugReportSending.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div
|
||||
:class="[
|
||||
'absolute inset-0 z-20 p-6',
|
||||
'flex items-center justify-center',
|
||||
'bg-white/45 dark:bg-black/45 backdrop-blur-sm',
|
||||
]"
|
||||
>
|
||||
<div
|
||||
:class="[
|
||||
'max-w-xl w-full',
|
||||
'flex flex-col gap-3',
|
||||
]"
|
||||
>
|
||||
<div :class="['flex flex-col gap-1 px-1']">
|
||||
<h2 :class="['text-lg font-semibold text-neutral-900 dark:text-neutral-100']">
|
||||
{{ t('stage.render-error.title', { renderer }) }}
|
||||
</h2>
|
||||
<p :class="['text-sm text-neutral-600 dark:text-neutral-300']">
|
||||
{{ t('stage.render-error.description') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ContainerError
|
||||
:error="error"
|
||||
:feedback-button-label="t('settings.dialogs.bug-report.trigger-label')"
|
||||
height-preset="lg"
|
||||
@feedback="openBugReportDialog"
|
||||
/>
|
||||
|
||||
<Button color="red" variant="secondary" @click="emit('retry')">
|
||||
{{ t('stage.chat.actions.retry') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<BugReportDialog
|
||||
v-model="showBugReportDialog"
|
||||
v-model:description="bugReportDescription"
|
||||
:sending="bugReportSending"
|
||||
:submit-error="bugReportSubmitError"
|
||||
@submit="submitBugReport"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user