refactor(stage(live2d(settings))): merge ui, extract store (#277)

This commit is contained in:
LemonNeko
2025-07-16 19:48:27 +08:00
committed by GitHub
parent 3e6f457669
commit b9223f8a72
19 changed files with 158 additions and 351 deletions
@@ -1,16 +1,16 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useLive2d } from '@proj-airi/stage-ui/stores'
defineEmits<{
(e: 'click'): void
}>()
const settings = useSettings()
const live2d = useLive2d()
</script>
<template>
<button
:disabled="settings.loadingLive2dModel"
:disabled="live2d.loadingModel"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250"
+5 -5
View File
@@ -2,7 +2,7 @@
import type { AiriTamagotchiEvents, Point } from '../composables/tauri'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useMcpStore, useSettings } from '@proj-airi/stage-ui/stores'
import { useLive2d, useMcpStore } from '@proj-airi/stage-ui/stores'
import { connectServer } from '@proj-airi/tauri-plugin-mcp'
import { useWindowSize } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -34,7 +34,7 @@ const resourcesStore = useResourcesStore()
const mcpStore = useMcpStore()
const { connected, serverCmd, serverArgs } = storeToRefs(mcpStore)
const { live2dScale, live2dPositionInPercentageString } = storeToRefs(useSettings())
const { scale, positionInPercentageString } = storeToRefs(useLive2d())
watch([live2dLookAtX, live2dLookAtY], ([x, y]) => live2dFocusAt.value = { x, y }, { immediate: true })
@@ -131,9 +131,9 @@ if (import.meta.hot) { // For better DX
<div relative h-full w-full items-end gap-2 class="view">
<WidgetStage
h-full w-full flex-1
:focus-at="live2dFocusAt" :scale="live2dScale"
:x-offset="live2dPositionInPercentageString.x"
:y-offset="live2dPositionInPercentageString.y" mb="<md:18"
:focus-at="live2dFocusAt" :scale="scale"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y" mb="<md:18"
/>
<ResourceStatusIsland />
<div
@@ -1,10 +1,10 @@
<script setup lang="ts">
import { Section } from '@proj-airi/stage-ui/components'
import { ColorPalette } from '@proj-airi/stage-ui/components/Widgets/Settings'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { ColorHueRange } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import ColorPalette from '../../../components/Settings/ColorPalette.vue'
import COLOR_PRESETS from './color-presets.json'
const settings = useSettings()
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { Live2DSettings } from '@proj-airi/stage-ui/components/Widgets/Settings'
import { useLive2d } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useMouse } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
import Live2DSettings from '../../../components/Widgets/Live2DSettings.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const live2dContainerRef = ref<HTMLDivElement>()
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { width, height } = useElementBounding(live2dContainerRef)
const { live2dPositionInPercentageString, live2dScale } = storeToRefs(useSettings())
const { positionInPercentageString, scale } = storeToRefs(useLive2d())
const palette = ref<string[]>([])
@@ -70,9 +70,9 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="live2dPositionInPercentageString.x"
:y-offset="live2dPositionInPercentageString.y"
:scale="live2dScale"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
</Live2DCanvas>
</div>
@@ -1,37 +0,0 @@
<script setup lang="ts">
import { DEFAULT_THEME_COLORS_HUE, useSettings } from '@proj-airi/stage-ui/stores'
import { TooltipArrow, TooltipContent, TooltipPortal, TooltipProvider, TooltipRoot, TooltipTrigger } from 'radix-vue'
interface Color {
hex?: string
name: string
}
defineProps<{
colors: Color[]
}>()
const settings = useSettings()
</script>
<template>
<div v-if="colors.length" flex gap-2>
<TooltipProvider v-for="{ hex, name } in colors" :key="hex || 'default'">
<TooltipRoot>
<TooltipTrigger
transition="all ease-in-out duration-250"
size-6 cursor-pointer rounded-full bg-primary-500
:style="hex ? { background: hex } : { '--chromatic-hue': DEFAULT_THEME_COLORS_HUE }"
:class="settings.isColorSelectedForPrimary(hex) ? 'scale-150 mx-1' : 'hover:scale-110'"
@click="settings.applyPrimaryColorFrom(hex)"
/>
<TooltipPortal>
<TooltipContent bg="white dark:neutral-800" rounded-lg px-3 py-1.5 text-sm shadow-md>
{{ name }}
<TooltipArrow fill-white dark:fill-neutral-800 />
</TooltipContent>
</TooltipPortal>
</TooltipRoot>
</TooltipProvider>
</div>
</template>
@@ -1,16 +1,16 @@
<script setup lang="ts">
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useLive2d } from '@proj-airi/stage-ui/stores'
defineEmits<{
(e: 'click'): void
}>()
const settings = useSettings()
const live2d = useLive2d()
</script>
<template>
<button
:disabled="settings.loadingLive2dModel"
:disabled="live2d.loadingModel"
bg="neutral-100 dark:neutral-800"
hover="bg-neutral-200 dark:bg-neutral-700"
transition="all ease-in-out duration-250"
@@ -1,182 +0,0 @@
<!-- TODO: merge to stage-ui -->
<script setup lang="ts">
import JSZip from 'jszip'
import localforage from 'localforage'
import { Section } from '@proj-airi/stage-ui/components'
import { Emotion, EmotionNeutralMotionName } from '@proj-airi/stage-ui/constants'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { FieldRange } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPalette from '../Settings/ColorPalette.vue'
import Button from '../Settings/Live2DModelControlButton.vue'
defineProps<{
palette: string[]
}>()
defineEmits<{
(e: 'extractColorsFromModel'): void
}>()
const { t } = useI18n()
const modelFile = useFileDialog({
accept: 'application/zip',
})
const settings = useSettings()
const { live2dModelFile, live2dMotionMap, live2dLoadSource, loadingLive2dModel, availableLive2dMotions, live2dModelUrl } = storeToRefs(settings)
const localModelUrl = ref(live2dModelUrl.value)
modelFile.onChange((files) => {
if (files && files.length > 0) {
live2dMotionMap.value = {}
live2dModelFile.value = files[0]
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
}
})
watch(() => settings.loadingLive2dModel, (value) => {
if (value) {
return
}
if (live2dLoadSource.value !== 'file') {
return
}
availableLive2dMotions.value.forEach((motion) => {
if (motion.motionName in Emotion) {
live2dMotionMap.value[motion.fileName] = motion.motionName
}
else {
live2dMotionMap.value[motion.fileName] = EmotionNeutralMotionName
}
})
})
async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
if (!Object.keys(motionMap).length)
return source
const jsZip = new JSZip()
const zip = await jsZip.loadAsync(source)
const fileName = Object.keys(zip.files).find(key => key.endsWith('model3.json'))
if (!fileName) {
throw new Error('model3.json not found')
}
const model3Json = await zip.file(fileName)!.async('string')
const model3JsonObject = JSON.parse(model3Json)
const motions: Record<string, { File: string }[]> = {}
Object.entries(motionMap).forEach(([key, value]) => {
if (motions[value]) {
motions[value].push({ File: key })
return
}
motions[value] = [{ File: key }]
})
model3JsonObject.FileReferences.Motions = motions
zip.file(fileName, JSON.stringify(model3JsonObject, null, 2))
const zipBlob = await zip.generateAsync({ type: 'blob' })
return new File([zipBlob], source.name, {
type: source.type,
lastModified: source.lastModified,
})
}
async function saveMotionMap() {
const fileFromIndexedDB = await localforage.getItem<File>('live2dModel')
if (!fileFromIndexedDB) {
return
}
const patchedFile = await patchMotionMap(fileFromIndexedDB, live2dMotionMap.value)
live2dModelFile.value = patchedFile
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
}
const exportObjectUrl = useObjectUrl(live2dModelFile)
</script>
<template>
<div flex="~ col gap-2">
<Section :title="t('settings.live2d.change-model.title')" icon="i-solar:magic-stick-3-bold-duotone" inner-class="text-sm">
<div flex items-center gap-2>
<input
v-model="localModelUrl"
:disabled="settings.loadingLive2dModel"
class="form-control flex-1"
border="neutral-300 dark:neutral-800 solid 1 focus:neutral-400 dark:focus:neutral-600"
transition="border duration-250 ease-in-out"
:placeholder="t('settings.live2d.change-model.from-url-placeholder')"
>
<Button class="form-control" @click="live2dModelUrl = localModelUrl">
{{ t('settings.live2d.change-model.from-url') }}
</Button>
</div>
<Button class="form-control place-self-end" @click="modelFile.open()">
{{ t('settings.live2d.change-model.from-file') }}...
</Button>
<Button class="form-control" @click="$emit('extractColorsFromModel')">
Extract colors from model
</Button>
<ColorPalette :colors="palette.map(hex => ({ hex, name: hex }))" />
</Section>
<Section
v-if="settings.live2dLoadSource === 'file'"
:title="t('settings.live2d.edit-motion-map.title')"
icon="i-solar:face-scan-circle-bold-duotone"
>
<div v-for="motion in settings.availableLive2dMotions" :key="motion.fileName" flex items-center justify-between text-sm>
<span font-medium font-mono>{{ motion.fileName }}</span>
<div flex gap-2>
<select v-model="settings.live2dMotionMap[motion.fileName]">
<option v-for="emotion in Object.keys(Emotion)" :key="emotion">
{{ emotion }}
</option>
</select>
<Button
class="form-control"
@click="settings.live2dCurrentMotion = { group: motion.motionName, index: motion.motionIndex }"
>
Play
</Button>
</div>
</div>
<Button @click="saveMotionMap">
Save and patch
</Button>
<a
mt-2 block :href="exportObjectUrl"
:download="`${settings.live2dModelFile?.name || 'live2d'}-motion-edited.zip`"
>
<Button w-full>Export</button>
</a>
</Section>
<Section :title="t('settings.live2d.scale-and-position.title')" icon="i-solar:scale-bold-duotone">
<FieldRange v-model="settings.live2dScale" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')" />
<FieldRange v-model="settings.live2dPosition.x" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')" />
<FieldRange v-model="settings.live2dPosition.y" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')" />
</Section>
</div>
</template>
<style scoped>
.form-control {
--at-apply: rounded px-2 py-1 outline-none;
}
</style>
+5 -5
View File
@@ -1,6 +1,6 @@
<script setup lang="ts">
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useLive2d } from '@proj-airi/stage-ui/stores'
import { breakpointsTailwind, useBreakpoints, useDark, useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { onMounted, ref, watch } from 'vue'
@@ -21,7 +21,7 @@ function handleSettingsOpen(open: boolean) {
}
const positionCursor = useMouse()
const { live2dScale, live2dPosition, live2dPositionInPercentageString } = storeToRefs(useSettings())
const { scale, position, positionInPercentageString } = storeToRefs(useLive2d())
const breakpoints = useBreakpoints(breakpointsTailwind)
const isMobile = breakpoints.smaller('md')
@@ -52,9 +52,9 @@ onMounted(() => updateThemeColor())
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="`${isMobile ? live2dPosition.x : live2dPosition.x - 10}%`"
:y-offset="live2dPositionInPercentageString.y"
:scale="live2dScale"
:x-offset="`${isMobile ? position.x : position.x - 10}%`"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
<InteractiveArea class="flex <md:hidden" absolute h="85dvh" right-4 flex-1 max-w="500px" min-w="30%" />
<MobileInteractiveArea class="hidden <md:block" mx2 mb2 @settings-open="handleSettingsOpen" />
@@ -1,10 +1,11 @@
<script setup lang="ts">
import ColorPalette from '@proj-airi/stage-ui/components/Widgets/Settings/ColorPalette.vue'
import { Section } from '@proj-airi/stage-ui/components'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { ColorHueRange } from '@proj-airi/ui'
import { useI18n } from 'vue-i18n'
import ColorPalette from '../../../components/Settings/ColorPalette.vue'
import COLOR_PRESETS from './color-presets.json'
const settings = useSettings()
@@ -1,20 +1,20 @@
<script setup lang="ts">
import { Live2DCanvas, Live2DModel } from '@proj-airi/stage-ui/components/scenes'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { Live2DSettings } from '@proj-airi/stage-ui/components'
import { useLive2d } from '@proj-airi/stage-ui/stores'
import { useElementBounding, useMouse } from '@vueuse/core'
import { Vibrant } from 'node-vibrant/browser'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import IconAnimation from '../../../components/IconAnimation.vue'
import Live2DSettings from '../../../components/Widgets/Live2DSettings.vue'
import { useIconAnimation } from '../../../composables/icon-animation'
const live2dContainerRef = ref<HTMLDivElement>()
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const { width, height } = useElementBounding(live2dContainerRef)
const { live2dPositionInPercentageString, live2dScale } = storeToRefs(useSettings())
const { positionInPercentageString, scale } = storeToRefs(useLive2d())
const palette = ref<string[]>([])
@@ -70,9 +70,9 @@ const positionCursor = useMouse()
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:x-offset="live2dPositionInPercentageString.x"
:y-offset="live2dPositionInPercentageString.y"
:scale="live2dScale"
:x-offset="positionInPercentageString.x"
:y-offset="positionInPercentageString.y"
:scale="scale"
/>
</Live2DCanvas>
</div>
@@ -8,7 +8,7 @@ import Screen from '../Misc/Screen.vue'
import Live2DCanvas from './Live2D/Canvas.vue'
import Live2DModel from './Live2D/Model.vue'
import { useSettings } from '../../stores'
import { useLive2d } from '../../stores'
import '../../utils/live2d-zip-loader'
@@ -26,7 +26,7 @@ withDefaults(defineProps<{
const { t } = useI18n()
const show = ref(false)
const { live2dCurrentMotion } = storeToRefs(useSettings())
const { currentMotion } = storeToRefs(useLive2d())
</script>
<template>
@@ -80,43 +80,43 @@ const { live2dCurrentMotion } = storeToRefs(useSettings())
<div flex="~ row" flex-wrap gap-2>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Surprise', index: 0 }"
@click="currentMotion = { group: 'Surprise', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.surprised') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Sad', index: 0 }"
@click="currentMotion = { group: 'Sad', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.sad') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Angry', index: 0 }"
@click="currentMotion = { group: 'Angry', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.angry') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Happy', index: 0 }"
@click="currentMotion = { group: 'Happy', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.happy') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Awkward', index: 0 }"
@click="currentMotion = { group: 'Awkward', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.awkward') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Question', index: 0 }"
@click="currentMotion = { group: 'Question', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.question') }}
</button>
<button
rounded-lg bg="neutral-100/70 dark:neutral-800/50" px-2 py-1 backdrop-blur-sm
@click="live2dCurrentMotion = { group: 'Think', index: 0 }"
@click="currentMotion = { group: 'Think', index: 0 }"
>
{{ t('stage.viewers.debug-menu.emotions-btn.think') }}
</button>
@@ -14,7 +14,7 @@ import { Live2DFactory, Live2DModel, MotionPriority } from 'pixi-live2d-display/
import { computed, onMounted, onUnmounted, ref, shallowRef, toRef, watch } from 'vue'
import { useLive2DIdleEyeFocus } from '../../../composables/live2d'
import { useSettings } from '../../../stores'
import { useLive2d, useSettings } from '../../../stores'
const props = withDefaults(defineProps<{
app?: Application
@@ -101,16 +101,20 @@ function setScaleAndPosition() {
}
const {
live2dModelFile,
loadingLive2dModel,
live2dCurrentMotion,
availableLive2dMotions,
live2dLoadSource,
live2dModelUrl,
modelFile,
loadingModel,
currentMotion,
availableMotions,
loadSource,
modelUrl,
} = storeToRefs(useLive2d())
const {
themeColorsHue,
themeColorsHueDynamic,
} = storeToRefs(useSettings())
const currentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
const localCurrentMotion = ref<{ group: string, index: number }>({ group: 'Idle', index: 0 })
async function loadModel() {
if (!pixiApp.value)
@@ -124,11 +128,11 @@ async function loadModel() {
const modelInstance = new Live2DModel()
if (live2dLoadSource.value === 'file') {
await Live2DFactory.setupLive2DModel(modelInstance, [live2dModelFile.value], { autoInteract: false })
if (loadSource.value === 'file') {
await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false })
}
else if (live2dLoadSource.value === 'url') {
await Live2DFactory.setupLive2DModel(modelInstance, live2dModelUrl.value, { autoInteract: false })
else if (loadSource.value === 'url') {
await Live2DFactory.setupLive2DModel(modelInstance, modelUrl.value, { autoInteract: false })
}
model.value = modelInstance
@@ -149,7 +153,7 @@ async function loadModel() {
const motionManager = internalModel.motionManager
coreModel.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
availableLive2dMotions.value = Object.entries(motionManager.definitions).flatMap(([motionName, definition]) => {
availableMotions.value = Object.entries(motionManager.definitions).flatMap(([motionName, definition]) => {
if (!definition)
return []
@@ -187,16 +191,16 @@ async function loadModel() {
}
motionManager.on('motionStart', (group, index) => {
currentMotion.value = { group, index }
localCurrentMotion.value = { group, index }
})
// save to indexdb
if (live2dModelFile.value) {
await localforage.setItem('live2dModel', live2dModelFile.value)
if (modelFile.value) {
await localforage.setItem('live2dModel', modelFile.value)
}
emits('modelLoaded')
loadingLive2dModel.value = false
loadingModel.value = false
}
async function initLive2DPixiStage() {
@@ -211,19 +215,19 @@ async function initLive2DPixiStage() {
// load indexdb model first
const live2dModelFromIndexedDB = await localforage.getItem<File>('live2dModel')
if (live2dModelFromIndexedDB) {
live2dModelFile.value = live2dModelFromIndexedDB
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
modelFile.value = live2dModelFromIndexedDB
loadSource.value = 'file'
loadingModel.value = true
return
}
if (live2dModelUrl.value) {
live2dLoadSource.value = 'url'
loadingLive2dModel.value = true
if (modelUrl.value) {
loadSource.value = 'url'
loadingModel.value = true
return
}
loadingLive2dModel.value = false
loadingModel.value = false
}
async function setMotion(motionName: string, index?: number) {
@@ -268,7 +272,7 @@ watch(themeColorsHueDynamic, () => {
watch(mouthOpenSize, value => getCoreModel().setParameterValueById('ParamMouthOpenY', value))
watch(pixiApp, initLive2DPixiStage)
watch(live2dCurrentMotion, value => setMotion(value.group, value.index))
watch(currentMotion, value => setMotion(value.group, value.index))
watch(paused, value => value ? pixiApp.value?.stop() : pixiApp.value?.start())
watch(focusAt, (value) => {
@@ -280,7 +284,7 @@ watch(focusAt, (value) => {
model.value.focus(value.x, value.y)
})
watchDebounced(loadingLive2dModel, (value) => {
watchDebounced(loadingModel, (value) => {
if (!value)
return
@@ -294,7 +298,7 @@ onUnmounted(() => {
})
function listMotionGroups() {
return availableLive2dMotions.value
return availableMotions.value
}
defineExpose({
@@ -21,6 +21,7 @@ import { useQueue } from '../../composables/queue'
import { useDelayMessageQueue, useEmotionsMessageQueue, useMessageContentQueue } from '../../composables/queues'
import { llmInferenceEndToken } from '../../constants'
import { EMOTION_EmotionMotionName_value, EMOTION_VRMExpressionName_value, EmotionThinkMotionName } from '../../constants/emotions'
import { useLive2d } from '../../stores'
import { useAudioContext, useSpeakingStore } from '../../stores/audio'
import { useChatStore } from '../../stores/chat'
import { useSpeechStore } from '../../stores/modules/speech'
@@ -131,7 +132,7 @@ ttsQueue.on('add', (content) => {
const messageContentQueue = useMessageContentQueue(ttsQueue)
const { live2dCurrentMotion } = storeToRefs(useSettings())
const { currentMotion } = storeToRefs(useLive2d())
const emotionsQueue = useQueue<Emotion>({
handlers: [
@@ -144,7 +145,7 @@ const emotionsQueue = useQueue<Emotion>({
await vrmViewerRef.value!.setExpression(value)
}
else if (stageView.value === '2d') {
live2dCurrentMotion.value = { group: EMOTION_EmotionMotionName_value[ctx.data] }
currentMotion.value = { group: EMOTION_EmotionMotionName_value[ctx.data] }
}
},
],
@@ -189,7 +190,7 @@ onBeforeMessageComposed(async () => {
})
onBeforeSend(async () => {
live2dCurrentMotion.value = { group: EmotionThinkMotionName }
currentMotion.value = { group: EmotionThinkMotionName }
})
onTokenLiteral(async (literal) => {
@@ -1,19 +1,18 @@
<!-- TODO: merge to stage-ui -->
<script setup lang="ts">
import JSZip from 'jszip'
import localforage from 'localforage'
import { Section } from '@proj-airi/stage-ui/components'
import { Emotion, EmotionNeutralMotionName } from '@proj-airi/stage-ui/constants'
import { useSettings } from '@proj-airi/stage-ui/stores'
import { useLive2d } from '@proj-airi/stage-ui/stores'
import { FieldRange } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import ColorPalette from '../Settings/ColorPalette.vue'
import Button from '../Settings/Live2DModelControlButton.vue'
import Section from '../../Layouts/Section.vue'
import Button from '../../Misc/Button.vue'
import ColorPalette from './ColorPalette.vue'
defineProps<{
palette: string[]
@@ -25,38 +24,48 @@ defineEmits<{
const { t } = useI18n()
const modelFile = useFileDialog({
const modelFileDialog = useFileDialog({
accept: 'application/zip',
})
const settings = useSettings()
const { live2dModelFile, live2dMotionMap, live2dLoadSource, loadingLive2dModel, availableLive2dMotions, live2dModelUrl } = storeToRefs(settings)
const localModelUrl = ref(live2dModelUrl.value)
const live2d = useLive2d()
const {
modelFile,
motionMap,
loadSource,
loadingModel,
availableMotions,
modelUrl,
currentMotion,
scale,
position,
} = storeToRefs(live2d)
const localModelUrl = ref(modelUrl.value)
modelFile.onChange((files) => {
modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
live2dMotionMap.value = {}
live2dModelFile.value = files[0]
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
motionMap.value = {}
modelFile.value = files[0]
loadSource.value = 'file'
loadingModel.value = true
}
})
watch(() => settings.loadingLive2dModel, (value) => {
watch(loadingModel, (value) => {
if (value) {
return
}
if (live2dLoadSource.value !== 'file') {
if (loadSource.value !== 'file') {
return
}
availableLive2dMotions.value.forEach((motion) => {
availableMotions.value.forEach((motion) => {
if (motion.motionName in Emotion) {
live2dMotionMap.value[motion.fileName] = motion.motionName
motionMap.value[motion.fileName] = motion.motionName
}
else {
live2dMotionMap.value[motion.fileName] = EmotionNeutralMotionName
motionMap.value[motion.fileName] = EmotionNeutralMotionName
}
})
})
@@ -101,13 +110,13 @@ async function saveMotionMap() {
return
}
const patchedFile = await patchMotionMap(fileFromIndexedDB, live2dMotionMap.value)
live2dModelFile.value = patchedFile
live2dLoadSource.value = 'file'
loadingLive2dModel.value = true
const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
modelFile.value = patchedFile
loadSource.value = 'file'
loadingModel.value = true
}
const exportObjectUrl = useObjectUrl(live2dModelFile)
const exportObjectUrl = useObjectUrl(modelFile)
</script>
<template>
@@ -116,61 +125,62 @@ const exportObjectUrl = useObjectUrl(live2dModelFile)
<div flex items-center gap-2>
<input
v-model="localModelUrl"
:disabled="settings.loadingLive2dModel"
:disabled="loadingModel"
class="form-control flex-1"
border="neutral-300 dark:neutral-800 solid 1 focus:neutral-400 dark:focus:neutral-600"
transition="border duration-250 ease-in-out"
:placeholder="t('settings.live2d.change-model.from-url-placeholder')"
>
<Button class="form-control" @click="live2dModelUrl = localModelUrl">
<Button class="form-control" size="sm" @click="modelUrl = localModelUrl">
{{ t('settings.live2d.change-model.from-url') }}
</Button>
</div>
<Button class="form-control place-self-end" @click="modelFile.open()">
<Button class="form-control place-self-end" size="sm" @click="modelFileDialog.open()">
{{ t('settings.live2d.change-model.from-file') }}...
</Button>
<Button class="form-control" @click="$emit('extractColorsFromModel')">
<Button class="form-control" size="sm" @click="$emit('extractColorsFromModel')">
Extract colors from model
</Button>
<ColorPalette :colors="palette.map(hex => ({ hex, name: hex }))" />
</Section>
<Section
v-if="settings.live2dLoadSource === 'file'"
v-if="loadSource === 'file'"
:title="t('settings.live2d.edit-motion-map.title')"
icon="i-solar:face-scan-circle-bold-duotone"
>
<div v-for="motion in settings.availableLive2dMotions" :key="motion.fileName" flex items-center justify-between text-sm>
<div v-for="motion in availableMotions" :key="motion.fileName" flex items-center justify-between text-sm>
<span font-medium font-mono>{{ motion.fileName }}</span>
<div flex gap-2>
<select v-model="settings.live2dMotionMap[motion.fileName]">
<select v-model="motionMap[motion.fileName]">
<option v-for="emotion in Object.keys(Emotion)" :key="emotion">
{{ emotion }}
</option>
</select>
<Button
size="sm"
class="form-control"
@click="settings.live2dCurrentMotion = { group: motion.motionName, index: motion.motionIndex }"
@click="currentMotion = { group: motion.motionName, index: motion.motionIndex }"
>
Play
</Button>
</div>
</div>
<Button @click="saveMotionMap">
<Button size="sm" @click="saveMotionMap">
Save and patch
</Button>
<a
mt-2 block :href="exportObjectUrl"
:download="`${settings.live2dModelFile?.name || 'live2d'}-motion-edited.zip`"
:download="`${modelFile?.name || 'live2d'}-motion-edited.zip`"
>
<Button w-full>Export</button>
<Button w-full size="sm">Export</button>
</a>
</Section>
<Section :title="t('settings.live2d.scale-and-position.title')" icon="i-solar:scale-bold-duotone">
<FieldRange v-model="settings.live2dScale" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')" />
<FieldRange v-model="settings.live2dPosition.x" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')" />
<FieldRange v-model="settings.live2dPosition.y" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')" />
<FieldRange v-model="scale" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')" />
<FieldRange v-model="position.x" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')" />
<FieldRange v-model="position.y" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')" />
</Section>
</div>
</template>
@@ -0,0 +1,2 @@
export { default as ColorPalette } from './ColorPalette.vue'
export { default as Live2DSettings } from './Live2DSettings.vue'
+1
View File
@@ -1,5 +1,6 @@
export * from './audio'
export * from './chat'
export * from './live2d'
export * from './llm'
export * from './mcp'
export * from './modules'
+32
View File
@@ -0,0 +1,32 @@
import { useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
export const useLive2d = defineStore('live2d', () => {
const modelFile = ref<File>()
const modelUrl = ref<string>('/assets/live2d/models/hiyori_pro_zh.zip')
const loadSource = ref<'file' | 'url'>('url')
const loadingModel = ref(false) // if set to true, the model will be loaded
const position = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
const positionInPercentageString = computed(() => ({
x: `${position.value.x}%`,
y: `${position.value.y}%`,
}))
const currentMotion = ref<{ group: string, index?: number }>({ group: 'Idle', index: 0 })
const availableMotions = ref<{ motionName: string, motionIndex: number, fileName: string }[]>([])
const motionMap = useLocalStorage<Record<string, string>>('settings/live2d/motion-map', {})
const scale = useLocalStorage('settings/live2d/scale', 1)
return {
modelFile,
modelUrl,
loadSource,
loadingModel,
position,
positionInPercentageString,
currentMotion,
availableMotions,
motionMap,
scale,
}
})
-25
View File
@@ -18,21 +18,6 @@ export const useSettings = defineStore('settings', () => {
const selectedAudioDeviceId = computed(() => selectedAudioDevice.value?.deviceId)
const { audioInputs, ensurePermissions } = useDevicesList({ constraints: { audio: true } })
// TODO: extract to a separate store, use a single page to do this
const live2dModelFile = ref<File>()
const live2dModelUrl = ref<string>('/assets/live2d/models/hiyori_pro_zh.zip')
const live2dLoadSource = ref<'file' | 'url'>('url')
const loadingLive2dModel = ref(false) // if set to true, the model will be loaded
const live2dPosition = useLocalStorage('settings/live2d/position', { x: 0, y: 0 }) // position is relative to the center of the screen, units are %
const live2dPositionInPercentageString = computed(() => ({
x: `${live2dPosition.value.x}%`,
y: `${live2dPosition.value.y}%`,
}))
const live2dCurrentMotion = ref<{ group: string, index?: number }>({ group: 'Idle', index: 0 })
const availableLive2dMotions = ref<{ motionName: string, motionIndex: number, fileName: string }[]>([])
const live2dMotionMap = useLocalStorage<Record<string, string>>('settings/live2d/motion-map', {})
const live2dScale = useLocalStorage('settings/live2d/scale', 1)
const disableTransitions = useLocalStorage('settings/disable-transitions', true)
const usePageSpecificTransitions = useLocalStorage('settings/use-page-specific-transitions', true)
@@ -88,16 +73,6 @@ export const useSettings = defineStore('settings', () => {
}, { immediate: true })
return {
live2dModelFile,
live2dModelUrl,
live2dLoadSource,
live2dCurrentMotion,
live2dPosition,
live2dPositionInPercentageString,
live2dScale,
availableLive2dMotions,
live2dMotionMap,
loadingLive2dModel,
disableTransitions,
usePageSpecificTransitions,
language,