diff --git a/.changeset/README.md b/.changeset/README.md
new file mode 100644
index 0000000..f07625b
--- /dev/null
+++ b/.changeset/README.md
@@ -0,0 +1,11 @@
+# Changesets
+
+Every user-visible package change must include a changeset created with `pnpm changeset`.
+
+The three runtime packages use one fixed version while the public API stabilizes. Select:
+
+- `patch` for compatible fixes and documentation corrections;
+- `minor` for compatible features and for breaking changes while the version is below `1.0.0`;
+- `major` only after the packages have reached `1.0.0`.
+
+Merging the generated release pull request publishes the versions recorded in that pull request.
diff --git a/.changeset/config.json b/.changeset/config.json
new file mode 100644
index 0000000..d9b3c41
--- /dev/null
+++ b/.changeset/config.json
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
+ "changelog": "@changesets/cli/changelog",
+ "commit": false,
+ "fixed": [
+ ["@bible-strong/avatar-core", "@bible-strong/avatar-react", "@bible-strong/avatar-web"]
+ ],
+ "linked": [],
+ "access": "public",
+ "baseBranch": "main",
+ "updateInternalDependencies": "patch",
+ "ignore": []
+}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
new file mode 100644
index 0000000..6c7bf0e
--- /dev/null
+++ b/.github/workflows/release.yml
@@ -0,0 +1,50 @@
+name: Release
+
+on:
+ push:
+ branches:
+ - main
+
+permissions:
+ contents: write
+ pull-requests: write
+ id-token: write
+
+concurrency:
+ group: release
+ cancel-in-progress: false
+
+jobs:
+ release:
+ name: Version or publish packages
+ runs-on: ubuntu-latest
+ timeout-minutes: 20
+
+ steps:
+ - name: Check out repository
+ uses: actions/checkout@v6
+
+ - name: Set up Node.js
+ uses: actions/setup-node@v6
+ with:
+ node-version: '24.15.0'
+ registry-url: 'https://registry.npmjs.org'
+
+ - name: Install pnpm
+ run: npm install --global pnpm@10.34.5
+
+ - name: Install dependencies
+ run: pnpm install --frozen-lockfile
+
+ - name: Validate project
+ run: pnpm check
+
+ - name: Create release pull request or publish
+ uses: changesets/action@v1
+ with:
+ publish: pnpm release
+ version: pnpm version-packages
+ title: 'chore(release): publish runtime packages'
+ commit: 'chore(release): publish runtime packages'
+ env:
+ GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
diff --git a/README.md b/README.md
index 4d7eb46..0af1f85 100644
--- a/README.md
+++ b/README.md
@@ -20,7 +20,7 @@ The application runs entirely in the browser. Projects are stored locally and ca
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
- Preview, play, pause, and stop animations inside the Studio.
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
-- Export a standalone React package or a framework-free JavaScript/HTML package.
+- Export one portable `.avatar.json` definition for React or framework-free JavaScript/ESM.
- Export and import the complete Studio project as JSON.
- Use the interface in English, French, or Simplified Chinese.
@@ -45,13 +45,21 @@ This copy-on-write model lets multiple avatars share the defaults without accide
## Export formats
-### React package
+### Avatar definition
-The React export is a local ZIP package containing a reusable TypeScript/React avatar component and the selected animations. It is intended for integration into React applications without shipping the Avatar Lab interface.
+The selected avatar and animations are stored in one portable `.avatar.json` definition. React and
+JavaScript use this exact same file, so visual behavior does not diverge between renderers.
-### JavaScript package
+### React / TypeScript
-The JavaScript export is a self-contained ZIP project with an ES module, the selected avatar data and animations, and an HTML demo. It can be used without React.
+Install `@bible-strong/avatar-react`, import the JSON and pass it to `createAvatar`. The React
+package depends on `@bible-strong/avatar-core` for validation, playback and geometry.
+
+### JavaScript / ESM
+
+Install `@bible-strong/avatar-web` for a DOM renderer without React. The integration ZIP contains
+the same `.avatar.json`, a lightweight ESM wrapper and usage instructions; it does not copy the
+rendering engine into every avatar export. `avatar-web` also depends on `avatar-core`.
### Photo Mode
diff --git a/TIMELOG.md b/TIMELOG.md
new file mode 100644
index 0000000..7bc02f2
--- /dev/null
+++ b/TIMELOG.md
@@ -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.
diff --git a/docs/PUBLISHING.md b/docs/PUBLISHING.md
new file mode 100644
index 0000000..087a778
--- /dev/null
+++ b/docs/PUBLISHING.md
@@ -0,0 +1,40 @@
+# Publishing the runtime packages
+
+The runtime is published as three public npm packages:
+
+- `@bible-strong/avatar-core` contains the validation, playback and renderer-neutral scene APIs;
+- `@bible-strong/avatar-react` depends on core and provides the React 19 integration;
+- `@bible-strong/avatar-web` depends on core and provides the direct DOM/ESM integration.
+
+All three packages keep the same version during the `0.x` stabilization period. Semantic Versioning
+is applied as follows:
+
+- a compatible fix increments the patch version (`0.1.0` to `0.1.1`);
+- a compatible feature increments the minor version (`0.1.0` to `0.2.0`);
+- a breaking change also increments the minor version while below `1.0.0`;
+- after `1.0.0`, a breaking change increments the major version.
+
+## Normal release flow
+
+1. Add a changeset to every pull request that changes a published API with `pnpm changeset`.
+2. Merge changes into `main`.
+3. The release workflow updates or creates a release pull request containing version and changelog
+ changes.
+4. Review and merge that release pull request.
+5. The workflow validates, builds and publishes every unpublished version to npm.
+
+Publication uses npm trusted publishing through GitHub Actions OIDC. It does not require a stored
+`NPM_TOKEN`. Each npm package must trust the `release.yml` workflow in the
+`smontlouis/bible-strong-avatar-lab` repository.
+
+## Local verification
+
+Run the complete project checks and verify real consumer projects against packed tarballs:
+
+```sh
+pnpm check
+pnpm packages:smoke
+```
+
+Do not run `npm publish` from an individual package for routine releases. The initial `0.1.0`
+bootstrap publication is the only manual release.
diff --git a/docs/specs/20260814-vite-dev-server-performance.md b/docs/specs/20260814-vite-dev-server-performance.md
new file mode 100644
index 0000000..d67b934
--- /dev/null
+++ b/docs/specs/20260814-vite-dev-server-performance.md
@@ -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).
diff --git a/docs/specs/archive/20260814-avatar-runtime-npm-package-and-semantic-api.md b/docs/specs/archive/20260814-avatar-runtime-npm-package-and-semantic-api.md
new file mode 100644
index 0000000..376835a
--- /dev/null
+++ b/docs/specs/archive/20260814-avatar-runtime-npm-package-and-semantic-api.md
@@ -0,0 +1,882 @@
+# Avatar Runtime NPM Package and Semantic API
+
+## Status
+
+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 `
` 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 ` `, 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
+
+Bible Strong Avatar Lab is currently a React Studio for authoring procedural SVG avatars. It stores a complete Studio document in browser local storage, can import/export that document as JSON, and can generate ZIP exports containing a standalone browser runtime.
+
+The next product capability is different from a Studio project export:
+
+- a developer installs a reusable package with `pnpm add`;
+- their application supplies one JSON avatar definition;
+- a React component renders that avatar without the Studio UI;
+- the application controls named expressions and named animations through a public API;
+- the same component can render inside an ordinary `div` or float above a page and be dragged by the user.
+
+The package must not require the consumer to install or run the Studio. It must not depend on Studio local storage, Studio document state, editor selection, or browser-only authoring concerns.
+
+The public API must use semantic names such as `neutral`, `happy-smile`, `idle`, and `thinking`. It must never require callers to know implementation identifiers such as `expression-07`, `avatar-`, or `shape-`.
+
+Avatar JSON is also the future compatibility boundary for copy/paste, file sharing, URL sharing, or a remote avatar registry. Those delivery mechanisms are explicitly out of scope for the first implementation; they must reuse this exact definition later rather than introduce another data format.
+
+## 2. Existing-system constraints
+
+The following constraints are mandatory when implementing this specification.
+
+- Read `CONTEXT.md` and follow the root `AGENTS.md` before changing behavior.
+- Geometry, playback and document operations remain framework-independent.
+- React owns durable UI state; high-frequency rendering and dragging use Motion values or direct transforms, not React state updates for every pointer movement.
+- Do not add `useMemo`, `useCallback`, or `memo`.
+- Keep English, French, and Simplified Chinese Studio copy synchronized in `src/i18n/index.ts` and `src/i18n/zh.ts`.
+- `src/features/export/standaloneEngine.generated.ts` is generated. Do not edit it manually; use `pnpm engine` if its source changes.
+- The current Studio document is pre-release schema version 2. A migration for older pre-release documents is not required, but the bundled `defaultStudioDocument.json`, document parser, and tests must remain coherent.
+- The existing export payload is not the new public schema. It is ZIP-export specific and currently omits a general semantic expression catalog.
+
+## 3. Scope
+
+### In scope
+
+1. A versioned, JSON-serializable `AvatarDefinition` public contract.
+2. Semantic expression and animation names in public exports.
+3. A framework-independent runtime for validation, geometry resolution, and playback.
+4. A React renderer package with embedded and floating layouts.
+5. Pointer/touch dragging, optional keyboard repositioning, constraints, and position callbacks.
+6. Studio authoring fields and export flow for the public definition.
+7. Tests, package build verification, and consumer documentation.
+
+### Explicitly out of scope
+
+1. A remote avatar registry, short opaque IDs, authentication, permissions, or publication workflow.
+2. Encoding avatar JSON into a URL fragment.
+3. A Web Component or native/mobile renderer. The core API must leave these possible later.
+4. Backwards compatibility with speculative prior public package formats. There is no public package yet.
+5. Importing a runtime definition back into a full Studio project. This can be designed after export is stable.
+
+## 4. Terminology
+
+| Term | Meaning |
+| ------------------ | -------------------------------------------------------------------------------------------------------------------- |
+| Avatar definition | The complete portable JSON contract consumed by the runtime. |
+| Expression key | Stable semantic machine name for one static pose, for example `happy-smile`. |
+| Animation key | Stable semantic machine name for a playable sequence, for example `happy`. |
+| Expression | A complete renderable pose: head, eyes, optional colors, perspective, and ambient motion. |
+| Animation | Ordered expression steps plus transition, playback, and blink settings. |
+| Standard animation | A runtime-supplied semantic animation such as `happy` or `thinking`, derived from the exported semantic expressions. |
+| Runtime | Framework-independent code that validates and advances an avatar definition. |
+| Renderer | Code that turns runtime output into SVG/DOM. |
+| Embedded avatar | An avatar laid out inside the supplied host element. |
+| Floating avatar | An avatar positioned relative to the browser viewport. |
+
+`id` is reserved for opaque internal identity only. It must not be required by any public runtime method or be used as a public expression/animation reference.
+
+## 5. Public JSON contract
+
+### 5.1 Contract principles
+
+1. The object must be plain JSON and survive `JSON.stringify` / `JSON.parse` without loss of meaning.
+2. Every expression is self-contained and has absolute eye values. The runtime must not need to reproduce Studio-only relative-eye inheritance to render an expression.
+3. `expressions.neutral` is mandatory. The exporter synthesizes it from the avatar neutral appearance; it is not an editable Studio expression.
+4. Every animation step references an expression key, not an array position or opaque ID.
+5. Shape order is retained because it can affect visual stacking. Secondary shapes do not need public UUIDs or labels.
+6. The definition must include all data required to render and play it. No runtime fetch is performed in v1.
+7. A future schema version is a distinct contract. A v1 runtime must reject unsupported versions with a useful error; it must not guess.
+
+### 5.2 TypeScript reference model
+
+The implementation should export equivalent public types from `@bible-strong/avatar-core`. Exact property names below are normative unless a documented compatibility reason requires a change.
+
+```ts
+export type AvatarDefinition = {
+ schema: 'bible-strong/avatar-definition'
+ schemaVersion: 1
+ name?: string
+ body: AvatarBodyDefinition
+ colors: AvatarColorsDefinition
+ expressions: Record
+ expressionOrder: ExpressionKey[]
+ animations: Record
+ animationOrder: AnimationKey[]
+ standardAnimationSet: 1
+}
+
+export type ExpressionKey = SemanticKey
+export type AnimationKey = SemanticKey
+export type SemanticKey = string
+export type HexColor = `#${string}`
+
+export type AvatarColorsDefinition = {
+ body: HexColor
+ eyes: HexColor
+}
+
+export type AvatarBodyDefinition = {
+ primary: PrimarySurfaceDefinition
+ nodes: AvatarBodyNodeDefinition[]
+}
+
+export type AvatarBodyNodeDefinition = {
+ surface: BodyNodeSurfaceDefinition
+ position: [number, number, number]
+ rotation: [number, number, number]
+}
+
+export type SurfaceType =
+ 'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
+export type BodyNodeSurfaceType = Exclude
+
+export type PrimarySurfaceDefinition = SurfaceDefinition
+export type BodyNodeSurfaceDefinition = SurfaceDefinition
+
+export type SurfaceDefinition = {
+ type: TType
+ width: number
+ height: number
+ depth: number
+ roundness: number
+ morphRoundness?: number
+ tipRoundness?: number
+ baseRoundness?: number
+}
+
+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
+}
+
+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 AvatarAnimationStepDefinition = {
+ expression: ExpressionKey
+ holdMs: number
+ transitionMs: number
+ transition: 'spring' | 'smooth' | 'snappy'
+}
+```
+
+### 5.3 Example
+
+```json
+{
+ "schema": "bible-strong/avatar-definition",
+ "schemaVersion": 1,
+ "name": "Strobi",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 240,
+ "height": 240,
+ "depth": 240,
+ "roundness": 1
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#5b7fe5",
+ "eyes": "#111316"
+ },
+ "expressions": {
+ "neutral": {
+ "head": { "x": 0, "y": 0, "z": 0 },
+ "eyes": {
+ "left": { "width": 20, "height": 50, "x": 0, "y": -7, "angle": 0 },
+ "right": { "width": 20, "height": 50, "x": 0, "y": -7, "angle": 0 },
+ "spacing": 35
+ },
+ "perspective": 1,
+ "motion": { "eyes": "none", "body": "none" }
+ }
+ },
+ "expressionOrder": ["neutral"],
+ "animations": {
+ "idle": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "neutral",
+ "holdMs": 3000,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2600,
+ "minIntervalMs": 3400,
+ "maxIntervalMs": 6200,
+ "durationMs": 280
+ }
+ }
+ },
+ "animationOrder": ["idle"],
+ "standardAnimationSet": 1
+}
+```
+
+### 5.4 Semantic key rules
+
+- Keys match `/^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/`.
+- `neutral` is required in `expressions` and is reserved for the neutral pose.
+- `neutral` is synthesized during export from `defaultExpression` after applying the active avatar's eye defaults. It is reserved and cannot be assigned as an editable Studio semantic key.
+- Expression keys are unique within `expressions`; animation keys are unique within `animations`.
+- An expression and an animation may have the same key (for example `happy`), because they occupy different namespaces. Prefer a more descriptive expression key such as `happy-smile` when an animation uses multiple frames.
+- Public keys are English machine keys. User-facing labels remain localizable Studio copy and must not be used for references.
+- Custom Studio content must have a user-provided semantic key before it can be included in a runtime export. The export UI must show a precise error for missing, malformed, or duplicate keys.
+- On duplicate, import and Studio validation reject the conflict; they must never silently add a numeric suffix.
+- Duplicating an expression or animation clears its `semanticKey`; the user must explicitly name the duplicate before export.
+- `expressionOrder` and `animationOrder` are complete, duplicate-free lists of their respective record keys. Consumers must not derive UI or playback order from object-key enumeration.
+
+### 5.5 Validation and resource limits
+
+The public contract is a committed JSON Schema Draft 2020-12 document. It is the single normative machine-readable source for the v1 data shape and is shipped from the public package. The core validates objects with Ajv configured for Draft 2020-12 and strict schema checking. Do not maintain a second Zod schema in the runtime: it would create two competing sources of truth. Zod may be used in a Studio-only form layer only when that layer is not a second definition validator.
+
+The core exposes two distinct non-mutating APIs:
+
+```ts
+type AvatarDefinitionError = {
+ path: string // RFC 6901 JSON Pointer, for example '/animations/happy/steps/1/expression'
+ code: string
+ message: string
+}
+
+type ValidationResult =
+ { ok: true; value: Readonly } | { ok: false; errors: readonly AvatarDefinitionError[] }
+
+function validateAvatarDefinition(value: unknown): ValidationResult
+function parseAvatarDefinition(text: string): ValidationResult
+```
+
+`validateAvatarDefinition` accepts an already materialized JavaScript value. `parseAvatarDefinition` additionally enforces JSON text limits and rejects duplicate object members before `JSON.parse`-style parsing loses that information. Neither API rounds values, fills missing fields, discards unknown properties, changes color casing, or substitutes defaults. Canonical serialization, if introduced, is a separate explicit function and never validation side effect.
+
+Studio persistence parsing may remain forgiving for local recovery; the public package boundary must reject invalid input rather than silently replacing it with a preset.
+
+Validation must check at least:
+
+- exact supported version;
+- exact `schema: 'bible-strong/avatar-definition'` and `schemaVersion: 1`;
+- no unknown properties at every object level in v1 (`additionalProperties: false`); future extensions require an explicit new schema version rather than ignored data;
+- plain-object shape and no non-finite numbers;
+- six-digit lowercase `#rrggbb` colors;
+- known surface type;
+- strictly positive dimensions;
+- three-item finite position/rotation tuples;
+- a maximum of 16 secondary nodes, 128 expressions, 64 explicit animations, and 128 steps per animation;
+- presence of `expressions.neutral`;
+- valid semantic keys;
+- at least one animation step when an animation is present;
+- every step references an existing expression key;
+- raw input of at most 262,144 UTF-8 bytes and JSON nesting depth of at most 32;
+- semantic keys and metadata `group` values of at most 64 characters, `name` and metadata `label` values of at most 120 characters, and metadata `description` values of at most 512 characters;
+- primary and secondary dimensions in `0.001..10000`; body-node positions in `-10000..10000`; rotations in `-360..360`; perspective in `0.1..10`; and all roundness values in `0..1`;
+- `holdMs` in `100..60000`, `transitionMs` in `0..5000`, and finite bounds for eyes and remaining expression coordinates chosen from the same `-10000..10000` rendering-safe range;
+- `blink.initialDelayMs` in `0..60000`, `blink.minIntervalMs` and `blink.maxIntervalMs` in `250..120000`, `blink.durationMs` in `50..2000`, and `blink.minIntervalMs <= blink.maxIntervalMs`;
+- text/semantic-key lengths, JSON nesting depth, and raw JSON byte length, with all initial limits documented and covered by boundary tests;
+- an actionable JSON Pointer path, for example `/animations/happy/steps/1/expression`.
+
+`parseAvatarDefinition` uses a tokenizing or streaming parser that tracks the current JSON Pointer, rejects the second occurrence of an object member, and applies the byte/depth/string limits before allocating unbounded structures. Ajv validates the resulting value against the JSON Schema. The validator returns a deeply immutable view or typed validation errors. It must not call browser APIs. The renderer cache must preserve provided geometry precision; it must not use a key that aliases definitions by rounding to four decimal places.
+
+### 5.6 Standard animation set
+
+`standardAnimationSet: 1` opts the definition into the version-1 runtime library of semantic animations. The runtime resolves this library from the semantic expressions present in the definition, without adding unexported Studio identifiers to the public format.
+
+- `STANDARD_ANIMATIONS_V1` is a committed, versioned runtime manifest. Before the package can ship, it must define for every standard key its required expression keys, ordered steps, timing, transition, playback mode and blink settings. The initial intent catalogue is `idle`, `happy`, `sad`, `thinking`, `excited`, and `celebrate`; its exact semantic-expression mapping requires explicit product/design curation and must be committed with the manifest. No runtime may infer this mapping from a numeric Studio expression or from visual values.
+- The runtime exposes an availability query that reports both available standard animations and unavailable ones with their missing semantic-expression requirements.
+- An explicit animation in `animations` with the same key overrides the standard animation for that avatar. This is the sole override rule; duplicate alternatives are not exposed under invented names.
+- `animations` may be `{}`. The avatar still renders `neutral` and may play every standard animation whose requirements are satisfied.
+- `play(key)` resolves the overridden or standard animation. If it is unknown or unavailable, it returns the documented typed error; it must not fall back silently to `neutral`.
+- Version 1 has one public animation per intention. Variants such as `happy.subtle` are not part of v1.
+
+## 6. Studio model and export mapping
+
+### 6.1 Internal versus public references
+
+Existing Studio expressions and animation steps use durable internal IDs. Keep those IDs for editor operations, reordering, deletion, and current document integrity. Add an optional durable `semanticKey` to Studio Expressions and Animations; this key is the public export name.
+
+Suggested internal additions:
+
+```ts
+type Expression = {
+ id: string
+ semanticKey?: string
+ // existing rendering fields
+}
+
+type AvatarSequence = {
+ id: string
+ semanticKey?: string
+ // existing playback fields
+}
+```
+
+Internal sequence steps continue to reference `expressionId`. The runtime exporter maps those IDs to expression semantic keys. This preserves current Studio behavior and does not force editor code to use display-facing data as a primary key.
+
+### 6.2 Baseline semantic catalog
+
+The bundled expression catalog is currently numeric. A product owner or designer must curate the semantic keys for every expression that is intended to be exported. Do not fabricate semantic meanings from an expression index.
+
+Implementation steps:
+
+1. Inventory each bundled expression visually.
+2. Assign a clear unique semantic key, such as `look-left`, `sleepy-eyes`, or `happy-smile`. `neutral` is reserved for the synthesized public pose and must not be assigned here.
+3. Assign every bundled animation an existing semantic key. Existing state names such as `idle`, `thinking`, and `happy` are suitable starting keys after validation.
+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 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
+
+Studio stores expressions relative to avatar-level neutral eye values. The runtime schema stores complete absolute expression values. Create a pure conversion function:
+
+```ts
+createAvatarDefinition({
+ avatar,
+ behavior: resolveAvatarBehavior(document, avatar),
+}: {
+ avatar: StudioAvatar
+ behavior: AvatarBehaviorLibrary
+}): ValidationResult
+```
+
+It must:
+
+1. map the primary body and secondary nodes, stripping Studio-only node IDs/names;
+2. map avatar base colors;
+3. synthesize `expressions.neutral` from `defaultExpression` and apply `applyAvatarEyeDefaults` to it and every exported expression;
+4. convert flat Studio expression fields into the structured public expression shape;
+5. retain expression color overrides and ambient motion;
+6. map each animation step from `expressionId` to its expression semantic key;
+7. fail if an included expression/animation has no valid semantic key or an animation references an excluded expression;
+8. generate the explicit standard-animation availability report and the declared catalog order;
+9. validate the finished object with the core validator before returning it.
+
+The `neutral` runtime expression must be a complete resolved representation of the Avatar's neutral visual appearance. It is not sufficient to export `defaultExpression` without applying the avatar eye defaults. This synthesized expression is always first in `expressionOrder`.
+
+### 6.4 Export scope
+
+The runtime JSON export must include all semantic expressions and explicit animations owned by the active avatar behavior library, not only the animations selected for the current ZIP export. Standard animations are supplied by the declared standard-animation set when their requirements are met. An application that installs the package must be able to discover every available key through the runtime API.
+
+The existing React/JavaScript ZIP export can keep its animation selection UI. Refactor it to derive selected subsets from `AvatarDefinition` or a shared normalized intermediate representation, without changing its public generated output unintentionally.
+
+## 7. Package architecture and build
+
+### 7.1 Package layout
+
+Convert the repository to a pnpm workspace. The public package cannot be released from the current single private application package. Required layout:
+
+```text
+packages/
+ avatar-core/
+ avatar-react/
+ avatar-vue/ # adapter planned after React v1
+ avatar-angular/ # adapter planned after React v1
+examples/
+ react-vite-consumer/
+src/ # existing Studio application
+```
+
+Package names:
+
+- `@bible-strong/avatar-core`
+- `@bible-strong/avatar-react`
+- `@bible-strong/avatar-vue`
+- `@bible-strong/avatar-angular`
+
+The Studio remains AGPL-3.0-only. Subject to confirmation by every copyright holder before publication, the runtime packages are released under Apache-2.0, with their own `LICENSE` files and unambiguous package metadata. Do not publish any package before that confirmation and repository ownership/metadata have been verified.
+
+This is deliberately not a façade package. A consumer installs only the adapter for its framework; that adapter brings a compatible `avatar-core` version as its normal dependency. The independent core can also be installed directly for non-UI rendering, validation, servers, or a future framework adapter.
+
+`avatar-react` depends on `avatar-core`. React and React DOM must be peer dependencies of `avatar-react`, compatible with React 19. `avatar-vue` must declare Vue as a peer dependency and `avatar-angular` must declare the Angular packages it imports as peer dependencies. No adapter may bring React, Vue, Angular, or another UI framework into another adapter's dependency graph. The core package must have no React, Vue, Angular, Motion, or DOM dependency.
+
+The initial release scope is `avatar-core` and `avatar-react`. Vue and Angular are planned follow-on adapters, built only after the cross-adapter conformance suite exists. They must expose the same JSON contract, semantic keys, playback rules, availability errors, and embedded/floating behavior; only the host-framework binding may differ.
+
+`avatar-angular` must be built as an Angular library in the Angular Package Format using partial compilation, with a deliberately small public API entry point. The React and Vue adapters must externalize their framework dependencies in library builds. Every public package must declare a narrow `files` allow-list and explicit `exports`; consumers must not import package internals.
+
+Do not move unrelated Studio UI into packages. Extract only reusable domain/runtime code.
+
+### 7.2 Core responsibilities
+
+`@bible-strong/avatar-core` exports:
+
+- public types and validation;
+- conversion helpers needed by package consumers, if safe;
+- expression interpolation, blink timeline, and animation timeline primitives;
+- SVG geometry data generation or a renderer-neutral scene model;
+- colors and ambient motion calculations;
+- semantic catalog lookup helpers;
+- typed errors for unknown expression/animation keys.
+
+Playback is a pure state machine. The core receives a monotonic `now()` clock and an injectable `random()` source; browser adapters alone use `performance.now()` and non-deterministic randomness. It exposes a pure `advance(state, now, environment)` operation so pause/resume, blink timing, and tests are deterministic.
+
+For every sequence step, the normative timeline is `[transitionMs -> holdMs]`: interpolate from the current pose to the target expression for exactly `transitionMs`, then keep the target pose for exactly `holdMs` before the next step. `spring` is a deterministic bounded damped easing over the specified transition duration, not an unbounded physical simulation. `smooth` and `snappy` are likewise bounded easing functions. Pause preserves the exact timeline progress and resumes from that same progress.
+
+The core must expose enough to support a future non-React renderer. It must not own DOM nodes, pointer events, CSS layout, portals, clipboard actions, or browser storage.
+
+### 7.3 React responsibilities
+
+`@bible-strong/avatar-react` exports at minimum:
+
+```ts
+export function Avatar(props: AvatarProps): React.ReactElement
+export type AvatarController = {
+ play(animation: AnimationKey): AvatarCommandResult
+ setExpression(expression: ExpressionKey): AvatarCommandResult
+ pause(): void
+ stop(): void
+ getState(): AvatarPlaybackState
+}
+
+export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
+
+export type AvatarRuntimeError = {
+ code:
+ | 'unknown_animation'
+ | 'unavailable_standard_animation'
+ | 'unknown_expression'
+ | 'controlled_by_props'
+ key: string
+ message: string
+}
+
+export type AvatarPlaybackState = {
+ activeAnimation?: AnimationKey
+ activeExpression: ExpressionKey
+ status: 'playing' | 'paused' | 'stopped'
+}
+```
+
+The component renders semantic SVG paths from core output. It may reuse the project Motion strategy for smooth, high-frequency visual updates, but rendering behavior must match core calculations.
+
+The public component must not expose internal Studio IDs, `AvatarSequence`, `Expression`, or Studio document types.
+
+### 7.4 Distribution requirements
+
+- Build ESM and TypeScript declarations.
+- Configure `exports` in each package `package.json`.
+- Mark source maps appropriately for debugging.
+- Keep peer dependencies external; do not bundle a second React copy.
+- Add a `pnpm pack` smoke test or equivalent that installs the packed artifacts into `examples/react-vite-consumer`.
+- Run `npm pack --dry-run --json` and install actual tarballs into a clean, non-workspace consumer before publishing.
+- Document the package entry points and supported React version.
+
+## 8. React rendering and control API
+
+### 8.1 Component props
+
+```ts
+type AvatarProps = {
+ definition: AvatarDefinition
+ ref?: React.Ref
+ /** Controlled playback target. Mutually exclusive with expression. */
+ animation?: AnimationKey
+ /** Controlled static pose. Mutually exclusive with animation. */
+ expression?: ExpressionKey
+ /** Uncontrolled initial playback target. */
+ defaultAnimation?: AnimationKey
+ /** Uncontrolled initial static pose; defaults to neutral. */
+ defaultExpression?: ExpressionKey
+ autoplay?: boolean
+ size?: number | string
+ className?: string
+ style?: React.CSSProperties
+ mode?: 'embedded' | 'floating'
+ /** Floating mode portal destination; defaults to document.body after hydration. */
+ portalContainer?: HTMLElement
+ draggable?: boolean
+ constrainTo?: 'none' | 'viewport' | 'parent'
+ position?: { x: number; y: number }
+ initialPosition?: FloatingInitialPosition
+ zIndex?: number
+ ariaLabel?: string
+ /** At most once per animation frame while dragging; never makes position controlled. */
+ onPositionPreview?: (position: { x: number; y: number }) => void
+ /** Final committed position after pointer/keyboard movement. */
+ onPositionCommit?: (position: { x: number; y: number }) => void
+ onDragStart?: () => void
+ onDragEnd?: (position: { x: number; y: number }) => void
+ onAnimationEnd?: (animation: AnimationKey) => void
+ onExpressionChange?: (expression: ExpressionKey) => void
+}
+
+type FloatingInitialPosition =
+ { x: number; y: number } | { top?: number; right?: number; bottom?: number; left?: number }
+```
+
+Rules:
+
+- `definition` is required and immutable from the component's perspective. When its reference changes, validate and reinitialize the runtime predictably.
+- `animation` and `expression` are controlled props and mutually exclusive. In development, reject both with a clear error. They always take priority over defaults and imperative commands.
+- When neither controlled prop is supplied, `defaultAnimation` or `defaultExpression` initializes uncontrolled playback; if neither is supplied, render `neutral` without playback. `autoplay={false}` initializes the selected default target as a static pose; `autoplay` defaults to `true` only when `defaultAnimation` is provided.
+- `mode` defaults to `embedded`.
+- `draggable` defaults to `false`.
+- `constrainTo` defaults to `viewport` for floating avatars and `none` for embedded avatars. `parent` is valid only when an appropriate parent element exists.
+- A controlled `position` always wins over `initialPosition`. The component emits rAF-limited `onPositionPreview` while dragging and `onPositionCommit` after commit, but does not hold authoritative position in controlled mode.
+- `size` changes only visual layout; it must not modify the supplied avatar definition.
+
+### 8.2 Controller behavior
+
+- `play(key)` starts or restarts the named animation from its first step.
+- `setExpression(key)` stops animation playback and renders that static expression.
+- `pause()` freezes current animation progress. Calling `play` while paused resumes the current animation only when called with the current key; otherwise it starts the requested key.
+- `stop()` stops playback and returns to `neutral`.
+- An unknown or unavailable key does not fail silently. `play` and `setExpression` return `AvatarCommandResult`; they never throw for expected caller input errors.
+- In controlled mode, `play` and `setExpression` return `{ ok: false, error: { code: 'controlled_by_props', ... } }`; the consumer changes the controlled prop instead.
+- `onAnimationEnd` fires only when a `once` animation completes naturally.
+- `onExpressionChange` fires when the active semantic expression changes, including sequence-step changes and direct `setExpression` calls.
+
+React 19 exposes the controller through the ordinary `ref` prop: `ref?: React.Ref`. The package must not use an incompatible `forwardRef` wrapper.
+
+## 9. Layout and dragging
+
+### 9.1 Embedded mode
+
+Embedded is the default:
+
+```tsx
+
+```
+
+The component participates in normal React layout. It must not use a portal. With `draggable` and `constrainTo="parent"`, movement is constrained to the containing element's content box. Document that the parent needs a definite rendered size; the package may add the minimum positioning style required for dragging but must not unexpectedly alter the parent layout.
+
+### 9.2 Floating mode
+
+Floating mode is page-relative:
+
+```tsx
+
+```
+
+It renders a fixed-position wrapper relative to the viewport. The initial position supports either `{ x, y }` from the top-left viewport origin or one of the documented edge-anchor forms such as `{ right, bottom }`. Normalize initial values once to a pixel `{ x, y }` position.
+
+Floating mode renders through a React portal to `document.body` by default, after hydration. This guarantees viewport-relative positioning even when an ancestor establishes a containing block through `transform`, `filter`, or containment. `portalContainer` opt-in replaces `document.body` for hosts that require a dedicated overlay root.
+
+During server-side rendering, and until client hydration can create the portal, render a fixed-size neutral placeholder in the caller tree. Browser APIs, media queries, pointer listeners, and the portal must be initialized only after mount. SVG definition IDs must use React `useId` so multiple avatars and SSR hydration never collide.
+
+### 9.3 Pointer and keyboard interaction
+
+Implement dragging with Pointer Events:
+
+1. On primary pointer down, record pointer origin and current avatar position.
+2. Call `setPointerCapture(pointerId)` on the draggable wrapper.
+3. Update only transform/Motion values while the pointer moves. Do not call React `setState` on each pixel.
+4. Clamp the proposed position to the selected viewport or parent bounds.
+5. On pointer up/cancel or lost pointer capture, release capture, commit the final position, and invoke callbacks. On cancel, restore the drag origin before reporting it.
+
+While drag is active:
+
+- use `touch-action: none` only on the draggable avatar surface;
+- prevent accidental text selection and image dragging;
+- expose an appropriate pressed visual state without deprecated `aria-grabbed` metadata and without suppressing the avatar animation;
+- do not interfere with keyboard navigation outside the component.
+
+For accessibility, when `draggable` is true the wrapper must be focusable and expose instructions through an accessible label or description. Arrow keys move by 10 px; Shift+Arrow moves by 1 px. Position changes clamp exactly as pointer movement does. Escape cancels an in-progress pointer drag and restores its origin. The package also renders accessible move-left, move-right, move-up, move-down and reset controls when dragging is enabled, so movement does not rely solely on dragging.
+
+### 9.4 Reflow and bounds
+
+- Re-clamp an uncontrolled position when the viewport resizes.
+- Re-clamp embedded parent constraints using `ResizeObserver`.
+- Do not overwrite controlled positions; instead expose the clamped suggested position through `onPositionChange` when a parent resize makes the supplied position invalid.
+- Preserve sub-pixel position internally only if the Motion implementation requires it; public callbacks may return finite pixel numbers.
+
+The avatar definition contains no display position. Consumers decide whether and how to persist `position` in local storage, a profile, or their own backend.
+
+## 10. Studio UX changes
+
+1. Add a semantic-key field to the expression editor and animation editor.
+2. Display a concise validation message for a missing/invalid/duplicate key.
+3. Do not translate the key itself. Translate the field label, help text, and validation errors.
+4. Add an export action named equivalent to `Export avatar runtime JSON` in all three languages.
+5. Export the active avatar's complete effective behavior library, after resolving inherited base behavior.
+6. Export a `.avatar.json` file. Suggested filename: a sanitized avatar name followed by `.avatar.json`.
+7. Add a copy-to-clipboard action only after the file export is verified; it copies formatted JSON and reports success/failure accessibly.
+8. Keep the existing full Studio project import/export unchanged. Its purpose remains authoring backup, not runtime integration.
+9. Keep the existing ZIP export working; refactor only after snapshot and generated-package tests prove no regression.
+
+## 11. Implementation plan
+
+Implement in the following order. Each step must compile and have focused tests before moving to the next.
+
+### Phase A - Contract and pure conversion
+
+1. Add `src/features/avatar/avatarDefinition.ts` as a framework-independent temporary home for the v1 contract, validator, and Studio-to-runtime conversion.
+2. Commit the JSON Schema Draft 2020-12, its Ajv validator, and the bounded duplicate-detecting JSON-text parser; define public types and validation errors exactly as described in section 5.
+3. Write conversion from `StudioAvatar`, its resolved behavior, and current Studio types to `AvatarDefinition`.
+4. Apply avatar eye defaults during conversion and flatten internal fields into the structured public expression shape.
+5. Add unit tests for valid conversion, invalid semantic keys, missing `neutral`, unresolved animation references, colors, body nodes, and non-finite values.
+6. Add semantic-key fields to Studio internal types, parser, default document, and focused tests.
+
+Deliverable: `createAvatarDefinition(...)` returns validated JSON-ready data without importing React or browser APIs.
+
+### Phase B - Semantic Studio authoring
+
+1. Inventory the bundled poses visually, propose their semantic keys and an exact `STANDARD_ANIMATIONS_V1` manifest, then obtain product/design approval before committing it.
+2. Add inputs to the expression and animation editors.
+3. Add per-field validation and export-readiness indication.
+4. Add localized copy in English, French, and Simplified Chinese.
+5. Add a runtime JSON download action for the active avatar.
+6. Add tests proving default data has a valid export and custom missing keys are rejected at export.
+
+Deliverable: a Studio user can produce a complete `.avatar.json` without manually editing JSON.
+
+### Phase C - Extract core package
+
+1. Create the pnpm workspace/package configuration and `packages/avatar-core`.
+2. Move or re-export the framework-independent contract, geometry, ambient motion, and playback primitives without changing their behavior.
+3. Define an adapter between public `AvatarDefinition` expressions and existing rendering geometry structures.
+4. Ensure the core can render a scene and advance playback without DOM/React imports.
+5. Update Studio imports to use the shared core where appropriate; do not create circular dependencies from packages back into `src/`.
+6. Configure ESM build, declarations, exports, and package metadata.
+
+Deliverable: a Node/Vitest test can load a JSON fixture, resolve `idle`, advance it, and generate the same geometry as Studio.
+
+### Phase D - React renderer package
+
+1. Create `packages/avatar-react` with React peer dependencies.
+2. Implement ` `, SVG rendering, palette resolution, clipping, eye visibility, blink rendering, and animation scheduling.
+3. Implement the imperative controller with `forwardRef` or the React 19-compatible ref pattern selected by the codebase.
+4. Implement embedded layout first, then floating layout.
+5. Implement pointer capture, controlled/uncontrolled position, viewport/parent constraints, ResizeObserver, and keyboard movement.
+6. Ensure continuous drag/render updates use transform/opacity and Motion values or equivalent high-frequency primitives.
+7. Document CSS hooks/classes and provide a minimal visual default without forcing an application theme.
+
+Deliverable: a Vite React fixture renders one supplied avatar and can call `play('happy')` and `setExpression('neutral')`.
+
+### Phase E - Integrate existing export and package verification
+
+1. Refactor existing ZIP export to use shared normalized data where practical.
+2. Run `pnpm engine` if standalone-engine source changes, then retain the generated file in the change.
+3. Build both packages.
+4. Pack them and install into the consumer fixture using package artifacts, not workspace shortcuts.
+5. Validate production build and behavior in the fixture.
+6. Add README/API documentation and a sample `strobi.avatar.json`.
+
+Deliverable: an external project can install the packed package and use the documented API without importing Studio source.
+
+### Phase F - Additional framework adapters (after React v1)
+
+1. Define framework-neutral conformance fixtures from the public JSON examples and standard-animation manifest.
+2. Create `packages/avatar-vue`, with Vue as a peer dependency, and prove the same render/playback/drag contract against the fixtures.
+3. Create `packages/avatar-angular` through the Angular library tooling, publish only partial-Ivy output, and keep Angular packages as peer dependencies.
+4. Pack every adapter and install it into a clean consumer for its own framework; never validate an adapter solely through workspace linking.
+
+Deliverable: each supported framework exposes a native component API around the same versioned avatar engine, without duplicating geometry or playback logic.
+
+## 12. Test plan and acceptance criteria
+
+### 12.1 Core contract tests
+
+- A valid example parses and round-trips through `JSON.stringify`/`JSON.parse`.
+- `parseAvatarDefinition` rejects duplicate JSON object members, input over 256 Kio, depth over 32, unknown fields, and every documented numeric/string boundary.
+- Unsupported version fails explicitly.
+- Invalid path-specific errors are returned for malformed keys and dangling expression references.
+- Conversion preserves every supported primary-surface parameter.
+- Conversion preserves all secondary-node surfaces, order, positions, and rotations.
+- Converted expressions use final resolved eye values.
+- Base and per-expression colors render correctly.
+- Animation timing, transitions, playback mode, and blink settings survive conversion.
+
+### 12.2 Rendering and playback tests
+
+- Geometry generated from a converted definition matches the geometry generated from the equivalent Studio avatar/expression.
+- `neutral` is rendered when nothing is playing.
+- `play('idle')` starts its first semantic step.
+- `setExpression('happy-smile')` stops playback and uses that exact expression.
+- `once` triggers exactly one completion callback.
+- unknown semantic names produce the documented error.
+- reduce-motion behavior remains deterministic and documented.
+
+### 12.3 React and interaction tests
+
+- Embedded mode is rendered inside the supplied parent without fixed positioning.
+- Floating mode uses viewport-relative fixed positioning.
+- Floating mode remains viewport-relative when its caller is under a transformed ancestor, and has no hydration warning during its portal handoff.
+- Pointer drag changes position and captures pointer outside the avatar bounds.
+- `constrainTo="viewport"` and `constrainTo="parent"` never place the avatar outside their bounds.
+- Controlled props take priority over imperative commands; uncontrolled defaults and controller commands work predictably.
+- Controlled position reports rAF-limited previews and final commits but does not become internally authoritative.
+- Uncontrolled position is re-clamped after a resize.
+- Keyboard arrows reposition a draggable avatar; Escape cancels active drag.
+- Accessible movement and reset controls work without pointer dragging.
+- Dragging does not cause one React render per pointer move (test through implementation boundary or profiler-friendly instrumentation, not fragile timing assumptions).
+
+### 12.4 Package smoke test
+
+The consumer fixture must:
+
+1. install both built tarballs;
+2. import the public package APIs and a JSON fixture;
+3. typecheck;
+4. build with Vite;
+5. run a browser-level assertion that the SVG is visible, `play('idle')` works, and a draggable floating avatar moves.
+
+### 12.5 Required commands before handoff
+
+Run at minimum:
+
+```bash
+pnpm typecheck
+pnpm test --
+pnpm engine:check
+pnpm check
+```
+
+Run package-specific build/test/pack commands introduced by the implementation as well. Report their exact result in the final handoff.
+
+## 13. Definition of done
+
+This work is complete only when all of the following are true:
+
+1. A Studio user can export one valid `.avatar.json` for the active avatar.
+2. That JSON uses semantic expression and animation keys, with no public opaque references.
+3. The JSON contains all required geometry, colors, expressions, and animations to render without Studio code or data.
+4. An independent React Vite project can install the packages with pnpm and render the JSON.
+5. The independent project can invoke semantic animation/expression controls.
+6. The independent project can use the same component embedded in a `div` or floating over the viewport.
+7. A floating or constrained embedded avatar is draggable by pointer/touch and keyboard, with correct preview/commit callbacks and bounds.
+8. Existing Studio project persistence, snapshots, and ZIP exports still pass their tests.
+9. All typecheck, targeted tests, `pnpm engine:check`, and `pnpm check` pass.
+10. Public API and JSON schema documentation are sufficient for an external developer to integrate without reading Studio source.
+
+## 14. Future extensions
+
+After v1 is stable, the same `AvatarDefinition` can be carried by:
+
+- a copyable Base64URL token;
+- a URL fragment for direct sharing;
+- a remote registry returning a short `avatarId`;
+- a CDN-hosted JSON file;
+- a Web Component or a non-React renderer.
+
+None of these extensions may alter the meaning of an existing v1 definition. They are transport or rendering adapters around the contract defined here.
diff --git a/docs/specs/archive/20260814-avatar-runtime-semantic-curation.md b/docs/specs/archive/20260814-avatar-runtime-semantic-curation.md
new file mode 100644
index 0000000..06bdbcd
--- /dev/null
+++ b/docs/specs/archive/20260814-avatar-runtime-semantic-curation.md
@@ -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
diff --git a/examples/react-vite-consumer/index.html b/examples/react-vite-consumer/index.html
new file mode 100644
index 0000000..3872ce8
--- /dev/null
+++ b/examples/react-vite-consumer/index.html
@@ -0,0 +1,13 @@
+
+
+
+
+
+
+ Avatar React consumer
+
+
+
+
+
+
diff --git a/examples/react-vite-consumer/package.json b/examples/react-vite-consumer/package.json
new file mode 100644
index 0000000..5dbad17
--- /dev/null
+++ b/examples/react-vite-consumer/package.json
@@ -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"
+ }
+}
diff --git a/examples/react-vite-consumer/src/main.tsx b/examples/react-vite-consumer/src/main.tsx
new file mode 100644
index 0000000..47d6797
--- /dev/null
+++ b/examples/react-vite-consumer/src/main.tsx
@@ -0,0 +1,101 @@
+import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
+import { Avatar, createAvatar } from '@bible-strong/avatar-react'
+import '@bible-strong/avatar-react/styles.css'
+import { StrictMode, 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
+const StrobiAvatar = createAvatar(definitionJson)
+type StrobiExpressionKey = keyof typeof definitionJson.expressions
+const expressions = Object.keys(definition.expressions) as StrobiExpressionKey[]
+const horizontalPosition = (expression: string) =>
+ expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
+const verticalPosition = (expression: string) =>
+ expression.includes('up') ? 'up' : expression.includes('down') ? 'down' : 'middle'
+const expressionGroups = [
+ ['up', 'left', 'Up left'],
+ ['up', 'center', 'Up'],
+ ['up', 'right', 'Up right'],
+ ['middle', 'left', 'Left'],
+ ['middle', 'center', 'Neutral'],
+ ['middle', 'right', 'Right'],
+ ['down', 'left', 'Down left'],
+ ['down', 'center', 'Down'],
+ ['down', 'right', 'Down right'],
+].map(([vertical, horizontal, label]) => ({
+ key: `${vertical}-${horizontal}`,
+ label,
+ expressions: expressions.filter(
+ expression =>
+ verticalPosition(expression) === vertical && horizontalPosition(expression) === horizontal
+ ),
+}))
+
+const formatExpressionName = (expression: string) =>
+ expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
+
+function Demo() {
+ const [expression, setExpression] = useState('neutral')
+
+ return (
+
+ Avatar package consumer
+
+
+
+
Available expressions ({expressions.length})
+ Active: {formatExpressionName(expression)}
+
+
+ {expressionGroups.map(group => (
+
+ {group.label}
+
+ {group.expressions.map(key => (
+ setExpression(key)}
+ >
+ {formatExpressionName(key)}
+
+ ))}
+
+
+ ))}
+
+
+
+
+ )
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
diff --git a/examples/react-vite-consumer/src/strobi.avatar.json b/examples/react-vite-consumer/src/strobi.avatar.json
new file mode 100644
index 0000000..cb86f62
--- /dev/null
+++ b/examples/react-vite-consumer/src/strobi.avatar.json
@@ -0,0 +1,1732 @@
+{
+ "schema": "bible-strong/avatar-definition",
+ "schemaVersion": 1,
+ "name": "Strobi",
+ "body": {
+ "primary": {
+ "type": "sphere",
+ "width": 240,
+ "height": 240,
+ "depth": 240.03671875,
+ "roundness": 1
+ },
+ "nodes": []
+ },
+ "colors": {
+ "body": "#5b7fe5",
+ "eyes": "#111316"
+ },
+ "expressions": {
+ "neutral": {
+ "head": {
+ "x": 0,
+ "y": 0,
+ "z": 0
+ },
+ "eyes": {
+ "left": {
+ "width": 20,
+ "height": 50,
+ "x": 0,
+ "y": -7,
+ "angle": 0
+ },
+ "right": {
+ "width": 20,
+ "height": 50,
+ "x": 0,
+ "y": -7,
+ "angle": 0
+ },
+ "spacing": 35
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "upward-side-glance": {
+ "head": {
+ "x": 7.3,
+ "y": 27.8,
+ "z": -16.1
+ },
+ "eyes": {
+ "left": {
+ "width": 22.501171874999997,
+ "height": 42.37773437499999,
+ "x": 0,
+ "y": -20.5,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.501171874999997,
+ "height": 42.37773437499999,
+ "x": 0,
+ "y": -20.5,
+ "angle": 0
+ },
+ "spacing": 54.3
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "downward-gaze": {
+ "head": {
+ "x": -15.057812500000004,
+ "y": 0.14296874999999964,
+ "z": -14.549218750000001
+ },
+ "eyes": {
+ "left": {
+ "width": 22.401171875000003,
+ "height": 54.5703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.401171875000003,
+ "height": 54.5703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 57.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "skeptical-right": {
+ "head": {
+ "x": -16.528515625,
+ "y": -3.7679687499999996,
+ "z": -13.7296875
+ },
+ "eyes": {
+ "left": {
+ "width": 23.090625000000003,
+ "height": 57.6796875,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 49.924609375,
+ "height": 12.431640625,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 56.3
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "small-attentive": {
+ "head": {
+ "x": -4.232421875000001,
+ "y": 14.362109375000003,
+ "z": 11.204296875
+ },
+ "eyes": {
+ "left": {
+ "width": 22.066796874999994,
+ "height": 39.599218750000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.066796874999994,
+ "height": 39.599218750000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 50.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "wide-downward-gaze": {
+ "head": {
+ "x": -19.20859375,
+ "y": 15.2,
+ "z": 11.8
+ },
+ "eyes": {
+ "left": {
+ "width": 52.084765625,
+ "height": 51.46715970849806,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 53.11410881916996,
+ "height": 52.18769994441695,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 69.5
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "surprised-left": {
+ "head": {
+ "x": 2.9468749999999986,
+ "y": -16.051171875,
+ "z": -20.916015625
+ },
+ "eyes": {
+ "left": {
+ "width": 51.68336723153048,
+ "height": 51.74054108796297,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 51.68336723153048,
+ "height": 51.74054108796297,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 70.9
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "sleepy-squint": {
+ "head": {
+ "x": 3.4,
+ "y": 13.22578125,
+ "z": 8.976953125
+ },
+ "eyes": {
+ "left": {
+ "width": 51.775000000000006,
+ "height": 13.02734375,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 51.775000000000006,
+ "height": 13.02734375,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 63.872265625
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "angry-right": {
+ "head": {
+ "x": 8.063671874999999,
+ "y": 17.626562500000002,
+ "z": -11.116796874999999
+ },
+ "eyes": {
+ "left": {
+ "width": 20.908203125,
+ "height": 40.400781249999994,
+ "x": 0,
+ "y": 0,
+ "angle": -30.865625
+ },
+ "right": {
+ "width": 20.908203125,
+ "height": 40.400781249999994,
+ "x": 0,
+ "y": 0,
+ "angle": 28.781640625
+ },
+ "spacing": 52.059765625
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "curious-left": {
+ "head": {
+ "x": -12.303515625,
+ "y": -17.601171875,
+ "z": 5.9109375
+ },
+ "eyes": {
+ "left": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": 23.523046875000002
+ },
+ "right": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": -24.042578125000002
+ },
+ "spacing": 54.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "asymmetric-down-right": {
+ "head": {
+ "x": -20.058203125,
+ "y": 12.607421875,
+ "z": -12.7
+ },
+ "eyes": {
+ "left": {
+ "width": 42.5,
+ "height": 41.8,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.1,
+ "height": 22.200000000000003,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 61.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "attentive-left": {
+ "head": {
+ "x": 1.43359375,
+ "y": 6.194140624999999,
+ "z": 10.56015625
+ },
+ "eyes": {
+ "left": {
+ "width": 23.836718749999996,
+ "height": 58.130078125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 23.836718749999996,
+ "height": 58.130078125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 56.8
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "joyful-wide": {
+ "head": {
+ "x": -2.092968750000001,
+ "y": -15.899609374999999,
+ "z": -14.469921875
+ },
+ "eyes": {
+ "left": {
+ "width": 34.20086765973213,
+ "height": 85.33085937499999,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 34.20086765973213,
+ "height": 83.17775668160692,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 59.414453124999994
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "eyes-closed": {
+ "head": {
+ "x": -8.752343750000001,
+ "y": -8.743359375,
+ "z": -10.773828125000001
+ },
+ "eyes": {
+ "left": {
+ "width": 56.133984375,
+ "height": 15.5,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 56.133984375,
+ "height": 15.155468749999997,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 69.276171875
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "joyful-down-right": {
+ "head": {
+ "x": -15.287109375000002,
+ "y": 15.006640625,
+ "z": 12.787890625
+ },
+ "eyes": {
+ "left": {
+ "width": 31.25390625,
+ "height": 76.720703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 31.25390625,
+ "height": 76.720703125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 68.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "skeptical-left": {
+ "head": {
+ "x": 3.5292968750000004,
+ "y": -7.0765625,
+ "z": 9.830078125
+ },
+ "eyes": {
+ "left": {
+ "width": 24.306250000000006,
+ "height": 59.281640624999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 48.92421875000001,
+ "height": 13.408203125,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 62.218359375000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "far-right-glance": {
+ "head": {
+ "x": 0.31914062500000184,
+ "y": 35.307421874999996,
+ "z": -10.904296875
+ },
+ "eyes": {
+ "left": {
+ "width": 22.4609375,
+ "height": 39.820703124999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.4609375,
+ "height": 39.820703124999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 53.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "angry-left": {
+ "head": {
+ "x": -14.750781250000001,
+ "y": -19.350000000000005,
+ "z": 5.631640624999998
+ },
+ "eyes": {
+ "left": {
+ "width": 19.602343750000003,
+ "height": 48.63984375,
+ "x": 0,
+ "y": 0,
+ "angle": -27.606640625
+ },
+ "right": {
+ "width": 19.602343750000003,
+ "height": 48.63984375,
+ "x": 0,
+ "y": 0,
+ "angle": 26.1484375
+ },
+ "spacing": 55.099999999999994
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "playful-right": {
+ "head": {
+ "x": -4.3953125,
+ "y": 14.07265625,
+ "z": -16.126171874999997
+ },
+ "eyes": {
+ "left": {
+ "width": 19.045145681988203,
+ "height": 43.370703125000006,
+ "x": 0,
+ "y": 0,
+ "angle": 26.2921875
+ },
+ "right": {
+ "width": 19.045145681988203,
+ "height": 43.370703125000006,
+ "x": 0,
+ "y": 0,
+ "angle": -20.249218750000004
+ },
+ "spacing": 51.73124999999999
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "asymmetric-up-left": {
+ "head": {
+ "x": 6.585546875,
+ "y": 4.737109375000001,
+ "z": 12.840234374999998
+ },
+ "eyes": {
+ "left": {
+ "width": 42.1,
+ "height": 41.7,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 22.200000000000003,
+ "height": 22.099999999999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 60.400000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "gentle-downward-gaze": {
+ "head": {
+ "x": -6.077734375000001,
+ "y": -11.03515625,
+ "z": -13.965625000000001
+ },
+ "eyes": {
+ "left": {
+ "width": 23.045703125000003,
+ "height": 58.685156250000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 23.045703125000003,
+ "height": 58.685156250000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 56.2
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "wide-down-left": {
+ "head": {
+ "x": -17.127734375000003,
+ "y": 18.070703124999998,
+ "z": 13.891796875
+ },
+ "eyes": {
+ "left": {
+ "width": 35.452734375,
+ "height": 79.10429687499999,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 35.452734375,
+ "height": 79.10429687499999,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 70.8
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "surprised-wide-left": {
+ "head": {
+ "x": -5.428125,
+ "y": -11.71328125,
+ "z": -13.472265625000002
+ },
+ "eyes": {
+ "left": {
+ "width": 51.400000000000006,
+ "height": 50.099999999999994,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 50.5,
+ "height": 49.400000000000006,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 69
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "drowsy-closed": {
+ "head": {
+ "x": 10.292578125,
+ "y": 3.39921875,
+ "z": 7.583203125
+ },
+ "eyes": {
+ "left": {
+ "width": 55.672265624999994,
+ "height": 14.616015625000003,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "right": {
+ "width": 55.672265624999994,
+ "height": 14.616015625000003,
+ "x": 0,
+ "y": 0,
+ "angle": 0
+ },
+ "spacing": 68.416796875
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "suspicious-right": {
+ "head": {
+ "x": -17.8,
+ "y": 10,
+ "z": -10.894921875
+ },
+ "eyes": {
+ "left": {
+ "width": 23.969921874999997,
+ "height": 55.892968749999994,
+ "x": 0,
+ "y": -9.8,
+ "angle": 0
+ },
+ "right": {
+ "width": 53.56328125,
+ "height": 13.333593750000006,
+ "x": 0,
+ "y": -9.8,
+ "angle": 0
+ },
+ "spacing": 59.943749999999994
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "shy-downward": {
+ "head": {
+ "x": 7.131640624999998,
+ "y": 7.7828124999999995,
+ "z": 3.935546874999999
+ },
+ "eyes": {
+ "left": {
+ "width": 21.5,
+ "height": 32,
+ "x": 0,
+ "y": 40,
+ "angle": 0
+ },
+ "right": {
+ "width": 23.200000000000003,
+ "height": 33.5,
+ "x": 0,
+ "y": 40,
+ "angle": 0
+ },
+ "spacing": 51.2
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "none"
+ }
+ },
+ "angry-brows": {
+ "head": {
+ "x": 10.473974503042374,
+ "y": 5.087293619785961,
+ "z": 4.698252132317348
+ },
+ "eyes": {
+ "left": {
+ "width": 27.12656249999999,
+ "height": 63.028125,
+ "x": 0,
+ "y": 0,
+ "angle": -36.244531249999994
+ },
+ "right": {
+ "width": 27.12656249999999,
+ "height": 63.028125,
+ "x": 0,
+ "y": 0,
+ "angle": 27.730078125000002
+ },
+ "spacing": 68.7
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "none",
+ "body": "shake"
+ },
+ "colors": {
+ "body": "#ba3636",
+ "eyes": "#610000"
+ }
+ },
+ "uneasy-left": {
+ "head": {
+ "x": -12.303515625,
+ "y": -17.601171875,
+ "z": 5.9109375
+ },
+ "eyes": {
+ "left": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": 23.523046875000002
+ },
+ "right": {
+ "width": 20.605859374999994,
+ "height": 47.769921874999994,
+ "x": 0,
+ "y": 0,
+ "angle": -24.042578125000002
+ },
+ "spacing": 54.900000000000006
+ },
+ "perspective": 1,
+ "motion": {
+ "eyes": "shake",
+ "body": "slowDrift"
+ },
+ "colors": {
+ "body": "#adc3ff"
+ }
+ }
+ },
+ "expressionOrder": [
+ "neutral",
+ "upward-side-glance",
+ "downward-gaze",
+ "skeptical-right",
+ "small-attentive",
+ "wide-downward-gaze",
+ "surprised-left",
+ "sleepy-squint",
+ "angry-right",
+ "curious-left",
+ "asymmetric-down-right",
+ "attentive-left",
+ "joyful-wide",
+ "eyes-closed",
+ "joyful-down-right",
+ "skeptical-left",
+ "far-right-glance",
+ "angry-left",
+ "playful-right",
+ "asymmetric-up-left",
+ "gentle-downward-gaze",
+ "wide-down-left",
+ "surprised-wide-left",
+ "drowsy-closed",
+ "suspicious-right",
+ "shy-downward",
+ "angry-brows",
+ "uneasy-left"
+ ],
+ "animations": {
+ "sleeping": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "eyes-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "sleeping",
+ "description": "Yeux presque fermés, respiration lente et expression de sommeil.",
+ "group": "Cycle de vie"
+ }
+ },
+ "waking": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "eyes-closed",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "waking",
+ "description": "Séquence courte de réveil avant retour vers une expression neutre.",
+ "group": "Cycle de vie"
+ }
+ },
+ "idle": {
+ "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
+ },
+ "metadata": {
+ "label": "idle",
+ "description": "Micro-mouvements lents, expressions 00 et 08, clignement rare.",
+ "group": "Cycle de vie"
+ }
+ },
+ "listening": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "attentive-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "gentle-downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 3200,
+ "minIntervalMs": 4800,
+ "maxIntervalMs": 7200,
+ "durationMs": 240
+ },
+ "metadata": {
+ "label": "listening",
+ "description": "Expressions 10, 01 et 19, regard stable et clignement attentif.",
+ "group": "Cycle de vie"
+ }
+ },
+ "thinking": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "angry-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "thinking",
+ "description": "Regard haut et latéral, expressions asymétriques et changements fréquents.",
+ "group": "Cycle de vie"
+ }
+ },
+ "searching": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "far-right-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "asymmetric-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "wide-down-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "wide-downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "asymmetric-up-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "searching",
+ "description": "Balayage rapide et changements très fréquents.",
+ "group": "Cycle de vie"
+ }
+ },
+ "working": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "angry-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "angry-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "attentive-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "working",
+ "description": "Rythme régulier et expressions concentrées.",
+ "group": "Cycle de vie"
+ }
+ },
+ "excited": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "excited",
+ "description": "Grandes expressions et transitions rapides.",
+ "group": "Réactions"
+ }
+ },
+ "bored": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "bored",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "suspicious": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "skeptical-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "suspicious-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "suspicious",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "angry": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "angry-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "angry-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "angry",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "drowsy": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "eyes-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "drowsy",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "happy": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "gentle-downward-gaze",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "happy",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "curious": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "far-right-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "curious",
+ "description": "Inclinaisons et forte asymétrie.",
+ "group": "Réactions"
+ }
+ },
+ "confused": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "skeptical-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "skeptical-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "confused",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "surprised": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "surprised",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "proud": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "far-right-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "proud",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "shy": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "upward-side-glance",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "shy-downward",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "eyes-closed",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "shy",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "sad": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "sleepy-squint",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "eyes-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "drowsy-closed",
+ "holdMs": 3600,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 4800,
+ "minIntervalMs": 6500,
+ "maxIntervalMs": 9500,
+ "durationMs": 420
+ },
+ "metadata": {
+ "label": "sad",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "laughing": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "laughing",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "scared": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "surprised-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "surprised-wide-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "scared",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "playful": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "joyful-wide",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 2100,
+ "minIntervalMs": 2800,
+ "maxIntervalMs": 5000,
+ "durationMs": 260
+ },
+ "metadata": {
+ "label": "playful",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ },
+ "celebrate": {
+ "playbackMode": "loop",
+ "steps": [
+ {
+ "expression": "joyful-down-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "curious-left",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ },
+ {
+ "expression": "playful-right",
+ "holdMs": 2300,
+ "transitionMs": 500,
+ "transition": "smooth"
+ }
+ ],
+ "blink": {
+ "enabled": true,
+ "initialDelayMs": 1200,
+ "minIntervalMs": 1800,
+ "maxIntervalMs": 3600,
+ "durationMs": 220
+ },
+ "metadata": {
+ "label": "celebrate",
+ "description": "Cet état enchaîne un pool de presets et des clignements.",
+ "group": "Réactions"
+ }
+ }
+ },
+ "animationOrder": [
+ "sleeping",
+ "waking",
+ "idle",
+ "listening",
+ "thinking",
+ "searching",
+ "working",
+ "excited",
+ "bored",
+ "suspicious",
+ "angry",
+ "drowsy",
+ "happy",
+ "curious",
+ "confused",
+ "surprised",
+ "proud",
+ "shy",
+ "sad",
+ "laughing",
+ "scared",
+ "playful",
+ "celebrate"
+ ]
+}
diff --git a/examples/react-vite-consumer/src/styles.css b/examples/react-vite-consumer/src/styles.css
new file mode 100644
index 0000000..3bd9c60
--- /dev/null
+++ b/examples/react-vite-consumer/src/styles.css
@@ -0,0 +1,222 @@
+: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;
+}
+
+.avatar-overlay {
+ position: fixed;
+ right: 24px;
+ bottom: 24px;
+ z-index: 10;
+ width: 128px;
+ height: 128px;
+}
+
+.expression-picker {
+ width: min(100%, 720px);
+}
+
+.expression-picker__heading {
+ display: flex;
+ align-items: baseline;
+ justify-content: space-between;
+ gap: 16px;
+}
+
+.expression-picker h2 {
+ margin: 0;
+ font-size: 1.125rem;
+}
+
+.expression-picker output {
+ color: #475569;
+}
+
+.expression-picker__list {
+ display: grid;
+ grid-template-areas:
+ 'up-left up-center up-right'
+ 'middle-left middle-center middle-right'
+ 'down-left down-center down-right';
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-rows: 1fr 3fr 1fr;
+ align-items: center;
+ aspect-ratio: 1;
+ min-height: 640px;
+ gap: 12px;
+ margin-top: 16px;
+ padding: 32px;
+ box-sizing: border-box;
+ border: 1px solid #cbd5e1;
+ border-radius: 50%;
+ background: radial-gradient(circle, #ffffff 0 28%, #eef2ff 28.25% 100%);
+}
+
+.expression-picker__group {
+ min-width: 0;
+}
+
+.expression-picker__group h3 {
+ margin: 0 0 8px;
+ color: #475569;
+ font-size: 0.75rem;
+ font-weight: 700;
+ letter-spacing: 0.08em;
+ text-align: center;
+ text-transform: uppercase;
+}
+
+.expression-picker__group--up-left {
+ grid-area: up-left;
+}
+
+.expression-picker__group--up-center {
+ grid-area: up-center;
+}
+
+.expression-picker__group--up-right {
+ grid-area: up-right;
+}
+
+.expression-picker__group--middle-left {
+ grid-area: middle-left;
+}
+
+.expression-picker__group--middle-center {
+ grid-area: middle-center;
+ padding: 16px;
+ border-radius: 24px;
+ background: white;
+ box-shadow: 0 8px 24px rgb(15 23 42 / 8%);
+}
+
+.expression-picker__group--middle-center .expression-picker__group-list {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+}
+
+.expression-picker__group--middle-right {
+ grid-area: middle-right;
+}
+
+.expression-picker__group--down-left {
+ grid-area: down-left;
+}
+
+.expression-picker__group--down-center {
+ grid-area: down-center;
+}
+
+.expression-picker__group--down-right {
+ grid-area: down-right;
+}
+
+.expression-picker__group-list {
+ display: grid;
+ gap: 8px;
+}
+
+.expression-picker__group button {
+ width: 100%;
+ min-height: 52px;
+ padding: 8px 12px;
+ border: 1px solid #cbd5e1;
+ border-radius: 12px;
+ color: #1e293b;
+ background: white;
+ font-size: 0.875rem;
+ font-weight: 600;
+ line-height: 1.25;
+ transition:
+ border-color 160ms ease,
+ background-color 160ms ease,
+ color 160ms ease,
+ transform 160ms ease;
+}
+
+.expression-picker__group button:hover {
+ border-color: #818cf8;
+ background: #eef2ff;
+}
+
+.expression-picker__group button:active {
+ transform: scale(0.98);
+}
+
+button {
+ min-height: 44px;
+ padding: 0 20px;
+ border: 0;
+ border-radius: 999px;
+ color: white;
+ background: #4338ca;
+ font: inherit;
+ cursor: pointer;
+}
+
+button.is-selected {
+ border-color: #312e81;
+ background: #312e81;
+ color: white;
+ box-shadow: 0 0 0 3px #c7d2fe;
+}
+
+button:focus-visible {
+ outline: 3px solid #4338ca;
+ outline-offset: 3px;
+}
+
+@media (max-width: 640px) {
+ .expression-picker__heading {
+ align-items: flex-start;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .expression-picker__list {
+ grid-template-areas: none;
+ grid-template-columns: 1fr;
+ grid-template-rows: none;
+ aspect-ratio: auto;
+ min-height: 0;
+ gap: 20px;
+ padding: 0;
+ border: 0;
+ border-radius: 0;
+ background: none;
+ }
+
+ .expression-picker__group {
+ grid-area: auto;
+ }
+
+ .expression-picker__group--middle-center {
+ padding: 0;
+ border-radius: 0;
+ background: none;
+ box-shadow: none;
+ }
+}
diff --git a/examples/react-vite-consumer/src/vite-env.d.ts b/examples/react-vite-consumer/src/vite-env.d.ts
new file mode 100644
index 0000000..11f02fe
--- /dev/null
+++ b/examples/react-vite-consumer/src/vite-env.d.ts
@@ -0,0 +1 @@
+///
diff --git a/examples/react-vite-consumer/tsconfig.json b/examples/react-vite-consumer/tsconfig.json
new file mode 100644
index 0000000..56ce52e
--- /dev/null
+++ b/examples/react-vite-consumer/tsconfig.json
@@ -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"]
+}
diff --git a/examples/react-vite-consumer/vite.config.ts b/examples/react-vite-consumer/vite.config.ts
new file mode 100644
index 0000000..80316f3
--- /dev/null
+++ b/examples/react-vite-consumer/vite.config.ts
@@ -0,0 +1,4 @@
+import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({ plugins: [react()] })
diff --git a/examples/web-vite-consumer/index.html b/examples/web-vite-consumer/index.html
new file mode 100644
index 0000000..2cbf660
--- /dev/null
+++ b/examples/web-vite-consumer/index.html
@@ -0,0 +1,21 @@
+
+
+
+
+
+ Avatar Web consumer
+
+
+
+
+
+ Sleeping
+ Idle
+ Neutral
+ Pause
+ Stop
+
+
+
+
+
diff --git a/examples/web-vite-consumer/package.json b/examples/web-vite-consumer/package.json
new file mode 100644
index 0000000..c69387a
--- /dev/null
+++ b/examples/web-vite-consumer/package.json
@@ -0,0 +1,16 @@
+{
+ "name": "avatar-web-vite-consumer",
+ "version": "0.0.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "build": "tsc --noEmit && vite build"
+ },
+ "dependencies": {
+ "@bible-strong/avatar-web": "workspace:*"
+ },
+ "devDependencies": {
+ "typescript": "~6.0.3",
+ "vite": "^8.0.13"
+ }
+}
diff --git a/examples/web-vite-consumer/src/main.ts b/examples/web-vite-consumer/src/main.ts
new file mode 100644
index 0000000..24f58ea
--- /dev/null
+++ b/examples/web-vite-consumer/src/main.ts
@@ -0,0 +1,21 @@
+import { createAvatar } from '@bible-strong/avatar-web'
+
+import definition from '../../react-vite-consumer/src/strobi.avatar.json'
+import './styles.css'
+
+const avatar = createAvatar('#avatar', {
+ definition,
+ defaultAnimation: 'sleeping',
+ size: '100%',
+})
+
+document.querySelectorAll('[data-animation]').forEach(button => {
+ button.addEventListener('click', () => avatar.play(button.dataset.animation ?? 'idle'))
+})
+document.querySelectorAll('[data-expression]').forEach(button => {
+ button.addEventListener('click', () =>
+ avatar.setExpression(button.dataset.expression ?? 'neutral')
+ )
+})
+document.querySelector('#pause')?.addEventListener('click', () => avatar.pause())
+document.querySelector('#stop')?.addEventListener('click', () => avatar.stop())
diff --git a/examples/web-vite-consumer/src/styles.css b/examples/web-vite-consumer/src/styles.css
new file mode 100644
index 0000000..834a25c
--- /dev/null
+++ b/examples/web-vite-consumer/src/styles.css
@@ -0,0 +1,41 @@
+:root {
+ color: #17191d;
+ background: #f4f6fa;
+ font-family: Inter, system-ui, sans-serif;
+}
+
+body {
+ display: grid;
+ min-height: 100vh;
+ margin: 0;
+ place-items: center;
+}
+
+main {
+ display: grid;
+ width: min(480px, calc(100% - 32px));
+ gap: 20px;
+}
+
+#avatar {
+ width: 100%;
+ aspect-ratio: 1;
+ border: 1px solid #dce1ea;
+ border-radius: 24px;
+ background: white;
+}
+
+.controls {
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+}
+
+button {
+ padding: 9px 13px;
+ border: 1px solid #cfd6e2;
+ border-radius: 9px;
+ background: white;
+ cursor: pointer;
+}
diff --git a/examples/web-vite-consumer/tsconfig.json b/examples/web-vite-consumer/tsconfig.json
new file mode 100644
index 0000000..a466ef4
--- /dev/null
+++ b/examples/web-vite-consumer/tsconfig.json
@@ -0,0 +1,13 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "resolveJsonModule": true,
+ "types": ["vite/client"],
+ "noEmit": true
+ },
+ "include": ["src"]
+}
diff --git a/package.json b/package.json
index 66df015..00980ef 100644
--- a/package.json
+++ b/package.json
@@ -29,12 +29,20 @@
"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",
+ "changeset": "changeset",
+ "version-packages": "changeset version",
+ "release": "pnpm packages:build && changeset publish",
+ "check": "pnpm engine:check && pnpm format:check && pnpm packages:build && pnpm typecheck && pnpm test && 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",
@@ -47,13 +55,16 @@
"devDependencies": {
"@babel/core": "^7.29.0",
"@babel/parser": "^7.28.6",
+ "@changesets/cli": "^3.0.0",
"@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",
diff --git a/packages/avatar-core/LICENSE b/packages/avatar-core/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/packages/avatar-core/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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
+ .
diff --git a/packages/avatar-core/README.md b/packages/avatar-core/README.md
new file mode 100644
index 0000000..f17f5fc
--- /dev/null
+++ b/packages/avatar-core/README.md
@@ -0,0 +1,80 @@
+# @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`.
+
+Definitions from the earlier pre-release runtime export may still contain
+`standardAnimationSet: 1`; the marker is accepted for compatibility but does not add any
+implicit animations.
+
+## Semantic lookup and playback
+
+Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
+for keys that are not present in the validated definition. Every animation is explicit in the
+definition, so the JSON remains the single source of truth for what an avatar can play.
+
+```ts
+import {
+ advanceAvatarPlayback,
+ playAvatarAnimation,
+ renderAvatarFrame,
+} from '@bible-strong/avatar-core'
+
+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.
+
+Application integrations are kept in separate packages: `@bible-strong/avatar-react` renders with
+React 19, while `@bible-strong/avatar-web` mounts the same definition directly into the DOM. Both
+depend on this package and use the same playback and scene implementation.
+
+The package follows Semantic Versioning. While it remains below `1.0.0`, breaking API changes
+increment the minor version and fixes increment the patch version.
diff --git a/packages/avatar-core/package.json b/packages/avatar-core/package.json
new file mode 100644
index 0000000..9974de4
--- /dev/null
+++ b/packages/avatar-core/package.json
@@ -0,0 +1,71 @@
+{
+ "name": "@bible-strong/avatar-core",
+ "version": "0.1.0",
+ "description": "Framework-independent runtime for Bible Strong procedural avatars.",
+ "keywords": [
+ "avatar",
+ "svg",
+ "animation",
+ "renderer",
+ "typescript"
+ ],
+ "author": "Stéphane Montlouis-Calixte",
+ "license": "AGPL-3.0-only",
+ "homepage": "https://github.com/smontlouis/bible-strong-avatar-lab#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/smontlouis/bible-strong-avatar-lab.git",
+ "directory": "packages/avatar-core"
+ },
+ "bugs": {
+ "url": "https://github.com/smontlouis/bible-strong-avatar-lab/issues"
+ },
+ "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",
+ "prepack": "pnpm build",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "ajv": "^8.20.0"
+ },
+ "devDependencies": {
+ "typescript": "~6.0.3",
+ "vite": "^8.0.13"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+}
diff --git a/packages/avatar-core/src/__tests__/core-test.ts b/packages/avatar-core/src/__tests__/core-test.ts
new file mode 100644
index 0000000..6e8caa4
--- /dev/null
+++ b/packages/avatar-core/src/__tests__/core-test.ts
@@ -0,0 +1,389 @@
+import {
+ advanceAvatarPlayback,
+ bodyFromDefinition,
+ createAvatarPlaybackState,
+ expressionFromDefinition,
+ parseAvatarDefinition,
+ playAvatarAnimation,
+ pauseAvatarPlayback,
+ poseFromExpression,
+ renderAvatar,
+ renderAvatarDefinition,
+ renderAvatarFrame,
+ resumeAvatarPlayback,
+ resolveAnimation,
+ sampleAvatarFrame,
+ 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: {
+ idle: {
+ playbackMode: 'loop',
+ steps: [
+ {
+ expression: 'upward-side-glance',
+ holdMs: 5_200,
+ transitionMs: 500,
+ transition: 'smooth',
+ },
+ { expression: 'curious-left', holdMs: 5_200, transitionMs: 500, transition: 'smooth' },
+ ],
+ blink: {
+ enabled: true,
+ initialDelayMs: 2_600,
+ minIntervalMs: 3_400,
+ maxIntervalMs: 6_200,
+ durationMs: 280,
+ },
+ },
+ },
+ animationOrder: ['idle'],
+}
+
+describe('@bible-strong/avatar-core', () => {
+ it('loads a JSON definition and resolves an explicit 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('accepts the legacy standard-animation marker without restoring hidden animations', () => {
+ const legacy = {
+ ...definition,
+ animations: {},
+ animationOrder: [],
+ standardAnimationSet: 1 as const,
+ }
+ const parsed = parseAvatarDefinition(JSON.stringify(legacy))
+ expect(parsed.ok).toBe(true)
+ if (!parsed.ok) return
+
+ expect(resolveAnimation(parsed.value, 'idle')).toMatchObject({
+ ok: false,
+ error: { code: 'unknown_animation', key: 'idle' },
+ })
+ })
+
+ it('advances playback deterministically from transition to hold and the next step', () => {
+ 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('interpolates and completes a direct expression transition', () => {
+ const from = sampleAvatarFrame(definition, createAvatarPlaybackState(), 1_000, {
+ random: () => 0.5,
+ })
+ const state = {
+ ...createAvatarPlaybackState(),
+ activeExpression: 'curious-left',
+ status: 'playing' as const,
+ directTransition: {
+ from,
+ startedAt: 1_000,
+ durationMs: 400,
+ transition: 'smooth' as const,
+ },
+ }
+ const start = renderAvatarFrame(definition, state, 1_000, { random: () => 0.5 })
+ const midway = renderAvatarFrame(definition, state, 1_200, { random: () => 0.5 })
+ const end = renderAvatarFrame(definition, state, 1_400, { random: () => 0.5 })
+
+ expect(midway.geometry).not.toEqual(start.geometry)
+ expect(midway.geometry).not.toEqual(end.geometry)
+ expect(advanceAvatarPlayback(definition, state, 1_400, { random: () => 0.5 })).toMatchObject({
+ activeExpression: 'curious-left',
+ status: 'stopped',
+ })
+ })
+
+ it('starts a new animation from the currently displayed frame instead of neutral', () => {
+ const current = {
+ ...createAvatarPlaybackState(),
+ activeExpression: 'curious-left',
+ }
+ const now = 1_000
+ const from = sampleAvatarFrame(definition, current, now, { random: () => 0.5 })
+ const started = playAvatarAnimation(definition, 'idle', now, from)
+ if (!started.ok) throw new Error(started.error.message)
+
+ expect(renderAvatarFrame(definition, started.value, now, { random: () => 0.5 })).toEqual(
+ renderAvatarFrame(definition, current, now, { random: () => 0.5 })
+ )
+ })
+
+ it('retargets a direct transition from its exact in-flight frame', () => {
+ const neutral = createAvatarPlaybackState()
+ const firstStartedAt = 1_000
+ const first = {
+ ...neutral,
+ activeExpression: 'curious-left',
+ status: 'playing' as const,
+ directTransition: {
+ from: sampleAvatarFrame(definition, neutral, firstStartedAt, { random: () => 0.5 }),
+ startedAt: firstStartedAt,
+ durationMs: 400,
+ transition: 'smooth' as const,
+ },
+ }
+ const retargetedAt = 1_200
+ const inFlight = sampleAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
+ const second = {
+ ...createAvatarPlaybackState(),
+ activeExpression: 'upward-side-glance',
+ status: 'playing' as const,
+ directTransition: {
+ from: inFlight,
+ startedAt: retargetedAt,
+ durationMs: 400,
+ transition: 'smooth' as const,
+ },
+ }
+
+ expect(renderAvatarFrame(definition, second, retargetedAt, { random: () => 0.5 })).toEqual(
+ renderAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
+ )
+ })
+
+ it('interpolates expression color overrides during a transition', () => {
+ const colored: AvatarDefinition = {
+ ...definition,
+ expressions: {
+ ...definition.expressions,
+ 'curious-left': {
+ ...definition.expressions['curious-left'],
+ colors: { body: '#ff0000', eyes: '#ffffff' },
+ },
+ },
+ }
+ const neutral = createAvatarPlaybackState()
+ const state = {
+ ...neutral,
+ activeExpression: 'curious-left',
+ status: 'playing' as const,
+ directTransition: {
+ from: sampleAvatarFrame(colored, neutral, 1_000, { random: () => 0.5 }),
+ startedAt: 1_000,
+ durationMs: 400,
+ transition: 'smooth' as const,
+ },
+ }
+
+ expect(renderAvatarFrame(colored, state, 1_200, { random: () => 0.5 }).colors).toEqual({
+ body: '#ad4073',
+ eyes: '#88898b',
+ })
+ })
+
+ it('generates the same geometry through the public definition adapter', () => {
+ 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 a typed error for an animation that is not present in the definition', () => {
+ expect(resolveAnimation(definition, 'missing')).toMatchObject({
+ ok: false,
+ error: { code: 'unknown_animation', key: 'missing' },
+ })
+ expect(resolveAnimation(definition, 'happy')).toMatchObject({
+ ok: false,
+ error: { code: 'unknown_animation', key: 'happy' },
+ })
+ })
+
+ it('resolves an explicit animation by its semantic 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',
+ })
+ })
+})
diff --git a/packages/avatar-core/src/ambientMotion.ts b/packages/avatar-core/src/ambientMotion.ts
new file mode 100644
index 0000000..2df3a01
--- /dev/null
+++ b/packages/avatar-core/src/ambientMotion.ts
@@ -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(eyeMotionModes)
+const bodyMotionSet = new Set(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
+}
diff --git a/packages/avatar-core/src/avatarDefinition.schema.json b/packages/avatar-core/src/avatarDefinition.schema.json
new file mode 100644
index 0000000..018a161
--- /dev/null
+++ b/packages/avatar-core/src/avatarDefinition.schema.json
@@ -0,0 +1,248 @@
+{
+ "$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"
+ ],
+ "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, "deprecated": true }
+ },
+ "$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 }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/packages/avatar-core/src/avatarDefinition.ts b/packages/avatar-core/src/avatarDefinition.ts
new file mode 100644
index 0000000..8713c0e
--- /dev/null
+++ b/packages/avatar-core/src/avatarDefinition.ts
@@ -0,0 +1,487 @@
+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 = {
+ type: TType
+ width: number
+ height: number
+ depth: number
+ roundness: number
+ morphRoundness?: number
+ tipRoundness?: number
+ baseRoundness?: number
+}
+
+export type BodyNodeSurfaceType = Exclude
+export type PrimarySurfaceDefinition = SurfaceDefinition
+export type BodyNodeSurfaceDefinition = SurfaceDefinition
+
+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
+}
+
+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
+ expressionOrder: ExpressionKey[]
+ animations: Record
+ animationOrder: AnimationKey[]
+ /** @deprecated Accepted for pre-release JSON compatibility; it no longer adds animations. */
+ standardAnimationSet?: 1
+}
+
+export type AvatarDefinitionError = {
+ path: string
+ code: string
+ message: string
+}
+
+export type ValidationResult =
+ { ok: true; value: Readonly } | { ok: false; errors: readonly AvatarDefinitionError[] }
+
+const ajv = new Ajv2020({ allErrors: true, strict: true })
+const validateSchema = ajv.compile(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()
+
+ 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)
+ const knownKeys = new Set(keys)
+ keys.forEach(key => {
+ if (!ordered.has(key)) {
+ errors.push({
+ path,
+ code: 'incomplete_order',
+ message: `Order is missing key '${key}'`,
+ })
+ }
+ })
+ order.forEach((key, index) => {
+ if (!knownKeys.has(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 = (value: T): Readonly => {
+ 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
+}
+
+export const validateAvatarDefinition = (value: unknown): ValidationResult => {
+ 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 = Object.create(null) as Record
+ const keys = new Set()
+ 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(source: string, value: T): T {
+ this.index += source.length
+ return value
+ }
+}
+
+export const parseAvatarDefinition = (text: string): ValidationResult => {
+ 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`
+}
diff --git a/packages/avatar-core/src/body.ts b/packages/avatar-core/src/body.ts
new file mode 100644
index 0000000..99db4c7
--- /dev/null
+++ b/packages/avatar-core/src/body.ts
@@ -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
+ 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
+ const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
+ const seenIds = new Set()
+ 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],
+})
diff --git a/packages/avatar-core/src/geometry.ts b/packages/avatar-core/src/geometry.ts
new file mode 100644
index 0000000..a96aa5e
--- /dev/null
+++ b/packages/avatar-core/src/geometry.ts
@@ -0,0 +1,1261 @@
+import {
+ cursorLayout,
+ surfaceFrontSampleAt,
+ surfacePointAt,
+ surfaceSampleAt,
+ type SurfaceConfig,
+} from './surfaces'
+import type { BodyNode } from './body'
+
+export type Quaternion = readonly [number, number, number, number]
+export type Point3 = readonly [number, number, number]
+export type EyeMotion = 'none' | 'microSaccades' | 'shake'
+export type BodyMotion = 'none' | 'slowDrift' | 'shake'
+
+export type Expression = {
+ id: string
+ semanticKey?: string
+ headX: number
+ headY: number
+ headZ: number
+ widthLeft: number
+ widthRight: number
+ heightLeft: number
+ heightRight: number
+ spacing: number
+ positionXLeft: number
+ positionXRight: number
+ positionYLeft: number
+ positionYRight: number
+ leftAngle: number
+ rightAngle: number
+ perspective: number
+ eyeMotion: EyeMotion
+ bodyMotion: BodyMotion
+ bodyColor?: string
+ eyeColor?: string
+}
+
+export type ExpressionNumericField = Exclude<
+ keyof Expression,
+ 'id' | 'semanticKey' | 'bodyColor' | 'eyeColor' | 'eyeMotion' | 'bodyMotion'
+>
+
+export type AvatarPose = {
+ expression: Expression
+ orientation: Quaternion
+}
+
+export type AvatarGeometry = {
+ backPaths: string[]
+ frontPaths: string[]
+ backNodeIds: (string | null)[]
+ frontNodeIds: (string | null)[]
+ headPath: string
+ leftPath: string
+ rightPath: string
+ leftVisible: boolean
+ rightVisible: boolean
+ wirePaths: string[]
+}
+
+export type RenderAvatarOptions = {
+ includeWire?: boolean
+ bodyNodes?: BodyNode[]
+ eyeOffset?: Readonly<{ x: number; y: number }>
+}
+
+export type EyeEditorGeometry = {
+ visible: boolean
+ selectionPath: string
+ widthGuide: string
+ heightGuide: string
+ rotationGuide: string
+ spacingGuide: string
+ center: Point3
+ widthHandle: Point3
+ heightHandle: Point3
+ rotateHandle: Point3
+ sizeHandle: Point3
+ spacingHandle: Point3
+}
+
+export type BodyNodeEditorGeometry = {
+ center: Point3
+ axes: Record<'x' | 'y' | 'z', Point3>
+ rings: Record<'x' | 'y' | 'z', Point3[]>
+}
+
+export const RADIUS = 120
+const FOCAL_LENGTH = 620
+const QUARTER_ARC_SAMPLES = 14
+
+export const expressionFields: ExpressionNumericField[] = [
+ 'headX',
+ 'headY',
+ 'headZ',
+ 'widthLeft',
+ 'widthRight',
+ 'heightLeft',
+ 'heightRight',
+ 'spacing',
+ 'positionXLeft',
+ 'positionXRight',
+ 'positionYLeft',
+ 'positionYRight',
+ 'leftAngle',
+ 'rightAngle',
+ 'perspective',
+]
+
+export const clamp = (value: number, min: number, max: number) =>
+ Math.max(min, Math.min(max, value))
+
+export const radians = (degrees: number) => (degrees * Math.PI) / 180
+
+export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => {
+ const length = Math.hypot(w, x, y, z) || 1
+ return [w / length, x / length, y / length, z / length]
+}
+
+export const multiplyQuaternions = (
+ [aw, ax, ay, az]: Quaternion,
+ [bw, bx, by, bz]: Quaternion
+): Quaternion =>
+ normalizeQuaternion([
+ aw * bw - ax * bx - ay * by - az * bz,
+ aw * bx + ax * bw + ay * bz - az * by,
+ aw * by - ax * bz + ay * bw + az * bx,
+ aw * bz + ax * by - ay * bx + az * bw,
+ ])
+
+export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => {
+ const halfAngle = angle / 2
+ const sine = Math.sin(halfAngle)
+ return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
+}
+
+export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => {
+ const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
+ const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
+ const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
+ return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
+}
+
+export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => {
+ const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
+ const cross: Point3 = [
+ from[1] * to[2] - from[2] * to[1],
+ from[2] * to[0] - from[0] * to[2],
+ from[0] * to[1] - from[1] * to[0],
+ ]
+ return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
+}
+
+export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => {
+ const matrix00 = 1 - 2 * (y * y + z * z)
+ const matrix01 = 2 * (x * y - z * w)
+ const matrix10 = 2 * (x * y + z * w)
+ const matrix11 = 1 - 2 * (x * x + z * z)
+ const matrix20 = 2 * (x * z - y * w)
+ const matrix21 = 2 * (y * z + x * w)
+ const matrix22 = 1 - 2 * (x * x + y * y)
+ const headX = Math.asin(clamp(matrix21, -1, 1))
+ if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)]
+ return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
+}
+
+const nearestEquivalentAngle = (angle: number, current: number) => {
+ let result = angle
+ while (result - current > 180) result -= 360
+ while (result - current < -180) result += 360
+ return clamp(result, -365, 365)
+}
+
+export const expressionWithOrientation = (
+ expression: Expression,
+ orientation: Quaternion
+): Expression => {
+ const [radiansX, radiansY, radiansZ] = quaternionToEuler(orientation)
+ const x = (radiansX * 180) / Math.PI
+ const y = (radiansY * 180) / Math.PI
+ const z = (radiansZ * 180) / Math.PI
+ return {
+ ...expression,
+ headX: nearestEquivalentAngle(x, expression.headX),
+ headY: nearestEquivalentAngle(y, expression.headY),
+ headZ: nearestEquivalentAngle(z, expression.headZ),
+ }
+}
+
+export const slerpQuaternion = (
+ start: Quaternion,
+ end: Quaternion,
+ progress: number
+): Quaternion => {
+ let target = end
+ let dot = start.reduce((total, value, index) => total + value * target[index], 0)
+ if (dot < 0) {
+ target = target.map(value => -value) as unknown as Quaternion
+ dot = -dot
+ }
+ if (dot > 0.9995) {
+ return normalizeQuaternion(
+ start.map(
+ (value, index) => value + (target[index] - value) * progress
+ ) as unknown as Quaternion
+ )
+ }
+ const angle = Math.acos(clamp(dot, -1, 1))
+ const sine = Math.sin(angle)
+ const startWeight = Math.sin((1 - progress) * angle) / sine
+ const targetWeight = Math.sin(progress * angle) / sine
+ return normalizeQuaternion(
+ start.map(
+ (value, index) => value * startWeight + target[index] * targetWeight
+ ) as unknown as Quaternion
+ )
+}
+
+export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => {
+ const tx = 2 * (y * pz - z * py)
+ const ty = 2 * (z * px - x * pz)
+ const tz = 2 * (x * py - y * px)
+ return [
+ px + w * tx + (y * tz - z * ty),
+ py + w * ty + (z * tx - x * tz),
+ pz + w * tz + (x * ty - y * tx),
+ ]
+}
+
+const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => {
+ const halfWidth = width / 2
+ const halfHeight = height / 2
+ const cornerRadius = Math.min(halfHeight, halfWidth)
+ const points: (readonly [number, number])[] = []
+ const addLine = (start: readonly [number, number], end: readonly [number, number]) => {
+ const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5))
+ for (let index = 0; index < samples; index += 1) {
+ const progress = index / samples
+ points.push([
+ start[0] + (end[0] - start[0]) * progress,
+ start[1] + (end[1] - start[1]) * progress,
+ ])
+ }
+ }
+ const addArc = (centerX: number, centerY: number, startAngle: number) => {
+ for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) {
+ const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2)
+ points.push([
+ centerX + Math.cos(angle) * cornerRadius,
+ centerY + Math.sin(angle) * cornerRadius,
+ ])
+ }
+ }
+ addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
+ addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
+ addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
+ addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
+ addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
+ addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
+ addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
+ addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
+ return points
+}
+
+const project = (point: Point3, perspective: number): Point3 => {
+ const denominator = FOCAL_LENGTH - point[2] * perspective
+ const scale = Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
+ return [point[0] * scale, point[1] * scale, point[2]]
+}
+
+export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
+ axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
+
+export const rotateExpressionAroundAxis = (
+ expression: Expression,
+ axis: 'x' | 'y' | 'z',
+ deltaDegrees: number
+) => {
+ const startOrientation = poseFromExpression(expression).orientation
+ const worldAxis = rotateWithQuaternion(startOrientation, axisVector(axis))
+ const orientation = multiplyQuaternions(
+ quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)),
+ startOrientation
+ )
+ return expressionWithOrientation(expression, orientation)
+}
+
+export const rotateExpressionAroundCamera = (expression: Expression, deltaRadians: number) => {
+ const startOrientation = poseFromExpression(expression).orientation
+ return expressionWithOrientation(
+ expression,
+ multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), startOrientation)
+ )
+}
+
+const arcballVector = ([xValue, yValue]: readonly [number, number]): Point3 => {
+ const x = xValue / RADIUS
+ const y = yValue / RADIUS
+ const squaredLength = x * x + y * y
+ if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
+ const length = Math.sqrt(squaredLength)
+ return [x / length, y / length, 0]
+}
+
+export const rotateExpressionWithArcball = (
+ expression: Expression,
+ startPoint: readonly [number, number],
+ currentPoint: readonly [number, number]
+) => {
+ const startOrientation = poseFromExpression(expression).orientation
+ const delta = quaternionFromVectors(arcballVector(startPoint), arcballVector(currentPoint))
+ return expressionWithOrientation(expression, multiplyQuaternions(delta, startOrientation))
+}
+
+export const rotationRing = (pose: AvatarPose, axis: 'x' | 'y' | 'z', radius = 30): Point3[] =>
+ Array.from({ length: 97 }, (_, index) => {
+ const angle = (index / 96) * Math.PI * 2
+ const cosine = Math.cos(angle)
+ const sine = Math.sin(angle)
+ const point: Point3 =
+ axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
+ const rotated = rotateWithQuaternion(pose.orientation, point)
+ return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
+ })
+
+export const renderBodyNodeEditor = (
+ pose: AvatarPose,
+ node: BodyNode,
+ axisLength = 34,
+ ringRadius = 26
+): BodyNodeEditorGeometry => {
+ const projectInHeadSpace = (point: Point3) =>
+ project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
+ const center = projectInHeadSpace(node.position)
+ const localOrientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const axes = Object.fromEntries(
+ (['x', 'y', 'z'] as const).map(axis => {
+ const vector = rotateWithQuaternion(localOrientation, axisVector(axis))
+ return [
+ axis,
+ projectInHeadSpace([
+ node.position[0] + vector[0] * axisLength,
+ node.position[1] + vector[1] * axisLength,
+ node.position[2] + vector[2] * axisLength,
+ ]),
+ ]
+ })
+ ) as BodyNodeEditorGeometry['axes']
+ ;(['x', 'y', 'z'] as const).forEach(axis => {
+ const endpoint = axes[axis]
+ if (Math.hypot(endpoint[0] - center[0], endpoint[1] - center[1]) >= 12) return
+ const fallback: Point3 =
+ axis === 'x'
+ ? [center[0] + 18, center[1], endpoint[2]]
+ : axis === 'y'
+ ? [center[0], center[1] + 18, endpoint[2]]
+ : [center[0] + 14, center[1] + 14, endpoint[2]]
+ axes[axis] = fallback
+ })
+ const rings = Object.fromEntries(
+ (['x', 'y', 'z'] as const).map(axis => [
+ axis,
+ Array.from({ length: 65 }, (_, index) => {
+ const angle = (index / 64) * Math.PI * 2
+ const cosine = Math.cos(angle) * ringRadius
+ const sine = Math.sin(angle) * ringRadius
+ const localPoint: Point3 =
+ axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
+ const rotated = rotateWithQuaternion(localOrientation, localPoint)
+ return projectInHeadSpace([
+ node.position[0] + rotated[0],
+ node.position[1] + rotated[1],
+ node.position[2] + rotated[2],
+ ])
+ }),
+ ])
+ ) as BodyNodeEditorGeometry['rings']
+ return { center, axes, rings }
+}
+
+export const translateBodyNodeAlongLocalAxis = (
+ node: BodyNode,
+ axis: 'x' | 'y' | 'z',
+ distance: number
+): BodyNode => {
+ const orientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const direction = rotateWithQuaternion(orientation, axisVector(axis))
+ return {
+ ...node,
+ position: [
+ node.position[0] + direction[0] * distance,
+ node.position[1] + direction[1] * distance,
+ node.position[2] + direction[2] * distance,
+ ],
+ }
+}
+
+export const translateBodyNodeInCameraPlane = (
+ node: BodyNode,
+ pose: AvatarPose,
+ screenDeltaX: number,
+ screenDeltaY: number
+): BodyNode => {
+ const cameraPosition = rotateWithQuaternion(pose.orientation, node.position)
+ const denominator = FOCAL_LENGTH - cameraPosition[2] * pose.expression.perspective
+ const perspectiveScale =
+ Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
+ const [w, x, y, z] = pose.orientation
+ const headDelta = rotateWithQuaternion(
+ [w, -x, -y, -z],
+ [screenDeltaX / perspectiveScale, screenDeltaY / perspectiveScale, 0]
+ )
+ return {
+ ...node,
+ position: [
+ node.position[0] + headDelta[0],
+ node.position[1] + headDelta[1],
+ node.position[2] + headDelta[2],
+ ],
+ }
+}
+
+export const rotateBodyNodeAroundLocalAxis = (
+ node: BodyNode,
+ axis: 'x' | 'y' | 'z',
+ deltaDegrees: number
+): BodyNode => {
+ const orientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const rotated = multiplyQuaternions(
+ orientation,
+ quaternionFromAxisAngle(axisVector(axis), radians(deltaDegrees))
+ )
+ const next = quaternionToEuler(rotated).map(value => (value * 180) / Math.PI) as [
+ number,
+ number,
+ number,
+ ]
+ return {
+ ...node,
+ rotation: next.map((value, index) => nearestEquivalentAngle(value, node.rotation[index])) as [
+ number,
+ number,
+ number,
+ ],
+ }
+}
+
+const path = (points: Point3[], close = true) => {
+ if (!points.length) return ''
+ return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
+ .slice(1)
+ .map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
+ .join('')}${close ? 'Z' : ''}`
+}
+
+export const poseFromExpression = (expression: Expression): AvatarPose => ({
+ expression,
+ orientation: quaternionFromEuler(
+ radians(expression.headX),
+ radians(expression.headY),
+ radians(expression.headZ)
+ ),
+})
+
+export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
+ const expression: Expression = { ...from.expression }
+ expressionFields.forEach(field => {
+ let target = to.expression[field]
+ if (
+ field === 'headX' ||
+ field === 'headY' ||
+ field === 'headZ' ||
+ field === 'leftAngle' ||
+ field === 'rightAngle'
+ ) {
+ target = nearestEquivalentAngle(target, from.expression[field])
+ }
+ expression[field] = from.expression[field] + (target - from.expression[field]) * progress
+ })
+ return {
+ expression,
+ orientation: poseFromExpression(expression).orientation,
+ }
+}
+
+type ProjectedSurfacePoint = { point: Point3; normal: Point3 }
+type LocalSurfacePoint = ProjectedSurfacePoint
+
+const MAX_SURFACE_CACHE_ENTRIES = 24
+const HEAD_LATITUDE_SAMPLES = 25
+const HEAD_LONGITUDE_SAMPLES = 73
+const PRIMITIVE_RING_SAMPLES = 144
+const ROUNDED_PRIMITIVE_LATITUDE_SAMPLES = 33
+const ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES = 73
+const headSamplesCache = new Map()
+const accessorySamplesCache = new Map()
+const wireSamplesCache = new Map()
+
+const surfaceCacheKey = (surface: SurfaceConfig) =>
+ JSON.stringify([
+ surface.type,
+ surface.width,
+ surface.height,
+ surface.depth,
+ surface.roundness,
+ surface.morphRoundness,
+ surface.tipRoundness,
+ surface.baseRoundness,
+ ])
+
+const cacheSurfaceValue = (cache: Map, key: string, value: Value) => {
+ if (cache.size >= MAX_SURFACE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!)
+ cache.set(key, value)
+ return value
+}
+
+const localSurfacePoint = (
+ surface: SurfaceConfig,
+ longitude: number,
+ latitude: number
+): LocalSurfacePoint => surfaceSampleAt(surface, longitude, latitude)
+
+const projectLocalSurfacePoint = (
+ pose: AvatarPose,
+ sample: LocalSurfacePoint
+): ProjectedSurfacePoint => ({
+ point: project(rotateWithQuaternion(pose.orientation, sample.point), pose.expression.perspective),
+ normal: rotateWithQuaternion(pose.orientation, sample.normal),
+})
+
+const canonicalFaceCoordinates = (x: number, y: number): readonly [number, number] => {
+ const longitude = x / RADIUS
+ const latitude = y / RADIUS
+ return [RADIUS * Math.cos(latitude) * Math.sin(longitude), RADIUS * Math.sin(latitude)]
+}
+
+const projectFacePoint = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ x: number,
+ y: number
+): ProjectedSurfacePoint => {
+ const [faceX, faceY] = canonicalFaceCoordinates(x, y)
+ return projectLocalSurfacePoint(pose, surfaceFrontSampleAt(surface, faceX, faceY))
+}
+
+const eyePoints = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ side: -1 | 1,
+ blink: number,
+ offset: Readonly<{ x: number; y: number }> = { x: 0, y: 0 }
+): ProjectedSurfacePoint[] => {
+ const expression = pose.expression
+ const suffix = side < 0 ? 'Left' : 'Right'
+ const width = expression[`width${suffix}`]
+ const restingHeight = expression[`height${suffix}`]
+ const height = 5 + (restingHeight - 5) * blink
+ const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
+ const centerY = expression[`positionY${suffix}`] + offset.y
+ const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
+ return roundedRectangle(width, height).map(([localX, localY]) => {
+ const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
+ const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
+ return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
+ })
+}
+
+const visiblePath = (points: ProjectedSurfacePoint[]) => {
+ const segments: Point3[][] = []
+ let segment: Point3[] = []
+ points.forEach(({ point, normal }) => {
+ if (normal[2] > 0) segment.push(point)
+ else if (segment.length) {
+ segments.push(segment)
+ segment = []
+ }
+ })
+ if (segment.length) segments.push(segment)
+ return segments
+ .filter(item => item.length > 1)
+ .map(item => path(item, false))
+ .join('')
+}
+
+const wirePaths = (pose: AvatarPose, surface: SurfaceConfig): string[] => {
+ const key = surfaceCacheKey(surface)
+ let samples = wireSamplesCache.get(key)
+ if (!samples) {
+ const parallels = [-60, -30, 0, 30, 60].map(latitude =>
+ Array.from({ length: 73 }, (_, index) =>
+ localSurfacePoint(surface, radians(-180 + index * 5), radians(latitude))
+ )
+ )
+ const meridians = Array.from(
+ { length: 12 },
+ (_, longitudeIndex) => -150 + longitudeIndex * 30
+ ).map(longitude =>
+ Array.from({ length: 37 }, (_, index) =>
+ localSurfacePoint(surface, radians(longitude), radians(-90 + index * 5))
+ )
+ )
+ samples = cacheSurfaceValue(wireSamplesCache, key, [...parallels, ...meridians])
+ }
+ return samples.map(curve =>
+ visiblePath(curve.map(sample => projectLocalSurfacePoint(pose, sample)))
+ )
+}
+
+const projectEyePoint = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ side: -1 | 1,
+ localX: number,
+ localY: number
+): Point3 => {
+ const expression = pose.expression
+ const suffix = side < 0 ? 'Left' : 'Right'
+ const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
+ const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
+ const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
+ return projectFacePoint(
+ pose,
+ surface,
+ (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + rotatedX,
+ expression[`positionY${suffix}`] + rotatedY
+ ).point
+}
+
+export const renderEyeEditor = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ side: -1 | 1
+): EyeEditorGeometry => {
+ const expression = pose.expression
+ const suffix = side < 0 ? 'Left' : 'Right'
+ const width = expression[`width${suffix}`]
+ const height = expression[`height${suffix}`]
+ const selectedSamples = eyePoints(pose, surface, side, 1)
+ const selectedPoints = selectedSamples.map(sample => sample.point)
+ const center = projectEyePoint(pose, surface, side, 0, 0)
+ const widthHandle = projectEyePoint(pose, surface, side, width / 2 + 9, 0)
+ const heightHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 9)
+ const rotateHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 30)
+ const sizeHandle = projectEyePoint(pose, surface, side, width / 2 + 11, height / 2 + 11)
+ const leftCenter = projectEyePoint(pose, surface, -1, 0, 0)
+ const rightCenter = projectEyePoint(pose, surface, 1, 0, 0)
+ const spacingCenterX = (expression.positionXLeft + expression.positionXRight) / 2
+ const spacingCenterY = (expression.positionYLeft + expression.positionYRight) / 2
+ const spacingHandle = projectFacePoint(
+ pose,
+ surface,
+ spacingCenterX,
+ spacingCenterY + height / 2 + 34
+ ).point
+ const spacingMiddle: Point3 = [
+ (leftCenter[0] + rightCenter[0]) / 2,
+ (leftCenter[1] + rightCenter[1]) / 2,
+ (leftCenter[2] + rightCenter[2]) / 2,
+ ]
+ const line = (from: Point3, to: Point3) => path([from, to], false)
+ return {
+ visible: selectedSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
+ selectionPath: path(selectedPoints),
+ widthGuide: line(center, widthHandle),
+ heightGuide: line(center, heightHandle),
+ rotationGuide: line(heightHandle, rotateHandle),
+ spacingGuide: `${line(leftCenter, rightCenter)}${line(spacingMiddle, spacingHandle)}`,
+ center,
+ widthHandle,
+ heightHandle,
+ rotateHandle,
+ sizeHandle,
+ spacingHandle,
+ }
+}
+
+const convexHull = (points: Point3[]): Point3[] => {
+ const sorted = [...points].sort((left, right) => left[0] - right[0] || left[1] - right[1])
+ const cross = (origin: Point3, first: Point3, second: Point3) =>
+ (first[0] - origin[0]) * (second[1] - origin[1]) -
+ (first[1] - origin[1]) * (second[0] - origin[0])
+ const half = (source: Point3[]) => {
+ const result: Point3[] = []
+ source.forEach(point => {
+ while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop()
+ result.push(point)
+ })
+ return result
+ }
+ return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)]
+}
+
+const smoothClosedPath = (points: Point3[]) => {
+ if (points.length < 3) return path(points)
+ const pointAt = (index: number) => points[(index + points.length) % points.length]
+ return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
+ .map((point, index) => {
+ const previous = pointAt(index - 1)
+ const next = pointAt(index + 1)
+ const afterNext = pointAt(index + 2)
+ const firstControl: Point3 = [
+ point[0] + (next[0] - previous[0]) / 6,
+ point[1] + (next[1] - previous[1]) / 6,
+ point[2],
+ ]
+ const secondControl: Point3 = [
+ next[0] - (afterNext[0] - point[0]) / 6,
+ next[1] - (afterNext[1] - point[1]) / 6,
+ next[2],
+ ]
+ return `C${firstControl[0].toFixed(2)} ${firstControl[1].toFixed(2)} ${secondControl[0].toFixed(2)} ${secondControl[1].toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ })
+ .join('')}Z`
+}
+
+const densifyClosedPoints = (points: Point3[], maximumDistance = 7) =>
+ points.flatMap((point, index) => {
+ const next = points[(index + 1) % points.length]
+ const steps = Math.max(
+ 1,
+ Math.ceil(Math.hypot(next[0] - point[0], next[1] - point[1]) / maximumDistance)
+ )
+ return Array.from({ length: steps }, (_, step) => {
+ const progress = step / steps
+ return [
+ point[0] + (next[0] - point[0]) * progress,
+ point[1] + (next[1] - point[1]) * progress,
+ point[2] + (next[2] - point[2]) * progress,
+ ] as Point3
+ })
+ })
+
+const smoothOpenPath = (points: Point3[]) => {
+ if (!points.length) return ''
+ if (points.length === 1) return `${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`
+ return points
+ .slice(0, -1)
+ .map((point, index) => {
+ const previous = points[Math.max(0, index - 1)]
+ const next = points[index + 1]
+ const afterNext = points[Math.min(points.length - 1, index + 2)]
+ const firstControlX = point[0] + (next[0] - previous[0]) / 6
+ const firstControlY = point[1] + (next[1] - previous[1]) / 6
+ const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
+ const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
+ return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ })
+ .join('')
+}
+
+const projectLocalPoint = (pose: AvatarPose, point: Point3) =>
+ project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
+
+const ringPoints = (width: number, depth: number, y: number) =>
+ Array.from({ length: PRIMITIVE_RING_SAMPLES + 1 }, (_, index) => {
+ const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
+ return [(width / 2) * Math.sin(angle), y, (depth / 2) * Math.cos(angle)] as Point3
+ })
+
+const projectedRoundedPrimitivePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const key = surfaceCacheKey(surface)
+ let localSamples = headSamplesCache.get(key)
+ if (!localSamples) {
+ localSamples = Array.from(
+ { length: ROUNDED_PRIMITIVE_LATITUDE_SAMPLES },
+ (_, latitudeIndex) => {
+ const latitude =
+ -Math.PI / 2 + (latitudeIndex / (ROUNDED_PRIMITIVE_LATITUDE_SAMPLES - 1)) * Math.PI
+ return Array.from({ length: ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
+ const longitude =
+ -Math.PI + (longitudeIndex / (ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
+ return surfacePointAt(surface, longitude, latitude)
+ })
+ }
+ ).flat()
+ cacheSurfaceValue(headSamplesCache, key, localSamples)
+ }
+ const projected = localSamples.map(point => projectLocalPoint(pose, point))
+ return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
+}
+
+const projectedCylinderPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.roundness > 0 || (surface.morphRoundness ?? 0) > 0) {
+ return projectedRoundedPrimitivePath(pose, surface)
+ }
+
+ const halfHeight = surface.height / 2
+ const projected = [
+ ...ringPoints(surface.width, surface.depth, -halfHeight),
+ ...ringPoints(surface.width, surface.depth, halfHeight),
+ ].map(point => projectLocalPoint(pose, point))
+ return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
+}
+
+const projectedCursorBodyPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const layout = cursorLayout(surface)
+ const halfHeight = layout.bodyHeight / 2
+ const projected = [
+ ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY - halfHeight),
+ ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY + halfHeight),
+ ].map(point => projectLocalPoint(pose, point))
+ return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
+}
+
+const projectedCursorConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const layout = cursorLayout(surface)
+ const apex = projectLocalPoint(pose, [0, layout.coneApexY, 0])
+ const base = ringPoints(surface.width, surface.depth, layout.coneBaseY).map(point =>
+ projectLocalPoint(pose, point)
+ )
+ return smoothClosedPath(densifyClosedPoints(convexHull([...base, apex])))
+}
+
+const projectedConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (
+ (surface.morphRoundness ?? 0) > 0 ||
+ (surface.tipRoundness ?? 0) > 0 ||
+ (surface.baseRoundness ?? 0) > 0
+ ) {
+ return projectedRoundedPrimitivePath(pose, surface)
+ }
+
+ const apex = projectLocalPoint(pose, [0, -surface.height / 2, 0])
+ const base = ringPoints(surface.width, surface.depth, surface.height / 2).map(point =>
+ projectLocalPoint(pose, point)
+ )
+ const hull = convexHull([...base, apex])
+ const apexIndex = hull.findIndex(
+ point => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < 0.01
+ )
+ if (apexIndex < 0) return smoothClosedPath(hull)
+
+ const ordered = [...hull.slice(apexIndex), ...hull.slice(0, apexIndex)]
+ const baseArc = ordered.slice(1)
+ if (baseArc.length < 2) return path(hull)
+ return `M${apex[0].toFixed(2)} ${apex[1].toFixed(2)}L${baseArc[0][0].toFixed(2)} ${baseArc[0][1].toFixed(2)}${smoothOpenPath(baseArc)}L${apex[0].toFixed(2)} ${apex[1].toFixed(2)}Z`
+}
+
+const projectedCubePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
+
+ const halfWidth = surface.width / 2
+ const halfHeight = surface.height / 2
+ const halfDepth = surface.depth / 2
+ const vertices = [-1, 1].flatMap(x =>
+ [-1, 1].flatMap(y => [-1, 1].map(z => [x * halfWidth, y * halfHeight, z * halfDepth] as Point3))
+ )
+ return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
+}
+
+const projectedDiamondPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
+
+ const halfWidth = surface.width / 2
+ const halfHeight = surface.height / 2
+ const halfDepth = surface.depth / 2
+ const vertices: Point3[] = [
+ [-halfWidth, 0, 0],
+ [halfWidth, 0, 0],
+ [0, -halfHeight, 0],
+ [0, halfHeight, 0],
+ [0, 0, -halfDepth],
+ [0, 0, halfDepth],
+ ]
+ return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
+}
+
+type ProjectedEllipse = {
+ centerX: number
+ centerY: number
+ majorRadius: number
+ minorRadius: number
+ rotation: number
+}
+
+const ellipseProjection = (
+ centerX: number,
+ centerY: number,
+ covarianceXX: number,
+ covarianceXY: number,
+ covarianceYY: number
+): ProjectedEllipse | null => {
+ const trace = covarianceXX + covarianceYY
+ const difference = Math.hypot(covarianceXX - covarianceYY, covarianceXY * 2)
+ const majorSquared = (trace + difference) / 2
+ const minorSquared = (trace - difference) / 2
+ if (majorSquared <= 0 || minorSquared <= 0) return null
+
+ return {
+ centerX,
+ centerY,
+ majorRadius: Math.sqrt(majorSquared),
+ minorRadius: Math.sqrt(minorSquared),
+ rotation: Math.atan2(covarianceXY * 2, covarianceXX - covarianceYY) / 2,
+ }
+}
+
+const ellipsePath = ({
+ centerX,
+ centerY,
+ majorRadius,
+ minorRadius,
+ rotation,
+}: ProjectedEllipse) => {
+ const rotationDegrees = (rotation * 180) / Math.PI
+ const offsetX = Math.cos(rotation) * majorRadius
+ const offsetY = Math.sin(rotation) * majorRadius
+ const startX = centerX + offsetX
+ const startY = centerY + offsetY
+ const endX = centerX - offsetX
+ const endY = centerY - offsetY
+
+ return `M${startX.toFixed(2)} ${startY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${endX.toFixed(2)} ${endY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${startX.toFixed(2)} ${startY.toFixed(2)}Z`
+}
+
+const projectedEllipsoid = (
+ pose: AvatarPose,
+ axes: Point3,
+ localCenter: Point3 = [0, 0, 0]
+): ProjectedEllipse | null => {
+ const rotatedAxes = [
+ rotateWithQuaternion(pose.orientation, [1, 0, 0]),
+ rotateWithQuaternion(pose.orientation, [0, 1, 0]),
+ rotateWithQuaternion(pose.orientation, [0, 0, 1]),
+ ]
+ const center = rotateWithQuaternion(pose.orientation, localCenter)
+
+ if (Math.abs(pose.expression.perspective) < 0.0001) {
+ const covarianceXX = rotatedAxes.reduce(
+ (total, axis, index) => total + axis[0] * axis[0] * axes[index] * axes[index],
+ 0
+ )
+ const covarianceXY = rotatedAxes.reduce(
+ (total, axis, index) => total + axis[0] * axis[1] * axes[index] * axes[index],
+ 0
+ )
+ const covarianceYY = rotatedAxes.reduce(
+ (total, axis, index) => total + axis[1] * axis[1] * axes[index] * axes[index],
+ 0
+ )
+ return ellipseProjection(center[0], center[1], covarianceXX, covarianceXY, covarianceYY)
+ }
+
+ const inverseAxesSquared = axes.map(axis => 1 / (axis * axis))
+ const quadratic = Array.from({ length: 3 }, (_, row) =>
+ Array.from({ length: 3 }, (_, column) =>
+ rotatedAxes.reduce(
+ (total, axis, index) => total + axis[row] * inverseAxesSquared[index] * axis[column],
+ 0
+ )
+ )
+ )
+ const focalLength = FOCAL_LENGTH / pose.expression.perspective
+ const cameraOffset: Point3 = [-center[0], -center[1], focalLength - center[2]]
+ const cameraNormal: Point3 = [
+ quadratic[0][0] * cameraOffset[0] +
+ quadratic[0][1] * cameraOffset[1] +
+ quadratic[0][2] * cameraOffset[2],
+ quadratic[1][0] * cameraOffset[0] +
+ quadratic[1][1] * cameraOffset[1] +
+ quadratic[1][2] * cameraOffset[2],
+ quadratic[2][0] * cameraOffset[0] +
+ quadratic[2][1] * cameraOffset[1] +
+ quadratic[2][2] * cameraOffset[2],
+ ]
+ const cameraTerm =
+ cameraOffset[0] * cameraNormal[0] +
+ cameraOffset[1] * cameraNormal[1] +
+ cameraOffset[2] * cameraNormal[2] -
+ 1
+ const tangentLinear = [cameraNormal[0], cameraNormal[1], -focalLength * cameraNormal[2]]
+ const rayQuadratic = [
+ [quadratic[0][0], quadratic[0][1], -focalLength * quadratic[0][2]],
+ [quadratic[1][0], quadratic[1][1], -focalLength * quadratic[1][2]],
+ [
+ -focalLength * quadratic[2][0],
+ -focalLength * quadratic[2][1],
+ focalLength * focalLength * quadratic[2][2],
+ ],
+ ]
+ const conic = Array.from({ length: 3 }, (_, row) =>
+ Array.from(
+ { length: 3 },
+ (_, column) =>
+ tangentLinear[row] * tangentLinear[column] - cameraTerm * rayQuadratic[row][column]
+ )
+ )
+ const determinant = conic[0][0] * conic[1][1] - conic[0][1] * conic[0][1]
+ if (Math.abs(determinant) < 1e-12) return null
+
+ const centerX = -(conic[1][1] * conic[0][2] - conic[0][1] * conic[1][2]) / determinant
+ const centerY = (conic[0][1] * conic[0][2] - conic[0][0] * conic[1][2]) / determinant
+ const centeredConstant = conic[2][2] + conic[0][2] * centerX + conic[1][2] * centerY
+ const scale = -centeredConstant
+ if (Math.abs(scale) < 1e-12) return null
+
+ const shapeXX = conic[0][0] / scale
+ const shapeXY = conic[0][1] / scale
+ const shapeYY = conic[1][1] / scale
+ const shapeDeterminant = shapeXX * shapeYY - shapeXY * shapeXY
+ if (shapeDeterminant <= 0) return null
+
+ return ellipseProjection(
+ centerX,
+ centerY,
+ shapeYY / shapeDeterminant,
+ -shapeXY / shapeDeterminant,
+ shapeXX / shapeDeterminant
+ )
+}
+
+const projectedEllipsoidPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const ellipse = projectedEllipsoid(pose, [
+ surface.width / 2,
+ surface.height / 2,
+ surface.depth / 2,
+ ])
+ const isSphere = surface.width === surface.height && surface.height === surface.depth
+ if (ellipse && isSphere) {
+ const radius = (ellipse.majorRadius + ellipse.minorRadius) / 2
+ return ellipsePath({
+ centerX: 0,
+ centerY: 0,
+ majorRadius: radius,
+ minorRadius: radius,
+ rotation: 0,
+ })
+ }
+ return ellipse ? ellipsePath(ellipse) : null
+}
+
+const mickeyEarPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.type !== 'mickey') return []
+
+ const radius = Math.min(surface.width, surface.height) * 0.23
+ const depthRadius = Math.min(radius, surface.depth * 0.29)
+ const centerX = surface.width * 0.37
+ const centerY = -surface.height * 0.39
+ const centerZ = -surface.depth * 0.12
+ const axes: Point3 = [radius, radius, depthRadius]
+ return [-1, 1]
+ .map(side => projectedEllipsoid(pose, axes, [side * centerX, centerY, centerZ]))
+ .filter((ear): ear is ProjectedEllipse => ear !== null)
+ .map(ellipsePath)
+}
+
+const compositeBackPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.type === 'mickey') return mickeyEarPaths(pose, surface)
+ if (surface.type === 'cursor') return [projectedCursorConePath(pose, surface)]
+ return []
+}
+
+const ellipsePoints = (ellipse: ProjectedEllipse) =>
+ Array.from({ length: PRIMITIVE_RING_SAMPLES }, (_, index) => {
+ const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
+ const major = Math.cos(angle) * ellipse.majorRadius
+ const minor = Math.sin(angle) * ellipse.minorRadius
+ return [
+ ellipse.centerX + major * Math.cos(ellipse.rotation) - minor * Math.sin(ellipse.rotation),
+ ellipse.centerY + major * Math.sin(ellipse.rotation) + minor * Math.cos(ellipse.rotation),
+ 0,
+ ] as Point3
+ })
+
+const smoothHullPath = (points: Point3[]) => {
+ if (points.length < 3) return path(points)
+ const distances = points.map((point, index) => {
+ const next = points[(index + 1) % points.length]
+ return Math.hypot(next[0] - point[0], next[1] - point[1])
+ })
+ const sortedDistances = [...distances].sort((left, right) => left - right)
+ const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] || 1
+ const straightThreshold = Math.max(8, medianDistance * 3.5)
+ const straightEdges = distances.map(distance => distance > straightThreshold)
+
+ return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
+ .map((point, index) => {
+ const nextIndex = (index + 1) % points.length
+ const next = points[nextIndex]
+ if (straightEdges[index]) return `L${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ const previous = straightEdges[(index - 1 + points.length) % points.length]
+ ? point
+ : points[(index - 1 + points.length) % points.length]
+ const afterNext = straightEdges[nextIndex] ? next : points[(index + 2) % points.length]
+ const firstControlX = point[0] + (next[0] - previous[0]) / 6
+ const firstControlY = point[1] + (next[1] - previous[1]) / 6
+ const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
+ const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
+ return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
+ })
+ .join('')}Z`
+}
+
+const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ const radiusX = surface.width / 2
+ const radiusY = Math.min(radiusX, surface.height / 2)
+ const radiusZ = surface.depth / 2
+ const straightHalf = Math.max(0, (surface.height - radiusY * 2) / 2)
+ const axes: Point3 = [radiusX, radiusY, radiusZ]
+ const top = projectedEllipsoid(pose, axes, [0, straightHalf, 0])
+ const bottom = projectedEllipsoid(pose, axes, [0, -straightHalf, 0])
+ if (!top || !bottom) return null
+ return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)]))
+}
+
+const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
+ if (surface.type === 'sphere' || surface.type === 'mickey') {
+ const exactPath = projectedEllipsoidPath(pose, surface)
+ if (exactPath) return exactPath
+ }
+
+ if (surface.type === 'capsule') {
+ const exactPath = projectedCapsulePath(pose, surface)
+ if (exactPath) return exactPath
+ }
+
+ if (surface.type === 'cylinder') return projectedCylinderPath(pose, surface)
+ if (surface.type === 'cursor') return projectedCursorBodyPath(pose, surface)
+ if (surface.type === 'cone') return projectedConePath(pose, surface)
+ if (surface.type === 'cube') return projectedCubePath(pose, surface)
+ if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
+
+ const key = surfaceCacheKey(surface)
+ let localSamples = headSamplesCache.get(key)
+ if (!localSamples) {
+ localSamples = Array.from({ length: HEAD_LATITUDE_SAMPLES }, (_, latitudeIndex) => {
+ const latitude = -Math.PI / 2 + (latitudeIndex / (HEAD_LATITUDE_SAMPLES - 1)) * Math.PI
+ return Array.from({ length: HEAD_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
+ const longitude = -Math.PI + (longitudeIndex / (HEAD_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
+ return surfacePointAt(surface, longitude, latitude)
+ })
+ }).flat()
+ cacheSurfaceValue(headSamplesCache, key, localSamples)
+ }
+ const projectedSamples = localSamples.map(sample =>
+ project(rotateWithQuaternion(pose.orientation, sample), pose.expression.perspective)
+ )
+ return path(convexHull(projectedSamples))
+}
+
+const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
+ const key = surfaceCacheKey(node.surface)
+ let localSamples = accessorySamplesCache.get(key)
+ if (!localSamples) {
+ localSamples = Array.from({ length: 17 }, (_, latitudeIndex) => {
+ const latitude = -Math.PI / 2 + (latitudeIndex / 16) * Math.PI
+ return Array.from({ length: 49 }, (_, longitudeIndex) => {
+ const longitude = -Math.PI + (longitudeIndex / 48) * Math.PI * 2
+ return surfacePointAt(node.surface, longitude, latitude)
+ })
+ }).flat()
+ cacheSurfaceValue(accessorySamplesCache, key, localSamples)
+ }
+
+ const localOrientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const projected = localSamples.map(point => {
+ const locallyRotated = rotateWithQuaternion(localOrientation, point)
+ const positioned: Point3 = [
+ locallyRotated[0] + node.position[0],
+ locallyRotated[1] + node.position[1],
+ locallyRotated[2] + node.position[2],
+ ]
+ return project(rotateWithQuaternion(pose.orientation, positioned), pose.expression.perspective)
+ })
+ const hull = convexHull(projected)
+ if (
+ (node.surface.type === 'cube' || node.surface.type === 'diamond') &&
+ node.surface.roundness <= 0
+ ) {
+ return path(hull)
+ }
+ return smoothClosedPath(densifyClosedPoints(hull))
+}
+
+const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
+
+const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
+ const localOrientation = quaternionFromEuler(
+ radians(node.rotation[0]),
+ radians(node.rotation[1]),
+ radians(node.rotation[2])
+ )
+ const cameraDepthByAxis = (
+ [
+ [1, 0, 0],
+ [0, 1, 0],
+ [0, 0, 1],
+ ] as Point3[]
+ ).map(
+ axis => rotateWithQuaternion(pose.orientation, rotateWithQuaternion(localOrientation, axis))[2]
+ )
+ return Math.hypot(
+ cameraDepthByAxis[0] * (node.surface.width / 2),
+ cameraDepthByAxis[1] * (node.surface.height / 2),
+ cameraDepthByAxis[2] * (node.surface.depth / 2)
+ )
+}
+
+const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
+ const layers = nodes
+ .map(node => {
+ const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
+ return {
+ id: node.id,
+ path: accessoryPath(pose, node),
+ depth,
+ front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO,
+ }
+ })
+ .sort((left, right) => left.depth - right.depth)
+ return {
+ backPaths: layers.filter(layer => !layer.front).map(layer => layer.path),
+ frontPaths: layers.filter(layer => layer.front).map(layer => layer.path),
+ backNodeIds: layers.filter(layer => !layer.front).map(layer => layer.id),
+ frontNodeIds: layers.filter(layer => layer.front).map(layer => layer.id),
+ }
+}
+
+export const renderAvatar = (
+ pose: AvatarPose,
+ surface: SurfaceConfig,
+ blink = 1,
+ options: RenderAvatarOptions = {}
+): AvatarGeometry => {
+ const leftSamples = eyePoints(pose, surface, -1, blink, options.eyeOffset)
+ const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
+ const left = leftSamples.map(sample => sample.point)
+ const right = rightSamples.map(sample => sample.point)
+ const accessories = accessoryLayers(pose, options.bodyNodes ?? [])
+ const compositePaths = compositeBackPaths(pose, surface)
+ return {
+ backPaths: [...compositePaths, ...accessories.backPaths],
+ frontPaths: accessories.frontPaths,
+ backNodeIds: [...compositePaths.map(() => null), ...accessories.backNodeIds],
+ frontNodeIds: accessories.frontNodeIds,
+ headPath: headPath(pose, surface),
+ leftPath: path(left),
+ rightPath: path(right),
+ leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
+ rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
+ wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
+ }
+}
diff --git a/packages/avatar-core/src/index.ts b/packages/avatar-core/src/index.ts
new file mode 100644
index 0000000..4886d86
--- /dev/null
+++ b/packages/avatar-core/src/index.ts
@@ -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'
diff --git a/packages/avatar-core/src/runtime.ts b/packages/avatar-core/src/runtime.ts
new file mode 100644
index 0000000..65522c1
--- /dev/null
+++ b/packages/avatar-core/src/runtime.ts
@@ -0,0 +1,348 @@
+import {
+ type AnimationKey,
+ type AvatarAnimationDefinition,
+ type AvatarDefinition,
+ type AvatarExpressionDefinition,
+ type ExpressionKey,
+} from './avatarDefinition'
+import { applyAmbientMotion } from './ambientMotion'
+import { interpolatePose, poseFromExpression, type Expression } from './geometry'
+import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
+
+export type AvatarRuntimeError = {
+ code: 'unknown_animation' | 'unknown_expression'
+ key: string
+ message: string
+}
+
+export type AvatarCommandResult =
+ { ok: true; value: T } | { ok: false; error: AvatarRuntimeError }
+
+export const resolveExpression = (
+ definition: Readonly,
+ key: ExpressionKey
+): AvatarCommandResult> => {
+ 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,
+ key: AnimationKey
+): AvatarCommandResult> => {
+ const explicit = definition.animations[key]
+ return explicit
+ ? { ok: true, value: explicit }
+ : {
+ ok: false,
+ error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
+ }
+}
+
+export type AvatarPlaybackState = {
+ activeAnimation?: AnimationKey
+ activeExpression: ExpressionKey
+ status: 'playing' | 'paused' | 'stopped'
+ stepIndex: number
+ direction: 1 | -1
+ phase: 'transition' | 'hold'
+ phaseStartedAt: number
+ transitionFrom: ExpressionKey
+ transitionSnapshot?: AvatarFrameSnapshot
+ pausedAt?: number
+ blinkDueAt?: number
+ blinkStartedAt?: number
+ directTransition?: {
+ from: AvatarFrameSnapshot
+ startedAt: number
+ durationMs: number
+ transition: AvatarAnimationDefinition['steps'][number]['transition']
+ }
+}
+
+export type AvatarFrameSnapshot = {
+ expression: Expression
+ colors: AvatarScene['colors']
+}
+
+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,
+ key: AnimationKey,
+ now: number,
+ from?: AvatarFrameSnapshot
+): AvatarCommandResult => {
+ 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',
+ ...(from ? { transitionSnapshot: from } : {}),
+ blinkDueAt: now + result.value.blink.initialDelayMs,
+ },
+ }
+}
+
+const nextCursor = (
+ animation: Readonly,
+ 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,
+ state: Readonly,
+ now: number,
+ environment: AvatarRuntimeEnvironment
+): AvatarPlaybackState => {
+ if (state.directTransition) {
+ if (now < state.directTransition.startedAt + state.directTransition.durationMs) {
+ return { ...state }
+ }
+ const { directTransition: _directTransition, ...next } = state
+ return { ...next, status: 'stopped' }
+ }
+ 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
+ delete next.transitionSnapshot
+ next.activeExpression = animation.steps[cursor.stepIndex].expression
+ }
+ return next
+}
+
+export const pauseAvatarPlayback = (
+ state: Readonly,
+ now: number
+): AvatarPlaybackState =>
+ state.status === 'playing' ? { ...state, status: 'paused', pausedAt: now } : { ...state }
+
+export const resumeAvatarPlayback = (
+ state: Readonly,
+ 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.directTransition
+ ? {
+ directTransition: {
+ ...state.directTransition,
+ startedAt: state.directTransition.startedAt + 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))
+}
+
+const expressionColors = (
+ definition: Readonly,
+ expression: Readonly
+): AvatarScene['colors'] => ({
+ body: expression.colors?.body ?? definition.colors.body,
+ eyes: expression.colors?.eyes ?? definition.colors.eyes,
+})
+
+const interpolateHexColor = (from: string, to: string, progress: number) => {
+ const parse = (color: string) => {
+ const value = color.slice(1)
+ const expanded = value.length === 3 ? [...value].map(part => `${part}${part}`).join('') : value
+ return [0, 2, 4].map(index => Number.parseInt(expanded.slice(index, index + 2), 16))
+ }
+ const source = parse(from)
+ const target = parse(to)
+ if (source.some(Number.isNaN) || target.some(Number.isNaN)) return progress < 1 ? from : to
+ return `#${source
+ .map((value, index) => Math.round(value + (target[index] - value) * progress))
+ .map(value => value.toString(16).padStart(2, '0'))
+ .join('')}`
+}
+
+const interpolateColors = (
+ from: AvatarScene['colors'],
+ to: AvatarScene['colors'],
+ progress: number
+): AvatarScene['colors'] => ({
+ body: interpolateHexColor(from.body, to.body, progress),
+ eyes: interpolateHexColor(from.eyes, to.eyes, progress),
+})
+
+export const blinkOpacityAt = (
+ animation: Readonly,
+ state: Readonly,
+ 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 sampleAvatarFrame = (
+ definition: Readonly,
+ state: Readonly,
+ now: number,
+ environment: AvatarRuntimeEnvironment
+): AvatarFrameSnapshot & { blink: number; sampledAt: number } => {
+ const sampledAt = state.status === 'paused' && state.pausedAt !== undefined ? state.pausedAt : now
+ const targetDefinition = definition.expressions[state.activeExpression]
+ if (!targetDefinition) {
+ const neutral = definition.expressions.neutral
+ return {
+ expression: expressionFromDefinition('neutral', neutral),
+ colors: expressionColors(definition, neutral),
+ blink: 1,
+ sampledAt,
+ }
+ }
+ let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
+ let colors = expressionColors(definition, targetDefinition)
+ let blink = 1
+ if (state.directTransition && !environment.reduceMotion) {
+ const progress = easing(
+ state.directTransition.transition,
+ (sampledAt - state.directTransition.startedAt) /
+ Math.max(state.directTransition.durationMs, 1)
+ )
+ expression = interpolatePose(
+ poseFromExpression(state.directTransition.from.expression),
+ poseFromExpression(expression),
+ progress
+ ).expression
+ colors = interpolateColors(state.directTransition.from.colors, colors, progress)
+ } else 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]
+ const from =
+ state.transitionSnapshot?.expression ??
+ (fromDefinition
+ ? expressionFromDefinition(state.transitionFrom, fromDefinition)
+ : undefined)
+ const fromColors =
+ state.transitionSnapshot?.colors ??
+ (fromDefinition ? expressionColors(definition, fromDefinition) : undefined)
+ if (from && fromColors) {
+ const duration = Math.max(step.transitionMs, 1)
+ const progress = easing(step.transition, (sampledAt - state.phaseStartedAt) / duration)
+ expression = interpolatePose(
+ poseFromExpression(from),
+ poseFromExpression(expression),
+ progress
+ ).expression
+ colors = interpolateColors(fromColors, colors, progress)
+ }
+ }
+ blink = blinkOpacityAt(resolved.value, state, sampledAt)
+ }
+ }
+ return { expression, colors, blink, sampledAt }
+}
+
+export const renderAvatarFrame = (
+ definition: Readonly,
+ state: Readonly,
+ now: number,
+ environment: AvatarRuntimeEnvironment
+): AvatarScene => {
+ const frame = sampleAvatarFrame(definition, state, now, environment)
+ const expression = environment.reduceMotion
+ ? frame.expression
+ : applyAmbientMotion(frame.expression, frame.sampledAt)
+ return renderAvatarExpression(definition, expression, frame.colors, frame.blink)
+}
diff --git a/packages/avatar-core/src/scene.ts b/packages/avatar-core/src/scene.ts
new file mode 100644
index 0000000..f1746c1
--- /dev/null
+++ b/packages/avatar-core/src/scene.ts
@@ -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,
+ 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,
+ 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)
+}
diff --git a/packages/avatar-core/src/surfaces.ts b/packages/avatar-core/src/surfaces.ts
new file mode 100644
index 0000000..98295fd
--- /dev/null
+++ b/packages/avatar-core/src/surfaces.ts
@@ -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 = {
+ 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 = {
+ 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),
+ }
+}
diff --git a/packages/avatar-core/tsconfig.build.json b/packages/avatar-core/tsconfig.build.json
new file mode 100644
index 0000000..738a64e
--- /dev/null
+++ b/packages/avatar-core/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"]
+}
diff --git a/packages/avatar-core/tsconfig.json b/packages/avatar-core/tsconfig.json
new file mode 100644
index 0000000..10fca9b
--- /dev/null
+++ b/packages/avatar-core/tsconfig.json
@@ -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__/**"]
+}
diff --git a/packages/avatar-core/vite.config.ts b/packages/avatar-core/vite.config.ts
new file mode 100644
index 0000000..aa73982
--- /dev/null
+++ b/packages/avatar-core/vite.config.ts
@@ -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))
+ )
+ },
+ },
+ ],
+})
diff --git a/packages/avatar-react/LICENSE b/packages/avatar-react/LICENSE
new file mode 100644
index 0000000..be3f7b2
--- /dev/null
+++ b/packages/avatar-react/LICENSE
@@ -0,0 +1,661 @@
+ GNU AFFERO GENERAL PUBLIC LICENSE
+ Version 3, 19 November 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ 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.
+
+
+ Copyright (C)
+
+ 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 .
+
+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
+ .
diff --git a/packages/avatar-react/README.md b/packages/avatar-react/README.md
new file mode 100644
index 0000000..52008a5
--- /dev/null
+++ b/packages/avatar-react/README.md
@@ -0,0 +1,125 @@
+# @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(null)
+ return (
+ <>
+
+ avatar.current?.play('happy')}>Play happy
+ avatar.current?.setExpression('neutral')}>Neutral
+ >
+ )
+}
+```
+
+For a reusable component tied to one JSON definition, use `createAvatar`. It validates the
+definition once and, when the JSON is statically typed, narrows `animation`, `defaultAnimation`,
+`expression` and `defaultExpression` to the semantic keys present in that definition:
+
+```tsx
+import { createAvatar } from '@bible-strong/avatar-react'
+import avatarJson from './strobi.avatar.json'
+
+const StrobiAvatar = createAvatar(avatarJson)
+
+export function Strobi() {
+ return
+}
+```
+
+Definitions fetched at runtime are validated by the same factory, but their keys are necessarily
+checked at runtime rather than inferred by TypeScript.
+
+`play` and `setExpression` return `{ ok: true }` or a typed error with one of
+`unknown_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`.
+
+## Props reference
+
+`Avatar` exposes typed props for the definition, playback state and presentation. `AnimationKey`
+and `ExpressionKey` are semantic string keys from the supplied definition.
+
+### Definition and playback
+
+| Prop | Type | Default | Behavior |
+| ------------------- | ------------------------------------ | -------- | -------------------------------------------------------------------------------------------------------------------------- |
+| `definition` | `AvatarDefinition` | required | Validated JSON definition containing the expressions and animations to render. |
+| `animation` | `AnimationKey \| undefined` | — | Controlled timeline. Each step chooses the displayed expression. Mutually exclusive with `expression`. |
+| `expression` | `ExpressionKey \| undefined` | — | Controlled direct expression. Mutually exclusive with `animation`. |
+| `defaultAnimation` | `AnimationKey \| undefined` | — | Initial uncontrolled timeline, read on mount. Autoplay is enabled by default. Mutually exclusive with `defaultExpression`. |
+| `defaultExpression` | `ExpressionKey \| undefined` | — | Initial uncontrolled expression, read on mount without starting a timeline. Mutually exclusive with `defaultAnimation`. |
+| `autoplay` | `boolean \| undefined` | `true` | Starts `defaultAnimation` automatically. It has no effect without `defaultAnimation`. |
+| `ref` | `Ref \| undefined` | — | Exposes the imperative API described below. |
+
+`animation` and `expression` are two alternative sources of truth. Passing both throws an error;
+the component never silently overrides one with the other. A controlled target takes priority over
+an uncontrolled default when they are intentionally mixed.
+
+### Presentation
+
+| Prop | Type | Default | Behavior |
+| ----------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------- |
+| `size` | `number \| string \| undefined` | `240` | Number or CSS value applied to the wrapper width and height. |
+| `className` | `string \| undefined` | — | CSS class added to the outer wrapper. |
+| `style` | `CSSProperties \| undefined` | — | Inline styles for the outer wrapper. `width` and `height` come from `size`. |
+| `ariaLabel` | `string \| undefined` | `Procedural avatar` | Accessible name announced to screen readers. |
+
+### Playback callbacks
+
+| Prop | Type | Receives |
+| -------------------- | ------------------------------------- | ---------------------------------------------------------------------- |
+| `onAnimationEnd` | `(animation: AnimationKey) => void` | The key of a `once` animation when it completes naturally. |
+| `onExpressionChange` | `(expression: ExpressionKey) => void` | The semantic expression key whenever the displayed expression changes. |
+| `onError` | `(error: AvatarRuntimeError) => void` | An unknown animation or expression key supplied through props. |
+
+Unknown keys passed through `animation`, `expression`, `defaultAnimation` or `defaultExpression`
+are reported to `onError`. Without an error handler, the component writes the typed runtime error
+to the developer console instead of failing silently.
+
+## Imperative API
+
+Pass a `ref` to receive an `AvatarController`. Use it when buttons, events or another imperative
+source need to drive an uncontrolled avatar:
+
+| Method | Type | Behavior |
+| --------------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
+| `play(animation)` | `(animation: AnimationKey) => AvatarCommandResult` | Starts an animation or resumes it from its paused position. |
+| `pause()` | `() => void` | Freezes the exact timeline position. |
+| `stop()` | `() => void` | Stops playback and resets an uncontrolled avatar to `neutral`. |
+| `setExpression(expression)` | `(expression: ExpressionKey) => AvatarCommandResult` | Shows one expression directly. |
+| `getState()` | `() => AvatarPlaybackState` | Returns `activeAnimation?`, `activeExpression` and `status`. |
+
+`play` and `setExpression` return `{ ok: true }` or `{ ok: false, error }`. Errors include
+`unknown_animation`, `unknown_expression` and `controlled_by_props`. When `animation` or
+`expression` is controlled by props, use those props to change the target; imperative target
+commands cannot replace the parent value.
+
+The definition is validated once per immutable object reference and revalidated/reinitialized when
+that reference changes.
+
+## Styling hooks
+
+The stylesheet exposes `.bs-avatar` and `.bs-avatar__svg`. Consumer `className` and `style` are
+applied to the outer wrapper.
+
+The public component never exposes Studio IDs or document types. The package follows Semantic
+Versioning. While it remains below `1.0.0`, breaking API changes increment the minor version and
+fixes increment the patch version.
diff --git a/packages/avatar-react/package.json b/packages/avatar-react/package.json
new file mode 100644
index 0000000..411dada
--- /dev/null
+++ b/packages/avatar-react/package.json
@@ -0,0 +1,65 @@
+{
+ "name": "@bible-strong/avatar-react",
+ "version": "0.1.0",
+ "description": "React 19 renderer for Bible Strong procedural avatars.",
+ "keywords": [
+ "avatar",
+ "svg",
+ "animation",
+ "react",
+ "typescript"
+ ],
+ "author": "Stéphane Montlouis-Calixte",
+ "license": "AGPL-3.0-only",
+ "homepage": "https://github.com/smontlouis/bible-strong-avatar-lab#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/smontlouis/bible-strong-avatar-lab.git",
+ "directory": "packages/avatar-react"
+ },
+ "bugs": {
+ "url": "https://github.com/smontlouis/bible-strong-avatar-lab/issues"
+ },
+ "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",
+ "prepack": "pnpm build",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "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"
+ }
+}
diff --git a/packages/avatar-react/src/Avatar.tsx b/packages/avatar-react/src/Avatar.tsx
new file mode 100644
index 0000000..9cbe0b0
--- /dev/null
+++ b/packages/avatar-react/src/Avatar.tsx
@@ -0,0 +1,509 @@
+import {
+ advanceAvatarPlayback,
+ createAvatarPlaybackState,
+ MAX_BODY_NODES,
+ playAvatarAnimation,
+ pauseAvatarPlayback,
+ renderAvatarDefinition,
+ renderAvatarFrame,
+ resolveAnimation,
+ resolveExpression,
+ resumeAvatarPlayback,
+ sampleAvatarFrame,
+ validateAvatarDefinition,
+ type AnimationKey,
+ type AvatarDefinition,
+ type AvatarPlaybackState as CorePlaybackState,
+ type AvatarRuntimeError as CoreRuntimeError,
+ type ExpressionKey,
+} from '@bible-strong/avatar-core'
+import {
+ useEffect,
+ useId,
+ useImperativeHandle,
+ useLayoutEffect,
+ useRef,
+ useState,
+ type CSSProperties,
+ type ReactElement,
+ type Ref,
+} from 'react'
+
+import './styles.css'
+
+const validatedDefinitions = new WeakSet()
+const controlledExpressionTransitionMs = 420
+const bodyPathSlots = MAX_BODY_NODES + 2
+
+const runtimeEnvironment = () => ({
+ random: Math.random,
+ reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
+})
+
+export const markAvatarDefinitionValidated = (definition: object) => {
+ validatedDefinitions.add(definition)
+}
+
+const assertValidDefinition = (definition: AvatarDefinition) => {
+ 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 AvatarProps = {
+ definition: AvatarDefinition
+ ref?: Ref
+ /** Controlled animation timeline. Mutually exclusive with `expression`. */
+ animation?: AnimationKey
+ /** Controlled expression target. Mutually exclusive with `animation`. */
+ expression?: ExpressionKey
+ /** Uncontrolled initial animation. Mutually exclusive with `defaultExpression`. */
+ defaultAnimation?: AnimationKey
+ /** Uncontrolled initial expression. Mutually exclusive with `defaultAnimation`. */
+ defaultExpression?: ExpressionKey
+ autoplay?: boolean
+ size?: number | string
+ className?: string
+ style?: CSSProperties
+ ariaLabel?: string
+ /** Receives invalid animation or expression targets supplied through props. */
+ onError?: (error: AvatarRuntimeError) => 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 &&
+ left.transitionSnapshot === right.transitionSnapshot &&
+ left.directTransition?.from === right.directTransition?.from &&
+ left.directTransition?.startedAt === right.directTransition?.startedAt &&
+ left.directTransition?.durationMs === right.directTransition?.durationMs &&
+ left.directTransition?.transition === right.directTransition?.transition
+
+const assertPlaybackProps = ({
+ animation,
+ expression,
+ defaultAnimation,
+ defaultExpression,
+}: Pick) => {
+ if (animation !== undefined && expression !== undefined) {
+ throw new Error(
+ 'Avatar accepts either animation or expression, not both. Animation controls a timeline; expression controls a single target.'
+ )
+ }
+ if (defaultAnimation !== undefined && defaultExpression !== undefined) {
+ throw new Error(
+ 'Avatar accepts either defaultAnimation or defaultExpression, not both. Choose one uncontrolled initial target.'
+ )
+ }
+}
+
+const createInitialPlayback = (
+ definition: AvatarDefinition,
+ animation: AnimationKey | undefined,
+ expression: ExpressionKey | undefined,
+ defaultAnimation: AnimationKey | undefined,
+ defaultExpression: ExpressionKey | undefined
+): CorePlaybackState => {
+ const animationKey = animation ?? (expression === undefined ? defaultAnimation : undefined)
+ if (animationKey) {
+ const result = resolveAnimation(definition, animationKey)
+ if (result.ok) {
+ return {
+ ...createAvatarPlaybackState(),
+ activeExpression: result.value.steps[0]?.expression ?? 'neutral',
+ }
+ }
+ }
+ const expressionKey =
+ expression ?? (animation === undefined ? defaultExpression : undefined) ?? 'neutral'
+ const resolved = resolveExpression(definition, expressionKey)
+ return {
+ ...createAvatarPlaybackState(),
+ activeExpression: resolved.ok ? expressionKey : 'neutral',
+ }
+}
+
+export function Avatar({
+ definition,
+ ref,
+ animation,
+ expression,
+ defaultAnimation,
+ defaultExpression,
+ autoplay,
+ size = 240,
+ className,
+ style,
+ ariaLabel = 'Procedural avatar',
+ onError,
+ onAnimationEnd,
+ onExpressionChange,
+}: AvatarProps): ReactElement {
+ assertPlaybackProps({ animation, expression, defaultAnimation, defaultExpression })
+ assertValidDefinition(definition)
+
+ const clipId = `${useId().replaceAll(':', '')}-head`
+ const clipPathRef = useRef(null)
+ const headPathRef = useRef(null)
+ const leftPathRef = useRef(null)
+ const rightPathRef = useRef(null)
+ const backPathRefs = useRef<(SVGPathElement | null)[]>([])
+ const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
+ const defaultPlaybackStarted = useRef(false)
+ const completedAnimation = useRef(undefined)
+ const playbackRef = useRef(null)
+ const paintedFrameRef = useRef | null>(null)
+ const previousDefinitionRef = useRef(definition)
+ const [playback, setPlayback] = useState(() =>
+ createInitialPlayback(definition, animation, expression, defaultAnimation, defaultExpression)
+ )
+
+ const paintScene = (frameScene: ReturnType) => {
+ 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')
+ backPathRefs.current.forEach((element, index) => {
+ element?.setAttribute('d', frameScene.geometry.backPaths[index] ?? '')
+ element?.setAttribute('fill', frameScene.colors.body)
+ })
+ frontPathRefs.current.forEach((element, index) => {
+ element?.setAttribute('d', frameScene.geometry.frontPaths[index] ?? '')
+ element?.setAttribute('fill', frameScene.colors.body)
+ })
+ }
+
+ const renderPlaybackFrame = (
+ current: Readonly,
+ now: number,
+ environment: ReturnType
+ ) => {
+ paintedFrameRef.current = sampleAvatarFrame(definition, current, now, environment)
+ return renderAvatarFrame(definition, current, now, environment)
+ }
+
+ useLayoutEffect(() => {
+ const current = playbackRef.current ?? playback
+ const now = performance.now()
+ const environment = runtimeEnvironment()
+ paintScene(renderPlaybackFrame(current, now, environment))
+ })
+
+ useEffect(() => {
+ playbackRef.current = playback
+ }, [playback])
+
+ useEffect(() => {
+ if (previousDefinitionRef.current === definition) return
+ previousDefinitionRef.current = definition
+ paintedFrameRef.current = null
+ defaultPlaybackStarted.current = false
+ completedAnimation.current = undefined
+ const next = createInitialPlayback(
+ definition,
+ animation,
+ expression,
+ defaultAnimation,
+ defaultExpression
+ )
+ playbackRef.current = next
+ setPlayback(next)
+ }, [animation, defaultAnimation, defaultExpression, definition, expression])
+
+ useEffect(() => {
+ if (animation !== undefined || expression !== undefined) return
+ const result = defaultAnimation
+ ? resolveAnimation(definition, defaultAnimation)
+ : defaultExpression
+ ? resolveExpression(definition, defaultExpression)
+ : null
+ if (result && !result.ok) {
+ if (onError) onError(result.error)
+ else console.error(`[Avatar] ${result.error.message}`)
+ }
+ }, [animation, defaultAnimation, defaultExpression, definition, expression, onError])
+
+ 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) {
+ playbackRef.current = result.value
+ setPlayback(result.value)
+ }
+ }, [animation, autoplay, defaultAnimation, definition, expression])
+
+ useEffect(() => {
+ if (expression !== undefined) {
+ const resolved = resolveExpression(definition, expression)
+ if (resolved.ok) {
+ const current = playbackRef.current ?? createAvatarPlaybackState()
+ const now = performance.now()
+ const from =
+ paintedFrameRef.current ??
+ sampleAvatarFrame(definition, current, now, runtimeEnvironment())
+ const next = {
+ ...createAvatarPlaybackState(),
+ activeExpression: expression,
+ ...(current.activeExpression === expression
+ ? {}
+ : {
+ status: 'playing' as const,
+ directTransition: {
+ from,
+ startedAt: now,
+ durationMs: controlledExpressionTransitionMs,
+ transition: 'smooth' as const,
+ },
+ }),
+ }
+ playbackRef.current = next
+ setPlayback(next)
+ } else if (onError) onError(resolved.error)
+ else console.error(`[Avatar] ${resolved.error.message}`)
+ return
+ }
+ if (animation !== undefined) {
+ const current = playbackRef.current ?? createAvatarPlaybackState()
+ const now = performance.now()
+ const from =
+ paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
+ const result = playAvatarAnimation(definition, animation, now, from)
+ if (result.ok) {
+ playbackRef.current = result.value
+ setPlayback(result.value)
+ } else if (onError) onError(result.error)
+ else console.error(`[Avatar] ${result.error.message}`)
+ }
+ }, [animation, definition, expression, onError])
+
+ useEffect(() => {
+ onExpressionChange?.(playback.activeExpression)
+ }, [playback.activeExpression, onExpressionChange])
+
+ useEffect(() => {
+ if (playback.status !== 'playing') return
+ let frame = 0
+ const tick = (now: number) => {
+ const current = playbackRef.current
+ if (!current) return
+ const environment = runtimeEnvironment()
+ const next = advanceAvatarPlayback(definition, current, now, environment)
+ 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 = renderPlaybackFrame(next, now, environment)
+ paintScene(frameScene)
+ 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 ?? createAvatarPlaybackState()
+ 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 now = performance.now()
+ const from =
+ paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
+ const result = playAvatarAnimation(definition, key, now, from)
+ if (!result.ok) return { ok: false, error: result.error }
+ 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 current = playbackRef.current ?? createAvatarPlaybackState()
+ const now = performance.now()
+ const from =
+ paintedFrameRef.current ?? sampleAvatarFrame(definition, current, now, runtimeEnvironment())
+ const next = {
+ ...createAvatarPlaybackState(),
+ activeExpression: key,
+ ...(current.activeExpression === key
+ ? {}
+ : {
+ status: 'playing' as const,
+ directTransition: {
+ from,
+ startedAt: now,
+ durationMs: controlledExpressionTransitionMs,
+ transition: 'smooth' as const,
+ },
+ }),
+ }
+ playbackRef.current = next
+ setPlayback(next)
+ return { ok: true }
+ },
+ pause() {
+ const current = playbackRef.current
+ if (!current || 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 ?? createAvatarPlaybackState()
+ return {
+ ...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
+ activeExpression: current.activeExpression,
+ status: current.status,
+ }
+ },
+ }))
+
+ const scene = renderAvatarDefinition(definition)
+ return (
+
+
+
+
+
+
+
+ {Array.from({ length: bodyPathSlots }, (_, index) => (
+ {
+ backPathRefs.current[index] = element
+ }}
+ d={scene.geometry.backPaths[index] ?? ''}
+ fill={scene.colors.body}
+ key={`back-${index}`}
+ />
+ ))}
+
+
+
+
+
+ {Array.from({ length: bodyPathSlots }, (_, index) => (
+ {
+ frontPathRefs.current[index] = element
+ }}
+ d={scene.geometry.frontPaths[index] ?? ''}
+ fill={scene.colors.body}
+ key={`front-${index}`}
+ />
+ ))}
+
+
+ )
+}
diff --git a/packages/avatar-react/src/__tests__/avatar-test.tsx b/packages/avatar-react/src/__tests__/avatar-test.tsx
new file mode 100644
index 0000000..4e92a33
--- /dev/null
+++ b/packages/avatar-react/src/__tests__/avatar-test.tsx
@@ -0,0 +1,327 @@
+// @vitest-environment jsdom
+
+import { type AvatarDefinition } from '@bible-strong/avatar-core'
+import { act, createRef, Profiler, StrictMode } from 'react'
+import { render } from '@testing-library/react'
+import { vi } from 'vitest'
+
+import { Avatar, type AvatarController } from '../Avatar'
+import { createAvatar } from '../createAvatar'
+
+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'],
+}
+
+beforeAll(() => {
+ window.matchMedia = () =>
+ ({
+ matches: false,
+ addEventListener: () => undefined,
+ removeEventListener: () => undefined,
+ }) as unknown as MediaQueryList
+})
+
+describe('@bible-strong/avatar-react', () => {
+ it('creates a validated concrete component from a definition', () => {
+ const ConcreteAvatar = createAvatar(definition)
+ const view = render( )
+
+ expect(view.getByRole('img', { name: 'Concrete avatar' })).toBeTruthy()
+ })
+
+ it('rejects invalid definitions before creating a component', () => {
+ expect(() => createAvatar({})).toThrow('Invalid avatar definition')
+ })
+
+ it('renders semantic SVG geometry', () => {
+ const view = render( )
+ const avatar = view.getByRole('img', { name: 'Assistant avatar' })
+ expect(avatar.classList.contains('bs-avatar')).toBe(true)
+ expect(avatar.className).toBe('bs-avatar')
+ expect(avatar.querySelector('svg path')).not.toBeNull()
+ })
+
+ it('keeps stable SVG layer slots for nodes moving in front of or behind the head', () => {
+ const view = render( )
+ const svg = view.getByRole('img', { name: 'Layered avatar' }).querySelector('svg')
+
+ expect(svg?.querySelectorAll(':scope > path')).toHaveLength(37)
+ })
+
+ it('exposes semantic imperative controls without Studio identifiers', () => {
+ const controller = createRef()
+ render( )
+
+ let result: ReturnType | undefined
+ act(() => {
+ result = controller.current?.setExpression('smile')
+ })
+ expect(result).toEqual({ ok: true })
+ expect(controller.current?.getState()).toMatchObject({
+ activeExpression: 'smile',
+ status: 'playing',
+ })
+ 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()
+ render( )
+ expect(controller.current?.setExpression('smile')).toMatchObject({
+ ok: false,
+ error: { code: 'controlled_by_props' },
+ })
+ })
+
+ it('smoothly transitions when a controlled expression changes', () => {
+ const controller = createRef()
+ const view = render( )
+
+ view.rerender( )
+
+ expect(controller.current?.getState()).toEqual({
+ activeExpression: 'smile',
+ status: 'playing',
+ })
+ })
+
+ it('retargets from the currently painted SVG frame without a target-frame flash', () => {
+ let nextFrame = 0
+ const frames = new Map()
+ const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
+ frames.set(++nextFrame, callback)
+ return nextFrame
+ })
+ const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
+ frames.delete(id)
+ })
+ const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
+ const controller = createRef()
+ const view = render( )
+ const eye = view.container.querySelector('.bs-avatar__svg g path')
+ const neutralPath = eye?.getAttribute('d')
+
+ act(() => controller.current?.setExpression('smile'))
+ expect(eye?.getAttribute('d')).toBe(neutralPath)
+
+ act(() => {
+ const callback = [...frames.values()].at(-1)
+ frames.clear()
+ callback?.(1_200)
+ })
+ const inFlightPath = eye?.getAttribute('d')
+ expect(inFlightPath).not.toBe(neutralPath)
+
+ clock.mockReturnValue(1_200)
+ act(() => controller.current?.setExpression('neutral'))
+ expect(eye?.getAttribute('d')).toBe(inFlightPath)
+
+ act(() => {
+ const callback = [...frames.values()].at(-1)
+ frames.clear()
+ callback?.(1_200)
+ })
+ expect(eye?.getAttribute('d')).toBe(inFlightPath)
+
+ clock.mockRestore()
+ request.mockRestore()
+ cancel.mockRestore()
+ })
+
+ it('rejects simultaneous controlled animation and expression props', () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ expect(() =>
+ render( )
+ ).toThrow('Avatar accepts either animation or expression, not both.')
+ errors.mockRestore()
+ })
+
+ it('rejects simultaneous uncontrolled animation and expression defaults', () => {
+ const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
+ expect(() =>
+ render(
+
+ )
+ ).toThrow('Avatar accepts either defaultAnimation or defaultExpression, not both.')
+ errors.mockRestore()
+ })
+
+ it('reports unknown controlled and default targets through onError', () => {
+ const onError = vi.fn()
+ const controlled = render(
+
+ )
+
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'unknown_animation', key: 'missing-animation' })
+ )
+
+ onError.mockClear()
+ controlled.rerender(
+
+ )
+ expect(onError).toHaveBeenCalledWith(
+ expect.objectContaining({ code: 'unknown_expression', key: 'missing-expression' })
+ )
+ })
+
+ it('keeps a controlled expression above an uncontrolled animation default', () => {
+ const controller = createRef()
+ render(
+
+ )
+ expect(controller.current?.getState()).toEqual({
+ activeExpression: 'neutral',
+ status: 'stopped',
+ })
+ })
+
+ it('honors uncontrolled defaults without autoplay when requested', () => {
+ const controller = createRef()
+ render(
+
+ )
+ expect(controller.current?.getState()).toEqual({
+ activeExpression: 'smile',
+ status: 'stopped',
+ })
+ })
+
+ it('resumes the current paused animation instead of restarting it', () => {
+ const controller = createRef()
+ const clock = vi.spyOn(performance, 'now')
+ clock.mockReturnValueOnce(100).mockReturnValueOnce(250).mockReturnValueOnce(1_250)
+ render( )
+ 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()
+ 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(
+ renders++}>
+
+
+ )
+ 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()
+ 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(
+
+
+
+ )
+ 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()
+ })
+})
diff --git a/packages/avatar-react/src/createAvatar.tsx b/packages/avatar-react/src/createAvatar.tsx
new file mode 100644
index 0000000..b575683
--- /dev/null
+++ b/packages/avatar-react/src/createAvatar.tsx
@@ -0,0 +1,74 @@
+import {
+ validateAvatarDefinition,
+ type AnimationKey,
+ type AvatarDefinition,
+ type ExpressionKey,
+} from '@bible-strong/avatar-core'
+import type { ReactElement } from 'react'
+
+import {
+ Avatar,
+ markAvatarDefinitionValidated,
+ type AvatarController,
+ type AvatarProps,
+} from './Avatar'
+
+type AvatarDefinitionInput = {
+ expressions: object
+ animations: object
+}
+
+type StringKey = Extract
+
+/** Props for a component created from one concrete avatar definition. */
+export type CreatedAvatarProps = Omit<
+ AvatarProps,
+ 'definition' | 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'
+> & {
+ animation?: StringKey
+ defaultAnimation?: StringKey
+ expression?: StringKey
+ defaultExpression?: StringKey
+}
+
+/** A concrete avatar component with animation and expression keys from its definition. */
+export type CreatedAvatarComponent = (
+ props: CreatedAvatarProps
+) => ReactElement
+
+const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
+ const first = errors[0]
+ return new Error(
+ first
+ ? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
+ : 'Invalid avatar definition.'
+ )
+}
+
+const buildAvatarComponent = (definition: Readonly) => {
+ const ConcreteAvatar = (props: CreatedAvatarProps): ReactElement => (
+
+ )
+ ConcreteAvatar.displayName = 'CreatedAvatar'
+ return ConcreteAvatar
+}
+
+/**
+ * Validate a JSON-compatible definition once and create a concrete React component from it.
+ *
+ * When the input is a statically typed definition, the returned component narrows its animation
+ * and expression props to that definition's semantic keys. Values loaded at runtime are still
+ * validated, but necessarily expose the broad string-key API at compile time.
+ */
+export function createAvatar(
+ definition: Definition
+): CreatedAvatarComponent
+export function createAvatar(definition: unknown): CreatedAvatarComponent
+export function createAvatar(definition: unknown): CreatedAvatarComponent {
+ const result = validateAvatarDefinition(definition)
+ if (!result.ok) throw invalidDefinitionError(result.errors)
+ markAvatarDefinitionValidated(result.value)
+ return buildAvatarComponent(result.value)
+}
+
+export type { AnimationKey, AvatarController, ExpressionKey }
diff --git a/packages/avatar-react/src/index.ts b/packages/avatar-react/src/index.ts
new file mode 100644
index 0000000..93224c5
--- /dev/null
+++ b/packages/avatar-react/src/index.ts
@@ -0,0 +1,10 @@
+export { Avatar } from './Avatar'
+export { createAvatar } from './createAvatar'
+export type {
+ AvatarCommandResult,
+ AvatarController,
+ AvatarPlaybackState,
+ AvatarProps,
+ AvatarRuntimeError,
+} from './Avatar'
+export type { CreatedAvatarComponent, CreatedAvatarProps } from './createAvatar'
diff --git a/packages/avatar-react/src/styles.css b/packages/avatar-react/src/styles.css
new file mode 100644
index 0000000..f57709c
--- /dev/null
+++ b/packages/avatar-react/src/styles.css
@@ -0,0 +1,31 @@
+.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:focus-visible {
+ outline: 3px solid currentColor;
+ outline-offset: 3px;
+}
+
+.bs-avatar__svg {
+ display: block;
+ width: 100%;
+ height: 100%;
+ overflow: visible;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: reduce) {
+ .bs-avatar,
+ .bs-avatar * {
+ scroll-behavior: auto !important;
+ transition-duration: 0.01ms !important;
+ }
+}
diff --git a/packages/avatar-react/src/styles.d.ts b/packages/avatar-react/src/styles.d.ts
new file mode 100644
index 0000000..5894ae0
--- /dev/null
+++ b/packages/avatar-react/src/styles.d.ts
@@ -0,0 +1 @@
+declare module '*.css'
diff --git a/packages/avatar-react/tsconfig.build.json b/packages/avatar-react/tsconfig.build.json
new file mode 100644
index 0000000..738a64e
--- /dev/null
+++ b/packages/avatar-react/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"]
+}
diff --git a/packages/avatar-react/tsconfig.json b/packages/avatar-react/tsconfig.json
new file mode 100644
index 0000000..586a83e
--- /dev/null
+++ b/packages/avatar-react/tsconfig.json
@@ -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__/**"]
+}
diff --git a/packages/avatar-react/vite.config.ts b/packages/avatar-react/vite.config.ts
new file mode 100644
index 0000000..f758b9e
--- /dev/null
+++ b/packages/avatar-react/vite.config.ts
@@ -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'],
+ },
+ },
+})
diff --git a/packages/avatar-web/LICENSE b/packages/avatar-web/LICENSE
new file mode 100644
index 0000000..804af0b
--- /dev/null
+++ b/packages/avatar-web/LICENSE
@@ -0,0 +1,10 @@
+Copyright (C) 2026 Stéphane Montlouis-Calixte
+
+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, version 3.
+
+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.
+
+The complete license text is available at .
diff --git a/packages/avatar-web/README.md b/packages/avatar-web/README.md
new file mode 100644
index 0000000..94d41d0
--- /dev/null
+++ b/packages/avatar-web/README.md
@@ -0,0 +1,28 @@
+# @bible-strong/avatar-web
+
+DOM renderer for Bible Strong procedural avatars. It uses `@bible-strong/avatar-core` for schema
+validation, playback and rendering, without requiring React.
+
+```sh
+pnpm add @bible-strong/avatar-web
+```
+
+```js
+import { createAvatar } from '@bible-strong/avatar-web'
+import definition from './cloudee.avatar.json'
+
+const avatar = createAvatar('#avatar', {
+ definition,
+ defaultAnimation: 'idle',
+})
+
+avatar.play('happy')
+avatar.pause()
+avatar.stop()
+```
+
+For a browser project without a bundler, load an ESM build through an import map or CDN and fetch
+the definition JSON before calling `createAvatar`.
+
+The package follows Semantic Versioning. While it remains below `1.0.0`, breaking API changes
+increment the minor version and fixes increment the patch version.
diff --git a/packages/avatar-web/package.json b/packages/avatar-web/package.json
new file mode 100644
index 0000000..78386ee
--- /dev/null
+++ b/packages/avatar-web/package.json
@@ -0,0 +1,55 @@
+{
+ "name": "@bible-strong/avatar-web",
+ "version": "0.1.0",
+ "description": "Framework-independent DOM renderer for Bible Strong procedural avatars.",
+ "keywords": [
+ "avatar",
+ "svg",
+ "animation",
+ "dom",
+ "esm",
+ "typescript"
+ ],
+ "author": "Stéphane Montlouis-Calixte",
+ "license": "AGPL-3.0-only",
+ "homepage": "https://github.com/smontlouis/bible-strong-avatar-lab#readme",
+ "repository": {
+ "type": "git",
+ "url": "git+https://github.com/smontlouis/bible-strong-avatar-lab.git",
+ "directory": "packages/avatar-web"
+ },
+ "bugs": {
+ "url": "https://github.com/smontlouis/bible-strong-avatar-lab/issues"
+ },
+ "type": "module",
+ "sideEffects": false,
+ "files": [
+ "dist",
+ "README.md",
+ "LICENSE"
+ ],
+ "exports": {
+ ".": {
+ "types": "./dist/index.d.ts",
+ "import": "./dist/index.js"
+ }
+ },
+ "scripts": {
+ "build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
+ "prepack": "pnpm build",
+ "typecheck": "tsc -p tsconfig.json --noEmit"
+ },
+ "publishConfig": {
+ "access": "public"
+ },
+ "dependencies": {
+ "@bible-strong/avatar-core": "workspace:^"
+ },
+ "devDependencies": {
+ "typescript": "~6.0.3",
+ "vite": "^8.0.13"
+ },
+ "engines": {
+ "node": ">=22.12.0"
+ }
+}
diff --git a/packages/avatar-web/src/__tests__/avatar-web-test.ts b/packages/avatar-web/src/__tests__/avatar-web-test.ts
new file mode 100644
index 0000000..745fbde
--- /dev/null
+++ b/packages/avatar-web/src/__tests__/avatar-web-test.ts
@@ -0,0 +1,49 @@
+// @vitest-environment jsdom
+
+import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
+import { createAvatar } from '../index'
+
+describe('@bible-strong/avatar-web', () => {
+ beforeEach(() => {
+ document.body.innerHTML = '
'
+ vi.stubGlobal('requestAnimationFrame', () => 1)
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
+ vi.stubGlobal('matchMedia', () => ({ matches: false }))
+ })
+
+ afterEach(() => vi.unstubAllGlobals())
+
+ it('mounts the shared avatar definition without React', () => {
+ const avatar = createAvatar('#avatar', {
+ definition: definitionJson,
+ defaultExpression: 'neutral',
+ size: 180,
+ })
+
+ expect(document.querySelector('#avatar svg')).not.toBeNull()
+ expect(document.querySelectorAll('#avatar svg > path')).toHaveLength(37)
+ expect(document.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe(
+ 'Procedural avatar'
+ )
+ expect(avatar.getState()).toMatchObject({
+ activeExpression: 'neutral',
+ status: 'stopped',
+ })
+
+ avatar.destroy()
+ expect(document.querySelector('#avatar svg')).toBeNull()
+ })
+
+ it('returns typed errors for unknown targets', () => {
+ const avatar = createAvatar('#avatar', { definition: definitionJson })
+
+ expect(avatar.play('missing')).toEqual({
+ ok: false,
+ error: expect.objectContaining({ code: 'unknown_animation', key: 'missing' }),
+ })
+ expect(avatar.setExpression('missing')).toEqual({
+ ok: false,
+ error: expect.objectContaining({ code: 'unknown_expression', key: 'missing' }),
+ })
+ })
+})
diff --git a/packages/avatar-web/src/index.ts b/packages/avatar-web/src/index.ts
new file mode 100644
index 0000000..d14ceed
--- /dev/null
+++ b/packages/avatar-web/src/index.ts
@@ -0,0 +1,304 @@
+import {
+ advanceAvatarPlayback,
+ createAvatarPlaybackState,
+ MAX_BODY_NODES,
+ pauseAvatarPlayback,
+ playAvatarAnimation,
+ renderAvatarDefinition,
+ renderAvatarFrame,
+ resolveAnimation,
+ resolveExpression,
+ resumeAvatarPlayback,
+ sampleAvatarFrame,
+ validateAvatarDefinition,
+ type AnimationKey,
+ type AvatarDefinition,
+ type AvatarPlaybackState as CorePlaybackState,
+ type AvatarRuntimeError,
+ type ExpressionKey,
+} from '@bible-strong/avatar-core'
+
+export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
+
+export type AvatarPlaybackState = Pick<
+ CorePlaybackState,
+ 'activeAnimation' | 'activeExpression' | 'status'
+>
+
+export type AvatarController = {
+ play(animation: AnimationKey): AvatarCommandResult
+ setExpression(expression: ExpressionKey): AvatarCommandResult
+ pause(): void
+ stop(): void
+ getState(): AvatarPlaybackState
+ destroy(): void
+}
+
+export type CreateAvatarOptions = {
+ definition: unknown
+ defaultAnimation?: AnimationKey
+ defaultExpression?: ExpressionKey
+ autoplay?: boolean
+ size?: number | string
+ ariaLabel?: string
+ className?: string
+ onError?: (error: AvatarRuntimeError) => void
+ onAnimationEnd?: (animation: AnimationKey) => void
+ onExpressionChange?: (expression: ExpressionKey) => void
+}
+
+const svgNamespace = 'http://www.w3.org/2000/svg'
+const controlledExpressionTransitionMs = 420
+const bodyPathSlots = MAX_BODY_NODES + 2
+let avatarInstanceId = 0
+
+const dimension = (size: number | string) => (typeof size === 'number' ? `${size}px` : size)
+
+const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
+ const first = errors[0]
+ return new Error(
+ first
+ ? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
+ : 'Invalid avatar definition.'
+ )
+}
+
+const resolveTarget = (target: string | HTMLElement) => {
+ const element = typeof target === 'string' ? document.querySelector(target) : target
+ if (!element) throw new Error(`Avatar target '${target}' was not found.`)
+ return element
+}
+
+const createSvgElement = (name: Name) =>
+ document.createElementNS(svgNamespace, name)
+
+const playbackSnapshot = (state: CorePlaybackState): AvatarPlaybackState => ({
+ ...(state.activeAnimation ? { activeAnimation: state.activeAnimation } : {}),
+ activeExpression: state.activeExpression,
+ status: state.status,
+})
+
+const runtimeEnvironment = () => ({
+ random: Math.random,
+ reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
+})
+
+export function createAvatar(
+ target: string | HTMLElement,
+ {
+ definition: input,
+ defaultAnimation,
+ defaultExpression,
+ autoplay = true,
+ size = 240,
+ ariaLabel = 'Procedural avatar',
+ className,
+ onError,
+ onAnimationEnd,
+ onExpressionChange,
+ }: CreateAvatarOptions
+): AvatarController {
+ if (defaultAnimation !== undefined && defaultExpression !== undefined) {
+ throw new Error('Choose either defaultAnimation or defaultExpression, not both.')
+ }
+ const validated = validateAvatarDefinition(input)
+ if (!validated.ok) throw invalidDefinitionError(validated.errors)
+ const definition: Readonly = validated.value
+ const mount = resolveTarget(target)
+ const host = document.createElement('span')
+ host.className = ['bs-avatar', className ?? ''].filter(Boolean).join(' ')
+ host.style.display = 'inline-block'
+ host.style.width = dimension(size)
+ host.style.height = dimension(size)
+ host.setAttribute('role', 'img')
+ host.setAttribute('aria-label', ariaLabel)
+
+ const svg = createSvgElement('svg')
+ svg.setAttribute('viewBox', '-150 -150 300 300')
+ svg.setAttribute('aria-hidden', 'true')
+ svg.style.display = 'block'
+ svg.style.width = '100%'
+ svg.style.height = '100%'
+ const defs = createSvgElement('defs')
+ const clipPath = createSvgElement('clipPath')
+ const clipId = `bs-avatar-web-${++avatarInstanceId}`
+ clipPath.id = clipId
+ const clipHeadPath = createSvgElement('path')
+ clipPath.append(clipHeadPath)
+ defs.append(clipPath)
+ svg.append(defs)
+
+ const initialScene = renderAvatarDefinition(definition)
+ const backPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
+ const headPath = createSvgElement('path')
+ const eyeGroup = createSvgElement('g')
+ eyeGroup.setAttribute('clip-path', `url(#${clipId})`)
+ const leftPath = createSvgElement('path')
+ const rightPath = createSvgElement('path')
+ eyeGroup.append(leftPath, rightPath)
+ const frontPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
+ svg.append(...backPaths, headPath, eyeGroup, ...frontPaths)
+ host.append(svg)
+ mount.append(host)
+
+ const reportError = (error: AvatarRuntimeError) => {
+ if (onError) onError(error)
+ else console.error(`[Avatar] ${error.message}`)
+ }
+ const paint = (scene: ReturnType) => {
+ clipHeadPath.setAttribute('d', scene.geometry.headPath)
+ headPath.setAttribute('d', scene.geometry.headPath)
+ headPath.setAttribute('fill', scene.colors.body)
+ leftPath.setAttribute('d', scene.geometry.leftPath)
+ leftPath.setAttribute('fill', scene.colors.eyes)
+ leftPath.setAttribute('opacity', scene.geometry.leftVisible ? '1' : '0')
+ rightPath.setAttribute('d', scene.geometry.rightPath)
+ rightPath.setAttribute('fill', scene.colors.eyes)
+ rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
+ backPaths.forEach((element, index) => {
+ element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
+ element.setAttribute('fill', scene.colors.body)
+ })
+ frontPaths.forEach((element, index) => {
+ element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
+ element.setAttribute('fill', scene.colors.body)
+ })
+ }
+
+ let playback = createAvatarPlaybackState()
+ let frameRequest: number | null = null
+ let destroyed = false
+ let completedAnimation: AnimationKey | undefined
+ let lastExpression: ExpressionKey | undefined
+ let paintedFrame: ReturnType | undefined
+
+ const notifyExpression = () => {
+ if (lastExpression === playback.activeExpression) return
+ lastExpression = playback.activeExpression
+ onExpressionChange?.(playback.activeExpression)
+ }
+ const renderCurrent = (now: number) => {
+ const environment = runtimeEnvironment()
+ paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
+ paint(renderAvatarFrame(definition, playback, now, environment))
+ notifyExpression()
+ }
+ const tick = (now: number) => {
+ frameRequest = null
+ if (destroyed) return
+ const currentAnimation = playback.activeAnimation
+ const wasPlaying = playback.status === 'playing'
+ playback = advanceAvatarPlayback(definition, playback, now, {
+ random: Math.random,
+ reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
+ })
+ renderCurrent(now)
+ if (wasPlaying && playback.status === 'stopped' && currentAnimation) {
+ if (completedAnimation !== currentAnimation) onAnimationEnd?.(currentAnimation)
+ completedAnimation = currentAnimation
+ }
+ if (playback.status === 'playing') frameRequest = requestAnimationFrame(tick)
+ }
+ const schedule = () => {
+ if (frameRequest === null && !destroyed) frameRequest = requestAnimationFrame(tick)
+ }
+
+ const controller: AvatarController = {
+ play(animation) {
+ if (
+ playback.status === 'paused' &&
+ playback.activeAnimation === animation &&
+ playback.pausedAt !== undefined
+ ) {
+ playback = resumeAvatarPlayback(playback, performance.now())
+ schedule()
+ return { ok: true }
+ }
+ const now = performance.now()
+ const from =
+ paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
+ const result = playAvatarAnimation(definition, animation, now, from)
+ if (!result.ok) return { ok: false, error: result.error }
+ completedAnimation = undefined
+ playback = result.value
+ renderCurrent(performance.now())
+ schedule()
+ return { ok: true }
+ },
+ setExpression(expression) {
+ const result = resolveExpression(definition, expression)
+ if (!result.ok) return { ok: false, error: result.error }
+ const now = performance.now()
+ const from =
+ paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
+ playback = {
+ ...createAvatarPlaybackState(),
+ activeExpression: expression,
+ ...(playback.activeExpression === expression
+ ? {}
+ : {
+ status: 'playing' as const,
+ directTransition: {
+ from,
+ startedAt: now,
+ durationMs: controlledExpressionTransitionMs,
+ transition: 'smooth' as const,
+ },
+ }),
+ }
+ renderCurrent(performance.now())
+ if (playback.status === 'playing') schedule()
+ return { ok: true }
+ },
+ pause() {
+ if (playback.status !== 'playing') return
+ playback = pauseAvatarPlayback(playback, performance.now())
+ if (frameRequest !== null) cancelAnimationFrame(frameRequest)
+ frameRequest = null
+ },
+ stop() {
+ playback = createAvatarPlaybackState()
+ if (frameRequest !== null) cancelAnimationFrame(frameRequest)
+ frameRequest = null
+ paint(renderAvatarDefinition(definition))
+ notifyExpression()
+ },
+ getState() {
+ return playbackSnapshot(playback)
+ },
+ destroy() {
+ destroyed = true
+ if (frameRequest !== null) cancelAnimationFrame(frameRequest)
+ frameRequest = null
+ host.remove()
+ },
+ }
+
+ if (defaultAnimation !== undefined) {
+ const resolved = resolveAnimation(definition, defaultAnimation)
+ if (!resolved.ok) reportError(resolved.error)
+ else if (autoplay) controller.play(defaultAnimation)
+ else {
+ playback = {
+ ...createAvatarPlaybackState(),
+ activeExpression: resolved.value.steps[0]?.expression ?? 'neutral',
+ }
+ renderCurrent(performance.now())
+ }
+ } else if (defaultExpression !== undefined) {
+ const resolved = resolveExpression(definition, defaultExpression)
+ if (!resolved.ok) reportError(resolved.error)
+ else {
+ playback = { ...createAvatarPlaybackState(), activeExpression: defaultExpression }
+ renderCurrent(performance.now())
+ }
+ } else {
+ paint(initialScene)
+ paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
+ notifyExpression()
+ }
+
+ return controller
+}
+
+export type { AnimationKey, AvatarDefinition, AvatarRuntimeError, ExpressionKey }
diff --git a/packages/avatar-web/tsconfig.build.json b/packages/avatar-web/tsconfig.build.json
new file mode 100644
index 0000000..738a64e
--- /dev/null
+++ b/packages/avatar-web/tsconfig.build.json
@@ -0,0 +1,12 @@
+{
+ "extends": "./tsconfig.json",
+ "compilerOptions": {
+ "noEmit": false,
+ "emitDeclarationOnly": true,
+ "declaration": true,
+ "declarationMap": true,
+ "rootDir": "src",
+ "outDir": "dist"
+ },
+ "include": ["src"]
+}
diff --git a/packages/avatar-web/tsconfig.json b/packages/avatar-web/tsconfig.json
new file mode 100644
index 0000000..7f8a4f7
--- /dev/null
+++ b/packages/avatar-web/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "strict": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "isolatedModules": true,
+ "skipLibCheck": true,
+ "noEmit": true
+ },
+ "include": ["src", "vite.config.ts"],
+ "exclude": ["src/**/__tests__/**"]
+}
diff --git a/packages/avatar-web/vite.config.ts b/packages/avatar-web/vite.config.ts
new file mode 100644
index 0000000..186cb13
--- /dev/null
+++ b/packages/avatar-web/vite.config.ts
@@ -0,0 +1,17 @@
+import { fileURLToPath } from 'node:url'
+
+import { defineConfig } from 'vite'
+
+export default defineConfig({
+ build: {
+ lib: {
+ entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
+ formats: ['es'],
+ fileName: 'index',
+ },
+ sourcemap: true,
+ rollupOptions: {
+ external: ['@bible-strong/avatar-core'],
+ },
+ },
+})
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 2c26efc..d8eac6e 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -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
@@ -48,12 +57,18 @@ importers:
'@babel/parser':
specifier: ^7.28.6
version: 7.29.8
+ '@changesets/cli':
+ specifier: ^3.0.0
+ version: 3.0.0
'@rolldown/plugin-babel':
specifier: ^0.2.3
- version: 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))
+ version: 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)(yaml@2.9.0))
'@tailwindcss/vite':
specifier: ^4.3.3
- version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
+ version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.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
@@ -65,10 +80,13 @@ importers:
version: 19.2.4(@types/react@19.2.18)
'@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))
+ 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)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
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
@@ -83,13 +101,116 @@ importers:
version: 6.0.3
vite:
specifier: ^8.0.13
- version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.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)(yaml@2.9.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)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.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)(yaml@2.9.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)
+
+ examples/web-vite-consumer:
+ dependencies:
+ '@bible-strong/avatar-web':
+ specifier: workspace:*
+ version: link:../../packages/avatar-web
+ devDependencies:
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^8.0.13
+ version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
+
+ 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)(yaml@2.9.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)(yaml@2.9.0)
+
+ packages/avatar-web:
+ dependencies:
+ '@bible-strong/avatar-core':
+ specifier: workspace:^
+ version: link:../avatar-core
+ devDependencies:
+ typescript:
+ specifier: ~6.0.3
+ version: 6.0.3
+ vite:
+ specifier: ^8.0.13
+ version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.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 +309,124 @@ packages:
'@types/react':
optional: true
+ '@bramus/specificity@2.4.2':
+ resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
+ hasBin: true
+
+ '@changesets/apply-release-plan@8.0.0':
+ resolution: {integrity: sha512-kUd2pbf1w5/AYmBMb0Tt+rkIPCjFJdT0SZMrkOjJT/WV/QbtmvkyB5jkV0oaNPheavprZk+SfUiozUti7TIL2w==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/assemble-release-plan@7.0.0':
+ resolution: {integrity: sha512-oEW8BxdA604kGGtDSCiHr5w9Tv4UWe9I2k61IBNZzCOE1kbYaJj4v+lFQNgcEZFkUc2pV/+hASErGDvpJOZCTg==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/changelog-git@1.0.0':
+ resolution: {integrity: sha512-3Dst2Ime2Op5nd4XmWJLPIgp11ZFqJqSkVug9izK6TDcIV4YlhPS4ECbEVR+eGI0bk0r1ItogD4j2Oli87bJrA==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/cli@3.0.0':
+ resolution: {integrity: sha512-V7Gm+GP5OT3mJinMI2YcJD/JyO/a4WChnaMCCzTRhWHgu1zhtsyY7zCjP6n5W6zsgSjJKs+yPmvtREvE0F2g8A==}
+ engines: {node: ^22.11 || ^24 || >=26, npm: '>=10.9.0', pnpm: '>=10.0.0', yarn: '>=4.5.2'}
+ hasBin: true
+
+ '@changesets/config@4.0.0':
+ resolution: {integrity: sha512-mw95/YrkOuhZZxfnVAA4bSXOFUi+KlhzOBTM8C4x777NhUU6HWIl9Z+K+nME+E4PVsv5NQVQwTfiHihAS1A/ow==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/errors@1.0.0':
+ resolution: {integrity: sha512-ElN/mEzn6zmETgjwf5MclCMa9ef59sAR0lfO8VSYIsiRvbC2FbLB/92EoYw10Sl0kGixxHFiJZUSv7dA+YpR8g==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/format@0.1.2':
+ resolution: {integrity: sha512-Caez5XtNXCFS/G5bwyav3wuXL0tMxVd2ZGbaumWbzN08tyzO21asCw7JZhNtVsAZDCvDRUzZN+Iit9SyRITSYA==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/get-dependents-graph@3.0.0':
+ resolution: {integrity: sha512-ji/t5wFA1zREKXRUePE6Qi+Qu2UgxCeSSGQrphezwvQZrp49B7sJ+8+wvM0tA7zPeSxYKCojDy3WWgrl+s+awg==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/git@4.0.0':
+ resolution: {integrity: sha512-uIEswpPUgzBBqrC0qg13byNaPorzhf05LE2T+gRizEKCXvMsJC6NPJp5iNDSV/gYj4Viqh09UiOF5E7XWeH5lQ==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/parse@1.0.0':
+ resolution: {integrity: sha512-P0iaMb9p9CRYZiTgAllEIF9AUMQHIy1G72tKlcIqJp61icZSsKQNiOPdxAMZG8m/DvZwt/oz5xEbTpike//dWg==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/pre@3.0.0':
+ resolution: {integrity: sha512-Zm/6YliV/a2oeWTqHJf6KxLrQwgcK1i/BRDl2m0EKZvbnxV5fG9QRhwJJGshjsZTUTS6dkfURQ2K6aAvgNw/3Q==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/read@1.0.0':
+ resolution: {integrity: sha512-8TdE2PwG6yArPt5Ozej83Z6iHz1G8BDBKvIEKZ455MccR4K3GNbLR2XqjBxa+5yLFnEd3xAOPXYboBg1pt8stQ==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/should-skip-package@1.0.0':
+ resolution: {integrity: sha512-pwqoJmbONn1XgXmZXPEExgAaT+HdZLjALFTDgIm+PnS5KeO2nLtzA2/Q+4aMFY14kFMuXKG30MObhvDzWgzDgg==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/types@7.0.0':
+ resolution: {integrity: sha512-c5GoiQyt3pxiXjrWSNoP8/GRf4kG+VnKzovx1OQM8dYYALlSwgedmkPmJ+ZqGxqwg9D3Bkj85Uo4KLd5BN3A0w==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@changesets/write@1.0.0':
+ resolution: {integrity: sha512-xCK3/4C7Z7muQB/wguE6HcbjtY9iOtaK9orZKE+7xi573cMGD8rLZz7qlo6IvK7s+SIUIKajzj1l+F1QuP97tw==}
+ engines: {node: ^22.11 || ^24 || >=26}
+
+ '@clack/core@1.4.3':
+ resolution: {integrity: sha512-/kr3UWNtdJfxZtPgDqUOmG2pvwlmcLGheex5yiZKdwbzZJxhV+HMNR9QNmyY5cGwTNV6LrR7Jtp+KjhUAP1qBQ==}
+ engines: {node: '>= 20.12.0'}
+
+ '@clack/prompts@1.7.0':
+ resolution: {integrity: sha512-y7/yvZ2TPAnR9+jnc00klvNNLkJiXFFrQA/hlLCcxA9a2A4zQIOimyFQ9XfwYKiGD1fb5GY8vbKIIgO8d5Tb2A==}
+ engines: {node: '>= 20.12.0'}
+
+ '@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==}
@@ -219,9 +458,25 @@ packages:
'@jridgewell/trace-mapping@0.3.31':
resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==}
+ '@manypkg/find-root@3.1.0':
+ resolution: {integrity: sha512-BcSqCyKhBVZ5YkSzOiheMCV41kqAFptW6xGqYSTjkVTl9XQpr+pqHhwgGCOHQtjDCv7Is6EFyA14Sm5GVbVABA==}
+ engines: {node: '>=20.0.0'}
+
+ '@manypkg/get-packages@3.1.0':
+ resolution: {integrity: sha512-0TbBVyvPrP7xGYBI/cP8UP+yl/z+HtbTttAD7FMAJgn/kXOTwh5/60TsqP9ZYY710forNfyV0N8P/IE/ujGZJg==}
+ engines: {node: '>=20.0.0'}
+
+ '@manypkg/tools@2.1.2':
+ resolution: {integrity: sha512-6QEf6yqFbETdwGITKq57aYoPfX/3K8XFNwsAlx0C1M7o8cb79sv1M3w+tWuWvIcSbNqrLF7OD7YpZMVVz335hQ==}
+ engines: {node: '>=20.0.0'}
+
'@oxc-project/types@0.144.0':
resolution: {integrity: sha512-nuhZIOLuI6TFQ32I/WnUx+SCPY7SdSKwgnFHydAuoS1+Z4BRcaP+RRJmGzl9lw+0OFF7UmaESf7KQRXaNLHypg==}
+ '@pnpm/deps.graph-sequencer@1100.0.1':
+ resolution: {integrity: sha512-pOr5+q1fLYKwFN3LAJuGZEnfXDcQ73zqgDHMtGy+K+uIoUqyY+6MeDCWFwfu+4EFuq76I5EPFofoNAI+Bmmq4A==}
+ engines: {node: '>=22.13'}
+
'@rolldown/binding-android-arm64@1.2.4':
resolution: {integrity: sha512-jHC2cnyKz5xU2fhECtFl8OZ83cYNt13GZQD+0uMJ/X3o+ijmd56okHhTUwxVSHPx1IRVIJEZ1/1pPzeLCU6XKA==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -429,6 +684,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 +823,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,11 +849,18 @@ 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}
hasBin: true
+ cac@7.0.0:
+ resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==}
+ engines: {node: '>=20.19.0'}
+
caniuse-lite@1.0.30001809:
resolution: {integrity: sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ==}
@@ -580,9 +878,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 +898,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 +919,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 +937,21 @@ 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-string-truncated-width@3.0.3:
+ resolution: {integrity: sha512-0jjjIEL6+0jag3l2XWWizO64/aZVtpiGE3t0Zgqxv0DPuxiMjvB3M24fCyhZUO4KomJQPj3LTSUnDP3GpdwC0g==}
+
+ fast-string-width@3.0.2:
+ resolution: {integrity: sha512-gX8LrtNEI5hq8DVUfRQMbr5lpaS4nMIWV+7XEbXk2b8kiQIizgnlr12B4dA3ZEx3308ze0O4Q1R+cHts8kyUJg==}
+
+ fast-uri@3.1.5:
+ resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
+
+ fast-wrap-ansi@0.2.2:
+ resolution: {integrity: sha512-7F2Fl+TjRSenLqlU3UjSH0iyqopqoZIu7eZVpEirP2g1GtWa2G/ecEmBdgz31+Mxr+ELclgg6sokpSFIQiZ02Q==}
+
fdir@6.5.0:
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
engines: {node: '>=12.0.0'}
@@ -649,23 +984,58 @@ 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}
+
+ human-id@4.2.0:
+ resolution: {integrity: sha512-K3GbkIWqyvvlpfhBPlbEvD97TtqBpAYA4kt+cn2lD2x2HuohzZCibcA2nOlnJT6exqvJLggoB5nv2dNf192nEA==}
+ hasBin: true
+
+ import-meta-resolve@4.2.0:
+ resolution: {integrity: sha512-Iqv2fzaTQN28s/FwZAoFq0ZSs/7hMAHJVX+w8PZl3cY19Pxk6jFFalxQoIfW2826i/fDLXv8IiEZRIT0lDuWcg==}
+
+ 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
+ jju@1.4.0:
+ resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==}
+
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'}
hasBin: true
+ jsonc-parser@3.3.1:
+ resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==}
+
+ launch-editor@2.14.1:
+ resolution: {integrity: sha512-QWBrQsMpH7gPr965dsKD/3cKWiNoTjpATQf++Xq63N6sKRGMwlVXz41O1IZTMfZQgBctD/K5Zt06+/I6pP6+HA==}
+
lightningcss-android-arm64@1.32.0:
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
engines: {node: '>= 12.0.0'}
@@ -814,6 +1184,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 +1196,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 +1239,12 @@ packages:
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
engines: {node: '>=12.20.0'}
+ package-manager-detector@1.8.0:
+ resolution: {integrity: sha512-yQA4H19AmPEoMUeavPMDIe1higySl/gH/yaQrkT/s07Qp+7pp2hYz30N3z2l5BkjVkF9Ow6o0wjJamm2y7Sn0A==}
+
+ parse5@8.0.1:
+ resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
+
pathe@2.0.3:
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
@@ -877,11 +1264,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 +1290,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 +1302,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==}
@@ -907,9 +1313,21 @@ packages:
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
hasBin: true
+ semver@7.8.5:
+ resolution: {integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==}
+ engines: {node: '>=10'}
+ hasBin: true
+
+ shell-quote@1.10.0:
+ resolution: {integrity: sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA==}
+ engines: {node: '>= 0.4'}
+
siginfo@2.0.0:
resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==}
+ sisteransi@1.0.5:
+ resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==}
+
source-map-js@1.2.1:
resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==}
engines: {node: '>=0.10.0'}
@@ -920,6 +1338,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 +1366,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 +1392,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 +1491,63 @@ 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==}
+ yaml@2.9.0:
+ resolution: {integrity: sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==}
+ engines: {node: '>= 14.6'}
+ hasBin: true
+
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 +1673,152 @@ snapshots:
optionalDependencies:
'@types/react': 19.2.18
+ '@bramus/specificity@2.4.2':
+ dependencies:
+ css-tree: 3.2.1
+
+ '@changesets/apply-release-plan@8.0.0':
+ dependencies:
+ '@changesets/config': 4.0.0
+ '@changesets/format': 0.1.2
+ '@changesets/git': 4.0.0
+ '@changesets/should-skip-package': 1.0.0
+ '@changesets/types': 7.0.0
+ import-meta-resolve: 4.2.0
+ jsonc-parser: 3.3.1
+ semver: 7.8.5
+
+ '@changesets/assemble-release-plan@7.0.0':
+ dependencies:
+ '@changesets/errors': 1.0.0
+ '@changesets/get-dependents-graph': 3.0.0
+ '@changesets/should-skip-package': 1.0.0
+ '@changesets/types': 7.0.0
+ semver: 7.8.5
+
+ '@changesets/changelog-git@1.0.0':
+ dependencies:
+ '@changesets/types': 7.0.0
+
+ '@changesets/cli@3.0.0':
+ dependencies:
+ '@changesets/apply-release-plan': 8.0.0
+ '@changesets/assemble-release-plan': 7.0.0
+ '@changesets/changelog-git': 1.0.0
+ '@changesets/config': 4.0.0
+ '@changesets/errors': 1.0.0
+ '@changesets/get-dependents-graph': 3.0.0
+ '@changesets/git': 4.0.0
+ '@changesets/pre': 3.0.0
+ '@changesets/read': 1.0.0
+ '@changesets/should-skip-package': 1.0.0
+ '@changesets/types': 7.0.0
+ '@changesets/write': 1.0.0
+ '@clack/prompts': 1.7.0
+ '@manypkg/get-packages': 3.1.0
+ '@pnpm/deps.graph-sequencer': 1100.0.1
+ cac: 7.0.0
+ import-meta-resolve: 4.2.0
+ launch-editor: 2.14.1
+ package-manager-detector: 1.8.0
+ semver: 7.8.5
+ tinyexec: 1.3.0
+
+ '@changesets/config@4.0.0':
+ dependencies:
+ '@changesets/get-dependents-graph': 3.0.0
+ '@changesets/should-skip-package': 1.0.0
+ '@changesets/types': 7.0.0
+ '@manypkg/get-packages': 3.1.0
+ picomatch: 4.0.5
+
+ '@changesets/errors@1.0.0': {}
+
+ '@changesets/format@0.1.2':
+ dependencies:
+ package-manager-detector: 1.8.0
+ tinyexec: 1.3.0
+
+ '@changesets/get-dependents-graph@3.0.0':
+ dependencies:
+ '@changesets/types': 7.0.0
+ semver: 7.8.5
+
+ '@changesets/git@4.0.0':
+ dependencies:
+ '@changesets/errors': 1.0.0
+ '@changesets/types': 7.0.0
+ '@manypkg/get-packages': 3.1.0
+ picomatch: 4.0.5
+ tinyexec: 1.3.0
+
+ '@changesets/parse@1.0.0':
+ dependencies:
+ '@changesets/types': 7.0.0
+ yaml: 2.9.0
+
+ '@changesets/pre@3.0.0':
+ dependencies:
+ '@changesets/errors': 1.0.0
+ '@changesets/types': 7.0.0
+ '@manypkg/get-packages': 3.1.0
+
+ '@changesets/read@1.0.0':
+ dependencies:
+ '@changesets/git': 4.0.0
+ '@changesets/parse': 1.0.0
+ '@changesets/types': 7.0.0
+
+ '@changesets/should-skip-package@1.0.0':
+ dependencies:
+ '@changesets/types': 7.0.0
+
+ '@changesets/types@7.0.0': {}
+
+ '@changesets/write@1.0.0':
+ dependencies:
+ '@changesets/format': 0.1.2
+ '@changesets/types': 7.0.0
+ human-id: 4.2.0
+
+ '@clack/core@1.4.3':
+ dependencies:
+ fast-wrap-ansi: 0.2.2
+ sisteransi: 1.0.5
+
+ '@clack/prompts@1.7.0':
+ dependencies:
+ '@clack/core': 1.4.3
+ fast-string-width: 3.0.2
+ fast-wrap-ansi: 0.2.2
+ sisteransi: 1.0.5
+
+ '@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
@@ -1222,8 +1855,25 @@ snapshots:
'@jridgewell/resolve-uri': 3.1.2
'@jridgewell/sourcemap-codec': 1.5.5
+ '@manypkg/find-root@3.1.0':
+ dependencies:
+ '@manypkg/tools': 2.1.2
+
+ '@manypkg/get-packages@3.1.0':
+ dependencies:
+ '@manypkg/find-root': 3.1.0
+ '@manypkg/tools': 2.1.2
+
+ '@manypkg/tools@2.1.2':
+ dependencies:
+ jju: 1.4.0
+ tinyglobby: 0.2.17
+ yaml: 2.9.0
+
'@oxc-project/types@0.144.0': {}
+ '@pnpm/deps.graph-sequencer@1100.0.1': {}
+
'@rolldown/binding-android-arm64@1.2.4':
optional: true
@@ -1266,14 +1916,14 @@ snapshots:
'@rolldown/binding-win32-x64-msvc@1.2.4':
optional: true
- '@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))':
+ '@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)(yaml@2.9.0))':
dependencies:
'@babel/core': 7.29.7
picomatch: 4.0.5
rolldown: 1.2.4
optionalDependencies:
'@babel/runtime': 7.29.7
- vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
'@rolldown/pluginutils@1.0.1': {}
@@ -1340,12 +1990,35 @@ snapshots:
'@tailwindcss/oxide-win32-arm64-msvc': 4.3.3
'@tailwindcss/oxide-win32-x64-msvc': 4.3.3
- '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))':
+ '@tailwindcss/vite@4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))':
dependencies:
'@tailwindcss/node': 4.3.3
'@tailwindcss/oxide': 4.3.3
tailwindcss: 4.3.3
- vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.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:
@@ -1376,12 +2049,12 @@ snapshots:
optionalDependencies:
react: 19.2.3
- '@vitejs/plugin-react@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))':
+ '@vitejs/plugin-react@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)(yaml@2.9.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))':
dependencies:
'@rolldown/pluginutils': 1.0.1
- vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
optionalDependencies:
- '@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))
+ '@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)(yaml@2.9.0))
babel-plugin-react-compiler: 1.0.0
'@vitest/expect@4.1.10':
@@ -1393,13 +2066,13 @@ snapshots:
chai: 6.2.2
tinyrainbow: 3.1.1
- '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))':
+ '@vitest/mocker@4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))':
dependencies:
'@vitest/spy': 4.1.10
estree-walker: 3.0.3
magic-string: 0.30.21
optionalDependencies:
- vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
'@vitest/pretty-format@4.1.10':
dependencies:
@@ -1425,6 +2098,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 +2121,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
@@ -1441,6 +2133,8 @@ snapshots:
node-releases: 2.0.53
update-browserslist-db: 1.3.1(browserslist@4.28.8)
+ cac@7.0.0: {}
+
caniuse-lite@1.0.30001809: {}
chai@6.2.2: {}
@@ -1453,14 +2147,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 +2180,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 +2192,20 @@ snapshots:
expect-type@1.4.0: {}
+ fast-deep-equal@3.1.3: {}
+
+ fast-string-truncated-width@3.0.3: {}
+
+ fast-string-width@3.0.2:
+ dependencies:
+ fast-string-truncated-width: 3.0.3
+
+ fast-uri@3.1.5: {}
+
+ fast-wrap-ansi@0.2.2:
+ dependencies:
+ fast-string-width: 3.0.2
+
fdir@6.5.0(picomatch@4.0.5):
optionalDependencies:
picomatch: 4.0.5
@@ -1498,14 +2226,63 @@ snapshots:
graceful-fs@4.2.11: {}
+ html-encoding-sniffer@6.0.0:
+ dependencies:
+ '@exodus/bytes': 1.15.1
+ transitivePeerDependencies:
+ - '@noble/hashes'
+
+ human-id@4.2.0: {}
+
+ import-meta-resolve@4.2.0: {}
+
+ is-potential-custom-element-name@1.0.1: {}
+
jiti@2.7.0: {}
+ jju@1.4.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: {}
+ jsonc-parser@3.3.1: {}
+
+ launch-editor@2.14.1:
+ dependencies:
+ picocolors: 1.1.1
+ shell-quote: 1.10.0
+
lightningcss-android-arm64@1.32.0:
optional: true
@@ -1604,6 +2381,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 +2391,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 +2421,12 @@ snapshots:
obug@2.1.4: {}
+ package-manager-detector@1.8.0: {}
+
+ parse5@8.0.1:
+ dependencies:
+ entities: 8.0.0
+
pathe@2.0.3: {}
picocolors@1.1.1: {}
@@ -1652,11 +2441,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 +2463,8 @@ snapshots:
react@19.2.3: {}
+ require-from-string@2.0.2: {}
+
reselect@5.2.0: {}
rolldown@1.2.4:
@@ -1686,18 +2487,30 @@ 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: {}
+ semver@7.8.5: {}
+
+ shell-quote@1.10.0: {}
+
siginfo@2.0.0: {}
+ sisteransi@1.0.5: {}
+
source-map-js@1.2.1: {}
stackback@0.0.2: {}
std-env@4.2.0: {}
+ symbol-tree@3.2.4: {}
+
tailwind-merge@3.6.0: {}
tailwindcss@4.3.3: {}
@@ -1715,12 +2528,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
@@ -1731,7 +2560,7 @@ snapshots:
dependencies:
react: 19.2.3
- vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0):
+ vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0):
dependencies:
lightningcss: 1.33.0
picomatch: 4.0.5
@@ -1742,11 +2571,12 @@ snapshots:
'@types/node': 24.13.3
fsevents: 2.3.3
jiti: 2.7.0
+ yaml: 2.9.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)(yaml@2.9.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))
+ '@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0))
'@vitest/pretty-format': 4.1.10
'@vitest/runner': 4.1.10
'@vitest/snapshot': 4.1.10
@@ -1763,16 +2593,47 @@ snapshots:
tinyexec: 1.3.0
tinyglobby: 0.2.17
tinyrainbow: 3.1.1
- vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
+ vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)(yaml@2.9.0)
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: {}
+
+ yaml@2.9.0: {}
diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml
new file mode 100644
index 0000000..e535fd3
--- /dev/null
+++ b/pnpm-workspace.yaml
@@ -0,0 +1,3 @@
+packages:
+ - packages/*
+ - examples/*
diff --git a/scripts/generate-standalone-engine.mjs b/scripts/generate-standalone-engine.mjs
index 8a13df9..158a06a 100644
--- a/scripts/generate-standalone-engine.mjs
+++ b/scripts/generate-standalone-engine.mjs
@@ -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,
diff --git a/scripts/smoke-packages.mjs b/scripts/smoke-packages.mjs
new file mode 100644
index 0000000..9d0a5fb
--- /dev/null
+++ b/scripts/smoke-packages.mjs
@@ -0,0 +1,73 @@
+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')
+const webTarball = pack('avatar-web')
+
+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)
+
+const webConsumer = path.join(workspace, 'web-consumer')
+await cp(path.join(root, 'examples/web-vite-consumer'), webConsumer, {
+ recursive: true,
+ filter: source =>
+ !source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
+})
+await cp(
+ path.join(root, 'examples/react-vite-consumer/src/strobi.avatar.json'),
+ path.join(webConsumer, 'src/strobi.avatar.json')
+)
+const webMainPath = path.join(webConsumer, 'src/main.ts')
+const webMain = await readFile(webMainPath, 'utf8')
+await writeFile(
+ webMainPath,
+ webMain.replace('../../react-vite-consumer/src/strobi.avatar.json', './strobi.avatar.json')
+)
+const webPackagePath = path.join(webConsumer, 'package.json')
+const webPackageJson = JSON.parse(await readFile(webPackagePath, 'utf8'))
+webPackageJson.dependencies['@bible-strong/avatar-web'] = `file:${webTarball}`
+webPackageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
+await writeFile(webPackagePath, `${JSON.stringify(webPackageJson, null, 2)}\n`)
+
+run(['install', '--no-frozen-lockfile'], webConsumer)
+run(['build'], webConsumer)
+process.stdout.write(`Tarball consumers verified at ${consumer} and ${webConsumer}\n`)
diff --git a/src/app/components/common.tsx b/src/app/components/common.tsx
index b3381bd..1268775 100644
--- a/src/app/components/common.tsx
+++ b/src/app/components/common.tsx
@@ -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({
- {t(title)}
+
+ {t(title)}
+ {badge && {t(badge)} }
+
{t(subtitle)}
diff --git a/src/app/styles.css b/src/app/styles.css
index 814b77b..3244490 100644
--- a/src/app/styles.css
+++ b/src/app/styles.css
@@ -382,12 +382,57 @@ input {
}
.export-avatar-summary small,
+.export-avatar-summary span,
.export-format-grid small {
color: var(--muted-foreground);
font-size: 11px;
font-weight: 500;
}
+.runtime-export-error {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+ padding: 10px;
+ border: 1px solid color-mix(in srgb, var(--destructive) 32%, var(--border));
+ border-radius: 10px;
+ background: color-mix(in srgb, var(--destructive) 5%, var(--card));
+}
+.runtime-export-error-heading {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+}
+.runtime-export-error-heading > svg {
+ width: 18px;
+ height: 18px;
+ flex: none;
+ color: var(--destructive);
+}
+.runtime-export-error-heading strong {
+ font-size: 12px;
+}
+.runtime-error-list {
+ display: grid;
+ gap: 5px;
+ margin: 0;
+ padding-left: 18px;
+ color: var(--destructive);
+ font-size: 11px;
+ line-height: 1.45;
+}
+.runtime-export-error-help {
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: 11px;
+ line-height: 1.45;
+}
+.runtime-export-error-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+
.export-avatar-summary strong {
overflow: hidden;
font-size: 16px;
@@ -402,6 +447,13 @@ input {
gap: 10px;
}
+.export-format-section {
+ display: grid;
+ min-width: 0;
+ gap: 0;
+ padding: 4px 2px 2px;
+}
+
.export-format-grid > button {
justify-content: flex-start;
min-width: 0;
@@ -431,25 +483,41 @@ input {
}
.export-format-grid strong,
.export-format-grid small {
- overflow: hidden;
- text-overflow: ellipsis;
- white-space: nowrap;
+ line-height: 1.3;
+ text-wrap: pretty;
}
.export-format-grid strong {
font-size: 11px;
}
.export-format-grid small {
- font-size: 9px;
+ font-size: 10px;
}
.export-animation-header {
align-items: center;
+ margin-bottom: 0;
}
.export-animation-header > button {
color: #4168d5;
font-size: 11px;
}
+.export-animation-header > button > svg {
+ width: 15px;
+ height: 15px;
+}
+.export-animation-picker {
+ display: grid;
+ gap: 10px;
+ margin-top: 12px;
+ padding-top: 12px;
+ border-top: 1px solid var(--border);
+}
+.export-animation-select-all {
+ justify-self: end;
+ color: #4168d5;
+ font-size: 11px;
+}
.export-animation-grid {
align-items: flex-start;
@@ -460,6 +528,168 @@ input {
min-height: 44px;
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
}
+.runtime-copy-status {
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: 11px;
+ text-align: center;
+}
+.runtime-copy-status[role='alert'] {
+ color: var(--destructive);
+}
+.runtime-quick-start-card,
+.runtime-export-card {
+ display: grid;
+ gap: 16px;
+}
+.runtime-export-description {
+ margin: 0;
+ color: var(--muted-foreground);
+ font-size: 11px;
+ line-height: 1.5;
+}
+.runtime-quick-start-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 10px;
+}
+.runtime-quick-start-heading > div,
+.runtime-export-heading > div {
+ display: grid;
+ min-width: 0;
+ gap: 2px;
+}
+.runtime-quick-start-heading h2 {
+ margin: 0;
+ font-size: 15px;
+ letter-spacing: -0.015em;
+}
+.runtime-quick-start-heading > button {
+ flex: none;
+ color: #4168d5;
+ font-size: 11px;
+}
+.runtime-quick-start-heading > button > svg {
+ width: 14px;
+ height: 14px;
+}
+.runtime-quick-start-step {
+ display: grid;
+ min-width: 0;
+ gap: 7px;
+}
+.runtime-quick-start-step > span,
+.runtime-export-heading small {
+ color: var(--muted-foreground);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.runtime-quick-start-step > code {
+ display: block;
+ overflow-x: auto;
+ padding: 10px 12px;
+ border: 1px solid #dfe3eb;
+ border-radius: 9px;
+ background: #f7f8fb;
+ color: #3159cb;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ line-height: 1.5;
+ white-space: nowrap;
+}
+.runtime-quick-start-step > pre {
+ max-width: 100%;
+ max-height: 245px;
+ margin: 0;
+ overflow: auto;
+ padding: 13px 14px;
+ border: 1px solid #2d323b;
+ border-radius: 10px;
+ background: #17191d;
+ color: #d8dee9;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 10px;
+ line-height: 1.65;
+ white-space: pre;
+}
+.runtime-export-heading {
+ display: grid;
+ gap: 8px;
+}
+.runtime-export-heading strong {
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ overflow-wrap: anywhere;
+}
+.runtime-export-actions {
+ display: grid;
+ gap: 8px;
+}
+.runtime-export-actions button {
+ width: 100%;
+ min-width: 0;
+ min-height: 42px;
+}
+.runtime-export-secondary-actions {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+.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 +708,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;
@@ -910,7 +1158,9 @@ input {
}
@media (prefers-reduced-motion: reduce) {
.studio-workspace,
- .body-workspace {
+ .body-workspace,
+ .dialog-backdrop,
+ .dialog-content {
animation: none;
}
}
@@ -1467,6 +1717,410 @@ p {
background: transparent;
animation: drawer-backdrop-in 180ms ease-out;
}
+
+.dialog-backdrop {
+ position: fixed;
+ z-index: 50;
+ inset: 0;
+ background: rgb(18 22 30 / 48%);
+ backdrop-filter: blur(8px);
+ animation: dialog-backdrop-in 180ms ease-out;
+}
+.dialog-content {
+ position: fixed;
+ z-index: 51;
+ top: 50%;
+ left: 50%;
+ display: flex;
+ width: min(760px, calc(100vw - 32px));
+ max-height: min(86dvh, 820px);
+ flex-direction: column;
+ overflow: hidden;
+ transform: translate(-50%, -50%);
+ border: 1px solid #d9dce3;
+ border-radius: 24px;
+ outline: 0;
+ background: #f5f6f8;
+ box-shadow: 0 28px 90px rgb(12 16 24 / 28%);
+ animation: dialog-content-in 220ms cubic-bezier(0.22, 1, 0.36, 1);
+}
+.dialog-header {
+ position: relative;
+ display: grid;
+ gap: 5px;
+ padding: 24px 28px 18px;
+ border-bottom: 1px solid #dfe2e8;
+ background: rgb(255 255 255 / 76%);
+}
+.dialog-title {
+ margin: 0;
+ color: #17191d;
+ font-size: 24px;
+ font-weight: 820;
+ letter-spacing: -0.045em;
+}
+.dialog-description {
+ max-width: 620px;
+ margin: 0;
+ color: #7c838e;
+ font-size: 12px;
+ line-height: 1.5;
+}
+.avatar-guide-dialog .avatar-guide-close {
+ position: absolute;
+ top: 18px;
+ right: 18px;
+}
+.runtime-preview-dialog {
+ width: min(1080px, calc(100vw - 32px));
+ max-height: min(90dvh, 880px);
+}
+.runtime-preview-dialog .runtime-preview-close {
+ position: absolute;
+ top: 18px;
+ right: 18px;
+}
+.runtime-preview-body {
+ display: grid;
+ min-height: 0;
+ grid-template-columns: minmax(0, 1.08fr) minmax(300px, 0.92fr);
+ gap: 14px;
+ overflow-y: auto;
+ padding: 18px 20px 22px;
+}
+.runtime-preview-stage-card,
+.runtime-preview-controls > section {
+ border: 1px solid #dfe2e8;
+ border-radius: 18px;
+ background: rgb(255 255 255 / 82%);
+}
+.runtime-preview-stage-card {
+ display: grid;
+ align-content: start;
+ gap: 14px;
+ padding: 16px;
+}
+.runtime-preview-stage-heading,
+.runtime-preview-control-heading {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 12px;
+}
+.runtime-preview-stage-heading > div,
+.runtime-preview-control-heading > div {
+ display: grid;
+ gap: 2px;
+}
+.runtime-preview-stage-heading small,
+.runtime-preview-control-heading small,
+.runtime-preview-current small {
+ color: var(--muted-foreground);
+ font-size: 10px;
+ font-weight: 700;
+ letter-spacing: 0.05em;
+ text-transform: uppercase;
+}
+.runtime-preview-stage-heading strong {
+ font-size: 15px;
+}
+.runtime-preview-status {
+ display: inline-flex;
+ align-items: center;
+ gap: 7px;
+ min-height: 28px;
+ padding: 0 10px;
+ border: 1px solid #dce1ea;
+ border-radius: 999px;
+ color: #687180;
+ background: #f8f9fb;
+ font-size: 10px;
+ font-weight: 750;
+}
+.runtime-preview-status > span {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background: #7d8796;
+}
+.runtime-preview-status[data-status='playing'] > span {
+ background: #45b878;
+ box-shadow: 0 0 0 4px rgb(69 184 120 / 11%);
+}
+.runtime-preview-status[data-status='paused'] > span {
+ background: #d99b35;
+ box-shadow: 0 0 0 4px rgb(217 155 53 / 11%);
+}
+.runtime-preview-stage {
+ display: grid;
+ min-height: 360px;
+ place-items: center;
+ overflow: hidden;
+ border: 1px solid #dce1e9;
+ border-radius: 16px;
+ background:
+ linear-gradient(rgb(92 113 160 / 7%) 1px, transparent 1px),
+ linear-gradient(90deg, rgb(92 113 160 / 7%) 1px, transparent 1px), #f4f6fa;
+ background-size: 20px 20px;
+}
+.runtime-preview-current {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+}
+.runtime-preview-current > div {
+ display: grid;
+ min-width: 0;
+ gap: 3px;
+ padding: 10px 12px;
+ border: 1px solid #e0e4eb;
+ border-radius: 11px;
+ background: #f9fafc;
+}
+.runtime-preview-current strong {
+ overflow: hidden;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.runtime-preview-transport {
+ display: grid;
+ grid-template-columns: repeat(4, minmax(0, 1fr));
+ gap: 7px;
+}
+.runtime-preview-transport > button {
+ min-width: 0;
+}
+.runtime-preview-error {
+ margin: 0;
+ color: var(--destructive);
+ font-size: 11px;
+}
+.runtime-preview-controls {
+ display: grid;
+ align-content: start;
+ gap: 12px;
+}
+.runtime-preview-controls > section {
+ display: grid;
+ gap: 12px;
+ padding: 14px;
+}
+.runtime-preview-control-heading h3 {
+ margin: 0;
+ font-size: 14px;
+}
+.runtime-preview-control-heading > span {
+ color: var(--muted-foreground);
+ font-size: 10px;
+}
+.runtime-preview-option-grid {
+ display: grid;
+ max-height: 224px;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 7px;
+ overflow-y: auto;
+ padding: 2px;
+}
+.runtime-preview-option-grid > button {
+ justify-content: flex-start;
+ min-width: 0;
+ padding-inline: 10px;
+}
+.runtime-preview-option-grid > button > span {
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+.runtime-preview-option-grid > button[aria-pressed='true'] {
+ border-color: #7894e7;
+ background: #edf2ff;
+ box-shadow: 0 0 0 2px rgb(91 127 229 / 12%);
+ color: #3159cb;
+}
+.runtime-preview-expression-grid {
+ max-height: 250px;
+}
+.runtime-preview-empty {
+ margin: 0;
+ padding: 18px;
+ color: var(--muted-foreground);
+ border: 1px dashed #d8dde6;
+ border-radius: 11px;
+ font-size: 11px;
+ text-align: center;
+}
+.avatar-guide-kicker {
+ display: flex;
+ align-items: center;
+ gap: 6px;
+ color: #4168d5;
+ font-size: 10px;
+ font-weight: 800;
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+}
+.avatar-guide-kicker > svg {
+ width: 14px;
+ height: 14px;
+}
+.avatar-guide-body {
+ display: grid;
+ gap: 12px;
+ overflow-y: auto;
+ padding: 18px 28px 24px;
+}
+.avatar-guide-section {
+ display: grid;
+ gap: 12px;
+ padding: 16px;
+ border: 1px solid #dfe2e8;
+ border-radius: 16px;
+ background: rgb(255 255 255 / 76%);
+}
+.avatar-guide-section--accent {
+ border-color: #c5d2fb;
+ background: linear-gradient(135deg, #f0f3ff, #ffffff);
+}
+.avatar-guide-section--api {
+ gap: 16px;
+ background: #fbfcfe;
+}
+.avatar-guide-section-heading {
+ display: flex;
+ align-items: flex-start;
+ gap: 10px;
+}
+.avatar-guide-section-heading > svg {
+ width: 18px;
+ height: 18px;
+ flex: none;
+ margin-top: 1px;
+ color: #4168d5;
+}
+.avatar-guide-section-heading h3 {
+ margin: 0;
+ color: #262a31;
+ font-size: 13px;
+ font-weight: 800;
+ letter-spacing: -0.02em;
+}
+.avatar-guide-section-heading p {
+ margin: 4px 0 0;
+ color: #717985;
+ font-size: 11px;
+ line-height: 1.55;
+}
+.avatar-guide-section-heading code {
+ padding: 1px 4px;
+ border-radius: 4px;
+ background: #edf1ff;
+ color: #3159cb;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 10px;
+}
+.avatar-guide-notice {
+ margin: 0;
+ color: #687281;
+ font-size: 11px;
+ line-height: 1.5;
+ text-wrap: pretty;
+}
+.avatar-guide-section-heading--nested {
+ margin-top: 2px;
+ padding-top: 14px;
+ border-top: 1px solid #e6e9ef;
+}
+.avatar-guide-prop-group {
+ display: grid;
+ gap: 6px;
+}
+.avatar-guide-prop-group > strong {
+ color: #4d5665;
+ font-size: 10px;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+}
+.avatar-guide-prop-group dl {
+ display: grid;
+ margin: 0;
+ overflow: hidden;
+ border: 1px solid #e0e4eb;
+ border-radius: 11px;
+ background: #ffffff;
+}
+.avatar-guide-prop {
+ display: grid;
+ grid-template-columns: minmax(150px, 0.42fr) minmax(0, 1fr);
+ gap: 14px;
+ padding: 9px 11px;
+ border-bottom: 1px solid #edf0f4;
+}
+.avatar-guide-prop:last-child {
+ border-bottom: 0;
+}
+.avatar-guide-prop dt,
+.avatar-guide-prop dd {
+ min-width: 0;
+ margin: 0;
+}
+.avatar-guide-prop dt {
+ display: grid;
+ align-content: start;
+ gap: 3px;
+}
+.avatar-guide-prop dt code {
+ color: #3159cb;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 10px;
+ font-weight: 700;
+ overflow-wrap: anywhere;
+}
+.avatar-guide-prop-type {
+ color: #8a93a1;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 10px;
+ line-height: 1.35;
+ overflow-wrap: anywhere;
+}
+.avatar-guide-prop dd {
+ color: #687281;
+ font-size: 11px;
+ line-height: 1.45;
+}
+.avatar-guide-code {
+ max-width: 100%;
+ margin: 0;
+ overflow-x: auto;
+ padding: 12px 14px;
+ border: 1px solid #2d323b;
+ border-radius: 11px;
+ background: #17191d;
+ color: #d8dee9;
+ font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
+ font-size: 11px;
+ line-height: 1.6;
+ white-space: pre;
+}
+.avatar-guide-footer {
+ display: flex;
+ justify-content: flex-end;
+ padding: 14px 28px 20px;
+ border-top: 1px solid #dfe2e8;
+ background: rgb(255 255 255 / 76%);
+}
+@keyframes dialog-backdrop-in {
+ from {
+ opacity: 0;
+ }
+}
+@keyframes dialog-content-in {
+ from {
+ opacity: 0;
+ transform: translate(-50%, calc(-50% + 12px)) scale(0.98);
+ }
+}
.drawer-viewport {
position: fixed;
z-index: 41;
@@ -1622,12 +2276,38 @@ 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;
}
.render-style-field > [data-slot='select-trigger'] {
width: 150px;
}
+.render-style-disabled {
+ display: grid;
+ gap: 12px;
+}
+.render-style-status {
+ display: flex;
+ justify-content: flex-start;
+}
.pixel-render-options {
display: grid;
margin-top: 14px;
@@ -2144,9 +2824,26 @@ p {
padding: 2px 4px;
border-radius: 5px;
background: rgb(255 255 255 / 78%);
- font-size: 8px;
+ font-size: 9px;
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%);
@@ -2499,6 +3196,55 @@ p {
}
@media (max-width: 600px) {
+ .dialog-content {
+ width: calc(100vw - 20px);
+ max-height: 90dvh;
+ border-radius: 20px;
+ }
+ .dialog-header {
+ padding: 20px 18px 16px;
+ }
+ .dialog-title {
+ max-width: calc(100% - 32px);
+ font-size: 20px;
+ }
+ .avatar-guide-body {
+ padding: 14px 14px 18px;
+ }
+ .avatar-guide-section {
+ padding: 13px;
+ }
+ .avatar-guide-prop {
+ grid-template-columns: minmax(0, 1fr);
+ gap: 3px;
+ }
+ .avatar-guide-footer {
+ padding: 12px 18px 16px;
+ }
+ .runtime-preview-dialog {
+ width: min(100vw - 20px, 620px);
+ max-height: calc(100dvh - 20px);
+ }
+ .runtime-preview-body {
+ grid-template-columns: minmax(0, 1fr);
+ padding: 14px;
+ }
+ .runtime-preview-stage {
+ min-height: min(64vw, 300px);
+ }
+ .runtime-preview-transport {
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ }
+ .runtime-preview-option-grid {
+ max-height: none;
+ }
+ .runtime-quick-start-heading {
+ align-items: flex-start;
+ flex-direction: column;
+ }
+ .runtime-quick-start-heading > button {
+ padding-inline: 0;
+ }
html,
body,
#root {
diff --git a/src/components/ui/dialog.tsx b/src/components/ui/dialog.tsx
new file mode 100644
index 0000000..3f17ea6
--- /dev/null
+++ b/src/components/ui/dialog.tsx
@@ -0,0 +1,37 @@
+import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
+import type * as React from 'react'
+
+import { cn } from '@/lib/utils'
+
+function Dialog(props: DialogPrimitive.Root.Props) {
+ return
+}
+
+function DialogClose(props: DialogPrimitive.Close.Props) {
+ return
+}
+
+function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
+ return (
+
+
+
+ {children}
+
+
+ )
+}
+
+function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
+ return
+}
+
+function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
+ return
+}
+
+function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
+ return
+}
+
+export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle }
diff --git a/src/features/animation/__tests__/sequences-test.ts b/src/features/animation/__tests__/sequences-test.ts
index c7cb909..863ba33 100644
--- a/src/features/animation/__tests__/sequences-test.ts
+++ b/src/features/animation/__tests__/sequences-test.ts
@@ -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)
+ })
})
diff --git a/src/features/animation/components/SequenceWorkspace.tsx b/src/features/animation/components/SequenceWorkspace.tsx
index 14f76bf..32c8cc1 100644
--- a/src/features/animation/components/SequenceWorkspace.tsx
+++ b/src/features/animation/components/SequenceWorkspace.tsx
@@ -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(null)
@@ -161,6 +163,35 @@ export function SequenceWorkspace({
compact
>
+
+
+ {t('Clé sémantique')}
+
+
+ onChange({
+ ...editing.draft,
+ semanticKey: event.currentTarget.value || undefined,
+ })
+ }
+ />
+
+ {semanticKeyError ??
+ t('Clé publique stable utilisée par l’API runtime, par exemple thinking.')}
+
+
{t('Nom')}
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') })),
diff --git a/src/features/avatar/__tests__/avatar-definition-test.ts b/src/features/avatar/__tests__/avatar-definition-test.ts
new file mode 100644
index 0000000..e0ff488
--- /dev/null
+++ b/src/features/avatar/__tests__/avatar-definition-test.ts
@@ -0,0 +1,630 @@
+import {
+ AVATAR_DEFINITION_MAX_BYTES,
+ avatarDefinitionFileName,
+ createAvatarDefinition,
+ parseAvatarDefinition,
+ 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' },
+ renderStyle: { type: 'vector' },
+ 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(structuredClone(result.value)).toEqual(input)
+ expect(validateAvatarDefinition(structuredClone(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(JSON.stringify(result.value)).not.toContain('expression-00')
+ })
+
+ it('exports a valid expression-only definition when no animation is selected', () => {
+ const behavior = behaviorFixture()
+ const result = createAvatarDefinition({
+ avatar: avatarFixture(),
+ behavior: { ...behavior, sequences: [] },
+ })
+
+ expect(result.ok).toBe(true)
+ if (!result.ok) return
+ expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
+ expect(result.value.animations).toEqual({})
+ expect(result.value.animationOrder).toEqual([])
+ })
+
+ it('rejects newly created custom content until semantic keys are supplied', () => {
+ 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('runtime definition filenames', () => {
+ it('creates a sanitized runtime-definition filename', () => {
+ expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
+ expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
+ })
+})
diff --git a/src/features/avatar/__tests__/avatars-test.ts b/src/features/avatar/__tests__/avatars-test.ts
index 69835a8..3ca19b3 100644
--- a/src/features/avatar/__tests__/avatars-test.ts
+++ b/src/features/avatar/__tests__/avatars-test.ts
@@ -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)
})
@@ -41,20 +52,14 @@ describe('avatar render style', () => {
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
})
- it('sanitizes pixel settings', () => {
+ it('falls back to vector rendering while pixel mode is disabled', () => {
expect(
parseAvatarRenderStyle({
type: 'pixel',
resolution: 500,
})
- ).toEqual({
- type: 'pixel',
- resolution: 192,
- })
- expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
- type: 'pixel',
- resolution: 8,
- })
+ ).toEqual({ type: 'vector' })
+ expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
})
})
diff --git a/src/features/avatar/__tests__/generate-strobi-fixture-test.ts b/src/features/avatar/__tests__/generate-strobi-fixture-test.ts
new file mode 100644
index 0000000..7d29639
--- /dev/null
+++ b/src/features/avatar/__tests__/generate-strobi-fixture-test.ts
@@ -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)
+})
diff --git a/src/features/avatar/ambientMotion.ts b/src/features/avatar/ambientMotion.ts
index 2df3a01..11f5adb 100644
--- a/src/features/avatar/ambientMotion.ts
+++ b/src/features/avatar/ambientMotion.ts
@@ -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(eyeMotionModes)
-const bodyMotionSet = new Set(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'
diff --git a/src/features/avatar/avatarDefinition.ts b/src/features/avatar/avatarDefinition.ts
new file mode 100644
index 0000000..ecea833
--- /dev/null
+++ b/src/features/avatar/avatarDefinition.ts
@@ -0,0 +1,191 @@
+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 = (
+ surface: SurfaceDefinition
+): SurfaceDefinition => ({
+ 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
+): 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 => {
+ const errors: AvatarDefinitionError[] = []
+ const expressionKeys = new Set()
+ const animationKeys = new Set()
+ const expressionKeyById = new Map()
+
+ 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 = {
+ neutral: mapExpression(applyAvatarEyeDefaults(defaultExpression, avatar.eyes)),
+ }
+ behavior.expressions.forEach(expression => {
+ expressions[expression.semanticKey!] = mapExpression(
+ applyAvatarEyeDefaults(expression, avatar.eyes)
+ )
+ })
+ const animations: Record = 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),
+ 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!),
+ }
+ return validateAvatarDefinition(definition)
+}
diff --git a/src/features/avatar/avatars.ts b/src/features/avatar/avatars.ts
index 72e2375..5a9d491 100644
--- a/src/features/avatar/avatars.ts
+++ b/src/features/avatar/avatars.ts
@@ -25,6 +25,7 @@ export type StudioAvatar = {
}
export type AvatarColors = { body: string; eyes: string }
+export const PIXEL_RENDERING_ENABLED = false
export type PixelRenderStyle = {
type: 'pixel'
resolution: number
@@ -85,7 +86,9 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as Partial | null
- if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
+ if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
+ return { ...defaultAvatarRenderStyle }
+ }
return {
type: 'pixel',
resolution: Math.round(
@@ -120,6 +123,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 +162,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 +183,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 +231,18 @@ const parseAvatarBehavior = (
const candidate = value as Partial
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
const expressions = parseExpressions(candidate.expressions)
- const sequences = normalizeSequencesForExpressions(
- Array.isArray(candidate.sequences)
- ? parseSequences(candidate.sequences)
- : cloneSequences(base.sequences),
- expressions
+ 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 => ({
diff --git a/src/features/avatar/body.ts b/src/features/avatar/body.ts
index 99db4c7..11f5adb 100644
--- a/src/features/avatar/body.ts
+++ b/src/features/avatar/body.ts
@@ -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
- 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
- const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
- const seenIds = new Set()
- 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'
diff --git a/src/features/avatar/components/ExpressionWorkspace.tsx b/src/features/avatar/components/ExpressionWorkspace.tsx
index dde381f..61ce31e 100644
--- a/src/features/avatar/components/ExpressionWorkspace.tsx
+++ b/src/features/avatar/components/ExpressionWorkspace.tsx
@@ -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) => void
onDrop?: (event: React.DragEvent) => void
onDragEnd?: () => void
+ runtimeError: string | null
}) {
const { t } = useStudioLanguage()
const card = (
@@ -187,6 +191,16 @@ export function ExpressionCard({
renderStyle={renderStyle}
id={previewId}
/>
+ {runtimeError && (
+
+ !
+
+ )}
{String(index).padStart(2, '0')}
)
@@ -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({
+
+
+
+
+ {t('Clé sémantique')}
+
+
+ update({ semanticKey: event.currentTarget.value || undefined })
+ }
+ />
+
+ {semanticKeyError ??
+ t('Clé publique stable utilisée par l’API runtime, par exemple happy-smile.')}
+
+
+
+
-
-export type AvatarPose = {
- expression: Expression
- orientation: Quaternion
-}
-
-export type AvatarGeometry = {
- backPaths: string[]
- frontPaths: string[]
- backNodeIds: (string | null)[]
- frontNodeIds: (string | null)[]
- headPath: string
- leftPath: string
- rightPath: string
- leftVisible: boolean
- rightVisible: boolean
- wirePaths: string[]
-}
-
-export type RenderAvatarOptions = {
- includeWire?: boolean
- bodyNodes?: BodyNode[]
- eyeOffset?: Readonly<{ x: number; y: number }>
-}
-
-export type EyeEditorGeometry = {
- visible: boolean
- selectionPath: string
- widthGuide: string
- heightGuide: string
- rotationGuide: string
- spacingGuide: string
- center: Point3
- widthHandle: Point3
- heightHandle: Point3
- rotateHandle: Point3
- sizeHandle: Point3
- spacingHandle: Point3
-}
-
-export type BodyNodeEditorGeometry = {
- center: Point3
- axes: Record<'x' | 'y' | 'z', Point3>
- rings: Record<'x' | 'y' | 'z', Point3[]>
-}
-
-export const RADIUS = 120
-const FOCAL_LENGTH = 620
-const QUARTER_ARC_SAMPLES = 14
-
-export const expressionFields: ExpressionNumericField[] = [
- 'headX',
- 'headY',
- 'headZ',
- 'widthLeft',
- 'widthRight',
- 'heightLeft',
- 'heightRight',
- 'spacing',
- 'positionXLeft',
- 'positionXRight',
- 'positionYLeft',
- 'positionYRight',
- 'leftAngle',
- 'rightAngle',
- 'perspective',
-]
-
-export const clamp = (value: number, min: number, max: number) =>
- Math.max(min, Math.min(max, value))
-
-export const radians = (degrees: number) => (degrees * Math.PI) / 180
-
-export const normalizeQuaternion = ([w, x, y, z]: Quaternion): Quaternion => {
- const length = Math.hypot(w, x, y, z) || 1
- return [w / length, x / length, y / length, z / length]
-}
-
-export const multiplyQuaternions = (
- [aw, ax, ay, az]: Quaternion,
- [bw, bx, by, bz]: Quaternion
-): Quaternion =>
- normalizeQuaternion([
- aw * bw - ax * bx - ay * by - az * bz,
- aw * bx + ax * bw + ay * bz - az * by,
- aw * by - ax * bz + ay * bw + az * bx,
- aw * bz + ax * by - ay * bx + az * bw,
- ])
-
-export const quaternionFromAxisAngle = ([x, y, z]: Point3, angle: number): Quaternion => {
- const halfAngle = angle / 2
- const sine = Math.sin(halfAngle)
- return normalizeQuaternion([Math.cos(halfAngle), x * sine, y * sine, z * sine])
-}
-
-export const quaternionFromEuler = (x: number, y: number, z: number): Quaternion => {
- const xRotation = quaternionFromAxisAngle([1, 0, 0], x)
- const yRotation = quaternionFromAxisAngle([0, 1, 0], y)
- const zRotation = quaternionFromAxisAngle([0, 0, 1], z)
- return multiplyQuaternions(multiplyQuaternions(zRotation, xRotation), yRotation)
-}
-
-export const quaternionFromVectors = (from: Point3, to: Point3): Quaternion => {
- const dot = from[0] * to[0] + from[1] * to[1] + from[2] * to[2]
- const cross: Point3 = [
- from[1] * to[2] - from[2] * to[1],
- from[2] * to[0] - from[0] * to[2],
- from[0] * to[1] - from[1] * to[0],
- ]
- return normalizeQuaternion([1 + dot, cross[0], cross[1], cross[2]])
-}
-
-export const quaternionToEuler = ([w, x, y, z]: Quaternion): Point3 => {
- const matrix00 = 1 - 2 * (y * y + z * z)
- const matrix01 = 2 * (x * y - z * w)
- const matrix10 = 2 * (x * y + z * w)
- const matrix11 = 1 - 2 * (x * x + z * z)
- const matrix20 = 2 * (x * z - y * w)
- const matrix21 = 2 * (y * z + x * w)
- const matrix22 = 1 - 2 * (x * x + y * y)
- const headX = Math.asin(clamp(matrix21, -1, 1))
- if (Math.abs(Math.cos(headX)) < 0.00001) return [headX, 0, Math.atan2(matrix10, matrix00)]
- return [headX, Math.atan2(-matrix20, matrix22), Math.atan2(-matrix01, matrix11)]
-}
-
-const nearestEquivalentAngle = (angle: number, current: number) => {
- let result = angle
- while (result - current > 180) result -= 360
- while (result - current < -180) result += 360
- return clamp(result, -365, 365)
-}
-
-export const expressionWithOrientation = (
- expression: Expression,
- orientation: Quaternion
-): Expression => {
- const [radiansX, radiansY, radiansZ] = quaternionToEuler(orientation)
- const x = (radiansX * 180) / Math.PI
- const y = (radiansY * 180) / Math.PI
- const z = (radiansZ * 180) / Math.PI
- return {
- ...expression,
- headX: nearestEquivalentAngle(x, expression.headX),
- headY: nearestEquivalentAngle(y, expression.headY),
- headZ: nearestEquivalentAngle(z, expression.headZ),
- }
-}
-
-export const slerpQuaternion = (
- start: Quaternion,
- end: Quaternion,
- progress: number
-): Quaternion => {
- let target = end
- let dot = start.reduce((total, value, index) => total + value * target[index], 0)
- if (dot < 0) {
- target = target.map(value => -value) as unknown as Quaternion
- dot = -dot
- }
- if (dot > 0.9995) {
- return normalizeQuaternion(
- start.map(
- (value, index) => value + (target[index] - value) * progress
- ) as unknown as Quaternion
- )
- }
- const angle = Math.acos(clamp(dot, -1, 1))
- const sine = Math.sin(angle)
- const startWeight = Math.sin((1 - progress) * angle) / sine
- const targetWeight = Math.sin(progress * angle) / sine
- return normalizeQuaternion(
- start.map(
- (value, index) => value * startWeight + target[index] * targetWeight
- ) as unknown as Quaternion
- )
-}
-
-export const rotateWithQuaternion = ([w, x, y, z]: Quaternion, [px, py, pz]: Point3): Point3 => {
- const tx = 2 * (y * pz - z * py)
- const ty = 2 * (z * px - x * pz)
- const tz = 2 * (x * py - y * px)
- return [
- px + w * tx + (y * tz - z * ty),
- py + w * ty + (z * tx - x * tz),
- pz + w * tz + (x * ty - y * tx),
- ]
-}
-
-const roundedRectangle = (width: number, height: number): (readonly [number, number])[] => {
- const halfWidth = width / 2
- const halfHeight = height / 2
- const cornerRadius = Math.min(halfHeight, halfWidth)
- const points: (readonly [number, number])[] = []
- const addLine = (start: readonly [number, number], end: readonly [number, number]) => {
- const samples = Math.max(2, Math.ceil(Math.hypot(end[0] - start[0], end[1] - start[1]) / 1.5))
- for (let index = 0; index < samples; index += 1) {
- const progress = index / samples
- points.push([
- start[0] + (end[0] - start[0]) * progress,
- start[1] + (end[1] - start[1]) * progress,
- ])
- }
- }
- const addArc = (centerX: number, centerY: number, startAngle: number) => {
- for (let index = 0; index < QUARTER_ARC_SAMPLES; index += 1) {
- const angle = startAngle + (index / QUARTER_ARC_SAMPLES) * (Math.PI / 2)
- points.push([
- centerX + Math.cos(angle) * cornerRadius,
- centerY + Math.sin(angle) * cornerRadius,
- ])
- }
- }
- addLine([-halfWidth + cornerRadius, -halfHeight], [halfWidth - cornerRadius, -halfHeight])
- addArc(halfWidth - cornerRadius, -halfHeight + cornerRadius, -Math.PI / 2)
- addLine([halfWidth, -halfHeight + cornerRadius], [halfWidth, halfHeight - cornerRadius])
- addArc(halfWidth - cornerRadius, halfHeight - cornerRadius, 0)
- addLine([halfWidth - cornerRadius, halfHeight], [-halfWidth + cornerRadius, halfHeight])
- addArc(-halfWidth + cornerRadius, halfHeight - cornerRadius, Math.PI / 2)
- addLine([-halfWidth, halfHeight - cornerRadius], [-halfWidth, -halfHeight + cornerRadius])
- addArc(-halfWidth + cornerRadius, -halfHeight + cornerRadius, Math.PI)
- return points
-}
-
-const project = (point: Point3, perspective: number): Point3 => {
- const denominator = FOCAL_LENGTH - point[2] * perspective
- const scale = Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
- return [point[0] * scale, point[1] * scale, point[2]]
-}
-
-export const axisVector = (axis: 'x' | 'y' | 'z'): Point3 =>
- axis === 'x' ? [1, 0, 0] : axis === 'y' ? [0, 1, 0] : [0, 0, 1]
-
-export const rotateExpressionAroundAxis = (
- expression: Expression,
- axis: 'x' | 'y' | 'z',
- deltaDegrees: number
-) => {
- const startOrientation = poseFromExpression(expression).orientation
- const worldAxis = rotateWithQuaternion(startOrientation, axisVector(axis))
- const orientation = multiplyQuaternions(
- quaternionFromAxisAngle(worldAxis, radians(deltaDegrees)),
- startOrientation
- )
- return expressionWithOrientation(expression, orientation)
-}
-
-export const rotateExpressionAroundCamera = (expression: Expression, deltaRadians: number) => {
- const startOrientation = poseFromExpression(expression).orientation
- return expressionWithOrientation(
- expression,
- multiplyQuaternions(quaternionFromAxisAngle([0, 0, 1], deltaRadians), startOrientation)
- )
-}
-
-const arcballVector = ([xValue, yValue]: readonly [number, number]): Point3 => {
- const x = xValue / RADIUS
- const y = yValue / RADIUS
- const squaredLength = x * x + y * y
- if (squaredLength <= 1) return [x, y, Math.sqrt(1 - squaredLength)]
- const length = Math.sqrt(squaredLength)
- return [x / length, y / length, 0]
-}
-
-export const rotateExpressionWithArcball = (
- expression: Expression,
- startPoint: readonly [number, number],
- currentPoint: readonly [number, number]
-) => {
- const startOrientation = poseFromExpression(expression).orientation
- const delta = quaternionFromVectors(arcballVector(startPoint), arcballVector(currentPoint))
- return expressionWithOrientation(expression, multiplyQuaternions(delta, startOrientation))
-}
-
-export const rotationRing = (pose: AvatarPose, axis: 'x' | 'y' | 'z', radius = 30): Point3[] =>
- Array.from({ length: 97 }, (_, index) => {
- const angle = (index / 96) * Math.PI * 2
- const cosine = Math.cos(angle)
- const sine = Math.sin(angle)
- const point: Point3 =
- axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
- const rotated = rotateWithQuaternion(pose.orientation, point)
- return [rotated[0] * radius, rotated[1] * radius, rotated[2]]
- })
-
-export const renderBodyNodeEditor = (
- pose: AvatarPose,
- node: BodyNode,
- axisLength = 34,
- ringRadius = 26
-): BodyNodeEditorGeometry => {
- const projectInHeadSpace = (point: Point3) =>
- project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
- const center = projectInHeadSpace(node.position)
- const localOrientation = quaternionFromEuler(
- radians(node.rotation[0]),
- radians(node.rotation[1]),
- radians(node.rotation[2])
- )
- const axes = Object.fromEntries(
- (['x', 'y', 'z'] as const).map(axis => {
- const vector = rotateWithQuaternion(localOrientation, axisVector(axis))
- return [
- axis,
- projectInHeadSpace([
- node.position[0] + vector[0] * axisLength,
- node.position[1] + vector[1] * axisLength,
- node.position[2] + vector[2] * axisLength,
- ]),
- ]
- })
- ) as BodyNodeEditorGeometry['axes']
- ;(['x', 'y', 'z'] as const).forEach(axis => {
- const endpoint = axes[axis]
- if (Math.hypot(endpoint[0] - center[0], endpoint[1] - center[1]) >= 12) return
- const fallback: Point3 =
- axis === 'x'
- ? [center[0] + 18, center[1], endpoint[2]]
- : axis === 'y'
- ? [center[0], center[1] + 18, endpoint[2]]
- : [center[0] + 14, center[1] + 14, endpoint[2]]
- axes[axis] = fallback
- })
- const rings = Object.fromEntries(
- (['x', 'y', 'z'] as const).map(axis => [
- axis,
- Array.from({ length: 65 }, (_, index) => {
- const angle = (index / 64) * Math.PI * 2
- const cosine = Math.cos(angle) * ringRadius
- const sine = Math.sin(angle) * ringRadius
- const localPoint: Point3 =
- axis === 'x' ? [0, cosine, sine] : axis === 'y' ? [cosine, 0, sine] : [cosine, sine, 0]
- const rotated = rotateWithQuaternion(localOrientation, localPoint)
- return projectInHeadSpace([
- node.position[0] + rotated[0],
- node.position[1] + rotated[1],
- node.position[2] + rotated[2],
- ])
- }),
- ])
- ) as BodyNodeEditorGeometry['rings']
- return { center, axes, rings }
-}
-
-export const translateBodyNodeAlongLocalAxis = (
- node: BodyNode,
- axis: 'x' | 'y' | 'z',
- distance: number
-): BodyNode => {
- const orientation = quaternionFromEuler(
- radians(node.rotation[0]),
- radians(node.rotation[1]),
- radians(node.rotation[2])
- )
- const direction = rotateWithQuaternion(orientation, axisVector(axis))
- return {
- ...node,
- position: [
- node.position[0] + direction[0] * distance,
- node.position[1] + direction[1] * distance,
- node.position[2] + direction[2] * distance,
- ],
- }
-}
-
-export const translateBodyNodeInCameraPlane = (
- node: BodyNode,
- pose: AvatarPose,
- screenDeltaX: number,
- screenDeltaY: number
-): BodyNode => {
- const cameraPosition = rotateWithQuaternion(pose.orientation, node.position)
- const denominator = FOCAL_LENGTH - cameraPosition[2] * pose.expression.perspective
- const perspectiveScale =
- Math.abs(denominator) < 0.0001 ? FOCAL_LENGTH / 0.0001 : FOCAL_LENGTH / denominator
- const [w, x, y, z] = pose.orientation
- const headDelta = rotateWithQuaternion(
- [w, -x, -y, -z],
- [screenDeltaX / perspectiveScale, screenDeltaY / perspectiveScale, 0]
- )
- return {
- ...node,
- position: [
- node.position[0] + headDelta[0],
- node.position[1] + headDelta[1],
- node.position[2] + headDelta[2],
- ],
- }
-}
-
-export const rotateBodyNodeAroundLocalAxis = (
- node: BodyNode,
- axis: 'x' | 'y' | 'z',
- deltaDegrees: number
-): BodyNode => {
- const orientation = quaternionFromEuler(
- radians(node.rotation[0]),
- radians(node.rotation[1]),
- radians(node.rotation[2])
- )
- const rotated = multiplyQuaternions(
- orientation,
- quaternionFromAxisAngle(axisVector(axis), radians(deltaDegrees))
- )
- const next = quaternionToEuler(rotated).map(value => (value * 180) / Math.PI) as [
- number,
- number,
- number,
- ]
- return {
- ...node,
- rotation: next.map((value, index) => nearestEquivalentAngle(value, node.rotation[index])) as [
- number,
- number,
- number,
- ],
- }
-}
-
-const path = (points: Point3[], close = true) => {
- if (!points.length) return ''
- return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
- .slice(1)
- .map(point => `L${point[0].toFixed(2)} ${point[1].toFixed(2)}`)
- .join('')}${close ? 'Z' : ''}`
-}
-
-export const poseFromExpression = (expression: Expression): AvatarPose => ({
- expression,
- orientation: quaternionFromEuler(
- radians(expression.headX),
- radians(expression.headY),
- radians(expression.headZ)
- ),
-})
-
-export const interpolatePose = (from: AvatarPose, to: AvatarPose, progress: number): AvatarPose => {
- const expression: Expression = { ...from.expression }
- expressionFields.forEach(field => {
- expression[field] =
- from.expression[field] + (to.expression[field] - from.expression[field]) * progress
- })
- return {
- expression,
- orientation: slerpQuaternion(from.orientation, to.orientation, progress),
- }
-}
-
-type ProjectedSurfacePoint = { point: Point3; normal: Point3 }
-type LocalSurfacePoint = ProjectedSurfacePoint
-
-const MAX_SURFACE_CACHE_ENTRIES = 24
-const HEAD_LATITUDE_SAMPLES = 25
-const HEAD_LONGITUDE_SAMPLES = 73
-const PRIMITIVE_RING_SAMPLES = 144
-const ROUNDED_PRIMITIVE_LATITUDE_SAMPLES = 33
-const ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES = 73
-const headSamplesCache = new Map()
-const accessorySamplesCache = new Map()
-const wireSamplesCache = new Map()
-
-const surfaceCacheKey = (surface: SurfaceConfig) =>
- [
- surface.type,
- surface.width,
- surface.height,
- surface.depth,
- surface.roundness,
- surface.morphRoundness,
- surface.tipRoundness,
- surface.baseRoundness,
- ]
- .map(value => (typeof value === 'number' ? value.toFixed(4) : value))
- .join(':')
-
-const cacheSurfaceValue = (cache: Map, key: string, value: Value) => {
- if (cache.size >= MAX_SURFACE_CACHE_ENTRIES) cache.delete(cache.keys().next().value!)
- cache.set(key, value)
- return value
-}
-
-const localSurfacePoint = (
- surface: SurfaceConfig,
- longitude: number,
- latitude: number
-): LocalSurfacePoint => surfaceSampleAt(surface, longitude, latitude)
-
-const projectLocalSurfacePoint = (
- pose: AvatarPose,
- sample: LocalSurfacePoint
-): ProjectedSurfacePoint => ({
- point: project(rotateWithQuaternion(pose.orientation, sample.point), pose.expression.perspective),
- normal: rotateWithQuaternion(pose.orientation, sample.normal),
-})
-
-const canonicalFaceCoordinates = (x: number, y: number): readonly [number, number] => {
- const longitude = x / RADIUS
- const latitude = y / RADIUS
- return [RADIUS * Math.cos(latitude) * Math.sin(longitude), RADIUS * Math.sin(latitude)]
-}
-
-const projectFacePoint = (
- pose: AvatarPose,
- surface: SurfaceConfig,
- x: number,
- y: number
-): ProjectedSurfacePoint => {
- const [faceX, faceY] = canonicalFaceCoordinates(x, y)
- return projectLocalSurfacePoint(pose, surfaceFrontSampleAt(surface, faceX, faceY))
-}
-
-const eyePoints = (
- pose: AvatarPose,
- surface: SurfaceConfig,
- side: -1 | 1,
- blink: number,
- offset: Readonly<{ x: number; y: number }> = { x: 0, y: 0 }
-): ProjectedSurfacePoint[] => {
- const expression = pose.expression
- const suffix = side < 0 ? 'Left' : 'Right'
- const width = expression[`width${suffix}`]
- const restingHeight = expression[`height${suffix}`]
- const height = 5 + (restingHeight - 5) * blink
- const centerX = (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + offset.x
- const centerY = expression[`positionY${suffix}`] + offset.y
- const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
- return roundedRectangle(width, height).map(([localX, localY]) => {
- const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
- const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
- return projectFacePoint(pose, surface, centerX + rotatedX, centerY + rotatedY)
- })
-}
-
-const visiblePath = (points: ProjectedSurfacePoint[]) => {
- const segments: Point3[][] = []
- let segment: Point3[] = []
- points.forEach(({ point, normal }) => {
- if (normal[2] > 0) segment.push(point)
- else if (segment.length) {
- segments.push(segment)
- segment = []
- }
- })
- if (segment.length) segments.push(segment)
- return segments
- .filter(item => item.length > 1)
- .map(item => path(item, false))
- .join('')
-}
-
-const wirePaths = (pose: AvatarPose, surface: SurfaceConfig): string[] => {
- const key = surfaceCacheKey(surface)
- let samples = wireSamplesCache.get(key)
- if (!samples) {
- const parallels = [-60, -30, 0, 30, 60].map(latitude =>
- Array.from({ length: 73 }, (_, index) =>
- localSurfacePoint(surface, radians(-180 + index * 5), radians(latitude))
- )
- )
- const meridians = Array.from(
- { length: 12 },
- (_, longitudeIndex) => -150 + longitudeIndex * 30
- ).map(longitude =>
- Array.from({ length: 37 }, (_, index) =>
- localSurfacePoint(surface, radians(longitude), radians(-90 + index * 5))
- )
- )
- samples = cacheSurfaceValue(wireSamplesCache, key, [...parallels, ...meridians])
- }
- return samples.map(curve =>
- visiblePath(curve.map(sample => projectLocalSurfacePoint(pose, sample)))
- )
-}
-
-const projectEyePoint = (
- pose: AvatarPose,
- surface: SurfaceConfig,
- side: -1 | 1,
- localX: number,
- localY: number
-): Point3 => {
- const expression = pose.expression
- const suffix = side < 0 ? 'Left' : 'Right'
- const angle = radians(side < 0 ? expression.leftAngle : expression.rightAngle)
- const rotatedX = localX * Math.cos(angle) - localY * Math.sin(angle)
- const rotatedY = localX * Math.sin(angle) + localY * Math.cos(angle)
- return projectFacePoint(
- pose,
- surface,
- (side * expression.spacing) / 2 + expression[`positionX${suffix}`] + rotatedX,
- expression[`positionY${suffix}`] + rotatedY
- ).point
-}
-
-export const renderEyeEditor = (
- pose: AvatarPose,
- surface: SurfaceConfig,
- side: -1 | 1
-): EyeEditorGeometry => {
- const expression = pose.expression
- const suffix = side < 0 ? 'Left' : 'Right'
- const width = expression[`width${suffix}`]
- const height = expression[`height${suffix}`]
- const selectedSamples = eyePoints(pose, surface, side, 1)
- const selectedPoints = selectedSamples.map(sample => sample.point)
- const center = projectEyePoint(pose, surface, side, 0, 0)
- const widthHandle = projectEyePoint(pose, surface, side, width / 2 + 9, 0)
- const heightHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 9)
- const rotateHandle = projectEyePoint(pose, surface, side, 0, -height / 2 - 30)
- const sizeHandle = projectEyePoint(pose, surface, side, width / 2 + 11, height / 2 + 11)
- const leftCenter = projectEyePoint(pose, surface, -1, 0, 0)
- const rightCenter = projectEyePoint(pose, surface, 1, 0, 0)
- const spacingCenterX = (expression.positionXLeft + expression.positionXRight) / 2
- const spacingCenterY = (expression.positionYLeft + expression.positionYRight) / 2
- const spacingHandle = projectFacePoint(
- pose,
- surface,
- spacingCenterX,
- spacingCenterY + height / 2 + 34
- ).point
- const spacingMiddle: Point3 = [
- (leftCenter[0] + rightCenter[0]) / 2,
- (leftCenter[1] + rightCenter[1]) / 2,
- (leftCenter[2] + rightCenter[2]) / 2,
- ]
- const line = (from: Point3, to: Point3) => path([from, to], false)
- return {
- visible: selectedSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
- selectionPath: path(selectedPoints),
- widthGuide: line(center, widthHandle),
- heightGuide: line(center, heightHandle),
- rotationGuide: line(heightHandle, rotateHandle),
- spacingGuide: `${line(leftCenter, rightCenter)}${line(spacingMiddle, spacingHandle)}`,
- center,
- widthHandle,
- heightHandle,
- rotateHandle,
- sizeHandle,
- spacingHandle,
- }
-}
-
-const convexHull = (points: Point3[]): Point3[] => {
- const sorted = [...points].sort((left, right) => left[0] - right[0] || left[1] - right[1])
- const cross = (origin: Point3, first: Point3, second: Point3) =>
- (first[0] - origin[0]) * (second[1] - origin[1]) -
- (first[1] - origin[1]) * (second[0] - origin[0])
- const half = (source: Point3[]) => {
- const result: Point3[] = []
- source.forEach(point => {
- while (result.length >= 2 && cross(result.at(-2)!, result.at(-1)!, point) <= 0) result.pop()
- result.push(point)
- })
- return result
- }
- return [...half(sorted).slice(0, -1), ...half(sorted.reverse()).slice(0, -1)]
-}
-
-const smoothClosedPath = (points: Point3[]) => {
- if (points.length < 3) return path(points)
- const pointAt = (index: number) => points[(index + points.length) % points.length]
- return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
- .map((point, index) => {
- const previous = pointAt(index - 1)
- const next = pointAt(index + 1)
- const afterNext = pointAt(index + 2)
- const firstControl: Point3 = [
- point[0] + (next[0] - previous[0]) / 6,
- point[1] + (next[1] - previous[1]) / 6,
- point[2],
- ]
- const secondControl: Point3 = [
- next[0] - (afterNext[0] - point[0]) / 6,
- next[1] - (afterNext[1] - point[1]) / 6,
- next[2],
- ]
- return `C${firstControl[0].toFixed(2)} ${firstControl[1].toFixed(2)} ${secondControl[0].toFixed(2)} ${secondControl[1].toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
- })
- .join('')}Z`
-}
-
-const densifyClosedPoints = (points: Point3[], maximumDistance = 7) =>
- points.flatMap((point, index) => {
- const next = points[(index + 1) % points.length]
- const steps = Math.max(
- 1,
- Math.ceil(Math.hypot(next[0] - point[0], next[1] - point[1]) / maximumDistance)
- )
- return Array.from({ length: steps }, (_, step) => {
- const progress = step / steps
- return [
- point[0] + (next[0] - point[0]) * progress,
- point[1] + (next[1] - point[1]) * progress,
- point[2] + (next[2] - point[2]) * progress,
- ] as Point3
- })
- })
-
-const smoothOpenPath = (points: Point3[]) => {
- if (!points.length) return ''
- if (points.length === 1) return `${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}`
- return points
- .slice(0, -1)
- .map((point, index) => {
- const previous = points[Math.max(0, index - 1)]
- const next = points[index + 1]
- const afterNext = points[Math.min(points.length - 1, index + 2)]
- const firstControlX = point[0] + (next[0] - previous[0]) / 6
- const firstControlY = point[1] + (next[1] - previous[1]) / 6
- const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
- const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
- return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
- })
- .join('')
-}
-
-const projectLocalPoint = (pose: AvatarPose, point: Point3) =>
- project(rotateWithQuaternion(pose.orientation, point), pose.expression.perspective)
-
-const ringPoints = (width: number, depth: number, y: number) =>
- Array.from({ length: PRIMITIVE_RING_SAMPLES + 1 }, (_, index) => {
- const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
- return [(width / 2) * Math.sin(angle), y, (depth / 2) * Math.cos(angle)] as Point3
- })
-
-const projectedRoundedPrimitivePath = (pose: AvatarPose, surface: SurfaceConfig) => {
- const key = surfaceCacheKey(surface)
- let localSamples = headSamplesCache.get(key)
- if (!localSamples) {
- localSamples = Array.from(
- { length: ROUNDED_PRIMITIVE_LATITUDE_SAMPLES },
- (_, latitudeIndex) => {
- const latitude =
- -Math.PI / 2 + (latitudeIndex / (ROUNDED_PRIMITIVE_LATITUDE_SAMPLES - 1)) * Math.PI
- return Array.from({ length: ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
- const longitude =
- -Math.PI + (longitudeIndex / (ROUNDED_PRIMITIVE_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
- return surfacePointAt(surface, longitude, latitude)
- })
- }
- ).flat()
- cacheSurfaceValue(headSamplesCache, key, localSamples)
- }
- const projected = localSamples.map(point => projectLocalPoint(pose, point))
- return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
-}
-
-const projectedCylinderPath = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (surface.roundness > 0 || (surface.morphRoundness ?? 0) > 0) {
- return projectedRoundedPrimitivePath(pose, surface)
- }
-
- const halfHeight = surface.height / 2
- const projected = [
- ...ringPoints(surface.width, surface.depth, -halfHeight),
- ...ringPoints(surface.width, surface.depth, halfHeight),
- ].map(point => projectLocalPoint(pose, point))
- return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
-}
-
-const projectedCursorBodyPath = (pose: AvatarPose, surface: SurfaceConfig) => {
- const layout = cursorLayout(surface)
- const halfHeight = layout.bodyHeight / 2
- const projected = [
- ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY - halfHeight),
- ...ringPoints(layout.bodyWidth, layout.bodyDepth, layout.bodyCenterY + halfHeight),
- ].map(point => projectLocalPoint(pose, point))
- return smoothClosedPath(densifyClosedPoints(convexHull(projected)))
-}
-
-const projectedCursorConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
- const layout = cursorLayout(surface)
- const apex = projectLocalPoint(pose, [0, layout.coneApexY, 0])
- const base = ringPoints(surface.width, surface.depth, layout.coneBaseY).map(point =>
- projectLocalPoint(pose, point)
- )
- return smoothClosedPath(densifyClosedPoints(convexHull([...base, apex])))
-}
-
-const projectedConePath = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (
- (surface.morphRoundness ?? 0) > 0 ||
- (surface.tipRoundness ?? 0) > 0 ||
- (surface.baseRoundness ?? 0) > 0
- ) {
- return projectedRoundedPrimitivePath(pose, surface)
- }
-
- const apex = projectLocalPoint(pose, [0, -surface.height / 2, 0])
- const base = ringPoints(surface.width, surface.depth, surface.height / 2).map(point =>
- projectLocalPoint(pose, point)
- )
- const hull = convexHull([...base, apex])
- const apexIndex = hull.findIndex(
- point => Math.hypot(point[0] - apex[0], point[1] - apex[1]) < 0.01
- )
- if (apexIndex < 0) return smoothClosedPath(hull)
-
- const ordered = [...hull.slice(apexIndex), ...hull.slice(0, apexIndex)]
- const baseArc = ordered.slice(1)
- if (baseArc.length < 2) return path(hull)
- return `M${apex[0].toFixed(2)} ${apex[1].toFixed(2)}L${baseArc[0][0].toFixed(2)} ${baseArc[0][1].toFixed(2)}${smoothOpenPath(baseArc)}L${apex[0].toFixed(2)} ${apex[1].toFixed(2)}Z`
-}
-
-const projectedCubePath = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
-
- const halfWidth = surface.width / 2
- const halfHeight = surface.height / 2
- const halfDepth = surface.depth / 2
- const vertices = [-1, 1].flatMap(x =>
- [-1, 1].flatMap(y => [-1, 1].map(z => [x * halfWidth, y * halfHeight, z * halfDepth] as Point3))
- )
- return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
-}
-
-const projectedDiamondPath = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (surface.roundness > 0) return projectedRoundedPrimitivePath(pose, surface)
-
- const halfWidth = surface.width / 2
- const halfHeight = surface.height / 2
- const halfDepth = surface.depth / 2
- const vertices: Point3[] = [
- [-halfWidth, 0, 0],
- [halfWidth, 0, 0],
- [0, -halfHeight, 0],
- [0, halfHeight, 0],
- [0, 0, -halfDepth],
- [0, 0, halfDepth],
- ]
- return path(convexHull(vertices.map(point => projectLocalPoint(pose, point))))
-}
-
-type ProjectedEllipse = {
- centerX: number
- centerY: number
- majorRadius: number
- minorRadius: number
- rotation: number
-}
-
-const ellipseProjection = (
- centerX: number,
- centerY: number,
- covarianceXX: number,
- covarianceXY: number,
- covarianceYY: number
-): ProjectedEllipse | null => {
- const trace = covarianceXX + covarianceYY
- const difference = Math.hypot(covarianceXX - covarianceYY, covarianceXY * 2)
- const majorSquared = (trace + difference) / 2
- const minorSquared = (trace - difference) / 2
- if (majorSquared <= 0 || minorSquared <= 0) return null
-
- return {
- centerX,
- centerY,
- majorRadius: Math.sqrt(majorSquared),
- minorRadius: Math.sqrt(minorSquared),
- rotation: Math.atan2(covarianceXY * 2, covarianceXX - covarianceYY) / 2,
- }
-}
-
-const ellipsePath = ({
- centerX,
- centerY,
- majorRadius,
- minorRadius,
- rotation,
-}: ProjectedEllipse) => {
- const rotationDegrees = (rotation * 180) / Math.PI
- const offsetX = Math.cos(rotation) * majorRadius
- const offsetY = Math.sin(rotation) * majorRadius
- const startX = centerX + offsetX
- const startY = centerY + offsetY
- const endX = centerX - offsetX
- const endY = centerY - offsetY
-
- return `M${startX.toFixed(2)} ${startY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${endX.toFixed(2)} ${endY.toFixed(2)}A${majorRadius.toFixed(2)} ${minorRadius.toFixed(2)} ${rotationDegrees.toFixed(2)} 0 1 ${startX.toFixed(2)} ${startY.toFixed(2)}Z`
-}
-
-const projectedEllipsoid = (
- pose: AvatarPose,
- axes: Point3,
- localCenter: Point3 = [0, 0, 0]
-): ProjectedEllipse | null => {
- const rotatedAxes = [
- rotateWithQuaternion(pose.orientation, [1, 0, 0]),
- rotateWithQuaternion(pose.orientation, [0, 1, 0]),
- rotateWithQuaternion(pose.orientation, [0, 0, 1]),
- ]
- const center = rotateWithQuaternion(pose.orientation, localCenter)
-
- if (Math.abs(pose.expression.perspective) < 0.0001) {
- const covarianceXX = rotatedAxes.reduce(
- (total, axis, index) => total + axis[0] * axis[0] * axes[index] * axes[index],
- 0
- )
- const covarianceXY = rotatedAxes.reduce(
- (total, axis, index) => total + axis[0] * axis[1] * axes[index] * axes[index],
- 0
- )
- const covarianceYY = rotatedAxes.reduce(
- (total, axis, index) => total + axis[1] * axis[1] * axes[index] * axes[index],
- 0
- )
- return ellipseProjection(center[0], center[1], covarianceXX, covarianceXY, covarianceYY)
- }
-
- const inverseAxesSquared = axes.map(axis => 1 / (axis * axis))
- const quadratic = Array.from({ length: 3 }, (_, row) =>
- Array.from({ length: 3 }, (_, column) =>
- rotatedAxes.reduce(
- (total, axis, index) => total + axis[row] * inverseAxesSquared[index] * axis[column],
- 0
- )
- )
- )
- const focalLength = FOCAL_LENGTH / pose.expression.perspective
- const cameraOffset: Point3 = [-center[0], -center[1], focalLength - center[2]]
- const cameraNormal: Point3 = [
- quadratic[0][0] * cameraOffset[0] +
- quadratic[0][1] * cameraOffset[1] +
- quadratic[0][2] * cameraOffset[2],
- quadratic[1][0] * cameraOffset[0] +
- quadratic[1][1] * cameraOffset[1] +
- quadratic[1][2] * cameraOffset[2],
- quadratic[2][0] * cameraOffset[0] +
- quadratic[2][1] * cameraOffset[1] +
- quadratic[2][2] * cameraOffset[2],
- ]
- const cameraTerm =
- cameraOffset[0] * cameraNormal[0] +
- cameraOffset[1] * cameraNormal[1] +
- cameraOffset[2] * cameraNormal[2] -
- 1
- const tangentLinear = [cameraNormal[0], cameraNormal[1], -focalLength * cameraNormal[2]]
- const rayQuadratic = [
- [quadratic[0][0], quadratic[0][1], -focalLength * quadratic[0][2]],
- [quadratic[1][0], quadratic[1][1], -focalLength * quadratic[1][2]],
- [
- -focalLength * quadratic[2][0],
- -focalLength * quadratic[2][1],
- focalLength * focalLength * quadratic[2][2],
- ],
- ]
- const conic = Array.from({ length: 3 }, (_, row) =>
- Array.from(
- { length: 3 },
- (_, column) =>
- tangentLinear[row] * tangentLinear[column] - cameraTerm * rayQuadratic[row][column]
- )
- )
- const determinant = conic[0][0] * conic[1][1] - conic[0][1] * conic[0][1]
- if (Math.abs(determinant) < 1e-12) return null
-
- const centerX = -(conic[1][1] * conic[0][2] - conic[0][1] * conic[1][2]) / determinant
- const centerY = (conic[0][1] * conic[0][2] - conic[0][0] * conic[1][2]) / determinant
- const centeredConstant = conic[2][2] + conic[0][2] * centerX + conic[1][2] * centerY
- const scale = -centeredConstant
- if (Math.abs(scale) < 1e-12) return null
-
- const shapeXX = conic[0][0] / scale
- const shapeXY = conic[0][1] / scale
- const shapeYY = conic[1][1] / scale
- const shapeDeterminant = shapeXX * shapeYY - shapeXY * shapeXY
- if (shapeDeterminant <= 0) return null
-
- return ellipseProjection(
- centerX,
- centerY,
- shapeYY / shapeDeterminant,
- -shapeXY / shapeDeterminant,
- shapeXX / shapeDeterminant
- )
-}
-
-const projectedEllipsoidPath = (pose: AvatarPose, surface: SurfaceConfig) => {
- const ellipse = projectedEllipsoid(pose, [
- surface.width / 2,
- surface.height / 2,
- surface.depth / 2,
- ])
- const isSphere = surface.width === surface.height && surface.height === surface.depth
- if (ellipse && isSphere) {
- const radius = (ellipse.majorRadius + ellipse.minorRadius) / 2
- return ellipsePath({
- centerX: 0,
- centerY: 0,
- majorRadius: radius,
- minorRadius: radius,
- rotation: 0,
- })
- }
- return ellipse ? ellipsePath(ellipse) : null
-}
-
-const mickeyEarPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (surface.type !== 'mickey') return []
-
- const radius = Math.min(surface.width, surface.height) * 0.23
- const depthRadius = Math.min(radius, surface.depth * 0.29)
- const centerX = surface.width * 0.37
- const centerY = -surface.height * 0.39
- const centerZ = -surface.depth * 0.12
- const axes: Point3 = [radius, radius, depthRadius]
- return [-1, 1]
- .map(side => projectedEllipsoid(pose, axes, [side * centerX, centerY, centerZ]))
- .filter((ear): ear is ProjectedEllipse => ear !== null)
- .map(ellipsePath)
-}
-
-const compositeBackPaths = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (surface.type === 'mickey') return mickeyEarPaths(pose, surface)
- if (surface.type === 'cursor') return [projectedCursorConePath(pose, surface)]
- return []
-}
-
-const ellipsePoints = (ellipse: ProjectedEllipse) =>
- Array.from({ length: PRIMITIVE_RING_SAMPLES }, (_, index) => {
- const angle = (index / PRIMITIVE_RING_SAMPLES) * Math.PI * 2
- const major = Math.cos(angle) * ellipse.majorRadius
- const minor = Math.sin(angle) * ellipse.minorRadius
- return [
- ellipse.centerX + major * Math.cos(ellipse.rotation) - minor * Math.sin(ellipse.rotation),
- ellipse.centerY + major * Math.sin(ellipse.rotation) + minor * Math.cos(ellipse.rotation),
- 0,
- ] as Point3
- })
-
-const smoothHullPath = (points: Point3[]) => {
- if (points.length < 3) return path(points)
- const distances = points.map((point, index) => {
- const next = points[(index + 1) % points.length]
- return Math.hypot(next[0] - point[0], next[1] - point[1])
- })
- const sortedDistances = [...distances].sort((left, right) => left - right)
- const medianDistance = sortedDistances[Math.floor(sortedDistances.length / 2)] || 1
- const straightThreshold = Math.max(8, medianDistance * 3.5)
- const straightEdges = distances.map(distance => distance > straightThreshold)
-
- return `M${points[0][0].toFixed(2)} ${points[0][1].toFixed(2)}${points
- .map((point, index) => {
- const nextIndex = (index + 1) % points.length
- const next = points[nextIndex]
- if (straightEdges[index]) return `L${next[0].toFixed(2)} ${next[1].toFixed(2)}`
- const previous = straightEdges[(index - 1 + points.length) % points.length]
- ? point
- : points[(index - 1 + points.length) % points.length]
- const afterNext = straightEdges[nextIndex] ? next : points[(index + 2) % points.length]
- const firstControlX = point[0] + (next[0] - previous[0]) / 6
- const firstControlY = point[1] + (next[1] - previous[1]) / 6
- const secondControlX = next[0] - (afterNext[0] - point[0]) / 6
- const secondControlY = next[1] - (afterNext[1] - point[1]) / 6
- return `C${firstControlX.toFixed(2)} ${firstControlY.toFixed(2)} ${secondControlX.toFixed(2)} ${secondControlY.toFixed(2)} ${next[0].toFixed(2)} ${next[1].toFixed(2)}`
- })
- .join('')}Z`
-}
-
-const projectedCapsulePath = (pose: AvatarPose, surface: SurfaceConfig) => {
- const radiusX = surface.width / 2
- const radiusY = Math.min(radiusX, surface.height / 2)
- const radiusZ = surface.depth / 2
- const straightHalf = Math.max(0, (surface.height - radiusY * 2) / 2)
- const axes: Point3 = [radiusX, radiusY, radiusZ]
- const top = projectedEllipsoid(pose, axes, [0, straightHalf, 0])
- const bottom = projectedEllipsoid(pose, axes, [0, -straightHalf, 0])
- if (!top || !bottom) return null
- return smoothHullPath(convexHull([...ellipsePoints(top), ...ellipsePoints(bottom)]))
-}
-
-const headPath = (pose: AvatarPose, surface: SurfaceConfig) => {
- if (surface.type === 'sphere' || surface.type === 'mickey') {
- const exactPath = projectedEllipsoidPath(pose, surface)
- if (exactPath) return exactPath
- }
-
- if (surface.type === 'capsule') {
- const exactPath = projectedCapsulePath(pose, surface)
- if (exactPath) return exactPath
- }
-
- if (surface.type === 'cylinder') return projectedCylinderPath(pose, surface)
- if (surface.type === 'cursor') return projectedCursorBodyPath(pose, surface)
- if (surface.type === 'cone') return projectedConePath(pose, surface)
- if (surface.type === 'cube') return projectedCubePath(pose, surface)
- if (surface.type === 'diamond') return projectedDiamondPath(pose, surface)
-
- const key = surfaceCacheKey(surface)
- let localSamples = headSamplesCache.get(key)
- if (!localSamples) {
- localSamples = Array.from({ length: HEAD_LATITUDE_SAMPLES }, (_, latitudeIndex) => {
- const latitude = -Math.PI / 2 + (latitudeIndex / (HEAD_LATITUDE_SAMPLES - 1)) * Math.PI
- return Array.from({ length: HEAD_LONGITUDE_SAMPLES }, (_, longitudeIndex) => {
- const longitude = -Math.PI + (longitudeIndex / (HEAD_LONGITUDE_SAMPLES - 1)) * Math.PI * 2
- return surfacePointAt(surface, longitude, latitude)
- })
- }).flat()
- cacheSurfaceValue(headSamplesCache, key, localSamples)
- }
- const projectedSamples = localSamples.map(sample =>
- project(rotateWithQuaternion(pose.orientation, sample), pose.expression.perspective)
- )
- return path(convexHull(projectedSamples))
-}
-
-const accessoryPath = (pose: AvatarPose, node: BodyNode) => {
- const key = surfaceCacheKey(node.surface)
- let localSamples = accessorySamplesCache.get(key)
- if (!localSamples) {
- localSamples = Array.from({ length: 17 }, (_, latitudeIndex) => {
- const latitude = -Math.PI / 2 + (latitudeIndex / 16) * Math.PI
- return Array.from({ length: 49 }, (_, longitudeIndex) => {
- const longitude = -Math.PI + (longitudeIndex / 48) * Math.PI * 2
- return surfacePointAt(node.surface, longitude, latitude)
- })
- }).flat()
- cacheSurfaceValue(accessorySamplesCache, key, localSamples)
- }
-
- const localOrientation = quaternionFromEuler(
- radians(node.rotation[0]),
- radians(node.rotation[1]),
- radians(node.rotation[2])
- )
- const projected = localSamples.map(point => {
- const locallyRotated = rotateWithQuaternion(localOrientation, point)
- const positioned: Point3 = [
- locallyRotated[0] + node.position[0],
- locallyRotated[1] + node.position[1],
- locallyRotated[2] + node.position[2],
- ]
- return project(rotateWithQuaternion(pose.orientation, positioned), pose.expression.perspective)
- })
- const hull = convexHull(projected)
- if (
- (node.surface.type === 'cube' || node.surface.type === 'diamond') &&
- node.surface.roundness <= 0
- ) {
- return path(hull)
- }
- return smoothClosedPath(densifyClosedPoints(hull))
-}
-
-const ACCESSORY_FRONT_CROSSING_RATIO = 0.1
-
-const accessoryCameraDepthRadius = (pose: AvatarPose, node: BodyNode) => {
- const localOrientation = quaternionFromEuler(
- radians(node.rotation[0]),
- radians(node.rotation[1]),
- radians(node.rotation[2])
- )
- const cameraDepthByAxis = (
- [
- [1, 0, 0],
- [0, 1, 0],
- [0, 0, 1],
- ] as Point3[]
- ).map(
- axis => rotateWithQuaternion(pose.orientation, rotateWithQuaternion(localOrientation, axis))[2]
- )
- return Math.hypot(
- cameraDepthByAxis[0] * (node.surface.width / 2),
- cameraDepthByAxis[1] * (node.surface.height / 2),
- cameraDepthByAxis[2] * (node.surface.depth / 2)
- )
-}
-
-const accessoryLayers = (pose: AvatarPose, nodes: BodyNode[]) => {
- const layers = nodes
- .map(node => {
- const depth = rotateWithQuaternion(pose.orientation, node.position)[2]
- return {
- id: node.id,
- path: accessoryPath(pose, node),
- depth,
- front: depth > accessoryCameraDepthRadius(pose, node) * ACCESSORY_FRONT_CROSSING_RATIO,
- }
- })
- .sort((left, right) => left.depth - right.depth)
- return {
- backPaths: layers.filter(layer => !layer.front).map(layer => layer.path),
- frontPaths: layers.filter(layer => layer.front).map(layer => layer.path),
- backNodeIds: layers.filter(layer => !layer.front).map(layer => layer.id),
- frontNodeIds: layers.filter(layer => layer.front).map(layer => layer.id),
- }
-}
-
-export const renderAvatar = (
- pose: AvatarPose,
- surface: SurfaceConfig,
- blink = 1,
- options: RenderAvatarOptions = {}
-): AvatarGeometry => {
- const leftSamples = eyePoints(pose, surface, -1, blink, options.eyeOffset)
- const rightSamples = eyePoints(pose, surface, 1, blink, options.eyeOffset)
- const left = leftSamples.map(sample => sample.point)
- const right = rightSamples.map(sample => sample.point)
- const accessories = accessoryLayers(pose, options.bodyNodes ?? [])
- const compositePaths = compositeBackPaths(pose, surface)
- return {
- backPaths: [...compositePaths, ...accessories.backPaths],
- frontPaths: accessories.frontPaths,
- backNodeIds: [...compositePaths.map(() => null), ...accessories.backNodeIds],
- frontNodeIds: accessories.frontNodeIds,
- headPath: headPath(pose, surface),
- leftPath: path(left),
- rightPath: path(right),
- leftVisible: leftSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
- rightVisible: rightSamples.reduce((total, sample) => total + sample.normal[2], 0) > 0,
- wirePaths: options.includeWire === false ? [] : wirePaths(pose, surface),
- }
-}
+export * from '@bible-strong/avatar-core'
diff --git a/src/features/avatar/presets.ts b/src/features/avatar/presets.ts
index 3b3a416..b254081 100644
--- a/src/features/avatar/presets.ts
+++ b/src/features/avatar/presets.ts
@@ -1,5 +1,33 @@
import type { Expression } from './geometry'
+export const bundledExpressionSemanticKeys: Record = {
+ '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,
diff --git a/src/features/avatar/surfaces.ts b/src/features/avatar/surfaces.ts
index 98295fd..11f5adb 100644
--- a/src/features/avatar/surfaces.ts
+++ b/src/features/avatar/surfaces.ts
@@ -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 = {
- 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 = {
- 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'
diff --git a/src/features/export/__tests__/exporter-test.ts b/src/features/export/__tests__/exporter-test.ts
index c3808a6..3d2b64b 100644
--- a/src/features/export/__tests__/exporter-test.ts
+++ b/src/features/export/__tests__/exporter-test.ts
@@ -1,17 +1,43 @@
import { createAvatar } from '@/features/avatar/avatars'
import { parse } from '@babel/parser'
import {
+ avatarDemoFileName,
createAvatarExportPayload,
generateJavaScriptAvatarHtml,
generateJavaScriptAvatarModule,
generateJavaScriptAvatarPackage,
+ generateJavaScriptEsmHtml,
+ generateJavaScriptEsmPackage,
generateReactAvatarComponent,
generateReactAvatarPackage,
generateReactAvatarRuntime,
+ generateReactViteMain,
+ generateReactVitePackage,
} from '@/features/export/exporter'
+import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
+import { resolveAvatarBehavior } from '@/features/avatar/avatars'
+import { loadStudioDocument } from '@/features/studio/studioDocument'
import { initialExpressions } from '@/features/avatar/presets'
import { createInitialSequences } from '@/features/animation/sequences'
+const storedZipFileNames = (archive: Uint8Array) => {
+ const names: string[] = []
+ const decoder = new TextDecoder()
+ const view = new DataView(archive.buffer, archive.byteOffset, archive.byteLength)
+ let offset = 0
+
+ while (offset + 30 <= archive.byteLength && view.getUint32(offset, true) === 0x04034b50) {
+ const contentLength = view.getUint32(offset + 18, true)
+ const nameLength = view.getUint16(offset + 26, true)
+ const extraLength = view.getUint16(offset + 28, true)
+ const nameStart = offset + 30
+ names.push(decoder.decode(archive.subarray(nameStart, nameStart + nameLength)))
+ offset = nameStart + nameLength + extraLength + contentLength
+ }
+
+ return names
+}
+
describe('avatar export', () => {
const avatar = createAvatar('Strobi')
const animations = createInitialSequences().filter(item =>
@@ -19,6 +45,11 @@ describe('avatar export', () => {
)
const payload = createAvatarExportPayload(avatar, initialExpressions, animations)
+ it('uses distinct filenames for React and ESM demo archives', () => {
+ expect(avatarDemoFileName('Strobi', 'react')).toBe('strobi-avatar-react.zip')
+ expect(avatarDemoFileName('Strobi', 'javascript')).toBe('strobi-avatar-esm.zip')
+ })
+
it('includes only the selected animations and their referenced expressions', () => {
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
expect(Object.keys(payload.expressions).sort()).toEqual(
@@ -29,6 +60,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', () => {
@@ -119,6 +153,77 @@ describe('avatar export', () => {
expect(contents).toContain('avatar.js')
})
+ it('generates a lightweight ESM integration backed by avatar-web', async () => {
+ const document = loadStudioDocument({ getItem: () => null })
+ const studioAvatar = document.library.avatars[0]
+ const definition = createAvatarDefinition({
+ avatar: studioAvatar,
+ behavior: resolveAvatarBehavior(studioAvatar, {
+ expressions: document.expressions,
+ sequences: document.sequences,
+ }),
+ })
+ expect(definition.ok).toBe(true)
+ if (!definition.ok) return
+
+ const source = generateJavaScriptEsmHtml('strobi.avatar.json', 'Strobi')
+ expect(source).toContain("from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'")
+ expect(source).toContain("fetch('./strobi.avatar.json')")
+ expect(source).not.toContain('AvatarProceduralEngine')
+
+ const archive = new Uint8Array(
+ await generateJavaScriptEsmPackage(definition.value, 'Strobi').arrayBuffer()
+ )
+ const contents = new TextDecoder().decode(archive)
+ expect(storedZipFileNames(archive)).toEqual(['strobi.avatar.json', 'index.html', 'README.md'])
+ expect(contents).toContain('esm.sh/@bible-strong/avatar-web@0.1.0')
+ expect(contents).not.toContain('AvatarProceduralEngine')
+ })
+
+ it('generates a ready-to-run React TypeScript demo backed by avatar-react', async () => {
+ const document = loadStudioDocument({ getItem: () => null })
+ const studioAvatar = document.library.avatars[0]
+ const definition = createAvatarDefinition({
+ avatar: studioAvatar,
+ behavior: resolveAvatarBehavior(studioAvatar, {
+ expressions: document.expressions,
+ sequences: document.sequences,
+ }),
+ })
+ expect(definition.ok).toBe(true)
+ if (!definition.ok) return
+
+ const source = generateReactViteMain('../strobi.avatar.json', 'Strobi')
+ expect(source).toContain("from '@bible-strong/avatar-react'")
+ expect(source).toContain("from '../strobi.avatar.json'")
+ expect(source).toContain('createAvatar(definition)')
+ expect(source).toContain("kind: 'animation'")
+ expect(source).toContain("kind: 'expression'")
+ expect(() =>
+ parse(source, { sourceType: 'module', plugins: ['typescript', 'jsx'] })
+ ).not.toThrow()
+
+ const archive = new Uint8Array(
+ await generateReactVitePackage(definition.value, 'Strobi').arrayBuffer()
+ )
+ const contents = new TextDecoder().decode(archive)
+ expect(storedZipFileNames(archive)).toEqual([
+ 'strobi.avatar.json',
+ 'package.json',
+ 'index.html',
+ 'tsconfig.json',
+ 'vite.config.ts',
+ 'src/main.tsx',
+ 'src/vite-env.d.ts',
+ 'src/styles.css',
+ 'README.md',
+ ])
+ expect(contents).toContain('"@bible-strong/avatar-react": "0.1.0"')
+ expect(contents).toContain('npm install')
+ expect(contents).toContain('npm run dev')
+ expect(contents).not.toContain('AvatarProceduralEngine')
+ })
+
it('generates a typed React component backed by the local runtime', () => {
const source = generateReactAvatarComponent(payload)
diff --git a/src/features/export/exporter.ts b/src/features/export/exporter.ts
index 9f3fc7a..2763e16 100644
--- a/src/features/export/exporter.ts
+++ b/src/features/export/exporter.ts
@@ -1,4 +1,5 @@
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
+import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
import type { Expression } from '../avatar/geometry'
import { translateStudioText, type StudioLanguage } from '../../i18n'
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
@@ -6,6 +7,8 @@ import type { AvatarSequence } from '../animation/sequences'
import { standaloneEngineSource } from './standaloneEngine.generated'
import { createStoredZip } from './storedZip'
+const avatarRuntimePackageVersion = '0.1.0'
+
export type AvatarExportAnimation = Pick<
AvatarSequence,
'name' | 'description' | 'playbackMode' | 'blink'
@@ -58,9 +61,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()
@@ -354,6 +360,120 @@ export const generateJavaScriptAvatarPackage = (
])
}
+export const generateJavaScriptEsmHtml = (
+ definitionFileName: string,
+ avatarName: string
+) => `
+
+
+
+
+ ${escapedHtml(avatarName)} · Avatar demo
+
+
+
+
+ ${escapedHtml(avatarName)}
+
+
+
+ Animations
+
+ Expressions
+
+
+
+
+
+
+
+`
+
+export const generateJavaScriptEsmReadme = (
+ definitionFileName: string
+) => `# Avatar JavaScript / ESM
+
+This export uses the same .avatar.json definition as @bible-strong/avatar-react. The rendering and
+playback engine is provided by @bible-strong/avatar-web instead of being copied into this export.
+
+## Run the demo
+
+\`\`\`sh
+npx serve .
+\`\`\`
+
+Then open the local URL displayed by the command. index.html loads avatar-web from esm.sh and the
+exported ${definitionFileName} file from this folder.
+
+## Use in your application
+
+\`\`\`js
+import { createAvatar } from '@bible-strong/avatar-web'
+import definition from './${definitionFileName}'
+
+const avatar = createAvatar('#avatar', {
+ definition,
+ defaultAnimation: 'idle',
+})
+
+avatar.play('happy')
+avatar.pause()
+avatar.stop()
+\`\`\`
+
+The JSON import is intended for a modern ESM build tool such as Vite.
+`
+
+export const generateJavaScriptEsmPackage = (
+ definition: Readonly,
+ avatarName: string
+) => {
+ const definitionFileName = avatarDefinitionFileName(avatarName)
+ return createStoredZip([
+ { name: definitionFileName, content: JSON.stringify(definition, null, 2) },
+ { name: 'index.html', content: generateJavaScriptEsmHtml(definitionFileName, avatarName) },
+ { name: 'README.md', content: generateJavaScriptEsmReadme(definitionFileName) },
+ ])
+}
+
const avatarExportSlug = (name: string) =>
name
.normalize('NFD')
@@ -370,6 +490,210 @@ const avatarComponentName = (name: string) => {
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
}
+export const generateReactViteMain = (
+ definitionImportPath: string,
+ avatarName: string
+) => `import { createAvatar } from '@bible-strong/avatar-react'
+import '@bible-strong/avatar-react/styles.css'
+import { StrictMode, useState } from 'react'
+import { createRoot } from 'react-dom/client'
+
+import definition from '${definitionImportPath}'
+import './styles.css'
+
+const ExportedAvatar = createAvatar(definition)
+type AnimationName = keyof typeof definition.animations
+type ExpressionName = keyof typeof definition.expressions
+type Target =
+ | { kind: 'animation'; key: AnimationName }
+ | { kind: 'expression'; key: ExpressionName }
+
+const animations = definition.animationOrder as AnimationName[]
+const expressions = definition.expressionOrder as ExpressionName[]
+const initialTarget: Target = animations[0]
+ ? { kind: 'animation', key: animations[0] }
+ : { kind: 'expression', key: expressions[0] ?? ('neutral' as ExpressionName) }
+
+function App() {
+ const [target, setTarget] = useState(initialTarget)
+
+ return (
+
+ {${JSON.stringify(avatarName)}}
+
+
+
+ Animations
+
+ {animations.map(key => (
+ setTarget({ kind: 'animation', key })}
+ >
+ {key}
+
+ ))}
+
+ Expressions
+
+ {expressions.map(key => (
+ setTarget({ kind: 'expression', key })}
+ >
+ {key}
+
+ ))}
+
+
+
+
+ )
+}
+
+createRoot(document.getElementById('root')!).render(
+
+
+
+)
+`
+
+const generateReactViteStyles = () => `* { box-sizing: border-box; }
+:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
+body { margin: 0; min-height: 100vh; padding: 32px; color: #f7f8fa; background: #0d1117; }
+main { width: min(980px, 100%); margin: auto; }
+h1 { margin: 0 0 24px; }
+.demo { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, .8fr); gap: 20px; }
+.stage, .controls { border: 1px solid #293140; border-radius: 22px; background: #141a22; }
+.stage { display: grid; min-height: 560px; padding: 32px; place-items: center; }
+.stage > * { width: min(80%, 460px) !important; height: auto !important; aspect-ratio: 1; }
+.controls { padding: 20px; }
+h2 { margin: 0 0 12px; font-size: 15px; }
+.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
+button { min-width: 0; padding: 10px; overflow: hidden; color: #dbe3ef; border: 1px solid #354052; border-radius: 10px; background: #1b2330; font: inherit; text-overflow: ellipsis; cursor: pointer; }
+button:hover, button[aria-pressed="true"] { color: #fff; border-color: #7392ed; background: #263657; }
+@media (max-width: 760px) { body { padding: 16px; } .demo { grid-template-columns: 1fr; } .stage { min-height: 420px; } }
+`
+
+export const generateReactViteReadme = (
+ definitionFileName: string,
+ avatarName: string
+) => `# ${avatarName} · React / TypeScript demo
+
+This ready-to-run Vite project uses the exported ${definitionFileName} definition with
+@bible-strong/avatar-react.
+
+## Run the demo
+
+\`\`\`sh
+npm install
+npm run dev
+\`\`\`
+
+Then open the local URL displayed by Vite. The demo includes controls for every exported animation
+and expression.
+
+## Production build
+
+\`\`\`sh
+npm run build
+\`\`\`
+
+The component is created once from the JSON definition with \`createAvatar(definition)\`. TypeScript
+derives the accepted animation and expression prop keys from that imported JSON file.
+`
+
+export const generateReactVitePackage = (
+ definition: Readonly,
+ avatarName: string
+) => {
+ const definitionFileName = avatarDefinitionFileName(avatarName)
+ const packageName = `${avatarExportSlug(avatarName)}-avatar-react-demo`
+ const packageJson = {
+ name: packageName,
+ version: '0.0.0',
+ private: true,
+ type: 'module',
+ engines: { node: '>=22.12.0' },
+ scripts: {
+ dev: 'vite',
+ build: 'tsc --noEmit && vite build',
+ },
+ dependencies: {
+ '@bible-strong/avatar-react': avatarRuntimePackageVersion,
+ react: '^19.0.0',
+ 'react-dom': '^19.0.0',
+ },
+ devDependencies: {
+ '@types/react': '^19.0.0',
+ '@types/react-dom': '^19.0.0',
+ '@vitejs/plugin-react': '^6.0.2',
+ typescript: '~6.0.3',
+ vite: '^8.0.13',
+ },
+ }
+ const indexHtml = `
+
+
+
+
+ ${escapedHtml(avatarName)} · React avatar demo
+
+
+
+
+
+
+`
+ const tsconfig = {
+ 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'],
+ }
+ const viteConfig = `import react from '@vitejs/plugin-react'
+import { defineConfig } from 'vite'
+
+export default defineConfig({ plugins: [react()] })
+`
+
+ return createStoredZip([
+ { name: definitionFileName, content: JSON.stringify(definition, null, 2) },
+ { name: 'package.json', content: JSON.stringify(packageJson, null, 2) },
+ { name: 'index.html', content: indexHtml },
+ { name: 'tsconfig.json', content: JSON.stringify(tsconfig, null, 2) },
+ { name: 'vite.config.ts', content: viteConfig },
+ {
+ name: 'src/main.tsx',
+ content: generateReactViteMain(`../${definitionFileName}`, avatarName),
+ },
+ { name: 'src/vite-env.d.ts', content: '/// \n' },
+ { name: 'src/styles.css', content: generateReactViteStyles() },
+ { name: 'README.md', content: generateReactViteReadme(definitionFileName, avatarName) },
+ ])
+}
+
export const generateReactAvatarRuntime = () => `
export type AvatarData = Readonly<{
version: number
@@ -547,3 +871,6 @@ export const generateReactAvatarPackage = (payload: AvatarExportPayload) => {
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
return `${avatarExportSlug(name)}-avatar.${extension}`
}
+
+export const avatarDemoFileName = (name: string, format: 'react' | 'javascript') =>
+ `${avatarExportSlug(name)}-avatar-${format === 'react' ? 'react' : 'esm'}.zip`
diff --git a/src/features/export/standaloneEngine.generated.ts b/src/features/export/standaloneEngine.generated.ts
index b1eac8c..38e0a7b 100644
--- a/src/features/export/standaloneEngine.generated.ts
+++ b/src/features/export/standaloneEngine.generated.ts
@@ -1,2 +1,2 @@
// Generated by scripts/generate-standalone-engine.mjs. Do not edit.
-export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=(e,t)=>Math.sign(e)*Math.abs(e)**t,n=(e,n,r,i,a,o,s)=>{let c=t(Math.cos(n),o);return[r/2*c*t(Math.sin(e),s),i/2*t(Math.sin(n),o),a/2*c*t(Math.cos(e),s)]},r=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),a=e=>1+i(e.roundness)/2,o=.04,s=e=>e.roundness<=0?1/0:2/(o+i(e.roundness)/2*.96),c=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},l=(e,t,n)=>c(e,t,n,a(e)),u=(e,t,n)=>c(e,t,n,s(e)),d=.24,f=.2,p=.22,m=e=>i(e.morphRoundness)/2,h=(e,t,n)=>{let r=m(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},g=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},_=e=>({tipFraction:(e.tipRoundness??0)*d,baseFraction:(e.baseRoundness??0)*f}),v=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*p;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},y=(e,t)=>h(e,t,v(e,t)),b=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=_(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:g(r,r/2,r/4,0,e),verticalProgress:g(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},S=(e,t)=>h(e,t,x(e,t)),C=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},w=(e,t,i)=>{let{width:a,height:o,depth:s}=e;switch(e.type){case`sphere`:case`mickey`:return n(t,i,a,o,s,1,1);case`cube`:return u(e,t,i);case`cylinder`:{let n=y(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),-o/2+o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}case`cursor`:{let n=C(e),r=(i+Math.PI/2)/Math.PI,a=v({...e,width:n.bodyWidth,height:n.bodyHeight,depth:n.bodyDepth},r);return[n.bodyWidth/2*a.radiusScale*Math.sin(t),n.bodyCenterY-n.bodyHeight/2+n.bodyHeight*a.verticalProgress,n.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return l(e,t,i);case`capsule`:return r(e,t,i);case`cone`:{let n=S(e,(i+Math.PI/2)/Math.PI);return[a/2*n.radiusScale*Math.sin(t),o/2-o*n.verticalProgress,s/2*n.radiusScale*Math.cos(t)]}}},ee=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],T=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},te=(e,t,n)=>{let r=e.type===`cone`?-1:1;return T([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},ne=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=w(e,t-r,n),a=w(e,t+r,n),o=w(e,t,Math.max(-Math.PI/2,n-r)),s=w(e,t,Math.min(Math.PI/2,n+r));return te(e,ee(a,i),ee(s,o))},E=(e,t)=>Math.sign(e)*Math.abs(e)**t,D=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return T([E(t[0]/r,n-1)/r,E(t[1]/i,n-1)/i,E(t[2]/a,n-1)/a])},re=(e,t)=>D(e,t,a(e)),ie=(e,t)=>{let n=s(e);if(Number.isFinite(n))return D(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},ae=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},oe=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:T([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},O=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=b(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=b(e,h,r),v=(b(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),x=-(o/a)*d/(l*y||1),S=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:T([-x,-S,1])}},se=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,o=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return oe(t,n,r,i,o);case`cube`:return ae(e,t,n,s(e),ie);case`capsule`:{let e=Math.min(r,i),a=Math.max(0,i-e);return oe(t,n,r,e,o,n<-a?-a:n>a?a:n)}case`cylinder`:return O(e,t,n,y,1);case`cursor`:{let r=C(e),i=O({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,v,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return O(e,t,n,S,-1);case`diamond`:return ae(e,t,n,a(e),re)}},ce=(e,t,n)=>{let r=w(e,t,n);if(e.type===`sphere`||e.type===`mickey`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:T([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:T([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:re(e,r)}:e.type===`cube`?{point:r,normal:ie(e,r)}:{point:r,normal:ne(e,t,n)}},k=620,A=14,le=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],j=e=>e*Math.PI/180,M=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},ue=([e,t,n,r],[i,a,o,s])=>M([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),N=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return M([Math.cos(i),e*a,t*a,n*a])},P=(e,t,n)=>{let r=N([1,0,0],e),i=N([0,1,0],t);return ue(ue(N([0,0,1],n),r),i)},F=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},de=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r{let n=k-e[2]*t,r=Math.abs(n)<1e-4?k/1e-4:k/n;return[e[0]*r,e[1]*r,e[2]]},L=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,fe=e=>({expression:e,orientation:P(j(e.headX),j(e.headY),j(e.headZ))}),pe=24,me=25,he=73,R=144,ge=33,_e=73,z=new Map,ve=new Map,ye=new Map,B=e=>[e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness].map(e=>typeof e==`number`?e.toFixed(4):e).join(`:`),V=(e,t,n)=>(e.size>=pe&&e.delete(e.keys().next().value),e.set(t,n),n),be=(e,t,n)=>ce(e,t,n),H=(e,t)=>({point:I(F(e.orientation,t.point),e.expression.perspective),normal:F(e.orientation,t.normal)}),xe=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},Se=(e,t,n,r)=>{let[i,a]=xe(n,r);return H(e,se(t,i,a))},U=(e,t,n,r,i={x:0,y:0})=>{let a=e.expression,o=n<0?`Left`:`Right`,s=a[`width${o}`],c=5+(a[`height${o}`]-5)*r,l=n*a.spacing/2+a[`positionX${o}`]+i.x,u=a[`positionY${o}`]+i.y,d=j(n<0?a.leftAngle:a.rightAngle);return de(s,c).map(([n,r])=>{let i=n*Math.cos(d)-r*Math.sin(d),a=n*Math.sin(d)+r*Math.cos(d);return Se(e,t,l+i,u+a)})},Ce=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>L(e,!1)).join(``)},we=(e,t)=>{let n=B(t),r=ye.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>be(t,j(-180+r*5),j(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>be(t,j(e),j(-90+r*5))));r=V(ye,n,[...e,...i])}return r.map(t=>Ce(t.map(t=>H(e,t))))},W=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},G=e=>{if(e.length<3)return L(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},K=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),Te=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,q=(e,t)=>I(F(e.orientation,t),e.expression.perspective),J=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/R*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),Y=(e,t)=>{let n=B(t),r=z.get(n);return r||(r=Array.from({length:ge},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:_e},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),V(z,n,r)),G(K(W(r.map(t=>q(e,t)))))},Ee=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return Y(e,t);let n=t.height/2;return G(K(W([...J(t.width,t.depth,-n),...J(t.width,t.depth,n)].map(t=>q(e,t)))))},De=(e,t)=>{let n=C(t),r=n.bodyHeight/2;return G(K(W([...J(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...J(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>q(e,t)))))},Oe=(e,t)=>{let n=C(t),r=q(e,[0,n.coneApexY,0]);return G(K(W([...J(t.width,t.depth,n.coneBaseY).map(t=>q(e,t)),r])))},ke=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return Y(e,t);let n=q(e,[0,-t.height/2,0]),r=W([...J(t.width,t.depth,t.height/2).map(t=>q(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return G(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?L(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${Te(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Ae=(e,t)=>{if(t.roundness>0)return Y(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return L(W([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>q(e,t))))},je=(e,t)=>{if(t.roundness>0)return Y(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return L(W([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>q(e,t))))},Me=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},X=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},Z=(e,t,n=[0,0,0])=>{let r=[F(e.orientation,[1,0,0]),F(e.orientation,[0,1,0]),F(e.orientation,[0,0,1])],i=F(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return Me(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=k/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:Me(h,g,b/x,-y/x,v/x)},Ne=(e,t)=>{let n=Z(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return X({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?X(n):null},Pe=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>Z(e,s,[t*i,a,o])).filter(e=>e!==null).map(X)},Fe=(e,t)=>t.type===`mickey`?Pe(e,t):t.type===`cursor`?[Oe(e,t)]:[],Ie=e=>Array.from({length:R},(t,n)=>{let r=n/R*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),Le=e=>{if(e.length<3)return L(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},Re=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=Z(e,o,[0,a,0]),c=Z(e,o,[0,-a,0]);return!s||!c?null:Le(W([...Ie(s),...Ie(c)]))},ze=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Ne(e,t);if(n)return n}if(t.type===`capsule`){let n=Re(e,t);if(n)return n}if(t.type===`cylinder`)return Ee(e,t);if(t.type===`cursor`)return De(e,t);if(t.type===`cone`)return ke(e,t);if(t.type===`cube`)return Ae(e,t);if(t.type===`diamond`)return je(e,t);let n=B(t),r=z.get(n);return r||(r=Array.from({length:me},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:he},(e,n)=>w(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),V(z,n,r)),L(W(r.map(t=>I(F(e.orientation,t),e.expression.perspective))))},Be=(e,t)=>{let n=B(t.surface),r=ve.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return w(t.surface,i,r)})}).flat(),V(ve,n,r));let i=P(j(t.rotation[0]),j(t.rotation[1]),j(t.rotation[2])),a=W(r.map(n=>{let r=F(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return I(F(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?L(a):G(K(a))},Ve=.1,He=(e,t)=>{let n=P(j(t.rotation[0]),j(t.rotation[1]),j(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>F(e.orientation,F(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},Ue=(e,t)=>{let n=t.map(t=>{let n=F(e.orientation,t.position)[2];return{id:t.id,path:Be(e,t),depth:n,front:n>He(e,t)*Ve}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}},We=(e,t,n=1,r={})=>{let i=U(e,t,-1,n,r.eyeOffset),a=U(e,t,1,n,r.eyeOffset),o=i.map(e=>e.point),s=a.map(e=>e.point),c=Ue(e,r.bodyNodes??[]),l=Fe(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:ze(e,t),leftPath:L(o),rightPath:L(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:we(e,t)}},Ge=e=>e*e*(3-2*e),Q=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},Ke=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,qe=17.29,$=(e,t,n,r)=>{let i=e/r,a=Math.floor(i),o=Ge(i-a),s=Q(a*3+t+n);return s+(Q((a+1)*3+t+n)-s)*o},Je=(e,t,n)=>{let r=1100;if(e<=0)return 0;let i=Math.floor(e/r),a=(e-i*r)/140,o=Ge(Math.min(a,1)),s=i===0?0:Q((i-1)*2+t+n);return s+(Q(i*2+t+n)-s)*o},Ye=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,Xe=(e,t,n=1)=>{let r=Ke(e);if(e.bodyMotion===`slowDrift`)return{x:$(t,3,r,2900)*1.45*n,y:$(t,4,r,3700)*1.1*n};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35*n,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1*n}}return{x:0,y:0}},Ze=(e,t,n=1)=>{if(e.eyeMotion===`microSaccades`)return{x:Je(t,0,qe)*1.5*n,y:Je(t,1,qe)*.9*n};if(e.eyeMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2*n,y:(Math.sin(e*59)+Math.sin(e*83)*.4)*.8*n}}return{x:0,y:0}},Qe=(e,t,n=1)=>{let r={...e},i=Ke(e);if(e.bodyMotion===`slowDrift`)r.headX+=$(t,0,i,2600)*.8*n,r.headY+=$(t,1,i,3300)*1.15*n,r.headZ+=$(t,2,i,4100)*.45*n;else if(e.bodyMotion===`shake`){let e=t/1e3;r.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15*n,r.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35*n,r.headZ+=Math.sin(e*43)*.7*n}return r};return e.ambientBodyOffset=Xe,e.ambientEyeOffset=Ze,e.applyAmbientBodyMotion=Qe,e.applyAmbientMotion=(e,t,n=1)=>{let r=Qe(e,t,n),i=Ze(e,t,n);return r.positionXLeft+=i.x,r.positionXRight+=i.x,r.positionYLeft+=i.y,r.positionYRight+=i.y,r},e.expressionFields=le,e.hasAmbientMotion=Ye,e.poseFromExpression=fe,e.renderAvatar=We,e})({});"
+export const standaloneEngineSource = "var AvatarProceduralEngine=(function(e){Object.defineProperty(e,Symbol.toStringTag,{value:`Module`});var t=e=>e*e*(3-2*e),n=e=>{let t=Math.sin(e*127.1+311.7)*43758.5453;return(t-Math.floor(t))*2-1},r=e=>e.headX*.71+e.headY*1.13+e.headZ*1.37,i=17.29,a=(e,r,i,a)=>{let o=e/a,s=Math.floor(o),c=t(o-s),l=n(s*3+r+i);return l+(n((s+1)*3+r+i)-l)*c},o=(e,r,i)=>{let a=1100;if(e<=0)return 0;let o=Math.floor(e/a),s=(e-o*a)/140,c=t(Math.min(s,1)),l=o===0?0:n((o-1)*2+r+i);return l+(n(o*2+r+i)-l)*c},s=e=>e.eyeMotion!==`none`||e.bodyMotion!==`none`,c=(e,t,n=1)=>{let i=r(e);if(e.bodyMotion===`slowDrift`)return{x:a(t,3,i,2900)*1.45*n,y:a(t,4,i,3700)*1.1*n};if(e.bodyMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*31)+Math.sin(e*53)*.45)*1.35*n,y:(Math.sin(e*37)+Math.sin(e*61)*.4)*1.1*n}}return{x:0,y:0}},l=(e,t,n=1)=>{if(e.eyeMotion===`microSaccades`)return{x:o(t,0,i)*1.5*n,y:o(t,1,i)*.9*n};if(e.eyeMotion===`shake`){let e=t/1e3;return{x:(Math.sin(e*47)+Math.sin(e*71)*.45)*1.2*n,y:(Math.sin(e*59)+Math.sin(e*83)*.4)*.8*n}}return{x:0,y:0}},u=(e,t,n=1)=>{let i={...e},o=r(e);if(e.bodyMotion===`slowDrift`)i.headX+=a(t,0,o,2600)*.8*n,i.headY+=a(t,1,o,3300)*1.15*n,i.headZ+=a(t,2,o,4100)*.45*n;else if(e.bodyMotion===`shake`){let e=t/1e3;i.headX+=(Math.sin(e*31)+Math.sin(e*53)*.45)*1.15*n,i.headY+=(Math.sin(e*37)+Math.sin(e*61)*.4)*1.35*n,i.headZ+=Math.sin(e*43)*.7*n}return i},d=(e,t,n=1)=>{let r=u(e,t,n),i=l(e,t,n);return r.positionXLeft+=i.x,r.positionXRight+=i.x,r.positionYLeft+=i.y,r.positionYRight+=i.y,r},f=(e,t)=>Math.sign(e)*Math.abs(e)**t,p=(e,t,n,r,i,a,o)=>{let s=f(Math.cos(t),a);return[n/2*s*f(Math.sin(e),o),r/2*f(Math.sin(t),a),i/2*s*f(Math.cos(e),o)]},m=(e,t,n)=>{let r=e.width/2,i=e.depth/2,a=Math.min(r,e.height/2),o=Math.max(0,(e.height-a*2)/2),s=o*2+Math.PI*a,c=(n+Math.PI/2)/Math.PI*s,l=r,u=0;if(cMath.max(0,Math.min(2,e??0)),g=e=>1+h(e.roundness)/2,_=.04,v=e=>e.roundness<=0?1/0:2/(_+h(e.roundness)/2*.96),y=(e,t,n,r)=>{let i=Math.cos(n)*Math.sin(t),a=Math.sin(n),o=Math.cos(n)*Math.cos(t),s=Number.isFinite(r)?(Math.abs(i)**r+Math.abs(a)**r+Math.abs(o)**r)**(1/r)||1:Math.max(Math.abs(i),Math.abs(a),Math.abs(o))||1;return[e.width/2*(i/s),e.height/2*(a/s),e.depth/2*(o/s)]},b=(e,t,n)=>y(e,t,n,g(e)),x=(e,t,n)=>y(e,t,n,v(e)),ee=.24,te=.2,ne=.22,re=e=>h(e.morphRoundness)/2,ie=(e,t,n)=>{let r=re(e),i=Math.max(0,Math.min(1,t)),a=Math.sin(i*Math.PI),o=(1-Math.cos(i*Math.PI))/2;return{radiusScale:n.radiusScale+(a-n.radiusScale)*r,verticalProgress:n.verticalProgress+(o-n.verticalProgress)*r}},S=(e,t,n,r,i)=>{let a=1-i;return a**3*e+3*a*a*i*t+3*a*i*i*n+i**3*r},ae=e=>({tipFraction:(e.tipRoundness??0)*ee,baseFraction:(e.baseRoundness??0)*te}),C=(e,t)=>{let n=Math.max(0,Math.min(1,t)),r=e.roundness*ne;if(r<=0)return{radiusScale:1,verticalProgress:(Math.sin((n-.5)*Math.PI)+1)/2};if(n1-r){let e=(n-(1-r))/r*(Math.PI/2);return{radiusScale:1-r+r*Math.cos(e),verticalProgress:1-r/2+r*Math.sin(e)/2}}let i=(n-r)/(1-r*2);return{radiusScale:1,verticalProgress:r/2+i*(1-r)}},oe=(e,t)=>ie(e,t,C(e,t)),w=(e,t,n)=>{let r=Math.max(0,Math.min(1,t)),i=0,a=1;for(let t=0;t<14;t+=1){let t=(i+a)/2;n(e,t).verticalProgress{let n=Math.max(0,Math.min(1,t)),{tipFraction:r,baseFraction:i}=ae(e);if(i>0&&n0&&n>1-r){let e=(n-(1-r))/r;return{radiusScale:S(r,r/2,r/4,0,e),verticalProgress:S(1-r,1-r/2,1,1,e)}}return{radiusScale:1-n,verticalProgress:n}},T=(e,t)=>ie(e,t,se(e,t)),E=e=>{let t=e.height*.36,n=e.height-t;return{coneApexY:-e.height/2,coneBaseY:-e.height/2+t,bodyHeight:n,bodyCenterY:e.height/2-n/2,bodyWidth:e.width*.54,bodyDepth:e.depth*.62}},D=(e,t,n)=>{let{width:r,height:i,depth:a}=e;switch(e.type){case`sphere`:case`mickey`:return p(t,n,r,i,a,1,1);case`cube`:return x(e,t,n);case`cylinder`:{let o=oe(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),-i/2+i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}case`cursor`:{let r=E(e),i=(n+Math.PI/2)/Math.PI,a=C({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},i);return[r.bodyWidth/2*a.radiusScale*Math.sin(t),r.bodyCenterY-r.bodyHeight/2+r.bodyHeight*a.verticalProgress,r.bodyDepth/2*a.radiusScale*Math.cos(t)]}case`diamond`:return b(e,t,n);case`capsule`:return m(e,t,n);case`cone`:{let o=T(e,(n+Math.PI/2)/Math.PI);return[r/2*o.radiusScale*Math.sin(t),i/2-i*o.verticalProgress,a/2*o.radiusScale*Math.cos(t)]}}},O=(e,t)=>[e[0]-t[0],e[1]-t[1],e[2]-t[2]],k=([e,t,n])=>{let r=Math.hypot(e,t,n)||1;return[e/r,t/r,n/r]},ce=(e,t,n)=>{let r=e.type===`cone`?-1:1;return k([r*(t[1]*n[2]-t[2]*n[1]),r*(t[2]*n[0]-t[0]*n[2]),r*(t[0]*n[1]-t[1]*n[0])])},le=(e,t,n)=>{let r=5e-4;if(e.type===`cone`&&n>=Math.PI/2-r)return[0,-1,0];let i=D(e,t-r,n),a=D(e,t+r,n),o=D(e,t,Math.max(-Math.PI/2,n-r)),s=D(e,t,Math.min(Math.PI/2,n+r));return ce(e,O(a,i),O(s,o))},A=(e,t)=>Math.sign(e)*Math.abs(e)**t,ue=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;return k([A(t[0]/r,n-1)/r,A(t[1]/i,n-1)/i,A(t[2]/a,n-1)/a])},de=(e,t)=>ue(e,t,g(e)),fe=(e,t)=>{let n=v(e);if(Number.isFinite(n))return ue(e,t,n);let r=[t[0]/(e.width/2||1),t[1]/(e.height/2||1),t[2]/(e.depth/2||1)],i=r.reduce((e,t,n)=>Math.abs(t)>Math.abs(r[e])?n:e,0);return[i===0?Math.sign(r[0]):0,i===1?Math.sign(r[1]):0,i===2?Math.sign(r[2]):0]},pe=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.height/2||1,s=e.depth/2||1;if(!Number.isFinite(r)){let r=[Math.max(-a,Math.min(a,t)),Math.max(-o,Math.min(o,n)),s];return{point:r,normal:i(e,r)}}let c=Math.max(-1,Math.min(1,n/o)),l=Math.max(0,1-Math.abs(c)**r)**(1/r),u=Math.max(-a*l,Math.min(a*l,t)),d=u/a,f=Math.max(0,1-Math.abs(d)**r-Math.abs(c)**r)**(1/r),p=[u,c*o,s*f];return{point:p,normal:i(e,p)}},me=(e,t,n,r,i,a=0)=>{let o=t-a,s=Math.max(0,1-(e/(n||1))**2-(o/(r||1))**2),c=i*Math.sqrt(s);return{point:[e,t,c],normal:k([e/(n*n||1),o/(r*r||1),c/(i*i||1)])}},j=(e,t,n,r,i)=>{let a=e.width/2||1,o=e.depth/2||1,s=Math.max(0,Math.min(1,.5+i*(n/e.height))),c=w(e,s,r),l=a*c,u=o*c,d=Math.max(-l,Math.min(l,t)),f=l>0?Math.max(0,1-(d/l)**2):0,p=u*Math.sqrt(f),m=1e-4,h=Math.max(0,s-m),g=Math.min(1,s+m),_=w(e,h,r),v=(w(e,g,r)-_)/(g-h||1),y=Math.max(Math.sqrt(f),1e-4),b=-(o/a)*d/(l*y||1),x=i*o*v/(e.height*y||1);return{point:[d,n,p],normal:k([-b,-x,1])}},he=(e,t,n)=>{let r=e.width/2||1,i=e.height/2||1,a=e.depth/2||1;switch(e.type){case`sphere`:case`mickey`:return me(t,n,r,i,a);case`cube`:return pe(e,t,n,v(e),fe);case`capsule`:{let e=Math.min(r,i),o=Math.max(0,i-e);return me(t,n,r,e,a,n<-o?-o:n>o?o:n)}case`cylinder`:return j(e,t,n,oe,1);case`cursor`:{let r=E(e),i=j({...e,width:r.bodyWidth,height:r.bodyHeight,depth:r.bodyDepth},t,n-r.bodyCenterY,C,1);return{point:[i.point[0],i.point[1]+r.bodyCenterY,i.point[2]],normal:i.normal}}case`cone`:return j(e,t,n,T,-1);case`diamond`:return pe(e,t,n,g(e),de)}},ge=(e,t,n)=>{let r=D(e,t,n);if(e.type===`sphere`||e.type===`mickey`){let t=e.width/2||1,n=e.height/2||1,i=e.depth/2||1;return{point:r,normal:k([r[0]/(t*t),r[1]/(n*n),r[2]/(i*i)])}}return e.type===`cylinder`&&e.roundness<=0&&(e.morphRoundness??0)<=0?{point:r,normal:k([Math.sin(t)/(e.width/2||1),0,Math.cos(t)/(e.depth/2||1)])}:e.type===`diamond`?{point:r,normal:de(e,r)}:e.type===`cube`?{point:r,normal:fe(e,r)}:{point:r,normal:le(e,t,n)}},M=620,_e=14,ve=[`headX`,`headY`,`headZ`,`widthLeft`,`widthRight`,`heightLeft`,`heightRight`,`spacing`,`positionXLeft`,`positionXRight`,`positionYLeft`,`positionYRight`,`leftAngle`,`rightAngle`,`perspective`],N=e=>e*Math.PI/180,P=([e,t,n,r])=>{let i=Math.hypot(e,t,n,r)||1;return[e/i,t/i,n/i,r/i]},F=([e,t,n,r],[i,a,o,s])=>P([e*i-t*a-n*o-r*s,e*a+t*i+n*s-r*o,e*o-t*s+n*i+r*a,e*s+t*o-n*a+r*i]),I=([e,t,n],r)=>{let i=r/2,a=Math.sin(i);return P([Math.cos(i),e*a,t*a,n*a])},L=(e,t,n)=>{let r=I([1,0,0],e),i=I([0,1,0],t);return F(F(I([0,0,1],n),r),i)},R=([e,t,n,r],[i,a,o])=>{let s=2*(n*o-r*a),c=2*(r*i-t*o),l=2*(t*a-n*i);return[i+e*s+(n*l-r*c),a+e*c+(r*s-t*l),o+e*l+(t*c-n*s)]},ye=(e,t)=>{let n=e/2,r=t/2,i=Math.min(r,n),a=[],o=(e,t)=>{let n=Math.max(2,Math.ceil(Math.hypot(t[0]-e[0],t[1]-e[1])/1.5));for(let r=0;r{for(let r=0;r<_e;r+=1){let o=n+r/_e*(Math.PI/2);a.push([e+Math.cos(o)*i,t+Math.sin(o)*i])}};return o([-n+i,-r],[n-i,-r]),s(n-i,-r+i,-Math.PI/2),o([n,-r+i],[n,r-i]),s(n-i,r-i,0),o([n-i,r],[-n+i,r]),s(-n+i,r-i,Math.PI/2),o([-n,r-i],[-n,-r+i]),s(-n+i,-r+i,Math.PI),a},z=(e,t)=>{let n=M-e[2]*t,r=Math.abs(n)<1e-4?M/1e-4:M/n;return[e[0]*r,e[1]*r,e[2]]},B=(e,t=!0)=>e.length?`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.slice(1).map(e=>`L${e[0].toFixed(2)} ${e[1].toFixed(2)}`).join(``)}${t?`Z`:``}`:``,be=e=>({expression:e,orientation:L(N(e.headX),N(e.headY),N(e.headZ))}),xe=24,Se=25,Ce=73,V=144,we=33,Te=73,H=new Map,Ee=new Map,De=new Map,U=e=>JSON.stringify([e.type,e.width,e.height,e.depth,e.roundness,e.morphRoundness,e.tipRoundness,e.baseRoundness]),W=(e,t,n)=>(e.size>=xe&&e.delete(e.keys().next().value),e.set(t,n),n),Oe=(e,t,n)=>ge(e,t,n),ke=(e,t)=>({point:z(R(e.orientation,t.point),e.expression.perspective),normal:R(e.orientation,t.normal)}),Ae=(e,t)=>{let n=e/120,r=t/120;return[120*Math.cos(r)*Math.sin(n),120*Math.sin(r)]},je=(e,t,n,r)=>{let[i,a]=Ae(n,r);return ke(e,he(t,i,a))},Me=(e,t,n,r,i={x:0,y:0})=>{let a=e.expression,o=n<0?`Left`:`Right`,s=a[`width${o}`],c=5+(a[`height${o}`]-5)*r,l=n*a.spacing/2+a[`positionX${o}`]+i.x,u=a[`positionY${o}`]+i.y,d=N(n<0?a.leftAngle:a.rightAngle);return ye(s,c).map(([n,r])=>{let i=n*Math.cos(d)-r*Math.sin(d),a=n*Math.sin(d)+r*Math.cos(d);return je(e,t,l+i,u+a)})},Ne=e=>{let t=[],n=[];return e.forEach(({point:e,normal:r})=>{r[2]>0?n.push(e):n.length&&(t.push(n),n=[])}),n.length&&t.push(n),t.filter(e=>e.length>1).map(e=>B(e,!1)).join(``)},Pe=(e,t)=>{let n=U(t),r=De.get(n);if(!r){let e=[-60,-30,0,30,60].map(e=>Array.from({length:73},(n,r)=>Oe(t,N(-180+r*5),N(e)))),i=Array.from({length:12},(e,t)=>-150+t*30).map(e=>Array.from({length:37},(n,r)=>Oe(t,N(e),N(-90+r*5))));r=W(De,n,[...e,...i])}return r.map(t=>Ne(t.map(t=>ke(e,t))))},G=e=>{let t=[...e].sort((e,t)=>e[0]-t[0]||e[1]-t[1]),n=(e,t,n)=>(t[0]-e[0])*(n[1]-e[1])-(t[1]-e[1])*(n[0]-e[0]),r=e=>{let t=[];return e.forEach(e=>{for(;t.length>=2&&n(t.at(-2),t.at(-1),e)<=0;)t.pop();t.push(e)}),t};return[...r(t).slice(0,-1),...r(t.reverse()).slice(0,-1)]},K=e=>{if(e.length<3)return B(e);let t=t=>e[(t+e.length)%e.length];return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((e,n)=>{let r=t(n-1),i=t(n+1),a=t(n+2),o=[e[0]+(i[0]-r[0])/6,e[1]+(i[1]-r[1])/6,e[2]],s=[i[0]-(a[0]-e[0])/6,i[1]-(a[1]-e[1])/6,i[2]];return`C${o[0].toFixed(2)} ${o[1].toFixed(2)} ${s[0].toFixed(2)} ${s[1].toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},q=(e,t=7)=>e.flatMap((n,r)=>{let i=e[(r+1)%e.length],a=Math.max(1,Math.ceil(Math.hypot(i[0]-n[0],i[1]-n[1])/t));return Array.from({length:a},(e,t)=>{let r=t/a;return[n[0]+(i[0]-n[0])*r,n[1]+(i[1]-n[1])*r,n[2]+(i[2]-n[2])*r]})}),Fe=e=>e.length?e.length===1?`${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}`:e.slice(0,-1).map((t,n)=>{let r=e[Math.max(0,n-1)],i=e[n+1],a=e[Math.min(e.length-1,n+2)],o=t[0]+(i[0]-r[0])/6,s=t[1]+(i[1]-r[1])/6,c=i[0]-(a[0]-t[0])/6,l=i[1]-(a[1]-t[1])/6;return`C${o.toFixed(2)} ${s.toFixed(2)} ${c.toFixed(2)} ${l.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``):``,J=(e,t)=>z(R(e.orientation,t),e.expression.perspective),Y=(e,t,n)=>Array.from({length:145},(r,i)=>{let a=i/V*Math.PI*2;return[e/2*Math.sin(a),n,t/2*Math.cos(a)]}),X=(e,t)=>{let n=U(t),r=H.get(n);return r||(r=Array.from({length:we},(e,n)=>{let r=-Math.PI/2+n/32*Math.PI;return Array.from({length:Te},(e,n)=>D(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),W(H,n,r)),K(q(G(r.map(t=>J(e,t)))))},Ie=(e,t)=>{if(t.roundness>0||(t.morphRoundness??0)>0)return X(e,t);let n=t.height/2;return K(q(G([...Y(t.width,t.depth,-n),...Y(t.width,t.depth,n)].map(t=>J(e,t)))))},Le=(e,t)=>{let n=E(t),r=n.bodyHeight/2;return K(q(G([...Y(n.bodyWidth,n.bodyDepth,n.bodyCenterY-r),...Y(n.bodyWidth,n.bodyDepth,n.bodyCenterY+r)].map(t=>J(e,t)))))},Re=(e,t)=>{let n=E(t),r=J(e,[0,n.coneApexY,0]);return K(q(G([...Y(t.width,t.depth,n.coneBaseY).map(t=>J(e,t)),r])))},ze=(e,t)=>{if((t.morphRoundness??0)>0||(t.tipRoundness??0)>0||(t.baseRoundness??0)>0)return X(e,t);let n=J(e,[0,-t.height/2,0]),r=G([...Y(t.width,t.depth,t.height/2).map(t=>J(e,t)),n]),i=r.findIndex(e=>Math.hypot(e[0]-n[0],e[1]-n[1])<.01);if(i<0)return K(r);let a=[...r.slice(i),...r.slice(0,i)].slice(1);return a.length<2?B(r):`M${n[0].toFixed(2)} ${n[1].toFixed(2)}L${a[0][0].toFixed(2)} ${a[0][1].toFixed(2)}${Fe(a)}L${n[0].toFixed(2)} ${n[1].toFixed(2)}Z`},Be=(e,t)=>{if(t.roundness>0)return X(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return B(G([-1,1].flatMap(e=>[-1,1].flatMap(t=>[-1,1].map(a=>[e*n,t*r,a*i]))).map(t=>J(e,t))))},Ve=(e,t)=>{if(t.roundness>0)return X(e,t);let n=t.width/2,r=t.height/2,i=t.depth/2;return B(G([[-n,0,0],[n,0,0],[0,-r,0],[0,r,0],[0,0,-i],[0,0,i]].map(t=>J(e,t))))},He=(e,t,n,r,i)=>{let a=n+i,o=Math.hypot(n-i,r*2),s=(a+o)/2,c=(a-o)/2;return s<=0||c<=0?null:{centerX:e,centerY:t,majorRadius:Math.sqrt(s),minorRadius:Math.sqrt(c),rotation:Math.atan2(r*2,n-i)/2}},Z=({centerX:e,centerY:t,majorRadius:n,minorRadius:r,rotation:i})=>{let a=i*180/Math.PI,o=Math.cos(i)*n,s=Math.sin(i)*n,c=e+o,l=t+s,u=e-o,d=t-s;return`M${c.toFixed(2)} ${l.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${u.toFixed(2)} ${d.toFixed(2)}A${n.toFixed(2)} ${r.toFixed(2)} ${a.toFixed(2)} 0 1 ${c.toFixed(2)} ${l.toFixed(2)}Z`},Q=(e,t,n=[0,0,0])=>{let r=[R(e.orientation,[1,0,0]),R(e.orientation,[0,1,0]),R(e.orientation,[0,0,1])],i=R(e.orientation,n);if(Math.abs(e.expression.perspective)<1e-4){let e=r.reduce((e,n,r)=>e+n[0]*n[0]*t[r]*t[r],0),n=r.reduce((e,n,r)=>e+n[0]*n[1]*t[r]*t[r],0),a=r.reduce((e,n,r)=>e+n[1]*n[1]*t[r]*t[r],0);return He(i[0],i[1],e,n,a)}let a=t.map(e=>1/(e*e)),o=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>r.reduce((e,r,i)=>e+r[t]*a[i]*r[n],0))),s=M/e.expression.perspective,c=[-i[0],-i[1],s-i[2]],l=[o[0][0]*c[0]+o[0][1]*c[1]+o[0][2]*c[2],o[1][0]*c[0]+o[1][1]*c[1]+o[1][2]*c[2],o[2][0]*c[0]+o[2][1]*c[1]+o[2][2]*c[2]],u=c[0]*l[0]+c[1]*l[1]+c[2]*l[2]-1,d=[l[0],l[1],-s*l[2]],f=[[o[0][0],o[0][1],-s*o[0][2]],[o[1][0],o[1][1],-s*o[1][2]],[-s*o[2][0],-s*o[2][1],s*s*o[2][2]]],p=Array.from({length:3},(e,t)=>Array.from({length:3},(e,n)=>d[t]*d[n]-u*f[t][n])),m=p[0][0]*p[1][1]-p[0][1]*p[0][1];if(Math.abs(m)<1e-12)return null;let h=-(p[1][1]*p[0][2]-p[0][1]*p[1][2])/m,g=(p[0][1]*p[0][2]-p[0][0]*p[1][2])/m,_=-(p[2][2]+p[0][2]*h+p[1][2]*g);if(Math.abs(_)<1e-12)return null;let v=p[0][0]/_,y=p[0][1]/_,b=p[1][1]/_,x=v*b-y*y;return x<=0?null:He(h,g,b/x,-y/x,v/x)},Ue=(e,t)=>{let n=Q(e,[t.width/2,t.height/2,t.depth/2]),r=t.width===t.height&&t.height===t.depth;if(n&&r){let e=(n.majorRadius+n.minorRadius)/2;return Z({centerX:0,centerY:0,majorRadius:e,minorRadius:e,rotation:0})}return n?Z(n):null},We=(e,t)=>{if(t.type!==`mickey`)return[];let n=Math.min(t.width,t.height)*.23,r=Math.min(n,t.depth*.29),i=t.width*.37,a=-t.height*.39,o=-t.depth*.12,s=[n,n,r];return[-1,1].map(t=>Q(e,s,[t*i,a,o])).filter(e=>e!==null).map(Z)},Ge=(e,t)=>t.type===`mickey`?We(e,t):t.type===`cursor`?[Re(e,t)]:[],$=e=>Array.from({length:V},(t,n)=>{let r=n/V*Math.PI*2,i=Math.cos(r)*e.majorRadius,a=Math.sin(r)*e.minorRadius;return[e.centerX+i*Math.cos(e.rotation)-a*Math.sin(e.rotation),e.centerY+i*Math.sin(e.rotation)+a*Math.cos(e.rotation),0]}),Ke=e=>{if(e.length<3)return B(e);let t=e.map((t,n)=>{let r=e[(n+1)%e.length];return Math.hypot(r[0]-t[0],r[1]-t[1])}),n=[...t].sort((e,t)=>e-t),r=n[Math.floor(n.length/2)]||1,i=Math.max(8,r*3.5),a=t.map(e=>e>i);return`M${e[0][0].toFixed(2)} ${e[0][1].toFixed(2)}${e.map((t,n)=>{let r=(n+1)%e.length,i=e[r];if(a[n])return`L${i[0].toFixed(2)} ${i[1].toFixed(2)}`;let o=a[(n-1+e.length)%e.length]?t:e[(n-1+e.length)%e.length],s=a[r]?i:e[(n+2)%e.length],c=t[0]+(i[0]-o[0])/6,l=t[1]+(i[1]-o[1])/6,u=i[0]-(s[0]-t[0])/6,d=i[1]-(s[1]-t[1])/6;return`C${c.toFixed(2)} ${l.toFixed(2)} ${u.toFixed(2)} ${d.toFixed(2)} ${i[0].toFixed(2)} ${i[1].toFixed(2)}`}).join(``)}Z`},qe=(e,t)=>{let n=t.width/2,r=Math.min(n,t.height/2),i=t.depth/2,a=Math.max(0,(t.height-r*2)/2),o=[n,r,i],s=Q(e,o,[0,a,0]),c=Q(e,o,[0,-a,0]);return!s||!c?null:Ke(G([...$(s),...$(c)]))},Je=(e,t)=>{if(t.type===`sphere`||t.type===`mickey`){let n=Ue(e,t);if(n)return n}if(t.type===`capsule`){let n=qe(e,t);if(n)return n}if(t.type===`cylinder`)return Ie(e,t);if(t.type===`cursor`)return Le(e,t);if(t.type===`cone`)return ze(e,t);if(t.type===`cube`)return Be(e,t);if(t.type===`diamond`)return Ve(e,t);let n=U(t),r=H.get(n);return r||(r=Array.from({length:Se},(e,n)=>{let r=-Math.PI/2+n/24*Math.PI;return Array.from({length:Ce},(e,n)=>D(t,-Math.PI+n/72*Math.PI*2,r))}).flat(),W(H,n,r)),B(G(r.map(t=>z(R(e.orientation,t),e.expression.perspective))))},Ye=(e,t)=>{let n=U(t.surface),r=Ee.get(n);r||(r=Array.from({length:17},(e,n)=>{let r=-Math.PI/2+n/16*Math.PI;return Array.from({length:49},(e,n)=>{let i=-Math.PI+n/48*Math.PI*2;return D(t.surface,i,r)})}).flat(),W(Ee,n,r));let i=L(N(t.rotation[0]),N(t.rotation[1]),N(t.rotation[2])),a=G(r.map(n=>{let r=R(i,n),a=[r[0]+t.position[0],r[1]+t.position[1],r[2]+t.position[2]];return z(R(e.orientation,a),e.expression.perspective)}));return(t.surface.type===`cube`||t.surface.type===`diamond`)&&t.surface.roundness<=0?B(a):K(q(a))},Xe=.1,Ze=(e,t)=>{let n=L(N(t.rotation[0]),N(t.rotation[1]),N(t.rotation[2])),r=[[1,0,0],[0,1,0],[0,0,1]].map(t=>R(e.orientation,R(n,t))[2]);return Math.hypot(r[0]*(t.surface.width/2),r[1]*(t.surface.height/2),r[2]*(t.surface.depth/2))},Qe=(e,t)=>{let n=t.map(t=>{let n=R(e.orientation,t.position)[2];return{id:t.id,path:Ye(e,t),depth:n,front:n>Ze(e,t)*Xe}}).sort((e,t)=>e.depth-t.depth);return{backPaths:n.filter(e=>!e.front).map(e=>e.path),frontPaths:n.filter(e=>e.front).map(e=>e.path),backNodeIds:n.filter(e=>!e.front).map(e=>e.id),frontNodeIds:n.filter(e=>e.front).map(e=>e.id)}};return e.ambientBodyOffset=c,e.ambientEyeOffset=l,e.applyAmbientBodyMotion=u,e.applyAmbientMotion=d,e.expressionFields=ve,e.hasAmbientMotion=s,e.poseFromExpression=be,e.renderAvatar=(e,t,n=1,r={})=>{let i=Me(e,t,-1,n,r.eyeOffset),a=Me(e,t,1,n,r.eyeOffset),o=i.map(e=>e.point),s=a.map(e=>e.point),c=Qe(e,r.bodyNodes??[]),l=Ge(e,t);return{backPaths:[...l,...c.backPaths],frontPaths:c.frontPaths,backNodeIds:[...l.map(()=>null),...c.backNodeIds],frontNodeIds:c.frontNodeIds,headPath:Je(e,t),leftPath:B(o),rightPath:B(s),leftVisible:i.reduce((e,t)=>e+t.normal[2],0)>0,rightVisible:a.reduce((e,t)=>e+t.normal[2],0)>0,wirePaths:r.includeWire===!1?[]:Pe(e,t)}},e})({});"
diff --git a/src/features/studio/__tests__/runtime-preview-dialog-test.tsx b/src/features/studio/__tests__/runtime-preview-dialog-test.tsx
new file mode 100644
index 0000000..780bcaf
--- /dev/null
+++ b/src/features/studio/__tests__/runtime-preview-dialog-test.tsx
@@ -0,0 +1,63 @@
+// @vitest-environment jsdom
+
+import { fireEvent, render, screen } from '@testing-library/react'
+import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
+
+import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
+import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
+import { StudioLanguageProvider } from '@/i18n'
+
+const definitionResult = validateAvatarDefinition(definitionJson)
+if (!definitionResult.ok) throw new Error('Invalid preview fixture')
+const definition = definitionResult.value
+
+const renderPreview = (avatarDefinition: AvatarDefinition, initialAnimation?: string) =>
+ render(
+
+
+
+ )
+
+describe('runtime export preview', () => {
+ beforeEach(() => {
+ vi.stubGlobal('requestAnimationFrame', () => 1)
+ vi.stubGlobal('cancelAnimationFrame', vi.fn())
+ vi.stubGlobal('matchMedia', () => ({ matches: false }))
+ })
+
+ afterEach(() => vi.unstubAllGlobals())
+
+ it('exposes the exported animations, expressions and playback controls', () => {
+ renderPreview(definition, 'sleeping')
+
+ expect(screen.getByRole('heading', { name: 'Exported definition preview' })).toBeTruthy()
+ expect(screen.getByRole('heading', { name: 'Exported animations' })).toBeTruthy()
+ expect(screen.getByRole('heading', { name: 'Exported expressions' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Play' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Pause' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()
+ expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy()
+
+ fireEvent.click(screen.getByRole('button', { name: 'idle' }))
+ expect(screen.getByRole('button', { name: 'idle' }).getAttribute('aria-pressed')).toBe('true')
+ })
+
+ it('previews an expression-only definition and disables Play', () => {
+ const expressionOnly = {
+ ...definition,
+ animations: {},
+ animationOrder: [],
+ } as AvatarDefinition
+
+ renderPreview(expressionOnly)
+
+ expect(screen.getByText('No exported animation')).toBeTruthy()
+ expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true)
+ expect(screen.getAllByText('neutral').length).toBeGreaterThan(0)
+ })
+})
diff --git a/src/features/studio/__tests__/studio-document-test.ts b/src/features/studio/__tests__/studio-document-test.ts
index ff2e146..9dcbf94 100644
--- a/src/features/studio/__tests__/studio-document-test.ts
+++ b/src/features/studio/__tests__/studio-document-test.ts
@@ -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', () => {
diff --git a/src/features/studio/components/RuntimeGuideDialog.tsx b/src/features/studio/components/RuntimeGuideDialog.tsx
new file mode 100644
index 0000000..cb9e103
--- /dev/null
+++ b/src/features/studio/components/RuntimeGuideDialog.tsx
@@ -0,0 +1,604 @@
+import { BookOpen, Code2, MousePointer2, Play, SlidersHorizontal, X } from 'lucide-react'
+
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { Button } from '@/components/ui/button'
+import { useStudioLanguage } from '@/i18n'
+
+const runtimeInstallExample = 'npm install @bible-strong/avatar-react react react-dom'
+const webInstallExample = 'npm install @bible-strong/avatar-web'
+
+const webAvatarExample = (
+ animationKey: string | undefined
+) => `import { createAvatar } from '@bible-strong/avatar-web'
+import definition from './avatar.avatar.json'
+
+const avatar = createAvatar('#avatar', {
+ definition,
+ ${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`}
+})
+
+avatar.play(${animationKey ? `'${animationKey}'` : `'idle'`})
+avatar.pause()
+avatar.setExpression('neutral')
+avatar.stop()`
+
+const nativeBrowserExample = `
+
+`
+
+const createAvatarExample = (
+ animationKey: string | undefined
+) => `import { createAvatar } from '@bible-strong/avatar-react'
+import '@bible-strong/avatar-react/styles.css'
+import avatarJson from './avatar.avatar.json'
+
+const StrobiAvatar = createAvatar(avatarJson)
+
+export function Strobi() {
+ return
+}`
+
+const genericAvatarExample = `import { Avatar } from '@bible-strong/avatar-react'
+import type { AvatarDefinition, ExpressionKey } from '@bible-strong/avatar-core'
+import '@bible-strong/avatar-react/styles.css'
+
+export function DynamicAvatar({
+ definition,
+ expression,
+}: {
+ definition: AvatarDefinition
+ expression: ExpressionKey
+}) {
+ return (
+ console.error(error)}
+ />
+ )
+}`
+
+const imperativeExample = (
+ animationKey: string | undefined
+) => `import { createAvatar, type AvatarController } from '@bible-strong/avatar-react'
+import { useRef } from 'react'
+import avatarJson from './avatar.avatar.json'
+
+const StrobiAvatar = createAvatar(avatarJson)
+
+export function Controls() {
+ const avatar = useRef(null)
+
+ return <>
+ ${
+ animationKey
+ ? `
+ avatar.current?.play('${animationKey}')}>Play animation `
+ : ''
+ }
+ avatar.current?.pause()}>Pause
+ avatar.current?.setExpression('neutral')}>Set expression
+ avatar.current?.stop()}>Stop
+ console.log(avatar.current?.getState())}>Read state
+ >
+}`
+
+const codeTokenPattern =
+ /(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)\b)/g
+
+const highlightedCode = (source: string) =>
+ source.split(codeTokenPattern).map((token, index) => {
+ if (!token) return null
+ const kind = token.startsWith('//')
+ ? 'comment'
+ : token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
+ ? 'string'
+ : token.startsWith('<')
+ ? 'tag'
+ : /^(?:npm|install|import|from|const|if|throw|new|export|function|return|onClick|ref|useRef)$/.test(
+ token
+ )
+ ? 'keyword'
+ : 'plain'
+ return (
+
+ {token}
+
+ )
+ })
+
+function GuideCode({ children }: { children: string }) {
+ return (
+
+ {highlightedCode(children)}
+
+ )
+}
+
+function PropRow({ name, type, description }: { name: string; type: string; description: string }) {
+ return (
+
+
+ {name}
+ {type}
+
+ {description}
+
+ )
+}
+
+export function RuntimeGuideDialog({
+ animationKey,
+ integration = 'react',
+ onOpenChange,
+ open,
+}: {
+ animationKey?: string
+ integration?: 'react' | 'javascript'
+ onOpenChange: (open: boolean) => void
+ open: boolean
+}) {
+ const { t } = useStudioLanguage()
+
+ return (
+
+
+
+
+
+ {t('Guide d’utilisation')}
+
+
+ {t(
+ integration === 'react'
+ ? 'Guide d’utilisation de l’avatar React'
+ : 'Guide d’utilisation de l’avatar JavaScript'
+ )}
+
+
+ {t(
+ integration === 'react'
+ ? 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.'
+ : 'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.'
+ )}
+
+
+
+
+ }
+ />
+
+
+
+ {integration === 'react' ? (
+ <>
+
+
+
+
+
{t('Installation')}
+
{t('Ajoute le package React et ses dépendances.')}
+
+
+ {runtimeInstallExample}
+
+ {t(
+ 'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
+ )}
+
+
+
+
+
+
+
+
{t('API recommandée : créer un avatar concret')}
+
+ {t(
+ 'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.'
+ )}
+
+
+
+ {createAvatarExample(animationKey)}
+
+
+
+
+
+
+
{t('Props de l’avatar')}
+
+ {t(
+ 'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.'
+ )}
+
+
+
+
+
+
{t('Cible et lecture')}
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('Présentation')}
+
+
+
+
+
+
+
+
+
+
{t('Callbacks de lecture')}
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('Avatar générique')}
+
+ {t(
+ 'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.'
+ )}
+
+
+
+ {genericAvatarExample}
+
+
+
+
+
+
+
{t('API impérative')}
+
+ {t('La ref expose les commandes de lecture et l’état courant de l’avatar.')}
+
+
+ {t(
+ 'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.'
+ )}
+
+
+
+
+ {imperativeExample(animationKey)}
+
+ >
+ ) : (
+ <>
+
+
+
+
+
{t('Installation')}
+
{t('Ajoute le renderer DOM, qui utilise automatiquement avatar-core.')}
+
+
+ {webInstallExample}
+
+ {t(
+ 'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.'
+ )}
+
+
+
+
+
+
+
+
{t('Utilisation avec un bundler ESM')}
+
+ {t(
+ 'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.'
+ )}
+
+
+
+ {webAvatarExample(animationKey)}
+
+
+
+
+
+
+
{t('Options de createAvatar')}
+
{t('Référence des valeurs acceptées lors du montage dans le DOM.')}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
{t('API du contrôleur DOM')}
+
{t('createAvatar retourne immédiatement ces commandes impératives.')}
+
+
+
+
+
+
+
+
+
+
{t('Navigateur sans bundler')}
+
+ {t(
+ 'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.'
+ )}
+
+
+
+ {nativeBrowserExample}
+
+ >
+ )}
+
+
+
+ {t('Fermer le guide')}} />
+
+
+
+ )
+}
diff --git a/src/features/studio/components/RuntimePreviewDialog.tsx b/src/features/studio/components/RuntimePreviewDialog.tsx
new file mode 100644
index 0000000..da0bb94
--- /dev/null
+++ b/src/features/studio/components/RuntimePreviewDialog.tsx
@@ -0,0 +1,257 @@
+import { Eye, Pause, Play, RotateCcw, Square, X } from 'lucide-react'
+import { useRef, useState } from 'react'
+
+import { Avatar, type AvatarController, type AvatarRuntimeError } from '@bible-strong/avatar-react'
+import type { AnimationKey, AvatarDefinition, ExpressionKey } from '@bible-strong/avatar-core'
+
+import { Button } from '@/components/ui/button'
+import {
+ Dialog,
+ DialogClose,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from '@/components/ui/dialog'
+import { useStudioLanguage } from '@/i18n'
+
+type PlaybackStatus = 'playing' | 'paused' | 'stopped'
+type PreviewTarget =
+ { type: 'animation'; key: AnimationKey } | { type: 'expression'; key: ExpressionKey }
+
+function RuntimePreviewContent({
+ definition: inputDefinition,
+ initialAnimation,
+}: {
+ definition: AvatarDefinition
+ initialAnimation?: AnimationKey
+}) {
+ const { t } = useStudioLanguage()
+ const [definition] = useState(inputDefinition)
+ const controller = useRef(null)
+ const firstAnimation = initialAnimation ?? definition.animationOrder[0]
+ const [target, setTarget] = useState(
+ firstAnimation
+ ? { type: 'animation', key: firstAnimation }
+ : { type: 'expression', key: 'neutral' }
+ )
+ const [activeAnimation, setActiveAnimation] = useState(firstAnimation)
+ const [activeExpression, setActiveExpression] = useState(
+ firstAnimation
+ ? (definition.animations[firstAnimation]?.steps[0]?.expression ?? 'neutral')
+ : 'neutral'
+ )
+ const [status, setStatus] = useState(firstAnimation ? 'playing' : 'stopped')
+ const [error, setError] = useState(null)
+
+ const reportResult = (result: ReturnType) => {
+ if (result.ok) setError(null)
+ else setError(result.error)
+ return result.ok
+ }
+ const playAnimation = (animation: AnimationKey) => {
+ if (!controller.current || !reportResult(controller.current.play(animation))) return
+ setTarget({ type: 'animation', key: animation })
+ setActiveAnimation(animation)
+ setStatus('playing')
+ }
+ const showExpression = (expression: ExpressionKey) => {
+ if (!controller.current || !reportResult(controller.current.setExpression(expression))) return
+ setTarget({ type: 'expression', key: expression })
+ setActiveAnimation(undefined)
+ setActiveExpression(expression)
+ setStatus('stopped')
+ }
+ const restart = () => {
+ if (target.type === 'animation') playAnimation(target.key)
+ else showExpression(target.key)
+ }
+ const pause = () => {
+ controller.current?.pause()
+ if (activeAnimation) setStatus('paused')
+ }
+ const stop = () => {
+ controller.current?.stop()
+ setActiveAnimation(undefined)
+ setActiveExpression('neutral')
+ setStatus('stopped')
+ }
+
+ const statusLabel =
+ status === 'playing' ? t('En lecture') : status === 'paused' ? t('En pause') : t('Arrêté')
+
+ return (
+
+
+
+
+ {t('Définition exportée')}
+ {definition.name ?? t('Avatar')}
+
+
+
+ {statusLabel}
+
+
+
+
{
+ setActiveAnimation(undefined)
+ setStatus('stopped')
+ }}
+ onExpressionChange={setActiveExpression}
+ onError={setError}
+ />
+
+
+
+ {t('Animation active')}
+ {activeAnimation ?? '—'}
+
+
+ {t('Expression active')}
+ {activeExpression}
+
+
+
+
+ playAnimation(target.type === 'animation' ? target.key : firstAnimation!)
+ }
+ >
+
+ {t('Lire')}
+
+
+
+ {t('Pause')}
+
+
+
+ {t('Arrêter')}
+
+
+
+ {t('Relancer')}
+
+
+ {error && (
+
+ {error.message}
+
+ )}
+
+
+
+
+ )
+}
+
+export function RuntimePreviewDialog({
+ definition,
+ initialAnimation,
+ onOpenChange,
+ open,
+}: {
+ definition: AvatarDefinition | null
+ initialAnimation?: AnimationKey
+ onOpenChange: (open: boolean) => void
+ open: boolean
+}) {
+ const { t } = useStudioLanguage()
+
+ return (
+
+
+
+
+
+ {t('Preview')}
+
+ {t('Preview de la définition exportée')}
+
+ {t(
+ 'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.'
+ )}
+
+
+
+
+ }
+ />
+
+ {open && definition && (
+
+ )}
+
+
+ )
+}
diff --git a/src/features/studio/components/StudioInspector.tsx b/src/features/studio/components/StudioInspector.tsx
index 75ea493..01429ff 100644
--- a/src/features/studio/components/StudioInspector.tsx
+++ b/src/features/studio/components/StudioInspector.tsx
@@ -1,5 +1,8 @@
import {
ArrowLeft,
+ ArrowRight,
+ ChevronDown,
+ ChevronUp,
Copy,
Download,
FileCode2,
@@ -10,6 +13,7 @@ import {
RotateCcw,
Smile,
Trash2,
+ TriangleAlert,
Upload,
} from 'lucide-react'
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
@@ -50,11 +54,7 @@ import { ColorField, LinkButton, NumericField } from '@/app/components/controls'
import { formatSeconds, scaleSurface, type Side, type SnapshotFormat } from '@/app/studio-utils'
import { SequenceWorkspace } from '@/features/animation/components/SequenceWorkspace'
import { findExpressionIndex, groupSequences } from '@/features/animation/sequences'
-import {
- defaultAvatarEyes,
- defaultPixelRenderStyle,
- type AvatarRenderStyle,
-} from '@/features/avatar/avatars'
+import { defaultAvatarEyes } from '@/features/avatar/avatars'
import { bodyPrimitiveTypes, MAX_BODY_NODES } from '@/features/avatar/body'
import {
ExpressionCard,
@@ -66,10 +66,38 @@ 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 { RuntimeGuideDialog } from '@/features/studio/components/RuntimeGuideDialog'
+import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
import type { StudioController } from '@/features/studio/useStudioController'
+const reactQuickStartInstall = 'npm install @bible-strong/avatar-react react react-dom'
+const webQuickStartInstall = 'npm install @bible-strong/avatar-web'
+
+const reactQuickStartExample = (animationKey: string | undefined) =>
+ `import { createAvatar } from '@bible-strong/avatar-react'
+import '@bible-strong/avatar-react/styles.css'
+import definition from './avatar.avatar.json'
+
+const Avatar = createAvatar(definition)
+
+export function App() {
+ return
+}`
+
+const webQuickStartExample = (animationKey: string | undefined) =>
+ `import { createAvatar } from '@bible-strong/avatar-web'
+import definition from './avatar.avatar.json'
+
+const avatar = createAvatar('#avatar', {
+ definition,
+ ${animationKey ? `defaultAnimation: '${animationKey}',` : `defaultExpression: 'neutral',`}
+})`
+
export function StudioInspector({ controller }: { controller: StudioController }) {
+ const [runtimePreviewOpen, setRuntimePreviewOpen] = useState(false)
+ const [guideOpen, setGuideOpen] = useState(false)
+ const [exportAnimationsOpen, setExportAnimationsOpen] = useState(false)
const {
activateAvatar,
activeAvatar,
@@ -96,9 +124,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
commitAvatarMove,
commitExpressionMove,
commitStateMove,
+ copyAvatarRuntimeDefinition,
createNewAvatar,
deleteSelectedBodyNode,
downloadAvatarExport,
+ downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
@@ -117,6 +147,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
exportFormat,
expression,
expressionById,
+ expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
@@ -141,6 +172,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
renameActiveAvatar,
renderedColors,
renderedScene,
+ runtimeDefinitionResult,
+ runtimeCopyStatus,
+ runtimeExportErrors,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@@ -151,6 +185,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
selectedSequenceStepId,
selectedState,
sequenceEditing,
+ animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
@@ -192,7 +227,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
- updateAvatarRenderStyle,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,
@@ -208,8 +242,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
updateWireVisibility,
workspaceBackButtonRef,
} = controller
- const pixelRenderStyle =
- activeAvatar.renderStyle.type === 'pixel' ? activeAvatar.renderStyle : null
+ const runtimePreviewAnimation = runtimeDefinitionResult.ok
+ ? runtimeDefinitionResult.value.animationOrder[0]
+ : undefined
const playbackFooterY = useMotionValue(0)
const playbackHandleY = useMotionValue(0)
const playbackHandleCounterY = useTransform(playbackHandleY, value => -value)
@@ -269,7 +304,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
duration: reduceMotion ? 0 : undefined,
})
}
-
return (
setDeleteSequenceOpen(true)}
+ semanticKeyError={animationSemanticKeyError(sequenceEditing.draft)}
/>
)}
@@ -339,6 +374,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
onSave={saveEditing}
onDuplicate={() => duplicateExpression(editing.index, editing.draft, true)}
onDelete={() => setDeleteExpressionOpen(true)}
+ semanticKeyError={expressionSemanticKeyError(editing.draft)}
/>
)}
@@ -802,58 +838,17 @@ export function StudioInspector({ controller }: { controller: StudioController }
-
+
-
- {t('Style')}
- {
- if (!next) return
- const renderStyle: AvatarRenderStyle =
- next === 'pixel'
- ? { ...defaultPixelRenderStyle }
- : { type: 'vector' }
- updateAvatarRenderStyle(renderStyle)
- }}
- >
-
-
-
-
- {t('Vectoriel')}
- {t('Pixel')}
-
-
-
- {pixelRenderStyle && (
-
-
- updateAvatarRenderStyle({
- ...pixelRenderStyle,
- resolution: Math.round(resolution),
- })
- }
- />
-
- )}
+
+ {t('Vectoriel')}
+
{
expressionDragOrigin.current = expressions
@@ -1563,6 +1559,16 @@ export function StudioInspector({ controller }: { controller: StudioController }
renderStyle={activeAvatar.renderStyle}
id={`state-card-${sequence.id}`}
/>
+ {animationSemanticKeyError(sequence) && (
+
+ !
+
+ )}
{sequence.builtIn ? t(sequence.name) : sequence.name}
)
@@ -1630,11 +1636,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
)}
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
-
+
@@ -1654,7 +1660,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
-
+
React / TypeScript
- {t('Package React local (.zip)')}
+ {t('JSON runtime + createAvatar')}
- {t('Module JavaScript')}
- {t('Projet HTML + module JS (.zip)')}
+ {t('JavaScript / ESM')}
+ {t('JSON runtime + avatar-web')}
-
+
@@ -1699,65 +1705,226 @@ export function StudioInspector({ controller }: { controller: StudioController }
variant="ghost"
size="sm"
type="button"
- onClick={() =>
- setExportAnimationIds(
- selectedExportAnimations.length === sequences.length
- ? []
- : sequences.map(animation => animation.id)
- )
- }
+ aria-expanded={exportAnimationsOpen}
+ onClick={() => setExportAnimationsOpen(open => !open)}
>
- {t(
- selectedExportAnimations.length === sequences.length
- ? 'Tout désélectionner'
- : 'Tout sélectionner'
- )}
+ {exportAnimationsOpen ? : }
+ {t(exportAnimationsOpen ? 'Masquer la sélection' : 'Personnaliser')}
-
- {sequences.map(animation => {
- const firstStep = animation.steps[0]
- const firstExpression = firstStep
- ? expressionById.get(firstStep.expressionId)
- : undefined
- return (
+ {exportAnimationsOpen && (
+
+
+ setExportAnimationIds(
+ selectedExportAnimations.length === sequences.length
+ ? []
+ : sequences.map(animation => animation.id)
+ )
+ }
+ >
+ {t(
+ selectedExportAnimations.length === sequences.length
+ ? 'Tout désélectionner'
+ : 'Tout sélectionner'
+ )}
+
+
+ {sequences.map(animation => {
+ const firstStep = animation.steps[0]
+ const firstExpression = firstStep
+ ? expressionById.get(firstStep.expressionId)
+ : undefined
+ return (
+ toggleExportAnimation(animation.id)}
+ >
+
+
+ {animation.builtIn ? t(animation.name) : animation.name}
+
+
+ )
+ })}
+
+
+ )}
+
+
+ {runtimeExportErrors.length > 0 && (
+
+
+
+
+ {t('Export runtime incomplet')}
+
+
+ {runtimeExportErrors.map((error, index) => (
+ {error}
+ ))}
+
+
+ {t(
+ 'Corrige les clés signalées dans les éditeurs Expressions ou Animations.'
+ )}
+
+
toggleExportAnimation(animation.id)}
+ variant="outline"
+ onClick={() => setMode('expressions')}
>
-
- {animation.builtIn ? t(animation.name) : animation.name}
+ {t('Expressions')}
- )
- })}
+ setMode('states')}>
+ {t('Animations')}
+
+
+
+
+ )}
+
+
+
+
+
{t('Démarrage rapide')}
+
{t('Utiliser cet avatar')}
+
+
setGuideOpen(true)}>
+ {t('Voir le guide complet')}
+
+
+
+
+
+ {t('Installation')}
+
+ {exportFormat === 'react' ? reactQuickStartInstall : webQuickStartInstall}
+
+
+
+
+
{t('Utilisation minimale')}
+
+
+ {exportFormat === 'react'
+ ? reactQuickStartExample(runtimePreviewAnimation)
+ : webQuickStartExample(runtimePreviewAnimation)}
+
+
-
-
- {t(
- exportFormat === 'react'
- ? 'Télécharger le package React'
- : 'Télécharger le module'
- )}
-
+
+
+
+ {t('Prêt à exporter')}
+
+ {selectedExportAnimations.length} {t('animations')} ·{' '}
+ {runtimeDefinitionResult.ok
+ ? runtimeDefinitionResult.value.expressionOrder.length
+ : 0}{' '}
+ {t('expressions')}
+
+
+
+ {t(
+ exportFormat === 'javascript'
+ ? 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.'
+ : 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.'
+ )}
+
+
+
+
+
+
+ {t('Télécharger la définition .avatar.json')}
+
+
+
+ {t(
+ exportFormat === 'javascript'
+ ? 'Télécharger la démo ESM (.zip)'
+ : 'Télécharger la démo React (.zip)'
+ )}
+
+
+
setRuntimePreviewOpen(true)}
+ >
+
+ {t('Preview')}
+
+
void copyAvatarRuntimeDefinition()}
+ >
+
+ {t('Copier le JSON')}
+
+
+
+
+ {runtimeCopyStatus !== 'idle' && (
+
+ {t(
+ runtimeCopyStatus === 'success'
+ ? 'JSON runtime copié dans le presse-papiers.'
+ : 'Impossible de copier le JSON runtime.'
+ )}
+
+ )}
+
+
{
}
}
+export const clearPersistedStudioDocument = (
+ storage: Pick = window.localStorage
+) => {
+ try {
+ storage.removeItem(DOCUMENT_STORAGE_KEY)
+ return true
+ } catch {
+ return false
+ }
+}
+
export const createStudioDocumentStore = (
initial: StudioDocument,
persist: (document: StudioDocument) => void = persistStudioDocument
diff --git a/src/features/studio/useStudioController.ts b/src/features/studio/useStudioController.ts
index 352ac67..43fd24f 100644
--- a/src/features/studio/useStudioController.ts
+++ b/src/features/studio/useStudioController.ts
@@ -45,15 +45,21 @@ import {
applyAmbientBodyMotion,
hasAmbientMotion,
} from '@/features/avatar/ambientMotion'
+import {
+ avatarDefinitionFileName,
+ createAvatarDefinition,
+ getSemanticKeyIssue,
+ type SemanticKeyIssueCode,
+} from '@/features/avatar/avatarDefinition'
import {
cloneAvatarBehavior,
createAvatar,
+ createUnkeyedExpressionCopy,
defaultAvatarEyes,
resolveAvatarBehavior,
type AvatarBehaviorLibrary,
type AvatarColors,
type AvatarEyeDefaults,
- type AvatarRenderStyle,
type StudioAvatar,
} from '@/features/avatar/avatars'
import {
@@ -78,10 +84,9 @@ import {
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
import {
- avatarExportFileName,
- createAvatarExportPayload,
- generateJavaScriptAvatarPackage,
- generateReactAvatarPackage,
+ avatarDemoFileName,
+ generateJavaScriptEsmPackage,
+ generateReactVitePackage,
} from '@/features/export/exporter'
import {
serializeAvatarSnapshot,
@@ -147,6 +152,10 @@ export function useStudioController() {
const [snapshotSize, setSnapshotSize] = useState('1024')
const [snapshotFormat, setSnapshotFormat] = useState('png')
const [photoFlash, setPhotoFlash] = useState(0)
+ const [runtimeCopyFeedback, setRuntimeCopyFeedback] = useState<{
+ status: 'idle' | 'success' | 'error'
+ source?: readonly unknown[]
+ }>({ status: 'idle' })
const initialStatePlayback = initialDocument.playback
const updateStudioLibrary = (library: typeof initialDocument.library) =>
documentStore.update({ library })
@@ -748,10 +757,6 @@ export function useStudioController() {
setDisplayColors(resolveColors(expression, colors))
}
- const updateAvatarRenderStyle = (renderStyle: AvatarRenderStyle) => {
- updateActiveAvatar(avatar => ({ ...avatar, renderStyle }))
- }
-
const updateAvatarEyes = (changes: Partial) => {
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
if (!avatar) return
@@ -1192,7 +1197,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 +1286,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))
@@ -1470,6 +1476,12 @@ export function useStudioController() {
})
}
const activeAvatar = avatars.find(avatar => avatar.id === activeAvatarId) ?? avatars[0]
+ const runtimeCopySource = [activeAvatar, exportAnimationIds, expressions, sequences] as const
+ const runtimeCopyStatus =
+ runtimeCopyFeedback.source?.length === runtimeCopySource.length &&
+ runtimeCopyFeedback.source.every((value, index) => value === runtimeCopySource[index])
+ ? runtimeCopyFeedback.status
+ : 'idle'
const activeAvatarEyes = activeAvatar.eyes ?? defaultAvatarEyes
const activeSequence = sequences.find(sequence => sequence.id === activeState) ?? null
const activeSequenceLabel = activeSequence
@@ -1478,10 +1490,81 @@ 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 exportAnimationIdSet = new Set(exportAnimationIds)
const selectedExportAnimations = sequences.filter(animation =>
exportAnimationIdSet.has(animation.id)
)
+ const runtimeDefinitionResult = createAvatarDefinition({
+ avatar: activeAvatar,
+ behavior: { expressions, sequences: selectedExportAnimations },
+ })
+ const runtimeExportErrors = runtimeDefinitionResult.ok
+ ? []
+ : (() => {
+ const messages = new Set()
+ const hasExpressionErrors = runtimeDefinitionResult.errors.some(error =>
+ error.path.startsWith('/studio/expressions/')
+ )
+ runtimeDefinitionResult.errors.forEach(error => {
+ if (error.code === 'unresolved_expression_reference' && hasExpressionErrors) return
+ const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
+ if (expressionMatch) {
+ const index = Number(expressionMatch[1])
+ const item = expressions[index]
+ messages.add(
+ `${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
+ )
+ return
+ }
+ const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
+ if (animationMatch) {
+ const index = Number(animationMatch[1])
+ const item = selectedExportAnimations[index]
+ messages.add(
+ error.code === 'unresolved_expression_reference'
+ ? `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
+ : `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
+ )
+ return
+ }
+ messages.add(`${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`)
+ })
+ return [...messages]
+ })()
const toggleExportAnimation = (animationId: string) => {
setExportAnimationIds(current =>
current.includes(animationId)
@@ -1490,14 +1573,35 @@ export function useStudioController() {
)
}
const downloadAvatarExport = () => {
- if (!selectedExportAnimations.length) return
- const payload = createAvatarExportPayload(activeAvatar, expressions, selectedExportAnimations)
- const isReact = exportFormat === 'react'
- const extension = 'zip'
- const blob = isReact
- ? generateReactAvatarPackage(payload)
- : generateJavaScriptAvatarPackage(payload, language)
- downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
+ if (!runtimeDefinitionResult.ok) return
+ downloadBlob(
+ exportFormat === 'javascript'
+ ? generateJavaScriptEsmPackage(runtimeDefinitionResult.value, activeAvatar.name)
+ : generateReactVitePackage(runtimeDefinitionResult.value, activeAvatar.name),
+ avatarDemoFileName(activeAvatar.name, exportFormat)
+ )
+ }
+ 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
+ if (!navigator.clipboard) {
+ setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource })
+ return
+ }
+ try {
+ await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
+ setRuntimeCopyFeedback({ status: 'success', source: runtimeCopySource })
+ } catch {
+ setRuntimeCopyFeedback({ status: 'error', source: runtimeCopySource })
+ }
}
const currentStudioDocument = (): StudioDocument => ({
version: 2,
@@ -1773,6 +1877,7 @@ export function useStudioController() {
commitExpressionMove,
commitStateMove,
confirmStudioProjectImport,
+ copyAvatarRuntimeDefinition,
createNewAvatar,
deleteActiveAvatar,
deleteAvatarOpen,
@@ -1782,6 +1887,7 @@ export function useStudioController() {
deleteSequenceEditing,
deleteSequenceOpen,
downloadAvatarExport,
+ downloadAvatarRuntimeDefinition,
downloadStudioProject,
draggedAvatarId,
draggedExpressionId,
@@ -1800,6 +1906,7 @@ export function useStudioController() {
exportFormat,
expression,
expressionById,
+ expressionSemanticKeyError,
expressionDragOrigin,
expressionDragPreview,
expressions,
@@ -1832,6 +1939,9 @@ export function useStudioController() {
renderedColors,
renderedRotationGizmo,
renderedScene,
+ runtimeDefinitionResult,
+ runtimeCopyStatus,
+ runtimeExportErrors,
saveAvatarEditing,
saveEditing,
saveSequenceEditing,
@@ -1843,6 +1953,7 @@ export function useStudioController() {
selectedSequenceStepId,
selectedState,
sequenceEditing,
+ animationSemanticKeyError,
sequences,
setDeleteAvatarOpen,
setDeleteExpressionOpen,
@@ -1888,7 +1999,6 @@ export function useStudioController() {
toggleStatePlayback,
transitionToExpression,
updateAvatarColors,
- updateAvatarRenderStyle,
updateAvatarEyeDimension,
updateAvatarEyePosition,
updateAvatarEyeSize,
diff --git a/src/i18n/__tests__/i18n-test.ts b/src/i18n/__tests__/i18n-test.ts
index e64a8c3..b4b34a5 100644
--- a/src/i18n/__tests__/i18n-test.ts
+++ b/src/i18n/__tests__/i18n-test.ts
@@ -31,6 +31,110 @@ 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('JSON runtime + createAvatar', 'en')).toBe(
+ 'Runtime JSON + createAvatar'
+ )
+ expect(translateStudioText('JSON runtime + createAvatar', 'zh-CN')).toBe(
+ '运行时 JSON + createAvatar'
+ )
+ expect(translateStudioText('JavaScript / ESM', 'en')).toBe('JavaScript / ESM')
+ expect(translateStudioText('JSON runtime + avatar-web', 'zh-CN')).toBe(
+ '运行时 JSON + avatar-web'
+ )
+ expect(translateStudioText('Guide d’utilisation de l’avatar JavaScript', 'en')).toBe(
+ 'JavaScript avatar usage guide'
+ )
+ expect(translateStudioText('Preview de la définition exportée', 'en')).toBe(
+ 'Exported definition preview'
+ )
+ expect(translateStudioText('Animations exportées', 'zh-CN')).toBe('导出的动画')
+ expect(
+ translateStudioText(
+ 'Choisis les animations puis exporte le JSON runtime ou un package autonome.',
+ 'en'
+ )
+ ).toBe('Choose animations, then export runtime JSON or a standalone package.')
+ expect(
+ translateStudioText(
+ 'Choisis les animations puis exporte le JSON runtime ou un package autonome.',
+ 'zh-CN'
+ )
+ ).toBe('选择动画,然后导出运行时 JSON 或独立包。')
+ expect(translateStudioText('Nouveau', 'en')).toBe('New')
+ expect(translateStudioText('Nouveau', 'zh-CN')).toBe('新增')
+ expect(translateStudioText('Lancer l’exemple', 'en')).toBe('Run example')
+ expect(translateStudioText('Lancer l’exemple', 'zh-CN')).toBe('运行示例')
+ expect(translateStudioText('Guide d’utilisation', 'en')).toBe('Usage guide')
+ expect(translateStudioText('Guide d’utilisation', 'zh-CN')).toBe('使用指南')
+ expect(translateStudioText('Le rendu Pixel est temporairement désactivé.', 'en')).toBe(
+ 'Pixel rendering is temporarily disabled.'
+ )
+ expect(translateStudioText('Le rendu Pixel est temporairement désactivé.', 'zh-CN')).toBe(
+ '像素渲染暂时已禁用。'
+ )
+ expect(translateStudioText('Props de l’avatar', 'en')).toBe('Avatar props')
+ expect(translateStudioText('Props de l’avatar', 'zh-CN')).toBe('头像 Props')
+ expect(translateStudioText('Cible et lecture', 'en')).toBe('Target and playback')
+ expect(translateStudioText('Présentation', 'zh-CN')).toBe('外观')
+ expect(translateStudioText('API impérative', 'en')).toBe('Imperative API')
+ expect(translateStudioText('API impérative', 'zh-CN')).toBe('命令式 API')
+ expect(
+ translateStudioText('Retourne l’animation, l’expression et le statut actifs.', 'en')
+ ).toBe('Returns the active animation, expression and status.')
+ expect(
+ translateStudioText(
+ 'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.',
+ 'en'
+ )
+ ).toBe(
+ 'Use Avatar directly when the definition is loaded at runtime or changes between multiple avatars.'
+ )
+ expect(
+ translateStudioText(
+ 'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.',
+ 'en'
+ )
+ ).toBe(
+ 'createAvatar validates the JSON and returns a dedicated component with typed animation keys.'
+ )
+ expect(
+ 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('Personnaliser', 'en')).toBe('Customize')
+ expect(translateStudioText('Personnaliser', 'zh-CN')).toBe('自定义')
+ expect(translateStudioText('Masquer la sélection', 'en')).toBe('Hide selection')
+ expect(translateStudioText('Masquer la sélection', 'zh-CN')).toBe('隐藏选择')
+ expect(translateStudioText('Expression de départ', 'en')).toBe('Starting expression')
+ expect(translateStudioText('Expression de départ', 'zh-CN')).toBe('起始表情')
+ expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
+ expect(translateStudioText('Copier le JSON formaté', '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.'
diff --git a/src/i18n/index.ts b/src/i18n/index.ts
index b51513c..0d4c936 100644
--- a/src/i18n/index.ts
+++ b/src/i18n/index.ts
@@ -61,6 +61,23 @@ const english: Record = {
'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',
@@ -68,6 +85,8 @@ const english: Record = {
'Choisis la finition visuelle propre à cet avatar.':
'Choose the visual finish specific to this avatar.',
'Type de rendu': 'Rendering type',
+ 'Le rendu Pixel est temporairement désactivé.': 'Pixel rendering is temporarily disabled.',
+ 'Le mode Vectoriel est utilisé pour l’instant.': 'Vector rendering is currently used.',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'Pixel uses a hard palette, with no smoothing or intermediate colors.',
Vectoriel: 'Vector',
@@ -108,24 +127,196 @@ const english: Record = {
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.',
+ 'JSON runtime + createAvatar': 'Runtime JSON + createAvatar',
+ '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',
+ 'Export runtime incomplet': 'Runtime export is incomplete',
+ 'Corrige les clés signalées dans les éditeurs Expressions ou Animations.':
+ 'Fix the highlighted keys in the Expressions or Animations editors.',
+ 'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
+ 'Copier le JSON formaté': 'Copy formatted JSON',
+ 'Copier le JSON': 'Copy 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.',
+ Installation: 'Installation',
+ 'Lancer l’exemple': 'Run example',
+ 'Masquer l’aperçu': 'Hide preview',
+ 'Aperçu avec le package React': 'Preview using the React package',
+ Preview: 'Preview',
+ 'Définition prête à tester': 'Definition ready to test',
+ 'Démarrage rapide': 'Quick start',
+ 'Preview de la définition exportée': 'Exported definition preview',
+ 'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
+ 'Test the animations and expressions actually included in the .avatar.json file.',
+ 'Fermer la preview': 'Close preview',
+ 'Définition exportée': 'Exported definition',
+ 'Animation active': 'Active animation',
+ 'Expression active': 'Active expression',
+ 'Contrôles de lecture': 'Playback controls',
+ 'Animations exportées': 'Exported animations',
+ 'Expressions exportées': 'Exported expressions',
+ 'Clique pour lancer': 'Click to play',
+ 'Clique pour afficher': 'Click to display',
+ 'Aucune animation exportée': 'No exported animation',
+ 'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
+ 'Animation de départ': 'Starting animation',
+ 'Expression de départ': 'Starting expression',
+ 'Aucune animation sélectionnée': 'No animation selected',
+ 'Guide d’utilisation': 'Usage guide',
+ 'Voir le guide complet': 'View full usage guide',
+ 'Guide d’utilisation de l’avatar React': 'React avatar usage guide',
+ 'Guide d’utilisation de l’avatar JavaScript': 'JavaScript avatar usage guide',
+ 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
+ 'Install the package, create your component and choose the right control level.',
+ 'Fermer le guide': 'Close guide',
+ 'Ajoute le package React et ses dépendances.': 'Add the React package and its dependencies.',
+ 'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.':
+ 'Install the ESM module, load the JSON definition and mount the avatar in a DOM element.',
+ 'Ajoute le renderer DOM, qui utilise automatiquement avatar-core.':
+ 'Add the DOM renderer, which automatically uses avatar-core.',
+ 'Utilisation avec un bundler ESM': 'Using an ESM bundler',
+ 'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.':
+ 'Vite and modern bundlers resolve the package and import the same .avatar.json file as React.',
+ 'Options de createAvatar': 'createAvatar options',
+ 'Référence des valeurs acceptées lors du montage dans le DOM.':
+ 'Reference for values accepted when mounting into the DOM.',
+ 'Obligatoire. Définition JSON validée avant la création des éléments SVG.':
+ 'Required. JSON definition validated before the SVG elements are created.',
+ 'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.':
+ 'Optional. Animation started on mount when autoplay is true. Mutually exclusive with defaultExpression.',
+ 'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
+ 'Optional. Initial expression displayed without starting a timeline. Mutually exclusive with defaultAnimation.',
+ 'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.':
+ 'Optional, defaults to true. Only controls whether defaultAnimation starts automatically.',
+ 'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.':
+ 'Optional, defaults to 240. CSS width and height of the rendered container.',
+ 'Optionnelle. Classe CSS ajoutée au conteneur rendu.':
+ 'Optional. CSS class added to the rendered container.',
+ 'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.':
+ 'Optional, defaults to “Procedural avatar”. Accessible name for the rendered avatar.',
+ 'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.':
+ 'Optional. Receives unknown-key errors encountered during initialization.',
+ 'Optionnelle. Appelée lorsqu’une animation once se termine.':
+ 'Optional. Called when a once animation completes.',
+ 'Optionnelle. Appelée lorsque l’expression active change.':
+ 'Optional. Called when the active expression changes.',
+ 'API du contrôleur DOM': 'DOM controller API',
+ 'createAvatar retourne immédiatement ces commandes impératives.':
+ 'createAvatar immediately returns these imperative commands.',
+ 'Lance ou reprend une animation par sa clé.': 'Starts or resumes an animation by key.',
+ 'Affiche une expression avec une transition courte.':
+ 'Displays an expression with a short transition.',
+ 'Arrête la lecture et revient à neutral.': 'Stops playback and returns to neutral.',
+ 'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.':
+ 'Cancels the scheduled frame and removes only the container created by avatar-web.',
+ 'Navigateur sans bundler': 'Browser without a bundler',
+ 'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.':
+ 'Use an ESM URL through a CDN or import map, then load the definition with fetch.',
+ 'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.':
+ 'The packages are still private. This command will work after publication; use the workspace or tarballs for local testing.',
+ 'API recommandée : créer un avatar concret': 'Recommended API: create a concrete avatar',
+ 'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.':
+ 'createAvatar validates the JSON and returns a dedicated component with typed animation keys.',
+ 'Props de l’avatar': 'Avatar props',
+ 'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.':
+ 'Complete reference: type, default value, behavior and constraints for every prop.',
+ 'Cible et lecture': 'Target and playback',
+ 'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.':
+ 'Required. Validated AvatarDefinition object containing the expressions and animations to display.',
+ 'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.':
+ 'Optional. Controls a timeline by key. Each step chooses the displayed expression. Mutually exclusive with expression; a controlled target takes priority over default values.',
+ 'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.':
+ 'Optional. Directly controls an expression by key. Mutually exclusive with animation; a controlled target takes priority over default values.',
+ 'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.':
+ 'Optional. Defines the initial timeline in uncontrolled mode. Read on mount; autoplay is enabled by default. Mutually exclusive with defaultExpression.',
+ 'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
+ 'Optional. Defines the initial expression in uncontrolled mode. Read on mount without starting a timeline. Mutually exclusive with defaultAnimation.',
+ 'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.':
+ 'Optional, defaults to true. Automatically starts defaultAnimation; without defaultAnimation, it has no effect.',
+ 'Optionnelle. Donne accès à l’API impérative AvatarController.':
+ 'Optional. Provides access to the imperative AvatarController API.',
+ Présentation: 'Presentation',
+ 'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.':
+ 'Optional, defaults to 240. Number or CSS value used for the container width and height.',
+ 'Optionnelle. Classe CSS ajoutée au conteneur externe.':
+ 'Optional. CSS class added to the outer container.',
+ 'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.':
+ 'Optional. Inline styles for the outer container; width and height come from size.',
+ 'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.':
+ 'Optional, defaults to “Procedural avatar”. Accessible name announced to screen readers.',
+ 'Callbacks de lecture': 'Playback callbacks',
+ 'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.':
+ 'Optional. Receives the key of a once animation when it completes naturally.',
+ 'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.':
+ 'Optional. Receives the expression key whenever the displayed semantic expression changes.',
+ 'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.':
+ 'Optional. Receives a typed error when an animation, expression or default prop references an unknown key.',
+ 'Avatar générique': 'Generic Avatar',
+ 'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.':
+ 'Use Avatar directly when the definition is loaded at runtime or changes between multiple avatars.',
+ 'API impérative': 'Imperative API',
+ 'La ref expose les commandes de lecture et l’état courant de l’avatar.':
+ 'The ref exposes playback commands and the avatar’s current state.',
+ 'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.':
+ 'Target commands are available in uncontrolled mode; otherwise use props.',
+ 'Lance ou reprend une animation et retourne un résultat typé.':
+ 'Starts or resumes an animation and returns a typed result.',
+ 'Met en pause la timeline à sa position exacte.': 'Pauses the timeline at its exact position.',
+ 'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.':
+ 'In uncontrolled mode, stops playback and returns to neutral. In controlled mode, props remain the source of truth.',
+ 'Affiche directement une expression.': 'Directly displays an expression.',
+ 'Retourne l’animation, l’expression et le statut actifs.':
+ 'Returns the active animation, expression and status.',
+ Expression: 'Expression',
+ 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.',
+ 'Choisis les animations puis exporte le JSON runtime ou un package autonome.':
+ 'Choose animations, then export runtime JSON or a standalone package.',
+ 'Choisis les animations puis utilise la même définition JSON avec React ou JavaScript.':
+ 'Choose animations, then use the same JSON definition with React or JavaScript.',
'Avatar sélectionné': 'Selected avatar',
Format: 'Format',
'Choisis l’intégration correspondant à ton projet.':
'Choose the integration that matches your project.',
'Composant TSX autonome': 'Standalone TSX component',
'Package React local (.zip)': 'Local React package (.zip)',
- 'Module JavaScript': 'JavaScript module',
+ 'JavaScript / ESM': 'JavaScript / ESM',
+ 'JSON runtime + avatar-web': 'Runtime JSON + avatar-web',
'Module ES autonome': 'Standalone ES module',
'Projet HTML + module JS (.zip)': 'HTML project + JS module (.zip)',
sélectionnées: 'selected',
'Animations à exporter': 'Animations to export',
'Tout sélectionner': 'Select all',
'Tout désélectionner': 'Deselect all',
+ Personnaliser: 'Customize',
+ 'Masquer la sélection': 'Hide selection',
'Télécharger le composant TSX': 'Download TSX component',
'Télécharger le package React': 'Download React package',
'Télécharger le module': 'Download module',
+ 'Intégration ESM avec le package avatar-web': 'ESM integration with the avatar-web package',
+ 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
+ 'The ZIP contains the exported JSON, an index.html demo and its README. The demo loads avatar-web from a CDN.',
+ 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.':
+ 'The ZIP contains the exported JSON and a ready-to-run Vite React TypeScript project. Start it with npm install, then npm run dev.',
+ 'Télécharger l’intégration ESM (.zip)': 'Download ESM integration (.zip)',
+ 'Télécharger la démo React (.zip)': 'Download React demo (.zip)',
+ 'Télécharger la démo ESM (.zip)': 'Download ESM demo (.zip)',
+ 'Télécharger le JSON': 'Download JSON',
+ 'Utilisation minimale': 'Minimal usage',
+ 'Utiliser cet avatar': 'Use this avatar',
+ 'Prêt à exporter': 'Ready to export',
Snapshot: 'Snapshot',
'Mode photo': 'Photo Mode',
'Capture une image statique de l’avatar.': 'Capture a static image of the avatar.',
diff --git a/src/i18n/zh.ts b/src/i18n/zh.ts
index f5ddb9f..9997d02 100644
--- a/src/i18n/zh.ts
+++ b/src/i18n/zh.ts
@@ -34,11 +34,28 @@ export const chinese: Record = {
'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: '渲染',
'Choisis la finition visuelle propre à cet avatar.': '选择此头像专属的视觉效果。',
'Type de rendu': '渲染类型',
+ 'Le rendu Pixel est temporairement désactivé.': '像素渲染暂时已禁用。',
+ 'Le mode Vectoriel est utilisé pour l’instant.': '当前使用矢量渲染。',
'Pixel utilise une palette franche, sans lissage ni couleur intermédiaire.':
'像素渲染使用纯色调色板,不进行平滑处理,也不产生中间色。',
Vectoriel: '矢量',
@@ -80,23 +97,187 @@ export const chinese: Record = {
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 文件。',
+ 'JSON runtime + createAvatar': '运行时 JSON + createAvatar',
+ 'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
+ 'Définition runtime': '运行时定义',
+ 'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
+ 'Export runtime incomplet': '运行时导出不完整',
+ 'Corrige les clés signalées dans les éditeurs Expressions ou Animations.':
+ '请在“表情”或“动画”编辑器中修正标记的键。',
+ 'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
+ 'Copier le JSON formaté': '复制格式化的 JSON',
+ 'Copier le JSON': '复制 JSON',
+ 'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
+ 'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
+ Installation: '安装',
+ 'Lancer l’exemple': '运行示例',
+ 'Masquer l’aperçu': '隐藏预览',
+ 'Aperçu avec le package React': '使用 React 包预览',
+ Preview: '预览',
+ 'Définition prête à tester': '定义已可测试',
+ 'Démarrage rapide': '快速开始',
+ 'Preview de la définition exportée': '导出定义预览',
+ 'Teste les animations et expressions réellement présentes dans le fichier .avatar.json.':
+ '测试 .avatar.json 文件中实际包含的动画和表情。',
+ 'Fermer la preview': '关闭预览',
+ 'Définition exportée': '导出的定义',
+ 'Animation active': '当前动画',
+ 'Expression active': '当前表情',
+ 'Contrôles de lecture': '播放控制',
+ 'Animations exportées': '导出的动画',
+ 'Expressions exportées': '导出的表情',
+ 'Clique pour lancer': '点击播放',
+ 'Clique pour afficher': '点击显示',
+ 'Aucune animation exportée': '没有导出的动画',
+ 'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
+ 'Animation de départ': '起始动画',
+ 'Expression de départ': '起始表情',
+ 'Aucune animation sélectionnée': '未选择动画',
+ 'Guide d’utilisation': '使用指南',
+ 'Voir le guide complet': '查看完整使用指南',
+ 'Guide d’utilisation de l’avatar React': 'React 头像使用指南',
+ 'Guide d’utilisation de l’avatar JavaScript': 'JavaScript 头像使用指南',
+ 'Installe le package, crée ton composant et choisis le niveau de contrôle adapté.':
+ '安装包,创建组件,并选择合适的控制方式。',
+ 'Fermer le guide': '关闭指南',
+ 'Ajoute le package React et ses dépendances.': '添加 React 包及其依赖项。',
+ 'Installe le module ESM, charge la définition JSON et monte l’avatar dans un élément du DOM.':
+ '安装 ESM 模块,加载 JSON 定义,并将头像挂载到 DOM 元素中。',
+ 'Ajoute le renderer DOM, qui utilise automatiquement avatar-core.':
+ '添加 DOM 渲染器,它会自动使用 avatar-core。',
+ 'Utilisation avec un bundler ESM': '使用 ESM 打包工具',
+ 'Vite et les bundlers modernes résolvent le package et importent le même fichier .avatar.json que React.':
+ 'Vite 和现代打包工具会解析该包,并导入与 React 相同的 .avatar.json 文件。',
+ 'Options de createAvatar': 'createAvatar 选项',
+ 'Référence des valeurs acceptées lors du montage dans le DOM.': '挂载到 DOM 时可用值的参考。',
+ 'Obligatoire. Définition JSON validée avant la création des éléments SVG.':
+ '必填。创建 SVG 元素之前会验证 JSON 定义。',
+ 'Optionnelle. Animation lancée au montage lorsque autoplay vaut true. Mutuellement exclusive avec defaultExpression.':
+ '可选。当 autoplay 为 true 时在挂载时启动动画。不能与 defaultExpression 同时使用。',
+ 'Optionnelle. Expression initiale affichée sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
+ '可选。显示初始表情但不启动时间线。不能与 defaultAnimation 同时使用。',
+ 'Optionnelle, défaut true. Contrôle uniquement le lancement automatique de defaultAnimation.':
+ '可选,默认 true。仅控制 defaultAnimation 是否自动启动。',
+ 'Optionnelle, défaut 240. Largeur et hauteur CSS du conteneur rendu.':
+ '可选,默认 240。渲染容器的 CSS 宽度和高度。',
+ 'Optionnelle. Classe CSS ajoutée au conteneur rendu.': '可选。添加到渲染容器的 CSS 类。',
+ 'Optionnelle, défaut « Procedural avatar ». Nom accessible du rendu.':
+ '可选,默认 “Procedural avatar”。渲染头像的可访问名称。',
+ 'Optionnelle. Reçoit les erreurs de clé inconnue utilisées lors de l’initialisation.':
+ '可选。接收初始化期间遇到的未知键错误。',
+ 'Optionnelle. Appelée lorsqu’une animation once se termine.': '可选。在 once 动画结束时调用。',
+ 'Optionnelle. Appelée lorsque l’expression active change.': '可选。在活动表情变化时调用。',
+ 'API du contrôleur DOM': 'DOM 控制器 API',
+ 'createAvatar retourne immédiatement ces commandes impératives.':
+ 'createAvatar 会立即返回这些命令式控制方法。',
+ 'Lance ou reprend une animation par sa clé.': '通过键启动或恢复动画。',
+ 'Affiche une expression avec une transition courte.': '使用短暂过渡显示表情。',
+ 'Arrête la lecture et revient à neutral.': '停止播放并返回 neutral。',
+ 'Annule la frame planifiée et retire uniquement le conteneur créé par avatar-web.':
+ '取消已计划的帧,并仅移除 avatar-web 创建的容器。',
+ 'Navigateur sans bundler': '不使用打包工具的浏览器',
+ 'Utilise une URL ESM via un CDN ou une import map, puis charge la définition avec fetch.':
+ '通过 CDN 或 import map 使用 ESM URL,然后通过 fetch 加载定义。',
+ 'Les packages sont encore privés. Cette commande fonctionnera après leur publication ; utilise le workspace ou les tarballs pour les tests locaux.':
+ '这些包目前仍为私有。发布后此命令才可使用;本地测试请使用 workspace 或 tarball。',
+ 'API recommandée : créer un avatar concret': '推荐 API:创建具体头像',
+ 'createAvatar valide le JSON et retourne un composant dédié dont les clés d’animations sont typées.':
+ 'createAvatar 会验证 JSON,并返回一个动画键经过类型约束的专用组件。',
+ 'Props de l’avatar': '头像 Props',
+ 'Référence complète : type, valeur par défaut, comportement et contraintes de chaque prop.':
+ '完整参考:每个 prop 的类型、默认值、行为和限制。',
+ 'Cible et lecture': '目标与播放',
+ 'Obligatoire. Objet AvatarDefinition validé contenant les expressions et les animations à afficher.':
+ '必填。经过验证的 AvatarDefinition 对象,包含要显示的表情和动画。',
+ 'Optionnelle. Contrôle une timeline par sa clé. Chaque étape choisit l’expression affichée. Mutuellement exclusive avec expression ; une cible contrôlée prend priorité sur les valeurs default.':
+ '可选。通过键控制时间线,每个步骤会选择要显示的表情。不能与 expression 同时使用;受控目标优先于默认值。',
+ 'Optionnelle. Contrôle directement une expression par sa clé. Mutuellement exclusive avec animation ; une cible contrôlée prend priorité sur les valeurs default.':
+ '可选。通过键直接控制一个表情。不能与 animation 同时使用;受控目标优先于默认值。',
+ 'Optionnelle. Définit la timeline initiale en mode non contrôlé. Lue au montage ; autoplay est activé par défaut. Mutuellement exclusive avec defaultExpression.':
+ '可选。定义非受控模式下的初始时间线;挂载时读取,默认启用 autoplay。不能与 defaultExpression 同时使用。',
+ 'Optionnelle. Définit l’expression initiale en mode non contrôlé. Lue au montage, sans lancer de timeline. Mutuellement exclusive avec defaultAnimation.':
+ '可选。定义非受控模式下的初始表情;挂载时读取,不会启动时间线。不能与 defaultAnimation 同时使用。',
+ 'Optionnelle, défaut true. Lance automatiquement defaultAnimation ; sans defaultAnimation, elle n’a aucun effet.':
+ '可选,默认 true。自动启动 defaultAnimation;没有 defaultAnimation 时无效。',
+ 'Optionnelle. Donne accès à l’API impérative AvatarController.':
+ '可选。提供对命令式 AvatarController API 的访问。',
+ Présentation: '外观',
+ 'Optionnelle, défaut 240. Nombre ou valeur CSS utilisée pour la largeur et la hauteur du conteneur.':
+ '可选,默认 240。用于容器宽高的数字或 CSS 值。',
+ 'Optionnelle. Classe CSS ajoutée au conteneur externe.': '可选。添加到外部容器的 CSS 类。',
+ 'Optionnelle. Styles inline du conteneur externe ; width et height viennent de size.':
+ '可选。外部容器的内联样式;width 和 height 来自 size。',
+ 'Optionnelle, défaut « Procedural avatar ». Nom accessible annoncé aux lecteurs d’écran.':
+ '可选,默认 “Procedural avatar”。屏幕阅读器播报的可访问名称。',
+ 'Callbacks de lecture': '播放回调',
+ 'Optionnelle. Reçoit la clé de l’animation once terminée naturellement.':
+ '可选。在 once 动画自然完成时接收其键。',
+ 'Optionnelle. Reçoit la clé de l’expression chaque fois que l’expression sémantique affichée change.':
+ '可选。每当显示的语义表情变化时接收表情键。',
+ 'Optionnelle. Reçoit une erreur typée lorsqu’une prop animation, expression ou default référence une clé inconnue.':
+ '可选。当 animation、expression 或 default prop 引用了未知键时,接收一个类型化错误。',
+ 'Avatar générique': '通用 Avatar',
+ 'Utilise Avatar directement lorsque la définition est chargée à l’exécution ou change entre plusieurs avatars.':
+ '当定义在运行时加载或需要在多个头像之间切换时,直接使用 Avatar。',
+ 'API impérative': '命令式 API',
+ 'La ref expose les commandes de lecture et l’état courant de l’avatar.':
+ 'ref 提供播放命令和头像当前状态。',
+ 'Les commandes de cible sont disponibles en mode non contrôlé ; sinon utilise les props.':
+ '目标命令仅在非受控模式下可用;否则请使用 props。',
+ 'Lance ou reprend une animation et retourne un résultat typé.':
+ '启动或恢复动画,并返回类型化结果。',
+ 'Met en pause la timeline à sa position exacte.': '在精确位置暂停时间线。',
+ 'En mode non contrôlé, arrête la lecture et revient à neutral. En mode contrôlé, les props restent la source de vérité.':
+ '在非受控模式下停止播放并回到 neutral;在受控模式下,props 仍是真实来源。',
+ 'Affiche directement une expression.': '直接显示一个表情。',
+ 'Retourne l’animation, l’expression et le statut actifs.': '返回当前动画、表情和状态。',
+ Expression: '表情',
+ 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 导出。',
+ 'Choisis les animations puis exporte le JSON runtime ou un package autonome.':
+ '选择动画,然后导出运行时 JSON 或独立包。',
+ 'Choisis les animations puis utilise la même définition JSON avec React ou JavaScript.':
+ '选择动画,然后在 React 或 JavaScript 中使用同一份 JSON 定义。',
'Avatar sélectionné': '已选头像',
Format: '格式',
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
'Composant TSX autonome': '独立 TSX 组件',
'Package React local (.zip)': '本地 React 包(.zip)',
- 'Module JavaScript': 'JavaScript 模块',
+ 'JavaScript / ESM': 'JavaScript / ESM',
+ 'JSON runtime + avatar-web': '运行时 JSON + avatar-web',
'Module ES autonome': '独立 ES 模块',
'Projet HTML + module JS (.zip)': 'HTML 项目 + JS 模块(.zip)',
sélectionnées: '已选择',
'Animations à exporter': '要导出的动画',
'Tout sélectionner': '全选',
'Tout désélectionner': '取消全选',
+ Personnaliser: '自定义',
+ 'Masquer la sélection': '隐藏选择',
'Télécharger le composant TSX': '下载 TSX 组件',
'Télécharger le package React': '下载 React 包',
'Télécharger le module': '下载模块',
+ 'Intégration ESM avec le package avatar-web': '使用 avatar-web 包的 ESM 集成',
+ 'Le ZIP contient le JSON exporté, une démo index.html et son README. La démo charge avatar-web depuis un CDN.':
+ 'ZIP 包含导出的 JSON、index.html 演示和 README。演示会从 CDN 加载 avatar-web。',
+ 'Le ZIP contient le JSON exporté et un projet Vite React TypeScript prêt à lancer avec npm install puis npm run dev.':
+ 'ZIP 包含导出的 JSON 和一个可直接运行的 Vite React TypeScript 项目。先运行 npm install,再运行 npm run dev。',
+ 'Télécharger l’intégration ESM (.zip)': '下载 ESM 集成(.zip)',
+ 'Télécharger la démo React (.zip)': '下载 React 演示(.zip)',
+ 'Télécharger la démo ESM (.zip)': '下载 ESM 演示(.zip)',
+ 'Télécharger le JSON': '下载 JSON',
+ 'Utilisation minimale': '最简用法',
+ 'Utiliser cet avatar': '使用此头像',
+ 'Prêt à exporter': '可以导出',
Snapshot: '快照',
'Mode photo': '照片模式',
'Capture une image statique de l’avatar.': '捕获头像的静态图像。',
diff --git a/tsconfig.json b/tsconfig.json
index e443020..799b398 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -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"]
}
diff --git a/vite.config.ts b/vite.config.ts
index b8015e9..6b0dc17 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -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 },
+ },
},
})
diff --git a/vitest.config.ts b/vitest.config.ts
index 799596f..7c74e8c 100644
--- a/vitest.config.ts
+++ b/vitest.config.ts
@@ -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}'],
},
})