fix(stage-ui): unable to load model correctly & race-ly loading of pixi live2d

Co-authored-by: Codex (AI assistant)
This commit is contained in:
Neko Ayaka
2025-12-12 15:40:45 +08:00
co-authored by Codex (AI assistant)
parent fa11015b29
commit ba0e70559d
2 changed files with 58 additions and 10 deletions
@@ -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<PixiLive2DInternalModel>()
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
}
+31 -4
View File
@@ -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<File[] | null> {
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<File[] | null> {
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<void> {
static async save(key: string, files: File[], sourceUrl?: string): Promise<void> {
// 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()
}