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:
co-authored by
autofix-ci[bot]
parent
aec5486c51
commit
7b7fb0f238
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user