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:
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user