Compare commits

..

1 Commits

Author SHA1 Message Date
smontlouis
18abc5c82f testing stuff 2026-08-15 12:01:40 +02:00
133 changed files with 7356 additions and 19447 deletions

View File

@ -1,11 +0,0 @@
# 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.

View File

@ -1,13 +0,0 @@
{
"$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": []
}

View File

@ -1,50 +0,0 @@
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 }}

View File

@ -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 one portable `.avatar.json` definition for React or framework-free JavaScript/ESM.
- Export a standalone React package or a framework-free JavaScript/HTML package.
- Export and import the complete Studio project as JSON.
- Use the interface in English, French, or Simplified Chinese.
@ -45,21 +45,13 @@ This copy-on-write model lets multiple avatars share the defaults without accide
## Export formats
### Avatar definition
### React package
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.
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.
### React / TypeScript
### JavaScript package
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`.
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.
### Photo Mode

View File

@ -1,29 +0,0 @@
# 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.

17
capture-lab/index.html Normal file
View File

@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#0d1014" />
<meta
name="description"
content="Experimental local face capture and procedural avatar retargeting for Bible Strong Avatar Lab."
/>
<title>Capture Lab — Bible Strong Avatar Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/features/capture/CaptureLabApp.tsx"></script>
</body>
</html>

View File

@ -1,40 +0,0 @@
# 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.

View File

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

View File

@ -1,882 +0,0 @@
# 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 `<pre>` quick start with the npm install command and a
minimal validated React integration. It explicitly notes that the commands become usable after the
currently private packages are published. The code uses accessible high-contrast syntax coloring,
and a `Run example` control renders the generated definition through the actual public React package
with a restartable `idle` animation.
A production-build browser regression loaded Strobi with all bundled semantic keys removed, opened
Export and observed `Ready for runtime export` and `6/6 standard animations available`. The captured
download was schema v1 with 28 Expressions (including synthesized `neutral`), 23 Animations and zero
public `expression-*` references. The formatted-copy action produced valid JSON and its accessible
success status.
#### Implemented architecture
- `pnpm-workspace.yaml` declares `packages/*` and `examples/*`.
- `packages/avatar-core` owns the public schema/types/validator, bounded JSON parser, semantic
manifests, geometry, surfaces, body model, ambient motion, pure playback state, definition-to-scene
adapter, ESM build, declarations, README and package metadata.
- `src/features/avatar/{geometry,surfaces,body,ambientMotion}.ts` are compatibility re-exports from
the shared core. `src/features/avatar/avatarDefinition.ts` keeps only the Studio-to-public adapter
and re-exports the public contract.
- `packages/avatar-react` owns `<Avatar />`, its typed controller, CSS hooks, embedded/floating
layouts, body portal, pointer capture, keyboard movement, position constraints/callbacks, SVG
rendering and direct per-frame path/transform updates.
- `examples/react-vite-consumer` is the independent React fixture. `scripts/smoke-packages.mjs`
builds and packs both packages, copies the fixture outside the workspace, forces the core
dependency to the local tarball, installs, typechecks and builds.
- Runtime packages remain `private: true` and AGPL-3.0-only. Apache-2.0 relicensing and any npm
publication remain blocked until every copyright holder and repository metadata are confirmed.
- The existing Studio ZIP export still consumes compatibility re-exports and strips new
`semanticKey` fields from its legacy payload to avoid an unintended output change.
#### Final verification recorded on 2026-08-14
- Focused Studio/avatar regression suite: 3 files and 67 tests passed.
- `pnpm check`: passed, including generated-engine freshness, formatting, TypeScript, 19 test files
and 162 tests, both package builds, and the production Studio build.
- `npm pack --dry-run --json`: passed; core contains 30 allow-listed files and React contains 10.
- `pnpm packages:smoke`: passed against newly built tarballs in a clean temporary consumer.
- Browser verification of the tarball consumer observed two SVGs/eight paths, working
`play('idle')` and `setExpression('neutral')`, floating drag, no mobile horizontal overflow, and no
application console or page errors.
- `git diff --check`: passed during final handoff.
#### Post-implementation constraints
- Decide licensing/repository metadata before removing `private: true` or publishing either package.
- Phase F requires a separate product decision; it is not a blocker for React v1.
- Local Vite preview still receives 404 responses for the pre-existing Vercel Analytics and Speed
Insights scripts; the runtime example itself renders without JavaScript errors.
## 1. Context and objective
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-<uuid>`, or `shape-<uuid>`.
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<ExpressionKey, AvatarExpressionDefinition>
expressionOrder: ExpressionKey[]
animations: Record<AnimationKey, AvatarAnimationDefinition>
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<SurfaceType, 'mickey' | 'cursor'>
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
type: TType
width: number
height: number
depth: number
roundness: number
morphRoundness?: number
tipRoundness?: number
baseRoundness?: number
}
export type AvatarExpressionDefinition = {
head: { x: number; y: number; z: number }
eyes: {
left: { width: number; height: number; x: number; y: number; angle: number }
right: { width: number; height: number; x: number; y: number; angle: number }
spacing: number
}
perspective: number
motion: {
eyes: 'none' | 'microSaccades' | 'shake'
body: 'none' | 'slowDrift' | 'shake'
}
colors?: Partial<AvatarColorsDefinition>
}
export type 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<T> =
{ ok: true; value: Readonly<T> } | { ok: false; errors: readonly AvatarDefinitionError[] }
function validateAvatarDefinition(value: unknown): ValidationResult<AvatarDefinition>
function parseAvatarDefinition(text: string): ValidationResult<AvatarDefinition>
```
`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<AvatarDefinition>
```
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<AvatarController>
/** 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<AvatarController>`. The package must not use an incompatible `forwardRef` wrapper.
## 9. Layout and dragging
### 9.1 Embedded mode
Embedded is the default:
```tsx
<div className="assistant-zone">
<Avatar definition={avatar} animation="idle" />
</div>
```
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
<Avatar
definition={avatar}
mode="floating"
draggable
initialPosition={{ right: 32, bottom: 32 }}
zIndex={1000}
/>
```
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 `<Avatar />`, 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 -- <focused-files>
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.

View File

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

View File

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

View File

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

View File

@ -1,101 +0,0 @@
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<StrobiExpressionKey>('neutral')
return (
<main>
<h1>Avatar package consumer</h1>
<section className="avatar-host" aria-label="Embedded avatar example">
<Avatar
definition={definition}
expression={expression}
size={240}
ariaLabel="Embedded Strobi avatar"
/>
</section>
<section className="expression-picker" aria-labelledby="expression-picker-title">
<div className="expression-picker__heading">
<h2 id="expression-picker-title">Available expressions ({expressions.length})</h2>
<output aria-live="polite">Active: {formatExpressionName(expression)}</output>
</div>
<div className="expression-picker__list">
{expressionGroups.map(group => (
<section
key={group.key}
className={`expression-picker__group expression-picker__group--${group.key}`}
>
<h3>{group.label}</h3>
<div className="expression-picker__group-list">
{group.expressions.map(key => (
<button
key={key}
type="button"
className={key === expression ? 'is-selected' : undefined}
aria-pressed={key === expression}
onClick={() => setExpression(key)}
>
{formatExpressionName(key)}
</button>
))}
</div>
</section>
))}
</div>
</section>
<div className="avatar-overlay" aria-label="Positioned avatar example">
<Avatar
definition={definition}
expression={expression}
size={128}
ariaLabel="Positioned Strobi avatar"
/>
</div>
</main>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<Demo />
</StrictMode>
)

File diff suppressed because it is too large Load Diff

View File

@ -1,222 +0,0 @@
: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;
}
}

View File

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

View File

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

View File

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

View File

@ -1,21 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Avatar Web consumer</title>
</head>
<body>
<main>
<div id="avatar"></div>
<div class="controls">
<button type="button" data-animation="sleeping">Sleeping</button>
<button type="button" data-animation="idle">Idle</button>
<button type="button" data-expression="neutral">Neutral</button>
<button type="button" id="pause">Pause</button>
<button type="button" id="stop">Stop</button>
</div>
</main>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

View File

@ -1,16 +0,0 @@
{
"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"
}
}

View File

@ -1,21 +0,0 @@
import { createAvatar } from '@bible-strong/avatar-web'
import definition from '../../react-vite-consumer/src/strobi.avatar.json'
import './styles.css'
const avatar = createAvatar('#avatar', {
definition,
defaultAnimation: 'sleeping',
size: '100%',
})
document.querySelectorAll<HTMLButtonElement>('[data-animation]').forEach(button => {
button.addEventListener('click', () => avatar.play(button.dataset.animation ?? 'idle'))
})
document.querySelectorAll<HTMLButtonElement>('[data-expression]').forEach(button => {
button.addEventListener('click', () =>
avatar.setExpression(button.dataset.expression ?? 'neutral')
)
})
document.querySelector('#pause')?.addEventListener('click', () => avatar.pause())
document.querySelector('#stop')?.addEventListener('click', () => avatar.stop())

View File

@ -1,41 +0,0 @@
: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;
}

View File

@ -1,13 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"strict": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"types": ["vite/client"],
"noEmit": true
},
"include": ["src"]
}

18
grok-compare/index.html Normal file
View File

@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#090a0c" />
<meta
name="description"
content="An interactive comparison between the official Grok Bot and its Bible Strong Avatar Lab remaster."
/>
<link rel="icon" type="image/svg+xml" href="../favicon.svg" />
<title>Grok Bot Remastered vs Official — Bible Strong Avatar Lab</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="../src/features/comparison/GrokComparisonApp.tsx"></script>
</body>
</html>

View File

@ -29,20 +29,13 @@
"test:watch": "vitest",
"format": "prettier --write .",
"format:check": "prettier --check .",
"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"
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm build"
},
"dependencies": {
"@base-ui/react": "^1.7.0",
"@bible-strong/avatar-core": "workspace:*",
"@bible-strong/avatar-react": "workspace:*",
"@mediapipe/tasks-vision": "^1.0.1",
"@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",
@ -55,16 +48,13 @@
"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",

View File

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

View File

@ -1,80 +0,0 @@
# @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.

View File

@ -1,71 +0,0 @@
{
"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"
}
}

View File

@ -1,389 +0,0 @@
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',
})
})
})

View File

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

View File

@ -1,248 +0,0 @@
{
"$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": 2 },
"surface": {
"type": "object",
"additionalProperties": false,
"required": ["type", "width", "height", "depth", "roundness"],
"properties": {
"type": {
"enum": ["sphere", "mickey", "cursor", "cube", "capsule", "cylinder", "cone", "diamond"]
},
"width": { "$ref": "#/$defs/dimension" },
"height": { "$ref": "#/$defs/dimension" },
"depth": { "$ref": "#/$defs/dimension" },
"roundness": { "$ref": "#/$defs/roundness" },
"morphRoundness": { "$ref": "#/$defs/roundness" },
"tipRoundness": { "$ref": "#/$defs/roundness" },
"baseRoundness": { "$ref": "#/$defs/roundness" }
}
},
"nodeSurface": {
"allOf": [
{ "$ref": "#/$defs/surface" },
{
"type": "object",
"properties": {
"type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] }
}
}
]
},
"position": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"prefixItems": [
{ "$ref": "#/$defs/boundedNumber" },
{ "$ref": "#/$defs/boundedNumber" },
{ "$ref": "#/$defs/boundedNumber" }
]
},
"rotation": {
"type": "array",
"minItems": 3,
"maxItems": 3,
"prefixItems": [
{ "type": "number", "minimum": -360, "maximum": 360 },
{ "type": "number", "minimum": -360, "maximum": 360 },
{ "type": "number", "minimum": -360, "maximum": 360 }
]
},
"body": {
"type": "object",
"additionalProperties": false,
"required": ["primary", "nodes"],
"properties": {
"primary": { "$ref": "#/$defs/surface" },
"nodes": {
"type": "array",
"maxItems": 16,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["surface", "position", "rotation"],
"properties": {
"surface": { "$ref": "#/$defs/nodeSurface" },
"position": { "$ref": "#/$defs/position" },
"rotation": { "$ref": "#/$defs/rotation" }
}
}
}
}
},
"colors": {
"type": "object",
"additionalProperties": false,
"required": ["body", "eyes"],
"properties": {
"body": { "$ref": "#/$defs/hexColor" },
"eyes": { "$ref": "#/$defs/hexColor" }
}
},
"eye": {
"type": "object",
"additionalProperties": false,
"required": ["width", "height", "x", "y", "angle"],
"properties": {
"width": { "$ref": "#/$defs/boundedNumber" },
"height": { "$ref": "#/$defs/boundedNumber" },
"x": { "$ref": "#/$defs/boundedNumber" },
"y": { "$ref": "#/$defs/boundedNumber" },
"angle": { "$ref": "#/$defs/boundedNumber" }
}
},
"expression": {
"type": "object",
"additionalProperties": false,
"required": ["head", "eyes", "perspective", "motion"],
"properties": {
"head": {
"type": "object",
"additionalProperties": false,
"required": ["x", "y", "z"],
"properties": {
"x": { "$ref": "#/$defs/boundedNumber" },
"y": { "$ref": "#/$defs/boundedNumber" },
"z": { "$ref": "#/$defs/boundedNumber" }
}
},
"eyes": {
"type": "object",
"additionalProperties": false,
"required": ["left", "right", "spacing"],
"properties": {
"left": { "$ref": "#/$defs/eye" },
"right": { "$ref": "#/$defs/eye" },
"spacing": { "$ref": "#/$defs/boundedNumber" }
}
},
"perspective": { "type": "number", "minimum": 0.1, "maximum": 10 },
"motion": {
"type": "object",
"additionalProperties": false,
"required": ["eyes", "body"],
"properties": {
"eyes": { "enum": ["none", "microSaccades", "shake"] },
"body": { "enum": ["none", "slowDrift", "shake"] }
}
},
"colors": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"body": { "$ref": "#/$defs/hexColor" },
"eyes": { "$ref": "#/$defs/hexColor" }
}
}
}
},
"blink": {
"type": "object",
"additionalProperties": false,
"required": ["enabled", "initialDelayMs", "minIntervalMs", "maxIntervalMs", "durationMs"],
"properties": {
"enabled": { "type": "boolean" },
"initialDelayMs": { "type": "number", "minimum": 0, "maximum": 60000 },
"minIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
"maxIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
"durationMs": { "type": "number", "minimum": 50, "maximum": 2000 }
}
},
"animation": {
"type": "object",
"additionalProperties": false,
"required": ["playbackMode", "steps", "blink"],
"properties": {
"playbackMode": { "enum": ["loop", "once", "pingPong"] },
"steps": {
"type": "array",
"minItems": 1,
"maxItems": 128,
"items": {
"type": "object",
"additionalProperties": false,
"required": ["expression", "holdMs", "transitionMs", "transition"],
"properties": {
"expression": { "$ref": "#/$defs/semanticKey" },
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
"transition": { "enum": ["spring", "smooth", "snappy"] }
}
}
},
"blink": { "$ref": "#/$defs/blink" },
"metadata": {
"type": "object",
"additionalProperties": false,
"minProperties": 1,
"properties": {
"label": { "type": "string", "maxLength": 120 },
"description": { "type": "string", "maxLength": 512 },
"group": { "type": "string", "maxLength": 64 }
}
}
}
}
}
}

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -1,348 +0,0 @@
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<T> =
{ ok: true; value: T } | { ok: false; error: AvatarRuntimeError }
export const resolveExpression = (
definition: Readonly<AvatarDefinition>,
key: ExpressionKey
): AvatarCommandResult<Readonly<AvatarExpressionDefinition>> => {
const expression = definition.expressions[key]
return expression
? { ok: true, value: expression }
: {
ok: false,
error: { code: 'unknown_expression', key, message: `Unknown expression '${key}'` },
}
}
export const resolveAnimation = (
definition: Readonly<AvatarDefinition>,
key: AnimationKey
): AvatarCommandResult<Readonly<AvatarAnimationDefinition>> => {
const explicit = definition.animations[key]
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<AvatarDefinition>,
key: AnimationKey,
now: number,
from?: AvatarFrameSnapshot
): AvatarCommandResult<AvatarPlaybackState> => {
const result = resolveAnimation(definition, key)
if (!result.ok) return result
return {
ok: true,
value: {
activeAnimation: key,
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
status: 'playing',
stepIndex: 0,
direction: 1,
phase: 'transition',
phaseStartedAt: now,
transitionFrom: 'neutral',
...(from ? { transitionSnapshot: from } : {}),
blinkDueAt: now + result.value.blink.initialDelayMs,
},
}
}
const nextCursor = (
animation: Readonly<AvatarAnimationDefinition>,
state: AvatarPlaybackState
): { stepIndex: number; direction: 1 | -1; complete: boolean } => {
const last = animation.steps.length - 1
if (state.stepIndex < last && state.direction === 1) {
return { stepIndex: state.stepIndex + 1, direction: state.direction, complete: false }
}
if (state.stepIndex > 0 && state.direction === -1) {
return { stepIndex: state.stepIndex - 1, direction: state.direction, complete: false }
}
if (animation.playbackMode === 'once') {
return { stepIndex: state.stepIndex, direction: state.direction, complete: true }
}
if (animation.playbackMode === 'pingPong' && last > 0) {
const direction = state.direction === 1 ? -1 : 1
return { stepIndex: state.stepIndex + direction, direction, complete: false }
}
return { stepIndex: 0, direction: 1 as const, complete: false }
}
export const advanceAvatarPlayback = (
definition: Readonly<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number,
environment: AvatarRuntimeEnvironment
): AvatarPlaybackState => {
if (state.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<AvatarPlaybackState>,
now: number
): AvatarPlaybackState =>
state.status === 'playing' ? { ...state, status: 'paused', pausedAt: now } : { ...state }
export const resumeAvatarPlayback = (
state: Readonly<AvatarPlaybackState>,
now: number
): AvatarPlaybackState => {
if (state.status !== 'paused' || state.pausedAt === undefined) return { ...state }
const pauseDuration = now - state.pausedAt
return {
...state,
status: 'playing',
phaseStartedAt: state.phaseStartedAt + pauseDuration,
...(state.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<AvatarDefinition>,
expression: Readonly<AvatarExpressionDefinition>
): AvatarScene['colors'] => ({
body: expression.colors?.body ?? definition.colors.body,
eyes: expression.colors?.eyes ?? definition.colors.eyes,
})
const interpolateHexColor = (from: string, to: string, progress: number) => {
const parse = (color: string) => {
const value = color.slice(1)
const expanded = value.length === 3 ? [...value].map(part => `${part}${part}`).join('') : value
return [0, 2, 4].map(index => Number.parseInt(expanded.slice(index, index + 2), 16))
}
const source = parse(from)
const target = parse(to)
if (source.some(Number.isNaN) || target.some(Number.isNaN)) return progress < 1 ? from : to
return `#${source
.map((value, index) => Math.round(value + (target[index] - value) * progress))
.map(value => value.toString(16).padStart(2, '0'))
.join('')}`
}
const interpolateColors = (
from: AvatarScene['colors'],
to: AvatarScene['colors'],
progress: number
): AvatarScene['colors'] => ({
body: interpolateHexColor(from.body, to.body, progress),
eyes: interpolateHexColor(from.eyes, to.eyes, progress),
})
export const blinkOpacityAt = (
animation: Readonly<AvatarAnimationDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number
) => {
if (!animation.blink.enabled || state.blinkStartedAt === undefined) return 1
const progress = (now - state.blinkStartedAt) / animation.blink.durationMs
if (progress < 0 || progress >= 1) return 1
return Math.abs(progress * 2 - 1)
}
export const sampleAvatarFrame = (
definition: Readonly<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
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<AvatarDefinition>,
state: Readonly<AvatarPlaybackState>,
now: number,
environment: AvatarRuntimeEnvironment
): AvatarScene => {
const frame = sampleAvatarFrame(definition, state, now, environment)
const expression = environment.reduceMotion
? frame.expression
: applyAmbientMotion(frame.expression, frame.sampledAt)
return renderAvatarExpression(definition, expression, frame.colors, frame.blink)
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,125 +0,0 @@
# @bible-strong/avatar-react
React 19 renderer for a validated Bible Strong `AvatarDefinition`. React and React DOM 19 are peer
dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
## Install
```sh
pnpm add @bible-strong/avatar-react react react-dom
```
Import the package stylesheet once in the application entry point:
```tsx
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
import type { AvatarDefinition } from '@bible-strong/avatar-core'
import '@bible-strong/avatar-react/styles.css'
import { useRef } from 'react'
export function Assistant({ definition }: { definition: AvatarDefinition }) {
const avatar = useRef<AvatarController>(null)
return (
<>
<Avatar ref={avatar} definition={definition} defaultAnimation="idle" />
<button onClick={() => avatar.current?.play('happy')}>Play happy</button>
<button onClick={() => avatar.current?.setExpression('neutral')}>Neutral</button>
</>
)
}
```
For a reusable component tied to one JSON definition, use `createAvatar`. It validates the
definition once and, when the JSON is statically typed, narrows `animation`, `defaultAnimation`,
`expression` and `defaultExpression` to the semantic keys present in that definition:
```tsx
import { createAvatar } from '@bible-strong/avatar-react'
import avatarJson from './strobi.avatar.json'
const StrobiAvatar = createAvatar(avatarJson)
export function Strobi() {
return <StrobiAvatar defaultAnimation="idle" />
}
```
Definitions fetched at runtime are validated by the same factory, but their keys are necessarily
checked at runtime rather than inferred by TypeScript.
`play` and `setExpression` return `{ ok: true }` or a typed error with one of
`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<AvatarController> \| undefined` | — | Exposes the imperative API described below. |
`animation` and `expression` are two alternative sources of truth. Passing both throws an error;
the component never silently overrides one with the other. A controlled target takes priority over
an uncontrolled default when they are intentionally mixed.
### Presentation
| Prop | Type | Default | Behavior |
| ----------- | ------------------------------- | ------------------- | --------------------------------------------------------------------------- |
| `size` | `number \| string \| undefined` | `240` | Number or CSS value applied to the wrapper width and height. |
| `className` | `string \| undefined` | — | CSS class added to the outer wrapper. |
| `style` | `CSSProperties \| undefined` | — | Inline styles for the outer wrapper. `width` and `height` come from `size`. |
| `ariaLabel` | `string \| undefined` | `Procedural avatar` | Accessible name announced to screen readers. |
### Playback callbacks
| Prop | Type | Receives |
| -------------------- | ------------------------------------- | ---------------------------------------------------------------------- |
| `onAnimationEnd` | `(animation: AnimationKey) => void` | The key of a `once` animation when it completes naturally. |
| `onExpressionChange` | `(expression: ExpressionKey) => void` | The semantic expression key whenever the displayed expression changes. |
| `onError` | `(error: AvatarRuntimeError) => void` | An unknown animation or expression key supplied through props. |
Unknown keys passed through `animation`, `expression`, `defaultAnimation` or `defaultExpression`
are reported to `onError`. Without an error handler, the component writes the typed runtime error
to the developer console instead of failing silently.
## Imperative API
Pass a `ref` to receive an `AvatarController`. Use it when buttons, events or another imperative
source need to drive an uncontrolled avatar:
| Method | Type | Behavior |
| --------------------------- | ---------------------------------------------------- | -------------------------------------------------------------- |
| `play(animation)` | `(animation: AnimationKey) => AvatarCommandResult` | Starts an animation or resumes it from its paused position. |
| `pause()` | `() => void` | Freezes the exact timeline position. |
| `stop()` | `() => void` | Stops playback and resets an uncontrolled avatar to `neutral`. |
| `setExpression(expression)` | `(expression: ExpressionKey) => AvatarCommandResult` | Shows one expression directly. |
| `getState()` | `() => AvatarPlaybackState` | Returns `activeAnimation?`, `activeExpression` and `status`. |
`play` and `setExpression` return `{ ok: true }` or `{ ok: false, error }`. Errors include
`unknown_animation`, `unknown_expression` and `controlled_by_props`. When `animation` or
`expression` is controlled by props, use those props to change the target; imperative target
commands cannot replace the parent value.
The definition is validated once per immutable object reference and revalidated/reinitialized when
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.

View File

@ -1,65 +0,0 @@
{
"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"
}
}

View File

@ -1,526 +0,0 @@
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<object>()
const controlledExpressionTransitionMs = 420
const bodyPathSlots = MAX_BODY_NODES + 2
const runtimeEnvironment = () => ({
random: Math.random,
reduceMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
})
const shouldRunFrameLoop = (
definition: AvatarDefinition,
playback: Readonly<CorePlaybackState>,
environment: ReturnType<typeof runtimeEnvironment>
) => {
if (playback.status === 'playing') return true
const motion = definition.expressions[playback.activeExpression]?.motion
return (
playback.status === 'stopped' &&
!environment.reduceMotion &&
motion !== undefined &&
(motion.eyes !== 'none' || motion.body !== 'none')
)
}
export const markAvatarDefinitionValidated = (definition: object) => {
validatedDefinitions.add(definition)
}
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<AvatarController>
/** 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<AvatarProps, 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'>) => {
if (animation !== undefined && expression !== undefined) {
throw new Error(
'Avatar accepts either animation or expression, not both. Animation controls a timeline; expression controls a single target.'
)
}
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
throw new Error(
'Avatar accepts either defaultAnimation or defaultExpression, not both. Choose one uncontrolled initial target.'
)
}
}
const 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<SVGPathElement>(null)
const headPathRef = useRef<SVGPathElement>(null)
const leftPathRef = useRef<SVGPathElement>(null)
const rightPathRef = useRef<SVGPathElement>(null)
const backPathRefs = useRef<(SVGPathElement | null)[]>([])
const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
const defaultPlaybackStarted = useRef(false)
const completedAnimation = useRef<AnimationKey | undefined>(undefined)
const playbackRef = useRef<CorePlaybackState | null>(null)
const paintedFrameRef = useRef<ReturnType<typeof sampleAvatarFrame> | null>(null)
const previousDefinitionRef = useRef(definition)
const [playback, setPlayback] = useState<CorePlaybackState>(() =>
createInitialPlayback(definition, animation, expression, defaultAnimation, defaultExpression)
)
const paintScene = (frameScene: ReturnType<typeof renderAvatarFrame>) => {
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<CorePlaybackState>,
now: number,
environment: ReturnType<typeof runtimeEnvironment>
) => {
paintedFrameRef.current = sampleAvatarFrame(definition, current, now, environment)
return renderAvatarFrame(definition, current, now, environment)
}
useLayoutEffect(() => {
const current = playbackRef.current ?? playback
const now = performance.now()
const environment = runtimeEnvironment()
paintScene(renderPlaybackFrame(current, now, environment))
})
useEffect(() => {
playbackRef.current = playback
}, [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 (!shouldRunFrameLoop(definition, playback, runtimeEnvironment())) 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 (shouldRunFrameLoop(definition, next, environment)) {
frame = requestAnimationFrame(tick)
}
}
frame = requestAnimationFrame(tick)
return () => cancelAnimationFrame(frame)
}, [definition, playback.activeExpression, 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 (
<div
className={['bs-avatar', className ?? ''].filter(Boolean).join(' ')}
style={{
...style,
width: size,
height: size,
}}
role="img"
aria-label={ariaLabel}
>
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={clipId}>
<path ref={clipPathRef} d={scene.geometry.headPath} />
</clipPath>
</defs>
{Array.from({ length: bodyPathSlots }, (_, index) => (
<path
ref={element => {
backPathRefs.current[index] = element
}}
d={scene.geometry.backPaths[index] ?? ''}
fill={scene.colors.body}
key={`back-${index}`}
/>
))}
<path ref={headPathRef} d={scene.geometry.headPath} fill={scene.colors.body} />
<g clipPath={`url(#${clipId})`} fill={scene.colors.eyes}>
<path
ref={leftPathRef}
d={scene.geometry.leftPath}
opacity={scene.geometry.leftVisible ? 1 : 0}
/>
<path
ref={rightPathRef}
d={scene.geometry.rightPath}
opacity={scene.geometry.rightVisible ? 1 : 0}
/>
</g>
{Array.from({ length: bodyPathSlots }, (_, index) => (
<path
ref={element => {
frontPathRefs.current[index] = element
}}
d={scene.geometry.frontPaths[index] ?? ''}
fill={scene.colors.body}
key={`front-${index}`}
/>
))}
</svg>
</div>
)
}

View File

@ -1,363 +0,0 @@
// @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 ambientExpression = {
...expression,
motion: { eyes: 'none', body: 'shake' },
} 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 } },
restless: ambientExpression,
},
expressionOrder: ['neutral', 'smile', 'restless'],
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(<ConcreteAvatar animation="greet" ariaLabel="Concrete avatar" />)
expect(view.getByRole('img', { name: 'Concrete avatar' })).toBeTruthy()
})
it('rejects invalid definitions before creating a component', () => {
expect(() => createAvatar({})).toThrow('Invalid avatar definition')
})
it('renders semantic SVG geometry', () => {
const view = render(<Avatar definition={definition} ariaLabel="Assistant avatar" />)
const 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(<Avatar definition={definition} ariaLabel="Layered avatar" />)
const svg = view.getByRole('img', { name: 'Layered avatar' }).querySelector('svg')
expect(svg?.querySelectorAll(':scope > path')).toHaveLength(37)
})
it('exposes semantic imperative controls without Studio identifiers', () => {
const controller = createRef<AvatarController>()
render(<Avatar definition={definition} ref={controller} />)
let result: ReturnType<AvatarController['setExpression']> | undefined
act(() => {
result = controller.current?.setExpression('smile')
})
expect(result).toEqual({ ok: true })
expect(controller.current?.getState()).toMatchObject({
activeExpression: 'smile',
status: '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<AvatarController>()
render(<Avatar definition={definition} expression="neutral" ref={controller} />)
expect(controller.current?.setExpression('smile')).toMatchObject({
ok: false,
error: { code: 'controlled_by_props' },
})
})
it('smoothly transitions when a controlled expression changes', () => {
const controller = createRef<AvatarController>()
const view = render(<Avatar definition={definition} expression="neutral" ref={controller} />)
view.rerender(<Avatar definition={definition} expression="smile" ref={controller} />)
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<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
const controller = createRef<AvatarController>()
const view = render(<Avatar definition={definition} ref={controller} />)
const eye = view.container.querySelector<SVGPathElement>('.bs-avatar__svg g path')
const neutralPath = eye?.getAttribute('d')
act(() => controller.current?.setExpression('smile'))
expect(eye?.getAttribute('d')).toBe(neutralPath)
act(() => {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(1_200)
})
const inFlightPath = eye?.getAttribute('d')
expect(inFlightPath).not.toBe(neutralPath)
clock.mockReturnValue(1_200)
act(() => controller.current?.setExpression('neutral'))
expect(eye?.getAttribute('d')).toBe(inFlightPath)
act(() => {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(1_200)
})
expect(eye?.getAttribute('d')).toBe(inFlightPath)
clock.mockRestore()
request.mockRestore()
cancel.mockRestore()
})
it('rejects simultaneous controlled animation and expression props', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(<Avatar definition={definition} animation="greet" expression="neutral" />)
).toThrow('Avatar accepts either animation or expression, not both.')
errors.mockRestore()
})
it('rejects simultaneous uncontrolled animation and expression defaults', () => {
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
expect(() =>
render(
<Avatar definition={definition} defaultAnimation="greet" defaultExpression="neutral" />
)
).toThrow('Avatar accepts either defaultAnimation or defaultExpression, not both.')
errors.mockRestore()
})
it('reports unknown controlled and default targets through onError', () => {
const onError = vi.fn()
const controlled = render(
<Avatar definition={definition} animation="missing-animation" onError={onError} />
)
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'unknown_animation', key: 'missing-animation' })
)
onError.mockClear()
controlled.rerender(
<Avatar definition={definition} defaultExpression="missing-expression" onError={onError} />
)
expect(onError).toHaveBeenCalledWith(
expect.objectContaining({ code: 'unknown_expression', key: 'missing-expression' })
)
})
it('keeps a controlled expression above an uncontrolled animation default', () => {
const controller = createRef<AvatarController>()
render(
<Avatar
definition={definition}
expression="neutral"
defaultAnimation="greet"
ref={controller}
/>
)
expect(controller.current?.getState()).toEqual({
activeExpression: 'neutral',
status: 'stopped',
})
})
it('honors uncontrolled defaults without autoplay when requested', () => {
const controller = createRef<AvatarController>()
render(
<Avatar definition={definition} defaultAnimation="greet" autoplay={false} ref={controller} />
)
expect(controller.current?.getState()).toEqual({
activeExpression: 'smile',
status: 'stopped',
})
})
it('resumes the current paused animation instead of restarting it', () => {
const controller = createRef<AvatarController>()
const clock = vi.spyOn(performance, 'now')
clock.mockReturnValueOnce(100).mockReturnValueOnce(250).mockReturnValueOnce(1_250)
render(<Avatar definition={definition} ref={controller} />)
act(() => {
controller.current?.play('greet')
controller.current?.pause()
controller.current?.play('greet')
})
expect(controller.current?.getState()).toMatchObject({
activeAnimation: 'greet',
activeExpression: 'smile',
status: 'playing',
})
clock.mockRestore()
})
it('updates SVG frames without rendering React once per animation frame', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
let renders = 0
render(
<Profiler id="animated-avatar" onRender={() => renders++}>
<Avatar definition={definition} defaultAnimation="greet" />
</Profiler>
)
const beforeFrames = renders
act(() => {
for (let index = 1; index <= 20; index++) {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(1_000 + index)
}
})
expect(renders).toBe(beforeFrames)
clock.mockRestore()
request.mockRestore()
cancel.mockRestore()
})
it('keeps scheduling frames for a controlled expression with ambient motion', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
render(<Avatar definition={definition} expression="restless" />)
act(() => {
for (let index = 1; index <= 3; index++) {
const callback = [...frames.values()].at(-1)
frames.clear()
callback?.(index * 1_000)
}
})
expect(request).toHaveBeenCalledTimes(4)
expect(frames).toHaveLength(1)
request.mockRestore()
cancel.mockRestore()
})
it('fires once-completion exactly once under Strict Mode', () => {
let nextFrame = 0
const frames = new Map<number, FrameRequestCallback>()
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
frames.set(++nextFrame, callback)
return nextFrame
})
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
frames.delete(id)
})
const ended = vi.fn()
render(
<StrictMode>
<Avatar definition={definition} defaultAnimation="wave-once" onAnimationEnd={ended} />
</StrictMode>
)
act(() => {
const callbacks = [...frames.values()]
frames.clear()
callbacks.forEach(callback => callback(performance.now() + 1_000))
})
expect(ended).toHaveBeenCalledTimes(1)
expect(ended).toHaveBeenCalledWith('wave-once')
request.mockRestore()
cancel.mockRestore()
})
})

View File

@ -1,74 +0,0 @@
import {
validateAvatarDefinition,
type AnimationKey,
type AvatarDefinition,
type ExpressionKey,
} from '@bible-strong/avatar-core'
import type { ReactElement } from 'react'
import {
Avatar,
markAvatarDefinitionValidated,
type AvatarController,
type AvatarProps,
} from './Avatar'
type AvatarDefinitionInput = {
expressions: object
animations: object
}
type StringKey<T> = Extract<keyof T, string>
/** Props for a component created from one concrete avatar definition. */
export type CreatedAvatarProps<Definition extends AvatarDefinitionInput> = Omit<
AvatarProps,
'definition' | 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'
> & {
animation?: StringKey<Definition['animations']>
defaultAnimation?: StringKey<Definition['animations']>
expression?: StringKey<Definition['expressions']>
defaultExpression?: StringKey<Definition['expressions']>
}
/** A concrete avatar component with animation and expression keys from its definition. */
export type CreatedAvatarComponent<Definition extends AvatarDefinitionInput> = (
props: CreatedAvatarProps<Definition>
) => ReactElement
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
const first = errors[0]
return new Error(
first
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
: 'Invalid avatar definition.'
)
}
const buildAvatarComponent = (definition: Readonly<AvatarDefinition>) => {
const ConcreteAvatar = (props: CreatedAvatarProps<AvatarDefinition>): ReactElement => (
<Avatar {...props} definition={definition} />
)
ConcreteAvatar.displayName = 'CreatedAvatar'
return ConcreteAvatar
}
/**
* Validate a JSON-compatible definition once and create a concrete React component from it.
*
* When the input is a statically typed definition, the returned component narrows its animation
* and expression props to that definition's semantic keys. Values loaded at runtime are still
* validated, but necessarily expose the broad string-key API at compile time.
*/
export function createAvatar<const Definition extends AvatarDefinitionInput>(
definition: Definition
): CreatedAvatarComponent<Definition>
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition>
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition> {
const result = validateAvatarDefinition(definition)
if (!result.ok) throw invalidDefinitionError(result.errors)
markAvatarDefinitionValidated(result.value)
return buildAvatarComponent(result.value)
}
export type { AnimationKey, AvatarController, ExpressionKey }

View File

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

View File

@ -1,31 +0,0 @@
.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;
}
}

View File

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

View File

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

View File

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

View File

@ -1,18 +0,0 @@
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'],
},
},
})

View File

@ -1,10 +0,0 @@
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 <https://www.gnu.org/licenses/agpl-3.0.txt>.

View File

@ -1,28 +0,0 @@
# @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.

View File

@ -1,55 +0,0 @@
{
"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"
}
}

View File

@ -1,49 +0,0 @@
// @vitest-environment jsdom
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
import { createAvatar } from '../index'
describe('@bible-strong/avatar-web', () => {
beforeEach(() => {
document.body.innerHTML = '<div id="avatar"></div>'
vi.stubGlobal('requestAnimationFrame', () => 1)
vi.stubGlobal('cancelAnimationFrame', vi.fn())
vi.stubGlobal('matchMedia', () => ({ matches: false }))
})
afterEach(() => vi.unstubAllGlobals())
it('mounts the shared avatar definition without React', () => {
const avatar = createAvatar('#avatar', {
definition: definitionJson,
defaultExpression: 'neutral',
size: 180,
})
expect(document.querySelector('#avatar svg')).not.toBeNull()
expect(document.querySelectorAll('#avatar svg > path')).toHaveLength(37)
expect(document.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe(
'Procedural avatar'
)
expect(avatar.getState()).toMatchObject({
activeExpression: 'neutral',
status: 'stopped',
})
avatar.destroy()
expect(document.querySelector('#avatar svg')).toBeNull()
})
it('returns typed errors for unknown targets', () => {
const avatar = createAvatar('#avatar', { definition: definitionJson })
expect(avatar.play('missing')).toEqual({
ok: false,
error: expect.objectContaining({ code: 'unknown_animation', key: 'missing' }),
})
expect(avatar.setExpression('missing')).toEqual({
ok: false,
error: expect.objectContaining({ code: 'unknown_expression', key: 'missing' }),
})
})
})

View File

@ -1,304 +0,0 @@
import {
advanceAvatarPlayback,
createAvatarPlaybackState,
MAX_BODY_NODES,
pauseAvatarPlayback,
playAvatarAnimation,
renderAvatarDefinition,
renderAvatarFrame,
resolveAnimation,
resolveExpression,
resumeAvatarPlayback,
sampleAvatarFrame,
validateAvatarDefinition,
type AnimationKey,
type AvatarDefinition,
type AvatarPlaybackState as CorePlaybackState,
type AvatarRuntimeError,
type ExpressionKey,
} from '@bible-strong/avatar-core'
export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
export type AvatarPlaybackState = Pick<
CorePlaybackState,
'activeAnimation' | 'activeExpression' | 'status'
>
export type AvatarController = {
play(animation: AnimationKey): AvatarCommandResult
setExpression(expression: ExpressionKey): AvatarCommandResult
pause(): void
stop(): void
getState(): AvatarPlaybackState
destroy(): void
}
export type CreateAvatarOptions = {
definition: unknown
defaultAnimation?: AnimationKey
defaultExpression?: ExpressionKey
autoplay?: boolean
size?: number | string
ariaLabel?: string
className?: string
onError?: (error: AvatarRuntimeError) => void
onAnimationEnd?: (animation: AnimationKey) => void
onExpressionChange?: (expression: ExpressionKey) => void
}
const svgNamespace = 'http://www.w3.org/2000/svg'
const controlledExpressionTransitionMs = 420
const bodyPathSlots = MAX_BODY_NODES + 2
let avatarInstanceId = 0
const dimension = (size: number | string) => (typeof size === 'number' ? `${size}px` : size)
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
const first = errors[0]
return new Error(
first
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
: 'Invalid avatar definition.'
)
}
const resolveTarget = (target: string | HTMLElement) => {
const element = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target
if (!element) throw new Error(`Avatar target '${target}' was not found.`)
return element
}
const createSvgElement = <Name extends keyof SVGElementTagNameMap>(name: Name) =>
document.createElementNS(svgNamespace, name)
const playbackSnapshot = (state: CorePlaybackState): AvatarPlaybackState => ({
...(state.activeAnimation ? { activeAnimation: state.activeAnimation } : {}),
activeExpression: state.activeExpression,
status: state.status,
})
const runtimeEnvironment = () => ({
random: Math.random,
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
})
export function createAvatar(
target: string | HTMLElement,
{
definition: input,
defaultAnimation,
defaultExpression,
autoplay = true,
size = 240,
ariaLabel = 'Procedural avatar',
className,
onError,
onAnimationEnd,
onExpressionChange,
}: CreateAvatarOptions
): AvatarController {
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
throw new Error('Choose either defaultAnimation or defaultExpression, not both.')
}
const validated = validateAvatarDefinition(input)
if (!validated.ok) throw invalidDefinitionError(validated.errors)
const definition: Readonly<AvatarDefinition> = validated.value
const mount = resolveTarget(target)
const host = document.createElement('span')
host.className = ['bs-avatar', className ?? ''].filter(Boolean).join(' ')
host.style.display = 'inline-block'
host.style.width = dimension(size)
host.style.height = dimension(size)
host.setAttribute('role', 'img')
host.setAttribute('aria-label', ariaLabel)
const svg = createSvgElement('svg')
svg.setAttribute('viewBox', '-150 -150 300 300')
svg.setAttribute('aria-hidden', 'true')
svg.style.display = 'block'
svg.style.width = '100%'
svg.style.height = '100%'
const defs = createSvgElement('defs')
const clipPath = createSvgElement('clipPath')
const clipId = `bs-avatar-web-${++avatarInstanceId}`
clipPath.id = clipId
const clipHeadPath = createSvgElement('path')
clipPath.append(clipHeadPath)
defs.append(clipPath)
svg.append(defs)
const initialScene = renderAvatarDefinition(definition)
const backPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
const headPath = createSvgElement('path')
const eyeGroup = createSvgElement('g')
eyeGroup.setAttribute('clip-path', `url(#${clipId})`)
const leftPath = createSvgElement('path')
const rightPath = createSvgElement('path')
eyeGroup.append(leftPath, rightPath)
const frontPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
svg.append(...backPaths, headPath, eyeGroup, ...frontPaths)
host.append(svg)
mount.append(host)
const reportError = (error: AvatarRuntimeError) => {
if (onError) onError(error)
else console.error(`[Avatar] ${error.message}`)
}
const paint = (scene: ReturnType<typeof renderAvatarDefinition>) => {
clipHeadPath.setAttribute('d', scene.geometry.headPath)
headPath.setAttribute('d', scene.geometry.headPath)
headPath.setAttribute('fill', scene.colors.body)
leftPath.setAttribute('d', scene.geometry.leftPath)
leftPath.setAttribute('fill', scene.colors.eyes)
leftPath.setAttribute('opacity', scene.geometry.leftVisible ? '1' : '0')
rightPath.setAttribute('d', scene.geometry.rightPath)
rightPath.setAttribute('fill', scene.colors.eyes)
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
backPaths.forEach((element, index) => {
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
element.setAttribute('fill', scene.colors.body)
})
frontPaths.forEach((element, index) => {
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
element.setAttribute('fill', scene.colors.body)
})
}
let playback = createAvatarPlaybackState()
let frameRequest: number | null = null
let destroyed = false
let completedAnimation: AnimationKey | undefined
let lastExpression: ExpressionKey | undefined
let paintedFrame: ReturnType<typeof sampleAvatarFrame> | undefined
const notifyExpression = () => {
if (lastExpression === playback.activeExpression) return
lastExpression = playback.activeExpression
onExpressionChange?.(playback.activeExpression)
}
const renderCurrent = (now: number) => {
const environment = runtimeEnvironment()
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
paint(renderAvatarFrame(definition, playback, now, environment))
notifyExpression()
}
const tick = (now: number) => {
frameRequest = null
if (destroyed) return
const currentAnimation = playback.activeAnimation
const wasPlaying = playback.status === 'playing'
playback = advanceAvatarPlayback(definition, playback, now, {
random: Math.random,
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
})
renderCurrent(now)
if (wasPlaying && playback.status === 'stopped' && currentAnimation) {
if (completedAnimation !== currentAnimation) onAnimationEnd?.(currentAnimation)
completedAnimation = currentAnimation
}
if (playback.status === 'playing') frameRequest = requestAnimationFrame(tick)
}
const schedule = () => {
if (frameRequest === null && !destroyed) frameRequest = requestAnimationFrame(tick)
}
const controller: AvatarController = {
play(animation) {
if (
playback.status === 'paused' &&
playback.activeAnimation === animation &&
playback.pausedAt !== undefined
) {
playback = resumeAvatarPlayback(playback, performance.now())
schedule()
return { ok: true }
}
const now = performance.now()
const from =
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
const result = playAvatarAnimation(definition, animation, now, from)
if (!result.ok) return { ok: false, error: result.error }
completedAnimation = undefined
playback = result.value
renderCurrent(performance.now())
schedule()
return { ok: true }
},
setExpression(expression) {
const result = resolveExpression(definition, expression)
if (!result.ok) return { ok: false, error: result.error }
const now = performance.now()
const from =
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
playback = {
...createAvatarPlaybackState(),
activeExpression: expression,
...(playback.activeExpression === expression
? {}
: {
status: 'playing' as const,
directTransition: {
from,
startedAt: now,
durationMs: controlledExpressionTransitionMs,
transition: 'smooth' as const,
},
}),
}
renderCurrent(performance.now())
if (playback.status === 'playing') schedule()
return { ok: true }
},
pause() {
if (playback.status !== 'playing') return
playback = pauseAvatarPlayback(playback, performance.now())
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
},
stop() {
playback = createAvatarPlaybackState()
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
paint(renderAvatarDefinition(definition))
notifyExpression()
},
getState() {
return playbackSnapshot(playback)
},
destroy() {
destroyed = true
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
frameRequest = null
host.remove()
},
}
if (defaultAnimation !== undefined) {
const resolved = resolveAnimation(definition, defaultAnimation)
if (!resolved.ok) reportError(resolved.error)
else if (autoplay) controller.play(defaultAnimation)
else {
playback = {
...createAvatarPlaybackState(),
activeExpression: resolved.value.steps[0]?.expression ?? 'neutral',
}
renderCurrent(performance.now())
}
} else if (defaultExpression !== undefined) {
const resolved = resolveExpression(definition, defaultExpression)
if (!resolved.ok) reportError(resolved.error)
else {
playback = { ...createAvatarPlaybackState(), activeExpression: defaultExpression }
renderCurrent(performance.now())
}
} else {
paint(initialScene)
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
notifyExpression()
}
return controller
}
export type { AnimationKey, AvatarDefinition, AvatarRuntimeError, ExpressionKey }

View File

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

View File

@ -1,14 +0,0 @@
{
"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__/**"]
}

View File

@ -1,17 +0,0 @@
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'],
},
},
})

901
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

@ -1,73 +0,0 @@
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`)

View File

@ -1,6 +1,6 @@
import { Pause, Play, Square } from 'lucide-react'
import { motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion/react'
import { useEffect, useEffectEvent, useId, useRef } from 'react'
import { motion } from 'motion/react'
import { useId } from 'react'
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
import { Button } from '@/components/ui/button'
@ -11,11 +11,6 @@ import { useStudioLanguage } from '@/i18n'
import { type PlaybackStatus } from '@/app/studio-utils'
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
import {
normalizeSnapshotComposition,
snapshotCornerRadius,
type SnapshotComposition,
} from '@/features/export/snapshotComposition'
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
export function ControlSection({
@ -49,8 +44,6 @@ export function SnapshotPreview({
colorFrom,
colorTo,
renderStyle,
composition,
onCompositionChange,
}: {
scene: RenderedScene
colors: RenderedColors
@ -58,29 +51,11 @@ export function SnapshotPreview({
colorFrom: string
colorTo: string
renderStyle: AvatarRenderStyle
composition: SnapshotComposition
onCompositionChange: (composition: SnapshotComposition) => void
}) {
const { t } = useStudioLanguage()
const id = useId().replace(/:/g, '')
const clipId = `${id}-clip`
const frameClipId = `${id}-frame-clip`
const linearId = `${id}-linear`
const radialId = `${id}-radial`
const positionX = useMotionValue(composition.x)
const positionY = useMotionValue(composition.y)
const scale = useMotionValue(composition.scale)
const pixelPositionX = useTransform(positionX, value => `${value / 3}%`)
const pixelPositionY = useTransform(positionY, value => `${value / 3}%`)
const frameRef = useRef<HTMLDivElement>(null)
const compositionGroupRef = useRef<SVGGElement>(null)
const dragRef = useRef<{
clientX: number
clientY: number
x: number
y: number
} | null>(null)
const wheelCommitRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const backgroundFill =
background === 'solid'
? colorFrom
@ -96,158 +71,21 @@ export function SnapshotPreview({
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
: undefined
const paintVectorTransform = () => {
compositionGroupRef.current?.setAttribute(
'transform',
`translate(${positionX.get()} ${positionY.get()}) scale(${scale.get()})`
)
}
useMotionValueEvent(positionX, 'change', paintVectorTransform)
useMotionValueEvent(positionY, 'change', paintVectorTransform)
useMotionValueEvent(scale, 'change', paintVectorTransform)
useEffect(() => {
positionX.set(composition.x)
positionY.set(composition.y)
scale.set(composition.scale)
compositionGroupRef.current?.setAttribute(
'transform',
`translate(${composition.x} ${composition.y}) scale(${composition.scale})`
)
}, [composition.x, composition.y, composition.scale, positionX, positionY, scale])
const commitComposition = () =>
onCompositionChange(
normalizeSnapshotComposition({
...composition,
x: positionX.get(),
y: positionY.get(),
scale: scale.get(),
})
)
const zoomFrame = useEffectEvent((event: WheelEvent) => {
event.preventDefault()
event.stopPropagation()
if (dragRef.current) return
scale.set(
normalizeSnapshotComposition({
...composition,
scale: scale.get() * Math.exp(-event.deltaY * 0.0015),
}).scale
)
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
wheelCommitRef.current = setTimeout(commitComposition, 120)
})
useEffect(() => {
const frame = frameRef.current
if (!frame) return
frame.addEventListener('wheel', zoomFrame, { passive: false })
return () => {
frame.removeEventListener('wheel', zoomFrame)
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
}
}, [])
const startDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (event.button !== 0) return
event.preventDefault()
if (wheelCommitRef.current) clearTimeout(wheelCommitRef.current)
dragRef.current = {
clientX: event.clientX,
clientY: event.clientY,
x: positionX.get(),
y: positionY.get(),
}
event.currentTarget.dataset.dragging = 'true'
event.currentTarget.setPointerCapture(event.pointerId)
}
const drag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current || !event.currentTarget.hasPointerCapture(event.pointerId)) return
const bounds = event.currentTarget.getBoundingClientRect()
const viewBoxPerPixel = 300 / Math.max(bounds.width, 1)
positionX.set(
normalizeSnapshotComposition({
...composition,
x: dragRef.current.x + (event.clientX - dragRef.current.clientX) * viewBoxPerPixel,
}).x
)
positionY.set(
normalizeSnapshotComposition({
...composition,
y: dragRef.current.y + (event.clientY - dragRef.current.clientY) * viewBoxPerPixel,
}).y
)
}
const stopDrag = (event: React.PointerEvent<HTMLDivElement>) => {
if (!dragRef.current) return
dragRef.current = null
delete event.currentTarget.dataset.dragging
commitComposition()
}
const nudge = (event: React.KeyboardEvent<HTMLDivElement>) => {
const distance = event.shiftKey ? 10 : 2
if (event.key === 'ArrowLeft') positionX.set(positionX.get() - distance)
else if (event.key === 'ArrowRight') positionX.set(positionX.get() + distance)
else if (event.key === 'ArrowUp') positionY.set(positionY.get() - distance)
else if (event.key === 'ArrowDown') positionY.set(positionY.get() + distance)
else if (event.key === '+' || event.key === '=') scale.set(scale.get() + 0.05)
else if (event.key === '-') scale.set(scale.get() - 0.05)
else return
event.preventDefault()
commitComposition()
}
return (
<div
ref={frameRef}
className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
style={
{
'--snapshot-corner-radius': `${composition.cornerRadius}%`,
...(renderStyle.type === 'pixel' ? { background: pixelBackground } : {}),
} as React.CSSProperties
}
role="application"
tabIndex={0}
aria-label={t(
'Cadre du logo. Glisse pour déplacer l’avatar et utilise la molette pour zoomer.'
)}
onPointerDown={startDrag}
onPointerMove={drag}
onPointerUp={stopDrag}
onPointerCancel={stopDrag}
onKeyDown={nudge}
style={renderStyle.type === 'pixel' ? { background: pixelBackground } : undefined}
>
{renderStyle.type === 'pixel' ? (
<motion.div
className="snapshot-pixel-transform"
style={{ x: pixelPositionX, y: pixelPositionY, scale }}
>
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-preview"
/>
</motion.div>
<LivePixelAvatarCanvas
scene={scene}
colors={colors}
style={renderStyle}
className="avatar-preview"
/>
) : (
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
<defs>
<clipPath id={frameClipId}>
<rect
x="-150"
y="-150"
width="300"
height="300"
rx={snapshotCornerRadius(composition.cornerRadius)}
/>
</clipPath>
<clipPath id={clipId}>
<motion.path d={scene.headPath} />
</clipPath>
@ -260,30 +98,22 @@ export function SnapshotPreview({
<stop offset="1" stopColor={colorTo} />
</radialGradient>
</defs>
<g clipPath={`url(#${frameClipId})`}>
{background !== 'transparent' && (
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
)}
<g ref={compositionGroupRef}>
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => (
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
))}
<motion.path d={scene.headPath} fill={colors.body} />
<g clipPath={`url(#${clipId})`}>
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
<motion.path
d={scene.rightPath}
fill={colors.eyes}
opacity={scene.rightOpacity}
/>
</g>
{scene.frontPaths.map((pathValue, index) => (
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
))}
</motion.g>
{background !== 'transparent' && (
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
)}
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
{scene.backPaths.map((pathValue, index) => (
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
))}
<motion.path d={scene.headPath} fill={colors.body} />
<g clipPath={`url(#${clipId})`}>
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
<motion.path d={scene.rightPath} fill={colors.eyes} opacity={scene.rightOpacity} />
</g>
</g>
{scene.frontPaths.map((pathValue, index) => (
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
))}
</motion.g>
</svg>
)}
</div>
@ -294,13 +124,11 @@ export function ExportSection({
value,
title,
subtitle,
badge,
children,
}: {
value: string
title: string
subtitle: string
badge?: string
children: React.ReactNode
}) {
const { t } = useStudioLanguage()
@ -308,10 +136,7 @@ export function ExportSection({
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
<AccordionTrigger className="export-accordion-trigger">
<span>
<span className="export-accordion-title">
<strong>{t(title)}</strong>
{badge && <b className="export-menu-badge">{t(badge)}</b>}
</span>
<strong>{t(title)}</strong>
<small>{t(subtitle)}</small>
</span>
</AccordionTrigger>

View File

@ -12,8 +12,7 @@ import { type BodyNode } from '@/features/avatar/body'
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo'
export type PhotoTool = 'pose' | 'frame'
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export'
export type ExportFormat = 'react' | 'javascript'
export type SnapshotFormat = 'svg' | 'png'
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
@ -44,7 +43,6 @@ export type Highlight = 'head' | 'left' | 'right' | 'both' | null
export const RETARGET_BLEND_MS = 120
export const INSPECTOR_FRAME_MS = 1000 / 24
export const AMBIENT_FRAME_MS = 1000 / 30
export const COPY_FEEDBACK_DURATION_MS = 2000
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
export const emptyBodyNodes: BodyNode[] = []
const previewGeometryCache = new WeakMap<

File diff suppressed because it is too large Load Diff

View File

@ -1,37 +0,0 @@
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
import type * as React from 'react'
import { cn } from '@/lib/utils'
function Dialog(props: DialogPrimitive.Root.Props) {
return <DialogPrimitive.Root {...props} />
}
function DialogClose(props: DialogPrimitive.Close.Props) {
return <DialogPrimitive.Close {...props} />
}
function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
return (
<DialogPrimitive.Portal>
<DialogPrimitive.Backdrop className="dialog-backdrop" />
<DialogPrimitive.Popup className={cn('dialog-content', className)} {...props}>
{children}
</DialogPrimitive.Popup>
</DialogPrimitive.Portal>
)
}
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
return <div className={cn('dialog-header', className)} {...props} />
}
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
return <DialogPrimitive.Title className={cn('dialog-title', className)} {...props} />
}
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
return <DialogPrimitive.Description className={cn('dialog-description', className)} {...props} />
}
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle }

View File

@ -1,41 +0,0 @@
import { Menu as MenuPrimitive } from '@base-ui/react/menu'
import { cn } from '../../lib/utils'
function Menu(props: MenuPrimitive.Root.Props) {
return <MenuPrimitive.Root {...props} />
}
function MenuTrigger(props: MenuPrimitive.Trigger.Props) {
return <MenuPrimitive.Trigger {...props} />
}
function MenuContent({ className, ...props }: MenuPrimitive.Popup.Props) {
return (
<MenuPrimitive.Portal>
<MenuPrimitive.Positioner className="z-50" sideOffset={6}>
<MenuPrimitive.Popup
className={cn(
'min-w-56 rounded-lg border border-border bg-popover p-1 text-popover-foreground shadow-xl outline-none',
className
)}
{...props}
/>
</MenuPrimitive.Positioner>
</MenuPrimitive.Portal>
)
}
function MenuItem({ className, ...props }: MenuPrimitive.Item.Props) {
return (
<MenuPrimitive.Item
className={cn(
'flex cursor-default items-center gap-2 rounded-md px-2.5 py-2 text-sm outline-none select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[highlighted]:bg-muted',
className
)}
{...props}
/>
)
}
export { Menu, MenuContent, MenuItem, MenuTrigger }

View File

@ -1,9 +1,7 @@
import {
advanceSequenceCursor,
createInitialSequences,
duplicateSequence,
getSequenceSpring,
NEUTRAL_EXPRESSION_ID,
normalizeSequencesForExpressions,
parseSequences,
remapSequencesAfterExpressionDelete,
@ -89,21 +87,6 @@ describe('editable avatar sequences', () => {
expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
})
it('preserves the system neutral target during normalization', () => {
const sequence = createInitialSequences()[0]
const [normalized] = normalizeSequencesForExpressions(
[
{
...sequence,
steps: [{ ...sequence.steps[0], expressionId: NEUTRAL_EXPRESSION_ID }],
},
],
[]
)
expect(normalized.steps[0].expressionId).toBe(NEUTRAL_EXPRESSION_ID)
})
it('maps transition styles and durations to distinct spring dynamics', () => {
const smooth = getSequenceSpring('smooth', 900, 7)
const snappy = getSequenceSpring('snappy', 250, 7)
@ -111,13 +94,4 @@ describe('editable avatar sequences', () => {
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
expect(smooth.damping).toBeGreaterThan(0)
})
it('clears the public semantic key when an animation is duplicated', () => {
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
const duplicate = duplicateSequence(sequence)
expect(duplicate.semanticKey).toBeUndefined()
expect(duplicate.id).not.toBe(sequence.id)
})
})

View File

@ -25,8 +25,7 @@ import { ControlSection, InspectorCard, PanelTitle } from '@/app/components/comm
import { NumericField } from '@/app/components/controls'
import {
createSequenceStep,
NEUTRAL_EXPRESSION_ID,
resolveSequenceExpression,
findExpressionIndex,
type AvatarSequence,
type SequenceStep,
} from '@/features/animation/sequences'
@ -38,7 +37,6 @@ import {
import { type BodyNode } from '@/features/avatar/body'
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
import { type Expression } from '@/features/avatar/geometry'
import { defaultExpression } from '@/features/avatar/presets'
import { type SurfaceConfig } from '@/features/avatar/surfaces'
export function SequenceWorkspace({
editing,
@ -64,7 +62,6 @@ export function SequenceWorkspace({
onSave,
onDuplicate,
onDelete,
semanticKeyError,
}: {
editing: { sourceId: string | null; draft: AvatarSequence }
expressions: Expression[]
@ -89,7 +86,6 @@ export function SequenceWorkspace({
onSave: () => void
onDuplicate: () => void
onDelete: () => void
semanticKeyError: string | null
}) {
const { t } = useStudioLanguage()
const draggedStepId = useRef<string | null>(null)
@ -165,35 +161,6 @@ export function SequenceWorkspace({
compact
>
<InspectorCard>
<Field>
<label className="semantic-key-label" htmlFor={`animation-key-${editing.draft.id}`}>
{t('Clé sémantique')}
</label>
<Input
id={`animation-key-${editing.draft.id}`}
value={editing.draft.semanticKey ?? ''}
maxLength={64}
spellCheck={false}
autoCapitalize="none"
autoCorrect="off"
aria-invalid={Boolean(semanticKeyError)}
aria-describedby={`animation-key-help-${editing.draft.id}`}
onChange={event =>
onChange({
...editing.draft,
semanticKey: event.currentTarget.value || undefined,
})
}
/>
<p
id={`animation-key-help-${editing.draft.id}`}
className={semanticKeyError ? 'semantic-key-error' : 'field-help'}
role={semanticKeyError ? 'alert' : undefined}
>
{semanticKeyError ??
t('Clé publique stable utilisée par l’API runtime, par exemple thinking.')}
</p>
</Field>
<Field>
<FieldTitle>{t('Nom')}</FieldTitle>
<Input
@ -270,8 +237,9 @@ export function SequenceWorkspace({
{editing.draft.steps.length ? (
<div className="sequence-timeline">
{editing.draft.steps.map((step, position) => {
const resolved = resolveSequenceExpression(expressions, step.expressionId)
if (!resolved.expression) return null
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
const preset = expressions[expressionIndex]
if (!preset) return null
const card = (
<Button
variant="outline"
@ -281,15 +249,11 @@ export function SequenceWorkspace({
onSelectedStepChange(step.id)
onPreviewStep(step)
}}
onDoubleClick={
resolved.neutral
? undefined
: () => onEditExpression(resolved.index, resolved.expression)
}
onDoubleClick={() => onEditExpression(expressionIndex, preset)}
>
<GripVertical className="sequence-grip" />
<ExpressionPreview
expression={resolved.expression}
expression={preset}
surface={surface}
bodyNodes={bodyNodes}
colors={colors}
@ -297,11 +261,7 @@ export function SequenceWorkspace({
renderStyle={renderStyle}
id={`sequence-${editing.draft.id}-${step.id}`}
/>
<span>
{resolved.neutral
? t('Apparence neutre')
: String(resolved.index).padStart(2, '0')}
</span>
<span>{String(expressionIndex).padStart(2, '0')}</span>
<small>{position + 1}</small>
</Button>
)
@ -327,20 +287,16 @@ export function SequenceWorkspace({
draggedStepId.current = null
}}
>
{resolved.neutral ? (
card
) : (
<ContextMenu>
<ContextMenuTrigger render={card} />
<ContextMenuContent>
<ContextMenuItem
onClick={() => onEditExpression(resolved.index, resolved.expression)}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
)}
<ContextMenu>
<ContextMenuTrigger render={card} />
<ContextMenuContent>
<ContextMenuItem
onClick={() => onEditExpression(expressionIndex, preset)}
>
<Pencil /> {t('Modifier')}
</ContextMenuItem>
</ContextMenuContent>
</ContextMenu>
</motion.div>
)
})}
@ -421,28 +377,6 @@ export function SequenceWorkspace({
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
/>
<div className="expression-grid sequence-expression-library">
<Button
className="expression-card"
variant="outline"
type="button"
onClick={() => {
const step = createSequenceStep(NEUTRAL_EXPRESSION_ID)
onChange({ ...editing.draft, steps: [...editing.draft.steps, step] })
onSelectedStepChange(step.id)
onPreviewStep(step)
}}
>
<ExpressionPreview
expression={defaultExpression}
surface={surface}
bodyNodes={bodyNodes}
colors={colors}
avatarEyes={avatarEyes}
renderStyle={renderStyle}
id="sequence-library-neutral"
/>
<span>{t('Apparence neutre')}</span>
</Button>
{expressions.map((preset, index) => (
<Button
className="expression-card"

View File

@ -1,5 +1,4 @@
import {
defaultExpression,
getStatePlaybackConfig,
initialExpressions,
stateGroups,
@ -10,7 +9,6 @@ import type { Expression } from '../avatar/geometry'
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
export const NEUTRAL_EXPRESSION_ID = defaultExpression.id
export type SequenceStep = {
id: string
@ -30,7 +28,6 @@ export type BlinkSettings = {
export type AvatarSequence = {
id: string
semanticKey?: string
name: string
group: string
description: string
@ -70,7 +67,6 @@ export const createInitialSequences = (): AvatarSequence[] =>
const playback = getStatePlaybackConfig(id)
return {
id,
semanticKey: id,
name: id,
group,
description:
@ -121,7 +117,6 @@ 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:
@ -172,16 +167,13 @@ export const normalizeSequencesForExpressions = (
expressions: Expression[]
) => {
const availableIds = new Set(expressions.map(expression => expression.id))
const fallbackId = expressions[0]?.id ?? NEUTRAL_EXPRESSION_ID
const fallbackId = expressions[0]?.id ?? initialExpressions[0].id
return sequences.map(sequence => ({
...sequence,
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
step => ({
...step,
expressionId:
step.expressionId === NEUTRAL_EXPRESSION_ID || availableIds.has(step.expressionId)
? step.expressionId
: fallbackId,
expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId,
})
),
}))
@ -190,17 +182,6 @@ export const normalizeSequencesForExpressions = (
export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
expressions.findIndex(expression => expression.id === expressionId)
export const resolveSequenceExpression = (expressions: Expression[], expressionId: string) => {
if (expressionId === NEUTRAL_EXPRESSION_ID) {
return { expression: defaultExpression, index: null, neutral: true } as const
}
const index = findExpressionIndex(expressions, expressionId)
const expression = expressions[index]
return expression
? ({ expression, index, neutral: false } as const)
: ({ expression: undefined, index: null, neutral: false } as const)
}
export const readSequenceClock = () => performance.now()
export const groupSequences = (sequences: AvatarSequence[]) => {
@ -246,7 +227,6 @@ export const createSequence = (expressionId = initialExpressions[0].id): AvatarS
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
...cloneSequence(source),
id: createId('sequence'),
semanticKey: undefined,
name: `${source.name} copy`,
builtIn: false,
steps: source.steps.map(step => ({ ...step, id: createId('step') })),

View File

@ -1,636 +0,0 @@
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 = 2.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 = 2
definition.body.primary.tipRoundness = 2
definition.body.primary.baseRoundness = 2
definition.body.nodes[0].surface.roundness = 2
definition.body.nodes[0].surface.morphRoundness = 2
definition.body.nodes[0].surface.tipRoundness = 2
definition.body.nodes[0].surface.baseRoundness = 2
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')
})
})

View File

@ -1,29 +1,17 @@
import { defaultExpression } from '@/features/avatar/presets'
import { createInitialSequences, NEUTRAL_EXPRESSION_ID } from '@/features/animation/sequences'
import { createInitialSequences } from '@/features/animation/sequences'
import {
applyAvatarEyeDefaults,
cloneAvatarBehavior,
createAvatar,
createUnkeyedExpressionCopy,
defaultAvatarEyes,
parseAvatarEyeDefaults,
parseAvatarLibrary,
parseAvatarRenderStyle,
resolveAvatarBehavior,
} from '@/features/avatar/avatars'
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)
})
@ -53,14 +41,20 @@ describe('avatar render style', () => {
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
})
it('falls back to vector rendering while pixel mode is disabled', () => {
it('sanitizes pixel settings', () => {
expect(
parseAvatarRenderStyle({
type: 'pixel',
resolution: 500,
})
).toEqual({ type: 'vector' })
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
).toEqual({
type: 'pixel',
resolution: 192,
})
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
type: 'pixel',
resolution: 8,
})
})
})
@ -85,30 +79,4 @@ describe('avatar behavior library', () => {
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
})
it('preserves an owned neutral-only behavior library on reload', () => {
const avatar = createAvatar('Neutral only')
const sequence = createInitialSequences()[0]
avatar.behavior = {
expressions: [],
sequences: [
{
...sequence,
steps: [{ ...sequence.steps[0], expressionId: NEUTRAL_EXPRESSION_ID }],
},
],
}
const fallbackAvatar = createAvatar('Fallback')
const parsed = parseAvatarLibrary(
{ activeAvatarId: avatar.id, avatars: [avatar] },
{ activeAvatarId: fallbackAvatar.id, avatars: [fallbackAvatar] },
base
)
expect(parsed.avatars[0].behavior?.expressions).toEqual([])
expect(parsed.avatars[0].behavior?.sequences[0].steps[0].expressionId).toBe(
NEUTRAL_EXPRESSION_ID
)
})
})

View File

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

View File

@ -1,145 +0,0 @@
import { type AvatarDefinition } from '@bible-strong/avatar-core'
import { describe, expect, it } from 'vitest'
import strobi from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
import { NEUTRAL_EXPRESSION_ID } from '../../animation/sequences'
import { createAvatarDefinition } from '../avatarDefinition'
import {
isAvatarDefinitionSource,
studioAvatarFromDefinition,
studioAvatarFromDefinitionSource,
} from '../importAvatarDefinition'
describe('studioAvatarFromDefinition', () => {
it('recognises a definition and rejects a studio project', () => {
expect(isAvatarDefinitionSource(strobi)).toBe(true)
expect(isAvatarDefinitionSource({ version: 2, library: { avatars: [] } })).toBe(false)
expect(isAvatarDefinitionSource(null)).toBe(false)
})
it('rejects a file that is not a valid definition', () => {
expect(() => studioAvatarFromDefinition({ schema: 'bible-strong/avatar-definition' })).toThrow()
})
it('round-trips back to the same definition', () => {
const { avatar, expressions, sequences } = studioAvatarFromDefinition(strobi)
expect(avatar.name).toBe(strobi.name)
expect(avatar.colors).toEqual(strobi.colors)
expect(avatar.body.primary).toEqual(strobi.body.primary)
expect(avatar.body.nodes).toHaveLength(strobi.body.nodes.length)
// `neutral` becomes the avatar's eye defaults rather than an expression.
expect(expressions).toHaveLength(strobi.expressionOrder.length - 1)
expect(expressions.map(e => e.semanticKey)).not.toContain('neutral')
expect(sequences).toHaveLength(strobi.animationOrder.length)
// Eye defaults are taken from `neutral`, the resting pose.
expect(avatar.eyes.spacing).toBe(strobi.expressions.neutral.eyes.spacing)
const result = createAvatarDefinition({ avatar, behavior: { expressions, sequences } })
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.body).toEqual(strobi.body)
expect(result.value.colors).toEqual(strobi.colors)
expect(result.value.expressionOrder).toEqual(strobi.expressionOrder)
expect(result.value.animationOrder).toEqual(strobi.animationOrder)
expect(result.value.expressions).toEqual(strobi.expressions)
expect(result.value.animations).toEqual(strobi.animations)
})
it('inverts customized neutral eyes instead of applying them twice', () => {
const customized = structuredClone(strobi) as AvatarDefinition
Object.values(customized.expressions).forEach(item => {
item.eyes.left.width += 10
item.eyes.right.width += 10
item.eyes.spacing += 6
item.eyes.left.y -= 4
item.eyes.right.y -= 4
})
const imported = studioAvatarFromDefinition(customized)
const result = createAvatarDefinition({
avatar: imported.avatar,
behavior: { expressions: imported.expressions, sequences: imported.sequences },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.expressions.neutral).toEqual(customized.expressions.neutral)
Object.keys(customized.expressions).forEach(key => {
expect(result.value.expressions[key].eyes.left.width).toBeCloseTo(
customized.expressions[key].eyes.left.width,
10
)
expect(result.value.expressions[key].eyes.spacing).toBeCloseTo(
customized.expressions[key].eyes.spacing,
10
)
expect(result.value.expressions[key].eyes.left.y).toBeCloseTo(
customized.expressions[key].eyes.left.y,
10
)
})
})
it('preserves animation steps targeting the neutral appearance', () => {
const customized = structuredClone(strobi) as AvatarDefinition
customized.animations.sleeping.steps[0].expression = 'neutral'
const imported = studioAvatarFromDefinition(customized)
const sleeping = imported.sequences.find(sequence => sequence.semanticKey === 'sleeping')
expect(sleeping?.steps[0].expressionId).toBe(NEUTRAL_EXPRESSION_ID)
const result = createAvatarDefinition({
avatar: imported.avatar,
behavior: { expressions: imported.expressions, sequences: imported.sequences },
})
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.value.animations.sleeping).toEqual(customized.animations.sleeping)
})
it('creates unique Studio ids for repeated imports', () => {
const first = studioAvatarFromDefinition(strobi)
const second = studioAvatarFromDefinition(strobi)
expect(first.avatar.id).not.toBe(second.avatar.id)
expect(first.expressions[0].id).not.toBe(second.expressions[0].id)
expect(first.sequences[0].id).not.toBe(second.sequences[0].id)
})
it('rejects neutral properties that the Studio cannot represent', () => {
const customized = structuredClone(strobi) as AvatarDefinition
customized.expressions.neutral.head.x = 1
expect(() => studioAvatarFromDefinition(customized)).toThrow(
'The neutral expression must use zero head rotation'
)
})
it('rejects eye dimensions that the Studio would clamp during re-export', () => {
const customized = structuredClone(strobi) as AvatarDefinition
customized.expressions['upward-side-glance'].eyes.left.width = 9
expect(() => studioAvatarFromDefinition(customized)).toThrow(
'uses an eye dimension below the Studio minimum of 10'
)
})
it('uses bounded parsing for untrusted definition text', () => {
expect(() => studioAvatarFromDefinitionSource('{')).toThrow('Unterminated object')
expect(() =>
studioAvatarFromDefinitionSource(JSON.stringify({ ...strobi, schemaVersion: 2 }))
).toThrow()
const duplicateKey = JSON.stringify(strobi).replace(
'"schemaVersion":1',
'"schemaVersion":1,"schemaVersion":1'
)
expect(() => studioAvatarFromDefinitionSource(duplicateKey)).toThrow('Duplicate object member')
expect(() => studioAvatarFromDefinitionSource(' '.repeat(262_145))).toThrow(
'JSON input exceeds'
)
})
})

View File

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

View File

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

View File

@ -25,7 +25,6 @@ export type StudioAvatar = {
}
export type AvatarColors = { body: string; eyes: string }
export const PIXEL_RENDERING_ENABLED = false
export type PixelRenderStyle = {
type: 'pixel'
resolution: number
@ -86,9 +85,7 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
const candidate = value as Partial<PixelRenderStyle> | null
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
return { ...defaultAvatarRenderStyle }
}
if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
return {
type: 'pixel',
resolution: Math.round(
@ -123,12 +120,6 @@ export const applyAvatarEyeDefaults = (
return result
}
export const createUnkeyedExpressionCopy = (source: Expression, id: string): Expression => ({
...source,
id,
semanticKey: undefined,
})
export type AvatarLibrary = {
activeAvatarId: string
avatars: StudioAvatar[]
@ -162,7 +153,6 @@ 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
@ -183,41 +173,6 @@ 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
@ -229,20 +184,15 @@ const parseAvatarBehavior = (
): AvatarBehaviorLibrary | undefined => {
if (!value || typeof value !== 'object') return undefined
const candidate = value as Partial<AvatarBehaviorLibrary>
if (!Array.isArray(candidate.expressions)) return undefined
const expressions = candidate.expressions.length ? parseExpressions(candidate.expressions) : []
return restoreLegacyBehaviorSemanticKeys(
{
expressions,
sequences: normalizeSequencesForExpressions(
Array.isArray(candidate.sequences)
? parseSequences(candidate.sequences)
: cloneSequences(base.sequences),
expressions
),
},
base
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 { expressions, sequences }
}
export const createAvatar = (name: string): StudioAvatar => ({

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@ -1,267 +0,0 @@
import {
parseAvatarDefinition,
validateAvatarDefinition,
type AvatarAnimationDefinition,
type AvatarDefinition,
type AvatarExpressionDefinition,
} from '@bible-strong/avatar-core'
import {
NEUTRAL_EXPRESSION_ID,
type AvatarSequence,
type SequenceStep,
} from '../animation/sequences'
import { defaultAvatarEyes, type StudioAvatar } from './avatars'
import type { Expression } from './geometry'
/**
* Reads a `.avatar.json` runtime definition back into studio state.
*
* This is the inverse of `createAvatarDefinition`: the definition keys expressions
* by semantic key with nested head/eyes objects, while the studio keeps a flat
* `Expression` record carrying its own id. Keep this in sync with `mapExpression`
* in ./avatarDefinition.ts — the two must round-trip.
*/
const AVATAR_DEFINITION_SCHEMA = 'bible-strong/avatar-definition'
export const isAvatarDefinitionSource = (value: unknown): boolean =>
typeof value === 'object' &&
value !== null &&
(value as { schema?: unknown }).schema === AVATAR_DEFINITION_SCHEMA
const slugify = (value: string) =>
value
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'avatar'
const createImportId = (slug: string) =>
`${slug}-${
typeof crypto !== 'undefined' && crypto.randomUUID
? crypto.randomUUID()
: `${Date.now()}-${Math.random().toString(36).slice(2)}`
}`
const relativeEyeValue = (value: number, neutral: number, fallback: number) =>
value - neutral + fallback
const toExpression = (
importId: string,
semanticKey: string,
expression: AvatarExpressionDefinition,
neutral: AvatarExpressionDefinition
): Expression => ({
id: `expression-${importId}-${semanticKey}`,
semanticKey,
headX: expression.head.x,
headY: expression.head.y,
headZ: expression.head.z,
widthLeft: relativeEyeValue(
expression.eyes.left.width,
neutral.eyes.left.width,
defaultAvatarEyes.widthLeft
),
widthRight: relativeEyeValue(
expression.eyes.right.width,
neutral.eyes.right.width,
defaultAvatarEyes.widthRight
),
heightLeft: relativeEyeValue(
expression.eyes.left.height,
neutral.eyes.left.height,
defaultAvatarEyes.heightLeft
),
heightRight: relativeEyeValue(
expression.eyes.right.height,
neutral.eyes.right.height,
defaultAvatarEyes.heightRight
),
spacing: relativeEyeValue(
expression.eyes.spacing,
neutral.eyes.spacing,
defaultAvatarEyes.spacing
),
positionXLeft: relativeEyeValue(
expression.eyes.left.x,
neutral.eyes.left.x,
defaultAvatarEyes.positionXLeft
),
positionXRight: relativeEyeValue(
expression.eyes.right.x,
neutral.eyes.right.x,
defaultAvatarEyes.positionXRight
),
positionYLeft: relativeEyeValue(
expression.eyes.left.y,
neutral.eyes.left.y,
defaultAvatarEyes.positionYLeft
),
positionYRight: relativeEyeValue(
expression.eyes.right.y,
neutral.eyes.right.y,
defaultAvatarEyes.positionYRight
),
leftAngle: relativeEyeValue(
expression.eyes.left.angle,
neutral.eyes.left.angle,
defaultAvatarEyes.leftAngle
),
rightAngle: relativeEyeValue(
expression.eyes.right.angle,
neutral.eyes.right.angle,
defaultAvatarEyes.rightAngle
),
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 } : {}),
})
const toSequence = (
importId: string,
semanticKey: string,
animation: AvatarAnimationDefinition,
expressionIdByKey: Map<string, string>
): AvatarSequence => {
const steps: SequenceStep[] = []
animation.steps.forEach((step, index) => {
const expressionId =
step.expression === 'neutral' ? NEUTRAL_EXPRESSION_ID : expressionIdByKey.get(step.expression)
// Validation guarantees that non-neutral references resolve.
if (!expressionId) throw new Error(`Unknown expression '${step.expression}'`)
steps.push({
id: `step-${importId}-${semanticKey}-${index}`,
expressionId,
holdMs: step.holdMs,
transitionMs: step.transitionMs,
transition: step.transition,
})
})
return {
id: `sequence-${importId}-${semanticKey}`,
semanticKey,
name: animation.metadata?.label ?? semanticKey,
group: animation.metadata?.group ?? 'Importé',
description: animation.metadata?.description ?? '',
builtIn: false,
playbackMode: animation.playbackMode,
steps,
blink: animation.blink,
}
}
export type ImportedAvatarDefinition = {
avatar: StudioAvatar
expressions: Expression[]
sequences: AvatarSequence[]
}
const assertRepresentableNeutral = (neutral: AvatarExpressionDefinition) => {
const canonical =
neutral.head.x === 0 &&
neutral.head.y === 0 &&
neutral.head.z === 0 &&
neutral.perspective === 1 &&
neutral.motion.eyes === 'none' &&
neutral.motion.body === 'none' &&
neutral.colors === undefined
if (!canonical) {
throw new Error(
'The neutral expression must use zero head rotation, perspective 1, no ambient motion and no color overrides.'
)
}
}
const assertRepresentableEyeDimensions = (definition: AvatarDefinition) => {
Object.entries(definition.expressions).forEach(([key, expression]) => {
const dimensions = [
expression.eyes.left.width,
expression.eyes.right.width,
expression.eyes.left.height,
expression.eyes.right.height,
]
if (dimensions.some(value => value < 10)) {
throw new Error(`Expression '${key}' uses an eye dimension below the Studio minimum of 10.`)
}
})
}
const formatValidationError = (errors: readonly { path: string; message: string }[]) => {
const first = errors[0]
return first ? `${first.path}: ${first.message}` : 'Invalid avatar definition'
}
/** Throws when the file is not a valid v1 avatar definition. */
export const studioAvatarFromDefinition = (value: unknown): ImportedAvatarDefinition => {
const result = validateAvatarDefinition(value)
if (!result.ok) {
throw new Error(formatValidationError(result.errors))
}
const definition: AvatarDefinition = result.value
const slug = slugify(definition.name ?? 'avatar')
const importId = createImportId(slug)
const neutral = definition.expressions.neutral
assertRepresentableNeutral(neutral)
assertRepresentableEyeDimensions(definition)
// `neutral` is reserved: the studio does not keep it as an editable expression,
// it lives on the avatar as eye defaults and is re-emitted on export.
const keys = definition.expressionOrder.filter(
key => key !== 'neutral' && definition.expressions[key]
)
const expressions = keys.map(key =>
toExpression(importId, key, definition.expressions[key]!, neutral)
)
const expressionIdByKey = new Map(keys.map((key, i) => [key, expressions[i]!.id]))
const sequences = definition.animationOrder
.filter(key => definition.animations[key])
.map(key => toSequence(importId, key, definition.animations[key]!, expressionIdByKey))
// The studio stores one flat set of eye defaults per avatar; `neutral` is the
// resting pose, so it is the expression those defaults come from.
const avatar: StudioAvatar = {
id: `avatar-${importId}`,
name: definition.name ?? slug,
body: {
primary: definition.body.primary,
// Definition nodes are anonymous; the studio addresses them by id in the editor.
nodes: definition.body.nodes.map((node, index) => ({
id: `shape-${importId}-${index}`,
name: `${node.surface.type} ${index + 1}`,
surface: node.surface,
position: node.position,
rotation: node.rotation,
})),
},
colors: definition.colors,
eyes: {
widthLeft: neutral.eyes.left.width,
widthRight: neutral.eyes.right.width,
heightLeft: neutral.eyes.left.height,
heightRight: neutral.eyes.right.height,
spacing: neutral.eyes.spacing,
positionXLeft: neutral.eyes.left.x,
positionXRight: neutral.eyes.right.x,
positionYLeft: neutral.eyes.left.y,
positionYRight: neutral.eyes.right.y,
leftAngle: neutral.eyes.left.angle,
rightAngle: neutral.eyes.right.angle,
},
renderStyle: { type: 'vector' },
behavior: { expressions, sequences },
}
return { avatar, expressions, sequences }
}
/** Parses untrusted `.avatar.json` text with the core size, depth and duplicate-key limits. */
export const studioAvatarFromDefinitionSource = (source: string): ImportedAvatarDefinition => {
const result = parseAvatarDefinition(source)
if (!result.ok) throw new Error(formatValidationError(result.errors))
return studioAvatarFromDefinition(result.value)
}

View File

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

View File

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

View File

@ -0,0 +1,811 @@
import { Analytics } from '@vercel/analytics/react'
import {
Activity,
ArrowLeft,
Camera,
CameraOff,
Circle,
Crosshair,
RefreshCw,
ShieldCheck,
Square,
} from 'lucide-react'
import { motion, useMotionValue } from 'motion/react'
import { StrictMode, useEffect, useRef, useState, type CSSProperties } from 'react'
import { createRoot } from 'react-dom/client'
import { poseWithAvatarEyes, resolveColors } from '@/app/studio-utils'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
import { clamp, poseFromExpression, renderAvatar } from '@/features/avatar/geometry'
import {
createFacialCaptureSmoother,
mirrorCaptureFrame,
observeExpression,
retargetCaptureFrame,
type ObservedExpression,
type FacialCaptureFrame,
} from '@/features/capture/facialCapture'
import { MotionTakeEditor } from '@/features/capture/MotionTakeEditor'
import {
defaultMotionTakeSettings,
processMotionTake,
sampleMotionTake,
type MotionSample,
type MotionTake,
type MotionTakeSettings,
} from '@/features/capture/motionTake'
import {
createRenderedScene,
paintRenderedOffset,
paintRenderedScene,
} from '@/features/rendering/renderedScene'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import './captureLab.css'
type CaptureStatus = 'idle' | 'loading' | 'tracking' | 'lost' | 'error'
type CaptureWorkerMessage =
| { type: 'ready' }
| { type: 'capture'; frame: FacialCaptureFrame }
| { type: 'missing'; timestamp: number }
| { type: 'error'; message: string }
type Telemetry = {
pitch: number
yaw: number
roll: number
positionX: number
positionY: number
positionZ: number
blinkLeft: number
blinkRight: number
lookX: number
lookY: number
jawOpen: number
fps: number
}
const emptyObservedExpression: ObservedExpression = {
id: 'neutral',
label: 'Waiting for a face',
confidence: 0,
scores: { smile: 0, laugh: 0, angry: 0, surprised: 0, sad: 0 },
}
const defaultSmoothing = 58
const emptyTelemetry: Telemetry = {
pitch: 0,
yaw: 0,
roll: 0,
positionX: 0,
positionY: 0,
positionZ: 0,
blinkLeft: 0,
blinkRight: 0,
lookX: 0,
lookY: 0,
jawOpen: 0,
fps: 0,
}
function Signal({
label,
value,
signed = false,
}: {
label: string
value: number
signed?: boolean
}) {
const normalized = signed ? (value + 1) / 2 : value
return (
<div className="capture-signal">
<div className="capture-signal-label">
<span>{label}</span>
<output>{signed ? value.toFixed(2) : `${Math.round(value * 100)}%`}</output>
</div>
<div className={`capture-signal-track ${signed ? 'is-signed' : ''}`}>
{signed && <span className="capture-signal-zero" />}
<motion.span
className="capture-signal-fill"
animate={{ scaleX: Math.max(0.015, normalized) }}
transition={{ type: 'spring', stiffness: 340, damping: 38, mass: 0.5 }}
/>
</div>
</div>
)
}
function CaptureLabApp() {
const [studioDocument] = useState(() => loadStudioDocument())
const activeAvatar =
studioDocument.library.avatars.find(
avatar => avatar.id === studioDocument.library.activeAvatarId
) ?? studioDocument.library.avatars[0]
const behavior = resolveAvatarBehavior(activeAvatar, {
expressions: studioDocument.expressions,
sequences: studioDocument.sequences,
})
const baseExpression = poseWithAvatarEyes(behavior.expressions[0], activeAvatar.eyes).expression
const captureExpression = {
...baseExpression,
headX: 0,
headY: 0,
headZ: 0,
}
const initialGeometry = renderAvatar(
poseFromExpression(baseExpression),
activeAvatar.body.primary,
1,
{ includeWire: false, bodyNodes: activeAvatar.body.nodes }
)
const captureGeometry = renderAvatar(
poseFromExpression(captureExpression),
activeAvatar.body.primary,
1,
{ includeWire: false, bodyNodes: activeAvatar.body.nodes }
)
const [scene] = useState(() => createRenderedScene(initialGeometry))
const captureScale = useMotionValue(1)
const [status, setStatus] = useState<CaptureStatus>('idle')
const [errorMessage, setErrorMessage] = useState('')
const [telemetry, setTelemetry] = useState<Telemetry>(emptyTelemetry)
const [observedExpression, setObservedExpression] = useState(emptyObservedExpression)
const [calibrated, setCalibrated] = useState(false)
const [smoothing, setSmoothing] = useState(defaultSmoothing)
const [recording, setRecording] = useState(false)
const [recordingDuration, setRecordingDuration] = useState(0)
const [take, setTake] = useState<MotionTake | null>(null)
const [processedTake, setProcessedTake] = useState<MotionTake | null>(null)
const [takeSettings, setTakeSettings] = useState<MotionTakeSettings>(defaultMotionTakeSettings)
const [playhead, setPlayhead] = useState(0)
const [playing, setPlaying] = useState(false)
const videoRef = useRef<HTMLVideoElement>(null)
const streamRef = useRef<MediaStream | null>(null)
const workerRef = useRef<Worker | null>(null)
const frameRequestRef = useRef<number | null>(null)
const workerReadyRef = useRef(false)
const cameraReadyRef = useRef(false)
const processingRef = useRef(false)
const lastVideoTimeRef = useRef(-1)
const neutralRef = useRef<FacialCaptureFrame | null>(null)
const latestFrameRef = useRef<FacialCaptureFrame | null>(null)
const lastTelemetryAtRef = useRef(0)
const lastCaptureAtRef = useRef(0)
const smootherRef = useRef(createFacialCaptureSmoother(defaultSmoothing / 100))
const recordingRef = useRef(false)
const recordingStartedAtRef = useRef<number | null>(null)
const recordingSamplesRef = useRef<MotionSample[]>([])
const lastRecordingUiAtRef = useRef(0)
const playbackFrameRef = useRef<number | null>(null)
const processedTakeRef = useRef<MotionTake | null>(null)
const lastPlaybackUiAtRef = useRef(0)
const colors = resolveColors(baseExpression, activeAvatar.colors)
const resetAvatar = () => {
paintRenderedScene(scene, initialGeometry)
paintRenderedOffset(scene, { x: 0, y: 0 })
captureScale.set(1)
setTelemetry(emptyTelemetry)
setObservedExpression(emptyObservedExpression)
}
const paintMotionSample = (sample: MotionSample) => {
const expression = {
...captureExpression,
headX: captureExpression.headX + sample.pitch,
headY: captureExpression.headY + sample.yaw,
headZ: captureExpression.headZ + sample.roll,
positionXLeft: captureExpression.positionXLeft + sample.lookX * 7.5,
positionXRight: captureExpression.positionXRight + sample.lookX * 7.5,
positionYLeft: captureExpression.positionYLeft - sample.lookY * 6,
positionYRight: captureExpression.positionYRight - sample.lookY * 6,
}
paintRenderedScene(
scene,
renderAvatar(poseFromExpression(expression), activeAvatar.body.primary, 1 - sample.blink, {
includeWire: false,
bodyNodes: activeAvatar.body.nodes,
})
)
paintRenderedOffset(scene, { x: sample.positionX, y: sample.positionY })
captureScale.set(clamp(1 + sample.positionZ, 0.62, 1.52))
}
const paintCapture = (captured: FacialCaptureFrame) => {
const mirrored = mirrorCaptureFrame(captured)
const frame = smootherRef.current.next(mirrored)
latestFrameRef.current = frame
if (!neutralRef.current) {
neutralRef.current = frame
setCalibrated(true)
}
const neutral = neutralRef.current
const retargeted = retargetCaptureFrame(frame, neutral, captureExpression)
if (recordingRef.current) {
const raw = retargetCaptureFrame(mirrored, neutral, captureExpression)
const startedAt = recordingStartedAtRef.current ?? mirrored.timestamp
recordingStartedAtRef.current = startedAt
const time = mirrored.timestamp - startedAt
recordingSamplesRef.current.push({
time,
pitch: raw.expression.headX - captureExpression.headX,
yaw: raw.expression.headY - captureExpression.headY,
roll: raw.expression.headZ - captureExpression.headZ,
positionX: raw.signals.positionX,
positionY: raw.signals.positionY,
positionZ: raw.signals.positionZ,
lookX: raw.signals.lookX,
lookY: raw.signals.lookY,
blink: 1 - raw.blinkAmount,
})
if (mirrored.timestamp - lastRecordingUiAtRef.current >= 90) {
lastRecordingUiAtRef.current = mirrored.timestamp
setRecordingDuration(time)
}
}
paintRenderedScene(
scene,
renderAvatar(
poseFromExpression(retargeted.expression),
activeAvatar.body.primary,
retargeted.blinkAmount,
{ includeWire: false, bodyNodes: activeAvatar.body.nodes }
)
)
paintRenderedOffset(scene, {
x: retargeted.signals.positionX,
y: retargeted.signals.positionY,
})
captureScale.set(clamp(1 + retargeted.signals.positionZ, 0.62, 1.52))
if (frame.timestamp - lastTelemetryAtRef.current >= 90) {
const elapsed = frame.timestamp - lastCaptureAtRef.current
const fps = elapsed > 0 ? 1000 / elapsed : 0
lastTelemetryAtRef.current = frame.timestamp
setTelemetry({
pitch: frame.head.pitch - neutral.head.pitch,
yaw: frame.head.yaw - neutral.head.yaw,
roll: frame.head.roll - neutral.head.roll,
positionX: retargeted.signals.positionX,
positionY: retargeted.signals.positionY,
positionZ: retargeted.signals.positionZ,
blinkLeft: retargeted.signals.blinkLeft,
blinkRight: retargeted.signals.blinkRight,
lookX: retargeted.signals.lookX,
lookY: retargeted.signals.lookY,
jawOpen: frame.mouth.jawOpen,
fps,
})
setObservedExpression(observeExpression(frame))
}
lastCaptureAtRef.current = frame.timestamp
}
const stopFrameLoop = () => {
if (frameRequestRef.current !== null) cancelAnimationFrame(frameRequestRef.current)
frameRequestRef.current = null
processingRef.current = false
lastVideoTimeRef.current = -1
}
const stopCameraStream = () => {
streamRef.current?.getTracks().forEach(track => track.stop())
streamRef.current = null
if (videoRef.current) videoRef.current.srcObject = null
cameraReadyRef.current = false
}
const frameLoop = () => {
const video = videoRef.current
const worker = workerRef.current
if (
video &&
worker &&
video.readyState >= HTMLMediaElement.HAVE_CURRENT_DATA &&
!processingRef.current &&
video.currentTime !== lastVideoTimeRef.current
) {
processingRef.current = true
lastVideoTimeRef.current = video.currentTime
createImageBitmap(video)
.then(image => {
worker.postMessage({ type: 'frame', image, timestamp: performance.now() }, [image])
})
.catch(error => {
processingRef.current = false
setStatus('error')
setErrorMessage(error instanceof Error ? error.message : 'Unable to read the camera.')
})
}
frameRequestRef.current = requestAnimationFrame(frameLoop)
}
const startFrameLoop = () => {
if (frameRequestRef.current !== null || !workerReadyRef.current || !cameraReadyRef.current)
return
frameRequestRef.current = requestAnimationFrame(frameLoop)
}
const ensureWorker = () => {
if (workerRef.current) return
const worker = new Worker(new URL('./faceLandmarker.worker.ts', import.meta.url), {
type: 'module',
})
workerRef.current = worker
worker.addEventListener('message', (event: MessageEvent<CaptureWorkerMessage>) => {
if (event.data.type === 'ready') {
workerReadyRef.current = true
startFrameLoop()
return
}
processingRef.current = false
if (event.data.type === 'capture') {
setStatus('tracking')
paintCapture(event.data.frame)
} else if (event.data.type === 'missing') {
setStatus('lost')
} else {
setStatus('error')
setErrorMessage(event.data.message)
stopFrameLoop()
stopCameraStream()
}
})
worker.postMessage({ type: 'initialize' })
}
const startCamera = async () => {
if (!navigator.mediaDevices?.getUserMedia) {
setStatus('error')
setErrorMessage('Camera access is not supported in this browser.')
return
}
setStatus('loading')
setErrorMessage('')
setTake(null)
setProcessedTake(null)
setPlayhead(0)
neutralRef.current = null
latestFrameRef.current = null
smootherRef.current.reset()
setCalibrated(false)
setTelemetry(emptyTelemetry)
paintRenderedScene(scene, captureGeometry)
paintRenderedOffset(scene, { x: 0, y: 0 })
captureScale.set(1)
ensureWorker()
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: { facingMode: 'user', width: { ideal: 960 }, height: { ideal: 720 } },
audio: false,
})
streamRef.current = stream
const video = videoRef.current
if (!video) return
video.srcObject = stream
await video.play()
cameraReadyRef.current = true
startFrameLoop()
} catch (error) {
stopFrameLoop()
stopCameraStream()
setStatus('error')
setErrorMessage(
error instanceof DOMException && error.name === 'NotAllowedError'
? 'Camera permission was declined. You can allow it from the browser address bar.'
: error instanceof Error
? error.message
: 'Unable to start the camera.'
)
}
}
const stopCamera = () => {
stopFrameLoop()
stopCameraStream()
neutralRef.current = null
latestFrameRef.current = null
smootherRef.current.reset()
setCalibrated(false)
setStatus('idle')
recordingRef.current = false
setRecording(false)
resetAvatar()
}
const calibrate = () => {
if (!latestFrameRef.current) return
neutralRef.current = latestFrameRef.current
setCalibrated(true)
}
const updateSmoothing = (nextSmoothing: number) => {
setSmoothing(nextSmoothing)
smootherRef.current.setSmoothing(nextSmoothing / 100)
}
const stopPlayback = () => {
if (playbackFrameRef.current !== null) cancelAnimationFrame(playbackFrameRef.current)
playbackFrameRef.current = null
setPlaying(false)
}
const seekTake = (time: number) => {
stopPlayback()
const processed = processedTakeRef.current
if (!processed) return
const bounded = Math.max(0, Math.min(processed.duration, time))
setPlayhead(bounded)
const sample = sampleMotionTake(processed, bounded)
if (sample) paintMotionSample(sample)
}
const togglePlayback = () => {
if (playing) {
stopPlayback()
return
}
const processed = processedTakeRef.current
if (!processed) return
const initialTime = playhead >= processed.duration ? 0 : playhead
const startedAt = performance.now() - initialTime
setPlaying(true)
const tick = (now: number) => {
const current = Math.min(processed.duration, now - startedAt)
const sample = sampleMotionTake(processed, current)
if (sample) paintMotionSample(sample)
if (now - lastPlaybackUiAtRef.current >= 45 || current >= processed.duration) {
lastPlaybackUiAtRef.current = now
setPlayhead(current)
}
if (current >= processed.duration) {
playbackFrameRef.current = null
setPlaying(false)
return
}
playbackFrameRef.current = requestAnimationFrame(tick)
}
playbackFrameRef.current = requestAnimationFrame(tick)
}
const beginRecording = () => {
if (status !== 'tracking' || !neutralRef.current) return
stopPlayback()
recordingSamplesRef.current = []
recordingStartedAtRef.current = null
lastRecordingUiAtRef.current = 0
recordingRef.current = true
setRecordingDuration(0)
setRecording(true)
}
const finishRecording = () => {
recordingRef.current = false
setRecording(false)
const samples = recordingSamplesRef.current
if (samples.length < 2) return
const recordedTake = { duration: samples.at(-1)?.time ?? 0, samples: [...samples] }
const processed = processMotionTake(recordedTake, takeSettings)
processedTakeRef.current = processed
setTake(recordedTake)
setProcessedTake(processed)
setPlayhead(0)
stopFrameLoop()
stopCameraStream()
setStatus('idle')
const first = sampleMotionTake(processed, 0)
if (first) paintMotionSample(first)
}
const updateTakeSettings = (settings: MotionTakeSettings) => {
setTakeSettings(settings)
if (!take) return
const processed = processMotionTake(take, settings)
processedTakeRef.current = processed
setProcessedTake(processed)
const nextPlayhead = Math.min(playhead, processed.duration)
setPlayhead(nextPlayhead)
const sample = sampleMotionTake(processed, nextPlayhead)
if (sample) paintMotionSample(sample)
}
const retake = () => {
stopPlayback()
setTake(null)
setProcessedTake(null)
processedTakeRef.current = null
setPlayhead(0)
requestAnimationFrame(() => void startCamera())
}
useEffect(
() => () => {
stopFrameLoop()
if (playbackFrameRef.current !== null) cancelAnimationFrame(playbackFrameRef.current)
streamRef.current?.getTracks().forEach(track => track.stop())
workerRef.current?.terminate()
},
[]
)
const statusLabel = {
idle: 'Camera off',
loading: 'Loading tracker',
tracking: 'Face locked',
lost: 'Find your face',
error: 'Tracker unavailable',
}[status]
const isCameraActive = status === 'tracking' || status === 'lost' || status === 'loading'
return (
<main
className="capture-lab"
style={
{
'--capture-body': colors.body,
'--capture-eyes': colors.eyes,
} as CSSProperties
}
>
<header className="capture-header">
<a className="capture-back" href="../">
<ArrowLeft />
Avatar Lab
</a>
<div className="capture-title-lockup">
<span>Experimental instrument · 01</span>
<h1>Performance Capture</h1>
</div>
<Badge className="capture-privacy-badge" variant="outline">
<ShieldCheck /> Local processing
</Badge>
</header>
<section className="capture-workbench">
<div className="capture-stage">
<div className="capture-stage-grid" aria-hidden="true" />
<div className="capture-avatar-label">
<span>{take ? 'Refined playback' : 'Live retarget'}</span>
<strong>{activeAvatar.name}</strong>
</div>
<svg className="capture-avatar" viewBox="-150 -150 300 300" role="img">
<title>{activeAvatar.name} driven by facial capture</title>
<defs>
<clipPath id="capture-head-clip">
<motion.path d={scene.headPath} />
</clipPath>
</defs>
<motion.g
style={{
x: scene.offsetX,
y: scene.offsetY,
scale: captureScale,
transformOrigin: '0px 0px',
}}
>
{scene.backPaths.map((path, index) => (
<motion.path className="capture-avatar-body" d={path} key={`back-${index}`} />
))}
<motion.path className="capture-avatar-body" d={scene.headPath} />
<g clipPath="url(#capture-head-clip)">
<motion.path
className="capture-avatar-eye"
d={scene.leftPath}
opacity={scene.leftOpacity}
/>
<motion.path
className="capture-avatar-eye"
d={scene.rightPath}
opacity={scene.rightOpacity}
/>
</g>
{scene.frontPaths.map((path, index) => (
<motion.path className="capture-avatar-body" d={path} key={`front-${index}`} />
))}
</motion.g>
</svg>
<div className={`capture-lock-status is-${take ? 'take' : status}`}>
<span />
{take ? 'Take ready' : statusLabel}
</div>
<div className={`capture-camera-card ${take ? 'is-hidden' : ''}`}>
<video ref={videoRef} muted playsInline aria-label="Webcam preview" />
<div className="capture-camera-reticle" aria-hidden="true">
<i />
<i />
<i />
<i />
</div>
{!isCameraActive && (
<div className="capture-camera-placeholder">
<Camera />
<span>Camera preview</span>
</div>
)}
<span className="capture-camera-caption">SOURCE / LOCAL</span>
</div>
</div>
<aside className="capture-console">
{take && processedTake ? (
<MotionTakeEditor
take={take}
processedTake={processedTake}
settings={takeSettings}
playhead={playhead}
playing={playing}
onSettingsChange={updateTakeSettings}
onSeek={seekTake}
onTogglePlayback={togglePlayback}
onRetake={retake}
/>
) : (
<>
<div className="capture-console-heading">
<div>
<span>Signal console</span>
<h2>Face → procedural rig</h2>
</div>
<Activity className={status === 'tracking' ? 'is-active' : ''} />
</div>
<div className="capture-orientation">
<div>
<span>Pitch</span>
<strong>{telemetry.pitch.toFixed(1)}°</strong>
</div>
<div>
<span>Yaw</span>
<strong>{telemetry.yaw.toFixed(1)}°</strong>
</div>
<div>
<span>Roll</span>
<strong>{telemetry.roll.toFixed(1)}°</strong>
</div>
</div>
<div className="capture-position">
<div>
<span>Move X</span>
<strong>{telemetry.positionX.toFixed(1)}</strong>
</div>
<div>
<span>Move Y</span>
<strong>{telemetry.positionY.toFixed(1)}</strong>
</div>
<div>
<span>Depth</span>
<strong>{telemetry.positionZ.toFixed(2)}</strong>
</div>
</div>
<div className="capture-signal-group">
<Signal label="Left blink" value={telemetry.blinkLeft} />
<Signal label="Right blink" value={telemetry.blinkRight} />
<Signal label="Gaze horizontal" value={telemetry.lookX} signed />
<Signal label="Gaze vertical" value={telemetry.lookY} signed />
</div>
<div className="capture-smoothing-control">
<div className="capture-smoothing-heading">
<label htmlFor="capture-smoothing">Motion smoothing</label>
<output htmlFor="capture-smoothing">{smoothing}%</output>
</div>
<input
id="capture-smoothing"
type="range"
min="0"
max="100"
step="1"
value={smoothing}
onChange={event => updateSmoothing(Number(event.currentTarget.value))}
/>
<div className="capture-smoothing-scale" aria-hidden="true">
<span>Responsive</span>
<span>Fluid</span>
</div>
</div>
<div className="capture-future-signal">
<div>
<span>Reserved channel</span>
<strong>Mouth / jaw</strong>
</div>
<output>{Math.round(telemetry.jawOpen * 100)}%</output>
</div>
<section className="capture-expression-observer" aria-live="polite">
<div className="capture-expression-summary">
<div>
<span>Expression observer</span>
<strong>{observedExpression.label}</strong>
</div>
<output>{Math.round(observedExpression.confidence * 100)}%</output>
</div>
<div className="capture-expression-scores">
{Object.entries(observedExpression.scores).map(([label, value]) => (
<div key={label}>
<span>{label === 'laugh' ? 'Laugh-like' : label}</span>
<i aria-hidden="true">
<motion.b
animate={{ scaleX: Math.max(0.01, value) }}
transition={{ type: 'spring', stiffness: 280, damping: 34, mass: 0.55 }}
/>
</i>
<output>{Math.round(value * 100)}</output>
</div>
))}
</div>
<p>Visual estimate only. It does not affect the avatar rig.</p>
</section>
<div className="capture-calibration-card">
<Crosshair />
<div>
<strong>
{calibrated ? 'Neutral pose calibrated' : 'Neutral pose required'}
</strong>
<p>Look forward, relax your eyes, then capture a new neutral reference.</p>
</div>
<Button
variant="outline"
size="icon"
aria-label="Calibrate neutral pose"
disabled={!latestFrameRef.current}
onClick={calibrate}
>
<RefreshCw />
</Button>
</div>
{errorMessage && <p className="capture-error-message">{errorMessage}</p>}
<div className="capture-console-actions">
{isCameraActive ? (
<>
<Button
className={`capture-record-button ${recording ? 'is-recording' : ''}`}
disabled={status !== 'tracking'}
onClick={recording ? finishRecording : beginRecording}
>
{recording ? <Square /> : <Circle />}
{recording ? 'Finish take' : 'Record a take'}
</Button>
<Button className="capture-stop-button" variant="outline" onClick={stopCamera}>
<CameraOff /> Stop camera
</Button>
</>
) : (
<Button className="capture-start-button" onClick={startCamera}>
<Camera /> Start capture
</Button>
)}
<span>
{recording
? `REC ${(recordingDuration / 1000).toFixed(1)}s`
: telemetry.fps
? `${Math.round(telemetry.fps)} FPS`
: '— FPS'}
</span>
</div>
<p className="capture-privacy-note">
Frames are analyzed on this device and are never stored or uploaded.
</p>
</>
)}
</aside>
</section>
</main>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<CaptureLabApp />
<Analytics />
</StrictMode>
)

View File

@ -0,0 +1,221 @@
import { Pause, Play, RotateCcw, Video } from 'lucide-react'
import type { PointerEvent } from 'react'
import { Button } from '@/components/ui/button'
import {
motionChannels,
type MotionChannel,
type MotionTake,
type MotionTakeSettings,
} from '@/features/capture/motionTake'
const channels: { channel: MotionChannel; label: string; range: number; color: string }[] = [
{ channel: 'pitch', label: 'Pitch', range: 38, color: '#567ef0' },
{ channel: 'yaw', label: 'Yaw', range: 52, color: '#ef725f' },
{ channel: 'roll', label: 'Roll', range: 42, color: '#d2a83f' },
{ channel: 'positionX', label: 'Move X', range: 55, color: '#d05a87' },
{ channel: 'positionY', label: 'Move Y', range: 55, color: '#62a8c6' },
{ channel: 'positionZ', label: 'Depth', range: 0.48, color: '#d67b3b' },
{ channel: 'lookX', label: 'Gaze X', range: 1, color: '#2f9f83' },
{ channel: 'lookY', label: 'Gaze Y', range: 1, color: '#9670d6' },
{ channel: 'blink', label: 'Blink', range: 1, color: '#34383f' },
]
const curvePoints = (take: MotionTake, channel: MotionChannel, range: number) =>
take.samples.map(sample => {
const x = take.duration ? (sample.time / take.duration) * 100 : 0
const normalized = channel === 'blink' ? sample[channel] * 2 - 1 : sample[channel] / range
return { x, y: 12 - Math.max(-1, Math.min(1, normalized)) * 9 }
})
const curvePath = (take: MotionTake, channel: MotionChannel, range: number, rounded = false) => {
if (take.samples.length === 0) return ''
const points = curvePoints(take, channel, range)
if (!rounded || points.length < 3)
return points
.map(
(point, index) => `${index === 0 ? 'M' : 'L'}${point.x.toFixed(2)} ${point.y.toFixed(2)}`
)
.join(' ')
return points.slice(0, -1).reduce(
(path, point, index) => {
const before = points[Math.max(0, index - 1)]
const next = points[index + 1]
const after = points[Math.min(points.length - 1, index + 2)]
const controlA = {
x: point.x + (next.x - before.x) / 6,
y: point.y + (next.y - before.y) / 6,
}
const controlB = {
x: next.x - (after.x - point.x) / 6,
y: next.y - (after.y - point.y) / 6,
}
return `${path} C${controlA.x.toFixed(2)} ${controlA.y.toFixed(2)} ${controlB.x.toFixed(2)} ${controlB.y.toFixed(2)} ${next.x.toFixed(2)} ${next.y.toFixed(2)}`
},
`M${points[0].x.toFixed(2)} ${points[0].y.toFixed(2)}`
)
}
function EditorSlider({
label,
value,
min,
max,
step,
format,
onChange,
}: {
label: string
value: number
min: number
max: number
step: number
format: (value: number) => string
onChange: (value: number) => void
}) {
const id = `take-${label.toLowerCase()}`
return (
<div className="take-editor-slider">
<div>
<label htmlFor={id}>{label}</label>
<output htmlFor={id}>{format(value)}</output>
</div>
<input
id={id}
type="range"
min={min}
max={max}
step={step}
value={value}
onChange={event => onChange(Number(event.currentTarget.value))}
/>
</div>
)
}
export function MotionTakeEditor({
take,
processedTake,
settings,
playhead,
playing,
onSettingsChange,
onSeek,
onTogglePlayback,
onRetake,
}: {
take: MotionTake
processedTake: MotionTake
settings: MotionTakeSettings
playhead: number
playing: boolean
onSettingsChange: (settings: MotionTakeSettings) => void
onSeek: (time: number) => void
onTogglePlayback: () => void
onRetake: () => void
}) {
const seekFromPointer = (event: PointerEvent<HTMLDivElement>) => {
const bounds = event.currentTarget.getBoundingClientRect()
onSeek(((event.clientX - bounds.left) / bounds.width) * processedTake.duration)
}
const playheadX = processedTake.duration ? (playhead / processedTake.duration) * 100 : 0
return (
<div className="take-editor">
<div className="take-editor-heading">
<div>
<span>Recorded take · 01</span>
<h2>Refine the gesture</h2>
</div>
<div className="take-editor-duration">
<strong>{(processedTake.duration / 1000).toFixed(2)}s</strong>
<span>{take.samples.length} samples</span>
</div>
</div>
<div className="take-editor-legend">
<span className="is-raw">Raw capture</span>
<span className="is-refined">Refined curve</span>
</div>
<div
className="take-timeline"
onPointerDown={event => {
event.currentTarget.setPointerCapture(event.pointerId)
seekFromPointer(event)
}}
onPointerMove={event => {
if (event.currentTarget.hasPointerCapture(event.pointerId)) seekFromPointer(event)
}}
>
<span className="take-playhead" style={{ left: `${playheadX}%` }} />
{channels.map(({ channel, label, range, color }) => (
<div className="take-channel" key={channel}>
<span>{label}</span>
<svg viewBox="0 0 100 24" preserveAspectRatio="none" aria-label={`${label} curve`}>
<line x1="0" y1="12" x2="100" y2="12" />
<path className="take-raw-curve" d={curvePath(take, channel, range)} />
<path
className="take-refined-curve"
d={curvePath(processedTake, channel, range, true)}
style={{ stroke: color }}
/>
</svg>
</div>
))}
</div>
<div className="take-editor-controls">
<EditorSlider
label="Curve smoothness"
value={settings.smoothing}
min={0}
max={1}
step={0.01}
format={value => `${Math.round(value * 100)}%`}
onChange={smoothing => onSettingsChange({ ...settings, smoothing })}
/>
<EditorSlider
label="Detail retention"
value={settings.detailRetention}
min={0}
max={1}
step={0.01}
format={value => `${Math.round(value * 100)}%`}
onChange={detailRetention => onSettingsChange({ ...settings, detailRetention })}
/>
<EditorSlider
label="Amplitude"
value={settings.amplitude}
min={0}
max={2}
step={0.01}
format={value => `${value.toFixed(2)}×`}
onChange={amplitude => onSettingsChange({ ...settings, amplitude })}
/>
<EditorSlider
label="Speed"
value={settings.speed}
min={0.25}
max={2.5}
step={0.05}
format={value => `${value.toFixed(2)}×`}
onChange={speed => onSettingsChange({ ...settings, speed })}
/>
</div>
<div className="take-editor-actions">
<Button className="take-play-button" onClick={onTogglePlayback}>
{playing ? <Pause /> : <Play />}
{playing ? 'Pause preview' : 'Preview take'}
</Button>
<Button variant="outline" onClick={onRetake}>
<RotateCcw /> Retake
</Button>
<span>
<Video /> Offline curve processing
</span>
</div>
</div>
)
}

View File

@ -0,0 +1,141 @@
import { defaultExpression } from '@/features/avatar/presets'
import {
captureFrameFromMediaPipe,
createFacialCaptureSmoother,
headCaptureFromMatrix,
mirrorCaptureFrame,
observeExpression,
positionCaptureFromMatrix,
retargetCaptureFrame,
} from '@/features/capture/facialCapture'
const identity = [1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1]
describe('facial capture adapter', () => {
it('extracts a neutral orientation from an identity transform', () => {
expect(headCaptureFromMatrix(identity)).toEqual({ pitch: 0, yaw: -0, roll: 0 })
})
it('extracts metric translation from the facial transform', () => {
const translated = [...identity]
translated[12] = 2.5
translated[13] = -1.25
translated[14] = -42
expect(positionCaptureFromMatrix(translated)).toEqual({ x: 2.5, y: -1.25, z: -42 })
})
it('keeps eye and future mouth signals from MediaPipe', () => {
const frame = captureFrameFromMediaPipe(
{
eyeBlinkLeft: 0.8,
eyeWideRight: 0.35,
eyeLookOutLeft: 0.6,
eyeLookInRight: 0.6,
jawOpen: 0.7,
mouthSmileLeft: 0.4,
},
identity,
42
)
expect(frame.eyes.left.blink).toBe(0.8)
expect(frame.eyes.right.wide).toBe(0.35)
expect(frame.eyes.lookX).toBe(0.6)
expect(frame.mouth.jawOpen).toBe(0.7)
expect(frame.mouth.smileLeft).toBe(0.4)
expect(frame.brows.downLeft).toBe(0)
})
it('observes expressive facial patterns without changing retargeting', () => {
const smiling = captureFrameFromMediaPipe(
{
mouthSmileLeft: 0.92,
mouthSmileRight: 0.88,
eyeSquintLeft: 0.5,
eyeSquintRight: 0.46,
jawOpen: 0.04,
},
identity,
16
)
const angry = captureFrameFromMediaPipe(
{
browDownLeft: 0.9,
browDownRight: 0.84,
mouthPressLeft: 0.72,
mouthPressRight: 0.68,
},
identity,
32
)
expect(observeExpression(smiling).id).toBe('smile')
expect(observeExpression(angry).id).toBe('angry')
})
it('retargets rotation, gaze and blink relative to calibration', () => {
const neutral = captureFrameFromMediaPipe({}, identity, 0)
const performance = captureFrameFromMediaPipe(
{ eyeBlinkLeft: 0.9, eyeBlinkRight: 0.85, eyeLookUpLeft: 0.5, eyeLookUpRight: 0.5 },
identity,
16
)
performance.head.yaw = 20
performance.position = { x: 2, y: -3, z: 4 }
const retargeted = retargetCaptureFrame(performance, neutral, defaultExpression)
expect(retargeted.expression.headY).toBe(-20)
expect(retargeted.expression.positionYLeft).toBeLessThan(defaultExpression.positionYLeft)
expect(retargeted.blinkAmount).toBeCloseTo(0.035)
expect(retargeted.signals.positionX).toBe(10)
expect(retargeted.signals.positionY).toBe(15)
expect(retargeted.signals.positionZ).toBeCloseTo(0.14)
})
it('mirrors horizontal movement without double-inverting renderer roll or blink channels', () => {
const frame = captureFrameFromMediaPipe(
{ eyeBlinkLeft: 0.8, eyeBlinkRight: 0.2, eyeLookOutLeft: 0.6, eyeLookInRight: 0.6 },
identity,
16
)
frame.head = { pitch: 8, yaw: 21, roll: -12 }
frame.position = { x: 4, y: -2, z: -40 }
const mirrored = mirrorCaptureFrame(frame)
expect(mirrored.head).toEqual({ pitch: 8, yaw: -21, roll: -12 })
expect(mirrored.eyes.lookX).toBe(-0.6)
expect(mirrored.eyes.left.blink).toBe(0.8)
expect(mirrored.eyes.right.blink).toBe(0.2)
expect(mirrored.position).toEqual({ x: -4, y: -2, z: -40 })
})
it('smooths discontinuities while converging toward the latest frame', () => {
const smoother = createFacialCaptureSmoother()
const first = captureFrameFromMediaPipe({}, identity, 0)
const next = captureFrameFromMediaPipe({ eyeBlinkLeft: 1 }, identity, 16)
next.head.pitch = 30
smoother.next(first)
const smoothed = smoother.next(next)
expect(smoothed.head.pitch).toBeGreaterThan(0)
expect(smoothed.head.pitch).toBeLessThan(30)
expect(smoothed.eyes.left.blink).toBeGreaterThan(0)
expect(smoothed.eyes.left.blink).toBeLessThan(1)
})
it('makes movement more inertial as smoothing increases', () => {
const responsive = createFacialCaptureSmoother(0)
const fluid = createFacialCaptureSmoother(1)
const first = captureFrameFromMediaPipe({}, identity, 0)
const next = captureFrameFromMediaPipe({}, identity, 16)
next.head.yaw = 30
responsive.next(first)
fluid.next(first)
expect(responsive.next(next).head.yaw).toBeGreaterThan(fluid.next(next).head.yaw)
})
})

View File

@ -0,0 +1,85 @@
import {
defaultMotionTakeSettings,
processMotionTake,
sampleMotionTake,
type MotionTake,
} from '@/features/capture/motionTake'
const take: MotionTake = {
duration: 40,
samples: [
{
time: 0,
pitch: 0,
yaw: 0,
roll: 0,
positionX: 0,
positionY: 0,
positionZ: 0,
lookX: 0,
lookY: 0,
blink: 0,
},
{
time: 20,
pitch: 30,
yaw: -20,
roll: 10,
positionX: 24,
positionY: -12,
positionZ: 0.25,
lookX: 1,
lookY: -1,
blink: 1,
},
{
time: 40,
pitch: 0,
yaw: 0,
roll: 0,
positionX: 0,
positionY: 0,
positionZ: 0,
lookX: 0,
lookY: 0,
blink: 0,
},
],
}
describe('recorded motion take processing', () => {
it('uses future and past samples to soften a captured spike', () => {
const processed = processMotionTake(take, {
...defaultMotionTakeSettings,
smoothing: 1,
detailRetention: 0,
})
expect(processed.samples[1].pitch).toBeGreaterThan(0)
expect(processed.samples[1].pitch).toBeLessThan(30)
expect(processed.samples[0].pitch).toBe(0)
})
it('preserves raw movement at full detail while applying amplitude and speed', () => {
const processed = processMotionTake(take, {
smoothing: 1,
detailRetention: 1,
amplitude: 1.5,
speed: 2,
})
expect(processed.samples[1].pitch).toBe(45)
expect(processed.samples[1].blink).toBe(1)
expect(processed.samples[1].positionX).toBe(36)
expect(processed.duration).toBe(20)
})
it('interpolates processed channels at an arbitrary playhead time', () => {
const sample = sampleMotionTake(take, 10)
expect(sample?.pitch).toBeGreaterThan(14)
expect(sample?.pitch).toBeLessThan(18)
expect(sample?.yaw).toBeLessThan(-9)
expect(sample?.blink).toBeGreaterThan(0.45)
})
})

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,64 @@
import { FaceLandmarker, FilesetResolver } from '@mediapipe/tasks-vision'
import { captureFrameFromMediaPipe } from './facialCapture'
const WASM_ROOT = 'https://cdn.jsdelivr.net/npm/@mediapipe/tasks-vision@1.0.1/wasm'
const MODEL_URL =
'https://storage.googleapis.com/mediapipe-models/face_landmarker/face_landmarker/float16/1/face_landmarker.task'
type WorkerRequest =
{ type: 'initialize' } | { type: 'frame'; image: ImageBitmap; timestamp: number }
let landmarker: FaceLandmarker | null = null
const initialize = async () => {
if (landmarker) return
// Module workers cannot expose the classic loader's local ModuleFactory.
// The module build publishes it on globalThis for MediaPipe's task runner.
const vision = await FilesetResolver.forVisionTasks(WASM_ROOT, true)
landmarker = await FaceLandmarker.createFromOptions(vision, {
baseOptions: { modelAssetPath: MODEL_URL },
runningMode: 'VIDEO',
numFaces: 1,
minFaceDetectionConfidence: 0.55,
minFacePresenceConfidence: 0.55,
minTrackingConfidence: 0.55,
outputFaceBlendshapes: true,
outputFacialTransformationMatrixes: true,
})
}
self.addEventListener('message', async (event: MessageEvent<WorkerRequest>) => {
try {
if (event.data.type === 'initialize') {
await initialize()
self.postMessage({ type: 'ready' })
return
}
const { image, timestamp } = event.data
try {
if (!landmarker) await initialize()
const result = landmarker!.detectForVideo(image, timestamp)
const classification = result.faceBlendshapes[0]
const matrix = result.facialTransformationMatrixes[0]
if (!classification || !matrix) {
self.postMessage({ type: 'missing', timestamp })
return
}
const scores = Object.fromEntries(
classification.categories.map(category => [category.categoryName, category.score])
)
self.postMessage({
type: 'capture',
frame: captureFrameFromMediaPipe(scores, matrix.data, timestamp),
})
} finally {
image.close()
}
} catch (error) {
self.postMessage({
type: 'error',
message: error instanceof Error ? error.message : 'Face tracking failed.',
})
}
})

View File

@ -0,0 +1,326 @@
import { clamp, type Expression } from '@/features/avatar/geometry'
export type HeadCapture = {
pitch: number
yaw: number
roll: number
}
export type PositionCapture = {
x: number
y: number
z: number
}
export type EyeCapture = {
blink: number
wide: number
squint: number
}
export type MouthCapture = {
jawOpen: number
smileLeft: number
smileRight: number
frownLeft: number
frownRight: number
pucker: number
funnel: number
left: number
right: number
pressLeft: number
pressRight: number
}
export type BrowCapture = {
downLeft: number
downRight: number
innerUp: number
outerUpLeft: number
outerUpRight: number
}
export type FacialCaptureFrame = {
timestamp: number
head: HeadCapture
position: PositionCapture
eyes: {
left: EyeCapture
right: EyeCapture
lookX: number
lookY: number
}
brows: BrowCapture
mouth: MouthCapture
}
export type ObservedExpression = {
id: 'neutral' | 'smile' | 'laugh' | 'angry' | 'surprised' | 'sad'
label: string
confidence: number
scores: {
smile: number
laugh: number
angry: number
surprised: number
sad: number
}
}
export type RetargetedCapture = {
expression: Expression
blinkAmount: number
signals: {
lookX: number
lookY: number
blinkLeft: number
blinkRight: number
positionX: number
positionY: number
positionZ: number
}
}
const degrees = (radians: number) => (radians * 180) / Math.PI
const score = (scores: Readonly<Record<string, number>>, name: string) => scores[name] ?? 0
export const headCaptureFromMatrix = (matrix: readonly number[]): HeadCapture => {
if (matrix.length < 11) return { pitch: 0, yaw: 0, roll: 0 }
const horizontalScale = Math.hypot(matrix[0], matrix[4])
const singular = horizontalScale < 0.000001
const pitch = singular ? Math.atan2(-matrix[6], matrix[5]) : Math.atan2(matrix[9], matrix[10])
const yaw = Math.atan2(-matrix[8], horizontalScale)
const roll = singular ? 0 : Math.atan2(matrix[4], matrix[0])
return { pitch: degrees(pitch), yaw: degrees(yaw), roll: degrees(roll) }
}
export const positionCaptureFromMatrix = (matrix: readonly number[]): PositionCapture => ({
x: matrix[12] ?? 0,
y: matrix[13] ?? 0,
z: matrix[14] ?? 0,
})
export const captureFrameFromMediaPipe = (
scores: Readonly<Record<string, number>>,
matrix: readonly number[],
timestamp: number
): FacialCaptureFrame => {
const lookRight = (score(scores, 'eyeLookOutLeft') + score(scores, 'eyeLookInRight')) / 2
const lookLeft = (score(scores, 'eyeLookInLeft') + score(scores, 'eyeLookOutRight')) / 2
const lookUp = (score(scores, 'eyeLookUpLeft') + score(scores, 'eyeLookUpRight')) / 2
const lookDown = (score(scores, 'eyeLookDownLeft') + score(scores, 'eyeLookDownRight')) / 2
return {
timestamp,
head: headCaptureFromMatrix(matrix),
position: positionCaptureFromMatrix(matrix),
eyes: {
left: {
blink: score(scores, 'eyeBlinkLeft'),
wide: score(scores, 'eyeWideLeft'),
squint: score(scores, 'eyeSquintLeft'),
},
right: {
blink: score(scores, 'eyeBlinkRight'),
wide: score(scores, 'eyeWideRight'),
squint: score(scores, 'eyeSquintRight'),
},
lookX: clamp(lookRight - lookLeft, -1, 1),
lookY: clamp(lookUp - lookDown, -1, 1),
},
brows: {
downLeft: score(scores, 'browDownLeft'),
downRight: score(scores, 'browDownRight'),
innerUp: score(scores, 'browInnerUp'),
outerUpLeft: score(scores, 'browOuterUpLeft'),
outerUpRight: score(scores, 'browOuterUpRight'),
},
mouth: {
jawOpen: score(scores, 'jawOpen'),
smileLeft: score(scores, 'mouthSmileLeft'),
smileRight: score(scores, 'mouthSmileRight'),
frownLeft: score(scores, 'mouthFrownLeft'),
frownRight: score(scores, 'mouthFrownRight'),
pucker: score(scores, 'mouthPucker'),
funnel: score(scores, 'mouthFunnel'),
left: score(scores, 'mouthLeft'),
right: score(scores, 'mouthRight'),
pressLeft: score(scores, 'mouthPressLeft'),
pressRight: score(scores, 'mouthPressRight'),
},
}
}
const mix = (from: number, to: number, amount: number) => from + (to - from) * amount
const mixEye = (from: EyeCapture, to: EyeCapture, amount: number): EyeCapture => ({
blink: mix(from.blink, to.blink, amount),
wide: mix(from.wide, to.wide, amount),
squint: mix(from.squint, to.squint, amount),
})
const mixBrows = (from: BrowCapture, to: BrowCapture, amount: number): BrowCapture => ({
downLeft: mix(from.downLeft, to.downLeft, amount),
downRight: mix(from.downRight, to.downRight, amount),
innerUp: mix(from.innerUp, to.innerUp, amount),
outerUpLeft: mix(from.outerUpLeft, to.outerUpLeft, amount),
outerUpRight: mix(from.outerUpRight, to.outerUpRight, amount),
})
const mixMouth = (from: MouthCapture, to: MouthCapture, amount: number): MouthCapture => ({
jawOpen: mix(from.jawOpen, to.jawOpen, amount),
smileLeft: mix(from.smileLeft, to.smileLeft, amount),
smileRight: mix(from.smileRight, to.smileRight, amount),
frownLeft: mix(from.frownLeft, to.frownLeft, amount),
frownRight: mix(from.frownRight, to.frownRight, amount),
pucker: mix(from.pucker, to.pucker, amount),
funnel: mix(from.funnel, to.funnel, amount),
left: mix(from.left, to.left, amount),
right: mix(from.right, to.right, amount),
pressLeft: mix(from.pressLeft, to.pressLeft, amount),
pressRight: mix(from.pressRight, to.pressRight, amount),
})
const average = (left: number, right: number) => (left + right) / 2
export const observeExpression = (frame: FacialCaptureFrame): ObservedExpression => {
const smile = average(frame.mouth.smileLeft, frame.mouth.smileRight)
const frown = average(frame.mouth.frownLeft, frame.mouth.frownRight)
const mouthPress = average(frame.mouth.pressLeft, frame.mouth.pressRight)
const eyeSquint = average(frame.eyes.left.squint, frame.eyes.right.squint)
const eyeWide = average(frame.eyes.left.wide, frame.eyes.right.wide)
const browDown = average(frame.brows.downLeft, frame.brows.downRight)
const browOuterUp = average(frame.brows.outerUpLeft, frame.brows.outerUpRight)
const jawOpen = frame.mouth.jawOpen
const scores = {
smile: clamp(smile * 0.78 + eyeSquint * 0.22, 0, 1),
laugh: clamp(smile * 0.5 + jawOpen * 0.35 + eyeSquint * 0.15, 0, 1),
angry: clamp(browDown * 0.5 + eyeSquint * 0.2 + mouthPress * 0.3, 0, 1),
surprised: clamp(
browOuterUp * 0.3 + frame.brows.innerUp * 0.2 + eyeWide * 0.25 + jawOpen * 0.25,
0,
1
),
sad: clamp(frame.brows.innerUp * 0.45 + frown * 0.55, 0, 1),
}
const candidates = [
{ id: 'smile' as const, label: 'Smile', confidence: scores.smile },
{ id: 'laugh' as const, label: 'Laugh-like', confidence: scores.laugh },
{ id: 'angry' as const, label: 'Angry-like', confidence: scores.angry },
{ id: 'surprised' as const, label: 'Surprised-like', confidence: scores.surprised },
{ id: 'sad' as const, label: 'Sad-like', confidence: scores.sad },
].sort((left, right) => right.confidence - left.confidence)
const strongest = candidates[0]
if (!strongest || strongest.confidence < 0.3) {
return {
id: 'neutral',
label: 'Neutral / unclear',
confidence: 1 - (strongest?.confidence ?? 0),
scores,
}
}
return { ...strongest, scores }
}
export const mirrorCaptureFrame = (frame: FacialCaptureFrame): FacialCaptureFrame => ({
...frame,
head: {
pitch: frame.head.pitch,
yaw: -frame.head.yaw,
// The renderer's Z axis already compensates for SVG's downward Y axis.
// Keeping MediaPipe's roll here avoids mirroring that rotation twice.
roll: frame.head.roll,
},
position: {
x: -frame.position.x,
y: frame.position.y,
z: frame.position.z,
},
eyes: {
...frame.eyes,
lookX: -frame.eyes.lookX,
},
})
export const createFacialCaptureSmoother = (initialSmoothing = 0.58) => {
let previous: FacialCaptureFrame | null = null
let smoothing = clamp(initialSmoothing, 0, 1)
return {
next(frame: FacialCaptureFrame) {
if (!previous) {
previous = frame
return frame
}
const elapsed = clamp(frame.timestamp - previous.timestamp, 1, 100)
const smoothingCurve = smoothing ** 2
const headResponse = mix(18, 298, smoothingCurve)
const featureResponse = mix(12, 122, smoothingCurve)
const headAmount = 1 - Math.exp(-elapsed / headResponse)
const featureAmount = 1 - Math.exp(-elapsed / featureResponse)
const next: FacialCaptureFrame = {
timestamp: frame.timestamp,
head: {
pitch: mix(previous.head.pitch, frame.head.pitch, headAmount),
yaw: mix(previous.head.yaw, frame.head.yaw, headAmount),
roll: mix(previous.head.roll, frame.head.roll, headAmount),
},
position: {
x: mix(previous.position.x, frame.position.x, headAmount),
y: mix(previous.position.y, frame.position.y, headAmount),
z: mix(previous.position.z, frame.position.z, headAmount),
},
eyes: {
left: mixEye(previous.eyes.left, frame.eyes.left, featureAmount),
right: mixEye(previous.eyes.right, frame.eyes.right, featureAmount),
lookX: mix(previous.eyes.lookX, frame.eyes.lookX, featureAmount),
lookY: mix(previous.eyes.lookY, frame.eyes.lookY, featureAmount),
},
brows: mixBrows(previous.brows, frame.brows, featureAmount),
mouth: mixMouth(previous.mouth, frame.mouth, featureAmount),
}
previous = next
return next
},
reset() {
previous = null
},
setSmoothing(nextSmoothing: number) {
smoothing = clamp(nextSmoothing, 0, 1)
},
}
}
export const retargetCaptureFrame = (
frame: FacialCaptureFrame,
neutral: FacialCaptureFrame,
base: Expression
): RetargetedCapture => {
const blinkLeft = clamp((frame.eyes.left.blink - neutral.eyes.left.blink) * 1.65, 0, 1)
const blinkRight = clamp((frame.eyes.right.blink - neutral.eyes.right.blink) * 1.65, 0, 1)
const leftWide = frame.eyes.left.wide - neutral.eyes.left.wide
const rightWide = frame.eyes.right.wide - neutral.eyes.right.wide
const leftSquint = frame.eyes.left.squint - neutral.eyes.left.squint
const rightSquint = frame.eyes.right.squint - neutral.eyes.right.squint
const lookX = clamp((frame.eyes.lookX - neutral.eyes.lookX) * 1.35, -1, 1)
const lookY = clamp((frame.eyes.lookY - neutral.eyes.lookY) * 1.35, -1, 1)
const positionX = clamp((frame.position.x - neutral.position.x) * 5, -55, 55)
const positionY = clamp((frame.position.y - neutral.position.y) * -5, -55, 55)
const positionZ = clamp((frame.position.z - neutral.position.z) * 0.035, -0.38, 0.48)
const expression: Expression = {
...base,
headX: base.headX + clamp(frame.head.pitch - neutral.head.pitch, -38, 38),
headY: base.headY - clamp(frame.head.yaw - neutral.head.yaw, -52, 52),
headZ: base.headZ - clamp(frame.head.roll - neutral.head.roll, -42, 42),
widthLeft: clamp(base.widthLeft * (1 - leftSquint * 0.16), 10, 100),
widthRight: clamp(base.widthRight * (1 - rightSquint * 0.16), 10, 100),
heightLeft: clamp(base.heightLeft * (1 + leftWide * 0.72 - leftSquint * 0.32), 10, 100),
heightRight: clamp(base.heightRight * (1 + rightWide * 0.72 - rightSquint * 0.32), 10, 100),
positionXLeft: base.positionXLeft + lookX * 7.5,
positionXRight: base.positionXRight + lookX * 7.5,
positionYLeft: base.positionYLeft - lookY * 6,
positionYRight: base.positionYRight - lookY * 6,
}
return {
expression,
blinkAmount: clamp(1 - Math.max(blinkLeft, blinkRight), 0.035, 1),
signals: { lookX, lookY, blinkLeft, blinkRight, positionX, positionY, positionZ },
}
}

View File

@ -0,0 +1,191 @@
import { clamp } from '@/features/avatar/geometry'
export type MotionSample = {
time: number
pitch: number
yaw: number
roll: number
positionX: number
positionY: number
positionZ: number
lookX: number
lookY: number
blink: number
}
export type MotionTake = {
duration: number
samples: MotionSample[]
}
export type MotionTakeSettings = {
smoothing: number
detailRetention: number
amplitude: number
speed: number
}
export const defaultMotionTakeSettings: MotionTakeSettings = {
smoothing: 0.62,
detailRetention: 0.32,
amplitude: 1,
speed: 1,
}
export const motionChannels = [
'pitch',
'yaw',
'roll',
'positionX',
'positionY',
'positionZ',
'lookX',
'lookY',
'blink',
] as const
export type MotionChannel = (typeof motionChannels)[number]
const mix = (from: number, to: number, amount: number) => from + (to - from) * amount
const dot = (left: readonly number[], right: readonly number[]) =>
left.reduce((sum, value, index) => sum + value * right[index], 0)
const applyCurvatureSystem = (values: readonly number[], lambda: number) => {
const result = [...values]
for (let index = 0; index < values.length - 2; index += 1) {
const secondDifference = values[index] - 2 * values[index + 1] + values[index + 2]
result[index] += lambda * secondDifference
result[index + 1] -= lambda * 2 * secondDifference
result[index + 2] += lambda * secondDifference
}
return result
}
const curvatureDiagonal = (length: number, lambda: number) =>
Array.from({ length }, (_, index) => {
const penalty =
index === 0 || index === length - 1 ? 1 : index === 1 || index === length - 2 ? 5 : 6
return 1 + lambda * penalty
})
const solvePenalizedCurve = (values: readonly number[], lambda: number) => {
if (values.length < 3 || lambda <= 0) return [...values]
let solution = [...values]
const initialProjection = applyCurvatureSystem(solution, lambda)
let residual = values.map((value, index) => value - initialProjection[index])
const diagonal = curvatureDiagonal(values.length, lambda)
let preconditioned = residual.map((value, index) => value / diagonal[index])
let direction = [...preconditioned]
let residualScale = dot(residual, preconditioned)
const tolerance = Math.max(1e-10, dot(values, values) * 1e-12)
for (let iteration = 0; iteration < Math.min(120, values.length * 2); iteration += 1) {
const projected = applyCurvatureSystem(direction, lambda)
const denominator = dot(direction, projected)
if (Math.abs(denominator) < 1e-12) break
const amount = residualScale / denominator
solution = solution.map((value, index) => value + amount * direction[index])
residual = residual.map((value, index) => value - amount * projected[index])
if (dot(residual, residual) <= tolerance) break
preconditioned = residual.map((value, index) => value / diagonal[index])
const nextScale = dot(residual, preconditioned)
const directionScale = nextScale / residualScale
direction = preconditioned.map((value, index) => value + directionScale * direction[index])
residualScale = nextScale
}
const startCorrection = values[0] - solution[0]
const endCorrection = values.at(-1)! - solution.at(-1)!
return solution.map(
(value, index) =>
value + mix(startCorrection, endCorrection, index / Math.max(1, solution.length - 1))
)
}
const linearSample = (take: MotionTake, time: number): MotionSample | null => {
if (take.samples.length === 0) return null
const target = clamp(time, 0, take.duration)
const exact = take.samples.findIndex(sample => sample.time >= target)
if (exact <= 0) return take.samples[0]
const to = take.samples[exact]
const from = take.samples[exact - 1]
const amount = clamp((target - from.time) / Math.max(1, to.time - from.time), 0, 1)
const sample = { ...from, time: target }
motionChannels.forEach(channel => {
sample[channel] = mix(from[channel], to[channel], amount)
})
return sample
}
const resampleTake = (take: MotionTake): MotionTake => {
if (take.samples.length < 2 || take.duration <= 0) return take
const intervals = take.samples
.slice(1)
.map((sample, index) => sample.time - take.samples[index].time)
.filter(interval => interval > 0)
.sort((left, right) => left - right)
const medianInterval = intervals[Math.floor(intervals.length / 2)] ?? 1000 / 30
const targetInterval = clamp(medianInterval, 1000 / 60, 1000 / 24)
const sampleCount = Math.max(2, Math.round(take.duration / targetInterval) + 1)
const interval = take.duration / (sampleCount - 1)
const samples = Array.from({ length: sampleCount }, (_, index) =>
linearSample(take, index * interval)
).filter((sample): sample is MotionSample => sample !== null)
return { duration: take.duration, samples }
}
export const processMotionTake = (take: MotionTake, settings: MotionTakeSettings): MotionTake => {
if (take.samples.length === 0) return { duration: 0, samples: [] }
const resampled = resampleTake(take)
const smoothing = clamp(settings.smoothing, 0, 1)
const detailRetention = clamp(settings.detailRetention, 0, 1)
const amplitude = clamp(settings.amplitude, 0, 2)
const speed = clamp(settings.speed, 0.25, 3)
const lambda = (10 ** (smoothing * 3) - 1) * 0.45
const refinedChannels = Object.fromEntries(
motionChannels.map(channel => {
const raw = resampled.samples.map(sample => sample[channel])
const rounded = solvePenalizedCurve(raw, lambda)
return [channel, rounded.map((value, index) => mix(value, raw[index], detailRetention))]
})
) as Record<MotionChannel, number[]>
const samples = resampled.samples.map((sample, index) => {
const processed = { ...sample, time: sample.time / speed }
motionChannels.forEach(channel => {
const refined = refinedChannels[channel][index]
processed[channel] =
channel === 'blink' ? clamp(refined * amplitude, 0, 1) : refined * amplitude
})
return processed
})
return { duration: take.duration / speed, samples }
}
export const sampleMotionTake = (take: MotionTake, time: number): MotionSample | null => {
if (take.samples.length === 0) return null
const target = clamp(time, 0, take.duration)
const exact = take.samples.findIndex(sample => sample.time >= target)
if (exact <= 0) return take.samples[0]
const to = take.samples[exact]
const from = take.samples[exact - 1]
const amount = clamp((target - from.time) / Math.max(1, to.time - from.time), 0, 1)
const before = take.samples[Math.max(0, exact - 2)]
const after = take.samples[Math.min(take.samples.length - 1, exact + 1)]
const sample = { ...from, time: target }
motionChannels.forEach(channel => {
const p0 = before[channel]
const p1 = from[channel]
const p2 = to[channel]
const p3 = after[channel]
const squared = amount * amount
const cubed = squared * amount
sample[channel] =
0.5 *
(2 * p1 +
(-p0 + p2) * amount +
(2 * p0 - 5 * p1 + 4 * p2 - p3) * squared +
(-p0 + 3 * p1 - 3 * p2 + p3) * cubed)
})
sample.blink = clamp(sample.blink, 0, 1)
return sample
}

View File

@ -0,0 +1,413 @@
import { ArrowLeft, Eye, Sparkles } from 'lucide-react'
import {
animate,
useMotionValue,
useMotionValueEvent,
useReducedMotion,
type AnimationPlaybackControls,
} from 'motion/react'
import {
StrictMode,
useEffect,
useRef,
useState,
type PointerEvent as ReactPointerEvent,
} from 'react'
import { createRoot } from 'react-dom/client'
import { interpolateHexColor, poseWithAvatarEyes, resolveColors } from '@/app/studio-utils'
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
import {
interpolatePose,
poseFromExpression,
renderAvatar,
rotateExpressionWithArcball,
type AvatarPose,
} from '@/features/avatar/geometry'
import {
advanceLegacyGrokSpring,
comparisonBlinkDelay,
interpolateLegacyGrokExpression,
legacyGrokSpringIsSettled,
} from '@/features/comparison/comparisonTransition'
import {
LEGACY_GROK_BODY_PATH,
legacyGrokExpressions,
legacyGrokPath,
type LegacyGrokExpression,
} from '@/features/comparison/legacyGrok'
import { loadStudioDocument } from '@/features/studio/studioDocument'
import './grokComparison.css'
const legacyRingCentroid = (ring: LegacyGrokExpression[number]) => {
const total = ring.reduce((sum, point) => [sum[0] + point[0], sum[1] + point[1]] as const, [
0, 0,
] as const)
return [total[0] / ring.length, total[1] / ring.length] as const
}
function LegacyGrok({
expression,
index,
blinkAmount,
}: {
expression: LegacyGrokExpression
index: number
blinkAmount: number
}) {
return (
<svg
className="comparison-avatar legacy-avatar"
viewBox="-25.87 -25.91 280.28 280.28"
role="img"
>
<title>Official Grok Bot, expression {index}</title>
<defs>
<clipPath id="legacy-comparison-head-clip">
<path d={LEGACY_GROK_BODY_PATH} />
</clipPath>
</defs>
<g>
<path className="legacy-body" d={LEGACY_GROK_BODY_PATH} />
<g clipPath="url(#legacy-comparison-head-clip)">
{expression.map((ring, eyeIndex) => {
const center = legacyRingCentroid(ring)
return (
<path
className="legacy-eye"
d={legacyGrokPath(ring)}
transform={`translate(${center[0]} ${center[1]}) scale(1 ${blinkAmount}) translate(${-center[0]} ${-center[1]})`}
key={eyeIndex}
/>
)
})}
</g>
</g>
</svg>
)
}
function GrokComparisonApp() {
const [studioDocument] = useState(() => loadStudioDocument())
const [selectedExpression, setSelectedExpression] = useState(0)
const [blinkAmount, setBlinkAmount] = useState(1)
const blinkValue = useMotionValue(1)
const rotationProgress = useMotionValue(0)
useMotionValueEvent(blinkValue, 'change', setBlinkAmount)
const reduceMotion = useReducedMotion()
const grokAvatar = studioDocument.library.avatars.find(
avatar => avatar.name.trim().toLowerCase() === 'grok bot'
)
if (!grokAvatar) {
return (
<main className="comparison-error">
<strong>Grok Bot could not be found.</strong>
<a href="../">Return to Avatar Lab</a>
</main>
)
}
const behavior = resolveAvatarBehavior(grokAvatar, {
expressions: studioDocument.expressions,
sequences: studioDocument.sequences,
})
const expressionCount = Math.min(behavior.expressions.length, legacyGrokExpressions.length)
const activeIndex = Math.min(selectedExpression, expressionCount - 1)
const [displayedPose, setDisplayedPose] = useState<AvatarPose>(() =>
poseWithAvatarEyes(behavior.expressions[0], grokAvatar.eyes)
)
const [displayedLegacyExpression, setDisplayedLegacyExpression] = useState<LegacyGrokExpression>(
() => legacyGrokExpressions[0]
)
const displayedPoseRef = useRef(displayedPose)
const displayedLegacyRef = useRef(displayedLegacyExpression)
const comparisonFrame = useRef<number | null>(null)
const blinkAnimation = useRef<AnimationPlaybackControls | null>(null)
const rotationAnimation = useRef<AnimationPlaybackControls | null>(null)
const rotationDrag = useRef<{
startPoint: readonly [number, number]
pose: AvatarPose
} | null>(null)
const expression = displayedPose.expression
const geometry = renderAvatar(displayedPose, grokAvatar.body.primary, blinkAmount, {
includeWire: false,
bodyNodes: grokAvatar.body.nodes,
})
const colors = resolveColors(expression, grokAvatar.colors)
useEffect(
() => () => {
if (comparisonFrame.current !== null) cancelAnimationFrame(comparisonFrame.current)
blinkAnimation.current?.stop()
rotationAnimation.current?.stop()
},
[]
)
const toAvatarPoint = (event: ReactPointerEvent<SVGPathElement>): readonly [number, number] => {
const rectangle = event.currentTarget.ownerSVGElement!.getBoundingClientRect()
return [
((event.clientX - rectangle.left) / rectangle.width) * 300 - 150,
((event.clientY - rectangle.top) / rectangle.height) * 300 - 150,
]
}
const animateDisplayedRotation = (target: AvatarPose, duration: number) => {
rotationAnimation.current?.stop()
const from = displayedPoseRef.current
if (reduceMotion) {
displayedPoseRef.current = target
setDisplayedPose(target)
return
}
rotationProgress.jump(0)
rotationAnimation.current = animate(rotationProgress, 1, {
duration,
ease: [0.22, 1, 0.36, 1],
onUpdate: progress => {
const pose = interpolatePose(from, target, progress)
displayedPoseRef.current = pose
setDisplayedPose(pose)
},
onComplete: () => {
displayedPoseRef.current = target
setDisplayedPose(target)
},
})
}
const startRotation = (event: ReactPointerEvent<SVGPathElement>) => {
event.preventDefault()
if (comparisonFrame.current !== null) {
cancelAnimationFrame(comparisonFrame.current)
comparisonFrame.current = null
}
rotationAnimation.current?.stop()
rotationDrag.current = {
startPoint: toAvatarPoint(event),
pose: displayedPoseRef.current,
}
event.currentTarget.setPointerCapture(event.pointerId)
}
const moveRotation = (event: ReactPointerEvent<SVGPathElement>) => {
if (!rotationDrag.current) return
const rotated = rotateExpressionWithArcball(
rotationDrag.current.pose.expression,
rotationDrag.current.startPoint,
toAvatarPoint(event)
)
animateDisplayedRotation(poseFromExpression(rotated), 0.1)
}
const finishRotation = () => {
if (!rotationDrag.current) return
rotationDrag.current = null
animateDisplayedRotation(
poseWithAvatarEyes(behavior.expressions[activeIndex], grokAvatar.eyes),
0.48
)
}
const blink = () => {
blinkAnimation.current?.stop()
blinkValue.jump(1)
blinkAnimation.current = animate(blinkValue, [1, 0.04, 1], {
duration: 0.32,
times: [0, 0.42, 1],
ease: ['easeIn', 'easeOut'],
onUpdate: setBlinkAmount,
})
}
useEffect(() => {
let timer: number | null = null
let active = true
const scheduleBlink = () => {
timer = window.setTimeout(() => {
if (!active) return
blink()
scheduleBlink()
}, comparisonBlinkDelay())
}
scheduleBlink()
return () => {
active = false
if (timer !== null) window.clearTimeout(timer)
}
}, [])
const selectExpression = (index: number) => {
if (index === activeIndex) return
const nextPose = poseWithAvatarEyes(behavior.expressions[index], grokAvatar.eyes)
const nextLegacyExpression = legacyGrokExpressions[index]
setSelectedExpression(index)
if (comparisonFrame.current !== null) cancelAnimationFrame(comparisonFrame.current)
rotationDrag.current = null
rotationAnimation.current?.stop()
if (reduceMotion) {
displayedPoseRef.current = nextPose
displayedLegacyRef.current = nextLegacyExpression
setDisplayedPose(nextPose)
setDisplayedLegacyExpression(nextLegacyExpression)
return
}
const fromPose = displayedPoseRef.current
const fromColors = resolveColors(fromPose.expression, grokAvatar.colors)
const targetColors = resolveColors(nextPose.expression, grokAvatar.colors)
const fromLegacyExpression = displayedLegacyRef.current
let spring = { morph: 0, velocity: 0 }
let previousTime = performance.now()
const tickComparison = (time: number) => {
spring = advanceLegacyGrokSpring(spring, (time - previousTime) / 1000)
previousTime = time
const progress = Math.max(0, Math.min(1, spring.morph))
const pose = interpolatePose(fromPose, nextPose, progress)
pose.expression.bodyColor = interpolateHexColor(fromColors.body, targetColors.body, progress)
pose.expression.eyeColor = interpolateHexColor(fromColors.eyes, targetColors.eyes, progress)
const legacyExpression = interpolateLegacyGrokExpression(
fromLegacyExpression,
nextLegacyExpression,
progress
)
displayedPoseRef.current = pose
displayedLegacyRef.current = legacyExpression
setDisplayedPose(pose)
setDisplayedLegacyExpression(legacyExpression)
if (!legacyGrokSpringIsSettled(spring)) {
comparisonFrame.current = requestAnimationFrame(tickComparison)
return
}
comparisonFrame.current = null
displayedPoseRef.current = nextPose
displayedLegacyRef.current = nextLegacyExpression
setDisplayedPose(nextPose)
setDisplayedLegacyExpression(nextLegacyExpression)
}
comparisonFrame.current = requestAnimationFrame(tickComparison)
}
return (
<main className="comparison-page">
<header className="comparison-header">
<a className="comparison-back" href="../">
<ArrowLeft aria-hidden="true" />
Avatar Lab
</a>
<div className="comparison-header-actions">
<div className="comparison-signal">
<Sparkles aria-hidden="true" />
Expression {String(activeIndex).padStart(2, '0')}
</div>
</div>
</header>
<section className="comparison-stage" aria-label="Comparison of both Grok Bots">
<article className="comparison-card remastered-card">
<div className="comparison-card-heading">
<div>
<h2>Procedural 3D engine</h2>
<p>
Each expression interpolates a three-dimensional pose. The engine rebuilds and
projects the body and eyes as SVG on every frame.
</p>
</div>
</div>
<div className="comparison-avatar-frame">
<svg className="comparison-avatar" viewBox="-150 -150 300 300" role="img">
<title>Grok Bot remastered, expression {activeIndex}</title>
<defs>
<clipPath id="remastered-comparison-head-clip">
<path d={geometry.headPath} />
</clipPath>
</defs>
<g>
{geometry.backPaths.map((path, index) => (
<path d={path} fill={colors.body} key={`back-${index}`} />
))}
<path d={geometry.headPath} fill={colors.body} />
<g clipPath="url(#remastered-comparison-head-clip)" fill={colors.eyes}>
{geometry.leftVisible && <path d={geometry.leftPath} />}
{geometry.rightVisible && <path d={geometry.rightPath} />}
</g>
{geometry.frontPaths.map((path, index) => (
<path d={path} fill={colors.body} key={`front-${index}`} />
))}
<path
className="comparison-rotation-hitarea"
d={geometry.headPath}
onPointerDown={startRotation}
onPointerMove={moveRotation}
onPointerUp={finishRotation}
onPointerCancel={finishRotation}
/>
</g>
</svg>
</div>
</article>
<div className="comparison-versus" aria-hidden="true">
VS
</div>
<article className="comparison-card legacy-card">
<div className="comparison-card-heading">
<div>
<h2>SVG morphing</h2>
<p>
Each transition morphs the 48 eye points between 25 fixed expressions. The body
remains a single, unchanged SVG path.
</p>
</div>
</div>
<div className="comparison-avatar-frame">
<LegacyGrok
expression={displayedLegacyExpression}
index={activeIndex}
blinkAmount={blinkAmount}
/>
</div>
</article>
</section>
<section className="comparison-expression-panel" aria-label="Official expressions">
<div className="comparison-expression-actions">
<button className="comparison-blink-button" type="button" onClick={blink}>
<Eye aria-hidden="true" />
Blink
</button>
</div>
<div className="comparison-expression-grid">
{legacyGrokExpressions.slice(0, expressionCount).map((legacyExpression, index) => (
<button
className="comparison-expression-button"
type="button"
aria-label={`Expression ${index}`}
aria-pressed={index === activeIndex}
onClick={() => selectExpression(index)}
key={index}
>
<svg viewBox="0 0 229 229" aria-hidden="true">
{legacyExpression.map((ring, eyeIndex) => (
<path d={legacyGrokPath(ring)} key={eyeIndex} />
))}
</svg>
<span>{String(index).padStart(2, '0')}</span>
</button>
))}
</div>
</section>
</main>
)
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<GrokComparisonApp />
</StrictMode>
)

View File

@ -0,0 +1,52 @@
import { legacyGrokExpressions, legacyGrokPath } from '@/features/comparison/legacyGrok'
import {
advanceLegacyGrokSpring,
comparisonBlinkDelay,
interpolateLegacyGrokExpression,
legacyGrokSpringIsSettled,
} from '@/features/comparison/comparisonTransition'
describe('legacy Grok source', () => {
it('extracts every official expression from the legacy prototype', () => {
expect(legacyGrokExpressions).toHaveLength(25)
legacyGrokExpressions.forEach(expression => {
expect(expression).toHaveLength(2)
expect(expression[0].length).toBeGreaterThan(40)
expect(expression[1].length).toBeGreaterThan(40)
})
})
it('turns a legacy ring into a closed SVG path', () => {
const path = legacyGrokPath(legacyGrokExpressions[0][0])
expect(path).toMatch(/^M/)
expect(path).toMatch(/Z$/)
})
})
describe('legacy Grok interpolation', () => {
it('morphs every historic point between two expressions', () => {
const from = legacyGrokExpressions[0]
const to = legacyGrokExpressions[1]
const halfway = interpolateLegacyGrokExpression(from, to, 0.5)
expect(halfway[0][0][0]).toBeCloseTo((from[0][0][0] + to[0][0][0]) / 2)
expect(halfway[1][20][1]).toBeCloseTo((from[1][20][1] + to[1][20][1]) / 2)
})
it('reproduces the legacy critically damped spring until it settles', () => {
let spring = { morph: 0, velocity: 0 }
for (let frame = 0; frame < 240; frame += 1) {
spring = advanceLegacyGrokSpring(spring, 1 / 60)
}
expect(spring.morph).toBeCloseTo(1, 3)
expect(legacyGrokSpringIsSettled(spring)).toBe(true)
})
it('schedules automatic blinks inside the shared random interval', () => {
expect(comparisonBlinkDelay(() => 0)).toBe(2400)
expect(comparisonBlinkDelay(() => 0.5)).toBe(3800)
expect(comparisonBlinkDelay(() => 1)).toBe(5200)
})
})

View File

@ -0,0 +1,45 @@
import type { LegacyGrokExpression } from '@/features/comparison/legacyGrok'
export type LegacyGrokSpring = {
morph: number
velocity: number
}
const clamp01 = (value: number) => Math.max(0, Math.min(1, value))
export const interpolateLegacyGrokExpression = (
from: LegacyGrokExpression,
to: LegacyGrokExpression,
progress: number
): LegacyGrokExpression => {
const boundedProgress = clamp01(progress)
return from.map((ring, eyeIndex) =>
ring.map((point, pointIndex) => {
const target = to[eyeIndex][pointIndex]
return [
point[0] + (target[0] - point[0]) * boundedProgress,
point[1] + (target[1] - point[1]) * boundedProgress,
] as const
})
) as unknown as LegacyGrokExpression
}
export const advanceLegacyGrokSpring = (
spring: LegacyGrokSpring,
deltaSeconds: number,
frequency = 7
): LegacyGrokSpring => {
const delta = Math.min(Math.max(deltaSeconds, 0), 0.1)
const velocity =
spring.velocity +
(-2 * frequency * spring.velocity - frequency * frequency * (spring.morph - 1)) * delta
const morph = spring.morph + velocity * delta
if (!Number.isFinite(morph) || !Number.isFinite(velocity)) return { morph: 1, velocity: 0 }
return { morph, velocity }
}
export const legacyGrokSpringIsSettled = ({ morph, velocity }: LegacyGrokSpring) =>
Math.abs(1 - morph) < 0.001 && Math.abs(velocity) < 0.001
export const comparisonBlinkDelay = (random = Math.random) => 2400 + random() * 2800

View File

@ -0,0 +1,461 @@
@import 'tailwindcss';
:root {
color: #f5f4ed;
background: #090a0c;
font-family: Georgia, 'Times New Roman', serif;
font-synthesis: none;
}
* {
box-sizing: border-box;
user-select: none;
}
body {
min-width: 320px;
min-height: 100vh;
margin: 0;
background:
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px),
radial-gradient(circle at 50% 8%, #25272c 0, #111216 34%, #090a0c 70%);
background-size:
32px 32px,
32px 32px,
auto;
}
button,
a {
font: inherit;
}
.comparison-page {
width: min(1440px, 100%);
min-height: 100vh;
margin: 0 auto;
padding: 34px clamp(18px, 4vw, 64px) 72px;
}
.comparison-header {
display: grid;
grid-template-columns: 1fr auto;
align-items: start;
gap: 28px;
padding-bottom: 32px;
border-bottom: 1px solid rgb(255 255 255 / 14%);
}
.comparison-back,
.comparison-signal,
.comparison-blink-button,
.comparison-kicker,
.comparison-edition,
.comparison-engine-tag,
.comparison-card footer,
.comparison-expression-button span {
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.comparison-back {
display: inline-flex;
width: fit-content;
align-items: center;
gap: 9px;
padding: 9px 0;
color: #969ba7;
font-size: 11px;
text-decoration: none;
transition: color 160ms ease;
}
.comparison-back:hover {
color: white;
}
.comparison-back svg,
.comparison-signal svg,
.comparison-blink-button svg {
width: 15px;
height: 15px;
}
.comparison-title-lockup {
max-width: 760px;
margin-inline: auto;
text-align: center;
}
.comparison-kicker,
.comparison-edition {
color: #6f8df1;
font-size: 14px;
font-weight: 800;
}
.comparison-title-lockup h1 {
margin: 13px 0 16px;
font-size: clamp(42px, 6.5vw, 92px);
font-weight: 400;
letter-spacing: -0.065em;
line-height: 0.86;
}
.comparison-title-lockup h1 em {
display: block;
color: #7f8490;
font-weight: 400;
}
.comparison-title-lockup p {
max-width: 600px;
margin: 0 auto;
color: #9b9fa8;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 14px;
line-height: 1.65;
}
.comparison-signal {
display: inline-flex;
justify-self: end;
align-items: center;
gap: 8px;
padding: 9px 11px;
border: 1px solid rgb(111 141 241 / 40%);
border-radius: 999px;
color: #b9c7fb;
background: rgb(111 141 241 / 10%);
font-size: 10px;
}
.comparison-header-actions {
display: grid;
justify-items: end;
gap: 8px;
}
.comparison-blink-button {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 9px 12px;
border: 1px solid rgb(255 255 255 / 18%);
border-radius: 999px;
color: #a6abb6;
background: rgb(255 255 255 / 5%);
font-size: 10px;
cursor: pointer;
transition:
color 160ms ease,
border-color 160ms ease,
background 160ms ease;
}
.comparison-blink-button:hover,
.comparison-blink-button:focus-visible {
border-color: rgb(255 255 255 / 42%);
color: white;
background: rgb(255 255 255 / 10%);
outline: none;
}
.comparison-stage {
position: relative;
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: clamp(14px, 2vw, 28px);
padding: 34px 0;
}
.comparison-card {
min-width: 0;
overflow: hidden;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 26px;
background: #111318;
box-shadow: 0 28px 70px rgb(0 0 0 / 28%);
}
.remastered-card,
.legacy-card {
background: #fff;
color: #111216;
}
.comparison-card-heading {
display: flex;
min-height: 112px;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 22px 24px 18px;
border-bottom: 1px solid rgb(0 0 0 / 12%);
}
.comparison-card-heading > div {
max-width: 560px;
}
.legacy-card .comparison-card-heading {
border-bottom-color: rgb(0 0 0 / 12%);
}
.comparison-card h2 {
margin: 0 0 8px;
color: #000;
font-family: 'SFMono-Regular', Consolas, 'Liberation Mono', monospace;
font-size: 18px;
font-weight: 800;
letter-spacing: 0.1em;
text-transform: uppercase;
}
.comparison-card-heading p {
max-width: 54ch;
margin: 0;
color: #555861;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 15px;
line-height: 1.5;
}
.legacy-card .comparison-card-heading h2 {
color: #51545d;
}
.legacy-card .comparison-card-heading p {
color: #555861;
}
.comparison-avatar-frame {
display: grid;
min-height: min(49vw, 610px);
place-items: center;
padding: clamp(18px, 3vw, 44px);
}
.comparison-avatar {
display: block;
width: min(100%, 540px);
aspect-ratio: 1;
filter: drop-shadow(0 26px 35px rgb(0 0 0 / 28%));
}
.comparison-rotation-hitarea {
fill: transparent;
cursor: grab;
touch-action: none;
}
.comparison-rotation-hitarea:active {
cursor: grabbing;
}
.legacy-avatar {
filter: drop-shadow(0 26px 32px rgb(0 0 0 / 18%));
}
.legacy-body {
fill: #000;
}
.legacy-eye {
fill: #fff;
}
.comparison-card footer {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 16px 24px 19px;
border-top: 1px solid rgb(255 255 255 / 12%);
color: #777d89;
font-size: 9px;
}
.legacy-card footer {
border-top-color: rgb(0 0 0 / 12%);
color: #565962;
}
.comparison-versus {
position: absolute;
z-index: 5;
top: 50%;
left: 50%;
display: grid;
width: 54px;
height: 54px;
place-items: center;
border: 7px solid #090a0c;
border-radius: 50%;
color: #0b0c0f;
background: #f1efea;
font-family: 'SFMono-Regular', Consolas, monospace;
font-size: 11px;
font-weight: 900;
transform: translate(-50%, -50%) rotate(-8deg);
}
.comparison-expression-panel {
padding: 28px;
border: 1px solid rgb(255 255 255 / 14%);
border-radius: 24px;
background: rgb(17 19 24 / 88%);
}
.comparison-expression-actions {
display: flex;
justify-content: flex-start;
margin-bottom: 14px;
}
.comparison-expression-heading {
display: flex;
align-items: end;
justify-content: space-between;
gap: 24px;
margin-bottom: 22px;
}
.comparison-expression-heading h2 {
margin: 6px 0 0;
font-size: clamp(28px, 4vw, 48px);
font-weight: 400;
letter-spacing: -0.045em;
}
.comparison-expression-heading p {
max-width: 360px;
margin: 0;
color: #858a95;
font-family: ui-sans-serif, system-ui, sans-serif;
font-size: 12px;
text-align: right;
}
.comparison-expression-grid {
display: grid;
grid-template-columns: repeat(13, minmax(0, 1fr));
gap: 8px;
}
.comparison-expression-button {
position: relative;
display: grid;
min-width: 0;
aspect-ratio: 0.78;
place-items: center;
overflow: hidden;
padding: 7px 6px 22px;
border: 1px solid rgb(255 255 255 / 12%);
border-radius: 12px;
color: #8d929d;
background: #181b21;
cursor: pointer;
transition:
border-color 160ms ease,
background 160ms ease,
transform 160ms ease;
}
.comparison-expression-button:hover {
z-index: 1;
border-color: rgb(255 255 255 / 38%);
transform: translateY(-3px);
}
.comparison-expression-button[aria-pressed='true'] {
border-color: #7592f1;
color: white;
background: #253156;
box-shadow: 0 0 0 2px rgb(117 146 241 / 20%);
}
.comparison-expression-button svg {
width: 100%;
aspect-ratio: 1;
}
.comparison-expression-button path {
fill: #f1f0ea;
}
.comparison-expression-button span {
position: absolute;
right: 7px;
bottom: 6px;
font-size: 8px;
}
.comparison-error {
display: grid;
min-height: 100vh;
place-content: center;
gap: 14px;
text-align: center;
}
.comparison-error a {
color: #8ea8ff;
}
@media (max-width: 980px) {
.comparison-header {
grid-template-columns: 1fr auto;
}
.comparison-title-lockup {
grid-column: 1 / -1;
grid-row: 2;
}
.comparison-expression-grid {
grid-template-columns: repeat(9, minmax(0, 1fr));
}
}
@media (max-width: 700px) {
.comparison-page {
padding-inline: 12px;
}
.comparison-stage {
grid-template-columns: 1fr;
}
.comparison-versus {
top: 50%;
}
.comparison-avatar-frame {
min-height: 390px;
}
.comparison-card footer {
gap: 8px;
padding-inline: 16px;
font-size: 7px;
}
.comparison-expression-panel {
padding: 20px 14px;
}
.comparison-expression-heading {
display: block;
}
.comparison-expression-heading p {
margin-top: 10px;
text-align: left;
}
.comparison-expression-grid {
grid-template-columns: repeat(5, minmax(0, 1fr));
}
}

View File

@ -0,0 +1,23 @@
import legacyGrokSource from '../../../legacy/index.html?raw'
export type LegacyGrokPoint = readonly [number, number]
export type LegacyGrokExpression = readonly [LegacyGrokPoint[], LegacyGrokPoint[]]
export const LEGACY_GROK_BODY_PATH =
'M228.541 114.228C228.541 130.133 225.184 145.994 218.738 160.534C212.674 174.217 203.904 186.669 193.065 196.988C155.933 232.34 99.497 238.596 55.5255 212.24C45.097 205.99 35.6851 198.072 27.7451 188.866C19.1926 178.953 12.3686 167.569 7.65781 155.351C2.60712 142.264 0 128.257 0 114.228C0 98.3219 3.35751 82.4611 9.80315 67.9215C15.8672 54.2382 24.6377 41.7862 35.4767 31.4668C72.6081 -3.88483 129.044 -10.1413 173.016 16.2153C183.444 22.4653 192.856 30.3829 200.796 39.5896C209.349 49.5018 216.173 60.8859 220.883 73.1037C225.934 86.1906 228.541 100.198 228.541 114.228Z'
const startMarker = 'const EXPRESSIONS = '
const endMarker = '\n const GROUPS = '
const start = legacyGrokSource.indexOf(startMarker)
const end = legacyGrokSource.indexOf(endMarker, start)
if (start < 0 || end < 0) throw new Error('Legacy Grok expressions are missing')
const expressionLiteral = legacyGrokSource
.slice(start + startMarker.length, end)
.replace(/,\s*]/g, ']')
export const legacyGrokExpressions = JSON.parse(expressionLiteral) as LegacyGrokExpression[]
export const legacyGrokPath = (ring: readonly LegacyGrokPoint[]) =>
`M${ring.map(point => `${point[0].toFixed(2)} ${point[1].toFixed(2)}`).join('L')}Z`

View File

@ -1,43 +1,17 @@
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 =>
@ -45,11 +19,6 @@ 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(
@ -60,9 +29,6 @@ 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', () => {
@ -153,77 +119,6 @@ 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)

Some files were not shown because too many files have changed in this diff Show More