From 0528239bfdd164d1081f18a81f3b3fef09db4239 Mon Sep 17 00:00:00 2001 From: LemonNeko Date: Mon, 3 Mar 2025 01:39:37 +0800 Subject: [PATCH] feat(stage-web): load and save live2d model (#41) * wip: ui * wip: load local live2d zip * feat(stage-web): save model to indexed db * fix: apply suggestion Co-authored-by: Neko --------- Co-authored-by: Neko --- .vscode/settings.json | 5 +- apps/stage-web/locales/en.yml | 12 ++ apps/stage-web/locales/zh-CN.yml | 12 ++ apps/stage-web/package.json | 1 + .../src/components/Widgets/Live2DSettings.vue | 102 +++++++++++ .../src/components/Widgets/MobileSettings.vue | 36 ++++ .../stage-ui/src/components/Live2D/Model.vue | 70 ++++++-- .../stage-ui/src/components/Scenes/Live2D.vue | 3 +- .../stage-ui/src/components/Widgets/Stage.vue | 1 - packages/stage-ui/src/stores/settings.ts | 8 + .../stage-ui/src/utils/live2d-zip-loader.ts | 75 +++++++- pnpm-lock.yaml | 163 ++++++++++-------- 12 files changed, 400 insertions(+), 88 deletions(-) create mode 100644 apps/stage-web/src/components/Widgets/Live2DSettings.vue diff --git a/.vscode/settings.json b/.vscode/settings.json index 11e98bb4e..63dd5dd36 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -49,5 +49,8 @@ "yaml" ], - "typescript.tsdk": "node_modules/typescript/lib" + "typescript.tsdk": "node_modules/typescript/lib", + "cSpell.words": [ + "localforage" + ] } diff --git a/apps/stage-web/locales/en.yml b/apps/stage-web/locales/en.yml index 1c4d7274c..d790e6e30 100644 --- a/apps/stage-web/locales/en.yml +++ b/apps/stage-web/locales/en.yml @@ -54,6 +54,18 @@ settings: microphone: Microphone model-provider: title: Model Providers + live2d: + title: Live2D Settings + change-model: + title: Change Model + from-url: Load from URL + from-url-placeholder: Enter Live2D model URL + from-url-confirm: Load + from-file: Load from File + from-file-select: Select + map-motions: + title: Map Motions + play: Play Motion models: Model openai-api-key: label: OpenAI API Key diff --git a/apps/stage-web/locales/zh-CN.yml b/apps/stage-web/locales/zh-CN.yml index 90bd75790..8b5e48668 100644 --- a/apps/stage-web/locales/zh-CN.yml +++ b/apps/stage-web/locales/zh-CN.yml @@ -41,6 +41,18 @@ settings: title: 语言 model-provider: title: 模型提供商 + live2d: + title: Live2D 设置 + change-model: + title: 更换模型 + from-url: 从 URL 加载 + from-url-placeholder: 输入 Live2D 模型 URL + from-url-confirm: 加载 + from-file: 从文件加载 + from-file-select: 选择 + map-motions: + title: 映射动作 + play: 播放动作 models: 模型 openai-api-key: label: OpenAI API 密钥 diff --git a/apps/stage-web/package.json b/apps/stage-web/package.json index e4bca13b1..a59cbafd3 100644 --- a/apps/stage-web/package.json +++ b/apps/stage-web/package.json @@ -61,6 +61,7 @@ "drizzle-kit": "^0.30.5", "drizzle-orm": "^0.40.0", "jszip": "^3.10.1", + "localforage": "^1.10.0", "nprogress": "^0.2.0", "ofetch": "^1.4.1", "onnxruntime-web": "^1.20.1", diff --git a/apps/stage-web/src/components/Widgets/Live2DSettings.vue b/apps/stage-web/src/components/Widgets/Live2DSettings.vue new file mode 100644 index 000000000..b01726079 --- /dev/null +++ b/apps/stage-web/src/components/Widgets/Live2DSettings.vue @@ -0,0 +1,102 @@ + + + diff --git a/apps/stage-web/src/components/Widgets/MobileSettings.vue b/apps/stage-web/src/components/Widgets/MobileSettings.vue index 3d04efaa0..8ba7710ff 100644 --- a/apps/stage-web/src/components/Widgets/MobileSettings.vue +++ b/apps/stage-web/src/components/Widgets/MobileSettings.vue @@ -24,6 +24,11 @@ function navigateToProviders() { currentView.value = 'providers' } +function navigateToLive2D() { + slideDirection.value = 'forward' + currentView.value = 'live2d' +} + function navigateBack() { slideDirection.value = 'backward' currentView.value = 'main' @@ -122,6 +127,22 @@ function navigateBack() { + +
+
+ {{ t('settings.live2d.title') }} +
+
+
+
+
@@ -141,6 +162,21 @@ function navigateBack() { + +
+
+ +

+ {{ t('settings.live2d.title') }} +

+
+ +
diff --git a/packages/stage-ui/src/components/Live2D/Model.vue b/packages/stage-ui/src/components/Live2D/Model.vue index 7be82cbd6..39479d63b 100644 --- a/packages/stage-ui/src/components/Live2D/Model.vue +++ b/packages/stage-ui/src/components/Live2D/Model.vue @@ -6,16 +6,18 @@ import type { Ref } from 'vue' import { extensions } from '@pixi/extensions' import { InteractionManager } from '@pixi/interaction' import { Ticker, TickerPlugin } from '@pixi/ticker' -import { breakpointsTailwind, useBreakpoints, useDark, useDebounceFn } from '@vueuse/core' +import { breakpointsTailwind, useBreakpoints, useDark, useDebounceFn, watchDebounced } from '@vueuse/core' +import localforage from 'localforage' +import { storeToRefs } from 'pinia' import { DropShadowFilter } from 'pixi-filters' -import { Live2DModel, MotionPreloadStrategy, MotionPriority } from 'pixi-live2d-display/cubism4' +import { Live2DFactory, Live2DModel, MotionPriority } from 'pixi-live2d-display/cubism4' import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue' import { useLive2DIdleEyeFocus } from '../../composables/live2d' +import { useSettings } from '../../stores' const props = withDefaults(defineProps<{ app?: Application - model: string mouthOpenSize?: number width: number height: number @@ -58,16 +60,31 @@ function setScale(model: Ref | undefined>) { model.value.scale.set(scale, scale) } -async function initLive2DPixiStage() { +const { live2dModel, loadingLive2dModel } = storeToRefs(useSettings()) + +// FIXME: it cannot blink if loading other model +async function loadModel(source: string | Blob) { if (!pixiApp.value) return - // https://guansss.github.io/pixi-live2d-display/#package-importing - Live2DModel.registerTicker(Ticker) - extensions.add(TickerPlugin) - extensions.add(InteractionManager) + if (model.value) { + pixiApp.value.stage.removeChild(model.value) + model.value.destroy() + model.value = undefined + } - model.value = await Live2DModel.from(props.model, { motionPreload: MotionPreloadStrategy.ALL }) + loadingLive2dModel.value = true + + const modelInstance = new Live2DModel() + + if (source instanceof Blob) { + await Live2DFactory.setupLive2DModel(modelInstance, [source]) + } + else { + await Live2DFactory.setupLive2DModel(modelInstance, source) + } + + model.value = modelInstance pixiApp.value.stage.addChild(model.value as any) initialModelWidth.value = model.value.width initialModelHeight.value = model.value.height @@ -112,6 +129,30 @@ async function initLive2DPixiStage() { } return true } + + // save to indexdb + await localforage.setItem('live2dModel', source) + + loadingLive2dModel.value = false +} + +async function initLive2DPixiStage() { + if (!pixiApp.value) + return + + // https://guansss.github.io/pixi-live2d-display/#package-importing + Live2DModel.registerTicker(Ticker) + extensions.add(TickerPlugin) + extensions.add(InteractionManager) + + // load indexdb model first + const live2dModelBlob = await localforage.getItem('live2dModel') + if (live2dModelBlob) { + await loadModel(live2dModelBlob) + return + } + + await loadModel(live2dModel.value) } async function setMotion(motionName: string) { @@ -148,8 +189,17 @@ watch(paused, (value) => { value ? pixiApp.value?.stop() : pixiApp.value?.start() }) +watchDebounced(live2dModel, (value) => { + if (!value) + return + + loadModel(value) +}, { debounce: 1000 }) + onMounted(updateDropShadowFilter) -onUnmounted(() => model.value && pixiApp.value?.stage.removeChild(model.value)) +onUnmounted(() => { + model.value && pixiApp.value?.stage.removeChild(model.value) +})