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,
}
})