Merge pull request #4 from leo-combaret/codex/fix-perpetual-ambient-motion

fix(avatar-react): keep ambient motion running
This commit is contained in:
Montlouis-Calixte Stéphane 2026-08-25 12:07:26 +02:00 committed by GitHub
commit 93faab3690
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 58 additions and 5 deletions

View File

@ -40,6 +40,21 @@ const runtimeEnvironment = () => ({
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
})
const shouldRunFrameLoop = (
definition: AvatarDefinition,
playback: Readonly<CorePlaybackState>,
environment: ReturnType<typeof runtimeEnvironment>
) => {
if (playback.status === 'playing') return true
const motion = definition.expressions[playback.activeExpression]?.motion
return (
playback.status === 'stopped' &&
!environment.reduceMotion &&
motion !== undefined &&
(motion.eyes !== 'none' || motion.body !== 'none')
)
}
export const markAvatarDefinitionValidated = (definition: object) => {
validatedDefinitions.add(definition)
}
@ -331,7 +346,7 @@ export function Avatar({
}, [playback.activeExpression, onExpressionChange])
useEffect(() => {
if (playback.status !== 'playing') return
if (!shouldRunFrameLoop(definition, playback, runtimeEnvironment())) return
let frame = 0
const tick = (now: number) => {
const current = playbackRef.current
@ -351,11 +366,13 @@ export function Avatar({
}
const frameScene = renderPlaybackFrame(next, now, environment)
paintScene(frameScene)
if (next.status === 'playing') frame = requestAnimationFrame(tick)
if (shouldRunFrameLoop(definition, next, environment)) {
frame = requestAnimationFrame(tick)
}
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [definition, playback.status, onAnimationEnd])
}, [definition, playback.activeExpression, playback.status, onAnimationEnd])
const controlled = animation !== undefined || expression !== undefined
useImperativeHandle(ref, () => ({

View File

@ -19,6 +19,11 @@ const expression = {
motion: { eyes: 'none', body: 'none' },
} as const
const ambientExpression = {
...expression,
motion: { eyes: 'none', body: 'shake' },
} as const
const definition: AvatarDefinition = {
schema: 'bible-strong/avatar-definition',
schemaVersion: 1,
@ -28,8 +33,12 @@ const definition: AvatarDefinition = {
nodes: [],
},
colors: { body: '#5b7fe5', eyes: '#111316' },
expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } },
expressionOrder: ['neutral', 'smile'],
expressions: {
neutral: expression,
smile: { ...expression, head: { x: 0, y: 10, z: 0 } },
restless: ambientExpression,
},
expressionOrder: ['neutral', 'smile', 'restless'],
animations: {
greet: {
playbackMode: 'loop',
@ -298,6 +307,33 @@ describe('@bible-strong/avatar-react', () => {
cancel.mockRestore()
})
it('keeps scheduling frames for a controlled expression with ambient motion', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
render(<Avatar definition={definition} expression="restless" />)
act(() => {
for (let index = 1; index <= 3; index++) {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(index * 1_000)
}
})
expect(request).toHaveBeenCalledTimes(4)
expect(frames).toHaveLength(1)
request.mockRestore()
cancel.mockRestore()
})
it('fires once-completion exactly once under Strict Mode', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()