From cb9eacdd39baad8f69f74f2f1b1f71793b493108 Mon Sep 17 00:00:00 2001 From: DrHuangMHT Date: Tue, 19 May 2026 19:08:15 +0800 Subject: [PATCH] fix(live2d): encode filepaths in re-created model settings (#1838) --- packages/stage-ui-live2d/package.json | 1 - .../src/components/scenes/live2d/Model.vue | 10 +-- packages/stage-ui-live2d/src/index.ts | 1 - packages/stage-ui-live2d/src/utils/index.ts | 1 - .../src/utils/live2d-opfs-registration.ts | 12 +-- .../src/utils/live2d-uri-encode-filenames.ts | 29 -------- .../stage-ui-live2d/src/utils/opfs-loader.ts | 74 +++++++++++++++---- .../settings/model-settings/panel.vue | 5 -- 8 files changed, 65 insertions(+), 68 deletions(-) delete mode 100644 packages/stage-ui-live2d/src/utils/live2d-uri-encode-filenames.ts diff --git a/packages/stage-ui-live2d/package.json b/packages/stage-ui-live2d/package.json index 835f42563..a4e5e9c31 100644 --- a/packages/stage-ui-live2d/package.json +++ b/packages/stage-ui-live2d/package.json @@ -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": { diff --git a/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue index 5af76b09b..df287ad6f 100644 --- a/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue +++ b/packages/stage-ui-live2d/src/components/scenes/live2d/Model.vue @@ -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 diff --git a/packages/stage-ui-live2d/src/index.ts b/packages/stage-ui-live2d/src/index.ts index 9adfd5cc8..9d16ea363 100644 --- a/packages/stage-ui-live2d/src/index.ts +++ b/packages/stage-ui-live2d/src/index.ts @@ -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' diff --git a/packages/stage-ui-live2d/src/utils/index.ts b/packages/stage-ui-live2d/src/utils/index.ts index 9f738682a..1e8e9c2dc 100644 --- a/packages/stage-ui-live2d/src/utils/index.ts +++ b/packages/stage-ui-live2d/src/utils/index.ts @@ -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' diff --git a/packages/stage-ui-live2d/src/utils/live2d-opfs-registration.ts b/packages/stage-ui-live2d/src/utils/live2d-opfs-registration.ts index 456dea773..a71f15fcc 100644 --- a/packages/stage-ui-live2d/src/utils/live2d-opfs-registration.ts +++ b/packages/stage-ui-live2d/src/utils/live2d-opfs-registration.ts @@ -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) - } -} diff --git a/packages/stage-ui-live2d/src/utils/live2d-uri-encode-filenames.ts b/packages/stage-ui-live2d/src/utils/live2d-uri-encode-filenames.ts deleted file mode 100644 index ccdfa1193..000000000 --- a/packages/stage-ui-live2d/src/utils/live2d-uri-encode-filenames.ts +++ /dev/null @@ -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 = (context, next) => { - if (typeof context.source !== 'object' || !context.source) - return next() - - // Be skeptical - const settings = context.source.settings as Partial | 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() -} diff --git a/packages/stage-ui-live2d/src/utils/opfs-loader.ts b/packages/stage-ui-live2d/src/utils/opfs-loader.ts index b428deaa8..b30b7cc3a 100644 --- a/packages/stage-ui-live2d/src/utils/opfs-loader.ts +++ b/packages/stage-ui-live2d/src/utils/opfs-loader.ts @@ -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, assume it is for now. + settings?.FileReferences?.Motions[k].map((exp: { File: string }) => { + exp.File = encodeURI(exp.File) + return exp + }) + }) + return JSON.stringify(settings) +} diff --git a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue index d342c1001..25dcdaf8d 100644 --- a/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue +++ b/packages/stage-ui/src/components/scenarios/settings/model-settings/panel.vue @@ -2,7 +2,6 @@ import type { DisplayModel } from '../../../../stores/display-models' import type { ModelSettingsRuntimeSnapshot } from './runtime' -import { useLive2d } from '@proj-airi/stage-ui-live2d' import { Button, Callout } from '@proj-airi/ui' import { storeToRefs } from 'pinia' import { computed, ref } from 'vue' @@ -11,7 +10,6 @@ import Godot from './godot.vue' import Live2D from './live2d.vue' import VRM from './vrm.vue' -import { DisplayModelFormat } from '../../../../stores/display-models' import { useAiriCardStore } from '../../../../stores/modules/airi-card' import { useSettings } from '../../../../stores/settings' import { ModelSelectorDialog } from '../../dialogs/model-selector' @@ -47,9 +45,6 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) { stageModelSelected.value = selectedModel?.id ?? '' airiCardStore.updateActiveCardDisplayModel(selectedModel?.id) await settingsStore.updateStageModel() - - if (selectedModel?.format === DisplayModelFormat.Live2dZip) - useLive2d().shouldUpdateView() }