Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79fe9ba06e | ||
|
|
0097f340f5 | ||
|
|
7f414b82de | ||
|
|
93faab3690 | ||
|
|
70a7ee2ea5 | ||
|
|
d6fcf63fe6 | ||
|
|
175691ab32 | ||
|
|
fc7445002a | ||
|
|
97127cf07a | ||
|
|
1b6a0c964c | ||
|
|
7ad18432f9 | ||
|
|
973d5e98c0 | ||
|
|
76cc00fc80 | ||
|
|
843da29718 | ||
|
|
2ba720e059 | ||
|
|
2386b78817 | ||
|
|
7bed34cc56 | ||
|
|
4d17393cb1 | ||
|
|
698d21feb1 | ||
|
|
99300b3b30 | ||
|
|
85ebe0149f |
11
.changeset/README.md
Normal file
11
.changeset/README.md
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
# Changesets
|
||||||
|
|
||||||
|
Every user-visible package change must include a changeset created with `pnpm changeset`.
|
||||||
|
|
||||||
|
The three runtime packages use one fixed version while the public API stabilizes. Select:
|
||||||
|
|
||||||
|
- `patch` for compatible fixes and documentation corrections;
|
||||||
|
- `minor` for compatible features and for breaking changes while the version is below `1.0.0`;
|
||||||
|
- `major` only after the packages have reached `1.0.0`.
|
||||||
|
|
||||||
|
Merging the generated release pull request publishes the versions recorded in that pull request.
|
||||||
13
.changeset/config.json
Normal file
13
.changeset/config.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://unpkg.com/@changesets/config@3.1.2/schema.json",
|
||||||
|
"changelog": "@changesets/cli/changelog",
|
||||||
|
"commit": false,
|
||||||
|
"fixed": [
|
||||||
|
["@bible-strong/avatar-core", "@bible-strong/avatar-react", "@bible-strong/avatar-web"]
|
||||||
|
],
|
||||||
|
"linked": [],
|
||||||
|
"access": "public",
|
||||||
|
"baseBranch": "main",
|
||||||
|
"updateInternalDependencies": "patch",
|
||||||
|
"ignore": []
|
||||||
|
}
|
||||||
50
.github/workflows/release.yml
vendored
Normal file
50
.github/workflows/release.yml
vendored
Normal file
@ -0,0 +1,50 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
pull-requests: write
|
||||||
|
id-token: write
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: release
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
name: Version or publish packages
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
timeout-minutes: 20
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Check out repository
|
||||||
|
uses: actions/checkout@v6
|
||||||
|
|
||||||
|
- name: Set up Node.js
|
||||||
|
uses: actions/setup-node@v6
|
||||||
|
with:
|
||||||
|
node-version: '24.15.0'
|
||||||
|
registry-url: 'https://registry.npmjs.org'
|
||||||
|
|
||||||
|
- name: Install pnpm
|
||||||
|
run: npm install --global pnpm@10.34.5
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
- name: Validate project
|
||||||
|
run: pnpm check
|
||||||
|
|
||||||
|
- name: Create release pull request or publish
|
||||||
|
uses: changesets/action@v1
|
||||||
|
with:
|
||||||
|
publish: pnpm release
|
||||||
|
version: pnpm version-packages
|
||||||
|
title: 'chore(release): publish runtime packages'
|
||||||
|
commit: 'chore(release): publish runtime packages'
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
18
README.md
18
README.md
@ -20,7 +20,7 @@ The application runs entirely in the browser. Projects are stored locally and ca
|
|||||||
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
|
- Choose loop, play-once, or ping-pong playback and configure automatic blinking.
|
||||||
- Preview, play, pause, and stop animations inside the Studio.
|
- Preview, play, pause, and stop animations inside the Studio.
|
||||||
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
|
- Take SVG or PNG snapshots with transparent, solid, linear-gradient, or radial-gradient backgrounds.
|
||||||
- Export a standalone React package or a framework-free JavaScript/HTML package.
|
- Export one portable `.avatar.json` definition for React or framework-free JavaScript/ESM.
|
||||||
- Export and import the complete Studio project as JSON.
|
- Export and import the complete Studio project as JSON.
|
||||||
- Use the interface in English, French, or Simplified Chinese.
|
- Use the interface in English, French, or Simplified Chinese.
|
||||||
|
|
||||||
@ -45,13 +45,21 @@ This copy-on-write model lets multiple avatars share the defaults without accide
|
|||||||
|
|
||||||
## Export formats
|
## Export formats
|
||||||
|
|
||||||
### React package
|
### Avatar definition
|
||||||
|
|
||||||
The React export is a local ZIP package containing a reusable TypeScript/React avatar component and the selected animations. It is intended for integration into React applications without shipping the Avatar Lab interface.
|
The selected avatar and animations are stored in one portable `.avatar.json` definition. React and
|
||||||
|
JavaScript use this exact same file, so visual behavior does not diverge between renderers.
|
||||||
|
|
||||||
### JavaScript package
|
### React / TypeScript
|
||||||
|
|
||||||
The JavaScript export is a self-contained ZIP project with an ES module, the selected avatar data and animations, and an HTML demo. It can be used without React.
|
Install `@bible-strong/avatar-react`, import the JSON and pass it to `createAvatar`. The React
|
||||||
|
package depends on `@bible-strong/avatar-core` for validation, playback and geometry.
|
||||||
|
|
||||||
|
### JavaScript / ESM
|
||||||
|
|
||||||
|
Install `@bible-strong/avatar-web` for a DOM renderer without React. The integration ZIP contains
|
||||||
|
the same `.avatar.json`, a lightweight ESM wrapper and usage instructions; it does not copy the
|
||||||
|
rendering engine into every avatar export. `avatar-web` also depends on `avatar-core`.
|
||||||
|
|
||||||
### Photo Mode
|
### Photo Mode
|
||||||
|
|
||||||
|
|||||||
29
TIMELOG.md
Normal file
29
TIMELOG.md
Normal file
@ -0,0 +1,29 @@
|
|||||||
|
# TIMELOG - Bible Strong Avatar Lab
|
||||||
|
|
||||||
|
> Engineering work log. Times are rounded from observed session timestamps and are provided for
|
||||||
|
> project traceability, not billing.
|
||||||
|
|
||||||
|
## 2026-08-14 - Avatar runtime React v1
|
||||||
|
|
||||||
|
| Workstream | Start | End | Duration | Status | Evidence |
|
||||||
|
| ------------------------------------------- | ----- | ----- | -------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||||
|
| Public contract and core runtime | 13:53 | 14:10 | 17 min | Complete | v1 JSON Schema, bounded parser, Ajv validation, semantic catalog, geometry, scene generation and deterministic playback extracted to `@bible-strong/avatar-core`. |
|
||||||
|
| React renderer and consumer hardening | 14:10 | 14:25 | 15 min | Complete | Embedded/floating rendering, React 19 controller, SSR portal handoff, drag/keyboard controls, resize constraints, callbacks and direct frame updates validated. |
|
||||||
|
| Studio semantic export and reported blocker | 14:25 | 14:39 | 14 min | Complete | Restored approved bundled keys, removed cascading errors, added targeted local-project clearing and verified a schema-v1 Strobi export with no public opaque references. |
|
||||||
|
| Export UX and developer guidance | 14:44 | 14:50 | 6 min | Complete | Flagged the new runtime menu, distinguished the historical ZIP export, added npm quick start, translations and accessible copy feedback. |
|
||||||
|
| Runnable package preview and visual polish | 14:50 | 15:02 | 12 min | Complete | Added high-contrast syntax coloring and an inline preview rendered by `@bible-strong/avatar-react`, with restartable `idle` playback. |
|
||||||
|
| Final audit, documentation and archive | 15:02 | 15:20 | 18 min | Complete | Updated the implementation status, archived completed specs, recorded verification evidence and prepared the detailed commit. |
|
||||||
|
|
||||||
|
**Recorded total: approximately 1 h 22 min.**
|
||||||
|
|
||||||
|
### Verification record
|
||||||
|
|
||||||
|
- `pnpm check`: 19 test files and 162 tests passed; typecheck, generated engine, package builds and
|
||||||
|
production Studio build passed.
|
||||||
|
- `npm pack --dry-run --json`: 30 allow-listed core files and 10 React files.
|
||||||
|
- `pnpm packages:smoke`: actual tarballs installed, typechecked and built in a clean non-workspace
|
||||||
|
React/Vite consumer.
|
||||||
|
- Browser checks: semantic playback, embedded/floating rendering, dragging, mobile overflow,
|
||||||
|
runtime export recovery, formatted JSON copy, syntax coloring and live package preview verified.
|
||||||
|
- Known unrelated local-preview noise: Vercel Analytics and Speed Insights scripts return 404 when
|
||||||
|
served outside Vercel.
|
||||||
40
docs/PUBLISHING.md
Normal file
40
docs/PUBLISHING.md
Normal file
@ -0,0 +1,40 @@
|
|||||||
|
# Publishing the runtime packages
|
||||||
|
|
||||||
|
The runtime is published as three public npm packages:
|
||||||
|
|
||||||
|
- `@bible-strong/avatar-core` contains the validation, playback and renderer-neutral scene APIs;
|
||||||
|
- `@bible-strong/avatar-react` depends on core and provides the React 19 integration;
|
||||||
|
- `@bible-strong/avatar-web` depends on core and provides the direct DOM/ESM integration.
|
||||||
|
|
||||||
|
All three packages keep the same version during the `0.x` stabilization period. Semantic Versioning
|
||||||
|
is applied as follows:
|
||||||
|
|
||||||
|
- a compatible fix increments the patch version (`0.1.0` to `0.1.1`);
|
||||||
|
- a compatible feature increments the minor version (`0.1.0` to `0.2.0`);
|
||||||
|
- a breaking change also increments the minor version while below `1.0.0`;
|
||||||
|
- after `1.0.0`, a breaking change increments the major version.
|
||||||
|
|
||||||
|
## Normal release flow
|
||||||
|
|
||||||
|
1. Add a changeset to every pull request that changes a published API with `pnpm changeset`.
|
||||||
|
2. Merge changes into `main`.
|
||||||
|
3. The release workflow updates or creates a release pull request containing version and changelog
|
||||||
|
changes.
|
||||||
|
4. Review and merge that release pull request.
|
||||||
|
5. The workflow validates, builds and publishes every unpublished version to npm.
|
||||||
|
|
||||||
|
Publication uses npm trusted publishing through GitHub Actions OIDC. It does not require a stored
|
||||||
|
`NPM_TOKEN`. Each npm package must trust the `release.yml` workflow in the
|
||||||
|
`smontlouis/bible-strong-avatar-lab` repository.
|
||||||
|
|
||||||
|
## Local verification
|
||||||
|
|
||||||
|
Run the complete project checks and verify real consumer projects against packed tarballs:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm check
|
||||||
|
pnpm packages:smoke
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not run `npm publish` from an individual package for routine releases. The initial `0.1.0`
|
||||||
|
bootstrap publication is the only manual release.
|
||||||
198
docs/specs/20260814-vite-dev-server-performance.md
Normal file
198
docs/specs/20260814-vite-dev-server-performance.md
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
# Vite Dev Server Performance Fix
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Draft - impact review completed on 2026-08-14; corrections and Eric's approval are still required.
|
||||||
|
|
||||||
|
## Impact review - 2026-08-14
|
||||||
|
|
||||||
|
### Decision
|
||||||
|
|
||||||
|
Do not implement this draft unchanged. The watcher and compiler-scope changes are isolated from the
|
||||||
|
runtime packages, but parts of the diagnosis and validation procedure are inaccurate for the current
|
||||||
|
workspace.
|
||||||
|
|
||||||
|
### Verified current state
|
||||||
|
|
||||||
|
- The installed workspace versions are Vite 8.2.1, `@vitejs/plugin-react` 6.0.5,
|
||||||
|
`@rolldown/plugin-babel` 0.2.3, and `@tailwindcss/vite` 4.3.3. The version sentence in the Problem
|
||||||
|
section reflects earlier manifest ranges, not the current lockfile.
|
||||||
|
- `fsevents` 2.3.3 is installed through Vite. Chokidar uses FSEvents when it is available; polling on
|
||||||
|
macOS is the fallback when FSEvents cannot be used. The draft must not state that Vite
|
||||||
|
unconditionally polls on macOS.
|
||||||
|
- Two already-running dev servers for this repository were sampled five times while idle on
|
||||||
|
2026-08-14. They reported 0-0.1% CPU, so the reported ~200% condition was not reproduced during
|
||||||
|
this review. A before/after measurement under the condition that triggers the problem is required.
|
||||||
|
- Vite 8.2.1 already ignores `.git`, `node_modules`, `test-results`, `cacheDir`, and every emptied
|
||||||
|
build `outDir`. Adding `node_modules` and root `dist` to `server.watch.ignored` is therefore
|
||||||
|
defensive and mostly redundant, not a fix for a missing default.
|
||||||
|
- The installed React Compiler preset already has a code filter, but it still admits many pure files
|
||||||
|
containing capitalized identifiers or hook-like names. Restricting Babel to actual JSX/TSX source
|
||||||
|
remains a credible optimization.
|
||||||
|
|
||||||
|
### Impact on the avatar-runtime work
|
||||||
|
|
||||||
|
| Area | Impact |
|
||||||
|
| ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| Root Studio dev server | Direct. `server.watch` and the root Babel plugin change startup, idle watching, and HMR behavior. |
|
||||||
|
| `packages/avatar-core` | Source HMR must remain enabled through the root alias. Its own build config is unaffected. The Babel compiler can safely exclude this package because it contains no JSX. |
|
||||||
|
| `packages/avatar-react` | Its library build config is unaffected. A generic `/src/...tsx/` regex also matches `packages/avatar-react/src/Avatar.tsx`, contrary to the draft's "app only" explanation; an app-root-anchored filter is required. |
|
||||||
|
| React consumer fixture | No direct impact because it owns a separate Vite config. Its HMR needs a separate smoke check only if that config is changed later. |
|
||||||
|
| Vitest, tarball smoke, production packages | No direct impact. Their processes/configs are separate, but the normal regression suite must still pass. |
|
||||||
|
| Standalone engine generation | No idle impact. `pnpm dev` runs `pnpm engine` once before Vite, so startup CPU must be measured separately from steady-state Vite CPU. |
|
||||||
|
|
||||||
|
### Required corrections before approval
|
||||||
|
|
||||||
|
1. Reword cause 1 as a hypothesis about an FSEvents fallback and capture evidence that the affected
|
||||||
|
process is actually polling.
|
||||||
|
2. Reword cause 2 to acknowledge Vite's existing ignore defaults. Add only project-specific paths if
|
||||||
|
measurements show that they are watched.
|
||||||
|
3. Anchor the Babel include filter to the absolute root `src/` directory. The proposed
|
||||||
|
`/src\/.*\.[jt]sx$/` expression is not root-specific.
|
||||||
|
4. Record both manifest ranges and installed lockfile versions, and repeat measurements after any
|
||||||
|
dependency install.
|
||||||
|
5. Start a single strict-port dev server for measurement and resolve its exact PID. `pgrep -f vite`
|
||||||
|
is ambiguous on this machine because several unrelated Vite servers run concurrently.
|
||||||
|
6. Measure startup separately from at least 30 seconds of idle CPU, an HMR edit under root `src/`,
|
||||||
|
and an HMR edit under `packages/avatar-core/src/`.
|
||||||
|
7. Treat the Vite 7 downgrade as a separate decision that modifies `package.json` and
|
||||||
|
`pnpm-lock.yaml`; this contradicts the current "vite.config.ts only" file list.
|
||||||
|
8. Re-evaluate the cited Vite 8/Rolldown memory reports against Vite 8.2.1 and the installed Rolldown
|
||||||
|
version before using an 800 MB downgrade threshold.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
The dev server (`pnpm dev`) consumes ~200% CPU on macOS. The project runs Vite 8.0.13 with
|
||||||
|
`@vitejs/plugin-react` 6.0.2, `@rolldown/plugin-babel` (React Compiler preset),
|
||||||
|
`@tailwindcss/vite` 4.3.3, and a pnpm workspace monorepo (`packages/avatar-core` linked via
|
||||||
|
`workspace:*`).
|
||||||
|
|
||||||
|
Four documented causes have been identified, ordered by expected impact.
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
### 1. File watcher defaults to polling on macOS
|
||||||
|
|
||||||
|
Vite inherits a legacy default that sets `usePolling: true` on macOS. Instead of using the
|
||||||
|
kernel's native `FSEvents`, chokidar scans every watched file on a timer. In a monorepo with
|
||||||
|
`node_modules` symlinks this produces sustained CPU even when no file changes.
|
||||||
|
|
||||||
|
**Source**: https://github.com/vitejs/vite/issues/21033
|
||||||
|
|
||||||
|
### 2. Watcher scope includes node_modules and dist
|
||||||
|
|
||||||
|
No `server.watch.ignored` is configured. The workspace alias
|
||||||
|
`@bible-strong/avatar-core → packages/avatar-core/src/index.ts` causes the watcher to follow
|
||||||
|
pnpm symlinks into `node_modules/.pnpm`, multiplying the number of watched paths.
|
||||||
|
|
||||||
|
### 3. React Compiler runs on all files
|
||||||
|
|
||||||
|
`@rolldown/plugin-babel` with `reactCompilerPreset()` is applied globally. It processes every
|
||||||
|
`.ts`/`.tsx` file including `packages/avatar-core`, which contains zero React components — pure
|
||||||
|
geometry, math, and schema validation. The compiler's analysis pass is expensive and wasted on
|
||||||
|
non-component code.
|
||||||
|
|
||||||
|
**Source**: https://github.com/vitejs/vite-plugin-react/discussions/1148
|
||||||
|
|
||||||
|
### 4. Vite 8 (Rolldown) baseline memory regression
|
||||||
|
|
||||||
|
Vite 8's Rolldown bundler uses ~1.1 GB within 37 seconds vs ~300 MB for Vite 5. Higher memory
|
||||||
|
pressure triggers frequent GC cycles that manifest as CPU usage.
|
||||||
|
|
||||||
|
**Sources**:
|
||||||
|
|
||||||
|
- https://github.com/vitejs/rolldown-vite/issues/577
|
||||||
|
- https://github.com/rolldown/rolldown/issues/9330
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
All changes are in `/vite.config.ts` (root). No other files are modified.
|
||||||
|
|
||||||
|
### Change 1 — Disable polling and scope the watcher
|
||||||
|
|
||||||
|
Add a `server` block to the Vite config:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
server: {
|
||||||
|
watch: {
|
||||||
|
usePolling: false,
|
||||||
|
ignored: ['**/node_modules/**', '**/dist/**'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Why `usePolling: false`**: macOS FSEvents is reliable and near-zero CPU. The polling
|
||||||
|
fallback exists for network filesystems (NFS/SMB) which do not apply here.
|
||||||
|
|
||||||
|
**Why `ignored`**: prevents chokidar from traversing pnpm's `.pnpm` store and build output
|
||||||
|
directories. These paths never contain source files that need HMR.
|
||||||
|
|
||||||
|
### Change 2 — Scope the Babel React Compiler to app source only
|
||||||
|
|
||||||
|
Current config (line 14):
|
||||||
|
|
||||||
|
```ts
|
||||||
|
plugins: [react(), babel({ presets: [reactCompilerPreset()] }), tailwindcss()],
|
||||||
|
```
|
||||||
|
|
||||||
|
Add an `include` filter to the `babel()` call:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
plugins: [
|
||||||
|
react(),
|
||||||
|
babel({
|
||||||
|
presets: [reactCompilerPreset()],
|
||||||
|
include: [/src\/.*\.[jt]sx$/],
|
||||||
|
}),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
The pattern `src\/.*\.[jt]sx$` matches only `.jsx`/`.tsx` files under `src/` (the app).
|
||||||
|
It excludes:
|
||||||
|
|
||||||
|
- `packages/avatar-core/` (no React components)
|
||||||
|
- `.ts` files that are pure logic (no JSX to compile)
|
||||||
|
|
||||||
|
### Change 3 — Monitor and consider Vite downgrade (conditional)
|
||||||
|
|
||||||
|
If changes 1–2 do not bring CPU below ~30% idle:
|
||||||
|
|
||||||
|
1. Run `pnpm dev` and note RSS memory after 60 seconds (`ps -o rss -p $(pgrep -f vite)`).
|
||||||
|
2. If RSS exceeds 800 MB, the Rolldown memory regression is contributing. Consider pinning
|
||||||
|
Vite 7 (`"vite": "^7.0.0"`) until Rolldown stabilises. This requires:
|
||||||
|
- Replacing `@rolldown/plugin-babel` with standard `@vitejs/plugin-react` Babel config
|
||||||
|
(v5 style), since `@rolldown/plugin-babel` is Vite 8–specific.
|
||||||
|
- Verifying the Tailwind v4 plugin remains compatible with Vite 7.
|
||||||
|
|
||||||
|
This step is **not** part of the default changeset — only pursue it if the first two changes
|
||||||
|
are insufficient.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
After applying changes 1–2:
|
||||||
|
|
||||||
|
1. `pnpm dev` — verify the server starts and HMR works (edit a `.tsx` in `src/`, confirm
|
||||||
|
hot reload).
|
||||||
|
2. Monitor CPU for 30 seconds idle: `top -pid $(pgrep -f vite) -l 5`. Expect < 10% idle CPU
|
||||||
|
(down from ~200%).
|
||||||
|
3. Edit a file in `packages/avatar-core/src/` — verify HMR still picks up the change despite
|
||||||
|
the watcher `ignored` pattern (the alias resolves to source, which is under the project
|
||||||
|
root and not under `node_modules`).
|
||||||
|
4. `pnpm build` — verify production build still succeeds (the `server` block does not affect
|
||||||
|
build).
|
||||||
|
5. `pnpm test` — verify no test regressions.
|
||||||
|
|
||||||
|
## Files modified
|
||||||
|
|
||||||
|
| File | Nature of change |
|
||||||
|
| ---------------- | ------------------------------------------------------------------- |
|
||||||
|
| `vite.config.ts` | Add `server.watch` config, add `include` filter to `babel()` plugin |
|
||||||
|
|
||||||
|
## Out of scope
|
||||||
|
|
||||||
|
- Upgrading or downgrading Vite (unless change 3 is triggered).
|
||||||
|
- Modifying `packages/avatar-core/vite.config.ts` (library build config, not the dev server).
|
||||||
|
- Changing `vitest.config.ts` (test runner, separate process).
|
||||||
|
- Tailwind v4 plugin tuning (4.3.3 has no known performance issues per
|
||||||
|
https://github.com/tailwindlabs/tailwindcss/issues/16911, fixed since 4.0.10).
|
||||||
@ -0,0 +1,882 @@
|
|||||||
|
# Avatar Runtime NPM Package and Semantic API
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Complete and archived on 2026-08-14. React v1 phases A-E are implemented and verified. The packages
|
||||||
|
remain private and must not be published until licensing and repository metadata are approved.
|
||||||
|
Product decisions 1 to 21 were recorded on 2026-08-14.
|
||||||
|
|
||||||
|
### Implementation progress - 2026-08-14
|
||||||
|
|
||||||
|
| Phase | State | Evidence |
|
||||||
|
| -------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
|
| A - Contract and pure conversion | Complete | The v1 JSON Schema, bounded duplicate-detecting parser, Ajv validation, public types, Studio conversion and focused boundary tests are implemented. |
|
||||||
|
| B - Semantic Studio authoring | Complete | The approved bundled catalog has semantic keys. The Studio validates keys, reports export readiness, downloads `.avatar.json`, copies formatted JSON accessibly, and keeps English, French and Simplified Chinese copy synchronized. |
|
||||||
|
| C - Extract core package | Complete | `@bible-strong/avatar-core` contains the framework-independent contract, geometry, scene generation, semantic lookup and deterministic playback. ESM, declarations and documented entry points build successfully. |
|
||||||
|
| D - React renderer package | Complete | `@bible-strong/avatar-react` implements React 19 refs, embedded/floating SVG, SSR portal handoff, controlled/uncontrolled playback, direct per-frame rendering, pointer/keyboard movement, bounds, resize behavior and accessible movement controls. |
|
||||||
|
| E - Existing export integration and package verification | Complete | Both packages build and pack. The smoke script installs their actual tarballs in a clean non-workspace React/Vite consumer, typechecks and builds it. Browser checks covered semantic controls, embedded/floating rendering, drag, reduced motion, mobile overflow, console errors and network responses. |
|
||||||
|
| F - Additional framework adapters | Deferred | Vue and Angular remain explicitly outside the React v1 scope and require a separate go-ahead. |
|
||||||
|
|
||||||
|
### Archive state
|
||||||
|
|
||||||
|
Implementation was completed on branch `avatar-runtime`. Do not publish either package and do not
|
||||||
|
start Vue or Angular adapters without a separate product decision.
|
||||||
|
|
||||||
|
#### Runtime export bug reported on 2026-08-14
|
||||||
|
|
||||||
|
The Studio displayed one missing-key error for every bundled Expression and Animation, followed by
|
||||||
|
many secondary unresolved-reference errors. This made the active Strobi avatar appear impossible to
|
||||||
|
export.
|
||||||
|
|
||||||
|
The fix is implemented:
|
||||||
|
|
||||||
|
- when the known bundled catalog is loaded without semantic keys, its approved keys are restored by
|
||||||
|
durable internal ID;
|
||||||
|
- custom Expressions and Animations are never assigned invented keys;
|
||||||
|
- unresolved-reference consequences are hidden when an Expression key error already explains the
|
||||||
|
problem, and identical messages are deduplicated;
|
||||||
|
- the incomplete-export card offers a destructive `Clear local project and reload` action that
|
||||||
|
removes only the Studio document storage key before loading the bundled catalog again;
|
||||||
|
- the same repair applies to base behavior and Avatar-owned behavior;
|
||||||
|
- focused persistence tests cover the repaired bundled catalog and prove that a custom item remains
|
||||||
|
unkeyed.
|
||||||
|
|
||||||
|
The Export accordion flags `Export runtime JSON` as new and distinguishes it from the pre-existing
|
||||||
|
`Export avatar` ZIP generator. The former exports runtime data for the npm packages; the latter
|
||||||
|
continues to generate the historical standalone React or JavaScript ZIP.
|
||||||
|
The runtime section also includes a compact `<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.
|
||||||
@ -0,0 +1,89 @@
|
|||||||
|
# Avatar Runtime Semantic Curation
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Approved by Éric on 2026-08-14. This document is the product/design source for the committed
|
||||||
|
`STANDARD_ANIMATIONS_V1` runtime manifest.
|
||||||
|
|
||||||
|
Implementation state: complete. The approved expression keys, explicit animation keys, and
|
||||||
|
`STANDARD_ANIMATIONS_V1` manifest are present in the bundled Studio data and runtime contract. This
|
||||||
|
approval gate no longer blocks phase C; overall implementation progress is tracked in
|
||||||
|
`20260814-avatar-runtime-npm-package-and-semantic-api.md`.
|
||||||
|
|
||||||
|
It implements the human-validation gate required by
|
||||||
|
`20260814-avatar-runtime-npm-package-and-semantic-api.md`, phase B, step 1. No semantic key or
|
||||||
|
standard-animation mapping below may be copied into bundled data or runtime code before explicit
|
||||||
|
approval. That approval has now been recorded below.
|
||||||
|
|
||||||
|
## Expression catalogue proposal
|
||||||
|
|
||||||
|
The 27 bundled Studio expressions were rendered with the framework-independent SVG geometry engine
|
||||||
|
and visually inventoried. Internal IDs remain editor-only identifiers and will not be exposed by the
|
||||||
|
runtime.
|
||||||
|
|
||||||
|
| Internal expression ID | Proposed semantic key | Visual reading |
|
||||||
|
| ------------------------------------------------- | ----------------------- | ---------------------------------------- |
|
||||||
|
| `expression-00` | `upward-side-glance` | Eyes raised toward one side |
|
||||||
|
| `expression-01` | `downward-gaze` | Medium eyes looking downward |
|
||||||
|
| `expression-05` | `skeptical-right` | One vertical eye and one horizontal eye |
|
||||||
|
| `expression-06` | `small-attentive` | Small attentive eyes |
|
||||||
|
| `expression-12` | `wide-downward-gaze` | Large round eyes looking downward |
|
||||||
|
| `expression-03` | `surprised-left` | Large offset eyes with a tilted head |
|
||||||
|
| `expression-04` | `sleepy-squint` | Narrow tired eyes |
|
||||||
|
| `expression-07` | `angry-right` | Tense angular gaze |
|
||||||
|
| `expression-08` | `curious-left` | Uneven sideways gaze |
|
||||||
|
| `expression-09` | `asymmetric-down-right` | One large and one small eye |
|
||||||
|
| `expression-10` | `attentive-left` | Slightly tilted attentive gaze |
|
||||||
|
| `expression-11` | `joyful-wide` | Very large open eyes |
|
||||||
|
| `expression-13` | `eyes-closed` | Closed, lowered eyes |
|
||||||
|
| `expression-02` | `joyful-down-right` | Large eyes angled downward |
|
||||||
|
| `expression-14` | `skeptical-left` | Opposite skeptical gaze |
|
||||||
|
| `expression-15` | `far-right-glance` | Small eyes strongly offset to one side |
|
||||||
|
| `expression-16` | `angry-left` | Opposite angular gaze |
|
||||||
|
| `expression-17` | `playful-right` | Lively uneven gaze |
|
||||||
|
| `expression-18` | `asymmetric-up-left` | One large and one small raised eye |
|
||||||
|
| `expression-19` | `gentle-downward-gaze` | Soft downward gaze |
|
||||||
|
| `expression-20` | `wide-down-left` | Very large lowered eyes |
|
||||||
|
| `expression-21` | `surprised-wide-left` | Wide round eyes, alternate surprise pose |
|
||||||
|
| `expression-22` | `drowsy-closed` | Nearly closed raised eyes |
|
||||||
|
| `expression-23` | `suspicious-right` | Wary sideways gaze |
|
||||||
|
| `expression-24` | `shy-downward` | Small lowered eyes |
|
||||||
|
| `expression-3d2bed26-f97c-477d-922f-77600cb10e92` | `angry-brows` | Strongly inward-sloping eyes |
|
||||||
|
| `expression-5220eaee-32fe-4bd8-ad31-432189534cc8` | `uneasy-left` | Uneven worried gaze |
|
||||||
|
|
||||||
|
## Explicit animation-key proposal
|
||||||
|
|
||||||
|
The 23 bundled animations already have unique English machine IDs. The proposal is to use each
|
||||||
|
existing ID unchanged as its `semanticKey`:
|
||||||
|
|
||||||
|
`sleeping`, `waking`, `idle`, `listening`, `thinking`, `searching`, `working`, `excited`, `bored`,
|
||||||
|
`suspicious`, `angry`, `drowsy`, `happy`, `curious`, `confused`, `surprised`, `proud`, `shy`, `sad`,
|
||||||
|
`laughing`, `scared`, `playful`, and `celebrate`.
|
||||||
|
|
||||||
|
## `STANDARD_ANIMATIONS_V1` proposal
|
||||||
|
|
||||||
|
This proposal retains the established Studio sequence order, timing, playback, and blink behavior.
|
||||||
|
All steps use `transitionMs: 500` and `transition: "smooth"`.
|
||||||
|
|
||||||
|
| Standard key | Ordered semantic-expression steps | `holdMs` per step | Playback mode | Blink: initial / min / max / duration (ms) |
|
||||||
|
| ------------ | ---------------------------------------------------------------------------------------------------- | ----------------: | ------------- | ------------------------------------------ |
|
||||||
|
| `idle` | `upward-side-glance` -> `curious-left` | 5200 | `loop` | 2600 / 3400 / 6200 / 280 |
|
||||||
|
| `happy` | `joyful-down-right` -> `joyful-wide` -> `playful-right` -> `gentle-downward-gaze` | 2300 | `loop` | 2100 / 2800 / 5000 / 260 |
|
||||||
|
| `sad` | `sleepy-squint` -> `eyes-closed` -> `drowsy-closed` | 3600 | `loop` | 4800 / 6500 / 9500 / 420 |
|
||||||
|
| `thinking` | `curious-left` -> `angry-left` -> `skeptical-left` -> `playful-right` -> `skeptical-right` | 2300 | `loop` | 2100 / 2800 / 5000 / 260 |
|
||||||
|
| `excited` | `joyful-down-right` -> `playful-right` -> `surprised-wide-left` -> `surprised-left` -> `joyful-wide` | 2300 | `loop` | 1200 / 1800 / 3600 / 220 |
|
||||||
|
| `celebrate` | `joyful-down-right` -> `curious-left` -> `playful-right` | 2300 | `loop` | 1200 / 1800 / 3600 / 220 |
|
||||||
|
|
||||||
|
For each row, the required expression keys are the unique keys appearing in its ordered steps. An
|
||||||
|
explicit animation with the same key will override the corresponding standard animation, as required
|
||||||
|
by the v1 runtime specification.
|
||||||
|
|
||||||
|
## Approval record
|
||||||
|
|
||||||
|
Approver: Éric
|
||||||
|
|
||||||
|
Decision date: 2026-08-14
|
||||||
|
|
||||||
|
Decision: approved as proposed
|
||||||
|
|
||||||
|
Requested changes: none recorded
|
||||||
13
examples/react-vite-consumer/index.html
Normal file
13
examples/react-vite-consumer/index.html
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'/%3E" />
|
||||||
|
<title>Avatar React consumer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
examples/react-vite-consumer/package.json
Normal file
23
examples/react-vite-consumer/package.json
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "avatar-react-vite-consumer",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc --noEmit && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bible-strong/avatar-core": "workspace:*",
|
||||||
|
"@bible-strong/avatar-react": "workspace:*",
|
||||||
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
|
"vite": "^8.0.13",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
101
examples/react-vite-consumer/src/main.tsx
Normal file
101
examples/react-vite-consumer/src/main.tsx
Normal file
@ -0,0 +1,101 @@
|
|||||||
|
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
|
import { Avatar, createAvatar } from '@bible-strong/avatar-react'
|
||||||
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
|
import { StrictMode, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import definitionJson from './strobi.avatar.json'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
const validation = validateAvatarDefinition(definitionJson)
|
||||||
|
if (!validation.ok) throw new Error(validation.errors[0]?.message)
|
||||||
|
const definition = validation.value as AvatarDefinition
|
||||||
|
const StrobiAvatar = createAvatar(definitionJson)
|
||||||
|
type StrobiExpressionKey = keyof typeof definitionJson.expressions
|
||||||
|
const expressions = Object.keys(definition.expressions) as StrobiExpressionKey[]
|
||||||
|
const horizontalPosition = (expression: string) =>
|
||||||
|
expression.includes('left') ? 'left' : expression.includes('right') ? 'right' : 'center'
|
||||||
|
const verticalPosition = (expression: string) =>
|
||||||
|
expression.includes('up') ? 'up' : expression.includes('down') ? 'down' : 'middle'
|
||||||
|
const expressionGroups = [
|
||||||
|
['up', 'left', 'Up left'],
|
||||||
|
['up', 'center', 'Up'],
|
||||||
|
['up', 'right', 'Up right'],
|
||||||
|
['middle', 'left', 'Left'],
|
||||||
|
['middle', 'center', 'Neutral'],
|
||||||
|
['middle', 'right', 'Right'],
|
||||||
|
['down', 'left', 'Down left'],
|
||||||
|
['down', 'center', 'Down'],
|
||||||
|
['down', 'right', 'Down right'],
|
||||||
|
].map(([vertical, horizontal, label]) => ({
|
||||||
|
key: `${vertical}-${horizontal}`,
|
||||||
|
label,
|
||||||
|
expressions: expressions.filter(
|
||||||
|
expression =>
|
||||||
|
verticalPosition(expression) === vertical && horizontalPosition(expression) === horizontal
|
||||||
|
),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const formatExpressionName = (expression: string) =>
|
||||||
|
expression.replaceAll('-', ' ').replace(/\b\w/g, letter => letter.toUpperCase())
|
||||||
|
|
||||||
|
function Demo() {
|
||||||
|
const [expression, setExpression] = useState<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>
|
||||||
|
)
|
||||||
1732
examples/react-vite-consumer/src/strobi.avatar.json
Normal file
1732
examples/react-vite-consumer/src/strobi.avatar.json
Normal file
File diff suppressed because it is too large
Load Diff
222
examples/react-vite-consumer/src/styles.css
Normal file
222
examples/react-vite-consumer/src/styles.css
Normal file
@ -0,0 +1,222 @@
|
|||||||
|
:root {
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
color: #111827;
|
||||||
|
background: #f8fafc;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
justify-items: center;
|
||||||
|
gap: 24px;
|
||||||
|
min-height: 100dvh;
|
||||||
|
padding: 48px 16px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-host {
|
||||||
|
width: min(100%, 360px);
|
||||||
|
min-height: 280px;
|
||||||
|
display: grid;
|
||||||
|
place-items: center;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-overlay {
|
||||||
|
position: fixed;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 24px;
|
||||||
|
z-index: 10;
|
||||||
|
width: 128px;
|
||||||
|
height: 128px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker {
|
||||||
|
width: min(100%, 720px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__heading {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1.125rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker output {
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__list {
|
||||||
|
display: grid;
|
||||||
|
grid-template-areas:
|
||||||
|
'up-left up-center up-right'
|
||||||
|
'middle-left middle-center middle-right'
|
||||||
|
'down-left down-center down-right';
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
grid-template-rows: 1fr 3fr 1fr;
|
||||||
|
align-items: center;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
min-height: 640px;
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 16px;
|
||||||
|
padding: 32px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, #ffffff 0 28%, #eef2ff 28.25% 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group h3 {
|
||||||
|
margin: 0 0 8px;
|
||||||
|
color: #475569;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
text-align: center;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--up-left {
|
||||||
|
grid-area: up-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--up-center {
|
||||||
|
grid-area: up-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--up-right {
|
||||||
|
grid-area: up-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-left {
|
||||||
|
grid-area: middle-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-center {
|
||||||
|
grid-area: middle-center;
|
||||||
|
padding: 16px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: white;
|
||||||
|
box-shadow: 0 8px 24px rgb(15 23 42 / 8%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-center .expression-picker__group-list {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-right {
|
||||||
|
grid-area: middle-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--down-left {
|
||||||
|
grid-area: down-left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--down-center {
|
||||||
|
grid-area: down-center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--down-right {
|
||||||
|
grid-area: down-right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group-list {
|
||||||
|
display: grid;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group button {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 52px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border: 1px solid #cbd5e1;
|
||||||
|
border-radius: 12px;
|
||||||
|
color: #1e293b;
|
||||||
|
background: white;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
transition:
|
||||||
|
border-color 160ms ease,
|
||||||
|
background-color 160ms ease,
|
||||||
|
color 160ms ease,
|
||||||
|
transform 160ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group button:hover {
|
||||||
|
border-color: #818cf8;
|
||||||
|
background: #eef2ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group button:active {
|
||||||
|
transform: scale(0.98);
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
min-height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 999px;
|
||||||
|
color: white;
|
||||||
|
background: #4338ca;
|
||||||
|
font: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.is-selected {
|
||||||
|
border-color: #312e81;
|
||||||
|
background: #312e81;
|
||||||
|
color: white;
|
||||||
|
box-shadow: 0 0 0 3px #c7d2fe;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:focus-visible {
|
||||||
|
outline: 3px solid #4338ca;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 640px) {
|
||||||
|
.expression-picker__heading {
|
||||||
|
align-items: flex-start;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__list {
|
||||||
|
grid-template-areas: none;
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-rows: none;
|
||||||
|
aspect-ratio: auto;
|
||||||
|
min-height: 0;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group {
|
||||||
|
grid-area: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expression-picker__group--middle-center {
|
||||||
|
padding: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
examples/react-vite-consumer/src/vite-env.d.ts
vendored
Normal file
1
examples/react-vite-consumer/src/vite-env.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
15
examples/react-vite-consumer/tsconfig.json
Normal file
15
examples/react-vite-consumer/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"skipLibCheck": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"]
|
||||||
|
}
|
||||||
4
examples/react-vite-consumer/vite.config.ts
Normal file
4
examples/react-vite-consumer/vite.config.ts
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [react()] })
|
||||||
21
examples/web-vite-consumer/index.html
Normal file
21
examples/web-vite-consumer/index.html
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Avatar Web consumer</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<div id="avatar"></div>
|
||||||
|
<div class="controls">
|
||||||
|
<button type="button" data-animation="sleeping">Sleeping</button>
|
||||||
|
<button type="button" data-animation="idle">Idle</button>
|
||||||
|
<button type="button" data-expression="neutral">Neutral</button>
|
||||||
|
<button type="button" id="pause">Pause</button>
|
||||||
|
<button type="button" id="stop">Stop</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
16
examples/web-vite-consumer/package.json
Normal file
16
examples/web-vite-consumer/package.json
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"name": "avatar-web-vite-consumer",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsc --noEmit && vite build"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bible-strong/avatar-web": "workspace:*"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13"
|
||||||
|
}
|
||||||
|
}
|
||||||
21
examples/web-vite-consumer/src/main.ts
Normal file
21
examples/web-vite-consumer/src/main.ts
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
|
||||||
|
import definition from '../../react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
defaultAnimation: 'sleeping',
|
||||||
|
size: '100%',
|
||||||
|
})
|
||||||
|
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-animation]').forEach(button => {
|
||||||
|
button.addEventListener('click', () => avatar.play(button.dataset.animation ?? 'idle'))
|
||||||
|
})
|
||||||
|
document.querySelectorAll<HTMLButtonElement>('[data-expression]').forEach(button => {
|
||||||
|
button.addEventListener('click', () =>
|
||||||
|
avatar.setExpression(button.dataset.expression ?? 'neutral')
|
||||||
|
)
|
||||||
|
})
|
||||||
|
document.querySelector('#pause')?.addEventListener('click', () => avatar.pause())
|
||||||
|
document.querySelector('#stop')?.addEventListener('click', () => avatar.stop())
|
||||||
41
examples/web-vite-consumer/src/styles.css
Normal file
41
examples/web-vite-consumer/src/styles.css
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
:root {
|
||||||
|
color: #17191d;
|
||||||
|
background: #f4f6fa;
|
||||||
|
font-family: Inter, system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
display: grid;
|
||||||
|
min-height: 100vh;
|
||||||
|
margin: 0;
|
||||||
|
place-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
display: grid;
|
||||||
|
width: min(480px, calc(100% - 32px));
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#avatar {
|
||||||
|
width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid #dce1ea;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 13px;
|
||||||
|
border: 1px solid #cfd6e2;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
13
examples/web-vite-consumer/tsconfig.json
Normal file
13
examples/web-vite-consumer/tsconfig.json
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
13
package.json
13
package.json
@ -29,12 +29,20 @@
|
|||||||
"test:watch": "vitest",
|
"test:watch": "vitest",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"format:check": "prettier --check .",
|
"format:check": "prettier --check .",
|
||||||
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm build"
|
"packages:build": "pnpm --filter './packages/*' build",
|
||||||
|
"packages:smoke": "node scripts/smoke-packages.mjs",
|
||||||
|
"changeset": "changeset",
|
||||||
|
"version-packages": "changeset version",
|
||||||
|
"release": "pnpm packages:build && changeset publish",
|
||||||
|
"check": "pnpm engine:check && pnpm format:check && pnpm packages:build && pnpm typecheck && pnpm test && pnpm build"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@base-ui/react": "^1.7.0",
|
"@base-ui/react": "^1.7.0",
|
||||||
|
"@bible-strong/avatar-core": "workspace:*",
|
||||||
|
"@bible-strong/avatar-react": "workspace:*",
|
||||||
"@vercel/analytics": "^2.0.1",
|
"@vercel/analytics": "^2.0.1",
|
||||||
"@vercel/speed-insights": "^2.0.0",
|
"@vercel/speed-insights": "^2.0.0",
|
||||||
|
"ajv": "^8.20.0",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^1.31.0",
|
"lucide-react": "^1.31.0",
|
||||||
@ -47,13 +55,16 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@babel/core": "^7.29.0",
|
"@babel/core": "^7.29.0",
|
||||||
"@babel/parser": "^7.28.6",
|
"@babel/parser": "^7.28.6",
|
||||||
|
"@changesets/cli": "^3.0.0",
|
||||||
"@rolldown/plugin-babel": "^0.2.3",
|
"@rolldown/plugin-babel": "^0.2.3",
|
||||||
"@tailwindcss/vite": "^4.3.3",
|
"@tailwindcss/vite": "^4.3.3",
|
||||||
|
"@testing-library/react": "^16.3.2",
|
||||||
"@types/node": "^24.10.1",
|
"@types/node": "^24.10.1",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.2",
|
"@vitejs/plugin-react": "^6.0.2",
|
||||||
"babel-plugin-react-compiler": "^1.0.0",
|
"babel-plugin-react-compiler": "^1.0.0",
|
||||||
|
"jsdom": "^30.0.1",
|
||||||
"prettier": "^3.8.1",
|
"prettier": "^3.8.1",
|
||||||
"rolldown": "^1.0.0-rc.5",
|
"rolldown": "^1.0.0-rc.5",
|
||||||
"tailwindcss": "^4.3.3",
|
"tailwindcss": "^4.3.3",
|
||||||
|
|||||||
661
packages/avatar-core/LICENSE
Normal file
661
packages/avatar-core/LICENSE
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
80
packages/avatar-core/README.md
Normal file
80
packages/avatar-core/README.md
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
# @bible-strong/avatar-core
|
||||||
|
|
||||||
|
Framework-independent validation, semantic playback and renderer-neutral SVG scene generation for
|
||||||
|
Bible Strong procedural avatars. The package has no React, DOM, Motion or browser-storage
|
||||||
|
dependency.
|
||||||
|
|
||||||
|
## Install and validate
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add @bible-strong/avatar-core
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `parseAvatarDefinition` for untrusted JSON text. It enforces the 256 KiB/depth limits and
|
||||||
|
detects duplicate object keys before validating against the v1 schema. Use
|
||||||
|
`validateAvatarDefinition` when the value is already materialized.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import { parseAvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
const parsed = parseAvatarDefinition(jsonText)
|
||||||
|
if (!parsed.ok) {
|
||||||
|
throw new Error(`${parsed.errors[0].path}: ${parsed.errors[0].message}`)
|
||||||
|
}
|
||||||
|
const definition = parsed.value
|
||||||
|
```
|
||||||
|
|
||||||
|
Both functions return a non-mutating discriminated result. Successful values are deeply frozen;
|
||||||
|
errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
|
||||||
|
`@bible-strong/avatar-core/schema`.
|
||||||
|
|
||||||
|
Definitions from the earlier pre-release runtime export may still contain
|
||||||
|
`standardAnimationSet: 1`; the marker is accepted for compatibility but does not add any
|
||||||
|
implicit animations.
|
||||||
|
|
||||||
|
## Semantic lookup and playback
|
||||||
|
|
||||||
|
Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
|
||||||
|
for keys that are not present in the validated definition. Every animation is explicit in the
|
||||||
|
definition, so the JSON remains the single source of truth for what an avatar can play.
|
||||||
|
|
||||||
|
```ts
|
||||||
|
import {
|
||||||
|
advanceAvatarPlayback,
|
||||||
|
playAvatarAnimation,
|
||||||
|
renderAvatarFrame,
|
||||||
|
} from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
const started = playAvatarAnimation(definition, 'idle', 0)
|
||||||
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
|
||||||
|
const state = advanceAvatarPlayback(definition, started.value, 500, {
|
||||||
|
random: () => 0.5,
|
||||||
|
})
|
||||||
|
const scene = renderAvatarFrame(definition, state, 500, {
|
||||||
|
random: () => 0.5,
|
||||||
|
reduceMotion: false,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`advanceAvatarPlayback` is a pure state transition driven by a monotonic timestamp and injected
|
||||||
|
random source. The timeline for each step is transition then hold. `pauseAvatarPlayback` and
|
||||||
|
`resumeAvatarPlayback` preserve exact progress. With `reduceMotion: true`, transitions and ambient
|
||||||
|
motion jump deterministically to their target while configured blinks remain active.
|
||||||
|
|
||||||
|
`renderAvatarDefinition` renders a static semantic expression. `renderAvatarFrame` renders an
|
||||||
|
animated frame. Both return paths, visibility and resolved colors without creating DOM nodes.
|
||||||
|
|
||||||
|
## Entry points
|
||||||
|
|
||||||
|
- `@bible-strong/avatar-core`: contract, validation, semantic catalog, playback and scene APIs.
|
||||||
|
- `@bible-strong/avatar-core/schema`: the v1 Draft 2020-12 JSON Schema.
|
||||||
|
- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
|
||||||
|
primitives for renderer authors.
|
||||||
|
|
||||||
|
Application integrations are kept in separate packages: `@bible-strong/avatar-react` renders with
|
||||||
|
React 19, while `@bible-strong/avatar-web` mounts the same definition directly into the DOM. Both
|
||||||
|
depend on this package and use the same playback and scene implementation.
|
||||||
|
|
||||||
|
The package follows Semantic Versioning. While it remains below `1.0.0`, breaking API changes
|
||||||
|
increment the minor version and fixes increment the patch version.
|
||||||
71
packages/avatar-core/package.json
Normal file
71
packages/avatar-core/package.json
Normal file
@ -0,0 +1,71 @@
|
|||||||
|
{
|
||||||
|
"name": "@bible-strong/avatar-core",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Framework-independent runtime for Bible Strong procedural avatars.",
|
||||||
|
"keywords": [
|
||||||
|
"avatar",
|
||||||
|
"svg",
|
||||||
|
"animation",
|
||||||
|
"renderer",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "Stéphane Montlouis-Calixte",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"homepage": "https://github.com/smontlouis/bible-strong-avatar-lab#readme",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/smontlouis/bible-strong-avatar-lab.git",
|
||||||
|
"directory": "packages/avatar-core"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/smontlouis/bible-strong-avatar-lab/issues"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./geometry": {
|
||||||
|
"types": "./dist/geometry.d.ts",
|
||||||
|
"import": "./dist/geometry.js"
|
||||||
|
},
|
||||||
|
"./body": {
|
||||||
|
"types": "./dist/body.d.ts",
|
||||||
|
"import": "./dist/body.js"
|
||||||
|
},
|
||||||
|
"./surfaces": {
|
||||||
|
"types": "./dist/surfaces.d.ts",
|
||||||
|
"import": "./dist/surfaces.js"
|
||||||
|
},
|
||||||
|
"./ambient-motion": {
|
||||||
|
"types": "./dist/ambientMotion.d.ts",
|
||||||
|
"import": "./dist/ambientMotion.js"
|
||||||
|
},
|
||||||
|
"./schema": "./dist/avatarDefinition.schema.json"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
|
||||||
|
"prepack": "pnpm build",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"ajv": "^8.20.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
389
packages/avatar-core/src/__tests__/core-test.ts
Normal file
389
packages/avatar-core/src/__tests__/core-test.ts
Normal file
@ -0,0 +1,389 @@
|
|||||||
|
import {
|
||||||
|
advanceAvatarPlayback,
|
||||||
|
bodyFromDefinition,
|
||||||
|
createAvatarPlaybackState,
|
||||||
|
expressionFromDefinition,
|
||||||
|
parseAvatarDefinition,
|
||||||
|
playAvatarAnimation,
|
||||||
|
pauseAvatarPlayback,
|
||||||
|
poseFromExpression,
|
||||||
|
renderAvatar,
|
||||||
|
renderAvatarDefinition,
|
||||||
|
renderAvatarFrame,
|
||||||
|
resumeAvatarPlayback,
|
||||||
|
resolveAnimation,
|
||||||
|
sampleAvatarFrame,
|
||||||
|
type AvatarDefinition,
|
||||||
|
} from '../index'
|
||||||
|
|
||||||
|
const expression = {
|
||||||
|
head: { x: 0, y: 0, z: 0 },
|
||||||
|
eyes: {
|
||||||
|
left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
|
||||||
|
right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
|
||||||
|
spacing: 54,
|
||||||
|
},
|
||||||
|
perspective: 1,
|
||||||
|
motion: { eyes: 'none', body: 'none' },
|
||||||
|
} as const
|
||||||
|
|
||||||
|
const definition: AvatarDefinition = {
|
||||||
|
schema: 'bible-strong/avatar-definition',
|
||||||
|
schemaVersion: 1,
|
||||||
|
name: 'Core fixture',
|
||||||
|
body: {
|
||||||
|
primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
colors: { body: '#5b7fe5', eyes: '#111316' },
|
||||||
|
expressions: {
|
||||||
|
neutral: expression,
|
||||||
|
'upward-side-glance': { ...expression, head: { x: -8, y: 18, z: -4 } },
|
||||||
|
'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
|
||||||
|
},
|
||||||
|
expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
|
||||||
|
animations: {
|
||||||
|
idle: {
|
||||||
|
playbackMode: 'loop',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
expression: 'upward-side-glance',
|
||||||
|
holdMs: 5_200,
|
||||||
|
transitionMs: 500,
|
||||||
|
transition: 'smooth',
|
||||||
|
},
|
||||||
|
{ expression: 'curious-left', holdMs: 5_200, transitionMs: 500, transition: 'smooth' },
|
||||||
|
],
|
||||||
|
blink: {
|
||||||
|
enabled: true,
|
||||||
|
initialDelayMs: 2_600,
|
||||||
|
minIntervalMs: 3_400,
|
||||||
|
maxIntervalMs: 6_200,
|
||||||
|
durationMs: 280,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationOrder: ['idle'],
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('@bible-strong/avatar-core', () => {
|
||||||
|
it('loads a JSON definition and resolves an explicit semantic animation', () => {
|
||||||
|
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
||||||
|
expect(parsed.ok).toBe(true)
|
||||||
|
if (!parsed.ok) return
|
||||||
|
|
||||||
|
const idle = resolveAnimation(parsed.value, 'idle')
|
||||||
|
expect(idle.ok).toBe(true)
|
||||||
|
if (!idle.ok) return
|
||||||
|
expect(idle.value.steps.map(step => step.expression)).toEqual([
|
||||||
|
'upward-side-glance',
|
||||||
|
'curious-left',
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('accepts the legacy standard-animation marker without restoring hidden animations', () => {
|
||||||
|
const legacy = {
|
||||||
|
...definition,
|
||||||
|
animations: {},
|
||||||
|
animationOrder: [],
|
||||||
|
standardAnimationSet: 1 as const,
|
||||||
|
}
|
||||||
|
const parsed = parseAvatarDefinition(JSON.stringify(legacy))
|
||||||
|
expect(parsed.ok).toBe(true)
|
||||||
|
if (!parsed.ok) return
|
||||||
|
|
||||||
|
expect(resolveAnimation(parsed.value, 'idle')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'unknown_animation', key: 'idle' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('advances playback deterministically from transition to hold and the next step', () => {
|
||||||
|
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||||
|
expect(started.ok).toBe(true)
|
||||||
|
if (!started.ok) return
|
||||||
|
|
||||||
|
const holding = advanceAvatarPlayback(definition, started.value, 1_500, {
|
||||||
|
random: () => 0.5,
|
||||||
|
})
|
||||||
|
expect(holding).toMatchObject({
|
||||||
|
activeAnimation: 'idle',
|
||||||
|
activeExpression: 'upward-side-glance',
|
||||||
|
phase: 'hold',
|
||||||
|
status: 'playing',
|
||||||
|
})
|
||||||
|
|
||||||
|
const next = advanceAvatarPlayback(definition, holding, 6_701, { random: () => 0.5 })
|
||||||
|
expect(next).toMatchObject({
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
phase: 'transition',
|
||||||
|
stepIndex: 1,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates and completes a direct expression transition', () => {
|
||||||
|
const from = sampleAvatarFrame(definition, createAvatarPlaybackState(), 1_000, {
|
||||||
|
random: () => 0.5,
|
||||||
|
})
|
||||||
|
const state = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from,
|
||||||
|
startedAt: 1_000,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const start = renderAvatarFrame(definition, state, 1_000, { random: () => 0.5 })
|
||||||
|
const midway = renderAvatarFrame(definition, state, 1_200, { random: () => 0.5 })
|
||||||
|
const end = renderAvatarFrame(definition, state, 1_400, { random: () => 0.5 })
|
||||||
|
|
||||||
|
expect(midway.geometry).not.toEqual(start.geometry)
|
||||||
|
expect(midway.geometry).not.toEqual(end.geometry)
|
||||||
|
expect(advanceAvatarPlayback(definition, state, 1_400, { random: () => 0.5 })).toMatchObject({
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts a new animation from the currently displayed frame instead of neutral', () => {
|
||||||
|
const current = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
}
|
||||||
|
const now = 1_000
|
||||||
|
const from = sampleAvatarFrame(definition, current, now, { random: () => 0.5 })
|
||||||
|
const started = playAvatarAnimation(definition, 'idle', now, from)
|
||||||
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
|
||||||
|
expect(renderAvatarFrame(definition, started.value, now, { random: () => 0.5 })).toEqual(
|
||||||
|
renderAvatarFrame(definition, current, now, { random: () => 0.5 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('retargets a direct transition from its exact in-flight frame', () => {
|
||||||
|
const neutral = createAvatarPlaybackState()
|
||||||
|
const firstStartedAt = 1_000
|
||||||
|
const first = {
|
||||||
|
...neutral,
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: sampleAvatarFrame(definition, neutral, firstStartedAt, { random: () => 0.5 }),
|
||||||
|
startedAt: firstStartedAt,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const retargetedAt = 1_200
|
||||||
|
const inFlight = sampleAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
|
||||||
|
const second = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: 'upward-side-glance',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: inFlight,
|
||||||
|
startedAt: retargetedAt,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderAvatarFrame(definition, second, retargetedAt, { random: () => 0.5 })).toEqual(
|
||||||
|
renderAvatarFrame(definition, first, retargetedAt, { random: () => 0.5 })
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates expression color overrides during a transition', () => {
|
||||||
|
const colored: AvatarDefinition = {
|
||||||
|
...definition,
|
||||||
|
expressions: {
|
||||||
|
...definition.expressions,
|
||||||
|
'curious-left': {
|
||||||
|
...definition.expressions['curious-left'],
|
||||||
|
colors: { body: '#ff0000', eyes: '#ffffff' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const neutral = createAvatarPlaybackState()
|
||||||
|
const state = {
|
||||||
|
...neutral,
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from: sampleAvatarFrame(colored, neutral, 1_000, { random: () => 0.5 }),
|
||||||
|
startedAt: 1_000,
|
||||||
|
durationMs: 400,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(renderAvatarFrame(colored, state, 1_200, { random: () => 0.5 }).colors).toEqual({
|
||||||
|
body: '#ad4073',
|
||||||
|
eyes: '#88898b',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('generates the same geometry through the public definition adapter', () => {
|
||||||
|
const scene = renderAvatarDefinition(definition, 'curious-left')
|
||||||
|
const body = bodyFromDefinition(definition.body)
|
||||||
|
const internalExpression = expressionFromDefinition(
|
||||||
|
'curious-left',
|
||||||
|
definition.expressions['curious-left']
|
||||||
|
)
|
||||||
|
const direct = renderAvatar(poseFromExpression(internalExpression), body.primary, 1, {
|
||||||
|
bodyNodes: body.nodes,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(scene.geometry).toEqual(direct)
|
||||||
|
expect(scene.colors).toEqual(definition.colors)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
|
||||||
|
const narrow: AvatarDefinition = {
|
||||||
|
...definition,
|
||||||
|
body: {
|
||||||
|
primary: {
|
||||||
|
type: 'cube',
|
||||||
|
width: 199.00018,
|
||||||
|
height: 200,
|
||||||
|
depth: 200,
|
||||||
|
roundness: 0.5,
|
||||||
|
},
|
||||||
|
nodes: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const wide: AvatarDefinition = {
|
||||||
|
...narrow,
|
||||||
|
body: {
|
||||||
|
...narrow.body,
|
||||||
|
primary: { ...narrow.body.primary, width: 199.00022 },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
const narrowScene = renderAvatarDefinition(narrow)
|
||||||
|
const wideScene = renderAvatarDefinition(wide)
|
||||||
|
|
||||||
|
expect(wideScene.geometry.headPath).not.toBe(narrowScene.geometry.headPath)
|
||||||
|
expect(wideScene.geometry.wirePaths).not.toEqual(narrowScene.geometry.wirePaths)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('starts from the documented neutral stopped state', () => {
|
||||||
|
expect(createAvatarPlaybackState()).toMatchObject({
|
||||||
|
activeExpression: 'neutral',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('interpolates a bounded transition and freezes its exact progress while paused', () => {
|
||||||
|
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||||
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
const neutral = renderAvatarDefinition(definition, 'neutral')
|
||||||
|
const target = renderAvatarDefinition(definition, 'upward-side-glance')
|
||||||
|
const halfway = renderAvatarFrame(definition, started.value, 1_250, {
|
||||||
|
random: () => 0.5,
|
||||||
|
})
|
||||||
|
expect(halfway.geometry.leftPath).not.toBe(neutral.geometry.leftPath)
|
||||||
|
expect(halfway.geometry.leftPath).not.toBe(target.geometry.leftPath)
|
||||||
|
|
||||||
|
const paused = pauseAvatarPlayback(started.value, 1_250)
|
||||||
|
const resumed = resumeAvatarPlayback(paused, 4_250)
|
||||||
|
expect(resumed.phaseStartedAt).toBe(4_000)
|
||||||
|
const resumedFrame = renderAvatarFrame(definition, resumed, 4_250, {
|
||||||
|
random: () => 0.5,
|
||||||
|
})
|
||||||
|
expect(resumedFrame.geometry.leftPath).toBe(halfway.geometry.leftPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the injectable random source for a deterministic blink timeline', () => {
|
||||||
|
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||||
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
const blinking = advanceAvatarPlayback(definition, started.value, 3_600, {
|
||||||
|
random: () => 0,
|
||||||
|
})
|
||||||
|
expect(blinking.blinkStartedAt).toBe(3_600)
|
||||||
|
expect(blinking.blinkDueAt).toBe(7_280)
|
||||||
|
const open = renderAvatarFrame(definition, blinking, 3_600, { random: () => 0 })
|
||||||
|
const closed = renderAvatarFrame(definition, blinking, 3_740, { random: () => 0 })
|
||||||
|
expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns a typed error for an animation that is not present in the definition', () => {
|
||||||
|
expect(resolveAnimation(definition, 'missing')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'unknown_animation', key: 'missing' },
|
||||||
|
})
|
||||||
|
expect(resolveAnimation(definition, 'happy')).toMatchObject({
|
||||||
|
ok: false,
|
||||||
|
error: { code: 'unknown_animation', key: 'happy' },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resolves an explicit animation by its semantic key', () => {
|
||||||
|
const overridden: AvatarDefinition = {
|
||||||
|
...definition,
|
||||||
|
animations: {
|
||||||
|
idle: {
|
||||||
|
playbackMode: 'once',
|
||||||
|
steps: [{ expression: 'neutral', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
|
||||||
|
blink: {
|
||||||
|
enabled: false,
|
||||||
|
initialDelayMs: 0,
|
||||||
|
minIntervalMs: 1_000,
|
||||||
|
maxIntervalMs: 1_000,
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationOrder: ['idle'],
|
||||||
|
}
|
||||||
|
expect(resolveAnimation(overridden, 'idle')).toMatchObject({
|
||||||
|
ok: true,
|
||||||
|
value: { playbackMode: 'once', steps: [{ expression: 'neutral' }] },
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('completes once playback and deterministically removes transition motion', () => {
|
||||||
|
const onceDefinition: AvatarDefinition = {
|
||||||
|
...definition,
|
||||||
|
animations: {
|
||||||
|
once: {
|
||||||
|
playbackMode: 'once',
|
||||||
|
steps: [
|
||||||
|
{
|
||||||
|
expression: 'curious-left',
|
||||||
|
holdMs: 100,
|
||||||
|
transitionMs: 100,
|
||||||
|
transition: 'smooth',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
blink: {
|
||||||
|
enabled: false,
|
||||||
|
initialDelayMs: 0,
|
||||||
|
minIntervalMs: 1_000,
|
||||||
|
maxIntervalMs: 1_000,
|
||||||
|
durationMs: 100,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
animationOrder: ['once'],
|
||||||
|
}
|
||||||
|
const started = playAvatarAnimation(onceDefinition, 'once', 0)
|
||||||
|
if (!started.ok) throw new Error(started.error.message)
|
||||||
|
const reduced = renderAvatarFrame(onceDefinition, started.value, 50, {
|
||||||
|
random: () => 0.5,
|
||||||
|
reduceMotion: true,
|
||||||
|
})
|
||||||
|
expect(reduced.geometry.leftPath).toBe(
|
||||||
|
renderAvatarDefinition(onceDefinition, 'curious-left').geometry.leftPath
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
advanceAvatarPlayback(onceDefinition, started.value, 200, { random: () => 0.5 })
|
||||||
|
).toMatchObject({
|
||||||
|
activeExpression: 'curious-left',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
115
packages/avatar-core/src/ambientMotion.ts
Normal file
115
packages/avatar-core/src/ambientMotion.ts
Normal file
@ -0,0 +1,115 @@
|
|||||||
|
import type { BodyMotion, Expression, EyeMotion } from './geometry'
|
||||||
|
|
||||||
|
export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const
|
||||||
|
export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const
|
||||||
|
const eyeMotionSet = new Set<string>(eyeMotionModes)
|
||||||
|
const bodyMotionSet = new Set<string>(bodyMotionModes)
|
||||||
|
export const isEyeMotion = (value: unknown): value is EyeMotion =>
|
||||||
|
typeof value === 'string' && eyeMotionSet.has(value)
|
||||||
|
export const isBodyMotion = (value: unknown): value is BodyMotion =>
|
||||||
|
typeof value === 'string' && bodyMotionSet.has(value)
|
||||||
|
|
||||||
|
const smoothstep = (value: number) => value * value * (3 - 2 * value)
|
||||||
|
const hash = (value: number) => {
|
||||||
|
const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
|
||||||
|
return (raw - Math.floor(raw)) * 2 - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressionSeed = (expression: Expression) =>
|
||||||
|
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
|
||||||
|
const EYE_MOTION_SEED = 17.29
|
||||||
|
|
||||||
|
const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => {
|
||||||
|
const progress = elapsedMs / interval
|
||||||
|
const step = Math.floor(progress)
|
||||||
|
const blend = smoothstep(progress - step)
|
||||||
|
const previous = hash(step * 3 + axis + seed)
|
||||||
|
const next = hash((step + 1) * 3 + axis + seed)
|
||||||
|
return previous + (next - previous) * blend
|
||||||
|
}
|
||||||
|
|
||||||
|
const saccade = (elapsedMs: number, axis: number, seed: number) => {
|
||||||
|
const interval = 1100
|
||||||
|
const duration = 140
|
||||||
|
if (elapsedMs <= 0) return 0
|
||||||
|
const step = Math.floor(elapsedMs / interval)
|
||||||
|
const progress = (elapsedMs - step * interval) / duration
|
||||||
|
const blend = smoothstep(Math.min(progress, 1))
|
||||||
|
const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
|
||||||
|
const next = hash(step * 2 + axis + seed)
|
||||||
|
return previous + (next - previous) * blend
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasAmbientMotion = (expression: Expression) =>
|
||||||
|
expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
|
||||||
|
|
||||||
|
export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
|
||||||
|
const seed = expressionSeed(expression)
|
||||||
|
if (expression.bodyMotion === 'slowDrift') {
|
||||||
|
return {
|
||||||
|
x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
|
||||||
|
y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expression.bodyMotion === 'shake') {
|
||||||
|
const time = elapsedMs / 1000
|
||||||
|
return {
|
||||||
|
x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
|
||||||
|
y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
|
||||||
|
if (expression.eyeMotion === 'microSaccades') {
|
||||||
|
return {
|
||||||
|
x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
|
||||||
|
y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (expression.eyeMotion === 'shake') {
|
||||||
|
const time = elapsedMs / 1000
|
||||||
|
return {
|
||||||
|
x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
|
||||||
|
y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { x: 0, y: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyAmbientBodyMotion = (
|
||||||
|
expression: Expression,
|
||||||
|
elapsedMs: number,
|
||||||
|
strength = 1
|
||||||
|
): Expression => {
|
||||||
|
const next = { ...expression }
|
||||||
|
const seed = expressionSeed(expression)
|
||||||
|
|
||||||
|
if (expression.bodyMotion === 'slowDrift') {
|
||||||
|
next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
|
||||||
|
next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
|
||||||
|
next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
|
||||||
|
} else if (expression.bodyMotion === 'shake') {
|
||||||
|
const time = elapsedMs / 1000
|
||||||
|
next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
|
||||||
|
next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
|
||||||
|
next.headZ += Math.sin(time * 43) * 0.7 * strength
|
||||||
|
}
|
||||||
|
|
||||||
|
return next
|
||||||
|
}
|
||||||
|
|
||||||
|
export const applyAmbientMotion = (
|
||||||
|
expression: Expression,
|
||||||
|
elapsedMs: number,
|
||||||
|
strength = 1
|
||||||
|
): Expression => {
|
||||||
|
const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
|
||||||
|
const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
|
||||||
|
next.positionXLeft += eyeOffset.x
|
||||||
|
next.positionXRight += eyeOffset.x
|
||||||
|
next.positionYLeft += eyeOffset.y
|
||||||
|
next.positionYRight += eyeOffset.y
|
||||||
|
return next
|
||||||
|
}
|
||||||
248
packages/avatar-core/src/avatarDefinition.schema.json
Normal file
248
packages/avatar-core/src/avatarDefinition.schema.json
Normal file
@ -0,0 +1,248 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"$id": "https://avatars.bible-strong.app/schemas/avatar-definition-v1.json",
|
||||||
|
"title": "Bible Strong Avatar Definition v1",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"schema",
|
||||||
|
"schemaVersion",
|
||||||
|
"body",
|
||||||
|
"colors",
|
||||||
|
"expressions",
|
||||||
|
"expressionOrder",
|
||||||
|
"animations",
|
||||||
|
"animationOrder"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"schema": { "const": "bible-strong/avatar-definition" },
|
||||||
|
"schemaVersion": { "const": 1 },
|
||||||
|
"name": { "type": "string", "maxLength": 120 },
|
||||||
|
"body": { "$ref": "#/$defs/body" },
|
||||||
|
"colors": { "$ref": "#/$defs/colors" },
|
||||||
|
"expressions": {
|
||||||
|
"type": "object",
|
||||||
|
"minProperties": 1,
|
||||||
|
"maxProperties": 128,
|
||||||
|
"required": ["neutral"],
|
||||||
|
"propertyNames": { "$ref": "#/$defs/semanticKey" },
|
||||||
|
"properties": { "neutral": { "$ref": "#/$defs/expression" } },
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/expression" }
|
||||||
|
},
|
||||||
|
"expressionOrder": {
|
||||||
|
"type": "array",
|
||||||
|
"minItems": 1,
|
||||||
|
"maxItems": 128,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "$ref": "#/$defs/semanticKey" }
|
||||||
|
},
|
||||||
|
"animations": {
|
||||||
|
"type": "object",
|
||||||
|
"maxProperties": 64,
|
||||||
|
"propertyNames": { "$ref": "#/$defs/semanticKey" },
|
||||||
|
"additionalProperties": { "$ref": "#/$defs/animation" }
|
||||||
|
},
|
||||||
|
"animationOrder": {
|
||||||
|
"type": "array",
|
||||||
|
"maxItems": 64,
|
||||||
|
"uniqueItems": true,
|
||||||
|
"items": { "$ref": "#/$defs/semanticKey" }
|
||||||
|
},
|
||||||
|
"standardAnimationSet": { "const": 1, "deprecated": true }
|
||||||
|
},
|
||||||
|
"$defs": {
|
||||||
|
"semanticKey": {
|
||||||
|
"type": "string",
|
||||||
|
"maxLength": 64,
|
||||||
|
"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
|
||||||
|
},
|
||||||
|
"hexColor": { "type": "string", "pattern": "^#[0-9a-f]{6}$" },
|
||||||
|
"boundedNumber": { "type": "number", "minimum": -10000, "maximum": 10000 },
|
||||||
|
"dimension": { "type": "number", "minimum": 0.001, "maximum": 10000 },
|
||||||
|
"roundness": { "type": "number", "minimum": 0, "maximum": 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 }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
487
packages/avatar-core/src/avatarDefinition.ts
Normal file
487
packages/avatar-core/src/avatarDefinition.ts
Normal file
@ -0,0 +1,487 @@
|
|||||||
|
import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js'
|
||||||
|
import avatarDefinitionSchema from './avatarDefinition.schema.json'
|
||||||
|
import type { SurfaceType } from './surfaces'
|
||||||
|
|
||||||
|
export const AVATAR_DEFINITION_MAX_BYTES = 262_144
|
||||||
|
export const AVATAR_DEFINITION_MAX_DEPTH = 32
|
||||||
|
const MAX_JSON_STRING_LENGTH = 512
|
||||||
|
export const SEMANTIC_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
||||||
|
|
||||||
|
export type SemanticKeyIssueCode =
|
||||||
|
'missing_semantic_key' | 'invalid_semantic_key' | 'reserved_semantic_key'
|
||||||
|
|
||||||
|
export const getSemanticKeyIssue = (
|
||||||
|
semanticKey: string | undefined,
|
||||||
|
kind: 'expression' | 'animation'
|
||||||
|
): SemanticKeyIssueCode | undefined => {
|
||||||
|
if (!semanticKey) return 'missing_semantic_key'
|
||||||
|
if (!SEMANTIC_KEY_PATTERN.test(semanticKey) || semanticKey.length > 64) {
|
||||||
|
return 'invalid_semantic_key'
|
||||||
|
}
|
||||||
|
if (kind === 'expression' && semanticKey === 'neutral') return 'reserved_semantic_key'
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SemanticKey = string
|
||||||
|
export type ExpressionKey = SemanticKey
|
||||||
|
export type AnimationKey = SemanticKey
|
||||||
|
export type HexColor = `#${string}`
|
||||||
|
|
||||||
|
export type AvatarColorsDefinition = {
|
||||||
|
body: HexColor
|
||||||
|
eyes: HexColor
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SurfaceDefinition<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`
|
||||||
|
}
|
||||||
117
packages/avatar-core/src/body.ts
Normal file
117
packages/avatar-core/src/body.ts
Normal file
@ -0,0 +1,117 @@
|
|||||||
|
import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
|
||||||
|
|
||||||
|
export type BodyVector = readonly [number, number, number]
|
||||||
|
|
||||||
|
export type BodyNode = {
|
||||||
|
id: string
|
||||||
|
name: string
|
||||||
|
surface: SurfaceConfig
|
||||||
|
position: BodyVector
|
||||||
|
rotation: BodyVector
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarBody = {
|
||||||
|
primary: SurfaceConfig
|
||||||
|
nodes: BodyNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const bodyPrimitiveTypes = [
|
||||||
|
'sphere',
|
||||||
|
'cube',
|
||||||
|
'capsule',
|
||||||
|
'cylinder',
|
||||||
|
'cone',
|
||||||
|
'diamond',
|
||||||
|
] as const
|
||||||
|
|
||||||
|
export const MAX_BODY_NODES = 16
|
||||||
|
|
||||||
|
const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[]
|
||||||
|
const finite = (value: unknown): value is number =>
|
||||||
|
typeof value === 'number' && Number.isFinite(value)
|
||||||
|
const vector = (value: unknown): value is BodyVector =>
|
||||||
|
Array.isArray(value) && value.length === 3 && value.every(finite)
|
||||||
|
|
||||||
|
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||||
|
if (!value || typeof value !== 'object') return { ...fallback }
|
||||||
|
const candidate = value as Partial<SurfaceConfig>
|
||||||
|
const type =
|
||||||
|
candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type
|
||||||
|
const preset = surfacePresets[type]
|
||||||
|
const numericFields = ['width', 'height', 'depth', 'roundness'] as const
|
||||||
|
if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback }
|
||||||
|
if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness))
|
||||||
|
return { ...fallback }
|
||||||
|
if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness))
|
||||||
|
return { ...fallback }
|
||||||
|
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
|
||||||
|
return { ...fallback }
|
||||||
|
return { ...preset, ...candidate, type }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => {
|
||||||
|
if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] }
|
||||||
|
const candidate = value as Partial<AvatarBody>
|
||||||
|
const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
|
||||||
|
const seenIds = new Set<string>()
|
||||||
|
const nodes = Array.isArray(candidate.nodes)
|
||||||
|
? candidate.nodes
|
||||||
|
.filter((node): node is BodyNode => {
|
||||||
|
if (!node || typeof node !== 'object') return false
|
||||||
|
const surface = (node as BodyNode).surface
|
||||||
|
const id = (node as BodyNode).id
|
||||||
|
if (id === 'primary' || seenIds.has(id)) return false
|
||||||
|
const valid = Boolean(
|
||||||
|
typeof (node as BodyNode).id === 'string' &&
|
||||||
|
id &&
|
||||||
|
typeof (node as BodyNode).name === 'string' &&
|
||||||
|
surface &&
|
||||||
|
bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) &&
|
||||||
|
finite(surface.width) &&
|
||||||
|
finite(surface.height) &&
|
||||||
|
finite(surface.depth) &&
|
||||||
|
finite(surface.roundness) &&
|
||||||
|
vector((node as BodyNode).position) &&
|
||||||
|
vector((node as BodyNode).rotation)
|
||||||
|
)
|
||||||
|
if (valid) seenIds.add(id)
|
||||||
|
return valid
|
||||||
|
})
|
||||||
|
.slice(0, MAX_BODY_NODES)
|
||||||
|
.map(node => ({
|
||||||
|
...node,
|
||||||
|
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||||
|
}))
|
||||||
|
: []
|
||||||
|
return { primary, nodes }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createBodyNode = (
|
||||||
|
type: (typeof bodyPrimitiveTypes)[number],
|
||||||
|
index: number
|
||||||
|
): BodyNode => {
|
||||||
|
const preset = surfacePresets[type]
|
||||||
|
const scale = 0.34
|
||||||
|
const side = index % 2 === 0 ? -1 : 1
|
||||||
|
return {
|
||||||
|
id: `shape-${crypto.randomUUID()}`,
|
||||||
|
name: `${surfaceLabels[type]} ${index + 1}`,
|
||||||
|
surface: {
|
||||||
|
...preset,
|
||||||
|
width: preset.width * scale,
|
||||||
|
height: preset.height * scale,
|
||||||
|
depth: preset.depth * scale,
|
||||||
|
},
|
||||||
|
position: [side * 82, -72, -18],
|
||||||
|
rotation: [0, 0, 0],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const duplicateBodyNode = (source: BodyNode): BodyNode => ({
|
||||||
|
...source,
|
||||||
|
id: `shape-${crypto.randomUUID()}`,
|
||||||
|
name: `${source.name} copie`,
|
||||||
|
surface: { ...source.surface },
|
||||||
|
position: [source.position[0] + 14, source.position[1] + 14, source.position[2]],
|
||||||
|
rotation: [...source.rotation],
|
||||||
|
})
|
||||||
1261
packages/avatar-core/src/geometry.ts
Normal file
1261
packages/avatar-core/src/geometry.ts
Normal file
File diff suppressed because it is too large
Load Diff
7
packages/avatar-core/src/index.ts
Normal file
7
packages/avatar-core/src/index.ts
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export * from './ambientMotion'
|
||||||
|
export * from './avatarDefinition'
|
||||||
|
export * from './body'
|
||||||
|
export * from './geometry'
|
||||||
|
export * from './runtime'
|
||||||
|
export * from './scene'
|
||||||
|
export * from './surfaces'
|
||||||
348
packages/avatar-core/src/runtime.ts
Normal file
348
packages/avatar-core/src/runtime.ts
Normal file
@ -0,0 +1,348 @@
|
|||||||
|
import {
|
||||||
|
type AnimationKey,
|
||||||
|
type AvatarAnimationDefinition,
|
||||||
|
type AvatarDefinition,
|
||||||
|
type AvatarExpressionDefinition,
|
||||||
|
type ExpressionKey,
|
||||||
|
} from './avatarDefinition'
|
||||||
|
import { applyAmbientMotion } from './ambientMotion'
|
||||||
|
import { interpolatePose, poseFromExpression, type Expression } from './geometry'
|
||||||
|
import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
|
||||||
|
|
||||||
|
export type AvatarRuntimeError = {
|
||||||
|
code: 'unknown_animation' | 'unknown_expression'
|
||||||
|
key: string
|
||||||
|
message: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type AvatarCommandResult<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)
|
||||||
|
}
|
||||||
79
packages/avatar-core/src/scene.ts
Normal file
79
packages/avatar-core/src/scene.ts
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
import type {
|
||||||
|
AvatarBodyDefinition,
|
||||||
|
AvatarDefinition,
|
||||||
|
AvatarExpressionDefinition,
|
||||||
|
ExpressionKey,
|
||||||
|
} from './avatarDefinition'
|
||||||
|
import type { AvatarBody } from './body'
|
||||||
|
import { poseFromExpression, renderAvatar, type AvatarGeometry, type Expression } from './geometry'
|
||||||
|
|
||||||
|
export const expressionFromDefinition = (
|
||||||
|
key: ExpressionKey,
|
||||||
|
expression: AvatarExpressionDefinition
|
||||||
|
): Expression => ({
|
||||||
|
id: key,
|
||||||
|
semanticKey: key,
|
||||||
|
headX: expression.head.x,
|
||||||
|
headY: expression.head.y,
|
||||||
|
headZ: expression.head.z,
|
||||||
|
widthLeft: expression.eyes.left.width,
|
||||||
|
widthRight: expression.eyes.right.width,
|
||||||
|
heightLeft: expression.eyes.left.height,
|
||||||
|
heightRight: expression.eyes.right.height,
|
||||||
|
spacing: expression.eyes.spacing,
|
||||||
|
positionXLeft: expression.eyes.left.x,
|
||||||
|
positionXRight: expression.eyes.right.x,
|
||||||
|
positionYLeft: expression.eyes.left.y,
|
||||||
|
positionYRight: expression.eyes.right.y,
|
||||||
|
leftAngle: expression.eyes.left.angle,
|
||||||
|
rightAngle: expression.eyes.right.angle,
|
||||||
|
perspective: expression.perspective,
|
||||||
|
eyeMotion: expression.motion.eyes,
|
||||||
|
bodyMotion: expression.motion.body,
|
||||||
|
...(expression.colors?.body ? { bodyColor: expression.colors.body } : {}),
|
||||||
|
...(expression.colors?.eyes ? { eyeColor: expression.colors.eyes } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const bodyFromDefinition = (body: AvatarBodyDefinition): AvatarBody => ({
|
||||||
|
primary: { ...body.primary },
|
||||||
|
nodes: body.nodes.map((node, index) => ({
|
||||||
|
id: `runtime-node-${index}`,
|
||||||
|
name: `Runtime node ${index + 1}`,
|
||||||
|
surface: { ...node.surface },
|
||||||
|
position: [...node.position],
|
||||||
|
rotation: [...node.rotation],
|
||||||
|
})),
|
||||||
|
})
|
||||||
|
|
||||||
|
export type AvatarScene = {
|
||||||
|
geometry: AvatarGeometry
|
||||||
|
colors: { body: string; eyes: string }
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderAvatarExpression = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
expression: Expression,
|
||||||
|
colors: { body?: string; eyes?: string } = {},
|
||||||
|
blink = 1
|
||||||
|
): AvatarScene => {
|
||||||
|
const body = bodyFromDefinition(definition.body)
|
||||||
|
return {
|
||||||
|
geometry: renderAvatar(poseFromExpression(expression), body.primary, blink, {
|
||||||
|
bodyNodes: body.nodes,
|
||||||
|
}),
|
||||||
|
colors: {
|
||||||
|
body: colors.body ?? expression.bodyColor ?? definition.colors.body,
|
||||||
|
eyes: colors.eyes ?? expression.eyeColor ?? definition.colors.eyes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const renderAvatarDefinition = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
expressionKey: ExpressionKey = 'neutral'
|
||||||
|
): AvatarScene => {
|
||||||
|
const publicExpression = definition.expressions[expressionKey]
|
||||||
|
if (!publicExpression) throw new Error(`Unknown expression '${expressionKey}'`)
|
||||||
|
const expression = expressionFromDefinition(expressionKey, publicExpression)
|
||||||
|
return renderAvatarExpression(definition, expression, publicExpression.colors)
|
||||||
|
}
|
||||||
665
packages/avatar-core/src/surfaces.ts
Normal file
665
packages/avatar-core/src/surfaces.ts
Normal file
@ -0,0 +1,665 @@
|
|||||||
|
import type { Point3 } from './geometry'
|
||||||
|
|
||||||
|
export type SurfaceType =
|
||||||
|
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
|
||||||
|
|
||||||
|
export type SurfaceConfig = {
|
||||||
|
type: SurfaceType
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
depth: number
|
||||||
|
roundness: number
|
||||||
|
morphRoundness?: number
|
||||||
|
tipRoundness?: number
|
||||||
|
baseRoundness?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SurfaceSample = {
|
||||||
|
point: Point3
|
||||||
|
normal: Point3
|
||||||
|
}
|
||||||
|
|
||||||
|
export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
||||||
|
sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
||||||
|
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
||||||
|
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
||||||
|
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
|
||||||
|
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
|
||||||
|
cylinder: {
|
||||||
|
type: 'cylinder',
|
||||||
|
width: 235,
|
||||||
|
height: 250,
|
||||||
|
depth: 215,
|
||||||
|
roundness: 0.45,
|
||||||
|
morphRoundness: 0,
|
||||||
|
},
|
||||||
|
cone: {
|
||||||
|
type: 'cone',
|
||||||
|
width: 250,
|
||||||
|
height: 265,
|
||||||
|
depth: 225,
|
||||||
|
roundness: 0,
|
||||||
|
morphRoundness: 0,
|
||||||
|
tipRoundness: 0.55,
|
||||||
|
baseRoundness: 0.45,
|
||||||
|
},
|
||||||
|
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
||||||
|
}
|
||||||
|
|
||||||
|
export const surfaceLabels: Record<SurfaceType, string> = {
|
||||||
|
sphere: 'Sphère',
|
||||||
|
mickey: 'Mickey',
|
||||||
|
cursor: 'Curseur',
|
||||||
|
cube: 'Cube',
|
||||||
|
capsule: 'Capsule',
|
||||||
|
cylinder: 'Cylindre',
|
||||||
|
cone: 'Cône',
|
||||||
|
diamond: 'Diamant',
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedPower = (value: number, exponent: number) =>
|
||||||
|
Math.sign(value) * Math.abs(value) ** exponent
|
||||||
|
|
||||||
|
const superellipsoid = (
|
||||||
|
longitude: number,
|
||||||
|
latitude: number,
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
depth: number,
|
||||||
|
verticalExponent: number,
|
||||||
|
horizontalExponent: number
|
||||||
|
): Point3 => {
|
||||||
|
const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
|
||||||
|
return [
|
||||||
|
(width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
|
||||||
|
(height / 2) * signedPower(Math.sin(latitude), verticalExponent),
|
||||||
|
(depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||||
|
const radiusX = config.width / 2
|
||||||
|
const radiusZ = config.depth / 2
|
||||||
|
const capRadius = Math.min(radiusX, config.height / 2)
|
||||||
|
const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
|
||||||
|
const meridianLength = straightHalf * 2 + Math.PI * capRadius
|
||||||
|
const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
|
||||||
|
let radial = radiusX
|
||||||
|
let y = 0
|
||||||
|
|
||||||
|
if (distance < (Math.PI * capRadius) / 2) {
|
||||||
|
const angle = -Math.PI / 2 + distance / capRadius
|
||||||
|
radial = radiusX * Math.cos(angle)
|
||||||
|
y = -straightHalf + capRadius * Math.sin(angle)
|
||||||
|
} else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
|
||||||
|
y = -straightHalf + distance - (Math.PI * capRadius) / 2
|
||||||
|
} else {
|
||||||
|
const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
|
||||||
|
radial = radiusX * Math.cos(angle)
|
||||||
|
y = straightHalf + capRadius * Math.sin(angle)
|
||||||
|
}
|
||||||
|
|
||||||
|
const depthScale = radiusX ? radiusZ / radiusX : 1
|
||||||
|
return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
|
||||||
|
|
||||||
|
const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
|
||||||
|
|
||||||
|
const MIN_CUBE_SURFACE_POWER = 0.04
|
||||||
|
|
||||||
|
const cubeExponent = (config: SurfaceConfig) => {
|
||||||
|
if (config.roundness <= 0) return Infinity
|
||||||
|
// The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
|
||||||
|
const surfacePower =
|
||||||
|
MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
|
||||||
|
return 2 / surfacePower
|
||||||
|
}
|
||||||
|
|
||||||
|
const lpSurface = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
longitude: number,
|
||||||
|
latitude: number,
|
||||||
|
exponent: number
|
||||||
|
): Point3 => {
|
||||||
|
const sphereX = Math.cos(latitude) * Math.sin(longitude)
|
||||||
|
const sphereY = Math.sin(latitude)
|
||||||
|
const sphereZ = Math.cos(latitude) * Math.cos(longitude)
|
||||||
|
const length = Number.isFinite(exponent)
|
||||||
|
? (Math.abs(sphereX) ** exponent +
|
||||||
|
Math.abs(sphereY) ** exponent +
|
||||||
|
Math.abs(sphereZ) ** exponent) **
|
||||||
|
(1 / exponent) || 1
|
||||||
|
: Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
|
||||||
|
return [
|
||||||
|
(config.width / 2) * (sphereX / length),
|
||||||
|
(config.height / 2) * (sphereY / length),
|
||||||
|
(config.depth / 2) * (sphereZ / length),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||||
|
return lpSurface(config, longitude, latitude, diamondExponent(config))
|
||||||
|
}
|
||||||
|
|
||||||
|
const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
|
||||||
|
lpSurface(config, longitude, latitude, cubeExponent(config))
|
||||||
|
|
||||||
|
const MAX_CONE_TIP_FRACTION = 0.24
|
||||||
|
const MAX_CONE_BASE_FRACTION = 0.2
|
||||||
|
const MAX_CYLINDER_EDGE_FRACTION = 0.22
|
||||||
|
|
||||||
|
type RadialProfile = {
|
||||||
|
radiusScale: number
|
||||||
|
verticalProgress: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
|
||||||
|
|
||||||
|
const morphProfileToEllipsoid = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
progress: number,
|
||||||
|
profile: RadialProfile
|
||||||
|
): RadialProfile => {
|
||||||
|
const amount = morphProgress(config)
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||||
|
const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
|
||||||
|
const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
|
||||||
|
return {
|
||||||
|
radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
|
||||||
|
verticalProgress:
|
||||||
|
profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const cubic = (
|
||||||
|
start: number,
|
||||||
|
firstControl: number,
|
||||||
|
secondControl: number,
|
||||||
|
end: number,
|
||||||
|
progress: number
|
||||||
|
) => {
|
||||||
|
const inverse = 1 - progress
|
||||||
|
return (
|
||||||
|
inverse ** 3 * start +
|
||||||
|
3 * inverse * inverse * progress * firstControl +
|
||||||
|
3 * inverse * progress * progress * secondControl +
|
||||||
|
progress ** 3 * end
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const coneRounding = (config: SurfaceConfig) => ({
|
||||||
|
tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
|
||||||
|
baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
|
||||||
|
})
|
||||||
|
|
||||||
|
/** Cylinder half-profile with a quarter-round transition at both caps. */
|
||||||
|
const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||||
|
const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
|
||||||
|
if (edgeFraction <= 0) {
|
||||||
|
return {
|
||||||
|
radiusScale: 1,
|
||||||
|
verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clampedProgress < edgeFraction) {
|
||||||
|
const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
|
||||||
|
return {
|
||||||
|
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
||||||
|
verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (clampedProgress > 1 - edgeFraction) {
|
||||||
|
const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
|
||||||
|
return {
|
||||||
|
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
||||||
|
verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
|
||||||
|
return {
|
||||||
|
radiusScale: 1,
|
||||||
|
verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||||
|
morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
|
||||||
|
|
||||||
|
const radiusScaleAtVerticalProgress = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
verticalProgress: number,
|
||||||
|
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
|
||||||
|
) => {
|
||||||
|
const progress = Math.max(0, Math.min(1, verticalProgress))
|
||||||
|
let lower = 0
|
||||||
|
let upper = 1
|
||||||
|
for (let iteration = 0; iteration < 14; iteration += 1) {
|
||||||
|
const candidate = (lower + upper) / 2
|
||||||
|
if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
|
||||||
|
else upper = candidate
|
||||||
|
}
|
||||||
|
return profileAt(config, (lower + upper) / 2).radiusScale
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Rounded half-profile revolved around the cone's vertical axis. */
|
||||||
|
const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||||
|
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||||
|
const { tipFraction, baseFraction } = coneRounding(config)
|
||||||
|
|
||||||
|
if (baseFraction > 0 && clampedProgress < baseFraction) {
|
||||||
|
const curveProgress = clampedProgress / baseFraction
|
||||||
|
return {
|
||||||
|
radiusScale: cubic(
|
||||||
|
1 - baseFraction,
|
||||||
|
1,
|
||||||
|
1 - baseFraction / 2,
|
||||||
|
1 - baseFraction,
|
||||||
|
curveProgress
|
||||||
|
),
|
||||||
|
verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
|
||||||
|
const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
|
||||||
|
return {
|
||||||
|
radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
|
||||||
|
verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
radiusScale: 1 - clampedProgress,
|
||||||
|
verticalProgress: clampedProgress,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||||
|
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
|
||||||
|
|
||||||
|
export const cursorLayout = (config: SurfaceConfig) => {
|
||||||
|
const coneHeight = config.height * 0.36
|
||||||
|
const bodyHeight = config.height - coneHeight
|
||||||
|
return {
|
||||||
|
coneApexY: -config.height / 2,
|
||||||
|
coneBaseY: -config.height / 2 + coneHeight,
|
||||||
|
bodyHeight,
|
||||||
|
bodyCenterY: config.height / 2 - bodyHeight / 2,
|
||||||
|
bodyWidth: config.width * 0.54,
|
||||||
|
bodyDepth: config.depth * 0.62,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const surfacePointAt = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
longitude: number,
|
||||||
|
latitude: number
|
||||||
|
): Point3 => {
|
||||||
|
const { width, height, depth } = config
|
||||||
|
switch (config.type) {
|
||||||
|
case 'sphere':
|
||||||
|
case 'mickey':
|
||||||
|
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
|
||||||
|
case 'cube':
|
||||||
|
return cube(config, longitude, latitude)
|
||||||
|
case 'cylinder': {
|
||||||
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||||
|
const profile = morphedCylinderProfileAt(config, progress)
|
||||||
|
return [
|
||||||
|
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||||
|
-height / 2 + height * profile.verticalProgress,
|
||||||
|
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
case 'cursor': {
|
||||||
|
const layout = cursorLayout(config)
|
||||||
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||||
|
const bodyConfig = {
|
||||||
|
...config,
|
||||||
|
width: layout.bodyWidth,
|
||||||
|
height: layout.bodyHeight,
|
||||||
|
depth: layout.bodyDepth,
|
||||||
|
}
|
||||||
|
const profile = cylinderProfileAt(bodyConfig, progress)
|
||||||
|
return [
|
||||||
|
(layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
|
||||||
|
layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
|
||||||
|
(layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
case 'diamond':
|
||||||
|
return diamond(config, longitude, latitude)
|
||||||
|
case 'capsule':
|
||||||
|
return capsule(config, longitude, latitude)
|
||||||
|
case 'cone': {
|
||||||
|
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||||
|
const profile = morphedConeProfileAt(config, progress)
|
||||||
|
return [
|
||||||
|
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||||
|
height / 2 - height * profile.verticalProgress,
|
||||||
|
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const subtract = (left: Point3, right: Point3): Point3 => [
|
||||||
|
left[0] - right[0],
|
||||||
|
left[1] - right[1],
|
||||||
|
left[2] - right[2],
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalize = ([x, y, z]: Point3): Point3 => {
|
||||||
|
const length = Math.hypot(x, y, z) || 1
|
||||||
|
return [x / length, y / length, z / length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalFromTangents = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
longitudeTangent: Point3,
|
||||||
|
latitudeTangent: Point3
|
||||||
|
) => {
|
||||||
|
const orientation = config.type === 'cone' ? -1 : 1
|
||||||
|
return normalize([
|
||||||
|
orientation *
|
||||||
|
(longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
|
||||||
|
orientation *
|
||||||
|
(longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
|
||||||
|
orientation *
|
||||||
|
(longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
|
||||||
|
const epsilon = 0.0005
|
||||||
|
if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
|
||||||
|
const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
|
||||||
|
const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
|
||||||
|
const latitudeBefore = surfacePointAt(
|
||||||
|
config,
|
||||||
|
longitude,
|
||||||
|
Math.max(-Math.PI / 2, latitude - epsilon)
|
||||||
|
)
|
||||||
|
const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
|
||||||
|
return normalFromTangents(
|
||||||
|
config,
|
||||||
|
subtract(longitudeAfter, longitudeBefore),
|
||||||
|
subtract(latitudeAfter, latitudeBefore)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const signedMagnitude = (value: number, exponent: number) =>
|
||||||
|
Math.sign(value) * Math.abs(value) ** exponent
|
||||||
|
|
||||||
|
const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
|
||||||
|
const radiusX = config.width / 2 || 1
|
||||||
|
const radiusY = config.height / 2 || 1
|
||||||
|
const radiusZ = config.depth / 2 || 1
|
||||||
|
return normalize([
|
||||||
|
signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
|
||||||
|
signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
|
||||||
|
signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
|
||||||
|
lpNormal(config, point, diamondExponent(config))
|
||||||
|
|
||||||
|
const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
|
||||||
|
const exponent = cubeExponent(config)
|
||||||
|
if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
|
||||||
|
|
||||||
|
const normalized = [
|
||||||
|
point[0] / (config.width / 2 || 1),
|
||||||
|
point[1] / (config.height / 2 || 1),
|
||||||
|
point[2] / (config.depth / 2 || 1),
|
||||||
|
] as Point3
|
||||||
|
const dominantAxis = normalized.reduce(
|
||||||
|
(largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
|
||||||
|
0
|
||||||
|
)
|
||||||
|
const normal: Point3 = [
|
||||||
|
dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
|
||||||
|
dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
|
||||||
|
dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
|
||||||
|
]
|
||||||
|
return normal
|
||||||
|
}
|
||||||
|
|
||||||
|
const lpFrontSample = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
exponent: number,
|
||||||
|
normalAt: (config: SurfaceConfig, point: Point3) => Point3
|
||||||
|
): SurfaceSample => {
|
||||||
|
const radiusX = config.width / 2 || 1
|
||||||
|
const radiusY = config.height / 2 || 1
|
||||||
|
const radiusZ = config.depth / 2 || 1
|
||||||
|
if (!Number.isFinite(exponent)) {
|
||||||
|
const point: Point3 = [
|
||||||
|
Math.max(-radiusX, Math.min(radiusX, x)),
|
||||||
|
Math.max(-radiusY, Math.min(radiusY, y)),
|
||||||
|
radiusZ,
|
||||||
|
]
|
||||||
|
return { point, normal: normalAt(config, point) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
|
||||||
|
const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
|
||||||
|
const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
|
||||||
|
const normalizedX = surfaceX / radiusX
|
||||||
|
const normalizedZ =
|
||||||
|
Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
|
||||||
|
(1 / exponent)
|
||||||
|
const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
|
||||||
|
return { point, normal: normalAt(config, point) }
|
||||||
|
}
|
||||||
|
|
||||||
|
const ellipsoidFrontSample = (
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
radiusX: number,
|
||||||
|
radiusY: number,
|
||||||
|
radiusZ: number,
|
||||||
|
centerY = 0
|
||||||
|
): SurfaceSample => {
|
||||||
|
const localY = y - centerY
|
||||||
|
const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
|
||||||
|
const z = radiusZ * Math.sqrt(remaining)
|
||||||
|
return {
|
||||||
|
point: [x, y, z],
|
||||||
|
normal: normalize([
|
||||||
|
x / (radiusX * radiusX || 1),
|
||||||
|
localY / (radiusY * radiusY || 1),
|
||||||
|
z / (radiusZ * radiusZ || 1),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const radialProfileFrontSample = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
|
||||||
|
verticalDirection: -1 | 1
|
||||||
|
): SurfaceSample => {
|
||||||
|
const radiusX = config.width / 2 || 1
|
||||||
|
const radiusZ = config.depth / 2 || 1
|
||||||
|
const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
|
||||||
|
const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
|
||||||
|
const sectionRadiusX = radiusX * radialScale
|
||||||
|
const sectionRadiusZ = radiusZ * radialScale
|
||||||
|
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
|
||||||
|
const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
|
||||||
|
const z = sectionRadiusZ * Math.sqrt(remaining)
|
||||||
|
const derivativeStep = 0.0001
|
||||||
|
const previousProgress = Math.max(0, verticalProgress - derivativeStep)
|
||||||
|
const nextProgress = Math.min(1, verticalProgress + derivativeStep)
|
||||||
|
const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
|
||||||
|
const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
|
||||||
|
const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
|
||||||
|
const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
|
||||||
|
const depthRatio = radiusZ / radiusX
|
||||||
|
const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
|
||||||
|
const depthYDerivative =
|
||||||
|
(verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
|
||||||
|
return {
|
||||||
|
point: [surfaceX, y, z],
|
||||||
|
normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Project canonical face coordinates onto a primitive's front-facing sheet. */
|
||||||
|
export const surfaceFrontSampleAt = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
x: number,
|
||||||
|
y: number
|
||||||
|
): SurfaceSample => {
|
||||||
|
const radiusX = config.width / 2 || 1
|
||||||
|
const radiusY = config.height / 2 || 1
|
||||||
|
const radiusZ = config.depth / 2 || 1
|
||||||
|
|
||||||
|
switch (config.type) {
|
||||||
|
case 'sphere':
|
||||||
|
case 'mickey':
|
||||||
|
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
|
||||||
|
|
||||||
|
case 'cube':
|
||||||
|
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
||||||
|
|
||||||
|
case 'capsule': {
|
||||||
|
const capRadiusY = Math.min(radiusX, radiusY)
|
||||||
|
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
||||||
|
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
||||||
|
return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cylinder':
|
||||||
|
return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
|
||||||
|
|
||||||
|
case 'cursor': {
|
||||||
|
const layout = cursorLayout(config)
|
||||||
|
const bodyConfig = {
|
||||||
|
...config,
|
||||||
|
width: layout.bodyWidth,
|
||||||
|
height: layout.bodyHeight,
|
||||||
|
depth: layout.bodyDepth,
|
||||||
|
}
|
||||||
|
const sample = radialProfileFrontSample(
|
||||||
|
bodyConfig,
|
||||||
|
x,
|
||||||
|
y - layout.bodyCenterY,
|
||||||
|
cylinderProfileAt,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
|
||||||
|
normal: sample.normal,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
case 'cone':
|
||||||
|
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
|
||||||
|
|
||||||
|
case 'diamond':
|
||||||
|
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const surfaceNormalAt = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
longitude: number,
|
||||||
|
latitude: number
|
||||||
|
): Point3 => {
|
||||||
|
const point = surfacePointAt(config, longitude, latitude)
|
||||||
|
|
||||||
|
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
|
||||||
|
// common path for the default spherical head.
|
||||||
|
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||||
|
const halfWidth = config.width / 2 || 1
|
||||||
|
const halfHeight = config.height / 2 || 1
|
||||||
|
const halfDepth = config.depth / 2 || 1
|
||||||
|
return normalize([
|
||||||
|
point[0] / (halfWidth * halfWidth),
|
||||||
|
point[1] / (halfHeight * halfHeight),
|
||||||
|
point[2] / (halfDepth * halfDepth),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
||||||
|
return normalize([
|
||||||
|
Math.sin(longitude) / (config.width / 2 || 1),
|
||||||
|
0,
|
||||||
|
Math.cos(longitude) / (config.depth / 2 || 1),
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'diamond') {
|
||||||
|
return diamondNormal(config, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'cube') {
|
||||||
|
return cubeNormal(config, point)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tangentNormalAt(config, longitude, latitude)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const surfaceSampleAt = (
|
||||||
|
config: SurfaceConfig,
|
||||||
|
longitude: number,
|
||||||
|
latitude: number
|
||||||
|
): SurfaceSample => {
|
||||||
|
const point = surfacePointAt(config, longitude, latitude)
|
||||||
|
|
||||||
|
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||||
|
const halfWidth = config.width / 2 || 1
|
||||||
|
const halfHeight = config.height / 2 || 1
|
||||||
|
const halfDepth = config.depth / 2 || 1
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
normal: normalize([
|
||||||
|
point[0] / (halfWidth * halfWidth),
|
||||||
|
point[1] / (halfHeight * halfHeight),
|
||||||
|
point[2] / (halfDepth * halfDepth),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
normal: normalize([
|
||||||
|
Math.sin(longitude) / (config.width / 2 || 1),
|
||||||
|
0,
|
||||||
|
Math.cos(longitude) / (config.depth / 2 || 1),
|
||||||
|
]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'diamond') {
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
normal: diamondNormal(config, point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.type === 'cube') {
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
normal: cubeNormal(config, point),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
point,
|
||||||
|
normal: tangentNormalAt(config, longitude, latitude),
|
||||||
|
}
|
||||||
|
}
|
||||||
12
packages/avatar-core/tsconfig.build.json
Normal file
12
packages/avatar-core/tsconfig.build.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
packages/avatar-core/tsconfig.json
Normal file
15
packages/avatar-core/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"],
|
||||||
|
"exclude": ["src/**/__tests__/**"]
|
||||||
|
}
|
||||||
35
packages/avatar-core/vite.config.ts
Normal file
35
packages/avatar-core/vite.config.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: {
|
||||||
|
index: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
||||||
|
geometry: fileURLToPath(new URL('./src/geometry.ts', import.meta.url)),
|
||||||
|
body: fileURLToPath(new URL('./src/body.ts', import.meta.url)),
|
||||||
|
surfaces: fileURLToPath(new URL('./src/surfaces.ts', import.meta.url)),
|
||||||
|
ambientMotion: fileURLToPath(new URL('./src/ambientMotion.ts', import.meta.url)),
|
||||||
|
},
|
||||||
|
formats: ['es'],
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['ajv/dist/2020.js'],
|
||||||
|
output: { entryFileNames: '[name].js', chunkFileNames: 'chunks/[name]-[hash].js' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
plugins: [
|
||||||
|
{
|
||||||
|
name: 'copy-avatar-schema',
|
||||||
|
closeBundle: async () => {
|
||||||
|
const { copyFile } = await import('node:fs/promises')
|
||||||
|
await copyFile(
|
||||||
|
fileURLToPath(new URL('./src/avatarDefinition.schema.json', import.meta.url)),
|
||||||
|
fileURLToPath(new URL('./dist/avatarDefinition.schema.json', import.meta.url))
|
||||||
|
)
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
661
packages/avatar-react/LICENSE
Normal file
661
packages/avatar-react/LICENSE
Normal file
@ -0,0 +1,661 @@
|
|||||||
|
GNU AFFERO GENERAL PUBLIC LICENSE
|
||||||
|
Version 3, 19 November 2007
|
||||||
|
|
||||||
|
Copyright (C) 2007 Free Software Foundation, Inc. <https://fsf.org/>
|
||||||
|
Everyone is permitted to copy and distribute verbatim copies
|
||||||
|
of this license document, but changing it is not allowed.
|
||||||
|
|
||||||
|
Preamble
|
||||||
|
|
||||||
|
The GNU Affero General Public License is a free, copyleft license for
|
||||||
|
software and other kinds of works, specifically designed to ensure
|
||||||
|
cooperation with the community in the case of network server software.
|
||||||
|
|
||||||
|
The licenses for most software and other practical works are designed
|
||||||
|
to take away your freedom to share and change the works. By contrast,
|
||||||
|
our General Public Licenses are intended to guarantee your freedom to
|
||||||
|
share and change all versions of a program--to make sure it remains free
|
||||||
|
software for all its users.
|
||||||
|
|
||||||
|
When we speak of free software, we are referring to freedom, not
|
||||||
|
price. Our General Public Licenses are designed to make sure that you
|
||||||
|
have the freedom to distribute copies of free software (and charge for
|
||||||
|
them if you wish), that you receive source code or can get it if you
|
||||||
|
want it, that you can change the software or use pieces of it in new
|
||||||
|
free programs, and that you know you can do these things.
|
||||||
|
|
||||||
|
Developers that use our General Public Licenses protect your rights
|
||||||
|
with two steps: (1) assert copyright on the software, and (2) offer
|
||||||
|
you this License which gives you legal permission to copy, distribute
|
||||||
|
and/or modify the software.
|
||||||
|
|
||||||
|
A secondary benefit of defending all users' freedom is that
|
||||||
|
improvements made in alternate versions of the program, if they
|
||||||
|
receive widespread use, become available for other developers to
|
||||||
|
incorporate. Many developers of free software are heartened and
|
||||||
|
encouraged by the resulting cooperation. However, in the case of
|
||||||
|
software used on network servers, this result may fail to come about.
|
||||||
|
The GNU General Public License permits making a modified version and
|
||||||
|
letting the public access it on a server without ever releasing its
|
||||||
|
source code to the public.
|
||||||
|
|
||||||
|
The GNU Affero General Public License is designed specifically to
|
||||||
|
ensure that, in such cases, the modified source code becomes available
|
||||||
|
to the community. It requires the operator of a network server to
|
||||||
|
provide the source code of the modified version running there to the
|
||||||
|
users of that server. Therefore, public use of a modified version, on
|
||||||
|
a publicly accessible server, gives the public access to the source
|
||||||
|
code of the modified version.
|
||||||
|
|
||||||
|
An older license, called the Affero General Public License and
|
||||||
|
published by Affero, was designed to accomplish similar goals. This is
|
||||||
|
a different license, not a version of the Affero GPL, but Affero has
|
||||||
|
released a new version of the Affero GPL which permits relicensing under
|
||||||
|
this license.
|
||||||
|
|
||||||
|
The precise terms and conditions for copying, distribution and
|
||||||
|
modification follow.
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
0. Definitions.
|
||||||
|
|
||||||
|
"This License" refers to version 3 of the GNU Affero General Public License.
|
||||||
|
|
||||||
|
"Copyright" also means copyright-like laws that apply to other kinds of
|
||||||
|
works, such as semiconductor masks.
|
||||||
|
|
||||||
|
"The Program" refers to any copyrightable work licensed under this
|
||||||
|
License. Each licensee is addressed as "you". "Licensees" and
|
||||||
|
"recipients" may be individuals or organizations.
|
||||||
|
|
||||||
|
To "modify" a work means to copy from or adapt all or part of the work
|
||||||
|
in a fashion requiring copyright permission, other than the making of an
|
||||||
|
exact copy. The resulting work is called a "modified version" of the
|
||||||
|
earlier work or a work "based on" the earlier work.
|
||||||
|
|
||||||
|
A "covered work" means either the unmodified Program or a work based
|
||||||
|
on the Program.
|
||||||
|
|
||||||
|
To "propagate" a work means to do anything with it that, without
|
||||||
|
permission, would make you directly or secondarily liable for
|
||||||
|
infringement under applicable copyright law, except executing it on a
|
||||||
|
computer or modifying a private copy. Propagation includes copying,
|
||||||
|
distribution (with or without modification), making available to the
|
||||||
|
public, and in some countries other activities as well.
|
||||||
|
|
||||||
|
To "convey" a work means any kind of propagation that enables other
|
||||||
|
parties to make or receive copies. Mere interaction with a user through
|
||||||
|
a computer network, with no transfer of a copy, is not conveying.
|
||||||
|
|
||||||
|
An interactive user interface displays "Appropriate Legal Notices"
|
||||||
|
to the extent that it includes a convenient and prominently visible
|
||||||
|
feature that (1) displays an appropriate copyright notice, and (2)
|
||||||
|
tells the user that there is no warranty for the work (except to the
|
||||||
|
extent that warranties are provided), that licensees may convey the
|
||||||
|
work under this License, and how to view a copy of this License. If
|
||||||
|
the interface presents a list of user commands or options, such as a
|
||||||
|
menu, a prominent item in the list meets this criterion.
|
||||||
|
|
||||||
|
1. Source Code.
|
||||||
|
|
||||||
|
The "source code" for a work means the preferred form of the work
|
||||||
|
for making modifications to it. "Object code" means any non-source
|
||||||
|
form of a work.
|
||||||
|
|
||||||
|
A "Standard Interface" means an interface that either is an official
|
||||||
|
standard defined by a recognized standards body, or, in the case of
|
||||||
|
interfaces specified for a particular programming language, one that
|
||||||
|
is widely used among developers working in that language.
|
||||||
|
|
||||||
|
The "System Libraries" of an executable work include anything, other
|
||||||
|
than the work as a whole, that (a) is included in the normal form of
|
||||||
|
packaging a Major Component, but which is not part of that Major
|
||||||
|
Component, and (b) serves only to enable use of the work with that
|
||||||
|
Major Component, or to implement a Standard Interface for which an
|
||||||
|
implementation is available to the public in source code form. A
|
||||||
|
"Major Component", in this context, means a major essential component
|
||||||
|
(kernel, window system, and so on) of the specific operating system
|
||||||
|
(if any) on which the executable work runs, or a compiler used to
|
||||||
|
produce the work, or an object code interpreter used to run it.
|
||||||
|
|
||||||
|
The "Corresponding Source" for a work in object code form means all
|
||||||
|
the source code needed to generate, install, and (for an executable
|
||||||
|
work) run the object code and to modify the work, including scripts to
|
||||||
|
control those activities. However, it does not include the work's
|
||||||
|
System Libraries, or general-purpose tools or generally available free
|
||||||
|
programs which are used unmodified in performing those activities but
|
||||||
|
which are not part of the work. For example, Corresponding Source
|
||||||
|
includes interface definition files associated with source files for
|
||||||
|
the work, and the source code for shared libraries and dynamically
|
||||||
|
linked subprograms that the work is specifically designed to require,
|
||||||
|
such as by intimate data communication or control flow between those
|
||||||
|
subprograms and other parts of the work.
|
||||||
|
|
||||||
|
The Corresponding Source need not include anything that users
|
||||||
|
can regenerate automatically from other parts of the Corresponding
|
||||||
|
Source.
|
||||||
|
|
||||||
|
The Corresponding Source for a work in source code form is that
|
||||||
|
same work.
|
||||||
|
|
||||||
|
2. Basic Permissions.
|
||||||
|
|
||||||
|
All rights granted under this License are granted for the term of
|
||||||
|
copyright on the Program, and are irrevocable provided the stated
|
||||||
|
conditions are met. This License explicitly affirms your unlimited
|
||||||
|
permission to run the unmodified Program. The output from running a
|
||||||
|
covered work is covered by this License only if the output, given its
|
||||||
|
content, constitutes a covered work. This License acknowledges your
|
||||||
|
rights of fair use or other equivalent, as provided by copyright law.
|
||||||
|
|
||||||
|
You may make, run and propagate covered works that you do not
|
||||||
|
convey, without conditions so long as your license otherwise remains
|
||||||
|
in force. You may convey covered works to others for the sole purpose
|
||||||
|
of having them make modifications exclusively for you, or provide you
|
||||||
|
with facilities for running those works, provided that you comply with
|
||||||
|
the terms of this License in conveying all material for which you do
|
||||||
|
not control copyright. Those thus making or running the covered works
|
||||||
|
for you must do so exclusively on your behalf, under your direction
|
||||||
|
and control, on terms that prohibit them from making any copies of
|
||||||
|
your copyrighted material outside their relationship with you.
|
||||||
|
|
||||||
|
Conveying under any other circumstances is permitted solely under
|
||||||
|
the conditions stated below. Sublicensing is not allowed; section 10
|
||||||
|
makes it unnecessary.
|
||||||
|
|
||||||
|
3. Protecting Users' Legal Rights From Anti-Circumvention Law.
|
||||||
|
|
||||||
|
No covered work shall be deemed part of an effective technological
|
||||||
|
measure under any applicable law fulfilling obligations under article
|
||||||
|
11 of the WIPO copyright treaty adopted on 20 December 1996, or
|
||||||
|
similar laws prohibiting or restricting circumvention of such
|
||||||
|
measures.
|
||||||
|
|
||||||
|
When you convey a covered work, you waive any legal power to forbid
|
||||||
|
circumvention of technological measures to the extent such circumvention
|
||||||
|
is effected by exercising rights under this License with respect to
|
||||||
|
the covered work, and you disclaim any intention to limit operation or
|
||||||
|
modification of the work as a means of enforcing, against the work's
|
||||||
|
users, your or third parties' legal rights to forbid circumvention of
|
||||||
|
technological measures.
|
||||||
|
|
||||||
|
4. Conveying Verbatim Copies.
|
||||||
|
|
||||||
|
You may convey verbatim copies of the Program's source code as you
|
||||||
|
receive it, in any medium, provided that you conspicuously and
|
||||||
|
appropriately publish on each copy an appropriate copyright notice;
|
||||||
|
keep intact all notices stating that this License and any
|
||||||
|
non-permissive terms added in accord with section 7 apply to the code;
|
||||||
|
keep intact all notices of the absence of any warranty; and give all
|
||||||
|
recipients a copy of this License along with the Program.
|
||||||
|
|
||||||
|
You may charge any price or no price for each copy that you convey,
|
||||||
|
and you may offer support or warranty protection for a fee.
|
||||||
|
|
||||||
|
5. Conveying Modified Source Versions.
|
||||||
|
|
||||||
|
You may convey a work based on the Program, or the modifications to
|
||||||
|
produce it from the Program, in the form of source code under the
|
||||||
|
terms of section 4, provided that you also meet all of these conditions:
|
||||||
|
|
||||||
|
a) The work must carry prominent notices stating that you modified
|
||||||
|
it, and giving a relevant date.
|
||||||
|
|
||||||
|
b) The work must carry prominent notices stating that it is
|
||||||
|
released under this License and any conditions added under section
|
||||||
|
7. This requirement modifies the requirement in section 4 to
|
||||||
|
"keep intact all notices".
|
||||||
|
|
||||||
|
c) You must license the entire work, as a whole, under this
|
||||||
|
License to anyone who comes into possession of a copy. This
|
||||||
|
License will therefore apply, along with any applicable section 7
|
||||||
|
additional terms, to the whole of the work, and all its parts,
|
||||||
|
regardless of how they are packaged. This License gives no
|
||||||
|
permission to license the work in any other way, but it does not
|
||||||
|
invalidate such permission if you have separately received it.
|
||||||
|
|
||||||
|
d) If the work has interactive user interfaces, each must display
|
||||||
|
Appropriate Legal Notices; however, if the Program has interactive
|
||||||
|
interfaces that do not display Appropriate Legal Notices, your
|
||||||
|
work need not make them do so.
|
||||||
|
|
||||||
|
A compilation of a covered work with other separate and independent
|
||||||
|
works, which are not by their nature extensions of the covered work,
|
||||||
|
and which are not combined with it such as to form a larger program,
|
||||||
|
in or on a volume of a storage or distribution medium, is called an
|
||||||
|
"aggregate" if the compilation and its resulting copyright are not
|
||||||
|
used to limit the access or legal rights of the compilation's users
|
||||||
|
beyond what the individual works permit. Inclusion of a covered work
|
||||||
|
in an aggregate does not cause this License to apply to the other
|
||||||
|
parts of the aggregate.
|
||||||
|
|
||||||
|
6. Conveying Non-Source Forms.
|
||||||
|
|
||||||
|
You may convey a covered work in object code form under the terms
|
||||||
|
of sections 4 and 5, provided that you also convey the
|
||||||
|
machine-readable Corresponding Source under the terms of this License,
|
||||||
|
in one of these ways:
|
||||||
|
|
||||||
|
a) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by the
|
||||||
|
Corresponding Source fixed on a durable physical medium
|
||||||
|
customarily used for software interchange.
|
||||||
|
|
||||||
|
b) Convey the object code in, or embodied in, a physical product
|
||||||
|
(including a physical distribution medium), accompanied by a
|
||||||
|
written offer, valid for at least three years and valid for as
|
||||||
|
long as you offer spare parts or customer support for that product
|
||||||
|
model, to give anyone who possesses the object code either (1) a
|
||||||
|
copy of the Corresponding Source for all the software in the
|
||||||
|
product that is covered by this License, on a durable physical
|
||||||
|
medium customarily used for software interchange, for a price no
|
||||||
|
more than your reasonable cost of physically performing this
|
||||||
|
conveying of source, or (2) access to copy the
|
||||||
|
Corresponding Source from a network server at no charge.
|
||||||
|
|
||||||
|
c) Convey individual copies of the object code with a copy of the
|
||||||
|
written offer to provide the Corresponding Source. This
|
||||||
|
alternative is allowed only occasionally and noncommercially, and
|
||||||
|
only if you received the object code with such an offer, in accord
|
||||||
|
with subsection 6b.
|
||||||
|
|
||||||
|
d) Convey the object code by offering access from a designated
|
||||||
|
place (gratis or for a charge), and offer equivalent access to the
|
||||||
|
Corresponding Source in the same way through the same place at no
|
||||||
|
further charge. You need not require recipients to copy the
|
||||||
|
Corresponding Source along with the object code. If the place to
|
||||||
|
copy the object code is a network server, the Corresponding Source
|
||||||
|
may be on a different server (operated by you or a third party)
|
||||||
|
that supports equivalent copying facilities, provided you maintain
|
||||||
|
clear directions next to the object code saying where to find the
|
||||||
|
Corresponding Source. Regardless of what server hosts the
|
||||||
|
Corresponding Source, you remain obligated to ensure that it is
|
||||||
|
available for as long as needed to satisfy these requirements.
|
||||||
|
|
||||||
|
e) Convey the object code using peer-to-peer transmission, provided
|
||||||
|
you inform other peers where the object code and Corresponding
|
||||||
|
Source of the work are being offered to the general public at no
|
||||||
|
charge under subsection 6d.
|
||||||
|
|
||||||
|
A separable portion of the object code, whose source code is excluded
|
||||||
|
from the Corresponding Source as a System Library, need not be
|
||||||
|
included in conveying the object code work.
|
||||||
|
|
||||||
|
A "User Product" is either (1) a "consumer product", which means any
|
||||||
|
tangible personal property which is normally used for personal, family,
|
||||||
|
or household purposes, or (2) anything designed or sold for incorporation
|
||||||
|
into a dwelling. In determining whether a product is a consumer product,
|
||||||
|
doubtful cases shall be resolved in favor of coverage. For a particular
|
||||||
|
product received by a particular user, "normally used" refers to a
|
||||||
|
typical or common use of that class of product, regardless of the status
|
||||||
|
of the particular user or of the way in which the particular user
|
||||||
|
actually uses, or expects or is expected to use, the product. A product
|
||||||
|
is a consumer product regardless of whether the product has substantial
|
||||||
|
commercial, industrial or non-consumer uses, unless such uses represent
|
||||||
|
the only significant mode of use of the product.
|
||||||
|
|
||||||
|
"Installation Information" for a User Product means any methods,
|
||||||
|
procedures, authorization keys, or other information required to install
|
||||||
|
and execute modified versions of a covered work in that User Product from
|
||||||
|
a modified version of its Corresponding Source. The information must
|
||||||
|
suffice to ensure that the continued functioning of the modified object
|
||||||
|
code is in no case prevented or interfered with solely because
|
||||||
|
modification has been made.
|
||||||
|
|
||||||
|
If you convey an object code work under this section in, or with, or
|
||||||
|
specifically for use in, a User Product, and the conveying occurs as
|
||||||
|
part of a transaction in which the right of possession and use of the
|
||||||
|
User Product is transferred to the recipient in perpetuity or for a
|
||||||
|
fixed term (regardless of how the transaction is characterized), the
|
||||||
|
Corresponding Source conveyed under this section must be accompanied
|
||||||
|
by the Installation Information. But this requirement does not apply
|
||||||
|
if neither you nor any third party retains the ability to install
|
||||||
|
modified object code on the User Product (for example, the work has
|
||||||
|
been installed in ROM).
|
||||||
|
|
||||||
|
The requirement to provide Installation Information does not include a
|
||||||
|
requirement to continue to provide support service, warranty, or updates
|
||||||
|
for a work that has been modified or installed by the recipient, or for
|
||||||
|
the User Product in which it has been modified or installed. Access to a
|
||||||
|
network may be denied when the modification itself materially and
|
||||||
|
adversely affects the operation of the network or violates the rules and
|
||||||
|
protocols for communication across the network.
|
||||||
|
|
||||||
|
Corresponding Source conveyed, and Installation Information provided,
|
||||||
|
in accord with this section must be in a format that is publicly
|
||||||
|
documented (and with an implementation available to the public in
|
||||||
|
source code form), and must require no special password or key for
|
||||||
|
unpacking, reading or copying.
|
||||||
|
|
||||||
|
7. Additional Terms.
|
||||||
|
|
||||||
|
"Additional permissions" are terms that supplement the terms of this
|
||||||
|
License by making exceptions from one or more of its conditions.
|
||||||
|
Additional permissions that are applicable to the entire Program shall
|
||||||
|
be treated as though they were included in this License, to the extent
|
||||||
|
that they are valid under applicable law. If additional permissions
|
||||||
|
apply only to part of the Program, that part may be used separately
|
||||||
|
under those permissions, but the entire Program remains governed by
|
||||||
|
this License without regard to the additional permissions.
|
||||||
|
|
||||||
|
When you convey a copy of a covered work, you may at your option
|
||||||
|
remove any additional permissions from that copy, or from any part of
|
||||||
|
it. (Additional permissions may be written to require their own
|
||||||
|
removal in certain cases when you modify the work.) You may place
|
||||||
|
additional permissions on material, added by you to a covered work,
|
||||||
|
for which you have or can give appropriate copyright permission.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, for material you
|
||||||
|
add to a covered work, you may (if authorized by the copyright holders of
|
||||||
|
that material) supplement the terms of this License with terms:
|
||||||
|
|
||||||
|
a) Disclaiming warranty or limiting liability differently from the
|
||||||
|
terms of sections 15 and 16 of this License; or
|
||||||
|
|
||||||
|
b) Requiring preservation of specified reasonable legal notices or
|
||||||
|
author attributions in that material or in the Appropriate Legal
|
||||||
|
Notices displayed by works containing it; or
|
||||||
|
|
||||||
|
c) Prohibiting misrepresentation of the origin of that material, or
|
||||||
|
requiring that modified versions of such material be marked in
|
||||||
|
reasonable ways as different from the original version; or
|
||||||
|
|
||||||
|
d) Limiting the use for publicity purposes of names of licensors or
|
||||||
|
authors of the material; or
|
||||||
|
|
||||||
|
e) Declining to grant rights under trademark law for use of some
|
||||||
|
trade names, trademarks, or service marks; or
|
||||||
|
|
||||||
|
f) Requiring indemnification of licensors and authors of that
|
||||||
|
material by anyone who conveys the material (or modified versions of
|
||||||
|
it) with contractual assumptions of liability to the recipient, for
|
||||||
|
any liability that these contractual assumptions directly impose on
|
||||||
|
those licensors and authors.
|
||||||
|
|
||||||
|
All other non-permissive additional terms are considered "further
|
||||||
|
restrictions" within the meaning of section 10. If the Program as you
|
||||||
|
received it, or any part of it, contains a notice stating that it is
|
||||||
|
governed by this License along with a term that is a further
|
||||||
|
restriction, you may remove that term. If a license document contains
|
||||||
|
a further restriction but permits relicensing or conveying under this
|
||||||
|
License, you may add to a covered work material governed by the terms
|
||||||
|
of that license document, provided that the further restriction does
|
||||||
|
not survive such relicensing or conveying.
|
||||||
|
|
||||||
|
If you add terms to a covered work in accord with this section, you
|
||||||
|
must place, in the relevant source files, a statement of the
|
||||||
|
additional terms that apply to those files, or a notice indicating
|
||||||
|
where to find the applicable terms.
|
||||||
|
|
||||||
|
Additional terms, permissive or non-permissive, may be stated in the
|
||||||
|
form of a separately written license, or stated as exceptions;
|
||||||
|
the above requirements apply either way.
|
||||||
|
|
||||||
|
8. Termination.
|
||||||
|
|
||||||
|
You may not propagate or modify a covered work except as expressly
|
||||||
|
provided under this License. Any attempt otherwise to propagate or
|
||||||
|
modify it is void, and will automatically terminate your rights under
|
||||||
|
this License (including any patent licenses granted under the third
|
||||||
|
paragraph of section 11).
|
||||||
|
|
||||||
|
However, if you cease all violation of this License, then your
|
||||||
|
license from a particular copyright holder is reinstated (a)
|
||||||
|
provisionally, unless and until the copyright holder explicitly and
|
||||||
|
finally terminates your license, and (b) permanently, if the copyright
|
||||||
|
holder fails to notify you of the violation by some reasonable means
|
||||||
|
prior to 60 days after the cessation.
|
||||||
|
|
||||||
|
Moreover, your license from a particular copyright holder is
|
||||||
|
reinstated permanently if the copyright holder notifies you of the
|
||||||
|
violation by some reasonable means, this is the first time you have
|
||||||
|
received notice of violation of this License (for any work) from that
|
||||||
|
copyright holder, and you cure the violation prior to 30 days after
|
||||||
|
your receipt of the notice.
|
||||||
|
|
||||||
|
Termination of your rights under this section does not terminate the
|
||||||
|
licenses of parties who have received copies or rights from you under
|
||||||
|
this License. If your rights have been terminated and not permanently
|
||||||
|
reinstated, you do not qualify to receive new licenses for the same
|
||||||
|
material under section 10.
|
||||||
|
|
||||||
|
9. Acceptance Not Required for Having Copies.
|
||||||
|
|
||||||
|
You are not required to accept this License in order to receive or
|
||||||
|
run a copy of the Program. Ancillary propagation of a covered work
|
||||||
|
occurring solely as a consequence of using peer-to-peer transmission
|
||||||
|
to receive a copy likewise does not require acceptance. However,
|
||||||
|
nothing other than this License grants you permission to propagate or
|
||||||
|
modify any covered work. These actions infringe copyright if you do
|
||||||
|
not accept this License. Therefore, by modifying or propagating a
|
||||||
|
covered work, you indicate your acceptance of this License to do so.
|
||||||
|
|
||||||
|
10. Automatic Licensing of Downstream Recipients.
|
||||||
|
|
||||||
|
Each time you convey a covered work, the recipient automatically
|
||||||
|
receives a license from the original licensors, to run, modify and
|
||||||
|
propagate that work, subject to this License. You are not responsible
|
||||||
|
for enforcing compliance by third parties with this License.
|
||||||
|
|
||||||
|
An "entity transaction" is a transaction transferring control of an
|
||||||
|
organization, or substantially all assets of one, or subdividing an
|
||||||
|
organization, or merging organizations. If propagation of a covered
|
||||||
|
work results from an entity transaction, each party to that
|
||||||
|
transaction who receives a copy of the work also receives whatever
|
||||||
|
licenses to the work the party's predecessor in interest had or could
|
||||||
|
give under the previous paragraph, plus a right to possession of the
|
||||||
|
Corresponding Source of the work from the predecessor in interest, if
|
||||||
|
the predecessor has it or can get it with reasonable efforts.
|
||||||
|
|
||||||
|
You may not impose any further restrictions on the exercise of the
|
||||||
|
rights granted or affirmed under this License. For example, you may
|
||||||
|
not impose a license fee, royalty, or other charge for exercise of
|
||||||
|
rights granted under this License, and you may not initiate litigation
|
||||||
|
(including a cross-claim or counterclaim in a lawsuit) alleging that
|
||||||
|
any patent claim is infringed by making, using, selling, offering for
|
||||||
|
sale, or importing the Program or any portion of it.
|
||||||
|
|
||||||
|
11. Patents.
|
||||||
|
|
||||||
|
A "contributor" is a copyright holder who authorizes use under this
|
||||||
|
License of the Program or a work on which the Program is based. The
|
||||||
|
work thus licensed is called the contributor's "contributor version".
|
||||||
|
|
||||||
|
A contributor's "essential patent claims" are all patent claims
|
||||||
|
owned or controlled by the contributor, whether already acquired or
|
||||||
|
hereafter acquired, that would be infringed by some manner, permitted
|
||||||
|
by this License, of making, using, or selling its contributor version,
|
||||||
|
but do not include claims that would be infringed only as a
|
||||||
|
consequence of further modification of the contributor version. For
|
||||||
|
purposes of this definition, "control" includes the right to grant
|
||||||
|
patent sublicenses in a manner consistent with the requirements of
|
||||||
|
this License.
|
||||||
|
|
||||||
|
Each contributor grants you a non-exclusive, worldwide, royalty-free
|
||||||
|
patent license under the contributor's essential patent claims, to
|
||||||
|
make, use, sell, offer for sale, import and otherwise run, modify and
|
||||||
|
propagate the contents of its contributor version.
|
||||||
|
|
||||||
|
In the following three paragraphs, a "patent license" is any express
|
||||||
|
agreement or commitment, however denominated, not to enforce a patent
|
||||||
|
(such as an express permission to practice a patent or covenant not to
|
||||||
|
sue for patent infringement). To "grant" such a patent license to a
|
||||||
|
party means to make such an agreement or commitment not to enforce a
|
||||||
|
patent against the party.
|
||||||
|
|
||||||
|
If you convey a covered work, knowingly relying on a patent license,
|
||||||
|
and the Corresponding Source of the work is not available for anyone
|
||||||
|
to copy, free of charge and under the terms of this License, through a
|
||||||
|
publicly available network server or other readily accessible means,
|
||||||
|
then you must either (1) cause the Corresponding Source to be so
|
||||||
|
available, or (2) arrange to deprive yourself of the benefit of the
|
||||||
|
patent license for this particular work, or (3) arrange, in a manner
|
||||||
|
consistent with the requirements of this License, to extend the patent
|
||||||
|
license to downstream recipients. "Knowingly relying" means you have
|
||||||
|
actual knowledge that, but for the patent license, your conveying the
|
||||||
|
covered work in a country, or your recipient's use of the covered work
|
||||||
|
in a country, would infringe one or more identifiable patents in that
|
||||||
|
country that you have reason to believe are valid.
|
||||||
|
|
||||||
|
If, pursuant to or in connection with a single transaction or
|
||||||
|
arrangement, you convey, or propagate by procuring conveyance of, a
|
||||||
|
covered work, and grant a patent license to some of the parties
|
||||||
|
receiving the covered work authorizing them to use, propagate, modify
|
||||||
|
or convey a specific copy of the covered work, then the patent license
|
||||||
|
you grant is automatically extended to all recipients of the covered
|
||||||
|
work and works based on it.
|
||||||
|
|
||||||
|
A patent license is "discriminatory" if it does not include within
|
||||||
|
the scope of its coverage, prohibits the exercise of, or is
|
||||||
|
conditioned on the non-exercise of one or more of the rights that are
|
||||||
|
specifically granted under this License. You may not convey a covered
|
||||||
|
work if you are a party to an arrangement with a third party that is
|
||||||
|
in the business of distributing software, under which you make payment
|
||||||
|
to the third party based on the extent of your activity of conveying
|
||||||
|
the work, and under which the third party grants, to any of the
|
||||||
|
parties who would receive the covered work from you, a discriminatory
|
||||||
|
patent license (a) in connection with copies of the covered work
|
||||||
|
conveyed by you (or copies made from those copies), or (b) primarily
|
||||||
|
for and in connection with specific products or compilations that
|
||||||
|
contain the covered work, unless you entered into that arrangement,
|
||||||
|
or that patent license was granted, prior to 28 March 2007.
|
||||||
|
|
||||||
|
Nothing in this License shall be construed as excluding or limiting
|
||||||
|
any implied license or other defenses to infringement that may
|
||||||
|
otherwise be available to you under applicable patent law.
|
||||||
|
|
||||||
|
12. No Surrender of Others' Freedom.
|
||||||
|
|
||||||
|
If conditions are imposed on you (whether by court order, agreement or
|
||||||
|
otherwise) that contradict the conditions of this License, they do not
|
||||||
|
excuse you from the conditions of this License. If you cannot convey a
|
||||||
|
covered work so as to satisfy simultaneously your obligations under this
|
||||||
|
License and any other pertinent obligations, then as a consequence you may
|
||||||
|
not convey it at all. For example, if you agree to terms that obligate you
|
||||||
|
to collect a royalty for further conveying from those to whom you convey
|
||||||
|
the Program, the only way you could satisfy both those terms and this
|
||||||
|
License would be to refrain entirely from conveying the Program.
|
||||||
|
|
||||||
|
13. Remote Network Interaction; Use with the GNU General Public License.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, if you modify the
|
||||||
|
Program, your modified version must prominently offer all users
|
||||||
|
interacting with it remotely through a computer network (if your version
|
||||||
|
supports such interaction) an opportunity to receive the Corresponding
|
||||||
|
Source of your version by providing access to the Corresponding Source
|
||||||
|
from a network server at no charge, through some standard or customary
|
||||||
|
means of facilitating copying of software. This Corresponding Source
|
||||||
|
shall include the Corresponding Source for any work covered by version 3
|
||||||
|
of the GNU General Public License that is incorporated pursuant to the
|
||||||
|
following paragraph.
|
||||||
|
|
||||||
|
Notwithstanding any other provision of this License, you have
|
||||||
|
permission to link or combine any covered work with a work licensed
|
||||||
|
under version 3 of the GNU General Public License into a single
|
||||||
|
combined work, and to convey the resulting work. The terms of this
|
||||||
|
License will continue to apply to the part which is the covered work,
|
||||||
|
but the work with which it is combined will remain governed by version
|
||||||
|
3 of the GNU General Public License.
|
||||||
|
|
||||||
|
14. Revised Versions of this License.
|
||||||
|
|
||||||
|
The Free Software Foundation may publish revised and/or new versions of
|
||||||
|
the GNU Affero General Public License from time to time. Such new versions
|
||||||
|
will be similar in spirit to the present version, but may differ in detail to
|
||||||
|
address new problems or concerns.
|
||||||
|
|
||||||
|
Each version is given a distinguishing version number. If the
|
||||||
|
Program specifies that a certain numbered version of the GNU Affero General
|
||||||
|
Public License "or any later version" applies to it, you have the
|
||||||
|
option of following the terms and conditions either of that numbered
|
||||||
|
version or of any later version published by the Free Software
|
||||||
|
Foundation. If the Program does not specify a version number of the
|
||||||
|
GNU Affero General Public License, you may choose any version ever published
|
||||||
|
by the Free Software Foundation.
|
||||||
|
|
||||||
|
If the Program specifies that a proxy can decide which future
|
||||||
|
versions of the GNU Affero General Public License can be used, that proxy's
|
||||||
|
public statement of acceptance of a version permanently authorizes you
|
||||||
|
to choose that version for the Program.
|
||||||
|
|
||||||
|
Later license versions may give you additional or different
|
||||||
|
permissions. However, no additional obligations are imposed on any
|
||||||
|
author or copyright holder as a result of your choosing to follow a
|
||||||
|
later version.
|
||||||
|
|
||||||
|
15. Disclaimer of Warranty.
|
||||||
|
|
||||||
|
THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
|
||||||
|
APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
|
||||||
|
HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
|
||||||
|
OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
|
||||||
|
THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
|
||||||
|
PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
|
||||||
|
IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
|
||||||
|
ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
|
||||||
|
|
||||||
|
16. Limitation of Liability.
|
||||||
|
|
||||||
|
IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
|
||||||
|
WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
|
||||||
|
THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
|
||||||
|
GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
|
||||||
|
USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
|
||||||
|
DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
|
||||||
|
PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
|
||||||
|
EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
|
||||||
|
SUCH DAMAGES.
|
||||||
|
|
||||||
|
17. Interpretation of Sections 15 and 16.
|
||||||
|
|
||||||
|
If the disclaimer of warranty and limitation of liability provided
|
||||||
|
above cannot be given local legal effect according to their terms,
|
||||||
|
reviewing courts shall apply local law that most closely approximates
|
||||||
|
an absolute waiver of all civil liability in connection with the
|
||||||
|
Program, unless a warranty or assumption of liability accompanies a
|
||||||
|
copy of the Program in return for a fee.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
How to Apply These Terms to Your New Programs
|
||||||
|
|
||||||
|
If you develop a new program, and you want it to be of the greatest
|
||||||
|
possible use to the public, the best way to achieve this is to make it
|
||||||
|
free software which everyone can redistribute and change under these terms.
|
||||||
|
|
||||||
|
To do so, attach the following notices to the program. It is safest
|
||||||
|
to attach them to the start of each source file to most effectively
|
||||||
|
state the exclusion of warranty; and each file should have at least
|
||||||
|
the "copyright" line and a pointer to where the full notice is found.
|
||||||
|
|
||||||
|
<one line to give the program's name and a brief idea of what it does.>
|
||||||
|
Copyright (C) <year> <name of author>
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify
|
||||||
|
it under the terms of the GNU Affero General Public License as published by
|
||||||
|
the Free Software Foundation, either version 3 of the License, or
|
||||||
|
(at your option) any later version.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful,
|
||||||
|
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
GNU Affero General Public License for more details.
|
||||||
|
|
||||||
|
You should have received a copy of the GNU Affero General Public License
|
||||||
|
along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
Also add information on how to contact you by electronic and paper mail.
|
||||||
|
|
||||||
|
If your software can interact with users remotely through a computer
|
||||||
|
network, you should also make sure that it provides a way for users to
|
||||||
|
get its source. For example, if your program is a web application, its
|
||||||
|
interface could display a "Source" link that leads users to an archive
|
||||||
|
of the code. There are many ways you could offer source, and different
|
||||||
|
solutions will be better for different programs; see section 13 for the
|
||||||
|
specific requirements.
|
||||||
|
|
||||||
|
You should also get your employer (if you work as a programmer) or school,
|
||||||
|
if any, to sign a "copyright disclaimer" for the program, if necessary.
|
||||||
|
For more information on this, and how to apply and follow the GNU AGPL, see
|
||||||
|
<https://www.gnu.org/licenses/>.
|
||||||
125
packages/avatar-react/README.md
Normal file
125
packages/avatar-react/README.md
Normal file
@ -0,0 +1,125 @@
|
|||||||
|
# @bible-strong/avatar-react
|
||||||
|
|
||||||
|
React 19 renderer for a validated Bible Strong `AvatarDefinition`. React and React DOM 19 are peer
|
||||||
|
dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add @bible-strong/avatar-react react react-dom
|
||||||
|
```
|
||||||
|
|
||||||
|
Import the package stylesheet once in the application entry point:
|
||||||
|
|
||||||
|
```tsx
|
||||||
|
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
|
||||||
|
import type { AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
|
import { useRef } from 'react'
|
||||||
|
|
||||||
|
export function Assistant({ definition }: { definition: AvatarDefinition }) {
|
||||||
|
const avatar = useRef<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.
|
||||||
65
packages/avatar-react/package.json
Normal file
65
packages/avatar-react/package.json
Normal file
@ -0,0 +1,65 @@
|
|||||||
|
{
|
||||||
|
"name": "@bible-strong/avatar-react",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "React 19 renderer for Bible Strong procedural avatars.",
|
||||||
|
"keywords": [
|
||||||
|
"avatar",
|
||||||
|
"svg",
|
||||||
|
"animation",
|
||||||
|
"react",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "Stéphane Montlouis-Calixte",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"homepage": "https://github.com/smontlouis/bible-strong-avatar-lab#readme",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/smontlouis/bible-strong-avatar-lab.git",
|
||||||
|
"directory": "packages/avatar-react"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/smontlouis/bible-strong-avatar-lab/issues"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": [
|
||||||
|
"**/*.css"
|
||||||
|
],
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
},
|
||||||
|
"./styles.css": "./dist/styles.css"
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
|
||||||
|
"prepack": "pnpm build",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bible-strong/avatar-core": "workspace:^"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"react-dom": "^19.0.0"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"react": "19.2.3",
|
||||||
|
"react-dom": "19.2.3",
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
526
packages/avatar-react/src/Avatar.tsx
Normal file
526
packages/avatar-react/src/Avatar.tsx
Normal file
@ -0,0 +1,526 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
363
packages/avatar-react/src/__tests__/avatar-test.tsx
Normal file
363
packages/avatar-react/src/__tests__/avatar-test.tsx
Normal file
@ -0,0 +1,363 @@
|
|||||||
|
// @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()
|
||||||
|
})
|
||||||
|
})
|
||||||
74
packages/avatar-react/src/createAvatar.tsx
Normal file
74
packages/avatar-react/src/createAvatar.tsx
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import {
|
||||||
|
validateAvatarDefinition,
|
||||||
|
type AnimationKey,
|
||||||
|
type AvatarDefinition,
|
||||||
|
type ExpressionKey,
|
||||||
|
} from '@bible-strong/avatar-core'
|
||||||
|
import type { ReactElement } from 'react'
|
||||||
|
|
||||||
|
import {
|
||||||
|
Avatar,
|
||||||
|
markAvatarDefinitionValidated,
|
||||||
|
type AvatarController,
|
||||||
|
type AvatarProps,
|
||||||
|
} from './Avatar'
|
||||||
|
|
||||||
|
type AvatarDefinitionInput = {
|
||||||
|
expressions: object
|
||||||
|
animations: object
|
||||||
|
}
|
||||||
|
|
||||||
|
type StringKey<T> = Extract<keyof T, string>
|
||||||
|
|
||||||
|
/** Props for a component created from one concrete avatar definition. */
|
||||||
|
export type CreatedAvatarProps<Definition extends AvatarDefinitionInput> = Omit<
|
||||||
|
AvatarProps,
|
||||||
|
'definition' | 'animation' | 'expression' | 'defaultAnimation' | 'defaultExpression'
|
||||||
|
> & {
|
||||||
|
animation?: StringKey<Definition['animations']>
|
||||||
|
defaultAnimation?: StringKey<Definition['animations']>
|
||||||
|
expression?: StringKey<Definition['expressions']>
|
||||||
|
defaultExpression?: StringKey<Definition['expressions']>
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A concrete avatar component with animation and expression keys from its definition. */
|
||||||
|
export type CreatedAvatarComponent<Definition extends AvatarDefinitionInput> = (
|
||||||
|
props: CreatedAvatarProps<Definition>
|
||||||
|
) => ReactElement
|
||||||
|
|
||||||
|
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
|
||||||
|
const first = errors[0]
|
||||||
|
return new Error(
|
||||||
|
first
|
||||||
|
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
|
||||||
|
: 'Invalid avatar definition.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildAvatarComponent = (definition: Readonly<AvatarDefinition>) => {
|
||||||
|
const ConcreteAvatar = (props: CreatedAvatarProps<AvatarDefinition>): ReactElement => (
|
||||||
|
<Avatar {...props} definition={definition} />
|
||||||
|
)
|
||||||
|
ConcreteAvatar.displayName = 'CreatedAvatar'
|
||||||
|
return ConcreteAvatar
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a JSON-compatible definition once and create a concrete React component from it.
|
||||||
|
*
|
||||||
|
* When the input is a statically typed definition, the returned component narrows its animation
|
||||||
|
* and expression props to that definition's semantic keys. Values loaded at runtime are still
|
||||||
|
* validated, but necessarily expose the broad string-key API at compile time.
|
||||||
|
*/
|
||||||
|
export function createAvatar<const Definition extends AvatarDefinitionInput>(
|
||||||
|
definition: Definition
|
||||||
|
): CreatedAvatarComponent<Definition>
|
||||||
|
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition>
|
||||||
|
export function createAvatar(definition: unknown): CreatedAvatarComponent<AvatarDefinition> {
|
||||||
|
const result = validateAvatarDefinition(definition)
|
||||||
|
if (!result.ok) throw invalidDefinitionError(result.errors)
|
||||||
|
markAvatarDefinitionValidated(result.value)
|
||||||
|
return buildAvatarComponent(result.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AnimationKey, AvatarController, ExpressionKey }
|
||||||
10
packages/avatar-react/src/index.ts
Normal file
10
packages/avatar-react/src/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
export { Avatar } from './Avatar'
|
||||||
|
export { createAvatar } from './createAvatar'
|
||||||
|
export type {
|
||||||
|
AvatarCommandResult,
|
||||||
|
AvatarController,
|
||||||
|
AvatarPlaybackState,
|
||||||
|
AvatarProps,
|
||||||
|
AvatarRuntimeError,
|
||||||
|
} from './Avatar'
|
||||||
|
export type { CreatedAvatarComponent, CreatedAvatarProps } from './createAvatar'
|
||||||
31
packages/avatar-react/src/styles.css
Normal file
31
packages/avatar-react/src/styles.css
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
.bs-avatar {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: inline-grid;
|
||||||
|
place-items: center;
|
||||||
|
max-width: 100%;
|
||||||
|
aspect-ratio: 1;
|
||||||
|
outline: none;
|
||||||
|
user-select: none;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-avatar:focus-visible {
|
||||||
|
outline: 3px solid currentColor;
|
||||||
|
outline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-avatar__svg {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
overflow: visible;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
.bs-avatar,
|
||||||
|
.bs-avatar * {
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
1
packages/avatar-react/src/styles.d.ts
vendored
Normal file
1
packages/avatar-react/src/styles.d.ts
vendored
Normal file
@ -0,0 +1 @@
|
|||||||
|
declare module '*.css'
|
||||||
12
packages/avatar-react/tsconfig.build.json
Normal file
12
packages/avatar-react/tsconfig.build.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
15
packages/avatar-react/tsconfig.json
Normal file
15
packages/avatar-react/tsconfig.json
Normal file
@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx"
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"],
|
||||||
|
"exclude": ["src/**/__tests__/**"]
|
||||||
|
}
|
||||||
18
packages/avatar-react/vite.config.ts
Normal file
18
packages/avatar-react/vite.config.ts
Normal file
@ -0,0 +1,18 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: 'index',
|
||||||
|
cssFileName: 'styles',
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['react', 'react-dom', 'react/jsx-runtime', '@bible-strong/avatar-core'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
10
packages/avatar-web/LICENSE
Normal file
10
packages/avatar-web/LICENSE
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
Copyright (C) 2026 Stéphane Montlouis-Calixte
|
||||||
|
|
||||||
|
This program is free software: you can redistribute it and/or modify it under the terms of the GNU
|
||||||
|
Affero General Public License as published by the Free Software Foundation, version 3.
|
||||||
|
|
||||||
|
This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without
|
||||||
|
even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||||
|
Affero General Public License for more details.
|
||||||
|
|
||||||
|
The complete license text is available at <https://www.gnu.org/licenses/agpl-3.0.txt>.
|
||||||
28
packages/avatar-web/README.md
Normal file
28
packages/avatar-web/README.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# @bible-strong/avatar-web
|
||||||
|
|
||||||
|
DOM renderer for Bible Strong procedural avatars. It uses `@bible-strong/avatar-core` for schema
|
||||||
|
validation, playback and rendering, without requiring React.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm add @bible-strong/avatar-web
|
||||||
|
```
|
||||||
|
|
||||||
|
```js
|
||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
import definition from './cloudee.avatar.json'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
defaultAnimation: 'idle',
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.play('happy')
|
||||||
|
avatar.pause()
|
||||||
|
avatar.stop()
|
||||||
|
```
|
||||||
|
|
||||||
|
For a browser project without a bundler, load an ESM build through an import map or CDN and fetch
|
||||||
|
the definition JSON before calling `createAvatar`.
|
||||||
|
|
||||||
|
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.
|
||||||
55
packages/avatar-web/package.json
Normal file
55
packages/avatar-web/package.json
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
{
|
||||||
|
"name": "@bible-strong/avatar-web",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"description": "Framework-independent DOM renderer for Bible Strong procedural avatars.",
|
||||||
|
"keywords": [
|
||||||
|
"avatar",
|
||||||
|
"svg",
|
||||||
|
"animation",
|
||||||
|
"dom",
|
||||||
|
"esm",
|
||||||
|
"typescript"
|
||||||
|
],
|
||||||
|
"author": "Stéphane Montlouis-Calixte",
|
||||||
|
"license": "AGPL-3.0-only",
|
||||||
|
"homepage": "https://github.com/smontlouis/bible-strong-avatar-lab#readme",
|
||||||
|
"repository": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "git+https://github.com/smontlouis/bible-strong-avatar-lab.git",
|
||||||
|
"directory": "packages/avatar-web"
|
||||||
|
},
|
||||||
|
"bugs": {
|
||||||
|
"url": "https://github.com/smontlouis/bible-strong-avatar-lab/issues"
|
||||||
|
},
|
||||||
|
"type": "module",
|
||||||
|
"sideEffects": false,
|
||||||
|
"files": [
|
||||||
|
"dist",
|
||||||
|
"README.md",
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"exports": {
|
||||||
|
".": {
|
||||||
|
"types": "./dist/index.d.ts",
|
||||||
|
"import": "./dist/index.js"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
|
||||||
|
"prepack": "pnpm build",
|
||||||
|
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||||
|
},
|
||||||
|
"publishConfig": {
|
||||||
|
"access": "public"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@bible-strong/avatar-core": "workspace:^"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"typescript": "~6.0.3",
|
||||||
|
"vite": "^8.0.13"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
49
packages/avatar-web/src/__tests__/avatar-web-test.ts
Normal file
49
packages/avatar-web/src/__tests__/avatar-web-test.ts
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import { createAvatar } from '../index'
|
||||||
|
|
||||||
|
describe('@bible-strong/avatar-web', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
document.body.innerHTML = '<div id="avatar"></div>'
|
||||||
|
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||||
|
vi.stubGlobal('matchMedia', () => ({ matches: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('mounts the shared avatar definition without React', () => {
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition: definitionJson,
|
||||||
|
defaultExpression: 'neutral',
|
||||||
|
size: 180,
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(document.querySelector('#avatar svg')).not.toBeNull()
|
||||||
|
expect(document.querySelectorAll('#avatar svg > path')).toHaveLength(37)
|
||||||
|
expect(document.querySelector('[role="img"]')?.getAttribute('aria-label')).toBe(
|
||||||
|
'Procedural avatar'
|
||||||
|
)
|
||||||
|
expect(avatar.getState()).toMatchObject({
|
||||||
|
activeExpression: 'neutral',
|
||||||
|
status: 'stopped',
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.destroy()
|
||||||
|
expect(document.querySelector('#avatar svg')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('returns typed errors for unknown targets', () => {
|
||||||
|
const avatar = createAvatar('#avatar', { definition: definitionJson })
|
||||||
|
|
||||||
|
expect(avatar.play('missing')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: expect.objectContaining({ code: 'unknown_animation', key: 'missing' }),
|
||||||
|
})
|
||||||
|
expect(avatar.setExpression('missing')).toEqual({
|
||||||
|
ok: false,
|
||||||
|
error: expect.objectContaining({ code: 'unknown_expression', key: 'missing' }),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
304
packages/avatar-web/src/index.ts
Normal file
304
packages/avatar-web/src/index.ts
Normal file
@ -0,0 +1,304 @@
|
|||||||
|
import {
|
||||||
|
advanceAvatarPlayback,
|
||||||
|
createAvatarPlaybackState,
|
||||||
|
MAX_BODY_NODES,
|
||||||
|
pauseAvatarPlayback,
|
||||||
|
playAvatarAnimation,
|
||||||
|
renderAvatarDefinition,
|
||||||
|
renderAvatarFrame,
|
||||||
|
resolveAnimation,
|
||||||
|
resolveExpression,
|
||||||
|
resumeAvatarPlayback,
|
||||||
|
sampleAvatarFrame,
|
||||||
|
validateAvatarDefinition,
|
||||||
|
type AnimationKey,
|
||||||
|
type AvatarDefinition,
|
||||||
|
type AvatarPlaybackState as CorePlaybackState,
|
||||||
|
type AvatarRuntimeError,
|
||||||
|
type ExpressionKey,
|
||||||
|
} from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
|
||||||
|
|
||||||
|
export type AvatarPlaybackState = Pick<
|
||||||
|
CorePlaybackState,
|
||||||
|
'activeAnimation' | 'activeExpression' | 'status'
|
||||||
|
>
|
||||||
|
|
||||||
|
export type AvatarController = {
|
||||||
|
play(animation: AnimationKey): AvatarCommandResult
|
||||||
|
setExpression(expression: ExpressionKey): AvatarCommandResult
|
||||||
|
pause(): void
|
||||||
|
stop(): void
|
||||||
|
getState(): AvatarPlaybackState
|
||||||
|
destroy(): void
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CreateAvatarOptions = {
|
||||||
|
definition: unknown
|
||||||
|
defaultAnimation?: AnimationKey
|
||||||
|
defaultExpression?: ExpressionKey
|
||||||
|
autoplay?: boolean
|
||||||
|
size?: number | string
|
||||||
|
ariaLabel?: string
|
||||||
|
className?: string
|
||||||
|
onError?: (error: AvatarRuntimeError) => void
|
||||||
|
onAnimationEnd?: (animation: AnimationKey) => void
|
||||||
|
onExpressionChange?: (expression: ExpressionKey) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const svgNamespace = 'http://www.w3.org/2000/svg'
|
||||||
|
const controlledExpressionTransitionMs = 420
|
||||||
|
const bodyPathSlots = MAX_BODY_NODES + 2
|
||||||
|
let avatarInstanceId = 0
|
||||||
|
|
||||||
|
const dimension = (size: number | string) => (typeof size === 'number' ? `${size}px` : size)
|
||||||
|
|
||||||
|
const invalidDefinitionError = (errors: readonly { path: string; message: string }[]) => {
|
||||||
|
const first = errors[0]
|
||||||
|
return new Error(
|
||||||
|
first
|
||||||
|
? `Invalid avatar definition${first.path ? ` at ${first.path}` : ''}: ${first.message}`
|
||||||
|
: 'Invalid avatar definition.'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveTarget = (target: string | HTMLElement) => {
|
||||||
|
const element = typeof target === 'string' ? document.querySelector<HTMLElement>(target) : target
|
||||||
|
if (!element) throw new Error(`Avatar target '${target}' was not found.`)
|
||||||
|
return element
|
||||||
|
}
|
||||||
|
|
||||||
|
const createSvgElement = <Name extends keyof SVGElementTagNameMap>(name: Name) =>
|
||||||
|
document.createElementNS(svgNamespace, name)
|
||||||
|
|
||||||
|
const playbackSnapshot = (state: CorePlaybackState): AvatarPlaybackState => ({
|
||||||
|
...(state.activeAnimation ? { activeAnimation: state.activeAnimation } : {}),
|
||||||
|
activeExpression: state.activeExpression,
|
||||||
|
status: state.status,
|
||||||
|
})
|
||||||
|
|
||||||
|
const runtimeEnvironment = () => ({
|
||||||
|
random: Math.random,
|
||||||
|
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
|
||||||
|
})
|
||||||
|
|
||||||
|
export function createAvatar(
|
||||||
|
target: string | HTMLElement,
|
||||||
|
{
|
||||||
|
definition: input,
|
||||||
|
defaultAnimation,
|
||||||
|
defaultExpression,
|
||||||
|
autoplay = true,
|
||||||
|
size = 240,
|
||||||
|
ariaLabel = 'Procedural avatar',
|
||||||
|
className,
|
||||||
|
onError,
|
||||||
|
onAnimationEnd,
|
||||||
|
onExpressionChange,
|
||||||
|
}: CreateAvatarOptions
|
||||||
|
): AvatarController {
|
||||||
|
if (defaultAnimation !== undefined && defaultExpression !== undefined) {
|
||||||
|
throw new Error('Choose either defaultAnimation or defaultExpression, not both.')
|
||||||
|
}
|
||||||
|
const validated = validateAvatarDefinition(input)
|
||||||
|
if (!validated.ok) throw invalidDefinitionError(validated.errors)
|
||||||
|
const definition: Readonly<AvatarDefinition> = validated.value
|
||||||
|
const mount = resolveTarget(target)
|
||||||
|
const host = document.createElement('span')
|
||||||
|
host.className = ['bs-avatar', className ?? ''].filter(Boolean).join(' ')
|
||||||
|
host.style.display = 'inline-block'
|
||||||
|
host.style.width = dimension(size)
|
||||||
|
host.style.height = dimension(size)
|
||||||
|
host.setAttribute('role', 'img')
|
||||||
|
host.setAttribute('aria-label', ariaLabel)
|
||||||
|
|
||||||
|
const svg = createSvgElement('svg')
|
||||||
|
svg.setAttribute('viewBox', '-150 -150 300 300')
|
||||||
|
svg.setAttribute('aria-hidden', 'true')
|
||||||
|
svg.style.display = 'block'
|
||||||
|
svg.style.width = '100%'
|
||||||
|
svg.style.height = '100%'
|
||||||
|
const defs = createSvgElement('defs')
|
||||||
|
const clipPath = createSvgElement('clipPath')
|
||||||
|
const clipId = `bs-avatar-web-${++avatarInstanceId}`
|
||||||
|
clipPath.id = clipId
|
||||||
|
const clipHeadPath = createSvgElement('path')
|
||||||
|
clipPath.append(clipHeadPath)
|
||||||
|
defs.append(clipPath)
|
||||||
|
svg.append(defs)
|
||||||
|
|
||||||
|
const initialScene = renderAvatarDefinition(definition)
|
||||||
|
const backPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
|
||||||
|
const headPath = createSvgElement('path')
|
||||||
|
const eyeGroup = createSvgElement('g')
|
||||||
|
eyeGroup.setAttribute('clip-path', `url(#${clipId})`)
|
||||||
|
const leftPath = createSvgElement('path')
|
||||||
|
const rightPath = createSvgElement('path')
|
||||||
|
eyeGroup.append(leftPath, rightPath)
|
||||||
|
const frontPaths = Array.from({ length: bodyPathSlots }, () => createSvgElement('path'))
|
||||||
|
svg.append(...backPaths, headPath, eyeGroup, ...frontPaths)
|
||||||
|
host.append(svg)
|
||||||
|
mount.append(host)
|
||||||
|
|
||||||
|
const reportError = (error: AvatarRuntimeError) => {
|
||||||
|
if (onError) onError(error)
|
||||||
|
else console.error(`[Avatar] ${error.message}`)
|
||||||
|
}
|
||||||
|
const paint = (scene: ReturnType<typeof renderAvatarDefinition>) => {
|
||||||
|
clipHeadPath.setAttribute('d', scene.geometry.headPath)
|
||||||
|
headPath.setAttribute('d', scene.geometry.headPath)
|
||||||
|
headPath.setAttribute('fill', scene.colors.body)
|
||||||
|
leftPath.setAttribute('d', scene.geometry.leftPath)
|
||||||
|
leftPath.setAttribute('fill', scene.colors.eyes)
|
||||||
|
leftPath.setAttribute('opacity', scene.geometry.leftVisible ? '1' : '0')
|
||||||
|
rightPath.setAttribute('d', scene.geometry.rightPath)
|
||||||
|
rightPath.setAttribute('fill', scene.colors.eyes)
|
||||||
|
rightPath.setAttribute('opacity', scene.geometry.rightVisible ? '1' : '0')
|
||||||
|
backPaths.forEach((element, index) => {
|
||||||
|
element.setAttribute('d', scene.geometry.backPaths[index] ?? '')
|
||||||
|
element.setAttribute('fill', scene.colors.body)
|
||||||
|
})
|
||||||
|
frontPaths.forEach((element, index) => {
|
||||||
|
element.setAttribute('d', scene.geometry.frontPaths[index] ?? '')
|
||||||
|
element.setAttribute('fill', scene.colors.body)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let playback = createAvatarPlaybackState()
|
||||||
|
let frameRequest: number | null = null
|
||||||
|
let destroyed = false
|
||||||
|
let completedAnimation: AnimationKey | undefined
|
||||||
|
let lastExpression: ExpressionKey | undefined
|
||||||
|
let paintedFrame: ReturnType<typeof sampleAvatarFrame> | undefined
|
||||||
|
|
||||||
|
const notifyExpression = () => {
|
||||||
|
if (lastExpression === playback.activeExpression) return
|
||||||
|
lastExpression = playback.activeExpression
|
||||||
|
onExpressionChange?.(playback.activeExpression)
|
||||||
|
}
|
||||||
|
const renderCurrent = (now: number) => {
|
||||||
|
const environment = runtimeEnvironment()
|
||||||
|
paintedFrame = sampleAvatarFrame(definition, playback, now, environment)
|
||||||
|
paint(renderAvatarFrame(definition, playback, now, environment))
|
||||||
|
notifyExpression()
|
||||||
|
}
|
||||||
|
const tick = (now: number) => {
|
||||||
|
frameRequest = null
|
||||||
|
if (destroyed) return
|
||||||
|
const currentAnimation = playback.activeAnimation
|
||||||
|
const wasPlaying = playback.status === 'playing'
|
||||||
|
playback = advanceAvatarPlayback(definition, playback, now, {
|
||||||
|
random: Math.random,
|
||||||
|
reduceMotion: window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false,
|
||||||
|
})
|
||||||
|
renderCurrent(now)
|
||||||
|
if (wasPlaying && playback.status === 'stopped' && currentAnimation) {
|
||||||
|
if (completedAnimation !== currentAnimation) onAnimationEnd?.(currentAnimation)
|
||||||
|
completedAnimation = currentAnimation
|
||||||
|
}
|
||||||
|
if (playback.status === 'playing') frameRequest = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
const schedule = () => {
|
||||||
|
if (frameRequest === null && !destroyed) frameRequest = requestAnimationFrame(tick)
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller: AvatarController = {
|
||||||
|
play(animation) {
|
||||||
|
if (
|
||||||
|
playback.status === 'paused' &&
|
||||||
|
playback.activeAnimation === animation &&
|
||||||
|
playback.pausedAt !== undefined
|
||||||
|
) {
|
||||||
|
playback = resumeAvatarPlayback(playback, performance.now())
|
||||||
|
schedule()
|
||||||
|
return { ok: true }
|
||||||
|
}
|
||||||
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
|
||||||
|
const result = playAvatarAnimation(definition, animation, now, from)
|
||||||
|
if (!result.ok) return { ok: false, error: result.error }
|
||||||
|
completedAnimation = undefined
|
||||||
|
playback = result.value
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
schedule()
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
setExpression(expression) {
|
||||||
|
const result = resolveExpression(definition, expression)
|
||||||
|
if (!result.ok) return { ok: false, error: result.error }
|
||||||
|
const now = performance.now()
|
||||||
|
const from =
|
||||||
|
paintedFrame ?? sampleAvatarFrame(definition, playback, now, runtimeEnvironment())
|
||||||
|
playback = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: expression,
|
||||||
|
...(playback.activeExpression === expression
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
status: 'playing' as const,
|
||||||
|
directTransition: {
|
||||||
|
from,
|
||||||
|
startedAt: now,
|
||||||
|
durationMs: controlledExpressionTransitionMs,
|
||||||
|
transition: 'smooth' as const,
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
if (playback.status === 'playing') schedule()
|
||||||
|
return { ok: true }
|
||||||
|
},
|
||||||
|
pause() {
|
||||||
|
if (playback.status !== 'playing') return
|
||||||
|
playback = pauseAvatarPlayback(playback, performance.now())
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
|
frameRequest = null
|
||||||
|
},
|
||||||
|
stop() {
|
||||||
|
playback = createAvatarPlaybackState()
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
|
frameRequest = null
|
||||||
|
paint(renderAvatarDefinition(definition))
|
||||||
|
notifyExpression()
|
||||||
|
},
|
||||||
|
getState() {
|
||||||
|
return playbackSnapshot(playback)
|
||||||
|
},
|
||||||
|
destroy() {
|
||||||
|
destroyed = true
|
||||||
|
if (frameRequest !== null) cancelAnimationFrame(frameRequest)
|
||||||
|
frameRequest = null
|
||||||
|
host.remove()
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (defaultAnimation !== undefined) {
|
||||||
|
const resolved = resolveAnimation(definition, defaultAnimation)
|
||||||
|
if (!resolved.ok) reportError(resolved.error)
|
||||||
|
else if (autoplay) controller.play(defaultAnimation)
|
||||||
|
else {
|
||||||
|
playback = {
|
||||||
|
...createAvatarPlaybackState(),
|
||||||
|
activeExpression: resolved.value.steps[0]?.expression ?? 'neutral',
|
||||||
|
}
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
}
|
||||||
|
} else if (defaultExpression !== undefined) {
|
||||||
|
const resolved = resolveExpression(definition, defaultExpression)
|
||||||
|
if (!resolved.ok) reportError(resolved.error)
|
||||||
|
else {
|
||||||
|
playback = { ...createAvatarPlaybackState(), activeExpression: defaultExpression }
|
||||||
|
renderCurrent(performance.now())
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
paint(initialScene)
|
||||||
|
paintedFrame = sampleAvatarFrame(definition, playback, performance.now(), runtimeEnvironment())
|
||||||
|
notifyExpression()
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { AnimationKey, AvatarDefinition, AvatarRuntimeError, ExpressionKey }
|
||||||
12
packages/avatar-web/tsconfig.build.json
Normal file
12
packages/avatar-web/tsconfig.build.json
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"noEmit": false,
|
||||||
|
"emitDeclarationOnly": true,
|
||||||
|
"declaration": true,
|
||||||
|
"declarationMap": true,
|
||||||
|
"rootDir": "src",
|
||||||
|
"outDir": "dist"
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
14
packages/avatar-web/tsconfig.json
Normal file
14
packages/avatar-web/tsconfig.json
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"strict": true,
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "Bundler",
|
||||||
|
"isolatedModules": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"noEmit": true
|
||||||
|
},
|
||||||
|
"include": ["src", "vite.config.ts"],
|
||||||
|
"exclude": ["src/**/__tests__/**"]
|
||||||
|
}
|
||||||
17
packages/avatar-web/vite.config.ts
Normal file
17
packages/avatar-web/vite.config.ts
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
build: {
|
||||||
|
lib: {
|
||||||
|
entry: fileURLToPath(new URL('./src/index.ts', import.meta.url)),
|
||||||
|
formats: ['es'],
|
||||||
|
fileName: 'index',
|
||||||
|
},
|
||||||
|
sourcemap: true,
|
||||||
|
rollupOptions: {
|
||||||
|
external: ['@bible-strong/avatar-core'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
897
pnpm-lock.yaml
generated
897
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
3
pnpm-workspace.yaml
Normal file
3
pnpm-workspace.yaml
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
packages:
|
||||||
|
- packages/*
|
||||||
|
- examples/*
|
||||||
@ -8,6 +8,11 @@ const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
|||||||
const result = await build({
|
const result = await build({
|
||||||
configFile: false,
|
configFile: false,
|
||||||
logLevel: 'silent',
|
logLevel: 'silent',
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
|
||||||
|
},
|
||||||
|
},
|
||||||
build: {
|
build: {
|
||||||
write: false,
|
write: false,
|
||||||
minify: true,
|
minify: true,
|
||||||
|
|||||||
73
scripts/smoke-packages.mjs
Normal file
73
scripts/smoke-packages.mjs
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||||
|
import { tmpdir } from 'node:os'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { spawnSync } from 'node:child_process'
|
||||||
|
|
||||||
|
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
||||||
|
const workspace = await mkdtemp(path.join(tmpdir(), 'avatar-runtime-pack-smoke-'))
|
||||||
|
const consumer = path.join(workspace, 'consumer')
|
||||||
|
|
||||||
|
const run = (args, cwd = root, capture = false) => {
|
||||||
|
const result = spawnSync('pnpm', args, {
|
||||||
|
cwd,
|
||||||
|
encoding: 'utf8',
|
||||||
|
stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
|
||||||
|
})
|
||||||
|
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||||
|
return result.stdout?.trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
run(['packages:build'])
|
||||||
|
const pack = packageName => {
|
||||||
|
const output = run(
|
||||||
|
['--dir', path.join(root, 'packages', packageName), 'pack', '--pack-destination', workspace],
|
||||||
|
root,
|
||||||
|
true
|
||||||
|
)
|
||||||
|
return output.split('\n').at(-1)
|
||||||
|
}
|
||||||
|
const coreTarball = pack('avatar-core')
|
||||||
|
const reactTarball = pack('avatar-react')
|
||||||
|
const webTarball = pack('avatar-web')
|
||||||
|
|
||||||
|
await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
|
||||||
|
recursive: true,
|
||||||
|
filter: source =>
|
||||||
|
!source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
|
||||||
|
})
|
||||||
|
const packagePath = path.join(consumer, 'package.json')
|
||||||
|
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
|
||||||
|
packageJson.dependencies['@bible-strong/avatar-core'] = `file:${coreTarball}`
|
||||||
|
packageJson.dependencies['@bible-strong/avatar-react'] = `file:${reactTarball}`
|
||||||
|
packageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
|
||||||
|
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
|
||||||
|
|
||||||
|
run(['install', '--no-frozen-lockfile'], consumer)
|
||||||
|
run(['build'], consumer)
|
||||||
|
|
||||||
|
const webConsumer = path.join(workspace, 'web-consumer')
|
||||||
|
await cp(path.join(root, 'examples/web-vite-consumer'), webConsumer, {
|
||||||
|
recursive: true,
|
||||||
|
filter: source =>
|
||||||
|
!source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
|
||||||
|
})
|
||||||
|
await cp(
|
||||||
|
path.join(root, 'examples/react-vite-consumer/src/strobi.avatar.json'),
|
||||||
|
path.join(webConsumer, 'src/strobi.avatar.json')
|
||||||
|
)
|
||||||
|
const webMainPath = path.join(webConsumer, 'src/main.ts')
|
||||||
|
const webMain = await readFile(webMainPath, 'utf8')
|
||||||
|
await writeFile(
|
||||||
|
webMainPath,
|
||||||
|
webMain.replace('../../react-vite-consumer/src/strobi.avatar.json', './strobi.avatar.json')
|
||||||
|
)
|
||||||
|
const webPackagePath = path.join(webConsumer, 'package.json')
|
||||||
|
const webPackageJson = JSON.parse(await readFile(webPackagePath, 'utf8'))
|
||||||
|
webPackageJson.dependencies['@bible-strong/avatar-web'] = `file:${webTarball}`
|
||||||
|
webPackageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
|
||||||
|
await writeFile(webPackagePath, `${JSON.stringify(webPackageJson, null, 2)}\n`)
|
||||||
|
|
||||||
|
run(['install', '--no-frozen-lockfile'], webConsumer)
|
||||||
|
run(['build'], webConsumer)
|
||||||
|
process.stdout.write(`Tarball consumers verified at ${consumer} and ${webConsumer}\n`)
|
||||||
@ -1,6 +1,6 @@
|
|||||||
import { Pause, Play, Square } from 'lucide-react'
|
import { Pause, Play, Square } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion, useMotionValue, useMotionValueEvent, useTransform } from 'motion/react'
|
||||||
import { useId } from 'react'
|
import { useEffect, useEffectEvent, useId, useRef } from 'react'
|
||||||
|
|
||||||
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
import { AccordionContent, AccordionItem, AccordionTrigger } from '@/components/ui/accordion'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
@ -11,6 +11,11 @@ import { useStudioLanguage } from '@/i18n'
|
|||||||
import { type PlaybackStatus } from '@/app/studio-utils'
|
import { type PlaybackStatus } from '@/app/studio-utils'
|
||||||
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
|
import type { AvatarRenderStyle } from '@/features/avatar/avatars'
|
||||||
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
||||||
|
import {
|
||||||
|
normalizeSnapshotComposition,
|
||||||
|
snapshotCornerRadius,
|
||||||
|
type SnapshotComposition,
|
||||||
|
} from '@/features/export/snapshotComposition'
|
||||||
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
import { LivePixelAvatarCanvas } from '@/features/rendering/components/PixelAvatarCanvas'
|
||||||
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
|
import { type RenderedColors, type RenderedScene } from '@/features/rendering/renderedScene'
|
||||||
export function ControlSection({
|
export function ControlSection({
|
||||||
@ -44,6 +49,8 @@ export function SnapshotPreview({
|
|||||||
colorFrom,
|
colorFrom,
|
||||||
colorTo,
|
colorTo,
|
||||||
renderStyle,
|
renderStyle,
|
||||||
|
composition,
|
||||||
|
onCompositionChange,
|
||||||
}: {
|
}: {
|
||||||
scene: RenderedScene
|
scene: RenderedScene
|
||||||
colors: RenderedColors
|
colors: RenderedColors
|
||||||
@ -51,11 +58,29 @@ export function SnapshotPreview({
|
|||||||
colorFrom: string
|
colorFrom: string
|
||||||
colorTo: string
|
colorTo: string
|
||||||
renderStyle: AvatarRenderStyle
|
renderStyle: AvatarRenderStyle
|
||||||
|
composition: SnapshotComposition
|
||||||
|
onCompositionChange: (composition: SnapshotComposition) => void
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useStudioLanguage()
|
||||||
const id = useId().replace(/:/g, '')
|
const id = useId().replace(/:/g, '')
|
||||||
const clipId = `${id}-clip`
|
const clipId = `${id}-clip`
|
||||||
|
const frameClipId = `${id}-frame-clip`
|
||||||
const linearId = `${id}-linear`
|
const linearId = `${id}-linear`
|
||||||
const radialId = `${id}-radial`
|
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 =
|
const backgroundFill =
|
||||||
background === 'solid'
|
background === 'solid'
|
||||||
? colorFrom
|
? colorFrom
|
||||||
@ -71,21 +96,158 @@ export function SnapshotPreview({
|
|||||||
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
|
? `radial-gradient(circle at 50% 42%, ${colorFrom}, ${colorTo})`
|
||||||
: undefined
|
: 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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={frameRef}
|
||||||
className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
|
className={`snapshot-preview ${background === 'transparent' ? 'is-transparent' : ''}`}
|
||||||
style={renderStyle.type === 'pixel' ? { background: pixelBackground } : undefined}
|
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}
|
||||||
>
|
>
|
||||||
{renderStyle.type === 'pixel' ? (
|
{renderStyle.type === 'pixel' ? (
|
||||||
<LivePixelAvatarCanvas
|
<motion.div
|
||||||
scene={scene}
|
className="snapshot-pixel-transform"
|
||||||
colors={colors}
|
style={{ x: pixelPositionX, y: pixelPositionY, scale }}
|
||||||
style={renderStyle}
|
>
|
||||||
className="avatar-preview"
|
<LivePixelAvatarCanvas
|
||||||
/>
|
scene={scene}
|
||||||
|
colors={colors}
|
||||||
|
style={renderStyle}
|
||||||
|
className="avatar-preview"
|
||||||
|
/>
|
||||||
|
</motion.div>
|
||||||
) : (
|
) : (
|
||||||
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
|
<svg className="avatar-preview" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||||
<defs>
|
<defs>
|
||||||
|
<clipPath id={frameClipId}>
|
||||||
|
<rect
|
||||||
|
x="-150"
|
||||||
|
y="-150"
|
||||||
|
width="300"
|
||||||
|
height="300"
|
||||||
|
rx={snapshotCornerRadius(composition.cornerRadius)}
|
||||||
|
/>
|
||||||
|
</clipPath>
|
||||||
<clipPath id={clipId}>
|
<clipPath id={clipId}>
|
||||||
<motion.path d={scene.headPath} />
|
<motion.path d={scene.headPath} />
|
||||||
</clipPath>
|
</clipPath>
|
||||||
@ -98,22 +260,30 @@ export function SnapshotPreview({
|
|||||||
<stop offset="1" stopColor={colorTo} />
|
<stop offset="1" stopColor={colorTo} />
|
||||||
</radialGradient>
|
</radialGradient>
|
||||||
</defs>
|
</defs>
|
||||||
{background !== 'transparent' && (
|
<g clipPath={`url(#${frameClipId})`}>
|
||||||
<rect x="-150" y="-150" width="300" height="300" fill={backgroundFill} />
|
{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) => (
|
<g ref={compositionGroupRef}>
|
||||||
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
<motion.g style={{ x: scene.offsetX, y: scene.offsetY }}>
|
||||||
))}
|
{scene.backPaths.map((pathValue, index) => (
|
||||||
<motion.path d={scene.headPath} fill={colors.body} />
|
<motion.path d={pathValue} fill={colors.body} key={`back-${index}`} />
|
||||||
<g clipPath={`url(#${clipId})`}>
|
))}
|
||||||
<motion.path d={scene.leftPath} fill={colors.eyes} opacity={scene.leftOpacity} />
|
<motion.path d={scene.headPath} fill={colors.body} />
|
||||||
<motion.path d={scene.rightPath} fill={colors.eyes} opacity={scene.rightOpacity} />
|
<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>
|
||||||
</g>
|
</g>
|
||||||
{scene.frontPaths.map((pathValue, index) => (
|
</g>
|
||||||
<motion.path d={pathValue} fill={colors.body} key={`front-${index}`} />
|
|
||||||
))}
|
|
||||||
</motion.g>
|
|
||||||
</svg>
|
</svg>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@ -124,11 +294,13 @@ export function ExportSection({
|
|||||||
value,
|
value,
|
||||||
title,
|
title,
|
||||||
subtitle,
|
subtitle,
|
||||||
|
badge,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
value: string
|
value: string
|
||||||
title: string
|
title: string
|
||||||
subtitle: string
|
subtitle: string
|
||||||
|
badge?: string
|
||||||
children: React.ReactNode
|
children: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { t } = useStudioLanguage()
|
const { t } = useStudioLanguage()
|
||||||
@ -136,7 +308,10 @@ export function ExportSection({
|
|||||||
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
|
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
|
||||||
<AccordionTrigger className="export-accordion-trigger">
|
<AccordionTrigger className="export-accordion-trigger">
|
||||||
<span>
|
<span>
|
||||||
<strong>{t(title)}</strong>
|
<span className="export-accordion-title">
|
||||||
|
<strong>{t(title)}</strong>
|
||||||
|
{badge && <b className="export-menu-badge">{t(badge)}</b>}
|
||||||
|
</span>
|
||||||
<small>{t(subtitle)}</small>
|
<small>{t(subtitle)}</small>
|
||||||
</span>
|
</span>
|
||||||
</AccordionTrigger>
|
</AccordionTrigger>
|
||||||
|
|||||||
@ -12,7 +12,8 @@ import { type BodyNode } from '@/features/avatar/body'
|
|||||||
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
|
import { poseFromExpression, renderAvatar, type Expression } from '@/features/avatar/geometry'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
|
|
||||||
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export'
|
export type Mode = 'avatars' | 'manual' | 'expressions' | 'states' | 'export' | 'photo'
|
||||||
|
export type PhotoTool = 'pose' | 'frame'
|
||||||
export type ExportFormat = 'react' | 'javascript'
|
export type ExportFormat = 'react' | 'javascript'
|
||||||
export type SnapshotFormat = 'svg' | 'png'
|
export type SnapshotFormat = 'svg' | 'png'
|
||||||
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
export type PlaybackStatus = 'playing' | 'paused' | 'stopped'
|
||||||
@ -43,6 +44,7 @@ export type Highlight = 'head' | 'left' | 'right' | 'both' | null
|
|||||||
export const RETARGET_BLEND_MS = 120
|
export const RETARGET_BLEND_MS = 120
|
||||||
export const INSPECTOR_FRAME_MS = 1000 / 24
|
export const INSPECTOR_FRAME_MS = 1000 / 24
|
||||||
export const AMBIENT_FRAME_MS = 1000 / 30
|
export const AMBIENT_FRAME_MS = 1000 / 30
|
||||||
|
export const COPY_FEEDBACK_DURATION_MS = 2000
|
||||||
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
|
export const createExpressionId = () => `expression-${crypto.randomUUID()}`
|
||||||
export const emptyBodyNodes: BodyNode[] = []
|
export const emptyBodyNodes: BodyNode[] = []
|
||||||
const previewGeometryCache = new WeakMap<
|
const previewGeometryCache = new WeakMap<
|
||||||
|
|||||||
1293
src/app/styles.css
1293
src/app/styles.css
File diff suppressed because it is too large
Load Diff
37
src/components/ui/dialog.tsx
Normal file
37
src/components/ui/dialog.tsx
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
import { Dialog as DialogPrimitive } from '@base-ui/react/dialog'
|
||||||
|
import type * as React from 'react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
function Dialog(props: DialogPrimitive.Root.Props) {
|
||||||
|
return <DialogPrimitive.Root {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogClose(props: DialogPrimitive.Close.Props) {
|
||||||
|
return <DialogPrimitive.Close {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogContent({ className, children, ...props }: DialogPrimitive.Popup.Props) {
|
||||||
|
return (
|
||||||
|
<DialogPrimitive.Portal>
|
||||||
|
<DialogPrimitive.Backdrop className="dialog-backdrop" />
|
||||||
|
<DialogPrimitive.Popup className={cn('dialog-content', className)} {...props}>
|
||||||
|
{children}
|
||||||
|
</DialogPrimitive.Popup>
|
||||||
|
</DialogPrimitive.Portal>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogHeader({ className, ...props }: React.ComponentProps<'div'>) {
|
||||||
|
return <div className={cn('dialog-header', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogTitle({ className, ...props }: DialogPrimitive.Title.Props) {
|
||||||
|
return <DialogPrimitive.Title className={cn('dialog-title', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
function DialogDescription({ className, ...props }: DialogPrimitive.Description.Props) {
|
||||||
|
return <DialogPrimitive.Description className={cn('dialog-description', className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Dialog, DialogClose, DialogContent, DialogDescription, DialogHeader, DialogTitle }
|
||||||
41
src/components/ui/menu.tsx
Normal file
41
src/components/ui/menu.tsx
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
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 }
|
||||||
@ -1,7 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
advanceSequenceCursor,
|
advanceSequenceCursor,
|
||||||
createInitialSequences,
|
createInitialSequences,
|
||||||
|
duplicateSequence,
|
||||||
getSequenceSpring,
|
getSequenceSpring,
|
||||||
|
NEUTRAL_EXPRESSION_ID,
|
||||||
normalizeSequencesForExpressions,
|
normalizeSequencesForExpressions,
|
||||||
parseSequences,
|
parseSequences,
|
||||||
remapSequencesAfterExpressionDelete,
|
remapSequencesAfterExpressionDelete,
|
||||||
@ -87,6 +89,21 @@ describe('editable avatar sequences', () => {
|
|||||||
expect(normalized.steps[0].expressionId).toBe(initialExpressions[0].id)
|
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', () => {
|
it('maps transition styles and durations to distinct spring dynamics', () => {
|
||||||
const smooth = getSequenceSpring('smooth', 900, 7)
|
const smooth = getSequenceSpring('smooth', 900, 7)
|
||||||
const snappy = getSequenceSpring('snappy', 250, 7)
|
const snappy = getSequenceSpring('snappy', 250, 7)
|
||||||
@ -94,4 +111,13 @@ describe('editable avatar sequences', () => {
|
|||||||
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
|
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
|
||||||
expect(smooth.damping).toBeGreaterThan(0)
|
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)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -25,7 +25,8 @@ import { ControlSection, InspectorCard, PanelTitle } from '@/app/components/comm
|
|||||||
import { NumericField } from '@/app/components/controls'
|
import { NumericField } from '@/app/components/controls'
|
||||||
import {
|
import {
|
||||||
createSequenceStep,
|
createSequenceStep,
|
||||||
findExpressionIndex,
|
NEUTRAL_EXPRESSION_ID,
|
||||||
|
resolveSequenceExpression,
|
||||||
type AvatarSequence,
|
type AvatarSequence,
|
||||||
type SequenceStep,
|
type SequenceStep,
|
||||||
} from '@/features/animation/sequences'
|
} from '@/features/animation/sequences'
|
||||||
@ -37,6 +38,7 @@ import {
|
|||||||
import { type BodyNode } from '@/features/avatar/body'
|
import { type BodyNode } from '@/features/avatar/body'
|
||||||
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
import { ExpressionPreview } from '@/features/avatar/components/ExpressionWorkspace'
|
||||||
import { type Expression } from '@/features/avatar/geometry'
|
import { type Expression } from '@/features/avatar/geometry'
|
||||||
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
import { type SurfaceConfig } from '@/features/avatar/surfaces'
|
||||||
export function SequenceWorkspace({
|
export function SequenceWorkspace({
|
||||||
editing,
|
editing,
|
||||||
@ -62,6 +64,7 @@ export function SequenceWorkspace({
|
|||||||
onSave,
|
onSave,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
semanticKeyError,
|
||||||
}: {
|
}: {
|
||||||
editing: { sourceId: string | null; draft: AvatarSequence }
|
editing: { sourceId: string | null; draft: AvatarSequence }
|
||||||
expressions: Expression[]
|
expressions: Expression[]
|
||||||
@ -86,6 +89,7 @@ export function SequenceWorkspace({
|
|||||||
onSave: () => void
|
onSave: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
semanticKeyError: string | null
|
||||||
}) {
|
}) {
|
||||||
const { t } = useStudioLanguage()
|
const { t } = useStudioLanguage()
|
||||||
const draggedStepId = useRef<string | null>(null)
|
const draggedStepId = useRef<string | null>(null)
|
||||||
@ -161,6 +165,35 @@ export function SequenceWorkspace({
|
|||||||
compact
|
compact
|
||||||
>
|
>
|
||||||
<InspectorCard>
|
<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>
|
<Field>
|
||||||
<FieldTitle>{t('Nom')}</FieldTitle>
|
<FieldTitle>{t('Nom')}</FieldTitle>
|
||||||
<Input
|
<Input
|
||||||
@ -237,9 +270,8 @@ export function SequenceWorkspace({
|
|||||||
{editing.draft.steps.length ? (
|
{editing.draft.steps.length ? (
|
||||||
<div className="sequence-timeline">
|
<div className="sequence-timeline">
|
||||||
{editing.draft.steps.map((step, position) => {
|
{editing.draft.steps.map((step, position) => {
|
||||||
const expressionIndex = findExpressionIndex(expressions, step.expressionId)
|
const resolved = resolveSequenceExpression(expressions, step.expressionId)
|
||||||
const preset = expressions[expressionIndex]
|
if (!resolved.expression) return null
|
||||||
if (!preset) return null
|
|
||||||
const card = (
|
const card = (
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@ -249,11 +281,15 @@ export function SequenceWorkspace({
|
|||||||
onSelectedStepChange(step.id)
|
onSelectedStepChange(step.id)
|
||||||
onPreviewStep(step)
|
onPreviewStep(step)
|
||||||
}}
|
}}
|
||||||
onDoubleClick={() => onEditExpression(expressionIndex, preset)}
|
onDoubleClick={
|
||||||
|
resolved.neutral
|
||||||
|
? undefined
|
||||||
|
: () => onEditExpression(resolved.index, resolved.expression)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<GripVertical className="sequence-grip" />
|
<GripVertical className="sequence-grip" />
|
||||||
<ExpressionPreview
|
<ExpressionPreview
|
||||||
expression={preset}
|
expression={resolved.expression}
|
||||||
surface={surface}
|
surface={surface}
|
||||||
bodyNodes={bodyNodes}
|
bodyNodes={bodyNodes}
|
||||||
colors={colors}
|
colors={colors}
|
||||||
@ -261,7 +297,11 @@ export function SequenceWorkspace({
|
|||||||
renderStyle={renderStyle}
|
renderStyle={renderStyle}
|
||||||
id={`sequence-${editing.draft.id}-${step.id}`}
|
id={`sequence-${editing.draft.id}-${step.id}`}
|
||||||
/>
|
/>
|
||||||
<span>{String(expressionIndex).padStart(2, '0')}</span>
|
<span>
|
||||||
|
{resolved.neutral
|
||||||
|
? t('Apparence neutre')
|
||||||
|
: String(resolved.index).padStart(2, '0')}
|
||||||
|
</span>
|
||||||
<small>{position + 1}</small>
|
<small>{position + 1}</small>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -287,16 +327,20 @@ export function SequenceWorkspace({
|
|||||||
draggedStepId.current = null
|
draggedStepId.current = null
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<ContextMenu>
|
{resolved.neutral ? (
|
||||||
<ContextMenuTrigger render={card} />
|
card
|
||||||
<ContextMenuContent>
|
) : (
|
||||||
<ContextMenuItem
|
<ContextMenu>
|
||||||
onClick={() => onEditExpression(expressionIndex, preset)}
|
<ContextMenuTrigger render={card} />
|
||||||
>
|
<ContextMenuContent>
|
||||||
<Pencil /> {t('Modifier')}
|
<ContextMenuItem
|
||||||
</ContextMenuItem>
|
onClick={() => onEditExpression(resolved.index, resolved.expression)}
|
||||||
</ContextMenuContent>
|
>
|
||||||
</ContextMenu>
|
<Pencil /> {t('Modifier')}
|
||||||
|
</ContextMenuItem>
|
||||||
|
</ContextMenuContent>
|
||||||
|
</ContextMenu>
|
||||||
|
)}
|
||||||
</motion.div>
|
</motion.div>
|
||||||
)
|
)
|
||||||
})}
|
})}
|
||||||
@ -377,6 +421,28 @@ export function SequenceWorkspace({
|
|||||||
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
|
subtitle="Sélectionne un preset pour l’ajouter à la fin de la timeline."
|
||||||
/>
|
/>
|
||||||
<div className="expression-grid sequence-expression-library">
|
<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) => (
|
{expressions.map((preset, index) => (
|
||||||
<Button
|
<Button
|
||||||
className="expression-card"
|
className="expression-card"
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
defaultExpression,
|
||||||
getStatePlaybackConfig,
|
getStatePlaybackConfig,
|
||||||
initialExpressions,
|
initialExpressions,
|
||||||
stateGroups,
|
stateGroups,
|
||||||
@ -9,6 +10,7 @@ import type { Expression } from '../avatar/geometry'
|
|||||||
|
|
||||||
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
export type SequencePlaybackMode = 'loop' | 'once' | 'pingPong'
|
||||||
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
export type SequenceTransition = 'spring' | 'smooth' | 'snappy'
|
||||||
|
export const NEUTRAL_EXPRESSION_ID = defaultExpression.id
|
||||||
|
|
||||||
export type SequenceStep = {
|
export type SequenceStep = {
|
||||||
id: string
|
id: string
|
||||||
@ -28,6 +30,7 @@ export type BlinkSettings = {
|
|||||||
|
|
||||||
export type AvatarSequence = {
|
export type AvatarSequence = {
|
||||||
id: string
|
id: string
|
||||||
|
semanticKey?: string
|
||||||
name: string
|
name: string
|
||||||
group: string
|
group: string
|
||||||
description: string
|
description: string
|
||||||
@ -67,6 +70,7 @@ export const createInitialSequences = (): AvatarSequence[] =>
|
|||||||
const playback = getStatePlaybackConfig(id)
|
const playback = getStatePlaybackConfig(id)
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
semanticKey: id,
|
||||||
name: id,
|
name: id,
|
||||||
group,
|
group,
|
||||||
description:
|
description:
|
||||||
@ -117,6 +121,7 @@ const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence
|
|||||||
const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
|
const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
|
||||||
return {
|
return {
|
||||||
id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
|
id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
|
||||||
|
...(typeof candidate?.semanticKey === 'string' ? { semanticKey: candidate.semanticKey } : {}),
|
||||||
name:
|
name:
|
||||||
typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
|
typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
|
||||||
group:
|
group:
|
||||||
@ -167,13 +172,16 @@ export const normalizeSequencesForExpressions = (
|
|||||||
expressions: Expression[]
|
expressions: Expression[]
|
||||||
) => {
|
) => {
|
||||||
const availableIds = new Set(expressions.map(expression => expression.id))
|
const availableIds = new Set(expressions.map(expression => expression.id))
|
||||||
const fallbackId = expressions[0]?.id ?? initialExpressions[0].id
|
const fallbackId = expressions[0]?.id ?? NEUTRAL_EXPRESSION_ID
|
||||||
return sequences.map(sequence => ({
|
return sequences.map(sequence => ({
|
||||||
...sequence,
|
...sequence,
|
||||||
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
|
steps: (sequence.steps.length ? sequence.steps : [createSequenceStep(fallbackId)]).map(
|
||||||
step => ({
|
step => ({
|
||||||
...step,
|
...step,
|
||||||
expressionId: availableIds.has(step.expressionId) ? step.expressionId : fallbackId,
|
expressionId:
|
||||||
|
step.expressionId === NEUTRAL_EXPRESSION_ID || availableIds.has(step.expressionId)
|
||||||
|
? step.expressionId
|
||||||
|
: fallbackId,
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
}))
|
}))
|
||||||
@ -182,6 +190,17 @@ export const normalizeSequencesForExpressions = (
|
|||||||
export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
|
export const findExpressionIndex = (expressions: Expression[], expressionId: string) =>
|
||||||
expressions.findIndex(expression => expression.id === expressionId)
|
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 readSequenceClock = () => performance.now()
|
||||||
|
|
||||||
export const groupSequences = (sequences: AvatarSequence[]) => {
|
export const groupSequences = (sequences: AvatarSequence[]) => {
|
||||||
@ -227,6 +246,7 @@ export const createSequence = (expressionId = initialExpressions[0].id): AvatarS
|
|||||||
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
|
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
|
||||||
...cloneSequence(source),
|
...cloneSequence(source),
|
||||||
id: createId('sequence'),
|
id: createId('sequence'),
|
||||||
|
semanticKey: undefined,
|
||||||
name: `${source.name} copy`,
|
name: `${source.name} copy`,
|
||||||
builtIn: false,
|
builtIn: false,
|
||||||
steps: source.steps.map(step => ({ ...step, id: createId('step') })),
|
steps: source.steps.map(step => ({ ...step, id: createId('step') })),
|
||||||
|
|||||||
636
src/features/avatar/__tests__/avatar-definition-test.ts
Normal file
636
src/features/avatar/__tests__/avatar-definition-test.ts
Normal file
@ -0,0 +1,636 @@
|
|||||||
|
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')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,17 +1,29 @@
|
|||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
import { createInitialSequences } from '@/features/animation/sequences'
|
import { createInitialSequences, NEUTRAL_EXPRESSION_ID } from '@/features/animation/sequences'
|
||||||
import {
|
import {
|
||||||
applyAvatarEyeDefaults,
|
applyAvatarEyeDefaults,
|
||||||
cloneAvatarBehavior,
|
cloneAvatarBehavior,
|
||||||
createAvatar,
|
createAvatar,
|
||||||
|
createUnkeyedExpressionCopy,
|
||||||
defaultAvatarEyes,
|
defaultAvatarEyes,
|
||||||
parseAvatarEyeDefaults,
|
parseAvatarEyeDefaults,
|
||||||
|
parseAvatarLibrary,
|
||||||
parseAvatarRenderStyle,
|
parseAvatarRenderStyle,
|
||||||
resolveAvatarBehavior,
|
resolveAvatarBehavior,
|
||||||
} from '@/features/avatar/avatars'
|
} from '@/features/avatar/avatars'
|
||||||
import { initialExpressions } from '@/features/avatar/presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('avatar eye defaults', () => {
|
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', () => {
|
it('keeps the historical rendering when using default values', () => {
|
||||||
expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
|
expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
|
||||||
})
|
})
|
||||||
@ -41,20 +53,14 @@ describe('avatar render style', () => {
|
|||||||
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
|
expect(parseAvatarRenderStyle(undefined)).toEqual({ type: 'vector' })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('sanitizes pixel settings', () => {
|
it('falls back to vector rendering while pixel mode is disabled', () => {
|
||||||
expect(
|
expect(
|
||||||
parseAvatarRenderStyle({
|
parseAvatarRenderStyle({
|
||||||
type: 'pixel',
|
type: 'pixel',
|
||||||
resolution: 500,
|
resolution: 500,
|
||||||
})
|
})
|
||||||
).toEqual({
|
).toEqual({ type: 'vector' })
|
||||||
type: 'pixel',
|
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({ type: 'vector' })
|
||||||
resolution: 192,
|
|
||||||
})
|
|
||||||
expect(parseAvatarRenderStyle({ type: 'pixel', resolution: 1 })).toEqual({
|
|
||||||
type: 'pixel',
|
|
||||||
resolution: 8,
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
@ -79,4 +85,30 @@ describe('avatar behavior library', () => {
|
|||||||
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
|
expect(behavior.sequences[0].steps).not.toBe(base.sequences[0].steps)
|
||||||
expect(behavior.sequences[0].blink).not.toBe(base.sequences[0].blink)
|
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
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@ -0,0 +1,23 @@
|
|||||||
|
import { readFile } from 'node:fs/promises'
|
||||||
|
import { resolve } from 'node:path'
|
||||||
|
|
||||||
|
import { createAvatarDefinition } from '../avatarDefinition'
|
||||||
|
import { resolveAvatarBehavior } from '../avatars'
|
||||||
|
import { loadStudioDocument } from '../../studio/studioDocument'
|
||||||
|
|
||||||
|
it('keeps the consumer fixture synchronized with the bundled Strobi Studio document', async () => {
|
||||||
|
const document = loadStudioDocument({ getItem: () => null })
|
||||||
|
const avatar = document.library.avatars.find(candidate => candidate.id === 'strobi')
|
||||||
|
if (!avatar) throw new Error('Bundled Strobi avatar not found')
|
||||||
|
const behavior = resolveAvatarBehavior(avatar, {
|
||||||
|
expressions: document.expressions,
|
||||||
|
sequences: document.sequences,
|
||||||
|
})
|
||||||
|
const result = createAvatarDefinition({ avatar, behavior })
|
||||||
|
if (!result.ok) throw new Error(result.errors.map(error => error.message).join('\n'))
|
||||||
|
const fixture = await readFile(
|
||||||
|
resolve('examples/react-vite-consumer/src/strobi.avatar.json'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
expect(JSON.parse(fixture)).toEqual(result.value)
|
||||||
|
})
|
||||||
145
src/features/avatar/__tests__/import-avatar-definition-test.ts
Normal file
145
src/features/avatar/__tests__/import-avatar-definition-test.ts
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
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'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,115 +1 @@
|
|||||||
import type { BodyMotion, Expression, EyeMotion } from './geometry'
|
export * from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
198
src/features/avatar/avatarDefinition.ts
Normal file
198
src/features/avatar/avatarDefinition.ts
Normal file
@ -0,0 +1,198 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@ -25,6 +25,7 @@ export type StudioAvatar = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type AvatarColors = { body: string; eyes: string }
|
export type AvatarColors = { body: string; eyes: string }
|
||||||
|
export const PIXEL_RENDERING_ENABLED = false
|
||||||
export type PixelRenderStyle = {
|
export type PixelRenderStyle = {
|
||||||
type: 'pixel'
|
type: 'pixel'
|
||||||
resolution: number
|
resolution: number
|
||||||
@ -85,7 +86,9 @@ const finiteBounded = (value: unknown, fallback: number, min: number, max: numbe
|
|||||||
|
|
||||||
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
export const parseAvatarRenderStyle = (value: unknown): AvatarRenderStyle => {
|
||||||
const candidate = value as Partial<PixelRenderStyle> | null
|
const candidate = value as Partial<PixelRenderStyle> | null
|
||||||
if (candidate?.type !== 'pixel') return { ...defaultAvatarRenderStyle }
|
if (!PIXEL_RENDERING_ENABLED || candidate?.type !== 'pixel') {
|
||||||
|
return { ...defaultAvatarRenderStyle }
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
type: 'pixel',
|
type: 'pixel',
|
||||||
resolution: Math.round(
|
resolution: Math.round(
|
||||||
@ -120,6 +123,12 @@ export const applyAvatarEyeDefaults = (
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const createUnkeyedExpressionCopy = (source: Expression, id: string): Expression => ({
|
||||||
|
...source,
|
||||||
|
id,
|
||||||
|
semanticKey: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
export type AvatarLibrary = {
|
export type AvatarLibrary = {
|
||||||
activeAvatarId: string
|
activeAvatarId: string
|
||||||
avatars: StudioAvatar[]
|
avatars: StudioAvatar[]
|
||||||
@ -153,6 +162,7 @@ export const parseExpressions = (value: unknown): Expression[] => {
|
|||||||
parsed.bodyColor = candidate.bodyColor
|
parsed.bodyColor = candidate.bodyColor
|
||||||
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
|
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
|
||||||
parsed.eyeColor = candidate.eyeColor
|
parsed.eyeColor = candidate.eyeColor
|
||||||
|
if (typeof candidate.semanticKey === 'string') parsed.semanticKey = candidate.semanticKey
|
||||||
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
|
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
|
||||||
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
|
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
|
||||||
? storedBodyMotion
|
? storedBodyMotion
|
||||||
@ -173,6 +183,41 @@ export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBeha
|
|||||||
sequences: cloneSequences(behavior.sequences),
|
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 = (
|
export const resolveAvatarBehavior = (
|
||||||
avatar: StudioAvatar,
|
avatar: StudioAvatar,
|
||||||
base: AvatarBehaviorLibrary
|
base: AvatarBehaviorLibrary
|
||||||
@ -184,15 +229,20 @@ const parseAvatarBehavior = (
|
|||||||
): AvatarBehaviorLibrary | undefined => {
|
): AvatarBehaviorLibrary | undefined => {
|
||||||
if (!value || typeof value !== 'object') return undefined
|
if (!value || typeof value !== 'object') return undefined
|
||||||
const candidate = value as Partial<AvatarBehaviorLibrary>
|
const candidate = value as Partial<AvatarBehaviorLibrary>
|
||||||
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
|
if (!Array.isArray(candidate.expressions)) return undefined
|
||||||
const expressions = parseExpressions(candidate.expressions)
|
const expressions = candidate.expressions.length ? parseExpressions(candidate.expressions) : []
|
||||||
const sequences = normalizeSequencesForExpressions(
|
return restoreLegacyBehaviorSemanticKeys(
|
||||||
Array.isArray(candidate.sequences)
|
{
|
||||||
? parseSequences(candidate.sequences)
|
expressions,
|
||||||
: cloneSequences(base.sequences),
|
sequences: normalizeSequencesForExpressions(
|
||||||
expressions
|
Array.isArray(candidate.sequences)
|
||||||
|
? parseSequences(candidate.sequences)
|
||||||
|
: cloneSequences(base.sequences),
|
||||||
|
expressions
|
||||||
|
),
|
||||||
|
},
|
||||||
|
base
|
||||||
)
|
)
|
||||||
return { expressions, sequences }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const createAvatar = (name: string): StudioAvatar => ({
|
export const createAvatar = (name: string): StudioAvatar => ({
|
||||||
|
|||||||
@ -1,117 +1 @@
|
|||||||
import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
|
export * from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
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],
|
|
||||||
})
|
|
||||||
|
|||||||
@ -3,6 +3,8 @@ import { useState, type RefObject } from 'react'
|
|||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Card } from '@/components/ui/card'
|
import { Card } from '@/components/ui/card'
|
||||||
|
import { Field } from '@/components/ui/field'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@ -141,6 +143,7 @@ export function ExpressionCard({
|
|||||||
onDragOver,
|
onDragOver,
|
||||||
onDrop,
|
onDrop,
|
||||||
onDragEnd,
|
onDragEnd,
|
||||||
|
runtimeError,
|
||||||
}: {
|
}: {
|
||||||
expression: Expression
|
expression: Expression
|
||||||
index: number
|
index: number
|
||||||
@ -161,6 +164,7 @@ export function ExpressionCard({
|
|||||||
onDragOver?: (event: React.DragEvent<HTMLButtonElement>) => void
|
onDragOver?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||||
onDrop?: (event: React.DragEvent<HTMLButtonElement>) => void
|
onDrop?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||||
onDragEnd?: () => void
|
onDragEnd?: () => void
|
||||||
|
runtimeError: string | null
|
||||||
}) {
|
}) {
|
||||||
const { t } = useStudioLanguage()
|
const { t } = useStudioLanguage()
|
||||||
const card = (
|
const card = (
|
||||||
@ -187,6 +191,16 @@ export function ExpressionCard({
|
|||||||
renderStyle={renderStyle}
|
renderStyle={renderStyle}
|
||||||
id={previewId}
|
id={previewId}
|
||||||
/>
|
/>
|
||||||
|
{runtimeError && (
|
||||||
|
<i
|
||||||
|
className="runtime-key-missing"
|
||||||
|
role="img"
|
||||||
|
aria-label={runtimeError}
|
||||||
|
title={runtimeError}
|
||||||
|
>
|
||||||
|
!
|
||||||
|
</i>
|
||||||
|
)}
|
||||||
<span>{String(index).padStart(2, '0')}</span>
|
<span>{String(index).padStart(2, '0')}</span>
|
||||||
</Button>
|
</Button>
|
||||||
)
|
)
|
||||||
@ -225,6 +239,7 @@ export function ExpressionWorkspace({
|
|||||||
onSave,
|
onSave,
|
||||||
onDuplicate,
|
onDuplicate,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
semanticKeyError,
|
||||||
}: {
|
}: {
|
||||||
editing: { index: number | null; draft: Expression }
|
editing: { index: number | null; draft: Expression }
|
||||||
avatarColors: AvatarColors
|
avatarColors: AvatarColors
|
||||||
@ -234,6 +249,7 @@ export function ExpressionWorkspace({
|
|||||||
onSave: () => void
|
onSave: () => void
|
||||||
onDuplicate: () => void
|
onDuplicate: () => void
|
||||||
onDelete: () => void
|
onDelete: () => void
|
||||||
|
semanticKeyError: string | null
|
||||||
}) {
|
}) {
|
||||||
const { t } = useStudioLanguage()
|
const { t } = useStudioLanguage()
|
||||||
const [linked, setLinked] = useState({
|
const [linked, setLinked] = useState({
|
||||||
@ -293,6 +309,43 @@ export function ExpressionWorkspace({
|
|||||||
</header>
|
</header>
|
||||||
<div className="workspace-scroll">
|
<div className="workspace-scroll">
|
||||||
<div className="dialog-fields">
|
<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
|
<ControlSection
|
||||||
title="Corps"
|
title="Corps"
|
||||||
subtitle="Apparence et orientation générale de l’avatar."
|
subtitle="Apparence et orientation générale de l’avatar."
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
267
src/features/avatar/importAvatarDefinition.ts
Normal file
267
src/features/avatar/importAvatarDefinition.ts
Normal file
@ -0,0 +1,267 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
@ -1,5 +1,33 @@
|
|||||||
import type { Expression } from './geometry'
|
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[][] = [
|
const calibrated: number[][] = [
|
||||||
[7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0],
|
[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],
|
[-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0],
|
||||||
@ -46,6 +74,7 @@ export const initialExpressions: Expression[] = calibrated.map(
|
|||||||
index
|
index
|
||||||
) => ({
|
) => ({
|
||||||
id: `expression-${String(index).padStart(2, '0')}`,
|
id: `expression-${String(index).padStart(2, '0')}`,
|
||||||
|
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
|
||||||
headX,
|
headX,
|
||||||
headY,
|
headY,
|
||||||
headZ,
|
headZ,
|
||||||
|
|||||||
@ -1,665 +1 @@
|
|||||||
import type { Point3 } from './geometry'
|
export * from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,17 +1,43 @@
|
|||||||
import { createAvatar } from '@/features/avatar/avatars'
|
import { createAvatar } from '@/features/avatar/avatars'
|
||||||
import { parse } from '@babel/parser'
|
import { parse } from '@babel/parser'
|
||||||
import {
|
import {
|
||||||
|
avatarDemoFileName,
|
||||||
createAvatarExportPayload,
|
createAvatarExportPayload,
|
||||||
generateJavaScriptAvatarHtml,
|
generateJavaScriptAvatarHtml,
|
||||||
generateJavaScriptAvatarModule,
|
generateJavaScriptAvatarModule,
|
||||||
generateJavaScriptAvatarPackage,
|
generateJavaScriptAvatarPackage,
|
||||||
|
generateJavaScriptEsmHtml,
|
||||||
|
generateJavaScriptEsmPackage,
|
||||||
generateReactAvatarComponent,
|
generateReactAvatarComponent,
|
||||||
generateReactAvatarPackage,
|
generateReactAvatarPackage,
|
||||||
generateReactAvatarRuntime,
|
generateReactAvatarRuntime,
|
||||||
|
generateReactViteMain,
|
||||||
|
generateReactVitePackage,
|
||||||
} from '@/features/export/exporter'
|
} from '@/features/export/exporter'
|
||||||
|
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||||
|
import { resolveAvatarBehavior } from '@/features/avatar/avatars'
|
||||||
|
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||||
import { initialExpressions } from '@/features/avatar/presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
import { createInitialSequences } from '@/features/animation/sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
|
|
||||||
|
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', () => {
|
describe('avatar export', () => {
|
||||||
const avatar = createAvatar('Strobi')
|
const avatar = createAvatar('Strobi')
|
||||||
const animations = createInitialSequences().filter(item =>
|
const animations = createInitialSequences().filter(item =>
|
||||||
@ -19,6 +45,11 @@ describe('avatar export', () => {
|
|||||||
)
|
)
|
||||||
const payload = createAvatarExportPayload(avatar, initialExpressions, animations)
|
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', () => {
|
it('includes only the selected animations and their referenced expressions', () => {
|
||||||
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
|
expect(Object.keys(payload.animations)).toEqual(['idle', 'listening'])
|
||||||
expect(Object.keys(payload.expressions).sort()).toEqual(
|
expect(Object.keys(payload.expressions).sort()).toEqual(
|
||||||
@ -29,6 +60,9 @@ describe('avatar export', () => {
|
|||||||
expect(payload).not.toHaveProperty('frames')
|
expect(payload).not.toHaveProperty('frames')
|
||||||
expect(payload.avatar.name).toBe('Strobi')
|
expect(payload.avatar.name).toBe('Strobi')
|
||||||
expect(payload.avatar.renderStyle).toEqual({ type: 'vector' })
|
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', () => {
|
it('preserves pixel rendering in standalone exports', () => {
|
||||||
@ -119,6 +153,77 @@ describe('avatar export', () => {
|
|||||||
expect(contents).toContain('avatar.js')
|
expect(contents).toContain('avatar.js')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('generates a lightweight ESM integration backed by avatar-web', async () => {
|
||||||
|
const document = loadStudioDocument({ getItem: () => null })
|
||||||
|
const studioAvatar = document.library.avatars[0]
|
||||||
|
const definition = createAvatarDefinition({
|
||||||
|
avatar: studioAvatar,
|
||||||
|
behavior: resolveAvatarBehavior(studioAvatar, {
|
||||||
|
expressions: document.expressions,
|
||||||
|
sequences: document.sequences,
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
expect(definition.ok).toBe(true)
|
||||||
|
if (!definition.ok) return
|
||||||
|
|
||||||
|
const source = generateJavaScriptEsmHtml('strobi.avatar.json', 'Strobi')
|
||||||
|
expect(source).toContain("from 'https://esm.sh/@bible-strong/avatar-web@0.1.0'")
|
||||||
|
expect(source).toContain("fetch('./strobi.avatar.json')")
|
||||||
|
expect(source).not.toContain('AvatarProceduralEngine')
|
||||||
|
|
||||||
|
const archive = new Uint8Array(
|
||||||
|
await generateJavaScriptEsmPackage(definition.value, 'Strobi').arrayBuffer()
|
||||||
|
)
|
||||||
|
const contents = new TextDecoder().decode(archive)
|
||||||
|
expect(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', () => {
|
it('generates a typed React component backed by the local runtime', () => {
|
||||||
const source = generateReactAvatarComponent(payload)
|
const source = generateReactAvatarComponent(payload)
|
||||||
|
|
||||||
|
|||||||
22
src/features/export/__tests__/snapshot-composition-test.ts
Normal file
22
src/features/export/__tests__/snapshot-composition-test.ts
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
import {
|
||||||
|
normalizeSnapshotComposition,
|
||||||
|
snapshotCornerRadius,
|
||||||
|
} from '@/features/export/snapshotComposition'
|
||||||
|
|
||||||
|
describe('snapshot composition', () => {
|
||||||
|
it('keeps framing values inside the authoring limits', () => {
|
||||||
|
expect(
|
||||||
|
normalizeSnapshotComposition({
|
||||||
|
x: -400,
|
||||||
|
y: 400,
|
||||||
|
scale: 8,
|
||||||
|
cornerRadius: 80,
|
||||||
|
})
|
||||||
|
).toEqual({ x: -180, y: 180, scale: 3, cornerRadius: 50 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('converts a percentage radius into the 300-unit snapshot viewBox', () => {
|
||||||
|
expect(snapshotCornerRadius(18)).toBe(54)
|
||||||
|
expect(snapshotCornerRadius(50)).toBe(150)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -26,7 +26,23 @@ describe('avatar snapshot export', () => {
|
|||||||
expect(svg).toContain('transform="translate(3 -2)"')
|
expect(svg).toContain('transform="translate(3 -2)"')
|
||||||
expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
|
expect(svg).toContain(`d="${geometry.headPath}" fill="#5b7fe5"`)
|
||||||
expect(svg).toContain('fill="#111316"')
|
expect(svg).toContain('fill="#111316"')
|
||||||
expect(svg).not.toContain('<rect')
|
expect(svg).not.toContain('width="300" height="300" fill=')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('applies logo framing without changing the rendered avatar scene', () => {
|
||||||
|
const svg = serializeAvatarSnapshot('Strobi', scene, colors, {
|
||||||
|
background: 'solid',
|
||||||
|
colorFrom: '#818181',
|
||||||
|
colorTo: '#818181',
|
||||||
|
size: 1024,
|
||||||
|
composition: { x: 55, y: 65, scale: 1.3, cornerRadius: 18 },
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(svg).toContain('<clipPath id="snapshot-frame-clip">')
|
||||||
|
expect(svg).toContain('rx="54"')
|
||||||
|
expect(svg).toContain('clip-path="url(#snapshot-frame-clip)"')
|
||||||
|
expect(svg).toContain('transform="translate(55 65) scale(1.3)"')
|
||||||
|
expect(svg).toContain('fill="#818181"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('embeds a radial background without external dependencies', () => {
|
it('embeds a radial background without external dependencies', () => {
|
||||||
|
|||||||
64
src/features/export/__tests__/snapshot-palette-test.ts
Normal file
64
src/features/export/__tests__/snapshot-palette-test.ts
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
import { randomSnapshotPalette, snapshotPaletteContrast } from '@/features/export/snapshotPalette'
|
||||||
|
|
||||||
|
describe('snapshot background palettes', () => {
|
||||||
|
it('returns a solid color for the solid style', () => {
|
||||||
|
const palette = randomSnapshotPalette(
|
||||||
|
'solid',
|
||||||
|
'#FFFFFF',
|
||||||
|
{ colorFrom: '#000000', colorTo: '#000000' },
|
||||||
|
() => 0
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(palette.colorFrom).toBe(palette.colorTo)
|
||||||
|
expect(snapshotPaletteContrast(palette.colorFrom, '#FFFFFF')).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each(['linear', 'radial'] as const)('returns two compatible colors for %s', style => {
|
||||||
|
const palette = randomSnapshotPalette(
|
||||||
|
style,
|
||||||
|
'#FFFFFF',
|
||||||
|
{ colorFrom: '#000000', colorTo: '#000000' },
|
||||||
|
() => 0
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(palette.colorFrom).not.toBe(palette.colorTo)
|
||||||
|
expect(snapshotPaletteContrast(palette.colorFrom, '#FFFFFF')).toBeGreaterThanOrEqual(3)
|
||||||
|
expect(snapshotPaletteContrast(palette.colorTo, '#FFFFFF')).toBeGreaterThanOrEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('creates a visibly deeper edge for radial backgrounds', () => {
|
||||||
|
const palette = randomSnapshotPalette(
|
||||||
|
'radial',
|
||||||
|
'#FFFFFF',
|
||||||
|
{ colorFrom: '#000000', colorTo: '#000000' },
|
||||||
|
() => 0.35
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(snapshotPaletteContrast(palette.colorFrom, palette.colorTo)).toBeGreaterThanOrEqual(1.5)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('does not immediately repeat the current palette', () => {
|
||||||
|
const initial = randomSnapshotPalette(
|
||||||
|
'linear',
|
||||||
|
'#151515',
|
||||||
|
{ colorFrom: '#000000', colorTo: '#000000' },
|
||||||
|
() => 0
|
||||||
|
)
|
||||||
|
const next = randomSnapshotPalette('linear', '#151515', initial, () => 0)
|
||||||
|
|
||||||
|
expect(next).not.toEqual(initial)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('produces broad hue variation instead of cycling through a short preset list', () => {
|
||||||
|
const palettes = Array.from({ length: 12 }, (_, index) =>
|
||||||
|
randomSnapshotPalette(
|
||||||
|
'solid',
|
||||||
|
'#FFFFFF',
|
||||||
|
{ colorFrom: '#000000', colorTo: '#000000' },
|
||||||
|
() => index / 12
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(new Set(palettes.map(palette => palette.colorFrom)).size).toBe(12)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,11 +1,15 @@
|
|||||||
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
import { applyAvatarEyeDefaults, type StudioAvatar } from '../avatar/avatars'
|
||||||
|
import { avatarDefinitionFileName, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
import type { Expression } from '../avatar/geometry'
|
import type { Expression } from '../avatar/geometry'
|
||||||
|
import { defaultExpression } from '../avatar/presets'
|
||||||
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
import { translateStudioText, type StudioLanguage } from '../../i18n'
|
||||||
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
import { proceduralBrowserRuntime } from './proceduralBrowserRuntime'
|
||||||
import type { AvatarSequence } from '../animation/sequences'
|
import { NEUTRAL_EXPRESSION_ID, type AvatarSequence } from '../animation/sequences'
|
||||||
import { standaloneEngineSource } from './standaloneEngine.generated'
|
import { standaloneEngineSource } from './standaloneEngine.generated'
|
||||||
import { createStoredZip } from './storedZip'
|
import { createStoredZip } from './storedZip'
|
||||||
|
|
||||||
|
const avatarRuntimePackageVersion = '0.1.0'
|
||||||
|
|
||||||
export type AvatarExportAnimation = Pick<
|
export type AvatarExportAnimation = Pick<
|
||||||
AvatarSequence,
|
AvatarSequence,
|
||||||
'name' | 'description' | 'playbackMode' | 'blink'
|
'name' | 'description' | 'playbackMode' | 'blink'
|
||||||
@ -57,10 +61,16 @@ export const createAvatarExportPayload = (
|
|||||||
)
|
)
|
||||||
const exportedExpressions = Object.fromEntries(
|
const exportedExpressions = Object.fromEntries(
|
||||||
[...referencedIds].flatMap(expressionId => {
|
[...referencedIds].flatMap(expressionId => {
|
||||||
const expression = expressionById.get(expressionId)
|
const expression =
|
||||||
return expression
|
expressionId === NEUTRAL_EXPRESSION_ID
|
||||||
? [[expressionId, applyAvatarEyeDefaults(expression, avatar.eyes)] as const]
|
? defaultExpression
|
||||||
: []
|
: expressionById.get(expressionId)
|
||||||
|
if (!expression) return []
|
||||||
|
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
|
||||||
|
expression,
|
||||||
|
avatar.eyes
|
||||||
|
)
|
||||||
|
return [[expressionId, legacyExpression] as const]
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
const usedKeys = new Set<string>()
|
const usedKeys = new Set<string>()
|
||||||
@ -354,6 +364,120 @@ export const generateJavaScriptAvatarPackage = (
|
|||||||
])
|
])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const generateJavaScriptEsmHtml = (
|
||||||
|
definitionFileName: string,
|
||||||
|
avatarName: string
|
||||||
|
) => `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${escapedHtml(avatarName)} · Avatar demo</title>
|
||||||
|
<style>
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
body { margin: 0; min-height: 100vh; padding: 32px; color: #f7f8fa; background: #0d1117; font-family: Inter, system-ui, sans-serif; }
|
||||||
|
main { width: min(980px, 100%); margin: auto; }
|
||||||
|
h1 { margin: 0 0 24px; }
|
||||||
|
.demo { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, .8fr); gap: 20px; }
|
||||||
|
.stage, .controls { border: 1px solid #293140; border-radius: 22px; background: #141a22; }
|
||||||
|
.stage { display: grid; min-height: 560px; place-items: center; }
|
||||||
|
#avatar { width: min(80%, 460px); aspect-ratio: 1; }
|
||||||
|
.controls { padding: 20px; }
|
||||||
|
h2 { margin: 0 0 12px; font-size: 15px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
|
||||||
|
button { min-width: 0; padding: 10px; overflow: hidden; color: #dbe3ef; border: 1px solid #354052; border-radius: 10px; background: #1b2330; font: inherit; text-overflow: ellipsis; cursor: pointer; }
|
||||||
|
button:hover, button[aria-pressed="true"] { color: #fff; border-color: #7392ed; background: #263657; }
|
||||||
|
@media (max-width: 760px) { body { padding: 16px; } .demo { grid-template-columns: 1fr; } .stage { min-height: 420px; } }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<main>
|
||||||
|
<h1>${escapedHtml(avatarName)}</h1>
|
||||||
|
<div class="demo">
|
||||||
|
<section class="stage"><div id="avatar"></div></section>
|
||||||
|
<aside class="controls">
|
||||||
|
<h2>Animations</h2>
|
||||||
|
<div class="grid" id="animations"></div>
|
||||||
|
<h2>Expressions</h2>
|
||||||
|
<div class="grid" id="expressions"></div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
<script type="module">
|
||||||
|
import { createAvatar } from 'https://esm.sh/@bible-strong/avatar-web@${avatarRuntimePackageVersion}'
|
||||||
|
|
||||||
|
const definition = await fetch('./${definitionFileName}').then(response => response.json())
|
||||||
|
const avatar = createAvatar('#avatar', { definition, size: '100%' })
|
||||||
|
|
||||||
|
const addButtons = (containerId, keys, activate) => {
|
||||||
|
const container = document.querySelector(containerId)
|
||||||
|
keys.forEach(key => {
|
||||||
|
const button = document.createElement('button')
|
||||||
|
button.textContent = key
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
container.querySelectorAll('button').forEach(item => item.setAttribute('aria-pressed', 'false'))
|
||||||
|
button.setAttribute('aria-pressed', 'true')
|
||||||
|
activate(key)
|
||||||
|
})
|
||||||
|
container.append(button)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
addButtons('#animations', definition.animationOrder, key => avatar.play(key))
|
||||||
|
addButtons('#expressions', definition.expressionOrder, key => avatar.setExpression(key))
|
||||||
|
if (definition.animationOrder[0]) avatar.play(definition.animationOrder[0])
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateJavaScriptEsmReadme = (
|
||||||
|
definitionFileName: string
|
||||||
|
) => `# Avatar JavaScript / ESM
|
||||||
|
|
||||||
|
This export uses the same .avatar.json definition as @bible-strong/avatar-react. The rendering and
|
||||||
|
playback engine is provided by @bible-strong/avatar-web instead of being copied into this export.
|
||||||
|
|
||||||
|
## Run the demo
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
npx serve .
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then open the local URL displayed by the command. index.html loads avatar-web from esm.sh and the
|
||||||
|
exported ${definitionFileName} file from this folder.
|
||||||
|
|
||||||
|
## Use in your application
|
||||||
|
|
||||||
|
\`\`\`js
|
||||||
|
import { createAvatar } from '@bible-strong/avatar-web'
|
||||||
|
import definition from './${definitionFileName}'
|
||||||
|
|
||||||
|
const avatar = createAvatar('#avatar', {
|
||||||
|
definition,
|
||||||
|
defaultAnimation: 'idle',
|
||||||
|
})
|
||||||
|
|
||||||
|
avatar.play('happy')
|
||||||
|
avatar.pause()
|
||||||
|
avatar.stop()
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The JSON import is intended for a modern ESM build tool such as Vite.
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateJavaScriptEsmPackage = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
avatarName: string
|
||||||
|
) => {
|
||||||
|
const definitionFileName = avatarDefinitionFileName(avatarName)
|
||||||
|
return createStoredZip([
|
||||||
|
{ name: definitionFileName, content: JSON.stringify(definition, null, 2) },
|
||||||
|
{ name: 'index.html', content: generateJavaScriptEsmHtml(definitionFileName, avatarName) },
|
||||||
|
{ name: 'README.md', content: generateJavaScriptEsmReadme(definitionFileName) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
const avatarExportSlug = (name: string) =>
|
const avatarExportSlug = (name: string) =>
|
||||||
name
|
name
|
||||||
.normalize('NFD')
|
.normalize('NFD')
|
||||||
@ -370,6 +494,210 @@ const avatarComponentName = (name: string) => {
|
|||||||
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
|
return /^[A-Za-z_$]/.test(identifier) ? identifier : `Avatar${identifier}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const generateReactViteMain = (
|
||||||
|
definitionImportPath: string,
|
||||||
|
avatarName: string
|
||||||
|
) => `import { createAvatar } from '@bible-strong/avatar-react'
|
||||||
|
import '@bible-strong/avatar-react/styles.css'
|
||||||
|
import { StrictMode, useState } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
|
||||||
|
import definition from '${definitionImportPath}'
|
||||||
|
import './styles.css'
|
||||||
|
|
||||||
|
const ExportedAvatar = createAvatar(definition)
|
||||||
|
type AnimationName = keyof typeof definition.animations
|
||||||
|
type ExpressionName = keyof typeof definition.expressions
|
||||||
|
type Target =
|
||||||
|
| { kind: 'animation'; key: AnimationName }
|
||||||
|
| { kind: 'expression'; key: ExpressionName }
|
||||||
|
|
||||||
|
const animations = definition.animationOrder as AnimationName[]
|
||||||
|
const expressions = definition.expressionOrder as ExpressionName[]
|
||||||
|
const initialTarget: Target = animations[0]
|
||||||
|
? { kind: 'animation', key: animations[0] }
|
||||||
|
: { kind: 'expression', key: expressions[0] ?? ('neutral' as ExpressionName) }
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
const [target, setTarget] = useState<Target>(initialTarget)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main>
|
||||||
|
<h1>{${JSON.stringify(avatarName)}}</h1>
|
||||||
|
<div className="demo">
|
||||||
|
<section className="stage">
|
||||||
|
<ExportedAvatar
|
||||||
|
{...(target.kind === 'animation'
|
||||||
|
? { animation: target.key }
|
||||||
|
: { expression: target.key })}
|
||||||
|
size="100%"
|
||||||
|
ariaLabel={${JSON.stringify(`${avatarName} avatar`)}}
|
||||||
|
/>
|
||||||
|
</section>
|
||||||
|
<aside className="controls">
|
||||||
|
<h2>Animations</h2>
|
||||||
|
<div className="grid">
|
||||||
|
{animations.map(key => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={target.kind === 'animation' && target.key === key}
|
||||||
|
onClick={() => setTarget({ kind: 'animation', key })}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<h2>Expressions</h2>
|
||||||
|
<div className="grid">
|
||||||
|
{expressions.map(key => (
|
||||||
|
<button
|
||||||
|
key={key}
|
||||||
|
type="button"
|
||||||
|
aria-pressed={target.kind === 'expression' && target.key === key}
|
||||||
|
onClick={() => setTarget({ kind: 'expression', key })}
|
||||||
|
>
|
||||||
|
{key}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>
|
||||||
|
)
|
||||||
|
`
|
||||||
|
|
||||||
|
const generateReactViteStyles = () => `* { box-sizing: border-box; }
|
||||||
|
:root { color-scheme: dark; font-family: Inter, system-ui, sans-serif; }
|
||||||
|
body { margin: 0; min-height: 100vh; padding: 32px; color: #f7f8fa; background: #0d1117; }
|
||||||
|
main { width: min(980px, 100%); margin: auto; }
|
||||||
|
h1 { margin: 0 0 24px; }
|
||||||
|
.demo { display: grid; grid-template-columns: minmax(280px, 1fr) minmax(280px, .8fr); gap: 20px; }
|
||||||
|
.stage, .controls { border: 1px solid #293140; border-radius: 22px; background: #141a22; }
|
||||||
|
.stage { display: grid; min-height: 560px; padding: 32px; place-items: center; }
|
||||||
|
.stage > * { width: min(80%, 460px) !important; height: auto !important; aspect-ratio: 1; }
|
||||||
|
.controls { padding: 20px; }
|
||||||
|
h2 { margin: 0 0 12px; font-size: 15px; }
|
||||||
|
.grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; margin-bottom: 22px; }
|
||||||
|
button { min-width: 0; padding: 10px; overflow: hidden; color: #dbe3ef; border: 1px solid #354052; border-radius: 10px; background: #1b2330; font: inherit; text-overflow: ellipsis; cursor: pointer; }
|
||||||
|
button:hover, button[aria-pressed="true"] { color: #fff; border-color: #7392ed; background: #263657; }
|
||||||
|
@media (max-width: 760px) { body { padding: 16px; } .demo { grid-template-columns: 1fr; } .stage { min-height: 420px; } }
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateReactViteReadme = (
|
||||||
|
definitionFileName: string,
|
||||||
|
avatarName: string
|
||||||
|
) => `# ${avatarName} · React / TypeScript demo
|
||||||
|
|
||||||
|
This ready-to-run Vite project uses the exported ${definitionFileName} definition with
|
||||||
|
@bible-strong/avatar-react.
|
||||||
|
|
||||||
|
## Run the demo
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
Then open the local URL displayed by Vite. The demo includes controls for every exported animation
|
||||||
|
and expression.
|
||||||
|
|
||||||
|
## Production build
|
||||||
|
|
||||||
|
\`\`\`sh
|
||||||
|
npm run build
|
||||||
|
\`\`\`
|
||||||
|
|
||||||
|
The component is created once from the JSON definition with \`createAvatar(definition)\`. TypeScript
|
||||||
|
derives the accepted animation and expression prop keys from that imported JSON file.
|
||||||
|
`
|
||||||
|
|
||||||
|
export const generateReactVitePackage = (
|
||||||
|
definition: Readonly<AvatarDefinition>,
|
||||||
|
avatarName: string
|
||||||
|
) => {
|
||||||
|
const definitionFileName = avatarDefinitionFileName(avatarName)
|
||||||
|
const packageName = `${avatarExportSlug(avatarName)}-avatar-react-demo`
|
||||||
|
const packageJson = {
|
||||||
|
name: packageName,
|
||||||
|
version: '0.0.0',
|
||||||
|
private: true,
|
||||||
|
type: 'module',
|
||||||
|
engines: { node: '>=22.12.0' },
|
||||||
|
scripts: {
|
||||||
|
dev: 'vite',
|
||||||
|
build: 'tsc --noEmit && vite build',
|
||||||
|
},
|
||||||
|
dependencies: {
|
||||||
|
'@bible-strong/avatar-react': avatarRuntimePackageVersion,
|
||||||
|
react: '^19.0.0',
|
||||||
|
'react-dom': '^19.0.0',
|
||||||
|
},
|
||||||
|
devDependencies: {
|
||||||
|
'@types/react': '^19.0.0',
|
||||||
|
'@types/react-dom': '^19.0.0',
|
||||||
|
'@vitejs/plugin-react': '^6.0.2',
|
||||||
|
typescript: '~6.0.3',
|
||||||
|
vite: '^8.0.13',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const indexHtml = `<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>${escapedHtml(avatarName)} · React avatar demo</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
|
`
|
||||||
|
const tsconfig = {
|
||||||
|
compilerOptions: {
|
||||||
|
target: 'ES2022',
|
||||||
|
lib: ['ES2022', 'DOM', 'DOM.Iterable'],
|
||||||
|
strict: true,
|
||||||
|
module: 'ESNext',
|
||||||
|
moduleResolution: 'Bundler',
|
||||||
|
resolveJsonModule: true,
|
||||||
|
isolatedModules: true,
|
||||||
|
noEmit: true,
|
||||||
|
jsx: 'react-jsx',
|
||||||
|
skipLibCheck: true,
|
||||||
|
},
|
||||||
|
include: ['src', 'vite.config.ts'],
|
||||||
|
}
|
||||||
|
const viteConfig = `import react from '@vitejs/plugin-react'
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
|
||||||
|
export default defineConfig({ plugins: [react()] })
|
||||||
|
`
|
||||||
|
|
||||||
|
return createStoredZip([
|
||||||
|
{ name: definitionFileName, content: JSON.stringify(definition, null, 2) },
|
||||||
|
{ name: 'package.json', content: JSON.stringify(packageJson, null, 2) },
|
||||||
|
{ name: 'index.html', content: indexHtml },
|
||||||
|
{ name: 'tsconfig.json', content: JSON.stringify(tsconfig, null, 2) },
|
||||||
|
{ name: 'vite.config.ts', content: viteConfig },
|
||||||
|
{
|
||||||
|
name: 'src/main.tsx',
|
||||||
|
content: generateReactViteMain(`../${definitionFileName}`, avatarName),
|
||||||
|
},
|
||||||
|
{ name: 'src/vite-env.d.ts', content: '/// <reference types="vite/client" />\n' },
|
||||||
|
{ name: 'src/styles.css', content: generateReactViteStyles() },
|
||||||
|
{ name: 'README.md', content: generateReactViteReadme(definitionFileName, avatarName) },
|
||||||
|
])
|
||||||
|
}
|
||||||
|
|
||||||
export const generateReactAvatarRuntime = () => `
|
export const generateReactAvatarRuntime = () => `
|
||||||
export type AvatarData<AnimationName extends string = string> = Readonly<{
|
export type AvatarData<AnimationName extends string = string> = Readonly<{
|
||||||
version: number
|
version: number
|
||||||
@ -547,3 +875,6 @@ export const generateReactAvatarPackage = (payload: AvatarExportPayload) => {
|
|||||||
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
|
export const avatarExportFileName = (name: string, extension: 'js' | 'tsx' | 'zip') => {
|
||||||
return `${avatarExportSlug(name)}-avatar.${extension}`
|
return `${avatarExportSlug(name)}-avatar.${extension}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const avatarDemoFileName = (name: string, format: 'react' | 'javascript') =>
|
||||||
|
`${avatarExportSlug(name)}-avatar-${format === 'react' ? 'react' : 'esm'}.zip`
|
||||||
|
|||||||
31
src/features/export/snapshotComposition.ts
Normal file
31
src/features/export/snapshotComposition.ts
Normal file
@ -0,0 +1,31 @@
|
|||||||
|
export type SnapshotComposition = Readonly<{
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
scale: number
|
||||||
|
cornerRadius: number
|
||||||
|
}>
|
||||||
|
|
||||||
|
export const defaultSnapshotComposition: SnapshotComposition = {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
scale: 1,
|
||||||
|
cornerRadius: 0,
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, min: number, max: number) =>
|
||||||
|
Math.min(max, Math.max(min, Number.isFinite(value) ? value : min))
|
||||||
|
|
||||||
|
export const normalizeSnapshotComposition = (
|
||||||
|
composition: SnapshotComposition
|
||||||
|
): SnapshotComposition => ({
|
||||||
|
x: clamp(composition.x, -180, 180),
|
||||||
|
y: clamp(composition.y, -180, 180),
|
||||||
|
scale: clamp(composition.scale, 0.4, 3),
|
||||||
|
cornerRadius: clamp(composition.cornerRadius, 0, 50),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const snapshotCornerRadius = (cornerRadius: number) =>
|
||||||
|
normalizeSnapshotComposition({
|
||||||
|
...defaultSnapshotComposition,
|
||||||
|
cornerRadius,
|
||||||
|
}).cornerRadius * 3
|
||||||
@ -1,5 +1,11 @@
|
|||||||
import type { AvatarColors } from '../avatar/avatars'
|
import type { AvatarColors } from '../avatar/avatars'
|
||||||
import type { RenderedScene } from '../rendering/renderedScene'
|
import type { RenderedScene } from '../rendering/renderedScene'
|
||||||
|
import {
|
||||||
|
defaultSnapshotComposition,
|
||||||
|
normalizeSnapshotComposition,
|
||||||
|
snapshotCornerRadius,
|
||||||
|
type SnapshotComposition,
|
||||||
|
} from './snapshotComposition'
|
||||||
|
|
||||||
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
export type SnapshotBackground = 'transparent' | 'solid' | 'linear' | 'radial'
|
||||||
|
|
||||||
@ -8,6 +14,7 @@ export type SnapshotOptions = {
|
|||||||
colorFrom: string
|
colorFrom: string
|
||||||
colorTo: string
|
colorTo: string
|
||||||
size: number
|
size: number
|
||||||
|
composition?: SnapshotComposition
|
||||||
}
|
}
|
||||||
|
|
||||||
const escapeXml = (value: string) =>
|
const escapeXml = (value: string) =>
|
||||||
@ -51,9 +58,18 @@ export const serializeAvatarSnapshot = (
|
|||||||
colors: AvatarColors,
|
colors: AvatarColors,
|
||||||
options: SnapshotOptions
|
options: SnapshotOptions
|
||||||
) => {
|
) => {
|
||||||
|
const composition = normalizeSnapshotComposition(
|
||||||
|
options.composition ?? defaultSnapshotComposition
|
||||||
|
)
|
||||||
const headPath = scene.headPath.get()
|
const headPath = scene.headPath.get()
|
||||||
const backPaths = scene.backPaths.map(item => item.get()).filter(Boolean)
|
const backPaths = scene.backPaths.flatMap(item => {
|
||||||
const frontPaths = scene.frontPaths.map(item => item.get()).filter(Boolean)
|
const value = item.get()
|
||||||
|
return value ? [value] : []
|
||||||
|
})
|
||||||
|
const frontPaths = scene.frontPaths.flatMap(item => {
|
||||||
|
const value = item.get()
|
||||||
|
return value ? [value] : []
|
||||||
|
})
|
||||||
const offsetX = scene.offsetX.get()
|
const offsetX = scene.offsetX.get()
|
||||||
const offsetY = scene.offsetY.get()
|
const offsetY = scene.offsetY.get()
|
||||||
const body = [
|
const body = [
|
||||||
@ -65,9 +81,11 @@ export const serializeAvatarSnapshot = (
|
|||||||
|
|
||||||
return `<?xml version="1.0" encoding="UTF-8"?>
|
return `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${options.size}" height="${options.size}" role="img" aria-label="${escapeXml(name)}">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-150 -150 300 300" width="${options.size}" height="${options.size}" role="img" aria-label="${escapeXml(name)}">
|
||||||
<defs>${gradientMarkup(options)}<clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
|
<defs>${gradientMarkup(options)}<clipPath id="snapshot-frame-clip"><rect x="-150" y="-150" width="300" height="300" rx="${snapshotCornerRadius(composition.cornerRadius)}"/></clipPath><clipPath id="snapshot-head-clip"><path d="${escapeXml(headPath)}"/></clipPath></defs>
|
||||||
${backgroundMarkup(options)}
|
<g clip-path="url(#snapshot-frame-clip)">
|
||||||
<g transform="translate(${offsetX} ${offsetY})">${body}</g>
|
${backgroundMarkup(options)}
|
||||||
|
<g transform="translate(${composition.x} ${composition.y}) scale(${composition.scale})"><g transform="translate(${offsetX} ${offsetY})">${body}</g></g>
|
||||||
|
</g>
|
||||||
</svg>`
|
</svg>`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
148
src/features/export/snapshotPalette.ts
Normal file
148
src/features/export/snapshotPalette.ts
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
export type SnapshotPaletteStyle = 'solid' | 'linear' | 'radial'
|
||||||
|
|
||||||
|
export type SnapshotPalette = {
|
||||||
|
colorFrom: string
|
||||||
|
colorTo: string
|
||||||
|
}
|
||||||
|
|
||||||
|
type OklchColor = {
|
||||||
|
lightness: number
|
||||||
|
chroma: number
|
||||||
|
hue: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const hexToRgb = (hex: string) => {
|
||||||
|
const normalized = hex.replace('#', '')
|
||||||
|
if (!/^[0-9a-f]{6}$/i.test(normalized)) return null
|
||||||
|
return {
|
||||||
|
red: Number.parseInt(normalized.slice(0, 2), 16),
|
||||||
|
green: Number.parseInt(normalized.slice(2, 4), 16),
|
||||||
|
blue: Number.parseInt(normalized.slice(4, 6), 16),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const relativeLuminance = (hex: string) => {
|
||||||
|
const rgb = hexToRgb(hex)
|
||||||
|
if (!rgb) return 0
|
||||||
|
const channel = (value: number) => {
|
||||||
|
const normalized = value / 255
|
||||||
|
return normalized <= 0.04045 ? normalized / 12.92 : ((normalized + 0.055) / 1.055) ** 2.4
|
||||||
|
}
|
||||||
|
return 0.2126 * channel(rgb.red) + 0.7152 * channel(rgb.green) + 0.0722 * channel(rgb.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const snapshotPaletteContrast = (first: string, second: string) => {
|
||||||
|
const firstLuminance = relativeLuminance(first)
|
||||||
|
const secondLuminance = relativeLuminance(second)
|
||||||
|
const lightest = Math.max(firstLuminance, secondLuminance)
|
||||||
|
const darkest = Math.min(firstLuminance, secondLuminance)
|
||||||
|
return (lightest + 0.05) / (darkest + 0.05)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clamp = (value: number, minimum = 0, maximum = 1) =>
|
||||||
|
Math.min(Math.max(value, minimum), maximum)
|
||||||
|
|
||||||
|
const linearToSrgb = (value: number) => {
|
||||||
|
const normalized = clamp(value)
|
||||||
|
return normalized <= 0.0031308 ? normalized * 12.92 : 1.055 * normalized ** (1 / 2.4) - 0.055
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelToHex = (value: number) =>
|
||||||
|
Math.round(clamp(value) * 255)
|
||||||
|
.toString(16)
|
||||||
|
.padStart(2, '0')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
const oklchToHex = ({ lightness, chroma, hue }: OklchColor) => {
|
||||||
|
const radians = (hue * Math.PI) / 180
|
||||||
|
const a = chroma * Math.cos(radians)
|
||||||
|
const b = chroma * Math.sin(radians)
|
||||||
|
const lPrime = lightness + 0.3963377774 * a + 0.2158037573 * b
|
||||||
|
const mPrime = lightness - 0.1055613458 * a - 0.0638541728 * b
|
||||||
|
const sPrime = lightness - 0.0894841775 * a - 1.291485548 * b
|
||||||
|
const l = lPrime ** 3
|
||||||
|
const m = mPrime ** 3
|
||||||
|
const s = sPrime ** 3
|
||||||
|
const red = linearToSrgb(4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s)
|
||||||
|
const green = linearToSrgb(-1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s)
|
||||||
|
const blue = linearToSrgb(-0.0041960863 * l - 0.7034186147 * m + 1.707614701 * s)
|
||||||
|
return `#${channelToHex(red)}${channelToHex(green)}${channelToHex(blue)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const samePalette = (first: SnapshotPalette, second: SnapshotPalette) =>
|
||||||
|
first.colorFrom.toLowerCase() === second.colorFrom.toLowerCase() &&
|
||||||
|
first.colorTo.toLowerCase() === second.colorTo.toLowerCase()
|
||||||
|
|
||||||
|
const paletteContrast = (palette: SnapshotPalette, foreground: string) =>
|
||||||
|
Math.min(
|
||||||
|
snapshotPaletteContrast(palette.colorFrom, foreground),
|
||||||
|
snapshotPaletteContrast(palette.colorTo, foreground)
|
||||||
|
)
|
||||||
|
|
||||||
|
const hueForAttempt = (random: () => number, attempt: number) =>
|
||||||
|
(random() * 360 + attempt * 137.508) % 360
|
||||||
|
|
||||||
|
const generatePalette = (
|
||||||
|
style: SnapshotPaletteStyle,
|
||||||
|
darkBackground: boolean,
|
||||||
|
random: () => number,
|
||||||
|
attempt: number
|
||||||
|
): SnapshotPalette => {
|
||||||
|
const hue = hueForAttempt(random, attempt)
|
||||||
|
const chroma = 0.065 + random() * 0.055
|
||||||
|
|
||||||
|
if (style === 'solid') {
|
||||||
|
const lightness = darkBackground ? 0.3 + random() * 0.14 : 0.78 + random() * 0.13
|
||||||
|
const color = oklchToHex({ lightness, chroma, hue })
|
||||||
|
return { colorFrom: color, colorTo: color }
|
||||||
|
}
|
||||||
|
|
||||||
|
const hueShift = 18 + random() * 38
|
||||||
|
const secondHue = (hue + (random() > 0.5 ? hueShift : -hueShift) + 360) % 360
|
||||||
|
if (style === 'radial') {
|
||||||
|
const centerLightness = darkBackground ? 0.36 : 0.94
|
||||||
|
const edgeLightness = darkBackground ? 0.12 : 0.7
|
||||||
|
return {
|
||||||
|
colorFrom: oklchToHex({ lightness: centerLightness, chroma: chroma * 0.82, hue }),
|
||||||
|
colorTo: oklchToHex({
|
||||||
|
lightness: edgeLightness,
|
||||||
|
chroma: chroma * (darkBackground ? 0.42 : 0.85),
|
||||||
|
hue: secondHue,
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startLightness = darkBackground ? 0.38 : 0.92
|
||||||
|
const endLightness = darkBackground ? 0.2 : 0.74
|
||||||
|
return {
|
||||||
|
colorFrom: oklchToHex({ lightness: startLightness, chroma, hue }),
|
||||||
|
colorTo: oklchToHex({ lightness: endLightness, chroma: chroma * 0.9, hue: secondHue }),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const randomSnapshotPalette = (
|
||||||
|
style: SnapshotPaletteStyle,
|
||||||
|
foreground: string,
|
||||||
|
current: SnapshotPalette,
|
||||||
|
random = Math.random
|
||||||
|
) => {
|
||||||
|
const darkContrast = snapshotPaletteContrast(foreground, '#15191F')
|
||||||
|
const lightContrast = snapshotPaletteContrast(foreground, '#F2E8D4')
|
||||||
|
const darkBackground =
|
||||||
|
darkContrast >= 3 && lightContrast >= 3 ? random() >= 0.5 : darkContrast >= lightContrast
|
||||||
|
let bestPalette: SnapshotPalette | null = null
|
||||||
|
let bestContrast = 0
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < 24; attempt += 1) {
|
||||||
|
const palette = generatePalette(style, darkBackground, random, attempt)
|
||||||
|
if (samePalette(palette, current)) continue
|
||||||
|
const contrast = paletteContrast(palette, foreground)
|
||||||
|
if (contrast >= 3) return palette
|
||||||
|
if (contrast > bestContrast) {
|
||||||
|
bestPalette = palette
|
||||||
|
bestContrast = contrast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return bestPalette ?? generatePalette(style, darkBackground, random, 25)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@ -1,8 +1,46 @@
|
|||||||
import { applyAvatarEyeDefaults, defaultAvatarEyes } from '@/features/avatar/avatars'
|
import { applyAvatarEyeDefaults, defaultAvatarEyes } from '@/features/avatar/avatars'
|
||||||
import { resolveCanvasPreviewExpression } from '@/features/rendering/canvasPreview'
|
import {
|
||||||
|
resetBodyEditorView,
|
||||||
|
resolveCanvasPreviewExpression,
|
||||||
|
shouldSyncCanvasPreviewToReact,
|
||||||
|
} from '@/features/rendering/canvasPreview'
|
||||||
import { defaultExpression } from '@/features/avatar/presets'
|
import { defaultExpression } from '@/features/avatar/presets'
|
||||||
|
|
||||||
describe('canvas preview expression', () => {
|
describe('canvas preview expression', () => {
|
||||||
|
it('resets the complete temporary pose when body editing opens', () => {
|
||||||
|
const expression = {
|
||||||
|
...defaultExpression,
|
||||||
|
id: 'expression-active',
|
||||||
|
semanticKey: 'active',
|
||||||
|
headX: 24,
|
||||||
|
headY: -31,
|
||||||
|
headZ: 17,
|
||||||
|
spacing: 62,
|
||||||
|
leftAngle: 12,
|
||||||
|
widthLeft: 44,
|
||||||
|
heightRight: 18,
|
||||||
|
positionXLeft: 9,
|
||||||
|
positionYRight: 14,
|
||||||
|
perspective: 3,
|
||||||
|
eyeMotion: 'shake' as const,
|
||||||
|
bodyMotion: 'slowDrift' as const,
|
||||||
|
bodyColor: '#123456',
|
||||||
|
eyeColor: '#abcdef',
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(resetBodyEditorView(expression)).toEqual({
|
||||||
|
...defaultExpression,
|
||||||
|
id: 'expression-active',
|
||||||
|
semanticKey: 'active',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('keeps body rotation previews in Motion values until the gesture commits', () => {
|
||||||
|
expect(shouldSyncCanvasPreviewToReact(true, 'head')).toBe(false)
|
||||||
|
expect(shouldSyncCanvasPreviewToReact(true, 'eyes')).toBe(true)
|
||||||
|
expect(shouldSyncCanvasPreviewToReact(false, 'head')).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps customized neutral eyes applied while rotating the body preview', () => {
|
it('keeps customized neutral eyes applied while rotating the body preview', () => {
|
||||||
const eyes = {
|
const eyes = {
|
||||||
...defaultAvatarEyes,
|
...defaultAvatarEyes,
|
||||||
|
|||||||
@ -1,8 +1,18 @@
|
|||||||
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars'
|
import { applyAvatarEyeDefaults, type AvatarEyeDefaults } from '../avatar/avatars'
|
||||||
import { type Expression } from '../avatar/geometry'
|
import { type Expression } from '../avatar/geometry'
|
||||||
|
import { defaultExpression } from '../avatar/presets'
|
||||||
|
|
||||||
export type CanvasPreviewTarget = 'head' | 'eyes'
|
export type CanvasPreviewTarget = 'head' | 'eyes'
|
||||||
|
|
||||||
|
export const resetBodyEditorView = (expression: Expression): Expression => ({
|
||||||
|
...defaultExpression,
|
||||||
|
id: expression.id,
|
||||||
|
...(expression.semanticKey ? { semanticKey: expression.semanticKey } : {}),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const shouldSyncCanvasPreviewToReact = (bodyEditing: boolean, target: CanvasPreviewTarget) =>
|
||||||
|
!bodyEditing || target !== 'head'
|
||||||
|
|
||||||
export const resolveCanvasPreviewExpression = (
|
export const resolveCanvasPreviewExpression = (
|
||||||
expression: Expression,
|
expression: Expression,
|
||||||
avatarEyes: AvatarEyeDefaults,
|
avatarEyes: AvatarEyeDefaults,
|
||||||
|
|||||||
@ -55,12 +55,14 @@ import {
|
|||||||
export function RotationGizmo({
|
export function RotationGizmo({
|
||||||
expression,
|
expression,
|
||||||
rendered,
|
rendered,
|
||||||
|
onPreview,
|
||||||
onChange,
|
onChange,
|
||||||
onActiveChange,
|
onActiveChange,
|
||||||
onReset,
|
onReset,
|
||||||
}: {
|
}: {
|
||||||
expression: Expression
|
expression: Expression
|
||||||
rendered: RenderedRotationGizmo
|
rendered: RenderedRotationGizmo
|
||||||
|
onPreview: (next: Expression) => void
|
||||||
onChange: (next: Expression) => void
|
onChange: (next: Expression) => void
|
||||||
onActiveChange: (active: boolean) => void
|
onActiveChange: (active: boolean) => void
|
||||||
onReset: () => void
|
onReset: () => void
|
||||||
@ -77,6 +79,7 @@ export function RotationGizmo({
|
|||||||
| { type: 'view'; startAngle: number; expression: Expression }
|
| { type: 'view'; startAngle: number; expression: Expression }
|
||||||
| null
|
| null
|
||||||
>(null)
|
>(null)
|
||||||
|
const latestExpression = useRef(expression)
|
||||||
const pose = poseFromExpression(expression)
|
const pose = poseFromExpression(expression)
|
||||||
const rings = {
|
const rings = {
|
||||||
x: rotationRing(pose, 'x'),
|
x: rotationRing(pose, 'x'),
|
||||||
@ -119,6 +122,7 @@ export function RotationGizmo({
|
|||||||
tangent: unitVector(previous, next),
|
tangent: unitVector(previous, next),
|
||||||
expression,
|
expression,
|
||||||
}
|
}
|
||||||
|
latestExpression.current = expression
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
const startView = (event: React.PointerEvent<SVGElement>) => {
|
const startView = (event: React.PointerEvent<SVGElement>) => {
|
||||||
@ -126,6 +130,7 @@ export function RotationGizmo({
|
|||||||
onActiveChange(true)
|
onActiveChange(true)
|
||||||
const point = toLocal(event)
|
const point = toLocal(event)
|
||||||
drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression }
|
drag.current = { type: 'view', startAngle: Math.atan2(point[1], point[0]), expression }
|
||||||
|
latestExpression.current = expression
|
||||||
event.currentTarget.setPointerCapture(event.pointerId)
|
event.currentTarget.setPointerCapture(event.pointerId)
|
||||||
}
|
}
|
||||||
const move = (event: React.PointerEvent<SVGElement>) => {
|
const move = (event: React.PointerEvent<SVGElement>) => {
|
||||||
@ -137,23 +142,34 @@ export function RotationGizmo({
|
|||||||
Math.sin(currentAngle - drag.current.startAngle),
|
Math.sin(currentAngle - drag.current.startAngle),
|
||||||
Math.cos(currentAngle - drag.current.startAngle)
|
Math.cos(currentAngle - drag.current.startAngle)
|
||||||
)
|
)
|
||||||
onChange(rotateExpressionAroundCamera(drag.current.expression, delta))
|
const next = rotateExpressionAroundCamera(drag.current.expression, delta)
|
||||||
|
latestExpression.current = next
|
||||||
|
onPreview(next)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const signedDistance =
|
const signedDistance =
|
||||||
(point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] +
|
(point[0] - drag.current.startPoint[0]) * drag.current.tangent[0] +
|
||||||
(point[1] - drag.current.startPoint[1]) * drag.current.tangent[1]
|
(point[1] - drag.current.startPoint[1]) * drag.current.tangent[1]
|
||||||
onChange(
|
const next = rotateExpressionAroundAxis(
|
||||||
rotateExpressionAroundAxis(drag.current.expression, drag.current.axis, signedDistance * 1.5)
|
drag.current.expression,
|
||||||
|
drag.current.axis,
|
||||||
|
signedDistance * 1.5
|
||||||
)
|
)
|
||||||
|
latestExpression.current = next
|
||||||
|
onPreview(next)
|
||||||
}
|
}
|
||||||
const stop = () => {
|
const stop = () => {
|
||||||
|
if (drag.current) onChange(latestExpression.current)
|
||||||
drag.current = null
|
drag.current = null
|
||||||
onActiveChange(false)
|
onActiveChange(false)
|
||||||
}
|
}
|
||||||
const cancel = () => {
|
const cancel = () => {
|
||||||
if (drag.current) onChange(drag.current.expression)
|
if (drag.current) {
|
||||||
stop()
|
onPreview(drag.current.expression)
|
||||||
|
onChange(drag.current.expression)
|
||||||
|
}
|
||||||
|
drag.current = null
|
||||||
|
onActiveChange(false)
|
||||||
}
|
}
|
||||||
useEscapeToCancel(cancel)
|
useEscapeToCancel(cancel)
|
||||||
return (
|
return (
|
||||||
@ -883,6 +899,7 @@ export function AvatarCanvas({
|
|||||||
<RotationGizmo
|
<RotationGizmo
|
||||||
expression={expression}
|
expression={expression}
|
||||||
rendered={rotationGizmo}
|
rendered={rotationGizmo}
|
||||||
|
onPreview={next => onPreview(next, 'head')}
|
||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
onActiveChange={active => onHighlightChange(active ? 'head' : null)}
|
onActiveChange={active => onHighlightChange(active ? 'head' : null)}
|
||||||
onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })}
|
onReset={() => onReset({ ...expression, headX: 0, headY: 0, headZ: 0 })}
|
||||||
|
|||||||
19
src/features/studio/__tests__/runtime-code-test.tsx
Normal file
19
src/features/studio/__tests__/runtime-code-test.tsx
Normal file
@ -0,0 +1,19 @@
|
|||||||
|
import { renderToStaticMarkup } from 'react-dom/server'
|
||||||
|
|
||||||
|
import { HighlightedRuntimeCode } from '@/features/studio/components/HighlightedRuntimeCode'
|
||||||
|
|
||||||
|
describe('runtime code highlighting', () => {
|
||||||
|
it('uses the guide token colors for quick-start TypeScript code', () => {
|
||||||
|
const markup = renderToStaticMarkup(
|
||||||
|
<code>
|
||||||
|
<HighlightedRuntimeCode>{`import { createAvatar } from '@bible-strong/avatar-react'
|
||||||
|
const Avatar = createAvatar(definition)
|
||||||
|
return <Avatar defaultExpression="neutral" />`}</HighlightedRuntimeCode>
|
||||||
|
</code>
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(markup).toContain('runtime-token-keyword')
|
||||||
|
expect(markup).toContain('runtime-token-string')
|
||||||
|
expect(markup).toContain('runtime-token-tag')
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
|
||||||
|
import { fireEvent, render, screen } from '@testing-library/react'
|
||||||
|
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||||
|
|
||||||
|
import definitionJson from '../../../../examples/react-vite-consumer/src/strobi.avatar.json'
|
||||||
|
import { RuntimePreviewDialog } from '@/features/studio/components/RuntimePreviewDialog'
|
||||||
|
import { StudioLanguageProvider } from '@/i18n'
|
||||||
|
|
||||||
|
const definitionResult = validateAvatarDefinition(definitionJson)
|
||||||
|
if (!definitionResult.ok) throw new Error('Invalid preview fixture')
|
||||||
|
const definition = definitionResult.value
|
||||||
|
|
||||||
|
const renderPreview = (avatarDefinition: AvatarDefinition, initialAnimation?: string) =>
|
||||||
|
render(
|
||||||
|
<StudioLanguageProvider>
|
||||||
|
<RuntimePreviewDialog
|
||||||
|
definition={avatarDefinition}
|
||||||
|
initialAnimation={initialAnimation}
|
||||||
|
open
|
||||||
|
onOpenChange={vi.fn()}
|
||||||
|
/>
|
||||||
|
</StudioLanguageProvider>
|
||||||
|
)
|
||||||
|
|
||||||
|
describe('runtime export preview', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal('requestAnimationFrame', () => 1)
|
||||||
|
vi.stubGlobal('cancelAnimationFrame', vi.fn())
|
||||||
|
vi.stubGlobal('matchMedia', () => ({ matches: false }))
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => vi.unstubAllGlobals())
|
||||||
|
|
||||||
|
it('exposes the exported animations, expressions and playback controls', () => {
|
||||||
|
renderPreview(definition, 'sleeping')
|
||||||
|
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exported definition preview' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exported animations' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('heading', { name: 'Exported expressions' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Play' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Pause' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Stop' })).toBeTruthy()
|
||||||
|
expect(screen.getByRole('button', { name: 'Restart' })).toBeTruthy()
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'idle' }))
|
||||||
|
expect(screen.getByRole('button', { name: 'idle' }).getAttribute('aria-pressed')).toBe('true')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('previews an expression-only definition and disables Play', () => {
|
||||||
|
const expressionOnly = {
|
||||||
|
...definition,
|
||||||
|
animations: {},
|
||||||
|
animationOrder: [],
|
||||||
|
} as AvatarDefinition
|
||||||
|
|
||||||
|
renderPreview(expressionOnly)
|
||||||
|
|
||||||
|
expect(screen.getByText('No exported animation')).toBeTruthy()
|
||||||
|
expect((screen.getByRole('button', { name: 'Play' }) as HTMLButtonElement).disabled).toBe(true)
|
||||||
|
expect(screen.getAllByText('neutral').length).toBeGreaterThan(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
@ -1,9 +1,12 @@
|
|||||||
import { createAvatar } from '@/features/avatar/avatars'
|
import { createAvatar } from '@/features/avatar/avatars'
|
||||||
import { createInitialSequences } from '@/features/animation/sequences'
|
import { createInitialSequences } from '@/features/animation/sequences'
|
||||||
import { initialExpressions } from '@/features/avatar/presets'
|
import { initialExpressions } from '@/features/avatar/presets'
|
||||||
|
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||||
import {
|
import {
|
||||||
|
clearPersistedStudioDocument,
|
||||||
createStudioDocumentStore,
|
createStudioDocumentStore,
|
||||||
loadStudioDocument,
|
loadStudioDocument,
|
||||||
|
parseStudioDocument,
|
||||||
parseImportedStudioDocument,
|
parseImportedStudioDocument,
|
||||||
serializeStudioDocument,
|
serializeStudioDocument,
|
||||||
type StudioDocument,
|
type StudioDocument,
|
||||||
@ -34,15 +37,71 @@ describe('Studio document', () => {
|
|||||||
expect(document.library.avatars[0].name).toBe('Strobi')
|
expect(document.library.avatars[0].name).toBe('Strobi')
|
||||||
expect(document.expressions).toHaveLength(27)
|
expect(document.expressions).toHaveLength(27)
|
||||||
expect(document.sequences).toHaveLength(23)
|
expect(document.sequences).toHaveLength(23)
|
||||||
|
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
|
||||||
|
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
|
||||||
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
|
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('clears only the persisted Studio project', () => {
|
||||||
|
const removeItem = vi.fn()
|
||||||
|
|
||||||
|
expect(clearPersistedStudioDocument({ removeItem })).toBe(true)
|
||||||
|
expect(removeItem).toHaveBeenCalledExactlyOnceWith('bible-strong-avatar-studio-v2')
|
||||||
|
})
|
||||||
|
|
||||||
it('keeps a locally saved project authoritative over the bundled snapshot', () => {
|
it('keeps a locally saved project authoritative over the bundled snapshot', () => {
|
||||||
const localDocument = documentFixture()
|
const localDocument = documentFixture()
|
||||||
|
|
||||||
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('restores bundled semantic keys in a legacy local project', () => {
|
||||||
|
const fallback = loadStudioDocument(storage())
|
||||||
|
const legacy = structuredClone(fallback)
|
||||||
|
legacy.expressions.forEach(expression => delete expression.semanticKey)
|
||||||
|
legacy.sequences.forEach(sequence => delete sequence.semanticKey)
|
||||||
|
|
||||||
|
const document = parseStudioDocument(legacy, fallback)
|
||||||
|
|
||||||
|
expect(document.expressions.map(expression => expression.semanticKey)).toEqual(
|
||||||
|
fallback.expressions.map(expression => expression.semanticKey)
|
||||||
|
)
|
||||||
|
expect(document.sequences.map(sequence => sequence.semanticKey)).toEqual(
|
||||||
|
fallback.sequences.map(sequence => sequence.semanticKey)
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
createAvatarDefinition({
|
||||||
|
avatar: document.library.avatars[0],
|
||||||
|
behavior: { expressions: document.expressions, sequences: document.sequences },
|
||||||
|
}).ok
|
||||||
|
).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('restores bundled keys in legacy avatar-owned behavior without naming custom items', () => {
|
||||||
|
const fallback = loadStudioDocument(storage())
|
||||||
|
const legacy = structuredClone(fallback)
|
||||||
|
const avatar = legacy.library.avatars[0]
|
||||||
|
avatar.behavior = {
|
||||||
|
expressions: legacy.expressions.map(expression => ({
|
||||||
|
...expression,
|
||||||
|
semanticKey: undefined,
|
||||||
|
})),
|
||||||
|
sequences: legacy.sequences.map(sequence => ({ ...sequence, semanticKey: undefined })),
|
||||||
|
}
|
||||||
|
avatar.behavior.expressions.push({
|
||||||
|
...avatar.behavior.expressions[0],
|
||||||
|
id: 'expression-custom',
|
||||||
|
semanticKey: undefined,
|
||||||
|
})
|
||||||
|
|
||||||
|
const document = parseStudioDocument(legacy, fallback)
|
||||||
|
const behavior = document.library.avatars[0].behavior!
|
||||||
|
|
||||||
|
expect(behavior.expressions[0].semanticKey).toBe(fallback.expressions[0].semanticKey)
|
||||||
|
expect(behavior.sequences[0].semanticKey).toBe(fallback.sequences[0].semanticKey)
|
||||||
|
expect(behavior.expressions.at(-1)?.semanticKey).toBeUndefined()
|
||||||
|
})
|
||||||
|
|
||||||
it('persists one coherent document after a mutation', () => {
|
it('persists one coherent document after a mutation', () => {
|
||||||
const persisted: StudioDocument[] = []
|
const persisted: StudioDocument[] = []
|
||||||
const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
|
const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
|
||||||
@ -71,9 +130,10 @@ describe('Studio document', () => {
|
|||||||
|
|
||||||
it('round-trips a complete project document as portable JSON', () => {
|
it('round-trips a complete project document as portable JSON', () => {
|
||||||
const document = documentFixture()
|
const document = documentFixture()
|
||||||
const expression = { ...initialExpressions[0], widthLeft: 42 }
|
const expression = { ...initialExpressions[0], semanticKey: 'happy-smile', widthLeft: 42 }
|
||||||
const sequence = {
|
const sequence = {
|
||||||
...createInitialSequences()[0],
|
...createInitialSequences()[0],
|
||||||
|
semanticKey: 'happy',
|
||||||
steps: createInitialSequences()[0].steps.map(step => ({
|
steps: createInitialSequences()[0].steps.map(step => ({
|
||||||
...step,
|
...step,
|
||||||
expressionId: expression.id,
|
expressionId: expression.id,
|
||||||
@ -88,6 +148,26 @@ describe('Studio document', () => {
|
|||||||
|
|
||||||
expect(imported).toEqual(document)
|
expect(imported).toEqual(document)
|
||||||
expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
|
expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
|
||||||
|
expect(imported.expressions[0].semanticKey).toBe(initialExpressions[0].semanticKey)
|
||||||
|
expect(imported.library.avatars[0].behavior?.expressions[0].semanticKey).toBe('happy-smile')
|
||||||
|
expect(imported.library.avatars[0].behavior?.sequences[0].semanticKey).toBe('happy')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('preserves semantic keys in the base behavior parser', () => {
|
||||||
|
const document = documentFixture()
|
||||||
|
document.expressions = document.expressions.map((expression, index) => ({
|
||||||
|
...expression,
|
||||||
|
...(index === 0 ? { semanticKey: 'attentive' } : {}),
|
||||||
|
}))
|
||||||
|
document.sequences = document.sequences.map((sequence, index) => ({
|
||||||
|
...sequence,
|
||||||
|
...(index === 0 ? { semanticKey: 'sleeping' } : {}),
|
||||||
|
}))
|
||||||
|
|
||||||
|
const imported = parseImportedStudioDocument(serializeStudioDocument(document), document)
|
||||||
|
|
||||||
|
expect(imported.expressions[0].semanticKey).toBe('attentive')
|
||||||
|
expect(imported.sequences[0].semanticKey).toBe('sleeping')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('keeps the base library unchanged when an avatar owns customized behavior', () => {
|
it('keeps the base library unchanged when an avatar owns customized behavior', () => {
|
||||||
|
|||||||
@ -1,7 +1,8 @@
|
|||||||
import { Copy, Pencil, Plus, Trash2 } from 'lucide-react'
|
import { Copy, Pencil, Plus, Trash2, Upload } from 'lucide-react'
|
||||||
import { motion } from 'motion/react'
|
import { motion } from 'motion/react'
|
||||||
|
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Menu, MenuContent, MenuItem, MenuTrigger } from '@/components/ui/menu'
|
||||||
import {
|
import {
|
||||||
ContextMenu,
|
ContextMenu,
|
||||||
ContextMenuContent,
|
ContextMenuContent,
|
||||||
@ -24,12 +25,15 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
avatarsRef,
|
avatarsRef,
|
||||||
cancelAvatarMove,
|
cancelAvatarMove,
|
||||||
commitAvatarMove,
|
commitAvatarMove,
|
||||||
|
avatarImportRef,
|
||||||
createNewAvatar,
|
createNewAvatar,
|
||||||
draggedAvatarId,
|
draggedAvatarId,
|
||||||
draggingAvatarId,
|
draggingAvatarId,
|
||||||
duplicateAvatar,
|
duplicateAvatar,
|
||||||
expressions,
|
expressions,
|
||||||
|
prepareStudioProjectImport,
|
||||||
previewAvatarMove,
|
previewAvatarMove,
|
||||||
|
projectImportError,
|
||||||
reduceMotion,
|
reduceMotion,
|
||||||
setDeleteAvatarOpen,
|
setDeleteAvatarOpen,
|
||||||
setDraggingAvatarId,
|
setDraggingAvatarId,
|
||||||
@ -133,15 +137,44 @@ export function AvatarPage({ controller }: { controller: StudioController }) {
|
|||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
</motion.div>
|
</motion.div>
|
||||||
))}
|
))}
|
||||||
<Button
|
<Menu>
|
||||||
variant="outline"
|
<MenuTrigger
|
||||||
className="avatar-add creation-card"
|
render={
|
||||||
onClick={createNewAvatar}
|
<Button
|
||||||
aria-label={t('Nouvel avatar')}
|
variant="outline"
|
||||||
>
|
className="avatar-add creation-card"
|
||||||
<Plus />
|
aria-label={t('Ajouter un avatar')}
|
||||||
</Button>
|
>
|
||||||
|
<Plus />
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<MenuContent>
|
||||||
|
<MenuItem onClick={createNewAvatar}>
|
||||||
|
<Plus /> {t('Nouvel avatar')}
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem onClick={() => avatarImportRef.current?.click()}>
|
||||||
|
<Upload /> {t('Importer un .avatar.json')}
|
||||||
|
</MenuItem>
|
||||||
|
</MenuContent>
|
||||||
|
</Menu>
|
||||||
|
<input
|
||||||
|
ref={avatarImportRef}
|
||||||
|
className="project-import-input"
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json,.avatar.json"
|
||||||
|
aria-label={t('Importer un .avatar.json')}
|
||||||
|
onChange={event => {
|
||||||
|
prepareStudioProjectImport(event.currentTarget.files?.[0])
|
||||||
|
event.currentTarget.value = ''
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{projectImportError && (
|
||||||
|
<p className="project-transfer-error" role="alert">
|
||||||
|
{projectImportError}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user