diff --git a/packages/i18n/src/locales/en/stage.yaml b/packages/i18n/src/locales/en/stage.yaml index 290eea9e2..1f30b332a 100644 --- a/packages/i18n/src/locales/en/stage.yaml +++ b/packages/i18n/src/locales/en/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/es/stage.yaml b/packages/i18n/src/locales/es/stage.yaml index cc35aaaeb..b41442bb9 100644 --- a/packages/i18n/src/locales/es/stage.yaml +++ b/packages/i18n/src/locales/es/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/fr/stage.yaml b/packages/i18n/src/locales/fr/stage.yaml index 367bfd3c6..46562a7be 100644 --- a/packages/i18n/src/locales/fr/stage.yaml +++ b/packages/i18n/src/locales/fr/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/ja/stage.yaml b/packages/i18n/src/locales/ja/stage.yaml index f6dd1338b..46a6236d5 100644 --- a/packages/i18n/src/locales/ja/stage.yaml +++ b/packages/i18n/src/locales/ja/stage.yaml @@ -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: キーを送信 diff --git a/packages/i18n/src/locales/ko/stage.yaml b/packages/i18n/src/locales/ko/stage.yaml index a217d8766..e703dc111 100644 --- a/packages/i18n/src/locales/ko/stage.yaml +++ b/packages/i18n/src/locales/ko/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/ru/stage.yaml b/packages/i18n/src/locales/ru/stage.yaml index 69ba1dbe0..83ae33028 100644 --- a/packages/i18n/src/locales/ru/stage.yaml +++ b/packages/i18n/src/locales/ru/stage.yaml @@ -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: Отправить ключ diff --git a/packages/i18n/src/locales/vi/stage.yaml b/packages/i18n/src/locales/vi/stage.yaml index c8d59b3fa..3ff0e51fc 100644 --- a/packages/i18n/src/locales/vi/stage.yaml +++ b/packages/i18n/src/locales/vi/stage.yaml @@ -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 diff --git a/packages/i18n/src/locales/zh-Hans/stage.yaml b/packages/i18n/src/locales/zh-Hans/stage.yaml index 278664784..237003e28 100644 --- a/packages/i18n/src/locales/zh-Hans/stage.yaml +++ b/packages/i18n/src/locales/zh-Hans/stage.yaml @@ -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: 发送键值 diff --git a/packages/i18n/src/locales/zh-Hant/stage.yaml b/packages/i18n/src/locales/zh-Hant/stage.yaml index 34ba92c5e..bdaecbdaf 100644 --- a/packages/i18n/src/locales/zh-Hant/stage.yaml +++ b/packages/i18n/src/locales/zh-Hant/stage.yaml @@ -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: 傳送按鍵 diff --git a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue index eff476fbe..989206826 100644 --- a/packages/stage-ui-live2d/src/components/scenes/Live2D.vue +++ b/packages/stage-ui-live2d/src/components/scenes/Live2D.vue @@ -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)" > diff --git a/packages/stage-ui-live2d/src/components/scenes/live2d/Canvas.vue b/packages/stage-ui-live2d/src/components/scenes/live2d/Canvas.vue index 1e86fb552..0229edd4a 100644 --- a/packages/stage-ui-live2d/src/components/scenes/live2d/Canvas.vue +++ b/packages/stage-ui-live2d/src/components/scenes/live2d/Canvas.vue @@ -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() @@ -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) } diff --git a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts index d0a9b89b1..c8cb57721 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.test.ts @@ -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') diff --git a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts index 49dff15ca..536daf965 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-zip-loader.ts @@ -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) { diff --git a/packages/stage-ui/src/components/scenes/Stage.vue b/packages/stage-ui/src/components/scenes/Stage.vue index 764bc8ca7..113c4cac2 100644 --- a/packages/stage-ui/src/components/scenes/Stage.vue +++ b/packages/stage-ui/src/components/scenes/Stage.vue @@ -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() 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" /> + + diff --git a/packages/stage-ui/src/components/scenes/stage-render-error.vue b/packages/stage-ui/src/components/scenes/stage-render-error.vue new file mode 100644 index 000000000..0d67a8be1 --- /dev/null +++ b/packages/stage-ui/src/components/scenes/stage-render-error.vue @@ -0,0 +1,102 @@ + + + + + + + + {{ t('stage.render-error.title', { renderer }) }} + + + {{ t('stage.render-error.description') }} + + + + + + + {{ t('stage.chat.actions.retry') }} + + + + + +
+ {{ t('stage.render-error.description') }} +