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
@@ -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,