feat(stage-*): new Model Selector to select models for Live2D, VRM, and future MMD

Close #426
Close #414
Close #375
This commit is contained in:
Neko Ayaka
2025-08-24 23:38:32 +08:00
parent 9c9d6c18d1
commit 21b3307e9e
25 changed files with 1147 additions and 449 deletions
+11 -5
View File
@@ -1,6 +1,7 @@
<script setup lang="ts">
import type { AiriTamagotchiEvents } from './composables/tauri'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
@@ -16,22 +17,27 @@ import { useTauriEvent } from './composables/tauri'
import { useWindowMode } from './stores/window-controls'
useWindowMode()
const { language, themeColorsHue, themeColorsHueDynamic, allowVisibleOnAllWorkspaces } = storeToRefs(useSettings())
const i18n = useI18n()
const mcpStore = useMcpStore()
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const { language, themeColorsHue, themeColorsHueDynamic, allowVisibleOnAllWorkspaces } = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore()
const { shouldShowSetup } = storeToRefs(onboardingStore)
const { platform } = useAppRuntime()
const mcpStore = useMcpStore()
const { listen } = useTauriEvent<AiriTamagotchiEvents>()
const { platform } = useAppRuntime()
watch(language, () => {
i18n.locale.value = language.value
})
// FIXME: store settings to file
onMounted(() => {
// Initialize first-time setup check
onMounted(async () => {
onboardingStore.initializeSetupCheck()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
})
watch(themeColorsHue, () => {
@@ -45,10 +45,10 @@ const {
<template>
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
live-2d-settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
+8 -2
View File
@@ -1,5 +1,6 @@
<script setup lang="ts">
import { OnboardingDialog, ToasterRoot } from '@proj-airi/stage-ui/components'
import { useDisplayModelsStore } from '@proj-airi/stage-ui/stores/display-models'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { useSettings } from '@proj-airi/stage-ui/stores/settings'
import { StageTransitionGroup } from '@proj-airi/ui-transitions'
@@ -16,7 +17,9 @@ import 'vue-sonner/style.css'
usePWAStore()
const i18n = useI18n()
const settings = storeToRefs(useSettings())
const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const settings = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore()
const { shouldShowSetup } = storeToRefs(onboardingStore)
const isDark = useDark()
@@ -56,8 +59,11 @@ watch(settings.themeColorsHueDynamic, () => {
}, { immediate: true })
// Initialize first-time setup check when app mounts
onMounted(() => {
onMounted(async () => {
onboardingStore.initializeSetupCheck()
await displayModelsStore.loadDisplayModelsFromIndexedDB()
await settingsStore.initializeStageModel()
})
// Handle first-time setup events
@@ -45,10 +45,9 @@ const {
<template>
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
live-2d-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
live-2d-settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
vrm-settings-class="w-100% md:w-40% lg:w-40% xl:w-25% 2xl:w-30% h-fit sm:max-h-80dvh overflow-y-scroll relative"
:palette="palette" @extract-colors-from-model="extractColorsFromModel"
/>
</div>
@@ -345,14 +345,14 @@ watch([hue, saturation, value, alphaValue], () => {
<template>
<PopoverRoot>
<PopoverTrigger class="grid grid-col-span-3 grid-cols-3 h-fit items-center">
<div :style="{ backgroundColor: modelValue }" grid-col-span-1 min-h-5 rounded-md />
<div grid-col-span-2 font-mono text="[10px]">
<div :style="{ backgroundColor: modelValue }" grid-col-span-2 min-h-5 rounded-md />
<div grid-col-span-1 font-mono text="[10px] right">
{{ modelValue }}
</div>
</PopoverTrigger>
<PopoverPortal>
<PopoverContent align="start">
<div class="mt-2 space-y-2" bg="white/90 dark:neutral-900/90" rounded-xl p-1>
<PopoverContent align="start" class="relative z-20">
<div class="z-20 mt-2 space-y-2" bg="white/90 dark:neutral-900/90" rounded-xl p-1>
<!-- Color Map -->
<div class="relative h-48 w-full select-none overflow-hidden rounded-lg">
<div
@@ -567,6 +567,12 @@ watch([hue, saturation, value, alphaValue], () => {
</PopoverRoot>
</template>
<style scoped>
data-[data-reka-popper-content-wrapper=""] {
z-index: 20;
}
</style>
<style>
.hue-slider,
.alpha-slider {
@@ -14,42 +14,42 @@ const themeClasses: Record<ThemeVariant, {
}> = {
primary: {
container: [
'text-neutral-700/80 dark:text-neutral-300/80',
'bg-primary-50/60 dark:bg-primary-900/25 backdrop-blur-md',
`before:bg-primary-200 before:content-[''] before:dark:bg-primary-900`,
'text-neutral-900/80 dark:text-neutral-100/80',
'bg-primary-50/80 dark:bg-primary-900/50 backdrop-blur-md',
`before:bg-primary-500/30 before:content-[''] before:dark:bg-primary-200/20`,
],
label: [
'text-primary-500 dark:text-primary-400 font-semibold',
'text-primary-500 dark:text-primary-200 font-semibold',
],
},
lime: {
container: [
'text-neutral-700/80 dark:text-neutral-300/80',
'bg-lime-50/60 dark:bg-lime-900/25 backdrop-blur-md',
`before:bg-lime-200 before:content-[''] before:dark:bg-lime-900`,
'text-neutral-900/80 dark:text-neutral-100/80',
'bg-lime-50/80 dark:bg-lime-900/50 backdrop-blur-md',
`before:bg-lime-500/30 before:content-[''] before:dark:bg-lime-200/20`,
],
label: [
'text-lime-500 dark:text-lime-400 font-semibold',
'text-lime-500 dark:text-lime-200 font-semibold',
],
},
violet: {
container: [
'text-neutral-700/80 dark:text-neutral-300/80',
'bg-violet-50/60 dark:bg-violet-900/25 backdrop-blur-md',
`before:bg-violet-200 before:content-[''] before:dark:bg-violet-900`,
'text-neutral-900/80 dark:text-neutral-100/80',
'bg-violet-50/80 dark:bg-violet-900/50 backdrop-blur-md',
`before:bg-violet-500/30 before:content-[''] before:dark:bg-violet-200/20`,
],
label: [
'text-violet-500 dark:text-violet-400 font-semibold',
'text-violet-500 dark:text-violet-200 font-semibold',
],
},
orange: {
container: [
'text-neutral-700/80 dark:text-neutral-300/80',
'bg-orange-50/70 dark:bg-orange-900/25 backdrop-blur-md',
`before:bg-orange-200 before:content-[''] before:dark:bg-orange-900`,
'text-neutral-900/80 dark:text-neutral-100/80',
'bg-orange-100/60 dark:bg-orange-900/50 backdrop-blur-md',
`before:bg-orange-500/30 before:content-[''] before:dark:bg-orange-200/20`,
],
label: [
'text-orange-500 dark:text-orange-400 font-semibold',
'text-orange-500 dark:text-orange-200 font-semibold',
],
},
}
@@ -59,7 +59,7 @@ const themeClasses: Record<ThemeVariant, {
<div
relative
flex flex-col gap-1
overflow-hidden rounded-lg
rounded-lg
py="2.5" pl="5" pr-3
:class="[
...themeClasses[props.theme || 'violet'].container,
@@ -28,8 +28,8 @@ const emit = defineEmits<{
const commonThemeClasses = {
tabActive: [
'bg-white shadow-sm font-bold text-blue-600 dark:text-blue-400 text-xs',
'ring-2 ring-blue-500',
'bg-white shadow-sm font-bold text-primary-600 dark:text-primary-400 text-xs',
'ring-2 ring-primary-500',
'dark:bg-neutral-700',
'ring-1 ring-black/5 dark:ring-white/10',
],
@@ -117,6 +117,7 @@ function onClick(v: string, disabled?: boolean) {
sizeCls,
t.disabled ? 'opacity-40 cursor-not-allowed' : '',
modelValue === t.value ? themeClasses[theme].tabActive : themeClasses[theme].tabIdle,
'focus-visible:outline-2 focus-visible:outline-solid focus-visible:outline-primary-200 focus-visible:outline-offset-2',
]"
@click="onClick(t.value, t.disabled)"
>
@@ -140,7 +141,3 @@ function onClick(v: string, disabled?: boolean) {
</div>
</div>
</template>
<style scoped>
button:focus-visible { outline: 2px solid rgb(147 197 253); outline-offset: 2px }
</style>
@@ -0,0 +1,48 @@
<script setup lang="ts">
import type { DisplayModel } from '../../../../stores/display-models'
import { useMediaQuery, useResizeObserver, useScreenSafeArea } from '@vueuse/core'
import { DialogContent, DialogOverlay, DialogPortal, DialogRoot, DialogTitle, DialogTrigger, VisuallyHidden } from 'reka-ui'
import { DrawerContent, DrawerHandle, DrawerOverlay, DrawerPortal, DrawerRoot, DrawerTrigger } from 'vaul-vue'
import { onMounted } from 'vue'
import ModelManager from './model-selector.vue'
const showDialog = defineModel('show', { type: Boolean, default: false, required: false })
const selectedModel = defineModel<DisplayModel | undefined>({ type: Object, required: false })
const isDesktop = useMediaQuery('(min-width: 768px)')
const screenSafeArea = useScreenSafeArea()
useResizeObserver(document.documentElement, () => screenSafeArea.update())
onMounted(() => screenSafeArea.update())
</script>
<template>
<DialogRoot v-if="isDesktop" :open="showDialog" @update:open="value => showDialog = value">
<DialogTrigger as-child>
<slot />
</DialogTrigger>
<DialogPortal>
<DialogOverlay class="fixed inset-0 z-[9999] bg-black/50 backdrop-blur-sm data-[state=closed]:animate-fadeOut data-[state=open]:animate-fadeIn" />
<DialogContent class="fixed left-1/2 top-1/2 z-[9999] max-h-full max-w-5xl w-[92dvw] transform overflow-y-scroll rounded-2xl bg-white p-6 shadow-xl outline-none backdrop-blur-md scrollbar-none -translate-x-1/2 -translate-y-1/2 data-[state=closed]:animate-contentHide data-[state=open]:animate-contentShow dark:bg-neutral-900">
<VisuallyHidden>
<DialogTitle>Models</DialogTitle>
</VisuallyHidden>
<ModelManager v-model="selectedModel" @close="showDialog = false" />
</DialogContent>
</DialogPortal>
</DialogRoot>
<DrawerRoot v-else :open="showDialog" should-scale-background @update:open="value => showDialog = value">
<DrawerTrigger as-child>
<slot />
</DrawerTrigger>
<DrawerPortal>
<DrawerOverlay class="fixed inset-0" />
<DrawerContent class="fixed bottom-0 left-0 right-0 z-1000 mt-20 h-full max-h-[96%] flex flex-col rounded-t-2xl bg-neutral-50 px-4 pt-4 outline-none backdrop-blur-md dark:bg-neutral-900/95" :style="{ paddingBottom: `${Math.max(Number.parseFloat(screenSafeArea.bottom.value.replace('px', '')), 24)}px` }">
<DrawerHandle />
<ModelManager v-model="selectedModel" @close="showDialog = false" />
</DrawerContent>
</DrawerPortal>
</DrawerRoot>
</template>
@@ -0,0 +1,225 @@
<script setup lang="ts">
import type { DisplayModel } from '../../../../stores/display-models'
import { BasicInputFile } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { DropdownMenuContent, DropdownMenuItem, DropdownMenuPortal, DropdownMenuRoot, DropdownMenuTrigger, EditableArea, EditableEditTrigger, EditableInput, EditablePreview, EditableRoot, EditableSubmitTrigger } from 'reka-ui'
import { ref, watch } from 'vue'
import Button from '../../../Misc/Button.vue'
import { DisplayModelFormat, useDisplayModelsStore } from '../../../../stores/display-models'
const emits = defineEmits<{ (e: 'close', value: void): void }>()
const selectedModel = defineModel<DisplayModel | undefined>({ type: Object, required: false })
const displayModelStore = useDisplayModelsStore()
const { displayModelsFromIndexedDBLoading, displayModels } = storeToRefs(displayModelStore)
function handleRemoveModel(model: DisplayModel) {
displayModelStore.removeDisplayModel(model.id)
}
const highlightDisplayModelCard = ref<string | undefined>(selectedModel.value?.id)
const live2dFiles = ref<File[]>([])
const vrmFiles = ref<File[]>([])
function handleAddLive2DModel(file: File[]) {
if (file.length === 0)
return
if (!file[0].name.endsWith('.zip'))
return
displayModelStore.addDisplayModel(DisplayModelFormat.Live2dZip, file[0])
}
function handlePick(m: DisplayModel) {
selectedModel.value = m
emits('close', undefined)
}
function handleMobilePick() {
selectedModel.value = displayModels.value.find(model => model.id === highlightDisplayModelCard.value)
emits('close', undefined)
}
watch(live2dFiles, (newFiles) => {
handleAddLive2DModel(newFiles)
}, { deep: true })
function handleAddVRMModel(file: File[]) {
if (file.length === 0)
return
if (!file[0].name.endsWith('.vrm'))
return
displayModelStore.addDisplayModel(DisplayModelFormat.VRM, file[0])
}
const mapFormatRenderer: Record<DisplayModelFormat, string> = {
[DisplayModelFormat.Live2dZip]: 'Live2D',
[DisplayModelFormat.Live2dDirectory]: 'Live2D',
[DisplayModelFormat.VRM]: 'VRM',
[DisplayModelFormat.PMXDirectory]: 'MMD',
[DisplayModelFormat.PMD]: 'MMD',
}
watch(vrmFiles, (newFiles) => {
handleAddVRMModel(newFiles)
}, { deep: true })
</script>
<template>
<div pt="4 sm:0" gap="4 sm:6" h-full flex flex-col>
<div flex items-center>
<div w-full flex-1 text-xl>
Model Selector
</div>
<div>
<DropdownMenuRoot>
<DropdownMenuTrigger
bg="neutral-400/20 hover:neutral-400/45 active:neutral-400/60 dark:neutral-700/50 hover:dark:neutral-700/65 active:dark:neutral-700/90"
flex items-center justify-center gap-1 rounded-lg px-2 py-1 backdrop-blur-sm
transition="colors duration-200 ease-in-out"
aria-label="Options for Display Models"
>
<div i-solar:add-circle-bold />
<div>Add</div>
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="will-change-[opacity,transform] z-10000 max-w-45 rounded-lg p-0.5 text-white shadow-md outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:text-black"
bg="neutral-700/50 dark:neutral-950/50"
transition="colors duration-200 ease-in-out"
backdrop-blur-sm
align="end"
side="bottom"
:side-offset="8"
>
<DropdownMenuItem
class="data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-primary-100/20 sm:text-sm data-[highlighted]:text-primary-200"
transition="colors duration-200 ease-in-out"
>
<BasicInputFile v-model="live2dFiles" accept=".zip">
Live2D
</BasicInputFile>
</DropdownMenuItem>
<DropdownMenuItem
class="data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-primary-100/20 sm:text-sm data-[highlighted]:text-primary-200"
transition="colors duration-200 ease-in-out"
>
<BasicInputFile v-model="vrmFiles" accept=".vrm">
VRM
</BasicInputFile>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
</div>
<div v-if="displayModelsFromIndexedDBLoading">
Loading display models...
</div>
<div class="flex-1 md:flex-none" h-full w-full>
<div class="flex flex-1 flex-row gap-2 overflow-x-scroll md:grid lg:grid-cols-2 md:grid-cols-2 lg:max-h-80dvh md:overflow-y-scroll">
<div
v-for="(model) of displayModels"
:key="model.id"
v-auto-animate
relative w-full flex="~ col md:row" gap-2
@click="() => highlightDisplayModelCard = model.id"
>
<div absolute left-3 top-4 z-1>
<DropdownMenuRoot>
<DropdownMenuTrigger
bg="neutral-900/20 hover:neutral-900/45 active:neutral-900/60 dark:neutral-950/50 hover:dark:neutral-900/65 active:dark:neutral-900/90"
text="white"
h-7 w-7 flex items-center justify-center rounded-lg backdrop-blur-sm
transition="colors duration-200 ease-in-out"
aria-label="Options for Display Models"
>
<div i-solar:menu-dots-bold />
</DropdownMenuTrigger>
<DropdownMenuPortal>
<DropdownMenuContent
class="will-change-[opacity,transform] z-10000 max-w-45 rounded-lg p-0.5 text-white shadow-md outline-none data-[side=bottom]:animate-slideUpAndFade data-[side=left]:animate-slideRightAndFade data-[side=right]:animate-slideLeftAndFade data-[side=top]:animate-slideDownAndFade dark:text-black"
bg="neutral-700/50 dark:neutral-950/50"
transition="colors duration-200 ease-in-out"
backdrop-blur-sm
align="start"
side="bottom"
:side-offset="4"
>
<DropdownMenuItem
class="data-[disabled]:text-mauve8 relative flex cursor-pointer select-none items-center rounded-md px-3 py-2 text-base leading-none outline-none data-[disabled]:pointer-events-none data-[highlighted]:bg-red-100/20 sm:text-sm data-[highlighted]:text-red-200"
transition="colors duration-200 ease-in-out"
>
<button flex items-center gap-1 outline-none @click="handleRemoveModel(model)">
<div i-solar:trash-bin-minimalistic-bold-duotone />
<div>Remove</div>
</button>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenuRoot>
</div>
<div
class="min-w-65 lg:min-h-60"
aspect="12/16"
px-1 py-2
>
<img v-if="model.previewImage" :src="model.previewImage" h-full w-full rounded-lg object-cover :class="[highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-3 ring-primary-400' : 'ring-0 ring-transparent']" transition="all duration-200 ease-in-out">
<div v-else bg="neutral-100 dark:neutral-900" relative h-full w-full flex flex-col items-center justify-center gap-2 rounded-lg :class="[highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-4 ring-primary-400' : 'ring-0 ring-transparent']" transition="all duration-200 ease-in-out">
<div i-solar:question-square-bold-duotone text-4xl opacity-75 />
<div translate-y="100%" absolute top-0 flex flex-col translate-x--7 rotate-45 scale-250 gap-0 opacity-5>
<div text="sm sm:sm" translate-x-7 translate-y--2 text-nowrap>
unavailable Preview unavailable Preview
</div>
<div text="sm sm:sm" translate-x-0 translate-y--0 text-nowrap>
Preview unavailable Preview unavailable
</div>
<div text="sm sm:sm" translate-x--7 translate-y-2 text-nowrap>
unavailable Preview unavailable Preview
</div>
</div>
</div>
</div>
<div h-full w-full flex flex-col>
<div w-full flex-1 p-2>
<EditableRoot
v-slot="{ isEditing }"
:default-value="model.name"
placeholder="Model Name..."
class="flex gap-2"
auto-resize
>
<EditableArea class="w-[calc(100%-8px-1rem)] dark:text-white">
<EditablePreview class="line-clamp-1 w-[calc(100%-8px)] overflow-hidden text-ellipsis" />
<EditableInput class="w-[calc(100%-8px)]! placeholder:text-neutral-700 dark:placeholder:text-neutral-600" />
</EditableArea>
<EditableEditTrigger v-if="!isEditing">
<div i-solar:pen-2-line-duotone opacity-50 />
</EditableEditTrigger>
<div v-else class="flex gap-2">
<EditableSubmitTrigger>
<div i-solar:check-read-line-duotone opacity-50 />
</EditableSubmitTrigger>
</div>
</EditableRoot>
<div flex items-center gap-1 text="neutral-400 dark:neutral-600">
<div i-solar:tag-horizontal-bold />
<div>{{ mapFormatRenderer[model.format] }}</div>
</div>
</div>
<Button class="hidden md:block" variant="secondary" @click="handlePick(model)">
Pick
</Button>
</div>
</div>
</div>
</div>
<Button class="block md:hidden" @click="handleMobilePick()">
Confirm
</Button>
</div>
</template>
@@ -1,14 +1,8 @@
<script setup lang="ts">
import JSZip from 'jszip'
import localforage from 'localforage'
import { Checkbox, FieldRange, Input } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { Checkbox, FieldRange } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { ref } from 'vue'
import { useI18n } from 'vue-i18n'
import { Emotion } from '../../../../constants'
import { useLive2d } from '../../../../stores/live2d'
import { useSettings } from '../../../../stores/settings'
import { Section } from '../../../Layouts'
@@ -21,126 +15,106 @@ defineProps<{
defineEmits<{
(e: 'extractColorsFromModel'): void
(e: 'switchToVRM'): void
}>()
const { t } = useI18n()
const modelFileDialog = useFileDialog({
accept: 'application/zip',
})
const settings = useSettings()
const { live2dDisableFocus } = storeToRefs(settings)
const live2d = useLive2d()
const {
modelFile,
motionMap,
availableMotions,
modelUrl,
currentMotion,
scale,
position,
} = storeToRefs(live2d)
const localModelUrl = ref(modelUrl.value)
modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
motionMap.value = {}
modelFile.value = files[0]
live2d.shouldUpdateView()
}
})
// async function patchMotionMap(source: File, motionMap: Record<string, string>): Promise<File> {
// if (!Object.keys(motionMap).length)
// return source
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 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 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 }]
// })
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
model3JsonObject.FileReferences.Motions = motions
// zip.file(fileName, JSON.stringify(model3JsonObject, null, 2))
// const zipBlob = await zip.generateAsync({ type: 'blob' })
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,
// })
// }
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
// }
async function saveMotionMap() {
const fileFromIndexedDB = await localforage.getItem<File>('live2dModel')
if (!fileFromIndexedDB) {
return
}
const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
modelFile.value = patchedFile
}
const exportObjectUrl = useObjectUrl(modelFile)
// const patchedFile = await patchMotionMap(fileFromIndexedDB, motionMap.value)
// modelFile.value = patchedFile
// }
</script>
<template>
<Section
:title="t('settings.live2d.switch-to-vrm.title')"
icon="i-solar:magic-stick-3-bold-duotone"
:title="t('settings.live2d.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
:expand="true"
>
<Button variant="secondary" @click="$emit('switchToVRM')">
{{ t('settings.live2d.switch-to-vrm.change-to-vrm') }}
</Button>
</Section>
<Section
:title="t('settings.live2d.change-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<Button variant="secondary" @click="modelFileDialog.open()">
{{ t('settings.live2d.change-model.from-file') }}...
</Button>
<div flex items-center gap-2>
<Input
v-model="localModelUrl"
class="flex-1"
:placeholder="t('settings.live2d.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="modelUrl = localModelUrl">
{{ t('settings.live2d.change-model.from-url') }}
</Button>
</div>
<FieldRange v-model="scale" as="div" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.scale') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="position.x" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.x') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="position.y" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
<Section
:title="t('settings.live2d.theme-color-from-model.title')"
@@ -159,7 +133,7 @@ const exportObjectUrl = useObjectUrl(modelFile)
{{ t('settings.live2d.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
<Section
<!-- <Section
v-if="modelFile"
:title="t('settings.live2d.edit-motion-map.title')"
icon="i-solar:face-scan-circle-bold-duotone"
@@ -198,49 +172,7 @@ const exportObjectUrl = useObjectUrl(modelFile)
>
<Button w-full>Export</button>
</a>
</Section>
<Section
:title="t('settings.live2d.scale-and-position.title')"
icon="i-solar:scale-bold-duotone"
:class="[
'rounded-xl',
'bg-white/80 dark:bg-black/75',
'backdrop-blur-lg',
]"
size="sm"
:expand="false"
>
<FieldRange v-model="scale" as="div" :min="0.5" :max="2" :step="0.01" :label="t('settings.live2d.scale-and-position.scale')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.scale') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => scale = 1">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="position.x" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.x')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.x') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.x = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
<FieldRange v-model="position.y" as="div" :min="-100" :max="100" :step="1" :label="t('settings.live2d.scale-and-position.y')">
<template #label>
<div flex items-center>
<div>{{ t('settings.live2d.scale-and-position.y') }}</div>
<button px-2 text-xs outline-none title="Reset value to default" @click="() => position.y = 0">
<div i-solar:forward-linear transform-scale-x--100 text="neutral-500 dark:neutral-400" />
</button>
</div>
</template>
</FieldRange>
</Section>
</Section> -->
<Section
:title="t('settings.live2d.focus.title')"
icon="i-solar:eye-scan-bold-duotone"
@@ -1,6 +1,4 @@
<script setup lang="ts">
import { Input } from '@proj-airi/ui'
import { useFileDialog, useObjectUrl } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
@@ -17,19 +15,12 @@ defineProps<{
defineEmits<{
(e: 'extractColorsFromModel'): void
(e: 'switchToLive2D'): void
}>()
const { t } = useI18n()
const modelFileDialog = useFileDialog({
accept: '.vrm',
})
const vrm = useVRM()
const {
modelFile,
modelUrl,
modelSize,
modelOffset,
cameraFOV,
@@ -53,52 +44,12 @@ const {
envSelect,
} = storeToRefs(vrm)
const { defaultModelUrl } = vrm // 普通字符串
const trackingOptions = computed(() => [
{ value: 'camera', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.camera'), class: 'col-start-3' },
{ value: 'mouse', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.mouse'), class: 'col-start-4' },
{ value: 'none', label: t('settings.vrm.scale-and-position.eye-tracking-mode.options.option.disabled'), class: 'col-start-5' },
])
modelFileDialog.onChange((files) => {
if (files && files.length > 0) {
modelFile.value = files[0]
vrm.shouldUpdateView()
}
})
const urlFile = useObjectUrl(modelFile)
const urlRef = computed({
get: () => urlFile.value || modelUrl.value || '',
set: (value) => {
modelUrl.value = value
},
})
async function handleUrlLoad() {
const raw = (modelUrl.value || '').trim()
// If empty input, reset to default
if (!raw) {
modelFile.value = null
urlRef.value = defaultModelUrl
return
}
// 3) parse URL
let parsedUrl: URL
try {
parsedUrl = new URL(raw, window.location.origin)
}
catch {
console.warn('Illegal URL input')
return
}
if (!['http:', 'https:', 'blob:', 'data:'].includes(parsedUrl.protocol))
return
modelUrl.value = parsedUrl.href
}
// switch between hemisphere light and sky box
const tabList = [
{
@@ -130,9 +81,6 @@ const tabList = [
'backdrop-blur-lg',
]"
>
<Button variant="secondary" size="sm" @click="$emit('switchToLive2D')">
{{ t('settings.vrm.switch-to-vrm.change-to-vrm') }}
</Button>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
@@ -274,25 +222,6 @@ const tabList = [
'backdrop-blur-lg',
]"
>
<Button
variant="secondary" @click=" () => {
modelFileDialog.reset()
modelFileDialog.open()
}"
>
{{ t('settings.vrm.change-model.from-file') }}...
</Button>
<div flex items-center gap-2>
<Input
v-model="urlRef"
class="flex-1"
:placeholder="t('settings.vrm.change-model.from-url-placeholder')"
/>
<Button size="sm" variant="secondary" @click="handleUrlLoad">
{{ t('settings.vrm.change-model.from-url') }}
</Button>
</div>
<Callout :label="t('settings.vrm.scale-and-position.model-info-title')">
<div>
<div class="text-sm text-neutral-600 space-y-1 dark:text-neutral-400">
@@ -1,87 +1,85 @@
<script setup lang="ts">
import type { DisplayModel } from '../../../../stores/display-models'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { ref, watch } from 'vue'
import Callout from '../../../Layouts/Callout.vue'
import Button from '../../../Misc/Button.vue'
import Live2DScene from '../../../Scenes/Live2D.vue'
import VRMScene from '../../../Scenes/VRM.vue'
import ModelManagerDialog from '../../Dialogs/model-selector/model-selector-dialog.vue'
import Live2D from './Live2D.vue'
import VRM from './VRM.vue'
import { useLive2d } from '../../../../stores/live2d'
import { useSettings } from '../../../../stores/settings'
import { useVRM } from '../../../../stores/vrm'
const props = defineProps<{
palette: string[]
settingsClass?: string | string[]
live2dSceneClass?: string | string[]
live2dSettingsClass?: string | string[]
vrmSceneClass?: string | string[]
vrmSettingsClass?: string | string[]
}>()
defineEmits<{
(e: 'extractColorsFromModel'): void
}>()
const positionCursor = useMouse()
const selectedModel = ref<DisplayModel | undefined>()
const { stageView, live2dDisableFocus } = storeToRefs(useSettings())
const { modelFile: live2dModelFile, modelUrl: live2dModelUrl } = storeToRefs(useLive2d())
const { modelFile: vrmModelFile, modelUrl: vrmModelUrl } = storeToRefs(useVRM())
const positionCursor = useMouse()
const settingsStore = useSettings()
const { live2dDisableFocus, stageModelSelectedUrl, stageModelSelected, stageModelRenderer } = storeToRefs(settingsStore)
watch(selectedModel, () => {
stageModelSelected.value = selectedModel.value?.id
settingsStore.updateStageModel()
}, { deep: true })
</script>
<template>
<div
flex="~ col gap-2" z-10 overflow-y-scroll p-2 :class="[
...(props.settingsClass
? (typeof props.settingsClass === 'string' ? [props.settingsClass] : props.settingsClass)
: []),
]"
>
<Callout label="We support both 2D and 3D models">
<p>
Click <strong>Select Model</strong> to import different formats of
models into catalog, currently, <code>.zip</code> (Live2D) and <code>.vrm</code> (VRM) are supported.
</p>
<p>
Neuro-sama uses 2D model driven by Live2D Inc. developed framework.
While Grok Ani (first female character announced in Grok Companion)
uses 3D model that is driven by VRM / MMD open formats.
</p>
</Callout>
<ModelManagerDialog v-model="selectedModel">
<Button variant="secondary">
Select Model
</Button>
</ModelManagerDialog>
<Live2D v-if="stageModelRenderer === 'live2d'" :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" />
<VRM v-if="stageModelRenderer === 'vrm'" :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" />
</div>
<!-- Live2D component for 2D stage view -->
<template v-if="stageView === '2d'">
<div
:class="[
...(props.live2dSceneClass
? (typeof props.live2dSceneClass === 'string' ? [props.live2dSceneClass] : props.live2dSceneClass)
: []),
]"
>
<template v-if="stageModelRenderer === 'live2d'">
<div :class="[...(props.live2dSceneClass ? (typeof props.live2dSceneClass === 'string' ? [props.live2dSceneClass] : props.live2dSceneClass) : [])]">
<Live2DScene
:focus-at="{
x: positionCursor.x.value,
y: positionCursor.y.value,
}"
:model-src="live2dModelUrl"
:model-file="live2dModelFile"
:focus-at="{ x: positionCursor.x.value, y: positionCursor.y.value }"
:model-src="stageModelSelectedUrl"
:disable-focus-at="live2dDisableFocus"
/>
</div>
<div
flex="~ col gap-2" :class="[
...(props.live2dSettingsClass
? (typeof props.live2dSettingsClass === 'string' ? [props.live2dSettingsClass] : props.live2dSettingsClass)
: []),
]"
>
<Live2D :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-v-r-m="stageView = '3d'" />
</div>
</template>
<!-- VRM component for 3D stage view -->
<template v-if="stageView === '3d'">
<div
:class="[
...(props.vrmSceneClass
? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass)
: []),
]"
>
<VRMScene :model-src="vrmModelUrl" :model-file="vrmModelFile" />
</div>
<div h-full w-full p-2>
<div
flex="~ col gap-2"
:class="[
...(props.vrmSettingsClass
? (typeof props.vrmSettingsClass === 'string' ? [props.vrmSettingsClass] : props.vrmSettingsClass)
: []),
]"
>
<VRM :palette="palette" @extract-colors-from-model="$emit('extractColorsFromModel')" @switch-to-live-2-d="stageView = '2d'" />
</div>
<template v-if="stageModelRenderer === 'vrm'">
<div :class="[...(props.vrmSceneClass ? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass) : [])]">
<VRMScene :model-src="stageModelSelectedUrl" />
</div>
</template>
</template>
@@ -7,7 +7,6 @@ import '../../utils/live2d-zip-loader'
withDefaults(defineProps<{
modelSrc?: string
modelFile?: File | null
paused?: boolean
mouthOpenSize?: number
@@ -35,7 +34,6 @@ withDefaults(defineProps<{
>
<Live2DModel
:model-src="modelSrc"
:model-file="modelFile"
:app="app"
:mouth-open-size="mouthOpenSize"
:width="width"
@@ -2,9 +2,7 @@
import type { Application } from '@pixi/app'
import type { Cubism4InternalModel, InternalModel } from 'pixi-live2d-display/cubism4'
import localforage from 'localforage'
import { breakpointsTailwind, until, useBreakpoints, useDark, useDebounceFn, useObjectUrl } from '@vueuse/core'
import { breakpointsTailwind, until, useBreakpoints, useDark, useDebounceFn } from '@vueuse/core'
import { formatHex } from 'culori'
import { storeToRefs } from 'pinia'
import { DropShadowFilter } from 'pixi-filters'
@@ -25,7 +23,6 @@ type PixiLive2DInternalModel = InternalModel & {
const props = withDefaults(defineProps<{
modelSrc?: string
modelFile?: File | null
app?: Application
mouthOpenSize?: number
@@ -67,17 +64,6 @@ function parsePropsOffset() {
}
const modelSrcRef = toRef(() => props.modelSrc)
const modelFileRef = toRef(() => props.modelFile)
const modelFileSrc = useObjectUrl(modelFileRef)
const modelSrcNormalized = computed(() => {
if (modelFileSrc.value)
return modelFileSrc.value
if (modelSrcRef.value)
return modelSrcRef.value
return ''
})
const modelLoading = ref(false)
@@ -127,7 +113,6 @@ function setScaleAndPosition() {
}
const {
modelFile,
currentMotion,
availableMotions,
motionMap,
@@ -155,7 +140,7 @@ async function loadModel() {
model.value.destroy()
model.value = undefined
}
if (!modelSrcNormalized.value) {
if (!modelSrcRef.value) {
console.warn('No Live2D model source provided.')
modelLoading.value = false
return
@@ -163,11 +148,13 @@ async function loadModel() {
try {
const modelInstance = new Live2DModel<PixiLive2DInternalModel>()
if (modelFile.value) {
await Live2DFactory.setupLive2DModel(modelInstance, [modelFile.value], { autoInteract: false })
if (modelSrcRef.value.startsWith('blob:')) {
const res = await fetch(modelSrcRef.value)
const blob = await res.blob()
await Live2DFactory.setupLive2DModel(modelInstance, [blob], { autoInteract: false })
}
else {
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcNormalized.value, { autoInteract: false })
await Live2DFactory.setupLive2DModel(modelInstance, modelSrcRef.value, { autoInteract: false })
}
availableMotions.value.forEach((motion) => {
@@ -268,11 +255,6 @@ async function loadModel() {
localCurrentMotion.value = { group, index }
})
// save to indexdb
if (modelFile.value) {
await localforage.setItem('live2dModel', modelFile.value)
}
emits('modelLoaded')
}
finally {
@@ -299,7 +281,7 @@ function updateDropShadowFilter() {
}
watch([() => props.width, () => props.height], () => handleResize())
watch(modelSrcNormalized, async () => await loadModel(), { immediate: true })
watch(modelSrcRef, async () => await loadModel(), { immediate: true })
watch(dark, updateDropShadowFilter, { immediate: true })
watch([model, themeColorsHue], updateDropShadowFilter)
watch(offset, setScaleAndPosition)
@@ -27,7 +27,6 @@ import { useLive2d } from '../../stores/live2d'
import { useSpeechStore } from '../../stores/modules/speech'
import { useProvidersStore } from '../../stores/providers'
import { useSettings } from '../../stores/settings'
import { useVRM } from '../../stores/vrm'
withDefaults(defineProps<{
paused?: boolean
@@ -42,13 +41,11 @@ const db = ref<DuckDBWasmDrizzleDatabase>()
const vrmViewerRef = ref<{ setExpression: (expression: string) => void }>()
const { stageView, stageViewControlsEnabled, live2dDisableFocus } = storeToRefs(useSettings())
const { stageView, stageViewControlsEnabled, live2dDisableFocus, stageModelSelectedUrl } = storeToRefs(useSettings())
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { audioContext, calculateVolume } = useAudioContext()
const { onBeforeMessageComposed, onBeforeSend, onTokenLiteral, onTokenSpecial, onStreamEnd, onAssistantResponseEnd } = useChatStore()
const providersStore = useProvidersStore()
const { modelFile, modelUrl } = storeToRefs(useLive2d())
const { modelFile: vrmModelFile, modelUrl: vrmModelUrl } = storeToRefs(useVRM())
const audioAnalyser = ref<AnalyserNode>()
const nowSpeaking = ref(false)
@@ -140,14 +137,14 @@ const { currentMotion } = storeToRefs(useLive2d())
const emotionsQueue = useQueue<Emotion>({
handlers: [
async (ctx) => {
if (stageView.value === '3d') {
if (stageView.value === 'vrm') {
const value = EMOTION_VRMExpressionName_value[ctx.data]
if (!value)
return
await vrmViewerRef.value!.setExpression(value)
}
else if (stageView.value === '2d') {
else if (stageView.value === 'live2d') {
currentMotion.value = { group: EMOTION_EmotionMotionName_value[ctx.data] }
}
},
@@ -232,10 +229,9 @@ onMounted(async () => {
<div relative>
<div h-full w-full>
<Live2DScene
v-if="stageView === '2d'"
v-if="stageView === 'live2d'"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:model-src="modelUrl"
:model-file="modelFile"
:model-src="stageModelSelectedUrl"
:focus-at="focusAt"
:mouth-open-size="mouthOpenSize"
:paused="paused"
@@ -245,10 +241,9 @@ onMounted(async () => {
:disable-focus-at="live2dDisableFocus"
/>
<VRMScene
v-else-if="stageView === '3d'"
v-else-if="stageView === 'vrm'"
ref="vrmViewerRef"
:model-src="vrmModelUrl"
:model-file="vrmModelFile"
:model-src="stageModelSelectedUrl"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
min-w="50% <lg:full" min-h="100 sm:100" h-full w-full flex-1
:paused="paused"
@@ -15,7 +15,6 @@ import { OrbitControls, VRMModel } from '../Scenes'
const props = defineProps<{
modelSrc?: string
modelFile?: File | null
}>()
const emit = defineEmits<{
@@ -292,7 +291,6 @@ defineExpose({
<VRMModel
ref="modelRef"
:model-src="props.modelSrc"
:model-file="props.modelFile"
idle-animation="/assets/vrm/animations/idle_loop.vrma"
:paused="false"
@load-model-progress="(val) => emit('loadModelProgress', val)"
@@ -4,7 +4,7 @@ import type { AnimationClip, Group } from 'three'
import { VRMUtils } from '@pixiv/three-vrm'
import { useLoop, useTresContext } from '@tresjs/core'
import { until, useObjectUrl } from '@vueuse/core'
import { until } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { AnimationMixer, MathUtils, Mesh, MeshPhysicalMaterial, MeshStandardMaterial, Quaternion, Vector3, VectorKeyframeTrack } from 'three'
import { computed, onMounted, onUnmounted, ref, toRef, watch } from 'vue'
@@ -16,7 +16,6 @@ import { useVRM } from '../../../stores/vrm'
const props = defineProps<{
modelSrc?: string
modelFile?: File | null
idleAnimation: string
loadAnimations?: string[]
@@ -31,20 +30,9 @@ const emit = defineEmits<{
let disposeBeforeRenderLoop: (() => void | undefined)
const modelCreating = ref(false)
const modelLoading = ref(false)
const modelLoaded = ref(false)
const modelSrcRef = toRef(() => props.modelSrc)
const modelFileRef = toRef(() => props.modelFile)
const modelFileSrc = useObjectUrl(modelFileRef)
const modelSrcNormalized = computed(() => {
if (modelFileSrc.value)
return modelFileSrc.value
if (modelSrcRef.value)
return modelSrcRef.value
return ''
})
const vrm = ref<VRMCore>()
const vrmAnimationMixer = ref<AnimationMixer>()
@@ -68,8 +56,8 @@ const vrmGroup = ref<Group>()
const idleEyeSaccades = useIdleEyeSaccades()
async function loadModel() {
await until(modelCreating).not.toBeTruthy()
modelCreating.value = true
await until(modelLoading).not.toBeTruthy()
modelLoading.value = true
modelLoaded.value = false
try {
@@ -80,9 +68,12 @@ async function loadModel() {
if (vrm.value) {
componentCleanUp()
}
if (!modelSrcRef.value) {
return
}
try {
const _vrmInfo = await loadVrm(modelSrcNormalized.value, {
const _vrmInfo = await loadVrm(modelSrcRef.value, {
scene: scene.value,
lookAt: true,
onProgress: progress => emit('loadModelProgress', Number((100 * progress.loaded / progress.total).toFixed(2))),
@@ -255,7 +246,7 @@ async function loadModel() {
console.error(err)
}
finally {
modelCreating.value = false
modelLoading.value = false
}
}
@@ -276,7 +267,7 @@ watch(modelRotationY, (newRotationY) => {
})
// watch if the model needs to be reloaded
watch(modelSrcNormalized, (newSrc, oldSrc) => {
watch(modelSrcRef, (newSrc, oldSrc) => {
if (newSrc !== oldSrc) {
loadModel()
}
@@ -0,0 +1,252 @@
<script setup lang="ts">
import { FieldRange } from '@proj-airi/ui'
import { ref } from 'vue'
import PoppinMarkdown from './PoppinMarkdown.web.vue'
import {
createCutePopupAnimator,
createFadeAnimator,
createFloatAnimator,
createPopupAnimator,
createStackAnimator,
} from './animators'
const duration = ref(750)
// Sample markdown content
const markdownText = ref(`# Hello World
This is **bold text** and *italic text*.
Here's a list:
- Item 1
- Item 2
- Item 3
\`\`\`javascript
console.log('Hello, world!');
\`\`\`
> This is a blockquote with some **bold** content.
And here's a [link](https://example.com) and some inline \`code\`.`)
const chatMarkdown = ref(`Hi! I can help you with **markdown**. Here are some examples:
- *Italics* and **bold** text
- \`inline code\` snippets
- Even [links](https://example.com)
\`\`\`typescript
function greet(name: string) {
console.log(\`Hello, \${name}!\`);
}
\`\`\`
What would you like to know?`)
function createMarkdownStream(text: string) {
return new ReadableStream<Uint8Array>({
start(controller) {
const bytes = new TextEncoder().encode(text)
let index = 0
const interval = setInterval(() => {
if (index < bytes.length) {
controller.enqueue(bytes.subarray(index, index + 1))
index++
}
else {
clearInterval(interval)
controller.close()
}
}, 30 + 50 * Math.random()) // Faster for better demo
},
})
}
const streamingMarkdown = createMarkdownStream(`# Streaming Markdown Demo
This text is being **streamed** character by character!
## Features
- Real-time *markdown* rendering
- **Character-by-character** animation
- Support for \`code\` and other elements
\`\`\`javascript
// Code blocks appear as complete units
const message = "Hello from streaming markdown!";
console.log(message);
\`\`\`
> Blockquotes work too with **mixed** formatting.
Pretty cool, right?`)
const chatStreamingMarkdown = createMarkdownStream(`🤖 **AI Assistant**: Hello! I can help you with various tasks.
Here's what I can do:
- Answer questions about **programming**
- Help with \`code debugging\`
- Explain *technical concepts*
\`\`\`python
# Example: Python function
def greet_user(name):
return f"Hello, {name}! How can I help?"
print(greet_user("Developer"))
\`\`\`
> 💡 **Tip**: I stream my responses in real-time for a better experience!
What would you like to work on today?`)
</script>
<template>
<Story
title="PoppinMarkdown"
group="widgets"
:layout="{ type: 'grid', width: '100%' }"
>
<template #controls>
<div px-2>
<FieldRange
v-model.number="duration"
:min="50"
:max="3000"
:step="50"
label="Animation Duration"
/>
</div>
</template>
<Variant
id="static-markdown"
title="Static Markdown"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createFadeAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="streaming-markdown"
title="Streaming Markdown"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="streamingMarkdown"
:animator="createFadeAnimator({ duration: 100 })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="chat-style"
title="Chat Style"
>
<div h-auto max-w-2xl w-full p-4>
<div
class="border-2 border-primary-200/50 rounded-lg border-solid bg-primary-50/50 p-4 dark:border-primary-500/50 dark:bg-primary-950/70"
>
<PoppinMarkdown
:text="chatMarkdown"
:animator="createFadeAnimator({ duration: 150 })"
class="prose-sm prose prose-primary dark:prose-invert"
/>
</div>
</div>
</Variant>
<Variant
id="streaming-chat"
title="Streaming Chat Response"
>
<div h-auto max-w-2xl w-full p-4>
<div
class="border-2 border-primary-200/50 rounded-lg border-solid bg-primary-50/50 p-4 dark:border-primary-500/50 dark:bg-primary-950/70"
>
<PoppinMarkdown
:text="chatStreamingMarkdown"
:animator="createFadeAnimator({ duration: 80 })"
class="prose-sm prose prose-primary dark:prose-invert"
/>
</div>
</div>
</Variant>
<Variant
id="popup-animation"
title="Popup Animation"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createPopupAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="float-animation"
title="Float Animation"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createFloatAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="cute-popup"
title="Cute Popup"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="chatMarkdown"
:animator="createCutePopupAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="stack-animation"
title="Stack Animation"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:animator="createStackAnimator({ duration })"
class="prose-sm prose dark:prose-invert"
/>
</div>
</Variant>
<Variant
id="no-markdown"
title="Plain Text Mode"
>
<div h-auto max-w-2xl w-full p-4>
<PoppinMarkdown
:text="markdownText"
:markdown="false"
:animator="createFadeAnimator({ duration })"
class="text-sm font-mono"
/>
</div>
</Variant>
</Story>
</template>
@@ -0,0 +1,212 @@
<!-- PoppinMarkdown - PoppinText with markdown support -->
<script setup lang="ts">
import type { Element, Root } from 'hast'
import type { Animator } from './animators'
import RemarkParse from 'remark-parse'
import RemarkRehype from 'remark-rehype'
import { readGraphemeClusters } from 'clustr'
import { unified } from 'unified'
import { computed, defineComponent, h, ref, shallowRef, watch } from 'vue'
const props = withDefaults(defineProps<{
/**
* A plain string or a ReadableStream of bytes from text in UTF-8 encoding.
* If a stream is provided, the stream **SHOULD NOT** be reused.
*/
text?: string | ReadableStream<Uint8Array>
textClass?: string | string[]
animator?: Animator
/**
* Whether to process the accumulated text as markdown
* @default true
*/
markdown?: boolean
}>(), {
markdown: true,
})
const emits = defineEmits<{
(e: 'textSplit', grapheme: string): void
(e: 'markdownUpdated', html: string): void
}>()
const targets = ref<string[]>([])
const abortController = shallowRef<AbortController>()
const segmenter = new Intl.Segmenter('und', { granularity: 'grapheme' })
// Accumulate all text content
const accumulatedText = ref('')
const animatedCharCount = ref(0)
// Create markdown processor
const processor = unified()
.use(RemarkParse)
.use(RemarkRehype)
// Convert HAST to Vue render function
function hastToVNode(node: any, animator?: Animator): any {
if (node.type === 'text') {
// Text node - split into animated characters
const textContent = node.value.trim()
if (!textContent)
return null
const characters = [...segmenter.segment(textContent)].map(seg => seg.segment)
const currentAnimatedCount = animatedCharCount.value
return characters.map((char, index) => {
const globalIndex = currentAnimatedCount + index
return h('span', {
key: `char-${globalIndex}`,
class: 'inline-block poppin-char',
onVnodeMounted: (vnode: any) => {
if (animator && vnode.el) {
setTimeout(() => animator([vnode.el]), 0)
}
},
}, char)
})
}
if (node.type === 'element') {
const element = node as Element
const { tagName, properties = {}, children = [] } = element
// Check if this is a non-text element that should animate as a unit
const isNonTextElement = ['img', 'code', 'pre', 'hr', 'br'].includes(tagName)
|| (properties.className
&& (properties.className as string[]).includes('code-block'))
if (isNonTextElement) {
return h(tagName, {
...properties,
key: `element-${tagName}-${animatedCharCount.value}`,
class: [properties.className, 'poppin-element'],
onVnodeMounted: (vnode: any) => {
if (animator && vnode.el) {
setTimeout(() => animator([vnode.el]), 0)
}
},
})
}
// Regular element - recursively process children
const childVNodes = children
.map((child: any) => hastToVNode(child, animator))
.flat()
.filter(Boolean)
return h(tagName, properties, childVNodes)
}
return null
}
// Parse markdown to HAST and create Vue component
const MarkdownComponent = computed(() => {
if (!props.markdown || !accumulatedText.value) {
// Fallback to character animation
// eslint-disable-next-line vue/one-component-per-file
return defineComponent({
setup() {
return () => targets.value.map((char, index) =>
h('span', {
key: `fallback-${index}`,
class: 'inline-block color-primary-400 dark:color-primary-100',
onVnodeMounted: (vnode: any) => {
if (props.animator && vnode.el) {
setTimeout(() => props.animator?.([vnode.el]), 0)
}
},
}, char),
)
},
})
}
try {
const ast = processor.parse(accumulatedText.value)
const hast = processor.runSync(ast) as Root
// eslint-disable-next-line vue/one-component-per-file
return defineComponent({
setup() {
return () => h('div', {
class: ['markdown-content', props.textClass].flat().filter(Boolean),
}, hast.children.map((child: any) => hastToVNode(child, props.animator)))
},
})
}
catch (error) {
console.warn('Markdown parsing failed:', error)
return null
}
})
// Update character count when streaming
watch(accumulatedText, (newText, oldText) => {
if (newText.length > (oldText?.length || 0)) {
// Only update count when new characters are added
const addedChars = newText.slice(oldText?.length || 0)
const addedCharCount = [...segmenter.segment(addedChars)].length
animatedCharCount.value += addedCharCount
}
else if (newText.length < (oldText?.length || 0)) {
// Text was reset, reset count
animatedCharCount.value = 0
}
emits('markdownUpdated', newText)
})
// Reset animation count when text prop changes
watch(() => props.text, () => {
animatedCharCount.value = 0
}, { flush: 'pre' })
watch(() => props.text, async (text) => {
if (!text) {
accumulatedText.value = ''
targets.value = []
return
}
if (typeof text === 'string') {
accumulatedText.value = text
targets.value = [...segmenter.segment(text)].map(seg => seg.segment)
}
else {
abortController.value?.abort()
abortController.value = new AbortController()
try {
targets.value = []
accumulatedText.value = ''
for await (const cluster of readGraphemeClusters(text.getReader(), { signal: abortController.value.signal })) {
accumulatedText.value += cluster
targets.value.push(cluster)
emits('textSplit', cluster)
}
}
catch (error) {
if (error instanceof Error && error.message === 'Aborted') {
console.warn('Text reading aborted')
}
else {
console.error('Error reading text:', error)
}
}
}
}, { immediate: true })
</script>
<template>
<component
:is="MarkdownComponent"
:key="accumulatedText.length"
/>
</template>
+107 -1
View File
@@ -1,5 +1,111 @@
import localforage from 'localforage'
import { until } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { ref } from 'vue'
export const useDisplayModelsStore = defineStore('display-models', {
export enum DisplayModelFormat {
Live2dZip = 'live2d-zip',
Live2dDirectory = 'live2d-directory',
VRM = 'vrm',
PMXZip = 'pmx-zip',
PMXDirectory = 'pmx-directory',
PMD = 'pmd',
}
export type DisplayModel
= | DisplayModelFile
| DisplayModelURL
export interface DisplayModelFile {
id: string
format: DisplayModelFormat
type: 'file'
file: File
name: string
previewImage?: string
}
export interface DisplayModelURL {
id: string
format: DisplayModelFormat
type: 'url'
url: string
name: string
previewImage?: string
}
const displayModelsPresets: DisplayModel[] = [
{ id: 'preset-live2d-1', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_pro_zh.zip', name: 'Hiyori (Pro)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png' },
{ id: 'preset-live2d-2', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png' },
{ id: 'preset-live2d-3', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)' },
{ id: 'preset-live2d-4', format: DisplayModelFormat.Live2dZip, type: 'url', url: '/assets/live2d/models/hiyori_free_zh.zip', name: 'Hiyori (Free)', previewImage: '/assets/live2d/models/hiyori_free_zh/avatar.png' },
]
export const useDisplayModelsStore = defineStore('display-models', () => {
const displayModels = ref<DisplayModel[]>([])
const displayModelsFromIndexedDBLoading = ref(false)
async function loadDisplayModelsFromIndexedDB() {
await until(displayModelsFromIndexedDBLoading).toBe(false)
displayModelsFromIndexedDBLoading.value = true
const models = [...displayModelsPresets]
try {
await localforage.iterate<{ format: DisplayModelFormat, file: File }, void>((val, key) => {
if (key.startsWith('display-model-')) {
models.push({ id: key, format: val.format, type: 'file', file: val.file, name: val.file.name })
}
})
}
catch (err) {
console.error(err)
}
displayModels.value = models
displayModelsFromIndexedDBLoading.value = false
}
async function getDisplayModel(id: string) {
await until(displayModelsFromIndexedDBLoading).toBe(false)
return displayModels.value.find(model => model.id === id)
}
async function addDisplayModel(format: DisplayModelFormat, file: File) {
await until(displayModelsFromIndexedDBLoading).toBe(false)
const newDisplayModel: DisplayModelFile = { id: `display-model-${nanoid()}`, format, type: 'file', file, name: file.name }
displayModels.value.push(newDisplayModel)
localforage.setItem<DisplayModelFile>(newDisplayModel.id, newDisplayModel)
.catch(err => console.error(err))
}
async function renameDisplayModel(id: string, name: string) {
await until(displayModelsFromIndexedDBLoading).toBe(false)
const displayModel = await localforage.getItem<DisplayModelFile>(id)
if (!displayModel)
return
displayModel.name = name
}
async function removeDisplayModel(id: string) {
await until(displayModelsFromIndexedDBLoading).toBe(false)
await localforage.removeItem(id)
displayModels.value = displayModels.value.filter(model => model.id !== id)
}
return {
displayModels,
displayModelsFromIndexedDBLoading,
loadDisplayModelsFromIndexedDB,
getDisplayModel,
addDisplayModel,
renameDisplayModel,
removeDisplayModel,
}
})
+1 -36
View File
@@ -1,8 +1,6 @@
import localforage from 'localforage'
import { useBroadcastChannel, useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { computed, ref, watch } from 'vue'
type BroadcastChannelEvents
= | BroadcastChannelEventShouldUpdateView
@@ -30,37 +28,6 @@ export const useLive2d = defineStore('live2d', () => {
}
})
const indexedDbModelFile = ref<File | null>(null)
onMounted(async () => await loadModelFileFromIndexedDb())
onShouldUpdateView(async () => await loadModelFileFromIndexedDb())
async function loadModelFileFromIndexedDb() {
const file = await localforage.getItem<File>('assets-models-live2d')
if (file) {
indexedDbModelFile.value = file
}
}
const modelFile = computed({
get: () => {
return indexedDbModelFile.value
},
set: (file: File | null) => {
if (file) {
localforage.setItem('assets-models-live2d', file)
}
else {
localforage.removeItem('assets-models-live2d')
}
indexedDbModelFile.value = file
},
})
const defaultModelUrl = '/assets/live2d/models/hiyori_pro_zh.zip'
const modelUrl = useLocalStorage<string>('settings/live2d/model-src', defaultModelUrl)
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}%`,
@@ -72,8 +39,6 @@ export const useLive2d = defineStore('live2d', () => {
const scale = useLocalStorage('settings/live2d/scale', 1)
return {
modelFile,
modelUrl,
position,
positionInPercentageString,
currentMotion,
+73 -9
View File
@@ -1,11 +1,14 @@
import type { DisplayModel } from './display-models'
import messages from '@proj-airi/i18n/locales'
import { useLocalStorage } from '@vueuse/core'
import { useEventListener, useLocalStorage } from '@vueuse/core'
import { converter } from 'culori'
import { defineStore } from 'pinia'
import { onMounted, ref, watch } from 'vue'
import { useAudioDevice } from './audio'
import { DisplayModelFormat, useDisplayModelsStore } from './display-models'
const languageRemap: Record<string, string> = {
'zh-CN': 'zh-Hans',
@@ -28,12 +31,69 @@ const convert = converter('oklch')
const getHueFrom = (color?: string) => color ? convert(color)?.h : DEFAULT_THEME_COLORS_HUE
export const useSettings = defineStore('settings', () => {
const selectedAudioDevice = ref<MediaDeviceInfo>()
const displayModelsStore = useDisplayModelsStore()
const language = useLocalStorage('settings/language', '')
const stageView = useLocalStorage('settings/stage/view/model-renderer', '2d')
const stageModelSelected = useLocalStorage<string | undefined>('settings/stage/model', undefined)
const stageModelSelectedDisplayModel = ref<DisplayModel | undefined>()
const stageModelSelectedUrl = ref<string>()
const stageModelRenderer = ref<'live2d' | 'vrm' | 'disabled'>()
async function updateStageModel() {
if (!stageModelSelected.value) {
stageModelSelectedUrl.value = undefined
stageModelSelectedDisplayModel.value = undefined
stageModelRenderer.value = 'disabled'
return
}
const model = await displayModelsStore.getDisplayModel(stageModelSelected.value)
if (!model) {
stageModelSelectedUrl.value = undefined
stageModelSelectedDisplayModel.value = undefined
stageModelRenderer.value = 'disabled'
return
}
if (model.type === 'file') {
if (stageModelSelectedUrl.value) {
URL.revokeObjectURL(stageModelSelectedUrl.value)
}
stageModelSelectedUrl.value = URL.createObjectURL(model.file)
}
else {
stageModelSelectedUrl.value = model.url
}
switch (model.format) {
case DisplayModelFormat.Live2dZip:
stageModelRenderer.value = 'live2d'
break
case DisplayModelFormat.VRM:
stageModelRenderer.value = 'vrm'
break
default:
stageModelRenderer.value = 'disabled'
break
}
stageModelSelectedDisplayModel.value = model
}
async function initializeStageModel() {
await updateStageModel()
}
useEventListener('unload', () => {
if (stageModelSelectedUrl.value) {
URL.revokeObjectURL(stageModelSelectedUrl.value)
}
})
const stageViewControlsEnabled = ref(false)
const live2dDisableFocus = useLocalStorage('settings/live2d/disable-focus', false)
const disableTransitions = useLocalStorage('settings/disable-transitions', true)
@@ -91,26 +151,30 @@ export const useSettings = defineStore('settings', () => {
return hueDifference < 0.01 || hueDifference > 359.99
}
onMounted(() => {
language.value = getLanguage()
})
onMounted(() => language.value = getLanguage())
return {
disableTransitions,
usePageSpecificTransitions,
language,
stageView,
live2dDisableFocus,
stageModelRenderer,
stageModelSelected,
stageModelSelectedUrl,
stageModelSelectedDisplayModel,
stageViewControlsEnabled,
live2dDisableFocus,
themeColorsHue,
themeColorsHueDynamic,
selectedAudioDevice,
allowVisibleOnAllWorkspaces,
setThemeColorsHue,
applyPrimaryColorFrom,
isColorSelectedForPrimary,
initializeStageModel,
updateStageModel,
}
})
+1 -37
View File
@@ -1,8 +1,6 @@
import localforage from 'localforage'
import { useBroadcastChannel, useLocalStorage } from '@vueuse/core'
import { defineStore } from 'pinia'
import { computed, onMounted, ref, watch } from 'vue'
import { ref, watch } from 'vue'
import defaultSkyBoxSrc from '../components/Scenes/Tres/assets/sky_linekotsi_23_HDRI.hdr?url'
@@ -32,37 +30,6 @@ export const useVRM = defineStore('vrm', () => {
}
})
const indexedDbModelFile = ref<File | null>(null)
async function loadModelFileFromIndexedDb() {
const file = await localforage.getItem<File>('assets-models-vrm')
if (file) {
indexedDbModelFile.value = file
}
}
onMounted(async () => loadModelFileFromIndexedDb())
// onShouldUpdateView(() => loadModelFileFromIndexedDb())
const modelFile = computed({
get: () => {
return indexedDbModelFile.value
},
set: (file: File | null) => {
if (file) {
localforage.setItem('assets-models-vrm', file)
}
else {
localforage.removeItem('assets-models-vrm')
}
indexedDbModelFile.value = file
},
})
const defaultModelUrl = '/assets/vrm/models/AvatarSample-B/AvatarSample_B.vrm'
const modelUrl = useLocalStorage('settings/vrm/modelURL', defaultModelUrl)
const scale = useLocalStorage('settings/vrm/cameraScale', 1)
const modelSize = useLocalStorage('settings/vrm/modelSize', { x: 0, y: 0, z: 0 })
const modelOrigin = useLocalStorage('settings/vrm/modelOrigin', { x: 0, y: 0, z: 0 })
@@ -117,9 +84,6 @@ export const useVRM = defineStore('vrm', () => {
const skyBoxSrc = useLocalStorage('settings/vrm/skyBoxUrl', defaultSkyBoxSrc)
return {
defaultModelUrl,
modelFile,
modelUrl,
modelSize,
scale,
@@ -17,13 +17,20 @@ const isDragging = ref(false)
const isDraggingDebounced = useDebounce(isDragging, 150)
function handleFileChange(e: Event) {
const input = e.target as HTMLInputElement
files.value = []
if (input.files && input.files.length > 0) {
firstFile.value = input.files[0]
const input = e.target as HTMLInputElement
if (!input.files)
return
for (let i = 0; i < input.files?.length; i++) {
files.value.push(input.files[i])
}
if (files.value && files.value.length > 0) {
firstFile.value = files.value[0]
}
files.value = Array.from(input.files || [])
isDragging.value = false
}
</script>
@@ -44,7 +51,7 @@ function handleFileChange(e: Event) {
type="file"
:accept="accept"
:multiple="multiple"
class="absolute inset-0 cursor-pointer appearance-none opacity-0"
class="absolute inset-0 h-0 w-0 cursor-pointer appearance-none opacity-0"
@change="handleFileChange"
>
<slot :is-dragging="isDraggingDebounced" :first-file="firstFile" :files="files" />
+18
View File
@@ -203,22 +203,40 @@ export function sharedUnoConfig() {
*/
animation: {
keyframes: {
overlayShow: '{from{opacity:0;}to{opacity:1;}}',
overlayHide: '{from{opacity:1;}to{opacity:0;}}',
contentShow: '{from:{opacity:0;transform:translate(-50%,-48%) scale(0.96);}to:{opacity:1;transform:translate(-50%,-50%) scale(1);}}',
contentHide: '{from:{opacity:1;transform:translate(-50%,-50%) scale(1);}to:{opacity:0;transform:translate(-50%,-48%) scale(0.96);}}',
slideUpAndFade: '{from{opacity:0;transform:translateY(2px)}to{opacity:1;transform:translateY(0)}}',
slideRightAndFade: '{from{opacity:0;transform:translateX(-2px)}to{opacity:1;transform:translateX(0)}}',
slideDownAndFade: '{from{opacity:0;transform:translateY(-2px)}to{opacity:1;transform:translateY(0)}}',
slideLeftAndFade: '{from{opacity:0;transform:translateX(2px)}to{opacity:1;transform:translateX(0)}}',
fadeIn: '{from{opacity:0;}to{opacity:1;}}',
fadeOut: '{from{opacity:1;}to{opacity:0;}}',
},
durations: {
overlayShow: '300ms',
overlayHide: '300ms',
contentShow: '150ms',
contentHide: '150ms',
slideUpAndFade: '400ms',
slideRightAndFade: '400ms',
slideDownAndFade: '400ms',
slideLeftAndFade: '400ms',
fadeIn: '200ms',
fadeOut: '200ms',
},
timingFns: {
overlayShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
overlayHide: 'cubic-bezier(0.16, 1, 0.3, 1)',
contentShow: 'cubic-bezier(0.16, 1, 0.3, 1)',
contentHide: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideUpAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideRightAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideDownAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
slideLeftAndFade: 'cubic-bezier(0.16, 1, 0.3, 1)',
fadeIn: 'ease-in-out',
fadeOut: 'ease-in-out',
},
},
},