refactor(stage(live2d(settings))): merge ui, extract store (#277)
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
@@ -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) => {
|
||||
|
||||
+48
-38
@@ -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,5 +1,6 @@
|
||||
export * from './audio'
|
||||
export * from './chat'
|
||||
export * from './live2d'
|
||||
export * from './llm'
|
||||
export * from './mcp'
|
||||
export * from './modules'
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user