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, 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) => { export const markAvatarDefinitionValidated = (definition: object) => {
validatedDefinitions.add(definition) validatedDefinitions.add(definition)
} }
@ -331,7 +346,7 @@ export function Avatar({
}, [playback.activeExpression, onExpressionChange]) }, [playback.activeExpression, onExpressionChange])
useEffect(() => { useEffect(() => {
if (playback.status !== 'playing') return if (!shouldRunFrameLoop(definition, playback, runtimeEnvironment())) return
let frame = 0 let frame = 0
const tick = (now: number) => { const tick = (now: number) => {
const current = playbackRef.current const current = playbackRef.current
@ -351,11 +366,13 @@ export function Avatar({
} }
const frameScene = renderPlaybackFrame(next, now, environment) const frameScene = renderPlaybackFrame(next, now, environment)
paintScene(frameScene) paintScene(frameScene)
if (next.status === 'playing') frame = requestAnimationFrame(tick) if (shouldRunFrameLoop(definition, next, environment)) {
frame = requestAnimationFrame(tick)
}
} }
frame = requestAnimationFrame(tick) frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame) return () => cancelAnimationFrame(frame)
}, [definition, playback.status, onAnimationEnd]) }, [definition, playback.activeExpression, playback.status, onAnimationEnd])
const controlled = animation !== undefined || expression !== undefined const controlled = animation !== undefined || expression !== undefined
useImperativeHandle(ref, () => ({ useImperativeHandle(ref, () => ({

View File

@ -19,6 +19,11 @@ const expression = {
motion: { eyes: 'none', body: 'none' }, motion: { eyes: 'none', body: 'none' },
} as const } as const
const ambientExpression = {
...expression,
motion: { eyes: 'none', body: 'shake' },
} as const
const definition: AvatarDefinition = { const definition: AvatarDefinition = {
schema: 'bible-strong/avatar-definition', schema: 'bible-strong/avatar-definition',
schemaVersion: 1, schemaVersion: 1,
@ -28,8 +33,12 @@ const definition: AvatarDefinition = {
nodes: [], nodes: [],
}, },
colors: { body: '#5b7fe5', eyes: '#111316' }, colors: { body: '#5b7fe5', eyes: '#111316' },
expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } }, expressions: {
expressionOrder: ['neutral', 'smile'], neutral: expression,
smile: { ...expression, head: { x: 0, y: 10, z: 0 } },
restless: ambientExpression,
},
expressionOrder: ['neutral', 'smile', 'restless'],
animations: { animations: {
greet: { greet: {
playbackMode: 'loop', playbackMode: 'loop',
@ -298,6 +307,33 @@ describe('@bible-strong/avatar-react', () => {
cancel.mockRestore() 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', () => { it('fires once-completion exactly once under Strict Mode', () => {
let nextFrame = 0 let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>() const frames = new Map<number, FrameRequestCallback>()