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:
co-authored by
Codex (AI assistant)
parent
fa11015b29
commit
ba0e70559d
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user