feat(tamagotchi): generate preview image when import live2d model (#555)

* fix(tamagotchi): set min width of settings page, use grid model selector view
* fix(tamagotchi): sort model by import time
* feat(tamagotchi): generate preview image when import live2d model
* fix(tamagotchi): don't show onboarding page when window open unless `/`
* fix: thanks to gemini
This commit is contained in:
LemonNeko
2025-09-05 13:27:26 +08:00
committed by GitHub
parent 08d579df4a
commit d55cdc220a
8 changed files with 142 additions and 55 deletions
@@ -22,6 +22,7 @@ pub fn new_settings_window<R: Runtime>(
)
.title("Settings")
.inner_size(550.0, 900.0)
.min_inner_size(550.0, 900.0)
.shadow(true)
.transparent(false)
.accept_first_mouse(true);
-8
View File
@@ -11,7 +11,6 @@ import { computed, onMounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { RouterView } from 'vue-router'
import { commands } from './bindings/tauri-plugins/window-router-link'
import { useAppRuntime } from './composables/runtime'
import { useTauriEvent } from './composables/tauri'
import { useWindowMode } from './stores/window-controls'
@@ -22,7 +21,6 @@ const displayModelsStore = useDisplayModelsStore()
const settingsStore = useSettings()
const { language, themeColorsHue, themeColorsHueDynamic, allowVisibleOnAllWorkspaces } = storeToRefs(settingsStore)
const onboardingStore = useOnboardingStore()
const { shouldShowSetup } = storeToRefs(onboardingStore)
const mcpStore = useMcpStore()
const { listen } = useTauriEvent<AiriTamagotchiEvents>()
@@ -48,12 +46,6 @@ watch(themeColorsHueDynamic, () => {
document.documentElement.classList.toggle('dynamic-hue', themeColorsHueDynamic.value)
}, { immediate: true })
watch(shouldShowSetup, () => {
if (shouldShowSetup.value) {
commands.go('/onboarding', 'onboarding')
}
})
onMounted(() => {
listen('mcp_plugin_destroyed', () => {
mcpStore.connected = false
+11
View File
@@ -4,6 +4,7 @@ import type { AiriTamagotchiEvents, Point } from '../composables/tauri'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useLive2d } from '@proj-airi/stage-ui/stores/live2d'
import { useMcpStore } from '@proj-airi/stage-ui/stores/mcp'
import { useOnboardingStore } from '@proj-airi/stage-ui/stores/onboarding'
import { connectServer } from '@proj-airi/tauri-plugin-mcp'
import { watchThrottled } from '@vueuse/core'
import { storeToRefs } from 'pinia'
@@ -12,6 +13,7 @@ import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import ResourceStatusIsland from '../components/Widgets/ResourceStatusIsland/index.vue'
import { commands as passThroughCommands } from '../bindings/tauri-plugins/window-pass-through-on-hover'
import { commands } from '../bindings/tauri-plugins/window-router-link'
import { useTauriCore, useTauriEvent, useTauriWindow } from '../composables/tauri'
import { useTauriGlobalShortcuts } from '../composables/tauri-global-shortcuts'
import { useRdevMouse } from '../composables/use-rdev-mouse'
@@ -20,6 +22,9 @@ import { useWindowStore } from '../stores/window'
import { useWindowControlStore } from '../stores/window-controls'
import { WindowControlMode } from '../types/window-controls'
const onboardingStore = useOnboardingStore()
const { shouldShowSetup } = storeToRefs(onboardingStore)
useTauriGlobalShortcuts()
const windowControlStore = useWindowControlStore()
const resourcesStore = useResourcesStore()
@@ -44,6 +49,12 @@ const isPassingThrough = ref(false)
const isOverUI = ref(false)
const isFirstTime = ref(true)
watch(shouldShowSetup, () => {
if (shouldShowSetup.value) {
commands.go('/onboarding', 'onboarding')
}
}, { immediate: true })
watchThrottled([mouseX, mouseY], async ([x, y]) => {
const canvas = widgetStageRef.value?.canvasElement()
if (!canvas) {
+1
View File
@@ -42,6 +42,7 @@
"@date-fns/utc": "^2.1.1",
"@formkit/auto-animate": "^0.8.4",
"@huggingface/transformers": "^3.7.2",
"@lemonneko/crop-empty-pixels": "0.1.1",
"@pixi/app": "^6.5.10",
"@pixi/constants": "^6.5.10",
"@pixi/core": "^6.5.10",
@@ -1,10 +1,10 @@
<script setup lang="ts">
import type { DisplayModel } from '../../../../stores/display-models'
import { BasicInputFile } from '@proj-airi/ui'
import { useFileDialog } from '@vueuse/core'
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 { ref } from 'vue'
import Button from '../../../Misc/Button.vue'
@@ -21,11 +21,9 @@ function handleRemoveModel(model: DisplayModel) {
}
const highlightDisplayModelCard = ref<string | undefined>(selectedModel.value?.id)
const live2dFiles = ref<File[]>([])
const vrmFiles = ref<File[]>([])
function handleAddLive2DModel(file: File[]) {
if (file.length === 0)
function handleAddLive2DModel(file: FileList | null) {
if (file === null || file.length === 0)
return
if (!file[0].name.endsWith('.zip'))
return
@@ -43,12 +41,8 @@ function handleMobilePick() {
emits('close', undefined)
}
watch(live2dFiles, (newFiles) => {
handleAddLive2DModel(newFiles)
}, { deep: true })
function handleAddVRMModel(file: File[]) {
if (file.length === 0)
function handleAddVRMModel(file: FileList | null) {
if (file === null || file.length === 0)
return
if (!file[0].name.endsWith('.vrm'))
return
@@ -65,9 +59,11 @@ const mapFormatRenderer: Record<DisplayModelFormat, string> = {
[DisplayModelFormat.PMD]: 'MMD',
}
watch(vrmFiles, (newFiles) => {
handleAddVRMModel(newFiles)
}, { deep: true })
const live2dDialog = useFileDialog({ accept: '.zip', multiple: false, reset: true })
const vrmDialog = useFileDialog({ accept: '.vrm', multiple: false, reset: true })
live2dDialog.onChange(handleAddLive2DModel)
vrmDialog.onChange(handleAddVRMModel)
</script>
<template>
@@ -100,18 +96,15 @@ watch(vrmFiles, (newFiles) => {
<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"
@click="live2dDialog.open()"
>
<BasicInputFile v-model="live2dFiles" accept=".zip">
Live2D
</BasicInputFile>
Live2D
</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"
transition="colors duration-200 ease-in-out" @click="vrmDialog.open()"
>
<BasicInputFile v-model="vrmFiles" accept=".vrm">
VRM
</BasicInputFile>
VRM
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenuPortal>
@@ -121,8 +114,8 @@ watch(vrmFiles, (newFiles) => {
<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 class="flex-1 md:flex-none" h-full w-full overflow-x-hidden overflow-y-scroll>
<div class="grid grid-cols-2 gap-2 lg:max-h-80dvh">
<div
v-for="(model) of displayModels"
:key="model.id"
@@ -165,11 +158,11 @@ watch(vrmFiles, (newFiles) => {
</DropdownMenuRoot>
</div>
<div
class="min-w-65 lg:min-h-60"
class="min-w-60 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">
<img v-if="model.previewImage" :src="model.previewImage" h-full w-full rounded-lg object-contain :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 overflow-hidden rounded-lg :class="[highlightDisplayModelCard && highlightDisplayModelCard === model.id ? 'ring-3 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>
@@ -215,7 +215,7 @@ async function loadModel() {
hookedUpdate?.call(this, model, now)
// Possibility 1: Only update eye focus when the model is idle
// Possibility 2: For models having no motion groups, currentGroup will be undefined while groups can be { idle: ... }
// Possibility 2: For models having no mo`ti`on groups, currentGroup will be undefined while groups can be { idle: ... }
if (!motionManager.state.currentGroup || motionManager.state.currentGroup === motionManager.groups.idle) {
idleEyeFocus.update(internalModel, now)
+83 -2
View File
@@ -1,10 +1,17 @@
import cropImg from '@lemonneko/crop-empty-pixels'
import localforage from 'localforage'
import { Application } from '@pixi/app'
import { extensions } from '@pixi/extensions'
import { Ticker, TickerPlugin } from '@pixi/ticker'
import { until } from '@vueuse/core'
import { nanoid } from 'nanoid'
import { defineStore } from 'pinia'
import { Live2DFactory, Live2DModel } from 'pixi-live2d-display/cubism4'
import { ref } from 'vue'
import '../utils/live2d-zip-loader'
export enum DisplayModelFormat {
Live2dZip = 'live2d-zip',
Live2dDirectory = 'live2d-directory',
@@ -67,7 +74,7 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
console.error(err)
}
displayModels.value = models
displayModels.value = models.sort((a, b) => b.importedAt - a.importedAt)
displayModelsFromIndexedDBLoading.value = false
}
@@ -82,10 +89,84 @@ export const useDisplayModelsStore = defineStore('display-models', () => {
return displayModelsPresets.find(model => model.id === id)
}
async function loadLive2DModelPreview(file: File) {
Live2DModel.registerTicker(Ticker)
extensions.add(TickerPlugin)
const offscreenCanvas = document.createElement('canvas')
offscreenCanvas.width = 720
offscreenCanvas.height = 1280
offscreenCanvas.style.position = 'absolute'
offscreenCanvas.style.top = '0'
offscreenCanvas.style.left = '0'
offscreenCanvas.style.objectFit = 'cover'
offscreenCanvas.style.display = 'block'
offscreenCanvas.style.zIndex = '10000000000'
offscreenCanvas.style.opacity = '0'
document.body.appendChild(offscreenCanvas)
const app = new Application({
view: offscreenCanvas,
// Ensure the drawing buffer persists so toDataURL() can read pixels
preserveDrawingBuffer: true,
backgroundAlpha: 0,
resizeTo: window,
})
const modelInstance = new Live2DModel()
const objUrl = URL.createObjectURL(file)
const res = await fetch(objUrl)
const blob = await res.blob()
try {
await Live2DFactory.setupLive2DModel(modelInstance, [new File([blob], file.name)], { autoInteract: false })
}
catch (error) {
app.destroy()
document.body.removeChild(offscreenCanvas)
URL.revokeObjectURL(objUrl)
console.error(error)
return
}
app.stage.addChild(modelInstance)
// transforms
modelInstance.x = 275
modelInstance.y = 450
modelInstance.width = offscreenCanvas.width
modelInstance.height = offscreenCanvas.height
modelInstance.scale.set(0.1, 0.1)
modelInstance.anchor.set(0.5, 0.5)
await new Promise(resolve => setTimeout(resolve, 500))
// Force a render to ensure the latest frame is in the drawing buffer
app.renderer.render(app.stage)
const croppedCanvas = cropImg(offscreenCanvas)
const dataUrl = croppedCanvas.toDataURL()
app.destroy()
document.body.removeChild(offscreenCanvas)
URL.revokeObjectURL(objUrl)
// return dataUrl
return dataUrl
}
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, importedAt: Date.now() }
displayModels.value.push(newDisplayModel)
if (format === DisplayModelFormat.Live2dZip) {
const previewImage = await loadLive2DModelPreview(file)
if (!previewImage)
return
newDisplayModel.previewImage = previewImage
}
displayModels.value.unshift(newDisplayModel)
localforage.setItem<DisplayModelFile>(newDisplayModel.id, newDisplayModel)
.catch(err => console.error(err))
+26 -18
View File
@@ -38,7 +38,7 @@ catalogs:
version: 0.4.0-beta.2
'@xsai/shared':
specifier: ^0.4.0-beta.2
version: 0.4.0-beta.2
version: 0.4.0-beta.3
'@xsai/shared-chat':
specifier: ^0.4.0-beta.2
version: 0.4.0-beta.2
@@ -429,7 +429,7 @@ importers:
version: 0.4.0-beta.2
'@xsai/shared':
specifier: 'catalog:'
version: 0.4.0-beta.2
version: 0.4.0-beta.3
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.4.0-beta.2
@@ -781,7 +781,7 @@ importers:
version: 0.4.0-beta.2
'@xsai/shared':
specifier: 'catalog:'
version: 0.4.0-beta.2
version: 0.4.0-beta.3
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.4.0-beta.2
@@ -1251,6 +1251,9 @@ importers:
'@huggingface/transformers':
specifier: ^3.7.2
version: 3.7.2
'@lemonneko/crop-empty-pixels':
specifier: 0.1.1
version: 0.1.1
'@pixi/app':
specifier: ^6.5.10
version: 6.5.10(@pixi/core@6.5.10(@pixi/constants@6.5.10)(@pixi/extensions@6.5.10)(@pixi/math@6.5.10)(@pixi/runner@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/ticker@6.5.10(@pixi/extensions@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/display@6.5.10(@pixi/constants@6.5.10)(@pixi/math@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10))))(@pixi/math@6.5.10)(@pixi/utils@6.5.10(@pixi/constants@6.5.10)(@pixi/settings@6.5.10(@pixi/constants@6.5.10)))
@@ -1382,7 +1385,7 @@ importers:
version: 0.4.0-beta.2
'@xsai/shared':
specifier: 'catalog:'
version: 0.4.0-beta.2
version: 0.4.0-beta.3
'@xsai/shared-chat':
specifier: 'catalog:'
version: 0.4.0-beta.2
@@ -4275,6 +4278,9 @@ packages:
'@kwsites/promise-deferred@1.1.1':
resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==}
'@lemonneko/crop-empty-pixels@0.1.1':
resolution: {integrity: sha512-4Mp6gBN3XbXw5OXyN0rF5bhWWISmrU4DRCGUWDnyWWFCERE2SZwvv2w4hoPhg804BcI0wXLPk5lHf/v15AFXSA==}
'@lezer/common@1.2.3':
resolution: {integrity: sha512-w7ojc8ejBqr2REPsWxJjrMFsA/ysDCFICn8zEOR9mrqzOu2amhITYuLD8ag6XZf0CFXDrhKqw7+tW8cX66NaDA==}
@@ -7493,8 +7499,8 @@ packages:
'@xsai/shared@0.3.5':
resolution: {integrity: sha512-gxWH+9UjhXgqqKeU/o9UteC/ih1FzxEEsmPojImd/jvq4j/wtIg4aP3dCFnu+EhCT/MRQ38dkBSra99iY9cC3w==}
'@xsai/shared@0.4.0-beta.2':
resolution: {integrity: sha512-nKdT+/gon1FxkEqv1iKfS2QRWnmYY/2o7Wl+Bcfot45qACE0sK9E4nw2BeLI/MeRYD6w7bTLP2J2U8373aHdYA==}
'@xsai/shared@0.4.0-beta.3':
resolution: {integrity: sha512-IOqe0CzSdRbaj5vgfL3pAjzOk/u2NCm0RrcsYgvvRc6VyTIPGsgAUuDpWXD+OfWEtCK4t24Q6qhjLkhgo2S9rA==}
'@xsai/stream-text@0.3.5':
resolution: {integrity: sha512-XUyM3/jo9FulWoqmQU6vvHFOoRZzr3rcsTGm0MY8hUXg9HJl91N0voQGb6R5AMlo5XgoSbO3GMFzJ26AVxmRMg==}
@@ -17425,6 +17431,8 @@ snapshots:
'@kwsites/promise-deferred@1.1.1': {}
'@lemonneko/crop-empty-pixels@0.1.1': {}
'@lezer/common@1.2.3': {}
'@lezer/highlight@1.2.1':
@@ -21427,16 +21435,16 @@ snapshots:
'@xsai-ext/providers-cloud@0.4.0-beta.2':
dependencies:
'@xsai-ext/shared-providers': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai-ext/providers-local@0.4.0-beta.2':
dependencies:
'@xsai-ext/shared-providers': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai-ext/shared-providers@0.2.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai-ext/shared-providers@0.3.5':
dependencies:
@@ -21444,7 +21452,7 @@ snapshots:
'@xsai-ext/shared-providers@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai-transformers/embed@0.0.7(web-worker@1.5.0)':
dependencies:
@@ -21471,11 +21479,11 @@ snapshots:
'@xsai/embed@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/generate-speech@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/generate-text@0.3.5':
dependencies:
@@ -21484,16 +21492,16 @@ snapshots:
'@xsai/generate-text@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/shared-chat': 0.4.0-beta.2
'@xsai/generate-transcription@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/model@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/shared-chat@0.3.5':
dependencies:
@@ -21501,13 +21509,13 @@ snapshots:
'@xsai/shared-chat@0.4.0-beta.2':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/shared@0.2.2': {}
'@xsai/shared@0.3.5': {}
'@xsai/shared@0.4.0-beta.2': {}
'@xsai/shared@0.4.0-beta.3': {}
'@xsai/stream-text@0.3.5':
dependencies:
@@ -21519,7 +21527,7 @@ snapshots:
'@xsai/tool@0.4.0-beta.2(zod-to-json-schema@3.24.6(zod@4.1.5))(zod@4.1.5)':
dependencies:
'@xsai/shared': 0.4.0-beta.2
'@xsai/shared': 0.4.0-beta.3
'@xsai/shared-chat': 0.4.0-beta.2
xsschema: 0.4.0-beta.2(@valibot/to-json-schema@1.0.0-rc.0(valibot@1.0.0-beta.9(typescript@5.9.2)))(zod-to-json-schema@3.24.6(zod@4.1.5))(zod@4.1.5)
transitivePeerDependencies: