feat(runtime): ship semantic avatar packages and Studio export
- add the v1 AvatarDefinition schema, bounded parser, strict validation, semantic catalog, pure geometry, scene generation, and deterministic playback to @bible-strong/avatar-core - add the React 19 renderer with semantic controls, SSR-safe embedded/floating layouts, direct frame updates, pointer and keyboard movement, constraints, callbacks, and accessible controls - add real tarball packaging and a clean React/Vite consumer using the exported Strobi definition - add Studio semantic-key authoring, runtime readiness, JSON download/copy, bundled-key recovery, concise errors, targeted local-project clearing, npm guidance, syntax highlighting, and a runnable package preview - preserve the historical ZIP and Studio project exports, regenerate the standalone engine, and synchronize English, French, and Simplified Chinese copy - add focused contract, playback, renderer, interaction, persistence, export, and localization coverage - archive the completed runtime and semantic-curation specs, retain the Vite performance draft, and record the engineering session in TIMELOG.md Validation: - pnpm check: 19 test files, 162 tests, typecheck, engine freshness, package builds, and Studio production build passed - npm pack dry runs: 30 core files and 10 React files - package smoke: real tarballs installed, typechecked, and built outside the workspace - browser checks: semantic playback, embedded/floating render, drag, mobile overflow, runtime export recovery, formatted copy, syntax colors, and live package preview Publication remains disabled: both packages stay private and AGPL-3.0-only pending licensing and repository metadata approval. Vue and Angular adapters remain deferred.
This commit is contained in:
parent
85ebe0149f
commit
99300b3b30
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.
|
||||
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).
|
||||
@ -2,7 +2,98 @@
|
||||
|
||||
## Status
|
||||
|
||||
Implementation specification - product decisions 1 to 21 recorded on 2026-08-14.
|
||||
Complete and archived on 2026-08-14. React v1 phases A-E are implemented and verified. The packages
|
||||
remain private and must not be published until licensing and repository metadata are approved.
|
||||
Product decisions 1 to 21 were recorded on 2026-08-14.
|
||||
|
||||
### Implementation progress - 2026-08-14
|
||||
|
||||
| Phase | State | Evidence |
|
||||
| -------------------------------------------------------- | -------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| A - Contract and pure conversion | Complete | The v1 JSON Schema, bounded duplicate-detecting parser, Ajv validation, public types, Studio conversion and focused boundary tests are implemented. |
|
||||
| B - Semantic Studio authoring | Complete | The approved bundled catalog has semantic keys. The Studio validates keys, reports export readiness, downloads `.avatar.json`, copies formatted JSON accessibly, and keeps English, French and Simplified Chinese copy synchronized. |
|
||||
| C - Extract core package | Complete | `@bible-strong/avatar-core` contains the framework-independent contract, geometry, scene generation, semantic lookup and deterministic playback. ESM, declarations and documented entry points build successfully. |
|
||||
| D - React renderer package | Complete | `@bible-strong/avatar-react` implements React 19 refs, embedded/floating SVG, SSR portal handoff, controlled/uncontrolled playback, direct per-frame rendering, pointer/keyboard movement, bounds, resize behavior and accessible movement controls. |
|
||||
| E - Existing export integration and package verification | Complete | Both packages build and pack. The smoke script installs their actual tarballs in a clean non-workspace React/Vite consumer, typechecks and builds it. Browser checks covered semantic controls, embedded/floating rendering, drag, reduced motion, mobile overflow, console errors and network responses. |
|
||||
| F - Additional framework adapters | Deferred | Vue and Angular remain explicitly outside the React v1 scope and require a separate go-ahead. |
|
||||
|
||||
### Archive state
|
||||
|
||||
Implementation was completed on branch `avatar-runtime`. Do not publish either package and do not
|
||||
start Vue or Angular adapters without a separate product decision.
|
||||
|
||||
#### Runtime export bug reported on 2026-08-14
|
||||
|
||||
The Studio displayed one missing-key error for every bundled Expression and Animation, followed by
|
||||
many secondary unresolved-reference errors. This made the active Strobi avatar appear impossible to
|
||||
export.
|
||||
|
||||
The fix is implemented:
|
||||
|
||||
- when the known bundled catalog is loaded without semantic keys, its approved keys are restored by
|
||||
durable internal ID;
|
||||
- custom Expressions and Animations are never assigned invented keys;
|
||||
- unresolved-reference consequences are hidden when an Expression key error already explains the
|
||||
problem, and identical messages are deduplicated;
|
||||
- the incomplete-export card offers a destructive `Clear local project and reload` action that
|
||||
removes only the Studio document storage key before loading the bundled catalog again;
|
||||
- the same repair applies to base behavior and Avatar-owned behavior;
|
||||
- focused persistence tests cover the repaired bundled catalog and prove that a custom item remains
|
||||
unkeyed.
|
||||
|
||||
The Export accordion flags `Export runtime JSON` as new and distinguishes it from the pre-existing
|
||||
`Export avatar` ZIP generator. The former exports runtime data for the npm packages; the latter
|
||||
continues to generate the historical standalone React or JavaScript ZIP.
|
||||
The runtime section also includes a compact `<pre>` quick start with the npm install command and a
|
||||
minimal validated React integration. It explicitly notes that the commands become usable after the
|
||||
currently private packages are published. The code uses accessible high-contrast syntax coloring,
|
||||
and a `Run example` control renders the generated definition through the actual public React package
|
||||
with a restartable `idle` animation.
|
||||
|
||||
A production-build browser regression loaded Strobi with all bundled semantic keys removed, opened
|
||||
Export and observed `Ready for runtime export` and `6/6 standard animations available`. The captured
|
||||
download was schema v1 with 28 Expressions (including synthesized `neutral`), 23 Animations and zero
|
||||
public `expression-*` references. The formatted-copy action produced valid JSON and its accessible
|
||||
success status.
|
||||
|
||||
#### Implemented architecture
|
||||
|
||||
- `pnpm-workspace.yaml` declares `packages/*` and `examples/*`.
|
||||
- `packages/avatar-core` owns the public schema/types/validator, bounded JSON parser, semantic
|
||||
manifests, geometry, surfaces, body model, ambient motion, pure playback state, definition-to-scene
|
||||
adapter, ESM build, declarations, README and package metadata.
|
||||
- `src/features/avatar/{geometry,surfaces,body,ambientMotion}.ts` are compatibility re-exports from
|
||||
the shared core. `src/features/avatar/avatarDefinition.ts` keeps only the Studio-to-public adapter
|
||||
and re-exports the public contract.
|
||||
- `packages/avatar-react` owns `<Avatar />`, its typed controller, CSS hooks, embedded/floating
|
||||
layouts, body portal, pointer capture, keyboard movement, position constraints/callbacks, SVG
|
||||
rendering and direct per-frame path/transform updates.
|
||||
- `examples/react-vite-consumer` is the independent React fixture. `scripts/smoke-packages.mjs`
|
||||
builds and packs both packages, copies the fixture outside the workspace, forces the core
|
||||
dependency to the local tarball, installs, typechecks and builds.
|
||||
- Runtime packages remain `private: true` and AGPL-3.0-only. Apache-2.0 relicensing and any npm
|
||||
publication remain blocked until every copyright holder and repository metadata are confirmed.
|
||||
- The existing Studio ZIP export still consumes compatibility re-exports and strips new
|
||||
`semanticKey` fields from its legacy payload to avoid an unintended output change.
|
||||
|
||||
#### Final verification recorded on 2026-08-14
|
||||
|
||||
- Focused Studio/avatar regression suite: 3 files and 67 tests passed.
|
||||
- `pnpm check`: passed, including generated-engine freshness, formatting, TypeScript, 19 test files
|
||||
and 162 tests, both package builds, and the production Studio build.
|
||||
- `npm pack --dry-run --json`: passed; core contains 30 allow-listed files and React contains 10.
|
||||
- `pnpm packages:smoke`: passed against newly built tarballs in a clean temporary consumer.
|
||||
- Browser verification of the tarball consumer observed two SVGs/eight paths, working
|
||||
`play('idle')` and `setExpression('neutral')`, floating drag, no mobile horizontal overflow, and no
|
||||
application console or page errors.
|
||||
- `git diff --check`: passed during final handoff.
|
||||
|
||||
#### Post-implementation constraints
|
||||
|
||||
- Decide licensing/repository metadata before removing `private: true` or publishing either package.
|
||||
- Phase F requires a separate product decision; it is not a blocker for React v1.
|
||||
- Local Vite preview still receives 404 responses for the pre-existing Vercel Analytics and Speed
|
||||
Insights scripts; the runtime example itself renders without JavaScript errors.
|
||||
|
||||
## 1. Context and objective
|
||||
|
||||
@ -349,7 +440,13 @@ Implementation steps:
|
||||
4. Update `defaultStudioDocument.json` with the curated keys.
|
||||
5. Update default constructors and tests so newly created custom content starts without a key and is visibly marked as not export-ready.
|
||||
|
||||
Because the project is pre-release, extend the current v2 document parser, serializer, default document, and behavior-copy operations coherently instead of adding a legacy migration. Existing local documents without semantic keys remain editable, but cannot be exported as runtime definitions until keys are supplied. The parser must round-trip `semanticKey` for both base behavior and avatar-owned behavior; it must not drop it as an unknown field after reload.
|
||||
Because the project is pre-release, extend the current v2 document parser, serializer, default
|
||||
document, and behavior-copy operations coherently instead of introducing another schema version.
|
||||
When approved bundled items are loaded without their keys, restore the known keys by durable internal
|
||||
ID so the bundled avatar remains exportable. Never invent a key for custom content: custom items
|
||||
remain editable but cannot be exported until the user supplies a valid key. The parser must
|
||||
round-trip `semanticKey` for both base behavior and Avatar-owned behavior; it must not drop it as an
|
||||
unknown field after reload.
|
||||
|
||||
### 6.3 Expression resolution during export
|
||||
|
||||
@ -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"
|
||||
}
|
||||
}
|
||||
60
examples/react-vite-consumer/src/main.tsx
Normal file
60
examples/react-vite-consumer/src/main.tsx
Normal file
@ -0,0 +1,60 @@
|
||||
import { validateAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
|
||||
import '@bible-strong/avatar-react/styles.css'
|
||||
import { StrictMode, useRef, useState } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
|
||||
import definitionJson from './strobi.avatar.json'
|
||||
import './styles.css'
|
||||
|
||||
const validation = validateAvatarDefinition(definitionJson)
|
||||
if (!validation.ok) throw new Error(validation.errors[0]?.message)
|
||||
const definition = validation.value as AvatarDefinition
|
||||
|
||||
function Demo() {
|
||||
const controller = useRef<AvatarController>(null)
|
||||
const [status, setStatus] = useState('neutral')
|
||||
const play = () => {
|
||||
const result = controller.current?.play('idle')
|
||||
setStatus(result?.ok ? 'idle' : (result?.error.code ?? 'unavailable'))
|
||||
}
|
||||
const setNeutral = () => {
|
||||
const result = controller.current?.setExpression('neutral')
|
||||
setStatus(result?.ok ? 'neutral' : (result?.error.code ?? 'unavailable'))
|
||||
}
|
||||
return (
|
||||
<main>
|
||||
<h1>Avatar package consumer</h1>
|
||||
<section className="avatar-host" aria-label="Embedded avatar example">
|
||||
<Avatar
|
||||
ref={controller}
|
||||
definition={definition}
|
||||
size={240}
|
||||
onExpressionChange={setStatus}
|
||||
ariaLabel="Embedded Strobi avatar"
|
||||
/>
|
||||
</section>
|
||||
<button type="button" onClick={play}>
|
||||
Play idle
|
||||
</button>
|
||||
<button type="button" onClick={setNeutral}>
|
||||
Set neutral
|
||||
</button>
|
||||
<output aria-live="polite">{status}</output>
|
||||
<Avatar
|
||||
definition={definition}
|
||||
mode="floating"
|
||||
draggable
|
||||
size={128}
|
||||
initialPosition={{ right: 24, bottom: 24 }}
|
||||
ariaLabel="Floating draggable Strobi avatar"
|
||||
/>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<Demo />
|
||||
</StrictMode>
|
||||
)
|
||||
1733
examples/react-vite-consumer/src/strobi.avatar.json
Normal file
1733
examples/react-vite-consumer/src/strobi.avatar.json
Normal file
File diff suppressed because it is too large
Load Diff
44
examples/react-vite-consumer/src/styles.css
Normal file
44
examples/react-vite-consumer/src/styles.css
Normal file
@ -0,0 +1,44 @@
|
||||
:root {
|
||||
font-family: system-ui, sans-serif;
|
||||
color: #111827;
|
||||
background: #f8fafc;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
main {
|
||||
display: grid;
|
||||
justify-items: center;
|
||||
gap: 24px;
|
||||
min-height: 100dvh;
|
||||
padding: 48px 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.avatar-host {
|
||||
width: min(100%, 360px);
|
||||
min-height: 280px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid #cbd5e1;
|
||||
border-radius: 24px;
|
||||
background: white;
|
||||
}
|
||||
|
||||
button {
|
||||
min-height: 44px;
|
||||
padding: 0 20px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
color: white;
|
||||
background: #4338ca;
|
||||
font: inherit;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button:focus-visible {
|
||||
outline: 3px solid #4338ca;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
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()] })
|
||||
@ -29,12 +29,17 @@
|
||||
"test:watch": "vitest",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check .",
|
||||
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm build"
|
||||
"packages:build": "pnpm --filter './packages/*' build",
|
||||
"packages:smoke": "node scripts/smoke-packages.mjs",
|
||||
"check": "pnpm engine:check && pnpm format:check && pnpm typecheck && pnpm test && pnpm packages:build && pnpm build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@base-ui/react": "^1.7.0",
|
||||
"@bible-strong/avatar-core": "workspace:*",
|
||||
"@bible-strong/avatar-react": "workspace:*",
|
||||
"@vercel/analytics": "^2.0.1",
|
||||
"@vercel/speed-insights": "^2.0.0",
|
||||
"ajv": "^8.20.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.31.0",
|
||||
@ -49,11 +54,13 @@
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@rolldown/plugin-babel": "^0.2.3",
|
||||
"@tailwindcss/vite": "^4.3.3",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.2",
|
||||
"babel-plugin-react-compiler": "^1.0.0",
|
||||
"jsdom": "^30.0.1",
|
||||
"prettier": "^3.8.1",
|
||||
"rolldown": "^1.0.0-rc.5",
|
||||
"tailwindcss": "^4.3.3",
|
||||
|
||||
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/>.
|
||||
74
packages/avatar-core/README.md
Normal file
74
packages/avatar-core/README.md
Normal file
@ -0,0 +1,74 @@
|
||||
# @bible-strong/avatar-core
|
||||
|
||||
Framework-independent validation, semantic playback and renderer-neutral SVG scene generation for
|
||||
Bible Strong procedural avatars. The package has no React, DOM, Motion or browser-storage
|
||||
dependency.
|
||||
|
||||
## Install and validate
|
||||
|
||||
```sh
|
||||
pnpm add @bible-strong/avatar-core
|
||||
```
|
||||
|
||||
Use `parseAvatarDefinition` for untrusted JSON text. It enforces the 256 KiB/depth limits and
|
||||
detects duplicate object keys before validating against the v1 schema. Use
|
||||
`validateAvatarDefinition` when the value is already materialized.
|
||||
|
||||
```ts
|
||||
import { parseAvatarDefinition } from '@bible-strong/avatar-core'
|
||||
|
||||
const parsed = parseAvatarDefinition(jsonText)
|
||||
if (!parsed.ok) {
|
||||
throw new Error(`${parsed.errors[0].path}: ${parsed.errors[0].message}`)
|
||||
}
|
||||
const definition = parsed.value
|
||||
```
|
||||
|
||||
Both functions return a non-mutating discriminated result. Successful values are deeply frozen;
|
||||
errors include an RFC 6901 JSON Pointer, code and message. The committed JSON Schema is exported as
|
||||
`@bible-strong/avatar-core/schema`.
|
||||
|
||||
## Semantic lookup and playback
|
||||
|
||||
Public calls use semantic keys only. `resolveExpression` and `resolveAnimation` return typed errors
|
||||
for unknown keys; standard animations additionally report
|
||||
`unavailable_standard_animation` when their required expressions are absent.
|
||||
|
||||
```ts
|
||||
import {
|
||||
advanceAvatarPlayback,
|
||||
getStandardAnimationAvailabilityV1,
|
||||
playAvatarAnimation,
|
||||
renderAvatarFrame,
|
||||
} from '@bible-strong/avatar-core'
|
||||
|
||||
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
|
||||
const started = playAvatarAnimation(definition, 'idle', 0)
|
||||
if (!started.ok) throw new Error(started.error.message)
|
||||
|
||||
const state = advanceAvatarPlayback(definition, started.value, 500, {
|
||||
random: () => 0.5,
|
||||
})
|
||||
const scene = renderAvatarFrame(definition, state, 500, {
|
||||
random: () => 0.5,
|
||||
reduceMotion: false,
|
||||
})
|
||||
```
|
||||
|
||||
`advanceAvatarPlayback` is a pure state transition driven by a monotonic timestamp and injected
|
||||
random source. The timeline for each step is transition then hold. `pauseAvatarPlayback` and
|
||||
`resumeAvatarPlayback` preserve exact progress. With `reduceMotion: true`, transitions and ambient
|
||||
motion jump deterministically to their target while configured blinks remain active.
|
||||
|
||||
`renderAvatarDefinition` renders a static semantic expression. `renderAvatarFrame` renders an
|
||||
animated frame. Both return paths, visibility and resolved colors without creating DOM nodes.
|
||||
|
||||
## Entry points
|
||||
|
||||
- `@bible-strong/avatar-core`: contract, validation, semantic catalog, playback and scene APIs.
|
||||
- `@bible-strong/avatar-core/schema`: the v1 Draft 2020-12 JSON Schema.
|
||||
- `@bible-strong/avatar-core/geometry`, `/body`, `/surfaces`, `/ambient-motion`: advanced pure
|
||||
primitives for renderer authors.
|
||||
|
||||
This package is private while copyright ownership, Apache-2.0 relicensing and repository metadata
|
||||
are confirmed. Local tarballs are for verification only and must not be published.
|
||||
51
packages/avatar-core/package.json
Normal file
51
packages/avatar-core/package.json
Normal file
@ -0,0 +1,51 @@
|
||||
{
|
||||
"name": "@bible-strong/avatar-core",
|
||||
"version": "0.1.0",
|
||||
"description": "Framework-independent runtime for Bible Strong procedural avatars.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": false,
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./geometry": {
|
||||
"types": "./dist/geometry.d.ts",
|
||||
"import": "./dist/geometry.js"
|
||||
},
|
||||
"./body": {
|
||||
"types": "./dist/body.d.ts",
|
||||
"import": "./dist/body.js"
|
||||
},
|
||||
"./surfaces": {
|
||||
"types": "./dist/surfaces.d.ts",
|
||||
"import": "./dist/surfaces.js"
|
||||
},
|
||||
"./ambient-motion": {
|
||||
"types": "./dist/ambientMotion.d.ts",
|
||||
"import": "./dist/ambientMotion.js"
|
||||
},
|
||||
"./schema": "./dist/avatarDefinition.schema.json"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"ajv": "^8.20.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
247
packages/avatar-core/src/__tests__/core-test.ts
Normal file
247
packages/avatar-core/src/__tests__/core-test.ts
Normal file
@ -0,0 +1,247 @@
|
||||
import {
|
||||
advanceAvatarPlayback,
|
||||
bodyFromDefinition,
|
||||
createAvatarPlaybackState,
|
||||
expressionFromDefinition,
|
||||
parseAvatarDefinition,
|
||||
playAvatarAnimation,
|
||||
pauseAvatarPlayback,
|
||||
poseFromExpression,
|
||||
renderAvatar,
|
||||
renderAvatarDefinition,
|
||||
renderAvatarFrame,
|
||||
resumeAvatarPlayback,
|
||||
resolveAnimation,
|
||||
type AvatarDefinition,
|
||||
} from '../index'
|
||||
|
||||
const expression = {
|
||||
head: { x: 0, y: 0, z: 0 },
|
||||
eyes: {
|
||||
left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
|
||||
right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
|
||||
spacing: 54,
|
||||
},
|
||||
perspective: 1,
|
||||
motion: { eyes: 'none', body: 'none' },
|
||||
} as const
|
||||
|
||||
const definition: AvatarDefinition = {
|
||||
schema: 'bible-strong/avatar-definition',
|
||||
schemaVersion: 1,
|
||||
name: 'Core fixture',
|
||||
body: {
|
||||
primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
||||
nodes: [],
|
||||
},
|
||||
colors: { body: '#5b7fe5', eyes: '#111316' },
|
||||
expressions: {
|
||||
neutral: expression,
|
||||
'upward-side-glance': { ...expression, head: { x: -8, y: 18, z: -4 } },
|
||||
'curious-left': { ...expression, head: { x: 0, y: -12, z: 3 } },
|
||||
},
|
||||
expressionOrder: ['neutral', 'upward-side-glance', 'curious-left'],
|
||||
animations: {},
|
||||
animationOrder: [],
|
||||
standardAnimationSet: 1,
|
||||
}
|
||||
|
||||
describe('@bible-strong/avatar-core', () => {
|
||||
it('loads a JSON definition and resolves a standard semantic animation', () => {
|
||||
const parsed = parseAvatarDefinition(JSON.stringify(definition))
|
||||
expect(parsed.ok).toBe(true)
|
||||
if (!parsed.ok) return
|
||||
|
||||
const idle = resolveAnimation(parsed.value, 'idle')
|
||||
expect(idle.ok).toBe(true)
|
||||
if (!idle.ok) return
|
||||
expect(idle.value.steps.map(step => step.expression)).toEqual([
|
||||
'upward-side-glance',
|
||||
'curious-left',
|
||||
])
|
||||
})
|
||||
|
||||
it('advances playback deterministically from transition to hold and the next step', () => {
|
||||
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||
expect(started.ok).toBe(true)
|
||||
if (!started.ok) return
|
||||
|
||||
const holding = advanceAvatarPlayback(definition, started.value, 1_500, {
|
||||
random: () => 0.5,
|
||||
})
|
||||
expect(holding).toMatchObject({
|
||||
activeAnimation: 'idle',
|
||||
activeExpression: 'upward-side-glance',
|
||||
phase: 'hold',
|
||||
status: 'playing',
|
||||
})
|
||||
|
||||
const next = advanceAvatarPlayback(definition, holding, 6_701, { random: () => 0.5 })
|
||||
expect(next).toMatchObject({
|
||||
activeExpression: 'curious-left',
|
||||
phase: 'transition',
|
||||
stepIndex: 1,
|
||||
})
|
||||
})
|
||||
|
||||
it('generates the same geometry through the public definition adapter', () => {
|
||||
const scene = renderAvatarDefinition(definition, 'curious-left')
|
||||
const body = bodyFromDefinition(definition.body)
|
||||
const internalExpression = expressionFromDefinition(
|
||||
'curious-left',
|
||||
definition.expressions['curious-left']
|
||||
)
|
||||
const direct = renderAvatar(poseFromExpression(internalExpression), body.primary, 1, {
|
||||
bodyNodes: body.nodes,
|
||||
})
|
||||
|
||||
expect(scene.geometry).toEqual(direct)
|
||||
expect(scene.colors).toEqual(definition.colors)
|
||||
})
|
||||
|
||||
it('does not alias cached geometry for surfaces that differ beyond four decimals', () => {
|
||||
const narrow: AvatarDefinition = {
|
||||
...definition,
|
||||
body: {
|
||||
primary: {
|
||||
type: 'cube',
|
||||
width: 199.00018,
|
||||
height: 200,
|
||||
depth: 200,
|
||||
roundness: 0.5,
|
||||
},
|
||||
nodes: [],
|
||||
},
|
||||
}
|
||||
const wide: AvatarDefinition = {
|
||||
...narrow,
|
||||
body: {
|
||||
...narrow.body,
|
||||
primary: { ...narrow.body.primary, width: 199.00022 },
|
||||
},
|
||||
}
|
||||
|
||||
const narrowScene = renderAvatarDefinition(narrow)
|
||||
const wideScene = renderAvatarDefinition(wide)
|
||||
|
||||
expect(wideScene.geometry.headPath).not.toBe(narrowScene.geometry.headPath)
|
||||
expect(wideScene.geometry.wirePaths).not.toEqual(narrowScene.geometry.wirePaths)
|
||||
})
|
||||
|
||||
it('starts from the documented neutral stopped state', () => {
|
||||
expect(createAvatarPlaybackState()).toMatchObject({
|
||||
activeExpression: 'neutral',
|
||||
status: 'stopped',
|
||||
})
|
||||
})
|
||||
|
||||
it('interpolates a bounded transition and freezes its exact progress while paused', () => {
|
||||
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||
if (!started.ok) throw new Error(started.error.message)
|
||||
const neutral = renderAvatarDefinition(definition, 'neutral')
|
||||
const target = renderAvatarDefinition(definition, 'upward-side-glance')
|
||||
const halfway = renderAvatarFrame(definition, started.value, 1_250, {
|
||||
random: () => 0.5,
|
||||
})
|
||||
expect(halfway.geometry.leftPath).not.toBe(neutral.geometry.leftPath)
|
||||
expect(halfway.geometry.leftPath).not.toBe(target.geometry.leftPath)
|
||||
|
||||
const paused = pauseAvatarPlayback(started.value, 1_250)
|
||||
const resumed = resumeAvatarPlayback(paused, 4_250)
|
||||
expect(resumed.phaseStartedAt).toBe(4_000)
|
||||
const resumedFrame = renderAvatarFrame(definition, resumed, 4_250, {
|
||||
random: () => 0.5,
|
||||
})
|
||||
expect(resumedFrame.geometry.leftPath).toBe(halfway.geometry.leftPath)
|
||||
})
|
||||
|
||||
it('uses the injectable random source for a deterministic blink timeline', () => {
|
||||
const started = playAvatarAnimation(definition, 'idle', 1_000)
|
||||
if (!started.ok) throw new Error(started.error.message)
|
||||
const blinking = advanceAvatarPlayback(definition, started.value, 3_600, {
|
||||
random: () => 0,
|
||||
})
|
||||
expect(blinking.blinkStartedAt).toBe(3_600)
|
||||
expect(blinking.blinkDueAt).toBe(7_280)
|
||||
const open = renderAvatarFrame(definition, blinking, 3_600, { random: () => 0 })
|
||||
const closed = renderAvatarFrame(definition, blinking, 3_740, { random: () => 0 })
|
||||
expect(closed.geometry.leftPath).not.toBe(open.geometry.leftPath)
|
||||
})
|
||||
|
||||
it('returns typed errors for unknown and unavailable semantic animations', () => {
|
||||
expect(resolveAnimation(definition, 'missing')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'unknown_animation', key: 'missing' },
|
||||
})
|
||||
expect(resolveAnimation(definition, 'happy')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'unavailable_standard_animation', key: 'happy' },
|
||||
})
|
||||
})
|
||||
|
||||
it('lets an explicit animation override the standard animation with the same key', () => {
|
||||
const overridden: AvatarDefinition = {
|
||||
...definition,
|
||||
animations: {
|
||||
idle: {
|
||||
playbackMode: 'once',
|
||||
steps: [{ expression: 'neutral', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
|
||||
blink: {
|
||||
enabled: false,
|
||||
initialDelayMs: 0,
|
||||
minIntervalMs: 1_000,
|
||||
maxIntervalMs: 1_000,
|
||||
durationMs: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
animationOrder: ['idle'],
|
||||
}
|
||||
expect(resolveAnimation(overridden, 'idle')).toMatchObject({
|
||||
ok: true,
|
||||
value: { playbackMode: 'once', steps: [{ expression: 'neutral' }] },
|
||||
})
|
||||
})
|
||||
|
||||
it('completes once playback and deterministically removes transition motion', () => {
|
||||
const onceDefinition: AvatarDefinition = {
|
||||
...definition,
|
||||
animations: {
|
||||
once: {
|
||||
playbackMode: 'once',
|
||||
steps: [
|
||||
{
|
||||
expression: 'curious-left',
|
||||
holdMs: 100,
|
||||
transitionMs: 100,
|
||||
transition: 'smooth',
|
||||
},
|
||||
],
|
||||
blink: {
|
||||
enabled: false,
|
||||
initialDelayMs: 0,
|
||||
minIntervalMs: 1_000,
|
||||
maxIntervalMs: 1_000,
|
||||
durationMs: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
animationOrder: ['once'],
|
||||
}
|
||||
const started = playAvatarAnimation(onceDefinition, 'once', 0)
|
||||
if (!started.ok) throw new Error(started.error.message)
|
||||
const reduced = renderAvatarFrame(onceDefinition, started.value, 50, {
|
||||
random: () => 0.5,
|
||||
reduceMotion: true,
|
||||
})
|
||||
expect(reduced.geometry.leftPath).toBe(
|
||||
renderAvatarDefinition(onceDefinition, 'curious-left').geometry.leftPath
|
||||
)
|
||||
expect(
|
||||
advanceAvatarPlayback(onceDefinition, started.value, 200, { random: () => 0.5 })
|
||||
).toMatchObject({
|
||||
activeExpression: 'curious-left',
|
||||
status: 'stopped',
|
||||
})
|
||||
})
|
||||
})
|
||||
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
|
||||
}
|
||||
249
packages/avatar-core/src/avatarDefinition.schema.json
Normal file
249
packages/avatar-core/src/avatarDefinition.schema.json
Normal file
@ -0,0 +1,249 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "https://avatars.bible-strong.app/schemas/avatar-definition-v1.json",
|
||||
"title": "Bible Strong Avatar Definition v1",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"schema",
|
||||
"schemaVersion",
|
||||
"body",
|
||||
"colors",
|
||||
"expressions",
|
||||
"expressionOrder",
|
||||
"animations",
|
||||
"animationOrder",
|
||||
"standardAnimationSet"
|
||||
],
|
||||
"properties": {
|
||||
"schema": { "const": "bible-strong/avatar-definition" },
|
||||
"schemaVersion": { "const": 1 },
|
||||
"name": { "type": "string", "maxLength": 120 },
|
||||
"body": { "$ref": "#/$defs/body" },
|
||||
"colors": { "$ref": "#/$defs/colors" },
|
||||
"expressions": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"maxProperties": 128,
|
||||
"required": ["neutral"],
|
||||
"propertyNames": { "$ref": "#/$defs/semanticKey" },
|
||||
"properties": { "neutral": { "$ref": "#/$defs/expression" } },
|
||||
"additionalProperties": { "$ref": "#/$defs/expression" }
|
||||
},
|
||||
"expressionOrder": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/semanticKey" }
|
||||
},
|
||||
"animations": {
|
||||
"type": "object",
|
||||
"maxProperties": 64,
|
||||
"propertyNames": { "$ref": "#/$defs/semanticKey" },
|
||||
"additionalProperties": { "$ref": "#/$defs/animation" }
|
||||
},
|
||||
"animationOrder": {
|
||||
"type": "array",
|
||||
"maxItems": 64,
|
||||
"uniqueItems": true,
|
||||
"items": { "$ref": "#/$defs/semanticKey" }
|
||||
},
|
||||
"standardAnimationSet": { "const": 1 }
|
||||
},
|
||||
"$defs": {
|
||||
"semanticKey": {
|
||||
"type": "string",
|
||||
"maxLength": 64,
|
||||
"pattern": "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$"
|
||||
},
|
||||
"hexColor": { "type": "string", "pattern": "^#[0-9a-f]{6}$" },
|
||||
"boundedNumber": { "type": "number", "minimum": -10000, "maximum": 10000 },
|
||||
"dimension": { "type": "number", "minimum": 0.001, "maximum": 10000 },
|
||||
"roundness": { "type": "number", "minimum": 0, "maximum": 1 },
|
||||
"surface": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["type", "width", "height", "depth", "roundness"],
|
||||
"properties": {
|
||||
"type": {
|
||||
"enum": ["sphere", "mickey", "cursor", "cube", "capsule", "cylinder", "cone", "diamond"]
|
||||
},
|
||||
"width": { "$ref": "#/$defs/dimension" },
|
||||
"height": { "$ref": "#/$defs/dimension" },
|
||||
"depth": { "$ref": "#/$defs/dimension" },
|
||||
"roundness": { "$ref": "#/$defs/roundness" },
|
||||
"morphRoundness": { "$ref": "#/$defs/roundness" },
|
||||
"tipRoundness": { "$ref": "#/$defs/roundness" },
|
||||
"baseRoundness": { "$ref": "#/$defs/roundness" }
|
||||
}
|
||||
},
|
||||
"nodeSurface": {
|
||||
"allOf": [
|
||||
{ "$ref": "#/$defs/surface" },
|
||||
{
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"type": { "enum": ["sphere", "cube", "capsule", "cylinder", "cone", "diamond"] }
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
"position": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"prefixItems": [
|
||||
{ "$ref": "#/$defs/boundedNumber" },
|
||||
{ "$ref": "#/$defs/boundedNumber" },
|
||||
{ "$ref": "#/$defs/boundedNumber" }
|
||||
]
|
||||
},
|
||||
"rotation": {
|
||||
"type": "array",
|
||||
"minItems": 3,
|
||||
"maxItems": 3,
|
||||
"prefixItems": [
|
||||
{ "type": "number", "minimum": -360, "maximum": 360 },
|
||||
{ "type": "number", "minimum": -360, "maximum": 360 },
|
||||
{ "type": "number", "minimum": -360, "maximum": 360 }
|
||||
]
|
||||
},
|
||||
"body": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["primary", "nodes"],
|
||||
"properties": {
|
||||
"primary": { "$ref": "#/$defs/surface" },
|
||||
"nodes": {
|
||||
"type": "array",
|
||||
"maxItems": 16,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["surface", "position", "rotation"],
|
||||
"properties": {
|
||||
"surface": { "$ref": "#/$defs/nodeSurface" },
|
||||
"position": { "$ref": "#/$defs/position" },
|
||||
"rotation": { "$ref": "#/$defs/rotation" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["body", "eyes"],
|
||||
"properties": {
|
||||
"body": { "$ref": "#/$defs/hexColor" },
|
||||
"eyes": { "$ref": "#/$defs/hexColor" }
|
||||
}
|
||||
},
|
||||
"eye": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["width", "height", "x", "y", "angle"],
|
||||
"properties": {
|
||||
"width": { "$ref": "#/$defs/boundedNumber" },
|
||||
"height": { "$ref": "#/$defs/boundedNumber" },
|
||||
"x": { "$ref": "#/$defs/boundedNumber" },
|
||||
"y": { "$ref": "#/$defs/boundedNumber" },
|
||||
"angle": { "$ref": "#/$defs/boundedNumber" }
|
||||
}
|
||||
},
|
||||
"expression": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["head", "eyes", "perspective", "motion"],
|
||||
"properties": {
|
||||
"head": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["x", "y", "z"],
|
||||
"properties": {
|
||||
"x": { "$ref": "#/$defs/boundedNumber" },
|
||||
"y": { "$ref": "#/$defs/boundedNumber" },
|
||||
"z": { "$ref": "#/$defs/boundedNumber" }
|
||||
}
|
||||
},
|
||||
"eyes": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["left", "right", "spacing"],
|
||||
"properties": {
|
||||
"left": { "$ref": "#/$defs/eye" },
|
||||
"right": { "$ref": "#/$defs/eye" },
|
||||
"spacing": { "$ref": "#/$defs/boundedNumber" }
|
||||
}
|
||||
},
|
||||
"perspective": { "type": "number", "minimum": 0.1, "maximum": 10 },
|
||||
"motion": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["eyes", "body"],
|
||||
"properties": {
|
||||
"eyes": { "enum": ["none", "microSaccades", "shake"] },
|
||||
"body": { "enum": ["none", "slowDrift", "shake"] }
|
||||
}
|
||||
},
|
||||
"colors": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"body": { "$ref": "#/$defs/hexColor" },
|
||||
"eyes": { "$ref": "#/$defs/hexColor" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"blink": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["enabled", "initialDelayMs", "minIntervalMs", "maxIntervalMs", "durationMs"],
|
||||
"properties": {
|
||||
"enabled": { "type": "boolean" },
|
||||
"initialDelayMs": { "type": "number", "minimum": 0, "maximum": 60000 },
|
||||
"minIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
|
||||
"maxIntervalMs": { "type": "number", "minimum": 250, "maximum": 120000 },
|
||||
"durationMs": { "type": "number", "minimum": 50, "maximum": 2000 }
|
||||
}
|
||||
},
|
||||
"animation": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["playbackMode", "steps", "blink"],
|
||||
"properties": {
|
||||
"playbackMode": { "enum": ["loop", "once", "pingPong"] },
|
||||
"steps": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"maxItems": 128,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": ["expression", "holdMs", "transitionMs", "transition"],
|
||||
"properties": {
|
||||
"expression": { "$ref": "#/$defs/semanticKey" },
|
||||
"holdMs": { "type": "number", "minimum": 100, "maximum": 60000 },
|
||||
"transitionMs": { "type": "number", "minimum": 0, "maximum": 5000 },
|
||||
"transition": { "enum": ["spring", "smooth", "snappy"] }
|
||||
}
|
||||
}
|
||||
},
|
||||
"blink": { "$ref": "#/$defs/blink" },
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"minProperties": 1,
|
||||
"properties": {
|
||||
"label": { "type": "string", "maxLength": 120 },
|
||||
"description": { "type": "string", "maxLength": 512 },
|
||||
"group": { "type": "string", "maxLength": 64 }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
617
packages/avatar-core/src/avatarDefinition.ts
Normal file
617
packages/avatar-core/src/avatarDefinition.ts
Normal file
@ -0,0 +1,617 @@
|
||||
import Ajv2020, { type ErrorObject } from 'ajv/dist/2020.js'
|
||||
import avatarDefinitionSchema from './avatarDefinition.schema.json'
|
||||
import type { SurfaceType } from './surfaces'
|
||||
|
||||
export const AVATAR_DEFINITION_MAX_BYTES = 262_144
|
||||
export const AVATAR_DEFINITION_MAX_DEPTH = 32
|
||||
const MAX_JSON_STRING_LENGTH = 512
|
||||
export const SEMANTIC_KEY_PATTERN = /^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$/
|
||||
|
||||
export type SemanticKeyIssueCode =
|
||||
'missing_semantic_key' | 'invalid_semantic_key' | 'reserved_semantic_key'
|
||||
|
||||
export const getSemanticKeyIssue = (
|
||||
semanticKey: string | undefined,
|
||||
kind: 'expression' | 'animation'
|
||||
): SemanticKeyIssueCode | undefined => {
|
||||
if (!semanticKey) return 'missing_semantic_key'
|
||||
if (!SEMANTIC_KEY_PATTERN.test(semanticKey) || semanticKey.length > 64) {
|
||||
return 'invalid_semantic_key'
|
||||
}
|
||||
if (kind === 'expression' && semanticKey === 'neutral') return 'reserved_semantic_key'
|
||||
return undefined
|
||||
}
|
||||
|
||||
export type SemanticKey = string
|
||||
export type ExpressionKey = SemanticKey
|
||||
export type AnimationKey = SemanticKey
|
||||
export type HexColor = `#${string}`
|
||||
|
||||
export type AvatarColorsDefinition = {
|
||||
body: HexColor
|
||||
eyes: HexColor
|
||||
}
|
||||
|
||||
export type SurfaceDefinition<TType extends SurfaceType = SurfaceType> = {
|
||||
type: TType
|
||||
width: number
|
||||
height: number
|
||||
depth: number
|
||||
roundness: number
|
||||
morphRoundness?: number
|
||||
tipRoundness?: number
|
||||
baseRoundness?: number
|
||||
}
|
||||
|
||||
export type BodyNodeSurfaceType = Exclude<SurfaceType, 'mickey' | 'cursor'>
|
||||
export type PrimarySurfaceDefinition = SurfaceDefinition<SurfaceType>
|
||||
export type BodyNodeSurfaceDefinition = SurfaceDefinition<BodyNodeSurfaceType>
|
||||
|
||||
export type AvatarBodyNodeDefinition = {
|
||||
surface: BodyNodeSurfaceDefinition
|
||||
position: [number, number, number]
|
||||
rotation: [number, number, number]
|
||||
}
|
||||
|
||||
export type AvatarBodyDefinition = {
|
||||
primary: PrimarySurfaceDefinition
|
||||
nodes: AvatarBodyNodeDefinition[]
|
||||
}
|
||||
|
||||
export type AvatarExpressionDefinition = {
|
||||
head: { x: number; y: number; z: number }
|
||||
eyes: {
|
||||
left: { width: number; height: number; x: number; y: number; angle: number }
|
||||
right: { width: number; height: number; x: number; y: number; angle: number }
|
||||
spacing: number
|
||||
}
|
||||
perspective: number
|
||||
motion: {
|
||||
eyes: 'none' | 'microSaccades' | 'shake'
|
||||
body: 'none' | 'slowDrift' | 'shake'
|
||||
}
|
||||
colors?: Partial<AvatarColorsDefinition>
|
||||
}
|
||||
|
||||
export type AvatarAnimationStepDefinition = {
|
||||
expression: ExpressionKey
|
||||
holdMs: number
|
||||
transitionMs: number
|
||||
transition: 'spring' | 'smooth' | 'snappy'
|
||||
}
|
||||
|
||||
export type AvatarAnimationDefinition = {
|
||||
playbackMode: 'loop' | 'once' | 'pingPong'
|
||||
steps: AvatarAnimationStepDefinition[]
|
||||
blink: {
|
||||
enabled: boolean
|
||||
initialDelayMs: number
|
||||
minIntervalMs: number
|
||||
maxIntervalMs: number
|
||||
durationMs: number
|
||||
}
|
||||
metadata?: {
|
||||
label?: string
|
||||
description?: string
|
||||
group?: string
|
||||
}
|
||||
}
|
||||
|
||||
export type AvatarDefinition = {
|
||||
schema: 'bible-strong/avatar-definition'
|
||||
schemaVersion: 1
|
||||
name?: string
|
||||
body: AvatarBodyDefinition
|
||||
colors: AvatarColorsDefinition
|
||||
expressions: Record<ExpressionKey, AvatarExpressionDefinition>
|
||||
expressionOrder: ExpressionKey[]
|
||||
animations: Record<AnimationKey, AvatarAnimationDefinition>
|
||||
animationOrder: AnimationKey[]
|
||||
standardAnimationSet: 1
|
||||
}
|
||||
|
||||
export const STANDARD_ANIMATION_ORDER_V1 = [
|
||||
'idle',
|
||||
'happy',
|
||||
'sad',
|
||||
'thinking',
|
||||
'excited',
|
||||
'celebrate',
|
||||
] as const
|
||||
|
||||
export type StandardAnimationKeyV1 = (typeof STANDARD_ANIMATION_ORDER_V1)[number]
|
||||
export type StandardAnimationManifestEntry = AvatarAnimationDefinition & {
|
||||
requiredExpressions: ExpressionKey[]
|
||||
}
|
||||
|
||||
const standardStep = (
|
||||
expression: ExpressionKey,
|
||||
holdMs: number
|
||||
): AvatarAnimationStepDefinition => ({
|
||||
expression,
|
||||
holdMs,
|
||||
transitionMs: 500,
|
||||
transition: 'smooth',
|
||||
})
|
||||
|
||||
const standardBlink = (
|
||||
initialDelayMs: number,
|
||||
minIntervalMs: number,
|
||||
maxIntervalMs: number,
|
||||
durationMs: number
|
||||
) => ({ enabled: true, initialDelayMs, minIntervalMs, maxIntervalMs, durationMs })
|
||||
|
||||
export const STANDARD_ANIMATIONS_V1 = {
|
||||
idle: {
|
||||
requiredExpressions: ['upward-side-glance', 'curious-left'],
|
||||
playbackMode: 'loop',
|
||||
steps: [standardStep('upward-side-glance', 5200), standardStep('curious-left', 5200)],
|
||||
blink: standardBlink(2600, 3400, 6200, 280),
|
||||
},
|
||||
happy: {
|
||||
requiredExpressions: [
|
||||
'joyful-down-right',
|
||||
'joyful-wide',
|
||||
'playful-right',
|
||||
'gentle-downward-gaze',
|
||||
],
|
||||
playbackMode: 'loop',
|
||||
steps: [
|
||||
standardStep('joyful-down-right', 2300),
|
||||
standardStep('joyful-wide', 2300),
|
||||
standardStep('playful-right', 2300),
|
||||
standardStep('gentle-downward-gaze', 2300),
|
||||
],
|
||||
blink: standardBlink(2100, 2800, 5000, 260),
|
||||
},
|
||||
sad: {
|
||||
requiredExpressions: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
|
||||
playbackMode: 'loop',
|
||||
steps: [
|
||||
standardStep('sleepy-squint', 3600),
|
||||
standardStep('eyes-closed', 3600),
|
||||
standardStep('drowsy-closed', 3600),
|
||||
],
|
||||
blink: standardBlink(4800, 6500, 9500, 420),
|
||||
},
|
||||
thinking: {
|
||||
requiredExpressions: [
|
||||
'curious-left',
|
||||
'angry-left',
|
||||
'skeptical-left',
|
||||
'playful-right',
|
||||
'skeptical-right',
|
||||
],
|
||||
playbackMode: 'loop',
|
||||
steps: [
|
||||
standardStep('curious-left', 2300),
|
||||
standardStep('angry-left', 2300),
|
||||
standardStep('skeptical-left', 2300),
|
||||
standardStep('playful-right', 2300),
|
||||
standardStep('skeptical-right', 2300),
|
||||
],
|
||||
blink: standardBlink(2100, 2800, 5000, 260),
|
||||
},
|
||||
excited: {
|
||||
requiredExpressions: [
|
||||
'joyful-down-right',
|
||||
'playful-right',
|
||||
'surprised-wide-left',
|
||||
'surprised-left',
|
||||
'joyful-wide',
|
||||
],
|
||||
playbackMode: 'loop',
|
||||
steps: [
|
||||
standardStep('joyful-down-right', 2300),
|
||||
standardStep('playful-right', 2300),
|
||||
standardStep('surprised-wide-left', 2300),
|
||||
standardStep('surprised-left', 2300),
|
||||
standardStep('joyful-wide', 2300),
|
||||
],
|
||||
blink: standardBlink(1200, 1800, 3600, 220),
|
||||
},
|
||||
celebrate: {
|
||||
requiredExpressions: ['joyful-down-right', 'curious-left', 'playful-right'],
|
||||
playbackMode: 'loop',
|
||||
steps: [
|
||||
standardStep('joyful-down-right', 2300),
|
||||
standardStep('curious-left', 2300),
|
||||
standardStep('playful-right', 2300),
|
||||
],
|
||||
blink: standardBlink(1200, 1800, 3600, 220),
|
||||
},
|
||||
} satisfies Record<StandardAnimationKeyV1, StandardAnimationManifestEntry>
|
||||
|
||||
export type StandardAnimationAvailabilityV1 = {
|
||||
available: StandardAnimationKeyV1[]
|
||||
unavailable: { key: StandardAnimationKeyV1; missingExpressions: ExpressionKey[] }[]
|
||||
}
|
||||
|
||||
export const getStandardAnimationAvailabilityV1 = (
|
||||
expressions: Readonly<Record<ExpressionKey, AvatarExpressionDefinition>>
|
||||
): StandardAnimationAvailabilityV1 => {
|
||||
const available: StandardAnimationKeyV1[] = []
|
||||
const unavailable: StandardAnimationAvailabilityV1['unavailable'] = []
|
||||
STANDARD_ANIMATION_ORDER_V1.forEach(key => {
|
||||
const missingExpressions = STANDARD_ANIMATIONS_V1[key].requiredExpressions.filter(
|
||||
expression => !(expression in expressions)
|
||||
)
|
||||
if (missingExpressions.length) unavailable.push({ key, missingExpressions })
|
||||
else available.push(key)
|
||||
})
|
||||
return { available, unavailable }
|
||||
}
|
||||
|
||||
export type AvatarDefinitionError = {
|
||||
path: string
|
||||
code: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type ValidationResult<T> =
|
||||
{ ok: true; value: Readonly<T> } | { ok: false; errors: readonly AvatarDefinitionError[] }
|
||||
|
||||
const ajv = new Ajv2020({ allErrors: true, strict: true })
|
||||
const validateSchema = ajv.compile<AvatarDefinition>(avatarDefinitionSchema)
|
||||
|
||||
const escapePointer = (value: string) => value.replaceAll('~', '~0').replaceAll('/', '~1')
|
||||
const childPointer = (path: string, segment: string | number) =>
|
||||
`${path}/${escapePointer(String(segment))}`
|
||||
const stringLength = (value: string) => [...value].length
|
||||
|
||||
const stringLimitAt = (path: string) => {
|
||||
if (
|
||||
/^\/(?:expressionOrder|animationOrder)\/\d+$/.test(path) ||
|
||||
/^\/animations\/[^/]+\/steps\/\d+\/expression$/.test(path) ||
|
||||
/^\/animations\/[^/]+\/metadata\/group$/.test(path)
|
||||
) {
|
||||
return 64
|
||||
}
|
||||
if (path === '/name' || /^\/animations\/[^/]+\/metadata\/label$/.test(path)) return 120
|
||||
return MAX_JSON_STRING_LENGTH
|
||||
}
|
||||
|
||||
const schemaErrorPath = (error: ErrorObject) => {
|
||||
const propertyName = (error as ErrorObject & { propertyName?: string }).propertyName
|
||||
if (propertyName !== undefined) return childPointer(error.instancePath, propertyName)
|
||||
if (error.keyword === 'required') {
|
||||
return childPointer(error.instancePath, String(error.params.missingProperty))
|
||||
}
|
||||
if (error.keyword === 'additionalProperties') {
|
||||
return childPointer(error.instancePath, String(error.params.additionalProperty))
|
||||
}
|
||||
if (error.keyword === 'propertyNames') {
|
||||
return childPointer(error.instancePath, String(error.params.propertyName))
|
||||
}
|
||||
return error.instancePath
|
||||
}
|
||||
|
||||
const schemaErrorCode = (error: ErrorObject) => {
|
||||
if (error.instancePath === '/schemaVersion' && error.keyword === 'const') {
|
||||
return 'unsupported_version'
|
||||
}
|
||||
return error.keyword
|
||||
}
|
||||
|
||||
const schemaErrors = (errors: ErrorObject[] | null | undefined): AvatarDefinitionError[] =>
|
||||
(errors ?? []).map(error => ({
|
||||
path: schemaErrorPath(error),
|
||||
code: schemaErrorCode(error),
|
||||
message: error.message ?? 'Invalid avatar definition',
|
||||
}))
|
||||
|
||||
const inspectMaterializedValue = (value: unknown): AvatarDefinitionError[] => {
|
||||
const errors: AvatarDefinitionError[] = []
|
||||
const ancestors = new WeakSet<object>()
|
||||
|
||||
const visit = (current: unknown, path: string) => {
|
||||
if (typeof current === 'number' && !Number.isFinite(current)) {
|
||||
errors.push({ path, code: 'non_finite_number', message: 'Number must be finite' })
|
||||
return
|
||||
}
|
||||
if (current === null || typeof current !== 'object') return
|
||||
if (ancestors.has(current)) {
|
||||
errors.push({ path, code: 'cyclic_value', message: 'Avatar definition must not be cyclic' })
|
||||
return
|
||||
}
|
||||
ancestors.add(current)
|
||||
if (!Array.isArray(current)) {
|
||||
const prototype = Object.getPrototypeOf(current)
|
||||
if (prototype !== Object.prototype && prototype !== null) {
|
||||
errors.push({ path, code: 'non_plain_object', message: 'Expected a plain object' })
|
||||
ancestors.delete(current)
|
||||
return
|
||||
}
|
||||
}
|
||||
Object.entries(current).forEach(([key, child]) => visit(child, childPointer(path, key)))
|
||||
ancestors.delete(current)
|
||||
}
|
||||
|
||||
visit(value, '')
|
||||
return errors
|
||||
}
|
||||
|
||||
const semanticErrors = (definition: AvatarDefinition): AvatarDefinitionError[] => {
|
||||
const errors: AvatarDefinitionError[] = []
|
||||
const expressionKeys = Object.keys(definition.expressions)
|
||||
const animationKeys = Object.keys(definition.animations)
|
||||
|
||||
const checkCompleteOrder = (
|
||||
order: string[],
|
||||
keys: string[],
|
||||
path: '/expressionOrder' | '/animationOrder'
|
||||
) => {
|
||||
const ordered = new Set(order)
|
||||
keys.forEach(key => {
|
||||
if (!ordered.has(key)) {
|
||||
errors.push({
|
||||
path,
|
||||
code: 'incomplete_order',
|
||||
message: `Order is missing key '${key}'`,
|
||||
})
|
||||
}
|
||||
})
|
||||
order.forEach((key, index) => {
|
||||
if (!keys.includes(key)) {
|
||||
errors.push({
|
||||
path: childPointer(path, index),
|
||||
code: 'unknown_order_key',
|
||||
message: `Unknown key '${key}'`,
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
checkCompleteOrder(definition.expressionOrder, expressionKeys, '/expressionOrder')
|
||||
checkCompleteOrder(definition.animationOrder, animationKeys, '/animationOrder')
|
||||
if (definition.expressionOrder[0] !== 'neutral') {
|
||||
errors.push({
|
||||
path: '/expressionOrder/0',
|
||||
code: 'neutral_not_first',
|
||||
message: "'neutral' must be the first expression-order entry",
|
||||
})
|
||||
}
|
||||
Object.entries(definition.animations).forEach(([animationKey, animation]) => {
|
||||
if (animation.blink.minIntervalMs > animation.blink.maxIntervalMs) {
|
||||
errors.push({
|
||||
path: `/animations/${escapePointer(animationKey)}/blink/minIntervalMs`,
|
||||
code: 'invalid_interval_range',
|
||||
message: 'minIntervalMs must be less than or equal to maxIntervalMs',
|
||||
})
|
||||
}
|
||||
animation.steps.forEach((step, index) => {
|
||||
if (!(step.expression in definition.expressions)) {
|
||||
errors.push({
|
||||
path: `/animations/${escapePointer(animationKey)}/steps/${index}/expression`,
|
||||
code: 'unknown_expression',
|
||||
message: `Unknown expression '${step.expression}'`,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
return errors
|
||||
}
|
||||
|
||||
const cloneAndFreeze = <T>(value: T): Readonly<T> => {
|
||||
if (value === null || typeof value !== 'object') return value
|
||||
const clone: unknown = Array.isArray(value)
|
||||
? value.map(item => cloneAndFreeze(item))
|
||||
: Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneAndFreeze(item)]))
|
||||
return Object.freeze(clone) as Readonly<T>
|
||||
}
|
||||
|
||||
export const validateAvatarDefinition = (value: unknown): ValidationResult<AvatarDefinition> => {
|
||||
const structuralErrors = inspectMaterializedValue(value)
|
||||
if (structuralErrors.length) return { ok: false, errors: structuralErrors }
|
||||
if (!validateSchema(value)) return { ok: false, errors: schemaErrors(validateSchema.errors) }
|
||||
const errors = semanticErrors(value)
|
||||
return errors.length ? { ok: false, errors } : { ok: true, value: cloneAndFreeze(value) }
|
||||
}
|
||||
|
||||
class JsonTextError extends Error {
|
||||
constructor(
|
||||
readonly path: string,
|
||||
readonly code: string,
|
||||
message: string
|
||||
) {
|
||||
super(message)
|
||||
}
|
||||
}
|
||||
|
||||
class BoundedJsonParser {
|
||||
private index = 0
|
||||
|
||||
constructor(private readonly source: string) {}
|
||||
|
||||
parse(): unknown {
|
||||
this.skipWhitespace()
|
||||
const value = this.parseValue('', 1)
|
||||
this.skipWhitespace()
|
||||
if (this.index !== this.source.length)
|
||||
this.fail('', 'invalid_json', 'Unexpected trailing input')
|
||||
return value
|
||||
}
|
||||
|
||||
private fail(path: string, code: string, message: string): never {
|
||||
throw new JsonTextError(path, code, `${message} at character ${this.index}`)
|
||||
}
|
||||
|
||||
private skipWhitespace() {
|
||||
while (
|
||||
(this.source[this.index] === ' ' ||
|
||||
this.source[this.index] === '\n' ||
|
||||
this.source[this.index] === '\r' ||
|
||||
this.source[this.index] === '\t') &&
|
||||
this.index < this.source.length
|
||||
) {
|
||||
this.index += 1
|
||||
}
|
||||
}
|
||||
|
||||
private parseValue(path: string, depth: number): unknown {
|
||||
this.skipWhitespace()
|
||||
const character = this.source[this.index]
|
||||
if (character === '{' || character === '[') {
|
||||
if (depth > AVATAR_DEFINITION_MAX_DEPTH) {
|
||||
this.fail(path, 'max_depth', `JSON nesting depth exceeds ${AVATAR_DEFINITION_MAX_DEPTH}`)
|
||||
}
|
||||
return character === '{' ? this.parseObject(path, depth) : this.parseArray(path, depth)
|
||||
}
|
||||
if (character === '"') return this.parseString(path)
|
||||
if (character === '-' || (character >= '0' && character <= '9')) return this.parseNumber(path)
|
||||
if (this.source.startsWith('true', this.index)) return this.parseLiteral('true', true)
|
||||
if (this.source.startsWith('false', this.index)) return this.parseLiteral('false', false)
|
||||
if (this.source.startsWith('null', this.index)) return this.parseLiteral('null', null)
|
||||
this.fail(path, 'invalid_json', 'Expected a JSON value')
|
||||
}
|
||||
|
||||
private parseObject(path: string, depth: number) {
|
||||
this.index += 1
|
||||
this.skipWhitespace()
|
||||
const result: Record<string, unknown> = Object.create(null) as Record<string, unknown>
|
||||
const keys = new Set<string>()
|
||||
if (this.source[this.index] === '}') {
|
||||
this.index += 1
|
||||
return result
|
||||
}
|
||||
while (this.index < this.source.length) {
|
||||
if (this.source[this.index] !== '"') this.fail(path, 'invalid_json', 'Expected an object key')
|
||||
const key = this.parseString(path)
|
||||
const keyPath = childPointer(path, key)
|
||||
if ((path === '/expressions' || path === '/animations') && stringLength(key) > 64) {
|
||||
this.fail(keyPath, 'string_too_long', 'Semantic key exceeds 64 characters')
|
||||
}
|
||||
if (keys.has(key)) this.fail(keyPath, 'duplicate_key', `Duplicate object member '${key}'`)
|
||||
keys.add(key)
|
||||
this.skipWhitespace()
|
||||
if (this.source[this.index] !== ':') this.fail(keyPath, 'invalid_json', "Expected ':'")
|
||||
this.index += 1
|
||||
result[key] = this.parseValue(keyPath, depth + 1)
|
||||
this.skipWhitespace()
|
||||
const separator = this.source[this.index]
|
||||
if (separator === '}') {
|
||||
this.index += 1
|
||||
return result
|
||||
}
|
||||
if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or '}'")
|
||||
this.index += 1
|
||||
this.skipWhitespace()
|
||||
}
|
||||
this.fail(path, 'invalid_json', 'Unterminated object')
|
||||
}
|
||||
|
||||
private parseArray(path: string, depth: number) {
|
||||
this.index += 1
|
||||
this.skipWhitespace()
|
||||
const result: unknown[] = []
|
||||
if (this.source[this.index] === ']') {
|
||||
this.index += 1
|
||||
return result
|
||||
}
|
||||
while (this.index < this.source.length) {
|
||||
result.push(this.parseValue(childPointer(path, result.length), depth + 1))
|
||||
this.skipWhitespace()
|
||||
const separator = this.source[this.index]
|
||||
if (separator === ']') {
|
||||
this.index += 1
|
||||
return result
|
||||
}
|
||||
if (separator !== ',') this.fail(path, 'invalid_json', "Expected ',' or ']'")
|
||||
this.index += 1
|
||||
}
|
||||
this.fail(path, 'invalid_json', 'Unterminated array')
|
||||
}
|
||||
|
||||
private parseString(path: string): string {
|
||||
const start = this.index
|
||||
this.index += 1
|
||||
let escaped = false
|
||||
while (this.index < this.source.length) {
|
||||
const character = this.source[this.index]
|
||||
if (!escaped && character === '"') {
|
||||
this.index += 1
|
||||
let value: string
|
||||
try {
|
||||
value = JSON.parse(this.source.slice(start, this.index)) as string
|
||||
} catch {
|
||||
this.fail(path, 'invalid_json', 'Invalid JSON string')
|
||||
}
|
||||
const limit = stringLimitAt(path)
|
||||
if (stringLength(value) > limit) {
|
||||
this.fail(path, 'string_too_long', `JSON string exceeds ${limit} characters`)
|
||||
}
|
||||
return value
|
||||
}
|
||||
if (!escaped && character.charCodeAt(0) < 0x20) {
|
||||
this.fail(path, 'invalid_json', 'Unescaped control character')
|
||||
}
|
||||
if (!escaped && character === '\\') escaped = true
|
||||
else escaped = false
|
||||
this.index += 1
|
||||
if (this.index - start > MAX_JSON_STRING_LENGTH * 12 + 2) {
|
||||
this.fail(
|
||||
path,
|
||||
'string_too_long',
|
||||
`JSON string exceeds ${MAX_JSON_STRING_LENGTH} characters`
|
||||
)
|
||||
}
|
||||
}
|
||||
this.fail(path, 'invalid_json', 'Unterminated string')
|
||||
}
|
||||
|
||||
private parseNumber(path: string): number {
|
||||
const remaining = this.source.slice(this.index)
|
||||
const match = /^-?(?:0|[1-9]\d*)(?:\.\d+)?(?:[eE][+-]?\d+)?/.exec(remaining)
|
||||
if (!match) this.fail(path, 'invalid_json', 'Invalid number')
|
||||
this.index += match[0].length
|
||||
const number = Number(match[0])
|
||||
if (!Number.isFinite(number)) this.fail(path, 'non_finite_number', 'Number must be finite')
|
||||
return number
|
||||
}
|
||||
|
||||
private parseLiteral<T>(source: string, value: T): T {
|
||||
this.index += source.length
|
||||
return value
|
||||
}
|
||||
}
|
||||
|
||||
export const parseAvatarDefinition = (text: string): ValidationResult<AvatarDefinition> => {
|
||||
if (new TextEncoder().encode(text).byteLength > AVATAR_DEFINITION_MAX_BYTES) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [
|
||||
{
|
||||
path: '',
|
||||
code: 'max_bytes',
|
||||
message: `JSON input exceeds ${AVATAR_DEFINITION_MAX_BYTES} UTF-8 bytes`,
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
try {
|
||||
return validateAvatarDefinition(new BoundedJsonParser(text).parse())
|
||||
} catch (error) {
|
||||
if (error instanceof JsonTextError) {
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ path: error.path, code: error.code, message: error.message }],
|
||||
}
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
errors: [{ path: '', code: 'invalid_json', message: 'Invalid JSON input' }],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const avatarDefinitionFileName = (name: string) => {
|
||||
const base =
|
||||
name
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-|-$/g, '') || 'avatar'
|
||||
return `${base}.avatar.json`
|
||||
}
|
||||
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],
|
||||
})
|
||||
1252
packages/avatar-core/src/geometry.ts
Normal file
1252
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'
|
||||
266
packages/avatar-core/src/runtime.ts
Normal file
266
packages/avatar-core/src/runtime.ts
Normal file
@ -0,0 +1,266 @@
|
||||
import {
|
||||
getStandardAnimationAvailabilityV1,
|
||||
STANDARD_ANIMATIONS_V1,
|
||||
type AnimationKey,
|
||||
type AvatarAnimationDefinition,
|
||||
type AvatarDefinition,
|
||||
type AvatarExpressionDefinition,
|
||||
type ExpressionKey,
|
||||
} from './avatarDefinition'
|
||||
import { applyAmbientMotion } from './ambientMotion'
|
||||
import { interpolatePose, poseFromExpression } from './geometry'
|
||||
import { expressionFromDefinition, renderAvatarExpression, type AvatarScene } from './scene'
|
||||
|
||||
export type AvatarRuntimeError = {
|
||||
code: 'unknown_animation' | 'unavailable_standard_animation' | 'unknown_expression'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AvatarCommandResult<T> =
|
||||
{ ok: true; value: T } | { ok: false; error: AvatarRuntimeError }
|
||||
|
||||
export const resolveExpression = (
|
||||
definition: Readonly<AvatarDefinition>,
|
||||
key: ExpressionKey
|
||||
): AvatarCommandResult<Readonly<AvatarExpressionDefinition>> => {
|
||||
const expression = definition.expressions[key]
|
||||
return expression
|
||||
? { ok: true, value: expression }
|
||||
: {
|
||||
ok: false,
|
||||
error: { code: 'unknown_expression', key, message: `Unknown expression '${key}'` },
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveAnimation = (
|
||||
definition: Readonly<AvatarDefinition>,
|
||||
key: AnimationKey
|
||||
): AvatarCommandResult<Readonly<AvatarAnimationDefinition>> => {
|
||||
const explicit = definition.animations[key]
|
||||
if (explicit) return { ok: true, value: explicit }
|
||||
if (!(key in STANDARD_ANIMATIONS_V1)) {
|
||||
return {
|
||||
ok: false,
|
||||
error: { code: 'unknown_animation', key, message: `Unknown animation '${key}'` },
|
||||
}
|
||||
}
|
||||
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
|
||||
const unavailable = availability.unavailable.find(item => item.key === key)
|
||||
if (unavailable) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'unavailable_standard_animation',
|
||||
key,
|
||||
message: `Animation '${key}' requires: ${unavailable.missingExpressions.join(', ')}`,
|
||||
},
|
||||
}
|
||||
}
|
||||
const { requiredExpressions: _requiredExpressions, ...animation } =
|
||||
STANDARD_ANIMATIONS_V1[key as keyof typeof STANDARD_ANIMATIONS_V1]
|
||||
return { ok: true, value: animation }
|
||||
}
|
||||
|
||||
export type AvatarPlaybackState = {
|
||||
activeAnimation?: AnimationKey
|
||||
activeExpression: ExpressionKey
|
||||
status: 'playing' | 'paused' | 'stopped'
|
||||
stepIndex: number
|
||||
direction: 1 | -1
|
||||
phase: 'transition' | 'hold'
|
||||
phaseStartedAt: number
|
||||
transitionFrom: ExpressionKey
|
||||
pausedAt?: number
|
||||
blinkDueAt?: number
|
||||
blinkStartedAt?: number
|
||||
}
|
||||
|
||||
export type AvatarRuntimeEnvironment = {
|
||||
random: () => number
|
||||
reduceMotion?: boolean
|
||||
}
|
||||
|
||||
export const createAvatarPlaybackState = (): AvatarPlaybackState => ({
|
||||
activeExpression: 'neutral',
|
||||
status: 'stopped',
|
||||
stepIndex: 0,
|
||||
direction: 1,
|
||||
phase: 'transition',
|
||||
phaseStartedAt: 0,
|
||||
transitionFrom: 'neutral',
|
||||
})
|
||||
|
||||
export const playAvatarAnimation = (
|
||||
definition: Readonly<AvatarDefinition>,
|
||||
key: AnimationKey,
|
||||
now: number
|
||||
): AvatarCommandResult<AvatarPlaybackState> => {
|
||||
const result = resolveAnimation(definition, key)
|
||||
if (!result.ok) return result
|
||||
return {
|
||||
ok: true,
|
||||
value: {
|
||||
activeAnimation: key,
|
||||
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
|
||||
status: 'playing',
|
||||
stepIndex: 0,
|
||||
direction: 1,
|
||||
phase: 'transition',
|
||||
phaseStartedAt: now,
|
||||
transitionFrom: 'neutral',
|
||||
blinkDueAt: now + result.value.blink.initialDelayMs,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const nextCursor = (
|
||||
animation: Readonly<AvatarAnimationDefinition>,
|
||||
state: AvatarPlaybackState
|
||||
): { stepIndex: number; direction: 1 | -1; complete: boolean } => {
|
||||
const last = animation.steps.length - 1
|
||||
if (state.stepIndex < last && state.direction === 1) {
|
||||
return { stepIndex: state.stepIndex + 1, direction: state.direction, complete: false }
|
||||
}
|
||||
if (state.stepIndex > 0 && state.direction === -1) {
|
||||
return { stepIndex: state.stepIndex - 1, direction: state.direction, complete: false }
|
||||
}
|
||||
if (animation.playbackMode === 'once') {
|
||||
return { stepIndex: state.stepIndex, direction: state.direction, complete: true }
|
||||
}
|
||||
if (animation.playbackMode === 'pingPong' && last > 0) {
|
||||
const direction = state.direction === 1 ? -1 : 1
|
||||
return { stepIndex: state.stepIndex + direction, direction, complete: false }
|
||||
}
|
||||
return { stepIndex: 0, direction: 1 as const, complete: false }
|
||||
}
|
||||
|
||||
export const advanceAvatarPlayback = (
|
||||
definition: Readonly<AvatarDefinition>,
|
||||
state: Readonly<AvatarPlaybackState>,
|
||||
now: number,
|
||||
environment: AvatarRuntimeEnvironment
|
||||
): AvatarPlaybackState => {
|
||||
if (state.status !== 'playing' || !state.activeAnimation) return { ...state }
|
||||
const resolved = resolveAnimation(definition, state.activeAnimation)
|
||||
if (!resolved.ok || !resolved.value.steps.length) return { ...createAvatarPlaybackState() }
|
||||
const animation = resolved.value
|
||||
let next = { ...state }
|
||||
if (animation.blink.enabled && next.blinkDueAt !== undefined && now >= next.blinkDueAt) {
|
||||
const startedAt = next.blinkDueAt
|
||||
const interval =
|
||||
animation.blink.minIntervalMs +
|
||||
Math.max(0, Math.min(1, environment.random())) *
|
||||
(animation.blink.maxIntervalMs - animation.blink.minIntervalMs)
|
||||
next.blinkStartedAt = startedAt
|
||||
next.blinkDueAt = startedAt + animation.blink.durationMs + interval
|
||||
}
|
||||
let safety = animation.steps.length * 4 + 4
|
||||
while (safety-- > 0) {
|
||||
const step = animation.steps[next.stepIndex]
|
||||
const duration = next.phase === 'transition' ? step.transitionMs : step.holdMs
|
||||
if (now < next.phaseStartedAt + duration) break
|
||||
next.phaseStartedAt += duration
|
||||
if (next.phase === 'transition') {
|
||||
next.phase = 'hold'
|
||||
next.activeExpression = step.expression
|
||||
continue
|
||||
}
|
||||
const cursor = nextCursor(animation, next)
|
||||
if (cursor.complete) {
|
||||
next.status = 'stopped'
|
||||
delete next.activeAnimation
|
||||
break
|
||||
}
|
||||
next.stepIndex = cursor.stepIndex
|
||||
next.direction = cursor.direction
|
||||
next.phase = 'transition'
|
||||
next.transitionFrom = next.activeExpression
|
||||
next.activeExpression = animation.steps[cursor.stepIndex].expression
|
||||
}
|
||||
return next
|
||||
}
|
||||
|
||||
export const pauseAvatarPlayback = (
|
||||
state: Readonly<AvatarPlaybackState>,
|
||||
now: number
|
||||
): AvatarPlaybackState =>
|
||||
state.status === 'playing' ? { ...state, status: 'paused', pausedAt: now } : { ...state }
|
||||
|
||||
export const resumeAvatarPlayback = (
|
||||
state: Readonly<AvatarPlaybackState>,
|
||||
now: number
|
||||
): AvatarPlaybackState => {
|
||||
if (state.status !== 'paused' || state.pausedAt === undefined) return { ...state }
|
||||
const pauseDuration = now - state.pausedAt
|
||||
return {
|
||||
...state,
|
||||
status: 'playing',
|
||||
phaseStartedAt: state.phaseStartedAt + pauseDuration,
|
||||
...(state.blinkDueAt === undefined ? {} : { blinkDueAt: state.blinkDueAt + pauseDuration }),
|
||||
...(state.blinkStartedAt === undefined
|
||||
? {}
|
||||
: { blinkStartedAt: state.blinkStartedAt + pauseDuration }),
|
||||
pausedAt: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
const easing = (
|
||||
transition: AvatarAnimationDefinition['steps'][number]['transition'],
|
||||
value: number
|
||||
) => {
|
||||
const progress = Math.max(0, Math.min(1, value))
|
||||
if (transition === 'smooth') return progress * progress * (3 - 2 * progress)
|
||||
if (transition === 'snappy') return 1 - (1 - progress) ** 3
|
||||
const end = 1 - Math.exp(-6) * Math.cos(8)
|
||||
return Math.max(0, Math.min(1, (1 - Math.exp(-6 * progress) * Math.cos(8 * progress)) / end))
|
||||
}
|
||||
|
||||
export const blinkOpacityAt = (
|
||||
animation: Readonly<AvatarAnimationDefinition>,
|
||||
state: Readonly<AvatarPlaybackState>,
|
||||
now: number
|
||||
) => {
|
||||
if (!animation.blink.enabled || state.blinkStartedAt === undefined) return 1
|
||||
const progress = (now - state.blinkStartedAt) / animation.blink.durationMs
|
||||
if (progress < 0 || progress >= 1) return 1
|
||||
return Math.abs(progress * 2 - 1)
|
||||
}
|
||||
|
||||
export const renderAvatarFrame = (
|
||||
definition: Readonly<AvatarDefinition>,
|
||||
state: Readonly<AvatarPlaybackState>,
|
||||
now: number,
|
||||
environment: AvatarRuntimeEnvironment
|
||||
): AvatarScene => {
|
||||
const targetDefinition = definition.expressions[state.activeExpression]
|
||||
if (!targetDefinition)
|
||||
return renderAvatarExpression(
|
||||
definition,
|
||||
expressionFromDefinition('neutral', definition.expressions.neutral)
|
||||
)
|
||||
let expression = expressionFromDefinition(state.activeExpression, targetDefinition)
|
||||
let blink = 1
|
||||
if (state.activeAnimation) {
|
||||
const resolved = resolveAnimation(definition, state.activeAnimation)
|
||||
if (resolved.ok) {
|
||||
const step = resolved.value.steps[state.stepIndex]
|
||||
if (state.phase === 'transition' && step && !environment.reduceMotion) {
|
||||
const fromDefinition = definition.expressions[state.transitionFrom]
|
||||
if (fromDefinition) {
|
||||
const from = expressionFromDefinition(state.transitionFrom, fromDefinition)
|
||||
const duration = Math.max(step.transitionMs, 1)
|
||||
const progress = easing(step.transition, (now - state.phaseStartedAt) / duration)
|
||||
expression = interpolatePose(
|
||||
poseFromExpression(from),
|
||||
poseFromExpression(expression),
|
||||
progress
|
||||
).expression
|
||||
}
|
||||
}
|
||||
blink = blinkOpacityAt(resolved.value, state, now)
|
||||
}
|
||||
}
|
||||
if (!environment.reduceMotion) expression = applyAmbientMotion(expression, now)
|
||||
return renderAvatarExpression(definition, expression, targetDefinition.colors, blink)
|
||||
}
|
||||
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/>.
|
||||
98
packages/avatar-react/README.md
Normal file
98
packages/avatar-react/README.md
Normal file
@ -0,0 +1,98 @@
|
||||
# @bible-strong/avatar-react
|
||||
|
||||
React 19 renderer for a validated Bible Strong `AvatarDefinition`. React and React DOM 19 are peer
|
||||
dependencies; `@bible-strong/avatar-core` is installed as a normal dependency.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
pnpm add @bible-strong/avatar-react react react-dom
|
||||
```
|
||||
|
||||
Import the package stylesheet once in the application entry point:
|
||||
|
||||
```tsx
|
||||
import { Avatar, type AvatarController } from '@bible-strong/avatar-react'
|
||||
import type { AvatarDefinition } from '@bible-strong/avatar-core'
|
||||
import '@bible-strong/avatar-react/styles.css'
|
||||
import { useRef } from 'react'
|
||||
|
||||
export function Assistant({ definition }: { definition: AvatarDefinition }) {
|
||||
const avatar = useRef<AvatarController>(null)
|
||||
return (
|
||||
<>
|
||||
<Avatar ref={avatar} definition={definition} defaultAnimation="idle" />
|
||||
<button onClick={() => avatar.current?.play('happy')}>Play happy</button>
|
||||
<button onClick={() => avatar.current?.setExpression('neutral')}>Neutral</button>
|
||||
</>
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
`play` and `setExpression` return `{ ok: true }` or a typed error with one of
|
||||
`unknown_animation`, `unavailable_standard_animation`, `unknown_expression` or
|
||||
`controlled_by_props`. `pause` freezes the exact timeline position, calling `play` with the paused
|
||||
key resumes it, and `stop` returns an uncontrolled avatar to `neutral`.
|
||||
|
||||
## Playback props
|
||||
|
||||
- `animation` and `expression` are mutually exclusive controlled targets. They take priority over
|
||||
defaults and imperative target changes.
|
||||
- `defaultAnimation` and `defaultExpression` initialize uncontrolled use. Animation autoplay is on
|
||||
by default when `defaultAnimation` is present; set `autoplay={false}` to show its first expression
|
||||
statically.
|
||||
- `onAnimationEnd` fires once when a `once` animation completes naturally.
|
||||
- `onExpressionChange` reports semantic expression changes.
|
||||
- `size`, `className`, `style` and `ariaLabel` customize layout without changing the definition.
|
||||
|
||||
The definition is validated once per immutable object reference and revalidated/reinitialized when
|
||||
that reference changes.
|
||||
|
||||
## Embedded and floating layout
|
||||
|
||||
Embedded mode is the default and stays in the caller's layout:
|
||||
|
||||
```tsx
|
||||
<div className="assistant-zone">
|
||||
<Avatar definition={definition} animation="idle" />
|
||||
</div>
|
||||
```
|
||||
|
||||
Floating mode uses fixed positioning and portals to `document.body` after hydration. Supply
|
||||
`portalContainer` for a dedicated overlay root. Server rendering emits a neutral fixed-size
|
||||
placeholder before the portal handoff.
|
||||
|
||||
```tsx
|
||||
<Avatar
|
||||
definition={definition}
|
||||
mode="floating"
|
||||
draggable
|
||||
initialPosition={{ right: 24, bottom: 24 }}
|
||||
constrainTo="viewport"
|
||||
zIndex={1000}
|
||||
/>
|
||||
```
|
||||
|
||||
`initialPosition` accepts `{ x, y }` or top/right/bottom/left anchors. A controlled `position` wins
|
||||
over it. `constrainTo` accepts `none`, `viewport` or `parent`; floating defaults to `viewport`, while
|
||||
embedded defaults to `none`. A constrained embedded parent needs a definite rendered size.
|
||||
|
||||
Dragging uses Pointer Events and direct transforms. `onPositionPreview` is limited to one callback
|
||||
per animation frame and `onPositionCommit` reports the final clamped point. `onDragStart` and
|
||||
`onDragEnd` bracket pointer movement. In controlled position mode callbacks report suggestions but
|
||||
the supplied position remains authoritative. When resized bounds invalidate a controlled position,
|
||||
`onPositionChange` reports the clamped suggestion. Resize re-clamping never emits a movement commit.
|
||||
|
||||
When `draggable` is enabled, arrow keys move by 10 px, Shift+Arrow by 1 px, and Escape cancels an
|
||||
active pointer drag. Accessible directional and reset buttons provide the same movement without a
|
||||
pointer.
|
||||
|
||||
## Styling hooks
|
||||
|
||||
The stylesheet exposes `.bs-avatar`, `.bs-avatar--embedded`, `.bs-avatar--floating`,
|
||||
`.bs-avatar--draggable`, `.bs-avatar--dragging`, `.bs-avatar__svg` and
|
||||
`.bs-avatar__move-controls`. Consumer `className` and `style` are applied to the outer wrapper.
|
||||
|
||||
The public component never exposes Studio IDs or document types. This package is private while
|
||||
copyright ownership, Apache-2.0 relicensing and repository metadata are confirmed. Local tarballs
|
||||
are for verification only and must not be published.
|
||||
45
packages/avatar-react/package.json
Normal file
45
packages/avatar-react/package.json
Normal file
@ -0,0 +1,45 @@
|
||||
{
|
||||
"name": "@bible-strong/avatar-react",
|
||||
"version": "0.1.0",
|
||||
"description": "React 19 renderer for Bible Strong procedural avatars.",
|
||||
"license": "AGPL-3.0-only",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"sideEffects": [
|
||||
"**/*.css"
|
||||
],
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md",
|
||||
"LICENSE"
|
||||
],
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./dist/index.d.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./styles.css": "./dist/styles.css"
|
||||
},
|
||||
"scripts": {
|
||||
"build": "vite build --config vite.config.ts && tsc -p tsconfig.build.json",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@bible-strong/avatar-core": "workspace:^"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^19.2.14",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"react": "19.2.3",
|
||||
"react-dom": "19.2.3",
|
||||
"typescript": "~6.0.3",
|
||||
"vite": "^8.0.13"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
}
|
||||
646
packages/avatar-react/src/Avatar.tsx
Normal file
646
packages/avatar-react/src/Avatar.tsx
Normal file
@ -0,0 +1,646 @@
|
||||
import {
|
||||
advanceAvatarPlayback,
|
||||
createAvatarPlaybackState,
|
||||
playAvatarAnimation,
|
||||
pauseAvatarPlayback,
|
||||
renderAvatarDefinition,
|
||||
renderAvatarFrame,
|
||||
resolveAnimation,
|
||||
resolveExpression,
|
||||
resumeAvatarPlayback,
|
||||
validateAvatarDefinition,
|
||||
type AnimationKey,
|
||||
type AvatarDefinition,
|
||||
type AvatarPlaybackState as CorePlaybackState,
|
||||
type AvatarRuntimeError as CoreRuntimeError,
|
||||
type ExpressionKey,
|
||||
} from '@bible-strong/avatar-core'
|
||||
import {
|
||||
useEffect,
|
||||
useId,
|
||||
useImperativeHandle,
|
||||
useRef,
|
||||
useState,
|
||||
type CSSProperties,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
type ReactElement,
|
||||
type Ref,
|
||||
} from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
|
||||
import './styles.css'
|
||||
|
||||
const validatedDefinitions = new WeakSet<object>()
|
||||
|
||||
const assertValidDefinition = (definition: AvatarDefinition) => {
|
||||
if (validatedDefinitions.has(definition)) return
|
||||
const result = validateAvatarDefinition(definition)
|
||||
if (!result.ok) {
|
||||
throw new Error(`Invalid avatar definition: ${result.errors[0]?.message}`)
|
||||
}
|
||||
validatedDefinitions.add(definition)
|
||||
}
|
||||
|
||||
export type AvatarRuntimeError =
|
||||
| CoreRuntimeError
|
||||
| {
|
||||
code: 'controlled_by_props'
|
||||
key: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export type AvatarCommandResult = { ok: true } | { ok: false; error: AvatarRuntimeError }
|
||||
|
||||
export type AvatarPlaybackState = Pick<
|
||||
CorePlaybackState,
|
||||
'activeAnimation' | 'activeExpression' | 'status'
|
||||
>
|
||||
|
||||
export type AvatarController = {
|
||||
play(animation: AnimationKey): AvatarCommandResult
|
||||
setExpression(expression: ExpressionKey): AvatarCommandResult
|
||||
pause(): void
|
||||
stop(): void
|
||||
getState(): AvatarPlaybackState
|
||||
}
|
||||
|
||||
export type AvatarPosition = { x: number; y: number }
|
||||
export type FloatingInitialPosition =
|
||||
AvatarPosition | { top?: number; right?: number; bottom?: number; left?: number }
|
||||
|
||||
export type AvatarProps = {
|
||||
definition: AvatarDefinition
|
||||
ref?: Ref<AvatarController>
|
||||
animation?: AnimationKey
|
||||
expression?: ExpressionKey
|
||||
defaultAnimation?: AnimationKey
|
||||
defaultExpression?: ExpressionKey
|
||||
autoplay?: boolean
|
||||
size?: number | string
|
||||
className?: string
|
||||
style?: CSSProperties
|
||||
mode?: 'embedded' | 'floating'
|
||||
portalContainer?: HTMLElement
|
||||
draggable?: boolean
|
||||
constrainTo?: 'none' | 'viewport' | 'parent'
|
||||
position?: AvatarPosition
|
||||
initialPosition?: FloatingInitialPosition
|
||||
zIndex?: number
|
||||
ariaLabel?: string
|
||||
onPositionPreview?: (position: AvatarPosition) => void
|
||||
onPositionCommit?: (position: AvatarPosition) => void
|
||||
/** Suggested clamped position when controlled bounds change. */
|
||||
onPositionChange?: (position: AvatarPosition) => void
|
||||
onDragStart?: () => void
|
||||
onDragEnd?: (position: AvatarPosition) => void
|
||||
onAnimationEnd?: (animation: AnimationKey) => void
|
||||
onExpressionChange?: (expression: ExpressionKey) => void
|
||||
}
|
||||
|
||||
const samePlayback = (left: CorePlaybackState, right: CorePlaybackState) =>
|
||||
left.activeAnimation === right.activeAnimation &&
|
||||
left.activeExpression === right.activeExpression &&
|
||||
left.status === right.status &&
|
||||
left.stepIndex === right.stepIndex &&
|
||||
left.direction === right.direction &&
|
||||
left.phase === right.phase &&
|
||||
left.phaseStartedAt === right.phaseStartedAt &&
|
||||
left.transitionFrom === right.transitionFrom &&
|
||||
left.blinkDueAt === right.blinkDueAt &&
|
||||
left.blinkStartedAt === right.blinkStartedAt
|
||||
|
||||
const samePosition = (left: AvatarPosition, right: AvatarPosition) =>
|
||||
left.x === right.x && left.y === right.y
|
||||
|
||||
const sizeInPixels = (size: number | string | undefined) => (typeof size === 'number' ? size : 240)
|
||||
|
||||
const initialPoint = (
|
||||
value: FloatingInitialPosition | undefined,
|
||||
width: number,
|
||||
height: number
|
||||
): AvatarPosition => {
|
||||
if (!value) return { x: 32, y: 32 }
|
||||
if ('x' in value && 'y' in value) return { x: value.x, y: value.y }
|
||||
const x = value.left ?? Math.max(0, width - (value.right ?? 32))
|
||||
const y = value.top ?? Math.max(0, height - (value.bottom ?? 32))
|
||||
return { x, y }
|
||||
}
|
||||
|
||||
export function Avatar({
|
||||
definition,
|
||||
ref,
|
||||
animation,
|
||||
expression,
|
||||
defaultAnimation,
|
||||
defaultExpression,
|
||||
autoplay,
|
||||
size = 240,
|
||||
className,
|
||||
style,
|
||||
mode = 'embedded',
|
||||
portalContainer,
|
||||
draggable = false,
|
||||
constrainTo,
|
||||
position,
|
||||
initialPosition,
|
||||
zIndex = 1000,
|
||||
ariaLabel = 'Procedural avatar',
|
||||
onPositionPreview,
|
||||
onPositionCommit,
|
||||
onPositionChange,
|
||||
onDragStart,
|
||||
onDragEnd,
|
||||
onAnimationEnd,
|
||||
onExpressionChange,
|
||||
}: AvatarProps): ReactElement {
|
||||
if (animation !== undefined && expression !== undefined) {
|
||||
throw new Error('Avatar accepts either animation or expression, not both.')
|
||||
}
|
||||
assertValidDefinition(definition)
|
||||
|
||||
const clipId = `${useId().replaceAll(':', '')}-head`
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
const clipPathRef = useRef<SVGPathElement>(null)
|
||||
const headPathRef = useRef<SVGPathElement>(null)
|
||||
const leftPathRef = useRef<SVGPathElement>(null)
|
||||
const rightPathRef = useRef<SVGPathElement>(null)
|
||||
const backPathRefs = useRef<(SVGPathElement | null)[]>([])
|
||||
const frontPathRefs = useRef<(SVGPathElement | null)[]>([])
|
||||
const defaultPlaybackStarted = useRef(false)
|
||||
const floatingPositionInitialized = useRef(false)
|
||||
const previewFrame = useRef<number | undefined>(undefined)
|
||||
const completedAnimation = useRef<AnimationKey | undefined>(undefined)
|
||||
const playbackRef = useRef<CorePlaybackState>(createAvatarPlaybackState())
|
||||
const drag = useRef<
|
||||
| {
|
||||
pointerId: number
|
||||
pointer: AvatarPosition
|
||||
origin: AvatarPosition
|
||||
current: AvatarPosition
|
||||
}
|
||||
| undefined
|
||||
>(undefined)
|
||||
const [mounted, setMounted] = useState(false)
|
||||
const [internalPosition, setInternalPosition] = useState<AvatarPosition>({ x: 0, y: 0 })
|
||||
const [playback, setPlayback] = useState<CorePlaybackState>(() => {
|
||||
const key = animation ?? defaultAnimation
|
||||
if (key) {
|
||||
const result = resolveAnimation(definition, key)
|
||||
if (result.ok) {
|
||||
return {
|
||||
...createAvatarPlaybackState(),
|
||||
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
|
||||
}
|
||||
}
|
||||
}
|
||||
const keyExpression = expression ?? defaultExpression ?? 'neutral'
|
||||
return { ...createAvatarPlaybackState(), activeExpression: keyExpression }
|
||||
})
|
||||
playbackRef.current = playback
|
||||
const authoritativePosition = position ?? internalPosition
|
||||
const effectiveConstraint = constrainTo ?? (mode === 'floating' ? 'viewport' : 'none')
|
||||
|
||||
const applyTransform = (point: AvatarPosition) => {
|
||||
if (wrapperRef.current) {
|
||||
wrapperRef.current.style.transform = `translate3d(${point.x}px, ${point.y}px, 0)`
|
||||
}
|
||||
}
|
||||
|
||||
const clampPosition = (point: AvatarPosition): AvatarPosition => {
|
||||
const element = wrapperRef.current
|
||||
if (!element || effectiveConstraint === 'none') return point
|
||||
const width = element.offsetWidth
|
||||
const height = element.offsetHeight
|
||||
if (effectiveConstraint === 'viewport') {
|
||||
return {
|
||||
x: Math.min(Math.max(point.x, 0), Math.max(window.innerWidth - width, 0)),
|
||||
y: Math.min(Math.max(point.y, 0), Math.max(window.innerHeight - height, 0)),
|
||||
}
|
||||
}
|
||||
const parent = element.parentElement
|
||||
if (!parent) return point
|
||||
return {
|
||||
x: Math.min(Math.max(point.x, 0), Math.max(parent.clientWidth - width, 0)),
|
||||
y: Math.min(Math.max(point.y, 0), Math.max(parent.clientHeight - height, 0)),
|
||||
}
|
||||
}
|
||||
|
||||
const commitPosition = (next: AvatarPosition) => {
|
||||
const clamped = clampPosition(next)
|
||||
applyTransform(position ?? clamped)
|
||||
if (!position) setInternalPosition(clamped)
|
||||
onPositionCommit?.(clamped)
|
||||
return clamped
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setMounted(true)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
defaultPlaybackStarted.current = false
|
||||
completedAnimation.current = undefined
|
||||
const key = animation ?? defaultAnimation
|
||||
if (key) {
|
||||
const result = resolveAnimation(definition, key)
|
||||
if (result.ok) {
|
||||
const next = {
|
||||
...createAvatarPlaybackState(),
|
||||
activeExpression: result.value.steps[0]?.expression ?? 'neutral',
|
||||
}
|
||||
playbackRef.current = next
|
||||
setPlayback(next)
|
||||
return
|
||||
}
|
||||
}
|
||||
const keyExpression = expression ?? defaultExpression ?? 'neutral'
|
||||
const resolved = resolveExpression(definition, keyExpression)
|
||||
const next = {
|
||||
...createAvatarPlaybackState(),
|
||||
activeExpression: resolved.ok ? keyExpression : 'neutral',
|
||||
}
|
||||
playbackRef.current = next
|
||||
setPlayback(next)
|
||||
}, [definition])
|
||||
|
||||
useEffect(() => {
|
||||
if (
|
||||
defaultPlaybackStarted.current ||
|
||||
animation !== undefined ||
|
||||
expression !== undefined ||
|
||||
defaultAnimation === undefined ||
|
||||
autoplay === false
|
||||
) {
|
||||
return
|
||||
}
|
||||
defaultPlaybackStarted.current = true
|
||||
const result = playAvatarAnimation(definition, defaultAnimation, performance.now())
|
||||
if (result.ok) setPlayback(result.value)
|
||||
}, [animation, autoplay, defaultAnimation, definition, expression])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode === 'floating') {
|
||||
if (floatingPositionInitialized.current) {
|
||||
applyTransform(position ?? internalPosition)
|
||||
return
|
||||
}
|
||||
floatingPositionInitialized.current = true
|
||||
const fallbackPixels = sizeInPixels(size)
|
||||
const measuredWidth = wrapperRef.current?.offsetWidth || fallbackPixels
|
||||
const measuredHeight = wrapperRef.current?.offsetHeight || fallbackPixels
|
||||
const point = initialPoint(
|
||||
initialPosition,
|
||||
window.innerWidth - measuredWidth,
|
||||
window.innerHeight - measuredHeight
|
||||
)
|
||||
const clamped = clampPosition(position ?? point)
|
||||
if (!position) setInternalPosition(clamped)
|
||||
applyTransform(position ?? clamped)
|
||||
} else {
|
||||
floatingPositionInitialized.current = false
|
||||
applyTransform(position ?? internalPosition)
|
||||
}
|
||||
}, [mode, position, size, internalPosition])
|
||||
|
||||
useEffect(() => {
|
||||
if (!mounted) return
|
||||
const reClamp = () => {
|
||||
const current = position ?? internalPosition
|
||||
const clamped = clampPosition(current)
|
||||
if (samePosition(current, clamped)) return
|
||||
if (position) {
|
||||
applyTransform(position)
|
||||
onPositionChange?.(clamped)
|
||||
return
|
||||
}
|
||||
applyTransform(clamped)
|
||||
setInternalPosition(clamped)
|
||||
}
|
||||
const observer = typeof ResizeObserver === 'undefined' ? undefined : new ResizeObserver(reClamp)
|
||||
if (wrapperRef.current) observer?.observe(wrapperRef.current)
|
||||
if (effectiveConstraint === 'parent' && wrapperRef.current?.parentElement && observer) {
|
||||
observer.observe(wrapperRef.current.parentElement)
|
||||
}
|
||||
window.addEventListener('resize', reClamp)
|
||||
return () => {
|
||||
observer?.disconnect()
|
||||
window.removeEventListener('resize', reClamp)
|
||||
}
|
||||
}, [mounted, position, internalPosition, effectiveConstraint, onPositionChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (expression !== undefined) {
|
||||
const resolved = resolveExpression(definition, expression)
|
||||
if (resolved.ok) {
|
||||
setPlayback({ ...createAvatarPlaybackState(), activeExpression: expression })
|
||||
}
|
||||
return
|
||||
}
|
||||
if (animation !== undefined) {
|
||||
const result = playAvatarAnimation(definition, animation, performance.now())
|
||||
if (result.ok) setPlayback(result.value)
|
||||
}
|
||||
}, [animation, definition, expression])
|
||||
|
||||
useEffect(() => {
|
||||
onExpressionChange?.(playback.activeExpression)
|
||||
}, [playback.activeExpression, onExpressionChange])
|
||||
|
||||
useEffect(() => {
|
||||
if (playback.status !== 'playing') return
|
||||
let frame = 0
|
||||
const tick = (now: number) => {
|
||||
const current = playbackRef.current
|
||||
const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
||||
const next = advanceAvatarPlayback(definition, current, now, {
|
||||
random: Math.random,
|
||||
reduceMotion,
|
||||
})
|
||||
playbackRef.current = next
|
||||
if (!samePlayback(current, next)) setPlayback(next)
|
||||
if (
|
||||
current.status === 'playing' &&
|
||||
next.status === 'stopped' &&
|
||||
current.activeAnimation &&
|
||||
completedAnimation.current !== current.activeAnimation
|
||||
) {
|
||||
completedAnimation.current = current.activeAnimation
|
||||
onAnimationEnd?.(current.activeAnimation)
|
||||
}
|
||||
const frameScene = renderAvatarFrame(definition, next, now, {
|
||||
random: Math.random,
|
||||
reduceMotion,
|
||||
})
|
||||
headPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
|
||||
clipPathRef.current?.setAttribute('d', frameScene.geometry.headPath)
|
||||
headPathRef.current?.setAttribute('fill', frameScene.colors.body)
|
||||
leftPathRef.current?.setAttribute('d', frameScene.geometry.leftPath)
|
||||
leftPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
|
||||
leftPathRef.current?.setAttribute('opacity', frameScene.geometry.leftVisible ? '1' : '0')
|
||||
rightPathRef.current?.setAttribute('d', frameScene.geometry.rightPath)
|
||||
rightPathRef.current?.setAttribute('fill', frameScene.colors.eyes)
|
||||
rightPathRef.current?.setAttribute('opacity', frameScene.geometry.rightVisible ? '1' : '0')
|
||||
frameScene.geometry.backPaths.forEach((path, index) => {
|
||||
backPathRefs.current[index]?.setAttribute('d', path)
|
||||
backPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
|
||||
})
|
||||
frameScene.geometry.frontPaths.forEach((path, index) => {
|
||||
frontPathRefs.current[index]?.setAttribute('d', path)
|
||||
frontPathRefs.current[index]?.setAttribute('fill', frameScene.colors.body)
|
||||
})
|
||||
if (next.status === 'playing') frame = requestAnimationFrame(tick)
|
||||
}
|
||||
frame = requestAnimationFrame(tick)
|
||||
return () => cancelAnimationFrame(frame)
|
||||
}, [definition, playback.status, onAnimationEnd])
|
||||
|
||||
const controlled = animation !== undefined || expression !== undefined
|
||||
useImperativeHandle(ref, () => ({
|
||||
play(key) {
|
||||
if (controlled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'controlled_by_props',
|
||||
key,
|
||||
message: 'Playback is controlled by Avatar props.',
|
||||
},
|
||||
}
|
||||
}
|
||||
const current = playbackRef.current
|
||||
if (
|
||||
current.status === 'paused' &&
|
||||
current.activeAnimation === key &&
|
||||
current.pausedAt !== undefined
|
||||
) {
|
||||
const now = performance.now()
|
||||
const resumed = resumeAvatarPlayback(current, now)
|
||||
playbackRef.current = resumed
|
||||
setPlayback(resumed)
|
||||
return { ok: true }
|
||||
}
|
||||
const result = playAvatarAnimation(definition, key, performance.now())
|
||||
if (!result.ok) return { ok: false, error: result.error }
|
||||
completedAnimation.current = undefined
|
||||
playbackRef.current = result.value
|
||||
setPlayback(result.value)
|
||||
return { ok: true }
|
||||
},
|
||||
setExpression(key) {
|
||||
if (controlled) {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
code: 'controlled_by_props',
|
||||
key,
|
||||
message: 'Expression is controlled by Avatar props.',
|
||||
},
|
||||
}
|
||||
}
|
||||
const result = resolveExpression(definition, key)
|
||||
if (!result.ok) return { ok: false, error: result.error }
|
||||
const next = { ...createAvatarPlaybackState(), activeExpression: key }
|
||||
playbackRef.current = next
|
||||
setPlayback(next)
|
||||
return { ok: true }
|
||||
},
|
||||
pause() {
|
||||
const current = playbackRef.current
|
||||
if (current.status !== 'playing') return
|
||||
const next = pauseAvatarPlayback(current, performance.now())
|
||||
playbackRef.current = next
|
||||
setPlayback(next)
|
||||
},
|
||||
stop() {
|
||||
if (!controlled) {
|
||||
const next = createAvatarPlaybackState()
|
||||
playbackRef.current = next
|
||||
setPlayback(next)
|
||||
}
|
||||
},
|
||||
getState() {
|
||||
const current = playbackRef.current
|
||||
return {
|
||||
...(current.activeAnimation ? { activeAnimation: current.activeAnimation } : {}),
|
||||
activeExpression: current.activeExpression,
|
||||
status: current.status,
|
||||
}
|
||||
},
|
||||
}))
|
||||
|
||||
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (!draggable || event.button !== 0) return
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
drag.current = {
|
||||
pointerId: event.pointerId,
|
||||
pointer: { x: event.clientX, y: event.clientY },
|
||||
origin: authoritativePosition,
|
||||
current: authoritativePosition,
|
||||
}
|
||||
event.currentTarget.classList.add('bs-avatar--dragging')
|
||||
onDragStart?.()
|
||||
}
|
||||
|
||||
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
const active = drag.current
|
||||
if (!active || active.pointerId !== event.pointerId) return
|
||||
const next = clampPosition({
|
||||
x: active.origin.x + event.clientX - active.pointer.x,
|
||||
y: active.origin.y + event.clientY - active.pointer.y,
|
||||
})
|
||||
active.current = next
|
||||
applyTransform(next)
|
||||
if (previewFrame.current === undefined) {
|
||||
previewFrame.current = requestAnimationFrame(() => {
|
||||
previewFrame.current = undefined
|
||||
if (drag.current) onPositionPreview?.(drag.current.current)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const endDrag = (cancelled: boolean) => {
|
||||
const active = drag.current
|
||||
if (!active) return
|
||||
if (previewFrame.current !== undefined) cancelAnimationFrame(previewFrame.current)
|
||||
previewFrame.current = undefined
|
||||
const finalPosition = commitPosition(cancelled ? active.origin : active.current)
|
||||
wrapperRef.current?.classList.remove('bs-avatar--dragging')
|
||||
drag.current = undefined
|
||||
if (
|
||||
wrapperRef.current?.hasPointerCapture?.(active.pointerId) &&
|
||||
wrapperRef.current.releasePointerCapture
|
||||
) {
|
||||
wrapperRef.current.releasePointerCapture(active.pointerId)
|
||||
}
|
||||
onDragEnd?.(finalPosition)
|
||||
}
|
||||
|
||||
const moveByKeyboard = (x: number, y: number) => {
|
||||
const next = commitPosition({ x: authoritativePosition.x + x, y: authoritativePosition.y + y })
|
||||
onPositionPreview?.(next)
|
||||
}
|
||||
|
||||
const scene = renderAvatarDefinition(
|
||||
definition,
|
||||
mode === 'floating' && !mounted ? 'neutral' : playback.activeExpression
|
||||
)
|
||||
const wrapper = (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={[
|
||||
'bs-avatar',
|
||||
`bs-avatar--${mode}`,
|
||||
draggable ? 'bs-avatar--draggable' : '',
|
||||
className ?? '',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ')}
|
||||
style={{
|
||||
...style,
|
||||
width: size,
|
||||
height: size,
|
||||
zIndex: mode === 'floating' ? zIndex : style?.zIndex,
|
||||
transform: `translate3d(${authoritativePosition.x}px, ${authoritativePosition.y}px, 0)`,
|
||||
}}
|
||||
role={draggable ? 'group' : 'img'}
|
||||
aria-label={ariaLabel}
|
||||
aria-description={
|
||||
draggable ? 'Use arrow keys or move controls to reposition the avatar.' : undefined
|
||||
}
|
||||
tabIndex={draggable ? 0 : undefined}
|
||||
onPointerDown={startDrag}
|
||||
onPointerMove={moveDrag}
|
||||
onPointerUp={() => endDrag(false)}
|
||||
onPointerCancel={() => endDrag(true)}
|
||||
onLostPointerCapture={() => endDrag(false)}
|
||||
onKeyDown={event => {
|
||||
if (!draggable) return
|
||||
const amount = event.shiftKey ? 1 : 10
|
||||
if (event.key === 'ArrowLeft') moveByKeyboard(-amount, 0)
|
||||
else if (event.key === 'ArrowRight') moveByKeyboard(amount, 0)
|
||||
else if (event.key === 'ArrowUp') moveByKeyboard(0, -amount)
|
||||
else if (event.key === 'ArrowDown') moveByKeyboard(0, amount)
|
||||
else if (event.key === 'Escape') endDrag(true)
|
||||
else return
|
||||
event.preventDefault()
|
||||
}}
|
||||
>
|
||||
<svg className="bs-avatar__svg" viewBox="-150 -150 300 300" aria-hidden="true">
|
||||
<defs>
|
||||
<clipPath id={clipId}>
|
||||
<path ref={clipPathRef} d={scene.geometry.headPath} />
|
||||
</clipPath>
|
||||
</defs>
|
||||
{scene.geometry.backPaths.map((path, index) => (
|
||||
<path
|
||||
ref={element => {
|
||||
backPathRefs.current[index] = element
|
||||
}}
|
||||
d={path}
|
||||
fill={scene.colors.body}
|
||||
key={`back-${index}`}
|
||||
/>
|
||||
))}
|
||||
<path ref={headPathRef} d={scene.geometry.headPath} fill={scene.colors.body} />
|
||||
<g clipPath={`url(#${clipId})`} fill={scene.colors.eyes}>
|
||||
<path
|
||||
ref={leftPathRef}
|
||||
d={scene.geometry.leftPath}
|
||||
opacity={scene.geometry.leftVisible ? 1 : 0}
|
||||
/>
|
||||
<path
|
||||
ref={rightPathRef}
|
||||
d={scene.geometry.rightPath}
|
||||
opacity={scene.geometry.rightVisible ? 1 : 0}
|
||||
/>
|
||||
</g>
|
||||
{scene.geometry.frontPaths.map((path, index) => (
|
||||
<path
|
||||
ref={element => {
|
||||
frontPathRefs.current[index] = element
|
||||
}}
|
||||
d={path}
|
||||
fill={scene.colors.body}
|
||||
key={`front-${index}`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
{draggable && (
|
||||
<div className="bs-avatar__move-controls">
|
||||
{(
|
||||
[
|
||||
['Move avatar left', -10, 0, '\u2190'],
|
||||
['Move avatar right', 10, 0, '\u2192'],
|
||||
['Move avatar up', 0, -10, '\u2191'],
|
||||
['Move avatar down', 0, 10, '\u2193'],
|
||||
] as const
|
||||
).map(([label, x, y, symbol]) => (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={label}
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
onClick={() => moveByKeyboard(x, y)}
|
||||
key={label}
|
||||
>
|
||||
<span aria-hidden="true">{symbol}</span>
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
className="bs-avatar__reset"
|
||||
type="button"
|
||||
aria-label="Reset avatar position"
|
||||
onPointerDown={event => event.stopPropagation()}
|
||||
onClick={() => commitPosition({ x: 0, y: 0 })}
|
||||
>
|
||||
<span aria-hidden="true">\u21ba</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
if (mode === 'floating' && mounted) {
|
||||
return createPortal(wrapper, portalContainer ?? document.body)
|
||||
}
|
||||
return wrapper
|
||||
}
|
||||
538
packages/avatar-react/src/__tests__/avatar-test.tsx
Normal file
538
packages/avatar-react/src/__tests__/avatar-test.tsx
Normal file
@ -0,0 +1,538 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { renderAvatarDefinition, type AvatarDefinition } from '@bible-strong/avatar-core'
|
||||
import { act, createRef, Profiler, StrictMode } from 'react'
|
||||
import { hydrateRoot } from 'react-dom/client'
|
||||
import { renderToString } from 'react-dom/server'
|
||||
import { fireEvent, render } from '@testing-library/react'
|
||||
import { vi } from 'vitest'
|
||||
|
||||
import { Avatar, type AvatarController } from '../Avatar'
|
||||
|
||||
let resizeCallbacks: ResizeObserverCallback[] = []
|
||||
|
||||
class TestResizeObserver implements ResizeObserver {
|
||||
constructor(callback: ResizeObserverCallback) {
|
||||
resizeCallbacks.push(callback)
|
||||
}
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
const expression = {
|
||||
head: { x: 0, y: 0, z: 0 },
|
||||
eyes: {
|
||||
left: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
|
||||
right: { width: 28, height: 38, x: 0, y: 0, angle: 0 },
|
||||
spacing: 54,
|
||||
},
|
||||
perspective: 1,
|
||||
motion: { eyes: 'none', body: 'none' },
|
||||
} as const
|
||||
|
||||
const definition: AvatarDefinition = {
|
||||
schema: 'bible-strong/avatar-definition',
|
||||
schemaVersion: 1,
|
||||
name: 'React fixture',
|
||||
body: {
|
||||
primary: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
||||
nodes: [],
|
||||
},
|
||||
colors: { body: '#5b7fe5', eyes: '#111316' },
|
||||
expressions: { neutral: expression, smile: { ...expression, head: { x: 0, y: 10, z: 0 } } },
|
||||
expressionOrder: ['neutral', 'smile'],
|
||||
animations: {
|
||||
greet: {
|
||||
playbackMode: 'loop',
|
||||
steps: [{ expression: 'smile', holdMs: 1_000, transitionMs: 100, transition: 'smooth' }],
|
||||
blink: {
|
||||
enabled: false,
|
||||
initialDelayMs: 0,
|
||||
minIntervalMs: 1_000,
|
||||
maxIntervalMs: 1_000,
|
||||
durationMs: 100,
|
||||
},
|
||||
},
|
||||
'wave-once': {
|
||||
playbackMode: 'once',
|
||||
steps: [{ expression: 'smile', holdMs: 100, transitionMs: 0, transition: 'snappy' }],
|
||||
blink: {
|
||||
enabled: false,
|
||||
initialDelayMs: 0,
|
||||
minIntervalMs: 1_000,
|
||||
maxIntervalMs: 1_000,
|
||||
durationMs: 100,
|
||||
},
|
||||
},
|
||||
},
|
||||
animationOrder: ['greet', 'wave-once'],
|
||||
standardAnimationSet: 1,
|
||||
}
|
||||
|
||||
beforeAll(() => {
|
||||
HTMLElement.prototype.setPointerCapture = () => undefined
|
||||
HTMLElement.prototype.hasPointerCapture = () => true
|
||||
HTMLElement.prototype.releasePointerCapture = () => undefined
|
||||
window.matchMedia = () =>
|
||||
({
|
||||
matches: false,
|
||||
addEventListener: () => undefined,
|
||||
removeEventListener: () => undefined,
|
||||
}) as unknown as MediaQueryList
|
||||
globalThis.ResizeObserver = TestResizeObserver
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
resizeCallbacks = []
|
||||
Object.defineProperties(window, {
|
||||
innerWidth: { configurable: true, value: 1_024 },
|
||||
innerHeight: { configurable: true, value: 768 },
|
||||
})
|
||||
})
|
||||
|
||||
describe('@bible-strong/avatar-react', () => {
|
||||
it('renders semantic SVG geometry in embedded mode', () => {
|
||||
const view = render(<Avatar definition={definition} ariaLabel="Assistant avatar" />)
|
||||
const avatar = view.getByRole('img', { name: 'Assistant avatar' })
|
||||
expect(avatar.classList.contains('bs-avatar--embedded')).toBe(true)
|
||||
expect(avatar.querySelector('svg path')).not.toBeNull()
|
||||
expect(document.body.querySelector('.bs-avatar--floating')).toBeNull()
|
||||
})
|
||||
|
||||
it('exposes semantic imperative controls without Studio identifiers', () => {
|
||||
const controller = createRef<AvatarController>()
|
||||
render(<Avatar definition={definition} ref={controller} />)
|
||||
|
||||
let result: ReturnType<AvatarController['setExpression']> | undefined
|
||||
act(() => {
|
||||
result = controller.current?.setExpression('smile')
|
||||
})
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(controller.current?.getState()).toMatchObject({
|
||||
activeExpression: 'smile',
|
||||
status: 'stopped',
|
||||
})
|
||||
expect(controller.current?.play('missing')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'unknown_animation', key: 'missing' },
|
||||
})
|
||||
act(() => {
|
||||
result = controller.current?.play('greet')
|
||||
})
|
||||
expect(result).toEqual({ ok: true })
|
||||
expect(controller.current?.getState()).toMatchObject({
|
||||
activeAnimation: 'greet',
|
||||
activeExpression: 'smile',
|
||||
status: 'playing',
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects imperative target changes when playback is controlled by props', () => {
|
||||
const controller = createRef<AvatarController>()
|
||||
render(<Avatar definition={definition} expression="neutral" ref={controller} />)
|
||||
expect(controller.current?.setExpression('smile')).toMatchObject({
|
||||
ok: false,
|
||||
error: { code: 'controlled_by_props' },
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects simultaneous controlled animation and expression props', () => {
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
expect(() =>
|
||||
render(<Avatar definition={definition} animation="greet" expression="neutral" />)
|
||||
).toThrow('Avatar accepts either animation or expression, not both.')
|
||||
errors.mockRestore()
|
||||
})
|
||||
|
||||
it('honors uncontrolled defaults without autoplay when requested', () => {
|
||||
const controller = createRef<AvatarController>()
|
||||
render(
|
||||
<Avatar definition={definition} defaultAnimation="greet" autoplay={false} ref={controller} />
|
||||
)
|
||||
expect(controller.current?.getState()).toEqual({
|
||||
activeExpression: 'smile',
|
||||
status: 'stopped',
|
||||
})
|
||||
})
|
||||
|
||||
it('portals floating mode to the document body after mount', () => {
|
||||
const host = document.createElement('section')
|
||||
document.body.append(host)
|
||||
const view = render(<Avatar definition={definition} mode="floating" />, { container: host })
|
||||
expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
|
||||
view.unmount()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('normalizes an uncontrolled floating initial position only once', () => {
|
||||
const view = render(
|
||||
<Avatar definition={definition} mode="floating" initialPosition={{ x: 10, y: 12 }} />
|
||||
)
|
||||
expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
|
||||
view.rerender(
|
||||
<Avatar definition={definition} mode="floating" initialPosition={{ x: 80, y: 90 }} />
|
||||
)
|
||||
expect(view.getByRole('img').style.transform).toBe('translate3d(10px, 12px, 0)')
|
||||
})
|
||||
|
||||
it('server-renders a neutral floating placeholder before portal handoff', () => {
|
||||
const markup = renderToString(
|
||||
<Avatar definition={definition} mode="floating" defaultExpression="smile" size={180} />
|
||||
)
|
||||
const neutral = renderAvatarDefinition(definition, 'neutral')
|
||||
expect(markup).toContain('bs-avatar--floating')
|
||||
expect(markup).toContain('width:180px;height:180px')
|
||||
expect(markup).toContain(`d="${neutral.geometry.leftPath}"`)
|
||||
})
|
||||
|
||||
it('hydrates the floating placeholder before moving it to a portal without warnings', async () => {
|
||||
const host = document.createElement('section')
|
||||
const markup = renderToString(<Avatar definition={definition} mode="floating" size={180} />)
|
||||
host.innerHTML = markup
|
||||
document.body.append(host)
|
||||
const errors = vi.spyOn(console, 'error').mockImplementation(() => undefined)
|
||||
let root: ReturnType<typeof hydrateRoot> | undefined
|
||||
await act(async () => {
|
||||
root = hydrateRoot(host, <Avatar definition={definition} mode="floating" size={180} />)
|
||||
await Promise.resolve()
|
||||
})
|
||||
expect(errors).not.toHaveBeenCalled()
|
||||
expect(host.querySelector('.bs-avatar--floating')).toBeNull()
|
||||
expect(document.body.querySelector('.bs-avatar--floating')).not.toBeNull()
|
||||
act(() => root?.unmount())
|
||||
errors.mockRestore()
|
||||
host.remove()
|
||||
})
|
||||
|
||||
it('moves by pointer without React state updates on every pointer move and commits once', () => {
|
||||
const previews: { x: number; y: number }[] = []
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
draggable
|
||||
constrainTo="none"
|
||||
onPositionPreview={point => previews.push(point)}
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
fireEvent.pointerDown(avatar, { pointerId: 1, button: 0, clientX: 10, clientY: 10 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
|
||||
expect(avatar.style.transform).toBe('translate3d(25px, 35px, 0)')
|
||||
fireEvent.pointerUp(avatar, { pointerId: 1, clientX: 35, clientY: 45 })
|
||||
expect(commits).toEqual([{ x: 25, y: 35 }])
|
||||
expect(previews.length).toBeLessThanOrEqual(1)
|
||||
})
|
||||
|
||||
it('limits drag previews to one callback per animation frame with the latest position', () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frames.set(++nextFrame, callback)
|
||||
return nextFrame
|
||||
})
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
frames.delete(id)
|
||||
})
|
||||
const previews: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
draggable
|
||||
constrainTo="none"
|
||||
onPositionPreview={point => previews.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
fireEvent.pointerDown(avatar, { pointerId: 6, button: 0, clientX: 0, clientY: 0 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 10, clientY: 12 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 6, clientX: 20, clientY: 24 })
|
||||
expect(previews).toEqual([])
|
||||
act(() => {
|
||||
const callbacks = [...frames.values()]
|
||||
frames.clear()
|
||||
callbacks.forEach(callback => callback(16))
|
||||
})
|
||||
expect(previews).toEqual([{ x: 20, y: 24 }])
|
||||
fireEvent.pointerUp(avatar, { pointerId: 6 })
|
||||
request.mockRestore()
|
||||
cancel.mockRestore()
|
||||
})
|
||||
|
||||
it('does not render React once per pointer movement', () => {
|
||||
let renders = 0
|
||||
const view = render(
|
||||
<Profiler id="avatar" onRender={() => renders++}>
|
||||
<Avatar definition={definition} draggable constrainTo="none" />
|
||||
</Profiler>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
const beforeMoves = renders
|
||||
fireEvent.pointerDown(avatar, { pointerId: 2, button: 0, clientX: 0, clientY: 0 })
|
||||
for (let index = 1; index <= 20; index++) {
|
||||
fireEvent.pointerMove(avatar, { pointerId: 2, clientX: index, clientY: index })
|
||||
}
|
||||
expect(renders).toBe(beforeMoves)
|
||||
})
|
||||
|
||||
it('restores the drag origin on pointer cancel', () => {
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
draggable
|
||||
constrainTo="none"
|
||||
initialPosition={{ x: 5, y: 7 }}
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
fireEvent.pointerDown(avatar, { pointerId: 3, button: 0, clientX: 0, clientY: 0 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 3, clientX: 40, clientY: 50 })
|
||||
fireEvent.pointerCancel(avatar, { pointerId: 3 })
|
||||
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
|
||||
expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('restores the drag origin when Escape cancels an active drag', () => {
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
draggable
|
||||
constrainTo="none"
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
fireEvent.pointerDown(avatar, { pointerId: 7, button: 0, clientX: 0, clientY: 0 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 7, clientX: 30, clientY: 40 })
|
||||
fireEvent.keyDown(avatar, { key: 'Escape' })
|
||||
expect(avatar.style.transform).toBe('translate3d(0px, 0px, 0)')
|
||||
expect(commits).toEqual([{ x: 0, y: 0 }])
|
||||
})
|
||||
|
||||
it('clamps pointer movement to an embedded parent', () => {
|
||||
const view = render(<Avatar definition={definition} draggable constrainTo="parent" size={40} />)
|
||||
const avatar = view.getByRole('group')
|
||||
Object.defineProperties(avatar, {
|
||||
offsetWidth: { configurable: true, value: 40 },
|
||||
offsetHeight: { configurable: true, value: 40 },
|
||||
})
|
||||
Object.defineProperties(avatar.parentElement!, {
|
||||
clientWidth: { configurable: true, value: 100 },
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
})
|
||||
fireEvent.pointerDown(avatar, { pointerId: 4, button: 0, clientX: 0, clientY: 0 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 4, clientX: 200, clientY: 200 })
|
||||
fireEvent.pointerUp(avatar, { pointerId: 4 })
|
||||
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
|
||||
})
|
||||
|
||||
it('keeps controlled position authoritative while reporting a constrained commit', () => {
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
draggable
|
||||
constrainTo="parent"
|
||||
size={40}
|
||||
position={{ x: 10, y: 12 }}
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
Object.defineProperties(avatar, {
|
||||
offsetWidth: { configurable: true, value: 40 },
|
||||
offsetHeight: { configurable: true, value: 40 },
|
||||
})
|
||||
Object.defineProperties(avatar.parentElement!, {
|
||||
clientWidth: { configurable: true, value: 100 },
|
||||
clientHeight: { configurable: true, value: 100 },
|
||||
})
|
||||
fireEvent.pointerDown(avatar, { pointerId: 5, button: 0, clientX: 0, clientY: 0 })
|
||||
fireEvent.pointerMove(avatar, { pointerId: 5, clientX: 200, clientY: 200 })
|
||||
fireEvent.pointerUp(avatar, { pointerId: 5 })
|
||||
expect(commits).toEqual([{ x: 60, y: 60 }])
|
||||
expect(avatar.style.transform).toBe('translate3d(10px, 12px, 0)')
|
||||
})
|
||||
|
||||
it('re-clamps on resize without emitting a movement commit', () => {
|
||||
const changes: { x: number; y: number }[] = []
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
mode="floating"
|
||||
draggable
|
||||
size={40}
|
||||
position={{ x: 90, y: 90 }}
|
||||
onPositionChange={point => changes.push(point)}
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
Object.defineProperties(avatar, {
|
||||
offsetWidth: { configurable: true, value: 40 },
|
||||
offsetHeight: { configurable: true, value: 40 },
|
||||
})
|
||||
Object.defineProperties(window, {
|
||||
innerWidth: { configurable: true, value: 100 },
|
||||
innerHeight: { configurable: true, value: 100 },
|
||||
})
|
||||
act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
|
||||
expect(changes.at(-1)).toEqual({ x: 60, y: 60 })
|
||||
expect(commits).toEqual([])
|
||||
expect(avatar.style.transform).toBe('translate3d(90px, 90px, 0)')
|
||||
})
|
||||
|
||||
it('re-clamps an uncontrolled floating position without emitting a movement commit', () => {
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
mode="floating"
|
||||
draggable
|
||||
size={40}
|
||||
initialPosition={{ x: 90, y: 90 }}
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
Object.defineProperties(avatar, {
|
||||
offsetWidth: { configurable: true, value: 40 },
|
||||
offsetHeight: { configurable: true, value: 40 },
|
||||
})
|
||||
Object.defineProperties(window, {
|
||||
innerWidth: { configurable: true, value: 100 },
|
||||
innerHeight: { configurable: true, value: 100 },
|
||||
})
|
||||
act(() => resizeCallbacks.forEach(callback => callback([], {} as ResizeObserver)))
|
||||
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
|
||||
expect(commits).toEqual([])
|
||||
})
|
||||
|
||||
it('re-clamps to the viewport on window resize without ResizeObserver support', () => {
|
||||
const originalObserver = globalThis.ResizeObserver
|
||||
// @ts-expect-error This test covers browsers without ResizeObserver.
|
||||
globalThis.ResizeObserver = undefined
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
mode="floating"
|
||||
draggable
|
||||
size={40}
|
||||
initialPosition={{ x: 90, y: 90 }}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
Object.defineProperties(avatar, {
|
||||
offsetWidth: { configurable: true, value: 40 },
|
||||
offsetHeight: { configurable: true, value: 40 },
|
||||
})
|
||||
Object.defineProperties(window, {
|
||||
innerWidth: { configurable: true, value: 100 },
|
||||
innerHeight: { configurable: true, value: 100 },
|
||||
})
|
||||
act(() => window.dispatchEvent(new Event('resize')))
|
||||
expect(avatar.style.transform).toBe('translate3d(60px, 60px, 0)')
|
||||
globalThis.ResizeObserver = originalObserver
|
||||
})
|
||||
|
||||
it('provides keyboard movement and an accessible reset control', () => {
|
||||
const commits: { x: number; y: number }[] = []
|
||||
const view = render(
|
||||
<Avatar
|
||||
definition={definition}
|
||||
draggable
|
||||
constrainTo="none"
|
||||
onPositionCommit={point => commits.push(point)}
|
||||
/>
|
||||
)
|
||||
const avatar = view.getByRole('group')
|
||||
fireEvent.keyDown(avatar, { key: 'ArrowRight' })
|
||||
expect(commits.at(-1)).toEqual({ x: 10, y: 0 })
|
||||
act(() => view.getByRole('button', { name: 'Move avatar down' }).click())
|
||||
expect(commits.at(-1)).toEqual({ x: 10, y: 10 })
|
||||
act(() => view.getByRole('button', { name: 'Reset avatar position' }).click())
|
||||
expect(commits.at(-1)).toEqual({ x: 0, y: 0 })
|
||||
})
|
||||
|
||||
it('resumes the current paused animation instead of restarting it', () => {
|
||||
const controller = createRef<AvatarController>()
|
||||
const clock = vi.spyOn(performance, 'now')
|
||||
clock.mockReturnValueOnce(100).mockReturnValueOnce(250).mockReturnValueOnce(1_250)
|
||||
render(<Avatar definition={definition} ref={controller} />)
|
||||
act(() => {
|
||||
controller.current?.play('greet')
|
||||
controller.current?.pause()
|
||||
controller.current?.play('greet')
|
||||
})
|
||||
expect(controller.current?.getState()).toMatchObject({
|
||||
activeAnimation: 'greet',
|
||||
activeExpression: 'smile',
|
||||
status: 'playing',
|
||||
})
|
||||
clock.mockRestore()
|
||||
})
|
||||
|
||||
it('updates SVG frames without rendering React once per animation frame', () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frames.set(++nextFrame, callback)
|
||||
return nextFrame
|
||||
})
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
frames.delete(id)
|
||||
})
|
||||
const clock = vi.spyOn(performance, 'now').mockReturnValue(1_000)
|
||||
let renders = 0
|
||||
render(
|
||||
<Profiler id="animated-avatar" onRender={() => renders++}>
|
||||
<Avatar definition={definition} defaultAnimation="greet" />
|
||||
</Profiler>
|
||||
)
|
||||
const beforeFrames = renders
|
||||
act(() => {
|
||||
for (let index = 1; index <= 20; index++) {
|
||||
const callback = [...frames.values()].at(-1)
|
||||
frames.clear()
|
||||
callback?.(1_000 + index)
|
||||
}
|
||||
})
|
||||
expect(renders).toBe(beforeFrames)
|
||||
clock.mockRestore()
|
||||
request.mockRestore()
|
||||
cancel.mockRestore()
|
||||
})
|
||||
|
||||
it('fires once-completion exactly once under Strict Mode', () => {
|
||||
let nextFrame = 0
|
||||
const frames = new Map<number, FrameRequestCallback>()
|
||||
const request = vi.spyOn(window, 'requestAnimationFrame').mockImplementation(callback => {
|
||||
frames.set(++nextFrame, callback)
|
||||
return nextFrame
|
||||
})
|
||||
const cancel = vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(id => {
|
||||
frames.delete(id)
|
||||
})
|
||||
const ended = vi.fn()
|
||||
render(
|
||||
<StrictMode>
|
||||
<Avatar definition={definition} defaultAnimation="wave-once" onAnimationEnd={ended} />
|
||||
</StrictMode>
|
||||
)
|
||||
act(() => {
|
||||
const callbacks = [...frames.values()]
|
||||
frames.clear()
|
||||
callbacks.forEach(callback => callback(performance.now() + 1_000))
|
||||
})
|
||||
expect(ended).toHaveBeenCalledTimes(1)
|
||||
expect(ended).toHaveBeenCalledWith('wave-once')
|
||||
request.mockRestore()
|
||||
cancel.mockRestore()
|
||||
})
|
||||
})
|
||||
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 type {
|
||||
AvatarCommandResult,
|
||||
AvatarController,
|
||||
AvatarPlaybackState,
|
||||
AvatarPosition,
|
||||
AvatarProps,
|
||||
AvatarRuntimeError,
|
||||
FloatingInitialPosition,
|
||||
} from './Avatar'
|
||||
98
packages/avatar-react/src/styles.css
Normal file
98
packages/avatar-react/src/styles.css
Normal file
@ -0,0 +1,98 @@
|
||||
.bs-avatar {
|
||||
box-sizing: border-box;
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
max-width: 100%;
|
||||
aspect-ratio: 1;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.bs-avatar--embedded {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.bs-avatar--floating {
|
||||
position: fixed;
|
||||
inset: 0 auto auto 0;
|
||||
}
|
||||
|
||||
.bs-avatar--draggable {
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.bs-avatar--dragging {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.bs-avatar:focus-visible {
|
||||
outline: 3px solid currentColor;
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.bs-avatar__svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: visible;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.bs-avatar__move-controls {
|
||||
position: absolute;
|
||||
inset: 0 0 auto auto;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 44px);
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.bs-avatar__move-controls button {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
border: 0;
|
||||
border-radius: 999px;
|
||||
background: transparent;
|
||||
color: currentColor;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bs-avatar__move-controls button:nth-child(1) {
|
||||
grid-column: 1;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.bs-avatar__move-controls button:nth-child(2) {
|
||||
grid-column: 3;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.bs-avatar__move-controls button:nth-child(3) {
|
||||
grid-column: 2;
|
||||
grid-row: 1;
|
||||
}
|
||||
|
||||
.bs-avatar__move-controls button:nth-child(4) {
|
||||
grid-column: 2;
|
||||
grid-row: 3;
|
||||
}
|
||||
|
||||
.bs-avatar__reset {
|
||||
grid-column: 2;
|
||||
grid-row: 2;
|
||||
}
|
||||
|
||||
.bs-avatar:hover .bs-avatar__move-controls,
|
||||
.bs-avatar:focus-within .bs-avatar__move-controls,
|
||||
.bs-avatar__move-controls:focus-within {
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.bs-avatar,
|
||||
.bs-avatar * {
|
||||
scroll-behavior: auto !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
}
|
||||
}
|
||||
1
packages/avatar-react/src/styles.d.ts
vendored
Normal file
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'],
|
||||
},
|
||||
},
|
||||
})
|
||||
533
pnpm-lock.yaml
generated
533
pnpm-lock.yaml
generated
@ -11,12 +11,21 @@ importers:
|
||||
'@base-ui/react':
|
||||
specifier: ^1.7.0
|
||||
version: 1.7.0(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@bible-strong/avatar-core':
|
||||
specifier: workspace:*
|
||||
version: link:packages/avatar-core
|
||||
'@bible-strong/avatar-react':
|
||||
specifier: workspace:*
|
||||
version: link:packages/avatar-react
|
||||
'@vercel/analytics':
|
||||
specifier: ^2.0.1
|
||||
version: 2.0.1(react@19.2.3)
|
||||
'@vercel/speed-insights':
|
||||
specifier: ^2.0.0
|
||||
version: 2.0.0(react@19.2.3)
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
class-variance-authority:
|
||||
specifier: ^0.7.1
|
||||
version: 0.7.1
|
||||
@ -54,6 +63,9 @@ importers:
|
||||
'@tailwindcss/vite':
|
||||
specifier: ^4.3.3
|
||||
version: 4.3.3(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
|
||||
'@testing-library/react':
|
||||
specifier: ^16.3.2
|
||||
version: 16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
|
||||
'@types/node':
|
||||
specifier: ^24.10.1
|
||||
version: 24.13.3
|
||||
@ -69,6 +81,9 @@ importers:
|
||||
babel-plugin-react-compiler:
|
||||
specifier: ^1.0.0
|
||||
version: 1.0.0
|
||||
jsdom:
|
||||
specifier: ^30.0.1
|
||||
version: 30.0.1
|
||||
prettier:
|
||||
specifier: ^3.8.1
|
||||
version: 3.9.6
|
||||
@ -86,10 +101,87 @@ importers:
|
||||
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||
vitest:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
|
||||
version: 4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
|
||||
|
||||
examples/react-vite-consumer:
|
||||
dependencies:
|
||||
'@bible-strong/avatar-core':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/avatar-core
|
||||
'@bible-strong/avatar-react':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/avatar-react
|
||||
'@vitejs/plugin-react':
|
||||
specifier: ^6.0.2
|
||||
version: 6.0.5(@rolldown/plugin-babel@0.2.3(@babel/core@7.29.7)(@babel/runtime@7.29.7)(rolldown@1.2.4)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)))(babel-plugin-react-compiler@1.0.0)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
|
||||
react:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
typescript:
|
||||
specifier: ~6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.13
|
||||
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.18
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.4(@types/react@19.2.18)
|
||||
|
||||
packages/avatar-core:
|
||||
dependencies:
|
||||
ajv:
|
||||
specifier: ^8.20.0
|
||||
version: 8.20.0
|
||||
devDependencies:
|
||||
typescript:
|
||||
specifier: ~6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.13
|
||||
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||
|
||||
packages/avatar-react:
|
||||
dependencies:
|
||||
'@bible-strong/avatar-core':
|
||||
specifier: workspace:^
|
||||
version: link:../avatar-core
|
||||
devDependencies:
|
||||
'@types/react':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.18
|
||||
'@types/react-dom':
|
||||
specifier: ^19.2.3
|
||||
version: 19.2.4(@types/react@19.2.18)
|
||||
react:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3
|
||||
react-dom:
|
||||
specifier: 19.2.3
|
||||
version: 19.2.3(react@19.2.3)
|
||||
typescript:
|
||||
specifier: ~6.0.3
|
||||
version: 6.0.3
|
||||
vite:
|
||||
specifier: ^8.0.13
|
||||
version: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||
|
||||
packages:
|
||||
|
||||
'@asamuzakjp/css-color@6.0.7':
|
||||
resolution: {integrity: sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw==}
|
||||
engines: {node: ^22.13.0 || >=24.0.0}
|
||||
|
||||
'@asamuzakjp/dom-selector@8.3.2':
|
||||
resolution: {integrity: sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q==}
|
||||
engines: {node: ^22.13.0 || >=24.0.0}
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
resolution: {integrity: sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==}
|
||||
engines: {node: '>=6.9.0'}
|
||||
@ -188,6 +280,55 @@ packages:
|
||||
'@types/react':
|
||||
optional: true
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
resolution: {integrity: sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==}
|
||||
hasBin: true
|
||||
|
||||
'@csstools/color-helpers@6.1.0':
|
||||
resolution: {integrity: sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@csstools/css-calc@3.3.0':
|
||||
resolution: {integrity: sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-color-parser@4.1.10':
|
||||
resolution: {integrity: sha512-UZhQLIUyJaaMepqehrCODwCg2KW25vFvLWBmqYFaPclYvvxzj/sG8LBOhBFCp11i9uE7t1EyS+RAoV9tztPFyw==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-parser-algorithms': ^4.0.0
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@4.0.0':
|
||||
resolution: {integrity: sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
peerDependencies:
|
||||
'@csstools/css-tokenizer': ^4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.7':
|
||||
resolution: {integrity: sha512-fQ+05118eQS1cofO3aJpB5efgpBZMvIzwr/sbC8kDLVA5XLG8q1kJV5yzrUAI1f7lvhPnm8fgIjzFB8/O/5Dig==}
|
||||
peerDependencies:
|
||||
css-tree: ^3.2.1
|
||||
peerDependenciesMeta:
|
||||
css-tree:
|
||||
optional: true
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0':
|
||||
resolution: {integrity: sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
'@exodus/bytes@1.15.1':
|
||||
resolution: {integrity: sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
peerDependencies:
|
||||
'@noble/hashes': ^1.8.0 || ^2.0.0
|
||||
peerDependenciesMeta:
|
||||
'@noble/hashes':
|
||||
optional: true
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
resolution: {integrity: sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ==}
|
||||
|
||||
@ -429,6 +570,28 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^5.2.0 || ^6 || ^7 || ^8
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
resolution: {integrity: sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
'@testing-library/react@16.3.2':
|
||||
resolution: {integrity: sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
'@testing-library/dom': ^10.0.0
|
||||
'@types/react': ^18.0.0 || ^19.0.0
|
||||
'@types/react-dom': ^18.0.0 || ^19.0.0
|
||||
react: ^18.0.0 || ^19.0.0
|
||||
react-dom: ^18.0.0 || ^19.0.0
|
||||
peerDependenciesMeta:
|
||||
'@types/react':
|
||||
optional: true
|
||||
'@types/react-dom':
|
||||
optional: true
|
||||
|
||||
'@types/aria-query@5.0.4':
|
||||
resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
resolution: {integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==}
|
||||
|
||||
@ -546,6 +709,20 @@ packages:
|
||||
'@vitest/utils@4.1.10':
|
||||
resolution: {integrity: sha512-fy9am/HWxbaGt/Sawrp90vt6Y6jQwf1RX77cz3uwoJwJVMli/e1IEwRPnMNJ7vKfPTwo0diXifkpPvwH9v7nGA==}
|
||||
|
||||
ajv@8.20.0:
|
||||
resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==}
|
||||
|
||||
ansi-regex@5.0.1:
|
||||
resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
ansi-styles@5.2.0:
|
||||
resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
aria-query@5.3.0:
|
||||
resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==}
|
||||
|
||||
assertion-error@2.0.1:
|
||||
resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==}
|
||||
engines: {node: '>=12'}
|
||||
@ -558,6 +735,9 @@ packages:
|
||||
engines: {node: '>=6.0.0'}
|
||||
hasBin: true
|
||||
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
browserslist@4.28.8:
|
||||
resolution: {integrity: sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA==}
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
@ -580,9 +760,17 @@ packages:
|
||||
convert-source-map@2.0.0:
|
||||
resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==}
|
||||
|
||||
css-tree@3.2.1:
|
||||
resolution: {integrity: sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==}
|
||||
engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0}
|
||||
|
||||
csstype@3.2.3:
|
||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||
|
||||
data-urls@7.0.0:
|
||||
resolution: {integrity: sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
debug@4.4.3:
|
||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||
engines: {node: '>=6.0'}
|
||||
@ -592,10 +780,20 @@ packages:
|
||||
supports-color:
|
||||
optional: true
|
||||
|
||||
decimal.js@10.6.0:
|
||||
resolution: {integrity: sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==}
|
||||
|
||||
dequal@2.0.3:
|
||||
resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
detect-libc@2.1.2:
|
||||
resolution: {integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
dom-accessibility-api@0.5.16:
|
||||
resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==}
|
||||
|
||||
electron-to-chromium@1.5.405:
|
||||
resolution: {integrity: sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew==}
|
||||
|
||||
@ -603,6 +801,10 @@ packages:
|
||||
resolution: {integrity: sha512-L1l8TNvomm6UVW5B253AGxQagSQr+vGwhMlrrfRS2qmhx46AMpMVJKQYLvWYbysTMY8VoicOvzHzoHMbyzB+4A==}
|
||||
engines: {node: '>=10.13.0'}
|
||||
|
||||
entities@8.0.0:
|
||||
resolution: {integrity: sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==}
|
||||
engines: {node: '>=20.19.0'}
|
||||
|
||||
es-module-lexer@2.3.1:
|
||||
resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==}
|
||||
|
||||
@ -617,6 +819,12 @@ packages:
|
||||
resolution: {integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
fast-deep-equal@3.1.3:
|
||||
resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==}
|
||||
|
||||
fast-uri@3.1.5:
|
||||
resolution: {integrity: sha512-gHwA1O9LDIcKunMKhObS/HimwtehO1nPUECKAu5TpKgaO19fcWEl4bliWe1jWxVFvIXztJjjQ4L8XQ1EU9f7Jw==}
|
||||
|
||||
fdir@6.5.0:
|
||||
resolution: {integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
@ -649,6 +857,13 @@ packages:
|
||||
graceful-fs@4.2.11:
|
||||
resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
resolution: {integrity: sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
is-potential-custom-element-name@1.0.1:
|
||||
resolution: {integrity: sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==}
|
||||
|
||||
jiti@2.7.0:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
@ -656,11 +871,23 @@ packages:
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
jsdom@30.0.1:
|
||||
resolution: {integrity: sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA==}
|
||||
engines: {node: ^22.22.2 || ^24.15.0 || >=26.0.0}
|
||||
peerDependencies:
|
||||
canvas: ^3.2.3
|
||||
peerDependenciesMeta:
|
||||
canvas:
|
||||
optional: true
|
||||
|
||||
jsesc@3.1.0:
|
||||
resolution: {integrity: sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==}
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
json-schema-traverse@1.0.0:
|
||||
resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==}
|
||||
|
||||
json5@2.2.3:
|
||||
resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==}
|
||||
engines: {node: '>=6'}
|
||||
@ -814,6 +1041,10 @@ packages:
|
||||
resolution: {integrity: sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lru-cache@11.5.2:
|
||||
resolution: {integrity: sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==}
|
||||
engines: {node: 20 || >=22}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==}
|
||||
|
||||
@ -822,9 +1053,16 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
lz-string@1.5.0:
|
||||
resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==}
|
||||
hasBin: true
|
||||
|
||||
magic-string@0.30.21:
|
||||
resolution: {integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==}
|
||||
|
||||
mdn-data@2.27.1:
|
||||
resolution: {integrity: sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==}
|
||||
|
||||
motion-dom@13.0.0:
|
||||
resolution: {integrity: sha512-Xk+SJas70uMAUIApg+m3lZDShxI3LBFHq7mFGbBKoRXc2PVPDyAKmzN64Bbzt4CZdP/CItTiJxWtn4TA0v53Ng==}
|
||||
|
||||
@ -858,6 +1096,9 @@ packages:
|
||||
resolution: {integrity: sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA==}
|
||||
engines: {node: '>=12.20.0'}
|
||||
|
||||
parse5@8.0.1:
|
||||
resolution: {integrity: sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==}
|
||||
|
||||
pathe@2.0.3:
|
||||
resolution: {integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==}
|
||||
|
||||
@ -877,11 +1118,22 @@ packages:
|
||||
engines: {node: '>=14'}
|
||||
hasBin: true
|
||||
|
||||
pretty-format@27.5.1:
|
||||
resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==}
|
||||
engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0}
|
||||
|
||||
punycode@2.3.1:
|
||||
resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
react-dom@19.2.3:
|
||||
resolution: {integrity: sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==}
|
||||
peerDependencies:
|
||||
react: ^19.2.3
|
||||
|
||||
react-is@17.0.2:
|
||||
resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==}
|
||||
|
||||
react-resizable-panels@4.12.2:
|
||||
resolution: {integrity: sha512-NwY5LCo4WrxVvDh0xoMML6EMLPONP/8ckKcIdpnojxexoatZdjLiRqLJQjQK5CPkd4SYiB/2M5BVrjZBQtOO7Q==}
|
||||
peerDependencies:
|
||||
@ -892,6 +1144,10 @@ packages:
|
||||
resolution: {integrity: sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
require-from-string@2.0.2:
|
||||
resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
reselect@5.2.0:
|
||||
resolution: {integrity: sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw==}
|
||||
|
||||
@ -900,6 +1156,10 @@ packages:
|
||||
engines: {node: ^20.19.0 || >=22.12.0}
|
||||
hasBin: true
|
||||
|
||||
saxes@6.0.0:
|
||||
resolution: {integrity: sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==}
|
||||
engines: {node: '>=v12.22.7'}
|
||||
|
||||
scheduler@0.27.0:
|
||||
resolution: {integrity: sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==}
|
||||
|
||||
@ -920,6 +1180,9 @@ packages:
|
||||
std-env@4.2.0:
|
||||
resolution: {integrity: sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==}
|
||||
|
||||
symbol-tree@3.2.4:
|
||||
resolution: {integrity: sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==}
|
||||
|
||||
tailwind-merge@3.6.0:
|
||||
resolution: {integrity: sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==}
|
||||
|
||||
@ -945,6 +1208,21 @@ packages:
|
||||
resolution: {integrity: sha512-yau8yJdTt989Mm0Bd/236QnzEiPf2xLLTqUZRUJOo/3CB078LSwzei343DgtJVmfJKJE3TMINY1u42SQsP6mXw==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
||||
tldts-core@7.4.10:
|
||||
resolution: {integrity: sha512-KnQjp53ZekKgm/r3l+u8kJGGzYgrWdP8+Mql7a4vijh2WE0IrZWspQj/TpTxDho/YxO+AnOZnIjQcCD+q6iJsw==}
|
||||
|
||||
tldts@7.4.10:
|
||||
resolution: {integrity: sha512-GgouD1B+sWwvkaEq8vXC15DjQitxbvs12oIXELpconwm+Tg3zfcEv4jgzq3vtKverDXsg3VI8aRgNL2Nra0Iog==}
|
||||
hasBin: true
|
||||
|
||||
tough-cookie@6.0.2:
|
||||
resolution: {integrity: sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
tr46@6.0.0:
|
||||
resolution: {integrity: sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
tslib@2.8.1:
|
||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||
|
||||
@ -956,6 +1234,10 @@ packages:
|
||||
undici-types@7.18.2:
|
||||
resolution: {integrity: sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==}
|
||||
|
||||
undici@8.10.0:
|
||||
resolution: {integrity: sha512-HvltHd7avK13QIw/oLe4qoOLyoVSoafqJ2jYOrtMRBkbYT31eiBQ8O0ehRKZiEZCMEyLFQNIADpgCWC5fALvYQ==}
|
||||
engines: {node: '>=22.19.0'}
|
||||
|
||||
update-browserslist-db@1.3.1:
|
||||
resolution: {integrity: sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ==}
|
||||
hasBin: true
|
||||
@ -1051,16 +1333,58 @@ packages:
|
||||
jsdom:
|
||||
optional: true
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
resolution: {integrity: sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
webidl-conversions@8.0.1:
|
||||
resolution: {integrity: sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-mimetype@5.0.0:
|
||||
resolution: {integrity: sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==}
|
||||
engines: {node: '>=20'}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
resolution: {integrity: sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==}
|
||||
engines: {node: ^20.19.0 || ^22.12.0 || >=24.0.0}
|
||||
|
||||
whatwg-url@17.1.0:
|
||||
resolution: {integrity: sha512-3GeworPmc2ZfEEHP7lEbUfBX/L75wdEsi0rLNhXcXxnoN5jyq0SL5gCy06SGW2cyTIZdTvWIDQNQoza++vKeaw==}
|
||||
engines: {node: ^22.14.0 || >=24.0.0}
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==}
|
||||
engines: {node: '>=8'}
|
||||
hasBin: true
|
||||
|
||||
xml-name-validator@5.0.0:
|
||||
resolution: {integrity: sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
xmlchars@2.2.0:
|
||||
resolution: {integrity: sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==}
|
||||
|
||||
yallist@3.1.1:
|
||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||
|
||||
snapshots:
|
||||
|
||||
'@asamuzakjp/css-color@6.0.7':
|
||||
dependencies:
|
||||
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-color-parser': 4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
lru-cache: 11.5.2
|
||||
|
||||
'@asamuzakjp/dom-selector@8.3.2':
|
||||
dependencies:
|
||||
bidi-js: 1.0.3
|
||||
css-tree: 3.2.1
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.5.2
|
||||
|
||||
'@babel/code-frame@7.29.7':
|
||||
dependencies:
|
||||
'@babel/helper-validator-identifier': 7.29.7
|
||||
@ -1186,6 +1510,36 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
|
||||
'@bramus/specificity@2.4.2':
|
||||
dependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/color-helpers@6.1.0': {}
|
||||
|
||||
'@csstools/css-calc@3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-color-parser@4.1.10(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/color-helpers': 6.1.0
|
||||
'@csstools/css-calc': 3.3.0(@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0))(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-parser-algorithms': 4.0.0(@csstools/css-tokenizer@4.0.0)
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-parser-algorithms@4.0.0(@csstools/css-tokenizer@4.0.0)':
|
||||
dependencies:
|
||||
'@csstools/css-tokenizer': 4.0.0
|
||||
|
||||
'@csstools/css-syntax-patches-for-csstree@1.1.7(css-tree@3.2.1)':
|
||||
optionalDependencies:
|
||||
css-tree: 3.2.1
|
||||
|
||||
'@csstools/css-tokenizer@4.0.0': {}
|
||||
|
||||
'@exodus/bytes@1.15.1': {}
|
||||
|
||||
'@floating-ui/core@1.8.0':
|
||||
dependencies:
|
||||
'@floating-ui/utils': 0.2.12
|
||||
@ -1347,6 +1701,29 @@ snapshots:
|
||||
tailwindcss: 4.3.3
|
||||
vite: 8.2.1(@types/node@24.13.3)(jiti@2.7.0)
|
||||
|
||||
'@testing-library/dom@10.4.1':
|
||||
dependencies:
|
||||
'@babel/code-frame': 7.29.7
|
||||
'@babel/runtime': 7.29.7
|
||||
'@types/aria-query': 5.0.4
|
||||
aria-query: 5.3.0
|
||||
dom-accessibility-api: 0.5.16
|
||||
lz-string: 1.5.0
|
||||
picocolors: 1.1.1
|
||||
pretty-format: 27.5.1
|
||||
|
||||
'@testing-library/react@16.3.2(@testing-library/dom@10.4.1)(@types/react-dom@19.2.4(@types/react@19.2.18))(@types/react@19.2.18)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)':
|
||||
dependencies:
|
||||
'@babel/runtime': 7.29.7
|
||||
'@testing-library/dom': 10.4.1
|
||||
react: 19.2.3
|
||||
react-dom: 19.2.3(react@19.2.3)
|
||||
optionalDependencies:
|
||||
'@types/react': 19.2.18
|
||||
'@types/react-dom': 19.2.4(@types/react@19.2.18)
|
||||
|
||||
'@types/aria-query@5.0.4': {}
|
||||
|
||||
'@types/chai@5.2.3':
|
||||
dependencies:
|
||||
'@types/deep-eql': 4.0.2
|
||||
@ -1425,6 +1802,21 @@ snapshots:
|
||||
convert-source-map: 2.0.0
|
||||
tinyrainbow: 3.1.1
|
||||
|
||||
ajv@8.20.0:
|
||||
dependencies:
|
||||
fast-deep-equal: 3.1.3
|
||||
fast-uri: 3.1.5
|
||||
json-schema-traverse: 1.0.0
|
||||
require-from-string: 2.0.2
|
||||
|
||||
ansi-regex@5.0.1: {}
|
||||
|
||||
ansi-styles@5.2.0: {}
|
||||
|
||||
aria-query@5.3.0:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
assertion-error@2.0.1: {}
|
||||
|
||||
babel-plugin-react-compiler@1.0.0:
|
||||
@ -1433,6 +1825,10 @@ snapshots:
|
||||
|
||||
baseline-browser-mapping@2.11.13: {}
|
||||
|
||||
bidi-js@1.0.3:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
browserslist@4.28.8:
|
||||
dependencies:
|
||||
baseline-browser-mapping: 2.11.13
|
||||
@ -1453,14 +1849,32 @@ snapshots:
|
||||
|
||||
convert-source-map@2.0.0: {}
|
||||
|
||||
css-tree@3.2.1:
|
||||
dependencies:
|
||||
mdn-data: 2.27.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
csstype@3.2.3: {}
|
||||
|
||||
data-urls@7.0.0:
|
||||
dependencies:
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 16.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
debug@4.4.3:
|
||||
dependencies:
|
||||
ms: 2.1.3
|
||||
|
||||
decimal.js@10.6.0: {}
|
||||
|
||||
dequal@2.0.3: {}
|
||||
|
||||
detect-libc@2.1.2: {}
|
||||
|
||||
dom-accessibility-api@0.5.16: {}
|
||||
|
||||
electron-to-chromium@1.5.405: {}
|
||||
|
||||
enhanced-resolve@5.24.5:
|
||||
@ -1468,6 +1882,8 @@ snapshots:
|
||||
graceful-fs: 4.2.11
|
||||
tapable: 2.3.3
|
||||
|
||||
entities@8.0.0: {}
|
||||
|
||||
es-module-lexer@2.3.1: {}
|
||||
|
||||
escalade@3.2.0: {}
|
||||
@ -1478,6 +1894,10 @@ snapshots:
|
||||
|
||||
expect-type@1.4.0: {}
|
||||
|
||||
fast-deep-equal@3.1.3: {}
|
||||
|
||||
fast-uri@3.1.5: {}
|
||||
|
||||
fdir@6.5.0(picomatch@4.0.5):
|
||||
optionalDependencies:
|
||||
picomatch: 4.0.5
|
||||
@ -1498,12 +1918,48 @@ snapshots:
|
||||
|
||||
graceful-fs@4.2.11: {}
|
||||
|
||||
html-encoding-sniffer@6.0.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
is-potential-custom-element-name@1.0.1: {}
|
||||
|
||||
jiti@2.7.0: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
jsdom@30.0.1:
|
||||
dependencies:
|
||||
'@asamuzakjp/css-color': 6.0.7
|
||||
'@asamuzakjp/dom-selector': 8.3.2
|
||||
'@bramus/specificity': 2.4.2
|
||||
'@csstools/css-syntax-patches-for-csstree': 1.1.7(css-tree@3.2.1)
|
||||
'@exodus/bytes': 1.15.1
|
||||
css-tree: 3.2.1
|
||||
data-urls: 7.0.0
|
||||
decimal.js: 10.6.0
|
||||
html-encoding-sniffer: 6.0.0
|
||||
is-potential-custom-element-name: 1.0.1
|
||||
lru-cache: 11.5.2
|
||||
parse5: 8.0.1
|
||||
saxes: 6.0.0
|
||||
symbol-tree: 3.2.4
|
||||
tough-cookie: 6.0.2
|
||||
undici: 8.10.0
|
||||
w3c-xmlserializer: 5.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
whatwg-mimetype: 5.0.0
|
||||
whatwg-url: 17.1.0
|
||||
xml-name-validator: 5.0.0
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
jsesc@3.1.0: {}
|
||||
|
||||
json-schema-traverse@1.0.0: {}
|
||||
|
||||
json5@2.2.3: {}
|
||||
|
||||
lightningcss-android-arm64@1.32.0:
|
||||
@ -1604,6 +2060,8 @@ snapshots:
|
||||
lightningcss-win32-arm64-msvc: 1.33.0
|
||||
lightningcss-win32-x64-msvc: 1.33.0
|
||||
|
||||
lru-cache@11.5.2: {}
|
||||
|
||||
lru-cache@5.1.1:
|
||||
dependencies:
|
||||
yallist: 3.1.1
|
||||
@ -1612,10 +2070,14 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
|
||||
lz-string@1.5.0: {}
|
||||
|
||||
magic-string@0.30.21:
|
||||
dependencies:
|
||||
'@jridgewell/sourcemap-codec': 1.5.5
|
||||
|
||||
mdn-data@2.27.1: {}
|
||||
|
||||
motion-dom@13.0.0:
|
||||
dependencies:
|
||||
motion-utils: 13.0.0
|
||||
@ -1638,6 +2100,10 @@ snapshots:
|
||||
|
||||
obug@2.1.4: {}
|
||||
|
||||
parse5@8.0.1:
|
||||
dependencies:
|
||||
entities: 8.0.0
|
||||
|
||||
pathe@2.0.3: {}
|
||||
|
||||
picocolors@1.1.1: {}
|
||||
@ -1652,11 +2118,21 @@ snapshots:
|
||||
|
||||
prettier@3.9.6: {}
|
||||
|
||||
pretty-format@27.5.1:
|
||||
dependencies:
|
||||
ansi-regex: 5.0.1
|
||||
ansi-styles: 5.2.0
|
||||
react-is: 17.0.2
|
||||
|
||||
punycode@2.3.1: {}
|
||||
|
||||
react-dom@19.2.3(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
scheduler: 0.27.0
|
||||
|
||||
react-is@17.0.2: {}
|
||||
|
||||
react-resizable-panels@4.12.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
|
||||
dependencies:
|
||||
react: 19.2.3
|
||||
@ -1664,6 +2140,8 @@ snapshots:
|
||||
|
||||
react@19.2.3: {}
|
||||
|
||||
require-from-string@2.0.2: {}
|
||||
|
||||
reselect@5.2.0: {}
|
||||
|
||||
rolldown@1.2.4:
|
||||
@ -1686,6 +2164,10 @@ snapshots:
|
||||
'@rolldown/binding-win32-arm64-msvc': 1.2.4
|
||||
'@rolldown/binding-win32-x64-msvc': 1.2.4
|
||||
|
||||
saxes@6.0.0:
|
||||
dependencies:
|
||||
xmlchars: 2.2.0
|
||||
|
||||
scheduler@0.27.0: {}
|
||||
|
||||
semver@6.3.1: {}
|
||||
@ -1698,6 +2180,8 @@ snapshots:
|
||||
|
||||
std-env@4.2.0: {}
|
||||
|
||||
symbol-tree@3.2.4: {}
|
||||
|
||||
tailwind-merge@3.6.0: {}
|
||||
|
||||
tailwindcss@4.3.3: {}
|
||||
@ -1715,12 +2199,28 @@ snapshots:
|
||||
|
||||
tinyrainbow@3.1.1: {}
|
||||
|
||||
tldts-core@7.4.10: {}
|
||||
|
||||
tldts@7.4.10:
|
||||
dependencies:
|
||||
tldts-core: 7.4.10
|
||||
|
||||
tough-cookie@6.0.2:
|
||||
dependencies:
|
||||
tldts: 7.4.10
|
||||
|
||||
tr46@6.0.0:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
tslib@2.8.1: {}
|
||||
|
||||
typescript@6.0.3: {}
|
||||
|
||||
undici-types@7.18.2: {}
|
||||
|
||||
undici@8.10.0: {}
|
||||
|
||||
update-browserslist-db@1.3.1(browserslist@4.28.8):
|
||||
dependencies:
|
||||
browserslist: 4.28.8
|
||||
@ -1743,7 +2243,7 @@ snapshots:
|
||||
fsevents: 2.3.3
|
||||
jiti: 2.7.0
|
||||
|
||||
vitest@4.1.10(@types/node@24.13.3)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)):
|
||||
vitest@4.1.10(@types/node@24.13.3)(jsdom@30.0.1)(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0)):
|
||||
dependencies:
|
||||
'@vitest/expect': 4.1.10
|
||||
'@vitest/mocker': 4.1.10(vite@8.2.1(@types/node@24.13.3)(jiti@2.7.0))
|
||||
@ -1767,12 +2267,41 @@ snapshots:
|
||||
why-is-node-running: 2.3.0
|
||||
optionalDependencies:
|
||||
'@types/node': 24.13.3
|
||||
jsdom: 30.0.1
|
||||
transitivePeerDependencies:
|
||||
- msw
|
||||
|
||||
w3c-xmlserializer@5.0.0:
|
||||
dependencies:
|
||||
xml-name-validator: 5.0.0
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
||||
whatwg-mimetype@5.0.0: {}
|
||||
|
||||
whatwg-url@16.0.1:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
whatwg-url@17.1.0:
|
||||
dependencies:
|
||||
'@exodus/bytes': 1.15.1
|
||||
tr46: 6.0.0
|
||||
webidl-conversions: 8.0.1
|
||||
transitivePeerDependencies:
|
||||
- '@noble/hashes'
|
||||
|
||||
why-is-node-running@2.3.0:
|
||||
dependencies:
|
||||
siginfo: 2.0.0
|
||||
stackback: 0.0.2
|
||||
|
||||
xml-name-validator@5.0.0: {}
|
||||
|
||||
xmlchars@2.2.0: {}
|
||||
|
||||
yallist@3.1.1: {}
|
||||
|
||||
3
pnpm-workspace.yaml
Normal file
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({
|
||||
configFile: false,
|
||||
logLevel: 'silent',
|
||||
resolve: {
|
||||
alias: {
|
||||
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
write: false,
|
||||
minify: true,
|
||||
|
||||
47
scripts/smoke-packages.mjs
Normal file
47
scripts/smoke-packages.mjs
Normal file
@ -0,0 +1,47 @@
|
||||
import { cp, mkdtemp, readFile, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
const root = path.dirname(path.dirname(fileURLToPath(import.meta.url)))
|
||||
const workspace = await mkdtemp(path.join(tmpdir(), 'avatar-runtime-pack-smoke-'))
|
||||
const consumer = path.join(workspace, 'consumer')
|
||||
|
||||
const run = (args, cwd = root, capture = false) => {
|
||||
const result = spawnSync('pnpm', args, {
|
||||
cwd,
|
||||
encoding: 'utf8',
|
||||
stdio: capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
|
||||
})
|
||||
if (result.status !== 0) process.exit(result.status ?? 1)
|
||||
return result.stdout?.trim()
|
||||
}
|
||||
|
||||
run(['packages:build'])
|
||||
const pack = packageName => {
|
||||
const output = run(
|
||||
['--dir', path.join(root, 'packages', packageName), 'pack', '--pack-destination', workspace],
|
||||
root,
|
||||
true
|
||||
)
|
||||
return output.split('\n').at(-1)
|
||||
}
|
||||
const coreTarball = pack('avatar-core')
|
||||
const reactTarball = pack('avatar-react')
|
||||
|
||||
await cp(path.join(root, 'examples/react-vite-consumer'), consumer, {
|
||||
recursive: true,
|
||||
filter: source =>
|
||||
!source.includes(`${path.sep}node_modules`) && !source.includes(`${path.sep}dist`),
|
||||
})
|
||||
const packagePath = path.join(consumer, 'package.json')
|
||||
const packageJson = JSON.parse(await readFile(packagePath, 'utf8'))
|
||||
packageJson.dependencies['@bible-strong/avatar-core'] = `file:${coreTarball}`
|
||||
packageJson.dependencies['@bible-strong/avatar-react'] = `file:${reactTarball}`
|
||||
packageJson.pnpm = { overrides: { '@bible-strong/avatar-core': `file:${coreTarball}` } }
|
||||
await writeFile(packagePath, `${JSON.stringify(packageJson, null, 2)}\n`)
|
||||
|
||||
run(['install', '--no-frozen-lockfile'], consumer)
|
||||
run(['build'], consumer)
|
||||
process.stdout.write(`Tarball consumer verified at ${consumer}\n`)
|
||||
@ -124,11 +124,13 @@ export function ExportSection({
|
||||
value,
|
||||
title,
|
||||
subtitle,
|
||||
badge,
|
||||
children,
|
||||
}: {
|
||||
value: string
|
||||
title: string
|
||||
subtitle: string
|
||||
badge?: string
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
const { t } = useStudioLanguage()
|
||||
@ -136,7 +138,10 @@ export function ExportSection({
|
||||
<AccordionItem value={value} className={`export-accordion-item export-accordion-${value}`}>
|
||||
<AccordionTrigger className="export-accordion-trigger">
|
||||
<span>
|
||||
<span className="export-accordion-title">
|
||||
<strong>{t(title)}</strong>
|
||||
{badge && <b className="export-menu-badge">{t(badge)}</b>}
|
||||
</span>
|
||||
<small>{t(subtitle)}</small>
|
||||
</span>
|
||||
</AccordionTrigger>
|
||||
|
||||
@ -382,12 +382,58 @@ input {
|
||||
}
|
||||
|
||||
.export-avatar-summary small,
|
||||
.export-avatar-summary span,
|
||||
.export-format-grid small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.runtime-readiness {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
border-color: color-mix(in srgb, var(--destructive) 32%, var(--border));
|
||||
background: color-mix(in srgb, var(--destructive) 5%, var(--card));
|
||||
}
|
||||
.runtime-readiness[data-ready='true'] {
|
||||
border-color: #b9d8c6;
|
||||
background: #f1faf5;
|
||||
}
|
||||
.runtime-readiness-heading {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
.runtime-readiness-heading > svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
flex: none;
|
||||
color: var(--destructive);
|
||||
}
|
||||
.runtime-readiness[data-ready='true'] .runtime-readiness-heading > svg {
|
||||
color: #278453;
|
||||
}
|
||||
.runtime-readiness-heading > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.runtime-readiness-heading strong {
|
||||
font-size: 12px;
|
||||
}
|
||||
.runtime-readiness-heading small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
}
|
||||
.runtime-error-list {
|
||||
display: grid;
|
||||
gap: 5px;
|
||||
margin: 0;
|
||||
padding-left: 18px;
|
||||
color: var(--destructive);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.export-avatar-summary strong {
|
||||
overflow: hidden;
|
||||
font-size: 16px;
|
||||
@ -460,6 +506,106 @@ input {
|
||||
min-height: 44px;
|
||||
box-shadow: 0 12px 30px rgb(23 25 29 / 18%);
|
||||
}
|
||||
.runtime-export-actions {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
}
|
||||
.runtime-export-actions > button {
|
||||
width: 100%;
|
||||
min-height: 44px;
|
||||
}
|
||||
.runtime-copy-status {
|
||||
margin: 0;
|
||||
color: var(--muted-foreground);
|
||||
font-size: 11px;
|
||||
text-align: center;
|
||||
}
|
||||
.runtime-copy-status[role='alert'] {
|
||||
color: var(--destructive);
|
||||
}
|
||||
.runtime-quickstart {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
}
|
||||
.runtime-quickstart > button {
|
||||
min-height: 44px;
|
||||
}
|
||||
.runtime-code-example {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
.runtime-code-example > small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.runtime-code-example pre {
|
||||
max-width: 100%;
|
||||
margin: 0;
|
||||
overflow-x: auto;
|
||||
padding: 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 9px;
|
||||
background: #17191d;
|
||||
color: #d8dee9;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 9px;
|
||||
line-height: 1.55;
|
||||
white-space: pre;
|
||||
}
|
||||
.runtime-token-comment {
|
||||
color: #a6adbb;
|
||||
}
|
||||
.runtime-token-keyword {
|
||||
color: #e9a6ff;
|
||||
}
|
||||
.runtime-token-string {
|
||||
color: #b9e88c;
|
||||
}
|
||||
.runtime-token-tag {
|
||||
color: #8fc7ff;
|
||||
}
|
||||
.runtime-live-example {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding-top: 12px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.runtime-live-example-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.runtime-live-example-heading > div {
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
}
|
||||
.runtime-live-example-heading small {
|
||||
color: var(--muted-foreground);
|
||||
font-size: 10px;
|
||||
}
|
||||
.runtime-live-example-heading strong {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
font-size: 9px;
|
||||
}
|
||||
.runtime-live-example-heading button {
|
||||
flex: none;
|
||||
}
|
||||
.runtime-live-example-stage {
|
||||
display: grid;
|
||||
min-height: 190px;
|
||||
overflow: hidden;
|
||||
place-items: center;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background-color: #f5f7fc;
|
||||
background-image:
|
||||
linear-gradient(rgb(91 127 229 / 6%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(91 127 229 / 6%) 1px, transparent 1px);
|
||||
background-size: 16px 16px;
|
||||
}
|
||||
.export-panel {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
@ -478,6 +624,24 @@ input {
|
||||
min-width: 0;
|
||||
gap: 5px;
|
||||
}
|
||||
.export-accordion-title {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
}
|
||||
.export-menu-badge {
|
||||
flex: none;
|
||||
padding: 2px 5px;
|
||||
border-radius: 5px;
|
||||
background: #e9edfa;
|
||||
color: #4168d5;
|
||||
font-size: 8px;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0.08em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.export-accordion-trigger strong {
|
||||
color: #343b49;
|
||||
font-size: 12px;
|
||||
@ -1622,6 +1786,24 @@ p {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.semantic-key-card input {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
.semantic-key-label {
|
||||
color: var(--foreground);
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.semantic-key-error {
|
||||
margin: 0;
|
||||
color: var(--destructive);
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
.semantic-key-card input[aria-invalid='true'] {
|
||||
border-color: var(--destructive);
|
||||
box-shadow: 0 0 0 3px color-mix(in srgb, var(--destructive) 14%, transparent);
|
||||
}
|
||||
.color-panel {
|
||||
position: relative;
|
||||
}
|
||||
@ -2147,6 +2329,23 @@ p {
|
||||
font-size: 8px;
|
||||
font-weight: 850;
|
||||
}
|
||||
.runtime-key-missing {
|
||||
position: absolute;
|
||||
top: 5px;
|
||||
left: 5px;
|
||||
display: grid;
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
place-items: center;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 50%;
|
||||
background: var(--destructive);
|
||||
color: white;
|
||||
font-size: 10px;
|
||||
font-style: normal;
|
||||
font-weight: 900;
|
||||
line-height: 1;
|
||||
}
|
||||
.expression-card[aria-pressed='true'] {
|
||||
border-color: #5b7fe5;
|
||||
box-shadow: 0 0 0 2px rgb(91 127 229 / 18%);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
advanceSequenceCursor,
|
||||
createInitialSequences,
|
||||
duplicateSequence,
|
||||
getSequenceSpring,
|
||||
normalizeSequencesForExpressions,
|
||||
parseSequences,
|
||||
@ -94,4 +95,13 @@ describe('editable avatar sequences', () => {
|
||||
expect(snappy.stiffness).toBeGreaterThan(smooth.stiffness)
|
||||
expect(smooth.damping).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('clears the public semantic key when an animation is duplicated', () => {
|
||||
const sequence = { ...createInitialSequences()[0], semanticKey: 'sleeping' }
|
||||
|
||||
const duplicate = duplicateSequence(sequence)
|
||||
|
||||
expect(duplicate.semanticKey).toBeUndefined()
|
||||
expect(duplicate.id).not.toBe(sequence.id)
|
||||
})
|
||||
})
|
||||
|
||||
@ -62,6 +62,7 @@ export function SequenceWorkspace({
|
||||
onSave,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
semanticKeyError,
|
||||
}: {
|
||||
editing: { sourceId: string | null; draft: AvatarSequence }
|
||||
expressions: Expression[]
|
||||
@ -86,6 +87,7 @@ export function SequenceWorkspace({
|
||||
onSave: () => void
|
||||
onDuplicate: () => void
|
||||
onDelete: () => void
|
||||
semanticKeyError: string | null
|
||||
}) {
|
||||
const { t } = useStudioLanguage()
|
||||
const draggedStepId = useRef<string | null>(null)
|
||||
@ -161,6 +163,35 @@ export function SequenceWorkspace({
|
||||
compact
|
||||
>
|
||||
<InspectorCard>
|
||||
<Field>
|
||||
<label className="semantic-key-label" htmlFor={`animation-key-${editing.draft.id}`}>
|
||||
{t('Clé sémantique')}
|
||||
</label>
|
||||
<Input
|
||||
id={`animation-key-${editing.draft.id}`}
|
||||
value={editing.draft.semanticKey ?? ''}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
aria-invalid={Boolean(semanticKeyError)}
|
||||
aria-describedby={`animation-key-help-${editing.draft.id}`}
|
||||
onChange={event =>
|
||||
onChange({
|
||||
...editing.draft,
|
||||
semanticKey: event.currentTarget.value || undefined,
|
||||
})
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id={`animation-key-help-${editing.draft.id}`}
|
||||
className={semanticKeyError ? 'semantic-key-error' : 'field-help'}
|
||||
role={semanticKeyError ? 'alert' : undefined}
|
||||
>
|
||||
{semanticKeyError ??
|
||||
t('Clé publique stable utilisée par l’API runtime, par exemple thinking.')}
|
||||
</p>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldTitle>{t('Nom')}</FieldTitle>
|
||||
<Input
|
||||
|
||||
@ -28,6 +28,7 @@ export type BlinkSettings = {
|
||||
|
||||
export type AvatarSequence = {
|
||||
id: string
|
||||
semanticKey?: string
|
||||
name: string
|
||||
group: string
|
||||
description: string
|
||||
@ -67,6 +68,7 @@ export const createInitialSequences = (): AvatarSequence[] =>
|
||||
const playback = getStatePlaybackConfig(id)
|
||||
return {
|
||||
id,
|
||||
semanticKey: id,
|
||||
name: id,
|
||||
group,
|
||||
description:
|
||||
@ -117,6 +119,7 @@ const parseSequence = (value: unknown, fallback: AvatarSequence): AvatarSequence
|
||||
const minIntervalMs = finite(storedBlink?.minIntervalMs, fallback.blink.minIntervalMs, 100, 60000)
|
||||
return {
|
||||
id: typeof candidate?.id === 'string' ? candidate.id : fallback.id,
|
||||
...(typeof candidate?.semanticKey === 'string' ? { semanticKey: candidate.semanticKey } : {}),
|
||||
name:
|
||||
typeof candidate?.name === 'string' && candidate.name.trim() ? candidate.name : fallback.name,
|
||||
group:
|
||||
@ -227,6 +230,7 @@ export const createSequence = (expressionId = initialExpressions[0].id): AvatarS
|
||||
export const duplicateSequence = (source: AvatarSequence): AvatarSequence => ({
|
||||
...cloneSequence(source),
|
||||
id: createId('sequence'),
|
||||
semanticKey: undefined,
|
||||
name: `${source.name} copy`,
|
||||
builtIn: false,
|
||||
steps: source.steps.map(step => ({ ...step, id: createId('step') })),
|
||||
|
||||
715
src/features/avatar/__tests__/avatar-definition-test.ts
Normal file
715
src/features/avatar/__tests__/avatar-definition-test.ts
Normal file
@ -0,0 +1,715 @@
|
||||
import {
|
||||
AVATAR_DEFINITION_MAX_BYTES,
|
||||
avatarDefinitionFileName,
|
||||
createAvatarDefinition,
|
||||
getStandardAnimationAvailabilityV1,
|
||||
parseAvatarDefinition,
|
||||
STANDARD_ANIMATIONS_V1,
|
||||
STANDARD_ANIMATION_ORDER_V1,
|
||||
validateAvatarDefinition,
|
||||
type AvatarDefinition,
|
||||
} from '@/features/avatar/avatarDefinition'
|
||||
import {
|
||||
defaultAvatarColors,
|
||||
defaultAvatarEyes,
|
||||
resolveAvatarBehavior,
|
||||
type AvatarBehaviorLibrary,
|
||||
type StudioAvatar,
|
||||
} from '@/features/avatar/avatars'
|
||||
import { createInitialSequences, createSequence } from '@/features/animation/sequences'
|
||||
import { defaultExpression, initialExpressions } from '@/features/avatar/presets'
|
||||
import { surfacePresets } from '@/features/avatar/surfaces'
|
||||
import { loadStudioDocument } from '@/features/studio/studioDocument'
|
||||
|
||||
const avatarFixture = (): StudioAvatar => ({
|
||||
id: 'avatar-fixture',
|
||||
name: 'Fixture',
|
||||
body: {
|
||||
primary: {
|
||||
...surfacePresets.cone,
|
||||
width: 251.123456,
|
||||
roundness: 0.25,
|
||||
morphRoundness: 0.2,
|
||||
tipRoundness: 0.3,
|
||||
baseRoundness: 0.4,
|
||||
},
|
||||
nodes: [
|
||||
{
|
||||
id: 'shape-private',
|
||||
name: 'Private label',
|
||||
surface: { ...surfacePresets.cylinder, roundness: 0.45, morphRoundness: 0.2 },
|
||||
position: [1.25, -2.5, 3.75],
|
||||
rotation: [-10, 20, 30],
|
||||
},
|
||||
],
|
||||
},
|
||||
colors: { body: '#abcdef', eyes: '#123456' },
|
||||
eyes: {
|
||||
...defaultAvatarEyes,
|
||||
widthLeft: defaultAvatarEyes.widthLeft + 3,
|
||||
positionYRight: defaultAvatarEyes.positionYRight - 4,
|
||||
},
|
||||
})
|
||||
|
||||
const behaviorFixture = (): AvatarBehaviorLibrary => {
|
||||
const expression = {
|
||||
...initialExpressions[0],
|
||||
semanticKey: 'happy-smile',
|
||||
bodyColor: '#fedcba',
|
||||
eyeColor: '#654321',
|
||||
eyeMotion: 'microSaccades' as const,
|
||||
bodyMotion: 'slowDrift' as const,
|
||||
}
|
||||
const sequence = createInitialSequences()[0]
|
||||
return {
|
||||
expressions: [expression],
|
||||
sequences: [
|
||||
{
|
||||
...sequence,
|
||||
semanticKey: 'happy',
|
||||
playbackMode: 'once',
|
||||
steps: [
|
||||
{
|
||||
...sequence.steps[0],
|
||||
expressionId: expression.id,
|
||||
holdMs: 1234,
|
||||
transitionMs: 432,
|
||||
transition: 'snappy',
|
||||
},
|
||||
],
|
||||
blink: {
|
||||
enabled: true,
|
||||
initialDelayMs: 100,
|
||||
minIntervalMs: 250,
|
||||
maxIntervalMs: 120000,
|
||||
durationMs: 50,
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
const definitionFixture = (): AvatarDefinition => {
|
||||
const result = createAvatarDefinition({ avatar: avatarFixture(), behavior: behaviorFixture() })
|
||||
if (!result.ok) throw new Error(JSON.stringify(result.errors))
|
||||
return structuredClone(result.value) as AvatarDefinition
|
||||
}
|
||||
|
||||
const expectError = (value: unknown, code: string, path?: string) => {
|
||||
const result = validateAvatarDefinition(value)
|
||||
expect(result.ok).toBe(false)
|
||||
if (result.ok) return
|
||||
expect(result.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ code, ...(path === undefined ? {} : { path }) }),
|
||||
])
|
||||
)
|
||||
}
|
||||
|
||||
describe('avatar definition validation', () => {
|
||||
it('validates, clones, deeply freezes, and JSON round-trips a v1 definition', () => {
|
||||
const input = definitionFixture()
|
||||
const result = validateAvatarDefinition(input)
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value).not.toBe(input)
|
||||
expect(Object.isFrozen(result.value)).toBe(true)
|
||||
expect(Object.isFrozen(result.value.expressions.neutral.eyes.left)).toBe(true)
|
||||
expect(JSON.parse(JSON.stringify(result.value))).toEqual(input)
|
||||
expect(validateAvatarDefinition(JSON.parse(JSON.stringify(result.value))).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('rejects unsupported versions, unknown fields, malformed keys, and missing neutral', () => {
|
||||
expectError(
|
||||
{ ...definitionFixture(), schemaVersion: 2 },
|
||||
'unsupported_version',
|
||||
'/schemaVersion'
|
||||
)
|
||||
expectError({ ...definitionFixture(), surprise: true }, 'additionalProperties', '/surprise')
|
||||
|
||||
const malformed = definitionFixture()
|
||||
malformed.expressions['Happy Smile'] = malformed.expressions['happy-smile']
|
||||
malformed.expressionOrder.push('Happy Smile')
|
||||
expectError(malformed, 'pattern', '/expressions/Happy Smile')
|
||||
|
||||
const missingNeutral = definitionFixture()
|
||||
delete missingNeutral.expressions.neutral
|
||||
missingNeutral.expressionOrder = missingNeutral.expressionOrder.filter(key => key !== 'neutral')
|
||||
expectError(missingNeutral, 'required', '/expressions/neutral')
|
||||
})
|
||||
|
||||
it('rejects incomplete order lists and dangling animation references with JSON pointers', () => {
|
||||
const incomplete = definitionFixture()
|
||||
incomplete.expressionOrder = ['neutral']
|
||||
expectError(incomplete, 'incomplete_order', '/expressionOrder')
|
||||
|
||||
const dangling = definitionFixture()
|
||||
dangling.animations.happy.steps[0].expression = 'missing'
|
||||
expectError(dangling, 'unknown_expression', '/animations/happy/steps/0/expression')
|
||||
})
|
||||
|
||||
it('requires neutral to be the first expression-order entry', () => {
|
||||
const definition = definitionFixture()
|
||||
definition.expressionOrder = ['happy-smile', 'neutral']
|
||||
|
||||
expectError(definition, 'neutral_not_first', '/expressionOrder/0')
|
||||
})
|
||||
|
||||
it('rejects non-finite numbers, non-plain objects, and invalid blink ranges', () => {
|
||||
const nonFinite = definitionFixture()
|
||||
nonFinite.body.primary.width = Number.POSITIVE_INFINITY
|
||||
expectError(nonFinite, 'non_finite_number', '/body/primary/width')
|
||||
|
||||
const nonPlain = definitionFixture()
|
||||
nonPlain.colors = new (class {
|
||||
body = '#abcdef' as const
|
||||
eyes = '#123456' as const
|
||||
})()
|
||||
expectError(nonPlain, 'non_plain_object', '/colors')
|
||||
|
||||
const interval = definitionFixture()
|
||||
interval.animations.happy.blink.minIntervalMs = 500
|
||||
interval.animations.happy.blink.maxIntervalMs = 499
|
||||
expectError(interval, 'invalid_interval_range', '/animations/happy/blink/minIntervalMs')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['dimension minimum', (value: AvatarDefinition) => (value.body.primary.width = 0), 'minimum'],
|
||||
[
|
||||
'dimension maximum',
|
||||
(value: AvatarDefinition) => (value.body.primary.height = 10000.1),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'roundness minimum',
|
||||
(value: AvatarDefinition) => (value.body.primary.roundness = -0.1),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'roundness maximum',
|
||||
(value: AvatarDefinition) => (value.body.primary.roundness = 1.1),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'position minimum',
|
||||
(value: AvatarDefinition) => (value.body.nodes[0].position[0] = -10000.1),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'position maximum',
|
||||
(value: AvatarDefinition) => (value.body.nodes[0].position[1] = 10000.1),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'rotation minimum',
|
||||
(value: AvatarDefinition) => (value.body.nodes[0].rotation[0] = -360.1),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'rotation maximum',
|
||||
(value: AvatarDefinition) => (value.body.nodes[0].rotation[1] = 360.1),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'perspective minimum',
|
||||
(value: AvatarDefinition) => (value.expressions.neutral.perspective = 0.09),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'perspective maximum',
|
||||
(value: AvatarDefinition) => (value.expressions.neutral.perspective = 10.01),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'eye bound',
|
||||
(value: AvatarDefinition) => (value.expressions.neutral.eyes.left.x = 10001),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'head bound',
|
||||
(value: AvatarDefinition) => (value.expressions.neutral.head.z = -10001),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'hold minimum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 99),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'hold maximum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.steps[0].holdMs = 60001),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'transition minimum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = -1),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'transition maximum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.steps[0].transitionMs = 5001),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'blink initial delay',
|
||||
(value: AvatarDefinition) => (value.animations.happy.blink.initialDelayMs = 60001),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'blink interval minimum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.blink.minIntervalMs = 249),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'blink interval maximum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.blink.maxIntervalMs = 120001),
|
||||
'maximum',
|
||||
],
|
||||
[
|
||||
'blink duration minimum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 49),
|
||||
'minimum',
|
||||
],
|
||||
[
|
||||
'blink duration maximum',
|
||||
(value: AvatarDefinition) => (value.animations.happy.blink.durationMs = 2001),
|
||||
'maximum',
|
||||
],
|
||||
])('enforces the documented %s boundary', (_name, mutate, code) => {
|
||||
const definition = definitionFixture()
|
||||
mutate(definition)
|
||||
expectError(definition, code)
|
||||
})
|
||||
|
||||
it('accepts all inclusive numeric boundaries', () => {
|
||||
const definition = definitionFixture()
|
||||
definition.body.primary.width = 0.001
|
||||
definition.body.primary.height = 10000
|
||||
definition.body.primary.roundness = 0
|
||||
definition.body.primary.morphRoundness = 1
|
||||
definition.body.nodes[0].position = [-10000, 0, 10000]
|
||||
definition.body.nodes[0].rotation = [-360, 0, 360]
|
||||
definition.expressions.neutral.head = { x: -10000, y: 0, z: 10000 }
|
||||
definition.expressions.neutral.perspective = 0.1
|
||||
definition.expressions['happy-smile'].perspective = 10
|
||||
definition.animations.happy.steps[0].holdMs = 60000
|
||||
definition.animations.happy.steps[0].transitionMs = 5000
|
||||
definition.animations.happy.blink = {
|
||||
enabled: false,
|
||||
initialDelayMs: 60000,
|
||||
minIntervalMs: 250,
|
||||
maxIntervalMs: 120000,
|
||||
durationMs: 2000,
|
||||
}
|
||||
|
||||
expect(validateAvatarDefinition(definition).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces collection and text limits', () => {
|
||||
const nodes = definitionFixture()
|
||||
nodes.body.nodes = Array.from({ length: 17 }, () => structuredClone(nodes.body.nodes[0]))
|
||||
expectError(nodes, 'maxItems', '/body/nodes')
|
||||
|
||||
const steps = definitionFixture()
|
||||
steps.animations.happy.steps = Array.from({ length: 129 }, () => ({
|
||||
...steps.animations.happy.steps[0],
|
||||
}))
|
||||
expectError(steps, 'maxItems', '/animations/happy/steps')
|
||||
|
||||
const text = definitionFixture()
|
||||
text.name = 'n'.repeat(121)
|
||||
text.animations.happy.metadata!.label = 'l'.repeat(121)
|
||||
text.animations.happy.metadata!.description = 'd'.repeat(513)
|
||||
text.animations.happy.metadata!.group = 'g'.repeat(65)
|
||||
expectError(text, 'maxLength')
|
||||
})
|
||||
|
||||
it('enforces expression and animation collection limits', () => {
|
||||
const expressions = definitionFixture()
|
||||
const pose = expressions.expressions['happy-smile']
|
||||
expressions.expressions = { neutral: expressions.expressions.neutral }
|
||||
expressions.expressionOrder = ['neutral']
|
||||
for (let index = 0; index < 128; index += 1) {
|
||||
const key = `pose-${index}`
|
||||
expressions.expressions[key] = structuredClone(pose)
|
||||
expressions.expressionOrder.push(key)
|
||||
}
|
||||
expectError(expressions, 'maxProperties', '/expressions')
|
||||
|
||||
const animations = definitionFixture()
|
||||
const animation = animations.animations.happy
|
||||
animations.animations = {}
|
||||
animations.animationOrder = []
|
||||
for (let index = 0; index < 65; index += 1) {
|
||||
const key = `animation-${index}`
|
||||
animations.animations[key] = structuredClone(animation)
|
||||
animations.animationOrder.push(key)
|
||||
}
|
||||
expectError(animations, 'maxProperties', '/animations')
|
||||
})
|
||||
|
||||
it('accepts exact collection and text maxima', () => {
|
||||
const definition = definitionFixture()
|
||||
definition.name = 'n'.repeat(120)
|
||||
definition.animations.happy.metadata = {
|
||||
label: 'l'.repeat(120),
|
||||
description: 'd'.repeat(512),
|
||||
group: 'g'.repeat(64),
|
||||
}
|
||||
definition.body.nodes = Array.from({ length: 16 }, () =>
|
||||
structuredClone(definition.body.nodes[0])
|
||||
)
|
||||
definition.animations.happy.steps = Array.from({ length: 128 }, () => ({
|
||||
...definition.animations.happy.steps[0],
|
||||
}))
|
||||
|
||||
expect(validateAvatarDefinition(definition).ok).toBe(true)
|
||||
})
|
||||
|
||||
it('enforces the 64-character semantic-key maximum', () => {
|
||||
const definition = definitionFixture()
|
||||
const expression = definition.expressions['happy-smile']
|
||||
delete definition.expressions['happy-smile']
|
||||
const key = `a${'b'.repeat(64)}`
|
||||
definition.expressions[key] = expression
|
||||
definition.expressionOrder = ['neutral', key]
|
||||
|
||||
expectError(definition, 'maxLength', `/expressions/${key}`)
|
||||
})
|
||||
|
||||
it('requires lowercase six-digit colors and known secondary surface types', () => {
|
||||
const color = definitionFixture()
|
||||
color.colors.body = '#ABCDEF'
|
||||
expectError(color, 'pattern', '/colors/body')
|
||||
|
||||
const surface = definitionFixture()
|
||||
surface.body.nodes[0].surface.type = 'cursor' as 'sphere'
|
||||
expectError(surface, 'enum', '/body/nodes/0/surface/type')
|
||||
})
|
||||
})
|
||||
|
||||
describe('bounded avatar JSON parser', () => {
|
||||
it('rejects duplicate object members at the second member path', () => {
|
||||
const result = parseAvatarDefinition('{"schema":1,"schema":2}')
|
||||
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [expect.objectContaining({ path: '/schema', code: 'duplicate_key' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('rejects oversized UTF-8 input and excessive nesting', () => {
|
||||
const oversized = parseAvatarDefinition(' '.repeat(AVATAR_DEFINITION_MAX_BYTES + 1))
|
||||
expect(oversized).toEqual({
|
||||
ok: false,
|
||||
errors: [expect.objectContaining({ path: '', code: 'max_bytes' })],
|
||||
})
|
||||
|
||||
const nested = parseAvatarDefinition(`${'['.repeat(33)}null${']'.repeat(33)}`)
|
||||
expect(nested).toEqual({
|
||||
ok: false,
|
||||
errors: [expect.objectContaining({ code: 'max_depth' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('accepts the exact byte limit and depth limit before schema validation', () => {
|
||||
const json = JSON.stringify(definitionFixture())
|
||||
const jsonBytes = new TextEncoder().encode(json).byteLength
|
||||
const exactBytes = `${json}${' '.repeat(AVATAR_DEFINITION_MAX_BYTES - jsonBytes)}`
|
||||
expect(new TextEncoder().encode(exactBytes)).toHaveLength(AVATAR_DEFINITION_MAX_BYTES)
|
||||
expect(parseAvatarDefinition(exactBytes).ok).toBe(true)
|
||||
|
||||
const depth32 = `${'['.repeat(32)}null${']'.repeat(32)}`
|
||||
const result = parseAvatarDefinition(depth32)
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) expect(result.errors.some(error => error.code === 'max_depth')).toBe(false)
|
||||
})
|
||||
|
||||
it('rejects overlong decoded strings before schema validation', () => {
|
||||
const result = parseAvatarDefinition(`{"name":"${'a'.repeat(513)}"}`)
|
||||
expect(result).toEqual({
|
||||
ok: false,
|
||||
errors: [expect.objectContaining({ path: '/name', code: 'string_too_long' })],
|
||||
})
|
||||
})
|
||||
|
||||
it('applies semantic-key limits while tokenizing and treats prototype keys as data', () => {
|
||||
const longKey = `a${'b'.repeat(64)}`
|
||||
const longKeyResult = parseAvatarDefinition(`{"expressions":{"${longKey}":null}}`)
|
||||
expect(longKeyResult).toEqual({
|
||||
ok: false,
|
||||
errors: [
|
||||
expect.objectContaining({
|
||||
path: `/expressions/${longKey}`,
|
||||
code: 'string_too_long',
|
||||
}),
|
||||
],
|
||||
})
|
||||
|
||||
const prototypeResult = parseAvatarDefinition('{"__proto__":{"polluted":true}}')
|
||||
expect(prototypeResult.ok).toBe(false)
|
||||
expect(({} as { polluted?: boolean }).polluted).toBeUndefined()
|
||||
if (!prototypeResult.ok) {
|
||||
expect(prototypeResult.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ path: '/__proto__', code: 'additionalProperties' }),
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('parses a valid definition and rejects malformed JSON', () => {
|
||||
expect(parseAvatarDefinition(JSON.stringify(definitionFixture())).ok).toBe(true)
|
||||
const malformed = parseAvatarDefinition('{"schema":]')
|
||||
expect(malformed).toEqual({
|
||||
ok: false,
|
||||
errors: [expect.objectContaining({ code: 'invalid_json' })],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Studio to avatar definition conversion', () => {
|
||||
it('exports the complete bundled document with curated semantic keys', () => {
|
||||
const document = loadStudioDocument({ getItem: () => null })
|
||||
const avatar = document.library.avatars[0]
|
||||
const behavior = resolveAvatarBehavior(avatar, {
|
||||
expressions: document.expressions,
|
||||
sequences: document.sequences,
|
||||
})
|
||||
|
||||
const result = createAvatarDefinition({ avatar, behavior })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.expressionOrder).toHaveLength(28)
|
||||
expect(result.value.expressionOrder[0]).toBe('neutral')
|
||||
expect(result.value.animationOrder).toHaveLength(23)
|
||||
expect(result.value.animationOrder).toContain('idle')
|
||||
expect(result.value.animations.idle.steps.map(step => step.expression)).toEqual([
|
||||
'upward-side-glance',
|
||||
'curious-left',
|
||||
])
|
||||
expect(getStandardAnimationAvailabilityV1(result.value.expressions).available).toEqual(
|
||||
STANDARD_ANIMATION_ORDER_V1
|
||||
)
|
||||
expect(JSON.stringify(result.value)).not.toContain('expression-00')
|
||||
})
|
||||
|
||||
it('rejects newly created custom content until semantic keys are supplied', () => {
|
||||
const expression = { ...initialExpressions[0], id: 'expression-custom', semanticKey: undefined }
|
||||
const sequence = createSequence(expression.id)
|
||||
|
||||
expect(expression.semanticKey).toBeUndefined()
|
||||
expect(sequence.semanticKey).toBeUndefined()
|
||||
const result = createAvatarDefinition({
|
||||
avatar: avatarFixture(),
|
||||
behavior: { expressions: [expression], sequences: [sequence] },
|
||||
})
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok) {
|
||||
expect(result.errors.map(error => error.code)).toEqual(
|
||||
expect.arrayContaining(['missing_semantic_key'])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves geometry, colors, timing, motion, and resolves avatar eye defaults', () => {
|
||||
const avatar = avatarFixture()
|
||||
const behavior = behaviorFixture()
|
||||
const result = createAvatarDefinition({ avatar, behavior })
|
||||
|
||||
expect(result.ok).toBe(true)
|
||||
if (!result.ok) return
|
||||
expect(result.value.body.primary).toEqual(avatar.body.primary)
|
||||
expect(result.value.body.nodes).toEqual([
|
||||
{
|
||||
surface: avatar.body.nodes[0].surface,
|
||||
position: avatar.body.nodes[0].position,
|
||||
rotation: avatar.body.nodes[0].rotation,
|
||||
},
|
||||
])
|
||||
expect(result.value.body.nodes[0]).not.toHaveProperty('id')
|
||||
expect(result.value.body.nodes[0]).not.toHaveProperty('name')
|
||||
expect(result.value.colors).toEqual(avatar.colors)
|
||||
expect(result.value.expressions.neutral.eyes.left.width).toBe(defaultExpression.widthLeft + 3)
|
||||
expect(result.value.expressions.neutral.eyes.right.y).toBe(defaultExpression.positionYRight - 4)
|
||||
expect(result.value.expressions['happy-smile'].eyes.left.width).toBeCloseTo(
|
||||
behavior.expressions[0].widthLeft + 3
|
||||
)
|
||||
expect(result.value.expressions['happy-smile'].colors).toEqual({
|
||||
body: '#fedcba',
|
||||
eyes: '#654321',
|
||||
})
|
||||
expect(result.value.expressions['happy-smile'].motion).toEqual({
|
||||
eyes: 'microSaccades',
|
||||
body: 'slowDrift',
|
||||
})
|
||||
expect(result.value.expressionOrder).toEqual(['neutral', 'happy-smile'])
|
||||
expect(result.value.animations.happy).toMatchObject({
|
||||
playbackMode: 'once',
|
||||
steps: [
|
||||
{
|
||||
expression: 'happy-smile',
|
||||
holdMs: 1234,
|
||||
transitionMs: 432,
|
||||
transition: 'snappy',
|
||||
},
|
||||
],
|
||||
blink: behavior.sequences[0].blink,
|
||||
})
|
||||
})
|
||||
|
||||
it.each([
|
||||
[undefined, 'missing_semantic_key'],
|
||||
['Bad Key', 'invalid_semantic_key'],
|
||||
['neutral', 'reserved_semantic_key'],
|
||||
])('rejects an unexportable expression semantic key', (semanticKey, code) => {
|
||||
const behavior = behaviorFixture()
|
||||
behavior.expressions[0].semanticKey = semanticKey
|
||||
const result = createAvatarDefinition({ avatar: avatarFixture(), behavior })
|
||||
|
||||
expect(result.ok).toBe(false)
|
||||
if (!result.ok)
|
||||
expect(result.errors).toEqual(expect.arrayContaining([expect.objectContaining({ code })]))
|
||||
})
|
||||
|
||||
it('rejects duplicate keys and unresolved animation expression references', () => {
|
||||
const duplicate = behaviorFixture()
|
||||
duplicate.expressions.push({ ...duplicate.expressions[0], id: 'other-expression' })
|
||||
const duplicateResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: duplicate })
|
||||
expect(duplicateResult.ok).toBe(false)
|
||||
if (!duplicateResult.ok) {
|
||||
expect(duplicateResult.errors).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ code: 'duplicate_semantic_key' })])
|
||||
)
|
||||
}
|
||||
|
||||
const dangling = behaviorFixture()
|
||||
dangling.sequences[0].steps[0].expressionId = 'missing-expression'
|
||||
const danglingResult = createAvatarDefinition({ avatar: avatarFixture(), behavior: dangling })
|
||||
expect(danglingResult.ok).toBe(false)
|
||||
if (!danglingResult.ok) {
|
||||
expect(danglingResult.errors).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
path: '/studio/animations/0/steps/0/expressionId',
|
||||
code: 'unresolved_expression_reference',
|
||||
}),
|
||||
])
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
it('does not mutate Studio inputs', () => {
|
||||
const avatar = avatarFixture()
|
||||
const behavior = behaviorFixture()
|
||||
const before = JSON.stringify({ avatar, behavior })
|
||||
|
||||
createAvatarDefinition({ avatar, behavior })
|
||||
|
||||
expect(JSON.stringify({ avatar, behavior })).toBe(before)
|
||||
expect(defaultAvatarColors).toEqual({ body: '#5b7fe5', eyes: '#111316' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('standard animation catalogue v1', () => {
|
||||
it('commits the approved keys, order, steps, timing, and blink settings', () => {
|
||||
expect(STANDARD_ANIMATION_ORDER_V1).toEqual([
|
||||
'idle',
|
||||
'happy',
|
||||
'sad',
|
||||
'thinking',
|
||||
'excited',
|
||||
'celebrate',
|
||||
])
|
||||
expect(STANDARD_ANIMATIONS_V1.idle).toEqual({
|
||||
requiredExpressions: ['upward-side-glance', 'curious-left'],
|
||||
playbackMode: 'loop',
|
||||
steps: [
|
||||
{
|
||||
expression: 'upward-side-glance',
|
||||
holdMs: 5200,
|
||||
transitionMs: 500,
|
||||
transition: 'smooth',
|
||||
},
|
||||
{
|
||||
expression: 'curious-left',
|
||||
holdMs: 5200,
|
||||
transitionMs: 500,
|
||||
transition: 'smooth',
|
||||
},
|
||||
],
|
||||
blink: {
|
||||
enabled: true,
|
||||
initialDelayMs: 2600,
|
||||
minIntervalMs: 3400,
|
||||
maxIntervalMs: 6200,
|
||||
durationMs: 280,
|
||||
},
|
||||
})
|
||||
expect(Object.values(STANDARD_ANIMATIONS_V1).every(animation => animation.steps.length)).toBe(
|
||||
true
|
||||
)
|
||||
expect(
|
||||
Object.values(STANDARD_ANIMATIONS_V1).every(animation =>
|
||||
animation.steps.every(
|
||||
step =>
|
||||
step.transitionMs === 500 &&
|
||||
step.transition === 'smooth' &&
|
||||
animation.requiredExpressions.includes(step.expression)
|
||||
)
|
||||
)
|
||||
).toBe(true)
|
||||
expect(
|
||||
Object.fromEntries(
|
||||
STANDARD_ANIMATION_ORDER_V1.map(key => [
|
||||
key,
|
||||
STANDARD_ANIMATIONS_V1[key].steps.map(step => step.expression),
|
||||
])
|
||||
)
|
||||
).toEqual({
|
||||
idle: ['upward-side-glance', 'curious-left'],
|
||||
happy: ['joyful-down-right', 'joyful-wide', 'playful-right', 'gentle-downward-gaze'],
|
||||
sad: ['sleepy-squint', 'eyes-closed', 'drowsy-closed'],
|
||||
thinking: [
|
||||
'curious-left',
|
||||
'angry-left',
|
||||
'skeptical-left',
|
||||
'playful-right',
|
||||
'skeptical-right',
|
||||
],
|
||||
excited: [
|
||||
'joyful-down-right',
|
||||
'playful-right',
|
||||
'surprised-wide-left',
|
||||
'surprised-left',
|
||||
'joyful-wide',
|
||||
],
|
||||
celebrate: ['joyful-down-right', 'curious-left', 'playful-right'],
|
||||
})
|
||||
})
|
||||
|
||||
it('reports available standards and exact missing semantic expressions', () => {
|
||||
const definition = definitionFixture()
|
||||
const availability = getStandardAnimationAvailabilityV1(definition.expressions)
|
||||
|
||||
expect(availability.available).toEqual([])
|
||||
expect(availability.unavailable.find(item => item.key === 'idle')).toEqual({
|
||||
key: 'idle',
|
||||
missingExpressions: ['upward-side-glance', 'curious-left'],
|
||||
})
|
||||
|
||||
const idleExpressions = {
|
||||
...definition.expressions,
|
||||
'upward-side-glance': definition.expressions.neutral,
|
||||
'curious-left': definition.expressions.neutral,
|
||||
}
|
||||
expect(getStandardAnimationAvailabilityV1(idleExpressions).available).toEqual(['idle'])
|
||||
})
|
||||
|
||||
it('creates a sanitized runtime-definition filename', () => {
|
||||
expect(avatarDefinitionFileName(' Éric Avatar! ')).toBe('eric-avatar.avatar.json')
|
||||
expect(avatarDefinitionFileName('***')).toBe('avatar.avatar.json')
|
||||
})
|
||||
})
|
||||
@ -4,6 +4,7 @@ import {
|
||||
applyAvatarEyeDefaults,
|
||||
cloneAvatarBehavior,
|
||||
createAvatar,
|
||||
createUnkeyedExpressionCopy,
|
||||
defaultAvatarEyes,
|
||||
parseAvatarEyeDefaults,
|
||||
parseAvatarRenderStyle,
|
||||
@ -12,6 +13,16 @@ import {
|
||||
import { initialExpressions } from '@/features/avatar/presets'
|
||||
|
||||
describe('avatar eye defaults', () => {
|
||||
it('clears the public semantic key when creating custom content from a preset', () => {
|
||||
const source = { ...defaultExpression, semanticKey: 'attentive-left' }
|
||||
|
||||
const copy = createUnkeyedExpressionCopy(source, 'expression-copy')
|
||||
|
||||
expect(copy.id).toBe('expression-copy')
|
||||
expect(copy.semanticKey).toBeUndefined()
|
||||
expect(source.semanticKey).toBe('attentive-left')
|
||||
})
|
||||
|
||||
it('keeps the historical rendering when using default values', () => {
|
||||
expect(applyAvatarEyeDefaults(defaultExpression, defaultAvatarEyes)).toEqual(defaultExpression)
|
||||
})
|
||||
|
||||
@ -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)
|
||||
})
|
||||
@ -1,115 +1 @@
|
||||
import type { BodyMotion, Expression, EyeMotion } from './geometry'
|
||||
|
||||
export const eyeMotionModes = ['none', 'microSaccades', 'shake'] as const
|
||||
export const bodyMotionModes = ['none', 'slowDrift', 'shake'] as const
|
||||
const eyeMotionSet = new Set<string>(eyeMotionModes)
|
||||
const bodyMotionSet = new Set<string>(bodyMotionModes)
|
||||
export const isEyeMotion = (value: unknown): value is EyeMotion =>
|
||||
typeof value === 'string' && eyeMotionSet.has(value)
|
||||
export const isBodyMotion = (value: unknown): value is BodyMotion =>
|
||||
typeof value === 'string' && bodyMotionSet.has(value)
|
||||
|
||||
const smoothstep = (value: number) => value * value * (3 - 2 * value)
|
||||
const hash = (value: number) => {
|
||||
const raw = Math.sin(value * 127.1 + 311.7) * 43758.5453
|
||||
return (raw - Math.floor(raw)) * 2 - 1
|
||||
}
|
||||
|
||||
const expressionSeed = (expression: Expression) =>
|
||||
expression.headX * 0.71 + expression.headY * 1.13 + expression.headZ * 1.37
|
||||
const EYE_MOTION_SEED = 17.29
|
||||
|
||||
const smoothNoise = (elapsedMs: number, axis: number, seed: number, interval: number) => {
|
||||
const progress = elapsedMs / interval
|
||||
const step = Math.floor(progress)
|
||||
const blend = smoothstep(progress - step)
|
||||
const previous = hash(step * 3 + axis + seed)
|
||||
const next = hash((step + 1) * 3 + axis + seed)
|
||||
return previous + (next - previous) * blend
|
||||
}
|
||||
|
||||
const saccade = (elapsedMs: number, axis: number, seed: number) => {
|
||||
const interval = 1100
|
||||
const duration = 140
|
||||
if (elapsedMs <= 0) return 0
|
||||
const step = Math.floor(elapsedMs / interval)
|
||||
const progress = (elapsedMs - step * interval) / duration
|
||||
const blend = smoothstep(Math.min(progress, 1))
|
||||
const previous = step === 0 ? 0 : hash((step - 1) * 2 + axis + seed)
|
||||
const next = hash(step * 2 + axis + seed)
|
||||
return previous + (next - previous) * blend
|
||||
}
|
||||
|
||||
export const hasAmbientMotion = (expression: Expression) =>
|
||||
expression.eyeMotion !== 'none' || expression.bodyMotion !== 'none'
|
||||
|
||||
export const ambientBodyOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
|
||||
const seed = expressionSeed(expression)
|
||||
if (expression.bodyMotion === 'slowDrift') {
|
||||
return {
|
||||
x: smoothNoise(elapsedMs, 3, seed, 2900) * 1.45 * strength,
|
||||
y: smoothNoise(elapsedMs, 4, seed, 3700) * 1.1 * strength,
|
||||
}
|
||||
}
|
||||
if (expression.bodyMotion === 'shake') {
|
||||
const time = elapsedMs / 1000
|
||||
return {
|
||||
x: (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.35 * strength,
|
||||
y: (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.1 * strength,
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
export const ambientEyeOffset = (expression: Expression, elapsedMs: number, strength = 1) => {
|
||||
if (expression.eyeMotion === 'microSaccades') {
|
||||
return {
|
||||
x: saccade(elapsedMs, 0, EYE_MOTION_SEED) * 1.5 * strength,
|
||||
y: saccade(elapsedMs, 1, EYE_MOTION_SEED) * 0.9 * strength,
|
||||
}
|
||||
}
|
||||
if (expression.eyeMotion === 'shake') {
|
||||
const time = elapsedMs / 1000
|
||||
return {
|
||||
x: (Math.sin(time * 47) + Math.sin(time * 71) * 0.45) * 1.2 * strength,
|
||||
y: (Math.sin(time * 59) + Math.sin(time * 83) * 0.4) * 0.8 * strength,
|
||||
}
|
||||
}
|
||||
return { x: 0, y: 0 }
|
||||
}
|
||||
|
||||
export const applyAmbientBodyMotion = (
|
||||
expression: Expression,
|
||||
elapsedMs: number,
|
||||
strength = 1
|
||||
): Expression => {
|
||||
const next = { ...expression }
|
||||
const seed = expressionSeed(expression)
|
||||
|
||||
if (expression.bodyMotion === 'slowDrift') {
|
||||
next.headX += smoothNoise(elapsedMs, 0, seed, 2600) * 0.8 * strength
|
||||
next.headY += smoothNoise(elapsedMs, 1, seed, 3300) * 1.15 * strength
|
||||
next.headZ += smoothNoise(elapsedMs, 2, seed, 4100) * 0.45 * strength
|
||||
} else if (expression.bodyMotion === 'shake') {
|
||||
const time = elapsedMs / 1000
|
||||
next.headX += (Math.sin(time * 31) + Math.sin(time * 53) * 0.45) * 1.15 * strength
|
||||
next.headY += (Math.sin(time * 37) + Math.sin(time * 61) * 0.4) * 1.35 * strength
|
||||
next.headZ += Math.sin(time * 43) * 0.7 * strength
|
||||
}
|
||||
|
||||
return next
|
||||
}
|
||||
|
||||
export const applyAmbientMotion = (
|
||||
expression: Expression,
|
||||
elapsedMs: number,
|
||||
strength = 1
|
||||
): Expression => {
|
||||
const next = applyAmbientBodyMotion(expression, elapsedMs, strength)
|
||||
const eyeOffset = ambientEyeOffset(expression, elapsedMs, strength)
|
||||
next.positionXLeft += eyeOffset.x
|
||||
next.positionXRight += eyeOffset.x
|
||||
next.positionYLeft += eyeOffset.y
|
||||
next.positionYRight += eyeOffset.y
|
||||
return next
|
||||
}
|
||||
export * from '@bible-strong/avatar-core'
|
||||
|
||||
192
src/features/avatar/avatarDefinition.ts
Normal file
192
src/features/avatar/avatarDefinition.ts
Normal file
@ -0,0 +1,192 @@
|
||||
import {
|
||||
getSemanticKeyIssue,
|
||||
validateAvatarDefinition,
|
||||
type AvatarAnimationDefinition,
|
||||
type AvatarBodyNodeDefinition,
|
||||
type AvatarDefinition,
|
||||
type AvatarDefinitionError,
|
||||
type AvatarExpressionDefinition,
|
||||
type BodyNodeSurfaceType,
|
||||
type HexColor,
|
||||
type SurfaceDefinition,
|
||||
type ValidationResult,
|
||||
} from '@bible-strong/avatar-core'
|
||||
import type { SurfaceType } from '@bible-strong/avatar-core'
|
||||
|
||||
import { applyAvatarEyeDefaults, type AvatarBehaviorLibrary, type StudioAvatar } from './avatars'
|
||||
import { defaultExpression } from './presets'
|
||||
import type { Expression } from './geometry'
|
||||
|
||||
export * from '@bible-strong/avatar-core'
|
||||
|
||||
const mapSurface = <TType extends SurfaceType>(
|
||||
surface: SurfaceDefinition<TType>
|
||||
): SurfaceDefinition<TType> => ({
|
||||
type: surface.type,
|
||||
width: surface.width,
|
||||
height: surface.height,
|
||||
depth: surface.depth,
|
||||
roundness: surface.roundness,
|
||||
...(surface.morphRoundness === undefined ? {} : { morphRoundness: surface.morphRoundness }),
|
||||
...(surface.tipRoundness === undefined ? {} : { tipRoundness: surface.tipRoundness }),
|
||||
...(surface.baseRoundness === undefined ? {} : { baseRoundness: surface.baseRoundness }),
|
||||
})
|
||||
|
||||
const mapExpression = (expression: Expression): AvatarExpressionDefinition => ({
|
||||
head: { x: expression.headX, y: expression.headY, z: expression.headZ },
|
||||
eyes: {
|
||||
left: {
|
||||
width: expression.widthLeft,
|
||||
height: expression.heightLeft,
|
||||
x: expression.positionXLeft,
|
||||
y: expression.positionYLeft,
|
||||
angle: expression.leftAngle,
|
||||
},
|
||||
right: {
|
||||
width: expression.widthRight,
|
||||
height: expression.heightRight,
|
||||
x: expression.positionXRight,
|
||||
y: expression.positionYRight,
|
||||
angle: expression.rightAngle,
|
||||
},
|
||||
spacing: expression.spacing,
|
||||
},
|
||||
perspective: expression.perspective,
|
||||
motion: { eyes: expression.eyeMotion, body: expression.bodyMotion },
|
||||
...(expression.bodyColor || expression.eyeColor
|
||||
? {
|
||||
colors: {
|
||||
...(expression.bodyColor ? { body: expression.bodyColor as HexColor } : {}),
|
||||
...(expression.eyeColor ? { eyes: expression.eyeColor as HexColor } : {}),
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
|
||||
const semanticKeyError = (
|
||||
path: string,
|
||||
kind: 'expression' | 'animation',
|
||||
semanticKey: string | undefined,
|
||||
seen: Set<string>
|
||||
): AvatarDefinitionError | undefined => {
|
||||
const issue = getSemanticKeyIssue(semanticKey, kind)
|
||||
if (issue === 'missing_semantic_key') {
|
||||
return { path, code: 'missing_semantic_key', message: `${kind} semantic key is required` }
|
||||
}
|
||||
if (issue === 'invalid_semantic_key') {
|
||||
return { path, code: 'invalid_semantic_key', message: `Invalid semantic key '${semanticKey}'` }
|
||||
}
|
||||
if (issue === 'reserved_semantic_key') {
|
||||
return { path, code: 'reserved_semantic_key', message: "'neutral' is reserved" }
|
||||
}
|
||||
if (semanticKey === undefined) {
|
||||
return { path, code: 'missing_semantic_key', message: `${kind} semantic key is required` }
|
||||
}
|
||||
if (seen.has(semanticKey)) {
|
||||
return {
|
||||
path,
|
||||
code: 'duplicate_semantic_key',
|
||||
message: `Duplicate semantic key '${semanticKey}'`,
|
||||
}
|
||||
}
|
||||
seen.add(semanticKey)
|
||||
return undefined
|
||||
}
|
||||
|
||||
export const createAvatarDefinition = ({
|
||||
avatar,
|
||||
behavior,
|
||||
}: {
|
||||
avatar: StudioAvatar
|
||||
behavior: AvatarBehaviorLibrary
|
||||
}): ValidationResult<AvatarDefinition> => {
|
||||
const errors: AvatarDefinitionError[] = []
|
||||
const expressionKeys = new Set<string>()
|
||||
const animationKeys = new Set<string>()
|
||||
const expressionKeyById = new Map<string, string>()
|
||||
|
||||
behavior.expressions.forEach((expression, index) => {
|
||||
const error = semanticKeyError(
|
||||
`/studio/expressions/${index}/semanticKey`,
|
||||
'expression',
|
||||
expression.semanticKey,
|
||||
expressionKeys
|
||||
)
|
||||
if (error) errors.push(error)
|
||||
else expressionKeyById.set(expression.id, expression.semanticKey!)
|
||||
})
|
||||
behavior.sequences.forEach((sequence, index) => {
|
||||
const error = semanticKeyError(
|
||||
`/studio/animations/${index}/semanticKey`,
|
||||
'animation',
|
||||
sequence.semanticKey,
|
||||
animationKeys
|
||||
)
|
||||
if (error) errors.push(error)
|
||||
sequence.steps.forEach((step, stepIndex) => {
|
||||
if (!expressionKeyById.has(step.expressionId)) {
|
||||
errors.push({
|
||||
path: `/studio/animations/${index}/steps/${stepIndex}/expressionId`,
|
||||
code: 'unresolved_expression_reference',
|
||||
message: `Animation step references unexportable expression '${step.expressionId}'`,
|
||||
})
|
||||
}
|
||||
})
|
||||
})
|
||||
if (errors.length) return { ok: false, errors }
|
||||
|
||||
const expressions: Record<string, AvatarExpressionDefinition> = {
|
||||
neutral: mapExpression(applyAvatarEyeDefaults(defaultExpression, avatar.eyes)),
|
||||
}
|
||||
behavior.expressions.forEach(expression => {
|
||||
expressions[expression.semanticKey!] = mapExpression(
|
||||
applyAvatarEyeDefaults(expression, avatar.eyes)
|
||||
)
|
||||
})
|
||||
const animations: Record<string, AvatarAnimationDefinition> = Object.fromEntries(
|
||||
behavior.sequences.map(sequence => [
|
||||
sequence.semanticKey!,
|
||||
{
|
||||
playbackMode: sequence.playbackMode,
|
||||
steps: sequence.steps.map(step => ({
|
||||
expression: expressionKeyById.get(step.expressionId)!,
|
||||
holdMs: step.holdMs,
|
||||
transitionMs: step.transitionMs,
|
||||
transition: step.transition,
|
||||
})),
|
||||
blink: { ...sequence.blink },
|
||||
metadata: {
|
||||
label: sequence.name,
|
||||
description: sequence.description,
|
||||
group: sequence.group,
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
const definition: AvatarDefinition = {
|
||||
schema: 'bible-strong/avatar-definition',
|
||||
schemaVersion: 1,
|
||||
...(avatar.name ? { name: avatar.name } : {}),
|
||||
body: {
|
||||
primary: mapSurface(avatar.body.primary),
|
||||
nodes: avatar.body.nodes.map((node): AvatarBodyNodeDefinition => ({
|
||||
surface: mapSurface(node.surface as SurfaceDefinition<BodyNodeSurfaceType>),
|
||||
position: [...node.position],
|
||||
rotation: [...node.rotation],
|
||||
})),
|
||||
},
|
||||
colors: {
|
||||
body: avatar.colors.body as HexColor,
|
||||
eyes: avatar.colors.eyes as HexColor,
|
||||
},
|
||||
expressions,
|
||||
expressionOrder: [
|
||||
'neutral',
|
||||
...behavior.expressions.map(expression => expression.semanticKey!),
|
||||
],
|
||||
animations,
|
||||
animationOrder: behavior.sequences.map(sequence => sequence.semanticKey!),
|
||||
standardAnimationSet: 1,
|
||||
}
|
||||
return validateAvatarDefinition(definition)
|
||||
}
|
||||
@ -120,6 +120,12 @@ export const applyAvatarEyeDefaults = (
|
||||
return result
|
||||
}
|
||||
|
||||
export const createUnkeyedExpressionCopy = (source: Expression, id: string): Expression => ({
|
||||
...source,
|
||||
id,
|
||||
semanticKey: undefined,
|
||||
})
|
||||
|
||||
export type AvatarLibrary = {
|
||||
activeAvatarId: string
|
||||
avatars: StudioAvatar[]
|
||||
@ -153,6 +159,7 @@ export const parseExpressions = (value: unknown): Expression[] => {
|
||||
parsed.bodyColor = candidate.bodyColor
|
||||
if (typeof candidate.eyeColor === 'string' && hexColor.test(candidate.eyeColor))
|
||||
parsed.eyeColor = candidate.eyeColor
|
||||
if (typeof candidate.semanticKey === 'string') parsed.semanticKey = candidate.semanticKey
|
||||
parsed.eyeMotion = isEyeMotion(storedEyeMotion) ? storedEyeMotion : defaultExpression.eyeMotion
|
||||
parsed.bodyMotion = isBodyMotion(storedBodyMotion)
|
||||
? storedBodyMotion
|
||||
@ -173,6 +180,41 @@ export const cloneAvatarBehavior = (behavior: AvatarBehaviorLibrary): AvatarBeha
|
||||
sequences: cloneSequences(behavior.sequences),
|
||||
})
|
||||
|
||||
export const restoreLegacyBehaviorSemanticKeys = (
|
||||
behavior: AvatarBehaviorLibrary,
|
||||
reference: AvatarBehaviorLibrary
|
||||
): AvatarBehaviorLibrary => {
|
||||
const expressionKeys = new Map(
|
||||
reference.expressions.flatMap(expression =>
|
||||
expression.semanticKey ? [[expression.id, expression.semanticKey] as const] : []
|
||||
)
|
||||
)
|
||||
const sequenceKeys = new Map(
|
||||
reference.sequences.flatMap(sequence =>
|
||||
sequence.semanticKey ? [[sequence.id, sequence.semanticKey] as const] : []
|
||||
)
|
||||
)
|
||||
const restoreExpressions = behavior.expressions.every(
|
||||
expression => expression.semanticKey === undefined
|
||||
)
|
||||
const restoreSequences = behavior.sequences.every(sequence => sequence.semanticKey === undefined)
|
||||
|
||||
return {
|
||||
expressions: restoreExpressions
|
||||
? behavior.expressions.map(expression => {
|
||||
const semanticKey = expressionKeys.get(expression.id)
|
||||
return semanticKey ? { ...expression, semanticKey } : expression
|
||||
})
|
||||
: behavior.expressions,
|
||||
sequences: restoreSequences
|
||||
? behavior.sequences.map(sequence => {
|
||||
const semanticKey = sequenceKeys.get(sequence.id)
|
||||
return semanticKey ? { ...sequence, semanticKey } : sequence
|
||||
})
|
||||
: behavior.sequences,
|
||||
}
|
||||
}
|
||||
|
||||
export const resolveAvatarBehavior = (
|
||||
avatar: StudioAvatar,
|
||||
base: AvatarBehaviorLibrary
|
||||
@ -186,13 +228,18 @@ const parseAvatarBehavior = (
|
||||
const candidate = value as Partial<AvatarBehaviorLibrary>
|
||||
if (!Array.isArray(candidate.expressions) || !candidate.expressions.length) return undefined
|
||||
const expressions = parseExpressions(candidate.expressions)
|
||||
const sequences = normalizeSequencesForExpressions(
|
||||
return restoreLegacyBehaviorSemanticKeys(
|
||||
{
|
||||
expressions,
|
||||
sequences: normalizeSequencesForExpressions(
|
||||
Array.isArray(candidate.sequences)
|
||||
? parseSequences(candidate.sequences)
|
||||
: cloneSequences(base.sequences),
|
||||
expressions
|
||||
),
|
||||
},
|
||||
base
|
||||
)
|
||||
return { expressions, sequences }
|
||||
}
|
||||
|
||||
export const createAvatar = (name: string): StudioAvatar => ({
|
||||
|
||||
@ -1,117 +1 @@
|
||||
import { surfaceLabels, surfacePresets, type SurfaceConfig, type SurfaceType } from './surfaces'
|
||||
|
||||
export type BodyVector = readonly [number, number, number]
|
||||
|
||||
export type BodyNode = {
|
||||
id: string
|
||||
name: string
|
||||
surface: SurfaceConfig
|
||||
position: BodyVector
|
||||
rotation: BodyVector
|
||||
}
|
||||
|
||||
export type AvatarBody = {
|
||||
primary: SurfaceConfig
|
||||
nodes: BodyNode[]
|
||||
}
|
||||
|
||||
export const bodyPrimitiveTypes = [
|
||||
'sphere',
|
||||
'cube',
|
||||
'capsule',
|
||||
'cylinder',
|
||||
'cone',
|
||||
'diamond',
|
||||
] as const
|
||||
|
||||
export const MAX_BODY_NODES = 16
|
||||
|
||||
const allSurfaceTypes = Object.keys(surfacePresets) as SurfaceType[]
|
||||
const finite = (value: unknown): value is number =>
|
||||
typeof value === 'number' && Number.isFinite(value)
|
||||
const vector = (value: unknown): value is BodyVector =>
|
||||
Array.isArray(value) && value.length === 3 && value.every(finite)
|
||||
|
||||
export const parseSurfaceConfig = (value: unknown, fallback: SurfaceConfig): SurfaceConfig => {
|
||||
if (!value || typeof value !== 'object') return { ...fallback }
|
||||
const candidate = value as Partial<SurfaceConfig>
|
||||
const type =
|
||||
candidate.type && allSurfaceTypes.includes(candidate.type) ? candidate.type : fallback.type
|
||||
const preset = surfacePresets[type]
|
||||
const numericFields = ['width', 'height', 'depth', 'roundness'] as const
|
||||
if (numericFields.some(field => !finite(candidate[field]))) return { ...fallback }
|
||||
if (candidate.morphRoundness !== undefined && !finite(candidate.morphRoundness))
|
||||
return { ...fallback }
|
||||
if (candidate.tipRoundness !== undefined && !finite(candidate.tipRoundness))
|
||||
return { ...fallback }
|
||||
if (candidate.baseRoundness !== undefined && !finite(candidate.baseRoundness))
|
||||
return { ...fallback }
|
||||
return { ...preset, ...candidate, type }
|
||||
}
|
||||
|
||||
export const parseAvatarBody = (value: unknown, fallbackPrimary: SurfaceConfig): AvatarBody => {
|
||||
if (!value || typeof value !== 'object') return { primary: fallbackPrimary, nodes: [] }
|
||||
const candidate = value as Partial<AvatarBody>
|
||||
const primary = parseSurfaceConfig(candidate.primary, fallbackPrimary)
|
||||
const seenIds = new Set<string>()
|
||||
const nodes = Array.isArray(candidate.nodes)
|
||||
? candidate.nodes
|
||||
.filter((node): node is BodyNode => {
|
||||
if (!node || typeof node !== 'object') return false
|
||||
const surface = (node as BodyNode).surface
|
||||
const id = (node as BodyNode).id
|
||||
if (id === 'primary' || seenIds.has(id)) return false
|
||||
const valid = Boolean(
|
||||
typeof (node as BodyNode).id === 'string' &&
|
||||
id &&
|
||||
typeof (node as BodyNode).name === 'string' &&
|
||||
surface &&
|
||||
bodyPrimitiveTypes.includes(surface.type as (typeof bodyPrimitiveTypes)[number]) &&
|
||||
finite(surface.width) &&
|
||||
finite(surface.height) &&
|
||||
finite(surface.depth) &&
|
||||
finite(surface.roundness) &&
|
||||
vector((node as BodyNode).position) &&
|
||||
vector((node as BodyNode).rotation)
|
||||
)
|
||||
if (valid) seenIds.add(id)
|
||||
return valid
|
||||
})
|
||||
.slice(0, MAX_BODY_NODES)
|
||||
.map(node => ({
|
||||
...node,
|
||||
surface: parseSurfaceConfig(node.surface, surfacePresets[node.surface.type]),
|
||||
}))
|
||||
: []
|
||||
return { primary, nodes }
|
||||
}
|
||||
|
||||
export const createBodyNode = (
|
||||
type: (typeof bodyPrimitiveTypes)[number],
|
||||
index: number
|
||||
): BodyNode => {
|
||||
const preset = surfacePresets[type]
|
||||
const scale = 0.34
|
||||
const side = index % 2 === 0 ? -1 : 1
|
||||
return {
|
||||
id: `shape-${crypto.randomUUID()}`,
|
||||
name: `${surfaceLabels[type]} ${index + 1}`,
|
||||
surface: {
|
||||
...preset,
|
||||
width: preset.width * scale,
|
||||
height: preset.height * scale,
|
||||
depth: preset.depth * scale,
|
||||
},
|
||||
position: [side * 82, -72, -18],
|
||||
rotation: [0, 0, 0],
|
||||
}
|
||||
}
|
||||
|
||||
export const duplicateBodyNode = (source: BodyNode): BodyNode => ({
|
||||
...source,
|
||||
id: `shape-${crypto.randomUUID()}`,
|
||||
name: `${source.name} copie`,
|
||||
surface: { ...source.surface },
|
||||
position: [source.position[0] + 14, source.position[1] + 14, source.position[2]],
|
||||
rotation: [...source.rotation],
|
||||
})
|
||||
export * from '@bible-strong/avatar-core'
|
||||
|
||||
@ -3,6 +3,8 @@ import { useState, type RefObject } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Field } from '@/components/ui/field'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import {
|
||||
ContextMenu,
|
||||
ContextMenuContent,
|
||||
@ -141,6 +143,7 @@ export function ExpressionCard({
|
||||
onDragOver,
|
||||
onDrop,
|
||||
onDragEnd,
|
||||
runtimeError,
|
||||
}: {
|
||||
expression: Expression
|
||||
index: number
|
||||
@ -161,6 +164,7 @@ export function ExpressionCard({
|
||||
onDragOver?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||
onDrop?: (event: React.DragEvent<HTMLButtonElement>) => void
|
||||
onDragEnd?: () => void
|
||||
runtimeError: string | null
|
||||
}) {
|
||||
const { t } = useStudioLanguage()
|
||||
const card = (
|
||||
@ -187,6 +191,16 @@ export function ExpressionCard({
|
||||
renderStyle={renderStyle}
|
||||
id={previewId}
|
||||
/>
|
||||
{runtimeError && (
|
||||
<i
|
||||
className="runtime-key-missing"
|
||||
role="img"
|
||||
aria-label={runtimeError}
|
||||
title={runtimeError}
|
||||
>
|
||||
!
|
||||
</i>
|
||||
)}
|
||||
<span>{String(index).padStart(2, '0')}</span>
|
||||
</Button>
|
||||
)
|
||||
@ -225,6 +239,7 @@ export function ExpressionWorkspace({
|
||||
onSave,
|
||||
onDuplicate,
|
||||
onDelete,
|
||||
semanticKeyError,
|
||||
}: {
|
||||
editing: { index: number | null; draft: Expression }
|
||||
avatarColors: AvatarColors
|
||||
@ -234,6 +249,7 @@ export function ExpressionWorkspace({
|
||||
onSave: () => void
|
||||
onDuplicate: () => void
|
||||
onDelete: () => void
|
||||
semanticKeyError: string | null
|
||||
}) {
|
||||
const { t } = useStudioLanguage()
|
||||
const [linked, setLinked] = useState({
|
||||
@ -293,6 +309,43 @@ export function ExpressionWorkspace({
|
||||
</header>
|
||||
<div className="workspace-scroll">
|
||||
<div className="dialog-fields">
|
||||
<ControlSection
|
||||
title="Identité runtime"
|
||||
subtitle="Nom public stable utilisé par les applications qui chargent cet avatar."
|
||||
compact
|
||||
>
|
||||
<Card className="dialog-group semantic-key-card">
|
||||
<Field>
|
||||
<label
|
||||
className="semantic-key-label"
|
||||
htmlFor={`expression-key-${editing.draft.id}`}
|
||||
>
|
||||
{t('Clé sémantique')}
|
||||
</label>
|
||||
<Input
|
||||
id={`expression-key-${editing.draft.id}`}
|
||||
value={editing.draft.semanticKey ?? ''}
|
||||
maxLength={64}
|
||||
spellCheck={false}
|
||||
autoCapitalize="none"
|
||||
autoCorrect="off"
|
||||
aria-invalid={Boolean(semanticKeyError)}
|
||||
aria-describedby={`expression-key-help-${editing.draft.id}`}
|
||||
onChange={event =>
|
||||
update({ semanticKey: event.currentTarget.value || undefined })
|
||||
}
|
||||
/>
|
||||
<p
|
||||
id={`expression-key-help-${editing.draft.id}`}
|
||||
className={semanticKeyError ? 'semantic-key-error' : 'field-help'}
|
||||
role={semanticKeyError ? 'alert' : undefined}
|
||||
>
|
||||
{semanticKeyError ??
|
||||
t('Clé publique stable utilisée par l’API runtime, par exemple happy-smile.')}
|
||||
</p>
|
||||
</Field>
|
||||
</Card>
|
||||
</ControlSection>
|
||||
<ControlSection
|
||||
title="Corps"
|
||||
subtitle="Apparence et orientation générale de l’avatar."
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -1,5 +1,33 @@
|
||||
import type { Expression } from './geometry'
|
||||
|
||||
export const bundledExpressionSemanticKeys: Record<string, string> = {
|
||||
'expression-00': 'upward-side-glance',
|
||||
'expression-01': 'downward-gaze',
|
||||
'expression-02': 'joyful-down-right',
|
||||
'expression-03': 'surprised-left',
|
||||
'expression-04': 'sleepy-squint',
|
||||
'expression-05': 'skeptical-right',
|
||||
'expression-06': 'small-attentive',
|
||||
'expression-07': 'angry-right',
|
||||
'expression-08': 'curious-left',
|
||||
'expression-09': 'asymmetric-down-right',
|
||||
'expression-10': 'attentive-left',
|
||||
'expression-11': 'joyful-wide',
|
||||
'expression-12': 'wide-downward-gaze',
|
||||
'expression-13': 'eyes-closed',
|
||||
'expression-14': 'skeptical-left',
|
||||
'expression-15': 'far-right-glance',
|
||||
'expression-16': 'angry-left',
|
||||
'expression-17': 'playful-right',
|
||||
'expression-18': 'asymmetric-up-left',
|
||||
'expression-19': 'gentle-downward-gaze',
|
||||
'expression-20': 'wide-down-left',
|
||||
'expression-21': 'surprised-wide-left',
|
||||
'expression-22': 'drowsy-closed',
|
||||
'expression-23': 'suspicious-right',
|
||||
'expression-24': 'shy-downward',
|
||||
}
|
||||
|
||||
const calibrated: number[][] = [
|
||||
[7.3, 27.8, -16.1, 24.2, 27.6, 38.9, 40.7, 54.3, -20.5, 0, 0],
|
||||
[-35.6, 0.7, -8.5, 29.4, 27.3, 49.5, 49.8, 57.7, -42, 0, 0],
|
||||
@ -46,6 +74,7 @@ export const initialExpressions: Expression[] = calibrated.map(
|
||||
index
|
||||
) => ({
|
||||
id: `expression-${String(index).padStart(2, '0')}`,
|
||||
semanticKey: bundledExpressionSemanticKeys[`expression-${String(index).padStart(2, '0')}`],
|
||||
headX,
|
||||
headY,
|
||||
headZ,
|
||||
|
||||
@ -1,665 +1 @@
|
||||
import type { Point3 } from './geometry'
|
||||
|
||||
export type SurfaceType =
|
||||
'sphere' | 'mickey' | 'cursor' | 'cube' | 'capsule' | 'cylinder' | 'cone' | 'diamond'
|
||||
|
||||
export type SurfaceConfig = {
|
||||
type: SurfaceType
|
||||
width: number
|
||||
height: number
|
||||
depth: number
|
||||
roundness: number
|
||||
morphRoundness?: number
|
||||
tipRoundness?: number
|
||||
baseRoundness?: number
|
||||
}
|
||||
|
||||
export type SurfaceSample = {
|
||||
point: Point3
|
||||
normal: Point3
|
||||
}
|
||||
|
||||
export const surfacePresets: Record<SurfaceType, SurfaceConfig> = {
|
||||
sphere: { type: 'sphere', width: 240, height: 240, depth: 240, roundness: 1 },
|
||||
mickey: { type: 'mickey', width: 220, height: 210, depth: 145, roundness: 1 },
|
||||
cursor: { type: 'cursor', width: 175, height: 260, depth: 145, roundness: 0 },
|
||||
cube: { type: 'cube', width: 245, height: 245, depth: 220, roundness: 0 },
|
||||
capsule: { type: 'capsule', width: 205, height: 270, depth: 205, roundness: 1 },
|
||||
cylinder: {
|
||||
type: 'cylinder',
|
||||
width: 235,
|
||||
height: 250,
|
||||
depth: 215,
|
||||
roundness: 0.45,
|
||||
morphRoundness: 0,
|
||||
},
|
||||
cone: {
|
||||
type: 'cone',
|
||||
width: 250,
|
||||
height: 265,
|
||||
depth: 225,
|
||||
roundness: 0,
|
||||
morphRoundness: 0,
|
||||
tipRoundness: 0.55,
|
||||
baseRoundness: 0.45,
|
||||
},
|
||||
diamond: { type: 'diamond', width: 235, height: 260, depth: 215, roundness: 0 },
|
||||
}
|
||||
|
||||
export const surfaceLabels: Record<SurfaceType, string> = {
|
||||
sphere: 'Sphère',
|
||||
mickey: 'Mickey',
|
||||
cursor: 'Curseur',
|
||||
cube: 'Cube',
|
||||
capsule: 'Capsule',
|
||||
cylinder: 'Cylindre',
|
||||
cone: 'Cône',
|
||||
diamond: 'Diamant',
|
||||
}
|
||||
|
||||
const signedPower = (value: number, exponent: number) =>
|
||||
Math.sign(value) * Math.abs(value) ** exponent
|
||||
|
||||
const superellipsoid = (
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
width: number,
|
||||
height: number,
|
||||
depth: number,
|
||||
verticalExponent: number,
|
||||
horizontalExponent: number
|
||||
): Point3 => {
|
||||
const latitudeCosine = signedPower(Math.cos(latitude), verticalExponent)
|
||||
return [
|
||||
(width / 2) * latitudeCosine * signedPower(Math.sin(longitude), horizontalExponent),
|
||||
(height / 2) * signedPower(Math.sin(latitude), verticalExponent),
|
||||
(depth / 2) * latitudeCosine * signedPower(Math.cos(longitude), horizontalExponent),
|
||||
]
|
||||
}
|
||||
|
||||
const capsule = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||
const radiusX = config.width / 2
|
||||
const radiusZ = config.depth / 2
|
||||
const capRadius = Math.min(radiusX, config.height / 2)
|
||||
const straightHalf = Math.max(0, (config.height - capRadius * 2) / 2)
|
||||
const meridianLength = straightHalf * 2 + Math.PI * capRadius
|
||||
const distance = ((latitude + Math.PI / 2) / Math.PI) * meridianLength
|
||||
let radial = radiusX
|
||||
let y = 0
|
||||
|
||||
if (distance < (Math.PI * capRadius) / 2) {
|
||||
const angle = -Math.PI / 2 + distance / capRadius
|
||||
radial = radiusX * Math.cos(angle)
|
||||
y = -straightHalf + capRadius * Math.sin(angle)
|
||||
} else if (distance <= (Math.PI * capRadius) / 2 + straightHalf * 2) {
|
||||
y = -straightHalf + distance - (Math.PI * capRadius) / 2
|
||||
} else {
|
||||
const angle = (distance - (Math.PI * capRadius) / 2 - straightHalf * 2) / capRadius
|
||||
radial = radiusX * Math.cos(angle)
|
||||
y = straightHalf + capRadius * Math.sin(angle)
|
||||
}
|
||||
|
||||
const depthScale = radiusX ? radiusZ / radiusX : 1
|
||||
return [radial * Math.sin(longitude), y, radial * depthScale * Math.cos(longitude)]
|
||||
}
|
||||
|
||||
const clampRoundness = (roundness: number | undefined) => Math.max(0, Math.min(2, roundness ?? 0))
|
||||
|
||||
const diamondExponent = (config: SurfaceConfig) => 1 + clampRoundness(config.roundness) / 2
|
||||
|
||||
const MIN_CUBE_SURFACE_POWER = 0.04
|
||||
|
||||
const cubeExponent = (config: SurfaceConfig) => {
|
||||
if (config.roundness <= 0) return Infinity
|
||||
// The implicit superellipsoid power moves from an almost-flat cube to an ellipsoid.
|
||||
const surfacePower =
|
||||
MIN_CUBE_SURFACE_POWER + (clampRoundness(config.roundness) / 2) * (1 - MIN_CUBE_SURFACE_POWER)
|
||||
return 2 / surfacePower
|
||||
}
|
||||
|
||||
const lpSurface = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number,
|
||||
exponent: number
|
||||
): Point3 => {
|
||||
const sphereX = Math.cos(latitude) * Math.sin(longitude)
|
||||
const sphereY = Math.sin(latitude)
|
||||
const sphereZ = Math.cos(latitude) * Math.cos(longitude)
|
||||
const length = Number.isFinite(exponent)
|
||||
? (Math.abs(sphereX) ** exponent +
|
||||
Math.abs(sphereY) ** exponent +
|
||||
Math.abs(sphereZ) ** exponent) **
|
||||
(1 / exponent) || 1
|
||||
: Math.max(Math.abs(sphereX), Math.abs(sphereY), Math.abs(sphereZ)) || 1
|
||||
return [
|
||||
(config.width / 2) * (sphereX / length),
|
||||
(config.height / 2) * (sphereY / length),
|
||||
(config.depth / 2) * (sphereZ / length),
|
||||
]
|
||||
}
|
||||
|
||||
const diamond = (config: SurfaceConfig, longitude: number, latitude: number): Point3 => {
|
||||
return lpSurface(config, longitude, latitude, diamondExponent(config))
|
||||
}
|
||||
|
||||
const cube = (config: SurfaceConfig, longitude: number, latitude: number): Point3 =>
|
||||
lpSurface(config, longitude, latitude, cubeExponent(config))
|
||||
|
||||
const MAX_CONE_TIP_FRACTION = 0.24
|
||||
const MAX_CONE_BASE_FRACTION = 0.2
|
||||
const MAX_CYLINDER_EDGE_FRACTION = 0.22
|
||||
|
||||
type RadialProfile = {
|
||||
radiusScale: number
|
||||
verticalProgress: number
|
||||
}
|
||||
|
||||
const morphProgress = (config: SurfaceConfig) => clampRoundness(config.morphRoundness) / 2
|
||||
|
||||
const morphProfileToEllipsoid = (
|
||||
config: SurfaceConfig,
|
||||
progress: number,
|
||||
profile: RadialProfile
|
||||
): RadialProfile => {
|
||||
const amount = morphProgress(config)
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const ellipsoidRadius = Math.sin(clampedProgress * Math.PI)
|
||||
const ellipsoidVerticalProgress = (1 - Math.cos(clampedProgress * Math.PI)) / 2
|
||||
return {
|
||||
radiusScale: profile.radiusScale + (ellipsoidRadius - profile.radiusScale) * amount,
|
||||
verticalProgress:
|
||||
profile.verticalProgress + (ellipsoidVerticalProgress - profile.verticalProgress) * amount,
|
||||
}
|
||||
}
|
||||
|
||||
const cubic = (
|
||||
start: number,
|
||||
firstControl: number,
|
||||
secondControl: number,
|
||||
end: number,
|
||||
progress: number
|
||||
) => {
|
||||
const inverse = 1 - progress
|
||||
return (
|
||||
inverse ** 3 * start +
|
||||
3 * inverse * inverse * progress * firstControl +
|
||||
3 * inverse * progress * progress * secondControl +
|
||||
progress ** 3 * end
|
||||
)
|
||||
}
|
||||
|
||||
const coneRounding = (config: SurfaceConfig) => ({
|
||||
tipFraction: (config.tipRoundness ?? 0) * MAX_CONE_TIP_FRACTION,
|
||||
baseFraction: (config.baseRoundness ?? 0) * MAX_CONE_BASE_FRACTION,
|
||||
})
|
||||
|
||||
/** Cylinder half-profile with a quarter-round transition at both caps. */
|
||||
const cylinderProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const edgeFraction = config.roundness * MAX_CYLINDER_EDGE_FRACTION
|
||||
if (edgeFraction <= 0) {
|
||||
return {
|
||||
radiusScale: 1,
|
||||
verticalProgress: (Math.sin((clampedProgress - 0.5) * Math.PI) + 1) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
if (clampedProgress < edgeFraction) {
|
||||
const angle = -Math.PI / 2 + (clampedProgress / edgeFraction) * (Math.PI / 2)
|
||||
return {
|
||||
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
||||
verticalProgress: (edgeFraction + edgeFraction * Math.sin(angle)) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
if (clampedProgress > 1 - edgeFraction) {
|
||||
const angle = ((clampedProgress - (1 - edgeFraction)) / edgeFraction) * (Math.PI / 2)
|
||||
return {
|
||||
radiusScale: 1 - edgeFraction + edgeFraction * Math.cos(angle),
|
||||
verticalProgress: 1 - edgeFraction / 2 + (edgeFraction * Math.sin(angle)) / 2,
|
||||
}
|
||||
}
|
||||
|
||||
const middleProgress = (clampedProgress - edgeFraction) / (1 - edgeFraction * 2)
|
||||
return {
|
||||
radiusScale: 1,
|
||||
verticalProgress: edgeFraction / 2 + middleProgress * (1 - edgeFraction),
|
||||
}
|
||||
}
|
||||
|
||||
const morphedCylinderProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||
morphProfileToEllipsoid(config, progress, cylinderProfileAt(config, progress))
|
||||
|
||||
const radiusScaleAtVerticalProgress = (
|
||||
config: SurfaceConfig,
|
||||
verticalProgress: number,
|
||||
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile
|
||||
) => {
|
||||
const progress = Math.max(0, Math.min(1, verticalProgress))
|
||||
let lower = 0
|
||||
let upper = 1
|
||||
for (let iteration = 0; iteration < 14; iteration += 1) {
|
||||
const candidate = (lower + upper) / 2
|
||||
if (profileAt(config, candidate).verticalProgress < progress) lower = candidate
|
||||
else upper = candidate
|
||||
}
|
||||
return profileAt(config, (lower + upper) / 2).radiusScale
|
||||
}
|
||||
|
||||
/** Rounded half-profile revolved around the cone's vertical axis. */
|
||||
const coneProfileAt = (config: SurfaceConfig, progress: number): RadialProfile => {
|
||||
const clampedProgress = Math.max(0, Math.min(1, progress))
|
||||
const { tipFraction, baseFraction } = coneRounding(config)
|
||||
|
||||
if (baseFraction > 0 && clampedProgress < baseFraction) {
|
||||
const curveProgress = clampedProgress / baseFraction
|
||||
return {
|
||||
radiusScale: cubic(
|
||||
1 - baseFraction,
|
||||
1,
|
||||
1 - baseFraction / 2,
|
||||
1 - baseFraction,
|
||||
curveProgress
|
||||
),
|
||||
verticalProgress: cubic(0, 0, baseFraction / 2, baseFraction, curveProgress),
|
||||
}
|
||||
}
|
||||
|
||||
if (tipFraction > 0 && clampedProgress > 1 - tipFraction) {
|
||||
const curveProgress = (clampedProgress - (1 - tipFraction)) / tipFraction
|
||||
return {
|
||||
radiusScale: cubic(tipFraction, tipFraction / 2, tipFraction / 4, 0, curveProgress),
|
||||
verticalProgress: cubic(1 - tipFraction, 1 - tipFraction / 2, 1, 1, curveProgress),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
radiusScale: 1 - clampedProgress,
|
||||
verticalProgress: clampedProgress,
|
||||
}
|
||||
}
|
||||
|
||||
const morphedConeProfileAt = (config: SurfaceConfig, progress: number) =>
|
||||
morphProfileToEllipsoid(config, progress, coneProfileAt(config, progress))
|
||||
|
||||
export const cursorLayout = (config: SurfaceConfig) => {
|
||||
const coneHeight = config.height * 0.36
|
||||
const bodyHeight = config.height - coneHeight
|
||||
return {
|
||||
coneApexY: -config.height / 2,
|
||||
coneBaseY: -config.height / 2 + coneHeight,
|
||||
bodyHeight,
|
||||
bodyCenterY: config.height / 2 - bodyHeight / 2,
|
||||
bodyWidth: config.width * 0.54,
|
||||
bodyDepth: config.depth * 0.62,
|
||||
}
|
||||
}
|
||||
|
||||
export const surfacePointAt = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number
|
||||
): Point3 => {
|
||||
const { width, height, depth } = config
|
||||
switch (config.type) {
|
||||
case 'sphere':
|
||||
case 'mickey':
|
||||
return superellipsoid(longitude, latitude, width, height, depth, 1, 1)
|
||||
case 'cube':
|
||||
return cube(config, longitude, latitude)
|
||||
case 'cylinder': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const profile = morphedCylinderProfileAt(config, progress)
|
||||
return [
|
||||
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
-height / 2 + height * profile.verticalProgress,
|
||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
case 'cursor': {
|
||||
const layout = cursorLayout(config)
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const bodyConfig = {
|
||||
...config,
|
||||
width: layout.bodyWidth,
|
||||
height: layout.bodyHeight,
|
||||
depth: layout.bodyDepth,
|
||||
}
|
||||
const profile = cylinderProfileAt(bodyConfig, progress)
|
||||
return [
|
||||
(layout.bodyWidth / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
layout.bodyCenterY - layout.bodyHeight / 2 + layout.bodyHeight * profile.verticalProgress,
|
||||
(layout.bodyDepth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
case 'diamond':
|
||||
return diamond(config, longitude, latitude)
|
||||
case 'capsule':
|
||||
return capsule(config, longitude, latitude)
|
||||
case 'cone': {
|
||||
const progress = (latitude + Math.PI / 2) / Math.PI
|
||||
const profile = morphedConeProfileAt(config, progress)
|
||||
return [
|
||||
(width / 2) * profile.radiusScale * Math.sin(longitude),
|
||||
height / 2 - height * profile.verticalProgress,
|
||||
(depth / 2) * profile.radiusScale * Math.cos(longitude),
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const subtract = (left: Point3, right: Point3): Point3 => [
|
||||
left[0] - right[0],
|
||||
left[1] - right[1],
|
||||
left[2] - right[2],
|
||||
]
|
||||
|
||||
const normalize = ([x, y, z]: Point3): Point3 => {
|
||||
const length = Math.hypot(x, y, z) || 1
|
||||
return [x / length, y / length, z / length]
|
||||
}
|
||||
|
||||
const normalFromTangents = (
|
||||
config: SurfaceConfig,
|
||||
longitudeTangent: Point3,
|
||||
latitudeTangent: Point3
|
||||
) => {
|
||||
const orientation = config.type === 'cone' ? -1 : 1
|
||||
return normalize([
|
||||
orientation *
|
||||
(longitudeTangent[1] * latitudeTangent[2] - longitudeTangent[2] * latitudeTangent[1]),
|
||||
orientation *
|
||||
(longitudeTangent[2] * latitudeTangent[0] - longitudeTangent[0] * latitudeTangent[2]),
|
||||
orientation *
|
||||
(longitudeTangent[0] * latitudeTangent[1] - longitudeTangent[1] * latitudeTangent[0]),
|
||||
])
|
||||
}
|
||||
|
||||
const tangentNormalAt = (config: SurfaceConfig, longitude: number, latitude: number) => {
|
||||
const epsilon = 0.0005
|
||||
if (config.type === 'cone' && latitude >= Math.PI / 2 - epsilon) return [0, -1, 0] as Point3
|
||||
const longitudeBefore = surfacePointAt(config, longitude - epsilon, latitude)
|
||||
const longitudeAfter = surfacePointAt(config, longitude + epsilon, latitude)
|
||||
const latitudeBefore = surfacePointAt(
|
||||
config,
|
||||
longitude,
|
||||
Math.max(-Math.PI / 2, latitude - epsilon)
|
||||
)
|
||||
const latitudeAfter = surfacePointAt(config, longitude, Math.min(Math.PI / 2, latitude + epsilon))
|
||||
return normalFromTangents(
|
||||
config,
|
||||
subtract(longitudeAfter, longitudeBefore),
|
||||
subtract(latitudeAfter, latitudeBefore)
|
||||
)
|
||||
}
|
||||
|
||||
const signedMagnitude = (value: number, exponent: number) =>
|
||||
Math.sign(value) * Math.abs(value) ** exponent
|
||||
|
||||
const lpNormal = (config: SurfaceConfig, point: Point3, exponent: number): Point3 => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
return normalize([
|
||||
signedMagnitude(point[0] / radiusX, exponent - 1) / radiusX,
|
||||
signedMagnitude(point[1] / radiusY, exponent - 1) / radiusY,
|
||||
signedMagnitude(point[2] / radiusZ, exponent - 1) / radiusZ,
|
||||
])
|
||||
}
|
||||
|
||||
const diamondNormal = (config: SurfaceConfig, point: Point3): Point3 =>
|
||||
lpNormal(config, point, diamondExponent(config))
|
||||
|
||||
const cubeNormal = (config: SurfaceConfig, point: Point3): Point3 => {
|
||||
const exponent = cubeExponent(config)
|
||||
if (Number.isFinite(exponent)) return lpNormal(config, point, exponent)
|
||||
|
||||
const normalized = [
|
||||
point[0] / (config.width / 2 || 1),
|
||||
point[1] / (config.height / 2 || 1),
|
||||
point[2] / (config.depth / 2 || 1),
|
||||
] as Point3
|
||||
const dominantAxis = normalized.reduce(
|
||||
(largest, value, index) => (Math.abs(value) > Math.abs(normalized[largest]) ? index : largest),
|
||||
0
|
||||
)
|
||||
const normal: Point3 = [
|
||||
dominantAxis === 0 ? Math.sign(normalized[0]) : 0,
|
||||
dominantAxis === 1 ? Math.sign(normalized[1]) : 0,
|
||||
dominantAxis === 2 ? Math.sign(normalized[2]) : 0,
|
||||
]
|
||||
return normal
|
||||
}
|
||||
|
||||
const lpFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number,
|
||||
exponent: number,
|
||||
normalAt: (config: SurfaceConfig, point: Point3) => Point3
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
if (!Number.isFinite(exponent)) {
|
||||
const point: Point3 = [
|
||||
Math.max(-radiusX, Math.min(radiusX, x)),
|
||||
Math.max(-radiusY, Math.min(radiusY, y)),
|
||||
radiusZ,
|
||||
]
|
||||
return { point, normal: normalAt(config, point) }
|
||||
}
|
||||
|
||||
const normalizedY = Math.max(-1, Math.min(1, y / radiusY))
|
||||
const availableX = Math.max(0, 1 - Math.abs(normalizedY) ** exponent) ** (1 / exponent)
|
||||
const surfaceX = Math.max(-radiusX * availableX, Math.min(radiusX * availableX, x))
|
||||
const normalizedX = surfaceX / radiusX
|
||||
const normalizedZ =
|
||||
Math.max(0, 1 - Math.abs(normalizedX) ** exponent - Math.abs(normalizedY) ** exponent) **
|
||||
(1 / exponent)
|
||||
const point: Point3 = [surfaceX, normalizedY * radiusY, radiusZ * normalizedZ]
|
||||
return { point, normal: normalAt(config, point) }
|
||||
}
|
||||
|
||||
const ellipsoidFrontSample = (
|
||||
x: number,
|
||||
y: number,
|
||||
radiusX: number,
|
||||
radiusY: number,
|
||||
radiusZ: number,
|
||||
centerY = 0
|
||||
): SurfaceSample => {
|
||||
const localY = y - centerY
|
||||
const remaining = Math.max(0, 1 - (x / (radiusX || 1)) ** 2 - (localY / (radiusY || 1)) ** 2)
|
||||
const z = radiusZ * Math.sqrt(remaining)
|
||||
return {
|
||||
point: [x, y, z],
|
||||
normal: normalize([
|
||||
x / (radiusX * radiusX || 1),
|
||||
localY / (radiusY * radiusY || 1),
|
||||
z / (radiusZ * radiusZ || 1),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
const radialProfileFrontSample = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number,
|
||||
profileAt: (config: SurfaceConfig, progress: number) => RadialProfile,
|
||||
verticalDirection: -1 | 1
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
const verticalProgress = Math.max(0, Math.min(1, 0.5 + verticalDirection * (y / config.height)))
|
||||
const radialScale = radiusScaleAtVerticalProgress(config, verticalProgress, profileAt)
|
||||
const sectionRadiusX = radiusX * radialScale
|
||||
const sectionRadiusZ = radiusZ * radialScale
|
||||
const surfaceX = Math.max(-sectionRadiusX, Math.min(sectionRadiusX, x))
|
||||
const remaining = sectionRadiusX > 0 ? Math.max(0, 1 - (surfaceX / sectionRadiusX) ** 2) : 0
|
||||
const z = sectionRadiusZ * Math.sqrt(remaining)
|
||||
const derivativeStep = 0.0001
|
||||
const previousProgress = Math.max(0, verticalProgress - derivativeStep)
|
||||
const nextProgress = Math.min(1, verticalProgress + derivativeStep)
|
||||
const previousScale = radiusScaleAtVerticalProgress(config, previousProgress, profileAt)
|
||||
const nextScale = radiusScaleAtVerticalProgress(config, nextProgress, profileAt)
|
||||
const scaleDerivative = (nextScale - previousScale) / (nextProgress - previousProgress || 1)
|
||||
const radialRemainder = Math.max(Math.sqrt(remaining), 0.0001)
|
||||
const depthRatio = radiusZ / radiusX
|
||||
const depthXDerivative = (-depthRatio * surfaceX) / (sectionRadiusX * radialRemainder || 1)
|
||||
const depthYDerivative =
|
||||
(verticalDirection * radiusZ * scaleDerivative) / (config.height * radialRemainder || 1)
|
||||
return {
|
||||
point: [surfaceX, y, z],
|
||||
normal: normalize([-depthXDerivative, -depthYDerivative, 1]),
|
||||
}
|
||||
}
|
||||
|
||||
/** Project canonical face coordinates onto a primitive's front-facing sheet. */
|
||||
export const surfaceFrontSampleAt = (
|
||||
config: SurfaceConfig,
|
||||
x: number,
|
||||
y: number
|
||||
): SurfaceSample => {
|
||||
const radiusX = config.width / 2 || 1
|
||||
const radiusY = config.height / 2 || 1
|
||||
const radiusZ = config.depth / 2 || 1
|
||||
|
||||
switch (config.type) {
|
||||
case 'sphere':
|
||||
case 'mickey':
|
||||
return ellipsoidFrontSample(x, y, radiusX, radiusY, radiusZ)
|
||||
|
||||
case 'cube':
|
||||
return lpFrontSample(config, x, y, cubeExponent(config), cubeNormal)
|
||||
|
||||
case 'capsule': {
|
||||
const capRadiusY = Math.min(radiusX, radiusY)
|
||||
const straightHalf = Math.max(0, radiusY - capRadiusY)
|
||||
const capCenterY = y < -straightHalf ? -straightHalf : y > straightHalf ? straightHalf : y
|
||||
return ellipsoidFrontSample(x, y, radiusX, capRadiusY, radiusZ, capCenterY)
|
||||
}
|
||||
|
||||
case 'cylinder':
|
||||
return radialProfileFrontSample(config, x, y, morphedCylinderProfileAt, 1)
|
||||
|
||||
case 'cursor': {
|
||||
const layout = cursorLayout(config)
|
||||
const bodyConfig = {
|
||||
...config,
|
||||
width: layout.bodyWidth,
|
||||
height: layout.bodyHeight,
|
||||
depth: layout.bodyDepth,
|
||||
}
|
||||
const sample = radialProfileFrontSample(
|
||||
bodyConfig,
|
||||
x,
|
||||
y - layout.bodyCenterY,
|
||||
cylinderProfileAt,
|
||||
1
|
||||
)
|
||||
return {
|
||||
point: [sample.point[0], sample.point[1] + layout.bodyCenterY, sample.point[2]],
|
||||
normal: sample.normal,
|
||||
}
|
||||
}
|
||||
|
||||
case 'cone':
|
||||
return radialProfileFrontSample(config, x, y, morphedConeProfileAt, -1)
|
||||
|
||||
case 'diamond':
|
||||
return lpFrontSample(config, x, y, diamondExponent(config), diamondNormal)
|
||||
}
|
||||
}
|
||||
|
||||
export const surfaceNormalAt = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number
|
||||
): Point3 => {
|
||||
const point = surfacePointAt(config, longitude, latitude)
|
||||
|
||||
// An ellipsoid has a cheap exact normal. This is also the overwhelmingly
|
||||
// common path for the default spherical head.
|
||||
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||
const halfWidth = config.width / 2 || 1
|
||||
const halfHeight = config.height / 2 || 1
|
||||
const halfDepth = config.depth / 2 || 1
|
||||
return normalize([
|
||||
point[0] / (halfWidth * halfWidth),
|
||||
point[1] / (halfHeight * halfHeight),
|
||||
point[2] / (halfDepth * halfDepth),
|
||||
])
|
||||
}
|
||||
|
||||
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
||||
return normalize([
|
||||
Math.sin(longitude) / (config.width / 2 || 1),
|
||||
0,
|
||||
Math.cos(longitude) / (config.depth / 2 || 1),
|
||||
])
|
||||
}
|
||||
|
||||
if (config.type === 'diamond') {
|
||||
return diamondNormal(config, point)
|
||||
}
|
||||
|
||||
if (config.type === 'cube') {
|
||||
return cubeNormal(config, point)
|
||||
}
|
||||
|
||||
return tangentNormalAt(config, longitude, latitude)
|
||||
}
|
||||
|
||||
export const surfaceSampleAt = (
|
||||
config: SurfaceConfig,
|
||||
longitude: number,
|
||||
latitude: number
|
||||
): SurfaceSample => {
|
||||
const point = surfacePointAt(config, longitude, latitude)
|
||||
|
||||
if (config.type === 'sphere' || config.type === 'mickey') {
|
||||
const halfWidth = config.width / 2 || 1
|
||||
const halfHeight = config.height / 2 || 1
|
||||
const halfDepth = config.depth / 2 || 1
|
||||
return {
|
||||
point,
|
||||
normal: normalize([
|
||||
point[0] / (halfWidth * halfWidth),
|
||||
point[1] / (halfHeight * halfHeight),
|
||||
point[2] / (halfDepth * halfDepth),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'cylinder' && config.roundness <= 0 && (config.morphRoundness ?? 0) <= 0) {
|
||||
return {
|
||||
point,
|
||||
normal: normalize([
|
||||
Math.sin(longitude) / (config.width / 2 || 1),
|
||||
0,
|
||||
Math.cos(longitude) / (config.depth / 2 || 1),
|
||||
]),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'diamond') {
|
||||
return {
|
||||
point,
|
||||
normal: diamondNormal(config, point),
|
||||
}
|
||||
}
|
||||
|
||||
if (config.type === 'cube') {
|
||||
return {
|
||||
point,
|
||||
normal: cubeNormal(config, point),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
point,
|
||||
normal: tangentNormalAt(config, longitude, latitude),
|
||||
}
|
||||
}
|
||||
export * from '@bible-strong/avatar-core'
|
||||
|
||||
@ -29,6 +29,9 @@ describe('avatar export', () => {
|
||||
expect(payload).not.toHaveProperty('frames')
|
||||
expect(payload.avatar.name).toBe('Strobi')
|
||||
expect(payload.avatar.renderStyle).toEqual({ type: 'vector' })
|
||||
expect(Object.values(payload.expressions).every(expression => !expression.semanticKey)).toBe(
|
||||
true
|
||||
)
|
||||
})
|
||||
|
||||
it('preserves pixel rendering in standalone exports', () => {
|
||||
|
||||
@ -58,9 +58,12 @@ export const createAvatarExportPayload = (
|
||||
const exportedExpressions = Object.fromEntries(
|
||||
[...referencedIds].flatMap(expressionId => {
|
||||
const expression = expressionById.get(expressionId)
|
||||
return expression
|
||||
? [[expressionId, applyAvatarEyeDefaults(expression, avatar.eyes)] as const]
|
||||
: []
|
||||
if (!expression) return []
|
||||
const { semanticKey: _semanticKey, ...legacyExpression } = applyAvatarEyeDefaults(
|
||||
expression,
|
||||
avatar.eyes
|
||||
)
|
||||
return [[expressionId, legacyExpression] as const]
|
||||
})
|
||||
)
|
||||
const usedKeys = new Set<string>()
|
||||
|
||||
File diff suppressed because one or more lines are too long
@ -1,9 +1,12 @@
|
||||
import { createAvatar } from '@/features/avatar/avatars'
|
||||
import { createInitialSequences } from '@/features/animation/sequences'
|
||||
import { initialExpressions } from '@/features/avatar/presets'
|
||||
import { createAvatarDefinition } from '@/features/avatar/avatarDefinition'
|
||||
import {
|
||||
clearPersistedStudioDocument,
|
||||
createStudioDocumentStore,
|
||||
loadStudioDocument,
|
||||
parseStudioDocument,
|
||||
parseImportedStudioDocument,
|
||||
serializeStudioDocument,
|
||||
type StudioDocument,
|
||||
@ -34,15 +37,71 @@ describe('Studio document', () => {
|
||||
expect(document.library.avatars[0].name).toBe('Strobi')
|
||||
expect(document.expressions).toHaveLength(27)
|
||||
expect(document.sequences).toHaveLength(23)
|
||||
expect(document.expressions.every(expression => expression.semanticKey)).toBe(true)
|
||||
expect(document.sequences.every(sequence => sequence.semanticKey)).toBe(true)
|
||||
expect(document.playback).toEqual({ stateId: 'proud', playing: true })
|
||||
})
|
||||
|
||||
it('clears only the persisted Studio project', () => {
|
||||
const removeItem = vi.fn()
|
||||
|
||||
expect(clearPersistedStudioDocument({ removeItem })).toBe(true)
|
||||
expect(removeItem).toHaveBeenCalledExactlyOnceWith('bible-strong-avatar-studio-v2')
|
||||
})
|
||||
|
||||
it('keeps a locally saved project authoritative over the bundled snapshot', () => {
|
||||
const localDocument = documentFixture()
|
||||
|
||||
expect(loadStudioDocument(storage(JSON.stringify(localDocument)))).toEqual(localDocument)
|
||||
})
|
||||
|
||||
it('restores bundled semantic keys in a legacy local project', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacy = structuredClone(fallback)
|
||||
legacy.expressions.forEach(expression => delete expression.semanticKey)
|
||||
legacy.sequences.forEach(sequence => delete sequence.semanticKey)
|
||||
|
||||
const document = parseStudioDocument(legacy, fallback)
|
||||
|
||||
expect(document.expressions.map(expression => expression.semanticKey)).toEqual(
|
||||
fallback.expressions.map(expression => expression.semanticKey)
|
||||
)
|
||||
expect(document.sequences.map(sequence => sequence.semanticKey)).toEqual(
|
||||
fallback.sequences.map(sequence => sequence.semanticKey)
|
||||
)
|
||||
expect(
|
||||
createAvatarDefinition({
|
||||
avatar: document.library.avatars[0],
|
||||
behavior: { expressions: document.expressions, sequences: document.sequences },
|
||||
}).ok
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('restores bundled keys in legacy avatar-owned behavior without naming custom items', () => {
|
||||
const fallback = loadStudioDocument(storage())
|
||||
const legacy = structuredClone(fallback)
|
||||
const avatar = legacy.library.avatars[0]
|
||||
avatar.behavior = {
|
||||
expressions: legacy.expressions.map(expression => ({
|
||||
...expression,
|
||||
semanticKey: undefined,
|
||||
})),
|
||||
sequences: legacy.sequences.map(sequence => ({ ...sequence, semanticKey: undefined })),
|
||||
}
|
||||
avatar.behavior.expressions.push({
|
||||
...avatar.behavior.expressions[0],
|
||||
id: 'expression-custom',
|
||||
semanticKey: undefined,
|
||||
})
|
||||
|
||||
const document = parseStudioDocument(legacy, fallback)
|
||||
const behavior = document.library.avatars[0].behavior!
|
||||
|
||||
expect(behavior.expressions[0].semanticKey).toBe(fallback.expressions[0].semanticKey)
|
||||
expect(behavior.sequences[0].semanticKey).toBe(fallback.sequences[0].semanticKey)
|
||||
expect(behavior.expressions.at(-1)?.semanticKey).toBeUndefined()
|
||||
})
|
||||
|
||||
it('persists one coherent document after a mutation', () => {
|
||||
const persisted: StudioDocument[] = []
|
||||
const store = createStudioDocumentStore(documentFixture(), value => persisted.push(value))
|
||||
@ -71,9 +130,10 @@ describe('Studio document', () => {
|
||||
|
||||
it('round-trips a complete project document as portable JSON', () => {
|
||||
const document = documentFixture()
|
||||
const expression = { ...initialExpressions[0], widthLeft: 42 }
|
||||
const expression = { ...initialExpressions[0], semanticKey: 'happy-smile', widthLeft: 42 }
|
||||
const sequence = {
|
||||
...createInitialSequences()[0],
|
||||
semanticKey: 'happy',
|
||||
steps: createInitialSequences()[0].steps.map(step => ({
|
||||
...step,
|
||||
expressionId: expression.id,
|
||||
@ -88,6 +148,26 @@ describe('Studio document', () => {
|
||||
|
||||
expect(imported).toEqual(document)
|
||||
expect(imported.library.avatars[0].behavior?.expressions[0].widthLeft).toBe(42)
|
||||
expect(imported.expressions[0].semanticKey).toBe(initialExpressions[0].semanticKey)
|
||||
expect(imported.library.avatars[0].behavior?.expressions[0].semanticKey).toBe('happy-smile')
|
||||
expect(imported.library.avatars[0].behavior?.sequences[0].semanticKey).toBe('happy')
|
||||
})
|
||||
|
||||
it('preserves semantic keys in the base behavior parser', () => {
|
||||
const document = documentFixture()
|
||||
document.expressions = document.expressions.map((expression, index) => ({
|
||||
...expression,
|
||||
...(index === 0 ? { semanticKey: 'attentive' } : {}),
|
||||
}))
|
||||
document.sequences = document.sequences.map((sequence, index) => ({
|
||||
...sequence,
|
||||
...(index === 0 ? { semanticKey: 'sleeping' } : {}),
|
||||
}))
|
||||
|
||||
const imported = parseImportedStudioDocument(serializeStudioDocument(document), document)
|
||||
|
||||
expect(imported.expressions[0].semanticKey).toBe('attentive')
|
||||
expect(imported.sequences[0].semanticKey).toBe('sleeping')
|
||||
})
|
||||
|
||||
it('keeps the base library unchanged when an avatar owns customized behavior', () => {
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import {
|
||||
ArrowLeft,
|
||||
CircleCheck,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
Copy,
|
||||
Download,
|
||||
FileCode2,
|
||||
@ -10,10 +13,16 @@ import {
|
||||
RotateCcw,
|
||||
Smile,
|
||||
Trash2,
|
||||
TriangleAlert,
|
||||
Upload,
|
||||
} from 'lucide-react'
|
||||
import { AnimatePresence, animate, motion, useMotionValue, useTransform } from 'motion/react'
|
||||
import { type CSSProperties, useLayoutEffect, useRef, useState } from 'react'
|
||||
import { AnimatePresence, motion } from 'motion/react'
|
||||
import { type CSSProperties, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
Avatar as RuntimeAvatar,
|
||||
type AvatarController as RuntimeAvatarController,
|
||||
} from '@bible-strong/avatar-react'
|
||||
|
||||
import { Accordion } from '@/components/ui/accordion'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@ -65,11 +74,51 @@ import {
|
||||
import { defaultExpression } from '@/features/avatar/presets'
|
||||
import { surfaceLabels, surfacePresets } from '@/features/avatar/surfaces'
|
||||
import { type SnapshotBackground } from '@/features/export/snapshotExporter'
|
||||
import { AvatarPage } from '@/features/studio/components/AvatarDrawer'
|
||||
import { AvatarDrawer } from '@/features/studio/components/AvatarDrawer'
|
||||
import { StudioIdentity } from '@/features/studio/components/StudioIdentity'
|
||||
import type { StudioController } from '@/features/studio/useStudioController'
|
||||
|
||||
const runtimeInstallExample =
|
||||
'npm install @bible-strong/avatar-core @bible-strong/avatar-react react react-dom'
|
||||
|
||||
const runtimeReactExample = `import { validateAvatarDefinition } from '@bible-strong/avatar-core'
|
||||
import { Avatar } from '@bible-strong/avatar-react'
|
||||
import '@bible-strong/avatar-react/styles.css'
|
||||
import avatarJson from './strobi.avatar.json'
|
||||
|
||||
const avatar = validateAvatarDefinition(avatarJson)
|
||||
if (!avatar.ok) throw new Error(avatar.errors[0].message)
|
||||
|
||||
export function Strobi() {
|
||||
return <Avatar definition={avatar.value} defaultAnimation="idle" />
|
||||
}`
|
||||
|
||||
const codeTokenPattern =
|
||||
/(\/\/.*|'.*?'|".*?"|@[a-z0-9-/]+|<\/?[A-Z][A-Za-z]*|\b(?:npm|install|import|from|const|if|throw|new|export|function|return)\b)/g
|
||||
|
||||
const highlightedCode = (source: string) =>
|
||||
source.split(codeTokenPattern).map((token, index) => {
|
||||
if (!token) return null
|
||||
const kind = token.startsWith('//')
|
||||
? 'comment'
|
||||
: token.startsWith("'") || token.startsWith('"') || token.startsWith('@')
|
||||
? 'string'
|
||||
: token.startsWith('<')
|
||||
? 'tag'
|
||||
: /^(?:npm|install|import|from|const|if|throw|new|export|function|return)$/.test(token)
|
||||
? 'keyword'
|
||||
: 'plain'
|
||||
return (
|
||||
<span className={`runtime-token runtime-token-${kind}`} key={`${index}-${token}`}>
|
||||
{token}
|
||||
</span>
|
||||
)
|
||||
})
|
||||
|
||||
export function StudioInspector({ controller }: { controller: StudioController }) {
|
||||
const [avatarDrawerOpen, setAvatarDrawerOpen] = useState(false)
|
||||
const [runtimeExampleOpen, setRuntimeExampleOpen] = useState(false)
|
||||
const runtimeExampleRef = useRef<RuntimeAvatarController>(null)
|
||||
const {
|
||||
activateAvatar,
|
||||
activeAvatar,
|
||||
@ -96,9 +145,12 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
commitAvatarMove,
|
||||
commitExpressionMove,
|
||||
commitStateMove,
|
||||
clearLocalStudioDocument,
|
||||
copyAvatarRuntimeDefinition,
|
||||
createNewAvatar,
|
||||
deleteSelectedBodyNode,
|
||||
downloadAvatarExport,
|
||||
downloadAvatarRuntimeDefinition,
|
||||
downloadStudioProject,
|
||||
draggedAvatarId,
|
||||
draggedExpressionId,
|
||||
@ -117,6 +169,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
exportFormat,
|
||||
expression,
|
||||
expressionById,
|
||||
expressionSemanticKeyError,
|
||||
expressionDragOrigin,
|
||||
expressionDragPreview,
|
||||
expressions,
|
||||
@ -141,6 +194,10 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
renameActiveAvatar,
|
||||
renderedColors,
|
||||
renderedScene,
|
||||
runtimeDefinitionResult,
|
||||
runtimeCopyStatus,
|
||||
runtimeExportErrors,
|
||||
runtimeStandardAvailability,
|
||||
saveAvatarEditing,
|
||||
saveEditing,
|
||||
saveSequenceEditing,
|
||||
@ -151,6 +208,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
selectedSequenceStepId,
|
||||
selectedState,
|
||||
sequenceEditing,
|
||||
animationSemanticKeyError,
|
||||
sequences,
|
||||
setDeleteAvatarOpen,
|
||||
setDeleteExpressionOpen,
|
||||
@ -269,9 +327,8 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
duration: reduceMotion ? 0 : undefined,
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Drawer>
|
||||
<Drawer open={avatarDrawerOpen} onOpenChange={setAvatarDrawerOpen}>
|
||||
<main
|
||||
className={`inspector ${editing ? 'expression-workspace-active' : sequenceEditing ? 'sequence-workspace-active' : bodyEditing ? 'body-workspace' : 'studio-workspace'}${activeSequence && !editorPageOpen ? ' state-player-active' : ''}`}
|
||||
>
|
||||
@ -319,6 +376,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
onSave={saveSequenceEditing}
|
||||
onDuplicate={duplicateSequenceEditing}
|
||||
onDelete={() => setDeleteSequenceOpen(true)}
|
||||
semanticKeyError={animationSemanticKeyError(sequenceEditing.draft)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -339,6 +397,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
onSave={saveEditing}
|
||||
onDuplicate={() => duplicateExpression(editing.index, editing.draft, true)}
|
||||
onDelete={() => setDeleteExpressionOpen(true)}
|
||||
semanticKeyError={expressionSemanticKeyError(editing.draft)}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
@ -389,14 +448,11 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
>
|
||||
{!editorPageOpen && (
|
||||
<header className="mode-page-header">
|
||||
<div>
|
||||
<p className="eyebrow">{activeAvatar.name}</p>
|
||||
<h1>
|
||||
{t(
|
||||
mode === 'manual'
|
||||
? 'Pose'
|
||||
: mode === 'avatars'
|
||||
? 'Avatars'
|
||||
: mode === 'expressions'
|
||||
? 'Expressions'
|
||||
: mode === 'states'
|
||||
@ -404,18 +460,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
: 'Exporter'
|
||||
)}
|
||||
</h1>
|
||||
</div>
|
||||
{mode === 'manual' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
type="button"
|
||||
aria-label={t('Réinitialiser')}
|
||||
onClick={() => transitionToExpression({ ...defaultExpression })}
|
||||
>
|
||||
<RotateCcw />
|
||||
</Button>
|
||||
)}
|
||||
</header>
|
||||
)}
|
||||
|
||||
@ -1350,13 +1394,20 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
aria-label={t('Afficher le maillage')}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
className="reset"
|
||||
variant="outline"
|
||||
type="button"
|
||||
onClick={() => transitionToExpression({ ...defaultExpression })}
|
||||
>
|
||||
{t('Réinitialiser')}
|
||||
</Button>
|
||||
</InspectorCard>
|
||||
</ControlSection>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!editorPageOpen && mode === 'avatars' && <AvatarPage controller={controller} />}
|
||||
|
||||
{!sequenceEditing && !editing && bodyEditing && (
|
||||
<footer className="workspace-footer">
|
||||
@ -1422,6 +1473,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
openExpressionEditor(index, preset)
|
||||
setDeleteExpressionOpen(true)
|
||||
}}
|
||||
runtimeError={expressionSemanticKeyError(preset)}
|
||||
draggable
|
||||
onDragStart={event => {
|
||||
expressionDragOrigin.current = expressions
|
||||
@ -1563,6 +1615,16 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
renderStyle={activeAvatar.renderStyle}
|
||||
id={`state-card-${sequence.id}`}
|
||||
/>
|
||||
{animationSemanticKeyError(sequence) && (
|
||||
<i
|
||||
className="runtime-key-missing"
|
||||
role="img"
|
||||
aria-label={animationSemanticKeyError(sequence) ?? undefined}
|
||||
title={animationSemanticKeyError(sequence) ?? undefined}
|
||||
>
|
||||
!
|
||||
</i>
|
||||
)}
|
||||
<span>{sequence.builtIn ? t(sequence.name) : sequence.name}</span>
|
||||
</Button>
|
||||
)
|
||||
@ -1630,11 +1692,168 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
)}
|
||||
|
||||
{!sequenceEditing && !editing && !bodyEditing && mode === 'export' && (
|
||||
<Accordion className="export-panel" defaultValue={['snapshot']}>
|
||||
<Accordion className="export-panel" defaultValue={['runtime']}>
|
||||
<ExportSection
|
||||
value="runtime"
|
||||
title="Exporter le JSON runtime"
|
||||
subtitle="Exporte le fichier .avatar.json utilisé par les nouveaux packages npm."
|
||||
badge="Nouveau"
|
||||
>
|
||||
<InspectorCard>
|
||||
<div className="export-avatar-summary">
|
||||
<ExpressionPreview
|
||||
expression={expressions[0] ?? defaultExpression}
|
||||
surface={activeAvatar.body.primary}
|
||||
bodyNodes={activeAvatar.body.nodes}
|
||||
colors={activeAvatar.colors}
|
||||
avatarEyes={activeAvatarEyes}
|
||||
id={`runtime-avatar-${activeAvatar.id}`}
|
||||
/>
|
||||
<div>
|
||||
<small>{t('Définition runtime')}</small>
|
||||
<strong>{activeAvatar.name}</strong>
|
||||
<span>{t('Fichier .avatar.json portable')}</span>
|
||||
</div>
|
||||
</div>
|
||||
</InspectorCard>
|
||||
|
||||
<InspectorCard
|
||||
className="runtime-readiness"
|
||||
data-ready={runtimeDefinitionResult.ok || undefined}
|
||||
>
|
||||
<div className="runtime-readiness-heading">
|
||||
{runtimeDefinitionResult.ok ? <CircleCheck /> : <TriangleAlert />}
|
||||
<div>
|
||||
<strong>
|
||||
{t(
|
||||
runtimeDefinitionResult.ok
|
||||
? 'Prêt pour l’export runtime'
|
||||
: 'Export runtime incomplet'
|
||||
)}
|
||||
</strong>
|
||||
{runtimeDefinitionResult.ok && runtimeStandardAvailability && (
|
||||
<small>
|
||||
{runtimeStandardAvailability.available.length}/6{' '}
|
||||
{t('animations standard disponibles')}
|
||||
</small>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{runtimeExportErrors.length > 0 && (
|
||||
<>
|
||||
<ul className="runtime-error-list" role="alert">
|
||||
{runtimeExportErrors.map((error, index) => (
|
||||
<li key={`${index}-${error}`}>{error}</li>
|
||||
))}
|
||||
</ul>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
onClick={clearLocalStudioDocument}
|
||||
>
|
||||
<Trash2 />
|
||||
{t('Effacer le projet local et recharger')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</InspectorCard>
|
||||
|
||||
<InspectorCard className="runtime-quickstart">
|
||||
<PanelTitle
|
||||
title="Démarrage rapide npm"
|
||||
subtitle="Ces commandes fonctionneront après la publication des packages actuellement privés."
|
||||
/>
|
||||
<div className="runtime-code-example">
|
||||
<small>{t('Installation')}</small>
|
||||
<pre tabIndex={0}>
|
||||
<code>{highlightedCode(runtimeInstallExample)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<div className="runtime-code-example">
|
||||
<small>{t('Exemple React minimal')}</small>
|
||||
<pre tabIndex={0}>
|
||||
<code>{highlightedCode(runtimeReactExample)}</code>
|
||||
</pre>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
aria-expanded={runtimeExampleOpen}
|
||||
onClick={() => setRuntimeExampleOpen(open => !open)}
|
||||
>
|
||||
<Play />
|
||||
{t(runtimeExampleOpen ? 'Masquer l’aperçu' : 'Lancer l’exemple')}
|
||||
</Button>
|
||||
{runtimeExampleOpen && runtimeDefinitionResult.ok && (
|
||||
<div className="runtime-live-example">
|
||||
<div className="runtime-live-example-heading">
|
||||
<div>
|
||||
<small>{t('Aperçu avec le package React')}</small>
|
||||
<strong>defaultAnimation="idle"</strong>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => runtimeExampleRef.current?.play('idle')}
|
||||
>
|
||||
<RotateCcw />
|
||||
{t('Relancer')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="runtime-live-example-stage">
|
||||
<RuntimeAvatar
|
||||
ref={runtimeExampleRef}
|
||||
definition={runtimeDefinitionResult.value}
|
||||
defaultAnimation="idle"
|
||||
size={150}
|
||||
ariaLabel={t('Aperçu runtime de l’avatar actif')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</InspectorCard>
|
||||
|
||||
<div className="runtime-export-actions">
|
||||
<Button
|
||||
className="export-download"
|
||||
type="button"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
onClick={downloadAvatarRuntimeDefinition}
|
||||
>
|
||||
<Download />
|
||||
{t('Télécharger la définition .avatar.json')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
disabled={!runtimeDefinitionResult.ok}
|
||||
onClick={() => void copyAvatarRuntimeDefinition()}
|
||||
>
|
||||
<Copy />
|
||||
{t('Copier le JSON formaté')}
|
||||
</Button>
|
||||
</div>
|
||||
{runtimeCopyStatus !== 'idle' && (
|
||||
<p
|
||||
className="runtime-copy-status"
|
||||
role={runtimeCopyStatus === 'error' ? 'alert' : 'status'}
|
||||
aria-live={runtimeCopyStatus === 'error' ? 'assertive' : 'polite'}
|
||||
>
|
||||
{t(
|
||||
runtimeCopyStatus === 'success'
|
||||
? 'JSON runtime copié dans le presse-papiers.'
|
||||
: 'Impossible de copier le JSON runtime.'
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</ExportSection>
|
||||
|
||||
<ExportSection
|
||||
value="avatar"
|
||||
title="Exporter l’avatar"
|
||||
subtitle="Télécharge un composant autonome avec les animations de ton choix."
|
||||
subtitle="Génère l’export ZIP autonome React ou JavaScript qui existait déjà."
|
||||
>
|
||||
<InspectorCard>
|
||||
<div className="export-avatar-summary">
|
||||
@ -1924,44 +2143,79 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
{activeSequence && !editorPageOpen && (
|
||||
<motion.footer
|
||||
className={`state-playback-footer${statePlayerExpanded ? ' is-expanded' : ''}`}
|
||||
style={{ y: playbackFooterY }}
|
||||
initial={{ opacity: 0, y: 18 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="state-playback-drag-handle-slot">
|
||||
<motion.div style={{ y: playbackHandleCounterY }}>
|
||||
<motion.button
|
||||
className="state-playback-drag-handle"
|
||||
style={{ y: playbackHandleY }}
|
||||
<Button
|
||||
className="state-playback-expand"
|
||||
variant="outline"
|
||||
size="icon-sm"
|
||||
type="button"
|
||||
drag="y"
|
||||
dragMomentum={false}
|
||||
aria-expanded={statePlayerExpanded}
|
||||
aria-label={t(
|
||||
statePlayerExpanded
|
||||
? 'Masquer les détails de l’animation'
|
||||
: 'Afficher les détails de l’animation'
|
||||
)}
|
||||
onTap={() => snapPlaybackFooter(!statePlayerExpanded)}
|
||||
onDragStart={() => {
|
||||
playbackFooterDragOriginY.current = playbackFooterY.get()
|
||||
}}
|
||||
onDrag={(_, info) => {
|
||||
playbackFooterY.set(
|
||||
Math.min(
|
||||
playbackFooterCollapsedOffset,
|
||||
Math.max(0, playbackFooterDragOriginY.current + info.offset.y)
|
||||
)
|
||||
)
|
||||
}}
|
||||
onDragEnd={(_, info) => {
|
||||
playbackHandleY.set(0)
|
||||
const projectedY = playbackFooterY.get() + info.velocity.y * 0.16
|
||||
snapPlaybackFooter(projectedY < playbackFooterCollapsedOffset / 2)
|
||||
}}
|
||||
onClick={() => setStatePlayerExpanded(expanded => !expanded)}
|
||||
>
|
||||
<span />
|
||||
</motion.button>
|
||||
</motion.div>
|
||||
{statePlayerExpanded ? <ChevronDown /> : <ChevronUp />}
|
||||
</Button>
|
||||
{statePlayerExpanded && (
|
||||
<motion.div
|
||||
className="state-playback-details"
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
<div className="state-playback-details-header">
|
||||
<div>
|
||||
<p className="eyebrow">{t('Détails de l’animation')}</p>
|
||||
<h2>{activeSequenceLabel}</h2>
|
||||
<p>
|
||||
{activeSequence.builtIn
|
||||
? t(activeSequence.description)
|
||||
: activeSequence.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="state-playback-detail-grid">
|
||||
<div>
|
||||
<span>{t('Expressions')}</span>
|
||||
<strong>{activeSequence.steps.length}</strong>
|
||||
<small>
|
||||
{activeSequence.steps
|
||||
.map(step => formatSeconds(step.holdMs, language))
|
||||
.join(' · ')}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('Premier clignement')}</span>
|
||||
<strong>
|
||||
{activeSequence.blink.enabled
|
||||
? formatSeconds(activeSequence.blink.initialDelayMs, language)
|
||||
: t('Désactivé')}
|
||||
</strong>
|
||||
<small>{t('après le lancement')}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('Intervalle du clignement')}</span>
|
||||
<strong>
|
||||
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
|
||||
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
|
||||
</strong>
|
||||
<small>{t('tirage aléatoire')}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('Durée du clignement')}</span>
|
||||
<strong>{activeSequence.blink.durationMs} ms</strong>
|
||||
<small>{t('fermeture et ouverture')}</small>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
<div className="state-playback-bar">
|
||||
<div className="state-playback-timeline">
|
||||
{activeSequence.steps.map((step, position) => {
|
||||
@ -2012,62 +2266,6 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<motion.div
|
||||
ref={playbackDetailsRef}
|
||||
className="state-playback-details-shell"
|
||||
style={{ opacity: playbackDetailsOpacity }}
|
||||
aria-hidden={!statePlayerExpanded}
|
||||
>
|
||||
<div className="state-playback-details">
|
||||
<div className="state-playback-details-header">
|
||||
<div>
|
||||
<p className="eyebrow">{t('Détails de l’animation')}</p>
|
||||
<h2>{activeSequenceLabel}</h2>
|
||||
<p>
|
||||
{activeSequence.builtIn
|
||||
? t(activeSequence.description)
|
||||
: activeSequence.description}
|
||||
</p>
|
||||
</div>
|
||||
<Badge variant="secondary">
|
||||
{t('Mode de lecture')} · {t(activeSequence.playbackMode)}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="state-playback-detail-grid">
|
||||
<div>
|
||||
<span>{t('Expressions')}</span>
|
||||
<strong>{activeSequence.steps.length}</strong>
|
||||
<small>
|
||||
{activeSequence.steps
|
||||
.map(step => formatSeconds(step.holdMs, language))
|
||||
.join(' · ')}
|
||||
</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('Premier clignement')}</span>
|
||||
<strong>
|
||||
{activeSequence.blink.enabled
|
||||
? formatSeconds(activeSequence.blink.initialDelayMs, language)
|
||||
: t('Désactivé')}
|
||||
</strong>
|
||||
<small>{t('après le lancement')}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('Intervalle du clignement')}</span>
|
||||
<strong>
|
||||
{formatSeconds(activeSequence.blink.minIntervalMs, language)}–
|
||||
{formatSeconds(activeSequence.blink.maxIntervalMs, language)}
|
||||
</strong>
|
||||
<small>{t('tirage aléatoire')}</small>
|
||||
</div>
|
||||
<div>
|
||||
<span>{t('Durée du clignement')}</span>
|
||||
<strong>{activeSequence.blink.durationMs} ms</strong>
|
||||
<small>{t('fermeture et ouverture')}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.footer>
|
||||
)}
|
||||
{!editorPageOpen && (
|
||||
@ -2076,9 +2274,9 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
className="mobile-mode-tab mobile-avatar-tab"
|
||||
variant="ghost"
|
||||
type="button"
|
||||
aria-pressed={mode === 'avatars'}
|
||||
aria-pressed={avatarDrawerOpen}
|
||||
aria-label={t('Choisir un avatar')}
|
||||
onClick={() => setMode('avatars')}
|
||||
onClick={() => setAvatarDrawerOpen(true)}
|
||||
>
|
||||
<ExpressionPreview
|
||||
expression={expressions[0] ?? defaultExpression}
|
||||
@ -2113,6 +2311,7 @@ export function StudioInspector({ controller }: { controller: StudioController }
|
||||
))}
|
||||
</nav>
|
||||
)}
|
||||
<AvatarDrawer controller={controller} onOpenChange={setAvatarDrawerOpen} />
|
||||
</main>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
@ -686,6 +686,7 @@
|
||||
"expressions": [
|
||||
{
|
||||
"id": "expression-00",
|
||||
"semanticKey": "upward-side-glance",
|
||||
"headX": 7.3,
|
||||
"headY": 27.8,
|
||||
"headZ": -16.1,
|
||||
@ -706,6 +707,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-01",
|
||||
"semanticKey": "downward-gaze",
|
||||
"headX": -15.057812500000004,
|
||||
"headY": 0.14296874999999964,
|
||||
"headZ": -14.549218750000001,
|
||||
@ -726,6 +728,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-05",
|
||||
"semanticKey": "skeptical-right",
|
||||
"headX": -16.528515625,
|
||||
"headY": -3.7679687499999996,
|
||||
"headZ": -13.7296875,
|
||||
@ -746,6 +749,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-06",
|
||||
"semanticKey": "small-attentive",
|
||||
"headX": -4.232421875000001,
|
||||
"headY": 14.362109375000003,
|
||||
"headZ": 11.204296875,
|
||||
@ -766,6 +770,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-12",
|
||||
"semanticKey": "wide-downward-gaze",
|
||||
"headX": -19.20859375,
|
||||
"headY": 15.2,
|
||||
"headZ": 11.8,
|
||||
@ -786,6 +791,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-03",
|
||||
"semanticKey": "surprised-left",
|
||||
"headX": 2.9468749999999986,
|
||||
"headY": -16.051171875,
|
||||
"headZ": -20.916015625,
|
||||
@ -806,6 +812,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-04",
|
||||
"semanticKey": "sleepy-squint",
|
||||
"headX": 3.4,
|
||||
"headY": 13.22578125,
|
||||
"headZ": 8.976953125,
|
||||
@ -826,6 +833,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-07",
|
||||
"semanticKey": "angry-right",
|
||||
"headX": 8.063671874999999,
|
||||
"headY": 17.626562500000002,
|
||||
"headZ": -11.116796874999999,
|
||||
@ -846,6 +854,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-08",
|
||||
"semanticKey": "curious-left",
|
||||
"headX": -12.303515625,
|
||||
"headY": -17.601171875,
|
||||
"headZ": 5.9109375,
|
||||
@ -866,6 +875,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-09",
|
||||
"semanticKey": "asymmetric-down-right",
|
||||
"headX": -20.058203125,
|
||||
"headY": 12.607421875,
|
||||
"headZ": -12.7,
|
||||
@ -886,6 +896,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-10",
|
||||
"semanticKey": "attentive-left",
|
||||
"headX": 1.43359375,
|
||||
"headY": 6.194140624999999,
|
||||
"headZ": 10.56015625,
|
||||
@ -906,6 +917,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-11",
|
||||
"semanticKey": "joyful-wide",
|
||||
"headX": -2.092968750000001,
|
||||
"headY": -15.899609374999999,
|
||||
"headZ": -14.469921875,
|
||||
@ -926,6 +938,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-13",
|
||||
"semanticKey": "eyes-closed",
|
||||
"headX": -8.752343750000001,
|
||||
"headY": -8.743359375,
|
||||
"headZ": -10.773828125000001,
|
||||
@ -946,6 +959,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-02",
|
||||
"semanticKey": "joyful-down-right",
|
||||
"headX": -15.287109375000002,
|
||||
"headY": 15.006640625,
|
||||
"headZ": 12.787890625,
|
||||
@ -966,6 +980,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-14",
|
||||
"semanticKey": "skeptical-left",
|
||||
"headX": 3.5292968750000004,
|
||||
"headY": -7.0765625,
|
||||
"headZ": 9.830078125,
|
||||
@ -986,6 +1001,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-15",
|
||||
"semanticKey": "far-right-glance",
|
||||
"headX": 0.31914062500000184,
|
||||
"headY": 35.307421874999996,
|
||||
"headZ": -10.904296875,
|
||||
@ -1006,6 +1022,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-16",
|
||||
"semanticKey": "angry-left",
|
||||
"headX": -14.750781250000001,
|
||||
"headY": -19.350000000000005,
|
||||
"headZ": 5.631640624999998,
|
||||
@ -1026,6 +1043,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-17",
|
||||
"semanticKey": "playful-right",
|
||||
"headX": -4.3953125,
|
||||
"headY": 14.07265625,
|
||||
"headZ": -16.126171874999997,
|
||||
@ -1046,6 +1064,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-18",
|
||||
"semanticKey": "asymmetric-up-left",
|
||||
"headX": 6.585546875,
|
||||
"headY": 4.737109375000001,
|
||||
"headZ": 12.840234374999998,
|
||||
@ -1066,6 +1085,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-19",
|
||||
"semanticKey": "gentle-downward-gaze",
|
||||
"headX": -6.077734375000001,
|
||||
"headY": -11.03515625,
|
||||
"headZ": -13.965625000000001,
|
||||
@ -1086,6 +1106,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-20",
|
||||
"semanticKey": "wide-down-left",
|
||||
"headX": -17.127734375000003,
|
||||
"headY": 18.070703124999998,
|
||||
"headZ": 13.891796875,
|
||||
@ -1106,6 +1127,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-21",
|
||||
"semanticKey": "surprised-wide-left",
|
||||
"headX": -5.428125,
|
||||
"headY": -11.71328125,
|
||||
"headZ": -13.472265625000002,
|
||||
@ -1126,6 +1148,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-22",
|
||||
"semanticKey": "drowsy-closed",
|
||||
"headX": 10.292578125,
|
||||
"headY": 3.39921875,
|
||||
"headZ": 7.583203125,
|
||||
@ -1146,6 +1169,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-23",
|
||||
"semanticKey": "suspicious-right",
|
||||
"headX": -17.8,
|
||||
"headY": 10,
|
||||
"headZ": -10.894921875,
|
||||
@ -1166,6 +1190,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-24",
|
||||
"semanticKey": "shy-downward",
|
||||
"headX": 7.131640624999998,
|
||||
"headY": 7.7828124999999995,
|
||||
"headZ": 3.935546874999999,
|
||||
@ -1186,6 +1211,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-3d2bed26-f97c-477d-922f-77600cb10e92",
|
||||
"semanticKey": "angry-brows",
|
||||
"headX": 10.473974503042374,
|
||||
"headY": 5.087293619785961,
|
||||
"headZ": 4.698252132317348,
|
||||
@ -1208,6 +1234,7 @@
|
||||
},
|
||||
{
|
||||
"id": "expression-5220eaee-32fe-4bd8-ad31-432189534cc8",
|
||||
"semanticKey": "uneasy-left",
|
||||
"headX": -12.303515625,
|
||||
"headY": -17.601171875,
|
||||
"headZ": 5.9109375,
|
||||
@ -1231,6 +1258,7 @@
|
||||
"sequences": [
|
||||
{
|
||||
"id": "sleeping",
|
||||
"semanticKey": "sleeping",
|
||||
"name": "sleeping",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Yeux presque fermés, respiration lente et expression de sommeil.",
|
||||
@ -1269,6 +1297,7 @@
|
||||
},
|
||||
{
|
||||
"id": "waking",
|
||||
"semanticKey": "waking",
|
||||
"name": "waking",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Séquence courte de réveil avant retour vers une expression neutre.",
|
||||
@ -1293,6 +1322,7 @@
|
||||
},
|
||||
{
|
||||
"id": "idle",
|
||||
"semanticKey": "idle",
|
||||
"name": "idle",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Micro-mouvements lents, expressions 00 et 08, clignement rare.",
|
||||
@ -1324,6 +1354,7 @@
|
||||
},
|
||||
{
|
||||
"id": "listening",
|
||||
"semanticKey": "listening",
|
||||
"name": "listening",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Expressions 10, 01 et 19, regard stable et clignement attentif.",
|
||||
@ -1362,6 +1393,7 @@
|
||||
},
|
||||
{
|
||||
"id": "thinking",
|
||||
"semanticKey": "thinking",
|
||||
"name": "thinking",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Regard haut et latéral, expressions asymétriques et changements fréquents.",
|
||||
@ -1414,6 +1446,7 @@
|
||||
},
|
||||
{
|
||||
"id": "searching",
|
||||
"semanticKey": "searching",
|
||||
"name": "searching",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Balayage rapide et changements très fréquents.",
|
||||
@ -1473,6 +1506,7 @@
|
||||
},
|
||||
{
|
||||
"id": "working",
|
||||
"semanticKey": "working",
|
||||
"name": "working",
|
||||
"group": "Cycle de vie",
|
||||
"description": "Rythme régulier et expressions concentrées.",
|
||||
@ -1518,6 +1552,7 @@
|
||||
},
|
||||
{
|
||||
"id": "excited",
|
||||
"semanticKey": "excited",
|
||||
"name": "excited",
|
||||
"group": "Réactions",
|
||||
"description": "Grandes expressions et transitions rapides.",
|
||||
@ -1570,6 +1605,7 @@
|
||||
},
|
||||
{
|
||||
"id": "bored",
|
||||
"semanticKey": "bored",
|
||||
"name": "bored",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1608,6 +1644,7 @@
|
||||
},
|
||||
{
|
||||
"id": "suspicious",
|
||||
"semanticKey": "suspicious",
|
||||
"name": "suspicious",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1646,6 +1683,7 @@
|
||||
},
|
||||
{
|
||||
"id": "angry",
|
||||
"semanticKey": "angry",
|
||||
"name": "angry",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1677,6 +1715,7 @@
|
||||
},
|
||||
{
|
||||
"id": "drowsy",
|
||||
"semanticKey": "drowsy",
|
||||
"name": "drowsy",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1715,6 +1754,7 @@
|
||||
},
|
||||
{
|
||||
"id": "happy",
|
||||
"semanticKey": "happy",
|
||||
"name": "happy",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1760,6 +1800,7 @@
|
||||
},
|
||||
{
|
||||
"id": "curious",
|
||||
"semanticKey": "curious",
|
||||
"name": "curious",
|
||||
"group": "Réactions",
|
||||
"description": "Inclinaisons et forte asymétrie.",
|
||||
@ -1805,6 +1846,7 @@
|
||||
},
|
||||
{
|
||||
"id": "confused",
|
||||
"semanticKey": "confused",
|
||||
"name": "confused",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1843,6 +1885,7 @@
|
||||
},
|
||||
{
|
||||
"id": "surprised",
|
||||
"semanticKey": "surprised",
|
||||
"name": "surprised",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1874,6 +1917,7 @@
|
||||
},
|
||||
{
|
||||
"id": "proud",
|
||||
"semanticKey": "proud",
|
||||
"name": "proud",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1912,6 +1956,7 @@
|
||||
},
|
||||
{
|
||||
"id": "shy",
|
||||
"semanticKey": "shy",
|
||||
"name": "shy",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1950,6 +1995,7 @@
|
||||
},
|
||||
{
|
||||
"id": "sad",
|
||||
"semanticKey": "sad",
|
||||
"name": "sad",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -1988,6 +2034,7 @@
|
||||
},
|
||||
{
|
||||
"id": "laughing",
|
||||
"semanticKey": "laughing",
|
||||
"name": "laughing",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -2026,6 +2073,7 @@
|
||||
},
|
||||
{
|
||||
"id": "scared",
|
||||
"semanticKey": "scared",
|
||||
"name": "scared",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -2057,6 +2105,7 @@
|
||||
},
|
||||
{
|
||||
"id": "playful",
|
||||
"semanticKey": "playful",
|
||||
"name": "playful",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
@ -2102,6 +2151,7 @@
|
||||
},
|
||||
{
|
||||
"id": "celebrate",
|
||||
"semanticKey": "celebrate",
|
||||
"name": "celebrate",
|
||||
"group": "Réactions",
|
||||
"description": "Cet état enchaîne un pool de presets et des clignements.",
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import {
|
||||
parseAvatarLibrary,
|
||||
parseExpressions,
|
||||
restoreLegacyBehaviorSemanticKeys,
|
||||
type AvatarBehaviorLibrary,
|
||||
type AvatarLibrary,
|
||||
} from '../avatar/avatars'
|
||||
@ -49,13 +50,16 @@ export const parseStudioDocument = (value: unknown, fallback: StudioDocument): S
|
||||
const sequences = Array.isArray(candidate.sequences)
|
||||
? normalizeSequencesForExpressions(parseSequences(candidate.sequences), expressions)
|
||||
: fallback.sequences
|
||||
const baseBehavior: AvatarBehaviorLibrary = { expressions, sequences }
|
||||
const baseBehavior = restoreLegacyBehaviorSemanticKeys(
|
||||
{ expressions, sequences },
|
||||
{ expressions: fallback.expressions, sequences: fallback.sequences }
|
||||
)
|
||||
const library = parseAvatarLibrary(candidate.library, fallback.library, baseBehavior)
|
||||
return {
|
||||
version: 2,
|
||||
library,
|
||||
expressions,
|
||||
sequences,
|
||||
expressions: baseBehavior.expressions,
|
||||
sequences: baseBehavior.sequences,
|
||||
playback: parsePlayback(candidate.playback, fallback.playback),
|
||||
}
|
||||
}
|
||||
@ -119,6 +123,17 @@ export const persistStudioDocument = (document: StudioDocument) => {
|
||||
}
|
||||
}
|
||||
|
||||
export const clearPersistedStudioDocument = (
|
||||
storage: Pick<Storage, 'removeItem'> = window.localStorage
|
||||
) => {
|
||||
try {
|
||||
storage.removeItem(DOCUMENT_STORAGE_KEY)
|
||||
return true
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export const createStudioDocumentStore = (
|
||||
initial: StudioDocument,
|
||||
persist: (document: StudioDocument) => void = persistStudioDocument
|
||||
|
||||
@ -45,9 +45,17 @@ import {
|
||||
applyAmbientBodyMotion,
|
||||
hasAmbientMotion,
|
||||
} from '@/features/avatar/ambientMotion'
|
||||
import {
|
||||
avatarDefinitionFileName,
|
||||
createAvatarDefinition,
|
||||
getSemanticKeyIssue,
|
||||
getStandardAnimationAvailabilityV1,
|
||||
type SemanticKeyIssueCode,
|
||||
} from '@/features/avatar/avatarDefinition'
|
||||
import {
|
||||
cloneAvatarBehavior,
|
||||
createAvatar,
|
||||
createUnkeyedExpressionCopy,
|
||||
defaultAvatarEyes,
|
||||
resolveAvatarBehavior,
|
||||
type AvatarBehaviorLibrary,
|
||||
@ -106,6 +114,7 @@ import {
|
||||
} from '@/features/rendering/renderedScene'
|
||||
import { paintPixelAvatar } from '@/features/rendering/pixelRenderer'
|
||||
import {
|
||||
clearPersistedStudioDocument,
|
||||
createStudioDocumentStore,
|
||||
loadStudioDocument,
|
||||
parseImportedStudioDocument,
|
||||
@ -147,6 +156,7 @@ export function useStudioController() {
|
||||
const [snapshotSize, setSnapshotSize] = useState('1024')
|
||||
const [snapshotFormat, setSnapshotFormat] = useState<SnapshotFormat>('png')
|
||||
const [photoFlash, setPhotoFlash] = useState(0)
|
||||
const [runtimeCopyStatus, setRuntimeCopyStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const initialStatePlayback = initialDocument.playback
|
||||
const updateStudioLibrary = (library: typeof initialDocument.library) =>
|
||||
documentStore.update({ library })
|
||||
@ -1192,7 +1202,7 @@ export function useStudioController() {
|
||||
}
|
||||
|
||||
const duplicateExpression = (_index: number | null, draft: Expression, editDuplicate = false) => {
|
||||
const duplicate = { ...draft, id: createExpressionId() }
|
||||
const duplicate = createUnkeyedExpressionCopy(draft, createExpressionId())
|
||||
const next = [...expressions, duplicate]
|
||||
const duplicateIndex = next.length - 1
|
||||
setExpressions(next)
|
||||
@ -1281,7 +1291,8 @@ export function useStudioController() {
|
||||
setMode('expressions')
|
||||
setEditing({
|
||||
index,
|
||||
draft: { ...draft, id: index === null ? createExpressionId() : draft.id },
|
||||
draft:
|
||||
index === null ? createUnkeyedExpressionCopy(draft, createExpressionId()) : { ...draft },
|
||||
})
|
||||
const avatar = avatarsRef.current.find(item => item.id === activeAvatarIdRef.current)
|
||||
if (avatar) setDisplayColors(resolveColors(draft, avatar.colors))
|
||||
@ -1478,6 +1489,79 @@ export function useStudioController() {
|
||||
: activeSequence.name
|
||||
: null
|
||||
const expressionById = new Map(expressions.map(item => [item.id, item]))
|
||||
const semanticKeyIssueMessage = (issue: SemanticKeyIssueCode | 'duplicate_semantic_key') =>
|
||||
t(
|
||||
issue === 'missing_semantic_key'
|
||||
? 'Ajoute une clé pour inclure cet élément dans l’export runtime.'
|
||||
: issue === 'invalid_semantic_key'
|
||||
? 'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.'
|
||||
: issue === 'reserved_semantic_key'
|
||||
? 'neutral est réservé à l’apparence neutre de l’avatar.'
|
||||
: 'Cette clé est déjà utilisée dans cette bibliothèque.'
|
||||
)
|
||||
const expressionSemanticKeyError = (draft: Expression) => {
|
||||
const issue = getSemanticKeyIssue(draft.semanticKey, 'expression')
|
||||
if (issue) return semanticKeyIssueMessage(issue)
|
||||
if (
|
||||
expressions.some(
|
||||
expression => expression.id !== draft.id && expression.semanticKey === draft.semanticKey
|
||||
)
|
||||
) {
|
||||
return semanticKeyIssueMessage('duplicate_semantic_key')
|
||||
}
|
||||
return null
|
||||
}
|
||||
const animationSemanticKeyError = (draft: AvatarSequence) => {
|
||||
const issue = getSemanticKeyIssue(draft.semanticKey, 'animation')
|
||||
if (issue) return semanticKeyIssueMessage(issue)
|
||||
if (
|
||||
sequences.some(
|
||||
sequence => sequence.id !== draft.id && sequence.semanticKey === draft.semanticKey
|
||||
)
|
||||
) {
|
||||
return semanticKeyIssueMessage('duplicate_semantic_key')
|
||||
}
|
||||
return null
|
||||
}
|
||||
const runtimeDefinitionResult = createAvatarDefinition({
|
||||
avatar: activeAvatar,
|
||||
behavior: { expressions, sequences },
|
||||
})
|
||||
const runtimeExportErrors = runtimeDefinitionResult.ok
|
||||
? []
|
||||
: [
|
||||
...new Set(
|
||||
runtimeDefinitionResult.errors
|
||||
.filter(
|
||||
error =>
|
||||
error.code !== 'unresolved_expression_reference' ||
|
||||
!runtimeDefinitionResult.errors.some(candidate =>
|
||||
candidate.path.startsWith('/studio/expressions/')
|
||||
)
|
||||
)
|
||||
.map(error => {
|
||||
const expressionMatch = error.path.match(/^\/studio\/expressions\/(\d+)/)
|
||||
if (expressionMatch) {
|
||||
const index = Number(expressionMatch[1])
|
||||
const item = expressions[index]
|
||||
return `${t('Expression')} ${item?.semanticKey || String(index).padStart(2, '0')}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
|
||||
}
|
||||
const animationMatch = error.path.match(/^\/studio\/animations\/(\d+)/)
|
||||
if (animationMatch) {
|
||||
const index = Number(animationMatch[1])
|
||||
const item = sequences[index]
|
||||
if (error.code === 'unresolved_expression_reference') {
|
||||
return `${t('Animation')} ${item?.name ?? index}: ${t('Une étape référence une expression qui ne peut pas être exportée.')}`
|
||||
}
|
||||
return `${t('Animation')} ${item?.semanticKey || item?.name || index}: ${semanticKeyIssueMessage(error.code as SemanticKeyIssueCode | 'duplicate_semantic_key')}`
|
||||
}
|
||||
return `${t('Valeur incompatible avec le format runtime')} (${error.path || '/'})`
|
||||
})
|
||||
),
|
||||
]
|
||||
const runtimeStandardAvailability = runtimeDefinitionResult.ok
|
||||
? getStandardAnimationAvailabilityV1(runtimeDefinitionResult.value.expressions)
|
||||
: null
|
||||
const exportAnimationIdSet = new Set(exportAnimationIds)
|
||||
const selectedExportAnimations = sequences.filter(animation =>
|
||||
exportAnimationIdSet.has(animation.id)
|
||||
@ -1499,6 +1583,28 @@ export function useStudioController() {
|
||||
: generateJavaScriptAvatarPackage(payload, language)
|
||||
downloadBlob(blob, avatarExportFileName(activeAvatar.name, extension))
|
||||
}
|
||||
const downloadAvatarRuntimeDefinition = () => {
|
||||
if (!runtimeDefinitionResult.ok) return
|
||||
downloadBlob(
|
||||
new Blob([JSON.stringify(runtimeDefinitionResult.value, null, 2)], {
|
||||
type: 'application/json;charset=utf-8',
|
||||
}),
|
||||
avatarDefinitionFileName(activeAvatar.name)
|
||||
)
|
||||
}
|
||||
const copyAvatarRuntimeDefinition = async () => {
|
||||
if (!runtimeDefinitionResult.ok) return
|
||||
try {
|
||||
if (!navigator.clipboard) throw new Error('Clipboard API unavailable')
|
||||
await navigator.clipboard.writeText(JSON.stringify(runtimeDefinitionResult.value, null, 2))
|
||||
setRuntimeCopyStatus('success')
|
||||
} catch {
|
||||
setRuntimeCopyStatus('error')
|
||||
}
|
||||
}
|
||||
const clearLocalStudioDocument = () => {
|
||||
if (clearPersistedStudioDocument()) window.location.reload()
|
||||
}
|
||||
const currentStudioDocument = (): StudioDocument => ({
|
||||
version: 2,
|
||||
library: { activeAvatarId, avatars },
|
||||
@ -1772,7 +1878,9 @@ export function useStudioController() {
|
||||
commitBodyNode,
|
||||
commitExpressionMove,
|
||||
commitStateMove,
|
||||
clearLocalStudioDocument,
|
||||
confirmStudioProjectImport,
|
||||
copyAvatarRuntimeDefinition,
|
||||
createNewAvatar,
|
||||
deleteActiveAvatar,
|
||||
deleteAvatarOpen,
|
||||
@ -1782,6 +1890,7 @@ export function useStudioController() {
|
||||
deleteSequenceEditing,
|
||||
deleteSequenceOpen,
|
||||
downloadAvatarExport,
|
||||
downloadAvatarRuntimeDefinition,
|
||||
downloadStudioProject,
|
||||
draggedAvatarId,
|
||||
draggedExpressionId,
|
||||
@ -1800,6 +1909,7 @@ export function useStudioController() {
|
||||
exportFormat,
|
||||
expression,
|
||||
expressionById,
|
||||
expressionSemanticKeyError,
|
||||
expressionDragOrigin,
|
||||
expressionDragPreview,
|
||||
expressions,
|
||||
@ -1832,6 +1942,10 @@ export function useStudioController() {
|
||||
renderedColors,
|
||||
renderedRotationGizmo,
|
||||
renderedScene,
|
||||
runtimeDefinitionResult,
|
||||
runtimeCopyStatus,
|
||||
runtimeExportErrors,
|
||||
runtimeStandardAvailability,
|
||||
saveAvatarEditing,
|
||||
saveEditing,
|
||||
saveSequenceEditing,
|
||||
@ -1843,6 +1957,7 @@ export function useStudioController() {
|
||||
selectedSequenceStepId,
|
||||
selectedState,
|
||||
sequenceEditing,
|
||||
animationSemanticKeyError,
|
||||
sequences,
|
||||
setDeleteAvatarOpen,
|
||||
setDeleteExpressionOpen,
|
||||
|
||||
@ -31,6 +31,50 @@ describe('avatar studio translations', () => {
|
||||
expect(translateStudioText('Format d’export', 'zh-CN')).toBe('导出格式')
|
||||
})
|
||||
|
||||
it('keeps runtime-export authoring copy synchronized in all three languages', () => {
|
||||
expect(translateStudioText('Clé sémantique', 'fr')).toBe('Clé sémantique')
|
||||
expect(translateStudioText('Clé sémantique', 'en')).toBe('Semantic key')
|
||||
expect(translateStudioText('Clé sémantique', 'zh-CN')).toBe('语义键')
|
||||
expect(translateStudioText('Exporter le JSON runtime', 'en')).toBe('Export runtime JSON')
|
||||
expect(translateStudioText('Exporter le JSON runtime', 'zh-CN')).toBe('导出运行时 JSON')
|
||||
expect(translateStudioText('Nouveau', 'en')).toBe('New')
|
||||
expect(translateStudioText('Nouveau', 'zh-CN')).toBe('新增')
|
||||
expect(translateStudioText('Démarrage rapide npm', 'en')).toBe('npm quick start')
|
||||
expect(translateStudioText('Démarrage rapide npm', 'zh-CN')).toBe('npm 快速开始')
|
||||
expect(translateStudioText('Lancer l’exemple', 'en')).toBe('Run example')
|
||||
expect(translateStudioText('Lancer l’exemple', 'zh-CN')).toBe('运行示例')
|
||||
expect(
|
||||
translateStudioText(
|
||||
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.',
|
||||
'en'
|
||||
)
|
||||
).toBe('Export the .avatar.json file used by the new npm packages.')
|
||||
expect(
|
||||
translateStudioText(
|
||||
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.',
|
||||
'zh-CN'
|
||||
)
|
||||
).toBe('生成原有的 React 或 JavaScript 独立 ZIP 导出。')
|
||||
expect(translateStudioText('Export runtime incomplet', 'en')).toBe(
|
||||
'Runtime export is incomplete'
|
||||
)
|
||||
expect(translateStudioText('Export runtime incomplet', 'zh-CN')).toBe('运行时导出不完整')
|
||||
expect(translateStudioText('Effacer le projet local et recharger', 'en')).toBe(
|
||||
'Clear local project and reload'
|
||||
)
|
||||
expect(translateStudioText('Effacer le projet local et recharger', 'zh-CN')).toBe(
|
||||
'清除本地项目并重新加载'
|
||||
)
|
||||
expect(translateStudioText('Copier le JSON formaté', 'en')).toBe('Copy formatted JSON')
|
||||
expect(translateStudioText('Copier le JSON formaté', 'zh-CN')).toBe('复制格式化的 JSON')
|
||||
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'en')).toBe(
|
||||
'Runtime JSON copied to the clipboard.'
|
||||
)
|
||||
expect(translateStudioText('JSON runtime copié dans le presse-papiers.', 'zh-CN')).toBe(
|
||||
'运行时 JSON 已复制到剪贴板。'
|
||||
)
|
||||
})
|
||||
|
||||
it('covers every configured state description in English', () => {
|
||||
expect(translateStudioText('Rythme régulier et expressions concentrées.', 'en')).toBe(
|
||||
'Steady rhythm and focused expressions.'
|
||||
|
||||
@ -61,6 +61,23 @@ const english: Record<string, string> = {
|
||||
'Retour au studio': 'Back to studio',
|
||||
'Preset en mémoire': 'Saved preset',
|
||||
'Nouvelle expression': 'New expression',
|
||||
'Identité runtime': 'Runtime identity',
|
||||
'Nom public stable utilisé par les applications qui chargent cet avatar.':
|
||||
'Stable public name used by applications that load this avatar.',
|
||||
'Clé sémantique': 'Semantic key',
|
||||
'Clé sémantique manquante': 'Missing semantic key',
|
||||
'Clé publique stable utilisée par l’API runtime, par exemple happy-smile.':
|
||||
'Stable public key used by the runtime API, for example happy-smile.',
|
||||
'Clé publique stable utilisée par l’API runtime, par exemple thinking.':
|
||||
'Stable public key used by the runtime API, for example thinking.',
|
||||
'Ajoute une clé pour inclure cet élément dans l’export runtime.':
|
||||
'Add a key to include this item in the runtime export.',
|
||||
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
|
||||
'Use lowercase letters, numbers, and hyphens, for example happy-smile.',
|
||||
'neutral est réservé à l’apparence neutre de l’avatar.':
|
||||
'neutral is reserved for the avatar neutral appearance.',
|
||||
'Cette clé est déjà utilisée dans cette bibliothèque.':
|
||||
'This key is already used in this library.',
|
||||
'L’avatar à gauche affiche cette expression en direct.':
|
||||
'The avatar on the left previews this expression live.',
|
||||
Corps: 'Body',
|
||||
@ -108,8 +125,40 @@ const english: Record<string, string> = {
|
||||
Animations: 'Animations',
|
||||
Exporter: 'Export',
|
||||
'Exporter l’avatar': 'Export avatar',
|
||||
'Exporter le JSON runtime': 'Export runtime JSON',
|
||||
Nouveau: 'New',
|
||||
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
|
||||
'Export the .avatar.json file used by the new npm packages.',
|
||||
'Télécharge la définition portable complète de l’avatar actif.':
|
||||
'Download the complete portable definition for the active avatar.',
|
||||
'Définition runtime': 'Runtime definition',
|
||||
'Fichier .avatar.json portable': 'Portable .avatar.json file',
|
||||
'Prêt pour l’export runtime': 'Ready for runtime export',
|
||||
'Export runtime incomplet': 'Runtime export is incomplete',
|
||||
'Effacer le projet local et recharger': 'Clear local project and reload',
|
||||
'animations standard disponibles': 'standard animations available',
|
||||
'Télécharger la définition .avatar.json': 'Download .avatar.json definition',
|
||||
'Copier le JSON formaté': 'Copy formatted JSON',
|
||||
'JSON runtime copié dans le presse-papiers.': 'Runtime JSON copied to the clipboard.',
|
||||
'Impossible de copier le JSON runtime.': 'Could not copy the runtime JSON.',
|
||||
'Démarrage rapide npm': 'npm quick start',
|
||||
'Ces commandes fonctionneront après la publication des packages actuellement privés.':
|
||||
'These commands will work after the currently private packages are published.',
|
||||
Installation: 'Installation',
|
||||
'Exemple React minimal': 'Minimal React example',
|
||||
'Lancer l’exemple': 'Run example',
|
||||
'Masquer l’aperçu': 'Hide preview',
|
||||
'Aperçu avec le package React': 'Preview using the React package',
|
||||
'Aperçu runtime de l’avatar actif': 'Runtime preview of the active avatar',
|
||||
Expression: 'Expression',
|
||||
Animation: 'Animation',
|
||||
'Une étape référence une expression qui ne peut pas être exportée.':
|
||||
'A step references an expression that cannot be exported.',
|
||||
'Valeur incompatible avec le format runtime': 'Value incompatible with the runtime format',
|
||||
'Télécharge un composant autonome avec les animations de ton choix.':
|
||||
'Download a standalone component with the animations you choose.',
|
||||
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
|
||||
'Generate the existing standalone React or JavaScript ZIP export.',
|
||||
'Avatar sélectionné': 'Selected avatar',
|
||||
Format: 'Format',
|
||||
'Choisis l’intégration correspondant à ton projet.':
|
||||
|
||||
@ -34,6 +34,21 @@ export const chinese: Record<string, string> = {
|
||||
'Retour au studio': '返回工作室',
|
||||
'Preset en mémoire': '已保存预设',
|
||||
'Nouvelle expression': '新建表情',
|
||||
'Identité runtime': '运行时标识',
|
||||
'Nom public stable utilisé par les applications qui chargent cet avatar.':
|
||||
'供加载此头像的应用使用的稳定公开名称。',
|
||||
'Clé sémantique': '语义键',
|
||||
'Clé sémantique manquante': '缺少语义键',
|
||||
'Clé publique stable utilisée par l’API runtime, par exemple happy-smile.':
|
||||
'运行时 API 使用的稳定公开键,例如 happy-smile。',
|
||||
'Clé publique stable utilisée par l’API runtime, par exemple thinking.':
|
||||
'运行时 API 使用的稳定公开键,例如 thinking。',
|
||||
'Ajoute une clé pour inclure cet élément dans l’export runtime.':
|
||||
'添加键以在运行时导出中包含此项目。',
|
||||
'Utilise des lettres minuscules, des chiffres et des tirets, par exemple happy-smile.':
|
||||
'请使用小写字母、数字和连字符,例如 happy-smile。',
|
||||
'neutral est réservé à l’apparence neutre de l’avatar.': 'neutral 保留用于头像的中性外观。',
|
||||
'Cette clé est déjà utilisée dans cette bibliothèque.': '此键已在当前库中使用。',
|
||||
'L’avatar à gauche affiche cette expression en direct.': '左侧头像会实时预览此表情。',
|
||||
Corps: '身体',
|
||||
Rendu: '渲染',
|
||||
@ -80,8 +95,39 @@ export const chinese: Record<string, string> = {
|
||||
Exporter: '导出',
|
||||
Export: '导出',
|
||||
'Exporter l’avatar': '导出头像',
|
||||
'Exporter le JSON runtime': '导出运行时 JSON',
|
||||
Nouveau: '新增',
|
||||
'Exporte le fichier .avatar.json utilisé par les nouveaux packages npm.':
|
||||
'导出新 npm 包使用的 .avatar.json 文件。',
|
||||
'Télécharge la définition portable complète de l’avatar actif.': '下载当前头像的完整可移植定义。',
|
||||
'Définition runtime': '运行时定义',
|
||||
'Fichier .avatar.json portable': '可移植 .avatar.json 文件',
|
||||
'Prêt pour l’export runtime': '可以导出运行时定义',
|
||||
'Export runtime incomplet': '运行时导出不完整',
|
||||
'Effacer le projet local et recharger': '清除本地项目并重新加载',
|
||||
'animations standard disponibles': '个标准动画可用',
|
||||
'Télécharger la définition .avatar.json': '下载 .avatar.json 定义',
|
||||
'Copier le JSON formaté': '复制格式化的 JSON',
|
||||
'JSON runtime copié dans le presse-papiers.': '运行时 JSON 已复制到剪贴板。',
|
||||
'Impossible de copier le JSON runtime.': '无法复制运行时 JSON。',
|
||||
'Démarrage rapide npm': 'npm 快速开始',
|
||||
'Ces commandes fonctionneront après la publication des packages actuellement privés.':
|
||||
'这些命令将在当前私有包发布后可用。',
|
||||
Installation: '安装',
|
||||
'Exemple React minimal': '最简 React 示例',
|
||||
'Lancer l’exemple': '运行示例',
|
||||
'Masquer l’aperçu': '隐藏预览',
|
||||
'Aperçu avec le package React': '使用 React 包预览',
|
||||
'Aperçu runtime de l’avatar actif': '当前头像的运行时预览',
|
||||
Expression: '表情',
|
||||
Animation: '动画',
|
||||
'Une étape référence une expression qui ne peut pas être exportée.':
|
||||
'某个步骤引用了无法导出的表情。',
|
||||
'Valeur incompatible avec le format runtime': '值与运行时格式不兼容',
|
||||
'Télécharge un composant autonome avec les animations de ton choix.':
|
||||
'下载包含所选动画的独立组件。',
|
||||
'Génère l’export ZIP autonome React ou JavaScript qui existait déjà.':
|
||||
'生成原有的 React 或 JavaScript 独立 ZIP 导出。',
|
||||
'Avatar sélectionné': '已选头像',
|
||||
Format: '格式',
|
||||
'Choisis l’intégration correspondant à ton projet.': '选择适合项目的集成方式。',
|
||||
|
||||
@ -12,7 +12,10 @@
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
"@/*": ["./src/*"],
|
||||
"@bible-strong/avatar-core": ["./packages/avatar-core/src/index.ts"],
|
||||
"@bible-strong/avatar-core/*": ["./packages/avatar-core/src/*"],
|
||||
"@bible-strong/avatar-react": ["./packages/avatar-react/src/index.ts"]
|
||||
},
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
@ -20,5 +23,5 @@
|
||||
"jsx": "react-jsx",
|
||||
"types": ["vite/client", "vitest/globals"]
|
||||
},
|
||||
"include": ["src", "vite.config.ts", "vitest.config.ts"]
|
||||
"include": ["src", "packages/*/src", "vite.config.ts", "vitest.config.ts"]
|
||||
}
|
||||
|
||||
@ -15,10 +15,16 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(root, 'src'),
|
||||
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
|
||||
'@bible-strong/avatar-react': path.join(root, 'packages/avatar-react/src/index.ts'),
|
||||
},
|
||||
},
|
||||
build: {
|
||||
outDir: path.join(root, 'dist'),
|
||||
emptyOutDir: true,
|
||||
chunkSizeWarningLimit: 1_100,
|
||||
rolldownOptions: {
|
||||
checks: { pluginTimings: false },
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
@ -9,11 +9,12 @@ export default defineConfig({
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.join(root, 'src'),
|
||||
'@bible-strong/avatar-core': path.join(root, 'packages/avatar-core/src/index.ts'),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
environment: 'node',
|
||||
globals: true,
|
||||
include: ['src/**/__tests__/**/*-test.ts'],
|
||||
include: ['src/**/__tests__/**/*-test.{ts,tsx}', 'packages/**/__tests__/**/*-test.{ts,tsx}'],
|
||||
},
|
||||
})
|
||||
|
||||
Loading…
Reference in New Issue
Block a user