refactor(tamagotchi/stage-*): remove duplicated scene in the setting page for tamagotchi (#1460)

* refactor(tamagotchi): remove setting window duplicate scene

* refactor(tamagotchi): some changes

* [autofix.ci] apply automated fixes

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
This commit is contained in:
Lilia_Chen
2026-03-24 03:46:52 +08:00
committed by GitHub
co-authored by autofix-ci[bot]
parent 11bb6b8e0a
commit 9ae795a831
15 changed files with 578 additions and 149 deletions
@@ -208,6 +208,7 @@ export default defineConfig({
...base,
'**/settings/connection/index.vue',
'**/settings/data/index.vue',
'**/settings/models/index.vue',
'**/settings/system/general.vue',
'**/settings/modules/mcp.vue',
],
@@ -0,0 +1,67 @@
import type {
ModelSettingsRuntimeSnapshot,
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime'
import {
createEmptyModelSettingsRuntimeSnapshot,
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import { useBroadcastChannel } from '@vueuse/core'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import {
modelSettingsRuntimeSnapshotChannelName,
} from '../../shared/model-settings-runtime'
export function useModelSettingsRuntimeSnapshot() {
const runtimeSnapshot = ref<ModelSettingsRuntimeSnapshot>(createEmptyModelSettingsRuntimeSnapshot())
const { data, post } = useBroadcastChannel<ModelSettingsRuntimeChannelEvent, ModelSettingsRuntimeChannelEvent>({
name: modelSettingsRuntimeSnapshotChannelName,
})
const requestCurrent = () => {
post({ type: 'request-current' })
}
const syncFromOwner = () => {
requestCurrent()
}
const syncFromOwnerWhenVisible = () => {
if (document.visibilityState === 'visible')
requestCurrent()
}
onMounted(() => {
requestCurrent()
window.addEventListener('focus', syncFromOwner)
document.addEventListener('visibilitychange', syncFromOwnerWhenVisible)
})
onUnmounted(() => {
window.removeEventListener('focus', syncFromOwner)
document.removeEventListener('visibilitychange', syncFromOwnerWhenVisible)
})
watch(data, (event) => {
if (!event)
return
if (event.type === 'snapshot') {
runtimeSnapshot.value = event.snapshot
return
}
if (event.type === 'owner-gone') {
if (runtimeSnapshot.value.ownerInstanceId !== event.ownerInstanceId)
return
runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot()
}
})
return {
runtimeSnapshot,
requestCurrent,
}
}
@@ -1,6 +1,9 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import type { ChatProvider } from '@xsai-ext/providers/utils'
import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime'
import workletUrl from '@proj-airi/stage-ui/workers/vad/process.worklet?worker&url'
import { electron } from '@proj-airi/electron-eventa'
@@ -11,7 +14,11 @@ import {
useElectronMouseInWindow,
useElectronRelativeMouse,
} from '@proj-airi/electron-vueuse'
import { useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
import {
createEmptyModelSettingsRuntimeSnapshot,
resolveComponentStateToRuntimePhase,
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import { WidgetStage } from '@proj-airi/stage-ui/components/scenes'
import { useAudioRecorder } from '@proj-airi/stage-ui/composables/audio/audio-recorder'
import { useCanvasPixelIsTransparentAtPoint } from '@proj-airi/stage-ui/composables/canvas-alpha'
@@ -32,6 +39,9 @@ import ResourceStatusIsland from '../components/stage-islands/resource-status-is
import StatusIsland from '../components/stage-islands/status-island/index.vue'
import { electronOpenOnboarding } from '../../shared/eventa'
import {
modelSettingsRuntimeSnapshotChannelName,
} from '../../shared/model-settings-runtime'
import { useControlsIslandStore } from '../stores/controls-island'
import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
import { useWindowStore } from '../stores/window'
@@ -42,8 +52,8 @@ const statusIslandRef = ref<InstanceType<typeof StatusIsland>>()
const widgetStageRef = ref<InstanceType<typeof WidgetStage>>()
const stageCanvas = toRef(() => widgetStageRef.value?.canvasElement())
const componentStateStage = ref<'pending' | 'loading' | 'mounted'>('pending')
const isLoading = ref(true)
const stageMounted = computed(() => componentStateStage.value === 'mounted')
const isLoading = computed(() => !stageMounted.value)
const isIgnoringMouseEvents = ref(false)
const shouldFadeOnCursorWithin = ref(false)
@@ -73,9 +83,19 @@ const isTransparentByThree = useThreeSceneIsTransparentAtPoint(
{ regionRadius: 25 },
)
const { stageModelRenderer } = storeToRefs(useSettings())
const settingsStore = useSettings()
const { stageModelRenderer, stageModelSelectedUrl } = storeToRefs(settingsStore)
const modelStore = useModelStore()
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
const { stagePaused } = storeToRefs(useStageWindowLifecycleStore())
const { fadeOnHoverEnabled } = storeToRefs(useControlsIslandStore())
const modelSettingsRuntimeOwnerInstanceId = `tamagotchi-main-stage:${Math.random().toString(36).slice(2, 10)}`
const {
data: modelSettingsRuntimeChannelEvent,
post: postModelSettingsRuntimeChannelEvent,
} = useBroadcastChannel<ModelSettingsRuntimeChannelEvent, ModelSettingsRuntimeChannelEvent>({
name: modelSettingsRuntimeSnapshotChannelName,
})
const shouldUseThreeTransparencyHitTest = computed(() => shouldSampleStageTransparency({
componentState: componentStateStage.value,
fadeOnHoverEnabled: fadeOnHoverEnabled.value,
@@ -104,14 +124,49 @@ const live2dStore = useLive2d()
const { scale, positionInPercentageString } = storeToRefs(live2dStore)
const { live2dLookAtX, live2dLookAtY } = storeToRefs(useWindowStore())
watch(componentStateStage, () => isLoading.value = componentStateStage.value !== 'mounted', { immediate: true })
const { pause, resume } = watch(isTransparent, (transparent) => {
shouldFadeOnCursorWithin.value = fadeOnHoverEnabled.value && !transparent
}, { immediate: true })
const hearingDialogOpen = computed(() => controlsIslandRef.value?.hearingDialogOpen ?? false)
const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
const hasModel = !!stageModelSelectedUrl.value
if (stageModelRenderer.value === 'live2d') {
const phase = resolveComponentStateToRuntimePhase(componentStateStage.value, { hasModel })
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
renderer: 'live2d',
phase,
controlsLocked: hasModel ? phase !== 'mounted' : false,
previewAvailable: hasModel,
canCapturePreview: false,
updatedAt: Date.now(),
})
}
if (stageModelRenderer.value === 'vrm') {
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
renderer: 'vrm',
phase: hasModel ? scenePhase.value : 'no-model',
controlsLocked: hasModel
? (!stageMounted.value || sceneMutationLocked.value)
: false,
previewAvailable: hasModel,
canCapturePreview: false,
updatedAt: Date.now(),
})
}
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
updatedAt: Date.now(),
})
})
watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor250Ms, isOutsideWindow, isTransparent, hearingDialogOpen, fadeOnHoverEnabled, stagePaused], () => {
if (stagePaused.value) {
isIgnoringMouseEvents.value = false
@@ -152,6 +207,16 @@ watch([isOutsideFor250Ms, isOutsideStatusIslandFor250Ms, isAroundWindowBorderFor
pause()
}
})
// Emit runtime snapshot on change and on request from settings panel
watch(modelSettingsRuntimeSnapshot, (snapshot) => {
postModelSettingsRuntimeChannelEvent({ type: 'snapshot', snapshot })
}, { immediate: true })
watch(modelSettingsRuntimeChannelEvent, (event) => {
if (event?.type !== 'request-current')
return
postModelSettingsRuntimeChannelEvent({ type: 'snapshot', snapshot: modelSettingsRuntimeSnapshot.value })
})
const settingsAudioDeviceStore = useSettingsAudioDevice()
const { stream, enabled } = storeToRefs(settingsAudioDeviceStore)
@@ -333,6 +398,10 @@ onMounted(() => {
})
onUnmounted(() => {
postModelSettingsRuntimeChannelEvent({
type: 'owner-gone',
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
})
stopAudioInteraction()
})
@@ -0,0 +1,56 @@
<script setup lang="ts">
import { ModelSettingsPanel } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { ref } from 'vue'
import { useModelSettingsRuntimeSnapshot } from '../../../composables/model-settings-runtime-snapshot'
const palette = ref<string[]>([])
const { runtimeSnapshot } = useModelSettingsRuntimeSnapshot()
</script>
<template>
<div :class="['relative', 'h-full', 'flex', 'justify-center']">
<ModelSettingsPanel
:allow-extract-colors="false"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
:settings-class="[
'w-full',
'max-w-6xl',
'h-fit',
'sm:max-h-[80dvh]',
'overflow-y-scroll',
'relative',
]"
/>
</div>
<div
v-motion
:class="[
'fixed',
'right--5 top-[calc(100dvh-15rem)] bottom-0 z--1',
'pointer-events-none flex size-60 items-center justify-center',
'text-neutral-200/50 dark:text-neutral-600/20',
]"
:initial="{ scale: 0.9, opacity: 0, y: 15 }"
:enter="{ scale: 1, opacity: 1, y: 0 }"
:duration="500"
>
<div class="i-solar:people-nearby-bold-duotone text-60" />
</div>
</template>
<route lang="yaml">
meta:
layout: settings
titleKey: settings.pages.models.title
subtitleKey: settings.title
descriptionKey: settings.pages.models.description
icon: i-solar:people-nearby-bold-duotone
settingsEntry: true
order: 4
stageTransition:
name: slide
pageSpecificAvailable: true
</route>
@@ -0,0 +1,8 @@
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
export const modelSettingsRuntimeSnapshotChannelName = 'airi-model-settings-runtime-snapshot'
export type ModelSettingsRuntimeChannelEvent
= | { type: 'request-current' }
| { type: 'snapshot', snapshot: ModelSettingsRuntimeSnapshot }
| { type: 'owner-gone', ownerInstanceId: string }
+6 -5
View File
@@ -91,15 +91,16 @@ export function resolveCapRunArgs(capArgs: string[], env: NodeJS.ProcessEnv = pr
const [platformArg, ...rest] = capArgs
const platform = parseCapacitorPlatform(platformArg)
let target: string | undefined;
let target: string | undefined
if (platform === 'ios') {
target = env.CAPACITOR_DEVICE_ID_IOS;
} else if (platform === 'android') {
target = env.CAPACITOR_DEVICE_ID_ANDROID;
target = env.CAPACITOR_DEVICE_ID_IOS
}
else if (platform === 'android') {
target = env.CAPACITOR_DEVICE_ID_ANDROID
}
if (!target) {
return capArgs;
return capArgs
}
return [platformArg, '--target', target, ...rest]
@@ -1,19 +1,13 @@
<script setup lang="ts">
import type { Live2DCanvas } from '@proj-airi/stage-ui/components/scenes'
import { ModelSettings } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings'
import { Vibrant } from 'node-vibrant/browser'
import { ref } from 'vue'
const live2dCanvasRef = ref<InstanceType<typeof Live2DCanvas>>()
const modelSettingsRef = ref<{ capturePreviewFrame: () => Promise<Blob | undefined> }>()
const palette = ref<string[]>([])
async function extractColorsFromModel() {
if (!live2dCanvasRef.value)
return
const frame = await live2dCanvasRef.value.captureFrame()
const frame = await modelSettingsRef.value?.capturePreviewFrame()
if (!frame) {
console.error('No frame captured')
return
@@ -35,6 +29,7 @@ async function extractColorsFromModel() {
<template>
<div flex class="relative h-full flex-col-reverse md:flex-row">
<ModelSettings
ref="modelSettingsRef"
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"
vrm-scene-class="absolute max-h-[calc(100dvh-100px-56px)] w-full h-full"
@@ -1 +1,3 @@
export { default as ModelSettings } from './index.vue'
export { default as ModelSettingsPanel } from './panel.vue'
export { default as ModelSettingsPreviewStage } from './preview-stage.vue'
@@ -1,122 +1,55 @@
<script setup lang="ts">
import type { DisplayModel } from '../../../../stores/display-models'
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d'
import { ThreeScene } from '@proj-airi/stage-ui-three'
import { Button, Callout } from '@proj-airi/ui'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import { ref } from 'vue'
import Live2D from './live2d.vue'
import VRM from './vrm.vue'
import ModelSettingsPanel from './panel.vue'
import ModelSettingsPreviewStage from './preview-stage.vue'
import { DisplayModelFormat } from '../../../../stores/display-models'
import { useSettings } from '../../../../stores/settings'
import { ModelSelectorDialog } from '../../dialogs/model-selector'
import { createEmptyModelSettingsRuntimeSnapshot } from './runtime'
const props = defineProps<{
withDefaults(defineProps<{
palette: string[]
settingsClass?: string | string[]
allowExtractColors?: boolean
live2dSceneClass?: string | string[]
vrmSceneClass?: string | string[]
}>()
}>(), {
allowExtractColors: true,
})
defineEmits<{
(e: 'extractColorsFromModel'): void
}>()
const modelSelectorOpen = ref(false)
const positionCursor = useMouse()
const settingsStore = useSettings()
const { scale: live2dScale } = storeToRefs(useLive2d())
const {
live2dDisableFocus,
stageModelSelectedUrl,
stageModelSelected,
stageModelSelectedDisplayModel,
stageModelRenderer,
themeColorsHue,
themeColorsHueDynamic,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
live2dShadowEnabled,
live2dMaxFps,
} = storeToRefs(settingsStore)
const previewStageRef = ref<{ capturePreviewFrame: () => Promise<Blob | undefined> }>()
const runtimeSnapshot = ref<ModelSettingsRuntimeSnapshot>(createEmptyModelSettingsRuntimeSnapshot())
const currentSelectedDisplayModel = computed<DisplayModel | undefined>(() => stageModelSelectedDisplayModel.value)
async function handleModelPick(selectedModel: DisplayModel | undefined) {
stageModelSelected.value = selectedModel?.id ?? ''
await settingsStore.updateStageModel()
if (selectedModel?.format === DisplayModelFormat.Live2dZip)
useLive2d().shouldUpdateView()
async function capturePreviewFrame() {
return previewStageRef.value?.capturePreviewFrame()
}
function handleRuntimeSnapshotChanged(nextSnapshot: ModelSettingsRuntimeSnapshot) {
runtimeSnapshot.value = nextSnapshot
}
defineExpose({
capturePreviewFrame,
})
</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>
<div :class="['flex flex-wrap gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="currentSelectedDisplayModel" @pick="handleModelPick">
<Button variant="secondary">
Select Model
</Button>
</ModelSelectorDialog>
</div>
<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="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="stageModelSelectedUrl"
:model-id="stageModelSelected"
:disable-focus-at="live2dDisableFocus"
:scale="live2dScale"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
:live2d-auto-blink-enabled="live2dAutoBlinkEnabled"
:live2d-force-auto-blink-enabled="live2dForceAutoBlinkEnabled"
:live2d-shadow-enabled="live2dShadowEnabled"
:live2d-max-fps="live2dMaxFps"
/>
</div>
</template>
<!-- VRM component for 3D stage view -->
<template v-if="stageModelRenderer === 'vrm'">
<div :class="[...(props.vrmSceneClass ? (typeof props.vrmSceneClass === 'string' ? [props.vrmSceneClass] : props.vrmSceneClass) : [])]">
<ThreeScene :model-src="stageModelSelectedUrl" />
</div>
</template>
<ModelSettingsPanel
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
:settings-class="settingsClass"
@extract-colors-from-model="$emit('extractColorsFromModel')"
/>
<ModelSettingsPreviewStage
ref="previewStageRef"
:live2d-scene-class="live2dSceneClass"
:vrm-scene-class="vrmSceneClass"
@runtime-snapshot-changed="handleRuntimeSnapshotChanged"
/>
</template>
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { defaultModelParameters, useLive2d } from '@proj-airi/stage-ui-live2d'
import { OPFSCache } from '@proj-airi/stage-ui-live2d/utils/opfs-loader'
import { Button, Checkbox, FieldRange, SelectTab } from '@proj-airi/ui'
@@ -10,9 +12,13 @@ import { useSettings } from '../../../../stores/settings'
import { Section } from '../../../layouts'
import { ColorPalette } from '../../../widgets'
defineProps<{
const props = withDefaults(defineProps<{
palette: string[]
}>()
allowExtractColors?: boolean
runtimeSnapshot: ModelSettingsRuntimeSnapshot
}>(), {
allowExtractColors: true,
})
defineEmits<{
(e: 'extractColorsFromModel'): void
}>()
@@ -41,6 +47,7 @@ const selectedRuntimeMotion = ref<string>('')
const selectedRuntimeMotionName = ref<string>('')
const runtimeMotions = ref<Array<{ name: string, fullPath: string, displayPath: string, group: string, index: number }>>([])
const showMotionSelector = ref(false)
const canExtractColors = computed(() => props.runtimeSnapshot.canCapturePreview)
const fpsOptions = computed(() => [
{ value: 0, label: t('settings.live2d.fps.options.unlimited') },
{ value: 60, label: '60' },
@@ -221,6 +228,7 @@ onUnmounted(() => {
</FieldRange>
</Section>
<Section
v-if="allowExtractColors"
:title="t('settings.live2d.theme-color-from-model.title')"
icon="i-solar:magic-stick-3-bold-duotone"
inner-class="text-sm"
@@ -233,7 +241,7 @@ onUnmounted(() => {
:expand="false"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" @click="$emit('extractColorsFromModel')">
<Button variant="secondary" :disabled="!canExtractColors" @click="$emit('extractColorsFromModel')">
{{ t('settings.live2d.theme-color-from-model.button-extract.title') }}
</Button>
</Section>
@@ -0,0 +1,93 @@
<script setup lang="ts">
import type { DisplayModel } from '../../../../stores/display-models'
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { useLive2d } from '@proj-airi/stage-ui-live2d'
import { Button, Callout } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
import { computed, ref } from 'vue'
import Live2D from './live2d.vue'
import VRM from './vrm.vue'
import { DisplayModelFormat } from '../../../../stores/display-models'
import { useSettings } from '../../../../stores/settings'
import { ModelSelectorDialog } from '../../dialogs/model-selector'
const props = withDefaults(defineProps<{
palette: string[]
settingsClass?: string | string[]
allowExtractColors?: boolean
runtimeSnapshot: ModelSettingsRuntimeSnapshot
}>(), {
allowExtractColors: true,
})
defineEmits<{
(e: 'extractColorsFromModel'): void
}>()
const modelSelectorOpen = ref(false)
const settingsStore = useSettings()
const { stageModelSelected, stageModelSelectedDisplayModel } = storeToRefs(settingsStore)
const currentSelectedDisplayModel = computed<DisplayModel | undefined>(() => stageModelSelectedDisplayModel.value)
const effectiveRenderer = computed(() => props.runtimeSnapshot.renderer)
const settingsClassList = computed(() => {
if (!props.settingsClass)
return []
return typeof props.settingsClass === 'string' ? [props.settingsClass] : props.settingsClass
})
async function handleModelPick(selectedModel: DisplayModel | undefined) {
stageModelSelected.value = selectedModel?.id ?? ''
await settingsStore.updateStageModel()
if (selectedModel?.format === DisplayModelFormat.Live2dZip)
useLive2d().shouldUpdateView()
}
</script>
<template>
<div
:class="[
'flex flex-col gap-2',
'z-10 overflow-y-scroll p-2',
...settingsClassList,
]"
>
<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>
<div :class="['flex flex-wrap gap-2']">
<ModelSelectorDialog v-model:show="modelSelectorOpen" :selected-model="currentSelectedDisplayModel" @pick="handleModelPick">
<Button variant="secondary">
Select Model
</Button>
</ModelSelectorDialog>
</div>
<Live2D
v-if="effectiveRenderer === 'live2d'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="$emit('extractColorsFromModel')"
/>
<VRM
v-if="effectiveRenderer === 'vrm'"
:allow-extract-colors="allowExtractColors"
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="$emit('extractColorsFromModel')"
/>
</div>
</template>
@@ -0,0 +1,147 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { Live2DScene, useLive2d } from '@proj-airi/stage-ui-live2d'
import { ThreeScene, useModelStore } from '@proj-airi/stage-ui-three'
import { useMouse } from '@vueuse/core'
import { storeToRefs } from 'pinia'
import { computed, ref, watch } from 'vue'
import { useSettings } from '../../../../stores/settings'
import {
createEmptyModelSettingsRuntimeSnapshot,
resolveComponentStateToRuntimePhase,
} from './runtime'
const props = defineProps<{
live2dSceneClass?: string | string[]
vrmSceneClass?: string | string[]
}>()
const emit = defineEmits<{
(e: 'runtimeSnapshotChanged', value: ModelSettingsRuntimeSnapshot): void
}>()
const positionCursor = useMouse()
const settingsStore = useSettings()
const live2dStore = useLive2d()
const modelStore = useModelStore()
const live2dSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
const vrmSceneRef = ref<{ canvasElement: () => HTMLCanvasElement | undefined }>()
const live2dComponentState = ref<'pending' | 'loading' | 'mounted'>('pending')
const vrmPreviewStageInstanceId = `model-settings-preview-stage:${Math.random().toString(36).slice(2, 10)}`
const {
live2dDisableFocus,
stageModelSelected,
stageModelSelectedUrl,
stageModelRenderer,
themeColorsHue,
themeColorsHueDynamic,
live2dIdleAnimationEnabled,
live2dAutoBlinkEnabled,
live2dForceAutoBlinkEnabled,
live2dShadowEnabled,
live2dMaxFps,
} = storeToRefs(settingsStore)
const { scale: live2dScale } = storeToRefs(live2dStore)
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
const live2dSceneClassList = computed(() => normalizeClassList(props.live2dSceneClass))
const vrmSceneClassList = computed(() => normalizeClassList(props.vrmSceneClass))
function normalizeClassList(value?: string | string[]) {
if (!value)
return []
return typeof value === 'string' ? [value] : value
}
function captureCanvasFrame(canvas?: HTMLCanvasElement) {
return new Promise<Blob | undefined>((resolve) => {
if (!canvas)
return resolve(undefined)
canvas.toBlob(blob => resolve(blob ?? undefined))
})
}
async function capturePreviewFrame() {
if (stageModelRenderer.value === 'live2d')
return captureCanvasFrame(live2dSceneRef.value?.canvasElement())
if (stageModelRenderer.value === 'vrm')
return captureCanvasFrame(vrmSceneRef.value?.canvasElement())
return undefined
}
const runtimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() => {
const hasModel = !!stageModelSelectedUrl.value
if (stageModelRenderer.value === 'live2d') {
const phase = resolveComponentStateToRuntimePhase(live2dComponentState.value, { hasModel })
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: vrmPreviewStageInstanceId,
renderer: 'live2d',
phase,
controlsLocked: hasModel ? phase !== 'mounted' : false,
previewAvailable: hasModel,
canCapturePreview: !!live2dSceneRef.value?.canvasElement(),
updatedAt: Date.now(),
})
}
if (stageModelRenderer.value === 'vrm') {
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: vrmPreviewStageInstanceId,
renderer: 'vrm',
phase: hasModel ? scenePhase.value : 'no-model',
controlsLocked: hasModel ? sceneMutationLocked.value : false,
previewAvailable: hasModel,
canCapturePreview: !!vrmSceneRef.value?.canvasElement(),
updatedAt: Date.now(),
})
}
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: vrmPreviewStageInstanceId,
updatedAt: Date.now(),
})
})
watch(runtimeSnapshot, snapshot => emit('runtimeSnapshotChanged', snapshot), { immediate: true })
defineExpose({
capturePreviewFrame,
})
</script>
<template>
<template v-if="stageModelRenderer === 'live2d'">
<div :class="live2dSceneClassList">
<Live2DScene
ref="live2dSceneRef"
v-model:state="live2dComponentState"
:focus-at="{ x: positionCursor.x.value, y: positionCursor.y.value }"
:model-src="stageModelSelectedUrl"
:model-id="stageModelSelected"
:disable-focus-at="live2dDisableFocus"
:scale="live2dScale"
:theme-colors-hue="themeColorsHue"
:theme-colors-hue-dynamic="themeColorsHueDynamic"
:live2d-idle-animation-enabled="live2dIdleAnimationEnabled"
:live2d-auto-blink-enabled="live2dAutoBlinkEnabled"
:live2d-force-auto-blink-enabled="live2dForceAutoBlinkEnabled"
:live2d-shadow-enabled="live2dShadowEnabled"
:live2d-max-fps="live2dMaxFps"
/>
</div>
</template>
<template v-if="stageModelRenderer === 'vrm'">
<div :class="vrmSceneClassList">
<ThreeScene ref="vrmSceneRef" :model-src="stageModelSelectedUrl" />
</div>
</template>
</template>
@@ -0,0 +1,40 @@
export type ModelSettingsRuntimeRenderer = 'disabled' | 'live2d' | 'vrm'
export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mounted' | 'no-model' | 'error'
export interface ModelSettingsRuntimeSnapshot {
ownerInstanceId: string
renderer: ModelSettingsRuntimeRenderer
phase: ModelSettingsRuntimePhase
controlsLocked: boolean
previewAvailable: boolean
canCapturePreview: boolean
lastError?: string
updatedAt: number
}
export function createEmptyModelSettingsRuntimeSnapshot(
overrides: Partial<ModelSettingsRuntimeSnapshot> = {},
): ModelSettingsRuntimeSnapshot {
return {
ownerInstanceId: '',
renderer: 'disabled',
phase: 'pending',
controlsLocked: false,
previewAvailable: false,
canCapturePreview: false,
updatedAt: 0,
...overrides,
}
}
export function resolveComponentStateToRuntimePhase(
componentState: 'pending' | 'loading' | 'mounted',
options: {
hasModel?: boolean
} = {},
): ModelSettingsRuntimePhase {
if (options.hasModel === false)
return 'no-model'
return componentState
}
@@ -1,4 +1,6 @@
<script setup lang="ts">
import type { ModelSettingsRuntimeSnapshot } from './runtime'
import { useModelStore } from '@proj-airi/stage-ui-three'
import { Button, Callout, SelectTab } from '@proj-airi/ui'
import { storeToRefs } from 'pinia'
@@ -8,9 +10,13 @@ import { useI18n } from 'vue-i18n'
import { Container, PropertyColor, PropertyNumber, PropertyPoint } from '../../../data-pane'
import { ColorPalette } from '../../../widgets'
defineProps<{
const props = withDefaults(defineProps<{
palette: string[]
}>()
allowExtractColors?: boolean
runtimeSnapshot: ModelSettingsRuntimeSnapshot
}>(), {
allowExtractColors: true,
})
defineEmits<{
(e: 'extractColorsFromModel'): void
@@ -20,7 +26,6 @@ const { t } = useI18n()
const modelStore = useModelStore()
const {
sceneMutationLocked,
modelSize,
modelOffset,
cameraFOV,
@@ -42,6 +47,8 @@ const {
envSelect,
skyBoxIntensity,
} = storeToRefs(modelStore)
const controlsLocked = computed(() => props.runtimeSnapshot.controlsLocked)
const canExtractColors = computed(() => props.runtimeSnapshot.canCapturePreview)
const trackingOptions = computed<{
value: 'camera' | 'mouse' | 'none'
label: string
@@ -54,7 +61,7 @@ const trackingOptions = computed<{
// switch between hemisphere light and sky box
const settingsLockClass = computed(() => {
return sceneMutationLocked.value ? ['pointer-events-none', 'opacity-60'] : []
return controlsLocked.value ? ['pointer-events-none', 'opacity-60'] : []
})
const envOptions = computed(() => [
@@ -85,17 +92,19 @@ const envOptions = computed(() => [
'backdrop-blur-lg',
]"
>
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="sceneMutationLocked" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
<template v-if="allowExtractColors">
<ColorPalette class="mb-4 mt-2" :colors="palette.map(hex => ({ hex, name: hex }))" mx-auto />
<Button variant="secondary" :disabled="controlsLocked || !canExtractColors" @click="$emit('extractColorsFromModel')">
{{ t('settings.vrm.theme-color-from-model.button-extract.title') }}
</Button>
</template>
<div grid="~ cols-5 gap-1" p-2 :class="settingsLockClass">
<PropertyPoint
v-model:x="modelOffset.x"
v-model:y="modelOffset.y"
v-model:z="modelOffset.z"
:disabled="sceneMutationLocked"
:disabled="controlsLocked"
label="Model Position"
:x-config="{ min: -modelSize.x * 2, max: modelSize.x * 2, step: modelSize.x / 10000, label: 'X', formatValue: val => val?.toFixed(4) }"
:y-config="{ min: -modelSize.y * 2, max: modelSize.y * 2, step: modelSize.y / 10000, label: 'Y', formatValue: val => val?.toFixed(4) }"
@@ -103,17 +112,17 @@ const envOptions = computed(() => [
/>
<PropertyNumber
v-model="cameraFOV"
:config="{ min: 1, max: 180, step: 1, label: t('settings.vrm.scale-and-position.fov'), disabled: sceneMutationLocked }"
:config="{ min: 1, max: 180, step: 1, label: t('settings.vrm.scale-and-position.fov'), disabled: controlsLocked }"
:label="t('settings.vrm.scale-and-position.fov')"
/>
<PropertyNumber
v-model="cameraDistance"
:config="{ min: modelSize.z, max: modelSize.z * 20, step: modelSize.z / 100, label: t('settings.vrm.scale-and-position.camera-distance'), formatValue: val => val?.toFixed(4), disabled: sceneMutationLocked }"
:config="{ min: modelSize.z, max: modelSize.z * 20, step: modelSize.z / 100, label: t('settings.vrm.scale-and-position.camera-distance'), formatValue: val => val?.toFixed(4), disabled: controlsLocked }"
:label="t('settings.vrm.scale-and-position.camera-distance')"
/>
<PropertyNumber
v-model="modelRotationY"
:config="{ min: -180, max: 180, step: 1, label: t('settings.vrm.scale-and-position.rotation-y'), disabled: sceneMutationLocked }"
:config="{ min: -180, max: 180, step: 1, label: t('settings.vrm.scale-and-position.rotation-y'), disabled: controlsLocked }"
:label="t('settings.vrm.scale-and-position.rotation-y')"
/>
@@ -125,7 +134,7 @@ const envOptions = computed(() => [
<template v-for="option in trackingOptions" :key="option.value">
<Button
:class="[option.class, 'w-auto']"
:disabled="sceneMutationLocked"
:disabled="controlsLocked"
size="sm"
:variant="trackingMode === option.value ? 'primary' : 'secondary'"
:label="option.label"
@@ -135,34 +144,34 @@ const envOptions = computed(() => [
<PropertyNumber
v-model="directionalLightRotation.x"
:config="{ min: -180, max: 180, step: 1, label: 'RotationXDeg', formatValue: val => val?.toFixed(0), disabled: sceneMutationLocked }"
:config="{ min: -180, max: 180, step: 1, label: 'RotationXDeg', formatValue: val => val?.toFixed(0), disabled: controlsLocked }"
label="Directional Light Rotation - X"
/>
<PropertyNumber
v-model="directionalLightRotation.y"
:config="{ min: -180, max: 180, step: 1, label: 'RotationYDeg', formatValue: val => val?.toFixed(0), disabled: sceneMutationLocked }"
:config="{ min: -180, max: 180, step: 1, label: 'RotationYDeg', formatValue: val => val?.toFixed(0), disabled: controlsLocked }"
label="Directional Light Rotation - Y"
/>
<PropertyColor
v-model="directionalLightColor"
:disabled="sceneMutationLocked"
:disabled="controlsLocked"
label="Directional Light Color"
/>
<PropertyNumber
v-model="directionalLightIntensity"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity', disabled: sceneMutationLocked }"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity', disabled: controlsLocked }"
label="Directional Light Intensity"
/>
<PropertyNumber
v-model="ambientLightIntensity"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity', disabled: sceneMutationLocked }"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity', disabled: controlsLocked }"
label="Ambient Light Intensity"
/>
<PropertyColor
v-model="ambientLightColor"
:disabled="sceneMutationLocked"
:disabled="controlsLocked"
label="Ambient Light Color"
/>
</div>
@@ -179,24 +188,24 @@ const envOptions = computed(() => [
Environment
</div>
<div :class="['p-2', ...settingsLockClass]">
<SelectTab v-model="envSelect" :options="envOptions" :disabled="sceneMutationLocked" size="sm" />
<SelectTab v-model="envSelect" :options="envOptions" :disabled="controlsLocked" size="sm" />
</div>
<div v-if="envSelect === 'hemisphere'">
<!-- hemisphere settings -->
<div grid="~ cols-5 gap-1" p-2 :class="settingsLockClass">
<PropertyNumber
v-model="hemisphereLightIntensity"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity', disabled: sceneMutationLocked }"
:config="{ min: 0, max: 10, step: 0.01, label: 'Intensity', disabled: controlsLocked }"
label="Hemisphere Light Intensity"
/>
<PropertyColor
v-model="hemisphereSkyColor"
:disabled="sceneMutationLocked"
:disabled="controlsLocked"
label="Hemisphere Sky Color"
/>
<PropertyColor
v-model="hemisphereGroundColor"
:disabled="sceneMutationLocked"
:disabled="controlsLocked"
label="Hemisphere Ground Color"
/>
</div>
@@ -206,7 +215,7 @@ const envOptions = computed(() => [
<div grid="~ cols-5 gap-1" p-2 :class="settingsLockClass">
<PropertyNumber
v-model="skyBoxIntensity"
:config="{ min: 0, max: 1, step: 0.01, label: 'Intensity', disabled: sceneMutationLocked }"
:config="{ min: 0, max: 1, step: 0.01, label: 'Intensity', disabled: controlsLocked }"
:label="t('settings.vrm.skybox.skybox-intensity')"
/>
</div>
+1 -1
View File
@@ -119,7 +119,7 @@ catalogs:
xsai:
unspeech: ^0.1.11
enableGlobalVirtualStore: true
# enableGlobalVirtualStore: true
ignoredBuiltDependencies:
- '@prisma/client'