fix(stage-ui-live2d): recover from model render failures (#2225)

This commit is contained in:
Neko
2026-08-05 20:28:39 +08:00
committed by GitHub
parent 20d6158144
commit 55ea1767e4
15 changed files with 293 additions and 3 deletions
+7
View File
@@ -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
+7
View File
@@ -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
+7
View File
@@ -14,6 +14,13 @@ chat:
cloud-badge: Synchronisé dans le cloud
new-chat-fallback: Nouvelle conversation
delete: Supprimer conversation
render-error:
title: '{renderer} na pas pu afficher ce modèle'
description: Le modèle est sélectionné, mais la scène na pas pu lafficher.
report-description: |-
{renderer} na 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
+7
View File
@@ -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: キーを送信
+7
View File
@@ -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
+7
View File
@@ -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: Отправить ключ
+7
View File
@@ -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>