fix(stage-tamagotchi): show Live2D expressions in settings (#2451)
## Summary - Send a serializable Live2D expression snapshot from the stage renderer to the separate Settings window. - Route expression changes back to the renderer that owns the loaded model, with owner ID checks for stale-window isolation. - Add browser regressions for the component boundary and the complete BroadcastChannel round trip. Fixes #2450 ## Verification - `pnpm typecheck` - `pnpm lint` - `../../node_modules/.bin/vitest run --project browser src/components/scenarios/settings/model-settings/live2d.browser.test.ts` - `node_modules/.bin/vitest run --config apps/stage-tamagotchi/vitest.config.ts --project browser apps/stage-tamagotchi/src/renderer/composables/model-settings-runtime.browser.test.ts` - `./node_modules/.bin/electron-vite build` - Vishot Electron capture with the issue Live2D fixture on the merge base and this branch - `sem diff upstream/main...HEAD --no-cosmetics -v --file-exts .ts .tsx` ## Visual changes | Before | After | |---|---| |  |  | | Settings / Models / Live2D expressions: empty list | Settings / Models / Live2D expressions: model entries are available |
This commit is contained in:
@@ -0,0 +1,90 @@
|
|||||||
|
import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
|
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||||
|
import type { MaybeRefOrGetter } from 'vue'
|
||||||
|
|
||||||
|
import type { ModelSettingsRuntimeContext } from '../../shared/model-settings-runtime'
|
||||||
|
|
||||||
|
import { defineInvokeHandler } from '@moeru/eventa'
|
||||||
|
import { onBeforeUnmount, toValue, watch } from 'vue'
|
||||||
|
|
||||||
|
import {
|
||||||
|
applyLive2DExpressionSettingsCommand,
|
||||||
|
getModelSettingsRuntimeContext,
|
||||||
|
modelSettingsRuntimeOwnerGone,
|
||||||
|
modelSettingsRuntimeSnapshotChanged,
|
||||||
|
modelSettingsRuntimeSnapshotRequested,
|
||||||
|
} from '../../shared/model-settings-runtime'
|
||||||
|
|
||||||
|
interface UseModelSettingsRuntimeOwnerOptions {
|
||||||
|
ownerInstanceId: string
|
||||||
|
renderer: MaybeRefOrGetter<ModelSettingsRuntimeSnapshot['renderer'] | undefined>
|
||||||
|
runtimeSnapshot: MaybeRefOrGetter<ModelSettingsRuntimeSnapshot>
|
||||||
|
applyLive2DExpressionCommand: (command: Live2DExpressionSettingsCommand) => void
|
||||||
|
context?: ModelSettingsRuntimeContext
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Owns the model-settings channel for the renderer that controls the active model.
|
||||||
|
*
|
||||||
|
* The owner publishes runtime snapshots and accepts commands for its current owner ID.
|
||||||
|
* Commands for stale owners or non-Live2D renderers do not change the expression store.
|
||||||
|
*/
|
||||||
|
export function useModelSettingsRuntimeOwner(options: UseModelSettingsRuntimeOwnerOptions) {
|
||||||
|
const context = options.context ?? getModelSettingsRuntimeContext()
|
||||||
|
|
||||||
|
function postSnapshot(snapshot: ModelSettingsRuntimeSnapshot) {
|
||||||
|
void context.emit(modelSettingsRuntimeSnapshotChanged, snapshot).catch((error) => {
|
||||||
|
console.warn('[Model Settings Runtime] Failed to publish the runtime snapshot:', error)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => toValue(options.runtimeSnapshot), (snapshot) => {
|
||||||
|
postSnapshot(snapshot)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
|
const stopSnapshotRequests = context.on(modelSettingsRuntimeSnapshotRequested, () => {
|
||||||
|
postSnapshot(toValue(options.runtimeSnapshot))
|
||||||
|
})
|
||||||
|
const stopExpressionCommands = defineInvokeHandler(context, applyLive2DExpressionSettingsCommand, (request) => {
|
||||||
|
const currentSnapshot = toValue(options.runtimeSnapshot)
|
||||||
|
if (request.ownerInstanceId !== options.ownerInstanceId) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
snapshot: currentSnapshot,
|
||||||
|
rejectionReason: 'owner-changed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.modelId || request.modelId !== currentSnapshot.modelId) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
snapshot: currentSnapshot,
|
||||||
|
rejectionReason: 'model-changed',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (toValue(options.renderer) !== 'live2d' || currentSnapshot.controlsLocked) {
|
||||||
|
return {
|
||||||
|
applied: false,
|
||||||
|
snapshot: currentSnapshot,
|
||||||
|
rejectionReason: 'runtime-unavailable',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
options.applyLive2DExpressionCommand(request.command)
|
||||||
|
return {
|
||||||
|
applied: true,
|
||||||
|
snapshot: toValue(options.runtimeSnapshot),
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopSnapshotRequests()
|
||||||
|
stopExpressionCommands()
|
||||||
|
void context.emit(modelSettingsRuntimeOwnerGone, {
|
||||||
|
ownerInstanceId: options.ownerInstanceId,
|
||||||
|
}).catch((error) => {
|
||||||
|
console.warn('[Model Settings Runtime] Failed to publish owner shutdown:', error)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,27 +1,61 @@
|
|||||||
|
import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
import type {
|
import type {
|
||||||
ModelSettingsRuntimeSnapshot,
|
ModelSettingsRuntimeSnapshot,
|
||||||
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||||
|
|
||||||
import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime'
|
import type { ModelSettingsRuntimeContext } from '../../shared/model-settings-runtime'
|
||||||
|
|
||||||
|
import { defineInvoke } from '@moeru/eventa'
|
||||||
import {
|
import {
|
||||||
createEmptyModelSettingsRuntimeSnapshot,
|
createEmptyModelSettingsRuntimeSnapshot,
|
||||||
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||||
import { useBroadcastChannel } from '@vueuse/core'
|
import { onMounted, onUnmounted, ref } from 'vue'
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
modelSettingsRuntimeSnapshotChannelName,
|
applyLive2DExpressionSettingsCommand,
|
||||||
|
getModelSettingsRuntimeContext,
|
||||||
|
modelSettingsRuntimeOwnerGone,
|
||||||
|
modelSettingsRuntimeSnapshotChanged,
|
||||||
|
modelSettingsRuntimeSnapshotRequested,
|
||||||
} from '../../shared/model-settings-runtime'
|
} from '../../shared/model-settings-runtime'
|
||||||
|
|
||||||
export function useModelSettingsRuntimeSnapshot() {
|
interface UseModelSettingsRuntimeSnapshotOptions {
|
||||||
|
context?: ModelSettingsRuntimeContext
|
||||||
|
/** Maximum wait for the stage owner to respond. @default 1000 */
|
||||||
|
commandTimeoutMs?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useModelSettingsRuntimeSnapshot(options: UseModelSettingsRuntimeSnapshotOptions = {}) {
|
||||||
const runtimeSnapshot = ref<ModelSettingsRuntimeSnapshot>(createEmptyModelSettingsRuntimeSnapshot())
|
const runtimeSnapshot = ref<ModelSettingsRuntimeSnapshot>(createEmptyModelSettingsRuntimeSnapshot())
|
||||||
const { data, post } = useBroadcastChannel<ModelSettingsRuntimeChannelEvent, ModelSettingsRuntimeChannelEvent>({
|
const context = options.context ?? getModelSettingsRuntimeContext()
|
||||||
name: modelSettingsRuntimeSnapshotChannelName,
|
const invokeExpressionCommand = defineInvoke(context, applyLive2DExpressionSettingsCommand)
|
||||||
})
|
|
||||||
|
|
||||||
const requestCurrent = () => {
|
const requestCurrent = () => {
|
||||||
post({ type: 'request-current' })
|
void context.emit(modelSettingsRuntimeSnapshotRequested, undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sendLive2DExpressionCommand = async (command: Live2DExpressionSettingsCommand) => {
|
||||||
|
const snapshot = runtimeSnapshot.value
|
||||||
|
if (!snapshot.ownerInstanceId || !snapshot.modelId || snapshot.renderer !== 'live2d' || snapshot.controlsLocked)
|
||||||
|
return false
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await invokeExpressionCommand({
|
||||||
|
ownerInstanceId: snapshot.ownerInstanceId,
|
||||||
|
modelId: snapshot.modelId,
|
||||||
|
command,
|
||||||
|
}, {
|
||||||
|
signal: AbortSignal.timeout(options.commandTimeoutMs ?? 1000),
|
||||||
|
})
|
||||||
|
runtimeSnapshot.value = response.snapshot
|
||||||
|
return response.applied
|
||||||
|
}
|
||||||
|
catch (error) {
|
||||||
|
runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot()
|
||||||
|
requestCurrent()
|
||||||
|
console.warn('[Model Settings Runtime] Failed to apply the Live2D expression command:', error)
|
||||||
|
return false
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const syncFromOwner = () => {
|
const syncFromOwner = () => {
|
||||||
@@ -32,6 +66,17 @@ export function useModelSettingsRuntimeSnapshot() {
|
|||||||
requestCurrent()
|
requestCurrent()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const stopSnapshots = context.on(modelSettingsRuntimeSnapshotChanged, (event) => {
|
||||||
|
if (event.body)
|
||||||
|
runtimeSnapshot.value = event.body
|
||||||
|
})
|
||||||
|
const stopOwnerGone = context.on(modelSettingsRuntimeOwnerGone, (event) => {
|
||||||
|
if (!event.body || runtimeSnapshot.value.ownerInstanceId !== event.body.ownerInstanceId)
|
||||||
|
return
|
||||||
|
|
||||||
|
runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot()
|
||||||
|
})
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
requestCurrent()
|
requestCurrent()
|
||||||
window.addEventListener('focus', syncFromOwner)
|
window.addEventListener('focus', syncFromOwner)
|
||||||
@@ -41,27 +86,13 @@ export function useModelSettingsRuntimeSnapshot() {
|
|||||||
onUnmounted(() => {
|
onUnmounted(() => {
|
||||||
window.removeEventListener('focus', syncFromOwner)
|
window.removeEventListener('focus', syncFromOwner)
|
||||||
document.removeEventListener('visibilitychange', syncFromOwnerWhenVisible)
|
document.removeEventListener('visibilitychange', syncFromOwnerWhenVisible)
|
||||||
})
|
stopSnapshots()
|
||||||
|
stopOwnerGone()
|
||||||
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 {
|
return {
|
||||||
runtimeSnapshot,
|
runtimeSnapshot,
|
||||||
requestCurrent,
|
requestCurrent,
|
||||||
|
sendLive2DExpressionCommand,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+171
@@ -0,0 +1,171 @@
|
|||||||
|
import type { ExpressionEntry, ExpressionGroupDefinition } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
|
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||||
|
|
||||||
|
import { defineInvoke } from '@moeru/eventa'
|
||||||
|
import { createContext as createBroadcastChannelContext } from '@moeru/eventa/adapters/broadcast-channel'
|
||||||
|
import { useExpressionStore } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
|
import { createEmptyModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, render } from 'vitest-browser-vue'
|
||||||
|
import { computed, defineComponent, shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { applyLive2DExpressionSettingsCommand, modelSettingsRuntimeChannelName } from '../../shared/model-settings-runtime'
|
||||||
|
import { useModelSettingsRuntimeOwner } from './model-settings-runtime-owner'
|
||||||
|
import { useModelSettingsRuntimeSnapshot } from './model-settings-runtime-snapshot'
|
||||||
|
|
||||||
|
const expressionGroups: ExpressionGroupDefinition[] = [{
|
||||||
|
name: 'happy',
|
||||||
|
parameters: [{ parameterId: 'ParamHappy', blend: 'Add', value: 1 }],
|
||||||
|
}]
|
||||||
|
|
||||||
|
const expressionEntries: ExpressionEntry[] = [{
|
||||||
|
name: 'ParamHappy',
|
||||||
|
parameterId: 'ParamHappy',
|
||||||
|
blend: 'Add',
|
||||||
|
currentValue: 0,
|
||||||
|
defaultValue: 0,
|
||||||
|
modelDefault: 0,
|
||||||
|
targetValue: 1,
|
||||||
|
}]
|
||||||
|
|
||||||
|
describe('model settings runtime channel', () => {
|
||||||
|
const channelContexts: Array<ReturnType<typeof createBroadcastChannelContext>> = []
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
for (const channelContext of channelContexts)
|
||||||
|
channelContext.dispose()
|
||||||
|
channelContexts.length = 0
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2450
|
||||||
|
it('applies an expression command through Eventa and rejects stale runtime identities for Issue #2450', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The settings window and the stage window have separate Pinia stores.
|
||||||
|
// A component-only test did not cover the channel, owner check, store update, or returned snapshot.
|
||||||
|
//
|
||||||
|
// We fixed this with an Eventa RPC that returns the current owner snapshot.
|
||||||
|
const ownerInstanceId = 'stage-owner'
|
||||||
|
const ownerPinia = createPinia()
|
||||||
|
const settingsPinia = createPinia()
|
||||||
|
const ownerExpressionStore = useExpressionStore(ownerPinia)
|
||||||
|
ownerExpressionStore.registerExpressions('model-a', expressionGroups, expressionEntries)
|
||||||
|
|
||||||
|
const ownerChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true })
|
||||||
|
const settingsChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true })
|
||||||
|
channelContexts.push(ownerChannelContext, settingsChannelContext)
|
||||||
|
|
||||||
|
const renderer = shallowRef<ModelSettingsRuntimeSnapshot['renderer']>('live2d')
|
||||||
|
const ownerSnapshot = computed(() => createEmptyModelSettingsRuntimeSnapshot({
|
||||||
|
ownerInstanceId,
|
||||||
|
modelId: ownerExpressionStore.modelId,
|
||||||
|
renderer: renderer.value,
|
||||||
|
phase: 'mounted',
|
||||||
|
controlsLocked: false,
|
||||||
|
previewAvailable: true,
|
||||||
|
canCapturePreview: false,
|
||||||
|
live2dExpressions: ownerExpressionStore.settingsSnapshot,
|
||||||
|
updatedAt: Date.now(),
|
||||||
|
}))
|
||||||
|
|
||||||
|
let settingsRuntime: ReturnType<typeof useModelSettingsRuntimeSnapshot> | undefined
|
||||||
|
const TestHost = defineComponent({
|
||||||
|
setup() {
|
||||||
|
useModelSettingsRuntimeOwner({
|
||||||
|
ownerInstanceId,
|
||||||
|
renderer: () => renderer.value,
|
||||||
|
runtimeSnapshot: ownerSnapshot,
|
||||||
|
applyLive2DExpressionCommand: command => ownerExpressionStore.applySettingsCommand(command),
|
||||||
|
context: ownerChannelContext.context,
|
||||||
|
})
|
||||||
|
settingsRuntime = useModelSettingsRuntimeSnapshot({
|
||||||
|
context: settingsChannelContext.context,
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await render(TestHost, {
|
||||||
|
global: {
|
||||||
|
plugins: [settingsPinia],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!settingsRuntime)
|
||||||
|
throw new Error('The settings runtime did not mount.')
|
||||||
|
const mountedSettingsRuntime = settingsRuntime
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.live2dExpressions?.groups).toEqual([{
|
||||||
|
name: 'happy',
|
||||||
|
active: false,
|
||||||
|
exposedToLlm: false,
|
||||||
|
}]))
|
||||||
|
|
||||||
|
const invokeExpressionCommand = defineInvoke(settingsChannelContext.context, applyLive2DExpressionSettingsCommand)
|
||||||
|
const staleOwnerResponse = await invokeExpressionCommand({
|
||||||
|
ownerInstanceId: 'stale-owner',
|
||||||
|
modelId: 'model-a',
|
||||||
|
command: { type: 'toggle', name: 'happy' },
|
||||||
|
}, {
|
||||||
|
signal: AbortSignal.timeout(1000),
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(staleOwnerResponse.applied).toBe(false)
|
||||||
|
expect(staleOwnerResponse.rejectionReason).toBe('owner-changed')
|
||||||
|
expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(false)
|
||||||
|
|
||||||
|
const applied = await mountedSettingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' })
|
||||||
|
|
||||||
|
await vi.waitFor(() => expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(true))
|
||||||
|
await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.live2dExpressions?.groups[0].active).toBe(true))
|
||||||
|
expect(applied).toBe(true)
|
||||||
|
expect(useExpressionStore(settingsPinia).expressionGroups.size).toBe(0)
|
||||||
|
|
||||||
|
ownerExpressionStore.registerExpressions('model-b', expressionGroups, expressionEntries)
|
||||||
|
const rejected = await mountedSettingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' })
|
||||||
|
|
||||||
|
expect(rejected).toBe(false)
|
||||||
|
expect(ownerExpressionStore.settingsSnapshot.groups[0].active).toBe(false)
|
||||||
|
await vi.waitFor(() => expect(mountedSettingsRuntime.runtimeSnapshot.value.modelId).toBe('model-b'))
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2450
|
||||||
|
it('clears a stale snapshot when the stage owner does not answer', async () => {
|
||||||
|
const settingsChannelContext = createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName), { closeOnDispose: true })
|
||||||
|
channelContexts.push(settingsChannelContext)
|
||||||
|
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
|
||||||
|
let settingsRuntime: ReturnType<typeof useModelSettingsRuntimeSnapshot> | undefined
|
||||||
|
const TestHost = defineComponent({
|
||||||
|
setup() {
|
||||||
|
settingsRuntime = useModelSettingsRuntimeSnapshot({
|
||||||
|
context: settingsChannelContext.context,
|
||||||
|
commandTimeoutMs: 20,
|
||||||
|
})
|
||||||
|
return () => null
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await render(TestHost)
|
||||||
|
if (!settingsRuntime)
|
||||||
|
throw new Error('The settings runtime did not mount.')
|
||||||
|
|
||||||
|
settingsRuntime.runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot({
|
||||||
|
ownerInstanceId: 'stale-owner',
|
||||||
|
modelId: 'model-a',
|
||||||
|
renderer: 'live2d',
|
||||||
|
phase: 'mounted',
|
||||||
|
controlsLocked: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const applied = await settingsRuntime.sendLive2DExpressionCommand({ type: 'toggle', name: 'happy' })
|
||||||
|
|
||||||
|
expect(applied).toBe(false)
|
||||||
|
expect(settingsRuntime.runtimeSnapshot.value.ownerInstanceId).toBe('')
|
||||||
|
expect(warn).toHaveBeenCalledOnce()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -2,8 +2,6 @@
|
|||||||
import type { CaptionChannelEvent, HearingInputChannelEvent } from '@proj-airi/stage-shared'
|
import type { CaptionChannelEvent, HearingInputChannelEvent } from '@proj-airi/stage-shared'
|
||||||
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
|
||||||
|
|
||||||
import type { ModelSettingsRuntimeChannelEvent } from '../../shared/model-settings-runtime'
|
|
||||||
|
|
||||||
import { errorMessageFrom, tryCatch } from '@moeru/std'
|
import { errorMessageFrom, tryCatch } from '@moeru/std'
|
||||||
import { electron } from '@proj-airi/electron-eventa'
|
import { electron } from '@proj-airi/electron-eventa'
|
||||||
import {
|
import {
|
||||||
@@ -15,6 +13,7 @@ import {
|
|||||||
} from '@proj-airi/electron-vueuse'
|
} from '@proj-airi/electron-vueuse'
|
||||||
import { createTranscriptBuffer } from '@proj-airi/pipelines-audio'
|
import { createTranscriptBuffer } from '@proj-airi/pipelines-audio'
|
||||||
import { hearingInputChannelName } from '@proj-airi/stage-shared'
|
import { hearingInputChannelName } from '@proj-airi/stage-shared'
|
||||||
|
import { useExpressionStore } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
|
import { useModelStore, useThreeSceneIsTransparentAtPoint } from '@proj-airi/stage-ui-three'
|
||||||
import { HoloCoupon } from '@proj-airi/stage-ui/components'
|
import { HoloCoupon } from '@proj-airi/stage-ui/components'
|
||||||
import {
|
import {
|
||||||
@@ -40,7 +39,7 @@ import ControlsIsland from '../components/stage-islands/controls-island/index.vu
|
|||||||
import ResourceStatusIsland from '../components/stage-islands/resource-status-island/index.vue'
|
import ResourceStatusIsland from '../components/stage-islands/resource-status-island/index.vue'
|
||||||
|
|
||||||
import { electronOpenOnboarding } from '../../shared/eventa'
|
import { electronOpenOnboarding } from '../../shared/eventa'
|
||||||
import { modelSettingsRuntimeSnapshotChannelName } from '../../shared/model-settings-runtime'
|
import { useModelSettingsRuntimeOwner } from '../composables/model-settings-runtime-owner'
|
||||||
import { useControlsIslandStore } from '../stores/controls-island'
|
import { useControlsIslandStore } from '../stores/controls-island'
|
||||||
import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
|
import { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
|
||||||
import { resolveFadeOnHoverInteraction } from '../utils/fade-on-hover'
|
import { resolveFadeOnHoverInteraction } from '../utils/fade-on-hover'
|
||||||
@@ -100,11 +99,11 @@ const isTransparentByThreeExact = useThreeSceneIsTransparentAtPoint(
|
|||||||
const settingsStore = useSettings()
|
const settingsStore = useSettings()
|
||||||
const { stageModelRenderer, stageModelSelectedUrl } = storeToRefs(settingsStore)
|
const { stageModelRenderer, stageModelSelectedUrl } = storeToRefs(settingsStore)
|
||||||
const modelStore = useModelStore()
|
const modelStore = useModelStore()
|
||||||
|
const expressionStore = useExpressionStore()
|
||||||
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
|
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
|
||||||
const { stagePaused } = storeToRefs(useStageWindowLifecycleStore())
|
const { stagePaused } = storeToRefs(useStageWindowLifecycleStore())
|
||||||
const { fadeOnHoverEnabled } = storeToRefs(useControlsIslandStore())
|
const { fadeOnHoverEnabled } = storeToRefs(useControlsIslandStore())
|
||||||
const modelSettingsRuntimeOwnerInstanceId = `tamagotchi-main-stage:${Math.random().toString(36).slice(2, 10)}`
|
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({
|
const shouldUseThreeTransparencyHitTest = computed(() => shouldSampleStageTransparency({
|
||||||
componentState: componentStateStage.value,
|
componentState: componentStateStage.value,
|
||||||
fadeOnHoverEnabled: fadeOnHoverEnabled.value,
|
fadeOnHoverEnabled: fadeOnHoverEnabled.value,
|
||||||
@@ -151,11 +150,13 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
|
|||||||
|
|
||||||
return createEmptyModelSettingsRuntimeSnapshot({
|
return createEmptyModelSettingsRuntimeSnapshot({
|
||||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||||
|
modelId: expressionStore.modelId,
|
||||||
renderer: 'live2d',
|
renderer: 'live2d',
|
||||||
phase,
|
phase,
|
||||||
controlsLocked: hasModel ? phase !== 'mounted' : false,
|
controlsLocked: hasModel ? phase !== 'mounted' : false,
|
||||||
previewAvailable: hasModel,
|
previewAvailable: hasModel,
|
||||||
canCapturePreview: false,
|
canCapturePreview: false,
|
||||||
|
live2dExpressions: expressionStore.settingsSnapshot,
|
||||||
updatedAt: Date.now(),
|
updatedAt: Date.now(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -297,25 +298,13 @@ watch(
|
|||||||
{ immediate: true },
|
{ immediate: true },
|
||||||
)
|
)
|
||||||
|
|
||||||
// Emit runtime snapshot on change and on request from settings panel
|
useModelSettingsRuntimeOwner({
|
||||||
/**
|
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
||||||
* Sends model-settings runtime events without letting closed HMR channels break the stage.
|
renderer: () => stageModelRenderer.value,
|
||||||
*/
|
runtimeSnapshot: modelSettingsRuntimeSnapshot,
|
||||||
function postModelSettingsRuntimeEvent(event: ModelSettingsRuntimeChannelEvent) {
|
applyLive2DExpressionCommand: (command) => {
|
||||||
const { error } = tryCatch(() => postModelSettingsRuntimeChannelEvent(event))
|
expressionStore.applySettingsCommand(command)
|
||||||
if (error)
|
},
|
||||||
console.warn('[Main Page] Failed to post model settings runtime event:', error)
|
|
||||||
}
|
|
||||||
|
|
||||||
watch(modelSettingsRuntimeSnapshot, (snapshot) => {
|
|
||||||
postModelSettingsRuntimeEvent({ type: 'snapshot', snapshot })
|
|
||||||
}, { immediate: true })
|
|
||||||
|
|
||||||
watch(modelSettingsRuntimeChannelEvent, (event) => {
|
|
||||||
if (event?.type !== 'request-current')
|
|
||||||
return
|
|
||||||
|
|
||||||
postModelSettingsRuntimeEvent({ type: 'snapshot', snapshot: modelSettingsRuntimeSnapshot.value })
|
|
||||||
})
|
})
|
||||||
|
|
||||||
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
const settingsAudioDeviceStore = useSettingsAudioDevice()
|
||||||
@@ -756,10 +745,6 @@ onUnmounted(() => {
|
|||||||
}
|
}
|
||||||
hearingInputClearTimers.clear()
|
hearingInputClearTimers.clear()
|
||||||
clearHearingInput()
|
clearHearingInput()
|
||||||
postModelSettingsRuntimeEvent({
|
|
||||||
type: 'owner-gone',
|
|
||||||
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
|
|
||||||
})
|
|
||||||
clearAssistantSpeechResumeTimer()
|
clearAssistantSpeechResumeTimer()
|
||||||
void voiceInputInteractionLifecycle.stop().catch(error => reportVoiceInputFailure('stop listening', error))
|
void voiceInputInteractionLifecycle.stop().catch(error => reportVoiceInputFailure('stop listening', error))
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ const godotStageStatus = ref<ElectronGodotStageStatus>({
|
|||||||
const switchingGodotStage = ref(false)
|
const switchingGodotStage = ref(false)
|
||||||
const godotViewError = ref<StageViewErrorPayload>()
|
const godotViewError = ref<StageViewErrorPayload>()
|
||||||
const godotViewSnapshot = ref<StageViewSnapshotPayload | null>(null)
|
const godotViewSnapshot = ref<StageViewSnapshotPayload | null>(null)
|
||||||
const { runtimeSnapshot } = useModelSettingsRuntimeSnapshot()
|
const { runtimeSnapshot, sendLive2DExpressionCommand } = useModelSettingsRuntimeSnapshot()
|
||||||
|
|
||||||
let sceneSyncGeneration = 0
|
let sceneSyncGeneration = 0
|
||||||
let godotSessionEpoch = 0
|
let godotSessionEpoch = 0
|
||||||
@@ -385,6 +385,7 @@ onUnmounted(() => {
|
|||||||
'sm:max-h-[80dvh]',
|
'sm:max-h-[80dvh]',
|
||||||
'relative',
|
'relative',
|
||||||
]"
|
]"
|
||||||
|
@live2d-expression-command="sendLive2DExpressionCommand"
|
||||||
@patch-godot-view-state="handleGodotViewPatch"
|
@patch-godot-view-state="handleGodotViewPatch"
|
||||||
>
|
>
|
||||||
<template #actions>
|
<template #actions>
|
||||||
|
|||||||
@@ -1,8 +1,42 @@
|
|||||||
|
import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components'
|
import type { ModelSettingsRuntimeSnapshot } from '@proj-airi/stage-ui/components'
|
||||||
|
|
||||||
export const modelSettingsRuntimeSnapshotChannelName = 'airi-model-settings-runtime-snapshot'
|
import { defineEventa, defineInvokeEventa } from '@moeru/eventa'
|
||||||
|
import { createContext as createBroadcastChannelContext } from '@moeru/eventa/adapters/broadcast-channel'
|
||||||
|
|
||||||
export type ModelSettingsRuntimeChannelEvent
|
export const modelSettingsRuntimeChannelName = 'airi-model-settings-runtime'
|
||||||
= | { type: 'request-current' }
|
|
||||||
| { type: 'snapshot', snapshot: ModelSettingsRuntimeSnapshot }
|
/** A settings mutation request for one loaded Live2D model. */
|
||||||
| { type: 'owner-gone', ownerInstanceId: string }
|
export interface ApplyLive2DExpressionSettingsCommandRequest {
|
||||||
|
/** Identifies the stage renderer that published the source snapshot. */
|
||||||
|
ownerInstanceId: string
|
||||||
|
/** Identifies the expression store that published the source snapshot. */
|
||||||
|
modelId: string
|
||||||
|
command: Live2DExpressionSettingsCommand
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The current owner state after it accepts or rejects a settings mutation. */
|
||||||
|
export interface ApplyLive2DExpressionSettingsCommandResponse {
|
||||||
|
applied: boolean
|
||||||
|
snapshot: ModelSettingsRuntimeSnapshot
|
||||||
|
rejectionReason?: 'owner-changed' | 'model-changed' | 'runtime-unavailable'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const modelSettingsRuntimeSnapshotRequested = defineEventa<void>('eventa:model-settings-runtime:snapshot:request')
|
||||||
|
export const modelSettingsRuntimeSnapshotChanged = defineEventa<ModelSettingsRuntimeSnapshot>('eventa:model-settings-runtime:snapshot:changed')
|
||||||
|
export const modelSettingsRuntimeOwnerGone = defineEventa<{ ownerInstanceId: string }>('eventa:model-settings-runtime:owner:gone')
|
||||||
|
export const applyLive2DExpressionSettingsCommand = defineInvokeEventa<
|
||||||
|
ApplyLive2DExpressionSettingsCommandResponse,
|
||||||
|
ApplyLive2DExpressionSettingsCommandRequest
|
||||||
|
>('eventa:model-settings-runtime:live2d-expression:apply')
|
||||||
|
|
||||||
|
let context: ReturnType<typeof createBroadcastChannelContext>['context'] | undefined
|
||||||
|
|
||||||
|
/** The Eventa context that carries model-settings state and commands. */
|
||||||
|
export type ModelSettingsRuntimeContext = ReturnType<typeof createBroadcastChannelContext>['context']
|
||||||
|
|
||||||
|
/** Returns the Eventa context for model-settings state and commands. */
|
||||||
|
export function getModelSettingsRuntimeContext() {
|
||||||
|
context ??= createBroadcastChannelContext(new BroadcastChannel(modelSettingsRuntimeChannelName)).context
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|||||||
@@ -189,8 +189,10 @@ const live2dShadowEnabled = toRef(() => props.live2dShadowEnabled)
|
|||||||
const internalModelRef = shallowRef<PixiLive2DInternalModel>()
|
const internalModelRef = shallowRef<PixiLive2DInternalModel>()
|
||||||
const expressionController = useExpressionController({
|
const expressionController = useExpressionController({
|
||||||
internalModel: internalModelRef,
|
internalModel: internalModelRef,
|
||||||
modelId: props.modelId,
|
|
||||||
})
|
})
|
||||||
|
// This identity belongs to model.value. It changes only when a model load
|
||||||
|
// commits, so expression initialization cannot observe a newer prop by mistake.
|
||||||
|
let loadedModelId: string | undefined
|
||||||
// Saved SDK manager references for runtime expression toggle (restore on disable)
|
// Saved SDK manager references for runtime expression toggle (restore on disable)
|
||||||
const savedEyeBlink = shallowRef<any>(null)
|
const savedEyeBlink = shallowRef<any>(null)
|
||||||
const savedExpressionManager = shallowRef<any>(null)
|
const savedExpressionManager = shallowRef<any>(null)
|
||||||
@@ -244,6 +246,7 @@ async function performModelLoad() {
|
|||||||
// Dispose expression controller before destroying the old model
|
// Dispose expression controller before destroying the old model
|
||||||
expressionController.dispose()
|
expressionController.dispose()
|
||||||
internalModelRef.value = undefined
|
internalModelRef.value = undefined
|
||||||
|
loadedModelId = undefined
|
||||||
|
|
||||||
try {
|
try {
|
||||||
pixiApp.value.stage.removeChild(model.value)
|
pixiApp.value.stage.removeChild(model.value)
|
||||||
@@ -254,7 +257,11 @@ async function performModelLoad() {
|
|||||||
}
|
}
|
||||||
model.value = undefined
|
model.value = undefined
|
||||||
}
|
}
|
||||||
if (!modelSrcRef.value) {
|
const pendingModel = {
|
||||||
|
id: props.modelId,
|
||||||
|
src: modelSrcRef.value,
|
||||||
|
}
|
||||||
|
if (!pendingModel.src) {
|
||||||
console.warn('No Live2D model source provided.')
|
console.warn('No Live2D model source provided.')
|
||||||
modelLoading.value = false
|
modelLoading.value = false
|
||||||
componentState.value = 'mounted'
|
componentState.value = 'mounted'
|
||||||
@@ -269,7 +276,7 @@ async function performModelLoad() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const live2DModel = new Live2DModel<PixiLive2DInternalModel>()
|
const live2DModel = new Live2DModel<PixiLive2DInternalModel>()
|
||||||
await Live2DFactory.setupLive2DModel(live2DModel, { url: modelSrcRef.value, id: props.modelId }, { autoInteract: false })
|
await Live2DFactory.setupLive2DModel(live2DModel, { url: pendingModel.src, id: pendingModel.id }, { autoInteract: false })
|
||||||
availableMotions.value.forEach((motion) => {
|
availableMotions.value.forEach((motion) => {
|
||||||
if (motion.motionName in Emotion) {
|
if (motion.motionName in Emotion) {
|
||||||
motionMap.value[motion.fileName] = motion.motionName
|
motionMap.value[motion.fileName] = motion.motionName
|
||||||
@@ -436,6 +443,7 @@ async function performModelLoad() {
|
|||||||
// toggled off at runtime.
|
// toggled off at runtime.
|
||||||
savedEyeBlink.value = internalModel.eyeBlink
|
savedEyeBlink.value = internalModel.eyeBlink
|
||||||
savedExpressionManager.value = motionManager.expressionManager
|
savedExpressionManager.value = motionManager.expressionManager
|
||||||
|
loadedModelId = pendingModel.id
|
||||||
|
|
||||||
// --- Expression controller initialisation (conditional)
|
// --- Expression controller initialisation (conditional)
|
||||||
if (live2dExpressionEnabled.value) {
|
if (live2dExpressionEnabled.value) {
|
||||||
@@ -465,7 +473,7 @@ async function performModelLoad() {
|
|||||||
finally {
|
finally {
|
||||||
modelLoading.value = false
|
modelLoading.value = false
|
||||||
componentState.value = 'mounted'
|
componentState.value = 'mounted'
|
||||||
await initExpressionController(internalModelRef.value).catch((err) => {
|
await initExpressionController(internalModelRef.value, loadedModelId).catch((err) => {
|
||||||
console.warn('[Model.vue] Expression controller initialization failed:', err)
|
console.warn('[Model.vue] Expression controller initialization failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -478,7 +486,7 @@ async function performModelLoad() {
|
|||||||
* This is intentionally fire-and-forget from loadModel so that a failure in
|
* This is intentionally fire-and-forget from loadModel so that a failure in
|
||||||
* expression loading does not prevent the model itself from rendering.
|
* expression loading does not prevent the model itself from rendering.
|
||||||
*/
|
*/
|
||||||
async function initExpressionController(internalModel?: PixiLive2DInternalModel) {
|
async function initExpressionController(internalModel?: PixiLive2DInternalModel, modelId?: string) {
|
||||||
// Dispose any previous state (handles model reloads)
|
// Dispose any previous state (handles model reloads)
|
||||||
expressionController.dispose()
|
expressionController.dispose()
|
||||||
|
|
||||||
@@ -502,7 +510,7 @@ async function initExpressionController(internalModel?: PixiLive2DInternalModel)
|
|||||||
return response.text()
|
return response.text()
|
||||||
}
|
}
|
||||||
|
|
||||||
await expressionController.initialise(expressionRefs, readExpFile)
|
await expressionController.initialise(modelId, expressionRefs, readExpFile)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function setMotion(motionName: string, index?: number) {
|
async function setMotion(motionName: string, index?: number) {
|
||||||
@@ -738,7 +746,7 @@ watch(live2dExpressionEnabled, (enabled) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
internalModelRef.value = im
|
internalModelRef.value = im
|
||||||
initExpressionController(im).catch((err) => {
|
initExpressionController(im, loadedModelId).catch((err) => {
|
||||||
console.warn('[Model.vue] Expression controller initialisation failed:', err)
|
console.warn('[Model.vue] Expression controller initialisation failed:', err)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -773,6 +781,7 @@ onUnmounted(() => {
|
|||||||
resizeAnimation?.pause()
|
resizeAnimation?.pause()
|
||||||
disposeShouldUpdateView?.()
|
disposeShouldUpdateView?.()
|
||||||
expressionController.dispose()
|
expressionController.dispose()
|
||||||
|
loadedModelId = undefined
|
||||||
})
|
})
|
||||||
|
|
||||||
function listMotionGroups() {
|
function listMotionGroups() {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import type { PixiLive2DInternalModel } from './motion-manager'
|
||||||
|
|
||||||
|
import { createPinia, setActivePinia } from 'pinia'
|
||||||
|
import { describe, expect, it } from 'vitest'
|
||||||
|
import { shallowRef } from 'vue'
|
||||||
|
|
||||||
|
import { useExpressionStore } from '../../stores/expression-store'
|
||||||
|
import { useExpressionController } from './expression-controller'
|
||||||
|
|
||||||
|
describe('useExpressionController', () => {
|
||||||
|
it('registers expressions with the identity of each loaded model', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// Model.vue creates one expression controller, then reuses it when the
|
||||||
|
// component loads another model. The controller captured the first model
|
||||||
|
// ID at setup time, so model B expressions were registered as model A.
|
||||||
|
//
|
||||||
|
// We fixed this by passing the identity of each completed model load into
|
||||||
|
// the matching expression initialization.
|
||||||
|
setActivePinia(createPinia())
|
||||||
|
|
||||||
|
const internalModel = shallowRef({
|
||||||
|
coreModel: {
|
||||||
|
getParameterDefaultValueById: () => 0,
|
||||||
|
},
|
||||||
|
} as unknown as PixiLive2DInternalModel)
|
||||||
|
const controller = useExpressionController({ internalModel })
|
||||||
|
const expressionStore = useExpressionStore()
|
||||||
|
const expressionRefs = [{ Name: 'Happy', File: 'happy.exp3.json' }]
|
||||||
|
const readExpFile = async () => JSON.stringify({
|
||||||
|
Type: 'Live2D Expression',
|
||||||
|
Parameters: [{ Id: 'ParamMouthForm', Value: 1, Blend: 'Add' }],
|
||||||
|
})
|
||||||
|
|
||||||
|
await controller.initialise('model-a', expressionRefs, readExpFile)
|
||||||
|
expect(expressionStore.modelId).toBe('model-a')
|
||||||
|
|
||||||
|
await controller.initialise('model-b', expressionRefs, readExpFile)
|
||||||
|
expect(expressionStore.modelId).toBe('model-b')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -39,12 +39,6 @@ export interface ExpressionControllerOptions {
|
|||||||
* while a model is being swapped).
|
* while a model is being swapped).
|
||||||
*/
|
*/
|
||||||
internalModel: Ref<PixiLive2DInternalModel | undefined>
|
internalModel: Ref<PixiLive2DInternalModel | undefined>
|
||||||
|
|
||||||
/**
|
|
||||||
* An optional model identifier used for persistence scoping.
|
|
||||||
* Falls back to `'unknown'` if not provided.
|
|
||||||
*/
|
|
||||||
modelId?: string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -66,12 +60,15 @@ export function useExpressionController(options: ExpressionControllerOptions) {
|
|||||||
* Parse model3.json expression references and the corresponding exp3 data,
|
* Parse model3.json expression references and the corresponding exp3 data,
|
||||||
* then register everything in the store.
|
* then register everything in the store.
|
||||||
*
|
*
|
||||||
|
* @param modelId - The identifier captured for this model load. It
|
||||||
|
* scopes expression persistence to the loaded model.
|
||||||
* @param expressionRefs - `FileReferences.Expressions` from model3.json
|
* @param expressionRefs - `FileReferences.Expressions` from model3.json
|
||||||
* @param readExpFile - An async function that reads the content of an
|
* @param readExpFile - An async function that reads the content of an
|
||||||
* exp3.json file given its path (relative to the
|
* exp3.json file given its path (relative to the
|
||||||
* model root inside the ZIP / OPFS).
|
* model root inside the ZIP / OPFS).
|
||||||
*/
|
*/
|
||||||
async function initialise(
|
async function initialise(
|
||||||
|
modelId: string | undefined,
|
||||||
expressionRefs: Model3ExpressionRef[],
|
expressionRefs: Model3ExpressionRef[],
|
||||||
readExpFile: (path: string) => Promise<string>,
|
readExpFile: (path: string) => Promise<string>,
|
||||||
) {
|
) {
|
||||||
@@ -126,7 +123,7 @@ export function useExpressionController(options: ExpressionControllerOptions) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
store.registerExpressions(
|
store.registerExpressions(
|
||||||
options.modelId ?? 'unknown',
|
modelId ?? 'unknown',
|
||||||
groups,
|
groups,
|
||||||
Array.from(entryMap.values()),
|
Array.from(entryMap.values()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -71,6 +71,35 @@ export interface ExpressionToolResult {
|
|||||||
available?: string[]
|
available?: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Controls which Live2D expressions the model exposes to LLM tools. */
|
||||||
|
export type Live2DExpressionLlmMode = 'all' | 'none' | 'custom'
|
||||||
|
|
||||||
|
/** A serializable expression group used by model settings in another renderer. */
|
||||||
|
export interface Live2DExpressionSettingsGroupSnapshot {
|
||||||
|
/** Expression name declared by the loaded model. */
|
||||||
|
name: string
|
||||||
|
/** Whether the owning renderer currently applies this expression. */
|
||||||
|
active: boolean
|
||||||
|
/** Whether custom LLM exposure includes this expression. */
|
||||||
|
exposedToLlm: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The serializable settings state owned by the renderer that loaded the Live2D model. */
|
||||||
|
export interface Live2DExpressionSettingsSnapshot {
|
||||||
|
/** Expression groups discovered by the owning renderer. */
|
||||||
|
groups: Live2DExpressionSettingsGroupSnapshot[]
|
||||||
|
/** Current LLM exposure policy. */
|
||||||
|
llmMode: Live2DExpressionLlmMode
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A settings operation sent to the renderer that owns the Live2D model. */
|
||||||
|
export type Live2DExpressionSettingsCommand
|
||||||
|
= | { type: 'toggle', name: string }
|
||||||
|
| { type: 'set-llm-mode', mode: Live2DExpressionLlmMode }
|
||||||
|
| { type: 'set-llm-exposed', name: string, exposed: boolean }
|
||||||
|
| { type: 'save-defaults' }
|
||||||
|
| { type: 'reset-all' }
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Persistence helpers (localStorage – no extra dependency needed)
|
// Persistence helpers (localStorage – no extra dependency needed)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -120,7 +149,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
const expressionGroups = ref<Map<string, ExpressionGroupDefinition>>(new Map())
|
const expressionGroups = ref<Map<string, ExpressionGroupDefinition>>(new Map())
|
||||||
|
|
||||||
/** LLM exposure mode: 'all' exposes everything, 'none' exposes nothing, 'custom' uses per-group map. */
|
/** LLM exposure mode: 'all' exposes everything, 'none' exposes nothing, 'custom' uses per-group map. */
|
||||||
const llmMode = ref<'all' | 'none' | 'custom'>('none')
|
const llmMode = ref<Live2DExpressionLlmMode>('none')
|
||||||
|
|
||||||
/** Per-group LLM exposure flags (only used when llmMode === 'custom'). */
|
/** Per-group LLM exposure flags (only used when llmMode === 'custom'). */
|
||||||
const llmExposed = ref<Map<string, boolean>>(new Map())
|
const llmExposed = ref<Map<string, boolean>>(new Map())
|
||||||
@@ -150,6 +179,25 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
return Array.from(expressions.value.keys())
|
return Array.from(expressions.value.keys())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isGroupActive(group: ExpressionGroupDefinition): boolean {
|
||||||
|
return group.parameters.some((parameter) => {
|
||||||
|
if (parameter.value === 0)
|
||||||
|
return false
|
||||||
|
|
||||||
|
const entry = expressions.value.get(parameter.parameterId)
|
||||||
|
return entry != null && entry.currentValue === parameter.value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const settingsSnapshot = computed<Live2DExpressionSettingsSnapshot>(() => ({
|
||||||
|
groups: Array.from(expressionGroups.value.values(), group => ({
|
||||||
|
name: group.name,
|
||||||
|
active: isGroupActive(group),
|
||||||
|
exposedToLlm: llmExposed.value.get(group.name) ?? false,
|
||||||
|
})),
|
||||||
|
llmMode: llmMode.value,
|
||||||
|
}))
|
||||||
|
|
||||||
// ---- public API ----------------------------------------------------------
|
// ---- public API ----------------------------------------------------------
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -290,12 +338,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
// A group is "active" when at least one of its non-zero (activation)
|
// A group is "active" when at least one of its non-zero (activation)
|
||||||
// params is currently set to the exp3 value. Zero-valued params are
|
// params is currently set to the exp3 value. Zero-valued params are
|
||||||
// "reset" instructions and are excluded from the active check.
|
// "reset" instructions and are excluded from the active check.
|
||||||
const isActive = resolved.group.parameters.some((p) => {
|
const isActive = isGroupActive(resolved.group)
|
||||||
if (p.value === 0)
|
|
||||||
return false
|
|
||||||
const entry = expressions.value.get(p.parameterId)
|
|
||||||
return entry && entry.currentValue === p.value
|
|
||||||
})
|
|
||||||
const states: ExpressionState[] = []
|
const states: ExpressionState[] = []
|
||||||
for (const param of resolved.group.parameters) {
|
for (const param of resolved.group.parameters) {
|
||||||
const entry = expressions.value.get(param.parameterId)
|
const entry = expressions.value.get(param.parameterId)
|
||||||
@@ -360,7 +403,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
|
|
||||||
// ---- LLM exposure --------------------------------------------------------
|
// ---- LLM exposure --------------------------------------------------------
|
||||||
|
|
||||||
function setLlmMode(mode: 'all' | 'none' | 'custom') {
|
function setLlmMode(mode: Live2DExpressionLlmMode) {
|
||||||
llmMode.value = mode
|
llmMode.value = mode
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -377,6 +420,24 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
return llmExposed.value.get(name) ?? false
|
return llmExposed.value.get(name) ?? false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Applies a settings command in the renderer that owns the Live2D runtime. */
|
||||||
|
function applySettingsCommand(command: Live2DExpressionSettingsCommand): ExpressionToolResult | void {
|
||||||
|
switch (command.type) {
|
||||||
|
case 'toggle':
|
||||||
|
return toggle(command.name)
|
||||||
|
case 'set-llm-mode':
|
||||||
|
setLlmMode(command.mode)
|
||||||
|
return
|
||||||
|
case 'set-llm-exposed':
|
||||||
|
setLlmExposed(command.name, command.exposed)
|
||||||
|
return
|
||||||
|
case 'save-defaults':
|
||||||
|
return saveDefaults()
|
||||||
|
case 'reset-all':
|
||||||
|
return resetAll()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ---- private -------------------------------------------------------------
|
// ---- private -------------------------------------------------------------
|
||||||
|
|
||||||
function applyValue(entry: ExpressionEntry, value: number, duration?: number) {
|
function applyValue(entry: ExpressionEntry, value: number, duration?: number) {
|
||||||
@@ -405,6 +466,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
expressionGroups,
|
expressionGroups,
|
||||||
llmMode,
|
llmMode,
|
||||||
llmExposed,
|
llmExposed,
|
||||||
|
settingsSnapshot,
|
||||||
|
|
||||||
// Actions
|
// Actions
|
||||||
registerExpressions,
|
registerExpressions,
|
||||||
@@ -418,5 +480,6 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
|
|||||||
setLlmMode,
|
setLlmMode,
|
||||||
setLlmExposed,
|
setLlmExposed,
|
||||||
isExposedToLlm,
|
isExposedToLlm,
|
||||||
|
applySettingsCommand,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
+83
@@ -0,0 +1,83 @@
|
|||||||
|
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||||
|
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||||
|
import { cleanup, render } from 'vitest-browser-vue'
|
||||||
|
import { createI18n } from 'vue-i18n'
|
||||||
|
|
||||||
|
function createTestI18n() {
|
||||||
|
return createI18n({
|
||||||
|
legacy: false,
|
||||||
|
locale: 'en',
|
||||||
|
missingWarn: false,
|
||||||
|
fallbackWarn: false,
|
||||||
|
messages: { en: {} },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('live2D model settings', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup()
|
||||||
|
localStorage.clear()
|
||||||
|
})
|
||||||
|
|
||||||
|
// https://github.com/moeru-ai/airi/issues/2450
|
||||||
|
it('renders a remote expression snapshot and emits a command without changing the local store', async () => {
|
||||||
|
// ROOT CAUSE:
|
||||||
|
//
|
||||||
|
// The Electron stage renderer loaded the model and registered its expressions.
|
||||||
|
// The separate settings renderer read a different Pinia store, so the list stayed empty.
|
||||||
|
//
|
||||||
|
// We fixed this by sending a serializable expression snapshot from the stage owner.
|
||||||
|
Object.assign(window, { Live2DCubismCore: {} })
|
||||||
|
const [{ useExpressionStore, useSettingsLive2d }, { default: Live2DSettings }] = await Promise.all([
|
||||||
|
import('@proj-airi/stage-ui-live2d'),
|
||||||
|
import('./live2d.vue'),
|
||||||
|
])
|
||||||
|
|
||||||
|
const pinia = createPinia()
|
||||||
|
const live2dSettings = useSettingsLive2d(pinia)
|
||||||
|
live2dSettings.live2dExpressionEnabled = true
|
||||||
|
|
||||||
|
const runtimeSnapshot = {
|
||||||
|
ownerInstanceId: 'stage-owner',
|
||||||
|
modelId: 'test-model',
|
||||||
|
renderer: 'live2d',
|
||||||
|
phase: 'mounted',
|
||||||
|
controlsLocked: false,
|
||||||
|
previewAvailable: true,
|
||||||
|
canCapturePreview: false,
|
||||||
|
updatedAt: 1,
|
||||||
|
live2dExpressions: {
|
||||||
|
groups: [
|
||||||
|
{ name: 'happy', active: false, exposedToLlm: false },
|
||||||
|
{ name: 'surprised', active: true, exposedToLlm: false },
|
||||||
|
],
|
||||||
|
llmMode: 'none',
|
||||||
|
},
|
||||||
|
} satisfies ModelSettingsRuntimeSnapshot
|
||||||
|
|
||||||
|
const onLive2dExpressionCommand = vi.fn()
|
||||||
|
|
||||||
|
const screen = await render(Live2DSettings, {
|
||||||
|
props: {
|
||||||
|
palette: [],
|
||||||
|
runtimeSnapshot,
|
||||||
|
onLive2dExpressionCommand,
|
||||||
|
},
|
||||||
|
global: {
|
||||||
|
plugins: [pinia, createTestI18n()],
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
await screen.getByText('settings.live2d.expressions.title', { exact: true }).click()
|
||||||
|
await expect.element(screen.getByText('happy', { exact: true })).toBeVisible()
|
||||||
|
await expect.element(screen.getByText('surprised', { exact: true })).toBeVisible()
|
||||||
|
|
||||||
|
const expressionSwitches = screen.getByRole('switch').all()
|
||||||
|
await expressionSwitches[1].click()
|
||||||
|
|
||||||
|
expect(onLive2dExpressionCommand).toHaveBeenCalledWith({ type: 'toggle', name: 'happy' })
|
||||||
|
expect(useExpressionStore(pinia).expressionGroups.size).toBe(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Live2DMotionDriver } from '@proj-airi/stage-ui-live2d'
|
import type {
|
||||||
|
Live2DExpressionLlmMode,
|
||||||
|
Live2DExpressionSettingsCommand,
|
||||||
|
Live2DMotionDriver,
|
||||||
|
} from '@proj-airi/stage-ui-live2d'
|
||||||
import type { SelectTabOption } from '@proj-airi/ui'
|
import type { SelectTabOption } from '@proj-airi/ui'
|
||||||
|
|
||||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||||
@@ -24,8 +28,9 @@ const props = withDefaults(defineProps<{
|
|||||||
}>(), {
|
}>(), {
|
||||||
allowExtractColors: true,
|
allowExtractColors: true,
|
||||||
})
|
})
|
||||||
defineEmits<{
|
const emit = defineEmits<{
|
||||||
(e: 'extractColorsFromModel'): void
|
(e: 'extractColorsFromModel'): void
|
||||||
|
(e: 'live2dExpressionCommand', command: Live2DExpressionSettingsCommand): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const { t } = useI18n()
|
const { t } = useI18n()
|
||||||
@@ -67,20 +72,16 @@ const {
|
|||||||
} = storeToRefs(live2d)
|
} = storeToRefs(live2d)
|
||||||
|
|
||||||
const expressionStore = useExpressionStore()
|
const expressionStore = useExpressionStore()
|
||||||
const { expressions, expressionGroups } = storeToRefs(expressionStore)
|
const expressionSettingsSnapshot = computed(() => props.runtimeSnapshot.live2dExpressions ?? expressionStore.settingsSnapshot)
|
||||||
|
const usesRemoteExpressionRuntime = computed(() => props.runtimeSnapshot.live2dExpressions != null)
|
||||||
|
|
||||||
/**
|
function applyExpressionSettingsCommand(command: Live2DExpressionSettingsCommand) {
|
||||||
* Check if an expression group is currently active.
|
if (usesRemoteExpressionRuntime.value) {
|
||||||
* Only considers non-zero exp3 params (zero-valued params are "reset" instructions).
|
emit('live2dExpressionCommand', command)
|
||||||
* A group is active when at least one of its activation params matches the exp3 value.
|
return
|
||||||
*/
|
}
|
||||||
function isGroupActive(group: { parameters: { parameterId: string, value: number }[] }): boolean {
|
|
||||||
return group.parameters.some((p) => {
|
expressionStore.applySettingsCommand(command)
|
||||||
if (p.value === 0)
|
|
||||||
return false // Skip reset params
|
|
||||||
const entry = expressions.value.get(p.parameterId)
|
|
||||||
return entry != null && entry.currentValue === p.value
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedRuntimeMotion = ref<string>('')
|
const selectedRuntimeMotion = ref<string>('')
|
||||||
@@ -762,7 +763,7 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
|
|||||||
<div v-if="!live2dExpressionEnabled" py-2 text-xs text-neutral-500 dark:text-neutral-400>
|
<div v-if="!live2dExpressionEnabled" py-2 text-xs text-neutral-500 dark:text-neutral-400>
|
||||||
{{ t('settings.live2d.expressions.sdk-preset-preserved-notice') }}
|
{{ t('settings.live2d.expressions.sdk-preset-preserved-notice') }}
|
||||||
</div>
|
</div>
|
||||||
<template v-else-if="expressionGroups.size === 0">
|
<template v-else-if="expressionSettingsSnapshot.groups.length === 0">
|
||||||
<div py-2 text-sm text-neutral-500 dark:text-neutral-400>
|
<div py-2 text-sm text-neutral-500 dark:text-neutral-400>
|
||||||
{{ t('settings.live2d.expressions.no-expression') }}
|
{{ t('settings.live2d.expressions.no-expression') }}
|
||||||
</div>
|
</div>
|
||||||
@@ -771,14 +772,14 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
|
|||||||
<!-- Expression preview toggles -->
|
<!-- Expression preview toggles -->
|
||||||
<div flex flex-col gap-2>
|
<div flex flex-col gap-2>
|
||||||
<div
|
<div
|
||||||
v-for="[groupName, group] in expressionGroups"
|
v-for="group in expressionSettingsSnapshot.groups"
|
||||||
:key="groupName"
|
:key="group.name"
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
>
|
>
|
||||||
<span text-sm text-neutral-700 dark:text-neutral-300>{{ groupName }}</span>
|
<span text-sm text-neutral-700 dark:text-neutral-300>{{ group.name }}</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="isGroupActive(group)"
|
:model-value="group.active"
|
||||||
@update:model-value="expressionStore.toggle(groupName)"
|
@update:model-value="applyExpressionSettingsCommand({ type: 'toggle', name: group.name })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -786,37 +787,37 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
|
|||||||
<div mt-4 flex flex-wrap items-center gap-3>
|
<div mt-4 flex flex-wrap items-center gap-3>
|
||||||
<span whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-400>{{ t('settings.live2d.expressions.expose-to-llm-toggle') }}</span>
|
<span whitespace-nowrap text-sm text-neutral-600 dark:text-neutral-400>{{ t('settings.live2d.expressions.expose-to-llm-toggle') }}</span>
|
||||||
<SelectTab
|
<SelectTab
|
||||||
:model-value="expressionStore.llmMode"
|
:model-value="expressionSettingsSnapshot.llmMode"
|
||||||
:options="llmModeOptions"
|
:options="llmModeOptions"
|
||||||
size="sm"
|
size="sm"
|
||||||
@update:model-value="(v: string) => expressionStore.setLlmMode(v as 'all' | 'none' | 'custom')"
|
@update:model-value="(mode: string) => applyExpressionSettingsCommand({ type: 'set-llm-mode', mode: mode as Live2DExpressionLlmMode })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="expressionStore.llmMode !== 'none'" text-xs text-neutral-500 dark:text-neutral-400>
|
<span v-if="expressionSettingsSnapshot.llmMode !== 'none'" text-xs text-neutral-500 dark:text-neutral-400>
|
||||||
{{ t('settings.live2d.expressions.llm-integration-wip') }}
|
{{ t('settings.live2d.expressions.llm-integration-wip') }}
|
||||||
</span>
|
</span>
|
||||||
|
|
||||||
<!-- Custom per-expression LLM toggles (only when mode = 'custom') -->
|
<!-- Custom per-expression LLM toggles (only when mode = 'custom') -->
|
||||||
<div v-if="expressionStore.llmMode === 'custom'" mt-2 flex flex-col gap-2 border-l-2 border-neutral-200 pl-3 dark:border-neutral-700>
|
<div v-if="expressionSettingsSnapshot.llmMode === 'custom'" mt-2 flex flex-col gap-2 border-l-2 border-neutral-200 pl-3 dark:border-neutral-700>
|
||||||
<div
|
<div
|
||||||
v-for="[groupName] in expressionGroups"
|
v-for="group in expressionSettingsSnapshot.groups"
|
||||||
:key="`llm-${groupName}`"
|
:key="`llm-${group.name}`"
|
||||||
flex items-center justify-between
|
flex items-center justify-between
|
||||||
>
|
>
|
||||||
<span text-xs text-neutral-600 dark:text-neutral-400>{{ groupName }}</span>
|
<span text-xs text-neutral-600 dark:text-neutral-400>{{ group.name }}</span>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
:model-value="expressionStore.llmExposed.get(groupName) ?? false"
|
:model-value="group.exposedToLlm"
|
||||||
@update:model-value="(v: boolean) => expressionStore.setLlmExposed(groupName, v)"
|
@update:model-value="(exposed: boolean) => applyExpressionSettingsCommand({ type: 'set-llm-exposed', name: group.name, exposed })"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Action buttons -->
|
<!-- Action buttons -->
|
||||||
<div mt-4 flex gap-2>
|
<div mt-4 flex gap-2>
|
||||||
<Button @click="expressionStore.saveDefaults()">
|
<Button @click="applyExpressionSettingsCommand({ type: 'save-defaults' })">
|
||||||
{{ t('settings.live2d.expressions.save-default') }}
|
{{ t('settings.live2d.expressions.save-default') }}
|
||||||
</Button>
|
</Button>
|
||||||
<Button @click="expressionStore.resetAll()">
|
<Button @click="applyExpressionSettingsCommand({ type: 'reset-all' })">
|
||||||
{{ t('settings.live2d.expressions.reset') }}
|
{{ t('settings.live2d.expressions.reset') }}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import type {
|
|||||||
StageViewPatch,
|
StageViewPatch,
|
||||||
StageViewSnapshotPayload,
|
StageViewSnapshotPayload,
|
||||||
} from '@proj-airi/stage-shared/godot-stage'
|
} from '@proj-airi/stage-shared/godot-stage'
|
||||||
|
import type { Live2DExpressionSettingsCommand } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
|
|
||||||
import type { DisplayModel } from '../../../../stores/display-models'
|
import type { DisplayModel } from '../../../../stores/display-models'
|
||||||
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
import type { ModelSettingsRuntimeSnapshot } from './runtime'
|
||||||
@@ -37,6 +38,7 @@ interface ModelSettingsPanelProps {
|
|||||||
|
|
||||||
interface ModelSettingsPanelEmits {
|
interface ModelSettingsPanelEmits {
|
||||||
extractColorsFromModel: []
|
extractColorsFromModel: []
|
||||||
|
live2dExpressionCommand: [command: Live2DExpressionSettingsCommand]
|
||||||
patchGodotViewState: [patch: StageViewPatch]
|
patchGodotViewState: [patch: StageViewPatch]
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,6 +112,7 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
|
|||||||
:palette="palette"
|
:palette="palette"
|
||||||
:runtime-snapshot="runtimeSnapshot"
|
:runtime-snapshot="runtimeSnapshot"
|
||||||
@extract-colors-from-model="emit('extractColorsFromModel')"
|
@extract-colors-from-model="emit('extractColorsFromModel')"
|
||||||
|
@live2d-expression-command="emit('live2dExpressionCommand', $event)"
|
||||||
/>
|
/>
|
||||||
<VRM
|
<VRM
|
||||||
v-if="effectiveRenderer === 'vrm'"
|
v-if="effectiveRenderer === 'vrm'"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { StageAvatarBoundsPayload, StageViewState } from '@proj-airi/stage-shared/godot-stage'
|
import type { StageAvatarBoundsPayload, StageViewState } from '@proj-airi/stage-shared/godot-stage'
|
||||||
|
import type { Live2DExpressionSettingsSnapshot } from '@proj-airi/stage-ui-live2d/stores/expression-store'
|
||||||
|
|
||||||
import type { StageModelRenderer } from '../../../../stores/settings/stage-model'
|
import type { StageModelRenderer } from '../../../../stores/settings/stage-model'
|
||||||
|
|
||||||
@@ -7,11 +8,14 @@ export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mou
|
|||||||
|
|
||||||
export interface ModelSettingsRuntimeSnapshot {
|
export interface ModelSettingsRuntimeSnapshot {
|
||||||
ownerInstanceId: string
|
ownerInstanceId: string
|
||||||
|
/** Identifies the loaded model state that produced the runtime controls. */
|
||||||
|
modelId: string
|
||||||
renderer: ModelSettingsRuntimeRenderer
|
renderer: ModelSettingsRuntimeRenderer
|
||||||
phase: ModelSettingsRuntimePhase
|
phase: ModelSettingsRuntimePhase
|
||||||
controlsLocked: boolean
|
controlsLocked: boolean
|
||||||
previewAvailable: boolean
|
previewAvailable: boolean
|
||||||
canCapturePreview: boolean
|
canCapturePreview: boolean
|
||||||
|
live2dExpressions?: Live2DExpressionSettingsSnapshot
|
||||||
lastError?: string
|
lastError?: string
|
||||||
updatedAt: number
|
updatedAt: number
|
||||||
}
|
}
|
||||||
@@ -21,6 +25,7 @@ export function createEmptyModelSettingsRuntimeSnapshot(
|
|||||||
): ModelSettingsRuntimeSnapshot {
|
): ModelSettingsRuntimeSnapshot {
|
||||||
return {
|
return {
|
||||||
ownerInstanceId: '',
|
ownerInstanceId: '',
|
||||||
|
modelId: '',
|
||||||
renderer: 'disabled',
|
renderer: 'disabled',
|
||||||
phase: 'pending',
|
phase: 'pending',
|
||||||
controlsLocked: false,
|
controlsLocked: false,
|
||||||
|
|||||||
Reference in New Issue
Block a user