mirror of
https://github.com/moeru-ai/airi.git
synced 2026-08-14 08:52:42 +00:00
+12
-28
@@ -1,19 +1,19 @@
|
||||
<script lang="ts" setup>
|
||||
import { supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { defaultControlConfig as threeCtrlConf, supportedControl as threeSupportedControl, useThreeViewControl } from '@proj-airi/stage-ui-three'
|
||||
import { defaultControlConfig as l2dCtrlConf, supportedControl as l2dSupportedCtrl, useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { Button } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
const { stageModelRenderer } = storeToRefs(useSettingsStageModel())
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, reset: l2dReset } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threePlainCtrlEnabled, viewControlMode: threeCtrlMode, reset: threeReset } = useThreeViewControl()
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled, viewControlMode: l2dCtrlMode, set: l2dSet } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threeSliderCtrlEnabled, viewControlMode: threeCtrlMode, set: threeSet } = useThreeViewControl()
|
||||
const controlEnabled = computed(() => {
|
||||
if (stageModelRenderer.value === 'live2d')
|
||||
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, reset: l2dReset }
|
||||
return { enabled: l2dViewCtrlEnabled, mode: l2dCtrlMode, supported: l2dSupportedCtrl, conf: l2dCtrlConf, reset: l2dSet }
|
||||
if (stageModelRenderer.value === 'vrm')
|
||||
return { enabled: threePlainCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, reset: threeReset }
|
||||
return { enabled: threeSliderCtrlEnabled, mode: threeCtrlMode, supported: threeSupportedControl, conf: threeCtrlConf, reset: threeSet }
|
||||
return null
|
||||
})
|
||||
|
||||
@@ -26,39 +26,23 @@ function handleViewControlsToggle(targetMode: string) {
|
||||
}
|
||||
controlEnabled.value.mode.value = targetMode as any
|
||||
}
|
||||
|
||||
// watch()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div w-full flex flex-1 items-center self-end justify-end gap-2>
|
||||
<Transition name="fade">
|
||||
<div v-if="controlEnabled?.enabled.value" w-full flex justify-between gap-2>
|
||||
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'x'" w-full @click="handleViewControlsToggle('x')">
|
||||
X
|
||||
</Button>
|
||||
<Button variant="secondary-muted" :toggled="controlEnabled.mode.value === 'y'" w-full @click="handleViewControlsToggle('y')">
|
||||
Y
|
||||
</Button>
|
||||
<Button v-if="controlEnabled.supported.includes('z' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'z'" w-full @click="handleViewControlsToggle('z')">
|
||||
Z
|
||||
</Button>
|
||||
<Button v-if="controlEnabled.supported.includes('cameraFOV' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraFOV'" w-full @click="handleViewControlsToggle('cameraFOV')">
|
||||
FOV
|
||||
</Button>
|
||||
<Button v-if="controlEnabled.supported.includes('scale' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'scale'" w-full @click="handleViewControlsToggle('scale')">
|
||||
Scale
|
||||
</Button>
|
||||
<Button v-if="controlEnabled.supported.includes('cameraDistance' as any)" variant="secondary-muted" :toggled="controlEnabled.mode.value === 'cameraDistance'" w-full @click="handleViewControlsToggle('cameraDistance')">
|
||||
Dis
|
||||
<Button
|
||||
v-for="control in controlEnabled.supported" :key="control" variant="secondary-muted"
|
||||
:toggled="controlEnabled.mode.value === control" w-full @click="handleViewControlsToggle(control)"
|
||||
>
|
||||
{{ (controlEnabled.conf as any)[control].buttonText }}
|
||||
</Button>
|
||||
</div>
|
||||
</Transition>
|
||||
<button
|
||||
w-fit flex items-center self-end justify-center justify-self-end rounded-xl p-2 backdrop-blur-md
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30"
|
||||
bg="neutral-50/70 dark:neutral-800/70"
|
||||
title="View"
|
||||
border="2 solid neutral-100/60 dark:neutral-800/30" bg="neutral-50/70 dark:neutral-800/70" title="View"
|
||||
text="neutral-500 dark:neutral-400"
|
||||
@click="controlEnabled && (controlEnabled.enabled.value = !controlEnabled.enabled.value)"
|
||||
>
|
||||
|
||||
@@ -31,7 +31,6 @@ import { useTranscriptions } from '../../composables/use-transcriptions'
|
||||
import { BackgroundDialogPicker } from '../Backgrounds'
|
||||
|
||||
const { isDark, toggleDark } = useTheme()
|
||||
const hearingDialogOpen = ref(false)
|
||||
const chatOrchestrator = useChatOrchestratorStore()
|
||||
const chatSession = useChatSessionStore()
|
||||
const chatStream = useChatStreamStore()
|
||||
@@ -59,24 +58,25 @@ const { themeColorsHueDynamic } = storeToRefs(useSettings())
|
||||
const { viewControlsEnabled: l2dViewCtrlEnabled } = useL2dViewControl()
|
||||
const { viewControlsEnabled: threeViewCtrlEnabled } = useThreeViewControl()
|
||||
const settingsAudioDevice = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput, stream, audioInputs } = storeToRefs(settingsAudioDevice)
|
||||
const { enabled, stream } = storeToRefs(settingsAudioDevice)
|
||||
const { ingest, onAfterMessageComposed } = chatOrchestrator
|
||||
const { t } = useI18n()
|
||||
const { audioContext } = useAudioContext()
|
||||
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
|
||||
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
||||
let analyzerSource: MediaStreamAudioSourceNode | undefined
|
||||
|
||||
function isMobileDevice() {
|
||||
return /Mobi|Android|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent)
|
||||
}
|
||||
|
||||
const { isListening } = useTranscriptions(
|
||||
const { isListening, startStreamingTranscription, stopStreamingTranscription } = useTranscriptions(
|
||||
{
|
||||
messageInputRef: messageInput,
|
||||
sendMessage: handleSend,
|
||||
isStageTamagotchi,
|
||||
},
|
||||
)
|
||||
const toggleTranscription = () => isListening.value ? stopStreamingTranscription() : startStreamingTranscription()
|
||||
|
||||
async function handleSubmit() {
|
||||
if (!isMobileDevice()) {
|
||||
@@ -115,14 +115,14 @@ function teardownAnalyzer() {
|
||||
try {
|
||||
analyzerSource?.disconnect()
|
||||
}
|
||||
catch {}
|
||||
catch { }
|
||||
analyzerSource = undefined
|
||||
stopAnalyzer()
|
||||
}
|
||||
|
||||
async function setupAnalyzer() {
|
||||
teardownAnalyzer()
|
||||
if (!hearingDialogOpen.value || !enabled.value || !stream.value)
|
||||
if (!enabled.value || !stream.value)
|
||||
return
|
||||
if (audioContext.state === 'suspended')
|
||||
await audioContext.resume()
|
||||
@@ -137,12 +137,6 @@ watch([enabled, stream], () => {
|
||||
setupAnalyzer()
|
||||
}, { immediate: true })
|
||||
|
||||
watch(hearingDialogOpen, (value) => {
|
||||
if (value) {
|
||||
settingsAudioDevice.askPermission()
|
||||
}
|
||||
})
|
||||
|
||||
onAfterMessageComposed(async () => {
|
||||
})
|
||||
|
||||
@@ -196,11 +190,9 @@ onMounted(() => {
|
||||
</button>
|
||||
<ChatSessionsDrawer v-model="sessionsDrawerOpen" />
|
||||
<HearingConfigDialog
|
||||
v-model:show="hearingDialogOpen"
|
||||
v-model:enabled="enabled"
|
||||
v-model:selected-audio-input="selectedAudioInput"
|
||||
:audio-inputs="audioInputs"
|
||||
:volume-level="volumeLevel"
|
||||
:transcription="isListening"
|
||||
:toggle-transcription="toggleTranscription"
|
||||
:granted="true"
|
||||
>
|
||||
<button
|
||||
|
||||
@@ -139,8 +139,7 @@ watch(hearingPopoverOpen, async (value) => {
|
||||
onAfterMessageComposed(async () => {
|
||||
})
|
||||
|
||||
const { startAnalyzer, stopAnalyzer, volumeLevel } = useAudioAnalyzer()
|
||||
const normalizedVolume = computed(() => Math.min(1, Math.max(0, (volumeLevel.value ?? 0) / 100)))
|
||||
const { startAnalyzer, stopAnalyzer } = useAudioAnalyzer()
|
||||
let analyzerSource: MediaStreamAudioSourceNode | undefined
|
||||
|
||||
function teardownAnalyzer() {
|
||||
@@ -294,7 +293,6 @@ watch(sendMode, () => {
|
||||
v-model:auto-send="autoSendEnabled"
|
||||
:transcription="isListening"
|
||||
:granted="true"
|
||||
:volume-level="normalizedVolume"
|
||||
@toggle-transcription="() => isListening ? stopStreamingTranscription() : startStreamingTranscription()"
|
||||
/>
|
||||
</PopoverContent>
|
||||
|
||||
@@ -17,13 +17,14 @@ export function useTranscriptions(options: TranscriptionOptions) {
|
||||
const { messageInputRef: messageInput, sendMessage, isStageTamagotchi } = options
|
||||
|
||||
const hearingStore = useHearingStore()
|
||||
const audioDeviceSettingsStore = useSettingsAudioDevice()
|
||||
const hearingPipeline = useHearingSpeechInputPipeline()
|
||||
const { transcribeForMediaStream, stopStreamingTranscription } = hearingPipeline
|
||||
const { supportsStreamInput } = storeToRefs(hearingPipeline)
|
||||
const { configured: hearingConfigured, autoSendEnabled, autoSendDelay } = storeToRefs(hearingStore)
|
||||
const { enabled: hearingEnabled, stream } = storeToRefs(useSettingsAudioDevice())
|
||||
const { enabled: hearingEnabled, stream } = storeToRefs(audioDeviceSettingsStore)
|
||||
const providersStore = useProvidersStore()
|
||||
const { askPermission, startStream } = useSettingsAudioDevice()
|
||||
const { askPermission, startStream } = audioDeviceSettingsStore
|
||||
|
||||
const isListening = ref(false)
|
||||
|
||||
|
||||
@@ -69,6 +69,7 @@
|
||||
"devDependencies": {
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/three": "^0.184.0",
|
||||
"vitest": "catalog:",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,10 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { onUnmounted } from 'vue'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { controlConfig, useL2dViewControl } from '../../stores'
|
||||
import { defaultControlConfig as conf, formatter, useL2dViewControl } from '../../stores'
|
||||
|
||||
const { scale, position, viewControlsEnabled, viewControlMode } = useL2dViewControl()
|
||||
const { scale, position, viewControlsEnabled, viewControlMode, set: setValue } = useL2dViewControl()
|
||||
|
||||
const controlledValue = computed({
|
||||
get() {
|
||||
switch (viewControlMode.value) {
|
||||
case 'x':
|
||||
return position.value.x
|
||||
case 'y':
|
||||
return position.value.y
|
||||
case 'scale':
|
||||
return scale.value
|
||||
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
setValue(viewControlMode.value, value)
|
||||
},
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
return formatter[viewControlMode.value](controlledValue.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewControlsEnabled.value = false
|
||||
@@ -15,31 +36,13 @@ onUnmounted(() => {
|
||||
<Transition name="fade-side-pops-in">
|
||||
<div v-if="viewControlsEnabled">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="position.x" :min="controlConfig.x.min" :max="controlConfig.x.max" :step="controlConfig.x.step" handle-wheel
|
||||
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max" :step="conf[viewControlMode].step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ controlConfig.x.format(position.x) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="position.y" :min="controlConfig.y.min" :max="controlConfig.y.max" :step="controlConfig.y.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ controlConfig.y.format(position.y) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'scale'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="scale" :min="controlConfig.scale.min" :max="controlConfig.scale.max" :step="controlConfig.scale.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ controlConfig.scale.format(scale) }}
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
|
||||
@@ -62,13 +62,13 @@ export const useLive2d = defineStore('live2d', () => {
|
||||
const currentMotion = useLocalStorageManualReset<{ group: string, index?: number }>('settings/live2d/current-motion', () => ({ group: 'Idle', index: 0 }))
|
||||
const availableMotions = useLocalStorageManualReset<{ motionName: string, motionIndex: number, fileName: string }[]>('settings/live2d/available-motions', () => [])
|
||||
const motionMap = useLocalStorageManualReset<Record<string, string>>('settings/live2d/motion-map', {})
|
||||
const { position, scale, reset: resetViewControl } = useL2dViewControl()
|
||||
const { position, scale, set: setViewControl } = useL2dViewControl()
|
||||
|
||||
// Live2D model parameters
|
||||
const modelParameters = useLocalStorageManualReset<Record<string, number>>('settings/live2d/parameters', defaultModelParameters)
|
||||
|
||||
function resetState() {
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
supportedControl.forEach(c => setViewControl(c))
|
||||
currentMotion.reset()
|
||||
availableMotions.reset()
|
||||
motionMap.reset()
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { defaultControlConfig, formatter, supportedControl, useL2dViewControl } from './view-control'
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useL2dViewControl', () => {
|
||||
let composable: ReturnType<typeof useL2dViewControl>
|
||||
|
||||
beforeEach(() => {
|
||||
composable = useL2dViewControl()
|
||||
})
|
||||
|
||||
it('should initialize with defaults', () => {
|
||||
expect(composable.viewControlsEnabled.value).toBe(false)
|
||||
expect(composable.viewControlMode.value).toBe('scale')
|
||||
expect(composable.position.value).toEqual({ x: 0, y: 0 })
|
||||
expect(composable.scale.value).toBe(1)
|
||||
})
|
||||
|
||||
describe('set function', () => {
|
||||
it('should set model position x', () => {
|
||||
composable.set('x', 5)
|
||||
expect(composable.position.value.x).toBe(5)
|
||||
})
|
||||
|
||||
it('should set scale', () => {
|
||||
composable.set('scale', 2)
|
||||
expect(composable.scale.value).toBe(2)
|
||||
})
|
||||
|
||||
it('should clamp values to max', () => {
|
||||
supportedControl.forEach(key => composable.set(key, Number.MAX_VALUE))
|
||||
expect(composable.position.value.x).toBe(defaultControlConfig.x.max)
|
||||
expect(composable.position.value.y).toBe(defaultControlConfig.y.max)
|
||||
expect(composable.scale.value).toBe(defaultControlConfig.scale.max)
|
||||
})
|
||||
|
||||
it('should clamp values to min', () => {
|
||||
supportedControl.forEach(key => composable.set(key, -Number.MAX_VALUE))
|
||||
expect(composable.position.value.x).toBe(defaultControlConfig.x.min)
|
||||
expect(composable.position.value.y).toBe(defaultControlConfig.y.min)
|
||||
expect(composable.scale.value).toBe(defaultControlConfig.scale.min)
|
||||
})
|
||||
|
||||
it('should reset to default when value is omitted', () => {
|
||||
supportedControl.forEach(k => composable.set(k, defaultControlConfig[k].default + 1))
|
||||
supportedControl.forEach(k => composable.set(k))
|
||||
expect(composable.position.value.x).toBe(defaultControlConfig.x.default)
|
||||
expect(composable.position.value.y).toBe(defaultControlConfig.y.default)
|
||||
expect(composable.scale.value).toBe(defaultControlConfig.scale.default)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatter', () => {
|
||||
it('should format percentages with 1 decimals', () => {
|
||||
expect(formatter.x(123.44)).toBe('123.4%')
|
||||
expect(formatter.x(123.56)).toBe('123.6%')
|
||||
expect(formatter.y(11.22)).toBe('11.2%')
|
||||
})
|
||||
|
||||
it('should format to percentage with 0 decimal', () => {
|
||||
expect(formatter.scale(1.234)).toBe('123%')
|
||||
expect(formatter.scale(1.236)).toBe('124%')
|
||||
expect(formatter.scale(0.11)).toBe('11%')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -3,60 +3,68 @@ import { ref } from 'vue'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'scale'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: string }
|
||||
|
||||
/** show or hide the control element(slider) on stage */
|
||||
const viewControlsEnabled = ref(false)
|
||||
/** what value to control for the control element */
|
||||
const viewControlMode = ref<SupportedControl>('scale')
|
||||
/** model position relative to the center of the screen, in pixels */
|
||||
/** model position relative to the center of the screen, in percentages */
|
||||
const position = useLocalStorage<{ x: number, y: number }>('settings/live2d/position', { x: 0, y: 0 })
|
||||
/** model scaling. `1` means no scaling. */
|
||||
/** model scaling factor. `1` means no scaling. */
|
||||
const scale = useLocalStorage('settings/live2d/scale', 1)
|
||||
|
||||
const formatPercentD1 = (val: number) => `${val.toFixed(1)}%`
|
||||
const formatToPercent = (val: number) => `${(val * 100).toFixed(0)}%`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: calculate the min and max value dynamically according to window height/width, or allow user to set it
|
||||
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: allow user to set preferred default
|
||||
x: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
default: 0,
|
||||
format: formatPercentD1,
|
||||
buttonText: 'X',
|
||||
},
|
||||
y: {
|
||||
min: -500,
|
||||
max: 500,
|
||||
step: 0.1,
|
||||
default: 0,
|
||||
format: formatPercentD1,
|
||||
buttonText: 'Y',
|
||||
},
|
||||
scale: {
|
||||
min: 0.01,
|
||||
max: 3,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
format: formatToPercent,
|
||||
buttonText: 'Scale',
|
||||
},
|
||||
}
|
||||
|
||||
export const formatter: Record<SupportedControl, (val: number) => string> = {
|
||||
x: formatPercentD1,
|
||||
y: formatPercentD1,
|
||||
scale: formatToPercent,
|
||||
}
|
||||
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
export function useL2dViewControl() {
|
||||
/**
|
||||
* reset the given control to its default value.
|
||||
* @param key the control to reset
|
||||
* @param value optional, will reset the value to its default if not provided
|
||||
*/
|
||||
function reset(key: SupportedControl) {
|
||||
function set(key: SupportedControl, value?: number) {
|
||||
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
|
||||
switch (key) {
|
||||
case 'x':
|
||||
position.value.x = controlConfig.x.default
|
||||
position.value.x = clamped ?? defaultControlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
position.value.y = controlConfig.y.default
|
||||
position.value.y = clamped ?? defaultControlConfig.y.default
|
||||
break
|
||||
case 'scale':
|
||||
scale.value = controlConfig.scale.default
|
||||
scale.value = clamped ?? defaultControlConfig.scale.default
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -67,7 +75,7 @@ export function useL2dViewControl() {
|
||||
/** model scaling in percentages. `100` means no scaling. */
|
||||
scale,
|
||||
/** reset the given control to its default value. */
|
||||
reset,
|
||||
set,
|
||||
/** show or hide the control element(slider) on stage */
|
||||
viewControlsEnabled,
|
||||
/** what value to control for the control element */
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
@@ -46,6 +46,7 @@
|
||||
"devDependencies": {
|
||||
"@types/culori": "^4.0.1",
|
||||
"@types/three": "^0.184.0",
|
||||
"vitest": "catalog:",
|
||||
"vue-tsc": "^3.2.6"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
/*
|
||||
* - Extend OrbitControls from three
|
||||
* - Define camera behavior
|
||||
* - TODO: implement the control elements and replace the <slot/>
|
||||
*/
|
||||
|
||||
import type { Vec3 } from '../../stores/model-store'
|
||||
@@ -17,23 +16,20 @@ import {
|
||||
} from 'three'
|
||||
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
|
||||
// From stage-ui-three package
|
||||
import { onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
|
||||
import { inject, onMounted, onUnmounted, shallowRef, toRefs, watch } from 'vue'
|
||||
|
||||
import { useThreeCamera } from '../../stores/camera'
|
||||
import { useThreeViewControl } from '../../stores/view-control'
|
||||
|
||||
/*
|
||||
* Props:
|
||||
* - model size
|
||||
* - camera position
|
||||
* - camera target: camera looking at target
|
||||
* - camera fov angle
|
||||
* - camera distance: camera position - camera target
|
||||
*/
|
||||
const props = defineProps<{
|
||||
controlEnable: boolean
|
||||
modelSize: Vec3
|
||||
cameraPosition: Vec3
|
||||
cameraTarget: Vec3
|
||||
cameraFOV: number
|
||||
cameraDistance: number
|
||||
}>()
|
||||
/*
|
||||
* Emits:
|
||||
@@ -51,10 +47,7 @@ const emit = defineEmits<{
|
||||
const {
|
||||
controlEnable,
|
||||
modelSize,
|
||||
cameraPosition,
|
||||
cameraTarget,
|
||||
cameraFOV,
|
||||
cameraDistance,
|
||||
} = toRefs(props)
|
||||
|
||||
extend({ OrbitControls })
|
||||
@@ -64,6 +57,10 @@ const controls = shallowRef<OrbitControls>()
|
||||
const camera = shallowRef<PerspectiveCamera | null>(null)
|
||||
let disposeControlsChange: (() => void) | undefined
|
||||
|
||||
const { cameraPosition, cameraFOV, cameraDistance } = useThreeCamera()
|
||||
const { viewControlsEnabled } = useThreeViewControl()
|
||||
const isPreviewStage = inject<boolean>('previewStage')
|
||||
|
||||
// Initialisation on onMounted
|
||||
function registerInfoFlow() {
|
||||
/*
|
||||
@@ -121,11 +118,11 @@ function registerInfoFlow() {
|
||||
camera.value.updateProjectionMatrix()
|
||||
controls.value.update()
|
||||
})
|
||||
watch(controlEnable, (newEnable) => {
|
||||
watch([controlEnable, viewControlsEnabled], (newEnable) => {
|
||||
if (!camera.value || !controls.value)
|
||||
return
|
||||
controls.value.enableRotate = newEnable
|
||||
controls.value.enableZoom = newEnable
|
||||
controls.value.enableRotate = isPreviewStage || newEnable.every(Boolean)
|
||||
controls.value.enableZoom = isPreviewStage || newEnable.every(Boolean)
|
||||
}, { immediate: true })
|
||||
|
||||
/*
|
||||
|
||||
@@ -1,10 +1,40 @@
|
||||
<script setup lang="ts">
|
||||
import { RoundRange } from '@proj-airi/ui'
|
||||
import { onUnmounted } from 'vue'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed, onUnmounted } from 'vue'
|
||||
|
||||
import { controlConfig as conf, useThreeViewControl } from '../../stores/view-control'
|
||||
import { useModelStore } from '../../stores/model-store'
|
||||
import { defaultControlConfig as conf, formatter, useThreeViewControl } from '../../stores/view-control'
|
||||
|
||||
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode } = useThreeViewControl()
|
||||
const { cameraDistance, cameraFOV, modelOffset, viewControlsEnabled, viewControlMode, set: setValue } = useThreeViewControl()
|
||||
const { sceneMutationLocked } = storeToRefs(useModelStore())
|
||||
|
||||
const controlledValue = computed({
|
||||
get() {
|
||||
switch (viewControlMode.value) {
|
||||
case 'x':
|
||||
return modelOffset.value.x
|
||||
case 'y':
|
||||
return modelOffset.value.y
|
||||
case 'z':
|
||||
return modelOffset.value.z
|
||||
case 'cameraDistance':
|
||||
return cameraDistance.value
|
||||
case 'cameraFOV':
|
||||
return cameraFOV.value
|
||||
default: throw new Error(`Unexpected control key: ${viewControlMode.value}`)
|
||||
}
|
||||
},
|
||||
set(value) {
|
||||
if (sceneMutationLocked.value)
|
||||
return
|
||||
setValue(viewControlMode.value, value)
|
||||
},
|
||||
})
|
||||
|
||||
const formattedValue = computed(() => {
|
||||
return formatter[viewControlMode.value](controlledValue.value)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
viewControlsEnabled.value = false
|
||||
@@ -13,56 +43,23 @@ onUnmounted(() => {
|
||||
|
||||
<template>
|
||||
<Transition name="fade-side-pops-in">
|
||||
<div v-if="viewControlsEnabled">
|
||||
<fieldset v-if="viewControlsEnabled">
|
||||
<Transition name="fade-side-pops-in" mode="out-in">
|
||||
<!-- TODO: generate the controls programmatically, while preserving the transition -->
|
||||
<div v-if="viewControlMode === 'x'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<div :key="viewControlMode" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="modelOffset.x" :min="conf.x.min" :max="conf.x.max" :step="conf.x.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
v-model="controlledValue" :min="conf[viewControlMode].min" :max="conf[viewControlMode].max"
|
||||
:disabled="sceneMutationLocked" :step="conf[viewControlMode].step" handle-wheel data-direction="vertical"
|
||||
h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ conf.x.format(modelOffset.x) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'y'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="modelOffset.y" :min="conf.y.min" :max="conf.y.max" :step="conf.y.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ conf.y.format(modelOffset.y) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'z'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="modelOffset.z" :min="conf.z.min" :max="conf.z.max" :step="conf.z.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ conf.z.format(modelOffset.z) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'cameraDistance'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="cameraDistance" :min="conf.cameraDistance.min" :max="conf.cameraDistance.max" :step="conf.cameraDistance.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ conf.cameraDistance.format(cameraDistance) }}
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="viewControlMode === 'cameraFOV'" relative class="[&_.round-range-tooltip]:hover:opacity-100">
|
||||
<RoundRange
|
||||
v-model="cameraFOV" :min="conf.cameraFOV.min" :max="conf.cameraFOV.max" :step="conf.cameraFOV.step" handle-wheel
|
||||
data-direction="vertical" h="50%" write-vertical-left
|
||||
/>
|
||||
<div class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0 transition="all duration-200 ease-in-out">
|
||||
{{ conf.cameraFOV.format(cameraFOV) }}
|
||||
<div
|
||||
class="round-range-tooltip" top="50%" translate-y="[-50%]" absolute left-10 font-mono op-0
|
||||
transition="all duration-200 ease-in-out"
|
||||
>
|
||||
{{ formattedValue }}
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</div>
|
||||
</fieldset>
|
||||
</Transition>
|
||||
</template>
|
||||
|
||||
|
||||
@@ -100,7 +100,6 @@ const {
|
||||
modelOffset,
|
||||
modelRotationY,
|
||||
|
||||
cameraFOV,
|
||||
cameraPosition,
|
||||
cameraDistance,
|
||||
|
||||
@@ -436,8 +435,7 @@ function onOrbitControlsReady() {
|
||||
const controlEnable = computed(() => {
|
||||
return controlsReady.value
|
||||
&& modelPhase.value === 'ready'
|
||||
&& scenePhase.value === 'mounted'
|
||||
&& sceneTransactionDepth.value === 0
|
||||
&& !sceneMutationLocked.value
|
||||
})
|
||||
function onVRMModelLoadStart(reason: VrmLifecycleReason) {
|
||||
modelPhase.value = 'loading'
|
||||
@@ -728,10 +726,7 @@ defineExpose({
|
||||
ref="controlsRef"
|
||||
:control-enable="controlEnable"
|
||||
:model-size="modelSize"
|
||||
:camera-position="cameraPosition"
|
||||
:camera-target="modelOrigin"
|
||||
:camera-f-o-v="cameraFOV"
|
||||
:camera-distance="cameraDistance"
|
||||
@orbit-controls-camera-changed="onOrbitControlsCameraChanged"
|
||||
@orbit-controls-ready="onOrbitControlsReady"
|
||||
/>
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
|
||||
export const DEFAULT_CAMERA_FOV = 40
|
||||
export const DEFAULT_CAMERA_DISTANCE = 1
|
||||
export const DEFAULT_CAMERA_POSITION = { x: 0, y: 0, z: -1 }
|
||||
|
||||
/** camera field of view, in degrees. */
|
||||
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', DEFAULT_CAMERA_FOV)
|
||||
/**
|
||||
* euclidean distance between the model center and the camera center, in meters.
|
||||
* setting this value will move the camera along the axis.
|
||||
*/
|
||||
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', DEFAULT_CAMERA_DISTANCE)
|
||||
/**
|
||||
* Internal state of the camera. Users should not be able to set this directly, use `cameraDistance` instead.
|
||||
*/
|
||||
const cameraPosition = useLocalStorage('settings/stage-ui-three/camera-position', { ...DEFAULT_CAMERA_POSITION })
|
||||
|
||||
export function useThreeCamera() {
|
||||
return {
|
||||
cameraFOV,
|
||||
cameraDistance,
|
||||
cameraPosition,
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { computed, ref, watch } from 'vue'
|
||||
|
||||
import defaultSkyBoxSrc from '../components/Environment/assets/sky_linekotsi_23_HDRI.hdr?url'
|
||||
|
||||
import { DEFAULT_CAMERA_POSITION, useThreeCamera } from './camera'
|
||||
import { supportedControl, useThreeViewControl } from './view-control'
|
||||
|
||||
// TODO: this is for future type injection features
|
||||
@@ -79,8 +80,8 @@ interface BroadcastChannelEventShouldUpdateView {
|
||||
|
||||
const vrmViewUpdateRuntimeInstanceId = Math.random().toString(36).slice(2, 10)
|
||||
let vrmViewUpdateMessageSequence = 0
|
||||
const { cameraFOV, cameraDistance, modelOffset, reset: resetViewControl } = useThreeViewControl()
|
||||
|
||||
const { modelOffset, set: setViewControl } = useThreeViewControl()
|
||||
const { cameraDistance, cameraFOV, cameraPosition } = useThreeCamera()
|
||||
export const useModelStore = defineStore('modelStore', () => {
|
||||
const { post, data } = useBroadcastChannel<BroadcastChannelEvents, BroadcastChannelEvents>({ name: 'airi-stores-stage-ui-three-vrm' })
|
||||
const shouldUpdateViewHooks = ref(new Set<() => void>())
|
||||
@@ -149,10 +150,7 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
const trackingMode = useLocalStorage('settings/stage-ui-three/trackingMode', 'none' as 'camera' | 'mouse' | 'none')
|
||||
|
||||
// === View state ===
|
||||
// `cameraDistance` is the user-facing distance control. `cameraPosition` and
|
||||
// `lookAtTarget` represent the current runtime pose and may be recalculated when
|
||||
// a new model bootstrap is applied.
|
||||
const cameraPosition = useLocalStorage('settings/stage-ui-three/camera-position', { x: 0, y: 0, z: -1 })
|
||||
/** current runtime pose. may be recalculated when a new model bootstrap is applied. */
|
||||
const lookAtTarget = useLocalStorage('settings/stage-ui-three/lookAtTarget', { x: 0, y: 0, z: 0 })
|
||||
|
||||
function resetModelStore() {
|
||||
@@ -164,8 +162,8 @@ export const useModelStore = defineStore('modelStore', () => {
|
||||
modelOrigin.value = { x: 0, y: 0, z: 0 }
|
||||
modelRotationY.value = 0
|
||||
|
||||
cameraPosition.value = { x: 0, y: 0, z: 0 }
|
||||
supportedControl.forEach(c => resetViewControl(c))
|
||||
cameraPosition.value = { ...DEFAULT_CAMERA_POSITION }
|
||||
supportedControl.forEach(c => setViewControl(c))
|
||||
|
||||
lookAtTarget.value = { x: 0, y: 0, z: 0 }
|
||||
trackingMode.value = 'none'
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { defaultControlConfig, formatter, supportedControl, useThreeViewControl } from './view-control'
|
||||
|
||||
vi.mock('./camera', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
DEFAULT_CAMERA_DISTANCE: 20,
|
||||
DEFAULT_CAMERA_FOV: 40,
|
||||
useThreeCamera: vi.fn(() => ({
|
||||
cameraDistance: vue.ref(20),
|
||||
cameraFOV: vue.ref(40),
|
||||
})),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@vueuse/core', async () => {
|
||||
const vue = await import('vue')
|
||||
return {
|
||||
useLocalStorage: vi.fn((_key, initialValue) => vue.ref(initialValue)),
|
||||
}
|
||||
})
|
||||
|
||||
describe('useThreeViewControl', () => {
|
||||
let composable: ReturnType<typeof useThreeViewControl>
|
||||
|
||||
beforeEach(() => {
|
||||
composable = useThreeViewControl()
|
||||
})
|
||||
|
||||
it('should initialize with defaults', () => {
|
||||
expect(composable.viewControlsEnabled.value).toBe(false)
|
||||
expect(composable.viewControlMode.value).toBe('cameraDistance')
|
||||
expect(composable.modelOffset.value).toEqual({ x: 0, y: 0, z: 0 })
|
||||
expect(composable.cameraDistance.value).toBe(20)
|
||||
expect(composable.cameraFOV.value).toBe(40)
|
||||
})
|
||||
|
||||
describe('set function', () => {
|
||||
it('should set model offset x', () => {
|
||||
composable.set('x', 5)
|
||||
expect(composable.modelOffset.value.x).toBe(5)
|
||||
})
|
||||
|
||||
it('should set camera distance', () => {
|
||||
composable.set('cameraDistance', 8)
|
||||
expect(composable.cameraDistance.value).toBe(8)
|
||||
})
|
||||
|
||||
it('should set camera FOV', () => {
|
||||
composable.set('cameraFOV', 60)
|
||||
expect(composable.cameraFOV.value).toBe(60)
|
||||
})
|
||||
|
||||
it('should clamp values to max', () => {
|
||||
supportedControl.forEach(key => composable.set(key, Number.MAX_VALUE))
|
||||
expect(composable.modelOffset.value.x).toBe(defaultControlConfig.x.max)
|
||||
expect(composable.modelOffset.value.y).toBe(defaultControlConfig.y.max)
|
||||
expect(composable.modelOffset.value.z).toBe(defaultControlConfig.z.max)
|
||||
expect(composable.cameraDistance.value).toBe(defaultControlConfig.cameraDistance.max)
|
||||
expect(composable.cameraFOV.value).toBe(defaultControlConfig.cameraFOV.max)
|
||||
})
|
||||
|
||||
it('should clamp values to min', () => {
|
||||
supportedControl.forEach(key => composable.set(key, -Number.MAX_VALUE))
|
||||
expect(composable.modelOffset.value.x).toBe(defaultControlConfig.x.min)
|
||||
expect(composable.modelOffset.value.y).toBe(defaultControlConfig.y.min)
|
||||
expect(composable.modelOffset.value.z).toBe(defaultControlConfig.z.min)
|
||||
expect(composable.cameraDistance.value).toBe(defaultControlConfig.cameraDistance.min)
|
||||
expect(composable.cameraFOV.value).toBe(defaultControlConfig.cameraFOV.min)
|
||||
})
|
||||
|
||||
it('should reset to default when value is omitted', () => {
|
||||
supportedControl.forEach(k => composable.set(k, defaultControlConfig[k].default + 1))
|
||||
supportedControl.forEach(k => composable.set(k))
|
||||
expect(composable.modelOffset.value.x).toBe(defaultControlConfig.x.default)
|
||||
expect(composable.modelOffset.value.y).toBe(defaultControlConfig.y.default)
|
||||
expect(composable.modelOffset.value.z).toBe(defaultControlConfig.z.default)
|
||||
expect(composable.cameraDistance.value).toBe(defaultControlConfig.cameraDistance.default)
|
||||
expect(composable.cameraFOV.value).toBe(defaultControlConfig.cameraFOV.default)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatter', () => {
|
||||
it('should format meters with 2 decimals', () => {
|
||||
expect(formatter.x(1.234)).toBe('1.23m')
|
||||
expect(formatter.x(1.236)).toBe('1.24m')
|
||||
expect(formatter.y(10)).toBe('10.00m')
|
||||
})
|
||||
|
||||
it('should format camera FOV with 0 decimal', () => {
|
||||
expect(formatter.cameraFOV(45.4)).toBe('45°')
|
||||
expect(formatter.cameraFOV(45.5)).toBe('46°')
|
||||
expect(formatter.cameraFOV(90)).toBe('90°')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,85 +1,96 @@
|
||||
import { useLocalStorage } from '@vueuse/core'
|
||||
import { ref } from 'vue'
|
||||
|
||||
import { DEFAULT_CAMERA_DISTANCE, DEFAULT_CAMERA_FOV, useThreeCamera } from './camera'
|
||||
|
||||
export const supportedControl = ['x', 'y', 'z', 'cameraDistance', 'cameraFOV'] as const
|
||||
type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, format: (val: number) => string }
|
||||
export type SupportedControl = typeof supportedControl[number]
|
||||
interface ControlConfig { min: number, max: number, step: number, default: number, buttonText: string }
|
||||
|
||||
const formatDecimal2Meters = (val: number) => `${val.toFixed(2)}m`
|
||||
const formatMetersD2 = (val: number) => `${val.toFixed(2)}m`
|
||||
|
||||
export const controlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
export const defaultControlConfig: Record<SupportedControl, ControlConfig> = {
|
||||
// TODO: allow user to set the min/max value
|
||||
x: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
format: formatDecimal2Meters,
|
||||
buttonText: 'X',
|
||||
},
|
||||
y: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
format: formatDecimal2Meters,
|
||||
buttonText: 'Y',
|
||||
},
|
||||
z: {
|
||||
min: -10,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 0,
|
||||
format: formatDecimal2Meters,
|
||||
buttonText: 'Z',
|
||||
},
|
||||
cameraDistance: {
|
||||
min: 0,
|
||||
max: 10,
|
||||
step: 0.01,
|
||||
default: 1,
|
||||
format: formatDecimal2Meters,
|
||||
default: DEFAULT_CAMERA_DISTANCE,
|
||||
buttonText: 'Dis',
|
||||
},
|
||||
cameraFOV: {
|
||||
min: 10,
|
||||
max: 120,
|
||||
step: 1,
|
||||
default: 40,
|
||||
format: (val: number) => `${val.toFixed(0)}°`,
|
||||
default: DEFAULT_CAMERA_FOV,
|
||||
buttonText: 'FOV',
|
||||
},
|
||||
}
|
||||
|
||||
/** camera field of view, in degrees. */
|
||||
const cameraFOV = useLocalStorage('settings/stage-ui-three/cameraFOV', 40)
|
||||
/**
|
||||
* euclidean distance between the model center and the camera center, in meters.
|
||||
* setting this value will move the camera along the axis.
|
||||
*/
|
||||
const cameraDistance = useLocalStorage('settings/stage-ui-three/cameraDistance', 1)
|
||||
export const formatter: Record<SupportedControl, (val: number) => string> = {
|
||||
x: formatMetersD2,
|
||||
y: formatMetersD2,
|
||||
z: formatMetersD2,
|
||||
cameraDistance: formatMetersD2,
|
||||
cameraFOV: (val: number) => `${val.toFixed(0)}°`,
|
||||
}
|
||||
const clampMinMax = (value: number, min: number, max: number) => Math.min(Math.max(value, min), max)
|
||||
|
||||
const { cameraDistance, cameraFOV } = useThreeCamera()
|
||||
const controlConfig = ref(defaultControlConfig)
|
||||
/** model position from the scene origin, in meters. */
|
||||
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: 0, y: 0, z: 0 })
|
||||
/** show or hide the control element(slider) on HUD. */
|
||||
const modelOffset = useLocalStorage('settings/stage-ui-three/modelOffset', { x: defaultControlConfig.x.default, y: defaultControlConfig.y.default, z: defaultControlConfig.z.default })
|
||||
/**
|
||||
* show or hide the control element(slider) on HUD.
|
||||
* also enable/disable camera panning.
|
||||
*/
|
||||
const viewControlsEnabled = ref(false)
|
||||
/** what value to control for the control element */
|
||||
const viewControlMode = ref<SupportedControl>('cameraDistance')
|
||||
|
||||
/**
|
||||
* reset the given control to its default value.
|
||||
* @param key the control to reset
|
||||
* set the given control to the given value.
|
||||
* @param key the control to set
|
||||
* @param value optional, will reset the value to its default if not provided
|
||||
*/
|
||||
function reset(key: SupportedControl) {
|
||||
function set(key: SupportedControl, value?: number) {
|
||||
const clamped = value !== undefined ? clampMinMax(value, defaultControlConfig[key].min, defaultControlConfig[key].max) : undefined
|
||||
switch (key) {
|
||||
case 'x':
|
||||
modelOffset.value.x = controlConfig.x.default
|
||||
modelOffset.value.x = clamped ?? defaultControlConfig.x.default
|
||||
break
|
||||
case 'y':
|
||||
modelOffset.value.y = controlConfig.y.default
|
||||
modelOffset.value.y = clamped ?? defaultControlConfig.y.default
|
||||
break
|
||||
case 'z':
|
||||
modelOffset.value.z = controlConfig.z.default
|
||||
modelOffset.value.z = clamped ?? defaultControlConfig.z.default
|
||||
break
|
||||
case 'cameraDistance':
|
||||
cameraDistance.value = controlConfig.cameraDistance.default
|
||||
cameraDistance.value = clamped ?? defaultControlConfig.cameraDistance.default
|
||||
break
|
||||
case 'cameraFOV':
|
||||
cameraFOV.value = controlConfig.cameraFOV.default
|
||||
cameraFOV.value = clamped ?? defaultControlConfig.cameraFOV.default
|
||||
break
|
||||
}
|
||||
}
|
||||
@@ -96,8 +107,8 @@ export function useThreeViewControl() {
|
||||
viewControlsEnabled,
|
||||
/** what value to control for the control element */
|
||||
viewControlMode,
|
||||
|
||||
controlConfig,
|
||||
/** reset the given control to its default value. */
|
||||
reset,
|
||||
set,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
export default defineConfig({
|
||||
test: {
|
||||
include: ['src/**/*.test.ts'],
|
||||
},
|
||||
})
|
||||
+13
-5
@@ -2,25 +2,32 @@
|
||||
import { 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 { onMounted, watch } from 'vue'
|
||||
|
||||
import HearingConfig from './hearing-config.vue'
|
||||
|
||||
import { useAudioDevice } from '../../../../composables'
|
||||
import { useBreakpoints } from '../../../../composables/use-breakpoints'
|
||||
|
||||
const props = defineProps<{
|
||||
overlayDim?: boolean
|
||||
overlayBlur?: boolean
|
||||
granted?: boolean
|
||||
volumeLevel?: number
|
||||
transcription?: boolean
|
||||
toggleTranscription?: () => void
|
||||
}>()
|
||||
|
||||
const showDialog = defineModel('show', { type: Boolean, default: false, required: false })
|
||||
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const { askPermission } = useAudioDevice()
|
||||
const screenSafeArea = useScreenSafeArea()
|
||||
|
||||
useResizeObserver(document.documentElement, () => screenSafeArea.update())
|
||||
watch(showDialog, (show) => {
|
||||
if (show)
|
||||
askPermission()
|
||||
})
|
||||
onMounted(() => screenSafeArea.update())
|
||||
</script>
|
||||
|
||||
@@ -42,8 +49,8 @@ onMounted(() => screenSafeArea.update())
|
||||
<DialogTitle>Hearing Input</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<HearingConfig
|
||||
:granted="props.granted"
|
||||
:volume-level="props.volumeLevel"
|
||||
:granted="props.granted" :transcription="props.transcription"
|
||||
@toggle-transcription="() => toggleTranscription?.()"
|
||||
/>
|
||||
<slot name="extra" />
|
||||
</DialogContent>
|
||||
@@ -73,7 +80,8 @@ onMounted(() => screenSafeArea.update())
|
||||
/>
|
||||
<HearingConfig
|
||||
:granted="props.granted"
|
||||
:volume-level="props.volumeLevel"
|
||||
:transcription="props.transcription"
|
||||
@toggle-transcription="() => toggleTranscription?.()"
|
||||
/>
|
||||
<slot name="extra" />
|
||||
</DrawerContent>
|
||||
|
||||
@@ -3,20 +3,21 @@ import { Button, Callout, FieldCombobox } from '@proj-airi/ui'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import { computed } from 'vue'
|
||||
|
||||
import { useAudioAnalyzer, useAudioDevice } from '../../../../composables'
|
||||
import { useSettingsAudioDevice } from '../../../../stores'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
granted?: boolean
|
||||
volumeLevel?: number
|
||||
granted?: boolean // permission status on OS level
|
||||
transcription?: boolean
|
||||
}>(), {
|
||||
granted: false,
|
||||
volumeLevel: 0,
|
||||
})
|
||||
|
||||
const emit = defineEmits(['toggleTranscription'])
|
||||
|
||||
const { enabled, selectedAudioInput, audioInputs } = storeToRefs(useSettingsAudioDevice())
|
||||
const deviceStore = useSettingsAudioDevice()
|
||||
const { enabled, selectedAudioInput } = storeToRefs(deviceStore)
|
||||
const { audioInputs, permissionGranted, askPermission } = useAudioDevice()
|
||||
const { volumeLevel } = useAudioAnalyzer()
|
||||
|
||||
const autoSend = defineModel<boolean>('autoSend')
|
||||
const ringEnabledClass = computed(() => enabled.value
|
||||
@@ -27,8 +28,10 @@ const ringEnabledClass = computed(() => enabled.value
|
||||
function toggleHearingEnabled() {
|
||||
if (enabled.value)
|
||||
return enabled.value = false
|
||||
if (!enabled.value && selectedAudioInput.value !== '')
|
||||
enabled.value = true
|
||||
if (selectedAudioInput.value !== '' && permissionGranted.value)
|
||||
return enabled.value = true
|
||||
if (!permissionGranted.value)
|
||||
return askPermission().then(() => { enabled.value = permissionGranted.value })
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -40,17 +43,17 @@ function toggleHearingEnabled() {
|
||||
<!-- Rings (scale + opacity follow volume) -->
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-20 w-20 rounded-full transition-all duration-150 -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1 + (props.volumeLevel / 100) * 0.35})`, opacity: String(0.25 + (props.volumeLevel / 100) * 0.25) }"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1 + (volumeLevel / 100) * 0.35})`, opacity: String(0.25 + (volumeLevel / 100) * 0.25) }"
|
||||
:class="ringEnabledClass"
|
||||
/>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-24 w-24 rounded-full transition-all duration-200 -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + (props.volumeLevel / 100) * 0.55})`, opacity: String(0.15 + (props.volumeLevel / 100) * 0.2) }"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1.2 + (volumeLevel / 100) * 0.55})`, opacity: String(0.15 + (volumeLevel / 100) * 0.2) }"
|
||||
:class="enabled ? 'bg-primary-500/10 dark:bg-primary-600/15' : 'bg-neutral-300/10 dark:bg-neutral-700/10'"
|
||||
/>
|
||||
<div
|
||||
class="absolute left-1/2 top-1/2 h-28 w-28 rounded-full transition-all duration-300 -translate-x-1/2 -translate-y-1/2"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + (props.volumeLevel / 100) * 0.8})`, opacity: String(0.08 + (props.volumeLevel / 100) * 0.15) }"
|
||||
:style="{ transform: `translate(-50%, -50%) scale(${1.5 + (volumeLevel / 100) * 0.8})`, opacity: String(0.08 + (volumeLevel / 100) * 0.15) }"
|
||||
:class="enabled ? 'bg-primary-500/5 dark:bg-primary-600/10' : 'bg-neutral-300/5 dark:bg-neutral-700/5'"
|
||||
/>
|
||||
|
||||
|
||||
+3
-1
@@ -5,7 +5,7 @@ import { Live2DScene } 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 { computed, provide, ref, watch } from 'vue'
|
||||
|
||||
import { useSettings } from '../../../../stores/settings'
|
||||
import {
|
||||
@@ -30,6 +30,8 @@ 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)}`
|
||||
|
||||
provide('previewStage', true)
|
||||
|
||||
const {
|
||||
live2dDisableFocus,
|
||||
stageModelSelected,
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { onUnmounted, ref } from 'vue'
|
||||
|
||||
const amplification = 3 // Amplification factor for volume visualization
|
||||
|
||||
const volumeLevel = ref(0) // 0-100
|
||||
const error = ref<string>()
|
||||
|
||||
export function useAudioAnalyzer() {
|
||||
const analyzer = ref<AnalyserNode>()
|
||||
const dataArray = ref<Uint8Array<ArrayBuffer>>()
|
||||
@@ -7,11 +12,6 @@ export function useAudioAnalyzer() {
|
||||
|
||||
const onAnalyzerUpdateHooks = ref<Array<(volumeLevel: number) => void | Promise<void>>>([])
|
||||
|
||||
const volumeLevel = ref(0) // 0-100
|
||||
const error = ref<string>()
|
||||
|
||||
const amplification = 3 // Amplification factor for volume visualization
|
||||
|
||||
function onAnalyzerUpdate(callback: (volumeLevel: number) => void | Promise<void>) {
|
||||
onAnalyzerUpdateHooks.value.push(callback)
|
||||
return () => {
|
||||
|
||||
@@ -2,9 +2,8 @@ import { useDevicesList, useUserMedia } from '@vueuse/core'
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
|
||||
export function useAudioDevice(requestPermission: boolean = false) {
|
||||
const devices = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
|
||||
const audioInputs = computed(() => devices.audioInputs.value)
|
||||
const selectedAudioInput = ref<string>(devices.audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
|
||||
const { audioInputs, permissionGranted, ensurePermissions } = useDevicesList({ constraints: { audio: true }, requestPermissions: requestPermission })
|
||||
const selectedAudioInput = ref<string>(audioInputs.value.find(device => device.deviceId === 'default')?.deviceId || '')
|
||||
const deviceConstraints = computed<MediaStreamConstraints>(() => ({
|
||||
audio: {
|
||||
deviceId: { exact: selectedAudioInput.value },
|
||||
@@ -16,13 +15,13 @@ export function useAudioDevice(requestPermission: boolean = false) {
|
||||
const { stream, stop: stopStream, start: startStream } = useUserMedia({ constraints: deviceConstraints, enabled: false, autoSwitch: true })
|
||||
|
||||
watch(audioInputs, () => {
|
||||
if (!selectedAudioInput.value && audioInputs.value.length > 0) {
|
||||
if (selectedAudioInput.value === '' && audioInputs.value.length > 0) {
|
||||
selectedAudioInput.value = audioInputs.value.find(input => input.deviceId === 'default')?.deviceId || audioInputs.value[0].deviceId
|
||||
}
|
||||
})
|
||||
|
||||
function askPermission() {
|
||||
return devices.ensurePermissions()
|
||||
return ensurePermissions()
|
||||
.then(() => nextTick())
|
||||
.then(() => {
|
||||
if (audioInputs.value.length > 0 && !selectedAudioInput.value) {
|
||||
@@ -40,6 +39,7 @@ export function useAudioDevice(requestPermission: boolean = false) {
|
||||
selectedAudioInput,
|
||||
stream,
|
||||
deviceConstraints,
|
||||
permissionGranted,
|
||||
|
||||
askPermission,
|
||||
startStream,
|
||||
|
||||
@@ -4,17 +4,19 @@ import { watch } from 'vue'
|
||||
|
||||
import { useAudioDevice } from '../../composables/audio'
|
||||
|
||||
let microphonePermissionStatus: PermissionStatus
|
||||
|
||||
export const useSettingsAudioDevice = defineStore('settings-audio-devices', () => {
|
||||
const { audioInputs, deviceConstraints, selectedAudioInput: selectedAudioInputNonPersist, startStream, stopStream, stream, askPermission } = useAudioDevice()
|
||||
|
||||
const selectedAudioInputPersist = useLocalStorageManualReset<string>('settings/audio/input', selectedAudioInputNonPersist.value)
|
||||
const selectedAudioInputEnabledPersist = useLocalStorageManualReset<boolean>('settings/audio/input/enabled', false)
|
||||
const audioInputEnabled = useLocalStorageManualReset<boolean>('settings/audio/input/enabled', false)
|
||||
|
||||
watch(selectedAudioInputPersist, (newValue) => {
|
||||
selectedAudioInputNonPersist.value = newValue
|
||||
})
|
||||
|
||||
watch(selectedAudioInputEnabledPersist, (val) => {
|
||||
watch(audioInputEnabled, (val) => {
|
||||
if (val) {
|
||||
startStream()
|
||||
}
|
||||
@@ -23,14 +25,27 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
|
||||
}
|
||||
})
|
||||
|
||||
// permissionGranted from vueuse does not track revocation yet.
|
||||
// implement it manually.
|
||||
try {
|
||||
navigator?.permissions?.query({ name: 'microphone' }).then((status) => {
|
||||
microphonePermissionStatus = status // existing one cleaned up by GC
|
||||
status.onchange = () => {
|
||||
if (status.state === 'denied' || status.state === 'prompt')
|
||||
audioInputEnabled.value = false
|
||||
}
|
||||
})
|
||||
}
|
||||
catch (e) { console.info(`Unable to track microphone permission: ${e}`) }
|
||||
void microphonePermissionStatus // suppress unused variable lint
|
||||
function initialize() {
|
||||
const hasSelectedInput = selectedAudioInputPersist.value
|
||||
&& audioInputs.value.some(device => device.deviceId === selectedAudioInputPersist.value)
|
||||
|
||||
if (selectedAudioInputEnabledPersist.value && hasSelectedInput) {
|
||||
if (audioInputEnabled.value && hasSelectedInput) {
|
||||
startStream()
|
||||
}
|
||||
if (selectedAudioInputNonPersist.value && !selectedAudioInputEnabledPersist.value) {
|
||||
if (selectedAudioInputNonPersist.value && !audioInputEnabled.value) {
|
||||
selectedAudioInputPersist.value = selectedAudioInputNonPersist.value
|
||||
}
|
||||
}
|
||||
@@ -38,7 +53,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
|
||||
function resetState() {
|
||||
selectedAudioInputPersist.reset()
|
||||
selectedAudioInputNonPersist.value = ''
|
||||
selectedAudioInputEnabledPersist.reset()
|
||||
audioInputEnabled.reset()
|
||||
stopStream()
|
||||
}
|
||||
|
||||
@@ -46,7 +61,7 @@ export const useSettingsAudioDevice = defineStore('settings-audio-devices', () =
|
||||
audioInputs,
|
||||
deviceConstraints,
|
||||
selectedAudioInput: selectedAudioInputPersist,
|
||||
enabled: selectedAudioInputEnabledPersist,
|
||||
enabled: audioInputEnabled,
|
||||
|
||||
stream,
|
||||
|
||||
|
||||
@@ -64,11 +64,13 @@ function handleInput(e: Event) {
|
||||
}
|
||||
|
||||
function onWheelInput(ev: WheelEvent) {
|
||||
let valueAfter = 0
|
||||
if (ev.deltaY === 0)
|
||||
return
|
||||
let valueAfter = modelValue.value
|
||||
if (ev.deltaY < 0)
|
||||
valueAfter = modelValue.value += props.step * (shiftPressed.value ? 50 : 1)
|
||||
valueAfter += props.step * (shiftPressed.value ? 50 : 1)
|
||||
if (ev.deltaY > 0)
|
||||
valueAfter = modelValue.value -= props.step * (shiftPressed.value ? 50 : 1)
|
||||
valueAfter -= props.step * (shiftPressed.value ? 50 : 1)
|
||||
modelValue.value = Math.min(Math.max(valueAfter, props.min), props.max)
|
||||
}
|
||||
</script>
|
||||
|
||||
Generated
+6
@@ -3661,6 +3661,9 @@ importers:
|
||||
'@types/three':
|
||||
specifier: ^0.184.0
|
||||
version: 0.184.0
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue-tsc:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
@@ -3725,6 +3728,9 @@ importers:
|
||||
'@types/three':
|
||||
specifier: ^0.184.0
|
||||
version: 0.184.0
|
||||
vitest:
|
||||
specifier: 'catalog:'
|
||||
version: 4.1.4(@opentelemetry/api@1.9.1)(@types/node@25.6.0)(@vitest/browser-playwright@4.1.4)(@vitest/coverage-v8@4.1.4)(jsdom@29.0.2(@noble/hashes@2.0.1)(canvas@3.2.3))(vite@8.0.8(@types/node@25.6.0)(esbuild@0.27.2)(jiti@2.6.1)(less@4.6.4)(terser@5.46.1)(tsx@4.21.0)(yaml@2.8.3))
|
||||
vue-tsc:
|
||||
specifier: ^3.2.6
|
||||
version: 3.2.6(typescript@5.9.3)
|
||||
|
||||
Reference in New Issue
Block a user