feat(runtime): ship semantic avatar packages and Studio export

- add the v1 AvatarDefinition schema, bounded parser, strict validation, semantic catalog, pure geometry, scene generation, and deterministic playback to @bible-strong/avatar-core
- add the React 19 renderer with semantic controls, SSR-safe embedded/floating layouts, direct frame updates, pointer and keyboard movement, constraints, callbacks, and accessible controls
- add real tarball packaging and a clean React/Vite consumer using the exported Strobi definition
- add Studio semantic-key authoring, runtime readiness, JSON download/copy, bundled-key recovery, concise errors, targeted local-project clearing, npm guidance, syntax highlighting, and a runnable package preview
- preserve the historical ZIP and Studio project exports, regenerate the standalone engine, and synchronize English, French, and Simplified Chinese copy
- add focused contract, playback, renderer, interaction, persistence, export, and localization coverage
- archive the completed runtime and semantic-curation specs, retain the Vite performance draft, and record the engineering session in TIMELOG.md

Validation:
- pnpm check: 19 test files, 162 tests, typecheck, engine freshness, package builds, and Studio production build passed
- npm pack dry runs: 30 core files and 10 React files
- package smoke: real tarballs installed, typechecked, and built outside the workspace
- browser checks: semantic playback, embedded/floating render, drag, mobile overflow, runtime export recovery, formatted copy, syntax colors, and live package preview

Publication remains disabled: both packages stay private and AGPL-3.0-only pending licensing and repository metadata approval. Vue and Angular adapters remain deferred.
This commit is contained in:
Eric Cappannelli 2026-08-14 15:14:49 -04:00 committed by smontlouis
parent 85ebe0149f
commit 99300b3b30
74 changed files with 11592 additions and 2304 deletions

29
TIMELOG.md Normal file
View File

@ -0,0 +1,29 @@
# TIMELOG - Bible Strong Avatar Lab
> Engineering work log. Times are rounded from observed session timestamps and are provided for
> project traceability, not billing.
## 2026-08-14 - Avatar runtime React v1
| Workstream | Start | End | Duration | Status | Evidence |
| ------------------------------------------- | ----- | ----- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Public contract and core runtime | 13:53 | 14:10 | 17 min | Complete | v1 JSON Schema, bounded parser, Ajv validation, semantic catalog, geometry, scene generation and deterministic playback extracted to `@bible-strong/avatar-core`. |
| React renderer and consumer hardening | 14:10 | 14:25 | 15 min | Complete | Embedded/floating rendering, React 19 controller, SSR portal handoff, drag/keyboard controls, resize constraints, callbacks and direct frame updates validated. |
| Studio semantic export and reported blocker | 14:25 | 14:39 | 14 min | Complete | Restored approved bundled keys, removed cascading errors, added targeted local-project clearing and verified a schema-v1 Strobi export with no public opaque references. |
| Export UX and developer guidance | 14:44 | 14:50 | 6 min | Complete | Flagged the new runtime menu, distinguished the historical ZIP export, added npm quick start, translations and accessible copy feedback. |
| Runnable package preview and visual polish | 14:50 | 15:02 | 12 min | Complete | Added high-contrast syntax coloring and an inline preview rendered by `@bible-strong/avatar-react`, with restartable `idle` playback. |
| Final audit, documentation and archive | 15:02 | 15:20 | 18 min | Complete | Updated the implementation status, archived completed specs, recorded verification evidence and prepared the detailed commit. |
**Recorded total: approximately 1 h 22 min.**
### Verification record
- `pnpm check`: 19 test files and 162 tests passed; typecheck, generated engine, package builds and
production Studio build passed.
- `npm pack --dry-run --json`: 30 allow-listed core files and 10 React files.
- `pnpm packages:smoke`: actual tarballs installed, typechecked and built in a clean non-workspace
React/Vite consumer.
- Browser checks: semantic playback, embedded/floating rendering, dragging, mobile overflow,
runtime export recovery, formatted JSON copy, syntax coloring and live package preview verified.
- Known unrelated local-preview noise: Vercel Analytics and Speed Insights scripts return 404 when
served outside Vercel.

View File

@ -0,0 +1,198 @@
# Vite Dev Server Performance Fix
## Status
Draft - impact review completed on 2026-08-14; corrections and Eric's approval are still required.
## Impact review - 2026-08-14
### Decision
Do not implement this draft unchanged. The watcher and compiler-scope changes are isolated from the
runtime packages, but parts of the diagnosis and validation procedure are inaccurate for the current
workspace.
### Verified current state
- The installed workspace versions are Vite 8.2.1, `@vitejs/plugin-react` 6.0.5,
`@rolldown/plugin-babel` 0.2.3, and `@tailwindcss/vite` 4.3.3. The version sentence in the Problem
section reflects earlier manifest ranges, not the current lockfile.
- `fsevents` 2.3.3 is installed through Vite. Chokidar uses FSEvents when it is available; polling on
macOS is the fallback when FSEvents cannot be used. The draft must not state that Vite
unconditionally polls on macOS.
- Two already-running dev servers for this repository were sampled five times while idle on
2026-08-14. They reported 0-0.1% CPU, so the reported ~200% condition was not reproduced during
this review. A before/after measurement under the condition that triggers the problem is required.
- Vite 8.2.1 already ignores `.git`, `node_modules`, `test-results`, `cacheDir`, and every emptied
build `outDir`. Adding `node_modules` and root `dist` to `server.watch.ignored` is therefore
defensive and mostly redundant, not a fix for a missing default.
- The installed React Compiler preset already has a code filter, but it still admits many pure files
containing capitalized identifiers or hook-like names. Restricting Babel to actual JSX/TSX source
remains a credible optimization.
### Impact on the avatar-runtime work
| Area | Impact |
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Root Studio dev server | Direct. `server.watch` and the root Babel plugin change startup, idle watching, and HMR behavior. |
| `packages/avatar-core` | Source HMR must remain enabled through the root alias. Its own build config is unaffected. The Babel compiler can safely exclude this package because it contains no JSX. |
| `packages/avatar-react` | Its library build config is unaffected. A generic `/src/...tsx/` regex also matches `packages/avatar-react/src/Avatar.tsx`, contrary to the draft's "app only" explanation; an app-root-anchored filter is required. |
| React consumer fixture | No direct impact because it owns a separate Vite config. Its HMR needs a separate smoke check only if that config is changed later. |
| Vitest, tarball smoke, production packages | No direct impact. Their processes/configs are separate, but the normal regression suite must still pass. |
| Standalone engine generation | No idle impact. `pnpm dev` runs `pnpm engine` once before Vite, so startup CPU must be measured separately from steady-state Vite CPU. |
### Required corrections before approval
1. Reword cause 1 as a hypothesis about an FSEvents fallback and capture evidence that the affected
process is actually polling.
2. Reword cause 2 to acknowledge Vite's existing ignore defaults. Add only project-specific paths if
measurements show that they are watched.
3. Anchor the Babel include filter to the absolute root `src/` directory. The proposed
`/src\/.*\.[jt]sx$/` expression is not root-specific.
4. Record both manifest ranges and installed lockfile versions, and repeat measurements after any
dependency install.
5. Start a single strict-port dev server for measurement and resolve its exact PID. `pgrep -f vite`
is ambiguous on this machine because several unrelated Vite servers run concurrently.
6. Measure startup separately from at least 30 seconds of idle CPU, an HMR edit under root `src/`,
and an HMR edit under `packages/avatar-core/src/`.
7. Treat the Vite 7 downgrade as a separate decision that modifies `package.json` and
`pnpm-lock.yaml`; this contradicts the current "vite.config.ts only" file list.
8. Re-evaluate the cited Vite 8/Rolldown memory reports against Vite 8.2.1 and the installed Rolldown
version before using an 800 MB downgrade threshold.
## Problem
The dev server (`pnpm dev`) consumes ~200% CPU on macOS. The project runs Vite 8.0.13 with
`@vitejs/plugin-react` 6.0.2, `@rolldown/plugin-babel` (React Compiler preset),
`@tailwindcss/vite` 4.3.3, and a pnpm workspace monorepo (`packages/avatar-core` linked via
`workspace:*`).
Four documented causes have been identified, ordered by expected impact.
## Root causes
### 1. File watcher defaults to polling on macOS
Vite inherits a legacy default that sets `usePolling: true` on macOS. Instead of using the
kernel's native `FSEvents`, chokidar scans every watched file on a timer. In a monorepo with
`node_modules` symlinks this produces sustained CPU even when no file changes.
**Source**: https://github.com/vitejs/vite/issues/21033
### 2. Watcher scope includes node_modules and dist
No `server.watch.ignored` is configured. The workspace alias
`@bible-strong/avatar-core → packages/avatar-core/src/index.ts` causes the watcher to follow
pnpm symlinks into `node_modules/.pnpm`, multiplying the number of watched paths.
### 3. React Compiler runs on all files
`@rolldown/plugin-babel` with `reactCompilerPreset()` is applied globally. It processes every
`.ts`/`.tsx` file including `packages/avatar-core`, which contains zero React components — pure
geometry, math, and schema validation. The compiler's analysis pass is expensive and wasted on
non-component code.
**Source**: https://github.com/vitejs/vite-plugin-react/discussions/1148
### 4. Vite 8 (Rolldown) baseline memory regression
Vite 8's Rolldown bundler uses ~1.1 GB within 37 seconds vs ~300 MB for Vite 5. Higher memory
pressure triggers frequent GC cycles that manifest as CPU usage.
**Sources**:
- https://github.com/vitejs/rolldown-vite/issues/577
- https://github.com/rolldown/rolldown/issues/9330
## Changes
All changes are in `/vite.config.ts` (root). No other files are modified.
### Change 1 — Disable polling and scope the watcher
Add a `server` block to the Vite config:
```ts
server: {
watch: {
usePolling: false,
ignored: ['**/node_modules/**', '**/dist/**'],
},
},
```
**Why `usePolling: false`**: macOS FSEvents is reliable and near-zero CPU. The polling
fallback exists for network filesystems (NFS/SMB) which do not apply here.
**Why `ignored`**: prevents chokidar from traversing pnpm's `.pnpm` store and build output
directories. These paths never contain source files that need HMR.
### Change 2 — Scope the Babel React Compiler to app source only
Current config (line 14):
```ts
plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
```
Add an `include` filter to the `babel()` call:
```ts
plugins: [
react(),
babel({
presets: [reactCompilerPreset()],
include: [/src\/.*\.[jt]sx$/],
}),
tailwindcss(),
],
```
The pattern `src\/.*\.[jt]sx$` matches only `.jsx`/`.tsx` files under `src/` (the app).
It excludes:
- `packages/avatar-core/` (no React components)
- `.ts` files that are pure logic (no JSX to compile)
### Change 3 — Monitor and consider Vite downgrade (conditional)
If changes 1–2 do not bring CPU below ~30% idle:
1. Run `pnpm dev` and note RSS memory after 60 seconds (`ps -o rss -p $(pgrep -f vite)`).
2. If RSS exceeds 800 MB, the Rolldown memory regression is contributing. Consider pinning
Vite 7 (`"vite": "^7.0.0"`) until Rolldown stabilises. This requires:
- Replacing `@rolldown/plugin-babel` with standard `@vitejs/plugin-react` Babel config
(v5 style), since `@rolldown/plugin-babel` is Vite 8–specific.
- Verifying the Tailwind v4 plugin remains compatible with Vite 7.
This step is **not** part of the default changeset — only pursue it if the first two changes
are insufficient.
## Validation
After applying changes 1–2:
1. `pnpm dev` — verify the server starts and HMR works (edit a `.tsx` in `src/`, confirm
hot reload).
2. Monitor CPU for 30 seconds idle: `top -pid $(pgrep -f vite) -l 5`. Expect < 10% idle CPU
(down from ~200%).
3. Edit a file in `packages/avatar-core/src/` — verify HMR still picks up the change despite
the watcher `ignored` pattern (the alias resolves to source, which is under the project
root and not under `node_modules`).
4. `pnpm build` — verify production build still succeeds (the `server` block does not affect
build).
5. `pnpm test` — verify no test regressions.
## Files modified
| File | Nature of change |
| ---------------- | ------------------------------------------------------------------- |
| `vite.config.ts` | Add `server.watch` config, add `include` filter to `babel()` plugin |
## Out of scope
- Upgrading or downgrading Vite (unless change 3 is triggered).
- Modifying `packages/avatar-core/vite.config.ts` (library build config, not the dev server).
- Changing `vitest.config.ts` (test runner, separate process).
- Tailwind v4 plugin tuning (4.3.3 has no known performance issues per
https://github.com/tailwindlabs/tailwindcss/issues/16911, fixed since 4.0.10).

View File

@ -2,7 +2,98 @@
## Status
Implementation specification - product decisions 1 to 21 recorded on 2026-08-14.
Complete and archived on 2026-08-14. React v1 phases A-E are implemented and verified. The packages
remain private and must not be published until licensing and repository metadata are approved.
Product decisions 1 to 21 were recorded on 2026-08-14.
### Implementation progress - 2026-08-14
| Phase | State | Evidence |
| -------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| A - Contract and pure conversion | Complete | The v1 JSON Schema, bounded duplicate-detecting parser, Ajv validation, public types, Studio conversion and focused boundary tests are implemented. |
| B - Semantic Studio authoring | Complete | The approved bundled catalog has semantic keys. The Studio validates keys, reports export readiness, downloads `.avatar.json`, copies formatted JSON accessibly, and keeps English, French and Simplified Chinese copy synchronized. |
| C - Extract core package | Complete | `@bible-strong/avatar-core` contains the framework-independent contract, geometry, scene generation, semantic lookup and deterministic playback. ESM, declarations and documented entry points build successfully. |
| D - React renderer package | Complete | `@bible-strong/avatar-react` implements React 19 refs, embedded/floating SVG, SSR portal handoff, controlled/uncontrolled playback, direct per-frame rendering, pointer/keyboard movement, bounds, resize behavior and accessible movement controls. |
| E - Existing export integration and package verification | Complete | Both packages build and pack. The smoke script installs their actual tarballs in a clean non-workspace React/Vite consumer, typechecks and builds it. Browser checks covered semantic controls, embedded/floating rendering, drag, reduced motion, mobile overflow, console errors and network responses. |
| F - Additional framework adapters | Deferred | Vue and Angular remain explicitly outside the React v1 scope and require a separate go-ahead. |
### Archive state
Implementation was completed on branch `avatar-runtime`. Do not publish either package and do not
start Vue or Angular adapters without a separate product decision.
#### Runtime export bug reported on 2026-08-14
The Studio displayed one missing-key error for every bundled Expression and Animation, followed by
many secondary unresolved-reference errors. This made the active Strobi avatar appear impossible to
export.
The fix is implemented:
- when the known bundled catalog is loaded without semantic keys, its approved keys are restored by
durable internal ID;
- custom Expressions and Animations are never assigned invented keys;
- unresolved-reference consequences are hidden when an Expression key error already explains the
problem, and identical messages are deduplicated;
- the incomplete-export card offers a destructive `Clear local project and reload` action that
removes only the Studio document storage key before loading the bundled catalog again;
- the same repair applies to base behavior and Avatar-owned behavior;
- focused persistence tests cover the repaired bundled catalog and prove that a custom item remains
unkeyed.
The Export accordion flags `Export runtime JSON` as new and distinguishes it from the pre-existing
`Export avatar` ZIP generator. The former exports runtime data for the npm packages; the latter
continues to generate the historical standalone React or JavaScript ZIP.
The runtime section also includes a compact `<pre>` quick start with the npm install command and a
minimal validated React integration. It explicitly notes that the commands become usable after the
currently private packages are published. The code uses accessible high-contrast syntax coloring,
and a `Run example` control renders the generated definition through the actual public React package
with a restartable `idle` animation.
A production-build browser regression loaded Strobi with all bundled semantic keys removed, opened
Export and observed `Ready for runtime export` and `6/6 standard animations available`. The captured
download was schema v1 with 28 Expressions (including synthesized `neutral`), 23 Animations and zero
public `expression-*` references. The formatted-copy action produced valid JSON and its accessible
success status.
#### Implemented architecture
- `pnpm-workspace.yaml` declares `packages/*` and `examples/*`.
- `packages/avatar-core` owns the public schema/types/validator, bounded JSON parser, semantic
manifests, geometry, surfaces, body model, ambient motion, pure playback state, definition-to-scene
adapter, ESM build, declarations, README and package metadata.
- `src/features/avatar/{geometry,surfaces,body,ambientMotion}.ts` are compatibility re-exports from
the shared core. `src/features/avatar/avatarDefinition.ts` keeps only the Studio-to-public adapter
and re-exports the public contract.
- `packages/avatar-react` owns `<Avatar />`, its typed controller, CSS hooks, embedded/floating
layouts, body portal, pointer capture, keyboard movement, position constraints/callbacks, SVG
rendering and direct per-frame path/transform updates.
- `examples/react-vite-consumer` is the independent React fixture. `scripts/smoke-packages.mjs`
builds and packs both packages, copies the fixture outside the workspace, forces the core
dependency to the local tarball, installs, typechecks and builds.
- Runtime packages remain `private: true` and AGPL-3.0-only. Apache-2.0 relicensing and any npm
publication remain blocked until every copyright holder and repository metadata are confirmed.
- The existing Studio ZIP export still consumes compatibility re-exports and strips new
`semanticKey` fields from its legacy payload to avoid an unintended output change.
#### Final verification recorded on 2026-08-14
- Focused Studio/avatar regression suite: 3 files and 67 tests passed.
- `pnpm check`: passed, including generated-engine freshness, formatting, TypeScript, 19 test files
and 162 tests, both package builds, and the production Studio build.
- `npm pack --dry-run --json`: passed; core contains 30 allow-listed files and React contains 10.
- `pnpm packages:smoke`: passed against newly built tarballs in a clean temporary consumer.
- Browser verification of the tarball consumer observed two SVGs/eight paths, working
`play('idle')` and `setExpression('neutral')`, floating drag, no mobile horizontal overflow, and no
application console or page errors.
- `git diff --check`: passed during final handoff.
#### Post-implementation constraints
- Decide licensing/repository metadata before removing `private: true` or publishing either package.
- Phase F requires a separate product decision; it is not a blocker for React v1.
- Local Vite preview still receives 404 responses for the pre-existing Vercel Analytics and Speed
Insights scripts; the runtime example itself renders without JavaScript errors.
## 1. Context and objective
@ -349,7 +440,13 @@ Implementation steps:
4. Update `defaultStudioDocument.json` with the curated keys.
5. Update default constructors and tests so newly created custom content starts without a key and is visibly marked as not export-ready.
Because the project is pre-release, extend the current v2 document parser, serializer, default document, and behavior-copy operations coherently instead of adding a legacy migration. Existing local documents without semantic keys remain editable, but cannot be exported as runtime definitions until keys are supplied. The parser must round-trip `semanticKey` for both base behavior and avatar-owned behavior; it must not drop it as an unknown field after reload.
Because the project is pre-release, extend the current v2 document parser, serializer, default
document, and behavior-copy operations coherently instead of introducing another schema version.
When approved bundled items are loaded without their keys, restore the known keys by durable internal
ID so the bundled avatar remains exportable. Never invent a key for custom content: custom items
remain editable but cannot be exported until the user supplies a valid key. The parser must
round-trip `semanticKey` for both base behavior and Avatar-owned behavior; it must not drop it as an
unknown field after reload.
### 6.3 Expression resolution during export

View File

@ -0,0 +1,89 @@
# Avatar Runtime Semantic Curation
## Status
Approved by Éric on 2026-08-14. This document is the product/design source for the committed
`STANDARD_ANIMATIONS_V1` runtime manifest.
Implementation state: complete. The approved expression keys, explicit animation keys, and
`STANDARD_ANIMATIONS_V1` manifest are present in the bundled Studio data and runtime contract. This
approval gate no longer blocks phase C; overall implementation progress is tracked in
`20260814-avatar-runtime-npm-package-and-semantic-api.md`.
It implements the human-validation gate required by
`20260814-avatar-runtime-npm-package-and-semantic-api.md`, phase B, step 1. No semantic key or
standard-animation mapping below may be copied into bundled data or runtime code before explicit
approval. That approval has now been recorded below.
## Expression catalogue proposal
The 27 bundled Studio expressions were rendered with the framework-independent SVG geometry engine
and visually inventoried. Internal IDs remain editor-only identifiers and will not be exposed by the
runtime.
| Internal expression ID | Proposed semantic key | Visual reading |
| ------------------------------------------------- | ----------------------- | ---------------------------------------- |
| `expression-00` | `upward-side-glance` | Eyes raised toward one side |
| `expression-01` | `downward-gaze` | Medium eyes looking downward |
| `expression-05` | `skeptical-right` | One vertical eye and one horizontal eye |
| `expression-06` | `small-attentive` | Small attentive eyes |
| `expression-12` | `wide-downward-gaze` | Large round eyes looking downward |
| `expression-03` | `surprised-left` | Large offset eyes with a tilted head |
| `expression-04` | `sleepy-squint` | Narrow tired eyes |
| `expression-07` | `angry-right` | Tense angular gaze |
| `expression-08` | `curious-left` | Uneven sideways gaze |
| `expression-09` | `asymmetric-down-right` | One large and one small eye |
| `expression-10` | `attentive-left` | Slightly tilted attentive gaze |
| `expression-11` | `joyful-wide` | Very large open eyes |
| `expression-13` | `eyes-closed` | Closed, lowered eyes |
| `expression-02` | `joyful-down-right` | Large eyes angled downward |
| `expression-14` | `skeptical-left` | Opposite skeptical gaze |
| `expression-15` | `far-right-glance` | Small eyes strongly offset to one side |
| `expression-16` | `angry-left` | Opposite angular gaze |
| `expression-17` | `playful-right` | Lively uneven gaze |
| `expression-18` | `asymmetric-up-left` | One large and one small raised eye |
| `expression-19` | `gentle-downward-gaze` | Soft downward gaze |
| `expression-20` | `wide-down-left` | Very large lowered eyes |
| `expression-21` | `surprised-wide-left` | Wide round eyes, alternate surprise pose |
| `expression-22` | `drowsy-closed` | Nearly closed raised eyes |
| `expression-23` | `suspicious-right` | Wary sideways gaze |
| `expression-24` | `shy-downward` | Small lowered eyes |
| `expression-3d2bed26-f97c-477d-922f-77600cb10e92` | `angry-brows` | Strongly inward-sloping eyes |
| `expression-5220eaee-32fe-4bd8-ad31-432189534cc8` | `uneasy-left` | Uneven worried gaze |
## Explicit animation-key proposal
The 23 bundled animations already have unique English machine IDs. The proposal is to use each
existing ID unchanged as its `semanticKey`:
`sleeping`, `waking`, `idle`, `listening`, `thinking`, `searching`, `working`, `excited`, `bored`,
`suspicious`, `angry`, `drowsy`, `happy`, `curious`, `confused`, `surprised`, `proud`, `shy`, `sad`,
`laughing`, `scared`, `playful`, and `celebrate`.
## `STANDARD_ANIMATIONS_V1` proposal
This proposal retains the established Studio sequence order, timing, playback, and blink behavior.
All steps use `transitionMs: 500` and `transition: "smooth"`.
| Standard key | Ordered semantic-expression steps | `holdMs` per step | Playback mode | Blink: initial / min / max / duration (ms) |
| ------------ | ---------------------------------------------------------------------------------------------------- | ----------------: | ------------- | ------------------------------------------ |
| `idle` | `upward-side-glance` -> `curious-left` | 5200 | `loop` | 2600 / 3400 / 6200 / 280 |
| `happy` | `joyful-down-right` -> `joyful-wide` -> `playful-right` -> `gentle-downward-gaze` | 2300 | `loop` | 2100 / 2800 / 5000 / 260 |
| `sad` | `sleepy-squint` -> `eyes-closed` -> `drowsy-closed` | 3600 | `loop` | 4800 / 6500 / 9500 / 420 |
| `thinking` | `curious-left` -> `angry-left` -> `skeptical-left` -> `playful-right` -> `skeptical-right` | 2300 | `loop` | 2100 / 2800 / 5000 / 260 |
| `excited` | `joyful-down-right` -> `playful-right` -> `surprised-wide-left` -> `surprised-left` -> `joyful-wide` | 2300 | `loop` | 1200 / 1800 / 3600 / 220 |
| `celebrate` | `joyful-down-right` -> `curious-left` -> `playful-right` | 2300 | `loop` | 1200 / 1800 / 3600 / 220 |
For each row, the required expression keys are the unique keys appearing in its ordered steps. An
explicit animation with the same key will override the corresponding standard animation, as required
by the v1 runtime specification.
## Approval record
Approver: Éric
Decision date: 2026-08-14
Decision: approved as proposed
Requested changes: none recorded

View File

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
<title>Avatar React consumer</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

View File

@ -0,0 +1,23 @@
{
"name": "avatar-react-vite-consumer",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build"
},
"dependencies": {
"@bible-strong/avatar-core": "workspace:*",
"@bible-strong/avatar-react": "workspace:*",
"@vitejs/plugin-react": "^6.0.2",
"vite": "^8.0.13",
"typescript": "~6.0.3",
"react": "19.2.3",
"react-dom": "19.2.3"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3"
}
}

View File

@ -0,0 +1,60 @@
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
import '@bible-strong/avatar-react/styles.css'
import { StrictMode, useRef, useState } from 'react'
import { createRoot } from 'react-dom/client'
import definitionJson from './strobi.avatar.json'
import './styles.css'
const validation = validateAvatarDefinition(definitionJson)
if (!validation.ok) throw new Error(validation.errors[0]?.message)
const definition = validation.value as AvatarDefinition
function Demo() {
const controller = useRef<AvatarController>(null)
const [status, setStatus] = useState('neutral')
const play = () => {
const result = controller.current?.play('idle')
setStatus(result?.ok ? 'idle' : (result?.error.code ?? 'unavailable'))
}
const setNeutral = () => {
const result = controller.current?.setExpression('neutral')
setStatus(result?.ok ? 'neutral' : (result?.error.code ?? 'unavailable'))
}
return (
<main>
<h1>Avatar package consumer</h1>
<section className="avatar-host" aria-label="Embedded avatar example">
<Avatar
ref={controller}
definition={definition}
size={240}
onExpressionChange={setStatus}
ariaLabel="Embedded Strobi avatar"
/>
</section>
<button type="button" onClick={play}>
Play idle
</button>
<button type="button" onClick={setNeutral}>
Set neutral
</button>
<output aria-live="polite">{status}</output>
<Avatar
definition={definition}
mode="floating"
draggable
size={128}
initialPosition={{ right: 24, bottom: 24 }}
ariaLabel="Floating draggable Strobi avatar"
/>
</main>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Demo />
</StrictMode>
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,44 @@
:root {
font-family: system-ui, sans-serif;
color: #111827;
background: #f8fafc;
}
body {
margin: 0;
}
main {
display: grid;
justify-items: center;
gap: 24px;
min-height: 100dvh;
padding: 48px 16px;
box-sizing: border-box;
}
.avatar-host {
width: min(100%, 360px);
min-height: 280px;
display: grid;
place-items: center;
border: 1px solid #cbd5e1;
border-radius: 24px;
background: white;
}
button {
min-height: 44px;
padding: 0 20px;
border: 0;
border-radius: 999px;
color: white;
background: #4338ca;
font: inherit;
cursor: pointer;
}
button:focus-visible {
outline: 3px solid #4338ca;
outline-offset: 3px;
}

View File

@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
"skipLibCheck": true
},
"include": ["src", "vite.config.ts"]
}

View File

@ -0,0 +1,4 @@
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite'
export default defineConfig({ plugins: [react()] })

View File

@ -29,12 +29,17 @@
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm build"
"packages:build": "pnpm --filter './packages/*' build",
"packages:smoke": "node scripts/smoke-packages.mjs",
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm packages:build && pnpm build"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@bible-strong/avatar-core": "workspace:*",
"@bible-strong/avatar-react": "workspace:*",
"@vercel/analytics": "^2.0.1",
"@vercel/speed-insights": "^2.0.0",
"ajv": "^8.20.0",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^1.31.0",
@ -49,11 +54,13 @@
"@babel/parser": "^7.28.6",
"@rolldown/plugin-babel": "^0.2.3",
"@tailwindcss/vite": "^4.3.3",
"@testing-library/react": "^16.3.2",
"@types/node": "^24.10.1",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"babel-plugin-react-compiler": "^1.0.0",
"jsdom": "^30.0.1",
"prettier": "^3.8.1",
"rolldown": "^1.0.0-rc.5",
"tailwindcss": "^4.3.3",

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,74 @@
# @bible-strong/avatar-core
Framework-independent validation, semantic playback and renderer-neutral SVG scene generation for
Bible Strong procedural avatars. The package has no React, DOM, Motion or browser-storage
dependency.
## Install and validate
```sh
pnpm add @bible-strong/avatar-core
```
Use `parseAvatarDefinition` for untrusted JSON text. It enforces the 256 KiB/depth limits and
detects duplicate object keys before validating against the v1 schema. Use
`validateAvatarDefinition` when the value is already materialized.
```ts
import { parseAvatarDefinition } from '@bible-strong/avatar-core'
const parsed = parseAvatarDefinition(jsonText)
if (!parsed.ok) {
throw new Error(`${parsed.errors[0].path}: ${parsed.errors[0].message}`)
}
const definition = parsed.value
```
Both functions return a non-mutating discriminated result. Successful values are deeply frozen;
errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
`@bible-strong/avatar-core/schema`.
## Semantic lookup and playback
Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
for unknown keys; standard animations additionally report
`unavailable_standard_animation` when their required expressions are absent.
```ts
import {
advanceAvatarPlayback,
getStandardAnimationAvailabilityV1,
playAvatarAnimation,
renderAvatarFrame,
} from '@bible-strong/avatar-core'
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
const started = playAvatarAnimation(definition, 'idle', 0)
if (!started.ok) throw new Error(started.error.message)
const state = advanceAvatarPlayback(definition, started.value, 500, {
random: () => 0.5,
})
const scene = renderAvatarFrame(definition, state, 500, {
random: () => 0.5,
reduceMotion: false,
})
```
`advanceAvatarPlayback` is a pure state transition driven by a monotonic timestamp and injected
random source. The timeline for each step is transition then hold. `pauseAvatarPlayback` and
`resumeAvatarPlayback` preserve exact progress. With `reduceMotion: true`, transitions and ambient
motion jump deterministically to their target while configured blinks remain active.
`renderAvatarDefinition` renders a static semantic expression. `renderAvatarFrame` renders an
animated frame. Both return paths, visibility and resolved colors without creating DOM nodes.
## Entry points
- `@bible-strong/avatar-core`: contract, validation, semantic catalog, playback and scene APIs.
- `@bible-strong/avatar-core/schema`: the v1 Draft 2020-12 JSON Schema.
- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
primitives for renderer authors.
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.

View File

@ -0,0 +1,51 @@
{
"name": "@bible-strong/avatar-core",
"version": "0.1.0",
"description": "Framework-independent runtime 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"
},
"./geometry": {
"types": "./dist/geometry.d.ts",
"import": "./dist/geometry.js"
},
"./body": {
"types": "./dist/body.d.ts",
"import": "./dist/body.js"
},
"./surfaces": {
"types": "./dist/surfaces.d.ts",
"import": "./dist/surfaces.js"
},
"./ambient-motion": {
"types": "./dist/ambientMotion.d.ts",
"import": "./dist/ambientMotion.js"
},
"./schema": "./dist/avatarDefinition.schema.json"
},
"scripts": {
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
"ajv": "^8.20.0"
},
"devDependencies": {
"typescript": "~6.0.3",
"vite": "^8.0.13"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@ -0,0 +1,247 @@
import {
advanceAvatarPlayback,
bodyFromDefinition,
createAvatarPlaybackState,
expressionFromDefinition,
parseAvatarDefinition,
playAvatarAnimation,
pauseAvatarPlayback,
poseFromExpression,
renderAvatar,
renderAvatarDefinition,
renderAvatarFrame,
resumeAvatarPlayback,
resolveAnimation,
type AvatarDefinition,
} from '../index'
const expression = {
head: { x: 0, y: 0, z: 0 },
eyes: {
left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
spacing: 54,
},
perspective: 1,
motion: { eyes: 'none', body: 'none' },
} as const
const definition: AvatarDefinition = {
schema: 'bible-strong/avatar-definition',
schemaVersion: 1,
name: 'Core fixture',
body: {
primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
nodes: [],
},
colors: { body: '#5b7fe5', eyes: '#111316' },
expressions: {
neutral: expression,
'upward-side-glance': { ...expression, head: { x: -8, y: 18, z: -4 } },
'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
},
expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
animations: {},
animationOrder: [],
standardAnimationSet: 1,
}
describe('@bible-strong/avatar-core', () => {
it('loads a JSON definition and resolves a standard semantic animation', () => {
const parsed = parseAvatarDefinition(JSON.stringify(definition))
expect(parsed.ok).toBe(true)
if (!parsed.ok) return
const idle = resolveAnimation(parsed.value, 'idle')
expect(idle.ok).toBe(true)
if (!idle.ok) return
expect(idle.value.steps.map(step => step.expression)).toEqual([
'upward-side-glance',
'curious-left',
])
})
it('advances playback deterministically from transition to hold and the next step', () => {
const started = playAvatarAnimation(definition, 'idle', 1_000)
expect(started.ok).toBe(true)
if (!started.ok) return
const holding = advanceAvatarPlayback(definition, started.value, 1_500, {
random: () => 0.5,
})
expect(holding).toMatchObject({
activeAnimation: 'idle',
activeExpression: 'upward-side-glance',
phase: 'hold',
status: 'playing',
})
const next = advanceAvatarPlayback(definition, holding, 6_701, { random: () => 0.5 })
expect(next).toMatchObject({
activeExpression: 'curious-left',
phase: 'transition',
stepIndex: 1,
})
})
it('generates the same geometry through the public definition adapter', () => {
const scene = renderAvatarDefinition(definition, 'curious-left')
const body = bodyFromDefinition(definition.body)
const internalExpression = expressionFromDefinition(
'curious-left',
definition.expressions['curious-left']
)
const direct = renderAvatar(poseFromExpression(internalExpression), body.primary, 1, {
bodyNodes: body.nodes,
})
expect(scene.geometry).toEqual(direct)
expect(scene.colors).toEqual(definition.colors)
})
it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
const narrow: AvatarDefinition = {
...definition,
body: {
primary: {
type: 'cube',
width: 199.00018,
height: 200,
depth: 200,
roundness: 0.5,
},
nodes: [],
},
}
const wide: AvatarDefinition = {
...narrow,
body: {
...narrow.body,
primary: { ...narrow.body.primary, width: 199.00022 },
},
}
const narrowScene = renderAvatarDefinition(narrow)
const wideScene = renderAvatarDefinition(wide)
expect(wideScene.geometry.headPath).not.toBe(narrowScene.geometry.headPath)
expect(wideScene.geometry.wirePaths).not.toEqual(narrowScene.geometry.wirePaths)
})
it('starts from the documented neutral stopped state', () => {
expect(createAvatarPlaybackState()).toMatchObject({
activeExpression: 'neutral',
status: 'stopped',
})
})
it('interpolates a bounded transition and freezes its exact progress while paused', () => {
const started = playAvatarAnimation(definition, 'idle', 1_000)
if (!started.ok) throw new Error(started.error.message)
const neutral = renderAvatarDefinition(definition, 'neutral')
const target = renderAvatarDefinition(definition, 'upward-side-glance')
const halfway = renderAvatarFrame(definition, started.value, 1_250, {
random: () => 0.5,
})
expect(halfway.geometry.leftPath).not.toBe(neutral.geometry.leftPath)
expect(halfway.geometry.leftPath).not.toBe(target.geometry.leftPath)
const paused = pauseAvatarPlayback(started.value, 1_250)
const resumed = resumeAvatarPlayback(paused, 4_250)
expect(resumed.phaseStartedAt).toBe(4_000)
const resumedFrame = renderAvatarFrame(definition, resumed, 4_250, {
random: () => 0.5,
})
expect(resumedFrame.geometry.leftPath).toBe(halfway.geometry.leftPath)
})
it('uses the injectable random source for a deterministic blink timeline', () => {
const started = playAvatarAnimation(definition, 'idle', 1_000)
if (!started.ok) throw new Error(started.error.message)
const blinking = advanceAvatarPlayback(definition, started.value, 3_600, {
random: () => 0,
})
expect(blinking.blinkStartedAt).toBe(3_600)
expect(blinking.blinkDueAt).toBe(7_280)
const open = renderAvatarFrame(definition, blinking, 3_600, { random: () => 0 })
const closed = renderAvatarFrame(definition, blinking, 3_740, { random: () => 0 })
expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
})
it('returns typed errors for unknown and unavailable semantic animations', () => {
expect(resolveAnimation(definition, 'missing')).toMatchObject({
ok: false,
error: { code: 'unknown_animation', key: 'missing' },
})
expect(resolveAnimation(definition, 'happy')).toMatchObject({
ok: false,
error: { code: 'unavailable_standard_animation', key: 'happy' },
})
})
it('lets an explicit animation override the standard animation with the same key', () => {
const overridden: AvatarDefinition = {
...definition,
animations: {
idle: {
playbackMode: 'once',
steps: [{ expression: 'neutral', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
blink: {
enabled: false,
initialDelayMs: 0,
minIntervalMs: 1_000,
maxIntervalMs: 1_000,
durationMs: 100,
},
},
},
animationOrder: ['idle'],
}
expect(resolveAnimation(overridden, 'idle')).toMatchObject({
ok: true,
value: { playbackMode: 'once', steps: [{ expression: 'neutral' }] },
})
})
it('completes once playback and deterministically removes transition motion', () => {
const onceDefinition: AvatarDefinition = {
...definition,
animations: {
once: {
playbackMode: 'once',
steps: [
{
expression: 'curious-left',
holdMs: 100,
transitionMs: 100,
transition: 'smooth',
},
],
blink: {
enabled: false,
initialDelayMs: 0,
minIntervalMs: 1_000,
maxIntervalMs: 1_000,
durationMs: 100,
},
},
},
animationOrder: ['once'],
}
const started = playAvatarAnimation(onceDefinition, 'once', 0)
if (!started.ok) throw new Error(started.error.message)
const reduced = renderAvatarFrame(onceDefinition, started.value, 50, {
random: () => 0.5,
reduceMotion: true,
})
expect(reduced.geometry.leftPath).toBe(
renderAvatarDefinition(onceDefinition, 'curious-left').geometry.leftPath
)
expect(
advanceAvatarPlayback(onceDefinition, started.value, 200, { random: () => 0.5 })
).toMatchObject({
activeExpression: 'curious-left',
status: 'stopped',
})
})
})

View File

@ -0,0 +1,115 @@
import type { BodyMotion, Expression, EyeMotion } from './geometry'
export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const
export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const
const eyeMotionSet = new Set<string>(eyeMotionModes)
const bodyMotionSet = new Set<string>(bodyMotionModes)
export const isEyeMotion = (value: unknown): value is EyeMotion =>
typeof value === 'string' && eyeMotionSet.has(value)
export const isBodyMotion = (value: unknown): value is BodyMotion =>
typeof value === 'string' && bodyMotionSet.has(value)
const smoothstep = (value: number) => value * value * (3 - 2 * value)
const hash = (value: number) => {
const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
return (raw - Math.floor(raw)) * 2 - 1
}
const expressionSeed = (expression: Expression) =>
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
const EYE_MOTION_SEED = 17.29
const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => {
const progress = elapsedMs / interval
const step = Math.floor(progress)
const blend = smoothstep(progress - step)
const previous = hash(step * 3 + axis + seed)
const next = hash((step + 1) * 3 + axis + seed)
return previous + (next - previous) * blend
}
const saccade = (elapsedMs: number, axis: number, seed: number) => {
const interval = 1100
const duration = 140
if (elapsedMs <= 0) return 0
const step = Math.floor(elapsedMs / interval)
const progress = (elapsedMs - step * interval) / duration
const blend = smoothstep(Math.min(progress, 1))
const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
const next = hash(step * 2 + axis + seed)
return previous + (next - previous) * blend
}
export const hasAmbientMotion = (expression: Expression) =>
expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
const seed = expressionSeed(expression)
if (expression.bodyMotion === 'slowDrift') {
return {
x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
}
}
if (expression.bodyMotion === 'shake') {
const time = elapsedMs / 1000
return {
x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
}
}
return { x: 0, y: 0 }
}
export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
if (expression.eyeMotion === 'microSaccades') {
return {
x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
}
}
if (expression.eyeMotion === 'shake') {
const time = elapsedMs / 1000
return {
x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
}
}
return { x: 0, y: 0 }
}
export const applyAmbientBodyMotion = (
expression: Expression,
elapsedMs: number,
strength = 1
): Expression => {
const next = { ...expression }
const seed = expressionSeed(expression)
if (expression.bodyMotion === 'slowDrift') {
next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
} else if (expression.bodyMotion === 'shake') {
const time = elapsedMs / 1000
next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
next.headZ += Math.sin(time * 43) * 0.7 * strength
}
return next
}
export const applyAmbientMotion = (
expression: Expression,
elapsedMs: number,
strength = 1
): Expression => {
const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
next.positionXLeft += eyeOffset.x
next.positionXRight += eyeOffset.x
next.positionYLeft += eyeOffset.y
next.positionYRight += eyeOffset.y
return next
}

View File

@ -0,0 +1,249 @@
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://avatars.bible-strong.app/schemas/avatar-definition-v1.json",
"title": "Bible Strong Avatar Definition v1",
"type": "object",
"additionalProperties": false,
"required": [
"schema",
"schemaVersion",
"body",
"colors",
"expressions",
"expressionOrder",
"animations",
"animationOrder",
"standardAnimationSet"
],
"properties": {
"schema": { "const": "bible-strong/avatar-definition" },
"schemaVersion": { "const": 1 },
"name": { "type": "string", "maxLength": 120 },
"body": { "$ref": "#/$defs/body" },
"colors": { "$ref": "#/$defs/colors" },
"expressions": {
"type": "object",
"minProperties": 1,
"maxProperties": 128,
"required": ["neutral"],
"propertyNames": { "$ref": "#/$defs/semanticKey" },
"properties": { "neutral": { "$ref": "#/$defs/expression" } },
"additionalProperties": { "$ref": "#/$defs/expression" }
},
"expressionOrder": {
"type": "array",
"minItems": 1,
"maxItems": 128,
"uniqueItems": true,
"items": { "$ref": "#/$defs/semanticKey" }
},
"animations": {
"type": "object",
"maxProperties": 64,
"propertyNames": { "$ref": "#/$defs/semanticKey" },
"additionalProperties": { "$ref": "#/$defs/animation" }
},
"animationOrder": {
"type": "array",
"maxItems": 64,
"uniqueItems": true,
"items": { "$ref": "#/$defs/semanticKey" }
},
"standardAnimationSet": { "const": 1 }
},
"$defs": {
"semanticKey": {
"type": "string",
"maxLength": 64,
"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
},
"hexColor": { "type": "string", "pattern": "^#[0-9a-f]{6}$" },
"boundedNumber": { "type": "number", "minimum": -10000, "maximum": 10000 },
"dimension": { "type": "number", "minimum": 0.001, "maximum": 10000 },
"roundness": { "type": "number", "minimum": 0, "maximum": 1 },
"surface": {
"type": "object",
"additionalProperties": false,
"required": ["type", "width", "height", "depth", "roundness"],
"properties": {
"type": {
"enum": ["sphere", "mickey", "cursor", "cube", "capsule", "cylinder", "cone", "diamond"]
},
"width": { "$ref": "#/$defs/dimension" },
"height": { "$ref": "#/$defs/dimension" },
"depth": { "$ref": "#/$defs/dimension" },
"roundness": { "$ref": "#/$defs/roundness" },
"morphRoundness": { "$ref": "#/$defs/roundness" },
"tipRoundness": { "$ref": "#/$defs/roundness" },
"baseRoundness": { "$ref": "#/$defs/roundness" }
}
},
"nodeSurface": {
"allOf": [
{ "$ref": "#/$defs/surface" },
{
"type": "object",
"properties": {
"type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] }
}
}
]
},
"position": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"prefixItems": [
{ "$ref": "#/$defs/boundedNumber" },
{ "$ref": "#/$defs/boundedNumber" },
{ "$ref": "#/$defs/boundedNumber" }
]
},
"rotation": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"prefixItems": [
{ "type": "number", "minimum": -360, "maximum": 360 },
{ "type": "number", "minimum": -360, "maximum": 360 },
{ "type": "number", "minimum": -360, "maximum": 360 }
]
},
"body": {
"type": "object",
"additionalProperties": false,
"required": ["primary", "nodes"],
"properties": {
"primary": { "$ref": "#/$defs/surface" },
"nodes": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["surface", "position", "rotation"],
"properties": {
"surface": { "$ref": "#/$defs/nodeSurface" },
"position": { "$ref": "#/$defs/position" },
"rotation": { "$ref": "#/$defs/rotation" }
}
}
}
}
},
"colors": {
"type": "object",
"additionalProperties": false,
"required": ["body", "eyes"],
"properties": {
"body": { "$ref": "#/$defs/hexColor" },
"eyes": { "$ref": "#/$defs/hexColor" }
}
},
"eye": {
"type": "object",
"additionalProperties": false,
"required": ["width", "height", "x", "y", "angle"],
"properties": {
"width": { "$ref": "#/$defs/boundedNumber" },
"height": { "$ref": "#/$defs/boundedNumber" },
"x": { "$ref": "#/$defs/boundedNumber" },
"y": { "$ref": "#/$defs/boundedNumber" },
"angle": { "$ref": "#/$defs/boundedNumber" }
}
},
"expression": {
"type": "object",
"additionalProperties": false,
"required": ["head", "eyes", "perspective", "motion"],
"properties": {
"head": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y", "z"],
"properties": {
"x": { "$ref": "#/$defs/boundedNumber" },
"y": { "$ref": "#/$defs/boundedNumber" },
"z": { "$ref": "#/$defs/boundedNumber" }
}
},
"eyes": {
"type": "object",
"additionalProperties": false,
"required": ["left", "right", "spacing"],
"properties": {
"left": { "$ref": "#/$defs/eye" },
"right": { "$ref": "#/$defs/eye" },
"spacing": { "$ref": "#/$defs/boundedNumber" }
}
},
"perspective": { "type": "number", "minimum": 0.1, "maximum": 10 },
"motion": {
"type": "object",
"additionalProperties": false,
"required": ["eyes", "body"],
"properties": {
"eyes": { "enum": ["none", "microSaccades", "shake"] },
"body": { "enum": ["none", "slowDrift", "shake"] }
}
},
"colors": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"body": { "$ref": "#/$defs/hexColor" },
"eyes": { "$ref": "#/$defs/hexColor" }
}
}
}
},
"blink": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "initialDelayMs", "minIntervalMs", "maxIntervalMs", "durationMs"],
"properties": {
"enabled": { "type": "boolean" },
"initialDelayMs": { "type": "number", "minimum": 0, "maximum": 60000 },
"minIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
"maxIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
"durationMs": { "type": "number", "minimum": 50, "maximum": 2000 }
}
},
"animation": {
"type": "object",
"additionalProperties": false,
"required": ["playbackMode", "steps", "blink"],
"properties": {
"playbackMode": { "enum": ["loop", "once", "pingPong"] },
"steps": {
"type": "array",
"minItems": 1,
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["expression", "holdMs", "transitionMs", "transition"],
"properties": {
"expression": { "$ref": "#/$defs/semanticKey" },
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
"transition": { "enum": ["spring", "smooth", "snappy"] }
}
}
},
"blink": { "$ref": "#/$defs/blink" },
"metadata": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"label": { "type": "string", "maxLength": 120 },
"description": { "type": "string", "maxLength": 512 },
"group": { "type": "string", "maxLength": 64 }
}
}
}
}
}
}

View File

@ -0,0 +1,617 @@
import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js'
import avatarDefinitionSchema from './avatarDefinition.schema.json'
import type { SurfaceType } from './surfaces'
export const AVATAR_DEFINITION_MAX_BYTES = 262_144
export const AVATAR_DEFINITION_MAX_DEPTH = 32
const MAX_JSON_STRING_LENGTH = 512
export const SEMANTIC_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
export type SemanticKeyIssueCode =
'missing_semantic_key' | 'invalid_semantic_key' | 'reserved_semantic_key'
export const getSemanticKeyIssue = (
semanticKey: string | undefined,
kind: 'expression' | 'animation'
): SemanticKeyIssueCode | undefined => {
if (!semanticKey) return 'missing_semantic_key'
if (!SEMANTIC_KEY_PATTERN.test(semanticKey) || semanticKey.length > 64) {
return 'invalid_semantic_key'
}
if (kind === 'expression' && semanticKey === 'neutral') return 'reserved_semantic_key'
return undefined
}
export type SemanticKey = string
export type ExpressionKey = SemanticKey
export type AnimationKey = SemanticKey
export type HexColor = `#${string}`
export type AvatarColorsDefinition = {
body: HexColor
eyes: HexColor
}
export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
type: TType
width: number
height: number
depth: number
roundness: number
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor'>
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
export type AvatarBodyNodeDefinition = {
surface: BodyNodeSurfaceDefinition
position: [number, number, number]
rotation: [number, number, number]
}
export type AvatarBodyDefinition = {
primary: PrimarySurfaceDefinition
nodes: AvatarBodyNodeDefinition[]
}
export type AvatarExpressionDefinition = {
head: { x: number; y: number; z: number }
eyes: {
left: { width: number; height: number; x: number; y: number; angle: number }
right: { width: number; height: number; x: number; y: number; angle: number }
spacing: number
}
perspective: number
motion: {
eyes: 'none' | 'microSaccades' | 'shake'
body: 'none' | 'slowDrift' | 'shake'
}
colors?: Partial<AvatarColorsDefinition>
}
export type AvatarAnimationStepDefinition = {
expression: ExpressionKey
holdMs: number
transitionMs: number
transition: 'spring' | 'smooth' | 'snappy'
}
export type AvatarAnimationDefinition = {
playbackMode: 'loop' | 'once' | 'pingPong'
steps: AvatarAnimationStepDefinition[]
blink: {
enabled: boolean
initialDelayMs: number
minIntervalMs: number
maxIntervalMs: number
durationMs: number
}
metadata?: {
label?: string
description?: string
group?: string
}
}
export type AvatarDefinition = {
schema: 'bible-strong/avatar-definition'
schemaVersion: 1
name?: string
body: AvatarBodyDefinition
colors: AvatarColorsDefinition
expressions: Record<ExpressionKey, AvatarExpressionDefinition>
expressionOrder: ExpressionKey[]
animations: Record<AnimationKey, AvatarAnimationDefinition>
animationOrder: AnimationKey[]
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 = {
path: string
code: string
message: string
}
export type ValidationResult<T> =
{ ok: true; value: Readonly<T> } | { ok: false; errors: readonly AvatarDefinitionError[] }
const ajv = new Ajv2020({ allErrors: true, strict: true })
const validateSchema = ajv.compile<AvatarDefinition>(avatarDefinitionSchema)
const escapePointer = (value: string) => value.replaceAll('~', '~0').replaceAll('/', '~1')
const childPointer = (path: string, segment: string | number) =>
`${path}/${escapePointer(String(segment))}`
const stringLength = (value: string) => [...value].length
const stringLimitAt = (path: string) => {
if (
/^\/(?:expressionOrder|animationOrder)\/\d+$/.test(path) ||
/^\/animations\/[^/]+\/steps\/\d+\/expression$/.test(path) ||
/^\/animations\/[^/]+\/metadata\/group$/.test(path)
) {
return 64
}
if (path === '/name' || /^\/animations\/[^/]+\/metadata\/label$/.test(path)) return 120
return MAX_JSON_STRING_LENGTH
}
const schemaErrorPath = (error: ErrorObject) => {
const propertyName = (error as ErrorObject & { propertyName?: string }).propertyName
if (propertyName !== undefined) return childPointer(error.instancePath, propertyName)
if (error.keyword === 'required') {
return childPointer(error.instancePath, String(error.params.missingProperty))
}
if (error.keyword === 'additionalProperties') {
return childPointer(error.instancePath, String(error.params.additionalProperty))
}
if (error.keyword === 'propertyNames') {
return childPointer(error.instancePath, String(error.params.propertyName))
}
return error.instancePath
}
const schemaErrorCode = (error: ErrorObject) => {
if (error.instancePath === '/schemaVersion' && error.keyword === 'const') {
return 'unsupported_version'
}
return error.keyword
}
const schemaErrors = (errors: ErrorObject[] | null | undefined): AvatarDefinitionError[] =>
(errors ?? []).map(error => ({
path: schemaErrorPath(error),
code: schemaErrorCode(error),
message: error.message ?? 'Invalid avatar definition',
}))
const inspectMaterializedValue = (value: unknown): AvatarDefinitionError[] => {
const errors: AvatarDefinitionError[] = []
const ancestors = new WeakSet<object>()
const visit = (current: unknown, path: string) => {
if (typeof current === 'number' && !Number.isFinite(current)) {
errors.push({ path, code: 'non_finite_number', message: 'Number must be finite' })
return
}
if (current === null || typeof current !== 'object') return
if (ancestors.has(current)) {
errors.push({ path, code: 'cyclic_value', message: 'Avatar definition must not be cyclic' })
return
}
ancestors.add(current)
if (!Array.isArray(current)) {
const prototype = Object.getPrototypeOf(current)
if (prototype !== Object.prototype && prototype !== null) {
errors.push({ path, code: 'non_plain_object', message: 'Expected a plain object' })
ancestors.delete(current)
return
}
}
Object.entries(current).forEach(([key, child]) => visit(child, childPointer(path, key)))
ancestors.delete(current)
}
visit(value, '')
return errors
}
const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] => {
const errors: AvatarDefinitionError[] = []
const expressionKeys = Object.keys(definition.expressions)
const animationKeys = Object.keys(definition.animations)
const checkCompleteOrder = (
order: string[],
keys: string[],
path: '/expressionOrder' | '/animationOrder'
) => {
const ordered = new Set(order)
keys.forEach(key => {
if (!ordered.has(key)) {
errors.push({
path,
code: 'incomplete_order',
message: `Order is missing key '${key}'`,
})
}
})
order.forEach((key, index) => {
if (!keys.includes(key)) {
errors.push({
path: childPointer(path, index),
code: 'unknown_order_key',
message: `Unknown key '${key}'`,
})
}
})
}
checkCompleteOrder(definition.expressionOrder, expressionKeys, '/expressionOrder')
checkCompleteOrder(definition.animationOrder, animationKeys, '/animationOrder')
if (definition.expressionOrder[0] !== 'neutral') {
errors.push({
path: '/expressionOrder/0',
code: 'neutral_not_first',
message: "'neutral' must be the first expression-order entry",
})
}
Object.entries(definition.animations).forEach(([animationKey, animation]) => {
if (animation.blink.minIntervalMs > animation.blink.maxIntervalMs) {
errors.push({
path: `/animations/${escapePointer(animationKey)}/blink/minIntervalMs`,
code: 'invalid_interval_range',
message: 'minIntervalMs must be less than or equal to maxIntervalMs',
})
}
animation.steps.forEach((step, index) => {
if (!(step.expression in definition.expressions)) {
errors.push({
path: `/animations/${escapePointer(animationKey)}/steps/${index}/expression`,
code: 'unknown_expression',
message: `Unknown expression '${step.expression}'`,
})
}
})
})
return errors
}
const cloneAndFreeze = <T>(value: T): Readonly<T> => {
if (value === null || typeof value !== 'object') return value
const clone: unknown = Array.isArray(value)
? value.map(item => cloneAndFreeze(item))
: Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneAndFreeze(item)]))
return Object.freeze(clone) as Readonly<T>
}
export const validateAvatarDefinition = (value: unknown): ValidationResult<AvatarDefinition> => {
const structuralErrors = inspectMaterializedValue(value)
if (structuralErrors.length) return { ok: false, errors: structuralErrors }
if (!validateSchema(value)) return { ok: false, errors: schemaErrors(validateSchema.errors) }
const errors = semanticErrors(value)
return errors.length ? { ok: false, errors } : { ok: true, value: cloneAndFreeze(value) }
}
class JsonTextError extends Error {
constructor(
readonly path: string,
readonly code: string,
message: string
) {
super(message)
}
}
class BoundedJsonParser {
private index = 0
constructor(private readonly source: string) {}
parse(): unknown {
this.skipWhitespace()
const value = this.parseValue('', 1)
this.skipWhitespace()
if (this.index !== this.source.length)
this.fail('', 'invalid_json', 'Unexpected trailing input')
return value
}
private fail(path: string, code: string, message: string): never {
throw new JsonTextError(path, code, `${message} at character ${this.index}`)
}
private skipWhitespace() {
while (
(this.source[this.index] === ' ' ||
this.source[this.index] === '\n' ||
this.source[this.index] === '\r' ||
this.source[this.index] === '\t') &&
this.index < this.source.length
) {
this.index += 1
}
}
private parseValue(path: string, depth: number): unknown {
this.skipWhitespace()
const character = this.source[this.index]
if (character === '{' || character === '[') {
if (depth > AVATAR_DEFINITION_MAX_DEPTH) {
this.fail(path, 'max_depth', `JSON nesting depth exceeds ${AVATAR_DEFINITION_MAX_DEPTH}`)
}
return character === '{' ? this.parseObject(path, depth) : this.parseArray(path, depth)
}
if (character === '"') return this.parseString(path)
if (character === '-' || (character >= '0' && character <= '9')) return this.parseNumber(path)
if (this.source.startsWith('true', this.index)) return this.parseLiteral('true', true)
if (this.source.startsWith('false', this.index)) return this.parseLiteral('false', false)
if (this.source.startsWith('null', this.index)) return this.parseLiteral('null', null)
this.fail(path, 'invalid_json', 'Expected a JSON value')
}
private parseObject(path: string, depth: number) {
this.index += 1
this.skipWhitespace()
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>
const keys = new Set<string>()
if (this.source[this.index] === '}') {
this.index += 1
return result
}
while (this.index < this.source.length) {
if (this.source[this.index] !== '"') this.fail(path, 'invalid_json', 'Expected an object key')
const key = this.parseString(path)
const keyPath = childPointer(path, key)
if ((path === '/expressions' || path === '/animations') && stringLength(key) > 64) {
this.fail(keyPath, 'string_too_long', 'Semantic key exceeds 64 characters')
}
if (keys.has(key)) this.fail(keyPath, 'duplicate_key', `Duplicate object member '${key}'`)
keys.add(key)
this.skipWhitespace()
if (this.source[this.index] !== ':') this.fail(keyPath, 'invalid_json', "Expected ':'")
this.index += 1
result[key] = this.parseValue(keyPath, depth + 1)
this.skipWhitespace()
const separator = this.source[this.index]
if (separator === '}') {
this.index += 1
return result
}
if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or '}'")
this.index += 1
this.skipWhitespace()
}
this.fail(path, 'invalid_json', 'Unterminated object')
}
private parseArray(path: string, depth: number) {
this.index += 1
this.skipWhitespace()
const result: unknown[] = []
if (this.source[this.index] === ']') {
this.index += 1
return result
}
while (this.index < this.source.length) {
result.push(this.parseValue(childPointer(path, result.length), depth + 1))
this.skipWhitespace()
const separator = this.source[this.index]
if (separator === ']') {
this.index += 1
return result
}
if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or ']'")
this.index += 1
}
this.fail(path, 'invalid_json', 'Unterminated array')
}
private parseString(path: string): string {
const start = this.index
this.index += 1
let escaped = false
while (this.index < this.source.length) {
const character = this.source[this.index]
if (!escaped && character === '"') {
this.index += 1
let value: string
try {
value = JSON.parse(this.source.slice(start, this.index)) as string
} catch {
this.fail(path, 'invalid_json', 'Invalid JSON string')
}
const limit = stringLimitAt(path)
if (stringLength(value) > limit) {
this.fail(path, 'string_too_long', `JSON string exceeds ${limit} characters`)
}
return value
}
if (!escaped && character.charCodeAt(0) < 0x20) {
this.fail(path, 'invalid_json', 'Unescaped control character')
}
if (!escaped && character === '\\') escaped = true
else escaped = false
this.index += 1
if (this.index - start > MAX_JSON_STRING_LENGTH * 12 + 2) {
this.fail(
path,
'string_too_long',
`JSON string exceeds ${MAX_JSON_STRING_LENGTH} characters`
)
}
}
this.fail(path, 'invalid_json', 'Unterminated string')
}
private parseNumber(path: string): number {
const remaining = this.source.slice(this.index)
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(remaining)
if (!match) this.fail(path, 'invalid_json', 'Invalid number')
this.index += match[0].length
const number = Number(match[0])
if (!Number.isFinite(number)) this.fail(path, 'non_finite_number', 'Number must be finite')
return number
}
private parseLiteral<T>(source: string, value: T): T {
this.index += source.length
return value
}
}
export const parseAvatarDefinition = (text: string): ValidationResult<AvatarDefinition> => {
if (new TextEncoder().encode(text).byteLength > AVATAR_DEFINITION_MAX_BYTES) {
return {
ok: false,
errors: [
{
path: '',
code: 'max_bytes',
message: `JSON input exceeds ${AVATAR_DEFINITION_MAX_BYTES} UTF-8 bytes`,
},
],
}
}
try {
return validateAvatarDefinition(new BoundedJsonParser(text).parse())
} catch (error) {
if (error instanceof JsonTextError) {
return {
ok: false,
errors: [{ path: error.path, code: error.code, message: error.message }],
}
}
return {
ok: false,
errors: [{ path: '', code: 'invalid_json', message: 'Invalid JSON input' }],
}
}
}
export const avatarDefinitionFileName = (name: string) => {
const base =
name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '') || 'avatar'
return `${base}.avatar.json`
}

View File

@ -0,0 +1,117 @@
import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
export type BodyVector = readonly [number, number, number]
export type BodyNode = {
id: string
name: string
surface: SurfaceConfig
position: BodyVector
rotation: BodyVector
}
export type AvatarBody = {
primary: SurfaceConfig
nodes: BodyNode[]
}
export const bodyPrimitiveTypes = [
'sphere',
'cube',
'capsule',
'cylinder',
'cone',
'diamond',
] as const
export const MAX_BODY_NODES = 16
const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[]
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
const vector = (value: unknown): value is BodyVector =>
Array.isArray(value) && value.length === 3 && value.every(finite)
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
if (!value || typeof value !== 'object') return { ...fallback }
const candidate = value as Partial<SurfaceConfig>
const type =
candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type
const preset = surfacePresets[type]
const numericFields = ['width', 'height', 'depth', 'roundness'] as const
if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback }
if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness))
return { ...fallback }
if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness))
return { ...fallback }
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
return { ...fallback }
return { ...preset, ...candidate, type }
}
export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => {
if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] }
const candidate = value as Partial<AvatarBody>
const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
const seenIds = new Set<string>()
const nodes = Array.isArray(candidate.nodes)
? candidate.nodes
.filter((node): node is BodyNode => {
if (!node || typeof node !== 'object') return false
const surface = (node as BodyNode).surface
const id = (node as BodyNode).id
if (id === 'primary' || seenIds.has(id)) return false
const valid = Boolean(
typeof (node as BodyNode).id === 'string' &&
id &&
typeof (node as BodyNode).name === 'string' &&
surface &&
bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) &&
finite(surface.width) &&
finite(surface.height) &&
finite(surface.depth) &&
finite(surface.roundness) &&
vector((node as BodyNode).position) &&
vector((node as BodyNode).rotation)
)
if (valid) seenIds.add(id)
return valid
})
.slice(0, MAX_BODY_NODES)
.map(node => ({
...node,
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
}))
: []
return { primary, nodes }
}
export const createBodyNode = (
type: (typeof bodyPrimitiveTypes)[number],
index: number
): BodyNode => {
const preset = surfacePresets[type]
const scale = 0.34
const side = index % 2 === 0 ? -1 : 1
return {
id: `shape-${crypto.randomUUID()}`,
name: `${surfaceLabels[type]} ${index + 1}`,
surface: {
...preset,
width: preset.width * scale,
height: preset.height * scale,
depth: preset.depth * scale,
},
position: [side * 82, -72, -18],
rotation: [0, 0, 0],
}
}
export const duplicateBodyNode = (source: BodyNode): BodyNode => ({
...source,
id: `shape-${crypto.randomUUID()}`,
name: `${source.name} copie`,
surface: { ...source.surface },
position: [source.position[0] + 14, source.position[1] + 14, source.position[2]],
rotation: [...source.rotation],
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,7 @@
export * from './ambientMotion'
export * from './avatarDefinition'
export * from './body'
export * from './geometry'
export * from './runtime'
export * from './scene'
export * from './surfaces'

View File

@ -0,0 +1,266 @@
import {
getStandardAnimationAvailabilityV1,
STANDARD_ANIMATIONS_V1,
type AnimationKey,
type AvatarAnimationDefinition,
type AvatarDefinition,
type AvatarExpressionDefinition,
type ExpressionKey,
} from './avatarDefinition'
import { applyAmbientMotion } from './ambientMotion'
import { interpolatePose, poseFromExpression } from './geometry'
import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
export type AvatarRuntimeError = {
code: 'unknown_animation' | 'unavailable_standard_animation' | 'unknown_expression'
key: string
message: string
}
export type AvatarCommandResult<T> =
{ ok: true; value: T } | { ok: false; error: AvatarRuntimeError }
export const resolveExpression = (
definition: Readonly<AvatarDefinition>,
key: ExpressionKey
): AvatarCommandResult<Readonly<AvatarExpressionDefinition>> => {
const expression = definition.expressions[key]
return expression
? { ok: true, value: expression }
: {
ok: false,
error: { code: 'unknown_expression', key, message: `Unknown expression '${key}'` },
}
}
export const resolveAnimation = (
definition: Readonly<AvatarDefinition>,
key: AnimationKey
): AvatarCommandResult<Readonly<AvatarAnimationDefinition>> => {
const explicit = definition.animations[key]
if (explicit) return { ok: true, value: explicit }
if (!(key in STANDARD_ANIMATIONS_V1)) {
return {
ok: false,
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 = {
activeAnimation?: AnimationKey
activeExpression: ExpressionKey
status: 'playing' | 'paused' | 'stopped'
stepIndex: number
direction: 1 | -1
phase: 'transition' | 'hold'
phaseStartedAt: number
transitionFrom: ExpressionKey
pausedAt?: number
blinkDueAt?: number
blinkStartedAt?: number
}
export type AvatarRuntimeEnvironment = {
random: () => number
reduceMotion?: boolean
}
export const createAvatarPlaybackState = (): AvatarPlaybackState => ({
activeExpression: 'neutral',
status: 'stopped',
stepIndex: 0,
direction: 1,
phase: 'transition',
phaseStartedAt: 0,
transitionFrom: 'neutral',
})
export const playAvatarAnimation = (
definition: Readonly<AvatarDefinition>,
key: AnimationKey,
now: number
): AvatarCommandResult<AvatarPlaybackState> => {
const result = resolveAnimation(definition, key)
if (!result.ok) return result
return {
ok: true,
value: {
activeAnimation: key,
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
status: 'playing',
stepIndex: 0,
direction: 1,
phase: 'transition',
phaseStartedAt: now,
transitionFrom: 'neutral',
blinkDueAt: now + result.value.blink.initialDelayMs,
},
}
}
const nextCursor = (
animation: Readonly<AvatarAnimationDefinition>,
state: AvatarPlaybackState
): { stepIndex: number; direction: 1 | -1; complete: boolean } => {
const last = animation.steps.length - 1
if (state.stepIndex < last && state.direction === 1) {
return { stepIndex: state.stepIndex + 1, direction: state.direction, complete: false }
}
if (state.stepIndex > 0 && state.direction === -1) {
return { stepIndex: state.stepIndex - 1, direction: state.direction, complete: false }
}
if (animation.playbackMode === 'once') {
return { stepIndex: state.stepIndex, direction: state.direction, complete: true }
}
if (animation.playbackMode === 'pingPong' && last > 0) {
const direction = state.direction === 1 ? -1 : 1
return { stepIndex: state.stepIndex + direction, direction, complete: false }
}
return { stepIndex: 0, direction: 1 as const, complete: false }
}
export const advanceAvatarPlayback = (
definition: Readonly<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number,
environment: AvatarRuntimeEnvironment
): AvatarPlaybackState => {
if (state.status !== 'playing' || !state.activeAnimation) return { ...state }
const resolved = resolveAnimation(definition, state.activeAnimation)
if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() }
const animation = resolved.value
let next = { ...state }
if (animation.blink.enabled && next.blinkDueAt !== undefined && now >= next.blinkDueAt) {
const startedAt = next.blinkDueAt
const interval =
animation.blink.minIntervalMs +
Math.max(0, Math.min(1, environment.random())) *
(animation.blink.maxIntervalMs - animation.blink.minIntervalMs)
next.blinkStartedAt = startedAt
next.blinkDueAt = startedAt + animation.blink.durationMs + interval
}
let safety = animation.steps.length * 4 + 4
while (safety-- > 0) {
const step = animation.steps[next.stepIndex]
const duration = next.phase === 'transition' ? step.transitionMs : step.holdMs
if (now < next.phaseStartedAt + duration) break
next.phaseStartedAt += duration
if (next.phase === 'transition') {
next.phase = 'hold'
next.activeExpression = step.expression
continue
}
const cursor = nextCursor(animation, next)
if (cursor.complete) {
next.status = 'stopped'
delete next.activeAnimation
break
}
next.stepIndex = cursor.stepIndex
next.direction = cursor.direction
next.phase = 'transition'
next.transitionFrom = next.activeExpression
next.activeExpression = animation.steps[cursor.stepIndex].expression
}
return next
}
export const pauseAvatarPlayback = (
state: Readonly<AvatarPlaybackState>,
now: number
): AvatarPlaybackState =>
state.status === 'playing' ? { ...state, status: 'paused', pausedAt: now } : { ...state }
export const resumeAvatarPlayback = (
state: Readonly<AvatarPlaybackState>,
now: number
): AvatarPlaybackState => {
if (state.status !== 'paused' || state.pausedAt === undefined) return { ...state }
const pauseDuration = now - state.pausedAt
return {
...state,
status: 'playing',
phaseStartedAt: state.phaseStartedAt + pauseDuration,
...(state.blinkDueAt === undefined ? {} : { blinkDueAt: state.blinkDueAt + pauseDuration }),
...(state.blinkStartedAt === undefined
? {}
: { blinkStartedAt: state.blinkStartedAt + pauseDuration }),
pausedAt: undefined,
}
}
const easing = (
transition: AvatarAnimationDefinition['steps'][number]['transition'],
value: number
) => {
const progress = Math.max(0, Math.min(1, value))
if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
if (transition === 'snappy') return 1 - (1 - progress) ** 3
const end = 1 - Math.exp(-6) * Math.cos(8)
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
}
export const blinkOpacityAt = (
animation: Readonly<AvatarAnimationDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number
) => {
if (!animation.blink.enabled || state.blinkStartedAt === undefined) return 1
const progress = (now - state.blinkStartedAt) / animation.blink.durationMs
if (progress < 0 || progress >= 1) return 1
return Math.abs(progress * 2 - 1)
}
export const renderAvatarFrame = (
definition: Readonly<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number,
environment: AvatarRuntimeEnvironment
): AvatarScene => {
const targetDefinition = definition.expressions[state.activeExpression]
if (!targetDefinition)
return renderAvatarExpression(
definition,
expressionFromDefinition('neutral', definition.expressions.neutral)
)
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
let blink = 1
if (state.activeAnimation) {
const resolved = resolveAnimation(definition, state.activeAnimation)
if (resolved.ok) {
const step = resolved.value.steps[state.stepIndex]
if (state.phase === 'transition' && step && !environment.reduceMotion) {
const fromDefinition = definition.expressions[state.transitionFrom]
if (fromDefinition) {
const from = expressionFromDefinition(state.transitionFrom, fromDefinition)
const duration = Math.max(step.transitionMs, 1)
const progress = easing(step.transition, (now - state.phaseStartedAt) / duration)
expression = interpolatePose(
poseFromExpression(from),
poseFromExpression(expression),
progress
).expression
}
}
blink = blinkOpacityAt(resolved.value, state, now)
}
}
if (!environment.reduceMotion) expression = applyAmbientMotion(expression, now)
return renderAvatarExpression(definition, expression, targetDefinition.colors, blink)
}

View File

@ -0,0 +1,79 @@
import type {
AvatarBodyDefinition,
AvatarDefinition,
AvatarExpressionDefinition,
ExpressionKey,
} from './avatarDefinition'
import type { AvatarBody } from './body'
import { poseFromExpression, renderAvatar, type AvatarGeometry, type Expression } from './geometry'
export const expressionFromDefinition = (
key: ExpressionKey,
expression: AvatarExpressionDefinition
): Expression => ({
id: key,
semanticKey: key,
headX: expression.head.x,
headY: expression.head.y,
headZ: expression.head.z,
widthLeft: expression.eyes.left.width,
widthRight: expression.eyes.right.width,
heightLeft: expression.eyes.left.height,
heightRight: expression.eyes.right.height,
spacing: expression.eyes.spacing,
positionXLeft: expression.eyes.left.x,
positionXRight: expression.eyes.right.x,
positionYLeft: expression.eyes.left.y,
positionYRight: expression.eyes.right.y,
leftAngle: expression.eyes.left.angle,
rightAngle: expression.eyes.right.angle,
perspective: expression.perspective,
eyeMotion: expression.motion.eyes,
bodyMotion: expression.motion.body,
...(expression.colors?.body ? { bodyColor: expression.colors.body } : {}),
...(expression.colors?.eyes ? { eyeColor: expression.colors.eyes } : {}),
})
export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
primary: { ...body.primary },
nodes: body.nodes.map((node, index) => ({
id: `runtime-node-${index}`,
name: `Runtime node ${index + 1}`,
surface: { ...node.surface },
position: [...node.position],
rotation: [...node.rotation],
})),
})
export type AvatarScene = {
geometry: AvatarGeometry
colors: { body: string; eyes: string }
}
export const renderAvatarExpression = (
definition: Readonly<AvatarDefinition>,
expression: Expression,
colors: { body?: string; eyes?: string } = {},
blink = 1
): AvatarScene => {
const body = bodyFromDefinition(definition.body)
return {
geometry: renderAvatar(poseFromExpression(expression), body.primary, blink, {
bodyNodes: body.nodes,
}),
colors: {
body: colors.body ?? expression.bodyColor ?? definition.colors.body,
eyes: colors.eyes ?? expression.eyeColor ?? definition.colors.eyes,
},
}
}
export const renderAvatarDefinition = (
definition: Readonly<AvatarDefinition>,
expressionKey: ExpressionKey = 'neutral'
): AvatarScene => {
const publicExpression = definition.expressions[expressionKey]
if (!publicExpression) throw new Error(`Unknown expression '${expressionKey}'`)
const expression = expressionFromDefinition(expressionKey, publicExpression)
return renderAvatarExpression(definition, expression, publicExpression.colors)
}

View File

@ -0,0 +1,665 @@
import type { Point3 } from './geometry'
export type SurfaceType =
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
export type SurfaceConfig = {
type: SurfaceType
width: number
height: number
depth: number
roundness: number
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
export type SurfaceSample = {
point: Point3
normal: Point3
}
export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
cylinder: {
type: 'cylinder',
width: 235,
height: 250,
depth: 215,
roundness: 0.45,
morphRoundness: 0,
},
cone: {
type: 'cone',
width: 250,
height: 265,
depth: 225,
roundness: 0,
morphRoundness: 0,
tipRoundness: 0.55,
baseRoundness: 0.45,
},
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
}
export const surfaceLabels: Record<SurfaceType, string> = {
sphere: 'Sphère',
mickey: 'Mickey',
cursor: 'Curseur',
cube: 'Cube',
capsule: 'Capsule',
cylinder: 'Cylindre',
cone: 'Cône',
diamond: 'Diamant',
}
const signedPower = (value: number, exponent: number) =>
Math.sign(value) * Math.abs(value) ** exponent
const superellipsoid = (
longitude: number,
latitude: number,
width: number,
height: number,
depth: number,
verticalExponent: number,
horizontalExponent: number
): Point3 => {
const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
return [
(width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
(height / 2) * signedPower(Math.sin(latitude), verticalExponent),
(depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
]
}
const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
const radiusX = config.width / 2
const radiusZ = config.depth / 2
const capRadius = Math.min(radiusX, config.height / 2)
const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
const meridianLength = straightHalf * 2 + Math.PI * capRadius
const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
let radial = radiusX
let y = 0
if (distance < (Math.PI * capRadius) / 2) {
const angle = -Math.PI / 2 + distance / capRadius
radial = radiusX * Math.cos(angle)
y = -straightHalf + capRadius * Math.sin(angle)
} else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
y = -straightHalf + distance - (Math.PI * capRadius) / 2
} else {
const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
radial = radiusX * Math.cos(angle)
y = straightHalf + capRadius * Math.sin(angle)
}
const depthScale = radiusX ? radiusZ / radiusX : 1
return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
}
const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
const MIN_CUBE_SURFACE_POWER = 0.04
const cubeExponent = (config: SurfaceConfig) => {
if (config.roundness <= 0) return Infinity
// The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
const surfacePower =
MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
return 2 / surfacePower
}
const lpSurface = (
config: SurfaceConfig,
longitude: number,
latitude: number,
exponent: number
): Point3 => {
const sphereX = Math.cos(latitude) * Math.sin(longitude)
const sphereY = Math.sin(latitude)
const sphereZ = Math.cos(latitude) * Math.cos(longitude)
const length = Number.isFinite(exponent)
? (Math.abs(sphereX) ** exponent +
Math.abs(sphereY) ** exponent +
Math.abs(sphereZ) ** exponent) **
(1 / exponent) || 1
: Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
return [
(config.width / 2) * (sphereX / length),
(config.height / 2) * (sphereY / length),
(config.depth / 2) * (sphereZ / length),
]
}
const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
return lpSurface(config, longitude, latitude, diamondExponent(config))
}
const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
lpSurface(config, longitude, latitude, cubeExponent(config))
const MAX_CONE_TIP_FRACTION = 0.24
const MAX_CONE_BASE_FRACTION = 0.2
const MAX_CYLINDER_EDGE_FRACTION = 0.22
type RadialProfile = {
radiusScale: number
verticalProgress: number
}
const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
const morphProfileToEllipsoid = (
config: SurfaceConfig,
progress: number,
profile: RadialProfile
): RadialProfile => {
const amount = morphProgress(config)
const clampedProgress = Math.max(0, Math.min(1, progress))
const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
return {
radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
verticalProgress:
profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
}
}
const cubic = (
start: number,
firstControl: number,
secondControl: number,
end: number,
progress: number
) => {
const inverse = 1 - progress
return (
inverse ** 3 * start +
3 * inverse * inverse * progress * firstControl +
3 * inverse * progress * progress * secondControl +
progress ** 3 * end
)
}
const coneRounding = (config: SurfaceConfig) => ({
tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
})
/** Cylinder half-profile with a quarter-round transition at both caps. */
const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
const clampedProgress = Math.max(0, Math.min(1, progress))
const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
if (edgeFraction <= 0) {
return {
radiusScale: 1,
verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
}
}
if (clampedProgress < edgeFraction) {
const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
return {
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
}
}
if (clampedProgress > 1 - edgeFraction) {
const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
return {
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
}
}
const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
return {
radiusScale: 1,
verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
}
}
const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
const radiusScaleAtVerticalProgress = (
config: SurfaceConfig,
verticalProgress: number,
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
) => {
const progress = Math.max(0, Math.min(1, verticalProgress))
let lower = 0
let upper = 1
for (let iteration = 0; iteration < 14; iteration += 1) {
const candidate = (lower + upper) / 2
if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
else upper = candidate
}
return profileAt(config, (lower + upper) / 2).radiusScale
}
/** Rounded half-profile revolved around the cone's vertical axis. */
const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
const clampedProgress = Math.max(0, Math.min(1, progress))
const { tipFraction, baseFraction } = coneRounding(config)
if (baseFraction > 0 && clampedProgress < baseFraction) {
const curveProgress = clampedProgress / baseFraction
return {
radiusScale: cubic(
1 - baseFraction,
1,
1 - baseFraction / 2,
1 - baseFraction,
curveProgress
),
verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
}
}
if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
return {
radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
}
}
return {
radiusScale: 1 - clampedProgress,
verticalProgress: clampedProgress,
}
}
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
export const cursorLayout = (config: SurfaceConfig) => {
const coneHeight = config.height * 0.36
const bodyHeight = config.height - coneHeight
return {
coneApexY: -config.height / 2,
coneBaseY: -config.height / 2 + coneHeight,
bodyHeight,
bodyCenterY: config.height / 2 - bodyHeight / 2,
bodyWidth: config.width * 0.54,
bodyDepth: config.depth * 0.62,
}
}
export const surfacePointAt = (
config: SurfaceConfig,
longitude: number,
latitude: number
): Point3 => {
const { width, height, depth } = config
switch (config.type) {
case 'sphere':
case 'mickey':
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
case 'cube':
return cube(config, longitude, latitude)
case 'cylinder': {
const progress = (latitude + Math.PI / 2) / Math.PI
const profile = morphedCylinderProfileAt(config, progress)
return [
(width / 2) * profile.radiusScale * Math.sin(longitude),
-height / 2 + height * profile.verticalProgress,
(depth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
case 'cursor': {
const layout = cursorLayout(config)
const progress = (latitude + Math.PI / 2) / Math.PI
const bodyConfig = {
...config,
width: layout.bodyWidth,
height: layout.bodyHeight,
depth: layout.bodyDepth,
}
const profile = cylinderProfileAt(bodyConfig, progress)
return [
(layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
(layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
case 'diamond':
return diamond(config, longitude, latitude)
case 'capsule':
return capsule(config, longitude, latitude)
case 'cone': {
const progress = (latitude + Math.PI / 2) / Math.PI
const profile = morphedConeProfileAt(config, progress)
return [
(width / 2) * profile.radiusScale * Math.sin(longitude),
height / 2 - height * profile.verticalProgress,
(depth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
}
}
const subtract = (left: Point3, right: Point3): Point3 => [
left[0] - right[0],
left[1] - right[1],
left[2] - right[2],
]
const normalize = ([x, y, z]: Point3): Point3 => {
const length = Math.hypot(x, y, z) || 1
return [x / length, y / length, z / length]
}
const normalFromTangents = (
config: SurfaceConfig,
longitudeTangent: Point3,
latitudeTangent: Point3
) => {
const orientation = config.type === 'cone' ? -1 : 1
return normalize([
orientation *
(longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
orientation *
(longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
orientation *
(longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
])
}
const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
const epsilon = 0.0005
if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
const latitudeBefore = surfacePointAt(
config,
longitude,
Math.max(-Math.PI / 2, latitude - epsilon)
)
const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
return normalFromTangents(
config,
subtract(longitudeAfter, longitudeBefore),
subtract(latitudeAfter, latitudeBefore)
)
}
const signedMagnitude = (value: number, exponent: number) =>
Math.sign(value) * Math.abs(value) ** exponent
const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
return normalize([
signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
])
}
const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
lpNormal(config, point, diamondExponent(config))
const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
const exponent = cubeExponent(config)
if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
const normalized = [
point[0] / (config.width / 2 || 1),
point[1] / (config.height / 2 || 1),
point[2] / (config.depth / 2 || 1),
] as Point3
const dominantAxis = normalized.reduce(
(largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
0
)
const normal: Point3 = [
dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
]
return normal
}
const lpFrontSample = (
config: SurfaceConfig,
x: number,
y: number,
exponent: number,
normalAt: (config: SurfaceConfig, point: Point3) => Point3
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
if (!Number.isFinite(exponent)) {
const point: Point3 = [
Math.max(-radiusX, Math.min(radiusX, x)),
Math.max(-radiusY, Math.min(radiusY, y)),
radiusZ,
]
return { point, normal: normalAt(config, point) }
}
const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
const normalizedX = surfaceX / radiusX
const normalizedZ =
Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
(1 / exponent)
const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
return { point, normal: normalAt(config, point) }
}
const ellipsoidFrontSample = (
x: number,
y: number,
radiusX: number,
radiusY: number,
radiusZ: number,
centerY = 0
): SurfaceSample => {
const localY = y - centerY
const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
const z = radiusZ * Math.sqrt(remaining)
return {
point: [x, y, z],
normal: normalize([
x / (radiusX * radiusX || 1),
localY / (radiusY * radiusY || 1),
z / (radiusZ * radiusZ || 1),
]),
}
}
const radialProfileFrontSample = (
config: SurfaceConfig,
x: number,
y: number,
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
verticalDirection: -1 | 1
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusZ = config.depth / 2 || 1
const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
const sectionRadiusX = radiusX * radialScale
const sectionRadiusZ = radiusZ * radialScale
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
const z = sectionRadiusZ * Math.sqrt(remaining)
const derivativeStep = 0.0001
const previousProgress = Math.max(0, verticalProgress - derivativeStep)
const nextProgress = Math.min(1, verticalProgress + derivativeStep)
const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
const depthRatio = radiusZ / radiusX
const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
const depthYDerivative =
(verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
return {
point: [surfaceX, y, z],
normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
}
}
/** Project canonical face coordinates onto a primitive's front-facing sheet. */
export const surfaceFrontSampleAt = (
config: SurfaceConfig,
x: number,
y: number
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
switch (config.type) {
case 'sphere':
case 'mickey':
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
case 'cube':
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
case 'capsule': {
const capRadiusY = Math.min(radiusX, radiusY)
const straightHalf = Math.max(0, radiusY - capRadiusY)
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
}
case 'cylinder':
return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
case 'cursor': {
const layout = cursorLayout(config)
const bodyConfig = {
...config,
width: layout.bodyWidth,
height: layout.bodyHeight,
depth: layout.bodyDepth,
}
const sample = radialProfileFrontSample(
bodyConfig,
x,
y - layout.bodyCenterY,
cylinderProfileAt,
1
)
return {
point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
normal: sample.normal,
}
}
case 'cone':
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
case 'diamond':
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
}
}
export const surfaceNormalAt = (
config: SurfaceConfig,
longitude: number,
latitude: number
): Point3 => {
const point = surfacePointAt(config, longitude, latitude)
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
// common path for the default spherical head.
if (config.type === 'sphere' || config.type === 'mickey') {
const halfWidth = config.width / 2 || 1
const halfHeight = config.height / 2 || 1
const halfDepth = config.depth / 2 || 1
return normalize([
point[0] / (halfWidth * halfWidth),
point[1] / (halfHeight * halfHeight),
point[2] / (halfDepth * halfDepth),
])
}
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
return normalize([
Math.sin(longitude) / (config.width / 2 || 1),
0,
Math.cos(longitude) / (config.depth / 2 || 1),
])
}
if (config.type === 'diamond') {
return diamondNormal(config, point)
}
if (config.type === 'cube') {
return cubeNormal(config, point)
}
return tangentNormalAt(config, longitude, latitude)
}
export const surfaceSampleAt = (
config: SurfaceConfig,
longitude: number,
latitude: number
): SurfaceSample => {
const point = surfacePointAt(config, longitude, latitude)
if (config.type === 'sphere' || config.type === 'mickey') {
const halfWidth = config.width / 2 || 1
const halfHeight = config.height / 2 || 1
const halfDepth = config.depth / 2 || 1
return {
point,
normal: normalize([
point[0] / (halfWidth * halfWidth),
point[1] / (halfHeight * halfHeight),
point[2] / (halfDepth * halfDepth),
]),
}
}
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
return {
point,
normal: normalize([
Math.sin(longitude) / (config.width / 2 || 1),
0,
Math.cos(longitude) / (config.depth / 2 || 1),
]),
}
}
if (config.type === 'diamond') {
return {
point,
normal: diamondNormal(config, point),
}
}
if (config.type === 'cube') {
return {
point,
normal: cubeNormal(config, point),
}
}
return {
point,
normal: tangentNormalAt(config, longitude, latitude),
}
}

View File

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true
},
"include": ["src", "vite.config.ts"],
"exclude": ["src/**/__tests__/**"]
}

View File

@ -0,0 +1,35 @@
import { fileURLToPath } from 'node:url'
import { defineConfig } from 'vite'
export default defineConfig({
build: {
lib: {
entry: {
index: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
geometry: fileURLToPath(new URL('./src/geometry.ts', import.meta.url)),
body: fileURLToPath(new URL('./src/body.ts', import.meta.url)),
surfaces: fileURLToPath(new URL('./src/surfaces.ts', import.meta.url)),
ambientMotion: fileURLToPath(new URL('./src/ambientMotion.ts', import.meta.url)),
},
formats: ['es'],
},
sourcemap: true,
rollupOptions: {
external: ['ajv/dist/2020.js'],
output: { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' },
},
},
plugins: [
{
name: 'copy-avatar-schema',
closeBundle: async () => {
const { copyFile } = await import('node:fs/promises')
await copyFile(
fileURLToPath(new URL('./src/avatarDefinition.schema.json', import.meta.url)),
fileURLToPath(new URL('./dist/avatarDefinition.schema.json', import.meta.url))
)
},
},
],
})

View File

@ -0,0 +1,661 @@
GNU AFFERO GENERAL PUBLIC LICENSE
Version 3, 19 November 2007
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
Everyone is permitted to copy and distribute verbatim copies
of this license document, but changing it is not allowed.
Preamble
The GNU Affero General Public License is a free, copyleft license for
software and other kinds of works, specifically designed to ensure
cooperation with the community in the case of network server software.
The licenses for most software and other practical works are designed
to take away your freedom to share and change the works. By contrast,
our General Public Licenses are intended to guarantee your freedom to
share and change all versions of a program--to make sure it remains free
software for all its users.
When we speak of free software, we are referring to freedom, not
price. Our General Public Licenses are designed to make sure that you
have the freedom to distribute copies of free software (and charge for
them if you wish), that you receive source code or can get it if you
want it, that you can change the software or use pieces of it in new
free programs, and that you know you can do these things.
Developers that use our General Public Licenses protect your rights
with two steps: (1) assert copyright on the software, and (2) offer
you this License which gives you legal permission to copy, distribute
and/or modify the software.
A secondary benefit of defending all users' freedom is that
improvements made in alternate versions of the program, if they
receive widespread use, become available for other developers to
incorporate. Many developers of free software are heartened and
encouraged by the resulting cooperation. However, in the case of
software used on network servers, this result may fail to come about.
The GNU General Public License permits making a modified version and
letting the public access it on a server without ever releasing its
source code to the public.
The GNU Affero General Public License is designed specifically to
ensure that, in such cases, the modified source code becomes available
to the community. It requires the operator of a network server to
provide the source code of the modified version running there to the
users of that server. Therefore, public use of a modified version, on
a publicly accessible server, gives the public access to the source
code of the modified version.
An older license, called the Affero General Public License and
published by Affero, was designed to accomplish similar goals. This is
a different license, not a version of the Affero GPL, but Affero has
released a new version of the Affero GPL which permits relicensing under
this license.
The precise terms and conditions for copying, distribution and
modification follow.
TERMS AND CONDITIONS
0. Definitions.
"This License" refers to version 3 of the GNU Affero General Public License.
"Copyright" also means copyright-like laws that apply to other kinds of
works, such as semiconductor masks.
"The Program" refers to any copyrightable work licensed under this
License. Each licensee is addressed as "you". "Licensees" and
"recipients" may be individuals or organizations.
To "modify" a work means to copy from or adapt all or part of the work
in a fashion requiring copyright permission, other than the making of an
exact copy. The resulting work is called a "modified version" of the
earlier work or a work "based on" the earlier work.
A "covered work" means either the unmodified Program or a work based
on the Program.
To "propagate" a work means to do anything with it that, without
permission, would make you directly or secondarily liable for
infringement under applicable copyright law, except executing it on a
computer or modifying a private copy. Propagation includes copying,
distribution (with or without modification), making available to the
public, and in some countries other activities as well.
To "convey" a work means any kind of propagation that enables other
parties to make or receive copies. Mere interaction with a user through
a computer network, with no transfer of a copy, is not conveying.
An interactive user interface displays "Appropriate Legal Notices"
to the extent that it includes a convenient and prominently visible
feature that (1) displays an appropriate copyright notice, and (2)
tells the user that there is no warranty for the work (except to the
extent that warranties are provided), that licensees may convey the
work under this License, and how to view a copy of this License. If
the interface presents a list of user commands or options, such as a
menu, a prominent item in the list meets this criterion.
1. Source Code.
The "source code" for a work means the preferred form of the work
for making modifications to it. "Object code" means any non-source
form of a work.
A "Standard Interface" means an interface that either is an official
standard defined by a recognized standards body, or, in the case of
interfaces specified for a particular programming language, one that
is widely used among developers working in that language.
The "System Libraries" of an executable work include anything, other
than the work as a whole, that (a) is included in the normal form of
packaging a Major Component, but which is not part of that Major
Component, and (b) serves only to enable use of the work with that
Major Component, or to implement a Standard Interface for which an
implementation is available to the public in source code form. A
"Major Component", in this context, means a major essential component
(kernel, window system, and so on) of the specific operating system
(if any) on which the executable work runs, or a compiler used to
produce the work, or an object code interpreter used to run it.
The "Corresponding Source" for a work in object code form means all
the source code needed to generate, install, and (for an executable
work) run the object code and to modify the work, including scripts to
control those activities. However, it does not include the work's
System Libraries, or general-purpose tools or generally available free
programs which are used unmodified in performing those activities but
which are not part of the work. For example, Corresponding Source
includes interface definition files associated with source files for
the work, and the source code for shared libraries and dynamically
linked subprograms that the work is specifically designed to require,
such as by intimate data communication or control flow between those
subprograms and other parts of the work.
The Corresponding Source need not include anything that users
can regenerate automatically from other parts of the Corresponding
Source.
The Corresponding Source for a work in source code form is that
same work.
2. Basic Permissions.
All rights granted under this License are granted for the term of
copyright on the Program, and are irrevocable provided the stated
conditions are met. This License explicitly affirms your unlimited
permission to run the unmodified Program. The output from running a
covered work is covered by this License only if the output, given its
content, constitutes a covered work. This License acknowledges your
rights of fair use or other equivalent, as provided by copyright law.
You may make, run and propagate covered works that you do not
convey, without conditions so long as your license otherwise remains
in force. You may convey covered works to others for the sole purpose
of having them make modifications exclusively for you, or provide you
with facilities for running those works, provided that you comply with
the terms of this License in conveying all material for which you do
not control copyright. Those thus making or running the covered works
for you must do so exclusively on your behalf, under your direction
and control, on terms that prohibit them from making any copies of
your copyrighted material outside their relationship with you.
Conveying under any other circumstances is permitted solely under
the conditions stated below. Sublicensing is not allowed; section 10
makes it unnecessary.
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
No covered work shall be deemed part of an effective technological
measure under any applicable law fulfilling obligations under article
11 of the WIPO copyright treaty adopted on 20 December 1996, or
similar laws prohibiting or restricting circumvention of such
measures.
When you convey a covered work, you waive any legal power to forbid
circumvention of technological measures to the extent such circumvention
is effected by exercising rights under this License with respect to
the covered work, and you disclaim any intention to limit operation or
modification of the work as a means of enforcing, against the work's
users, your or third parties' legal rights to forbid circumvention of
technological measures.
4. Conveying Verbatim Copies.
You may convey verbatim copies of the Program's source code as you
receive it, in any medium, provided that you conspicuously and
appropriately publish on each copy an appropriate copyright notice;
keep intact all notices stating that this License and any
non-permissive terms added in accord with section 7 apply to the code;
keep intact all notices of the absence of any warranty; and give all
recipients a copy of this License along with the Program.
You may charge any price or no price for each copy that you convey,
and you may offer support or warranty protection for a fee.
5. Conveying Modified Source Versions.
You may convey a work based on the Program, or the modifications to
produce it from the Program, in the form of source code under the
terms of section 4, provided that you also meet all of these conditions:
a) The work must carry prominent notices stating that you modified
it, and giving a relevant date.
b) The work must carry prominent notices stating that it is
released under this License and any conditions added under section
7. This requirement modifies the requirement in section 4 to
"keep intact all notices".
c) You must license the entire work, as a whole, under this
License to anyone who comes into possession of a copy. This
License will therefore apply, along with any applicable section 7
additional terms, to the whole of the work, and all its parts,
regardless of how they are packaged. This License gives no
permission to license the work in any other way, but it does not
invalidate such permission if you have separately received it.
d) If the work has interactive user interfaces, each must display
Appropriate Legal Notices; however, if the Program has interactive
interfaces that do not display Appropriate Legal Notices, your
work need not make them do so.
A compilation of a covered work with other separate and independent
works, which are not by their nature extensions of the covered work,
and which are not combined with it such as to form a larger program,
in or on a volume of a storage or distribution medium, is called an
"aggregate" if the compilation and its resulting copyright are not
used to limit the access or legal rights of the compilation's users
beyond what the individual works permit. Inclusion of a covered work
in an aggregate does not cause this License to apply to the other
parts of the aggregate.
6. Conveying Non-Source Forms.
You may convey a covered work in object code form under the terms
of sections 4 and 5, provided that you also convey the
machine-readable Corresponding Source under the terms of this License,
in one of these ways:
a) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by the
Corresponding Source fixed on a durable physical medium
customarily used for software interchange.
b) Convey the object code in, or embodied in, a physical product
(including a physical distribution medium), accompanied by a
written offer, valid for at least three years and valid for as
long as you offer spare parts or customer support for that product
model, to give anyone who possesses the object code either (1) a
copy of the Corresponding Source for all the software in the
product that is covered by this License, on a durable physical
medium customarily used for software interchange, for a price no
more than your reasonable cost of physically performing this
conveying of source, or (2) access to copy the
Corresponding Source from a network server at no charge.
c) Convey individual copies of the object code with a copy of the
written offer to provide the Corresponding Source. This
alternative is allowed only occasionally and noncommercially, and
only if you received the object code with such an offer, in accord
with subsection 6b.
d) Convey the object code by offering access from a designated
place (gratis or for a charge), and offer equivalent access to the
Corresponding Source in the same way through the same place at no
further charge. You need not require recipients to copy the
Corresponding Source along with the object code. If the place to
copy the object code is a network server, the Corresponding Source
may be on a different server (operated by you or a third party)
that supports equivalent copying facilities, provided you maintain
clear directions next to the object code saying where to find the
Corresponding Source. Regardless of what server hosts the
Corresponding Source, you remain obligated to ensure that it is
available for as long as needed to satisfy these requirements.
e) Convey the object code using peer-to-peer transmission, provided
you inform other peers where the object code and Corresponding
Source of the work are being offered to the general public at no
charge under subsection 6d.
A separable portion of the object code, whose source code is excluded
from the Corresponding Source as a System Library, need not be
included in conveying the object code work.
A "User Product" is either (1) a "consumer product", which means any
tangible personal property which is normally used for personal, family,
or household purposes, or (2) anything designed or sold for incorporation
into a dwelling. In determining whether a product is a consumer product,
doubtful cases shall be resolved in favor of coverage. For a particular
product received by a particular user, "normally used" refers to a
typical or common use of that class of product, regardless of the status
of the particular user or of the way in which the particular user
actually uses, or expects or is expected to use, the product. A product
is a consumer product regardless of whether the product has substantial
commercial, industrial or non-consumer uses, unless such uses represent
the only significant mode of use of the product.
"Installation Information" for a User Product means any methods,
procedures, authorization keys, or other information required to install
and execute modified versions of a covered work in that User Product from
a modified version of its Corresponding Source. The information must
suffice to ensure that the continued functioning of the modified object
code is in no case prevented or interfered with solely because
modification has been made.
If you convey an object code work under this section in, or with, or
specifically for use in, a User Product, and the conveying occurs as
part of a transaction in which the right of possession and use of the
User Product is transferred to the recipient in perpetuity or for a
fixed term (regardless of how the transaction is characterized), the
Corresponding Source conveyed under this section must be accompanied
by the Installation Information. But this requirement does not apply
if neither you nor any third party retains the ability to install
modified object code on the User Product (for example, the work has
been installed in ROM).
The requirement to provide Installation Information does not include a
requirement to continue to provide support service, warranty, or updates
for a work that has been modified or installed by the recipient, or for
the User Product in which it has been modified or installed. Access to a
network may be denied when the modification itself materially and
adversely affects the operation of the network or violates the rules and
protocols for communication across the network.
Corresponding Source conveyed, and Installation Information provided,
in accord with this section must be in a format that is publicly
documented (and with an implementation available to the public in
source code form), and must require no special password or key for
unpacking, reading or copying.
7. Additional Terms.
"Additional permissions" are terms that supplement the terms of this
License by making exceptions from one or more of its conditions.
Additional permissions that are applicable to the entire Program shall
be treated as though they were included in this License, to the extent
that they are valid under applicable law. If additional permissions
apply only to part of the Program, that part may be used separately
under those permissions, but the entire Program remains governed by
this License without regard to the additional permissions.
When you convey a copy of a covered work, you may at your option
remove any additional permissions from that copy, or from any part of
it. (Additional permissions may be written to require their own
removal in certain cases when you modify the work.) You may place
additional permissions on material, added by you to a covered work,
for which you have or can give appropriate copyright permission.
Notwithstanding any other provision of this License, for material you
add to a covered work, you may (if authorized by the copyright holders of
that material) supplement the terms of this License with terms:
a) Disclaiming warranty or limiting liability differently from the
terms of sections 15 and 16 of this License; or
b) Requiring preservation of specified reasonable legal notices or
author attributions in that material or in the Appropriate Legal
Notices displayed by works containing it; or
c) Prohibiting misrepresentation of the origin of that material, or
requiring that modified versions of such material be marked in
reasonable ways as different from the original version; or
d) Limiting the use for publicity purposes of names of licensors or
authors of the material; or
e) Declining to grant rights under trademark law for use of some
trade names, trademarks, or service marks; or
f) Requiring indemnification of licensors and authors of that
material by anyone who conveys the material (or modified versions of
it) with contractual assumptions of liability to the recipient, for
any liability that these contractual assumptions directly impose on
those licensors and authors.
All other non-permissive additional terms are considered "further
restrictions" within the meaning of section 10. If the Program as you
received it, or any part of it, contains a notice stating that it is
governed by this License along with a term that is a further
restriction, you may remove that term. If a license document contains
a further restriction but permits relicensing or conveying under this
License, you may add to a covered work material governed by the terms
of that license document, provided that the further restriction does
not survive such relicensing or conveying.
If you add terms to a covered work in accord with this section, you
must place, in the relevant source files, a statement of the
additional terms that apply to those files, or a notice indicating
where to find the applicable terms.
Additional terms, permissive or non-permissive, may be stated in the
form of a separately written license, or stated as exceptions;
the above requirements apply either way.
8. Termination.
You may not propagate or modify a covered work except as expressly
provided under this License. Any attempt otherwise to propagate or
modify it is void, and will automatically terminate your rights under
this License (including any patent licenses granted under the third
paragraph of section 11).
However, if you cease all violation of this License, then your
license from a particular copyright holder is reinstated (a)
provisionally, unless and until the copyright holder explicitly and
finally terminates your license, and (b) permanently, if the copyright
holder fails to notify you of the violation by some reasonable means
prior to 60 days after the cessation.
Moreover, your license from a particular copyright holder is
reinstated permanently if the copyright holder notifies you of the
violation by some reasonable means, this is the first time you have
received notice of violation of this License (for any work) from that
copyright holder, and you cure the violation prior to 30 days after
your receipt of the notice.
Termination of your rights under this section does not terminate the
licenses of parties who have received copies or rights from you under
this License. If your rights have been terminated and not permanently
reinstated, you do not qualify to receive new licenses for the same
material under section 10.
9. Acceptance Not Required for Having Copies.
You are not required to accept this License in order to receive or
run a copy of the Program. Ancillary propagation of a covered work
occurring solely as a consequence of using peer-to-peer transmission
to receive a copy likewise does not require acceptance. However,
nothing other than this License grants you permission to propagate or
modify any covered work. These actions infringe copyright if you do
not accept this License. Therefore, by modifying or propagating a
covered work, you indicate your acceptance of this License to do so.
10. Automatic Licensing of Downstream Recipients.
Each time you convey a covered work, the recipient automatically
receives a license from the original licensors, to run, modify and
propagate that work, subject to this License. You are not responsible
for enforcing compliance by third parties with this License.
An "entity transaction" is a transaction transferring control of an
organization, or substantially all assets of one, or subdividing an
organization, or merging organizations. If propagation of a covered
work results from an entity transaction, each party to that
transaction who receives a copy of the work also receives whatever
licenses to the work the party's predecessor in interest had or could
give under the previous paragraph, plus a right to possession of the
Corresponding Source of the work from the predecessor in interest, if
the predecessor has it or can get it with reasonable efforts.
You may not impose any further restrictions on the exercise of the
rights granted or affirmed under this License. For example, you may
not impose a license fee, royalty, or other charge for exercise of
rights granted under this License, and you may not initiate litigation
(including a cross-claim or counterclaim in a lawsuit) alleging that
any patent claim is infringed by making, using, selling, offering for
sale, or importing the Program or any portion of it.
11. Patents.
A "contributor" is a copyright holder who authorizes use under this
License of the Program or a work on which the Program is based. The
work thus licensed is called the contributor's "contributor version".
A contributor's "essential patent claims" are all patent claims
owned or controlled by the contributor, whether already acquired or
hereafter acquired, that would be infringed by some manner, permitted
by this License, of making, using, or selling its contributor version,
but do not include claims that would be infringed only as a
consequence of further modification of the contributor version. For
purposes of this definition, "control" includes the right to grant
patent sublicenses in a manner consistent with the requirements of
this License.
Each contributor grants you a non-exclusive, worldwide, royalty-free
patent license under the contributor's essential patent claims, to
make, use, sell, offer for sale, import and otherwise run, modify and
propagate the contents of its contributor version.
In the following three paragraphs, a "patent license" is any express
agreement or commitment, however denominated, not to enforce a patent
(such as an express permission to practice a patent or covenant not to
sue for patent infringement). To "grant" such a patent license to a
party means to make such an agreement or commitment not to enforce a
patent against the party.
If you convey a covered work, knowingly relying on a patent license,
and the Corresponding Source of the work is not available for anyone
to copy, free of charge and under the terms of this License, through a
publicly available network server or other readily accessible means,
then you must either (1) cause the Corresponding Source to be so
available, or (2) arrange to deprive yourself of the benefit of the
patent license for this particular work, or (3) arrange, in a manner
consistent with the requirements of this License, to extend the patent
license to downstream recipients. "Knowingly relying" means you have
actual knowledge that, but for the patent license, your conveying the
covered work in a country, or your recipient's use of the covered work
in a country, would infringe one or more identifiable patents in that
country that you have reason to believe are valid.
If, pursuant to or in connection with a single transaction or
arrangement, you convey, or propagate by procuring conveyance of, a
covered work, and grant a patent license to some of the parties
receiving the covered work authorizing them to use, propagate, modify
or convey a specific copy of the covered work, then the patent license
you grant is automatically extended to all recipients of the covered
work and works based on it.
A patent license is "discriminatory" if it does not include within
the scope of its coverage, prohibits the exercise of, or is
conditioned on the non-exercise of one or more of the rights that are
specifically granted under this License. You may not convey a covered
work if you are a party to an arrangement with a third party that is
in the business of distributing software, under which you make payment
to the third party based on the extent of your activity of conveying
the work, and under which the third party grants, to any of the
parties who would receive the covered work from you, a discriminatory
patent license (a) in connection with copies of the covered work
conveyed by you (or copies made from those copies), or (b) primarily
for and in connection with specific products or compilations that
contain the covered work, unless you entered into that arrangement,
or that patent license was granted, prior to 28 March 2007.
Nothing in this License shall be construed as excluding or limiting
any implied license or other defenses to infringement that may
otherwise be available to you under applicable patent law.
12. No Surrender of Others' Freedom.
If conditions are imposed on you (whether by court order, agreement or
otherwise) that contradict the conditions of this License, they do not
excuse you from the conditions of this License. If you cannot convey a
covered work so as to satisfy simultaneously your obligations under this
License and any other pertinent obligations, then as a consequence you may
not convey it at all. For example, if you agree to terms that obligate you
to collect a royalty for further conveying from those to whom you convey
the Program, the only way you could satisfy both those terms and this
License would be to refrain entirely from conveying the Program.
13. Remote Network Interaction; Use with the GNU General Public License.
Notwithstanding any other provision of this License, if you modify the
Program, your modified version must prominently offer all users
interacting with it remotely through a computer network (if your version
supports such interaction) an opportunity to receive the Corresponding
Source of your version by providing access to the Corresponding Source
from a network server at no charge, through some standard or customary
means of facilitating copying of software. This Corresponding Source
shall include the Corresponding Source for any work covered by version 3
of the GNU General Public License that is incorporated pursuant to the
following paragraph.
Notwithstanding any other provision of this License, you have
permission to link or combine any covered work with a work licensed
under version 3 of the GNU General Public License into a single
combined work, and to convey the resulting work. The terms of this
License will continue to apply to the part which is the covered work,
but the work with which it is combined will remain governed by version
3 of the GNU General Public License.
14. Revised Versions of this License.
The Free Software Foundation may publish revised and/or new versions of
the GNU Affero General Public License from time to time. Such new versions
will be similar in spirit to the present version, but may differ in detail to
address new problems or concerns.
Each version is given a distinguishing version number. If the
Program specifies that a certain numbered version of the GNU Affero General
Public License "or any later version" applies to it, you have the
option of following the terms and conditions either of that numbered
version or of any later version published by the Free Software
Foundation. If the Program does not specify a version number of the
GNU Affero General Public License, you may choose any version ever published
by the Free Software Foundation.
If the Program specifies that a proxy can decide which future
versions of the GNU Affero General Public License can be used, that proxy's
public statement of acceptance of a version permanently authorizes you
to choose that version for the Program.
Later license versions may give you additional or different
permissions. However, no additional obligations are imposed on any
author or copyright holder as a result of your choosing to follow a
later version.
15. Disclaimer of Warranty.
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
16. Limitation of Liability.
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
SUCH DAMAGES.
17. Interpretation of Sections 15 and 16.
If the disclaimer of warranty and limitation of liability provided
above cannot be given local legal effect according to their terms,
reviewing courts shall apply local law that most closely approximates
an absolute waiver of all civil liability in connection with the
Program, unless a warranty or assumption of liability accompanies a
copy of the Program in return for a fee.
END OF TERMS AND CONDITIONS
How to Apply These Terms to Your New Programs
If you develop a new program, and you want it to be of the greatest
possible use to the public, the best way to achieve this is to make it
free software which everyone can redistribute and change under these terms.
To do so, attach the following notices to the program. It is safest
to attach them to the start of each source file to most effectively
state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
<one line to give the program's name and a brief idea of what it does.>
Copyright (C) <year> <name of author>
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
Also add information on how to contact you by electronic and paper mail.
If your software can interact with users remotely through a computer
network, you should also make sure that it provides a way for users to
get its source. For example, if your program is a web application, its
interface could display a "Source" link that leads users to an archive
of the code. There are many ways you could offer source, and different
solutions will be better for different programs; see section 13 for the
specific requirements.
You should also get your employer (if you work as a programmer) or school,
if any, to sign a "copyright disclaimer" for the program, if necessary.
For more information on this, and how to apply and follow the GNU AGPL, see
<https://www.gnu.org/licenses/>.

View File

@ -0,0 +1,98 @@
# @bible-strong/avatar-react
React 19 renderer for a validated Bible Strong `AvatarDefinition`. React and React DOM 19 are peer
dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
## Install
```sh
pnpm add @bible-strong/avatar-react react react-dom
```
Import the package stylesheet once in the application entry point:
```tsx
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
import type { AvatarDefinition } from '@bible-strong/avatar-core'
import '@bible-strong/avatar-react/styles.css'
import { useRef } from 'react'
export function Assistant({ definition }: { definition: AvatarDefinition }) {
const avatar = useRef<AvatarController>(null)
return (
<>
<Avatar ref={avatar} definition={definition} defaultAnimation="idle" />
<button onClick={() => avatar.current?.play('happy')}>Play happy</button>
<button onClick={() => avatar.current?.setExpression('neutral')}>Neutral</button>
</>
)
}
```
`play` and `setExpression` return `{ ok: true }` or a typed error with one of
`unknown_animation`, `unavailable_standard_animation`, `unknown_expression` or
`controlled_by_props`. `pause` freezes the exact timeline position, calling `play` with the paused
key resumes it, and `stop` returns an uncontrolled avatar to `neutral`.
## Playback props
- `animation` and `expression` are mutually exclusive controlled targets. They take priority over
defaults and imperative target changes.
- `defaultAnimation` and `defaultExpression` initialize uncontrolled use. Animation autoplay is on
by default when `defaultAnimation` is present; set `autoplay={false}` to show its first expression
statically.
- `onAnimationEnd` fires once when a `once` animation completes naturally.
- `onExpressionChange` reports semantic expression changes.
- `size`, `className`, `style` and `ariaLabel` customize layout without changing the definition.
The definition is validated once per immutable object reference and revalidated/reinitialized when
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
The stylesheet exposes `.bs-avatar`, `.bs-avatar--embedded`, `.bs-avatar--floating`,
`.bs-avatar--draggable`, `.bs-avatar--dragging`, `.bs-avatar__svg` and
`.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
copyright ownership, Apache-2.0 relicensing and repository metadata are confirmed. Local tarballs
are for verification only and must not be published.

View File

@ -0,0 +1,45 @@
{
"name": "@bible-strong/avatar-react",
"version": "0.1.0",
"description": "React 19 renderer for Bible Strong procedural avatars.",
"license": "AGPL-3.0-only",
"private": true,
"type": "module",
"sideEffects": [
"**/*.css"
],
"files": [
"dist",
"README.md",
"LICENSE"
],
"exports": {
".": {
"types": "./dist/index.d.ts",
"import": "./dist/index.js"
},
"./styles.css": "./dist/styles.css"
},
"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:^"
},
"peerDependencies": {
"react": "^19.0.0",
"react-dom": "^19.0.0"
},
"devDependencies": {
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"react": "19.2.3",
"react-dom": "19.2.3",
"typescript": "~6.0.3",
"vite": "^8.0.13"
},
"engines": {
"node": ">=22.12.0"
}
}

View File

@ -0,0 +1,646 @@
import {
advanceAvatarPlayback,
createAvatarPlaybackState,
playAvatarAnimation,
pauseAvatarPlayback,
renderAvatarDefinition,
renderAvatarFrame,
resolveAnimation,
resolveExpression,
resumeAvatarPlayback,
validateAvatarDefinition,
type AnimationKey,
type AvatarDefinition,
type AvatarPlaybackState as CorePlaybackState,
type AvatarRuntimeError as CoreRuntimeError,
type ExpressionKey,
} from '@bible-strong/avatar-core'
import {
useEffect,
useId,
useImperativeHandle,
useRef,
useState,
type CSSProperties,
type PointerEvent as ReactPointerEvent,
type ReactElement,
type Ref,
} from 'react'
import { createPortal } from 'react-dom'
import './styles.css'
const validatedDefinitions = new WeakSet<object>()
const assertValidDefinition = (definition: AvatarDefinition) => {
if (validatedDefinitions.has(definition)) return
const result = validateAvatarDefinition(definition)
if (!result.ok) {
throw new Error(`Invalid avatar definition: ${result.errors[0]?.message}`)
}
validatedDefinitions.add(definition)
}
export type AvatarRuntimeError =
| CoreRuntimeError
| {
code: 'controlled_by_props'
key: string
message: string
}
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
}
export type AvatarPosition = { x: number; y: number }
export type FloatingInitialPosition =
AvatarPosition | { top?: number; right?: number; bottom?: number; left?: number }
export type AvatarProps = {
definition: AvatarDefinition
ref?: Ref<AvatarController>
animation?: AnimationKey
expression?: ExpressionKey
defaultAnimation?: AnimationKey
defaultExpression?: ExpressionKey
autoplay?: boolean
size?: number | string
className?: string
style?: CSSProperties
mode?: 'embedded' | 'floating'
portalContainer?: HTMLElement
draggable?: boolean
constrainTo?: 'none' | 'viewport' | 'parent'
position?: AvatarPosition
initialPosition?: FloatingInitialPosition
zIndex?: number
ariaLabel?: string
onPositionPreview?: (position: AvatarPosition) => void
onPositionCommit?: (position: AvatarPosition) => void
/** Suggested clamped position when controlled bounds change. */
onPositionChange?: (position: AvatarPosition) => void
onDragStart?: () => void
onDragEnd?: (position: AvatarPosition) => void
onAnimationEnd?: (animation: AnimationKey) => void
onExpressionChange?: (expression: ExpressionKey) => void
}
const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
left.activeAnimation === right.activeAnimation &&
left.activeExpression === right.activeExpression &&
left.status === right.status &&
left.stepIndex === right.stepIndex &&
left.direction === right.direction &&
left.phase === right.phase &&
left.phaseStartedAt === right.phaseStartedAt &&
left.transitionFrom === right.transitionFrom &&
left.blinkDueAt === right.blinkDueAt &&
left.blinkStartedAt === right.blinkStartedAt
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
left.x === right.x && left.y === right.y
const sizeInPixels = (size: number | string | undefined) => (typeof size === 'number' ? size : 240)
const initialPoint = (
value: FloatingInitialPosition | undefined,
width: number,
height: number
): AvatarPosition => {
if (!value) return { x: 32, y: 32 }
if ('x' in value && 'y' in value) return { x: value.x, y: value.y }
const x = value.left ?? Math.max(0, width - (value.right ?? 32))
const y = value.top ?? Math.max(0, height - (value.bottom ?? 32))
return { x, y }
}
export function Avatar({
definition,
ref,
animation,
expression,
defaultAnimation,
defaultExpression,
autoplay,
size = 240,
className,
style,
mode = 'embedded',
portalContainer,
draggable = false,
constrainTo,
position,
initialPosition,
zIndex = 1000,
ariaLabel = 'Procedural avatar',
onPositionPreview,
onPositionCommit,
onPositionChange,
onDragStart,
onDragEnd,
onAnimationEnd,
onExpressionChange,
}: AvatarProps): ReactElement {
if (animation !== undefined && expression !== undefined) {
throw new Error('Avatar accepts either animation or expression, not both.')
}
assertValidDefinition(definition)
const clipId = `${useId().replaceAll(':', '')}-head`
const wrapperRef = useRef<HTMLDivElement>(null)
const clipPathRef = useRef<SVGPathElement>(null)
const headPathRef = useRef<SVGPathElement>(null)
const leftPathRef = useRef<SVGPathElement>(null)
const rightPathRef = useRef<SVGPathElement>(null)
const backPathRefs = useRef<(SVGPathElement | null)[]>([])
const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
const defaultPlaybackStarted = useRef(false)
const floatingPositionInitialized = useRef(false)
const previewFrame = useRef<number | undefined>(undefined)
const completedAnimation = useRef<AnimationKey | undefined>(undefined)
const playbackRef = useRef<CorePlaybackState>(createAvatarPlaybackState())
const drag = useRef<
| {
pointerId: number
pointer: AvatarPosition
origin: AvatarPosition
current: AvatarPosition
}
| undefined
>(undefined)
const [mounted, setMounted] = useState(false)
const [internalPosition, setInternalPosition] = useState<AvatarPosition>({ x: 0, y: 0 })
const [playback, setPlayback] = useState<CorePlaybackState>(() => {
const key = animation ?? defaultAnimation
if (key) {
const result = resolveAnimation(definition, key)
if (result.ok) {
return {
...createAvatarPlaybackState(),
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
}
}
}
const keyExpression = expression ?? defaultExpression ?? 'neutral'
return { ...createAvatarPlaybackState(), activeExpression: keyExpression }
})
playbackRef.current = playback
const authoritativePosition = position ?? internalPosition
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(() => {
defaultPlaybackStarted.current = false
completedAnimation.current = undefined
const key = animation ?? defaultAnimation
if (key) {
const result = resolveAnimation(definition, key)
if (result.ok) {
const next = {
...createAvatarPlaybackState(),
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
}
playbackRef.current = next
setPlayback(next)
return
}
}
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(() => {
if (
defaultPlaybackStarted.current ||
animation !== undefined ||
expression !== undefined ||
defaultAnimation === undefined ||
autoplay === false
) {
return
}
defaultPlaybackStarted.current = true
const result = playAvatarAnimation(definition, defaultAnimation, performance.now())
if (result.ok) setPlayback(result.value)
}, [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(() => {
if (expression !== undefined) {
const resolved = resolveExpression(definition, expression)
if (resolved.ok) {
setPlayback({ ...createAvatarPlaybackState(), activeExpression: expression })
}
return
}
if (animation !== undefined) {
const result = playAvatarAnimation(definition, animation, performance.now())
if (result.ok) setPlayback(result.value)
}
}, [animation, definition, expression])
useEffect(() => {
onExpressionChange?.(playback.activeExpression)
}, [playback.activeExpression, onExpressionChange])
useEffect(() => {
if (playback.status !== 'playing') return
let frame = 0
const tick = (now: number) => {
const current = playbackRef.current
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
const next = advanceAvatarPlayback(definition, current, now, {
random: Math.random,
reduceMotion,
})
playbackRef.current = next
if (!samePlayback(current, next)) setPlayback(next)
if (
current.status === 'playing' &&
next.status === 'stopped' &&
current.activeAnimation &&
completedAnimation.current !== current.activeAnimation
) {
completedAnimation.current = current.activeAnimation
onAnimationEnd?.(current.activeAnimation)
}
const frameScene = renderAvatarFrame(definition, next, now, {
random: Math.random,
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)
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [definition, playback.status, onAnimationEnd])
const controlled = animation !== undefined || expression !== undefined
useImperativeHandle(ref, () => ({
play(key) {
if (controlled) {
return {
ok: false,
error: {
code: 'controlled_by_props',
key,
message: 'Playback is controlled by Avatar props.',
},
}
}
const current = playbackRef.current
if (
current.status === 'paused' &&
current.activeAnimation === key &&
current.pausedAt !== undefined
) {
const now = performance.now()
const resumed = resumeAvatarPlayback(current, now)
playbackRef.current = resumed
setPlayback(resumed)
return { ok: true }
}
const result = playAvatarAnimation(definition, key, performance.now())
if (!result.ok) return { ok: false, error: result.error }
completedAnimation.current = undefined
playbackRef.current = result.value
setPlayback(result.value)
return { ok: true }
},
setExpression(key) {
if (controlled) {
return {
ok: false,
error: {
code: 'controlled_by_props',
key,
message: 'Expression is controlled by Avatar props.',
},
}
}
const result = resolveExpression(definition, key)
if (!result.ok) return { ok: false, error: result.error }
const next = { ...createAvatarPlaybackState(), activeExpression: key }
playbackRef.current = next
setPlayback(next)
return { ok: true }
},
pause() {
const current = playbackRef.current
if (current.status !== 'playing') return
const next = pauseAvatarPlayback(current, performance.now())
playbackRef.current = next
setPlayback(next)
},
stop() {
if (!controlled) {
const next = createAvatarPlaybackState()
playbackRef.current = next
setPlayback(next)
}
},
getState() {
const current = playbackRef.current
return {
...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
activeExpression: current.activeExpression,
status: current.status,
}
},
}))
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0) 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
ref={wrapperRef}
className={[
'bs-avatar',
`bs-avatar--${mode}`,
draggable ? 'bs-avatar--draggable' : '',
className ?? '',
]
.filter(Boolean)
.join(' ')}
style={{
...style,
width: size,
height: size,
zIndex: mode === 'floating' ? zIndex : style?.zIndex,
transform: `translate3d(${authoritativePosition.x}px, ${authoritativePosition.y}px, 0)`,
}}
role={draggable ? 'group' : 'img'}
aria-label={ariaLabel}
aria-description={
draggable ? 'Use 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()
}}
>
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<path ref={clipPathRef} d={scene.geometry.headPath} />
</clipPath>
</defs>
{scene.geometry.backPaths.map((path, index) => (
<path
ref={element => {
backPathRefs.current[index] = element
}}
d={path}
fill={scene.colors.body}
key={`back-${index}`}
/>
))}
<path ref={headPathRef} d={scene.geometry.headPath} fill={scene.colors.body} />
<g clipPath={`url(#${clipId})`} fill={scene.colors.eyes}>
<path
ref={leftPathRef}
d={scene.geometry.leftPath}
opacity={scene.geometry.leftVisible ? 1 : 0}
/>
<path
ref={rightPathRef}
d={scene.geometry.rightPath}
opacity={scene.geometry.rightVisible ? 1 : 0}
/>
</g>
{scene.geometry.frontPaths.map((path, index) => (
<path
ref={element => {
frontPathRefs.current[index] = element
}}
d={path}
fill={scene.colors.body}
key={`front-${index}`}
/>
))}
</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>
)
if (mode === 'floating' && mounted) {
return createPortal(wrapper, portalContainer ?? document.body)
}
return wrapper
}

View File

@ -0,0 +1,538 @@
// @vitest-environment jsdom
import { renderAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
import { act, createRef, Profiler, StrictMode } from 'react'
import { hydrateRoot } from 'react-dom/client'
import { renderToString } from 'react-dom/server'
import { fireEvent, render } from '@testing-library/react'
import { vi } from 'vitest'
import { Avatar, type AvatarController } from '../Avatar'
let resizeCallbacks: ResizeObserverCallback[] = []
class TestResizeObserver implements ResizeObserver {
constructor(callback: ResizeObserverCallback) {
resizeCallbacks.push(callback)
}
observe() {}
unobserve() {}
disconnect() {}
}
const expression = {
head: { x: 0, y: 0, z: 0 },
eyes: {
left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
spacing: 54,
},
perspective: 1,
motion: { eyes: 'none', body: 'none' },
} as const
const definition: AvatarDefinition = {
schema: 'bible-strong/avatar-definition',
schemaVersion: 1,
name: 'React fixture',
body: {
primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
nodes: [],
},
colors: { body: '#5b7fe5', eyes: '#111316' },
expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } },
expressionOrder: ['neutral', 'smile'],
animations: {
greet: {
playbackMode: 'loop',
steps: [{ expression: 'smile', holdMs: 1_000, transitionMs: 100, transition: 'smooth' }],
blink: {
enabled: false,
initialDelayMs: 0,
minIntervalMs: 1_000,
maxIntervalMs: 1_000,
durationMs: 100,
},
},
'wave-once': {
playbackMode: 'once',
steps: [{ expression: 'smile', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
blink: {
enabled: false,
initialDelayMs: 0,
minIntervalMs: 1_000,
maxIntervalMs: 1_000,
durationMs: 100,
},
},
},
animationOrder: ['greet', 'wave-once'],
standardAnimationSet: 1,
}
beforeAll(() => {
HTMLElement.prototype.setPointerCapture = () => undefined
HTMLElement.prototype.hasPointerCapture = () => true
HTMLElement.prototype.releasePointerCapture = () => undefined
window.matchMedia = () =>
({
matches: false,
addEventListener: () => undefined,
removeEventListener: () => undefined,
}) 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', () => {
it('renders semantic SVG geometry in embedded mode', () => {
const view = render(<Avatar definition={definition} ariaLabel="Assistant avatar" />)
const avatar = view.getByRole('img', { name: 'Assistant avatar' })
expect(avatar.classList.contains('bs-avatar--embedded')).toBe(true)
expect(avatar.querySelector('svg path')).not.toBeNull()
expect(document.body.querySelector('.bs-avatar--floating')).toBeNull()
})
it('exposes semantic imperative controls without Studio identifiers', () => {
const controller = createRef<AvatarController>()
render(<Avatar definition={definition} ref={controller} />)
let result: ReturnType<AvatarController['setExpression']> | undefined
act(() => {
result = controller.current?.setExpression('smile')
})
expect(result).toEqual({ ok: true })
expect(controller.current?.getState()).toMatchObject({
activeExpression: 'smile',
status: 'stopped',
})
expect(controller.current?.play('missing')).toMatchObject({
ok: false,
error: { code: 'unknown_animation', key: 'missing' },
})
act(() => {
result = controller.current?.play('greet')
})
expect(result).toEqual({ ok: true })
expect(controller.current?.getState()).toMatchObject({
activeAnimation: 'greet',
activeExpression: 'smile',
status: 'playing',
})
})
it('rejects imperative target changes when playback is controlled by props', () => {
const controller = createRef<AvatarController>()
render(<Avatar definition={definition} expression="neutral" ref={controller} />)
expect(controller.current?.setExpression('smile')).toMatchObject({
ok: false,
error: { code: 'controlled_by_props' },
})
})
it('rejects simultaneous controlled animation and expression props', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(<Avatar definition={definition} animation="greet" expression="neutral" />)
).toThrow('Avatar accepts either animation or expression, not both.')
errors.mockRestore()
})
it('honors uncontrolled defaults without autoplay when requested', () => {
const controller = createRef<AvatarController>()
render(
<Avatar definition={definition} defaultAnimation="greet" autoplay={false} ref={controller} />
)
expect(controller.current?.getState()).toEqual({
activeExpression: 'smile',
status: 'stopped',
})
})
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('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', () => {
const controller = createRef<AvatarController>()
const clock = vi.spyOn(performance, 'now')
clock.mockReturnValueOnce(100).mockReturnValueOnce(250).mockReturnValueOnce(1_250)
render(<Avatar definition={definition} ref={controller} />)
act(() => {
controller.current?.play('greet')
controller.current?.pause()
controller.current?.play('greet')
})
expect(controller.current?.getState()).toMatchObject({
activeAnimation: 'greet',
activeExpression: 'smile',
status: 'playing',
})
clock.mockRestore()
})
it('updates SVG frames without rendering React once per animation frame', () => {
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)
let renders = 0
render(
<Profiler id="animated-avatar" onRender={() => renders++}>
<Avatar definition={definition} defaultAnimation="greet" />
</Profiler>
)
const beforeFrames = renders
act(() => {
for (let index = 1; index <= 20; index++) {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(1_000 + index)
}
})
expect(renders).toBe(beforeFrames)
clock.mockRestore()
request.mockRestore()
cancel.mockRestore()
})
it('fires once-completion exactly once under Strict Mode', () => {
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 ended = vi.fn()
render(
<StrictMode>
<Avatar definition={definition} defaultAnimation="wave-once" onAnimationEnd={ended} />
</StrictMode>
)
act(() => {
const callbacks = [...frames.values()]
frames.clear()
callbacks.forEach(callback => callback(performance.now() + 1_000))
})
expect(ended).toHaveBeenCalledTimes(1)
expect(ended).toHaveBeenCalledWith('wave-once')
request.mockRestore()
cancel.mockRestore()
})
})

View File

@ -0,0 +1,10 @@
export { Avatar } from './Avatar'
export type {
AvatarCommandResult,
AvatarController,
AvatarPlaybackState,
AvatarPosition,
AvatarProps,
AvatarRuntimeError,
FloatingInitialPosition,
} from './Avatar'

View File

@ -0,0 +1,98 @@
.bs-avatar {
box-sizing: border-box;
display: inline-grid;
place-items: center;
max-width: 100%;
aspect-ratio: 1;
outline: none;
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:focus-visible {
outline: 3px solid currentColor;
outline-offset: 3px;
}
.bs-avatar__svg {
display: block;
width: 100%;
height: 100%;
overflow: visible;
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 (prefers-reduced-motion: reduce) {
.bs-avatar,
.bs-avatar * {
scroll-behavior: auto !important;
transition-duration: 0.01ms !important;
}
}

1
packages/avatar-react/src/styles.d.ts vendored Normal file
View File

@ -0,0 +1 @@
declare module '*.css'

View File

@ -0,0 +1,12 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": false,
"emitDeclarationOnly": true,
"declaration": true,
"declarationMap": true,
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}

View File

@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"isolatedModules": true,
"skipLibCheck": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src", "vite.config.ts"],
"exclude": ["src/**/__tests__/**"]
}

View File

@ -0,0 +1,18 @@
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',
cssFileName: 'styles',
},
sourcemap: true,
rollupOptions: {
external: ['react', 'react-dom', 'react/jsx-runtime', '@bible-strong/avatar-core'],
},
},
})

533
pnpm-lock.yaml generated
View File

@ -11,12 +11,21 @@ importers:
'@base-ui/react':
specifier: ^1.7.0
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@bible-strong/avatar-core':
specifier: workspace:*
version: link:packages/avatar-core
'@bible-strong/avatar-react':
specifier: workspace:*
version: link:packages/avatar-react
'@vercel/analytics':
specifier: ^2.0.1
version: 2.0.1(react@19.2.3)
'@vercel/speed-insights':
specifier: ^2.0.0
version: 2.0.0(react@19.2.3)
ajv:
specifier: ^8.20.0
version: 8.20.0
class-variance-authority:
specifier: ^0.7.1
version: 0.7.1
@ -54,6 +63,9 @@ importers:
'@tailwindcss/vite':
specifier: ^4.3.3
version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
'@testing-library/react':
specifier: ^16.3.2
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
'@types/node':
specifier: ^24.10.1
version: 24.13.3
@ -69,6 +81,9 @@ importers:
babel-plugin-react-compiler:
specifier: ^1.0.0
version: 1.0.0
jsdom:
specifier: ^30.0.1
version: 30.0.1
prettier:
specifier: ^3.8.1
version: 3.9.6
@ -86,10 +101,87 @@ importers:
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
vitest:
specifier: ^4.1.10
version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
examples/react-vite-consumer:
dependencies:
'@bible-strong/avatar-core':
specifier: workspace:*
version: link:../../packages/avatar-core
'@bible-strong/avatar-react':
specifier: workspace:*
version: link:../../packages/avatar-react
'@vitejs/plugin-react':
specifier: ^6.0.2
version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
react:
specifier: 19.2.3
version: 19.2.3
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
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)
devDependencies:
'@types/react':
specifier: ^19.2.14
version: 19.2.18
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.4(@types/react@19.2.18)
packages/avatar-core:
dependencies:
ajv:
specifier: ^8.20.0
version: 8.20.0
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-react:
dependencies:
'@bible-strong/avatar-core':
specifier: workspace:^
version: link:../avatar-core
devDependencies:
'@types/react':
specifier: ^19.2.14
version: 19.2.18
'@types/react-dom':
specifier: ^19.2.3
version: 19.2.4(@types/react@19.2.18)
react:
specifier: 19.2.3
version: 19.2.3
react-dom:
specifier: 19.2.3
version: 19.2.3(react@19.2.3)
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:
'@asamuzakjp/css-color@6.0.7':
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
engines: {node: ^22.13.0 || >=24.0.0}
'@asamuzakjp/dom-selector@8.3.2':
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
engines: {node: ^22.13.0 || >=24.0.0}
'@babel/code-frame@7.29.7':
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
engines: {node: '>=6.9.0'}
@ -188,6 +280,55 @@ packages:
'@types/react':
optional: true
'@bramus/specificity@2.4.2':
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
hasBin: true
'@csstools/color-helpers@6.1.0':
resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
engines: {node: '>=20.19.0'}
'@csstools/css-calc@3.3.0':
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-color-parser@4.1.10':
resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-parser-algorithms': ^4.0.0
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-parser-algorithms@4.0.0':
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
engines: {node: '>=20.19.0'}
peerDependencies:
'@csstools/css-tokenizer': ^4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.7':
resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
peerDependencies:
css-tree: ^3.2.1
peerDependenciesMeta:
css-tree:
optional: true
'@csstools/css-tokenizer@4.0.0':
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
engines: {node: '>=20.19.0'}
'@exodus/bytes@1.15.1':
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
peerDependencies:
'@noble/hashes': ^1.8.0 || ^2.0.0
peerDependenciesMeta:
'@noble/hashes':
optional: true
'@floating-ui/core@1.8.0':
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
@ -429,6 +570,28 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7 || ^8
'@testing-library/dom@10.4.1':
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
engines: {node: '>=18'}
'@testing-library/react@16.3.2':
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
engines: {node: '>=18'}
peerDependencies:
'@testing-library/dom': ^10.0.0
'@types/react': ^18.0.0 || ^19.0.0
'@types/react-dom': ^18.0.0 || ^19.0.0
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
'@types/aria-query@5.0.4':
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
'@types/chai@5.2.3':
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
@ -546,6 +709,20 @@ packages:
'@vitest/utils@4.1.10':
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
ajv@8.20.0:
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
ansi-regex@5.0.1:
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
engines: {node: '>=8'}
ansi-styles@5.2.0:
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
engines: {node: '>=10'}
aria-query@5.3.0:
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
assertion-error@2.0.1:
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
engines: {node: '>=12'}
@ -558,6 +735,9 @@ packages:
engines: {node: '>=6.0.0'}
hasBin: true
bidi-js@1.0.3:
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
browserslist@4.28.8:
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
@ -580,9 +760,17 @@ packages:
convert-source-map@2.0.0:
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
css-tree@3.2.1:
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
csstype@3.2.3:
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
data-urls@7.0.0:
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
debug@4.4.3:
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
engines: {node: '>=6.0'}
@ -592,10 +780,20 @@ packages:
supports-color:
optional: true
decimal.js@10.6.0:
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
dequal@2.0.3:
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
engines: {node: '>=6'}
detect-libc@2.1.2:
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
engines: {node: '>=8'}
dom-accessibility-api@0.5.16:
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
electron-to-chromium@1.5.405:
resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==}
@ -603,6 +801,10 @@ packages:
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
engines: {node: '>=10.13.0'}
entities@8.0.0:
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
engines: {node: '>=20.19.0'}
es-module-lexer@2.3.1:
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
@ -617,6 +819,12 @@ packages:
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
engines: {node: '>=12.0.0'}
fast-deep-equal@3.1.3:
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
fast-uri@3.1.5:
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@ -649,6 +857,13 @@ packages:
graceful-fs@4.2.11:
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
html-encoding-sniffer@6.0.0:
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
is-potential-custom-element-name@1.0.1:
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
jiti@2.7.0:
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
hasBin: true
@ -656,11 +871,23 @@ packages:
js-tokens@4.0.0:
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
jsdom@30.0.1:
resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
peerDependencies:
canvas: ^3.2.3
peerDependenciesMeta:
canvas:
optional: true
jsesc@3.1.0:
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
engines: {node: '>=6'}
hasBin: true
json-schema-traverse@1.0.0:
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
json5@2.2.3:
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
engines: {node: '>=6'}
@ -814,6 +1041,10 @@ packages:
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
engines: {node: '>= 12.0.0'}
lru-cache@11.5.2:
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
engines: {node: 20 || >=22}
lru-cache@5.1.1:
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
@ -822,9 +1053,16 @@ packages:
peerDependencies:
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
lz-string@1.5.0:
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
hasBin: true
magic-string@0.30.21:
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
mdn-data@2.27.1:
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
motion-dom@13.0.0:
resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==}
@ -858,6 +1096,9 @@ packages:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
parse5@8.0.1:
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@ -877,11 +1118,22 @@ packages:
engines: {node: '>=14'}
hasBin: true
pretty-format@27.5.1:
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
punycode@2.3.1:
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
engines: {node: '>=6'}
react-dom@19.2.3:
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
peerDependencies:
react: ^19.2.3
react-is@17.0.2:
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
react-resizable-panels@4.12.2:
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
peerDependencies:
@ -892,6 +1144,10 @@ packages:
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
engines: {node: '>=0.10.0'}
require-from-string@2.0.2:
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
engines: {node: '>=0.10.0'}
reselect@5.2.0:
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
@ -900,6 +1156,10 @@ packages:
engines: {node: ^20.19.0 || >=22.12.0}
hasBin: true
saxes@6.0.0:
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
engines: {node: '>=v12.22.7'}
scheduler@0.27.0:
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
@ -920,6 +1180,9 @@ packages:
std-env@4.2.0:
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
symbol-tree@3.2.4:
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
tailwind-merge@3.6.0:
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
@ -945,6 +1208,21 @@ packages:
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
engines: {node: '>=14.0.0'}
tldts-core@7.4.10:
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
tldts@7.4.10:
resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
hasBin: true
tough-cookie@6.0.2:
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
engines: {node: '>=16'}
tr46@6.0.0:
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
engines: {node: '>=20'}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@ -956,6 +1234,10 @@ packages:
undici-types@7.18.2:
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
undici@8.10.0:
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
engines: {node: '>=22.19.0'}
update-browserslist-db@1.3.1:
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
hasBin: true
@ -1051,16 +1333,58 @@ packages:
jsdom:
optional: true
w3c-xmlserializer@5.0.0:
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
engines: {node: '>=18'}
webidl-conversions@8.0.1:
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
engines: {node: '>=20'}
whatwg-mimetype@5.0.0:
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
engines: {node: '>=20'}
whatwg-url@16.0.1:
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
whatwg-url@17.1.0:
resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
engines: {node: ^22.14.0 || >=24.0.0}
why-is-node-running@2.3.0:
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
engines: {node: '>=8'}
hasBin: true
xml-name-validator@5.0.0:
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
engines: {node: '>=18'}
xmlchars@2.2.0:
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
snapshots:
'@asamuzakjp/css-color@6.0.7':
dependencies:
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
lru-cache: 11.5.2
'@asamuzakjp/dom-selector@8.3.2':
dependencies:
bidi-js: 1.0.3
css-tree: 3.2.1
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
'@babel/code-frame@7.29.7':
dependencies:
'@babel/helper-validator-identifier': 7.29.7
@ -1186,6 +1510,36 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
'@bramus/specificity@2.4.2':
dependencies:
css-tree: 3.2.1
'@csstools/color-helpers@6.1.0': {}
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/color-helpers': 6.1.0
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
dependencies:
'@csstools/css-tokenizer': 4.0.0
'@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
optionalDependencies:
css-tree: 3.2.1
'@csstools/css-tokenizer@4.0.0': {}
'@exodus/bytes@1.15.1': {}
'@floating-ui/core@1.8.0':
dependencies:
'@floating-ui/utils': 0.2.12
@ -1347,6 +1701,29 @@ snapshots:
tailwindcss: 4.3.3
vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
'@testing-library/dom@10.4.1':
dependencies:
'@babel/code-frame': 7.29.7
'@babel/runtime': 7.29.7
'@types/aria-query': 5.0.4
aria-query: 5.3.0
dom-accessibility-api: 0.5.16
lz-string: 1.5.0
picocolors: 1.1.1
pretty-format: 27.5.1
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
dependencies:
'@babel/runtime': 7.29.7
'@testing-library/dom': 10.4.1
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
optionalDependencies:
'@types/react': 19.2.18
'@types/react-dom': 19.2.4(@types/react@19.2.18)
'@types/aria-query@5.0.4': {}
'@types/chai@5.2.3':
dependencies:
'@types/deep-eql': 4.0.2
@ -1425,6 +1802,21 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.1
ajv@8.20.0:
dependencies:
fast-deep-equal: 3.1.3
fast-uri: 3.1.5
json-schema-traverse: 1.0.0
require-from-string: 2.0.2
ansi-regex@5.0.1: {}
ansi-styles@5.2.0: {}
aria-query@5.3.0:
dependencies:
dequal: 2.0.3
assertion-error@2.0.1: {}
babel-plugin-react-compiler@1.0.0:
@ -1433,6 +1825,10 @@ snapshots:
baseline-browser-mapping@2.11.13: {}
bidi-js@1.0.3:
dependencies:
require-from-string: 2.0.2
browserslist@4.28.8:
dependencies:
baseline-browser-mapping: 2.11.13
@ -1453,14 +1849,32 @@ snapshots:
convert-source-map@2.0.0: {}
css-tree@3.2.1:
dependencies:
mdn-data: 2.27.1
source-map-js: 1.2.1
csstype@3.2.3: {}
data-urls@7.0.0:
dependencies:
whatwg-mimetype: 5.0.0
whatwg-url: 16.0.1
transitivePeerDependencies:
- '@noble/hashes'
debug@4.4.3:
dependencies:
ms: 2.1.3
decimal.js@10.6.0: {}
dequal@2.0.3: {}
detect-libc@2.1.2: {}
dom-accessibility-api@0.5.16: {}
electron-to-chromium@1.5.405: {}
enhanced-resolve@5.24.5:
@ -1468,6 +1882,8 @@ snapshots:
graceful-fs: 4.2.11
tapable: 2.3.3
entities@8.0.0: {}
es-module-lexer@2.3.1: {}
escalade@3.2.0: {}
@ -1478,6 +1894,10 @@ snapshots:
expect-type@1.4.0: {}
fast-deep-equal@3.1.3: {}
fast-uri@3.1.5: {}
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@ -1498,12 +1918,48 @@ snapshots:
graceful-fs@4.2.11: {}
html-encoding-sniffer@6.0.0:
dependencies:
'@exodus/bytes': 1.15.1
transitivePeerDependencies:
- '@noble/hashes'
is-potential-custom-element-name@1.0.1: {}
jiti@2.7.0: {}
js-tokens@4.0.0: {}
jsdom@30.0.1:
dependencies:
'@asamuzakjp/css-color': 6.0.7
'@asamuzakjp/dom-selector': 8.3.2
'@bramus/specificity': 2.4.2
'@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
'@exodus/bytes': 1.15.1
css-tree: 3.2.1
data-urls: 7.0.0
decimal.js: 10.6.0
html-encoding-sniffer: 6.0.0
is-potential-custom-element-name: 1.0.1
lru-cache: 11.5.2
parse5: 8.0.1
saxes: 6.0.0
symbol-tree: 3.2.4
tough-cookie: 6.0.2
undici: 8.10.0
w3c-xmlserializer: 5.0.0
webidl-conversions: 8.0.1
whatwg-mimetype: 5.0.0
whatwg-url: 17.1.0
xml-name-validator: 5.0.0
transitivePeerDependencies:
- '@noble/hashes'
jsesc@3.1.0: {}
json-schema-traverse@1.0.0: {}
json5@2.2.3: {}
lightningcss-android-arm64@1.32.0:
@ -1604,6 +2060,8 @@ snapshots:
lightningcss-win32-arm64-msvc: 1.33.0
lightningcss-win32-x64-msvc: 1.33.0
lru-cache@11.5.2: {}
lru-cache@5.1.1:
dependencies:
yallist: 3.1.1
@ -1612,10 +2070,14 @@ snapshots:
dependencies:
react: 19.2.3
lz-string@1.5.0: {}
magic-string@0.30.21:
dependencies:
'@jridgewell/sourcemap-codec': 1.5.5
mdn-data@2.27.1: {}
motion-dom@13.0.0:
dependencies:
motion-utils: 13.0.0
@ -1638,6 +2100,10 @@ snapshots:
obug@2.1.4: {}
parse5@8.0.1:
dependencies:
entities: 8.0.0
pathe@2.0.3: {}
picocolors@1.1.1: {}
@ -1652,11 +2118,21 @@ snapshots:
prettier@3.9.6: {}
pretty-format@27.5.1:
dependencies:
ansi-regex: 5.0.1
ansi-styles: 5.2.0
react-is: 17.0.2
punycode@2.3.1: {}
react-dom@19.2.3(react@19.2.3):
dependencies:
react: 19.2.3
scheduler: 0.27.0
react-is@17.0.2: {}
react-resizable-panels@4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
react: 19.2.3
@ -1664,6 +2140,8 @@ snapshots:
react@19.2.3: {}
require-from-string@2.0.2: {}
reselect@5.2.0: {}
rolldown@1.2.4:
@ -1686,6 +2164,10 @@ snapshots:
'@rolldown/binding-win32-arm64-msvc': 1.2.4
'@rolldown/binding-win32-x64-msvc': 1.2.4
saxes@6.0.0:
dependencies:
xmlchars: 2.2.0
scheduler@0.27.0: {}
semver@6.3.1: {}
@ -1698,6 +2180,8 @@ snapshots:
std-env@4.2.0: {}
symbol-tree@3.2.4: {}
tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {}
@ -1715,12 +2199,28 @@ snapshots:
tinyrainbow@3.1.1: {}
tldts-core@7.4.10: {}
tldts@7.4.10:
dependencies:
tldts-core: 7.4.10
tough-cookie@6.0.2:
dependencies:
tldts: 7.4.10
tr46@6.0.0:
dependencies:
punycode: 2.3.1
tslib@2.8.1: {}
typescript@6.0.3: {}
undici-types@7.18.2: {}
undici@8.10.0: {}
update-browserslist-db@1.3.1(browserslist@4.28.8):
dependencies:
browserslist: 4.28.8
@ -1743,7 +2243,7 @@ snapshots:
fsevents: 2.3.3
jiti: 2.7.0
vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)):
vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)):
dependencies:
'@vitest/expect': 4.1.10
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
@ -1767,12 +2267,41 @@ snapshots:
why-is-node-running: 2.3.0
optionalDependencies:
'@types/node': 24.13.3
jsdom: 30.0.1
transitivePeerDependencies:
- msw
w3c-xmlserializer@5.0.0:
dependencies:
xml-name-validator: 5.0.0
webidl-conversions@8.0.1: {}
whatwg-mimetype@5.0.0: {}
whatwg-url@16.0.1:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
whatwg-url@17.1.0:
dependencies:
'@exodus/bytes': 1.15.1
tr46: 6.0.0
webidl-conversions: 8.0.1
transitivePeerDependencies:
- '@noble/hashes'
why-is-node-running@2.3.0:
dependencies:
siginfo: 2.0.0
stackback: 0.0.2
xml-name-validator@5.0.0: {}
xmlchars@2.2.0: {}
yallist@3.1.1: {}

3
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,3 @@
packages:
- packages/*
- examples/*

View File

@ -8,6 +8,11 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
const result = await build({
configFile: false,
logLevel: 'silent',
resolve: {
alias: {
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
},
},
build: {
write: false,
minify: true,

View File

@ -0,0 +1,47 @@
import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { spawnSync } from 'node:child_process'
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
const workspace = await mkdtemp(path.join(tmpdir(), 'avatar-runtime-pack-smoke-'))
const consumer = path.join(workspace, 'consumer')
const run = (args, cwd = root, capture = false) => {
const result = spawnSync('pnpm', args, {
cwd,
encoding: 'utf8',
stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
})
if (result.status !== 0) process.exit(result.status ?? 1)
return result.stdout?.trim()
}
run(['packages:build'])
const pack = packageName => {
const output = run(
['--dir', path.join(root, 'packages', packageName), 'pack', '--pack-destination', workspace],
root,
true
)
return output.split('\n').at(-1)
}
const coreTarball = pack('avatar-core')
const reactTarball = pack('avatar-react')
await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
recursive: true,
filter: source =>
!source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
})
const packagePath = path.join(consumer, 'package.json')
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
packageJson.dependencies['@bible-strong/avatar-core'] = `file:${coreTarball}`
packageJson.dependencies['@bible-strong/avatar-react'] = `file:${reactTarball}`
packageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
run(['install', '--no-frozen-lockfile'], consumer)
run(['build'], consumer)
process.stdout.write(`Tarball consumer verified at ${consumer}\n`)

View File

@ -124,11 +124,13 @@ export function ExportSection({
value,
title,
subtitle,
badge,
children,
}: {
value: string
title: string
subtitle: string
badge?: string
children: React.ReactNode
}) {
const { t } = useStudioLanguage()
@ -136,7 +138,10 @@ export function ExportSection({
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
<AccordionTrigger className="export-accordion-trigger">
<span>
<span className="export-accordion-title">
<strong>{t(title)}</strong>
{badge && <b className="export-menu-badge">{t(badge)}</b>}
</span>
<small>{t(subtitle)}</small>
</span>
</AccordionTrigger>

View File

@ -382,12 +382,58 @@ input {
}
.export-avatar-summary small,
.export-avatar-summary span,
.export-format-grid small {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
}
.runtime-readiness {
display: grid;
gap: 10px;
border-color: color-mix(in srgb, var(--destructive) 32%, var(--border));
background: color-mix(in srgb, var(--destructive) 5%, var(--card));
}
.runtime-readiness[data-ready='true'] {
border-color: #b9d8c6;
background: #f1faf5;
}
.runtime-readiness-heading {
display: flex;
align-items: flex-start;
gap: 10px;
}
.runtime-readiness-heading > svg {
width: 18px;
height: 18px;
flex: none;
color: var(--destructive);
}
.runtime-readiness[data-ready='true'] .runtime-readiness-heading > svg {
color: #278453;
}
.runtime-readiness-heading > div {
display: grid;
gap: 2px;
}
.runtime-readiness-heading strong {
font-size: 12px;
}
.runtime-readiness-heading small {
color: var(--muted-foreground);
font-size: 10px;
}
.runtime-error-list {
display: grid;
gap: 5px;
margin: 0;
padding-left: 18px;
color: var(--destructive);
font-size: 11px;
line-height: 1.45;
}
.export-avatar-summary strong {
overflow: hidden;
font-size: 16px;
@ -460,6 +506,106 @@ input {
min-height: 44px;
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
}
.runtime-export-actions {
display: grid;
gap: 8px;
}
.runtime-export-actions > button {
width: 100%;
min-height: 44px;
}
.runtime-copy-status {
margin: 0;
color: var(--muted-foreground);
font-size: 11px;
text-align: center;
}
.runtime-copy-status[role='alert'] {
color: var(--destructive);
}
.runtime-quickstart {
display: grid;
gap: 12px;
}
.runtime-quickstart > button {
min-height: 44px;
}
.runtime-code-example {
display: grid;
min-width: 0;
gap: 5px;
}
.runtime-code-example > small {
color: var(--muted-foreground);
font-size: 10px;
font-weight: 700;
}
.runtime-code-example pre {
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-size: 9px;
line-height: 1.55;
white-space: pre;
}
.runtime-token-comment {
color: #a6adbb;
}
.runtime-token-keyword {
color: #e9a6ff;
}
.runtime-token-string {
color: #b9e88c;
}
.runtime-token-tag {
color: #8fc7ff;
}
.runtime-live-example {
display: grid;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.runtime-live-example-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.runtime-live-example-heading > div {
display: grid;
gap: 2px;
}
.runtime-live-example-heading small {
color: var(--muted-foreground);
font-size: 10px;
}
.runtime-live-example-heading strong {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
}
.runtime-live-example-heading button {
flex: none;
}
.runtime-live-example-stage {
display: grid;
min-height: 190px;
overflow: hidden;
place-items: center;
border: 1px solid var(--border);
border-radius: 12px;
background-color: #f5f7fc;
background-image:
linear-gradient(rgb(91 127 229 / 6%) 1px, transparent 1px),
linear-gradient(90deg, rgb(91 127 229 / 6%) 1px, transparent 1px);
background-size: 16px 16px;
}
.export-panel {
width: 100%;
min-width: 0;
@ -478,6 +624,24 @@ input {
min-width: 0;
gap: 5px;
}
.export-accordion-title {
display: flex;
min-width: 0;
align-items: center;
gap: 7px;
}
.export-menu-badge {
flex: none;
padding: 2px 5px;
border-radius: 5px;
background: #e9edfa;
color: #4168d5;
font-size: 8px;
font-weight: 850;
letter-spacing: 0.08em;
line-height: 1.3;
text-transform: uppercase;
}
.export-accordion-trigger strong {
color: #343b49;
font-size: 12px;
@ -1622,6 +1786,24 @@ p {
font-size: 11px;
line-height: 1.45;
}
.semantic-key-card input {
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
}
.semantic-key-label {
color: var(--foreground);
font-size: 12px;
font-weight: 700;
}
.semantic-key-error {
margin: 0;
color: var(--destructive);
font-size: 11px;
line-height: 1.45;
}
.semantic-key-card input[aria-invalid='true'] {
border-color: var(--destructive);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--destructive) 14%, transparent);
}
.color-panel {
position: relative;
}
@ -2147,6 +2329,23 @@ p {
font-size: 8px;
font-weight: 850;
}
.runtime-key-missing {
position: absolute;
top: 5px;
left: 5px;
display: grid;
width: 17px;
height: 17px;
place-items: center;
border: 1px solid #fff;
border-radius: 50%;
background: var(--destructive);
color: white;
font-size: 10px;
font-style: normal;
font-weight: 900;
line-height: 1;
}
.expression-card[aria-pressed='true'] {
border-color: #5b7fe5;
box-shadow: 0 0 0 2px rgb(91 127 229 / 18%);

View File

@ -1,6 +1,7 @@
import {
advanceSequenceCursor,
createInitialSequences,
duplicateSequence,
getSequenceSpring,
normalizeSequencesForExpressions,
parseSequences,
@ -94,4 +95,13 @@ describe('editable avatar sequences', () => {
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
expect(smooth.damping).toBeGreaterThan(0)
})
it('clears the public semantic key when an animation is duplicated', () => {
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
const duplicate = duplicateSequence(sequence)
expect(duplicate.semanticKey).toBeUndefined()
expect(duplicate.id).not.toBe(sequence.id)
})
})

View File

@ -62,6 +62,7 @@ export function SequenceWorkspace({
onSave,
onDuplicate,
onDelete,
semanticKeyError,
}: {
editing: { sourceId: string | null; draft: AvatarSequence }
expressions: Expression[]
@ -86,6 +87,7 @@ export function SequenceWorkspace({
onSave: () => void
onDuplicate: () => void
onDelete: () => void
semanticKeyError: string | null
}) {
const { t } = useStudioLanguage()
const draggedStepId = useRef<string | null>(null)
@ -161,6 +163,35 @@ export function SequenceWorkspace({
compact
>
<InspectorCard>
<Field>
<label className="semantic-key-label" htmlFor={`animation-key-${editing.draft.id}`}>
{t('Clé sémantique')}
</label>
<Input
id={`animation-key-${editing.draft.id}`}
value={editing.draft.semanticKey ?? ''}
maxLength={64}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
aria-invalid={Boolean(semanticKeyError)}
aria-describedby={`animation-key-help-${editing.draft.id}`}
onChange={event =>
onChange({
...editing.draft,
semanticKey: event.currentTarget.value || undefined,
})
}
/>
<p
id={`animation-key-help-${editing.draft.id}`}
className={semanticKeyError ? 'semantic-key-error' : 'field-help'}
role={semanticKeyError ? 'alert' : undefined}
>
{semanticKeyError ??
t('Clé publique stable utilisée par l’API runtime, par exemple thinking.')}
</p>
</Field>
<Field>
<FieldTitle>{t('Nom')}</FieldTitle>
<Input

View File

@ -28,6 +28,7 @@ export type BlinkSettings = {
export type AvatarSequence = {
id: string
semanticKey?: string
name: string
group: string
description: string
@ -67,6 +68,7 @@ export const createInitialSequences = (): AvatarSequence[] =>
const playback = getStatePlaybackConfig(id)
return {
id,
semanticKey: id,
name: id,
group,
description:
@ -117,6 +119,7 @@ const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence
const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
return {
id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
...(typeof candidate?.semanticKey === 'string' ? { semanticKey: candidate.semanticKey } : {}),
name:
typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
group:
@ -227,6 +230,7 @@ export const createSequence = (expressionId = initialExpressions[0].id): AvatarS
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
...cloneSequence(source),
id: createId('sequence'),
semanticKey: undefined,
name: `${source.name} copy`,
builtIn: false,
steps: source.steps.map(step => ({ ...step, id: createId('step') })),

View File

@ -0,0 +1,715 @@
import {
AVATAR_DEFINITION_MAX_BYTES,
avatarDefinitionFileName,
createAvatarDefinition,
getStandardAnimationAvailabilityV1,
parseAvatarDefinition,
STANDARD_ANIMATIONS_V1,
STANDARD_ANIMATION_ORDER_V1,
validateAvatarDefinition,
type AvatarDefinition,
} from '@/features/avatar/avatarDefinition'
import {
defaultAvatarColors,
defaultAvatarEyes,
resolveAvatarBehavior,
type AvatarBehaviorLibrary,
type StudioAvatar,
} from '@/features/avatar/avatars'
import { createInitialSequences, createSequence } from '@/features/animation/sequences'
import { defaultExpression, initialExpressions } from '@/features/avatar/presets'
import { surfacePresets } from '@/features/avatar/surfaces'
import { loadStudioDocument } from '@/features/studio/studioDocument'
const avatarFixture = (): StudioAvatar => ({
id: 'avatar-fixture',
name: 'Fixture',
body: {
primary: {
...surfacePresets.cone,
width: 251.123456,
roundness: 0.25,
morphRoundness: 0.2,
tipRoundness: 0.3,
baseRoundness: 0.4,
},
nodes: [
{
id: 'shape-private',
name: 'Private label',
surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
position: [1.25, -2.5, 3.75],
rotation: [-10, 20, 30],
},
],
},
colors: { body: '#abcdef', eyes: '#123456' },
eyes: {
...defaultAvatarEyes,
widthLeft: defaultAvatarEyes.widthLeft + 3,
positionYRight: defaultAvatarEyes.positionYRight - 4,
},
})
const behaviorFixture = (): AvatarBehaviorLibrary => {
const expression = {
...initialExpressions[0],
semanticKey: 'happy-smile',
bodyColor: '#fedcba',
eyeColor: '#654321',
eyeMotion: 'microSaccades' as const,
bodyMotion: 'slowDrift' as const,
}
const sequence = createInitialSequences()[0]
return {
expressions: [expression],
sequences: [
{
...sequence,
semanticKey: 'happy',
playbackMode: 'once',
steps: [
{
...sequence.steps[0],
expressionId: expression.id,
holdMs: 1234,
transitionMs: 432,
transition: 'snappy',
},
],
blink: {
enabled: true,
initialDelayMs: 100,
minIntervalMs: 250,
maxIntervalMs: 120000,
durationMs: 50,
},
},
],
}
}
const definitionFixture = (): AvatarDefinition => {
const result = createAvatarDefinition({ avatar: avatarFixture(), behavior: behaviorFixture() })
if (!result.ok) throw new Error(JSON.stringify(result.errors))
return structuredClone(result.value) as AvatarDefinition
}
const expectError = (value: unknown, code: string, path?: string) => {
const result = validateAvatarDefinition(value)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ code, ...(path === undefined ? {} : { path }) }),
])
)
}
describe('avatar definition validation', () => {
it('validates, clones, deeply freezes, and JSON round-trips a v1 definition', () => {
const input = definitionFixture()
const result = validateAvatarDefinition(input)
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value).not.toBe(input)
expect(Object.isFrozen(result.value)).toBe(true)
expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
expect(JSON.parse(JSON.stringify(result.value))).toEqual(input)
expect(validateAvatarDefinition(JSON.parse(JSON.stringify(result.value))).ok).toBe(true)
})
it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
expectError(
{ ...definitionFixture(), schemaVersion: 2 },
'unsupported_version',
'/schemaVersion'
)
expectError({ ...definitionFixture(), surprise: true }, 'additionalProperties', '/surprise')
const malformed = definitionFixture()
malformed.expressions['Happy Smile'] = malformed.expressions['happy-smile']
malformed.expressionOrder.push('Happy Smile')
expectError(malformed, 'pattern', '/expressions/Happy Smile')
const missingNeutral = definitionFixture()
delete missingNeutral.expressions.neutral
missingNeutral.expressionOrder = missingNeutral.expressionOrder.filter(key => key !== 'neutral')
expectError(missingNeutral, 'required', '/expressions/neutral')
})
it('rejects incomplete order lists and dangling animation references with JSON pointers', () => {
const incomplete = definitionFixture()
incomplete.expressionOrder = ['neutral']
expectError(incomplete, 'incomplete_order', '/expressionOrder')
const dangling = definitionFixture()
dangling.animations.happy.steps[0].expression = 'missing'
expectError(dangling, 'unknown_expression', '/animations/happy/steps/0/expression')
})
it('requires neutral to be the first expression-order entry', () => {
const definition = definitionFixture()
definition.expressionOrder = ['happy-smile', 'neutral']
expectError(definition, 'neutral_not_first', '/expressionOrder/0')
})
it('rejects non-finite numbers, non-plain objects, and invalid blink ranges', () => {
const nonFinite = definitionFixture()
nonFinite.body.primary.width = Number.POSITIVE_INFINITY
expectError(nonFinite, 'non_finite_number', '/body/primary/width')
const nonPlain = definitionFixture()
nonPlain.colors = new (class {
body = '#abcdef' as const
eyes = '#123456' as const
})()
expectError(nonPlain, 'non_plain_object', '/colors')
const interval = definitionFixture()
interval.animations.happy.blink.minIntervalMs = 500
interval.animations.happy.blink.maxIntervalMs = 499
expectError(interval, 'invalid_interval_range', '/animations/happy/blink/minIntervalMs')
})
it.each([
['dimension minimum', (value: AvatarDefinition) => (value.body.primary.width = 0), 'minimum'],
[
'dimension maximum',
(value: AvatarDefinition) => (value.body.primary.height = 10000.1),
'maximum',
],
[
'roundness minimum',
(value: AvatarDefinition) => (value.body.primary.roundness = -0.1),
'minimum',
],
[
'roundness maximum',
(value: AvatarDefinition) => (value.body.primary.roundness = 1.1),
'maximum',
],
[
'position minimum',
(value: AvatarDefinition) => (value.body.nodes[0].position[0] = -10000.1),
'minimum',
],
[
'position maximum',
(value: AvatarDefinition) => (value.body.nodes[0].position[1] = 10000.1),
'maximum',
],
[
'rotation minimum',
(value: AvatarDefinition) => (value.body.nodes[0].rotation[0] = -360.1),
'minimum',
],
[
'rotation maximum',
(value: AvatarDefinition) => (value.body.nodes[0].rotation[1] = 360.1),
'maximum',
],
[
'perspective minimum',
(value: AvatarDefinition) => (value.expressions.neutral.perspective = 0.09),
'minimum',
],
[
'perspective maximum',
(value: AvatarDefinition) => (value.expressions.neutral.perspective = 10.01),
'maximum',
],
[
'eye bound',
(value: AvatarDefinition) => (value.expressions.neutral.eyes.left.x = 10001),
'maximum',
],
[
'head bound',
(value: AvatarDefinition) => (value.expressions.neutral.head.z = -10001),
'minimum',
],
[
'hold minimum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 99),
'minimum',
],
[
'hold maximum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 60001),
'maximum',
],
[
'transition minimum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = -1),
'minimum',
],
[
'transition maximum',
(value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = 5001),
'maximum',
],
[
'blink initial delay',
(value: AvatarDefinition) => (value.animations.happy.blink.initialDelayMs = 60001),
'maximum',
],
[
'blink interval minimum',
(value: AvatarDefinition) => (value.animations.happy.blink.minIntervalMs = 249),
'minimum',
],
[
'blink interval maximum',
(value: AvatarDefinition) => (value.animations.happy.blink.maxIntervalMs = 120001),
'maximum',
],
[
'blink duration minimum',
(value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 49),
'minimum',
],
[
'blink duration maximum',
(value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 2001),
'maximum',
],
])('enforces the documented %s boundary', (_name, mutate, code) => {
const definition = definitionFixture()
mutate(definition)
expectError(definition, code)
})
it('accepts all inclusive numeric boundaries', () => {
const definition = definitionFixture()
definition.body.primary.width = 0.001
definition.body.primary.height = 10000
definition.body.primary.roundness = 0
definition.body.primary.morphRoundness = 1
definition.body.nodes[0].position = [-10000, 0, 10000]
definition.body.nodes[0].rotation = [-360, 0, 360]
definition.expressions.neutral.head = { x: -10000, y: 0, z: 10000 }
definition.expressions.neutral.perspective = 0.1
definition.expressions['happy-smile'].perspective = 10
definition.animations.happy.steps[0].holdMs = 60000
definition.animations.happy.steps[0].transitionMs = 5000
definition.animations.happy.blink = {
enabled: false,
initialDelayMs: 60000,
minIntervalMs: 250,
maxIntervalMs: 120000,
durationMs: 2000,
}
expect(validateAvatarDefinition(definition).ok).toBe(true)
})
it('enforces collection and text limits', () => {
const nodes = definitionFixture()
nodes.body.nodes = Array.from({ length: 17 }, () => structuredClone(nodes.body.nodes[0]))
expectError(nodes, 'maxItems', '/body/nodes')
const steps = definitionFixture()
steps.animations.happy.steps = Array.from({ length: 129 }, () => ({
...steps.animations.happy.steps[0],
}))
expectError(steps, 'maxItems', '/animations/happy/steps')
const text = definitionFixture()
text.name = 'n'.repeat(121)
text.animations.happy.metadata!.label = 'l'.repeat(121)
text.animations.happy.metadata!.description = 'd'.repeat(513)
text.animations.happy.metadata!.group = 'g'.repeat(65)
expectError(text, 'maxLength')
})
it('enforces expression and animation collection limits', () => {
const expressions = definitionFixture()
const pose = expressions.expressions['happy-smile']
expressions.expressions = { neutral: expressions.expressions.neutral }
expressions.expressionOrder = ['neutral']
for (let index = 0; index < 128; index += 1) {
const key = `pose-${index}`
expressions.expressions[key] = structuredClone(pose)
expressions.expressionOrder.push(key)
}
expectError(expressions, 'maxProperties', '/expressions')
const animations = definitionFixture()
const animation = animations.animations.happy
animations.animations = {}
animations.animationOrder = []
for (let index = 0; index < 65; index += 1) {
const key = `animation-${index}`
animations.animations[key] = structuredClone(animation)
animations.animationOrder.push(key)
}
expectError(animations, 'maxProperties', '/animations')
})
it('accepts exact collection and text maxima', () => {
const definition = definitionFixture()
definition.name = 'n'.repeat(120)
definition.animations.happy.metadata = {
label: 'l'.repeat(120),
description: 'd'.repeat(512),
group: 'g'.repeat(64),
}
definition.body.nodes = Array.from({ length: 16 }, () =>
structuredClone(definition.body.nodes[0])
)
definition.animations.happy.steps = Array.from({ length: 128 }, () => ({
...definition.animations.happy.steps[0],
}))
expect(validateAvatarDefinition(definition).ok).toBe(true)
})
it('enforces the 64-character semantic-key maximum', () => {
const definition = definitionFixture()
const expression = definition.expressions['happy-smile']
delete definition.expressions['happy-smile']
const key = `a${'b'.repeat(64)}`
definition.expressions[key] = expression
definition.expressionOrder = ['neutral', key]
expectError(definition, 'maxLength', `/expressions/${key}`)
})
it('requires lowercase six-digit colors and known secondary surface types', () => {
const color = definitionFixture()
color.colors.body = '#ABCDEF'
expectError(color, 'pattern', '/colors/body')
const surface = definitionFixture()
surface.body.nodes[0].surface.type = 'cursor' as 'sphere'
expectError(surface, 'enum', '/body/nodes/0/surface/type')
})
})
describe('bounded avatar JSON parser', () => {
it('rejects duplicate object members at the second member path', () => {
const result = parseAvatarDefinition('{"schema":1,"schema":2}')
expect(result).toEqual({
ok: false,
errors: [expect.objectContaining({ path: '/schema', code: 'duplicate_key' })],
})
})
it('rejects oversized UTF-8 input and excessive nesting', () => {
const oversized = parseAvatarDefinition(' '.repeat(AVATAR_DEFINITION_MAX_BYTES + 1))
expect(oversized).toEqual({
ok: false,
errors: [expect.objectContaining({ path: '', code: 'max_bytes' })],
})
const nested = parseAvatarDefinition(`${'['.repeat(33)}null${']'.repeat(33)}`)
expect(nested).toEqual({
ok: false,
errors: [expect.objectContaining({ code: 'max_depth' })],
})
})
it('accepts the exact byte limit and depth limit before schema validation', () => {
const json = JSON.stringify(definitionFixture())
const jsonBytes = new TextEncoder().encode(json).byteLength
const exactBytes = `${json}${' '.repeat(AVATAR_DEFINITION_MAX_BYTES - jsonBytes)}`
expect(new TextEncoder().encode(exactBytes)).toHaveLength(AVATAR_DEFINITION_MAX_BYTES)
expect(parseAvatarDefinition(exactBytes).ok).toBe(true)
const depth32 = `${'['.repeat(32)}null${']'.repeat(32)}`
const result = parseAvatarDefinition(depth32)
expect(result.ok).toBe(false)
if (!result.ok) expect(result.errors.some(error => error.code === 'max_depth')).toBe(false)
})
it('rejects overlong decoded strings before schema validation', () => {
const result = parseAvatarDefinition(`{"name":"${'a'.repeat(513)}"}`)
expect(result).toEqual({
ok: false,
errors: [expect.objectContaining({ path: '/name', code: 'string_too_long' })],
})
})
it('applies semantic-key limits while tokenizing and treats prototype keys as data', () => {
const longKey = `a${'b'.repeat(64)}`
const longKeyResult = parseAvatarDefinition(`{"expressions":{"${longKey}":null}}`)
expect(longKeyResult).toEqual({
ok: false,
errors: [
expect.objectContaining({
path: `/expressions/${longKey}`,
code: 'string_too_long',
}),
],
})
const prototypeResult = parseAvatarDefinition('{"__proto__":{"polluted":true}}')
expect(prototypeResult.ok).toBe(false)
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
if (!prototypeResult.ok) {
expect(prototypeResult.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({ path: '/__proto__', code: 'additionalProperties' }),
])
)
}
})
it('parses a valid definition and rejects malformed JSON', () => {
expect(parseAvatarDefinition(JSON.stringify(definitionFixture())).ok).toBe(true)
const malformed = parseAvatarDefinition('{"schema":]')
expect(malformed).toEqual({
ok: false,
errors: [expect.objectContaining({ code: 'invalid_json' })],
})
})
})
describe('Studio to avatar definition conversion', () => {
it('exports the complete bundled document with curated semantic keys', () => {
const document = loadStudioDocument({ getItem: () => null })
const avatar = document.library.avatars[0]
const behavior = resolveAvatarBehavior(avatar, {
expressions: document.expressions,
sequences: document.sequences,
})
const result = createAvatarDefinition({ avatar, behavior })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressionOrder).toHaveLength(28)
expect(result.value.expressionOrder[0]).toBe('neutral')
expect(result.value.animationOrder).toHaveLength(23)
expect(result.value.animationOrder).toContain('idle')
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
'upward-side-glance',
'curious-left',
])
expect(getStandardAnimationAvailabilityV1(result.value.expressions).available).toEqual(
STANDARD_ANIMATION_ORDER_V1
)
expect(JSON.stringify(result.value)).not.toContain('expression-00')
})
it('rejects newly created custom content until semantic keys are supplied', () => {
const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
const sequence = createSequence(expression.id)
expect(expression.semanticKey).toBeUndefined()
expect(sequence.semanticKey).toBeUndefined()
const result = createAvatarDefinition({
avatar: avatarFixture(),
behavior: { expressions: [expression], sequences: [sequence] },
})
expect(result.ok).toBe(false)
if (!result.ok) {
expect(result.errors.map(error => error.code)).toEqual(
expect.arrayContaining(['missing_semantic_key'])
)
}
})
it('preserves geometry, colors, timing, motion, and resolves avatar eye defaults', () => {
const avatar = avatarFixture()
const behavior = behaviorFixture()
const result = createAvatarDefinition({ avatar, behavior })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.body.primary).toEqual(avatar.body.primary)
expect(result.value.body.nodes).toEqual([
{
surface: avatar.body.nodes[0].surface,
position: avatar.body.nodes[0].position,
rotation: avatar.body.nodes[0].rotation,
},
])
expect(result.value.body.nodes[0]).not.toHaveProperty('id')
expect(result.value.body.nodes[0]).not.toHaveProperty('name')
expect(result.value.colors).toEqual(avatar.colors)
expect(result.value.expressions.neutral.eyes.left.width).toBe(defaultExpression.widthLeft + 3)
expect(result.value.expressions.neutral.eyes.right.y).toBe(defaultExpression.positionYRight - 4)
expect(result.value.expressions['happy-smile'].eyes.left.width).toBeCloseTo(
behavior.expressions[0].widthLeft + 3
)
expect(result.value.expressions['happy-smile'].colors).toEqual({
body: '#fedcba',
eyes: '#654321',
})
expect(result.value.expressions['happy-smile'].motion).toEqual({
eyes: 'microSaccades',
body: 'slowDrift',
})
expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
expect(result.value.animations.happy).toMatchObject({
playbackMode: 'once',
steps: [
{
expression: 'happy-smile',
holdMs: 1234,
transitionMs: 432,
transition: 'snappy',
},
],
blink: behavior.sequences[0].blink,
})
})
it.each([
[undefined, 'missing_semantic_key'],
['Bad Key', 'invalid_semantic_key'],
['neutral', 'reserved_semantic_key'],
])('rejects an unexportable expression semantic key', (semanticKey, code) => {
const behavior = behaviorFixture()
behavior.expressions[0].semanticKey = semanticKey
const result = createAvatarDefinition({ avatar: avatarFixture(), behavior })
expect(result.ok).toBe(false)
if (!result.ok)
expect(result.errors).toEqual(expect.arrayContaining([expect.objectContaining({ code })]))
})
it('rejects duplicate keys and unresolved animation expression references', () => {
const duplicate = behaviorFixture()
duplicate.expressions.push({ ...duplicate.expressions[0], id: 'other-expression' })
const duplicateResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: duplicate })
expect(duplicateResult.ok).toBe(false)
if (!duplicateResult.ok) {
expect(duplicateResult.errors).toEqual(
expect.arrayContaining([expect.objectContaining({ code: 'duplicate_semantic_key' })])
)
}
const dangling = behaviorFixture()
dangling.sequences[0].steps[0].expressionId = 'missing-expression'
const danglingResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: dangling })
expect(danglingResult.ok).toBe(false)
if (!danglingResult.ok) {
expect(danglingResult.errors).toEqual(
expect.arrayContaining([
expect.objectContaining({
path: '/studio/animations/0/steps/0/expressionId',
code: 'unresolved_expression_reference',
}),
])
)
}
})
it('does not mutate Studio inputs', () => {
const avatar = avatarFixture()
const behavior = behaviorFixture()
const before = JSON.stringify({ avatar, behavior })
createAvatarDefinition({ avatar, behavior })
expect(JSON.stringify({ avatar, behavior })).toBe(before)
expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
})
})
describe('standard animation catalogue v1', () => {
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', () => {
expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
})
})

View File

@ -4,6 +4,7 @@ import {
applyAvatarEyeDefaults,
cloneAvatarBehavior,
createAvatar,
createUnkeyedExpressionCopy,
defaultAvatarEyes,
parseAvatarEyeDefaults,
parseAvatarRenderStyle,
@ -12,6 +13,16 @@ import {
import { initialExpressions } from '@/features/avatar/presets'
describe('avatar eye defaults', () => {
it('clears the public semantic key when creating custom content from a preset', () => {
const source = { ...defaultExpression, semanticKey: 'attentive-left' }
const copy = createUnkeyedExpressionCopy(source, 'expression-copy')
expect(copy.id).toBe('expression-copy')
expect(copy.semanticKey).toBeUndefined()
expect(source.semanticKey).toBe('attentive-left')
})
it('keeps the historical rendering when using default values', () => {
expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
})

View File

@ -0,0 +1,23 @@
import { readFile } from 'node:fs/promises'
import { resolve } from 'node:path'
import { createAvatarDefinition } from '../avatarDefinition'
import { resolveAvatarBehavior } from '../avatars'
import { loadStudioDocument } from '../../studio/studioDocument'
it('keeps the consumer fixture synchronized with the bundled Strobi Studio document', async () => {
const document = loadStudioDocument({ getItem: () => null })
const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi')
if (!avatar) throw new Error('Bundled Strobi avatar not found')
const behavior = resolveAvatarBehavior(avatar, {
expressions: document.expressions,
sequences: document.sequences,
})
const result = createAvatarDefinition({ avatar, behavior })
if (!result.ok) throw new Error(result.errors.map(error => error.message).join('\n'))
const fixture = await readFile(
resolve('examples/react-vite-consumer/src/strobi.avatar.json'),
'utf8'
)
expect(JSON.parse(fixture)).toEqual(result.value)
})

View File

@ -1,115 +1 @@
import type { BodyMotion, Expression, EyeMotion } from './geometry'
export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const
export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const
const eyeMotionSet = new Set<string>(eyeMotionModes)
const bodyMotionSet = new Set<string>(bodyMotionModes)
export const isEyeMotion = (value: unknown): value is EyeMotion =>
typeof value === 'string' && eyeMotionSet.has(value)
export const isBodyMotion = (value: unknown): value is BodyMotion =>
typeof value === 'string' && bodyMotionSet.has(value)
const smoothstep = (value: number) => value * value * (3 - 2 * value)
const hash = (value: number) => {
const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
return (raw - Math.floor(raw)) * 2 - 1
}
const expressionSeed = (expression: Expression) =>
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
const EYE_MOTION_SEED = 17.29
const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => {
const progress = elapsedMs / interval
const step = Math.floor(progress)
const blend = smoothstep(progress - step)
const previous = hash(step * 3 + axis + seed)
const next = hash((step + 1) * 3 + axis + seed)
return previous + (next - previous) * blend
}
const saccade = (elapsedMs: number, axis: number, seed: number) => {
const interval = 1100
const duration = 140
if (elapsedMs <= 0) return 0
const step = Math.floor(elapsedMs / interval)
const progress = (elapsedMs - step * interval) / duration
const blend = smoothstep(Math.min(progress, 1))
const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
const next = hash(step * 2 + axis + seed)
return previous + (next - previous) * blend
}
export const hasAmbientMotion = (expression: Expression) =>
expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
const seed = expressionSeed(expression)
if (expression.bodyMotion === 'slowDrift') {
return {
x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
}
}
if (expression.bodyMotion === 'shake') {
const time = elapsedMs / 1000
return {
x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
}
}
return { x: 0, y: 0 }
}
export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
if (expression.eyeMotion === 'microSaccades') {
return {
x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
}
}
if (expression.eyeMotion === 'shake') {
const time = elapsedMs / 1000
return {
x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
}
}
return { x: 0, y: 0 }
}
export const applyAmbientBodyMotion = (
expression: Expression,
elapsedMs: number,
strength = 1
): Expression => {
const next = { ...expression }
const seed = expressionSeed(expression)
if (expression.bodyMotion === 'slowDrift') {
next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
} else if (expression.bodyMotion === 'shake') {
const time = elapsedMs / 1000
next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
next.headZ += Math.sin(time * 43) * 0.7 * strength
}
return next
}
export const applyAmbientMotion = (
expression: Expression,
elapsedMs: number,
strength = 1
): Expression => {
const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
next.positionXLeft += eyeOffset.x
next.positionXRight += eyeOffset.x
next.positionYLeft += eyeOffset.y
next.positionYRight += eyeOffset.y
return next
}
export * from '@bible-strong/avatar-core'

View File

@ -0,0 +1,192 @@
import {
getSemanticKeyIssue,
validateAvatarDefinition,
type AvatarAnimationDefinition,
type AvatarBodyNodeDefinition,
type AvatarDefinition,
type AvatarDefinitionError,
type AvatarExpressionDefinition,
type BodyNodeSurfaceType,
type HexColor,
type SurfaceDefinition,
type ValidationResult,
} from '@bible-strong/avatar-core'
import type { SurfaceType } from '@bible-strong/avatar-core'
import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
import { defaultExpression } from './presets'
import type { Expression } from './geometry'
export * from '@bible-strong/avatar-core'
const mapSurface = <TType extends SurfaceType>(
surface: SurfaceDefinition<TType>
): SurfaceDefinition<TType> => ({
type: surface.type,
width: surface.width,
height: surface.height,
depth: surface.depth,
roundness: surface.roundness,
...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
})
const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
head: { x: expression.headX, y: expression.headY, z: expression.headZ },
eyes: {
left: {
width: expression.widthLeft,
height: expression.heightLeft,
x: expression.positionXLeft,
y: expression.positionYLeft,
angle: expression.leftAngle,
},
right: {
width: expression.widthRight,
height: expression.heightRight,
x: expression.positionXRight,
y: expression.positionYRight,
angle: expression.rightAngle,
},
spacing: expression.spacing,
},
perspective: expression.perspective,
motion: { eyes: expression.eyeMotion, body: expression.bodyMotion },
...(expression.bodyColor || expression.eyeColor
? {
colors: {
...(expression.bodyColor ? { body: expression.bodyColor as HexColor } : {}),
...(expression.eyeColor ? { eyes: expression.eyeColor as HexColor } : {}),
},
}
: {}),
})
const semanticKeyError = (
path: string,
kind: 'expression' | 'animation',
semanticKey: string | undefined,
seen: Set<string>
): AvatarDefinitionError | undefined => {
const issue = getSemanticKeyIssue(semanticKey, kind)
if (issue === 'missing_semantic_key') {
return { path, code: 'missing_semantic_key', message: `${kind} semantic key is required` }
}
if (issue === 'invalid_semantic_key') {
return { path, code: 'invalid_semantic_key', message: `Invalid semantic key '${semanticKey}'` }
}
if (issue === 'reserved_semantic_key') {
return { path, code: 'reserved_semantic_key', message: "'neutral' is reserved" }
}
if (semanticKey === undefined) {
return { path, code: 'missing_semantic_key', message: `${kind} semantic key is required` }
}
if (seen.has(semanticKey)) {
return {
path,
code: 'duplicate_semantic_key',
message: `Duplicate semantic key '${semanticKey}'`,
}
}
seen.add(semanticKey)
return undefined
}
export const createAvatarDefinition = ({
avatar,
behavior,
}: {
avatar: StudioAvatar
behavior: AvatarBehaviorLibrary
}): ValidationResult<AvatarDefinition> => {
const errors: AvatarDefinitionError[] = []
const expressionKeys = new Set<string>()
const animationKeys = new Set<string>()
const expressionKeyById = new Map<string, string>()
behavior.expressions.forEach((expression, index) => {
const error = semanticKeyError(
`/studio/expressions/${index}/semanticKey`,
'expression',
expression.semanticKey,
expressionKeys
)
if (error) errors.push(error)
else expressionKeyById.set(expression.id, expression.semanticKey!)
})
behavior.sequences.forEach((sequence, index) => {
const error = semanticKeyError(
`/studio/animations/${index}/semanticKey`,
'animation',
sequence.semanticKey,
animationKeys
)
if (error) errors.push(error)
sequence.steps.forEach((step, stepIndex) => {
if (!expressionKeyById.has(step.expressionId)) {
errors.push({
path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
code: 'unresolved_expression_reference',
message: `Animation step references unexportable expression '${step.expressionId}'`,
})
}
})
})
if (errors.length) return { ok: false, errors }
const expressions: Record<string, AvatarExpressionDefinition> = {
neutral: mapExpression(applyAvatarEyeDefaults(defaultExpression, avatar.eyes)),
}
behavior.expressions.forEach(expression => {
expressions[expression.semanticKey!] = mapExpression(
applyAvatarEyeDefaults(expression, avatar.eyes)
)
})
const animations: Record<string, AvatarAnimationDefinition> = Object.fromEntries(
behavior.sequences.map(sequence => [
sequence.semanticKey!,
{
playbackMode: sequence.playbackMode,
steps: sequence.steps.map(step => ({
expression: expressionKeyById.get(step.expressionId)!,
holdMs: step.holdMs,
transitionMs: step.transitionMs,
transition: step.transition,
})),
blink: { ...sequence.blink },
metadata: {
label: sequence.name,
description: sequence.description,
group: sequence.group,
},
},
])
)
const definition: AvatarDefinition = {
schema: 'bible-strong/avatar-definition',
schemaVersion: 1,
...(avatar.name ? { name: avatar.name } : {}),
body: {
primary: mapSurface(avatar.body.primary),
nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
position: [...node.position],
rotation: [...node.rotation],
})),
},
colors: {
body: avatar.colors.body as HexColor,
eyes: avatar.colors.eyes as HexColor,
},
expressions,
expressionOrder: [
'neutral',
...behavior.expressions.map(expression => expression.semanticKey!),
],
animations,
animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!),
standardAnimationSet: 1,
}
return validateAvatarDefinition(definition)
}

View File

@ -120,6 +120,12 @@ export const applyAvatarEyeDefaults = (
return result
}
export const createUnkeyedExpressionCopy = (source: Expression, id: string): Expression => ({
...source,
id,
semanticKey: undefined,
})
export type AvatarLibrary = {
activeAvatarId: string
avatars: StudioAvatar[]
@ -153,6 +159,7 @@ export const parseExpressions = (value: unknown): Expression[] => {
parsed.bodyColor = candidate.bodyColor
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
parsed.eyeColor = candidate.eyeColor
if (typeof candidate.semanticKey === 'string') parsed.semanticKey = candidate.semanticKey
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
? storedBodyMotion
@ -173,6 +180,41 @@ export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBeha
sequences: cloneSequences(behavior.sequences),
})
export const restoreLegacyBehaviorSemanticKeys = (
behavior: AvatarBehaviorLibrary,
reference: AvatarBehaviorLibrary
): AvatarBehaviorLibrary => {
const expressionKeys = new Map(
reference.expressions.flatMap(expression =>
expression.semanticKey ? [[expression.id, expression.semanticKey] as const] : []
)
)
const sequenceKeys = new Map(
reference.sequences.flatMap(sequence =>
sequence.semanticKey ? [[sequence.id, sequence.semanticKey] as const] : []
)
)
const restoreExpressions = behavior.expressions.every(
expression => expression.semanticKey === undefined
)
const restoreSequences = behavior.sequences.every(sequence => sequence.semanticKey === undefined)
return {
expressions: restoreExpressions
? behavior.expressions.map(expression => {
const semanticKey = expressionKeys.get(expression.id)
return semanticKey ? { ...expression, semanticKey } : expression
})
: behavior.expressions,
sequences: restoreSequences
? behavior.sequences.map(sequence => {
const semanticKey = sequenceKeys.get(sequence.id)
return semanticKey ? { ...sequence, semanticKey } : sequence
})
: behavior.sequences,
}
}
export const resolveAvatarBehavior = (
avatar: StudioAvatar,
base: AvatarBehaviorLibrary
@ -186,13 +228,18 @@ const parseAvatarBehavior = (
const candidate = value as Partial<AvatarBehaviorLibrary>
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
const expressions = parseExpressions(candidate.expressions)
const sequences = normalizeSequencesForExpressions(
return restoreLegacyBehaviorSemanticKeys(
{
expressions,
sequences: normalizeSequencesForExpressions(
Array.isArray(candidate.sequences)
? parseSequences(candidate.sequences)
: cloneSequences(base.sequences),
expressions
),
},
base
)
return { expressions, sequences }
}
export const createAvatar = (name: string): StudioAvatar => ({

View File

@ -1,117 +1 @@
import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
export type BodyVector = readonly [number, number, number]
export type BodyNode = {
id: string
name: string
surface: SurfaceConfig
position: BodyVector
rotation: BodyVector
}
export type AvatarBody = {
primary: SurfaceConfig
nodes: BodyNode[]
}
export const bodyPrimitiveTypes = [
'sphere',
'cube',
'capsule',
'cylinder',
'cone',
'diamond',
] as const
export const MAX_BODY_NODES = 16
const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[]
const finite = (value: unknown): value is number =>
typeof value === 'number' && Number.isFinite(value)
const vector = (value: unknown): value is BodyVector =>
Array.isArray(value) && value.length === 3 && value.every(finite)
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
if (!value || typeof value !== 'object') return { ...fallback }
const candidate = value as Partial<SurfaceConfig>
const type =
candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type
const preset = surfacePresets[type]
const numericFields = ['width', 'height', 'depth', 'roundness'] as const
if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback }
if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness))
return { ...fallback }
if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness))
return { ...fallback }
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
return { ...fallback }
return { ...preset, ...candidate, type }
}
export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => {
if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] }
const candidate = value as Partial<AvatarBody>
const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
const seenIds = new Set<string>()
const nodes = Array.isArray(candidate.nodes)
? candidate.nodes
.filter((node): node is BodyNode => {
if (!node || typeof node !== 'object') return false
const surface = (node as BodyNode).surface
const id = (node as BodyNode).id
if (id === 'primary' || seenIds.has(id)) return false
const valid = Boolean(
typeof (node as BodyNode).id === 'string' &&
id &&
typeof (node as BodyNode).name === 'string' &&
surface &&
bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) &&
finite(surface.width) &&
finite(surface.height) &&
finite(surface.depth) &&
finite(surface.roundness) &&
vector((node as BodyNode).position) &&
vector((node as BodyNode).rotation)
)
if (valid) seenIds.add(id)
return valid
})
.slice(0, MAX_BODY_NODES)
.map(node => ({
...node,
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
}))
: []
return { primary, nodes }
}
export const createBodyNode = (
type: (typeof bodyPrimitiveTypes)[number],
index: number
): BodyNode => {
const preset = surfacePresets[type]
const scale = 0.34
const side = index % 2 === 0 ? -1 : 1
return {
id: `shape-${crypto.randomUUID()}`,
name: `${surfaceLabels[type]} ${index + 1}`,
surface: {
...preset,
width: preset.width * scale,
height: preset.height * scale,
depth: preset.depth * scale,
},
position: [side * 82, -72, -18],
rotation: [0, 0, 0],
}
}
export const duplicateBodyNode = (source: BodyNode): BodyNode => ({
...source,
id: `shape-${crypto.randomUUID()}`,
name: `${source.name} copie`,
surface: { ...source.surface },
position: [source.position[0] + 14, source.position[1] + 14, source.position[2]],
rotation: [...source.rotation],
})
export * from '@bible-strong/avatar-core'

View File

@ -3,6 +3,8 @@ import { useState, type RefObject } from 'react'
import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import { Field } from '@/components/ui/field'
import { Input } from '@/components/ui/input'
import {
ContextMenu,
ContextMenuContent,
@ -141,6 +143,7 @@ export function ExpressionCard({
onDragOver,
onDrop,
onDragEnd,
runtimeError,
}: {
expression: Expression
index: number
@ -161,6 +164,7 @@ export function ExpressionCard({
onDragOver?: (event: React.DragEvent<HTMLButtonElement>) => void
onDrop?: (event: React.DragEvent<HTMLButtonElement>) => void
onDragEnd?: () => void
runtimeError: string | null
}) {
const { t } = useStudioLanguage()
const card = (
@ -187,6 +191,16 @@ export function ExpressionCard({
renderStyle={renderStyle}
id={previewId}
/>
{runtimeError && (
<i
className="runtime-key-missing"
role="img"
aria-label={runtimeError}
title={runtimeError}
>
!
</i>
)}
<span>{String(index).padStart(2, '0')}</span>
</Button>
)
@ -225,6 +239,7 @@ export function ExpressionWorkspace({
onSave,
onDuplicate,
onDelete,
semanticKeyError,
}: {
editing: { index: number | null; draft: Expression }
avatarColors: AvatarColors
@ -234,6 +249,7 @@ export function ExpressionWorkspace({
onSave: () => void
onDuplicate: () => void
onDelete: () => void
semanticKeyError: string | null
}) {
const { t } = useStudioLanguage()
const [linked, setLinked] = useState({
@ -293,6 +309,43 @@ export function ExpressionWorkspace({
</header>
<div className="workspace-scroll">
<div className="dialog-fields">
<ControlSection
title="Identité runtime"
subtitle="Nom public stable utilisé par les applications qui chargent cet avatar."
compact
>
<Card className="dialog-group semantic-key-card">
<Field>
<label
className="semantic-key-label"
htmlFor={`expression-key-${editing.draft.id}`}
>
{t('Clé sémantique')}
</label>
<Input
id={`expression-key-${editing.draft.id}`}
value={editing.draft.semanticKey ?? ''}
maxLength={64}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
aria-invalid={Boolean(semanticKeyError)}
aria-describedby={`expression-key-help-${editing.draft.id}`}
onChange={event =>
update({ semanticKey: event.currentTarget.value || undefined })
}
/>
<p
id={`expression-key-help-${editing.draft.id}`}
className={semanticKeyError ? 'semantic-key-error' : 'field-help'}
role={semanticKeyError ? 'alert' : undefined}
>
{semanticKeyError ??
t('Clé publique stable utilisée par l’API runtime, par exemple happy-smile.')}
</p>
</Field>
</Card>
</ControlSection>
<ControlSection
title="Corps"
subtitle="Apparence et orientation générale de l’avatar."

File diff suppressed because it is too large Load Diff

View File

@ -1,5 +1,33 @@
import type { Expression } from './geometry'
export const bundledExpressionSemanticKeys: Record<string, string> = {
'expression-00': 'upward-side-glance',
'expression-01': 'downward-gaze',
'expression-02': 'joyful-down-right',
'expression-03': 'surprised-left',
'expression-04': 'sleepy-squint',
'expression-05': 'skeptical-right',
'expression-06': 'small-attentive',
'expression-07': 'angry-right',
'expression-08': 'curious-left',
'expression-09': 'asymmetric-down-right',
'expression-10': 'attentive-left',
'expression-11': 'joyful-wide',
'expression-12': 'wide-downward-gaze',
'expression-13': 'eyes-closed',
'expression-14': 'skeptical-left',
'expression-15': 'far-right-glance',
'expression-16': 'angry-left',
'expression-17': 'playful-right',
'expression-18': 'asymmetric-up-left',
'expression-19': 'gentle-downward-gaze',
'expression-20': 'wide-down-left',
'expression-21': 'surprised-wide-left',
'expression-22': 'drowsy-closed',
'expression-23': 'suspicious-right',
'expression-24': 'shy-downward',
}
const calibrated: number[][] = [
[7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0],
[-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0],
@ -46,6 +74,7 @@ export const initialExpressions: Expression[] = calibrated.map(
index
) => ({
id: `expression-${String(index).padStart(2, '0')}`,
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
headX,
headY,
headZ,

View File

@ -1,665 +1 @@
import type { Point3 } from './geometry'
export type SurfaceType =
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
export type SurfaceConfig = {
type: SurfaceType
width: number
height: number
depth: number
roundness: number
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
export type SurfaceSample = {
point: Point3
normal: Point3
}
export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
cylinder: {
type: 'cylinder',
width: 235,
height: 250,
depth: 215,
roundness: 0.45,
morphRoundness: 0,
},
cone: {
type: 'cone',
width: 250,
height: 265,
depth: 225,
roundness: 0,
morphRoundness: 0,
tipRoundness: 0.55,
baseRoundness: 0.45,
},
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
}
export const surfaceLabels: Record<SurfaceType, string> = {
sphere: 'Sphère',
mickey: 'Mickey',
cursor: 'Curseur',
cube: 'Cube',
capsule: 'Capsule',
cylinder: 'Cylindre',
cone: 'Cône',
diamond: 'Diamant',
}
const signedPower = (value: number, exponent: number) =>
Math.sign(value) * Math.abs(value) ** exponent
const superellipsoid = (
longitude: number,
latitude: number,
width: number,
height: number,
depth: number,
verticalExponent: number,
horizontalExponent: number
): Point3 => {
const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
return [
(width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
(height / 2) * signedPower(Math.sin(latitude), verticalExponent),
(depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
]
}
const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
const radiusX = config.width / 2
const radiusZ = config.depth / 2
const capRadius = Math.min(radiusX, config.height / 2)
const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
const meridianLength = straightHalf * 2 + Math.PI * capRadius
const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
let radial = radiusX
let y = 0
if (distance < (Math.PI * capRadius) / 2) {
const angle = -Math.PI / 2 + distance / capRadius
radial = radiusX * Math.cos(angle)
y = -straightHalf + capRadius * Math.sin(angle)
} else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
y = -straightHalf + distance - (Math.PI * capRadius) / 2
} else {
const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
radial = radiusX * Math.cos(angle)
y = straightHalf + capRadius * Math.sin(angle)
}
const depthScale = radiusX ? radiusZ / radiusX : 1
return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
}
const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
const MIN_CUBE_SURFACE_POWER = 0.04
const cubeExponent = (config: SurfaceConfig) => {
if (config.roundness <= 0) return Infinity
// The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
const surfacePower =
MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
return 2 / surfacePower
}
const lpSurface = (
config: SurfaceConfig,
longitude: number,
latitude: number,
exponent: number
): Point3 => {
const sphereX = Math.cos(latitude) * Math.sin(longitude)
const sphereY = Math.sin(latitude)
const sphereZ = Math.cos(latitude) * Math.cos(longitude)
const length = Number.isFinite(exponent)
? (Math.abs(sphereX) ** exponent +
Math.abs(sphereY) ** exponent +
Math.abs(sphereZ) ** exponent) **
(1 / exponent) || 1
: Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
return [
(config.width / 2) * (sphereX / length),
(config.height / 2) * (sphereY / length),
(config.depth / 2) * (sphereZ / length),
]
}
const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
return lpSurface(config, longitude, latitude, diamondExponent(config))
}
const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
lpSurface(config, longitude, latitude, cubeExponent(config))
const MAX_CONE_TIP_FRACTION = 0.24
const MAX_CONE_BASE_FRACTION = 0.2
const MAX_CYLINDER_EDGE_FRACTION = 0.22
type RadialProfile = {
radiusScale: number
verticalProgress: number
}
const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
const morphProfileToEllipsoid = (
config: SurfaceConfig,
progress: number,
profile: RadialProfile
): RadialProfile => {
const amount = morphProgress(config)
const clampedProgress = Math.max(0, Math.min(1, progress))
const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
return {
radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
verticalProgress:
profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
}
}
const cubic = (
start: number,
firstControl: number,
secondControl: number,
end: number,
progress: number
) => {
const inverse = 1 - progress
return (
inverse ** 3 * start +
3 * inverse * inverse * progress * firstControl +
3 * inverse * progress * progress * secondControl +
progress ** 3 * end
)
}
const coneRounding = (config: SurfaceConfig) => ({
tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
})
/** Cylinder half-profile with a quarter-round transition at both caps. */
const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
const clampedProgress = Math.max(0, Math.min(1, progress))
const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
if (edgeFraction <= 0) {
return {
radiusScale: 1,
verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
}
}
if (clampedProgress < edgeFraction) {
const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
return {
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
}
}
if (clampedProgress > 1 - edgeFraction) {
const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
return {
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
}
}
const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
return {
radiusScale: 1,
verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
}
}
const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
const radiusScaleAtVerticalProgress = (
config: SurfaceConfig,
verticalProgress: number,
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
) => {
const progress = Math.max(0, Math.min(1, verticalProgress))
let lower = 0
let upper = 1
for (let iteration = 0; iteration < 14; iteration += 1) {
const candidate = (lower + upper) / 2
if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
else upper = candidate
}
return profileAt(config, (lower + upper) / 2).radiusScale
}
/** Rounded half-profile revolved around the cone's vertical axis. */
const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
const clampedProgress = Math.max(0, Math.min(1, progress))
const { tipFraction, baseFraction } = coneRounding(config)
if (baseFraction > 0 && clampedProgress < baseFraction) {
const curveProgress = clampedProgress / baseFraction
return {
radiusScale: cubic(
1 - baseFraction,
1,
1 - baseFraction / 2,
1 - baseFraction,
curveProgress
),
verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
}
}
if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
return {
radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
}
}
return {
radiusScale: 1 - clampedProgress,
verticalProgress: clampedProgress,
}
}
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
export const cursorLayout = (config: SurfaceConfig) => {
const coneHeight = config.height * 0.36
const bodyHeight = config.height - coneHeight
return {
coneApexY: -config.height / 2,
coneBaseY: -config.height / 2 + coneHeight,
bodyHeight,
bodyCenterY: config.height / 2 - bodyHeight / 2,
bodyWidth: config.width * 0.54,
bodyDepth: config.depth * 0.62,
}
}
export const surfacePointAt = (
config: SurfaceConfig,
longitude: number,
latitude: number
): Point3 => {
const { width, height, depth } = config
switch (config.type) {
case 'sphere':
case 'mickey':
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
case 'cube':
return cube(config, longitude, latitude)
case 'cylinder': {
const progress = (latitude + Math.PI / 2) / Math.PI
const profile = morphedCylinderProfileAt(config, progress)
return [
(width / 2) * profile.radiusScale * Math.sin(longitude),
-height / 2 + height * profile.verticalProgress,
(depth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
case 'cursor': {
const layout = cursorLayout(config)
const progress = (latitude + Math.PI / 2) / Math.PI
const bodyConfig = {
...config,
width: layout.bodyWidth,
height: layout.bodyHeight,
depth: layout.bodyDepth,
}
const profile = cylinderProfileAt(bodyConfig, progress)
return [
(layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
(layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
case 'diamond':
return diamond(config, longitude, latitude)
case 'capsule':
return capsule(config, longitude, latitude)
case 'cone': {
const progress = (latitude + Math.PI / 2) / Math.PI
const profile = morphedConeProfileAt(config, progress)
return [
(width / 2) * profile.radiusScale * Math.sin(longitude),
height / 2 - height * profile.verticalProgress,
(depth / 2) * profile.radiusScale * Math.cos(longitude),
]
}
}
}
const subtract = (left: Point3, right: Point3): Point3 => [
left[0] - right[0],
left[1] - right[1],
left[2] - right[2],
]
const normalize = ([x, y, z]: Point3): Point3 => {
const length = Math.hypot(x, y, z) || 1
return [x / length, y / length, z / length]
}
const normalFromTangents = (
config: SurfaceConfig,
longitudeTangent: Point3,
latitudeTangent: Point3
) => {
const orientation = config.type === 'cone' ? -1 : 1
return normalize([
orientation *
(longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
orientation *
(longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
orientation *
(longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
])
}
const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
const epsilon = 0.0005
if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
const latitudeBefore = surfacePointAt(
config,
longitude,
Math.max(-Math.PI / 2, latitude - epsilon)
)
const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
return normalFromTangents(
config,
subtract(longitudeAfter, longitudeBefore),
subtract(latitudeAfter, latitudeBefore)
)
}
const signedMagnitude = (value: number, exponent: number) =>
Math.sign(value) * Math.abs(value) ** exponent
const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
return normalize([
signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
])
}
const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
lpNormal(config, point, diamondExponent(config))
const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
const exponent = cubeExponent(config)
if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
const normalized = [
point[0] / (config.width / 2 || 1),
point[1] / (config.height / 2 || 1),
point[2] / (config.depth / 2 || 1),
] as Point3
const dominantAxis = normalized.reduce(
(largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
0
)
const normal: Point3 = [
dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
]
return normal
}
const lpFrontSample = (
config: SurfaceConfig,
x: number,
y: number,
exponent: number,
normalAt: (config: SurfaceConfig, point: Point3) => Point3
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
if (!Number.isFinite(exponent)) {
const point: Point3 = [
Math.max(-radiusX, Math.min(radiusX, x)),
Math.max(-radiusY, Math.min(radiusY, y)),
radiusZ,
]
return { point, normal: normalAt(config, point) }
}
const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
const normalizedX = surfaceX / radiusX
const normalizedZ =
Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
(1 / exponent)
const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
return { point, normal: normalAt(config, point) }
}
const ellipsoidFrontSample = (
x: number,
y: number,
radiusX: number,
radiusY: number,
radiusZ: number,
centerY = 0
): SurfaceSample => {
const localY = y - centerY
const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
const z = radiusZ * Math.sqrt(remaining)
return {
point: [x, y, z],
normal: normalize([
x / (radiusX * radiusX || 1),
localY / (radiusY * radiusY || 1),
z / (radiusZ * radiusZ || 1),
]),
}
}
const radialProfileFrontSample = (
config: SurfaceConfig,
x: number,
y: number,
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
verticalDirection: -1 | 1
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusZ = config.depth / 2 || 1
const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
const sectionRadiusX = radiusX * radialScale
const sectionRadiusZ = radiusZ * radialScale
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
const z = sectionRadiusZ * Math.sqrt(remaining)
const derivativeStep = 0.0001
const previousProgress = Math.max(0, verticalProgress - derivativeStep)
const nextProgress = Math.min(1, verticalProgress + derivativeStep)
const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
const depthRatio = radiusZ / radiusX
const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
const depthYDerivative =
(verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
return {
point: [surfaceX, y, z],
normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
}
}
/** Project canonical face coordinates onto a primitive's front-facing sheet. */
export const surfaceFrontSampleAt = (
config: SurfaceConfig,
x: number,
y: number
): SurfaceSample => {
const radiusX = config.width / 2 || 1
const radiusY = config.height / 2 || 1
const radiusZ = config.depth / 2 || 1
switch (config.type) {
case 'sphere':
case 'mickey':
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
case 'cube':
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
case 'capsule': {
const capRadiusY = Math.min(radiusX, radiusY)
const straightHalf = Math.max(0, radiusY - capRadiusY)
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
}
case 'cylinder':
return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
case 'cursor': {
const layout = cursorLayout(config)
const bodyConfig = {
...config,
width: layout.bodyWidth,
height: layout.bodyHeight,
depth: layout.bodyDepth,
}
const sample = radialProfileFrontSample(
bodyConfig,
x,
y - layout.bodyCenterY,
cylinderProfileAt,
1
)
return {
point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
normal: sample.normal,
}
}
case 'cone':
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
case 'diamond':
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
}
}
export const surfaceNormalAt = (
config: SurfaceConfig,
longitude: number,
latitude: number
): Point3 => {
const point = surfacePointAt(config, longitude, latitude)
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
// common path for the default spherical head.
if (config.type === 'sphere' || config.type === 'mickey') {
const halfWidth = config.width / 2 || 1
const halfHeight = config.height / 2 || 1
const halfDepth = config.depth / 2 || 1
return normalize([
point[0] / (halfWidth * halfWidth),
point[1] / (halfHeight * halfHeight),
point[2] / (halfDepth * halfDepth),
])
}
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
return normalize([
Math.sin(longitude) / (config.width / 2 || 1),
0,
Math.cos(longitude) / (config.depth / 2 || 1),
])
}
if (config.type === 'diamond') {
return diamondNormal(config, point)
}
if (config.type === 'cube') {
return cubeNormal(config, point)
}
return tangentNormalAt(config, longitude, latitude)
}
export const surfaceSampleAt = (
config: SurfaceConfig,
longitude: number,
latitude: number
): SurfaceSample => {
const point = surfacePointAt(config, longitude, latitude)
if (config.type === 'sphere' || config.type === 'mickey') {
const halfWidth = config.width / 2 || 1
const halfHeight = config.height / 2 || 1
const halfDepth = config.depth / 2 || 1
return {
point,
normal: normalize([
point[0] / (halfWidth * halfWidth),
point[1] / (halfHeight * halfHeight),
point[2] / (halfDepth * halfDepth),
]),
}
}
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
return {
point,
normal: normalize([
Math.sin(longitude) / (config.width / 2 || 1),
0,
Math.cos(longitude) / (config.depth / 2 || 1),
]),
}
}
if (config.type === 'diamond') {
return {
point,
normal: diamondNormal(config, point),
}
}
if (config.type === 'cube') {
return {
point,
normal: cubeNormal(config, point),
}
}
return {
point,
normal: tangentNormalAt(config, longitude, latitude),
}
}
export * from '@bible-strong/avatar-core'

View File

@ -29,6 +29,9 @@ describe('avatar export', () => {
expect(payload).not.toHaveProperty('frames')
expect(payload.avatar.name).toBe('Strobi')
expect(payload.avatar.renderStyle).toEqual({ type: 'vector' })
expect(Object.values(payload.expressions).every(expression => !expression.semanticKey)).toBe(
true
)
})
it('preserves pixel rendering in standalone exports', () => {

View File

@ -58,9 +58,12 @@ export const createAvatarExportPayload = (
const exportedExpressions = Object.fromEntries(
[...referencedIds].flatMap(expressionId => {
const expression = expressionById.get(expressionId)
return expression
? [[expressionId, applyAvatarEyeDefaults(expression, avatar.eyes)] as const]
: []
if (!expression) return []
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
expression,
avatar.eyes
)
return [[expressionId, legacyExpression] as const]
})
)
const usedKeys = new Set<string>()

File diff suppressed because one or more lines are too long

View File

@ -1,9 +1,12 @@
import { createAvatar } from '@/features/avatar/avatars'
import { createInitialSequences } from '@/features/animation/sequences'
import { initialExpressions } from '@/features/avatar/presets'
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
import {
clearPersistedStudioDocument,
createStudioDocumentStore,
loadStudioDocument,
parseStudioDocument,
parseImportedStudioDocument,
serializeStudioDocument,
type StudioDocument,
@ -34,15 +37,71 @@ describe('Studio document', () => {
expect(document.library.avatars[0].name).toBe('Strobi')
expect(document.expressions).toHaveLength(27)
expect(document.sequences).toHaveLength(23)
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
})
it('clears only the persisted Studio project', () => {
const removeItem = vi.fn()
expect(clearPersistedStudioDocument({ removeItem })).toBe(true)
expect(removeItem).toHaveBeenCalledExactlyOnceWith('bible-strong-avatar-studio-v2')
})
it('keeps a locally saved project authoritative over the bundled snapshot', () => {
const localDocument = documentFixture()
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
})
it('restores bundled semantic keys in a legacy local project', () => {
const fallback = loadStudioDocument(storage())
const legacy = structuredClone(fallback)
legacy.expressions.forEach(expression => delete expression.semanticKey)
legacy.sequences.forEach(sequence => delete sequence.semanticKey)
const document = parseStudioDocument(legacy, fallback)
expect(document.expressions.map(expression => expression.semanticKey)).toEqual(
fallback.expressions.map(expression => expression.semanticKey)
)
expect(document.sequences.map(sequence => sequence.semanticKey)).toEqual(
fallback.sequences.map(sequence => sequence.semanticKey)
)
expect(
createAvatarDefinition({
avatar: document.library.avatars[0],
behavior: { expressions: document.expressions, sequences: document.sequences },
}).ok
).toBe(true)
})
it('restores bundled keys in legacy avatar-owned behavior without naming custom items', () => {
const fallback = loadStudioDocument(storage())
const legacy = structuredClone(fallback)
const avatar = legacy.library.avatars[0]
avatar.behavior = {
expressions: legacy.expressions.map(expression => ({
...expression,
semanticKey: undefined,
})),
sequences: legacy.sequences.map(sequence => ({ ...sequence, semanticKey: undefined })),
}
avatar.behavior.expressions.push({
...avatar.behavior.expressions[0],
id: 'expression-custom',
semanticKey: undefined,
})
const document = parseStudioDocument(legacy, fallback)
const behavior = document.library.avatars[0].behavior!
expect(behavior.expressions[0].semanticKey).toBe(fallback.expressions[0].semanticKey)
expect(behavior.sequences[0].semanticKey).toBe(fallback.sequences[0].semanticKey)
expect(behavior.expressions.at(-1)?.semanticKey).toBeUndefined()
})
it('persists one coherent document after a mutation', () => {
const persisted: StudioDocument[] = []
const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
@ -71,9 +130,10 @@ describe('Studio document', () => {
it('round-trips a complete project document as portable JSON', () => {
const document = documentFixture()
const expression = { ...initialExpressions[0], widthLeft: 42 }
const expression = { ...initialExpressions[0], semanticKey: 'happy-smile', widthLeft: 42 }
const sequence = {
...createInitialSequences()[0],
semanticKey: 'happy',
steps: createInitialSequences()[0].steps.map(step => ({
...step,
expressionId: expression.id,
@ -88,6 +148,26 @@ describe('Studio document', () => {
expect(imported).toEqual(document)
expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
expect(imported.expressions[0].semanticKey).toBe(initialExpressions[0].semanticKey)
expect(imported.library.avatars[0].behavior?.expressions[0].semanticKey).toBe('happy-smile')
expect(imported.library.avatars[0].behavior?.sequences[0].semanticKey).toBe('happy')
})
it('preserves semantic keys in the base behavior parser', () => {
const document = documentFixture()
document.expressions = document.expressions.map((expression, index) => ({
...expression,
...(index === 0 ? { semanticKey: 'attentive' } : {}),
}))
document.sequences = document.sequences.map((sequence, index) => ({
...sequence,
...(index === 0 ? { semanticKey: 'sleeping' } : {}),
}))
const imported = parseImportedStudioDocument(serializeStudioDocument(document), document)
expect(imported.expressions[0].semanticKey).toBe('attentive')
expect(imported.sequences[0].semanticKey).toBe('sleeping')
})
it('keeps the base library unchanged when an avatar owns customized behavior', () => {

View File

@ -1,5 +1,8 @@
import {
ArrowLeft,
CircleCheck,
ChevronDown,
ChevronUp,
Copy,
Download,
FileCode2,
@ -10,10 +13,16 @@ import {
RotateCcw,
Smile,
Trash2,
TriangleAlert,
Upload,
} from 'lucide-react'
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
import { AnimatePresence, motion } from 'motion/react'
import { type CSSProperties, useRef, useState } from 'react'
import {
Avatar as RuntimeAvatar,
type AvatarController as RuntimeAvatarController,
} from '@bible-strong/avatar-react'
import { Accordion } from '@/components/ui/accordion'
import { Badge } from '@/components/ui/badge'
@ -65,11 +74,51 @@ import {
import { defaultExpression } from '@/features/avatar/presets'
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
import { AvatarDrawer } from '@/features/studio/components/AvatarDrawer'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
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 }) {
const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false)
const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false)
const runtimeExampleRef = useRef<RuntimeAvatarController>(null)
const {
activateAvatar,
activeAvatar,
@ -96,9 +145,12 @@ export function StudioInspector({ controller }: { controller: StudioController }
commitAvatarMove,
commitExpressionMove,
commitStateMove,
clearLocalStudioDocument,
copyAvatarRuntimeDefinition,
createNewAvatar,
deleteSelectedBodyNode,
downloadAvatarExport,
downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
@ -117,6 +169,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
exportFormat,
expression,
expressionById,
expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
@ -141,6 +194,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
renameActiveAvatar,
renderedColors,
renderedScene,
runtimeDefinitionResult,
runtimeCopyStatus,
runtimeExportErrors,
runtimeStandardAvailability,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@ -151,6 +208,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
selectedSequenceStepId,
selectedState,
sequenceEditing,
animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
@ -269,9 +327,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
duration: reduceMotion ? 0 : undefined,
})
}
return (
<Drawer>
<Drawer open={avatarDrawerOpen} onOpenChange={setAvatarDrawerOpen}>
<main
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`}
>
@ -319,6 +376,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
onSave={saveSequenceEditing}
onDuplicate={duplicateSequenceEditing}
onDelete={() => setDeleteSequenceOpen(true)}
semanticKeyError={animationSemanticKeyError(sequenceEditing.draft)}
/>
</motion.div>
)}
@ -339,6 +397,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
onSave={saveEditing}
onDuplicate={() => duplicateExpression(editing.index, editing.draft, true)}
onDelete={() => setDeleteExpressionOpen(true)}
semanticKeyError={expressionSemanticKeyError(editing.draft)}
/>
</motion.div>
)}
@ -389,14 +448,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
>
{!editorPageOpen && (
<header className="mode-page-header">
<div>
<p className="eyebrow">{activeAvatar.name}</p>
<h1>
{t(
mode === 'manual'
? 'Pose'
: mode === 'avatars'
? 'Avatars'
: mode === 'expressions'
? 'Expressions'
: mode === 'states'
@ -404,18 +460,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
: 'Exporter'
)}
</h1>
</div>
{mode === 'manual' && (
<Button
variant="outline"
size="icon"
type="button"
aria-label={t('Réinitialiser')}
onClick={() => transitionToExpression({ ...defaultExpression })}
>
<RotateCcw />
</Button>
)}
</header>
)}
@ -1350,13 +1394,20 @@ export function StudioInspector({ controller }: { controller: StudioController }
aria-label={t('Afficher le maillage')}
/>
</div>
<Button
className="reset"
variant="outline"
type="button"
onClick={() => transitionToExpression({ ...defaultExpression })}
>
{t('Réinitialiser')}
</Button>
</InspectorCard>
</ControlSection>
</>
)}
</div>
)}
{!editorPageOpen && mode === 'avatars' && <AvatarPage controller={controller} />}
{!sequenceEditing && !editing && bodyEditing && (
<footer className="workspace-footer">
@ -1422,6 +1473,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
openExpressionEditor(index, preset)
setDeleteExpressionOpen(true)
}}
runtimeError={expressionSemanticKeyError(preset)}
draggable
onDragStart={event => {
expressionDragOrigin.current = expressions
@ -1563,6 +1615,16 @@ export function StudioInspector({ controller }: { controller: StudioController }
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
{animationSemanticKeyError(sequence) && (
<i
className="runtime-key-missing"
role="img"
aria-label={animationSemanticKeyError(sequence) ?? undefined}
title={animationSemanticKeyError(sequence) ?? undefined}
>
!
</i>
)}
<span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span>
</Button>
)
@ -1630,11 +1692,168 @@ export function StudioInspector({ controller }: { controller: StudioController }
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
<Accordion className="export-panel" defaultValue={['snapshot']}>
<Accordion className="export-panel" defaultValue={['runtime']}>
<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}
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=&quot;idle&quot;</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
value="avatar"
title="Exporter l’avatar"
subtitle="Télécharge un composant autonome avec les animations de ton choix."
subtitle="Génère l’export ZIP autonome React ou JavaScript qui existait déjà."
>
<InspectorCard>
<div className="export-avatar-summary">
@ -1924,44 +2143,79 @@ export function StudioInspector({ controller }: { controller: StudioController }
{activeSequence && !editorPageOpen && (
<motion.footer
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
style={{ y: playbackFooterY }}
initial={{ opacity: 0, y: 18 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="state-playback-drag-handle-slot">
<motion.div style={{ y: playbackHandleCounterY }}>
<motion.button
className="state-playback-drag-handle"
style={{ y: playbackHandleY }}
<Button
className="state-playback-expand"
variant="outline"
size="icon-sm"
type="button"
drag="y"
dragMomentum={false}
aria-expanded={statePlayerExpanded}
aria-label={t(
statePlayerExpanded
? 'Masquer les détails de l’animation'
: 'Afficher les détails de l’animation'
)}
onTap={() => snapPlaybackFooter(!statePlayerExpanded)}
onDragStart={() => {
playbackFooterDragOriginY.current = playbackFooterY.get()
}}
onDrag={(_, info) => {
playbackFooterY.set(
Math.min(
playbackFooterCollapsedOffset,
Math.max(0, playbackFooterDragOriginY.current + info.offset.y)
)
)
}}
onDragEnd={(_, info) => {
playbackHandleY.set(0)
const projectedY = playbackFooterY.get() + info.velocity.y * 0.16
snapPlaybackFooter(projectedY < playbackFooterCollapsedOffset / 2)
}}
onClick={() => setStatePlayerExpanded(expanded => !expanded)}
>
<span />
</motion.button>
</motion.div>
{statePlayerExpanded ? <ChevronDown /> : <ChevronUp />}
</Button>
{statePlayerExpanded && (
<motion.div
className="state-playback-details"
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
>
<div className="state-playback-details-header">
<div>
<p className="eyebrow">{t('Détails de l’animation')}</p>
<h2>{activeSequenceLabel}</h2>
<p>
{activeSequence.builtIn
? t(activeSequence.description)
: activeSequence.description}
</p>
</div>
<Badge variant="secondary">
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
</Badge>
</div>
<div className="state-playback-detail-grid">
<div>
<span>{t('Expressions')}</span>
<strong>{activeSequence.steps.length}</strong>
<small>
{activeSequence.steps
.map(step => formatSeconds(step.holdMs, language))
.join(' · ')}
</small>
</div>
<div>
<span>{t('Premier clignement')}</span>
<strong>
{activeSequence.blink.enabled
? formatSeconds(activeSequence.blink.initialDelayMs, language)
: t('Désactivé')}
</strong>
<small>{t('après le lancement')}</small>
</div>
<div>
<span>{t('Intervalle du clignement')}</span>
<strong>
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
</strong>
<small>{t('tirage aléatoire')}</small>
</div>
<div>
<span>{t('Durée du clignement')}</span>
<strong>{activeSequence.blink.durationMs} ms</strong>
<small>{t('fermeture et ouverture')}</small>
</div>
</div>
</motion.div>
)}
<div className="state-playback-bar">
<div className="state-playback-timeline">
{activeSequence.steps.map((step, position) => {
@ -2012,62 +2266,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
/>
</div>
</div>
<motion.div
ref={playbackDetailsRef}
className="state-playback-details-shell"
style={{ opacity: playbackDetailsOpacity }}
aria-hidden={!statePlayerExpanded}
>
<div className="state-playback-details">
<div className="state-playback-details-header">
<div>
<p className="eyebrow">{t('Détails de l’animation')}</p>
<h2>{activeSequenceLabel}</h2>
<p>
{activeSequence.builtIn
? t(activeSequence.description)
: activeSequence.description}
</p>
</div>
<Badge variant="secondary">
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
</Badge>
</div>
<div className="state-playback-detail-grid">
<div>
<span>{t('Expressions')}</span>
<strong>{activeSequence.steps.length}</strong>
<small>
{activeSequence.steps
.map(step => formatSeconds(step.holdMs, language))
.join(' · ')}
</small>
</div>
<div>
<span>{t('Premier clignement')}</span>
<strong>
{activeSequence.blink.enabled
? formatSeconds(activeSequence.blink.initialDelayMs, language)
: t('Désactivé')}
</strong>
<small>{t('après le lancement')}</small>
</div>
<div>
<span>{t('Intervalle du clignement')}</span>
<strong>
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
</strong>
<small>{t('tirage aléatoire')}</small>
</div>
<div>
<span>{t('Durée du clignement')}</span>
<strong>{activeSequence.blink.durationMs} ms</strong>
<small>{t('fermeture et ouverture')}</small>
</div>
</div>
</div>
</motion.div>
</motion.footer>
)}
{!editorPageOpen && (
@ -2076,9 +2274,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
className="mobile-mode-tab mobile-avatar-tab"
variant="ghost"
type="button"
aria-pressed={mode === 'avatars'}
aria-pressed={avatarDrawerOpen}
aria-label={t('Choisir un avatar')}
onClick={() => setMode('avatars')}
onClick={() => setAvatarDrawerOpen(true)}
>
<ExpressionPreview
expression={expressions[0] ?? defaultExpression}
@ -2113,6 +2311,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
))}
</nav>
)}
<AvatarDrawer controller={controller} onOpenChange={setAvatarDrawerOpen} />
</main>
</Drawer>
)

View File

@ -686,6 +686,7 @@
"expressions": [
{
"id": "expression-00",
"semanticKey": "upward-side-glance",
"headX": 7.3,
"headY": 27.8,
"headZ": -16.1,
@ -706,6 +707,7 @@
},
{
"id": "expression-01",
"semanticKey": "downward-gaze",
"headX": -15.057812500000004,
"headY": 0.14296874999999964,
"headZ": -14.549218750000001,
@ -726,6 +728,7 @@
},
{
"id": "expression-05",
"semanticKey": "skeptical-right",
"headX": -16.528515625,
"headY": -3.7679687499999996,
"headZ": -13.7296875,
@ -746,6 +749,7 @@
},
{
"id": "expression-06",
"semanticKey": "small-attentive",
"headX": -4.232421875000001,
"headY": 14.362109375000003,
"headZ": 11.204296875,
@ -766,6 +770,7 @@
},
{
"id": "expression-12",
"semanticKey": "wide-downward-gaze",
"headX": -19.20859375,
"headY": 15.2,
"headZ": 11.8,
@ -786,6 +791,7 @@
},
{
"id": "expression-03",
"semanticKey": "surprised-left",
"headX": 2.9468749999999986,
"headY": -16.051171875,
"headZ": -20.916015625,
@ -806,6 +812,7 @@
},
{
"id": "expression-04",
"semanticKey": "sleepy-squint",
"headX": 3.4,
"headY": 13.22578125,
"headZ": 8.976953125,
@ -826,6 +833,7 @@
},
{
"id": "expression-07",
"semanticKey": "angry-right",
"headX": 8.063671874999999,
"headY": 17.626562500000002,
"headZ": -11.116796874999999,
@ -846,6 +854,7 @@
},
{
"id": "expression-08",
"semanticKey": "curious-left",
"headX": -12.303515625,
"headY": -17.601171875,
"headZ": 5.9109375,
@ -866,6 +875,7 @@
},
{
"id": "expression-09",
"semanticKey": "asymmetric-down-right",
"headX": -20.058203125,
"headY": 12.607421875,
"headZ": -12.7,
@ -886,6 +896,7 @@
},
{
"id": "expression-10",
"semanticKey": "attentive-left",
"headX": 1.43359375,
"headY": 6.194140624999999,
"headZ": 10.56015625,
@ -906,6 +917,7 @@
},
{
"id": "expression-11",
"semanticKey": "joyful-wide",
"headX": -2.092968750000001,
"headY": -15.899609374999999,
"headZ": -14.469921875,
@ -926,6 +938,7 @@
},
{
"id": "expression-13",
"semanticKey": "eyes-closed",
"headX": -8.752343750000001,
"headY": -8.743359375,
"headZ": -10.773828125000001,
@ -946,6 +959,7 @@
},
{
"id": "expression-02",
"semanticKey": "joyful-down-right",
"headX": -15.287109375000002,
"headY": 15.006640625,
"headZ": 12.787890625,
@ -966,6 +980,7 @@
},
{
"id": "expression-14",
"semanticKey": "skeptical-left",
"headX": 3.5292968750000004,
"headY": -7.0765625,
"headZ": 9.830078125,
@ -986,6 +1001,7 @@
},
{
"id": "expression-15",
"semanticKey": "far-right-glance",
"headX": 0.31914062500000184,
"headY": 35.307421874999996,
"headZ": -10.904296875,
@ -1006,6 +1022,7 @@
},
{
"id": "expression-16",
"semanticKey": "angry-left",
"headX": -14.750781250000001,
"headY": -19.350000000000005,
"headZ": 5.631640624999998,
@ -1026,6 +1043,7 @@
},
{
"id": "expression-17",
"semanticKey": "playful-right",
"headX": -4.3953125,
"headY": 14.07265625,
"headZ": -16.126171874999997,
@ -1046,6 +1064,7 @@
},
{
"id": "expression-18",
"semanticKey": "asymmetric-up-left",
"headX": 6.585546875,
"headY": 4.737109375000001,
"headZ": 12.840234374999998,
@ -1066,6 +1085,7 @@
},
{
"id": "expression-19",
"semanticKey": "gentle-downward-gaze",
"headX": -6.077734375000001,
"headY": -11.03515625,
"headZ": -13.965625000000001,
@ -1086,6 +1106,7 @@
},
{
"id": "expression-20",
"semanticKey": "wide-down-left",
"headX": -17.127734375000003,
"headY": 18.070703124999998,
"headZ": 13.891796875,
@ -1106,6 +1127,7 @@
},
{
"id": "expression-21",
"semanticKey": "surprised-wide-left",
"headX": -5.428125,
"headY": -11.71328125,
"headZ": -13.472265625000002,
@ -1126,6 +1148,7 @@
},
{
"id": "expression-22",
"semanticKey": "drowsy-closed",
"headX": 10.292578125,
"headY": 3.39921875,
"headZ": 7.583203125,
@ -1146,6 +1169,7 @@
},
{
"id": "expression-23",
"semanticKey": "suspicious-right",
"headX": -17.8,
"headY": 10,
"headZ": -10.894921875,
@ -1166,6 +1190,7 @@
},
{
"id": "expression-24",
"semanticKey": "shy-downward",
"headX": 7.131640624999998,
"headY": 7.7828124999999995,
"headZ": 3.935546874999999,
@ -1186,6 +1211,7 @@
},
{
"id": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
"semanticKey": "angry-brows",
"headX": 10.473974503042374,
"headY": 5.087293619785961,
"headZ": 4.698252132317348,
@ -1208,6 +1234,7 @@
},
{
"id": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
"semanticKey": "uneasy-left",
"headX": -12.303515625,
"headY": -17.601171875,
"headZ": 5.9109375,
@ -1231,6 +1258,7 @@
"sequences": [
{
"id": "sleeping",
"semanticKey": "sleeping",
"name": "sleeping",
"group": "Cycle de vie",
"description": "Yeux presque fermés, respiration lente et expression de sommeil.",
@ -1269,6 +1297,7 @@
},
{
"id": "waking",
"semanticKey": "waking",
"name": "waking",
"group": "Cycle de vie",
"description": "Séquence courte de réveil avant retour vers une expression neutre.",
@ -1293,6 +1322,7 @@
},
{
"id": "idle",
"semanticKey": "idle",
"name": "idle",
"group": "Cycle de vie",
"description": "Micro-mouvements lents, expressions 00 et 08, clignement rare.",
@ -1324,6 +1354,7 @@
},
{
"id": "listening",
"semanticKey": "listening",
"name": "listening",
"group": "Cycle de vie",
"description": "Expressions 10, 01 et 19, regard stable et clignement attentif.",
@ -1362,6 +1393,7 @@
},
{
"id": "thinking",
"semanticKey": "thinking",
"name": "thinking",
"group": "Cycle de vie",
"description": "Regard haut et latéral, expressions asymétriques et changements fréquents.",
@ -1414,6 +1446,7 @@
},
{
"id": "searching",
"semanticKey": "searching",
"name": "searching",
"group": "Cycle de vie",
"description": "Balayage rapide et changements très fréquents.",
@ -1473,6 +1506,7 @@
},
{
"id": "working",
"semanticKey": "working",
"name": "working",
"group": "Cycle de vie",
"description": "Rythme régulier et expressions concentrées.",
@ -1518,6 +1552,7 @@
},
{
"id": "excited",
"semanticKey": "excited",
"name": "excited",
"group": "Réactions",
"description": "Grandes expressions et transitions rapides.",
@ -1570,6 +1605,7 @@
},
{
"id": "bored",
"semanticKey": "bored",
"name": "bored",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1608,6 +1644,7 @@
},
{
"id": "suspicious",
"semanticKey": "suspicious",
"name": "suspicious",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1646,6 +1683,7 @@
},
{
"id": "angry",
"semanticKey": "angry",
"name": "angry",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1677,6 +1715,7 @@
},
{
"id": "drowsy",
"semanticKey": "drowsy",
"name": "drowsy",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1715,6 +1754,7 @@
},
{
"id": "happy",
"semanticKey": "happy",
"name": "happy",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1760,6 +1800,7 @@
},
{
"id": "curious",
"semanticKey": "curious",
"name": "curious",
"group": "Réactions",
"description": "Inclinaisons et forte asymétrie.",
@ -1805,6 +1846,7 @@
},
{
"id": "confused",
"semanticKey": "confused",
"name": "confused",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1843,6 +1885,7 @@
},
{
"id": "surprised",
"semanticKey": "surprised",
"name": "surprised",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1874,6 +1917,7 @@
},
{
"id": "proud",
"semanticKey": "proud",
"name": "proud",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1912,6 +1956,7 @@
},
{
"id": "shy",
"semanticKey": "shy",
"name": "shy",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1950,6 +1995,7 @@
},
{
"id": "sad",
"semanticKey": "sad",
"name": "sad",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -1988,6 +2034,7 @@
},
{
"id": "laughing",
"semanticKey": "laughing",
"name": "laughing",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -2026,6 +2073,7 @@
},
{
"id": "scared",
"semanticKey": "scared",
"name": "scared",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -2057,6 +2105,7 @@
},
{
"id": "playful",
"semanticKey": "playful",
"name": "playful",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",
@ -2102,6 +2151,7 @@
},
{
"id": "celebrate",
"semanticKey": "celebrate",
"name": "celebrate",
"group": "Réactions",
"description": "Cet état enchaîne un pool de presets et des clignements.",

View File

@ -1,6 +1,7 @@
import {
parseAvatarLibrary,
parseExpressions,
restoreLegacyBehaviorSemanticKeys,
type AvatarBehaviorLibrary,
type AvatarLibrary,
} from '../avatar/avatars'
@ -49,13 +50,16 @@ export const parseStudioDocument = (value: unknown, fallback: StudioDocument): S
const sequences = Array.isArray(candidate.sequences)
? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions)
: fallback.sequences
const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences }
const baseBehavior = restoreLegacyBehaviorSemanticKeys(
{ expressions, sequences },
{ expressions: fallback.expressions, sequences: fallback.sequences }
)
const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior)
return {
version: 2,
library,
expressions,
sequences,
expressions: baseBehavior.expressions,
sequences: baseBehavior.sequences,
playback: parsePlayback(candidate.playback, fallback.playback),
}
}
@ -119,6 +123,17 @@ export const persistStudioDocument = (document: StudioDocument) => {
}
}
export const clearPersistedStudioDocument = (
storage: Pick<Storage, 'removeItem'> = window.localStorage
) => {
try {
storage.removeItem(DOCUMENT_STORAGE_KEY)
return true
} catch {
return false
}
}
export const createStudioDocumentStore = (
initial: StudioDocument,
persist: (document: StudioDocument) => void = persistStudioDocument

View File

@ -45,9 +45,17 @@ import {
applyAmbientBodyMotion,
hasAmbientMotion,
} from '@/features/avatar/ambientMotion'
import {
avatarDefinitionFileName,
createAvatarDefinition,
getSemanticKeyIssue,
getStandardAnimationAvailabilityV1,
type SemanticKeyIssueCode,
} from '@/features/avatar/avatarDefinition'
import {
cloneAvatarBehavior,
createAvatar,
createUnkeyedExpressionCopy,
defaultAvatarEyes,
resolveAvatarBehavior,
type AvatarBehaviorLibrary,
@ -106,6 +114,7 @@ import {
} from '@/features/rendering/renderedScene'
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
import {
clearPersistedStudioDocument,
createStudioDocumentStore,
loadStudioDocument,
parseImportedStudioDocument,
@ -147,6 +156,7 @@ export function useStudioController() {
const [snapshotSize, setSnapshotSize] = useState('1024')
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
const [photoFlash, setPhotoFlash] = useState(0)
const [runtimeCopyStatus, setRuntimeCopyStatus] = useState<'idle' | 'success' | 'error'>('idle')
const initialStatePlayback = initialDocument.playback
const updateStudioLibrary = (library: typeof initialDocument.library) =>
documentStore.update({ library })
@ -1192,7 +1202,7 @@ export function useStudioController() {
}
const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => {
const duplicate = { ...draft, id: createExpressionId() }
const duplicate = createUnkeyedExpressionCopy(draft, createExpressionId())
const next = [...expressions, duplicate]
const duplicateIndex = next.length - 1
setExpressions(next)
@ -1281,7 +1291,8 @@ export function useStudioController() {
setMode('expressions')
setEditing({
index,
draft: { ...draft, id: index === null ? createExpressionId() : draft.id },
draft:
index === null ? createUnkeyedExpressionCopy(draft, createExpressionId()) : { ...draft },
})
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (avatar) setDisplayColors(resolveColors(draft, avatar.colors))
@ -1478,6 +1489,79 @@ export function useStudioController() {
: activeSequence.name
: null
const expressionById = new Map(expressions.map(item => [item.id, item]))
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
t(
issue === 'missing_semantic_key'
? 'Ajoute une clé pour inclure cet élément dans l’export runtime.'
: issue === 'invalid_semantic_key'
? 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.'
: issue === 'reserved_semantic_key'
? 'neutral est réservé à l’apparence neutre de l’avatar.'
: 'Cette clé est déjà utilisée dans cette bibliothèque.'
)
const expressionSemanticKeyError = (draft: Expression) => {
const issue = getSemanticKeyIssue(draft.semanticKey, 'expression')
if (issue) return semanticKeyIssueMessage(issue)
if (
expressions.some(
expression => expression.id !== draft.id && expression.semanticKey === draft.semanticKey
)
) {
return semanticKeyIssueMessage('duplicate_semantic_key')
}
return null
}
const animationSemanticKeyError = (draft: AvatarSequence) => {
const issue = getSemanticKeyIssue(draft.semanticKey, 'animation')
if (issue) return semanticKeyIssueMessage(issue)
if (
sequences.some(
sequence => sequence.id !== draft.id && sequence.semanticKey === draft.semanticKey
)
) {
return semanticKeyIssueMessage('duplicate_semantic_key')
}
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 selectedExportAnimations = sequences.filter(animation =>
exportAnimationIdSet.has(animation.id)
@ -1499,6 +1583,28 @@ export function useStudioController() {
: generateJavaScriptAvatarPackage(payload, language)
downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
}
const downloadAvatarRuntimeDefinition = () => {
if (!runtimeDefinitionResult.ok) return
downloadBlob(
new Blob([JSON.stringify(runtimeDefinitionResult.value, null, 2)], {
type: 'application/json;charset=utf-8',
}),
avatarDefinitionFileName(activeAvatar.name)
)
}
const copyAvatarRuntimeDefinition = async () => {
if (!runtimeDefinitionResult.ok) return
try {
if (!navigator.clipboard) throw new Error('Clipboard API unavailable')
await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
setRuntimeCopyStatus('success')
} catch {
setRuntimeCopyStatus('error')
}
}
const clearLocalStudioDocument = () => {
if (clearPersistedStudioDocument()) window.location.reload()
}
const currentStudioDocument = (): StudioDocument => ({
version: 2,
library: { activeAvatarId, avatars },
@ -1772,7 +1878,9 @@ export function useStudioController() {
commitBodyNode,
commitExpressionMove,
commitStateMove,
clearLocalStudioDocument,
confirmStudioProjectImport,
copyAvatarRuntimeDefinition,
createNewAvatar,
deleteActiveAvatar,
deleteAvatarOpen,
@ -1782,6 +1890,7 @@ export function useStudioController() {
deleteSequenceEditing,
deleteSequenceOpen,
downloadAvatarExport,
downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
@ -1800,6 +1909,7 @@ export function useStudioController() {
exportFormat,
expression,
expressionById,
expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
@ -1832,6 +1942,10 @@ export function useStudioController() {
renderedColors,
renderedRotationGizmo,
renderedScene,
runtimeDefinitionResult,
runtimeCopyStatus,
runtimeExportErrors,
runtimeStandardAvailability,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@ -1843,6 +1957,7 @@ export function useStudioController() {
selectedSequenceStepId,
selectedState,
sequenceEditing,
animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,

View File

@ -31,6 +31,50 @@ describe('avatar studio translations', () => {
expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式')
})
it('keeps runtime-export authoring copy synchronized in all three languages', () => {
expect(translateStudioText('Clé sémantique', 'fr')).toBe('Clé sémantique')
expect(translateStudioText('Clé sémantique', 'en')).toBe('Semantic key')
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', 'zh-CN')).toBe('导出运行时 JSON')
expect(translateStudioText('Nouveau', 'en')).toBe('New')
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', 'zh-CN')).toBe('运行示例')
expect(
translateStudioText(
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.',
'en'
)
).toBe('Export the .avatar.json file used by the new npm packages.')
expect(
translateStudioText(
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.',
'zh-CN'
)
).toBe('生成原有的 React 或 JavaScript 独立 ZIP 导出。')
expect(translateStudioText('Export runtime incomplet', 'en')).toBe(
'Runtime export is incomplete'
)
expect(translateStudioText('Export runtime incomplet', 'zh-CN')).toBe('运行时导出不完整')
expect(translateStudioText('Effacer le projet local et recharger', 'en')).toBe(
'Clear local project and reload'
)
expect(translateStudioText('Effacer le projet local et recharger', 'zh-CN')).toBe(
'清除本地项目并重新加载'
)
expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
expect(translateStudioText('Copier le JSON formaté', 'zh-CN')).toBe('复制格式化的 JSON')
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'en')).toBe(
'Runtime JSON copied to the clipboard.'
)
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'zh-CN')).toBe(
'运行时 JSON 已复制到剪贴板。'
)
})
it('covers every configured state description in English', () => {
expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe(
'Steady rhythm and focused expressions.'

View File

@ -61,6 +61,23 @@ const english: Record<string, string> = {
'Retour au studio': 'Back to studio',
'Preset en mémoire': 'Saved preset',
'Nouvelle expression': 'New expression',
'Identité runtime': 'Runtime identity',
'Nom public stable utilisé par les applications qui chargent cet avatar.':
'Stable public name used by applications that load this avatar.',
'Clé sémantique': 'Semantic key',
'Clé sémantique manquante': 'Missing semantic key',
'Clé publique stable utilisée par l’API runtime, par exemple happy-smile.':
'Stable public key used by the runtime API, for example happy-smile.',
'Clé publique stable utilisée par l’API runtime, par exemple thinking.':
'Stable public key used by the runtime API, for example thinking.',
'Ajoute une clé pour inclure cet élément dans l’export runtime.':
'Add a key to include this item in the runtime export.',
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
'Use lowercase letters, numbers, and hyphens, for example happy-smile.',
'neutral est réservé à l’apparence neutre de l’avatar.':
'neutral is reserved for the avatar neutral appearance.',
'Cette clé est déjà utilisée dans cette bibliothèque.':
'This key is already used in this library.',
'L’avatar à gauche affiche cette expression en direct.':
'The avatar on the left previews this expression live.',
Corps: 'Body',
@ -108,8 +125,40 @@ const english: Record<string, string> = {
Animations: 'Animations',
Exporter: 'Export',
'Exporter l’avatar': 'Export avatar',
'Exporter le JSON runtime': 'Export runtime JSON',
Nouveau: 'New',
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
'Export the .avatar.json file used by the new npm packages.',
'Télécharge la définition portable complète de l’avatar actif.':
'Download the complete portable definition for the active avatar.',
'Définition runtime': 'Runtime definition',
'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',
'Effacer le projet local et recharger': 'Clear local project and reload',
'animations standard disponibles': 'standard animations available',
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
'Copier le JSON formaté': 'Copy formatted JSON',
'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.',
'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',
'Exemple React minimal': 'Minimal React example',
'Lancer l’exemple': 'Run example',
'Masquer l’aperçu': 'Hide preview',
'Aperçu avec le package React': 'Preview using the React package',
'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
Expression: 'Expression',
Animation: 'Animation',
'Une étape référence une expression qui ne peut pas être exportée.':
'A step references an expression that cannot be exported.',
'Valeur incompatible avec le format runtime': 'Value incompatible with the runtime format',
'Télécharge un composant autonome avec les animations de ton choix.':
'Download a standalone component with the animations you choose.',
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
'Generate the existing standalone React or JavaScript ZIP export.',
'Avatar sélectionné': 'Selected avatar',
Format: 'Format',
'Choisis l’intégration correspondant à ton projet.':

View File

@ -34,6 +34,21 @@ export const chinese: Record<string, string> = {
'Retour au studio': '返回工作室',
'Preset en mémoire': '已保存预设',
'Nouvelle expression': '新建表情',
'Identité runtime': '运行时标识',
'Nom public stable utilisé par les applications qui chargent cet avatar.':
'供加载此头像的应用使用的稳定公开名称。',
'Clé sémantique': '语义键',
'Clé sémantique manquante': '缺少语义键',
'Clé publique stable utilisée par l’API runtime, par exemple happy-smile.':
'运行时 API 使用的稳定公开键,例如 happy-smile。',
'Clé publique stable utilisée par l’API runtime, par exemple thinking.':
'运行时 API 使用的稳定公开键,例如 thinking。',
'Ajoute une clé pour inclure cet élément dans l’export runtime.':
'添加键以在运行时导出中包含此项目。',
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
'请使用小写字母、数字和连字符,例如 happy-smile。',
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
Corps: '身体',
Rendu: '渲染',
@ -80,8 +95,39 @@ export const chinese: Record<string, string> = {
Exporter: '导出',
Export: '导出',
'Exporter l’avatar': '导出头像',
'Exporter le JSON runtime': '导出运行时 JSON',
Nouveau: '新增',
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
'导出新 npm 包使用的 .avatar.json 文件。',
'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
'Définition runtime': '运行时定义',
'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
'Prêt pour l’export runtime': '可以导出运行时定义',
'Export runtime incomplet': '运行时导出不完整',
'Effacer le projet local et recharger': '清除本地项目并重新加载',
'animations standard disponibles': '个标准动画可用',
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
'Copier le JSON formaté': '复制格式化的 JSON',
'JSON runtime copié dans le presse-papiers.': '运行时 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: '安装',
'Exemple React minimal': '最简 React 示例',
'Lancer l’exemple': '运行示例',
'Masquer l’aperçu': '隐藏预览',
'Aperçu avec le package React': '使用 React 包预览',
'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
Expression: '表情',
Animation: '动画',
'Une étape référence une expression qui ne peut pas être exportée.':
'某个步骤引用了无法导出的表情。',
'Valeur incompatible avec le format runtime': '值与运行时格式不兼容',
'Télécharge un composant autonome avec les animations de ton choix.':
'下载包含所选动画的独立组件。',
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
'生成原有的 React 或 JavaScript 独立 ZIP 导出。',
'Avatar sélectionné': '已选头像',
Format: '格式',
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',

View File

@ -12,7 +12,10 @@
"module": "ESNext",
"moduleResolution": "Bundler",
"paths": {
"@/*": ["./src/*"]
"@/*": ["./src/*"],
"@bible-strong/avatar-core": ["./packages/avatar-core/src/index.ts"],
"@bible-strong/avatar-core/*": ["./packages/avatar-core/src/*"],
"@bible-strong/avatar-react": ["./packages/avatar-react/src/index.ts"]
},
"resolveJsonModule": true,
"isolatedModules": true,
@ -20,5 +23,5 @@
"jsx": "react-jsx",
"types": ["vite/client", "vitest/globals"]
},
"include": ["src", "vite.config.ts", "vitest.config.ts"]
"include": ["src", "packages/*/src", "vite.config.ts", "vitest.config.ts"]
}

View File

@ -15,10 +15,16 @@ export default defineConfig({
resolve: {
alias: {
'@': path.join(root, 'src'),
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
'@bible-strong/avatar-react': path.join(root, 'packages/avatar-react/src/index.ts'),
},
},
build: {
outDir: path.join(root, 'dist'),
emptyOutDir: true,
chunkSizeWarningLimit: 1_100,
rolldownOptions: {
checks: { pluginTimings: false },
},
},
})

View File

@ -9,11 +9,12 @@ export default defineConfig({
resolve: {
alias: {
'@': path.join(root, 'src'),
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
},
},
test: {
environment: 'node',
globals: true,
include: ['src/**/__tests__/**/*-test.ts'],
include: ['src/**/__tests__/**/*-test.{ts,tsx}', 'packages/**/__tests__/**/*-test.{ts,tsx}'],
},
})