diff --git a/packages/stage-ui/src/components/scenes/live2d/Model.vue b/packages/stage-ui/src/components/scenes/live2d/Model.vue index 434d20291..cfeca1513 100644 --- a/packages/stage-ui/src/components/scenes/live2d/Model.vue +++ b/packages/stage-ui/src/components/scenes/live2d/Model.vue @@ -73,6 +73,8 @@ function parsePropsOffset() { const modelSrcRef = toRef(() => props.modelSrc) const modelLoading = ref(false) +// NOTICE: boolean is sufficient; this flag is only used inside loadModel to bail out if the component unmounts mid-load. +let isUnmounted = false const offset = computed(() => parsePropsOffset()) @@ -154,13 +156,21 @@ async function loadModel() { modelLoading.value = true componentState.value = 'loading' - if (!pixiApp.value) { - modelLoading.value = false - componentState.value = 'mounted' - return + if (!pixiApp.value || !pixiApp.value.stage) { + try { + // NOTICE: shouldUpdateView can fire while the canvas (pixiApp) is being torn down/recreated. + // Wait briefly for the new stage instead of bailing out, otherwise we keep a blank screen. + await until(() => !!pixiApp.value && !!pixiApp.value.stage).toBeTruthy({ timeout: 1500 }) + } + catch { + modelLoading.value = false + componentState.value = 'mounted' + return + } } - if (model.value && pixiApp.value.stage) { + // REVIEW: here as await until(...) guarded the pixiApp and stage to be valid. + if (model.value && pixiApp.value?.stage) { try { pixiApp.value.stage.removeChild(model.value) model.value.destroy() @@ -178,6 +188,12 @@ async function loadModel() { } try { + if (isUnmounted) { + modelLoading.value = false + componentState.value = 'mounted' + return + } + const live2DModel = new Live2DModel() await Live2DFactory.setupLive2DModel(live2DModel, { url: modelSrcRef.value, id: props.modelId }, { autoInteract: false }) availableMotions.value.forEach((motion) => { @@ -192,7 +208,8 @@ async function loadModel() { // --- Scene model.value = live2DModel - pixiApp.value.stage.addChild(model.value) + // REVIEW: pixiApp and stage are guaranteed to be valid here due to the until(...) above. + pixiApp.value!.stage.addChild(model.value) initialModelWidth.value = model.value.width initialModelHeight.value = model.value.height model.value.anchor.set(0.5, 0.5) @@ -574,6 +591,10 @@ onMounted(async () => { updateDropShadowFilter() }) +onUnmounted(() => { + isUnmounted = true +}) + function listMotionGroups() { return availableMotions.value } diff --git a/packages/stage-ui/src/utils/opfs-loader.ts b/packages/stage-ui/src/utils/opfs-loader.ts index 1b65aed1b..a82cc2687 100644 --- a/packages/stage-ui/src/utils/opfs-loader.ts +++ b/packages/stage-ui/src/utils/opfs-loader.ts @@ -2,6 +2,7 @@ import type { Live2DFactoryContext, Middleware, ModelSettings } from 'pixi-live2 interface OPFSContext extends Live2DFactoryContext { opfsKey?: string + opfsUrl?: string } declare global { @@ -17,6 +18,8 @@ export class OPFSCache { if (entry.kind === 'file') { const fileHandle = entry as FileSystemFileHandle const file = await fileHandle.getFile() + if (file.name === '__meta.json') + continue // live2d-display expects this Object.defineProperty(file, 'webkitRelativePath', { value: pathPrefix + file.name, @@ -56,13 +59,33 @@ export class OPFSCache { await writable.close() } - static async get(key: string): Promise { + static async readMeta(dirHandle: FileSystemDirectoryHandle) { + try { + const metaHandle = await dirHandle.getFileHandle('__meta.json', { create: false }) + const metaFile = await metaHandle.getFile() + const metaText = await metaFile.text() + return JSON.parse(metaText) as { sourceUrl?: string } + } + catch { + return null + } + } + + static async get(key: string, sourceUrl: string): Promise { try { const root = await navigator.storage.getDirectory() const dirHandle = await root.getDirectoryHandle(key, { create: false }) // eslint-disable-next-line no-console console.debug(`[OPFS] Cache hit for ${key}`) + const meta = await OPFSCache.readMeta(dirHandle) + if (meta?.sourceUrl && meta.sourceUrl !== sourceUrl) { + // NOTICE: Skip cache when the requested URL changes while the key stays the same. + // This avoids serving a stale model when ids are reused or props are out of sync. + console.debug(`[OPFS] Cache mismatch for ${key}, source url changed`) + return null + } + const files = await OPFSCache.readDirectoryRecursive(dirHandle, '') if (files.length > 0) { @@ -75,7 +98,7 @@ export class OPFSCache { return null } - static async save(key: string, files: File[]): Promise { + static async save(key: string, files: File[], sourceUrl?: string): Promise { // eslint-disable-next-line no-console console.debug(`[OPFS] Saving ${files.length} files to ${key}`) @@ -106,6 +129,9 @@ export class OPFSCache { } await Promise.all(writePromises) + if (sourceUrl) { + await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({ sourceUrl })) + } // eslint-disable-next-line no-console console.debug(`[OPFS] Saved to cache`) } @@ -140,7 +166,7 @@ export class OPFSCache { return next() } - const files = await OPFSCache.get(key) + const files = await OPFSCache.get(key, blobUrl) if (files) { // cache hit @@ -152,6 +178,7 @@ export class OPFSCache { // eslint-disable-next-line no-console console.debug(`[OPFS] Cache miss for ${key}`) context.opfsKey = key + context.opfsUrl = blobUrl try { const res = await fetch(blobUrl) @@ -179,7 +206,7 @@ export class OPFSCache { return next() } - await OPFSCache.save(context.opfsKey, files) + await OPFSCache.save(context.opfsKey, files, context.opfsUrl) return next() }