fix(live2d): encode filepaths in re-created model settings (#1838)

This commit is contained in:
DrHuangMHT
2026-05-19 19:08:15 +08:00
committed by GitHub
parent 6b31552a16
commit cb9eacdd39
8 changed files with 65 additions and 68 deletions
-1
View File
@@ -30,7 +30,6 @@
"./utils/live2d-preview": "./src/utils/live2d-preview.ts",
"./utils/live2d-zip-loader": "./src/utils/live2d-zip-loader.ts",
"./utils/live2d-opfs-registration": "./src/utils/live2d-opfs-registration.ts",
"./utils/live2d-uri-encode-filenames": "./src/utils/live2d-uri-encode-filenames.ts",
"./utils/opfs-loader": "./src/utils/opfs-loader.ts"
},
"scripts": {
@@ -443,9 +443,6 @@ async function loadModel() {
}
internalModelRef.value = internalModel
initExpressionController(internalModel).catch((err) => {
console.warn('[Model.vue] Expression controller initialisation failed:', err)
})
}
emits('modelLoaded')
@@ -457,6 +454,9 @@ async function loadModel() {
finally {
modelLoading.value = false
componentState.value = 'mounted'
await initExpressionController(internalModelRef.value).catch((err) => {
console.warn('[Model.vue] Expression controller initialization failed:', err)
})
modelLoadMutex.release()
}
}
@@ -468,11 +468,11 @@ async function loadModel() {
* This is intentionally fire-and-forget from loadModel so that a failure in
* expression loading does not prevent the model itself from rendering.
*/
async function initExpressionController(internalModel: PixiLive2DInternalModel) {
async function initExpressionController(internalModel?: PixiLive2DInternalModel) {
// Dispose any previous state (handles model reloads)
expressionController.dispose()
const settings = (internalModel as any).settings
const settings = internalModel?.settings as any
if (!settings)
return
-1
View File
@@ -6,7 +6,6 @@ export * from './stores'
export { randomSaccadeInterval } from './utils/eye-motions'
export * from './utils/live2d-opfs-registration'
export * from './utils/live2d-preview'
export * from './utils/live2d-uri-encode-filenames'
export * from './utils/live2d-validator'
export * from './utils/live2d-zip-loader'
export * from './utils/opfs-loader'
@@ -2,7 +2,6 @@ export { randomSaccadeInterval } from './eye-motions'
export * from './live2d-opfs-registration'
export * from './live2d-preview'
export * from './live2d-uri-encode-filenames'
export * from './live2d-validator'
export * from './live2d-zip-loader'
export * from './opfs-loader'
@@ -1,6 +1,5 @@
import { FileLoader, Live2DFactory, ZipLoader } from 'pixi-live2d-display/cubism4'
import { Live2DFactory, ZipLoader } from 'pixi-live2d-display/cubism4'
import { live2dEncodeFilenamesMiddleware } from './live2d-uri-encode-filenames'
import { OPFSCache } from './opfs-loader'
const zipLoaderIndex = Live2DFactory.live2DModelMiddlewares.indexOf(ZipLoader.factory)
@@ -17,12 +16,3 @@ else if (zipLoaderIndex !== -1) {
else {
console.warn('[OPFS] ZipLoader not found in middlewares, caching disabled')
}
// A middleware to URI-encode possible filenames in settings to handle filenames with UTF-8 characters.
if (!Live2DFactory.live2DModelMiddlewares.includes(live2dEncodeFilenamesMiddleware)) {
// Insert before FileLoader
const insertBefore = Live2DFactory.live2DModelMiddlewares.indexOf(FileLoader.factory)
if (insertBefore >= 0) {
Live2DFactory.live2DModelMiddlewares.splice(insertBefore, 0, live2dEncodeFilenamesMiddleware)
}
}
@@ -1,29 +0,0 @@
import type { Live2DFactoryContext, Middleware, ModelSettings } from 'pixi-live2d-display/cubism4'
function tryEncode(obj: any, prop: string | number) {
if (obj?.[prop] && typeof obj[prop] === 'string') {
obj[prop] = encodeURI(obj[prop])
}
}
// A middleware to URI-encode possible filenames in settings to handle filenames with UTF-8 characters.
export const live2dEncodeFilenamesMiddleware: Middleware<Live2DFactoryContext> = (context, next) => {
if (typeof context.source !== 'object' || !context.source)
return next()
// Be skeptical
const settings = context.source.settings as Partial<ModelSettings> | undefined
if (!settings)
return next()
tryEncode(settings, 'moc')
if (Array.isArray(settings.textures)) {
for (let i = 0; i < settings.textures.length; i++) {
tryEncode(settings.textures, i)
}
}
tryEncode(settings, 'physics')
tryEncode(settings, 'url')
return next()
}
@@ -96,6 +96,7 @@ export class OPFSCache {
// This avoids serving a stale model when ids are reused or props are out of sync.
// eslint-disable-next-line no-console
console.debug(`[OPFS] Cache mismatch for ${key}, source url changed`)
await root.removeEntry(dirHandle.name, { recursive: true }) // actually invalidates cache
return null
}
@@ -126,21 +127,6 @@ export class OPFSCache {
writePromises.push(OPFSCache.writeFile(dirHandle, relativePath, file))
}
const settingsFile = files.find(f => f.name.endsWith('.model.json') || f.name.endsWith('.model3.json'))
if (!settingsFile) {
// reconstruct settings files from ModelSettings
const settings: ModelSettings = (files as any).settings
if (settings) {
// eslint-disable-next-line no-console
console.debug('[OPFS] Reconstructing settings file...')
const settingsJson = JSON.stringify(settings.json)
const settingsFileName = settings.url || 'model.model3.json'
writePromises.push(OPFSCache.writeFile(dirHandle, settingsFileName, settingsJson))
}
}
await Promise.all(writePromises)
if (sourceUrl) {
await OPFSCache.writeFile(dirHandle, '__meta.json', JSON.stringify({ sourceUrl }))
@@ -219,8 +205,66 @@ export class OPFSCache {
return next()
}
const settingsFile = files.find(f => f.name.endsWith('.model.json') || f.name.endsWith('.model3.json'))
if (!settingsFile) {
// reconstruct settings files from ModelSettings
const settings: ModelSettings = (files as any).settings
if (settings) {
// eslint-disable-next-line no-console
console.debug('[OPFS] Reconstructing settings file...')
const settingsText = encodeModelSettings(settings.json)
const settingsFilePath = settings.url || 'model.model3.json'
const settingsFile = new File([settingsText], settingsFilePath)
Object.defineProperty(settingsFile, 'webkitRelativePath', {
value: encodeURI(settingsFilePath),
})
files.push(settingsFile)
}
delete (context.source as any).settings // force the loader to read re-created settings file
}
await OPFSCache.save(context.opfsKey, files, context.opfsUrl)
return next()
}
}
function encodeProperty(obj: any, path: string) {
let cursor = obj
const propPath = path.split('.')
// will lose reference when access to the last level
while (propPath.length > 1 && cursor != null && typeof cursor === 'object' && propPath[0] in cursor) {
cursor = cursor[propPath.shift()!]
}
if (cursor == null || cursor[propPath[0]] == null)
return
if (typeof cursor[propPath[0]] === 'string')
cursor[propPath[0]] = encodeURI(cursor[propPath[0]])
if (Array.isArray(cursor[propPath[0]]) && typeof cursor[propPath[0]][0] === 'string') {
cursor[propPath[0]] = cursor[propPath[0]].map((s: string) => encodeURI(s))
}
}
// TODO: find all file paths and encode them by recursively visiting the settings
function encodeModelSettings(input: any): string {
const settings = JSON.parse(JSON.stringify(input))
const propertyToEncode = [
'FileReferences.DisplayInfo',
'FileReferences.Moc',
'FileReferences.Textures',
'FileReferences.Physics',
'url',
]
propertyToEncode.forEach(k => encodeProperty(settings, k))
settings?.FileReferences?.Expressions?.map((exp: { Name: string, File: string }) => {
exp.File = encodeURI(exp.File)
return exp
})
Object.keys(settings?.FileReferences?.Motions ?? {}).forEach((k) => {
if (!Array.isArray(settings?.FileReferences?.Motions[k]))
return // not sure whether 'Motions' is of type Record<string,[]>, assume it is for now.
settings?.FileReferences?.Motions[k].map((exp: { File: string }) => {
exp.File = encodeURI(exp.File)
return exp
})
})
return JSON.stringify(settings)
}