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 |
|---|---|
|
![](https://github.com/user-attachments/assets/e383ebf3-d60e-4f66-8550-4cab0dc61261)
|
![](https://github.com/user-attachments/assets/b8aee712-c227-44d0-a60c-2dd24cc0b0cd)
|
| Settings / Models / Live2D expressions: empty list | Settings / Models
/ Live2D expressions: model entries are available |
This commit is contained in:
leafyy
2026-09-07 11:14:44 +08:00
committed by GitHub
parent 836941fda8
commit 05ac66edfa
14 changed files with 628 additions and 114 deletions
@@ -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 {
ModelSettingsRuntimeSnapshot,
} 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 {
createEmptyModelSettingsRuntimeSnapshot,
} from '@proj-airi/stage-ui/components/scenarios/settings/model-settings/runtime'
import { useBroadcastChannel } from '@vueuse/core'
import { onMounted, onUnmounted, ref, watch } from 'vue'
import { onMounted, onUnmounted, ref } from 'vue'
import {
modelSettingsRuntimeSnapshotChannelName,
applyLive2DExpressionSettingsCommand,
getModelSettingsRuntimeContext,
modelSettingsRuntimeOwnerGone,
modelSettingsRuntimeSnapshotChanged,
modelSettingsRuntimeSnapshotRequested,
} 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 { data, post } = useBroadcastChannel<ModelSettingsRuntimeChannelEvent, ModelSettingsRuntimeChannelEvent>({
name: modelSettingsRuntimeSnapshotChannelName,
})
const context = options.context ?? getModelSettingsRuntimeContext()
const invokeExpressionCommand = defineInvoke(context, applyLive2DExpressionSettingsCommand)
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 = () => {
@@ -32,6 +66,17 @@ export function useModelSettingsRuntimeSnapshot() {
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(() => {
requestCurrent()
window.addEventListener('focus', syncFromOwner)
@@ -41,27 +86,13 @@ export function useModelSettingsRuntimeSnapshot() {
onUnmounted(() => {
window.removeEventListener('focus', syncFromOwner)
document.removeEventListener('visibilitychange', syncFromOwnerWhenVisible)
})
watch(data, (event) => {
if (!event)
return
if (event.type === 'snapshot') {
runtimeSnapshot.value = event.snapshot
return
}
if (event.type === 'owner-gone') {
if (runtimeSnapshot.value.ownerInstanceId !== event.ownerInstanceId)
return
runtimeSnapshot.value = createEmptyModelSettingsRuntimeSnapshot()
}
stopSnapshots()
stopOwnerGone()
})
return {
runtimeSnapshot,
requestCurrent,
sendLive2DExpressionCommand,
}
}
@@ -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 { 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 { electron } from '@proj-airi/electron-eventa'
import {
@@ -15,6 +13,7 @@ import {
} from '@proj-airi/electron-vueuse'
import { createTranscriptBuffer } from '@proj-airi/pipelines-audio'
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 { HoloCoupon } from '@proj-airi/stage-ui/components'
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 { 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 { useStageWindowLifecycleStore } from '../stores/stage-window-lifecycle'
import { resolveFadeOnHoverInteraction } from '../utils/fade-on-hover'
@@ -100,11 +99,11 @@ const isTransparentByThreeExact = useThreeSceneIsTransparentAtPoint(
const settingsStore = useSettings()
const { stageModelRenderer, stageModelSelectedUrl } = storeToRefs(settingsStore)
const modelStore = useModelStore()
const expressionStore = useExpressionStore()
const { sceneMutationLocked, scenePhase } = storeToRefs(modelStore)
const { stagePaused } = storeToRefs(useStageWindowLifecycleStore())
const { fadeOnHoverEnabled } = storeToRefs(useControlsIslandStore())
const modelSettingsRuntimeOwnerInstanceId = `tamagotchi-main-stage:${Math.random().toString(36).slice(2, 10)}`
const { data: modelSettingsRuntimeChannelEvent, post: postModelSettingsRuntimeChannelEvent } = useBroadcastChannel<ModelSettingsRuntimeChannelEvent, ModelSettingsRuntimeChannelEvent>({ name: modelSettingsRuntimeSnapshotChannelName })
const shouldUseThreeTransparencyHitTest = computed(() => shouldSampleStageTransparency({
componentState: componentStateStage.value,
fadeOnHoverEnabled: fadeOnHoverEnabled.value,
@@ -151,11 +150,13 @@ const modelSettingsRuntimeSnapshot = computed<ModelSettingsRuntimeSnapshot>(() =
return createEmptyModelSettingsRuntimeSnapshot({
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
modelId: expressionStore.modelId,
renderer: 'live2d',
phase,
controlsLocked: hasModel ? phase !== 'mounted' : false,
previewAvailable: hasModel,
canCapturePreview: false,
live2dExpressions: expressionStore.settingsSnapshot,
updatedAt: Date.now(),
})
}
@@ -297,25 +298,13 @@ watch(
{ immediate: true },
)
// Emit runtime snapshot on change and on request from settings panel
/**
* Sends model-settings runtime events without letting closed HMR channels break the stage.
*/
function postModelSettingsRuntimeEvent(event: ModelSettingsRuntimeChannelEvent) {
const { error } = tryCatch(() => postModelSettingsRuntimeChannelEvent(event))
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 })
useModelSettingsRuntimeOwner({
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
renderer: () => stageModelRenderer.value,
runtimeSnapshot: modelSettingsRuntimeSnapshot,
applyLive2DExpressionCommand: (command) => {
expressionStore.applySettingsCommand(command)
},
})
const settingsAudioDeviceStore = useSettingsAudioDevice()
@@ -756,10 +745,6 @@ onUnmounted(() => {
}
hearingInputClearTimers.clear()
clearHearingInput()
postModelSettingsRuntimeEvent({
type: 'owner-gone',
ownerInstanceId: modelSettingsRuntimeOwnerInstanceId,
})
clearAssistantSpeechResumeTimer()
void voiceInputInteractionLifecycle.stop().catch(error => reportVoiceInputFailure('stop listening', error))
})
@@ -61,7 +61,7 @@ const godotStageStatus = ref<ElectronGodotStageStatus>({
const switchingGodotStage = ref(false)
const godotViewError = ref<StageViewErrorPayload>()
const godotViewSnapshot = ref<StageViewSnapshotPayload | null>(null)
const { runtimeSnapshot } = useModelSettingsRuntimeSnapshot()
const { runtimeSnapshot, sendLive2DExpressionCommand } = useModelSettingsRuntimeSnapshot()
let sceneSyncGeneration = 0
let godotSessionEpoch = 0
@@ -385,6 +385,7 @@ onUnmounted(() => {
'sm:max-h-[80dvh]',
'relative',
]"
@live2d-expression-command="sendLive2DExpressionCommand"
@patch-godot-view-state="handleGodotViewPatch"
>
<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'
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
= | { type: 'request-current' }
| { type: 'snapshot', snapshot: ModelSettingsRuntimeSnapshot }
| { type: 'owner-gone', ownerInstanceId: string }
export const modelSettingsRuntimeChannelName = 'airi-model-settings-runtime'
/** A settings mutation request for one loaded Live2D model. */
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 expressionController = useExpressionController({
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)
const savedEyeBlink = shallowRef<any>(null)
const savedExpressionManager = shallowRef<any>(null)
@@ -244,6 +246,7 @@ async function performModelLoad() {
// Dispose expression controller before destroying the old model
expressionController.dispose()
internalModelRef.value = undefined
loadedModelId = undefined
try {
pixiApp.value.stage.removeChild(model.value)
@@ -254,7 +257,11 @@ async function performModelLoad() {
}
model.value = undefined
}
if (!modelSrcRef.value) {
const pendingModel = {
id: props.modelId,
src: modelSrcRef.value,
}
if (!pendingModel.src) {
console.warn('No Live2D model source provided.')
modelLoading.value = false
componentState.value = 'mounted'
@@ -269,7 +276,7 @@ async function performModelLoad() {
}
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) => {
if (motion.motionName in Emotion) {
motionMap.value[motion.fileName] = motion.motionName
@@ -436,6 +443,7 @@ async function performModelLoad() {
// toggled off at runtime.
savedEyeBlink.value = internalModel.eyeBlink
savedExpressionManager.value = motionManager.expressionManager
loadedModelId = pendingModel.id
// --- Expression controller initialisation (conditional)
if (live2dExpressionEnabled.value) {
@@ -465,7 +473,7 @@ async function performModelLoad() {
finally {
modelLoading.value = false
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)
})
}
@@ -478,7 +486,7 @@ async function performModelLoad() {
* This is intentionally fire-and-forget from loadModel so that a failure in
* 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)
expressionController.dispose()
@@ -502,7 +510,7 @@ async function initExpressionController(internalModel?: PixiLive2DInternalModel)
return response.text()
}
await expressionController.initialise(expressionRefs, readExpFile)
await expressionController.initialise(modelId, expressionRefs, readExpFile)
}
async function setMotion(motionName: string, index?: number) {
@@ -738,7 +746,7 @@ watch(live2dExpressionEnabled, (enabled) => {
}
internalModelRef.value = im
initExpressionController(im).catch((err) => {
initExpressionController(im, loadedModelId).catch((err) => {
console.warn('[Model.vue] Expression controller initialisation failed:', err)
})
}
@@ -773,6 +781,7 @@ onUnmounted(() => {
resizeAnimation?.pause()
disposeShouldUpdateView?.()
expressionController.dispose()
loadedModelId = undefined
})
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).
*/
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,
* 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 readExpFile - An async function that reads the content of an
* exp3.json file given its path (relative to the
* model root inside the ZIP / OPFS).
*/
async function initialise(
modelId: string | undefined,
expressionRefs: Model3ExpressionRef[],
readExpFile: (path: string) => Promise<string>,
) {
@@ -126,7 +123,7 @@ export function useExpressionController(options: ExpressionControllerOptions) {
}
store.registerExpressions(
options.modelId ?? 'unknown',
modelId ?? 'unknown',
groups,
Array.from(entryMap.values()),
)
@@ -1,5 +1,5 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
import { computed, ref } from 'vue'
// ---------------------------------------------------------------------------
// Types
@@ -71,6 +71,35 @@ export interface ExpressionToolResult {
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)
// ---------------------------------------------------------------------------
@@ -120,7 +149,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
const expressionGroups = ref<Map<string, ExpressionGroupDefinition>>(new 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'). */
const llmExposed = ref<Map<string, boolean>>(new Map())
@@ -150,6 +179,25 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
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 ----------------------------------------------------------
/**
@@ -290,12 +338,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
// 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
// "reset" instructions and are excluded from the active check.
const isActive = resolved.group.parameters.some((p) => {
if (p.value === 0)
return false
const entry = expressions.value.get(p.parameterId)
return entry && entry.currentValue === p.value
})
const isActive = isGroupActive(resolved.group)
const states: ExpressionState[] = []
for (const param of resolved.group.parameters) {
const entry = expressions.value.get(param.parameterId)
@@ -360,7 +403,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
// ---- LLM exposure --------------------------------------------------------
function setLlmMode(mode: 'all' | 'none' | 'custom') {
function setLlmMode(mode: Live2DExpressionLlmMode) {
llmMode.value = mode
}
@@ -377,6 +420,24 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
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 -------------------------------------------------------------
function applyValue(entry: ExpressionEntry, value: number, duration?: number) {
@@ -405,6 +466,7 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
expressionGroups,
llmMode,
llmExposed,
settingsSnapshot,
// Actions
registerExpressions,
@@ -418,5 +480,6 @@ export const useExpressionStore = defineStore('live2d-expressions', () => {
setLlmMode,
setLlmExposed,
isExposedToLlm,
applySettingsCommand,
}
})
@@ -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">
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 { ModelSettingsRuntimeSnapshot } from './runtime'
@@ -24,8 +28,9 @@ const props = withDefaults(defineProps<{
}>(), {
allowExtractColors: true,
})
defineEmits<{
const emit = defineEmits<{
(e: 'extractColorsFromModel'): void
(e: 'live2dExpressionCommand', command: Live2DExpressionSettingsCommand): void
}>()
const { t } = useI18n()
@@ -67,20 +72,16 @@ const {
} = storeToRefs(live2d)
const expressionStore = useExpressionStore()
const { expressions, expressionGroups } = storeToRefs(expressionStore)
const expressionSettingsSnapshot = computed(() => props.runtimeSnapshot.live2dExpressions ?? expressionStore.settingsSnapshot)
const usesRemoteExpressionRuntime = computed(() => props.runtimeSnapshot.live2dExpressions != null)
/**
* Check if an expression group is currently active.
* Only considers non-zero exp3 params (zero-valued params are "reset" instructions).
* A group is active when at least one of its activation params matches the exp3 value.
*/
function isGroupActive(group: { parameters: { parameterId: string, value: number }[] }): boolean {
return group.parameters.some((p) => {
if (p.value === 0)
return false // Skip reset params
const entry = expressions.value.get(p.parameterId)
return entry != null && entry.currentValue === p.value
})
function applyExpressionSettingsCommand(command: Live2DExpressionSettingsCommand) {
if (usesRemoteExpressionRuntime.value) {
emit('live2dExpressionCommand', command)
return
}
expressionStore.applySettingsCommand(command)
}
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>
{{ t('settings.live2d.expressions.sdk-preset-preserved-notice') }}
</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>
{{ t('settings.live2d.expressions.no-expression') }}
</div>
@@ -771,14 +772,14 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
<!-- Expression preview toggles -->
<div flex flex-col gap-2>
<div
v-for="[groupName, group] in expressionGroups"
:key="groupName"
v-for="group in expressionSettingsSnapshot.groups"
:key="group.name"
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
:model-value="isGroupActive(group)"
@update:model-value="expressionStore.toggle(groupName)"
:model-value="group.active"
@update:model-value="applyExpressionSettingsCommand({ type: 'toggle', name: group.name })"
/>
</div>
</div>
@@ -786,37 +787,37 @@ function handleMotionSelect(selectedMotionPath: string | number | undefined) {
<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>
<SelectTab
:model-value="expressionStore.llmMode"
:model-value="expressionSettingsSnapshot.llmMode"
:options="llmModeOptions"
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>
<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') }}
</span>
<!-- 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
v-for="[groupName] in expressionGroups"
:key="`llm-${groupName}`"
v-for="group in expressionSettingsSnapshot.groups"
:key="`llm-${group.name}`"
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
:model-value="expressionStore.llmExposed.get(groupName) ?? false"
@update:model-value="(v: boolean) => expressionStore.setLlmExposed(groupName, v)"
:model-value="group.exposedToLlm"
@update:model-value="(exposed: boolean) => applyExpressionSettingsCommand({ type: 'set-llm-exposed', name: group.name, exposed })"
/>
</div>
</div>
<!-- Action buttons -->
<div mt-4 flex gap-2>
<Button @click="expressionStore.saveDefaults()">
<Button @click="applyExpressionSettingsCommand({ type: 'save-defaults' })">
{{ t('settings.live2d.expressions.save-default') }}
</Button>
<Button @click="expressionStore.resetAll()">
<Button @click="applyExpressionSettingsCommand({ type: 'reset-all' })">
{{ t('settings.live2d.expressions.reset') }}
</Button>
</div>
@@ -4,6 +4,7 @@ import type {
StageViewPatch,
StageViewSnapshotPayload,
} 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 { ModelSettingsRuntimeSnapshot } from './runtime'
@@ -37,6 +38,7 @@ interface ModelSettingsPanelProps {
interface ModelSettingsPanelEmits {
extractColorsFromModel: []
live2dExpressionCommand: [command: Live2DExpressionSettingsCommand]
patchGodotViewState: [patch: StageViewPatch]
}
@@ -110,6 +112,7 @@ async function handleModelPick(selectedModel: DisplayModel | undefined) {
:palette="palette"
:runtime-snapshot="runtimeSnapshot"
@extract-colors-from-model="emit('extractColorsFromModel')"
@live2d-expression-command="emit('live2dExpressionCommand', $event)"
/>
<VRM
v-if="effectiveRenderer === 'vrm'"
@@ -1,4 +1,5 @@
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'
@@ -7,11 +8,14 @@ export type ModelSettingsRuntimePhase = 'pending' | 'loading' | 'binding' | 'mou
export interface ModelSettingsRuntimeSnapshot {
ownerInstanceId: string
/** Identifies the loaded model state that produced the runtime controls. */
modelId: string
renderer: ModelSettingsRuntimeRenderer
phase: ModelSettingsRuntimePhase
controlsLocked: boolean
previewAvailable: boolean
canCapturePreview: boolean
live2dExpressions?: Live2DExpressionSettingsSnapshot
lastError?: string
updatedAt: number
}
@@ -21,6 +25,7 @@ export function createEmptyModelSettingsRuntimeSnapshot(
): ModelSettingsRuntimeSnapshot {
return {
ownerInstanceId: '',
modelId: '',
renderer: 'disabled',
phase: 'pending',
controlsLocked: false,