feat(runtime): unify React and ESM avatar exports
Add shared core-backed React and browser runtimes, continuous playback transitions, export previews and API documentation. Package the JavaScript export as a CDN-powered demo with the portable avatar definition.
This commit is contained in:
parent
7bed34cc56
commit
2386b78817
18
README.md
18
README.md
@ -20,7 +20,7 @@ The application runs entirely in the browser. Projects are stored locally and ca
|
|||||||
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
|
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
|
||||||
- Preview, play, pause, and stop animations inside the Studio.
|
- Preview, play, pause, and stop animations inside the Studio.
|
||||||
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
|
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
|
||||||
- Export a standalone React package or a framework-free JavaScript/HTML package.
|
- Export one portable `.avatar.json` definition for React or framework-free JavaScript/ESM.
|
||||||
- Export and import the complete Studio project as JSON.
|
- Export and import the complete Studio project as JSON.
|
||||||
- Use the interface in English, French, or Simplified Chinese.
|
- Use the interface in English, French, or Simplified Chinese.
|
||||||
|
|
||||||
@ -45,13 +45,21 @@ This copy-on-write model lets multiple avatars share the defaults without accide
|
|||||||
|
|
||||||
## Export formats
|
## Export formats
|
||||||
|
|
||||||
### React package
|
### Avatar definition
|
||||||
|
|
||||||
The React export is a local ZIP package containing a reusable TypeScript/React avatar component and the selected animations. It is intended for integration into React applications without shipping the Avatar Lab interface.
|
The selected avatar and animations are stored in one portable `.avatar.json` definition. React and
|
||||||
|
JavaScript use this exact same file, so visual behavior does not diverge between renderers.
|
||||||
|
|
||||||
### JavaScript package
|
### React / TypeScript
|
||||||
|
|
||||||
The JavaScript export is a self-contained ZIP project with an ES module, the selected avatar data and animations, and an HTML demo. It can be used without React.
|
Install `@bible-strong/avatar-react`, import the JSON and pass it to `createAvatar`. The React
|
||||||
|
package depends on `@bible-strong/avatar-core` for validation, playback and geometry.
|
||||||
|
|
||||||
|
### JavaScript / ESM
|
||||||
|
|
||||||
|
Install `@bible-strong/avatar-web` for a DOM renderer without React. The integration ZIP contains
|
||||||
|
the same `.avatar.json`, a lightweight ESM wrapper and usage instructions; it does not copy the
|
||||||
|
rendering engine into every avatar export. `avatar-web` also depends on `avatar-core`.
|
||||||
|
|
||||||
### Photo Mode
|
### Photo Mode
|
||||||
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import { Avatar } from '@bible-strong/avatar-react'
|
import { Avatar, createAvatar } from '@bible-strong/avatar-react'
|
||||||
import '@bible-strong/avatar-react/styles.css'
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
import { StrictMode, useState } from 'react'
|
import { StrictMode, useState } from 'react'
|
||||||
import { createRoot } from 'react-dom/client'
|
import { createRoot } from 'react-dom/client'
|
||||||
@ -10,7 +10,9 @@ import './styles.css'
|
|||||||
const validation = validateAvatarDefinition(definitionJson)
|
const validation = validateAvatarDefinition(definitionJson)
|
||||||
if (!validation.ok) throw new Error(validation.errors[0]?.message)
|
if (!validation.ok) throw new Error(validation.errors[0]?.message)
|
||||||
const definition = validation.value as AvatarDefinition
|
const definition = validation.value as AvatarDefinition
|
||||||
const expressions = Object.keys(definition.expressions)
|
const StrobiAvatar = createAvatar(definitionJson)
|
||||||
|
type StrobiExpressionKey = keyof typeof definitionJson.expressions
|
||||||
|
const expressions = Object.keys(definition.expressions) as StrobiExpressionKey[]
|
||||||
const horizontalPosition = (expression: string) =>
|
const horizontalPosition = (expression: string) =>
|
||||||
expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
|
expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
|
||||||
const verticalPosition = (expression: string) =>
|
const verticalPosition = (expression: string) =>
|
||||||
@ -38,7 +40,7 @@ const formatExpressionName = (expression: string) =>
|
|||||||
expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
|
expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
|
||||||
|
|
||||||
function Demo() {
|
function Demo() {
|
||||||
const [expression, setExpression] = useState('neutral')
|
const [expression, setExpression] = useState<StrobiExpressionKey>('neutral')
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main>
|
<main>
|
||||||
@ -80,15 +82,14 @@ function Demo() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
<div className="avatar-overlay" aria-label="Positioned avatar example">
|
||||||
<Avatar
|
<Avatar
|
||||||
definition={definition}
|
definition={definition}
|
||||||
expression={expression}
|
expression={expression}
|
||||||
mode="floating"
|
|
||||||
draggable
|
|
||||||
size={128}
|
size={128}
|
||||||
initialPosition={{ right: 24, bottom: 24 }}
|
ariaLabel="Positioned Strobi avatar"
|
||||||
ariaLabel="Floating draggable Strobi avatar"
|
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1728,6 +1728,5 @@
|
|||||||
"scared",
|
"scared",
|
||||||
"playful",
|
"playful",
|
||||||
"celebrate"
|
"celebrate"
|
||||||
],
|
]
|
||||||
"standardAnimationSet": 1
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -27,6 +27,15 @@ main {
|
|||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.avatar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
.expression-picker {
|
.expression-picker {
|
||||||
width: min(100%, 720px);
|
width: min(100%, 720px);
|
||||||
}
|
}
|
||||||
|
|||||||
21
examples/web-vite-consumer/index.html
Normal file
21
examples/web-vite-consumer/index.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Avatar Web consumer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div id="avatar"></div>
|
||||||
|
<div class="controls">
|
||||||
|
<button type="button" data-animation="sleeping">Sleeping</button>
|
||||||
|
<button type="button" data-animation="idle">Idle</button>
|
||||||
|
<button type="button" data-expression="neutral">Neutral</button>
|
||||||
|
<button type="button" id="pause">Pause</button>
|
||||||
|
<button type="button" id="stop">Stop</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
examples/web-vite-consumer/package.json
Normal file
16
examples/web-vite-consumer/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "avatar-web-vite-consumer",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bible-strong/avatar-web": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
examples/web-vite-consumer/src/main.ts
Normal file
21
examples/web-vite-consumer/src/main.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
|
||||||
|
import definition from '../../react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
defaultAnimation: 'sleeping',
|
||||||
|
size: '100%',
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-animation]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => avatar.play(button.dataset.animation ?? 'idle'))
|
||||||
|
})
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-expression]').forEach(button => {
|
||||||
|
button.addEventListener('click', () =>
|
||||||
|
avatar.setExpression(button.dataset.expression ?? 'neutral')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
document.querySelector('#pause')?.addEventListener('click', () => avatar.pause())
|
||||||
|
document.querySelector('#stop')?.addEventListener('click', () => avatar.stop())
|
||||||
41
examples/web-vite-consumer/src/styles.css
Normal file
41
examples/web-vite-consumer/src/styles.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
:root {
|
||||||
|
color: #17191d;
|
||||||
|
background: #f4f6fa;
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
width: min(480px, calc(100% - 32px));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#avatar {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid #dce1ea;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid #cfd6e2;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
13
examples/web-vite-consumer/tsconfig.json
Normal file
13
examples/web-vite-consumer/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
@ -28,21 +28,23 @@ Both functions return a non-mutating discriminated result. Successful values are
|
|||||||
errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
|
errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
|
||||||
`@bible-strong/avatar-core/schema`.
|
`@bible-strong/avatar-core/schema`.
|
||||||
|
|
||||||
|
Definitions from the earlier pre-release runtime export may still contain
|
||||||
|
`standardAnimationSet: 1`; the marker is accepted for compatibility but does not add any
|
||||||
|
implicit animations.
|
||||||
|
|
||||||
## Semantic lookup and playback
|
## Semantic lookup and playback
|
||||||
|
|
||||||
Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
|
Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
|
||||||
for unknown keys; standard animations additionally report
|
for keys that are not present in the validated definition. Every animation is explicit in the
|
||||||
`unavailable_standard_animation` when their required expressions are absent.
|
definition, so the JSON remains the single source of truth for what an avatar can play.
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
import {
|
import {
|
||||||
advanceAvatarPlayback,
|
advanceAvatarPlayback,
|
||||||
getStandardAnimationAvailabilityV1,
|
|
||||||
playAvatarAnimation,
|
playAvatarAnimation,
|
||||||
renderAvatarFrame,
|
renderAvatarFrame,
|
||||||
} from '@bible-strong/avatar-core'
|
} from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
|
|
||||||
const started = playAvatarAnimation(definition, 'idle', 0)
|
const started = playAvatarAnimation(definition, 'idle', 0)
|
||||||
if (!started.ok) throw new Error(started.error.message)
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
|
||||||
@ -70,5 +72,9 @@ animated frame. Both return paths, visibility and resolved colors without creati
|
|||||||
- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
|
- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
|
||||||
primitives for renderer authors.
|
primitives for renderer authors.
|
||||||
|
|
||||||
|
Application integrations are kept in separate packages: `@bible-strong/avatar-react` renders with
|
||||||
|
React 19, while `@bible-strong/avatar-web` mounts the same definition directly into the DOM. Both
|
||||||
|
depend on this package and use the same playback and scene implementation.
|
||||||
|
|
||||||
This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
|
This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
|
||||||
are confirmed. Local tarballs are for verification only and must not be published.
|
are confirmed. Local tarballs are for verification only and must not be published.
|
||||||
|
|||||||
@ -12,6 +12,7 @@ import {
|
|||||||
renderAvatarFrame,
|
renderAvatarFrame,
|
||||||
resumeAvatarPlayback,
|
resumeAvatarPlayback,
|
||||||
resolveAnimation,
|
resolveAnimation,
|
||||||
|
sampleAvatarFrame,
|
||||||
type AvatarDefinition,
|
type AvatarDefinition,
|
||||||
} from '../index'
|
} from '../index'
|
||||||
|
|
||||||
@ -41,13 +42,32 @@ const definition: AvatarDefinition = {
|
|||||||
'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
|
'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
|
||||||
},
|
},
|
||||||
expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
|
expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
|
||||||
animations: {},
|
animations: {
|
||||||
animationOrder: [],
|
idle: {
|
||||||
standardAnimationSet: 1,
|
playbackMode: 'loop',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
expression: 'upward-side-glance',
|
||||||
|
holdMs: 5_200,
|
||||||
|
transitionMs: 500,
|
||||||
|
transition: 'smooth',
|
||||||
|
},
|
||||||
|
{ expression: 'curious-left', holdMs: 5_200, transitionMs: 500, transition: 'smooth' },
|
||||||
|
],
|
||||||
|
blink: {
|
||||||
|
enabled: true,
|
||||||
|
initialDelayMs: 2_600,
|
||||||
|
minIntervalMs: 3_400,
|
||||||
|
maxIntervalMs: 6_200,
|
||||||
|
durationMs: 280,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationOrder: ['idle'],
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('@bible-strong/avatar-core', () => {
|
describe('@bible-strong/avatar-core', () => {
|
||||||
it('loads a JSON definition and resolves a standard semantic animation', () => {
|
it('loads a JSON definition and resolves an explicit semantic animation', () => {
|
||||||
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
||||||
expect(parsed.ok).toBe(true)
|
expect(parsed.ok).toBe(true)
|
||||||
if (!parsed.ok) return
|
if (!parsed.ok) return
|
||||||
@ -61,6 +81,23 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('accepts the legacy standard-animation marker without restoring hidden animations', () => {
|
||||||
|
const legacy = {
|
||||||
|
...definition,
|
||||||
|
animations: {},
|
||||||
|
animationOrder: [],
|
||||||
|
standardAnimationSet: 1 as const,
|
||||||
|
}
|
||||||
|
const parsed = parseAvatarDefinition(JSON.stringify(legacy))
|
||||||
|
expect(parsed.ok).toBe(true)
|
||||||
|
if (!parsed.ok) return
|
||||||
|
|
||||||
|
expect(resolveAnimation(parsed.value, 'idle')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'unknown_animation', key: 'idle' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('advances playback deterministically from transition to hold and the next step', () => {
|
it('advances playback deterministically from transition to hold and the next step', () => {
|
||||||
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||||
expect(started.ok).toBe(true)
|
expect(started.ok).toBe(true)
|
||||||
@ -85,12 +122,15 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('interpolates and completes a direct expression transition', () => {
|
it('interpolates and completes a direct expression transition', () => {
|
||||||
|
const from = sampleAvatarFrame(definition, createAvatarPlaybackState(), 1_000, {
|
||||||
|
random: () => 0.5,
|
||||||
|
})
|
||||||
const state = {
|
const state = {
|
||||||
...createAvatarPlaybackState(),
|
...createAvatarPlaybackState(),
|
||||||
activeExpression: 'curious-left',
|
activeExpression: 'curious-left',
|
||||||
status: 'playing' as const,
|
status: 'playing' as const,
|
||||||
directTransition: {
|
directTransition: {
|
||||||
from: 'neutral',
|
from,
|
||||||
startedAt: 1_000,
|
startedAt: 1_000,
|
||||||
durationMs: 400,
|
durationMs: 400,
|
||||||
transition: 'smooth' as const,
|
transition: 'smooth' as const,
|
||||||
@ -108,6 +148,84 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('starts a new animation from the currently displayed frame instead of neutral', () => {
|
||||||
|
const current = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
}
|
||||||
|
const now = 1_000
|
||||||
|
const from = sampleAvatarFrame(definition, current, now, { random: () => 0.5 })
|
||||||
|
const started = playAvatarAnimation(definition, 'idle', now, from)
|
||||||
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
|
||||||
|
expect(renderAvatarFrame(definition, started.value, now, { random: () => 0.5 })).toEqual(
|
||||||
|
renderAvatarFrame(definition, current, now, { random: () => 0.5 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retargets a direct transition from its exact in-flight frame', () => {
|
||||||
|
const neutral = createAvatarPlaybackState()
|
||||||
|
const firstStartedAt = 1_000
|
||||||
|
const first = {
|
||||||
|
...neutral,
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: sampleAvatarFrame(definition, neutral, firstStartedAt, { random: () => 0.5 }),
|
||||||
|
startedAt: firstStartedAt,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const retargetedAt = 1_200
|
||||||
|
const inFlight = sampleAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
|
||||||
|
const second = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: 'upward-side-glance',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: inFlight,
|
||||||
|
startedAt: retargetedAt,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderAvatarFrame(definition, second, retargetedAt, { random: () => 0.5 })).toEqual(
|
||||||
|
renderAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates expression color overrides during a transition', () => {
|
||||||
|
const colored: AvatarDefinition = {
|
||||||
|
...definition,
|
||||||
|
expressions: {
|
||||||
|
...definition.expressions,
|
||||||
|
'curious-left': {
|
||||||
|
...definition.expressions['curious-left'],
|
||||||
|
colors: { body: '#ff0000', eyes: '#ffffff' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const neutral = createAvatarPlaybackState()
|
||||||
|
const state = {
|
||||||
|
...neutral,
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: sampleAvatarFrame(colored, neutral, 1_000, { random: () => 0.5 }),
|
||||||
|
startedAt: 1_000,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderAvatarFrame(colored, state, 1_200, { random: () => 0.5 }).colors).toEqual({
|
||||||
|
body: '#ad4073',
|
||||||
|
eyes: '#88898b',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('generates the same geometry through the public definition adapter', () => {
|
it('generates the same geometry through the public definition adapter', () => {
|
||||||
const scene = renderAvatarDefinition(definition, 'curious-left')
|
const scene = renderAvatarDefinition(definition, 'curious-left')
|
||||||
const body = bodyFromDefinition(definition.body)
|
const body = bodyFromDefinition(definition.body)
|
||||||
@ -192,18 +310,18 @@ describe('@bible-strong/avatar-core', () => {
|
|||||||
expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
|
expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('returns typed errors for unknown and unavailable semantic animations', () => {
|
it('returns a typed error for an animation that is not present in the definition', () => {
|
||||||
expect(resolveAnimation(definition, 'missing')).toMatchObject({
|
expect(resolveAnimation(definition, 'missing')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: 'unknown_animation', key: 'missing' },
|
error: { code: 'unknown_animation', key: 'missing' },
|
||||||
})
|
})
|
||||||
expect(resolveAnimation(definition, 'happy')).toMatchObject({
|
expect(resolveAnimation(definition, 'happy')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: 'unavailable_standard_animation', key: 'happy' },
|
error: { code: 'unknown_animation', key: 'happy' },
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lets an explicit animation override the standard animation with the same key', () => {
|
it('resolves an explicit animation by its semantic key', () => {
|
||||||
const overridden: AvatarDefinition = {
|
const overridden: AvatarDefinition = {
|
||||||
...definition,
|
...definition,
|
||||||
animations: {
|
animations: {
|
||||||
|
|||||||
@ -12,8 +12,7 @@
|
|||||||
"expressions",
|
"expressions",
|
||||||
"expressionOrder",
|
"expressionOrder",
|
||||||
"animations",
|
"animations",
|
||||||
"animationOrder",
|
"animationOrder"
|
||||||
"standardAnimationSet"
|
|
||||||
],
|
],
|
||||||
"properties": {
|
"properties": {
|
||||||
"schema": { "const": "bible-strong/avatar-definition" },
|
"schema": { "const": "bible-strong/avatar-definition" },
|
||||||
@ -49,7 +48,7 @@
|
|||||||
"uniqueItems": true,
|
"uniqueItems": true,
|
||||||
"items": { "$ref": "#/$defs/semanticKey" }
|
"items": { "$ref": "#/$defs/semanticKey" }
|
||||||
},
|
},
|
||||||
"standardAnimationSet": { "const": 1 }
|
"standardAnimationSet": { "const": 1, "deprecated": true }
|
||||||
},
|
},
|
||||||
"$defs": {
|
"$defs": {
|
||||||
"semanticKey": {
|
"semanticKey": {
|
||||||
|
|||||||
@ -107,139 +107,8 @@ export type AvatarDefinition = {
|
|||||||
expressionOrder: ExpressionKey[]
|
expressionOrder: ExpressionKey[]
|
||||||
animations: Record<AnimationKey, AvatarAnimationDefinition>
|
animations: Record<AnimationKey, AvatarAnimationDefinition>
|
||||||
animationOrder: AnimationKey[]
|
animationOrder: AnimationKey[]
|
||||||
standardAnimationSet: 1
|
/** @deprecated Accepted for pre-release JSON compatibility; it no longer adds animations. */
|
||||||
}
|
standardAnimationSet?: 1
|
||||||
|
|
||||||
export const STANDARD_ANIMATION_ORDER_V1 = [
|
|
||||||
'idle',
|
|
||||||
'happy',
|
|
||||||
'sad',
|
|
||||||
'thinking',
|
|
||||||
'excited',
|
|
||||||
'celebrate',
|
|
||||||
] as const
|
|
||||||
|
|
||||||
export type StandardAnimationKeyV1 = (typeof STANDARD_ANIMATION_ORDER_V1)[number]
|
|
||||||
export type StandardAnimationManifestEntry = AvatarAnimationDefinition & {
|
|
||||||
requiredExpressions: ExpressionKey[]
|
|
||||||
}
|
|
||||||
|
|
||||||
const standardStep = (
|
|
||||||
expression: ExpressionKey,
|
|
||||||
holdMs: number
|
|
||||||
): AvatarAnimationStepDefinition => ({
|
|
||||||
expression,
|
|
||||||
holdMs,
|
|
||||||
transitionMs: 500,
|
|
||||||
transition: 'smooth',
|
|
||||||
})
|
|
||||||
|
|
||||||
const standardBlink = (
|
|
||||||
initialDelayMs: number,
|
|
||||||
minIntervalMs: number,
|
|
||||||
maxIntervalMs: number,
|
|
||||||
durationMs: number
|
|
||||||
) => ({ enabled: true, initialDelayMs, minIntervalMs, maxIntervalMs, durationMs })
|
|
||||||
|
|
||||||
export const STANDARD_ANIMATIONS_V1 = {
|
|
||||||
idle: {
|
|
||||||
requiredExpressions: ['upward-side-glance', 'curious-left'],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [standardStep('upward-side-glance', 5200), standardStep('curious-left', 5200)],
|
|
||||||
blink: standardBlink(2600, 3400, 6200, 280),
|
|
||||||
},
|
|
||||||
happy: {
|
|
||||||
requiredExpressions: [
|
|
||||||
'joyful-down-right',
|
|
||||||
'joyful-wide',
|
|
||||||
'playful-right',
|
|
||||||
'gentle-downward-gaze',
|
|
||||||
],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [
|
|
||||||
standardStep('joyful-down-right', 2300),
|
|
||||||
standardStep('joyful-wide', 2300),
|
|
||||||
standardStep('playful-right', 2300),
|
|
||||||
standardStep('gentle-downward-gaze', 2300),
|
|
||||||
],
|
|
||||||
blink: standardBlink(2100, 2800, 5000, 260),
|
|
||||||
},
|
|
||||||
sad: {
|
|
||||||
requiredExpressions: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [
|
|
||||||
standardStep('sleepy-squint', 3600),
|
|
||||||
standardStep('eyes-closed', 3600),
|
|
||||||
standardStep('drowsy-closed', 3600),
|
|
||||||
],
|
|
||||||
blink: standardBlink(4800, 6500, 9500, 420),
|
|
||||||
},
|
|
||||||
thinking: {
|
|
||||||
requiredExpressions: [
|
|
||||||
'curious-left',
|
|
||||||
'angry-left',
|
|
||||||
'skeptical-left',
|
|
||||||
'playful-right',
|
|
||||||
'skeptical-right',
|
|
||||||
],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [
|
|
||||||
standardStep('curious-left', 2300),
|
|
||||||
standardStep('angry-left', 2300),
|
|
||||||
standardStep('skeptical-left', 2300),
|
|
||||||
standardStep('playful-right', 2300),
|
|
||||||
standardStep('skeptical-right', 2300),
|
|
||||||
],
|
|
||||||
blink: standardBlink(2100, 2800, 5000, 260),
|
|
||||||
},
|
|
||||||
excited: {
|
|
||||||
requiredExpressions: [
|
|
||||||
'joyful-down-right',
|
|
||||||
'playful-right',
|
|
||||||
'surprised-wide-left',
|
|
||||||
'surprised-left',
|
|
||||||
'joyful-wide',
|
|
||||||
],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [
|
|
||||||
standardStep('joyful-down-right', 2300),
|
|
||||||
standardStep('playful-right', 2300),
|
|
||||||
standardStep('surprised-wide-left', 2300),
|
|
||||||
standardStep('surprised-left', 2300),
|
|
||||||
standardStep('joyful-wide', 2300),
|
|
||||||
],
|
|
||||||
blink: standardBlink(1200, 1800, 3600, 220),
|
|
||||||
},
|
|
||||||
celebrate: {
|
|
||||||
requiredExpressions: ['joyful-down-right', 'curious-left', 'playful-right'],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [
|
|
||||||
standardStep('joyful-down-right', 2300),
|
|
||||||
standardStep('curious-left', 2300),
|
|
||||||
standardStep('playful-right', 2300),
|
|
||||||
],
|
|
||||||
blink: standardBlink(1200, 1800, 3600, 220),
|
|
||||||
},
|
|
||||||
} satisfies Record<StandardAnimationKeyV1, StandardAnimationManifestEntry>
|
|
||||||
|
|
||||||
export type StandardAnimationAvailabilityV1 = {
|
|
||||||
available: StandardAnimationKeyV1[]
|
|
||||||
unavailable: { key: StandardAnimationKeyV1; missingExpressions: ExpressionKey[] }[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export const getStandardAnimationAvailabilityV1 = (
|
|
||||||
expressions: Readonly<Record<ExpressionKey, AvatarExpressionDefinition>>
|
|
||||||
): StandardAnimationAvailabilityV1 => {
|
|
||||||
const available: StandardAnimationKeyV1[] = []
|
|
||||||
const unavailable: StandardAnimationAvailabilityV1['unavailable'] = []
|
|
||||||
STANDARD_ANIMATION_ORDER_V1.forEach(key => {
|
|
||||||
const missingExpressions = STANDARD_ANIMATIONS_V1[key].requiredExpressions.filter(
|
|
||||||
expression => !(expression in expressions)
|
|
||||||
)
|
|
||||||
if (missingExpressions.length) unavailable.push({ key, missingExpressions })
|
|
||||||
else available.push(key)
|
|
||||||
})
|
|
||||||
return { available, unavailable }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarDefinitionError = {
|
export type AvatarDefinitionError = {
|
||||||
@ -342,6 +211,7 @@ const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] =
|
|||||||
path: '/expressionOrder' | '/animationOrder'
|
path: '/expressionOrder' | '/animationOrder'
|
||||||
) => {
|
) => {
|
||||||
const ordered = new Set(order)
|
const ordered = new Set(order)
|
||||||
|
const knownKeys = new Set(keys)
|
||||||
keys.forEach(key => {
|
keys.forEach(key => {
|
||||||
if (!ordered.has(key)) {
|
if (!ordered.has(key)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
@ -352,7 +222,7 @@ const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] =
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
order.forEach((key, index) => {
|
order.forEach((key, index) => {
|
||||||
if (!keys.includes(key)) {
|
if (!knownKeys.has(key)) {
|
||||||
errors.push({
|
errors.push({
|
||||||
path: childPointer(path, index),
|
path: childPointer(path, index),
|
||||||
code: 'unknown_order_key',
|
code: 'unknown_order_key',
|
||||||
|
|||||||
@ -478,12 +478,21 @@ export const poseFromExpression = (expression: Expression): AvatarPose => ({
|
|||||||
export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
|
export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
|
||||||
const expression: Expression = { ...from.expression }
|
const expression: Expression = { ...from.expression }
|
||||||
expressionFields.forEach(field => {
|
expressionFields.forEach(field => {
|
||||||
expression[field] =
|
let target = to.expression[field]
|
||||||
from.expression[field] + (to.expression[field] - from.expression[field]) * progress
|
if (
|
||||||
|
field === 'headX' ||
|
||||||
|
field === 'headY' ||
|
||||||
|
field === 'headZ' ||
|
||||||
|
field === 'leftAngle' ||
|
||||||
|
field === 'rightAngle'
|
||||||
|
) {
|
||||||
|
target = nearestEquivalentAngle(target, from.expression[field])
|
||||||
|
}
|
||||||
|
expression[field] = from.expression[field] + (target - from.expression[field]) * progress
|
||||||
})
|
})
|
||||||
return {
|
return {
|
||||||
expression,
|
expression,
|
||||||
orientation: slerpQuaternion(from.orientation, to.orientation, progress),
|
orientation: poseFromExpression(expression).orientation,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
getStandardAnimationAvailabilityV1,
|
|
||||||
STANDARD_ANIMATIONS_V1,
|
|
||||||
type AnimationKey,
|
type AnimationKey,
|
||||||
type AvatarAnimationDefinition,
|
type AvatarAnimationDefinition,
|
||||||
type AvatarDefinition,
|
type AvatarDefinition,
|
||||||
@ -8,11 +6,11 @@ import {
|
|||||||
type ExpressionKey,
|
type ExpressionKey,
|
||||||
} from './avatarDefinition'
|
} from './avatarDefinition'
|
||||||
import { applyAmbientMotion } from './ambientMotion'
|
import { applyAmbientMotion } from './ambientMotion'
|
||||||
import { interpolatePose, poseFromExpression } from './geometry'
|
import { interpolatePose, poseFromExpression, type Expression } from './geometry'
|
||||||
import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
|
import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
|
||||||
|
|
||||||
export type AvatarRuntimeError = {
|
export type AvatarRuntimeError = {
|
||||||
code: 'unknown_animation' | 'unavailable_standard_animation' | 'unknown_expression'
|
code: 'unknown_animation' | 'unknown_expression'
|
||||||
key: string
|
key: string
|
||||||
message: string
|
message: string
|
||||||
}
|
}
|
||||||
@ -38,28 +36,12 @@ export const resolveAnimation = (
|
|||||||
key: AnimationKey
|
key: AnimationKey
|
||||||
): AvatarCommandResult<Readonly<AvatarAnimationDefinition>> => {
|
): AvatarCommandResult<Readonly<AvatarAnimationDefinition>> => {
|
||||||
const explicit = definition.animations[key]
|
const explicit = definition.animations[key]
|
||||||
if (explicit) return { ok: true, value: explicit }
|
return explicit
|
||||||
if (!(key in STANDARD_ANIMATIONS_V1)) {
|
? { ok: true, value: explicit }
|
||||||
return {
|
: {
|
||||||
ok: false,
|
ok: false,
|
||||||
error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
|
error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
|
||||||
}
|
}
|
||||||
}
|
|
||||||
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
|
|
||||||
const unavailable = availability.unavailable.find(item => item.key === key)
|
|
||||||
if (unavailable) {
|
|
||||||
return {
|
|
||||||
ok: false,
|
|
||||||
error: {
|
|
||||||
code: 'unavailable_standard_animation',
|
|
||||||
key,
|
|
||||||
message: `Animation '${key}' requires: ${unavailable.missingExpressions.join(', ')}`,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const { requiredExpressions: _requiredExpressions, ...animation } =
|
|
||||||
STANDARD_ANIMATIONS_V1[key as keyof typeof STANDARD_ANIMATIONS_V1]
|
|
||||||
return { ok: true, value: animation }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarPlaybackState = {
|
export type AvatarPlaybackState = {
|
||||||
@ -71,17 +53,23 @@ export type AvatarPlaybackState = {
|
|||||||
phase: 'transition' | 'hold'
|
phase: 'transition' | 'hold'
|
||||||
phaseStartedAt: number
|
phaseStartedAt: number
|
||||||
transitionFrom: ExpressionKey
|
transitionFrom: ExpressionKey
|
||||||
|
transitionSnapshot?: AvatarFrameSnapshot
|
||||||
pausedAt?: number
|
pausedAt?: number
|
||||||
blinkDueAt?: number
|
blinkDueAt?: number
|
||||||
blinkStartedAt?: number
|
blinkStartedAt?: number
|
||||||
directTransition?: {
|
directTransition?: {
|
||||||
from: ExpressionKey
|
from: AvatarFrameSnapshot
|
||||||
startedAt: number
|
startedAt: number
|
||||||
durationMs: number
|
durationMs: number
|
||||||
transition: AvatarAnimationDefinition['steps'][number]['transition']
|
transition: AvatarAnimationDefinition['steps'][number]['transition']
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type AvatarFrameSnapshot = {
|
||||||
|
expression: Expression
|
||||||
|
colors: AvatarScene['colors']
|
||||||
|
}
|
||||||
|
|
||||||
export type AvatarRuntimeEnvironment = {
|
export type AvatarRuntimeEnvironment = {
|
||||||
random: () => number
|
random: () => number
|
||||||
reduceMotion?: boolean
|
reduceMotion?: boolean
|
||||||
@ -100,7 +88,8 @@ export const createAvatarPlaybackState = (): AvatarPlaybackState => ({
|
|||||||
export const playAvatarAnimation = (
|
export const playAvatarAnimation = (
|
||||||
definition: Readonly<AvatarDefinition>,
|
definition: Readonly<AvatarDefinition>,
|
||||||
key: AnimationKey,
|
key: AnimationKey,
|
||||||
now: number
|
now: number,
|
||||||
|
from?: AvatarFrameSnapshot
|
||||||
): AvatarCommandResult<AvatarPlaybackState> => {
|
): AvatarCommandResult<AvatarPlaybackState> => {
|
||||||
const result = resolveAnimation(definition, key)
|
const result = resolveAnimation(definition, key)
|
||||||
if (!result.ok) return result
|
if (!result.ok) return result
|
||||||
@ -115,6 +104,7 @@ export const playAvatarAnimation = (
|
|||||||
phase: 'transition',
|
phase: 'transition',
|
||||||
phaseStartedAt: now,
|
phaseStartedAt: now,
|
||||||
transitionFrom: 'neutral',
|
transitionFrom: 'neutral',
|
||||||
|
...(from ? { transitionSnapshot: from } : {}),
|
||||||
blinkDueAt: now + result.value.blink.initialDelayMs,
|
blinkDueAt: now + result.value.blink.initialDelayMs,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -189,6 +179,7 @@ export const advanceAvatarPlayback = (
|
|||||||
next.direction = cursor.direction
|
next.direction = cursor.direction
|
||||||
next.phase = 'transition'
|
next.phase = 'transition'
|
||||||
next.transitionFrom = next.activeExpression
|
next.transitionFrom = next.activeExpression
|
||||||
|
delete next.transitionSnapshot
|
||||||
next.activeExpression = animation.steps[cursor.stepIndex].expression
|
next.activeExpression = animation.steps[cursor.stepIndex].expression
|
||||||
}
|
}
|
||||||
return next
|
return next
|
||||||
@ -210,6 +201,14 @@ export const resumeAvatarPlayback = (
|
|||||||
...state,
|
...state,
|
||||||
status: 'playing',
|
status: 'playing',
|
||||||
phaseStartedAt: state.phaseStartedAt + pauseDuration,
|
phaseStartedAt: state.phaseStartedAt + pauseDuration,
|
||||||
|
...(state.directTransition
|
||||||
|
? {
|
||||||
|
directTransition: {
|
||||||
|
...state.directTransition,
|
||||||
|
startedAt: state.directTransition.startedAt + pauseDuration,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
...(state.blinkDueAt === undefined ? {} : { blinkDueAt: state.blinkDueAt + pauseDuration }),
|
...(state.blinkDueAt === undefined ? {} : { blinkDueAt: state.blinkDueAt + pauseDuration }),
|
||||||
...(state.blinkStartedAt === undefined
|
...(state.blinkStartedAt === undefined
|
||||||
? {}
|
? {}
|
||||||
@ -229,6 +228,38 @@ const easing = (
|
|||||||
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
|
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const expressionColors = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
expression: Readonly<AvatarExpressionDefinition>
|
||||||
|
): AvatarScene['colors'] => ({
|
||||||
|
body: expression.colors?.body ?? definition.colors.body,
|
||||||
|
eyes: expression.colors?.eyes ?? definition.colors.eyes,
|
||||||
|
})
|
||||||
|
|
||||||
|
const interpolateHexColor = (from: string, to: string, progress: number) => {
|
||||||
|
const parse = (color: string) => {
|
||||||
|
const value = color.slice(1)
|
||||||
|
const expanded = value.length === 3 ? [...value].map(part => `${part}${part}`).join('') : value
|
||||||
|
return [0, 2, 4].map(index => Number.parseInt(expanded.slice(index, index + 2), 16))
|
||||||
|
}
|
||||||
|
const source = parse(from)
|
||||||
|
const target = parse(to)
|
||||||
|
if (source.some(Number.isNaN) || target.some(Number.isNaN)) return progress < 1 ? from : to
|
||||||
|
return `#${source
|
||||||
|
.map((value, index) => Math.round(value + (target[index] - value) * progress))
|
||||||
|
.map(value => value.toString(16).padStart(2, '0'))
|
||||||
|
.join('')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const interpolateColors = (
|
||||||
|
from: AvatarScene['colors'],
|
||||||
|
to: AvatarScene['colors'],
|
||||||
|
progress: number
|
||||||
|
): AvatarScene['colors'] => ({
|
||||||
|
body: interpolateHexColor(from.body, to.body, progress),
|
||||||
|
eyes: interpolateHexColor(from.eyes, to.eyes, progress),
|
||||||
|
})
|
||||||
|
|
||||||
export const blinkOpacityAt = (
|
export const blinkOpacityAt = (
|
||||||
animation: Readonly<AvatarAnimationDefinition>,
|
animation: Readonly<AvatarAnimationDefinition>,
|
||||||
state: Readonly<AvatarPlaybackState>,
|
state: Readonly<AvatarPlaybackState>,
|
||||||
@ -240,54 +271,78 @@ export const blinkOpacityAt = (
|
|||||||
return Math.abs(progress * 2 - 1)
|
return Math.abs(progress * 2 - 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
export const renderAvatarFrame = (
|
export const sampleAvatarFrame = (
|
||||||
definition: Readonly<AvatarDefinition>,
|
definition: Readonly<AvatarDefinition>,
|
||||||
state: Readonly<AvatarPlaybackState>,
|
state: Readonly<AvatarPlaybackState>,
|
||||||
now: number,
|
now: number,
|
||||||
environment: AvatarRuntimeEnvironment
|
environment: AvatarRuntimeEnvironment
|
||||||
): AvatarScene => {
|
): AvatarFrameSnapshot & { blink: number; sampledAt: number } => {
|
||||||
|
const sampledAt = state.status === 'paused' && state.pausedAt !== undefined ? state.pausedAt : now
|
||||||
const targetDefinition = definition.expressions[state.activeExpression]
|
const targetDefinition = definition.expressions[state.activeExpression]
|
||||||
if (!targetDefinition)
|
if (!targetDefinition) {
|
||||||
return renderAvatarExpression(
|
const neutral = definition.expressions.neutral
|
||||||
definition,
|
return {
|
||||||
expressionFromDefinition('neutral', definition.expressions.neutral)
|
expression: expressionFromDefinition('neutral', neutral),
|
||||||
)
|
colors: expressionColors(definition, neutral),
|
||||||
|
blink: 1,
|
||||||
|
sampledAt,
|
||||||
|
}
|
||||||
|
}
|
||||||
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
|
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
|
||||||
|
let colors = expressionColors(definition, targetDefinition)
|
||||||
let blink = 1
|
let blink = 1
|
||||||
if (state.directTransition && !environment.reduceMotion) {
|
if (state.directTransition && !environment.reduceMotion) {
|
||||||
const fromDefinition = definition.expressions[state.directTransition.from]
|
|
||||||
if (fromDefinition) {
|
|
||||||
const from = expressionFromDefinition(state.directTransition.from, fromDefinition)
|
|
||||||
const progress = easing(
|
const progress = easing(
|
||||||
state.directTransition.transition,
|
state.directTransition.transition,
|
||||||
(now - state.directTransition.startedAt) / Math.max(state.directTransition.durationMs, 1)
|
(sampledAt - state.directTransition.startedAt) /
|
||||||
|
Math.max(state.directTransition.durationMs, 1)
|
||||||
)
|
)
|
||||||
expression = interpolatePose(
|
expression = interpolatePose(
|
||||||
poseFromExpression(from),
|
poseFromExpression(state.directTransition.from.expression),
|
||||||
poseFromExpression(expression),
|
poseFromExpression(expression),
|
||||||
progress
|
progress
|
||||||
).expression
|
).expression
|
||||||
}
|
colors = interpolateColors(state.directTransition.from.colors, colors, progress)
|
||||||
} else if (state.activeAnimation) {
|
} else if (state.activeAnimation) {
|
||||||
const resolved = resolveAnimation(definition, state.activeAnimation)
|
const resolved = resolveAnimation(definition, state.activeAnimation)
|
||||||
if (resolved.ok) {
|
if (resolved.ok) {
|
||||||
const step = resolved.value.steps[state.stepIndex]
|
const step = resolved.value.steps[state.stepIndex]
|
||||||
if (state.phase === 'transition' && step && !environment.reduceMotion) {
|
if (state.phase === 'transition' && step && !environment.reduceMotion) {
|
||||||
const fromDefinition = definition.expressions[state.transitionFrom]
|
const fromDefinition = definition.expressions[state.transitionFrom]
|
||||||
if (fromDefinition) {
|
const from =
|
||||||
const from = expressionFromDefinition(state.transitionFrom, fromDefinition)
|
state.transitionSnapshot?.expression ??
|
||||||
|
(fromDefinition
|
||||||
|
? expressionFromDefinition(state.transitionFrom, fromDefinition)
|
||||||
|
: undefined)
|
||||||
|
const fromColors =
|
||||||
|
state.transitionSnapshot?.colors ??
|
||||||
|
(fromDefinition ? expressionColors(definition, fromDefinition) : undefined)
|
||||||
|
if (from && fromColors) {
|
||||||
const duration = Math.max(step.transitionMs, 1)
|
const duration = Math.max(step.transitionMs, 1)
|
||||||
const progress = easing(step.transition, (now - state.phaseStartedAt) / duration)
|
const progress = easing(step.transition, (sampledAt - state.phaseStartedAt) / duration)
|
||||||
expression = interpolatePose(
|
expression = interpolatePose(
|
||||||
poseFromExpression(from),
|
poseFromExpression(from),
|
||||||
poseFromExpression(expression),
|
poseFromExpression(expression),
|
||||||
progress
|
progress
|
||||||
).expression
|
).expression
|
||||||
|
colors = interpolateColors(fromColors, colors, progress)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
blink = blinkOpacityAt(resolved.value, state, now)
|
blink = blinkOpacityAt(resolved.value, state, sampledAt)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!environment.reduceMotion) expression = applyAmbientMotion(expression, now)
|
return { expression, colors, blink, sampledAt }
|
||||||
return renderAvatarExpression(definition, expression, targetDefinition.colors, blink)
|
}
|
||||||
|
|
||||||
|
export const renderAvatarFrame = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
state: Readonly<AvatarPlaybackState>,
|
||||||
|
now: number,
|
||||||
|
environment: AvatarRuntimeEnvironment
|
||||||
|
): AvatarScene => {
|
||||||
|
const frame = sampleAvatarFrame(definition, state, now, environment)
|
||||||
|
const expression = environment.reduceMotion
|
||||||
|
? frame.expression
|
||||||
|
: applyAmbientMotion(frame.expression, frame.sampledAt)
|
||||||
|
return renderAvatarExpression(definition, expression, frame.colors, frame.blink)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -9,6 +9,9 @@ dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
|
|||||||
pnpm add @bible-strong/avatar-react react react-dom
|
pnpm add @bible-strong/avatar-react react react-dom
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The packages are currently private. This registry command will work after publication; use the
|
||||||
|
workspace packages or locally packed tarballs for development verification in the meantime.
|
||||||
|
|
||||||
Import the package stylesheet once in the application entry point:
|
Import the package stylesheet once in the application entry point:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
@ -29,69 +32,96 @@ export function Assistant({ definition }: { definition: AvatarDefinition }) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
For a reusable component tied to one JSON definition, use `createAvatar`. It validates the
|
||||||
|
definition once and, when the JSON is statically typed, narrows `animation`, `defaultAnimation`,
|
||||||
|
`expression` and `defaultExpression` to the semantic keys present in that definition:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { createAvatar } from '@bible-strong/avatar-react'
|
||||||
|
import avatarJson from './strobi.avatar.json'
|
||||||
|
|
||||||
|
const StrobiAvatar = createAvatar(avatarJson)
|
||||||
|
|
||||||
|
export function Strobi() {
|
||||||
|
return <StrobiAvatar defaultAnimation="idle" />
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Definitions fetched at runtime are validated by the same factory, but their keys are necessarily
|
||||||
|
checked at runtime rather than inferred by TypeScript.
|
||||||
|
|
||||||
`play` and `setExpression` return `{ ok: true }` or a typed error with one of
|
`play` and `setExpression` return `{ ok: true }` or a typed error with one of
|
||||||
`unknown_animation`, `unavailable_standard_animation`, `unknown_expression` or
|
`unknown_animation`, `unknown_expression` or `controlled_by_props`. `pause` freezes the exact
|
||||||
`controlled_by_props`. `pause` freezes the exact timeline position, calling `play` with the paused
|
timeline position, calling `play` with the paused key resumes it, and `stop` returns an
|
||||||
key resumes it, and `stop` returns an uncontrolled avatar to `neutral`.
|
uncontrolled avatar to `neutral`.
|
||||||
|
|
||||||
## Playback props
|
## Props reference
|
||||||
|
|
||||||
- `animation` and `expression` are mutually exclusive controlled targets. They take priority over
|
`Avatar` exposes typed props for the definition, playback state and presentation. `AnimationKey`
|
||||||
defaults and imperative target changes.
|
and `ExpressionKey` are semantic string keys from the supplied definition.
|
||||||
- `defaultAnimation` and `defaultExpression` initialize uncontrolled use. Animation autoplay is on
|
|
||||||
by default when `defaultAnimation` is present; set `autoplay={false}` to show its first expression
|
### Definition and playback
|
||||||
statically.
|
|
||||||
- `onAnimationEnd` fires once when a `once` animation completes naturally.
|
| Prop | Type | Default | Behavior |
|
||||||
- `onExpressionChange` reports semantic expression changes.
|
| ------------------- | ------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------- |
|
||||||
- `size`, `className`, `style` and `ariaLabel` customize layout without changing the definition.
|
| `definition` | `AvatarDefinition` | required | Validated JSON definition containing the expressions and animations to render. |
|
||||||
|
| `animation` | `AnimationKey \| undefined` | — | Controlled timeline. Each step chooses the displayed expression. Mutually exclusive with `expression`. |
|
||||||
|
| `expression` | `ExpressionKey \| undefined` | — | Controlled direct expression. Mutually exclusive with `animation`. |
|
||||||
|
| `defaultAnimation` | `AnimationKey \| undefined` | — | Initial uncontrolled timeline, read on mount. Autoplay is enabled by default. Mutually exclusive with `defaultExpression`. |
|
||||||
|
| `defaultExpression` | `ExpressionKey \| undefined` | — | Initial uncontrolled expression, read on mount without starting a timeline. Mutually exclusive with `defaultAnimation`. |
|
||||||
|
| `autoplay` | `boolean \| undefined` | `true` | Starts `defaultAnimation` automatically. It has no effect without `defaultAnimation`. |
|
||||||
|
| `ref` | `Ref<AvatarController> \| undefined` | — | Exposes the imperative API described below. |
|
||||||
|
|
||||||
|
`animation` and `expression` are two alternative sources of truth. Passing both throws an error;
|
||||||
|
the component never silently overrides one with the other. A controlled target takes priority over
|
||||||
|
an uncontrolled default when they are intentionally mixed.
|
||||||
|
|
||||||
|
### Presentation
|
||||||
|
|
||||||
|
| Prop | Type | Default | Behavior |
|
||||||
|
| ----------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------- |
|
||||||
|
| `size` | `number \| string \| undefined` | `240` | Number or CSS value applied to the wrapper width and height. |
|
||||||
|
| `className` | `string \| undefined` | — | CSS class added to the outer wrapper. |
|
||||||
|
| `style` | `CSSProperties \| undefined` | — | Inline styles for the outer wrapper. `width` and `height` come from `size`. |
|
||||||
|
| `ariaLabel` | `string \| undefined` | `Procedural avatar` | Accessible name announced to screen readers. |
|
||||||
|
|
||||||
|
### Playback callbacks
|
||||||
|
|
||||||
|
| Prop | Type | Receives |
|
||||||
|
| -------------------- | ------------------------------------- | ---------------------------------------------------------------------- |
|
||||||
|
| `onAnimationEnd` | `(animation: AnimationKey) => void` | The key of a `once` animation when it completes naturally. |
|
||||||
|
| `onExpressionChange` | `(expression: ExpressionKey) => void` | The semantic expression key whenever the displayed expression changes. |
|
||||||
|
| `onError` | `(error: AvatarRuntimeError) => void` | An unknown animation or expression key supplied through props. |
|
||||||
|
|
||||||
|
Unknown keys passed through `animation`, `expression`, `defaultAnimation` or `defaultExpression`
|
||||||
|
are reported to `onError`. Without an error handler, the component writes the typed runtime error
|
||||||
|
to the developer console instead of failing silently.
|
||||||
|
|
||||||
|
## Imperative API
|
||||||
|
|
||||||
|
Pass a `ref` to receive an `AvatarController`. Use it when buttons, events or another imperative
|
||||||
|
source need to drive an uncontrolled avatar:
|
||||||
|
|
||||||
|
| Method | Type | Behavior |
|
||||||
|
| --------------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
|
||||||
|
| `play(animation)` | `(animation: AnimationKey) => AvatarCommandResult` | Starts an animation or resumes it from its paused position. |
|
||||||
|
| `pause()` | `() => void` | Freezes the exact timeline position. |
|
||||||
|
| `stop()` | `() => void` | Stops playback and resets an uncontrolled avatar to `neutral`. |
|
||||||
|
| `setExpression(expression)` | `(expression: ExpressionKey) => AvatarCommandResult` | Shows one expression directly. |
|
||||||
|
| `getState()` | `() => AvatarPlaybackState` | Returns `activeAnimation?`, `activeExpression` and `status`. |
|
||||||
|
|
||||||
|
`play` and `setExpression` return `{ ok: true }` or `{ ok: false, error }`. Errors include
|
||||||
|
`unknown_animation`, `unknown_expression` and `controlled_by_props`. When `animation` or
|
||||||
|
`expression` is controlled by props, use those props to change the target; imperative target
|
||||||
|
commands cannot replace the parent value.
|
||||||
|
|
||||||
The definition is validated once per immutable object reference and revalidated/reinitialized when
|
The definition is validated once per immutable object reference and revalidated/reinitialized when
|
||||||
that reference changes.
|
that reference changes.
|
||||||
|
|
||||||
## Embedded and floating layout
|
|
||||||
|
|
||||||
Embedded mode is the default and stays in the caller's layout:
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<div className="assistant-zone">
|
|
||||||
<Avatar definition={definition} animation="idle" />
|
|
||||||
</div>
|
|
||||||
```
|
|
||||||
|
|
||||||
Floating mode uses fixed positioning and portals to `document.body` after hydration. Supply
|
|
||||||
`portalContainer` for a dedicated overlay root. Server rendering emits a neutral fixed-size
|
|
||||||
placeholder before the portal handoff.
|
|
||||||
|
|
||||||
```tsx
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
mode="floating"
|
|
||||||
draggable
|
|
||||||
initialPosition={{ right: 24, bottom: 24 }}
|
|
||||||
constrainTo="viewport"
|
|
||||||
zIndex={1000}
|
|
||||||
/>
|
|
||||||
```
|
|
||||||
|
|
||||||
`initialPosition` accepts `{ x, y }` or top/right/bottom/left anchors. A controlled `position` wins
|
|
||||||
over it. `constrainTo` accepts `none`, `viewport` or `parent`; floating defaults to `viewport`, while
|
|
||||||
embedded defaults to `none`. A constrained embedded parent needs a definite rendered size.
|
|
||||||
|
|
||||||
Dragging uses Pointer Events and direct transforms. `onPositionPreview` is limited to one callback
|
|
||||||
per animation frame and `onPositionCommit` reports the final clamped point. `onDragStart` and
|
|
||||||
`onDragEnd` bracket pointer movement. In controlled position mode callbacks report suggestions but
|
|
||||||
the supplied position remains authoritative. When resized bounds invalidate a controlled position,
|
|
||||||
`onPositionChange` reports the clamped suggestion. Resize re-clamping never emits a movement commit.
|
|
||||||
|
|
||||||
When `draggable` is enabled, arrow keys move by 10 px, Shift+Arrow by 1 px, and Escape cancels an
|
|
||||||
active pointer drag. Accessible directional and reset buttons provide the same movement without a
|
|
||||||
pointer.
|
|
||||||
|
|
||||||
## Styling hooks
|
## Styling hooks
|
||||||
|
|
||||||
The stylesheet exposes `.bs-avatar`, `.bs-avatar--embedded`, `.bs-avatar--floating`,
|
The stylesheet exposes `.bs-avatar` and `.bs-avatar__svg`. Consumer `className` and `style` are
|
||||||
`.bs-avatar--draggable`, `.bs-avatar--dragging`, `.bs-avatar__svg` and
|
applied to the outer wrapper.
|
||||||
`.bs-avatar__move-controls`. Consumer `className` and `style` are applied to the outer wrapper.
|
|
||||||
|
|
||||||
The public component never exposes Studio IDs or document types. This package is private while
|
The public component never exposes Studio IDs or document types. This package is private while
|
||||||
copyright ownership, Apache-2.0 relicensing and repository metadata are confirmed. Local tarballs
|
copyright ownership, Apache-2.0 relicensing and repository metadata are confirmed. Local tarballs
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
advanceAvatarPlayback,
|
advanceAvatarPlayback,
|
||||||
createAvatarPlaybackState,
|
createAvatarPlaybackState,
|
||||||
|
MAX_BODY_NODES,
|
||||||
playAvatarAnimation,
|
playAvatarAnimation,
|
||||||
pauseAvatarPlayback,
|
pauseAvatarPlayback,
|
||||||
renderAvatarDefinition,
|
renderAvatarDefinition,
|
||||||
@ -8,6 +9,7 @@ import {
|
|||||||
resolveAnimation,
|
resolveAnimation,
|
||||||
resolveExpression,
|
resolveExpression,
|
||||||
resumeAvatarPlayback,
|
resumeAvatarPlayback,
|
||||||
|
sampleAvatarFrame,
|
||||||
validateAvatarDefinition,
|
validateAvatarDefinition,
|
||||||
type AnimationKey,
|
type AnimationKey,
|
||||||
type AvatarDefinition,
|
type AvatarDefinition,
|
||||||
@ -19,19 +21,28 @@ import {
|
|||||||
useEffect,
|
useEffect,
|
||||||
useId,
|
useId,
|
||||||
useImperativeHandle,
|
useImperativeHandle,
|
||||||
|
useLayoutEffect,
|
||||||
useRef,
|
useRef,
|
||||||
useState,
|
useState,
|
||||||
type CSSProperties,
|
type CSSProperties,
|
||||||
type PointerEvent as ReactPointerEvent,
|
|
||||||
type ReactElement,
|
type ReactElement,
|
||||||
type Ref,
|
type Ref,
|
||||||
} from 'react'
|
} from 'react'
|
||||||
import { createPortal } from 'react-dom'
|
|
||||||
|
|
||||||
import './styles.css'
|
import './styles.css'
|
||||||
|
|
||||||
const validatedDefinitions = new WeakSet<object>()
|
const validatedDefinitions = new WeakSet<object>()
|
||||||
const controlledExpressionTransitionMs = 420
|
const controlledExpressionTransitionMs = 420
|
||||||
|
const bodyPathSlots = MAX_BODY_NODES + 2
|
||||||
|
|
||||||
|
const runtimeEnvironment = () => ({
|
||||||
|
random: Math.random,
|
||||||
|
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
|
||||||
|
})
|
||||||
|
|
||||||
|
export const markAvatarDefinitionValidated = (definition: object) => {
|
||||||
|
validatedDefinitions.add(definition)
|
||||||
|
}
|
||||||
|
|
||||||
const assertValidDefinition = (definition: AvatarDefinition) => {
|
const assertValidDefinition = (definition: AvatarDefinition) => {
|
||||||
if (validatedDefinitions.has(definition)) return
|
if (validatedDefinitions.has(definition)) return
|
||||||
@ -65,35 +76,24 @@ export type AvatarController = {
|
|||||||
getState(): AvatarPlaybackState
|
getState(): AvatarPlaybackState
|
||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarPosition = { x: number; y: number }
|
|
||||||
export type FloatingInitialPosition =
|
|
||||||
AvatarPosition | { top?: number; right?: number; bottom?: number; left?: number }
|
|
||||||
|
|
||||||
export type AvatarProps = {
|
export type AvatarProps = {
|
||||||
definition: AvatarDefinition
|
definition: AvatarDefinition
|
||||||
ref?: Ref<AvatarController>
|
ref?: Ref<AvatarController>
|
||||||
|
/** Controlled animation timeline. Mutually exclusive with `expression`. */
|
||||||
animation?: AnimationKey
|
animation?: AnimationKey
|
||||||
|
/** Controlled expression target. Mutually exclusive with `animation`. */
|
||||||
expression?: ExpressionKey
|
expression?: ExpressionKey
|
||||||
|
/** Uncontrolled initial animation. Mutually exclusive with `defaultExpression`. */
|
||||||
defaultAnimation?: AnimationKey
|
defaultAnimation?: AnimationKey
|
||||||
|
/** Uncontrolled initial expression. Mutually exclusive with `defaultAnimation`. */
|
||||||
defaultExpression?: ExpressionKey
|
defaultExpression?: ExpressionKey
|
||||||
autoplay?: boolean
|
autoplay?: boolean
|
||||||
size?: number | string
|
size?: number | string
|
||||||
className?: string
|
className?: string
|
||||||
style?: CSSProperties
|
style?: CSSProperties
|
||||||
mode?: 'embedded' | 'floating'
|
|
||||||
portalContainer?: HTMLElement
|
|
||||||
draggable?: boolean
|
|
||||||
constrainTo?: 'none' | 'viewport' | 'parent'
|
|
||||||
position?: AvatarPosition
|
|
||||||
initialPosition?: FloatingInitialPosition
|
|
||||||
zIndex?: number
|
|
||||||
ariaLabel?: string
|
ariaLabel?: string
|
||||||
onPositionPreview?: (position: AvatarPosition) => void
|
/** Receives invalid animation or expression targets supplied through props. */
|
||||||
onPositionCommit?: (position: AvatarPosition) => void
|
onError?: (error: AvatarRuntimeError) => void
|
||||||
/** Suggested clamped position when controlled bounds change. */
|
|
||||||
onPositionChange?: (position: AvatarPosition) => void
|
|
||||||
onDragStart?: () => void
|
|
||||||
onDragEnd?: (position: AvatarPosition) => void
|
|
||||||
onAnimationEnd?: (animation: AnimationKey) => void
|
onAnimationEnd?: (animation: AnimationKey) => void
|
||||||
onExpressionChange?: (expression: ExpressionKey) => void
|
onExpressionChange?: (expression: ExpressionKey) => void
|
||||||
}
|
}
|
||||||
@ -109,26 +109,54 @@ const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
|
|||||||
left.transitionFrom === right.transitionFrom &&
|
left.transitionFrom === right.transitionFrom &&
|
||||||
left.blinkDueAt === right.blinkDueAt &&
|
left.blinkDueAt === right.blinkDueAt &&
|
||||||
left.blinkStartedAt === right.blinkStartedAt &&
|
left.blinkStartedAt === right.blinkStartedAt &&
|
||||||
|
left.transitionSnapshot === right.transitionSnapshot &&
|
||||||
left.directTransition?.from === right.directTransition?.from &&
|
left.directTransition?.from === right.directTransition?.from &&
|
||||||
left.directTransition?.startedAt === right.directTransition?.startedAt &&
|
left.directTransition?.startedAt === right.directTransition?.startedAt &&
|
||||||
left.directTransition?.durationMs === right.directTransition?.durationMs &&
|
left.directTransition?.durationMs === right.directTransition?.durationMs &&
|
||||||
left.directTransition?.transition === right.directTransition?.transition
|
left.directTransition?.transition === right.directTransition?.transition
|
||||||
|
|
||||||
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
|
const assertPlaybackProps = ({
|
||||||
left.x === right.x && left.y === right.y
|
animation,
|
||||||
|
expression,
|
||||||
|
defaultAnimation,
|
||||||
|
defaultExpression,
|
||||||
|
}: Pick<AvatarProps, 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'>) => {
|
||||||
|
if (animation !== undefined && expression !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
'Avatar accepts either animation or expression, not both. Animation controls a timeline; expression controls a single target.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
|
||||||
|
throw new Error(
|
||||||
|
'Avatar accepts either defaultAnimation or defaultExpression, not both. Choose one uncontrolled initial target.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sizeInPixels = (size: number | string | undefined) => (typeof size === 'number' ? size : 240)
|
const createInitialPlayback = (
|
||||||
|
definition: AvatarDefinition,
|
||||||
const initialPoint = (
|
animation: AnimationKey | undefined,
|
||||||
value: FloatingInitialPosition | undefined,
|
expression: ExpressionKey | undefined,
|
||||||
width: number,
|
defaultAnimation: AnimationKey | undefined,
|
||||||
height: number
|
defaultExpression: ExpressionKey | undefined
|
||||||
): AvatarPosition => {
|
): CorePlaybackState => {
|
||||||
if (!value) return { x: 32, y: 32 }
|
const animationKey = animation ?? (expression === undefined ? defaultAnimation : undefined)
|
||||||
if ('x' in value && 'y' in value) return { x: value.x, y: value.y }
|
if (animationKey) {
|
||||||
const x = value.left ?? Math.max(0, width - (value.right ?? 32))
|
const result = resolveAnimation(definition, animationKey)
|
||||||
const y = value.top ?? Math.max(0, height - (value.bottom ?? 32))
|
if (result.ok) {
|
||||||
return { x, y }
|
return {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const expressionKey =
|
||||||
|
expression ?? (animation === undefined ? defaultExpression : undefined) ?? 'neutral'
|
||||||
|
const resolved = resolveExpression(definition, expressionKey)
|
||||||
|
return {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: resolved.ok ? expressionKey : 'neutral',
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Avatar({
|
export function Avatar({
|
||||||
@ -142,29 +170,15 @@ export function Avatar({
|
|||||||
size = 240,
|
size = 240,
|
||||||
className,
|
className,
|
||||||
style,
|
style,
|
||||||
mode = 'embedded',
|
|
||||||
portalContainer,
|
|
||||||
draggable = false,
|
|
||||||
constrainTo,
|
|
||||||
position,
|
|
||||||
initialPosition,
|
|
||||||
zIndex = 1000,
|
|
||||||
ariaLabel = 'Procedural avatar',
|
ariaLabel = 'Procedural avatar',
|
||||||
onPositionPreview,
|
onError,
|
||||||
onPositionCommit,
|
|
||||||
onPositionChange,
|
|
||||||
onDragStart,
|
|
||||||
onDragEnd,
|
|
||||||
onAnimationEnd,
|
onAnimationEnd,
|
||||||
onExpressionChange,
|
onExpressionChange,
|
||||||
}: AvatarProps): ReactElement {
|
}: AvatarProps): ReactElement {
|
||||||
if (animation !== undefined && expression !== undefined) {
|
assertPlaybackProps({ animation, expression, defaultAnimation, defaultExpression })
|
||||||
throw new Error('Avatar accepts either animation or expression, not both.')
|
|
||||||
}
|
|
||||||
assertValidDefinition(definition)
|
assertValidDefinition(definition)
|
||||||
|
|
||||||
const clipId = `${useId().replaceAll(':', '')}-head`
|
const clipId = `${useId().replaceAll(':', '')}-head`
|
||||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
|
||||||
const clipPathRef = useRef<SVGPathElement>(null)
|
const clipPathRef = useRef<SVGPathElement>(null)
|
||||||
const headPathRef = useRef<SVGPathElement>(null)
|
const headPathRef = useRef<SVGPathElement>(null)
|
||||||
const leftPathRef = useRef<SVGPathElement>(null)
|
const leftPathRef = useRef<SVGPathElement>(null)
|
||||||
@ -172,101 +186,83 @@ export function Avatar({
|
|||||||
const backPathRefs = useRef<(SVGPathElement | null)[]>([])
|
const backPathRefs = useRef<(SVGPathElement | null)[]>([])
|
||||||
const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
|
const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
|
||||||
const defaultPlaybackStarted = useRef(false)
|
const defaultPlaybackStarted = useRef(false)
|
||||||
const floatingPositionInitialized = useRef(false)
|
|
||||||
const previewFrame = useRef<number | undefined>(undefined)
|
|
||||||
const completedAnimation = useRef<AnimationKey | undefined>(undefined)
|
const completedAnimation = useRef<AnimationKey | undefined>(undefined)
|
||||||
const playbackRef = useRef<CorePlaybackState>(createAvatarPlaybackState())
|
const playbackRef = useRef<CorePlaybackState | null>(null)
|
||||||
const drag = useRef<
|
const paintedFrameRef = useRef<ReturnType<typeof sampleAvatarFrame> | null>(null)
|
||||||
| {
|
const previousDefinitionRef = useRef(definition)
|
||||||
pointerId: number
|
const [playback, setPlayback] = useState<CorePlaybackState>(() =>
|
||||||
pointer: AvatarPosition
|
createInitialPlayback(definition, animation, expression, defaultAnimation, defaultExpression)
|
||||||
origin: AvatarPosition
|
)
|
||||||
current: AvatarPosition
|
|
||||||
}
|
const paintScene = (frameScene: ReturnType<typeof renderAvatarFrame>) => {
|
||||||
| undefined
|
headPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
|
||||||
>(undefined)
|
clipPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
|
||||||
const [mounted, setMounted] = useState(false)
|
headPathRef.current?.setAttribute('fill', frameScene.colors.body)
|
||||||
const [internalPosition, setInternalPosition] = useState<AvatarPosition>({ x: 0, y: 0 })
|
leftPathRef.current?.setAttribute('d', frameScene.geometry.leftPath)
|
||||||
const [playback, setPlayback] = useState<CorePlaybackState>(() => {
|
leftPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
|
||||||
const key = animation ?? defaultAnimation
|
leftPathRef.current?.setAttribute('opacity', frameScene.geometry.leftVisible ? '1' : '0')
|
||||||
if (key) {
|
rightPathRef.current?.setAttribute('d', frameScene.geometry.rightPath)
|
||||||
const result = resolveAnimation(definition, key)
|
rightPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
|
||||||
if (result.ok) {
|
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
|
||||||
return {
|
backPathRefs.current.forEach((element, index) => {
|
||||||
...createAvatarPlaybackState(),
|
element?.setAttribute('d', frameScene.geometry.backPaths[index] ?? '')
|
||||||
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
|
element?.setAttribute('fill', frameScene.colors.body)
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const keyExpression = expression ?? defaultExpression ?? 'neutral'
|
|
||||||
return { ...createAvatarPlaybackState(), activeExpression: keyExpression }
|
|
||||||
})
|
})
|
||||||
|
frontPathRefs.current.forEach((element, index) => {
|
||||||
|
element?.setAttribute('d', frameScene.geometry.frontPaths[index] ?? '')
|
||||||
|
element?.setAttribute('fill', frameScene.colors.body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderPlaybackFrame = (
|
||||||
|
current: Readonly<CorePlaybackState>,
|
||||||
|
now: number,
|
||||||
|
environment: ReturnType<typeof runtimeEnvironment>
|
||||||
|
) => {
|
||||||
|
paintedFrameRef.current = sampleAvatarFrame(definition, current, now, environment)
|
||||||
|
return renderAvatarFrame(definition, current, now, environment)
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const current = playbackRef.current ?? playback
|
||||||
|
const now = performance.now()
|
||||||
|
const environment = runtimeEnvironment()
|
||||||
|
paintScene(renderPlaybackFrame(current, now, environment))
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
playbackRef.current = playback
|
playbackRef.current = playback
|
||||||
const authoritativePosition = position ?? internalPosition
|
}, [playback])
|
||||||
const effectiveConstraint = constrainTo ?? (mode === 'floating' ? 'viewport' : 'none')
|
|
||||||
|
|
||||||
const applyTransform = (point: AvatarPosition) => {
|
|
||||||
if (wrapperRef.current) {
|
|
||||||
wrapperRef.current.style.transform = `translate3d(${point.x}px, ${point.y}px, 0)`
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const clampPosition = (point: AvatarPosition): AvatarPosition => {
|
|
||||||
const element = wrapperRef.current
|
|
||||||
if (!element || effectiveConstraint === 'none') return point
|
|
||||||
const width = element.offsetWidth
|
|
||||||
const height = element.offsetHeight
|
|
||||||
if (effectiveConstraint === 'viewport') {
|
|
||||||
return {
|
|
||||||
x: Math.min(Math.max(point.x, 0), Math.max(window.innerWidth - width, 0)),
|
|
||||||
y: Math.min(Math.max(point.y, 0), Math.max(window.innerHeight - height, 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const parent = element.parentElement
|
|
||||||
if (!parent) return point
|
|
||||||
return {
|
|
||||||
x: Math.min(Math.max(point.x, 0), Math.max(parent.clientWidth - width, 0)),
|
|
||||||
y: Math.min(Math.max(point.y, 0), Math.max(parent.clientHeight - height, 0)),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const commitPosition = (next: AvatarPosition) => {
|
|
||||||
const clamped = clampPosition(next)
|
|
||||||
applyTransform(position ?? clamped)
|
|
||||||
if (!position) setInternalPosition(clamped)
|
|
||||||
onPositionCommit?.(clamped)
|
|
||||||
return clamped
|
|
||||||
}
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setMounted(true)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (previousDefinitionRef.current === definition) return
|
||||||
|
previousDefinitionRef.current = definition
|
||||||
|
paintedFrameRef.current = null
|
||||||
defaultPlaybackStarted.current = false
|
defaultPlaybackStarted.current = false
|
||||||
completedAnimation.current = undefined
|
completedAnimation.current = undefined
|
||||||
const key = animation ?? defaultAnimation
|
const next = createInitialPlayback(
|
||||||
if (key) {
|
definition,
|
||||||
const result = resolveAnimation(definition, key)
|
animation,
|
||||||
if (result.ok) {
|
expression,
|
||||||
const next = {
|
defaultAnimation,
|
||||||
...createAvatarPlaybackState(),
|
defaultExpression
|
||||||
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
|
)
|
||||||
}
|
|
||||||
playbackRef.current = next
|
playbackRef.current = next
|
||||||
setPlayback(next)
|
setPlayback(next)
|
||||||
return
|
}, [animation, defaultAnimation, defaultExpression, definition, expression])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (animation !== undefined || expression !== undefined) return
|
||||||
|
const result = defaultAnimation
|
||||||
|
? resolveAnimation(definition, defaultAnimation)
|
||||||
|
: defaultExpression
|
||||||
|
? resolveExpression(definition, defaultExpression)
|
||||||
|
: null
|
||||||
|
if (result && !result.ok) {
|
||||||
|
if (onError) onError(result.error)
|
||||||
|
else console.error(`[Avatar] ${result.error.message}`)
|
||||||
}
|
}
|
||||||
}
|
}, [animation, defaultAnimation, defaultExpression, definition, expression, onError])
|
||||||
const keyExpression = expression ?? defaultExpression ?? 'neutral'
|
|
||||||
const resolved = resolveExpression(definition, keyExpression)
|
|
||||||
const next = {
|
|
||||||
...createAvatarPlaybackState(),
|
|
||||||
activeExpression: resolved.ok ? keyExpression : 'neutral',
|
|
||||||
}
|
|
||||||
playbackRef.current = next
|
|
||||||
setPlayback(next)
|
|
||||||
}, [definition])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (
|
if (
|
||||||
@ -280,64 +276,21 @@ export function Avatar({
|
|||||||
}
|
}
|
||||||
defaultPlaybackStarted.current = true
|
defaultPlaybackStarted.current = true
|
||||||
const result = playAvatarAnimation(definition, defaultAnimation, performance.now())
|
const result = playAvatarAnimation(definition, defaultAnimation, performance.now())
|
||||||
if (result.ok) setPlayback(result.value)
|
if (result.ok) {
|
||||||
|
playbackRef.current = result.value
|
||||||
|
setPlayback(result.value)
|
||||||
|
}
|
||||||
}, [animation, autoplay, defaultAnimation, definition, expression])
|
}, [animation, autoplay, defaultAnimation, definition, expression])
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (mode === 'floating') {
|
|
||||||
if (floatingPositionInitialized.current) {
|
|
||||||
applyTransform(position ?? internalPosition)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
floatingPositionInitialized.current = true
|
|
||||||
const fallbackPixels = sizeInPixels(size)
|
|
||||||
const measuredWidth = wrapperRef.current?.offsetWidth || fallbackPixels
|
|
||||||
const measuredHeight = wrapperRef.current?.offsetHeight || fallbackPixels
|
|
||||||
const point = initialPoint(
|
|
||||||
initialPosition,
|
|
||||||
window.innerWidth - measuredWidth,
|
|
||||||
window.innerHeight - measuredHeight
|
|
||||||
)
|
|
||||||
const clamped = clampPosition(position ?? point)
|
|
||||||
if (!position) setInternalPosition(clamped)
|
|
||||||
applyTransform(position ?? clamped)
|
|
||||||
} else {
|
|
||||||
floatingPositionInitialized.current = false
|
|
||||||
applyTransform(position ?? internalPosition)
|
|
||||||
}
|
|
||||||
}, [mode, position, size, internalPosition])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!mounted) return
|
|
||||||
const reClamp = () => {
|
|
||||||
const current = position ?? internalPosition
|
|
||||||
const clamped = clampPosition(current)
|
|
||||||
if (samePosition(current, clamped)) return
|
|
||||||
if (position) {
|
|
||||||
applyTransform(position)
|
|
||||||
onPositionChange?.(clamped)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
applyTransform(clamped)
|
|
||||||
setInternalPosition(clamped)
|
|
||||||
}
|
|
||||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(reClamp)
|
|
||||||
if (wrapperRef.current) observer?.observe(wrapperRef.current)
|
|
||||||
if (effectiveConstraint === 'parent' && wrapperRef.current?.parentElement && observer) {
|
|
||||||
observer.observe(wrapperRef.current.parentElement)
|
|
||||||
}
|
|
||||||
window.addEventListener('resize', reClamp)
|
|
||||||
return () => {
|
|
||||||
observer?.disconnect()
|
|
||||||
window.removeEventListener('resize', reClamp)
|
|
||||||
}
|
|
||||||
}, [mounted, position, internalPosition, effectiveConstraint, onPositionChange])
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (expression !== undefined) {
|
if (expression !== undefined) {
|
||||||
const resolved = resolveExpression(definition, expression)
|
const resolved = resolveExpression(definition, expression)
|
||||||
if (resolved.ok) {
|
if (resolved.ok) {
|
||||||
const current = playbackRef.current
|
const current = playbackRef.current ?? createAvatarPlaybackState()
|
||||||
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrameRef.current ??
|
||||||
|
sampleAvatarFrame(definition, current, now, runtimeEnvironment())
|
||||||
const next = {
|
const next = {
|
||||||
...createAvatarPlaybackState(),
|
...createAvatarPlaybackState(),
|
||||||
activeExpression: expression,
|
activeExpression: expression,
|
||||||
@ -346,8 +299,8 @@ export function Avatar({
|
|||||||
: {
|
: {
|
||||||
status: 'playing' as const,
|
status: 'playing' as const,
|
||||||
directTransition: {
|
directTransition: {
|
||||||
from: current.activeExpression,
|
from,
|
||||||
startedAt: performance.now(),
|
startedAt: now,
|
||||||
durationMs: controlledExpressionTransitionMs,
|
durationMs: controlledExpressionTransitionMs,
|
||||||
transition: 'smooth' as const,
|
transition: 'smooth' as const,
|
||||||
},
|
},
|
||||||
@ -355,14 +308,23 @@ export function Avatar({
|
|||||||
}
|
}
|
||||||
playbackRef.current = next
|
playbackRef.current = next
|
||||||
setPlayback(next)
|
setPlayback(next)
|
||||||
}
|
} else if (onError) onError(resolved.error)
|
||||||
|
else console.error(`[Avatar] ${resolved.error.message}`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if (animation !== undefined) {
|
if (animation !== undefined) {
|
||||||
const result = playAvatarAnimation(definition, animation, performance.now())
|
const current = playbackRef.current ?? createAvatarPlaybackState()
|
||||||
if (result.ok) setPlayback(result.value)
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
|
||||||
|
const result = playAvatarAnimation(definition, animation, now, from)
|
||||||
|
if (result.ok) {
|
||||||
|
playbackRef.current = result.value
|
||||||
|
setPlayback(result.value)
|
||||||
|
} else if (onError) onError(result.error)
|
||||||
|
else console.error(`[Avatar] ${result.error.message}`)
|
||||||
}
|
}
|
||||||
}, [animation, definition, expression])
|
}, [animation, definition, expression, onError])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
onExpressionChange?.(playback.activeExpression)
|
onExpressionChange?.(playback.activeExpression)
|
||||||
@ -373,11 +335,9 @@ export function Avatar({
|
|||||||
let frame = 0
|
let frame = 0
|
||||||
const tick = (now: number) => {
|
const tick = (now: number) => {
|
||||||
const current = playbackRef.current
|
const current = playbackRef.current
|
||||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
if (!current) return
|
||||||
const next = advanceAvatarPlayback(definition, current, now, {
|
const environment = runtimeEnvironment()
|
||||||
random: Math.random,
|
const next = advanceAvatarPlayback(definition, current, now, environment)
|
||||||
reduceMotion,
|
|
||||||
})
|
|
||||||
playbackRef.current = next
|
playbackRef.current = next
|
||||||
if (!samePlayback(current, next)) setPlayback(next)
|
if (!samePlayback(current, next)) setPlayback(next)
|
||||||
if (
|
if (
|
||||||
@ -389,27 +349,8 @@ export function Avatar({
|
|||||||
completedAnimation.current = current.activeAnimation
|
completedAnimation.current = current.activeAnimation
|
||||||
onAnimationEnd?.(current.activeAnimation)
|
onAnimationEnd?.(current.activeAnimation)
|
||||||
}
|
}
|
||||||
const frameScene = renderAvatarFrame(definition, next, now, {
|
const frameScene = renderPlaybackFrame(next, now, environment)
|
||||||
random: Math.random,
|
paintScene(frameScene)
|
||||||
reduceMotion,
|
|
||||||
})
|
|
||||||
headPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
|
|
||||||
clipPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
|
|
||||||
headPathRef.current?.setAttribute('fill', frameScene.colors.body)
|
|
||||||
leftPathRef.current?.setAttribute('d', frameScene.geometry.leftPath)
|
|
||||||
leftPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
|
|
||||||
leftPathRef.current?.setAttribute('opacity', frameScene.geometry.leftVisible ? '1' : '0')
|
|
||||||
rightPathRef.current?.setAttribute('d', frameScene.geometry.rightPath)
|
|
||||||
rightPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
|
|
||||||
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
|
|
||||||
frameScene.geometry.backPaths.forEach((path, index) => {
|
|
||||||
backPathRefs.current[index]?.setAttribute('d', path)
|
|
||||||
backPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
|
|
||||||
})
|
|
||||||
frameScene.geometry.frontPaths.forEach((path, index) => {
|
|
||||||
frontPathRefs.current[index]?.setAttribute('d', path)
|
|
||||||
frontPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
|
|
||||||
})
|
|
||||||
if (next.status === 'playing') frame = requestAnimationFrame(tick)
|
if (next.status === 'playing') frame = requestAnimationFrame(tick)
|
||||||
}
|
}
|
||||||
frame = requestAnimationFrame(tick)
|
frame = requestAnimationFrame(tick)
|
||||||
@ -429,7 +370,7 @@ export function Avatar({
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const current = playbackRef.current
|
const current = playbackRef.current ?? createAvatarPlaybackState()
|
||||||
if (
|
if (
|
||||||
current.status === 'paused' &&
|
current.status === 'paused' &&
|
||||||
current.activeAnimation === key &&
|
current.activeAnimation === key &&
|
||||||
@ -441,7 +382,10 @@ export function Avatar({
|
|||||||
setPlayback(resumed)
|
setPlayback(resumed)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
}
|
}
|
||||||
const result = playAvatarAnimation(definition, key, performance.now())
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
|
||||||
|
const result = playAvatarAnimation(definition, key, now, from)
|
||||||
if (!result.ok) return { ok: false, error: result.error }
|
if (!result.ok) return { ok: false, error: result.error }
|
||||||
completedAnimation.current = undefined
|
completedAnimation.current = undefined
|
||||||
playbackRef.current = result.value
|
playbackRef.current = result.value
|
||||||
@ -461,14 +405,32 @@ export function Avatar({
|
|||||||
}
|
}
|
||||||
const result = resolveExpression(definition, key)
|
const result = resolveExpression(definition, key)
|
||||||
if (!result.ok) return { ok: false, error: result.error }
|
if (!result.ok) return { ok: false, error: result.error }
|
||||||
const next = { ...createAvatarPlaybackState(), activeExpression: key }
|
const current = playbackRef.current ?? createAvatarPlaybackState()
|
||||||
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
|
||||||
|
const next = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: key,
|
||||||
|
...(current.activeExpression === key
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from,
|
||||||
|
startedAt: now,
|
||||||
|
durationMs: controlledExpressionTransitionMs,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
playbackRef.current = next
|
playbackRef.current = next
|
||||||
setPlayback(next)
|
setPlayback(next)
|
||||||
return { ok: true }
|
return { ok: true }
|
||||||
},
|
},
|
||||||
pause() {
|
pause() {
|
||||||
const current = playbackRef.current
|
const current = playbackRef.current
|
||||||
if (current.status !== 'playing') return
|
if (!current || current.status !== 'playing') return
|
||||||
const next = pauseAvatarPlayback(current, performance.now())
|
const next = pauseAvatarPlayback(current, performance.now())
|
||||||
playbackRef.current = next
|
playbackRef.current = next
|
||||||
setPlayback(next)
|
setPlayback(next)
|
||||||
@ -481,7 +443,7 @@ export function Avatar({
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
getState() {
|
getState() {
|
||||||
const current = playbackRef.current
|
const current = playbackRef.current ?? createAvatarPlaybackState()
|
||||||
return {
|
return {
|
||||||
...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
|
...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
|
||||||
activeExpression: current.activeExpression,
|
activeExpression: current.activeExpression,
|
||||||
@ -490,124 +452,30 @@ export function Avatar({
|
|||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
const scene = renderAvatarDefinition(definition)
|
||||||
if (!draggable || event.button !== 0) return
|
return (
|
||||||
if (
|
|
||||||
mode === 'floating' &&
|
|
||||||
!(event.target instanceof Element && event.target.closest('.bs-avatar__drag-grip'))
|
|
||||||
) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
|
||||||
drag.current = {
|
|
||||||
pointerId: event.pointerId,
|
|
||||||
pointer: { x: event.clientX, y: event.clientY },
|
|
||||||
origin: authoritativePosition,
|
|
||||||
current: authoritativePosition,
|
|
||||||
}
|
|
||||||
event.currentTarget.classList.add('bs-avatar--dragging')
|
|
||||||
onDragStart?.()
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
|
||||||
const active = drag.current
|
|
||||||
if (!active || active.pointerId !== event.pointerId) return
|
|
||||||
const next = clampPosition({
|
|
||||||
x: active.origin.x + event.clientX - active.pointer.x,
|
|
||||||
y: active.origin.y + event.clientY - active.pointer.y,
|
|
||||||
})
|
|
||||||
active.current = next
|
|
||||||
applyTransform(next)
|
|
||||||
if (previewFrame.current === undefined) {
|
|
||||||
previewFrame.current = requestAnimationFrame(() => {
|
|
||||||
previewFrame.current = undefined
|
|
||||||
if (drag.current) onPositionPreview?.(drag.current.current)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const endDrag = (cancelled: boolean) => {
|
|
||||||
const active = drag.current
|
|
||||||
if (!active) return
|
|
||||||
if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
|
|
||||||
previewFrame.current = undefined
|
|
||||||
const finalPosition = commitPosition(cancelled ? active.origin : active.current)
|
|
||||||
wrapperRef.current?.classList.remove('bs-avatar--dragging')
|
|
||||||
drag.current = undefined
|
|
||||||
if (
|
|
||||||
wrapperRef.current?.hasPointerCapture?.(active.pointerId) &&
|
|
||||||
wrapperRef.current.releasePointerCapture
|
|
||||||
) {
|
|
||||||
wrapperRef.current.releasePointerCapture(active.pointerId)
|
|
||||||
}
|
|
||||||
onDragEnd?.(finalPosition)
|
|
||||||
}
|
|
||||||
|
|
||||||
const moveByKeyboard = (x: number, y: number) => {
|
|
||||||
const next = commitPosition({ x: authoritativePosition.x + x, y: authoritativePosition.y + y })
|
|
||||||
onPositionPreview?.(next)
|
|
||||||
}
|
|
||||||
|
|
||||||
const scene = renderAvatarDefinition(
|
|
||||||
definition,
|
|
||||||
mode === 'floating' && !mounted ? 'neutral' : playback.activeExpression
|
|
||||||
)
|
|
||||||
const wrapper = (
|
|
||||||
<div
|
<div
|
||||||
ref={wrapperRef}
|
className={['bs-avatar', className ?? ''].filter(Boolean).join(' ')}
|
||||||
className={[
|
|
||||||
'bs-avatar',
|
|
||||||
`bs-avatar--${mode}`,
|
|
||||||
draggable ? 'bs-avatar--draggable' : '',
|
|
||||||
className ?? '',
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' ')}
|
|
||||||
style={{
|
style={{
|
||||||
...style,
|
...style,
|
||||||
width: size,
|
width: size,
|
||||||
height: size,
|
height: size,
|
||||||
zIndex: mode === 'floating' ? zIndex : style?.zIndex,
|
|
||||||
transform: `translate3d(${authoritativePosition.x}px, ${authoritativePosition.y}px, 0)`,
|
|
||||||
}}
|
}}
|
||||||
role={draggable ? 'group' : 'img'}
|
role="img"
|
||||||
aria-label={ariaLabel}
|
aria-label={ariaLabel}
|
||||||
aria-description={
|
|
||||||
draggable
|
|
||||||
? 'Use the drag handle, arrow keys or move controls to reposition the avatar.'
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
tabIndex={draggable ? 0 : undefined}
|
|
||||||
onPointerDown={startDrag}
|
|
||||||
onPointerMove={moveDrag}
|
|
||||||
onPointerUp={() => endDrag(false)}
|
|
||||||
onPointerCancel={() => endDrag(true)}
|
|
||||||
onLostPointerCapture={() => endDrag(false)}
|
|
||||||
onKeyDown={event => {
|
|
||||||
if (!draggable) return
|
|
||||||
const amount = event.shiftKey ? 1 : 10
|
|
||||||
if (event.key === 'ArrowLeft') moveByKeyboard(-amount, 0)
|
|
||||||
else if (event.key === 'ArrowRight') moveByKeyboard(amount, 0)
|
|
||||||
else if (event.key === 'ArrowUp') moveByKeyboard(0, -amount)
|
|
||||||
else if (event.key === 'ArrowDown') moveByKeyboard(0, amount)
|
|
||||||
else if (event.key === 'Escape') endDrag(true)
|
|
||||||
else return
|
|
||||||
event.preventDefault()
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{draggable && <div className="bs-avatar__drag-grip" aria-hidden="true" title="Drag avatar" />}
|
|
||||||
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
|
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
<clipPath id={clipId}>
|
<clipPath id={clipId}>
|
||||||
<path ref={clipPathRef} d={scene.geometry.headPath} />
|
<path ref={clipPathRef} d={scene.geometry.headPath} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
</defs>
|
</defs>
|
||||||
{scene.geometry.backPaths.map((path, index) => (
|
{Array.from({ length: bodyPathSlots }, (_, index) => (
|
||||||
<path
|
<path
|
||||||
ref={element => {
|
ref={element => {
|
||||||
backPathRefs.current[index] = element
|
backPathRefs.current[index] = element
|
||||||
}}
|
}}
|
||||||
d={path}
|
d={scene.geometry.backPaths[index] ?? ''}
|
||||||
fill={scene.colors.body}
|
fill={scene.colors.body}
|
||||||
key={`back-${index}`}
|
key={`back-${index}`}
|
||||||
/>
|
/>
|
||||||
@ -625,53 +493,17 @@ export function Avatar({
|
|||||||
opacity={scene.geometry.rightVisible ? 1 : 0}
|
opacity={scene.geometry.rightVisible ? 1 : 0}
|
||||||
/>
|
/>
|
||||||
</g>
|
</g>
|
||||||
{scene.geometry.frontPaths.map((path, index) => (
|
{Array.from({ length: bodyPathSlots }, (_, index) => (
|
||||||
<path
|
<path
|
||||||
ref={element => {
|
ref={element => {
|
||||||
frontPathRefs.current[index] = element
|
frontPathRefs.current[index] = element
|
||||||
}}
|
}}
|
||||||
d={path}
|
d={scene.geometry.frontPaths[index] ?? ''}
|
||||||
fill={scene.colors.body}
|
fill={scene.colors.body}
|
||||||
key={`front-${index}`}
|
key={`front-${index}`}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</svg>
|
</svg>
|
||||||
{draggable && (
|
|
||||||
<div className="bs-avatar__move-controls">
|
|
||||||
{(
|
|
||||||
[
|
|
||||||
['Move avatar left', -10, 0, '\u2190'],
|
|
||||||
['Move avatar right', 10, 0, '\u2192'],
|
|
||||||
['Move avatar up', 0, -10, '\u2191'],
|
|
||||||
['Move avatar down', 0, 10, '\u2193'],
|
|
||||||
] as const
|
|
||||||
).map(([label, x, y, symbol]) => (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={label}
|
|
||||||
onPointerDown={event => event.stopPropagation()}
|
|
||||||
onClick={() => moveByKeyboard(x, y)}
|
|
||||||
key={label}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">{symbol}</span>
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
<button
|
|
||||||
className="bs-avatar__reset"
|
|
||||||
type="button"
|
|
||||||
aria-label="Reset avatar position"
|
|
||||||
onPointerDown={event => event.stopPropagation()}
|
|
||||||
onClick={() => commitPosition({ x: 0, y: 0 })}
|
|
||||||
>
|
|
||||||
<span aria-hidden="true">\u21ba</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (mode === 'floating' && mounted) {
|
|
||||||
return createPortal(wrapper, portalContainer ?? document.body)
|
|
||||||
}
|
|
||||||
return wrapper
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,24 +1,12 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
import { renderAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
import { type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import { act, createRef, Profiler, StrictMode } from 'react'
|
import { act, createRef, Profiler, StrictMode } from 'react'
|
||||||
import { hydrateRoot } from 'react-dom/client'
|
import { render } from '@testing-library/react'
|
||||||
import { renderToString } from 'react-dom/server'
|
|
||||||
import { fireEvent, render } from '@testing-library/react'
|
|
||||||
import { vi } from 'vitest'
|
import { vi } from 'vitest'
|
||||||
|
|
||||||
import { Avatar, type AvatarController } from '../Avatar'
|
import { Avatar, type AvatarController } from '../Avatar'
|
||||||
|
import { createAvatar } from '../createAvatar'
|
||||||
let resizeCallbacks: ResizeObserverCallback[] = []
|
|
||||||
|
|
||||||
class TestResizeObserver implements ResizeObserver {
|
|
||||||
constructor(callback: ResizeObserverCallback) {
|
|
||||||
resizeCallbacks.push(callback)
|
|
||||||
}
|
|
||||||
observe() {}
|
|
||||||
unobserve() {}
|
|
||||||
disconnect() {}
|
|
||||||
}
|
|
||||||
|
|
||||||
const expression = {
|
const expression = {
|
||||||
head: { x: 0, y: 0, z: 0 },
|
head: { x: 0, y: 0, z: 0 },
|
||||||
@ -67,37 +55,42 @@ const definition: AvatarDefinition = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
animationOrder: ['greet', 'wave-once'],
|
animationOrder: ['greet', 'wave-once'],
|
||||||
standardAnimationSet: 1,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
HTMLElement.prototype.setPointerCapture = () => undefined
|
|
||||||
HTMLElement.prototype.hasPointerCapture = () => true
|
|
||||||
HTMLElement.prototype.releasePointerCapture = () => undefined
|
|
||||||
window.matchMedia = () =>
|
window.matchMedia = () =>
|
||||||
({
|
({
|
||||||
matches: false,
|
matches: false,
|
||||||
addEventListener: () => undefined,
|
addEventListener: () => undefined,
|
||||||
removeEventListener: () => undefined,
|
removeEventListener: () => undefined,
|
||||||
}) as unknown as MediaQueryList
|
}) as unknown as MediaQueryList
|
||||||
globalThis.ResizeObserver = TestResizeObserver
|
|
||||||
})
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
resizeCallbacks = []
|
|
||||||
Object.defineProperties(window, {
|
|
||||||
innerWidth: { configurable: true, value: 1_024 },
|
|
||||||
innerHeight: { configurable: true, value: 768 },
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('@bible-strong/avatar-react', () => {
|
describe('@bible-strong/avatar-react', () => {
|
||||||
it('renders semantic SVG geometry in embedded mode', () => {
|
it('creates a validated concrete component from a definition', () => {
|
||||||
|
const ConcreteAvatar = createAvatar(definition)
|
||||||
|
const view = render(<ConcreteAvatar animation="greet" ariaLabel="Concrete avatar" />)
|
||||||
|
|
||||||
|
expect(view.getByRole('img', { name: 'Concrete avatar' })).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects invalid definitions before creating a component', () => {
|
||||||
|
expect(() => createAvatar({})).toThrow('Invalid avatar definition')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders semantic SVG geometry', () => {
|
||||||
const view = render(<Avatar definition={definition} ariaLabel="Assistant avatar" />)
|
const view = render(<Avatar definition={definition} ariaLabel="Assistant avatar" />)
|
||||||
const avatar = view.getByRole('img', { name: 'Assistant avatar' })
|
const avatar = view.getByRole('img', { name: 'Assistant avatar' })
|
||||||
expect(avatar.classList.contains('bs-avatar--embedded')).toBe(true)
|
expect(avatar.classList.contains('bs-avatar')).toBe(true)
|
||||||
|
expect(avatar.className).toBe('bs-avatar')
|
||||||
expect(avatar.querySelector('svg path')).not.toBeNull()
|
expect(avatar.querySelector('svg path')).not.toBeNull()
|
||||||
expect(document.body.querySelector('.bs-avatar--floating')).toBeNull()
|
})
|
||||||
|
|
||||||
|
it('keeps stable SVG layer slots for nodes moving in front of or behind the head', () => {
|
||||||
|
const view = render(<Avatar definition={definition} ariaLabel="Layered avatar" />)
|
||||||
|
const svg = view.getByRole('img', { name: 'Layered avatar' }).querySelector('svg')
|
||||||
|
|
||||||
|
expect(svg?.querySelectorAll(':scope > path')).toHaveLength(37)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('exposes semantic imperative controls without Studio identifiers', () => {
|
it('exposes semantic imperative controls without Studio identifiers', () => {
|
||||||
@ -111,7 +104,7 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
expect(result).toEqual({ ok: true })
|
expect(result).toEqual({ ok: true })
|
||||||
expect(controller.current?.getState()).toMatchObject({
|
expect(controller.current?.getState()).toMatchObject({
|
||||||
activeExpression: 'smile',
|
activeExpression: 'smile',
|
||||||
status: 'stopped',
|
status: 'playing',
|
||||||
})
|
})
|
||||||
expect(controller.current?.play('missing')).toMatchObject({
|
expect(controller.current?.play('missing')).toMatchObject({
|
||||||
ok: false,
|
ok: false,
|
||||||
@ -149,6 +142,49 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('retargets from the currently painted SVG frame without a target-frame flash', () => {
|
||||||
|
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)
|
||||||
|
})
|
||||||
|
const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
|
||||||
|
const controller = createRef<AvatarController>()
|
||||||
|
const view = render(<Avatar definition={definition} ref={controller} />)
|
||||||
|
const eye = view.container.querySelector<SVGPathElement>('.bs-avatar__svg g path')
|
||||||
|
const neutralPath = eye?.getAttribute('d')
|
||||||
|
|
||||||
|
act(() => controller.current?.setExpression('smile'))
|
||||||
|
expect(eye?.getAttribute('d')).toBe(neutralPath)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const callback = [...frames.values()].at(-1)
|
||||||
|
frames.clear()
|
||||||
|
callback?.(1_200)
|
||||||
|
})
|
||||||
|
const inFlightPath = eye?.getAttribute('d')
|
||||||
|
expect(inFlightPath).not.toBe(neutralPath)
|
||||||
|
|
||||||
|
clock.mockReturnValue(1_200)
|
||||||
|
act(() => controller.current?.setExpression('neutral'))
|
||||||
|
expect(eye?.getAttribute('d')).toBe(inFlightPath)
|
||||||
|
|
||||||
|
act(() => {
|
||||||
|
const callback = [...frames.values()].at(-1)
|
||||||
|
frames.clear()
|
||||||
|
callback?.(1_200)
|
||||||
|
})
|
||||||
|
expect(eye?.getAttribute('d')).toBe(inFlightPath)
|
||||||
|
|
||||||
|
clock.mockRestore()
|
||||||
|
request.mockRestore()
|
||||||
|
cancel.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects simultaneous controlled animation and expression props', () => {
|
it('rejects simultaneous controlled animation and expression props', () => {
|
||||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
expect(() =>
|
expect(() =>
|
||||||
@ -157,6 +193,51 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
errors.mockRestore()
|
errors.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('rejects simultaneous uncontrolled animation and expression defaults', () => {
|
||||||
|
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||||
|
expect(() =>
|
||||||
|
render(
|
||||||
|
<Avatar definition={definition} defaultAnimation="greet" defaultExpression="neutral" />
|
||||||
|
)
|
||||||
|
).toThrow('Avatar accepts either defaultAnimation or defaultExpression, not both.')
|
||||||
|
errors.mockRestore()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('reports unknown controlled and default targets through onError', () => {
|
||||||
|
const onError = vi.fn()
|
||||||
|
const controlled = render(
|
||||||
|
<Avatar definition={definition} animation="missing-animation" onError={onError} />
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(onError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ code: 'unknown_animation', key: 'missing-animation' })
|
||||||
|
)
|
||||||
|
|
||||||
|
onError.mockClear()
|
||||||
|
controlled.rerender(
|
||||||
|
<Avatar definition={definition} defaultExpression="missing-expression" onError={onError} />
|
||||||
|
)
|
||||||
|
expect(onError).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ code: 'unknown_expression', key: 'missing-expression' })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps a controlled expression above an uncontrolled animation default', () => {
|
||||||
|
const controller = createRef<AvatarController>()
|
||||||
|
render(
|
||||||
|
<Avatar
|
||||||
|
definition={definition}
|
||||||
|
expression="neutral"
|
||||||
|
defaultAnimation="greet"
|
||||||
|
ref={controller}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
expect(controller.current?.getState()).toEqual({
|
||||||
|
activeExpression: 'neutral',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
it('honors uncontrolled defaults without autoplay when requested', () => {
|
it('honors uncontrolled defaults without autoplay when requested', () => {
|
||||||
const controller = createRef<AvatarController>()
|
const controller = createRef<AvatarController>()
|
||||||
render(
|
render(
|
||||||
@ -168,333 +249,6 @@ describe('@bible-strong/avatar-react', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('portals floating mode to the document body after mount', () => {
|
|
||||||
const host = document.createElement('section')
|
|
||||||
document.body.append(host)
|
|
||||||
const view = render(<Avatar definition={definition} mode="floating" />, { container: host })
|
|
||||||
expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
|
|
||||||
view.unmount()
|
|
||||||
host.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('normalizes an uncontrolled floating initial position only once', () => {
|
|
||||||
const view = render(
|
|
||||||
<Avatar definition={definition} mode="floating" initialPosition={{ x: 10, y: 12 }} />
|
|
||||||
)
|
|
||||||
expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
|
|
||||||
view.rerender(
|
|
||||||
<Avatar definition={definition} mode="floating" initialPosition={{ x: 80, y: 90 }} />
|
|
||||||
)
|
|
||||||
expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('server-renders a neutral floating placeholder before portal handoff', () => {
|
|
||||||
const markup = renderToString(
|
|
||||||
<Avatar definition={definition} mode="floating" defaultExpression="smile" size={180} />
|
|
||||||
)
|
|
||||||
const neutral = renderAvatarDefinition(definition, 'neutral')
|
|
||||||
expect(markup).toContain('bs-avatar--floating')
|
|
||||||
expect(markup).toContain('width:180px;height:180px')
|
|
||||||
expect(markup).toContain(`d="${neutral.geometry.leftPath}"`)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('hydrates the floating placeholder before moving it to a portal without warnings', async () => {
|
|
||||||
const host = document.createElement('section')
|
|
||||||
const markup = renderToString(<Avatar definition={definition} mode="floating" size={180} />)
|
|
||||||
host.innerHTML = markup
|
|
||||||
document.body.append(host)
|
|
||||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
||||||
let root: ReturnType<typeof hydrateRoot> | undefined
|
|
||||||
await act(async () => {
|
|
||||||
root = hydrateRoot(host, <Avatar definition={definition} mode="floating" size={180} />)
|
|
||||||
await Promise.resolve()
|
|
||||||
})
|
|
||||||
expect(errors).not.toHaveBeenCalled()
|
|
||||||
expect(host.querySelector('.bs-avatar--floating')).toBeNull()
|
|
||||||
expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
|
|
||||||
act(() => root?.unmount())
|
|
||||||
errors.mockRestore()
|
|
||||||
host.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('moves by pointer without React state updates on every pointer move and commits once', () => {
|
|
||||||
const previews: { x: number; y: number }[] = []
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
draggable
|
|
||||||
constrainTo="none"
|
|
||||||
onPositionPreview={point => previews.push(point)}
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 1, button: 0, clientX: 10, clientY: 10 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(25px, 35px, 0)')
|
|
||||||
fireEvent.pointerUp(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
|
|
||||||
expect(commits).toEqual([{ x: 25, y: 35 }])
|
|
||||||
expect(previews.length).toBeLessThanOrEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('uses the visible grip as the floating avatar drag target', () => {
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
mode="floating"
|
|
||||||
draggable
|
|
||||||
constrainTo="none"
|
|
||||||
initialPosition={{ x: 0, y: 0 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
const grip = avatar.querySelector('.bs-avatar__drag-grip')
|
|
||||||
expect(grip).not.toBeNull()
|
|
||||||
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 8, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 8, clientX: 20, clientY: 20 })
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
|
|
||||||
|
|
||||||
fireEvent.pointerDown(grip!, { pointerId: 9, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 9, clientX: 20, clientY: 20 })
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(20px, 20px, 0)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('limits drag previews to one callback per animation frame with the latest position', () => {
|
|
||||||
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)
|
|
||||||
})
|
|
||||||
const previews: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
draggable
|
|
||||||
constrainTo="none"
|
|
||||||
onPositionPreview={point => previews.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 6, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 10, clientY: 12 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 20, clientY: 24 })
|
|
||||||
expect(previews).toEqual([])
|
|
||||||
act(() => {
|
|
||||||
const callbacks = [...frames.values()]
|
|
||||||
frames.clear()
|
|
||||||
callbacks.forEach(callback => callback(16))
|
|
||||||
})
|
|
||||||
expect(previews).toEqual([{ x: 20, y: 24 }])
|
|
||||||
fireEvent.pointerUp(avatar, { pointerId: 6 })
|
|
||||||
request.mockRestore()
|
|
||||||
cancel.mockRestore()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not render React once per pointer movement', () => {
|
|
||||||
let renders = 0
|
|
||||||
const view = render(
|
|
||||||
<Profiler id="avatar" onRender={() => renders++}>
|
|
||||||
<Avatar definition={definition} draggable constrainTo="none" />
|
|
||||||
</Profiler>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
const beforeMoves = renders
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 2, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
for (let index = 1; index <= 20; index++) {
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 2, clientX: index, clientY: index })
|
|
||||||
}
|
|
||||||
expect(renders).toBe(beforeMoves)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restores the drag origin on pointer cancel', () => {
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
draggable
|
|
||||||
constrainTo="none"
|
|
||||||
initialPosition={{ x: 5, y: 7 }}
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 3, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 3, clientX: 40, clientY: 50 })
|
|
||||||
fireEvent.pointerCancel(avatar, { pointerId: 3 })
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
|
|
||||||
expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('restores the drag origin when Escape cancels an active drag', () => {
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
draggable
|
|
||||||
constrainTo="none"
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 7, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 7, clientX: 30, clientY: 40 })
|
|
||||||
fireEvent.keyDown(avatar, { key: 'Escape' })
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
|
|
||||||
expect(commits).toEqual([{ x: 0, y: 0 }])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('clamps pointer movement to an embedded parent', () => {
|
|
||||||
const view = render(<Avatar definition={definition} draggable constrainTo="parent" size={40} />)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
Object.defineProperties(avatar, {
|
|
||||||
offsetWidth: { configurable: true, value: 40 },
|
|
||||||
offsetHeight: { configurable: true, value: 40 },
|
|
||||||
})
|
|
||||||
Object.defineProperties(avatar.parentElement!, {
|
|
||||||
clientWidth: { configurable: true, value: 100 },
|
|
||||||
clientHeight: { configurable: true, value: 100 },
|
|
||||||
})
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 4, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 4, clientX: 200, clientY: 200 })
|
|
||||||
fireEvent.pointerUp(avatar, { pointerId: 4 })
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('keeps controlled position authoritative while reporting a constrained commit', () => {
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
draggable
|
|
||||||
constrainTo="parent"
|
|
||||||
size={40}
|
|
||||||
position={{ x: 10, y: 12 }}
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
Object.defineProperties(avatar, {
|
|
||||||
offsetWidth: { configurable: true, value: 40 },
|
|
||||||
offsetHeight: { configurable: true, value: 40 },
|
|
||||||
})
|
|
||||||
Object.defineProperties(avatar.parentElement!, {
|
|
||||||
clientWidth: { configurable: true, value: 100 },
|
|
||||||
clientHeight: { configurable: true, value: 100 },
|
|
||||||
})
|
|
||||||
fireEvent.pointerDown(avatar, { pointerId: 5, button: 0, clientX: 0, clientY: 0 })
|
|
||||||
fireEvent.pointerMove(avatar, { pointerId: 5, clientX: 200, clientY: 200 })
|
|
||||||
fireEvent.pointerUp(avatar, { pointerId: 5 })
|
|
||||||
expect(commits).toEqual([{ x: 60, y: 60 }])
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(10px, 12px, 0)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-clamps on resize without emitting a movement commit', () => {
|
|
||||||
const changes: { x: number; y: number }[] = []
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
mode="floating"
|
|
||||||
draggable
|
|
||||||
size={40}
|
|
||||||
position={{ x: 90, y: 90 }}
|
|
||||||
onPositionChange={point => changes.push(point)}
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
Object.defineProperties(avatar, {
|
|
||||||
offsetWidth: { configurable: true, value: 40 },
|
|
||||||
offsetHeight: { configurable: true, value: 40 },
|
|
||||||
})
|
|
||||||
Object.defineProperties(window, {
|
|
||||||
innerWidth: { configurable: true, value: 100 },
|
|
||||||
innerHeight: { configurable: true, value: 100 },
|
|
||||||
})
|
|
||||||
act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
|
|
||||||
expect(changes.at(-1)).toEqual({ x: 60, y: 60 })
|
|
||||||
expect(commits).toEqual([])
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(90px, 90px, 0)')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-clamps an uncontrolled floating position without emitting a movement commit', () => {
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
mode="floating"
|
|
||||||
draggable
|
|
||||||
size={40}
|
|
||||||
initialPosition={{ x: 90, y: 90 }}
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
Object.defineProperties(avatar, {
|
|
||||||
offsetWidth: { configurable: true, value: 40 },
|
|
||||||
offsetHeight: { configurable: true, value: 40 },
|
|
||||||
})
|
|
||||||
Object.defineProperties(window, {
|
|
||||||
innerWidth: { configurable: true, value: 100 },
|
|
||||||
innerHeight: { configurable: true, value: 100 },
|
|
||||||
})
|
|
||||||
act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
|
|
||||||
expect(commits).toEqual([])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-clamps to the viewport on window resize without ResizeObserver support', () => {
|
|
||||||
const originalObserver = globalThis.ResizeObserver
|
|
||||||
// @ts-expect-error This test covers browsers without ResizeObserver.
|
|
||||||
globalThis.ResizeObserver = undefined
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
mode="floating"
|
|
||||||
draggable
|
|
||||||
size={40}
|
|
||||||
initialPosition={{ x: 90, y: 90 }}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
Object.defineProperties(avatar, {
|
|
||||||
offsetWidth: { configurable: true, value: 40 },
|
|
||||||
offsetHeight: { configurable: true, value: 40 },
|
|
||||||
})
|
|
||||||
Object.defineProperties(window, {
|
|
||||||
innerWidth: { configurable: true, value: 100 },
|
|
||||||
innerHeight: { configurable: true, value: 100 },
|
|
||||||
})
|
|
||||||
act(() => window.dispatchEvent(new Event('resize')))
|
|
||||||
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
|
|
||||||
globalThis.ResizeObserver = originalObserver
|
|
||||||
})
|
|
||||||
|
|
||||||
it('provides keyboard movement and an accessible reset control', () => {
|
|
||||||
const commits: { x: number; y: number }[] = []
|
|
||||||
const view = render(
|
|
||||||
<Avatar
|
|
||||||
definition={definition}
|
|
||||||
draggable
|
|
||||||
constrainTo="none"
|
|
||||||
onPositionCommit={point => commits.push(point)}
|
|
||||||
/>
|
|
||||||
)
|
|
||||||
const avatar = view.getByRole('group')
|
|
||||||
fireEvent.keyDown(avatar, { key: 'ArrowRight' })
|
|
||||||
expect(commits.at(-1)).toEqual({ x: 10, y: 0 })
|
|
||||||
act(() => view.getByRole('button', { name: 'Move avatar down' }).click())
|
|
||||||
expect(commits.at(-1)).toEqual({ x: 10, y: 10 })
|
|
||||||
act(() => view.getByRole('button', { name: 'Reset avatar position' }).click())
|
|
||||||
expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
|
|
||||||
})
|
|
||||||
|
|
||||||
it('resumes the current paused animation instead of restarting it', () => {
|
it('resumes the current paused animation instead of restarting it', () => {
|
||||||
const controller = createRef<AvatarController>()
|
const controller = createRef<AvatarController>()
|
||||||
const clock = vi.spyOn(performance, 'now')
|
const clock = vi.spyOn(performance, 'now')
|
||||||
|
|||||||
74
packages/avatar-react/src/createAvatar.tsx
Normal file
74
packages/avatar-react/src/createAvatar.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
validateAvatarDefinition,
|
||||||
|
type AnimationKey,
|
||||||
|
type AvatarDefinition,
|
||||||
|
type ExpressionKey,
|
||||||
|
} from '@bible-strong/avatar-core'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
markAvatarDefinitionValidated,
|
||||||
|
type AvatarController,
|
||||||
|
type AvatarProps,
|
||||||
|
} from './Avatar'
|
||||||
|
|
||||||
|
type AvatarDefinitionInput = {
|
||||||
|
expressions: object
|
||||||
|
animations: object
|
||||||
|
}
|
||||||
|
|
||||||
|
type StringKey<T> = Extract<keyof T, string>
|
||||||
|
|
||||||
|
/** Props for a component created from one concrete avatar definition. */
|
||||||
|
export type CreatedAvatarProps<Definition extends AvatarDefinitionInput> = Omit<
|
||||||
|
AvatarProps,
|
||||||
|
'definition' | 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'
|
||||||
|
> & {
|
||||||
|
animation?: StringKey<Definition['animations']>
|
||||||
|
defaultAnimation?: StringKey<Definition['animations']>
|
||||||
|
expression?: StringKey<Definition['expressions']>
|
||||||
|
defaultExpression?: StringKey<Definition['expressions']>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A concrete avatar component with animation and expression keys from its definition. */
|
||||||
|
export type CreatedAvatarComponent<Definition extends AvatarDefinitionInput> = (
|
||||||
|
props: CreatedAvatarProps<Definition>
|
||||||
|
) => ReactElement
|
||||||
|
|
||||||
|
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
|
||||||
|
const first = errors[0]
|
||||||
|
return new Error(
|
||||||
|
first
|
||||||
|
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
|
||||||
|
: 'Invalid avatar definition.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildAvatarComponent = (definition: Readonly<AvatarDefinition>) => {
|
||||||
|
const ConcreteAvatar = (props: CreatedAvatarProps<AvatarDefinition>): ReactElement => (
|
||||||
|
<Avatar {...props} definition={definition} />
|
||||||
|
)
|
||||||
|
ConcreteAvatar.displayName = 'CreatedAvatar'
|
||||||
|
return ConcreteAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a JSON-compatible definition once and create a concrete React component from it.
|
||||||
|
*
|
||||||
|
* When the input is a statically typed definition, the returned component narrows its animation
|
||||||
|
* and expression props to that definition's semantic keys. Values loaded at runtime are still
|
||||||
|
* validated, but necessarily expose the broad string-key API at compile time.
|
||||||
|
*/
|
||||||
|
export function createAvatar<const Definition extends AvatarDefinitionInput>(
|
||||||
|
definition: Definition
|
||||||
|
): CreatedAvatarComponent<Definition>
|
||||||
|
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition>
|
||||||
|
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition> {
|
||||||
|
const result = validateAvatarDefinition(definition)
|
||||||
|
if (!result.ok) throw invalidDefinitionError(result.errors)
|
||||||
|
markAvatarDefinitionValidated(result.value)
|
||||||
|
return buildAvatarComponent(result.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AnimationKey, AvatarController, ExpressionKey }
|
||||||
@ -1,10 +1,10 @@
|
|||||||
export { Avatar } from './Avatar'
|
export { Avatar } from './Avatar'
|
||||||
|
export { createAvatar } from './createAvatar'
|
||||||
export type {
|
export type {
|
||||||
AvatarCommandResult,
|
AvatarCommandResult,
|
||||||
AvatarController,
|
AvatarController,
|
||||||
AvatarPlaybackState,
|
AvatarPlaybackState,
|
||||||
AvatarPosition,
|
|
||||||
AvatarProps,
|
AvatarProps,
|
||||||
AvatarRuntimeError,
|
AvatarRuntimeError,
|
||||||
FloatingInitialPosition,
|
|
||||||
} from './Avatar'
|
} from './Avatar'
|
||||||
|
export type { CreatedAvatarComponent, CreatedAvatarProps } from './createAvatar'
|
||||||
|
|||||||
@ -9,73 +9,6 @@
|
|||||||
-webkit-user-select: none;
|
-webkit-user-select: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bs-avatar--embedded {
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar--floating {
|
|
||||||
position: fixed;
|
|
||||||
inset: 0 auto auto 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar--draggable {
|
|
||||||
cursor: grab;
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar--dragging {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar--floating.bs-avatar--draggable {
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__drag-grip {
|
|
||||||
position: absolute;
|
|
||||||
z-index: 1;
|
|
||||||
top: 4px;
|
|
||||||
left: 50%;
|
|
||||||
width: 44px;
|
|
||||||
height: 28px;
|
|
||||||
border: 1px solid rgb(15 23 42 / 18%);
|
|
||||||
border-radius: 999px;
|
|
||||||
background: rgb(255 255 255 / 92%);
|
|
||||||
box-shadow: 0 2px 6px rgb(15 23 42 / 14%);
|
|
||||||
cursor: grab;
|
|
||||||
opacity: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
touch-action: none;
|
|
||||||
transform: translateX(-50%) translateY(-4px);
|
|
||||||
transition:
|
|
||||||
opacity 160ms ease,
|
|
||||||
transform 160ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__drag-grip::before {
|
|
||||||
position: absolute;
|
|
||||||
top: 8px;
|
|
||||||
left: 13px;
|
|
||||||
width: 18px;
|
|
||||||
height: 12px;
|
|
||||||
content: '';
|
|
||||||
background-image: radial-gradient(circle, #475569 1.5px, transparent 1.7px);
|
|
||||||
background-size: 6px 6px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar--dragging .bs-avatar__drag-grip {
|
|
||||||
cursor: grabbing;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar:hover .bs-avatar__drag-grip,
|
|
||||||
.bs-avatar:focus-visible .bs-avatar__drag-grip,
|
|
||||||
.bs-avatar:focus-within .bs-avatar__drag-grip,
|
|
||||||
.bs-avatar--dragging .bs-avatar__drag-grip {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar:focus-visible {
|
.bs-avatar:focus-visible {
|
||||||
outline: 3px solid currentColor;
|
outline: 3px solid currentColor;
|
||||||
outline-offset: 3px;
|
outline-offset: 3px;
|
||||||
@ -89,63 +22,6 @@
|
|||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.bs-avatar__move-controls {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0 0 auto auto;
|
|
||||||
display: grid;
|
|
||||||
grid-template-columns: repeat(3, 44px);
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__move-controls button {
|
|
||||||
width: 44px;
|
|
||||||
height: 44px;
|
|
||||||
border: 0;
|
|
||||||
border-radius: 999px;
|
|
||||||
background: transparent;
|
|
||||||
color: currentColor;
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__move-controls button:nth-child(1) {
|
|
||||||
grid-column: 1;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__move-controls button:nth-child(2) {
|
|
||||||
grid-column: 3;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__move-controls button:nth-child(3) {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__move-controls button:nth-child(4) {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 3;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar__reset {
|
|
||||||
grid-column: 2;
|
|
||||||
grid-row: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bs-avatar:hover .bs-avatar__move-controls,
|
|
||||||
.bs-avatar:focus-within .bs-avatar__move-controls,
|
|
||||||
.bs-avatar__move-controls:focus-within {
|
|
||||||
opacity: 0.75;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (hover: none) {
|
|
||||||
.bs-avatar__drag-grip {
|
|
||||||
opacity: 1;
|
|
||||||
pointer-events: auto;
|
|
||||||
transform: translateX(-50%);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.bs-avatar,
|
.bs-avatar,
|
||||||
.bs-avatar * {
|
.bs-avatar * {
|
||||||
|
|||||||
28
packages/avatar-web/README.md
Normal file
28
packages/avatar-web/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# @bible-strong/avatar-web
|
||||||
|
|
||||||
|
DOM renderer for Bible Strong procedural avatars. It uses `@bible-strong/avatar-core` for schema
|
||||||
|
validation, playback and rendering, without requiring React.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add @bible-strong/avatar-web
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
import definition from './cloudee.avatar.json'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
defaultAnimation: 'idle',
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.play('happy')
|
||||||
|
avatar.pause()
|
||||||
|
avatar.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
For a browser project without a bundler, load an ESM build through an import map or CDN and fetch
|
||||||
|
the definition JSON before calling `createAvatar`.
|
||||||
|
|
||||||
|
This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
|
||||||
|
are confirmed. Local tarballs are for verification only and must not be published.
|
||||||
34
packages/avatar-web/package.json
Normal file
34
packages/avatar-web/package.json
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
{
|
||||||
|
"name": "@bible-strong/avatar-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Framework-independent DOM renderer for Bible Strong procedural avatars.",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bible-strong/avatar-core": "workspace:^"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/avatar-web/src/__tests__/avatar-web-test.ts
Normal file
49
packages/avatar-web/src/__tests__/avatar-web-test.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import { createAvatar } from '../index'
|
||||||
|
|
||||||
|
describe('@bible-strong/avatar-web', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="avatar"></div>'
|
||||||
|
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||||
|
vi.stubGlobal('matchMedia', () => ({ matches: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('mounts the shared avatar definition without React', () => {
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition: definitionJson,
|
||||||
|
defaultExpression: 'neutral',
|
||||||
|
size: 180,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.querySelector('#avatar svg')).not.toBeNull()
|
||||||
|
expect(document.querySelectorAll('#avatar svg > path')).toHaveLength(37)
|
||||||
|
expect(document.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe(
|
||||||
|
'Procedural avatar'
|
||||||
|
)
|
||||||
|
expect(avatar.getState()).toMatchObject({
|
||||||
|
activeExpression: 'neutral',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.destroy()
|
||||||
|
expect(document.querySelector('#avatar svg')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns typed errors for unknown targets', () => {
|
||||||
|
const avatar = createAvatar('#avatar', { definition: definitionJson })
|
||||||
|
|
||||||
|
expect(avatar.play('missing')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: expect.objectContaining({ code: 'unknown_animation', key: 'missing' }),
|
||||||
|
})
|
||||||
|
expect(avatar.setExpression('missing')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: expect.objectContaining({ code: 'unknown_expression', key: 'missing' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
304
packages/avatar-web/src/index.ts
Normal file
304
packages/avatar-web/src/index.ts
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
advanceAvatarPlayback,
|
||||||
|
createAvatarPlaybackState,
|
||||||
|
MAX_BODY_NODES,
|
||||||
|
pauseAvatarPlayback,
|
||||||
|
playAvatarAnimation,
|
||||||
|
renderAvatarDefinition,
|
||||||
|
renderAvatarFrame,
|
||||||
|
resolveAnimation,
|
||||||
|
resolveExpression,
|
||||||
|
resumeAvatarPlayback,
|
||||||
|
sampleAvatarFrame,
|
||||||
|
validateAvatarDefinition,
|
||||||
|
type AnimationKey,
|
||||||
|
type AvatarDefinition,
|
||||||
|
type AvatarPlaybackState as CorePlaybackState,
|
||||||
|
type AvatarRuntimeError,
|
||||||
|
type ExpressionKey,
|
||||||
|
} from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
|
||||||
|
|
||||||
|
export type AvatarPlaybackState = Pick<
|
||||||
|
CorePlaybackState,
|
||||||
|
'activeAnimation' | 'activeExpression' | 'status'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type AvatarController = {
|
||||||
|
play(animation: AnimationKey): AvatarCommandResult
|
||||||
|
setExpression(expression: ExpressionKey): AvatarCommandResult
|
||||||
|
pause(): void
|
||||||
|
stop(): void
|
||||||
|
getState(): AvatarPlaybackState
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAvatarOptions = {
|
||||||
|
definition: unknown
|
||||||
|
defaultAnimation?: AnimationKey
|
||||||
|
defaultExpression?: ExpressionKey
|
||||||
|
autoplay?: boolean
|
||||||
|
size?: number | string
|
||||||
|
ariaLabel?: string
|
||||||
|
className?: string
|
||||||
|
onError?: (error: AvatarRuntimeError) => void
|
||||||
|
onAnimationEnd?: (animation: AnimationKey) => void
|
||||||
|
onExpressionChange?: (expression: ExpressionKey) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgNamespace = 'http://www.w3.org/2000/svg'
|
||||||
|
const controlledExpressionTransitionMs = 420
|
||||||
|
const bodyPathSlots = MAX_BODY_NODES + 2
|
||||||
|
let avatarInstanceId = 0
|
||||||
|
|
||||||
|
const dimension = (size: number | string) => (typeof size === 'number' ? `${size}px` : size)
|
||||||
|
|
||||||
|
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
|
||||||
|
const first = errors[0]
|
||||||
|
return new Error(
|
||||||
|
first
|
||||||
|
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
|
||||||
|
: 'Invalid avatar definition.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTarget = (target: string | HTMLElement) => {
|
||||||
|
const element = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target
|
||||||
|
if (!element) throw new Error(`Avatar target '${target}' was not found.`)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSvgElement = <Name extends keyof SVGElementTagNameMap>(name: Name) =>
|
||||||
|
document.createElementNS(svgNamespace, name)
|
||||||
|
|
||||||
|
const playbackSnapshot = (state: CorePlaybackState): AvatarPlaybackState => ({
|
||||||
|
...(state.activeAnimation ? { activeAnimation: state.activeAnimation } : {}),
|
||||||
|
activeExpression: state.activeExpression,
|
||||||
|
status: state.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
const runtimeEnvironment = () => ({
|
||||||
|
random: Math.random,
|
||||||
|
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function createAvatar(
|
||||||
|
target: string | HTMLElement,
|
||||||
|
{
|
||||||
|
definition: input,
|
||||||
|
defaultAnimation,
|
||||||
|
defaultExpression,
|
||||||
|
autoplay = true,
|
||||||
|
size = 240,
|
||||||
|
ariaLabel = 'Procedural avatar',
|
||||||
|
className,
|
||||||
|
onError,
|
||||||
|
onAnimationEnd,
|
||||||
|
onExpressionChange,
|
||||||
|
}: CreateAvatarOptions
|
||||||
|
): AvatarController {
|
||||||
|
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
|
||||||
|
throw new Error('Choose either defaultAnimation or defaultExpression, not both.')
|
||||||
|
}
|
||||||
|
const validated = validateAvatarDefinition(input)
|
||||||
|
if (!validated.ok) throw invalidDefinitionError(validated.errors)
|
||||||
|
const definition: Readonly<AvatarDefinition> = validated.value
|
||||||
|
const mount = resolveTarget(target)
|
||||||
|
const host = document.createElement('span')
|
||||||
|
host.className = ['bs-avatar', className ?? ''].filter(Boolean).join(' ')
|
||||||
|
host.style.display = 'inline-block'
|
||||||
|
host.style.width = dimension(size)
|
||||||
|
host.style.height = dimension(size)
|
||||||
|
host.setAttribute('role', 'img')
|
||||||
|
host.setAttribute('aria-label', ariaLabel)
|
||||||
|
|
||||||
|
const svg = createSvgElement('svg')
|
||||||
|
svg.setAttribute('viewBox', '-150 -150 300 300')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
svg.style.display = 'block'
|
||||||
|
svg.style.width = '100%'
|
||||||
|
svg.style.height = '100%'
|
||||||
|
const defs = createSvgElement('defs')
|
||||||
|
const clipPath = createSvgElement('clipPath')
|
||||||
|
const clipId = `bs-avatar-web-${++avatarInstanceId}`
|
||||||
|
clipPath.id = clipId
|
||||||
|
const clipHeadPath = createSvgElement('path')
|
||||||
|
clipPath.append(clipHeadPath)
|
||||||
|
defs.append(clipPath)
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const initialScene = renderAvatarDefinition(definition)
|
||||||
|
const backPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
|
||||||
|
const headPath = createSvgElement('path')
|
||||||
|
const eyeGroup = createSvgElement('g')
|
||||||
|
eyeGroup.setAttribute('clip-path', `url(#${clipId})`)
|
||||||
|
const leftPath = createSvgElement('path')
|
||||||
|
const rightPath = createSvgElement('path')
|
||||||
|
eyeGroup.append(leftPath, rightPath)
|
||||||
|
const frontPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
|
||||||
|
svg.append(...backPaths, headPath, eyeGroup, ...frontPaths)
|
||||||
|
host.append(svg)
|
||||||
|
mount.append(host)
|
||||||
|
|
||||||
|
const reportError = (error: AvatarRuntimeError) => {
|
||||||
|
if (onError) onError(error)
|
||||||
|
else console.error(`[Avatar] ${error.message}`)
|
||||||
|
}
|
||||||
|
const paint = (scene: ReturnType<typeof renderAvatarDefinition>) => {
|
||||||
|
clipHeadPath.setAttribute('d', scene.geometry.headPath)
|
||||||
|
headPath.setAttribute('d', scene.geometry.headPath)
|
||||||
|
headPath.setAttribute('fill', scene.colors.body)
|
||||||
|
leftPath.setAttribute('d', scene.geometry.leftPath)
|
||||||
|
leftPath.setAttribute('fill', scene.colors.eyes)
|
||||||
|
leftPath.setAttribute('opacity', scene.geometry.leftVisible ? '1' : '0')
|
||||||
|
rightPath.setAttribute('d', scene.geometry.rightPath)
|
||||||
|
rightPath.setAttribute('fill', scene.colors.eyes)
|
||||||
|
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
||||||
|
backPaths.forEach((element, index) => {
|
||||||
|
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
|
||||||
|
element.setAttribute('fill', scene.colors.body)
|
||||||
|
})
|
||||||
|
frontPaths.forEach((element, index) => {
|
||||||
|
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
|
||||||
|
element.setAttribute('fill', scene.colors.body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let playback = createAvatarPlaybackState()
|
||||||
|
let frameRequest: number | null = null
|
||||||
|
let destroyed = false
|
||||||
|
let completedAnimation: AnimationKey | undefined
|
||||||
|
let lastExpression: ExpressionKey | undefined
|
||||||
|
let paintedFrame: ReturnType<typeof sampleAvatarFrame> | undefined
|
||||||
|
|
||||||
|
const notifyExpression = () => {
|
||||||
|
if (lastExpression === playback.activeExpression) return
|
||||||
|
lastExpression = playback.activeExpression
|
||||||
|
onExpressionChange?.(playback.activeExpression)
|
||||||
|
}
|
||||||
|
const renderCurrent = (now: number) => {
|
||||||
|
const environment = runtimeEnvironment()
|
||||||
|
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
|
||||||
|
paint(renderAvatarFrame(definition, playback, now, environment))
|
||||||
|
notifyExpression()
|
||||||
|
}
|
||||||
|
const tick = (now: number) => {
|
||||||
|
frameRequest = null
|
||||||
|
if (destroyed) return
|
||||||
|
const currentAnimation = playback.activeAnimation
|
||||||
|
const wasPlaying = playback.status === 'playing'
|
||||||
|
playback = advanceAvatarPlayback(definition, playback, now, {
|
||||||
|
random: Math.random,
|
||||||
|
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
|
||||||
|
})
|
||||||
|
renderCurrent(now)
|
||||||
|
if (wasPlaying && playback.status === 'stopped' && currentAnimation) {
|
||||||
|
if (completedAnimation !== currentAnimation) onAnimationEnd?.(currentAnimation)
|
||||||
|
completedAnimation = currentAnimation
|
||||||
|
}
|
||||||
|
if (playback.status === 'playing') frameRequest = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
const schedule = () => {
|
||||||
|
if (frameRequest === null && !destroyed) frameRequest = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller: AvatarController = {
|
||||||
|
play(animation) {
|
||||||
|
if (
|
||||||
|
playback.status === 'paused' &&
|
||||||
|
playback.activeAnimation === animation &&
|
||||||
|
playback.pausedAt !== undefined
|
||||||
|
) {
|
||||||
|
playback = resumeAvatarPlayback(playback, performance.now())
|
||||||
|
schedule()
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
|
||||||
|
const result = playAvatarAnimation(definition, animation, now, from)
|
||||||
|
if (!result.ok) return { ok: false, error: result.error }
|
||||||
|
completedAnimation = undefined
|
||||||
|
playback = result.value
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
schedule()
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
setExpression(expression) {
|
||||||
|
const result = resolveExpression(definition, expression)
|
||||||
|
if (!result.ok) return { ok: false, error: result.error }
|
||||||
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
|
||||||
|
playback = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: expression,
|
||||||
|
...(playback.activeExpression === expression
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from,
|
||||||
|
startedAt: now,
|
||||||
|
durationMs: controlledExpressionTransitionMs,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
if (playback.status === 'playing') schedule()
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
if (playback.status !== 'playing') return
|
||||||
|
playback = pauseAvatarPlayback(playback, performance.now())
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
|
frameRequest = null
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
playback = createAvatarPlaybackState()
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
|
frameRequest = null
|
||||||
|
paint(renderAvatarDefinition(definition))
|
||||||
|
notifyExpression()
|
||||||
|
},
|
||||||
|
getState() {
|
||||||
|
return playbackSnapshot(playback)
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
|
frameRequest = null
|
||||||
|
host.remove()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultAnimation !== undefined) {
|
||||||
|
const resolved = resolveAnimation(definition, defaultAnimation)
|
||||||
|
if (!resolved.ok) reportError(resolved.error)
|
||||||
|
else if (autoplay) controller.play(defaultAnimation)
|
||||||
|
else {
|
||||||
|
playback = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: resolved.value.steps[0]?.expression ?? 'neutral',
|
||||||
|
}
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
}
|
||||||
|
} else if (defaultExpression !== undefined) {
|
||||||
|
const resolved = resolveExpression(definition, defaultExpression)
|
||||||
|
if (!resolved.ok) reportError(resolved.error)
|
||||||
|
else {
|
||||||
|
playback = { ...createAvatarPlaybackState(), activeExpression: defaultExpression }
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paint(initialScene)
|
||||||
|
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
|
||||||
|
notifyExpression()
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AnimationKey, AvatarDefinition, AvatarRuntimeError, ExpressionKey }
|
||||||
12
packages/avatar-web/tsconfig.build.json
Normal file
12
packages/avatar-web/tsconfig.build.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
14
packages/avatar-web/tsconfig.json
Normal file
14
packages/avatar-web/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"],
|
||||||
|
"exclude": ["src/**/__tests__/**"]
|
||||||
|
}
|
||||||
17
packages/avatar-web/vite.config.ts
Normal file
17
packages/avatar-web/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: 'index',
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['@bible-strong/avatar-core'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
26
pnpm-lock.yaml
generated
26
pnpm-lock.yaml
generated
@ -134,6 +134,19 @@ importers:
|
|||||||
specifier: ^19.2.3
|
specifier: ^19.2.3
|
||||||
version: 19.2.4(@types/react@19.2.18)
|
version: 19.2.4(@types/react@19.2.18)
|
||||||
|
|
||||||
|
examples/web-vite-consumer:
|
||||||
|
dependencies:
|
||||||
|
'@bible-strong/avatar-web':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../../packages/avatar-web
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ~6.0.3
|
||||||
|
version: 6.0.3
|
||||||
|
vite:
|
||||||
|
specifier: ^8.0.13
|
||||||
|
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||||
|
|
||||||
packages/avatar-core:
|
packages/avatar-core:
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv:
|
ajv:
|
||||||
@ -172,6 +185,19 @@ importers:
|
|||||||
specifier: ^8.0.13
|
specifier: ^8.0.13
|
||||||
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||||
|
|
||||||
|
packages/avatar-web:
|
||||||
|
dependencies:
|
||||||
|
'@bible-strong/avatar-core':
|
||||||
|
specifier: workspace:^
|
||||||
|
version: link:../avatar-core
|
||||||
|
devDependencies:
|
||||||
|
typescript:
|
||||||
|
specifier: ~6.0.3
|
||||||
|
version: 6.0.3
|
||||||
|
vite:
|
||||||
|
specifier: ^8.0.13
|
||||||
|
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||||
|
|
||||||
packages:
|
packages:
|
||||||
|
|
||||||
'@asamuzakjp/css-color@6.0.7':
|
'@asamuzakjp/css-color@6.0.7':
|
||||||
|
|||||||
@ -29,6 +29,7 @@ const pack = packageName => {
|
|||||||
}
|
}
|
||||||
const coreTarball = pack('avatar-core')
|
const coreTarball = pack('avatar-core')
|
||||||
const reactTarball = pack('avatar-react')
|
const reactTarball = pack('avatar-react')
|
||||||
|
const webTarball = pack('avatar-web')
|
||||||
|
|
||||||
await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
|
await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
|
||||||
recursive: true,
|
recursive: true,
|
||||||
@ -44,4 +45,29 @@ await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
|
|||||||
|
|
||||||
run(['install', '--no-frozen-lockfile'], consumer)
|
run(['install', '--no-frozen-lockfile'], consumer)
|
||||||
run(['build'], consumer)
|
run(['build'], consumer)
|
||||||
process.stdout.write(`Tarball consumer verified at ${consumer}\n`)
|
|
||||||
|
const webConsumer = path.join(workspace, 'web-consumer')
|
||||||
|
await cp(path.join(root, 'examples/web-vite-consumer'), webConsumer, {
|
||||||
|
recursive: true,
|
||||||
|
filter: source =>
|
||||||
|
!source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
|
||||||
|
})
|
||||||
|
await cp(
|
||||||
|
path.join(root, 'examples/react-vite-consumer/src/strobi.avatar.json'),
|
||||||
|
path.join(webConsumer, 'src/strobi.avatar.json')
|
||||||
|
)
|
||||||
|
const webMainPath = path.join(webConsumer, 'src/main.ts')
|
||||||
|
const webMain = await readFile(webMainPath, 'utf8')
|
||||||
|
await writeFile(
|
||||||
|
webMainPath,
|
||||||
|
webMain.replace('../../react-vite-consumer/src/strobi.avatar.json', './strobi.avatar.json')
|
||||||
|
)
|
||||||
|
const webPackagePath = path.join(webConsumer, 'package.json')
|
||||||
|
const webPackageJson = JSON.parse(await readFile(webPackagePath, 'utf8'))
|
||||||
|
webPackageJson.dependencies['@bible-strong/avatar-web'] = `file:${webTarball}`
|
||||||
|
webPackageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
|
||||||
|
await writeFile(webPackagePath, `${JSON.stringify(webPackageJson, null, 2)}\n`)
|
||||||
|
|
||||||
|
run(['install', '--no-frozen-lockfile'], webConsumer)
|
||||||
|
run(['build'], webConsumer)
|
||||||
|
process.stdout.write(`Tarball consumers verified at ${consumer} and ${webConsumer}\n`)
|
||||||
|
|||||||
@ -389,41 +389,29 @@ input {
|
|||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.runtime-readiness {
|
.runtime-export-error {
|
||||||
display: grid;
|
display: flex;
|
||||||
gap: 10px;
|
flex-direction: column;
|
||||||
border-color: color-mix(in srgb, var(--destructive) 32%, var(--border));
|
gap: 8px;
|
||||||
|
padding: 10px;
|
||||||
|
border: 1px solid color-mix(in srgb, var(--destructive) 32%, var(--border));
|
||||||
|
border-radius: 10px;
|
||||||
background: color-mix(in srgb, var(--destructive) 5%, var(--card));
|
background: color-mix(in srgb, var(--destructive) 5%, var(--card));
|
||||||
}
|
}
|
||||||
.runtime-readiness[data-ready='true'] {
|
.runtime-export-error-heading {
|
||||||
border-color: #b9d8c6;
|
|
||||||
background: #f1faf5;
|
|
||||||
}
|
|
||||||
.runtime-readiness-heading {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: center;
|
||||||
gap: 10px;
|
gap: 8px;
|
||||||
}
|
}
|
||||||
.runtime-readiness-heading > svg {
|
.runtime-export-error-heading > svg {
|
||||||
width: 18px;
|
width: 18px;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
flex: none;
|
flex: none;
|
||||||
color: var(--destructive);
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
.runtime-readiness[data-ready='true'] .runtime-readiness-heading > svg {
|
.runtime-export-error-heading strong {
|
||||||
color: #278453;
|
|
||||||
}
|
|
||||||
.runtime-readiness-heading > div {
|
|
||||||
display: grid;
|
|
||||||
gap: 2px;
|
|
||||||
}
|
|
||||||
.runtime-readiness-heading strong {
|
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
}
|
}
|
||||||
.runtime-readiness-heading small {
|
|
||||||
color: var(--muted-foreground);
|
|
||||||
font-size: 10px;
|
|
||||||
}
|
|
||||||
.runtime-error-list {
|
.runtime-error-list {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 5px;
|
gap: 5px;
|
||||||
@ -433,6 +421,17 @@ input {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
line-height: 1.45;
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
|
.runtime-export-error-help {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.runtime-export-error-actions {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-avatar-summary strong {
|
.export-avatar-summary strong {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@ -477,25 +476,41 @@ input {
|
|||||||
}
|
}
|
||||||
.export-format-grid strong,
|
.export-format-grid strong,
|
||||||
.export-format-grid small {
|
.export-format-grid small {
|
||||||
overflow: hidden;
|
line-height: 1.3;
|
||||||
text-overflow: ellipsis;
|
text-wrap: pretty;
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
.export-format-grid strong {
|
.export-format-grid strong {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
.export-format-grid small {
|
.export-format-grid small {
|
||||||
font-size: 9px;
|
font-size: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-animation-header {
|
.export-animation-header {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.export-animation-header > button {
|
.export-animation-header > button {
|
||||||
color: #4168d5;
|
color: #4168d5;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
}
|
}
|
||||||
|
.export-animation-header > button > svg {
|
||||||
|
width: 15px;
|
||||||
|
height: 15px;
|
||||||
|
}
|
||||||
|
.export-animation-picker {
|
||||||
|
display: grid;
|
||||||
|
gap: 10px;
|
||||||
|
margin-top: 12px;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.export-animation-select-all {
|
||||||
|
justify-self: end;
|
||||||
|
color: #4168d5;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
.export-animation-grid {
|
.export-animation-grid {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
@ -523,36 +538,42 @@ input {
|
|||||||
.runtime-copy-status[role='alert'] {
|
.runtime-copy-status[role='alert'] {
|
||||||
color: var(--destructive);
|
color: var(--destructive);
|
||||||
}
|
}
|
||||||
.runtime-quickstart {
|
.runtime-example-card {
|
||||||
display: grid;
|
display: grid;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
}
|
}
|
||||||
.runtime-quickstart > button {
|
.runtime-export-description {
|
||||||
min-height: 44px;
|
margin: 0;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.runtime-code-example {
|
.runtime-example-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: stretch;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.runtime-example-heading > div:first-child {
|
||||||
display: grid;
|
display: grid;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
gap: 5px;
|
gap: 2px;
|
||||||
}
|
}
|
||||||
.runtime-code-example > small {
|
.runtime-example-heading small {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: 10px;
|
font-size: 10px;
|
||||||
font-weight: 700;
|
|
||||||
}
|
}
|
||||||
.runtime-code-example pre {
|
.runtime-example-heading strong {
|
||||||
max-width: 100%;
|
|
||||||
margin: 0;
|
|
||||||
overflow-x: auto;
|
|
||||||
padding: 10px;
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 9px;
|
|
||||||
background: #17191d;
|
|
||||||
color: #d8dee9;
|
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
font-size: 9px;
|
font-size: 11px;
|
||||||
line-height: 1.55;
|
overflow-wrap: anywhere;
|
||||||
white-space: pre;
|
}
|
||||||
|
.runtime-example-actions {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.runtime-token-comment {
|
.runtime-token-comment {
|
||||||
color: #a6adbb;
|
color: #a6adbb;
|
||||||
@ -1074,7 +1095,9 @@ input {
|
|||||||
}
|
}
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.studio-workspace,
|
.studio-workspace,
|
||||||
.body-workspace {
|
.body-workspace,
|
||||||
|
.dialog-backdrop,
|
||||||
|
.dialog-content {
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1631,6 +1654,410 @@ p {
|
|||||||
background: transparent;
|
background: transparent;
|
||||||
animation: drawer-backdrop-in 180ms ease-out;
|
animation: drawer-backdrop-in 180ms ease-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dialog-backdrop {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 50;
|
||||||
|
inset: 0;
|
||||||
|
background: rgb(18 22 30 / 48%);
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
animation: dialog-backdrop-in 180ms ease-out;
|
||||||
|
}
|
||||||
|
.dialog-content {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 51;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
display: flex;
|
||||||
|
width: min(760px, calc(100vw - 32px));
|
||||||
|
max-height: min(86dvh, 820px);
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
border: 1px solid #d9dce3;
|
||||||
|
border-radius: 24px;
|
||||||
|
outline: 0;
|
||||||
|
background: #f5f6f8;
|
||||||
|
box-shadow: 0 28px 90px rgb(12 16 24 / 28%);
|
||||||
|
animation: dialog-content-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
|
||||||
|
}
|
||||||
|
.dialog-header {
|
||||||
|
position: relative;
|
||||||
|
display: grid;
|
||||||
|
gap: 5px;
|
||||||
|
padding: 24px 28px 18px;
|
||||||
|
border-bottom: 1px solid #dfe2e8;
|
||||||
|
background: rgb(255 255 255 / 76%);
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
margin: 0;
|
||||||
|
color: #17191d;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 820;
|
||||||
|
letter-spacing: -0.045em;
|
||||||
|
}
|
||||||
|
.dialog-description {
|
||||||
|
max-width: 620px;
|
||||||
|
margin: 0;
|
||||||
|
color: #7c838e;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.avatar-guide-dialog .avatar-guide-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
}
|
||||||
|
.runtime-preview-dialog {
|
||||||
|
width: min(1080px, calc(100vw - 32px));
|
||||||
|
max-height: min(90dvh, 880px);
|
||||||
|
}
|
||||||
|
.runtime-preview-dialog .runtime-preview-close {
|
||||||
|
position: absolute;
|
||||||
|
top: 18px;
|
||||||
|
right: 18px;
|
||||||
|
}
|
||||||
|
.runtime-preview-body {
|
||||||
|
display: grid;
|
||||||
|
min-height: 0;
|
||||||
|
grid-template-columns: minmax(0, 1.08fr) minmax(300px, 0.92fr);
|
||||||
|
gap: 14px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 20px 22px;
|
||||||
|
}
|
||||||
|
.runtime-preview-stage-card,
|
||||||
|
.runtime-preview-controls > section {
|
||||||
|
border: 1px solid #dfe2e8;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: rgb(255 255 255 / 82%);
|
||||||
|
}
|
||||||
|
.runtime-preview-stage-card {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
.runtime-preview-stage-heading,
|
||||||
|
.runtime-preview-control-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.runtime-preview-stage-heading > div,
|
||||||
|
.runtime-preview-control-heading > div {
|
||||||
|
display: grid;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
.runtime-preview-stage-heading small,
|
||||||
|
.runtime-preview-control-heading small,
|
||||||
|
.runtime-preview-current small {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.runtime-preview-stage-heading strong {
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.runtime-preview-status {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 7px;
|
||||||
|
min-height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid #dce1ea;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: #687180;
|
||||||
|
background: #f8f9fb;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 750;
|
||||||
|
}
|
||||||
|
.runtime-preview-status > span {
|
||||||
|
width: 6px;
|
||||||
|
height: 6px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #7d8796;
|
||||||
|
}
|
||||||
|
.runtime-preview-status[data-status='playing'] > span {
|
||||||
|
background: #45b878;
|
||||||
|
box-shadow: 0 0 0 4px rgb(69 184 120 / 11%);
|
||||||
|
}
|
||||||
|
.runtime-preview-status[data-status='paused'] > span {
|
||||||
|
background: #d99b35;
|
||||||
|
box-shadow: 0 0 0 4px rgb(217 155 53 / 11%);
|
||||||
|
}
|
||||||
|
.runtime-preview-stage {
|
||||||
|
display: grid;
|
||||||
|
min-height: 360px;
|
||||||
|
place-items: center;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #dce1e9;
|
||||||
|
border-radius: 16px;
|
||||||
|
background:
|
||||||
|
linear-gradient(rgb(92 113 160 / 7%) 1px, transparent 1px),
|
||||||
|
linear-gradient(90deg, rgb(92 113 160 / 7%) 1px, transparent 1px), #f4f6fa;
|
||||||
|
background-size: 20px 20px;
|
||||||
|
}
|
||||||
|
.runtime-preview-current {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.runtime-preview-current > div {
|
||||||
|
display: grid;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 3px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #e0e4eb;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #f9fafc;
|
||||||
|
}
|
||||||
|
.runtime-preview-current strong {
|
||||||
|
overflow: hidden;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.runtime-preview-transport {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
}
|
||||||
|
.runtime-preview-transport > button {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
.runtime-preview-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--destructive);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.runtime-preview-controls {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.runtime-preview-controls > section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.runtime-preview-control-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.runtime-preview-control-heading > span {
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.runtime-preview-option-grid {
|
||||||
|
display: grid;
|
||||||
|
max-height: 224px;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 7px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 2px;
|
||||||
|
}
|
||||||
|
.runtime-preview-option-grid > button {
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
padding-inline: 10px;
|
||||||
|
}
|
||||||
|
.runtime-preview-option-grid > button > span {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.runtime-preview-option-grid > button[aria-pressed='true'] {
|
||||||
|
border-color: #7894e7;
|
||||||
|
background: #edf2ff;
|
||||||
|
box-shadow: 0 0 0 2px rgb(91 127 229 / 12%);
|
||||||
|
color: #3159cb;
|
||||||
|
}
|
||||||
|
.runtime-preview-expression-grid {
|
||||||
|
max-height: 250px;
|
||||||
|
}
|
||||||
|
.runtime-preview-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--muted-foreground);
|
||||||
|
border: 1px dashed #d8dde6;
|
||||||
|
border-radius: 11px;
|
||||||
|
font-size: 11px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
.avatar-guide-kicker {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
color: #4168d5;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.avatar-guide-kicker > svg {
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
||||||
|
.avatar-guide-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 18px 28px 24px;
|
||||||
|
}
|
||||||
|
.avatar-guide-section {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 16px;
|
||||||
|
border: 1px solid #dfe2e8;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: rgb(255 255 255 / 76%);
|
||||||
|
}
|
||||||
|
.avatar-guide-section--accent {
|
||||||
|
border-color: #c5d2fb;
|
||||||
|
background: linear-gradient(135deg, #f0f3ff, #ffffff);
|
||||||
|
}
|
||||||
|
.avatar-guide-section--api {
|
||||||
|
gap: 16px;
|
||||||
|
background: #fbfcfe;
|
||||||
|
}
|
||||||
|
.avatar-guide-section-heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
.avatar-guide-section-heading > svg {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
flex: none;
|
||||||
|
margin-top: 1px;
|
||||||
|
color: #4168d5;
|
||||||
|
}
|
||||||
|
.avatar-guide-section-heading h3 {
|
||||||
|
margin: 0;
|
||||||
|
color: #262a31;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 800;
|
||||||
|
letter-spacing: -0.02em;
|
||||||
|
}
|
||||||
|
.avatar-guide-section-heading p {
|
||||||
|
margin: 4px 0 0;
|
||||||
|
color: #717985;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.55;
|
||||||
|
}
|
||||||
|
.avatar-guide-section-heading code {
|
||||||
|
padding: 1px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: #edf1ff;
|
||||||
|
color: #3159cb;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
}
|
||||||
|
.avatar-guide-notice {
|
||||||
|
margin: 0;
|
||||||
|
color: #687281;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.5;
|
||||||
|
text-wrap: pretty;
|
||||||
|
}
|
||||||
|
.avatar-guide-section-heading--nested {
|
||||||
|
margin-top: 2px;
|
||||||
|
padding-top: 14px;
|
||||||
|
border-top: 1px solid #e6e9ef;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop-group {
|
||||||
|
display: grid;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop-group > strong {
|
||||||
|
color: #4d5665;
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop-group dl {
|
||||||
|
display: grid;
|
||||||
|
margin: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid #e0e4eb;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #ffffff;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
|
||||||
|
gap: 14px;
|
||||||
|
padding: 9px 11px;
|
||||||
|
border-bottom: 1px solid #edf0f4;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop:last-child {
|
||||||
|
border-bottom: 0;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop dt,
|
||||||
|
.avatar-guide-prop dd {
|
||||||
|
min-width: 0;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop dt {
|
||||||
|
display: grid;
|
||||||
|
align-content: start;
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop dt code {
|
||||||
|
color: #3159cb;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 700;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop-type {
|
||||||
|
color: #8a93a1;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 10px;
|
||||||
|
line-height: 1.35;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop dd {
|
||||||
|
color: #687281;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.avatar-guide-code {
|
||||||
|
max-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 12px 14px;
|
||||||
|
border: 1px solid #2d323b;
|
||||||
|
border-radius: 11px;
|
||||||
|
background: #17191d;
|
||||||
|
color: #d8dee9;
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1.6;
|
||||||
|
white-space: pre;
|
||||||
|
}
|
||||||
|
.avatar-guide-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 14px 28px 20px;
|
||||||
|
border-top: 1px solid #dfe2e8;
|
||||||
|
background: rgb(255 255 255 / 76%);
|
||||||
|
}
|
||||||
|
@keyframes dialog-backdrop-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes dialog-content-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, calc(-50% + 12px)) scale(0.98);
|
||||||
|
}
|
||||||
|
}
|
||||||
.drawer-viewport {
|
.drawer-viewport {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 41;
|
z-index: 41;
|
||||||
@ -1810,6 +2237,14 @@ p {
|
|||||||
.render-style-field > [data-slot='select-trigger'] {
|
.render-style-field > [data-slot='select-trigger'] {
|
||||||
width: 150px;
|
width: 150px;
|
||||||
}
|
}
|
||||||
|
.render-style-disabled {
|
||||||
|
display: grid;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.render-style-status {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
.pixel-render-options {
|
.pixel-render-options {
|
||||||
display: grid;
|
display: grid;
|
||||||
margin-top: 14px;
|
margin-top: 14px;
|
||||||
@ -2326,7 +2761,7 @@ p {
|
|||||||
padding: 2px 4px;
|
padding: 2px 4px;
|
||||||
border-radius: 5px;
|
border-radius: 5px;
|
||||||
background: rgb(255 255 255 / 78%);
|
background: rgb(255 255 255 / 78%);
|
||||||
font-size: 8px;
|
font-size: 9px;
|
||||||
font-weight: 850;
|
font-weight: 850;
|
||||||
}
|
}
|
||||||
.runtime-key-missing {
|
.runtime-key-missing {
|
||||||
@ -2698,6 +3133,55 @@ p {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
|
.dialog-content {
|
||||||
|
width: calc(100vw - 20px);
|
||||||
|
max-height: 90dvh;
|
||||||
|
border-radius: 20px;
|
||||||
|
}
|
||||||
|
.dialog-header {
|
||||||
|
padding: 20px 18px 16px;
|
||||||
|
}
|
||||||
|
.dialog-title {
|
||||||
|
max-width: calc(100% - 32px);
|
||||||
|
font-size: 20px;
|
||||||
|
}
|
||||||
|
.avatar-guide-body {
|
||||||
|
padding: 14px 14px 18px;
|
||||||
|
}
|
||||||
|
.avatar-guide-section {
|
||||||
|
padding: 13px;
|
||||||
|
}
|
||||||
|
.avatar-guide-prop {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
gap: 3px;
|
||||||
|
}
|
||||||
|
.avatar-guide-footer {
|
||||||
|
padding: 12px 18px 16px;
|
||||||
|
}
|
||||||
|
.runtime-preview-dialog {
|
||||||
|
width: min(100vw - 20px, 620px);
|
||||||
|
max-height: calc(100dvh - 20px);
|
||||||
|
}
|
||||||
|
.runtime-preview-body {
|
||||||
|
grid-template-columns: minmax(0, 1fr);
|
||||||
|
padding: 14px;
|
||||||
|
}
|
||||||
|
.runtime-preview-stage {
|
||||||
|
min-height: min(64vw, 300px);
|
||||||
|
}
|
||||||
|
.runtime-preview-transport {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
.runtime-preview-option-grid {
|
||||||
|
max-height: none;
|
||||||
|
}
|
||||||
|
.runtime-example-heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.runtime-example-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
html,
|
html,
|
||||||
body,
|
body,
|
||||||
#root {
|
#root {
|
||||||
|
|||||||
37
src/components/ui/dialog.tsx
Normal file
37
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Dialog(props: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose(props: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Backdrop className="dialog-backdrop" />
|
||||||
|
<DialogPrimitive.Popup className={cn('dialog-content', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('dialog-header', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return <DialogPrimitive.Title className={cn('dialog-title', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||||
|
return <DialogPrimitive.Description className={cn('dialog-description', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle }
|
||||||
@ -2,10 +2,7 @@ import {
|
|||||||
AVATAR_DEFINITION_MAX_BYTES,
|
AVATAR_DEFINITION_MAX_BYTES,
|
||||||
avatarDefinitionFileName,
|
avatarDefinitionFileName,
|
||||||
createAvatarDefinition,
|
createAvatarDefinition,
|
||||||
getStandardAnimationAvailabilityV1,
|
|
||||||
parseAvatarDefinition,
|
parseAvatarDefinition,
|
||||||
STANDARD_ANIMATIONS_V1,
|
|
||||||
STANDARD_ANIMATION_ORDER_V1,
|
|
||||||
validateAvatarDefinition,
|
validateAvatarDefinition,
|
||||||
type AvatarDefinition,
|
type AvatarDefinition,
|
||||||
} from '@/features/avatar/avatarDefinition'
|
} from '@/features/avatar/avatarDefinition'
|
||||||
@ -117,8 +114,8 @@ describe('avatar definition validation', () => {
|
|||||||
expect(result.value).not.toBe(input)
|
expect(result.value).not.toBe(input)
|
||||||
expect(Object.isFrozen(result.value)).toBe(true)
|
expect(Object.isFrozen(result.value)).toBe(true)
|
||||||
expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
|
expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
|
||||||
expect(JSON.parse(JSON.stringify(result.value))).toEqual(input)
|
expect(structuredClone(result.value)).toEqual(input)
|
||||||
expect(validateAvatarDefinition(JSON.parse(JSON.stringify(result.value))).ok).toBe(true)
|
expect(validateAvatarDefinition(structuredClone(result.value)).ok).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
|
it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
|
||||||
@ -491,12 +488,23 @@ describe('Studio to avatar definition conversion', () => {
|
|||||||
'upward-side-glance',
|
'upward-side-glance',
|
||||||
'curious-left',
|
'curious-left',
|
||||||
])
|
])
|
||||||
expect(getStandardAnimationAvailabilityV1(result.value.expressions).available).toEqual(
|
|
||||||
STANDARD_ANIMATION_ORDER_V1
|
|
||||||
)
|
|
||||||
expect(JSON.stringify(result.value)).not.toContain('expression-00')
|
expect(JSON.stringify(result.value)).not.toContain('expression-00')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('exports a valid expression-only definition when no animation is selected', () => {
|
||||||
|
const behavior = behaviorFixture()
|
||||||
|
const result = createAvatarDefinition({
|
||||||
|
avatar: avatarFixture(),
|
||||||
|
behavior: { ...behavior, sequences: [] },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(result.ok).toBe(true)
|
||||||
|
if (!result.ok) return
|
||||||
|
expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
|
||||||
|
expect(result.value.animations).toEqual({})
|
||||||
|
expect(result.value.animationOrder).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
it('rejects newly created custom content until semantic keys are supplied', () => {
|
it('rejects newly created custom content until semantic keys are supplied', () => {
|
||||||
const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
|
const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
|
||||||
const sequence = createSequence(expression.id)
|
const sequence = createSequence(expression.id)
|
||||||
@ -614,101 +622,7 @@ describe('Studio to avatar definition conversion', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('standard animation catalogue v1', () => {
|
describe('runtime definition filenames', () => {
|
||||||
it('commits the approved keys, order, steps, timing, and blink settings', () => {
|
|
||||||
expect(STANDARD_ANIMATION_ORDER_V1).toEqual([
|
|
||||||
'idle',
|
|
||||||
'happy',
|
|
||||||
'sad',
|
|
||||||
'thinking',
|
|
||||||
'excited',
|
|
||||||
'celebrate',
|
|
||||||
])
|
|
||||||
expect(STANDARD_ANIMATIONS_V1.idle).toEqual({
|
|
||||||
requiredExpressions: ['upward-side-glance', 'curious-left'],
|
|
||||||
playbackMode: 'loop',
|
|
||||||
steps: [
|
|
||||||
{
|
|
||||||
expression: 'upward-side-glance',
|
|
||||||
holdMs: 5200,
|
|
||||||
transitionMs: 500,
|
|
||||||
transition: 'smooth',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
expression: 'curious-left',
|
|
||||||
holdMs: 5200,
|
|
||||||
transitionMs: 500,
|
|
||||||
transition: 'smooth',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
blink: {
|
|
||||||
enabled: true,
|
|
||||||
initialDelayMs: 2600,
|
|
||||||
minIntervalMs: 3400,
|
|
||||||
maxIntervalMs: 6200,
|
|
||||||
durationMs: 280,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
expect(Object.values(STANDARD_ANIMATIONS_V1).every(animation => animation.steps.length)).toBe(
|
|
||||||
true
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
Object.values(STANDARD_ANIMATIONS_V1).every(animation =>
|
|
||||||
animation.steps.every(
|
|
||||||
step =>
|
|
||||||
step.transitionMs === 500 &&
|
|
||||||
step.transition === 'smooth' &&
|
|
||||||
animation.requiredExpressions.includes(step.expression)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
Object.fromEntries(
|
|
||||||
STANDARD_ANIMATION_ORDER_V1.map(key => [
|
|
||||||
key,
|
|
||||||
STANDARD_ANIMATIONS_V1[key].steps.map(step => step.expression),
|
|
||||||
])
|
|
||||||
)
|
|
||||||
).toEqual({
|
|
||||||
idle: ['upward-side-glance', 'curious-left'],
|
|
||||||
happy: ['joyful-down-right', 'joyful-wide', 'playful-right', 'gentle-downward-gaze'],
|
|
||||||
sad: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
|
|
||||||
thinking: [
|
|
||||||
'curious-left',
|
|
||||||
'angry-left',
|
|
||||||
'skeptical-left',
|
|
||||||
'playful-right',
|
|
||||||
'skeptical-right',
|
|
||||||
],
|
|
||||||
excited: [
|
|
||||||
'joyful-down-right',
|
|
||||||
'playful-right',
|
|
||||||
'surprised-wide-left',
|
|
||||||
'surprised-left',
|
|
||||||
'joyful-wide',
|
|
||||||
],
|
|
||||||
celebrate: ['joyful-down-right', 'curious-left', 'playful-right'],
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('reports available standards and exact missing semantic expressions', () => {
|
|
||||||
const definition = definitionFixture()
|
|
||||||
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
|
|
||||||
|
|
||||||
expect(availability.available).toEqual([])
|
|
||||||
expect(availability.unavailable.find(item => item.key === 'idle')).toEqual({
|
|
||||||
key: 'idle',
|
|
||||||
missingExpressions: ['upward-side-glance', 'curious-left'],
|
|
||||||
})
|
|
||||||
|
|
||||||
const idleExpressions = {
|
|
||||||
...definition.expressions,
|
|
||||||
'upward-side-glance': definition.expressions.neutral,
|
|
||||||
'curious-left': definition.expressions.neutral,
|
|
||||||
}
|
|
||||||
expect(getStandardAnimationAvailabilityV1(idleExpressions).available).toEqual(['idle'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('creates a sanitized runtime-definition filename', () => {
|
it('creates a sanitized runtime-definition filename', () => {
|
||||||
expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
|
expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
|
||||||
expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
|
expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
|
||||||
|
|||||||
@ -52,20 +52,14 @@ describe('avatar render style', () => {
|
|||||||
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
|
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sanitizes pixel settings', () => {
|
it('falls back to vector rendering while pixel mode is disabled', () => {
|
||||||
expect(
|
expect(
|
||||||
parseAvatarRenderStyle({
|
parseAvatarRenderStyle({
|
||||||
type: 'pixel',
|
type: 'pixel',
|
||||||
resolution: 500,
|
resolution: 500,
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({ type: 'vector' })
|
||||||
type: 'pixel',
|
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
|
||||||
resolution: 192,
|
|
||||||
})
|
|
||||||
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
|
|
||||||
type: 'pixel',
|
|
||||||
resolution: 8,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@ -186,7 +186,6 @@ export const createAvatarDefinition = ({
|
|||||||
],
|
],
|
||||||
animations,
|
animations,
|
||||||
animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!),
|
animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!),
|
||||||
standardAnimationSet: 1,
|
|
||||||
}
|
}
|
||||||
return validateAvatarDefinition(definition)
|
return validateAvatarDefinition(definition)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ export type StudioAvatar = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarColors = { body: string; eyes: string }
|
export type AvatarColors = { body: string; eyes: string }
|
||||||
|
export const PIXEL_RENDERING_ENABLED = false
|
||||||
export type PixelRenderStyle = {
|
export type PixelRenderStyle = {
|
||||||
type: 'pixel'
|
type: 'pixel'
|
||||||
resolution: number
|
resolution: number
|
||||||
@ -85,7 +86,9 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
|
|||||||
|
|
||||||
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
||||||
const candidate = value as Partial<PixelRenderStyle> | null
|
const candidate = value as Partial<PixelRenderStyle> | null
|
||||||
if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
|
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
||||||
|
return { ...defaultAvatarRenderStyle }
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'pixel',
|
type: 'pixel',
|
||||||
resolution: Math.round(
|
resolution: Math.round(
|
||||||
|
|||||||
@ -5,10 +5,15 @@ import {
|
|||||||
generateJavaScriptAvatarHtml,
|
generateJavaScriptAvatarHtml,
|
||||||
generateJavaScriptAvatarModule,
|
generateJavaScriptAvatarModule,
|
||||||
generateJavaScriptAvatarPackage,
|
generateJavaScriptAvatarPackage,
|
||||||
|
generateJavaScriptEsmHtml,
|
||||||
|
generateJavaScriptEsmPackage,
|
||||||
generateReactAvatarComponent,
|
generateReactAvatarComponent,
|
||||||
generateReactAvatarPackage,
|
generateReactAvatarPackage,
|
||||||
generateReactAvatarRuntime,
|
generateReactAvatarRuntime,
|
||||||
} from '@/features/export/exporter'
|
} from '@/features/export/exporter'
|
||||||
|
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||||
|
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
|
||||||
|
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||||
import { initialExpressions } from '@/features/avatar/presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
import { createInitialSequences } from '@/features/animation/sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
|
|
||||||
@ -122,6 +127,36 @@ describe('avatar export', () => {
|
|||||||
expect(contents).toContain('avatar.js')
|
expect(contents).toContain('avatar.js')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('generates a lightweight ESM integration backed by avatar-web', async () => {
|
||||||
|
const document = loadStudioDocument({ getItem: () => null })
|
||||||
|
const studioAvatar = document.library.avatars[0]
|
||||||
|
const definition = createAvatarDefinition({
|
||||||
|
avatar: studioAvatar,
|
||||||
|
behavior: resolveAvatarBehavior(studioAvatar, {
|
||||||
|
expressions: document.expressions,
|
||||||
|
sequences: document.sequences,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(definition.ok).toBe(true)
|
||||||
|
if (!definition.ok) return
|
||||||
|
|
||||||
|
const source = generateJavaScriptEsmHtml('strobi.avatar.json', 'Strobi')
|
||||||
|
expect(source).toContain("from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'")
|
||||||
|
expect(source).toContain("fetch('./strobi.avatar.json')")
|
||||||
|
expect(source).not.toContain('AvatarProceduralEngine')
|
||||||
|
|
||||||
|
const archive = new Uint8Array(
|
||||||
|
await generateJavaScriptEsmPackage(definition.value, 'Strobi').arrayBuffer()
|
||||||
|
)
|
||||||
|
const contents = new TextDecoder().decode(archive)
|
||||||
|
expect(contents).toContain('strobi.avatar.json')
|
||||||
|
expect(contents).toContain('index.html')
|
||||||
|
expect(contents).not.toContain('avatar.js')
|
||||||
|
expect(contents).toContain('README.md')
|
||||||
|
expect(contents).toContain('esm.sh/@bible-strong/avatar-web@0.1.0')
|
||||||
|
expect(contents).not.toContain('AvatarProceduralEngine')
|
||||||
|
})
|
||||||
|
|
||||||
it('generates a typed React component backed by the local runtime', () => {
|
it('generates a typed React component backed by the local runtime', () => {
|
||||||
const source = generateReactAvatarComponent(payload)
|
const source = generateReactAvatarComponent(payload)
|
||||||
|
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
||||||
|
import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import type { Expression } from '../avatar/geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
||||||
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
||||||
@ -357,6 +358,120 @@ export const generateJavaScriptAvatarPackage = (
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const generateJavaScriptEsmHtml = (
|
||||||
|
definitionFileName: string,
|
||||||
|
avatarName: string
|
||||||
|
) => `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${escapedHtml(avatarName)} · Avatar demo</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; padding: 32px; color: #f7f8fa; background: #0d1117; font-family: Inter, system-ui, sans-serif; }
|
||||||
|
main { width: min(980px, 100%); margin: auto; }
|
||||||
|
h1 { margin: 0 0 24px; }
|
||||||
|
.demo { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, .8fr); gap: 20px; }
|
||||||
|
.stage, .controls { border: 1px solid #293140; border-radius: 22px; background: #141a22; }
|
||||||
|
.stage { display: grid; min-height: 560px; place-items: center; }
|
||||||
|
#avatar { width: min(80%, 460px); aspect-ratio: 1; }
|
||||||
|
.controls { padding: 20px; }
|
||||||
|
h2 { margin: 0 0 12px; font-size: 15px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
|
||||||
|
button { min-width: 0; padding: 10px; overflow: hidden; color: #dbe3ef; border: 1px solid #354052; border-radius: 10px; background: #1b2330; font: inherit; text-overflow: ellipsis; cursor: pointer; }
|
||||||
|
button:hover, button[aria-pressed="true"] { color: #fff; border-color: #7392ed; background: #263657; }
|
||||||
|
@media (max-width: 760px) { body { padding: 16px; } .demo { grid-template-columns: 1fr; } .stage { min-height: 420px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${escapedHtml(avatarName)}</h1>
|
||||||
|
<div class="demo">
|
||||||
|
<section class="stage"><div id="avatar"></div></section>
|
||||||
|
<aside class="controls">
|
||||||
|
<h2>Animations</h2>
|
||||||
|
<div class="grid" id="animations"></div>
|
||||||
|
<h2>Expressions</h2>
|
||||||
|
<div class="grid" id="expressions"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module">
|
||||||
|
import { createAvatar } from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'
|
||||||
|
|
||||||
|
const definition = await fetch('./${definitionFileName}').then(response => response.json())
|
||||||
|
const avatar = createAvatar('#avatar', { definition, size: '100%' })
|
||||||
|
|
||||||
|
const addButtons = (containerId, keys, activate) => {
|
||||||
|
const container = document.querySelector(containerId)
|
||||||
|
keys.forEach(key => {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.textContent = key
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
container.querySelectorAll('button').forEach(item => item.setAttribute('aria-pressed', 'false'))
|
||||||
|
button.setAttribute('aria-pressed', 'true')
|
||||||
|
activate(key)
|
||||||
|
})
|
||||||
|
container.append(button)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addButtons('#animations', definition.animationOrder, key => avatar.play(key))
|
||||||
|
addButtons('#expressions', definition.expressionOrder, key => avatar.setExpression(key))
|
||||||
|
if (definition.animationOrder[0]) avatar.play(definition.animationOrder[0])
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateJavaScriptEsmReadme = (
|
||||||
|
definitionFileName: string
|
||||||
|
) => `# Avatar JavaScript / ESM
|
||||||
|
|
||||||
|
This export uses the same .avatar.json definition as @bible-strong/avatar-react. The rendering and
|
||||||
|
playback engine is provided by @bible-strong/avatar-web instead of being copied into this export.
|
||||||
|
|
||||||
|
## Run the demo
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
npx serve .
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then open the local URL displayed by the command. index.html loads avatar-web from esm.sh and the
|
||||||
|
exported ${definitionFileName} file from this folder.
|
||||||
|
|
||||||
|
## Use in your application
|
||||||
|
|
||||||
|
\`\`\`js
|
||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
import definition from './${definitionFileName}'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
defaultAnimation: 'idle',
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.play('happy')
|
||||||
|
avatar.pause()
|
||||||
|
avatar.stop()
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The JSON import is intended for a modern ESM build tool such as Vite.
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateJavaScriptEsmPackage = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
avatarName: string
|
||||||
|
) => {
|
||||||
|
const definitionFileName = avatarDefinitionFileName(avatarName)
|
||||||
|
return createStoredZip([
|
||||||
|
{ name: definitionFileName, content: JSON.stringify(definition, null, 2) },
|
||||||
|
{ name: 'index.html', content: generateJavaScriptEsmHtml(definitionFileName, avatarName) },
|
||||||
|
{ name: 'README.md', content: generateJavaScriptEsmReadme(definitionFileName) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
const avatarExportSlug = (name: string) =>
|
const avatarExportSlug = (name: string) =>
|
||||||
name
|
name
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
|
|||||||
@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
|
||||||
|
import { StudioLanguageProvider } from '@/i18n'
|
||||||
|
|
||||||
|
const definitionResult = validateAvatarDefinition(definitionJson)
|
||||||
|
if (!definitionResult.ok) throw new Error('Invalid preview fixture')
|
||||||
|
const definition = definitionResult.value
|
||||||
|
|
||||||
|
const renderPreview = (avatarDefinition: AvatarDefinition, initialAnimation?: string) =>
|
||||||
|
render(
|
||||||
|
<StudioLanguageProvider>
|
||||||
|
<RuntimePreviewDialog
|
||||||
|
definition={avatarDefinition}
|
||||||
|
initialAnimation={initialAnimation}
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
</StudioLanguageProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('runtime export preview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||||
|
vi.stubGlobal('matchMedia', () => ({ matches: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('exposes the exported animations, expressions and playback controls', () => {
|
||||||
|
renderPreview(definition, 'sleeping')
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exported definition preview' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exported animations' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exported expressions' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Play' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Pause' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'idle' }))
|
||||||
|
expect(screen.getByRole('button', { name: 'idle' }).getAttribute('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews an expression-only definition and disables Play', () => {
|
||||||
|
const expressionOnly = {
|
||||||
|
...definition,
|
||||||
|
animations: {},
|
||||||
|
animationOrder: [],
|
||||||
|
} as AvatarDefinition
|
||||||
|
|
||||||
|
renderPreview(expressionOnly)
|
||||||
|
|
||||||
|
expect(screen.getByText('No exported animation')).toBeTruthy()
|
||||||
|
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect(screen.getAllByText('neutral').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
604
src/features/studio/components/RuntimeGuideDialog.tsx
Normal file
604
src/features/studio/components/RuntimeGuideDialog.tsx
Normal file
@ -0,0 +1,604 @@
|
|||||||
|
import { BookOpen, Code2, MousePointer2, Play, SlidersHorizontal, X } from 'lucide-react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
const runtimeInstallExample = 'npm install @bible-strong/avatar-react react react-dom'
|
||||||
|
const webInstallExample = 'npm install @bible-strong/avatar-web'
|
||||||
|
|
||||||
|
const webAvatarExample = (
|
||||||
|
animationKey: string | undefined
|
||||||
|
) => `import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
import definition from './avatar.avatar.json'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`}
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.play(${animationKey ? `'${animationKey}'` : `'idle'`})
|
||||||
|
avatar.pause()
|
||||||
|
avatar.setExpression('neutral')
|
||||||
|
avatar.stop()`
|
||||||
|
|
||||||
|
const nativeBrowserExample = `<!-- Resolve the package URL through your CDN or import map. -->
|
||||||
|
<div id="avatar"></div>
|
||||||
|
<script type="module">
|
||||||
|
import { createAvatar } from 'https://cdn.example.com/avatar-web.js'
|
||||||
|
|
||||||
|
const definition = await fetch('./avatar.avatar.json').then(response => response.json())
|
||||||
|
const avatar = createAvatar('#avatar', { definition, defaultExpression: 'neutral' })
|
||||||
|
</script>`
|
||||||
|
|
||||||
|
const createAvatarExample = (
|
||||||
|
animationKey: string | undefined
|
||||||
|
) => `import { createAvatar } from '@bible-strong/avatar-react'
|
||||||
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
|
import avatarJson from './avatar.avatar.json'
|
||||||
|
|
||||||
|
const StrobiAvatar = createAvatar(avatarJson)
|
||||||
|
|
||||||
|
export function Strobi() {
|
||||||
|
return <StrobiAvatar ${
|
||||||
|
animationKey ? `defaultAnimation="${animationKey}"` : 'defaultExpression="neutral"'
|
||||||
|
} />
|
||||||
|
}`
|
||||||
|
|
||||||
|
const genericAvatarExample = `import { Avatar } from '@bible-strong/avatar-react'
|
||||||
|
import type { AvatarDefinition, ExpressionKey } from '@bible-strong/avatar-core'
|
||||||
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
|
|
||||||
|
export function DynamicAvatar({
|
||||||
|
definition,
|
||||||
|
expression,
|
||||||
|
}: {
|
||||||
|
definition: AvatarDefinition
|
||||||
|
expression: ExpressionKey
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Avatar
|
||||||
|
definition={definition}
|
||||||
|
expression={expression}
|
||||||
|
onError={error => console.error(error)}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}`
|
||||||
|
|
||||||
|
const imperativeExample = (
|
||||||
|
animationKey: string | undefined
|
||||||
|
) => `import { createAvatar, type AvatarController } from '@bible-strong/avatar-react'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
import avatarJson from './avatar.avatar.json'
|
||||||
|
|
||||||
|
const StrobiAvatar = createAvatar(avatarJson)
|
||||||
|
|
||||||
|
export function Controls() {
|
||||||
|
const avatar = useRef<AvatarController>(null)
|
||||||
|
|
||||||
|
return <>
|
||||||
|
<StrobiAvatar ref={avatar} ${
|
||||||
|
animationKey ? `defaultAnimation="${animationKey}"` : 'defaultExpression="neutral"'
|
||||||
|
} />${
|
||||||
|
animationKey
|
||||||
|
? `
|
||||||
|
<button onClick={() => avatar.current?.play('${animationKey}')}>Play animation</button>`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
<button onClick={() => avatar.current?.pause()}>Pause</button>
|
||||||
|
<button onClick={() => avatar.current?.setExpression('neutral')}>Set expression</button>
|
||||||
|
<button onClick={() => avatar.current?.stop()}>Stop</button>
|
||||||
|
<button onClick={() => console.log(avatar.current?.getState())}>Read state</button>
|
||||||
|
</>
|
||||||
|
}`
|
||||||
|
|
||||||
|
const codeTokenPattern =
|
||||||
|
/(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)\b)/g
|
||||||
|
|
||||||
|
const highlightedCode = (source: string) =>
|
||||||
|
source.split(codeTokenPattern).map((token, index) => {
|
||||||
|
if (!token) return null
|
||||||
|
const kind = token.startsWith('//')
|
||||||
|
? 'comment'
|
||||||
|
: token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
|
||||||
|
? 'string'
|
||||||
|
: token.startsWith('<')
|
||||||
|
? 'tag'
|
||||||
|
: /^(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)$/.test(
|
||||||
|
token
|
||||||
|
)
|
||||||
|
? 'keyword'
|
||||||
|
: 'plain'
|
||||||
|
return (
|
||||||
|
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
|
||||||
|
{token}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function GuideCode({ children }: { children: string }) {
|
||||||
|
return (
|
||||||
|
<pre className="avatar-guide-code" tabIndex={0}>
|
||||||
|
<code>{highlightedCode(children)}</code>
|
||||||
|
</pre>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function PropRow({ name, type, description }: { name: string; type: string; description: string }) {
|
||||||
|
return (
|
||||||
|
<div className="avatar-guide-prop">
|
||||||
|
<dt>
|
||||||
|
<code>{name}</code>
|
||||||
|
<span className="avatar-guide-prop-type">{type}</span>
|
||||||
|
</dt>
|
||||||
|
<dd>{description}</dd>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimeGuideDialog({
|
||||||
|
animationKey,
|
||||||
|
integration = 'react',
|
||||||
|
onOpenChange,
|
||||||
|
open,
|
||||||
|
}: {
|
||||||
|
animationKey?: string
|
||||||
|
integration?: 'react' | 'javascript'
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
open: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="avatar-guide-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="avatar-guide-kicker">
|
||||||
|
<BookOpen />
|
||||||
|
<span>{t('Guide d’utilisation')}</span>
|
||||||
|
</div>
|
||||||
|
<DialogTitle>
|
||||||
|
{t(
|
||||||
|
integration === 'react'
|
||||||
|
? 'Guide d’utilisation de l’avatar React'
|
||||||
|
: 'Guide d’utilisation de l’avatar JavaScript'
|
||||||
|
)}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
integration === 'react'
|
||||||
|
? 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.'
|
||||||
|
: 'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.'
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="avatar-guide-close"
|
||||||
|
aria-label={t('Fermer le guide')}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="avatar-guide-body">
|
||||||
|
{integration === 'react' ? (
|
||||||
|
<>
|
||||||
|
<section className="avatar-guide-section avatar-guide-section--accent">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<Code2 />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Installation')}</h3>
|
||||||
|
<p>{t('Ajoute le package React et ses dépendances.')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{runtimeInstallExample}</GuideCode>
|
||||||
|
<p className="avatar-guide-notice">
|
||||||
|
{t(
|
||||||
|
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<Play />
|
||||||
|
<div>
|
||||||
|
<h3>{t('API recommandée : créer un avatar concret')}</h3>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{createAvatarExample(animationKey)}</GuideCode>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section avatar-guide-section--api">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<SlidersHorizontal />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Props de l’avatar')}</h3>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-guide-prop-group">
|
||||||
|
<strong>{t('Cible et lecture')}</strong>
|
||||||
|
<dl>
|
||||||
|
<PropRow
|
||||||
|
name="definition"
|
||||||
|
type="AvatarDefinition"
|
||||||
|
description={t(
|
||||||
|
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="animation"
|
||||||
|
type="AnimationKey | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="expression"
|
||||||
|
type="ExpressionKey | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="defaultAnimation"
|
||||||
|
type="AnimationKey | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="defaultExpression"
|
||||||
|
type="ExpressionKey | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="autoplay"
|
||||||
|
type="boolean | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="ref"
|
||||||
|
type="Ref<AvatarController> | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Donne accès à l’API impérative AvatarController.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-guide-prop-group">
|
||||||
|
<strong>{t('Présentation')}</strong>
|
||||||
|
<dl>
|
||||||
|
<PropRow
|
||||||
|
name="size"
|
||||||
|
type="number | string | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="className"
|
||||||
|
type="string | undefined"
|
||||||
|
description={t('Optionnelle. Classe CSS ajoutée au conteneur externe.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="style"
|
||||||
|
type="CSSProperties | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="ariaLabel"
|
||||||
|
type="string | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-guide-prop-group">
|
||||||
|
<strong>{t('Callbacks de lecture')}</strong>
|
||||||
|
<dl>
|
||||||
|
<PropRow
|
||||||
|
name="onAnimationEnd"
|
||||||
|
type="(animation: AnimationKey) => void"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="onExpressionChange"
|
||||||
|
type="(expression: ExpressionKey) => void"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="onError"
|
||||||
|
type="(error: AvatarRuntimeError) => void"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<Code2 />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Avatar générique')}</h3>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{genericAvatarExample}</GuideCode>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section avatar-guide-section--api">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<MousePointer2 />
|
||||||
|
<div>
|
||||||
|
<h3>{t('API impérative')}</h3>
|
||||||
|
<p>
|
||||||
|
{t('La ref expose les commandes de lecture et l’état courant de l’avatar.')}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-guide-prop-group">
|
||||||
|
<dl>
|
||||||
|
<PropRow
|
||||||
|
name="play(animation)"
|
||||||
|
type="(animation: AnimationKey) => AvatarCommandResult"
|
||||||
|
description={t(
|
||||||
|
'Lance ou reprend une animation et retourne un résultat typé.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="pause()"
|
||||||
|
type="() => void"
|
||||||
|
description={t('Met en pause la timeline à sa position exacte.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="stop()"
|
||||||
|
type="() => void"
|
||||||
|
description={t(
|
||||||
|
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="setExpression(expression)"
|
||||||
|
type="(expression: ExpressionKey) => AvatarCommandResult"
|
||||||
|
description={t('Affiche directement une expression.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="getState()"
|
||||||
|
type="() => AvatarPlaybackState"
|
||||||
|
description={t('Retourne l’animation, l’expression et le statut actifs.')}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{imperativeExample(animationKey)}</GuideCode>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<section className="avatar-guide-section avatar-guide-section--accent">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<Code2 />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Installation')}</h3>
|
||||||
|
<p>{t('Ajoute le renderer DOM, qui utilise automatiquement avatar-core.')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{webInstallExample}</GuideCode>
|
||||||
|
<p className="avatar-guide-notice">
|
||||||
|
{t(
|
||||||
|
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<Play />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Utilisation avec un bundler ESM')}</h3>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{webAvatarExample(animationKey)}</GuideCode>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section avatar-guide-section--api">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<SlidersHorizontal />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Options de createAvatar')}</h3>
|
||||||
|
<p>{t('Référence des valeurs acceptées lors du montage dans le DOM.')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-guide-prop-group">
|
||||||
|
<dl>
|
||||||
|
<PropRow
|
||||||
|
name="definition"
|
||||||
|
type="AvatarDefinition | unknown"
|
||||||
|
description={t(
|
||||||
|
'Obligatoire. Définition JSON validée avant la création des éléments SVG.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="defaultAnimation"
|
||||||
|
type="AnimationKey | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="defaultExpression"
|
||||||
|
type="ExpressionKey | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="autoplay"
|
||||||
|
type="boolean | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="size"
|
||||||
|
type="number | string | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="className"
|
||||||
|
type="string | undefined"
|
||||||
|
description={t('Optionnelle. Classe CSS ajoutée au conteneur rendu.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="ariaLabel"
|
||||||
|
type="string | undefined"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="onError"
|
||||||
|
type="(error: AvatarRuntimeError) => void"
|
||||||
|
description={t(
|
||||||
|
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="onAnimationEnd"
|
||||||
|
type="(animation: AnimationKey) => void"
|
||||||
|
description={t('Optionnelle. Appelée lorsqu’une animation once se termine.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="onExpressionChange"
|
||||||
|
type="(expression: ExpressionKey) => void"
|
||||||
|
description={t('Optionnelle. Appelée lorsque l’expression active change.')}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section avatar-guide-section--api">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<MousePointer2 />
|
||||||
|
<div>
|
||||||
|
<h3>{t('API du contrôleur DOM')}</h3>
|
||||||
|
<p>{t('createAvatar retourne immédiatement ces commandes impératives.')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="avatar-guide-prop-group">
|
||||||
|
<dl>
|
||||||
|
<PropRow
|
||||||
|
name="play(animation)"
|
||||||
|
type="(animation: AnimationKey) => AvatarCommandResult"
|
||||||
|
description={t('Lance ou reprend une animation par sa clé.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="setExpression(expression)"
|
||||||
|
type="(expression: ExpressionKey) => AvatarCommandResult"
|
||||||
|
description={t('Affiche une expression avec une transition courte.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="pause()"
|
||||||
|
type="() => void"
|
||||||
|
description={t('Met en pause la timeline à sa position exacte.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="stop()"
|
||||||
|
type="() => void"
|
||||||
|
description={t('Arrête la lecture et revient à neutral.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="getState()"
|
||||||
|
type="() => AvatarPlaybackState"
|
||||||
|
description={t('Retourne l’animation, l’expression et le statut actifs.')}
|
||||||
|
/>
|
||||||
|
<PropRow
|
||||||
|
name="destroy()"
|
||||||
|
type="() => void"
|
||||||
|
description={t(
|
||||||
|
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.'
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section className="avatar-guide-section">
|
||||||
|
<div className="avatar-guide-section-heading">
|
||||||
|
<Code2 />
|
||||||
|
<div>
|
||||||
|
<h3>{t('Navigateur sans bundler')}</h3>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<GuideCode>{nativeBrowserExample}</GuideCode>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="avatar-guide-footer">
|
||||||
|
<DialogClose render={<Button variant="outline">{t('Fermer le guide')}</Button>} />
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
257
src/features/studio/components/RuntimePreviewDialog.tsx
Normal file
257
src/features/studio/components/RuntimePreviewDialog.tsx
Normal file
@ -0,0 +1,257 @@
|
|||||||
|
import { Eye, Pause, Play, RotateCcw, Square, X } from 'lucide-react'
|
||||||
|
import { useRef, useState } from 'react'
|
||||||
|
|
||||||
|
import { Avatar, type AvatarController, type AvatarRuntimeError } from '@bible-strong/avatar-react'
|
||||||
|
import type { AnimationKey, AvatarDefinition, ExpressionKey } from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { useStudioLanguage } from '@/i18n'
|
||||||
|
|
||||||
|
type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
||||||
|
type PreviewTarget =
|
||||||
|
{ type: 'animation'; key: AnimationKey } | { type: 'expression'; key: ExpressionKey }
|
||||||
|
|
||||||
|
function RuntimePreviewContent({
|
||||||
|
definition: inputDefinition,
|
||||||
|
initialAnimation,
|
||||||
|
}: {
|
||||||
|
definition: AvatarDefinition
|
||||||
|
initialAnimation?: AnimationKey
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
const [definition] = useState(inputDefinition)
|
||||||
|
const controller = useRef<AvatarController>(null)
|
||||||
|
const firstAnimation = initialAnimation ?? definition.animationOrder[0]
|
||||||
|
const [target, setTarget] = useState<PreviewTarget>(
|
||||||
|
firstAnimation
|
||||||
|
? { type: 'animation', key: firstAnimation }
|
||||||
|
: { type: 'expression', key: 'neutral' }
|
||||||
|
)
|
||||||
|
const [activeAnimation, setActiveAnimation] = useState<AnimationKey | undefined>(firstAnimation)
|
||||||
|
const [activeExpression, setActiveExpression] = useState<ExpressionKey>(
|
||||||
|
firstAnimation
|
||||||
|
? (definition.animations[firstAnimation]?.steps[0]?.expression ?? 'neutral')
|
||||||
|
: 'neutral'
|
||||||
|
)
|
||||||
|
const [status, setStatus] = useState<PlaybackStatus>(firstAnimation ? 'playing' : 'stopped')
|
||||||
|
const [error, setError] = useState<AvatarRuntimeError | null>(null)
|
||||||
|
|
||||||
|
const reportResult = (result: ReturnType<AvatarController['play']>) => {
|
||||||
|
if (result.ok) setError(null)
|
||||||
|
else setError(result.error)
|
||||||
|
return result.ok
|
||||||
|
}
|
||||||
|
const playAnimation = (animation: AnimationKey) => {
|
||||||
|
if (!controller.current || !reportResult(controller.current.play(animation))) return
|
||||||
|
setTarget({ type: 'animation', key: animation })
|
||||||
|
setActiveAnimation(animation)
|
||||||
|
setStatus('playing')
|
||||||
|
}
|
||||||
|
const showExpression = (expression: ExpressionKey) => {
|
||||||
|
if (!controller.current || !reportResult(controller.current.setExpression(expression))) return
|
||||||
|
setTarget({ type: 'expression', key: expression })
|
||||||
|
setActiveAnimation(undefined)
|
||||||
|
setActiveExpression(expression)
|
||||||
|
setStatus('stopped')
|
||||||
|
}
|
||||||
|
const restart = () => {
|
||||||
|
if (target.type === 'animation') playAnimation(target.key)
|
||||||
|
else showExpression(target.key)
|
||||||
|
}
|
||||||
|
const pause = () => {
|
||||||
|
controller.current?.pause()
|
||||||
|
if (activeAnimation) setStatus('paused')
|
||||||
|
}
|
||||||
|
const stop = () => {
|
||||||
|
controller.current?.stop()
|
||||||
|
setActiveAnimation(undefined)
|
||||||
|
setActiveExpression('neutral')
|
||||||
|
setStatus('stopped')
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusLabel =
|
||||||
|
status === 'playing' ? t('En lecture') : status === 'paused' ? t('En pause') : t('Arrêté')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="runtime-preview-body">
|
||||||
|
<section className="runtime-preview-stage-card">
|
||||||
|
<div className="runtime-preview-stage-heading">
|
||||||
|
<div>
|
||||||
|
<small>{t('Définition exportée')}</small>
|
||||||
|
<strong>{definition.name ?? t('Avatar')}</strong>
|
||||||
|
</div>
|
||||||
|
<span className="runtime-preview-status" data-status={status}>
|
||||||
|
<span aria-hidden="true" />
|
||||||
|
{statusLabel}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="runtime-preview-stage">
|
||||||
|
<Avatar
|
||||||
|
ref={controller}
|
||||||
|
definition={definition}
|
||||||
|
defaultAnimation={firstAnimation}
|
||||||
|
defaultExpression={firstAnimation ? undefined : 'neutral'}
|
||||||
|
size="min(100%, 400px)"
|
||||||
|
ariaLabel={t('Aperçu runtime de l’avatar actif')}
|
||||||
|
onAnimationEnd={() => {
|
||||||
|
setActiveAnimation(undefined)
|
||||||
|
setStatus('stopped')
|
||||||
|
}}
|
||||||
|
onExpressionChange={setActiveExpression}
|
||||||
|
onError={setError}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="runtime-preview-current">
|
||||||
|
<div>
|
||||||
|
<small>{t('Animation active')}</small>
|
||||||
|
<strong>{activeAnimation ?? '—'}</strong>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<small>{t('Expression active')}</small>
|
||||||
|
<strong>{activeExpression}</strong>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="runtime-preview-transport" aria-label={t('Contrôles de lecture')}>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!definition.animationOrder.length}
|
||||||
|
onClick={() =>
|
||||||
|
playAnimation(target.type === 'animation' ? target.key : firstAnimation!)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Play />
|
||||||
|
{t('Lire')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" disabled={!activeAnimation} onClick={pause}>
|
||||||
|
<Pause />
|
||||||
|
{t('Pause')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={stop}>
|
||||||
|
<Square />
|
||||||
|
{t('Arrêter')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={restart}>
|
||||||
|
<RotateCcw />
|
||||||
|
{t('Relancer')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{error && (
|
||||||
|
<p className="runtime-preview-error" role="alert">
|
||||||
|
{error.message}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<aside className="runtime-preview-controls">
|
||||||
|
<section>
|
||||||
|
<div className="runtime-preview-control-heading">
|
||||||
|
<div>
|
||||||
|
<small>{definition.animationOrder.length}</small>
|
||||||
|
<h3>{t('Animations exportées')}</h3>
|
||||||
|
</div>
|
||||||
|
<span>{t('Clique pour lancer')}</span>
|
||||||
|
</div>
|
||||||
|
{definition.animationOrder.length ? (
|
||||||
|
<div className="runtime-preview-option-grid">
|
||||||
|
{definition.animationOrder.map(animation => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
key={animation}
|
||||||
|
aria-pressed={target.type === 'animation' && target.key === animation}
|
||||||
|
onClick={() => playAnimation(animation)}
|
||||||
|
>
|
||||||
|
<Play />
|
||||||
|
<span>{definition.animations[animation]?.metadata?.label ?? animation}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="runtime-preview-empty">{t('Aucune animation exportée')}</p>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<div className="runtime-preview-control-heading">
|
||||||
|
<div>
|
||||||
|
<small>{definition.expressionOrder.length}</small>
|
||||||
|
<h3>{t('Expressions exportées')}</h3>
|
||||||
|
</div>
|
||||||
|
<span>{t('Clique pour afficher')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="runtime-preview-option-grid runtime-preview-expression-grid">
|
||||||
|
{definition.expressionOrder.map(expression => (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
key={expression}
|
||||||
|
aria-pressed={target.type === 'expression' && target.key === expression}
|
||||||
|
onClick={() => showExpression(expression)}
|
||||||
|
>
|
||||||
|
<Eye />
|
||||||
|
<span>{expression}</span>
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RuntimePreviewDialog({
|
||||||
|
definition,
|
||||||
|
initialAnimation,
|
||||||
|
onOpenChange,
|
||||||
|
open,
|
||||||
|
}: {
|
||||||
|
definition: AvatarDefinition | null
|
||||||
|
initialAnimation?: AnimationKey
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
open: boolean
|
||||||
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="runtime-preview-dialog">
|
||||||
|
<DialogHeader>
|
||||||
|
<div className="avatar-guide-kicker">
|
||||||
|
<Eye />
|
||||||
|
<span>{t('Preview')}</span>
|
||||||
|
</div>
|
||||||
|
<DialogTitle>{t('Preview de la définition exportée')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.'
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
<DialogClose
|
||||||
|
render={
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="runtime-preview-close"
|
||||||
|
aria-label={t('Fermer la preview')}
|
||||||
|
>
|
||||||
|
<X />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</DialogHeader>
|
||||||
|
{open && definition && (
|
||||||
|
<RuntimePreviewContent definition={definition} initialAnimation={initialAnimation} />
|
||||||
|
)}
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
CircleCheck,
|
BookOpen,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronUp,
|
ChevronUp,
|
||||||
Copy,
|
Copy,
|
||||||
@ -19,11 +19,6 @@ import {
|
|||||||
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
|
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
|
||||||
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
|
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
import {
|
|
||||||
Avatar as RuntimeAvatar,
|
|
||||||
type AvatarController as RuntimeAvatarController,
|
|
||||||
} from '@bible-strong/avatar-react'
|
|
||||||
|
|
||||||
import { Accordion } from '@/components/ui/accordion'
|
import { Accordion } from '@/components/ui/accordion'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@ -59,11 +54,7 @@ import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
|
|||||||
import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
|
import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
|
||||||
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
|
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
|
||||||
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
|
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
|
||||||
import {
|
import { defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||||
defaultAvatarEyes,
|
|
||||||
defaultPixelRenderStyle,
|
|
||||||
type AvatarRenderStyle,
|
|
||||||
} from '@/features/avatar/avatars'
|
|
||||||
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
|
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
|
||||||
import {
|
import {
|
||||||
ExpressionCard,
|
ExpressionCard,
|
||||||
@ -75,49 +66,15 @@ import { defaultExpression } from '@/features/avatar/presets'
|
|||||||
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
|
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
|
||||||
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
||||||
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
|
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
|
||||||
|
import { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog'
|
||||||
|
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
|
||||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||||
import type { StudioController } from '@/features/studio/useStudioController'
|
import type { StudioController } from '@/features/studio/useStudioController'
|
||||||
|
|
||||||
const runtimeInstallExample =
|
|
||||||
'npm install @bible-strong/avatar-core @bible-strong/avatar-react react react-dom'
|
|
||||||
|
|
||||||
const runtimeReactExample = `import { validateAvatarDefinition } from '@bible-strong/avatar-core'
|
|
||||||
import { Avatar } from '@bible-strong/avatar-react'
|
|
||||||
import '@bible-strong/avatar-react/styles.css'
|
|
||||||
import avatarJson from './strobi.avatar.json'
|
|
||||||
|
|
||||||
const avatar = validateAvatarDefinition(avatarJson)
|
|
||||||
if (!avatar.ok) throw new Error(avatar.errors[0].message)
|
|
||||||
|
|
||||||
export function Strobi() {
|
|
||||||
return <Avatar definition={avatar.value} defaultAnimation="idle" />
|
|
||||||
}`
|
|
||||||
|
|
||||||
const codeTokenPattern =
|
|
||||||
/(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return)\b)/g
|
|
||||||
|
|
||||||
const highlightedCode = (source: string) =>
|
|
||||||
source.split(codeTokenPattern).map((token, index) => {
|
|
||||||
if (!token) return null
|
|
||||||
const kind = token.startsWith('//')
|
|
||||||
? 'comment'
|
|
||||||
: token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
|
|
||||||
? 'string'
|
|
||||||
: token.startsWith('<')
|
|
||||||
? 'tag'
|
|
||||||
: /^(?:npm|install|import|from|const|if|throw|new|export|function|return)$/.test(token)
|
|
||||||
? 'keyword'
|
|
||||||
: 'plain'
|
|
||||||
return (
|
|
||||||
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
|
|
||||||
{token}
|
|
||||||
</span>
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
export function StudioInspector({ controller }: { controller: StudioController }) {
|
export function StudioInspector({ controller }: { controller: StudioController }) {
|
||||||
const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false)
|
const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
|
||||||
const runtimeExampleRef = useRef<RuntimeAvatarController>(null)
|
const [guideOpen, setGuideOpen] = useState(false)
|
||||||
|
const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
|
||||||
const {
|
const {
|
||||||
activateAvatar,
|
activateAvatar,
|
||||||
activeAvatar,
|
activeAvatar,
|
||||||
@ -144,7 +101,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
commitAvatarMove,
|
commitAvatarMove,
|
||||||
commitExpressionMove,
|
commitExpressionMove,
|
||||||
commitStateMove,
|
commitStateMove,
|
||||||
clearLocalStudioDocument,
|
|
||||||
copyAvatarRuntimeDefinition,
|
copyAvatarRuntimeDefinition,
|
||||||
createNewAvatar,
|
createNewAvatar,
|
||||||
deleteSelectedBodyNode,
|
deleteSelectedBodyNode,
|
||||||
@ -196,7 +152,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
runtimeDefinitionResult,
|
runtimeDefinitionResult,
|
||||||
runtimeCopyStatus,
|
runtimeCopyStatus,
|
||||||
runtimeExportErrors,
|
runtimeExportErrors,
|
||||||
runtimeStandardAvailability,
|
|
||||||
saveAvatarEditing,
|
saveAvatarEditing,
|
||||||
saveEditing,
|
saveEditing,
|
||||||
saveSequenceEditing,
|
saveSequenceEditing,
|
||||||
@ -249,7 +204,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
toggleStatePlayback,
|
toggleStatePlayback,
|
||||||
transitionToExpression,
|
transitionToExpression,
|
||||||
updateAvatarColors,
|
updateAvatarColors,
|
||||||
updateAvatarRenderStyle,
|
|
||||||
updateAvatarEyeDimension,
|
updateAvatarEyeDimension,
|
||||||
updateAvatarEyePosition,
|
updateAvatarEyePosition,
|
||||||
updateAvatarEyeSize,
|
updateAvatarEyeSize,
|
||||||
@ -265,8 +219,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
updateWireVisibility,
|
updateWireVisibility,
|
||||||
workspaceBackButtonRef,
|
workspaceBackButtonRef,
|
||||||
} = controller
|
} = controller
|
||||||
const pixelRenderStyle =
|
const runtimePreviewAnimation = runtimeDefinitionResult.ok
|
||||||
activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null
|
? runtimeDefinitionResult.value.animationOrder[0]
|
||||||
|
: undefined
|
||||||
const playbackFooterY = useMotionValue(0)
|
const playbackFooterY = useMotionValue(0)
|
||||||
const playbackHandleY = useMotionValue(0)
|
const playbackHandleY = useMotionValue(0)
|
||||||
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
|
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
|
||||||
@ -860,58 +815,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
</ControlSection>
|
</ControlSection>
|
||||||
<ControlSection
|
<ControlSection
|
||||||
title="Rendu"
|
title="Rendu"
|
||||||
subtitle="Choisis la finition visuelle propre à cet avatar."
|
subtitle="Le rendu Pixel est temporairement désactivé."
|
||||||
>
|
>
|
||||||
<InspectorCard className="render-style-panel">
|
<InspectorCard className="render-style-panel render-style-disabled">
|
||||||
<PanelTitle
|
<PanelTitle
|
||||||
level={3}
|
level={3}
|
||||||
title="Type de rendu"
|
title="Type de rendu"
|
||||||
subtitle="Pixel utilise une palette franche, sans lissage ni couleur intermédiaire."
|
subtitle="Le mode Vectoriel est utilisé pour l’instant."
|
||||||
/>
|
|
||||||
<Field className="render-style-field" orientation="horizontal">
|
|
||||||
<FieldTitle>{t('Style')}</FieldTitle>
|
|
||||||
<Select
|
|
||||||
value={activeAvatar.renderStyle.type}
|
|
||||||
items={[
|
|
||||||
{ value: 'vector', label: t('Vectoriel') },
|
|
||||||
{ value: 'pixel', label: t('Pixel') },
|
|
||||||
]}
|
|
||||||
onValueChange={next => {
|
|
||||||
if (!next) return
|
|
||||||
const renderStyle: AvatarRenderStyle =
|
|
||||||
next === 'pixel'
|
|
||||||
? { ...defaultPixelRenderStyle }
|
|
||||||
: { type: 'vector' }
|
|
||||||
updateAvatarRenderStyle(renderStyle)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<SelectTrigger aria-label={t('Type de rendu')}>
|
|
||||||
<SelectValue />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value="vector">{t('Vectoriel')}</SelectItem>
|
|
||||||
<SelectItem value="pixel">{t('Pixel')}</SelectItem>
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</Field>
|
|
||||||
{pixelRenderStyle && (
|
|
||||||
<div className="pixel-render-options">
|
|
||||||
<NumericField
|
|
||||||
label="Définition de la grille"
|
|
||||||
value={pixelRenderStyle.resolution}
|
|
||||||
min={8}
|
|
||||||
max={192}
|
|
||||||
step={8}
|
|
||||||
unit="px"
|
|
||||||
onChange={resolution =>
|
|
||||||
updateAvatarRenderStyle({
|
|
||||||
...pixelRenderStyle,
|
|
||||||
resolution: Math.round(resolution),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
|
<div className="render-style-status">
|
||||||
|
<Badge variant="secondary">{t('Vectoriel')}</Badge>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</InspectorCard>
|
</InspectorCard>
|
||||||
</ControlSection>
|
</ControlSection>
|
||||||
<ControlSection
|
<ControlSection
|
||||||
@ -1699,169 +1613,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
|
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
|
||||||
<Accordion className="export-panel" defaultValue={['runtime']}>
|
<Accordion className="export-panel" defaultValue={['avatar']}>
|
||||||
<ExportSection
|
|
||||||
value="runtime"
|
|
||||||
title="Exporter le JSON runtime"
|
|
||||||
subtitle="Exporte le fichier .avatar.json utilisé par les nouveaux packages npm."
|
|
||||||
badge="Nouveau"
|
|
||||||
>
|
|
||||||
<InspectorCard>
|
|
||||||
<div className="export-avatar-summary">
|
|
||||||
<ExpressionPreview
|
|
||||||
expression={expressions[0] ?? defaultExpression}
|
|
||||||
surface={activeAvatar.body.primary}
|
|
||||||
bodyNodes={activeAvatar.body.nodes}
|
|
||||||
colors={activeAvatar.colors}
|
|
||||||
avatarEyes={activeAvatarEyes}
|
|
||||||
renderStyle={activeAvatar.renderStyle}
|
|
||||||
id={`runtime-avatar-${activeAvatar.id}`}
|
|
||||||
/>
|
|
||||||
<div>
|
|
||||||
<small>{t('Définition runtime')}</small>
|
|
||||||
<strong>{activeAvatar.name}</strong>
|
|
||||||
<span>{t('Fichier .avatar.json portable')}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</InspectorCard>
|
|
||||||
|
|
||||||
<InspectorCard
|
|
||||||
className="runtime-readiness"
|
|
||||||
data-ready={runtimeDefinitionResult.ok || undefined}
|
|
||||||
>
|
|
||||||
<div className="runtime-readiness-heading">
|
|
||||||
{runtimeDefinitionResult.ok ? <CircleCheck /> : <TriangleAlert />}
|
|
||||||
<div>
|
|
||||||
<strong>
|
|
||||||
{t(
|
|
||||||
runtimeDefinitionResult.ok
|
|
||||||
? 'Prêt pour l’export runtime'
|
|
||||||
: 'Export runtime incomplet'
|
|
||||||
)}
|
|
||||||
</strong>
|
|
||||||
{runtimeDefinitionResult.ok && runtimeStandardAvailability && (
|
|
||||||
<small>
|
|
||||||
{runtimeStandardAvailability.available.length}/6{' '}
|
|
||||||
{t('animations standard disponibles')}
|
|
||||||
</small>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{runtimeExportErrors.length > 0 && (
|
|
||||||
<>
|
|
||||||
<ul className="runtime-error-list" role="alert">
|
|
||||||
{runtimeExportErrors.map((error, index) => (
|
|
||||||
<li key={`${index}-${error}`}>{error}</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="destructive"
|
|
||||||
onClick={clearLocalStudioDocument}
|
|
||||||
>
|
|
||||||
<Trash2 />
|
|
||||||
{t('Effacer le projet local et recharger')}
|
|
||||||
</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</InspectorCard>
|
|
||||||
|
|
||||||
<InspectorCard className="runtime-quickstart">
|
|
||||||
<PanelTitle
|
|
||||||
title="Démarrage rapide npm"
|
|
||||||
subtitle="Ces commandes fonctionneront après la publication des packages actuellement privés."
|
|
||||||
/>
|
|
||||||
<div className="runtime-code-example">
|
|
||||||
<small>{t('Installation')}</small>
|
|
||||||
<pre tabIndex={0}>
|
|
||||||
<code>{highlightedCode(runtimeInstallExample)}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<div className="runtime-code-example">
|
|
||||||
<small>{t('Exemple React minimal')}</small>
|
|
||||||
<pre tabIndex={0}>
|
|
||||||
<code>{highlightedCode(runtimeReactExample)}</code>
|
|
||||||
</pre>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
|
||||||
aria-expanded={runtimeExampleOpen}
|
|
||||||
onClick={() => setRuntimeExampleOpen(open => !open)}
|
|
||||||
>
|
|
||||||
<Play />
|
|
||||||
{t(runtimeExampleOpen ? 'Masquer l’aperçu' : 'Lancer l’exemple')}
|
|
||||||
</Button>
|
|
||||||
{runtimeExampleOpen && runtimeDefinitionResult.ok && (
|
|
||||||
<div className="runtime-live-example">
|
|
||||||
<div className="runtime-live-example-heading">
|
|
||||||
<div>
|
|
||||||
<small>{t('Aperçu avec le package React')}</small>
|
|
||||||
<strong>defaultAnimation="idle"</strong>
|
|
||||||
</div>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
size="sm"
|
|
||||||
variant="outline"
|
|
||||||
onClick={() => runtimeExampleRef.current?.play('idle')}
|
|
||||||
>
|
|
||||||
<RotateCcw />
|
|
||||||
{t('Relancer')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="runtime-live-example-stage">
|
|
||||||
<RuntimeAvatar
|
|
||||||
ref={runtimeExampleRef}
|
|
||||||
definition={runtimeDefinitionResult.value}
|
|
||||||
defaultAnimation="idle"
|
|
||||||
size={150}
|
|
||||||
ariaLabel={t('Aperçu runtime de l’avatar actif')}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</InspectorCard>
|
|
||||||
|
|
||||||
<div className="runtime-export-actions">
|
|
||||||
<Button
|
|
||||||
className="export-download"
|
|
||||||
type="button"
|
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
|
||||||
onClick={downloadAvatarRuntimeDefinition}
|
|
||||||
>
|
|
||||||
<Download />
|
|
||||||
{t('Télécharger la définition .avatar.json')}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
disabled={!runtimeDefinitionResult.ok}
|
|
||||||
onClick={() => void copyAvatarRuntimeDefinition()}
|
|
||||||
>
|
|
||||||
<Copy />
|
|
||||||
{t('Copier le JSON formaté')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
{runtimeCopyStatus !== 'idle' && (
|
|
||||||
<p
|
|
||||||
className="runtime-copy-status"
|
|
||||||
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
|
|
||||||
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
|
|
||||||
>
|
|
||||||
{t(
|
|
||||||
runtimeCopyStatus === 'success'
|
|
||||||
? 'JSON runtime copié dans le presse-papiers.'
|
|
||||||
: 'Impossible de copier le JSON runtime.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</ExportSection>
|
|
||||||
|
|
||||||
<ExportSection
|
<ExportSection
|
||||||
value="avatar"
|
value="avatar"
|
||||||
title="Exporter l’avatar"
|
title="Exporter l’avatar"
|
||||||
subtitle="Génère l’export ZIP autonome React ou JavaScript qui existait déjà."
|
subtitle="Choisis les animations puis utilise la même définition JSON avec React ou JavaScript."
|
||||||
>
|
>
|
||||||
<InspectorCard>
|
<InspectorCard>
|
||||||
<div className="export-avatar-summary">
|
<div className="export-avatar-summary">
|
||||||
@ -1896,7 +1652,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
<FileCode2 />
|
<FileCode2 />
|
||||||
<span>
|
<span>
|
||||||
<strong>React / TypeScript</strong>
|
<strong>React / TypeScript</strong>
|
||||||
<small>{t('Package React local (.zip)')}</small>
|
<small>{t('JSON runtime + createAvatar')}</small>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
@ -1907,8 +1663,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
>
|
>
|
||||||
<FileCode2 />
|
<FileCode2 />
|
||||||
<span>
|
<span>
|
||||||
<strong>{t('Module JavaScript')}</strong>
|
<strong>{t('JavaScript / ESM')}</strong>
|
||||||
<small>{t('Projet HTML + module JS (.zip)')}</small>
|
<small>{t('JSON runtime + avatar-web')}</small>
|
||||||
</span>
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
@ -1923,6 +1679,20 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
<h2>{t('Animations à exporter')}</h2>
|
<h2>{t('Animations à exporter')}</h2>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
type="button"
|
||||||
|
aria-expanded={exportAnimationsOpen}
|
||||||
|
onClick={() => setExportAnimationsOpen(open => !open)}
|
||||||
|
>
|
||||||
|
{exportAnimationsOpen ? <ChevronUp /> : <ChevronDown />}
|
||||||
|
{t(exportAnimationsOpen ? 'Masquer la sélection' : 'Personnaliser')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{exportAnimationsOpen && (
|
||||||
|
<div className="export-animation-picker">
|
||||||
|
<Button
|
||||||
|
className="export-animation-select-all"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
type="button"
|
type="button"
|
||||||
@ -1940,7 +1710,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
: 'Tout sélectionner'
|
: 'Tout sélectionner'
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
|
||||||
<div className="state-buttons export-animation-grid">
|
<div className="state-buttons export-animation-grid">
|
||||||
{sequences.map(animation => {
|
{sequences.map(animation => {
|
||||||
const firstStep = animation.steps[0]
|
const firstStep = animation.steps[0]
|
||||||
@ -1957,7 +1726,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
onClick={() => toggleExportAnimation(animation.id)}
|
onClick={() => toggleExportAnimation(animation.id)}
|
||||||
>
|
>
|
||||||
<ExpressionPreview
|
<ExpressionPreview
|
||||||
expression={firstExpression ?? expressions[0] ?? defaultExpression}
|
expression={
|
||||||
|
firstExpression ?? expressions[0] ?? defaultExpression
|
||||||
|
}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
bodyNodes={bodyNodes}
|
bodyNodes={bodyNodes}
|
||||||
colors={activeAvatar.colors}
|
colors={activeAvatar.colors}
|
||||||
@ -1965,26 +1736,143 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
|||||||
renderStyle={activeAvatar.renderStyle}
|
renderStyle={activeAvatar.renderStyle}
|
||||||
id={`export-animation-${animation.id}`}
|
id={`export-animation-${animation.id}`}
|
||||||
/>
|
/>
|
||||||
<span>{animation.builtIn ? t(animation.name) : animation.name}</span>
|
<span>
|
||||||
|
{animation.builtIn ? t(animation.name) : animation.name}
|
||||||
|
</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</InspectorCard>
|
</InspectorCard>
|
||||||
|
|
||||||
|
{runtimeExportErrors.length > 0 && (
|
||||||
|
<InspectorCard>
|
||||||
|
<div className="runtime-export-error" role="alert">
|
||||||
|
<div className="runtime-export-error-heading">
|
||||||
|
<TriangleAlert />
|
||||||
|
<strong>{t('Export runtime incomplet')}</strong>
|
||||||
|
</div>
|
||||||
|
<ul className="runtime-error-list">
|
||||||
|
{runtimeExportErrors.map((error, index) => (
|
||||||
|
<li key={`${index}-${error}`}>{error}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
<p className="runtime-export-error-help">
|
||||||
|
{t(
|
||||||
|
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
<div className="runtime-export-error-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => setMode('expressions')}
|
||||||
|
>
|
||||||
|
{t('Expressions')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="outline" onClick={() => setMode('states')}>
|
||||||
|
{t('Animations')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</InspectorCard>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InspectorCard className="runtime-example-card">
|
||||||
|
<div className="runtime-example-heading">
|
||||||
|
<div>
|
||||||
|
<small>{t('Définition prête à tester')}</small>
|
||||||
|
<strong>
|
||||||
|
{selectedExportAnimations.length} {t('animations')} ·{' '}
|
||||||
|
{runtimeDefinitionResult.ok
|
||||||
|
? runtimeDefinitionResult.value.expressionOrder.length
|
||||||
|
: 0}{' '}
|
||||||
|
{t('expressions')}
|
||||||
|
</strong>
|
||||||
|
</div>
|
||||||
|
<div className="runtime-example-actions">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
|
onClick={() => setRuntimePreviewOpen(true)}
|
||||||
|
>
|
||||||
|
<Play />
|
||||||
|
{t('Preview')}
|
||||||
|
</Button>
|
||||||
|
<Button type="button" variant="ghost" onClick={() => setGuideOpen(true)}>
|
||||||
|
<BookOpen />
|
||||||
|
{t('Guide d’utilisation')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{exportFormat === 'javascript' && (
|
||||||
|
<p className="runtime-export-description">
|
||||||
|
{t(
|
||||||
|
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</InspectorCard>
|
||||||
|
|
||||||
|
<div className="runtime-export-actions">
|
||||||
<Button
|
<Button
|
||||||
className="export-download"
|
className="export-download"
|
||||||
type="button"
|
type="button"
|
||||||
disabled={!selectedExportAnimations.length}
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
|
onClick={downloadAvatarRuntimeDefinition}
|
||||||
|
>
|
||||||
|
<Download />
|
||||||
|
{t('Télécharger la définition .avatar.json')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
|
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||||
|
>
|
||||||
|
<Copy />
|
||||||
|
{t('Copier le JSON formaté')}
|
||||||
|
</Button>
|
||||||
|
{exportFormat === 'javascript' && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
disabled={!runtimeDefinitionResult.ok}
|
||||||
onClick={downloadAvatarExport}
|
onClick={downloadAvatarExport}
|
||||||
>
|
>
|
||||||
<Download />
|
<Download />
|
||||||
{t(
|
{t('Télécharger l’intégration ESM (.zip)')}
|
||||||
exportFormat === 'react'
|
|
||||||
? 'Télécharger le package React'
|
|
||||||
: 'Télécharger le module'
|
|
||||||
)}
|
|
||||||
</Button>
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{runtimeCopyStatus !== 'idle' && (
|
||||||
|
<p
|
||||||
|
className="runtime-copy-status"
|
||||||
|
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
|
||||||
|
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
|
||||||
|
>
|
||||||
|
{t(
|
||||||
|
runtimeCopyStatus === 'success'
|
||||||
|
? 'JSON runtime copié dans le presse-papiers.'
|
||||||
|
: 'Impossible de copier le JSON runtime.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
<RuntimePreviewDialog
|
||||||
|
definition={runtimeDefinitionResult.ok ? runtimeDefinitionResult.value : null}
|
||||||
|
initialAnimation={runtimePreviewAnimation}
|
||||||
|
open={runtimePreviewOpen}
|
||||||
|
onOpenChange={setRuntimePreviewOpen}
|
||||||
|
/>
|
||||||
|
<RuntimeGuideDialog
|
||||||
|
animationKey={runtimePreviewAnimation}
|
||||||
|
integration={exportFormat}
|
||||||
|
open={guideOpen}
|
||||||
|
onOpenChange={setGuideOpen}
|
||||||
|
/>
|
||||||
</ExportSection>
|
</ExportSection>
|
||||||
|
|
||||||
<ExportSection
|
<ExportSection
|
||||||
|
|||||||
@ -49,7 +49,6 @@ import {
|
|||||||
avatarDefinitionFileName,
|
avatarDefinitionFileName,
|
||||||
createAvatarDefinition,
|
createAvatarDefinition,
|
||||||
getSemanticKeyIssue,
|
getSemanticKeyIssue,
|
||||||
getStandardAnimationAvailabilityV1,
|
|
||||||
type SemanticKeyIssueCode,
|
type SemanticKeyIssueCode,
|
||||||
} from '@/features/avatar/avatarDefinition'
|
} from '@/features/avatar/avatarDefinition'
|
||||||
import {
|
import {
|
||||||
@ -61,7 +60,6 @@ import {
|
|||||||
type AvatarBehaviorLibrary,
|
type AvatarBehaviorLibrary,
|
||||||
type AvatarColors,
|
type AvatarColors,
|
||||||
type AvatarEyeDefaults,
|
type AvatarEyeDefaults,
|
||||||
type AvatarRenderStyle,
|
|
||||||
type StudioAvatar,
|
type StudioAvatar,
|
||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
import {
|
import {
|
||||||
@ -85,12 +83,7 @@ import {
|
|||||||
} from '@/features/avatar/geometry'
|
} from '@/features/avatar/geometry'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
import {
|
import { avatarExportFileName, generateJavaScriptEsmPackage } from '@/features/export/exporter'
|
||||||
avatarExportFileName,
|
|
||||||
createAvatarExportPayload,
|
|
||||||
generateJavaScriptAvatarPackage,
|
|
||||||
generateReactAvatarPackage,
|
|
||||||
} from '@/features/export/exporter'
|
|
||||||
import {
|
import {
|
||||||
serializeAvatarSnapshot,
|
serializeAvatarSnapshot,
|
||||||
serializePixelSnapshot,
|
serializePixelSnapshot,
|
||||||
@ -114,7 +107,6 @@ import {
|
|||||||
} from '@/features/rendering/renderedScene'
|
} from '@/features/rendering/renderedScene'
|
||||||
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
|
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
|
||||||
import {
|
import {
|
||||||
clearPersistedStudioDocument,
|
|
||||||
createStudioDocumentStore,
|
createStudioDocumentStore,
|
||||||
loadStudioDocument,
|
loadStudioDocument,
|
||||||
parseImportedStudioDocument,
|
parseImportedStudioDocument,
|
||||||
@ -156,7 +148,10 @@ export function useStudioController() {
|
|||||||
const [snapshotSize, setSnapshotSize] = useState('1024')
|
const [snapshotSize, setSnapshotSize] = useState('1024')
|
||||||
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
|
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
|
||||||
const [photoFlash, setPhotoFlash] = useState(0)
|
const [photoFlash, setPhotoFlash] = useState(0)
|
||||||
const [runtimeCopyStatus, setRuntimeCopyStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{
|
||||||
|
status: 'idle' | 'success' | 'error'
|
||||||
|
source?: readonly unknown[]
|
||||||
|
}>({ status: 'idle' })
|
||||||
const initialStatePlayback = initialDocument.playback
|
const initialStatePlayback = initialDocument.playback
|
||||||
const updateStudioLibrary = (library: typeof initialDocument.library) =>
|
const updateStudioLibrary = (library: typeof initialDocument.library) =>
|
||||||
documentStore.update({ library })
|
documentStore.update({ library })
|
||||||
@ -758,10 +753,6 @@ export function useStudioController() {
|
|||||||
setDisplayColors(resolveColors(expression, colors))
|
setDisplayColors(resolveColors(expression, colors))
|
||||||
}
|
}
|
||||||
|
|
||||||
const updateAvatarRenderStyle = (renderStyle: AvatarRenderStyle) => {
|
|
||||||
updateActiveAvatar(avatar => ({ ...avatar, renderStyle }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateAvatarEyes = (changes: Partial<AvatarEyeDefaults>) => {
|
const updateAvatarEyes = (changes: Partial<AvatarEyeDefaults>) => {
|
||||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||||
if (!avatar) return
|
if (!avatar) return
|
||||||
@ -1481,6 +1472,12 @@ export function useStudioController() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0]
|
const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0]
|
||||||
|
const runtimeCopySource = [activeAvatar, exportAnimationIds, expressions, sequences] as const
|
||||||
|
const runtimeCopyStatus =
|
||||||
|
runtimeCopyFeedback.source?.length === runtimeCopySource.length &&
|
||||||
|
runtimeCopyFeedback.source.every((value, index) => value === runtimeCopySource[index])
|
||||||
|
? runtimeCopyFeedback.status
|
||||||
|
: 'idle'
|
||||||
const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes
|
const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes
|
||||||
const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null
|
const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null
|
||||||
const activeSequenceLabel = activeSequence
|
const activeSequenceLabel = activeSequence
|
||||||
@ -1523,49 +1520,47 @@ export function useStudioController() {
|
|||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const runtimeDefinitionResult = createAvatarDefinition({
|
|
||||||
avatar: activeAvatar,
|
|
||||||
behavior: { expressions, sequences },
|
|
||||||
})
|
|
||||||
const runtimeExportErrors = runtimeDefinitionResult.ok
|
|
||||||
? []
|
|
||||||
: [
|
|
||||||
...new Set(
|
|
||||||
runtimeDefinitionResult.errors
|
|
||||||
.filter(
|
|
||||||
error =>
|
|
||||||
error.code !== 'unresolved_expression_reference' ||
|
|
||||||
!runtimeDefinitionResult.errors.some(candidate =>
|
|
||||||
candidate.path.startsWith('/studio/expressions/')
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.map(error => {
|
|
||||||
const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
|
|
||||||
if (expressionMatch) {
|
|
||||||
const index = Number(expressionMatch[1])
|
|
||||||
const item = expressions[index]
|
|
||||||
return `${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
|
|
||||||
}
|
|
||||||
const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
|
|
||||||
if (animationMatch) {
|
|
||||||
const index = Number(animationMatch[1])
|
|
||||||
const item = sequences[index]
|
|
||||||
if (error.code === 'unresolved_expression_reference') {
|
|
||||||
return `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
|
|
||||||
}
|
|
||||||
return `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
|
|
||||||
}
|
|
||||||
return `${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`
|
|
||||||
})
|
|
||||||
),
|
|
||||||
]
|
|
||||||
const runtimeStandardAvailability = runtimeDefinitionResult.ok
|
|
||||||
? getStandardAnimationAvailabilityV1(runtimeDefinitionResult.value.expressions)
|
|
||||||
: null
|
|
||||||
const exportAnimationIdSet = new Set(exportAnimationIds)
|
const exportAnimationIdSet = new Set(exportAnimationIds)
|
||||||
const selectedExportAnimations = sequences.filter(animation =>
|
const selectedExportAnimations = sequences.filter(animation =>
|
||||||
exportAnimationIdSet.has(animation.id)
|
exportAnimationIdSet.has(animation.id)
|
||||||
)
|
)
|
||||||
|
const runtimeDefinitionResult = createAvatarDefinition({
|
||||||
|
avatar: activeAvatar,
|
||||||
|
behavior: { expressions, sequences: selectedExportAnimations },
|
||||||
|
})
|
||||||
|
const runtimeExportErrors = runtimeDefinitionResult.ok
|
||||||
|
? []
|
||||||
|
: (() => {
|
||||||
|
const messages = new Set<string>()
|
||||||
|
const hasExpressionErrors = runtimeDefinitionResult.errors.some(error =>
|
||||||
|
error.path.startsWith('/studio/expressions/')
|
||||||
|
)
|
||||||
|
runtimeDefinitionResult.errors.forEach(error => {
|
||||||
|
if (error.code === 'unresolved_expression_reference' && hasExpressionErrors) return
|
||||||
|
const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
|
||||||
|
if (expressionMatch) {
|
||||||
|
const index = Number(expressionMatch[1])
|
||||||
|
const item = expressions[index]
|
||||||
|
messages.add(
|
||||||
|
`${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
|
||||||
|
if (animationMatch) {
|
||||||
|
const index = Number(animationMatch[1])
|
||||||
|
const item = selectedExportAnimations[index]
|
||||||
|
messages.add(
|
||||||
|
error.code === 'unresolved_expression_reference'
|
||||||
|
? `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
|
||||||
|
: `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
messages.add(`${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`)
|
||||||
|
})
|
||||||
|
return [...messages]
|
||||||
|
})()
|
||||||
const toggleExportAnimation = (animationId: string) => {
|
const toggleExportAnimation = (animationId: string) => {
|
||||||
setExportAnimationIds(current =>
|
setExportAnimationIds(current =>
|
||||||
current.includes(animationId)
|
current.includes(animationId)
|
||||||
@ -1574,14 +1569,11 @@ export function useStudioController() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
const downloadAvatarExport = () => {
|
const downloadAvatarExport = () => {
|
||||||
if (!selectedExportAnimations.length) return
|
if (!runtimeDefinitionResult.ok || exportFormat !== 'javascript') return
|
||||||
const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations)
|
downloadBlob(
|
||||||
const isReact = exportFormat === 'react'
|
generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name),
|
||||||
const extension = 'zip'
|
avatarExportFileName(activeAvatar.name, 'zip')
|
||||||
const blob = isReact
|
)
|
||||||
? generateReactAvatarPackage(payload)
|
|
||||||
: generateJavaScriptAvatarPackage(payload, language)
|
|
||||||
downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
|
|
||||||
}
|
}
|
||||||
const downloadAvatarRuntimeDefinition = () => {
|
const downloadAvatarRuntimeDefinition = () => {
|
||||||
if (!runtimeDefinitionResult.ok) return
|
if (!runtimeDefinitionResult.ok) return
|
||||||
@ -1594,17 +1586,17 @@ export function useStudioController() {
|
|||||||
}
|
}
|
||||||
const copyAvatarRuntimeDefinition = async () => {
|
const copyAvatarRuntimeDefinition = async () => {
|
||||||
if (!runtimeDefinitionResult.ok) return
|
if (!runtimeDefinitionResult.ok) return
|
||||||
|
if (!navigator.clipboard) {
|
||||||
|
setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource })
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
if (!navigator.clipboard) throw new Error('Clipboard API unavailable')
|
|
||||||
await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
|
await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
|
||||||
setRuntimeCopyStatus('success')
|
setRuntimeCopyFeedback({ status: 'success', source: runtimeCopySource })
|
||||||
} catch {
|
} catch {
|
||||||
setRuntimeCopyStatus('error')
|
setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const clearLocalStudioDocument = () => {
|
|
||||||
if (clearPersistedStudioDocument()) window.location.reload()
|
|
||||||
}
|
|
||||||
const currentStudioDocument = (): StudioDocument => ({
|
const currentStudioDocument = (): StudioDocument => ({
|
||||||
version: 2,
|
version: 2,
|
||||||
library: { activeAvatarId, avatars },
|
library: { activeAvatarId, avatars },
|
||||||
@ -1878,7 +1870,6 @@ export function useStudioController() {
|
|||||||
commitBodyNode,
|
commitBodyNode,
|
||||||
commitExpressionMove,
|
commitExpressionMove,
|
||||||
commitStateMove,
|
commitStateMove,
|
||||||
clearLocalStudioDocument,
|
|
||||||
confirmStudioProjectImport,
|
confirmStudioProjectImport,
|
||||||
copyAvatarRuntimeDefinition,
|
copyAvatarRuntimeDefinition,
|
||||||
createNewAvatar,
|
createNewAvatar,
|
||||||
@ -1945,7 +1936,6 @@ export function useStudioController() {
|
|||||||
runtimeDefinitionResult,
|
runtimeDefinitionResult,
|
||||||
runtimeCopyStatus,
|
runtimeCopyStatus,
|
||||||
runtimeExportErrors,
|
runtimeExportErrors,
|
||||||
runtimeStandardAvailability,
|
|
||||||
saveAvatarEditing,
|
saveAvatarEditing,
|
||||||
saveEditing,
|
saveEditing,
|
||||||
saveSequenceEditing,
|
saveSequenceEditing,
|
||||||
@ -2003,7 +1993,6 @@ export function useStudioController() {
|
|||||||
toggleStatePlayback,
|
toggleStatePlayback,
|
||||||
transitionToExpression,
|
transitionToExpression,
|
||||||
updateAvatarColors,
|
updateAvatarColors,
|
||||||
updateAvatarRenderStyle,
|
|
||||||
updateAvatarEyeDimension,
|
updateAvatarEyeDimension,
|
||||||
updateAvatarEyePosition,
|
updateAvatarEyePosition,
|
||||||
updateAvatarEyeSize,
|
updateAvatarEyeSize,
|
||||||
|
|||||||
@ -37,12 +37,72 @@ describe('avatar studio translations', () => {
|
|||||||
expect(translateStudioText('Clé sémantique', 'zh-CN')).toBe('语义键')
|
expect(translateStudioText('Clé sémantique', 'zh-CN')).toBe('语义键')
|
||||||
expect(translateStudioText('Exporter le JSON runtime', 'en')).toBe('Export runtime JSON')
|
expect(translateStudioText('Exporter le JSON runtime', 'en')).toBe('Export runtime JSON')
|
||||||
expect(translateStudioText('Exporter le JSON runtime', 'zh-CN')).toBe('导出运行时 JSON')
|
expect(translateStudioText('Exporter le JSON runtime', 'zh-CN')).toBe('导出运行时 JSON')
|
||||||
|
expect(translateStudioText('JSON runtime + createAvatar', 'en')).toBe(
|
||||||
|
'Runtime JSON + createAvatar'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('JSON runtime + createAvatar', 'zh-CN')).toBe(
|
||||||
|
'运行时 JSON + createAvatar'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('JavaScript / ESM', 'en')).toBe('JavaScript / ESM')
|
||||||
|
expect(translateStudioText('JSON runtime + avatar-web', 'zh-CN')).toBe(
|
||||||
|
'运行时 JSON + avatar-web'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
|
||||||
|
'JavaScript avatar usage guide'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Preview de la définition exportée', 'en')).toBe(
|
||||||
|
'Exported definition preview'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Animations exportées', 'zh-CN')).toBe('导出的动画')
|
||||||
|
expect(
|
||||||
|
translateStudioText(
|
||||||
|
'Choisis les animations puis exporte le JSON runtime ou un package autonome.',
|
||||||
|
'en'
|
||||||
|
)
|
||||||
|
).toBe('Choose animations, then export runtime JSON or a standalone package.')
|
||||||
|
expect(
|
||||||
|
translateStudioText(
|
||||||
|
'Choisis les animations puis exporte le JSON runtime ou un package autonome.',
|
||||||
|
'zh-CN'
|
||||||
|
)
|
||||||
|
).toBe('选择动画,然后导出运行时 JSON 或独立包。')
|
||||||
expect(translateStudioText('Nouveau', 'en')).toBe('New')
|
expect(translateStudioText('Nouveau', 'en')).toBe('New')
|
||||||
expect(translateStudioText('Nouveau', 'zh-CN')).toBe('新增')
|
expect(translateStudioText('Nouveau', 'zh-CN')).toBe('新增')
|
||||||
expect(translateStudioText('Démarrage rapide npm', 'en')).toBe('npm quick start')
|
|
||||||
expect(translateStudioText('Démarrage rapide npm', 'zh-CN')).toBe('npm 快速开始')
|
|
||||||
expect(translateStudioText('Lancer l’exemple', 'en')).toBe('Run example')
|
expect(translateStudioText('Lancer l’exemple', 'en')).toBe('Run example')
|
||||||
expect(translateStudioText('Lancer l’exemple', 'zh-CN')).toBe('运行示例')
|
expect(translateStudioText('Lancer l’exemple', 'zh-CN')).toBe('运行示例')
|
||||||
|
expect(translateStudioText('Guide d’utilisation', 'en')).toBe('Usage guide')
|
||||||
|
expect(translateStudioText('Guide d’utilisation', 'zh-CN')).toBe('使用指南')
|
||||||
|
expect(translateStudioText('Le rendu Pixel est temporairement désactivé.', 'en')).toBe(
|
||||||
|
'Pixel rendering is temporarily disabled.'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Le rendu Pixel est temporairement désactivé.', 'zh-CN')).toBe(
|
||||||
|
'像素渲染暂时已禁用。'
|
||||||
|
)
|
||||||
|
expect(translateStudioText('Props de l’avatar', 'en')).toBe('Avatar props')
|
||||||
|
expect(translateStudioText('Props de l’avatar', 'zh-CN')).toBe('头像 Props')
|
||||||
|
expect(translateStudioText('Cible et lecture', 'en')).toBe('Target and playback')
|
||||||
|
expect(translateStudioText('Présentation', 'zh-CN')).toBe('外观')
|
||||||
|
expect(translateStudioText('API impérative', 'en')).toBe('Imperative API')
|
||||||
|
expect(translateStudioText('API impérative', 'zh-CN')).toBe('命令式 API')
|
||||||
|
expect(
|
||||||
|
translateStudioText('Retourne l’animation, l’expression et le statut actifs.', 'en')
|
||||||
|
).toBe('Returns the active animation, expression and status.')
|
||||||
|
expect(
|
||||||
|
translateStudioText(
|
||||||
|
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.',
|
||||||
|
'en'
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
'Use Avatar directly when the definition is loaded at runtime or changes between multiple avatars.'
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
translateStudioText(
|
||||||
|
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.',
|
||||||
|
'en'
|
||||||
|
)
|
||||||
|
).toBe(
|
||||||
|
'createAvatar validates the JSON and returns a dedicated component with typed animation keys.'
|
||||||
|
)
|
||||||
expect(
|
expect(
|
||||||
translateStudioText(
|
translateStudioText(
|
||||||
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.',
|
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.',
|
||||||
@ -59,12 +119,12 @@ describe('avatar studio translations', () => {
|
|||||||
'Runtime export is incomplete'
|
'Runtime export is incomplete'
|
||||||
)
|
)
|
||||||
expect(translateStudioText('Export runtime incomplet', 'zh-CN')).toBe('运行时导出不完整')
|
expect(translateStudioText('Export runtime incomplet', 'zh-CN')).toBe('运行时导出不完整')
|
||||||
expect(translateStudioText('Effacer le projet local et recharger', 'en')).toBe(
|
expect(translateStudioText('Personnaliser', 'en')).toBe('Customize')
|
||||||
'Clear local project and reload'
|
expect(translateStudioText('Personnaliser', 'zh-CN')).toBe('自定义')
|
||||||
)
|
expect(translateStudioText('Masquer la sélection', 'en')).toBe('Hide selection')
|
||||||
expect(translateStudioText('Effacer le projet local et recharger', 'zh-CN')).toBe(
|
expect(translateStudioText('Masquer la sélection', 'zh-CN')).toBe('隐藏选择')
|
||||||
'清除本地项目并重新加载'
|
expect(translateStudioText('Expression de départ', 'en')).toBe('Starting expression')
|
||||||
)
|
expect(translateStudioText('Expression de départ', 'zh-CN')).toBe('起始表情')
|
||||||
expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
|
expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
|
||||||
expect(translateStudioText('Copier le JSON formaté', 'zh-CN')).toBe('复制格式化的 JSON')
|
expect(translateStudioText('Copier le JSON formaté', 'zh-CN')).toBe('复制格式化的 JSON')
|
||||||
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'en')).toBe(
|
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'en')).toBe(
|
||||||
|
|||||||
@ -85,6 +85,8 @@ const english: Record<string, string> = {
|
|||||||
'Choisis la finition visuelle propre à cet avatar.':
|
'Choisis la finition visuelle propre à cet avatar.':
|
||||||
'Choose the visual finish specific to this avatar.',
|
'Choose the visual finish specific to this avatar.',
|
||||||
'Type de rendu': 'Rendering type',
|
'Type de rendu': 'Rendering type',
|
||||||
|
'Le rendu Pixel est temporairement désactivé.': 'Pixel rendering is temporarily disabled.',
|
||||||
|
'Le mode Vectoriel est utilisé pour l’instant.': 'Vector rendering is currently used.',
|
||||||
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
|
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
|
||||||
'Pixel uses a hard palette, with no smoothing or intermediate colors.',
|
'Pixel uses a hard palette, with no smoothing or intermediate colors.',
|
||||||
Vectoriel: 'Vector',
|
Vectoriel: 'Vector',
|
||||||
@ -129,27 +131,145 @@ const english: Record<string, string> = {
|
|||||||
Nouveau: 'New',
|
Nouveau: 'New',
|
||||||
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
|
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
|
||||||
'Export the .avatar.json file used by the new npm packages.',
|
'Export the .avatar.json file used by the new npm packages.',
|
||||||
|
'JSON runtime + createAvatar': 'Runtime JSON + createAvatar',
|
||||||
'Télécharge la définition portable complète de l’avatar actif.':
|
'Télécharge la définition portable complète de l’avatar actif.':
|
||||||
'Download the complete portable definition for the active avatar.',
|
'Download the complete portable definition for the active avatar.',
|
||||||
'Définition runtime': 'Runtime definition',
|
'Définition runtime': 'Runtime definition',
|
||||||
'Fichier .avatar.json portable': 'Portable .avatar.json file',
|
'Fichier .avatar.json portable': 'Portable .avatar.json file',
|
||||||
'Prêt pour l’export runtime': 'Ready for runtime export',
|
|
||||||
'Export runtime incomplet': 'Runtime export is incomplete',
|
'Export runtime incomplet': 'Runtime export is incomplete',
|
||||||
'Effacer le projet local et recharger': 'Clear local project and reload',
|
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.':
|
||||||
'animations standard disponibles': 'standard animations available',
|
'Fix the highlighted keys in the Expressions or Animations editors.',
|
||||||
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
|
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
|
||||||
'Copier le JSON formaté': 'Copy formatted JSON',
|
'Copier le JSON formaté': 'Copy formatted JSON',
|
||||||
'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.',
|
'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.',
|
||||||
'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
|
'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
|
||||||
'Démarrage rapide npm': 'npm quick start',
|
|
||||||
'Ces commandes fonctionneront après la publication des packages actuellement privés.':
|
|
||||||
'These commands will work after the currently private packages are published.',
|
|
||||||
Installation: 'Installation',
|
Installation: 'Installation',
|
||||||
'Exemple React minimal': 'Minimal React example',
|
|
||||||
'Lancer l’exemple': 'Run example',
|
'Lancer l’exemple': 'Run example',
|
||||||
'Masquer l’aperçu': 'Hide preview',
|
'Masquer l’aperçu': 'Hide preview',
|
||||||
'Aperçu avec le package React': 'Preview using the React package',
|
'Aperçu avec le package React': 'Preview using the React package',
|
||||||
|
Preview: 'Preview',
|
||||||
|
'Définition prête à tester': 'Definition ready to test',
|
||||||
|
'Preview de la définition exportée': 'Exported definition preview',
|
||||||
|
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
||||||
|
'Test the animations and expressions actually included in the .avatar.json file.',
|
||||||
|
'Fermer la preview': 'Close preview',
|
||||||
|
'Définition exportée': 'Exported definition',
|
||||||
|
'Animation active': 'Active animation',
|
||||||
|
'Expression active': 'Active expression',
|
||||||
|
'Contrôles de lecture': 'Playback controls',
|
||||||
|
'Animations exportées': 'Exported animations',
|
||||||
|
'Expressions exportées': 'Exported expressions',
|
||||||
|
'Clique pour lancer': 'Click to play',
|
||||||
|
'Clique pour afficher': 'Click to display',
|
||||||
|
'Aucune animation exportée': 'No exported animation',
|
||||||
'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
|
'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
|
||||||
|
'Animation de départ': 'Starting animation',
|
||||||
|
'Expression de départ': 'Starting expression',
|
||||||
|
'Aucune animation sélectionnée': 'No animation selected',
|
||||||
|
'Guide d’utilisation': 'Usage guide',
|
||||||
|
'Guide d’utilisation de l’avatar React': 'React avatar usage guide',
|
||||||
|
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript avatar usage guide',
|
||||||
|
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
||||||
|
'Install the package, create your component and choose the right control level.',
|
||||||
|
'Fermer le guide': 'Close guide',
|
||||||
|
'Ajoute le package React et ses dépendances.': 'Add the React package and its dependencies.',
|
||||||
|
'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.':
|
||||||
|
'Install the ESM module, load the JSON definition and mount the avatar in a DOM element.',
|
||||||
|
'Ajoute le renderer DOM, qui utilise automatiquement avatar-core.':
|
||||||
|
'Add the DOM renderer, which automatically uses avatar-core.',
|
||||||
|
'Utilisation avec un bundler ESM': 'Using an ESM bundler',
|
||||||
|
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.':
|
||||||
|
'Vite and modern bundlers resolve the package and import the same .avatar.json file as React.',
|
||||||
|
'Options de createAvatar': 'createAvatar options',
|
||||||
|
'Référence des valeurs acceptées lors du montage dans le DOM.':
|
||||||
|
'Reference for values accepted when mounting into the DOM.',
|
||||||
|
'Obligatoire. Définition JSON validée avant la création des éléments SVG.':
|
||||||
|
'Required. JSON definition validated before the SVG elements are created.',
|
||||||
|
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.':
|
||||||
|
'Optional. Animation started on mount when autoplay is true. Mutually exclusive with defaultExpression.',
|
||||||
|
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
|
||||||
|
'Optional. Initial expression displayed without starting a timeline. Mutually exclusive with defaultAnimation.',
|
||||||
|
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.':
|
||||||
|
'Optional, defaults to true. Only controls whether defaultAnimation starts automatically.',
|
||||||
|
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.':
|
||||||
|
'Optional, defaults to 240. CSS width and height of the rendered container.',
|
||||||
|
'Optionnelle. Classe CSS ajoutée au conteneur rendu.':
|
||||||
|
'Optional. CSS class added to the rendered container.',
|
||||||
|
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.':
|
||||||
|
'Optional, defaults to “Procedural avatar”. Accessible name for the rendered avatar.',
|
||||||
|
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.':
|
||||||
|
'Optional. Receives unknown-key errors encountered during initialization.',
|
||||||
|
'Optionnelle. Appelée lorsqu’une animation once se termine.':
|
||||||
|
'Optional. Called when a once animation completes.',
|
||||||
|
'Optionnelle. Appelée lorsque l’expression active change.':
|
||||||
|
'Optional. Called when the active expression changes.',
|
||||||
|
'API du contrôleur DOM': 'DOM controller API',
|
||||||
|
'createAvatar retourne immédiatement ces commandes impératives.':
|
||||||
|
'createAvatar immediately returns these imperative commands.',
|
||||||
|
'Lance ou reprend une animation par sa clé.': 'Starts or resumes an animation by key.',
|
||||||
|
'Affiche une expression avec une transition courte.':
|
||||||
|
'Displays an expression with a short transition.',
|
||||||
|
'Arrête la lecture et revient à neutral.': 'Stops playback and returns to neutral.',
|
||||||
|
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.':
|
||||||
|
'Cancels the scheduled frame and removes only the container created by avatar-web.',
|
||||||
|
'Navigateur sans bundler': 'Browser without a bundler',
|
||||||
|
'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.':
|
||||||
|
'Use an ESM URL through a CDN or import map, then load the definition with fetch.',
|
||||||
|
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.':
|
||||||
|
'The packages are still private. This command will work after publication; use the workspace or tarballs for local testing.',
|
||||||
|
'API recommandée : créer un avatar concret': 'Recommended API: create a concrete avatar',
|
||||||
|
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.':
|
||||||
|
'createAvatar validates the JSON and returns a dedicated component with typed animation keys.',
|
||||||
|
'Props de l’avatar': 'Avatar props',
|
||||||
|
'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.':
|
||||||
|
'Complete reference: type, default value, behavior and constraints for every prop.',
|
||||||
|
'Cible et lecture': 'Target and playback',
|
||||||
|
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.':
|
||||||
|
'Required. Validated AvatarDefinition object containing the expressions and animations to display.',
|
||||||
|
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.':
|
||||||
|
'Optional. Controls a timeline by key. Each step chooses the displayed expression. Mutually exclusive with expression; a controlled target takes priority over default values.',
|
||||||
|
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.':
|
||||||
|
'Optional. Directly controls an expression by key. Mutually exclusive with animation; a controlled target takes priority over default values.',
|
||||||
|
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.':
|
||||||
|
'Optional. Defines the initial timeline in uncontrolled mode. Read on mount; autoplay is enabled by default. Mutually exclusive with defaultExpression.',
|
||||||
|
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
|
||||||
|
'Optional. Defines the initial expression in uncontrolled mode. Read on mount without starting a timeline. Mutually exclusive with defaultAnimation.',
|
||||||
|
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.':
|
||||||
|
'Optional, defaults to true. Automatically starts defaultAnimation; without defaultAnimation, it has no effect.',
|
||||||
|
'Optionnelle. Donne accès à l’API impérative AvatarController.':
|
||||||
|
'Optional. Provides access to the imperative AvatarController API.',
|
||||||
|
Présentation: 'Presentation',
|
||||||
|
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.':
|
||||||
|
'Optional, defaults to 240. Number or CSS value used for the container width and height.',
|
||||||
|
'Optionnelle. Classe CSS ajoutée au conteneur externe.':
|
||||||
|
'Optional. CSS class added to the outer container.',
|
||||||
|
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.':
|
||||||
|
'Optional. Inline styles for the outer container; width and height come from size.',
|
||||||
|
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.':
|
||||||
|
'Optional, defaults to “Procedural avatar”. Accessible name announced to screen readers.',
|
||||||
|
'Callbacks de lecture': 'Playback callbacks',
|
||||||
|
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.':
|
||||||
|
'Optional. Receives the key of a once animation when it completes naturally.',
|
||||||
|
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.':
|
||||||
|
'Optional. Receives the expression key whenever the displayed semantic expression changes.',
|
||||||
|
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.':
|
||||||
|
'Optional. Receives a typed error when an animation, expression or default prop references an unknown key.',
|
||||||
|
'Avatar générique': 'Generic Avatar',
|
||||||
|
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.':
|
||||||
|
'Use Avatar directly when the definition is loaded at runtime or changes between multiple avatars.',
|
||||||
|
'API impérative': 'Imperative API',
|
||||||
|
'La ref expose les commandes de lecture et l’état courant de l’avatar.':
|
||||||
|
'The ref exposes playback commands and the avatar’s current state.',
|
||||||
|
'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.':
|
||||||
|
'Target commands are available in uncontrolled mode; otherwise use props.',
|
||||||
|
'Lance ou reprend une animation et retourne un résultat typé.':
|
||||||
|
'Starts or resumes an animation and returns a typed result.',
|
||||||
|
'Met en pause la timeline à sa position exacte.': 'Pauses the timeline at its exact position.',
|
||||||
|
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.':
|
||||||
|
'In uncontrolled mode, stops playback and returns to neutral. In controlled mode, props remain the source of truth.',
|
||||||
|
'Affiche directement une expression.': 'Directly displays an expression.',
|
||||||
|
'Retourne l’animation, l’expression et le statut actifs.':
|
||||||
|
'Returns the active animation, expression and status.',
|
||||||
Expression: 'Expression',
|
Expression: 'Expression',
|
||||||
Animation: 'Animation',
|
Animation: 'Animation',
|
||||||
'Une étape référence une expression qui ne peut pas être exportée.':
|
'Une étape référence une expression qui ne peut pas être exportée.':
|
||||||
@ -159,22 +279,33 @@ const english: Record<string, string> = {
|
|||||||
'Download a standalone component with the animations you choose.',
|
'Download a standalone component with the animations you choose.',
|
||||||
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
|
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
|
||||||
'Generate the existing standalone React or JavaScript ZIP export.',
|
'Generate the existing standalone React or JavaScript ZIP export.',
|
||||||
|
'Choisis les animations puis exporte le JSON runtime ou un package autonome.':
|
||||||
|
'Choose animations, then export runtime JSON or a standalone package.',
|
||||||
|
'Choisis les animations puis utilise la même définition JSON avec React ou JavaScript.':
|
||||||
|
'Choose animations, then use the same JSON definition with React or JavaScript.',
|
||||||
'Avatar sélectionné': 'Selected avatar',
|
'Avatar sélectionné': 'Selected avatar',
|
||||||
Format: 'Format',
|
Format: 'Format',
|
||||||
'Choisis l’intégration correspondant à ton projet.':
|
'Choisis l’intégration correspondant à ton projet.':
|
||||||
'Choose the integration that matches your project.',
|
'Choose the integration that matches your project.',
|
||||||
'Composant TSX autonome': 'Standalone TSX component',
|
'Composant TSX autonome': 'Standalone TSX component',
|
||||||
'Package React local (.zip)': 'Local React package (.zip)',
|
'Package React local (.zip)': 'Local React package (.zip)',
|
||||||
'Module JavaScript': 'JavaScript module',
|
'JavaScript / ESM': 'JavaScript / ESM',
|
||||||
|
'JSON runtime + avatar-web': 'Runtime JSON + avatar-web',
|
||||||
'Module ES autonome': 'Standalone ES module',
|
'Module ES autonome': 'Standalone ES module',
|
||||||
'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)',
|
'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)',
|
||||||
sélectionnées: 'selected',
|
sélectionnées: 'selected',
|
||||||
'Animations à exporter': 'Animations to export',
|
'Animations à exporter': 'Animations to export',
|
||||||
'Tout sélectionner': 'Select all',
|
'Tout sélectionner': 'Select all',
|
||||||
'Tout désélectionner': 'Deselect all',
|
'Tout désélectionner': 'Deselect all',
|
||||||
|
Personnaliser: 'Customize',
|
||||||
|
'Masquer la sélection': 'Hide selection',
|
||||||
'Télécharger le composant TSX': 'Download TSX component',
|
'Télécharger le composant TSX': 'Download TSX component',
|
||||||
'Télécharger le package React': 'Download React package',
|
'Télécharger le package React': 'Download React package',
|
||||||
'Télécharger le module': 'Download module',
|
'Télécharger le module': 'Download module',
|
||||||
|
'Intégration ESM avec le package avatar-web': 'ESM integration with the avatar-web package',
|
||||||
|
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
|
||||||
|
'The ZIP contains the exported JSON, an index.html demo and its README. The demo loads avatar-web from a CDN.',
|
||||||
|
'Télécharger l’intégration ESM (.zip)': 'Download ESM integration (.zip)',
|
||||||
Snapshot: 'Snapshot',
|
Snapshot: 'Snapshot',
|
||||||
'Mode photo': 'Photo Mode',
|
'Mode photo': 'Photo Mode',
|
||||||
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
|
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
|
||||||
|
|||||||
140
src/i18n/zh.ts
140
src/i18n/zh.ts
@ -54,6 +54,8 @@ export const chinese: Record<string, string> = {
|
|||||||
Rendu: '渲染',
|
Rendu: '渲染',
|
||||||
'Choisis la finition visuelle propre à cet avatar.': '选择此头像专属的视觉效果。',
|
'Choisis la finition visuelle propre à cet avatar.': '选择此头像专属的视觉效果。',
|
||||||
'Type de rendu': '渲染类型',
|
'Type de rendu': '渲染类型',
|
||||||
|
'Le rendu Pixel est temporairement désactivé.': '像素渲染暂时已禁用。',
|
||||||
|
'Le mode Vectoriel est utilisé pour l’instant.': '当前使用矢量渲染。',
|
||||||
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
|
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
|
||||||
'像素渲染使用纯色调色板,不进行平滑处理,也不产生中间色。',
|
'像素渲染使用纯色调色板,不进行平滑处理,也不产生中间色。',
|
||||||
Vectoriel: '矢量',
|
Vectoriel: '矢量',
|
||||||
@ -99,26 +101,137 @@ export const chinese: Record<string, string> = {
|
|||||||
Nouveau: '新增',
|
Nouveau: '新增',
|
||||||
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
|
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
|
||||||
'导出新 npm 包使用的 .avatar.json 文件。',
|
'导出新 npm 包使用的 .avatar.json 文件。',
|
||||||
|
'JSON runtime + createAvatar': '运行时 JSON + createAvatar',
|
||||||
'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
|
'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
|
||||||
'Définition runtime': '运行时定义',
|
'Définition runtime': '运行时定义',
|
||||||
'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
|
'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
|
||||||
'Prêt pour l’export runtime': '可以导出运行时定义',
|
|
||||||
'Export runtime incomplet': '运行时导出不完整',
|
'Export runtime incomplet': '运行时导出不完整',
|
||||||
'Effacer le projet local et recharger': '清除本地项目并重新加载',
|
'Corrige les clés signalées dans les éditeurs Expressions ou Animations.':
|
||||||
'animations standard disponibles': '个标准动画可用',
|
'请在“表情”或“动画”编辑器中修正标记的键。',
|
||||||
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
|
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
|
||||||
'Copier le JSON formaté': '复制格式化的 JSON',
|
'Copier le JSON formaté': '复制格式化的 JSON',
|
||||||
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
|
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
|
||||||
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
|
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
|
||||||
'Démarrage rapide npm': 'npm 快速开始',
|
|
||||||
'Ces commandes fonctionneront après la publication des packages actuellement privés.':
|
|
||||||
'这些命令将在当前私有包发布后可用。',
|
|
||||||
Installation: '安装',
|
Installation: '安装',
|
||||||
'Exemple React minimal': '最简 React 示例',
|
|
||||||
'Lancer l’exemple': '运行示例',
|
'Lancer l’exemple': '运行示例',
|
||||||
'Masquer l’aperçu': '隐藏预览',
|
'Masquer l’aperçu': '隐藏预览',
|
||||||
'Aperçu avec le package React': '使用 React 包预览',
|
'Aperçu avec le package React': '使用 React 包预览',
|
||||||
|
Preview: '预览',
|
||||||
|
'Définition prête à tester': '定义已可测试',
|
||||||
|
'Preview de la définition exportée': '导出定义预览',
|
||||||
|
'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
|
||||||
|
'测试 .avatar.json 文件中实际包含的动画和表情。',
|
||||||
|
'Fermer la preview': '关闭预览',
|
||||||
|
'Définition exportée': '导出的定义',
|
||||||
|
'Animation active': '当前动画',
|
||||||
|
'Expression active': '当前表情',
|
||||||
|
'Contrôles de lecture': '播放控制',
|
||||||
|
'Animations exportées': '导出的动画',
|
||||||
|
'Expressions exportées': '导出的表情',
|
||||||
|
'Clique pour lancer': '点击播放',
|
||||||
|
'Clique pour afficher': '点击显示',
|
||||||
|
'Aucune animation exportée': '没有导出的动画',
|
||||||
'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
|
'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
|
||||||
|
'Animation de départ': '起始动画',
|
||||||
|
'Expression de départ': '起始表情',
|
||||||
|
'Aucune animation sélectionnée': '未选择动画',
|
||||||
|
'Guide d’utilisation': '使用指南',
|
||||||
|
'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
|
||||||
|
'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
|
||||||
|
'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
|
||||||
|
'安装包,创建组件,并选择合适的控制方式。',
|
||||||
|
'Fermer le guide': '关闭指南',
|
||||||
|
'Ajoute le package React et ses dépendances.': '添加 React 包及其依赖项。',
|
||||||
|
'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.':
|
||||||
|
'安装 ESM 模块,加载 JSON 定义,并将头像挂载到 DOM 元素中。',
|
||||||
|
'Ajoute le renderer DOM, qui utilise automatiquement avatar-core.':
|
||||||
|
'添加 DOM 渲染器,它会自动使用 avatar-core。',
|
||||||
|
'Utilisation avec un bundler ESM': '使用 ESM 打包工具',
|
||||||
|
'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.':
|
||||||
|
'Vite 和现代打包工具会解析该包,并导入与 React 相同的 .avatar.json 文件。',
|
||||||
|
'Options de createAvatar': 'createAvatar 选项',
|
||||||
|
'Référence des valeurs acceptées lors du montage dans le DOM.': '挂载到 DOM 时可用值的参考。',
|
||||||
|
'Obligatoire. Définition JSON validée avant la création des éléments SVG.':
|
||||||
|
'必填。创建 SVG 元素之前会验证 JSON 定义。',
|
||||||
|
'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.':
|
||||||
|
'可选。当 autoplay 为 true 时在挂载时启动动画。不能与 defaultExpression 同时使用。',
|
||||||
|
'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
|
||||||
|
'可选。显示初始表情但不启动时间线。不能与 defaultAnimation 同时使用。',
|
||||||
|
'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.':
|
||||||
|
'可选,默认 true。仅控制 defaultAnimation 是否自动启动。',
|
||||||
|
'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.':
|
||||||
|
'可选,默认 240。渲染容器的 CSS 宽度和高度。',
|
||||||
|
'Optionnelle. Classe CSS ajoutée au conteneur rendu.': '可选。添加到渲染容器的 CSS 类。',
|
||||||
|
'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.':
|
||||||
|
'可选,默认 “Procedural avatar”。渲染头像的可访问名称。',
|
||||||
|
'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.':
|
||||||
|
'可选。接收初始化期间遇到的未知键错误。',
|
||||||
|
'Optionnelle. Appelée lorsqu’une animation once se termine.': '可选。在 once 动画结束时调用。',
|
||||||
|
'Optionnelle. Appelée lorsque l’expression active change.': '可选。在活动表情变化时调用。',
|
||||||
|
'API du contrôleur DOM': 'DOM 控制器 API',
|
||||||
|
'createAvatar retourne immédiatement ces commandes impératives.':
|
||||||
|
'createAvatar 会立即返回这些命令式控制方法。',
|
||||||
|
'Lance ou reprend une animation par sa clé.': '通过键启动或恢复动画。',
|
||||||
|
'Affiche une expression avec une transition courte.': '使用短暂过渡显示表情。',
|
||||||
|
'Arrête la lecture et revient à neutral.': '停止播放并返回 neutral。',
|
||||||
|
'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.':
|
||||||
|
'取消已计划的帧,并仅移除 avatar-web 创建的容器。',
|
||||||
|
'Navigateur sans bundler': '不使用打包工具的浏览器',
|
||||||
|
'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.':
|
||||||
|
'通过 CDN 或 import map 使用 ESM URL,然后通过 fetch 加载定义。',
|
||||||
|
'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.':
|
||||||
|
'这些包目前仍为私有。发布后此命令才可使用;本地测试请使用 workspace 或 tarball。',
|
||||||
|
'API recommandée : créer un avatar concret': '推荐 API:创建具体头像',
|
||||||
|
'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.':
|
||||||
|
'createAvatar 会验证 JSON,并返回一个动画键经过类型约束的专用组件。',
|
||||||
|
'Props de l’avatar': '头像 Props',
|
||||||
|
'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.':
|
||||||
|
'完整参考:每个 prop 的类型、默认值、行为和限制。',
|
||||||
|
'Cible et lecture': '目标与播放',
|
||||||
|
'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.':
|
||||||
|
'必填。经过验证的 AvatarDefinition 对象,包含要显示的表情和动画。',
|
||||||
|
'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.':
|
||||||
|
'可选。通过键控制时间线,每个步骤会选择要显示的表情。不能与 expression 同时使用;受控目标优先于默认值。',
|
||||||
|
'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.':
|
||||||
|
'可选。通过键直接控制一个表情。不能与 animation 同时使用;受控目标优先于默认值。',
|
||||||
|
'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.':
|
||||||
|
'可选。定义非受控模式下的初始时间线;挂载时读取,默认启用 autoplay。不能与 defaultExpression 同时使用。',
|
||||||
|
'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
|
||||||
|
'可选。定义非受控模式下的初始表情;挂载时读取,不会启动时间线。不能与 defaultAnimation 同时使用。',
|
||||||
|
'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.':
|
||||||
|
'可选,默认 true。自动启动 defaultAnimation;没有 defaultAnimation 时无效。',
|
||||||
|
'Optionnelle. Donne accès à l’API impérative AvatarController.':
|
||||||
|
'可选。提供对命令式 AvatarController API 的访问。',
|
||||||
|
Présentation: '外观',
|
||||||
|
'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.':
|
||||||
|
'可选,默认 240。用于容器宽高的数字或 CSS 值。',
|
||||||
|
'Optionnelle. Classe CSS ajoutée au conteneur externe.': '可选。添加到外部容器的 CSS 类。',
|
||||||
|
'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.':
|
||||||
|
'可选。外部容器的内联样式;width 和 height 来自 size。',
|
||||||
|
'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.':
|
||||||
|
'可选,默认 “Procedural avatar”。屏幕阅读器播报的可访问名称。',
|
||||||
|
'Callbacks de lecture': '播放回调',
|
||||||
|
'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.':
|
||||||
|
'可选。在 once 动画自然完成时接收其键。',
|
||||||
|
'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.':
|
||||||
|
'可选。每当显示的语义表情变化时接收表情键。',
|
||||||
|
'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.':
|
||||||
|
'可选。当 animation、expression 或 default prop 引用了未知键时,接收一个类型化错误。',
|
||||||
|
'Avatar générique': '通用 Avatar',
|
||||||
|
'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.':
|
||||||
|
'当定义在运行时加载或需要在多个头像之间切换时,直接使用 Avatar。',
|
||||||
|
'API impérative': '命令式 API',
|
||||||
|
'La ref expose les commandes de lecture et l’état courant de l’avatar.':
|
||||||
|
'ref 提供播放命令和头像当前状态。',
|
||||||
|
'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.':
|
||||||
|
'目标命令仅在非受控模式下可用;否则请使用 props。',
|
||||||
|
'Lance ou reprend une animation et retourne un résultat typé.':
|
||||||
|
'启动或恢复动画,并返回类型化结果。',
|
||||||
|
'Met en pause la timeline à sa position exacte.': '在精确位置暂停时间线。',
|
||||||
|
'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.':
|
||||||
|
'在非受控模式下停止播放并回到 neutral;在受控模式下,props 仍是真实来源。',
|
||||||
|
'Affiche directement une expression.': '直接显示一个表情。',
|
||||||
|
'Retourne l’animation, l’expression et le statut actifs.': '返回当前动画、表情和状态。',
|
||||||
Expression: '表情',
|
Expression: '表情',
|
||||||
Animation: '动画',
|
Animation: '动画',
|
||||||
'Une étape référence une expression qui ne peut pas être exportée.':
|
'Une étape référence une expression qui ne peut pas être exportée.':
|
||||||
@ -128,21 +241,32 @@ export const chinese: Record<string, string> = {
|
|||||||
'下载包含所选动画的独立组件。',
|
'下载包含所选动画的独立组件。',
|
||||||
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
|
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
|
||||||
'生成原有的 React 或 JavaScript 独立 ZIP 导出。',
|
'生成原有的 React 或 JavaScript 独立 ZIP 导出。',
|
||||||
|
'Choisis les animations puis exporte le JSON runtime ou un package autonome.':
|
||||||
|
'选择动画,然后导出运行时 JSON 或独立包。',
|
||||||
|
'Choisis les animations puis utilise la même définition JSON avec React ou JavaScript.':
|
||||||
|
'选择动画,然后在 React 或 JavaScript 中使用同一份 JSON 定义。',
|
||||||
'Avatar sélectionné': '已选头像',
|
'Avatar sélectionné': '已选头像',
|
||||||
Format: '格式',
|
Format: '格式',
|
||||||
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
|
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
|
||||||
'Composant TSX autonome': '独立 TSX 组件',
|
'Composant TSX autonome': '独立 TSX 组件',
|
||||||
'Package React local (.zip)': '本地 React 包(.zip)',
|
'Package React local (.zip)': '本地 React 包(.zip)',
|
||||||
'Module JavaScript': 'JavaScript 模块',
|
'JavaScript / ESM': 'JavaScript / ESM',
|
||||||
|
'JSON runtime + avatar-web': '运行时 JSON + avatar-web',
|
||||||
'Module ES autonome': '独立 ES 模块',
|
'Module ES autonome': '独立 ES 模块',
|
||||||
'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)',
|
'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)',
|
||||||
sélectionnées: '已选择',
|
sélectionnées: '已选择',
|
||||||
'Animations à exporter': '要导出的动画',
|
'Animations à exporter': '要导出的动画',
|
||||||
'Tout sélectionner': '全选',
|
'Tout sélectionner': '全选',
|
||||||
'Tout désélectionner': '取消全选',
|
'Tout désélectionner': '取消全选',
|
||||||
|
Personnaliser: '自定义',
|
||||||
|
'Masquer la sélection': '隐藏选择',
|
||||||
'Télécharger le composant TSX': '下载 TSX 组件',
|
'Télécharger le composant TSX': '下载 TSX 组件',
|
||||||
'Télécharger le package React': '下载 React 包',
|
'Télécharger le package React': '下载 React 包',
|
||||||
'Télécharger le module': '下载模块',
|
'Télécharger le module': '下载模块',
|
||||||
|
'Intégration ESM avec le package avatar-web': '使用 avatar-web 包的 ESM 集成',
|
||||||
|
'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
|
||||||
|
'ZIP 包含导出的 JSON、index.html 演示和 README。演示会从 CDN 加载 avatar-web。',
|
||||||
|
'Télécharger l’intégration ESM (.zip)': '下载 ESM 集成(.zip)',
|
||||||
Snapshot: '快照',
|
Snapshot: '快照',
|
||||||
'Mode photo': '照片模式',
|
'Mode photo': '照片模式',
|
||||||
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user