fix(stage-ui-live2d): prevent motions from overriding lip sync mouth values (#1783)

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
Co-authored-by-agent: Unknown <unknown@example.com>
This commit is contained in:
felix tremblay
2026-05-13 12:10:03 +08:00
committed by GitHub
co-authored by autofix-ci[bot]
parent aec5486c51
commit 7b7fb0f238
7 changed files with 60 additions and 17 deletions
@@ -66,7 +66,7 @@ signIn:
footer:
prefix: Al continuar, aceptas nuestros
terms: Términos
and: "y"
and: 'y'
privacy: Política de Privacidad
verifyEmail:
title:
+2 -2
View File
@@ -685,7 +685,7 @@ pages:
empty: Здесь пока ничего нет. Добавьте одно ниже!
add:
title: Новый
description: "Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше."
description: 'Заполните новый сервер, затем нажмите кнопку «Сохранить и перезапустить» — он будет перемещен в «Конфигурация» выше.'
pending-badge: Не сохранено
status:
unknown: Не загружен
@@ -902,7 +902,7 @@ pages:
description: >-
Провайдеры транскрипции (speech-to-text): Whisper.cpp, OpenAI, Azure Speech
artistry:
title: Artistry
title: Artistry
description: Поставщики моделей генерации и создания изображений, например ComfyUI, Replicate.
items:
comfyui:
@@ -685,7 +685,7 @@ pages:
empty: 这里什么都还没有哦,在下面添加一个!
add:
title: 新建
description: "填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」"
description: '填写新的服务器配置,然后点击「保存并重启」,完成后将会更新至上方的「已配置」'
pending-badge: 未保存
status:
unknown: 未加载
@@ -15,6 +15,7 @@ withDefaults(defineProps<{
paused?: boolean
mouthOpenSize?: number
nowSpeaking?: boolean
focusAt?: { x: number, y: number }
disableFocusAt?: boolean
themeColorsHue?: number
@@ -30,6 +31,7 @@ withDefaults(defineProps<{
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
mouthOpenSize: 0,
nowSpeaking: false,
themeColorsHue: 220.44,
themeColorsHueDynamic: false,
live2dIdleAnimationEnabled: true,
@@ -84,6 +86,7 @@ defineExpose({
:model-id="modelId"
:app="app"
:mouth-open-size="mouthOpenSize"
:now-speaking="nowSpeaking"
:width="width"
:height="height"
:paused="paused"
@@ -24,6 +24,7 @@ import {
useMotionUpdatePluginExpression,
useMotionUpdatePluginIdleDisable,
useMotionUpdatePluginIdleFocus,
useMotionUpdatePluginLipSync,
} from '../../../composables/live2d'
import { Emotion, EmotionNeutralMotionName } from '../../../constants/emotions'
import { useL2dViewControl, useLive2d } from '../../../stores/live2d'
@@ -34,6 +35,7 @@ const props = withDefaults(defineProps<{
app?: Application
mouthOpenSize?: number
nowSpeaking?: boolean
width: number
height: number
paused?: boolean
@@ -48,6 +50,7 @@ const props = withDefaults(defineProps<{
live2dShadowEnabled?: boolean
}>(), {
mouthOpenSize: 0,
nowSpeaking: false,
paused: false,
focusAt: () => ({ x: 0, y: 0 }),
disableFocusAt: false,
@@ -96,6 +99,7 @@ const model = ref<Live2DModel<PixiLive2DInternalModel>>()
const initialModelWidth = ref<number>(0)
const initialModelHeight = ref<number>(0)
const mouthOpenSize = computed(() => Math.max(0, Math.min(100, props.mouthOpenSize)))
const nowSpeaking = toRef(() => props.nowSpeaking)
const lastUpdateTime = ref(0)
const { isDark: dark } = useTheme()
@@ -108,10 +112,6 @@ const dropShadowFilter = shallowRef(new DropShadowFilter({
rotation: 45,
}))
function getCoreModel() {
return model.value!.internalModel.coreModel as any
}
let resizeAnimation: ReturnType<typeof animate> | undefined
function computeScaleAndPosition() {
@@ -369,6 +369,7 @@ async function loadModel() {
// This ensures blink respects expression state (0 × blinkFactor = 0).
motionManagerUpdate.register(useMotionUpdatePluginExpression(expressionController), 'final')
motionManagerUpdate.register(useMotionUpdatePluginAutoEyeBlink(live2dExpressionEnabled), 'final')
motionManagerUpdate.register(useMotionUpdatePluginLipSync(mouthOpenSize, nowSpeaking), 'final')
const hookedUpdate = motionManager.update as (model: PixiLive2DInternalModel['coreModel'], now: number) => boolean
motionManager.update = function (model: PixiLive2DInternalModel['coreModel'], now: number) {
@@ -563,7 +564,6 @@ watch([themeColorsHueDynamic, live2dShadowEnabled], ([dynamic, shadowEnabled]) =
}
}, { immediate: true })
watch(mouthOpenSize, value => getCoreModel().setParameterValueById('ParamMouthOpenY', value))
watch(currentMotion, value => setMotion(value.group, value.index))
watch(paused, value => value ? pixiApp.value?.stop() : pixiApp.value?.start())
@@ -16,7 +16,9 @@ export type PixiLive2DInternalModel = InternalModel & {
export interface MotionManagerUpdateContext {
model: CubismModel
// in seconds
now: number
// in seconds
timeDelta: number
hookedUpdate?: (model: CubismModel, now: number) => boolean
}
@@ -330,9 +332,7 @@ export function useMotionUpdatePluginAutoEyeBlink(
// Force ON or eyeBlink null: timer blink + markHandled.
if (ctx.live2dForceAutoBlinkEnabled.value || !ctx.internalModel.eyeBlink) {
const rawDelta = Math.max(ctx.timeDelta ?? 0, 0)
const dt = rawDelta < 5 ? rawDelta * 1000 : rawDelta
const safeDt = dt || 16
const safeDt = ctx.timeDelta * 1000 || 16
const { eyeLOpen, eyeROpen } = updateForcedBlink(safeDt, baseLeft, baseRight)
ctx.model.setParameterValueById('ParamEyeLOpen', eyeLOpen)
ctx.model.setParameterValueById('ParamEyeROpen', eyeROpen)
@@ -399,9 +399,7 @@ export function useMotionUpdatePluginAutoEyeBlink(
// Advance blink timer.
const wasActive = blinkState.phase !== 'idle'
const rawDelta = Math.max(ctx.timeDelta ?? 0, 0)
const dt = rawDelta < 5 ? rawDelta * 1000 : rawDelta
const safeDt = dt || 16
const safeDt = ctx.timeDelta * 1000 || 16
const { eyeLOpen: blinkFactorL, eyeROpen: blinkFactorR } = updateForcedBlink(safeDt, 1.0, 1.0)
// Blink cycle complete: restore exact pre-blink values.
@@ -437,3 +435,45 @@ export function useMotionUpdatePluginExpression(
controller.applyExpressions(ctx.model)
}
}
/**
* Final-phase plugin that owns ParamMouthOpenY while speech is active and
* smoothly cross-fades back to the motion-driven value when speech ends.
*
* `nowSpeaking` (not `mouthOpenSize > 0`) is the speech boundary, so silent
* gaps between phonemes write 0 directly instead of triggering the release.
*/
export function useMotionUpdatePluginLipSync(
mouthOpenSize: Ref<number>,
nowSpeaking: Ref<boolean>,
): MotionManagerPlugin {
// 200 ms covers a typical phoneme tail without lagging behind the next utterance.
const RELEASE_DURATION_MS = 200
let releaseRemainingMs = 0
let lastForcedValue = 0
// Smoothstep: 3t^2 - 2t^3, eases in/out with zero slope at endpoints.
const smoothstep = (t: number) => t * t * (3 - 2 * t)
return (ctx) => {
if (nowSpeaking.value) {
lastForcedValue = mouthOpenSize.value
releaseRemainingMs = RELEASE_DURATION_MS
ctx.model.setParameterValueById('ParamMouthOpenY', mouthOpenSize.value)
return
}
if (releaseRemainingMs <= 0)
return
releaseRemainingMs = Math.max(0, releaseRemainingMs - ctx.timeDelta * 1000)
const blend = smoothstep(1 - releaseRemainingMs / RELEASE_DURATION_MS)
// ParamMouthOpenY was already written by motion + expression plugins this frame.
const motionValue = ctx.model.getParameterValueById('ParamMouthOpenY') as number
const blended = lastForcedValue * (1 - blend) + motionValue * blend
ctx.model.setParameterValueById('ParamMouthOpenY', blended)
}
}
@@ -69,7 +69,7 @@ const {
live2dMaxFps,
live2dRenderScale,
} = storeToRefs(settingsStore)
const { mouthOpenSize } = storeToRefs(useSpeakingStore())
const { mouthOpenSize, nowSpeaking } = storeToRefs(useSpeakingStore())
const { audioContext } = useAudioContext()
const currentAudioSource = ref<AudioBufferSourceNode>()
@@ -106,7 +106,6 @@ viewUpdateCleanups.push(live2dStore.onShouldUpdateView(async () => {
}))
const audioAnalyser = ref<AnalyserNode>()
const nowSpeaking = ref(false)
const lipSyncStarted = ref(false)
const lipSyncLoopId = ref<number>()
const live2dLipSync = ref<Live2DLipSync>()
@@ -648,6 +647,7 @@ defineExpose({
:model-id="stageModelSelected"
:focus-at="focusAt"
:mouth-open-size="mouthOpenSize"
:now-speaking="nowSpeaking"
:paused="paused"
:disable-focus-at="live2dDisableFocus"
:theme-colors-hue="themeColorsHue"